This commit is contained in:
admin
2026-04-11 20:46:55 +08:00
parent e6c2d76238
commit e04405d0bc
70 changed files with 10438 additions and 332 deletions

1630
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"axios": "^1.13.1",
@@ -19,7 +21,10 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/test-utils": "^2.4.6",
"jsdom": "^26.1.0",
"sass": "^1.98.0",
"vite": "^7.1.5"
"vite": "^7.1.5",
"vitest": "^3.2.4"
}
}

View File

@@ -1,40 +1,37 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="tile" x1="16" y1="18" x2="48" y2="50" gradientUnits="userSpaceOnUse">
<stop stop-color="#E8FBFF"/>
<stop offset="1" stop-color="#BCEFFA"/>
</linearGradient>
<linearGradient id="needle" x1="34" y1="17" x2="47" y2="31" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFF8D9"/>
<stop offset="1" stop-color="#F4FCFF"/>
</linearGradient>
</defs>
<g filter="url(#shadow)">
<rect x="15" y="17" width="14" height="12" rx="4" fill="url(#tile)" stroke="#0A8FB5" stroke-width="2"/>
<rect x="15" y="33" width="14" height="16" rx="4" fill="url(#tile)" stroke="#0A8FB5" stroke-width="2"/>
<rect x="33" y="35" width="15" height="14" rx="4" fill="url(#tile)" fill-opacity="0.92" stroke="#0A8FB5" stroke-width="2"/>
<svg
xmlns="http://www.w3.org/2000/svg"
width="100"
height="100"
viewBox="0 0 48 48"
fill="none"
role="img"
aria-label="Code tools logo"
>
<g fill="#1e293b" font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace" font-size="30" font-weight="300">
<text x="7" y="24" text-anchor="start" dominant-baseline="middle">{</text>
<text x="41" y="24" text-anchor="end" dominant-baseline="middle">}</text>
</g>
<path d="M35.2 17.4L49.2 22.4L38.2 33.3L35.6 27.2L29.6 24.6L35.2 17.4Z" fill="url(#needle)"/>
<path d="M35.2 17.4L40.3 27.1L49.2 22.4" stroke="#74DFF2" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
<g transform="translate(24 24)">
<g transform="rotate(-45) scale(0.75) translate(-12 -12)">
<path
d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"
fill="#1e293b"
stroke="#1e293b"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
</g>
<path d="M18.5 22.2H25.5" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/>
<path d="M18.5 37.8H25.5" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/>
<path d="M18.5 42.8H24" stroke="#0C7D9E" stroke-opacity="0.72" stroke-width="2" stroke-linecap="round"/>
<path d="M36.5 40.8H44.5" stroke="#0C7D9E" stroke-opacity="0.64" stroke-width="2" stroke-linecap="round"/>
<path d="M36.5 45.6H42.5" stroke="#0C7D9E" stroke-opacity="0.64" stroke-width="2" stroke-linecap="round"/>
<defs>
<filter id="shadow" x="11" y="14" width="41" height="39" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1.5"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.016 0 0 0 0 0.282 0 0 0 0 0.38 0 0 0 0.14 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1_1"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1_1" result="shape"/>
</filter>
</defs>
<g transform="translate(21 13)">
<g transform="scale(0.6666667)">
<circle cx="18" cy="5" r="3" stroke="#f97316" stroke-width="3" fill="none" />
<circle cx="6" cy="12" r="3" stroke="#f97316" stroke-width="3" fill="none" />
<circle cx="18" cy="19" r="3" stroke="#f97316" stroke-width="3" fill="none" />
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49" stroke="#f97316" stroke-width="3" stroke-linecap="round" />
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49" stroke="#f97316" stroke-width="3" stroke-linecap="round" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

146
client/src/App.spec.js Normal file
View File

