init: initial commit
Checks / verify (push) Canceled after 41s

This commit is contained in:
2026-10-06 11:24:02 +08:00
parent 456e87d4f2
commit ba6dfffab7
63 changed files with 7223 additions and 0 deletions
+62
View File
@@ -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>
}
+38
View File
@@ -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>
}
+26
View File
@@ -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>
}
+6
View File
@@ -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}
+17
View File
@@ -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 }
+8
View File
@@ -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}
+6
View File
@@ -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}
+32
View File
@@ -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; }
}
+16
View File
@@ -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
}
+16
View File
@@ -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})}
+3
View File
@@ -0,0 +1,3 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
+6
View File
@@ -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>)