update
This commit is contained in:
1630
client/package-lock.json
generated
1630
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
146
client/src/App.spec.js
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
border-radius: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #2f83ed;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@@ -54,12 +54,10 @@
|
||||
<el-input
|
||||
v-model="toolForm.description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
placeholder="支持 Markdown,例如:## 用途 支持 **加粗**、`代码`、[链接](https://example.com)"
|
||||
:rows="8"
|
||||
placeholder="这里会作为访客端详情页的用户手册正文,支持 Markdown,例如:# 快速开始 ## 安装 支持 **加粗**、`代码`、[链接](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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
64
client/src/components/AppIcon.vue
Normal file
64
client/src/components/AppIcon.vue
Normal 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>
|
||||
180
client/src/pages/ToolDetailPage.spec.js
Normal file
180
client/src/pages/ToolDetailPage.spec.js
Normal 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.');
|
||||
});
|
||||
});
|
||||
312
client/src/pages/ToolDetailPage.vue
Normal file
312
client/src/pages/ToolDetailPage.vue
Normal 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>
|
||||
@@ -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
13
client/src/test/setup.js
Normal 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();
|
||||
});
|
||||
81
client/src/utils/markdown-outline.js
Normal file
81
client/src/utils/markdown-outline.js
Normal 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;
|
||||
}
|
||||
46
client/src/utils/markdown-outline.spec.js
Normal file
46
client/src/utils/markdown-outline.spec.js
Normal 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
11
client/vitest.config.js
Normal 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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user