合并最新master并融合图片生成链路

保留BgFilter complex、cross-check、单次重试、全帧并发排空与任务阶段上报
采用master的生成产物一次上传、真实素材类型及成本和供应商归因
同步后台多账号、Dashboard、SpacetimeDB schema、迁移与生成绑定
融合项目决策记录和相关前后端文档
This commit is contained in:
2026-07-15 05:06:12 +00:00
131 changed files with 8041 additions and 1065 deletions
@@ -1,16 +1,61 @@
import { afterEach, expect, test, vi } from 'vitest';
import {
createAdminAccount,
executeAdminRechargeRefund,
getAdminUserDetail,
listAdminRechargeOrders,
resolveAdminRechargeRefundManualReview,
updateAdminAccount,
} from './adminApiClient';
afterEach(() => {
vi.unstubAllGlobals();
});
test('后台账号创建和更新携带 owner 会话与 Tab 权限', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({account: {accountId: 'member-1'}}), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await createAdminAccount('owner-token', {
username: 'operator',
displayName: '运营',
password: 'secret123',
tabPermissions: ['dashboard', 'tracking'],
enabled: true,
});
await updateAdminAccount('owner-token', 'member/1', {
displayName: '运营二组',
tabPermissions: ['tracking'],
enabled: false,
});
expect(fetchMock.mock.calls[0]?.[0]).toBe('/admin/api/accounts');
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({Authorization: 'Bearer owner-token'}),
}),
);
expect(fetchMock.mock.calls[1]?.[0]).toBe('/admin/api/accounts/member%2F1');
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({
displayName: '运营二组',
tabPermissions: ['tracking'],
enabled: false,
}),
}),
);
});
test('充值订单查询按后台契约序列化筛选参数', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ entries: [] }), {
+31
View File
@@ -1,4 +1,7 @@
import type {
AdminAccountListResponse,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
AdminUpsertCreationEntryEventBannersRequest,
AdminUpsertCreationEntryTypeConfigRequest,
AdminCreationEntryConfigResponse,
@@ -39,6 +42,8 @@ import type {
AdminTrackingEventListResponse,
AdminUpdateWorkVisibilityRequest,
AdminUpdateWorkVisibilityResponse,
AdminUpdateAccountRequest,
AdminUpdateAccountResponse,
AdminUploadedEditorShowcaseCampaignImage,
AdminUpsertEditorShowcaseCampaignRequest,
AdminUpsertFeatureGateConfigRequest,
@@ -188,6 +193,32 @@ export function getAdminMe(token: string) {
return request<AdminMeResponse>('/admin/api/me', { token });
}
export function listAdminAccounts(token: string) {
return request<AdminAccountListResponse>('/admin/api/accounts', {token});
}
export function createAdminAccount(
token: string,
payload: AdminCreateAccountRequest,
) {
return request<AdminCreateAccountResponse>('/admin/api/accounts', {
method: 'POST',
token,
body: payload,
});
}
export function updateAdminAccount(
token: string,
accountId: string,
payload: AdminUpdateAccountRequest,
) {
return request<AdminUpdateAccountResponse>(
`/admin/api/accounts/${encodeURIComponent(accountId)}`,
{method: 'PUT', token, body: payload},
);
}
export function getAdminOverview(token: string) {
return request<AdminOverviewResponse>('/admin/api/overview', { token });
}
+62
View File
@@ -36,10 +36,53 @@ export interface AdminSessionPayload {
username: string;
displayName: string;
roles: string[];
accountRole: 'owner' | 'member';
tabPermissions: string[];
issuedAt: string;
expiresAt: string;
}
export interface AdminAccountPayload {
accountId: string;
username: string;
displayName: string;
accountRole: 'owner' | 'member';
tabPermissions: string[];
enabled: boolean;
tokenVersion: number;
createdBy: string;
updatedBy: string;
createdAt: string;
updatedAt: string;
}
export interface AdminAccountListResponse {
accounts: AdminAccountPayload[];
}
export interface AdminCreateAccountRequest {
username: string;
displayName: string;
password: string;
tabPermissions: string[];
enabled: boolean;
}
export interface AdminCreateAccountResponse {
account: AdminAccountPayload;
}
export interface AdminUpdateAccountRequest {
displayName: string;
password?: string;
tabPermissions: string[];
enabled: boolean;
}
export interface AdminUpdateAccountResponse {
account: AdminAccountPayload;
}
export interface AdminLoginResponse {
token: string;
admin: AdminSessionPayload;
@@ -85,6 +128,8 @@ export interface AdminDashboardMetricsPayload {
consumedMudPoints: number;
totalRegisteredUsers: number;
newRegisteredUsers: number;
day1Retention: AdminDashboardRetentionMetricPayload;
day7Retention: AdminDashboardRetentionMetricPayload;
visitUsers: number;
totalVisitUsers: number;
visitCount: number;
@@ -92,6 +137,12 @@ export interface AdminDashboardMetricsPayload {
currentUsers: number;
}
export interface AdminDashboardRetentionMetricPayload {
eligibleUsers: number;
retainedUsers: number;
rateBasisPoints: number;
}
export interface AdminDashboardChartPayload {
id: string;
title: string;
@@ -416,6 +467,10 @@ export interface AdminEditorAssetPayload {
generationCostMudPoints: number;
createdAt: string;
updatedAt: string;
generator: string;
taskGenerator: string;
taskCostMudPoints: number;
children: AdminEditorAssetPayload[];
}
export interface AdminEditorAssetListResponse {
@@ -629,6 +684,7 @@ export interface ProfileCodeOperationAdminResponse {
code: string;
action: 'create' | 'update' | 'disable' | string;
operatorUserId: string;
operatorDisplayName: string;
createdAt: string;
}
@@ -665,8 +721,10 @@ export interface ProfileTaskConfigAdminResponse {
enabled: boolean;
sortOrder: number;
createdBy: string;
createdByDisplayName: string;
createdAt: string;
updatedBy: string;
updatedByDisplayName: string;
updatedAt: string;
}
@@ -705,8 +763,10 @@ export interface ProfileWalletConfigAdminResponse {
configId: string;
initialMudPoints: number;
createdBy: string;
createdByDisplayName: string;
createdAt: string;
updatedBy: string;
updatedByDisplayName: string;
updatedAt: string;
}
@@ -762,8 +822,10 @@ export interface AdminWalletManualRestrictionPayload {
frozen: boolean;
reason: string;
createdByAdminUserId: string;
createdByAdminDisplayName: string;
createdAtMicros: number;
updatedByAdminUserId: string;
updatedByAdminDisplayName: string;
updatedAtMicros: number;
}
+66 -21
View File
@@ -1,4 +1,4 @@
import {useCallback, useEffect, useState} from 'react';
import {useCallback, useEffect, useMemo, useState} from 'react';
import {
formatAdminApiError,
@@ -18,6 +18,7 @@ import {
setStoredAdminToken,
} from '../auth/adminAuthStore';
import {AdminCreationEntrySwitchPage} from '../pages/AdminCreationEntrySwitchPage';
import {AdminAccountsPage} from '../pages/AdminAccountsPage';
import {AdminDashboardPage} from '../pages/AdminDashboardPage';
import {AdminDebugHttpPage} from '../pages/AdminDebugHttpPage';
import {AdminDatabaseTablesPage} from '../pages/AdminDatabaseTablesPage';
@@ -37,7 +38,12 @@ import {AdminTrackingEventsPage} from '../pages/AdminTrackingEventsPage';
import {AdminWorkVisibilityPage} from '../pages/AdminWorkVisibilityPage';
import {AdminShell} from './AdminShell';
import type {AdminRouteId} from './adminRoutes';
import {resolveAdminRoute, routeHash} from './adminRoutes';
import {
getAccessibleAdminRoutes,
resolveAccessibleAdminRoute,
resolveAdminRoute,
routeHash,
} from './adminRoutes';
type SessionStatus = 'checking' | 'guest' | 'authenticated';
@@ -55,6 +61,13 @@ export function AdminApp() {
useState<ProfileWalletConfigAdminResponse | null>(null);
const [rechargeProductResult, setRechargeProductResult] =
useState<ProfileRechargeProductConfigAdminResponse | null>(null);
const accessibleRoutes = useMemo(
() => (admin ? getAccessibleAdminRoutes(admin) : []),
[admin],
);
const activeRouteId = accessibleRoutes.some((route) => route.id === routeId)
? routeId
: null;
const clearSession = useCallback((message = '') => {
clearStoredAdminToken();
@@ -107,6 +120,26 @@ export function AdminApp() {
};
}, []);
useEffect(() => {
if (status !== 'authenticated' || !admin) {
return;
}
const nextRouteId = resolveAccessibleAdminRoute(
window.location.hash,
accessibleRoutes,
);
if (!nextRouteId) {
return;
}
setRouteId(nextRouteId);
const nextHash = routeHash(nextRouteId);
if (window.location.hash !== nextHash) {
window.history.replaceState(null, '', nextHash);
}
}, [accessibleRoutes, admin, routeId, status]);
useEffect(() => {
const handleHashChange = () => {
setRouteId(resolveAdminRoute(window.location.hash));
@@ -163,69 +196,75 @@ export function AdminApp() {
return (
<AdminShell
admin={admin}
routeId={routeId}
routeId={activeRouteId}
routes={accessibleRoutes}
onLogout={handleLogout}
onRouteChange={handleRouteChange}
>
{routeId === 'dashboard' ? (
{activeRouteId === null ? (
<section className="admin-panel admin-zero-permission-state">
<h2>访</h2>
</section>
) : null}
{activeRouteId === 'dashboard' ? (
<AdminDashboardPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'overview' ? (
{activeRouteId === 'overview' ? (
<AdminOverviewPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'tables' ? (
{activeRouteId === 'tables' ? (
<AdminDatabaseTablesPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'debug' ? (
{activeRouteId === 'debug' ? (
<AdminDebugHttpPage token={token} onUnauthorized={handleUnauthorized} />
) : null}
{routeId === 'tracking' ? (
{activeRouteId === 'tracking' ? (
<AdminTrackingEventsPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'gray-release' ? (
{activeRouteId === 'gray-release' ? (
<AdminGrayReleaseConfigPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'redeem' ? (
{activeRouteId === 'redeem' ? (
<AdminRedeemCodePage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'invite' ? (
{activeRouteId === 'invite' ? (
<AdminInviteCodePage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'creation-announcement' ? (
{activeRouteId === 'creation-announcement' ? (
<AdminCreationEntrySwitchPage
mode="announcements"
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'creation-entry' ? (
{activeRouteId === 'creation-entry' ? (
<AdminCreationEntrySwitchPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'work-visibility' ? (
{activeRouteId === 'work-visibility' ? (
<AdminWorkVisibilityPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'tasks' ? (
{activeRouteId === 'tasks' ? (
<AdminTaskConfigPage
result={taskConfigResult}
token={token}
@@ -233,7 +272,7 @@ export function AdminApp() {
onResultChange={setTaskConfigResult}
/>
) : null}
{routeId === 'profile-wallet' ? (
{activeRouteId === 'profile-wallet' ? (
<AdminProfileWalletConfigPage
result={profileWalletConfigResult}
token={token}
@@ -241,7 +280,7 @@ export function AdminApp() {
onResultChange={setProfileWalletConfigResult}
/>
) : null}
{routeId === 'recharge-products' ? (
{activeRouteId === 'recharge-products' ? (
<AdminRechargeProductPage
result={rechargeProductResult}
token={token}
@@ -249,30 +288,36 @@ export function AdminApp() {
onResultChange={setRechargeProductResult}
/>
) : null}
{routeId === 'recharge-orders' ? (
{activeRouteId === 'recharge-orders' ? (
<AdminRechargeOrderPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'editor-generation-pricing' ? (
{activeRouteId === 'editor-generation-pricing' ? (
<AdminEditorGenerationPricingPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'editor-showcase' ? (
{activeRouteId === 'editor-showcase' ? (
<AdminEditorShowcaseReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{routeId === 'editor-assets' ? (
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'accounts' ? (
<AdminAccountsPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
</AdminShell>
);
}
+9 -6
View File
@@ -19,16 +19,17 @@ import {
TicketCheck,
TicketPercent,
ReceiptText,
Users,
} from 'lucide-react';
import type {ReactNode} from 'react';
import type {AdminSessionPayload} from '../api/adminApiTypes';
import type {AdminRouteId} from './adminRoutes';
import {adminRoutes} from './adminRoutes';
import type {AdminRouteDefinition, AdminRouteId} from './adminRoutes';
interface AdminShellProps {
admin: AdminSessionPayload;
routeId: AdminRouteId;
routeId: AdminRouteId | null;
routes: AdminRouteDefinition[];
children: ReactNode;
onRouteChange: (routeId: AdminRouteId) => void;
onLogout: () => void;
@@ -53,11 +54,13 @@ const routeIcons = {
'creation-announcement': Megaphone,
'creation-entry': SlidersHorizontal,
'work-visibility': Eye,
accounts: Users,
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
export function AdminShell({
admin,
routeId,
routes,
children,
onRouteChange,
onLogout,
@@ -76,7 +79,7 @@ export function AdminShell({
</div>
<nav className="admin-nav" aria-label="后台导航">
{adminRoutes.map((route) => {
{routes.map((route) => {
const Icon = routeIcons[route.id];
return (
<button
@@ -99,7 +102,7 @@ export function AdminShell({
<header className="admin-topbar">
<div className="admin-user">
<span>{admin.displayName || admin.username}</span>
<small>{admin.roles.join(' / ')}</small>
<small>{admin.accountRole === 'owner' ? 'owner' : 'member'}</small>
</div>
<button
className="admin-icon-button"
@@ -116,7 +119,7 @@ export function AdminShell({
</div>
<nav className="admin-bottom-nav" aria-label="后台导航">
{adminRoutes.map((route) => {
{routes.map((route) => {
const Icon = routeIcons[route.id];
return (
<button
+40 -1
View File
@@ -1,6 +1,12 @@
import {expect, test} from 'vitest';
import {adminRoutes, resolveAdminRoute, routeHash} from './adminRoutes';
import {
adminRoutes,
getAccessibleAdminRoutes,
resolveAccessibleAdminRoute,
resolveAdminRoute,
routeHash,
} from './adminRoutes';
test('后台默认进入 Dashboard', () => {
expect(adminRoutes[0]).toEqual({
@@ -79,3 +85,36 @@ test('后台充值管理路由可通过导航和 hash 访问', () => {
expect(resolveAdminRoute('#recharge-orders')).toBe('recharge-orders');
expect(routeHash('recharge-orders')).toBe('#recharge-orders');
});
test('owner 可访问全部业务 Tab 和账号管理', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'owner',
tabPermissions: [],
});
expect(routes).toEqual(adminRoutes);
expect(routes.at(-1)).toMatchObject({id: 'accounts', ownerOnly: true});
});
test('member 只访问已分配 Tab 且无权 hash 回落到第一项', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['tracking', 'recharge-orders'],
});
expect(routes.map((route) => route.id)).toEqual([
'tracking',
'recharge-orders',
]);
expect(resolveAccessibleAdminRoute('#accounts', routes)).toBe('tracking');
expect(resolveAccessibleAdminRoute('#recharge-orders', routes)).toBe(
'recharge-orders',
);
});
test('零权限 member 不回落到 Dashboard', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: [],
});
expect(routes).toEqual([]);
expect(resolveAccessibleAdminRoute('#dashboard', routes)).toBeNull();
});
+36 -1
View File
@@ -17,13 +17,17 @@ export type AdminRouteId =
| 'editor-assets'
| 'creation-announcement'
| 'creation-entry'
| 'work-visibility';
| 'work-visibility'
| 'accounts';
export type AdminTabPermission = Exclude<AdminRouteId, 'accounts'>;
/** 后台导航项定义,hash 是浏览器地址栏和移动底栏共用入口。 */
export interface AdminRouteDefinition {
id: AdminRouteId;
label: string;
hash: string;
ownerOnly?: boolean;
}
export const adminRoutes: AdminRouteDefinition[] = [
@@ -45,8 +49,39 @@ export const adminRoutes: AdminRouteDefinition[] = [
{id: 'creation-announcement', label: '入口公告', hash: '#creation-announcement'},
{id: 'creation-entry', label: '入口开关', hash: '#creation-entry'},
{id: 'work-visibility', label: '作品可见性', hash: '#work-visibility'},
{id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true},
];
export interface AdminRouteAccess {
accountRole: 'owner' | 'member';
tabPermissions: string[];
}
export function getAccessibleAdminRoutes(
admin: AdminRouteAccess,
): AdminRouteDefinition[] {
if (admin.accountRole === 'owner') {
return adminRoutes;
}
const permissions = new Set(admin.tabPermissions);
return adminRoutes.filter(
(route) => !route.ownerOnly && permissions.has(route.id),
);
}
export function resolveAccessibleAdminRoute(
hash: string,
routes: AdminRouteDefinition[],
): AdminRouteId | null {
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
return (
routes.find((route) => route.hash === normalizedHash)?.id ??
routes[0]?.id ??
null
);
}
/** 根据地址栏 hash 解析后台路由,未知 hash 回落到 Dashboard。 */
export function resolveAdminRoute(hash: string): AdminRouteId {
const normalizedHash = hash.trim().toLowerCase().split('?')[0] ?? '';
@@ -146,8 +146,10 @@ test('人工冻结和解除人工冻结分别提交原因且不解除退款欠
frozen: true,
reason: '风险核查',
createdByAdminUserId: 'admin:root',
createdByAdminDisplayName: '后台负责人',
createdAtMicros: 1_720_000_000_000_000,
updatedByAdminUserId: 'admin:root',
updatedByAdminDisplayName: '后台负责人',
updatedAtMicros: 1_720_000_000_000_000,
},
};
@@ -174,6 +176,8 @@ test('人工冻结和解除人工冻结分别提交原因且不解除退款欠
reason: '异常登录',
});
});
expect(await screen.findByText(/后台负责人/)).toBeTruthy();
expect(screen.queryByText(/admin:root/)).toBeNull();
expect(await screen.findByText('解除人工冻结后,退款欠账限制仍会保留。')).toBeTruthy();
await user.type(screen.getByRole('textbox', {name: '人工冻结操作原因'}), '核查完成');
@@ -236,7 +236,7 @@ export function AdminUserDetailDialog({
<span>{detail.wallet.manualRestriction.reason || '未填写原因'}</span>
<small>
{formatMicros(detail.wallet.manualRestriction.updatedAtMicros)} /{' '}
{detail.wallet.manualRestriction.updatedByAdminUserId}
{detail.wallet.manualRestriction.updatedByAdminDisplayName}
</small>
</div>
) : null}
@@ -0,0 +1,305 @@
import {Plus, RefreshCcw, Save} from 'lucide-react';
import {type FormEvent, useEffect, useState} from 'react';
import {
createAdminAccount,
listAdminAccounts,
updateAdminAccount,
} from '../api/adminApiClient';
import type {AdminAccountPayload} from '../api/adminApiTypes';
import {adminRoutes} from '../app/adminRoutes';
import {useAdminWriteConfirm} from '../components/useAdminWriteConfirm';
import {handlePageError} from './pageUtils';
interface AdminAccountsPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
const assignableRoutes = adminRoutes.filter((route) => !route.ownerOnly);
export function AdminAccountsPage({
token,
onUnauthorized,
}: AdminAccountsPageProps) {
const [accounts, setAccounts] = useState<AdminAccountPayload[]>([]);
const [selectedAccountId, setSelectedAccountId] = useState('');
const [username, setUsername] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [enabled, setEnabled] = useState(true);
const [tabPermissions, setTabPermissions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const {confirmWrite, confirmDialog} = useAdminWriteConfirm();
useEffect(() => {
void refreshAccounts();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshAccounts() {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminAccounts(token);
setAccounts(response.accounts);
const selected = response.accounts.find(
(account) => account.accountId === selectedAccountId,
);
if (selected) {
fillForm(selected);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}
function startCreate() {
setSelectedAccountId('');
setUsername('');
setDisplayName('');
setPassword('');
setEnabled(true);
setTabPermissions([]);
setErrorMessage('');
}
function fillForm(account: AdminAccountPayload) {
setSelectedAccountId(account.accountId);
setUsername(account.username);
setDisplayName(account.displayName);
setPassword('');
setEnabled(account.enabled);
setTabPermissions(account.tabPermissions);
setErrorMessage('');
}
function togglePermission(permission: string, checked: boolean) {
setTabPermissions((current) =>
checked
? assignableRoutes
.map((route) => route.id)
.filter((routeId) =>
routeId === permission || current.includes(routeId),
)
: current.filter((item) => item !== permission),
);
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isSaving) {
return;
}
const normalizedUsername = username.trim();
const normalizedDisplayName = displayName.trim();
if (!selectedAccountId && !normalizedUsername) {
setErrorMessage('请输入用户名');
return;
}
if (!normalizedDisplayName) {
setErrorMessage('请输入显示名称');
return;
}
if (!selectedAccountId && !password) {
setErrorMessage('请输入密码');
return;
}
const confirmed = await confirmWrite({
action: selectedAccountId ? '更新后台账号' : '创建后台账号',
target: normalizedUsername,
});
if (!confirmed) {
return;
}
setIsSaving(true);
setErrorMessage('');
try {
const response = selectedAccountId
? await updateAdminAccount(token, selectedAccountId, {
displayName: normalizedDisplayName,
...(password ? {password} : {}),
tabPermissions,
enabled,
})
: await createAdminAccount(token, {
username: normalizedUsername,
displayName: normalizedDisplayName,
password,
tabPermissions,
enabled,
});
setAccounts((current) => {
const rest = current.filter(
(account) => account.accountId !== response.account.accountId,
);
return [...rest, response.account].sort((left, right) =>
left.username.localeCompare(right.username),
);
});
fillForm(response.account);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<div className="admin-action-row">
<button
className="admin-secondary-button"
type="button"
onClick={startCreate}
>
<Plus size={17} aria-hidden="true" />
<span></span>
</button>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshAccounts}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
</div>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<div className="admin-accounts-layout">
<section className="admin-panel admin-account-list">
<div className="admin-panel-heading">
<h3></h3>
<span>{accounts.length}</span>
</div>
{accounts.length ? (
<div className="admin-account-list-items">
{accounts.map((account) => (
<button
data-active={account.accountId === selectedAccountId}
disabled={account.accountRole === 'owner'}
key={account.accountId}
title={account.accountRole === 'owner' ? 'owner' : account.username}
type="button"
onClick={() => {
if (account.accountRole === 'member') {
fillForm(account);
}
}}
>
<span>
<strong>{account.displayName || account.username}</strong>
<small>{account.username}</small>
</span>
<small>
{account.accountRole === 'owner'
? 'owner'
: account.enabled
? '启用'
: '停用'}
</small>
</button>
))}
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无成员账号'}
</div>
)}
</section>
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-panel-heading">
<h3>{selectedAccountId ? '编辑账号' : '添加账号'}</h3>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span></span>
</label>
</div>
<div className="admin-form-row">
<label className="admin-field">
<span></span>
<input
disabled={Boolean(selectedAccountId)}
autoComplete="off"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label className="admin-field">
<span></span>
<input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</label>
</div>
<label className="admin-field">
<span>{selectedAccountId ? '新密码' : '密码'}</span>
<input
autoComplete="new-password"
placeholder={selectedAccountId ? '不修改' : ''}
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<fieldset className="admin-permission-fieldset">
<legend>Tab 访</legend>
<div className="admin-permission-grid">
{assignableRoutes.map((route) => (
<label key={route.id}>
<input
checked={tabPermissions.includes(route.id)}
type="checkbox"
onChange={(event) =>
togglePermission(route.id, event.target.checked)
}
/>
<span>{route.label}</span>
</label>
))}
</div>
</fieldset>
<button
className="admin-primary-button"
disabled={isSaving}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
</div>
{confirmDialog}
</section>
);
}
@@ -1,6 +1,12 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
@@ -29,6 +35,16 @@ const dashboardResponse: AdminDashboardResponse = {
consumedMudPoints: 88,
totalRegisteredUsers: 1200,
newRegisteredUsers: 16,
day1Retention: {
eligibleUsers: 12,
retainedUsers: 3,
rateBasisPoints: 2500,
},
day7Retention: {
eligibleUsers: 0,
retainedUsers: 0,
rateBasisPoints: 0,
},
visitUsers: 34,
totalVisitUsers: 456,
visitCount: 98,
@@ -41,7 +57,10 @@ const dashboardResponse: AdminDashboardResponse = {
title: '生产素材',
unit: '个',
total: 12,
buckets: [{ key: '2026-06-23', label: '2026-06-23', value: 12 }],
buckets: [
{ key: '2026-06-23', label: '2026-06-23', value: 12 },
{ key: '2026-06-24', label: '2026-06-24', value: 0 },
],
},
],
operations: {
@@ -82,8 +101,21 @@ test('Dashboard 默认加载今日指标并支持运营汇总页签', async () =
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.getByText('25%')).toBeTruthy();
expect(screen.getByText('3 / 12 人')).toBeTruthy();
const day7Card = screen.getByText('七日留存').closest('article');
expect(day7Card).toBeTruthy();
expect(within(day7Card as HTMLElement).getByText('-')).toBeTruthy();
expect(within(day7Card as HTMLElement).getByText('0 / 0 人')).toBeTruthy();
expect(screen.getByText('近 5 分钟活跃用户')).toBeTruthy();
expect(screen.getByText('生产素材')).toBeTruthy();
expect(
screen
.getByTitle('2026-06-24: 0 个')
.firstElementChild?.getAttribute('style'),
).toContain('height: 0%');
expect(screen.queryByRole('button', { name: '本时段' })).toBeNull();
await user.click(screen.getByRole('button', { name: '运营汇总' }));
@@ -108,7 +140,7 @@ test('Dashboard 默认日期使用北京时间', async () => {
});
});
test('Dashboard 选择本周时按今天填充整周范围', async () => {
test('Dashboard 选择本周时按北京时间今天截断未来日期', async () => {
const user = setupUser();
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
@@ -123,12 +155,12 @@ test('Dashboard 选择本周时按今天填充整周范围', async () => {
granularity: 'period',
anchor: undefined,
startDate: '2026-06-22',
endDate: '2026-06-28',
endDate: '2026-06-27',
});
});
});
test('Dashboard 选择本月时按今天填充整月范围', async () => {
test('Dashboard 选择本月时按北京时间今天截断未来日期', async () => {
const user = setupUser();
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
@@ -143,7 +175,7 @@ test('Dashboard 选择本月时按今天填充整月范围', async () => {
granularity: 'period',
anchor: undefined,
startDate: '2026-06-01',
endDate: '2026-06-30',
endDate: '2026-06-27',
});
});
});
@@ -188,6 +220,107 @@ test('Dashboard 手动选择起止日期时使用本时段查询', async () => {
expect(screen.getByText('本时段新增用户数')).toBeTruthy();
});
test('Dashboard 手动选择日期时不允许查询北京时间今天之后', async () => {
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('本日生产素材数');
const endDateInput = screen.getByLabelText('终止日期') as HTMLInputElement;
expect(endDateInput.max).toBe('2026-06-27');
fireEvent.change(endDateInput, { target: { value: '2026-07-12' } });
await waitFor(() => {
expect(endDateInput.value).toBe('2026-06-27');
expect(getAdminDashboard).toHaveBeenLastCalledWith('admin-token', {
granularity: 'period',
anchor: undefined,
startDate: '2026-06-27',
endDate: '2026-06-27',
});
});
});
test('Dashboard 四张趋势图共享横向日期窗口', async () => {
const baseChart = dashboardResponse.charts[0]!;
vi.mocked(getAdminDashboard).mockResolvedValue({
...dashboardResponse,
charts: [
baseChart,
{
...baseChart,
id: 'consumed-mud-points',
title: '消耗泥点',
unit: '泥点',
},
],
});
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
const chartRegions = await screen.findAllByRole('region', {
name: /趋势图$/,
});
const [sourceChart, targetChart] = chartRegions;
if (!sourceChart || !targetChart) {
throw new Error('趋势图未完整渲染');
}
setScrollableDimensions(sourceChart, {
clientWidth: 100,
scrollWidth: 500,
scrollLeft: 200,
});
setScrollableDimensions(targetChart, {
clientWidth: 100,
scrollWidth: 500,
scrollLeft: 0,
});
fireEvent.scroll(sourceChart);
await waitFor(() => {
expect(targetChart.scrollLeft).toBe(200);
});
});
test('Dashboard 访问人数趋势明确区分每日桶与时段去重总数', async () => {
const baseChart = dashboardResponse.charts[0]!;
vi.mocked(getAdminDashboard).mockResolvedValue({
...dashboardResponse,
charts: [
{
...baseChart,
id: 'visit-users',
title: '每日访问人数',
unit: '人',
total: 34,
},
],
});
render(<AdminDashboardPage token="admin-token" onUnauthorized={vi.fn()} />);
expect(await screen.findByText('每日访问人数')).toBeTruthy();
expect(screen.getByText('时段去重 34 人')).toBeTruthy();
});
function setupUser() {
return userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
}
function setScrollableDimensions(
element: HTMLElement,
dimensions: {
clientWidth: number;
scrollWidth: number;
scrollLeft: number;
},
) {
Object.defineProperties(element, {
clientWidth: { configurable: true, value: dimensions.clientWidth },
scrollWidth: { configurable: true, value: dimensions.scrollWidth },
scrollLeft: {
configurable: true,
value: dimensions.scrollLeft,
writable: true,
},
});
}
+123 -31
View File
@@ -1,11 +1,12 @@
import { RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getAdminDashboard } from '../api/adminApiClient';
import type {
AdminDashboardBreakdownRowPayload,
AdminDashboardChartPayload,
AdminDashboardGranularity,
AdminDashboardRetentionMetricPayload,
AdminDashboardResponse,
} from '../api/adminApiTypes';
import { handlePageError } from './pageUtils';
@@ -42,24 +43,29 @@ export function AdminDashboardPage({
const [activeTab, setActiveTab] = useState<AdminDashboardTab>('metrics');
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [chartScrollLeft, setChartScrollLeft] = useState(0);
const today = formatBeijingDateInput(new Date());
const loadDashboard = useCallback(async (range = dateRange) => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await getAdminDashboard(token, {
granularity: 'period',
anchor: undefined,
startDate: range.startDate,
endDate: range.endDate,
});
setDashboard(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [dateRange, onUnauthorized, token]);
const loadDashboard = useCallback(
async (range = dateRange) => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await getAdminDashboard(token, {
granularity: 'period',
anchor: undefined,
startDate: range.startDate,
endDate: range.endDate,
});
setDashboard(response);
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
},
[dateRange, onUnauthorized, token],
);
useEffect(() => {
void loadDashboard();
@@ -75,6 +81,10 @@ export function AdminDashboardPage({
return () => window.clearInterval(timer);
}, [loadDashboard]);
useEffect(() => {
setChartScrollLeft(0);
}, [dashboard?.range.periodEndDate, dashboard?.range.periodStartDate]);
const metrics = dashboard?.metrics;
const totalMetricCards = useMemo(
() => [
@@ -98,7 +108,7 @@ export function AdminDashboardPage({
},
{
id: 'current-users',
label: '当前使用人数(五分钟统计一次)',
label: '近 5 分钟活跃用户',
value: metrics?.currentUsers ?? 0,
unit: '人',
},
@@ -154,6 +164,7 @@ export function AdminDashboardPage({
<span></span>
<input
type="date"
max={today}
value={dateRange.startDate}
onChange={(event) =>
handleDateRangeChange('startDate', event.target.value)
@@ -164,6 +175,7 @@ export function AdminDashboardPage({
<span></span>
<input
type="date"
max={today}
value={dateRange.endDate}
onChange={(event) =>
handleDateRangeChange('endDate', event.target.value)
@@ -278,9 +290,25 @@ export function AdminDashboardPage({
</div>
</section>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3></h3>
<span>{dashboard?.range.periodLabel ?? '-'}</span>
</div>
<div className="admin-dashboard-retention-grid">
<RetentionCard label="次日留存" metric={metrics?.day1Retention} />
<RetentionCard label="七日留存" metric={metrics?.day7Retention} />
</div>
</section>
<div className="admin-dashboard-chart-grid">
{(dashboard?.charts ?? []).map((chart) => (
<ChartPanel key={chart.id} chart={chart} />
<ChartPanel
key={chart.id}
chart={chart}
scrollLeft={chartScrollLeft}
onScrollLeftChange={setChartScrollLeft}
/>
))}
{dashboard && dashboard.charts.length === 0 ? (
<div className="admin-empty-state"></div>
@@ -347,9 +375,10 @@ export function AdminDashboardPage({
if (!parseDateValueAsUtc(value)) {
return;
}
const clampedValue = value > today ? today : value;
setGranularity('period');
setDateRange((current) =>
normalizeDateRange({ ...current, [field]: value }),
normalizeDateRange({ ...current, [field]: clampedValue }),
);
}
}
@@ -377,17 +406,75 @@ function MetricCard({
);
}
function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) {
function RetentionCard({
label,
metric,
}: {
label: string;
metric?: AdminDashboardRetentionMetricPayload;
}) {
const hasEligibleUsers = Boolean(metric?.eligibleUsers);
return (
<article className="admin-dashboard-retention-card">
<span>{label}</span>
<strong>
{hasEligibleUsers && metric
? formatRateBasisPoints(metric.rateBasisPoints)
: '-'}
</strong>
<div>
<small> / </small>
<b>
{metric
? `${formatNumber(metric.retainedUsers)} / ${formatNumber(metric.eligibleUsers)}`
: '-'}
</b>
</div>
</article>
);
}
function ChartPanel({
chart,
scrollLeft,
onScrollLeftChange,
}: {
chart: AdminDashboardChartPayload;
scrollLeft: number;
onScrollLeftChange: (scrollLeft: number) => void;
}) {
const barsRef = useRef<HTMLDivElement>(null);
const maxValue = Math.max(1, ...chart.buckets.map((bucket) => bucket.value));
useEffect(() => {
const element = barsRef.current;
if (!element) {
return;
}
if (Math.abs(element.scrollLeft - scrollLeft) > 1) {
element.scrollLeft = scrollLeft;
}
}, [chart.buckets.length, scrollLeft]);
return (
<section className="admin-panel admin-dashboard-chart-card">
<div className="admin-panel-heading">
<h3>{chart.title}</h3>
<span>
{chart.id === 'visit-users' ? '时段去重 ' : ''}
{formatNumber(chart.total)} {chart.unit}
</span>
</div>
<div className="admin-dashboard-bars">
<div
ref={barsRef}
className="admin-dashboard-bars"
role="region"
aria-label={`${chart.title}趋势图`}
tabIndex={0}
onScroll={(event) => {
onScrollLeftChange(event.currentTarget.scrollLeft);
}}
>
{chart.buckets.map((bucket) => (
<div className="admin-dashboard-bar-item" key={bucket.key}>
<div
@@ -396,7 +483,10 @@ function ChartPanel({ chart }: { chart: AdminDashboardChartPayload }) {
>
<span
style={{
height: `${Math.max(4, (bucket.value / maxValue) * 100)}%`,
height:
bucket.value === 0
? '0%'
: `${Math.max(4, (bucket.value / maxValue) * 100)}%`,
}}
/>
</div>
@@ -450,6 +540,13 @@ function formatNumber(value: number) {
return new Intl.NumberFormat('zh-CN').format(value);
}
function formatRateBasisPoints(value: number) {
return new Intl.NumberFormat('zh-CN', {
style: 'percent',
maximumFractionDigits: 2,
}).format(value / 10_000);
}
function formatBeijingDateInput(date: Date) {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
@@ -498,9 +595,7 @@ function buildWeekDateRange(dateValue: string) {
const weekday = date.getUTCDay() || 7;
const monday = new Date(date);
monday.setUTCDate(date.getUTCDate() - weekday + 1);
const sunday = new Date(monday);
sunday.setUTCDate(monday.getUTCDate() + 6);
return { startDate: formatUtcDate(monday), endDate: formatUtcDate(sunday) };
return { startDate: formatUtcDate(monday), endDate: dateValue };
}
function buildMonthDateRange(dateValue: string) {
@@ -512,12 +607,9 @@ function buildMonthDateRange(dateValue: string) {
const firstDate = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1),
);
const lastDate = new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + 1, 0),
);
return {
startDate: formatUtcDate(firstDate),
endDate: formatUtcDate(lastDate),
endDate: dateValue,
};
}
@@ -148,6 +148,10 @@ const generatedAsset: AdminEditorAssetPayload = {
generationCostMudPoints: 12,
createdAt: '2026-07-04T10:00:00Z',
updatedAt: '2026-07-04T10:00:00Z',
generator: '角色抠图',
taskGenerator: '角色生成',
taskCostMudPoints: 12,
children: [],
};
beforeEach(() => {
@@ -245,6 +249,80 @@ test('后台素材查询不展示分类筛选和分类列', async () => {
expect(screen.queryByRole('columnheader', { name: '分类' })).toBeNull();
});
test('后台素材查询将中间产物按真实类型逐行收纳在最终产物下', async () => {
const providerSource: AdminEditorAssetPayload = {
...generatedAsset,
assetId: 'asset-source',
label: '角色形象生成原图',
objectKey:
'generated-character-drafts/editor/character-images/provider-source-task-1.png',
imageSrc:
'/generated-character-drafts/editor/character-images/provider-source-task-1.png',
assetKind: 'character',
generator: '角色生图',
taskGenerator: '角色生成',
generationCostMudPoints: 12,
taskCostMudPoints: 12,
children: [],
};
const postprocessOutput: AdminEditorAssetPayload = {
...generatedAsset,
assetId: 'asset-postprocess',
label: '角色抠图阶段产物',
objectKey:
'generated-character-drafts/editor/character-images/background-removal-task-1.png',
imageSrc:
'/generated-character-drafts/editor/character-images/background-removal-task-1.png',
assetKind: 'character',
generator: '角色抠图',
taskGenerator: '角色生成',
generationCostMudPoints: 0,
taskCostMudPoints: 0,
children: [],
};
vi.mocked(listAdminEditorAssets).mockResolvedValueOnce({
entries: [
{
...generatedAsset,
generationCostMudPoints: 0,
taskCostMudPoints: 12,
children: [providerSource, postprocessOutput],
},
],
nextCursor: null,
});
render(
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const finalRow = (await screen.findByText('角色形象 1')).closest('tr');
expect(finalRow).not.toBeNull();
expect(within(finalRow!).getByText('12 泥点')).toBeTruthy();
expect(screen.queryByText('角色形象生成原图')).toBeNull();
expect(screen.queryByText('角色抠图阶段产物')).toBeNull();
fireEvent.click(
within(finalRow!).getByRole('button', { name: '展开中间产物' }),
);
const sourceRow = (await screen.findByText('角色形象生成原图')).closest('tr');
const postprocessRow = screen.getByText('角色抠图阶段产物').closest('tr');
expect(sourceRow).not.toBeNull();
expect(postprocessRow).not.toBeNull();
expect(within(sourceRow!).getByText('角色生图')).toBeTruthy();
expect(within(sourceRow!).getByText('12 泥点')).toBeTruthy();
expect(within(postprocessRow!).getByText('角色抠图')).toBeTruthy();
expect(
within(postprocessRow!).getByText('本阶段不额外扣费'),
).toBeTruthy();
fireEvent.click(within(sourceRow!).getByRole('button', { name: '详情' }));
const detail = await screen.findByRole('dialog', { name: '素材详情' });
expect(within(detail).getByText('角色生图')).toBeTruthy();
expect(within(detail).getByText('12 泥点')).toBeTruthy();
});
test('后台素材查询缩略图使用 objectKey 换签后展示', async () => {
render(
<AdminEditorAssetQueryPage token="admin-token" onUnauthorized={vi.fn()} />,
@@ -1,5 +1,5 @@
import { Eye, FileText, RefreshCcw, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { ChevronDown, ChevronRight, Eye, FileText, RefreshCcw, X } from 'lucide-react';
import { Fragment, type ReactNode } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
@@ -44,6 +44,9 @@ export function AdminEditorAssetQueryPage({
useState<AdminEditorAssetPayload | null>(null);
const [previewEntry, setPreviewEntry] =
useState<AdminEditorAssetPayload | null>(null);
const [expandedTaskIds, setExpandedTaskIds] = useState<Set<string>>(
() => new Set(),
);
const [promptPreview, setPromptPreview] = useState<{
title: string;
prompt: string;
@@ -149,7 +152,7 @@ export function AdminEditorAssetQueryPage({
<label className="admin-field">
<span></span>
<input
placeholder="素材名 / 用户 / 模型 / 提示词"
placeholder="素材名 / 用户 / 生成器 / 模型 / 提示词"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
/>
@@ -164,6 +167,7 @@ export function AdminEditorAssetQueryPage({
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
@@ -171,64 +175,143 @@ export function AdminEditorAssetQueryPage({
<tbody>
{entries.map((entry) => {
const promptText = entry.prompt || entry.actualPrompt || '-';
const expanded = expandedTaskIds.has(entry.assetId);
const children = entry.children ?? [];
return (
<tr key={entry.assetId}>
<td>
<button
className="admin-asset-query-thumb-button"
title="预览素材"
type="button"
onClick={() => setPreviewEntry(entry)}
>
<AdminAssetThumbnail entry={entry} token={token} />
</button>
<small>{entry.label || '-'}</small>
</td>
<td>{formatDateTime(entry.createdAt)}</td>
<td>
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
<Fragment key={entry.assetId}>
<tr>
<td>
<div className="admin-asset-query-resource-cell">
{children.length ? (
<button
aria-label={expanded ? '收起中间产物' : '展开中间产物'}
className="admin-asset-query-expand-button"
type="button"
onClick={() =>
setExpandedTaskIds((current) => {
const next = new Set(current);
if (next.has(entry.assetId)) {
next.delete(entry.assetId);
} else {
next.add(entry.assetId);
}
return next;
})
}
>
{expanded ? (
<ChevronDown size={17} aria-hidden="true" />
) : (
<ChevronRight size={17} aria-hidden="true" />
)}
</button>
) : (
<span className="admin-asset-query-expand-spacer" />
)}
<div>
<button
className="admin-asset-query-thumb-button"
title="预览素材"
type="button"
onClick={() => setPreviewEntry(entry)}
>
<AdminAssetThumbnail entry={entry} token={token} />
</button>
<small>{entry.label || '-'}</small>
</div>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
</td>
<td>{formatDateTime(entry.createdAt)}</td>
<td>
<div className="admin-inline-identity">
<div>
{authorDisplayName(entry)}
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.ownerUserId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td>
<AdminAssetPromptButton
entry={entry}
promptText={promptText}
onOpen={setPromptPreview}
/>
</div>
</td>
<td>
<button
className="admin-text-button admin-asset-query-prompt-text"
disabled={promptText === '-'}
title={promptText}
type="button"
onClick={() =>
setPromptPreview({
title: entry.label || entry.assetId,
prompt: promptText,
})
}
>
{promptText}
</button>
</td>
<td>{entry.generationCostMudPoints} </td>
<td>
<button
className="admin-secondary-button"
type="button"
onClick={() => setDetailEntry(entry)}
>
<Eye size={16} aria-hidden="true" />
<span></span>
</button>
</td>
</tr>
</td>
<td>{entry.taskGenerator || entry.generator || '-'}</td>
<td>{taskGenerationCostLabel(entry)}</td>
<td>
<button
className="admin-secondary-button"
type="button"
onClick={() => setDetailEntry(entry)}
>
<Eye size={16} aria-hidden="true" />
<span></span>
</button>
</td>
</tr>
{expanded
? children.map((child) => {
const childPrompt =
child.prompt || child.actualPrompt || '-';
return (
<tr
className="admin-asset-query-child-row"
key={child.assetId}
>
<td>
<div className="admin-asset-query-resource-cell admin-asset-query-resource-cell-child">
<span className="admin-asset-query-child-branch" />
<div>
<button
className="admin-asset-query-thumb-button"
title="预览中间产物"
type="button"
onClick={() => setPreviewEntry(child)}
>
<AdminAssetThumbnail
entry={child}
token={token}
/>
</button>
<small>{child.label || '-'}</small>
</div>
</div>
</td>
<td>{formatDateTime(child.createdAt)}</td>
<td>{authorDisplayName(child)}</td>
<td>
<AdminAssetPromptButton
entry={child}
promptText={childPrompt}
onOpen={setPromptPreview}
/>
</td>
<td>{child.generator || '-'}</td>
<td>{stageGenerationCostLabel(child)}</td>
<td>
<button
className="admin-secondary-button"
type="button"
onClick={() => setDetailEntry(child)}
>
<Eye size={16} aria-hidden="true" />
<span></span>
</button>
</td>
</tr>
);
})
: null}
</Fragment>
);
})}
</tbody>
@@ -306,6 +389,33 @@ export function AdminEditorAssetQueryPage({
);
}
function AdminAssetPromptButton({
entry,
promptText,
onOpen,
}: {
entry: AdminEditorAssetPayload;
promptText: string;
onOpen: (value: { title: string; prompt: string }) => void;
}) {
return (
<button
className="admin-text-button admin-asset-query-prompt-text"
disabled={promptText === '-'}
title={promptText}
type="button"
onClick={() =>
onOpen({
title: entry.label || entry.assetId,
prompt: promptText,
})
}
>
{promptText}
</button>
);
}
function AdminAssetThumbnail({
entry,
token,
@@ -516,8 +626,20 @@ function AdminAssetDetailDialog({
{entry.width} x {entry.height}
</AdminInfoItem>
<AdminInfoItem label="成本">
{entry.generationCostMudPoints}
{entry.children?.length
? taskGenerationCostLabel(entry)
: stageGenerationCostLabel(entry)}
</AdminInfoItem>
<AdminInfoItem label="生成器">
{entry.children?.length
? entry.taskGenerator || entry.generator || '-'
: entry.generator || '-'}
</AdminInfoItem>
{entry.children?.length ? (
<AdminInfoItem label="当前产物阶段">
{entry.generator || '-'}
</AdminInfoItem>
) : null}
<AdminInfoItem label="模型">{entry.model || '-'}</AdminInfoItem>
<AdminInfoItem label="Provider">
{entry.provider || '-'}
@@ -558,6 +680,16 @@ function AdminAssetDetailDialog({
);
}
function taskGenerationCostLabel(entry: AdminEditorAssetPayload) {
return `${entry.taskCostMudPoints ?? entry.generationCostMudPoints} 泥点`;
}
function stageGenerationCostLabel(entry: AdminEditorAssetPayload) {
return entry.generationCostMudPoints > 0
? `${entry.generationCostMudPoints} 泥点`
: '本阶段不额外扣费';
}
function AdminAssetPreviewDialog({
entry,
token,
@@ -0,0 +1,41 @@
/* @vitest-environment jsdom */
import {render, screen} from '@testing-library/react';
import {beforeEach, expect, test, vi} from 'vitest';
import {listProfileInviteCodes} from '../api/adminApiClient';
import {AdminInviteCodePage} from './AdminInviteCodePage';
vi.mock('../api/adminApiClient', () => ({
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
isAdminApiError: vi.fn(() => false),
listProfileInviteCodes: vi.fn(),
upsertProfileInviteCode: vi.fn(),
}));
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listProfileInviteCodes).mockResolvedValue({
entries: [],
operations: [
{
operationId: 'operation-1',
codeKind: 'invite',
code: 'TEAM',
action: 'create',
operatorUserId: 'admin-account-internal-1',
operatorDisplayName: '邀请运营',
createdAt: '2026-07-14T10:00:00Z',
},
],
});
});
test('操作记录只展示管理员显示名称', async () => {
render(<AdminInviteCodePage token="admin-token" onUnauthorized={vi.fn()} />);
expect(await screen.findByText('邀请运营')).toBeTruthy();
expect(screen.queryByText('admin-account-internal-1')).toBeNull();
});
@@ -278,7 +278,7 @@ export function AdminInviteCodePage({
<tr key={operation.operationId}>
<td>{operationActionLabel(operation.action)}</td>
<td>{operation.code}</td>
<td>{operation.operatorUserId}</td>
<td>{operation.operatorDisplayName}</td>
<td>{formatDateTime(operation.createdAt)}</td>
</tr>
))}
@@ -149,7 +149,7 @@ export function AdminProfileWalletConfigPage({
</div>
<div>
<dt></dt>
<dd>{result.updatedBy || '-'}</dd>
<dd>{result.updatedByDisplayName || '-'}</dd>
</div>
<div>
<dt></dt>
@@ -68,7 +68,17 @@ beforeEach(() => {
vi.clearAllMocks();
vi.mocked(listProfileRedeemCodes).mockResolvedValue({
entries,
operations: [],
operations: [
{
operationId: 'operation-1',
codeKind: 'redeem',
code: 'LONG-LIVED',
action: 'create',
operatorUserId: 'admin-account-internal-1',
operatorDisplayName: '兑换码运营',
createdAt: '2026-07-13T01:00:00Z',
},
],
});
vi.mocked(upsertProfileRedeemCode).mockResolvedValue(baseEntry);
vi.mocked(disableProfileRedeemCode).mockResolvedValue({
@@ -77,6 +87,13 @@ beforeEach(() => {
});
});
test('操作记录只展示管理员显示名称', async () => {
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
expect(await screen.findByText('兑换码运营')).toBeTruthy();
expect(screen.queryByText('admin-account-internal-1')).toBeNull();
});
test('兑换码列表展示生效状态与日期范围', async () => {
render(<AdminRedeemCodePage token="admin-token" onUnauthorized={vi.fn()} />);
@@ -402,7 +402,7 @@ export function AdminRedeemCodePage({
<tr key={operation.operationId}>
<td>{operationActionLabel(operation.action)}</td>
<td>{operation.code}</td>
<td>{operation.operatorUserId}</td>
<td>{operation.operatorDisplayName}</td>
<td>{formatDateTime(operation.createdAt)}</td>
</tr>
))}
@@ -553,7 +553,7 @@ export function AdminTaskConfigPage({
</div>
<div>
<dt></dt>
<dd>{result.updatedBy}</dd>
<dd>{result.updatedByDisplayName}</dd>
</div>
<div>
<dt></dt>
+268 -8
View File
@@ -216,6 +216,17 @@ button:disabled {
overflow: auto;
}
.admin-zero-permission-state {
min-height: 180px;
place-items: center;
}
.admin-zero-permission-state h2 {
margin: 0;
color: #6f5848;
font-size: 20px;
}
.admin-page {
display: grid;
gap: 18px;
@@ -350,6 +361,63 @@ button:disabled {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.admin-dashboard-retention-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.admin-dashboard-retention-card {
display: grid;
min-width: 0;
min-height: 112px;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto 1fr;
gap: 8px 16px;
align-items: center;
padding: 16px;
}
.admin-dashboard-retention-card + .admin-dashboard-retention-card {
border-left: 1px solid #ead8ca;
}
.admin-dashboard-retention-card > span {
color: #8f7868;
font-size: 13px;
font-weight: 750;
}
.admin-dashboard-retention-card > strong {
grid-row: 1 / -1;
grid-column: 2;
color: #8f3f27;
font-size: 30px;
font-variant-numeric: tabular-nums;
line-height: 1.1;
white-space: nowrap;
}
.admin-dashboard-retention-card > div {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
}
.admin-dashboard-retention-card small {
min-width: 0;
color: #a38f80;
font-size: 12px;
}
.admin-dashboard-retention-card b {
flex: 0 0 auto;
color: #755a49;
font-size: 13px;
font-variant-numeric: tabular-nums;
}
.admin-dashboard-chart-grid,
.admin-dashboard-operations {
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -406,7 +474,10 @@ button:disabled {
gap: 8px;
min-height: 196px;
overflow-x: auto;
overscroll-behavior-inline: contain;
padding: 4px 2px 0;
scrollbar-color: #bdaea3 #f4e5d7;
scrollbar-width: thin;
}
.admin-dashboard-bar-item {
@@ -513,6 +584,90 @@ button:disabled {
grid-template-columns: minmax(0, 1.1fr) minmax(300px, 0.9fr);
}
.admin-accounts-layout {
display: grid;
grid-template-columns: minmax(240px, 0.42fr) minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.admin-account-list {
align-content: start;
}
.admin-account-list-items {
display: grid;
gap: 8px;
}
.admin-account-list-items > button {
display: flex;
min-width: 0;
min-height: 58px;
align-items: center;
justify-content: space-between;
gap: 12px;
border: 1px solid #eaded2;
border-radius: 8px;
color: #755a49;
background: #fffdf9;
padding: 10px 12px;
text-align: left;
}
.admin-account-list-items > button[data-active="true"] {
border-color: #c87955;
background: #f9eee5;
}
.admin-account-list-items > button > span {
display: grid;
min-width: 0;
gap: 3px;
}
.admin-account-list-items strong,
.admin-account-list-items small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-account-list-items small {
color: #8f7868;
font-size: 12px;
}
.admin-permission-fieldset {
min-width: 0;
margin: 0;
border: 1px solid #e1ccbb;
border-radius: 8px;
padding: 14px;
}
.admin-permission-fieldset legend {
color: #6f5848;
font-size: 13px;
font-weight: 750;
padding: 0 6px;
}
.admin-permission-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px 14px;
}
.admin-permission-grid label {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
color: #5f4738;
font-size: 13px;
}
.admin-stack,
.admin-form {
display: grid;
@@ -566,7 +721,17 @@ button:disabled {
}
.admin-inline-identity > div {
display: grid;
min-width: 0;
gap: 2px;
}
.admin-inline-identity small {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-database-user-cell,
@@ -655,6 +820,55 @@ button:disabled {
display: block;
}
.admin-asset-query-resource-cell {
display: flex;
align-items: flex-start;
gap: 6px;
}
.admin-asset-query-resource-cell > div:last-child small {
display: block;
max-width: 92px;
margin-top: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-asset-query-expand-button {
display: inline-flex;
flex: 0 0 28px;
width: 28px;
height: 28px;
align-items: center;
justify-content: center;
border: 0;
background: transparent;
color: #78523e;
cursor: pointer;
}
.admin-asset-query-expand-spacer,
.admin-asset-query-child-branch {
display: block;
flex: 0 0 28px;
width: 28px;
}
.admin-asset-query-child-row {
background: #fffaf5;
}
.admin-asset-query-resource-cell-child {
padding-left: 12px;
}
.admin-asset-query-child-branch {
height: 34px;
border-bottom: 1px solid #d8c3b3;
border-left: 1px solid #d8c3b3;
}
.admin-asset-query-prompt-text {
display: block;
max-width: 100%;
@@ -1117,7 +1331,7 @@ button:disabled {
margin: 0;
}
.admin-info-list div {
.admin-info-list > div {
display: grid;
grid-template-columns: minmax(90px, 0.34fr) minmax(0, 1fr);
gap: 12px;
@@ -1307,29 +1521,30 @@ button:disabled {
}
.admin-asset-query-table {
min-width: 1080px;
table-layout: fixed;
}
.admin-asset-query-table th:nth-child(1),
.admin-asset-query-table td:nth-child(1) {
width: 10%;
width: 13%;
}
.admin-asset-query-table th:nth-child(2),
.admin-asset-query-table td:nth-child(2),
.admin-asset-query-table th:nth-child(3),
.admin-asset-query-table td:nth-child(3) {
width: 14%;
width: 13%;
}
.admin-asset-query-table th:nth-child(4),
.admin-asset-query-table td:nth-child(4) {
width: 34%;
width: 27%;
}
.admin-asset-query-table th:nth-child(5),
.admin-asset-query-table td:nth-child(5) {
width: 8%;
width: 10%;
}
.admin-asset-query-table th:nth-child(6),
@@ -1337,6 +1552,11 @@ button:disabled {
width: 10%;
}
.admin-asset-query-table th:nth-child(7),
.admin-asset-query-table td:nth-child(7) {
width: 10%;
}
.admin-showcase-review-table {
table-layout: fixed;
}
@@ -1861,6 +2081,7 @@ button:disabled {
.admin-dashboard-operations,
.admin-two-column,
.admin-two-column-wide,
.admin-accounts-layout,
.admin-pricing-grid,
.admin-form-row,
.admin-filter-grid,
@@ -1870,6 +2091,10 @@ button:disabled {
grid-template-columns: 1fr;
}
.admin-permission-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-dashboard-heading {
display: grid;
}
@@ -1890,7 +2115,8 @@ button:disabled {
}
.admin-dashboard-metric-grid,
.admin-dashboard-operation-grid {
.admin-dashboard-operation-grid,
.admin-dashboard-retention-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1965,6 +2191,10 @@ button:disabled {
}
@media (max-width: 560px) {
.admin-permission-grid {
grid-template-columns: 1fr;
}
.admin-login-panel,
.admin-panel {
padding: 16px;
@@ -2022,11 +2252,19 @@ button:disabled {
font-size: 22px;
}
.admin-info-list div {
.admin-info-list > div {
grid-template-columns: 1fr;
gap: 3px;
}
.admin-asset-query-detail-layout {
grid-template-columns: 1fr;
}
.admin-asset-query-detail-thumb-button {
justify-self: center;
}
.admin-dashboard-tabs {
width: 100%;
}
@@ -2035,8 +2273,30 @@ button:disabled {
grid-template-columns: 1fr;
}
.admin-dashboard-retention-card {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
.admin-dashboard-retention-card + .admin-dashboard-retention-card {
border-top: 1px solid #ead8ca;
border-left: 0;
}
.admin-dashboard-retention-card > strong {
grid-row: auto;
grid-column: auto;
}
.admin-dashboard-retention-card > div {
align-items: flex-start;
flex-direction: column;
gap: 3px;
}
.admin-dashboard-metric-grid,
.admin-dashboard-operation-grid {
.admin-dashboard-operation-grid,
.admin-dashboard-retention-grid {
grid-template-columns: 1fr;
}
+1 -1
View File
@@ -46,7 +46,7 @@ React 组件测试的用户行为、稳定契约、hook / model 分层断言口
本地通过 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),Dashboard 默认入口、运营指标和统计口径见 [【后台管理】Dashboard运营看板方案-2026-06-23.md](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.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)owner/member 多账号、18 个一级 Tab 权限和逐请求鉴权方案见 [【后台管理】多账号与Tab访问权限方案-2026-07-14.md](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md)
SpacetimeDB 表结构变更、自动迁移边界和保留旧数据的分阶段迁移流程见 [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./technical/SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md)。
@@ -47,19 +47,28 @@
- 影响范围:`server-rs/crates/api-server/src/state.rs``server-rs/crates/api-server/src/editor_project.rs``server-rs/crates/api-server/src/character_animation_assets.rs`、后端架构文档和图片画布技术文档。
- 验证方式:运行 `cargo test -p api-server editor_bgfilter_retries_once_before_fallback --manifest-path server-rs/Cargo.toml``cargo test -p api-server editor_character_animation_frames_use_three_stage_matting_fallback --manifest-path server-rs/Cargo.toml``cargo test -p api-server editor_canvas_screen_background_generation_uses_bgfilter_postprocess --manifest-path server-rs/Cargo.toml``cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
## 2026-07-13 图片画布生成资源支持提交前统一命名
## 2026-07-14 后台账号采用 owner 引导账号与一级 Tab 实时授权
- 背景:后台此前只支持一组环境变量管理员,所有 `/admin/api/*` 共用统一 admin 门禁,无法给运营、审核等人员分配独立账号和页面范围。
- 决策:现有 `GENARRATIVE_ADMIN_USERNAME/PASSWORD` 账号固定作为不可编辑 owner;新增 member 独立保存到私有 `admin_account` 表,密码使用 Argon2id 摘要。登录凭据快照与普通账号快照在类型层分离,普通列表、按 ID 查询和写入响应不包含 `password_hash`。Argon2id 在 blocking 任务中运行并由 api-server 有界限流;未知、停用和 owner 错密账号使用 dummy hash 抹平耗时。member 权限粒度固定为后台 18 个一级 Tab,“账号管理”只允许 owner 且不可授予 member。member 每次请求重新读取当前账号并校验启停、`token_version` 和 Tab 权限;权限、密码或启停变化递增版本并立即淘汰旧 JWT。账号不存在返回 `401`SpacetimeDB 故障保留 `502/503` 而不清理有效 token。前端导航过滤和页面挂载门禁只负责体验,正式授权由 api-server 的 API-to-Tab 矩阵执行,未登记的新后台路由对 member 默认拒绝。后台面向运营展示管理员身份时统一使用 `displayName`;持久审计仍保存稳定 subject,由 api-server 解析显示名称,前端不得暴露账号 ID 或用登录用户名代替。写接口必须在主事务前加载显示名目录,或在主事务后降级解析,不能把已提交写入伪装为失败。
- 影响范围:`admin_account`、SpacetimeDB typed procedures / client facade、后台 JWT 与 session DTO、`/admin/api/accounts*`、后台路由权限中间件、admin-web 导航和账号管理页。
- 验证方式:SpacetimeDB schema / client / API 定向测试、`npm run check:admin-account-procedures` 隔离 procedure smoke、完整路由矩阵测试、admin-web 权限路由与账号 API 测试、owner/member 浏览器 smoke、`npm run check:spacetime-schema`、编码与 diff 门禁。
- 关联文档:`docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md`
## 2026-07-13 图片画布生成资源统一命名
- 背景:图片画布的普通图片、规范、角色、图标图集、UI 设计、宣发素材、视频和音频默认使用“类型 + 数字”命名,用户只能在生成后单独重命名素材,画布图层、项目资源和素材库名称容易不一致。
- 决策:主生成状态统一使用可选 `assetLabel`所有生成面板提供同一个资源名称输入。名称最多 80 个字符提交时去除首尾空格;留空时继续使用现有自动编号名称。一次提交解析出的名称必须同时用于 `assetLabel``canvasCompletion.title`、本地结果图层标题、项目资源和账号素材库,不允许各链路自行生成不同名称。
- 派生产物:图标和角色动作后端契约补齐 `assetLabel`。带背景原图、图片修改 provider 原始输出等中间产物基于主名称追加“(原图)”或“(原始输出)”;图标切片继续按用户填写的图标描述命名,不继承图集名称覆盖独立素材语义。
- 决策:主生成状态继续使用可选 `assetLabel`,名称最多 80 个字符并在提交时去除首尾空格;当前生成面板不展示“资源名称”标签和输入框,默认沿用现有自动编号名称,历史状态或内部调用若携带非空名称,仍必须让同一个名称贯穿 `assetLabel``canvasCompletion.title`、本地结果图层标题、项目资源和账号素材库,不允许各链路自行生成不同名称。移除名称输入后,角色、图标图集、UI 设计和角色动作等提示词输入恢复统一可见边框。
- 派生产物:图标和角色动作后端契约补齐 `assetLabel`。带背景原图、角色动作绿幕预览等具有独立复用价值的中间产物基于主名称追加“(原图)”等后缀;普通图片和图片修改的纯尺寸变换在内存完成后只上传一次,不生成“原始输出”副本。图标切片继续按用户填写的图标描述命名,不继承图集名称覆盖独立素材语义。
- 影响范围:图片画布生成状态与面板、提交模型、图标和角色动作请求契约、项目资源 / 素材库持久化和相关编辑器文档。
- 验证方式:覆盖自定义名、空白回退、长度限制,以及图片 / 图标 / 视频 / 音频 / 角色动作的请求名称、完成快照标题和素材名称一致性;运行前端定向测试、Rust 契约与 API 定向测试、`npm run typecheck``npm run check:encoding``git diff --check`
- 验证方式:覆盖生成面板不渲染资源名称输入、提示词边框、空白回退、内部自定义名与长度限制,以及图片 / 图标 / 视频 / 音频 / 角色动作的请求名称、完成快照标题和素材名称一致性;运行前端定向测试、Rust 契约与 API 定向测试、`npm run typecheck``npm run check:encoding``git diff --check`
## 2026-07-13 图片画布多产物生成任务必须保存全部可恢复产物
- 背景:角色形象、图标 spritesheet 和 UI 素材提取会先得到带纯色背景的原图,再执行抠图或拆分;图片修改会先得到模型对齐尺寸的原始输出,角色动作会先得到绿幕预览视频,再抽帧和抠图。此前部分原始产物只登记到 OSS,或者要等后处理成功后才进入项目资源,用户无法在失败后找回已经生成成功的内容。
- 决策:凡一次资产生成任务产生多个可恢复产物,后端必须把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、尺寸恢复、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取同时保留纯色背景原图与透明后处理结果;图片修改保留模型原始输出与尺寸恢复结果;角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。普通图片、去背景、音频等没有独立上游中间产物的任务不制造重复副本。
- 画布与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,生成器 `generatedLayerId` 锚定主后处理结果。图标和 UI 图集自动拆分是 best-effort;识别或切片持久化失败仍完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。
- 决策:凡一次资产生成任务产生多个具有独立复用价值的产物,后端必须把上游已返回的中间产物写入 OSS、`asset_object`、项目资源和账号素材库,再执行抠图、抽帧或拆分;未指定素材文件夹时进入默认“项目”文件夹。角色形象、图标 spritesheet 和 UI 素材提取同时保留纯色背景原图与透明后处理结果。普通图片和图片修改的纯尺寸变换不属于独立产物:provider 回图保留在内存,变换成功只上传变换结果,变换失败只上传 provider 原图,整个流程只写一次 OSS 并只创建一个素材,不能制造重复“原始输出”。`nanobanana2` 使用标量清晰度档位和独立比例,保留 provider 输出尺寸,不按 `WIDTHxHEIGHT` 解析。角色动作把绿幕预览视频作为一个可复用素材保存,逐帧源图继续留在同一任务 OSS 路径,不把 32 至 48 帧逐张灌入素材库。去背景、音频等没有独立上游中间产物的任务不制造重复副本。
- 画布、成本与降级:有项目上下文的图片多产物继续由同一次 `canvasCompletion` 写入权威画布快照,生成器 `generatedLayerId` 锚定主后处理结果。图标和 UI 图集自动拆分是 best-effort;识别或切片持久化失败仍完成整张透明图集,并在 inline、队列轮询和刷新后任务列表中提示非阻断 warning,不得借用失败错误字段。provider 原图或角色动作预览视频承载该任务的模型生成成本,抠图、逐帧处理、透明图集和切片等后处理派生产物的 `generation_cost_mud_points = 0`,避免把生图成本误显示成抠图成本;所有中间产物沿用所属任务的真实 `asset_kind`,角色原图仍为 `character`、图标和 UI 图集原图仍为 `icon-spritesheet`、角色动作预览仍为 `character-animation`,不得再写新的“原图类型”。后台素材查询按任务分页,最终产物作为父行并显示任务总成本,每个中间产物作为可展开的独立子行显示阶段生成器和阶段成本。扣费确认边界保持为 provider 成功,OSS、尺寸恢复和画布回填不延长退款保护。
- 影响范围:`server-rs/crates/api-server/src/editor_project.rs``character_animation_assets.rs`、外部生成任务摘要、图片画布完成快照、账号素材库和前端生成提示。
- 验证方式:覆盖中间产物登记先于后处理、默认素材文件夹、图集拆分降级、inline / queue warning 和主结果锚定的定向测试,并运行 `cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run check:spacetime-schema`、前端定向测试、`npm run check:encoding``git diff --check`
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
@@ -374,7 +383,7 @@
## 2026-06-18 图片画布 UI 设计图提取素材保留图集
- 背景:UI 设计图需要从成图中继续抽取可复用独立素材;原图标素材生成只把拆分后的图标放入画布,spritesheet 原图没有保留,后续追溯和二次切图不方便。
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成先把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库并回填画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。本条新决策取代“图标素材生成只保留图集”的旧口径。
- 决策:`assetKind="ui-design"` 图层浮动工具栏新增 `提取素材`,点击后先进入红框素材框选编辑态,默认矩形框选,并支持椭圆框选和画笔自由框选。至少存在一个框选区域后才能提交;前端把红色轮廓绘入原 UI 设计图并将合成图作为 `/api/editor/ui-designs/assets/extractions` 的参考图。后端固定 `gpt-image-2` 和纯色背景素材提取提示词,返回结构复用图标 spritesheet 响应。UI 提取把 spritesheet 图集作为 `assetKind="icon-spritesheet"` 图层放到画布,再放拆分后的 `assetKind="icon"` 素材。2026-07-03 起,UI 提取的纯色背景由 `screenColor` 选择并经 BgFilter 透明化。2026-07-13 起,图标素材生成先把带背景原图和透明 spritesheet 同时写入项目资源、账号素材库并回填画布,未指定文件夹时落默认“项目”文件夹,再 best-effort 按 alpha 连通域拆分独立图标;拆分失败不改变生成成功状态,响应以空 `iconImageSrcs` 和结构化 `sliceWarning` 返回原因,用户可从图集工具栏手动重试。手动拆分不计费,限制单边 `4096`、总像素 `2048×2048`、最多 `64` 个切片,所有切片用 `sourceResourceId` 指向透明图集。`icon-spritesheet` 图集继续显示并允许快速编辑,只有拆分后的 `assetKind="icon"` 单图标隐藏并拒绝快速编辑;工具栏、右键菜单、打开流程和提交兜底必须共用同一判定。本条新决策取代“图标素材生成只保留图集”的旧口径。
- 影响范围:图片画布浮动工具栏、编辑器图片生成 BFF、`platform-image` 图集连通域拆分、画布图层类型和编辑器文档。
- 验证方式:运行图片画布工具栏 / 图集落层 / 生成提交相关前端测试,`cargo test -p platform-image generated_asset_sheets --manifest-path server-rs/Cargo.toml`,以及 `cargo test -p api-server editor_ui_design_asset_extraction_prompt_is_fixed --manifest-path server-rs/Cargo.toml`
- 关联文档:`docs/【编辑器】画板UI设计图生成入口设计-2026-06-17.md``docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`
@@ -4125,3 +4134,21 @@
- 后台边界:充值订单、预检、执行、应急退款号登记、用户详情和钱包冻结均只挂在管理员鉴权路由。用户详情由 `user_id` 或陶泥号经认证服务解析,返回头像、昵称、脱敏手机号、绑定状态、钱包分桶、占用、欠账和最近订单;后台语义明确的用户字段复用同一个图标按钮和弹窗,管理员主体及 `admin:*` 合成 ID 不打开用户详情。
- 部分退款预检:微信支付查单 `trade_state=REFUND` 只表示已发生退款,不代表全额退款。刷新已登记退款后,本地累计成功退款大于 0 且小于订单总额、且不存在非终态退款、活动 hold、欠账或人工冻结时,可以继续退本地剩余额度;没有本地成功退款事实能解释 `REFUND` 时继续失败关闭并要求登记或账单对账。
- 影响范围:`module-runtime``spacetime-module``spacetime-client``api-server` 管理员 BFF / refund worker、`shared-contracts``apps/admin-web`
## 2026-07-14 Jenkins Git 源收口到本机 loopback
- 背景:Jenkins controller 与 Gitea SSH 当前同机运行,live Job 的 `Pipeline script from SCM` 已使用 `127.0.0.1:2222`,但仓库 Jenkinsfile 内部 checkout 仍固定到局域网 IP,导致入口 SCM 与执行阶段来源不一致。
- 决策:所有生产 Job 的 SCM URL 和 Jenkinsfile 内部源码准备统一使用 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`,继续使用 `genarrative-local-gitea-ssh`,不保留局域网 IP、HTTP 内网或公网 fallback。该决策覆盖 2026-06-19 的局域网 SSH 地址口径。
- 目标机边界:`127.0.0.1` 只允许在 Jenkins controller / Built-In Node 用于 Git。数据库导入导出与 Server-Provision 都必须在带 `linux && genarrative-build` 标签的 Built-In Node 完成 checkout 和 commit 校验,再通过 stash 把必要脚本交给 dev / release 目标 agent;目标 agent 不得自行 checkout Git 或挂载 Git SSH 凭据。
- 影响范围:生产构建、Full、数据库导入导出和 Server-Provision Jenkinsfile,生产运维文档、共享踩坑记录与生产运维静态门禁。
- 验证方式:`npm run check:production-ops``npm run check:encoding``bash -n scripts/jenkins-checkout-source.sh``git diff --check`;只读核对 live Job `config.xml` 的 SCM URL,并在 Jenkins 凭据环境对 loopback SSH 地址执行 `git ls-remote ... HEAD`
## 2026-07-14 后台 Dashboard 修正访问趋势并增加新增用户留存
- 背景:Dashboard 本月范围包含未来日期,四张图可停在不同横向窗口;“访问人数”又把整个时段 UV 塞到终止日,0 值仍显示短柱。访问模块分布从 `tracking_event LIMIT 50000` 的任意截断样本计算,页面却继续展示精确值并产生置顶告警。
- 决策:本周、本月快捷范围和手动日期均不晚于北京时间今天;访问人数趋势按日去重登录用户绘制,图头与时段卡保留跨日去重 UV,0 值不绘柱,四图同步横向滚动。“当前使用人数(五分钟统计一次)”纠正为滚动口径“近 5 分钟活跃用户”。
- 聚合边界:不新增持久化表或字段;新增仅 runtime service identity 可调用的 `get_admin_dashboard_stats_and_return` procedure,在 SpacetimeDB 事务快照内聚合现有 profile、tracking 私有事实,经 `spacetime-client` facade 返回紧凑投影;素材与钱包仍走原查询。api-server 不再依赖固定 50,000 行原始明细读取来生成访问模块分布或精确统计;该权威聚合失败时 Dashboard 请求失败,不把未知统计降级成 0。
- 扩展性边界:现有表缺少覆盖跨 scope、跨日期统计的现成索引,本阶段为保持精确性仍在 procedure 内遍历相关事实并监控耗时;数据规模继续增长时改为日期前缀索引或持久化日聚合事实,不恢复固定 `LIMIT` 截断。
- 留存口径:筛选范围内 `profile_dashboard_state.created_at` 的北京时间注册日构成 cohort;在精确 `D+1` / `D+7` 存在有效登录 user scope 日聚合即留存。观察日必须早于今天;D1、D7 分别返回留存人数、可观察人数和四舍五入后的基点率,按人数加权汇总,零分母前端显示 `-`
- 影响范围:SpacetimeDB Dashboard 聚合 procedure、`spacetime-client` facade、`/admin/api/dashboard` 与 shared contracts、`apps/admin-web` Dashboard 页面和运营文档。
- 验证方式:SpacetimeDB 聚合与 api-server 定向 Rust 测试、Dashboard Vitest、`npm run admin-web:typecheck``npm run check:spacetime-schema``npm run check:spacetime-runtime-access``npm run check:encoding``git diff --check`,并用桌面 / 移动浏览器核对留存、日期、每日 UV、零值与滚动同步。
+39 -10
View File
@@ -384,6 +384,14 @@
- 验证:`npm run test -- src/components/image-editor/ImageCanvasGenerationDialogModel.test.ts src/components/image-editor/ImageCanvasQuickEditPanelView.test.tsx src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx` 覆盖来源参数继承、模型参数切换、目标尺寸提交和图层回填;`cargo test -p api-server editor_image_edit --manifest-path server-rs/Cargo.toml` 覆盖图标类拒绝、provider 尺寸对齐和回图恢复。
- 关联:`src/components/image-editor/ImageCanvasGenerationSubmissionModel.ts``src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts``src/components/image-editor/ImageCanvasGenerationLayerModel.ts``server-rs/crates/api-server/src/editor_project.rs`
## 图片画布纯尺寸变换必须先在内存决策再单次上传
- 现象:`nanobanana2` 已返回并成功解码图片,任务随后报“尺寸无效”;普通生图若把尺寸恢复作为硬失败,provider 已成功回图后仍可能没有素材进入素材库和画布。
- 原因:通用尺寸恢复把 `nanobanana2` 的标量清晰度档位 `512 / 1024 / 2K` 当成 `WIDTHxHEIGHT` 解析;普通图片与快速编辑还把 OSS 持久化放在尺寸恢复之后。同步 `generateContent` 返回的是内联 base64,本地兜底 task id 不能用于向 provider 回查原图。
- 处理:`nanobanana2` 保留 provider 输出几何尺寸;其它模型仍按显式像素目标尝试恢复。普通生图和快速编辑先把 provider 回图留在内存,尺寸恢复成功后只上传变换结果,恢复失败则降级为只上传 provider 原图;每个主结果只执行一次 OSS 持久化并只创建一个素材。角色、图标图集、UI 提取和角色动作的 provider 原始输出按多产物语义单独保留并承载任务模型成本,后续抠图、逐帧处理和切片阶段成本为 0;中间产物沿用 `character``icon-spritesheet``character-animation` 等真实类型,不新增“原图类型”。扣费确认仍以 provider 成功为界,不延长到 OSS、后处理或画布回填;后台按任务显示最终产物父行,并把每个中间产物作为独立子行展开,分别展示阶段生成器和阶段成本。
- 验证:`cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml` 覆盖 nanobanana 标量尺寸、变换失败回落 provider 原图、变换先于单次持久化;`cargo test -p api-server character_animation_assets --manifest-path server-rs/Cargo.toml` 覆盖角色动作原始预览的真实类型和成本归因;`npm run test -- apps/admin-web/src/pages/AdminEditorAssetQueryPage.test.tsx` 覆盖中间产物逐行展开和成本文案。
- 关联:`server-rs/crates/api-server/src/editor_project.rs``apps/admin-web/src/pages/AdminEditorAssetQueryPage.tsx`
## 图片画布快速编辑元数据必须记录原图引用
- 现象:快速编辑生成的新图可以替换画布,但打开图片信息时“生成输入”里看不到被修改的原图。
@@ -946,12 +954,12 @@
- 验证:`npm run test -- src/services/miniGameDraftGenerationProgress.test.ts -t "wooden fish"`,并观察木鱼生成页在 5 分钟以上等待时仍停留在合理阶段。
- 关联:`src/services/miniGameDraftGenerationProgress.ts``docs/prd/【玩法创作】敲木鱼玩法模板PRD-2026-05-20.md``docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`
## 敲木鱼点击生成出现 SpacetimeDB procedure 超时先查版本错配
## 本地 SpacetimeDB procedure 超时或缺失先查版本错配
- 现象:敲木鱼创作时点击“生成”,前端提示 `SpacetimeDB procedure 调用超时`服务端日志更早出现 `Failed to BSATN deserialize procedure return value` 或类似反序列化错误
- 原因:本机 `spacetime` CLI / standalone 版本与 `server-rs/Cargo.toml` 锁定的 `spacetimedb` 版本不一致时,procedure 返回值会在宿主侧反序列化失败,api-server 继续等待就表现成调用超时。若旧 standalone 进程还在复用,也会把这个错配继续带进新一轮创作
- 处理:先用 `spacetime --version` 确认 `spacetimedb tool version`,再和 `server-rs/Cargo.toml``spacetimedb = "..."` 对齐;遇到版本不匹配时先直接执行 `spacetime version install <version> && spacetime version use <version>`,或在目标就是最新版本时执行 `spacetime version upgrade`,升级后重启 `npm run dev:spacetime` 再重试。当前 dev 脚本会在启动和复用本地 SpacetimeDB 前写入并校验 `dev-spacetime-tool-version`避免继续复用旧宿主
- 验证:`spacetime --version` 输出与 `server-rs/Cargo.toml` 一致,`http://127.0.0.1:3101/v1/ping` 正常,`npm run test -- scripts/dev.test.ts` 通过,敲木鱼创作点击生成不再卡在 procedure timeout
- 现象:敲木鱼创作时点击“生成”提示 `SpacetimeDB procedure 调用超时`或后台 Dashboard 的指标与柱状图同时消失;服务端日志更早出现 `Failed to BSATN deserialize procedure return value``No such procedure`Dashboard 请求返回 `502`
- 原因:本机 `spacetime` CLI / standalone 版本与 `server-rs/Cargo.toml` 锁定的 `spacetimedb` 版本不一致时,procedure 返回值会在宿主侧反序列化失败,api-server 继续等待就表现成调用超时。若旧 worktree 已删除但其 orphan standalone 仍监听原端口,API 还可能连到旧 wasm:健康检查正常,新 bindings 对应的 procedure 却尚未发布
- 处理:先用 `spacetime --version` 和监听端口对应的 `/proc/<pid>/exe --version` 分别核对 CLI 与真实宿主,再和 `server-rs/Cargo.toml` 的锁定版本对齐;不能把新版本模块硬发布到旧宿主。旧实例仍有需要保留的本地数据时,先用迁移 procedure 导出,在独立端口启动匹配版本、发布当前模块并增量导入,逐表对账后再把本次 API 切到新实例;旧实例在对账前不停止。当前 dev 脚本会对带版本记录的本地实例校验 `dev-spacetime-tool-version`但显式连接历史端口时仍要核对真实进程和 module schema
- 验证:CLI、standalone 与 Cargo 锁定版本一致,`/v1/ping` 正常,`spacetime describe` 可找到调用中的 procedureDashboard 接口返回 `200` 且包含 4 张图,敲木鱼生成不再卡在 procedure timeout。另执行 `npm run test -- scripts/dev.test.ts` 验证本地调度门禁
- 关联:`scripts/dev.mjs``scripts/dev.test.ts``server-rs/Cargo.toml``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 拼图 UI spritesheet 运行态不要二次包圆底或拉伸比例
@@ -1896,13 +1904,13 @@
- 验证:deploy 工作区应直接出现 `build/<version>/web.tar.gz``web.tar.gz.sha256`;后续仍由 `scripts/deploy/production-web-deploy.sh` 执行 checksum 校验和解压 smoke。
- 关联:`jenkins/Jenkinsfile.production-web-deploy``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## Jenkins 生产流水线拉 Git 统一走内网 SSH
## Jenkins 生产流水线拉 Git 统一走本机 SSH
- 后续更新:2026-06-19常规构建 / 导入导出 / Full Build 流水线的 Jenkinsfile 内部 checkout 统一使用内网 SSH 地址 `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git` 凭据 `genarrative-local-gitea-ssh`,不再`https://git.genarrative.world/git/GenarrativeAI/Genarrative.git` 作为默认主源或 fallback,也不再配置公网 Git fallback`Genarrative-Server-Provision` 仍是服务器初始化专用口径,Job 的 `Pipeline script from SCM` 和 Jenkinsfile 内部 checkout 都必须使用本机路径或目标 agent 可访问的内网 Git 源
- 后续更新:2026-07-14所有生产 Job 的 `Pipeline script from SCM` Jenkinsfile 内部 checkout 统一使用本机 SSH 地址 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git` 凭据 `genarrative-local-gitea-ssh`,不再保留局域网 IP、HTTP 内网地址或公网 fallback
- 现象:生产发布、数据库导入导出、服务器配置、构建或 `Genarrative-Full-Build-And-Deploy` 流水线执行 `GitSCM checkout` 时,如果 Jenkins 生成的 fetch 是 `+refs/heads/*:refs/remotes/origin/*`,公网 Git 链路可能在收包阶段以 `git-remote-https died of signal 15``curl 56 GnuTLS recv error (-9)``early EOF``invalid index-pack output` 失败;写死 `127.0.0.1:3000` 也会在当前执行 agent 不是 Gitea 所在机器时失败。
- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;公网域名会绕外部链路并受公网代理、TLS、带宽和凭据影响。HTTP 私有仓库入口如果没有配置 Jenkins 凭据,会在 Git 插件日志中显示 `No credentials specified` 并以 `Failed to authenticate user` 失败。即使使用内网 Git,如果 `GitSCM` 没有显式 refspec 并开启 `CloneOption honorRefspec=true`Jenkins Git 插件会拉取所有分支。
- 处理:运行于 `linux && genarrative-build``Genarrative-Full-Build-And-Deploy` 源码解析阶段、`Genarrative-Web-Build` / `Genarrative-Api-Build` / `Genarrative-Stdb-Module-Build` checkout 阶段,以及数据库导入导出流水线的首次 `checkout([$class: 'GitSCM', ...])` 层统一使用 `GIT_REMOTE_URL=ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git``GIT_REMOTE_CREDENTIAL_ID=genarrative-local-gitea-ssh``GIT_REMOTE_FALLBACK_URL` 留空。这些首次 checkout 都必须使用目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`。后续统一走 `scripts/jenkins-checkout-source.sh`,构建类流水线以 `GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT=true` 复用首次 `GitSCM` 带凭据浅克隆,只有指定 commit 不在浅克隆里时才通过同一 SSH 凭据继续 fetch 和加深;`COMMIT_HASH` 为空时继续 `--depth=1 --no-tags`,指定 commit 时也先保持 `depth=1` 校验,浅历史无法证明归属时才按 `GENARRATIVE_JENKINS_CHECKOUT_DEEPEN_STEPS` 逐步加深,最后才展开完整历史。发布流水线不得为了缩短 checkout 时间清空上游构建传入的 `COMMIT_HASH`
- 验证:扫描本地 Jenkins live job `config.xml`,确认 SCM `<url>` 不再指向 `https://git.genarrative.world/GenarrativeAI/Genarrative.git`;扫描所有生产 Jenkinsfile 的首次 `GitSCM checkout`,确认 `GIT_REMOTE_URL` `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git``GIT_REMOTE_CREDENTIAL_ID` `genarrative-local-gitea-ssh``GIT_REMOTE_FALLBACK_URL` 为空,`userRemoteConfigs``+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}``CloneOption``honorRefspec: true`;重放 Jenkins 时 checkout 日志不应再出现 `No credentials specified`;扫描发布流水线确认传给 `scripts/jenkins-checkout-source.sh``COMMIT_HASH` 未被硬编码为空;运行 `bash -n scripts/jenkins-checkout-source.sh`
- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身,因此 Git checkout 必须收口到同机运行 Gitea SSH、带 `linux && genarrative-build` 标签的 Jenkins Built-In Node;公网域名和局域网 IP 会引入额外网络、代理、TLS 与地址漂移。即使使用本机 Git,如果 `GitSCM` 没有显式 refspec 并开启 `CloneOption honorRefspec=true`Jenkins Git 插件会拉取所有分支。
- 处理:Full、Web、API、Stdb、Server-Provision 与数据库导入导出的源码准备统一在 Jenkins Built-In Node 使用 `GIT_REMOTE_URL=ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git``GIT_REMOTE_CREDENTIAL_ID=genarrative-local-gitea-ssh``GIT_REMOTE_FALLBACK_URL` 留空。数据库导入导出把经过 commit 校验的必要脚本 stash 给目标 agentrelease / dev 目标阶段只 unstash,不再 checkout Git 或挂载 Git SSH 凭据。首次 checkout 保留目标分支 refspec、`CloneOption shallow=true depth=1 noTags=true honorRefspec=true`,随后由 `scripts/jenkins-checkout-source.sh` 复用并在必要时逐步加深
- 验证:扫描本地 Jenkins live Job `config.xml` 和所有生产 Jenkinsfile,确认 Git URL 均为 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`,凭据仍为 `genarrative-local-gitea-ssh` 且 fallback 为空;确认数据库导入导出在 Prepare 阶段 checkout / stash,目标阶段只 unstash;在 Jenkins 凭据环境运行 `git ls-remote ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git HEAD`,并运行 `npm run check:production-ops``bash -n scripts/jenkins-checkout-source.sh`
- 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy``jenkins/Jenkinsfile.production-web-build``jenkins/Jenkinsfile.production-api-build``jenkins/Jenkinsfile.production-stdb-module-build``jenkins/Jenkinsfile.production-web-deploy``jenkins/Jenkinsfile.production-api-deploy``jenkins/Jenkinsfile.production-stdb-module-publish``jenkins/Jenkinsfile.production-server-provision``jenkins/Jenkinsfile.production-database-export``jenkins/Jenkinsfile.production-database-import``scripts/jenkins-checkout-source.sh``docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`
## Jenkins 可选参数在 set -u 下不能裸读
@@ -3080,3 +3088,24 @@
- 处理:网络结果未知时保留活动 hold,页面把原 `requestId`、订单、金额、原因和已知 `out_refund_no` 保存到当前后台标签页、当前管理员会话隔离的 `sessionStorage`,有效期 2 小时;刷新后必须与后端 active hold 的金额、原因和退款号对账一致才允许直接复用原请求,服务端明确拒绝时清理上下文。没有原请求上下文、上下文过期或管理员会话已切换时只开放预填 `out_refund_no` 的安全查单登记,不生成新 ID 硬撞活动 hold。worker 在占用创建至少 10 分钟后查同一退款号,查到退款就将验签事实写入统一 observation,只有连续 3 次查单收到官方 `RESOURCE_NOT_EXISTS` 才释放。进程重启清空连续次数并重新观察;超时、签名、配置、解析等错误一律重置次数并继续占用。
- 补充:退款查单适配器必须保留微信 `RESOURCE_NOT_EXISTS` 业务码,并兼容同类 `ORDER_NOT_EXIST`,不能把所有非 2xx 都抹平成通用上游错误;签名有效的退款申请/查询响应仍须与本次 `out_refund_no`、订单号、交易号和金额做关联校验。已释放 hold 复用旧 `requestId` 时必须在调用微信前拒绝,并要求重新预检生成新的请求 ID。
- 关联:`server-rs/crates/api-server/src/admin_recharge.rs``server-rs/crates/api-server/src/profile_recharge_refund_reconciliation.rs``profile_recharge_refund_hold`
## 时段 UV 不能伪装成单日趋势
- 现象:访问人数卡显示整段时间有数百人,但趋势图只有终止日一根满柱,其余日期是同样高度的小短柱;横向滚动后还容易误以为后半月突然出现访问。
- 原因:把时段跨日去重 UV 通过单值 series 塞进 `anchor_date_key`,再对 0 值强制设置最小可见高度。时段 UV、每日 UV 和每日 UV 之和是三个不同指标,不能互相替代。
- 处理:趋势 bucket 按 `day_key + user_id` 每日去重,图头单独使用整个筛选范围跨日去重人数;0 值高度必须为 0。多张同轴图使用同一日期范围并同步横向滚动,快捷范围不生成未来日 bucket。
- 验证:构造同一用户跨两日访问与某日零访问的 fixture,断言每日 bucket、时段去重总数和零值柱分别正确;浏览器核对四图首尾日期窗口一致。
## 运营聚合不能用固定 LIMIT 的原始事实冒充精确结果
- 现象:Dashboard 出现“达到单次读取上限 50000 行”告警,但模块分布和累计值仍以无“不完整”标识的精确数字展示;数据增长后结果会随任意截断样本漂移。
- 原因:api-server 拉取 `SELECT ... LIMIT 50000` 原始事实再聚合,没有完整分页、稳定排序或数据库侧聚合。提高上限只会推迟错误,并增加响应体与内存压力。
- 处理:精确运营指标通过受 runtime service identity 限制的 SpacetimeDB procedure 在事务内聚合,只返回紧凑统计投影,再由 `spacetime-client` facade 交给 BFF。权威聚合失败时请求必须失败,不能用默认 0 代替未知值;现有索引无法覆盖跨 scope、跨日期统计时先监控事务扫描耗时,数据增长后补日期前缀索引或持久化日聚合事实,不能退回固定 `LIMIT`。若某查询只能采样,契约和 UI 必须明确标为采样,不能展示成精确值。
- 验证:聚合结果不随 HTTP SQL 行上限变化;超过 50,000 条事实时仍无截断告警,并用数据库事实抽样对账每日、时段、累计与分布结果。
## 后台详情列表的 grid 规则不要命中嵌套身份组件
- 现象:素材查询或精选素材详情弹窗中的作者陶泥号被逐字符竖排,用户详情按钮也被挤到编号旁边;窄屏下图片与详情列继续互相挤压。
- 原因:`.admin-info-list div` 会命中列表内所有后代 `div`,把字段值内部的 `.admin-inline-identity` 和昵称容器也覆盖成双列 grid;陶泥号又允许任意位置换行,最终只剩单字符宽度。素材详情布局若始终固定为 `220px + 信息列`,移动端也没有足够空间。
- 处理:信息列表的行布局只使用直接子选择器 `.admin-info-list > div`;作者昵称与陶泥号在身份组件内分行,陶泥号保持单行并在真正不足时省略。`560px` 以下的素材详情改为单列,缩略图居中;素材查询与精选审核共用该规则。
- 验证:在桌面、560px、390px 和 320px 浏览器宽度打开素材详情,确认 `.admin-inline-identity` 的 computed `display``flex`、陶泥号横向显示、详情字段不溢出页面。
@@ -45,7 +45,7 @@ server-rs + Axum + SpacetimeDB
- `spacetime-client`:后端访问 SpacetimeDB 的 typed facade。
- `module-*`:纯领域模型、命令、应用规则、领域事件和领域错误。
- `platform-*`:OSS、LLM、认证、语音等外部平台能力。
- `shared-contracts` / `packages/shared`:前后端 DTO公开契约。
- `shared-contracts` / `packages/shared`:前后端 DTO公开契约和跨页面复用的无业务真相 TypeScript 代码;`packages/shared` 可承载共享 UI 组件与纯工具,但不承载领域规则、后端副作用或正式状态
- 前端:表现、交互、临时 UI 状态和后端结果渲染。
明确废弃:旧 `server-node`、Express、PostgreSQL、Go 服务端、`maincloud`、人工 `spacetime --root-dir` 口径,以及前端承接正式业务真相的路线。

Some files were not shown because too many files have changed in this diff Show More