feat(app): implement multi-page application structure with routing and theme management
ZhenYi committed
Modified lib/service/webapi/src/assets.rs lib/service/webapi/src/assets.rs +19 −2 View lib/service/webapi/src/assets.rs
@@ -15,8 +15,14 @@
1515
1616/// 非 SPA 的首段路径:这些前缀只服务于接口,拼错时给 JSON 404,
1717/// 避免把已移除的旧路径回落到 index.html 变成 200。
18const API_PREFIXES: [&str; 2] = ["api", "auth"];
18const API_PREFIXES: [&str; 1] = ["api"];
19
20/// MPA 子应用前缀 → 入口 HTML,与 vite.config.ts 的 APP_ENTRIES 保持一致。
21const APP_ENTRIES: [(&str, &str); 2] = [("auth", "auth/index.html"), ("app", "app/index.html")];
1922
23/// 未命中任何子应用前缀时回落落地页。
24const LANDING_ENTRY: &str = "index.html";
25
2026pub async fn serve(request: HttpRequest) -> HttpResponse {
2127 let raw = request.path().trim_start_matches('/');
2228 let path = if raw.is_empty() { "index.html" } else { raw };
@@ -44,7 +50,9 @@
4450 return api_not_found();
4551 }
4652
47 match lookup(path).or_else(|| lookup("index.html")) {
53 let fallback = app_entry(path);
54
55 match lookup(path).or_else(|| lookup(fallback)) {
4856 Some(asset) => {
4957 let cache_control = if asset.path.starts_with("assets/") {
5058 "public, max-age=31536000, immutable"
@@ -63,6 +71,15 @@
6371 }
6472}
6573
74fn app_entry(path: &str) -> &'static str {
75 let first = path.split('/').next().unwrap_or_default();
76 APP_ENTRIES
77 .iter()
78 .find(|(prefix, _)| *prefix == first)
79 .map(|(_, entry)| *entry)
80 .unwrap_or(LANDING_ENTRY)
81}
82
6683fn lookup(path: &str) -> Option<&'static Asset> {
6784 ASSETS.iter().find(|asset| asset.path == path)
6885}
Added view/app/src/styles/index.css view/app/src/styles/index.css +4 −0 View view/app/src/styles/index.css
@@ -0,0 +1,4 @@
1@import "../../../shared/styles/fonts.css";
2@import "tailwindcss";
3@import "../../../shared/styles/theme.css";
4@source "../../";
Added view/app/src/app.tsx view/app/src/app.tsx +112 −0 View view/app/src/app.tsx
@@ -0,0 +1,112 @@
1import { LayoutDashboard, Moon, Sun } from 'lucide-react'
2import type { ReactNode } from 'react'
3import { useEffect, useState } from 'react'
4import { Navigate, Route, Routes } from 'react-router-dom'
5
6function useTheme() {
7 const [theme, setTheme] = useState(() => {
8 try {
9 return localStorage.getItem('auth-theme') || 'system'
10 } catch {
11 return 'system'
12 }
13 })
14 const [systemDark, setSystemDark] = useState(() => matchMedia('(prefers-color-scheme: dark)').matches)
15 const dark = theme === 'dark' || (theme === 'system' && systemDark)
16 useEffect(() => {
17 const media = matchMedia('(prefers-color-scheme: dark)')
18 const update = () => setSystemDark(media.matches)
19 media.addEventListener('change', update)
20 return () => media.removeEventListener('change', update)
21 }, [])
22 useEffect(() => {
23 document.documentElement.dataset.theme = dark ? 'dark' : 'light'
24 }, [dark])
25 function toggle() {
26 const next = dark ? 'light' : 'dark'
27 setTheme(next)
28 try {
29 localStorage.setItem('auth-theme', next)
30 } catch {}
31 }
32 return { dark, toggle }
33}
34
35function Shell({ children }: { children: ReactNode }) {
36 const { dark, toggle } = useTheme()
37 return (
38 <div className="flex min-h-dvh flex-col bg-page text-ink">
39 <header className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-5 md:px-10">
40 <a href="/" className="inline-flex items-center gap-2.75" aria-label="GitDataAI 首页">
41 <img src="/media/gitdataai-red-panda.png" width={40} height={40} alt="" />
42 <span className="font-latin text-[19px] font-semibold tracking-[-0.4px]">GitDataAI</span>
43 </a>
44 <div className="flex items-center gap-2">
45 <button
46 type="button"
47 onClick={toggle}
48 aria-label={dark ? '切换到浅色主题' : '切换到深色主题'}
49 className="flex size-11 items-center justify-center rounded-lg text-icon hover:bg-elevated hover:text-ink"
50 >
51 {dark ? <Sun size={18} strokeWidth={1.6} /> : <Moon size={18} strokeWidth={1.6} />}
52 </button>
53 <a
54 href="/auth/login"
55 className="inline-flex min-h-10 items-center rounded-lg border border-control-border bg-field px-4 text-sm font-medium text-ink transition-colors hover:bg-elevated"
56 >
57 账户
58 </a>
59 </div>
60 </header>
61 <main className="mx-auto w-full max-w-6xl flex-1 px-5 py-10 md:px-10">{children}</main>
62 </div>
63 )
64}
65
66function HomePage() {
67 return (
68 <>
69 <div className="mb-8 flex items-center gap-3">
70 <span className="flex size-11 items-center justify-center rounded-xl bg-accent-soft text-accent">
71 <LayoutDashboard size={20} strokeWidth={1.8} />
72 </span>
73 <div>
74 <h1 className="text-2xl font-semibold tracking-[-0.4px]">工作台</h1>
75 <p className="text-sm text-muted">空间、仓库与协作的入口。</p>
76 </div>
77 </div>
78
79 <section className="rounded-xl border border-line bg-field p-6">
80 <h2 className="mb-2 text-base font-semibold">应用骨架已就绪</h2>
81 <p className="mb-4 text-sm leading-[1.7] text-muted">
82 这里将承载登录后的空间列表、通知与仓库视图。当前只初始化了路由与布局,业务模块按需接入。
83 </p>
84 <div className="flex flex-wrap gap-3">
85 <a
86 href="/auth/login"
87 className="inline-flex min-h-11 items-center rounded-lg bg-primary px-5 text-sm font-semibold text-white transition-colors hover:bg-primary-hover active:bg-primary-pressed"
88 >
89 前往登录
90 </a>
91 <a
92 href="/"
93 className="inline-flex min-h-11 items-center rounded-lg border border-control-border bg-field px-5 text-sm font-semibold text-ink transition-colors hover:bg-elevated"
94 >
95 返回首页
96 </a>
97 </div>
98 </section>
99 </>
100 )
101}
102
103export function App() {
104 return (
105 <Shell>
106 <Routes>
107 <Route path="/app" element={<HomePage />} />
108 <Route path="*" element={<Navigate to="/app" replace />} />
109 </Routes>
110 </Shell>
111 )
112}
Added view/app/src/main.tsx view/app/src/main.tsx +12 −0 View view/app/src/main.tsx
@@ -0,0 +1,12 @@
1import { createRoot } from 'react-dom/client'
2import { BrowserRouter } from 'react-router-dom'
3import './styles/index.css'
4import { App } from './app'
5
6const root = document.getElementById('app')
7if (root)
8 createRoot(root).render(
9 <BrowserRouter>
10 <App />
11 </BrowserRouter>,
12 )
Added view/app/index.html view/app/index.html +27 −0 View view/app/index.html
@@ -0,0 +1,27 @@
1<!doctype html>
2<html lang="zh-CN">
3 <head>
4 <meta charset="UTF-8" />
5 <link rel="icon" type="image/png" href="/media/gitdataai-red-panda.png" />
6 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
7 <meta name="referrer" content="no-referrer" />
8 <meta name="color-scheme" content="light dark" />
9 <meta name="description" content="GitDataAI 工作台:管理你的空间、仓库与协作。" />
10 <title>GitDataAI</title>
11 <script>
12 try {
13 const t = localStorage.getItem('auth-theme')
14 document.documentElement.dataset.theme =
15 t === 'dark' || (t !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'
16 } catch {
17 document.documentElement.dataset.theme = matchMedia('(prefers-color-scheme: dark)').matches
18 ? 'dark'
19 : 'light'
20 }
21 </script>
22 </head>
23 <body>
24 <div id="app"></div>
25 <script type="module" src="/app/src/main.tsx"></script>
26 </body>
27</html>
Modified view/auth/src/app/styles/index.css view/auth/src/app/styles/index.css +2 −138 View view/auth/src/app/styles/index.css
@@ -1,140 +1,4 @@
1@import "@fontsource-variable/noto-sans-sc";
2@import "@fontsource-variable/geist";
1@import "../../../../shared/styles/fonts.css";
32@import "tailwindcss";
3@import "../../../../shared/styles/theme.css";
44@source "../../";
5
6@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
7
8:root {
9 color-scheme: light;
10 --auth-ink: #172136;
11 --auth-muted: #5f6c7f;
12 --auth-line: #dfe5ef;
13 --auth-accent: #f04d00;
14 --auth-accent-text: #b83d00;
15 --auth-accent-soft: #fff0e6;
16 --auth-bg: #ffffff;
17 --auth-field: #ffffff;
18 --auth-border: #8591a2;
19 --auth-divider: #dfe4eb;
20 --auth-icon: #8a94a6;
21 --auth-danger: #c93636;
22 --auth-success: #238450;
23 --auth-success-bg: #edf9f2;
24 --auth-elevated: #f4f6f8;
25 --auth-focus: #b83d00;
26 --auth-error-bg: #fff2ef;
27 --auth-disabled: #edf0f4;
28 --auth-disabled-ink: #606a79;
29 font-family: "Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif;
30 font-synthesis: none;
31 text-rendering: optimizeLegibility;
32 -webkit-font-smoothing: antialiased;
33 -moz-osx-font-smoothing: grayscale;
34 background: var(--auth-bg);
35 color: var(--auth-ink);
36}
37
38:root[data-theme="dark"] {
39 color-scheme: dark;
40 --auth-ink: #f3f3f5;
41 --auth-muted: #bebec5;
42 --auth-line: #46464c;
43 --auth-accent-text: #ff9a62;
44 --auth-accent-soft: #463329;
45 --auth-bg: #242426;
46 --auth-field: #2e2e31;
47 --auth-border: #85858f;
48 --auth-divider: #48484e;
49 --auth-icon: #b6b6bf;
50 --auth-danger: #ff968c;
51 --auth-success: #74d9a0;
52 --auth-success-bg: #273c30;
53 --auth-elevated: #38383c;
54 --auth-focus: #ff9a62;
55 --auth-error-bg: #48302e;
56 --auth-disabled: #38383c;
57 --auth-disabled-ink: #acacb5;
58}
59
60@theme inline {
61 --color-ink: var(--auth-ink);
62 --color-muted: var(--auth-muted);
63 --color-line: var(--auth-line);
64 --color-accent: var(--auth-accent);
65 --color-accent-text: var(--auth-accent-text);
66 --color-accent-soft: var(--auth-accent-soft);
67 --color-page: var(--auth-bg);
68 --color-field: var(--auth-field);
69 --color-control-border: var(--auth-border);
70 --color-divider: var(--auth-divider);
71 --color-icon: var(--auth-icon);
72 --color-danger: var(--auth-danger);
73 --color-success: var(--auth-success);
74 --color-success-bg: var(--auth-success-bg);
75 --color-elevated: var(--auth-elevated);
76 --color-focus: var(--auth-focus);
77 --color-error-bg: var(--auth-error-bg);
78 --color-disabled: var(--auth-disabled);
79 --color-disabled-ink: var(--auth-disabled-ink);
80 --color-primary: #d44200;
81 --color-primary-hover: #be3900;
82 --color-primary-pressed: #a83300;
83 --font-latin: "Geist Variable", sans-serif;
84}
85
86html,
87body,
88#app {
89 min-height: 100%;
90}
91body {
92 margin: 0;
93 min-width: 280px;
94}
95button,
96input,
97select {
98 font: inherit;
99}
100button,
101a,
102select,
103input[type="checkbox"] {
104 -webkit-tap-highlight-color: transparent;
105}
106button:not(:disabled),
107select:not(:disabled) {
108 cursor: pointer;
109}
110a,
111button,
112input,
113select {
114 touch-action: manipulation;
115}
116@layer base {
117 :where(button, a, input, select):focus-visible {
118 outline: 2px solid var(--auth-focus);
119 outline-offset: 3px;
120 }
121}
122input:disabled {
123 -webkit-text-fill-color: var(--auth-disabled-ink);
124 opacity: 1;
125}
126fieldset:disabled {
127 color: var(--auth-disabled-ink);
128}
129input:-webkit-autofill {
130 -webkit-box-shadow: 0 0 0 100px var(--auth-field) inset;
131 -webkit-text-fill-color: var(--auth-ink);
132 caret-color: var(--auth-ink);
133}
134::selection {
135 background: var(--auth-accent-soft);
136 color: var(--auth-ink);
137}
138dialog[open] {
139 display: block;
140}
Modified view/auth/src/app/app.tsx view/auth/src/app/app.tsx +3 −31 View view/auth/src/app/app.tsx
@@ -1,34 +1,6 @@
1import { useEffect } from 'react'
2import { registrationDraft } from '@/entities/registration'
3import { mfaChallenge } from '@/entities/session'
4import { routes } from '@/shared/config'
5import { t, useLocale } from '@/shared/i18n'
6import { usePath } from '@/shared/lib'
7import { AppShell } from '@/widgets/app-shell'
8import { Router } from './routing/router'
1import { RouterProvider } from 'react-router-dom'
2import { router } from './routing/router'
93
104export function App() {
11 const path = usePath()
12 const locale = useLocale()
13 useEffect(() => {
14 document.documentElement.lang = locale
15 document.querySelector('meta[name="description"]')?.setAttribute('content', t('app.description'))
16 }, [locale])
17 useEffect(() => {
18 const heading = document.querySelector('h1')
19 document.title = `${heading?.textContent || t('app.title')} · GitDataAI`
20 heading?.focus({ preventScroll: true })
21 if (path !== routes.loginVerify) mfaChallenge.reset()
22 if (path !== routes.register && path !== routes.registerVerify) registrationDraft.reset()
23 if (path === routes.resetPassword && new URLSearchParams(location.search).has('token')) {
24 const url = new URL(location.href)
25 url.searchParams.delete('token')
26 history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`)
27 }
28 }, [path, locale])
29 return (
30 <AppShell>
31 <Router />
32 </AppShell>
33 )
5 return <RouterProvider router={router} />
346}
Modified view/auth/src/shared/config/routes.ts view/auth/src/shared/config/routes.ts +11 −11 View view/auth/src/shared/config/routes.ts
@@ -1,13 +1,13 @@
11export const routes = {
2 home: '/',
3 login: '/login',
4 loginCode: '/login/code',
5 loginVerify: '/login/verify',
6 register: '/register',
7 registerVerify: '/register/verify',
8 forgotPassword: '/forgot-password',
9 resetSent: '/reset-sent',
10 resetPassword: '/reset-password',
11 resetComplete: '/reset-complete',
12 account: '/success',
2 home: '/auth',
3 login: '/auth/login',
4 loginCode: '/auth/login/code',
5 loginVerify: '/auth/login/verify',
6 register: '/auth/register',
7 registerVerify: '/auth/register/verify',
8 forgotPassword: '/auth/forgot-password',
9 resetSent: '/auth/reset-sent',
10 resetPassword: '/auth/reset-password',
11 resetComplete: '/auth/reset-complete',
12 account: '/auth/success',
1313} as const
Modified view/auth/src/shared/lib/index.ts view/auth/src/shared/lib/index.ts +1 −1 View view/auth/src/shared/lib/index.ts
@@ -1,4 +1,4 @@
1export { navigate, navigationHref, usePath } from './navigation'
1export { navigationHref } from './navigation'
22export { passwordDigest } from './password'
33export type { Store } from './store'
44export { createStore, useStore } from './store'
Modified view/auth/src/shared/lib/navigation.ts view/auth/src/shared/lib/navigation.ts +0 −34 View view/auth/src/shared/lib/navigation.ts
@@ -1,5 +1,3 @@
1import { useEffect, useState } from 'react'
2
31export function navigationHref(path: string): string {
42 const target = new URL(path, location.href)
53 if (target.origin !== location.origin) return target.href
@@ -9,36 +7,4 @@
97 for (const value of current.getAll(key)) target.searchParams.append(key, value)
108 }
119 return `${target.pathname}${target.search}${target.hash}`
12}
13
14export function navigate(path: string, replace = false) {
15 const href = navigationHref(path)
16 const target = new URL(href, location.href)
17 if (target.origin !== location.origin) {
18 location.assign(href)
19 return
20 }
21 const sameDocument = target.pathname === location.pathname && target.search === location.search
22 if (replace) history.replaceState(null, '', href)
23 else history.pushState(null, '', href)
24 window.dispatchEvent(new PopStateEvent('popstate'))
25 if (target.hash) {
26 const anchor = document.getElementById(decodeURIComponent(target.hash.slice(1)))
27 if (anchor) {
28 anchor.scrollIntoView({ block: 'start' })
29 return
30 }
31 if (sameDocument) return
32 }
33 window.scrollTo(0, 0)
34}
35
36export function usePath() {
37 const [url, setUrl] = useState(location.pathname + location.search)
38 useEffect(() => {
39 const update = () => setUrl(location.pathname + location.search)
40 window.addEventListener('popstate', update)
41 return () => window.removeEventListener('popstate', update)
42 }, [])
43 return url.split('?')[0].replace(/\/$/, '') || '/'
4410}
Modified view/auth/src/shared/ui/atoms/link.tsx view/auth/src/shared/ui/atoms/link.tsx +5 −13 View view/auth/src/shared/ui/atoms/link.tsx
@@ -1,22 +1,14 @@
11import type { ReactNode } from 'react'
2import { navigate, navigationHref } from '@/shared/lib'
2import { Link as RouterLink } from 'react-router-dom'
3import { navigationHref } from '@/shared/lib'
34
45export function Link({ to, children, className = '' }: { to: string; children: ReactNode; className?: string }) {
56 return (
6 <a
7 href={navigationHref(to)}
7 <RouterLink
8 to={navigationHref(to)}
89 className={`inline-flex min-h-11 items-center justify-center gap-2 px-0.5 text-xs font-medium text-accent-text hover:underline ${className}`}
9 onClick={(event) => {
10 if (event.button || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
11 // 只有同源 http(s) 链接才拦截:外链、mailto/tel 与自定义协议交给浏览器原生行为。
12 const href = navigationHref(to)
13 const target = new URL(href, location.href)
14 if (!/^https?:$/.test(target.protocol) || target.origin !== location.origin) return
15 event.preventDefault()
16 navigate(to)
17 }}
1810 >
1911 {children}
20 </a>
12 </RouterLink>
2113 )
2214}
Modified view/auth/src/widgets/app-shell/ui/app-shell.tsx view/auth/src/widgets/app-shell/ui/app-shell.tsx +2 −12 View view/auth/src/widgets/app-shell/ui/app-shell.tsx
@@ -1,8 +1,6 @@
11import type { ReactNode } from 'react'
22import { ThemeSwitch, useTheme } from '@/features/theme-toggle'
3import { routes } from '@/shared/config'
43import { t } from '@/shared/i18n'
5import { navigate, navigationHref } from '@/shared/lib'
64import { LegalLink } from '@/shared/ui'
75
86export function AppShell({ children }: { children: ReactNode }) {
@@ -10,16 +8,8 @@
108 return (
119 <div className="flex min-h-dvh flex-col bg-page px-4 pt-6 pb-[max(28px,env(safe-area-inset-bottom))] text-ink md:px-12 md:pt-10 md:pb-8 lg:px-16 lg:pt-12 lg:pb-10">
1210 <header className="flex h-10 shrink-0 items-center justify-between">
13 <a
14 href={navigationHref(routes.home)}
15 className="inline-flex min-h-11 items-center gap-2.75"
16 aria-label={t('shell.home')}
17 onClick={(event) => {
18 if (event.button || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return
19 event.preventDefault()
20 navigate(routes.home)
21 }}
22 >
11 {/* MPA:品牌入口整页回到落地页,不交给认证应用的客户端路由。 */}
12 <a href="/" className="inline-flex min-h-11 items-center gap-2.75" aria-label={t('shell.home')}>
2313 <img src="/media/gitdataai-red-panda.png" width={40} height={40} alt="" />
2414 <span className="font-latin text-[19px] font-semibold tracking-[-0.4px]">GitDataAI</span>
2515 </a>
Modified view/auth/index.html view/auth/index.html +1 −1 View view/auth/index.html
@@ -12,6 +12,6 @@
1212 </head>
1313 <body>
1414 <div id="app"></div>
15 <script type="module" src="/src/app/main.tsx"></script>
15 <script type="module" src="/auth/src/app/main.tsx"></script>
1616 </body>
1717</html>
Added view/index/src/styles/index.css view/index/src/styles/index.css +4 −0 View view/index/src/styles/index.css
@@ -0,0 +1,4 @@
1@import "../../../shared/styles/fonts.css";
2@import "tailwindcss";
3@import "../../../shared/styles/theme.css";
4@source "../../";
Added view/index/src/app.tsx view/index/src/app.tsx +147 −0 View view/index/src/app.tsx
@@ -0,0 +1,147 @@
1import { Moon, Sun } from 'lucide-react'
2import { useEffect, useState } from 'react'
3import { Navigate, Route, Routes } from 'react-router-dom'
4
5const features = [
6 {
7 title: '统一身份',
8 description: '一个账户通行所有接入的应用与服务,不必为每个站点重复注册、反复记密码。',
9 },
10 {
11 title: '安全登录',
12 description: '支持动态口令二次验证与恢复码,登录与授权行为全程留痕,异常访问及时阻断。',
13 },
14 {
15 title: '会话可控',
16 description: '设备会话与访问密钥集中管理,随时撤销可疑登录,账号处置不需要逐个应用排查。',
17 },
18]
19
20function useTheme() {
21 const [theme, setTheme] = useState(() => {
22 try {
23 return localStorage.getItem('auth-theme') || 'system'
24 } catch {
25 return 'system'
26 }
27 })
28 const [systemDark, setSystemDark] = useState(() => matchMedia('(prefers-color-scheme: dark)').matches)
29 const dark = theme === 'dark' || (theme === 'system' && systemDark)
30 useEffect(() => {
31 const media = matchMedia('(prefers-color-scheme: dark)')
32 const update = () => setSystemDark(media.matches)
33 media.addEventListener('change', update)
34 return () => media.removeEventListener('change', update)
35 }, [])
36 useEffect(() => {
37 document.documentElement.dataset.theme = dark ? 'dark' : 'light'
38 }, [dark])
39 function toggle() {
40 const next = dark ? 'light' : 'dark'
41 setTheme(next)
42 try {
43 localStorage.setItem('auth-theme', next)
44 } catch {}
45 }
46 return { dark, toggle }
47}
48
49function LandingPage() {
50 return (
51 <>
52 <section className="flex flex-col items-start gap-6 py-16 md:py-24">
53 <p className="rounded-full bg-accent-soft px-3 py-1 text-xs font-medium text-accent-text">统一身份认证</p>
54 <h1 className="max-w-3xl text-4xl leading-[1.2] font-semibold tracking-[-0.8px] text-balance md:text-5xl">
55 一个账户,通达你的所有应用
56 </h1>
57 <p className="max-w-2xl text-base leading-[1.7] text-muted md:text-lg">
58 GitDataAI 为代码、协作与数据服务提供统一的登录入口、二次验证与授权管理,让身份这件事只做一次。
59 </p>
60 <div className="mt-2 flex flex-wrap items-center gap-3">
61 {/* 跨应用整页跳转:/auth 是独立的 MPA 文档,不能走客户端路由。 */}
62 <a
63 href="/auth/login"
64 className="inline-flex min-h-12 items-center justify-center rounded-lg bg-primary px-6 text-base font-semibold text-white transition-colors hover:bg-primary-hover active:bg-primary-pressed"
65 >
66 登录账户
67 </a>
68 <a
69 href="/auth/register"
70 className="inline-flex min-h-12 items-center justify-center rounded-lg border border-control-border bg-field px-6 text-base font-semibold text-ink transition-colors hover:bg-elevated"
71 >
72 创建账户
73 </a>
74 </div>
75 </section>
76
77 <section className="grid gap-4 pb-20 md:grid-cols-3">
78 {features.map((feature) => (
79 <article key={feature.title} className="rounded-xl border border-line bg-field p-6">
80 <h2 className="mb-2 text-base font-semibold">{feature.title}</h2>
81 <p className="text-sm leading-[1.7] text-muted">{feature.description}</p>
82 </article>
83 ))}
84 </section>
85 </>
86 )
87}
88
89export function LandingApp() {
90 const { dark, toggle } = useTheme()
91 const terms = import.meta.env.VITE_TERMS_URL
92 const privacy = import.meta.env.VITE_PRIVACY_URL
93
94 return (
95 <div className="flex min-h-dvh flex-col bg-page text-ink">
96 <header className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-5 md:px-10">
97 <a href="/" className="inline-flex items-center gap-2.75" aria-label="GitDataAI 首页">
98 <img src="/media/gitdataai-red-panda.png" width={40} height={40} alt="" />
99 <span className="font-latin text-[19px] font-semibold tracking-[-0.4px]">GitDataAI</span>
100 </a>
101 <div className="flex items-center gap-2">
102 <button
103 type="button"
104 onClick={toggle}
105 aria-label={dark ? '切换到浅色主题' : '切换到深色主题'}
106 className="flex size-11 items-center justify-center rounded-lg text-icon hover:bg-elevated hover:text-ink"
107 >
108 {dark ? <Sun size={18} strokeWidth={1.6} /> : <Moon size={18} strokeWidth={1.6} />}
109 </button>
110 <a
111 href="/auth/login"
112 className="inline-flex min-h-10 items-center rounded-lg border border-control-border bg-field px-4 text-sm font-medium text-ink transition-colors hover:bg-elevated"
113 >
114 登录
115 </a>
116 </div>
117 </header>
118
119 <main className="mx-auto w-full max-w-6xl flex-1 px-5 md:px-10">
120 <Routes>
121 <Route path="/" element={<LandingPage />} />
122 <Route path="*" element={<Navigate to="/" replace />} />
123 </Routes>
124 </main>
125
126 <footer className="border-t border-line">
127 <div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-3 px-5 py-6 text-xs text-muted md:px-10">
128 <span className="font-latin">© 2026 GitDataAI</span>
129 {(terms || privacy) && (
130 <nav className="flex items-center gap-4">
131 {terms && (
132 <a href={terms} target="_blank" rel="noopener noreferrer" className="hover:underline">
133 服务条款
134 </a>
135 )}
136 {privacy && (
137 <a href={privacy} target="_blank" rel="noopener noreferrer" className="hover:underline">
138 隐私政策
139 </a>
140 )}
141 </nav>
142 )}
143 </div>
144 </footer>
145 </div>
146 )
147}
Added view/index/src/main.tsx view/index/src/main.tsx +12 −0 View view/index/src/main.tsx
@@ -0,0 +1,12 @@
1import { createRoot } from 'react-dom/client'
2import { BrowserRouter } from 'react-router-dom'
3import './styles/index.css'
4import { LandingApp } from './app'
5
6const root = document.getElementById('app')
7if (root)
8 createRoot(root).render(
9 <BrowserRouter>
10 <LandingApp />
11 </BrowserRouter>,
12 )
Added view/index/index.html view/index/index.html +30 −0 View view/index/index.html
@@ -0,0 +1,30 @@
1<!doctype html>
2<html lang="zh-CN">
3 <head>
4 <meta charset="UTF-8" />
5 <link rel="icon" type="image/png" href="/media/gitdataai-red-panda.png" />
6 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
7 <meta name="referrer" content="no-referrer" />
8 <meta name="color-scheme" content="light dark" />
9 <meta
10 name="description"
11 content="GitDataAI 统一身份认证:一个账户,通达代码、协作与数据服务。"
12 />
13 <title>GitDataAI</title>
14 <script>
15 try {
16 const t = localStorage.getItem('auth-theme')
17 document.documentElement.dataset.theme =
18 t === 'dark' || (t !== 'light' && matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'
19 } catch {
20 document.documentElement.dataset.theme = matchMedia('(prefers-color-scheme: dark)').matches
21 ? 'dark'
22 : 'light'
23 }
24 </script>
25 </head>
26 <body>
27 <div id="app"></div>
28 <script type="module" src="/index/src/main.tsx"></script>
29 </body>
30</html>
Added view/shared/styles/fonts.css view/shared/styles/fonts.css +2 −0 View view/shared/styles/fonts.css
@@ -0,0 +1,2 @@
1@import "@fontsource-variable/noto-sans-sc";
2@import "@fontsource-variable/geist";
Added view/shared/styles/theme.css view/shared/styles/theme.css +135 −0 View view/shared/styles/theme.css
@@ -0,0 +1,135 @@
1@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
2
3:root {
4 color-scheme: light;
5 --auth-ink: #172136;
6 --auth-muted: #5f6c7f;
7 --auth-line: #dfe5ef;
8 --auth-accent: #f04d00;
9 --auth-accent-text: #b83d00;
10 --auth-accent-soft: #fff0e6;
11 --auth-bg: #ffffff;
12 --auth-field: #ffffff;
13 --auth-border: #8591a2;
14 --auth-divider: #dfe4eb;
15 --auth-icon: #8a94a6;
16 --auth-danger: #c93636;
17 --auth-success: #238450;
18 --auth-success-bg: #edf9f2;
19 --auth-elevated: #f4f6f8;
20 --auth-focus: #b83d00;
21 --auth-error-bg: #fff2ef;
22 --auth-disabled: #edf0f4;
23 --auth-disabled-ink: #606a79;
24 font-family: "Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif;
25 font-synthesis: none;
26 text-rendering: optimizeLegibility;
27 -webkit-font-smoothing: antialiased;
28 -moz-osx-font-smoothing: grayscale;
29 background: var(--auth-bg);
30 color: var(--auth-ink);
31}
32
33:root[data-theme="dark"] {
34 color-scheme: dark;
35 --auth-ink: #f3f3f5;
36 --auth-muted: #bebec5;
37 --auth-line: #46464c;
38 --auth-accent-text: #ff9a62;
39 --auth-accent-soft: #463329;
40 --auth-bg: #242426;
41 --auth-field: #2e2e31;
42 --auth-border: #85858f;
43 --auth-divider: #48484e;
44 --auth-icon: #b6b6bf;
45 --auth-danger: #ff968c;
46 --auth-success: #74d9a0;
47 --auth-success-bg: #273c30;
48 --auth-elevated: #38383c;
49 --auth-focus: #ff9a62;
50 --auth-error-bg: #48302e;
51 --auth-disabled: #38383c;
52 --auth-disabled-ink: #acacb5;
53}
54
55@theme inline {
56 --color-ink: var(--auth-ink);
57 --color-muted: var(--auth-muted);
58 --color-line: var(--auth-line);
59 --color-accent: var(--auth-accent);
60 --color-accent-text: var(--auth-accent-text);
61 --color-accent-soft: var(--auth-accent-soft);
62 --color-page: var(--auth-bg);
63 --color-field: var(--auth-field);
64 --color-control-border: var(--auth-border);
65 --color-divider: var(--auth-divider);
66 --color-icon: var(--auth-icon);
67 --color-danger: var(--auth-danger);
68 --color-success: var(--auth-success);
69 --color-success-bg: var(--auth-success-bg);
70 --color-elevated: var(--auth-elevated);
71 --color-focus: var(--auth-focus);
72 --color-error-bg: var(--auth-error-bg);
73 --color-disabled: var(--auth-disabled);
74 --color-disabled-ink: var(--auth-disabled-ink);
75 --color-primary: #d44200;
76 --color-primary-hover: #be3900;
77 --color-primary-pressed: #a83300;
78 --font-latin: "Geist Variable", sans-serif;
79}
80
81html,
82body,
83#app {
84 min-height: 100%;
85}
86body {
87 margin: 0;
88 min-width: 280px;
89}
90button,
91input,
92select {
93 font: inherit;
94}
95button,
96a,
97select,
98input[type="checkbox"] {
99 -webkit-tap-highlight-color: transparent;
100}
101button:not(:disabled),
102select:not(:disabled) {
103 cursor: pointer;
104}
105a,
106button,
107input,
108select {
109 touch-action: manipulation;
110}
111@layer base {
112 :where(button, a, input, select):focus-visible {
113 outline: 2px solid var(--auth-focus);
114 outline-offset: 3px;
115 }
116}
117input:disabled {
118 -webkit-text-fill-color: var(--auth-disabled-ink);
119 opacity: 1;
120}
121fieldset:disabled {
122 color: var(--auth-disabled-ink);
123}
124input:-webkit-autofill {
125 -webkit-box-shadow: 0 0 0 100px var(--auth-field) inset;
126 -webkit-text-fill-color: var(--auth-ink);
127 caret-color: var(--auth-ink);
128}
129::selection {
130 background: var(--auth-accent-soft);
131 color: var(--auth-ink);
132}
133dialog[open] {
134 display: block;
135}
Modified tsconfig.app.json tsconfig.app.json +1 −1 View tsconfig.app.json
@@ -28,5 +28,5 @@
2828 "erasableSyntaxOnly": true,
2929 "noFallthroughCasesInSwitch": true
3030 },
31 "include": ["view/auth/src"]
31 "include": ["view/auth/src", "view/index/src", "view/app/src"]
3232}
Modified vite.config.ts vite.config.ts +74 −5 View vite.config.ts
@@ -1,15 +1,84 @@
1import { existsSync, renameSync, rmSync } from 'node:fs'
2import { join } from 'node:path'
13import { fileURLToPath } from 'node:url'
24import tailwindcss from '@tailwindcss/vite'
35import react from '@vitejs/plugin-react'
4import { defineConfig, loadEnv } from 'vite'
6import { type Connect, defineConfig, loadEnv, type Plugin } from 'vite'
7
8/// 应用前缀 → 入口 HTML,与 lib/service/webapi/src/assets.rs 的 APP_ENTRIES 保持一致。
9const APP_ENTRIES: Record<string, string> = {
10 auth: '/auth/index.html',
11 app: '/app/index.html',
12}
513
14/// dev / preview 下按后端 assets::serve 的规则把子应用路由回落到各自入口。
15function mpaFallback(): Plugin {
16 const rewrite = (request: { url?: string }, landing?: string) => {
17 const path = (request.url || '').split('?')[0]
18 if (!path || path === '/') {
19 if (landing) request.url = landing
20 return
21 }
22 if (path.includes('.')) return
23 const first = path.split('/')[1] || ''
24 const entry = APP_ENTRIES[first]
25 if (entry) request.url = entry
26 }
27 const middleware =
28 (landing?: string): Connect.NextHandleFunction =>
29 (request, _response, next) => {
30 rewrite(request, landing)
31 next()
32 }
33 return {
34 name: 'mpa-fallback',
35 configureServer(server) {
36 // dev 直接读源码目录,落地页入口在 view/index/index.html。
37 server.middlewares.use(middleware('/index/index.html'))
38 },
39 configurePreviewServer(server) {
40 // preview 读 dist,落地页产物已归位到 index.html,无需改写。
41 server.middlewares.use(middleware())
42 },
43 }
44}
45
46/// Vite 按 HTML 相对 root 的路径生成产物;落地页入口在 view/index/ 下,这里归位到 dist/index.html。
47function landingOutput(outDir: string): Plugin {
48 return {
49 name: 'landing-output',
50 apply: 'build',
51 closeBundle() {
52 const source = join(outDir, 'index', 'index.html')
53 if (!existsSync(source)) return
54 renameSync(source, join(outDir, 'index.html'))
55 rmSync(join(outDir, 'index'), { recursive: true, force: true })
56 },
57 }
58}
59
660export default defineConfig(({ mode }) => {
761 const env = loadEnv(mode, process.cwd(), '')
862 return {
9 root: 'view/auth',
10 publicDir: 'public',
11 build: { outDir: '../dist', emptyOutDir: true },
12 plugins: [react(), tailwindcss()],
63 root: 'view',
64 publicDir: 'auth/public',
65 build: {
66 outDir: 'dist',
67 emptyOutDir: true,
68 rollupOptions: {
69 input: {
70 landing: fileURLToPath(new URL('./view/index/index.html', import.meta.url)),
71 auth: fileURLToPath(new URL('./view/auth/index.html', import.meta.url)),
72 app: fileURLToPath(new URL('./view/app/index.html', import.meta.url)),
73 },
74 },
75 },
76 plugins: [
77 react(),
78 tailwindcss(),
79 mpaFallback(),
80 landingOutput(fileURLToPath(new URL('./view/dist', import.meta.url))),
81 ],
1382 resolve: {
1483 alias: {
1584 '@kenapi/apiclient': fileURLToPath(new URL('./packages/apiclient', import.meta.url)),