合并最新master并融合图片生成链路
保留BgFilter complex、cross-check、单次重试、全帧并发排空与任务阶段上报 采用master的生成产物一次上传、真实素材类型及成本和供应商归因 同步后台多账号、Dashboard、SpacetimeDB schema、迁移与生成绑定 融合项目决策记录和相关前后端文档
This commit is contained in:
@@ -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: [] }), {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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、零值与滚动同步。
|
||||
|
||||
@@ -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` 可找到调用中的 procedure;Dashboard 接口返回 `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 给目标 agent,release / 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
Reference in New Issue
Block a user