Files
configcenter/ConfigCenter.jsx

1683 lines
90 KiB
JavaScript
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 React, { useState, useEffect } from 'react';
import Editor, { DiffEditor } from '@monaco-editor/react';
import {
Package, FolderTree, Globe, SlidersHorizontal, Plus, Pencil, Trash2,
Search, X, UploadCloud, History, RotateCcw, AlertTriangle, Check,
LockKeyhole, GitBranch, Users, LogOut, ShieldCheck,
} from 'lucide-react';
import { api } from './web/src/api.js';
/* =========================================================================
* 工具函数
* ========================================================================= */
let idCounter = 1000;
function genId(prefix) {
idCounter += 1;
return `${prefix}-${idCounter}`;
}
function nowStr() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function todayStr() {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
}
const ENV_STYLES = {
DEV: { badge: 'bg-emerald-50 text-emerald-700 border-emerald-200', dot: 'bg-emerald-500' },
TEST: { badge: 'bg-amber-50 text-amber-700 border-amber-200', dot: 'bg-amber-500' },
STAGING: { badge: 'bg-violet-50 text-violet-700 border-violet-200', dot: 'bg-violet-500' },
PROD: { badge: 'bg-rose-50 text-rose-700 border-rose-200', dot: 'bg-rose-500' },
};
function envStyle(code) {
return ENV_STYLES[code] || { badge: 'bg-slate-100 text-slate-700 border-slate-200', dot: 'bg-slate-400' };
}
const TYPE_BADGE = {
properties: 'bg-slate-100 text-slate-600',
yaml: 'bg-indigo-50 text-indigo-600',
json: 'bg-amber-50 text-amber-600',
xml: 'bg-violet-50 text-violet-600',
};
const MONACO_LANGUAGE = {
properties: 'ini',
yaml: 'yaml',
json: 'json',
xml: 'xml',
};
/* =========================================================================
* 种子数据
* ========================================================================= */
const initialEnvironments = [
{ id: 'env-dev', code: 'DEV', name: '开发环境', desc: '开发联调使用', order: 1 },
{ id: 'env-test', code: 'TEST', name: '测试环境', desc: 'QA 测试验证', order: 2 },
{ id: 'env-staging', code: 'STAGING', name: '预发环境', desc: '上线前灰度验证', order: 3 },
{ id: 'env-prod', code: 'PROD', name: '生产环境', desc: '线上正式环境', order: 4 },
];
const initialApps = [
{ id: 'app-user', code: 'user-service', name: '用户服务', department: '平台架构部', owner: '张伟', desc: '用户注册、登录与账户中心', createdAt: '2026-01-10' },
{ id: 'app-order', code: 'order-service', name: '订单服务', department: '交易平台部', owner: '李娜', desc: '订单创建、支付与售后流程', createdAt: '2026-02-15' },
{ id: 'app-gateway', code: 'api-gateway', name: 'API 网关', department: '平台架构部', owner: '王强', desc: '统一流量入口与路由转发', createdAt: '2026-01-22' },
];
const initialNamespaces = [
{ id: 'ns-user-app', appId: 'app-user', name: 'application', type: 'properties', desc: '默认应用配置' },
{ id: 'ns-user-db', appId: 'app-user', name: 'database', type: 'yaml', desc: '数据库连接配置' },
{ id: 'ns-order-app', appId: 'app-order', name: 'application', type: 'properties', desc: '默认应用配置' },
{ id: 'ns-gateway-app', appId: 'app-gateway', name: 'application', type: 'properties', desc: '默认应用配置' },
{ id: 'ns-gateway-routes', appId: 'app-gateway', name: 'routes', type: 'json', desc: '路由与限流规则' },
];
const rawConfigs = [
{ appId: 'app-user', nsId: 'ns-user-app', envId: 'env-dev', key: 'server.port', value: '8080', comment: '服务监听端口', released: true },
{ appId: 'app-user', nsId: 'ns-user-app', envId: 'env-dev', key: 'feature.newLogin.enabled', value: 'true', comment: '新登录页灰度开关', released: true },
{ appId: 'app-user', nsId: 'ns-user-app', envId: 'env-dev', key: 'log.level', value: 'INFO', releasedValueOverride: 'DEBUG', comment: '日志级别' },
{ appId: 'app-user', nsId: 'ns-user-app', envId: 'env-test', key: 'server.port', value: '8080', released: true },
{ appId: 'app-user', nsId: 'ns-user-app', envId: 'env-test', key: 'feature.newLogin.enabled', value: 'false', released: true },
{ appId: 'app-user', nsId: 'ns-user-app', envId: 'env-prod', key: 'server.port', value: '8080', released: true },
{ appId: 'app-user', nsId: 'ns-user-app', envId: 'env-prod', key: 'feature.newLogin.enabled', value: 'false', released: true },
{ appId: 'app-user', nsId: 'ns-user-app', envId: 'env-prod', key: 'rate.limit.qps', value: '500', comment: '接口限流阈值', released: true, pendingDelete: true },
{ appId: 'app-user', nsId: 'ns-user-app', envId: 'env-prod', key: 'cache.ttl.seconds', value: '300', comment: '缓存过期时间' },
{ appId: 'app-user', nsId: 'ns-user-db', envId: 'env-dev', key: 'db.url', value: 'jdbc:mysql://dev-db:3306/user', comment: '数据库连接地址', released: true },
{ appId: 'app-user', nsId: 'ns-user-db', envId: 'env-dev', key: 'db.pool.size', value: '10', released: true },
{ appId: 'app-user', nsId: 'ns-user-db', envId: 'env-prod', key: 'db.url', value: 'jdbc:mysql://prod-db-vip:3306/user', released: true },
{ appId: 'app-user', nsId: 'ns-user-db', envId: 'env-prod', key: 'db.pool.size', value: '50', released: true },
{ appId: 'app-order', nsId: 'ns-order-app', envId: 'env-dev', key: 'pay.channel.default', value: 'alipay', comment: '默认支付渠道', released: true },
{ appId: 'app-order', nsId: 'ns-order-app', envId: 'env-dev', key: 'order.timeout.minutes', value: '30', released: true },
{ appId: 'app-order', nsId: 'ns-order-app', envId: 'env-test', key: 'pay.channel.default', value: 'alipay', released: true },
{ appId: 'app-order', nsId: 'ns-order-app', envId: 'env-prod', key: 'pay.channel.default', value: 'wechat', released: true },
{ appId: 'app-order', nsId: 'ns-order-app', envId: 'env-prod', key: 'order.timeout.minutes', value: '15', released: true },
{ appId: 'app-gateway', nsId: 'ns-gateway-app', envId: 'env-dev', key: 'gateway.timeout.ms', value: '3000', released: true },
{ appId: 'app-gateway', nsId: 'ns-gateway-app', envId: 'env-prod', key: 'gateway.timeout.ms', value: '1500', released: true },
{ appId: 'app-gateway', nsId: 'ns-gateway-routes', envId: 'env-prod', key: 'routes.user-service', value: '/api/user/** -> user-service', comment: '用户服务路由规则', released: true },
];
const initialConfigs = rawConfigs.map((r) => ({
id: genId('cfg'),
appId: r.appId,
nsId: r.nsId,
envId: r.envId,
key: r.key,
value: r.value,
releasedValue: r.releasedValueOverride !== undefined ? r.releasedValueOverride : (r.released ? r.value : null),
pendingDelete: !!r.pendingDelete,
comment: r.comment || '',
updatedAt: '2026-08-25 14:30',
}));
const initialReleases = [
{ id: genId('rel'), appId: 'app-user', nsId: 'ns-user-app', envId: 'env-prod', version: 1, time: '2026-07-01 10:00', operator: 'admin', comment: '初始化生产环境配置', added: 2, modified: 0, removed: 0, snapshot: { 'server.port': '8080', 'feature.newLogin.enabled': 'false' } },
{ id: genId('rel'), appId: 'app-user', nsId: 'ns-user-app', envId: 'env-prod', version: 2, time: '2026-08-10 16:20', operator: 'admin', comment: '新增限流配置', added: 1, modified: 0, removed: 0, snapshot: { 'server.port': '8080', 'feature.newLogin.enabled': 'false', 'rate.limit.qps': '500' } },
];
/* =========================================================================
* 通用小组件
* ========================================================================= */
function Field({ label, required, children, composite = false }) {
const content = (
<>
<span className="block text-xs font-medium text-slate-500 mb-1">
{label}
{required && <span className="text-rose-500 ml-0.5">*</span>}
</span>
{children}
</>
);
// Monaco 等复合控件内部包含自己的 textarea / 可聚焦元素。
// 不能包在 <label> 中,否则浏览器会在点击后执行 label 的默认聚焦行为,
// 抢走 Monaco 自己刚获得的焦点,表现为“点击编辑器立即失焦”。
return composite ? <div className="block">{content}</div> : <label className="block">{content}</label>;
}
function inputCls(disabled) {
return `w-full rounded-lg border px-3 py-1.5 text-sm outline-none transition ${
disabled ? 'bg-slate-50 text-slate-400 border-slate-200 cursor-not-allowed' : 'border-slate-200 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100'
}`;
}
function ConfigValueEditor({ mode, namespaceType, value, releasedValue, onChange }) {
const language = MONACO_LANGUAGE[namespaceType] || 'plaintext';
const commonOptions = {
automaticLayout: true,
fontSize: 13,
lineHeight: 20,
minimap: { enabled: false },
scrollBeyondLastLine: false,
wordWrap: 'on',
tabSize: 2,
insertSpaces: true,
padding: { top: 12, bottom: 12 },
};
return (
<div className="overflow-hidden rounded-lg border border-slate-200 bg-white focus-within:border-indigo-400 focus-within:ring-2 focus-within:ring-indigo-100">
<div className="flex items-center justify-between gap-3 border-b border-slate-100 bg-slate-50 px-3 py-2 text-[11px] text-slate-400">
<span className={`rounded px-1.5 py-0.5 font-mono ${TYPE_BADGE[namespaceType] || TYPE_BADGE.properties}`}>
{namespaceType || 'properties'}
</span>
<span>{mode === 'edit' ? '左侧:已发布值 · 右侧:当前编辑值' : '编辑器语法跟随命名空间格式'}</span>
</div>
{mode === 'edit' ? (
<DiffEditor
key={`diff-${namespaceType}`}
height="300px"
original={releasedValue ?? ''}
modified={value ?? ''}
originalLanguage={language}
modifiedLanguage={language}
theme="vs"
options={{
...commonOptions,
renderSideBySide: true,
originalEditable: false,
readOnly: false,
}}
onMount={(diffEditor) => {
const modifiedEditor = diffEditor.getModifiedEditor();
modifiedEditor.onDidChangeModelContent(() => onChange(modifiedEditor.getValue()));
}}
/>
) : (
<Editor
key={`editor-${namespaceType}`}
height="260px"
language={language}
value={value ?? ''}
theme="vs"
options={commonOptions}
onChange={(nextValue) => onChange(nextValue ?? '')}
/>
)}
</div>
);
}
function EmptyHint({ text }) {
return <div className="bg-white rounded-xl border border-dashed border-slate-300 p-12 text-center text-sm text-slate-400">{text}</div>;
}
function Toast({ toast }) {
const tones = { success: 'bg-slate-900 text-white', danger: 'bg-rose-600 text-white' };
return (
<div className={`fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-2.5 rounded-lg shadow-lg text-sm flex items-center gap-2 ${tones[toast.tone] || tones.success}`}>
<Check size={16} /> {toast.msg}
</div>
);
}
function LoginPage({ onLogin }) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
async function submit(e) {
e.preventDefault();
setSubmitting(true);
setError('');
try {
await onLogin(username, password);
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
}
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center p-6">
<form onSubmit={submit} className="w-full max-w-sm rounded-2xl bg-white shadow-2xl p-7 space-y-5">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-indigo-600 text-white flex items-center justify-center"><LockKeyhole size={19} /></div>
<div><h1 className="font-semibold text-slate-900">登录配置中心</h1><p className="text-xs text-slate-400 mt-0.5">使</p></div>
</div>
<Field label="用户名" required><input autoFocus required value={username} onChange={(e) => setUsername(e.target.value)} className={inputCls()} autoComplete="username" /></Field>
<Field label="密码" required><input required type="password" value={password} onChange={(e) => setPassword(e.target.value)} className={inputCls()} autoComplete="current-password" /></Field>
{error && <p className="text-xs text-rose-600 bg-rose-50 rounded-lg px-3 py-2">{error}</p>}
<button disabled={submitting} className="w-full rounded-lg bg-indigo-600 text-white text-sm font-medium py-2.5 hover:bg-indigo-700 disabled:opacity-50">{submitting ? '登录中…' : '登录'}</button>
</form>
</div>
);
}
function ConfirmDialog({ confirm, onCancel }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" onClick={onCancel}>
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl shadow-xl w-full max-w-sm p-5">
<div className="flex items-start gap-3">
<div className="shrink-0 w-9 h-9 rounded-full bg-rose-50 text-rose-500 flex items-center justify-center">
<AlertTriangle size={18} />
</div>
<div>
<h3 className="text-sm font-semibold text-slate-800">{confirm.title || '确认操作'}</h3>
<p className="text-sm text-slate-500 mt-1">{confirm.message}</p>
</div>
</div>
<div className="mt-5 flex justify-end gap-2">
<button onClick={onCancel} className="px-3 py-1.5 text-sm rounded-lg text-slate-600 hover:bg-slate-100">取消</button>
<button onClick={confirm.onConfirm} className="px-3 py-1.5 text-sm rounded-lg bg-rose-600 text-white hover:bg-rose-700">
{confirm.confirmLabel || '确认删除'}
</button>
</div>
</div>
</div>
);
}
/* =========================================================================
* 新建 / 编辑 弹窗(应用、命名空间、环境、配置项 共用)
* ========================================================================= */
function EntityModal({ modal, apps, onClose, onSubmit }) {
const { entityType, mode, data, namespaceType = 'properties' } = modal;
const [form, setForm] = useState(() => data || {});
const set = (k, v) => setForm((f) => ({ ...f, [k]: v }));
const titles = {
app: mode === 'add' ? '新建应用' : '编辑应用',
namespace: mode === 'add' ? '新建命名空间' : '编辑命名空间',
environment: mode === 'add' ? '新建环境' : '编辑环境',
config: mode === 'add' ? '添加配置项' : '编辑配置项',
};
function handleSubmit(e) {
e.preventDefault();
onSubmit(form);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" onClick={onClose}>
<form onClick={(e) => e.stopPropagation()} onSubmit={handleSubmit} className={`bg-white rounded-xl shadow-xl w-full overflow-hidden ${entityType === 'config' ? 'max-w-4xl' : 'max-w-md'}`}>
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800">{titles[entityType]}</h3>
<button type="button" onClick={onClose} className="text-slate-400 hover:text-slate-600">
<X size={18} />
</button>
</div>
<div className="px-5 py-4 space-y-4 max-h-[70vh] overflow-y-auto">
{entityType === 'app' && (
<>
<Field label="应用 ID" required>
<input required disabled={mode === 'edit'} value={form.code || ''} onChange={(e) => set('code', e.target.value)} placeholder="如 user-service" className={inputCls(mode === 'edit') + ' font-mono'} />
</Field>
<Field label="应用名称" required>
<input required value={form.name || ''} onChange={(e) => set('name', e.target.value)} className={inputCls()} />
</Field>
<Field label="所属部门">
<input value={form.department || ''} onChange={(e) => set('department', e.target.value)} className={inputCls()} />
</Field>
<Field label="负责人">
<input value={form.owner || ''} onChange={(e) => set('owner', e.target.value)} className={inputCls()} />
</Field>
<Field label="描述">
<textarea value={form.desc || ''} onChange={(e) => set('desc', e.target.value)} rows={2} className={inputCls()} />
</Field>
</>
)}
{entityType === 'namespace' && (
<>
<Field label="所属应用" required>
<select required disabled={mode === 'edit'} value={form.appId || ''} onChange={(e) => set('appId', e.target.value)} className={inputCls(mode === 'edit')}>
<option value="" disabled>请选择应用</option>
{apps.map((a) => (
<option key={a.id} value={a.id}>{a.name}</option>
))}
</select>
</Field>
<Field label="命名空间名称" required>
<input required disabled={mode === 'edit'} value={form.name || ''} onChange={(e) => set('name', e.target.value)} placeholder="如 application" className={inputCls(mode === 'edit') + ' font-mono'} />
</Field>
<Field label="格式类型" required>
<select required value={form.type || 'properties'} onChange={(e) => set('type', e.target.value)} className={inputCls()}>
<option value="properties">properties</option>
<option value="yaml">yaml</option>
<option value="json">json</option>
<option value="xml">xml</option>
</select>
</Field>
<Field label="描述">
<textarea value={form.desc || ''} onChange={(e) => set('desc', e.target.value)} rows={2} className={inputCls()} />
</Field>
</>
)}
{entityType === 'environment' && (
<>
<Field label="环境编码" required>
<input required disabled={mode === 'edit'} value={form.code || ''} onChange={(e) => set('code', e.target.value.toUpperCase())} placeholder="如 DEV" className={inputCls(mode === 'edit') + ' font-mono'} />
</Field>
<Field label="环境名称" required>
<input required value={form.name || ''} onChange={(e) => set('name', e.target.value)} className={inputCls()} />
</Field>
<Field label="排序">
<input type="number" value={form.order ?? 0} onChange={(e) => set('order', Number(e.target.value))} className={inputCls()} />
</Field>
<Field label="描述">
<textarea value={form.desc || ''} onChange={(e) => set('desc', e.target.value)} rows={2} className={inputCls()} />
</Field>
</>
)}
{entityType === 'config' && (
<>
<Field label="Key" required>
<input required disabled={mode === 'edit'} value={form.key || ''} onChange={(e) => set('key', e.target.value)} placeholder="如 server.port" className={inputCls(mode === 'edit') + ' font-mono'} />
</Field>
<Field label="Value" required composite>
<ConfigValueEditor
mode={mode}
namespaceType={namespaceType}
value={form.value || ''}
releasedValue={form.releasedValue}
onChange={(value) => set('value', value)}
/>
</Field>
<Field label="备注">
<input value={form.comment || ''} onChange={(e) => set('comment', e.target.value)} className={inputCls()} />
</Field>
</>
)}
</div>
<div className="px-5 py-3 border-t border-slate-100 flex justify-end gap-2 bg-slate-50">
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm rounded-lg text-slate-600 hover:bg-slate-100">取消</button>
<button type="submit" className="px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700">保存</button>
</div>
</form>
</div>
);
}
/* =========================================================================
* 发布 弹窗diff 预览)
* ========================================================================= */
function isPendingItem(c) {
return c.pendingDelete || c.releasedValue === null || c.value !== c.releasedValue;
}
function PublishModal({ scopeConfigs, appName, nsName, envCode, comment, setComment, onCancel, onConfirm }) {
const pending = scopeConfigs.filter(isPendingItem);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" onClick={onCancel}>
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl shadow-xl w-full max-w-lg overflow-hidden flex flex-col max-h-[85vh]">
<div className="px-5 py-4 border-b border-slate-100">
<h3 className="text-sm font-semibold text-slate-800">发布配置</h3>
<p className="text-xs text-slate-400 mt-0.5">{appName} / {nsName} / {envCode} · {pending.length} 项变更</p>
</div>
<div className="px-5 py-4 overflow-y-auto flex-1 space-y-1.5 bg-slate-900 font-mono text-xs leading-6">
{pending.length === 0 && <div className="text-slate-500">没有待发布的变更</div>}
{pending.map((c) => {
if (c.pendingDelete) {
return <div key={c.id} className="text-rose-400">- {c.key} = {c.releasedValue}</div>;
}
if (c.releasedValue === null) {
return <div key={c.id} className="text-emerald-400">+ {c.key} = {c.value}</div>;
}
return (
<div key={c.id}>
<div className="text-rose-400">- {c.key} = {c.releasedValue}</div>
<div className="text-emerald-400">+ {c.key} = {c.value}</div>
</div>
);
})}
</div>
<div className="px-5 py-4 border-t border-slate-100 space-y-3">
<Field label="发布说明">
<input value={comment} onChange={(e) => setComment(e.target.value)} placeholder="本次发布的变更说明(选填)" className={inputCls()} />
</Field>
<div className="flex justify-end gap-2">
<button onClick={onCancel} className="px-3 py-1.5 text-sm rounded-lg text-slate-600 hover:bg-slate-100">取消</button>
<button onClick={onConfirm} disabled={pending.length === 0} className="px-3 py-1.5 text-sm rounded-lg bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40">确认发布</button>
</div>
</div>
</div>
</div>
);
}
/* =========================================================================
* 发布历史 弹窗
* ========================================================================= */
function HistoryModal({ releases, appName, nsName, envCode, onClose, onRollback }) {
const sorted = [...releases].sort((a, b) => b.version - a.version);
const latestVersion = sorted[0]?.version;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" onClick={onClose}>
<div onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl shadow-xl w-full max-w-lg overflow-hidden flex flex-col max-h-[85vh]">
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-slate-800">发布历史</h3>
<p className="text-xs text-slate-400 mt-0.5">{appName} / {nsName} / {envCode}</p>
</div>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600">
<X size={18} />
</button>
</div>
<div className="overflow-y-auto flex-1 divide-y divide-slate-100">
{sorted.length === 0 && <div className="px-5 py-10 text-center text-sm text-slate-400">暂无发布记录</div>}
{sorted.map((r) => (
<div key={r.id} className="px-5 py-3.5 flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className="px-2 py-0.5 rounded bg-indigo-50 text-indigo-600 text-xs font-mono font-medium">v{r.version}</span>
<span className="text-xs text-slate-400">{r.time}</span>
<span className="text-xs text-slate-400">by {r.operator}</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${r.status === 'applied' ? 'bg-emerald-50 text-emerald-600' : r.status === 'failed' ? 'bg-rose-50 text-rose-600' : 'bg-amber-50 text-amber-600'}`}>
{r.status === 'applied' ? '已生效' : r.status === 'failed' ? '失败' : '下发中'}
</span>
</div>
<p className="text-sm text-slate-700 mt-1">{r.comment || '无发布说明'}</p>
<p className="text-xs text-slate-400 mt-1">
<span className="text-emerald-600">+{r.added}</span>{' '}
<span className="text-amber-600">~{r.modified}</span>{' '}
<span className="text-rose-500">-{r.removed}</span>
</p>
</div>
{r.version !== latestVersion && (
<button onClick={() => onRollback(r)} className="shrink-0 flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-slate-200 text-xs text-slate-600 hover:bg-slate-50">
<RotateCcw size={13} /> 回滚
</button>
)}
</div>
))}
</div>
</div>
</div>
);
}
/* =========================================================================
* 侧边导航 / 顶部栏
* ========================================================================= */
const NAV_ITEMS = [
{ key: 'configs', label: '配置管理', icon: SlidersHorizontal },
{ key: 'gray', label: '灰度规则', icon: GitBranch },
{ key: 'apps', label: '应用管理', icon: Package },
{ key: 'namespaces', label: '命名空间管理', icon: FolderTree },
{ key: 'environments', label: '环境管理', icon: Globe },
{ key: 'users', label: '用户与权限', icon: Users, adminOnly: true },
{ key: 'audit', label: '审计日志', icon: History, adminOnly: true },
];
function Sidebar({ nav, setNav, identity, onLogout }) {
const items = NAV_ITEMS.filter((item) => !item.adminOnly || identity?.user?.isAdmin);
return (
<aside className="w-56 shrink-0 bg-slate-900 text-slate-300 flex flex-col">
<div className="h-14 flex items-center gap-2 px-5 border-b border-slate-800">
<div className="w-7 h-7 rounded-md bg-indigo-500 flex items-center justify-center text-white font-bold text-sm">C</div>
<span className="text-white font-semibold text-sm tracking-wide">配置中心</span>
</div>
<nav className="flex-1 py-3 px-2 space-y-1">
{items.map((item) => {
const Icon = item.icon;
const active = nav === item.key;
return (
<button
key={item.key}
onClick={() => setNav(item.key)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm transition ${active ? 'bg-indigo-600 text-white' : 'hover:bg-slate-800 hover:text-white'}`}
>
<Icon size={16} /> {item.label}
</button>
);
})}
</nav>
<div className="px-3 py-3 border-t border-slate-800">
<div className="px-2 text-xs text-slate-400 truncate">{identity?.user?.displayName || identity?.user?.username || 'Config Center'}</div>
<div className="px-2 mt-0.5 text-[10px] text-slate-600">{identity?.user?.isAdmin ? '系统管理员' : '应用成员'}</div>
{identity?.authEnabled && (
<button onClick={onLogout} className="mt-2 w-full flex items-center gap-2 px-2 py-1.5 rounded text-xs text-slate-400 hover:bg-slate-800 hover:text-white">
<LogOut size={13} /> 退出登录
</button>
)}
</div>
</aside>
);
}
const PAGE_META = {
configs: { title: '配置管理', desc: '按应用 / 环境 / 命名空间管理配置项,支持发布与回滚' },
gray: { title: '灰度规则', desc: '按 IP、实例或稳定百分比覆盖已发布配置' },
apps: { title: '应用管理', desc: '管理接入配置中心的应用' },
namespaces: { title: '命名空间管理', desc: '管理各应用下的配置命名空间' },
environments: { title: '环境管理', desc: '管理配置生效的环境' },
users: { title: '用户与权限', desc: '管理用户生命周期与应用级 viewer / app-owner 角色' },
audit: { title: '审计日志', desc: '按操作者、动作、资源类型和时间范围追溯管理操作' },
};
function TopBar({ nav }) {
const meta = PAGE_META[nav];
return (
<header className="h-14 shrink-0 flex items-center justify-between px-6 border-b border-slate-200 bg-white">
<h1 className="text-sm font-semibold text-slate-800">{meta.title}</h1>
<div className="text-xs text-slate-400 hidden sm:block">{meta.desc}</div>
</header>
);
}
/* =========================================================================
* 应用管理 页面
* ========================================================================= */
function AppsPage({ apps, namespaces, search, setSearch, onAdd, onEdit, onDelete }) {
const kw = search.toLowerCase();
const filtered = apps.filter((a) => `${a.code}${a.name}${a.department}${a.owner}`.toLowerCase().includes(kw));
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="relative w-72 max-w-full">
<Search size={15} className="absolute left-3 top-2.5 text-slate-400" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索应用 ID / 名称 / 负责人" className="w-full pl-8 pr-3 py-2 text-sm rounded-lg border border-slate-200 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 outline-none" />
</div>
<button onClick={onAdd} className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700">
<Plus size={15} /> 新建应用
</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[760px]">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
<tr>
<th className="text-left font-medium px-4 py-2.5">应用 ID</th>
<th className="text-left font-medium px-4 py-2.5">应用名称</th>
<th className="text-left font-medium px-4 py-2.5">所属部门</th>
<th className="text-left font-medium px-4 py-2.5">负责人</th>
<th className="text-left font-medium px-4 py-2.5">命名空间</th>
<th className="text-left font-medium px-4 py-2.5">创建时间</th>
<th className="text-right font-medium px-4 py-2.5">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((a) => (
<tr key={a.id} className="hover:bg-slate-50/70">
<td className="px-4 py-2.5 font-mono text-xs text-slate-600">{a.code}</td>
<td className="px-4 py-2.5 font-medium text-slate-800">{a.name}</td>
<td className="px-4 py-2.5 text-slate-500">{a.department || '-'}</td>
<td className="px-4 py-2.5 text-slate-500">{a.owner || '-'}</td>
<td className="px-4 py-2.5 text-slate-500">{namespaces.filter((n) => n.appId === a.id).length}</td>
<td className="px-4 py-2.5 text-slate-400 text-xs">{a.createdAt}</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1">
<button onClick={() => onEdit(a)} className="p-1.5 rounded-md text-slate-400 hover:text-indigo-600 hover:bg-indigo-50"><Pencil size={14} /></button>
<button onClick={() => onDelete(a)} className="p-1.5 rounded-md text-slate-400 hover:text-rose-600 hover:bg-rose-50"><Trash2 size={14} /></button>
</div>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={7} className="px-4 py-10 text-center text-sm text-slate-400">没有找到匹配的应用</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
/* =========================================================================
* 命名空间管理 页面
* ========================================================================= */
function NamespacesPage({ namespaces, apps, search, setSearch, filterAppId, setFilterAppId, onAdd, onEdit, onDelete }) {
const appName = (id) => apps.find((a) => a.id === id)?.name || '未知应用';
const kw = search.toLowerCase();
const filtered = namespaces.filter((n) => (filterAppId === 'all' || n.appId === filterAppId) && `${n.name}${n.desc}`.toLowerCase().includes(kw));
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-wrap">
<select value={filterAppId} onChange={(e) => setFilterAppId(e.target.value)} className="px-3 py-2 text-sm rounded-lg border border-slate-200 outline-none focus:border-indigo-400">
<option value="all">全部应用</option>
{apps.map((a) => (<option key={a.id} value={a.id}>{a.name}</option>))}
</select>
<div className="relative w-64 max-w-full">
<Search size={15} className="absolute left-3 top-2.5 text-slate-400" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索命名空间" className="w-full pl-8 pr-3 py-2 text-sm rounded-lg border border-slate-200 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 outline-none" />
</div>
</div>
<button onClick={onAdd} className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700">
<Plus size={15} /> 新建命名空间
</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[640px]">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
<tr>
<th className="text-left font-medium px-4 py-2.5">所属应用</th>
<th className="text-left font-medium px-4 py-2.5">命名空间</th>
<th className="text-left font-medium px-4 py-2.5">格式</th>
<th className="text-left font-medium px-4 py-2.5">描述</th>
<th className="text-right font-medium px-4 py-2.5">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((n) => (
<tr key={n.id} className="hover:bg-slate-50/70">
<td className="px-4 py-2.5 text-slate-600">{appName(n.appId)}</td>
<td className="px-4 py-2.5 font-mono text-xs font-medium text-slate-800">{n.name}</td>
<td className="px-4 py-2.5"><span className={`px-2 py-0.5 rounded text-xs font-mono ${TYPE_BADGE[n.type]}`}>{n.type}</span></td>
<td className="px-4 py-2.5 text-slate-500">{n.desc || '-'}</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1">
<button onClick={() => onEdit(n)} className="p-1.5 rounded-md text-slate-400 hover:text-indigo-600 hover:bg-indigo-50"><Pencil size={14} /></button>
<button onClick={() => onDelete(n)} className="p-1.5 rounded-md text-slate-400 hover:text-rose-600 hover:bg-rose-50"><Trash2 size={14} /></button>
</div>
</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={5} className="px-4 py-10 text-center text-sm text-slate-400">没有找到匹配的命名空间</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
/* =========================================================================
* 环境管理 页面
* ========================================================================= */
function EnvironmentsPage({ environments, onAdd, onEdit, onDelete }) {
const sorted = [...environments].sort((a, b) => a.order - b.order);
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
<button onClick={onAdd} className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700">
<Plus size={15} /> 新建环境
</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[560px]">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
<tr>
<th className="text-left font-medium px-4 py-2.5 w-16">排序</th>
<th className="text-left font-medium px-4 py-2.5">环境编码</th>
<th className="text-left font-medium px-4 py-2.5">环境名称</th>
<th className="text-left font-medium px-4 py-2.5">描述</th>
<th className="text-right font-medium px-4 py-2.5">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{sorted.map((env) => {
const st = envStyle(env.code);
return (
<tr key={env.id} className="hover:bg-slate-50/70">
<td className="px-4 py-2.5 text-slate-400">{env.order}</td>
<td className="px-4 py-2.5">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded border text-xs font-mono font-medium ${st.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${st.dot}`} />{env.code}
</span>
</td>
<td className="px-4 py-2.5 font-medium text-slate-800">{env.name}</td>
<td className="px-4 py-2.5 text-slate-500">{env.desc || '-'}</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1">
<button onClick={() => onEdit(env)} className="p-1.5 rounded-md text-slate-400 hover:text-indigo-600 hover:bg-indigo-50"><Pencil size={14} /></button>
<button onClick={() => onDelete(env)} className="p-1.5 rounded-md text-slate-400 hover:text-rose-600 hover:bg-rose-50"><Trash2 size={14} /></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}
/* =========================================================================
* 配置管理 页面(核心)
* ========================================================================= */
function ConfigsPage(props) {
const {
apps, environments, namespaces, configs,
selectedAppId, setSelectedAppId, selectedEnvId, setSelectedEnvId, selectedNsId, setSelectedNsId,
search, setSearch, onAddConfig, onEditConfig, onToggleDelete, onOpenPublish, onOpenHistory,
} = props;
if (apps.length === 0) {
return <EmptyHint text="请先在「应用管理」中创建应用" />;
}
const appNamespaces = namespaces.filter((n) => n.appId === selectedAppId);
const sortedEnvs = [...environments].sort((a, b) => a.order - b.order);
const scopeConfigs = configs.filter((c) => c.appId === selectedAppId && c.nsId === selectedNsId && c.envId === selectedEnvId);
const pendingCount = scopeConfigs.filter(isPendingItem).length;
const kw = search.toLowerCase();
const filtered = scopeConfigs.filter((c) => c.key.toLowerCase().includes(kw));
return (
<div className="space-y-4">
<div className="bg-white rounded-xl border border-slate-200 p-4 space-y-3">
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-slate-400 w-16 shrink-0">应用</span>
<select value={selectedAppId} onChange={(e) => setSelectedAppId(e.target.value)} className="px-3 py-1.5 text-sm rounded-lg border border-slate-200 outline-none focus:border-indigo-400 min-w-[220px]">
{apps.map((a) => (<option key={a.id} value={a.id}>{a.name} ({a.code})</option>))}
</select>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-slate-400 w-16 shrink-0">环境</span>
<div className="flex gap-1.5 flex-wrap">
{sortedEnvs.map((env) => {
const st = envStyle(env.code);
const active = env.id === selectedEnvId;
return (
<button
key={env.id}
onClick={() => setSelectedEnvId(env.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-mono font-medium border transition ${active ? st.badge + ' ring-1 ring-inset ring-current' : 'border-slate-200 text-slate-500 hover:bg-slate-50'}`}
>
{env.code}
</button>
);
})}
</div>
</div>
<div className="flex items-center gap-3">
<span className="text-xs font-medium text-slate-400 w-16 shrink-0">命名空间</span>
{appNamespaces.length === 0 ? (
<span className="text-xs text-slate-400">该应用暂无命名空间请先在命名空间管理中创建</span>
) : (
<div className="flex gap-1.5 flex-wrap">
{appNamespaces.map((ns) => {
const active = ns.id === selectedNsId;
return (
<button
key={ns.id}
onClick={() => setSelectedNsId(ns.id)}
className={`px-3 py-1.5 rounded-lg text-xs font-mono font-medium border transition ${active ? 'bg-indigo-600 text-white border-indigo-600' : 'border-slate-200 text-slate-500 hover:bg-slate-50'}`}
>
{ns.name}
</button>
);
})}
</div>
)}
</div>
</div>
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="relative w-64 max-w-full">
<Search size={15} className="absolute left-3 top-2.5 text-slate-400" />
<input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="搜索 Key" className="w-full pl-8 pr-3 py-2 text-sm rounded-lg border border-slate-200 focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 outline-none" />
</div>
<div className="flex items-center gap-2 flex-wrap">
<button onClick={onOpenHistory} className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-slate-200 text-slate-600 hover:bg-slate-50">
<History size={15} /> 发布历史
</button>
<button onClick={onAddConfig} disabled={!selectedNsId} className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-slate-200 text-slate-600 hover:bg-slate-50 disabled:opacity-40">
<Plus size={15} /> 添加配置
</button>
<button
onClick={onOpenPublish}
disabled={pendingCount === 0}
className={`flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg font-medium transition ${pendingCount > 0 ? 'bg-emerald-600 text-white hover:bg-emerald-700' : 'bg-slate-100 text-slate-400 cursor-not-allowed'}`}
>
<UploadCloud size={15} /> 发布{pendingCount > 0 ? ` (${pendingCount})` : ''}
</button>
</div>
</div>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[820px]">
<thead className="bg-slate-50 text-slate-500 text-xs uppercase tracking-wide">
<tr>
<th className="text-left font-medium px-4 py-2.5 w-1/5">Key</th>
<th className="text-left font-medium px-4 py-2.5 w-1/3">Value</th>
<th className="text-left font-medium px-4 py-2.5">备注</th>
<th className="text-left font-medium px-4 py-2.5">状态</th>
<th className="text-left font-medium px-4 py-2.5">更新时间</th>
<th className="text-right font-medium px-4 py-2.5">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filtered.map((c) => {
const pending = isPendingItem(c);
const label = c.pendingDelete ? '待删除' : c.releasedValue === null ? '待发布·新增' : c.value !== c.releasedValue ? '待发布·修改' : '已发布';
const badgeCls = c.pendingDelete ? 'bg-rose-50 text-rose-600' : pending ? 'bg-amber-50 text-amber-600' : 'bg-slate-100 text-slate-500';
return (
<tr key={c.id} className={`hover:bg-slate-50/70 ${c.pendingDelete ? 'opacity-60' : ''}`}>
<td className="px-4 py-2.5 font-mono text-xs font-medium text-slate-800 align-top">{c.key}</td>
<td className="px-4 py-2.5 font-mono text-xs text-slate-600 align-top whitespace-pre-wrap break-all">{c.value}</td>
<td className="px-4 py-2.5 text-slate-500 align-top">{c.comment || '-'}</td>
<td className="px-4 py-2.5 align-top"><span className={`px-2 py-0.5 rounded text-xs font-medium ${badgeCls}`}>{label}</span></td>
<td className="px-4 py-2.5 text-slate-400 text-xs align-top">{c.updatedAt}</td>
<td className="px-4 py-2.5 align-top">
<div className="flex justify-end gap-1">
{!c.pendingDelete && (
<button onClick={() => onEditConfig(c)} className="p-1.5 rounded-md text-slate-400 hover:text-indigo-600 hover:bg-indigo-50"><Pencil size={14} /></button>
)}
<button
onClick={() => onToggleDelete(c)}
title={c.pendingDelete ? '撤销删除' : '删除'}
className={`p-1.5 rounded-md ${c.pendingDelete ? 'text-amber-600 hover:bg-amber-50' : 'text-slate-400 hover:text-rose-600 hover:bg-rose-50'}`}
>
{c.pendingDelete ? <RotateCcw size={14} /> : <Trash2 size={14} />}
</button>
</div>
</td>
</tr>
);
})}
{filtered.length === 0 && (
<tr><td colSpan={6} className="px-4 py-10 text-center text-sm text-slate-400">{selectedNsId ? '暂无配置项,点击「添加配置」创建' : '请选择命名空间'}</td></tr>
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
/* =========================================================================
* 灰度规则
* ========================================================================= */
function grayTargetText(rule) {
if (rule.ruleType === 'ip') return (rule.ruleValue?.ips || []).join(', ');
if (rule.ruleType === 'instance') return (rule.ruleValue?.instances || []).join(', ');
return `${rule.ruleValue?.percentage ?? 0}% · salt=${rule.ruleValue?.salt || '-'}`;
}
function GrayRuleModal({ rule, scope, onClose, onSubmit }) {
const [form, setForm] = useState(() => ({
ruleType: rule?.ruleType || 'instance',
targets: rule ? grayTargetText(rule).replace(/% · salt=.*$/, '') : '',
salt: rule?.ruleValue?.salt || 'configcenter',
overrides: rule ? Object.entries(rule.overrides || {}).map(([key, value]) => `${key}=${value}`).join('\n') : '',
priority: rule?.priority ?? 0,
enabled: rule?.enabled ?? true,
desc: rule?.desc || '',
}));
const [error, setError] = useState('');
function submit(e) {
e.preventDefault();
try {
const overrides = {};
form.overrides.split('\n').map((line) => line.trim()).filter(Boolean).forEach((line) => {
const split = line.indexOf('=');
if (split < 1) throw new Error(`覆盖项格式错误:${line}`);
overrides[line.slice(0, split).trim()] = line.slice(split + 1);
});
if (!Object.keys(overrides).length) throw new Error('至少填写一个 key=value 覆盖项');
const values = form.targets.split(',').map((value) => value.trim()).filter(Boolean);
let ruleValue;
if (form.ruleType === 'ip') ruleValue = { ips: values };
else if (form.ruleType === 'instance') ruleValue = { instances: values };
else ruleValue = { percentage: Number(form.targets), salt: form.salt.trim() };
if (form.ruleType !== 'percentage' && values.length === 0) throw new Error('匹配目标不能为空');
onSubmit({
...scope,
ruleType: form.ruleType,
ruleValue,
overrides,
priority: Number(form.priority),
enabled: form.enabled,
desc: form.desc.trim(),
});
} catch (err) {
setError(err.message);
}
}
const set = (key, value) => setForm((current) => ({ ...current, [key]: value }));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" onClick={onClose}>
<form onSubmit={submit} onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl shadow-xl w-full max-w-lg overflow-hidden">
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<div><h3 className="text-sm font-semibold text-slate-800">{rule ? '编辑灰度规则' : '新建灰度规则'}</h3><p className="text-xs text-slate-400 mt-0.5"></p></div>
<button type="button" onClick={onClose} className="text-slate-400 hover:text-slate-600"><X size={18} /></button>
</div>
<div className="p-5 space-y-3">
<div className="grid grid-cols-2 gap-3">
<Field label="匹配类型" required>
<select value={form.ruleType} onChange={(e) => set('ruleType', e.target.value)} className={inputCls()}>
<option value="instance">实例 ID</option><option value="ip">IP / CIDR</option><option value="percentage"></option>
</select>
</Field>
<Field label="优先级"><input type="number" value={form.priority} onChange={(e) => set('priority', e.target.value)} className={inputCls()} /></Field>
</div>
<Field label={form.ruleType === 'percentage' ? '百分比0100' : form.ruleType === 'ip' ? 'IP / CIDR逗号分隔' : '实例 ID逗号分隔'} required>
<input value={form.targets} onChange={(e) => set('targets', e.target.value)} placeholder={form.ruleType === 'percentage' ? '10' : form.ruleType === 'ip' ? '10.0.0.1, 10.0.1.0/24' : 'order-1, order-2'} className={inputCls()} />
</Field>
{form.ruleType === 'percentage' && <Field label="哈希盐"><input value={form.salt} onChange={(e) => set('salt', e.target.value)} className={inputCls()} /></Field>}
<Field label="配置覆盖(每行 key=value" required><textarea rows={5} value={form.overrides} onChange={(e) => set('overrides', e.target.value)} placeholder={'feature.checkout=true\nrequest.timeout=2000'} className={`${inputCls()} font-mono`} /></Field>
<Field label="说明"><input value={form.desc} onChange={(e) => set('desc', e.target.value)} className={inputCls()} /></Field>
<label className="flex items-center gap-2 text-sm text-slate-600"><input type="checkbox" checked={form.enabled} onChange={(e) => set('enabled', e.target.checked)} /> 立即启用</label>
{error && <p className="text-xs text-rose-600">{error}</p>}
</div>
<div className="px-5 py-3 border-t border-slate-100 flex justify-end gap-2 bg-slate-50">
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm rounded-lg text-slate-600 hover:bg-slate-100">取消</button>
<button type="submit" className="px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700">保存规则</button>
</div>
</form>
</div>
);
}
function ScopeSelector({ apps, environments, namespaces, selectedAppId, setSelectedAppId, selectedEnvId, setSelectedEnvId, selectedNsId, setSelectedNsId }) {
const appNamespaces = namespaces.filter((item) => item.appId === selectedAppId);
return (
<div className="bg-white rounded-xl border border-slate-200 p-4 grid md:grid-cols-3 gap-3">
<Field label="应用"><select value={selectedAppId} onChange={(e) => setSelectedAppId(e.target.value)} className={inputCls()}>{apps.map((item) => <option key={item.id} value={item.id}>{item.name} ({item.code})</option>)}</select></Field>
<Field label="环境"><select value={selectedEnvId} onChange={(e) => setSelectedEnvId(e.target.value)} className={inputCls()}>{[...environments].sort((a, b) => a.order - b.order).map((item) => <option key={item.id} value={item.id}>{item.code} · {item.name}</option>)}</select></Field>
<Field label="命名空间"><select value={selectedNsId} onChange={(e) => setSelectedNsId(e.target.value)} className={inputCls()}>{appNamespaces.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></Field>
</div>
);
}
function GrayRulesPage(props) {
const { grayRules, onAdd, onEdit, onDelete } = props;
return (
<div className="space-y-4">
<ScopeSelector {...props} />
<div className="flex justify-end"><button onClick={onAdd} disabled={!props.selectedNsId} className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-40"><Plus size={15} /> 新建规则</button></div>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden overflow-x-auto">
<table className="w-full text-sm min-w-[820px]">
<thead className="bg-slate-50 text-slate-500 text-xs"><tr><th className="text-left font-medium px-4 py-2.5">状态</th><th className="text-left font-medium px-4 py-2.5"></th><th className="text-left font-medium px-4 py-2.5"></th><th className="text-left font-medium px-4 py-2.5"></th><th className="text-left font-medium px-4 py-2.5"></th><th className="text-right font-medium px-4 py-2.5"></th></tr></thead>
<tbody className="divide-y divide-slate-100">
{grayRules.map((rule) => <tr key={rule.id} className="hover:bg-slate-50/70">
<td className="px-4 py-3"><span className={`px-2 py-0.5 rounded text-xs ${rule.enabled ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-500'}`}>{rule.enabled ? '启用' : '停用'}</span></td>
<td className="px-4 py-3 font-mono text-xs text-indigo-600">{rule.ruleType}</td>
<td className="px-4 py-3 text-xs text-slate-600 max-w-xs break-all">{grayTargetText(rule)}{rule.desc && <div className="text-slate-400 mt-1">{rule.desc}</div>}</td>
<td className="px-4 py-3 font-mono text-xs text-slate-600">{Object.entries(rule.overrides || {}).map(([key, value]) => <div key={key}>{key}={value}</div>)}</td>
<td className="px-4 py-3 text-slate-500">{rule.priority}</td>
<td className="px-4 py-3"><div className="flex justify-end gap-1"><button onClick={() => onEdit(rule)} className="p-1.5 rounded-md text-slate-400 hover:text-indigo-600 hover:bg-indigo-50"><Pencil size={14} /></button><button onClick={() => onDelete(rule)} className="p-1.5 rounded-md text-slate-400 hover:text-rose-600 hover:bg-rose-50"><Trash2 size={14} /></button></div></td>
</tr>)}
{grayRules.length === 0 && <tr><td colSpan={6} className="px-4 py-10 text-center text-sm text-slate-400">当前范围暂无灰度规则</td></tr>}
</tbody>
</table>
</div>
</div>
);
}
/* =========================================================================
* 用户与应用权限
* ========================================================================= */
function UserRoleEditor({ user, apps, notify }) {
const [roles, setRoles] = useState([]);
const [appId, setAppId] = useState(apps[0]?.id || '');
const currentRole = roles.find((item) => String(item.appId) === String(appId))?.role || '';
useEffect(() => { api.listUserRoles(user.id).then(setRoles).catch((err) => notify(err.message, 'danger')); }, [user.id]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { if (!apps.find((item) => item.id === appId)) setAppId(apps[0]?.id || ''); }, [apps]); // eslint-disable-line react-hooks/exhaustive-deps
async function change(role) {
try {
if (role) await api.setUserRole(user.id, appId, role);
else await api.deleteUserRole(user.id, appId);
setRoles(await api.listUserRoles(user.id));
notify('应用权限已更新');
} catch (err) { notify(err.message, 'danger'); }
}
if (user.isAdmin) return <span className="inline-flex items-center gap-1 text-xs text-indigo-600"><ShieldCheck size={14} /> 全局管理员</span>;
return <div className="flex items-center gap-2"><select value={appId} onChange={(e) => setAppId(e.target.value)} className="px-2 py-1.5 text-xs rounded border border-slate-200">{apps.map((item) => <option key={item.id} value={item.id}>{item.code}</option>)}</select><select value={currentRole} onChange={(e) => change(e.target.value)} disabled={!appId || user.disabled} className="px-2 py-1.5 text-xs rounded border border-slate-200 disabled:bg-slate-50 disabled:text-slate-400"><option value=""></option><option value="viewer">viewer</option><option value="app-owner">app-owner</option></select></div>;
}
function CreateUserModal({ onClose, onCreated }) {
const [form, setForm] = useState({ username: '', displayName: '', password: '', isAdmin: false });
const [error, setError] = useState('');
async function submit(e) {
e.preventDefault();
try { await onCreated(form); } catch (err) { setError(err.message); }
}
const set = (key, value) => setForm((current) => ({ ...current, [key]: value }));
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" onClick={onClose}><form onSubmit={submit} onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl shadow-xl w-full max-w-md overflow-hidden"><div className="px-5 py-4 border-b border-slate-100 flex justify-between"><h3 className="text-sm font-semibold">创建用户</h3><button type="button" onClick={onClose}><X size={18} /></button></div><div className="p-5 space-y-3"><Field label="" required><input required value={form.username} onChange={(e) => set('username', e.target.value)} className={inputCls()} /></Field><Field label="" required><input required value={form.displayName} onChange={(e) => set('displayName', e.target.value)} className={inputCls()} /></Field><Field label=" 12 " required><input required minLength={12} type="password" value={form.password} onChange={(e) => set('password', e.target.value)} className={inputCls()} /></Field><label className="flex items-center gap-2 text-sm text-slate-600"><input type="checkbox" checked={form.isAdmin} onChange={(e) => set('isAdmin', e.target.checked)} /> </label>{error && <p className="text-xs text-rose-600">{error}</p>}</div><div className="px-5 py-3 bg-slate-50 flex justify-end gap-2"><button type="button" onClick={onClose} className="px-3 py-1.5 text-sm text-slate-600"></button><button className="px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white"></button></div></form></div>;
}
function PasswordResetModal({ user, onClose, onSubmit }) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
async function submit(e) {
e.preventDefault();
setSubmitting(true);
setError('');
try {
await onSubmit(password);
onClose();
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/40 backdrop-blur-sm p-4" onClick={onClose}>
<form onSubmit={submit} onClick={(e) => e.stopPropagation()} className="bg-white rounded-xl shadow-xl w-full max-w-md overflow-hidden">
<div className="px-5 py-4 border-b border-slate-100 flex items-center justify-between">
<div><h3 className="text-sm font-semibold">重置密码</h3><p className="text-xs text-slate-400 mt-0.5">{user.username} </p></div>
<button type="button" onClick={onClose}><X size={18} /></button>
</div>
<div className="p-5 space-y-3">
<Field label="新密码(至少 12 位)" required>
<input autoFocus required minLength={12} type="password" value={password} onChange={(e) => setPassword(e.target.value)} className={inputCls()} autoComplete="new-password" />
</Field>
{error && <p className="text-xs text-rose-600">{error}</p>}
</div>
<div className="px-5 py-3 bg-slate-50 flex justify-end gap-2">
<button type="button" onClick={onClose} className="px-3 py-1.5 text-sm text-slate-600">取消</button>
<button disabled={submitting} className="px-3 py-1.5 text-sm rounded-lg bg-indigo-600 text-white disabled:opacity-50">{submitting ? '提交中…' : '重置密码'}</button>
</div>
</form>
</div>
);
}
function UsersPage({ users, apps, identity, notify, onCreate, onToggleStatus, onResetPassword, onDelete }) {
const currentUserId = String(identity?.user?.userId || '');
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400">禁用密码重置和角色变化都会立即使该用户已签发的 JWT 失效</p>
<button onClick={onCreate} className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700"><Plus size={15} /> 创建用户</button>
</div>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden overflow-x-auto">
<table className="w-full text-sm min-w-[980px]">
<thead className="bg-slate-50 text-xs text-slate-500"><tr><th className="text-left font-medium px-4 py-2.5">用户名</th><th className="text-left font-medium px-4 py-2.5"></th><th className="text-left font-medium px-4 py-2.5"></th><th className="text-left font-medium px-4 py-2.5"></th><th className="text-left font-medium px-4 py-2.5"></th><th className="text-right font-medium px-4 py-2.5"></th></tr></thead>
<tbody className="divide-y divide-slate-100">
{users.map((user) => {
const isSelf = currentUserId && String(user.id) === currentUserId;
return (
<tr key={user.id} className="hover:bg-slate-50/60">
<td className="px-4 py-3 font-mono text-xs">{user.username}{isSelf && <span className="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-600">当前账号</span>}</td>
<td className="px-4 py-3">{user.displayName}</td>
<td className="px-4 py-3"><span className={`text-xs ${user.disabled ? 'text-rose-600' : 'text-emerald-600'}`}>{user.disabled ? '已禁用' : '正常'}</span></td>
<td className="px-4 py-3"><UserRoleEditor user={user} apps={apps} notify={notify} /></td>
<td className="px-4 py-3 text-xs text-slate-400 whitespace-nowrap">{user.updatedAt || user.createdAt || '-'}</td>
<td className="px-4 py-3">
<div className="flex justify-end gap-1.5">
<button disabled={isSelf && !user.disabled} onClick={() => onToggleStatus(user)} className="px-2 py-1.5 rounded-md border border-slate-200 text-xs text-slate-600 hover:bg-slate-50 disabled:opacity-35 disabled:cursor-not-allowed">{user.disabled ? '启用' : '禁用'}</button>
<button disabled={isSelf} onClick={() => onResetPassword(user)} className="px-2 py-1.5 rounded-md border border-slate-200 text-xs text-slate-600 hover:text-indigo-600 hover:bg-indigo-50 disabled:opacity-35 disabled:cursor-not-allowed">重置密码</button>
<button disabled={isSelf} onClick={() => onDelete(user)} className="p-1.5 rounded-md text-slate-400 hover:text-rose-600 hover:bg-rose-50 disabled:opacity-35 disabled:cursor-not-allowed" title="删除用户"><Trash2 size={14} /></button>
</div>
</td>
</tr>
);
})}
{users.length === 0 && <tr><td colSpan={6} className="px-4 py-10 text-center text-slate-400">暂无用户</td></tr>}
</tbody>
</table>
</div>
</div>
);
}
const AUDIT_ACTIONS = ['', 'create', 'update', 'delete', 'publish', 'rollback', 'enable', 'disable', 'reset_password'];
const AUDIT_TARGETS = ['', 'app', 'env', 'namespace', 'config', 'release', 'gray_rule', 'user', 'user_app_role'];
function AuditPage({ notify }) {
const emptyFilters = { actor: '', action: '', targetType: '', from: '', to: '' };
const [filters, setFilters] = useState(emptyFilters);
const [appliedFilters, setAppliedFilters] = useState(emptyFilters);
const [page, setPage] = useState({ items: [], nextCursor: null });
const [currentCursor, setCurrentCursor] = useState('');
const [backStack, setBackStack] = useState([]);
const [loading, setLoading] = useState(true);
function queryFilters(source, cursor) {
const result = { actor: source.actor, action: source.action, targetType: source.targetType, limit: 50 };
if (source.from) result.from = new Date(source.from).toISOString();
if (source.to) result.to = new Date(source.to).toISOString();
if (cursor) result.cursor = cursor;
return result;
}
async function load(cursor, source) {
setLoading(true);
try {
setPage(await api.listAuditLogs(queryFilters(source, cursor)));
setCurrentCursor(cursor || '');
} catch (err) {
notify(err.message, 'danger');
} finally {
setLoading(false);
}
}
useEffect(() => { load('', emptyFilters); }, []); // eslint-disable-line react-hooks/exhaustive-deps
function apply(e) {
e.preventDefault();
const next = { ...filters };
setAppliedFilters(next);
setBackStack([]);
load('', next);
}
function reset() {
const next = { ...emptyFilters };
setFilters(next);
setAppliedFilters(next);
setBackStack([]);
load('', next);
}
function nextPage() {
if (!page.nextCursor) return;
setBackStack((current) => [...current, currentCursor]);
load(page.nextCursor, appliedFilters);
}
function previousPage() {
if (!backStack.length) return;
const target = backStack[backStack.length - 1];
setBackStack((current) => current.slice(0, -1));
load(target, appliedFilters);
}
const set = (key, value) => setFilters((current) => ({ ...current, [key]: value }));
return (
<div className="space-y-4">
<form onSubmit={apply} className="bg-white rounded-xl border border-slate-200 p-4 grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 gap-3 items-end">
<Field label="操作者"><input value={filters.actor} onChange={(e) => set('actor', e.target.value)} placeholder="如 admin" className={inputCls()} /></Field>
<Field label="动作"><select value={filters.action} onChange={(e) => set('action', e.target.value)} className={inputCls()}>{AUDIT_ACTIONS.map((value) => <option key={value || 'all'} value={value}>{value || '全部动作'}</option>)}</select></Field>
<Field label="资源类型"><select value={filters.targetType} onChange={(e) => set('targetType', e.target.value)} className={inputCls()}>{AUDIT_TARGETS.map((value) => <option key={value || 'all'} value={value}>{value || '全部类型'}</option>)}</select></Field>
<Field label="开始时间"><input type="datetime-local" value={filters.from} onChange={(e) => set('from', e.target.value)} className={inputCls()} /></Field>
<Field label="结束时间"><input type="datetime-local" value={filters.to} onChange={(e) => set('to', e.target.value)} className={inputCls()} /></Field>
<div className="flex gap-2"><button type="button" onClick={reset} className="flex-1 px-3 py-2 text-sm rounded-lg border border-slate-200 text-slate-600 hover:bg-slate-50">重置</button><button className="flex-1 px-3 py-2 text-sm rounded-lg bg-indigo-600 text-white hover:bg-indigo-700"></button></div>
</form>
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden overflow-x-auto">
<table className="w-full text-sm min-w-[980px]">
<thead className="bg-slate-50 text-xs text-slate-500"><tr><th className="text-left font-medium px-4 py-2.5">时间</th><th className="text-left font-medium px-4 py-2.5"></th><th className="text-left font-medium px-4 py-2.5"></th><th className="text-left font-medium px-4 py-2.5"></th><th className="text-left font-medium px-4 py-2.5"></th></tr></thead>
<tbody className="divide-y divide-slate-100">
{!loading && page.items.map((log) => (
<tr key={log.id} className="align-top hover:bg-slate-50/60">
<td className="px-4 py-3 text-xs text-slate-500 whitespace-nowrap">{log.createdAt}</td>
<td className="px-4 py-3 font-mono text-xs text-slate-700">{log.actor}</td>
<td className="px-4 py-3"><span className="px-2 py-0.5 rounded bg-slate-100 text-slate-600 text-xs">{log.action}</span></td>
<td className="px-4 py-3 text-xs"><span className="font-mono text-indigo-600">{log.targetType}</span>{log.targetId && <span className="text-slate-400 ml-1">#{log.targetId}</span>}</td>
<td className="px-4 py-3 max-w-xl"><pre className="text-[11px] leading-4 text-slate-500 whitespace-pre-wrap break-all max-h-24 overflow-auto">{log.detail == null ? '-' : JSON.stringify(log.detail, null, 2)}</pre></td>
</tr>
))}
{loading && <tr><td colSpan={5} className="px-4 py-10 text-center text-slate-400">正在加载审计日志</td></tr>}
{!loading && page.items.length === 0 && <tr><td colSpan={5} className="px-4 py-10 text-center text-slate-400">没有符合条件的审计记录</td></tr>}
</tbody>
</table>
</div>
<div className="flex justify-end gap-2">
<button disabled={!backStack.length || loading} onClick={previousPage} className="px-3 py-1.5 text-sm rounded-lg border border-slate-200 text-slate-600 disabled:opacity-40">上一页</button>
<button disabled={!page.nextCursor || loading} onClick={nextPage} className="px-3 py-1.5 text-sm rounded-lg border border-slate-200 text-slate-600 disabled:opacity-40">下一页</button>
</div>
</div>
);
}
/* =========================================================================
* 主组件
* ========================================================================= */
export default function ConfigCenter() {
const [nav, setNav] = useState('configs');
const [environments, setEnvironments] = useState([]);
const [apps, setApps] = useState([]);
const [namespaces, setNamespaces] = useState([]);
const [configs, setConfigs] = useState([]);
const [releases, setReleases] = useState([]);
const [grayRules, setGrayRules] = useState([]);
const [users, setUsers] = useState([]);
const [identity, setIdentity] = useState(null);
const [appSearch, setAppSearch] = useState('');
const [nsSearch, setNsSearch] = useState('');
const [nsFilterApp, setNsFilterApp] = useState('all');
const [configSearch, setConfigSearch] = useState('');
const [selectedAppId, setSelectedAppId] = useState('');
const [selectedEnvId, setSelectedEnvId] = useState('');
const [selectedNsId, setSelectedNsId] = useState('');
const [modal, setModal] = useState(null);
const [confirmState, setConfirmState] = useState(null);
const [toast, setToast] = useState(null);
const [publishOpen, setPublishOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [grayModal, setGrayModal] = useState(null);
const [createUserOpen, setCreateUserOpen] = useState(false);
const [passwordResetUser, setPasswordResetUser] = useState(null);
const [releaseComment, setReleaseComment] = useState('');
const [loading, setLoading] = useState(true);
const [authRequired, setAuthRequired] = useState(false);
useEffect(() => {
let active = true;
api.bootstrap()
.then((data) => {
if (!active) return;
setApps(data.apps);
setEnvironments(data.environments);
setNamespaces(data.namespaces);
setIdentity(data.identity);
})
.catch((err) => {
if (!active) return;
if (err.status === 401) setAuthRequired(true);
else notify(err.message, 'danger');
})
.finally(() => active && setLoading(false));
return () => { active = false; };
}, []);
useEffect(() => api.onUnauthorized(() => setAuthRequired(true)), []);
useEffect(() => {
if (!selectedAppId || !selectedNsId || !selectedEnvId) {
setConfigs([]);
setReleases([]);
setGrayRules([]);
return undefined;
}
let active = true;
Promise.all([
api.listConfigItems(selectedAppId, selectedNsId, selectedEnvId),
api.listReleases(selectedAppId, selectedNsId, selectedEnvId),
api.listGrayRules(selectedAppId, selectedNsId, selectedEnvId),
]).then(([nextConfigs, nextReleases, nextGrayRules]) => {
if (!active) return;
setConfigs(nextConfigs);
setReleases(nextReleases);
setGrayRules(nextGrayRules);
}).catch((err) => active && notify(err.message, 'danger'));
return () => { active = false; };
}, [selectedAppId, selectedNsId, selectedEnvId]);
useEffect(() => {
if (nav !== 'users' || !identity?.user?.isAdmin) return;
api.listUsers().then(setUsers).catch((err) => notify(err.message, 'danger'));
}, [nav, identity?.user?.isAdmin]); // eslint-disable-line react-hooks/exhaustive-deps
// 保持所选应用 / 环境 / 命名空间在数据变化后依然有效
useEffect(() => {
if (!apps.length) setSelectedAppId('');
else if (!apps.find((a) => a.id === selectedAppId)) setSelectedAppId(apps[0].id);
}, [apps]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!environments.length) setSelectedEnvId('');
else if (!environments.find((e) => e.id === selectedEnvId)) setSelectedEnvId([...environments].sort((a, b) => a.order - b.order)[0].id);
}, [environments]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const appNs = namespaces.filter((n) => n.appId === selectedAppId);
if (!appNs.find((n) => n.id === selectedNsId)) {
setSelectedNsId(appNs[0]?.id || '');
}
}, [selectedAppId, namespaces]); // eslint-disable-line react-hooks/exhaustive-deps
function notify(msg, tone) {
setToast({ msg, tone });
setTimeout(() => setToast(null), 2600);
}
async function reloadCatalog() {
const data = await api.bootstrap();
setApps(data.apps);
setEnvironments(data.environments);
setNamespaces(data.namespaces);
setIdentity(data.identity);
}
async function login(username, password) {
const result = await api.login(username, password);
api.setToken(result.token);
setLoading(true);
try {
await reloadCatalog();
setAuthRequired(false);
} catch (err) {
api.setToken('');
throw err;
} finally {
setLoading(false);
}
}
async function reloadScope() {
if (!selectedAppId || !selectedNsId || !selectedEnvId) return;
const [nextConfigs, nextReleases, nextGrayRules] = await Promise.all([
api.listConfigItems(selectedAppId, selectedNsId, selectedEnvId),
api.listReleases(selectedAppId, selectedNsId, selectedEnvId),
api.listGrayRules(selectedAppId, selectedNsId, selectedEnvId),
]);
setConfigs(nextConfigs);
setReleases(nextReleases);
setGrayRules(nextGrayRules);
}
function logout() {
api.setToken('');
setIdentity(null);
setAuthRequired(true);
}
/* ---------- 应用 CRUD ---------- */
function openAddApp() { setModal({ entityType: 'app', mode: 'add', data: {} }); }
function openEditApp(a) { setModal({ entityType: 'app', mode: 'edit', data: { ...a } }); }
function deleteApp(a) {
setConfirmState({
title: '删除应用',
message: `删除应用「${a.name}」将同时删除其下所有命名空间与配置项,此操作不可撤销。`,
onConfirm: async () => {
try {
await api.deleteApplication(a.id);
await reloadCatalog();
setConfirmState(null);
notify('应用已删除', 'danger');
} catch (err) {
notify(err.message, 'danger');
}
},
});
}
/* ---------- 命名空间 CRUD ---------- */
function openAddNamespace() { setModal({ entityType: 'namespace', mode: 'add', data: { appId: selectedAppId || apps[0]?.id, type: 'properties' } }); }
function openEditNamespace(n) { setModal({ entityType: 'namespace', mode: 'edit', data: { ...n } }); }
function deleteNamespace(n) {
setConfirmState({
title: '删除命名空间',
message: `删除命名空间「${n.name}」将同时删除其下所有配置项,此操作不可撤销。`,
onConfirm: async () => {
try {
await api.deleteNamespace(n.id);
await reloadCatalog();
setConfirmState(null);
notify('命名空间已删除', 'danger');
} catch (err) {
notify(err.message, 'danger');
}
},
});
}
/* ---------- 环境 CRUD ---------- */
function openAddEnvironment() { setModal({ entityType: 'environment', mode: 'add', data: { order: environments.length + 1 } }); }
function openEditEnvironment(env) { setModal({ entityType: 'environment', mode: 'edit', data: { ...env } }); }
function deleteEnvironment(env) {
setConfirmState({
title: '删除环境',
message: `删除环境「${env.name}」将同时删除该环境下的所有配置项与发布记录,此操作不可撤销。`,
onConfirm: async () => {
try {
await api.deleteEnvironment(env.id);
await reloadCatalog();
setConfirmState(null);
notify('环境已删除', 'danger');
} catch (err) {
notify(err.message, 'danger');
}
},
});
}
/* ---------- 配置项 CRUD ---------- */
function openAddConfig() {
if (!selectedNsId) return;
const namespaceType = namespaces.find((item) => item.id === selectedNsId)?.type || 'properties';
setModal({ entityType: 'config', mode: 'add', namespaceType, data: {} });
}
function openEditConfig(c) {
const namespaceType = namespaces.find((item) => item.id === c.nsId)?.type || 'properties';
setModal({ entityType: 'config', mode: 'edit', namespaceType, data: { ...c } });
}
async function toggleDeleteConfig(c) {
try {
if (c.pendingDelete) await api.restoreConfigItem(c.id);
else await api.deleteConfigItem(c.id);
await reloadScope();
} catch (err) {
notify(err.message, 'danger');
}
}
async function saveGrayRule(input) {
try {
if (grayModal?.rule) await api.updateGrayRule(grayModal.rule.id, input);
else await api.createGrayRule(input);
setGrayModal(null);
await reloadScope();
notify(grayModal?.rule ? '灰度规则已更新' : '灰度规则已创建');
} catch (err) { notify(err.message, 'danger'); }
}
function deleteGrayRule(rule) {
setConfirmState({
title: '删除灰度规则',
message: `确定删除规则「${rule.desc || `#${rule.id}`}」吗?匹配该规则的实例将立即恢复基础配置。`,
onConfirm: async () => {
try { await api.deleteGrayRule(rule.id); setConfirmState(null); await reloadScope(); notify('灰度规则已删除', 'danger'); }
catch (err) { notify(err.message, 'danger'); }
},
});
}
async function reloadUsers() {
setUsers(await api.listUsers());
}
async function createUser(input) {
await api.createUser(input);
await reloadUsers();
setCreateUserOpen(false);
notify('用户创建成功');
}
function toggleUserStatus(user) {
const disabling = !user.disabled;
setConfirmState({
title: disabling ? '禁用用户' : '启用用户',
message: disabling
? `禁用「${user.username}」后,该用户现有登录令牌会立即失效。`
: `确定重新启用「${user.username}」吗?`,
confirmLabel: disabling ? '确认禁用' : '确认启用',
onConfirm: async () => {
try {
await api.setUserStatus(user.id, disabling);
await reloadUsers();
setConfirmState(null);
notify(disabling ? '用户已禁用' : '用户已启用', disabling ? 'danger' : 'success');
} catch (err) { notify(err.message, 'danger'); }
},
});
}
function deleteUser(user) {
setConfirmState({
title: '删除用户',
message: `删除「${user.username}」会同时清理其应用角色,且无法恢复。`,
confirmLabel: '确认删除',
onConfirm: async () => {
try {
await api.deleteUser(user.id);
await reloadUsers();
setConfirmState(null);
notify('用户已删除', 'danger');
} catch (err) { notify(err.message, 'danger'); }
},
});
}
async function resetUserPassword(password) {
if (!passwordResetUser) return;
await api.resetUserPassword(passwordResetUser.id, password);
await reloadUsers();
notify('密码已重置,该用户现有登录令牌已失效');
}
/* ---------- 弹窗提交统一分发 ---------- */
async function handleModalSubmit(form) {
if (!modal) return;
const { entityType, mode } = modal;
try {
if (entityType === 'app') {
if (!form.code || !form.name) { notify('请填写应用 ID 与名称', 'danger'); return; }
if (mode === 'add') await api.createApplication(form);
else await api.updateApplication(form.id, form);
await reloadCatalog();
notify(mode === 'add' ? '应用创建成功' : '应用已更新');
}
if (entityType === 'namespace') {
if (!form.appId || !form.name) { notify('请选择应用并填写命名空间名称', 'danger'); return; }
if (mode === 'add') await api.createNamespace(form);
else await api.updateNamespace(form.id, form);
await reloadCatalog();
notify(mode === 'add' ? '命名空间创建成功' : '命名空间已更新');
}
if (entityType === 'environment') {
if (!form.code || !form.name) { notify('请填写环境编码与名称', 'danger'); return; }
if (mode === 'add') await api.createEnvironment(form);
else await api.updateEnvironment(form.id, form);
await reloadCatalog();
notify(mode === 'add' ? '环境创建成功' : '环境已更新');
}
if (entityType === 'config') {
if (!form.value || (mode === 'add' && !form.key)) { notify('请填写 Key 和 Value', 'danger'); return; }
if (mode === 'add') {
await api.createConfigItem({ ...form, appId: selectedAppId, nsId: selectedNsId, envId: selectedEnvId });
} else {
await api.updateConfigItem(form.id, form);
}
await reloadScope();
notify(mode === 'add' ? '配置项已添加,待发布生效' : '配置项已修改,待发布生效');
}
setModal(null);
} catch (err) {
notify(err.message, 'danger');
}
}
/* ---------- 当前选中范围 ---------- */
const scopeConfigs = configs.filter((c) => c.appId === selectedAppId && c.nsId === selectedNsId && c.envId === selectedEnvId);
const scopeReleases = releases.filter((r) => r.appId === selectedAppId && r.nsId === selectedNsId && r.envId === selectedEnvId);
const selectedApp = apps.find((a) => a.id === selectedAppId);
const selectedNs = namespaces.find((n) => n.id === selectedNsId);
const selectedEnv = environments.find((e) => e.id === selectedEnvId);
/* ---------- 发布 ---------- */
async function confirmPublish() {
const pending = scopeConfigs.filter(isPendingItem);
if (pending.length === 0) return;
try {
const release = await api.publish({ appId: selectedAppId, nsId: selectedNsId, envId: selectedEnvId, comment: releaseComment });
setPublishOpen(false);
setReleaseComment('');
await reloadScope();
notify(`发布 v${release.version} 已提交,正在下发到运行时`);
window.setTimeout(() => reloadScope().catch(() => {}), 1000);
} catch (err) {
notify(err.message, 'danger');
}
}
/* ---------- 回滚 ---------- */
function rollbackTo(release) {
setConfirmState({
title: '回滚确认',
message: `确定要回滚到 v${release.version} 吗?当前配置将被覆盖,回滚会生成一条新的发布记录。`,
confirmLabel: '确认回滚',
onConfirm: async () => {
try {
const newRelease = await api.rollback({
appId: release.appId,
nsId: release.nsId,
envId: release.envId,
targetVersion: release.version,
});
await reloadScope();
setConfirmState(null);
setHistoryOpen(false);
notify(`回滚版本 v${newRelease.version} 已提交`);
window.setTimeout(() => reloadScope().catch(() => {}), 1000);
} catch (err) {
notify(err.message, 'danger');
}
},
});
}
if (authRequired) return <LoginPage onLogin={login} />;
return (
<div className="h-screen bg-slate-50 font-sans text-slate-900 flex overflow-hidden">
<Sidebar nav={nav} setNav={setNav} identity={identity} onLogout={logout} />
<div className="flex-1 flex flex-col min-w-0">
<TopBar nav={nav} />
<main className="flex-1 overflow-auto p-6">
{loading && <div className="h-full flex items-center justify-center text-sm text-slate-400">正在连接配置中心</div>}
{!loading && nav === 'apps' && (
<AppsPage apps={apps} namespaces={namespaces} search={appSearch} setSearch={setAppSearch} onAdd={openAddApp} onEdit={openEditApp} onDelete={deleteApp} />
)}
{!loading && nav === 'namespaces' && (
<NamespacesPage namespaces={namespaces} apps={apps} search={nsSearch} setSearch={setNsSearch} filterAppId={nsFilterApp} setFilterAppId={setNsFilterApp} onAdd={openAddNamespace} onEdit={openEditNamespace} onDelete={deleteNamespace} />
)}
{!loading && nav === 'environments' && (
<EnvironmentsPage environments={environments} onAdd={openAddEnvironment} onEdit={openEditEnvironment} onDelete={deleteEnvironment} />
)}
{!loading && nav === 'configs' && (
<ConfigsPage
apps={apps} environments={environments} namespaces={namespaces} configs={configs}
selectedAppId={selectedAppId} setSelectedAppId={setSelectedAppId}
selectedEnvId={selectedEnvId} setSelectedEnvId={setSelectedEnvId}
selectedNsId={selectedNsId} setSelectedNsId={setSelectedNsId}
search={configSearch} setSearch={setConfigSearch}
onAddConfig={openAddConfig} onEditConfig={openEditConfig} onToggleDelete={toggleDeleteConfig}
onOpenPublish={() => setPublishOpen(true)} onOpenHistory={() => setHistoryOpen(true)}
/>
)}
{!loading && nav === 'gray' && (
<GrayRulesPage
apps={apps} environments={environments} namespaces={namespaces} grayRules={grayRules}
selectedAppId={selectedAppId} setSelectedAppId={setSelectedAppId}
selectedEnvId={selectedEnvId} setSelectedEnvId={setSelectedEnvId}
selectedNsId={selectedNsId} setSelectedNsId={setSelectedNsId}
onAdd={() => setGrayModal({ rule: null })} onEdit={(rule) => setGrayModal({ rule })} onDelete={deleteGrayRule}
/>
)}
{!loading && nav === 'users' && identity?.user?.isAdmin && (
<UsersPage
users={users} apps={apps} identity={identity} notify={notify}
onCreate={() => setCreateUserOpen(true)} onToggleStatus={toggleUserStatus}
onResetPassword={setPasswordResetUser} onDelete={deleteUser}
/>
)}
{!loading && nav === 'audit' && identity?.user?.isAdmin && <AuditPage notify={notify} />}
</main>
</div>
{modal && <EntityModal modal={modal} apps={apps} onClose={() => setModal(null)} onSubmit={handleModalSubmit} />}
{confirmState && <ConfirmDialog confirm={confirmState} onCancel={() => setConfirmState(null)} />}
{toast && <Toast toast={toast} />}
{publishOpen && (
<PublishModal
scopeConfigs={scopeConfigs}
appName={selectedApp?.name} nsName={selectedNs?.name} envCode={selectedEnv?.code}
comment={releaseComment} setComment={setReleaseComment}
onCancel={() => { setPublishOpen(false); setReleaseComment(''); }}
onConfirm={confirmPublish}
/>
)}
{historyOpen && (
<HistoryModal
releases={scopeReleases}
appName={selectedApp?.name} nsName={selectedNs?.name} envCode={selectedEnv?.code}
onClose={() => setHistoryOpen(false)} onRollback={rollbackTo}
/>
)}
{grayModal && (
<GrayRuleModal
rule={grayModal.rule}
scope={{ appId: selectedAppId, nsId: selectedNsId, envId: selectedEnvId }}
onClose={() => setGrayModal(null)} onSubmit={saveGrayRule}
/>
)}
{createUserOpen && <CreateUserModal onClose={() => setCreateUserOpen(false)} onCreated={createUser} />}
{passwordResetUser && <PasswordResetModal user={passwordResetUser} onClose={() => setPasswordResetUser(null)} onSubmit={resetUserPassword} />}
</div>
);
}