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>
|