feat: implement new layout components and routing structure
- Added HeaderLayout component for the application header. - Introduced KeepAliveViewport for managing tab states and rendering. - Created TabNav for tab navigation with animated indicator. - Removed old Tabs component in favor of new layout structure. - Updated routing with AppRouter and defined appTabs for navigation. - Enhanced theme context to manage dark mode styles. - Added new UI components: Badge, Button, Separator, and Tabs. - Refactored pages to utilize new layout components and improve responsiveness. - Updated global styles for better theming and layout consistency. - Introduced TypeScript path aliases for cleaner imports.
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -58,4 +58,7 @@ Thumbs.db
|
|||||||
|
|
||||||
|
|
||||||
# logs files
|
# logs files
|
||||||
logs/
|
logs/
|
||||||
|
|
||||||
|
# codex
|
||||||
|
.agents
|
||||||
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
- Backend code lives under `backend/app/`; frontend is the Vite app in `frontend/`.
|
- Backend code lives under `backend/app/`; frontend is the Vite app in `frontend/`.
|
||||||
|
|
||||||
|
## Frontend UX Constraints
|
||||||
|
|
||||||
|
- Frontend work in `frontend/` must target desktop Web first.
|
||||||
|
- Do not proactively add mobile-specific adaptations, responsive reflow for small screens, or mobile-first layout compromises unless the user explicitly asks for them.
|
||||||
|
- When desktop and mobile requirements conflict, preserve the desktop Web layout and interaction model by default.
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
|
|
||||||
- Backend entrypoint is `backend/app/main.py`, which re-exports `app` from `app.api.main`.
|
- Backend entrypoint is `backend/app/main.py`, which re-exports `app` from `app.api.main`.
|
||||||
|
|||||||
@@ -381,5 +381,41 @@
|
|||||||
"processing_stage": "indexed",
|
"processing_stage": "indexed",
|
||||||
"index_collection": "regulations_dense_1024_v1"
|
"index_collection": "regulations_dense_1024_v1"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"52bd970f": {
|
||||||
|
"doc_id": "52bd970f",
|
||||||
|
"doc_name": "使用RSA Token连接CheckPoint VPN及PIN码设置_220.181.114.93 or 10.25.134.3.docx",
|
||||||
|
"file_name": "使用RSA Token连接CheckPoint VPN及PIN码设置_220.181.114.93 or 10.25.134.3.docx",
|
||||||
|
"object_name": "52bd970f/使用RSA Token连接CheckPoint VPN及PIN码设置_220.181.114.93 or 10.25.134.3.docx",
|
||||||
|
"content_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"size_bytes": 1199920,
|
||||||
|
"status": "indexed",
|
||||||
|
"regulation_type": "",
|
||||||
|
"version": "",
|
||||||
|
"summary": "",
|
||||||
|
"summary_latency_ms": 0,
|
||||||
|
"chunk_count": 34,
|
||||||
|
"parser_name": "aliyun_docmind",
|
||||||
|
"index_name": "regulations_dense_1024_v1",
|
||||||
|
"error_message": "",
|
||||||
|
"created_at": "2026-05-25T07:45:12.777459+00:00",
|
||||||
|
"updated_at": "2026-05-25T07:45:37.314290+00:00",
|
||||||
|
"metadata": {
|
||||||
|
"generate_summary": true,
|
||||||
|
"parser_backend": "aliyun_docmind",
|
||||||
|
"parse_task_id": "docmind-20260525-6d782dc33f2748a4a1020df765b8182d",
|
||||||
|
"layout_count": 48,
|
||||||
|
"structure_node_count": 6,
|
||||||
|
"semantic_block_count": 33,
|
||||||
|
"vector_chunk_count": 34,
|
||||||
|
"artifact_keys": {
|
||||||
|
"layouts": "artifacts/52bd970f/layouts.json",
|
||||||
|
"structure_nodes": "artifacts/52bd970f/structure_nodes.json",
|
||||||
|
"semantic_blocks": "artifacts/52bd970f/semantic_blocks.json",
|
||||||
|
"vector_chunks": "artifacts/52bd970f/vector_chunks.json"
|
||||||
|
},
|
||||||
|
"processing_stage": "indexed",
|
||||||
|
"index_collection": "regulations_dense_1024_v1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
25
frontend/components.json
Normal file
25
frontend/components.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "radix-nova",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/styles/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/shadcn/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
67
frontend/components/ui/button.tsx
Normal file
67
frontend/components/ui/button.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
import { Slot } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
outline:
|
||||||
|
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||||
|
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||||
|
icon: "size-8",
|
||||||
|
"icon-xs":
|
||||||
|
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||||
|
"icon-sm":
|
||||||
|
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||||
|
"icon-lg": "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button }
|
||||||
60
frontend/package-lock.json
generated
60
frontend/package-lock.json
generated
@@ -9,7 +9,8 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5"
|
"react-dom": "^19.2.5",
|
||||||
|
"react-router-dom": "^7.9.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
@@ -1631,6 +1632,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2751,6 +2765,44 @@
|
|||||||
"react": "^19.2.5"
|
"react": "^19.2.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-router": {
|
||||||
|
"version": "7.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
|
||||||
|
"integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.1",
|
||||||
|
"set-cookie-parser": "^2.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-router-dom": {
|
||||||
|
"version": "7.15.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
|
||||||
|
"integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-router": "7.15.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18",
|
||||||
|
"react-dom": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.0-rc.17",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
||||||
@@ -2808,6 +2860,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-cookie-parser": {
|
||||||
|
"version": "2.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||||
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
|
|||||||
@@ -10,8 +10,17 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.5",
|
"react": "^19.2.5",
|
||||||
"react-dom": "^19.2.5"
|
"react-dom": "^19.2.5",
|
||||||
|
"react-router-dom": "^7.9.6",
|
||||||
|
"shadcn": "^4.8.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|||||||
4076
frontend/pnpm-lock.yaml
generated
4076
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,51 +1,13 @@
|
|||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
import { ThemeProvider, AppProvider, useApp, useTheme } from './contexts';
|
import { ThemeProvider } from './contexts';
|
||||||
import { Header, Tabs } from './components/layout';
|
import { AppRouter } from './router/AppRouter';
|
||||||
import { CompliancePage } from './pages/Compliance';
|
|
||||||
import { DocsPage } from './pages/Docs';
|
|
||||||
import { StatusPage } from './pages/Status';
|
|
||||||
import { RagChatPage } from './pages/RagChat';
|
|
||||||
import { PerceptionPage } from './pages/Perception';
|
|
||||||
|
|
||||||
const PageContent = () => {
|
|
||||||
const { activeTab } = useApp();
|
|
||||||
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'perception':
|
|
||||||
return <PerceptionPage />;
|
|
||||||
case 'docs':
|
|
||||||
return <DocsPage />;
|
|
||||||
case 'compliance':
|
|
||||||
return <CompliancePage />;
|
|
||||||
case 'status':
|
|
||||||
return <StatusPage />;
|
|
||||||
case 'rag':
|
|
||||||
return <RagChatPage />;
|
|
||||||
default:
|
|
||||||
return <PerceptionPage />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const AppContent = () => {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-full flex flex-col min-h-screen" style={{ backgroundColor: theme.bg }}>
|
|
||||||
<Header />
|
|
||||||
<Tabs />
|
|
||||||
<PageContent />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<AppProvider>
|
<AppRouter />
|
||||||
<AppContent />
|
|
||||||
</AppProvider>
|
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
@@ -1,35 +1,25 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { MoonStar, SunMedium } from 'lucide-react';
|
||||||
|
|
||||||
import { useTheme } from '../../contexts';
|
import { useTheme } from '../../contexts';
|
||||||
|
import { Button } from '../shadcn/ui/button';
|
||||||
|
|
||||||
export const ThemeToggle: React.FC = () => {
|
export const ThemeToggle: React.FC = () => {
|
||||||
const { isDark, toggleTheme, theme } = useTheme();
|
const { isDark, toggleTheme } = useTheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
style={{
|
variant="outline"
|
||||||
width: 44,
|
size="icon-lg"
|
||||||
height: 44,
|
className="rounded-xl border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
borderRadius: 10,
|
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
background: isDark ? theme.bgHover : theme.bgCard,
|
|
||||||
border: `1px solid ${theme.border}`,
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
transition: 'all 0.3s ease',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{isDark ? (
|
{isDark ? (
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
<SunMedium />
|
||||||
<circle cx="12" cy="12" r="4" fill={theme.accent}/>
|
|
||||||
<path d="M12 2V4M12 20V22M4 12H2M22 12H20M6.34 6.34L4.93 4.93M19.07 19.07L17.66 17.66M6.34 17.66L4.93 19.07M19.07 4.93L17.66 6.34" stroke={theme.accent} strokeWidth="2" strokeLinecap="round"/>
|
|
||||||
</svg>
|
|
||||||
) : (
|
) : (
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
<MoonStar />
|
||||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" fill={theme.accent} stroke={theme.accent} strokeWidth="1"/>
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
23
frontend/src/components/layout/AppShell.tsx
Normal file
23
frontend/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { Outlet, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { FooterLayout } from './FooterLayout';
|
||||||
|
import { HeaderLayout } from './HeaderLayout';
|
||||||
|
import { ContentLayout } from './ContentLayout';
|
||||||
|
import { KeepAliveViewport } from './KeepAliveViewport';
|
||||||
|
import { getTabByPath } from '../../router/tabs';
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
const location = useLocation();
|
||||||
|
const activeTab = getTabByPath(location.pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col bg-t-bg text-t-text">
|
||||||
|
<HeaderLayout activeTab={activeTab} />
|
||||||
|
<ContentLayout tab={activeTab}>
|
||||||
|
<KeepAliveViewport activeTab={activeTab} />
|
||||||
|
<Outlet />
|
||||||
|
</ContentLayout>
|
||||||
|
<FooterLayout />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTheme } from '../../contexts';
|
|
||||||
|
|
||||||
interface ContentProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
wide?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Content: React.FC<ContentProps> = ({ children, wide = false }) => {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: '48px 56px',
|
|
||||||
maxWidth: wide ? 1400 : 1100,
|
|
||||||
margin: '0 auto',
|
|
||||||
width: '100%',
|
|
||||||
position: 'relative',
|
|
||||||
backgroundColor: theme.bg,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
40
frontend/src/components/layout/ContentLayout.tsx
Normal file
40
frontend/src/components/layout/ContentLayout.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
import type { AppTabConfig } from '../../router/tabs';
|
||||||
|
import { shellFrameClassName } from './shell-config';
|
||||||
|
|
||||||
|
interface ContentLayoutProps {
|
||||||
|
children: ReactNode;
|
||||||
|
tab: AppTabConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const widthClassMap = {
|
||||||
|
default: 'mx-auto w-full max-w-[1120px]',
|
||||||
|
wide: 'mx-auto w-full max-w-[1440px]',
|
||||||
|
full: 'w-full',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function ContentLayout({ children, tab }: ContentLayoutProps) {
|
||||||
|
const widthClass = widthClassMap[tab.contentWidth];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-0 flex-1 bg-t-bg">
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
shellFrameClassName,
|
||||||
|
'relative flex min-h-0 flex-1 py-8',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'relative flex w-full min-h-0 flex-1',
|
||||||
|
widthClass,
|
||||||
|
tab.fillHeight ? 'overflow-hidden' : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/components/layout/FooterLayout.tsx
Normal file
38
frontend/src/components/layout/FooterLayout.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Badge } from '../shadcn/ui/badge';
|
||||||
|
import { Separator } from '../shadcn/ui/separator';
|
||||||
|
|
||||||
|
import { shellFrameClassName, shellMeta } from './shell-config';
|
||||||
|
|
||||||
|
export function FooterLayout() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-t-border bg-t-bg">
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
shellFrameClassName,
|
||||||
|
'flex items-center justify-between gap-6 py-4 text-xs text-t-text3',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 max-w-[360px]">
|
||||||
|
<div className="mono mb-1 tracking-[0.18em] text-t-text2">
|
||||||
|
{shellMeta.productLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-3 whitespace-nowrap rounded-xl border border-border bg-card px-3 py-2 shadow-sm">
|
||||||
|
<Badge variant="secondary" className="mono border-0 bg-transparent px-0 py-0 text-[11px] tracking-[0.24em] text-muted-foreground">
|
||||||
|
{shellMeta.version}
|
||||||
|
</Badge>
|
||||||
|
<Separator orientation="vertical" className="h-4 bg-border" />
|
||||||
|
<Badge variant="outline" className="mono gap-2 border-0 bg-transparent px-0 py-0 text-[11px] tracking-[0.24em] text-[var(--t-green)]">
|
||||||
|
<span className="size-2 rounded-full bg-[var(--t-green)]" />
|
||||||
|
{shellMeta.status}
|
||||||
|
</Badge>
|
||||||
|
<Separator orientation="vertical" className="h-4 bg-border" />
|
||||||
|
<span className="mono text-[11px] tracking-[0.18em] text-muted-foreground">
|
||||||
|
{shellMeta.surface}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTheme } from '../../contexts';
|
|
||||||
import { TLogo } from '../common/TLogo';
|
|
||||||
import { ThemeToggle } from '../common/ThemeToggle';
|
|
||||||
|
|
||||||
export const Header: React.FC = () => {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header
|
|
||||||
className="h-[72px] flex items-center justify-between sticky top-0 z-[100]"
|
|
||||||
style={{
|
|
||||||
padding: '0 48px',
|
|
||||||
borderBottom: `1px solid ${theme.border}`,
|
|
||||||
backgroundColor: theme.bg,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center" style={{ gap: 20 }}>
|
|
||||||
<TLogo size={80} />
|
|
||||||
<div className="flex items-baseline" style={{ gap: 12 }}>
|
|
||||||
<span style={{ fontWeight: 700, fontSize: 20, letterSpacing: '-0.5px', color: theme.text }}>
|
|
||||||
T-Systems
|
|
||||||
</span>
|
|
||||||
<span style={{ fontWeight: 300, fontSize: 16, color: theme.text2 }}>
|
|
||||||
Regulation
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center" style={{ gap: 16 }}>
|
|
||||||
<ThemeToggle />
|
|
||||||
<div
|
|
||||||
className="flex items-center rounded-lg"
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
gap: 8,
|
|
||||||
backgroundColor: theme.bgHover,
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="mono" style={{ fontSize: 11, color: theme.text3 }}>v1.0.0</span>
|
|
||||||
<div style={{ width: 1, height: 12, background: theme.border }} />
|
|
||||||
<span className="mono" style={{ fontSize: 12, color: theme.green }}>● ONLINE</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
19
frontend/src/components/layout/HeaderBrand.tsx
Normal file
19
frontend/src/components/layout/HeaderBrand.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { TLogo } from '../common/TLogo';
|
||||||
|
|
||||||
|
export function HeaderBrand() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-[280px] shrink-0 items-center gap-4 whitespace-nowrap">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<TLogo size={46} />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 items-center gap-2 whitespace-nowrap">
|
||||||
|
<span className="text-[1.18rem] font-semibold tracking-[-0.04em] text-foreground">
|
||||||
|
T-Systems
|
||||||
|
</span>
|
||||||
|
<span className="text-[1.02rem] font-light text-muted-foreground">
|
||||||
|
Regulation
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/components/layout/HeaderLayout.tsx
Normal file
38
frontend/src/components/layout/HeaderLayout.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { AppTabConfig } from '../../router/tabs';
|
||||||
|
import { ThemeToggle } from '../common/ThemeToggle';
|
||||||
|
import { Badge } from '../shadcn/ui/badge';
|
||||||
|
import { Separator } from '../shadcn/ui/separator';
|
||||||
|
|
||||||
|
import { HeaderBrand } from './HeaderBrand';
|
||||||
|
import { shellFrameClassName, shellMeta } from './shell-config';
|
||||||
|
import { TabNav } from './TabNav';
|
||||||
|
|
||||||
|
interface HeaderLayoutProps {
|
||||||
|
activeTab: AppTabConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeaderLayout({ activeTab }: HeaderLayoutProps) {
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-[100] border-b border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||||
|
<div className={[shellFrameClassName, 'flex h-20 items-center gap-8'].join(' ')}>
|
||||||
|
<HeaderBrand />
|
||||||
|
<div className="min-w-0 flex-1 self-stretch overflow-hidden">
|
||||||
|
<TabNav activeTab={activeTab} />
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto flex shrink-0 items-center gap-3 self-center">
|
||||||
|
<ThemeToggle />
|
||||||
|
<div className="flex h-11 shrink-0 items-center gap-3 whitespace-nowrap rounded-xl border border-border bg-card px-3 shadow-sm">
|
||||||
|
<Badge variant="secondary" className="mono border-0 bg-transparent px-0 py-0 text-[11px] tracking-[0.24em] text-muted-foreground">
|
||||||
|
{shellMeta.version}
|
||||||
|
</Badge>
|
||||||
|
<Separator orientation="vertical" className="h-4 bg-border" />
|
||||||
|
<Badge variant="outline" className="mono gap-2 border-0 bg-transparent px-0 py-0 text-[11px] tracking-[0.24em] text-[var(--t-green)]">
|
||||||
|
<span className="size-2 rounded-full bg-[var(--t-green)]" />
|
||||||
|
{shellMeta.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
frontend/src/components/layout/KeepAliveViewport.tsx
Normal file
45
frontend/src/components/layout/KeepAliveViewport.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { appTabs, type AppTabConfig } from '../../router/tabs';
|
||||||
|
|
||||||
|
interface KeepAliveViewportProps {
|
||||||
|
activeTab: AppTabConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeepAliveViewport({ activeTab }: KeepAliveViewportProps) {
|
||||||
|
const [mountedTabIds, setMountedTabIds] = useState<string[]>([activeTab.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timerId = window.setTimeout(() => {
|
||||||
|
setMountedTabIds((prev) => (prev.includes(activeTab.id) ? prev : [...prev, activeTab.id]));
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(timerId);
|
||||||
|
}, [activeTab.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
{appTabs.map((tab) => {
|
||||||
|
const shouldRender = tab.keepAlive ? mountedTabIds.includes(tab.id) : tab.id === activeTab.id;
|
||||||
|
if (!shouldRender) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TabComponent = tab.component;
|
||||||
|
const isActive = tab.id === activeTab.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
aria-hidden={!isActive}
|
||||||
|
className={[
|
||||||
|
'min-h-0 flex-1',
|
||||||
|
isActive ? 'flex' : 'hidden',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<TabComponent />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
frontend/src/components/layout/TabNav.tsx
Normal file
125
frontend/src/components/layout/TabNav.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import type { AppTabConfig, TabId } from '../../router/tabs';
|
||||||
|
import { appTabs } from '../../router/tabs';
|
||||||
|
|
||||||
|
interface TabNavProps {
|
||||||
|
activeTab: AppTabConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IndicatorStyle {
|
||||||
|
opacity: number;
|
||||||
|
transform: string;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reducedMotionQuery = '(prefers-reduced-motion: reduce)';
|
||||||
|
|
||||||
|
export function TabNav({ activeTab }: TabNavProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const trackRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const buttonRefs = useRef<Record<TabId, HTMLButtonElement | null>>({
|
||||||
|
perception: null,
|
||||||
|
docs: null,
|
||||||
|
compliance: null,
|
||||||
|
status: null,
|
||||||
|
rag: null,
|
||||||
|
});
|
||||||
|
const [indicatorStyle, setIndicatorStyle] = useState<IndicatorStyle>({
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'translateX(0px)',
|
||||||
|
width: 0,
|
||||||
|
});
|
||||||
|
const [reducedMotion, setReducedMotion] = useState(false);
|
||||||
|
|
||||||
|
const handleValueChange = (value: string) => {
|
||||||
|
const nextTab = appTabs.find((tab) => tab.id === value);
|
||||||
|
if (nextTab && nextTab.path !== activeTab.path) {
|
||||||
|
navigate(nextTab.path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia(reducedMotionQuery);
|
||||||
|
const updateMotionPreference = () => {
|
||||||
|
setReducedMotion(mediaQuery.matches);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateMotionPreference();
|
||||||
|
mediaQuery.addEventListener('change', updateMotionPreference);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mediaQuery.removeEventListener('change', updateMotionPreference);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const updateIndicator = () => {
|
||||||
|
const trackNode = trackRef.current;
|
||||||
|
const activeNode = buttonRefs.current[activeTab.id];
|
||||||
|
if (!trackNode || !activeNode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackRect = trackNode.getBoundingClientRect();
|
||||||
|
const activeRect = activeNode.getBoundingClientRect();
|
||||||
|
|
||||||
|
setIndicatorStyle({
|
||||||
|
opacity: 1,
|
||||||
|
transform: `translateX(${activeRect.left - trackRect.left}px)`,
|
||||||
|
width: activeRect.width,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateIndicator();
|
||||||
|
window.addEventListener('resize', updateIndicator);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', updateIndicator);
|
||||||
|
};
|
||||||
|
}, [activeTab.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex h-full min-w-0 items-stretch overflow-x-auto overflow-y-hidden">
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className="relative flex h-full min-w-max flex-nowrap items-stretch gap-3 pr-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className={[
|
||||||
|
'pointer-events-none absolute bottom-0 left-0 h-0.5 rounded-full bg-primary',
|
||||||
|
reducedMotion
|
||||||
|
? 'transition-none'
|
||||||
|
: 'transition-[transform,width,opacity] duration-220 ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||||
|
].join(' ')}
|
||||||
|
style={indicatorStyle}
|
||||||
|
/>
|
||||||
|
{appTabs.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
ref={(node) => {
|
||||||
|
buttonRefs.current[tab.id] = node;
|
||||||
|
}}
|
||||||
|
data-shell-tab="true"
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleValueChange(tab.id satisfies TabId)}
|
||||||
|
aria-current={tab.id === activeTab.id ? 'page' : undefined}
|
||||||
|
className={[
|
||||||
|
'inline-flex h-full shrink-0 appearance-none items-center justify-center border-0 border-b-2 border-transparent bg-transparent px-5 pt-1 text-[0.95rem] font-medium tracking-[0.02em] outline-none',
|
||||||
|
reducedMotion
|
||||||
|
? 'transition-none'
|
||||||
|
: 'transition-[color,opacity] duration-200 ease-out',
|
||||||
|
tab.id === activeTab.id
|
||||||
|
? 'text-foreground'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTheme, useApp } from '../../contexts';
|
|
||||||
import type { TabId } from '../../contexts';
|
|
||||||
|
|
||||||
const tabs: Array<{ id: TabId; label: string }> = [
|
|
||||||
{ id: 'perception', label: '智能感知' },
|
|
||||||
{ id: 'docs', label: '文档管理' },
|
|
||||||
{ id: 'compliance', label: '合规分析' },
|
|
||||||
{ id: 'status', label: '系统状态' },
|
|
||||||
{ id: 'rag', label: '法规对话' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const Tabs: React.FC = () => {
|
|
||||||
const { theme } = useTheme();
|
|
||||||
const { activeTab, setActiveTab } = useApp();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav
|
|
||||||
className="h-[56px] flex items-center"
|
|
||||||
style={{
|
|
||||||
padding: '0 48px',
|
|
||||||
borderBottom: `1px solid ${theme.border}`,
|
|
||||||
backgroundColor: theme.bg,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
style={{
|
|
||||||
height: 56,
|
|
||||||
padding: '0 32px',
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: activeTab === tab.id ? 600 : 400,
|
|
||||||
color: activeTab === tab.id ? theme.accent : theme.text3,
|
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
borderBottom: activeTab === tab.id ? `3px solid ${theme.accent}` : '3px solid transparent',
|
|
||||||
marginBottom: -1,
|
|
||||||
cursor: 'pointer',
|
|
||||||
transition: 'all 0.2s ease',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { Header } from './Header';
|
export { AppShell } from './AppShell';
|
||||||
export { Tabs } from './Tabs';
|
export { ContentLayout } from './ContentLayout';
|
||||||
export { Content } from './Content';
|
export { FooterLayout } from './FooterLayout';
|
||||||
|
export { HeaderLayout } from './HeaderLayout';
|
||||||
|
|||||||
12
frontend/src/components/layout/shell-config.ts
Normal file
12
frontend/src/components/layout/shell-config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { appTabs } from '../../router/tabs';
|
||||||
|
|
||||||
|
export const shellFrameClassName = 'mx-auto w-full max-w-[1680px] px-8';
|
||||||
|
|
||||||
|
export const shellMeta = {
|
||||||
|
productLabel: 'T-Systems Regulation',
|
||||||
|
version: 'v1.0.0',
|
||||||
|
status: 'ONLINE',
|
||||||
|
surface: 'Desktop Web',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const shellModuleSummary = appTabs.map((tab) => tab.label).join(' / ');
|
||||||
30
frontend/src/components/shadcn/ui/badge.tsx
Normal file
30
frontend/src/components/shadcn/ui/badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import type * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-md border px-2 py-1 text-[11px] font-medium tracking-[0.22em] uppercase transition-colors',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-primary/30 bg-primary/10 text-primary',
|
||||||
|
secondary: 'border-border bg-muted text-muted-foreground',
|
||||||
|
outline: 'border-border bg-transparent text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<'span'> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge };
|
||||||
65
frontend/src/components/shadcn/ui/button.tsx
Normal file
65
frontend/src/components/shadcn/ui/button.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { Slot } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-4',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||||
|
outline:
|
||||||
|
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:bg-input/30 dark:hover:bg-input/50',
|
||||||
|
secondary:
|
||||||
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
||||||
|
ghost:
|
||||||
|
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default:
|
||||||
|
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
xs: 'h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3',
|
||||||
|
sm: 'h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*=size-])]:size-3.5',
|
||||||
|
lg: 'h-9 gap-1.5 px-3 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
||||||
|
icon: 'size-8',
|
||||||
|
'icon-xs': 'size-6 rounded-[min(var(--radius-md),10px)] [&_svg:not([class*=size-])]:size-3',
|
||||||
|
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)]',
|
||||||
|
'icon-lg': 'size-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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.Root : 'button';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
data-variant={variant}
|
||||||
|
data-size={size}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button };
|
||||||
27
frontend/src/components/shadcn/ui/separator.tsx
Normal file
27
frontend/src/components/shadcn/ui/separator.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Separator as SeparatorPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = 'horizontal',
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator-root"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 bg-border',
|
||||||
|
orientation === 'horizontal' ? 'h-px w-full' : 'h-full w-px',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator };
|
||||||
48
frontend/src/components/shadcn/ui/tabs.tsx
Normal file
48
frontend/src/components/shadcn/ui/tabs.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Tabs as TabsPrimitive } from 'radix-ui';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
className={cn('w-full', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
className={cn('inline-flex items-center gap-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger };
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { useState, type ReactNode } from 'react';
|
|
||||||
|
|
||||||
import { AppContext, type TabId } from './app-context';
|
|
||||||
|
|
||||||
interface AppProviderProps {
|
|
||||||
children: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AppProvider: React.FC<AppProviderProps> = ({ children }) => {
|
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('compliance');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AppContext.Provider value={{ activeTab, setActiveTab }}>
|
|
||||||
{children}
|
|
||||||
</AppContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -16,13 +16,14 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle('dark', isDark);
|
||||||
|
document.body.classList.toggle('dark-mode', isDark);
|
||||||
|
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
document.body.style.background = '#0a0a12';
|
document.body.style.background = '#0a0a12';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
document.body.style.background = '#ffffff';
|
document.body.style.background = '#ffffff';
|
||||||
}, [isDark]);
|
}, [isDark]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import { createContext } from 'react';
|
|
||||||
|
|
||||||
export type TabId = 'perception' | 'docs' | 'compliance' | 'status' | 'rag';
|
|
||||||
|
|
||||||
export interface AppContextValue {
|
|
||||||
activeTab: TabId;
|
|
||||||
setActiveTab: (tab: TabId) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AppContext = createContext<AppContextValue | undefined>(undefined);
|
|
||||||
@@ -1,5 +1,2 @@
|
|||||||
export { ThemeProvider } from './ThemeContext';
|
export { ThemeProvider } from './ThemeContext';
|
||||||
export { useTheme } from './useTheme';
|
export { useTheme } from './useTheme';
|
||||||
export { AppProvider } from './AppContext';
|
|
||||||
export type { AppContextValue, TabId } from './app-context';
|
|
||||||
export { useApp } from './useApp';
|
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { useContext } from 'react';
|
|
||||||
|
|
||||||
import { AppContext, type AppContextValue } from './app-context';
|
|
||||||
|
|
||||||
export function useApp(): AppContextValue {
|
|
||||||
const context = useContext(AppContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useApp must be used within an AppProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -587,7 +587,7 @@ export const CompliancePage: React.FC = () => {
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
minHeight: 'calc(100vh - 128px)',
|
minHeight: 0,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}>
|
}}>
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { useTheme } from '../../contexts';
|
import { useTheme } from '../../contexts';
|
||||||
import { Content } from '../../components/layout/Content';
|
|
||||||
import { TPattern } from '../../components/common/TPattern';
|
import { TPattern } from '../../components/common/TPattern';
|
||||||
import { getDocumentList, getDocumentStatus, searchRegulations, uploadDocument, deleteDocument, retryDocument, type RegulationSearchItem } from '../../api/docs';
|
import { getDocumentList, getDocumentStatus, searchRegulations, uploadDocument, deleteDocument, retryDocument, type RegulationSearchItem } from '../../api/docs';
|
||||||
import type { Doc } from '../../types';
|
import type { Doc } from '../../types';
|
||||||
@@ -40,6 +39,7 @@ export const DocsPage: React.FC = () => {
|
|||||||
const [searchResults, setSearchResults] = useState<RegulationSearchItem[]>([]);
|
const [searchResults, setSearchResults] = useState<RegulationSearchItem[]>([]);
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
const [searchError, setSearchError] = useState('');
|
const [searchError, setSearchError] = useState('');
|
||||||
|
const [batchQueueLength, setBatchQueueLength] = useState(0);
|
||||||
|
|
||||||
// Upload metadata
|
// Upload metadata
|
||||||
const [regulationType, setRegulationType] = useState('');
|
const [regulationType, setRegulationType] = useState('');
|
||||||
@@ -48,12 +48,17 @@ export const DocsPage: React.FC = () => {
|
|||||||
// Batch queue: files waiting to be uploaded after the current one finishes
|
// Batch queue: files waiting to be uploaded after the current one finishes
|
||||||
const batchQueueRef = useRef<File[]>([]);
|
const batchQueueRef = useRef<File[]>([]);
|
||||||
|
|
||||||
|
const setBatchQueue = (files: File[]) => {
|
||||||
|
batchQueueRef.current = files;
|
||||||
|
setBatchQueueLength(files.length);
|
||||||
|
};
|
||||||
|
|
||||||
async function loadDocuments() {
|
async function loadDocuments() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await getDocumentList();
|
const response = await getDocumentList();
|
||||||
const apiDocs: Doc[] = response.docs.map((doc) => ({
|
const apiDocs: Doc[] = response.docs.map((doc, index) => ({
|
||||||
id: parseInt(String(doc.id).replace('doc-', ''), 10) || Math.floor(Math.random() * 10000),
|
id: Number.parseInt(String(doc.id).replace('doc-', ''), 10) || -(index + 1),
|
||||||
name: doc.name,
|
name: doc.name,
|
||||||
chunks: doc.chunks,
|
chunks: doc.chunks,
|
||||||
size: doc.updated_at ? new Date(doc.updated_at).toLocaleString() : 'Indexed document',
|
size: doc.updated_at ? new Date(doc.updated_at).toLocaleString() : 'Indexed document',
|
||||||
@@ -209,6 +214,7 @@ export const DocsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Process next file in batch queue
|
// Process next file in batch queue
|
||||||
const next = batchQueueRef.current.shift();
|
const next = batchQueueRef.current.shift();
|
||||||
|
setBatchQueueLength(batchQueueRef.current.length);
|
||||||
if (next) {
|
if (next) {
|
||||||
const nextRunId = pipelineRunIdRef.current + 1;
|
const nextRunId = pipelineRunIdRef.current + 1;
|
||||||
pipelineRunIdRef.current = nextRunId;
|
pipelineRunIdRef.current = nextRunId;
|
||||||
@@ -222,7 +228,7 @@ export const DocsPage: React.FC = () => {
|
|||||||
if (files.length === 0 || uploading) return;
|
if (files.length === 0 || uploading) return;
|
||||||
|
|
||||||
const [first, ...rest] = files;
|
const [first, ...rest] = files;
|
||||||
batchQueueRef.current = rest;
|
setBatchQueue(rest);
|
||||||
|
|
||||||
const runId = pipelineRunIdRef.current + 1;
|
const runId = pipelineRunIdRef.current + 1;
|
||||||
pipelineRunIdRef.current = runId;
|
pipelineRunIdRef.current = runId;
|
||||||
@@ -262,7 +268,7 @@ export const DocsPage: React.FC = () => {
|
|||||||
if (files.length === 0 || uploading) return;
|
if (files.length === 0 || uploading) return;
|
||||||
|
|
||||||
const [first, ...rest] = files;
|
const [first, ...rest] = files;
|
||||||
batchQueueRef.current = rest;
|
setBatchQueue(rest);
|
||||||
const runId = pipelineRunIdRef.current + 1;
|
const runId = pipelineRunIdRef.current + 1;
|
||||||
pipelineRunIdRef.current = runId;
|
pipelineRunIdRef.current = runId;
|
||||||
void uploadSingleFile(first, runId);
|
void uploadSingleFile(first, runId);
|
||||||
@@ -282,8 +288,7 @@ export const DocsPage: React.FC = () => {
|
|||||||
|
|
||||||
const getPipelineHint = () => {
|
const getPipelineHint = () => {
|
||||||
if (pipelineStatus === 'running') {
|
if (pipelineStatus === 'running') {
|
||||||
const queueLen = batchQueueRef.current.length;
|
const suffix = batchQueueLength > 0 ? ` (+${batchQueueLength} 待上传)` : '';
|
||||||
const suffix = queueLen > 0 ? ` (+${queueLen} 待上传)` : '';
|
|
||||||
return `${activeStep >= 0 ? PIPELINE_STEPS[activeStep].name : 'LOAD'} · ${uploadFileName}${suffix}`;
|
return `${activeStep >= 0 ? PIPELINE_STEPS[activeStep].name : 'LOAD'} · ${uploadFileName}${suffix}`;
|
||||||
}
|
}
|
||||||
if (pipelineStatus === 'completed') return 'PIPELINE COMPLETE';
|
if (pipelineStatus === 'completed') return 'PIPELINE COMPLETE';
|
||||||
@@ -291,6 +296,11 @@ export const DocsPage: React.FC = () => {
|
|||||||
return 'WAITING FOR UPLOAD';
|
return 'WAITING FOR UPLOAD';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getDocKey = (doc: Doc) => {
|
||||||
|
// Prefer the backend document identifier because the numeric display id is not guaranteed unique.
|
||||||
|
return doc.docId ?? `local-${doc.id}-${doc.name}`;
|
||||||
|
};
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
@@ -302,7 +312,7 @@ export const DocsPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Content>
|
<div className="relative w-full">
|
||||||
<TPattern />
|
<TPattern />
|
||||||
|
|
||||||
<section style={{ marginBottom: 56 }}>
|
<section style={{ marginBottom: 56 }}>
|
||||||
@@ -432,7 +442,7 @@ export const DocsPage: React.FC = () => {
|
|||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
{docs.map((doc) => (
|
{docs.map((doc) => (
|
||||||
<div
|
<div
|
||||||
key={doc.id}
|
key={getDocKey(doc)}
|
||||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 20, background: theme.bgCard, borderRadius: 12, border: `1px solid ${doc.status === 'parsing' ? theme.accent : theme.border}`, transition: 'all 0.2s ease', boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none' }}
|
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: 20, background: theme.bgCard, borderRadius: 12, border: `1px solid ${doc.status === 'parsing' ? theme.accent : theme.border}`, transition: 'all 0.2s ease', boxShadow: !isDark ? '0 2px 8px rgba(226,0,116,0.04)' : 'none' }}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
||||||
@@ -543,6 +553,6 @@ export const DocsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Content>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTheme } from '../../contexts';
|
import { useTheme } from '../../contexts';
|
||||||
import { Content } from '../../components/layout/Content';
|
|
||||||
import { TPattern } from '../../components/common/TPattern';
|
import { TPattern } from '../../components/common/TPattern';
|
||||||
import {
|
import {
|
||||||
listEvents,
|
listEvents,
|
||||||
@@ -53,7 +52,10 @@ export const PerceptionPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [filterSource, filterImpact]);
|
}, [filterSource, filterImpact]);
|
||||||
|
|
||||||
useEffect(() => { void loadFeed(); }, [loadFeed]);
|
useEffect(() => {
|
||||||
|
const timerId = window.setTimeout(() => { void loadFeed(); }, 0);
|
||||||
|
return () => window.clearTimeout(timerId);
|
||||||
|
}, [loadFeed]);
|
||||||
|
|
||||||
// When selecting a new event, clear previous analysis
|
// When selecting a new event, clear previous analysis
|
||||||
const handleSelectEvent = (id: string) => {
|
const handleSelectEvent = (id: string) => {
|
||||||
@@ -96,7 +98,7 @@ export const PerceptionPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Content wide>
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
|
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
|
||||||
`}</style>
|
`}</style>
|
||||||
@@ -113,7 +115,7 @@ export const PerceptionPage: React.FC = () => {
|
|||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '400px 1fr',
|
gridTemplateColumns: '400px 1fr',
|
||||||
gap: 24,
|
gap: 24,
|
||||||
height: 'calc(100vh - 220px)',
|
flex: 1,
|
||||||
minHeight: 560,
|
minHeight: 560,
|
||||||
}}>
|
}}>
|
||||||
{/* Left: Event feed */}
|
{/* Left: Event feed */}
|
||||||
@@ -139,6 +141,6 @@ export const PerceptionPage: React.FC = () => {
|
|||||||
onAbort={handleAbort}
|
onAbort={handleAbort}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Content>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ export const RagChatPage: React.FC = () => {
|
|||||||
sessionId,
|
sessionId,
|
||||||
abortRef.current.signal,
|
abortRef.current.signal,
|
||||||
);
|
);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [filterRegulationType, sessionId]);
|
}, [filterRegulationType, sessionId]);
|
||||||
|
|
||||||
const sendMessage = (text: string) => {
|
const sendMessage = (text: string) => {
|
||||||
@@ -173,7 +172,7 @@ export const RagChatPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, display: 'flex', height: 'calc(100vh - 128px)' }}>
|
<div style={{ flex: 1, display: 'flex', minHeight: 0, height: '100%' }}>
|
||||||
{/* ── Left: chat panel ─────────────────────────────────── */}
|
{/* ── Left: chat panel ─────────────────────────────────── */}
|
||||||
<div style={{
|
<div style={{
|
||||||
flex: '0 0 60%',
|
flex: '0 0 60%',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useTheme } from '../../contexts';
|
import { useTheme } from '../../contexts';
|
||||||
import { Content } from '../../components/layout/Content';
|
|
||||||
import { TPattern } from '../../components/common/TPattern';
|
import { TPattern } from '../../components/common/TPattern';
|
||||||
import { getSystemStats, getSystemConfig, getSystemHealth, type SystemStats, type SystemConfig, type SystemHealth } from '../../api/status';
|
import { getSystemStats, getSystemConfig, getSystemHealth, type SystemStats, type SystemConfig, type SystemHealth } from '../../api/status';
|
||||||
import { getDocumentList, type DocInfo } from '../../api/docs';
|
import { getDocumentList, type DocInfo } from '../../api/docs';
|
||||||
@@ -107,7 +106,8 @@ export const StatusPage: React.FC = () => {
|
|||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadData();
|
const timerId = window.setTimeout(() => { void loadData(); }, 0);
|
||||||
|
return () => window.clearTimeout(timerId);
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
// Auto-poll every 5 s while any document is still processing
|
// Auto-poll every 5 s while any document is still processing
|
||||||
@@ -119,7 +119,7 @@ export const StatusPage: React.FC = () => {
|
|||||||
}, [docs, loadData]);
|
}, [docs, loadData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Content>
|
<div className="relative w-full">
|
||||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||||
<TPattern />
|
<TPattern />
|
||||||
|
|
||||||
@@ -386,6 +386,6 @@ export const StatusPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</section>
|
</section>
|
||||||
</Content>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
20
frontend/src/router/AppRouter.tsx
Normal file
20
frontend/src/router/AppRouter.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { AppShell } from '../components/layout/AppShell';
|
||||||
|
import { appTabs, defaultTab } from './tabs';
|
||||||
|
|
||||||
|
export function AppRouter() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppShell />}>
|
||||||
|
<Route index element={<Navigate to={defaultTab.path} replace />} />
|
||||||
|
{appTabs.map((tab) => (
|
||||||
|
<Route key={tab.id} path={tab.path.slice(1)} element={null} />
|
||||||
|
))}
|
||||||
|
<Route path="*" element={<Navigate to={defaultTab.path} replace />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend/src/router/tabs.tsx
Normal file
73
frontend/src/router/tabs.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import type { ComponentType } from 'react';
|
||||||
|
|
||||||
|
import { CompliancePage } from '../pages/Compliance';
|
||||||
|
import { DocsPage } from '../pages/Docs';
|
||||||
|
import { PerceptionPage } from '../pages/Perception';
|
||||||
|
import { RagChatPage } from '../pages/RagChat';
|
||||||
|
import { StatusPage } from '../pages/Status';
|
||||||
|
|
||||||
|
export type TabId = 'perception' | 'docs' | 'compliance' | 'status' | 'rag';
|
||||||
|
|
||||||
|
export type ContentWidth = 'default' | 'wide' | 'full';
|
||||||
|
|
||||||
|
export interface AppTabConfig {
|
||||||
|
id: TabId;
|
||||||
|
path: string;
|
||||||
|
label: string;
|
||||||
|
component: ComponentType;
|
||||||
|
keepAlive: boolean;
|
||||||
|
contentWidth: ContentWidth;
|
||||||
|
fillHeight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const appTabs: AppTabConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'perception',
|
||||||
|
path: '/perception',
|
||||||
|
label: '智能感知',
|
||||||
|
component: PerceptionPage,
|
||||||
|
keepAlive: true,
|
||||||
|
contentWidth: 'wide',
|
||||||
|
fillHeight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'docs',
|
||||||
|
path: '/docs',
|
||||||
|
label: '文档管理',
|
||||||
|
component: DocsPage,
|
||||||
|
keepAlive: true,
|
||||||
|
contentWidth: 'default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'compliance',
|
||||||
|
path: '/compliance',
|
||||||
|
label: '合规分析',
|
||||||
|
component: CompliancePage,
|
||||||
|
keepAlive: true,
|
||||||
|
contentWidth: 'full',
|
||||||
|
fillHeight: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
path: '/status',
|
||||||
|
label: '系统状态',
|
||||||
|
component: StatusPage,
|
||||||
|
keepAlive: true,
|
||||||
|
contentWidth: 'default',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rag',
|
||||||
|
path: '/rag',
|
||||||
|
label: '法规对话',
|
||||||
|
component: RagChatPage,
|
||||||
|
keepAlive: true,
|
||||||
|
contentWidth: 'full',
|
||||||
|
fillHeight: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const defaultTab = appTabs.find((tab) => tab.id === 'compliance') ?? appTabs[0];
|
||||||
|
|
||||||
|
export function getTabByPath(pathname: string): AppTabConfig {
|
||||||
|
return appTabs.find((tab) => tab.path === pathname) ?? defaultTab;
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=TeleNeo:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=TeleNeo:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
||||||
|
@import "tw-animate-css";
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
/* Light mode (default) */
|
/* Light mode (default) */
|
||||||
:root {
|
:root {
|
||||||
@@ -19,6 +20,38 @@
|
|||||||
--t-orange: #ff7700;
|
--t-orange: #ff7700;
|
||||||
--t-accent-glow: rgba(226,0,116,0.08);
|
--t-accent-glow: rgba(226,0,116,0.08);
|
||||||
--t-pattern-opacity: 0.04;
|
--t-pattern-opacity: 0.04;
|
||||||
|
--background: var(--t-bg);
|
||||||
|
--foreground: var(--t-text);
|
||||||
|
--card: var(--t-bg-card);
|
||||||
|
--card-foreground: var(--t-text);
|
||||||
|
--popover: var(--t-bg-card);
|
||||||
|
--popover-foreground: var(--t-text);
|
||||||
|
--primary: #e20074;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: var(--t-bg-hover);
|
||||||
|
--secondary-foreground: var(--t-text);
|
||||||
|
--muted: var(--t-bg-hover);
|
||||||
|
--muted-foreground: var(--t-text3);
|
||||||
|
--accent: rgba(226, 0, 116, 0.08);
|
||||||
|
--accent-foreground: #e20074;
|
||||||
|
--destructive: #ff4444;
|
||||||
|
--border: var(--t-border);
|
||||||
|
--input: var(--t-border);
|
||||||
|
--ring: rgba(226, 0, 116, 0.35);
|
||||||
|
--chart-1: #e20074;
|
||||||
|
--chart-2: #be0060;
|
||||||
|
--chart-3: #00b89c;
|
||||||
|
--chart-4: #ff7700;
|
||||||
|
--chart-5: #4a4a5a;
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--sidebar: var(--t-bg-card);
|
||||||
|
--sidebar-foreground: var(--t-text);
|
||||||
|
--sidebar-primary: #e20074;
|
||||||
|
--sidebar-primary-foreground: #ffffff;
|
||||||
|
--sidebar-accent: var(--t-bg-hover);
|
||||||
|
--sidebar-accent-foreground: var(--t-text);
|
||||||
|
--sidebar-border: var(--t-border);
|
||||||
|
--sidebar-ring: rgba(226, 0, 116, 0.35);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode */
|
/* Dark mode */
|
||||||
@@ -36,6 +69,37 @@
|
|||||||
--t-orange: #ff8800;
|
--t-orange: #ff8800;
|
||||||
--t-accent-glow: rgba(226,0,116,0.12);
|
--t-accent-glow: rgba(226,0,116,0.12);
|
||||||
--t-pattern-opacity: 0.03;
|
--t-pattern-opacity: 0.03;
|
||||||
|
--background: var(--t-bg);
|
||||||
|
--foreground: var(--t-text);
|
||||||
|
--card: var(--t-bg-card);
|
||||||
|
--card-foreground: var(--t-text);
|
||||||
|
--popover: var(--t-bg-card);
|
||||||
|
--popover-foreground: var(--t-text);
|
||||||
|
--primary: #e20074;
|
||||||
|
--primary-foreground: #ffffff;
|
||||||
|
--secondary: var(--t-bg-hover);
|
||||||
|
--secondary-foreground: var(--t-text);
|
||||||
|
--muted: var(--t-bg-hover);
|
||||||
|
--muted-foreground: var(--t-text3);
|
||||||
|
--accent: rgba(226, 0, 116, 0.14);
|
||||||
|
--accent-foreground: #ff7abf;
|
||||||
|
--destructive: #ff4444;
|
||||||
|
--border: var(--t-border);
|
||||||
|
--input: var(--t-border-light);
|
||||||
|
--ring: rgba(226, 0, 116, 0.45);
|
||||||
|
--chart-1: #e20074;
|
||||||
|
--chart-2: #f04090;
|
||||||
|
--chart-3: #00d4aa;
|
||||||
|
--chart-4: #ff8800;
|
||||||
|
--chart-5: #c0c0d0;
|
||||||
|
--sidebar: var(--t-bg-card);
|
||||||
|
--sidebar-foreground: var(--t-text);
|
||||||
|
--sidebar-primary: #e20074;
|
||||||
|
--sidebar-primary-foreground: #ffffff;
|
||||||
|
--sidebar-accent: var(--t-bg-hover);
|
||||||
|
--sidebar-accent-foreground: var(--t-text);
|
||||||
|
--sidebar-border: var(--t-border);
|
||||||
|
--sidebar-ring: rgba(226, 0, 116, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base styles */
|
/* Base styles */
|
||||||
@@ -100,6 +164,11 @@ button, input {
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shell navigation manages its own transition timing. */
|
||||||
|
[data-shell-tab='true'] {
|
||||||
|
transition: color 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
/* T-Systems Button Style */
|
/* T-Systems Button Style */
|
||||||
.t-btn,
|
.t-btn,
|
||||||
.t-btn:hover {
|
.t-btn:hover {
|
||||||
@@ -271,4 +340,60 @@ button, input {
|
|||||||
.gradient-accent-hover {
|
.gradient-accent-hover {
|
||||||
background: linear-gradient(135deg, #f0208a 0%, #d01070 100%);
|
background: linear-gradient(135deg, #f0208a 0%, #d01070 100%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-heading: 'TeleNeo', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--font-sans: 'TeleNeo', 'Segoe UI', system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) * 1.4);
|
||||||
|
--radius-2xl: calc(var(--radius) * 1.8);
|
||||||
|
--radius-3xl: calc(var(--radius) * 2.2);
|
||||||
|
--radius-4xl: calc(var(--radius) * 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
html {
|
||||||
|
@apply font-sans;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,11 @@
|
|||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"types": ["vite/client"],
|
"types": ["vite/client"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
/* Bundler mode */
|
/* Bundler mode */
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
|||||||
@@ -3,5 +3,12 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" }
|
||||||
]
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"ignoreDeprecations": "6.0",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import path from 'node:path'
|
||||||
import { defineConfig, loadEnv } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
@@ -11,6 +12,11 @@ export default defineConfig(({ mode }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: Number(env.FRONTEND_PORT || 5173),
|
port: Number(env.FRONTEND_PORT || 5173),
|
||||||
|
|||||||
11
skills-lock.json
Normal file
11
skills-lock.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"shadcn": {
|
||||||
|
"source": "shadcn/ui",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/shadcn/SKILL.md",
|
||||||
|
"computedHash": "f0fdd03f4755cbb3b57e20fe6fd4a97c00d34e6780187b6559316986e8d848db"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user