1602 lines
88 KiB
JavaScript
1602 lines
88 KiB
JavaScript
import React, { useState, useEffect } from '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 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 }) {
|
||
return (
|
||
<label className="block">
|
||
<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}
|
||
</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 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 } = 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 max-w-md overflow-hidden">
|
||
<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>
|
||
<textarea required value={form.value || ''} onChange={(e) => set('value', e.target.value)} rows={4} className={inputCls() + ' font-mono'} />
|
||
</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' ? '百分比(0–100)' : 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; setModal({ entityType: 'config', mode: 'add', data: {} }); }
|
||
function openEditConfig(c) { setModal({ entityType: 'config', mode: 'edit', 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>
|
||
);
|
||
}
|