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:
ash66
2026-05-25 16:19:18 +08:00
parent 10a034e294
commit 987cc097da
43 changed files with 5099 additions and 265 deletions

3
.gitignore vendored
View File

@@ -59,3 +59,6 @@ Thumbs.db
# logs files # logs files
logs/ logs/
# codex
.agents

View File

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

View File

@@ -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
View 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": {}
}

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,49 +1,11 @@
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>
); );
} }

View File

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

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 */}

View File

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

View File

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

View File

@@ -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%',

View File

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

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

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

View File

@@ -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 {
@@ -272,3 +341,59 @@ button, input {
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;
}
}

View File

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

View File

@@ -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/*"]
}
}
} }

View File

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

@@ -0,0 +1,11 @@
{
"version": 1,
"skills": {
"shadcn": {
"source": "shadcn/ui",
"sourceType": "github",
"skillPath": "skills/shadcn/SKILL.md",
"computedHash": "f0fdd03f4755cbb3b57e20fe6fd4a97c00d34e6780187b6559316986e8d848db"
}
}
}