Files
Genarrative/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx
T
kdletters 8315d5660a
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m53s
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
后台公共组件收口到共享组件库并统一列表实现
- packages/shared/src/components/admin/ 新增 16 个后台共享组件(AdminPage/AdminPanel/AdminButton/AdminAlert/AdminTable/AdminField/AdminInfoList/AdminTagList/AdminPagination/AdminListPanel/AdminDialog 等)与随组件的 admin.css,经 @genarrative/shared/components 导出
- AdminListPanel 统一列表外壳:columns + renderRow、加载两口径(empty/line)、空态三态(state/text/row)、toolbar 与 pagination 槽位、showEmpty 判据、surface=plain(弹窗内列表)
- AdminListPanel 新增列头排序(sortable/sortValue/sortDescription):正序→倒序→不排序循环、同步 th[aria-sort]、稳定排序
- AdminDialog 统一弹窗外壳(遮罩/标题行/关闭动作/role=dialog/aria-modal/Esc/点遮罩关闭/初始聚焦/as=form),12 处各页自写弹窗全部改用它
- AdminFormat 新增 formatAdminDateTime/formatAdminDate,删除 7 份重复的本地时间格式化实现,修掉列表里裸 ISO 时间
- 21 个文件里 26 处列表全部迁到 AdminListPanel(24 处 columns 形态、2 处弹窗内 plain),页内已无手写表头或 AdminTable 当 children 的写法;累计 45 个可排序列
- 页面壳归一:错误报告补 AdminPage wide + AdminPageHeading,详情弹窗移出列表面板;模板管理列表从共享 ui Table 换成 AdminTable
- 样式收口:公共组件规则随组件进入共享 styles,清理 admin.css 重复按钮/面板规则,.admin-tag 与 .admin-table th 改 nowrap,埋点用户列参考按钮紧跟本行并留行距,错误报告修版心
- 新增 scripts/admin-web-fake-api.mjs 本地假 API(含 overview/dashboard/tracking/error-reports/feature-gates/邀请码/兑换码/任务/充值/项目工程/AGC 模型与模板/游戏/账号/用户详情夹具),用于本地核对渲染
- docs 同步:共享基础组件库与展示页(后台组件层/列表排序/收敛范围/本地核对)、decision-log 与 pitfalls 记录迁移口径、排序边界与踩坑
- 验证:npm run admin-web:typecheck、npx vitest run apps/admin-web packages/shared/src/components/admin(26 files / 244 tests)、npm run check:encoding、git diff --check、prettier、eslint 全部通过
2026-09-29 22:39:41 +08:00

