Merge remote-tracking branch 'origin/codex/editor-asset-library' into codex/ui-extraction-model-integrate
This commit is contained in:
@@ -71,11 +71,14 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
|
||||
const user = userEvent.setup();
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
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(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
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(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
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(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
await screen.findByText('本日总生产素材数');
|
||||
await screen.findByText('本日生产素材数');
|
||||
fireEvent.change(screen.getByLabelText('起始日期'), {
|
||||
target: { value: '2026-06-01' },
|
||||
});
|
||||
|
||||
@@ -18,13 +18,12 @@ interface AdminDashboardPageProps {
|
||||
type AdminDashboardTab = 'metrics' | 'operations';
|
||||
|
||||
const granularityOptions: Array<{
|
||||
id: AdminDashboardGranularity;
|
||||
id: Exclude<AdminDashboardGranularity, 'period'>;
|
||||
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' ? (
|
||||
<>
|
||||
<div className="admin-dashboard-metric-grid">
|
||||
{metricCards.map((metric) => (
|
||||
<MetricCard
|
||||
key={metric.id}
|
||||
label={metric.label}
|
||||
value={metric.value}
|
||||
unit={metric.unit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>总计数据</h3>
|
||||
<span>全站累计</span>
|
||||
</div>
|
||||
<div className="admin-dashboard-metric-grid">
|
||||
{totalMetricCards.map((metric) => (
|
||||
<MetricCard
|
||||
key={metric.id}
|
||||
label={metric.label}
|
||||
value={metric.value}
|
||||
unit={metric.unit}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>时段数据</h3>
|
||||
<span>{dashboard?.range.periodLabel ?? '-'}</span>
|
||||
</div>
|
||||
<div className="admin-dashboard-metric-grid">
|
||||
{periodMetricCards.map((metric) => (
|
||||
<MetricCard
|
||||
key={metric.id}
|
||||
label={metric.label}
|
||||
value={metric.value}
|
||||
unit={metric.unit}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="admin-dashboard-chart-grid">
|
||||
{(dashboard?.charts ?? []).map((chart) => (
|
||||
@@ -299,13 +322,19 @@ export function AdminDashboardPage({
|
||||
</section>
|
||||
);
|
||||
|
||||
function handleGranularityChange(nextGranularity: AdminDashboardGranularity) {
|
||||
function handleGranularityChange(
|
||||
nextGranularity: Exclude<AdminDashboardGranularity, 'period'>,
|
||||
) {
|
||||
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(
|
||||
|
||||
@@ -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`。
|
||||
@@ -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`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。
|
||||
|
||||
@@ -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> = {}): 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(
|
||||
<ImageCanvasMetadataModalView
|
||||
layer={createLayer({
|
||||
prompt: '内部组装 Prompt 不应复制',
|
||||
model: 'gpt-image-2',
|
||||
taskId: 'gpt-image-2-task-123',
|
||||
objectKey: 'generated/object.png',
|
||||
generationInputs: {
|
||||
fields: [{ title: '生成提示词', value: '清爽游戏按钮' }],
|
||||
references: [
|
||||
{
|
||||
title: '参考图',
|
||||
label: '角色立绘',
|
||||
refType: 'project-resource',
|
||||
refId: 'resource-reference',
|
||||
},
|
||||
],
|
||||
},
|
||||
})}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<ImageCanvasMetadataModalView
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
||||
import { UnifiedModal } from '../common/UnifiedModal';
|
||||
import { useCopyFeedback } from '../common/useCopyFeedback';
|
||||
import type { CanvasLayer } from './ImageCanvasEditorTypes';
|
||||
import { formatTaskIdForDisplay } from './ImageCanvasExportModel';
|
||||
import {
|
||||
@@ -16,10 +20,50 @@ function formatReferenceType(refType: 'project-resource' | 'asset') {
|
||||
return refType === 'project-resource' ? '项目资源' : '素材';
|
||||
}
|
||||
|
||||
function formatLayerDurationForDisplay(layer: CanvasLayer) {
|
||||
return formatCanvasDurationMetric(layer.durationSeconds).replace(
|
||||
/^时长\s*/u,
|
||||
'',
|
||||
);
|
||||
}
|
||||
|
||||
function formatGenerationInputsForCopy(layer: CanvasLayer) {
|
||||
const fields = layer.generationInputs?.fields ?? [];
|
||||
const references = layer.generationInputs?.references ?? [];
|
||||
|
||||
if (!fields.length && !references.length) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
return [
|
||||
...fields.map((field) => `${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 (
|
||||
<UnifiedModal
|
||||
open={Boolean(layer)}
|
||||
@@ -37,6 +95,20 @@ export function ImageCanvasMetadataModalView({
|
||||
onClose={onClose}
|
||||
panelClassName="image-canvas-editor__metadata-dialog"
|
||||
bodyClassName="image-canvas-editor__metadata-body"
|
||||
footerClassName="image-canvas-editor__metadata-footer"
|
||||
footer={
|
||||
layer ? (
|
||||
<CopyFeedbackButton
|
||||
state={copyState}
|
||||
idleLabel="复制信息"
|
||||
accessibleLabel={`复制${mediaLabel}信息`}
|
||||
actionSurface="platform"
|
||||
actionFullWidth
|
||||
className="image-canvas-editor__metadata-copy-button sm:w-auto"
|
||||
onClick={copyMetadata}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{layer ? (
|
||||
<dl className="image-canvas-editor__metadata-grid">
|
||||
@@ -90,10 +162,7 @@ export function ImageCanvasMetadataModalView({
|
||||
<>
|
||||
<dt>时长</dt>
|
||||
<dd>
|
||||
{formatCanvasDurationMetric(layer.durationSeconds).replace(
|
||||
/^时长\s*/u,
|
||||
'',
|
||||
)}
|
||||
{formatLayerDurationForDisplay(layer)}
|
||||
</dd>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -8430,6 +8430,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 {
|
||||
@@ -8561,6 +8564,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;
|
||||
|
||||
Reference in New Issue
Block a user