update
This commit is contained in:
312
client/src/pages/ToolDetailPage.vue
Normal file
312
client/src/pages/ToolDetailPage.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div id="tool-detail-top" class="tool-detail-shell">
|
||||
<header class="header-wrap is-scrolled">
|
||||
<div class="container header">
|
||||
<button type="button" class="brand detail-back-link" @click="goHome">
|
||||
<AppIcon name="arrowLeft" :size="18" />
|
||||
<span class="brand-mark" aria-hidden="true">
|
||||
<img src="/favicon.svg" alt="" width="32" height="32" />
|
||||
</span>
|
||||
<span>返回工具列表</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="container detail-main-content">
|
||||
<section v-if="loading" class="detail-state-card">
|
||||
<p>正在加载用户手册...</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="notFound" class="detail-state-card">
|
||||
<h1>手册不存在或已下线</h1>
|
||||
<p>当前链接无法找到可访问的工具详情。</p>
|
||||
<button type="button" class="btn btn-primary" @click="goHome">返回工具列表</button>
|
||||
</section>
|
||||
|
||||
<section v-else-if="errorMessage" class="detail-state-card">
|
||||
<h1>加载失败</h1>
|
||||
<p>{{ errorMessage }}</p>
|
||||
<div class="detail-state-actions">
|
||||
<button type="button" class="btn btn-primary" @click="loadDetail">重新加载</button>
|
||||
<button type="button" class="btn" @click="goHome">返回工具列表</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-else-if="detail">
|
||||
<section class="detail-overview-card">
|
||||
<div class="detail-hero-section">
|
||||
<div class="detail-hero-copy">
|
||||
<p class="detail-category-label">{{ detail.category?.name || '未分类' }}</p>
|
||||
<h1>{{ detail.name }}</h1>
|
||||
<div class="detail-summary-row">
|
||||
<p class="detail-summary-text">
|
||||
更新时间:{{ formatDate(detail.updatedAt) }} · {{ toolModeSummary(detail) }}
|
||||
</p>
|
||||
<div v-if="detail.tags?.length" class="tags detail-inline-tags">
|
||||
<span v-for="tag in detail.tags" :key="tag" class="tag">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-hero-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-with-icon"
|
||||
:disabled="isPrimaryActionDisabled"
|
||||
@click="triggerPrimaryAction"
|
||||
>
|
||||
<AppIcon :name="primaryActionIconName" :size="16" />
|
||||
{{ primaryActionLabel }}
|
||||
</button>
|
||||
<p v-if="launchError" class="detail-inline-error">{{ launchError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detail.features?.length" class="detail-summary-section">
|
||||
<div class="detail-feature-block">
|
||||
<h2 class="title-with-icon">
|
||||
<AppIcon name="sparkles" :size="18" />
|
||||
核心能力
|
||||
</h2>
|
||||
<ul class="feature-list">
|
||||
<li v-for="(feature, index) in detail.features" :key="`${detail.id}-${index}`">
|
||||
<AppIcon name="star" :size="15" />
|
||||
<div class="markdown markdown-inline" v-html="renderInlineMarkdown(feature)"></div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-manual-card">
|
||||
<div class="detail-manual-header">
|
||||
<div>
|
||||
<h2 class="title-with-icon">
|
||||
<AppIcon name="book" :size="18" />
|
||||
用户手册
|
||||
</h2>
|
||||
</div>
|
||||
<a v-if="hasManual" class="detail-back-top" href="#tool-detail-top">回到顶部</a>
|
||||
</div>
|
||||
<p v-if="!hasManual" class="detail-empty-manual">暂未提供用户手册</p>
|
||||
<div v-else class="detail-manual-layout">
|
||||
<aside v-if="outline.length" class="detail-outline">
|
||||
<p>目录</p>
|
||||
<a
|
||||
v-for="item in outline"
|
||||
:key="item.id"
|
||||
:href="`#${item.id}`"
|
||||
class="detail-outline-link"
|
||||
:class="`level-${item.level}`"
|
||||
>
|
||||
{{ item.text }}
|
||||
</a>
|
||||
</aside>
|
||||
<div
|
||||
class="detail-manual-body markdown markdown-detail"
|
||||
v-html="manualHtml"
|
||||
></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import AppIcon from '../components/AppIcon.vue';
|
||||
import {
|
||||
fetchToolDetailBySlug,
|
||||
getApiErrorMessage,
|
||||
launchTool,
|
||||
notifyToolInteraction,
|
||||
resolveActionUrl,
|
||||
} from '../api';
|
||||
import { renderInlineMarkdown, renderMarkdown } from '../utils/markdown';
|
||||
import { extractMarkdownOutline, injectOutlineAnchors } from '../utils/markdown-outline';
|
||||
|
||||
const CLIENT_VERSION = 'web-1.0.0';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const notFound = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const launchError = ref('');
|
||||
const detail = ref(null);
|
||||
const launching = ref(false);
|
||||
|
||||
const hasManual = computed(() => Boolean(detail.value?.description?.trim()));
|
||||
const outline = computed(() => (hasManual.value ? extractMarkdownOutline(detail.value.description) : []));
|
||||
const primaryActionIconName = computed(() => accessModeIconName(detail.value?.accessMode));
|
||||
const manualHtml = computed(() => {
|
||||
if (!hasManual.value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return injectOutlineAnchors(renderMarkdown(detail.value.description), outline.value);
|
||||
});
|
||||
|
||||
const primaryActionLabel = computed(() => {
|
||||
if (!detail.value) {
|
||||
return '';
|
||||
}
|
||||
if (launching.value) {
|
||||
return '处理中...';
|
||||
}
|
||||
if (detail.value.accessMode === 'web') {
|
||||
return '打开网页';
|
||||
}
|
||||
if (!detail.value.downloadReady) {
|
||||
return '暂无可下载资源';
|
||||
}
|
||||
return '下载';
|
||||
});
|
||||
|
||||
const isPrimaryActionDisabled = computed(() => {
|
||||
if (!detail.value || launching.value) {
|
||||
return true;
|
||||
}
|
||||
return detail.value.accessMode === 'download' && !detail.value.downloadReady;
|
||||
});
|
||||
|
||||
function formatNumber(value) {
|
||||
const numeric = Number(value);
|
||||
return new Intl.NumberFormat('zh-CN').format(Number.isFinite(numeric) ? numeric : 0);
|
||||
}
|
||||
|
||||
function formatDate(dateText) {
|
||||
if (!dateText) {
|
||||
return '-';
|
||||
}
|
||||
const date = new Date(dateText);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '-';
|
||||
}
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
const size = Number(bytes);
|
||||
if (!Number.isFinite(size) || size <= 0) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let value = size;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
|
||||
return `${value.toFixed(value >= 100 ? 0 : 1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function toolModeSummary(tool) {
|
||||
if (tool.accessMode === 'download') {
|
||||
return `下载 ${formatNumber(tool.downloadCount)}`;
|
||||
}
|
||||
return `访问 ${formatNumber(tool.openCount)}`;
|
||||
}
|
||||
|
||||
function accessModeIconName(accessMode) {
|
||||
return accessMode === 'download' ? 'download' : 'externalLink';
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.push({ name: 'public-home' });
|
||||
}
|
||||
|
||||
function isNotFoundError(error) {
|
||||
return Number(error?.response?.status) === 404;
|
||||
}
|
||||
|
||||
// Keep route-driven state in one loader so direct visits and in-app navigation behave the same.
|
||||
async function loadDetail() {
|
||||
const slug = String(route.params.slug ?? '').trim();
|
||||
loading.value = true;
|
||||
notFound.value = false;
|
||||
errorMessage.value = '';
|
||||
launchError.value = '';
|
||||
detail.value = null;
|
||||
|
||||
if (!slug) {
|
||||
loading.value = false;
|
||||
notFound.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
detail.value = await fetchToolDetailBySlug(slug);
|
||||
} catch (error) {
|
||||
if (isNotFoundError(error)) {
|
||||
notFound.value = true;
|
||||
} else {
|
||||
errorMessage.value = getApiErrorMessage(error);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerPrimaryAction() {
|
||||
if (!detail.value || isPrimaryActionDisabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
launching.value = true;
|
||||
launchError.value = '';
|
||||
|
||||
try {
|
||||
const result = await launchTool(detail.value.id, {
|
||||
channel: 'official',
|
||||
clientVersion: CLIENT_VERSION,
|
||||
});
|
||||
const isWebLaunch = result?.mode === 'web';
|
||||
const isDownloadLaunch = result?.mode === 'download';
|
||||
|
||||
if (isWebLaunch || isDownloadLaunch) {
|
||||
notifyToolInteraction(detail.value.id, {
|
||||
action: isWebLaunch ? 'open' : 'download',
|
||||
channel: 'official',
|
||||
clientVersion: CLIENT_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
const actionUrl = resolveActionUrl(result?.actionUrl);
|
||||
if (!actionUrl) {
|
||||
throw new Error('未获取到可执行地址');
|
||||
}
|
||||
|
||||
if ((isWebLaunch || isDownloadLaunch) && result.openIn === 'same_tab') {
|
||||
window.location.assign(actionUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
|
||||
if (!page) {
|
||||
window.location.assign(actionUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
launchError.value = getApiErrorMessage(error);
|
||||
} finally {
|
||||
launching.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.params.slug,
|
||||
() => {
|
||||
loadDetail();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
Reference in New Issue
Block a user