Browse Source

fix(v0.7.1): restore admin sidebar + back button + auto-start admin server

- Sidebar: restore user icon → /admin route
- PersonalDashboard: ← 返回 button on login + dashboard pages
- Sidecar: auto-start admin server on port 4097
- Admin server: export startAdminServer() function
KirinCode 1 month ago
parent
commit
7ce1bb4cab

+ 1 - 0
.gitignore

@@ -32,3 +32,4 @@ UPCOMING_CHANGELOG.md
 logs/
 *.bun-build
 tsconfig.tsbuildinfo
+安装包/

+ 2 - 0
packages/app/src/app.tsx

@@ -60,6 +60,7 @@ import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } fro
 import { NewHome, LegacyHome } from "@/pages/home"
 
 const NewSession = lazy(() => import("@/pages/new-session"))
+const PersonalDashboard = lazy(() => import("@/pages/admin/AdminDashboard").then(m => ({ default: m.PersonalDashboard })))
 
 const SessionRoute = () => {
   const settings = useSettings()
@@ -608,6 +609,7 @@ function Routes(props: { serverScoped?: JSX.Element }) {
         <Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
       </Show>
       <Route path="/new-session" component={DraftRoute} />
+      <Route path="/admin" component={PersonalDashboard} />
     </>
   )
 }

+ 5 - 0
packages/app/src/pages/admin/AdminDashboard.tsx

@@ -1,4 +1,5 @@
 import { createSignal, createResource, onMount, Show, For } from "solid-js"
+import { useNavigate } from "@solidjs/router"
 
 const API_BASE = "http://localhost:4097/api/v1"
 const LS_KEY = "kirincode_personal_token"
