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:
parent
5bbea7101c
commit
3f7cc276d9
18
.env.example
18
.env.example
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
104
README.md
|
|
@ -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/ # 样式文件
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const routes: RouteConfig[] = [
|
|||
visible: false,
|
||||
},
|
||||
{
|
||||
name: "系统管理",
|
||||
name: "数据库管理",
|
||||
path: "/admin",
|
||||
element: <AdminDashboard />,
|
||||
visible: true,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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. 所有文本内容(title、description、suggestion、ai_explanation、xai 等)必须使用简体中文
|
||||
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.
|
||||
|
||||
【IMPORTANT】Please 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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, '.*'));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue