update:增加文档模式
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,12 +619,10 @@ async function triggerLaunch(tool) {
|
||||
|
||||
const actionUrl = resolveActionUrl(result?.actionUrl);
|
||||
|
||||
if (isWebLaunch || isDownloadLaunch) {
|
||||
if (result.openIn === 'same_tab') {
|
||||
if (isWebLaunch && result.openIn === 'same_tab') {
|
||||
window.location.assign(actionUrl);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (isWebLaunch) {
|
||||
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
|
||||
@@ -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} 已开始下载`);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
69
client/src/admin/components/AccessModeDialog.spec.js
Normal file
69
client/src/admin/components/AccessModeDialog.spec.js
Normal 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('新标签页');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
57
client/src/admin/components/AdminOverviewSection.spec.js
Normal file
57
client/src/admin/components/AdminOverviewSection.spec.js
Normal 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('下载就绪');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
75
client/src/admin/components/AdminToolsSection.spec.js
Normal file
75
client/src/admin/components/AdminToolsSection.spec.js
Normal 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('无');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
85
client/src/admin/components/ToolFormDialog.spec.js
Normal file
85
client/src/admin/components/ToolFormDialog.spec.js
Normal 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('新标签页');
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
229
client/src/admin/components/test-stubs.js
Normal file
229
client/src/admin/components/test-stubs.js
Normal 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,
|
||||
};
|
||||
@@ -33,6 +33,7 @@ function createEmptyOverviewState() {
|
||||
tagTotal: 0,
|
||||
webToolTotal: 0,
|
||||
downloadToolTotal: 0,
|
||||
noneToolTotal: 0,
|
||||
downloadReadyToolTotal: 0,
|
||||
openTotal: 0,
|
||||
downloadTotal: 0,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
141
design/plans/2026-04-14-tool-display-version-none-mode-plan.md
Normal file
141
design/plans/2026-04-14-tool-display-version-none-mode-plan.md
Normal 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`,预期前后端关键回归全部通过。
|
||||
44
design/session-state-20260413095000.md
Normal file
44
design/session-state-20260413095000.md
Normal 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:长文阅读体验、后台提示文案和整体验证。
|
||||
28
design/session-state-20260414181132.md
Normal file
28
design/session-state-20260414181132.md
Normal 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 不变的行为。
|
||||
@@ -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 |
|
||||
|
||||
56
design/specs/2026-04-13-download-launch-url-design.md
Normal file
56
design/specs/2026-04-13-download-launch-url-design.md
Normal 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` 断言;若当前模块无单测,则至少保证前端回归测试覆盖核心用户路径。
|
||||
152
design/specs/2026-04-14-tool-display-version-none-mode-design.md
Normal file
152
design/specs/2026-04-14-tool-display-version-none-mode-design.md
Normal 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` 模式 |
|
||||
@@ -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
BIN
server/prisma/prisma.zip
Normal file
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,7 +35,13 @@ function normalizeWrappedEnv(name) {
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
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,
|
||||
@@ -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) {
|
||||
|
||||
128
server/src/modules/access/access.service.spec.ts
Normal file
128
server/src/modules/access/access.service.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
111
server/src/modules/admin-overview/admin-overview.service.spec.ts
Normal file
111
server/src/modules/admin-overview/admin-overview.service.spec.ts
Normal 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,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
232
server/src/modules/admin-tools/admin-tools.service.spec.ts
Normal file
232
server/src/modules/admin-tools/admin-tools.service.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
81
server/test/tool-launch-none.e2e-spec.ts
Normal file
81
server/test/tool-launch-none.e2e-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user