@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york", "rsc": false, "tsx": true,
|
||||
"tailwind": { "config": "", "css": "src/index.css", "baseColor": "neutral", "cssVariables": true },
|
||||
"aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" },
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><meta name="theme-color" content="#f8f6f2"/><title>见势 · Veyra 管理端</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "@veyra/admin",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1",
|
||||
"build": "tsc --noEmit && vite build && node scripts/embed.mjs",
|
||||
"preview": "vite preview --host 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"lucide-react": "0.468.0",
|
||||
"class-variance-authority": "0.7.1",
|
||||
"clsx": "2.1.1",
|
||||
"tailwind-merge": "3.5.0",
|
||||
"@radix-ui/react-slot": "1.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "6.4.3",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
"typescript": "5.9.3",
|
||||
"@types/node": "22.19.19",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"tailwindcss": "4.2.4",
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"tw-animate-css": "1.4.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { cp, mkdir, rm } from "node:fs/promises"
|
||||
const target = new URL("../../../internal/adminui/dist/", import.meta.url)
|
||||
await mkdir(target, { recursive: true })
|
||||
// Preserve the tracked .keep so a clean checkout can build the foundation API.
|
||||
const { readdir } = await import("node:fs/promises")
|
||||
for (const file of await readdir(target)) if (file !== ".keep") await rm(new URL(file, target), { recursive: true, force: true })
|
||||
await cp(new URL("../dist/", import.meta.url), target, { recursive: true })
|
||||
console.log("Management console copied into Go embed assets.")
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Activity, ArrowUpRight, BarChart3, Bell, BookOpen, ChevronRight, CircleDot, Database, FlaskConical, LayoutDashboard, LockKeyhole, RefreshCw, Server, Settings2, ShieldCheck, Sparkles, Users } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { getHealth, type Health } from "@/lib/api"
|
||||
import {type User} from "@/lib/auth"
|
||||
import {UsersPanel} from "@/components/UsersPanel"
|
||||
|
||||
const workspaces = [
|
||||
{name:"总览", icon:LayoutDashboard}, {name:"候选审核",icon:BookOpen}, {name:"策略中心",icon:Settings2},
|
||||
{name:"回测研究",icon:FlaskConical}, {name:"数据健康",icon:Database}, {name:"模型配置",icon:Sparkles},
|
||||
{name:"通知任务",icon:Bell}, {name:"用户管理",icon:Users}, {name:"审计日志",icon:ShieldCheck}, {name:"系统设置",icon:LockKeyhole},
|
||||
]
|
||||
const label = {ready:"连接正常",unavailable:"无法连接",not_configured:"待配置"}
|
||||
|
||||
export default function App({user,onLogout,onSessionExpired}:{user:User;onLogout:()=>Promise<void>;onSessionExpired:()=>void}) {
|
||||
const [page,setPage] = useState("总览")
|
||||
const [health,setHealth] = useState<Health | null>(null)
|
||||
const [error,setError] = useState("")
|
||||
const [loading,setLoading] = useState(false)
|
||||
const [logoutError,setLogoutError] = useState("")
|
||||
const [signingOut,setSigningOut] = useState(false)
|
||||
async function signOut() {setSigningOut(true);setLogoutError("");try {await onLogout()} catch(e) {setLogoutError(e instanceof Error ? e.message : "退出失败,请重试。")} finally {setSigningOut(false)}}
|
||||
const controller = useRef<AbortController | null>(null)
|
||||
async function refresh() {
|
||||
controller.current?.abort()
|
||||
const next = new AbortController()
|
||||
controller.current = next
|
||||
setLoading(true)
|
||||
try { const data = await getHealth(next.signal); if (!next.signal.aborted) {setHealth(data);setError("")} }
|
||||
catch (e) { if (!next.signal.aborted) {setHealth(null);setError(e instanceof Error ? e.message : "暂时无法连接 API 服务")} }
|
||||
finally {if (!next.signal.aborted) setLoading(false)}
|
||||
}
|
||||
useEffect(()=>{void refresh();return ()=>controller.current?.abort()},[])
|
||||
const overview = page === "总览" || page === "数据健康"
|
||||
return <div className="min-h-screen lg:grid lg:grid-cols-[238px_1fr]">
|
||||
<aside className="flex flex-col border-r bg-card px-5 py-7 lg:min-h-screen">
|
||||
<a href="#" onClick={()=>setPage("总览")} className="flex items-center gap-3 px-2 text-foreground no-underline"><span className="flex size-10 items-center justify-center rounded-xl bg-foreground font-serif text-xl text-card">V</span><div><strong className="text-base">见势 · Veyra</strong><p className="mt-1 text-[10px] tracking-[.2em] text-muted-foreground">OPERATIONS CONSOLE</p></div></a>
|
||||
<p className="mb-3 mt-10 px-3 text-[10px] tracking-[.18em] text-muted-foreground">工作空间</p>
|
||||
<nav aria-label="管理端工作区" className="grid grid-cols-2 gap-1 sm:grid-cols-5 lg:grid-cols-1">{workspaces.map(({name,icon:Icon})=><button key={name} onClick={()=>setPage(name)} aria-current={page===name ? "page" : undefined} className={`flex items-center gap-3 rounded-lg px-3 py-3 text-left text-sm transition-colors ${page===name ? "bg-accent font-medium text-accent-foreground" : "text-muted-foreground hover:bg-muted"}`}><Icon size={17}/>{name}{page===name && <ChevronRight size={14} className="ml-auto"/>}</button>)}</nav>
|
||||
<div className="mt-auto hidden pt-12 lg:block"><div className="rounded-lg border p-3 text-xs text-muted-foreground"><div className="mb-2 flex items-center gap-2 text-foreground"><CircleDot size={14}/>开发环境</div>第一阶段 · 工程与服务基础</div></div>
|
||||
</aside>
|
||||
<div className="min-w-0">
|
||||
<header className="flex h-20 items-center justify-between border-b px-6 lg:px-10"><div className="flex items-center gap-2 text-sm text-muted-foreground">工作空间 <ChevronRight size={13}/><span className="text-foreground">{page}</span></div><div className="flex items-center gap-3"><span className="hidden text-xs text-muted-foreground sm:block">{user.display_name}</span><Badge variant="outline">LOCAL · 开发环境</Badge><Button variant="ghost" size="sm" onClick={()=>void signOut()} disabled={signingOut}>{signingOut ? "正在退出" : "退出"}</Button></div></header>
|
||||
<main className="mx-auto max-w-7xl p-6 lg:p-10">
|
||||
{logoutError && <p role="alert" className="mb-6 text-sm text-destructive">{logoutError}</p>}
|
||||
<div className="mb-8 flex flex-wrap items-end justify-between gap-4"><div><p className="mb-3 text-[10px] tracking-[.2em] text-primary">VEYRA / {overview ? "SYSTEM OVERVIEW" : "WORKSPACE"}</p><h1 className="text-3xl font-semibold tracking-tight">{page === "总览" ? "把每一步,建立在依据之上。" : page}</h1><p className="mt-3 text-sm text-muted-foreground">{overview ? "查看服务连接与建设进度,从可靠的基础开始。" : "该工作区的业务功能正在建设中。"}</p></div>{overview && <Button variant="outline" onClick={()=>void refresh()} disabled={loading}><RefreshCw className={loading ? "animate-spin" : ""}/>{loading ? "检查中" : "刷新状态"}</Button>}</div>
|
||||
{overview ? <>
|
||||
{error && <div role="alert" className="mb-6 rounded-lg border border-destructive/25 bg-destructive/5 p-4 text-sm text-destructive">无法读取当前服务状态。请检查 API 是否启动。{error}</div>}
|
||||
<div className="mb-7 grid gap-4 md:grid-cols-3">{[
|
||||
{title:"API 服务",subtitle:"Go · Gin",icon:Server,value:loading ? "检查中" : health ? "运行中" : "未连接"},
|
||||
{title:"PostgreSQL",subtitle:"业务事实与版本化迁移",icon:Database,value:loading ? "检查中" : health ? label[health.components.postgresql.status] : "未知"},
|
||||
{title:"Redis",subtitle:"缓存与订阅索引",icon:Activity,value:loading ? "检查中" : health ? label[health.components.redis.status] : "未知"},
|
||||
].map(({title,subtitle,icon:Icon,value})=><Card key={title}><CardHeader><div className="flex items-center justify-between"><CardDescription>{title}</CardDescription><Icon size={18} className="text-muted-foreground"/></div></CardHeader><CardContent><p className="mb-2 text-2xl font-semibold">{value}</p><p className="text-xs text-muted-foreground">{subtitle}</p></CardContent></Card>)}</div>
|
||||
<div className="grid gap-6 xl:grid-cols-[1.5fr_1fr]"><Card><CardHeader><div className="flex items-center justify-between"><CardTitle>第一条产品闭环</CardTitle><Badge>建设中</Badge></div><CardDescription className="mt-2">先验证完整流程,再扩大策略与数据范围。</CardDescription></CardHeader><CardContent><ol className="space-y-5">{["管理端审核并发布观察候选","用户查看证据,确认买入计划","条件满足后发送提醒","记录实际成交,建立持仓监控","记录退出成交,生成成本后复盘"].map((v,i)=><li key={v} className="flex items-center gap-4"><span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-muted text-xs text-muted-foreground">0{i+1}</span><span className="text-sm">{v}</span><span className="ml-auto text-xs text-muted-foreground">待实现</span></li>)}</ol></CardContent></Card><Card><CardHeader><BarChart3 size={24} className="mb-4 text-primary"/><CardTitle>数据接入后,开始观察。</CardTitle><CardDescription className="mt-2 leading-7">候选数量、策略表现与账户指标将在业务功能与数据接入完成后展示。</CardDescription></CardHeader><CardContent><div className="rounded-lg bg-muted p-4 text-xs leading-6 text-muted-foreground">当前状态来源:Go API 实时探测<br/>最近检查:{health ? new Date(health.checked_at).toLocaleString("zh-CN") : "尚未取得状态"}</div><Button variant="ghost" className="mt-4 w-full justify-between" onClick={()=>setPage("数据健康")}>查看数据健康<ArrowUpRight/></Button></CardContent></Card></div>
|
||||
</> : page==="用户管理" ? <UsersPanel onSessionExpired={onSessionExpired}/> : <Card><CardContent className="flex min-h-80 flex-col items-center justify-center p-10 text-center"><div className="mb-5 rounded-2xl bg-muted p-5"><LockKeyhole size={28} className="text-muted-foreground"/></div><h2 className="text-lg font-semibold">{page}尚未开放</h2><p className="mt-3 max-w-md text-sm leading-7 text-muted-foreground">该模块完成业务接口、权限与验证后开放。</p><Button variant="outline" className="mt-6" onClick={()=>setPage("总览")}>返回总览</Button></CardContent></Card>}
|
||||
<footer className="mt-10 flex items-center justify-between border-t pt-5 text-[11px] text-muted-foreground"><span>见势 · Veyra / 可追溯的投资决策</span><span>工程基础 v0.1</span></footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect,useState,useCallback,type FormEvent } from "react"
|
||||
import { ArrowRight,LoaderCircle } from "lucide-react"
|
||||
import App from "./App"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {ApiError,getMe,login,logout,type User} from "@/lib/auth"
|
||||
|
||||
export default function Console() {
|
||||
const [user,setUser]=useState<User | null>(null)
|
||||
const [checking,setChecking]=useState(true)
|
||||
const [loginName,setLoginName]=useState("")
|
||||
const [password,setPassword]=useState("")
|
||||
const [busy,setBusy]=useState(false)
|
||||
const [error,setError]=useState("")
|
||||
useEffect(()=>{
|
||||
const controller=new AbortController()
|
||||
getMe(controller.signal).then(setUser).catch(e=>{if (!controller.signal.aborted && !(e instanceof ApiError && e.status===401)) setError(e instanceof Error ? e.message : "无法连接服务。")}).finally(()=>{if (!controller.signal.aborted) setChecking(false)})
|
||||
return ()=>controller.abort()
|
||||
},[])
|
||||
async function submit(event:FormEvent) {
|
||||
event.preventDefault()
|
||||
setBusy(true);setError("")
|
||||
try {const result=await login(loginName,password);setUser(result.user);setPassword("")}
|
||||
catch(e) {setError(e instanceof Error ? e.message : "登录失败,请重试。")}
|
||||
finally {setBusy(false)}
|
||||
}
|
||||
async function signOut() {
|
||||
try {await logout();setUser(null);setError("")}
|
||||
catch(e) {if (e instanceof ApiError && e.status===401) {setUser(null);return};throw e}
|
||||
}
|
||||
const sessionExpired=useCallback(()=>setUser(null),[])
|
||||
if (checking) return <div className="flex min-h-screen items-center justify-center gap-3 text-sm text-muted-foreground"><LoaderCircle className="animate-spin" size={18}/>正在验证管理端会话…</div>
|
||||
if (user) return <App user={user} onLogout={signOut} onSessionExpired={sessionExpired}/>
|
||||
return <div className="grid min-h-screen lg:grid-cols-[1.05fr_1fr]">
|
||||
<section className="relative hidden flex-col justify-between bg-foreground p-14 text-card lg:flex"><div className="flex items-center gap-3"><span className="flex size-10 items-center justify-center rounded-lg border border-card/30 font-serif text-xl">V</span><span className="font-semibold">见势 · Veyra</span></div><div><p className="mb-6 text-xs tracking-[.24em] text-card/45">OPERATIONS CONSOLE</p><h1 className="text-4xl font-semibold leading-relaxed">看清依据,<br/>守住每一步决策。</h1><p className="mt-7 max-w-sm text-sm leading-8 text-card/55">从候选审核到策略监控,让每条证据、每次变化与每项操作都有记录。</p></div><p className="text-xs text-card/35">见势 · Veyra / 管理工作空间</p></section>
|
||||
<main className="flex items-center justify-center p-7"><div className="w-full max-w-sm"><p className="mb-5 text-xs font-semibold tracking-[.15em] text-primary">VEYRA / ADMIN</p><h2 className="text-3xl font-semibold">登录管理端</h2><p className="mb-9 mt-3 text-sm text-muted-foreground">使用已创建的管理员账号继续。</p><form onSubmit={event=>void submit(event)} className="space-y-5"><div><label htmlFor="login-name" className="mb-2 block text-sm">登录名</label><Input id="login-name" value={loginName} onChange={e=>setLoginName(e.target.value)} autoComplete="username" minLength={3} maxLength={64} required disabled={busy}/></div><div><label htmlFor="password" className="mb-2 block text-sm">密码</label><Input id="password" type="password" value={password} onChange={e=>setPassword(e.target.value)} autoComplete="current-password" maxLength={72} required disabled={busy}/></div>{error && <p role="alert" className="rounded-md border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">{error}</p>}<Button type="submit" disabled={busy} className="mt-2 w-full">{busy ? <LoaderCircle className="animate-spin"/> : <ArrowRight/>}{busy ? "正在登录" : "进入管理端"}</Button></form><p className="mt-7 border-t pt-5 text-xs leading-6 text-muted-foreground">当前登录用于本地开发验证。正式管理端将在多因素认证完成后开放。</p></div></main>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {useEffect,useState} from "react"
|
||||
import {RefreshCw,Users} from "lucide-react"
|
||||
import {ApiError,getUsers,type User} from "@/lib/auth"
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Card,CardHeader,CardTitle,CardDescription,CardContent} from "@/components/ui/card"
|
||||
|
||||
export function UsersPanel({onSessionExpired}:{onSessionExpired:()=>void}) {
|
||||
const [users,setUsers]=useState<User[]>([])
|
||||
const [error,setError]=useState("")
|
||||
const [loading,setLoading]=useState(true)
|
||||
const [revision,setRevision]=useState(0)
|
||||
useEffect(()=>{
|
||||
const controller=new AbortController()
|
||||
setLoading(true)
|
||||
getUsers(controller.signal).then(data=>{if (!controller.signal.aborted) {setUsers(data);setError("")}}).catch(e=>{
|
||||
if (controller.signal.aborted) return
|
||||
if (e instanceof ApiError && e.status===401) {onSessionExpired();return}
|
||||
setError(e instanceof Error ? e.message : "无法读取用户。")
|
||||
}).finally(()=>{if (!controller.signal.aborted) setLoading(false)})
|
||||
return ()=>controller.abort()
|
||||
},[revision,onSessionExpired])
|
||||
return <Card><CardHeader><div className="flex items-center justify-between"><CardTitle>用户列表</CardTitle><Button variant="outline" size="sm" onClick={()=>setRevision(v=>v+1)} disabled={loading}><RefreshCw className={loading ? "animate-spin" : ""}/>刷新</Button></div><CardDescription>按创建时间显示最近 100 位 App 用户。</CardDescription></CardHeader><CardContent>
|
||||
{error ? <p role="alert" className="py-8 text-sm text-destructive">{error}</p> : loading ? <p className="py-8 text-sm text-muted-foreground">正在加载用户…</p> : users.length===0 ? <div className="flex flex-col items-center py-14"><Users size={28} className="mb-4 text-muted-foreground"/><p className="text-sm text-muted-foreground">暂时没有 App 用户</p></div> : <div className="overflow-x-auto"><table className="w-full text-left text-sm"><thead><tr className="border-b text-muted-foreground"><th className="py-3 font-normal">用户</th><th className="py-3 font-normal">状态</th><th className="py-3 font-normal">创建时间</th></tr></thead><tbody>{users.map(user=><tr key={user.id} className="border-b last:border-0"><td className="py-4 pr-6"><p className="font-medium">{user.display_name}</p><p className="mt-1 font-mono text-[11px] text-muted-foreground">{user.id}</p></td><td className="py-4 pr-6"><Badge variant={user.status==="ACTIVE" ? "default" : "secondary"}>{user.status==="ACTIVE" ? "正常" : "已停用"}</Badge></td><td className="whitespace-nowrap py-4 text-muted-foreground">{new Date(user.created_at).toLocaleString("zh-CN")}</td></tr>)}</tbody></table></div>}
|
||||
</CardContent></Card>
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import {cn} from "@/lib/utils"
|
||||
const badgeVariants = cva("inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium", { variants: {variant: {default: "border-transparent bg-accent text-accent-foreground", secondary:"border-transparent bg-secondary text-secondary-foreground", outline:"text-muted-foreground"}}, defaultVariants:{variant:"default"} })
|
||||
function Badge({className,variant,...props}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) { return <span data-slot="badge" className={cn(badgeVariants({variant}),className)} {...props}/> }
|
||||
export {Badge}
|
||||
@@ -0,0 +1,17 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
{ variants: {
|
||||
variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", outline: "border border-input bg-card hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground" },
|
||||
size: { default: "h-10 px-4 py-2", sm: "h-9 rounded-md px-3", icon: "h-10 w-10" },
|
||||
}, defaultVariants: { variant: "default", size: "default" } },
|
||||
)
|
||||
function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return <Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props} />
|
||||
}
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,8 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
function Card({className, ...props}: React.ComponentProps<"div">) { return <div data-slot="card" className={cn("rounded-xl border bg-card text-card-foreground shadow-sm",className)} {...props} /> }
|
||||
function CardHeader({className, ...props}: React.ComponentProps<"div">) { return <div data-slot="card-header" className={cn("flex flex-col gap-1.5 p-6",className)} {...props} /> }
|
||||
function CardTitle({className, ...props}: React.ComponentProps<"h2">) { return <h2 data-slot="card-title" className={cn("text-base font-semibold leading-none",className)} {...props} /> }
|
||||
function CardDescription({className, ...props}: React.ComponentProps<"p">) { return <p data-slot="card-description" className={cn("text-sm text-muted-foreground",className)} {...props} /> }
|
||||
function CardContent({className, ...props}: React.ComponentProps<"div">) { return <div data-slot="card-content" className={cn("p-6 pt-0",className)} {...props} /> }
|
||||
export {Card, CardHeader, CardTitle, CardDescription, CardContent}
|
||||
@@ -0,0 +1,6 @@
|
||||
import * as React from "react"
|
||||
import {cn} from "@/lib/utils"
|
||||
function Input({className,type,...props}:React.ComponentProps<"input">) {
|
||||
return <input data-slot="input" type={type} className={cn("flex h-10 w-full rounded-md border border-input bg-card px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",className)} {...props}/>
|
||||
}
|
||||
export {Input}
|
||||
@@ -0,0 +1,32 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #eee9e2;
|
||||
--color-secondary-foreground: var(--foreground);
|
||||
--color-muted: #f1eee8;
|
||||
--color-muted-foreground: #79736c;
|
||||
--color-accent: #f6e9da;
|
||||
--color-accent-foreground: #995e2e;
|
||||
--color-border: #e7e2da;
|
||||
--color-input: #e7e2da;
|
||||
--color-ring: #ac723f;
|
||||
--color-destructive: #b34d45;
|
||||
--radius-sm: 0.375rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-xl: 1rem;
|
||||
}
|
||||
:root { --background: #f8f6f2; --foreground: #302c28; --card: #fffefa; --primary: #a86b35; }
|
||||
@layer base {
|
||||
* { @apply border-border; }
|
||||
body { @apply bg-background text-foreground antialiased; margin: 0; font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; }
|
||||
button:not(:disabled) { cursor: pointer; }
|
||||
button:focus-visible, a:focus-visible { outline: 2px solid var(--primary); outline-offset: 4px; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type ComponentStatus = "ready" | "unavailable" | "not_configured"
|
||||
export type Health = {
|
||||
status: "ready" | "not_ready"
|
||||
checked_at: string
|
||||
components: { postgresql: { status: ComponentStatus }; redis: { status: ComponentStatus } }
|
||||
}
|
||||
export async function getHealth(signal?: AbortSignal): Promise<Health> {
|
||||
const response = await fetch("/api/v1/system/health", { signal, headers: { Accept: "application/json" } })
|
||||
if (!response.ok && response.status !== 503) throw new Error(`服务请求失败(${response.status})`)
|
||||
const data: unknown = await response.json()
|
||||
if (!data || typeof data !== "object" || !("status" in data) || !("components" in data) || !("checked_at" in data)) throw new Error("服务返回了无效的状态数据")
|
||||
const health = data as Health
|
||||
const statuses = ["ready", "unavailable", "not_configured"]
|
||||
if (!["ready", "not_ready"].includes(health.status) || typeof health.checked_at !== "string" || !statuses.includes(health.components?.postgresql?.status) || !statuses.includes(health.components?.redis?.status)) throw new Error("服务返回了无效的状态数据")
|
||||
return health
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export type User = {id:string;display_name:string;status:"ACTIVE" | "DISABLED";created_at:string;updated_at:string}
|
||||
type Envelope<T> = {data:T;meta:{request_id:string;server_time?:string}}
|
||||
export class ApiError extends Error {
|
||||
constructor(public status:number,public code:string,message:string) {super(message);this.name="ApiError"}
|
||||
}
|
||||
async function request<T>(path:string,init?:RequestInit):Promise<T> {
|
||||
const response=await fetch(path,{...init,credentials:"same-origin",headers:{Accept:"application/json",...(init?.body ? {"Content-Type":"application/json"} : {}),...init?.headers}})
|
||||
const data=await response.json()
|
||||
if (!response.ok) throw new ApiError(response.status,data.error?.code ?? "REQUEST_FAILED",data.error?.message ?? "请求失败,请稍后重试。")
|
||||
if (!data || typeof data !== "object" || !("data" in data)) throw new Error("服务返回了无效数据。")
|
||||
return (data as Envelope<T>).data
|
||||
}
|
||||
export function getMe(signal?:AbortSignal) {return request<User>("/api/admin/v1/me",{signal})}
|
||||
export function login(login_name:string,password:string) {return request<{user:User;expires_at:string}>("/api/admin/v1/auth/login",{method:"POST",body:JSON.stringify({login_name,password})})}
|
||||
export function logout() {return request<{revoked:boolean}>("/api/admin/v1/auth/logout",{method:"POST"})}
|
||||
export function getUsers(signal?:AbortSignal) {return request<User[]>("/api/admin/v1/users",{signal})}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
|
||||
@@ -0,0 +1,6 @@
|
||||
import React from "react"
|
||||
import ReactDOM from "react-dom/client"
|
||||
import Console from "./Console"
|
||||
import "./index.css"
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(<React.StrictMode><Console /></React.StrictMode>)
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext", "moduleResolution": "Bundler", "jsx": "react-jsx",
|
||||
"strict": true, "skipLibCheck": true, "noEmit": true, "esModuleInterop": true,
|
||||
"resolveJsonModule": true, "noUnusedLocals": true, "noUnusedParameters": true,
|
||||
"baseUrl": ".", "paths": {"@/*": ["./src/*"]}, "types": ["vite/client", "node"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { fileURLToPath, URL } from "node:url"
|
||||
import { defineConfig } from "vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: { alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) } },
|
||||
server: { proxy: { "/api": "http://127.0.0.1:8080", "/healthz": "http://127.0.0.1:8080" } },
|
||||
})
|
||||
Reference in New Issue
Block a user