313 lines
9.1 KiB
Vue
313 lines
9.1 KiB
Vue
<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>
|