合并 origin/master:待发消息队列归宿主叠加 master 的 active-turns 广播与保活下沉
- direct_runtime/mod.rs:保留本分支「Thread Manager 是活动回合唯一事实源」(direct_active_turn_id_at), 丢掉 master 的 DirectTaonierActiveInvocationGuard 第二份进程内表;master 的美术背景路由改动全部保留 - direct_thread_manager.rs:双方都保留——本分支的待发队列 / 容量上限 / 残留回合兜底释放, 与 master 的 emit_direct_active_turns_changed 广播、unsubscribe、update_active_turn 返回「是否真的变了」 - useDirectProjectChatController.ts:保留本分支入队化逻辑,采纳 master 的 rawMessage 小重构与保活下沉 - direct_turn_dispatch.rs:会话保活改挂放行占用(DirectTurnReservation._session_keepalive), 随占用释放即停,等价承接 master 放在调用身份守卫里的保活意图 - 保留本分支对 direct-execution-production-fixture.mjs 的删除(--direct-codex-chat CLI 已退役,夹具无入口) - 连带退役 scripts/run-agc-direct-execution-fixture.mjs 与 npm run check:agc-direct-execution-fixture(只服务该夹具) - 文档同步:ADR / 09-24 实施计划 / decision-log 记录上述退役,09-16 与 09-18 里程碑、pitfalls 的夹具命令标注为已退役
This commit is contained in:
@@ -30,6 +30,7 @@ The hosted MCP offers the following tools. Choose the task tool when its action
|
||||
| `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` |
|
||||
| `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` |
|
||||
| `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` |
|
||||
| `POST /api/external/v1/editor/scenes/generations` | structured game-scene generation (no hosted MCP tool yet) | `generate_external_editor_scene` |
|
||||
| `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` |
|
||||
| `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` |
|
||||
| `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` |
|
||||
@@ -89,6 +90,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| Capability | POST path | Required body fields | Common optional body fields |
|
||||
| ------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Game scene | `/api/external/v1/editor/scenes/generations` | `sceneContent`, `stylePreset` | `customStyle` (required when `stylePreset="custom"`), `model`, `aspectRatio`, `imageSize`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` |
|
||||
| Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` |
|
||||
@@ -98,7 +100,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP
|
||||
| Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
| Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` |
|
||||
|
||||
Poll all nine through:
|
||||
Poll all ten through:
|
||||
|
||||
```text
|
||||
GET /api/external/v1/generations/{operationId}
|
||||
@@ -140,7 +142,7 @@ The icon-spritesheet primary `referenceId` is intentionally stricter than ordina
|
||||
Use OpenAPI as the final authority; these common values are a routing aid:
|
||||
|
||||
- Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it.
|
||||
- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing.
|
||||
- Game scenes must use the dedicated structured route `POST /api/external/v1/editor/scenes/generations` (`sceneContent` + `stylePreset`; `customStyle` required for `custom`). Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. The scene route assembles the full provider prompt server-side and never accepts a caller-assembled `prompt`.
|
||||
- Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`.
|
||||
- Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
|
||||
- Image `imageSize`: `0.5K`, `1K`, `2K`.
|
||||
|
||||
@@ -148,7 +148,7 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep
|
||||
|
||||
## Art Spec and Image Request
|
||||
|
||||
Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt.
|
||||
Game scenes have a dedicated structured route: `POST /api/external/v1/editor/scenes/generations` with `sceneContent` and `stylePreset` (`customStyle` required when `stylePreset` is `custom`). The server assembles the full provider prompt; a caller-assembled `prompt` is not accepted. `kind: "scene"` and `assetKind: "scene"` remain invalid on generic image generation and return HTTP `400` before any generation job is queued.
|
||||
|
||||
When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation:
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -542,6 +542,37 @@ function pickUploadFiles(dialog: HTMLElement, accept: string, files: File[]) {
|
||||
fireEvent.change(input, { target: { files } });
|
||||
}
|
||||
|
||||
test('批量上传:表头是真实列头单元格,不是嵌套 thead', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
pickUploadFiles(dialog, '.zip,application/zip', [uploadZipFile('alpha.zip')]);
|
||||
|
||||
const table = Array.from(dialog.querySelectorAll('table')).find((candidate) =>
|
||||
candidate.textContent?.includes('alpha.zip'),
|
||||
);
|
||||
expect(table).toBeTruthy();
|
||||
|
||||
// 每一列表头都必须是 `th`:把 `<TableHeader>`(渲染 `<thead>`)当单元格用会产出
|
||||
// `<th><tr><thead>`,列头语义与样式一起丢失。
|
||||
expect(
|
||||
within(table as HTMLElement)
|
||||
.getAllByRole('columnheader')
|
||||
.map((cell) => cell.textContent),
|
||||
).toEqual([
|
||||
'模板包',
|
||||
'ID',
|
||||
'名称',
|
||||
'版本',
|
||||
'运行时',
|
||||
'entry',
|
||||
'封面',
|
||||
'操作',
|
||||
]);
|
||||
expect(table?.querySelectorAll('thead thead')).toHaveLength(0);
|
||||
expect(table?.querySelectorAll('thead > tr > th')).toHaveLength(8);
|
||||
expect(table?.querySelectorAll('thead > th')).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('批量上传:多选 ZIP 生成行,封面匹配齐了才能提交', async () => {
|
||||
render(<AdminAgcTemplatesPage token="token" onUnauthorized={vi.fn()} />);
|
||||
const dialog = await openUploadDialog();
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import {
|
||||
AdminListPanel,
|
||||
AdminPage,
|
||||
AdminPageHeading,
|
||||
Button,
|
||||
Modal,
|
||||
SelectField,
|
||||
@@ -294,28 +297,30 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
const writesDisabled = busy || loading || conflict || !snapshot?.writable;
|
||||
|
||||
return (
|
||||
<section className="admin-page admin-page-wide admin-agc-templates genarrative-ui">
|
||||
<div className="admin-page-heading">
|
||||
<h2>模板管理</h2>
|
||||
<div className="admin-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={writesDisabled || !snapshot}
|
||||
onClick={openUpload}
|
||||
>
|
||||
<Upload size={16} aria-hidden="true" />
|
||||
上传模板
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading || busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AdminPage wide className="admin-agc-templates genarrative-ui">
|
||||
<AdminPageHeading
|
||||
title="模板管理"
|
||||
actions={
|
||||
<div className="admin-actions">
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={writesDisabled || !snapshot}
|
||||
onClick={openUpload}
|
||||
>
|
||||
<Upload size={16} aria-hidden="true" />
|
||||
上传模板
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={loading || busy}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
<RefreshCcw size={16} aria-hidden="true" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{readOnly ? (
|
||||
<Status tone="warning">当前为只读模式,无法保存或上下架</Status>
|
||||
) : null}
|
||||
@@ -334,133 +339,155 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
</Button>
|
||||
) : null}
|
||||
{notice ? <Status tone="success">{notice}</Status> : null}
|
||||
<div className="admin-agc-template-filters">
|
||||
<TextField
|
||||
label="搜索模板"
|
||||
placeholder="名称、ID 或标签"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
/>
|
||||
<SelectField
|
||||
label="运行时"
|
||||
value={runtime}
|
||||
onChange={(event) => setRuntime(event.currentTarget.value)}
|
||||
>
|
||||
<option value="">全部运行时</option>
|
||||
{runtimes.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{runtimeLabels[value] ?? value}
|
||||
</option>
|
||||
))}
|
||||
</SelectField>
|
||||
<SelectField
|
||||
label="上架状态"
|
||||
value={visibility}
|
||||
onChange={(event) => setVisibility(event.currentTarget.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="enabled">已上架</option>
|
||||
<option value="disabled">已下架</option>
|
||||
</SelectField>
|
||||
</div>
|
||||
{loading ? <Status>正在加载模板</Status> : null}
|
||||
{snapshot ? (
|
||||
<Table className="admin-table admin-agc-template-table">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>封面</TableHead>
|
||||
<TableHead>模板</TableHead>
|
||||
<TableHead>引擎 / 版本</TableHead>
|
||||
<TableHead>包大小</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{entries.map((entry) => (
|
||||
<TableRow key={entry.id}>
|
||||
<TableCell>
|
||||
<img
|
||||
className="admin-agc-template-cover"
|
||||
src={entry.coverUrl}
|
||||
alt={`${entry.title}封面`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<strong>{entry.title}</strong>
|
||||
<small>{entry.id}</small>
|
||||
<p className="admin-agc-template-summary">{entry.summary}</p>
|
||||
<div className="admin-agc-template-tags">
|
||||
{entry.tags.map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{entry.engine ||
|
||||
runtimeLabels[entry.runtime] ||
|
||||
entry.runtime}{' '}
|
||||
{entry.engineVersion}
|
||||
<small>模板 {entry.templateVersion}</small>
|
||||
</TableCell>
|
||||
<TableCell>{formatTemplateSize(entry.zipSizeBytes)}</TableCell>
|
||||
<TableCell>{entry.enabled ? '已上架' : '已下架'}</TableCell>
|
||||
<TableCell>
|
||||
<div className="admin-agc-template-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busy || loading}
|
||||
aria-label={`编辑 ${entry.title}`}
|
||||
onClick={() => {
|
||||
setError('');
|
||||
setEditing({
|
||||
entry,
|
||||
revision: snapshot.revision,
|
||||
key: ++editorSequence.current,
|
||||
});
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={entry.enabled ? 'danger' : 'secondary'}
|
||||
disabled={writesDisabled}
|
||||
aria-label={`${entry.enabled ? '下架' : '上架'} ${entry.title}`}
|
||||
onClick={() =>
|
||||
void writeTemplate(
|
||||
entry,
|
||||
{
|
||||
expectedRevision: snapshot.revision,
|
||||
title: entry.title,
|
||||
summary: entry.summary,
|
||||
tags: [...entry.tags],
|
||||
enabled: !entry.enabled,
|
||||
},
|
||||
entry.enabled ? '下架模板' : '上架模板',
|
||||
)
|
||||
}
|
||||
>
|
||||
{entry.enabled ? '下架' : '上架'}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!entries.length ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
{snapshot.templates.length
|
||||
? '没有符合筛选条件的模板'
|
||||
: '暂无模板'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
<AdminListPanel
|
||||
busy={loading}
|
||||
busyText="正在加载模板"
|
||||
busyVariant="line"
|
||||
toolbar={
|
||||
<div className="admin-agc-template-filters">
|
||||
<TextField
|
||||
label="搜索模板"
|
||||
placeholder="名称、ID 或标签"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.currentTarget.value)}
|
||||
/>
|
||||
<SelectField
|
||||
label="运行时"
|
||||
value={runtime}
|
||||
onChange={(event) => setRuntime(event.currentTarget.value)}
|
||||
>
|
||||
<option value="">全部运行时</option>
|
||||
{runtimes.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{runtimeLabels[value] ?? value}
|
||||
</option>
|
||||
))}
|
||||
</SelectField>
|
||||
<SelectField
|
||||
label="上架状态"
|
||||
value={visibility}
|
||||
onChange={(event) => setVisibility(event.currentTarget.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="enabled">已上架</option>
|
||||
<option value="disabled">已下架</option>
|
||||
</SelectField>
|
||||
</div>
|
||||
}
|
||||
columns={[
|
||||
{ key: 'cover', label: '封面' },
|
||||
{
|
||||
key: 'title',
|
||||
label: '模板',
|
||||
sortable: true,
|
||||
sortValue: (entry) => entry.title,
|
||||
},
|
||||
{
|
||||
key: 'engine',
|
||||
label: '引擎 / 版本',
|
||||
sortable: true,
|
||||
sortValue: (entry) => entry.engine || entry.engineVersion,
|
||||
},
|
||||
{
|
||||
key: 'zipSizeBytes',
|
||||
label: '包大小',
|
||||
sortable: true,
|
||||
sortValue: (entry) => entry.zipSizeBytes,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: '状态',
|
||||
sortable: true,
|
||||
sortDescription: '上架状态',
|
||||
sortValue: (entry) => (entry.enabled ? 1 : 0),
|
||||
},
|
||||
{ key: 'actions', label: '操作' },
|
||||
]}
|
||||
rows={snapshot ? entries : []}
|
||||
showEmpty={Boolean(snapshot && entries.length === 0)}
|
||||
emptyText={
|
||||
snapshot && snapshot.templates.length
|
||||
? '没有符合筛选条件的模板'
|
||||
: '暂无模板'
|
||||
}
|
||||
emptyVariant="row"
|
||||
renderRow={(entry) => {
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<tr key={entry.id}>
|
||||
<td>
|
||||
<img
|
||||
className="admin-agc-template-cover"
|
||||
src={entry.coverUrl}
|
||||
alt={`${entry.title}封面`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{entry.title}</strong>
|
||||
<small>{entry.id}</small>
|
||||
<p className="admin-agc-template-summary">{entry.summary}</p>
|
||||
<div className="admin-agc-template-tags">
|
||||
{entry.tags.map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{entry.engine || runtimeLabels[entry.runtime] || entry.runtime}{' '}
|
||||
{entry.engineVersion}
|
||||
<small>模板 {entry.templateVersion}</small>
|
||||
</td>
|
||||
<td>{formatTemplateSize(entry.zipSizeBytes)}</td>
|
||||
<td>{entry.enabled ? '已上架' : '已下架'}</td>
|
||||
<td>
|
||||
<div className="admin-agc-template-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={busy || loading}
|
||||
aria-label={`编辑 ${entry.title}`}
|
||||
onClick={() => {
|
||||
setError('');
|
||||
setEditing({
|
||||
entry,
|
||||
revision: snapshot.revision,
|
||||
key: ++editorSequence.current,
|
||||
});
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={entry.enabled ? 'danger' : 'secondary'}
|
||||
disabled={writesDisabled}
|
||||
aria-label={`${entry.enabled ? '下架' : '上架'} ${entry.title}`}
|
||||
onClick={() =>
|
||||
void writeTemplate(
|
||||
entry,
|
||||
{
|
||||
expectedRevision: snapshot.revision,
|
||||
title: entry.title,
|
||||
summary: entry.summary,
|
||||
tags: [...entry.tags],
|
||||
enabled: !entry.enabled,
|
||||
},
|
||||
entry.enabled ? '下架模板' : '上架模板',
|
||||
)
|
||||
}
|
||||
>
|
||||
{entry.enabled ? '下架' : '上架'}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}}
|
||||
tableClassName="admin-agc-template-table"
|
||||
/>
|
||||
{editing ? (
|
||||
<Modal
|
||||
open
|
||||
@@ -534,7 +561,7 @@ function AdminAgcTemplatesSession({ token, onUnauthorized }: PageProps) {
|
||||
{confirmDialog}
|
||||
</Modal>
|
||||
) : null}
|
||||
</section>
|
||||
</AdminPage>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -880,18 +907,18 @@ function TemplateUploadDialog({
|
||||
) : (
|
||||
<div className="admin-agc-template-upload-scroll">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeader>模板包</TableHeader>
|
||||
<TableHeader>ID</TableHeader>
|
||||
<TableHeader>名称</TableHeader>
|
||||
<TableHeader>版本</TableHeader>
|
||||
<TableHeader>运行时</TableHeader>
|
||||
<TableHeader>entry</TableHeader>
|
||||
<TableHeader>封面</TableHeader>
|
||||
<TableHeader>操作</TableHeader>
|
||||
<TableHead>模板包</TableHead>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>版本</TableHead>
|
||||
<TableHead>运行时</TableHead>
|
||||
<TableHead>entry</TableHead>
|
||||
<TableHead>封面</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{state.rows.map((row) => {
|
||||
const rowError = state.errors[row.key];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user