update:增加文档模式

This commit is contained in:
dlandy
2026-04-15 13:47:39 +08:00
parent e04405d0bc
commit 8973d46a42
38 changed files with 2193 additions and 83 deletions

View File

@@ -1,6 +1,6 @@
import { flushPromises, mount } from '@vue/test-utils';
import { createMemoryHistory, createRouter } from 'vue-router';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import App from './App.vue';
@@ -47,6 +47,8 @@ function createRouterForTest() {
}
describe('App', () => {
let openSpy;
beforeEach(() => {
apiMocks.fetchCategories.mockResolvedValue([]);
apiMocks.fetchHotKeywords.mockResolvedValue([]);
@@ -65,6 +67,7 @@ describe('App', () => {
description: 'Simple tool',
category: { id: 'cat_demo', name: 'Developer Tools' },
tags: ['cli'],
displayVersion: '2026.04',
latestVersion: '1.0.0',
updatedAt: '2026-04-11',
accessMode: 'web',
@@ -95,10 +98,17 @@ describe('App', () => {
features: [],
updatedAt: '2026-04-11',
openUrl: 'https://example.com/tool',
displayVersion: '2026.04',
latestVersion: null,
fileSize: null,
downloadReady: true,
});
openSpy = vi.spyOn(window, 'open').mockReturnValue({});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('navigates detail actions to the slug route and does not render the old detail modal', async () => {
@@ -143,4 +153,123 @@ describe('App', () => {
expect(wrapper.findAll('.modal-backdrop.open')).toHaveLength(1);
expect(wrapper.get('button.nav-btn').attributes('aria-expanded')).toBe('true');
});
it('renders displayVersion from the API payload', async () => {
const router = createRouterForTest();
await router.push('/');
await router.isReady();
const wrapper = mount(App, {
global: {
plugins: [router],
},
});
await flushPromises();
expect(wrapper.text()).toContain('2026.04');
expect(wrapper.text()).not.toContain('1.0.0');
});
it('keeps the current route unchanged when launching a download tool', async () => {
apiMocks.fetchTools.mockResolvedValue({
list: [
{
id: 'tool_download',
slug: 'download-tool',
name: 'Download Tool',
description: 'Download tool',
category: { id: 'cat_demo', name: 'Developer Tools' },
tags: ['installer'],
displayVersion: '2.0.0',
latestVersion: '2.0.0',
updatedAt: '2026-04-11',
accessMode: 'download',
openUrl: 'https://example.com/download',
downloadReady: true,
downloadCount: 12,
openCount: 0,
},
],
pagination: {
page: 1,
pageSize: 6,
total: 1,
totalPages: 1,
},
});
apiMocks.launchTool.mockResolvedValue({
mode: 'download',
actionUrl: 'https://example.com/download',
openIn: 'same_tab',
});
const router = createRouterForTest();
await router.push('/');
await router.isReady();
const wrapper = mount(App, {
global: {
plugins: [router],
},
});
await flushPromises();
await wrapper.findAll('button.btn-small.btn-with-icon')[0].trigger('click');
await flushPromises();
expect(openSpy).toHaveBeenCalledWith('https://example.com/download', '_blank', 'noopener,noreferrer');
expect(router.currentRoute.value.fullPath).toBe('/');
});
it('uses the primary action as a detail shortcut for none mode tools', async () => {
apiMocks.fetchTools.mockResolvedValue({
list: [
{
id: 'tool_none',
slug: 'preview-tool',
name: 'Preview Tool',
description: 'Preview only',
category: { id: 'cat_demo', name: 'Developer Tools' },
tags: ['preview'],
displayVersion: 'preview-1',
latestVersion: null,
updatedAt: '2026-04-11',
accessMode: 'none',
openUrl: null,
downloadReady: false,
downloadCount: 0,
openCount: 0,
},
],
pagination: {
page: 1,
pageSize: 6,
total: 1,
totalPages: 1,
},
});
const router = createRouterForTest();
await router.push('/');
await router.isReady();
const wrapper = mount(App, {
global: {
plugins: [router],
},
});
await flushPromises();
expect(wrapper.text()).toContain('查看详情');
expect(wrapper.text()).toContain('preview-1');
await wrapper.get('button.btn-small.btn-with-icon').trigger('click');
await flushPromises();
expect(apiMocks.launchTool).not.toHaveBeenCalled();
expect(router.currentRoute.value.fullPath).toBe('/tools/preview-tool');
});
});

View File

@@ -152,7 +152,7 @@
<AppIcon name="sparkles" :size="15" />
版本
</span>
<strong>{{ tool.latestVersion || '暂无版本' }}</strong>
<strong>{{ tool.displayVersion || '暂无版本' }}</strong>
</li>
<li>
<span class="meta-label">
@@ -175,7 +175,7 @@
<button
type="button"
class="btn-small btn-with-icon"
:class="tool.accessMode === 'web' ? 'btn-open' : 'btn-download'"
:class="tool.accessMode === 'download' ? 'btn-download' : 'btn-open'"
:disabled="isLaunchDisabled(tool)"
@click="triggerLaunch(tool)"
>
@@ -558,6 +558,9 @@ function launchButtonText(tool) {
if (launchingId.value === tool.id) {
return '处理中...';
}
if (tool.accessMode === 'none') {
return '查看详情';
}
if (tool.accessMode === 'web') {
return '打开网页';
}
@@ -568,13 +571,22 @@ function launchButtonText(tool) {
}
function actionIconName(accessMode) {
return accessMode === 'download' ? 'download' : 'externalLink';
if (accessMode === 'download') {
return 'download';
}
if (accessMode === 'none') {
return 'book';
}
return 'externalLink';
}
function toolModeSummary(tool) {
if (tool.accessMode === 'download') {
return `下载 ${formatNumber(tool.downloadCount)}`;
}
if (tool.accessMode === 'none') {
return '仅详情';
}
return `访问 ${formatNumber(tool.openCount)}`;
}
@@ -583,6 +595,11 @@ async function triggerLaunch(tool) {
return;
}
if (tool.accessMode === 'none') {
openDetailPage(tool.slug);
return;
}
launchingId.value = tool.id;
try {
@@ -602,11 +619,9 @@ async function triggerLaunch(tool) {
const actionUrl = resolveActionUrl(result?.actionUrl);
if (isWebLaunch || isDownloadLaunch) {
if (result.openIn === 'same_tab') {
window.location.assign(actionUrl);
return;
}
if (isWebLaunch && result.openIn === 'same_tab') {
window.location.assign(actionUrl);
return;
}
if (isWebLaunch) {
@@ -619,7 +634,7 @@ async function triggerLaunch(tool) {
} else if (result?.mode === 'download') {
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
if (!page) {
window.location.assign(actionUrl);
showToast('浏览器阻止了下载窗口,请允许弹窗后重试');
return;
}
showToast(`${tool.name} 已开始下载`);

View File

@@ -313,6 +313,7 @@ const statusOptions = [
const accessModeOptions = [
{ label: '下载模式', value: 'download' },
{ label: '网页模式', value: 'web' },
{ label: '无', value: 'none' },
];
const toolDialogFormRef = ref(null);
@@ -326,6 +327,7 @@ function createEmptyToolForm() {
description: '',
rating: 0,
featuresText: '',
versionOverride: '',
accessMode: 'download',
openUrl: '',
openInNewTab: true,
@@ -362,6 +364,10 @@ const toolFormRules = {
{
validator: (_rule, value, callback) => {
const normalized = String(value || '').trim();
if (toolForm.accessMode === 'none') {
callback();
return;
}
if (toolForm.accessMode !== 'web' && !normalized) {
callback();
return;
@@ -561,6 +567,9 @@ function accessModeTagType(accessMode) {
if (accessMode === 'web') {
return 'primary';
}
if (accessMode === 'none') {
return 'info';
}
return 'success';
}
@@ -591,6 +600,11 @@ function normalizeFeatureText(value) {
.slice(0, 20);
}
function normalizeOptionalText(value) {
const normalized = String(value || '').trim();
return normalized || null;
}
function normalizeTagName(value) {
return String(value || '').trim().replace(/\s+/g, ' ');
}
@@ -983,6 +997,7 @@ function openEditToolDialog(row) {
description: row.description || '',
rating: Number(row.rating ?? 0),
featuresText: Array.isArray(row.features) ? row.features.join('\n') : '',
versionOverride: row.versionOverride || '',
accessMode: row.accessMode || 'download',
openUrl: row.openUrl || '',
openInNewTab: row.openInNewTab ?? true,
@@ -993,6 +1008,9 @@ function openEditToolDialog(row) {
function buildToolPayload(tagIds) {
const openUrl = toolForm.openUrl.trim();
const accessMode = toolForm.accessMode;
// UI state uses empty strings for editing convenience; normalize before submit.
const payload = {
name: toolForm.name.trim(),
categoryId: toolForm.categoryId,
@@ -1000,10 +1018,11 @@ function buildToolPayload(tagIds) {
description: toolForm.description.trim(),
rating: Number(toolForm.rating ?? 0),
features: normalizeFeatureText(toolForm.featuresText),
accessMode: toolForm.accessMode,
openInNewTab: toolForm.openInNewTab,
versionOverride: normalizeOptionalText(toolForm.versionOverride),
accessMode,
openInNewTab: accessMode === 'none' ? false : toolForm.openInNewTab,
status: toolForm.status,
openUrl: openUrl || null,
openUrl: accessMode === 'none' ? null : openUrl || null,
};
return payload;
@@ -1226,7 +1245,7 @@ async function submitAccessModeUpdate() {
ElMessage.warning('网页模式必须填写 Open URL');
return;
}
if (openUrl && !isValidHttpUrl(openUrl)) {
if (modeDialog.accessMode !== 'none' && openUrl && !isValidHttpUrl(openUrl)) {
ElMessage.warning('请输入有效的 http/https 地址');
return;
}
@@ -1238,8 +1257,8 @@ async function submitAccessModeUpdate() {
modeDialog.id,
{
accessMode: modeDialog.accessMode,
openUrl: openUrl || null,
openInNewTab: modeDialog.openInNewTab,
openUrl: modeDialog.accessMode === 'none' ? null : openUrl || null,
openInNewTab: modeDialog.accessMode === 'none' ? false : modeDialog.openInNewTab,
},
token,
),

View File

@@ -0,0 +1,69 @@
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { nextTick, reactive } from 'vue';
import AccessModeDialog from './AccessModeDialog.vue';
import { elementPlusStubs } from './test-stubs';
function mountDialog(modeOverrides = {}) {
const modeDialog = reactive({
id: 'tool_preview',
name: 'Preview Tool',
accessMode: 'download',
openUrl: 'https://example.com/download',
openInNewTab: true,
submitting: false,
...modeOverrides,
});
const wrapper = mount(AccessModeDialog, {
props: {
visible: true,
modeDialog,
accessModeOptions: [
{ label: '下载模式', value: 'download' },
{ label: '网页模式', value: 'web' },
{ label: '无', value: 'none' },
],
},
global: {
stubs: elementPlusStubs,
},
});
return { wrapper, modeDialog };
}
afterEach(() => {
vi.clearAllMocks();
document.body.innerHTML = '';
});
describe('AccessModeDialog', () => {
it('includes none access mode option', () => {
const { wrapper } = mountDialog();
const optionLabels = wrapper
.findAllComponents({ name: 'ElOption' })
.map((component) => component.props('label'));
expect(optionLabels).toContain('无');
});
it('hides url and new tab controls when switching to none mode', async () => {
const { wrapper, modeDialog } = mountDialog({
accessMode: 'web',
openUrl: 'https://example.com/tool',
});
expect(wrapper.text()).toContain('Open URL');
expect(wrapper.text()).toContain('新标签页');
modeDialog.accessMode = 'none';
await nextTick();
expect(wrapper.text()).not.toContain('Open URL');
expect(wrapper.text()).not.toContain('下载地址');
expect(wrapper.text()).not.toContain('新标签页');
});
});

View File

@@ -23,7 +23,7 @@
:placeholder="modeDialog.accessMode === 'web' ? 'https://example.com' : 'https://gitlab.example.com/...' "
/>
</el-form-item>
<el-form-item label="新标签页">
<el-form-item v-if="modeDialog.accessMode !== 'none'" label="新标签页">
<el-switch v-model="modeDialog.openInNewTab" />
</el-form-item>
</el-form>

View File

@@ -0,0 +1,57 @@
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import AdminOverviewSection from './AdminOverviewSection.vue';
import { elementPlusStubs } from './test-stubs';
function mountSection() {
return mount(AdminOverviewSection, {
props: {
loading: false,
overview: {
summary: {
toolTotal: 3,
publishedTotal: 2,
draftTotal: 1,
archivedTotal: 0,
downloadToolTotal: 1,
webToolTotal: 1,
noneToolTotal: 1,
},
dailyActivity: [],
topCategories: [],
topTools: [
{
id: 'tool_preview',
name: 'Preview Tool',
categoryName: 'Preview',
accessMode: 'none',
interactionTotal: 0,
updatedAt: '2026-04-11',
},
],
},
formatNumber: (value) => String(value),
formatDate: (value) => value,
},
global: {
stubs: elementPlusStubs,
},
});
}
afterEach(() => {
vi.clearAllMocks();
document.body.innerHTML = '';
});
describe('AdminOverviewSection', () => {
it('renders none mode stats and top tool labels', () => {
const wrapper = mountSection();
expect(wrapper.text()).toContain('访问模式分布');
expect(wrapper.text()).toContain('无');
expect(wrapper.text()).toContain('Preview Tool');
expect(wrapper.text()).not.toContain('下载就绪');
});
});

View File

@@ -113,7 +113,7 @@
<el-table-column prop="categoryName" label="分类" min-width="120" />
<el-table-column label="模式" min-width="80">
<template #default="{ row }">
{{ row.accessMode === 'web' ? '网页' : '下载' }}
{{ accessModeLabel(row.accessMode) }}
</template>
</el-table-column>
<el-table-column label="交互总量" min-width="110">
@@ -170,6 +170,16 @@ function toPercent(value, total) {
return Math.max(0, Math.min(100, Number(ratio.toFixed(1))));
}
function accessModeLabel(accessMode) {
if (accessMode === 'web') {
return '网页';
}
if (accessMode === 'none') {
return '无';
}
return '下载';
}
const statusStats = computed(() => {
const total = Number(summary.value.toolTotal || 0);
return [
@@ -210,10 +220,10 @@ const accessModeStats = computed(() => {
ratio: toPercent(summary.value.webToolTotal, total),
},
{
key: 'ready',
label: '下载就绪',
value: summary.value.downloadReadyToolTotal || 0,
ratio: toPercent(summary.value.downloadReadyToolTotal, summary.value.downloadToolTotal || 0),
key: 'none',
label: '',
value: summary.value.noneToolTotal || 0,
ratio: toPercent(summary.value.noneToolTotal, total),
},
];
});

View File

@@ -0,0 +1,75 @@
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { reactive } from 'vue';
import AdminToolsSection from './AdminToolsSection.vue';
import { elementPlusStubs } from './test-stubs';
function mountSection() {
return mount(AdminToolsSection, {
props: {
toolFilters: reactive({
query: '',
categoryId: '',
status: '',
accessMode: '',
page: 1,
pageSize: 10,
}),
categoryLoading: false,
categories: [{ id: 'cat_preview', name: 'Preview', toolCount: 1 }],
statusOptions: [{ label: '草稿', value: 'draft' }],
accessModeOptions: [
{ label: '下载模式', value: 'download' },
{ label: '网页模式', value: 'web' },
{ label: '无', value: 'none' },
],
toolLoading: false,
tools: [
{
id: 'tool_preview',
name: 'Preview Tool',
category: { id: 'cat_preview', name: 'Preview' },
status: 'draft',
accessMode: 'none',
displayVersion: 'preview-1',
rating: 4.5,
downloadCount: 0,
openCount: 0,
updatedAt: '2026-04-11',
},
],
toolPagination: reactive({
page: 1,
pageSize: 10,
total: 1,
totalPages: 1,
}),
statusTagType: () => 'info',
accessModeTagType: () => 'info',
formatNumber: (value) => String(value),
formatDate: (value) => value,
},
global: {
stubs: elementPlusStubs,
directives: {
loading: {},
},
},
});
}
afterEach(() => {
vi.clearAllMocks();
document.body.innerHTML = '';
});
describe('AdminToolsSection', () => {
it('renders the display version column and none mode label', async () => {
const wrapper = mountSection();
expect(wrapper.text()).toContain('版本');
expect(wrapper.text()).toContain('preview-1');
expect(wrapper.text()).toContain('无');
});
});

View File

@@ -79,7 +79,14 @@
</el-table-column>
<el-table-column label="访问方式" width="120">
<template #default="{ row }">
<el-tag :type="accessModeTagType(row.accessMode)" effect="light">{{ row.accessMode }}</el-tag>
<el-tag :type="accessModeTagType(row.accessMode)" effect="light">
{{ accessModeLabel(row.accessMode) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" min-width="120">
<template #default="{ row }">
{{ row.displayVersion || '-' }}
</template>
</el-table-column>
<el-table-column prop="rating" label="评分" width="80" />
@@ -122,6 +129,16 @@
</template>
<script setup>
function accessModeLabel(accessMode) {
if (accessMode === 'web') {
return '网页';
}
if (accessMode === 'none') {
return '无';
}
return '下载';
}
defineProps({
toolFilters: {
type: Object,

View File

@@ -0,0 +1,85 @@
import { mount } from '@vue/test-utils';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { nextTick, reactive } from 'vue';
import ToolFormDialog from './ToolFormDialog.vue';
import { elementPlusStubs } from './test-stubs';
function mountDialog(toolFormOverrides = {}) {
const toolForm = reactive({
name: 'Preview Tool',
categoryId: 'cat_preview',
tagIds: [],
description: 'Preview only tool',
rating: 4.2,
featuresText: '',
versionOverride: 'preview-1',
accessMode: 'download',
openUrl: 'https://example.com/download',
openInNewTab: true,
status: 'draft',
...toolFormOverrides,
});
const wrapper = mount(ToolFormDialog, {
props: {
visible: true,
mode: 'create',
toolForm,
toolFormRules: {},
categories: [{ id: 'cat_preview', name: 'Preview' }],
categoryLoading: false,
tags: [],
tagLoading: false,
accessModeOptions: [
{ label: '下载模式', value: 'download' },
{ label: '网页模式', value: 'web' },
{ label: '无', value: 'none' },
],
statusOptions: [{ label: '草稿', value: 'draft' }],
submitting: false,
},
global: {
stubs: elementPlusStubs,
},
});
return { wrapper, toolForm };
}
afterEach(() => {
vi.clearAllMocks();
document.body.innerHTML = '';
});
describe('ToolFormDialog', () => {
it('renders version override input and includes none access mode option', () => {
const { wrapper } = mountDialog();
expect(wrapper.text()).toContain('展示版本');
expect(wrapper.text()).toContain('仅用于页面展示');
const optionLabels = wrapper
.findAllComponents({ name: 'ElOption' })
.map((component) => component.props('label'));
expect(optionLabels).toContain('无');
});
it('hides url and new tab controls for none mode', async () => {
const { wrapper, toolForm } = mountDialog({
accessMode: 'web',
openUrl: 'https://example.com/tool',
});
expect(wrapper.text()).toContain('Open URL');
expect(wrapper.text()).toContain('新标签页');
toolForm.accessMode = 'none';
await nextTick();
expect(wrapper.text()).not.toContain('Open URL');
expect(wrapper.text()).not.toContain('下载地址');
expect(wrapper.text()).not.toContain('新标签页');
});
});

View File

@@ -81,6 +81,17 @@
/>
</el-select>
</el-form-item>
<el-form-item label="展示版本" prop="versionOverride">
<el-input
v-model="toolForm.versionOverride"
placeholder="例如2026.04 / v2.1.0"
maxlength="80"
show-word-limit
/>
<div class="el-form-item__description">
仅用于页面展示下载模式留空时会回退到最新安装包版本网页/无模式留空则显示暂无版本
</div>
</el-form-item>
<el-form-item
v-if="toolForm.accessMode === 'web' || toolForm.accessMode === 'download'"
:label="toolForm.accessMode === 'web' ? 'Open URL' : '下载地址'"
@@ -94,7 +105,7 @@
{{ toolForm.accessMode === 'web' ? '网页模式下用于打开页面。' : '下载模式下可直接填写 GitLab 下载地址,不上传文件也可使用。' }}
</div>
</el-form-item>
<el-form-item label="新标签页">
<el-form-item v-if="toolForm.accessMode !== 'none'" label="新标签页">
<el-switch v-model="toolForm.openInNewTab" />
</el-form-item>
<el-form-item label="状态" prop="status">

View File

@@ -0,0 +1,229 @@
import { computed, defineComponent, h, inject, provide } from 'vue';
export const DialogStub = defineComponent({
name: 'ElDialog',
props: {
modelValue: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
},
emits: ['update:modelValue'],
template: `
<div v-if="modelValue" class="el-dialog">
<h2>{{ title }}</h2>
<slot />
<slot name="footer" />
</div>
`,
});
export const FormStub = defineComponent({
name: 'ElForm',
template: '<form class="el-form"><slot /></form>',
});
export const FormItemStub = defineComponent({
name: 'ElFormItem',
props: {
label: {
type: String,
default: '',
},
},
template: `
<div class="el-form-item">
<label v-if="label">{{ label }}</label>
<slot />
</div>
`,
});
export const InputStub = defineComponent({
name: 'ElInput',
props: {
modelValue: {
type: [String, Number],
default: '',
},
placeholder: {
type: String,
default: '',
},
},
emits: ['update:modelValue'],
template: `
<input
class="el-input"
:value="modelValue"
:placeholder="placeholder"
@input="$emit('update:modelValue', $event.target.value)"
/>
`,
});
export const InputNumberStub = defineComponent({
name: 'ElInputNumber',
props: {
modelValue: {
type: Number,
default: 0,
},
},
emits: ['update:modelValue'],
template: `
<input
class="el-input-number"
type="number"
:value="modelValue"
@input="$emit('update:modelValue', Number($event.target.value))"
/>
`,
});
export const SelectStub = defineComponent({
name: 'ElSelect',
props: {
modelValue: {
type: [String, Number, Array],
default: '',
},
},
emits: ['update:modelValue'],
template: `
<select
class="el-select"
:value="Array.isArray(modelValue) ? modelValue[0] : modelValue"
@change="$emit('update:modelValue', $event.target.value)"
>
<slot />
</select>
`,
});
export const OptionStub = defineComponent({
name: 'ElOption',
props: {
label: {
type: String,
default: '',
},
value: {
type: [String, Number],
default: '',
},
},
template: '<option :value="value">{{ label }}</option>',
});
export const SwitchStub = defineComponent({
name: 'ElSwitch',
props: {
modelValue: {
type: Boolean,
default: false,
},
},
emits: ['update:modelValue'],
template: `
<input
class="el-switch"
type="checkbox"
:checked="modelValue"
@change="$emit('update:modelValue', $event.target.checked)"
/>
`,
});
export const ButtonStub = defineComponent({
name: 'ElButton',
emits: ['click'],
template: '<button class="el-button" @click="$emit(\'click\', $event)"><slot /></button>',
});
export const TagStub = defineComponent({
name: 'ElTag',
template: '<span class="el-tag"><slot /></span>',
});
export const SpaceStub = defineComponent({
name: 'ElSpace',
template: '<div class="el-space"><slot /></div>',
});
export const PaginationStub = defineComponent({
name: 'ElPagination',
props: {
total: {
type: Number,
default: 0,
},
},
template: '<div class="el-pagination">Total {{ total }}</div>',
});
const TABLE_DATA_KEY = Symbol('table-data');
export const TableStub = defineComponent({
name: 'ElTable',
props: {
data: {
type: Array,
default: () => [],
},
},
setup(props, { slots }) {
provide(TABLE_DATA_KEY, computed(() => props.data || []));
return () => h('div', { class: 'el-table' }, slots.default ? slots.default() : []);
},
});
export const TableColumnStub = defineComponent({
name: 'ElTableColumn',
props: {
label: {
type: String,
default: '',
},
prop: {
type: String,
default: '',
},
},
setup(props, { slots }) {
const rows = inject(TABLE_DATA_KEY, computed(() => []));
return () =>
h('section', { class: 'el-table-column' }, [
h('header', { class: 'el-table-column-label' }, props.label),
...rows.value.map((row, index) =>
h(
'div',
{ key: `${props.label}-${index}`, class: 'el-table-cell' },
slots.default ? slots.default({ row }) : String(row?.[props.prop] ?? ''),
),
),
]);
},
});
export const elementPlusStubs = {
'el-dialog': DialogStub,
'el-form': FormStub,
'el-form-item': FormItemStub,
'el-input': InputStub,
'el-input-number': InputNumberStub,
'el-select': SelectStub,
'el-option': OptionStub,
'el-switch': SwitchStub,
'el-button': ButtonStub,
'el-tag': TagStub,
'el-space': SpaceStub,
'el-pagination': PaginationStub,
'el-table': TableStub,
'el-table-column': TableColumnStub,
};

View File

@@ -33,6 +33,7 @@ function createEmptyOverviewState() {
tagTotal: 0,
webToolTotal: 0,
downloadToolTotal: 0,
noneToolTotal: 0,
downloadReadyToolTotal: 0,
openTotal: 0,
downloadTotal: 0,

View File

@@ -35,6 +35,7 @@ function createDetail(overrides = {}) {
features: ['Fast setup'],
updatedAt: '2026-04-11',
openUrl: 'https://example.com/tool',
displayVersion: '2026.04',
latestVersion: null,
fileSize: null,
downloadReady: true,
@@ -72,12 +73,16 @@ async function mountPage(path = '/tools/demo-tool') {
}
describe('ToolDetailPage', () => {
let openSpy;
beforeEach(() => {
apiMocks.fetchToolDetailBySlug.mockReset();
apiMocks.getApiErrorMessage.mockClear();
apiMocks.launchTool.mockReset();
apiMocks.notifyToolInteraction.mockReset();
apiMocks.resolveActionUrl.mockClear();
openSpy = vi.spyOn(window, 'open').mockReturnValue({});
});
afterEach(() => {
@@ -102,6 +107,7 @@ describe('ToolDetailPage', () => {
expect(apiMocks.fetchToolDetailBySlug).toHaveBeenCalledWith('demo-tool');
expect(wrapper.text()).toContain('Demo Tool');
expect(wrapper.text()).toContain('Developer Tools');
expect(wrapper.text()).toContain('2026.04');
expect(wrapper.text()).toContain('打开网页');
});
@@ -137,6 +143,7 @@ describe('ToolDetailPage', () => {
apiMocks.fetchToolDetailBySlug.mockResolvedValue(
createDetail({
accessMode: 'download',
displayVersion: '2.0.0',
latestVersion: '2.0.0',
fileSize: 4096,
}),
@@ -149,6 +156,25 @@ describe('ToolDetailPage', () => {
expect(wrapper.text()).toContain('2.0.0');
});
it('shows displayVersion and hides the primary action for none mode tools', async () => {
apiMocks.fetchToolDetailBySlug.mockResolvedValue(
createDetail({
accessMode: 'none',
openUrl: null,
displayVersion: 'preview-1',
latestVersion: null,
downloadReady: false,
}),
);
const { wrapper } = await mountPage();
await flushPromises();
expect(wrapper.text()).toContain('preview-1');
expect(wrapper.text()).toContain('仅详情');
expect(wrapper.find('.detail-hero-actions .btn').exists()).toBe(false);
});
it('renders a table of contents for markdown headings', async () => {
apiMocks.fetchToolDetailBySlug.mockResolvedValue(
createDetail({
@@ -177,4 +203,27 @@ describe('ToolDetailPage', () => {
expect(wrapper.find('.detail-outline').exists()).toBe(false);
expect(wrapper.text()).toContain('Plain manual without headings.');
});
it('keeps the current detail route unchanged when launching a download tool', async () => {
apiMocks.fetchToolDetailBySlug.mockResolvedValue(
createDetail({
accessMode: 'download',
downloadReady: true,
}),
);
apiMocks.launchTool.mockResolvedValue({
mode: 'download',
actionUrl: 'https://example.com/download',
openIn: 'same_tab',
});
const { router, wrapper } = await mountPage();
await flushPromises();
await wrapper.get('.detail-hero-actions .btn').trigger('click');
await flushPromises();
expect(openSpy).toHaveBeenCalledWith('https://example.com/download', '_blank', 'noopener,noreferrer');
expect(router.currentRoute.value.fullPath).toBe('/tools/demo-tool');
});
});

View File

@@ -40,7 +40,7 @@
<h1>{{ detail.name }}</h1>
<div class="detail-summary-row">
<p class="detail-summary-text">
更新时间{{ formatDate(detail.updatedAt) }} · {{ toolModeSummary(detail) }}
版本{{ detail.displayVersion || '暂无版本' }} · 更新时间{{ 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>
@@ -50,6 +50,7 @@
<div class="detail-hero-actions">
<button
v-if="hasPrimaryAction"
type="button"
class="btn btn-primary btn-with-icon"
:disabled="isPrimaryActionDisabled"
@@ -58,7 +59,7 @@
<AppIcon :name="primaryActionIconName" :size="16" />
{{ primaryActionLabel }}
</button>
<p v-if="launchError" class="detail-inline-error">{{ launchError }}</p>
<p v-if="hasPrimaryAction && launchError" class="detail-inline-error">{{ launchError }}</p>
</div>
</div>
@@ -141,6 +142,7 @@ const launching = ref(false);
const hasManual = computed(() => Boolean(detail.value?.description?.trim()));
const outline = computed(() => (hasManual.value ? extractMarkdownOutline(detail.value.description) : []));
const hasPrimaryAction = computed(() => Boolean(detail.value) && detail.value.accessMode !== 'none');
const primaryActionIconName = computed(() => accessModeIconName(detail.value?.accessMode));
const manualHtml = computed(() => {
if (!hasManual.value) {
@@ -157,6 +159,9 @@ const primaryActionLabel = computed(() => {
if (launching.value) {
return '处理中...';
}
if (detail.value.accessMode === 'none') {
return '';
}
if (detail.value.accessMode === 'web') {
return '打开网页';
}
@@ -170,6 +175,9 @@ const isPrimaryActionDisabled = computed(() => {
if (!detail.value || launching.value) {
return true;
}
if (detail.value.accessMode === 'none') {
return true;
}
return detail.value.accessMode === 'download' && !detail.value.downloadReady;
});
@@ -214,11 +222,20 @@ function toolModeSummary(tool) {
if (tool.accessMode === 'download') {
return `下载 ${formatNumber(tool.downloadCount)}`;
}
if (tool.accessMode === 'none') {
return '仅详情';
}
return `访问 ${formatNumber(tool.openCount)}`;
}
function accessModeIconName(accessMode) {
return accessMode === 'download' ? 'download' : 'externalLink';
if (accessMode === 'download') {
return 'download';
}
if (accessMode === 'none') {
return 'book';
}
return 'externalLink';
}
function goHome() {
@@ -286,13 +303,16 @@ async function triggerPrimaryAction() {
throw new Error('未获取到可执行地址');
}
if ((isWebLaunch || isDownloadLaunch) && result.openIn === 'same_tab') {
if (isWebLaunch && result.openIn === 'same_tab') {
window.location.assign(actionUrl);
return;
}
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
if (!page) {
if (isDownloadLaunch) {
throw new Error('浏览器阻止了下载窗口,请允许弹窗后重试');
}
window.location.assign(actionUrl);
}
} catch (error) {