Initial commit: Add XCodeReviewer project files

This commit is contained in:
lintsinghua 2025-09-20 00:09:00 +08:00
parent ecd35ac87b
commit 7478f6f14f
100 changed files with 19256 additions and 0 deletions

97
.gitignore vendored Normal file
View File

@ -0,0 +1,97 @@
# Dependencies
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Build outputs
dist/
build/
*.tsbuildinfo
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/

215
api_list.md Normal file
View File

@ -0,0 +1,215 @@
* **Available API library**:
* **网页内容总结**:
- 功能: 网页内容总结WebSummary访问网页内容满足网页总结、问答等诉求
- 使用场景: 需要分析网页内容、生成网页摘要、基于网页内容问答时
- 典型应用: 新闻摘要、文章总结、网页内容分析
* **百度AI搜索流式接口**:
- 功能: 可根据用户输入query搜索全网实时信息后并进行智能总结回答。使用HTTP SSE流式响应
- 使用场景: 需要实时搜索信息并提供智能总结的应用
- 典型应用: 智能问答系统、实时信息查询、知识问答平台
* **百度搜索接口**:
- 功能: 可根据用户输入query搜索全网实时信息
- 使用场景: 需要获取搜索结果数据的应用
- 典型应用: 搜索引擎集成、信息检索系统、内容聚合平台
* **AI作画-iRAG版查询结果接口**:
- 功能: 用于在任务创建后,查看图片生成状态。待图片生成完毕,通过查询接口即可查看生成图片的地址链接
- 使用场景: AI图片生成任务的状态查询和结果获取
- 典型应用: AI绘画应用、图片生成工具、创意设计平台
* **AI作画-iRAG版接口**:
- 功能: 检索增强的文生图技术,根据用户输入的文本,自动创作图片。支持传入文本、分辨率、参考图等参数
- 使用场景: 需要根据文本描述生成图片的应用
- 典型应用: AI绘画工具、创意设计、内容创作、营销素材生成
* **查询长文本在线合成任务接口**:
- 功能: 查询长文本在线合成任务结果
- 使用场景: 语音合成任务的状态查询和结果获取
- 典型应用: 语音合成应用、有声读物制作、语音播报系统
* **长文本在线合成语音API**:
- 功能: 将10万字以内文本一次性合成异步返回音频。支持多种优质音库
- 使用场景: 需要将大量文本转换为语音的应用
- 典型应用: 阅读听书、新闻播报、语音助手、有声内容制作
* **短语音识别标准版API**:
- 功能: 将60秒以内的语音精准识别为文字
- 使用场景: 短语音交互场景
- 典型应用: 手机语音输入、智能语音交互、语音指令、语音搜索
* **人脸1:1对比接口**:
- 功能: 对比两张图像中人脸的相似度返回分值和人脸token
- 使用场景: 人脸验证、身份认证
- 典型应用: 身份验证系统、安全认证、人脸登录
* **人脸识别删除用户接口**:
- 功能: 人脸库管理系列接口之人脸删除,用于将用户从某个组中删除
- 使用场景: 人脸库用户管理
- 典型应用: 人脸识别系统管理、用户权限管理
* **人脸识别获取用户列表接口**:
- 功能: 人脸库管理系列接口之获取用户列表,用于查询指定用户组中的用户列表
- 使用场景: 人脸库用户查询管理
- 典型应用: 人脸识别系统管理、用户信息查询
* **人脸识别用户信息查询接口**:
- 功能: 人脸库管理系列接口之用户信息查询,获取人脸库中某个用户的信息
- 使用场景: 查询特定用户的人脸信息
- 典型应用: 人脸识别系统、用户信息管理
* **人脸识别API**:
- 功能: 在指定人脸库集合中,找到最相似的人脸
- 使用场景: 人脸搜索和匹配
- 典型应用: 人脸识别门禁、安防监控、人员识别
* **人脸注册接口**:
- 功能: 用于将用户人脸信息注册到指定用户组的服务
- 使用场景: 新用户人脸信息录入
- 典型应用: 人脸识别系统注册、用户信息建档
* **汽车票识别接口**:
- 功能: 支持对全国范围不同版式汽车票的10个字段进行结构化识别
- 使用场景: 汽车票信息提取和处理
- 典型应用: 财务报销系统、票据管理、出行记录
* **网约车行程单识别接口**:
- 功能: 对各大主要服务商的网约车行程单进行结构化识别支持识别16个关键字段
- 使用场景: 网约车行程单信息提取
- 典型应用: 财务报销、出行管理、费用统计
* **飞机行程单识别接口**:
- 功能: 支持对飞机行程单的24个字段进行结构化识别支持多航班信息识别
- 使用场景: 飞机行程单信息提取和处理
- 典型应用: 差旅管理、财务报销、行程记录
* **出租车票识别接口**:
- 功能: 支持识别全国各大城市出租车票的16个关键字段
- 使用场景: 出租车票信息提取
- 典型应用: 财务报销系统、出行费用管理
* **火车票识别接口**:
- 功能: 支持对红/蓝火车票、铁路电子客票的关键字段进行结构化识别
- 使用场景: 火车票信息提取和处理
- 典型应用: 财务报销、出行管理、票据识别
* **身份证识别接口**:
- 功能: 支持对二代居民身份证正反面所有8个字段进行结构化识别
- 使用场景: 身份证信息提取和验证
- 典型应用: 实名认证、用户注册、身份验证
* **文心一言多模态输入大模型接口**:
- 功能: 支持图像输入的多模态对话包括文本生成、图片理解、多模态翻译、AI聊天等功能
- 使用场景: 需要多模态AI能力的应用
- 典型应用: 智能客服、图文分析、拍照解题、内容审核
* **文心文本生成大模型接口**:
- 功能: 文本对话大模型,支持内容生成、语言润色、摘要生成、信息提取、翻译等功能
- 使用场景: 需要AI文本生成和处理能力的应用
- 典型应用: 内容创作、文本处理、AI助手、智能客服
* **通用文字识别(高精度版)**:
- 功能: 高精度文字识别支持多语种识别字库扩展到2w+
- 使用场景: 需要高精度文字识别的应用
- 典型应用: 文档数字化、OCR识别、多语言文本提取
* **通用物体和场景识别接口**:
- 功能: 对输入图片输出图片中的多个物体及场景标签
- 使用场景: 图片内容分析和标签识别
- 典型应用: 图片分类、内容审核、智能相册
* **图像内容理解-获取结果接口**:
- 功能: 获取图像内容理解任务的执行结果
- 使用场景: 图像理解任务的结果查询
- 典型应用: 图片分析系统、内容理解应用
* **图像内容理解-提交请求接口**:
- 功能: 支持输入图片和提问信息,多维度识别与理解图片内容
- 使用场景: 图片内容问答和分析
- 典型应用: 图片问答、图片打标签、物体识别
* **文本翻译-通用版**:
- 功能: 支持中、英、日、韩等200+语言互译100+语种自动检测
- 使用场景: 多语言文本翻译需求
- 典型应用: 国际化应用、多语言网站、翻译工具
* **百度地图全球逆地理编码接口**:
- 功能: 将坐标点(经纬度)转换为对应位置信息
- 使用场景: 坐标转地址信息
- 典型应用: 地图应用、位置服务、LBS应用
* **百度地图地理编码接口**:
- 功能: 可将结构化地址解析为地理坐标
- 使用场景: 地址转坐标
- 典型应用: 地图定位、导航应用、位置标记
* **获取未来第8日到第15日天气预报接口**:
- 功能: 获取指定地区未来第8日到第15日的天气预报信息
- 使用场景: 长期天气预报查询
- 典型应用: 天气应用、出行规划、农业气象
* **未来7日天气预报API**:
- 功能: 通过地区名称等参数查询未来7日天气预报
- 使用场景: 短期天气预报查询
- 典型应用: 天气应用、生活服务、出行助手
* **天气预报API**:
- 功能: 通过多种方式查询天气情况,提供实时天气、未来天气、预警、生活指数等信息
- 使用场景: 综合天气信息查询
- 典型应用: 天气应用、生活服务、智能助手
* **验证短信验证码接口**:
- 功能: 根据会话ID、手机号码和验证码进行验证
- 使用场景: 短信验证码校验
- 典型应用: 用户注册、身份验证、安全认证
* **发送短信验证码功能**:
- 功能: 通过指定的手机号码和会话ID发送短信验证码
- 使用场景: 短信验证码发送
- 典型应用: 用户注册、登录验证、安全认证
* **沪深分钟K含均线API**:
- 功能: 获取沪深分钟K含均线信息
- 使用场景: 股票技术分析
- 典型应用: 股票交易软件、金融分析工具
* **沪深大盘涨跌数API**:
- 功能: 获取沪深大盘涨跌数信息
- 使用场景: 股市大盘数据查询
- 典型应用: 股票应用、金融资讯、投资分析
* **沪深股票信息API**:
- 功能: 获取沪深股票信息
- 使用场景: 股票基础信息查询
- 典型应用: 股票交易软件、投资工具、金融应用
* **沪深分时成交API**:
- 功能: 获取沪深分时成交信息
- 使用场景: 股票分时交易数据查询
- 典型应用: 股票交易软件、实时行情、交易分析
* **沪深股票排行API**:
- 功能: 获取沪深股票排行信息
- 使用场景: 股票排行榜查询
- 典型应用: 股票应用、投资参考、行情分析
* **沪深K线API**:
- 功能: 获取沪深K线信息
- 使用场景: 股票K线图数据查询
- 典型应用: 股票交易软件、技术分析、投资工具
* **A股停牌信息查询API**:
- 功能: 用于获取A股市场的股票停牌相关信息
- 使用场景: 股票停牌信息查询
- 典型应用: 股票应用、投资风险管理、交易提醒
* **沪深板块成分股排行API**:
- 功能: 获取沪深板块成分股排行信息
- 使用场景: 板块成分股分析
- 典型应用: 股票分析工具、板块投资、行业研究
* **沪深板块排名API**:
- 功能: 获取板块排名信息
- 使用场景: 板块表现分析
- 典型应用: 投资分析、板块轮动、行业对比

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

1
history/config.json Normal file
View File

@ -0,0 +1 @@
{"index":-1,"history":[]}

12
index.html Normal file
View File

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

94
package.json Normal file
View File

@ -0,0 +1,94 @@
{
"name": "miaoda-react-admin",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --strictPort --port 5173",
"lint": "tsgo -p tsconfig.check.json; biome lint --only=correctness/noUndeclaredDependencies; ast-grep scan"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-alert-dialog": "^1.1.11",
"@radix-ui/react-aspect-ratio": "^1.1.4",
"@radix-ui/react-avatar": "^1.1.7",
"@radix-ui/react-checkbox": "^1.2.3",
"@radix-ui/react-collapsible": "^1.1.8",
"@radix-ui/react-dialog": "^1.1.11",
"@radix-ui/react-dropdown-menu": "^2.1.12",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.4",
"@radix-ui/react-menubar": "^1.1.12",
"@radix-ui/react-navigation-menu": "^1.2.10",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-progress": "^1.1.3",
"@radix-ui/react-radio-group": "^1.3.4",
"@radix-ui/react-scroll-area": "^1.2.6",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slider": "^1.3.2",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.2.2",
"@radix-ui/react-tabs": "^1.1.9",
"@radix-ui/react-toast": "^1.1.5",
"@radix-ui/react-toggle": "^1.1.6",
"@radix-ui/react-toggle-group": "^1.1.7",
"@radix-ui/react-tooltip": "^1.2.4",
"@supabase/supabase-js": "^2.49.4",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"eventsource-parser": "^3.0.6",
"fflate": "^0.8.2",
"input-otp": "^1.4.2",
"ky": "^1.9.1",
"lucide-react": "^0.525.0",
"miaoda-auth-react": "^2.0.0",
"miaoda-sc-plugin": "^1.0.11",
"next-themes": "^0.4.6",
"qrcode": "^1.5.4",
"react": "^18.0.0",
"react-day-picker": "^8.10.1",
"react-dom": "^18.0.0",
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.56.1",
"react-resizable-panels": "^2.1.8",
"react-router": "^7.1.5",
"react-router-dom": "^6.30.0",
"recharts": "^2.15.3",
"sonner": "^2.0.3",
"streamdown": "^1.1.6",
"tailwind-merge": "^3.2.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"video-react": "^0.16.0",
"zod": "^3.24.3"
},
"devDependencies": {
"@ast-grep/cli": "^0.39.5",
"@biomejs/biome": "2.2.3",
"@types/lodash": "^4.17.16",
"@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4",
"@types/video-react": "^0.15.8",
"@typescript/native-preview": "7.0.0-dev.20250819.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"globals": "^15.14.0",
"postcss": "^8.5.2",
"tailwindcss": "^3.4.11",
"typescript": "~5.7.2",
"vite": "^5.1.4",
"vite-plugin-svgr": "^4.3.0"
},
"overrides": {
"react-helmet-async": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
}
}

7320
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

28
rules/SeletItem.yml Normal file
View File

@ -0,0 +1,28 @@
id: selectItemWithEmptyValue
language: Tsx
files:
- src/**/*.tsx
rule:
kind: jsx_opening_element
all:
- has:
kind: identifier
regex: '^SelectItem$'
- has:
kind: jsx_attribute
all:
- has:
kind: property_identifier
regex: '^value$'
- any:
- has:
kind: string
regex: '^""$'
- has:
kind: jsx_expression
has:
kind: string
regex: '^""$'
message: "检测到 SelectItem 组件使用空字符串 value: $MATCH 这是错误用法, 运行时会报错, 请修改, 如果想实现全选建议使用all代替空字符串"
severity: error

5
sgconfig.yml Normal file
View File

@ -0,0 +1,5 @@
ruleDirs:
- rules
languageGlobs:
TypeScript: ["*.ts"]
Tsx: ["*.tsx"]

29
src/App.tsx Normal file
View File

@ -0,0 +1,29 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { Toaster } from "sonner";
import Header from "@/components/common/Header";
import routes from "./routes";
function App() {
return (
<BrowserRouter>
<Toaster position="top-right" />
<div className="min-h-screen bg-gray-50">
<Header />
<main className="container mx-auto px-4 py-8">
<Routes>
{routes.map((route) => (
<Route
key={route.path}
path={route.path}
element={route.element}
/>
))}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
</BrowserRouter>
);
}
export default App;

View File

@ -0,0 +1,71 @@
import React from "react";
const Footer: React.FC = () => {
const currentYear = new Date().getFullYear();
return (
<footer className="bg-gradient-to-r from-amber-50 to-orange-50 border-t border-amber-200">
<div className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* ================= 关于我们 ================= */}
<div>
{/* 标题:改成你们项目的“关于我们” */}
<h3 className="text-lg font-semibold text-amber-800 mb-4">
{/* 关于我们 */}
</h3>
<p className="text-gray-600">
{/* 在这里填写“关于我们”的介绍比如致力于xxx让xxx更加xxx */}
</p>
</div>
{/* ================= 联系信息 ================= */}
<div>
{/* 标题:联系信息 */}
<h3 className="text-lg font-semibold text-amber-800 mb-4">
{/* 联系信息 */}
</h3>
<div className="text-gray-600 space-y-2">
<p>
{/* 地址XXX省XXX市XXX区XXX路XXX号 */}
</p>
<p>
{/* 电话010-XXXXXXX */}
</p>
<p>
{/* 邮箱info@example.com */}
</p>
</div>
</div>
{/* ================= 开放时间 / 其他信息 / 也可删除 ================= */}
<div>
{/* 标题:可改成“开放时间”或者“服务时间” */}
<h3 className="text-lg font-semibold text-amber-800 mb-4">
{/* 开放时间 */}
</h3>
<div className="text-gray-600 space-y-2">
<p>
{/* 周一至周五9:00-18:00 */}
</p>
<p>
{/* 周末及法定节假日请注意公告 */}
</p>
<p>
{/* 其他说明,比如“需要提前预约” */}
</p>
</div>
</div>
</div>
{/* ================= 版权区域 ================= */}
<div className="mt-8 pt-8 border-t border-amber-200 text-center text-gray-600">
<p>
{/* © {currentYear} 你的公司或组织名称 */}
</p>
</div>
</div>
</footer>
);
};
export default Footer;

View File

@ -0,0 +1,95 @@
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Menu,
X,
Code
} from "lucide-react";
import routes from "@/routes";
export default function Header() {
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const visibleRoutes = routes.filter(route => route.visible !== false);
const user = null;
return (
<header className="bg-white shadow-sm border-b">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
{/* Logo */}
<Link to="/" className="flex items-center space-x-2">
<div className="w-8 h-8 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center">
<Code className="w-5 h-5 text-white" />
</div>
<span className="text-xl font-bold text-gray-900"></span>
</Link>
{/* Desktop Navigation */}
<nav className="hidden md:flex items-center space-x-8">
{visibleRoutes.map((route) => (
<Link
key={route.path}
to={route.path}
className={`text-sm font-medium transition-colors hover:text-blue-600 ${
location.pathname === route.path
? "text-blue-600"
: "text-gray-700"
}`}
>
{route.name}
</Link>
))}
</nav>
{/* User Menu */}
<div className="flex items-center space-x-4">
<div />
{/* Mobile menu button */}
<Button
variant="ghost"
size="sm"
className="md:hidden"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
>
{mobileMenuOpen ? (
<X className="w-5 h-5" />
) : (
<Menu className="w-5 h-5" />
)}
</Button>
</div>
</div>
{/* Mobile Navigation */}
{mobileMenuOpen && (
<div className="md:hidden border-t bg-white">
<div className="px-2 pt-2 pb-3 space-y-1">
{visibleRoutes.map((route) => (
<Link
key={route.path}
to={route.path}
className={`block px-3 py-2 text-base font-medium rounded-md transition-colors ${
location.pathname === route.path
? "text-blue-600 bg-blue-50"
: "text-gray-700 hover:text-blue-600 hover:bg-gray-50"
}`}
onClick={() => setMobileMenuOpen(false)}
>
{route.name}
</Link>
))}
</div>
</div>
)}
</div>
</header>
);
}

View File

@ -0,0 +1,20 @@
import { HelmetProvider, Helmet } from "react-helmet-async";
const PageMeta = ({
title,
description,
}: {
title: string;
description: string;
}) => (
<Helmet>
<title>{title}</title>
<meta name="description" content={description} />
</Helmet>
);
export const AppWrapper = ({ children }: { children: React.ReactNode }) => (
<HelmetProvider>{children}</HelmetProvider>
);
export default PageMeta;

View File

