feat: Refactor frontend layout with new sidebar and i18n, update backend LLM adapters, and adjust database models.

This commit is contained in:
lintsinghua 2025-11-27 18:01:57 +08:00
parent 6ce5b3c6c1
commit 7d1925db66
48 changed files with 436 additions and 673 deletions

View File

@ -13,3 +13,4 @@ COPY . .
# Command is overridden by docker-compose for dev # Command is overridden by docker-compose for dev
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -101,3 +101,4 @@ formatter = generic
format = %(levelname)-5.5s [%(name)s] %(message)s format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S datefmt = %H:%M:%S

View File

@ -88,3 +88,4 @@ if context.is_offline_mode():
else: else:
asyncio.run(run_migrations_online()) asyncio.run(run_migrations_online())

View File

@ -23,3 +23,4 @@ def upgrade() -> None:
def downgrade() -> None: def downgrade() -> None:
${downgrades if downgrades else "pass"} ${downgrades if downgrades else "pass"}

View File

@ -208,3 +208,4 @@ async def remove_project_member(
return {"message": "成员已移除"} return {"message": "成员已移除"}

View File

@ -64,3 +64,4 @@ async def read_user_me(
""" """
return current_user return current_user

View File

@ -27,3 +27,4 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
def get_password_hash(password: str) -> str: def get_password_hash(password: str) -> str:
return pwd_context.hash(password) return pwd_context.hash(password)

View File

@ -10,3 +10,4 @@ class Base:
def __tablename__(cls) -> str: def __tablename__(cls) -> str:
return cls.__name__.lower() + "s" return cls.__name__.lower() + "s"

View File

@ -15,3 +15,4 @@ async def get_db():
finally: finally:
await session.close() await session.close()

View File

@ -3,3 +3,4 @@ from .project import Project, ProjectMember
from .audit import AuditTask, AuditIssue from .audit import AuditTask, AuditIssue
from .analysis import InstantAnalysis from .analysis import InstantAnalysis

View File

@ -22,3 +22,4 @@ class InstantAnalysis(Base):
# Relationships # Relationships
user = relationship("User", backref="instant_analyses") user = relationship("User", backref="instant_analyses")

View File

@ -42,3 +42,4 @@ class ProjectMember(Base):
project = relationship("Project", back_populates="members") project = relationship("Project", back_populates="members")
user = relationship("User", backref="project_memberships") user = relationship("User", backref="project_memberships")

View File

@ -23,3 +23,4 @@ class User(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now()) created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())

View File

@ -28,3 +28,4 @@ class UserConfig(Base):
# Relationships # Relationships
user = relationship("User", backref="config") user = relationship("User", backref="config")

View File

@ -8,3 +8,4 @@ class Token(BaseModel):
class TokenPayload(BaseModel): class TokenPayload(BaseModel):
sub: Optional[str] = None sub: Optional[str] = None

View File

@ -33,3 +33,4 @@ class UserInDBBase(UserBase):
class User(UserInDBBase): class User(UserInDBBase):
pass pass

View File

@ -135,3 +135,4 @@ class BaiduAdapter(BaseLLMAdapter):
def get_model(self) -> str: def get_model(self) -> str:
return self.config.model or "ERNIE-3.5-8K" return self.config.model or "ERNIE-3.5-8K"

View File

@ -91,3 +91,4 @@ class ClaudeAdapter(BaseLLMAdapter):
raise Exception(f"无效的Claude模型: {self.config.model}") raise Exception(f"无效的Claude模型: {self.config.model}")
return True return True

View File

@ -79,3 +79,4 @@ class DeepSeekAdapter(BaseLLMAdapter):
raise Exception("未指定DeepSeek模型") raise Exception("未指定DeepSeek模型")
return True return True

View File

@ -85,3 +85,4 @@ class DoubaoAdapter(BaseLLMAdapter):
def get_model(self) -> str: def get_model(self) -> str:
return self.config.model or "doubao-pro-32k" return self.config.model or "doubao-pro-32k"

View File

@ -82,3 +82,4 @@ class MinimaxAdapter(BaseLLMAdapter):
def get_model(self) -> str: def get_model(self) -> str:
return self.config.model or "abab6.5-chat" return self.config.model or "abab6.5-chat"

View File

@ -77,3 +77,4 @@ class MoonshotAdapter(BaseLLMAdapter):
raise Exception("未指定Moonshot模型") raise Exception("未指定Moonshot模型")
return True return True

View File

@ -80,3 +80,4 @@ class OllamaAdapter(BaseLLMAdapter):
raise Exception("未指定Ollama模型") raise Exception("未指定Ollama模型")
return True return True

View File

@ -90,3 +90,4 @@ class OpenAIAdapter(BaseLLMAdapter):
raise Exception("未指定OpenAI模型") raise Exception("未指定OpenAI模型")
return True return True

View File

@ -77,3 +77,4 @@ class QwenAdapter(BaseLLMAdapter):
raise Exception("未指定通义千问模型") raise Exception("未指定通义千问模型")
return True return True

View File

@ -77,3 +77,4 @@ class ZhipuAdapter(BaseLLMAdapter):
raise Exception("未指定智谱AI模型") raise Exception("未指定智谱AI模型")
return True return True

View File

@ -132,3 +132,4 @@ class BaseLLMAdapter(ABC):
await self._client.aclose() await self._client.aclose()
self._client = None self._client = None

View File

@ -118,3 +118,4 @@ DEFAULT_BASE_URLS: Dict[LLMProvider, str] = {
LLMProvider.CLAUDE: "https://api.anthropic.com/v1", LLMProvider.CLAUDE: "https://api.anthropic.com/v1",
} }

View File

@ -10,3 +10,4 @@ python-jose[cryptography]
python-multipart python-multipart
httpx httpx

View File

@ -16,3 +16,4 @@ EXPOSE 5173
CMD ["npm", "run", "dev", "--", "--host"] CMD ["npm", "run", "dev", "--", "--host"]

View File

@ -1,13 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="UTF-8" /> <head>
<link rel="icon" type="image/png" href="/logo_xcodereviewer.png" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="icon" type="image/png" href="/logo_xcodereviewer.png" />
<title>XCodeReviewer</title> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head> <title>XCodeReviewer</title>
<body class="dark:bg-gray-900"> </head>
<div id="root"></div>
<script type="module" src="/src/app/main.tsx"></script> <body class="dark:bg-gray-900">
</body> <div id="root"></div>
</html> <script type="module" src="/src/app/main.tsx"></script>
</body>
</html>