@@ -0,0 +1,146 @@
import { flushPromises, mount } from '@vue/test-utils';
import { createMemoryHistory, createRouter } from 'vue-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import App from './App.vue';
const apiMocks = vi.hoisted(() => ({
fetchCategories: vi.fn(),
fetchHotKeywords: vi.fn(),
fetchOverview: vi.fn(),
fetchToolDetail: vi.fn(),
fetchTools: vi.fn(),
getApiErrorMessage: vi.fn((error) => error?.message || '请求失败'),
launchTool: vi.fn(),
notifyToolInteraction: vi.fn(),
resolveActionUrl: vi.fn((value) => value),
}));
vi.mock('./api', () => ({
fetchCategories: apiMocks.fetchCategories,
fetchHotKeywords: apiMocks.fetchHotKeywords,
fetchOverview: apiMocks.fetchOverview,
fetchToolDetail: apiMocks.fetchToolDetail,
fetchTools: apiMocks.fetchTools,
getApiErrorMessage: apiMocks.getApiErrorMessage,
launchTool: apiMocks.launchTool,
notifyToolInteraction: apiMocks.notifyToolInteraction,
resolveActionUrl: apiMocks.resolveActionUrl,
}));
function createRouterForTest() {
return createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/',
name: 'public-home',
component: App,
},
{
path: '/tools/:slug',
name: 'tool-detail',
component: { template: '<div>detail page</div>' },
},
],
});
}
describe('App', () => {
beforeEach(() => {
apiMocks.fetchCategories.mockResolvedValue([]);
apiMocks.fetchHotKeywords.mockResolvedValue([]);
apiMocks.fetchOverview.mockResolvedValue({
toolTotal: 1,
categoryTotal: 1,
downloadTotal: 0,
openTotal: 0,
});
apiMocks.fetchTools.mockResolvedValue({
list: [
{
id: 'tool_demo',
slug: 'demo-tool',
name: 'Demo Tool',
description: 'Simple tool',
category: { id: 'cat_demo', name: 'Developer Tools' },
tags: ['cli'],
latestVersion: '1.0.0',
updatedAt: '2026-04-11',
accessMode: 'web',
openUrl: 'https://example.com/tool',
downloadReady: true,
downloadCount: 0,
openCount: 3,
},
],
pagination: {
page: 1,
pageSize: 6,
total: 1,
totalPages: 1,
},
});
apiMocks.fetchToolDetail.mockResolvedValue({
id: 'tool_demo',
slug: 'demo-tool',
name: 'Demo Tool',
description: '# Demo Tool',
category: { id: 'cat_demo', name: 'Developer Tools' },
rating: 4.5,
downloadCount: 0,
openCount: 3,
accessMode: 'web',
tags: ['cli'],
features: [],
updatedAt: '2026-04-11',
openUrl: 'https://example.com/tool',
latestVersion: null,
fileSize: null,
downloadReady: true,
});
});
it('navigates detail actions to the slug route and does not render the old detail modal', async () => {
const router = createRouterForTest();
await router.push('/');
await router.isReady();
const wrapper = mount(App, {
global: {
plugins: [router],
},
});
await flushPromises();
expect(wrapper.find('#detailTitle').exists()).toBe(false);
await wrapper.get('button.btn-small').trigger('click');
await flushPromises();
expect(router.currentRoute.value.fullPath).toBe('/tools/demo-tool');
});
it('keeps the overview modal behavior available', async () => {
const router = createRouterForTest();
await router.push('/');
await router.isReady();
const wrapper = mount(App, {
global: {
plugins: [router],
},
});
await flushPromises();
expect(wrapper.findAll('.modal-backdrop.open')).toHaveLength(0);
await wrapper.get('button.nav-btn').trigger('click');
await flushPromises();
expect(wrapper.findAll('.modal-backdrop.open')).toHaveLength(1);
expect(wrapper.get('button.nav-btn').attributes('aria-expanded')).toBe('true');
});
});

View File