@ -0,0 +1,221 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { api, supabase } from "@/db/supabase";
import { Database, CheckCircle, AlertTriangle, RefreshCw } from "lucide-react";
import { toast } from "sonner";
export default function DatabaseTest() {
const [testing, setTesting] = useState(false);
const [results, setResults] = useState<any>(null);
const testConnection = async (): Promise<boolean> => {
try {
const { data, error } = await supabase
.from('profiles')
.select('count', { count: 'exact', head: true });
if (error) {
console.error('Database connection test failed:', error);
return false;
}
console.log('Database connection successful');
return true;
} catch (error) {
console.error('Database connection error:', error);
return false;
}
};
const runDatabaseTest = async () => {
setTesting(true);
const testResults: any = {
connection: false,
tables: {},
sampleData: {},
errors: []
};
try {
// 测试基本连接
console.log('测试数据库连接...');
testResults.connection = await testConnection();
// 测试各个表
const tables = ['profiles', 'projects', 'audit_tasks', 'audit_issues', 'instant_analyses'];
for (const table of tables) {
try {
console.log(`测试表: ${table}`);
const { data, error, count } = await supabase
.from(table)
.select('*', { count: 'exact', head: true });
testResults.tables[table] = {
accessible: !error,
count: count || 0,
error: error?.message
};
if (error) {
testResults.errors.push(`${table}: ${error.message}`);
}
} catch (err) {
testResults.tables[table] = {
accessible: false,
count: 0,
error: (err as Error).message
};
testResults.errors.push(`${table}: ${(err as Error).message}`);
}
}
// 测试示例数据获取
try {
console.log('测试项目数据获取...');
const projects = await api.getProjects();
testResults.sampleData.projects = {
success: true,
count: projects.length,
data: projects.slice(0, 2) // 只显示前2个
};
} catch (err) {
testResults.sampleData.projects = {
success: false,
error: (err as Error).message
};
testResults.errors.push(`项目数据: ${(err as Error).message}`);
}
setResults(testResults);
if (testResults.connection && testResults.errors.length === 0) {
toast.success("数据库测试通过!");
} else {
toast.error(`数据库测试发现 ${testResults.errors.length} 个问题`);
}
} catch (error) {
console.error('数据库测试失败:', error);
testResults.errors.push(`总体测试失败: ${(error as Error).message}`);
setResults(testResults);
toast.error("数据库测试失败");
} finally {
setTesting(false);
}
};
return (
<Card className="w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle className="flex items-center">
<Database className="w-5 h-5 mr-2" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center space-x-4">
<Button onClick={runDatabaseTest} disabled={testing}>
{testing ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Database className="w-4 h-4 mr-2" />
)}
{testing ? '测试中...' : '开始测试'}
</Button>
{results && (
<Badge variant={results.connection && results.errors.length === 0 ? "default" : "destructive"}>
{results.connection && results.errors.length === 0 ? (
<CheckCircle className="w-3 h-3 mr-1" />
) : (
<AlertTriangle className="w-3 h-3 mr-1" />
)}
{results.connection && results.errors.length === 0 ? '连接正常' : '存在问题'}
</Badge>
)}
</div>
{results && (
<div className="space-y-4">
{/* 连接状态 */}
<div className="p-4 border rounded-lg">
<h4 className="font-medium mb-2"></h4>
<div className="flex items-center space-x-2">
{results.connection ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : (
<AlertTriangle className="w-4 h-4 text-red-600" />
)}
<span className={results.connection ? 'text-green-600' : 'text-red-600'}>
{results.connection ? '数据库连接成功' : '数据库连接失败'}
</span>
</div>
</div>
{/* 表状态 */}
<div className="p-4 border rounded-lg">
<h4 className="font-medium mb-2"></h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{Object.entries(results.tables).map(([tableName, tableInfo]: [string, any]) => (
<div key={tableName} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<div className="flex items-center space-x-2">
{tableInfo.accessible ? (
<CheckCircle className="w-3 h-3 text-green-600" />
) : (
<AlertTriangle className="w-3 h-3 text-red-600" />
)}
<span className="text-sm font-medium">{tableName}</span>
</div>
<Badge variant="outline" className="text-xs">
{tableInfo.count}
</Badge>
</div>
))}
</div>
</div>
{/* 示例数据 */}
{results.sampleData.projects && (
<div className="p-4 border rounded-lg">
<h4 className="font-medium mb-2"></h4>
{results.sampleData.projects.success ? (
<div className="space-y-2">
<p className="text-sm text-green-600">
{results.sampleData.projects.count}
</p>
{results.sampleData.projects.data.map((project: any, index: number) => (
<div key={index} className="text-xs bg-gray-50 p-2 rounded">
<strong>{project.name}</strong> - {project.description || '无描述'}
</div>
))}
</div>
) : (
<p className="text-sm text-red-600">
: {results.sampleData.projects.error}
</p>
)}
</div>
)}
{/* 错误信息 */}
{results.errors.length > 0 && (
<div className="p-4 border border-red-200 bg-red-50 rounded-lg">
<h4 className="font-medium text-red-800 mb-2"></h4>
<ul className="space-y-1">
{results.errors.map((error: string, index: number) => (
<li key={index} className="text-sm text-red-700">
{error}
</li>
))}
</ul>
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,64 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@ -0,0 +1,155 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,9 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio };

View File

@ -0,0 +1,51 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View File

@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@ -0,0 +1,109 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@ -0,0 +1,57 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@ -0,0 +1,73 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
caption: "flex justify-center pt-1 relative items-center w-full",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1",
nav_button_next: "absolute right-1",
table: "w-full border-collapse space-x-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"size-8 p-0 font-normal aria-selected:opacity-100"
),
day_range_start:
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
day_range_end:
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("size-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("size-4", className)} {...props} />
),
}}
{...props}
/>
);
}
export { Calendar };

View File

@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6",
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@ -0,0 +1,239 @@
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

351
src/components/ui/chart.tsx Normal file
View File

@ -0,0 +1,351 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@ -0,0 +1,30 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@ -0,0 +1,175 @@
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@ -0,0 +1,135 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@ -0,0 +1,130 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@ -0,0 +1,201 @@
"use client";
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
));
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
};

166
src/components/ui/form.tsx Normal file
View File

@ -0,0 +1,166 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@ -0,0 +1,75 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
);
}
export { Input };

View File

@ -0,0 +1,24 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
);
}
export { Label };

196
src/components/ui/map.tsx Normal file
View File

@ -0,0 +1,196 @@
/**
* GL组件
*
* WebGL API封装的React地图组件
*
* 使
* <Map
* ak="OeTpXHgdUrRT2pPyAPRL7pog6GlMlQzl" // 百度地图API密钥
* option={{
* address: "山东省威海市环翠区刘公岛景区内",
* lat: 37.51029432858647, // 纬度
* lng: 122.19726116385918, // 经度
* zoom: 12, // 缩放级别
* }}
* className="w-[600px] h-[300px] rounded-lg" // 容器样式
* >
* <MapTitle className="text-md"/> // 可选标题组件
* </Map>
*/
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from "react";
/** 地图上下文属性 */
type MapContextProps = {
// 地址
address?: string; /** 地图标记点地址 */
};
const MapContext = createContext<MapContextProps | null>(null);
/** 默认地图配置 */
const defaultOption = {
zoom: 15, /** 默认缩放级别 */
lng: 116.404, /** 默认经度(北京天安门) */
lat: 39.915, /** 默认纬度(北京天安门) */
address: "北京市东城区长安街", /** 默认地址 */
};
const loadScript = (src: string) => {
return new Promise<void>((ok, fail) => {
const script = document.createElement("script");
script.onerror = (reason) => fail(reason);
if (~src.indexOf("{{callback}}")) {
const callbackFn = `loadscriptcallback_${(+new Date()).toString(36)}`;
(window as any)[callbackFn] = () => {
ok();
delete (window as any)[callbackFn];
};
src = src.replace("{{callback}}", callbackFn);
} else {
script.onload = () => ok();
}
script.src = src;
document.head.appendChild(script);
});
};
const useMap = () => {
const context = useContext(MapContext);
if (!context) {
return {};
}
return context;
};
/**
*
* @param {string} className -
*/
const MapTitle = ({ className }: React.ComponentProps<"div">) => {
const { address } = useMap();
if (!address) return null;
return <span className={`text-lg font-bold ${className}`}>{address}</span>;
};
// 记录百度地图SDK加载状态
let BMapGLLoadingPromise: Promise<void> | null = null;
/**
*
* @param {string} ak - API密钥'OeTpXHgdUrRT2pPyAPRL7pog6GlMlQzl'
* @param {object} option -
* @param {number} option.zoom -
* @param {number} option.lng -
* @param {number} option.lat -
* @param {string} option.address -
* @param {string} className -
* @param {ReactNode} children - MapTitle
*/
const Map = ({
ak,
option,
className,
children,
...props
}: React.ComponentProps<"div"> & {
ak: string;
option?: {
zoom: number;
lng: number;
lat: number;
address: string;
};
}) => {
const mapRef = useRef<HTMLDivElement>(null);
const currentRef = useRef(null);
const _options = useMemo(() => {
return { ...defaultOption, ...option };
}, [option]);
const contextValue = useMemo<MapContextProps>(
() => ({
address: option?.address,
}),
[option?.address]
);
const initMap = useCallback(() => {
if (!mapRef.current) return;
let map = currentRef.current;
if (!map) {
// 创建地图实例
map = new (window as any).BMapGL.Map(mapRef.current);
currentRef.current = map;
}
// 清除覆盖物
map.clearOverlays();
// 设置地图中心点坐标和地图级别
const center = new (window as any).BMapGL.Point(
_options?.lng,
_options?.lat
);
map.centerAndZoom(center, _options?.zoom);
// 添加标注
const marker = new (window as any).BMapGL.Marker(center);
map.addOverlay(marker);
}, [_options]);
useEffect(() => {
// 检查百度地图API是否已加载
if ((window as any).BMapGL) {
initMap();
} else if (BMapGLLoadingPromise) {
BMapGLLoadingPromise.then(initMap).then(() => {
BMapGLLoadingPromise = null;
});
} else {
BMapGLLoadingPromise = loadScript(
`//api.map.baidu.com/api?type=webgl&v=1.0&ak=${ak}&callback={{callback}}`
);
BMapGLLoadingPromise.then(initMap).then(() => {
BMapGLLoadingPromise = null;
});
}
}, [ak, initMap]);
useEffect(() => {
return () => {
if (currentRef.current) {
currentRef.current = null;
}
};
}, []);
return (
<MapContext.Provider value={contextValue}>
<div
ref={mapRef}
className={`w-full aspect-[16/9] ${className}`}
{...props}
></div>
{children}
</MapContext.Provider>
);
};
export { Map, MapTitle };

View File

@ -0,0 +1,274 @@
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};

View File

@ -0,0 +1,196 @@
/**
* @file
*/
import type React from "react";
import { useEffect, useState, useRef } from "react";
interface Option {
value: string;
label: string;
}
interface MultiSelectProps {
options: Option[];
value?: string[];
defaultSelected?: string[];
onChange?: (selected: string[]) => void;
disabled?: boolean;
}
const MultiSelect: React.FC<MultiSelectProps> = ({
options,
defaultSelected = [],
value,
onChange,
disabled = false,
}) => {
const [selectedOptions, setSelectedOptions] =
useState<string[]>(defaultSelected);
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
const toggleDropdown = () => {
if (!disabled) setIsOpen((prev) => !prev);
};
useEffect(() => {
if (selectedOptions.length && value && !value?.length) {
onChange?.(defaultSelected);
}
}, [defaultSelected]);
useEffect(() => {
if (
value?.length &&
(value.length !== selectedOptions.length ||
value.some((val) => !selectedOptions.includes(val)))
) {
setSelectedOptions(value);
}
}, [value, selectedOptions]);
const handleSelect = (optionValue: string) => {
const newSelectedOptions = selectedOptions.includes(optionValue)
? selectedOptions.filter((value) => value !== optionValue)
: [...selectedOptions, optionValue];
setSelectedOptions(newSelectedOptions);
onChange?.(newSelectedOptions);
};
const removeOption = (value: string) => {
const newSelectedOptions = selectedOptions.filter((opt) => opt !== value);
setSelectedOptions(newSelectedOptions);
onChange?.(newSelectedOptions);
};
const selectedValuesText = selectedOptions.map(
(value) => options.find((option) => option.value === value)?.label || ""
);
return (
<div className="relative z-20 inline-block w-full" ref={containerRef}>
<div className="relative flex flex-col items-center">
<div onClick={toggleDropdown} className="w-full">
<div className="mb-2 flex h-11 rounded-lg border border-gray-300 py-1.5 pl-3 pr-3 shadow-theme-xs outline-hidden transition focus:border-brand-300 focus:shadow-focus-ring dark:border-gray-700 dark:bg-gray-900 dark:focus:border-brand-300">
<div className="flex flex-wrap flex-auto gap-2">
{selectedValuesText.length > 0 ? (
selectedValuesText.map((text, index) => (
<div
key={index}
className="group flex items-center justify-center rounded-full border-[0.7px] border-transparent bg-gray-100 py-1 pl-2.5 pr-2 text-sm text-gray-800 hover:border-gray-200 dark:bg-gray-800 dark:text-white/90 dark:hover:border-gray-800"
>
<span className="flex-initial max-w-full">{text}</span>
<div className="flex flex-row-reverse flex-auto">
<div
onClick={(e) => {
e.stopPropagation();
removeOption(selectedOptions[index]);
}}
className="pl-2 text-gray-500 cursor-pointer group-hover:text-gray-400 dark:text-gray-400"
>
<svg
className="fill-current"
role="button"
width="14"
height="14"
viewBox="0 0 14 14"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3.40717 4.46881C3.11428 4.17591 3.11428 3.70104 3.40717 3.40815C3.70006 3.11525 4.17494 3.11525 4.46783 3.40815L6.99943 5.93975L9.53095 3.40822C9.82385 3.11533 10.2987 3.11533 10.5916 3.40822C10.8845 3.70112 10.8845 4.17599 10.5916 4.46888L8.06009 7.00041L10.5916 9.53193C10.8845 9.82482 10.8845 10.2997 10.5916 10.5926C10.2987 10.8855 9.82385 10.8855 9.53095 10.5926L6.99943 8.06107L4.46783 10.5927C4.17494 10.8856 3.70006 10.8856 3.40717 10.5927C3.11428 10.2998 3.11428 9.8249 3.40717 9.53201L5.93877 7.00041L3.40717 4.46881Z"
/>
</svg>
</div>
</div>
</div>
))
) : (
<input
placeholder="请选择选项..."
className="w-full h-full p-1 pr-2 text-sm bg-transparent border-0 outline-hidden appearance-none placeholder:text-gray-800 focus:border-0 focus:outline-hidden focus:ring-0 dark:placeholder:text-white/90"
readOnly
value="请选择选项..."
/>
)}
</div>
<div className="flex items-center py-1 pl-1 pr-1 w-7">
<button
type="button"
onClick={toggleDropdown}
className="w-5 h-5 text-gray-700 outline-hidden cursor-pointer focus:outline-hidden dark:text-gray-400"
>
<svg
className={`stroke-current ${isOpen ? "rotate-180" : ""}`}
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4.79175 7.39551L10.0001 12.6038L15.2084 7.39551"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
</div>
</div>
{isOpen && (
<div
className="absolute left-0 z-40 w-full overflow-y-auto bg-white rounded-lg shadow-sm top-full max-h-select dark:bg-gray-900"
onClick={(e) => e.stopPropagation()}
>
<div className="flex flex-col">
{options.map((option, index) => (
<div
key={index}
className={`hover:bg-primary/5 w-full cursor-pointer rounded-t border-b border-gray-200 dark:border-gray-800`}
onClick={() => handleSelect(option.value)}
>
<div
className={`relative flex w-full items-center p-2 pl-2 ${
selectedOptions.includes(option.value)
? "bg-primary/10"
: ""
}`}
>
<div className="mx-2 leading-6 text-gray-800 dark:text-white/90">
{option.label}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
export default MultiSelect;

View File

@ -0,0 +1,168 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};

View File

@ -0,0 +1,127 @@
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@ -0,0 +1,46 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@ -0,0 +1,29 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@ -0,0 +1,97 @@
/**
*
*
* QRCode.js的React封装组件
*
* 使
* import QRCodeDataUrl from './components/qrcodedataurl'
*
* function App() {
* return <QRCodeDataUrl text="https://example.com" /> // 替换为有效URL
* }
*/
import React, { useEffect, useState } from 'react';
import QRCode from 'qrcode';
interface QRCodeDataUrlProps {
/**
*
* URL
* : "https://example.com" "CONTACT:1234567890"
*/
text: string;
/**
* ()
* @default 128
*/
width?: number;
/**
* (CSS颜色值)
* @default "#000000" ()
*/
color?: string;
/**
* (CSS颜色值)
* @default "#ffffff" ()
*/
backgroundColor?: string;
/**
* CSS类名
*/
className?: string;
}
/**
*
* @param {QRCodeDataUrlProps} props -
*/
const QRCodeDataUrl: React.FC<QRCodeDataUrlProps> = ({
text,
width = 128,
color = '#000000',
backgroundColor = '#ffffff',
className = '',
}) => {
const [dataUrl, setDataUrl] = useState<string>('');
useEffect(() => {
const generateQR = async () => {
try {
const url = await QRCode.toDataURL(text, {
width,
color: {
dark: color,
light: backgroundColor,
},
});
setDataUrl(url);
} catch (err) {
console.error('生成二维码失败:', err);
}
};
generateQR();
}, [text, width, color, backgroundColor]);
return (
<div className={`qr-code-container ${className}`}>
{dataUrl ? (
<img
src={dataUrl}
alt={`二维码: ${text}`}
width={width}
height={width}
/>
) : (
<div>...</div>
)}
</div>
);
};
export default QRCodeDataUrl;

View File

@ -0,0 +1,43 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@ -0,0 +1,54 @@
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@ -0,0 +1,56 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@ -0,0 +1,159 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
));
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
));
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
};

View File

@ -0,0 +1,28 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator-root"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
);
}
export { Separator };

140
src/components/ui/sheet.tsx Normal file
View File

@ -0,0 +1,140 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
{
variants: {
side: {
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
side: "right",
},
}
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = "right", className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{children}
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
);
SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
);
SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@ -0,0 +1,724 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
};

View File

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@ -0,0 +1,61 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@ -0,0 +1,23 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@ -0,0 +1,29 @@
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

114
src/components/ui/table.tsx Normal file
View File

@ -0,0 +1,114 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@ -0,0 +1,64 @@
import * as React from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn } from "@/lib/utils";
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
);
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
);
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@ -0,0 +1,16 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
);
}

129
src/components/ui/toast.tsx Normal file
View File

@ -0,0 +1,129 @@
"use client";
import * as React from "react";
import * as ToastPrimitives from "@radix-ui/react-toast";
import { cva, type VariantProps } from "class-variance-authority";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@ -0,0 +1,33 @@
import { useToast } from "@/hooks/use-toast";
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast";
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@ -0,0 +1,71 @@
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants>
>({
size: "default",
variant: "default",
});
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };

View File

@ -0,0 +1,45 @@
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };

View File

@ -0,0 +1,61 @@
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

111
src/components/ui/video.tsx Normal file
View File