View File

@ -45,6 +45,8 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"eventsource-parser": "^3.0.6", "eventsource-parser": "^3.0.6",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"ky": "^1.9.1", "ky": "^1.9.1",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
@ -57,6 +59,7 @@
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-hook-form": "^7.56.1", "react-hook-form": "^7.56.1",
"react-i18next": "^16.3.5",
"react-resizable-panels": "^2.1.8", "react-resizable-panels": "^2.1.8",
"react-router": "^7.1.5", "react-router": "^7.1.5",
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.0",
@ -6391,6 +6394,15 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html-url-attributes": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmmirror.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@ -6411,6 +6423,46 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/i18next": {
"version": "25.6.3",
"resolved": "https://registry.npmmirror.com/i18next/-/i18next-25.6.3.tgz",
"integrity": "sha512-AEQvoPDljhp67a1+NsnG/Wb1Nh6YoSvtrmeEd24sfGn3uujCtXCF3cXpr7ulhMywKNFF7p3TX1u2j7y+caLOJg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.28.4"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.0",
"resolved": "https://registry.npmmirror.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.0.tgz",
"integrity": "sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.6.3.tgz",
@ -8602,6 +8654,33 @@
"react": "^16.8.0 || ^17 || ^18 || ^19" "react": "^16.8.0 || ^17 || ^18 || ^19"
} }
}, },
"node_modules/react-i18next": {
"version": "16.3.5",
"resolved": "https://registry.npmmirror.com/react-i18next/-/react-i18next-16.3.5.tgz",
"integrity": "sha512-F7Kglc+T0aE6W2rO5eCAFBEuWRpNb5IFmXOYEgztjZEuiuSLTe/xBIEG6Q3S0fbl8GXMNo+Q7gF8bpokFNWJww==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 25.6.2",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-18.3.1.tgz",
@ -9684,7 +9763,7 @@
"version": "5.7.3", "version": "5.7.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.7.3.tgz", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -10091,6 +10170,15 @@
"vite": ">=2.6.0" "vite": ">=2.6.0"
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/vscode-jsonrpc": { "node_modules/vscode-jsonrpc": {
"version": "8.2.0", "version": "8.2.0",
"resolved": "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", "resolved": "https://registry.npmmirror.com/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",

View File

@ -52,6 +52,8 @@
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"eventsource-parser": "^3.0.6", "eventsource-parser": "^3.0.6",
"fflate": "^0.8.2", "fflate": "^0.8.2",
"i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"ky": "^1.9.1", "ky": "^1.9.1",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
@ -64,6 +66,7 @@
"react-dom": "^18.0.0", "react-dom": "^18.0.0",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-hook-form": "^7.56.1", "react-hook-form": "^7.56.1",
"react-i18next": "^16.3.5",
"react-resizable-panels": "^2.1.8", "react-resizable-panels": "^2.1.8",
"react-router": "^7.1.5", "react-router": "^7.1.5",
"react-router-dom": "^6.30.0", "react-router-dom": "^6.30.0",

View File

@ -1,29 +0,0 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { Toaster } from "sonner";
import Header from "@/components/layout/Header";
import routes, { type RouteConfig } from "./app/routes";
function App() {
return (
<BrowserRouter>
<Toaster position="top-right" />
<div className="min-h-screen gradient-bg">
<Header />
<main className="container-responsive py-4 md:py-6">
<Routes>
{routes.map((route: RouteConfig) => (
<Route
key={route.path}
path={route.path}
element={route.element}
/>
))}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
</BrowserRouter>
);
}
export default App;

View File

@ -1,6 +1,7 @@
import { BrowserRouter, Routes, Route, Navigate, Outlet } from "react-router-dom"; import { useState } from "react";
import { BrowserRouter, Routes, Route, Outlet } from "react-router-dom";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import Header from "@/components/layout/Header"; import Sidebar from "@/components/layout/Sidebar";
import routes from "./routes"; import routes from "./routes";
import { AuthProvider } from "@/shared/context/AuthContext"; import { AuthProvider } from "@/shared/context/AuthContext";
import { ProtectedRoute } from "./ProtectedRoute"; import { ProtectedRoute } from "./ProtectedRoute";
@ -9,12 +10,19 @@ import Register from "@/pages/Register";
import NotFound from "@/pages/NotFound"; import NotFound from "@/pages/NotFound";
function AppLayout() { function AppLayout() {
const [collapsed, setCollapsed] = useState(false);
return ( return (
<div className="min-h-screen gradient-bg"> <div className="min-h-screen gradient-bg">
<Header /> <Sidebar collapsed={collapsed} setCollapsed={setCollapsed} />
<main className="container-responsive py-4 md:py-6"> <main
<Outlet /> className={`transition-all duration-300 min-h-screen ${collapsed ? "md:ml-20" : "md:ml-64"
</main> }`}
>
<div className="container mx-auto px-4 py-6 md:py-8 pt-16 md:pt-8">
<Outlet />
</div>
</main>
</div> </div>
); );
} }
@ -28,17 +36,17 @@ function App() {
{/* Public Routes */} {/* Public Routes */}
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
{/* Protected Routes */} {/* Protected Routes */}
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
{routes.map((route) => ( {routes.map((route) => (
<Route <Route
key={route.path} key={route.path}
path={route.path} path={route.path}
element={route.element} element={route.element}
/> />
))} ))}
</Route> </Route>
</Route> </Route>

View File

@ -16,3 +16,4 @@ export const ProtectedRoute = () => {
return <Outlet />; return <Outlet />;
}; };

View File

@ -1,368 +0,0 @@
/* stylelint-disable */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
All colors MUST be HSL.
专业深红色配色方案
- 主色调深红色 (0 75% 25%) - 体现专业性和权威感
- 强调色暗红色 (0 60% 35%) - 用于交互元素和重点突出
- 背景色浅灰到黑色渐变 - 提供高对比度和现代感
- 状态色保持语义化的绿色(成功)红色(错误)黄色(警告)
*/
@layer base {
:root {
--background: 0 0% 98%;
--foreground: 0 0% 5%;
--card: 0 0% 100%;
--card-foreground: 0 0% 5%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 5%;
--primary: 0 75% 25%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 95%;
--secondary-foreground: 0 75% 25%;
--muted: 0 0% 94%;
--muted-foreground: 0 0% 40%;
--accent: 0 60% 35%;
--accent-foreground: 0 0% 98%;
--destructive: 0 84% 60%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 90%;
--input: 0 0% 90%;
--ring: 0 75% 25%;
--radius: 0.5rem;
/* 专业渐变效果 */
--gradient-primary: linear-gradient(135deg, hsl(0 75% 25%) 0%, hsl(0 60% 35%) 100%);
--gradient-card: linear-gradient(145deg, hsl(0 0% 100%) 0%, hsl(0 0% 98%) 100%);
--gradient-background: linear-gradient(180deg, hsl(0 0% 98%) 0%, hsl(0 0% 95%) 100%);
/* 专业阴影效果 */
--shadow-card: 0 1px 3px 0 hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1);
--shadow-hover: 0 4px 6px -1px hsl(0 0% 0% / 0.1), 0 2px 4px -2px hsl(0 0% 0% / 0.1);
--shadow-focus: 0 0 0 3px hsl(0 75% 25% / 0.1);
--sidebar-background: 0 0% 8%;
--sidebar-foreground: 0 0% 85%;
--sidebar-primary: 0 75% 35%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 0 0% 12%;
--sidebar-accent-foreground: 0 0% 85%;
--sidebar-border: 0 0% 15%;
--sidebar-ring: 0 75% 45%;
}
.dark {
--background: 0 0% 3%;
--foreground: 0 0% 95%;
--card: 0 0% 5%;
--card-foreground: 0 0% 95%;
--popover: 0 0% 5%;
--popover-foreground: 0 0% 95%;
--primary: 0 75% 45%;
--primary-foreground: 0 0% 5%;
--secondary: 0 0% 8%;
--secondary-foreground: 0 0% 95%;
--muted: 0 0% 8%;
--muted-foreground: 0 0% 60%;
--accent: 0 60% 50%;
--accent-foreground: 0 0% 5%;
--destructive: 0 75% 55%;
--destructive-foreground: 0 0% 95%;
--border: 0 0% 12%;
--input: 0 0% 12%;
--ring: 0 75% 45%;
/* 暗色模式专业渐变效果 */
--gradient-primary: linear-gradient(135deg, hsl(0 75% 45%) 0%, hsl(0 60% 50%) 100%);
--gradient-card: linear-gradient(145deg, hsl(0 0% 5%) 0%, hsl(0 0% 3%) 100%);
--gradient-background: linear-gradient(180deg, hsl(0 0% 3%) 0%, hsl(0 0% 1%) 100%);
/* 暗色模式专业阴影效果 */
--shadow-card: 0 1px 3px 0 hsl(0 0% 0% / 0.3), 0 1px 2px -1px hsl(0 0% 0% / 0.3);
--shadow-hover: 0 4px 6px -1px hsl(0 0% 0% / 0.4), 0 2px 4px -2px hsl(0 0% 0% / 0.4);
--shadow-focus: 0 0 0 3px hsl(0 75% 45% / 0.2);
--sidebar-background: 0 0% 2%;
--sidebar-foreground: 0 0% 85%;
--sidebar-primary: 0 75% 50%;
--sidebar-primary-foreground: 0 0% 5%;
--sidebar-accent: 0 0% 6%;
--sidebar-accent-foreground: 0 0% 85%;
--sidebar-border: 0 0% 8%;
--sidebar-ring: 0 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
@layer components {
/* 页面标题样式 */
.page-title {
@apply text-2xl md:text-3xl font-bold text-foreground tracking-tight;
}
.page-subtitle {
@apply text-sm md:text-base text-muted-foreground mt-1;
}
/* 现代化卡片样式 */
.card-modern {
@apply bg-card rounded-xl border border-border shadow-sm hover:shadow-md transition-all duration-200;
}
/* 统计卡片样式 */
.stat-card {
@apply bg-card rounded-xl border border-border shadow-sm hover:shadow-lg transition-all duration-300 hover:border-accent/30;
}
.stat-label {
@apply text-xs font-medium text-muted-foreground uppercase tracking-wide;
}
.stat-value {
@apply text-2xl md:text-3xl font-bold text-foreground mt-1;
}
.stat-icon {
@apply w-12 h-12 rounded-lg bg-gradient-to-br from-primary/10 to-accent/10 flex items-center justify-center shadow-sm;
}
/* 按钮样式 */
.btn-primary {
@apply bg-gradient-to-r from-primary to-accent hover:from-primary/90 hover:to-accent/90 text-primary-foreground shadow-sm hover:shadow-md transition-all duration-200;
}
.btn-secondary {
@apply border-border hover:border-accent/50 hover:bg-accent/5 transition-all duration-200;
}
/* 空状态样式 */
.empty-state {
@apply flex flex-col items-center justify-center text-center;
}
.empty-icon {
@apply w-16 h-16 rounded-full bg-muted flex items-center justify-center mb-4;
}
/* 动画 */
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 渐变背景 */
.gradient-bg {
@apply bg-gradient-to-br from-background via-muted/30 to-accent/5;
}
/* 响应式容器 */
.container-responsive {
@apply container mx-auto px-4 sm:px-6 lg:px-8;
}
/* 文本截断 */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 毛玻璃效果 */
.backdrop-blur-md {
backdrop-filter: blur(12px);
}
/* 平滑滚动 */
html {
scroll-behavior: smooth;
}
/* 自定义滚动条 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(var(--accent) / 0.6);
}
/* 专业高级感样式 */
.professional-card {
@apply bg-gradient-card border border-border/50 shadow-card hover:shadow-hover transition-all duration-300;
}
.professional-button {
@apply bg-gradient-primary text-primary-foreground font-semibold px-6 py-3 rounded-lg shadow-sm hover:shadow-md transition-all duration-200 focus:ring-2 focus:ring-primary/20 focus:outline-none;
}
.professional-input {
@apply bg-background border border-border rounded-lg px-4 py-2 focus:border-primary focus:ring-2 focus:ring-primary/10 transition-all duration-200;
}
.status-indicator {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.status-success {
@apply bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400;
}
.status-warning {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400;
}
.status-error {
@apply bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400;
}
.status-info {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400;
}
/* 高级导航样式 */
.nav-link {
@apply flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-all duration-200 hover:bg-accent/10 hover:text-accent-foreground;
}
.nav-link.active {
@apply bg-primary/10 text-primary border-r-2 border-primary;
}
/* 专业表格样式 */
.professional-table {
@apply w-full border-collapse bg-card rounded-lg overflow-hidden shadow-card;
}
.professional-table th {
@apply bg-muted/50 px-6 py-3 text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider border-b border-border;
}
.professional-table td {
@apply px-6 py-4 whitespace-nowrap text-sm text-foreground border-b border-border/50;
}
.professional-table tr:hover {
@apply bg-accent/5;
}
/* 图表和图标专用样式 */
.chart-primary {
color: hsl(var(--primary));
}
.chart-accent {
color: hsl(var(--accent));
}
.chart-success {
color: #10b981;
}
.chart-warning {
color: #f59e0b;
}
.chart-error {
color: #ef4444;
}
.icon-primary {
@apply text-primary;
}
.icon-accent {
@apply text-accent;
}
.icon-muted {
@apply text-muted-foreground;
}
/* 加载动画 */
.loading-spinner {
@apply animate-spin rounded-full border-2 border-primary border-t-transparent;
}
/* 状态指示器颜色 */
.status-running {
@apply bg-red-50 text-red-800 border-red-200;
}
.status-completed {
@apply bg-green-50 text-green-800 border-green-200;
}
.status-failed {
@apply bg-red-100 text-red-900 border-red-300;
}
/* 渐变图标背景 */
.icon-bg-primary {
@apply bg-gradient-to-br from-primary/10 to-accent/10;
}
.icon-bg-success {
@apply bg-gradient-to-br from-green-100 to-emerald-100;
}
.icon-bg-warning {
@apply bg-gradient-to-br from-yellow-100 to-orange-100;
}
.icon-bg-error {
@apply bg-gradient-to-br from-red-100 to-red-200;
}
}

View File

@ -8,11 +8,11 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
GitBranch, GitBranch,
Settings, Settings,
FileText, FileText,
AlertCircle, AlertCircle,
Info, Info,
Zap, Zap,
Shield, Shield,
@ -43,7 +43,7 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
const [zipFile, setZipFile] = useState<File | null>(null); const [zipFile, setZipFile] = useState<File | null>(null);
const [loadingZipFile, setLoadingZipFile] = useState(false); const [loadingZipFile, setLoadingZipFile] = useState(false);
const [hasLoadedZip, setHasLoadedZip] = useState(false); const [hasLoadedZip, setHasLoadedZip] = useState(false);
const [taskForm, setTaskForm] = useState<CreateAuditTaskForm>({ const [taskForm, setTaskForm] = useState<CreateAuditTaskForm>({
project_id: "", project_id: "",
task_type: "repository", task_type: "repository",
@ -77,7 +77,7 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
// 后端 MAX_FILE_SIZE_BYTES 是 200 * 1024 = 204800 bytes = 200KB // 后端 MAX_FILE_SIZE_BYTES 是 200 * 1024 = 204800 bytes = 200KB
// 转换为KB用于前端显示 // 转换为KB用于前端显示
const maxFileSizeKB = 200; // 后端默认值 200KB const maxFileSizeKB = 200; // 后端默认值 200KB
setTaskForm(prev => ({ setTaskForm(prev => ({
...prev, ...prev,
scan_config: { scan_config: {
@ -111,14 +111,14 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
useEffect(() => { useEffect(() => {
const autoLoadZipFile = async () => { const autoLoadZipFile = async () => {
if (!taskForm.project_id || hasLoadedZip) return; if (!taskForm.project_id || hasLoadedZip) return;
const project = projects.find(p => p.id === taskForm.project_id); const project = projects.find(p => p.id === taskForm.project_id);
if (!project || project.repository_type !== 'other') return; if (!project || project.repository_type !== 'other') return;
try { try {
setLoadingZipFile(true); setLoadingZipFile(true);
const savedFile = await loadZipFile(taskForm.project_id); const savedFile = await loadZipFile(taskForm.project_id);
if (savedFile) { if (savedFile) {
setZipFile(savedFile); setZipFile(savedFile);
setHasLoadedZip(true); setHasLoadedZip(true);
@ -167,11 +167,11 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
try { try {
setCreating(true); setCreating(true);
console.log('🎯 开始创建审计任务...', { console.log('🎯 开始创建审计任务...', {
projectId: project.id, projectId: project.id,
projectName: project.name, projectName: project.name,
repositoryType: project.repository_type repositoryType: project.repository_type
}); });
let taskId: string; let taskId: string;
@ -183,7 +183,7 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
toast.error("请上传ZIP文件进行扫描"); toast.error("请上传ZIP文件进行扫描");
return; return;
} }
console.log('📦 调用 scanZipFile...'); console.log('📦 调用 scanZipFile...');
taskId = await scanZipFile({ taskId = await scanZipFile({
projectId: project.id, projectId: project.id,
@ -194,7 +194,7 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
} else { } else {
// GitHub/GitLab等远程仓库 // GitHub/GitLab等远程仓库
console.log('📡 调用 runRepositoryAudit...'); console.log('📡 调用 runRepositoryAudit...');
// 后端会从用户配置中读取 GitHub/GitLab Token前端不需要传递 // 后端会从用户配置中读取 GitHub/GitLab Token前端不需要传递
taskId = await runRepositoryAudit({ taskId = await runRepositoryAudit({
projectId: project.id, projectId: project.id,
@ -204,9 +204,9 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
createdBy: 'local-user' createdBy: 'local-user'
}); });
} }
console.log('✅ 任务创建成功:', taskId); console.log('✅ 任务创建成功:', taskId);
// 记录用户操作 // 记录用户操作
import('@/shared/utils/logger').then(({ logger, LogCategory }) => { import('@/shared/utils/logger').then(({ logger, LogCategory }) => {
logger.logUserAction('创建审计任务', { logger.logUserAction('创建审计任务', {
@ -218,25 +218,25 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
hasZipFile: !!zipFile, hasZipFile: !!zipFile,
}); });
}); });
// 关闭创建对话框 // 关闭创建对话框
onOpenChange(false); onOpenChange(false);
resetForm(); resetForm();
onTaskCreated(); onTaskCreated();
// 显示终端进度窗口 // 显示终端进度窗口
setCurrentTaskId(taskId); setCurrentTaskId(taskId);
setShowTerminalDialog(true); setShowTerminalDialog(true);
toast.success("审计任务已创建并启动"); toast.success("审计任务已创建并启动");
} catch (error) { } catch (error) {
console.error('❌ 创建任务失败:', error); console.error('❌ 创建任务失败:', error);
// 记录错误并显示详细信息 // 记录错误并显示详细信息
import('@/shared/utils/errorHandler').then(({ handleError }) => { import('@/shared/utils/errorHandler').then(({ handleError }) => {
handleError(error, '创建审计任务失败'); handleError(error, '创建审计任务失败');
}); });
const errorMessage = error instanceof Error ? error.message : '未知错误'; const errorMessage = error instanceof Error ? error.message : '未知错误';
toast.error(`创建任务失败: ${errorMessage}`); toast.error(`创建任务失败: ${errorMessage}`);
} finally { } finally {
@ -336,13 +336,12 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
</div> </div>
) : filteredProjects.length > 0 ? ( ) : filteredProjects.length > 0 ? (
filteredProjects.map((project) => ( filteredProjects.map((project) => (
<Card <Card
key={project.id} key={project.id}
className={`cursor-pointer transition-all hover:shadow-md ${ className={`cursor-pointer transition-all hover:shadow-md ${taskForm.project_id === project.id
taskForm.project_id === project.id ? 'ring-2 ring-primary bg-primary/5'
? 'ring-2 ring-primary bg-primary/5'
: 'hover:bg-gray-50' : 'hover:bg-gray-50'
}`} }`}
onClick={() => setTaskForm({ ...taskForm, project_id: project.id })} onClick={() => setTaskForm({ ...taskForm, project_id: project.id })}
> >
<CardContent className="p-4"> <CardContent className="p-4">
@ -415,11 +414,11 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
<p className="font-medium text-green-900 text-sm"></p> <p className="font-medium text-green-900 text-sm"></p>
<p className="text-xs text-green-700 mt-1"> <p className="text-xs text-green-700 mt-1">
使ZIP文件: {zipFile.name} ( 使ZIP文件: {zipFile.name} (
{zipFile.size >= 1024 * 1024 {zipFile.size >= 1024 * 1024
? `${(zipFile.size / 1024 / 1024).toFixed(2)} MB` ? `${(zipFile.size / 1024 / 1024).toFixed(2)} MB`
: zipFile.size >= 1024 : zipFile.size >= 1024
? `${(zipFile.size / 1024).toFixed(2)} KB` ? `${(zipFile.size / 1024).toFixed(2)} KB`
: `${zipFile.size} B` : `${zipFile.size} B`
}) })
</p> </p>
</div> </div>
@ -445,7 +444,7 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="zipFile">ZIP文件</Label> <Label htmlFor="zipFile">ZIP文件</Label>
<Input <Input
@ -461,7 +460,7 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
type: file.type, type: file.type,
sizeMB: (file.size / 1024 / 1024).toFixed(2) sizeMB: (file.size / 1024 / 1024).toFixed(2)
}); });
const validation = validateZipFile(file); const validation = validateZipFile(file);
if (!validation.valid) { if (!validation.valid) {
toast.error(validation.error || "文件无效"); toast.error(validation.error || "文件无效");
@ -470,11 +469,11 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
} }
setZipFile(file); setZipFile(file);
setHasLoadedZip(true); setHasLoadedZip(true);
const sizeMB = (file.size / 1024 / 1024).toFixed(2); const sizeMB = (file.size / 1024 / 1024).toFixed(2);
const sizeKB = (file.size / 1024).toFixed(2); const sizeKB = (file.size / 1024).toFixed(2);
const sizeText = file.size >= 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`; const sizeText = file.size >= 1024 * 1024 ? `${sizeMB} MB` : `${sizeKB} KB`;
toast.success(`已选择文件: ${file.name} (${sizeText})`); toast.success(`已选择文件: ${file.name} (${sizeText})`);
} }
}} }}
@ -491,8 +490,8 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="task_type"></Label> <Label htmlFor="task_type"></Label>
<Select <Select
value={taskForm.task_type} value={taskForm.task_type}
onValueChange={(value: any) => setTaskForm({ ...taskForm, task_type: value })} onValueChange={(value: any) => setTaskForm({ ...taskForm, task_type: value })}
> >
<SelectTrigger> <SelectTrigger>
@ -588,8 +587,8 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
} }
}} }}
/> />
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={(e) => { onClick={(e) => {
const input = e.currentTarget.previousElementSibling as HTMLInputElement; const input = e.currentTarget.previousElementSibling as HTMLInputElement;
@ -608,9 +607,9 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
<Label></Label> <Label></Label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{taskForm.exclude_patterns.map((pattern) => ( {taskForm.exclude_patterns.map((pattern) => (
<Badge <Badge
key={pattern} key={pattern}
variant="secondary" variant="secondary"
className="cursor-pointer hover:bg-red-100 hover:text-red-800" className="cursor-pointer hover:bg-red-100 hover:text-red-800"
onClick={() => removeExcludePattern(pattern)} onClick={() => removeExcludePattern(pattern)}
> >
@ -637,7 +636,7 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Checkbox <Checkbox
checked={taskForm.scan_config.include_tests} checked={taskForm.scan_config.include_tests}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setTaskForm({ setTaskForm({
...taskForm, ...taskForm,
scan_config: { ...taskForm.scan_config, include_tests: !!checked } scan_config: { ...taskForm.scan_config, include_tests: !!checked }
@ -653,7 +652,7 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Checkbox <Checkbox
checked={taskForm.scan_config.include_docs} checked={taskForm.scan_config.include_docs}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setTaskForm({ setTaskForm({
...taskForm, ...taskForm,
scan_config: { ...taskForm.scan_config, include_docs: !!checked } scan_config: { ...taskForm.scan_config, include_docs: !!checked }
@ -674,12 +673,12 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
id="max_file_size" id="max_file_size"
type="number" type="number"
value={taskForm.scan_config.max_file_size} value={taskForm.scan_config.max_file_size}
onChange={(e) => onChange={(e) =>
setTaskForm({ setTaskForm({
...taskForm, ...taskForm,
scan_config: { scan_config: {
...taskForm.scan_config, ...taskForm.scan_config,
max_file_size: parseInt(e.target.value) || 200 max_file_size: parseInt(e.target.value) || 200
} }
}) })
} }
@ -690,9 +689,9 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="analysis_depth"></Label> <Label htmlFor="analysis_depth"></Label>
<Select <Select
value={taskForm.scan_config.analysis_depth} value={taskForm.scan_config.analysis_depth}
onValueChange={(value: any) => onValueChange={(value: any) =>
setTaskForm({ setTaskForm({
...taskForm, ...taskForm,
scan_config: { ...taskForm.scan_config, analysis_depth: value } scan_config: { ...taskForm.scan_config, analysis_depth: value }
@ -738,8 +737,8 @@ export default function CreateTaskDialog({ open, onOpenChange, onTaskCreated, pr
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={creating}> <Button variant="outline" onClick={() => onOpenChange(false)} disabled={creating}>
</Button> </Button>
<Button <Button
onClick={handleCreateTask} onClick={handleCreateTask}
disabled={!taskForm.project_id || creating} disabled={!taskForm.project_id || creating}
className="btn-primary" className="btn-primary"
> >

View File

@ -9,13 +9,13 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { import {
Download, Download,
Upload, Upload,
Trash2, Trash2,
AlertCircle, AlertCircle,
CheckCircle2, CheckCircle2,
Server, Server,
Activity, Activity,
RefreshCw, RefreshCw,
Database, Database,
@ -95,16 +95,16 @@ export function DatabaseManager() {
try { try {
setLoading(true); setLoading(true);
setMessage(null); setMessage(null);
const exportData = await api.exportDatabase(); const exportData = await api.exportDatabase();
// 构建完整的导出数据 // 构建完整的导出数据
const fullData = { const fullData = {
version: "1.0.0", version: "1.0.0",
export_date: exportData.export_date, export_date: exportData.export_date,
data: exportData.data data: exportData.data
}; };
const blob = new Blob([JSON.stringify(fullData, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(fullData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@ -114,10 +114,10 @@ export function DatabaseManager() {
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
toast.success('数据导出成功!'); toast.success('数据导出成功!');
setMessage({ type: 'success', text: '数据导出成功!' }); setMessage({ type: 'success', text: '数据导出成功!' });
// 刷新统计信息 // 刷新统计信息
loadStats(); loadStats();
} catch (error: any) { } catch (error: any) {
@ -152,9 +152,9 @@ export function DatabaseManager() {
try { try {
setLoading(true); setLoading(true);
setMessage(null); setMessage(null);
const result = await api.importDatabase(file); const result = await api.importDatabase(file);
const imported = result.imported; const imported = result.imported;
const summary = [ const summary = [
imported.projects > 0 && `${imported.projects} 个项目`, imported.projects > 0 && `${imported.projects} 个项目`,
@ -163,17 +163,17 @@ export function DatabaseManager() {
imported.analyses > 0 && `${imported.analyses} 条分析记录`, imported.analyses > 0 && `${imported.analyses} 条分析记录`,
imported.config > 0 && '用户配置', imported.config > 0 && '用户配置',
].filter(Boolean).join('、'); ].filter(Boolean).join('、');
toast.success(`数据导入成功!已导入:${summary}`); toast.success(`数据导入成功!已导入:${summary}`);
setMessage({ type: 'success', text: `数据导入成功!已导入:${summary}` }); setMessage({ type: 'success', text: `数据导入成功!已导入:${summary}` });
// 清空文件输入 // 清空文件输入
event.target.value = ''; event.target.value = '';
// 刷新统计信息和健康检查 // 刷新统计信息和健康检查
loadStats(); loadStats();
loadHealth(); loadHealth();
// 延迟刷新页面 // 延迟刷新页面
setTimeout(() => window.location.reload(), 2000); setTimeout(() => window.location.reload(), 2000);
} catch (error: any) { } catch (error: any) {
@ -202,9 +202,9 @@ export function DatabaseManager() {
try { try {
setLoading(true); setLoading(true);
setMessage(null); setMessage(null);
const result = await api.clearDatabase(); const result = await api.clearDatabase();
const deleted = result.deleted; const deleted = result.deleted;
const summary = [ const summary = [
deleted.projects > 0 && `${deleted.projects} 个项目`, deleted.projects > 0 && `${deleted.projects} 个项目`,
@ -213,14 +213,14 @@ export function DatabaseManager() {
deleted.analyses > 0 && `${deleted.analyses} 条分析记录`, deleted.analyses > 0 && `${deleted.analyses} 条分析记录`,
deleted.config > 0 && '用户配置', deleted.config > 0 && '用户配置',
].filter(Boolean).join('、'); ].filter(Boolean).join('、');
toast.success(`数据已清空!已删除:${summary}`); toast.success(`数据已清空!已删除:${summary}`);
setMessage({ type: 'success', text: `数据已清空!已删除:${summary}` }); setMessage({ type: 'success', text: `数据已清空!已删除:${summary}` });
// 刷新统计信息和健康检查 // 刷新统计信息和健康检查
loadStats(); loadStats();
loadHealth(); loadHealth();
// 延迟刷新页面 // 延迟刷新页面
setTimeout(() => window.location.reload(), 2000); setTimeout(() => window.location.reload(), 2000);
} catch (error: any) { } catch (error: any) {
@ -306,7 +306,7 @@ export function DatabaseManager() {
</Badge> </Badge>
</div> </div>
<div className="text-sm mt-1"> <div className="text-sm mt-1">
{health.database_connected ? '正常' : '异常'} | {health.database_connected ? '正常' : '异常'} |
{health.total_records.toLocaleString()} {health.total_records.toLocaleString()}
</div> </div>
</div> </div>
@ -507,7 +507,7 @@ export function DatabaseManager() {
<Info className="h-4 w-4" /> <Info className="h-4 w-4" />
<AlertDescription> <AlertDescription>
<strong></strong> <strong></strong>
{dbMode === 'api' {dbMode === 'api'
? '数据存储在后端 PostgreSQL 数据库中,支持多用户、多设备同步。建议定期导出备份。' ? '数据存储在后端 PostgreSQL 数据库中,支持多用户、多设备同步。建议定期导出备份。'
: '建议定期导出数据备份,以防意外数据丢失。'} : '建议定期导出数据备份,以防意外数据丢失。'}
</AlertDescription> </AlertDescription>

View File

@ -1,26 +0,0 @@
import React from "react";
import { Code } from "lucide-react";
const Footer: React.FC = () => {
const currentYear = new Date().getFullYear();
return (
<footer className="bg-white border-t border-gray-200/60 mt-16">
<div className="container-responsive py-8">
<div className="text-center">
<div className="flex items-center justify-center space-x-2 mb-4">
<div className="w-6 h-6 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<Code className="w-4 h-4 text-white" />
</div>
<span className="text-lg font-semibold text-gray-900">XCodeReviewer</span>
</div>
<p className="text-gray-500 text-sm">
© {currentYear} XCodeReviewer. .
</p>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@ -1,116 +0,0 @@
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
Menu,
X
} from "lucide-react";
import routes from "@/app/routes";
export default function Header() {
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const visibleRoutes = routes.filter(route => route.visible !== false);
return (
<header className="bg-white/80 backdrop-blur-md shadow-sm border-b border-gray-200/60 sticky top-0 z-50">
<div className="container-responsive">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link to="/" className="flex items-center space-x-3 group">
<img
src="/logo_xcodereviewer.png"
alt="XCodeReviewer Logo"
className="w-9 h-9 rounded-xl shadow-sm group-hover:shadow-md transition-all"
/>
<span className="text-xl font-bold text-gray-900 group-hover:text-primary transition-colors">XCodeReviewer</span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-1">
{visibleRoutes.map((route) => (
<Link
key={route.path}
to={route.path}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-all ${
location.pathname === route.path
? "text-primary bg-red-50"
: "text-gray-700 hover:text-primary hover:bg-gray-50"
}`}
>
{route.name}
</Link>
))}
</nav>
{/* User Menu */}
<div className="flex items-center space-x-4">
{/* GitHub Link */}
<a
href="https://github.com/lintsinghua/XCodeReviewer"
target="_blank"
rel="noopener noreferrer"
className="hidden md:flex items-center justify-center w-9 h-9 rounded-lg text-gray-700 hover:text-gray-900 hover:bg-gray-100 transition-all"
title="GitHub Repository"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
</svg>
</a>
{/* Mobile menu button */}
<Button
variant="ghost"
size="sm"
className="md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? (
<X className="w-5 h-5" />
) : (
<Menu className="w-5 h-5" />
)}
</Button>
</div>
</div>
{/* Mobile Navigation */}
{mobileMenuOpen && (
<div className="md:hidden border-t bg-white">
<div className="px-2 pt-2 pb-3 space-y-1">
{visibleRoutes.map((route) => (
<Link
key={route.path}
to={route.path}
className={`block px-3 py-2 text-base font-medium rounded-md transition-colors ${
location.pathname === route.path
? "text-primary bg-red-50"
: "text-gray-700 hover:text-primary hover:bg-gray-50"
}`}
onClick={() => setMobileMenuOpen(false)}
>
{route.name}
</Link>
))}
{/* GitHub Link for Mobile */}
<a
href="https://github.com/lintsinghua/XCodeReviewer"
target="_blank"
rel="noopener noreferrer"
className="flex items-center px-3 py-2 text-base font-medium text-gray-700 hover:text-primary hover:bg-gray-50 rounded-md transition-colors"
onClick={() => setMobileMenuOpen(false)}
>
<svg className="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
</svg>
GitHub
</a>
</div>
</div>
)}
</div>
</header>
);
}

View File

@ -36,7 +36,7 @@ export default function PageMeta({
<title>{fullTitle}</title> <title>{fullTitle}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
<meta name="keywords" content={keywords} /> <meta name="keywords" content={keywords} />
{/* Open Graph */} {/* Open Graph */}
<meta property="og:title" content={fullTitle} /> <meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
@ -44,13 +44,13 @@ export default function PageMeta({
<meta property="og:url" content={url} /> <meta property="og:url" content={url} />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="XCodeReviewer" /> <meta property="og:site_name" content="XCodeReviewer" />
{/* Twitter Card */} {/* Twitter Card */}
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} /> <meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} /> <meta name="twitter:description" content={description} />
<meta name="twitter:image" content={image} /> <meta name="twitter:image" content={image} />
{/* 其他 */} {/* 其他 */}
<meta name="robots" content="index, follow" /> <meta name="robots" content="index, follow" />
<meta name="author" content="XCodeReviewer" /> <meta name="author" content="XCodeReviewer" />

View File

@ -0,0 +1,186 @@
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
Menu,
X,
LayoutDashboard,
FolderGit2,
Zap,
ListTodo,
Settings,
Trash2,
FileText,
ChevronLeft,
ChevronRight,
Github
} from "lucide-react";
import routes from "@/app/routes";
// Icon mapping for routes
const routeIcons: Record<string, React.ReactNode> = {
"/": <LayoutDashboard className="w-5 h-5" />,
"/projects": <FolderGit2 className="w-5 h-5" />,
"/instant-analysis": <Zap className="w-5 h-5" />,
"/audit-tasks": <ListTodo className="w-5 h-5" />,
"/admin": <Settings className="w-5 h-5" />,
"/recycle-bin": <Trash2 className="w-5 h-5" />,
"/logs": <FileText className="w-5 h-5" />,
};
interface SidebarProps {
collapsed: boolean;
setCollapsed: (collapsed: boolean) => void;
}
export default function Sidebar({ collapsed, setCollapsed }: SidebarProps) {
const location = useLocation();
const [mobileOpen, setMobileOpen] = useState(false);
const visibleRoutes = routes.filter(route => route.visible !== false);
return (
<>
{/* Mobile Menu Button */}
<Button
variant="ghost"
size="sm"
className="fixed top-4 left-4 z-50 md:hidden bg-white shadow-md border border-gray-200 text-gray-700 hover:bg-gray-50"
onClick={() => setMobileOpen(!mobileOpen)}
>
{mobileOpen ? <X className="w-5 h-5" /> : <Menu className="w-5 h-5" />}
</Button>
{/* Overlay for mobile */}
{mobileOpen && (
<div
className="fixed inset-0 bg-black/20 backdrop-blur-sm z-40 md:hidden transition-opacity"
onClick={() => setMobileOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`
fixed top-0 left-0 h-screen bg-white/95 backdrop-blur-xl
border-r border-gray-200/80 shadow-[4px_0_24px_-12px_rgba(0,0,0,0.1)]
z-40 transition-all duration-300 ease-in-out
${collapsed ? "w-20" : "w-64"}
${mobileOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"}
`}
>
<div className="flex flex-col h-full">
{/* Logo Section */}
<div className="relative flex items-center h-[72px] px-4 border-b border-gray-100">
<Link
to="/"
className={`flex items-center space-x-3 group overflow-hidden transition-all duration-300 ${collapsed ? 'justify-center w-full' : ''}`}
onClick={() => setMobileOpen(false)}
>
<div className="relative flex-shrink-0">
<div className="absolute inset-0 bg-red-500/20 blur-lg rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<img
src="/logo_xcodereviewer.png"
alt="XCodeReviewer Logo"
className="w-9 h-9 rounded-xl shadow-sm relative z-10 group-hover:scale-105 transition-transform duration-300"
/>
</div>
<div className={`transition-all duration-300 ${collapsed ? 'w-0 opacity-0 overflow-hidden' : 'w-auto opacity-100'}`}>
<span className="text-lg font-bold bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-700 group-hover:from-red-700 group-hover:to-red-500 whitespace-nowrap">
XCodeReviewer
</span>
</div>
</Link>
{/* Collapse button for desktop - Absolute Positioned */}
<Button
variant="ghost"
size="icon"
className="hidden md:flex absolute -right-3 top-1/2 -translate-y-1/2 w-6 h-6 rounded-full bg-white text-gray-400 hover:text-gray-900 border border-gray-200 shadow-sm z-50 hover:bg-gray-50"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed ? (
<ChevronRight className="w-3 h-3" />
) : (
<ChevronLeft className="w-3 h-3" />
)}
</Button>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto py-6 px-3">
<div className="space-y-1.5">
{visibleRoutes.map((route) => {
const isActive = location.pathname === route.path;
return (
<Link
key={route.path}
to={route.path}
className={`
flex items-center space-x-3 px-3 py-2.5 rounded-xl
transition-all duration-200 group relative overflow-hidden
${isActive
? "bg-red-50 text-red-700 font-medium shadow-sm ring-1 ring-red-100"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
}
`}
onClick={() => setMobileOpen(false)}
title={collapsed ? route.name : undefined}
>
{/* Active Indicator Bar */}
{isActive && (
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-1 h-8 bg-red-600 rounded-r-full opacity-0 md:opacity-100 transition-opacity" />
)}
{/* Icon */}
<span className={`
flex-shrink-0 transition-colors duration-200
${isActive ? "text-red-600" : "text-gray-400 group-hover:text-gray-600"}
`}>
{routeIcons[route.path] || <LayoutDashboard className="w-5 h-5" />}
</span>
{/* Label */}
{!collapsed && (
<span className="whitespace-nowrap z-10">
{route.name}
</span>
)}
{/* Hover Effect Background (Subtle) */}
{!isActive && (
<div className="absolute inset-0 bg-gray-100 opacity-0 group-hover:opacity-100 transition-opacity duration-200 -z-0 rounded-xl" />
)}
</Link>
);
})}
</div>
</nav>
{/* Footer with GitHub Link */}
<div className="p-4 border-t border-gray-100 bg-gray-50/50">
<a
href="https://github.com/lintsinghua/XCodeReviewer"
target="_blank"
rel="noopener noreferrer"
className={`
flex items-center space-x-3 px-3 py-2.5 rounded-xl
text-gray-500 hover:bg-white hover:text-gray-900 hover:shadow-sm hover:ring-1 hover:ring-gray-200
transition-all duration-200 group
`}
title={collapsed ? "GitHub Repository" : undefined}
>
<Github className="w-5 h-5 flex-shrink-0 text-gray-400 group-hover:text-gray-900 transition-colors" />
{!collapsed && (
<div className="flex flex-col">
<span className="font-medium text-sm">GitHub</span>
<span className="text-xs text-gray-400 group-hover:text-gray-500">View Source</span>
</div>
)}
</a>
</div>
</div>
</aside>
</>
);
}

View File

@ -109,8 +109,8 @@ export default function ExportReportDialog({
<Label <Label
htmlFor={format.value} htmlFor={format.value}
className={`flex items-start space-x-4 p-4 rounded-lg border-2 cursor-pointer transition-all ${isSelected className={`flex items-start space-x-4 p-4 rounded-lg border-2 cursor-pointer transition-all ${isSelected
? `${format.borderColor} ${format.bgColor} shadow-md` ? `${format.borderColor} ${format.bgColor} shadow-md`
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm" : "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
}`} }`}
> >
<div <div

View File

@ -205,7 +205,7 @@ export function SystemConfig() {
doubaoApiKey: config.doubaoApiKey, doubaoApiKey: config.doubaoApiKey,
ollamaBaseUrl: config.ollamaBaseUrl, ollamaBaseUrl: config.ollamaBaseUrl,
}; };
const otherConfig = { const otherConfig = {
githubToken: config.githubToken, githubToken: config.githubToken,
gitlabToken: config.gitlabToken, gitlabToken: config.gitlabToken,
@ -219,7 +219,7 @@ export function SystemConfig() {
llmConfig, llmConfig,
otherConfig, otherConfig,
}); });
setHasChanges(false); setHasChanges(false);
setConfigSource('runtime'); setConfigSource('runtime');
@ -232,7 +232,7 @@ export function SystemConfig() {
concurrency: config.llmConcurrency, concurrency: config.llmConcurrency,
language: config.outputLanguage, language: config.outputLanguage,
}); });
}).catch(() => {}); }).catch(() => { });
toast.success("配置已保存到后端!刷新页面后生效"); toast.success("配置已保存到后端!刷新页面后生效");
@ -254,7 +254,7 @@ export function SystemConfig() {
try { try {
// 删除后端配置 // 删除后端配置
await api.deleteUserConfig(); await api.deleteUserConfig();
// 重新加载配置(会使用后端默认配置) // 重新加载配置(会使用后端默认配置)
await loadConfig(); await loadConfig();
setHasChanges(false); setHasChanges(false);
@ -262,7 +262,7 @@ export function SystemConfig() {
// 记录用户操作 // 记录用户操作
import('@/shared/utils/logger').then(({ logger }) => { import('@/shared/utils/logger').then(({ logger }) => {
logger.logUserAction('重置系统配置', { action: 'reset_to_default' }); logger.logUserAction('重置系统配置', { action: 'reset_to_default' });
}).catch(() => {}); }).catch(() => { });
toast.success("已重置为默认配置"); toast.success("已重置为默认配置");
} catch (error) { } catch (error) {

View File

@ -384,9 +384,9 @@ export default function AdminDashboard() {
<AlertDescription> <AlertDescription>
<strong></strong> { <strong></strong> {
dbMode === 'api' ? '后端 PostgreSQL 数据库' : dbMode === 'api' ? '后端 PostgreSQL 数据库' :
dbMode === 'local' ? '本地 IndexedDB' : dbMode === 'local' ? '本地 IndexedDB' :
dbMode === 'supabase' ? 'Supabase 云端(已废弃)' : dbMode === 'supabase' ? 'Supabase 云端(已废弃)' :
'演示模式' '演示模式'
} }
</AlertDescription> </AlertDescription>
</Alert> </Alert>

View File

@ -1,16 +0,0 @@
/**
*
*/
import PageMeta from "@/components/layout/PageMeta";
export default function SamplePage() {
return (
<>
<PageMeta title="首页" description="首页介绍" />
<div>
<h3></h3>
</div>
</>
);
}