generated from kevisual/vite-react-template
feat: add CNB Board live information page with domain management
- Created a new page for managing domains with a table view and modal for editing. - Implemented Zustand store for domain state management including fetching, updating, and deleting domains. - Added components for displaying information cards with search functionality. - Integrated API calls for fetching CNB Board live data including build, repo, pull, NPC, and comment information. - Established routing for the CNB Board page.
This commit is contained in:
19
package.json
19
package.json
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "vite-react",
|
||||
"name": "cli-center",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"basename": "/",
|
||||
"basename": "/root/cli-center",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"ui": "bunx shadcn@latest add ",
|
||||
"pub": "envision deploy ./dist -k vite-react -v 0.0.1 -y y -u"
|
||||
"pub": "envision deploy ./dist -k cli-center -v 0.0.1 -y y -u"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
@@ -20,8 +20,8 @@
|
||||
"@base-ui/react": "^1.2.0",
|
||||
"@kevisual/api": "^0.0.59",
|
||||
"@kevisual/context": "^0.0.8",
|
||||
"@kevisual/router": "0.0.83",
|
||||
"@tanstack/react-router": "^1.161.4",
|
||||
"@kevisual/router": "0.0.84",
|
||||
"@tanstack/react-router": "^1.162.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@@ -41,18 +41,19 @@
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kevisual/cache": "^0.0.5",
|
||||
"@kevisual/query": "0.0.49",
|
||||
"@kevisual/types": "^0.0.12",
|
||||
"@tailwindcss/vite": "^4.2.0",
|
||||
"@tanstack/react-router-devtools": "^1.161.4",
|
||||
"@tanstack/router-plugin": "^1.161.4",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@tanstack/react-router-devtools": "^1.162.6",
|
||||
"@tanstack/router-plugin": "^1.162.6",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"dotenv": "^17.3.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "v8.0.0-beta.14"
|
||||
|
||||
2828
pnpm-lock.yaml
generated
Normal file
2828
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
9
skills/page/SKILL.md
Normal file
9
skills/page/SKILL.md
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: new-page
|
||||
description: 创建一个新页面
|
||||
---
|
||||
|
||||
## 参考当前的文档
|
||||
|
||||
|
||||
`./references/*.ts`
|
||||
202
skills/page/references/page.tsx
Normal file
202
skills/page/references/page.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
import { useEffect } from 'react';
|
||||
import { appDomainStatus, useDomainStore } from './store/index ';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { pick } from 'es-toolkit';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { LayoutUser } from '@/modules/layout/LayoutUser';
|
||||
|
||||
const TableList = () => {
|
||||
const { list, setShowEditModal, setFormData, deleteDomain } = useDomainStore();
|
||||
useEffect(() => {
|
||||
// Initial load is handled by the parent component
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>域名</TableHead>
|
||||
<TableHead>应用ID</TableHead>
|
||||
<TableHead>UID</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{list.map((domain) => (
|
||||
<TableRow key={domain.id}>
|
||||
<TableCell>{domain.id}</TableCell>
|
||||
<TableCell>{domain.domain}</TableCell>
|
||||
<TableCell>{domain.appId}</TableCell>
|
||||
<TableCell>{domain.uid}</TableCell>
|
||||
<TableCell>{domain.status}</TableCell>
|
||||
<TableCell className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowEditModal(true);
|
||||
setFormData(domain);
|
||||
}}>
|
||||
<Pencil className="w-4 h-4 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteDomain(domain)}>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FomeModal = () => {
|
||||
const { showEditModal, setShowEditModal, formData, updateDomain } = useDomainStore();
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
control,
|
||||
setValue,
|
||||
} = useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (!showEditModal) return;
|
||||
if (formData?.id) {
|
||||
reset(formData);
|
||||
} else {
|
||||
reset({
|
||||
status: 'running',
|
||||
});
|
||||
}
|
||||
}, [formData, showEditModal, reset]);
|
||||
|
||||
const onSubmit = async (data: any) => {
|
||||
const _formData = pick(data, ['domain', 'appId', 'status', 'id']);
|
||||
if (formData.id) {
|
||||
_formData.id = formData.id;
|
||||
}
|
||||
const res = await updateDomain(_formData);
|
||||
if (res.code === 200) {
|
||||
setShowEditModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={showEditModal} onOpenChange={setShowEditModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加域名</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="p-4">
|
||||
<form className="w-full flex flex-col gap-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">域名</label>
|
||||
<Input
|
||||
{...control.register('domain', { required: '请输入域名' })}
|
||||
placeholder="请输入域名"
|
||||
className={errors.domain ? "border-red-500" : ""}
|
||||
/>
|
||||
{errors.domain && <span className="text-xs text-red-500">{errors.domain.message as string}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">应用ID</label>
|
||||
<Input
|
||||
{...control.register('appId', { required: '请输入应用ID' })}
|
||||
placeholder="请输入应用ID"
|
||||
className={errors.appId ? "border-red-500" : ""}
|
||||
/>
|
||||
{errors.appId && <span className="text-xs text-red-500">{errors.appId.message as string}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="text-sm font-medium">状态</label>
|
||||
<Controller
|
||||
name="status"
|
||||
control={control}
|
||||
defaultValue=""
|
||||
render={({ field }) => (
|
||||
<Select value={field.value || ''} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{appDomainStatus.map((item) => (
|
||||
<SelectItem key={item} value={item}>{item}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit">提交</Button>
|
||||
</form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const List = () => {
|
||||
const { getDomainList, setShowEditModal, setFormData } = useDomainStore();
|
||||
|
||||
useEffect(() => {
|
||||
getDomainList();
|
||||
}, [getDomainList]);
|
||||
|
||||
return (
|
||||
<div className="p-4 w-full h-full">
|
||||
<div className="flex mb-4">
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowEditModal(true);
|
||||
setFormData({});
|
||||
}}>
|
||||
<Plus className="w-4 h-4 mr-1" />
|
||||
添加
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</Dialog>
|
||||
</div>
|
||||
<TableList />
|
||||
<FomeModal />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default List
|
||||
92
skills/page/references/store/index .ts
Normal file
92
skills/page/references/store/index .ts
Normal file
@@ -0,0 +1,92 @@
|
||||
'use strict';
|
||||
import { create } from 'zustand';
|
||||
import { query } from '@/modules/query';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// 审核,通过,驳回
|
||||
export const appDomainStatus = ['audit', 'auditReject', 'auditPending', 'running', 'stop'] as const;
|
||||
|
||||
type AppDomainStatus = (typeof appDomainStatus)[number];
|
||||
type Domain = {
|
||||
id: string;
|
||||
domain: string;
|
||||
appId?: string;
|
||||
status: AppDomainStatus;
|
||||
data?: any;
|
||||
uid?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
interface Store {
|
||||
getDomainList: () => Promise<any>;
|
||||
updateDomain: (data: { domain: string; id: string; [key: string]: any }, opts?: { refresh?: boolean }) => Promise<any>;
|
||||
deleteDomain: (data: { id: string }) => Promise<any>;
|
||||
getDomainDetail: (data: { domain?: string; id?: string }) => Promise<any>;
|
||||
list: Domain[];
|
||||
setList: (list: Domain[]) => void;
|
||||
formData: any;
|
||||
setFormData: (formData: any) => void;
|
||||
showEditModal: boolean;
|
||||
setShowEditModal: (showEditModal: boolean) => void;
|
||||
}
|
||||
|
||||
export const useDomainStore = create<Store>((set, get) => ({
|
||||
getDomainList: async () => {
|
||||
const res = await query.get({
|
||||
path: 'app.domain.manager',
|
||||
key: 'list',
|
||||
});
|
||||
if (res.code === 200) {
|
||||
set({ list: res.data?.list || [] });
|
||||
}
|
||||
return res;
|
||||
},
|
||||
updateDomain: async (data: any, opts?: { refresh?: boolean }) => {
|
||||
const res = await query.post({
|
||||
path: 'app.domain.manager',
|
||||
key: 'update',
|
||||
data,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
const list = get().list;
|
||||
set({ list: list.map((item) => (item.id === data.id ? res.data : item)) });
|
||||
toast.success('更新成功');
|
||||
if (opts?.refresh ?? true) {
|
||||
get().getDomainList();
|
||||
}
|
||||
} else {
|
||||
toast.error(res.message || '更新失败');
|
||||
}
|
||||
return res;
|
||||
},
|
||||
deleteDomain: async (data: any) => {
|
||||
const res = await query.post({
|
||||
path: 'app.domain.manager',
|
||||
key: 'delete',
|
||||
data,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
const list = get().list;
|
||||
set({ list: list.filter((item) => item.id !== data.id) });
|
||||
toast.success('删除成功');
|
||||
}
|
||||
return res;
|
||||
},
|
||||
getDomainDetail: async (data: any) => {
|
||||
const res = await query.post({
|
||||
path: 'app.domain.manager',
|
||||
key: 'get',
|
||||
data,
|
||||
});
|
||||
if (res.code === 200) {
|
||||
set({ formData: res.data });
|
||||
}
|
||||
return res;
|
||||
},
|
||||
list: [],
|
||||
setList: (list: any[]) => set({ list }),
|
||||
formData: {},
|
||||
setFormData: (formData: any) => set({ formData }),
|
||||
showEditModal: false,
|
||||
setShowEditModal: (showEditModal: boolean) => set({ showEditModal }),
|
||||
}));
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Query } from '@kevisual/query';
|
||||
import { QueryLoginBrowser } from '@kevisual/api/query-login'
|
||||
export const query = new Query({
|
||||
import { Query, DataOpts } from '@kevisual/query';
|
||||
import { QueryLoginBrowser } from '@kevisual/api/login'
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
export const query = useContextKey('query', new Query({
|
||||
url: '/api/router',
|
||||
});
|
||||
}));
|
||||
|
||||
export const queryClient = new Query({
|
||||
export const queryClient = useContextKey('queryClient', new Query({
|
||||
url: '/client/router',
|
||||
});
|
||||
}));
|
||||
|
||||
export const queryLogin = new QueryLoginBrowser({
|
||||
export const queryLogin = useContextKey('queryLogin', new QueryLoginBrowser({
|
||||
query: query
|
||||
});
|
||||
}));
|
||||
@@ -1,7 +1,46 @@
|
||||
import { useEffect } from "react"
|
||||
import { useLayoutStore } from "./store"
|
||||
import { useShallow } from "zustand/shallow"
|
||||
import { LogIn, LockKeyhole } from "lucide-react"
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
children?: React.ReactNode,
|
||||
mustLogin?: boolean,
|
||||
}
|
||||
export const AuthProvider = ({ children }: Props) => {
|
||||
export const AuthProvider = ({ children, mustLogin }: Props) => {
|
||||
const store = useLayoutStore(useShallow(state => ({
|
||||
init: state.init,
|
||||
me: state.me,
|
||||
})));
|
||||
useEffect(() => {
|
||||
store.init()
|
||||
}, [])
|
||||
const loginUrl = '/root/login/?redirect=' + encodeURIComponent(window.location.href);
|
||||
if (mustLogin && !store.me) {
|
||||
return (
|
||||
<div className="w-full h-full min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-6 p-10 rounded-2xl border border-border bg-card shadow-lg max-w-sm w-full mx-4">
|
||||
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-muted">
|
||||
<LockKeyhole className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h2 className="text-xl font-semibold text-foreground">需要登录</h2>
|
||||
<p className="text-sm text-muted-foreground">请先登录以继续访问此页面</p>
|
||||
</div>
|
||||
<div
|
||||
className="inline-flex items-center justify-center gap-2 w-full px-6 py-2.5 rounded-lg bg-foreground text-background text-sm font-medium transition-opacity hover:opacity-80 active:opacity-70"
|
||||
onClick={() => {
|
||||
window.open(loginUrl, '_self')
|
||||
}}
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
立即登录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>
|
||||
{children}
|
||||
</>
|
||||
|
||||
65
src/pages/auth/store.ts
Normal file
65
src/pages/auth/store.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
|
||||
import { queryLogin } from '@/modules/query';
|
||||
import { create } from 'zustand';
|
||||
import { toast } from 'sonner';
|
||||
type UserInfo = {
|
||||
id?: string;
|
||||
username?: string;
|
||||
nickname?: string | null;
|
||||
needChangePassword?: boolean;
|
||||
description?: string | null;
|
||||
type?: 'user' | 'org';
|
||||
orgs?: string[];
|
||||
avatar?: string;
|
||||
};
|
||||
export type LayoutStore = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openUser: boolean;
|
||||
setOpenUser: (openUser: boolean) => void;
|
||||
me?: UserInfo;
|
||||
setMe: (me: UserInfo) => void;
|
||||
getMe: () => Promise<void>;
|
||||
switchOrg: (username?: string) => Promise<void>;
|
||||
isAdmin: boolean;
|
||||
setIsAdmin: (isAdmin: boolean) => void
|
||||
init: () => Promise<void>;
|
||||
};
|
||||
export const useLayoutStore = create<LayoutStore>((set, get) => ({
|
||||
open: false,
|
||||
setOpen: (open) => set({ open }),
|
||||
openUser: false,
|
||||
setOpenUser: (openUser) => set({ openUser }),
|
||||
me: undefined,
|
||||
setMe: (me) => set({ me }),
|
||||
getMe: async () => {
|
||||
const res = await queryLogin.getMe();
|
||||
if (res.code === 200) {
|
||||
set({ me: res.data });
|
||||
set({ isAdmin: res.data.orgs?.includes?.('admin') || false });
|
||||
}
|
||||
},
|
||||
switchOrg: async (username?: string) => {
|
||||
const res = await queryLogin.switchUser(username || '');
|
||||
if (res.code === 200) {
|
||||
toast.success('切换成功');
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else {
|
||||
toast.error(res.message || '请求失败');
|
||||
}
|
||||
},
|
||||
isAdmin: false,
|
||||
setIsAdmin: (isAdmin) => set({ isAdmin }),
|
||||
init: async () => {
|
||||
const token = await queryLogin.checkTokenValid()
|
||||
if (token) {
|
||||
const user = await queryLogin.checkLocalUser() as UserInfo;
|
||||
if (user) {
|
||||
set({ me: user });
|
||||
set({ isAdmin: user.orgs?.includes?.('admin') || false });
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
148
src/pages/cnb-board/components/InfoCard.tsx
Normal file
148
src/pages/cnb-board/components/InfoCard.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import Fuse from 'fuse.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface InfoItem {
|
||||
title: string;
|
||||
value: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface InfoCardProps {
|
||||
title: string;
|
||||
list: InfoItem[];
|
||||
}
|
||||
|
||||
const MAX_VALUE_LENGTH = 200;
|
||||
|
||||
function isUrl(value: string): boolean {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.protocol === 'http:' || url.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function InfoCard({ title, list }: InfoCardProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const fuse = useMemo(
|
||||
() =>
|
||||
new Fuse(list, {
|
||||
keys: ['title', 'value', 'description'],
|
||||
threshold: 0.3,
|
||||
}),
|
||||
[list]
|
||||
);
|
||||
|
||||
const filteredList = useMemo(() => {
|
||||
if (!search.trim()) return list;
|
||||
return fuse.search(search).map((result) => result.item);
|
||||
}, [search, fuse, list]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>
|
||||
{search ? `匹配 ${filteredList.length} / ${list.length} 个变量` : `共 ${list.length} 个变量`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="搜索..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-48 h-8"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
{filteredList.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-4">
|
||||
未找到匹配项
|
||||
</div>
|
||||
) : (
|
||||
filteredList.map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="grid grid-cols-[240px_1fr] gap-4 py-2 border-b last:border-0"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge variant="outline" className="w-fit font-mono text-xs">
|
||||
{item.title}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
{item.value ? (
|
||||
isUrl(item.value) ? (
|
||||
<a
|
||||
href={item.value}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="w-fit font-mono text-xs text-blue-500 hover:underline break-all"
|
||||
>
|
||||
{item.value}
|
||||
</a>
|
||||
) : item.value.length > MAX_VALUE_LENGTH ? (
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Kbd className="w-full h-full flex flex-col items-start font-mono text-xs break-all cursor-pointer hover:text-primary">
|
||||
<pre className='text-left'>
|
||||
{item.value.slice(0, MAX_VALUE_LENGTH)}...
|
||||
</pre>
|
||||
<div className="text-primary ml-1">(点击展开)</div>
|
||||
</Kbd>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="w-200! max-w-[90vw]! overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{item.title}</DialogTitle>
|
||||
<DialogDescription>{item.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="text-sm whitespace-pre-wrap break-all font-mono bg-muted p-2 rounded scrollbar">
|
||||
{item.value}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<Kbd className="w-fit font-mono text-xs break-all">
|
||||
{item.value}
|
||||
</Kbd>
|
||||
)
|
||||
) : (
|
||||
<Kbd className="w-fit font-mono text-xs text-muted-foreground">
|
||||
(空)
|
||||
</Kbd>
|
||||
)}
|
||||
{item.description && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
118
src/pages/cnb-board/modules/api.ts
Normal file
118
src/pages/cnb-board/modules/api.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
|
||||
import { queryClient as query } from '@/modules/query';
|
||||
import { createQueryApi } from '@kevisual/query/api';
|
||||
const api = {
|
||||
"cnb_board": {
|
||||
/**
|
||||
* 检查是否是 cnb_board 环境
|
||||
*/
|
||||
"is_cnb_board": {
|
||||
"path": "cnb_board",
|
||||
"key": "is_cnb_board",
|
||||
"description": "检查是否是 cnb_board 环境",
|
||||
"metadata": {
|
||||
"url": "/root/v1/cnb-dev",
|
||||
"source": "query-proxy-api"
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 获取cnb_board live的repo信息
|
||||
*/
|
||||
"live_repo_info": {
|
||||
"path": "cnb_board",
|
||||
"key": "live_repo_info",
|
||||
"description": "获取cnb_board live的repo信息",
|
||||
"metadata": {
|
||||
"url": "/root/v1/cnb-dev",
|
||||
"source": "query-proxy-api"
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 获取cnb_board live的构建信息
|
||||
*/
|
||||
"live_build_info": {
|
||||
"path": "cnb_board",
|
||||
"key": "live_build_info",
|
||||
"description": "获取cnb_board live的构建信息",
|
||||
"metadata": {
|
||||
"url": "/root/v1/cnb-dev",
|
||||
"source": "query-proxy-api"
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 获取cnb_board live的PR信息
|
||||
*/
|
||||
"live_pull_info": {
|
||||
"path": "cnb_board",
|
||||
"key": "live_pull_info",
|
||||
"description": "获取cnb_board live的PR信息",
|
||||
"metadata": {
|
||||
"url": "/root/v1/cnb-dev",
|
||||
"source": "query-proxy-api"
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 获取cnb_board live的NPC信息
|
||||
*/
|
||||
"live_npc_info": {
|
||||
"path": "cnb_board",
|
||||
"key": "live_npc_info",
|
||||
"description": "获取cnb_board live的NPC信息",
|
||||
"metadata": {
|
||||
"url": "/root/v1/cnb-dev",
|
||||
"source": "query-proxy-api"
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 获取cnb_board live的评论信息
|
||||
*/
|
||||
"live_comment_info": {
|
||||
"path": "cnb_board",
|
||||
"key": "live_comment_info",
|
||||
"description": "获取cnb_board live的评论信息",
|
||||
"metadata": {
|
||||
"url": "/root/v1/cnb-dev",
|
||||
"source": "query-proxy-api"
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 获取cnb_board live的mdContent内容
|
||||
*
|
||||
* @param data - Request parameters
|
||||
* @param data.more - {boolean} 是否获取更多系统信息,默认false
|
||||
*/
|
||||
"live": {
|
||||
"path": "cnb_board",
|
||||
"key": "live",
|
||||
"description": "获取cnb_board live的mdContent内容",
|
||||
"metadata": {
|
||||
"args": {
|
||||
"more": {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"description": "是否获取更多系统信息,默认false",
|
||||
"type": "boolean",
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"url": "/root/v1/cnb-dev",
|
||||
"source": "query-proxy-api"
|
||||
}
|
||||
},
|
||||
/**
|
||||
* cnb的工作环境退出程序
|
||||
*/
|
||||
"exit": {
|
||||
"path": "cnb_board",
|
||||
"key": "exit",
|
||||
"description": "cnb的工作环境退出程序",
|
||||
"metadata": {
|
||||
"url": "/root/v1/cnb-dev",
|
||||
"source": "query-proxy-api"
|
||||
}
|
||||
}
|
||||
}
|
||||
} as const;
|
||||
const queryApi = createQueryApi({ api, query });
|
||||
export { queryApi };
|
||||
|
||||
// const result = await queryApi['cnb_board']['live_build_info']()
|
||||
84
src/pages/cnb-board/page.tsx
Normal file
84
src/pages/cnb-board/page.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { useCnbBoardStore } from './store';
|
||||
import { InfoCard } from './components/InfoCard';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RotateCcw } from 'lucide-react';
|
||||
import { useLocation } from '@tanstack/react-router';
|
||||
|
||||
type TabValue = 'build' | 'repo' | 'pull' | 'npc' | 'comment' | 'content';
|
||||
|
||||
const tabs: { value: TabValue; label: string }[] = [
|
||||
{ value: 'build', label: '构建信息' },
|
||||
{ value: 'repo', label: '仓库信息' },
|
||||
{ value: 'pull', label: 'PR 信息' },
|
||||
{ value: 'npc', label: 'NPC 信息' },
|
||||
{ value: 'comment', label: '评论信息' },
|
||||
{ value: 'content', label: 'MD 内容' },
|
||||
];
|
||||
|
||||
export const App = () => {
|
||||
const { loading, data, fetchAllData, refresh } = useCnbBoardStore();
|
||||
const location = useLocation();
|
||||
|
||||
// 从 URL 读取 tab 参数
|
||||
const searchParams = useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
const urlTab = searchParams.get('tab') as TabValue | null;
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabValue>(
|
||||
urlTab && tabs.some(t => t.value === urlTab) ? urlTab : 'build'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllData();
|
||||
}, [fetchAllData]);
|
||||
|
||||
const handleTabChange = useCallback((tab: TabValue) => {
|
||||
setActiveTab(tab);
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set('tab', tab);
|
||||
// 使用 hash 路由模式
|
||||
window.history.pushState(null, '', `?${params.toString()}`);
|
||||
}, [location.search]);
|
||||
|
||||
const currentData = activeTab === 'build' ? data['live_build_info']
|
||||
: activeTab === 'repo' ? data['live_repo_info']
|
||||
: activeTab === 'pull' ? data['live_pull_info']
|
||||
: activeTab === 'npc' ? data['live_npc_info']
|
||||
: activeTab === 'comment' ? data['live_comment_info']
|
||||
: data.live;
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className="text-2xl font-bold">CNB Board Live 信息</h1>
|
||||
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
|
||||
<RotateCcw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.value}
|
||||
variant={activeTab === tab.value ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => handleTabChange(tab.value)}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{currentData?.list ? (
|
||||
<InfoCard title={currentData.title} list={currentData.list} />
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
88
src/pages/cnb-board/store.ts
Normal file
88
src/pages/cnb-board/store.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { create } from 'zustand';
|
||||
import { queryApi } from './modules/api';
|
||||
import { Result } from '@kevisual/query';
|
||||
|
||||
interface InfoItem {
|
||||
title: string;
|
||||
value: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
title: string;
|
||||
list: InfoItem[];
|
||||
}
|
||||
|
||||
interface CnbBoardData {
|
||||
live: ApiResponse | null;
|
||||
'live_repo_info': ApiResponse | null;
|
||||
'live_build_info': ApiResponse | null;
|
||||
'live_pull_info': ApiResponse | null;
|
||||
'live_npc_info': ApiResponse | null;
|
||||
'live_comment_info': ApiResponse | null;
|
||||
}
|
||||
|
||||
type State = {
|
||||
loading: boolean;
|
||||
data: CnbBoardData;
|
||||
fetchAllData: () => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const useCnbBoardStore = create<State>((set, get) => ({
|
||||
loading: false,
|
||||
data: {
|
||||
live: null,
|
||||
'live_repo_info': null,
|
||||
'live_build_info': null,
|
||||
'live_pull_info': null,
|
||||
'live_npc_info': null,
|
||||
'live_comment_info': null,
|
||||
},
|
||||
|
||||
fetchAllData: async () => {
|
||||
// 1. 先优先加载 live 数据
|
||||
set({ loading: true });
|
||||
try {
|
||||
const live = await queryApi['cnb_board']['live']({ more: false }) as Result<ApiResponse>;
|
||||
set({
|
||||
loading: false,
|
||||
data: {
|
||||
...get().data,
|
||||
live: live?.data || null,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
set({ loading: false });
|
||||
}
|
||||
|
||||
// 2. 再并行加载其他数据
|
||||
try {
|
||||
const [liveRepoInfo, liveBuildInfo, livePullInfo, liveNpcInfo, liveCommentInfo] =
|
||||
await Promise.all([
|
||||
queryApi['cnb_board']['live_repo_info']() as Promise<Result<ApiResponse>>,
|
||||
queryApi['cnb_board']['live_build_info']() as Promise<Result<ApiResponse>>,
|
||||
queryApi['cnb_board']['live_pull_info']() as Promise<Result<ApiResponse>>,
|
||||
queryApi['cnb_board']['live_npc_info']() as Promise<Result<ApiResponse>>,
|
||||
queryApi['cnb_board']['live_comment_info']() as Promise<Result<ApiResponse>>,
|
||||
]);
|
||||
|
||||
set({
|
||||
data: {
|
||||
...get().data,
|
||||
'live_repo_info': liveRepoInfo?.data || null,
|
||||
'live_build_info': liveBuildInfo?.data || null,
|
||||
'live_pull_info': livePullInfo?.data || null,
|
||||
'live_npc_info': liveNpcInfo?.data || null,
|
||||
'live_comment_info': liveCommentInfo?.data || null,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// 其他数据加载失败不影响主流程
|
||||
}
|
||||
},
|
||||
|
||||
refresh: async () => {
|
||||
await get().fetchAllData();
|
||||
},
|
||||
}));
|
||||
@@ -1,5 +1,3 @@
|
||||
const Home = () => {
|
||||
return <div>Home Page</div>
|
||||
}
|
||||
import { App } from './cnb-board/page'
|
||||
|
||||
export default Home;
|
||||
export default App;
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as DemoRouteImport } from './routes/demo'
|
||||
import { Route as CnbBoardRouteImport } from './routes/cnb-board'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
|
||||
const DemoRoute = DemoRouteImport.update({
|
||||
@@ -17,6 +18,11 @@ const DemoRoute = DemoRouteImport.update({
|
||||
path: '/demo',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const CnbBoardRoute = CnbBoardRouteImport.update({
|
||||
id: '/cnb-board',
|
||||
path: '/cnb-board',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
@@ -25,27 +31,31 @@ const IndexRoute = IndexRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/cnb-board': typeof CnbBoardRoute
|
||||
'/demo': typeof DemoRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/cnb-board': typeof CnbBoardRoute
|
||||
'/demo': typeof DemoRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/cnb-board': typeof CnbBoardRoute
|
||||
'/demo': typeof DemoRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/demo'
|
||||
fullPaths: '/' | '/cnb-board' | '/demo'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/demo'
|
||||
id: '__root__' | '/' | '/demo'
|
||||
to: '/' | '/cnb-board' | '/demo'
|
||||
id: '__root__' | '/' | '/cnb-board' | '/demo'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
CnbBoardRoute: typeof CnbBoardRoute
|
||||
DemoRoute: typeof DemoRoute
|
||||
}
|
||||
|
||||
@@ -58,6 +68,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof DemoRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/cnb-board': {
|
||||
id: '/cnb-board'
|
||||
path: '/cnb-board'
|
||||
fullPath: '/cnb-board'
|
||||
preLoaderRoute: typeof CnbBoardRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -70,6 +87,7 @@ declare module '@tanstack/react-router' {
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
CnbBoardRoute: CnbBoardRoute,
|
||||
DemoRoute: DemoRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { Link, Outlet, createRootRoute, useNavigate } from '@tanstack/react-router'
|
||||
// import { LayoutMain } from '@/modules/layout'
|
||||
const LayoutMain = null;
|
||||
import { Link, Outlet, createRootRoute } from '@tanstack/react-router'
|
||||
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { AuthProvider } from '@/pages/auth'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { Home } from 'lucide-react';
|
||||
export const Route = createRootRoute({
|
||||
component: RootComponent,
|
||||
})
|
||||
|
||||
function RootComponent() {
|
||||
const BaseHeader = (props: { main?: React.ComponentType | null }) => {
|
||||
if (props.main) {
|
||||
const MainComponent = props.main
|
||||
return <MainComponent />
|
||||
}
|
||||
return (
|
||||
<div className='h-full overflow-hidden'>
|
||||
|
||||
<div className="p-2 flex gap-2 text-lg">
|
||||
<>
|
||||
<div className="flex gap-2 text-lg w-full h-12 items-center">
|
||||
<div className='px-2'>
|
||||
<Link
|
||||
to="/"
|
||||
activeProps={{
|
||||
@@ -18,18 +26,27 @@ function RootComponent() {
|
||||
}}
|
||||
activeOptions={{ exact: true }}
|
||||
>
|
||||
Home
|
||||
<Home className='w-5 h-5' />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<AuthProvider>
|
||||
<main className='h-[calc(100%-4rem)] overflow-auto scrollbar'>
|
||||
</>
|
||||
)
|
||||
}
|
||||
function RootComponent() {
|
||||
return (
|
||||
<div className='h-full overflow-hidden'>
|
||||
<BaseHeader main={LayoutMain} />
|
||||
<AuthProvider mustLogin={true}>
|
||||
<TooltipProvider>
|
||||
<main className='h-[calc(100%-3rem)] overflow-auto scrollbar'>
|
||||
<Outlet />
|
||||
</main>
|
||||
</TooltipProvider>
|
||||
</AuthProvider>
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
<Toaster />
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
9
src/routes/cnb-board.tsx
Normal file
9
src/routes/cnb-board.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { App } from '@/pages/cnb-board/page'
|
||||
export const Route = createFileRoute('/cnb-board')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <App />
|
||||
}
|
||||
Reference in New Issue
Block a user