Merge branch 'master' into opt/hide-docs-name
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m34s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m55s
Project CI / Backend tests (pull_request) Successful in 3m59s
Project CI / Frontend tests (pull_request) Successful in 2m15s
Project CI / Native shell tests (pull_request) Successful in 6m8s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m45s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m34s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m5s
Project CI / Repository checks (pull_request) Successful in 2m48s

This commit is contained in:
2026-09-29 22:46:45 +08:00
54 changed files with 8262 additions and 5400 deletions
+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>
);
}
+169 -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,161 @@ 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: '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,
},
],
},
],
},
);
}}
>
<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>
<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>
);
}
+177 -150
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>
);
}
+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 '新增';
+13 -12
View File
@@ -1,3 +1,8 @@
import {
AdminAlert,
AdminButton,
AdminField,
} from '@genarrative/shared/components';
import { LockKeyhole, ShieldCheck } from 'lucide-react';
import { FormEvent, useState } from 'react';
@@ -44,39 +49,35 @@ export function AdminLoginPage({ notice, onLogin }: AdminLoginPageProps) {
</div>
</div>
<label className="admin-field">
<span>用户名</span>
<AdminField label="用户名">
<input
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
/>
</label>
</AdminField>
<label className="admin-field">
<span>密码</span>
<AdminField label="密码">
<input
autoComplete="current-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
</AdminField>
{notice || errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage || notice}
</div>
<AdminAlert>{errorMessage || notice}</AdminAlert>
) : null}
<button
className="admin-primary-button"
<AdminButton
variant="primary"
disabled={isSubmitting || !username.trim() || !password}
type="submit"
>
<LockKeyhole size={18} aria-hidden="true" />
<span>{isSubmitting ? '登录中' : '登录'}</span>
</button>
</AdminButton>
</form>
</main>
);
+59 -69
View File
@@ -1,3 +1,13 @@
import {
AdminAlert,
AdminButton,
AdminInfoList,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminPanel,
AdminStatusPill,
} from '@genarrative/shared/components';
import { RefreshCw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
@@ -39,28 +49,24 @@ export function AdminOverviewPage({
}, [loadOverview]);
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={() => void loadOverview()}
>
<RefreshCw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
<AdminPage>
<AdminPageHeading
title="总览"
description="服务与数据库状态"
actions={
<AdminButton
variant="secondary"
disabled={isLoading}
type="button"
onClick={() => void loadOverview()}
>
<RefreshCw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</AdminButton>
}
/>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
<div className="admin-overview-grid">
<InfoPanel
@@ -88,48 +94,37 @@ export function AdminOverviewPage({
/>
</div>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>表统计</h3>
<span>{overview?.database.schemaTableNames.length ?? 0} tables</span>
</div>
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th>表</th>
<th>行数</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{(overview?.database.tableStats ?? []).map((stat) => (
<TableStatRow key={stat.tableName} stat={stat} />
))}
{overview && overview.database.tableStats.length === 0 ? (
<tr>
<td colSpan={3}>暂无统计</td>
</tr>
) : null}
</tbody>
</table>
</div>
</section>
<AdminListPanel
columns={[
{ key: 'table', label: '表' },
{ key: 'rowCount', label: '行数' },
{ key: 'status', label: '状态' },
]}
count={<>{overview?.database.schemaTableNames.length ?? 0} tables</>}
emptyText="暂无统计"
emptyVariant="row"
renderRow={(stat) => <TableStatRow key={stat.tableName} stat={stat} />}
rows={overview?.database.tableStats ?? []}
showEmpty={Boolean(
overview && overview.database.tableStats.length === 0,
)}
title="表统计"
/>
{overview?.database.fetchErrors.length ? (
<section className="admin-panel admin-panel-warning">
<div className="admin-panel-heading">
<h3>读取异常</h3>
<span>{overview.database.fetchErrors.length}</span>
</div>
<AdminPanel
tone="warning"
title="读取异常"
count={overview.database.fetchErrors.length}
>
<ul className="admin-error-list">
{overview.database.fetchErrors.map((message) => (
<li key={message}>{message}</li>
))}
</ul>
</section>
</AdminPanel>
) : null}
</section>
</AdminPage>
);
}
@@ -141,19 +136,16 @@ function InfoPanel({
rows: Array<[string, string]>;
}) {
return (
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>{title}</h3>
</div>
<dl className="admin-info-list">
<AdminPanel title={title}>
<AdminInfoList>
{rows.map(([label, value]) => (
<div key={label}>
<dt>{label}</dt>
<dd>{value}</dd>
</div>
))}
</dl>
</section>
</AdminInfoList>
</AdminPanel>
);
}
@@ -161,24 +153,22 @@ function TableStatRow({ stat }: { stat: AdminDatabaseTableStatPayload }) {
return (
<tr>
<td>
<button
className="admin-text-button"
<AdminButton
variant="text"
type="button"
onClick={() => {
window.location.hash = `#tables?table=${encodeURIComponent(stat.tableName)}`;
}}
>
{stat.tableName}
</button>
</AdminButton>
</td>
<td>{typeof stat.rowCount === 'number' ? stat.rowCount : '-'}</td>
<td>
{stat.errorMessage ? (
<span className="admin-status admin-status-error">
{stat.errorMessage}
</span>
<AdminStatusPill tone="error">{stat.errorMessage}</AdminStatusPill>
) : (
<span className="admin-status admin-status-ok">OK</span>
<AdminStatusPill tone="ok">OK</AdminStatusPill>
)}
</td>
</tr>
@@ -1,3 +1,14 @@
import {
AdminAlert,
AdminButton,
AdminField,
AdminInfoList,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminPanel,
formatAdminDateTime,
} from '@genarrative/shared/components';
import { RefreshCcw, Save } from 'lucide-react';
import { FormEvent, useEffect, useState } from 'react';
@@ -95,33 +106,28 @@ export function AdminProfileWalletConfigPage({
}
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={refreshConfig}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</button>
</div>
<AdminPage>
<AdminPageHeading
title="账号配置"
description="泥点钱包"
actions={
<AdminButton
variant="secondary"
disabled={isLoading}
type="button"
onClick={refreshConfig}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '刷新中' : '刷新'}</span>
</AdminButton>
}
/>
{loadErrorMessage ? (
<div className="admin-alert" role="status">
{loadErrorMessage}
</div>
) : null}
{loadErrorMessage ? <AdminAlert>{loadErrorMessage}</AdminAlert> : null}
<div className="admin-two-column">
<form className="admin-panel admin-form" onSubmit={handleSave}>
<label className="admin-field">
<span>账号初始泥点数</span>
<AdminPanel as="form" onSubmit={handleSave}>
<AdminField label="账号初始泥点数">
<input
min={1}
step={1}
@@ -129,10 +135,9 @@ export function AdminProfileWalletConfigPage({
value={initialMudPoints}
onChange={(event) => setInitialMudPoints(event.target.value)}
/>
</label>
</AdminField>
<label className="admin-field">
<span>每日免费泥点数</span>
<AdminField label="每日免费泥点数">
<input
min={1}
step={1}
@@ -140,16 +145,12 @@ export function AdminProfileWalletConfigPage({
value={dailyFreePointsPerDay}
onChange={(event) => setDailyFreePointsPerDay(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 ||
!parsePositiveInteger(initialMudPoints) ||
@@ -159,42 +160,44 @@ export function AdminProfileWalletConfigPage({
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存'}</span>
</button>
</form>
</AdminButton>
</AdminPanel>
<section className="admin-panel admin-result-panel">
<div className="admin-panel-heading">
<h3>当前配置</h3>
<span>{result?.configId ?? '-'}</span>
</div>
<AdminListPanel
busy={isLoading}
busyText="加载中"
count={result?.configId ?? '-'}
className="admin-result-panel"
emptyText="暂无记录"
rows={result ? [result] : []}
title="当前配置"
>
{result ? (
<dl className="admin-info-list">
<div>
<dt>初始泥点</dt>
<dd>{result.initialMudPoints}</dd>
</div>
<div>
<dt>每日免费泥点</dt>
<dd>{result.dailyFreePointsPerDay}</dd>
</div>
<div>
<dt>更新人</dt>
<dd>{result.updatedByDisplayName || '-'}</dd>
</div>
<div>
<dt>更新</dt>
<dd>{result.updatedAt}</dd>
</div>
</dl>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '暂无记录'}
</div>
)}
</section>
<AdminInfoList
items={[
{
label: <>初始泥点</>,
value: <>{result.initialMudPoints}</>,
},
{
label: <>每日免费泥点</>,
value: <>{result.dailyFreePointsPerDay}</>,
},
{
label: <>更新人</>,
value: <>{result.updatedByDisplayName || '-'}</>,
},
{
label: <>更新</>,
value: <>{formatAdminDateTime(result.updatedAt)}</>,
},
]}
/>
) : null}
</AdminListPanel>
</div>
{confirmDialog}
</section>
</AdminPage>
);
}
@@ -1,10 +1,16 @@
import type { AdminStatusTone } from '@genarrative/shared/components';
import {
ChevronLeft,
ChevronRight,
Download,
RefreshCcw,
X,
} from 'lucide-react';
AdminActionRow,
AdminAlert,
AdminButton,
AdminField,
AdminListPanel,
AdminPage,
AdminPageHeading,
AdminPagination,
AdminStatusPill,
} from '@genarrative/shared/components';
import { Download, RefreshCcw, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
@@ -21,20 +27,23 @@ interface AdminProjectSnapshotsPageProps {
onUnauthorized: (message?: string) => void;
}
const snapshotStatuses = {
const snapshotStatuses: Record<
AdminProjectSnapshotEntry['status'],
{ label: string; tone: AdminStatusTone; action: string }
> = {
ready: {
label: '已同步',
className: 'admin-status-ok',
tone: 'ok',
action: '下载完整工程',
},
partial: {
label: '同步未完成',
className: 'admin-status-pending',
tone: 'pending',
action: '同步未完成',
},
unverified: {
label: '完整性未知',
className: 'admin-status-pending',
tone: 'pending',
action: '下载已存文件',
},
};
@@ -209,186 +218,150 @@ export function AdminProjectSnapshotsPage({
: [];
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h2>项目工程</h2>
<div className="admin-action-row">
<label className="admin-field admin-field-compact">
<span>渠道</span>
<select
aria-label="项目工程渠道"
value={channel ?? ''}
onChange={(event) => setChannel(event.target.value)}
<AdminPage wide>
<AdminPageHeading
title="项目工程"
actions={
<AdminActionRow>
<AdminField label="渠道" compact>
<select
aria-label="项目工程渠道"
value={channel ?? ''}
onChange={(event) => setChannel(event.target.value)}
>
{visibleChannelOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</AdminField>
<AdminButton
variant="secondary"
disabled={isLoading}
type="button"
onClick={refreshCurrentPage}
>
{visibleChannelOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<button
className="admin-secondary-button"
disabled={isLoading}
type="button"
onClick={refreshCurrentPage}
>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '加载中' : '刷新'}</span>
</button>
</div>
</div>
<RefreshCcw size={17} aria-hidden="true" />
<span>{isLoading ? '加载中' : '刷新'}</span>
</AdminButton>
</AdminActionRow>
}
/>
{errorMessage ? (
<div className="admin-alert" role="alert">
{errorMessage}
</div>
<AdminAlert role="alert">{errorMessage}</AdminAlert>
) : null}
<section
className="admin-panel admin-stack"
aria-label="项目工程列表"
aria-busy={isLoading}
>
<div className="admin-table-wrap">
<table className="admin-table admin-project-snapshot-table">
<thead>
<tr>
<th>项目</th>
<th>用户</th>
<th>同步时间</th>
<th>文件数</th>
<th>体积</th>
<th>完整性</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{items.map((entry) => {
const status = snapshotStatuses[entry.status];
const isDownloading = downloadingKey === snapshotKey(entry);
return (
<tr key={snapshotKey(entry)}>
<td data-label="项目">
<strong>{entry.projectName || entry.projectId}</strong>
<small>{entry.projectId}</small>
</td>
<td data-label="用户">
<div className="admin-inline-identity">
<div>
{projectOwnerDisplayName(entry)}
<small>
{entry.authorPublicUserCode?.trim() || '-'}
</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.userId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td data-label="同步时间">
<span>
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
hour12: false,
})}
<small>版本 {entry.syncRevision}</small>
</span>
</td>
<td data-label="文件数">
{entry.fileCount.toLocaleString('zh-CN')}
</td>
<td data-label="体积">{formatBytes(entry.totalBytes)}</td>
<td data-label="完整性">
<span className={`admin-status ${status.className}`}>
{status.label}
</span>
</td>
<td data-label="操作">
{isDownloading ? (
<button
className="admin-secondary-button"
type="button"
onClick={cancelDownload}
>
<X size={16} aria-hidden="true" />
<span>取消下载</span>
</button>
) : (
<button
className="admin-secondary-button"
disabled={
entry.status === 'partial' ||
downloadingKey !== null
}
type="button"
onClick={() => void downloadProject(entry)}
>
<Download size={16} aria-hidden="true" />
<span>{status.action}</span>
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{hasLoaded && items.length === 0 && !errorMessage ? (
<p className="admin-muted-text">暂无已上传项目</p>
) : null}
{hasLoaded ? (
<nav
className="admin-action-row admin-project-snapshot-pagination"
aria-label="项目工程分页"
>
<span className="admin-project-snapshot-pagination-info">
第 {pageIndex} 页
{items.length ? `,本页 ${items.length} 个项目` : ''}
</span>
<div className="admin-action-row">
<label className="admin-field admin-field-compact">
<span>每页</span>
<select
aria-label="每页条数"
value={pageSize}
onChange={(event) => setPageSize(Number(event.target.value))}
>
{PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</label>
<button
aria-label="上一页"
className="admin-secondary-button"
disabled={isLoading || pageIndex <= 1}
title="上一页"
type="button"
onClick={goToPreviousPage}
>
<ChevronLeft size={17} aria-hidden="true" />
<span>上一页</span>
</button>
<button
aria-label="下一页"
className="admin-secondary-button"
disabled={isLoading || !nextCursor}
title="下一页"
type="button"
onClick={goToNextPage}
>
<span>下一页</span>
<ChevronRight size={17} aria-hidden="true" />
</button>
</div>
</nav>
) : null}
</section>
</section>
<AdminListPanel
ariaLabel="项目工程列表"
busy={isLoading}
className="admin-stack"
emptyText="暂无已上传项目"
emptyVariant="text"
rows={items}
showEmpty={hasLoaded && items.length === 0 && !errorMessage}
pagination={
hasLoaded ? (
<AdminPagination
ariaLabel="项目工程分页"
className="admin-project-snapshot-pagination"
infoClassName="admin-project-snapshot-pagination-info"
info={
<>
第 {pageIndex} 页
{items.length ? `,本页 ${items.length} 个项目` : ''}
</>
}
pageSize={{
value: pageSize,
options: PAGE_SIZE_OPTIONS,
ariaLabel: '每页条数',
onChange: setPageSize,
}}
busy={isLoading}
previous={{ disabled: pageIndex <= 1, onClick: goToPreviousPage }}
next={{ disabled: !nextCursor, onClick: goToNextPage }}
/>
) : null
}
columns={[
{ key: 'project', label: '项目' },
{ key: 'user', label: '用户' },
{ key: 'syncedAt', label: '同步时间' },
{ key: 'fileCount', label: '文件数' },
{ key: 'totalBytes', label: '体积' },
{ key: 'status', label: '完整性' },
{ key: 'actions', label: '操作' },
]}
renderRow={(entry) => {
const status = snapshotStatuses[entry.status];
const isDownloading = downloadingKey === snapshotKey(entry);
return (
<tr key={snapshotKey(entry)}>
<td data-label="项目">
<strong>{entry.projectName || entry.projectId}</strong>
<small>{entry.projectId}</small>
</td>
<td data-label="用户">
<div className="admin-inline-identity">
<div>
{projectOwnerDisplayName(entry)}
<small>{entry.authorPublicUserCode?.trim() || '-'}</small>
</div>
<AdminUserReferenceButton
token={token}
userId={entry.userId}
publicUserCode={entry.authorPublicUserCode}
onUnauthorized={onUnauthorized}
/>
</div>
</td>
<td data-label="同步时间">
<span>
{new Date(entry.syncedAtMs).toLocaleString('zh-CN', {
hour12: false,
})}
<small>版本 {entry.syncRevision}</small>
</span>
</td>
<td data-label="文件数">
{entry.fileCount.toLocaleString('zh-CN')}
</td>
<td data-label="体积">{formatBytes(entry.totalBytes)}</td>
<td data-label="完整性">
<AdminStatusPill tone={status.tone}>
{status.label}
</AdminStatusPill>
</td>
<td data-label="操作">
{isDownloading ? (
<AdminButton
variant="secondary"
type="button"
onClick={cancelDownload}
>
<X size={16} aria-hidden="true" />
<span>取消下载</span>
</AdminButton>
) : (
<AdminButton
variant="secondary"
disabled={
entry.status === 'partial' || downloadingKey !== null
}
type="button"
onClick={() => void downloadProject(entry)}
>
<Download size={16} aria-hidden="true" />
<span>{status.action}</span>
</AdminButton>
)}
</td>
</tr>
);
}}
tableClassName="admin-project-snapshot-table"
/>
</AdminPage>
);
}

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