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 和自定义渠道 允许网站通过服务端配置选择客户端下载检测渠道 接入模板库灰度权限并阻断退出和切号后的异步操作 补齐发布、下载、灰度与会话竞态测试及当前规范
490 lines
14 KiB
TypeScript
490 lines
14 KiB
TypeScript
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 : '';
|
||
}
|