Initial commit: Add XCodeReviewer project files
This commit is contained in:
parent
ecd35ac87b
commit
7478f6f14f
|
|
@ -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/
|
||||||
|
|
@ -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**:
|
||||||
|
- 功能: 获取板块排名信息
|
||||||
|
- 使用场景: 板块表现分析
|
||||||
|
- 典型应用: 投资分析、板块轮动、行业对比
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"index":-1,"history":[]}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
ruleDirs:
|
||||||
|
- rules
|
||||||
|
languageGlobs:
|
||||||
|
TypeScript: ["*.ts"]
|
||||||
|
Tsx: ["*.tsx"]
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 }
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
// global types
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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">
|
||||||
|
© {new Date().getFullYear()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* 示例页面
|
||||||
|
*/
|
||||||
|
|
||||||
|
import PageMeta from "../components/common/PageMeta";
|
||||||
|
|
||||||
|
export default function SamplePage() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageMeta title="首页" description="首页介绍" />
|
||||||
|
<div>
|
||||||
|
<h3>这是一个示例页面</h3>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,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
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export interface Option {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
|
withCount?: boolean;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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']
|
||||||
|
);
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
}
|
||||||
|
|
@ -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: {},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue