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
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:
@@ -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} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 '新增';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user