反合编辑器素材库分支到master
@@ -5,6 +5,7 @@ name = "Genarrative"
|
||||
[setup]
|
||||
script = '''
|
||||
cp "$CODEX_SOURCE_TREE_PATH/.env.secrets.local" "$CODEX_WORKTREE_PATH/.env.secrets.local"
|
||||
git reset --hard
|
||||
npm install
|
||||
npm run codegraph:init
|
||||
npm run codegraph:index
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 图片画布编辑器 Lovart 化执行跟踪
|
||||
|
||||
更新时间:`2026-06-17`
|
||||
更新时间:`2026-06-23`
|
||||
|
||||
## 目标
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
- `npm run check:encoding`
|
||||
- `git diff --check`
|
||||
- Headless Playwright smoke:`http://127.0.0.1:10000/editor` 可展示画布、缩放菜单、底部工具栏和图片工具栏;只启动 `dev:web` 时 `/api/*` 代理 500 属于未启动后端的预期现象。
|
||||
- 2026-06-23 规范图选择回归修正:角色规范槽只接受画布中的规范图,图标素材与 UI 设计规范槽只接受图标规范图;选择普通角色图、图标图或其它不合格图片时不写回引用,并在画板顶部显示 warning toast。已验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/useImageCanvasGenerationWorkflow.test.tsx src/components/image-editor/useImageCanvasGenerationSurface.test.tsx`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
|
||||
- 2026-06-12 Lovart 布局修正 smoke:`http://127.0.0.1:10000/editor` 已移除左侧竖向工具栏和右侧独立图层栏;素材、已生成文件、图层统一收在左侧可折叠面板,中央画布和周边面板保持浅色一体布局;截图留存于 `output/playwright/editor-left-integrated-light.png`。
|
||||
- 2026-06-12 外圈背景修正 smoke:`http://127.0.0.1:10000/editor` 的编辑器宿主和画布根容器已铺成同一块白色工作台,不再通过圆角边框露出底部平台背景;截图留存于 `output/playwright/editor-no-outer-background.png`。
|
||||
- 2026-06-12 画布背景与原生菜单修正 smoke:`http://127.0.0.1:10000/editor` 已拦截编辑器区域右键菜单,禁用长按文本选择 / iOS callout,并移除画布网格线与棋盘格底纹;截图留存于 `output/playwright/editor-plain-background.png`。
|
||||
|
||||
@@ -2,6 +2,8 @@ import type {
|
||||
AdminUpsertCreationEntryEventBannersRequest,
|
||||
AdminUpsertCreationEntryTypeConfigRequest,
|
||||
AdminCreationEntryConfigResponse,
|
||||
AdminDashboardQuery,
|
||||
AdminDashboardResponse,
|
||||
AdminDebugHttpRequest,
|
||||
AdminDebugHttpResponse,
|
||||
AdminDisableProfileRedeemCodeRequest,
|
||||
@@ -145,6 +147,16 @@ export function getAdminOverview(token: string) {
|
||||
return request<AdminOverviewResponse>('/admin/api/overview', { token });
|
||||
}
|
||||
|
||||
export function getAdminDashboard(
|
||||
token: string,
|
||||
query: AdminDashboardQuery = {},
|
||||
) {
|
||||
return request<AdminDashboardResponse>(
|
||||
`/admin/api/dashboard${buildDashboardQuery(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminDatabaseTables(token: string) {
|
||||
return request<AdminDatabaseTableListResponse>('/admin/api/database/tables', {
|
||||
token,
|
||||
@@ -425,6 +437,14 @@ function buildQueryString(query: AdminTrackingEventListQuery) {
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildDashboardQuery(query: AdminDashboardQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'granularity', query.granularity);
|
||||
appendQueryParam(params, 'anchor', query.anchor);
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function buildDatabaseTableRowsQuery(query: AdminDatabaseTableRowsQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'search', query.search);
|
||||
|
||||
@@ -54,6 +54,74 @@ export interface AdminOverviewResponse {
|
||||
database: AdminDatabaseOverviewPayload;
|
||||
}
|
||||
|
||||
export type AdminDashboardGranularity = 'day' | 'week' | 'month';
|
||||
|
||||
export interface AdminDashboardQuery {
|
||||
granularity?: AdminDashboardGranularity;
|
||||
anchor?: string;
|
||||
}
|
||||
|
||||
export interface AdminDashboardResponse {
|
||||
range: AdminDashboardRangePayload;
|
||||
metrics: AdminDashboardMetricsPayload;
|
||||
charts: AdminDashboardChartPayload[];
|
||||
operations: AdminDashboardOperationsPayload;
|
||||
warnings: string[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface AdminDashboardRangePayload {
|
||||
granularity: AdminDashboardGranularity;
|
||||
anchorDate: string;
|
||||
periodStartDate: string;
|
||||
periodEndDate: string;
|
||||
periodLabel: string;
|
||||
}
|
||||
|
||||
export interface AdminDashboardMetricsPayload {
|
||||
generatedAssets: number;
|
||||
consumedMudPoints: number;
|
||||
totalRegisteredUsers: number;
|
||||
visitUsers: number;
|
||||
totalVisitUsers: number;
|
||||
visitCount: number;
|
||||
totalVisitCount: number;
|
||||
currentUsers: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardChartPayload {
|
||||
id: string;
|
||||
title: string;
|
||||
unit: string;
|
||||
total: number;
|
||||
buckets: AdminDashboardChartBucketPayload[];
|
||||
}
|
||||
|
||||
export interface AdminDashboardChartBucketPayload {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardOperationsPayload {
|
||||
cards: AdminDashboardOperationMetricPayload[];
|
||||
assetKindBreakdown: AdminDashboardBreakdownRowPayload[];
|
||||
moduleVisitBreakdown: AdminDashboardBreakdownRowPayload[];
|
||||
}
|
||||
|
||||
export interface AdminDashboardOperationMetricPayload {
|
||||
id: string;
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
}
|
||||
|
||||
export interface AdminDashboardBreakdownRowPayload {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AdminServiceOverviewPayload {
|
||||
bindHost: string;
|
||||
bindPort: number;
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
setStoredAdminToken,
|
||||
} from '../auth/adminAuthStore';
|
||||
import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage';
|
||||
import {AdminDashboardPage} from '../pages/AdminDashboardPage';
|
||||
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
|
||||
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
|
||||
import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
|
||||
@@ -173,6 +174,9 @@ export function AdminApp() {
|
||||
onLogout={handleLogout}
|
||||
onRouteChange={handleRouteChange}
|
||||
>
|
||||
{routeId === 'dashboard' ? (
|
||||
<AdminDashboardPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
{routeId === 'overview' ? (
|
||||
<AdminOverviewPage token={token} onUnauthorized={handleUnauthorized} />
|
||||
) : null}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Activity,
|
||||
Bug,
|
||||
BadgeDollarSign,
|
||||
Coins,
|
||||
@@ -30,7 +31,8 @@ interface AdminShellProps {
|
||||
}
|
||||
|
||||
const routeIcons = {
|
||||
overview: LayoutDashboard,
|
||||
dashboard: LayoutDashboard,
|
||||
overview: Activity,
|
||||
tables: Database,
|
||||
debug: Bug,
|
||||
tracking: Table2,
|
||||
|
||||
@@ -2,6 +2,17 @@ import {expect, test} from 'vitest';
|
||||
|
||||
import {adminRoutes, resolveAdminRoute, routeHash} from './adminRoutes';
|
||||
|
||||
test('后台默认进入 Dashboard', () => {
|
||||
expect(adminRoutes[0]).toEqual({
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
hash: '#dashboard',
|
||||
});
|
||||
expect(resolveAdminRoute('')).toBe('dashboard');
|
||||
expect(resolveAdminRoute('#unknown')).toBe('dashboard');
|
||||
expect(routeHash('dashboard')).toBe('#dashboard');
|
||||
});
|
||||
|
||||
// 中文注释:后台入口公告必须作为独立导航存在,避免公告表单被误藏在入口开关页。
|
||||
test('后台入口公告路由可通过导航和 hash 访问', () => {
|
||||
expect(adminRoutes).toContainEqual({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** 后台单页应用可导航的路由标识,入口公告独立于入口开关维护。 */
|
||||
export type AdminRouteId =
|
||||
| 'dashboard'
|
||||
| 'overview'
|
||||
| 'tables'
|
||||
| 'debug'
|
||||
@@ -22,7 +23,8 @@ export interface AdminRouteDefinition {
|
||||
}
|
||||
|
||||
export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'overview', label: '总览', hash: '#overview'},
|
||||
{id: 'dashboard', label: 'Dashboard', hash: '#dashboard'},
|
||||
{id: 'overview', label: '服务总览', hash: '#overview'},
|
||||
{id: 'tables', label: '表查询', hash: '#tables'},
|
||||
{id: 'debug', label: 'API 调试', hash: '#debug'},
|
||||
{id: 'tracking', label: '埋点数据', hash: '#tracking'},
|
||||
@@ -37,12 +39,12 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
|
||||
];
|
||||
|
||||
/** 根据地址栏 hash 解析后台路由,未知 hash 回落到总览页。 */
|
||||
/** 根据地址栏 hash 解析后台路由,未知 hash 回落到 Dashboard。 */
|
||||
export function resolveAdminRoute(hash: string): AdminRouteId {
|
||||
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
|
||||
return (
|
||||
adminRoutes.find((route) => route.hash === normalizedHash)?.id ??
|
||||
'overview'
|
||||
'dashboard'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +53,6 @@ export function routeHash(routeId: AdminRouteId) {
|
||||
return (
|
||||
adminRoutes.find((route) => route.id === routeId)?.hash ??
|
||||
adminRoutes[0]?.hash ??
|
||||
'#overview'
|
||||
'#dashboard'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import {fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {beforeEach, expect, test, vi} from 'vitest';
|
||||
|
||||
import {getAdminDashboard} from '../api/adminApiClient';
|
||||
import type {AdminDashboardResponse} from '../api/adminApiTypes';
|
||||
import {AdminDashboardPage} from './AdminDashboardPage';
|
||||
|
||||
vi.mock('../api/adminApiClient', () => ({
|
||||
formatAdminApiError: vi.fn((error: unknown) =>
|
||||
error instanceof Error ? error.message : '请求失败',
|
||||
),
|
||||
getAdminDashboard: vi.fn(),
|
||||
isAdminApiError: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const dashboardResponse: AdminDashboardResponse = {
|
||||
range: {
|
||||
granularity: 'day',
|
||||
anchorDate: '2026-06-23',
|
||||
periodStartDate: '2026-06-23',
|
||||
periodEndDate: '2026-06-23',
|
||||
periodLabel: '2026-06-23',
|
||||
},
|
||||
metrics: {
|
||||
generatedAssets: 12,
|
||||
consumedMudPoints: 88,
|
||||
totalRegisteredUsers: 1200,
|
||||
visitUsers: 34,
|
||||
totalVisitUsers: 456,
|
||||
visitCount: 98,
|
||||
totalVisitCount: 9876,
|
||||
currentUsers: 7,
|
||||
},
|
||||
charts: [
|
||||
{
|
||||
id: 'generated-assets',
|
||||
title: '生产素材',
|
||||
unit: '个',
|
||||
total: 12,
|
||||
buckets: [{key: '2026-06-23', label: '2026-06-23', value: 12}],
|
||||
},
|
||||
],
|
||||
operations: {
|
||||
cards: [
|
||||
{
|
||||
id: 'period-generated-assets',
|
||||
label: '生产素材',
|
||||
value: 12,
|
||||
unit: '个',
|
||||
},
|
||||
],
|
||||
assetKindBreakdown: [
|
||||
{key: 'editor_generated_image', label: '画板图片', value: 9},
|
||||
],
|
||||
moduleVisitBreakdown: [{key: 'auth', label: '认证', value: 30}],
|
||||
},
|
||||
warnings: [],
|
||||
generatedAt: '2026-06-23T12:00:00Z',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getAdminDashboard).mockResolvedValue(dashboardResponse);
|
||||
});
|
||||
|
||||
test('Dashboard 默认加载今日指标并支持运营汇总页签', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
expect(await screen.findByText('今日总生产素材数')).toBeTruthy();
|
||||
expect(screen.getByText('总注册用户')).toBeTruthy();
|
||||
expect(screen.getByText('当前使用人数(五分钟统计一次)')).toBeTruthy();
|
||||
expect(screen.getByText('生产素材')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '运营汇总' }));
|
||||
|
||||
expect(screen.getByText('素材类型分布')).toBeTruthy();
|
||||
expect(screen.getByText('画板图片')).toBeTruthy();
|
||||
expect(screen.getByText('访问模块分布')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('Dashboard 选择周时带上 week granularity 和周一 anchor', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
await screen.findByText('今日总生产素材数');
|
||||
await user.click(screen.getByRole('button', { name: '周' }));
|
||||
fireEvent.change(screen.getByLabelText('周'), {
|
||||
target: {value: '2026-W27'},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
||||
granularity: 'week',
|
||||
anchor: '2026-06-29',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('Dashboard 选择月份时带上 month granularity 和月初 anchor', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
|
||||
|
||||
await screen.findByText('今日总生产素材数');
|
||||
await user.click(screen.getByRole('button', { name: '月' }));
|
||||
fireEvent.change(screen.getByLabelText('月份'), {
|
||||
target: {value: '2026-07'},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
|
||||
granularity: 'month',
|
||||
anchor: '2026-07-01',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -271,6 +271,216 @@ button:disabled {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-dashboard-heading {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.admin-dashboard-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-dashboard-granularity {
|
||||
width: 188px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.admin-dashboard-date-field {
|
||||
width: 170px;
|
||||
}
|
||||
|
||||
.admin-dashboard-tabs {
|
||||
display: inline-grid;
|
||||
width: fit-content;
|
||||
grid-template-columns: repeat(2, minmax(96px, 1fr));
|
||||
gap: 6px;
|
||||
border: 1px solid #e1ccbb;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.admin-dashboard-tabs button {
|
||||
min-height: 38px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
color: #755a49;
|
||||
background: transparent;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.admin-dashboard-tabs button[data-active="true"] {
|
||||
color: #8f3f27;
|
||||
background: #f4e5d7;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid,
|
||||
.admin-dashboard-chart-grid,
|
||||
.admin-dashboard-operation-grid {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard-operation-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard-chart-grid,
|
||||
.admin-dashboard-operations {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-dashboard-operations {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.admin-dashboard-operations > .admin-panel:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-card {
|
||||
gap: 8px;
|
||||
min-height: 132px;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-card[data-compact="true"] {
|
||||
min-height: 112px;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-card span {
|
||||
color: #8f7868;
|
||||
font-size: 13px;
|
||||
font-weight: 750;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-card strong {
|
||||
color: #3d1f10;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-card small {
|
||||
color: #a38f80;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-dashboard-chart-card {
|
||||
min-height: 286px;
|
||||
}
|
||||
|
||||
.admin-dashboard-bars {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(18px, 1fr);
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
min-height: 196px;
|
||||
overflow-x: auto;
|
||||
padding: 4px 2px 0;
|
||||
}
|
||||
|
||||
.admin-dashboard-bar-item {
|
||||
display: grid;
|
||||
min-width: 18px;
|
||||
gap: 7px;
|
||||
align-items: end;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.admin-dashboard-bar-track {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 18px;
|
||||
height: 160px;
|
||||
align-items: flex-end;
|
||||
overflow: hidden;
|
||||
border-radius: 7px;
|
||||
background: #f4e5d7;
|
||||
}
|
||||
|
||||
.admin-dashboard-bar-track span {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-radius: 7px 7px 0 0;
|
||||
background: linear-gradient(180deg, #c87955, #8f3f27);
|
||||
}
|
||||
|
||||
.admin-dashboard-bar-item small {
|
||||
max-width: 48px;
|
||||
overflow: hidden;
|
||||
color: #8f7868;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-panel {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-row {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-row > div:first-child {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-row strong,
|
||||
.admin-dashboard-breakdown-row span {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-row strong {
|
||||
color: #3d1f10;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-row span {
|
||||
color: #8f7868;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-track {
|
||||
height: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: #f4e5d7;
|
||||
}
|
||||
|
||||
.admin-dashboard-breakdown-track span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #8f3f27;
|
||||
}
|
||||
|
||||
.admin-two-column {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(320px, 1.1fr);
|
||||
@@ -1048,6 +1258,8 @@ button:disabled {
|
||||
}
|
||||
|
||||
.admin-overview-grid,
|
||||
.admin-dashboard-chart-grid,
|
||||
.admin-dashboard-operations,
|
||||
.admin-two-column,
|
||||
.admin-two-column-wide,
|
||||
.admin-pricing-grid,
|
||||
@@ -1058,6 +1270,25 @@ button:disabled {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-dashboard-heading {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.admin-dashboard-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.admin-dashboard-granularity,
|
||||
.admin-dashboard-date-field,
|
||||
.admin-dashboard-actions .admin-secondary-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid,
|
||||
.admin-dashboard-operation-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.admin-field-compact {
|
||||
max-width: none;
|
||||
}
|
||||
@@ -1116,6 +1347,23 @@ button:disabled {
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.admin-dashboard-tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-grid,
|
||||
.admin-dashboard-operation-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-dashboard-metric-card {
|
||||
min-height: 112px;
|
||||
}
|
||||
|
||||
.admin-dashboard-chart-card {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.admin-header-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -34,11 +34,13 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView
|
||||
|
||||
图片画布生成类面板的模型泥点默认 JSON、运行时 override、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。
|
||||
|
||||
桌面端全站客服悬浮入口的全局接入点、固定层级、拖拽边界和移动端隐藏口径见 [【前端架构】全站客服悬浮入口接入约定-2026-06-23.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%85%A8%E7%AB%99%E5%AE%A2%E6%9C%8D%E6%82%AC%E6%B5%AE%E5%85%A5%E5%8F%A3%E6%8E%A5%E5%85%A5%E7%BA%A6%E5%AE%9A-2026-06-23.md)。
|
||||
|
||||
桌面端 `/creation` 创作工具主页、顶级“草稿”入口替换为“项目”、最近项目、新建项目和陶泥儿精选素材瀑布流的落地计划见 [【玩法创作】创作主页与项目入口改版计划-2026-06-18.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E4%B8%BB%E9%A1%B5%E4%B8%8E%E9%A1%B9%E7%9B%AE%E5%85%A5%E5%8F%A3%E6%94%B9%E7%89%88%E8%AE%A1%E5%88%92-2026-06-18.md)。
|
||||
|
||||
本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。
|
||||
|
||||
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。Pingora 只作为独立二进制影子网关试点时,边界、路由口径与替换前验收见 [【开发运维】Pingora独立网关试点-2026-06-11.md](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md)。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md)。
|
||||
生产部署切换到 systemd + Nginx + SpacetimeDB 自托管的总方案见 [PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md](./technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md),该文档也是当前生产 Jenkinsfile 的唯一入口。Pingora 只作为独立二进制影子网关试点时,边界、路由口径与替换前验收见 [【开发运维】Pingora独立网关试点-2026-06-11.md](./technical/【开发运维】Pingora独立网关试点-2026-06-11.md)。SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md);private 表迁移 JSON 导入导出、HTTP 413 分片导入和旧数据库迁移流水线经验见 [SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md](./technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md) 与 [JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md](./technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md);后台管理独立前端工程技术方案见 [ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md](./technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md),Dashboard 默认入口、运营指标和统计口径见 [【后台管理】Dashboard运营看板方案-2026-06-23.md](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。
|
||||
|
||||
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
|
||||
|
||||
|
||||
@@ -3689,3 +3689,11 @@
|
||||
- 2026-06-22 调整:release 打包资源在 Windows WebView 内可能以 `http://tauri.localhost/index.html` 出现,这仍是 Tauri 内部资源,不允许被导航拦截交给系统浏览器;`shell/navigation.rs` 必须允许 `http` / `https` 的 `*.localhost` 留在 WebView。Windows release 二进制必须使用 GUI subsystem,避免正式包启动时额外弹出控制台窗口。
|
||||
- 影响范围:`apps/desktop-shell/src-tauri/tauri.conf.json`、`apps/desktop-shell/scripts/check-config.mjs`、`scripts/dev.test.ts`、Expo / Tauri HostBridge 方案文档。
|
||||
- 验证方式:`npm run test -- scripts/dev.test.ts -t "Linux 桌面壳显式指定 web-port"`、`cargo test --manifest-path apps/desktop-shell/src-tauri/Cargo.toml desktop_main_window_config_uses_dev_url_in_dev_builds desktop_webview_navigation_stays_on_packaged_or_same_origin_pages`、`npm run desktop-shell:typecheck`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
## 2026-06-23 后台默认入口切到 Dashboard 运营看板
|
||||
|
||||
- 背景:后台需要默认进入运营数据面板,而不是服务 / 数据库状态页;看板要同时支持日 / 周 / 月筛选,并展示生产素材、泥点消耗、注册、访问和当前使用人数。
|
||||
- 决策:`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`。
|
||||
- 指标口径:生产素材数统计 `editor_project_resource.source_type = generated`;消耗泥点数统计 `profile_wallet_ledger.source_type = asset_operation_consume` 的负向流水绝对值;访问次数只统计 `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`。
|
||||
|
||||
@@ -670,6 +670,14 @@
|
||||
- 验证:`cargo test -p platform-image --test generated_asset_sheets --manifest-path server-rs/Cargo.toml` 通过,且 `cargo check -p api-server --manifest-path server-rs/Cargo.toml` 保持绿灯。
|
||||
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/`、`server-rs/crates/api-server/src/generated_asset_sheets.rs`、`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||
|
||||
## 图片画布图标素材切片不要把断开的高光阴影当独立图标
|
||||
|
||||
- 现象:图片画布生成图标素材或提取 UI 素材后,右侧素材库出现很小的废图;主体图标的阴影、反光、高光或小装饰不完整。
|
||||
- 原因:图标 spritesheet 切片按 alpha 连通域识别素材,模型常把软阴影、高光、小星星等画成与主体断开的透明块;如果直接逐连通域出图,小碎片会抢占图标顺序,主体也会缺边缘装饰。
|
||||
- 处理:在 `platform-image` 的 `sheet.rs` 里先合并靠近主体的辅助连通域,再过滤孤立小碎片,最后给裁剪框保留安全 padding。不要在前端素材卡或画布层里修已经切坏的 PNG。
|
||||
- 验证:`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml` 覆盖断开的高光合并和孤立小碎片过滤;调用方补跑 `cargo test -p api-server editor_icon --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/platform-image/src/generated_asset_sheets/sheet.rs`、`server-rs/crates/api-server/src/editor_project.rs`。
|
||||
|
||||
## UI spritesheet 不要依赖模型直接生成透明背景
|
||||
|
||||
- 现象:拼图或抓大鹅运行态解析 UI spritesheet 时,把整张背景图、棋盘格、叶子或装饰图也当作 UI 素材区域,按钮映射错乱;截图里常表现为底部按钮区只剩透明棋盘格或素材碎片。
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# 全站客服悬浮入口接入约定
|
||||
|
||||
日期:2026-06-23
|
||||
|
||||
## 背景
|
||||
|
||||
主站需要在桌面端每个页面提供稳定可见的客服入口,包括 `/editor/canvas` 这类全屏画布页面。入口使用陶泥儿客服形象图片,不进入具体业务页布局,避免被滚动、画布缩放或面板层级影响。
|
||||
|
||||
## 接入口
|
||||
|
||||
- 全局 React 入口为 `src/components/common/FloatingFeedbackEntry.tsx`。
|
||||
- 在 `src/main.tsx` 的 `RouteImageReadyGate` 内、当前路由组件之后渲染,覆盖主站 `resolveAppRoute` 管理的所有页面。
|
||||
- 图片资产放在 `public/branding/taonier-feedback-entry.png`,并通过 `BRAND_ASSETS.taonierFeedbackEntry` 引用。
|
||||
- 悬浮层样式使用 `.floating-feedback-entry`,通过 `position: fixed` 和最高层级保持在页面最上方。
|
||||
|
||||
## 交互约定
|
||||
|
||||
- 仅桌面端展示,媒体查询为 `(min-width: 1024px)`;移动端不渲染入口。
|
||||
- 初始位置靠近页面左下角,随窗口滚动保持固定。
|
||||
- 用户可以按住拖动,拖拽过程中实时夹在当前 viewport 内,保证图片完整可见,不被窗口边界遮挡。
|
||||
- 窗口尺寸变化后重新夹取当前位置,避免缩小窗口后入口跑出视口。
|
||||
- 点击入口使用新页面打开客服表单:
|
||||
|
||||
```text
|
||||
https://kcnz41bksl1c.feishu.cn/share/base/form/shrcn1p9WpuZMDxrI5ysLLJhw7k
|
||||
```
|
||||
|
||||
- 拖拽释放后短时间内的补发 click 会被吞掉,避免用户拖动入口时误打开表单。
|
||||
|
||||
## 验收口径
|
||||
|
||||
- 桌面端主站任意路由均能看到客服入口,画布页也可见。
|
||||
- 滚动页面时入口位置相对视口不变。
|
||||
- 拖到窗口四边时入口完整留在可见区域内。
|
||||
- 移动端视口不渲染入口。
|
||||
- 单击入口新开页面跳转到飞书表单。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 后台 Dashboard 运营看板方案
|
||||
|
||||
## 范围
|
||||
|
||||
- 后台管理默认入口为 `#dashboard`,原服务 / 数据库状态页保留为 `#overview`,导航展示名为“服务总览”。
|
||||
- Dashboard 由 `GET /admin/api/dashboard` 提供统一 BFF 投影,前端只展示后端返回的 `range`、`metrics`、`charts`、`operations` 和 `warnings`。
|
||||
- 本次不修改 SpacetimeDB schema,不新增统计表;读取现有 private 表后在 api-server 聚合。
|
||||
|
||||
## 查询参数
|
||||
|
||||
`GET /admin/api/dashboard?granularity=day|week|month&anchor=YYYY-MM-DD`
|
||||
|
||||
- `granularity` 默认为 `day`。
|
||||
- `anchor` 使用北京时间日历日期。`week` 和 `month` 选择包含该日期的自然周 / 自然月。
|
||||
- 前端在日 / 周 / 月模式分别展示日期、周、月份选择器;周选择器会把选中的周转换为该周周一作为 `anchor`,月份选择器会转换为当月 1 日作为 `anchor`。
|
||||
- 前端每 5 分钟自动刷新一次,同时保留手动刷新按钮。
|
||||
|
||||
## 指标口径
|
||||
|
||||
- 生产素材数:`editor_project_resource` 中 `source_type = 'generated'` 的资源,按 `created_at` 映射到北京时间业务日。
|
||||
- 消耗泥点数:`profile_wallet_ledger` 中 `source_type = asset_operation_consume` 且 `amount_delta < 0` 的流水绝对值,按 `created_at` 映射到北京时间业务日。
|
||||
- 总注册用户:`profile_dashboard_state` 行数。
|
||||
- 访问次数:`tracking_daily_stat` 中 `scope_kind = site` 的日聚合次数。它表示站点级成功路由 / 站点级事件,不把用户级钱包、任务、生成等业务操作混入访问次数。
|
||||
- 访问人数:当前数据只具备登录用户维度,按 `tracking_daily_stat` 中 `scope_kind = user` 的 `scope_id` 去重;匿名访问人数需要未来补充稳定 visitor id 后才能统计。
|
||||
- 当前使用人数:最近 5 分钟内 `tracking_event` 中有 `user_id` 的登录用户去重;不跟随页面选择的历史日 / 周 / 月。
|
||||
- 运营汇总页签:复用同一时间窗,展示运营指标卡、素材类型分布和访问模块分布。
|
||||
|
||||
## 前后端文件
|
||||
|
||||
- 后端路由:`server-rs/crates/api-server/src/modules/admin.rs`
|
||||
- 后端聚合:`server-rs/crates/api-server/src/admin.rs`
|
||||
- 契约:`server-rs/crates/shared-contracts/src/admin.rs`、`apps/admin-web/src/api/adminApiTypes.ts`
|
||||
- 前端页面:`apps/admin-web/src/pages/AdminDashboardPage.tsx`
|
||||
- 后台路由:`apps/admin-web/src/app/adminRoutes.ts`
|
||||
|
||||
## 验证
|
||||
|
||||
- `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`
|
||||
@@ -54,7 +54,7 @@ npm run check:server-rs-ddd
|
||||
路由树由 `server-rs/crates/api-server/src/app.rs` 统一构造。当前主要分组:
|
||||
|
||||
- 健康检查:`GET /healthz`。
|
||||
- 后台管理:`/admin/api/*`,包括登录、概览、HTTP debug、埋点、表查询、创作入口开关、作品互动配置、作品可见性、兑换码、邀请码、任务配置和充值商品配置。
|
||||
- 后台管理:`/admin/api/*`,包括登录、Dashboard 运营看板、概览、HTTP debug、埋点、表查询、创作入口开关、作品互动配置、作品可见性、兑换码、邀请码、任务配置和充值商品配置。Dashboard 指标口径见 [`docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。
|
||||
- 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
|
||||
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
|
||||
- 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
|
||||
|
||||
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 62 KiB |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"generatedBy": "VectorEngine gpt-image-2",
|
||||
"generatedBy": "VectorEngine gpt-image-2 icon sheet v3 + local transparent slicing",
|
||||
"generatedFor": "/creation 创作工具主页 image2 风格资源",
|
||||
"styleReference": "用户提供参考图 codex-clipboard-5ee77a57-5ceb-46c7-a309-f6c8e374d989.png",
|
||||
"styleReference": "用户提供参考图 codex-clipboard-fe833c5b-42e0-44e6-a0f7-76b9340ed7bf.png",
|
||||
"constraints": [
|
||||
"无图内文字",
|
||||
"不替换现有 UI 文案",
|
||||
@@ -16,102 +16,102 @@
|
||||
{
|
||||
"file": "/creation-home/brand-mascot.png",
|
||||
"subject": "创作主页旧装饰头像,不作为产品 IP 形象",
|
||||
"bytes": 265352
|
||||
"bytes": 113204
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/feature-character.png",
|
||||
"subject": "游戏角色",
|
||||
"bytes": 257922
|
||||
"bytes": 100268
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/feature-coding.png",
|
||||
"subject": "游戏功能编码",
|
||||
"bytes": 280325
|
||||
"bytes": 95259
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/feature-effects.png",
|
||||
"subject": "游戏特效",
|
||||
"bytes": 246466
|
||||
"bytes": 78890
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/feature-export.png",
|
||||
"subject": "一键导出",
|
||||
"bytes": 230725
|
||||
"bytes": 88954
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/feature-marketing.png",
|
||||
"subject": "游戏美宣",
|
||||
"bytes": 272968
|
||||
"bytes": 88415
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/feature-music.png",
|
||||
"subject": "游戏音乐",
|
||||
"bytes": 258027
|
||||
"bytes": 67669
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/feature-scene.png",
|
||||
"subject": "游戏场景",
|
||||
"bytes": 284949
|
||||
"bytes": 114222
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/feature-ui.png",
|
||||
"subject": "游戏 UI",
|
||||
"bytes": 280537
|
||||
"bytes": 65351
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/feature-visual-spec.png",
|
||||
"subject": "游戏视觉规范",
|
||||
"bytes": 297305
|
||||
"bytes": 82856
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/hero-community.png",
|
||||
"subject": "玩家社区按钮图标",
|
||||
"bytes": 250131
|
||||
"bytes": 88168
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/hero-create.png",
|
||||
"subject": "开始创作按钮图标",
|
||||
"bytes": 242745
|
||||
"bytes": 88664
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/nav-create.png",
|
||||
"subject": "左侧导航创作",
|
||||
"bytes": 278377
|
||||
"bytes": 81621
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/nav-discover.png",
|
||||
"subject": "左侧导航发现",
|
||||
"bytes": 261177
|
||||
"bytes": 109929
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/nav-profile.png",
|
||||
"subject": "左侧导航我的",
|
||||
"bytes": 288027
|
||||
"bytes": 83915
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/nav-projects.png",
|
||||
"subject": "左侧导航项目 / 草稿兜底",
|
||||
"bytes": 247891
|
||||
"bytes": 55869
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/nav-recommend.png",
|
||||
"subject": "左侧导航推荐",
|
||||
"bytes": 284543
|
||||
"bytes": 76995
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/project-new.png",
|
||||
"subject": "新建项目",
|
||||
"bytes": 260908
|
||||
"bytes": 43201
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/topbar-search.png",
|
||||
"subject": "顶部搜索",
|
||||
"bytes": 241211
|
||||
"bytes": 83466
|
||||
},
|
||||
{
|
||||
"file": "/creation-home/topbar-wallet.png",
|
||||
"subject": "顶部泥点余额",
|
||||
"bytes": 280555
|
||||
"bytes": 95544
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 286 KiB After Width: | Height: | Size: 77 KiB |