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

View File

@@ -0,0 +1,141 @@
# 工具展示版本与无访问模式实施计划
**目标**:为工具站新增 `Tool.versionOverride``AccessMode.none`,由服务端统一计算 `displayVersion`,并让后台工具管理、公开列表、公开详情和 launch 语义全部兼容新模型。
**架构**:采用“数据契约先行”的增量改造。先在 Prisma 和服务端建立稳定的数据模型与返回字段,再分别更新公开站和管理端消费 `displayVersion`,最后用单元测试与 e2e 回归把 `none` 模式的行为闭合。
**技术栈**Prisma、SQLite、NestJS、Jest、Supertest、Vue 3、Element Plus、Vitest
---
## 文件结构
### 新建文件
- `server/prisma/migrations/*_tool-display-version-none-mode/migration.sql` - 由 Prisma 生成 `AccessMode.none``Tool.versionOverride` 的迁移脚本。注释需求:低。复杂度:低。
- `server/src/modules/admin-tools/admin-tools.service.spec.ts` - 覆盖后台工具列表/详情的 `displayVersion` 映射、`versionOverride` 归一化和 `none` 模式输入。注释需求:低。复杂度:中。
- `server/src/modules/access/access.service.spec.ts` - 覆盖 `none` 模式 launch 被拒绝、`web/download` 既有行为不回归。注释需求:低。复杂度:中。
- `server/src/modules/admin-overview/admin-overview.service.spec.ts` - 覆盖概览统计对 `none` 模式的计数与文案数据。注释需求:低。复杂度:中。
- `server/test/tool-launch-none.e2e-spec.ts` - 覆盖公开 launch 接口对 `none` 模式返回 `409`。注释需求:低。复杂度:中。
- `client/src/admin/components/ToolFormDialog.spec.js` - 覆盖工具表单展示版本输入项和 `none` 模式可见性。注释需求:低。复杂度:中。
- `client/src/admin/components/AccessModeDialog.spec.js` - 覆盖快速切换访问方式时 `none` 模式隐藏 URL 输入。注释需求:低。复杂度:中。
- `client/src/admin/components/AdminToolsSection.spec.js` - 覆盖后台工具列表版本列和 `none` 模式标签渲染。注释需求:低。复杂度:中。
- `client/src/admin/components/AdminOverviewSection.spec.js` - 覆盖后台概览的 `none` 模式统计展示。注释需求:低。复杂度:中。
### 修改文件
- `server/prisma/schema.prisma` - 为 `AccessMode` 增加 `none`,为 `Tool` 增加 `versionOverride`。注释需求:低。复杂度:中。
- `server/prisma/seed.ts` - 为种子工具补充展示版本样例,并加入至少一个 `none` 模式工具。注释需求:低。复杂度:中。
- `server/src/modules/tools/tools.service.ts` - 公开列表和详情统一输出 `displayVersion`,保留下载工具 `latestVersion` 兼容字段。注释需求:中,新增版本解析 helper 需说明回退顺序。复杂度:高。
- `server/src/modules/tools/tools.service.spec.ts` - 增加 `displayVersion``none` 模式回归断言。注释需求:低。复杂度:中。
- `server/test/tools-detail.e2e-spec.ts` - 扩展详情接口断言,验证 `displayVersion``accessMode=none` 可被公开读取。注释需求:低。复杂度:中。
- `server/src/modules/admin-tools/dto/create-tool.dto.ts` - 接收 `versionOverride`,并让 `accessMode` 接受 `none`。注释需求:低。复杂度:中。
- `server/src/modules/admin-tools/dto/update-tool.dto.ts` - 继承新增展示版本字段。注释需求:低。复杂度:低。
- `server/src/modules/admin-tools/dto/update-access-mode.dto.ts` - 允许切换到 `none`,并明确 URL 仅在 `web/download` 下有意义。注释需求:低。复杂度:中。
- `server/src/modules/admin-tools/admin-tools.service.ts` - 统一归一化 `versionOverride`,后台接口输出 `displayVersion``versionOverride`。注释需求:中,新增映射 helper 需要解释字段语义。复杂度:高。
- `server/src/modules/access/access.service.ts` - 对 `none` 模式 launch 返回冲突错误,不生成 ticket也不返回 actionUrl。注释需求中。复杂度中。
- `server/src/modules/admin-overview/admin-overview.service.ts` - 新增 `noneToolTotal` 统计,并让模式分布闭合。注释需求:低。复杂度:中。
- `client/src/App.vue` - 首页卡片改为展示 `displayVersion``none` 模式主按钮跳详情并显示“查看详情”。注释需求:低。复杂度:中。
- `client/src/App.spec.js` - 增加 `displayVersion``none` 模式交互回归。注释需求:低。复杂度:中。
- `client/src/pages/ToolDetailPage.vue` - 详情页展示 `displayVersion``none` 模式不渲染主操作按钮。注释需求:低。复杂度:中。
- `client/src/pages/ToolDetailPage.spec.js` - 增加 `displayVersion` 渲染和 `none` 模式无主按钮断言。注释需求:低。复杂度:中。
- `client/src/admin/AdminApp.vue` - 表单状态新增 `versionOverride`,访问方式选项新增“无”,构造 payload 和标签样式兼容 `none`。注释需求:中,构造 payload 的归一化逻辑需要简短说明。复杂度:高。
- `client/src/admin/components/ToolFormDialog.vue` - 新增展示版本输入项,按模式调整文案。注释需求:低。复杂度:中。
- `client/src/admin/components/AccessModeDialog.vue` - `none` 模式隐藏 URL 输入和无意义的新标签页控制。注释需求:低。复杂度:中。
- `client/src/admin/components/AdminToolsSection.vue` - 增加版本列并渲染 `none` 标签文案。注释需求:低。复杂度:中。
- `client/src/admin/components/AdminOverviewSection.vue` - 新增或调整模式分布项,显示“无”模式。注释需求:低。复杂度:中。
- `client/src/admin/stores/console.js` - 为概览 summary 默认值补充 `noneToolTotal`。注释需求:低。复杂度:低。
### 测试文件
- `server/src/modules/tools/tools.service.spec.ts` - 公开服务的 `displayVersion` 计算与详情映射回归。
- `server/src/modules/admin-tools/admin-tools.service.spec.ts` - 后台服务的版本覆盖与访问方式回归。
- `server/src/modules/access/access.service.spec.ts` - `none` 模式 launch 约束回归。
- `server/src/modules/admin-overview/admin-overview.service.spec.ts` - 概览模式分布回归。
- `server/test/tools-detail.e2e-spec.ts` - 公开详情接口契约回归。
- `server/test/tool-launch-none.e2e-spec.ts` - 公开 launch 接口 `none` 模式回归。
- `client/src/App.spec.js` - 首页卡片版本展示和 `none` 模式路由回归。
- `client/src/pages/ToolDetailPage.spec.js` - 详情页版本展示和无主按钮回归。
- `client/src/admin/components/ToolFormDialog.spec.js` - 后台工具表单版本输入与 `none` 模式渲染回归。
- `client/src/admin/components/AccessModeDialog.spec.js` - 后台访问方式对话框 `none` 模式回归。
- `client/src/admin/components/AdminToolsSection.spec.js` - 后台列表版本列和模式标签回归。
- `client/src/admin/components/AdminOverviewSection.spec.js` - 后台概览模式分布回归。
---
## 任务列表
### 任务 1先锁定 Prisma 契约和公开接口版本映射
**文件**
- 修改:`server/prisma/schema.prisma`
- 修改:`server/src/modules/tools/tools.service.ts`
- 修改:`server/src/modules/tools/tools.service.spec.ts`
- 修改:`server/test/tools-detail.e2e-spec.ts`
- 创建:`server/prisma/migrations/*_tool-display-version-none-mode/migration.sql`
- [ ] **步骤 1**:在 `server/src/modules/tools/tools.service.spec.ts` 先补 3 个失败测试,分别覆盖“下载工具无覆盖版本时回退 `latestArtifact.version`”“网页工具使用 `versionOverride`”“`none` 模式详情返回 `displayVersion``accessMode=none`”。
- [ ] **步骤 2**:在 `server/test/tools-detail.e2e-spec.ts` 先补失败断言,执行 `cd server && npm run test -- src/modules/tools/tools.service.spec.ts``cd server && npm run test:e2e -- test/tools-detail.e2e-spec.ts`,预期出现缺少 `displayVersion``none` 枚举未生效或断言失败。
- [ ] **步骤 3**:更新 `server/prisma/schema.prisma`,执行 `cd server && npx prisma migrate dev --name tool-display-version-none-mode``cd server && npm run prisma:generate`,然后在 `server/src/modules/tools/tools.service.ts` 加入统一的 `displayVersion` 解析 helper并保留下载工具 `latestVersion` 兼容字段。
- [ ] **步骤 4**:重新执行 `cd server && npm run test -- src/modules/tools/tools.service.spec.ts``cd server && npm run test:e2e -- test/tools-detail.e2e-spec.ts`,预期 Jest 与 e2e 通过,公开接口开始稳定返回 `displayVersion`
### 任务 2补齐后台工具服务对 `versionOverride` 和 `none` 的支持
**文件**
- 修改:`server/prisma/seed.ts`
- 修改:`server/src/modules/admin-tools/dto/create-tool.dto.ts`
- 修改:`server/src/modules/admin-tools/dto/update-tool.dto.ts`
- 修改:`server/src/modules/admin-tools/dto/update-access-mode.dto.ts`
- 修改:`server/src/modules/admin-tools/admin-tools.service.ts`
- 创建:`server/src/modules/admin-tools/admin-tools.service.spec.ts`
- [ ] **步骤 1**:创建 `server/src/modules/admin-tools/admin-tools.service.spec.ts`,先写失败测试,覆盖“后台列表/详情同时返回 `displayVersion``versionOverride`”“下载工具覆盖版本优先于安装包版本”“`none` 模式允许空 `openUrl` 保存”。
- [ ] **步骤 2**:执行 `cd server && npm run test -- src/modules/admin-tools/admin-tools.service.spec.ts`,预期因 DTO、服务映射或归一化逻辑缺失而失败。
- [ ] **步骤 3**:更新后台 DTO 与 `server/src/modules/admin-tools/admin-tools.service.ts`,为 `versionOverride` 做 trim/null 归一化、为 `none` 模式放开 URL 约束,并在 `server/prisma/seed.ts` 加入带版本覆盖值的 `web`/`none` 示例工具,便于本地人工验证。
- [ ] **步骤 4**:执行 `cd server && npm run test -- src/modules/admin-tools/admin-tools.service.spec.ts src/modules/tools/tools.service.spec.ts`,预期后台和公开服务映射同时通过。
### 任务 3封闭 `none` 模式的 launch 语义并补齐概览统计
**文件**
- 修改:`server/src/modules/access/access.service.ts`
- 修改:`server/src/modules/admin-overview/admin-overview.service.ts`
- 创建:`server/src/modules/access/access.service.spec.ts`
- 创建:`server/src/modules/admin-overview/admin-overview.service.spec.ts`
- 创建:`server/test/tool-launch-none.e2e-spec.ts`
- [ ] **步骤 1**:先创建 `server/src/modules/access/access.service.spec.ts``server/src/modules/admin-overview/admin-overview.service.spec.ts`,分别写失败测试,覆盖“`none` 模式 launch 抛 `409`”“概览 summary 返回 `noneToolTotal` 且模式分布不把 `none` 算进下载模式”;同时创建 `server/test/tool-launch-none.e2e-spec.ts` 作为接口级失败测试。
- [ ] **步骤 2**:执行 `cd server && npm run test -- src/modules/access/access.service.spec.ts src/modules/admin-overview/admin-overview.service.spec.ts``cd server && npm run test:e2e -- test/tool-launch-none.e2e-spec.ts`,预期 launch 与概览相关断言失败。
- [ ] **步骤 3**:在 `server/src/modules/access/access.service.ts` 拒绝 `none` 模式 launch`server/src/modules/admin-overview/admin-overview.service.ts` 增加 `noneToolTotal``none` 统计分支;若复用现有错误码,则统一错误消息语义。
- [ ] **步骤 4**:重新执行上述测试命令,预期单元测试和 e2e 都通过,`none` 模式行为在服务端完全闭合。
### 任务 4更新公开站首页与详情页统一消费 `displayVersion`
**文件**
- 修改:`client/src/App.vue`
- 修改:`client/src/App.spec.js`
- 修改:`client/src/pages/ToolDetailPage.vue`
- 修改:`client/src/pages/ToolDetailPage.spec.js`
- [ ] **步骤 1**:先在 `client/src/App.spec.js``client/src/pages/ToolDetailPage.spec.js` 增加失败用例,覆盖“首页优先展示 `displayVersion`”“`none` 模式主按钮文案为‘查看详情’并跳转详情页”“详情页展示 `displayVersion``none` 模式不渲染主按钮”。
- [ ] **步骤 2**:执行 `cd client && npm run test:run -- src/App.spec.js src/pages/ToolDetailPage.spec.js`,预期 Vitest 因页面仍依赖 `latestVersion` 或仍渲染打开/下载按钮而失败。
- [ ] **步骤 3**:更新 `client/src/App.vue``client/src/pages/ToolDetailPage.vue`,将版本展示切换到 `displayVersion`,为 `none` 模式增加详情跳转分支,并移除详情页无意义的主操作按钮。
- [ ] **步骤 4**:重新执行 `cd client && npm run test:run -- src/App.spec.js src/pages/ToolDetailPage.spec.js`,预期公开站回归通过。
### 任务 5补齐后台表单、列表和概览对新字段的 UI 支持
**文件**
- 修改:`client/src/admin/AdminApp.vue`
- 修改:`client/src/admin/components/ToolFormDialog.vue`
- 修改:`client/src/admin/components/AccessModeDialog.vue`
- 修改:`client/src/admin/components/AdminToolsSection.vue`
- 修改:`client/src/admin/components/AdminOverviewSection.vue`
- 修改:`client/src/admin/stores/console.js`
- 创建:`client/src/admin/components/ToolFormDialog.spec.js`
- 创建:`client/src/admin/components/AccessModeDialog.spec.js`
- 创建:`client/src/admin/components/AdminToolsSection.spec.js`
- 创建:`client/src/admin/components/AdminOverviewSection.spec.js`
- [ ] **步骤 1**:先创建 4 个后台组件测试文件,分别写失败测试,覆盖“工具表单存在展示版本输入项并可选择‘无’”“访问方式对话框切换到 `none` 时隐藏 URL 输入”“工具列表显示版本列和 `none` 标签”“概览模式分布显示 `none` 模式统计”。
- [ ] **步骤 2**:执行 `cd client && npm run test:run -- src/admin/components/ToolFormDialog.spec.js src/admin/components/AccessModeDialog.spec.js src/admin/components/AdminToolsSection.spec.js src/admin/components/AdminOverviewSection.spec.js`,预期后台组件测试失败。
- [ ] **步骤 3**:更新 `client/src/admin/AdminApp.vue``ToolFormDialog.vue``AccessModeDialog.vue``AdminToolsSection.vue``AdminOverviewSection.vue``client/src/admin/stores/console.js`,让后台表单、快速切换、列表和概览统一识别 `versionOverride``displayVersion``noneToolTotal`
- [ ] **步骤 4**:重新执行上面的后台组件测试,并追加执行 `cd client && npm run test:run -- src/App.spec.js src/pages/ToolDetailPage.spec.js``cd server && npm run test -- src/modules/tools/tools.service.spec.ts src/modules/admin-tools/admin-tools.service.spec.ts src/modules/access/access.service.spec.ts src/modules/admin-overview/admin-overview.service.spec.ts`,预期前后端关键回归全部通过。