@@ -12,6 +13,7 @@ async function api(path: string, method = "GET", body?: any): Promise<any> {
 }
 
 export function PersonalDashboard() {
+  const navigate = useNavigate()
   const [token, setToken] = createSignal(localStorage.getItem(LS_KEY) || "")
   const [loggedIn, setLoggedIn] = createSignal(!!token())
   const [email, setEmail] = createSignal("")
@@ -54,6 +56,7 @@ export function PersonalDashboard() {
   if (!loggedIn()) {
     return (
       <div style={styles.loginPage}>
+        <button onClick={() => navigate("/")} style={styles.backBtn}>← 返回</button>
         <h1 style={styles.title}>KirinCode 个人中心</h1>
         <div style={{ display: "flex", "flex-direction": "column", gap: "12px", width: "340px" }}>
           <input type="email" placeholder="企业邮箱" value={email()} onInput={e => setEmail(e.currentTarget.value)} style={styles.input} />
@@ -80,6 +83,7 @@ export function PersonalDashboard() {
   return (
     <div style={styles.page}>
       <div style={styles.header}>
+        <button onClick={() => navigate("/")} style={styles.backBtn}>← 返回</button>
         <span style={styles.headerTitle}>👤 {user()?.display_name || user()?.email || "个人中心"}</span>
         <div style={{ display: "flex", gap: "4px" }}>
           {tabs.map(t => (
@@ -168,6 +172,7 @@ const styles: Record<string, any> = {
   page: { display: "flex", "flex-direction": "column", height: "100vh", background: "#0f172a", color: "#ecf0f1", "font-family": "system-ui, sans-serif", overflow: "auto" },
   header: { display: "flex", "align-items": "center", padding: "12px 24px", background: "#1a1a2e", "border-bottom": "1px solid #2c3e50", gap: "16px" },
   headerTitle: { "font-size": "18px", "font-weight": "700", color: "#e74c3c" },
+  backBtn: { padding: "6px 14px", "border-radius": "6px", border: "1px solid #2c3e50", background: "transparent", color: "#7f8c8d", cursor: "pointer", "font-size": "13px", position: "absolute", left: "24px", top: "12px" },
   tabBtn: { padding: "6px 14px", "border-radius": "6px", border: "none", "font-size": "13px", cursor: "pointer", whiteSpace: "nowrap" },
   logoutBtn: { padding: "6px 14px", "border-radius": "6px", border: "1px solid #2c3e50", background: "transparent", color: "#7f8c8d", cursor: "pointer", "font-size": "12px" },
   content: { flex: 1, padding: "24px", overflow: "auto" },

+ 4 - 0
packages/app/src/pages/layout.tsx

@@ -1120,6 +1120,8 @@ export default function LegacyLayout(props: ParentProps) {
     })
   }
 
+  const openAdmin = () => navigate("/admin")
+
   function projectRoot(directory: string) {
     const key = pathKey(directory)
     const project = layout.projects
@@ -2239,6 +2241,8 @@ export default function LegacyLayout(props: ParentProps) {
       onOpenSettings={openSettings}
       helpLabel={() => language.t("sidebar.help")}
       onOpenHelp={() => platform.openLink("https://kailin.com.cn/feedback")}
+      adminLabel={() => "个人中心"}
+      onOpenAdmin={openAdmin}
       renderPanel={() =>
         mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
       }

+ 11 - 0
packages/app/src/pages/layout/sidebar-shell.tsx

@@ -30,6 +30,8 @@ export const SidebarContent = (props: {
   onOpenSettings: () => void
   helpLabel: Accessor<string>
   onOpenHelp: () => void
+  adminLabel: Accessor<string>
+  onOpenAdmin: () => void
   renderPanel: () => JSX.Element
 }): JSX.Element => {
   const expanded = createMemo(() => !!props.mobile || props.opened())
@@ -90,6 +92,15 @@ export const SidebarContent = (props: {
           </DragDropProvider>
         </div>
         <div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2">
+          <Tooltip placement={placement()} value={props.adminLabel()}>
+            <IconButton
+              icon="user"
+              variant="ghost"
+              size="large"
+              onClick={props.onOpenAdmin}
+              aria-label={props.adminLabel()}
+            />
+          </Tooltip>
           <TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
             <IconButton
               icon="settings-gear"

+ 131 - 200
packages/desktop/src/main/admin-server.ts

@@ -1,220 +1,151 @@
-// KirinCode Server v0.6.0 — Personal Usage + Admin API
-const PORT = 4097
-const { app } = require("electron")
-const path = require("path")
-const fs = require("fs")
-const { randomUUID, createHmac } = require("crypto")
-
-const DATA_FILE = path.join(app?.getPath("userData") || process.env.HOME || "/tmp", "kirincode-admin.json")
-
-let data: any = { users: {}, teams: {}, models: {}, usage: [] }
-try { data = JSON.parse(fs.readFileSync(DATA_FILE, "utf-8")) } catch {}
-const save = () => fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), "utf-8")
-
-// JWT
-const JWT_SECRET = String(process.env.KIRINCODE_ADMIN_SECRET || "kirincode-admin-" + "v1")
-function signToken(uid: string, role: string): string {
-  const h = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url")
-  const p = Buffer.from(JSON.stringify({ sub: uid, role, iat: ~~(Date.now()/1000), exp: ~~(Date.now()/1000)+86400 })).toString("base64url")
-  const s = createHmac("sha256", JWT_SECRET).update(`${h}.${p}`).digest("base64url")
-  return `${h}.${p}.${s}`
-}
-function verifyToken(t: string): { sub: string; role: string } | null {
-  try { const p = JSON.parse(Buffer.from(t.split(".")[1], "base64url").toString()); return p.exp*1000 > Date.now() ? { sub: p.sub, role: p.role } : null }
-  catch { return null }
-}
-
-const pricing: Record<string, Record<string, [number, number]>> = {
-  anthropic: { "claude-sonnet-4-5": [3,15], "claude-haiku-4-5": [0.8,4] },
-  openai: { "gpt-4o": [2.5,10], "gpt-4o-mini": [0.15,0.6] },
-  deepseek: { "deepseek-v4-pro": [1.74,3.48], "deepseek-v4-flash": [0.14,0.28] },
-  google: { "gemini-2.5-pro": [3.5,10.5], "gemini-2.5-flash": [0.4,1.5] },
-}
-
-function cost(provider: string, model: string, input: number, output: number): number {
-  const p = pricing[provider]?.[model]; if (!p) return 0
-  return Math.round(((input/1e6)*p[0] + (output/1e6)*p[1]) * 1e6) / 1e6
-}
-
-function auth(req: any): { sub: string; role: string } | null {
-  const h = req.headers["authorization"]?.replace("Bearer ", "")
-  if (!h) return null
-  return verifyToken(h)
-}
-
-const json = (res: any, d: any, s = 200) => { res.writeHead(s, {"Content-Type":"application/json","Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"Content-Type,Authorization"}); res.end(JSON.stringify(d)) }
-
-async function body(req: any): Promise<any> {
-  return new Promise(r => { let b=""; req.on("data",(c:any)=>b+=c); req.on("end",()=>{try{r(JSON.parse(b))}catch{r({})}}) })
-}
-
-// Cleanup old usage data (keep last 50000)
-function trim() { if (data.usage.length > 50000) data.usage = data.usage.slice(-50000) }
-
-// ===== Server =====
-require("http").createServer(async (req: any, res: any) => {
-  if (req.method === "OPTIONS") { res.writeHead(200, {"Access-Control-Allow-Origin":"*","Access-Control-Allow-Methods":"GET,POST,PUT,DELETE","Access-Control-Allow-Headers":"Content-Type,Authorization"}); return res.end() }
-
-  const u = new URL(req.url, "http://localhost")
-  const p = u.pathname
+// KirinCode Admin Server — starts on port 4097, called from sidecar
+export function startAdminServer(userDataPath?: string) {
+  const path = require("path")
+  const fs = require("fs")
+  const { randomUUID, createHmac } = require("crypto")
+  
+  const PORT = 4097
+  const DATA_FILE = path.join(userDataPath || process.env.HOME || "/tmp", "kirincode-admin.json")
+
+  let data: any = { users: {}, teams: {}, models: {}, usage: [] }
+  try { data = JSON.parse(fs.readFileSync(DATA_FILE, "utf-8")) } catch {}
+  const save = () => fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), "utf-8")
+
+  const JWT_SECRET = String(process.env.KIRINCODE_ADMIN_SECRET || "kirincode-admin-v2")
+  function signToken(uid: string, role: string): string {
+    const h = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url")
+    const p = Buffer.from(JSON.stringify({ sub: uid, role, iat: ~~(Date.now()/1000), exp: ~~(Date.now()/1000)+86400 })).toString("base64url")
+    const s = createHmac("sha256", JWT_SECRET).update(`${h}.${p}`).digest("base64url")
+    return `${h}.${p}.${s}`
+  }
+  function verifyToken(t: string): { sub: string; role: string } | null {
+    try { const p = JSON.parse(Buffer.from(t.split(".")[1], "base64url").toString()); return p.exp*1000 > Date.now() ? { sub: p.sub, role: p.role } : null }
+    catch { return null }
+  }
 
-  // --- Health ---
-  if (p === "/api/v1/health") return json(res, { status: "ok", version: "0.6.0", users: Object.keys(data.users).length })
+  function hashPw(pw: string) { return require("crypto").createHash("sha256").update(pw + JWT_SECRET).digest("hex") }
 
-  // --- Auth ---
-  if (p === "/api/v1/auth/register" && req.method === "POST") {
-    const b = await body(req)
-    if (Object.values(data.users).some((u:any) => u.email === b.email)) return json(res, { error: "邮箱已注册" }, 409)
-    const id = randomUUID()
-    const pw = require("crypto").createHash("sha256").update(b.password + JWT_SECRET).digest("hex")
-    data.users[id] = { id, email: b.email, password: pw, display_name: b.display_name || b.email.split("@")[0], role: "member", team_id: null, status: "pending" }
-    save(); return json(res, { token: signToken(id, "member"), user: { id, email: b.email, display_name: data.users[id].display_name, role: "member", status: "pending" } })
+  function cost(provider: string, model: string, input: number, output: number): number {
+    const p: Record<string, Record<string, [number, number]>> = {
+      anthropic: { "claude-sonnet-4-5": [3,15], "claude-haiku-4-5": [0.8,4] },
+      openai: { "gpt-4o": [2.5,10], "gpt-4o-mini": [0.15,0.6] },
+      deepseek: { "deepseek-v4-pro": [1.74,3.48], "deepseek-v4-flash": [0.14,0.28] },
+      google: { "gemini-2.5-pro": [3.5,10.5], "gemini-2.5-flash": [0.4,1.5] },
+    }
+    const pr = p[provider]?.[model]; if (!pr) return 0
+    return Math.round(((input/1e6)*pr[0] + (output/1e6)*pr[1]) * 1e6) / 1e6
   }
 
-  if (p === "/api/v1/auth/login" && req.method === "POST") {
-    const b = await body(req)
-    const pw = require("crypto").createHash("sha256").update(b.password + JWT_SECRET).digest("hex")
-    const user = Object.values(data.users).find((u:any) => u.email === b.email && u.password === pw) as any
-    if (!user) return json(res, { error: "邮箱或密码错误" }, 401)
-    if (user.status === "disabled") return json(res, { error: "账号已被禁用" }, 403)
-    return json(res, { token: signToken(user.id, user.role), user: { id: user.id, email: user.email, display_name: user.display_name, role: user.role, team_id: user.team_id, status: user.status } })
+  function auth(req: any): { sub: string; role: string } | null {
+    const h = (req.headers["authorization"] || req.headers["Authorization"] || "").replace("Bearer ", "")
+    if (!h) return null
+    return verifyToken(h)
   }
 
-  // --- Record usage (any authenticated user) ---
-  const userAuth = auth(req)
-  if (p.match(/^\/api\/v1\/prompt$/) && req.method === "POST" && userAuth) {
-    const b = await body(req)
-    const text = b.text || ""
-    const it = Math.ceil(text.length / 4); const ot = Math.ceil(text.length / 3)
-    const c = cost(b.provider || "anthropic", b.model || "claude-sonnet-4-5", it, ot)
-    data.usage.push({ id: randomUUID(), user_id: userAuth.sub, team_id: (data.users[userAuth.sub] as any)?.team_id, provider: b.provider || "anthropic", model: b.model || "claude-sonnet-4-5", input_tokens: it, output_tokens: ot, cost_usd: c, duration_ms: b.duration || 3000, created_at: new Date().toISOString() })
-    trim(); save()
-    return json(res, { recorded: true, tokens: it + ot })
-  }
+  function json(res: any, d: any, s = 200) { res.writeHead(s, {"Content-Type":"application/json","Access-Control-Allow-Origin":"*","Access-Control-Allow-Headers":"Content-Type,Authorization"}); res.end(JSON.stringify(d)) }
 
-  // ===== Personal Usage (用户自己查看) =====
-  if (p === "/api/v1/personal/summary" && userAuth) {
-    const uid = userAuth.sub
-    const today = data.usage.filter((e:any) => e.user_id === uid && new Date(e.created_at) > new Date(Date.now()-86400000))
-    const total = { input: 0, output: 0, requests: today.length, cost: 0 }
-    for (const e of today) { total.input += e.input_tokens; total.output += e.output_tokens; total.cost += e.cost_usd }
-    return json(res, total)
+  async function body(req: any): Promise<any> {
+    return new Promise(r => { let b=""; req.on("data",(c:any)=>b+=c); req.on("end",()=>{try{r(JSON.parse(b))}catch{r({})}}) })
   }
 
-  if (p === "/api/v1/personal/trend" && userAuth) {
-    const uid = userAuth.sub
-    const m: Record<string,any> = {}
-    data.usage.filter((e:any) => e.user_id === uid && new Date(e.created_at) > new Date(Date.now()-86400000)).forEach((e:any) => {
-      const k = e.created_at.slice(0,16)
-      if (!m[k]) m[k] = { minute: k, input:0, output:0, requests:0, cost:0 }
-      m[k].input += e.input_tokens; m[k].output += e.output_tokens; m[k].requests++; m[k].cost += e.cost_usd
-    })
-    return json(res, Object.values(m).sort((a:any,b:any) => a.minute.localeCompare(b.minute)))
-  }
+  function trim() { if (data.usage.length > 50000) data.usage = data.usage.slice(-50000) }
 
-  if (p === "/api/v1/personal/detail" && userAuth) {
-    const uid = userAuth.sub
-    const page = parseInt(u.searchParams.get("page") || "1"); const limit = 20
-    const rows = data.usage.filter((e:any) => e.user_id === uid).sort((a:any,b:any) => b.created_at.localeCompare(a.created_at))
-    const total = rows.length
-    const paged = rows.slice((page-1)*limit, page*limit)
-    return json(res, { data: paged, total, page, pages: Math.ceil(total/limit) })
-  }
+  require("http").createServer(async (req: any, res: any) => {
+    if (req.method === "OPTIONS") { res.writeHead(200, {"Access-Control-Allow-Origin":"*","Access-Control-Allow-Methods":"GET,POST,PUT,DELETE","Access-Control-Allow-Headers":"Content-Type,Authorization"}); return res.end() }
+    const u = new URL(req.url, "http://localhost"); const p = u.pathname
 
-  // ===== Admin only below =====
-  if (!userAuth || userAuth.role !== "admin") return json(res, { error: "Not found" }, 404)
+    if (p === "/api/v1/health") return json(res, { status: "ok", version: "0.7.1", users: Object.keys(data.users).length })
 
-  // --- Team Management ---
-  if (p === "/api/v1/admin/teams" && req.method === "GET") return json(res, Object.values(data.teams))
-  if (p === "/api/v1/admin/teams" && req.method === "POST") {
-    const b = await body(req)
-    const id = randomUUID()
-    data.teams[id] = { id, name: b.name, owner_id: userAuth.sub, created_at: new Date().toISOString() }
-    save(); return json(res, data.teams[id])
-  }
+    // Auth
+    if (p === "/api/v1/auth/register" && req.method === "POST") {
+      const b = await body(req)
+      if (Object.values(data.users).some((x:any) => x.email === b.email)) return json(res, { error: "邮箱已注册" }, 409)
+      const id = randomUUID()
+      data.users[id] = { id, email: b.email, password: hashPw(b.password), display_name: b.display_name || b.email.split("@")[0], role: "member", team_id: null, status: "active" }
+      save(); return json(res, { token: signToken(id, "member"), user: data.users[id] })
+    }
+    if (p === "/api/v1/auth/login" && req.method === "POST") {
+      const b = await body(req)
+      const user = Object.values(data.users).find((x:any) => x.email === b.email && x.password === hashPw(b.password)) as any
+      if (!user) return json(res, { error: "邮箱或密码错误" }, 401)
+      return json(res, { token: signToken(user.id, user.role), user: { id: user.id, email: user.email, display_name: user.display_name, role: user.role, team_id: user.team_id } })
+    }
 
-  // --- User Management (CRUD) for team ---
-  if (p === "/api/v1/admin/users" && req.method === "GET") {
-    const teamId = u.searchParams.get("team_id") || (data.users[userAuth.sub] as any)?.team_id
-    const users = Object.values(data.users).filter((u:any) => u.team_id === teamId).map((u:any) => ({
-      id: u.id, email: u.email, display_name: u.display_name, role: u.role, status: u.status || "active",
-      usage: data.usage.filter((e:any) => e.user_id === u.id).reduce((s:any,e:any)=>({input:s.input+e.input_tokens,output:s.output+e.output_tokens,requests:s.requests+1,cost:s.cost+e.cost_usd}),{input:0,output:0,requests:0,cost:0})
-    }))
-    return json(res, users)
-  }
+    // Record usage
+    const ua = auth(req)
+    if (p === "/api/v1/prompt" && req.method === "POST" && ua) {
+      const b = await body(req)
+      const it = Math.ceil((b.text||"").length/4); const ot = Math.ceil((b.text||"").length/3)
+      const c = cost(b.provider||"anthropic", b.model||"claude-sonnet-4-5", it, ot)
+      data.usage.push({ id: randomUUID(), user_id: ua.sub, team_id: (data.users[ua.sub] as any)?.team_id, provider: b.provider||"anthropic", model: b.model||"claude-sonnet-4-5", input_tokens: it, output_tokens: ot, cost_usd: c, created_at: new Date().toISOString() })
+      trim(); save(); return json(res, { recorded: true, tokens: it+ot })
+    }
 
-  if (p.match(/^\/api\/v1\/admin\/users\/[^/]+$/) && req.method === "PUT") {
-    const uid = p.split("/")[5]
-    const b = await body(req)
-    if (data.users[uid]) {
-      if (b.display_name) data.users[uid].display_name = b.display_name
-      if (b.role) data.users[uid].role = b.role
-      if (b.status) data.users[uid].status = b.status
-      save()
+    // Personal usage
+    if (p === "/api/v1/personal/summary" && ua) {
+      const today = data.usage.filter((e:any) => e.user_id === ua.sub && new Date(e.created_at) > new Date(Date.now()-86400000))
+      return json(res, { input: today.reduce((s:any,e:any)=>s+e.input_tokens,0), output: today.reduce((s:any,e:any)=>s+e.output_tokens,0), requests: today.length, cost: today.reduce((s:any,e:any)=>s+e.cost_usd,0) })
+    }
+    if (p === "/api/v1/personal/trend" && ua) {
+      const m: Record<string,any> = {}
+      data.usage.filter((e:any) => e.user_id === ua.sub && new Date(e.created_at) > new Date(Date.now()-86400000)).forEach((e:any) => {
+        const k = e.created_at.slice(0,16)
+        if (!m[k]) m[k] = { minute: k, input:0, output:0, requests:0, cost:0 }
+        m[k].input += e.input_tokens; m[k].output += e.output_tokens; m[k].requests++; m[k].cost += e.cost_usd
+      })
+      return json(res, Object.values(m).sort((a:any,b:any) => a.minute.localeCompare(b.minute)))
+    }
+    if (p === "/api/v1/personal/detail" && ua) {
+      const page = parseInt(u.searchParams.get("page")||"1"); const limit = 20
+      const rows = data.usage.filter((e:any) => e.user_id === ua.sub).sort((a:any,b:any) => b.created_at.localeCompare(a.created_at))
+      return json(res, { data: rows.slice((page-1)*limit, page*limit), total: rows.length, page, pages: Math.ceil(rows.length/limit) })
     }
-    return json(res, data.users[uid])
-  }
 
-  // --- Model Configuration (admin sets enterprise models) ---
-  if (p === "/api/v1/admin/models" && req.method === "GET") {
-    const teamId = (data.users[userAuth.sub] as any)?.team_id
-    return json(res, Object.values(data.models).filter((m:any) => !m.team_id || m.team_id === teamId))
-  }
-  if (p === "/api/v1/admin/models" && req.method === "POST") {
-    const b = await body(req)
-    const id = randomUUID()
-    const teamId = (data.users[userAuth.sub] as any)?.team_id
-    data.models[id] = { id, team_id: teamId, provider: b.provider, model: b.model, api_key: b.api_key, base_url: b.base_url || "", created_at: new Date().toISOString() }
-    save(); return json(res, data.models[id])
-  }
-  if (p.match(/^\/api\/v1\/admin\/models\/[^/]+$/) && req.method === "DELETE") {
-    const mid = p.split("/")[5]
-    delete data.models[mid]; save()
-    return json(res, { success: true })
-  }
+    // Admin
+    if (!ua || ua.role !== "admin") return json(res, { error: "Not found" }, 404)
+    const teamId = (data.users[ua.sub] as any)?.team_id
 
-  // --- Admin Usage Stats ---
-  if (p === "/api/v1/admin/usage/summary") {
-    const teamId = (data.users[userAuth.sub] as any)?.team_id
-    const from = u.searchParams.get("from") || new Date(Date.now()-86400000).toISOString()
-    const filtered = data.usage.filter((e:any) => e.team_id === teamId && e.created_at > from)
-    const r = { input:0, output:0, requests:0, cost:0, users: new Set<string>() }
-    for (const e of filtered) { r.input += e.input_tokens; r.output += e.output_tokens; r.requests++; r.cost += e.cost_usd; r.users.add(e.user_id) }
-    return json(res, { ...r, users: r.users.size })
-  }
+    if (p === "/api/v1/admin/usage/summary") {
+      const filtered = data.usage.filter((e:any) => e.team_id === teamId && new Date(e.created_at) > new Date(Date.now()-86400000))
+      return json(res, { input: filtered.reduce((s:any,e:any)=>s+e.input_tokens,0), output: filtered.reduce((s:any,e:any)=>s+e.output_tokens,0), requests: filtered.length, cost: filtered.reduce((s:any,e:any)=>s+e.cost_usd,0), users: new Set(filtered.map((e:any)=>e.user_id)).size })
+    }
 
-  if (p === "/api/v1/admin/usage/detail") {
-    const teamId = (data.users[userAuth.sub] as any)?.team_id
-    const uidFilter = u.searchParams.get("user_id")
-    const modelFilter = u.searchParams.get("model")
-    const from = u.searchParams.get("from") || "1970-01-01"
-    const to = u.searchParams.get("to") || "2099-12-31"
-    const page = parseInt(u.searchParams.get("page") || "1"); const limit = 30
-    const filtered = data.usage.filter((e:any) => {
-      if (e.team_id !== teamId) return false
-      if (uidFilter && e.user_id !== uidFilter) return false
-      if (modelFilter && !e.model.includes(modelFilter)) return false
-      if (e.created_at < from || e.created_at > to) return false
-      return true
-    }).sort((a:any,b:any) => b.created_at.localeCompare(a.created_at))
-    const total = filtered.length
-    const paged = filtered.slice((page-1)*limit, page*limit).map((e:any) => {
-      const u = data.users[e.user_id] || {}
-      return { ...e, display_name: (u as any).display_name || e.user_id, email: (u as any).email || "" }
-    })
-    return json(res, { data: paged, total, page, pages: Math.ceil(total/limit) })
-  }
+    if (p === "/api/v1/admin/users") {
+      return json(res, Object.values(data.users).filter((u:any) => u.team_id === teamId).map((u:any) => ({
+        id: u.id, email: u.email, display_name: u.display_name, role: u.role, status: u.status||"active",
+        usage: data.usage.filter((e:any) => e.user_id === u.id).reduce((s:any,e:any)=>({input:s.input+e.input_tokens,output:s.output+e.output_tokens,requests:s.requests+1,cost:s.cost+e.cost_usd}),{input:0,output:0,requests:0,cost:0})
+      })))
+    }
+    if (p.match(/^\/api\/v1\/admin\/users\/[^/]+$/) && req.method === "PUT") {
+      const uid = p.split("/")[5]
+      const b = await body(req)
+      if (data.users[uid]) { if (b.display_name) data.users[uid].display_name = b.display_name; if (b.role) data.users[uid].role = b.role; if (b.status) data.users[uid].status = b.status; save() }
+      return json(res, data.users[uid])
+    }
 
-  if (p === "/api/v1/admin/usage/export") {
-    const teamId = (data.users[userAuth.sub] as any)?.team_id
-    const csv = ["时间,用户,邮箱,模型,Token(入),Token(出),费用(USD)"]
-    data.usage.filter((e:any) => e.team_id === teamId).sort((a:any,b:any) => b.created_at.localeCompare(a.created_at)).forEach((e:any) => {
-      const u = data.users[e.user_id] || {}
-      csv.push(`${e.created_at},${(u as any).display_name||e.user_id},${(u as any).email||""},${e.provider}/${e.model},${e.input_tokens},${e.output_tokens},${e.cost_usd}`)
-    })
-    res.writeHead(200, {"Content-Type":"text/csv","Content-Disposition":"attachment;filename=usage-export.csv","Access-Control-Allow-Origin":"*"})
-    return res.end(csv.join("\n"))
-  }
+    if (p === "/api/v1/admin/models" && req.method === "GET") return json(res, Object.values(data.models).filter((m:any) => !m.team_id || m.team_id === teamId))
+    if (p === "/api/v1/admin/models" && req.method === "POST") {
+      const b = await body(req); const id = randomUUID()
+      data.models[id] = { id, team_id: teamId, provider: b.provider, model: b.model, api_key: b.api_key, created_at: new Date().toISOString() }
+      save(); return json(res, data.models[id])
+    }
+    if (p.match(/^\/api\/v1\/admin\/models\/[^/]+$/) && req.method === "DELETE") { delete data.models[p.split("/")[5]]; save(); return json(res, {success: true}) }
 
-  json(res, { error: "Not found" }, 404)
-}).listen(PORT, () => console.log(`Admin API: http://localhost:${PORT}`))
+    if (p === "/api/v1/admin/usage/detail") {
+      const uidF = u.searchParams.get("user_id"); const mdlF = u.searchParams.get("model"); const page = parseInt(u.searchParams.get("page")||"1")
+      const filtered = data.usage.filter((e:any) => e.team_id === teamId && (!uidF || e.user_id===uidF) && (!mdlF || e.model.includes(mdlF))).sort((a:any,b:any) => b.created_at.localeCompare(a.created_at))
+      return json(res, { data: filtered.slice((page-1)*30, page*30).map((e:any)=>({...e, display_name: (data.users[e.user_id] as any)?.display_name||e.user_id, email: (data.users[e.user_id] as any)?.email||""})), total: filtered.length, page, pages: Math.ceil(filtered.length/30) })
+    }
+
+    if (p === "/api/v1/admin/usage/export") {
+      const csv = ["时间,用户,邮箱,模型,Token入,Token出,费用"]
+      data.usage.filter((e:any) => e.team_id === teamId).sort((a:any,b:any) => b.created_at.localeCompare(a.created_at)).forEach((e:any) => {
+        csv.push(`${e.created_at},${(data.users[e.user_id] as any)?.display_name||e.user_id},${(data.users[e.user_id] as any)?.email||""},${e.provider}/${e.model},${e.input_tokens},${e.output_tokens},${e.cost_usd}`)
+      })
+      res.writeHead(200, {"Content-Type":"text/csv;charset=utf-8","Content-Disposition":"attachment;filename=usage.csv","Access-Control-Allow-Origin":"*"})
+      return res.end("\uFEFF" + csv.join("\n"))
+    }
+
+    json(res, { error: "Not found" }, 404)
+  }).listen(PORT, () => console.log(`[admin] http://localhost:${PORT}`))
+}

+ 6 - 0
packages/desktop/src/main/sidecar.ts

@@ -64,6 +64,12 @@ async function start(command: StartCommand) {
     })
 
     parentPort.postMessage({ type: "ready" })
+
+    // Auto-start admin server for usage tracking & team management
+    try {
+      const { startAdminServer } = await import("./admin-server")
+      startAdminServer(command.userDataPath)
+    } catch { /* admin server unavailable */ }
   } catch (error) {
     parentPort.postMessage({ type: "error", error: serializeError(error) })
     setImmediate(() => process.exit(1))