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 全部通过
543 lines
16 KiB
TypeScript
543 lines
16 KiB
TypeScript
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 : '';
|
||
}
|