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:
2026-02-24 04:34:25 +08:00
parent c2d4d706be
commit dd8d5b7341
16 changed files with 3762 additions and 45 deletions

View File

@@ -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

File diff suppressed because it is too large Load Diff

9
skills/page/SKILL.md Normal file
View File

@@ -0,0 +1,9 @@
---
name: new-page
description: 创建一个新页面
---
## 参考当前的文档
`./references/*.ts`

View 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

View 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 }),
}));

View File

@@ -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
});
}));

View File

@@ -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
View 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 });
}
}
}
}));

View 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>
);
}

View 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']()

View 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;

View 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();
},
}));

View File

@@ -1,5 +1,3 @@
const Home = () => {
return <div>Home Page</div>
}
import { App } from './cnb-board/page'
export default Home;
export default App;

View File

@@ -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

View File

@@ -1,35 +1,52 @@
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,
})
const BaseHeader = (props: { main?: React.ComponentType | null }) => {
if (props.main) {
const MainComponent = props.main
return <MainComponent />
}
return (
<>
<div className="flex gap-2 text-lg w-full h-12 items-center">
<div className='px-2'>
<Link
to="/"
activeProps={{
className: 'font-bold',
}}
activeOptions={{ exact: true }}
>
<Home className='w-5 h-5' />
</Link>
</div>
</div>
<hr />
</>
)
}
function RootComponent() {
return (
<div className='h-full overflow-hidden'>
<div className="p-2 flex gap-2 text-lg">
<Link
to="/"
activeProps={{
className: 'font-bold',
}}
activeOptions={{ exact: true }}
>
Home
</Link>
</div>
<hr />
<AuthProvider>
<main className='h-[calc(100%-4rem)] overflow-auto scrollbar'>
<Outlet />
</main>
<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
View 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 />
}