feat: 修改社区模板市场入口 放到日报二级菜单下, 增加跳转github

This commit is contained in:
Wxw-Gu
2026-09-15 11:03:00 +08:00
parent ff212d594c
commit 67dc45819e
10 changed files with 1075 additions and 273 deletions
+17 -2
View File
@@ -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<SettingsCategoryId>('account-database')
const [reportSourceContact, setReportSourceContact] = useState<Contact | null>(null)
const [reportWorkspaceView, setReportWorkspaceView] = useState<ReportWorkspaceView>('result')
const [reportSection, setReportSection] = useState<'today' | 'scheduled'>('today')
const [reportSection, setReportSection] = useState<'today' | 'scheduled' | 'market'>('today')
const [generatedReports, setGeneratedReports] = useState<GeneratedReportRecord[]>([])
const [selectedReportId, setSelectedReportId] = useState<string | null>(null)
const [latestGeneratedReportId, setLatestGeneratedReportId] = useState<string | null>(null)
@@ -1941,9 +1942,23 @@ function App(): React.ReactElement {
>
定时日报{!supportsPersonalWechatSend && <small>仅 macOS / Windows</small>}
</button>
<button
type="button"
role="tab"
aria-selected={reportSection === 'market'}
className={reportSection === 'market' ? 'active' : ''}
onClick={() => setReportSection('market')}
>
社区模板市场
</button>
</div>
<div className="report-workspace-body">
{reportSection === 'scheduled' ? (
{reportSection === 'market' ? (
<ReportTemplateMarketWorkspace
value={reportGeneration.templateId}
onChange={reportGeneration.setTemplateId}
/>
) : reportSection === 'scheduled' ? (
<ScheduledReportsWorkspace
contacts={contacts}
platformSupported={supportsPersonalWechatSend}
@@ -0,0 +1,396 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import {
DEFAULT_REPORT_TEMPLATE,
REPORT_TEMPLATES,
encodeExternalReportTemplateId,
type ReportTemplateDefinition,
type ReportTemplateSelectionId
} from '../../../../shared/report-templates'
import type {
ReportTemplateCatalog,
ReportTemplateCatalogEntry
} from '../../../../shared/report-template-market'
import type { InstalledReportTemplate } from '../../../../shared/report-template-package'
import { Button } from '../ui'
import {
ReportTemplatePreviewDialog,
TemplateDiagram,
reportTemplatePlatformLabel,
type ReportTemplatePreviewItem
} from './ReportTemplatePreview'
/** 社区模板仓库:投稿模板从这里提交 PR。 */
const TEMPLATE_REPOSITORY_URL = 'https://github.com/Wxw-Gu/TraceMemo-Templates'
/**
* 社区模板市场独立页面:日报工作区顶部标签页的第三个入口。
*
* 与「生成群聊日报」里的模板选择器读同一套 IPC 与远端 catalog。这里是模板市场的主入口:
* 浏览、预览、安装、选用和卸载;生成页只保留选用与预览,不再重复展示市场列表。
*/
export function ReportTemplateMarketWorkspace({
value,
onChange
}: {
value: ReportTemplateSelectionId
onChange: (value: ReportTemplateSelectionId) => void
}): React.ReactElement {
const [installed, setInstalled] = useState<InstalledReportTemplate[]>([])
const [catalog, setCatalog] = useState<ReportTemplateCatalog | null>(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [busyKey, setBusyKey] = useState('')
const [previewing, setPreviewing] = useState<ReportTemplatePreviewItem | null>(null)
const previewTriggerRef = useRef<HTMLButtonElement | null>(null)
const refresh = async (): Promise<void> => {
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<void> => {
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<void> => {
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 => (
<div
key={template.id}
className={`report-template-item ${value === template.id ? 'active' : ''}`}
>
<TemplateDiagram template={template} />
<div className="report-template-body">
<div className="report-template-eyebrow">
{template.platform === 'default' ? '默认模板' : template.label}
</div>
<div className="report-template-title">{template.name}</div>
<div className="report-template-tagline">{template.tagline}</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={(event) => openPreview({ kind: 'builtin', template }, event.currentTarget)}
>
查看版式
</Button>
</div>
</div>
)
const renderInstalledGroup = (): React.ReactElement | null => {
if (!externalInstalled.length) return null
return (
<section className="report-template-group">
<div className="report-template-group-title">已安装市场模板</div>
<div className="report-template-list">
{externalInstalled.map((template) => {
const key = encodeExternalReportTemplateId(template.id, template.version)
const entry = catalogEntryFor(template)
return (
<div key={key} className={`report-template-item ${value === key ? 'active' : ''}`}>
{entry?.preview ? (
<img className="report-template-diagram" src={entry.preview} alt="" />
) : (
<TemplateDiagram template={DEFAULT_REPORT_TEMPLATE} />
)}
<div className="report-template-body">
<div className="report-template-eyebrow">市场 · v{template.version}</div>
<div className="report-template-title">{template.name}</div>
<div className="report-template-tagline">
{entry?.description || `${template.author} · ${reportTemplatePlatformLabel(entry?.platform)}`}
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={(event) =>
openPreview(
{
kind: 'external',
id: template.id,
version: template.version,
name: template.name,
description:
entry?.description || `${template.author} · ${template.version}`,
installed: true,
platform: entry?.platform,
preview: entry?.preview
},
event.currentTarget
)
}
>
查看版式
</Button>
{value === key ? (
<Button variant="ghost" size="sm" disabled>
当前使用
</Button>
) : null}
<Button
variant="ghost"
size="sm"
disabled={busyKey === key}
onClick={() => void uninstall(template)}
>
{busyKey === key ? '处理中…' : '卸载'}
</Button>
</div>
</div>
)
})}
</div>
</section>
)
}
const renderAvailableGroup = (): React.ReactElement | null => {
if (!availableEntries.length) return null
return (
<section className="report-template-group">
<div className="report-template-group-title">模板市场</div>
<div className="report-template-list">
{availableEntries.map((entry) => {
const key = encodeExternalReportTemplateId(entry.id, entry.version)
return (
<div key={key} className="report-template-item">
{entry.preview ? (
<img className="report-template-diagram" src={entry.preview} alt="" />
) : (
<TemplateDiagram template={DEFAULT_REPORT_TEMPLATE} />
)}
<div className="report-template-body">
<div className="report-template-eyebrow">
市场 · v{entry.version} · {entry.author}
</div>
<div className="report-template-title">{entry.name}</div>
<div className="report-template-tagline">{entry.description}</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={(event) =>
openPreview(
{
kind: 'external',
id: entry.id,
version: entry.version,
name: entry.name,
description: entry.description,
installed: false,
platform: entry.platform,
preview: entry.preview
},
event.currentTarget
)
}
>
查看版式
</Button>
<Button size="sm" disabled={busyKey === key} onClick={() => void install(entry)}>
{busyKey === key ? '安装中…' : '安装'}
</Button>
</div>
</div>
)
})}
</div>
</section>
)
}
const describeMarketState = (): string => {
if (loading) return '正在读取模板市场…'
if (error) return `模板市场:${error}`
if (!catalogEntries.length && !externalInstalled.length) return '模板市场暂无可安装模板。'
return `已安装 ${externalInstalled.length} 个市场模板,另有 ${availableEntries.length} 个可以安装。`
}
return (
<div className="report-market-page">
<header className="report-market-header">
<div>
<h1>社区模板市场</h1>
<p>
模板与默认日报读取同一份真实数据,只改变展示方式,适合手机长图分享、桌面归档与团队复盘。
这里可以浏览、预览、安装、选用和卸载社区模板。
</p>
</div>
<Button variant="outline" size="sm" disabled={loading} onClick={() => void refresh()}>
{loading ? '刷新中…' : '刷新'}
</Button>
</header>
<section className="report-market-contribute" aria-label="投稿模板">
<div>
<strong>想把你做的模板分享给其他人?</strong>
<p>
如果想提供给其他人模板样式,可以来 TraceMemo-Templates
项目里提交 PR,方便给其他用户使用。
</p>
</div>
<a
className="report-market-contribute-link"
href={TEMPLATE_REPOSITORY_URL}
target="_blank"
rel="noreferrer"
>
前往提交模板
</a>
</section>
<p className="report-market-summary" role="status">
{describeMarketState()}
</p>
<div className="report-market-body">
<section className="report-template-group">
<div className="report-template-group-title">默认模板</div>
<div className="report-template-list">{renderBuiltinRow(DEFAULT_REPORT_TEMPLATE)}</div>
</section>
<section className="report-template-group">
<div className="report-template-group-title">手机端 · 375–414 px</div>
<div className="report-template-list">
{REPORT_TEMPLATES.filter((template) => template.platform === 'mobile').map(
renderBuiltinRow
)}
</div>
</section>
<section className="report-template-group">
<div className="report-template-group-title">电脑端 · 1280–1920 px</div>
<div className="report-template-list">
{REPORT_TEMPLATES.filter((template) => template.platform === 'desktop').map(
renderBuiltinRow
)}
</div>
</section>
{renderInstalledGroup()}
{renderAvailableGroup()}
{catalog?.generatedAt ? (
<p className="report-market-footnote">
目录更新于 {new Date(catalog.generatedAt).toLocaleString('zh-CN')}
{catalog.source?.commit ? ` · ${catalog.source.commit.slice(0, 8)}` : ''}
</p>
) : null}
</div>
<ReportTemplatePreviewDialog
previewing={previewing}
restoreFocusRef={previewTriggerRef}
onClose={() => setPreviewing(null)}
onConfirm={(item) => {
onChange(
item.kind === 'builtin'
? item.template.id
: encodeExternalReportTemplateId(item.id, item.version)
)
setPreviewing(null)
}}
/>
</div>
)
}
@@ -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 => (
<div className={`report-template-diagram diagram-${template.id}`} aria-hidden="true">
<div className="diagram-masthead">
<i />
<b />
<span />
</div>
<div className="diagram-kpis">
<i />
<i />
<i />
<i />
</div>
<div className="diagram-content">
<div className="diagram-column diagram-column-a">
<b />
<span />
<span />
</div>
<div className="diagram-column diagram-column-b">
<b />
<span />
<span />
<span />
</div>
<div className="diagram-column diagram-column-c">
<b />
<span />
<span />
</div>
</div>
</div>
)
export function ReportTemplatePreviewDialog({
previewing,
confirmLabel = '选择此模板',
restoreFocusRef,
onClose,
onConfirm
}: {
previewing: ReportTemplatePreviewItem | null
confirmLabel?: string
restoreFocusRef: React.RefObject<HTMLButtonElement | null>
onClose: () => void
onConfirm: (item: ReportTemplatePreviewItem) => void
}): React.ReactElement {
return (
<Dialog
open={Boolean(previewing)}
onOpenChange={(open) => {
if (!open) onClose()
}}
>
{previewing && (
<DialogContent
className="report-template-preview-card max-h-[90vh] max-w-[520px] overflow-y-auto bg-surface-muted p-[18px]"
onCloseAutoFocus={(event) => {
event.preventDefault()
restoreFocusRef.current?.focus()
}}
>
<DialogHeader className="pr-8">
<div className="report-template-preview-heading">
<div>
<span>
{previewing.kind === 'builtin'
? previewing.template.label
: `市场 · v${previewing.version}`}
</span>
<DialogTitle>
{previewing.kind === 'builtin' ? previewing.template.name : previewing.name}
</DialogTitle>
</div>
<em>
{reportTemplatePlatformLabel(
previewing.kind === 'builtin'
? previewing.template.platform
: previewing.platform
)}
</em>
</div>
<DialogDescription>
{previewing.kind === 'builtin'
? previewing.template.tagline
: previewing.description}
</DialogDescription>
</DialogHeader>
{previewing.kind === 'builtin' ? (
<TemplateDiagram template={previewing.template} />
) : previewing.preview ? (
<img
src={previewing.preview}
alt={`${previewing.name} 预览`}
className="max-h-[60vh] w-full object-contain"
/>
) : (
<TemplateDiagram template={DEFAULT_REPORT_TEMPLATE} />
)}
<DialogDescription className="report-template-preview-note">
生成时会自动代入当前群聊的真实头像、昵称、消息、讨论摘要、Q&amp;A、统计与关键词。
</DialogDescription>
<DialogFooter className="report-template-preview-actions">
<DialogClose asChild>
<Button variant="outline">关闭</Button>
</DialogClose>
{previewing.kind === 'builtin' || previewing.installed ? (
<Button onClick={() => onConfirm(previewing)}>{confirmLabel}</Button>
) : (
<Button disabled>请先安装</Button>
)}
</DialogFooter>
</DialogContent>
)}
</Dialog>
)
}
@@ -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 => (
<div className={`report-template-diagram diagram-${template.id}`} aria-hidden="true">
<div className="diagram-masthead">
<i />
<b />
<span />
</div>
<div className="diagram-kpis">
<i />
<i />
<i />
<i />
</div>
<div className="diagram-content">
<div className="diagram-column diagram-column-a">
<b />
<span />
<span />
</div>
<div className="diagram-column diagram-column-b">
<b />
<span />
<span />
<span />
</div>
<div className="diagram-column diagram-column-c">
<b />
<span />
<span />
</div>
</div>
</div>
)
export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
value,
onChange,
disabled
}) => {
const [previewing, setPreviewing] = useState<PreviewItem | null>(null)
const [previewing, setPreviewing] = useState<ReportTemplatePreviewItem | null>(null)
const [installed, setInstalled] = useState<InstalledReportTemplate[]>([])
const [catalog, setCatalog] = useState<ReportTemplateCatalog | null>(null)
const [marketError, setMarketError] = useState('')
const [marketBusyKey, setMarketBusyKey] = useState('')
const previewTriggerRef = useRef<HTMLButtonElement | null>(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<ReportTemplateSelectorProps> = ({
[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<void> => {
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<void> => {
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<ReportTemplateSelectorProps> = ({
</div>
</div>
</label>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={(event) => {
previewTriggerRef.current = event.currentTarget
setPreviewing({
kind: 'external',
id: template.id,
version: template.version,
name: template.name,
description: entry?.description || `${template.author} · ${template.version}`,
installed: true,
platform: entry?.platform,
preview: entry?.preview
})
}}
>
查看版式
</Button>
<Button
variant="ghost"
size="sm"
disabled={disabled || marketBusyKey === key}
onClick={() => void uninstallExternal(template)}
>
{marketBusyKey === key ? '处理中…' : '卸载'}
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={(event) => {
previewTriggerRef.current = event.currentTarget
setPreviewing({
kind: 'external',
id: template.id,
version: template.version,
name: template.name,
description: entry?.description || `${template.author} · ${template.version}`,
installed: true,
platform: entry?.platform,
preview: entry?.preview
})
}}
>
查看版式
</Button>
</div>
)
})}
</div>
</div>
)}
{catalogEntries.filter((entry) => !installedKeys.has(encodeExternalReportTemplateId(entry.id, entry.version))).length > 0 && (
<div className="report-template-group report-template-market">
<div className="report-template-group-title">模板市场</div>
<div className="report-template-list">
{catalogEntries
.filter((entry) => !installedKeys.has(encodeExternalReportTemplateId(entry.id, entry.version)))
.map((entry) => {
const key = encodeExternalReportTemplateId(entry.id, entry.version)
return (
<div key={key} className="report-template-item">
<div className="report-template-body">
<div className="report-template-eyebrow">远端 · v{entry.version}</div>
<div className="report-template-title">{entry.name}</div>
<div className="report-template-tagline">{entry.description}</div>
<div className="text-xs text-muted-foreground">
接口 {entry.interfaceVersion} · {platformLabel(entry.platform)} · {entry.sizeBytes} bytes
</div>
</div>
<div className="flex gap-2">
{entry.preview && (
<Button
variant="outline"
size="sm"
onClick={(event) => {
previewTriggerRef.current = event.currentTarget
setPreviewing({
kind: 'external',
id: entry.id,
version: entry.version,
name: entry.name,
description: entry.description,
installed: false,
platform: entry.platform,
preview: entry.preview
})
}}
>
查看版式
</Button>
)}
<Button
size="sm"
disabled={disabled || marketBusyKey === key}
onClick={() => void installFromCatalog(entry)}
>
{marketBusyKey === key ? '安装中…' : '安装'}
</Button>
</div>
</div>
)
})}
</div>
</div>
)}
</RadioGroup>
{marketError && <p className="report-inline-error">模板市场:{marketError}</p>}
<Dialog
open={Boolean(previewing)}
onOpenChange={(open) => {
if (!open) setPreviewing(null)
<ReportTemplatePreviewDialog
previewing={previewing}
restoreFocusRef={previewTriggerRef}
onClose={() => setPreviewing(null)}
onConfirm={(item) => {
onChange(
item.kind === 'builtin'
? item.template.id
: encodeExternalReportTemplateId(item.id, item.version)
)
setPreviewing(null)
}}
>
{previewing && (
<DialogContent
className="report-template-preview-card max-h-[90vh] max-w-[520px] overflow-y-auto bg-surface-muted p-[18px]"
onCloseAutoFocus={(event) => {
event.preventDefault()
previewTriggerRef.current?.focus()
}}
>
<DialogHeader className="pr-8">
<div className="report-template-preview-heading">
<div>
<span>
{previewing.kind === 'builtin'
? previewing.template.label
: `市场 · v${previewing.version}`}
</span>
<DialogTitle>
{previewing.kind === 'builtin' ? previewing.template.name : previewing.name}
</DialogTitle>
</div>
<em>
{platformLabel(
previewing.kind === 'builtin' ? previewing.template.platform : previewing.platform
)}
</em>
</div>
<DialogDescription>
{previewing.kind === 'builtin' ? previewing.template.tagline : previewing.description}
</DialogDescription>
</DialogHeader>
{previewing.kind === 'builtin' ? (
<TemplateDiagram template={previewing.template} />
) : previewing.preview ? (
<img src={previewing.preview} alt={`${previewing.name} 预览`} className="max-h-[60vh] w-full object-contain" />
) : (
<TemplateDiagram template={DEFAULT_REPORT_TEMPLATE} />
)}
<DialogDescription className="report-template-preview-note">
生成时会自动代入当前群聊的真实头像、昵称、消息、讨论摘要、Q&amp;A、统计与关键词。
</DialogDescription>
<DialogFooter className="report-template-preview-actions">
<DialogClose asChild>
<Button variant="outline">关闭</Button>
</DialogClose>
{previewing.kind === 'builtin' || previewing.installed ? (
<Button
onClick={() => {
onChange(
previewing.kind === 'builtin'
? previewing.template.id
: encodeExternalReportTemplateId(previewing.id, previewing.version)
)
setPreviewing(null)
}}
>
选择此模板
</Button>
) : (
<Button disabled>请先安装</Button>
)}
</DialogFooter>
</DialogContent>
)}
</Dialog>
/>
</section>
)
}
@@ -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);
}