View File

@@ -0,0 +1,44 @@
# 会话状态
## 基本信息
- **技能**: executing-plans
- **主题**: 用户手册详情页(支持 Markdown
- **开始时间**: 2026-04-11 10:26
- **最后更新**: 2026-04-11 11:28
## 当前状态
- **阶段**: 阶段 3完成与报告
- **上一步**: 已完成全部 6 个任务,并通过前端测试、客户端构建、服务端单测、服务端 e2e 与服务端构建。
- **下一步**: 输出完成报告并结束本轮执行。
## 已确认内容
- 2026-04-11 10:26-需求目标是增加一个新的详情页,可显示 Markdown 格式的用户手册。
- 2026-04-11 10:26-主站当前没有独立详情页,详情内容以弹窗形式展示。
- 2026-04-11 10:26-前端已存在 Markdown 渲染工具,可作为后续方案基础。
- 2026-04-11 10:27-详情页主要用户为普通访客。
- 2026-04-11 10:31-详情页交互方式为站内路由跳转。
- 2026-04-11 10:32-用户手册内容来源先复用现有 `description` 字段承载 Markdown。
- 2026-04-11 10:33-旧的详情弹窗将被新详情页替换。
- 2026-04-11 10:37-本次实现范围包含详情页空状态、404 状态与返回入口体验。
- 2026-04-11 10:39-验收标准为长期可用,要求详情页具备清晰信息结构。
- 2026-04-11 10:42-边界情况需覆盖直达路由、空内容、404 及超长 Markdown 阅读体验。
- 2026-04-11 10:58-最终方案为基于 `slug` 的详情页路由与详情接口。
- 2026-04-11 11:02-规格文档已获批准,开始编写实施计划。
- 2026-04-11 11:05-实施计划已保存,包含 6 个按 TDD 拆分的执行任务。
- 2026-04-11 11:08-执行前检查发现当前分支为 `master`,按技能规则需用户确认后才能继续。
- 2026-04-11 11:09-用户已确认允许在 `master` 分支执行计划,任务 1 开始。
- 2026-04-11 11:12-任务 1 完成:前端测试基建建立,`markdown-outline` 工具和测试通过。
- 2026-04-11 11:17-任务 2 和任务 3 完成:后端 `slug` 详情服务单测和控制器 e2e 通过。
- 2026-04-11 11:28-任务 4、5、6 完成:公开详情页、首页路由跳转、目录阅读体验和后台文案已落地并完成最终验证。
## 待处理问题
- [x] 任务 1前端测试基建和 Markdown 目录工具。
- [x] 任务 2后端按 `slug` 查询详情的服务层能力。
- [x] 任务 3公开详情 `slug` 接口和后端 e2e。
- [x] 任务 4公开详情页路由、API 接入和页面状态。
- [x] 任务 5首页详情弹窗替换为路由跳转。
- [x] 任务 6长文阅读体验、后台提示文案和整体验证。

View File

@@ -0,0 +1,28 @@
# 会话状态
## 基本信息
- **技能**: brainstorming
- **主题**: 下载跳转不应修改当前页 URL
- **开始时间**: 2026-04-13 09:50
- **最后更新**: 2026-04-13 10:02
## 当前状态
- **阶段**: 阶段 3完成与报告
- **上一步**: 已完成前后端修复与前端回归测试补充。
- **下一步**: 输出修复结果,并说明测试环境依赖缺失导致无法实际执行 Vitest。
## 已确认内容
- 2026-04-13 09:46-问题表现为跳转到下载页时,当前访客页 URL 也会被改成下载地址。
- 2026-04-13 09:47-根因位于访客端 launch 逻辑和后端 access launch 响应对下载模式的 same_tab 处理。
- 2026-04-13 09:49-本次采用最小修复,下载模式统一保持当前页不变,仅在新标签页或新窗口执行下载。
- 2026-04-13 09:57-已修改 `AccessService`、首页和详情页 launch 逻辑,并新增前端回归测试用例。
- 2026-04-13 10:01-测试执行受阻:本地 `client/node_modules` 不完整,`vitest` 可执行不存在,且 `npx` 因网络/缓存权限失败无法补装。
## 待处理问题
- [x] 修改后端 launch 响应,下载模式统一返回 `openIn: new_tab`
- [x] 修改首页与详情页前端逻辑,下载模式不再走 `window.location.assign(...)`
- [x] 增加下载模式回归测试,覆盖当前页 URL 不变的行为。

View File

@@ -3,42 +3,70 @@
## 基本信息
- **技能**: executing-plans
- **主题**: 用户手册详情页(支持 Markdown
- **开始时间**: 2026-04-11 10:26
- **最后更新**: 2026-04-11 11:28
- **主题**: 网页工具维护版本字段
- **开始时间**: 2026-04-14 18:11
- **最后更新**: 2026-04-15 09:50
## 当前状态
- **阶段**: 阶段 3完成与报告
- **上一步**: 已完成全部 6 个任务,并通过前端测试、客户端构建、服务端单测、服务端 e2e 与服务端构建
- **下一步**: 输出完成报告并结束本轮执行
- **阶段**: 阶段 2执行任务
- **上一步**: 已完成任务 5后台表单、列表和概览已补齐 `versionOverride` / `displayVersion` / `none` 模式 UI 支持,并完成前端回归
- **下一步**: 等待用户验收;如继续扩展,可补管理端更高层级交互测试或继续清理构建体积告警
## 已确认内容
- 2026-04-11 10:26-需求目标是增加一个新的详情页,可显示 Markdown 格式的用户手册
- 2026-04-11 10:26-主站当前没有独立详情页,详情内容以弹窗形式展示
- 2026-04-11 10:26-前端已存在 Markdown 渲染工具,可作为后续方案基础
- 2026-04-11 10:27-详情页主要用户为普通访客
- 2026-04-11 10:31-详情页交互方式为站内路由跳转
- 2026-04-11 10:32-用户手册内容来源先复用现有 `description` 字段承载 Markdown
- 2026-04-11 10:33-旧的详情弹窗将被新详情页替换
- 2026-04-11 10:37-本次实现范围包含详情页空状态、404 状态与返回入口体验
- 2026-04-11 10:39-验收标准为长期可用,要求详情页具备清晰信息结构
- 2026-04-11 10:42-边界情况需覆盖直达路由、空内容、404 及超长 Markdown 阅读体验
- 2026-04-11 10:58-最终方案为基于 `slug` 的详情页路由与详情接口
- 2026-04-11 11:02-规格文档已获批准,开始编写实施计划
- 2026-04-11 11:05-实施计划已保存,包含 6 个按 TDD 拆分的执行任务
- 2026-04-11 11:08-执行前检查发现当前分支为 `master`,按技能规则需用户确认后才能继续
- 2026-04-11 11:09-用户已确认允许在 `master` 分支执行计划,任务 1 开始
- 2026-04-11 11:12-任务 1 完成:前端测试基建建立,`markdown-outline` 工具和测试通过
- 2026-04-11 11:17-任务 2 和任务 3 完成:后端 `slug` 详情服务单测和控制器 e2e 通过
- 2026-04-11 11:28-任务 4、5、6 完成:公开详情页、首页路由跳转、目录阅读体验和后台文案已落地并完成最终验证
- 2026-04-14 18:06-项目为 Vue 3 + NestJS + Prisma 的工具站,工具实体区分 `web``download` 两种访问方式
- 2026-04-14 18:08-下载工具的版本来自 `ToolArtifact.version`,网页工具 `Tool` 本体目前没有独立版本字段
- 2026-04-14 18:10-前台首页和详情页当前都显示“版本”,但仅下载工具会返回 `latestVersion`,网页工具会显示“暂无版本”
- 2026-04-14 18:11-后台新增/编辑工具表单当前没有网页工具版本输入项
- 2026-04-14 18:13-用户确认版本字段采用“后台维护 + 前台展示”方案,需在访客端列表页和详情页展示
- 2026-04-14 18:15-用户选择混合模式:`web` 工具有独立版本字段,`download` 工具默认取 `latestArtifact.version`,并允许单独覆盖展示版本
- 2026-04-14 18:15-访问方式需要新增“无”模式,该模式也要支持维护版本
- 2026-04-14 18:17-关于“无”模式的访客端行为,用户连续回复了 `1``3`,当前需要进一步确认最终选择
- 2026-04-14 18:18-用户最终确认“无”模式采用详情引导:列表页主按钮为“查看详情”,详情页不提供打开/下载按钮
- 2026-04-14 18:20-用户确认版本字段仅用于展示,不参与发布校验,现有发布规则保持不变
- 2026-04-14 18:21-用户确认完成范围为“常规完整”:后台工具列表也显示版本和访问方式 `none`,相关接口统一返回展示版本
- 2026-04-14 18:24-用户选择方案 2使用 `Tool.versionOverride` 存储覆盖版本,由服务端统一计算 `displayVersion`
- 2026-04-14 18:29-规格文档已写入 `design/specs/2026-04-14-tool-display-version-none-mode-design.md`
- 2026-04-14 18:43-实施计划已写入 `design/plans/2026-04-14-tool-display-version-none-mode-plan.md`,按单一子系统拆为 5 个任务
- 2026-04-14 18:49-已切换到 `executing-plans` 技能并完成计划审阅;当前分支为 `master`,按执行规范需用户确认后才能继续执行
- 2026-04-14 19:20-用户确认继续在 `master` 上执行;任务 1 完成`ToolsService` 与公开详情 e2e 已验证 `displayVersion`,本地数据库已手工应用 `version_override`
- 2026-04-14 20:10-任务 2 已完成,后台 DTO、后台工具服务与种子数据已支持 `versionOverride``accessMode=none`,并通过相关 Jest 用例
- 2026-04-14 20:40-任务 3 已完成,`none` 模式 launch 已在服务端返回 `409`,后台概览已补 `noneToolTotal`,相关单测与 e2e 已通过
- 2026-04-15 09:27-任务 4 已完成,公开站首页与详情页已统一展示 `displayVersion``none` 模式按“查看详情/无主按钮”行为闭合,公共前端 Vitest 已通过。
- 2026-04-15 09:49-任务 5 已完成,管理端表单新增展示版本输入,访问方式支持“无”,工具列表新增版本列,概览显示 `none` 模式统计;新增 4 个管理端组件测试并通过,前端构建通过。
## 待处理问题
- [x] 任务 1前端测试基建和 Markdown 目录工具
- [x] 任务 2后端按 `slug` 查询详情的服务层能力
- [x] 任务 3公开详情 `slug` 接口和后端 e2e
- [x] 任务 4公开详情页路由、API 接入和页面状态
- [x] 任务 5首页详情弹窗替换为路由跳转
- [x] 任务 6长文阅读体验、后台提示文案和整体验证。
- [x] 网页工具版本字段的主要使用对象是谁,以及它需要出现在哪些页面或接口里
- [x] 版本字段是否仅适用于 `web` 模式,还是所有工具都应统一维护一个主版本
- [x] 版本字段是否参与发布校验、排序、筛选或审计记录
- [x] “无”访问模式在访客端的按钮、详情页主操作和可见性规则
- [x] 版本字段和“无”模式需要覆盖到哪些后台页面和接口返回
## 执行状态
### 计划信息
- **计划文档**: `design/plans/2026-04-14-tool-display-version-none-mode-plan.md`
- **开始时间**: 2026-04-14 18:49
### 任务进度
| 任务 | 状态 | 完成时间 | 备注 |
|------|------|----------|------|
| 任务1先锁定 Prisma 契约和公开接口版本映射 | 已完成 | 19:20 | `schema-engine` 被沙箱拦截,改为手工 migration SQL + raw SQL 应用列变更 |
| 任务2补齐后台工具服务对 `versionOverride``none` 的支持 | 已完成 | 2026-04-14 20:10 | 后台 DTO/服务/seed 已对齐,并通过 Jest 回归 |
| 任务3封闭 `none` 模式的 launch 语义并补齐概览统计 | 已完成 | 2026-04-14 20:40 | `none` launch 返回 `409`,概览统计补齐,单测与 e2e 已通过 |
| 任务4更新公开站首页与详情页统一消费 `displayVersion` | 已完成 | 2026-04-15 09:27 | 列表/详情统一显示 `displayVersion``none` 模式行为闭合Vitest 通过 |
| 任务5补齐后台表单、列表和概览对新字段的 UI 支持 | 已完成 | 2026-04-15 09:49 | 表单/访问方式/列表/概览已支持 `versionOverride``none`,组件测试与构建通过 |
### 遇到的问题
| 问题 | 解决方案 | 解决时间 |
|------|----------|----------|
| 当前工作分支为 `master`,执行规范要求先确认是否允许直接在主分支改动 | 用户已明确确认继续在 `master` 上执行 | 19:20 |
| `jest` 默认 worker 在沙箱中触发 `spawn EPERM` | 改用 `npx jest --runInBand` 单进程执行测试 | 19:14 |
| `prisma migrate dev``schema-engine` 在沙箱中触发 `spawn EPERM` | 手工创建 migration 文件,并通过 Prisma raw SQL 为本地 SQLite 添加 `version_override` 列 | 19:18 |
| `client` 本地缺少 `vitest` 依赖,`npm run test:run` 无法直接执行 | 在 `client` 目录执行 `npm install --no-save vitest@3.2.4` 后,统一使用 `npx vitest run ...` 执行前端测试 | 2026-04-15 09:00 |
| Element Plus 的 `dialog/table` 在 jsdom 下不稳定渲染测试文本 | 为管理端组件测试增加轻量 stub直接覆盖组件分支逻辑避免把断言绑定到第三方渲染细节 | 2026-04-15 09:46 |

View File

@@ -0,0 +1,56 @@
# 下载跳转 URL 修复设计
## 概述
当前访客端在触发下载型工具时,会沿用网页型工具的 `same_tab` 导航分支。若后台将下载工具配置为非新标签页,前端会直接执行 `window.location.assign(actionUrl)`,导致当前 SPA 页面 URL 被改写成下载地址,破坏返回和继续浏览体验。
本次修复目标是保证下载行为不会修改当前访客页的 URL。下载应在新标签页或浏览器新窗口中完成当前页继续停留在工具列表页或详情页。
## 目标定位
主要用户为访客端普通用户,在浏览工具列表或详情页时点击下载操作。
## 约束条件
- 保持现有网页模式 `web` 的同标签/新标签配置能力不变。
- 保持已有下载埋点与下载票据逻辑不变。
- 变更范围尽量小,优先修复公开访客端入口,不扩展后台交互。
## 成功标准
- 下载型工具触发后,当前页 URL 保持不变。
- 首页和详情页的下载按钮行为一致。
- 网页型工具仍按现有 `openIn` 配置运行。
- 新增自动化测试能覆盖下载模式不走当前页跳转。
## 架构设计
- 后端 `AccessService.launchTool`
- 对下载模式统一返回 `openIn: 'new_tab'`
- 包括外部下载地址和票据下载两种分支。
- 前端 `App.vue``ToolDetailPage.vue`
- 仅网页模式 `web + same_tab` 执行 `window.location.assign(...)`
- 下载模式始终使用 `window.open(...)`,不再降级到当前页跳转。
- 若浏览器阻止弹窗,则提示用户允许新窗口,而不是改写当前页 URL。
## 数据流
1. 用户点击下载按钮。
2. 前端调用 `/tools/:id/launch`
3. 后端返回 `mode: download``openIn: new_tab`
4. 前端使用 `window.open(actionUrl, '_blank', 'noopener,noreferrer')` 发起下载。
5. 当前 SPA 路由保持原状。
## 错误处理
-`actionUrl` 为空,维持现有错误提示。
- 若浏览器拦截新窗口,显示明确提示,不执行 `location.assign`
## 测试策略
- 前端单测覆盖首页下载按钮:
- `download + same_tab` 输入场景下,断言不会调用 `window.location.assign`
- 断言 `window.open` 被调用并保持当前路由不变。
- 前端单测覆盖详情页下载按钮:
- 断言下载模式不会调用 `window.location.assign`
- 服务端单测如已有 launch 行为覆盖,则补充下载模式 `openIn` 断言;若当前模块无单测,则至少保证前端回归测试覆盖核心用户路径。

View File

@@ -0,0 +1,152 @@
# 工具展示版本与无访问模式设计规格
## 概述
当前工具站只区分 `web``download` 两种访问方式。下载工具的版本来源于 `ToolArtifact.version`,因此访客端首页和详情页中的“版本”信息只对下载工具有效,网页工具长期显示“暂无版本”。与此同时,当前系统没有“仅展示详情、不提供打开或下载入口”的访问模式,无法承载预告型、资料型或人工分发型工具。
本次设计目标是在现有 `Tool` 模型上补齐“展示版本”和“无访问模式”能力,保持下载包管理逻辑不变,同时让前台和后台统一拿到一个稳定的展示版本字段。具体策略为:数据库新增 `Tool.versionOverride` 作为人工维护的展示版本覆盖值;服务端统一计算 `displayVersion`;访问方式新增 `none`,用于“只展示详情,不执行 launch”的工具。
## 目标定位
- **主要用户**:后台管理员、公开站访客
- **使用场景**:管理员在新增或编辑工具时维护展示版本,并可将工具设置为 `download``web``none`;访客在首页和详情页查看统一的版本信息,其中 `none` 模式工具只提供“查看详情”入口,不提供打开网页或下载操作
## 约束条件
- **技术限制**:前端基于 Vue 3 + Element Plus服务端基于 NestJS + Prisma + SQLite本次只做现有模型上的增量改造不引入新的依赖或新的版本历史表
- **时间限制**:本轮只实现版本展示和 `none` 访问模式,不扩展版本历史、发布日期、版本说明等二级能力
- **资源限制**:继续复用现有下载包模型、公开列表/详情接口和后台工具管理流程;版本字段仅用于展示,不参与发布校验、不新增额外审核流程
## 成功标准
- [ ] Prisma 数据模型支持 `AccessMode.none``Tool.versionOverride`
- [ ] 后台新增/编辑工具表单可以维护“展示版本”,访问方式下拉可选择“无”
- [ ] 后台工具列表接口和公开工具列表/详情接口统一返回 `displayVersion`
- [ ] 下载工具的 `displayVersion` 计算规则为 `versionOverride || latestArtifact.version || null`
- [ ] 网页工具和无访问模式工具的 `displayVersion` 计算规则为 `versionOverride || null`
- [ ] 访客端首页和详情页统一展示 `displayVersion`,不再依赖下载模式专属的 `latestVersion`
- [ ] `none` 模式在首页主按钮显示“查看详情”,详情页不展示打开/下载主按钮
- [ ] 直接调用 `launch` 接口访问 `none` 模式工具时返回明确错误,且不会产生打开/下载计数
- [ ] 现有发布约束保持不变:`web` 仍要求 `openUrl``download` 仍要求安装包或下载地址,`none` 不因为缺少版本而被阻止发布
- [ ] 后台概览和模式标签不再把 `none` 模式误显示为“下载模式”
## 架构设计
### 系统结构
本次改造分为四层:
1. **数据层**:在 `Tool` 模型中新增 `versionOverride` 字段;在 `AccessMode` 枚举中新增 `none`
2. **映射层**:在后台服务和公开服务中统一计算 `displayVersion`,将“原始覆盖值”和“最终展示值”分离
3. **行为层**:在 `AccessService` 中显式拒绝 `none` 模式的 launch 行为,保证服务端语义闭合
4. **展示层**:后台编辑页维护 `versionOverride`,后台列表和访客端页面统一展示 `displayVersion`
版本数据的核心原则如下:
1. `versionOverride` 是管理员手工维护的原始字段,允许为空
2. `displayVersion` 是接口返回给前端的最终展示值,不直接持久化
3. 对下载工具,若 `versionOverride` 为空,则自动回退到当前 `latestArtifact.version`
4. 对网页工具和无访问模式工具,`displayVersion` 只来自 `versionOverride`
5. `latestArtifact.version` 继续用于安装包管理;`displayVersion` 用于页面展示
访问模式的核心原则如下:
1. 新增枚举值 `none`,内部值固定为 `none`,后台展示文案为“无”
2. `none` 模式工具允许存在详情页和展示版本,但不允许执行 launch
3. 访客端列表页对 `none` 模式的主按钮改为“查看详情”
4. 访客端详情页对 `none` 模式不渲染打开/下载按钮
5. 服务端若收到对 `none` 模式的 launch 请求,返回 `409 Conflict`
### 组件划分
| 组件 | 职责 | 依赖 |
|------|------|------|
| `server/prisma/schema.prisma` | 为 `AccessMode` 增加 `none`;为 `Tool` 增加 `versionOverride` 字段 | Prisma |
| `server/prisma/seed.ts` | 补齐种子数据中的访问方式和展示版本样例,避免本地初始化后看不到新行为 | Prisma Seed |
| `server/src/modules/admin-tools/dto/create-tool.dto.ts` | 接收 `versionOverride`,并允许 `accessMode=none` | `class-validator` |
| `server/src/modules/admin-tools/dto/update-tool.dto.ts` | 继承新增字段,支持后台更新展示版本 | `CreateToolDto` |
| `server/src/modules/admin-tools/dto/update-access-mode.dto.ts` | 接收 `none` 枚举值,兼容访问方式切换 | `class-validator` |
| `server/src/modules/admin-tools/admin-tools.service.ts` | 规范化 `versionOverride`;列表和详情返回 `displayVersion``versionOverride`;保持发布校验逻辑不因版本字段变化 | Prisma |
| `server/src/modules/tools/tools.service.ts` | 公开列表和详情统一返回 `displayVersion`;保留下载工具的 `latestVersion` 作为兼容字段 | Prisma |
| `server/src/modules/access/access.service.ts` | 对 `none` 模式返回明确错误,禁止生成 launch 地址或下载票据 | Prisma, ConfigService |
| `server/src/modules/admin-overview/admin-overview.service.ts` | 访问方式统计兼容 `none`,避免概览数据遗漏或模式显示错误 | Prisma |
| `client/src/admin/components/ToolFormDialog.vue` | 新增“展示版本”输入项;访问方式下拉新增“无”;根据模式调整提示文案 | Element Plus |
| `client/src/admin/components/AdminToolsSection.vue` | 列表增加版本列,访问方式标签兼容 `none` 文案 | Element Plus |
| `client/src/admin/components/AccessModeDialog.vue` | 快速修改访问方式时允许选择“无”,并在 `none` 模式下隐藏 URL 输入项 | Element Plus |
| `client/src/admin/AdminApp.vue` | 表单数据结构新增 `versionOverride`;构造创建/更新 payload访问模式、标签颜色和模式文案兼容 `none` | Vue 3 |
| `client/src/App.vue` | 工具卡片统一显示 `displayVersion``none` 模式主按钮改为“查看详情”并直接路由跳转 | `vue-router`, `client/src/api.js` |
| `client/src/pages/ToolDetailPage.vue` | 摘要区展示 `displayVersion``none` 模式不显示主操作按钮 | `vue-router`, `client/src/api.js` |
| `client/src/admin/components/AdminOverviewSection.vue` | 模式统计与 Top 工具模式文案兼容 `none` | Element Plus |
## 数据流
### 版本字段维护流
1. 管理员在新增或编辑工具时填写“展示版本”
2. 前端将该值作为 `versionOverride` 提交给后台;空字符串在提交前或服务端落库前统一归一化为 `null`
3. 服务端保存 `Tool.versionOverride`
4. 当后台列表、后台详情、公开列表或公开详情查询工具时,服务端统一计算 `displayVersion`
5. 前端页面一律渲染 `displayVersion`,不再直接依赖下载工具专属的 `latestVersion`
`displayVersion` 计算规则如下:
1.`tool.accessMode === 'download'` 时:`displayVersion = tool.versionOverride || tool.latestArtifact?.version || null`
2.`tool.accessMode === 'web'``tool.accessMode === 'none'` 时:`displayVersion = tool.versionOverride || null`
为降低回归风险,下载工具响应中的 `latestVersion` 字段继续保留,其语义保持为“当前最新安装包版本”;页面展示逻辑统一切换为 `displayVersion`
### 访问模式切换流
1. 管理员在新增/编辑表单或访问方式对话框中选择 `download``web``none`
2. 当模式为 `web` 时,沿用现有规则,发布时仍要求 `openUrl`
3. 当模式为 `download` 时,沿用现有规则,发布时仍要求活动安装包或外部下载地址
4. 当模式为 `none` 时,不生成 launch 目标,不要求 `openUrl`,前端不展示打开/下载按钮
5. 当公开站列表遇到 `none` 模式工具时,主按钮直接路由到详情页
6. 当公开站详情页遇到 `none` 模式工具时,仅展示内容和版本,不发起 launch 请求
7. 若外部调用者直接请求 `POST /api/v1/tools/:id/launch` 且目标工具为 `none` 模式,服务端返回 `409`
### 前后台显示流
1. 后台工具列表新增版本列,显示 `row.displayVersion || '-'`
2. 公开首页卡片“版本”行显示 `tool.displayVersion || '暂无版本'`
3. 公开详情页摘要区显示 `detail.displayVersion || '暂无版本'`
4. `none` 模式的卡片副文案和详情摘要不再显示“下载/访问”动词,统一显示“仅详情”
5. 概览和模式标签遇到 `none` 时显示“无”,不将其归并到下载模式
## 错误处理
| 错误类型 | 处理方式 |
|----------|----------|
| `versionOverride` 为空字符串 | 前端提交前或服务端保存前统一归一化为 `null`,避免空串污染数据 |
| 下载工具没有覆盖版本且没有最新安装包版本 | `displayVersion` 返回 `null`,前端显示“暂无版本”,但不阻止发布之外的页面展示 |
| `none` 模式被直接调用 launch 接口 | 服务端返回 `409 Conflict`,建议复用 `TOOL_ACCESS_MODE_MISMATCH` 并提供明确消息如“tool is not launchable in none mode” |
| 访问方式统计遇到 `none` | 后台概览必须显式处理该值,避免统计遗漏或错误文案 |
| 旧前端代码仍读取 `latestVersion` | 下载工具继续保留该字段,版本展示迁移到 `displayVersion` 后逐步收敛消费端 |
| 管理员把工具切换为 `none` | 页面行为以模式为准,版本仍可展示,但公开站不再允许打开/下载 |
## 风险评估
| 风险描述 | 类别 | 影响 | 概率 | 缓解策略 |
|----------|------|------|------|----------|
| 新增 `none` 枚举后,现有前端分支把未知模式错误地当成下载模式处理 | 集成风险 | 高 | 中 | 全量检查 `accessMode` 条件分支,补齐 `none` 的按钮文案、标签颜色和统计展示 |
| 下载工具同时存在 `latestArtifact.version``versionOverride`,容易出现展示口径不一致 | 技术风险 | 中 | 中 | 服务端统一生成 `displayVersion`,前端禁止自行拼接版本来源 |
| 旧接口调用方继续消费 `latestVersion`,导致 web/none 工具仍显示“暂无版本” | 范围风险 | 中 | 中 | 在页面和后台列表统一切到 `displayVersion`,同时短期保留 `latestVersion` 兼容下载工具 |
| 增加 `none` 模式后,后台概览和模式统计遗漏该值 | 集成风险 | 中 | 中 | 同步更新概览服务、模式标签和概览页面文案,避免出现统计不闭合 |
## 测试策略
- **单元测试**:为服务端新增版本解析逻辑编写测试,覆盖三种场景:下载工具无覆盖值时回退安装包版本、下载工具有覆盖值时优先显示覆盖值、`web/none` 工具只使用覆盖值
- **集成测试**:覆盖后台创建/更新工具时提交 `versionOverride``accessMode=none`;覆盖公开列表和详情接口返回 `displayVersion`;覆盖 `none` 模式 launch 接口返回 `409`
- **前端组件测试**:覆盖首页卡片在 `none` 模式下显示“查看详情”;覆盖详情页在 `none` 模式下不渲染主操作按钮;覆盖版本展示优先使用 `displayVersion`
- **验收标准**:管理员可维护展示版本;后台工具列表可看到版本;访客端列表与详情都能看到统一版本;下载工具默认继承安装包版本;`none` 模式只可查看详情、不可 launch后台概览与模式文案不出现错误映射
## 决策记录
| 决策 | 理由 | 影响 |
|------|------|------|
| 使用 `Tool.versionOverride + displayVersion`,而不是为所有工具新建统一版本表 | 当前需求只需要单值展示和少量覆盖能力,不需要版本历史,增量成本最低 | 需要在服务端增加统一映射逻辑 |
| `displayVersion` 由服务端计算,不在数据库落地 | 避免前端自行拼接版本来源,减少 download/web/none 三种模式的歧义 | 后台和公开接口都要增加该字段 |
| 下载工具保留 `latestVersion`,但页面展示切到 `displayVersion` | 降低旧代码回归风险,同时给新页面一个统一字段 | 服务端响应短期内会同时存在两个版本相关字段 |
| 访问方式新增 `none`,而不是用布尔开关控制是否可 launch | 与现有 `AccessMode` 模型保持一致,语义清晰,便于后台筛选和统计 | Prisma 枚举、前后端条件分支和概览统计都需要更新 |
| `none` 模式首页主按钮跳详情页,详情页不展示主操作按钮 | 用户已明确选择详情引导型行为,比“禁用按钮”更可用 | 公开站首页和详情页都需增加 `none` 分支 |
| 版本字段不参与发布校验 | 用户明确要求版本只作展示,不希望改变当前发布约束 | `assertPublishInput` 和状态流保持现状,只需兼容 `none` 模式 |

View File

@@ -0,0 +1,4 @@
-- SQLite stores Prisma enums as TEXT, so AccessMode.none does not require a
-- physical enum migration here. The only schema change needed is the display
-- version override column on tools.
ALTER TABLE "tools" ADD COLUMN "version_override" TEXT;

BIN
server/prisma/prisma.zip Normal file

Binary file not shown.

View File

@@ -10,6 +10,7 @@ datasource db {
enum AccessMode {
web
download
none
}
enum ToolStatus {
@@ -45,6 +46,7 @@ model Tool {
openCount Int @default(0) @map("open_count")
accessMode AccessMode @default(download) @map("access_mode")
openUrl String? @map("open_url")
versionOverride String? @map("version_override")
openInNewTab Boolean @default(true) @map("open_in_new_tab")
latestArtifactId String? @map("latest_artifact_id")
status ToolStatus @default(draft)

View File

@@ -38,6 +38,7 @@ type ToolSeed = {
openCount: number;
accessMode: AccessMode;
openUrl?: string;
versionOverride?: string;
openInNewTab?: boolean;
status: ToolStatus;
createdAt: Date;
@@ -132,6 +133,7 @@ async function createToolWithRelations(tool: ToolSeed) {
openCount: tool.openCount,
accessMode: tool.accessMode,
openUrl: tool.openUrl,
versionOverride: tool.versionOverride,
openInNewTab: tool.openInNewTab ?? true,
status: tool.status,
createdAt: tool.createdAt,
@@ -251,6 +253,7 @@ async function main() {
openCount: 642,
accessMode: AccessMode.web,
openUrl: 'https://platform.openai.com/playground',
versionOverride: '2026.04',
openInNewTab: true,
status: ToolStatus.published,
createdAt: daysAgo(45, 10, 0),
@@ -282,6 +285,7 @@ async function main() {
openCount: 438,
accessMode: AccessMode.web,
openUrl: 'https://claude.ai',
versionOverride: '2026.03',
openInNewTab: true,
status: ToolStatus.published,
createdAt: daysAgo(31, 11, 0),
@@ -344,6 +348,7 @@ async function main() {
openCount: 196,
accessMode: AccessMode.web,
openUrl: 'https://atlas.example.internal/runbooks',
versionOverride: 'atlas-3.2',
openInNewTab: false,
status: ToolStatus.published,
createdAt: daysAgo(19, 9, 0),
@@ -379,6 +384,7 @@ async function main() {
openCount: 153,
accessMode: AccessMode.web,
openUrl: 'https://www.notion.so',
versionOverride: 'workflow-1.8',
openInNewTab: true,
status: ToolStatus.published,
createdAt: daysAgo(12, 13, 30),
@@ -413,6 +419,7 @@ async function main() {
downloadCount: 182,
openCount: 0,
accessMode: AccessMode.download,
versionOverride: 'stable-1.1',
status: ToolStatus.published,
createdAt: daysAgo(40, 10, 15),
updatedAt: dateOnly(daysAgo(0)),
@@ -515,6 +522,7 @@ async function main() {
downloadCount: 89,
openCount: 0,
accessMode: AccessMode.download,
versionOverride: 'stable-2.1',
status: ToolStatus.published,
createdAt: daysAgo(26, 9, 40),
updatedAt: dateOnly(daysAgo(4)),
@@ -569,6 +577,40 @@ async function main() {
],
latestArtifactId: 'art_202',
},
{
id: 'tool_none_001',
name: 'Prompt Catalog Preview',
slug: 'prompt-catalog-preview',
categoryId: 'cat_ai',
description:
'Preview-only catalog for upcoming internal prompt packs, curation notes, and launch plans.',
rating: 4.1,
downloadCount: 0,
openCount: 0,
accessMode: AccessMode.none,
versionOverride: 'preview-2026.04',
status: ToolStatus.published,
createdAt: daysAgo(7, 15, 20),
updatedAt: dateOnly(daysAgo(0)),
features: [
{
id: 'feat_none_001',
featureText: 'Upcoming prompt pack roadmap',
sortOrder: 10,
},
{
id: 'feat_none_002',
featureText: 'Preview-only release notes',
sortOrder: 20,
},
{
id: 'feat_none_003',
featureText: 'Owner and rollout visibility',
sortOrder: 30,
},
],
tagIds: ['tag_new', 'tag_team'],
},
{
id: 'tool_draft_001',
name: 'Local Agent Kit',

View File

@@ -1,5 +1,26 @@
const { spawn, spawnSync } = require('child_process');
const IS_WINDOWS = process.platform === 'win32';
function quoteWindowsArg(value) {
const text = String(value ?? '');
if (text.length === 0) {
return '""';
}
if (!/[\s"&()^<>|]/.test(text)) {
return text;
}
return `"${text
.replace(/(\\*)"/g, '$1$1\\"')
.replace(/(\\+)$/g, '$1$1')}"`;
}
function buildWindowsCommand(command, args) {
return [command, ...args].map(quoteWindowsArg).join(' ');
}
function normalizeWrappedEnv(name) {
const value = process.env[name];
if (!value || value.length < 2) {
@@ -14,11 +35,17 @@ function normalizeWrappedEnv(name) {
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
stdio: 'inherit',
env: process.env,
...options,
});
const result = IS_WINDOWS
? spawnSync('cmd.exe', ['/d', '/s', '/c', buildWindowsCommand(command, args)], {
stdio: 'inherit',
env: process.env,
...options,
})
: spawnSync(command, args, {
stdio: 'inherit',
env: process.env,
...options,
});
if (result.error) {
throw result.error;
@@ -29,23 +56,34 @@ function run(command, args, options = {}) {
}
}
function launch(command, args, options = {}) {
return IS_WINDOWS
? spawn('cmd.exe', ['/d', '/s', '/c', buildWindowsCommand(command, args)], {
stdio: 'inherit',
env: process.env,
...options,
})
: spawn(command, args, {
stdio: 'inherit',
env: process.env,
...options,
});
}
function main() {
if (!process.env.DATABASE_URL) {
process.env.DATABASE_URL = 'file:./dev.db';
}
normalizeWrappedEnv('DATABASE_URL');
run(process.platform === 'win32' ? 'npx.cmd' : 'npx', ['prisma', 'migrate', 'deploy']);
run('npx', ['prisma', 'migrate', 'deploy']);
const args = process.argv.slice(2);
if (args.length === 0) {
return;
}
const child = spawn(args[0], args.slice(1), {
stdio: 'inherit',
env: process.env,
});
const child = launch(args[0], args.slice(1));
child.on('exit', (code, signal) => {
if (signal) {

View File

@@ -0,0 +1,128 @@
import { AccessMode, ArtifactStatus, ToolStatus } from '@prisma/client';
import { ERROR_CODES } from '../../common/constants/error-codes';
import { AppException } from '../../common/exceptions/app.exception';
import type { RequestWithContext } from '../../common/interfaces/request-with-context.interface';
import { AccessService } from './access.service';
function createRequest(): RequestWithContext {
return {
headers: {
'user-agent': 'Vitest',
},
ip: '127.0.0.1',
} as RequestWithContext;
}
function createTool(overrides = {}) {
return {
id: 'tool_demo',
accessMode: AccessMode.web,
openUrl: 'https://example.com/tool',
openInNewTab: true,
isDeleted: false,
status: ToolStatus.published,
latestArtifact: null,
...overrides,
};
}
describe('AccessService', () => {
function createService() {
const findFirst = jest.fn();
const create = jest.fn();
const prisma = {
tool: {
findFirst,
},
downloadTicket: {
create,
},
};
const configService = {
get: jest.fn((key: string, fallback: number) => fallback),
};
return {
prisma,
configService,
service: new AccessService(prisma as never, configService as never),
};
}
it('rejects launch requests for none mode tools', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createTool({
accessMode: AccessMode.none,
openUrl: null,
}),
);
await expect(
service.launchTool(
'tool_demo',
{ channel: 'official', clientVersion: 'web-1.0.0' },
createRequest(),
),
).rejects.toMatchObject({
errorCode: ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
} satisfies Partial<AppException>);
await expect(
service.launchTool(
'tool_demo',
{ channel: 'official', clientVersion: 'web-1.0.0' },
createRequest(),
),
).rejects.toHaveProperty('status', 409);
});
it('keeps web launch behavior unchanged for launchable tools', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createTool({
accessMode: AccessMode.web,
openUrl: 'https://example.com/tool',
openInNewTab: false,
}),
);
await expect(
service.launchTool(
'tool_demo',
{ channel: 'official', clientVersion: 'web-1.0.0' },
createRequest(),
),
).resolves.toMatchObject({
mode: 'web',
actionUrl: 'https://example.com/tool',
openIn: 'same_tab',
});
});
it('keeps download ticket launches available for download tools', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createTool({
accessMode: AccessMode.download,
openUrl: null,
latestArtifact: {
id: 'art_demo',
status: ArtifactStatus.active,
},
}),
);
await expect(
service.launchTool(
'tool_demo',
{ channel: 'official', clientVersion: 'desktop-1.0.0' },
createRequest(),
),
).resolves.toMatchObject({
mode: 'download',
actionUrl: expect.stringContaining('/api/v1/downloads/'),
openIn: 'new_tab',
});
});
});

View File

@@ -1,6 +1,6 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { ArtifactStatus, ToolStatus } from '@prisma/client';
import { AccessMode, ArtifactStatus, ToolStatus } from '@prisma/client';
import { randomUUID } from 'crypto';
import { ERROR_CODES } from '../../common/constants/error-codes';
import { AppException } from '../../common/exceptions/app.exception';
@@ -32,6 +32,14 @@ export class AccessService {
throw new AppException(ERROR_CODES.NOT_FOUND, 'tool not found', HttpStatus.NOT_FOUND);
}
if (tool.accessMode === AccessMode.none) {
throw new AppException(
ERROR_CODES.TOOL_ACCESS_MODE_MISMATCH,
'tool is not launchable in none mode',
HttpStatus.CONFLICT,
);
}
if (tool.accessMode === 'web') {
if (!tool.openUrl) {
throw new AppException(
@@ -52,7 +60,7 @@ export class AccessService {
return {
mode: 'download' as const,
actionUrl: tool.openUrl,
openIn: tool.openInNewTab ? 'new_tab' : 'same_tab',
openIn: 'new_tab' as const,
};
}

View File

@@ -0,0 +1,111 @@
import { AccessMode, ArtifactStatus, ToolStatus } from '@prisma/client';
import { AdminOverviewService } from './admin-overview.service';
describe('AdminOverviewService', () => {
function createService() {
const prisma = {
category: {
count: jest.fn().mockResolvedValue(4),
},
tag: {
count: jest.fn().mockResolvedValue(8),
},
toolArtifact: {
count: jest
.fn()
.mockResolvedValueOnce(5)
.mockResolvedValueOnce(3),
},
adminAuditLog: {
count: jest.fn().mockResolvedValue(12),
},
tool: {
count: jest.fn().mockResolvedValue(2),
groupBy: jest
.fn()
.mockResolvedValueOnce([
{ status: ToolStatus.draft, _count: { _all: 1 } },
{ status: ToolStatus.published, _count: { _all: 2 } },
{ status: ToolStatus.archived, _count: { _all: 1 } },
])
.mockResolvedValueOnce([
{ accessMode: AccessMode.download, _count: { _all: 2 } },
{ accessMode: AccessMode.web, _count: { _all: 3 } },
{ accessMode: AccessMode.none, _count: { _all: 1 } },
]),
aggregate: jest.fn().mockResolvedValue({
_sum: {
openCount: 23,
downloadCount: 7,
},
}),
findMany: jest.fn().mockResolvedValue([
{
id: 'tool_none',
name: 'Prompt Catalog Preview',
categoryId: 'cat_ai',
category: {
name: 'AI',
},
accessMode: AccessMode.none,
openCount: 0,
downloadCount: 0,
modifiedAt: new Date('2026-04-14T00:00:00.000Z'),
},
{
id: 'tool_web',
name: 'OpenAI Playground',
categoryId: 'cat_ai',
category: {
name: 'AI',
},
accessMode: AccessMode.web,
openCount: 23,
downloadCount: 0,
modifiedAt: new Date('2026-04-14T00:00:00.000Z'),
},
]),
},
openRecord: {
count: jest.fn(),
},
downloadRecord: {
count: jest.fn(),
},
$transaction: jest
.fn()
.mockImplementation(async (operations: Array<Promise<number>>) => Promise.all(operations)),
};
prisma.openRecord.count.mockResolvedValue(0);
prisma.downloadRecord.count.mockResolvedValue(0);
prisma.adminAuditLog.count
.mockResolvedValueOnce(12)
.mockResolvedValue(0);
return {
prisma,
service: new AdminOverviewService(prisma as never),
};
}
it('returns noneToolTotal in summary and keeps none mode separate from downloads', async () => {
const { service } = createService();
await expect(service.getOverview()).resolves.toMatchObject({
summary: expect.objectContaining({
toolTotal: 4,
webToolTotal: 3,
downloadToolTotal: 2,
noneToolTotal: 1,
downloadReadyToolTotal: 2,
}),
topTools: expect.arrayContaining([
expect.objectContaining({
id: 'tool_none',
accessMode: AccessMode.none,
}),
]),
});
});
});

View File

@@ -141,6 +141,7 @@ export class AdminOverviewService {
const modeStats = {
web: 0,
download: 0,
none: 0,
};
accessModeBuckets.forEach((item) => {
modeStats[item.accessMode] = item._count._all;
@@ -219,6 +220,7 @@ export class AdminOverviewService {
tagTotal,
webToolTotal: modeStats.web,
downloadToolTotal: modeStats.download,
noneToolTotal: modeStats.none,
downloadReadyToolTotal,
openTotal,
downloadTotal,

View File

@@ -0,0 +1,232 @@
import { AccessMode, ArtifactStatus, Prisma, ToolStatus } from '@prisma/client';
import { AdminToolsService } from './admin-tools.service';
function createToolEntity(overrides = {}) {
return {
id: 'tool_demo',
name: 'Demo Tool',
slug: 'demo-tool',
categoryId: 'cat_dev',
description: 'Demo description',
rating: 4.2,
status: ToolStatus.published,
accessMode: AccessMode.download,
openUrl: 'https://example.com/download',
versionOverride: null,
openInNewTab: true,
downloadCount: 12,
openCount: 34,
updatedAt: '2026-04-14',
createdAt: new Date('2026-04-14T00:00:00.000Z'),
modifiedAt: new Date('2026-04-14T00:00:00.000Z'),
category: {
id: 'cat_dev',
name: 'Developer Tools',
},
tags: [{ tag: { id: 'tag_cli', name: 'cli' } }],
features: [{ featureText: 'Fast setup', sortOrder: 10 }],
latestArtifact: {
id: 'art_demo',
version: '2.0.0',
status: ArtifactStatus.active,
fileName: 'demo.zip',
fileSizeBytes: 4096,
},
...overrides,
};
}
describe('AdminToolsService', () => {
type ToolEntity = ReturnType<typeof createToolEntity>;
type CountArgs = {
where?: Prisma.ToolWhereInput;
};
type FindManyArgs = {
where?: Prisma.ToolWhereInput;
include?: Prisma.ToolInclude;
orderBy?: Prisma.ToolOrderByWithRelationInput;
skip?: number;
take?: number;
};
type FindFirstArgs = {
where?: Prisma.ToolWhereInput;
include?: Prisma.ToolInclude;
};
type UpdateArgs = {
where: { id: string };
data: Prisma.ToolUpdateInput;
};
function createService() {
const count = jest.fn<Promise<number>, [CountArgs]>();
const findMany = jest.fn<Promise<ToolEntity[]>, [FindManyArgs]>();
const findFirst = jest.fn<Promise<ToolEntity | null>, [FindFirstArgs]>();
const update = jest.fn<Promise<ToolEntity>, [UpdateArgs]>();
const $transaction = jest
.fn()
.mockImplementation(async (operations: Array<Promise<unknown>>) => Promise.all(operations));
const prisma = {
$transaction,
tool: {
count,
findMany,
findFirst,
update,
},
};
return {
prisma,
service: new AdminToolsService(prisma as never),
};
}
it('returns versionOverride and displayVersion for admin detail responses', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createToolEntity({
accessMode: AccessMode.web,
openUrl: 'https://example.com/tool',
versionOverride: '2026.04',
latestArtifact: null,
}),
);
await expect(service.getToolById('tool_demo')).resolves.toMatchObject({
id: 'tool_demo',
accessMode: AccessMode.web,
versionOverride: '2026.04',
displayVersion: '2026.04',
latestArtifact: null,
});
});
it('prefers versionOverride over artifact version in admin list responses', async () => {
const { prisma, service } = createService();
prisma.tool.count.mockResolvedValue(1);
prisma.tool.findMany.mockResolvedValue([
createToolEntity({
accessMode: AccessMode.download,
versionOverride: 'hotfix-7',
latestArtifact: {
id: 'art_demo',
version: '2.0.0',
status: ArtifactStatus.active,
fileName: 'demo.zip',
fileSizeBytes: 4096,
},
}),
]);
await expect(
service.getTools({
page: 1,
pageSize: 10,
} as never),
).resolves.toMatchObject({
list: [
expect.objectContaining({
accessMode: AccessMode.download,
versionOverride: 'hotfix-7',
displayVersion: 'hotfix-7',
latestArtifact: expect.objectContaining({
version: '2.0.0',
}),
}),
],
});
});
it('allows switching to none mode with an empty openUrl', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst
.mockResolvedValueOnce(
createToolEntity({
accessMode: AccessMode.web,
openUrl: 'https://example.com/tool',
versionOverride: 'preview-2',
latestArtifact: null,
}),
)
.mockResolvedValueOnce(
createToolEntity({
accessMode: AccessMode.none,
openUrl: null,
openInNewTab: false,
versionOverride: 'preview-2',
latestArtifact: null,
}),
);
prisma.tool.update.mockResolvedValue(
createToolEntity({
accessMode: AccessMode.none,
openUrl: null,
openInNewTab: false,
versionOverride: 'preview-2',
latestArtifact: null,
}),
);
await expect(
service.updateAccessMode('tool_demo', {
accessMode: AccessMode.none,
openUrl: null,
openInNewTab: false,
}),
).resolves.toMatchObject({
accessMode: AccessMode.none,
openUrl: null,
versionOverride: 'preview-2',
displayVersion: 'preview-2',
});
expect(prisma.tool.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'tool_demo' },
data: expect.objectContaining({
accessMode: AccessMode.none,
openUrl: null,
openInNewTab: false,
}),
}),
);
});
it('allows publishing a none mode tool without openUrl or artifacts', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst
.mockResolvedValueOnce(
createToolEntity({
status: ToolStatus.draft,
accessMode: AccessMode.none,
openUrl: null,
versionOverride: 'preview-2',
latestArtifact: null,
}),
)
.mockResolvedValueOnce(
createToolEntity({
status: ToolStatus.published,
accessMode: AccessMode.none,
openUrl: null,
versionOverride: 'preview-2',
latestArtifact: null,
}),
);
prisma.tool.update.mockResolvedValue(
createToolEntity({
status: ToolStatus.published,
accessMode: AccessMode.none,
openUrl: null,
versionOverride: 'preview-2',
latestArtifact: null,
}),
);
await expect(service.updateToolStatus('tool_demo', ToolStatus.published)).resolves.toMatchObject({
status: ToolStatus.published,
accessMode: AccessMode.none,
displayVersion: 'preview-2',
});
});
});

View File

@@ -77,7 +77,9 @@ export class AdminToolsService {
async createTool(body: CreateToolDto) {
await this.assertCategoryExists(body.categoryId);
await this.assertTagsExist(body.tags ?? []);
const openUrl = this.normalizeOptionalUrl(body.openUrl);
const openUrl =
body.accessMode === AccessMode.none ? null : this.normalizeOptionalUrl(body.openUrl);
const versionOverride = this.normalizeOptionalText(body.versionOverride);
if ((body.status ?? ToolStatus.draft) === ToolStatus.published) {
this.assertPublishInput(body.accessMode, openUrl, undefined);
@@ -97,6 +99,7 @@ export class AdminToolsService {
rating: body.rating ?? 0,
accessMode: body.accessMode,
openUrl,
versionOverride,
openInNewTab: body.openInNewTab ?? true,
status: body.status ?? ToolStatus.draft,
updatedAt,
@@ -159,10 +162,18 @@ export class AdminToolsService {
const existingTool = await this.getToolEntity(id);
const normalizedOpenUrl =
body.openUrl !== undefined ? this.normalizeOptionalUrl(body.openUrl) : undefined;
const normalizedVersionOverride =
body.versionOverride !== undefined
? this.normalizeOptionalText(body.versionOverride)
: undefined;
const nextAccessMode = body.accessMode ?? existingTool.accessMode;
const nextOpenUrl =
normalizedOpenUrl !== undefined ? normalizedOpenUrl : existingTool.openUrl ?? null;
nextAccessMode === AccessMode.none
? null
: normalizedOpenUrl !== undefined
? normalizedOpenUrl
: existingTool.openUrl ?? null;
const nextStatus = body.status ?? existingTool.status;
if (body.categoryId) {
@@ -188,7 +199,8 @@ export class AdminToolsService {
description: body.description?.trim(),
rating: body.rating,
accessMode: body.accessMode,
openUrl: normalizedOpenUrl,
openUrl: nextAccessMode === AccessMode.none ? null : normalizedOpenUrl,
versionOverride: normalizedVersionOverride,
openInNewTab: body.openInNewTab,
status: body.status,
updatedAt,
@@ -245,7 +257,11 @@ export class AdminToolsService {
async updateAccessMode(id: string, body: UpdateAccessModeDto) {
const tool = await this.getToolEntity(id);
const nextOpenUrl =
body.openUrl !== undefined ? this.normalizeOptionalUrl(body.openUrl) : tool.openUrl ?? null;
body.accessMode === AccessMode.none
? null
: body.openUrl !== undefined
? this.normalizeOptionalUrl(body.openUrl)
: tool.openUrl ?? null;
this.assertModeSwitchConstraint(
tool.status,
body.accessMode,
@@ -369,6 +385,10 @@ export class AdminToolsService {
return;
}
if (accessMode === AccessMode.none) {
return;
}
if (openUrl) {
return;
}
@@ -389,6 +409,10 @@ export class AdminToolsService {
tool: { latestArtifact?: { status: ArtifactStatus } | null },
isSwitching = false,
) {
if (targetMode === AccessMode.none) {
return;
}
if (targetMode === AccessMode.web && !openUrl) {
throw new AppException(
ERROR_CODES.WEB_OPEN_URL_NOT_CONFIGURED,
@@ -422,6 +446,8 @@ export class AdminToolsService {
};
}>,
) {
const latestVersion = this.resolveLatestVersion(tool);
return {
id: tool.id,
name: tool.name,
@@ -435,6 +461,8 @@ export class AdminToolsService {
status: tool.status,
accessMode: tool.accessMode,
openUrl: tool.openUrl,
versionOverride: tool.versionOverride,
displayVersion: this.resolveDisplayVersion(tool),
openInNewTab: tool.openInNewTab,
downloadCount: tool.downloadCount,
openCount: tool.openCount,
@@ -453,6 +481,7 @@ export class AdminToolsService {
})),
features: tool.features.map((item) => item.featureText),
updatedAt: tool.updatedAt,
latestVersion,
createdAt: tool.createdAt,
modifiedAt: tool.modifiedAt,
};
@@ -496,4 +525,47 @@ export class AdminToolsService {
const trimmed = String(value ?? '').trim();
return trimmed ? trimmed : null;
}
private normalizeOptionalText(value: string | null | undefined): string | null | undefined {
if (value === undefined) {
return undefined;
}
const trimmed = String(value ?? '').trim();
return trimmed ? trimmed : null;
}
private resolveDisplayVersion(
tool: Prisma.ToolGetPayload<{
include: {
category: true;
tags: { include: { tag: true } };
features: true;
latestArtifact: true;
};
}>,
): string | null {
if (tool.versionOverride) {
return tool.versionOverride;
}
return this.resolveLatestVersion(tool);
}
private resolveLatestVersion(
tool: Prisma.ToolGetPayload<{
include: {
category: true;
tags: { include: { tag: true } };
features: true;
latestArtifact: true;
};
}>,
): string | null {
if (tool.accessMode !== AccessMode.download || !tool.latestArtifact) {
return null;
}
return tool.latestArtifact.version;
}
}

View File

@@ -70,6 +70,15 @@ export class CreateToolDto {
})
openUrl?: string;
@ApiPropertyOptional({
description: 'Optional display version override shown in admin and public pages',
maxLength: 80,
})
@IsOptional()
@IsString()
@MaxLength(80)
versionOverride?: string;
@ApiPropertyOptional({ default: true })
@IsOptional()
@Type(() => Boolean)

View File

@@ -9,7 +9,8 @@ export class UpdateAccessModeDto {
accessMode!: AccessMode;
@ApiPropertyOptional({
description: 'Required when accessMode=web; optional external download URL when accessMode=download',
description:
'Required when accessMode=web; optional external download URL when accessMode=download; ignored when accessMode=none',
})
@IsOptional()
@IsString()

View File

@@ -14,6 +14,7 @@ function createToolEntity(overrides = {}) {
openCount: 42,
accessMode: AccessMode.download,
openUrl: 'https://example.com/download',
versionOverride: null,
updatedAt: '2026-04-11',
isDeleted: false,
status: 'published',
@@ -37,11 +38,29 @@ describe('ToolsService', () => {
type FindFirstArgs = {
where?: Prisma.ToolWhereInput;
};
type CountArgs = {
where?: Prisma.ToolWhereInput;
};
type FindManyArgs = {
where?: Prisma.ToolWhereInput;
include?: Prisma.ToolInclude;
skip?: number;
take?: number;
orderBy?: Prisma.ToolOrderByWithRelationInput | Prisma.ToolOrderByWithRelationInput[];
};
function createService() {
const findFirst = jest.fn<Promise<ToolEntity | null>, [FindFirstArgs]>();
const count = jest.fn<Promise<number>, [CountArgs]>();
const findMany = jest.fn<Promise<ToolEntity[]>, [FindManyArgs]>();
const $transaction = jest
.fn()
.mockImplementation(async (operations: Array<Promise<unknown>>) => Promise.all(operations));
const prisma = {
$transaction,
tool: {
count,
findMany,
findFirst,
},
};
@@ -62,6 +81,7 @@ describe('ToolsService', () => {
id: 'tool_demo',
slug: 'demo-tool',
name: 'Demo Tool',
displayVersion: '2.0.0',
latestVersion: '2.0.0',
fileSize: 4096,
downloadReady: true,
@@ -111,9 +131,78 @@ describe('ToolsService', () => {
await expect(
service.getToolDetailBySlug('demo-tool'),
).resolves.toMatchObject({
displayVersion: null,
downloadReady: false,
latestVersion: null,
fileSize: null,
});
});
it('returns displayVersion from versionOverride for web tools', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createToolEntity({
accessMode: AccessMode.web,
versionOverride: '2026.04',
latestArtifact: null,
}),
);
await expect(service.getToolDetailBySlug('demo-tool')).resolves.toMatchObject({
accessMode: AccessMode.web,
displayVersion: '2026.04',
latestVersion: null,
downloadReady: false,
});
});
it('returns displayVersion and none mode for non-launchable tools', async () => {
const { prisma, service } = createService();
prisma.tool.findFirst.mockResolvedValue(
createToolEntity({
accessMode: 'none' as AccessMode,
openUrl: null,
versionOverride: 'alpha-preview',
latestArtifact: null,
}),
);
await expect(service.getToolDetailBySlug('demo-tool')).resolves.toMatchObject({
accessMode: 'none',
displayVersion: 'alpha-preview',
latestVersion: null,
downloadReady: false,
});
});
it('returns displayVersion for list items and keeps latestVersion for download tools', async () => {
const { prisma, service } = createService();
prisma.tool.count.mockResolvedValue(1);
prisma.tool.findMany.mockResolvedValue([
createToolEntity({
accessMode: AccessMode.download,
versionOverride: null,
latestArtifact: {
version: '3.1.4',
fileSizeBytes: 2048,
status: ArtifactStatus.active,
},
}),
]);
await expect(
service.getTools({
page: 1,
pageSize: 6,
} as never),
).resolves.toMatchObject({
list: [
expect.objectContaining({
accessMode: AccessMode.download,
displayVersion: '3.1.4',
latestVersion: '3.1.4',
}),
],
});
});
});

View File

@@ -129,6 +129,7 @@ export class ToolsService {
);
const hasExternalDownloadUrl =
tool.accessMode === 'download' && Boolean(tool.openUrl);
const latestVersion = this.resolveLatestVersion(tool);
return {
id: tool.id,
@@ -142,10 +143,8 @@ export class ToolsService {
rating: tool.rating,
downloadCount: tool.downloadCount,
openCount: tool.openCount,
latestVersion:
tool.accessMode === 'download' && tool.latestArtifact
? tool.latestArtifact.version
: null,
displayVersion: this.resolveDisplayVersion(tool),
latestVersion,
accessMode: tool.accessMode,
openUrl: tool.openUrl,
hasArtifact: tool.accessMode === 'download' ? hasArtifact : false,
@@ -160,6 +159,8 @@ export class ToolsService {
}
private mapToolDetail(tool: ToolDetailEntity) {
const latestVersion = this.resolveLatestVersion(tool);
return {
id: tool.id,
name: tool.name,
@@ -177,10 +178,8 @@ export class ToolsService {
features: tool.features.map((item) => item.featureText),
updatedAt: tool.updatedAt,
openUrl: tool.openUrl,
latestVersion:
tool.accessMode === 'download' && tool.latestArtifact
? tool.latestArtifact.version
: null,
displayVersion: this.resolveDisplayVersion(tool),
latestVersion,
fileSize:
tool.accessMode === 'download' && tool.latestArtifact
? tool.latestArtifact.fileSizeBytes
@@ -195,4 +194,22 @@ export class ToolsService {
: false,
};
}
// Keep version selection centralized so public list/detail responses always
// expose the same display value regardless of tool access mode.
private resolveDisplayVersion(tool: ToolDetailEntity): string | null {
if (tool.versionOverride) {
return tool.versionOverride;
}
return this.resolveLatestVersion(tool);
}
private resolveLatestVersion(tool: ToolDetailEntity): string | null {
if (tool.accessMode !== 'download' || !tool.latestArtifact) {
return null;
}
return tool.latestArtifact.version;
}
}

View File

@@ -0,0 +1,81 @@
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { AccessMode, ToolStatus } from '@prisma/client';
import { randomUUID } from 'crypto';
import request from 'supertest';
import { AppModule } from '../src/app.module';
import { PrismaService } from '../src/prisma/prisma.service';
describe('Tool launch none mode (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
let categoryId = '';
let toolId = '';
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
prisma = app.get(PrismaService);
});
beforeEach(async () => {
categoryId = `cat_${randomUUID().replace(/-/g, '')}`;
toolId = `tool_${randomUUID().replace(/-/g, '')}`;
await prisma.category.create({
data: {
id: categoryId,
name: `Category ${toolId}`,
},
});
await prisma.tool.create({
data: {
id: toolId,
name: 'Preview Tool',
slug: `preview-${randomUUID().slice(0, 8)}`,
categoryId,
description: 'Preview only tool',
accessMode: AccessMode.none,
versionOverride: 'preview-1',
status: ToolStatus.published,
updatedAt: '2026-04-14',
},
});
});
afterEach(async () => {
await prisma.tool.deleteMany({
where: {
id: toolId,
},
});
await prisma.category.deleteMany({
where: {
id: categoryId,
},
});
});
afterAll(async () => {
await app.close();
});
function getHttpServer(): Parameters<typeof request>[0] {
return app.getHttpServer() as Parameters<typeof request>[0];
}
it('returns 409 when launching a none mode tool', async () => {
await request(getHttpServer())
.post(`/tools/${toolId}/launch`)
.send({
channel: 'official',
clientVersion: 'web-1.0.0',
})
.expect(409);
});
});

View File

@@ -11,6 +11,8 @@ interface ToolDetailResponse {
slug: string;
name: string;
description: string;
accessMode: string;
displayVersion: string | null;
}
describe('Tools detail by slug (e2e)', () => {
@@ -51,6 +53,7 @@ describe('Tools detail by slug (e2e)', () => {
description: '# Manual\n\n## Install',
accessMode: AccessMode.web,
openUrl: 'https://example.com/tool',
versionOverride: '2026.04',
status: ToolStatus.published,
updatedAt: '2026-04-11',
},
@@ -87,10 +90,46 @@ describe('Tools detail by slug (e2e)', () => {
expect(body.slug).toBe(toolSlug);
expect(body.name).toBe('Slug Detail Tool');
expect(body.description).toContain('# Manual');
expect(body.accessMode).toBe(AccessMode.web);
expect(body.displayVersion).toBe('2026.04');
});
});
it('returns 404 for an unknown slug', async () => {
await request(getHttpServer()).get('/tools/slug/missing-tool').expect(404);
});
it('returns none mode tool detail with displayVersion', async () => {
const noneToolId = `tool_${randomUUID().replace(/-/g, '')}`;
const noneToolSlug = `none-tool-${randomUUID().slice(0, 8)}`;
await prisma.tool.create({
data: {
id: noneToolId,
name: 'Preview Tool',
slug: noneToolSlug,
categoryId,
description: '# Preview\n\n即将上线',
accessMode: 'none' as AccessMode,
versionOverride: 'preview-1',
status: ToolStatus.published,
updatedAt: '2026-04-11',
},
});
await request(getHttpServer())
.get(`/tools/slug/${noneToolSlug}`)
.expect(200)
.expect(({ body }: { body: ToolDetailResponse }) => {
expect(body.id).toBe(noneToolId);
expect(body.accessMode).toBe('none');
expect(body.displayVersion).toBe('preview-1');
});
await prisma.tool.deleteMany({
where: {
id: noneToolId,
},
});
});
});