feat(database): Add local database support with IndexedDB and multi-mode configuration

- Introduce three database modes: local IndexedDB, Supabase cloud, and demo mode
- Update `.env.example` with comprehensive database configuration options
- Add new database management components for local storage and status tracking
- Implement local database initialization and configuration utilities
- Update Dockerfile and docker-compose.yml to support local database environment variable
- Enhance README documentation with detailed database mode explanations and usage guidance
- Add configuration for output language selection in environment settings
- Refactor database configuration to support flexible storage strategies
- Improve error handling and configuration management for database interactions
Resolves database persistence and provides users with flexible data storage options across different use cases.
This commit is contained in:
lintsinghua 2025-10-24 18:34:55 +08:00
parent 5bbea7101c
commit 3f7cc276d9
18 changed files with 2019 additions and 355 deletions

View File

@ -89,8 +89,16 @@ VITE_LLM_PROVIDER=gemini
#
# 更多模型: https://ollama.com/library
# ==================== Supabase 数据库配置 (可选) ====================
# 如果不配置,系统将以演示模式运行,数据不会持久化
# ==================== 数据库配置 ====================
# 数据库模式选择:
# 1. 本地数据库模式(推荐):设置 VITE_USE_LOCAL_DB=true数据存储在浏览器 IndexedDB 中
# 2. Supabase 云端模式:配置 Supabase URL 和 Key数据存储在云端
# 3. 演示模式:不配置任何数据库,使用演示数据(数据不持久化)
# 使用本地数据库IndexedDB
# VITE_USE_LOCAL_DB=true
# Supabase 云端数据库配置 (可选)
# 获取配置: https://supabase.com/
# VITE_SUPABASE_URL=https://your-project.supabase.co
# VITE_SUPABASE_ANON_KEY=your-anon-key-here
@ -107,3 +115,9 @@ VITE_APP_ID=xcodereviewer
VITE_MAX_ANALYZE_FILES=40
VITE_LLM_CONCURRENCY=2
VITE_LLM_GAP_MS=500
# ==================== 输出语言配置 ====================
# 设置 LLM 分析结果的输出语言
# zh-CN: 简体中文(默认)
# en-US: 英文
VITE_OUTPUT_LANGUAGE=zh-CN

0
DOCKER.md Normal file
View File

View File

@ -82,11 +82,15 @@ ARG VITE_SUPABASE_ANON_KEY
# GitHub 配置
ARG VITE_GITHUB_TOKEN
# 数据库配置
ARG VITE_USE_LOCAL_DB
# 应用配置
ARG VITE_APP_ID
ARG VITE_MAX_ANALYZE_FILES
ARG VITE_LLM_CONCURRENCY
ARG VITE_LLM_GAP_MS
ARG VITE_OUTPUT_LANGUAGE
# 将构建参数转换为环境变量Vite 构建时会读取这些环境变量)
ENV VITE_LLM_PROVIDER=$VITE_LLM_PROVIDER
@ -133,6 +137,7 @@ ENV VITE_OLLAMA_API_KEY=$VITE_OLLAMA_API_KEY
ENV VITE_OLLAMA_MODEL=$VITE_OLLAMA_MODEL
ENV VITE_OLLAMA_BASE_URL=$VITE_OLLAMA_BASE_URL
ENV VITE_USE_LOCAL_DB=$VITE_USE_LOCAL_DB
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
@ -142,6 +147,7 @@ ENV VITE_APP_ID=$VITE_APP_ID
ENV VITE_MAX_ANALYZE_FILES=$VITE_MAX_ANALYZE_FILES
ENV VITE_LLM_CONCURRENCY=$VITE_LLM_CONCURRENCY
ENV VITE_LLM_GAP_MS=$VITE_LLM_GAP_MS
ENV VITE_OUTPUT_LANGUAGE=$VITE_OUTPUT_LANGUAGE
# 复制依赖文件
COPY package.json pnpm-lock.yaml ./

104
README.md
View File

