diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 22f1aad..e4431ef 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -19,6 +19,7 @@ import { ReportSourceSidebar } from './components/reports/ReportSourceSidebar' import { ReportTaskStatusPanel } from './components/reports/ReportTaskStatusPanel' import { ReportViewer } from './components/reports/ReportViewer' import { ScheduledReportsWorkspace } from './components/reports/ScheduledReportsWorkspace' +import { ReportTemplateMarketWorkspace } from './components/reports/ReportTemplateMarketWorkspace' import { contactDisplayName } from './components/reports/types' import type { GeneratedReportRecord, ReportWorkspaceView } from './components/reports/types' import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportGeneration' @@ -267,7 +268,7 @@ function App(): React.ReactElement { const [settingsCategory, setSettingsCategory] = useState('account-database') const [reportSourceContact, setReportSourceContact] = useState(null) const [reportWorkspaceView, setReportWorkspaceView] = useState('result') - const [reportSection, setReportSection] = useState<'today' | 'scheduled'>('today') + const [reportSection, setReportSection] = useState<'today' | 'scheduled' | 'market'>('today') const [generatedReports, setGeneratedReports] = useState([]) const [selectedReportId, setSelectedReportId] = useState(null) const [latestGeneratedReportId, setLatestGeneratedReportId] = useState(null) @@ -1941,9 +1942,23 @@ function App(): React.ReactElement { > 定时日报{!supportsPersonalWechatSend && 仅 macOS / Windows} +
- {reportSection === 'scheduled' ? ( + {reportSection === 'market' ? ( + + ) : reportSection === 'scheduled' ? ( void +}): React.ReactElement { + const [installed, setInstalled] = useState([]) + const [catalog, setCatalog] = useState(null) + const [error, setError] = useState('') + const [loading, setLoading] = useState(true) + const [busyKey, setBusyKey] = useState('') + const [previewing, setPreviewing] = useState(null) + const previewTriggerRef = useRef(null) + + const refresh = async (): Promise => { + if (typeof window === 'undefined' || !window.api) { + setLoading(false) + return + } + setError('') + if (typeof window.api.listReportTemplates === 'function') { + try { + const listed = await window.api.listReportTemplates() + if (Array.isArray(listed)) setInstalled(listed) + } catch (reason) { + setError(reason instanceof Error ? reason.message : '已安装模板加载失败') + } + } + if (typeof window.api.listReportTemplateCatalog === 'function') { + try { + const result = await window.api.listReportTemplateCatalog() + if (result.success && result.catalog) setCatalog(result.catalog) + else setError(result.error || '远端模板目录加载失败') + } catch (reason) { + setError(reason instanceof Error ? reason.message : '远端模板目录加载失败') + } + } + setLoading(false) + } + + useEffect(() => { + void refresh() + }, []) + + const externalInstalled = useMemo( + () => installed.filter((template) => template.source === 'installed'), + [installed] + ) + const catalogEntries = catalog?.templates || [] + const installedKeys = useMemo( + () => + new Set( + externalInstalled.map((template) => + encodeExternalReportTemplateId(template.id, template.version) + ) + ), + [externalInstalled] + ) + const availableEntries = useMemo( + () => + catalogEntries.filter( + (entry) => !installedKeys.has(encodeExternalReportTemplateId(entry.id, entry.version)) + ), + [catalogEntries, installedKeys] + ) + + const catalogEntryFor = ( + template: InstalledReportTemplate + ): ReportTemplateCatalogEntry | undefined => + catalogEntries.find((entry) => entry.id === template.id && entry.version === template.version) + + const install = async (entry: ReportTemplateCatalogEntry): Promise => { + const key = encodeExternalReportTemplateId(entry.id, entry.version) + if (typeof window.api.installReportTemplateFromCatalog !== 'function') { + setError('当前 TraceMemo 版本不支持模板市场安装') + return + } + setBusyKey(key) + setError('') + try { + const result = await window.api.installReportTemplateFromCatalog(entry.id, entry.version) + if (!result.success) { + setError(result.error || `模板安装失败:${entry.name}`) + return + } + await refresh() + } catch (reason) { + setError(reason instanceof Error ? reason.message : `模板安装失败:${entry.name}`) + } finally { + setBusyKey('') + } + } + + /** + * 卸载市场模板。卸载掉的正好是当前选中的模板时回退到默认模板, + * 避免生成页保留一个已经不存在的模板引用。 + */ + const uninstall = async (template: InstalledReportTemplate): Promise => { + const key = encodeExternalReportTemplateId(template.id, template.version) + setBusyKey(key) + setError('') + try { + const result = await window.api.uninstallReportTemplate(template.id, template.version) + if (!result.success) { + setError(result.error || `模板卸载失败:${template.name}`) + return + } + if (value === key) onChange(DEFAULT_REPORT_TEMPLATE.id) + await refresh() + } catch (reason) { + setError(reason instanceof Error ? reason.message : `模板卸载失败:${template.name}`) + } finally { + setBusyKey('') + } + } + + const openPreview = (item: ReportTemplatePreviewItem, trigger: HTMLButtonElement): void => { + previewTriggerRef.current = trigger + setPreviewing(item) + } + + const renderBuiltinRow = (template: ReportTemplateDefinition): React.ReactElement => ( +
+ +
+
+ {template.platform === 'default' ? '默认模板' : template.label} +
+
{template.name}
+
{template.tagline}
+
+
+ +
+
+ ) + + const renderInstalledGroup = (): React.ReactElement | null => { + if (!externalInstalled.length) return null + return ( +
+
已安装市场模板
+
+ {externalInstalled.map((template) => { + const key = encodeExternalReportTemplateId(template.id, template.version) + const entry = catalogEntryFor(template) + return ( +
+ {entry?.preview ? ( + + ) : ( + + )} +
+
市场 · v{template.version}
+
{template.name}
+
+ {entry?.description || `${template.author} · ${reportTemplatePlatformLabel(entry?.platform)}`} +
+
+
+ + {value === key ? ( + + ) : null} + +
+
+ ) + })} +
+
+ ) + } + + const renderAvailableGroup = (): React.ReactElement | null => { + if (!availableEntries.length) return null + return ( +
+
模板市场
+
+ {availableEntries.map((entry) => { + const key = encodeExternalReportTemplateId(entry.id, entry.version) + return ( +
+ {entry.preview ? ( + + ) : ( + + )} +
+
+ 市场 · v{entry.version} · {entry.author} +
+
{entry.name}
+
{entry.description}
+
+
+ + +
+
+ ) + })} +
+
+ ) + } + + const describeMarketState = (): string => { + if (loading) return '正在读取模板市场…' + if (error) return `模板市场:${error}` + if (!catalogEntries.length && !externalInstalled.length) return '模板市场暂无可安装模板。' + return `已安装 ${externalInstalled.length} 个市场模板,另有 ${availableEntries.length} 个可以安装。` + } + + return ( +
+
+
+

社区模板市场

+

+ 模板与默认日报读取同一份真实数据,只改变展示方式,适合手机长图分享、桌面归档与团队复盘。 + 这里可以浏览、预览、安装、选用和卸载社区模板。 +

+
+ +
+ +
+
+ 想把你做的模板分享给其他人? +

+ 如果想提供给其他人模板样式,可以来 TraceMemo-Templates + 项目里提交 PR,方便给其他用户使用。 +

+
+ + 前往提交模板 + +
+ +

+ {describeMarketState()} +

+ +
+
+
默认模板
+
{renderBuiltinRow(DEFAULT_REPORT_TEMPLATE)}
+
+ +
+
手机端 · 375–414 px
+
+ {REPORT_TEMPLATES.filter((template) => template.platform === 'mobile').map( + renderBuiltinRow + )} +
+
+ +
+
电脑端 · 1280–1920 px
+
+ {REPORT_TEMPLATES.filter((template) => template.platform === 'desktop').map( + renderBuiltinRow + )} +
+
+ + {renderInstalledGroup()} + {renderAvailableGroup()} + + {catalog?.generatedAt ? ( +

+ 目录更新于 {new Date(catalog.generatedAt).toLocaleString('zh-CN')} + {catalog.source?.commit ? ` · ${catalog.source.commit.slice(0, 8)}` : ''} +

+ ) : null} +
+ + setPreviewing(null)} + onConfirm={(item) => { + onChange( + item.kind === 'builtin' + ? item.template.id + : encodeExternalReportTemplateId(item.id, item.version) + ) + setPreviewing(null) + }} + /> +
+ ) +} diff --git a/src/renderer/src/components/reports/ReportTemplatePreview.tsx b/src/renderer/src/components/reports/ReportTemplatePreview.tsx new file mode 100644 index 0000000..239a24f --- /dev/null +++ b/src/renderer/src/components/reports/ReportTemplatePreview.tsx @@ -0,0 +1,158 @@ +import React from 'react' +import { + DEFAULT_REPORT_TEMPLATE, + type ReportTemplateDefinition +} from '../../../../shared/report-templates' +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '../ui' + +/** + * 日报模板预览的共享部分:生成页的模板选择器与社区模板市场页共用同一套 + * 卡片示意图与预览弹窗,避免两处各维护一份。 + */ +export type ReportTemplatePreviewItem = + | { kind: 'builtin'; template: ReportTemplateDefinition } + | { + kind: 'external' + id: string + version: string + name: string + description: string + installed: boolean + platform?: 'default' | 'mobile' | 'desktop' + preview?: string + } + +export const reportTemplatePlatformLabel = ( + platform?: 'default' | 'mobile' | 'desktop' +): string => (platform === 'desktop' ? '桌面宽屏' : platform === 'default' ? '经典长图' : '手机长图') + +export const TemplateDiagram = ({ + template +}: { + template: ReportTemplateDefinition +}): React.ReactElement => ( + +) + +export function ReportTemplatePreviewDialog({ + previewing, + confirmLabel = '选择此模板', + restoreFocusRef, + onClose, + onConfirm +}: { + previewing: ReportTemplatePreviewItem | null + confirmLabel?: string + restoreFocusRef: React.RefObject + onClose: () => void + onConfirm: (item: ReportTemplatePreviewItem) => void +}): React.ReactElement { + return ( + { + if (!open) onClose() + }} + > + {previewing && ( + { + event.preventDefault() + restoreFocusRef.current?.focus() + }} + > + +
+
+ + {previewing.kind === 'builtin' + ? previewing.template.label + : `市场 · v${previewing.version}`} + + + {previewing.kind === 'builtin' ? previewing.template.name : previewing.name} + +
+ + {reportTemplatePlatformLabel( + previewing.kind === 'builtin' + ? previewing.template.platform + : previewing.platform + )} + +
+ + {previewing.kind === 'builtin' + ? previewing.template.tagline + : previewing.description} + +
+ {previewing.kind === 'builtin' ? ( + + ) : previewing.preview ? ( + {`${previewing.name} + ) : ( + + )} + + 生成时会自动代入当前群聊的真实头像、昵称、消息、讨论摘要、Q&A、统计与关键词。 + + + + + + {previewing.kind === 'builtin' || previewing.installed ? ( + + ) : ( + + )} + +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/reports/ReportTemplateSelector.tsx b/src/renderer/src/components/reports/ReportTemplateSelector.tsx index d3e813f..42872bf 100644 --- a/src/renderer/src/components/reports/ReportTemplateSelector.tsx +++ b/src/renderer/src/components/reports/ReportTemplateSelector.tsx @@ -13,16 +13,15 @@ import type { import type { InstalledReportTemplate } from '../../../../shared/report-template-package' import { Button, - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, RadioGroup, RadioGroupItem } from '../ui' +import { + ReportTemplatePreviewDialog, + TemplateDiagram, + reportTemplatePlatformLabel, + type ReportTemplatePreviewItem +} from './ReportTemplatePreview' export type { SelectableReportTemplateId, @@ -35,67 +34,15 @@ interface ReportTemplateSelectorProps { disabled?: boolean } -type PreviewItem = - | { kind: 'builtin'; template: ReportTemplateDefinition } - | { - kind: 'external' - id: string - version: string - name: string - description: string - installed: boolean - platform?: 'default' | 'mobile' | 'desktop' - preview?: string - } - -const TemplateDiagram = ({ - template -}: { - template: ReportTemplateDefinition -}): React.ReactElement => ( - -) - export const ReportTemplateSelector: React.FC = ({ value, onChange, disabled }) => { - const [previewing, setPreviewing] = useState(null) + const [previewing, setPreviewing] = useState(null) const [installed, setInstalled] = useState([]) const [catalog, setCatalog] = useState(null) const [marketError, setMarketError] = useState('') - const [marketBusyKey, setMarketBusyKey] = useState('') const previewTriggerRef = useRef(null) const mobileTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'mobile') const desktopTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'desktop') @@ -133,58 +80,12 @@ export const ReportTemplateSelector: React.FC = ({ [installed] ) const catalogEntries = catalog?.templates || [] - const installedKeys = useMemo( - () => new Set(externalInstalled.map((template) => encodeExternalReportTemplateId(template.id, template.version))), - [externalInstalled] - ) - const platformLabel = (platform?: 'default' | 'mobile' | 'desktop'): string => - platform === 'desktop' ? '桌面宽屏' : platform === 'default' ? '经典长图' : '手机长图' + const platformLabel = reportTemplatePlatformLabel const catalogEntryFor = (template: InstalledReportTemplate): ReportTemplateCatalogEntry | undefined => catalogEntries.find((entry) => entry.id === template.id && entry.version === template.version) - const installFromCatalog = async (entry: ReportTemplateCatalogEntry): Promise => { - const key = encodeExternalReportTemplateId(entry.id, entry.version) - if (typeof window.api.installReportTemplateFromCatalog !== 'function') { - setMarketError('当前 TraceMemo 版本不支持模板市场安装') - return - } - setMarketBusyKey(key) - setMarketError('') - try { - const result = await window.api.installReportTemplateFromCatalog(entry.id, entry.version) - if (!result.success) { - setMarketError(result.error || `模板安装失败:${entry.name}`) - return - } - await refreshMarket() - } catch (error) { - setMarketError(error instanceof Error ? error.message : `模板安装失败:${entry.name}`) - } finally { - setMarketBusyKey('') - } - } - - const uninstallExternal = async (template: InstalledReportTemplate): Promise => { - const key = encodeExternalReportTemplateId(template.id, template.version) - setMarketBusyKey(key) - setMarketError('') - try { - const result = await window.api.uninstallReportTemplate(template.id, template.version) - if (!result.success) { - setMarketError(result.error || `模板卸载失败:${template.name}`) - return - } - if (value === key) onChange('v1') - await refreshMarket() - } catch (error) { - setMarketError(error instanceof Error ? error.message : `模板卸载失败:${template.name}`) - } finally { - setMarketBusyKey('') - } - } - const renderGroup = ( title: string, templates: readonly ReportTemplateDefinition[] @@ -276,167 +177,46 @@ export const ReportTemplateSelector: React.FC = ({
-
- - -
+ ) })} )} - {catalogEntries.filter((entry) => !installedKeys.has(encodeExternalReportTemplateId(entry.id, entry.version))).length > 0 && ( -
-
模板市场
-
- {catalogEntries - .filter((entry) => !installedKeys.has(encodeExternalReportTemplateId(entry.id, entry.version))) - .map((entry) => { - const key = encodeExternalReportTemplateId(entry.id, entry.version) - return ( -
-
-
远端 · v{entry.version}
-
{entry.name}
-
{entry.description}
-
- 接口 {entry.interfaceVersion} · {platformLabel(entry.platform)} · {entry.sizeBytes} bytes -
-
-
- {entry.preview && ( - - )} - -
-
- ) - })} -
-
- )} {marketError &&

模板市场:{marketError}

} - { - if (!open) setPreviewing(null) + setPreviewing(null)} + onConfirm={(item) => { + onChange( + item.kind === 'builtin' + ? item.template.id + : encodeExternalReportTemplateId(item.id, item.version) + ) + setPreviewing(null) }} - > - {previewing && ( - { - event.preventDefault() - previewTriggerRef.current?.focus() - }} - > - -
-
- - {previewing.kind === 'builtin' - ? previewing.template.label - : `市场 · v${previewing.version}`} - - - {previewing.kind === 'builtin' ? previewing.template.name : previewing.name} - -
- - {platformLabel( - previewing.kind === 'builtin' ? previewing.template.platform : previewing.platform - )} - -
- - {previewing.kind === 'builtin' ? previewing.template.tagline : previewing.description} - -
- {previewing.kind === 'builtin' ? ( - - ) : previewing.preview ? ( - {`${previewing.name} - ) : ( - - )} - - 生成时会自动代入当前群聊的真实头像、昵称、消息、讨论摘要、Q&A、统计与关键词。 - - - - - - {previewing.kind === 'builtin' || previewing.installed ? ( - - ) : ( - - )} - -
- )} -
+ /> ) } diff --git a/src/renderer/src/styles/reports-templates.scss b/src/renderer/src/styles/reports-templates.scss index 8b5b5f4..e7824fe 100644 --- a/src/renderer/src/styles/reports-templates.scss +++ b/src/renderer/src/styles/reports-templates.scss @@ -986,3 +986,102 @@ color: var(--wxex-text-muted); font: 11px/17px var(--wxex-font); } + +/* ============================================================ */ +/* 社区模板市场页 */ +/* ============================================================ */ +.report-market-page { + display: flex; + height: 100%; + min-height: 0; + flex-direction: column; + gap: 14px; + overflow-y: auto; + padding: 28px clamp(20px, 4vw, 56px) 40px; + color: var(--wxex-text-primary); +} + +.report-market-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; +} + +.report-market-header h1 { + margin: 0; + color: var(--wxex-text-primary); + font: 700 24px/32px var(--wxex-font); +} + +.report-market-header p { + margin: 5px 0 0; + max-width: 720px; + color: var(--wxex-text-secondary); + font: 13px/20px var(--wxex-font); +} + +.report-market-summary { + margin: 0; + color: var(--wxex-text-muted); + font: 12px/18px var(--wxex-font); +} + +.report-market-body { + display: grid; + gap: 22px; + margin-top: 4px; +} + +.report-market-footnote { + margin: 0; + color: var(--wxex-text-muted); + font: 11px/17px var(--wxex-font); +} + +@media (max-width: 900px) { + .report-market-header { + flex-direction: column; + } +} + +.report-market-contribute { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + padding: 16px 18px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-lg); + background: var(--wxex-bg-elevated); +} + +.report-market-contribute strong { + display: block; + color: var(--wxex-text-primary); + font: 700 13px/20px var(--wxex-font); +} + +.report-market-contribute p { + margin: 4px 0 0; + max-width: 640px; + color: var(--wxex-text-secondary); + font: 12px/18px var(--wxex-font); +} + +.report-market-contribute-link { + flex: 0 0 auto; + padding: 6px 12px; + border: 1px solid var(--wxex-border); + border-radius: var(--wxex-radius-md); + color: var(--wxex-text-primary); + cursor: pointer; + font: 600 12px/18px var(--wxex-font); + text-decoration: none; + white-space: nowrap; +} + +.report-market-contribute-link:hover { + border-color: var(--wxex-brand); + color: var(--wxex-brand); +} diff --git a/tests/component/report-features.test.tsx b/tests/component/report-features.test.tsx index c64ee47..307e544 100644 --- a/tests/component/report-features.test.tsx +++ b/tests/component/report-features.test.tsx @@ -911,10 +911,23 @@ describe('daily report controls', () => { expect(onChange).toHaveBeenCalledWith('mobile-magazine') }) - it('does not select a market template before it is installed', async () => { + it('keeps market browsing on the community page instead of the generation form', async () => { const user = userEvent.setup() const onChange = vi.fn() Object.assign(window.api, { + listReportTemplates: vi.fn(async () => [ + { + id: 'community.github.tracememo.quickread', + version: '1.0.0', + interfaceVersion: '1', + source: 'installed' as const, + name: '极简速读', + author: 'fixture', + entryPath: '/tmp/entry.html', + capture: { width: 414, maxWidth: 414, maxHeight: 2000 }, + license: { spdx: 'MIT' } + } + ]), listReportTemplateCatalog: vi.fn(async () => ({ success: true, catalog: { @@ -931,11 +944,10 @@ describe('daily report controls', () => { tags: [], license: 'MIT', minAppVersion: null, - download: 'https://raw.githubusercontent.com/Wxw-Gu/TraceMemo/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/packages/community.github.example.market/1.0.0/template.zip', + download: 'https://example.com/template.zip', sizeBytes: 1, sha256: 'a'.repeat(64), - preview: 'https://raw.githubusercontent.com/Wxw-Gu/TraceMemo/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/previews/community.github.example.market.png', - status: 'published' + status: 'published' as const } ] } @@ -943,12 +955,18 @@ describe('daily report controls', () => { }) render() - const marketItem = (await screen.findByText('市场测试模板')).closest('.report-template-item') - expect(marketItem).not.toBeNull() - await user.click(within(marketItem!).getByRole('button', { name: '查看版式' })) - const dialog = screen.getByRole('dialog', { name: '市场测试模板' }) - expect(within(dialog).getByRole('button', { name: '请先安装' })).toBeDisabled() - expect(within(dialog).queryByRole('button', { name: '选择此模板' })).not.toBeInTheDocument() - expect(onChange).not.toHaveBeenCalled() + + // 已安装的市场模板仍可在生成页选用,但不再提供卸载入口。 + expect(await screen.findByText('极简速读')).toBeVisible() + expect(screen.getByText('已安装市场模板')).toBeVisible() + expect(screen.queryByRole('button', { name: '卸载' })).not.toBeInTheDocument() + // 未安装的远端模板只出现在社区模板市场页。 + expect(screen.queryByText('市场测试模板')).not.toBeInTheDocument() + expect(screen.queryByText('安装')).not.toBeInTheDocument() + + await user.click(screen.getByRole('radio', { name: /极简速读/ })) + expect(onChange).toHaveBeenCalledWith( + 'external:community.github.tracememo.quickread@1.0.0' + ) }) }) diff --git a/tests/component/report-template-market-workspace.test.tsx b/tests/component/report-template-market-workspace.test.tsx new file mode 100644 index 0000000..8422170 --- /dev/null +++ b/tests/component/report-template-market-workspace.test.tsx @@ -0,0 +1,204 @@ +import { render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { ReportTemplateMarketWorkspace } from '../../src/renderer/src/components/reports/ReportTemplateMarketWorkspace' + +const installed = { + id: 'community.github.tracememo.quickread', + version: '1.0.0', + interfaceVersion: '1', + source: 'installed' as const, + name: '极简速读', + author: 'fixture', + entryPath: '/tmp/entry.html', + capture: { width: 414, maxWidth: 414, maxHeight: 2000 }, + license: { spdx: 'MIT' } +} + +const availableEntry = { + id: 'community.github.tracememo.paperdaily', + version: '1.0.2', + interfaceVersion: '1', + name: '霓光指挥日报', + description: '霓光主题的桌面指挥日报。', + author: 'fixture', + platform: 'desktop' as const, + tags: [], + license: 'MIT', + minAppVersion: null, + download: 'https://example.com/template.zip', + sizeBytes: 1, + sha256: 'a'.repeat(64), + preview: 'https://example.com/preview.png', + status: 'published' as const +} + +const listReportTemplates = vi.fn() +const listReportTemplateCatalog = vi.fn() +const installReportTemplateFromCatalog = vi.fn() +const uninstallReportTemplate = vi.fn() + +describe('ReportTemplateMarketWorkspace', () => { + beforeEach(() => { + listReportTemplates.mockReset().mockResolvedValue([installed]) + listReportTemplateCatalog.mockReset().mockResolvedValue({ + success: true, + catalog: { schemaVersion: '1', status: 'published', templates: [availableEntry] } + }) + installReportTemplateFromCatalog.mockReset().mockResolvedValue({ success: true }) + uninstallReportTemplate.mockReset().mockResolvedValue({ success: true }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + listReportTemplates, + listReportTemplateCatalog, + installReportTemplateFromCatalog, + uninstallReportTemplate + } + }) + }) + + it('lists builtin, installed and installable market templates with all market actions', async () => { + render() + + expect(await screen.findByText('极简速读')).toBeVisible() + expect(screen.getByText('霓光指挥日报')).toBeVisible() + expect(screen.getByText('经典日报')).toBeVisible() + expect(screen.getByText('已安装市场模板')).toBeVisible() + expect(screen.getByText('已安装 1 个市场模板,另有 1 个可以安装。')).toBeVisible() + expect(screen.getByRole('button', { name: '安装' })).toBeEnabled() + expect(screen.getByRole('button', { name: '卸载' })).toBeEnabled() + expect(screen.queryByRole('button', { name: '当前使用' })).not.toBeInTheDocument() + }) + + it('links to the community template repository for submitting new templates', async () => { + render() + + const link = await screen.findByRole('link', { name: '前往提交模板' }) + expect(link).toHaveAttribute('href', 'https://github.com/Wxw-Gu/TraceMemo-Templates') + expect(link).toHaveAttribute('target', '_blank') + expect( + screen.getByText( + '如果想提供给其他人模板样式,可以来 TraceMemo-Templates 项目里提交 PR,方便给其他用户使用。' + ) + ).toBeVisible() + }) + + it('uninstalls a market template and refreshes the list', async () => { + const user = userEvent.setup() + uninstallReportTemplate.mockImplementation(async () => { + listReportTemplates.mockResolvedValue([]) + return { success: true } + }) + render() + + await user.click(await screen.findByRole('button', { name: '卸载' })) + + await waitFor(() => + expect(uninstallReportTemplate).toHaveBeenCalledWith(installed.id, installed.version) + ) + expect(await screen.findByText('已安装 0 个市场模板,另有 1 个可以安装。')).toBeVisible() + }) + + it('falls back to the default template when the active market template is uninstalled', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + uninstallReportTemplate.mockResolvedValue({ success: true }) + render( + + ) + + await user.click(await screen.findByRole('button', { name: '卸载' })) + + expect(onChange).toHaveBeenCalledWith('v1') + }) + + it('reports an uninstall failure without dropping the installed template', async () => { + const user = userEvent.setup() + uninstallReportTemplate.mockResolvedValue({ success: false, error: '模板正在使用中' }) + render() + + await user.click(await screen.findByRole('button', { name: '卸载' })) + + expect(await screen.findByText('模板市场:模板正在使用中')).toBeVisible() + expect(screen.getByText('极简速读')).toBeVisible() + }) + + it('installs a market template and refreshes the list', async () => { + const user = userEvent.setup() + installReportTemplateFromCatalog.mockImplementation(async () => { + listReportTemplates.mockResolvedValue([installed, { ...availableEntry, source: 'installed' }]) + listReportTemplateCatalog.mockResolvedValue({ + success: true, + catalog: { schemaVersion: '1', status: 'published', templates: [availableEntry] } + }) + return { success: true } + }) + render() + + await user.click(await screen.findByRole('button', { name: '安装' })) + + await waitFor(() => + expect(installReportTemplateFromCatalog).toHaveBeenCalledWith( + availableEntry.id, + availableEntry.version + ) + ) + expect(await screen.findByText('已安装 2 个市场模板,另有 0 个可以安装。')).toBeVisible() + }) + + it('reports an install failure without losing the catalog', async () => { + const user = userEvent.setup() + installReportTemplateFromCatalog.mockResolvedValue({ success: false, error: '下载校验失败' }) + render() + + await user.click(await screen.findByRole('button', { name: '安装' })) + + expect(await screen.findByText('模板市场:下载校验失败')).toBeVisible() + expect(screen.getByText('霓光指挥日报')).toBeVisible() + }) + + it('previews a market template and can switch to it once installed', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + + const installedRow = (await screen.findByText('极简速读')).closest('.report-template-item')! + await user.click(within(installedRow as HTMLElement).getByRole('button', { name: '查看版式' })) + const dialog = await screen.findByRole('dialog', { name: '极简速读' }) + await user.click(within(dialog).getByRole('button', { name: '选择此模板' })) + + expect(onChange).toHaveBeenCalledWith(`external:${installed.id}@${installed.version}`) + }) + + it('does not let an uninstalled market template be selected from the preview', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + + const availableRow = (await screen.findByText('霓光指挥日报')).closest('.report-template-item')! + await user.click(within(availableRow as HTMLElement).getByRole('button', { name: '查看版式' })) + const dialog = await screen.findByRole('dialog', { name: '霓光指挥日报' }) + + expect(within(dialog).getByRole('button', { name: '请先安装' })).toBeDisabled() + expect(within(dialog).queryByRole('button', { name: '使用此模板' })).not.toBeInTheDocument() + expect(onChange).not.toHaveBeenCalled() + }) + + it('marks the active market template and hides the switch action', async () => { + render( + + ) + + const installedRow = (await screen.findByText('极简速读')).closest('.report-template-item')! + expect(within(installedRow as HTMLElement).getByRole('button', { name: '当前使用' })).toBeDisabled() + expect(within(installedRow as HTMLElement).queryByRole('button', { name: '使用' })).toBeNull() + expect(within(installedRow as HTMLElement).getByRole('button', { name: '卸载' })).toBeEnabled() + }) +}) diff --git a/tests/e2e/report-template-market-tab.spec.ts b/tests/e2e/report-template-market-tab.spec.ts new file mode 100644 index 0000000..2f02b5b --- /dev/null +++ b/tests/e2e/report-template-market-tab.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test' +import { launchTestApp } from './support/electron' + +/** + * 社区模板市场现在是日报工作区顶部的第三个标签页,也是模板市场的主入口: + * 浏览、预览、安装、选用与卸载都在这里;生成页只保留选用与预览。 + */ +test('REPORT-TEMPLATE-MARKET-TAB-01 browses and installs community templates from the report tabs', async () => { + const fixture = await launchTestApp({ templateMarket: 'fixture' }) + const page = fixture.page + const pageErrors: Error[] = [] + page.on('pageerror', (error) => pageErrors.push(error)) + try { + await page.getByRole('button', { name: '日报' }).click() + await page.getByRole('button', { name: '开始生成日报' }).click() + + await page.getByRole('tab', { name: '社区模板市场' }).click() + await expect(page.getByRole('heading', { name: '社区模板市场' })).toBeVisible() + await expect(page.getByText('经典日报')).toBeVisible() + await expect(page.getByText('微信信息流')).toBeVisible() + await expect(page.getByText('已安装 0 个市场模板,另有 1 个可以安装。')).toBeVisible() + + // 投稿入口紧跟在标题下方,不需要滚到底部就能看到。 + const contributeLink = page.getByRole('link', { name: '前往提交模板' }) + await expect(contributeLink).toBeInViewport() + await expect(contributeLink).toHaveAttribute( + 'href', + 'https://github.com/Wxw-Gu/TraceMemo-Templates' + ) + await expect(page.getByText('想把你做的模板分享给其他人?')).toBeVisible() + + const marketRow = page.locator('.report-template-item').filter({ hasText: '霓光指挥日报' }) + await expect(marketRow).toBeVisible() + await expect(marketRow.getByRole('button', { name: '安装' })).toBeEnabled() + + await marketRow.getByRole('button', { name: '安装' }).click() + await expect(page.getByText('已安装 1 个市场模板,另有 0 个可以安装。')).toBeVisible() + await expect(page.getByText('已安装市场模板')).toBeVisible() + + // 已安装后市场页提供卸载入口,未安装时只有安装。 + await expect( + marketRow.getByRole('button', { name: '卸载' }) + ).toBeEnabled() + + await page.getByRole('tab', { name: '今日日报' }).click() + await expect(page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible() + + const templateSection = page.getByRole('heading', { name: '日报模板' }).locator('..') + // 生成页只保留选用与预览:没有卸载,也不重复展示市场列表。 + await expect(templateSection.getByText('霓光指挥日报')).toBeVisible() + await expect(templateSection.getByRole('button', { name: '卸载' })).toHaveCount(0) + await expect(templateSection.getByRole('button', { name: '安装' })).toHaveCount(0) + + await page.getByRole('tab', { name: '社区模板市场' }).click() + const roundTripRow = page.locator('.report-template-item').filter({ hasText: '霓光指挥日报' }) + await roundTripRow.getByRole('button', { name: '卸载' }).click() + await expect(page.getByText('已安装 0 个市场模板,另有 1 个可以安装。')).toBeVisible() + + expect(pageErrors).toEqual([]) + } finally { + await fixture.close() + } +}) diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs index ad4eac8..a20e109 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -991,6 +991,72 @@ handle('report:updateGeneratedTemplate', (request) => ({ handle('report:deleteGenerated', () => ({ success: true })) handle('report:reveal', () => ({ success: true })) handle('copy-image', () => ({ success: true })) +// 社区模板市场:默认关闭(返回空目录),测试用例可通过 WXE_E2E_TEMPLATE_MARKET=fixture 打开合成目录。 +const templateMarketEnabled = process.env.WXE_E2E_TEMPLATE_MARKET === 'fixture' +const templateMarketEntry = { + id: 'community.github.example.neonboard', + version: '1.0.2', + interfaceVersion: '1', + name: '霓光指挥日报', + description: '霓光主题的桌面指挥日报,突出实时讨论、重点消息与行动项。', + author: 'TraceMemo 社区', + platform: 'desktop', + tags: ['desktop'], + license: 'MIT', + minAppVersion: null, + download: 'https://example.com/packages/community.github.example.neonboard/1.0.2/template.zip', + sizeBytes: 2048, + sha256: 'b'.repeat(64), + status: 'published' +} +const templateMarketInstalled = [] +handle('report-template:list', () => + templateMarketInstalled.map((entry) => ({ + id: entry.id, + version: entry.version, + interfaceVersion: entry.interfaceVersion, + source: 'installed', + name: entry.name, + author: entry.author, + entryPath: path.join(userData, 'report-templates', entry.id, 'template.html'), + capture: { width: 1280, maxWidth: 1280, maxHeight: 4000 }, + license: { spdx: entry.license }, + installedAt: '2026-09-15T00:00:00.000Z' + })) +) +handle('report-template-market:list', () => + templateMarketEnabled + ? { + success: true, + catalog: { + schemaVersion: '1', + generatedAt: '2026-09-15T00:00:00.000Z', + status: 'published', + templates: [templateMarketEntry] + } + } + : { success: true, catalog: { schemaVersion: '1', status: 'published', templates: [] } } +) +handle('report-template-market:install', (id, version) => { + if ( + !templateMarketEnabled || + String(id) !== templateMarketEntry.id || + String(version) !== templateMarketEntry.version + ) { + return { success: false, error: '模板不存在', code: 'unknown_template' } + } + if (!templateMarketInstalled.some((entry) => entry.id === id && entry.version === version)) { + templateMarketInstalled.push(templateMarketEntry) + } + return { success: true, catalogEntry: templateMarketEntry } +}) +handle('report-template:uninstall', (id, version) => { + const index = templateMarketInstalled.findIndex( + (entry) => entry.id === String(id) && entry.version === String(version) + ) + if (index >= 0) templateMarketInstalled.splice(index, 1) + return { success: true } +}) handle('api:copyText', () => ({ success: true })) handle('app-log:write', (entry) => { const safe = JSON.stringify(entry) diff --git a/tests/e2e/support/electron.ts b/tests/e2e/support/electron.ts index 40c823c..1a1b06b 100644 --- a/tests/e2e/support/electron.ts +++ b/tests/e2e/support/electron.ts @@ -16,6 +16,8 @@ export interface TestApplication { export async function launchTestApp( options: { mode?: 'connected' | 'disconnected' + /** 让模板市场 IPC 返回确定性的合成目录,供社区模板市场页视觉/行为用例使用。 */ + templateMarket?: 'off' | 'fixture' userData?: string largeContacts?: number corruptCache?: boolean @@ -55,7 +57,8 @@ export async function launchTestApp( WXE_E2E_UNSIGNED_MAC_UPDATE: options.unsignedMacUpdate ? '1' : '0', WXE_E2E_REALTIME_REORDER: options.realtimeReorder ? '1' : '0', WXE_E2E_NOW_MS: options.now ? String(options.now) : '', - WXE_E2E_APPEARANCE_THEME: options.appearanceTheme || 'light' + WXE_E2E_APPEARANCE_THEME: options.appearanceTheme || 'light', + WXE_E2E_TEMPLATE_MARKET: options.templateMarket || 'off' } }) const page = await app.firstWindow()