Initial commit: tkmind admin frontend (React + Vite).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+131
@@ -0,0 +1,131 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Navigate, Route, Routes, useNavigate } from 'react-router-dom';
|
||||
import { checkAuth, login, logout, setUnauthorizedHandler } from './api/client';
|
||||
import { AdminLayout } from './admin/AdminLayout';
|
||||
import { BillingPage } from './admin/pages/BillingPage';
|
||||
import { CapabilitiesPage } from './admin/pages/CapabilitiesPage';
|
||||
import { DashboardPage } from './admin/pages/DashboardPage';
|
||||
import { PoliciesPage } from './admin/pages/PoliciesPage';
|
||||
import { ProvidersPage } from './admin/pages/ProvidersPage';
|
||||
import { SkillsPage } from './admin/pages/SkillsPage';
|
||||
import { UserDetailPage } from './admin/pages/UserDetailPage';
|
||||
import { UsersPage } from './admin/pages/UsersPage';
|
||||
import type { PortalUser } from './types';
|
||||
|
||||
function AdminLoginPage({ onAuth }: { onAuth: (user: PortalUser) => void }) {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const user = await login(username, password);
|
||||
if (!user || user.role !== 'admin') {
|
||||
setError('无管理员权限');
|
||||
return;
|
||||
}
|
||||
onAuth(user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-login-page">
|
||||
<div className="admin-login-card">
|
||||
<h1 className="admin-login-title">管理后台</h1>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
<form onSubmit={handleSubmit} className="admin-login-form">
|
||||
<input
|
||||
className="admin-login-input"
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="admin-login-input"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button type="submit" className="send-btn" disabled={loading}>
|
||||
{loading ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/admin" element={<AdminLayout user={user} onLogout={onLogout} />}>
|
||||
<Route index element={<DashboardPage />} />
|
||||
<Route path="users" element={<UsersPage />} />
|
||||
<Route path="users/:userId" element={<UserDetailPage />} />
|
||||
<Route path="billing/*" element={<BillingPage />} />
|
||||
<Route path="capabilities" element={<CapabilitiesPage />} />
|
||||
<Route path="skills" element={<SkillsPage />} />
|
||||
<Route path="policies" element={<PoliciesPage />} />
|
||||
<Route path="providers" element={<ProvidersPage />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const navigate = useNavigate();
|
||||
const [authed, setAuthed] = useState<boolean | null>(null);
|
||||
const [user, setUser] = useState<PortalUser | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler(() => {
|
||||
setAuthed(false);
|
||||
setUser(null);
|
||||
navigate('/', { replace: true });
|
||||
});
|
||||
void checkAuth().then((status) => {
|
||||
const isAdmin = status.authenticated && status.user?.role === 'admin';
|
||||
setAuthed(isAdmin);
|
||||
setUser(isAdmin ? (status.user ?? null) : null);
|
||||
});
|
||||
}, [navigate]);
|
||||
|
||||
if (authed === null) {
|
||||
return <div className="app-loading">正在验证登录状态…</div>;
|
||||
}
|
||||
|
||||
if (!authed || !user) {
|
||||
return (
|
||||
<AdminLoginPage
|
||||
onAuth={(nextUser) => {
|
||||
setAuthed(true);
|
||||
setUser(nextUser);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleLogout = () => {
|
||||
void logout().finally(() => {
|
||||
setAuthed(false);
|
||||
setUser(null);
|
||||
});
|
||||
};
|
||||
|
||||
return <AdminApp user={user} onLogout={handleLogout} />;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import type { PortalUser } from '../types';
|
||||
import { AdminNav } from './AdminNav';
|
||||
|
||||
const MAIN_APP_URL = import.meta.env.VITE_MAIN_APP_URL ?? '/';
|
||||
|
||||
export function AdminLayout({
|
||||
user,
|
||||
onLogout,
|
||||
}: {
|
||||
user: PortalUser;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-shell">
|
||||
<header className="admin-topbar">
|
||||
<div className="admin-topbar-left">
|
||||
<a href={MAIN_APP_URL} className="ghost-btn admin-back-link">
|
||||
← 返回对话
|
||||
</a>
|
||||
<h1 className="admin-topbar-title">管理后台</h1>
|
||||
</div>
|
||||
<div className="admin-topbar-actions">
|
||||
<span className="admin-topbar-user">{user.displayName ?? user.username}</span>
|
||||
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="admin-body">
|
||||
<aside className="admin-sidebar">
|
||||
<AdminNav />
|
||||
</aside>
|
||||
<main className="admin-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
type NavItem = {
|
||||
to: string;
|
||||
label: string;
|
||||
end?: boolean;
|
||||
};
|
||||
|
||||
type NavSection = {
|
||||
label?: string;
|
||||
items: NavItem[];
|
||||
};
|
||||
|
||||
const NAV_SECTIONS: NavSection[] = [
|
||||
{
|
||||
items: [{ to: '/admin', label: '概览', end: true }],
|
||||
},
|
||||
{
|
||||
label: '用户与账户',
|
||||
items: [{ to: '/admin/users', label: '用户管理' }],
|
||||
},
|
||||
{
|
||||
label: '计费',
|
||||
items: [{ to: '/admin/billing', label: '计费中心', end: false }],
|
||||
},
|
||||
{
|
||||
label: '平台配置',
|
||||
items: [
|
||||
{ to: '/admin/capabilities', label: '能力' },
|
||||
{ to: '/admin/skills', label: '技能' },
|
||||
{ to: '/admin/policies', label: '策略' },
|
||||
{ to: '/admin/providers', label: 'LLM Provider' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function AdminNav() {
|
||||
return (
|
||||
<nav className="admin-sidebar-nav" aria-label="管理后台导航">
|
||||
{NAV_SECTIONS.map((section) => (
|
||||
<div key={section.label ?? section.items[0]?.to} className="admin-nav-section">
|
||||
{section.label && <div className="admin-nav-section-label">{section.label}</div>}
|
||||
{section.items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) => `admin-nav-link${isActive ? ' active' : ''}`}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { listAdminUsers } from '../../api/client';
|
||||
import type { AdminUserRow } from '../../types';
|
||||
|
||||
export function useAdminUsers() {
|
||||
const [users, setUsers] = useState<AdminUserRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setUsers(await listAdminUsers());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载用户失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
return { users, loading, error, reload, setError };
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { listAdminLedger, listAdminUsage, rechargeUser } from '../../api/client';
|
||||
import type { LedgerEntry, UsageRecord } from '../../types';
|
||||
import { useAdminUsers } from '../hooks/useAdminUsers';
|
||||
import { formatTime, formatYuan } from '../utils/format';
|
||||
|
||||
type TabKey = 'recharge' | 'usage' | 'ledger';
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'recharge', label: '充值' },
|
||||
{ key: 'usage', label: '用量记录' },
|
||||
{ key: 'ledger', label: '资金流水' },
|
||||
];
|
||||
|
||||
function RechargeTab() {
|
||||
const { users, reload, error, setError } = useAdminUsers();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [recharge, setRecharge] = useState({ userId: '', amountYuan: '10', note: '' });
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const handleRecharge = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!recharge.userId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const amountCents = Math.round(Number(recharge.amountYuan) * 100);
|
||||
await rechargeUser(recharge.userId, amountCents, recharge.note || undefined);
|
||||
setMessage('充值成功');
|
||||
setRecharge((s) => ({ ...s, userId: '', note: '' }));
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '充值失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<h2>充值</h2>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
<form className="admin-form" onSubmit={handleRecharge}>
|
||||
<select
|
||||
value={recharge.userId}
|
||||
onChange={(e) => setRecharge((s) => ({ ...s, userId: e.target.value }))}
|
||||
>
|
||||
<option value="">选择用户</option>
|
||||
{users
|
||||
.filter((u) => u.role === 'user')
|
||||
.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}(¥{formatYuan(u.balanceCents)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
placeholder="金额(元)"
|
||||
type="number"
|
||||
min="0.01"
|
||||
step="0.01"
|
||||
value={recharge.amountYuan}
|
||||
onChange={(e) => setRecharge((s) => ({ ...s, amountYuan: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
placeholder="备注(可选)"
|
||||
value={recharge.note}
|
||||
onChange={(e) => setRecharge((s) => ({ ...s, note: e.target.value }))}
|
||||
/>
|
||||
<button type="submit" className="send-btn" disabled={!recharge.userId || submitting}>
|
||||
{submitting ? '处理中…' : '充值'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageTab() {
|
||||
const { users } = useAdminUsers();
|
||||
const [usage, setUsage] = useState<UsageRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterUserId, setFilterUserId] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (userId?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setUsage(await listAdminUsage(userId || undefined));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load(filterUserId || undefined);
|
||||
}, [load, filterUserId]);
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>用量记录</h2>
|
||||
<div className="billing-toolbar">
|
||||
<select
|
||||
className="billing-filter-select"
|
||||
value={filterUserId}
|
||||
onChange={(e) => setFilterUserId(e.target.value)}
|
||||
>
|
||||
<option value="">全部用户</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load(filterUserId || undefined)}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : usage.length === 0 ? (
|
||||
<p className="muted billing-empty">暂无记录</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>用户</th>
|
||||
<th>输入 Token</th>
|
||||
<th>输出 Token</th>
|
||||
<th>扣费</th>
|
||||
<th>余额后</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{usage.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="billing-time">{formatTime(row.createdAt)}</td>
|
||||
<td>@{row.username}</td>
|
||||
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
|
||||
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
|
||||
<td className="billing-num">¥{formatYuan(row.costCents)}</td>
|
||||
<td className="billing-num">¥{formatYuan(row.balanceAfterCents)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerTab() {
|
||||
const { users } = useAdminUsers();
|
||||
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterUserId, setFilterUserId] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (userId?: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setLedger(await listAdminLedger(userId || undefined));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load(filterUserId || undefined);
|
||||
}, [load, filterUserId]);
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
recharge: '充值',
|
||||
deduct: '扣费',
|
||||
refund: '退款',
|
||||
adjust: '调整',
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>资金流水</h2>
|
||||
<div className="billing-toolbar">
|
||||
<select
|
||||
className="billing-filter-select"
|
||||
value={filterUserId}
|
||||
onChange={(e) => setFilterUserId(e.target.value)}
|
||||
>
|
||||
<option value="">全部用户</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load(filterUserId || undefined)}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : ledger.length === 0 ? (
|
||||
<p className="muted billing-empty">暂无记录</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>用户</th>
|
||||
<th>类型</th>
|
||||
<th>金额</th>
|
||||
<th>备注</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ledger.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td className="billing-time">{formatTime(row.createdAt)}</td>
|
||||
<td>@{row.username}</td>
|
||||
<td>
|
||||
<span className={`ledger-type-tag ledger-type-${row.type}`}>
|
||||
{TYPE_LABEL[row.type] ?? row.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`billing-num ${row.amountCents < 0 ? 'text-error' : 'text-income'}`}>
|
||||
{row.amountCents >= 0 ? '+' : ''}¥{formatYuan(Math.abs(row.amountCents))}
|
||||
</td>
|
||||
<td className="billing-note">{row.note ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function BillingPage() {
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('recharge');
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>计费中心</h2>
|
||||
<p className="muted">充值、用量与资金流水</p>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs" role="tablist">
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeTab === tab.key}
|
||||
className={`admin-tab${activeTab === tab.key ? ' active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="billing-tab-content">
|
||||
{activeTab === 'recharge' && <RechargeTab />}
|
||||
{activeTab === 'usage' && <UsageTab />}
|
||||
{activeTab === 'ledger' && <LedgerTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { CapabilitySettings } from '../../components/CapabilitySettings';
|
||||
import { useAdminUsers } from '../hooks/useAdminUsers';
|
||||
|
||||
export function CapabilitiesPage() {
|
||||
const { users, loading, error } = useAdminUsers();
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>能力权限</h2>
|
||||
<p className="muted">控制各扩展与工具是否可用</p>
|
||||
</div>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{loading ? <p className="muted">加载用户列表…</p> : <CapabilitySettings users={users} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getAdminDashboardSummary } from '../../api/client';
|
||||
import type { AdminDashboardSummary } from '../../types';
|
||||
import { formatTime, formatYuan } from '../utils/format';
|
||||
|
||||
const QUICK_LINKS = [
|
||||
{ to: '/admin/users', label: '用户管理', desc: '创建账号、启用禁用' },
|
||||
{ to: '/admin/billing/recharge', label: '充值', desc: '为用户账户充值' },
|
||||
{ to: '/admin/providers', label: 'LLM Provider', desc: '模型 Key 与全局配置' },
|
||||
{ to: '/admin/capabilities', label: '能力权限', desc: '扩展与工具开关' },
|
||||
] as const;
|
||||
|
||||
export function DashboardPage() {
|
||||
const [summary, setSummary] = useState<AdminDashboardSummary | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setSummary(await getAdminDashboardSummary());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载概览失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>概览</h2>
|
||||
<p className="muted">平台运行摘要与快捷入口</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
|
||||
<div className="admin-stat-grid">
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">用户总数</div>
|
||||
<div className="admin-stat-value">{loading ? '—' : summary?.users.total}</div>
|
||||
<div className="muted">活跃 {loading ? '—' : summary?.users.active}</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">余额不足</div>
|
||||
<div className="admin-stat-value">{loading ? '—' : summary?.users.lowBalance}</div>
|
||||
<div className="muted">普通用户余额 ≤ ¥0</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">账户总余额</div>
|
||||
<div className="admin-stat-value">
|
||||
{loading ? '—' : `¥${formatYuan(summary?.users.totalBalanceCents ?? 0)}`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">24h 用量</div>
|
||||
<div className="admin-stat-value">{loading ? '—' : summary?.usage24h.count}</div>
|
||||
<div className="muted">
|
||||
扣费 ¥{loading ? '—' : formatYuan(summary?.usage24h.costCents ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-stat-card">
|
||||
<div className="admin-stat-label">LLM Provider</div>
|
||||
<div className="admin-stat-value">{loading ? '—' : (summary?.llm?.keyCount ?? '—')}</div>
|
||||
<div className="muted">
|
||||
{loading
|
||||
? '—'
|
||||
: summary?.llm?.selectedKeyName
|
||||
? `当前 ${summary.llm.selectedKeyName}`
|
||||
: '未配置'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && summary && summary.lowBalanceUsers.length > 0 && (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>余额告警</h2>
|
||||
<Link to="/admin/billing/recharge" className="ghost-btn admin-inline-link">
|
||||
去充值
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>余额</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.lowBalanceUsers.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
<div>{user.displayName}</div>
|
||||
<div className="muted">@{user.username}</div>
|
||||
</td>
|
||||
<td>¥{formatYuan(user.balanceCents)}</td>
|
||||
<td>
|
||||
<Link
|
||||
to={`/admin/users/${user.id}`}
|
||||
className="ghost-btn admin-inline-link"
|
||||
>
|
||||
用户详情
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="admin-card">
|
||||
<h2>快捷入口</h2>
|
||||
<div className="admin-link-grid">
|
||||
{QUICK_LINKS.map((item) => (
|
||||
<Link key={item.to} to={item.to} className="admin-link-card">
|
||||
<span className="admin-link-card-title">{item.label}</span>
|
||||
<span className="muted">{item.desc}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{!loading && summary && (
|
||||
<>
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>最近用量</h2>
|
||||
<Link to="/admin/billing/usage" className="ghost-btn admin-inline-link">
|
||||
查看全部
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>用户</th>
|
||||
<th>Token</th>
|
||||
<th>扣费</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.recentUsage.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="muted">
|
||||
暂无记录
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
summary.recentUsage.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{formatTime(row.createdAt)}</td>
|
||||
<td>@{row.username}</td>
|
||||
<td>
|
||||
in {row.inputTokens} / out {row.outputTokens}
|
||||
</td>
|
||||
<td>¥{formatYuan(row.costCents)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>最近流水</h2>
|
||||
<Link to="/admin/billing/ledger" className="ghost-btn admin-inline-link">
|
||||
查看全部
|
||||
</Link>
|
||||
</div>
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>用户</th>
|
||||
<th>类型</th>
|
||||
<th>金额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.recentLedger.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="muted">
|
||||
暂无记录
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
summary.recentLedger.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>{formatTime(row.createdAt)}</td>
|
||||
<td>@{row.username}</td>
|
||||
<td>{row.type}</td>
|
||||
<td className={row.amountCents < 0 ? 'text-error' : ''}>
|
||||
{row.amountCents >= 0 ? '+' : ''}¥
|
||||
{formatYuan(Math.abs(row.amountCents))}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { PolicySettings } from '../../components/PolicySettings';
|
||||
import { useAdminUsers } from '../hooks/useAdminUsers';
|
||||
|
||||
export function PoliciesPage() {
|
||||
const { users, loading, error } = useAdminUsers();
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>安全策略</h2>
|
||||
<p className="muted">TKMind 模式、工作区只读与 API 代理锁定</p>
|
||||
</div>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{loading ? <p className="muted">加载用户列表…</p> : <PolicySettings users={users} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ProviderKeySettings } from '../../components/ProviderKeySettings';
|
||||
|
||||
export function ProvidersPage() {
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>LLM Provider</h2>
|
||||
<p className="muted">模型 Key、全局默认模型与联通测试</p>
|
||||
</div>
|
||||
<ProviderKeySettings />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { SkillSettings } from '../../components/SkillSettings';
|
||||
import { useAdminUsers } from '../hooks/useAdminUsers';
|
||||
|
||||
export function SkillsPage() {
|
||||
const { users, loading, error } = useAdminUsers();
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>技能配置</h2>
|
||||
<p className="muted">管理平台技能与用户 MindSpace 安装</p>
|
||||
</div>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{loading ? <p className="muted">加载用户列表…</p> : <SkillSettings users={users} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||
import { rechargeUser, updateAdminUser } from '../../api/client';
|
||||
import { CapabilitySettings } from '../../components/CapabilitySettings';
|
||||
import { PolicySettings } from '../../components/PolicySettings';
|
||||
import { SkillSettings } from '../../components/SkillSettings';
|
||||
import { useAdminUsers } from '../hooks/useAdminUsers';
|
||||
import { formatYuan } from '../utils/format';
|
||||
|
||||
export function UserDetailPage() {
|
||||
const { userId = '' } = useParams();
|
||||
const { users, loading, error, reload, setError } = useAdminUsers();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [recharge, setRecharge] = useState({ amountYuan: '10', note: '' });
|
||||
|
||||
const user = users.find((row) => row.id === userId);
|
||||
|
||||
const handleRecharge = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!user) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const amountCents = Math.round(Number(recharge.amountYuan) * 100);
|
||||
await rechargeUser(user.id, amountCents, recharge.note || undefined);
|
||||
setMessage('充值成功');
|
||||
setRecharge({ amountYuan: '10', note: '' });
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '充值失败');
|
||||
}
|
||||
};
|
||||
|
||||
if (!loading && !user) {
|
||||
return <Navigate to="/admin/users" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<Link to="/admin/users" className="ghost-btn admin-back-inline">
|
||||
← 返回用户列表
|
||||
</Link>
|
||||
<h2>{user?.displayName ?? '用户详情'}</h2>
|
||||
{user && <p className="muted">@{user.username} · {user.role} · {user.status}</p>}
|
||||
</div>
|
||||
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
|
||||
{loading || !user ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<>
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>账户信息</h2>
|
||||
{user.role === 'user' && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() =>
|
||||
void updateAdminUser(user.id, {
|
||||
status: user.status === 'active' ? 'disabled' : 'active',
|
||||
}).then(reload)
|
||||
}
|
||||
>
|
||||
{user.status === 'active' ? '禁用' : '启用'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<dl className="admin-dl">
|
||||
<div>
|
||||
<dt>余额</dt>
|
||||
<dd>¥{formatYuan(user.balanceCents)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>工作目录</dt>
|
||||
<dd className="mono">{user.workspaceRoot}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{user.role === 'user' && (
|
||||
<>
|
||||
<section className="admin-card">
|
||||
<h2>充值</h2>
|
||||
<form className="admin-form" onSubmit={handleRecharge}>
|
||||
<input
|
||||
placeholder="金额(元)"
|
||||
value={recharge.amountYuan}
|
||||
onChange={(e) => setRecharge((s) => ({ ...s, amountYuan: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
placeholder="备注"
|
||||
value={recharge.note}
|
||||
onChange={(e) => setRecharge((s) => ({ ...s, note: e.target.value }))}
|
||||
/>
|
||||
<button type="submit" className="send-btn">
|
||||
充值
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<CapabilitySettings users={users} userId={user.id} userOnly />
|
||||
<SkillSettings users={users} userId={user.id} userOnly />
|
||||
<PolicySettings users={users} userId={user.id} userOnly />
|
||||
</>
|
||||
)}
|
||||
|
||||
{user.role === 'admin' && (
|
||||
<section className="admin-card">
|
||||
<p className="muted">
|
||||
管理员账号拥有 goose 全权限:不受能力、技能、策略、计费与路径限制,会话以 auto 模式运行。
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { createAdminUser, updateAdminUser } from '../../api/client';
|
||||
import { useAdminUsers } from '../hooks/useAdminUsers';
|
||||
import { formatYuan } from '../utils/format';
|
||||
|
||||
export function UsersPage() {
|
||||
const { users, loading, error, reload, setError } = useAdminUsers();
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [newUser, setNewUser] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
displayName: '',
|
||||
workspaceRoot: '',
|
||||
balanceCents: 500,
|
||||
});
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await createAdminUser({
|
||||
username: newUser.username,
|
||||
password: newUser.password,
|
||||
displayName: newUser.displayName || undefined,
|
||||
workspaceRoot: newUser.workspaceRoot || undefined,
|
||||
balanceCents: Number(newUser.balanceCents),
|
||||
});
|
||||
setNewUser({
|
||||
username: '',
|
||||
password: '',
|
||||
displayName: '',
|
||||
workspaceRoot: '',
|
||||
balanceCents: 500,
|
||||
});
|
||||
setMessage('用户已创建');
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h2>用户管理</h2>
|
||||
<p className="muted">创建账号、查看列表、进入用户详情配置权限</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
|
||||
<section className="admin-card">
|
||||
<h2>创建用户</h2>
|
||||
<form className="admin-form" onSubmit={handleCreate}>
|
||||
<input
|
||||
placeholder="用户名"
|
||||
autoComplete="username"
|
||||
value={newUser.username}
|
||||
onChange={(e) => setNewUser((s) => ({ ...s, username: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
placeholder="密码"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={newUser.password}
|
||||
onChange={(e) => setNewUser((s) => ({ ...s, password: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
placeholder="显示名称"
|
||||
value={newUser.displayName}
|
||||
onChange={(e) => setNewUser((s) => ({ ...s, displayName: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
placeholder="工作目录(可选)"
|
||||
value={newUser.workspaceRoot}
|
||||
onChange={(e) => setNewUser((s) => ({ ...s, workspaceRoot: e.target.value }))}
|
||||
/>
|
||||
<input
|
||||
placeholder="初始余额(分)"
|
||||
type="number"
|
||||
value={newUser.balanceCents}
|
||||
onChange={(e) =>
|
||||
setNewUser((s) => ({ ...s, balanceCents: Number(e.target.value) }))
|
||||
}
|
||||
/>
|
||||
<button type="submit" className="send-btn">
|
||||
创建
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>用户列表</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void reload()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>余额</th>
|
||||
<th>工作目录</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
<div>{user.displayName}</div>
|
||||
<div className="muted">@{user.username}</div>
|
||||
</td>
|
||||
<td>{user.role}</td>
|
||||
<td>{user.status}</td>
|
||||
<td>¥{formatYuan(user.balanceCents)}</td>
|
||||
<td className="mono">{user.workspaceRoot}</td>
|
||||
<td className="admin-actions">
|
||||
<Link to={`/admin/users/${user.id}`} className="ghost-btn admin-inline-link">
|
||||
详情
|
||||
</Link>
|
||||
{user.role === 'user' && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() =>
|
||||
void updateAdminUser(user.id, {
|
||||
status: user.status === 'active' ? 'disabled' : 'active',
|
||||
}).then(reload)
|
||||
}
|
||||
>
|
||||
{user.status === 'active' ? '禁用' : '启用'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function formatYuan(cents: number) {
|
||||
return (cents / 100).toFixed(2);
|
||||
}
|
||||
|
||||
export function formatTime(ts: number) {
|
||||
return new Date(ts).toLocaleString('zh-CN');
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import type {
|
||||
AdminDashboardSummary,
|
||||
AdminUserRow,
|
||||
AuthStatus,
|
||||
CapabilityDefinition,
|
||||
CapabilityMap,
|
||||
InsufficientBalanceDetails,
|
||||
LedgerEntry,
|
||||
LlmConnectionTestResult,
|
||||
LlmGlobalSettings,
|
||||
LlmProviderDefinition,
|
||||
LlmProviderKeyRow,
|
||||
PolicyDefinition,
|
||||
PolicyMap,
|
||||
PortalUser,
|
||||
SkillDefinition,
|
||||
SkillMap,
|
||||
UsageRecord,
|
||||
} from '../types';
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code?: string;
|
||||
readonly details?: InsufficientBalanceDetails | Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
status: number,
|
||||
message: string,
|
||||
code?: string,
|
||||
details?: InsufficientBalanceDetails | Record<string, unknown>,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseErrorResponse(res: Response) {
|
||||
const text = await res.text().catch(() => '');
|
||||
try {
|
||||
const body = JSON.parse(text) as Record<string, unknown>;
|
||||
const nested =
|
||||
body.error && typeof body.error === 'object'
|
||||
? (body.error as Record<string, unknown>)
|
||||
: body;
|
||||
const message =
|
||||
typeof nested.message === 'string'
|
||||
? nested.message
|
||||
: typeof body.message === 'string'
|
||||
? body.message
|
||||
: text;
|
||||
const code =
|
||||
typeof nested.code === 'string'
|
||||
? nested.code
|
||||
: typeof body.code === 'string'
|
||||
? body.code
|
||||
: undefined;
|
||||
return { message, code };
|
||||
} catch {
|
||||
return { message: text || res.statusText };
|
||||
}
|
||||
}
|
||||
|
||||
let unauthorizedHandler: (() => void) | null = null;
|
||||
let unauthorizedHandling = false;
|
||||
|
||||
export function setUnauthorizedHandler(handler: (() => void) | null) {
|
||||
unauthorizedHandler = handler;
|
||||
if (handler) unauthorizedHandling = false;
|
||||
}
|
||||
|
||||
export function resetUnauthorizedGuard() {
|
||||
unauthorizedHandling = false;
|
||||
}
|
||||
|
||||
function notifyUnauthorized() {
|
||||
if (!unauthorizedHandler || unauthorizedHandling) return;
|
||||
unauthorizedHandling = true;
|
||||
unauthorizedHandler();
|
||||
}
|
||||
|
||||
function formatNetworkError(err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '网络请求失败';
|
||||
if (message.includes('Failed to fetch') || message.includes('NetworkError')) {
|
||||
return '无法连接后端服务';
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
async function portalFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(path, {
|
||||
...init,
|
||||
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiError(0, formatNetworkError(err));
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
notifyUnauthorized();
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new ApiError(401, text || '未授权,请重新登录');
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const parsed = await parseErrorResponse(res);
|
||||
throw new ApiError(res.status, parsed.message || `${res.status} ${res.statusText}`, parsed.code);
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────
|
||||
|
||||
export async function checkAuth(): Promise<AuthStatus> {
|
||||
try {
|
||||
const response = await fetch('/auth/status');
|
||||
if (!response.ok) return { authenticated: false };
|
||||
const status = (await response.json()) as AuthStatus;
|
||||
if (status.authenticated) resetUnauthorizedGuard();
|
||||
return status;
|
||||
} catch {
|
||||
return { authenticated: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<PortalUser | null> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new ApiError(0, formatNetworkError(err));
|
||||
}
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
message?: string;
|
||||
user?: PortalUser;
|
||||
} | null;
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, body?.message ?? '登录失败');
|
||||
}
|
||||
resetUnauthorizedGuard();
|
||||
return body?.user ?? null;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
if (import.meta.env.DEV) return;
|
||||
await fetch('/auth/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── Admin dashboard ───────────────────────────────────
|
||||
|
||||
export async function getAdminDashboardSummary(): Promise<AdminDashboardSummary> {
|
||||
const result = await portalFetch<{ summary: AdminDashboardSummary }>('/admin-api/summary');
|
||||
return result.summary;
|
||||
}
|
||||
|
||||
export async function listAdminUsage(userId?: string): Promise<UsageRecord[]> {
|
||||
const query = userId ? `?userId=${encodeURIComponent(userId)}` : '';
|
||||
const result = await portalFetch<{ records: UsageRecord[] }>(`/admin-api/usage${query}`);
|
||||
return result.records ?? [];
|
||||
}
|
||||
|
||||
export async function listAdminLedger(userId?: string): Promise<LedgerEntry[]> {
|
||||
const query = userId ? `?userId=${encodeURIComponent(userId)}` : '';
|
||||
const result = await portalFetch<{ entries: LedgerEntry[] }>(`/admin-api/ledger${query}`);
|
||||
return result.entries ?? [];
|
||||
}
|
||||
|
||||
// ── Admin users ───────────────────────────────────────
|
||||
|
||||
export async function listAdminUsers(): Promise<AdminUserRow[]> {
|
||||
const result = await portalFetch<{ users: AdminUserRow[] }>('/admin-api/users');
|
||||
return result.users ?? [];
|
||||
}
|
||||
|
||||
export async function createAdminUser(payload: {
|
||||
username: string;
|
||||
password: string;
|
||||
displayName?: string;
|
||||
workspaceRoot?: string;
|
||||
balanceCents?: number;
|
||||
role?: 'user' | 'admin';
|
||||
email?: string;
|
||||
}): Promise<PortalUser> {
|
||||
const result = await portalFetch<{ user: PortalUser }>('/admin-api/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return result.user;
|
||||
}
|
||||
|
||||
export async function updateAdminUser(
|
||||
userId: string,
|
||||
payload: Partial<{
|
||||
displayName: string;
|
||||
status: 'active' | 'suspended' | 'disabled';
|
||||
workspaceRoot: string;
|
||||
balanceCents: number;
|
||||
role: 'user' | 'admin';
|
||||
}>,
|
||||
): Promise<PortalUser> {
|
||||
const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return result.user;
|
||||
}
|
||||
|
||||
export async function rechargeUser(
|
||||
userId: string,
|
||||
amountCents: number,
|
||||
note?: string,
|
||||
): Promise<PortalUser> {
|
||||
const result = await portalFetch<{ user: PortalUser }>(`/admin-api/users/${userId}/recharge`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ amountCents, note }),
|
||||
});
|
||||
return result.user;
|
||||
}
|
||||
|
||||
// ── Capabilities ──────────────────────────────────────
|
||||
|
||||
export async function listCapabilityCatalog(): Promise<CapabilityDefinition[]> {
|
||||
const result = await portalFetch<{ catalog: CapabilityDefinition[] }>(
|
||||
'/admin-api/capabilities/catalog',
|
||||
);
|
||||
return result.catalog ?? [];
|
||||
}
|
||||
|
||||
export async function getRoleCapabilities(role: 'user' = 'user'): Promise<{
|
||||
role: string;
|
||||
capabilities: CapabilityMap;
|
||||
}> {
|
||||
return portalFetch(`/admin-api/capabilities/role/${role}`);
|
||||
}
|
||||
|
||||
export async function updateRoleCapabilities(
|
||||
role: 'user',
|
||||
capabilities: CapabilityMap,
|
||||
): Promise<{ role: string; capabilities: CapabilityMap }> {
|
||||
return portalFetch(`/admin-api/capabilities/role/${role}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ capabilities }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserCapabilities(userId: string): Promise<{
|
||||
userId: string;
|
||||
role: string;
|
||||
unrestricted: boolean;
|
||||
capabilities: CapabilityMap;
|
||||
overrides: CapabilityMap;
|
||||
}> {
|
||||
return portalFetch(`/admin-api/users/${userId}/capabilities`);
|
||||
}
|
||||
|
||||
export async function updateUserCapabilities(
|
||||
userId: string,
|
||||
capabilities: CapabilityMap,
|
||||
): Promise<{ userId: string; capabilities: CapabilityMap }> {
|
||||
return portalFetch(`/admin-api/users/${userId}/capabilities`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ capabilities }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearUserCapabilityOverrides(userId: string): Promise<void> {
|
||||
await portalFetch(`/admin-api/users/${userId}/capabilities`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── Policies ──────────────────────────────────────────
|
||||
|
||||
export async function listPolicyCatalog(): Promise<PolicyDefinition[]> {
|
||||
const result = await portalFetch<{ catalog: PolicyDefinition[] }>('/admin-api/policies/catalog');
|
||||
return result.catalog ?? [];
|
||||
}
|
||||
|
||||
export async function getRolePolicies(role: 'user' = 'user'): Promise<{
|
||||
role: string;
|
||||
policies: PolicyMap;
|
||||
}> {
|
||||
return portalFetch(`/admin-api/policies/role/${role}`);
|
||||
}
|
||||
|
||||
export async function updateRolePolicies(
|
||||
role: 'user',
|
||||
policies: PolicyMap,
|
||||
): Promise<{ role: string; policies: PolicyMap }> {
|
||||
return portalFetch(`/admin-api/policies/role/${role}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ policies }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserPolicies(userId: string): Promise<{
|
||||
userId: string;
|
||||
role: string;
|
||||
unrestricted: boolean;
|
||||
policies: PolicyMap;
|
||||
overrides: PolicyMap;
|
||||
}> {
|
||||
return portalFetch(`/admin-api/users/${userId}/policies`);
|
||||
}
|
||||
|
||||
export async function updateUserPolicies(
|
||||
userId: string,
|
||||
policies: PolicyMap,
|
||||
): Promise<{ userId: string; policies: PolicyMap }> {
|
||||
return portalFetch(`/admin-api/users/${userId}/policies`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ policies }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearUserPolicyOverrides(userId: string): Promise<void> {
|
||||
await portalFetch(`/admin-api/users/${userId}/policies`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── Skills ────────────────────────────────────────────
|
||||
|
||||
export async function listSkillCatalog(): Promise<SkillDefinition[]> {
|
||||
const result = await portalFetch<{ catalog: SkillDefinition[] }>('/admin-api/skills/catalog');
|
||||
return result.catalog ?? [];
|
||||
}
|
||||
|
||||
export async function getRoleSkills(role: 'user' = 'user'): Promise<{
|
||||
role: string;
|
||||
skills: SkillMap;
|
||||
}> {
|
||||
return portalFetch(`/admin-api/skills/role/${role}`);
|
||||
}
|
||||
|
||||
export async function updateRoleSkills(
|
||||
role: 'user',
|
||||
skills: SkillMap,
|
||||
): Promise<{ role: string; skills: SkillMap }> {
|
||||
return portalFetch(`/admin-api/skills/role/${role}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ skills }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getUserSkills(userId: string): Promise<{
|
||||
userId: string;
|
||||
role: string;
|
||||
skills: SkillMap;
|
||||
grantedSkills: string[];
|
||||
overrides: SkillMap;
|
||||
}> {
|
||||
return portalFetch(`/admin-api/users/${userId}/skills`);
|
||||
}
|
||||
|
||||
export async function updateUserSkills(
|
||||
userId: string,
|
||||
skills: SkillMap,
|
||||
): Promise<{ userId: string; skills: SkillMap; grantedSkills: string[] }> {
|
||||
return portalFetch(`/admin-api/users/${userId}/skills`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ skills }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearUserSkillOverrides(userId: string): Promise<void> {
|
||||
await portalFetch(`/admin-api/users/${userId}/skills`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ── LLM Providers ─────────────────────────────────────
|
||||
|
||||
export async function listLlmProviderCatalog(): Promise<LlmProviderDefinition[]> {
|
||||
const result = await portalFetch<{ catalog: LlmProviderDefinition[] }>(
|
||||
'/admin-api/llm-providers/catalog',
|
||||
);
|
||||
return result.catalog ?? [];
|
||||
}
|
||||
|
||||
export async function listLlmProviderKeys(): Promise<LlmProviderKeyRow[]> {
|
||||
const result = await portalFetch<{ keys: LlmProviderKeyRow[] }>('/admin-api/llm-providers/keys');
|
||||
return result.keys ?? [];
|
||||
}
|
||||
|
||||
export async function createLlmProviderKey(payload: {
|
||||
providerId: string;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
defaultModel?: string;
|
||||
apiUrl?: string;
|
||||
basePath?: string;
|
||||
models?: string | string[];
|
||||
engine?: string;
|
||||
relayProvider?: string;
|
||||
}): Promise<LlmProviderKeyRow> {
|
||||
const result = await portalFetch<{ key: LlmProviderKeyRow }>('/admin-api/llm-providers/keys', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return result.key;
|
||||
}
|
||||
|
||||
export async function updateLlmProviderKey(
|
||||
keyId: string,
|
||||
payload: {
|
||||
name?: string;
|
||||
apiKey?: string;
|
||||
defaultModel?: string;
|
||||
status?: 'active' | 'disabled';
|
||||
apiUrl?: string;
|
||||
basePath?: string;
|
||||
models?: string | string[];
|
||||
engine?: string;
|
||||
relayProvider?: string;
|
||||
},
|
||||
): Promise<LlmProviderKeyRow> {
|
||||
const result = await portalFetch<{ key: LlmProviderKeyRow }>(
|
||||
`/admin-api/llm-providers/keys/${keyId}`,
|
||||
{ method: 'PATCH', body: JSON.stringify(payload) },
|
||||
);
|
||||
return result.key;
|
||||
}
|
||||
|
||||
export async function selectLlmProviderKey(keyId: string): Promise<LlmProviderKeyRow> {
|
||||
const result = await portalFetch<{ key: LlmProviderKeyRow }>(
|
||||
`/admin-api/llm-providers/keys/${keyId}/select`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
return result.key;
|
||||
}
|
||||
|
||||
export async function deleteLlmProviderKey(keyId: string): Promise<void> {
|
||||
await portalFetch(`/admin-api/llm-providers/keys/${keyId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function syncLlmProviderToGoosed(): Promise<{ synced: boolean }> {
|
||||
return portalFetch('/admin-api/llm-providers/sync', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function getLlmGlobalSettings(): Promise<LlmGlobalSettings> {
|
||||
const result = await portalFetch<{ global: LlmGlobalSettings }>('/admin-api/llm-providers/global');
|
||||
return result.global;
|
||||
}
|
||||
|
||||
export async function setLlmGlobalModel(model: string): Promise<{ global: LlmGlobalSettings }> {
|
||||
return portalFetch('/admin-api/llm-providers/global', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ model }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function testLlmProviderDraft(payload: {
|
||||
providerId: string;
|
||||
name: string;
|
||||
apiKey: string;
|
||||
apiUrl?: string;
|
||||
basePath?: string;
|
||||
models?: string | string[];
|
||||
defaultModel?: string;
|
||||
testModel?: string;
|
||||
engine?: string;
|
||||
relayProvider?: string;
|
||||
}): Promise<LlmConnectionTestResult> {
|
||||
return portalFetch('/admin-api/llm-providers/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function testLlmProviderKey(
|
||||
keyId: string,
|
||||
model?: string,
|
||||
): Promise<LlmConnectionTestResult> {
|
||||
return portalFetch(`/admin-api/llm-providers/keys/${keyId}/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(model ? { model } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
clearUserCapabilityOverrides,
|
||||
getRoleCapabilities,
|
||||
getUserCapabilities,
|
||||
listCapabilityCatalog,
|
||||
updateRoleCapabilities,
|
||||
updateUserCapabilities,
|
||||
} from '../api/client';
|
||||
import type { AdminUserRow, CapabilityDefinition, CapabilityMap } from '../types';
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
};
|
||||
|
||||
type FilterType = 'all' | 'high' | 'enabled';
|
||||
|
||||
function CapabilityGrid({
|
||||
catalog,
|
||||
values,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
catalog: CapabilityDefinition[];
|
||||
values: CapabilityMap;
|
||||
disabled?: boolean;
|
||||
onChange: (key: string, allowed: boolean) => void;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<FilterType>('all');
|
||||
|
||||
const grouped = catalog.reduce<Record<string, CapabilityDefinition[]>>((acc, item) => {
|
||||
acc[item.category] ??= [];
|
||||
acc[item.category].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const filterItem = (item: CapabilityDefinition) => {
|
||||
if (filter === 'high') return item.risk === 'high';
|
||||
if (filter === 'enabled') return Boolean(values[item.key]);
|
||||
return true;
|
||||
};
|
||||
|
||||
const FILTER_OPTIONS: { key: FilterType; label: string }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'high', label: '高风险' },
|
||||
{ key: 'enabled', label: '已启用' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="capability-grid">
|
||||
<div className="capability-filter-bar">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
className={`capability-filter-btn${filter === opt.key ? ' active' : ''}`}
|
||||
onClick={() => setFilter(opt.key)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{Object.entries(grouped).map(([category, items]) => {
|
||||
const visible = items.filter(filterItem);
|
||||
if (visible.length === 0) return null;
|
||||
const enabledCount = items.filter((i) => Boolean(values[i.key])).length;
|
||||
return (
|
||||
<div key={category} className="capability-group">
|
||||
<div className="capability-group-header">
|
||||
<h3>{category}</h3>
|
||||
<span className="capability-group-count">
|
||||
{enabledCount}/{items.length} 已启用
|
||||
</span>
|
||||
</div>
|
||||
<ul>
|
||||
{visible.map((item) => {
|
||||
const checked = Boolean(values[item.key]);
|
||||
return (
|
||||
<li key={item.key} className="capability-item">
|
||||
<div className="capability-item-info">
|
||||
<span className="capability-label">
|
||||
{item.label}
|
||||
<span className={`risk-pill risk-${item.risk}`}>
|
||||
风险 {RISK_LABEL[item.risk] ?? item.risk}
|
||||
</span>
|
||||
</span>
|
||||
<span className="muted capability-desc">{item.description}</span>
|
||||
</div>
|
||||
<label className="capability-toggle" aria-label={item.label}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(item.key, e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track">
|
||||
<span className="toggle-thumb" />
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CapabilitySettings({
|
||||
users,
|
||||
userId,
|
||||
userOnly = false,
|
||||
}: {
|
||||
users: AdminUserRow[];
|
||||
userId?: string;
|
||||
userOnly?: boolean;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<CapabilityDefinition[]>([]);
|
||||
const [roleCapabilities, setRoleCapabilities] = useState<CapabilityMap>({});
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
|
||||
const [userCapabilities, setUserCapabilities] = useState<CapabilityMap>({});
|
||||
const [userOverrides, setUserOverrides] = useState<CapabilityMap>({});
|
||||
const [userUnrestricted, setUserUnrestricted] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, roleState] = await Promise.all([
|
||||
listCapabilityCatalog(),
|
||||
getRoleCapabilities('user'),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setRoleCapabilities(roleState.capabilities);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载权限配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUserCapabilities = useCallback(async (userId: string) => {
|
||||
if (!userId) {
|
||||
setUserCapabilities({});
|
||||
setUserOverrides({});
|
||||
setUserUnrestricted(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = await getUserCapabilities(userId);
|
||||
setUserCapabilities(state.capabilities);
|
||||
setUserOverrides(state.overrides);
|
||||
setUserUnrestricted(state.unrestricted);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载用户权限失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUserCapabilities(selectedUserId);
|
||||
}, [loadUserCapabilities, selectedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
setSelectedUserId(userId);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveRoleDefaults = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateRoleCapabilities('user', roleCapabilities);
|
||||
setRoleCapabilities(result.capabilities);
|
||||
setMessage('普通用户默认权限已保存(对新用户及无单独配置的用户生效)');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateUserCapabilities(selectedUserId, userCapabilities);
|
||||
setUserCapabilities(result.capabilities);
|
||||
await loadUserCapabilities(selectedUserId);
|
||||
setMessage('用户单独权限已保存');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await clearUserCapabilityOverrides(selectedUserId);
|
||||
await loadUserCapabilities(selectedUserId);
|
||||
setMessage('已恢复为角色默认权限');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const regularUsers = users.filter((user) => user.role === 'user');
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>能力权限</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted capability-intro">
|
||||
控制各扩展/工具是否可用。用户沙箱(MindSpace/用户名)请在「技能配置」勾选 static-page-publish。
|
||||
「安全策略」可进一步收紧模式与 API。管理员不受限制。
|
||||
</p>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<>
|
||||
{!userOnly && (
|
||||
<>
|
||||
<h3 className="capability-section-title">普通用户默认权限</h3>
|
||||
<CapabilityGrid
|
||||
catalog={catalog}
|
||||
values={roleCapabilities}
|
||||
onChange={(key, allowed) =>
|
||||
setRoleCapabilities((prev) => ({ ...prev, [key]: allowed }))
|
||||
}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
|
||||
保存默认权限
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!userOnly && <h3 className="capability-section-title">单用户覆盖</h3>}
|
||||
{!userOnly && (
|
||||
<div className="admin-form capability-user-picker">
|
||||
<select
|
||||
value={selectedUserId}
|
||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||
>
|
||||
<option value="">选择用户(可选)</option>
|
||||
{regularUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.displayName} (@{user.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{selectedUserId && (
|
||||
<>
|
||||
{userUnrestricted ? (
|
||||
<p className="muted">该用户为管理员,不受能力限制。</p>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(userOverrides).length > 0 && (
|
||||
<p className="muted">
|
||||
已设置 {Object.keys(userOverrides).length} 项单独覆盖,保存后将覆盖角色默认。
|
||||
</p>
|
||||
)}
|
||||
<CapabilityGrid
|
||||
catalog={catalog}
|
||||
values={userCapabilities}
|
||||
onChange={(key, allowed) =>
|
||||
setUserCapabilities((prev) => ({ ...prev, [key]: allowed }))
|
||||
}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
onClick={() => void saveUserOverrides()}
|
||||
>
|
||||
保存用户权限
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void resetUserOverrides()}
|
||||
>
|
||||
恢复角色默认
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
clearUserPolicyOverrides,
|
||||
getRolePolicies,
|
||||
getUserPolicies,
|
||||
listPolicyCatalog,
|
||||
updateRolePolicies,
|
||||
updateUserPolicies,
|
||||
} from '../api/client';
|
||||
import type { AdminUserRow, PolicyDefinition, PolicyMap } from '../types';
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
};
|
||||
|
||||
function PolicyGrid({
|
||||
catalog,
|
||||
values,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
catalog: PolicyDefinition[];
|
||||
values: PolicyMap;
|
||||
disabled?: boolean;
|
||||
onChange: (key: string, value: PolicyMap[string]) => void;
|
||||
}) {
|
||||
const grouped = catalog.reduce<Record<string, PolicyDefinition[]>>((acc, item) => {
|
||||
acc[item.category] ??= [];
|
||||
acc[item.category].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="capability-grid">
|
||||
{Object.entries(grouped).map(([category, items]) => (
|
||||
<div key={category} className="capability-group">
|
||||
<h3>{category}</h3>
|
||||
<ul>
|
||||
{items.map((item) => (
|
||||
<li key={item.key}>
|
||||
<label>
|
||||
{item.type === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(values[item.key])}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(item.key, e.target.checked)}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
value={String(values[item.key] ?? item.defaultValue)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(item.key, e.target.value)}
|
||||
>
|
||||
{(item.options ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<span className="capability-label">
|
||||
{item.label}
|
||||
<span className={`risk-pill risk-${item.risk}`}>
|
||||
风险 {RISK_LABEL[item.risk] ?? item.risk}
|
||||
</span>
|
||||
</span>
|
||||
<span className="muted capability-desc">{item.description}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PolicySettings({
|
||||
users,
|
||||
userId,
|
||||
userOnly = false,
|
||||
}: {
|
||||
users: AdminUserRow[];
|
||||
userId?: string;
|
||||
userOnly?: boolean;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<PolicyDefinition[]>([]);
|
||||
const [rolePolicies, setRolePolicies] = useState<PolicyMap>({});
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
|
||||
const [userPolicies, setUserPolicies] = useState<PolicyMap>({});
|
||||
const [userOverrides, setUserOverrides] = useState<PolicyMap>({});
|
||||
const [userUnrestricted, setUserUnrestricted] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, roleState] = await Promise.all([
|
||||
listPolicyCatalog(),
|
||||
getRolePolicies('user'),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setRolePolicies(roleState.policies);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载安全策略失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUserPolicies = useCallback(async (userId: string) => {
|
||||
if (!userId) {
|
||||
setUserPolicies({});
|
||||
setUserOverrides({});
|
||||
setUserUnrestricted(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = await getUserPolicies(userId);
|
||||
setUserPolicies(state.policies);
|
||||
setUserOverrides(state.overrides);
|
||||
setUserUnrestricted(state.unrestricted);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载用户策略失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUserPolicies(selectedUserId);
|
||||
}, [loadUserPolicies, selectedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
setSelectedUserId(userId);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveRoleDefaults = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateRolePolicies('user', rolePolicies);
|
||||
setRolePolicies(result.policies);
|
||||
setMessage('普通用户默认安全策略已保存');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateUserPolicies(selectedUserId, userPolicies);
|
||||
setUserPolicies(result.policies);
|
||||
await loadUserPolicies(selectedUserId);
|
||||
setMessage('用户单独策略已保存');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await clearUserPolicyOverrides(selectedUserId);
|
||||
await loadUserPolicies(selectedUserId);
|
||||
setMessage('已恢复为角色默认策略');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const regularUsers = users.filter((user) => user.role === 'user');
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>安全策略</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted capability-intro">
|
||||
控制 TKMind 模式、工作区只读、网络出站与 API 代理锁定。与上方「能力权限」叠加生效(策略会进一步收紧高风险能力)。
|
||||
</p>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<>
|
||||
{!userOnly && (
|
||||
<>
|
||||
<h3 className="capability-section-title">普通用户默认策略</h3>
|
||||
<PolicyGrid
|
||||
catalog={catalog}
|
||||
values={rolePolicies}
|
||||
onChange={(key, value) => setRolePolicies((prev) => ({ ...prev, [key]: value }))}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
|
||||
保存默认策略
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!userOnly && <h3 className="capability-section-title">单用户策略覆盖</h3>}
|
||||
{!userOnly && (
|
||||
<div className="admin-form capability-user-picker">
|
||||
<select
|
||||
value={selectedUserId}
|
||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||
>
|
||||
<option value="">选择用户(可选)</option>
|
||||
{regularUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.displayName} (@{user.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{selectedUserId && (
|
||||
<>
|
||||
{userUnrestricted ? (
|
||||
<p className="muted">该用户为管理员,不受策略限制。</p>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(userOverrides).length > 0 && (
|
||||
<p className="muted">
|
||||
已设置 {Object.keys(userOverrides).length} 项单独覆盖。
|
||||
</p>
|
||||
)}
|
||||
<PolicyGrid
|
||||
catalog={catalog}
|
||||
values={userPolicies}
|
||||
onChange={(key, value) =>
|
||||
setUserPolicies((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
onClick={() => void saveUserOverrides()}
|
||||
>
|
||||
保存用户策略
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void resetUserOverrides()}
|
||||
>
|
||||
恢复角色默认
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
createLlmProviderKey,
|
||||
deleteLlmProviderKey,
|
||||
getLlmGlobalSettings,
|
||||
listLlmProviderCatalog,
|
||||
listLlmProviderKeys,
|
||||
selectLlmProviderKey,
|
||||
setLlmGlobalModel,
|
||||
syncLlmProviderToGoosed,
|
||||
testLlmProviderDraft,
|
||||
testLlmProviderKey,
|
||||
updateLlmProviderKey,
|
||||
} from '../api/client';
|
||||
import type {
|
||||
LlmConnectionTestResult,
|
||||
LlmGlobalSettings,
|
||||
LlmProviderDefinition,
|
||||
LlmProviderKeyRow,
|
||||
} from '../types';
|
||||
|
||||
const CUSTOM_PROVIDER_ID = '__custom__';
|
||||
|
||||
const emptyForm = {
|
||||
providerId: CUSTOM_PROVIDER_ID,
|
||||
name: 'Relay Buyer Ollama',
|
||||
apiKey: 'UqyHPKSSEZq0-oPnl8sru-7hZcJ2anPUL1yAVk866Vo',
|
||||
apiUrl: 'http://127.0.0.1:18300/relay/buyer/v1/chat/completions',
|
||||
basePath: '',
|
||||
modelsText: 'qwen2.5:3b',
|
||||
defaultModel: 'qwen2.5:3b',
|
||||
relayProvider: 'ollama',
|
||||
engine: 'openai',
|
||||
};
|
||||
|
||||
function parseModelsText(text: string) {
|
||||
return [...new Set(text.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function TestResultBanner({ result }: { result: LlmConnectionTestResult | null }) {
|
||||
if (!result) return null;
|
||||
if (!result.ok) {
|
||||
return <p className="banner banner-error">联通失败:{result.message ?? '未知错误'}</p>;
|
||||
}
|
||||
return (
|
||||
<p className="banner banner-info">
|
||||
联通成功({result.latencyMs}ms,模型 {result.model}):{result.reply}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderKeySettings() {
|
||||
const [catalog, setCatalog] = useState<LlmProviderDefinition[]>([]);
|
||||
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
|
||||
const [globalSettings, setGlobalSettings] = useState<LlmGlobalSettings | null>(null);
|
||||
const [globalModelDraft, setGlobalModelDraft] = useState('');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [testingForm, setTestingForm] = useState(false);
|
||||
const [testingGlobal, setTestingGlobal] = useState(false);
|
||||
const [testingKeyId, setTestingKeyId] = useState<string | null>(null);
|
||||
const [formTestResult, setFormTestResult] = useState<LlmConnectionTestResult | null>(null);
|
||||
const [globalTestResult, setGlobalTestResult] = useState<LlmConnectionTestResult | null>(null);
|
||||
|
||||
const isCustom = form.providerId === CUSTOM_PROVIDER_ID;
|
||||
const selectedCatalog = useMemo(
|
||||
() => catalog.find((item) => item.id === form.providerId) ?? null,
|
||||
[catalog, form.providerId],
|
||||
);
|
||||
const customModels = useMemo(() => parseModelsText(form.modelsText), [form.modelsText]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, keyRows, globalState] = await Promise.all([
|
||||
listLlmProviderCatalog(),
|
||||
listLlmProviderKeys(),
|
||||
getLlmGlobalSettings(),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setKeys(keyRows);
|
||||
setGlobalSettings(globalState);
|
||||
setGlobalModelDraft(globalState.globalModel ?? '');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载 LLM 配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCustom) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
defaultModel: current.defaultModel || customModels[0] || '',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!selectedCatalog) return;
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
defaultModel: current.defaultModel || selectedCatalog.defaultModel,
|
||||
}));
|
||||
}, [customModels, isCustom, selectedCatalog]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
setFormTestResult(null);
|
||||
try {
|
||||
if (isCustom) {
|
||||
await createLlmProviderKey({
|
||||
providerId: CUSTOM_PROVIDER_ID,
|
||||
name: form.name,
|
||||
apiKey: form.apiKey,
|
||||
apiUrl: form.apiUrl,
|
||||
basePath: form.basePath || undefined,
|
||||
models: customModels,
|
||||
defaultModel: form.defaultModel || customModels[0],
|
||||
engine: form.engine,
|
||||
relayProvider: form.relayProvider || undefined,
|
||||
});
|
||||
} else {
|
||||
await createLlmProviderKey({
|
||||
providerId: form.providerId,
|
||||
name: form.name,
|
||||
apiKey: form.apiKey,
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
});
|
||||
}
|
||||
setForm({ ...emptyForm, providerId: form.providerId });
|
||||
setMessage('LLM 配置已保存到数据库');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestForm = async () => {
|
||||
setTestingForm(true);
|
||||
setFormTestResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await testLlmProviderDraft({
|
||||
providerId: CUSTOM_PROVIDER_ID,
|
||||
name: form.name || 'test',
|
||||
apiKey: form.apiKey,
|
||||
apiUrl: form.apiUrl,
|
||||
basePath: form.basePath || undefined,
|
||||
models: customModels,
|
||||
defaultModel: form.defaultModel || customModels[0],
|
||||
testModel: form.defaultModel || customModels[0],
|
||||
relayProvider: form.relayProvider || undefined,
|
||||
});
|
||||
setFormTestResult(result);
|
||||
} catch (err) {
|
||||
setFormTestResult({
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : '联通测试失败',
|
||||
});
|
||||
} finally {
|
||||
setTestingForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyGlobalModel = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
setGlobalTestResult(null);
|
||||
try {
|
||||
const result = await setLlmGlobalModel(globalModelDraft);
|
||||
setGlobalSettings(result.global);
|
||||
setMessage(`全局模型已设为 ${result.global.globalModel},并已同步到 TKMind Agent`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存全局模型失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestGlobal = async () => {
|
||||
if (!globalSettings?.keyId) {
|
||||
setError('请先启用一个 LLM 配置');
|
||||
return;
|
||||
}
|
||||
setTestingGlobal(true);
|
||||
setGlobalTestResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await testLlmProviderKey(
|
||||
globalSettings.keyId,
|
||||
globalModelDraft || globalSettings.globalModel || undefined,
|
||||
);
|
||||
setGlobalTestResult(result);
|
||||
} catch (err) {
|
||||
setGlobalTestResult({
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : '联通测试失败',
|
||||
});
|
||||
} finally {
|
||||
setTestingGlobal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = async (keyId: string) => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await selectLlmProviderKey(keyId);
|
||||
setMessage('已切换当前 LLM 并同步到 TKMind Agent');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '切换失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestKey = async (row: LlmProviderKeyRow) => {
|
||||
setTestingKeyId(row.id);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await testLlmProviderKey(row.id, row.defaultModel);
|
||||
if (result.ok) {
|
||||
setMessage(`「${row.name}」联通成功(${result.latencyMs}ms):${result.reply}`);
|
||||
} else {
|
||||
setError(`「${row.name}」联通失败:${result.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '联通测试失败');
|
||||
} finally {
|
||||
setTestingKeyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (row: LlmProviderKeyRow) => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await updateLlmProviderKey(row.id, {
|
||||
status: row.status === 'active' ? 'disabled' : 'active',
|
||||
});
|
||||
setMessage('状态已更新');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (row: LlmProviderKeyRow) => {
|
||||
if (!window.confirm(`确定删除配置「${row.name}」?`)) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteLlmProviderKey(row.id);
|
||||
setMessage('配置已删除');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await syncLlmProviderToGoosed();
|
||||
setMessage(result.synced ? '已重新同步到 TKMind Agent' : '当前没有启用的 LLM 配置');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '同步失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>LLM Key 管理</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
服务启动时会自动导入 Relay Buyer Ollama(若尚未存在)。全局模型会写入
|
||||
GOOSE_MODEL / TKMIND_MODEL,全站对话均使用该模型。
|
||||
</p>
|
||||
|
||||
<div className="admin-card global-model-card">
|
||||
<div className="admin-card-head">
|
||||
<h3>全局模型</h3>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : !globalSettings?.keyId ? (
|
||||
<p className="muted">尚未启用 LLM 配置。保存并启用 Relay 后即可选择全局模型。</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="muted">
|
||||
当前 Provider:<strong>{globalSettings.providerLabel}</strong>(
|
||||
{globalSettings.keyName})
|
||||
</p>
|
||||
<div className="admin-form global-model-form">
|
||||
<select
|
||||
value={globalModelDraft}
|
||||
onChange={(e) => setGlobalModelDraft(e.target.value)}
|
||||
>
|
||||
{globalSettings.availableModels.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="send-btn" onClick={() => void handleApplyGlobalModel()}>
|
||||
应用全局模型
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={testingGlobal}
|
||||
onClick={() => void handleTestGlobal()}
|
||||
>
|
||||
{testingGlobal ? '测试中…' : '联通测试'}
|
||||
</button>
|
||||
</div>
|
||||
<TestResultBanner result={globalTestResult} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="admin-form" onSubmit={handleCreate}>
|
||||
<select
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((state) => ({
|
||||
...state,
|
||||
providerId: e.target.value,
|
||||
defaultModel:
|
||||
e.target.value === CUSTOM_PROVIDER_ID
|
||||
? parseModelsText(state.modelsText)[0] ?? ''
|
||||
: (catalog.find((item) => item.id === e.target.value)?.defaultModel ?? ''),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{catalog.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
placeholder="配置名称"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((state) => ({ ...state, name: e.target.value }))}
|
||||
/>
|
||||
|
||||
{isCustom ? (
|
||||
<>
|
||||
<input
|
||||
placeholder="API 地址"
|
||||
value={form.apiUrl}
|
||||
onChange={(e) => setForm((state) => ({ ...state, apiUrl: e.target.value }))}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="模型列表,每行一个"
|
||||
value={form.modelsText}
|
||||
rows={4}
|
||||
onChange={(e) =>
|
||||
setForm((state) => ({
|
||||
...state,
|
||||
modelsText: e.target.value,
|
||||
defaultModel: parseModelsText(e.target.value)[0] ?? state.defaultModel,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
placeholder="Relay Provider(请求 body 中的 provider,如 ollama)"
|
||||
value={form.relayProvider}
|
||||
onChange={(e) => setForm((state) => ({ ...state, relayProvider: e.target.value }))}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
placeholder="API Key / Bearer Token"
|
||||
type="password"
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((state) => ({ ...state, apiKey: e.target.value }))}
|
||||
/>
|
||||
|
||||
{isCustom ? (
|
||||
<select
|
||||
value={form.defaultModel || customModels[0] || ''}
|
||||
onChange={(e) => setForm((state) => ({ ...state, defaultModel: e.target.value }))}
|
||||
>
|
||||
{customModels.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
value={form.defaultModel || selectedCatalog?.defaultModel || ''}
|
||||
onChange={(e) => setForm((state) => ({ ...state, defaultModel: e.target.value }))}
|
||||
>
|
||||
{(selectedCatalog?.models ?? []).map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button type="submit" className="send-btn">
|
||||
添加配置
|
||||
</button>
|
||||
{isCustom && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={testingForm}
|
||||
onClick={() => void handleTestForm()}
|
||||
>
|
||||
{testingForm ? '测试中…' : '联通测试'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<TestResultBanner result={formTestResult} />
|
||||
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
|
||||
<div className="admin-card-head">
|
||||
<h3>已保存配置</h3>
|
||||
<button type="button" className="ghost-btn" onClick={() => void handleSync()}>
|
||||
手动同步 Agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="muted">暂无配置,重启服务后会自动导入 Relay Buyer Ollama。</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>Endpoint</th>
|
||||
<th>模型</th>
|
||||
<th>Key</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
{row.name}
|
||||
{row.isSelected && <span className="risk-pill risk-low">当前</span>}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{row.providerKind === 'custom' ? (
|
||||
<>
|
||||
<div>{row.apiUrl}</div>
|
||||
{row.relayProvider && (
|
||||
<div className="muted">provider: {row.relayProvider}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
row.providerLabel
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">
|
||||
<div>{row.defaultModel}</div>
|
||||
{row.models.length > 1 && (
|
||||
<div className="muted">+{row.models.length - 1} 个可选</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">{row.apiKeyMasked}</td>
|
||||
<td>{row.status === 'active' ? '可用' : '禁用'}</td>
|
||||
<td className="admin-actions">
|
||||
{row.providerKind === 'custom' && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={testingKeyId === row.id}
|
||||
onClick={() => void handleTestKey(row)}
|
||||
>
|
||||
{testingKeyId === row.id ? '测试中…' : '测试'}
|
||||
</button>
|
||||
)}
|
||||
{!row.isSelected && row.status === 'active' && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void handleSelect(row.id)}
|
||||
>
|
||||
启用
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={row.isSelected}
|
||||
onClick={() => void handleToggleStatus(row)}
|
||||
>
|
||||
{row.status === 'active' ? '禁用' : '恢复'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={row.isSelected}
|
||||
onClick={() => void handleDelete(row)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
clearUserSkillOverrides,
|
||||
getRoleSkills,
|
||||
getUserSkills,
|
||||
listSkillCatalog,
|
||||
updateRoleSkills,
|
||||
updateUserSkills,
|
||||
} from '../api/client';
|
||||
import type { AdminUserRow, SkillDefinition, SkillMap } from '../types';
|
||||
|
||||
function SkillGrid({
|
||||
catalog,
|
||||
values,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
catalog: SkillDefinition[];
|
||||
values: SkillMap;
|
||||
disabled?: boolean;
|
||||
onChange: (name: string, enabled: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="capability-grid">
|
||||
<div className="capability-group">
|
||||
<h3>平台技能</h3>
|
||||
<ul>
|
||||
{catalog.map((item) => (
|
||||
<li key={item.name}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(values[item.name])}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(item.name, e.target.checked)}
|
||||
/>
|
||||
<span className="capability-label">
|
||||
{item.label}
|
||||
<code className="skill-name-tag">{item.name}</code>
|
||||
{item.requiresPublish && (
|
||||
<span className="risk-pill risk-medium">含页面发布</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="muted capability-desc">{item.description}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillSettings({
|
||||
users,
|
||||
userId,
|
||||
userOnly = false,
|
||||
}: {
|
||||
users: AdminUserRow[];
|
||||
userId?: string;
|
||||
userOnly?: boolean;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<SkillDefinition[]>([]);
|
||||
const [roleSkills, setRoleSkills] = useState<SkillMap>({});
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
|
||||
const [userSkills, setUserSkills] = useState<SkillMap>({});
|
||||
const [userOverrides, setUserOverrides] = useState<SkillMap>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, roleState] = await Promise.all([
|
||||
listSkillCatalog(),
|
||||
getRoleSkills('user'),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setRoleSkills(roleState.skills);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载技能目录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUserSkills = useCallback(async (userId: string) => {
|
||||
if (!userId) {
|
||||
setUserSkills({});
|
||||
setUserOverrides({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = await getUserSkills(userId);
|
||||
setUserSkills(state.skills);
|
||||
setUserOverrides(state.overrides);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载用户技能失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUserSkills(selectedUserId);
|
||||
}, [loadUserSkills, selectedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
setSelectedUserId(userId);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveRoleDefaults = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateRoleSkills('user', roleSkills);
|
||||
setRoleSkills(result.skills);
|
||||
setMessage('普通用户默认技能已保存,并已同步到各用户工作区');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateUserSkills(selectedUserId, userSkills);
|
||||
setUserSkills(result.skills);
|
||||
await loadUserSkills(selectedUserId);
|
||||
setMessage('用户技能已保存,并已安装到其 MindSpace 发布目录');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await clearUserSkillOverrides(selectedUserId);
|
||||
await loadUserSkills(selectedUserId);
|
||||
setMessage('已恢复为角色默认技能');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const regularUsers = users.filter((user) => user.role === 'user');
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>技能配置</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted capability-intro">
|
||||
勾选后技能会安装到用户 <code>MindSpace/用户名/.agents/skills/</code>,对话中可通过 load_skill
|
||||
使用。开通「静态页面发布」技能会自动启用页面写入能力。
|
||||
</p>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : catalog.length === 0 ? (
|
||||
<p className="muted">暂无平台技能,请在 ui/h5/skills/ 下添加 SKILL.md</p>
|
||||
) : (
|
||||
<>
|
||||
{!userOnly && (
|
||||
<>
|
||||
<h3 className="capability-section-title">普通用户默认技能</h3>
|
||||
<SkillGrid
|
||||
catalog={catalog}
|
||||
values={roleSkills}
|
||||
onChange={(name, enabled) => setRoleSkills((prev) => ({ ...prev, [name]: enabled }))}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
|
||||
保存默认技能
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!userOnly && <h3 className="capability-section-title">单用户技能覆盖</h3>}
|
||||
{!userOnly && (
|
||||
<div className="admin-form capability-user-picker">
|
||||
<select value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}>
|
||||
<option value="">选择用户(可选)</option>
|
||||
{regularUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.displayName} (@{user.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{selectedUserId && (
|
||||
<>
|
||||
{Object.keys(userOverrides).length > 0 && (
|
||||
<p className="muted">
|
||||
已设置 {Object.keys(userOverrides).length} 项单独覆盖。
|
||||
</p>
|
||||
)}
|
||||
<SkillGrid
|
||||
catalog={catalog}
|
||||
values={userSkills}
|
||||
onChange={(name, enabled) =>
|
||||
setUserSkills((prev) => ({ ...prev, [name]: enabled }))
|
||||
}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveUserOverrides()}>
|
||||
保存用户技能
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={() => void resetUserOverrides()}>
|
||||
恢复角色默认
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+804
@@ -0,0 +1,804 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
|
||||
--color-bg-base: #0f1419;
|
||||
--color-bg-surface: #121820;
|
||||
--color-bg-elevated: #1c2735;
|
||||
--color-bg-active: #1a3050;
|
||||
--color-bg-code: #0f1419;
|
||||
--color-bg-warning: #2a2318;
|
||||
--color-bg-error: #3a1f24;
|
||||
--color-bg-info: #152033;
|
||||
|
||||
--color-text-primary: #e7ecf3;
|
||||
--color-text-secondary: #c9d7ea;
|
||||
--color-text-muted: #8fa3bf;
|
||||
--color-text-faint: #6b829e;
|
||||
--color-text-meta: #5f738d;
|
||||
--color-text-link: #6ab0ff;
|
||||
--color-text-error: #ffb4b0;
|
||||
--color-text-info: #9ec5ff;
|
||||
|
||||
--color-border: #243041;
|
||||
--color-border-input: #2a3a50;
|
||||
--color-border-warning: #5c4a2a;
|
||||
--color-border-input-focus: #3d8bfd;
|
||||
|
||||
--color-accent: #3d8bfd;
|
||||
--color-accent-user: #2458a6;
|
||||
--color-danger: #c4453d;
|
||||
|
||||
--radius-sm: 8px;
|
||||
--radius-xs: 4px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 16px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 12px;
|
||||
--space-lg: 16px;
|
||||
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── Shared buttons ──────────────────────────────────── */
|
||||
|
||||
.ghost-btn,
|
||||
.send-btn,
|
||||
.danger-btn {
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 10px 14px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ghost-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
background: var(--color-accent);
|
||||
color: white;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.danger-btn {
|
||||
background: var(--color-danger);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ── Banners ─────────────────────────────────────────── */
|
||||
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
padding: 10px var(--space-lg);
|
||||
font-size: 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.banner-error {
|
||||
background: var(--color-bg-error);
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
|
||||
.banner-info {
|
||||
background: var(--color-bg-info);
|
||||
color: var(--color-text-info);
|
||||
}
|
||||
|
||||
.banner-warning {
|
||||
background: var(--color-bg-warning);
|
||||
color: var(--color-text-muted);
|
||||
border-bottom: 1px solid var(--color-border-warning);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* ── Admin login ─────────────────────────────────────── */
|
||||
|
||||
.admin-login-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-base);
|
||||
}
|
||||
|
||||
.admin-login-card {
|
||||
width: 340px;
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 32px 28px;
|
||||
}
|
||||
|
||||
.admin-login-title {
|
||||
margin: 0 0 24px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-login-input {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-input);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.admin-login-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-border-input-focus);
|
||||
}
|
||||
|
||||
/* ── Admin shell ─────────────────────────────────────── */
|
||||
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-base);
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.admin-topbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-topbar-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-topbar-user {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-back-link {
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
flex: 0 0 200px;
|
||||
padding: 16px 12px;
|
||||
border-right: 1px solid var(--color-border);
|
||||
background: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.admin-sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-nav-section {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-nav-section-label {
|
||||
padding: 0 12px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-nav-link {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.admin-nav-link:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.admin-nav-link.active {
|
||||
background: var(--color-bg-active);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 20px 24px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ── Admin page ──────────────────────────────────────── */
|
||||
|
||||
.admin-page {
|
||||
min-height: 100%;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-page-head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-page-head h2 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.admin-back-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* ── Admin stat cards ────────────────────────────────── */
|
||||
|
||||
.admin-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-stat-card {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.admin-stat-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-link-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-link-card {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.admin-link-card:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.admin-link-card-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.admin-inline-link {
|
||||
color: var(--color-text-link);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ── Admin tabs ──────────────────────────────────────── */
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.admin-tab:hover {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-elevated);
|
||||
}
|
||||
|
||||
.admin-tab.active {
|
||||
color: var(--color-text-primary);
|
||||
background: var(--color-bg-surface);
|
||||
border-color: var(--color-border);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Admin cards / forms / tables ────────────────────── */
|
||||
|
||||
.admin-header h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-card h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.admin-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-form input,
|
||||
.admin-form select,
|
||||
.admin-form textarea {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-input);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.admin-form textarea {
|
||||
grid-column: 1 / -1;
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.global-model-card {
|
||||
margin-bottom: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-subtle, rgba(0, 0, 0, 0.02));
|
||||
}
|
||||
|
||||
.global-model-form {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.admin-table-wrap {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 10px 8px;
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.admin-table .mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Admin detail / dl ───────────────────────────────── */
|
||||
|
||||
.admin-dl {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 4px 16px;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-dl div {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.admin-dl dt {
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.admin-dl dd {
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* ── Skill tag ───────────────────────────────────────── */
|
||||
|
||||
.skill-name-tag {
|
||||
margin-left: var(--space-sm);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-bg-code);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.publish-banner a {
|
||||
color: var(--color-text-link);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── Capability settings ─────────────────────────────── */
|
||||
|
||||
.capability-intro {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.capability-section-title {
|
||||
margin: 1.25rem 0 0.75rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.capability-filter-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.capability-filter-btn {
|
||||
padding: 0.3rem 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-border-input);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.capability-filter-btn:hover {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.capability-filter-btn.active {
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.capability-grid {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.capability-group {
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.capability-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.6rem 1rem;
|
||||
background: var(--color-bg-elevated);
|
||||
border-bottom: 1px solid var(--color-border-input);
|
||||
}
|
||||
|
||||
.capability-group-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.capability-group-count {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.capability-group ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.capability-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.capability-item + .capability-item {
|
||||
border-top: 1px solid var(--color-border-input);
|
||||
}
|
||||
|
||||
.capability-item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.capability-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.capability-desc {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.capability-toggle {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.capability-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-track {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-border-input);
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.capability-toggle input:checked + .toggle-track {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.capability-toggle input:disabled + .toggle-track {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toggle-thumb {
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: left 0.2s;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.capability-toggle input:checked + .toggle-track .toggle-thumb {
|
||||
left: 19px;
|
||||
}
|
||||
|
||||
.risk-pill {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.risk-low {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.risk-medium {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #ca8a04;
|
||||
}
|
||||
|
||||
.risk-high {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.capability-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.capability-user-picker {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Billing page ────────────────────────────────────── */
|
||||
|
||||
.billing-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.billing-filter-select {
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border-input);
|
||||
background: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.billing-tab-content {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.billing-time {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.billing-num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.billing-note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.billing-empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-income {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.ledger-type-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ledger-type-recharge {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.ledger-type-deduct {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.ledger-type-refund {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.ledger-type-adjust {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
color: #ca8a04;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
export type InsufficientBalanceDetails = {
|
||||
code: 'INSUFFICIENT_BALANCE';
|
||||
balanceCents: number;
|
||||
minRechargeCents: number;
|
||||
suggestedTiers: number[];
|
||||
};
|
||||
|
||||
export type PortalUser = {
|
||||
id: string;
|
||||
username: string;
|
||||
slug?: string;
|
||||
email?: string | null;
|
||||
displayName: string;
|
||||
role: 'user' | 'admin';
|
||||
status: 'active' | 'suspended' | 'disabled';
|
||||
planType?: 'free' | 'growth' | 'pro' | 'enterprise';
|
||||
workspaceRoot: string;
|
||||
balanceCents: number;
|
||||
totalCreditCents?: number;
|
||||
tokensUsed: number;
|
||||
publishSlug?: string;
|
||||
publishUrl?: string;
|
||||
publishSkillName?: string;
|
||||
};
|
||||
|
||||
export type CapabilityMap = Record<string, boolean>;
|
||||
|
||||
export type PolicyValue = string | boolean;
|
||||
export type PolicyMap = Record<string, PolicyValue>;
|
||||
|
||||
export type SkillMap = Record<string, boolean>;
|
||||
|
||||
export type AuthStatus = {
|
||||
authenticated: boolean;
|
||||
mode?: 'user' | 'legacy' | 'none';
|
||||
user?: PortalUser | null;
|
||||
capabilities?: CapabilityMap;
|
||||
grantedSkills?: string[];
|
||||
unrestricted?: boolean;
|
||||
};
|
||||
|
||||
export type AdminUserRow = PortalUser & {
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type CapabilityDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
risk: 'low' | 'medium' | 'high';
|
||||
category: string;
|
||||
};
|
||||
|
||||
export type SkillDefinition = {
|
||||
name: string;
|
||||
dirName: string;
|
||||
label: string;
|
||||
description: string;
|
||||
category: string;
|
||||
requiresPublish?: boolean;
|
||||
};
|
||||
|
||||
export type PolicyDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: 'select' | 'boolean';
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
defaultValue: PolicyValue;
|
||||
category: string;
|
||||
risk: 'low' | 'medium' | 'high';
|
||||
};
|
||||
|
||||
export type LlmProviderDefinition = {
|
||||
id: string;
|
||||
label: string;
|
||||
kind?: 'builtin' | 'custom';
|
||||
apiKeyEnv: string | null;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
};
|
||||
|
||||
export type LlmProviderKeyRow = {
|
||||
id: string;
|
||||
providerId: string;
|
||||
providerKind: 'builtin' | 'custom';
|
||||
providerLabel: string;
|
||||
name: string;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
apiUrl: string | null;
|
||||
basePath: string | null;
|
||||
engine: string;
|
||||
relayProvider: string | null;
|
||||
goosedProviderId: string | null;
|
||||
status: 'active' | 'disabled';
|
||||
isSelected: boolean;
|
||||
apiKeyMasked: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type LlmGlobalSettings = {
|
||||
keyId: string | null;
|
||||
keyName: string | null;
|
||||
providerLabel: string | null;
|
||||
globalModel: string | null;
|
||||
availableModels: string[];
|
||||
};
|
||||
|
||||
export type LlmConnectionTestResult = {
|
||||
ok: boolean;
|
||||
latencyMs?: number;
|
||||
reply?: string;
|
||||
model?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type UsageRecord = {
|
||||
id: number;
|
||||
userId: string;
|
||||
username: string;
|
||||
agentSessionId: string;
|
||||
requestId: string | null;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
costCents: number;
|
||||
balanceAfterCents: number;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type LedgerEntry = {
|
||||
id: number;
|
||||
userId: string;
|
||||
username: string;
|
||||
type: 'recharge' | 'deduct' | 'refund' | 'adjust';
|
||||
amountCents: number;
|
||||
tokens: number;
|
||||
sessionId: string | null;
|
||||
note: string | null;
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type AdminDashboardSummary = {
|
||||
users: {
|
||||
total: number;
|
||||
active: number;
|
||||
lowBalance: number;
|
||||
totalBalanceCents: number;
|
||||
};
|
||||
usage24h: {
|
||||
count: number;
|
||||
costCents: number;
|
||||
};
|
||||
lowBalanceUsers: Array<{
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
balanceCents: number;
|
||||
}>;
|
||||
recentUsage: UsageRecord[];
|
||||
recentLedger: LedgerEntry[];
|
||||
llm: {
|
||||
keyCount: number;
|
||||
selectedKeyName: string | null;
|
||||
globalModel: string | null;
|
||||
} | null;
|
||||
};
|
||||
Reference in New Issue
Block a user