@ -140,9 +140,15 @@
VITE_CLAUDE_API_KEY=your_claude_api_key_here
# ... 支持10+主流平台
# Supabase 配置 (可选,用于数据持久化)
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
# 数据库配置 (三种模式可选)
# 1. 本地数据库模式(推荐)- 数据存储在浏览器 IndexedDB
VITE_USE_LOCAL_DB=true
# 2. Supabase 云端模式 - 数据存储在云端
# VITE_SUPABASE_URL=https://your-project.supabase.co
# VITE_SUPABASE_ANON_KEY=your-anon-key-here
# 3. 演示模式 - 不配置任何数据库,使用演示数据(数据不持久化)
# GitHub 集成 (可选,用于仓库分析)
VITE_GITHUB_TOKEN=your_github_token_here
@ -154,6 +160,9 @@
VITE_MAX_ANALYZE_FILES=40
VITE_LLM_CONCURRENCY=2
VITE_LLM_GAP_MS=500
# 输出语言配置zh-CN: 中文 | en-US: 英文)
VITE_OUTPUT_LANGUAGE=zh-CN
```
4. **启动开发服务器**
@ -241,6 +250,69 @@ VITE_LLM_PROVIDER=baidu
VITE_BAIDU_API_KEY=your_api_key:your_secret_key
VITE_BAIDU_MODEL=ERNIE-3.5-8K
```
</details>
<details>
<summary><b>Q: 数据库有哪些模式可选?如何选择?</b></summary>
XCodeReviewer 支持三种数据库模式:
**1. 本地数据库模式(推荐)**
- 数据存储在浏览器 IndexedDB 中
- 无需配置云端服务,开箱即用
- 数据完全本地化,隐私安全
- 适合个人使用和快速体验
```env
VITE_USE_LOCAL_DB=true
```
**2. Supabase 云端模式**
- 数据存储在 Supabase 云端
- 支持多设备同步
- 需要注册 Supabase 账号并配置
- 适合团队协作和跨设备使用
```env
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
```
**3. 演示模式**
- 不配置任何数据库
- 使用内置演示数据
- 数据不会持久化保存
- 适合快速预览功能
</details>
<details>
<summary><b>Q: 本地数据库的数据存储在哪里?如何备份?</b></summary>
本地数据库使用浏览器的 IndexedDB 存储数据:
- **存储位置**:浏览器本地存储(不同浏览器位置不同)
- **数据安全**:数据仅存储在本地,不会上传到服务器
- **清除数据**:清除浏览器数据会删除所有本地数据
- **备份方法**:可以在管理界面导出数据为 JSON 文件
- **恢复方法**:通过导入 JSON 文件恢复数据
注意:本地数据库数据仅在当前浏览器可用,更换浏览器或设备需要重新导入数据。
</details>
<details>
<summary><b>Q: 如何设置分析结果的输出语言?</b></summary>
`.env` 文件中配置 `VITE_OUTPUT_LANGUAGE`
```env
# 中文输出(默认)
VITE_OUTPUT_LANGUAGE=zh-CN
# 英文输出
VITE_OUTPUT_LANGUAGE=en-US
```
重启应用后,所有 LLM 分析结果将使用指定的语言输出。
可在[百度千帆平台](https://console.bce.baidu.com/qianfan/)获取API Key和Secret Key。
</details>
@ -430,6 +502,21 @@ VITE_LLM_BASE_URL=http://localhost:11434/v1 # Ollama API地址可选
- **质量趋势分析**:通过图表展示代码质量随时间的变化趋势。
</details>
<details>
<summary><b>💾 本地数据库管理</b></summary>
- **三种数据库模式**
- 🏠 **本地模式**:使用浏览器 IndexedDB数据完全本地化隐私安全
- ☁️ **云端模式**:使用 Supabase支持多设备同步
- 🎭 **演示模式**:无需配置,快速体验功能
- **数据管理功能**
- 📤 **导出备份**:将数据导出为 JSON 文件
- 📥 **导入恢复**:从备份文件恢复数据
- 🗑️ **清空数据**:一键清理所有本地数据
- 📊 **存储监控**:实时查看存储空间使用情况
- **智能统计**:项目、任务、问题的完整统计和可视化展示
</details>
## 🛠️ 技术栈
| 分类 | 技术 | 说明 |
@ -440,7 +527,7 @@ VITE_LLM_BASE_URL=http://localhost:11434/v1 # Ollama API地址可选
| **路由管理** | `React Router v6` | 单页应用路由解决方案 |
| **状态管理** | `React Hooks` `Sonner` | 轻量级状态管理和通知系统 |
| **AI 引擎** | `多平台 LLM` | 支持 Gemini、OpenAI、Claude、通义千问、DeepSeek 等 10+ 主流平台 |
| **后端服务** | `Supabase` `PostgreSQL` | 全栈后端即服务,实时数据库 |
| **数据存储** | `IndexedDB` `Supabase` `PostgreSQL` | 本地数据库 + 云端数据库双模式支持 |
| **HTTP 客户端** | `Axios` `Ky` | 现代化的 HTTP 请求库 |
| **代码质量** | `Biome` `Ast-grep` `TypeScript` | 代码格式化、静态分析和类型检查 |
| **构建工具** | `Vite` `PostCSS` `Autoprefixer` | 快速的构建工具和 CSS 处理 |
@ -457,23 +544,28 @@ XCodeReviewer/
│ ├── components/ # React 组件
│ │ ├── layout/ # 布局组件 (Header, Footer, PageMeta)
│ │ ├── ui/ # UI 组件库 (基于 Radix UI)
│ │ ├── database/ # 数据库管理组件
│ │ └── debug/ # 调试组件
│ ├── pages/ # 页面组件
│ │ ├── Dashboard.tsx # 仪表盘
│ │ ├── Projects.tsx # 项目管理
│ │ ├── InstantAnalysis.tsx # 即时分析
│ │ ├── AuditTasks.tsx # 审计任务
│ │ └── AdminDashboard.tsx # 系统管理
│ │ └── AdminDashboard.tsx # 数据库管理
│ ├── features/ # 功能模块
│ │ ├── analysis/ # 分析相关服务
│ │ │ └── services/ # AI 代码分析引擎
│ │ └── projects/ # 项目相关服务
│ │ └── services/ # 仓库扫描、ZIP 文件扫描
│ ├── shared/ # 共享工具
│ │ ├── config/ # 配置文件 (数据库、环境变量)
│ │ ├── config/ # 配置文件
│ │ │ ├── database.ts # 数据库统一接口
│ │ │ ├── localDatabase.ts # IndexedDB 实现
│ │ │ └── env.ts # 环境变量配置
│ │ ├── types/ # TypeScript 类型定义
│ │ ├── hooks/ # 自定义 React Hooks
│ │ ├── utils/ # 工具函数
│ │ │ └── initLocalDB.ts # 本地数据库初始化
│ │ └── constants/ # 常量定义
│ └── assets/ # 静态资源
│ └── styles/ # 样式文件

View File

@ -62,7 +62,8 @@ services:
- VITE_OLLAMA_MODEL=${VITE_OLLAMA_MODEL:-llama3}
- VITE_OLLAMA_BASE_URL=${VITE_OLLAMA_BASE_URL:-http://localhost:11434/v1}
# Supabase 配置
# 数据库配置
- VITE_USE_LOCAL_DB=${VITE_USE_LOCAL_DB:-true}
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
@ -74,6 +75,7 @@ services:
- VITE_MAX_ANALYZE_FILES=${VITE_MAX_ANALYZE_FILES:-40}
- VITE_LLM_CONCURRENCY=${VITE_LLM_CONCURRENCY:-2}
- VITE_LLM_GAP_MS=${VITE_LLM_GAP_MS:-500}
- VITE_OUTPUT_LANGUAGE=${VITE_OUTPUT_LANGUAGE:-zh-CN}
container_name: xcodereviewer-app
ports:

View File

@ -3,6 +3,13 @@ import { createRoot } from "react-dom/client";
import "@/assets/styles/globals.css";
import App from "./App.tsx";
import { AppWrapper } from "@/components/layout/PageMeta";
import { isLocalMode } from "@/shared/config/database";
import { initLocalDatabase } from "@/shared/utils/initLocalDB";
// 初始化本地数据库
if (isLocalMode) {
initLocalDatabase().catch(console.error);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>

View File

@ -52,7 +52,7 @@ const routes: RouteConfig[] = [
visible: false,
},
{
name: "系统管理",
name: "数据库管理",
path: "/admin",
element: <AdminDashboard />,
visible: true,

View File

@ -0,0 +1,241 @@
/**
*
*
*/
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Download, Upload, Trash2, Database, AlertCircle, CheckCircle2 } from 'lucide-react';
import { dbMode, isLocalMode } from '@/shared/config/database';
import {
exportLocalDatabase,
importLocalDatabase,
clearLocalDatabase,
initLocalDatabase
} from '@/shared/utils/initLocalDB';
export function DatabaseManager() {
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// 导出数据
const handleExport = async () => {
try {
setLoading(true);
setMessage(null);
const jsonData = await exportLocalDatabase();
const blob = new Blob([jsonData], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `xcodereviewer-backup-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
setMessage({ type: 'success', text: '数据导出成功!' });
} catch (error) {
console.error('导出失败:', error);
setMessage({ type: 'error', text: '数据导出失败,请重试' });
} finally {
setLoading(false);
}
};
// 导入数据
const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
try {
setLoading(true);
setMessage(null);
const text = await file.text();
await importLocalDatabase(text);
setMessage({ type: 'success', text: '数据导入成功!页面将刷新...' });
setTimeout(() => window.location.reload(), 1500);
} catch (error) {
console.error('导入失败:', error);
setMessage({ type: 'error', text: '数据导入失败,请检查文件格式' });
} finally {
setLoading(false);
}
};
// 清空数据
const handleClear = async () => {
if (!confirm('确定要清空所有本地数据吗?此操作不可恢复!')) {
return;
}
try {
setLoading(true);
setMessage(null);
await clearLocalDatabase();
setMessage({ type: 'success', text: '数据已清空!页面将刷新...' });
setTimeout(() => window.location.reload(), 1500);
} catch (error) {
console.error('清空失败:', error);
setMessage({ type: 'error', text: '清空数据失败,请重试' });
} finally {
setLoading(false);
}
};
// 初始化数据库
const handleInit = async () => {
try {
setLoading(true);
setMessage(null);
await initLocalDatabase();
setMessage({ type: 'success', text: '数据库初始化成功!' });
} catch (error) {
console.error('初始化失败:', error);
setMessage({ type: 'error', text: '初始化失败,请重试' });
} finally {
setLoading(false);
}
};
if (!isLocalMode) {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
</CardTitle>
<CardDescription>
使 {dbMode === 'supabase' ? 'Supabase 云端' : '演示'}
</CardDescription>
</CardHeader>
<CardContent>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{dbMode === 'demo' && '请在 .env 文件中配置 VITE_USE_LOCAL_DB=true 启用本地数据库。'}
</AlertDescription>
</Alert>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" />
</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{message && (
<Alert variant={message.type === 'error' ? 'destructive' : 'default'}>
{message.type === 'success' ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
<AlertDescription>{message.text}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<h4 className="text-sm font-medium"></h4>
<p className="text-sm text-muted-foreground">
JSON
</p>
<Button
onClick={handleExport}
disabled={loading}
className="w-full"
variant="outline"
>
<Download className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium"></h4>
<p className="text-sm text-muted-foreground">
JSON
</p>
<Button
onClick={() => document.getElementById('import-file')?.click()}
disabled={loading}
className="w-full"
variant="outline"
>
<Upload className="mr-2 h-4 w-4" />
</Button>
<input
id="import-file"
type="file"
accept=".json"
onChange={handleImport}
className="hidden"
/>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium"></h4>
<p className="text-sm text-muted-foreground">
</p>
<Button
onClick={handleInit}
disabled={loading}
className="w-full"
variant="outline"
>
<Database className="mr-2 h-4 w-4" />
</Button>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium text-destructive"></h4>
<p className="text-sm text-muted-foreground">
</p>
<Button
onClick={handleClear}
disabled={loading}
className="w-full"
variant="destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<strong></strong>
</AlertDescription>
</Alert>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,115 @@
/**
*
* 使
*/
import { Database, Cloud, Eye } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { dbMode } from '@/shared/config/database';
export function DatabaseStatus() {
const getStatusConfig = () => {
switch (dbMode) {
case 'local':
return {
icon: Database,
label: '本地数据库',
variant: 'default' as const,
description: '数据存储在浏览器本地'
};
case 'supabase':
return {
icon: Cloud,
label: 'Supabase 云端',
variant: 'secondary' as const,
description: '数据存储在云端'
};
case 'demo':
return {
icon: Eye,
label: '演示模式',
variant: 'outline' as const,
description: '使用演示数据,不会持久化'
};
default:
return {
icon: Database,
label: '未知模式',
variant: 'destructive' as const,
description: ''
};
}
};
const config = getStatusConfig();
const Icon = config.icon;
return (
<Badge variant={config.variant} className="gap-1.5">
<Icon className="h-3 w-3" />
{config.label}
</Badge>
);
}
export function DatabaseStatusDetail() {
const getStatusConfig = () => {
switch (dbMode) {
case 'local':
return {
icon: Database,
label: '本地数据库模式',
variant: 'default' as const,
description: '数据存储在浏览器 IndexedDB 中,完全本地化,隐私安全。',
tips: '提示:定期导出数据以防丢失。'
};
case 'supabase':
return {
icon: Cloud,
label: 'Supabase 云端模式',
variant: 'secondary' as const,
description: '数据存储在 Supabase 云端,支持多设备同步。',
tips: '提示:确保网络连接正常。'
};
case 'demo':
return {
icon: Eye,
label: '演示模式',
variant: 'outline' as const,
description: '使用内置演示数据,所有操作不会持久化保存。',
tips: '提示:配置数据库以保存您的数据。'
};
default:
return {
icon: Database,
label: '未知模式',
variant: 'destructive' as const,
description: '数据库配置异常',
tips: ''
};
}
};
const config = getStatusConfig();
const Icon = config.icon;
return (
<div className="flex items-start gap-3 rounded-lg border p-4">
<div className="rounded-full bg-muted p-2">
<Icon className="h-5 w-5" />
</div>
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">{config.label}</h4>
<Badge variant={config.variant} className="text-xs">
{dbMode}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{config.description}</p>
{config.tips && (
<p className="text-xs text-muted-foreground italic">{config.tips}</p>
)}
</div>
</div>
);
}

View File

@ -1,4 +1,4 @@
import type { CodeAnalysisResult } from "@/types/types";
import type { CodeAnalysisResult } from "@/shared/types";
import { LLMService } from '@/shared/services/llm';
import { getCurrentLLMApiKey, getCurrentLLMModel, env } from '@/shared/config/env';
import type { LLMConfig } from '@/shared/services/llm/types';
@ -37,6 +37,10 @@ export class CodeAnalysisEngine {
static async analyzeCode(code: string, language: string): Promise<CodeAnalysisResult> {
const llmService = this.createLLMService();
// 获取输出语言配置
const outputLanguage = env.OUTPUT_LANGUAGE || 'zh-CN';
const isChineseOutput = outputLanguage === 'zh-CN';
const schema = `{
"issues": [
@ -74,9 +78,66 @@ export class CodeAnalysisEngine {
}
}`;
const systemPrompt = `请严格使用中文。你是一个专业代码审计助手。请从编码规范、潜在Bug、性能问题、安全漏洞、可维护性、最佳实践等维度分析代码并严格输出 JSON仅 JSON符合以下 schema\n\n${schema}`;
const userPrompt = `语言: ${language}\n\n代码:\n\n${code}`;
// 根据配置生成不同语言的提示词
const systemPrompt = isChineseOutput
? `你是一个专业的代码审计助手。
1. titledescriptionsuggestionai_explanationxai 使
2. JSON markdown
3. JSON
-
- Bug
-
-
-
-
JSON Schema
${schema}
- title: 问题的简短标题
- description: 详细描述问题
- suggestion: 具体的修复建议
- ai_explanation: AI
- xai.what: 这是什么问题
- xai.why: 为什么会有这个问题
- xai.how: 如何修复这个问题`
: `You are a professional code auditing assistant.
IMPORTANTPlease strictly follow these rules:
1. All text content (title, description, suggestion, ai_explanation, xai, etc.) MUST be in English
2. Output ONLY valid JSON format, without any additional text, explanations, or markdown markers
3. Ensure the JSON format is completely correct with all string values properly escaped
Please comprehensively analyze the code from the following dimensions:
- Coding standards and code style
- Potential bugs and logical errors
- Performance issues and optimization suggestions
- Security vulnerabilities and risks
- Maintainability and readability
- Best practices and design patterns
The output format MUST strictly conform to the following JSON Schema:
${schema}
Note:
- title: Brief title of the issue (in English)
- description: Detailed description of the issue (in English)
- suggestion: Specific fix suggestions (in English)
- ai_explanation: AI's in-depth explanation (in English)
- xai.what: What is this issue (in English)
- xai.why: Why does this issue exist (in English)
- xai.how: How to fix this issue (in English)`;
const userPrompt = isChineseOutput
? `编程语言: ${language}\n\n请分析以下代码:\n\n${code}`
: `Programming Language: ${language}\n\nPlease analyze the following code:\n\n${code}`;
let text = '';
try {
@ -84,7 +145,7 @@ export class CodeAnalysisEngine {
console.log(`📡 提供商: ${env.LLM_PROVIDER}`);
console.log(`🤖 模型: ${getCurrentLLMModel()}`);
console.log(`🔗 Base URL: ${env.LLM_BASE_URL || '(默认)'}`);
// 使用新的LLM服务进行分析
const response = await llmService.complete({
messages: [
@ -94,17 +155,17 @@ export class CodeAnalysisEngine {
temperature: 0.2,
});
text = response.content;
console.log('✅ LLM 响应成功');
console.log(`📊 响应长度: ${text.length} 字符`);
console.log(`📝 响应内容预览: ${text.substring(0, 200)}...`);
} catch (e: any) {
console.error('LLM分析失败:', e);
// 构造更友好的错误消息
const errorMsg = e.message || '未知错误';
const provider = env.LLM_PROVIDER;
// 抛出详细的错误信息给前端
throw new Error(
`${provider} API调用失败\n\n` +
@ -118,15 +179,15 @@ export class CodeAnalysisEngine {
);
}
const parsed = this.safeParseJson(text);
// 如果解析失败,抛出错误而不是返回默认值
if (!parsed) {
const provider = env.LLM_PROVIDER;
const currentModel = getCurrentLLMModel();
let suggestions = '';
if (provider === 'ollama') {
suggestions =
suggestions =
`建议解决方案:\n` +
`1. 升级到更强的模型(推荐):\n` +
` ollama pull codellama\n` +
@ -136,7 +197,7 @@ export class CodeAnalysisEngine {
`3. 重启应用后重试\n\n` +
`注意:超轻量模型仅适合测试连接,实际使用需要更强的模型。`;
} else {
suggestions =
suggestions =
`建议解决方案:\n` +
`1. 尝试更换更强大的模型(在 .env 中修改 VITE_LLM_MODEL\n` +
`2. 检查当前模型是否支持结构化输出JSON 格式)\n` +
@ -148,7 +209,7 @@ export class CodeAnalysisEngine {
`4. 如果使用代理,检查网络连接是否稳定\n` +
`5. 增加超时时间VITE_LLM_TIMEOUT`;
}
throw new Error(
`LLM 响应解析失败\n\n` +
`提供商: ${provider}\n` +
@ -158,7 +219,7 @@ export class CodeAnalysisEngine {
suggestions
);
}
console.log('🔍 解析结果:', {
hasIssues: Array.isArray(parsed?.issues),
issuesCount: parsed?.issues?.length || 0,
@ -169,7 +230,7 @@ export class CodeAnalysisEngine {
const issues = Array.isArray(parsed?.issues) ? parsed.issues : [];
const metrics = parsed?.metrics ?? this.estimateMetricsFromIssues(issues);
const qualityScore = parsed?.quality_score ?? this.calculateQualityScore(metrics, issues);
console.log(`📋 最终发现 ${issues.length} 个问题`);
console.log(`⭐ 质量评分: ${qualityScore}`);
@ -192,7 +253,7 @@ export class CodeAnalysisEngine {
const fixJsonFormat = (str: string): string => {
// 1. 去除前后空白
str = str.trim();
// 2. 将 JavaScript 模板字符串(反引号)替换为双引号,并处理多行内容
// 匹配: "key": `多行内容` => "key": "转义后的内容"
str = str.replace(/:\s*`([\s\S]*?)`/g, (match, content) => {
@ -207,7 +268,7 @@ export class CodeAnalysisEngine {
.replace(/\b/g, '\\b'); // 退格符
return `: "${escaped}"`;
});
// 3. 处理字符串中未转义的换行符(防御性处理)
// 匹配双引号字符串内的实际换行符
str = str.replace(/"([^"]*?)"/g, (match, content) => {
@ -222,51 +283,140 @@ export class CodeAnalysisEngine {
}
return match;
});
// 4. 修复尾部逗号JSON 不允许)
str = str.replace(/,(\s*[}\]])/g, '$1');
// 5. 修复缺少逗号的问题(两个连续的 } 或 ]
str = str.replace(/\}(\s*)\{/g, '},\n{');
str = str.replace(/\](\s*)\[/g, '],\n[');
return str;
};
try {
// 先尝试修复后直接解析
const fixed = fixJsonFormat(text);
return JSON.parse(fixed);
} catch (e1) {
// 如果失败,尝试提取 JSON 对象
try {
const match = text.match(/\{[\s\S]*\}/);
if (match) {
const fixed = fixJsonFormat(match[0]);
return JSON.parse(fixed);
// 清理和修复 JSON 字符串
const cleanText = (str: string): string => {
// 移除 BOM 和零宽字符
let cleaned = str
.replace(/^\uFEFF/, '')
.replace(/[\u200B-\u200D\uFEFF]/g, '');
// 修复字符串值中的特殊字符
// 匹配所有 JSON 字符串值(包括 description, suggestion, code_snippet 等)
cleaned = cleaned.replace(/"([^"]+)":\s*"((?:[^"\\]|\\.)*)"/g, (match, key, value) => {
// 如果值已经正确转义,跳过
if (!value.includes('\n') && !value.includes('\r') && !value.includes('\t') && !value.match(/[^\x20-\x7E]/)) {
return match;
}
} catch (e2) {
console.warn('提取 JSON 对象后解析失败:', e2);
}
// 尝试去除 markdown 代码块标记
try {
const codeBlockMatch = text.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/);
// 转义特殊字符
let escaped = value
// 先处理已存在的反斜杠
.replace(/\\/g, '\\\\')
// 转义换行符
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')
// 转义制表符
.replace(/\t/g, '\\t')
// 转义引号
.replace(/"/g, '\\"')
// 移除其他控制字符
.replace(/[\x00-\x1F\x7F]/g, '');
return `"${key}": "${escaped}"`;
});
return cleaned;
};
// 尝试多种方式解析
const attempts = [
// 1. 直接清理和修复
() => {
const cleaned = cleanText(text);
const fixed = fixJsonFormat(cleaned);
return JSON.parse(fixed);
},
// 2. 提取 JSON 对象(贪婪匹配,找到第一个完整的 JSON
() => {
const cleaned = cleanText(text);
// 找到第一个 { 的位置
const startIdx = cleaned.indexOf('{');
if (startIdx === -1) throw new Error('No JSON object found');
// 从第一个 { 开始,找到匹配的 }
let braceCount = 0;
let endIdx = -1;
for (let i = startIdx; i < cleaned.length; i++) {
if (cleaned[i] === '{') braceCount++;
if (cleaned[i] === '}') {
braceCount--;
if (braceCount === 0) {
endIdx = i + 1;
break;
}
}
}
if (endIdx === -1) throw new Error('Incomplete JSON object');
const jsonStr = cleaned.substring(startIdx, endIdx);
const fixed = fixJsonFormat(jsonStr);
return JSON.parse(fixed);
},
// 3. 去除 markdown 代码块
() => {
const cleaned = cleanText(text);
const codeBlockMatch = cleaned.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/);
if (codeBlockMatch) {
const fixed = fixJsonFormat(codeBlockMatch[1]);
return JSON.parse(fixed);
}
} catch (e3) {
console.warn('从代码块提取 JSON 失败:', e3);
throw new Error('No code block found');
},
// 4. 尝试修复截断的 JSON
() => {
const cleaned = cleanText(text);
const startIdx = cleaned.indexOf('{');
if (startIdx === -1) throw new Error('Cannot fix truncated JSON');
let json = cleaned.substring(startIdx);
// 尝试补全未闭合的结构
const openBraces = (json.match(/\{/g) || []).length;
const closeBraces = (json.match(/\}/g) || []).length;
const openBrackets = (json.match(/\[/g) || []).length;
const closeBrackets = (json.match(/\]/g) || []).length;
// 补全缺失的闭合符号
json += ']'.repeat(Math.max(0, openBrackets - closeBrackets));
json += '}'.repeat(Math.max(0, openBraces - closeBraces));
const fixed = fixJsonFormat(json);
return JSON.parse(fixed);
}
];
let lastError: any = null;
for (let i = 0; i < attempts.length; i++) {
try {
return attempts[i]();
} catch (e) {
lastError = e;
if (i === 1) {
console.warn('提取 JSON 对象后解析失败:', e);
} else if (i === 2) {
console.warn('从代码块提取 JSON 失败:', e);
}
}
console.error('⚠️ 无法解析 LLM 响应为 JSON');
console.error('原始内容前500字符:', text.substring(0, 500));
console.error('解析错误:', e1);
console.warn('💡 提示: 当前模型可能无法生成有效的 JSON 格式');
console.warn(' 建议:更换更强大的模型或切换其他 LLM 提供商');
return null;
}
// 所有尝试都失败
console.error('⚠️ 无法解析 LLM 响应为 JSON');
console.error('原始内容前500字符:', text.substring(0, 500));
console.error('解析错误:', lastError);
console.warn('💡 提示: 当前模型可能无法生成有效的 JSON 格式');
console.warn(' 建议:更换更强大的模型或切换其他 LLM 提供商');
return null;
}
private static estimateMetricsFromIssues(issues: any[]) {
@ -300,9 +450,9 @@ export class CodeAnalysisEngine {
);
const metricsScore = (
metrics.complexity +
metrics.maintainability +
metrics.security +
metrics.complexity +
metrics.maintainability +
metrics.security +
metrics.performance
) / 4;

View File

@ -15,6 +15,63 @@ function isTextFile(path: string): boolean {
}
function shouldExclude(path: string, excludePatterns: string[]): boolean {
// 排除 Mac 系统文件
if (path.includes('__MACOSX/') || path.includes('/.DS_Store') || path.match(/\/\._[^/]+$/)) {
return true;
}
// 排除 IDE 和编辑器配置目录
const idePatterns = [
'/.vscode/',
'/.idea/',
'/.vs/',
'/.eclipse/',
'/.settings/'
];
if (idePatterns.some(pattern => path.includes(pattern))) {
return true;
}
// 排除版本控制和依赖目录
const systemDirs = [
'/.git/',
'/node_modules/',
'/vendor/',
'/dist/',
'/build/',
'/.next/',
'/.nuxt/',
'/target/',
'/out/',
'/__pycache__/',
'/.pytest_cache/',
'/coverage/',
'/.nyc_output/'
];
if (systemDirs.some(dir => path.includes(dir))) {
return true;
}
// 排除其他隐藏文件(但保留 .gitignore, .env.example 等重要配置)
const allowedHiddenFiles = ['.gitignore', '.env.example', '.editorconfig', '.prettierrc'];
const fileName = path.split('/').pop() || '';
if (fileName.startsWith('.') && !allowedHiddenFiles.includes(fileName)) {
return true;
}
// 排除常见的非代码文件
const excludeExtensions = [
'.lock', '.log', '.tmp', '.temp', '.cache',
'.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico',
'.pdf', '.zip', '.tar', '.gz', '.rar',
'.exe', '.dll', '.so', '.dylib',
'.min.js', '.min.css', '.map'
];
if (excludeExtensions.some(ext => path.toLowerCase().endsWith(ext))) {
return true;
}
// 应用用户自定义的排除模式
return excludePatterns.some(pattern => {
if (pattern.includes('*')) {
const regex = new RegExp(pattern.replace(/\*/g, '.*'));

View File

@ -1,157 +1,159 @@
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Progress } from "@/components/ui/progress";
import {
Users,
Settings,
Shield,
Activity,
AlertTriangle,
Search,
Edit,
Trash2,
UserPlus,
Database,
Server,
BarChart3
HardDrive,
RefreshCw,
Info,
CheckCircle2,
AlertCircle,
FolderOpen,
Clock,
AlertTriangle,
TrendingUp,
Package
} from "lucide-react";
import { api } from "@/shared/config/database";
import type { Profile } from "@/shared/types";
import { api, dbMode, isLocalMode } from "@/shared/config/database";
import { DatabaseManager } from "@/components/database/DatabaseManager";
import { DatabaseStatusDetail } from "@/components/database/DatabaseStatus";
import { toast } from "sonner";
export default function AdminDashboard() {
const user = null as any;
const [profiles, setProfiles] = useState<Profile[]>([]);
const [stats, setStats] = useState({
totalProjects: 0,
activeProjects: 0,
totalTasks: 0,
completedTasks: 0,
totalIssues: 0,
resolvedIssues: 0,
storageUsed: '计算中...',
storageQuota: '未知'
});
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [editingUser, setEditingUser] = useState<Profile | null>(null);
const [showEditDialog, setShowEditDialog] = useState(false);
const [storageDetails, setStorageDetails] = useState<{
usage: number;
quota: number;
percentage: number;
} | null>(null);
useEffect(() => {
loadProfiles();
loadStats();
}, []);
const loadProfiles = async () => {
const loadStats = async () => {
try {
setLoading(true);
const data = await api.getAllProfiles();
setProfiles(data);
const projectStats = await api.getProjectStats();
// 获取存储使用量IndexedDB
let storageUsed = '未知';
let storageQuota = '未知';
let details = null;
if ('storage' in navigator && 'estimate' in navigator.storage) {
try {
const estimate = await navigator.storage.estimate();
const usedMB = ((estimate.usage || 0) / 1024 / 1024).toFixed(2);
const quotaMB = ((estimate.quota || 0) / 1024 / 1024).toFixed(2);
const percentage = estimate.quota ? ((estimate.usage || 0) / estimate.quota * 100) : 0;
storageUsed = `${usedMB} MB`;
storageQuota = `${quotaMB} MB`;
details = {
usage: estimate.usage || 0,
quota: estimate.quota || 0,
percentage: Math.round(percentage)
};
} catch (e) {
console.error('Failed to estimate storage:', e);
}
}
setStats({
totalProjects: projectStats.total_projects || 0,
activeProjects: projectStats.active_projects || 0,
totalTasks: projectStats.total_tasks || 0,
completedTasks: projectStats.completed_tasks || 0,
totalIssues: projectStats.total_issues || 0,
resolvedIssues: projectStats.resolved_issues || 0,
storageUsed,
storageQuota
});
setStorageDetails(details);
} catch (error) {
console.error('Failed to load profiles:', error);
toast.error("加载用户数据失败");
console.error('Failed to load stats:', error);
toast.error("加载统计数据失败");
} finally {
setLoading(false);
}
};
const handleUpdateUserRole = async (userId: string, newRole: 'admin' | 'member') => {
try {
await api.updateProfile(userId, { role: newRole });
toast.success("用户角色更新成功");
loadProfiles();
} catch (error) {
console.error('Failed to update user role:', error);
toast.error("更新用户角色失败");
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const filteredProfiles = profiles.filter(profile =>
profile.full_name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
profile.phone?.toLowerCase().includes(searchTerm.toLowerCase()) ||
profile.email?.toLowerCase().includes(searchTerm.toLowerCase())
);
// 检查当前用户是否为管理员
const isAdmin = true;
if (!isAdmin) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<Shield className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-gray-900 mb-2">访</h2>
<p className="text-gray-600 mb-4">访</p>
</div>
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary"></div>
<div className="space-y-4 text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-primary mx-auto"></div>
<p className="text-muted-foreground">...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="space-y-6 pb-8">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
<Database className="h-8 w-8 text-primary" />
</h1>
<p className="text-gray-600 mt-2">
使
</p>
</div>
<Badge variant="outline" className="bg-red-50 text-red-700">
<Shield className="w-4 h-4 mr-2" />
</Badge>
<Button variant="outline" onClick={loadStats}>
<RefreshCw className="w-4 h-4 mr-2" />
</Button>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<Users className="h-8 w-8 text-primary" />
<div className="ml-4">
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold">{profiles.length}</p>
</div>
</div>
</CardContent>
</Card>
{/* 数据库模式提示 */}
{!isLocalMode && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
使 <strong>{dbMode === 'supabase' ? 'Supabase 云端' : '演示'}</strong>
{dbMode === 'demo' && ' 请在 .env 文件中配置 VITE_USE_LOCAL_DB=true 启用本地数据库。'}
</AlertDescription>
</Alert>
)}
{/* 数据库状态卡片 */}
<DatabaseStatusDetail />
{/* 统计概览 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<Shield className="h-8 w-8 text-green-600" />
<div className="ml-4">
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold">
{profiles.filter(p => p.role === 'admin').length}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-3xl font-bold mt-2">{stats.totalProjects}</p>
<p className="text-xs text-muted-foreground mt-1">
: {stats.activeProjects}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<Activity className="h-8 w-8 text-orange-600" />
<div className="ml-4">
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold">
{profiles.filter(p => p.role === 'member').length}
</p>
<div className="h-12 w-12 bg-primary/10 rounded-full flex items-center justify-center">
<FolderOpen className="h-6 w-6 text-primary" />
</div>
</div>
</CardContent>
@ -159,206 +161,184 @@ export default function AdminDashboard() {
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<Server className="h-8 w-8 text-purple-600" />
<div className="ml-4">
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold text-green-600"></p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-3xl font-bold mt-2">{stats.totalTasks}</p>
<p className="text-xs text-muted-foreground mt-1">
: {stats.completedTasks}
</p>
</div>
<div className="h-12 w-12 bg-green-100 rounded-full flex items-center justify-center">
<Clock className="h-6 w-6 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-3xl font-bold mt-2">{stats.totalIssues}</p>
<p className="text-xs text-muted-foreground mt-1">
: {stats.resolvedIssues}
</p>
</div>
<div className="h-12 w-12 bg-orange-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-6 w-6 text-orange-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">使</p>
<p className="text-3xl font-bold mt-2">{stats.storageUsed}</p>
<p className="text-xs text-muted-foreground mt-1">
: {stats.storageQuota}
</p>
</div>
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
<HardDrive className="h-6 w-6 text-purple-600" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* 主要内容 */}
<Tabs defaultValue="users" className="w-full">
{/* 主要内容标签页 */}
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="users"></TabsTrigger>
<TabsTrigger value="system"></TabsTrigger>
<TabsTrigger value="settings"></TabsTrigger>
<TabsTrigger value="logs"></TabsTrigger>
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="storage"></TabsTrigger>
<TabsTrigger value="operations"></TabsTrigger>
<TabsTrigger value="settings"></TabsTrigger>
</TabsList>
<TabsContent value="users" className="space-y-6">
{/* 搜索和操作 */}
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1 relative mr-4">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="搜索用户姓名、手机号或邮箱..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
{/* 数据概览 */}
<TabsContent value="overview" className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 任务完成率 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<TrendingUp className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span></span>
<span className="font-medium">
{stats.totalTasks > 0
? Math.round((stats.completedTasks / stats.totalTasks) * 100)
: 0}%
</span>
</div>
<Progress
value={stats.totalTasks > 0
? (stats.completedTasks / stats.totalTasks) * 100
: 0
}
/>
</div>
<Button>
<UserPlus className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
{/* 用户列表 */}
<Card>
<CardHeader>
<CardTitle> ({filteredProfiles.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{filteredProfiles.map((profile) => (
<div key={profile.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center space-x-4">
<div className="w-10 h-10 bg-gradient-to-r from-primary to-accent rounded-full flex items-center justify-center text-white font-medium">
{profile.full_name?.charAt(0) || profile.phone?.charAt(0) || 'U'}
</div>
<div>
<h4 className="font-medium">
{profile.full_name || '未设置姓名'}
</h4>
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
{profile.phone && (
<span>📱 {profile.phone}</span>
)}
{profile.email && (
<span>📧 {profile.email}</span>
)}
</div>
<p className="text-xs text-muted-foreground">
{formatDate(profile.created_at)}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Badge variant={profile.role === 'admin' ? 'default' : 'secondary'}>
{profile.role === 'admin' ? '管理员' : '普通用户'}
</Badge>
{profile.id !== user?.id && (
<Select
value={profile.role}
onValueChange={(value: 'admin' | 'member') =>
handleUpdateUserRole(profile.id, value)
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="member"></SelectItem>
<SelectItem value="admin"></SelectItem>
</SelectContent>
</Select>
)}
<Button variant="outline" size="sm">
<Edit className="w-4 h-4" />
</Button>
</div>
<div className="grid grid-cols-2 gap-4 pt-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">{stats.totalTasks}</p>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="system" className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 系统性能 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<BarChart3 className="w-5 h-5 mr-2" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">CPU 使</span>
<span className="text-sm text-muted-foreground">15%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="bg-primary h-2 rounded-full" style={{ width: '15%' }}></div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">使</span>
<span className="text-sm text-muted-foreground">32%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="bg-green-600 h-2 rounded-full" style={{ width: '32%' }}></div>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">使</span>
<span className="text-sm text-muted-foreground">58%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div className="bg-orange-600 h-2 rounded-full" style={{ width: '58%' }}></div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-green-600">{stats.completedTasks}</p>
</div>
</div>
</CardContent>
</Card>
{/* 数据库状态 */}
{/* 问题解决率 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Database className="w-5 h-5 mr-2" />
<CardTitle className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<Badge className="bg-green-100 text-green-800"></Badge>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span></span>
<span className="font-medium">
{stats.totalIssues > 0
? Math.round((stats.resolvedIssues / stats.totalIssues) * 100)
: 0}%
</span>
</div>
<Progress
value={stats.totalIssues > 0
? (stats.resolvedIssues / stats.totalIssues) * 100
: 0
}
className="bg-orange-100"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<span className="text-sm text-muted-foreground">12</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<span className="text-sm text-muted-foreground">45ms</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">使</span>
<span className="text-sm text-muted-foreground">2.3GB</span>
<div className="grid grid-cols-2 gap-4 pt-4">
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">{stats.totalIssues}</p>
</div>
<div className="space-y-1">
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold text-green-600">{stats.resolvedIssues}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 系统日志 */}
{/* 数据库表统计 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardTitle className="flex items-center gap-2">
<Package className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center space-x-3 p-3 bg-green-50 rounded-lg">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<div className="flex-1">
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground">2024-01-15 09:00:00</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<div className="p-4 border rounded-lg">
<div className="flex items-center gap-3">
<FolderOpen className="h-8 w-8 text-primary" />
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">{stats.totalProjects}</p>
</div>
</div>
</div>
<div className="flex items-center space-x-3 p-3 bg-red-50 rounded-lg">
<div className="w-2 h-2 bg-primary rounded-full"></div>
<div className="flex-1">
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground">2024-01-15 08:45:00</p>
<div className="p-4 border rounded-lg">
<div className="flex items-center gap-3">
<Clock className="h-8 w-8 text-green-600" />
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">{stats.totalTasks}</p>
</div>
</div>
</div>
<div className="flex items-center space-x-3 p-3 bg-yellow-50 rounded-lg">
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
<div className="flex-1">
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground">2024-01-15 08:30:00</p>
<div className="p-4 border rounded-lg">
<div className="flex items-center gap-3">
<AlertTriangle className="h-8 w-8 text-orange-600" />
<div>
<p className="text-sm text-muted-foreground"></p>
<p className="text-2xl font-bold">{stats.totalIssues}</p>
</div>
</div>
</div>
</div>
@ -366,22 +346,182 @@ export default function AdminDashboard() {
</Card>
</TabsContent>
{/* 存储管理 */}
<TabsContent value="storage" className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardDrive className="h-5 w-5" />
使
</CardTitle>
<CardDescription>
IndexedDB 使
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{storageDetails ? (
<>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>使</span>
<span className="font-medium">{storageDetails.percentage}%</span>
</div>
<Progress value={storageDetails.percentage} />
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{stats.storageUsed} 使</span>
<span>{stats.storageQuota} </span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-4">
<div className="p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground mb-1">使</p>
<p className="text-xl font-bold">{stats.storageUsed}</p>
</div>
<div className="p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-xl font-bold">{stats.storageQuota}</p>
</div>
<div className="p-4 bg-muted rounded-lg">
<p className="text-sm text-muted-foreground mb-1"></p>
<p className="text-xl font-bold">
{((storageDetails.quota - storageDetails.usage) / 1024 / 1024).toFixed(2)} MB
</p>
</div>
</div>
{storageDetails.percentage > 80 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
使 80%
</AlertDescription>
</Alert>
)}
</>
) : (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
Storage API
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-start gap-3 p-3 bg-muted rounded-lg">
<CheckCircle2 className="h-5 w-5 text-green-600 mt-0.5" />
<div>
<p className="font-medium"></p>
<p className="text-sm text-muted-foreground">
JSON
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-muted rounded-lg">
<CheckCircle2 className="h-5 w-5 text-green-600 mt-0.5" />
<div>
<p className="font-medium"></p>
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-muted rounded-lg">
<CheckCircle2 className="h-5 w-5 text-green-600 mt-0.5" />
<div>
<p className="font-medium">使</p>
<p className="text-sm text-muted-foreground">
使
</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 数据操作 */}
<TabsContent value="operations" className="space-y-6">
<DatabaseManager />
</TabsContent>
{/* 设置 */}
<TabsContent value="settings" className="space-y-6">
<div className="text-center py-12">
<Settings className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-muted-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"></p>
</div>
</TabsContent>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong></strong> {dbMode === 'local' ? '本地 IndexedDB' : dbMode === 'supabase' ? 'Supabase 云端' : '演示模式'}
</AlertDescription>
</Alert>
<TabsContent value="logs" className="space-y-6">
<div className="text-center py-12">
<Activity className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium text-muted-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="space-y-4 pt-4">
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium"></p>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Badge variant="outline"></Badge>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium"></p>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Badge variant="outline"></Badge>
</div>
<div className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-medium"></p>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Badge variant="outline"></Badge>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<p>
使 IndexedDB
</p>
<ul className="list-disc list-inside space-y-2 ml-2">
<li></li>
<li>线访</li>
<li></li>
<li></li>
<li></li>
</ul>
<p className="pt-2">
<strong></strong>
</p>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
}

View File

@ -147,7 +147,7 @@ export default function Projects() {
projectId: project.id,
zipFile: file,
excludePatterns: ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
createdBy: undefined
createdBy: 'local-user' // 使用默认本地用户ID
});
clearInterval(progressInterval);

View File

@ -1,4 +1,5 @@
import { createClient } from "@supabase/supabase-js";
import { localDB } from "./localDatabase";
import type {
Profile,
Project,
@ -9,10 +10,11 @@ import type {
CreateProjectForm,
CreateAuditTaskForm,
InstantAnalysisForm
} from "@/types/types";
} from "../types/index";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
const useLocalDB = import.meta.env.VITE_USE_LOCAL_DB === 'true';
const isValidUuid = (value?: string): boolean => {
if (!value) return false;
@ -35,19 +37,21 @@ export const supabase = hasSupabaseConfig ? createClient(finalSupabaseUrl, final
}
}) : null;
// 演示模式标识
export const isDemoMode = !hasSupabaseConfig;
// 数据库模式local本地IndexedDB、supabase云端、demo演示模式
export const dbMode = useLocalDB ? 'local' : (hasSupabaseConfig ? 'supabase' : 'demo');
export const isDemoMode = dbMode === 'demo';
export const isLocalMode = dbMode === 'local';
// 演示数据
const demoProfile: Profile = {
id: 'demo-user',
phone: null,
phone: undefined,
email: 'demo@xcodereviewer.com',
full_name: 'Demo User',
avatar_url: null,
avatar_url: undefined,
role: 'admin',
github_username: 'demo-user',
gitlab_username: null,
gitlab_username: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
@ -60,6 +64,10 @@ export const api = {
return demoProfile;
}
if (isLocalMode) {
return localDB.getProfileById(id);
}
if (!supabase) return null;
const { data, error } = await supabase
@ -77,6 +85,10 @@ export const api = {
return 1;
}
if (isLocalMode) {
return localDB.getProfilesCount();
}
if (!supabase) return 0;
const { count, error } = await supabase
@ -88,6 +100,12 @@ export const api = {
},
async createProfiles(profile: Partial<Profile>): Promise<Profile> {
if (isLocalMode) {
return localDB.createProfile(profile);
}
if (!supabase) throw new Error('Database not available');
const { data, error } = await supabase
.from('profiles')
.insert([{
@ -108,6 +126,12 @@ export const api = {
},
async updateProfile(id: string, updates: Partial<Profile>): Promise<Profile> {
if (isLocalMode) {
return localDB.updateProfile(id, updates);
}
if (!supabase) throw new Error('Database not available');
const { data, error } = await supabase
.from('profiles')
.update(updates)
@ -120,6 +144,12 @@ export const api = {
},
async getAllProfiles(): Promise<Profile[]> {
if (isLocalMode) {
return localDB.getAllProfiles();
}
if (!supabase) return [];
const { data, error } = await supabase
.from('profiles')
.select('*')
@ -148,6 +178,10 @@ export const api = {
}];
}
if (isLocalMode) {
return localDB.getProjects();
}
if (!supabase) return [];
const { data, error } = await supabase
@ -164,6 +198,12 @@ export const api = {
},
async getProjectById(id: string): Promise<Project | null> {
if (isLocalMode) {
return localDB.getProjectById(id);
}
if (!supabase) return null;
const { data, error } = await supabase
.from('projects')
.select(`
@ -178,6 +218,12 @@ export const api = {
},
async createProject(project: CreateProjectForm & { owner_id?: string }): Promise<Project> {
if (isLocalMode) {
return localDB.createProject(project);
}
if (!supabase) throw new Error('Database not available');
const { data, error } = await supabase
.from('projects')
.insert([{
@ -201,6 +247,12 @@ export const api = {
},
async updateProject(id: string, updates: Partial<CreateProjectForm>): Promise<Project> {
if (isLocalMode) {
return localDB.updateProject(id, updates);
}
if (!supabase) throw new Error('Database not available');
const updateData: any = { ...updates };
if (updates.programming_languages) {
updateData.programming_languages = JSON.stringify(updates.programming_languages);
@ -221,6 +273,12 @@ export const api = {
},
async deleteProject(id: string): Promise<void> {
if (isLocalMode) {
return localDB.deleteProject(id);
}
if (!supabase) throw new Error('Database not available');
const { error } = await supabase
.from('projects')
.update({ is_active: false })
@ -231,6 +289,12 @@ export const api = {
// ProjectMember相关
async getProjectMembers(projectId: string): Promise<ProjectMember[]> {
if (isLocalMode) {
return localDB.getProjectMembers(projectId);
}
if (!supabase) return [];
const { data, error } = await supabase
.from('project_members')
.select(`
@ -246,6 +310,12 @@ export const api = {
},
async addProjectMember(projectId: string, userId: string, role: string = 'member'): Promise<ProjectMember> {
if (isLocalMode) {
return localDB.addProjectMember(projectId, userId, role);
}
if (!supabase) throw new Error('Database not available');
const { data, error } = await supabase
.from('project_members')
.insert([{
@ -267,6 +337,12 @@ export const api = {
// AuditTask相关
async getAuditTasks(projectId?: string): Promise<AuditTask[]> {
if (isLocalMode) {
return localDB.getAuditTasks(projectId);
}
if (!supabase) return [];
let query = supabase
.from('audit_tasks')
.select(`
@ -286,6 +362,12 @@ export const api = {
},
async getAuditTaskById(id: string): Promise<AuditTask | null> {
if (isLocalMode) {
return localDB.getAuditTaskById(id);
}
if (!supabase) return null;
const { data, error } = await supabase
.from('audit_tasks')
.select(`
@ -301,6 +383,12 @@ export const api = {
},
async createAuditTask(task: CreateAuditTaskForm & { created_by: string }): Promise<AuditTask> {
if (isLocalMode) {
return localDB.createAuditTask(task);
}
if (!supabase) throw new Error('Database not available');
const { data, error } = await supabase
.from('audit_tasks')
.insert([{
@ -324,6 +412,12 @@ export const api = {
},
async updateAuditTask(id: string, updates: Partial<AuditTask>): Promise<AuditTask> {
if (isLocalMode) {
return localDB.updateAuditTask(id, updates);
}
if (!supabase) throw new Error('Database not available');
const { data, error } = await supabase
.from('audit_tasks')
.update(updates)
@ -341,6 +435,12 @@ export const api = {
// AuditIssue相关
async getAuditIssues(taskId: string): Promise<AuditIssue[]> {
if (isLocalMode) {
return localDB.getAuditIssues(taskId);
}
if (!supabase) return [];
const { data, error } = await supabase
.from('audit_issues')
.select(`
@ -357,6 +457,12 @@ export const api = {
},
async createAuditIssue(issue: Omit<AuditIssue, 'id' | 'created_at' | 'task' | 'resolver'>): Promise<AuditIssue> {
if (isLocalMode) {
return localDB.createAuditIssue(issue);
}
if (!supabase) throw new Error('Database not available');
const { data, error } = await supabase
.from('audit_issues')
.insert([issue])
@ -372,6 +478,12 @@ export const api = {
},
async updateAuditIssue(id: string, updates: Partial<AuditIssue>): Promise<AuditIssue> {
if (isLocalMode) {
return localDB.updateAuditIssue(id, updates);
}
if (!supabase) throw new Error('Database not available');
const { data, error } = await supabase
.from('audit_issues')
.update(updates)
@ -389,6 +501,12 @@ export const api = {
// InstantAnalysis相关
async getInstantAnalyses(userId?: string): Promise<InstantAnalysis[]> {
if (isLocalMode) {
return localDB.getInstantAnalyses(userId);
}
if (!supabase) return [];
let query = supabase
.from('instant_analyses')
.select(`
@ -413,6 +531,12 @@ export const api = {
quality_score?: number;
analysis_time?: number;
}): Promise<InstantAnalysis> {
if (isLocalMode) {
return localDB.createInstantAnalysis(analysis);
}
if (!supabase) throw new Error('Database not available');
const { data, error } = await supabase
.from('instant_analyses')
.insert([{
@ -448,6 +572,10 @@ export const api = {
};
}
if (isLocalMode) {
return localDB.getProjectStats();
}
if (!supabase) {
return {
total_projects: 0,

View File

@ -77,6 +77,9 @@ export const env = {
MAX_ANALYZE_FILES: Number(import.meta.env.VITE_MAX_ANALYZE_FILES) || 40,
LLM_CONCURRENCY: Number(import.meta.env.VITE_LLM_CONCURRENCY) || 2,
LLM_GAP_MS: Number(import.meta.env.VITE_LLM_GAP_MS) || 500,
// ==================== 语言配置 ====================
OUTPUT_LANGUAGE: import.meta.env.VITE_OUTPUT_LANGUAGE || 'zh-CN', // zh-CN | en-US
// ==================== 开发环境标识 ====================
isDev: import.meta.env.DEV,

View File

@ -0,0 +1,591 @@
/**
* - 使 IndexedDB
* Supabase API
*/
import type {
Profile,
Project,
ProjectMember,
AuditTask,
AuditIssue,
InstantAnalysis,
CreateProjectForm,
CreateAuditTaskForm,
InstantAnalysisForm
} from "../types/index";
const DB_NAME = 'xcodereviewer_local';
const DB_VERSION = 1;
// 数据库表名
const STORES = {
PROFILES: 'profiles',
PROJECTS: 'projects',
PROJECT_MEMBERS: 'project_members',
AUDIT_TASKS: 'audit_tasks',
AUDIT_ISSUES: 'audit_issues',
INSTANT_ANALYSES: 'instant_analyses',
} as const;
class LocalDatabase {
private db: IDBDatabase | null = null;
private initPromise: Promise<void> | null = null;
/**
*
*/
async init(): Promise<void> {
if (this.db) return;
if (this.initPromise) return this.initPromise;
this.initPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
// 创建 profiles 表
if (!db.objectStoreNames.contains(STORES.PROFILES)) {
const profileStore = db.createObjectStore(STORES.PROFILES, { keyPath: 'id' });
profileStore.createIndex('email', 'email', { unique: false });
profileStore.createIndex('role', 'role', { unique: false });
}
// 创建 projects 表
if (!db.objectStoreNames.contains(STORES.PROJECTS)) {
const projectStore = db.createObjectStore(STORES.PROJECTS, { keyPath: 'id' });
projectStore.createIndex('owner_id', 'owner_id', { unique: false });
projectStore.createIndex('is_active', 'is_active', { unique: false });
projectStore.createIndex('created_at', 'created_at', { unique: false });
}
// 创建 project_members 表
if (!db.objectStoreNames.contains(STORES.PROJECT_MEMBERS)) {
const memberStore = db.createObjectStore(STORES.PROJECT_MEMBERS, { keyPath: 'id' });
memberStore.createIndex('project_id', 'project_id', { unique: false });
memberStore.createIndex('user_id', 'user_id', { unique: false });
}
// 创建 audit_tasks 表
if (!db.objectStoreNames.contains(STORES.AUDIT_TASKS)) {
const taskStore = db.createObjectStore(STORES.AUDIT_TASKS, { keyPath: 'id' });
taskStore.createIndex('project_id', 'project_id', { unique: false });
taskStore.createIndex('created_by', 'created_by', { unique: false });
taskStore.createIndex('status', 'status', { unique: false });
taskStore.createIndex('created_at', 'created_at', { unique: false });
}
// 创建 audit_issues 表
if (!db.objectStoreNames.contains(STORES.AUDIT_ISSUES)) {
const issueStore = db.createObjectStore(STORES.AUDIT_ISSUES, { keyPath: 'id' });
issueStore.createIndex('task_id', 'task_id', { unique: false });
issueStore.createIndex('severity', 'severity', { unique: false });
issueStore.createIndex('status', 'status', { unique: false });
}
// 创建 instant_analyses 表
if (!db.objectStoreNames.contains(STORES.INSTANT_ANALYSES)) {
const analysisStore = db.createObjectStore(STORES.INSTANT_ANALYSES, { keyPath: 'id' });
analysisStore.createIndex('user_id', 'user_id', { unique: false });
analysisStore.createIndex('created_at', 'created_at', { unique: false });
}
};
});
return this.initPromise;
}
/**
* UUID
*/
private generateId(): string {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
/**
*
*/
private getStore(storeName: string, mode: IDBTransactionMode = 'readonly'): IDBObjectStore {
if (!this.db) throw new Error('Database not initialized');
const transaction = this.db.transaction(storeName, mode);
return transaction.objectStore(storeName);
}
/**
*
*/
private async getAll<T>(storeName: string): Promise<T[]> {
await this.init();
return new Promise((resolve, reject) => {
const store = this.getStore(storeName);
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
* ID
*/
private async getById<T>(storeName: string, id: string): Promise<T | null> {
if (!id) return null;
await this.init();
return new Promise((resolve, reject) => {
const store = this.getStore(storeName);
const request = store.get(id);
request.onsuccess = () => resolve(request.result || null);
request.onerror = () => reject(request.error);
});
}
/**
*
*/
private async getByIndex<T>(storeName: string, indexName: string, value: any): Promise<T[]> {
await this.init();
return new Promise((resolve, reject) => {
const store = this.getStore(storeName);
const index = store.index(indexName);
const request = index.getAll(value);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
/**
*
*/
private async put<T>(storeName: string, data: T): Promise<T> {
await this.init();
return new Promise((resolve, reject) => {
const store = this.getStore(storeName, 'readwrite');
const request = store.put(data);
request.onsuccess = () => resolve(data);
request.onerror = () => reject(request.error);
});
}
/**
*
*/
private async deleteRecord(storeName: string, id: string): Promise<void> {
await this.init();
return new Promise((resolve, reject) => {
const store = this.getStore(storeName, 'readwrite');
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
/**
*
*/
private async count(storeName: string): Promise<number> {
await this.init();
return new Promise((resolve, reject) => {
const store = this.getStore(storeName);
const request = store.count();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
// ==================== Profile 相关方法 ====================
async getProfileById(id: string): Promise<Profile | null> {
return this.getById<Profile>(STORES.PROFILES, id);
}
async getProfilesCount(): Promise<number> {
return this.count(STORES.PROFILES);
}
async createProfile(profile: Partial<Profile>): Promise<Profile> {
const newProfile: Profile = {
id: profile.id || this.generateId(),
phone: profile.phone || undefined,
email: profile.email || undefined,
full_name: profile.full_name || undefined,
avatar_url: profile.avatar_url || undefined,
role: profile.role || 'member',
github_username: profile.github_username || undefined,
gitlab_username: profile.gitlab_username || undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
return this.put(STORES.PROFILES, newProfile);
}
async updateProfile(id: string, updates: Partial<Profile>): Promise<Profile> {
const existing = await this.getProfileById(id);
if (!existing) throw new Error('Profile not found');
const updated: Profile = {
...existing,
...updates,
id,
updated_at: new Date().toISOString(),
};
return this.put(STORES.PROFILES, updated);
}
async getAllProfiles(): Promise<Profile[]> {
return this.getAll<Profile>(STORES.PROFILES);
}
// ==================== Project 相关方法 ====================
async getProjects(): Promise<Project[]> {
const projects = await this.getAll<Project>(STORES.PROJECTS);
const activeProjects = projects.filter(p => p.is_active);
// 关联 owner 信息
const projectsWithOwner = await Promise.all(
activeProjects.map(async (project) => {
const owner = project.owner_id ? await this.getProfileById(project.owner_id) : null;
return { ...project, owner: owner || undefined };
})
);
return projectsWithOwner.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
}
async getProjectById(id: string): Promise<Project | null> {
if (!id) return null;
const project = await this.getById<Project>(STORES.PROJECTS, id);
if (!project) return null;
const owner = project.owner_id ? await this.getProfileById(project.owner_id) : null;
return { ...project, owner: owner || undefined };
}
async createProject(projectData: CreateProjectForm & { owner_id?: string }): Promise<Project> {
const newProject: Project = {
id: this.generateId(),
name: projectData.name,
description: projectData.description || undefined,
repository_url: projectData.repository_url || undefined,
repository_type: projectData.repository_type || 'other',
default_branch: projectData.default_branch || 'main',
programming_languages: JSON.stringify(projectData.programming_languages || []),
owner_id: projectData.owner_id || 'local-user',
is_active: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
await this.put(STORES.PROJECTS, newProject);
return this.getProjectById(newProject.id) as Promise<Project>;
}
async updateProject(id: string, updates: Partial<CreateProjectForm>): Promise<Project> {
const existing = await this.getById<Project>(STORES.PROJECTS, id);
if (!existing) throw new Error('Project not found');
const updateData: any = { ...updates };
if (updates.programming_languages) {
updateData.programming_languages = JSON.stringify(updates.programming_languages);
}
const updated: Project = {
...existing,
...updateData,
id,
updated_at: new Date().toISOString(),
};
await this.put(STORES.PROJECTS, updated);
return this.getProjectById(id) as Promise<Project>;
}
async deleteProject(id: string): Promise<void> {
const existing = await this.getById<Project>(STORES.PROJECTS, id);
if (!existing) throw new Error('Project not found');
const updated: Project = {
...existing,
is_active: false,
updated_at: new Date().toISOString(),
};
await this.put(STORES.PROJECTS, updated);
}
// ==================== ProjectMember 相关方法 ====================
async getProjectMembers(projectId: string): Promise<ProjectMember[]> {
const members = await this.getByIndex<ProjectMember>(STORES.PROJECT_MEMBERS, 'project_id', projectId);
const membersWithRelations = await Promise.all(
members.map(async (member) => {
const user = member.user_id ? await this.getProfileById(member.user_id) : null;
const project = member.project_id ? await this.getProjectById(member.project_id) : null;
return {
...member,
user: user || undefined,
project: project || undefined
};
})
);
return membersWithRelations.sort((a, b) =>
new Date(b.joined_at).getTime() - new Date(a.joined_at).getTime()
);
}
async addProjectMember(projectId: string, userId: string, role: string = 'member'): Promise<ProjectMember> {
const newMember: ProjectMember = {
id: this.generateId(),
project_id: projectId,
user_id: userId,
role: role as any,
permissions: '{}',
joined_at: new Date().toISOString(),
created_at: new Date().toISOString(),
};
await this.put(STORES.PROJECT_MEMBERS, newMember);
const user = userId ? await this.getProfileById(userId) : null;
const project = projectId ? await this.getProjectById(projectId) : null;
return {
...newMember,
user: user || undefined,
project: project || undefined
};
}
// ==================== AuditTask 相关方法 ====================
async getAuditTasks(projectId?: string): Promise<AuditTask[]> {
let tasks: AuditTask[];
if (projectId) {
tasks = await this.getByIndex<AuditTask>(STORES.AUDIT_TASKS, 'project_id', projectId);
} else {
tasks = await this.getAll<AuditTask>(STORES.AUDIT_TASKS);
}
const tasksWithRelations = await Promise.all(
tasks.map(async (task) => {
const project = task.project_id ? await this.getProjectById(task.project_id) : null;
const creator = task.created_by ? await this.getProfileById(task.created_by) : null;
return {
...task,
project: project || undefined,
creator: creator || undefined
};
})
);
return tasksWithRelations.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
}
async getAuditTaskById(id: string): Promise<AuditTask | null> {
if (!id) return null;
const task = await this.getById<AuditTask>(STORES.AUDIT_TASKS, id);
if (!task) return null;
const project = task.project_id ? await this.getProjectById(task.project_id) : null;
const creator = task.created_by ? await this.getProfileById(task.created_by) : null;
return {
...task,
project: project || undefined,
creator: creator || undefined
};
}
async createAuditTask(taskData: CreateAuditTaskForm & { created_by: string }): Promise<AuditTask> {
const newTask: AuditTask = {
id: this.generateId(),
project_id: taskData.project_id,
task_type: taskData.task_type,
status: 'pending',
branch_name: taskData.branch_name || undefined,
exclude_patterns: JSON.stringify(taskData.exclude_patterns || []),
scan_config: JSON.stringify(taskData.scan_config || {}),
total_files: 0,
scanned_files: 0,
total_lines: 0,
issues_count: 0,
quality_score: 0,
started_at: undefined,
completed_at: undefined,
created_by: taskData.created_by,
created_at: new Date().toISOString(),
};
await this.put(STORES.AUDIT_TASKS, newTask);
return this.getAuditTaskById(newTask.id) as Promise<AuditTask>;
}
async updateAuditTask(id: string, updates: Partial<AuditTask>): Promise<AuditTask> {
const existing = await this.getById<AuditTask>(STORES.AUDIT_TASKS, id);
if (!existing) throw new Error('Audit task not found');
const updated: AuditTask = {
...existing,
...updates,
id,
};
await this.put(STORES.AUDIT_TASKS, updated);
return this.getAuditTaskById(id) as Promise<AuditTask>;
}
// ==================== AuditIssue 相关方法 ====================
async getAuditIssues(taskId: string): Promise<AuditIssue[]> {
const issues = await this.getByIndex<AuditIssue>(STORES.AUDIT_ISSUES, 'task_id', taskId);
const issuesWithRelations = await Promise.all(
issues.map(async (issue) => {
const task = issue.task_id ? await this.getAuditTaskById(issue.task_id) : null;
const resolver = issue.resolved_by ? await this.getProfileById(issue.resolved_by) : null;
return {
...issue,
task: task || undefined,
resolver: resolver || undefined
};
})
);
// 按严重程度和创建时间排序
const severityOrder: Record<string, number> = { critical: 0, high: 1, medium: 2, low: 3 };
return issuesWithRelations.sort((a, b) => {
const severityDiff = (severityOrder[a.severity] || 999) - (severityOrder[b.severity] || 999);
if (severityDiff !== 0) return severityDiff;
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
}
async createAuditIssue(issueData: Omit<AuditIssue, 'id' | 'created_at' | 'task' | 'resolver'>): Promise<AuditIssue> {
const newIssue: AuditIssue = {
...issueData,
id: this.generateId(),
created_at: new Date().toISOString(),
};
await this.put(STORES.AUDIT_ISSUES, newIssue);
const task = newIssue.task_id ? await this.getAuditTaskById(newIssue.task_id) : null;
const resolver = newIssue.resolved_by ? await this.getProfileById(newIssue.resolved_by) : null;
return {
...newIssue,
task: task || undefined,
resolver: resolver || undefined
};
}
async updateAuditIssue(id: string, updates: Partial<AuditIssue>): Promise<AuditIssue> {
const existing = await this.getById<AuditIssue>(STORES.AUDIT_ISSUES, id);
if (!existing) throw new Error('Audit issue not found');
const updated: AuditIssue = {
...existing,
...updates,
id,
};
await this.put(STORES.AUDIT_ISSUES, updated);
const task = updated.task_id ? await this.getAuditTaskById(updated.task_id) : null;
const resolver = updated.resolved_by ? await this.getProfileById(updated.resolved_by) : null;
return {
...updated,
task: task || undefined,
resolver: resolver || undefined
};
}
// ==================== InstantAnalysis 相关方法 ====================
async getInstantAnalyses(userId?: string): Promise<InstantAnalysis[]> {
let analyses: InstantAnalysis[];
if (userId) {
analyses = await this.getByIndex<InstantAnalysis>(STORES.INSTANT_ANALYSES, 'user_id', userId);
} else {
analyses = await this.getAll<InstantAnalysis>(STORES.INSTANT_ANALYSES);
}
const analysesWithUser = await Promise.all(
analyses.map(async (analysis) => {
const user = analysis.user_id ? await this.getProfileById(analysis.user_id) : null;
return { ...analysis, user: user || undefined };
})
);
return analysesWithUser.sort((a, b) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
}
async createInstantAnalysis(analysisData: InstantAnalysisForm & {
user_id: string;
analysis_result?: string;
issues_count?: number;
quality_score?: number;
analysis_time?: number;
}): Promise<InstantAnalysis> {
const newAnalysis: InstantAnalysis = {
id: this.generateId(),
user_id: analysisData.user_id,
language: analysisData.language,
code_content: '', // 不持久化代码内容
analysis_result: analysisData.analysis_result || '{}',
issues_count: analysisData.issues_count || 0,
quality_score: analysisData.quality_score || 0,
analysis_time: analysisData.analysis_time || 0,
created_at: new Date().toISOString(),
};
await this.put(STORES.INSTANT_ANALYSES, newAnalysis);
const user = newAnalysis.user_id ? await this.getProfileById(newAnalysis.user_id) : null;
return { ...newAnalysis, user: user || undefined };
}
// ==================== 统计相关方法 ====================
async getProjectStats(): Promise<any> {
const projects = await this.getAll<Project>(STORES.PROJECTS);
const tasks = await this.getAll<AuditTask>(STORES.AUDIT_TASKS);
const issues = await this.getAll<AuditIssue>(STORES.AUDIT_ISSUES);
return {
total_projects: projects.length,
active_projects: projects.filter(p => p.is_active).length,
total_tasks: tasks.length,
completed_tasks: tasks.filter(t => t.status === 'completed').length,
total_issues: issues.length,
resolved_issues: issues.filter(i => i.status === 'resolved').length,
};
}
}
// 导出单例
export const localDB = new LocalDatabase();

View File

@ -0,0 +1,118 @@
/**
*
* 使
*/
import { localDB } from '../config/localDatabase';
import { api } from '../config/database';
/**
*
*
*/
export async function initLocalDatabase(): Promise<void> {
try {
// 初始化数据库
await localDB.init();
// 检查是否已有用户
const profileCount = await localDB.getProfilesCount();
if (profileCount === 0) {
// 创建默认本地用户
await api.createProfiles({
id: 'local-user',
email: 'local@xcodereviewer.com',
full_name: '本地用户',
role: 'admin',
github_username: 'local-user',
});
console.log('✅ 本地数据库初始化成功');
}
} catch (error) {
console.error('❌ 本地数据库初始化失败:', error);
throw error;
}
}
/**
*
*
*/
export async function clearLocalDatabase(): Promise<void> {
try {
const dbName = 'xcodereviewer_local';
const request = indexedDB.deleteDatabase(dbName);
return new Promise((resolve, reject) => {
request.onsuccess = () => {
console.log('✅ 本地数据库已清空');
resolve();
};
request.onerror = () => {
console.error('❌ 清空本地数据库失败');
reject(request.error);
};
});
} catch (error) {
console.error('❌ 清空本地数据库失败:', error);
throw error;
}
}
/**
*
*
*/
export async function exportLocalDatabase(): Promise<string> {
try {
await localDB.init();
const data = {
version: 1,
exportDate: new Date().toISOString(),
profiles: await localDB.getAllProfiles(),
projects: await localDB.getProjects(),
auditTasks: await localDB.getAuditTasks(),
};
return JSON.stringify(data, null, 2);
} catch (error) {
console.error('❌ 导出数据失败:', error);
throw error;
}
}
/**
*
*
*/
export async function importLocalDatabase(jsonData: string): Promise<void> {
try {
const data = JSON.parse(jsonData);
if (!data.version || !data.profiles) {
throw new Error('无效的数据格式');
}
await localDB.init();
// 导入用户
for (const profile of data.profiles) {
await api.createProfiles(profile);
}
// 导入项目
if (data.projects) {
for (const project of data.projects) {
await api.createProject(project);
}
}
console.log('✅ 数据导入成功');
} catch (error) {
console.error('❌ 导入数据失败:', error);
throw error;
}
}