Files
tools-show/client/src/pages/ToolDetailPage.vue
2026-04-11 20:46:55 +08:00

313 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>