Files
Genarrative/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx
T
kdletters 623e007fae
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m36s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m37s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m42s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m51s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m55s
Project CI / AI game creator shell Rust crates (push) Successful in 4m15s
Project CI / Repository checks (push) Successful in 4m45s
Project CI / Frontend tests (push) Successful in 7m47s
Project CI / Native shell tests (push) Successful in 9m33s
Project CI / Backend tests (push) Successful in 10m9s
Project CI / AI game creator shell web tests (push) Successful in 4m38s
完善客户端发布渠道并接入模板库灰度
区分发布渠道与系统,支持 dev、release 和自定义渠道
允许网站通过服务端配置选择客户端下载检测渠道
接入模板库灰度权限并阻断退出和切号后的异步操作
补齐发布、下载、灰度与会话竞态测试及当前规范
2026-09-20 12:12:49 +08:00

490 lines
14 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 { 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: '客户端',
};
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 入口灰度',
},
];
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('');
}
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 (
<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>
{listErrorMessage ? (
<div className="admin-alert" role="status">
{listErrorMessage}
</div>
) : 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>
<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>
</div>
<div className="admin-form-row">
<label className="admin-field admin-field-fill">
<span>Gate Key</span>
<input
value={gateKey}
onChange={(event) => setGateKey(event.target.value)}
/>
</label>
<label className="admin-switch-field">
<input
checked={enabled}
type="checkbox"
onChange={(event) => setEnabled(event.target.checked)}
/>
<span>启用</span>
</label>
</div>
<label className="admin-field admin-field-compact">
<span>灰度比例</span>
<input
max={100}
min={0}
step={1}
type="number"
value={rolloutPercent}
onChange={(event) => setRolloutPercent(event.target.value)}
/>
</label>
<div className="admin-form-row">
<label className="admin-field">
<span>允许用户 ID</span>
<textarea
rows={6}
value={allowUserIds}
onChange={(event) => setAllowUserIds(event.target.value)}
/>
</label>
<label className="admin-field">
<span>允许用户标签</span>
<textarea
rows={6}
value={allowUserTags}
onChange={(event) => setAllowUserTags(event.target.value)}
/>
</label>
</div>
<label className="admin-field">
<span>拒绝用户 ID</span>
<textarea
rows={5}
value={denyUserIds}
onChange={(event) => setDenyUserIds(event.target.value)}
/>
</label>
<label className="admin-field">
<span>描述</span>
<textarea
rows={3}
value={description}
onChange={(event) => setDescription(event.target.value)}
/>
</label>
{errorMessage ? (
<div className="admin-alert" role="status">
{errorMessage}
</div>
) : null}
<button
className="admin-primary-button"
disabled={isSaving || !canSave}
type="submit"
>
<Save size={17} aria-hidden="true" />
<span>{isSaving ? '保存中' : '保存配置'}</span>
</button>
</form>
<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>
)}
</section>
</div>
{confirmDialog}
</section>
);
}
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 : '';
}