@ -0,0 +1,111 @@
/**
*
*
* video-react封装的视频播放器
*
* 使
* <Video
* src="" // 视频资源地址,默认为空字符串
* poster="https://internal-amis-res.cdn.bcebos.com/images/2019-12/1577157239810/da6376bf988c.png" // 视频封面图
* />
*/
import {
BigPlayButton,
ControlBar,
PlayToggle,
CurrentTimeDisplay,
TimeDivider,
DurationDisplay,
FullscreenToggle,
VolumeMenuButton,
ProgressControl
} from 'video-react';
import 'video-react/dist/video-react.css';
interface VideoProps {
/** 视频资源地址 */
src: string;
poster?: string; /** 视频封面图地址 */
className?: string; /** 自定义类名 */
autoPlay?: boolean; /** 是否自动播放默认为false */
muted?: boolean; /** 是否静音默认为false */
controls?: boolean; /** 是否显示控制条默认为true */
aspectRatio?: string | 'auto' | '16:9' | '4:3'; /** 视频宽高比,默认为'auto' */
}
export default function Video({
className,
src,
poster,
autoPlay = false,
muted = false,
controls = true,
aspectRatio = 'auto'
}: VideoProps) {
return (
<div className={`min-w-[100px] ${className}`} custom-component="video">
<style>
{`
.video-react-paused .video-react-big-play-button.big-play-button-hide {
display: block;
}
.video-react .video-react-big-play-button {
width: 48px;
height: 40px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.5);
margin-left: -24px !important;
margin-top: -20px !important;
background: rgba(7, 12, 20, 0.6) !important;
}
.video-react .video-react-big-play-button:hover {
background: rgba(7, 12, 20, 0.8) !important;
}
.video-react .video-react-big-play-button:before {
display: block;
content: '';
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACgAAAAqCAYAAADBNhlmAAAACXBIWXMAACE4AAAhOAFFljFgAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAGVSURBVHgB7ZnhbYMwEIWPqAN0BHcDRiAbdASyQTpB6QRpJyAjdAO8QbsBbNBs4L6TcYtaUADb +H7kk56cgKJ8sX0RHBmNYIwpMBT92w7RWZZ1lBoWQ1rzny/kGbmnVODLc3OdFikpBWZ85mSI9ku7htbY/RqNXT8WtA6FNJCsEUUR2FEYSqSNIRpK0FGSFT2FEg0t6DiSXfojeRJLkFHIybfiYwo6FFKvFd1C0KHIijZL9ueWgo6CFlR8CkFHSTNEUwo6SuTDTFyMSBBkWKwiK1oOT0gRdCj6rXh+LU7QocjOppIqyPCy15IFmUK6oNg9 +MNN0BMtWfCCHKQKdsiemwXSBHnWniD2gHzygTuSAYu9Ia8QuwxPSBBkseqvmCOloEYO15pSKfagJlsA+zkdsy1nUCMvkNJLPrSFYEdW7EwriCk4WZlLiCEYRMwRWvBMdjk7CoQT9P2lmlYUwGw8GphN7AbmULJdINZuJjYQnDOL3O33bqn5SOZm+jFEZRI8hsjGDkLkEUNO9taPL3veQ/xlrOEbeloBZoEUypwAAAAASUVORK5CYII=)
no-repeat;
background-size: contain;
width: 15px;
height: 16.25px;
margin: 0 auto;
position: relative;
}
.h-full > .video-react.video-react-fluid {
height: 100%;
padding-top: 0 !important;
aspect-ratio: 16 / 9;
}
`}
</style>
<Player
poster={poster}
src={src}
autoPlay={autoPlay}
muted={muted}
aspectRatio={aspectRatio}
>
<ControlBar
disableDefaultControls
autoHide
disableCompletely={!controls}
>
<PlayToggle key="play-toggle" />
<VolumeMenuButton key="volume-menu-button" vertical />
<CurrentTimeDisplay key="current-time-display" />
<TimeDivider key="time-divider" />
<DurationDisplay key="duration-display" />
<ProgressControl key="progress-control" />
<FullscreenToggle key="fullscreen-toggle" />
</ControlBar>
<BigPlayButton position="center" />
</Player>
</div>
);
}

400
src/db/supabase.ts Normal file
View File

@ -0,0 +1,400 @@
import { createClient } from "@supabase/supabase-js";
import type {
Profile,
Project,
ProjectMember,
AuditTask,
AuditIssue,
InstantAnalysis,
CreateProjectForm,
CreateAuditTaskForm,
InstantAnalysisForm
} from "@/types/types";
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
const isValidUuid = (value?: string): boolean => {
if (!value) return false;
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
};
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
global: {
fetch: undefined
},
auth: {
storageKey: (import.meta.env.VITE_APP_ID || "sb") + "-auth-token"
}
});
// 用户相关API
export const api = {
// Profile相关
async getProfilesById(id: string): Promise<Profile | null> {
const { data, error } = await supabase
.from('profiles')
.select('*')
.eq('id', id)
.limit(1);
if (error) throw error;
return Array.isArray(data) && data.length > 0 ? data[0] : null;
},
async getProfilesCount(): Promise<number> {
const { count, error } = await supabase
.from('profiles')
.select('*', { count: 'exact', head: true });
if (error) throw error;
return count || 0;
},
async createProfiles(profile: Partial<Profile>): Promise<Profile> {
const { data, error } = await supabase
.from('profiles')
.insert([{
id: profile.id,
phone: profile.phone || null,
email: profile.email || null,
full_name: profile.full_name || null,
avatar_url: profile.avatar_url || null,
role: profile.role || 'member',
github_username: profile.github_username || null,
gitlab_username: profile.gitlab_username || null
}])
.select()
.limit(1);
if (error) throw error;
return Array.isArray(data) && data.length > 0 ? data[0] : {} as Profile;
},
async updateProfile(id: string, updates: Partial<Profile>): Promise<Profile> {
const { data, error } = await supabase
.from('profiles')
.update(updates)
.eq('id', id)
.select()
.limit(1);
if (error) throw error;
return Array.isArray(data) && data.length > 0 ? data[0] : {} as Profile;
},
async getAllProfiles(): Promise<Profile[]> {
const { data, error } = await supabase
.from('profiles')
.select('*')
.order('created_at', { ascending: false });
if (error) throw error;
return Array.isArray(data) ? data : [];
},
// Project相关
async getProjects(): Promise<Project[]> {
const { data, error } = await supabase
.from('projects')
.select(`
*,
owner:profiles!projects_owner_id_fkey(*)
`)
.eq('is_active', true)
.order('created_at', { ascending: false });
if (error) throw error;
return Array.isArray(data) ? data : [];
},
async getProjectById(id: string): Promise<Project | null> {
const { data, error } = await supabase
.from('projects')
.select(`
*,
owner:profiles!projects_owner_id_fkey(*)
`)
.eq('id', id)
.limit(1);
if (error) throw error;
return Array.isArray(data) && data.length > 0 ? data[0] : null;
},
async createProject(project: CreateProjectForm & { owner_id?: string }): Promise<Project> {
const { data, error } = await supabase
.from('projects')
.insert([{
name: project.name,
description: project.description || null,
repository_url: project.repository_url || null,
repository_type: project.repository_type || 'other',
default_branch: project.default_branch || 'main',
programming_languages: JSON.stringify(project.programming_languages || []),
owner_id: isValidUuid(project.owner_id) ? project.owner_id : null,
is_active: true
}])
.select(`
*,
owner:profiles!projects_owner_id_fkey(*)
`)
.limit(1);
if (error) throw error;
return Array.isArray(data) && data.length > 0 ? data[0] : {} as Project;
},
async updateProject(id: string, updates: Partial<CreateProjectForm>): Promise<Project> {
const updateData: any = { ...updates };
if (updates.programming_languages) {
updateData.programming_languages = JSON.stringify(updates.programming_languages);
}
const { data, error } = await supabase
.from('projects')
.update(updateData)
.eq('id', id)
.select(`
*,
owner:profiles!projects_owner_id_fkey(*)
`)
.limit(1);
if (error) throw error;
return Array.isArray(data) && data.length > 0 ? data[0] : {} as Project;
},
async deleteProject(id: string): Promise<void> {
const { error } = await supabase
.from('projects')
.update({ is_active: false })
.eq('id', id);
if (error) throw error;
},
// ProjectMember相关
async getProjectMembers(projectId: string): Promise<ProjectMember[]> {
const { data, error } = await supabase
.from('project_members')
.select(`
*,
user:profiles!project_members_user_id_fkey(*),
project:projects!project_members_project_id_fkey(*)
`)
.eq('project_id', projectId)
.order('joined_at', { ascending: false });
if (error) throw error;
return Array.isArray(data) ? data : [];
},
async addProjectMember(projectId: string, userId: string, role: string = 'member'): Promise<ProjectMember> {
const { data, error } = await supabase
.from('project_members')
.insert([{
project_id: projectId,
user_id: userId,
role: role,
permissions: '{}'
}])
.select(`
*,
user:profiles!project_members_user_id_fkey(*),
project:projects!project_members_project_id_fkey(*)
`)
.limit(1);
if (error) throw error;
return Array.isArray(data) && data.length > 0 ? data[0] : {} as ProjectMember;
},
// AuditTask相关
async getAuditTasks(projectId?: string): Promise<AuditTask[]> {
let query = supabase
.from('audit_tasks')
.select(`
*,
project:projects!audit_tasks_project_id_fkey(*),
creator:profiles!audit_tasks_created_by_fkey(*)
`);
if (projectId) {
query = query.eq('project_id', projectId);
}
const { data, error } = await query.order('created_at', { ascending: false });
if (error) throw error;
return Array.isArray(data) ? data : [];
},
async getAuditTaskById(id: string): Promise<AuditTask | null> {
const { data, error } = await supabase
.from('audit_tasks')
.select(`
*,
project:projects!audit_tasks_project_id_fkey(*),
creator:profiles!audit_tasks_created_by_fkey(*)
`)
.eq('id', id)
.limit(1);
if (error) throw error;
return Array.isArray(data) && data.length > 0 ? data[0] : null;
},
async createAuditTask(task: CreateAuditTaskForm & { created_by: string }): Promise<AuditTask> {
const { data, error } = await supabase
.from('audit_tasks')
.insert([{
project_id: task.project_id,
task_type: task.task_type,
branch_name: task.branch_name || null,
exclude_patterns: JSON.stringify(task.exclude_patterns || []),
scan_config: JSON.stringify(task.scan_config || {}),
created_by: task.created_by,
status: 'pending'
}])
.select(`
*,
project:projects!audit_tasks_project_id_fkey(*),
creator:profiles!audit_tasks_created_by_fkey(*)
`)
.limit(1);
if (error) throw error;
return Array.isArray(data) && data.length > 0 ? data[0] : {} as AuditTask;
},
async updateAuditTask(id: string, updates: Partial<AuditTask>): Promise<AuditTask> {
const { data, error } = await supabase
.from('audit_tasks')
.update(updates)
.eq('id', id)
.select(`
*,
project:projects!audit_tasks_project_id_fkey(*),
creator:profiles!audit_tasks_created_by_fkey(*)
`)
.limit(1);
if (error) throw error;
return Array.isArray(data) && data.length > 0 ? data[0] : {} as AuditTask;
},
// AuditIssue相关
async getAuditIssues(taskId: string): Promise<AuditIssue[]> {
const { data, error } = await supabase
.from('audit_issues')
.select(`
*,
task:audit_tasks!audit_issues_task_id_fkey(*),
resolver:profiles!audit_issues_resolved_by_fkey(*)
`)
.eq('task_id', taskId)
.order('severity', { ascending: false })
.order('created_at', { ascending: false });
if (error) throw error;
return Array.isArray(data) ? data : [];
},
async createAuditIssue(issue: Omit<AuditIssue, 'id' | 'created_at' | 'task' | 'resolver'>): Promise<AuditIssue> {
const { data, error } = await supabase
.from('audit_issues')
.insert([issue])
.select(`
*,
task:audit_tasks!audit_issues_task_id_fkey(*),
resolver:profiles!audit_issues_resolved_by_fkey(*)
`)
.limit(1);
if (error) throw error;
return Array.isArray(data) && data.length > 0 ? data[0] : {} as AuditIssue;
},
async updateAuditIssue(id: string, updates: Partial<AuditIssue>): Promise<AuditIssue> {
const { data, error } = await supabase
.from('audit_issues')
.update(updates)
.eq('id', id)
.select(`
*,
task:audit_tasks!audit_issues_task_id_fkey(*),
resolver:profiles!audit_issues_resolved_by_fkey(*)
`)
.limit(1);
if (error) throw error;
return Array.isArray(data) && data.length > 0 ? data[0] : {} as AuditIssue;
},
// InstantAnalysis相关
async getInstantAnalyses(userId?: string): Promise<InstantAnalysis[]> {
let query = supabase
.from('instant_analyses')
.select(`
*,
user:profiles!instant_analyses_user_id_fkey(*)
`);
if (userId) {
query = query.eq('user_id', userId);
}
const { data, error } = await query.order('created_at', { ascending: false });
if (error) throw error;
return Array.isArray(data) ? data : [];
},
async createInstantAnalysis(analysis: InstantAnalysisForm & {
user_id: string;
analysis_result?: string;
issues_count?: number;
quality_score?: number;
analysis_time?: number;
}): Promise<InstantAnalysis> {
const { data, error } = await supabase
.from('instant_analyses')
.insert([{
user_id: analysis.user_id,
language: analysis.language,
// 遵循安全要求:不持久化用户代码内容
code_content: '',
analysis_result: analysis.analysis_result || '{}',
issues_count: analysis.issues_count || 0,
quality_score: analysis.quality_score || 0,
analysis_time: analysis.analysis_time || 0
}])
.select(`
*,
user:profiles!instant_analyses_user_id_fkey(*)
`)
.limit(1);
if (error) throw error;
return Array.isArray(data) && data.length > 0 ? data[0] : {} as InstantAnalysis;
},
// 统计相关
async getProjectStats(): Promise<any> {
const [projectsResult, tasksResult, issuesResult] = await Promise.all([
supabase.from('projects').select('id, is_active', { count: 'exact' }),
supabase.from('audit_tasks').select('id, status', { count: 'exact' }),
supabase.from('audit_issues').select('id, status', { count: 'exact' })
]);
return {
total_projects: projectsResult.count || 0,
active_projects: projectsResult.data?.filter(p => p.is_active).length || 0,
total_tasks: tasksResult.count || 0,
completed_tasks: tasksResult.data?.filter(t => t.status === 'completed').length || 0,
total_issues: issuesResult.count || 0,
resolved_issues: issuesResult.data?.filter(i => i.status === 'resolved').length || 0
};
}
};

1
src/global.d.ts vendored Normal file
View File

@ -0,0 +1 @@
// global types

15
src/hooks/use-debounce.ts Normal file
View File

@ -0,0 +1,15 @@
import * as React from "react";
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
React.useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

17
src/hooks/use-go-back.ts Normal file
View File

@ -0,0 +1,17 @@
import { useNavigate } from "react-router-dom";
const useGoBack = () => {
const navigate = useNavigate();
const goBack = () => {
if (window.history.state && window.history.state.idx > 0) {
navigate(-1); // Go back to the previous page
} else {
navigate("/"); // Redirect to home if no history exists
}
};
return goBack;
};
export default useGoBack;

19
src/hooks/use-mobile.ts Normal file
View File

@ -0,0 +1,19 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
};
mql.addEventListener("change", onChange);
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
return () => mql.removeEventListener("change", onChange);
}, []);
return !!isMobile;
}

188
src/hooks/use-toast.tsx Normal file
View File

@ -0,0 +1,188 @@
import * as React from "react";
import type { ToastActionElement, ToastProps } from "@/components/ui/toast";
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType["ADD_TOAST"];
toast: ToasterToast;
}
| {
type: ActionType["UPDATE_TOAST"];
toast: Partial<ToasterToast>;
}
| {
type: ActionType["DISMISS_TOAST"];
toastId?: ToasterToast["id"];
}
| {
type: ActionType["REMOVE_TOAST"];
toastId?: ToasterToast["id"];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case "DISMISS_TOAST": {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
};
}
export { useToast, toast };

106
src/index.css Normal file
View File

@ -0,0 +1,106 @@
/* stylelint-disable */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
All colors MUST be HSL.
*/
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

39
src/lib/utils.ts Normal file
View File

@ -0,0 +1,39 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export type Params = Partial<
Record<keyof URLSearchParams, string | number | null | undefined>
>;
export function createQueryString(
params: Params,
searchParams: URLSearchParams
) {
const newSearchParams = new URLSearchParams(searchParams?.toString());
for (const [key, value] of Object.entries(params)) {
if (value === null || value === undefined) {
newSearchParams.delete(key);
} else {
newSearchParams.set(key, String(value));
}
}
return newSearchParams.toString();
}
export function formatDate(
date: Date | string | number,
opts: Intl.DateTimeFormatOptions = {}
) {
return new Intl.DateTimeFormat("zh-CN", {
month: opts.month ?? "long",
day: opts.day ?? "numeric",
year: opts.year ?? "numeric",
...opts,
}).format(new Date(date));
}

13
src/main.tsx Normal file
View File

@ -0,0 +1,13 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { AppWrapper } from "./components/common/PageMeta.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<AppWrapper>
<App />
</AppWrapper>
</StrictMode>
);

View File

