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,11 +619,9 @@ async function triggerLaunch(tool) {
|
||||
|
||||
const actionUrl = resolveActionUrl(result?.actionUrl);
|
||||
|
||||
if (isWebLaunch || isDownloadLaunch) {
|
||||
if (result.openIn === 'same_tab') {
|
||||
window.location.assign(actionUrl);
|
||||
return;
|
||||
}
|
||||
if (isWebLaunch && result.openIn === 'same_tab') {
|
||||
window.location.assign(actionUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isWebLaunch) {
|
||||
@@ -619,7 +634,7 @@ async function triggerLaunch(tool) {
|
||||
} else if (result?.mode === 'download') {
|
||||
const page = window.open(actionUrl, '_blank', 'noopener,noreferrer');
|
||||
if (!page) {
|
||||
window.location.assign(actionUrl);
|
||||
showToast('浏览器阻止了下载窗口,请允许弹窗后重试');
|
||||
return;
|
||||
}
|
||||
showToast(`${tool.name} 已开始下载`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user