From 20f6f20c5510cae7eb055600880ab9b83dd4f6f5 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 27 Jun 2026 17:15:19 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E4=BF=A1=E6=81=AF=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为图片信息弹窗增加复制信息按钮 复制内容仅包含当前可见元数据字段 补充图片信息复制回归测试和文档约定 --- .../shared-memory/decision-log.md | 2 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 2 +- .../ImageCanvasMetadataModalView.test.tsx | 50 ++++++++++++ .../ImageCanvasMetadataModalView.tsx | 77 ++++++++++++++++++- src/index.css | 11 +++ 5 files changed, 136 insertions(+), 6 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 7210ddce7..c23510bf6 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2835,7 +2835,7 @@ ## 2026-06-16 图片画布图片信息页不展示生图 Prompt - 背景:图片画布中每张生成图片的信息页原来展示 `Prompt` 和复制 Prompt,但该字段可能是后端组装后的生图提示词,不适合作为用户可见的图片输入信息。 -- 决策:图片信息页删除生图 Prompt 展示和复制入口,改为展示生成时的用户面板输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色规范、常规参考图、图标规范和修改参考图等参考图卡片。旧数据或上传图片没有输入快照时显示 `-`,不得回退展示内部 Prompt。 +- 决策:图片信息页删除生图 Prompt 展示和复制入口,改为展示生成时的用户面板输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、修改要求,以及角色规范、常规参考图、图标规范和修改参考图等参考图卡片,并只允许“复制信息”复制当前可见字段。旧数据或上传图片没有输入快照时显示 `-`,不得回退展示内部 Prompt。 - 影响范围:`src/components/image-editor/ImageCanvasEditorView.tsx`、图片画布 layout snapshot、图片画布技术方案。 - 验证方式:`npm run test -- src/components/image-editor/ImageCanvasEditorView.test.tsx` 应覆盖图片信息页无 `Prompt`、无 `复制Prompt`,并展示普通生成、角色生成、图标素材和修改结果的输入快照。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index b4f865dfc..7040aba55 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -18,7 +18,7 @@ - 默认工具为选择模式;底部工具栏采用 AI 画布工作流工具组:选择、抓手、上传、生成图片、生成视频、生成音乐、生成规范、生成角色形象、生成图标素材、生成 UI 设计图。底部栏不再展示文字工具、形状标注工具和导出工具;上传与生成图片之间、生成音乐与生成规范之间各有一个半图标高度分割线。 - 鼠标中键拖拽始终平移画布;长按 Space 临时进入抓手模式,松开后恢复原工具。 - 图片拖拽时显示水平 / 垂直吸附参考线,吸附到其它图层、生成占位框或画板的边缘与中心线;当移动元素接近两个同轴元素形成的等距位置时,支持横向或纵向等距吸附。 -- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 +- 生成资源右上角显示元数据按钮,点击打开独立元数据窗口。图片信息页不展示后端组装后的生图 Prompt,也不提供复制 Prompt;只展示该图片生成时用户在面板里提交的输入快照,包括普通生成提示词、规范表单字段、角色设定、图标素材描述、快速编辑提示词、重绘提示词,以及角色规范 / 常规参考图 / 图标规范 / 编辑参考图等参考图卡片,并提供“复制信息”复制当前可见字段。参考图输入快照只保存 `refType/refId` 行引用,其中 `refType="project-resource"` 指向 `editor_project_resource.resourceId`,`refType="asset"` 指向 `editor_asset.assetId`;不得把图片 Data URL、普通 URL 或 `objectKey` 写入 `generationInputs.references`。旧数据或上传图片没有输入快照时显示 `-`,禁止回退展示内部 Prompt。 - 对生成资源执行重绘时,在右侧创建新的生成结果图层,并自动调整视图显示原图和新图;重绘面板不因提交成功自动关闭,便于连续改提示词。重绘 / 改造输入框只允许从 `generationInputs.fields` 中恢复用户可见输入快照,例如普通生成提示词、视频描述、音效 `prompt`、背景音乐 `gpt_description_prompt`、角色设定、UI 用户输入、图标素材描述、规范表单和宣发素材字段;禁止回退展示资源 `prompt` / `actualPrompt` 中的后端拼接 Prompt、固定生成模板或模型默认提示词。没有用户输入快照的旧图层打开改造时保持空输入,等待用户重新填写。 - 图片生成 / 修改统一经 api-server BFF 接入 VectorEngine。普通生成、生成规范和重绘保留既有 `gpt-image-2` 路径;图片快速编辑默认从原图模型和分辨率初始化,但提交使用面板当前选择的 `model/aspectRatio/imageSize`;其中生成规范类图片固定 `16:9`、`2K`、`gpt-image-2`,面板底部用与可编辑面板一致的比例 / 尺寸 / 模型胶囊按钮展示固定参数,但按钮为禁用态,不允许在该面板改比例、尺寸或模型。`生成角色形象` 与 `生成图标素材` 支持 `nanobanana2`(`gemini-3.1-flash-image-preview`)和 `gpt-image-2`,默认 `nanobanana2`,并在两类面板之间沿用用户上次选择的模型。`nanobanana2` 走 `/v1beta/models/{model}:generateContent`,请求体写入 `generationConfig.imageConfig.aspectRatio/imageSize`;`gpt-image-2` 走 `/v1/images/generations` 或 `/v1/images/edits`,请求体按 VectorEngine 文档映射 `size`。宣发素材三个工作流(游戏首图、详情五图、运营海报)固定使用 `gpt-image-2`,面板模型胶囊为禁用态,不提供 `nanobanana2` 入口;前端按 workflow 同时提交 `outputSize`、`aspectRatio` 和 `imageSize`,其中游戏首图为 `720x540 / 4:3`、详情单图为 `720x1280 / 9:16`、运营海报为 `1280x720 / 16:9`;后端收到 `kind: "publication-material"` 时也强制归一为 `gpt-image-2` 生成和计费,生成回填图层优先使用生成占位的 `originalWidth/originalHeight`,即使上游回包尺寸漂移也不得把宣发素材卡片变成随机 `1:1` 或 `4:3`。纯文本生成走 `/api/editor/images/generations`,重绘在前端读入当前图层图片 Data URL 后走同一图片生成 BFF,并在原图右侧生成一张新图;普通图层重绘作为 `quick-edit` 参考图提交,角色图层重绘必须按 `kind: "character"` 提交,继续套用角色生成器提示词限定、透明 PNG 后处理和角色资产持久化。`生成视频` 走 `/api/editor/videos/generations`,前端模型入口仅展示 Seedance 2.0 Fast / Seedance 2.0 / Kling 3.0 / Kling 3.0 Omni,不展示 Veo 入口,默认 Seedance 2.0 Fast;视频参数按当前正式面板支持的比例、时长、清晰度和声音开关提交,且 Seedance Fast 与 Seedance 标准版必须按各自真实模型 ID 独立映射,不得混用。生成结果以视频图层加入画布。纯文本生成入口采用 Lovart 式画布内占位图 + 锚定生成输入框:点击生成图片后以当前视口世界中心为目标,经统一 placement 避让后创建选中的灰色占位框,输入框跟随占位框显示;待生成、生成中和失败后保留的占位图都必须继续支持拖动,生成完成时真实生成图或视频落在最新占位框位置,输入框继续跟随新生成图层;占位图失焦时隐藏高亮边框、左上角生成器名称和右上角原始尺寸,重新聚焦时再显示,且名称 / 尺寸在画布缩小时按 viewport 反向缩放保持屏幕尺寸稳定;点击所有图片 / 视频生成入口并确认请求开始后,必须隐藏对应设置面板,只保留画布内占位图或原图预览,并在预览上显示 Lovart 式生成中遮罩,避免“面板仍占屏”或“预览一起消失”。图片快速编辑和重绘在调用图片 BFF 前必须把当前图层图片源读取为图片 Data URL;视频素材快速编辑走视频生成 BFF,不允许走图片模型;角色动作快速编辑固定使用 `seedance2.0-fast` 动作 / 视频模型。前端不持有 provider 密钥;上游失败或配置缺失时恢复当前生成设置面板展示失败,不创建 mock 成功图。 - 快速编辑面板对齐其它生成类面板:首行支持额外参考图,最多 8 张;原图 / 原素材自动作为最后一张隐式参考提交,但不在参考图条里固定展示 `图x`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。尺寸和模型默认从原素材配置 / 分辨率推断,底部参数胶囊可点击修改;图片快速编辑左下角统一显示 `x:y·xK`,右下角模型胶囊紧贴生成按钮。提交前将提示词里对原素材的 `原图`、`当前图片`、`当前图` 或 `图1` 引用改写为最后一张源素材编号,例如额外 1 张参考图时写成 `图2`。点击快速编辑生成后立即创建独立 `Quick Edit Generator` 画布占位播放生成中动画,不再在原图图层上播放生成中遮罩;该生成中占位可通过键盘 `Delete` / `Backspace` 删除。 diff --git a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx index cdabe319f..ade7a0bf0 100644 --- a/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx +++ b/src/components/image-editor/ImageCanvasMetadataModalView.test.tsx @@ -3,9 +3,14 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; +import * as clipboardService from '../../services/clipboard'; import type { CanvasLayer } from './ImageCanvasEditorTypes'; import { ImageCanvasMetadataModalView } from './ImageCanvasMetadataModalView'; +vi.mock('../../services/clipboard', () => ({ + copyTextToClipboard: vi.fn(), +})); + function createLayer(overrides: Partial = {}): CanvasLayer { return { id: 'layer-1', @@ -67,6 +72,51 @@ describe('ImageCanvasMetadataModalView', () => { expect(within(dialog).getByText('generated/object.png')).toBeTruthy(); }); + it('copies the visible metadata snapshot without restoring internal prompt fields', async () => { + vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValueOnce(true); + + render( + , + ); + + const dialog = screen.getByRole('dialog', { name: '图片信息' }); + fireEvent.click(within(dialog).getByRole('button', { name: '复制图片信息' })); + + expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith( + [ + '图片类型:生成图片', + '生成输入:\n生成提示词:清爽游戏按钮\n参考图:角色立绘 (项目资源 · resource-reference)', + 'Model:gpt-image-2', + 'Resolution:1024 x 768 px', + 'Task:123', + 'Object:generated/object.png', + ].join('\n'), + ); + expect(clipboardService.copyTextToClipboard).not.toHaveBeenCalledWith( + expect.stringContaining('内部组装 Prompt 不应复制'), + ); + expect(await within(dialog).findByText('已复制')).toBeTruthy(); + }); + it('renders nanobanana2 instead of the raw Gemini image model id', () => { render( `${field.title}:${field.value}`), + ...references.map( + (reference) => + `${reference.title}:${reference.label} (${formatReferenceType( + reference.refType, + )} · ${reference.refId})`, + ), + ].join('\n'); +} + +function buildLayerMetadataCopyText(layer: CanvasLayer, mediaLabel: string) { + return [ + `${mediaLabel}类型:${formatLayerImageType(layer)}`, + `生成输入:\n${formatGenerationInputsForCopy(layer)}`, + `Model:${layer.model ? getEditorImageModelDisplayName(layer.model) : '-'}`, + layer.mediaType === 'audio' + ? `时长:${formatLayerDurationForDisplay(layer)}` + : `Resolution:${layer.originalWidth} x ${layer.originalHeight} px`, + `Task:${formatTaskIdForDisplay(layer.taskId)}`, + `Object:${layer.objectKey ?? layer.assetObjectId ?? '-'}`, + ].join('\n'); +} + export function ImageCanvasMetadataModalView({ layer, onClose, }: ImageCanvasMetadataModalViewProps) { + const { copyState, copyText, resetCopyState } = useCopyFeedback(); const mediaLabel = layer?.mediaType === 'audio' ? '音频' @@ -28,6 +72,20 @@ export function ImageCanvasMetadataModalView({ : layer?.mediaType === 'image-sequence' ? '序列帧' : '图片'; + const metadataText = layer ? buildLayerMetadataCopyText(layer, mediaLabel) : ''; + + useEffect(() => { + resetCopyState(); + }, [metadataText, resetCopyState]); + + const copyMetadata = () => { + if (!metadataText) { + return; + } + + void copyText(metadataText); + }; + return ( + ) : null + } > {layer ? (
@@ -90,10 +162,7 @@ export function ImageCanvasMetadataModalView({ <>
时长
- {formatCanvasDurationMetric(layer.durationSeconds).replace( - /^时长\s*/u, - '', - )} + {formatLayerDurationForDisplay(layer)}
) : ( diff --git a/src/index.css b/src/index.css index af2729776..015a05fd1 100644 --- a/src/index.css +++ b/src/index.css @@ -8418,6 +8418,9 @@ button.image-canvas-editor__reference-chip:disabled { .image-canvas-editor__metadata-body { padding: 0; + -webkit-touch-callout: default; + -webkit-user-select: text; + user-select: text; } .image-canvas-editor__generate-dialog-body { @@ -8549,6 +8552,14 @@ button.image-canvas-editor__reference-chip:disabled { overflow-wrap: anywhere; } +.image-canvas-editor__metadata-footer { + padding: 0.72rem 0.85rem; +} + +.image-canvas-editor__metadata-copy-button { + justify-content: center; +} + @media (max-width: 760px) { .image-canvas-editor { flex-direction: column; From 107c07815453b48b3c865f3d9679360ded0cfb36 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 27 Jun 2026 17:24:19 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E8=B0=83=E6=95=B4=E5=90=8E=E5=8F=B0Dashboa?= =?UTF-8?q?rd=E6=97=B6=E9=97=B4=E7=AD=9B=E9=80=89=E4=B8=8E=E6=8C=87?= =?UTF-8?q?=E6=A0=87=E5=88=86=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除Dashboard时间快捷里的本时段按钮 让本日本周本月只改起止日期并按时段查询刷新 将总计数据和时段数据拆成独立区块 同步Dashboard页面测试和运营看板文档 --- .../src/pages/AdminDashboardPage.test.tsx | 19 ++- .../src/pages/AdminDashboardPage.tsx | 151 +++++++++++------- .../shared-memory/decision-log.md | 2 +- ...台管理】Dashboard运营看板方案-2026-06-23.md | 4 +- 4 files changed, 104 insertions(+), 72 deletions(-) diff --git a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx index 46c47f20e..781a2862f 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.test.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.test.tsx @@ -71,11 +71,14 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () = const user = userEvent.setup(); render(); - expect(await screen.findByText('本日总生产素材数')).toBeTruthy(); + expect(await screen.findByText('总计数据')).toBeTruthy(); + expect(screen.getByText('时段数据')).toBeTruthy(); + expect(screen.getByText('本日生产素材数')).toBeTruthy(); expect(screen.getByText('总注册用户')).toBeTruthy(); expect(screen.getByText('本日新增用户数')).toBeTruthy(); expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy(); expect(screen.getByText('生产素材')).toBeTruthy(); + expect(screen.queryByRole('button', { name: '本时段' })).toBeNull(); await user.click(screen.getByRole('button', { name: '运营汇总' })); @@ -88,7 +91,7 @@ test('Dashboard 选择本周时按当前终止日期填充整周范围', async ( const user = userEvent.setup(); render(); - await screen.findByText('本日总生产素材数'); + await screen.findByText('本日生产素材数'); fireEvent.change(screen.getByLabelText('终止日期'), { target: { value: '2026-07-02' }, }); @@ -96,8 +99,8 @@ test('Dashboard 选择本周时按当前终止日期填充整周范围', async ( await waitFor(() => { expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', { - granularity: 'week', - anchor: '2026-07-05', + granularity: 'period', + anchor: undefined, startDate: '2026-06-29', endDate: '2026-07-05', }); @@ -108,7 +111,7 @@ test('Dashboard 选择本月时按当前终止日期填充整月范围', async ( const user = userEvent.setup(); render(); - await screen.findByText('本日总生产素材数'); + await screen.findByText('本日生产素材数'); fireEvent.change(screen.getByLabelText('终止日期'), { target: { value: '2026-07-12' }, }); @@ -116,8 +119,8 @@ test('Dashboard 选择本月时按当前终止日期填充整月范围', async ( await waitFor(() => { expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', { - granularity: 'month', - anchor: '2026-07-31', + granularity: 'period', + anchor: undefined, startDate: '2026-07-01', endDate: '2026-07-31', }); @@ -127,7 +130,7 @@ test('Dashboard 选择本月时按当前终止日期填充整月范围', async ( test('Dashboard 手动选择起止日期时使用本时段查询', async () => { render(); - await screen.findByText('本日总生产素材数'); + await screen.findByText('本日生产素材数'); fireEvent.change(screen.getByLabelText('起始日期'), { target: { value: '2026-06-01' }, }); diff --git a/apps/admin-web/src/pages/AdminDashboardPage.tsx b/apps/admin-web/src/pages/AdminDashboardPage.tsx index e3277a236..7c942ea54 100644 --- a/apps/admin-web/src/pages/AdminDashboardPage.tsx +++ b/apps/admin-web/src/pages/AdminDashboardPage.tsx @@ -18,13 +18,12 @@ interface AdminDashboardPageProps { type AdminDashboardTab = 'metrics' | 'operations'; const granularityOptions: Array<{ - id: AdminDashboardGranularity; + id: Exclude; label: string; }> = [ { id: 'day', label: '本日' }, { id: 'week', label: '本周' }, { id: 'month', label: '本月' }, - { id: 'period', label: '本时段' }, ]; export function AdminDashboardPage({ @@ -44,15 +43,15 @@ export function AdminDashboardPage({ const [errorMessage, setErrorMessage] = useState(''); const [isLoading, setIsLoading] = useState(false); - const loadDashboard = useCallback(async () => { + const loadDashboard = useCallback(async (range = dateRange) => { setIsLoading(true); setErrorMessage(''); try { const response = await getAdminDashboard(token, { - granularity, - anchor: granularity === 'period' ? undefined : dateRange.endDate, - startDate: dateRange.startDate, - endDate: dateRange.endDate, + granularity: 'period', + anchor: undefined, + startDate: range.startDate, + endDate: range.endDate, }); setDashboard(response); } catch (error: unknown) { @@ -60,13 +59,7 @@ export function AdminDashboardPage({ } finally { setIsLoading(false); } - }, [ - dateRange.endDate, - dateRange.startDate, - granularity, - onUnauthorized, - token, - ]); + }, [dateRange, onUnauthorized, token]); useEffect(() => { void loadDashboard(); @@ -83,26 +76,49 @@ export function AdminDashboardPage({ }, [loadDashboard]); const metrics = dashboard?.metrics; - const metricCards = useMemo( + const totalMetricCards = useMemo( () => [ - { - id: 'generated-assets', - label: `${rangePrefix(granularity)}总生产素材数`, - value: metrics?.generatedAssets ?? 0, - unit: '个', - }, - { - id: 'consumed-mud-points', - label: `${rangePrefix(granularity)}总消耗泥点数`, - value: metrics?.consumedMudPoints ?? 0, - unit: '泥点', - }, { id: 'total-registered-users', label: '总注册用户', value: metrics?.totalRegisteredUsers ?? 0, unit: '人', }, + { + id: 'total-visit-users', + label: '总访问人数', + value: metrics?.totalVisitUsers ?? 0, + unit: '人', + }, + { + id: 'total-visit-count', + label: '总访问次数', + value: metrics?.totalVisitCount ?? 0, + unit: '次', + }, + { + id: 'current-users', + label: '当前使用人数(五分钟统计一次)', + value: metrics?.currentUsers ?? 0, + unit: '人', + }, + ], + [metrics], + ); + const periodMetricCards = useMemo( + () => [ + { + id: 'generated-assets', + label: `${rangePrefix(granularity)}生产素材数`, + value: metrics?.generatedAssets ?? 0, + unit: '个', + }, + { + id: 'consumed-mud-points', + label: `${rangePrefix(granularity)}消耗泥点数`, + value: metrics?.consumedMudPoints ?? 0, + unit: '泥点', + }, { id: 'new-registered-users', label: `${rangePrefix(granularity)}新增用户数`, @@ -115,30 +131,12 @@ export function AdminDashboardPage({ value: metrics?.visitUsers ?? 0, unit: '人', }, - { - id: 'total-visit-users', - label: '总访问人数', - value: metrics?.totalVisitUsers ?? 0, - unit: '人', - }, { id: 'visit-count', label: `${rangePrefix(granularity)}访问次数`, value: metrics?.visitCount ?? 0, unit: '次', }, - { - id: 'total-visit-count', - label: '总访问次数', - value: metrics?.totalVisitCount ?? 0, - unit: '次', - }, - { - id: 'current-users', - label: '当前使用人数(五分钟统计一次)', - value: metrics?.currentUsers ?? 0, - unit: '人', - }, ], [granularity, metrics], ); @@ -244,16 +242,41 @@ export function AdminDashboardPage({ {activeTab === 'metrics' ? ( <> -
- {metricCards.map((metric) => ( - - ))} -
+
+
+

总计数据

+ 全站累计 +
+
+ {totalMetricCards.map((metric) => ( + + ))} +
+
+ +
+
+

时段数据

+ {dashboard?.range.periodLabel ?? '-'} +
+
+ {periodMetricCards.map((metric) => ( + + ))} +
+
{(dashboard?.charts ?? []).map((chart) => ( @@ -299,13 +322,19 @@ export function AdminDashboardPage({ ); - function handleGranularityChange(nextGranularity: AdminDashboardGranularity) { + function handleGranularityChange( + nextGranularity: Exclude, + ) { + const nextRange = buildPresetDateRange(nextGranularity, dateRange.endDate); + const rangeChanged = + nextRange.startDate !== dateRange.startDate || + nextRange.endDate !== dateRange.endDate; setGranularity(nextGranularity); - setDateRange((current) => - nextGranularity === 'period' - ? normalizeDateRange(current) - : buildPresetDateRange(nextGranularity, current.endDate), - ); + if (rangeChanged) { + setDateRange(nextRange); + } else { + void loadDashboard(nextRange); + } } function handleDateRangeChange( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c23510bf6..73d8e5f26 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3722,7 +3722,7 @@ - 背景:后台需要默认进入运营数据面板,而不是服务 / 数据库状态页;看板要同时支持日 / 周 / 月筛选,并展示生产素材、泥点消耗、注册、访问和当前使用人数。 - 决策:`apps/admin-web` 默认路由改为 `#dashboard`,原 `#overview` 保留为“服务总览”。Dashboard 统一通过 `GET /admin/api/dashboard` 读取 api-server 后端投影,不让前端绕过 BFF 直接访问 SpacetimeDB。后端不新增 SpacetimeDB schema,聚合现有 `editor_project_resource`、`profile_wallet_ledger`、`profile_dashboard_state`、`tracking_daily_stat` 和 `tracking_event`。 -- 2026-06-24 补充:Dashboard 日期控件改为起始日期 / 终止日期;`granularity=period` 使用 `startDate` / `endDate` 自定义闭区间,`day` / `week` / `month` 保留 `anchor` 兼容并由前端自动填充对应范围,页面周期切换展示为“本日 / 本周 / 本月 / 本时段”。 +- 2026-06-24 补充:Dashboard 日期控件改为起始日期 / 终止日期;`granularity=period` 使用 `startDate` / `endDate` 自定义闭区间,`day` / `week` / `month` 保留 `anchor` 兼容;前端展示“本日 / 本周 / 本月”快捷按钮,只修改起止日期并刷新,页面总计数据和时段数据分区展示。 - 指标口径:生产素材数统计 `editor_project_resource.source_type = generated`;消耗泥点数统计 `profile_wallet_ledger.source_type = asset_operation_consume` 的负向流水绝对值;总注册用户和新增用户数均来自 `profile_dashboard_state`,其中新增用户数按 `created_at` 落入当前时间窗统计;访问次数只统计 `tracking_daily_stat.scope_kind = site`;访问人数和当前使用人数按登录用户去重,匿名访问人数需要未来补 visitor id 后才能统计。 - 影响范围:`/admin/api/dashboard`、`shared-contracts` admin DTO、`apps/admin-web` 默认路由和 Dashboard 页面、后台运营文档。 - 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml admin`、`npm run admin-web:typecheck`、`npx vitest run apps/admin-web/src/pages/AdminDashboardPage.test.tsx apps/admin-web/src/app/adminRoutes.test.ts --reporter verbose`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md index 79ef1e63d..506e6a04d 100644 --- a/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md +++ b/docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md @@ -13,7 +13,7 @@ - `granularity` 默认为 `day`。 - `anchor` 使用北京时间日历日期,保留给 `day` / `week` / `month` 兼容旧查询口径。`week` 和 `month` 选择包含该日期的自然周 / 自然月。 - `period` 使用 `startDate` / `endDate` 作为闭区间自定义时段,最多 366 天。 -- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”会按当前终止日期自动填充对应自然日 / 自然周 / 自然月范围,手动修改起止日期或点击“本时段”后按当前日期范围查询。 +- 前端统一展示起始日期和终止日期两个日期选择器;点击“本日 / 本周 / 本月”只按当前终止日期自动填充对应自然日 / 自然周 / 自然月范围并刷新,手动修改起止日期后按当前日期范围查询。 - 前端每 5 分钟自动刷新一次,同时保留手动刷新按钮。 ## 指标口径 @@ -21,7 +21,7 @@ - 生产素材数:`editor_project_resource` 中 `source_type = 'generated'` 的资源,按 `created_at` 映射到北京时间业务日。 - 消耗泥点数:`profile_wallet_ledger` 中 `source_type = asset_operation_consume` 且 `amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。 - 总注册用户:`profile_dashboard_state` 行数。 -- 新增用户数:`profile_dashboard_state` 中 `created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月 / 本时段切换。 +- 新增用户数:`profile_dashboard_state` 中 `created_at` 落在当前筛选时间窗内的账号数,按北京时间业务日归属,支持本日 / 本周 / 本月快捷日期范围。 - 访问次数:`tracking_daily_stat` 中 `scope_kind = site` 的日聚合次数。它表示站点级成功路由 / 站点级事件,不把用户级钱包、任务、生成等业务操作混入访问次数。 - 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat` 中 `scope_kind = user` 的 `scope_id` 去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。 - 当前使用人数:最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。