Remove embedded admin UI and redirect admins to standalone ops app.

Extract admin routes from the main SPA, add plaza-api bootstrap module, and track ops pnpm lockfile.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-16 19:43:56 +08:00
parent f818dd0307
commit befeedfd37
20 changed files with 1681 additions and 2333 deletions
+1179
View File
File diff suppressed because it is too large Load Diff
+496
View File
@@ -0,0 +1,496 @@
import express from 'express';
import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs';
import { createPlazaInteractionService } from './plaza-interactions.mjs';
import {
ensureAlgorithmConfig,
loadAlgorithmConfig,
recalculateHotScores,
} from './plaza-algorithm.mjs';
import { createPlazaRedis, createNoopPlazaRedis } from './plaza-redis.mjs';
import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs';
import { createPlazaSeoService } from './plaza-seo.mjs';
import { createPlazaOpsService, hasOpsRole } from './plaza-ops.mjs';
export async function bootstrapPlaza(pool) {
await ensureAlgorithmConfig(pool);
const algorithmConfig = await loadAlgorithmConfig(pool);
const redis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool);
if (redis.enabled) {
console.log('Plaza Redis enabled');
}
const seo = createPlazaSeoService(pool);
const interactions = createPlazaInteractionService(pool, {
formatPostRow,
plazaRedis: redis,
});
let ops = null;
const posts = createPlazaPostService(pool, {
loadViewerReactions: (viewerId, postIds) =>
interactions.loadViewerReactions(viewerId, postIds),
plazaRedis: redis,
algorithmConfig,
onPostPublished: (postId) => seo?.notifyPostPublished(postId),
loadFeaturedPosts: async (viewerId) => {
if (!ops) return { homepage_banner: [], trending: [], category_top: {} };
return ops.loadActiveFeaturedPosts(viewerId);
},
});
ops = createPlazaOpsService(pool, {
formatPostRow,
reviewPost: (...args) => posts.reviewPost(...args),
invalidateFeedCaches: () => redis?.invalidateFeedCaches?.(),
});
startPlazaTasks({
pool,
plazaRedis: redis,
recalculateHotScores,
writebackPublications,
});
return { posts, interactions, seo, ops, redis };
}
export function createNoopPlaza() {
return { posts: null, interactions: null, seo: null, ops: null, redis: createNoopPlazaRedis() };
}
export function isPlazaPublicRead(path, method) {
if (method !== 'GET' || !path.startsWith('/plaza/v1/')) return false;
return (
path === '/plaza/v1/feed' ||
path === '/plaza/v1/categories' ||
path === '/plaza/v1/seo/sitemap' ||
/^\/plaza\/v1\/posts\/[^/]+$/.test(path) ||
/^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path) ||
/^\/plaza\/v1\/users\/[^/]+$/.test(path) ||
/^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path)
);
}
export function plazaClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string' && forwarded.length > 0) {
return forwarded.split(',')[0].trim();
}
return req.ip;
}
function plazaRouteError(res, req, error, { sendError }) {
const status = mapPlazaError(error);
const code = error?.code ?? 'internal_error';
const message = error instanceof Error ? error.message : 'Plaza 请求失败';
return sendError(res, req, status, code, message, error?.details);
}
export function attachPlazaApiRoutes(api, plaza, { sendData, sendError }) {
const { posts, interactions, seo, ops, redis } = plaza;
function ensurePosts(res, req) {
if (!posts) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return false; }
return true;
}
function ensureInteractions(res, req) {
if (!interactions) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return false; }
return true;
}
function routeError(res, req, error) {
return plazaRouteError(res, req, error, { sendError });
}
function makeRequireOps(minRole = 'reviewer') {
return async (req, res, next) => {
if (!ops) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
const role = await ops.loadOperatorRole(req.currentUser.id);
if (!hasOpsRole(role, minRole)) {
return sendError(res, req, 403, 'OPS_PERMISSION_DENIED', '无运营权限');
}
req.opsRole = role;
return next();
};
}
// ---- Plaza feed & posts ----
api.get('/plaza/v1/categories', async (req, res) => {
if (!ensurePosts(res, req)) return;
try {
return sendData(res, req, { categories: await posts.listCategories() });
} catch (error) {
return routeError(res, req, error);
}
});
api.get('/plaza/v1/seo/sitemap', async (req, res) => {
if (!ensurePosts(res, req)) return;
if (!seo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza SEO 未启用');
try {
const data = await seo.listSitemapData({
postLimit: req.query.post_limit,
userLimit: req.query.user_limit,
});
return sendData(res, req, data);
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/attribution/events', async (req, res) => {
if (!seo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
try {
const result = await seo.recordAttribution(req.body ?? {}, plazaClientIp(req));
return sendData(res, req, result, 201);
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/reports', async (req, res) => {
if (!ops) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const report = await ops.createReport(req.currentUser.id, {
target_type: 'post',
target_id: req.params.id,
reason: req.body?.reason,
detail: req.body?.detail,
});
return sendData(res, req, { report }, 201);
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/comments/:id/reports', async (req, res) => {
if (!ops) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用');
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const report = await ops.createReport(req.currentUser.id, {
target_type: 'comment',
target_id: req.params.id,
reason: req.body?.reason,
detail: req.body?.detail,
});
return sendData(res, req, { report }, 201);
} catch (error) {
return routeError(res, req, error);
}
});
api.get('/plaza/v1/feed', async (req, res) => {
if (!ensurePosts(res, req)) return;
try {
const feed = await posts.listFeed({
sort: req.query.sort,
categorySlug: req.query.category ?? null,
cursor: req.query.cursor ?? null,
limit: req.query.limit,
viewerId: req.currentUser?.id ?? null,
});
return sendData(res, req, feed);
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/reactions', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await interactions.addReaction(req.currentUser.id, req.params.id, req.body?.type);
return sendData(res, req, result);
} catch (error) {
return routeError(res, req, error);
}
});
api.delete('/plaza/v1/posts/:id/reactions/:type', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await interactions.removeReaction(req.currentUser.id, req.params.id, req.params.type);
return sendData(res, req, result);
} catch (error) {
return routeError(res, req, error);
}
});
api.get('/plaza/v1/posts/:id/comments', async (req, res) => {
if (!ensureInteractions(res, req)) return;
try {
const comments = await interactions.listComments(req.params.id, {
cursor: req.query.cursor ?? null,
limit: req.query.limit,
parentId: req.query.parent_id ?? null,
viewerId: req.currentUser?.id ?? null,
});
return sendData(res, req, comments);
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/posts/:id/comments', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const comment = await interactions.createComment(req.currentUser.id, req.params.id, req.body ?? {});
return sendData(res, req, { comment }, 201);
} catch (error) {
return routeError(res, req, error);
}
});
api.delete('/plaza/v1/comments/:id', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const comment = await interactions.deleteComment(req.currentUser.id, req.params.id);
return sendData(res, req, { comment });
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/comments/:id/reactions', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const liked = req.body?.liked !== false;
const result = await interactions.toggleCommentLike(req.currentUser.id, req.params.id, liked);
return sendData(res, req, result);
} catch (error) {
return routeError(res, req, error);
}
});
api.get('/plaza/v1/users/:slug', async (req, res) => {
if (!ensureInteractions(res, req)) return;
try {
const profile = await interactions.getUserProfile(req.params.slug, req.currentUser?.id ?? null);
return sendData(res, req, profile);
} catch (error) {
return routeError(res, req, error);
}
});
api.get('/plaza/v1/users/:slug/posts', async (req, res) => {
if (!ensureInteractions(res, req)) return;
try {
const feed = await interactions.listUserPosts(req.params.slug, {
cursor: req.query.cursor ?? null,
limit: req.query.limit,
viewerId: req.currentUser?.id ?? null,
});
return sendData(res, req, feed);
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/users/:slug/follow', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await interactions.followUser(req.currentUser.id, req.params.slug);
return sendData(res, req, result);
} catch (error) {
return routeError(res, req, error);
}
});
api.delete('/plaza/v1/users/:slug/follow', async (req, res) => {
if (!ensureInteractions(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const result = await interactions.unfollowUser(req.currentUser.id, req.params.slug);
return sendData(res, req, result);
} catch (error) {
return routeError(res, req, error);
}
});
api.get('/plaza/v1/posts/:id', async (req, res) => {
if (!ensurePosts(res, req)) return;
try {
const post = await posts.getPostById(req.params.id, {
viewerId: req.currentUser?.id ?? null,
});
void redis.recordView(req.params.id, plazaClientIp(req)).catch(() => {});
return sendData(res, req, { post });
} catch (error) {
return routeError(res, req, error);
}
});
api.post('/plaza/v1/posts', async (req, res) => {
if (!ensurePosts(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const post = await posts.createPost(req.currentUser.id, req.body ?? {});
return sendData(res, req, { post }, 201);
} catch (error) {
return routeError(res, req, error);
}
});
api.patch('/plaza/v1/posts/:id', async (req, res) => {
if (!ensurePosts(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const post = await posts.updatePost(req.currentUser.id, req.params.id, req.body ?? {});
return sendData(res, req, { post });
} catch (error) {
return routeError(res, req, error);
}
});
api.delete('/plaza/v1/posts/:id', async (req, res) => {
if (!ensurePosts(res, req)) return;
if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录');
try {
const post = await posts.hidePost(req.currentUser.id, req.params.id);
return sendData(res, req, { post });
} catch (error) {
return routeError(res, req, error);
}
});
// ---- Ops API ----
const opsApi = express.Router();
opsApi.use(express.json({ limit: '1mb' }));
opsApi.use(makeRequireOps('reviewer'));
opsApi.get('/review/queue', async (req, res) => {
try {
const queue = await ops.listReviewQueue({
status: req.query.status ?? 'pending_review',
cursor: req.query.cursor ?? null,
limit: req.query.limit,
keyword: req.query.keyword ?? null,
});
return sendData(res, req, queue);
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.post('/review/posts/:id', async (req, res) => {
try {
const result = await ops.reviewPostAsOps(req.currentUser.id, req.params.id, req.body?.action, {
reason: req.body?.reason ?? null,
});
return sendData(res, req, { post: result });
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.post('/review/batch', makeRequireOps('editor'), async (req, res) => {
try {
const result = await ops.batchReviewPosts(req.currentUser.id, req.body ?? {});
return sendData(res, req, result);
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.get('/reports', async (req, res) => {
try {
return sendData(res, req, await ops.listReports({ status: req.query.status ?? 'pending' }));
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.post('/reports/:id/process', async (req, res) => {
try {
const result = await ops.processReport(req.currentUser.id, req.params.id, req.body ?? {});
return sendData(res, req, { report: result });
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.get('/featured', makeRequireOps('editor'), async (req, res) => {
try {
return sendData(res, req, await ops.listFeatured());
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.post('/featured', makeRequireOps('editor'), async (req, res) => {
try {
const result = await ops.setFeatured(req.currentUser.id, req.body ?? {});
return sendData(res, req, { featured: result }, 201);
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.delete('/featured/:id', makeRequireOps('editor'), async (req, res) => {
try {
const result = await ops.removeFeatured(req.currentUser.id, req.params.id);
return sendData(res, req, { featured: result });
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.get('/analytics/overview', makeRequireOps('ops_admin'), async (req, res) => {
try {
return sendData(res, req, await ops.getAnalyticsOverview());
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.get('/creators', async (req, res) => {
try {
return sendData(res, req, await ops.listCreators({ keyword: req.query.keyword ?? null }));
} catch (error) {
return routeError(res, req, error);
}
});
opsApi.patch('/creators/:userId', async (req, res) => {
try {
const result = await ops.updateCreator(req.currentUser.id, req.params.userId, req.body ?? {});
return sendData(res, req, { creator: result });
} catch (error) {
return routeError(res, req, error);
}
});
api.use('/ops/v1', opsApi);
}
export function attachPlazaAdminRoutes(adminApi, plaza, { sendError }) {
const { posts, ops } = plaza;
adminApi.get('/plaza/pending', async (_req, res) => {
if (!posts) return res.status(503).json({ message: 'Plaza 未启用' });
const plazaPosts = await posts.listPendingPosts();
res.json({ data: { posts: plazaPosts } });
});
adminApi.post('/plaza/posts/:id/review', async (req, res) => {
if (!posts) return res.status(503).json({ message: 'Plaza 未启用' });
try {
const action = String(req.body?.action ?? 'approve');
const reason = req.body?.reason ?? null;
const result =
ops && req.currentUser?.id
? await ops.reviewPostAsOps(req.currentUser.id, req.params.id, action, { reason })
: await posts.reviewPost(req.params.id, action, { reason });
res.json({ data: result });
} catch (error) {
const status = mapPlazaError(error);
res.status(status).json({
error: { code: error?.code ?? 'review_failed', message: error.message },
});
}
});
}
+6 -34
View File
@@ -1,22 +1,12 @@
import { useEffect, useState } from 'react';
import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { checkAuth, 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 { AuthView } from './components/AuthView';
import { ChatView } from './components/ChatView';
import { MindSpaceView } from './components/MindSpaceView';
import { ChatProvider } from './context/ChatProvider';
import { PREVIEW_USER } from './dev/mindspacePreviewData';
import { MindSpaceRoute } from './routes/MindSpaceRoute';
import { RequireAdmin } from './routes/RequireAdmin';
import type { CapabilityMap, PortalUser } from './types';
function isMindSpacePreview() {
@@ -88,7 +78,11 @@ function AuthenticatedApp({
: undefined
}
onOpenPage={user ? (pageId) => navigate(`/space/page/${pageId}`) : undefined}
onOpenAdmin={user?.role === 'admin' ? () => navigate('/admin') : undefined}
onOpenAdmin={
user?.role === 'admin'
? () => { window.location.href = import.meta.env.VITE_ADMIN_APP_URL ?? '/admin-app'; }
: undefined
}
onLogout={handleLogout}
/>
);
@@ -96,24 +90,6 @@ function AuthenticatedApp({
return (
<ChatProvider user={user} capabilities={capabilities} onUserUpdate={onUserUpdate}>
<Routes>
<Route
path="/admin"
element={
<RequireAdmin user={user}>
{user ? <AdminLayout user={user} onLogout={handleLogout} /> : null}
</RequireAdmin>
}
>
<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="/space/*"
element={<MindSpaceAuthGate user={user} onLogout={handleLogout} />}
@@ -180,11 +156,7 @@ export function App() {
legacyMode={legacyMode}
onAuth={(nextUser, nextCapabilities, nextSkills) => {
const params = new URLSearchParams(window.location.search);
const returnTo =
params.get('return_to') ??
(window.location.pathname.startsWith('/admin')
? `${window.location.pathname}${window.location.search}`
: null);
const returnTo = params.get('return_to') ?? null;
if (returnTo) {
try {
const url = new URL(returnTo, window.location.origin);
-38
View File
@@ -1,38 +0,0 @@
import { Link, Outlet } from 'react-router-dom';
import type { PortalUser } from '../types';
import { AdminNav } from './AdminNav';
export function AdminLayout({
user,
onLogout,
}: {
user: PortalUser;
onLogout: () => void;
}) {
return (
<div className="admin-shell">
<header className="admin-topbar">
<div className="admin-topbar-left">
<Link to="/" className="ghost-btn admin-back-link">
</Link>
<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>
);
}
-57
View File
@@ -1,57 +0,0 @@
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>
);
}
-27
View File
@@ -1,27 +0,0 @@
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 };
}
-219
View File
@@ -1,219 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { NavLink, Navigate, Route, Routes } from 'react-router-dom';
import { listAdminLedger, listAdminUsage, rechargeUser } from '../../api/client';
import type { LedgerEntry, UsageRecord } from '../../types';
import { useAdminUsers } from '../hooks/useAdminUsers';
import { formatTime, formatYuan } from '../utils/format';
const TABS = [
{ to: 'recharge', label: '充值' },
{ to: 'usage', label: '用量记录' },
{ to: 'ledger', label: '资金流水' },
] as const;
function RechargeTab() {
const { users, reload, error, setError } = useAdminUsers();
const [message, setMessage] = useState<string | null>(null);
const [recharge, setRecharge] = useState({ userId: '', amountYuan: '10', note: '' });
const handleRecharge = async (e: React.FormEvent) => {
e.preventDefault();
if (!recharge.userId) return;
setMessage(null);
setError(null);
try {
const amountCents = Math.round(Number(recharge.amountYuan) * 100);
await rechargeUser(recharge.userId, amountCents, recharge.note || undefined);
setMessage('充值成功');
setRecharge({ userId: '', amountYuan: '10', note: '' });
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : '充值失败');
}
};
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((user) => user.role === 'user')
.map((user) => (
<option key={user.id} value={user.id}>
{user.username}¥{formatYuan(user.balanceCents)}
</option>
))}
</select>
<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" disabled={!recharge.userId}>
</button>
</form>
</section>
);
}
function UsageTab() {
const [usage, setUsage] = useState<UsageRecord[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
setUsage(await listAdminUsage());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<button type="button" className="ghost-btn" onClick={() => void load()}>
</button>
</div>
{loading ? (
<p className="muted"></p>
) : (
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
<th></th>
<th></th>
<th>Token</th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{usage.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>
<td>¥{formatYuan(row.balanceAfterCents)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
function LedgerTab() {
const [ledger, setLedger] = useState<LedgerEntry[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
setLoading(true);
try {
setLedger(await listAdminLedger());
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<section className="admin-card">
<div className="admin-card-head">
<h2></h2>
<button type="button" className="ghost-btn" onClick={() => void load()}>
</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>
</tr>
</thead>
<tbody>
{ledger.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>
<td>{row.note ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
export function BillingPage() {
return (
<div className="admin-page">
<div className="admin-page-head">
<h2></h2>
<p className="muted"></p>
</div>
<nav className="admin-tabs" aria-label="计费子页">
{TABS.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className={({ isActive }) => `admin-tab${isActive ? ' active' : ''}`}
>
{tab.label}
</NavLink>
))}
</nav>
<Routes>
<Route index element={<Navigate to="recharge" replace />} />
<Route path="recharge" element={<RechargeTab />} />
<Route path="usage" element={<UsageTab />} />
<Route path="ledger" element={<LedgerTab />} />
<Route path="*" element={<Navigate to="recharge" replace />} />
</Routes>
</div>
);
}
-17
View File
@@ -1,17 +0,0 @@
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>
);
}
-222
View File
@@ -1,222 +0,0 @@
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>
);
}
-17
View File
@@ -1,17 +0,0 @@
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>
);
}
-13
View File
@@ -1,13 +0,0 @@
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>
);
}
-17
View File
@@ -1,17 +0,0 @@
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>
);
}
-122
View File
@@ -1,122 +0,0 @@
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>
);
}
-154
View File
@@ -1,154 +0,0 @@
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>
);
}
-7
View File
@@ -1,7 +0,0 @@
export function formatYuan(cents: number) {
return (cents / 100).toFixed(2);
}
export function formatTime(ts: number) {
return new Date(ts).toLocaleString('zh-CN');
}
-315
View File
@@ -1,315 +0,0 @@
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>
);
}
-280
View File
@@ -1,280 +0,0 @@
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>
);
}
-535
View File
@@ -1,535 +0,0 @@
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>
);
}
-236
View File
@@ -1,236 +0,0 @@
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>
);
}
-23
View File
@@ -1,23 +0,0 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import type { PortalUser } from '../types';
export function RequireAdmin({
user,
children,
}: {
user: PortalUser | null;
children: React.ReactNode;
}) {
const navigate = useNavigate();
const allowed = user?.role === 'admin';
useEffect(() => {
if (!allowed) {
navigate('/', { replace: true });
}
}, [allowed, navigate]);
if (!allowed) return null;
return children;
}