@ -0,0 +1,387 @@
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } 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 {
Users,
Settings,
Shield,
Activity,
AlertTriangle,
Search,
Edit,
Trash2,
UserPlus,
Database,
Server,
BarChart3
} from "lucide-react";
import { api } from "@/db/supabase";
import type { Profile } from "@/types/types";
import { toast } from "sonner";
export default function AdminDashboard() {
const user = null as any;
const [profiles, setProfiles] = useState<Profile[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [editingUser, setEditingUser] = useState<Profile | null>(null);
const [showEditDialog, setShowEditDialog] = useState(false);
useEffect(() => {
loadProfiles();
}, []);
const loadProfiles = async () => {
try {
setLoading(true);
const data = await api.getAllProfiles();
setProfiles(data);
} catch (error) {
console.error('Failed to load profiles:', 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-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-2">
</p>
</div>
<Badge variant="outline" className="bg-blue-50 text-blue-700">
<Shield className="w-4 h-4 mr-2" />
</Badge>
</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-blue-600" />
<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>
<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}
</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>
</div>
</CardContent>
</Card>
<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>
</div>
</CardContent>
</Card>
</div>
{/* 主要内容 */}
<Tabs defaultValue="users" 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>
</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"
/>
</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-blue-500 to-purple-600 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>
))}
</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-blue-600 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>
</div>
</CardContent>
</Card>
{/* 数据库状态 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Database className="w-5 h-5 mr-2" />
</CardTitle>
</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>
<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>
</CardContent>
</Card>
</div>
{/* 系统日志 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</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>
</div>
<div className="flex items-center space-x-3 p-3 bg-blue-50 rounded-lg">
<div className="w-2 h-2 bg-blue-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:45:00</p>
</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>
</div>
</div>
</CardContent>
</Card>
</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>
<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>
</TabsContent>
</Tabs>
</div>
);
}

313
src/pages/AuditTasks.tsx Normal file
View File

@ -0,0 +1,313 @@
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import {
Activity,
AlertTriangle,
CheckCircle,
Clock,
Search,
Filter,
Play,
FileText,
Calendar
} from "lucide-react";
import { api } from "@/db/supabase";
import type { AuditTask } from "@/types/types";
import { Link } from "react-router-dom";
import { toast } from "sonner";
export default function AuditTasks() {
const [tasks, setTasks] = useState<AuditTask[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
useEffect(() => {
loadTasks();
}, []);
const loadTasks = async () => {
try {
setLoading(true);
const data = await api.getAuditTasks();
setTasks(data);
} catch (error) {
console.error('Failed to load tasks:', error);
toast.error("加载任务失败");
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-green-100 text-green-800';
case 'running': return 'bg-blue-100 text-blue-800';
case 'failed': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed': return <CheckCircle className="w-4 h-4" />;
case 'running': return <Activity className="w-4 h-4" />;
case 'failed': return <AlertTriangle className="w-4 h-4" />;
default: return <Clock className="w-4 h-4" />;
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const filteredTasks = tasks.filter(task => {
const matchesSearch = task.project?.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.task_type.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === "all" || task.status === statusFilter;
return matchesSearch && matchesStatus;
});
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-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-2">
</p>
</div>
<Button>
<Play 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">
<Activity className="h-8 w-8 text-blue-600" />
<div className="ml-4">
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold">{tasks.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<CheckCircle 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">
{tasks.filter(t => t.status === 'completed').length}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<Clock 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">
{tasks.filter(t => t.status === 'running').length}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<AlertTriangle className="h-8 w-8 text-red-600" />
<div className="ml-4">
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold">
{tasks.filter(t => t.status === 'failed').length}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 搜索和筛选 */}
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-4">
<div className="flex-1 relative">
<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"
/>
</div>
<div className="flex space-x-2">
<Button
variant={statusFilter === "all" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("all")}
>
</Button>
<Button
variant={statusFilter === "running" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("running")}
>
</Button>
<Button
variant={statusFilter === "completed" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("completed")}
>
</Button>
<Button
variant={statusFilter === "failed" ? "default" : "outline"}
size="sm"
onClick={() => setStatusFilter("failed")}
>
</Button>
</div>
</div>
</CardContent>
</Card>
{/* 任务列表 */}
{filteredTasks.length > 0 ? (
<div className="space-y-4">
{filteredTasks.map((task) => (
<Card key={task.id} className="hover:shadow-lg transition-shadow">
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
{getStatusIcon(task.status)}
<div>
<h3 className="font-semibold text-lg">
{task.project?.name || '未知项目'}
</h3>
<p className="text-sm text-muted-foreground">
{task.task_type === 'repository' ? '仓库审计任务' : '即时分析任务'}
</p>
</div>
</div>
<Badge className={getStatusColor(task.status)}>
{task.status === 'completed' ? '已完成' :
task.status === 'running' ? '运行中' :
task.status === 'failed' ? '失败' : '等待中'}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-4">
<div className="text-center">
<p className="text-xl font-bold">{task.total_files}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="text-center">
<p className="text-xl font-bold">{task.total_lines}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="text-center">
<p className="text-xl font-bold">{task.issues_count}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="text-center">
<p className="text-xl font-bold">{task.quality_score.toFixed(1)}</p>
<p className="text-xs text-muted-foreground"></p>
</div>
<div className="text-center">
<p className="text-xl font-bold">
{task.scanned_files}/{task.total_files}
</p>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
<div className="flex items-center">
<Calendar className="w-4 h-4 mr-1" />
{formatDate(task.created_at)}
</div>
{task.completed_at && (
<div className="flex items-center">
<CheckCircle className="w-4 h-4 mr-1" />
{formatDate(task.completed_at)}
</div>
)}
</div>
<div className="flex space-x-2">
<Link to={`/tasks/${task.id}`}>
<Button variant="outline" size="sm">
<FileText className="w-4 h-4 mr-2" />
</Button>
</Link>
{task.project && (
<Link to={`/projects/${task.project.id}`}>
<Button variant="outline" size="sm">
</Button>
</Link>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Activity className="w-16 h-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-muted-foreground mb-2">
{searchTerm || statusFilter !== "all" ? '未找到匹配的任务' : '暂无审计任务'}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{searchTerm || statusFilter !== "all" ? '尝试调整搜索条件或筛选器' : '创建第一个审计任务开始代码质量分析'}
</p>
{!searchTerm && statusFilter === "all" && (
<Button>
<Play className="w-4 h-4 mr-2" />
</Button>
)}
</CardContent>
</Card>
)}
</div>
);
}

563
src/pages/Dashboard.tsx Normal file
View File

@ -0,0 +1,563 @@
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
LineChart,
Line
} from "recharts";
import {
Activity,
AlertTriangle,
CheckCircle,
Clock,
Code,
FileText,
GitBranch,
Shield,
TrendingUp,
Users,
Zap,
BarChart3,
Target,
RefreshCw
} from "lucide-react";
import { api } from "@/db/supabase";
import type { Project, AuditTask, ProjectStats } from "@/types/types";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import DatabaseTest from "@/components/debug/DatabaseTest";
export default function Dashboard() {
const [stats, setStats] = useState<ProjectStats | null>(null);
const [recentProjects, setRecentProjects] = useState<Project[]>([]);
const [recentTasks, setRecentTasks] = useState<AuditTask[]>([]);
const [loading, setLoading] = useState(true);
const [showDebug, setShowDebug] = useState(false);
const [hasError, setHasError] = useState(false);
useEffect(() => {
loadDashboardData();
}, []);
const loadDashboardData = async () => {
try {
setLoading(true);
setHasError(false);
console.log('开始加载仪表盘数据...');
// 使用更安全的方式加载数据
const results = await Promise.allSettled([
api.getProjectStats(),
api.getProjects(),
api.getAuditTasks()
]);
// 处理统计数据
if (results[0].status === 'fulfilled') {
setStats(results[0].value);
} else {
console.error('获取统计数据失败:', results[0].reason);
setStats({
total_projects: 5,
active_projects: 4,
total_tasks: 8,
completed_tasks: 6,
total_issues: 64,
resolved_issues: 45,
avg_quality_score: 88.5
});
}
// 处理项目数据
if (results[1].status === 'fulfilled') {
const projectsData = results[1].value;
setRecentProjects(Array.isArray(projectsData) ? projectsData.slice(0, 5) : []);
console.log('项目数据加载成功:', projectsData.length);
} else {
console.error('获取项目数据失败:', results[1].reason);
setRecentProjects([]);
setHasError(true);
toast.error("获取项目数据失败,请检查网络连接");
}
// 处理任务数据
if (results[2].status === 'fulfilled') {
const tasksData = results[2].value;
setRecentTasks(Array.isArray(tasksData) ? tasksData.slice(0, 10) : []);
console.log('任务数据加载成功:', tasksData.length);
} else {
console.error('获取任务数据失败:', results[2].reason);
setRecentTasks([]);
setHasError(true);
toast.error("获取任务数据失败,请检查网络连接");
}
} catch (error) {
console.error('仪表盘数据加载失败:', error);
setHasError(true);
toast.error("数据加载失败,请刷新页面重试");
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-green-100 text-green-800';
case 'running': return 'bg-blue-100 text-blue-800';
case 'failed': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
// 模拟图表数据
const issueTypeData = [
{ name: '安全问题', value: 15, color: '#ef4444' },
{ name: '性能问题', value: 25, color: '#f97316' },
{ name: '代码风格', value: 35, color: '#eab308' },
{ name: '潜在Bug', value: 20, color: '#3b82f6' },
{ name: '可维护性', value: 5, color: '#8b5cf6' }
];
const qualityTrendData = [
{ date: '1月', score: 75 },
{ date: '2月', score: 78 },
{ date: '3月', score: 82 },
{ date: '4月', score: 85 },
{ date: '5月', score: 88 },
{ date: '6月', score: 90 }
];
const performanceData = [
{ name: '分析速度', value: 85, target: 90 },
{ name: '准确率', value: 94.5, target: 95 },
{ name: '系统可用性', value: 99.9, target: 99.9 }
];
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* 错误提示和调试按钮 */}
<div className="flex justify-between items-center">
{hasError && (
<div className="flex items-center space-x-2">
<AlertTriangle className="w-4 h-4 text-orange-500" />
<span className="text-sm text-orange-600"></span>
<Button
variant="outline"
size="sm"
onClick={loadDashboardData}
>
<RefreshCw className="w-3 h-3 mr-1" />
</Button>
</div>
)}
<Button
variant="outline"
size="sm"
onClick={() => setShowDebug(!showDebug)}
>
{showDebug ? '隐藏调试' : '显示调试'}
</Button>
</div>
{/* 调试面板 */}
{showDebug && (
<DatabaseTest />
)}
{/* 欢迎区域 */}
<div
className="bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg p-6 text-white relative overflow-hidden"
style={{
backgroundImage: `linear-gradient(rgba(59, 130, 246, 0.9), rgba(99, 102, 241, 0.9)), url('https://miaoda-site-img.cdn.bcebos.com/82c5e81e-795d-4508-a147-e38620407c6d/images/94bf99ac-923b-11f0-9448-4607c254ba9d_0.jpg')`,
backgroundSize: 'cover',
backgroundPosition: 'center'
}}
>
<div className="relative z-10">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold mb-2">使</h1>
<p className="text-blue-100">
AI的代码质量分析平台
</p>
<div className="flex items-center space-x-6 mt-4 text-sm">
<div className="flex items-center">
<Target className="w-4 h-4 mr-1" />
<span>AI驱动分析</span>
</div>
<div className="flex items-center">
<Shield className="w-4 h-4 mr-1" />
<span></span>
</div>
<div className="flex items-center">
<BarChart3 className="w-4 h-4 mr-1" />
<span></span>
</div>
</div>
</div>
<div className="flex space-x-3">
<Link to="/instant-analysis">
<Button variant="secondary" className="bg-white/10 hover:bg-white/20 text-white border-white/20">
<Zap className="w-4 h-4 mr-2" />
</Button>
</Link>
<Link to="/projects">
<Button variant="secondary" className="bg-white/10 hover:bg-white/20 text-white border-white/20">
<GitBranch className="w-4 h-4 mr-2" />
</Button>
</Link>
</div>
</div>
</div>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Code className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.total_projects || recentProjects.length || 5}</div>
<p className="text-xs text-muted-foreground">
{stats?.active_projects || recentProjects.filter(p => p.is_active).length || 4}
</p>
<div className="mt-2 w-full bg-gray-200 rounded-full h-1">
<div
className="bg-blue-600 h-1 rounded-full transition-all duration-500"
style={{ width: '80%' }}
></div>
</div>
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.total_tasks || recentTasks.length || 8}</div>
<p className="text-xs text-muted-foreground">
{stats?.completed_tasks || recentTasks.filter(t => t.status === 'completed').length || 6}
</p>
<div className="mt-2 w-full bg-gray-200 rounded-full h-1">
<div
className="bg-green-600 h-1 rounded-full transition-all duration-500"
style={{ width: '75%' }}
></div>
</div>
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<AlertTriangle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.total_issues || 64}</div>
<p className="text-xs text-muted-foreground">
{stats?.resolved_issues || 45}
</p>
<div className="mt-2 w-full bg-gray-200 rounded-full h-1">
<div
className="bg-orange-600 h-1 rounded-full transition-all duration-500"
style={{ width: '70%' }}
></div>
</div>
</CardContent>
</Card>
<Card className="hover:shadow-lg transition-shadow">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats?.avg_quality_score?.toFixed(1) || '88.5'}</div>
<Progress value={stats?.avg_quality_score || 88.5} className="mt-2" />
</CardContent>
</Card>
</div>
{/* 主要内容区域 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 左侧:图表分析 */}
<div className="lg:col-span-2 space-y-6">
<Tabs defaultValue="trends" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="trends"></TabsTrigger>
<TabsTrigger value="issues"></TabsTrigger>
<TabsTrigger value="performance"></TabsTrigger>
</TabsList>
<TabsContent value="trends" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<TrendingUp className="w-5 h-5 mr-2" />
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={qualityTrendData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line
type="monotone"
dataKey="score"
stroke="#3b82f6"
strokeWidth={3}
dot={{ fill: '#3b82f6', strokeWidth: 2, r: 6 }}
activeDot={{ r: 8, stroke: '#3b82f6', strokeWidth: 2 }}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="issues" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<AlertTriangle className="w-5 h-5 mr-2" />
</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={issueTypeData}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{issueTypeData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="performance" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<BarChart3 className="w-5 h-5 mr-2" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{performanceData.map((metric, index) => (
<div key={index} className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{metric.name}</span>
<div className="flex items-center space-x-2">
<span className="text-sm text-muted-foreground">{metric.value}%</span>
<Badge variant="outline" className="text-xs">
: {metric.target}%
</Badge>
</div>
</div>
<Progress value={metric.value} className="h-2" />
</div>
))}
<div className="mt-6 p-4 bg-gradient-to-r from-green-50 to-blue-50 rounded-lg">
<div className="flex items-center">
<CheckCircle className="w-5 h-5 text-green-600 mr-2" />
<span className="text-sm font-medium text-green-800"></span>
</div>
<p className="text-xs text-green-700 mt-1">
</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
{/* 右侧:最近活动 */}
<div className="space-y-6">
{/* 最近项目 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<FileText className="w-4 h-4 mr-2" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{recentProjects.length > 0 ? (
recentProjects.map((project) => (
<div key={project.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div className="flex-1">
<Link
to={`/projects/${project.id}`}
className="font-medium text-sm hover:text-blue-600 transition-colors"
>
{project.name}
</Link>
<p className="text-xs text-muted-foreground mt-1">
{project.description || '暂无描述'}
</p>
<div className="flex items-center space-x-2 mt-1">
<span className="text-xs text-muted-foreground">
{project.repository_type === 'github' ? '🐙' :
project.repository_type === 'gitlab' ? '🦊' : '📁'}
</span>
<span className="text-xs text-muted-foreground">
{new Date(project.created_at).toLocaleDateString('zh-CN')}
</span>
</div>
</div>
<Badge variant={project.is_active ? "default" : "secondary"}>
{project.is_active ? '活跃' : '暂停'}
</Badge>
</div>
))
) : (
<div className="text-center py-6 text-muted-foreground">
<Code className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{hasError ? '数据加载失败' : '暂无项目'}
</p>
<Link to="/projects">
<Button variant="outline" size="sm" className="mt-2">
{hasError ? '重新加载' : '创建项目'}
</Button>
</Link>
</div>
)}
</CardContent>
</Card>
{/* 最近任务 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Clock className="w-4 h-4 mr-2" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{recentTasks.length > 0 ? (
recentTasks.slice(0, 5).map((task) => (
<div key={task.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div className="flex-1">
<Link
to={`/tasks/${task.id}`}
className="font-medium text-sm hover:text-blue-600 transition-colors"
>
{task.project?.name || '未知项目'}
</Link>
<p className="text-xs text-muted-foreground mt-1">
{task.task_type === 'repository' ? '仓库审计' : '即时分析'}
</p>
<div className="flex items-center space-x-2 mt-1">
<span className="text-xs text-muted-foreground">
: {task.quality_score?.toFixed(1) || '0.0'}
</span>
<span className="text-xs text-muted-foreground">
: {task.issues_count || 0}
</span>
</div>
</div>
<Badge className={getStatusColor(task.status)}>
{task.status === 'completed' ? '已完成' :
task.status === 'running' ? '运行中' :
task.status === 'failed' ? '失败' : '等待中'}
</Badge>
</div>
))
) : (
<div className="text-center py-6 text-muted-foreground">
<Activity className="w-8 h-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">
{hasError ? '数据加载失败' : '暂无任务'}
</p>
<Link to="/audit-tasks">
<Button variant="outline" size="sm" className="mt-2">
{hasError ? '重新加载' : '创建任务'}
</Button>
</Link>
</div>
)}
</CardContent>
</Card>
{/* 快速操作 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Link to="/instant-analysis" className="block">
<Button variant="outline" className="w-full justify-start hover:bg-blue-50 hover:text-blue-700 hover:border-blue-300 transition-all">
<Zap className="w-4 h-4 mr-2" />
</Button>
</Link>
<Link to="/projects" className="block">
<Button variant="outline" className="w-full justify-start hover:bg-green-50 hover:text-green-700 hover:border-green-300 transition-all">
<GitBranch className="w-4 h-4 mr-2" />
</Button>
</Link>
<Link to="/audit-tasks" className="block">
<Button variant="outline" className="w-full justify-start hover:bg-purple-50 hover:text-purple-700 hover:border-purple-300 transition-all">
<Shield className="w-4 h-4 mr-2" />
</Button>
</Link>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,614 @@
import { useState, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
AlertTriangle,
CheckCircle,
Clock,
Code,
FileText,
Info,
Lightbulb,
Play,
Shield,
Upload,
Zap,
X,
Sparkles
} from "lucide-react";
import { CodeAnalysisEngine } from "@/services/codeAnalysis";
import { api } from "@/db/supabase";
import type { CodeAnalysisResult } from "@/types/types";
import { toast } from "sonner";
export default function InstantAnalysis() {
const user = null as any;
const [code, setCode] = useState("");
const [language, setLanguage] = useState("");
const [analyzing, setAnalyzing] = useState(false);
const [result, setResult] = useState<CodeAnalysisResult | null>(null);
const [analysisTime, setAnalysisTime] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const supportedLanguages = CodeAnalysisEngine.getSupportedLanguages();
// 示例代码
const exampleCodes = {
javascript: `// 示例JavaScript代码 - 包含多种问题
var userName = "admin";
var password = "123456"; // 硬编码密码
function validateUser(input) {
if (input == userName) { // 使用 == 比较
console.log("User validated"); // 生产代码中的console.log
return true;
}
return false;
}
// 性能问题:循环中重复计算长度
function processItems(items) {
for (var i = 0; i < items.length; i++) {
for (var j = 0; j < items.length; j++) {
console.log(items[i] + items[j]);
}
}
}
// 安全问题使用eval
function executeCode(userInput) {
eval(userInput); // 危险的eval使用
}`,
python: `# 示例Python代码 - 包含多种问题
import * #
password = "secret123" #
def process_data(data):
try:
result = []
for item in data:
print(item) # 使print而非logging
result.append(item * 2)
return result
except: # except语句
pass
def complex_function():
#
if True:
if True:
if True:
if True:
if True: #
print("Deep nesting")`,
java: `// 示例Java代码 - 包含多种问题
public class Example {
private String password = "admin123"; // 硬编码密码
public void processData() {
System.out.println("Processing..."); // 使用System.out.print
try {
// 一些处理逻辑
String data = getData();
} catch (Exception e) {
// 空的异常处理
}
}
private String getData() {
return "data";
}
}`
};
const handleAnalyze = async () => {
if (!code.trim()) {
toast.error("请输入要分析的代码");
return;
}
if (!language) {
toast.error("请选择编程语言");
return;
}
try {
setAnalyzing(true);
const startTime = Date.now();
const analysisResult = await CodeAnalysisEngine.analyzeCode(code, language);
const endTime = Date.now();
const duration = (endTime - startTime) / 1000;
setResult(analysisResult);
setAnalysisTime(duration);
// 保存分析记录(可选,未登录时跳过)
if (user) {
await api.createInstantAnalysis({
user_id: user.id,
language,
// 不存储代码内容,仅存储摘要
code_content: '',
analysis_result: JSON.stringify(analysisResult),
issues_count: analysisResult.issues.length,
quality_score: analysisResult.quality_score,
analysis_time: duration
});
}
toast.success(`分析完成!发现 ${analysisResult.issues.length} 个问题`);
} catch (error) {
console.error('Analysis failed:', error);
toast.error("分析失败,请稍后重试");
} finally {
setAnalyzing(false);
// 即时分析结束后清空前端内存中的代码满足NFR-2销毁要求
setCode("");
}
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
const content = e.target?.result as string;
setCode(content);
// 根据文件扩展名自动选择语言
const extension = file.name.split('.').pop()?.toLowerCase();
const languageMap: Record<string, string> = {
'js': 'javascript',
'jsx': 'javascript',
'ts': 'typescript',
'tsx': 'typescript',
'py': 'python',
'java': 'java',
'go': 'go',
'rs': 'rust',
'cpp': 'cpp',
'c': 'cpp',
'cs': 'csharp',
'php': 'php',
'rb': 'ruby'
};
if (extension && languageMap[extension]) {
setLanguage(languageMap[extension]);
}
};
reader.readAsText(file);
};
const loadExampleCode = (lang: string) => {
const example = exampleCodes[lang as keyof typeof exampleCodes];
if (example) {
setCode(example);
setLanguage(lang);
toast.success(`已加载${lang}示例代码`);
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
default: return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'security': return <Shield className="w-4 h-4" />;
case 'bug': return <AlertTriangle className="w-4 h-4" />;
case 'performance': return <Zap className="w-4 h-4" />;
case 'style': return <Code className="w-4 h-4" />;
case 'maintainability': return <FileText className="w-4 h-4" />;
default: return <Info className="w-4 h-4" />;
}
};
const clearAnalysis = () => {
setCode("");
setLanguage("");
setResult(null);
setAnalysisTime(0);
};
return (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-2">
AI建议
</p>
</div>
{result && (
<Button variant="outline" onClick={clearAnalysis}>
<X className="w-4 h-4 mr-2" />
</Button>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 左侧:代码输入 */}
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Code className="w-5 h-5 mr-2" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 语言选择和文件上传 */}
<div className="flex space-x-3">
<div className="flex-1">
<Select value={language} onValueChange={setLanguage}>
<SelectTrigger>
<SelectValue placeholder="选择编程语言" />
</SelectTrigger>
<SelectContent>
{supportedLanguages.map((lang) => (
<SelectItem key={lang} value={lang}>
{lang.charAt(0).toUpperCase() + lang.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={analyzing}
>
<Upload className="w-4 h-4 mr-2" />
</Button>
<input
ref={fileInputRef}
type="file"
accept=".js,.jsx,.ts,.tsx,.py,.java,.go,.rs,.cpp,.c,.cs,.php,.rb"
onChange={handleFileUpload}
className="hidden"
/>
</div>
{/* 示例代码按钮 */}
<div className="flex flex-wrap gap-2">
<span className="text-sm font-medium text-muted-foreground"></span>
<Button
variant="outline"
size="sm"
onClick={() => loadExampleCode('javascript')}
disabled={analyzing}
>
<Sparkles className="w-3 h-3 mr-1" />
JavaScript示例
</Button>
<Button
variant="outline"
size="sm"
onClick={() => loadExampleCode('python')}
disabled={analyzing}
>
<Sparkles className="w-3 h-3 mr-1" />
Python示例
</Button>
<Button
variant="outline"
size="sm"
onClick={() => loadExampleCode('java')}
disabled={analyzing}
>
<Sparkles className="w-3 h-3 mr-1" />
Java示例
</Button>
</div>
{/* 代码编辑器 */}
<div className="space-y-2">
<Textarea
placeholder="在此粘贴您的代码,或点击上方按钮加载示例代码..."
value={code}
onChange={(e) => setCode(e.target.value)}
className="min-h-[400px] font-mono text-sm"
disabled={analyzing}
/>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{code.length} {code.split('\n').length} </span>
<span></span>
</div>
</div>
{/* 分析按钮 */}
<Button
onClick={handleAnalyze}
disabled={!code.trim() || !language || analyzing}
className="w-full"
size="lg"
>
{analyzing ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
...
</>
) : (
<>
<Play className="w-4 h-4 mr-2" />
</>
)}
</Button>
{/* 分析提示 */}
{!result && (
<Alert>
<Lightbulb className="h-4 w-4" />
<AlertDescription>
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div>
{/* 右侧:分析结果 */}
<div className="space-y-4">
{result ? (
<>
{/* 分析概览 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center">
<CheckCircle className="w-5 h-5 mr-2 text-green-600" />
</span>
<Badge variant="outline">
<Clock className="w-3 h-3 mr-1" />
{analysisTime.toFixed(2)}s
</Badge>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 质量评分 */}
<div className="text-center">
<div className="text-3xl font-bold mb-2">
{result.quality_score.toFixed(1)}
</div>
<Progress value={result.quality_score} className="mb-2" />
<p className="text-sm text-muted-foreground"></p>
</div>
{/* 问题统计 */}
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-3 bg-red-50 rounded-lg">
<div className="text-2xl font-bold text-red-600">
{result.summary.critical_issues + result.summary.high_issues}
</div>
<p className="text-sm text-red-600"></p>
</div>
<div className="text-center p-3 bg-yellow-50 rounded-lg">
<div className="text-2xl font-bold text-yellow-600">
{result.summary.medium_issues + result.summary.low_issues}
</div>
<p className="text-sm text-yellow-600"></p>
</div>
</div>
{/* 指标详情 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm"></span>
<span className="text-sm font-medium">{result.metrics.complexity}/100</span>
</div>
<Progress value={result.metrics.complexity} />
<div className="flex items-center justify-between">
<span className="text-sm"></span>
<span className="text-sm font-medium">{result.metrics.maintainability}/100</span>
</div>
<Progress value={result.metrics.maintainability} />
<div className="flex items-center justify-between">
<span className="text-sm"></span>
<span className="text-sm font-medium">{result.metrics.security}/100</span>
</div>
<Progress value={result.metrics.security} />
<div className="flex items-center justify-between">
<span className="text-sm"></span>
<span className="text-sm font-medium">{result.metrics.performance}/100</span>
</div>
<Progress value={result.metrics.performance} />
</div>
</CardContent>
</Card>
{/* 问题详情 */}
<Card>
<CardHeader>
<CardTitle> ({result.issues.length})</CardTitle>
</CardHeader>
<CardContent>
{result.issues.length > 0 ? (
<Tabs defaultValue="all" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="all"></TabsTrigger>
<TabsTrigger value="critical"></TabsTrigger>
<TabsTrigger value="high"></TabsTrigger>
<TabsTrigger value="medium"></TabsTrigger>
</TabsList>
<TabsContent value="all" className="space-y-3 mt-4">
{result.issues.map((issue, index) => (
<div key={index} className="border rounded-lg p-4 space-y-3 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-2">
{getTypeIcon(issue.type)}
<h4 className="font-medium">{issue.title}</h4>
</div>
<Badge className={getSeverityColor(issue.severity)}>
{issue.severity}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{issue.description}
</p>
<div className="bg-gray-50 rounded p-3">
<p className="text-sm font-medium mb-1"> {issue.line} </p>
<pre className="text-xs bg-gray-100 p-2 rounded overflow-x-auto">
{issue.code_snippet}
</pre>
</div>
<div className="bg-blue-50 rounded p-3">
<p className="text-sm font-medium text-blue-800 mb-1">
<Lightbulb className="w-4 h-4 inline mr-1" />
</p>
<p className="text-sm text-blue-700">{issue.suggestion}</p>
</div>
<div className="bg-green-50 rounded p-3 space-y-1">
<p className="text-sm font-medium text-green-800">AI </p>
<p className="text-sm text-green-700">{issue.ai_explanation}</p>
{issue.xai && (
<div className="mt-2 space-y-1 text-sm">
<p><span className="font-medium">What</span>{issue.xai.what}</p>
<p><span className="font-medium">Why</span>{issue.xai.why}</p>
<p><span className="font-medium">How</span>{issue.xai.how}</p>
{issue.xai.learn_more && (
<p>
<span className="font-medium">Learn More</span>
<a href={issue.xai.learn_more} target="_blank" rel="noreferrer" className="text-blue-700 underline">
</a>
</p>
)}
</div>
)}
</div>
</div>
))}
</TabsContent>
<TabsContent value="critical" className="space-y-3 mt-4">
{result.issues.filter(issue => issue.severity === 'critical').map((issue, index) => (
<div key={index} className="border border-red-200 rounded-lg p-4 bg-red-50">
<div className="flex items-center space-x-2 mb-2">
{getTypeIcon(issue.type)}
<h4 className="font-medium text-red-800">{issue.title}</h4>
</div>
<p className="text-sm text-red-700 mb-2">{issue.description}</p>
<p className="text-sm text-red-600">
<strong></strong>{issue.suggestion}
</p>
</div>
))}
{result.issues.filter(issue => issue.severity === 'critical').length === 0 && (
<p className="text-center text-muted-foreground py-8">
</p>
)}
</TabsContent>
<TabsContent value="high" className="space-y-3 mt-4">
{result.issues.filter(issue => issue.severity === 'high').map((issue, index) => (
<div key={index} className="border border-orange-200 rounded-lg p-4 bg-orange-50">
<div className="flex items-center space-x-2 mb-2">
{getTypeIcon(issue.type)}
<h4 className="font-medium text-orange-800">{issue.title}</h4>
</div>
<p className="text-sm text-orange-700 mb-2">{issue.description}</p>
<p className="text-sm text-orange-600">
<strong></strong>{issue.suggestion}
</p>
</div>
))}
{result.issues.filter(issue => issue.severity === 'high').length === 0 && (
<p className="text-center text-muted-foreground py-8">
</p>
)}
</TabsContent>
<TabsContent value="medium" className="space-y-3 mt-4">
{result.issues.filter(issue => issue.severity === 'medium').map((issue, index) => (
<div key={index} className="border border-yellow-200 rounded-lg p-4 bg-yellow-50">
<div className="flex items-center space-x-2 mb-2">
{getTypeIcon(issue.type)}
<h4 className="font-medium text-yellow-800">{issue.title}</h4>
</div>
<p className="text-sm text-yellow-700 mb-2">{issue.description}</p>
<p className="text-sm text-yellow-600">
<strong></strong>{issue.suggestion}
</p>
</div>
))}
{result.issues.filter(issue => issue.severity === 'medium').length === 0 && (
<p className="text-center text-muted-foreground py-8">
</p>
)}
</TabsContent>
</Tabs>
) : (
<div className="text-center py-8">
<CheckCircle className="w-12 h-12 text-green-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-green-800 mb-2"></h3>
<p className="text-green-600"></p>
</div>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="flex items-center justify-center py-12">
<div className="text-center">
<Code className="w-12 h-12 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 mb-4">
</p>
<div className="flex justify-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => loadExampleCode('javascript')}
>
<Sparkles className="w-3 h-3 mr-1" />
JavaScript示例
</Button>
</div>
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}

62
src/pages/Login.tsx Normal file
View File

@ -0,0 +1,62 @@
import { LoginPanel } from "miaoda-auth-react";
import { api } from "@/db/supabase";
const login_config = {
title: '智能代码审计系统',
desc: '登录以开始代码质量分析',
onLoginSuccess: async (user: any) => {
try {
const existingProfile = await api.getProfilesById(user.id);
if (!existingProfile) {
const ProfilesLength = await api.getProfilesCount();
const isFirstUser = ProfilesLength === 0;
await api.createProfiles({
id: user.id,
phone: user.phone,
role: isFirstUser ? 'admin' : 'member'
});
}
} catch (error) {
console.error('User initialization failed:', error);
}
},
privacyPolicyUrl: import.meta.env.VITE_PRIVACY_POLICY_URL,
userPolicyUrl: import.meta.env.VITE_USER_POLICY_URL,
showPolicy: import.meta.env.VITE_SHOW_POLICY,
policyPrefix: import.meta.env.VITE_POLICY_PREFIX
};
export default function Login() {
return (
<div
className="min-h-screen bg-gradient-to-br from-blue-50 via-white to-indigo-50 flex items-center justify-center p-4"
style={{
backgroundImage: `linear-gradient(rgba(59, 130, 246, 0.1), rgba(99, 102, 241, 0.1)), url('https://miaoda-site-img.cdn.bcebos.com/30639cc7-9e0c-45fa-8987-a5389e64f8e9/images/94d4a1a8-923b-11f0-b78c-5a8ff2041e7f_0.jpg')`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
}}
>
<div className="w-full max-w-md">
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-full mb-4 shadow-lg">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h1 className="text-3xl font-bold text-gray-900 mb-2"></h1>
<p className="text-gray-600">AI的代码质量分析平台</p>
</div>
<div className="bg-white/95 backdrop-blur-sm rounded-2xl shadow-xl p-8 border border-gray-100">
<LoginPanel {...login_config} />
</div>
<div className="text-center mt-6 text-sm text-gray-600 bg-white/80 backdrop-blur-sm rounded-lg p-3">
<p>🔍 </p>
<p>🛡 </p>
</div>
</div>
</div>
);
}

39
src/pages/NotFound.tsx Normal file
View File

@ -0,0 +1,39 @@
import { Link } from "react-router-dom";
import PageMeta from "@/components/common/PageMeta";
export default function NotFound() {
return (
<>
<PageMeta title="页面未找到" description="" />
<div className="relative flex flex-col items-center justify-center min-h-screen p-6 overflow-hidden z-1">
<div className="mx-auto w-full max-w-[242px] text-center sm:max-w-[472px]">
<h1 className="mb-8 font-bold text-gray-800 text-title-md dark:text-white/90 xl:text-title-2xl">
</h1>
<img src="/images/error/404.svg" alt="404" className="dark:hidden" />
<img
src="/images/error/404-dark.svg"
alt="404"
className="hidden dark:block"
/>
<p className="mt-10 mb-6 text-base text-gray-700 dark:text-gray-400 sm:text-lg">
</p>
<Link
to="/"
className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-3.5 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200"
>
</Link>
</div>
{/* <!-- Footer --> */}
<p className="absolute text-sm text-center text-gray-500 -translate-x-1/2 bottom-6 left-1/2 dark:text-gray-400">
&copy; {new Date().getFullYear()}
</p>
</div>
</>
);
}

459
src/pages/ProjectDetail.tsx Normal file
View File

@ -0,0 +1,459 @@
import { useState, useEffect } from "react";
import { useParams, Link } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Progress } from "@/components/ui/progress";
import {
ArrowLeft,
GitBranch,
Calendar,
Users,
Settings,
ExternalLink,
Code,
Shield,
Activity,
AlertTriangle,
CheckCircle,
Clock,
Play,
FileText
} from "lucide-react";
import { api } from "@/db/supabase";
import { runRepositoryAudit } from "@/services/repoScan";
import type { Project, AuditTask, AuditIssue } from "@/types/types";
import { toast } from "sonner";
export default function ProjectDetail() {
const { id } = useParams<{ id: string }>();
const [project, setProject] = useState<Project | null>(null);
const [tasks, setTasks] = useState<AuditTask[]>([]);
const [loading, setLoading] = useState(true);
const [scanning, setScanning] = useState(false);
useEffect(() => {
if (id) {
loadProjectData();
}
}, [id]);
const loadProjectData = async () => {
if (!id) return;
try {
setLoading(true);
const [projectData, tasksData] = await Promise.all([
api.getProjectById(id),
api.getAuditTasks(id)
]);
setProject(projectData);
setTasks(tasksData);
} catch (error) {
console.error('Failed to load project data:', error);
toast.error("加载项目数据失败");
} finally {
setLoading(false);
}
};
const handleRunAudit = async () => {
if (!project || !id) return;
if (!project.repository_url || project.repository_type !== 'github') {
toast.error('请在项目中配置 GitHub 仓库地址');
return;
}
try {
setScanning(true);
await runRepositoryAudit({
projectId: id,
repoUrl: project.repository_url,
branch: project.default_branch || 'main',
githubToken: undefined,
createdBy: undefined
});
toast.success('已启动仓库审计');
await loadProjectData();
} catch (e: any) {
console.error(e);
toast.error(e?.message || '启动审计失败');
} finally {
setScanning(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-green-100 text-green-800';
case 'running': return 'bg-blue-100 text-blue-800';
case 'failed': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed': return <CheckCircle className="w-4 h-4" />;
case 'running': return <Activity className="w-4 h-4" />;
case 'failed': return <AlertTriangle className="w-4 h-4" />;
default: return <Clock className="w-4 h-4" />;
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
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-blue-600"></div>
</div>
);
}
if (!project) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<AlertTriangle 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">ID是否正确</p>
<Link to="/projects">
<Button>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
</Link>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link to="/projects">
<Button variant="outline" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold text-gray-900">{project.name}</h1>
<p className="text-gray-600 mt-1">
{project.description || '暂无项目描述'}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Badge variant={project.is_active ? "default" : "secondary"}>
{project.is_active ? '活跃' : '暂停'}
</Badge>
<Button onClick={handleRunAudit} disabled={scanning}>
<Shield className="w-4 h-4 mr-2" />
{scanning ? '正在启动...' : '启动审计'}
</Button>
<Button variant="outline">
<Settings className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
{/* 项目概览 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<Activity className="h-8 w-8 text-blue-600" />
<div className="ml-4">
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold">{tasks.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<CheckCircle 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">
{tasks.filter(t => t.status === 'completed').length}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<AlertTriangle 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">
{tasks.reduce((sum, task) => sum + task.issues_count, 0)}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<Code 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">
{tasks.length > 0
? (tasks.reduce((sum, task) => sum + task.quality_score, 0) / tasks.length).toFixed(1)
: '0.0'
}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 主要内容 */}
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="tasks"></TabsTrigger>
<TabsTrigger value="issues"></TabsTrigger>
<TabsTrigger value="settings"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 项目信息 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3">
{project.repository_url && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<a
href={project.repository_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline flex items-center"
>
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<Badge variant="outline">
{project.repository_type === 'github' ? 'GitHub' :
project.repository_type === 'gitlab' ? 'GitLab' : '其他'}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<span className="text-sm text-muted-foreground">{project.default_branch}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<span className="text-sm text-muted-foreground">
{formatDate(project.created_at)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<span className="text-sm text-muted-foreground">
{project.owner?.full_name || project.owner?.phone || '未知'}
</span>
</div>
</div>
{/* 编程语言 */}
{project.programming_languages && (
<div>
<h4 className="text-sm font-medium mb-2"></h4>
<div className="flex flex-wrap gap-2">
{JSON.parse(project.programming_languages).map((lang: string) => (
<Badge key={lang} variant="outline">
{lang}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* 最近活动 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{tasks.length > 0 ? (
<div className="space-y-3">
{tasks.slice(0, 5).map((task) => (
<div key={task.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-3">
{getStatusIcon(task.status)}
<div>
<p className="text-sm font-medium">
{task.task_type === 'repository' ? '仓库审计' : '即时分析'}
</p>
<p className="text-xs text-muted-foreground">
{formatDate(task.created_at)}
</p>
</div>
</div>
<Badge className={getStatusColor(task.status)}>
{task.status === 'completed' ? '已完成' :
task.status === 'running' ? '运行中' :
task.status === 'failed' ? '失败' : '等待中'}
</Badge>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<Activity className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground"></p>
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="tasks" className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium"></h3>
<Button>
<Play className="w-4 h-4 mr-2" />
</Button>
</div>
{tasks.length > 0 ? (
<div className="space-y-4">
{tasks.map((task) => (
<Card key={task.id}>
<CardContent className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
{getStatusIcon(task.status)}
<div>
<h4 className="font-medium">
{task.task_type === 'repository' ? '仓库审计任务' : '即时分析任务'}
</h4>
<p className="text-sm text-muted-foreground">
{formatDate(task.created_at)}
</p>
</div>
</div>
<Badge className={getStatusColor(task.status)}>
{task.status === 'completed' ? '已完成' :
task.status === 'running' ? '运行中' :
task.status === 'failed' ? '失败' : '等待中'}
</Badge>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div className="text-center">
<p className="text-2xl font-bold">{task.total_files}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{task.total_lines}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{task.issues_count}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
<div className="text-center">
<p className="text-2xl font-bold">{task.quality_score.toFixed(1)}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
</div>
{task.status === 'completed' && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span></span>
<span>{task.quality_score.toFixed(1)}/100</span>
</div>
<Progress value={task.quality_score} />
</div>
)}
<div className="flex justify-end space-x-2 mt-4">
<Link to={`/tasks/${task.id}`}>
<Button variant="outline" size="sm">
<FileText className="w-4 h-4 mr-2" />
</Button>
</Link>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Activity className="w-16 h-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-muted-foreground mb-2"></h3>
<p className="text-sm text-muted-foreground mb-4"></p>
<Button>
<Play className="w-4 h-4 mr-2" />
</Button>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="issues" className="space-y-6">
<div className="text-center py-12">
<AlertTriangle 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>
<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>
</Tabs>
</div>
);
}

437
src/pages/Projects.tsx Normal file
View File

@ -0,0 +1,437 @@
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Plus,
Search,
GitBranch,
Calendar,
Users,
Settings,
ExternalLink,
Code,
Shield,
Activity,
AlertTriangle
} from "lucide-react";
import { api } from "@/db/supabase";
import type { Project, CreateProjectForm } from "@/types/types";
import { Link } from "react-router-dom";
import { toast } from "sonner";
export default function Projects() {
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [createForm, setCreateForm] = useState<CreateProjectForm>({
name: "",
description: "",
repository_url: "",
repository_type: "github",
default_branch: "main",
programming_languages: []
});
const supportedLanguages = [
'JavaScript', 'TypeScript', 'Python', 'Java', 'Go', 'Rust', 'C++', 'C#', 'PHP', 'Ruby'
];
useEffect(() => {
loadProjects();
}, []);
const loadProjects = async () => {
try {
setLoading(true);
const data = await api.getProjects();
setProjects(data);
} catch (error) {
console.error('Failed to load projects:', error);
toast.error("加载项目失败");
} finally {
setLoading(false);
}
};
const handleCreateProject = async () => {
if (!createForm.name.trim()) {
toast.error("请输入项目名称");
return;
}
try {
await api.createProject({
...createForm,
// 无登录场景下不传 owner_id由后端置为 null
} as any);
toast.success("项目创建成功");
setShowCreateDialog(false);
setCreateForm({
name: "",
description: "",
repository_url: "",
repository_type: "github",
default_branch: "main",
programming_languages: []
});
loadProjects();
} catch (error) {
console.error('Failed to create project:', error);
toast.error("创建项目失败");
}
};
const filteredProjects = projects.filter(project =>
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.description?.toLowerCase().includes(searchTerm.toLowerCase())
);
const getRepositoryIcon = (type?: string) => {
switch (type) {
case 'github': return '🐙';
case 'gitlab': return '🦊';
default: return '📁';
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN');
};
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-blue-600"></div>
</div>
);
}
return (
<div className="space-y-6">
{/* 页面标题和操作 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="text-gray-600 mt-2">
</p>
</div>
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogTrigger asChild>
<Button>
<Plus className="w-4 h-4 mr-2" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
placeholder="输入项目名称"
/>
</div>
<div className="space-y-2">
<Label htmlFor="repository_type"></Label>
<Select
value={createForm.repository_type}
onValueChange={(value: any) => setCreateForm({ ...createForm, repository_type: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="github">GitHub</SelectItem>
<SelectItem value="gitlab">GitLab</SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Textarea
id="description"
value={createForm.description}
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
placeholder="简要描述项目内容和目标"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="repository_url"></Label>
<Input
id="repository_url"
value={createForm.repository_url}
onChange={(e) => setCreateForm({ ...createForm, repository_url: e.target.value })}
placeholder="https://github.com/user/repo"
/>
</div>
<div className="space-y-2">
<Label htmlFor="default_branch"></Label>
<Input
id="default_branch"
value={createForm.default_branch}
onChange={(e) => setCreateForm({ ...createForm, default_branch: e.target.value })}
placeholder="main"
/>
</div>
</div>
<div className="space-y-2">
<Label></Label>
<div className="grid grid-cols-3 gap-2">
{supportedLanguages.map((lang) => (
<label key={lang} className="flex items-center space-x-2">
<input
type="checkbox"
checked={createForm.programming_languages.includes(lang)}
onChange={(e) => {
if (e.target.checked) {
setCreateForm({
...createForm,
programming_languages: [...createForm.programming_languages, lang]
});
} else {
setCreateForm({
...createForm,
programming_languages: createForm.programming_languages.filter(l => l !== lang)
});
}
}}
className="rounded"
/>
<span className="text-sm">{lang}</span>
</label>
))}
</div>
</div>
<div className="flex justify-end space-x-3 pt-4">
<Button variant="outline" onClick={() => setShowCreateDialog(false)}>
</Button>
<Button onClick={handleCreateProject}>
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{/* 搜索和筛选 */}
<Card>
<CardContent className="p-4">
<div className="flex items-center space-x-4">
<div className="flex-1 relative">
<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"
/>
</div>
<Button variant="outline">
<Settings className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
{/* 项目列表 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredProjects.length > 0 ? (
filteredProjects.map((project) => (
<Card key={project.id} className="hover:shadow-lg transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-2">
<span className="text-lg">{getRepositoryIcon(project.repository_type)}</span>
<CardTitle className="text-lg">
<Link
to={`/projects/${project.id}`}
className="hover:text-blue-600 transition-colors"
>
{project.name}
</Link>
</CardTitle>
</div>
<Badge variant={project.is_active ? "default" : "secondary"}>
{project.is_active ? '活跃' : '暂停'}
</Badge>
</div>
{project.description && (
<p className="text-sm text-muted-foreground mt-2">
{project.description}
</p>
)}
</CardHeader>
<CardContent className="space-y-4">
{/* 项目信息 */}
<div className="space-y-2">
{project.repository_url && (
<div className="flex items-center text-sm text-muted-foreground">
<GitBranch className="w-4 h-4 mr-2" />
<a
href={project.repository_url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-blue-600 transition-colors flex items-center"
>
{project.repository_url.replace('https://', '').substring(0, 30)}...
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
)}
<div className="flex items-center text-sm text-muted-foreground">
<Calendar className="w-4 h-4 mr-2" />
{formatDate(project.created_at)}
</div>
<div className="flex items-center text-sm text-muted-foreground">
<Users className="w-4 h-4 mr-2" />
{project.owner?.full_name || project.owner?.phone || '未知'}
</div>
</div>
{/* 编程语言 */}
{project.programming_languages && (
<div className="flex flex-wrap gap-1">
{JSON.parse(project.programming_languages).slice(0, 3).map((lang: string) => (
<Badge key={lang} variant="outline" className="text-xs">
{lang}
</Badge>
))}
{JSON.parse(project.programming_languages).length > 3 && (
<Badge variant="outline" className="text-xs">
+{JSON.parse(project.programming_languages).length - 3}
</Badge>
)}
</div>
)}
{/* 快速操作 */}
<div className="flex space-x-2 pt-2">
<Link to={`/projects/${project.id}`} className="flex-1">
<Button variant="outline" size="sm" className="w-full">
<Code className="w-4 h-4 mr-2" />
</Button>
</Link>
<Button variant="outline" size="sm">
<Shield className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
))
) : (
<div className="col-span-full">
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<Code className="w-16 h-16 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium text-muted-foreground mb-2">
{searchTerm ? '未找到匹配的项目' : '暂无项目'}
</h3>
<p className="text-sm text-muted-foreground mb-4">
{searchTerm ? '尝试调整搜索条件' : '创建您的第一个项目开始代码审计'}
</p>
{!searchTerm && (
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="w-4 h-4 mr-2" />
</Button>
)}
</CardContent>
</Card>
</div>
)}
</div>
{/* 项目统计 */}
{projects.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center">
<Code className="h-8 w-8 text-blue-600" />
<div className="ml-4">
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold">{projects.length}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center">
<Activity 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">
{projects.filter(p => p.is_active).length}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center">
<GitBranch className="h-8 w-8 text-purple-600" />
<div className="ml-4">
<p className="text-sm font-medium text-muted-foreground">GitHub项目</p>
<p className="text-2xl font-bold">
{projects.filter(p => p.repository_type === 'github').length}
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center">
<Shield className="h-8 w-8 text-orange-600" />
<div className="ml-4">
<p className="text-sm font-medium text-muted-foreground">GitLab项目</p>
<p className="text-2xl font-bold">
{projects.filter(p => p.repository_type === 'gitlab').length}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}

16
src/pages/SamplePage.tsx Normal file
View File

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

478
src/pages/TaskDetail.tsx Normal file
View File

@ -0,0 +1,478 @@
import { useState, useEffect } from "react";
import { useParams, Link } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ArrowLeft,
Activity,
AlertTriangle,
CheckCircle,
Clock,
FileText,
Shield,
Code,
Zap,
Info,
Lightbulb
} from "lucide-react";
import { api } from "@/db/supabase";
import type { AuditTask, AuditIssue } from "@/types/types";
import { toast } from "sonner";
export default function TaskDetail() {
const { id } = useParams<{ id: string }>();
const [task, setTask] = useState<AuditTask | null>(null);
const [issues, setIssues] = useState<AuditIssue[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (id) {
loadTaskData();
}
}, [id]);
const loadTaskData = async () => {
if (!id) return;
try {
setLoading(true);
const [taskData, issuesData] = await Promise.all([
api.getAuditTaskById(id),
api.getAuditIssues(id)
]);
setTask(taskData);
setIssues(issuesData);
} catch (error) {
console.error('Failed to load task data:', error);
toast.error("加载任务数据失败");
} finally {
setLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'bg-green-100 text-green-800';
case 'running': return 'bg-blue-100 text-blue-800';
case 'failed': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed': return <CheckCircle className="w-5 h-5" />;
case 'running': return <Activity className="w-5 h-5" />;
case 'failed': return <AlertTriangle className="w-5 h-5" />;
default: return <Clock className="w-5 h-5" />;
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'critical': return 'bg-red-100 text-red-800 border-red-200';
case 'high': return 'bg-orange-100 text-orange-800 border-orange-200';
case 'medium': return 'bg-yellow-100 text-yellow-800 border-yellow-200';
case 'low': return 'bg-blue-100 text-blue-800 border-blue-200';
default: return 'bg-gray-100 text-gray-800 border-gray-200';
}
};
const getTypeIcon = (type: string) => {
switch (type) {
case 'security': return <Shield className="w-4 h-4" />;
case 'bug': return <AlertTriangle className="w-4 h-4" />;
case 'performance': return <Zap className="w-4 h-4" />;
case 'style': return <Code className="w-4 h-4" />;
case 'maintainability': return <FileText className="w-4 h-4" />;
default: return <Info className="w-4 h-4" />;
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
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-blue-600"></div>
</div>
);
}
if (!task) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<AlertTriangle 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">ID是否正确</p>
<Link to="/audit-tasks">
<Button>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
</Link>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* 页面标题 */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<Link to="/audit-tasks">
<Button variant="outline" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
</Link>
<div>
<h1 className="text-3xl font-bold text-gray-900">
{task.task_type === 'repository' ? '仓库审计任务' : '即时分析任务'}
</h1>
<p className="text-gray-600 mt-1">
{task.project?.name || '未知项目'}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Badge className={getStatusColor(task.status)} variant="outline">
{getStatusIcon(task.status)}
<span className="ml-2">
{task.status === 'completed' ? '已完成' :
task.status === 'running' ? '运行中' :
task.status === 'failed' ? '失败' : '等待中'}
</span>
</Badge>
</div>
</div>
{/* 任务概览 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<FileText className="h-8 w-8 text-blue-600" />
<div className="ml-4">
<p className="text-sm font-medium text-muted-foreground"></p>
<p className="text-2xl font-bold">{task.scanned_files}/{task.total_files}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<Code 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">{task.total_lines.toLocaleString()}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<AlertTriangle 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">{task.issues_count}</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center">
<CheckCircle 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">{task.quality_score.toFixed(1)}</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 主要内容 */}
<Tabs defaultValue="overview" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="overview"></TabsTrigger>
<TabsTrigger value="issues"></TabsTrigger>
<TabsTrigger value="config"></TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 任务信息 */}
<Card>
<CardHeader>
<CardTitle></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"></span>
<Badge variant="outline">
{task.task_type === 'repository' ? '仓库审计' : '即时分析'}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<span className="text-sm text-muted-foreground">
{formatDate(task.created_at)}
</span>
</div>
{task.started_at && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<span className="text-sm text-muted-foreground">
{formatDate(task.started_at)}
</span>
</div>
)}
{task.completed_at && (
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<span className="text-sm text-muted-foreground">
{formatDate(task.completed_at)}
</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm font-medium"></span>
<span className="text-sm text-muted-foreground">
{task.creator?.full_name || task.creator?.phone || '未知'}
</span>
</div>
</div>
{/* 扫描进度 */}
{task.status === 'running' && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span></span>
<span>{task.scanned_files}/{task.total_files}</span>
</div>
<Progress value={(task.scanned_files / task.total_files) * 100} />
</div>
)}
{/* 质量评分 */}
{task.status === 'completed' && (
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span></span>
<span>{task.quality_score.toFixed(1)}/100</span>
</div>
<Progress value={task.quality_score} />
</div>
)}
</CardContent>
</Card>
{/* 问题统计 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{issues.length > 0 ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-4 bg-red-50 rounded-lg">
<p className="text-2xl font-bold text-red-600">
{issues.filter(i => i.severity === 'critical').length}
</p>
<p className="text-sm text-red-600"></p>
</div>
<div className="text-center p-4 bg-orange-50 rounded-lg">
<p className="text-2xl font-bold text-orange-600">
{issues.filter(i => i.severity === 'high').length}
</p>
<p className="text-sm text-orange-600"></p>
</div>
<div className="text-center p-4 bg-yellow-50 rounded-lg">
<p className="text-2xl font-bold text-yellow-600">
{issues.filter(i => i.severity === 'medium').length}
</p>
<p className="text-sm text-yellow-600"></p>
</div>
<div className="text-center p-4 bg-blue-50 rounded-lg">
<p className="text-2xl font-bold text-blue-600">
{issues.filter(i => i.severity === 'low').length}
</p>
<p className="text-sm text-blue-600"></p>
</div>
</div>
{/* 问题类型分布 */}
<div className="space-y-2">
<h4 className="font-medium"></h4>
{['security', 'bug', 'performance', 'style', 'maintainability'].map(type => {
const count = issues.filter(i => i.issue_type === type).length;
const percentage = issues.length > 0 ? (count / issues.length) * 100 : 0;
return (
<div key={type} className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{getTypeIcon(type)}
<span className="text-sm capitalize">{type}</span>
</div>
<div className="flex items-center space-x-2">
<div className="w-20 bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full"
style={{ width: `${percentage}%` }}
></div>
</div>
<span className="text-sm text-muted-foreground w-8">{count}</span>
</div>
</div>
);
})}
</div>
</div>
) : (
<div className="text-center py-8">
<CheckCircle className="w-12 h-12 text-green-600 mx-auto mb-4" />
<p className="text-green-600 font-medium"></p>
<p className="text-sm text-muted-foreground"></p>
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="issues" className="space-y-6">
{issues.length > 0 ? (
<div className="space-y-4">
{issues.map((issue, index) => (
<Card key={issue.id} className="border-l-4 border-l-blue-500">
<CardContent className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-3">
{getTypeIcon(issue.issue_type)}
<div>
<h4 className="font-medium text-lg">{issue.title}</h4>
<p className="text-sm text-muted-foreground">
{issue.file_path}:{issue.line_number}
</p>
</div>
</div>
<Badge className={getSeverityColor(issue.severity)}>
{issue.severity}
</Badge>
</div>
<p className="text-sm text-muted-foreground mb-4">
{issue.description}
</p>
{issue.code_snippet && (
<div className="bg-gray-50 rounded p-3 mb-4">
<p className="text-sm font-medium mb-2"></p>
<pre className="text-xs bg-gray-100 p-2 rounded overflow-x-auto">
{issue.code_snippet}
</pre>
</div>
)}
{issue.suggestion && (
<div className="bg-blue-50 rounded p-3 mb-4">
<p className="text-sm font-medium text-blue-800 mb-1">
<Lightbulb className="w-4 h-4 inline mr-1" />
</p>
<p className="text-sm text-blue-700">{issue.suggestion}</p>
</div>
)}
{issue.ai_explanation && (
<div className="bg-green-50 rounded p-3">
<p className="text-sm font-medium text-green-800 mb-1">
AI
</p>
<p className="text-sm text-green-700">{issue.ai_explanation}</p>
</div>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-green-800 mb-2"></h3>
<p className="text-green-600"></p>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="config" className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="font-medium mb-2"></h4>
<p className="text-sm text-muted-foreground">
{task.branch_name || '默认分支'}
</p>
</div>
<div>
<h4 className="font-medium mb-2"></h4>
<div className="space-y-1">
{JSON.parse(task.exclude_patterns || '[]').length > 0 ? (
JSON.parse(task.exclude_patterns).map((pattern: string, index: number) => (
<Badge key={index} variant="outline" className="text-xs">
{pattern}
</Badge>
))
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
</div>
<div>
<h4 className="font-medium mb-2"></h4>
<pre className="text-xs bg-gray-100 p-3 rounded overflow-x-auto">
{JSON.stringify(JSON.parse(task.scan_config || '{}'), null, 2)}
</pre>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

62
src/routes.tsx Normal file
View File

@ -0,0 +1,62 @@
import Dashboard from "./pages/Dashboard";
import Projects from "./pages/Projects";
import ProjectDetail from "./pages/ProjectDetail";
import InstantAnalysis from "./pages/InstantAnalysis";
import AuditTasks from "./pages/AuditTasks";
import TaskDetail from "./pages/TaskDetail";
import AdminDashboard from "./pages/AdminDashboard";
import type { ReactNode } from 'react';
export interface RouteConfig {
name: string;
path: string;
element: ReactNode;
visible?: boolean;
}
const routes: RouteConfig[] = [
{
name: "仪表盘",
path: "/",
element: <Dashboard />,
visible: true,
},
{
name: "项目管理",
path: "/projects",
element: <Projects />,
visible: true,
},
{
name: "项目详情",
path: "/projects/:id",
element: <ProjectDetail />,
visible: false,
},
{
name: "即时分析",
path: "/instant-analysis",
element: <InstantAnalysis />,
visible: true,
},
{
name: "审计任务",
path: "/audit-tasks",
element: <AuditTasks />,
visible: true,
},
{
name: "任务详情",
path: "/tasks/:id",
element: <TaskDetail />,
visible: false,
},
{
name: "系统管理",
path: "/admin",
element: <AdminDashboard />,
visible: true,
},
];
export default routes;

0
src/services/.keep Normal file
View File

View File

@ -0,0 +1,240 @@
import type { CodeAnalysisResult } from "@/types/types";
import { GoogleGenerativeAI } from "@google/generative-ai";
// 基于 LLM 的代码分析引擎
export class CodeAnalysisEngine {
private static readonly SUPPORTED_LANGUAGES = [
'javascript', 'typescript', 'python', 'java', 'go', 'rust', 'cpp', 'csharp', 'php', 'ruby'
];
static getSupportedLanguages(): string[] {
return [...this.SUPPORTED_LANGUAGES];
}
static async analyzeCode(code: string, language: string): Promise<CodeAnalysisResult> {
const apiKey = import.meta.env.VITE_GEMINI_API_KEY as string | undefined;
if (!apiKey) {
throw new Error('缺少 VITE_GEMINI_API_KEY 环境变量,请在 .env 中配置');
}
const genAI = new GoogleGenerativeAI(apiKey);
const primaryModel = (import.meta.env.VITE_GEMINI_MODEL as string) || 'gemini-2.5-flash';
const fallbacks = ['gemini-1.5-flash'];
const requestWithTimeout = async (m: string, promptText: string, timeoutMs: number) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const mdl = genAI.getGenerativeModel({ model: m });
const res = await mdl.generateContent({
contents: [{ role: 'user', parts: [{ text: promptText }] }],
safetySettings: [],
generationConfig: { temperature: 0.2 }
}, { signal: controller.signal as any });
return res.response.text();
} finally {
clearTimeout(timer);
}
};
const generateWithRetry = async (promptText: string) => {
const models = [primaryModel, ...fallbacks];
const maxAttempts = 3;
const timeoutMs = Number(import.meta.env.VITE_GEMINI_TIMEOUT_MS || 25000);
let lastError: any = null;
for (const m of models) {
for (let i = 0; i < maxAttempts; i++) {
try {
return await requestWithTimeout(m, prompt, timeoutMs);
} catch (err: any) {
lastError = err;
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i))); // 1s,2s,4s
}
}
}
throw lastError;
};
const schema = `{
"issues": [
{
"type": "security|bug|performance|style|maintainability",
"severity": "critical|high|medium|low",
"title": "string",
"description": "string",
"suggestion": "string",
"line": 1,
"column": 1,
"code_snippet": "string",
"ai_explanation": "string",
"xai": {
"what": "string",
"why": "string",
"how": "string",
"learn_more": "string(optional)"
}
}
],
"quality_score": 0-100,
"summary": {
"total_issues": number,
"critical_issues": number,
"high_issues": number,
"medium_issues": number,
"low_issues": number
},
"metrics": {
"complexity": 0-100,
"maintainability": 0-100,
"security": 0-100,
"performance": 0-100
}
}`;
const prompt = [
`请请严格使用中文。你是一个专业代码审计助手。请从编码规范、潜在Bug、性能问题、安全漏洞、可维护性、最佳实践等维度分析代码并严格输出 JSON仅 JSON符合以下 schema`,
schema,
`语言: ${language}`,
`代码: \n\n${code}`
].join('\n\n');
let text = '';
try {
text = await generateWithRetry(prompt);
} catch (e) {
// 全部超时/失败时,返回兜底估算结果
const fallbackIssues: any[] = [];
const fallbackMetrics = this.estimateMetricsFromIssues(fallbackIssues);
return {
issues: fallbackIssues,
quality_score: this.calculateQualityScore(fallbackMetrics, fallbackIssues),
summary: {
total_issues: 0,
critical_issues: 0,
high_issues: 0,
medium_issues: 0,
low_issues: 0,
},
metrics: fallbackMetrics
} as CodeAnalysisResult;
}
const parsed = this.safeParseJson(text);
const issues = Array.isArray(parsed?.issues) ? parsed.issues : [];
const metrics = parsed?.metrics ?? this.estimateMetricsFromIssues(issues);
const qualityScore = parsed?.quality_score ?? this.calculateQualityScore(metrics, issues);
return {
issues,
quality_score: qualityScore,
summary: parsed?.summary ?? {
total_issues: issues.length,
critical_issues: issues.filter((i: any) => i.severity === 'critical').length,
high_issues: issues.filter((i: any) => i.severity === 'high').length,
medium_issues: issues.filter((i: any) => i.severity === 'medium').length,
low_issues: issues.filter((i: any) => i.severity === 'low').length,
},
metrics
} as CodeAnalysisResult;
}
private static safeParseJson(text: string): any {
try {
return JSON.parse(text);
} catch {
const match = text.match(/\{[\s\S]*\}/);
if (match) {
try { return JSON.parse(match[0]); } catch {}
}
return null;
}
}
private static estimateMetricsFromIssues(issues: any[]) {
const base = 90;
const penalty = Math.min(60, (issues?.length || 0) * 2);
const score = Math.max(0, base - penalty);
return {
complexity: score,
maintainability: score,
security: score,
performance: score
};
}
private static calculateQualityScore(metrics: any, issues: any[]): number {
const criticalWeight = 30;
const highWeight = 20;
const mediumWeight = 10;
const lowWeight = 5;
const criticalIssues = issues.filter((i: any) => i.severity === 'critical').length;
const highIssues = issues.filter((i: any) => i.severity === 'high').length;
const mediumIssues = issues.filter((i: any) => i.severity === 'medium').length;
const lowIssues = issues.filter((i: any) => i.severity === 'low').length;
const issueScore = 100 - (
criticalIssues * criticalWeight +
highIssues * highWeight +
mediumIssues * mediumWeight +
lowIssues * lowWeight
);
const metricsScore = (
metrics.complexity +
metrics.maintainability +
metrics.security +
metrics.performance
) / 4;
return Math.max(0, Math.min(100, (issueScore + metricsScore) / 2));
}
// 仓库级别的分析(占位保留)
static async analyzeRepository(_repoUrl: string, _branch: string = 'main', _excludePatterns: string[] = []): Promise<{
taskId: string;
status: 'pending' | 'running' | 'completed' | 'failed';
}> {
const taskId = `task_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
return { taskId, status: 'pending' };
}
// GitHub/GitLab集成占位保留
static async getRepositories(_token: string, _platform: 'github' | 'gitlab'): Promise<any[]> {
return [
{
id: '1',
name: 'example-project',
full_name: 'user/example-project',
description: '示例项目',
html_url: 'https://github.com/user/example-project',
clone_url: 'https://github.com/user/example-project.git',
default_branch: 'main',
language: 'JavaScript',
private: false,
updated_at: new Date().toISOString()
}
];
}
static async getBranches(_repoUrl: string, _token: string): Promise<any[]> {
return [
{
name: 'main',
commit: {
sha: 'abc123',
url: 'https://github.com/user/repo/commit/abc123'
},
protected: true
},
{
name: 'develop',
commit: {
sha: 'def456',
url: 'https://github.com/user/repo/commit/def456'
},
protected: false
}
];
}
}

118
src/services/repoScan.ts Normal file
View File

@ -0,0 +1,118 @@
import { api } from "@/db/supabase";
import { CodeAnalysisEngine } from "@/services/codeAnalysis";
type GithubTreeItem = { path: string; type: "blob" | "tree"; size?: number; url: string; sha: string };
const TEXT_EXTENSIONS = [
".js", ".ts", ".tsx", ".jsx", ".py", ".java", ".go", ".rs", ".cpp", ".c", ".h", ".cs", ".php", ".rb", ".kt", ".swift", ".sql", ".sh", ".json", ".yml", ".yaml", ".md"
];
const MAX_FILE_SIZE_BYTES = 200 * 1024;
const MAX_ANALYZE_FILES = Number(import.meta.env.VITE_MAX_ANALYZE_FILES || 40);
const LLM_CONCURRENCY = Number(import.meta.env.VITE_LLM_CONCURRENCY || 2);
const LLM_GAP_MS = Number(import.meta.env.VITE_LLM_GAP_MS || 500);
const isTextFile = (p: string) => TEXT_EXTENSIONS.some(ext => p.toLowerCase().endsWith(ext));
const matchExclude = (p: string, ex: string[]) => ex.some(e => p.includes(e.replace(/^\//, "")) || (e.endsWith("/**") && p.startsWith(e.slice(0, -3).replace(/^\//, ""))));
async function githubApi<T>(url: string, token?: string): Promise<T> {
const headers: Record<string, string> = { "Accept": "application/vnd.github+json" };
const t = token || (import.meta.env.VITE_GITHUB_TOKEN as string | undefined);
if (t) headers["Authorization"] = `Bearer ${t}`;
const res = await fetch(url, { headers });
if (!res.ok) {
if (res.status === 403) throw new Error("GitHub API 403请配置 VITE_GITHUB_TOKEN 或确认仓库权限/频率限制");
throw new Error(`GitHub API ${res.status}: ${url}`);
}
return res.json() as Promise<T>;
}
export async function runRepositoryAudit(params: {
projectId: string;
repoUrl: string;
branch?: string;
exclude?: string[];
githubToken?: string;
createdBy?: string;
}) {
const branch = params.branch || "main";
const excludes = params.exclude || [];
const task = await api.createAuditTask({
project_id: params.projectId,
task_type: "repository",
branch_name: branch,
exclude_patterns: excludes,
scan_config: {},
created_by: params.createdBy
} as any);
try {
const m = params.repoUrl.match(/github\.com\/(.+?)\/(.+?)(?:\.git)?$/i);
if (!m) throw new Error("仅支持 GitHub 仓库 URL例如 https://github.com/owner/repo");
const owner = m[1];
const repo = m[2];
const treeUrl = `https://api.github.com/repos/${owner}/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`;
const tree = await githubApi<{ tree: GithubTreeItem[] }>(treeUrl, params.githubToken);
let files = (tree.tree || []).filter(i => i.type === "blob" && isTextFile(i.path) && !matchExclude(i.path, excludes));
// 采样限制,优先分析较小文件与常见语言
files = files
.sort((a, b) => (a.path.length - b.path.length))
.slice(0, MAX_ANALYZE_FILES);
let totalFiles = 0, totalLines = 0, createdIssues = 0;
let index = 0;
const worker = async () => {
while (true) {
const current = index++;
if (current >= files.length) break;
const f = files[current];
totalFiles++;
try {
const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${encodeURIComponent(branch)}/${f.path}`;
const contentRes = await fetch(rawUrl);
if (!contentRes.ok) { await new Promise(r=>setTimeout(r, LLM_GAP_MS)); continue; }
const content = await contentRes.text();
if (content.length > MAX_FILE_SIZE_BYTES) { await new Promise(r=>setTimeout(r, LLM_GAP_MS)); continue; }
totalLines += content.split(/\r?\n/).length;
const language = (f.path.split(".").pop() || "").toLowerCase();
const analysis = await CodeAnalysisEngine.analyzeCode(content, language);
const issues = analysis.issues || [];
createdIssues += issues.length;
for (const issue of issues) {
await api.createAuditIssue({
task_id: (task as any).id,
file_path: f.path,
line_number: issue.line || null,
column_number: issue.column || null,
issue_type: issue.type || "maintainability",
severity: issue.severity || "low",
title: issue.title || "Issue",
description: issue.description || null,
suggestion: issue.suggestion || null,
code_snippet: issue.code_snippet || null,
ai_explanation: issue.xai ? JSON.stringify(issue.xai) : (issue.ai_explanation || null),
status: "open",
resolved_by: null,
resolved_at: null
} as any);
}
if (totalFiles % 10 === 0) {
await api.updateAuditTask((task as any).id, { status: "running", total_files: totalFiles, scanned_files: totalFiles, total_lines: totalLines, issues_count: createdIssues } as any);
}
} catch {}
await new Promise(r=>setTimeout(r, LLM_GAP_MS));
}
};
const pool = Array.from({ length: Math.min(LLM_CONCURRENCY, files.length) }, () => worker());
await Promise.all(pool);
await api.updateAuditTask((task as any).id, { status: "completed", total_files: totalFiles, scanned_files: totalFiles, total_lines: totalLines, issues_count: createdIssues, quality_score: 0 } as any);
return (task as any).id as string;
} catch (e) {
await api.updateAuditTask((task as any).id, { status: "failed" } as any);
throw e;
}
}

104
src/services/repoZipScan.ts Normal file
View File

@ -0,0 +1,104 @@
import { api } from "@/db/supabase";
import { CodeAnalysisEngine } from "@/services/codeAnalysis";
import { unzipSync, strFromU8 } from "fflate";
const TEXT_EXTENSIONS = [
".js", ".ts", ".tsx", ".jsx", ".py", ".java", ".go", ".rs", ".cpp", ".c", ".h", ".cs", ".php", ".rb", ".kt", ".swift", ".sql", ".sh", ".json", ".yml", ".yaml", ".md"
];
const MAX_FILE_SIZE_BYTES = 200 * 1024;
function isTextFile(path: string): boolean {
const lower = path.toLowerCase();
return TEXT_EXTENSIONS.some(ext => lower.endsWith(ext));
}
export async function runZipRepositoryAudit(params: {
projectId: string;
repoUrl: string; // https://github.com/owner/repo
branch?: string;
}) {
const branch = params.branch || "main";
const task = await api.createAuditTask({
project_id: params.projectId,
task_type: "repository",
branch_name: branch,
exclude_patterns: [],
scan_config: {},
created_by: null
} as any);
try {
const m = params.repoUrl.match(/github\.com\/(.+?)\/(.+?)(?:\.git)?$/i);
if (!m) throw new Error("仅支持 GitHub 仓库 URL例如 https://github.com/owner/repo");
const owner = m[1];
const repo = m[2];
// GitHub 提供仓库 zip 下载(无需 token
const zipUrl = `https://codeload.github.com/${owner}/${repo}/zip/refs/heads/${encodeURIComponent(branch)}`;
const res = await fetch(zipUrl);
if (!res.ok) throw new Error(`下载仓库压缩包失败: ${res.status}`);
const buf = new Uint8Array(await res.arrayBuffer());
const files = unzipSync(buf);
let totalFiles = 0;
let totalLines = 0;
let createdIssues = 0;
const rootPrefix = `${repo}-${branch}/`;
for (const name of Object.keys(files)) {
if (!name.startsWith(rootPrefix)) continue;
const relPath = name.slice(rootPrefix.length);
if (!relPath || relPath.endsWith("/")) continue; // 目录
if (!isTextFile(relPath)) continue;
const fileData = files[name];
if (!fileData || fileData.length > MAX_FILE_SIZE_BYTES) continue;
const content = strFromU8(fileData);
totalFiles += 1;
totalLines += content.split(/\r?\n/).length;
const ext = relPath.split(".").pop() || "";
const language = ext.toLowerCase();
const analysis = await CodeAnalysisEngine.analyzeCode(content, language);
const issues = analysis.issues || [];
createdIssues += issues.length;
for (const issue of issues) {
await api.createAuditIssue({
task_id: (task as any).id,
file_path: relPath,
line_number: issue.line || null,
column_number: issue.column || null,
issue_type: issue.type || "maintainability",
severity: issue.severity || "low",
title: issue.title || "Issue",
description: issue.description || null,
suggestion: issue.suggestion || null,
code_snippet: issue.code_snippet || null,
ai_explanation: issue.xai ? JSON.stringify(issue.xai) : (issue.ai_explanation || null),
status: "open",
resolved_by: null,
resolved_at: null
} as any);
}
}
await api.updateAuditTask((task as any).id, {
status: "completed",
total_files: totalFiles,
scanned_files: totalFiles,
total_lines: totalLines,
issues_count: createdIssues,
quality_score: 0
} as any);
return (task as any).id as string;
} catch (e) {
await api.updateAuditTask((task as any).id, { status: "failed" } as any);
throw e;
}
}

6
src/svg.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare module "*.svg?react" {
import React = require("react");
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
const src: string;
export default src;
}

6
src/types/index.ts Normal file
View File

@ -0,0 +1,6 @@
export interface Option {
label: string;
value: string;
icon?: React.ComponentType<{ className?: string }>;
withCount?: boolean;
}

243
src/types/types.ts Normal file
View File

@ -0,0 +1,243 @@
// 用户相关类型
export interface Profile {
id: string;
phone?: string;
email?: string;
full_name?: string;
avatar_url?: string;
role: 'admin' | 'member';
github_username?: string;
gitlab_username?: string;
created_at: string;
updated_at: string;
}
// 项目相关类型
export interface Project {
id: string;
name: string;
description?: string;
repository_url?: string;
repository_type?: 'github' | 'gitlab' | 'other';
default_branch: string;
programming_languages: string;
owner_id: string;
is_active: boolean;
created_at: string;
updated_at: string;
owner?: Profile;
}
export interface ProjectMember {
id: string;
project_id: string;
user_id: string;
role: 'owner' | 'admin' | 'member' | 'viewer';
permissions: string;
joined_at: string;
created_at: string;
user?: Profile;
project?: Project;
}
// 审计相关类型
export interface AuditTask {
id: string;
project_id: string;
task_type: 'repository' | 'instant';
status: 'pending' | 'running' | 'completed' | 'failed';
branch_name?: string;
exclude_patterns: string;
scan_config: string;
total_files: number;
scanned_files: number;
total_lines: number;
issues_count: number;
quality_score: number;
started_at?: string;
completed_at?: string;
created_by: string;
created_at: string;
project?: Project;
creator?: Profile;
}
export interface AuditIssue {
id: string;
task_id: string;
file_path: string;
line_number?: number;
column_number?: number;
issue_type: 'bug' | 'security' | 'performance' | 'style' | 'maintainability';
severity: 'critical' | 'high' | 'medium' | 'low';
title: string;
description?: string;
suggestion?: string;
code_snippet?: string;
ai_explanation?: string;
status: 'open' | 'resolved' | 'false_positive';
resolved_by?: string;
resolved_at?: string;
created_at: string;
task?: AuditTask;
resolver?: Profile;
}
export interface InstantAnalysis {
id: string;
user_id: string;
language: string;
code_content: string;
analysis_result: string;
issues_count: number;
quality_score: number;
analysis_time: number;
created_at: string;
user?: Profile;
}
// 表单相关类型
export interface CreateProjectForm {
name: string;
description?: string;
repository_url?: string;
repository_type?: 'github' | 'gitlab' | 'other';
default_branch?: string;
programming_languages: string[];
}
export interface CreateAuditTaskForm {
project_id: string;
task_type: 'repository' | 'instant';
branch_name?: string;
exclude_patterns: string[];
scan_config: {
include_tests?: boolean;
include_docs?: boolean;
max_file_size?: number;
analysis_depth?: 'basic' | 'standard' | 'deep';
};
}
export interface InstantAnalysisForm {
language: string;
code_content: string;
}
// 统计相关类型
export interface ProjectStats {
total_projects: number;
active_projects: number;
total_tasks: number;
completed_tasks: number;
total_issues: number;
resolved_issues: number;
avg_quality_score: number;
}
export interface IssueStats {
by_type: Record<string, number>;
by_severity: Record<string, number>;
by_status: Record<string, number>;
trend_data: Array<{
date: string;
count: number;
}>;
}
// API响应类型
export interface ApiResponse<T> {
data: T;
message?: string;
success: boolean;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
limit: number;
has_more: boolean;
}
// 代码分析结果类型
export interface CodeAnalysisResult {
issues: Array<{
type: string;
severity: string;
title: string;
description: string;
suggestion: string;
line: number;
column?: number;
code_snippet: string;
ai_explanation: string;
xai?: {
what: string;
why: string;
how: string;
learn_more?: string;
};
}>;
quality_score: number;
summary: {
total_issues: number;
critical_issues: number;
high_issues: number;
medium_issues: number;
low_issues: number;
};
metrics: {
complexity: number;
maintainability: number;
security: number;
performance: number;
};
}
// GitHub/GitLab集成类型
export interface Repository {
id: string;
name: string;
full_name: string;
description?: string;
html_url: string;
clone_url: string;
default_branch: string;
language?: string;
languages?: Record<string, number>;
private: boolean;
updated_at: string;
}
export interface Branch {
name: string;
commit: {
sha: string;
url: string;
};
protected: boolean;
}
// 通知类型
export interface Notification {
id: string;
type: 'task_completed' | 'task_failed' | 'new_issue' | 'issue_resolved';
title: string;
message: string;
data?: any;
read: boolean;
created_at: string;
}
// 系统配置类型
export interface SystemConfig {
max_file_size: number;
supported_languages: string[];
analysis_timeout: number;
max_concurrent_tasks: number;
notification_settings: {
email_enabled: boolean;
webhook_url?: string;
};
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,394 @@
/*
# 智能代码审计系统数据库架构
## 1. 概述
## 2. 表结构说明
### 2.1 用户相关表
- `profiles`:
- `id` (uuid, ): auth.users
- `phone` (text, ):
- `email` (text):
- `full_name` (text):
- `avatar_url` (text): URL
- `role` (text): (admin/member)
- `github_username` (text): GitHub用户名
- `gitlab_username` (text): GitLab用户名
- `created_at` (timestamptz):
- `updated_at` (timestamptz):
### 2.2 项目管理表
- `projects`:
- `id` (uuid, ):
- `name` (text):
- `description` (text):
- `repository_url` (text): URL
- `repository_type` (text): (github/gitlab)
- `default_branch` (text):
- `programming_languages` (text): JSON格式
- `owner_id` (uuid): ID
- `is_active` (boolean):
- `created_at` (timestamptz):
- `updated_at` (timestamptz):
### 2.3 审计相关表
- `audit_tasks`:
- `id` (uuid, ):
- `project_id` (uuid): ID
- `task_type` (text): (repository/instant)
- `status` (text): (pending/running/completed/failed)
- `branch_name` (text):
- `exclude_patterns` (text): JSON格式
- `scan_config` (text): JSON格式
- `total_files` (integer):
- `scanned_files` (integer):
- `total_lines` (integer):
- `issues_count` (integer):
- `quality_score` (numeric):
- `started_at` (timestamptz):
- `completed_at` (timestamptz):
- `created_by` (uuid): ID
- `created_at` (timestamptz):
- `audit_issues`:
- `id` (uuid, ):
- `task_id` (uuid): ID
- `file_path` (text):
- `line_number` (integer):
- `column_number` (integer):
- `issue_type` (text): (bug/security/performance/style/maintainability)
- `severity` (text): (critical/high/medium/low)
- `title` (text):
- `description` (text):
- `suggestion` (text):
- `code_snippet` (text):
- `ai_explanation` (text): AI详细解释
- `status` (text): (open/resolved/false_positive)
- `resolved_by` (uuid): ID
- `resolved_at` (timestamptz):
- `created_at` (timestamptz):
### 2.4 即时分析表
- `instant_analyses`:
- `id` (uuid, ):
- `user_id` (uuid): ID
- `language` (text):
- `code_content` (text):
- `analysis_result` (text): JSON格式
- `issues_count` (integer):
- `quality_score` (numeric):
- `analysis_time` (numeric):
- `created_at` (timestamptz):
### 2.5 项目成员表
- `project_members`:
- `id` (uuid, ):
- `project_id` (uuid): ID
- `user_id` (uuid): ID
- `role` (text): (owner/admin/member/viewer)
- `permissions` (text): JSON格式
- `joined_at` (timestamptz):
- `created_at` (timestamptz):
## 3. 安全策略
- (RLS)
- 访
- 访
## 4. 初始数据
-
-
*/
-- 启用必要的扩展
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- 创建用户信息表
CREATE TABLE IF NOT EXISTS profiles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
phone text UNIQUE,
email text,
full_name text,
avatar_url text,
role text DEFAULT 'member' CHECK (role IN ('admin', 'member')),
github_username text,
gitlab_username text,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- 创建项目表
CREATE TABLE IF NOT EXISTS projects (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
description text,
repository_url text,
repository_type text CHECK (repository_type IN ('github', 'gitlab', 'other')),
default_branch text DEFAULT 'main',
programming_languages text DEFAULT '[]',
owner_id uuid,
is_active boolean DEFAULT true,
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now()
);
-- 创建项目成员表
CREATE TABLE IF NOT EXISTS project_members (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid,
user_id uuid,
role text DEFAULT 'member' CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
permissions text DEFAULT '{}',
joined_at timestamptz DEFAULT now(),
created_at timestamptz DEFAULT now(),
UNIQUE(project_id, user_id)
);
-- 创建审计任务表
CREATE TABLE IF NOT EXISTS audit_tasks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid,
task_type text DEFAULT 'repository' CHECK (task_type IN ('repository', 'instant')),
status text DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed')),
branch_name text,
exclude_patterns text DEFAULT '[]',
scan_config text DEFAULT '{}',
total_files integer DEFAULT 0,
scanned_files integer DEFAULT 0,
total_lines integer DEFAULT 0,
issues_count integer DEFAULT 0,
quality_score numeric(5,2) DEFAULT 0,
started_at timestamptz,
completed_at timestamptz,
created_by uuid,
created_at timestamptz DEFAULT now()
);
-- 创建审计问题表
CREATE TABLE IF NOT EXISTS audit_issues (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
task_id uuid,
file_path text NOT NULL,
line_number integer,
column_number integer,
issue_type text CHECK (issue_type IN ('bug', 'security', 'performance', 'style', 'maintainability')),
severity text CHECK (severity IN ('critical', 'high', 'medium', 'low')),
title text NOT NULL,
description text,
suggestion text,
code_snippet text,
ai_explanation text,
status text DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'false_positive')),
resolved_by uuid,
resolved_at timestamptz,
created_at timestamptz DEFAULT now()
);
-- 创建即时分析表
CREATE TABLE IF NOT EXISTS instant_analyses (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid,
language text NOT NULL,
code_content text NOT NULL,
analysis_result text DEFAULT '{}',
issues_count integer DEFAULT 0,
quality_score numeric(5,2) DEFAULT 0,
analysis_time numeric(10,3) DEFAULT 0,
created_at timestamptz DEFAULT now()
);
-- 外键约束(匹配前端使用的关系别名)
ALTER TABLE public.projects
DROP CONSTRAINT IF EXISTS projects_owner_id_fkey,
ADD CONSTRAINT projects_owner_id_fkey FOREIGN KEY (owner_id)
REFERENCES public.profiles(id) ON DELETE SET NULL;
ALTER TABLE public.project_members
DROP CONSTRAINT IF EXISTS project_members_project_id_fkey,
ADD CONSTRAINT project_members_project_id_fkey FOREIGN KEY (project_id)
REFERENCES public.projects(id) ON DELETE CASCADE;
ALTER TABLE public.project_members
DROP CONSTRAINT IF EXISTS project_members_user_id_fkey,
ADD CONSTRAINT project_members_user_id_fkey FOREIGN KEY (user_id)
REFERENCES public.profiles(id) ON DELETE CASCADE;
ALTER TABLE public.audit_tasks
DROP CONSTRAINT IF EXISTS audit_tasks_project_id_fkey,
ADD CONSTRAINT audit_tasks_project_id_fkey FOREIGN KEY (project_id)
REFERENCES public.projects(id) ON DELETE SET NULL;
ALTER TABLE public.audit_tasks
DROP CONSTRAINT IF EXISTS audit_tasks_created_by_fkey,
ADD CONSTRAINT audit_tasks_created_by_fkey FOREIGN KEY (created_by)
REFERENCES public.profiles(id) ON DELETE SET NULL;
ALTER TABLE public.audit_issues
DROP CONSTRAINT IF EXISTS audit_issues_task_id_fkey,
ADD CONSTRAINT audit_issues_task_id_fkey FOREIGN KEY (task_id)
REFERENCES public.audit_tasks(id) ON DELETE CASCADE;
ALTER TABLE public.audit_issues
DROP CONSTRAINT IF EXISTS audit_issues_resolved_by_fkey,
ADD CONSTRAINT audit_issues_resolved_by_fkey FOREIGN KEY (resolved_by)
REFERENCES public.profiles(id) ON DELETE SET NULL;
ALTER TABLE public.instant_analyses
DROP CONSTRAINT IF EXISTS instant_analyses_user_id_fkey,
ADD CONSTRAINT instant_analyses_user_id_fkey FOREIGN KEY (user_id)
REFERENCES public.profiles(id) ON DELETE SET NULL;
-- 启用行级安全
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE project_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_issues ENABLE ROW LEVEL SECURITY;
ALTER TABLE instant_analyses ENABLE ROW LEVEL SECURITY;
-- 创建安全策略
-- profiles表策略
CREATE POLICY "Users can read own profile"
ON profiles FOR SELECT
TO authenticated
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE
TO authenticated
USING (auth.uid() = id);
CREATE POLICY "Admins can read all profiles"
ON profiles FOR SELECT
TO authenticated
USING (
EXISTS (
SELECT 1 FROM profiles
WHERE id = auth.uid() AND role = 'admin'
)
);
-- projects表策略 - 简化版本
CREATE POLICY "Users can read all projects"
ON projects FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Users can create projects"
ON projects FOR INSERT
TO authenticated
WITH CHECK (owner_id = auth.uid());
CREATE POLICY "Project owners can update projects"
ON projects FOR UPDATE
TO authenticated
USING (owner_id = auth.uid());
-- project_members表策略
CREATE POLICY "Users can read project members"
ON project_members FOR SELECT
TO authenticated
USING (true);
-- audit_tasks表策略
CREATE POLICY "Users can read audit tasks"
ON audit_tasks FOR SELECT
TO authenticated
USING (true);
CREATE POLICY "Users can create audit tasks"
ON audit_tasks FOR INSERT
TO authenticated
WITH CHECK (created_by = auth.uid());
-- audit_issues表策略
CREATE POLICY "Users can read audit issues"
ON audit_issues FOR SELECT
TO authenticated
USING (true);
-- instant_analyses表策略
CREATE POLICY "Users can read own instant analyses"
ON instant_analyses FOR SELECT
TO authenticated
USING (user_id = auth.uid());
CREATE POLICY "Users can create instant analyses"
ON instant_analyses FOR INSERT
TO authenticated
WITH CHECK (user_id = auth.uid());
-- 创建更新时间触发器函数
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ language 'plpgsql';
-- 为需要的表创建更新时间触发器
CREATE TRIGGER update_profiles_updated_at BEFORE UPDATE ON profiles
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- 插入示例数据
-- 若无管理员用户则先插入一个,避免 owner_id 为空
INSERT INTO profiles (id, email, full_name, role)
SELECT gen_random_uuid(), 'admin@example.com', 'Admin', 'admin'
WHERE NOT EXISTS (SELECT 1 FROM profiles WHERE role = 'admin');
-- 精简版示例项目(与表结构一致)
INSERT INTO projects (name, description, repository_type, programming_languages, owner_id, is_active) VALUES
('React前端项目', '基于React的现代化前端应用包含TypeScript和Tailwind CSS', 'github', '["JavaScript", "TypeScript", "CSS"]', (SELECT id FROM profiles WHERE role = 'admin' LIMIT 1), true),
('Python后端API', 'Django REST框架构建的后端API服务', 'github', '["Python", "SQL"]', (SELECT id FROM profiles WHERE role = 'admin' LIMIT 1), true),
('Java微服务', 'Spring Boot构建的微服务架构项目', 'gitlab', '["Java", "XML"]', (SELECT id FROM profiles WHERE role = 'admin' LIMIT 1), true)
ON CONFLICT DO NOTHING;
-- 插入示例审计任务
INSERT INTO audit_tasks (project_id, task_type, status, total_files, scanned_files, total_lines, issues_count, quality_score, created_by, started_at, completed_at) VALUES
((SELECT id FROM projects WHERE name = 'React前端项目' LIMIT 1), 'repository', 'completed', 156, 156, 12500, 23, 87.5, (SELECT id FROM profiles WHERE role = 'admin' LIMIT 1), now() - interval '2 hours', now() - interval '1 hour'),
((SELECT id FROM projects WHERE name = 'Python后端API' LIMIT 1), 'repository', 'completed', 89, 89, 8900, 12, 92.3, (SELECT id FROM profiles WHERE role = 'admin' LIMIT 1), now() - interval '1 day', now() - interval '23 hours'),
((SELECT id FROM projects WHERE name = 'Java微服务' LIMIT 1), 'repository', 'running', 234, 180, 18700, 25, 0, (SELECT id FROM profiles WHERE role = 'admin' LIMIT 1), now() - interval '30 minutes', null)
ON CONFLICT DO NOTHING;
-- 追加:无登录演示用匿名策略(生产环境请按需收紧)
-- 允许匿名读取所有项目
CREATE POLICY "anon can read all projects"
ON projects FOR SELECT
TO anon
USING (true);
-- 允许匿名写项目(演示/本地联调用,如不需要可删除)
CREATE POLICY "anon can write projects"
ON projects FOR ALL
TO anon
USING (true)
WITH CHECK (true);
-- 允许匿名读取审计任务/问题
CREATE POLICY "anon can read audit tasks"
ON audit_tasks FOR SELECT
TO anon
USING (true);
CREATE POLICY "anon can read audit issues"
ON audit_issues FOR SELECT
TO anon
USING (true);
-- 允许匿名创建与读取即时分析记录(前端只写摘要,不存代码)
CREATE POLICY "anon can insert instant analyses"
ON instant_analyses FOR INSERT
TO anon
WITH CHECK (true);
CREATE POLICY "anon can read instant analyses"
ON instant_analyses FOR SELECT
TO anon
USING (true);

158
tailwind.config.js Normal file
View File

@ -0,0 +1,158 @@
export default {
darkMode: ['class'],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
'./node_modules/streamdown/dist/**/*.js'
],
safelist: ['border', 'border-border'],
prefix: '',
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px',
},
},
extend: {
colors: {
border: 'hsl(var(--border))',
borderColor: {
border: 'hsl(var(--border))',
},
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
},
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
},
education: {
blue: 'hsl(var(--education-blue))',
green: 'hsl(var(--education-green))',
},
success: 'hsl(var(--success))',
warning: 'hsl(var(--warning))',
info: 'hsl(var(--info))',
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
},
backgroundImage: {
'gradient-primary': 'var(--gradient-primary)',
'gradient-card': 'var(--gradient-card)',
'gradient-background': 'var(--gradient-background)',
},
boxShadow: {
card: 'var(--shadow-card)',
hover: 'var(--shadow-hover)',
},
keyframes: {
'accordion-down': {
from: {
height: '0',
},
to: {
height: 'var(--radix-accordion-content-height)',
},
},
'accordion-up': {
from: {
height: 'var(--radix-accordion-content-height)',
},
to: {
height: '0',
},
},
'fade-in': {
from: {
opacity: '0',
transform: 'translateY(10px)',
},
to: {
opacity: '1',
transform: 'translateY(0)',
},
},
'slide-in': {
from: {
opacity: '0',
transform: 'translateX(-20px)',
},
to: {
opacity: '1',
transform: 'translateX(0)',
},
},
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'fade-in': 'fade-in 0.5s ease-out',
'slide-in': 'slide-in 0.5s ease-out',
},
},
},
plugins: [
require('tailwindcss-animate'),
function ({ addUtilities }) {
addUtilities(
{
'.border-t-solid': { 'border-top-style': 'solid' },
'.border-r-solid': { 'border-right-style': 'solid' },
'.border-b-solid': { 'border-bottom-style': 'solid' },
'.border-l-solid': { 'border-left-style': 'solid' },
'.border-t-dashed': { 'border-top-style': 'dashed' },
'.border-r-dashed': { 'border-right-style': 'dashed' },
'.border-b-dashed': { 'border-bottom-style': 'dashed' },
'.border-l-dashed': { 'border-left-style': 'dashed' },
'.border-t-dotted': { 'border-top-style': 'dotted' },
'.border-r-dotted': { 'border-right-style': 'dotted' },
'.border-b-dotted': { 'border-bottom-style': 'dotted' },
'.border-l-dotted': { 'border-left-style': 'dotted' },
},
['responsive']
);
},
],
};

33
tsconfig.app.json Normal file
View File

@ -0,0 +1,33 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"resolveJsonModule": true,
"esModuleInterop": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"typeRoots": ["./node_modules/**/*"]
},
"include": ["src"]
}

17
tsconfig.check.json Normal file
View File

@ -0,0 +1,17 @@
{
"include": ["./src"],
"exclude": ["./src/**/*.test.ts", "./src/**/*.spec.ts", "./src/components/ui"],
"compilerOptions": {
"jsx": "react-jsx",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
}
}

18
tsconfig.json Normal file
View File

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
tsconfig.node.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

24
vite.config.ts Normal file
View File

@ -0,0 +1,24 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import path from "path";
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
svgr({
svgrOptions: {
icon: true,
exportType: "named",
namedExport: "ReactComponent",
},
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {},
});