Files
tools-show/client/src/pages/ToolDetailPage.vue

313 lines
9.1 KiB
Vue
Raw Normal View History

2026-04-11 20:46:55 +08:00
<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>