@@ -4,7 +4,7 @@
<div class="container header">
<a class="brand" href="#" aria-label="Tools工具" @click.prevent>
<span class="brand-mark" aria-hidden="true">
<img src="/favicon.svg" alt="" width="32" height="32" />
<img src="/favicon.svg" alt="" />
</span>
<span>资源导航</span>
</a>
@@ -18,6 +18,7 @@
:aria-expanded="overviewModalOpen ? 'true' : 'false'"
@click="openOverviewModal"
>
<AppIcon name="chartLine" :size="16" />
站点概览
</button>
</nav>
@@ -30,10 +31,7 @@
<div class="search-row">
<label class="search-box" for="searchInput">
<span class="sr-only">搜索工具</span>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.8" />
<path d="M16.5 16.5L21 21" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
<AppIcon name="search" :size="18" />
<input
id="searchInput"
v-model="filters.query"
@@ -74,7 +72,12 @@
<section id="tools">
<div class="tools-layout">
<aside class="category-sidebar" aria-label="分类导航">
<h2 class="sidebar-title">分类导航</h2>
<h2 class="sidebar-title">
<span class="title-with-icon">
<AppIcon name="stack" :size="18" />
分类导航
</span>
</h2>
<p class="sidebar-tip">点击分类可快速筛选工具</p>
<div class="category-sidebar-list">
<button
@@ -94,7 +97,10 @@
<div class="tools-main">
<div class="toolbar">
<p class="result-tip">{{ resultTip }}</p>
<p class="result-tip">
<AppIcon name="layoutGrid" :size="16" />
{{ resultTip }}
</p>
<label class="sr-only" for="sortSelect">排序方式</label>
<select id="sortSelect" v-model="filters.sortBy" class="select" @change="onSortChange">
<option value="created">按创建时间排序</option>
@@ -141,23 +147,39 @@
</div>
<ul class="meta-list">
<li>版本<strong>{{ tool.latestVersion || '暂无版本' }}</strong></li>
<li>更新时间<strong>{{ formatDate(tool.updatedAt) }}</strong></li>
<li>
<span class="meta-label">
<AppIcon name="sparkles" :size="15" />
版本
</span>
<strong>{{ tool.latestVersion || '暂无版本' }}</strong>
</li>
<li>
<span class="meta-label">
<AppIcon name="calendar" :size="15" />
更新时间
</span>
<strong>{{ formatDate(tool.updatedAt) }}</strong>
</li>
</ul>
<div class="card-foot">
<span class="download-num">
<AppIcon :name="actionIconName(tool.accessMode)" :size="15" />
{{ toolModeSummary(tool) }}
</span>
<div class="actions">
<button type="button" class="btn-small" @click="openDetailModal(tool.id)">详情</button>
<button type="button" class="btn-small" @click="openDetailPage(tool.slug)">
详情
</button>
<button
type="button"
class="btn-small"
class="btn-small btn-with-icon"
:class="tool.accessMode === 'web' ? 'btn-open' : 'btn-download'"
:disabled="isLaunchDisabled(tool)"
@click="triggerLaunch(tool)"
>
<AppIcon :name="actionIconName(tool.accessMode)" :size="15" />
{{ launchButtonText(tool) }}
</button>
</div>
@@ -189,66 +211,13 @@
</section>
</main>
<div class="modal-backdrop" :class="{ open: detailModalOpen }" role="dialog" aria-modal="true" aria-labelledby="detailTitle" @click.self="closeDetailModal">
<div class="modal">
<div class="modal-head">
<h2 id="detailTitle">工具详情</h2>
<button type="button" class="icon-btn" aria-label="关闭详情" @click="closeDetailModal">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
</svg>
</button>
</div>
<template v-if="detailLoading">
<p class="modal-muted">正在加载工具详情...</p>
</template>
<template v-else-if="detailError">
<p class="modal-error">{{ detailError }}</p>
</template>
<template v-else-if="detail">
<div class="markdown markdown-detail" v-html="renderMarkdown(detail.description)"></div>
<ul class="meta-list">
<li>分类<strong>{{ detail.category?.name || '-' }}</strong></li>
<li>评分<strong>{{ Number(detail.rating || 0).toFixed(1) }}</strong></li>
<li>访问方式<strong>{{ detail.accessMode === 'web' ? '网页打开' : '下载安装' }}</strong></li>
<li v-if="detail.accessMode === 'download'">
下载次数<strong>{{ formatNumber(detail.downloadCount) }}</strong>
</li>
<li v-else>
访问次数<strong>{{ formatNumber(detail.openCount) }}</strong>
</li>
<li v-if="detail.accessMode === 'download'">
最新版本<strong>{{ detail.latestVersion || '暂无版本' }}</strong>
</li>
<li v-if="detail.accessMode === 'download' && detail.fileSize !== null">
文件大小<strong>{{ formatFileSize(detail.fileSize) }}</strong>
</li>
<li v-if="detail.accessMode === 'download' && detail.openUrl">
下载地址
<a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a>
</li>
<li v-if="detail.accessMode === 'web' && detail.openUrl">
打开地址
<a class="inline-link" :href="detail.openUrl" target="_blank" rel="noopener noreferrer">{{ detail.openUrl }}</a>
</li>
<li>更新时间<strong>{{ formatDate(detail.updatedAt) }}</strong></li>
</ul>
<h3>核心能力</h3>
<ul v-if="detail.features?.length" class="feature-list">
<li v-for="(feature, featureIndex) in detail.features" :key="`detail-${featureIndex}`">
<div class="markdown markdown-inline" v-html="renderInlineMarkdown(feature)"></div>
</li>
</ul>
<p v-else class="modal-muted">暂无能力描述</p>
</template>
</div>
</div>
<div class="modal-backdrop" :class="{ open: overviewModalOpen }" role="dialog" aria-modal="true" aria-labelledby="overviewTitle" @click.self="closeOverviewModal">
<div class="modal">
<div class="modal-head">
<h2 id="overviewTitle">站点概览</h2>
<h2 id="overviewTitle" class="title-with-icon">
<AppIcon name="chartLine" :size="18" />
站点概览
</h2>
<button type="button" class="icon-btn" aria-label="关闭站点概览" @click="closeOverviewModal">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
@@ -257,16 +226,55 @@
</div>
<p>展示站当前统计信息与核心能力说明</p>
<div class="kpi-grid">
<div><strong>{{ formatNumber(overview.toolTotal) }}</strong><span>工具总数</span></div>
<div><strong>{{ formatNumber(overview.categoryTotal) }}</strong><span>分类数量</span></div>
<div><strong>{{ formatNumber(overview.downloadTotal) }}</strong><span>累计下载</span></div>
<div><strong>{{ formatNumber(overview.openTotal) }}</strong><span>累计访问</span></div>
<div><strong>{{ formatNumber(pagination.total) }}</strong><span>当前结果</span></div>
<div>
<span class="kpi-label">
<AppIcon name="layoutGrid" :size="16" />
工具总数
</span>
<strong>{{ formatNumber(overview.toolTotal) }}</strong>
</div>
<div>
<span class="kpi-label">
<AppIcon name="stack" :size="16" />
分类数量
</span>
<strong>{{ formatNumber(overview.categoryTotal) }}</strong>
</div>
<div>
<span class="kpi-label">
<AppIcon name="download" :size="16" />
累计下载
</span>
<strong>{{ formatNumber(overview.downloadTotal) }}</strong>
</div>
<div>
<span class="kpi-label">
<AppIcon name="externalLink" :size="16" />
累计访问
</span>
<strong>{{ formatNumber(overview.openTotal) }}</strong>
</div>
<div>
<span class="kpi-label">
<AppIcon name="search" :size="16" />
当前结果
</span>
<strong>{{ formatNumber(pagination.total) }}</strong>
</div>
</div>
<ul class="tips">
<li>浏览分页展示工具卡片</li>
<li>搜索关键词 + 分类 + 排序组合筛选</li>
<li>获取统一通过 launch 接口完成网页打开或下载</li>
<li>
<AppIcon name="layoutGrid" :size="16" />
浏览分页展示工具卡片
</li>
<li>
<AppIcon name="search" :size="16" />
搜索关键词 + 分类 + 排序组合筛选
</li>
<li>
<AppIcon name="externalLink" :size="16" />
获取统一通过 launch 接口完成网页打开或下载
</li>
</ul>
</div>
</div>
@@ -279,21 +287,23 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import AppIcon from './components/AppIcon.vue';
import {
fetchCategories,
fetchHotKeywords,
fetchOverview,
fetchToolDetail,
fetchTools,
getApiErrorMessage,
launchTool,
notifyToolInteraction,
resolveActionUrl,
} from './api';
import { renderInlineMarkdown, renderMarkdown } from './utils/markdown';
import { renderInlineMarkdown } from './utils/markdown';
const CLIENT_VERSION = 'web-1.0.0';
const QUERY_DEBOUNCE_MS = 320;
const router = useRouter();
const filters = reactive({
query: '',
@@ -325,11 +335,6 @@ const loadingTools = ref(false);
const loadingMeta = ref(false);
const launchingId = ref('');
const detailModalOpen = ref(false);
const detailLoading = ref(false);
const detailError = ref('');
const detail = ref(null);
const overviewModalOpen = ref(false);
const isScrolled = ref(false);
@@ -385,23 +390,6 @@ function formatDate(dateText) {
}).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 resolveCategoryCount(item) {
if (item.id === 'all') {
return pagination.total;
@@ -543,24 +531,11 @@ function changePage(nextPage) {
loadTools();
}
async function openDetailModal(toolId) {
detailModalOpen.value = true;
detailLoading.value = true;
detailError.value = '';
detail.value = null;
try {
const data = await fetchToolDetail(toolId);
detail.value = data;
} catch (error) {
detailError.value = getApiErrorMessage(error);
} finally {
detailLoading.value = false;
}
}
function closeDetailModal() {
detailModalOpen.value = false;
function openDetailPage(slug) {
router.push({
name: 'tool-detail',
params: { slug },
});
}
function openOverviewModal() {
@@ -592,6 +567,10 @@ function launchButtonText(tool) {
return '下载';
}
function actionIconName(accessMode) {
return accessMode === 'download' ? 'download' : 'externalLink';
}
function toolModeSummary(tool) {
if (tool.accessMode === 'download') {
return `下载 ${formatNumber(tool.downloadCount)}`;
@@ -662,9 +641,6 @@ function handleKeydown(event) {
if (event.key !== 'Escape') {
return;
}
if (detailModalOpen.value) {
closeDetailModal();
}
if (overviewModalOpen.value) {
closeOverviewModal();
}

View File

@@ -355,7 +355,7 @@ const toolFormRules = {
],
description: [
{ required: true, message: '请输入工具简介', trigger: 'blur' },
{ min: 10, max: 2000, message: '工具简介长度为 10-2000 位', trigger: 'blur' },
{ min: 10, message: '工具简介长度至少为 10 位', trigger: 'blur' },
],
accessMode: [{ required: true, message: '请选择访问方式', trigger: 'change' }],
openUrl: [
@@ -1345,4 +1345,3 @@ onMounted(async () => {
}
});
</script>

View File

@@ -94,7 +94,6 @@
border-radius: 0;
display: grid;
place-items: center;
background: #2f83ed;
color: #fff;
font-size: 16px;
}

View File

@@ -54,12 +54,10 @@
<el-input
v-model="toolForm.description"
type="textarea"
:rows="4"
maxlength="2000"
show-word-limit
placeholder="支持 Markdown例如## 用途&#10;支持 **加粗**、`代码`、[链接](https://example.com)"
:rows="8"
placeholder="这里会作为访客端详情页的用户手册正文,支持 Markdown例如# 快速开始&#10;## 安装&#10;支持 **加粗**、`代码`、[链接](https://example.com)"
/>
<div class="el-form-item__description">简介支持 Markdown 渲染</div>
<div class="el-form-item__description">该内容会在访客端详情页按 Markdown 手册展示</div>
</el-form-item>
<el-form-item label="评分" prop="rating">
<el-input-number v-model="toolForm.rating" :min="0" :max="5" :step="0.1" :precision="1" />

View File

@@ -1,5 +1,6 @@
import { createRouter, createWebHistory } from 'vue-router';
import PublicApp from '../App.vue';
import ToolDetailPage from '../pages/ToolDetailPage.vue';
import AdminApp from './AdminApp.vue';
import AdminAuditLogsPage from './pages/AdminAuditLogsPage.vue';
import AdminCategoriesPage from './pages/AdminCategoriesPage.vue';
@@ -12,6 +13,11 @@ const routes = [
name: 'public-home',
component: PublicApp,
},
{
path: '/tools/:slug',
name: 'tool-detail',
component: ToolDetailPage,
},
{
path: '/admin',
component: AdminApp,

View File

@@ -36,6 +36,10 @@ export function fetchToolDetail(id) {
return apiGet(`/tools/${id}`);
}
export function fetchToolDetailBySlug(slug) {
return apiGet(`/tools/slug/${slug}`);
}
export function fetchCategories() {
return apiGet('/categories');
}

View File

@@ -0,0 +1,64 @@
<template>
<svg
v-if="icon"
class="app-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
:width="size"
:height="size"
:aria-hidden="decorative ? 'true' : undefined"
:aria-label="decorative ? undefined : title"
role="img"
>
<title v-if="!decorative && title">{{ title }}</title>
<path :d="icon" :stroke-width="strokeWidth" />
</svg>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
name: {
type: String,
required: true,
},
size: {
type: [Number, String],
default: 18,
},
strokeWidth: {
type: [Number, String],
default: 1.8,
},
decorative: {
type: Boolean,
default: true,
},
title: {
type: String,
default: '',
},
});
const ICON_PATHS = {
arrowLeft: 'M5 12h14M5 12l6 6m-6-6l6-6',
book: 'M3 19a9 9 0 0 1 9 0a9 9 0 0 1 9 0M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0M3 6v13m9-13v13m9-13v13',
calendar: 'M4 7a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2zm12-4v4M8 3v4m-4 4h16m-9 4h1m0 0v3',
chartLine: 'M4 19h16M4 15l4-6l4 2l4-5l4 4',
download: 'M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 11l5 5l5-5m-5-7v12',
externalLink: 'M12 6H6a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6m-7 1l9-9m-5 0h5v5',
layoutGrid:
'M4 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm10 0a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1zM4 15a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1zm10 0a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z',
search: 'M3 10a7 7 0 1 0 14 0a7 7 0 1 0-14 0m18 11l-6-6',
sparkles: 'M16 18a2 2 0 0 1 2 2a2 2 0 0 1 2-2a2 2 0 0 1-2-2a2 2 0 0 1-2 2m0-12a2 2 0 0 1 2 2a2 2 0 0 1 2-2a2 2 0 0 1-2-2a2 2 0 0 1-2 2M9 18a6 6 0 0 1 6-6a6 6 0 0 1-6-6a6 6 0 0 1-6 6a6 6 0 0 1 6 6',
stack: 'M12 4L4 8l8 4l8-4zm-8 8l8 4l8-4M4 16l8 4l8-4',
star: 'm12 17.75l-6.172 3.245l1.179-6.873l-5-4.867l6.9-1l3.086-6.253l3.086 6.253l6.9 1l-5 4.867l1.179 6.873z',
};
const icon = computed(() => ICON_PATHS[props.name] || '');
</script>

View File

@@ -0,0 +1,180 @@
import { flushPromises, mount } from '@vue/test-utils';
import { createMemoryHistory, createRouter } from 'vue-router';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import ToolDetailPage from './ToolDetailPage.vue';
const apiMocks = vi.hoisted(() => ({
fetchToolDetailBySlug: vi.fn(),
getApiErrorMessage: vi.fn((error) => error?.message || '请求失败'),
launchTool: vi.fn(),
notifyToolInteraction: vi.fn(),
resolveActionUrl: vi.fn((value) => value),
}));
vi.mock('../api', () => ({
fetchToolDetailBySlug: apiMocks.fetchToolDetailBySlug,
getApiErrorMessage: apiMocks.getApiErrorMessage,
launchTool: apiMocks.launchTool,
notifyToolInteraction: apiMocks.notifyToolInteraction,
resolveActionUrl: apiMocks.resolveActionUrl,
}));
function createDetail(overrides = {}) {
return {
id: 'tool_demo',
slug: 'demo-tool',
name: 'Demo Tool',
description: '# Demo Tool\n\n## Install',
category: { id: 'cat_demo', name: 'Developer Tools' },
rating: 4.6,
downloadCount: 12,
openCount: 34,
accessMode: 'web',
tags: ['cli'],
features: ['Fast setup'],
updatedAt: '2026-04-11',
openUrl: 'https://example.com/tool',
latestVersion: null,
fileSize: null,
downloadReady: true,
...overrides,
};
}
async function mountPage(path = '/tools/demo-tool') {
const router = createRouter({
history: createMemoryHistory(),
routes: [
{
path: '/',
name: 'public-home',
component: { template: '<div>home</div>' },
},
{
path: '/tools/:slug',
name: 'tool-detail',
component: ToolDetailPage,
},
],
});
await router.push(path);
await router.isReady();
const wrapper = mount(ToolDetailPage, {
global: {
plugins: [router],
},
});
return { router, wrapper };
}
describe('ToolDetailPage', () => {
beforeEach(() => {
apiMocks.fetchToolDetailBySlug.mockReset();
apiMocks.getApiErrorMessage.mockClear();
apiMocks.launchTool.mockReset();
apiMocks.notifyToolInteraction.mockReset();
apiMocks.resolveActionUrl.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('loads detail data by slug and renders the page', async () => {
let resolveRequest;
apiMocks.fetchToolDetailBySlug.mockReturnValue(
new Promise((resolve) => {
resolveRequest = resolve;
}),
);
const { wrapper } = await mountPage();
expect(wrapper.text()).toContain('正在加载用户手册');
resolveRequest(createDetail());
await flushPromises();
expect(apiMocks.fetchToolDetailBySlug).toHaveBeenCalledWith('demo-tool');
expect(wrapper.text()).toContain('Demo Tool');
expect(wrapper.text()).toContain('Developer Tools');
expect(wrapper.text()).toContain('打开网页');
});
it('shows a not found state for unknown slugs', async () => {
apiMocks.fetchToolDetailBySlug.mockRejectedValue({
response: { status: 404 },
message: 'not found',
});
const { wrapper } = await mountPage('/tools/missing-tool');
await flushPromises();
expect(wrapper.text()).toContain('手册不存在或已下线');
expect(wrapper.text()).toContain('返回工具列表');
});
it('shows an empty manual state when description is blank', async () => {
apiMocks.fetchToolDetailBySlug.mockResolvedValue(
createDetail({
description: ' ',
}),
);
const { wrapper } = await mountPage();
await flushPromises();
expect(wrapper.text()).toContain('暂未提供用户手册');
expect(wrapper.text()).toContain('Demo Tool');
expect(wrapper.text()).toContain('打开网页');
});
it('renders the download action label for downloadable tools', async () => {
apiMocks.fetchToolDetailBySlug.mockResolvedValue(
createDetail({
accessMode: 'download',
latestVersion: '2.0.0',
fileSize: 4096,
}),
);
const { wrapper } = await mountPage();
await flushPromises();
expect(wrapper.text()).toContain('下载');
expect(wrapper.text()).toContain('2.0.0');
});
it('renders a table of contents for markdown headings', async () => {
apiMocks.fetchToolDetailBySlug.mockResolvedValue(
createDetail({
description: '# Overview\n\n## Install\n\n## Usage',
}),
);
const { wrapper } = await mountPage();
await flushPromises();
expect(wrapper.text()).toContain('目录');
expect(wrapper.find('.detail-outline a[href="#overview"]').exists()).toBe(true);
expect(wrapper.find('.detail-outline a[href="#install"]').exists()).toBe(true);
});
it('hides the table of contents when the manual has no headings', async () => {
apiMocks.fetchToolDetailBySlug.mockResolvedValue(
createDetail({
description: 'Plain manual without headings.',
}),
);
const { wrapper } = await mountPage();
await flushPromises();
expect(wrapper.find('.detail-outline').exists()).toBe(false);
expect(wrapper.text()).toContain('Plain manual without headings.');
});
});

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

View File

@@ -65,6 +65,10 @@ select {
color: inherit;
}
.app-icon {
flex-shrink: 0;
}
.container {
width: min(1200px, calc(100% - 32px));
margin: 0 auto;
@@ -106,8 +110,8 @@ select {
}
.brand-mark {
width: 32px;
height: 32px;
width: 50px;
height: 50px;
display: block;
}
@@ -140,6 +144,12 @@ select {
border-color var(--duration-fast) var(--ease-standard);
}
.nav-btn {
display: inline-flex;
align-items: center;
gap: 8px;
}
.nav a:hover,
.nav-btn:hover {
background: rgba(233, 249, 255, 0.86);
@@ -196,6 +206,10 @@ h3 {
padding: 0 12px;
}
.search-box .app-icon {
color: var(--primary-strong);
}
.search-box input {
border: none;
outline: none;
@@ -283,13 +297,20 @@ h3 {
.tips {
margin: 12px 0 0;
padding-left: 18px;
padding-left: 0;
list-style: none;
color: var(--muted);
display: grid;
gap: 8px;
font-size: 14px;
}
.tips li {
display: flex;
align-items: flex-start;
gap: 8px;
}
.toolbar {
margin-top: 0;
margin-bottom: 14px;
@@ -306,6 +327,12 @@ h3 {
font-size: 14px;
}
.result-tip {
display: inline-flex;
align-items: center;
gap: 8px;
}
.tool-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
@@ -339,6 +366,12 @@ h3 {
font-size: 18px;
}
.title-with-icon {
display: inline-flex;
align-items: center;
gap: 8px;
}
.sidebar-tip {
margin: 6px 0 12px;
color: var(--muted);
@@ -571,6 +604,21 @@ h3 {
font-size: 13px;
}
.meta-list li {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.meta-label {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.meta-list strong {
color: var(--text);
}
@@ -580,6 +628,9 @@ h3 {
}
.download-num {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
}
@@ -602,6 +653,13 @@ h3 {
transform var(--duration-fast) var(--ease-standard);
}
.btn-with-icon {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-small:hover {
background: rgba(240, 251, 255, 0.95);
border-color: rgba(20, 143, 179, 0.3);
@@ -738,15 +796,258 @@ h3 {
.feature-list {
margin: 0;
padding-left: 18px;
padding-left: 0;
list-style: none;
display: grid;
gap: 6px;
}
.feature-list li {
display: flex;
align-items: flex-start;
gap: 10px;
color: var(--muted);
}
.tool-detail-shell {
min-height: 100vh;
}
.detail-back-link {
border: none;
background: transparent;
cursor: pointer;
padding: 0;
display: inline-flex;
align-items: center;
}
.detail-main-content {
padding: 22px 0 40px;
display: grid;
gap: 16px;
}
.detail-state-card,
.detail-overview-card,
.detail-manual-card {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: linear-gradient(160deg, rgba(255, 255, 255, 0.94), rgba(248, 253, 255, 0.86));
box-shadow: var(--shadow-soft);
}
.detail-state-card {
padding: 28px;
text-align: center;
color: var(--muted);
}
.detail-state-card h1 {
margin-bottom: 10px;
}
.detail-state-actions {
display: flex;
justify-content: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 16px;
}
.detail-overview-card {
overflow: hidden;
}
.detail-hero-section {
padding: 24px;
display: flex;
justify-content: space-between;
gap: 20px;
align-items: flex-start;
}
.detail-hero-copy {
display: grid;
gap: 8px;
min-width: 0;
}
.detail-category-label {
margin: 0;
font-size: 13px;
color: #0c6f8d;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.detail-hero-section h1 {
font-size: clamp(28px, 4vw, 42px);
line-height: 1.05;
}
.detail-summary-text,
.detail-manual-tip {
margin: 0;
color: var(--muted);
}
.detail-summary-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.detail-inline-tags {
gap: 8px;
align-items: center;
}
.detail-inline-tags .tag {
white-space: nowrap;
}
.detail-hero-meta {
margin-top: 2px;
}
.detail-hero-actions {
min-width: 220px;
display: grid;
gap: 10px;
}
.detail-inline-error {
margin: 0;
color: #b91c1c;
font-size: 13px;
}
.detail-summary-section {
padding: 20px 24px;
display: grid;
gap: 14px;
border-top: 1px solid rgba(18, 117, 150, 0.14);
background: rgba(248, 253, 255, 0.68);
}
.detail-feature-block {
display: grid;
gap: 8px;
}
.detail-feature-block h2,
.detail-manual-card h2 {
font-size: 20px;
}
.detail-manual-card {
padding: 24px;
display: grid;
gap: 16px;
}
.detail-manual-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.detail-back-top {
font-size: 13px;
color: var(--primary-strong);
}
.detail-empty-manual {
margin: 0;
color: var(--muted);
}
.detail-manual-layout {
display: grid;
grid-template-columns: minmax(180px, 220px) minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.detail-outline {
position: sticky;
top: 86px;
border: 1px solid rgba(18, 117, 150, 0.16);
border-radius: var(--radius-md);
background: rgba(241, 251, 255, 0.88);
padding: 14px;
display: grid;
gap: 8px;
}
.detail-outline p {
margin: 0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #0c6f8d;
}
.detail-outline-link {
display: block;
color: var(--muted);
font-size: 14px;
line-height: 1.4;
}
.detail-outline-link.level-2 {
padding-left: 10px;
}
.detail-outline-link.level-3,
.detail-outline-link.level-4,
.detail-outline-link.level-5,
.detail-outline-link.level-6 {
padding-left: 20px;
font-size: 13px;
}
.detail-manual-body {
min-width: 0;
max-width: 780px;
padding: 4px 0;
}
.detail-manual-body.markdown-detail {
margin: 0;
}
.detail-manual-body :where(h1, h2, h3, h4, h5, h6) {
scroll-margin-top: 92px;
}
.detail-manual-body :where(h1) {
font-size: 30px;
margin-bottom: 16px;
}
.detail-manual-body :where(h2) {
font-size: 24px;
margin-top: 28px;
margin-bottom: 12px;
}
.detail-manual-body :where(h3) {
font-size: 20px;
margin-top: 22px;
margin-bottom: 10px;
}
.detail-manual-body :where(p, li) {
font-size: 15px;
line-height: 1.8;
}
.icon-btn {
width: 36px;
height: 36px;
@@ -785,6 +1086,16 @@ h3 {
border-radius: var(--radius-md);
background: rgba(248, 253, 255, 0.92);
padding: 12px;
display: grid;
gap: 10px;
}
.kpi-label {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 13px;
}
.kpi-grid strong {
@@ -793,11 +1104,6 @@ h3 {
line-height: 1;
}
.kpi-grid span {
color: var(--muted);
font-size: 13px;
}
.toast {
position: fixed;
bottom: 16px;
@@ -893,6 +1199,21 @@ select:focus-visible {
.btn {
width: 100%;
}
.detail-hero-section,
.detail-manual-header,
.detail-manual-layout {
display: grid;
grid-template-columns: 1fr;
}
.detail-hero-actions {
min-width: 0;
}
.detail-outline {
position: static;
}
}
@media (max-width: 1024px) {

13
client/src/test/setup.js Normal file
View File

@@ -0,0 +1,13 @@
import { afterEach, vi } from 'vitest';
const noop = vi.fn();
window.scrollTo = noop;
if (!Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = noop;
}
afterEach(() => {
vi.clearAllMocks();
});

View File

@@ -0,0 +1,81 @@
import { marked } from 'marked';
function readInlineText(tokens = []) {
return tokens
.map((token) => {
if (Array.isArray(token.tokens) && token.tokens.length > 0) {
return readInlineText(token.tokens);
}
if (typeof token.text === 'string') {
return token.text;
}
if (typeof token.raw === 'string') {
return token.raw;
}
return '';
})
.join('');
}
export function slugifyHeading(value) {
const normalized = String(value ?? '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
.replace(/[^\p{Letter}\p{Number}]+/gu, '-')
.replace(/^-+|-+$/g, '');
return normalized || 'section';
}
export function extractMarkdownOutline(markdown) {
const source = String(markdown ?? '').trim();
if (!source) {
return [];
}
// Preserve deterministic heading anchors even when titles repeat in the same manual.
const duplicateCounts = new Map();
return marked
.lexer(source)
.filter((token) => token.type === 'heading')
.map((token) => {
const text = readInlineText(token.tokens).trim() || token.text?.trim() || 'Untitled';
const baseId = slugifyHeading(text);
const count = (duplicateCounts.get(baseId) ?? 0) + 1;
duplicateCounts.set(baseId, count);
return {
id: count === 1 ? baseId : `${baseId}-${count}`,
level: token.depth,
text,
};
});
}
export function injectOutlineAnchors(html, outline = []) {
if (!html || !outline.length || typeof DOMParser === 'undefined') {
return html;
}
const parser = new DOMParser();
const document = parser.parseFromString(`<div data-outline-root>${html}</div>`, 'text/html');
const container = document.querySelector('[data-outline-root]');
if (!container) {
return html;
}
const headings = container.querySelectorAll('h1, h2, h3, h4, h5, h6');
headings.forEach((heading, index) => {
if (outline[index]) {
heading.id = outline[index].id;
}
});
return container.innerHTML;
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { extractMarkdownOutline, injectOutlineAnchors } from './markdown-outline';
describe('extractMarkdownOutline', () => {
it('extracts headings with stable ids', () => {
const markdown = `
# Overview
## Install
### Step One
`;
expect(extractMarkdownOutline(markdown)).toEqual([
{ id: 'overview', level: 1, text: 'Overview' },
{ id: 'install', level: 2, text: 'Install' },
{ id: 'step-one', level: 3, text: 'Step One' },
]);
});
it('deduplicates repeated headings', () => {
const markdown = `
## Install
## Install
## Install
`;
expect(extractMarkdownOutline(markdown)).toEqual([
{ id: 'install', level: 2, text: 'Install' },
{ id: 'install-2', level: 2, text: 'Install' },
{ id: 'install-3', level: 2, text: 'Install' },
]);
});
it('returns an empty outline when no headings are present', () => {
expect(extractMarkdownOutline('Plain text only.')).toEqual([]);
});
it('injects heading ids into rendered html using the extracted outline', () => {
const outline = extractMarkdownOutline('# Overview\n\n## Install');
const html = '<h1>Overview</h1><h2>Install</h2>';
expect(injectOutlineAnchors(html, outline)).toContain('id="overview"');
expect(injectOutlineAnchors(html, outline)).toContain('id="install"');
});
});

11
client/vitest.config.js Normal file
View File

@@ -0,0 +1,11 @@
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.js'],
},
});