543 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import {
getAdminFeatureGateConfig,
upsertAdminFeatureGateConfig,
} from '../api/adminApiClient';
import type {
AdminFeatureGateConfigPayload,
AdminUpsertFeatureGateConfigRequest,
} from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError, splitLines } from './pageUtils';
interface AdminGrayReleaseConfigPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
interface GateTargetOption {
prefix: string;
suffix: string;
key: string;
label: string;
description: string;
}
const GATE_PREFIX_LABELS: Record<string, string> = {
'image-editor': '画布',
agc: '客户端',
'game-distribution': '游戏分发',
};
const FIXED_GATE_TARGETS: GateTargetOption[] = [
{
prefix: 'agc',
suffix: 'template-library',
key: 'agc:template-library',
label: '模板库',
description: '客户端模板库灰度',
},
{
prefix: 'image-editor',
suffix: 'agent-sidebar',
key: 'image-editor:agent-sidebar',
label: 'Agent 侧边栏',
description: '画布 Agent 入口灰度',
},
{
prefix: 'game-distribution',
suffix: 'publish',
key: 'game-distribution:publish',
label: '游戏发布',
description:
'游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中',
},
];
export function AdminGrayReleaseConfigPage({
token,
onUnauthorized,
}: AdminGrayReleaseConfigPageProps) {
const [gates, setGates] = useState<AdminFeatureGateConfigPayload[]>([]);
const [selectedGateKey, setSelectedGateKey] = useState('');
const [gatePrefix, setGatePrefix] = useState('');
const [gateKey, setGateKey] = useState('');
const [enabled, setEnabled] = useState(false);
const [rolloutPercent, setRolloutPercent] = useState('0');
const [allowUserIds, setAllowUserIds] = useState('');
const [allowUserTags, setAllowUserTags] = useState('');
const [denyUserIds, setDenyUserIds] = useState('');
const [description, setDescription] = useState('');
const [listErrorMessage, setListErrorMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const { confirmWrite, confirmDialog } = useAdminWriteConfirm();
useEffect(() => {
void refreshFeatureGates();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
async function refreshFeatureGates() {
const requestToken = token.trim();
if (!requestToken) {
setGates([]);
setListErrorMessage('');
setIsLoading(false);
return;
}
setIsLoading(true);
setListErrorMessage('');
try {
const featureGateResponse = await getAdminFeatureGateConfig(requestToken);
setGates(featureGateResponse.gates);
const selectedGate = featureGateResponse.gates.find(
(gate) => gate.gateKey === selectedGateKey,
);
if (selectedGate) {
fillForm(selectedGate);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setListErrorMessage);
} finally {
setIsLoading(false);
}
}
async function handleSave(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const requestToken = token.trim();
if (isSaving || !requestToken) {
return;
}
setErrorMessage('');
const payload = buildPayload();
const confirmed = await confirmWrite({
action: '保存灰度配置',
target: payload.gateKey,
});
if (!confirmed) {
return;
}
setIsSaving(true);
try {
const response = await upsertAdminFeatureGateConfig(
requestToken,
payload,
);
setGates(response.gates);
const savedGate = response.gates.find(
(gate) => gate.gateKey === payload.gateKey,
);
if (savedGate) {
fillForm(savedGate);
} else {
setSelectedGateKey(payload.gateKey);
}
} catch (error: unknown) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsSaving(false);
}
}
function handleGatePrefixSelect(
nextPrefix: string,
gateTargetOptions: GateTargetOption[],
) {
setGatePrefix(nextPrefix);
if (!nextPrefix) {
setSelectedGateKey('');
setGateKey('');
return;
}
const nextOptions = gateTargetOptions.filter(
(option) => option.prefix === nextPrefix,
);
const onlyOption = nextOptions[0];
if (nextOptions.length === 1 && onlyOption) {
applyGateTarget(onlyOption);
return;
}
setSelectedGateKey('');
setGateKey('');
}
function handleGateTargetSelect(
suffix: string,
gateTargetOptions: GateTargetOption[],
) {
if (!gatePrefix || !suffix) {
return;
}
const option = gateTargetOptions.find(
(item) => item.prefix === gatePrefix && item.suffix === suffix,
);
if (option) {
applyGateTarget(option);
}
}
function applyGateTarget(option: GateTargetOption) {
const existingGate = gates.find((gate) => gate.gateKey === option.key);
if (existingGate) {
fillForm(existingGate);
return;
}
setSelectedGateKey('');
setGatePrefix(option.prefix);
setGateKey(option.key);
setEnabled(option.key === 'agc:template-library');
setRolloutPercent('0');
setAllowUserIds('');
setAllowUserTags('');
setDenyUserIds('');
setDescription(option.description);
setErrorMessage('');
}
// 预设里尚未创建行的开关也要可见:运营需要先看到 key 才能配置灰度。
const unconfiguredGateTargets = FIXED_GATE_TARGETS.filter(
(option) => !gates.some((gate) => gate.gateKey === option.key),
);
function buildPayload(): AdminUpsertFeatureGateConfigRequest {
return {
gateKey: gateKey.trim(),
enabled,
rolloutPercent: parseRolloutPercent(rolloutPercent),
allowUserIds: splitLines(allowUserIds),
allowUserTags: splitLines(allowUserTags),
denyUserIds: splitLines(denyUserIds),
description: description.trim(),
};
}
function fillForm(entry: AdminFeatureGateConfigPayload) {
setSelectedGateKey(entry.gateKey);
setGatePrefix(resolveGatePrefix(entry.gateKey));
setGateKey(entry.gateKey);
setEnabled(entry.enabled);
setRolloutPercent(String(entry.rolloutPercent));
setAllowUserIds(entry.allowUserIds.join('\n'));
setAllowUserTags(entry.allowUserTags.join('\n'));
setDenyUserIds(entry.denyUserIds.join('\n'));
setDescription(entry.description);
setErrorMessage('');
}
function resetForm() {
setSelectedGateKey('');
setGatePrefix('');
setGateKey('');
setEnabled(false);
setRolloutPercent('0');
setAllowUserIds('');
setAllowUserTags('');
setDenyUserIds('');
setDescription('');
setErrorMessage('');
}
const canSave =
gateKey.trim().length > 0 && isRolloutPercentInputValid(rolloutPercent);
const gateTargetOptions = FIXED_GATE_TARGETS;
const gatePrefixOptions = buildGatePrefixOptions(gateTargetOptions);
const gateTargetsForPrefix = gateTargetOptions.filter(
(option) => option.prefix === gatePrefix,
);
const selectedGateTargetSuffix =
gateTargetsForPrefix.find((option) => option.key === gateKey)?.suffix ?? '';
return (
<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 ? <AdminAlert>{listErrorMessage}</AdminAlert> : null}
<div className="admin-two-column admin-two-column-wide">
<AdminPanel as="form" onSubmit={handleSave}>
<AdminField as="div" label="Gate Key 选择">
<div className="admin-gate-key-selectors">
<select
aria-label="Gate Key 前缀"
value={gatePrefix}
onChange={(event) =>
handleGatePrefixSelect(event.target.value, gateTargetOptions)
}
>
<option value="">选择前缀</option>
{gatePrefixOptions.map((option) => (
<option key={option.prefix} value={option.prefix}>
{option.prefix}({option.label})
</option>
))}
</select>
<select
aria-label="Gate Key 目标"
disabled={!gatePrefix || gateTargetsForPrefix.length === 0}
value={selectedGateTargetSuffix}
onChange={(event) =>
handleGateTargetSelect(event.target.value, gateTargetOptions)
}
>
<option value="">选择目标</option>
{gateTargetsForPrefix.map((option) => (
<option key={option.key} value={option.suffix}>
{option.label}({option.suffix})
</option>
))}
</select>
</div>
</AdminField>
<AdminFormRow>
<AdminField label="Gate Key" fill>
<input
value={gateKey}
onChange={(event) => setGateKey(event.target.value)}
/>
</AdminField>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span>启用</span>
</label>
</AdminFormRow>
<AdminField label="灰度比例" compact>
<input
max={100}
min={0}
step={1}
type="number"
value={rolloutPercent}
onChange={(event) => setRolloutPercent(event.target.value)}
/>
</AdminField>
<AdminFormRow>
<AdminField label="允许用户 ID">
<textarea
rows={6}
value={allowUserIds}
onChange={(event) => setAllowUserIds(event.target.value)}
/>
</AdminField>
<AdminField label="允许用户标签">
<textarea
rows={6}
value={allowUserTags}
onChange={(event) => setAllowUserTags(event.target.value)}
/>
</AdminField>
</AdminFormRow>
<AdminField label="拒绝用户 ID">
<textarea
rows={5}
value={denyUserIds}
onChange={(event) => setDenyUserIds(event.target.value)}
/>
</AdminField>
<AdminField label="描述">
<textarea
rows={3}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</AdminField>
{errorMessage ? <AdminAlert>{errorMessage}</AdminAlert> : null}
<AdminButton
variant="primary"
disabled={isSaving || !canSave}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存配置'}</span>
</AdminButton>
</AdminPanel>
<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>
)}
title="Gate 列表"
count={gates.length}
rows={gates}
busy={isLoading}
busyText="加载中"
emptyText="暂无 Gate"
size="compact"
/>
<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>
)}
title="可配置开关"
count={unconfiguredGateTargets.length}
rows={unconfiguredGateTargets}
busy={isLoading}
busyText="加载中"
emptyText="预设开关都已创建"
size="compact"
/>
</div>
{confirmDialog}
</AdminPage>
);
}
function parseRolloutPercent(value: string) {
const parsed = Number.parseFloat(value);
if (!Number.isFinite(parsed)) {
return 0;
}
return Math.min(100, Math.max(0, parsed));
}
function isRolloutPercentInputValid(value: string) {
const parsed = Number.parseFloat(value);
return Number.isFinite(parsed) && parsed >= 0 && parsed <= 100;
}
function buildGatePrefixOptions(gateTargetOptions: GateTargetOption[]) {
const seen = new Set<string>();
return gateTargetOptions.flatMap((option) => {
if (seen.has(option.prefix)) {
return [];
}
seen.add(option.prefix);
return [
{
prefix: option.prefix,
label: GATE_PREFIX_LABELS[option.prefix] ?? option.prefix,
},
];
});
}
function resolveGatePrefix(gateKey: string) {
const separatorIndex = gateKey.indexOf(':');
if (separatorIndex <= 0) {
return '';
}
const prefix = gateKey.slice(0, separatorIndex);
return GATE_PREFIX_LABELS[prefix] ? prefix : '';
}