合并 origin/master:随包资源仍由准备步骤生成,Claude Agent SDK sidecar 按同一架构归位

- 合并 origin/master(140 个提交),解决 4 处冲突:build.rs 取只读校验分支、package.json 同时保留 bundled-resources:test 与 check:generated-bindings、决策日志与踩坑记录两侧条目并存
- master 的构建期 staging(stage_bundled_claude_agent、prune_stale_codex_components)不进构建脚本;sidecar 改由声明与准备步骤产出
- package-layout.json 新增 claudeAgent section(锁定版本、sidecar 入口、上游 SDK 与平台原生运行时目标表、复制跳过规则),layoutVersion 递增到 2
- prepare-bundled-resources.mjs 新增 sidecar 整目录原子替换与缓存 key,复制规则按 section 参数化,Windows 真机 24 个文件、复跑命中缓存零写入
- package_layout.rs 新增 sidecar 只读校验(入口与仓库源码逐字节一致、上游包版本、可执行位、白名单外文件)与声明钉住用例
- build.rs 调用 sidecar 校验并导出 AGC_CLAUDE_AGENT_SDK_VERSION;claude_code_cli.rs 的 sidecar 身份串改用声明版本
- resources/claude-agent 映射从基线 tauri.conf.json 移到 windows/macos 平台配置,check-config.mjs 增加「基线不得声明 resources/**」守卫
- 修正 package_layout 的 Windows 路径期望用例(期望值改与实现同形,原用例在 Windows 必红)
- 同步技术方案 §4.5/§4.9、共享决策日志与踩坑记录
This commit is contained in:
2026-09-30 17:23:04 +08:00
497 changed files with 39302 additions and 20386 deletions
+1
View File
@@ -56,6 +56,7 @@ temp*build*/
/plugins/agc-unity-editor/dotnet/native-build/
/apps/ai-game-creator-shell/logs/
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime/
/apps/ai-game-creator-shell/src-tauri/resources/claude-agent/
/apps/ai-game-creator-shell/src-tauri/resources/node-runtime-staging-*/
/apps/ai-game-creator-shell/src-tauri/resources/plugins-staging-*/
/apps/ai-game-creator-shell/src-tauri/resources/codex/*-staging-*/
+3
View File
@@ -1075,11 +1075,14 @@ export interface AdminRechargeRefundActionResponse {
export interface AdminWalletRestrictionResponse {
wallet: AdminProfileWalletPayload;
}
export type AdminAgcAgentMode = 'codex' | 'cc';
export interface AdminAgcModel {
id: string;
alias: string;
modelId: string;
enabled: boolean;
agentMode: AdminAgcAgentMode;
}
export interface AdminAgcModelCatalog {
+3 -2
View File
@@ -1,3 +1,4 @@
import { AdminPanel } from '@genarrative/shared/components';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
@@ -210,9 +211,9 @@ export function AdminApp() {
onRouteChange={handleRouteChange}
>
{activeRouteId === null ? (
<section className="admin-panel admin-zero-permission-state">
<AdminPanel className="admin-zero-permission-state">
<h2>暂无访问权限</h2>
</section>
</AdminPanel>
) : null}
{activeRouteId === 'dashboard' ? (
<AdminDashboardPage token={token} onUnauthorized={handleUnauthorized} />
+4 -3
View File
@@ -1,3 +1,4 @@
import { AdminButton } from '@genarrative/shared/components';
import {
Activity,
BadgeDollarSign,
@@ -108,15 +109,15 @@ export function AdminShell({
<span>{admin.displayName || admin.username}</span>
<small>{admin.accountRole === 'owner' ? 'owner' : 'member'}</small>
</div>
<button
className="admin-icon-button"
<AdminButton
variant="icon"
title="退出登录"
type="button"
onClick={onLogout}
>
<LogOut size={18} aria-hidden="true" />
<span>退出</span>
</button>
</AdminButton>
</header>
<main className="admin-content">{children}</main>
@@ -1,4 +1,5 @@
import { Pause, Play, X } from 'lucide-react';
import { AdminDialog } from '@genarrative/shared/components';
import { Pause, Play } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
@@ -85,35 +86,22 @@ export function AdminEditorAssetPreviewDialog({
: null;
return (
<div className="admin-confirm-backdrop" role="presentation">
<section
aria-label="素材预览"
className="admin-detail-panel admin-asset-query-preview-dialog"
role="dialog"
>
<div className="admin-panel-heading">
<div>
<h3>{entry.label || entry.assetId}</h3>
<span>{entry.assetId}</span>
</div>
<button
aria-label="关闭素材预览"
className="admin-ghost-button"
type="button"
onClick={onClose}
>
<X size={17} aria-hidden="true" />
</button>
<AdminDialog
ariaLabel="素材预览"
closeLabel="关闭素材预览"
description={entry.assetId}
panelClassName="admin-asset-query-preview-dialog"
title={entry.label || entry.assetId}
onClose={onClose}
>
{sourceEntry ? (
<div className="admin-asset-query-source-preview">
<h4>原图</h4>
<AdminEditorAssetPreviewMedia entry={sourceEntry} token={token} />
</div>
{sourceEntry ? (
<div className="admin-asset-query-source-preview">
<h4>原图</h4>
<AdminEditorAssetPreviewMedia entry={sourceEntry} token={token} />
</div>
) : null}
<AdminEditorAssetPreviewMedia entry={entry} token={token} />
</section>
</div>
) : null}
<AdminEditorAssetPreviewMedia entry={entry} token={token} />
</AdminDialog>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,4 @@
import { AdminButton } from '@genarrative/shared/components';
import { UserRoundSearch } from 'lucide-react';
import { MouseEvent, useRef, useState } from 'react';
@@ -42,16 +43,17 @@ export function AdminUserReferenceButton({
return (
<>
<button
<AdminButton
variant="ghost"
ref={triggerRef}
aria-label="查看用户信息"
className="admin-ghost-button admin-user-reference-button"
title="查看用户信息"
type="button"
onClick={openDialog}
className="admin-user-reference-button"
>
<UserRoundSearch size={16} aria-hidden="true" />
</button>
</AdminButton>
{open ? (
<AdminUserDetailDialog
token={token}
@@ -1,3 +1,8 @@
import {
AdminButton,
AdminDialog,
AdminInfoList,
} from '@genarrative/shared/components';
import { useCallback, useEffect, useRef, useState } from 'react';
interface AdminWriteConfirmOptions {
@@ -44,63 +49,49 @@ export function useAdminWriteConfirm() {
}
cancelButtonRef.current?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
closeConfirm(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [closeConfirm, pendingConfirm]);
}, [pendingConfirm]);
const confirmDialog = pendingConfirm ? (
<div
aria-modal="true"
aria-labelledby="admin-write-confirm-title"
className="admin-confirm-backdrop"
role="dialog"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeConfirm(false);
}
}}
<AdminDialog
count={pendingConfirm.action}
initialFocusRef={cancelButtonRef}
panelClassName="admin-confirm-panel"
showClose={false}
title="确认操作"
titleProps={{ id: 'admin-write-confirm-title' }}
onClose={() => closeConfirm(false)}
>
<section className="admin-confirm-panel">
<div className="admin-panel-heading">
<h3 id="admin-write-confirm-title">确认操作</h3>
<span>{pendingConfirm.action}</span>
</div>
<dl className="admin-info-list">
<div>
<dt>操作</dt>
<dd>{pendingConfirm.action}</dd>
</div>
<div>
<dt>对象</dt>
<dd>{pendingConfirm.target}</dd>
</div>
</dl>
<div className="admin-confirm-warning">该操作会立即影响线上数据</div>
<div className="admin-confirm-actions">
<button
className="admin-secondary-button"
ref={cancelButtonRef}
type="button"
onClick={() => closeConfirm(false)}
>
取消
</button>
<button
className="admin-danger-button"
type="button"
onClick={() => closeConfirm(true)}
>
确认
</button>
</div>
</section>
</div>
<AdminInfoList
items={[
{
label: <>操作</>,
value: <>{pendingConfirm.action}</>,
},
{
label: <>对象</>,
value: <>{pendingConfirm.target}</>,
},
]}
/>
<div className="admin-confirm-warning">该操作会立即影响线上数据</div>
<div className="admin-confirm-actions">
<AdminButton
variant="secondary"
ref={cancelButtonRef}
type="button"
onClick={() => closeConfirm(false)}
>
取消
</AdminButton>
<AdminButton
variant="danger"
type="button"
onClick={() => closeConfirm(true)}
>
确认
</AdminButton>
</div>
</AdminDialog>
) : null;
return {
+105 -107
View File
@@ -1,3 +1,14 @@
import {
AdminActionRow,
AdminAlert,
AdminButton,
AdminField,
AdminFormRow,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminPanel,
} from '@genarrative/shared/components';
import { Plus, RefreshCcw, Save } from 'lucide-react';
import { type FormEvent, useEffect, useState } from 'react';
@@ -159,117 +170,111 @@ export function AdminAccountsPage({
}
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>
<AdminPage wide>
<AdminPageHeading
title="账号管理"
description="后台成员"
actions={
<AdminActionRow>
<AdminButton
variant="secondary"
type="button"
onClick={startCreate}
>
<Plus size={17} aria-hidden="true" />
<span>添加账号</span>
</AdminButton>
<AdminButton
variant="secondary"
disabled={isLoading}
type="button"
onClick={refreshAccounts}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</AdminButton>
</AdminActionRow>
}
/>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : 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
<AdminListPanel
busy={isLoading}
busyText="加载中"
className="admin-account-list"
count={accounts.length}
emptyText="暂无成员账号"
rows={accounts}
title="后台账号"
>
<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);
}
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>
}}
>
<span>
<strong>{account.displayName || account.username}</strong>
<small>{account.username}</small>
</span>
<small>
{account.accountRole === 'owner'
? 'owner'
: account.enabled
? '启用'
: '停用'}
</small>
</button>
))}
</div>
</AdminListPanel>
<div className="admin-form-row">
<label className="admin-field">
<span>用户名</span>
<AdminPanel
as="form"
onSubmit={handleSave}
title={selectedAccountId ? '编辑账号' : '添加账号'}
extra={
<>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span>启用</span>
</label>
</>
}
>
<AdminFormRow>
<AdminField label="用户名">
<input
disabled={Boolean(selectedAccountId)}
autoComplete="off"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
<label className="admin-field">
<span>显示名称</span>
</AdminField>
<AdminField label="显示名称">
<input
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
</label>
</div>
<label className="admin-field">
<span>{selectedAccountId ? '新密码' : '密码'}</span>
</AdminField>
</AdminFormRow>
<AdminField label={selectedAccountId ? '新密码' : '密码'}>
<input
autoComplete="new-password"
placeholder={selectedAccountId ? '不修改' : ''}
@@ -277,8 +282,7 @@ export function AdminAccountsPage({
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
</AdminField>
<fieldset className="admin-permission-fieldset">
<legend>Tab 访问权限</legend>
<div className="admin-permission-grid">
@@ -296,7 +300,6 @@ export function AdminAccountsPage({
))}
</div>
</fieldset>
<fieldset className="admin-permission-fieldset">
<legend>独立操作权限</legend>
<div className="admin-permission-grid">
@@ -318,18 +321,13 @@ export function AdminAccountsPage({
</label>
</div>
</fieldset>
<button
className="admin-primary-button"
disabled={isSaving}
type="submit"
>
<AdminButton variant="primary" disabled={isSaving} type="submit">
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
</AdminButton>
</AdminPanel>
</div>
{confirmDialog}
</section>
</AdminPage>
);
}
@@ -23,14 +23,23 @@ vi.mock('../components/useAdminWriteConfirm', () => ({
confirmDialog: null,
}),
}));
afterEach(cleanup);
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test('edits alias and upstream model without changing the stable identifier or revision', async () => {
const catalog = {
revision: 3,
defaultModelId: 'quality',
models: [
{ id: 'quality', alias: '高质量', modelId: 'gpt-6-astra', enabled: true },
{
id: 'quality',
alias: '高质量',
modelId: 'gpt-6-astra',
enabled: true,
agentMode: 'codex' as const,
},
],
};
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
@@ -54,3 +63,44 @@ test('edits alias and upstream model without changing the stable identifier or r
expect(screen.getAllByText('已保存').length).toBeGreaterThanOrEqual(2);
});
});
test('new models default to Codex and save the selected Agent mode', async () => {
const catalog = {
revision: 3,
defaultModelId: 'quality',
models: [
{
id: 'quality',
alias: '高质量',
modelId: 'gpt-6-astra',
enabled: true,
agentMode: 'codex' as const,
},
],
};
vi.mocked(getAgcModelCatalog).mockResolvedValue(catalog);
vi.mocked(saveAgcModelCatalog).mockImplementation(async (_, input) => ({
...input,
revision: 4,
}));
render(<AdminAgcModelsPage token="test" onUnauthorized={vi.fn()} />);
await screen.findByDisplayValue('gpt-6-astra');
fireEvent.click(screen.getByRole('button', { name: '添加模型' }));
const agentMode = screen.getByLabelText('模型 2 Agent 模式');
expect((agentMode as HTMLSelectElement).value).toBe('codex');
fireEvent.change(agentMode, { target: { value: 'cc' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
const calls = vi.mocked(saveAgcModelCatalog).mock.calls;
const payload = calls[calls.length - 1]?.[1];
expect(payload?.models).toHaveLength(2);
expect(payload?.models[1]).toMatchObject({
alias: '',
modelId: '',
enabled: true,
agentMode: 'cc',
});
});
});
+186 -153
View File
@@ -1,3 +1,8 @@
import {
AdminListPanel,
AdminPage,
AdminPageHeading,
} from '@genarrative/shared/components';
import { CircleHelp, Plus, RefreshCcw, Save, Trash2 } from 'lucide-react';
import { useEffect, useState } from 'react';
@@ -74,16 +79,16 @@ export function AdminAgcModelsPage({
}
return (
<section className="admin-page admin-page-wide admin-agc-models">
<div className="admin-page-heading">
<div>
<h2>AGC 模型</h2>
<p>管理客户端可用模型与用户看到的名称</p>
</div>
<span className="admin-agc-models-revision">
版本 v{catalog?.revision ?? '-'}
</span>
</div>
<AdminPage wide className="admin-agc-models">
<AdminPageHeading
title="AGC 模型"
description="管理客户端可用模型与用户看到的名称"
actions={
<span className="admin-agc-models-revision">
版本 v{catalog?.revision ?? '-'}
</span>
}
/>
<div className="admin-agc-models-summary">
<div>
<span>已启用</span>
@@ -106,150 +111,178 @@ export function AdminAgcModelsPage({
<strong>{busy ? '处理中' : saved ? '已保存' : '待修改'}</strong>
</div>
</div>
<section className="admin-panel admin-agc-models-panel">
<div className="admin-panel-heading">
<div>
<h3>模型目录</h3>
<span>客户端仅显示别名,实际模型名仅在这里维护</span>
</div>
<CircleHelp size={17} aria-label="模型目录帮助" />
</div>
<div className="admin-agc-models-toolbar">
<button
type="button"
title="重新读取"
aria-label="重新读取模型"
disabled={busy}
onClick={() => void refresh()}
>
<RefreshCcw size={16} />
</button>
<button
type="button"
title="添加模型"
aria-label="添加模型"
disabled={busy || !catalog || catalog.models.length >= 32}
onClick={() => {
setSaved(false);
setCatalog(
(current) =>
current && {
...current,
models: [
...current.models,
{
id: crypto.randomUUID(),
alias: '',
modelId: '',
enabled: true,
<AdminListPanel
className="admin-agc-models-panel"
columns={[
{ key: 'alias', label: '别名' },
{ key: 'modelId', label: '实际模型名' },
{ key: 'agentMode', label: 'Agent 模式' },
{ key: 'enabled', label: '启用' },
{ key: 'default', label: '默认' },
{ key: 'actions', label: '操作' },
]}
rows={catalog?.models ?? []}
showEmpty={false}
title="模型目录"
description="客户端仅显示别名,实际模型名仅在这里维护"
extra={<CircleHelp size={17} aria-label="模型目录帮助" />}
toolbar={
<>
<div className="admin-agc-models-toolbar">
<button
type="button"
title="重新读取"
aria-label="重新读取模型"
disabled={busy}
onClick={() => void refresh()}
>
<RefreshCcw size={16} />
</button>
<button
type="button"
title="添加模型"
aria-label="添加模型"
disabled={busy || !catalog || catalog.models.length >= 32}
onClick={() => {
setSaved(false);
setCatalog(
(current) =>
current && {
...current,
models: [
...current.models,
{
id: crypto.randomUUID(),
alias: '',
modelId: '',
enabled: true,
agentMode: 'codex',
},
],
},
],
},
);
}}
>
<Plus size={16} />
</button>
<button
type="button"
disabled={busy || !catalog}
onClick={() => void save()}
>
<Save size={16} />
保存
</button>
</div>
{error ? <p role="alert">{error}</p> : null}
{saved ? <p role="status">已保存</p> : null}
{busy ? <p role="status">正在处理</p> : null}
<div className="admin-table-wrap admin-agc-models-table">
<table className="admin-table admin-agc-models-table-grid">
<thead>
<tr>
<th>别名</th>
<th>实际模型名</th>
<th>启用</th>
<th>默认</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{catalog?.models.map((model, index) => (
<tr key={model.id}>
<td>
<input
aria-label={`模型 ${index + 1} 别名`}
maxLength={40}
required
value={model.alias}
disabled={busy}
onChange={(e) =>
update(model.id, { alias: e.target.value })
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 实际模型名`}
maxLength={200}
required
value={model.modelId}
disabled={busy}
onChange={(e) =>
update(model.id, { modelId: e.target.value })
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 启用`}
type="checkbox"
checked={model.enabled}
disabled={busy || model.id === catalog.defaultModelId}
onChange={(e) =>
update(model.id, { enabled: e.target.checked })
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 默认`}
name="agc-default-model"
type="radio"
checked={model.id === catalog.defaultModelId}
disabled={busy || !model.enabled}
onChange={() => {
setSaved(false);
setCatalog({ ...catalog, defaultModelId: model.id });
}}
/>
</td>
<td>
<button
type="button"
title="删除模型"
aria-label={`删除模型 ${index + 1}`}
disabled={busy || model.id === catalog.defaultModelId}
onClick={() => {
setSaved(false);
setCatalog({
...catalog,
models: catalog.models.filter(
(candidate) => candidate.id !== model.id,
),
});
}}
>
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}}
>
<Plus size={16} />
</button>
<button
type="button"
disabled={busy || !catalog}
onClick={() => void save()}
>
<Save size={16} />
保存
</button>
</div>
{error ? <p role="alert">{error}</p> : null}
{saved ? <p role="status">已保存</p> : null}
{busy ? <p role="status">正在处理</p> : null}
</>
}
renderRow={(model, index) => {
if (!catalog) {
return null;
}
return (
<tr key={model.id}>
<td>
<input
aria-label={`模型 ${index + 1} 别名`}
maxLength={40}
required
value={model.alias}
disabled={busy}
onChange={(e) =>
update(model.id, {
alias: e.target.value,
})
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 实际模型名`}
maxLength={200}
required
value={model.modelId}
disabled={busy}
onChange={(e) =>
update(model.id, {
modelId: e.target.value,
})
}
/>
</td>
<td>
<select
aria-label={`模型 ${index + 1} Agent 模式`}
value={model.agentMode}
disabled={busy}
onChange={(e) =>
update(model.id, {
agentMode: e.target.value as AdminAgcModel['agentMode'],
})
}
>
<option value="codex">Codex</option>
<option value="cc">Claude Code</option>
</select>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 启用`}
type="checkbox"
checked={model.enabled}
disabled={busy || model.id === catalog.defaultModelId}
onChange={(e) =>
update(model.id, {
enabled: e.target.checked,
})
}
/>
</td>
<td>
<input
aria-label={`模型 ${index + 1} 默认`}
name="agc-default-model"
type="radio"
checked={model.id === catalog.defaultModelId}
disabled={busy || !model.enabled}
onChange={() => {
setSaved(false);
setCatalog({
...catalog,
defaultModelId: model.id,
});
}}
/>
</td>
<td>
<button
type="button"
title="删除模型"
aria-label={`删除模型 ${index + 1}`}
disabled={busy || model.id === catalog.defaultModelId}
onClick={() => {
setSaved(false);
setCatalog({
...catalog,
models: catalog.models.filter(
(candidate) => candidate.id !== model.id,
),
});
}}
>
<Trash2 size={16} />
</button>
</td>
</tr>
);
}}
tableClassName="admin-agc-models-table-grid"
wrapClassName="admin-agc-models-table"
/>
{confirmDialog}
</section>
</AdminPage>
);
}
@@ -542,6 +542,37 @@ function pickUploadFiles(dialog: HTMLElement, accept: string, files: File[]) {
fireEvent.change(input, { target: { files } });
}
test('批量上传:表头是真实列头单元格,不是嵌套 thead', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const dialog = await openUploadDialog();
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
const table = Array.from(dialog.querySelectorAll('table')).find((candidate) =>
candidate.textContent?.includes('alpha.zip'),
);
expect(table).toBeTruthy();
// 每一列表头都必须是 `th`:把 `<TableHeader>`(渲染 `<thead>`)当单元格用会产出
// `<th><tr><thead>`,列头语义与样式一起丢失。
expect(
within(table as HTMLElement)
.getAllByRole('columnheader')
.map((cell) => cell.textContent),
).toEqual([
'模板包',
'ID',
'名称',
'版本',
'运行时',
'entry',
'封面',
'操作',
]);
expect(table?.querySelectorAll('thead thead')).toHaveLength(0);
expect(table?.querySelectorAll('thead > tr > th')).toHaveLength(8);
expect(table?.querySelectorAll('thead > th')).toHaveLength(0);
});
test('批量上传:多选 ZIP 生成行,封面匹配齐了才能提交', async () => {
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
const dialog = await openUploadDialog();
+187 -160
View File
@@ -1,4 +1,7 @@
import {
AdminListPanel,
AdminPage,
AdminPageHeading,
Button,
Modal,
SelectField,
@@ -294,28 +297,30 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
const writesDisabled = busy || loading || conflict || !snapshot?.writable;
return (
<section className="admin-page admin-page-wide admin-agc-templates genarrative-ui">
<div className="admin-page-heading">
<h2>模板管理</h2>
<div className="admin-actions">
<Button
variant="secondary"
disabled={writesDisabled || !snapshot}
onClick={openUpload}
>
<Upload size={16} aria-hidden="true" />
上传模板
</Button>
<Button
variant="secondary"
disabled={loading || busy}
onClick={() => void refresh()}
>
<RefreshCcw size={16} aria-hidden="true" />
刷新
</Button>
</div>
</div>
<AdminPage wide className="admin-agc-templates genarrative-ui">
<AdminPageHeading
title="模板管理"
actions={
<div className="admin-actions">
<Button
variant="secondary"
disabled={writesDisabled || !snapshot}
onClick={openUpload}
>
<Upload size={16} aria-hidden="true" />
上传模板
</Button>
<Button
variant="secondary"
disabled={loading || busy}
onClick={() => void refresh()}
>
<RefreshCcw size={16} aria-hidden="true" />
刷新
</Button>
</div>
}
/>
{readOnly ? (
<Status tone="warning">当前为只读模式,无法保存或上下架</Status>
) : null}
@@ -334,133 +339,155 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
</Button>
) : null}
{notice ? <Status tone="success">{notice}</Status> : null}
<div className="admin-agc-template-filters">
<TextField
label="搜索模板"
placeholder="名称、ID 或标签"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
/>
<SelectField
label="运行时"
value={runtime}
onChange={(event) => setRuntime(event.currentTarget.value)}
>
<option value="">全部运行时</option>
{runtimes.map((value) => (
<option key={value} value={value}>
{runtimeLabels[value] ?? value}
</option>
))}
</SelectField>
<SelectField
label="上架状态"
value={visibility}
onChange={(event) => setVisibility(event.currentTarget.value)}
>
<option value="">全部状态</option>
<option value="enabled">已上架</option>
<option value="disabled">已下架</option>
</SelectField>
</div>
{loading ? <Status>正在加载模板</Status> : null}
{snapshot ? (
<Table className="admin-table admin-agc-template-table">
<TableHeader>
<TableRow>
<TableHead>封面</TableHead>
<TableHead>模板</TableHead>
<TableHead>引擎 / 版本</TableHead>
<TableHead>包大小</TableHead>
<TableHead>状态</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map((entry) => (
<TableRow key={entry.id}>
<TableCell>
<img
className="admin-agc-template-cover"
src={entry.coverUrl}
alt={`${entry.title}封面`}
loading="lazy"
/>
</TableCell>
<TableCell>
<strong>{entry.title}</strong>
<small>{entry.id}</small>
<p className="admin-agc-template-summary">{entry.summary}</p>
<div className="admin-agc-template-tags">
{entry.tags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
</TableCell>
<TableCell>
{entry.engine ||
runtimeLabels[entry.runtime] ||
entry.runtime}{' '}
{entry.engineVersion}
<small>模板 {entry.templateVersion}</small>
</TableCell>
<TableCell>{formatTemplateSize(entry.zipSizeBytes)}</TableCell>
<TableCell>{entry.enabled ? '已上架' : '已下架'}</TableCell>
<TableCell>
<div className="admin-agc-template-actions">
<Button
size="sm"
variant="secondary"
disabled={busy || loading}
aria-label={`编辑 ${entry.title}`}
onClick={() => {
setError('');
setEditing({
entry,
revision: snapshot.revision,
key: ++editorSequence.current,
});
}}
>
编辑
</Button>
<Button
size="sm"
variant={entry.enabled ? 'danger' : 'secondary'}
disabled={writesDisabled}
aria-label={`${entry.enabled ? '下架' : '上架'} ${entry.title}`}
onClick={() =>
void writeTemplate(
entry,
{
expectedRevision: snapshot.revision,
title: entry.title,
summary: entry.summary,
tags: [...entry.tags],
enabled: !entry.enabled,
},
entry.enabled ? '下架模板' : '上架模板',
)
}
>
{entry.enabled ? '下架' : '上架'}
</Button>
</div>
</TableCell>
</TableRow>
))}
{!entries.length ? (
<TableRow>
<TableCell colSpan={6}>
{snapshot.templates.length
? '没有符合筛选条件的模板'
: '暂无模板'}
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
) : null}
<AdminListPanel
busy={loading}
busyText="正在加载模板"
busyVariant="line"
toolbar={
<div className="admin-agc-template-filters">
<TextField
label="搜索模板"
placeholder="名称、ID 或标签"
value={query}
onChange={(event) => setQuery(event.currentTarget.value)}
/>
<SelectField
label="运行时"
value={runtime}
onChange={(event) => setRuntime(event.currentTarget.value)}
>
<option value="">全部运行时</option>
{runtimes.map((value) => (
<option key={value} value={value}>
{runtimeLabels[value] ?? value}
</option>
))}
</SelectField>
<SelectField
label="上架状态"
value={visibility}
onChange={(event) => setVisibility(event.currentTarget.value)}
>
<option value="">全部状态</option>
<option value="enabled">已上架</option>
<option value="disabled">已下架</option>
</SelectField>
</div>
}
columns={[
{ key: 'cover', label: '封面' },
{
key: 'title',
label: '模板',
sortable: true,
sortValue: (entry) => entry.title,
},
{
key: 'engine',
label: '引擎 / 版本',
sortable: true,
sortValue: (entry) => entry.engine || entry.engineVersion,
},
{
key: 'zipSizeBytes',
label: '包大小',
sortable: true,
sortValue: (entry) => entry.zipSizeBytes,
},
{
key: 'enabled',
label: '状态',
sortable: true,
sortDescription: '上架状态',
sortValue: (entry) => (entry.enabled ? 1 : 0),
},
{ key: 'actions', label: '操作' },
]}
rows={snapshot ? entries : []}
showEmpty={Boolean(snapshot && entries.length === 0)}
emptyText={
snapshot && snapshot.templates.length
? '没有符合筛选条件的模板'
: '暂无模板'
}
emptyVariant="row"
renderRow={(entry) => {
if (!snapshot) {
return null;
}
return (
<tr key={entry.id}>
<td>
<img
className="admin-agc-template-cover"
src={entry.coverUrl}
alt={`${entry.title}封面`}
loading="lazy"
/>
</td>
<td>
<strong>{entry.title}</strong>
<small>{entry.id}</small>
<p className="admin-agc-template-summary">{entry.summary}</p>
<div className="admin-agc-template-tags">
{entry.tags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
</td>
<td>
{entry.engine || runtimeLabels[entry.runtime] || entry.runtime}{' '}
{entry.engineVersion}
<small>模板 {entry.templateVersion}</small>
</td>
<td>{formatTemplateSize(entry.zipSizeBytes)}</td>
<td>{entry.enabled ? '已上架' : '已下架'}</td>
<td>
<div className="admin-agc-template-actions">
<Button
size="sm"
variant="secondary"
disabled={busy || loading}
aria-label={`编辑 ${entry.title}`}
onClick={() => {
setError('');
setEditing({
entry,
revision: snapshot.revision,
key: ++editorSequence.current,
});
}}
>
编辑
</Button>
<Button
size="sm"
variant={entry.enabled ? 'danger' : 'secondary'}
disabled={writesDisabled}
aria-label={`${entry.enabled ? '下架' : '上架'} ${entry.title}`}
onClick={() =>
void writeTemplate(
entry,
{
expectedRevision: snapshot.revision,
title: entry.title,
summary: entry.summary,
tags: [...entry.tags],
enabled: !entry.enabled,
},
entry.enabled ? '下架模板' : '上架模板',
)
}
>
{entry.enabled ? '下架' : '上架'}
</Button>
</div>
</td>
</tr>
);
}}
tableClassName="admin-agc-template-table"
/>
{editing ? (
<Modal
open
@@ -534,7 +561,7 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
{confirmDialog}
</Modal>
) : null}
</section>
</AdminPage>
);
}
@@ -880,18 +907,18 @@ function TemplateUploadDialog({
) : (
<div className="admin-agc-template-upload-scroll">
<Table>
<TableHead>
<TableHeader>
<TableRow>
<TableHeader>模板包</TableHeader>
<TableHeader>ID</TableHeader>
<TableHeader>名称</TableHeader>
<TableHeader>版本</TableHeader>
<TableHeader>运行时</TableHeader>
<TableHeader>entry</TableHeader>
<TableHeader>封面</TableHeader>
<TableHeader>操作</TableHeader>
<TableHead>模板包</TableHead>
<TableHead>ID</TableHead>
<TableHead>名称</TableHead>
<TableHead>版本</TableHead>
<TableHead>运行时</TableHead>
<TableHead>entry</TableHead>
<TableHead>封面</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{state.rows.map((row) => {
const rowError = state.errors[row.key];
+150 -141
View File
@@ -1,4 +1,15 @@
import { Modal } from '@genarrative/shared/components';
import {
AdminActionRow,
AdminButton,
AdminField,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminPagination,
AdminPanel,
formatAdminDateTime,
Modal,
} from '@genarrative/shared/components';
import { FormEvent, useEffect, useRef, useState } from 'react';
import { listAdminAgcTrackingEvents } from '../api/adminApiClient';
@@ -45,11 +56,6 @@ const fieldLabels: Record<keyof AdminAgcTrackingEventEntry, string> = {
receivedAt: '入库时间',
};
function formatTime(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
}
export function AdminAgcTrackingPage({
token,
onUnauthorized,
@@ -86,8 +92,8 @@ export function AdminAgcTrackingPage({
useEffect(() => {
const saved = firstPage.current;
if (!cursor && saved?.token === token && saved.query === query) {
setEntries(saved.response.entries);
setNextCursor(saved.response.nextCursor);
setEntries(saved.response.entries ?? []);
setNextCursor(saved.response.nextCursor ?? null);
setError('');
setLoading(false);
return;
@@ -101,8 +107,9 @@ export function AdminAgcTrackingPage({
.then((response) => {
if (!active) return;
if (!cursor) firstPage.current = { token, query, response };
setEntries(response.entries);
setNextCursor(response.nextCursor);
// 接口形状异常时退回空列表,避免整页渲染因 undefined.length 崩掉。
setEntries(response.entries ?? []);
setNextCursor(response.nextCursor ?? null);
})
.catch((failure: unknown) => {
if (active) handlePageError(failure, onUnauthorized, setError);
@@ -162,43 +169,47 @@ export function AdminAgcTrackingPage({
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2>客户端埋点</h2>
<p>按发生时间倒序展示</p>
</div>
<button
type="button"
className="admin-secondary-button"
disabled={loading}
onClick={() => {
resetPages();
setRefresh((value) => value + 1);
}}
>
刷新
</button>
</div>
<form className="admin-panel admin-form" onSubmit={search}>
<AdminPage wide>
<AdminPageHeading
title="客户端埋点"
description="按发生时间倒序展示"
actions={
<AdminButton
variant="secondary"
type="button"
disabled={loading}
onClick={() => {
resetPages();
setRefresh((value) => value + 1);
}}
>
刷新
</AdminButton>
}
/>
<AdminPanel as="form" onSubmit={search}>
<div className="admin-filter-grid">
{(['userId', 'projectId'] as const).map((key) => (
<label key={key} className="admin-field">
<span>{fieldLabels[key]}</span>
<AdminField label={fieldLabels[key]} key={key}>
<input
value={filters[key]}
onChange={(event) =>
setFilters({ ...filters, [key]: event.target.value })
setFilters({
...filters,
[key]: event.target.value,
})
}
/>
</label>
</AdminField>
))}
<label className="admin-field">
<span>事件类型</span>
<AdminField label="事件类型">
<select
value={filters.eventName}
onChange={(event) =>
setFilters({ ...filters, eventName: event.target.value })
setFilters({
...filters,
eventName: event.target.value,
})
}
>
<option value="">全部</option>
@@ -208,33 +219,34 @@ export function AdminAgcTrackingPage({
</option>
))}
</select>
</label>
</AdminField>
{(['startTime', 'endTime'] as const).map((key) => (
<label key={key} className="admin-field">
<span>
{key === 'startTime'
<AdminField
label={
key === 'startTime'
? '发生时间起点(含)'
: '发生时间终点(不含)'}
</span>
: '发生时间终点(不含)'
}
key={key}
>
<input
type="datetime-local"
value={filters[key]}
onChange={(event) =>
setFilters({ ...filters, [key]: event.target.value })
setFilters({
...filters,
[key]: event.target.value,
})
}
/>
</label>
</AdminField>
))}
</div>
<div className="admin-action-row">
<button
type="submit"
className="admin-primary-button"
disabled={loading}
>
<AdminActionRow>
<AdminButton variant="primary" type="submit" disabled={loading}>
查询
</button>
</div>
</AdminButton>
</AdminActionRow>
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const)
.filter((key) => query[key])
.map((key) => (
@@ -242,93 +254,81 @@ export function AdminAgcTrackingPage({
{fieldLabels[key]}:{query[key]}(重新查询可清除)
</p>
))}
</form>
</AdminPanel>
{error ? (
<p role="alert" className="admin-error-message">
{error}
</p>
) : null}
<div className="admin-panel">
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
{[
'入库时间',
'发生时间',
'用户',
'事件名称',
'项目',
'来源',
'结果',
'客户端版本',
'详情',
].map((label) => (
<th key={label}>{label}</th>
))}
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.eventId}>
<td>{formatTime(entry.receivedAt)}</td>
<td>{formatTime(entry.eventTime)}</td>
<td>
{entry.userId}
<AdminUserReferenceButton
token={token}
userId={entry.userId}
onUnauthorized={onUnauthorized}
/>
</td>
<td>{eventLabels[entry.eventName] ?? entry.eventName}</td>
<td>{entry.projectId ?? '—'}</td>
<td>{entry.source}</td>
<td>{entry.status ?? '—'}</td>
<td>{entry.clientVersion}</td>
<td>
<button
type="button"
className="admin-ghost-button"
onClick={() => setDetail(entry)}
>
查看详情
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{loading ? (
<p role="status">加载中…</p>
) : !error && entries.length === 0 ? (
<p>暂无客户端埋点数据</p>
) : null}
<div className="admin-action-row">
<button
type="button"
className="admin-secondary-button"
disabled={loading || page === 0}
onClick={() => setPage((value) => value - 1)}
>
上一页
</button>
<span>第 {page + 1} 页</span>
<button
type="button"
className="admin-secondary-button"
disabled={loading || !nextCursor}
onClick={() => {
if (!nextCursor) return;
setCursors([...cursors.slice(0, page + 1), nextCursor]);
setPage(page + 1);
<AdminListPanel
busy={loading}
busyRole="status"
busyText="加载中…"
busyVariant="line"
emptyText="暂无客户端埋点数据"
emptyVariant="text"
rows={entries}
showEmpty={!loading && !error && entries.length === 0}
pagination={
<AdminPagination
ariaLabel="客户端埋点分页"
info={<>第 {page + 1} 页</>}
busy={loading}
previous={{
disabled: page === 0,
onClick: () => setPage((value) => value - 1),
}}
>
下一页
</button>
</div>
</div>
next={{
disabled: !nextCursor,
onClick: () => {
if (!nextCursor) return;
setCursors([...cursors.slice(0, page + 1), nextCursor]);
setPage(page + 1);
},
}}
/>
}
columns={[
{ key: 'receivedAt', label: '入库时间' },
{ key: 'eventTime', label: '发生时间' },
{ key: 'userId', label: '用户' },
{ key: 'eventName', label: '事件名称' },
{ key: 'projectId', label: '项目' },
{ key: 'source', label: '来源' },
{ key: 'status', label: '结果' },
{ key: 'clientVersion', label: '客户端版本' },
{ key: 'actions', label: '详情' },
]}
renderRow={(entry) => (
<tr key={entry.eventId}>
<td>{formatAdminDateTime(entry.receivedAt)}</td>
<td>{formatAdminDateTime(entry.eventTime)}</td>
<td>
{entry.userId}
<AdminUserReferenceButton
token={token}
userId={entry.userId}
onUnauthorized={onUnauthorized}
/>
</td>
<td>{eventLabels[entry.eventName] ?? entry.eventName}</td>
<td>{entry.projectId ?? '—'}</td>
<td>{entry.source}</td>
<td>{entry.status ?? '—'}</td>
<td>{entry.clientVersion}</td>
<td>
<AdminButton
variant="ghost"
type="button"
onClick={() => setDetail(entry)}
>
查看详情
</AdminButton>
</td>
</tr>
)}
size="wide"
/>
{detail ? (
<Modal
open
@@ -347,33 +347,42 @@ export function AdminAgcTrackingPage({
.map((key) => (
<div key={key}>
<dt>{fieldLabels[key]}</dt>
<dd style={{ overflowWrap: 'anywhere' }}>
<dd
style={{
overflowWrap: 'anywhere',
}}
>
{detail[key] == null ? '—' : String(detail[key])}
</dd>
</div>
))}
</dl>
<div className="admin-action-row">
<AdminActionRow>
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const).map(
(key) =>
detail[key] ? (
<button
<AdminButton
variant="secondary"
type="button"
key={key}
className="admin-secondary-button"
onClick={() => related(key, detail[key]!)}
>
查询同一{fieldLabels[key]}
</button>
</AdminButton>
) : null,
)}
</div>
</AdminActionRow>
<h3>事件属性</h3>
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
<pre
style={{
whiteSpace: 'pre-wrap',
overflowWrap: 'anywhere',
}}
>
{JSON.stringify(detail.properties, null, 2)}
</pre>
</Modal>
) : null}
</section>
</AdminPage>
);
}
+118 -115
View File
@@ -1,3 +1,12 @@
import {
AdminAlert,
AdminButton,
AdminEmptyState,
AdminField,
AdminPage,
AdminPageHeading,
AdminPanel,
} from '@genarrative/shared/components';
import { RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -157,64 +166,68 @@ export function AdminDashboardPage({
);
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading admin-dashboard-heading">
<div>
<h2>Dashboard</h2>
<p>{dashboard?.range.periodLabel ?? '运营指标'}</p>
</div>
<div className="admin-dashboard-actions">
<div className="admin-dashboard-period-fields">
<label className="admin-field admin-dashboard-date-field">
<span>起始日期</span>
<input
type="date"
max={today}
value={dateRange.startDate}
onChange={(event) =>
handleDateRangeChange('startDate', event.target.value)
}
/>
</label>
<label className="admin-field admin-dashboard-date-field">
<span>终止日期</span>
<input
type="date"
max={today}
value={dateRange.endDate}
onChange={(event) =>
handleDateRangeChange('endDate', event.target.value)
}
/>
</label>
</div>
<div
className="admin-segmented-control admin-dashboard-granularity"
role="tablist"
aria-label="统计周期"
>
{granularityOptions.map((option) => (
<button
key={option.id}
type="button"
data-active={option.id === granularity}
onClick={() => handleGranularityChange(option.id)}
<AdminPage wide>
<AdminPageHeading
title="Dashboard"
description={dashboard?.range.periodLabel ?? '运营指标'}
actions={
<div className="admin-dashboard-actions">
<div className="admin-dashboard-period-fields">
<AdminField
label="起始日期"
className="admin-dashboard-date-field"
>
{option.label}
</button>
))}
<input
type="date"
max={today}
value={dateRange.startDate}
onChange={(event) =>
handleDateRangeChange('startDate', event.target.value)
}
/>
</AdminField>
<AdminField
label="终止日期"
className="admin-dashboard-date-field"
>
<input
type="date"
max={today}
value={dateRange.endDate}
onChange={(event) =>
handleDateRangeChange('endDate', event.target.value)
}
/>
</AdminField>
</div>
<div
className="admin-segmented-control admin-dashboard-granularity"
role="tablist"
aria-label="统计周期"
>
{granularityOptions.map((option) => (
<button
key={option.id}
type="button"
data-active={option.id === granularity}
onClick={() => handleGranularityChange(option.id)}
>
{option.label}
</button>
))}
</div>
<AdminButton
variant="secondary"
disabled={isLoading}
type="button"
onClick={() => void loadDashboard()}
>
<RefreshCw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</AdminButton>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={() => void loadDashboard()}
>
<RefreshCw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
</div>
}
/>
<div
className="admin-dashboard-tabs"
@@ -237,33 +250,25 @@ export function AdminDashboardPage({
</button>
</div>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
{dashboard?.warnings.length ? (
<section className="admin-panel admin-panel-warning">
<div className="admin-panel-heading">
<h3>读取告警</h3>
<span>{dashboard.warnings.length}</span>
</div>
<AdminPanel
tone="warning"
title="读取告警"
count={dashboard.warnings.length}
>
<ul className="admin-error-list">
{dashboard.warnings.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
</section>
</AdminPanel>
) : null}
{activeTab === 'metrics' ? (
<>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>总计数据</h3>
<span>全站累计</span>
</div>
<AdminPanel title="总计数据" count="全站累计">
<div className="admin-dashboard-metric-grid">
{totalMetricCards.map((metric) => (
<MetricCard
@@ -275,13 +280,12 @@ export function AdminDashboardPage({
/>
))}
</div>
</section>
</AdminPanel>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>时段数据</h3>
<span>{dashboard?.range.periodLabel ?? '-'}</span>
</div>
<AdminPanel
title="时段数据"
count={dashboard?.range.periodLabel ?? '-'}
>
<div className="admin-dashboard-metric-grid">
{periodMetricCards.map((metric) => (
<MetricCard
@@ -293,13 +297,12 @@ export function AdminDashboardPage({
/>
))}
</div>
</section>
</AdminPanel>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>新增用户转化与留存</h3>
<span>{dashboard?.range.periodLabel ?? '-'}</span>
</div>
<AdminPanel
title="新增用户转化与留存"
count={dashboard?.range.periodLabel ?? '-'}
>
<div className="admin-dashboard-retention-grid">
<RateCard
label={`${rangePrefix(granularity)}新增用户付费率`}
@@ -330,7 +333,7 @@ export function AdminDashboardPage({
rateBasisPoints={metrics?.day7Retention.rateBasisPoints}
/>
</div>
</section>
</AdminPanel>
<div className="admin-dashboard-chart-grid">
{(dashboard?.charts ?? []).map((chart) => (
@@ -342,17 +345,16 @@ export function AdminDashboardPage({
/>
))}
{dashboard && dashboard.charts.length === 0 ? (
<div className="admin-empty-state">暂无图表</div>
<AdminEmptyState>暂无图表</AdminEmptyState>
) : null}
</div>
</>
) : (
<div className="admin-dashboard-operations">
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>运营指标</h3>
<span>{dashboard?.range.periodLabel ?? '-'}</span>
</div>
<AdminPanel
title="运营指标"
count={dashboard?.range.periodLabel ?? '-'}
>
<div className="admin-dashboard-operation-grid">
{(dashboard?.operations.cards ?? []).map((card) => (
<MetricCard
@@ -364,7 +366,7 @@ export function AdminDashboardPage({
/>
))}
</div>
</section>
</AdminPanel>
<BreakdownPanel
title="素材类型分布"
@@ -378,7 +380,7 @@ export function AdminDashboardPage({
/>
</div>
)}
</section>
</AdminPage>
);
function handleGranularityChange(
@@ -426,14 +428,11 @@ function MetricCard({
compact?: boolean;
}) {
return (
<section
className="admin-panel admin-dashboard-metric-card"
data-compact={compact}
>
<AdminPanel data-compact={compact} className="admin-dashboard-metric-card">
<span>{label}</span>
<strong>{formatNumber(value)}</strong>
<small>{unit}</small>
</section>
</AdminPanel>
);
}
@@ -498,14 +497,16 @@ function ChartPanel({
}, [chart.buckets.length, scrollLeft]);
return (
<section className="admin-panel admin-dashboard-chart-card">
<div className="admin-panel-heading">
<h3>{chart.title}</h3>
<span>
<AdminPanel
title={chart.title}
count={
<>
{chart.id === 'visit-users' ? '时段去重 ' : ''}
{formatNumber(chart.total)} {chart.unit}
</span>
</div>
</>
}
className="admin-dashboard-chart-card"
>
<div
ref={barsRef}
className="admin-dashboard-bars"
@@ -535,7 +536,7 @@ function ChartPanel({
</div>
))}
</div>
</section>
</AdminPanel>
);
}
@@ -550,11 +551,11 @@ function BreakdownPanel({
}) {
const maxValue = Math.max(1, ...rows.map((row) => row.value));
return (
<section className="admin-panel admin-dashboard-breakdown-panel">
<div className="admin-panel-heading">
<h3>{title}</h3>
<span>{rows.length}</span>
</div>
<AdminPanel
title={title}
count={rows.length}
className="admin-dashboard-breakdown-panel"
>
<div className="admin-dashboard-breakdown-list">
{rows.map((row) => (
<div className="admin-dashboard-breakdown-row" key={row.key}>
@@ -565,15 +566,17 @@ function BreakdownPanel({
</span>
</div>
<div className="admin-dashboard-breakdown-track">
<span style={{ width: `${(row.value / maxValue) * 100}%` }} />
<span
style={{
width: `${(row.value / maxValue) * 100}%`,
}}
/>
</div>
</div>
))}
{rows.length === 0 ? (
<div className="admin-empty-state">暂无数据</div>
) : null}
{rows.length === 0 ? <AdminEmptyState>暂无数据</AdminEmptyState> : null}
</div>
</section>
</AdminPanel>
);
}
File diff suppressed because it is too large Load Diff
+68 -57
View File
@@ -1,3 +1,14 @@
import {
AdminAlert,
AdminButton,
AdminEmptyState,
AdminField,
AdminFormRow,
AdminInfoList,
AdminPage,
AdminPageHeading,
AdminPanel,
} from '@genarrative/shared/components';
import { Plus, Send, Trash2 } from 'lucide-react';
import { FormEvent, useMemo, useState } from 'react';
@@ -75,19 +86,13 @@ export function AdminDebugHttpPage({
}
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2>API 调试</h2>
<p>受控同源请求</p>
</div>
</div>
<AdminPage>
<AdminPageHeading title="API 调试" description="受控同源请求" />
<div className="admin-two-column">
<form className="admin-panel admin-form" onSubmit={handleSubmit}>
<div className="admin-form-row">
<label className="admin-field admin-field-compact">
<span>Method</span>
<AdminPanel as="form" onSubmit={handleSubmit}>
<AdminFormRow>
<AdminField label="Method" compact>
<select
value={method}
onChange={(event) =>
@@ -100,28 +105,33 @@ export function AdminDebugHttpPage({
</option>
))}
</select>
</label>
<label className="admin-field admin-field-fill">
<span>Path</span>
</AdminField>
<AdminField label="Path" fill>
<input
value={path}
onChange={(event) => setPath(event.target.value)}
/>
</label>
</div>
</AdminField>
</AdminFormRow>
<section className="admin-subsection">
<div className="admin-subsection-heading">
<span>Headers</span>
<button
className="admin-ghost-button"
<AdminButton
variant="ghost"
type="button"
onClick={() =>
setHeaders((current) => [...current, { name: '', value: '' }])
setHeaders((current) => [
...current,
{
name: '',
value: '',
},
])
}
>
<Plus size={16} aria-hidden="true" />
</button>
</AdminButton>
</div>
<div className="admin-header-editor">
{headers.map((header, index) => (
@@ -132,7 +142,10 @@ export function AdminDebugHttpPage({
setHeaders((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? { ...item, name: event.target.value }
? {
...item,
name: event.target.value,
}
: item,
),
)
@@ -144,14 +157,17 @@ export function AdminDebugHttpPage({
setHeaders((current) =>
current.map((item, itemIndex) =>
itemIndex === index
? { ...item, value: event.target.value }
? {
...item,
value: event.target.value,
}
: item,
),
)
}
/>
<button
className="admin-ghost-button"
<AdminButton
variant="ghost"
title="移除"
type="button"
onClick={() =>
@@ -161,66 +177,61 @@ export function AdminDebugHttpPage({
}
>
<Trash2 size={16} aria-hidden="true" />
</button>
</AdminButton>
</div>
))}
</div>
</section>
<label className="admin-field">
<span>Body</span>
<AdminField label="Body">
<textarea
rows={9}
value={body}
onChange={(event) => setBody(event.target.value)}
/>
</label>
</AdminField>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
<button
className="admin-primary-button"
<AdminButton
variant="primary"
disabled={isSubmitting || !path.trim().startsWith('/')}
type="submit"
>
<Send size={17} aria-hidden="true" />
<span>{isSubmitting ? '发送中' : '发送'}</span>
</button>
</form>
</AdminButton>
</AdminPanel>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3>结果</h3>
<span>
{result ? `${result.status} ${result.statusText}` : '-'}
</span>
</div>
<AdminPanel
title="结果"
count={result ? `${result.status} ${result.statusText}` : '-'}
className="admin-result-panel"
>
{result ? (
<>
<dl className="admin-info-list">
<div>
<dt>Status</dt>
<dd>{result.status}</dd>
</div>
<div>
<dt>Headers</dt>
<dd>{result.headers.length}</dd>
</div>
</dl>
<AdminInfoList
items={[
{
label: <>Status</>,
value: <>{result.status}</>,
},
{
label: <>Headers</>,
value: <>{result.headers.length}</>,
},
]}
/>
<pre className="admin-code-block">
{jsonPreview || result.bodyText || '(empty)'}
</pre>
</>
) : (
<div className="admin-empty-state">暂无结果</div>
<AdminEmptyState>暂无结果</AdminEmptyState>
)}
</section>
</AdminPanel>
</div>
{confirmDialog}
</section>
</AdminPage>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,12 @@
import {
AdminAlert,
AdminButton,
AdminField,
AdminFormRow,
AdminPage,
AdminPageHeading,
AdminPanel,
} from '@genarrative/shared/components';
import { RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
@@ -121,28 +130,24 @@ export function AdminEditorGenerationPricingPage({
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2>模型定价</h2>
<p>生成泥点消耗</p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshPricing}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
<AdminPage wide>
<AdminPageHeading
title="模型定价"
description="生成泥点消耗"
actions={
<AdminButton
variant="secondary"
disabled={isLoading}
type="button"
onClick={refreshPricing}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</AdminButton>
}
/>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
<form className="admin-stack" onSubmit={handleSave}>
<div className="admin-pricing-model-list admin-pricing-model-list--single">
@@ -156,18 +161,14 @@ export function AdminEditorGenerationPricingPage({
)}
</div>
<button
className="admin-primary-button"
disabled={isSaving}
type="submit"
>
<AdminButton variant="primary" disabled={isSaving} type="submit">
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存定价'}</span>
</button>
</AdminButton>
</form>
{confirmDialog}
</section>
</AdminPage>
);
}
@@ -184,16 +185,15 @@ function renderModelPricingCard({
}) {
const unitLabel = unitLabels[modelPricing.unit] ?? modelPricing.unit;
return (
<section className="admin-panel admin-pricing-model-card" key={model}>
<AdminPanel key={model} className="admin-pricing-model-card">
<div className="admin-pricing-model-heading">
<strong>{model}</strong>
<span className="admin-pricing-unit">{unitLabel}</span>
</div>
{modelPricing.prices ? (
<div className="admin-form-row">
<AdminFormRow>
{Object.entries(modelPricing.prices).map(([tier, price]) => (
<label className="admin-field" key={`${model}-${tier}`}>
<span>{tier}</span>
<AdminField label={tier} key={`${model}-${tier}`}>
<input
aria-label={`${model} ${tier}`}
min={1}
@@ -204,12 +204,11 @@ function renderModelPricingCard({
onTierChange(model, tier, event.target.value)
}
/>
</label>
</AdminField>
))}
</div>
</AdminFormRow>
) : (
<label className="admin-field">
<span>泥点</span>
<AdminField label="泥点">
<input
aria-label={`${model} 泥点`}
min={1}
@@ -218,9 +217,9 @@ function renderModelPricingCard({
value={modelPricing.price ?? 0}
onChange={(event) => onFlatChange(model, event.target.value)}
/>
</label>
</AdminField>
)}
</section>
</AdminPanel>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,14 @@
import { Modal, TextField } from '@genarrative/shared/components';
import {
AdminActionRow,
AdminAlert,
AdminButton,
AdminListPanel,
AdminPage,
AdminPageHeading,
formatAdminDateTime,
Modal,
TextField,
} from '@genarrative/shared/components';
import { RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@@ -31,12 +41,6 @@ function formatBytes(value: number) {
return `${value} B`;
}
function formatTime(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString('zh-CN', { hour12: false });
}
function createSuspendIdempotencyKey(gameId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
@@ -201,119 +205,101 @@ export function AdminGameDistributionReviewPage({
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h1>游戏审核</h1>
<button
type="button"
className="admin-secondary-button"
onClick={() => void loadReviews()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" />
刷新
</button>
</div>
<AdminPage wide>
<AdminPageHeading
title="游戏审核"
actions={
<AdminButton
variant="secondary"
type="button"
onClick={() => void loadReviews()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" />
刷新
</AdminButton>
}
/>
{errorMessage ? (
<div className="admin-alert admin-alert-warning" role="alert">
<AdminAlert tone="warning" role="alert">
{errorMessage}
</div>
</AdminAlert>
) : null}
{statusMessage ? (
<div className="admin-alert admin-alert-success" role="status">
{statusMessage}
</div>
<AdminAlert tone="success">{statusMessage}</AdminAlert>
) : null}
<div className="admin-panel">
<div className="admin-panel-heading">
<h2>待审版本</h2>
<span className="admin-muted-text">共 {entries.length} 条</span>
</div>
{isLoading ? (
<p className="admin-muted-text">正在加载待审版本…</p>
) : null}
{!isLoading && entries.length === 0 ? (
<p className="admin-muted-text">当前没有待审核的游戏版本。</p>
) : null}
{!isLoading && entries.length > 0 ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th>游戏</th>
<th>版本</th>
<th>发行包</th>
<th>提交时间</th>
<th>审核</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const busy = busyVersionId === entry.versionId;
return (
<tr key={entry.versionId}>
<td>
<code>{entry.gameId}</code>
</td>
<td>
v{entry.versionNumber}
<div className="admin-muted-text">{entry.status}</div>
{entry.reviewReason ? (
<div className="admin-muted-text">
{entry.reviewReason}
</div>
) : null}
</td>
<td>
{formatBytes(entry.packageBytes)}
<div className="admin-muted-text">
<code>{entry.packageSha256.slice(0, 12)}</code>
</div>
</td>
<td>{formatTime(entry.createdAt)}</td>
<td>
<div className="admin-action-row">
<button
type="button"
className="admin-primary-button"
disabled={busy}
onClick={() => void submitReview(entry, 'approve')}
>
通过
</button>
<button
type="button"
className="admin-ghost-button"
disabled={busy}
onClick={() => openReasonPrompt(entry, 'reject')}
>
拒绝
</button>
<button
type="button"
className="admin-danger-button"
disabled={busy || busyGameId === entry.gameId}
onClick={() => openReasonPrompt(entry, 'suspend')}
>
{busyGameId === entry.gameId
? '正在下架…'
: '安全下架'}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</div>
<AdminListPanel
busy={isLoading}
busyText="正在加载待审版本…"
busyVariant="line"
emptyText="当前没有待审核的游戏版本。"
emptyVariant="text"
title="待审版本"
extra={<span className="admin-muted-text">共 {entries.length} 条</span>}
rows={entries}
size="wide"
columns={[
{ key: 'gameId', label: '游戏' },
{ key: 'versionNumber', label: '版本' },
{ key: 'packageBytes', label: '发行包' },
{ key: 'createdAt', label: '提交时间' },
{ key: 'actions', label: '审核' },
]}
renderRow={(entry) => {
const busy = busyVersionId === entry.versionId;
return (
<tr key={entry.versionId}>
<td>
<code>{entry.gameId}</code>
</td>
<td>
v{entry.versionNumber}
<div className="admin-muted-text">{entry.status}</div>
{entry.reviewReason ? (
<div className="admin-muted-text">{entry.reviewReason}</div>
) : null}
</td>
<td>
{formatBytes(entry.packageBytes)}
<div className="admin-muted-text">
<code>{entry.packageSha256.slice(0, 12)}</code>
</div>
</td>
<td>{formatAdminDateTime(entry.createdAt)}</td>
<td>
<AdminActionRow>
<AdminButton
variant="primary"
type="button"
disabled={busy}
onClick={() => void submitReview(entry, 'approve')}
>
通过
</AdminButton>
<AdminButton
variant="ghost"
type="button"
disabled={busy}
onClick={() => openReasonPrompt(entry, 'reject')}
>
拒绝
</AdminButton>
<AdminButton
variant="danger"
type="button"
disabled={busy || busyGameId === entry.gameId}
onClick={() => openReasonPrompt(entry, 'suspend')}
>
{busyGameId === entry.gameId ? '正在下架…' : '安全下架'}
</AdminButton>
</AdminActionRow>
</td>
</tr>
);
}}
/>
{reasonPrompt ? (
<Modal
open
@@ -324,25 +310,28 @@ export function AdminGameDistributionReviewPage({
size="sm"
className="genarrative-ui"
footer={
<div className="admin-confirm-actions" style={{ width: '100%' }}>
<button
<div
className="admin-confirm-actions"
style={{
width: '100%',
}}
>
<AdminButton
variant="secondary"
type="button"
className="admin-secondary-button"
onClick={closeReasonPrompt}
>
取消
</button>
<button
</AdminButton>
<AdminButton
type="button"
className={
reasonPrompt.decision === 'reject'
? 'admin-ghost-button'
: 'admin-danger-button'
variant={
reasonPrompt.decision === 'reject' ? 'ghost' : 'danger'
}
onClick={confirmReasonPrompt}
>
{reasonPrompt.decision === 'reject' ? '确认拒绝' : '继续下架'}
</button>
</AdminButton>
</div>
}
>
@@ -361,6 +350,6 @@ export function AdminGameDistributionReviewPage({
</Modal>
) : null}
{writeConfirm.confirmDialog}
</section>
</AdminPage>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,15 @@
import {
AdminActionRow,
AdminAlert,
AdminButton,
AdminField,
AdminFormRow,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminPanel,
formatAdminDateTime,
} from '@genarrative/shared/components';
import { Plus, RefreshCcw, Save } from 'lucide-react';
import { type FormEvent, useEffect, useState } from 'react';
@@ -260,43 +272,34 @@ export function AdminGrayReleaseConfigPage({
gateTargetsForPrefix.find((option) => option.key === gateKey)?.suffix ?? '';
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={resetForm}
>
<Plus size={17} aria-hidden="true" />
<span>新增 Gate</span>
</button>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshFeatureGates}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
</div>
<AdminPage wide>
<AdminPageHeading
title="灰度发布"
description="开关配置"
actions={
<AdminActionRow>
<AdminButton variant="secondary" type="button" onClick={resetForm}>
<Plus size={17} aria-hidden="true" />
<span>新增 Gate</span>
</AdminButton>
<AdminButton
variant="secondary"
disabled={isLoading}
type="button"
onClick={refreshFeatureGates}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</AdminButton>
</AdminActionRow>
}
/>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
{listErrorMessage ? <AdminAlert>{listErrorMessage}</AdminAlert> : null}
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<div className="admin-field">
<span>Gate Key 选择</span>
<AdminPanel as="form" onSubmit={handleSave}>
<AdminField as="div" label="Gate Key 选择">
<div className="admin-gate-key-selectors">
<select
aria-label="Gate Key 前缀"
@@ -328,16 +331,15 @@ export function AdminGrayReleaseConfigPage({
))}
</select>
</div>
</div>
</AdminField>
<div className="admin-form-row">
<label className="admin-field admin-field-fill">
<span>Gate Key</span>
<AdminFormRow>
<AdminField label="Gate Key" fill>
<input
value={gateKey}
onChange={(event) => setGateKey(event.target.value)}
/>
</label>
</AdminField>
<label className="admin-switch-field">
<input
checked={enabled}
@@ -346,10 +348,9 @@ export function AdminGrayReleaseConfigPage({
/>
<span>启用</span>
</label>
</div>
</AdminFormRow>
<label className="admin-field admin-field-compact">
<span>灰度比例</span>
<AdminField label="灰度比例" compact>
<input
max={100}
min={0}
@@ -358,156 +359,147 @@ export function AdminGrayReleaseConfigPage({
value={rolloutPercent}
onChange={(event) => setRolloutPercent(event.target.value)}
/>
</label>
</AdminField>
<div className="admin-form-row">
<label className="admin-field">
<span>允许用户 ID</span>
<AdminFormRow>
<AdminField label="允许用户 ID">
<textarea
rows={6}
value={allowUserIds}
onChange={(event) => setAllowUserIds(event.target.value)}
/>
</label>
<label className="admin-field">
<span>允许用户标签</span>
</AdminField>
<AdminField label="允许用户标签">
<textarea
rows={6}
value={allowUserTags}
onChange={(event) => setAllowUserTags(event.target.value)}
/>
</label>
</div>
</AdminField>
</AdminFormRow>
<label className="admin-field">
<span>拒绝用户 ID</span>
<AdminField label="拒绝用户 ID">
<textarea
rows={5}
value={denyUserIds}
onChange={(event) => setDenyUserIds(event.target.value)}
/>
</label>
</AdminField>
<label className="admin-field">
<span>描述</span>
<AdminField label="描述">
<textarea
rows={3}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
</AdminField>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
<button
className="admin-primary-button"
<AdminButton
variant="primary"
disabled={isSaving || !canSave}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存配置'}</span>
</button>
</form>
</AdminButton>
</AdminPanel>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>Gate 列表</h3>
<span>{gates.length}</span>
</div>
{gates.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th>Gate</th>
<th>比例</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{gates.map((gate) => (
<tr
data-active={gate.gateKey === selectedGateKey}
key={gate.gateKey}
>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(gate)}
>
{gate.gateKey}
</button>
<small>{gate.updatedAt}</small>
</td>
<td>{gate.rolloutPercent}%</td>
<td>{gate.enabled ? '启用' : '停用'}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无 Gate'}
</div>
<AdminListPanel
columns={[
{
key: 'gateKey',
label: 'Gate',
sortable: true,
sortDescription: 'Gate Key',
},
{
key: 'rolloutPercent',
label: '比例',
sortable: true,
sortValue: (gate) => gate.rolloutPercent,
},
{
key: 'enabled',
label: '状态',
sortable: true,
sortDescription: '启用状态',
sortValue: (gate) => (gate.enabled ? 1 : 0),
},
]}
renderRow={(gate) => (
<tr
data-active={gate.gateKey === selectedGateKey}
key={gate.gateKey}
>
<td>
<AdminButton
variant="text"
type="button"
onClick={() => fillForm(gate)}
>
{gate.gateKey}
</AdminButton>
<small>{formatAdminDateTime(gate.updatedAt)}</small>
</td>
<td>{gate.rolloutPercent}%</td>
<td>{gate.enabled ? '启用' : '停用'}</td>
</tr>
)}
</section>
title="Gate 列表"
count={gates.length}
rows={gates}
busy={isLoading}
busyText="加载中"
emptyText="暂无 Gate"
size="compact"
/>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>可配置开关</h3>
<span>{unconfiguredGateTargets.length}</span>
</div>
{unconfiguredGateTargets.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th>Gate</th>
<th>说明</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{unconfiguredGateTargets.map((option) => (
<tr key={option.key}>
<td>
{option.key}
<small>
{GATE_PREFIX_LABELS[option.prefix] ?? option.prefix} ·{' '}
{option.label}
</small>
</td>
<td>{option.description}</td>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => applyGateTarget(option)}
>
配置
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '预设开关都已创建'}
</div>
<AdminListPanel
columns={[
{
key: 'key',
label: 'Gate',
sortable: true,
sortDescription: 'Gate Key',
},
{ key: 'description', label: '说明', sortable: true },
{ key: 'action', label: '操作' },
]}
renderRow={(option) => (
<tr key={option.key}>
<td>
{option.key}
<small>
{GATE_PREFIX_LABELS[option.prefix] ?? option.prefix} ·{' '}
{option.label}
</small>
</td>
<td>{option.description}</td>
<td>
<AdminButton
variant="text"
type="button"
onClick={() => applyGateTarget(option)}
>
配置
</AdminButton>
</td>
</tr>
)}
</section>
title="可配置开关"
count={unconfiguredGateTargets.length}
rows={unconfiguredGateTargets}
busy={isLoading}
busyText="加载中"
emptyText="预设开关都已创建"
size="compact"
/>
</div>
{confirmDialog}
</section>
</AdminPage>
);
}
+144 -165
View File
@@ -1,3 +1,17 @@
import type { AdminStatusTone } from '@genarrative/shared/components';
import {
AdminAlert,
AdminButton,
AdminField,
AdminFormRow,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminPanel,
AdminStatusPill,
AdminTagList,
formatAdminDateTime,
} from '@genarrative/shared/components';
import { RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
@@ -110,91 +124,74 @@ export function AdminInviteCodePage({
const validityError = validateValidityWindow(startsAt, expiresAt);
return (
<section className="admin-page">
<div className="admin-page-heading">
<div>
<h2>邀请码</h2>
<p>注册链路预置码</p>
</div>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshInviteCodes}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
<AdminPage>
<AdminPageHeading
title="邀请码"
description="注册链路预置码"
actions={
<AdminButton
variant="secondary"
disabled={isLoading}
type="button"
onClick={refreshInviteCodes}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</AdminButton>
}
/>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : null}
{listErrorMessage ? <AdminAlert>{listErrorMessage}</AdminAlert> : null}
<div className="admin-two-column admin-two-column-wide">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<label className="admin-field">
<span>Invite Code</span>
<AdminPanel as="form" onSubmit={handleSave}>
<AdminField label="Invite Code">
<input
autoComplete="off"
value={inviteCode}
onChange={(event) => setInviteCode(event.target.value)}
/>
</label>
</AdminField>
<div className="admin-form-row">
<label className="admin-field">
<span>开始时间</span>
<AdminFormRow>
<AdminField label="开始时间">
<input
type="datetime-local"
value={startsAt}
onChange={(event) => setStartsAt(event.target.value)}
/>
</label>
<label className="admin-field">
<span>截止时间</span>
</AdminField>
<AdminField label="截止时间">
<input
type="datetime-local"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
/>
</label>
</div>
</AdminField>
</AdminFormRow>
<label className="admin-field">
<span>用户标签</span>
<AdminField label="用户标签">
<input
autoComplete="off"
value={grantedTagsText}
onChange={(event) => setGrantedTagsText(event.target.value)}
/>
</label>
</AdminField>
<label className="admin-field">
<span>Metadata JSON</span>
<AdminField label="Metadata JSON">
<textarea
rows={10}
spellCheck={false}
value={metadataText}
onChange={(event) => setMetadataText(event.target.value)}
/>
</label>
</AdminField>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
{validityError ? (
<div className="admin-alert" role="status">
{validityError}
</div>
) : null}
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
{validityError ? <AdminAlert>{validityError}</AdminAlert> : null}
<button
className="admin-primary-button"
<AdminButton
variant="primary"
disabled={
isSaving ||
!inviteCode.trim() ||
@@ -205,114 +202,98 @@ export function AdminInviteCodePage({
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
</AdminButton>
</AdminPanel>
<div className="admin-stack">
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>邀请码列表</h3>
<span>{entries.length}</span>
</div>
{entries.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th>邀请码</th>
<th>标签</th>
<th>有效期</th>
<th>创建</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.inviteCode}>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => fillForm(entry)}
>
{entry.inviteCode}
</button>
</td>
<td>
<TagList tags={metadataUserTags(entry.metadata)} />
</td>
<td>
<span
className={`admin-status ${inviteValidityClass(entry)}`}
>
{inviteValidityLabel(entry)}
</span>
<small>{formatValidityWindow(entry)}</small>
</td>
<td>{entry.createdAt}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无邀请码'}
</div>
<AdminListPanel
busy={isLoading}
busyText="加载中"
columns={[
{ key: 'inviteCode', label: '邀请码', sortable: true },
{
key: 'tags',
label: '标签',
sortable: true,
sortValue: (entry) =>
metadataUserTags(entry.metadata).join(' '),
},
{
key: 'validity',
label: '有效期',
sortable: true,
sortDescription: '有效期(按截止时间)',
sortValue: (entry) =>
entry.expiresAt ?? entry.startsAt ?? '9999',
},
{
key: 'createdAt',
label: '创建',
sortable: true,
sortDescription: '创建时间',
},
]}
count={entries.length}
emptyText="暂无邀请码"
renderRow={(entry) => (
<tr key={entry.inviteCode}>
<td>
<AdminButton
variant="text"
type="button"
onClick={() => fillForm(entry)}
>
{entry.inviteCode}
</AdminButton>
</td>
<td>
<AdminTagList tags={metadataUserTags(entry.metadata)} />
</td>
<td>
<AdminStatusPill tone={inviteValidityTone(entry)}>
{inviteValidityLabel(entry)}
</AdminStatusPill>
<small>{formatValidityWindow(entry)}</small>
</td>
<td>{formatAdminDateTime(entry.createdAt)}</td>
</tr>
)}
</section>
rows={entries}
size="compact"
title="邀请码列表"
/>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3>操作记录</h3>
<span>{operations.length}</span>
</div>
{operations.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th>操作</th>
<th>邀请码</th>
<th>操作人</th>
<th>时间</th>
</tr>
</thead>
<tbody>
{operations.map((operation) => (
<tr key={operation.operationId}>
<td>{operationActionLabel(operation.action)}</td>
<td>{operation.code}</td>
<td>{operation.operatorDisplayName}</td>
<td>{formatDateTime(operation.createdAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">暂无记录</div>
<AdminListPanel
title="操作记录"
count={operations.length}
className="admin-result-panel"
columns={[
{ key: 'action', label: '操作', sortable: true },
{ key: 'code', label: '邀请码', sortable: true },
{
key: 'operator',
label: '操作人',
sortable: true,
sortValue: (operation) => operation.operatorDisplayName,
},
{ key: 'createdAt', label: '时间', sortable: true },
]}
emptyText="暂无记录"
renderRow={(operation) => (
<tr key={operation.operationId}>
<td>{operationActionLabel(operation.action)}</td>
<td>{operation.code}</td>
<td>{operation.operatorDisplayName}</td>
<td>{formatAdminDateTime(operation.createdAt)}</td>
</tr>
)}
</section>
rows={operations}
size="compact"
/>
</div>
</div>
{confirmDialog}
</section>
);
}
function TagList({ tags }: { tags: string[] }) {
if (!tags.length) {
return <span className="admin-muted-text">-</span>;
}
return (
<span className="admin-tag-list">
{tags.map((tag) => (
<span className="admin-tag" key={tag}>
{tag}
</span>
))}
</span>
</AdminPage>
);
}
@@ -429,31 +410,29 @@ function inviteValidityLabel(entry: ProfileInviteCodeAdminResponse) {
return '长期有效';
}
function inviteValidityClass(entry: ProfileInviteCodeAdminResponse) {
function inviteValidityTone(
entry: ProfileInviteCodeAdminResponse,
): AdminStatusTone {
const label = inviteValidityLabel(entry);
if (label === '已过期') {
return 'admin-status-error';
return 'error';
}
if (label === '未生效') {
return 'admin-status-pending';
return 'pending';
}
return 'admin-status-ok';
return 'ok';
}
function formatValidityWindow(entry: ProfileInviteCodeAdminResponse) {
const startsAt = entry.startsAt ? formatDateTime(entry.startsAt) : '立即';
const expiresAt = entry.expiresAt ? formatDateTime(entry.expiresAt) : '长期';
const startsAt = entry.startsAt
? formatAdminDateTime(entry.startsAt)
: '立即';
const expiresAt = entry.expiresAt
? formatAdminDateTime(entry.expiresAt)
: '长期';
return `${startsAt} / ${expiresAt}`;
}
function formatDateTime(value: string) {
const date = new Date(value);
if (!Number.isFinite(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', { hour12: false });
}
function operationActionLabel(action: string) {
if (action === 'create') {
return '新增';

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