style: 数据源类型使用品牌SVG图标 + 按钮去装饰
- MySQL: 官方海豚蓝色SVG - PostgreSQL: 官方大象蓝色轮廓SVG - Redis: 官方红色菱形堆叠SVG - MongoDB: 官方绿色叶子SVG - 底部按钮去掉闪电图标,纯文字简洁风格 - 前端构建零错误, 后端202测试通过
This commit is contained in:
1 parent
e0cb505d43
commit
836ea97ee5
142 files changed
+54
-36571
No files matched your search
@@ -317,6 +317,46 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Database Brand SVG Icons ---------- */
|
||||||
|
function MySQLIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 256 256" className={className}>
|
||||||
|
<path d="M235.6 184.1c-13.2-1.3-24.5-10.2-29.8-22.1a7.7 7.7 0 01-.4-1.5c-1.2-3.2-1.5-7-.7-10.8 2-9.5 9.9-15.2 18.6-14.3 8.7.9 15.6 8.1 16.2 17.7.5 7.6-3.2 14.3-9 18.2a1.3 1.3 0 00-.5 1.5c2.7 7.4 8 12.6 14.9 14.4a1.3 1.3 0 01-.8 2.4c-3-.5-5.9-2.4-8.5-5.5z" fill="#00758F"/>
|
||||||
|
<path d="M28.3 200.3c0 .3.2.5.5.5h5.6c.3 0 .5-.2.5-.5v-19.4h7.8c.3 0 .5-.2.5-.5v-4.4c0-.3-.2-.5-.5-.5H20.5c-.3 0-.5.2-.5.5v4.4c0 .3.2.5.5.5h7.8v19.4zm22.5 0c0 .3.2.5.5.5h5.6c.3 0 .5-.2.5-.5v-24.8c0-.3-.2-.5-.5-.5h-5.6c-.3 0-.5.2-.5.5v24.8zm15.6 0c0 .3.2.5.5.5h13.2c.3 0 .5-.2.5-.5v-4.4c0-.3-.2-.5-.5-.5h-7.1v-19.9c0-.3-.2-.5-.5-.5h-5.6c-.3 0-.5.2-.5.5v24.8zm22.5 0c0 .3.2.5.5.5h5.6c.3 0 .5-.2.5-.5v-24.8c0-.3-.2-.5-.5-.5h-5.6c-.3 0-.5.2-.5.5v24.8zm16.8-12.4c0-7.5 5.7-12.9 13.2-12.9 4.6 0 8.1 1.9 10.5 5l-3.9 3.2c-1.7-2.2-3.9-3.3-6.4-3.3-4.6 0-7.9 3.4-7.9 8s3.3 8 7.9 8c2.5 0 4.7-1.1 6.4-3.3l3.9 3.2c-2.4 3.1-5.9 5-10.5 5-7.5 0-13.2-5.4-13.2-12.9z" fill="#F29111"/>
|
||||||
|
<ellipse cx="128" cy="56" rx="90" ry="28" fill="none" stroke="#00758F" strokeWidth="6"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PostgreSQLIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 256 256" className={className}>
|
||||||
|
<path d="M191.5 18.2c-1.2-10.2-7.8-17.6-18.3-18C163.6-.3 153 4 145.2 12c-7.2 7.3-11 17-10.6 27 .3 5.7 1.5 11 3.6 15.7-3.2 7.5-7.6 18.2-10.5 25.8-4.5 11.8-13.4 29.7-19.6 29.7-1.5 0-3.2-.8-5-2.4-6.5-5.8-14.4-8.8-22.9-8.8-11 0-20.7 5.2-25.5 13.6-3.6 6.3-3.7 13.5-.4 19.7 4 7.4 12.5 12.8 22 14l.7.1c6.6.6 13.6-2.3 20.7-8.6 3.3-2.9 7.6-4.5 12.4-4.7 7.4-.3 14.8 3.3 18.7 9.1 3.4 5.1 3.5 11.5.2 16.6-2.2 3.4-5.3 5.8-9 7.1-3 1-5.3 3.3-6.3 6.3-1 3.1-.5 6.4 1.4 9 3.8 5.2 9.3 7.9 15.8 7.9 4.5 0 9.2-1.3 13.5-3.8 4.6-2.7 7.9-6.7 9.6-11.7 1.4-4.1 1.5-8.5.2-12.8l-.1-.3c-2-6.5.3-13.8 5.7-17.9 5.6-4.2 13-4.5 18.9-.7 8.8 5.6 14.1 13.2 15.4 22 .6 3.8.4 7.7-.6 11.4" fill="none" stroke="#336791" strokeWidth="10" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RedisIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 256 256" className={className}>
|
||||||
|
<path d="M128 190L20.5 145.3v-20.6l107.5 44.7 107.5-44.7v20.6L128 190z" fill="#912626"/>
|
||||||
|
<path d="M128 149.4L20.5 104.7V84.1L128 128.8l107.5-44.7v20.6L128 149.4z" fill="#C6302B"/>
|
||||||
|
<path d="M128 108.8L20.5 64.1V43.5L128 88.2l107.5-44.7v20.6L128 108.8z" fill="#912626"/>
|
||||||
|
<path d="M128 66.5L73.2 43.7 128 20.9l54.8 22.8L128 66.5z" fill="#C6302B"/>
|
||||||
|
<path d="M128 235.2l-107.5-44.7v-20.6L128 214.6l107.5-44.7v20.6L128 235.2z" fill="#912626"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MongoDBIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 256 256" className={className}>
|
||||||
|
<path d="M128 20c-4.4 16-10 28-17.6 39.2C101.6 72.4 88.8 84 78 98c-14.8 19.2-23.2 40.4-23.2 65.2 0 38.8 31.2 72.8 73.2 72.8s73.2-34 73.2-72.8c0-24.8-8.4-46-23.2-65.2C167.2 84 154.4 72.4 145.6 59.2 138 48 132.4 36 128 20z" fill="#439934"/>
|
||||||
|
<path d="M128 20c-2 12-6.8 22-12.8 31.2C107.6 63.6 98 73.6 89.6 85.2 78 100.4 72 118 72 138c0 30 22 56 56 56s56-26 56-56c0-20-6-37.6-17.6-52.8C158 73.6 148.4 63.6 140.8 51.2 134.8 42 130 32 128 20z" fill="#4AA841"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- CreateInstanceForm ---------- */
|
/* ---------- CreateInstanceForm ---------- */
|
||||||
const PRESET_MASK_FIELDS = [
|
const PRESET_MASK_FIELDS = [
|
||||||
'mobile', 'concat_mobile', 'email_alias', 'email', 'password',
|
'mobile', 'concat_mobile', 'email_alias', 'email', 'password',
|
||||||
@@ -426,23 +466,18 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
|||||||
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">数据源类型</h3>
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">数据源类型</h3>
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
{[
|
{[
|
||||||
{ value: 'mysql', label: 'MySQL', icon: Database, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]' },
|
{ value: 'mysql', label: 'MySQL', Icon: MySQLIcon },
|
||||||
{ value: 'postgres', label: 'PostgreSQL', icon: Server, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]' },
|
{ value: 'postgres', label: 'PostgreSQL', Icon: PostgreSQLIcon },
|
||||||
{ value: 'redis', label: 'Redis', icon: Zap, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]' },
|
{ value: 'redis', label: 'Redis', Icon: RedisIcon },
|
||||||
{ value: 'mongo', label: 'MongoDB', icon: Leaf, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]' },
|
{ value: 'mongo', label: 'MongoDB', Icon: MongoDBIcon },
|
||||||
].map(t => {
|
].map(t => (
|
||||||
const Icon = t.icon;
|
<button key={t.value} type="button" onClick={() => handleTypeChange(t.value)}
|
||||||
return (
|
className={'flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all '
|
||||||
<button key={t.value} type="button" onClick={() => handleTypeChange(t.value)}
|
+ (dbType === t.value ? 'border-blue-500 bg-blue-500/5' : 'border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]')}>
|
||||||
className={'flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all '
|
<t.Icon className="w-8 h-8" />
|
||||||
+ (dbType === t.value ? 'border-blue-500 bg-blue-500/5' : 'border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]')}>
|
<span className="text-xs font-medium">{t.label}</span>
|
||||||
<div className={'w-10 h-10 rounded-lg flex items-center justify-center ' + t.bg}>
|
</button>
|
||||||
<Icon className={'w-5 h-5 ' + t.color} />
|
))}
|
||||||
</div>
|
|
||||||
<span className="text-xs font-medium">{t.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -614,8 +649,8 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
|
|||||||
{error && <div className="text-sm text-red-400">{error}</div>}
|
{error && <div className="text-sm text-red-400">{error}</div>}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<button onClick={handleTestConnection} disabled={testing || !host || !port}
|
<button onClick={handleTestConnection} disabled={testing || !host || !port}
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-40">
|
className="px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-40">
|
||||||
<Zap className="w-4 h-4" /> {testing ? '测试中...' : '测试连接'}
|
{testing ? '测试中...' : '测试连接'}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button onClick={onCancel} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">取消</button>
|
<button onClick={onCancel} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)]">取消</button>
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,547 +0,0 @@
|
|||||||
# DataGuard - 数据库变更审核平台 开发文档
|
|
||||||
|
|
||||||
> 版本: v2.0
|
|
||||||
> 更新日期: 2026-05-20
|
|
||||||
> 技术栈: React 19 + TypeScript + Vite + Tailwind CSS + shadcn/ui
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、项目概述
|
|
||||||
|
|
||||||
DataGuard 是一款企业级数据库变更审核平台,对标 Bytebase / Yearning / Archery,支持多数据库实例管理、SQL 变更工单审批、查询审计、自动任务、权限控制等核心功能。
|
|
||||||
|
|
||||||
### 核心设计理念
|
|
||||||
|
|
||||||
- **安全优先**: 所有数据变更通过工单审批流程,SQL 自动审核 + 人工审批双重保障
|
|
||||||
- **多数据库支持**: MySQL、PostgreSQL、Redis、MongoDB、ClickHouse
|
|
||||||
- **审计可追溯**: 完整的查询审计日志,敏感数据自动脱敏
|
|
||||||
- **回滚可控**: 基于 Binlog ROW 格式的自动回滚 SQL 生成
|
|
||||||
- **角色分级**: admin / dba / developer / guest 四级权限控制
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、技术栈
|
|
||||||
|
|
||||||
| 类别 | 技术 | 版本 |
|
|
||||||
|------|------|------|
|
|
||||||
| 框架 | React | 19 |
|
|
||||||
| 语言 | TypeScript | 5.x |
|
|
||||||
| 构建 | Vite | 7.x |
|
|
||||||
| 样式 | Tailwind CSS | 3.4 |
|
|
||||||
| UI 库 | shadcn/ui + Radix UI | latest |
|
|
||||||
| 路由 | react-router-dom | 7.x |
|
|
||||||
| 图标 | lucide-react | latest |
|
|
||||||
| 图表 | recharts | 2.x |
|
|
||||||
|
|
||||||
### 目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── components/ # 全局组件
|
|
||||||
│ ├── ui/ # shadcn/ui 组件(Button, Card, Dialog, Tabs, Switch, Select 等)
|
|
||||||
│ ├── Layout.tsx # 主布局(侧边栏 + 顶部栏 + 内容区)
|
|
||||||
│ ├── PageHeader.tsx # 统一页面头部(标题 + 描述 + 操作按钮)
|
|
||||||
│ ├── EmptyState.tsx # 空状态组件
|
|
||||||
│ └── SkeletonCard.tsx # Loading 骨架屏
|
|
||||||
├── pages/ # 页面组件(按路由划分)
|
|
||||||
│ ├── Dashboard.tsx # 仪表盘
|
|
||||||
│ ├── InstancePage.tsx # 数据库实例管理
|
|
||||||
│ ├── TicketsPage.tsx # 数据变更工单
|
|
||||||
│ ├── QueriesPage.tsx # 查询审计
|
|
||||||
│ ├── RolesPage.tsx # 角色管理
|
|
||||||
│ ├── UsersPage.tsx # 用户管理
|
|
||||||
│ ├── DataPermissionPage.tsx # 数据权限
|
|
||||||
│ ├── AuditRulesPage.tsx # 审核规则
|
|
||||||
│ ├── WorkflowPage.tsx # 审批流程配置
|
|
||||||
│ ├── AutoTaskPage.tsx # 自动任务
|
|
||||||
│ ├── AnnouncementPage.tsx # 公告管理
|
|
||||||
│ ├── ConfigPlatformPage.tsx # 配置平台
|
|
||||||
│ └── LoginPage.tsx # 登录
|
|
||||||
├── hooks/ # 自定义 Hooks
|
|
||||||
│ └── useAuth.tsx # 权限上下文 + useAuth + useActionPermission
|
|
||||||
├── lib/ # 工具库
|
|
||||||
│ ├── mock.ts # 全量 Mock 数据
|
|
||||||
│ └── utils.ts # 工具函数
|
|
||||||
├── types/ # TypeScript 类型定义
|
|
||||||
│ └── index.ts # 全局类型
|
|
||||||
├── App.tsx # 根组件(路由 + 路由守卫)
|
|
||||||
├── index.css # 全局样式 + 深色主题变量
|
|
||||||
└── main.tsx # 入口文件
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、核心功能模块
|
|
||||||
|
|
||||||
### 3.1 仪表盘 (Dashboard)
|
|
||||||
|
|
||||||
**路由**: `/dashboard`
|
|
||||||
**权限**: 全部角色
|
|
||||||
|
|
||||||
**功能说明**:
|
|
||||||
- 工单统计卡片(总工单 / 待审批 / 本月 / 进行中)
|
|
||||||
- 审核规则统计卡片
|
|
||||||
- 查询统计卡片
|
|
||||||
- 工单趋势折线图(30天)
|
|
||||||
- 状态分布饼图
|
|
||||||
- 工单表格列表(含详情弹窗)
|
|
||||||
|
|
||||||
**关键代码**:
|
|
||||||
```tsx
|
|
||||||
// pages/Dashboard.tsx
|
|
||||||
// 使用 recharts 绘制图表,工单表格复用 tickets mock 数据
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.2 数据库实例管理 (InstancePage)
|
|
||||||
|
|
||||||
**路由**: `/mysql`, `/postgres`, `/redis`, `/mongo`, `/clickhouse`
|
|
||||||
**权限**: admin, dba
|
|
||||||
|
|
||||||
**功能说明**:
|
|
||||||
- 卡片网格展示实例(连接状态、版本、环境、主机、端口)
|
|
||||||
- 实时性能指标(QPS、连接数、磁盘、CPU、内存)
|
|
||||||
- 实例详情页(监控图表、连接信息、慢查询 TOP5、基本信息)
|
|
||||||
- 支持批量添加实例
|
|
||||||
|
|
||||||
**关键数据结构**:
|
|
||||||
```typescript
|
|
||||||
interface DBInstance {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
type: 'mysql' | 'postgres' | 'redis' | 'mongo' | 'clickhouse';
|
|
||||||
env: 'production' | 'staging' | 'development';
|
|
||||||
status: 'connected' | 'disconnected' | 'error';
|
|
||||||
version: string;
|
|
||||||
databases: number;
|
|
||||||
tables: number;
|
|
||||||
size: string;
|
|
||||||
connections: number;
|
|
||||||
maxConnections: number;
|
|
||||||
qps: number;
|
|
||||||
latency: number;
|
|
||||||
cpu: number;
|
|
||||||
memory: number;
|
|
||||||
disk: number;
|
|
||||||
uptime: string;
|
|
||||||
lastBackup: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.3 数据变更工单 (TicketsPage)
|
|
||||||
|
|
||||||
**路由**: `/tickets`
|
|
||||||
**权限**: admin, dba, developer(developer 仅可查看自己工单)
|
|
||||||
|
|
||||||
**功能说明**:
|
|
||||||
- 工单列表(状态快捷筛选、搜索、多维度过滤)
|
|
||||||
- 新建工单(SQL 编辑器、审核预览、回滚 SQL)
|
|
||||||
- 工单详情(审批流程可视化、SQL 内容、回滚 SQL、审批记录)
|
|
||||||
- 操作权限:审批/拒绝(dba+admin)、执行(dba+admin)
|
|
||||||
|
|
||||||
**工单状态流转**:
|
|
||||||
```
|
|
||||||
pending(待审批) -> reviewing(审批中) -> approved(已通过) -> executing(执行中) -> success(成功)
|
|
||||||
-> rejected(已拒绝)
|
|
||||||
-> failed(失败)
|
|
||||||
```
|
|
||||||
|
|
||||||
**回滚机制**:
|
|
||||||
- 基于 MySQL Binlog ROW 格式解析
|
|
||||||
- AST 语法分析提取变更前后的数据
|
|
||||||
- 执行前自动备份受影响数据
|
|
||||||
- 72 小时内可一键回滚
|
|
||||||
|
|
||||||
**关键数据结构**:
|
|
||||||
```typescript
|
|
||||||
interface Ticket {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
dbType: string;
|
|
||||||
instance: string;
|
|
||||||
database: string;
|
|
||||||
sql: string;
|
|
||||||
rollbackSql?: string;
|
|
||||||
status: 'pending' | 'reviewing' | 'approved' | 'rejected' | 'executing' | 'success' | 'failed';
|
|
||||||
priority: 'high' | 'medium' | 'low';
|
|
||||||
applicant: string;
|
|
||||||
approver: string;
|
|
||||||
createTime: string;
|
|
||||||
executeTime: string;
|
|
||||||
reason: string;
|
|
||||||
workflowSteps?: { name: string; status: string; time?: string }[];
|
|
||||||
approvalRecords?: { approver: string; action: string; comment: string; time: string }[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.4 查询审计 (QueriesPage)
|
|
||||||
|
|
||||||
**路由**: `/queries`
|
|
||||||
**权限**: 全部角色(guest 不可新建查询)
|
|
||||||
|
|
||||||
**功能说明**:
|
|
||||||
- 查询审计日志列表(SQL 语句、执行时长、返回行数、敏感数据标记)
|
|
||||||
- 查询结果集展示(敏感字段自动脱敏)
|
|
||||||
- 新建查询(SQL 编辑器 + 数据库选择)
|
|
||||||
- 查询结果导出
|
|
||||||
|
|
||||||
**敏感数据脱敏规则**:
|
|
||||||
| 字段类型 | 脱敏方式 | 示例 |
|
|
||||||
|---------|---------|------|
|
|
||||||
| 邮箱 | 前缀掩码 | `abc***@gmail.com` |
|
|
||||||
| 手机号 | 中间掩码 | `138****8888` |
|
|
||||||
| 身份证号 | 中间掩码 | `110**********0011` |
|
|
||||||
| 银行卡 | 后四位保留 | `**** **** **** 1234` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.5 角色管理 (RolesPage)
|
|
||||||
|
|
||||||
**路由**: `/roles`
|
|
||||||
**权限**: admin
|
|
||||||
|
|
||||||
**功能说明**:
|
|
||||||
- 角色列表展示
|
|
||||||
- 权限矩阵(页面权限 + 操作权限 + 数据权限)
|
|
||||||
- 角色创建/编辑/删除
|
|
||||||
|
|
||||||
**系统内置角色**:
|
|
||||||
|
|
||||||
| 角色 | 权限范围 |
|
|
||||||
|------|---------|
|
|
||||||
| 超级管理员(admin) | 全部功能 |
|
|
||||||
| 数据库管理员(dba) | 数据源 + 工单审批 + 查询 + 权限 + 规则 + 流程 |
|
|
||||||
| 开发人员(developer) | 工单提交 + 查询 |
|
|
||||||
| 访客(guest) | 仅查询审计查看 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.6 用户管理 (UsersPage)
|
|
||||||
|
|
||||||
**路由**: `/users`
|
|
||||||
**权限**: admin
|
|
||||||
|
|
||||||
**功能说明**:
|
|
||||||
- 用户列表(用户名、角色、状态、最近登录)
|
|
||||||
- 新建/编辑/禁用用户
|
|
||||||
- 角色分配
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.7 审批流程配置 (WorkflowPage)
|
|
||||||
|
|
||||||
**路由**: `/workflow`
|
|
||||||
**权限**: admin, dba
|
|
||||||
|
|
||||||
**功能说明**:
|
|
||||||
- 审批流程模板管理
|
|
||||||
- 流程步骤配置:提交阶段 -> 审核阶段 -> 执行阶段
|
|
||||||
- 支持多级审批(一级审批、二级审批)
|
|
||||||
- 审批模式:或签(任一人通过)/ 会签(全部人通过)
|
|
||||||
- 流程可视化展示
|
|
||||||
- 适用数据库类型配置
|
|
||||||
|
|
||||||
**典型流程示例**:
|
|
||||||
```
|
|
||||||
提交阶段(提交人) -> 一级审批(部门负责人, 或签) -> 二级审批(DBA团队, 或签) -> 执行阶段(DBA执行人)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.8 自动任务 (AutoTaskPage)
|
|
||||||
|
|
||||||
**路由**: `/auto-task`
|
|
||||||
**权限**: admin
|
|
||||||
|
|
||||||
**功能说明**:
|
|
||||||
- 定时 SQL 任务管理(Cron 表达式)
|
|
||||||
- 支持频率:一次性 / 每小时 / 每天 / 每周 / 每月
|
|
||||||
- 任务启停控制
|
|
||||||
- 执行日志和通知
|
|
||||||
|
|
||||||
**典型场景**:
|
|
||||||
- 每日数据归档
|
|
||||||
- 周/月度统计报表生成
|
|
||||||
- 索引健康检查
|
|
||||||
- 过期数据清理
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3.9 配置平台 (ConfigPlatformPage)
|
|
||||||
|
|
||||||
**路由**: `/config-platform`
|
|
||||||
**权限**: admin
|
|
||||||
|
|
||||||
**包含 5 个配置模块**:
|
|
||||||
|
|
||||||
#### 3.9.1 消息推送
|
|
||||||
- 渠道:飞书、钉钉、企业微信、Telegram、邮件
|
|
||||||
- 每个渠道独立开关 + Webhook 配置
|
|
||||||
- 通知事件:工单创建、审批通过、审批拒绝、执行完成、执行失败
|
|
||||||
- 连接测试功能
|
|
||||||
|
|
||||||
#### 3.9.2 LDAP 设置
|
|
||||||
- LDAP/AD 域账号登录集成
|
|
||||||
- 服务器地址、Bind DN、Base DN、字段映射
|
|
||||||
- 自动同步用户(可配置同步间隔)
|
|
||||||
|
|
||||||
#### 3.9.3 查询限制
|
|
||||||
- 查询审核审批开关(不开启时允许查询授权资源)
|
|
||||||
- 最大 Limit 限制(滑块控制 100-100,000)
|
|
||||||
- 查询超时时间
|
|
||||||
- 结果导出行数限制
|
|
||||||
- 写入操作开关
|
|
||||||
- 禁止的关键词列表
|
|
||||||
|
|
||||||
#### 3.9.4 工单提交规则
|
|
||||||
| 规则 | 说明 |
|
|
||||||
|------|------|
|
|
||||||
| SQL 语法检查 | 提交前自动语法校验 |
|
|
||||||
| 影响行数限制 | 单次操作最大影响行数 |
|
|
||||||
| 工单标题必填 | 最小 5 字符 |
|
|
||||||
| 变更原因必填 | 最小 10 字符 |
|
|
||||||
| 回滚 SQL 必填 | UPDATE/DELETE 必须提供回滚 |
|
|
||||||
| 禁止提交时段 | 配置高峰时段禁止提交 |
|
|
||||||
| 同一表并发限制 | 同一表只能有一个进行中的变更 |
|
|
||||||
| 敏感操作二次确认 | 影响行数超阈值需二次确认 |
|
|
||||||
|
|
||||||
#### 3.9.5 DDL 拦截规则
|
|
||||||
| 规则 | 动作 |
|
|
||||||
|------|------|
|
|
||||||
| DROP TABLE / DATABASE | 拦截 |
|
|
||||||
| TRUNCATE TABLE | 拦截 |
|
|
||||||
| ALTER TABLE DROP/MODIFY/CHANGE COLUMN | 需审批 |
|
|
||||||
| RENAME TABLE | 需审批 |
|
|
||||||
| CREATE TABLE / ADD COLUMN / CREATE INDEX | 警告 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、权限系统
|
|
||||||
|
|
||||||
### 4.1 权限架构
|
|
||||||
|
|
||||||
```
|
|
||||||
路由守卫 (RouteGuard)
|
|
||||||
-> 菜单过滤 (visibleMenuItems)
|
|
||||||
-> 页面级权限 (PageHeader action/secondaryAction)
|
|
||||||
-> 操作级权限 (useActionPermission)
|
|
||||||
-> 数据级权限 (列表过滤)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.2 权限 Hook
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 获取用户信息和权限方法
|
|
||||||
const { user, isAdmin, isDBA, isDeveloper, isGuest, hasPermission } = useAuth();
|
|
||||||
|
|
||||||
// 检查操作权限
|
|
||||||
const canApprove = useActionPermission('ticket.approve'); // true/false
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.3 权限配置表
|
|
||||||
|
|
||||||
路由权限:`useAuth.tsx` 中 `routePermissionMap`
|
|
||||||
操作权限:`useAuth.tsx` 中 `actionPermissionMap`
|
|
||||||
菜单权限:`lib/mock.ts` 中 `menuItems[*].roles`
|
|
||||||
|
|
||||||
### 4.4 角色切换演示
|
|
||||||
|
|
||||||
侧边栏底部提供角色切换按钮(仅演示用途),切换后:
|
|
||||||
1. 自动过滤菜单项
|
|
||||||
2. 检查当前页面是否有权限,无权限则跳转到仪表盘
|
|
||||||
3. 页面内按钮按新角色重新渲染
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、Mock 数据层
|
|
||||||
|
|
||||||
所有数据存储在 `src/lib/mock.ts`,后端接口就绪后替换为 API 调用即可。
|
|
||||||
|
|
||||||
**主要数据集合**:
|
|
||||||
|
|
||||||
| 数据 | 变量名 | 用途 |
|
|
||||||
|------|--------|------|
|
|
||||||
| 数据库实例 | `dbInstances` | 实例管理 |
|
|
||||||
| 工单 | `tickets` | 工单管理 |
|
|
||||||
| 查询审计 | `queryAudits` | 查询审计 |
|
|
||||||
| 审核规则 | `auditRules` | 审核规则 |
|
|
||||||
| 用户 | `users` | 用户管理 |
|
|
||||||
| 角色 | `roles` | 角色管理 |
|
|
||||||
| 审批流程 | `workflows` | 流程配置 |
|
|
||||||
| 菜单 | `menuItems` | 侧边栏菜单 |
|
|
||||||
| 当前用户 | `currentUser` | 登录用户 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、开发规范
|
|
||||||
|
|
||||||
### 6.1 新增页面步骤
|
|
||||||
|
|
||||||
1. 在 `src/pages/` 创建页面组件
|
|
||||||
2. 在 `src/App.tsx` 添加路由(配置 `routePermissionMap` 权限)
|
|
||||||
3. 在 `src/hooks/useAuth.tsx` 的 `routePermissionMap` 中添加路由权限
|
|
||||||
4. 在 `src/lib/mock.ts` 的 `menuItems` 中添加菜单项
|
|
||||||
5. (如需要)在 `actionPermissionMap` 中添加页面操作权限
|
|
||||||
|
|
||||||
### 6.2 新增操作权限
|
|
||||||
|
|
||||||
1. 在 `useAuth.tsx` 的 `actionPermissionMap` 中定义权限
|
|
||||||
2. 在页面中使用 `useActionPermission('action.key')` 获取权限
|
|
||||||
3. 使用条件渲染控制按钮/操作的显示
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const canDelete = useActionPermission('ticket.delete');
|
|
||||||
|
|
||||||
{canDelete && (
|
|
||||||
<button onClick={handleDelete}>删除</button>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.3 使用 PageHeader 组件
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
|
|
||||||
<PageHeader
|
|
||||||
title="页面标题"
|
|
||||||
description="页面描述"
|
|
||||||
action={{ label: '新建', icon: Plus, onClick: () => {}, permission: 'action.key' }}
|
|
||||||
secondaryAction={{ label: '导出', icon: Download, onClick: () => {} }}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、主题与样式
|
|
||||||
|
|
||||||
### 7.1 CSS 变量
|
|
||||||
|
|
||||||
深色主题变量定义在 `src/index.css`:
|
|
||||||
|
|
||||||
```css
|
|
||||||
:root {
|
|
||||||
--dg-bg-primary: #0d1117; /* 主背景 */
|
|
||||||
--dg-bg-secondary: #161b22; /* 卡片背景 */
|
|
||||||
--dg-bg-tertiary: #21262d; /* 输入框/标签背景 */
|
|
||||||
--dg-bg-hover: #1c2128; /* hover 状态 */
|
|
||||||
--dg-border: #30363d; /* 边框 */
|
|
||||||
--dg-border-light: #21262d; /* 浅色边框 */
|
|
||||||
--dg-text-primary: #e6edf3; /* 主要文字 */
|
|
||||||
--dg-text-secondary: #8b949e; /* 次要文字 */
|
|
||||||
--dg-text-muted: #484f58; /* 弱化文字 */
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 7.2 公共样式类
|
|
||||||
|
|
||||||
```css
|
|
||||||
.dg-card /* 统一卡片样式 */
|
|
||||||
.dg-btn-primary /* 主按钮 */
|
|
||||||
.dg-btn-secondary /* 次要按钮 */
|
|
||||||
.dg-input /* 输入框样式 */
|
|
||||||
.dg-label /* 标签样式 */
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、后续优化建议
|
|
||||||
|
|
||||||
### 8.1 后端集成(高优先级)
|
|
||||||
|
|
||||||
| 模块 | 接口需求 |
|
|
||||||
|------|----------|
|
|
||||||
| 用户认证 | `/api/auth/login`, `/api/auth/logout` |
|
|
||||||
| 仪表盘 | `/api/dashboard/stats`, `/api/dashboard/trends` |
|
|
||||||
| 实例管理 | `/api/instances/*` CRUD |
|
|
||||||
| 工单 | `/api/tickets/*` CRUD + 审批/执行/回滚 |
|
|
||||||
| 查询审计 | `/api/queries/*` 执行 + 日志 |
|
|
||||||
| 用户管理 | `/api/users/*` CRUD |
|
|
||||||
| 角色管理 | `/api/roles/*` CRUD |
|
|
||||||
| 配置平台 | `/api/config/*` 读写 |
|
|
||||||
|
|
||||||
### 8.2 功能优化
|
|
||||||
|
|
||||||
- [ ] **SQL 编辑器升级**: 集成 Monaco Editor 替代 textarea,支持语法高亮、自动补全、错误提示
|
|
||||||
- [ ] **实时通知**: WebSocket 推送工单状态变更、审批提醒
|
|
||||||
- [ ] **数据同步**: LDAP 用户自动同步定时任务
|
|
||||||
- [ ] **报表导出**: PDF/Excel 格式的审计报告生成
|
|
||||||
- [ ] **SQL 变更影响分析**: 自动计算影响行数、预估执行时间
|
|
||||||
- [ ] **数据库对比**: 结构对比、数据对比功能
|
|
||||||
- [ ] **备份管理**: 定时备份、备份恢复、备份策略配置
|
|
||||||
- [ ] **操作审计**: 管理员操作日志,防止越权
|
|
||||||
|
|
||||||
### 8.3 性能优化
|
|
||||||
|
|
||||||
- [ ] **虚拟滚动**: 大数据量表格使用虚拟滚动
|
|
||||||
- [ ] **懒加载**: 路由级 Code Splitting
|
|
||||||
- [ ] **数据缓存**: React Query / SWR 管理服务端状态
|
|
||||||
- [ ] **图表优化**: 大数据量图表采样渲染
|
|
||||||
|
|
||||||
### 8.4 安全加固
|
|
||||||
|
|
||||||
- [ ] **前端加密**: 敏感配置字段前端加密传输
|
|
||||||
- [ ] **防 XSS**: 所有用户输入转义输出
|
|
||||||
- [ ] **防 CSRF**: 请求头携带 Token
|
|
||||||
- [ ] **会话管理**: JWT Token 自动刷新、过期提醒
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 九、API 接口规范(建议)
|
|
||||||
|
|
||||||
### 9.1 统一响应格式
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": 200,
|
|
||||||
"message": "success",
|
|
||||||
"data": {},
|
|
||||||
"timestamp": 1705315200000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.2 分页参数
|
|
||||||
|
|
||||||
```
|
|
||||||
GET /api/tickets?page=1&size=10&status=pending&sort=-createTime
|
|
||||||
```
|
|
||||||
|
|
||||||
### 9.3 权限相关 Header
|
|
||||||
|
|
||||||
```
|
|
||||||
Authorization: Bearer <JWT_TOKEN>
|
|
||||||
X-User-Role: admin
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 十、部署说明
|
|
||||||
|
|
||||||
### 10.1 构建
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /mnt/agents/output/app
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
输出目录: `dist/`
|
|
||||||
|
|
||||||
### 10.2 环境变量
|
|
||||||
|
|
||||||
```env
|
|
||||||
# .env.production
|
|
||||||
VITE_API_BASE_URL=https://api.dataguard.company.com
|
|
||||||
VITE_APP_TITLE=DataGuard
|
|
||||||
VITE_VERSION=2.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*文档维护: 开发团队*
|
|
||||||
*如有问题请联系 DBA 团队或提交 Issue*
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,73 +0,0 @@
|
|||||||
# React + TypeScript + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "new-york",
|
|
||||||
"rsc": false,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "postcss.config.js",
|
|
||||||
"css": "src/index.css",
|
|
||||||
"baseColor": "slate",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide",
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/utils",
|
|
||||||
"ui": "@/components/ui",
|
|
||||||
"lib": "@/lib",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
},
|
|
||||||
"registries": {}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
reactHooks.configs.flat.recommended,
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>DataGuard Pro</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
Using Node.js 20, Tailwind CSS v3.4.19, and Vite v7.2.4
|
|
||||||
|
|
||||||
Tailwind CSS has been set up with the shadcn theme
|
|
||||||
|
|
||||||
Setup complete: /mnt/agents/output/app
|
|
||||||
|
|
||||||
Components (40+):
|
|
||||||
accordion, alert-dialog, alert, aspect-ratio, avatar, badge, breadcrumb,
|
|
||||||
button-group, button, calendar, card, carousel, chart, checkbox, collapsible,
|
|
||||||
command, context-menu, dialog, drawer, dropdown-menu, empty, field, form,
|
|
||||||
hover-card, input-group, input-otp, input, item, kbd, label, menubar,
|
|
||||||
navigation-menu, pagination, popover, progress, radio-group, resizable,
|
|
||||||
scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner,
|
|
||||||
spinner, switch, table, tabs, textarea, toggle-group, toggle, tooltip
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
|
|
||||||
|
|
||||||
Structure:
|
|
||||||
src/sections/ Page sections
|
|
||||||
src/hooks/ Custom hooks
|
|
||||||
src/types/ Type definitions
|
|
||||||
src/App.css Styles specific to the Webapp
|
|
||||||
src/App.tsx Root React component
|
|
||||||
src/index.css Global styles
|
|
||||||
src/main.tsx Entry point for rendering the Webapp
|
|
||||||
index.html Entry point for the Webapp
|
|
||||||
tailwind.config.js Configures Tailwind's theme, plugins, etc.
|
|
||||||
vite.config.ts Main build and dev server settings for Vite
|
|
||||||
postcss.config.js Config file for CSS post-processing tools
|
|
||||||
Generated
-8400
File diff suppressed because it is too large.
Load diff
@@ -1,84 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "my-app",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@hookform/resolvers": "^5.2.2",
|
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
|
||||||
"@radix-ui/react-menubar": "^1.1.16",
|
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
|
||||||
"@types/echarts": "^4.9.22",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"cmdk": "^1.1.1",
|
|
||||||
"date-fns": "^4.1.0",
|
|
||||||
"echarts": "^6.0.0",
|
|
||||||
"embla-carousel-react": "^8.6.0",
|
|
||||||
"input-otp": "^1.4.2",
|
|
||||||
"lucide-react": "^0.562.0",
|
|
||||||
"next-themes": "^0.4.6",
|
|
||||||
"react": "^19.2.0",
|
|
||||||
"react-day-picker": "^9.13.0",
|
|
||||||
"react-dom": "^19.2.0",
|
|
||||||
"react-hook-form": "^7.70.0",
|
|
||||||
"react-resizable-panels": "^4.2.2",
|
|
||||||
"react-router": "^7.6.1",
|
|
||||||
"react-router-dom": "^7.15.0",
|
|
||||||
"recharts": "^2.15.4",
|
|
||||||
"sonner": "^2.0.7",
|
|
||||||
"tailwind-merge": "^3.4.0",
|
|
||||||
"vaul": "^1.1.2",
|
|
||||||
"zod": "^4.3.5"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.39.1",
|
|
||||||
"@types/node": "^24.10.1",
|
|
||||||
"@types/react": "^19.2.5",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^5.1.1",
|
|
||||||
"autoprefixer": "^10.4.23",
|
|
||||||
"eslint": "^9.39.1",
|
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.24",
|
|
||||||
"globals": "^16.5.0",
|
|
||||||
"kimi-plugin-inspect-react": "^1.0.3",
|
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"tailwindcss": "^3.4.19",
|
|
||||||
"tailwindcss-animate": "^1.0.7",
|
|
||||||
"tw-animate-css": "^1.4.0",
|
|
||||||
"typescript": "~5.9.3",
|
|
||||||
"typescript-eslint": "^8.46.4",
|
|
||||||
"vite": "^7.2.4"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
#root {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 6em;
|
|
||||||
padding: 1.5em;
|
|
||||||
will-change: filter;
|
|
||||||
transition: filter 300ms;
|
|
||||||
}
|
|
||||||
.logo:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #646cffaa);
|
|
||||||
}
|
|
||||||
.logo.react:hover {
|
|
||||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logo-spin {
|
|
||||||
from {
|
|
||||||
transform: rotate(0deg);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
a:nth-of-type(2) .logo {
|
|
||||||
animation: logo-spin infinite 20s linear;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
padding: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.read-the-docs {
|
|
||||||
color: #888;
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
|
||||||
import { AuthProvider, useAuth } from '@/hooks/useAuth';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
import LoginPage from '@/pages/LoginPage';
|
|
||||||
import Dashboard from '@/pages/Dashboard';
|
|
||||||
import InstancePage from '@/pages/InstancePage';
|
|
||||||
import TicketsPage from '@/pages/TicketsPage';
|
|
||||||
import QueriesPage from '@/pages/QueriesPage';
|
|
||||||
import RolesPage from '@/pages/RolesPage';
|
|
||||||
import UsersPage from '@/pages/UsersPage';
|
|
||||||
import DataPermissionPage from '@/pages/DataPermissionPage';
|
|
||||||
import AuditRulesPage from '@/pages/AuditRulesPage';
|
|
||||||
import WorkflowPage from '@/pages/WorkflowPage';
|
|
||||||
import AutoTaskPage from '@/pages/AutoTaskPage';
|
|
||||||
import AnnouncementPage from '@/pages/AnnouncementPage';
|
|
||||||
import ConfigPlatformPage from '@/pages/ConfigPlatformPage';
|
|
||||||
|
|
||||||
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
|
||||||
function RouteGuard() {
|
|
||||||
const { isLoggedIn, hasPermission } = useAuth();
|
|
||||||
const path = location.pathname.slice(1) || 'dashboard';
|
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
return <Navigate to="/login" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查当前路径是否有权限访问
|
|
||||||
if (path !== 'dashboard' && !hasPermission(path)) {
|
|
||||||
return <Navigate to="/dashboard" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <Outlet />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登录页守卫:已登录用户自动跳转到仪表盘
|
|
||||||
function LoginGuard() {
|
|
||||||
const { isLoggedIn } = useAuth();
|
|
||||||
if (isLoggedIn) {
|
|
||||||
return <Navigate to="/dashboard" replace />;
|
|
||||||
}
|
|
||||||
return <LoginPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppRoutes() {
|
|
||||||
return (
|
|
||||||
<Routes>
|
|
||||||
<Route path="/login" element={<LoginGuard />} />
|
|
||||||
<Route element={<RouteGuard />}>
|
|
||||||
<Route path="/" element={<Layout />}>
|
|
||||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
|
||||||
<Route path="dashboard" element={<Dashboard />} />
|
|
||||||
<Route path="mysql" element={<InstancePage />} />
|
|
||||||
<Route path="postgres" element={<InstancePage />} />
|
|
||||||
<Route path="redis" element={<InstancePage />} />
|
|
||||||
<Route path="mongo" element={<InstancePage />} />
|
|
||||||
<Route path="clickhouse" element={<InstancePage />} />
|
|
||||||
<Route path="tickets" element={<TicketsPage />} />
|
|
||||||
<Route path="queries" element={<QueriesPage />} />
|
|
||||||
<Route path="roles" element={<RolesPage />} />
|
|
||||||
<Route path="users" element={<UsersPage />} />
|
|
||||||
<Route path="data-permission" element={<DataPermissionPage />} />
|
|
||||||
<Route path="audit-rules" element={<AuditRulesPage />} />
|
|
||||||
<Route path="workflow" element={<WorkflowPage />} />
|
|
||||||
<Route path="auto-task" element={<AutoTaskPage />} />
|
|
||||||
<Route path="announcement" element={<AnnouncementPage />} />
|
|
||||||
<Route path="config-platform" element={<ConfigPlatformPage />} />
|
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
|
||||||
</Route>
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<BrowserRouter>
|
|
||||||
<AuthProvider>
|
|
||||||
<AppRoutes />
|
|
||||||
</AuthProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { SearchX, Inbox, FileQuestion } from 'lucide-react';
|
|
||||||
|
|
||||||
interface EmptyStateProps {
|
|
||||||
type?: 'search' | 'data' | 'error';
|
|
||||||
title?: string;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
search: { icon: SearchX, title: '没有找到匹配的结果', desc: '请尝试调整搜索关键词或筛选条件' },
|
|
||||||
data: { icon: Inbox, title: '暂无数据', desc: '当前没有可展示的数据' },
|
|
||||||
error: { icon: FileQuestion, title: '出错了', desc: '请稍后重试' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function EmptyState({ type = 'data', title, description }: EmptyStateProps) {
|
|
||||||
const c = config[type];
|
|
||||||
const Icon = c.icon;
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
||||||
<div className="w-14 h-14 rounded-2xl bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center mb-4">
|
|
||||||
<Icon className="w-7 h-7 text-[var(--dg-text-muted)]" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm font-medium text-[var(--dg-text-secondary)]">{title || c.title}</p>
|
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] mt-1">{description || c.desc}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
LayoutDashboard, Database, Server, Zap, Leaf, MousePointerClick,
|
|
||||||
ClipboardList, Search, UserCog, Users, Lock, Shield, GitBranch, Bell,
|
|
||||||
LogOut, Settings, Clock, Megaphone
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { menuSections } from '@/lib/mock';
|
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
|
||||||
import type { UserRole } from '@/hooks/useAuth';
|
|
||||||
|
|
||||||
const iconMap: Record<string, React.ElementType> = {
|
|
||||||
LayoutDashboard, Database, Server, Zap, Leaf, MousePointerClick,
|
|
||||||
ClipboardList, Search, UserCog, Users, Lock, Shield, GitBranch, Bell,
|
|
||||||
Settings, Clock, Megaphone,
|
|
||||||
};
|
|
||||||
|
|
||||||
const roleBadgeStyle: Record<UserRole, string> = {
|
|
||||||
admin: 'bg-purple-500/15 text-purple-400',
|
|
||||||
dba: 'bg-blue-500/15 text-blue-400',
|
|
||||||
developer: 'bg-green-500/15 text-green-400',
|
|
||||||
guest: 'bg-gray-500/15 text-gray-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Layout() {
|
|
||||||
const { user, logout, switchRole, visibleMenuItems } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const currentPath = location.pathname.slice(1) || 'dashboard';
|
|
||||||
|
|
||||||
const getPageTitle = () => {
|
|
||||||
const item = visibleMenuItems.find(m => m.id === currentPath);
|
|
||||||
return item?.label || '仪表盘';
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleRole = () => {
|
|
||||||
const roles: UserRole[] = ['admin', 'dba', 'developer', 'guest'];
|
|
||||||
const idx = roles.indexOf(user.role);
|
|
||||||
const nextRole = roles[(idx + 1) % roles.length];
|
|
||||||
switchRole(nextRole);
|
|
||||||
// 切换角色后如果当前页面无权限,自动跳仪表盘
|
|
||||||
const allowed = visibleMenuItems.find(m => m.id === currentPath);
|
|
||||||
if (!allowed && currentPath !== 'dashboard') {
|
|
||||||
navigate('/dashboard', { replace: true });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] overflow-hidden">
|
|
||||||
{/* 侧边栏 */}
|
|
||||||
<aside className="flex flex-col bg-[var(--dg-bg-secondary)] border-r border-[var(--dg-border)] w-[260px] min-w-[260px]">
|
|
||||||
{/* Logo */}
|
|
||||||
<div className="h-16 flex items-center px-5 border-b border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center flex-shrink-0">
|
|
||||||
<Shield className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent whitespace-nowrap">
|
|
||||||
DataGuard
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 菜单 */}
|
|
||||||
<nav className="flex-1 overflow-y-auto scrollbar-thin py-3">
|
|
||||||
{menuSections.map(section => {
|
|
||||||
const items = visibleMenuItems.filter(item => item.section === section.id);
|
|
||||||
if (items.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div key={section.id} className="mb-2">
|
|
||||||
<div className="px-5 py-2 text-[11px] font-semibold uppercase tracking-wider text-[var(--dg-text-muted)]">
|
|
||||||
{section.label}
|
|
||||||
</div>
|
|
||||||
{items.map(item => {
|
|
||||||
const Icon = iconMap[item.icon] || Database;
|
|
||||||
const isActive = currentPath === item.id;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className={`
|
|
||||||
flex items-center mx-3 px-4 py-2.5 rounded-lg cursor-pointer transition-all duration-200 relative
|
|
||||||
${isActive
|
|
||||||
? 'bg-blue-500/15 text-blue-400'
|
|
||||||
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)]'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
onClick={() => navigate(`/${item.id}`)}
|
|
||||||
>
|
|
||||||
{isActive && (
|
|
||||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 bg-blue-400 rounded-r" />
|
|
||||||
)}
|
|
||||||
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
|
||||||
<span className="ml-3 text-sm font-medium">{item.label}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* 底部 */}
|
|
||||||
<div className="p-4 border-t border-[var(--dg-border)]">
|
|
||||||
{/* 角色切换演示 */}
|
|
||||||
<div className="mb-3 px-3 py-2 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">当前角色</span>
|
|
||||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${roleBadgeStyle[user.role]}`}>
|
|
||||||
{user.roleName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={toggleRole}
|
|
||||||
className="mt-2 w-full text-center text-[11px] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors py-1 rounded hover:bg-[var(--dg-bg-hover)]"
|
|
||||||
>
|
|
||||||
[演示] 切换角色
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer transition-colors">
|
|
||||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-semibold text-sm flex-shrink-0">
|
|
||||||
{user.avatar}
|
|
||||||
</div>
|
|
||||||
<div className="overflow-hidden flex-1">
|
|
||||||
<div className="text-sm font-medium truncate">{user.username}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)]">{user.roleName}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="mt-2 w-full flex items-center justify-center gap-2 p-2 rounded-lg hover:bg-[var(--dg-bg-hover)] cursor-pointer text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors text-xs"
|
|
||||||
onClick={() => { logout(); navigate('/login'); }}
|
|
||||||
>
|
|
||||||
<LogOut className="w-3.5 h-3.5" /> 退出登录
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* 主内容区 */}
|
|
||||||
<main className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
||||||
{/* 顶部栏 */}
|
|
||||||
<header className="h-16 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)] flex items-center justify-between px-6 flex-shrink-0">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<span className="text-[var(--dg-text-muted)]">DataGuard</span>
|
|
||||||
<span className="text-[var(--dg-text-muted)]">/</span>
|
|
||||||
<span className="text-[var(--dg-text-primary)] font-medium">{getPageTitle()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* 角色标签 */}
|
|
||||||
<span className={`px-2.5 py-1 rounded-md text-xs font-medium ${roleBadgeStyle[user.role]}`}>
|
|
||||||
{user.roleName}
|
|
||||||
</span>
|
|
||||||
<button className="w-9 h-9 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-colors relative">
|
|
||||||
<Bell className="w-[18px] h-[18px]" />
|
|
||||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* 页面内容 */}
|
|
||||||
<div className="flex-1 overflow-y-auto scrollbar-thin p-6">
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useAuth } from '@/hooks/useAuth';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface ActionConfig {
|
|
||||||
label: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
onClick: () => void;
|
|
||||||
permission?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PageHeaderProps {
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
action?: ActionConfig;
|
|
||||||
secondaryAction?: ActionConfig;
|
|
||||||
children?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PageHeader({ title, description, action, secondaryAction, children }: PageHeaderProps) {
|
|
||||||
const { hasPermission } = useAuth();
|
|
||||||
|
|
||||||
const canShowAction = !action?.permission || hasPermission(action.permission);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">{title}</h1>
|
|
||||||
{description && <p className="text-sm text-[var(--dg-text-secondary)] mt-1">{description}</p>}
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{secondaryAction && (
|
|
||||||
<button
|
|
||||||
onClick={secondaryAction.onClick}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all"
|
|
||||||
>
|
|
||||||
{(() => { const Icon = secondaryAction.icon; return <Icon className="w-4 h-4" />; })()}
|
|
||||||
{secondaryAction.label}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canShowAction && action && (
|
|
||||||
<button
|
|
||||||
onClick={action.onClick}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
{(() => { const Icon = action.icon; return <Icon className="w-4 h-4" />; })()}
|
|
||||||
{action.label}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
export function SkeletonCard({ count = 4 }: { count?: number }) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
|
||||||
<div key={i} className="dg-card p-4 animate-pulse">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-[var(--dg-bg-tertiary)]" />
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="h-6 w-12 bg-[var(--dg-bg-tertiary)] rounded" />
|
|
||||||
<div className="h-3 w-16 bg-[var(--dg-bg-tertiary)] rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkeletonTable({ rows = 5, cols = 6 }: { rows?: number; cols?: number }) {
|
|
||||||
return (
|
|
||||||
<div className="dg-card animate-pulse">
|
|
||||||
<div className="p-0">
|
|
||||||
<div className="flex gap-4 px-5 py-3 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
|
||||||
{Array.from({ length: cols }).map((_, i) => (
|
|
||||||
<div key={i} className="h-4 flex-1 bg-[var(--dg-bg-primary)] rounded max-w-[120px]" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{Array.from({ length: rows }).map((_, r) => (
|
|
||||||
<div key={r} className="flex gap-4 px-5 py-3.5 border-b border-[var(--dg-border-light)]">
|
|
||||||
{Array.from({ length: cols }).map((_, c) => (
|
|
||||||
<div key={c} className="h-4 flex-1 bg-[var(--dg-bg-tertiary)] rounded max-w-[120px]" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SkeletonDetail() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 animate-pulse max-w-5xl">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)]" />
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="h-7 w-48 bg-[var(--dg-bg-tertiary)] rounded" />
|
|
||||||
<div className="h-4 w-32 bg-[var(--dg-bg-tertiary)] rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-5">
|
|
||||||
<div className="dg-card p-5 space-y-3">
|
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<div key={i} className="flex justify-between py-2">
|
|
||||||
<div className="h-4 w-24 bg-[var(--dg-bg-tertiary)] rounded" />
|
|
||||||
<div className="h-4 w-32 bg-[var(--dg-bg-tertiary)] rounded" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="dg-card p-5">
|
|
||||||
<div className="h-48 bg-[var(--dg-bg-tertiary)] rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
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 }
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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-full 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 }
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
|
|
||||||
const buttonGroupVariants = cva(
|
|
||||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
orientation: {
|
|
||||||
horizontal:
|
|
||||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
|
||||||
vertical:
|
|
||||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
orientation: "horizontal",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function ButtonGroup({
|
|
||||||
className,
|
|
||||||
orientation,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
data-slot="button-group"
|
|
||||||
data-orientation={orientation}
|
|
||||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ButtonGroupText({
|
|
||||||
className,
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : "div"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
className={cn(
|
|
||||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ButtonGroupSeparator({
|
|
||||||
className,
|
|
||||||
orientation = "vertical",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Separator>) {
|
|
||||||
return (
|
|
||||||
<Separator
|
|
||||||
data-slot="button-group-separator"
|
|
||||||
orientation={orientation}
|
|
||||||
className={cn(
|
|
||||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
ButtonGroup,
|
|
||||||
ButtonGroupSeparator,
|
|
||||||
ButtonGroupText,
|
|
||||||
buttonGroupVariants,
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-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",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
||||||
destructive:
|
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
|
||||||
outline:
|
|
||||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
|
||||||
secondary:
|
|
||||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
||||||
ghost:
|
|
||||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
|
||||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
|
||||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
|
||||||
icon: "size-9",
|
|
||||||
"icon-sm": "size-8",
|
|
||||||
"icon-lg": "size-10",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Button({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
size = "default",
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"button"> &
|
|
||||||
VariantProps<typeof buttonVariants> & {
|
|
||||||
asChild?: boolean
|
|
||||||
}) {
|
|
||||||
const Comp = asChild ? Slot : "button"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="button"
|
|
||||||
data-variant={variant}
|
|
||||||
data-size={size}
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import {
|
|
||||||
ChevronDownIcon,
|
|
||||||
ChevronLeftIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import {
|
|
||||||
DayPicker,
|
|
||||||
getDefaultClassNames,
|
|
||||||
type DayButton,
|
|
||||||
} from "react-day-picker"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button, buttonVariants } from "@/components/ui/button"
|
|
||||||
|
|
||||||
function Calendar({
|
|
||||||
className,
|
|
||||||
classNames,
|
|
||||||
showOutsideDays = true,
|
|
||||||
captionLayout = "label",
|
|
||||||
buttonVariant = "ghost",
|
|
||||||
formatters,
|
|
||||||
components,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DayPicker> & {
|
|
||||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
|
||||||
}) {
|
|
||||||
const defaultClassNames = getDefaultClassNames()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DayPicker
|
|
||||||
showOutsideDays={showOutsideDays}
|
|
||||||
className={cn(
|
|
||||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
|
||||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
|
||||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
captionLayout={captionLayout}
|
|
||||||
formatters={{
|
|
||||||
formatMonthDropdown: (date) =>
|
|
||||||
date.toLocaleString("default", { month: "short" }),
|
|
||||||
...formatters,
|
|
||||||
}}
|
|
||||||
classNames={{
|
|
||||||
root: cn("w-fit", defaultClassNames.root),
|
|
||||||
months: cn(
|
|
||||||
"flex gap-4 flex-col md:flex-row relative",
|
|
||||||
defaultClassNames.months
|
|
||||||
),
|
|
||||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
|
||||||
nav: cn(
|
|
||||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
|
||||||
defaultClassNames.nav
|
|
||||||
),
|
|
||||||
button_previous: cn(
|
|
||||||
buttonVariants({ variant: buttonVariant }),
|
|
||||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
|
||||||
defaultClassNames.button_previous
|
|
||||||
),
|
|
||||||
button_next: cn(
|
|
||||||
buttonVariants({ variant: buttonVariant }),
|
|
||||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
|
||||||
defaultClassNames.button_next
|
|
||||||
),
|
|
||||||
month_caption: cn(
|
|
||||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
|
||||||
defaultClassNames.month_caption
|
|
||||||
),
|
|
||||||
dropdowns: cn(
|
|
||||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
|
||||||
defaultClassNames.dropdowns
|
|
||||||
),
|
|
||||||
dropdown_root: cn(
|
|
||||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
|
||||||
defaultClassNames.dropdown_root
|
|
||||||
),
|
|
||||||
dropdown: cn(
|
|
||||||
"absolute bg-popover inset-0 opacity-0",
|
|
||||||
defaultClassNames.dropdown
|
|
||||||
),
|
|
||||||
caption_label: cn(
|
|
||||||
"select-none font-medium",
|
|
||||||
captionLayout === "label"
|
|
||||||
? "text-sm"
|
|
||||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
|
||||||
defaultClassNames.caption_label
|
|
||||||
),
|
|
||||||
table: "w-full border-collapse",
|
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
|
||||||
weekday: cn(
|
|
||||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
|
||||||
defaultClassNames.weekday
|
|
||||||
),
|
|
||||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
|
||||||
week_number_header: cn(
|
|
||||||
"select-none w-(--cell-size)",
|
|
||||||
defaultClassNames.week_number_header
|
|
||||||
),
|
|
||||||
week_number: cn(
|
|
||||||
"text-[0.8rem] select-none text-muted-foreground",
|
|
||||||
defaultClassNames.week_number
|
|
||||||
),
|
|
||||||
day: cn(
|
|
||||||
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
|
||||||
props.showWeekNumber
|
|
||||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
|
||||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
|
||||||
defaultClassNames.day
|
|
||||||
),
|
|
||||||
range_start: cn(
|
|
||||||
"rounded-l-md bg-accent",
|
|
||||||
defaultClassNames.range_start
|
|
||||||
),
|
|
||||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
|
||||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
|
||||||
today: cn(
|
|
||||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
|
||||||
defaultClassNames.today
|
|
||||||
),
|
|
||||||
outside: cn(
|
|
||||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
|
||||||
defaultClassNames.outside
|
|
||||||
),
|
|
||||||
disabled: cn(
|
|
||||||
"text-muted-foreground opacity-50",
|
|
||||||
defaultClassNames.disabled
|
|
||||||
),
|
|
||||||
hidden: cn("invisible", defaultClassNames.hidden),
|
|
||||||
...classNames,
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
Root: ({ className, rootRef, ...props }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="calendar"
|
|
||||||
ref={rootRef}
|
|
||||||
className={cn(className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
Chevron: ({ className, orientation, ...props }) => {
|
|
||||||
if (orientation === "left") {
|
|
||||||
return (
|
|
||||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (orientation === "right") {
|
|
||||||
return (
|
|
||||||
<ChevronRightIcon
|
|
||||||
className={cn("size-4", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
|
||||||
)
|
|
||||||
},
|
|
||||||
DayButton: CalendarDayButton,
|
|
||||||
WeekNumber: ({ children, ...props }) => {
|
|
||||||
return (
|
|
||||||
<td {...props}>
|
|
||||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
...components,
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function CalendarDayButton({
|
|
||||||
className,
|
|
||||||
day,
|
|
||||||
modifiers,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DayButton>) {
|
|
||||||
const defaultClassNames = getDefaultClassNames()
|
|
||||||
|
|
||||||
const ref = React.useRef<HTMLButtonElement>(null)
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (modifiers.focused) ref.current?.focus()
|
|
||||||
}, [modifiers.focused])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
ref={ref}
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
data-day={day.date.toLocaleDateString()}
|
|
||||||
data-selected-single={
|
|
||||||
modifiers.selected &&
|
|
||||||
!modifiers.range_start &&
|
|
||||||
!modifiers.range_end &&
|
|
||||||
!modifiers.range_middle
|
|
||||||
}
|
|
||||||
data-range-start={modifiers.range_start}
|
|
||||||
data-range-end={modifiers.range_end}
|
|
||||||
data-range-middle={modifiers.range_middle}
|
|
||||||
className={cn(
|
|
||||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
|
||||||
defaultClassNames.day,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Calendar, CalendarDayButton }
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
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 shadow-sm",
|
|
||||||
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-2 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,
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
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
|
|
||||||
.filter((item) => item.type !== "none")
|
|
||||||
.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
|
|
||||||
.filter((item) => item.type !== "none")
|
|
||||||
.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,
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
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="grid place-content-center text-current transition-none"
|
|
||||||
>
|
|
||||||
<CheckIcon className="size-3.5" />
|
|
||||||
</CheckboxPrimitive.Indicator>
|
|
||||||
</CheckboxPrimitive.Root>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Checkbox }
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
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,
|
|
||||||
className,
|
|
||||||
showCloseButton = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Dialog> & {
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
className?: string
|
|
||||||
showCloseButton?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Dialog {...props}>
|
|
||||||
<DialogHeader className="sr-only">
|
|
||||||
<DialogTitle>{title}</DialogTitle>
|
|
||||||
<DialogDescription>{description}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogContent
|
|
||||||
className={cn("overflow-hidden p-0", className)}
|
|
||||||
showCloseButton={showCloseButton}
|
|
||||||
>
|
|
||||||
<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,
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
|
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function ContextMenu({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
|
|
||||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuSub({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
|
||||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuRadioGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.RadioGroup
|
|
||||||
data-slot="context-menu-radio-group"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuSubTrigger({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.SubTrigger
|
|
||||||
data-slot="context-menu-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 [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRightIcon className="ml-auto" />
|
|
||||||
</ContextMenuPrimitive.SubTrigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuSubContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.SubContent
|
|
||||||
data-slot="context-menu-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-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Portal>
|
|
||||||
<ContextMenuPrimitive.Content
|
|
||||||
data-slot="context-menu-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 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</ContextMenuPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuItem({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
variant?: "default" | "destructive"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Item
|
|
||||||
data-slot="context-menu-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 ContextMenuCheckboxItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
checked,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.CheckboxItem
|
|
||||||
data-slot="context-menu-checkbox-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm 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">
|
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</ContextMenuPrimitive.CheckboxItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuRadioItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.RadioItem
|
|
||||||
data-slot="context-menu-radio-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm 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">
|
|
||||||
<ContextMenuPrimitive.ItemIndicator>
|
|
||||||
<CircleIcon className="size-2 fill-current" />
|
|
||||||
</ContextMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</ContextMenuPrimitive.RadioItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuLabel({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Label
|
|
||||||
data-slot="context-menu-label"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<ContextMenuPrimitive.Separator
|
|
||||||
data-slot="context-menu-separator"
|
|
||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ContextMenuShortcut({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="context-menu-shortcut"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuCheckboxItem,
|
|
||||||
ContextMenuRadioItem,
|
|
||||||
ContextMenuLabel,
|
|
||||||
ContextMenuSeparator,
|
|
||||||
ContextMenuShortcut,
|
|
||||||
ContextMenuGroup,
|
|
||||||
ContextMenuPortal,
|
|
||||||
ContextMenuSub,
|
|
||||||
ContextMenuSubContent,
|
|
||||||
ContextMenuSubTrigger,
|
|
||||||
ContextMenuRadioGroup,
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
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,
|
|
||||||
showCloseButton = true,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
|
||||||
showCloseButton?: boolean
|
|
||||||
}) {
|
|
||||||
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 outline-none sm:max-w-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
{showCloseButton && (
|
|
||||||
<DialogPrimitive.Close
|
|
||||||
data-slot="dialog-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,
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
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-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
|
||||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function DropdownMenu({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Trigger
|
|
||||||
data-slot="dropdown-menu-trigger"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuContent({
|
|
||||||
className,
|
|
||||||
sideOffset = 4,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Portal>
|
|
||||||
<DropdownMenuPrimitive.Content
|
|
||||||
data-slot="dropdown-menu-content"
|
|
||||||
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 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</DropdownMenuPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuItem({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
|
||||||
inset?: boolean
|
|
||||||
variant?: "default" | "destructive"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Item
|
|
||||||
data-slot="dropdown-menu-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 DropdownMenuCheckboxItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
checked,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm 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">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.RadioGroup
|
|
||||||
data-slot="dropdown-menu-radio-group"
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.RadioItem
|
|
||||||
data-slot="dropdown-menu-radio-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm 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">
|
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
<CircleIcon className="size-2 fill-current" />
|
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
{children}
|
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Label
|
|
||||||
data-slot="dropdown-menu-label"
|
|
||||||
data-inset={inset}
|
|
||||||
className={cn(
|
|
||||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.Separator
|
|
||||||
data-slot="dropdown-menu-separator"
|
|
||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
data-slot="dropdown-menu-shortcut"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSub({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
|
||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
|
||||||
className,
|
|
||||||
inset,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
|
||||||
inset?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
|
||||||
data-slot="dropdown-menu-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 [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
|
||||||
return (
|
|
||||||
<DropdownMenuPrimitive.SubContent
|
|
||||||
data-slot="dropdown-menu-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-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuCheckboxItem,
|
|
||||||
DropdownMenuRadioGroup,
|
|
||||||
DropdownMenuRadioItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Empty({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="empty"
|
|
||||||
className={cn(
|
|
||||||
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="empty-header"
|
|
||||||
className={cn(
|
|
||||||
"flex max-w-sm flex-col items-center gap-2 text-center",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyMediaVariants = cva(
|
|
||||||
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-transparent",
|
|
||||||
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function EmptyMedia({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="empty-icon"
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(emptyMediaVariants({ variant, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="empty-title"
|
|
||||||
className={cn("text-lg font-medium tracking-tight", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="empty-description"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="empty-content"
|
|
||||||
className={cn(
|
|
||||||
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Empty,
|
|
||||||
EmptyHeader,
|
|
||||||
EmptyTitle,
|
|
||||||
EmptyDescription,
|
|
||||||
EmptyContent,
|
|
||||||
EmptyMedia,
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import { useMemo } from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
|
|
||||||
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
|
|
||||||
return (
|
|
||||||
<fieldset
|
|
||||||
data-slot="field-set"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col gap-6",
|
|
||||||
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldLegend({
|
|
||||||
className,
|
|
||||||
variant = "legend",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
|
|
||||||
return (
|
|
||||||
<legend
|
|
||||||
data-slot="field-legend"
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(
|
|
||||||
"mb-3 font-medium",
|
|
||||||
"data-[variant=legend]:text-base",
|
|
||||||
"data-[variant=label]:text-sm",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-group"
|
|
||||||
className={cn(
|
|
||||||
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const fieldVariants = cva(
|
|
||||||
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
orientation: {
|
|
||||||
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
|
|
||||||
horizontal: [
|
|
||||||
"flex-row items-center",
|
|
||||||
"[&>[data-slot=field-label]]:flex-auto",
|
|
||||||
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
|
||||||
],
|
|
||||||
responsive: [
|
|
||||||
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
|
|
||||||
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
|
|
||||||
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
orientation: "vertical",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Field({
|
|
||||||
className,
|
|
||||||
orientation = "vertical",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
data-slot="field"
|
|
||||||
data-orientation={orientation}
|
|
||||||
className={cn(fieldVariants({ orientation }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-content"
|
|
||||||
className={cn(
|
|
||||||
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldLabel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Label>) {
|
|
||||||
return (
|
|
||||||
<Label
|
|
||||||
data-slot="field-label"
|
|
||||||
className={cn(
|
|
||||||
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
|
|
||||||
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
|
|
||||||
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-label"
|
|
||||||
className={cn(
|
|
||||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
data-slot="field-description"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
|
|
||||||
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
|
|
||||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldSeparator({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
children?: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="field-separator"
|
|
||||||
data-content={!!children}
|
|
||||||
className={cn(
|
|
||||||
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<Separator className="absolute inset-0 top-1/2" />
|
|
||||||
{children && (
|
|
||||||
<span
|
|
||||||
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
|
||||||
data-slot="field-separator-content"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldError({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
errors,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & {
|
|
||||||
errors?: Array<{ message?: string } | undefined>
|
|
||||||
}) {
|
|
||||||
const content = useMemo(() => {
|
|
||||||
if (children) {
|
|
||||||
return children
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!errors?.length) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueErrors = [
|
|
||||||
...new Map(errors.map((error) => [error?.message, error])).values(),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (uniqueErrors?.length == 1) {
|
|
||||||
return uniqueErrors[0]?.message
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ul className="ml-4 flex list-disc flex-col gap-1">
|
|
||||||
{uniqueErrors.map(
|
|
||||||
(error, index) =>
|
|
||||||
error?.message && <li key={index}>{error.message}</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
)
|
|
||||||
}, [children, errors])
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
data-slot="field-error"
|
|
||||||
className={cn("text-destructive text-sm font-normal", className)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Field,
|
|
||||||
FieldLabel,
|
|
||||||
FieldDescription,
|
|
||||||
FieldError,
|
|
||||||
FieldGroup,
|
|
||||||
FieldLegend,
|
|
||||||
FieldSeparator,
|
|
||||||
FieldSet,
|
|
||||||
FieldContent,
|
|
||||||
FieldTitle,
|
|
||||||
}
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import type * 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,
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function HoverCard({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
|
|
||||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function HoverCardTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
|
||||||
return (
|
|
||||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function HoverCardContent({
|
|
||||||
className,
|
|
||||||
align = "center",
|
|
||||||
sideOffset = 4,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
|
||||||
<HoverCardPrimitive.Content
|
|
||||||
data-slot="hover-card-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-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
</HoverCardPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
|
||||||
|
|
||||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="input-group"
|
|
||||||
role="group"
|
|
||||||
className={cn(
|
|
||||||
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
|
|
||||||
"h-9 min-w-0 has-[>textarea]:h-auto",
|
|
||||||
|
|
||||||
// Variants based on alignment.
|
|
||||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
|
||||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
|
||||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
|
||||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
|
||||||
|
|
||||||
// Focus state.
|
|
||||||
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
|
|
||||||
|
|
||||||
// Error state.
|
|
||||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
|
||||||
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputGroupAddonVariants = cva(
|
|
||||||
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
align: {
|
|
||||||
"inline-start":
|
|
||||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
|
||||||
"inline-end":
|
|
||||||
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
|
|
||||||
"block-start":
|
|
||||||
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
|
|
||||||
"block-end":
|
|
||||||
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
align: "inline-start",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function InputGroupAddon({
|
|
||||||
className,
|
|
||||||
align = "inline-start",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="group"
|
|
||||||
data-slot="input-group-addon"
|
|
||||||
data-align={align}
|
|
||||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
|
||||||
onClick={(e) => {
|
|
||||||
if ((e.target as HTMLElement).closest("button")) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputGroupButtonVariants = cva(
|
|
||||||
"text-sm shadow-none flex gap-2 items-center",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
size: {
|
|
||||||
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
|
|
||||||
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
|
|
||||||
"icon-xs":
|
|
||||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
|
||||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
size: "xs",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function InputGroupButton({
|
|
||||||
className,
|
|
||||||
type = "button",
|
|
||||||
variant = "ghost",
|
|
||||||
size = "xs",
|
|
||||||
...props
|
|
||||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
|
||||||
VariantProps<typeof inputGroupButtonVariants>) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type={type}
|
|
||||||
data-size={size}
|
|
||||||
variant={variant}
|
|
||||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputGroupInput({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"input">) {
|
|
||||||
return (
|
|
||||||
<Input
|
|
||||||
data-slot="input-group-control"
|
|
||||||
className={cn(
|
|
||||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function InputGroupTextarea({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"textarea">) {
|
|
||||||
return (
|
|
||||||
<Textarea
|
|
||||||
data-slot="input-group-control"
|
|
||||||
className={cn(
|
|
||||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
InputGroup,
|
|
||||||
InputGroupAddon,
|
|
||||||
InputGroupButton,
|
|
||||||
InputGroupText,
|
|
||||||
InputGroupInput,
|
|
||||||
InputGroupTextarea,
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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 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 }
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
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"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
|
|
||||||
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="list"
|
|
||||||
data-slot="item-group"
|
|
||||||
className={cn("group/item-group flex flex-col", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof Separator>) {
|
|
||||||
return (
|
|
||||||
<Separator
|
|
||||||
data-slot="item-separator"
|
|
||||||
orientation="horizontal"
|
|
||||||
className={cn("my-0", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemVariants = cva(
|
|
||||||
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-transparent",
|
|
||||||
outline: "border-border",
|
|
||||||
muted: "bg-muted/50",
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default: "p-4 gap-4 ",
|
|
||||||
sm: "py-3 px-4 gap-2.5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
size: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function Item({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
size = "default",
|
|
||||||
asChild = false,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> &
|
|
||||||
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
|
|
||||||
const Comp = asChild ? Slot : "div"
|
|
||||||
return (
|
|
||||||
<Comp
|
|
||||||
data-slot="item"
|
|
||||||
data-variant={variant}
|
|
||||||
data-size={size}
|
|
||||||
className={cn(itemVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const itemMediaVariants = cva(
|
|
||||||
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: "bg-transparent",
|
|
||||||
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
image:
|
|
||||||
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: "default",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
function ItemMedia({
|
|
||||||
className,
|
|
||||||
variant = "default",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="item-media"
|
|
||||||
data-variant={variant}
|
|
||||||
className={cn(itemMediaVariants({ variant, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="item-content"
|
|
||||||
className={cn(
|
|
||||||
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="item-title"
|
|
||||||
className={cn(
|
|
||||||
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
|
|
||||||
return (
|
|
||||||
<p
|
|
||||||
data-slot="item-description"
|
|
||||||
className={cn(
|
|
||||||
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
|
|
||||||
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="item-actions"
|
|
||||||
className={cn("flex items-center gap-2", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="item-header"
|
|
||||||
className={cn(
|
|
||||||
"flex basis-full items-center justify-between gap-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="item-footer"
|
|
||||||
className={cn(
|
|
||||||
"flex basis-full items-center justify-between gap-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Item,
|
|
||||||
ItemMedia,
|
|
||||||
ItemContent,
|
|
||||||
ItemActions,
|
|
||||||
ItemGroup,
|
|
||||||
ItemSeparator,
|
|
||||||
ItemTitle,
|
|
||||||
ItemDescription,
|
|
||||||
ItemHeader,
|
|
||||||
ItemFooter,
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
|
|
||||||
return (
|
|
||||||
<kbd
|
|
||||||
data-slot="kbd"
|
|
||||||
className={cn(
|
|
||||||
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
|
|
||||||
"[&_svg:not([class*='size-'])]:size-3",
|
|
||||||
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<kbd
|
|
||||||
data-slot="kbd-group"
|
|
||||||
className={cn("inline-flex items-center gap-1", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Kbd, KbdGroup }
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
"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 }
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import {
|
|
||||||
ChevronLeftIcon,
|
|
||||||
ChevronRightIcon,
|
|
||||||
MoreHorizontalIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
import { buttonVariants, type Button } 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,
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
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 }
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
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 }
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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.Group>) {
|
|
||||||
return (
|
|
||||||
<ResizablePrimitive.Group
|
|
||||||
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.Separator> & {
|
|
||||||
withHandle?: boolean
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ResizablePrimitive.Separator
|
|
||||||
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-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[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.Separator>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
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 }
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
|
||||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Select({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectGroup({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectValue({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectTrigger({
|
|
||||||
className,
|
|
||||||
size = "default",
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
|
||||||
size?: "sm" | "default"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Trigger
|
|
||||||
data-slot="select-trigger"
|
|
||||||
data-size={size}
|
|
||||||
className={cn(
|
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]: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 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SelectPrimitive.Icon asChild>
|
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
|
||||||
</SelectPrimitive.Icon>
|
|
||||||
</SelectPrimitive.Trigger>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
position = "item-aligned",
|
|
||||||
align = "center",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Portal>
|
|
||||||
<SelectPrimitive.Content
|
|
||||||
data-slot="select-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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
|
||||||
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}
|
|
||||||
align={align}
|
|
||||||
{...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)] scroll-my-1"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</SelectPrimitive.Viewport>
|
|
||||||
<SelectScrollDownButton />
|
|
||||||
</SelectPrimitive.Content>
|
|
||||||
</SelectPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectLabel({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Label
|
|
||||||
data-slot="select-label"
|
|
||||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectItem({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
data-slot="select-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 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 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
data-slot="select-item-indicator"
|
|
||||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
|
||||||
>
|
|
||||||
<SelectPrimitive.ItemIndicator>
|
|
||||||
<CheckIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ItemIndicator>
|
|
||||||
</span>
|
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
|
||||||
</SelectPrimitive.Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectSeparator({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Separator
|
|
||||||
data-slot="select-separator"
|
|
||||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollUpButton
|
|
||||||
data-slot="select-scroll-up-button"
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronUpIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ScrollUpButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollDownButton
|
|
||||||
data-slot="select-scroll-down-button"
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ChevronDownIcon className="size-4" />
|
|
||||||
</SelectPrimitive.ScrollDownButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectScrollDownButton,
|
|
||||||
SelectScrollUpButton,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"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"
|
|
||||||
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 }
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
|
||||||
import { XIcon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
|
||||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetTrigger({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
|
||||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetClose({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
|
||||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetPortal({
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
|
||||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetOverlay({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
|
||||||
return (
|
|
||||||
<SheetPrimitive.Overlay
|
|
||||||
data-slot="sheet-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 SheetContent({
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
side = "right",
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<SheetPortal>
|
|
||||||
<SheetOverlay />
|
|
||||||
<SheetPrimitive.Content
|
|
||||||
data-slot="sheet-content"
|
|
||||||
className={cn(
|
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
|
||||||
side === "right" &&
|
|
||||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
|
||||||
side === "left" &&
|
|
||||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
|
||||||
side === "top" &&
|
|
||||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
|
||||||
side === "bottom" &&
|
|
||||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary 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">
|
|
||||||
<XIcon className="size-4" />
|
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</SheetPrimitive.Close>
|
|
||||||
</SheetPrimitive.Content>
|
|
||||||
</SheetPortal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="sheet-header"
|
|
||||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
data-slot="sheet-footer"
|
|
||||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetTitle({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
|
||||||
return (
|
|
||||||
<SheetPrimitive.Title
|
|
||||||
data-slot="sheet-title"
|
|
||||||
className={cn("text-foreground font-semibold", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SheetDescription({
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
|
||||||
return (
|
|
||||||
<SheetPrimitive.Description
|
|
||||||
data-slot="sheet-description"
|
|
||||||
className={cn("text-muted-foreground text-sm", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export {
|
|
||||||
Sheet,
|
|
||||||
SheetTrigger,
|
|
||||||
SheetClose,
|
|
||||||
SheetContent,
|
|
||||||
SheetHeader,
|
|
||||||
SheetFooter,
|
|
||||||
SheetTitle,
|
|
||||||
SheetDescription,
|
|
||||||
}
|
|
||||||
@@ -1,726 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Slot } from "@radix-ui/react-slot"
|
|
||||||
import { cva, type VariantProps } 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,
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
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 ring-ring/50 block size-4 shrink-0 rounded-full border bg-white 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 }
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import {
|
|
||||||
CircleCheckIcon,
|
|
||||||
InfoIcon,
|
|
||||||
Loader2Icon,
|
|
||||||
OctagonXIcon,
|
|
||||||
TriangleAlertIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
import { useTheme } from "next-themes"
|
|
||||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
|
||||||
|
|
||||||
const Toaster = ({ ...props }: ToasterProps) => {
|
|
||||||
const { theme = "system" } = useTheme()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Sonner
|
|
||||||
theme={theme as ToasterProps["theme"]}
|
|
||||||
className="toaster group"
|
|
||||||
icons={{
|
|
||||||
success: <CircleCheckIcon className="size-4" />,
|
|
||||||
info: <InfoIcon className="size-4" />,
|
|
||||||
warning: <TriangleAlertIcon className="size-4" />,
|
|
||||||
error: <OctagonXIcon className="size-4" />,
|
|
||||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
|
||||||
}}
|
|
||||||
style={
|
|
||||||
{
|
|
||||||
"--normal-bg": "var(--popover)",
|
|
||||||
"--normal-text": "var(--popover-foreground)",
|
|
||||||
"--normal-border": "var(--border)",
|
|
||||||
"--border-radius": "var(--radius)",
|
|
||||||
} as React.CSSProperties
|
|
||||||
}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Toaster }
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Loader2Icon } from "lucide-react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
|
||||||
return (
|
|
||||||
<Loader2Icon
|
|
||||||
role="status"
|
|
||||||
aria-label="Loading"
|
|
||||||
className={cn("size-4 animate-spin", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Spinner }
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
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 }
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
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,
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
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 }
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import * as React from "react"
|
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
|
||||||
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Textarea }
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
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> & {
|
|
||||||
spacing?: number
|
|
||||||
}
|
|
||||||
>({
|
|
||||||
size: "default",
|
|
||||||
variant: "default",
|
|
||||||
spacing: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
function ToggleGroup({
|
|
||||||
className,
|
|
||||||
variant,
|
|
||||||
size,
|
|
||||||
spacing = 0,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
|
||||||
VariantProps<typeof toggleVariants> & {
|
|
||||||
spacing?: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<ToggleGroupPrimitive.Root
|
|
||||||
data-slot="toggle-group"
|
|
||||||
data-variant={variant}
|
|
||||||
data-size={size}
|
|
||||||
data-spacing={spacing}
|
|
||||||
style={{ "--gap": spacing } as React.CSSProperties}
|
|
||||||
className={cn(
|
|
||||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
|
||||||
{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}
|
|
||||||
data-spacing={context.spacing}
|
|
||||||
className={cn(
|
|
||||||
toggleVariants({
|
|
||||||
variant: context.variant || variant,
|
|
||||||
size: context.size || size,
|
|
||||||
}),
|
|
||||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
|
||||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ToggleGroupPrimitive.Item>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { ToggleGroup, ToggleGroupItem }
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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 }
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
"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-foreground text-background 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-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
|
||||||
</TooltipPrimitive.Content>
|
|
||||||
</TooltipPrimitive.Portal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { createContext, useContext, useState, useCallback, useMemo } from 'react';
|
|
||||||
import { menuItems, currentUser as defaultUser } from '@/lib/mock';
|
|
||||||
|
|
||||||
export type UserRole = 'admin' | 'dba' | 'developer' | 'guest';
|
|
||||||
|
|
||||||
export interface AuthUser {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
role: UserRole;
|
|
||||||
roleName: string;
|
|
||||||
avatar: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AuthContextType {
|
|
||||||
user: AuthUser;
|
|
||||||
isLoggedIn: boolean;
|
|
||||||
login: (username: string, password: string) => Promise<boolean>;
|
|
||||||
logout: () => void;
|
|
||||||
switchRole: (role: UserRole) => void;
|
|
||||||
hasPermission: (menuId: string) => boolean;
|
|
||||||
visibleMenuItems: typeof menuItems;
|
|
||||||
isAdmin: boolean;
|
|
||||||
isDBA: boolean;
|
|
||||||
isDeveloper: boolean;
|
|
||||||
isGuest: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const roleNameMap: Record<UserRole, string> = {
|
|
||||||
admin: '超级管理员',
|
|
||||||
dba: '数据库管理员',
|
|
||||||
developer: '开发人员',
|
|
||||||
guest: '访客',
|
|
||||||
};
|
|
||||||
|
|
||||||
const routePermissionMap: Record<string, UserRole[]> = {
|
|
||||||
dashboard: ['admin', 'dba', 'developer', 'guest'],
|
|
||||||
mysql: ['admin', 'dba'],
|
|
||||||
postgres: ['admin', 'dba'],
|
|
||||||
redis: ['admin', 'dba'],
|
|
||||||
mongo: ['admin', 'dba'],
|
|
||||||
clickhouse: ['admin', 'dba'],
|
|
||||||
tickets: ['admin', 'dba', 'developer'],
|
|
||||||
queries: ['admin', 'dba', 'developer', 'guest'],
|
|
||||||
roles: ['admin'],
|
|
||||||
users: ['admin'],
|
|
||||||
'data-permission': ['admin', 'dba'],
|
|
||||||
'audit-rules': ['admin', 'dba'],
|
|
||||||
workflow: ['admin', 'dba'],
|
|
||||||
'auto-task': ['admin'],
|
|
||||||
announcement: ['admin'],
|
|
||||||
'config-platform': ['admin'],
|
|
||||||
notifications: ['admin'],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 页面内操作权限
|
|
||||||
export const actionPermissionMap: Record<string, UserRole[]> = {
|
|
||||||
'ticket.create': ['admin', 'dba', 'developer'],
|
|
||||||
'ticket.approve': ['admin', 'dba'],
|
|
||||||
'ticket.execute': ['admin', 'dba'],
|
|
||||||
'ticket.rollback': ['admin', 'dba'],
|
|
||||||
'ticket.delete': ['admin'],
|
|
||||||
'query.create': ['admin', 'dba', 'developer'],
|
|
||||||
'query.export': ['admin', 'dba', 'developer'],
|
|
||||||
'instance.add': ['admin'],
|
|
||||||
'instance.edit': ['admin', 'dba'],
|
|
||||||
'instance.delete': ['admin'],
|
|
||||||
'user.manage': ['admin'],
|
|
||||||
'role.manage': ['admin'],
|
|
||||||
'rule.manage': ['admin', 'dba'],
|
|
||||||
'workflow.manage': ['admin', 'dba'],
|
|
||||||
'config.manage': ['admin'],
|
|
||||||
'announcement.manage': ['admin'],
|
|
||||||
'auto-task.manage': ['admin'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | null>(null);
|
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [user, setUser] = useState<AuthUser>({
|
|
||||||
id: defaultUser.id,
|
|
||||||
username: defaultUser.username,
|
|
||||||
email: defaultUser.email,
|
|
||||||
role: defaultUser.role,
|
|
||||||
roleName: defaultUser.roleName,
|
|
||||||
avatar: defaultUser.avatar,
|
|
||||||
});
|
|
||||||
const [isLoggedIn, setIsLoggedIn] = useState(true);
|
|
||||||
|
|
||||||
const login = useCallback(async (username: string, _password: string): Promise<boolean> => {
|
|
||||||
await new Promise(r => setTimeout(r, 800));
|
|
||||||
setIsLoggedIn(true);
|
|
||||||
setUser({
|
|
||||||
id: 1,
|
|
||||||
username,
|
|
||||||
email: `${username}@company.com`,
|
|
||||||
role: username === 'admin' ? 'admin' : username === 'dba' ? 'dba' : 'developer',
|
|
||||||
roleName: username === 'admin' ? '超级管理员' : username === 'dba' ? '数据库管理员' : '开发人员',
|
|
||||||
avatar: username.charAt(0).toUpperCase(),
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
|
||||||
setIsLoggedIn(false);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const switchRole = useCallback((role: UserRole) => {
|
|
||||||
setUser(prev => ({
|
|
||||||
...prev,
|
|
||||||
role,
|
|
||||||
roleName: roleNameMap[role],
|
|
||||||
username: role === 'admin' ? 'admin' : role === 'dba' ? 'zhangsan' : role === 'developer' ? 'wangwu' : 'visitor',
|
|
||||||
email: `${role}@company.com`,
|
|
||||||
avatar: role.charAt(0).toUpperCase(),
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const hasPermission = useCallback((menuId: string): boolean => {
|
|
||||||
const allowedRoles = routePermissionMap[menuId];
|
|
||||||
if (!allowedRoles) return false;
|
|
||||||
return allowedRoles.includes(user.role);
|
|
||||||
}, [user.role]);
|
|
||||||
|
|
||||||
const visibleMenuItems = useMemo(() => {
|
|
||||||
return menuItems.filter(item => item.roles.includes(user.role));
|
|
||||||
}, [user.role]);
|
|
||||||
|
|
||||||
const value = useMemo(() => ({
|
|
||||||
user,
|
|
||||||
isLoggedIn,
|
|
||||||
login,
|
|
||||||
logout,
|
|
||||||
switchRole,
|
|
||||||
hasPermission,
|
|
||||||
visibleMenuItems,
|
|
||||||
isAdmin: user.role === 'admin',
|
|
||||||
isDBA: user.role === 'dba',
|
|
||||||
isDeveloper: user.role === 'developer',
|
|
||||||
isGuest: user.role === 'guest',
|
|
||||||
}), [user, isLoggedIn, login, logout, switchRole, hasPermission, visibleMenuItems]);
|
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth(): AuthContextType {
|
|
||||||
const ctx = useContext(AuthContext);
|
|
||||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
|
||||||
return ctx;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useActionPermission(action: string): boolean {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const allowedRoles = actionPermissionMap[action];
|
|
||||||
if (!allowedRoles) return false;
|
|
||||||
return allowedRoles.includes(user.role);
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
:root {
|
|
||||||
--background: 220 13% 5%;
|
|
||||||
--foreground: 210 40% 98%;
|
|
||||||
--card: 220 13% 7%;
|
|
||||||
--card-foreground: 210 40% 98%;
|
|
||||||
--popover: 220 13% 7%;
|
|
||||||
--popover-foreground: 210 40% 98%;
|
|
||||||
--primary: 217 91% 60%;
|
|
||||||
--primary-foreground: 0 0% 100%;
|
|
||||||
--secondary: 220 13% 14%;
|
|
||||||
--secondary-foreground: 210 40% 98%;
|
|
||||||
--muted: 220 13% 14%;
|
|
||||||
--muted-foreground: 215 20% 55%;
|
|
||||||
--accent: 220 13% 14%;
|
|
||||||
--accent-foreground: 210 40% 98%;
|
|
||||||
--destructive: 0 62% 50%;
|
|
||||||
--destructive-foreground: 0 0% 100%;
|
|
||||||
--border: 220 13% 18%;
|
|
||||||
--input: 220 13% 14%;
|
|
||||||
--ring: 217 91% 60%;
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--sidebar-background: 220 13% 7%;
|
|
||||||
--sidebar-foreground: 210 40% 90%;
|
|
||||||
--sidebar-primary: 217 91% 60%;
|
|
||||||
--sidebar-primary-foreground: 0 0% 100%;
|
|
||||||
--sidebar-accent: 220 13% 14%;
|
|
||||||
--sidebar-accent-foreground: 210 40% 98%;
|
|
||||||
--sidebar-border: 220 13% 18%;
|
|
||||||
--sidebar-ring: 217 91% 60%;
|
|
||||||
|
|
||||||
--dg-bg-primary: #0d1117;
|
|
||||||
--dg-bg-secondary: #161b22;
|
|
||||||
--dg-bg-tertiary: #21262d;
|
|
||||||
--dg-bg-hover: #30363d;
|
|
||||||
--dg-border: #30363d;
|
|
||||||
--dg-border-light: #21262d;
|
|
||||||
--dg-text-primary: #f0f6fc;
|
|
||||||
--dg-text-secondary: #8b949e;
|
|
||||||
--dg-text-muted: #6e7681;
|
|
||||||
--dg-blue: #58a6ff;
|
|
||||||
--dg-green: #3fb950;
|
|
||||||
--dg-red: #f85149;
|
|
||||||
--dg-orange: #f0883e;
|
|
||||||
--dg-purple: #8957e5;
|
|
||||||
--dg-cyan: #39c5cf;
|
|
||||||
--dg-yellow: #d29922;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] antialiased;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif;
|
|
||||||
}
|
|
||||||
code, pre, .font-mono {
|
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.dg-card {
|
|
||||||
@apply bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl;
|
|
||||||
}
|
|
||||||
.dg-card-hover {
|
|
||||||
@apply transition-all duration-300 hover:-translate-y-0.5;
|
|
||||||
}
|
|
||||||
.dg-card-hover:hover {
|
|
||||||
border-color: rgba(88, 166, 255, 0.5);
|
|
||||||
}
|
|
||||||
.dg-input {
|
|
||||||
@apply bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] focus:border-[var(--dg-blue)] focus:ring-1 focus:ring-[var(--dg-blue)]/20;
|
|
||||||
}
|
|
||||||
.dg-text-muted {
|
|
||||||
color: var(--dg-text-muted);
|
|
||||||
}
|
|
||||||
.dg-text-secondary {
|
|
||||||
color: var(--dg-text-secondary);
|
|
||||||
}
|
|
||||||
.scrollbar-thin::-webkit-scrollbar {
|
|
||||||
width: 4px;
|
|
||||||
height: 4px;
|
|
||||||
}
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--dg-border);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--dg-text-muted);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
import type { Ticket, DBInstance, QueryAudit, AuditRule, Workflow, NotificationChannel, Role, User, DataPermission, PermissionNode, DashboardStats } from '@/types';
|
|
||||||
|
|
||||||
export const dashboardStats: DashboardStats = {
|
|
||||||
todayTickets: 128,
|
|
||||||
todayTicketsTrend: 12,
|
|
||||||
pendingApproval: 23,
|
|
||||||
pendingTrend: -5,
|
|
||||||
queryAuditCount: 15234,
|
|
||||||
queryTrend: 8,
|
|
||||||
slowQueries: 47,
|
|
||||||
slowQueryTrend: -12,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const menuSections = [
|
|
||||||
{ id: 'overview', label: '概览' },
|
|
||||||
{ id: 'datasource', label: '数据源' },
|
|
||||||
{ id: 'ticket', label: '工单管理' },
|
|
||||||
{ id: 'permission', label: '权限管理' },
|
|
||||||
{ id: 'system', label: '系统配置' },
|
|
||||||
{ id: 'config', label: '配置平台' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const menuItems = [
|
|
||||||
{ id: 'dashboard', label: '仪表盘', icon: 'LayoutDashboard', section: 'overview', roles: ['admin', 'dba', 'developer', 'guest'] },
|
|
||||||
{ id: 'mysql', label: 'MySQL', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] },
|
|
||||||
{ id: 'postgres', label: 'PostgreSQL', icon: 'Server', section: 'datasource', roles: ['admin', 'dba'] },
|
|
||||||
{ id: 'redis', label: 'Redis', icon: 'Zap', section: 'datasource', roles: ['admin', 'dba'] },
|
|
||||||
{ id: 'mongo', label: 'MongoDB', icon: 'Leaf', section: 'datasource', roles: ['admin', 'dba'] },
|
|
||||||
{ id: 'clickhouse', label: 'ClickHouse', icon: 'MousePointerClick', section: 'datasource', roles: ['admin', 'dba'] },
|
|
||||||
{ id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] },
|
|
||||||
{ id: 'queries', label: '查询审计', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] },
|
|
||||||
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
|
|
||||||
{ id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] },
|
|
||||||
{ id: 'data-permission', label: '数据权限', icon: 'Lock', section: 'permission', roles: ['admin', 'dba'] },
|
|
||||||
{ id: 'audit-rules', label: '审核规则', icon: 'Shield', section: 'system', roles: ['admin', 'dba'] },
|
|
||||||
{ id: 'workflow', label: '审批流程', icon: 'GitBranch', section: 'system', roles: ['admin', 'dba'] },
|
|
||||||
{ id: 'auto-task', label: '自动任务', icon: 'Clock', section: 'system', roles: ['admin'] },
|
|
||||||
{ id: 'announcement', label: '公告管理', icon: 'Megaphone', section: 'system', roles: ['admin'] },
|
|
||||||
{ id: 'config-platform', label: '配置平台', icon: 'Settings', section: 'config', roles: ['admin'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const currentUser = {
|
|
||||||
id: 1,
|
|
||||||
username: 'admin',
|
|
||||||
email: 'admin@company.com',
|
|
||||||
role: 'admin' as 'admin' | 'dba' | 'developer' | 'guest',
|
|
||||||
roleName: '超级管理员',
|
|
||||||
avatar: 'A',
|
|
||||||
permissions: menuItems.map(m => m.id),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dbInstances: DBInstance[] = [
|
|
||||||
{ id: 1, name: '生产主库', host: '192.168.1.10', port: 3306, status: 'online', dbType: 'mysql', version: '8.0.35', env: 'prod', connections: 245, qps: 3250, latency: 2.5, cpu: 45, memory: 68, disk: 72, uptime: '120天', tags: ['主库', '核心'] },
|
|
||||||
{ id: 2, name: '生产从库-01', host: '192.168.1.11', port: 3306, status: 'online', dbType: 'mysql', version: '8.0.35', env: 'prod', connections: 189, qps: 2800, latency: 3.1, cpu: 38, memory: 55, disk: 65, uptime: '120天', tags: ['从库'] },
|
|
||||||
{ id: 3, name: '生产从库-02', host: '192.168.1.12', port: 3306, status: 'online', dbType: 'mysql', version: '8.0.35', env: 'prod', connections: 192, qps: 2750, latency: 2.8, cpu: 40, memory: 58, disk: 68, uptime: '120天', tags: ['从库'] },
|
|
||||||
{ id: 4, name: '测试库', host: '192.168.2.10', port: 3306, status: 'warning', dbType: 'mysql', version: '8.0.33', env: 'test', connections: 45, qps: 120, latency: 15.2, cpu: 82, memory: 90, disk: 45, uptime: '30天', tags: ['测试'] },
|
|
||||||
{ id: 5, name: '开发库', host: '192.168.3.10', port: 3306, status: 'online', dbType: 'mysql', version: '8.0.33', env: 'dev', connections: 23, qps: 80, latency: 1.2, cpu: 15, memory: 30, disk: 25, uptime: '15天', tags: ['开发'] },
|
|
||||||
{ id: 6, name: '归档库', host: '192.168.1.50', port: 3306, status: 'offline', dbType: 'mysql', version: '8.0.30', env: 'prod', connections: 0, qps: 0, latency: 0, cpu: 0, memory: 0, disk: 88, uptime: '-', tags: ['归档'] },
|
|
||||||
{ id: 7, name: 'PG 主库', host: '192.168.1.20', port: 5432, status: 'online', dbType: 'postgres', version: '15.4', env: 'prod', connections: 156, qps: 2100, latency: 1.8, cpu: 35, memory: 60, disk: 55, uptime: '200天', tags: ['主库'] },
|
|
||||||
{ id: 8, name: 'PG 分析库', host: '192.168.1.21', port: 5432, status: 'warning', dbType: 'postgres', version: '15.4', env: 'prod', connections: 89, qps: 980, latency: 8.5, cpu: 78, memory: 85, disk: 70, uptime: '200天', tags: ['分析'] },
|
|
||||||
{ id: 9, name: 'Redis 集群-01', host: '192.168.1.30', port: 6379, status: 'online', dbType: 'redis', version: '7.2.3', env: 'prod', connections: 1200, qps: 45000, latency: 0.5, cpu: 25, memory: 75, disk: 10, uptime: '90天', tags: ['集群'] },
|
|
||||||
{ id: 10, name: 'Redis 集群-02', host: '192.168.1.31', port: 6379, status: 'online', dbType: 'redis', version: '7.2.3', env: 'prod', connections: 1150, qps: 42000, latency: 0.6, cpu: 28, memory: 72, disk: 10, uptime: '90天', tags: ['集群'] },
|
|
||||||
{ id: 11, name: 'MongoDB 分片-01', host: '192.168.1.40', port: 27017, status: 'online', dbType: 'mongo', version: '7.0.4', env: 'prod', connections: 340, qps: 1800, latency: 3.2, cpu: 42, memory: 65, disk: 60, uptime: '60天', tags: ['分片'] },
|
|
||||||
{ id: 12, name: 'ClickHouse 节点-01', host: '192.168.1.50', port: 8123, status: 'online', dbType: 'clickhouse', version: '23.11', env: 'prod', connections: 67, qps: 560, latency: 12.5, cpu: 55, memory: 80, disk: 75, uptime: '45天', tags: ['分析'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const tickets: Ticket[] = [
|
|
||||||
{
|
|
||||||
id: 'TK-20240115-001', title: '更新用户状态字段', dbType: 'mysql', instance: '生产主库', applicant: '张三', status: 'pending', priority: 'high',
|
|
||||||
sql: 'UPDATE users SET status = \'active\', updated_at = NOW() WHERE id IN (1001, 1002, 1003) AND status != \'active\';',
|
|
||||||
rollbackSql: "-- 基于 Binlog ROW 格式逆向生成的回滚语句\n-- 使用主键精确定位恢复旧值\nUPDATE users SET status = 'inactive', updated_at = NOW() WHERE id IN (1001, 1002, 1003);\n\n-- 备选:从备份库恢复\n-- UPDATE users u JOIN $_$Inception_backup_information$_$ b ON u.id = b.pk_value\n-- SET u.status = b.old_status WHERE b.opid_time = '20240115093025';",
|
|
||||||
reason: '批量激活过期用户账号', createTime: '2024-01-15 09:30:00', updateTime: '2024-01-15 09:30:00',
|
|
||||||
auditScore: 92, auditResults: [
|
|
||||||
{ rule: '禁止无 WHERE 条件的 UPDATE/DELETE', level: 'success', description: 'WHERE 条件存在,符合规范' },
|
|
||||||
{ rule: '大表变更警告', level: 'warning', description: '目标表 users 数据量超过 100 万行', suggestion: '建议分批执行,每次不超过 1000 条' },
|
|
||||||
{ rule: 'SQL 语法检查', level: 'success', description: '语法正确' },
|
|
||||||
],
|
|
||||||
workflowSteps: [
|
|
||||||
{ name: '提交', status: 'completed', time: '09:30', assignee: '张三' },
|
|
||||||
{ name: '一级审批', status: 'completed', time: '10:15', assignee: '李四' },
|
|
||||||
{ name: '二级审批', status: 'active', assignee: '王五' },
|
|
||||||
{ name: '执行', status: 'pending' },
|
|
||||||
],
|
|
||||||
approvalRecords: [
|
|
||||||
{ step: 1, approver: '张三', action: 'approve', comment: '提交工单', time: '2024-01-15 09:30:25' },
|
|
||||||
{ step: 2, approver: '李四', action: 'approve', comment: 'SQL 符合规范,影响范围可控,同意执行', time: '2024-01-15 10:15:38' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'TK-20240115-002', title: '清理过期日志数据', dbType: 'postgres', instance: '日志库', applicant: '李四', status: 'reviewing', priority: 'medium',
|
|
||||||
sql: 'DELETE FROM system_logs WHERE created_at < DATE_TRUNC(\'month\', NOW() - INTERVAL \'3 months\');',
|
|
||||||
rollbackSql: '-- DDL 回滚支持有限,建议先备份再执行\n-- 已从备份库 system_logs_bak 恢复数据\n-- INSERT INTO system_logs SELECT * FROM system_logs_bak WHERE created_at < DATE_TRUNC(\'month\', NOW() - INTERVAL \'3 months\');',
|
|
||||||
reason: '清理3个月前的系统日志,释放磁盘空间', createTime: '2024-01-15 10:15:00', updateTime: '2024-01-15 10:15:00',
|
|
||||||
auditScore: 85, auditResults: [
|
|
||||||
{ rule: 'DELETE 操作检查', level: 'warning', description: 'DELETE 操作影响行数可能较大', suggestion: '建议先 SELECT COUNT(*) 确认影响范围' },
|
|
||||||
{ rule: '备份检查', level: 'success', description: '已确认数据已备份' },
|
|
||||||
],
|
|
||||||
workflowSteps: [
|
|
||||||
{ name: '提交', status: 'completed', time: '10:15', assignee: '李四' },
|
|
||||||
{ name: '一级审批', status: 'active', assignee: '张三' },
|
|
||||||
{ name: '执行', status: 'pending' },
|
|
||||||
],
|
|
||||||
approvalRecords: [
|
|
||||||
{ step: 1, approver: '李四', action: 'approve', comment: '提交工单', time: '2024-01-15 10:15:00' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'TK-20240115-003', title: 'Redis 缓存预热', dbType: 'redis', instance: 'Redis集群', applicant: '王五', status: 'pending', priority: 'low',
|
|
||||||
sql: 'MSET user:1001:profile \'{"name":"张三","level":5}\' user:1002:profile \'{"name":"李四","level":3}\'',
|
|
||||||
reason: '大促前预热用户缓存数据', createTime: '2024-01-15 11:00:00', updateTime: '2024-01-15 11:00:00',
|
|
||||||
auditScore: 98, auditResults: [
|
|
||||||
{ rule: 'Redis 命令检查', level: 'success', description: 'MSET 命令安全' },
|
|
||||||
{ rule: '数据大小检查', level: 'success', description: '单条数据小于 1MB' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ id: 'TK-20240114-005', title: '用户表添加索引', dbType: 'mysql', instance: '生产主库', applicant: '赵六', status: 'approved', priority: 'high', sql: 'ALTER TABLE users ADD INDEX idx_phone (phone);', reason: '优化手机号查询性能', createTime: '2024-01-14 14:20:00', updateTime: '2024-01-14 16:30:00' },
|
|
||||||
{ id: 'TK-20240114-006', title: '订单数据归档', dbType: 'mysql', instance: '归档库', applicant: '钱七', status: 'executing', priority: 'medium', sql: 'INSERT INTO orders_archive SELECT * FROM orders WHERE created_at < "2023-06-01";', rollbackSql: '-- 从归档表删除已归档的数据\nDELETE FROM orders_archive WHERE created_at < "2023-06-01";', reason: '归档半年前的订单数据', createTime: '2024-01-14 15:30:00', updateTime: '2024-01-15 09:00:00' },
|
|
||||||
{ id: 'TK-20240114-007', title: '配置表更新', dbType: 'redis', instance: 'Redis集群', applicant: '孙八', status: 'success', priority: 'low', sql: 'HSET app:config feature_flag new_feature enabled', reason: '开启新功能开关', createTime: '2024-01-14 16:00:00', updateTime: '2024-01-14 16:05:00' },
|
|
||||||
{ id: 'TK-20240114-008', title: '订单表索引优化', dbType: 'mysql', instance: '生产主库', applicant: '周九', status: 'failed', priority: 'high', sql: 'ALTER TABLE orders ADD INDEX idx_created_at (created_at, status);', reason: '优化订单查询', createTime: '2024-01-14 16:45:00', updateTime: '2024-01-14 17:00:00' },
|
|
||||||
{ id: 'TK-20240113-001', title: '用户权限调整', dbType: 'postgres', instance: '权限库', applicant: '吴十', status: 'rejected', priority: 'medium', sql: 'UPDATE user_roles SET role = \'admin\' WHERE user_id = 456;', reason: '调整用户权限', createTime: '2024-01-13 09:00:00', updateTime: '2024-01-13 11:30:00' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const queryAudits: QueryAudit[] = [
|
|
||||||
{ id: 'Q-20240115-001', sql: 'SELECT u.id, u.name, u.email, u.phone, u.status, u.created_at FROM users u WHERE u.status = "active" ORDER BY u.created_at DESC LIMIT 100', sqlPreview: 'SELECT u.id, u.name, u.email...', dbType: 'mysql', instance: '生产主库', user: '张三', executeTime: 125, rowCount: 100, scanRows: 50000, affectedRows: 0, status: 'success', hasSensitive: true, timestamp: '2024-01-15 10:30:25', clientIp: '192.168.10.50', source: 'Web Console', resultColumns: [{ prop: 'id', label: 'ID' }, { prop: 'name', label: '姓名' }, { prop: 'email', label: '邮箱', sensitive: true }, { prop: 'phone', label: '电话', sensitive: true }, { prop: 'status', label: '状态' }, { prop: 'created_at', label: '创建时间' }], resultSet: [{ id: 1001, name: '张三', email: 'zhang***@email.com', phone: '138****8888', status: 'active', created_at: '2024-01-15' }, { id: 1002, name: '李四', email: 'li***@email.com', phone: '139****9999', status: 'active', created_at: '2024-01-14' }, { id: 1003, name: '王五', email: 'wang***@email.com', phone: '137****7777', status: 'active', created_at: '2024-01-13' }] },
|
|
||||||
{ id: 'Q-20240115-002', sql: 'SELECT COUNT(*) as total, DATE(created_at) as date FROM orders WHERE created_at > "2024-01-01" GROUP BY DATE(created_at)', sqlPreview: 'SELECT COUNT(*) as total...', dbType: 'mysql', instance: '生产主库', user: '李四', executeTime: 2500, rowCount: 15, scanRows: 1000000, affectedRows: 0, status: 'slow', hasSensitive: false, timestamp: '2024-01-15 10:28:15', clientIp: '192.168.10.51', source: 'API' },
|
|
||||||
{ id: 'Q-20240115-003', sql: 'UPDATE users SET last_login = NOW(), login_ip = "192.168.10.50" WHERE id = 123', sqlPreview: 'UPDATE users SET...', dbType: 'mysql', instance: '生产主库', user: '系统', executeTime: 45, rowCount: 0, scanRows: 1, affectedRows: 1, status: 'success', hasSensitive: false, timestamp: '2024-01-15 10:25:08', clientIp: '192.168.1.100', source: 'Application' },
|
|
||||||
{ id: 'Q-20240115-004', sql: 'SELECT * FROM user_profiles WHERE user_id = 456', sqlPreview: 'SELECT * FROM user_profiles...', dbType: 'postgres', instance: 'PG 分析库', user: '赵六', executeTime: 89, rowCount: 5, scanRows: 100, affectedRows: 0, status: 'success', hasSensitive: true, timestamp: '2024-01-15 10:20:33', clientIp: '192.168.10.52', source: 'Web Console', resultColumns: [{ prop: 'user_id', label: '用户ID' }, { prop: 'id_card', label: '身份证号', sensitive: true }, { prop: 'address', label: '地址', sensitive: true }], resultSet: [{ user_id: 456, id_card: '310***********1234', address: '上海市***区***路***号' }] },
|
|
||||||
{ id: 'Q-20240115-005', sql: 'DELETE FROM temp_logs WHERE created_at < "2024-01-01"', sqlPreview: 'DELETE FROM temp_logs...', dbType: 'mysql', instance: '测试库', user: '钱七', executeTime: 5000, rowCount: 0, scanRows: 500000, affectedRows: 0, status: 'failed', hasSensitive: false, timestamp: '2024-01-15 10:15:42', clientIp: '192.168.10.53', source: 'CLI' },
|
|
||||||
{ id: 'Q-20240115-006', sql: 'GET user:session:123', sqlPreview: 'GET user:session:123', dbType: 'redis', instance: 'Redis集群-01', user: '孙八', executeTime: 2, rowCount: 1, scanRows: 1, affectedRows: 0, status: 'success', hasSensitive: false, timestamp: '2024-01-15 10:10:18', clientIp: '192.168.10.54', source: 'Application' },
|
|
||||||
{ id: 'Q-20240115-007', sql: 'SELECT o.id, o.order_no, u.name, o.amount, o.status, o.created_at FROM orders o JOIN users u ON o.user_id = u.id WHERE o.created_at > DATE_SUB(NOW(), INTERVAL 7 DAY) ORDER BY o.created_at DESC LIMIT 50', sqlPreview: 'SELECT o.id, o.order_no, u.name...', dbType: 'mysql', instance: '生产主库', user: '周九', executeTime: 3200, rowCount: 50, scanRows: 800000, affectedRows: 0, status: 'slow', hasSensitive: true, timestamp: '2024-01-15 10:05:22', clientIp: '192.168.10.55', source: 'Web Console', resultColumns: [{ prop: 'id', label: 'ID' }, { prop: 'order_no', label: '订单号' }, { prop: 'name', label: '用户名' }, { prop: 'amount', label: '金额' }, { prop: 'status', label: '状态' }], resultSet: [{ id: 50001, order_no: 'ORD-20240115-001', name: '张三', amount: '¥1,299.00', status: '已支付' }, { id: 50002, order_no: 'ORD-20240115-002', name: '李四', amount: '¥2,599.00', status: '待发货' }] },
|
|
||||||
{ id: 'Q-20240115-008', sql: 'db.collection("logs").find({ created_at: { $gte: new Date("2024-01-01") } }).limit(100)', sqlPreview: 'db.collection("logs").find...', dbType: 'mongo', instance: 'MongoDB 分片-01', user: '吴十', executeTime: 156, rowCount: 100, scanRows: 50000, affectedRows: 0, status: 'success', hasSensitive: false, timestamp: '2024-01-15 09:55:10', clientIp: '192.168.10.56', source: 'DataGrip' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const auditRules: AuditRule[] = [
|
|
||||||
{ id: 'R000', name: '表必须有主键(回滚依赖)', level: 'error', dbTypes: ['mysql'], description: '操作的表必须包含主键,否则无法生成精确的回滚语句。回滚依赖主键定位修改的行。', enabled: true, category: '回滚' },
|
|
||||||
{ id: 'R001', name: '禁止无 WHERE 条件的 UPDATE/DELETE', level: 'error', dbTypes: ['mysql', 'postgres'], description: 'UPDATE 和 DELETE 语句必须包含 WHERE 条件,防止误操作全表数据', enabled: true, category: '安全性' },
|
|
||||||
{ id: 'R002', name: '禁止 SELECT * 查询', level: 'warning', dbTypes: ['mysql', 'postgres'], description: 'SELECT 语句应明确指定字段名,避免不必要的数据传输', enabled: true, category: '性能' },
|
|
||||||
{ id: 'R003', name: '大表变更警告(>100万行)', level: 'warning', dbTypes: ['mysql', 'postgres'], description: '影响超过 100 万行的变更需要额外审批,建议使用 pt-online-schema-change', enabled: true, category: '性能' },
|
|
||||||
{ id: 'R004', name: '禁止 DROP TABLE/DATABASE', level: 'error', dbTypes: ['mysql', 'postgres', 'mongo'], description: '禁止执行 DROP TABLE/DATABASE 操作,防止数据丢失', enabled: true, category: '安全性' },
|
|
||||||
{ id: 'R005', name: 'SQL 注入风险检测', level: 'error', dbTypes: ['mysql', 'postgres', 'redis'], description: '检测潜在的 SQL 注入风险,包括拼接字符串等', enabled: true, category: '安全性' },
|
|
||||||
{ id: 'R006', name: '索引使用检查', level: 'info', dbTypes: ['mysql', 'postgres'], description: '检查查询是否使用了索引,避免全表扫描', enabled: false, category: '性能' },
|
|
||||||
{ id: 'R007', name: '禁止 TRUNCATE TABLE', level: 'error', dbTypes: ['mysql', 'postgres'], description: 'TRUNCATE 操作会清空整表数据且无法回滚', enabled: true, category: '安全性' },
|
|
||||||
{ id: 'R008', name: '字段类型变更警告', level: 'warning', dbTypes: ['mysql', 'postgres'], description: 'ALTER TABLE 修改字段类型可能导致数据截断', enabled: true, category: '数据完整性' },
|
|
||||||
{ id: 'R009', name: '外键约束检查', level: 'info', dbTypes: ['mysql', 'postgres'], description: '建议重要关联表添加外键约束保证数据一致性', enabled: false, category: '数据完整性' },
|
|
||||||
{ id: 'R010', name: 'Redis 大键检测(>1MB)', level: 'warning', dbTypes: ['redis'], description: '检测到 Redis 大键,可能影响性能', enabled: true, category: '性能' },
|
|
||||||
{ id: 'R011', name: 'Binlog ROW 格式检查', level: 'error', dbTypes: ['mysql'], description: 'MySQL 必须开启 Binlog 且格式为 ROW,否则无法生成回滚语句', enabled: true, category: '回滚' },
|
|
||||||
{ id: 'R012', name: '禁止 DML 与 DDL 混合操作', level: 'error', dbTypes: ['mysql'], description: '同一工单中不能同时包含 DML 和 DDL 语句,避免表结构变化导致备份解析出错', enabled: true, category: '回滚' },
|
|
||||||
{ id: 'R013', name: '回滚权限检查', level: 'error', dbTypes: ['mysql'], description: '执行回滚的数据库账号需有 REPLICATION SLAVE 和 REPLICATION CLIENT 权限', enabled: true, category: '回滚' },
|
|
||||||
{ id: 'R014', name: 'DDL 回滚限制警告', level: 'warning', dbTypes: ['mysql'], description: 'DROP/TRUNCATE 等 DDL 操作回滚仅能重建表结构,无法恢复原有数据', enabled: true, category: '回滚' },
|
|
||||||
{ id: 'R015', name: '影响行数限制(<10000)', level: 'warning', dbTypes: ['mysql', 'postgres'], description: '单次操作影响行数建议不超过 10000 行,过大的事务可能导致回滚困难和性能问题', enabled: true, category: '回滚' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const workflows: Workflow[] = [
|
|
||||||
{ id: 1, name: '生产环境变更流程', dbTypes: ['mysql', 'postgres', 'redis'], steps: 3, mode: 'all', description: '需要 DBA 和架构师双重审批,适用于所有生产环境变更', enabled: true, conditions: ['影响行数>1000', '执行时间>1s'] },
|
|
||||||
{ id: 2, name: '测试环境变更流程', dbTypes: ['mysql', 'postgres', 'redis', 'mongo'], steps: 1, mode: 'any', description: '仅需一级审批,适用于测试环境', enabled: true },
|
|
||||||
{ id: 3, name: '紧急变更流程', dbTypes: ['mysql', 'postgres'], steps: 2, mode: 'any', description: 'P0/P1 故障时的快速审批通道,30分钟内完成', enabled: true, conditions: ['故障等级>P1'] },
|
|
||||||
{ id: 4, name: '数据查询流程', dbTypes: ['mysql', 'postgres', 'clickhouse'], steps: 1, mode: 'any', description: '敏感数据查询审批,需数据负责人审批', enabled: false },
|
|
||||||
{ id: 5, name: 'DDL 变更流程', dbTypes: ['mysql', 'postgres'], steps: 2, mode: 'all', description: '表结构变更专用流程,需架构师评审', enabled: true, conditions: ['涉及DDL操作'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const notificationChannels: NotificationChannel[] = [
|
|
||||||
{ id: 1, name: '飞书', type: 'feishu', icon: 'Send', description: '通过飞书自定义机器人发送通知', enabled: true, webhook: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx', events: ['ticket_created', 'ticket_approved', 'ticket_rejected', 'ticket_executed', 'ticket_failed'] },
|
|
||||||
{ id: 2, name: '钉钉', type: 'dingtalk', icon: 'Bell', description: '通过钉钉群机器人发送通知', enabled: true, webhook: 'https://oapi.dingtalk.com/robot/send?access_token=xxx', events: ['ticket_created', 'ticket_approved', 'ticket_failed'] },
|
|
||||||
{ id: 3, name: '企业微信', type: 'wechat', icon: 'MessageSquare', description: '通过企业微信Webhook发送通知', enabled: false, webhook: '', events: ['ticket_created', 'ticket_executed'] },
|
|
||||||
{ id: 4, name: 'Telegram', type: 'telegram', icon: 'Navigation', description: '通过 Telegram Bot 发送通知', enabled: false, webhook: '', events: ['ticket_failed'] },
|
|
||||||
{ id: 5, name: '邮件通知', type: 'email', icon: 'Mail', description: '通过 SMTP 邮件发送通知', enabled: true, events: ['ticket_created', 'ticket_approved', 'ticket_rejected', 'ticket_executed'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const roles: Role[] = [
|
|
||||||
{ id: 'admin', name: '超级管理员', description: '拥有系统的所有权限,包括用户管理、系统配置等', icon: 'Crown', iconClass: 'text-purple-400 bg-purple-400/10', userCount: 2, permissionCount: 56, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'clickhouse', 'tickets', 'queries', 'roles', 'users', 'data-permission', 'audit-rules', 'workflow', 'auto-task', 'announcement', 'config-platform', 'notifications'] },
|
|
||||||
{ id: 'dba', name: '数据库管理员', description: '负责数据库运维、工单审批和 SQL 审核', icon: 'Database', iconClass: 'text-blue-400 bg-blue-400/10', userCount: 5, permissionCount: 32, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'tickets', 'queries', 'data-permission', 'audit-rules', 'workflow'] },
|
|
||||||
{ id: 'developer', name: '开发人员', description: '可以提交变更工单和执行数据查询', icon: 'Code', iconClass: 'text-green-400 bg-green-400/10', userCount: 28, permissionCount: 12, permissions: ['dashboard', 'tickets', 'queries'] },
|
|
||||||
{ id: 'guest', name: '访客', description: '仅可查看,无法执行任何操作', icon: 'Eye', iconClass: 'text-gray-400 bg-gray-400/10', userCount: 8, permissionCount: 4, permissions: ['dashboard', 'queries'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const users: User[] = [
|
|
||||||
{ id: 1, username: 'admin', email: 'admin@company.com', role: 'admin', roleName: '超级管理员', department: '运维部', lastLogin: '2024-01-15 09:30:00', status: 'active', avatar: 'A' },
|
|
||||||
{ id: 2, username: 'zhangsan', email: 'zhangsan@company.com', role: 'dba', roleName: '数据库管理员', department: 'DBA组', lastLogin: '2024-01-15 08:45:00', status: 'active', avatar: '张' },
|
|
||||||
{ id: 3, username: 'lisi', email: 'lisi@company.com', role: 'dba', roleName: '数据库管理员', department: 'DBA组', lastLogin: '2024-01-14 18:20:00', status: 'active', avatar: '李' },
|
|
||||||
{ id: 4, username: 'wangwu', email: 'wangwu@company.com', role: 'developer', roleName: '开发人员', department: '研发部', lastLogin: '2024-01-15 10:15:00', status: 'active', avatar: '王' },
|
|
||||||
{ id: 5, username: 'zhaoliu', email: 'zhaoliu@company.com', role: 'developer', roleName: '开发人员', department: '研发部', lastLogin: '2024-01-14 16:30:00', status: 'disabled', avatar: '赵' },
|
|
||||||
{ id: 6, username: 'qianqi', email: 'qianqi@company.com', role: 'developer', roleName: '开发人员', department: '产品部', lastLogin: '2024-01-14 14:20:00', status: 'active', avatar: '钱' },
|
|
||||||
{ id: 7, username: 'sunba', email: 'sunba@company.com', role: 'guest', roleName: '访客', department: '测试部', lastLogin: '2024-01-13 11:00:00', status: 'active', avatar: '孙' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const dataPermissions: DataPermission[] = [
|
|
||||||
{ id: 1, user: 'zhangsan', instance: '生产主库 (MySQL)', database: 'user_db', table: 'users', columns: '*', rowFilter: '', operations: ['SELECT', 'INSERT', 'UPDATE'] },
|
|
||||||
{ id: 2, user: 'lisi', instance: '生产主库 (MySQL)', database: 'order_db', table: 'orders', columns: 'id,order_no,amount,status,created_at', rowFilter: 'created_at > DATE_SUB(NOW(), INTERVAL 30 DAY)', operations: ['SELECT'] },
|
|
||||||
{ id: 3, user: 'wangwu', instance: '测试库 (MySQL)', database: 'test_db', table: '*', columns: '*', rowFilter: '', operations: ['SELECT', 'INSERT', 'UPDATE', 'DELETE'] },
|
|
||||||
{ id: 4, user: 'zhangsan', instance: 'PG 主库', database: 'analytics', table: 'user_behavior', columns: 'user_id,event_type,page_url,timestamp', rowFilter: '', operations: ['SELECT'] },
|
|
||||||
{ id: 5, user: 'admin', instance: 'Redis集群-01', database: '0', table: '*', columns: '*', rowFilter: '', operations: ['GET', 'SET', 'DEL', 'EXPIRE'] },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const permissionTreeData: PermissionNode[] = [
|
|
||||||
{ id: 'dashboard', label: '仪表盘(查看统计、图表)' },
|
|
||||||
{ id: 'datasource', label: '数据源管理', children: [
|
|
||||||
{ id: 'mysql', label: 'MySQL 实例(查看、添加、编辑、删除)' },
|
|
||||||
{ id: 'postgres', label: 'PostgreSQL 实例(查看、添加、编辑、删除)' },
|
|
||||||
{ id: 'redis', label: 'Redis 实例(查看、添加、编辑、删除)' },
|
|
||||||
{ id: 'mongo', label: 'MongoDB 实例(查看、添加、编辑、删除)' },
|
|
||||||
{ id: 'clickhouse', label: 'ClickHouse 实例(查看、添加、编辑、删除)' },
|
|
||||||
]},
|
|
||||||
{ id: 'tickets', label: '数据变更工单(创建、查看、审批、执行)', children: [
|
|
||||||
{ id: 'ticket_create', label: '创建工单' },
|
|
||||||
{ id: 'ticket_approve', label: '审批工单' },
|
|
||||||
{ id: 'ticket_execute', label: '执行工单' },
|
|
||||||
{ id: 'ticket_rollback', label: '回滚工单' },
|
|
||||||
]},
|
|
||||||
{ id: 'queries', label: '查询审计(查看日志、新建查询、导出)', children: [
|
|
||||||
{ id: 'query_create', label: '执行查询' },
|
|
||||||
{ id: 'query_export', label: '导出结果' },
|
|
||||||
{ id: 'query_view_all', label: '查看全部用户查询' },
|
|
||||||
]},
|
|
||||||
{ id: 'permission', label: '权限管理', children: [
|
|
||||||
{ id: 'roles', label: '角色权限(创建、编辑、分配权限)' },
|
|
||||||
{ id: 'users', label: '用户管理(创建、编辑、禁用)' },
|
|
||||||
{ id: 'data-permission', label: '数据权限(配置表级、字段级、行级权限)' },
|
|
||||||
]},
|
|
||||||
{ id: 'system', label: '系统配置', children: [
|
|
||||||
{ id: 'audit-rules', label: '审核规则(启用/禁用、添加规则)' },
|
|
||||||
{ id: 'workflow', label: '审批流程(创建、编辑流程模板)' },
|
|
||||||
{ id: 'auto-task', label: '自动任务' },
|
|
||||||
{ id: 'announcement', label: '公告管理' },
|
|
||||||
]},
|
|
||||||
{ id: 'config', label: '配置平台', children: [
|
|
||||||
{ id: 'config-platform', label: '全局配置(消息推送/LDAP/查询限制/工单规则)' },
|
|
||||||
{ id: 'notifications', label: '通知渠道(配置 Webhook、测试)' },
|
|
||||||
]},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const statusMap: Record<string, string> = {
|
|
||||||
pending: '待审批',
|
|
||||||
reviewing: '审批中',
|
|
||||||
approved: '已通过',
|
|
||||||
rejected: '已拒绝',
|
|
||||||
executing: '执行中',
|
|
||||||
success: '执行成功',
|
|
||||||
failed: '执行失败',
|
|
||||||
rollback: '已回滚',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const statusColorMap: Record<string, string> = {
|
|
||||||
pending: 'bg-yellow-500/15 text-yellow-500',
|
|
||||||
reviewing: 'bg-blue-500/15 text-blue-400',
|
|
||||||
approved: 'bg-green-500/15 text-green-400',
|
|
||||||
rejected: 'bg-red-500/15 text-red-400',
|
|
||||||
executing: 'bg-purple-500/15 text-purple-400',
|
|
||||||
success: 'bg-green-500/15 text-green-400',
|
|
||||||
failed: 'bg-red-500/15 text-red-400',
|
|
||||||
rollback: 'bg-orange-500/15 text-orange-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dbTypeColorMap: Record<string, string> = {
|
|
||||||
mysql: 'bg-[rgba(0,117,143,0.15)] text-[#26a69a]',
|
|
||||||
postgres: 'bg-[rgba(51,103,145,0.15)] text-[#42a5f5]',
|
|
||||||
redis: 'bg-[rgba(220,50,32,0.15)] text-[#ef5350]',
|
|
||||||
mongo: 'bg-[rgba(71,162,72,0.15)] text-[#66bb6a]',
|
|
||||||
clickhouse: 'bg-[rgba(255,202,40,0.15)] text-[#ffca28]',
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
|
||||||
import { twMerge } from "tailwind-merge"
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { StrictMode } from 'react'
|
|
||||||
import { createRoot } from 'react-dom/client'
|
|
||||||
import './index.css'
|
|
||||||
import App from './App.tsx'
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
)
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Megaphone, Plus, Edit2, Trash2, Pin, PinOff, Eye, Clock,
|
|
||||||
CheckCircle, X, Save, AlertTriangle, Send, Calendar
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
|
|
||||||
type AnnouncementPriority = 'urgent' | 'high' | 'normal' | 'low';
|
|
||||||
|
|
||||||
type Announcement = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
content: string;
|
|
||||||
priority: AnnouncementPriority;
|
|
||||||
pinned: boolean;
|
|
||||||
enabled: boolean;
|
|
||||||
author: string;
|
|
||||||
publishTime: string;
|
|
||||||
expireTime: string;
|
|
||||||
readCount: number;
|
|
||||||
targetRoles: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const priorityLabels: Record<AnnouncementPriority, string> = {
|
|
||||||
urgent: '紧急',
|
|
||||||
high: '重要',
|
|
||||||
normal: '普通',
|
|
||||||
low: '低',
|
|
||||||
};
|
|
||||||
|
|
||||||
const priorityColors: Record<AnnouncementPriority, string> = {
|
|
||||||
urgent: 'bg-red-500/15 text-red-400 border-red-500/30',
|
|
||||||
high: 'bg-orange-500/15 text-orange-400 border-orange-500/30',
|
|
||||||
normal: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
|
||||||
low: 'bg-gray-500/15 text-gray-400 border-gray-500/30',
|
|
||||||
};
|
|
||||||
|
|
||||||
const roleOptions = [
|
|
||||||
{ id: 'admin', label: '超级管理员' },
|
|
||||||
{ id: 'dba', label: '数据库管理员' },
|
|
||||||
{ id: 'developer', label: '开发人员' },
|
|
||||||
{ id: 'guest', label: '访客' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const initialAnnouncements: Announcement[] = [
|
|
||||||
{
|
|
||||||
id: 'ann-1', title: '【系统维护】本周日凌晨 2:00-4:00 数据库升级维护',
|
|
||||||
content: '计划于本周日(2024年1月21日)凌晨 2:00-4:00 进行数据库版本升级,期间以下服务将不可用:\n1. 数据变更工单提交和审批\n2. 在线 SQL 查询\n3. 数据导出功能\n\n请提前安排工作,避免在维护时段提交重要工单。如有紧急情况,请联系 DBA 团队。',
|
|
||||||
priority: 'urgent', pinned: true, enabled: true, author: 'admin',
|
|
||||||
publishTime: '2024-01-15 10:00:00', expireTime: '2024-01-22 00:00:00',
|
|
||||||
readCount: 156, targetRoles: ['admin', 'dba', 'developer', 'guest'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ann-2', title: '【安全提醒】禁止使用 SELECT * 查询生产数据库',
|
|
||||||
content: '为加强数据安全,即日起严禁使用 SELECT * 查询生产环境数据库。所有查询必须明确指定所需字段名。\n\n审核规则已更新(规则编号 R002),违规查询将被自动拦截并记录审计日志。',
|
|
||||||
priority: 'high', pinned: true, enabled: true, author: 'admin',
|
|
||||||
publishTime: '2024-01-14 09:00:00', expireTime: '2024-02-14 00:00:00',
|
|
||||||
readCount: 234, targetRoles: ['admin', 'dba', 'developer'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ann-3', title: '【新功能】SQL 变更自动回滚功能已上线',
|
|
||||||
content: 'SQL 变更工单现已支持自动回滚功能。对于 UPDATE 和 DELETE 操作,系统会自动:\n1. 基于 Binlog ROW 格式生成逆向回滚语句\n2. 执行前备份受影响数据\n3. 提供一键回滚入口(执行后 72 小时内有效)\n\n使用要求:\n- MySQL 必须开启 Binlog ROW 格式\n- 操作表必须有主键\n- 单次操作影响行数不超过 10,000 行',
|
|
||||||
priority: 'normal', pinned: false, enabled: true, author: 'zhangsan',
|
|
||||||
publishTime: '2024-01-13 14:00:00', expireTime: '2024-04-13 00:00:00',
|
|
||||||
readCount: 89, targetRoles: ['admin', 'dba', 'developer'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ann-4', title: '【培训通知】SQL 审核规范培训 - 1月25日',
|
|
||||||
content: '时间:2024年1月25日 14:00-16:00\n地点:会议室 A\n讲师:DBA 团队负责人\n\n培训内容:\n1. SQL 变更工单提交流程\n2. 审核规则详解\n3. 回滚操作最佳实践\n4. 常见问题排查\n\n请相关人员准时参加。',
|
|
||||||
priority: 'normal', pinned: false, enabled: true, author: 'lisi',
|
|
||||||
publishTime: '2024-01-12 10:00:00', expireTime: '2024-01-26 00:00:00',
|
|
||||||
readCount: 67, targetRoles: ['developer'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ann-5', title: '【版本更新】DataGuard v2.5.0 更新日志',
|
|
||||||
content: '本次更新内容:\n\n新增功能:\n- 支持 ClickHouse 数据源管理\n- 查询审计新增敏感字段自动脱敏\n- 工单批量审批功能\n\n优化改进:\n- SQL 编辑器性能优化\n- 审批流程可视化升级\n- 通知渠道新增 Telegram 支持\n\nBug 修复:\n- 修复大结果集导出内存溢出问题\n- 修复 Redis 集群连接超时问题',
|
|
||||||
priority: 'low', pinned: false, enabled: false, author: 'admin',
|
|
||||||
publishTime: '2024-01-10 16:00:00', expireTime: '2024-02-10 00:00:00',
|
|
||||||
readCount: 45, targetRoles: ['admin', 'dba', 'developer', 'guest'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AnnouncementPage() {
|
|
||||||
const [announcements, setAnnouncements] = useState<Announcement[]>(initialAnnouncements);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [editingAnn, setEditingAnn] = useState<Announcement | null>(null);
|
|
||||||
const [previewAnn, setPreviewAnn] = useState<Announcement | null>(null);
|
|
||||||
|
|
||||||
// 表单
|
|
||||||
const [formTitle, setFormTitle] = useState('');
|
|
||||||
const [formContent, setFormContent] = useState('');
|
|
||||||
const [formPriority, setFormPriority] = useState<AnnouncementPriority>('normal');
|
|
||||||
const [formPinned, setFormPinned] = useState(false);
|
|
||||||
const [formTargetRoles, setFormTargetRoles] = useState<string[]>(['admin', 'dba', 'developer', 'guest']);
|
|
||||||
const [formExpireTime, setFormExpireTime] = useState('');
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setFormTitle('');
|
|
||||||
setFormContent('');
|
|
||||||
setFormPriority('normal');
|
|
||||||
setFormPinned(false);
|
|
||||||
setFormTargetRoles(['admin', 'dba', 'developer', 'guest']);
|
|
||||||
setFormExpireTime('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
resetForm();
|
|
||||||
setEditingAnn(null);
|
|
||||||
setShowCreate(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (ann: Announcement) => {
|
|
||||||
setFormTitle(ann.title);
|
|
||||||
setFormContent(ann.content);
|
|
||||||
setFormPriority(ann.priority);
|
|
||||||
setFormPinned(ann.pinned);
|
|
||||||
setFormTargetRoles([...ann.targetRoles]);
|
|
||||||
setFormExpireTime(ann.expireTime);
|
|
||||||
setEditingAnn(ann);
|
|
||||||
setShowCreate(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleRole = (role: string) => {
|
|
||||||
setFormTargetRoles(prev =>
|
|
||||||
prev.includes(role) ? prev.filter(r => r !== role) : [...prev, role]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveAnnouncement = () => {
|
|
||||||
if (!formTitle.trim() || !formContent.trim()) return;
|
|
||||||
const now = '2024-01-15 12:00:00';
|
|
||||||
if (editingAnn) {
|
|
||||||
setAnnouncements(prev => prev.map(a => a.id === editingAnn.id ? {
|
|
||||||
...a,
|
|
||||||
title: formTitle,
|
|
||||||
content: formContent,
|
|
||||||
priority: formPriority,
|
|
||||||
pinned: formPinned,
|
|
||||||
targetRoles: [...formTargetRoles],
|
|
||||||
expireTime: formExpireTime || a.expireTime,
|
|
||||||
} : a));
|
|
||||||
} else {
|
|
||||||
const newAnn: Announcement = {
|
|
||||||
id: `ann-${Date.now()}`,
|
|
||||||
title: formTitle,
|
|
||||||
content: formContent,
|
|
||||||
priority: formPriority,
|
|
||||||
pinned: formPinned,
|
|
||||||
enabled: true,
|
|
||||||
author: 'admin',
|
|
||||||
publishTime: now,
|
|
||||||
expireTime: formExpireTime || '2024-12-31 00:00:00',
|
|
||||||
readCount: 0,
|
|
||||||
targetRoles: [...formTargetRoles],
|
|
||||||
};
|
|
||||||
setAnnouncements(prev => [newAnn, ...prev]);
|
|
||||||
}
|
|
||||||
setShowCreate(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePin = (id: string) => {
|
|
||||||
setAnnouncements(prev => prev.map(a => a.id === id ? { ...a, pinned: !a.pinned } : a));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleEnable = (id: string) => {
|
|
||||||
setAnnouncements(prev => prev.map(a => a.id === id ? { ...a, enabled: !a.enabled } : a));
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteAnnouncement = (id: string) => {
|
|
||||||
setAnnouncements(prev => prev.filter(a => a.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedAnnouncements = [...announcements].sort((a, b) => {
|
|
||||||
if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
|
|
||||||
return new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime();
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">公告管理</h1>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">发布和管理系统公告,支持按角色定向推送</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={openCreate} className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
|
||||||
<Plus className="w-4 h-4" /> 发布公告
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{[
|
|
||||||
{ label: '公告总数', value: announcements.length, icon: Megaphone, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
|
||||||
{ label: '已置顶', value: announcements.filter(a => a.pinned).length, icon: Pin, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
|
||||||
{ label: '生效中', value: announcements.filter(a => a.enabled).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
|
||||||
{ label: '已失效', value: announcements.filter(a => !a.enabled).length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
|
||||||
].map((s, i) => (
|
|
||||||
<Card key={i} className="dg-card">
|
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
|
||||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
|
|
||||||
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{sortedAnnouncements.map(ann => (
|
|
||||||
<Card key={ann.id} className={`dg-card ${!ann.enabled ? 'opacity-50' : ''} ${ann.pinned ? 'border-l-2 border-l-purple-500' : ''}`}>
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{ann.pinned && <Pin className="w-4 h-4 text-purple-400" />}
|
|
||||||
<h3 className="font-semibold text-sm">{ann.title}</h3>
|
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded-full border ${priorityColors[ann.priority]}`}>
|
|
||||||
{priorityLabels[ann.priority]}
|
|
||||||
</span>
|
|
||||||
{!ann.enabled && <span className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">已失效</span>}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--dg-text-secondary)] line-clamp-2 mb-3">{ann.content.replace(/\n/g, ' ')}</p>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-[var(--dg-text-muted)]">
|
|
||||||
<span className="flex items-center gap-1"><Send className="w-3 h-3" /> {ann.author}</span>
|
|
||||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {ann.publishTime}</span>
|
|
||||||
<span className="flex items-center gap-1"><Calendar className="w-3 h-3" /> 到期: {ann.expireTime}</span>
|
|
||||||
<span className="flex items-center gap-1"><Eye className="w-3 h-3" /> {ann.readCount} 次阅读</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span>可见范围:</span>
|
|
||||||
{ann.targetRoles.map(r => (
|
|
||||||
<span key={r} className="text-[10px] px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded">
|
|
||||||
{r === 'admin' ? '管理员' : r === 'dba' ? 'DBA' : r === 'developer' ? '开发' : '访客'}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 ml-4">
|
|
||||||
<button onClick={() => togglePin(ann.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-purple-500/15 hover:text-purple-400 transition-colors" title={ann.pinned ? '取消置顶' : '置顶'}>
|
|
||||||
{ann.pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setPreviewAnn(ann)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="预览">
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => openEdit(ann)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="编辑">
|
|
||||||
<Edit2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => toggleEnable(ann.id)} className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title={ann.enabled ? '失效' : '生效'}>
|
|
||||||
{ann.enabled ? <CheckCircle className="w-4 h-4 text-green-400" /> : <AlertTriangle className="w-4 h-4 text-yellow-400" />}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => deleteAnnouncement(ann.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors" title="删除">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 预览弹窗 */}
|
|
||||||
{previewAnn && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setPreviewAnn(null)}>
|
|
||||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4" onClick={e => e.stopPropagation()}>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="text-lg font-semibold">{previewAnn.title}</h3>
|
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded-full border ${priorityColors[previewAnn.priority]}`}>
|
|
||||||
{priorityLabels[previewAnn.priority]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button onClick={() => setPreviewAnn(null)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] flex items-center gap-3 mb-4 pb-3 border-b border-[var(--dg-border)]">
|
|
||||||
<span className="flex items-center gap-1"><Send className="w-3 h-3" /> {previewAnn.author}</span>
|
|
||||||
<span className="flex items-center gap-1"><Clock className="w-3 h-3" /> {previewAnn.publishTime}</span>
|
|
||||||
<span className="flex items-center gap-1"><Eye className="w-3 h-3" /> {previewAnn.readCount} 次阅读</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-[var(--dg-text-secondary)] whitespace-pre-line leading-relaxed max-h-96 overflow-y-auto">
|
|
||||||
{previewAnn.content}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end mt-4 pt-4 border-t border-[var(--dg-border)]">
|
|
||||||
<button onClick={() => setPreviewAnn(null)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">关闭</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 创建/编辑弹窗 */}
|
|
||||||
{showCreate && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)}>
|
|
||||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h3 className="text-xl font-semibold">{editingAnn ? '编辑公告' : '发布公告'}</h3>
|
|
||||||
<button onClick={() => setShowCreate(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">公告标题 <span className="text-red-400">*</span></label>
|
|
||||||
<input type="text" value={formTitle} onChange={e => setFormTitle(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" placeholder="输入公告标题" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">公告内容 <span className="text-red-400">*</span></label>
|
|
||||||
<textarea value={formContent} onChange={e => setFormContent(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 resize-none" rows={8} placeholder="输入公告内容,支持多行文本..." />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">优先级</label>
|
|
||||||
<select value={formPriority} onChange={e => setFormPriority(e.target.value as AnnouncementPriority)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500">
|
|
||||||
{Object.entries(priorityLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">到期时间</label>
|
|
||||||
<input type="datetime-local" value={formExpireTime} onChange={e => setFormExpireTime(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch checked={formPinned} onCheckedChange={setFormPinned} />
|
|
||||||
<span className="text-sm">置顶公告</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-2 block">可见角色</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{roleOptions.map(role => (
|
|
||||||
<button
|
|
||||||
key={role.id}
|
|
||||||
onClick={() => toggleRole(role.id)}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs border transition-colors ${
|
|
||||||
formTargetRoles.includes(role.id)
|
|
||||||
? 'bg-blue-500/15 text-blue-400 border-blue-500/30'
|
|
||||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border-[var(--dg-border)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{role.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
|
|
||||||
<button onClick={() => setShowCreate(false)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
|
||||||
<button onClick={saveAnnouncement} disabled={!formTitle.trim() || !formContent.trim()} className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
<Save className="w-4 h-4" /> {editingAnn ? '保存修改' : '发布公告'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Plus, Search, Edit2, Trash2, Shield, AlertTriangle, AlertCircle, Info, CheckCircle, ToggleLeft, ToggleRight, FileCode } from 'lucide-react';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { auditRules, dbTypeColorMap } from '@/lib/mock';
|
|
||||||
|
|
||||||
const levelConfig = {
|
|
||||||
error: { icon: AlertTriangle, color: 'text-red-400 bg-red-400/10', label: '错误' },
|
|
||||||
warning: { icon: AlertCircle, color: 'text-yellow-400 bg-yellow-400/10', label: '警告' },
|
|
||||||
info: { icon: Info, color: 'text-blue-400 bg-blue-400/10', label: '提示' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AuditRulesPage() {
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [levelFilter, setLevelFilter] = useState('');
|
|
||||||
const [rules, setRules] = useState(auditRules);
|
|
||||||
|
|
||||||
const filtered = rules.filter(r => {
|
|
||||||
if (levelFilter && r.level !== levelFilter) return false;
|
|
||||||
if (search && !r.name.includes(search) && !r.description.includes(search)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggleRule = (id: string) => {
|
|
||||||
setRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">SQL 审核规则</h1>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 SQL 审核规则,确保数据变更安全合规</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)] transition-all">
|
|
||||||
<FileCode className="w-4 h-4" /> 导入规则
|
|
||||||
</button>
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
|
||||||
<Plus className="w-4 h-4" /> 添加规则
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{[
|
|
||||||
{ label: '规则总数', value: rules.length, icon: Shield, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
|
||||||
{ label: '已启用', value: rules.filter(r => r.enabled).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
|
||||||
{ label: '错误级', value: rules.filter(r => r.level === 'error').length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
|
||||||
{ label: '警告级', value: rules.filter(r => r.level === 'warning').length, icon: AlertCircle, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
|
|
||||||
].map((s, i) => (
|
|
||||||
<Card key={i} className="dg-card">
|
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
|
||||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
|
|
||||||
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
|
||||||
<div className="relative flex-1 max-w-xs">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
|
||||||
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none" placeholder="搜索规则名称" value={search} onChange={e => setSearch(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={levelFilter} onChange={e => setLevelFilter(e.target.value)}>
|
|
||||||
<option value="">全部级别</option>
|
|
||||||
<option value="error">错误</option>
|
|
||||||
<option value="warning">警告</option>
|
|
||||||
<option value="info">提示</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">规则ID</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">规则名称</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">级别</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">适用数据库</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">描述</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filtered.map(rule => {
|
|
||||||
const lc = levelConfig[rule.level];
|
|
||||||
const LIcon = lc.icon;
|
|
||||||
return (
|
|
||||||
<tr key={rule.id} className={`border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors ${!rule.enabled ? 'opacity-50' : ''}`}>
|
|
||||||
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{rule.id}</td>
|
|
||||||
<td className="px-5 py-3.5 font-medium">{rule.name}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${lc.color}`}>
|
|
||||||
<LIcon className="w-3.5 h-3.5" /> {lc.label}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{rule.dbTypes.map(dt => (
|
|
||||||
<span key={dt} className={`inline-flex px-2 py-0.5 rounded text-[11px] font-medium ${dbTypeColorMap[dt] || ''}`}>{dt.toUpperCase()}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)] max-w-xs truncate">{rule.description}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<button onClick={() => toggleRule(rule.id)} className="transition-colors">
|
|
||||||
{rule.enabled ? (
|
|
||||||
<ToggleRight className="w-8 h-8 text-green-400" />
|
|
||||||
) : (
|
|
||||||
<ToggleLeft className="w-8 h-8 text-[var(--dg-text-muted)]" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="text-blue-400 hover:text-blue-300"><Edit2 className="w-4 h-4" /></button>
|
|
||||||
<button className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4" /></button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Clock, Plus, Play, Pause, Trash2, AlertTriangle,
|
|
||||||
Calendar, Zap, Database, ChevronDown, ChevronUp, X, Save, Eye
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
|
|
||||||
type TaskFrequency = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly';
|
|
||||||
type TaskStatus = 'running' | 'paused' | 'completed' | 'failed';
|
|
||||||
|
|
||||||
type AutoTask = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
frequency: TaskFrequency;
|
|
||||||
cron: string;
|
|
||||||
nextRun: string;
|
|
||||||
lastRun: string;
|
|
||||||
lastStatus: 'success' | 'failed' | 'none';
|
|
||||||
dbType: string;
|
|
||||||
instance: string;
|
|
||||||
sql: string;
|
|
||||||
status: TaskStatus;
|
|
||||||
notifyOnFail: boolean;
|
|
||||||
createdBy: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const frequencyLabels: Record<TaskFrequency, string> = {
|
|
||||||
once: '一次性',
|
|
||||||
hourly: '每小时',
|
|
||||||
daily: '每天',
|
|
||||||
weekly: '每周',
|
|
||||||
monthly: '每月',
|
|
||||||
};
|
|
||||||
|
|
||||||
const initialTasks: AutoTask[] = [
|
|
||||||
{ id: 'task-1', name: '每日数据归档', description: '将 30 天前的日志数据归档到历史表', enabled: true, frequency: 'daily', cron: '0 2 * * *', nextRun: '2024-01-16 02:00', lastRun: '2024-01-15 02:00', lastStatus: 'success', dbType: 'mysql', instance: '生产主库', sql: 'INSERT INTO logs_archive SELECT * FROM system_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY);\nDELETE FROM system_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY);', status: 'running', notifyOnFail: true, createdBy: 'admin' },
|
|
||||||
{ id: 'task-2', name: '每周统计报表', description: '生成周活跃用户统计报表', enabled: true, frequency: 'weekly', cron: '0 6 * * 1', nextRun: '2024-01-22 06:00', lastRun: '2024-01-08 06:00', lastStatus: 'success', dbType: 'mysql', instance: '生产主库', sql: 'INSERT INTO report_weekly_active (week_start, week_end, active_users, new_users)\nSELECT \n DATE_SUB(NOW(), INTERVAL 7 DAY) as week_start,\n NOW() as week_end,\n COUNT(DISTINCT user_id) as active_users,\n COUNT(DISTINCT CASE WHEN created_at > DATE_SUB(NOW(), INTERVAL 7 DAY) THEN user_id END) as new_users\nFROM user_activity\nWHERE created_at > DATE_SUB(NOW(), INTERVAL 7 DAY);', status: 'running', notifyOnFail: true, createdBy: 'admin' },
|
|
||||||
{ id: 'task-3', name: '索引健康检查', description: '检查慢查询并建议优化索引', enabled: true, frequency: 'daily', cron: '0 3 * * *', nextRun: '2024-01-16 03:00', lastRun: '2024-01-15 03:00', lastStatus: 'failed', dbType: 'mysql', instance: '生产主库', sql: 'SELECT \n table_schema,\n table_name,\n index_name,\n cardinality,\n table_rows,\n ROUND(cardinality / table_rows * 100, 2) as selectivity\nFROM information_schema.statistics\nWHERE table_schema NOT IN ("information_schema", "mysql", "performance_schema")\n AND index_name != "PRIMARY"\n AND cardinality / table_rows < 0.1\nORDER BY table_rows DESC\nLIMIT 100;', status: 'paused', notifyOnFail: true, createdBy: 'zhangsan' },
|
|
||||||
{ id: 'task-4', name: 'Redis 键过期清理', description: '清理 Redis 中已过期的会话键', enabled: false, frequency: 'hourly', cron: '0 * * * *', nextRun: '-', lastRun: '2024-01-14 12:00', lastStatus: 'success', dbType: 'redis', instance: 'Redis集群-01', sql: 'EVAL "local keys = redis.call(\'keys\', \'session:*\')\nfor i=1,#keys,5000 do\n redis.call(\'del\', unpack(keys, i, math.min(i+4999, #keys)))\nend\nreturn #keys" 0', status: 'paused', notifyOnFail: false, createdBy: 'lisi' },
|
|
||||||
{ id: 'task-5', name: '月度数据备份', description: '每月全量备份核心数据表', enabled: true, frequency: 'monthly', cron: '0 1 1 * *', nextRun: '2024-02-01 01:00', lastRun: '2024-01-01 01:00', lastStatus: 'success', dbType: 'mysql', instance: '生产主库', sql: '-- 调用备份脚本\nBACKUP DATABASE user_db, order_db TO S3://backups/monthly/;', status: 'running', notifyOnFail: true, createdBy: 'admin' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AutoTaskPage() {
|
|
||||||
const [tasks, setTasks] = useState<AutoTask[]>(initialTasks);
|
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
||||||
const [previewTask, setPreviewTask] = useState<AutoTask | null>(null);
|
|
||||||
|
|
||||||
// 表单状态
|
|
||||||
const [formName, setFormName] = useState('');
|
|
||||||
const [formDesc, setFormDesc] = useState('');
|
|
||||||
const [formFreq, setFormFreq] = useState<TaskFrequency>('daily');
|
|
||||||
const [formCron, setFormCron] = useState('0 2 * * *');
|
|
||||||
const [formDbType, setFormDbType] = useState('mysql');
|
|
||||||
const [formInstance, setFormInstance] = useState('生产主库');
|
|
||||||
const [formSql, setFormSql] = useState('');
|
|
||||||
const [formNotify, setFormNotify] = useState(true);
|
|
||||||
|
|
||||||
const toggleTask = (id: string) => {
|
|
||||||
setTasks(prev => prev.map(t => t.id === id ? { ...t, enabled: !t.enabled, status: !t.enabled ? 'running' : 'paused' } : t));
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteTask = (id: string) => {
|
|
||||||
setTasks(prev => prev.filter(t => t.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
setFormName('');
|
|
||||||
setFormDesc('');
|
|
||||||
setFormFreq('daily');
|
|
||||||
setFormCron('0 2 * * *');
|
|
||||||
setFormDbType('mysql');
|
|
||||||
setFormInstance('生产主库');
|
|
||||||
setFormSql('');
|
|
||||||
setFormNotify(true);
|
|
||||||
setShowCreate(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveTask = () => {
|
|
||||||
if (!formName.trim() || !formSql.trim()) return;
|
|
||||||
const newTask: AutoTask = {
|
|
||||||
id: `task-${Date.now()}`,
|
|
||||||
name: formName,
|
|
||||||
description: formDesc,
|
|
||||||
enabled: true,
|
|
||||||
frequency: formFreq,
|
|
||||||
cron: formCron,
|
|
||||||
nextRun: '即将计算...',
|
|
||||||
lastRun: '-',
|
|
||||||
lastStatus: 'none',
|
|
||||||
dbType: formDbType,
|
|
||||||
instance: formInstance,
|
|
||||||
sql: formSql,
|
|
||||||
status: 'running',
|
|
||||||
notifyOnFail: formNotify,
|
|
||||||
createdBy: 'admin',
|
|
||||||
};
|
|
||||||
setTasks(prev => [...prev, newTask]);
|
|
||||||
setShowCreate(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
|
||||||
running: 'bg-green-500/15 text-green-400',
|
|
||||||
paused: 'bg-yellow-500/15 text-yellow-400',
|
|
||||||
completed: 'bg-blue-500/15 text-blue-400',
|
|
||||||
failed: 'bg-red-500/15 text-red-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">自动任务</h1>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置定时执行的 SQL 任务,支持 Cron 表达式</p>
|
|
||||||
</div>
|
|
||||||
<button onClick={openCreate} className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
|
||||||
<Plus className="w-4 h-4" /> 新建任务
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{[
|
|
||||||
{ label: '任务总数', value: tasks.length, icon: Clock, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
|
||||||
{ label: '运行中', value: tasks.filter(t => t.status === 'running').length, icon: Play, color: 'text-green-400', bg: 'bg-green-400/10' },
|
|
||||||
{ label: '已暂停', value: tasks.filter(t => t.status === 'paused').length, icon: Pause, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
|
|
||||||
{ label: '上次失败', value: tasks.filter(t => t.lastStatus === 'failed').length, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
|
||||||
].map((s, i) => (
|
|
||||||
<Card key={i} className="dg-card">
|
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
|
||||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
|
|
||||||
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{tasks.map(task => {
|
|
||||||
const isExpanded = expandedId === task.id;
|
|
||||||
return (
|
|
||||||
<Card key={task.id} className={`dg-card ${!task.enabled ? 'opacity-60' : ''}`}>
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<h3 className="font-semibold">{task.name}</h3>
|
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded-full ${statusColors[task.status]}`}>
|
|
||||||
{task.status === 'running' ? '运行中' : task.status === 'paused' ? '已暂停' : task.status === 'failed' ? '失败' : '完成'}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded-full bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">
|
|
||||||
{frequencyLabels[task.frequency]}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] font-mono bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] px-2 py-0.5 rounded">
|
|
||||||
{task.cron}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mb-3">{task.description}</p>
|
|
||||||
<div className="flex items-center gap-4 text-xs text-[var(--dg-text-muted)]">
|
|
||||||
<span className="flex items-center gap-1"><Database className="w-3.5 h-3.5" /> {task.instance} ({task.dbType.toUpperCase()})</span>
|
|
||||||
<span className="flex items-center gap-1"><Calendar className="w-3.5 h-3.5" /> 下次执行: {task.nextRun}</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock className="w-3.5 h-3.5" /> 上次执行:
|
|
||||||
<span className={task.lastStatus === 'success' ? 'text-green-400' : task.lastStatus === 'failed' ? 'text-red-400' : ''}>{task.lastRun}</span>
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1"><Zap className="w-3.5 h-3.5" /> 创建者: {task.createdBy}</span>
|
|
||||||
</div>
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="mt-4 p-4 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">SQL 语句</div>
|
|
||||||
<pre className="text-sm text-[var(--dg-text-secondary)] font-mono whitespace-pre-wrap overflow-x-auto">{task.sql}</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1 ml-4">
|
|
||||||
<button onClick={() => setPreviewTask(task)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="预览">
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setExpandedId(isExpanded ? null : task.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="展开">
|
|
||||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => toggleTask(task.id)} className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title={task.enabled ? '暂停' : '启动'}>
|
|
||||||
{task.enabled ? <Pause className="w-4 h-4 text-yellow-400" /> : <Play className="w-4 h-4 text-green-400" />}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => deleteTask(task.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors" title="删除">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 预览弹窗 */}
|
|
||||||
{previewTask && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setPreviewTask(null)}>
|
|
||||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4" onClick={e => e.stopPropagation()}>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-xl font-semibold">{previewTask.name}</h3>
|
|
||||||
<button onClick={() => setPreviewTask(null)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3 text-sm">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div><span className="text-[var(--dg-text-muted)]">执行频率:</span> {frequencyLabels[previewTask.frequency]} ({previewTask.cron})</div>
|
|
||||||
<div><span className="text-[var(--dg-text-muted)]">数据库:</span> {previewTask.instance} ({previewTask.dbType.toUpperCase()})</div>
|
|
||||||
<div><span className="text-[var(--dg-text-muted)]">下次执行:</span> {previewTask.nextRun}</div>
|
|
||||||
<div><span className="text-[var(--dg-text-muted)]">上次状态:</span> <span className={previewTask.lastStatus === 'success' ? 'text-green-400' : previewTask.lastStatus === 'failed' ? 'text-red-400' : ''}>{previewTask.lastStatus === 'success' ? '成功' : previewTask.lastStatus === 'failed' ? '失败' : '未执行'}</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">SQL 语句</div>
|
|
||||||
<pre className="text-sm font-mono whitespace-pre-wrap overflow-x-auto">{previewTask.sql}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end mt-4">
|
|
||||||
<button onClick={() => setPreviewTask(null)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">关闭</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 创建弹窗 */}
|
|
||||||
{showCreate && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)}>
|
|
||||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h3 className="text-xl font-semibold">新建自动任务</h3>
|
|
||||||
<button onClick={() => setShowCreate(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">任务名称 <span className="text-red-400">*</span></label>
|
|
||||||
<input type="text" value={formName} onChange={e => setFormName(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" placeholder="例如:每日数据归档" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">任务描述</label>
|
|
||||||
<textarea value={formDesc} onChange={e => setFormDesc(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 resize-none" rows={2} placeholder="描述任务用途" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">执行频率</label>
|
|
||||||
<select value={formFreq} onChange={e => setFormFreq(e.target.value as TaskFrequency)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500">
|
|
||||||
{Object.entries(frequencyLabels).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">Cron 表达式</label>
|
|
||||||
<input type="text" value={formCron} onChange={e => setFormCron(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono focus:outline-none focus:border-blue-500" placeholder="0 2 * * *" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">数据库类型</label>
|
|
||||||
<select value={formDbType} onChange={e => setFormDbType(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500">
|
|
||||||
<option value="mysql">MySQL</option>
|
|
||||||
<option value="postgres">PostgreSQL</option>
|
|
||||||
<option value="redis">Redis</option>
|
|
||||||
<option value="mongo">MongoDB</option>
|
|
||||||
<option value="clickhouse">ClickHouse</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">目标实例</label>
|
|
||||||
<input type="text" value={formInstance} onChange={e => setFormInstance(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500" placeholder="生产主库" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">SQL 语句 <span className="text-red-400">*</span></label>
|
|
||||||
<textarea value={formSql} onChange={e => setFormSql(e.target.value)} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm font-mono focus:outline-none focus:border-blue-500 resize-none" rows={6} placeholder="输入要执行的 SQL 语句..." />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Switch checked={formNotify} onCheckedChange={setFormNotify} />
|
|
||||||
<span className="text-sm">执行失败时发送通知</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
|
|
||||||
<button onClick={() => setShowCreate(false)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
|
||||||
<button onClick={saveTask} disabled={!formName.trim() || !formSql.trim()} className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed">
|
|
||||||
<Save className="w-4 h-4" /> 创建任务
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,700 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Send, MessageSquare, Mail, Navigation, Bell,
|
|
||||||
Shield, Search, ClipboardList, Database, AlertTriangle,
|
|
||||||
Save, CheckCircle, Info, Server, UserSearch, Lock, FileText
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Slider } from '@/components/ui/slider';
|
|
||||||
|
|
||||||
type NotificationChannel = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
icon: React.ElementType;
|
|
||||||
enabled: boolean;
|
|
||||||
webhook: string;
|
|
||||||
events: string[];
|
|
||||||
testStatus: 'none' | 'success' | 'error';
|
|
||||||
};
|
|
||||||
|
|
||||||
type DDLPattern = {
|
|
||||||
id: string;
|
|
||||||
pattern: string;
|
|
||||||
description: string;
|
|
||||||
action: 'block' | 'warn' | 'require_approval';
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SubmitRule = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
config: Record<string, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ConfigPlatformPage() {
|
|
||||||
const [activeTab, setActiveTab] = useState('notification');
|
|
||||||
const [savedTab, setSavedTab] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// 消息推送配置
|
|
||||||
const [notifChannels, setNotifChannels] = useState<NotificationChannel[]>([
|
|
||||||
{ id: 'feishu', name: '飞书', icon: Send, enabled: true, webhook: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx', events: ['工单创建', '审批通过', '审批拒绝', '执行完成', '执行失败'], testStatus: 'none' },
|
|
||||||
{ id: 'dingtalk', name: '钉钉', icon: MessageSquare, enabled: true, webhook: 'https://oapi.dingtalk.com/robot/send?access_token=xxx', events: ['工单创建', '审批通过', '执行失败'], testStatus: 'none' },
|
|
||||||
{ id: 'wechat', name: '企业微信', icon: Bell, enabled: false, webhook: '', events: ['工单创建', '执行完成'], testStatus: 'none' },
|
|
||||||
{ id: 'telegram', name: 'Telegram', icon: Navigation, enabled: false, webhook: '', events: ['执行失败'], testStatus: 'none' },
|
|
||||||
{ id: 'email', name: '邮件通知', icon: Mail, enabled: true, webhook: '', events: ['工单创建', '审批通过', '审批拒绝', '执行完成'], testStatus: 'none' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// LDAP 配置
|
|
||||||
const [ldapConfig, setLdapConfig] = useState({
|
|
||||||
enabled: false,
|
|
||||||
server: 'ldap://ldap.company.com:389',
|
|
||||||
bindDN: 'cn=admin,dc=company,dc=com',
|
|
||||||
bindPassword: '',
|
|
||||||
baseDN: 'ou=users,dc=company,dc=com',
|
|
||||||
userFilter: '(objectClass=person)',
|
|
||||||
uidField: 'uid',
|
|
||||||
emailField: 'mail',
|
|
||||||
nameField: 'cn',
|
|
||||||
groupField: 'memberOf',
|
|
||||||
autoSync: true,
|
|
||||||
syncInterval: 30,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 查询限制配置
|
|
||||||
const [queryConfig, setQueryConfig] = useState({
|
|
||||||
maxLimit: 10000,
|
|
||||||
enableAudit: true,
|
|
||||||
requireApproval: false,
|
|
||||||
allowExport: true,
|
|
||||||
maxExportRows: 50000,
|
|
||||||
timeoutSeconds: 30,
|
|
||||||
allowWriteOperation: false,
|
|
||||||
allowedDataSources: ['mysql', 'postgres', 'clickhouse', 'redis'],
|
|
||||||
forbiddenKeywords: ['DROP', 'TRUNCATE', 'DELETE FROM', 'UPDATE.*SET'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 工单提交规则
|
|
||||||
const [submitRules, setSubmitRules] = useState<SubmitRule[]>([
|
|
||||||
{ id: 'r1', name: 'SQL 语法检查', description: '提交前自动进行 SQL 语法检查,语法错误不允许提交', enabled: true, config: {} },
|
|
||||||
{ id: 'r2', name: '影响行数限制', description: '单次操作影响行数不得超过配置值', enabled: true, config: { maxRows: 10000 } },
|
|
||||||
{ id: 'r3', name: '工单标题必填', description: '工单标题不能为空,且长度至少 5 个字符', enabled: true, config: { minLength: 5 } },
|
|
||||||
{ id: 'r4', name: '变更原因必填', description: '必须填写变更原因/业务背景说明', enabled: true, config: { minLength: 10 } },
|
|
||||||
{ id: 'r5', name: '回滚 SQL 必填', description: 'UPDATE/DELETE 操作必须提供回滚 SQL', enabled: true, config: {} },
|
|
||||||
{ id: 'r6', name: '禁止提交时间限制', description: '配置禁止提交工单的时间段(如生产高峰期)', enabled: false, config: { forbiddenHours: ['09:00-10:00', '14:00-15:00', '20:00-09:00'] } },
|
|
||||||
{ id: 'r7', name: '同一表并发限制', description: '同一数据表同时只能有一个进行中的变更工单', enabled: true, config: {} },
|
|
||||||
{ id: 'r8', name: '敏感操作二次确认', description: '影响行数超过 1000 行的操作需要二次确认', enabled: true, config: { threshold: 1000 } },
|
|
||||||
]);
|
|
||||||
|
|
||||||
// DDL 拦截规则
|
|
||||||
const [ddlPatterns, setDdlPatterns] = useState<DDLPattern[]>([
|
|
||||||
{ id: 'd1', pattern: 'DROP TABLE', description: '禁止删除表操作', action: 'block', enabled: true },
|
|
||||||
{ id: 'd2', pattern: 'DROP DATABASE', description: '禁止删除数据库操作', action: 'block', enabled: true },
|
|
||||||
{ id: 'd3', pattern: 'TRUNCATE TABLE', description: '禁止清空表操作', action: 'block', enabled: true },
|
|
||||||
{ id: 'd4', pattern: 'ALTER TABLE.*DROP COLUMN', description: '删除字段需要审批', action: 'require_approval', enabled: true },
|
|
||||||
{ id: 'd5', pattern: 'ALTER TABLE.*MODIFY', description: '修改字段类型需要审批', action: 'require_approval', enabled: true },
|
|
||||||
{ id: 'd6', pattern: 'ALTER TABLE.*CHANGE', description: '重命名字段需要审批', action: 'require_approval', enabled: true },
|
|
||||||
{ id: 'd7', pattern: 'RENAME TABLE', description: '重命名表需要审批', action: 'require_approval', enabled: true },
|
|
||||||
{ id: 'd8', pattern: 'CREATE TABLE', description: '创建表警告提示', action: 'warn', enabled: true },
|
|
||||||
{ id: 'd9', pattern: 'ALTER TABLE.*ADD COLUMN', description: '添加字段警告提示', action: 'warn', enabled: true },
|
|
||||||
{ id: 'd10', pattern: 'CREATE INDEX', description: '创建索引警告提示', action: 'warn', enabled: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleSave = (tabId: string) => {
|
|
||||||
setSavedTab(tabId);
|
|
||||||
setTimeout(() => setSavedTab(null), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleNotifChannel = (id: string) => {
|
|
||||||
setNotifChannels(prev => prev.map(c => c.id === id ? { ...c, enabled: !c.enabled } : c));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleSubmitRule = (id: string) => {
|
|
||||||
setSubmitRules(prev => prev.map(r => r.id === id ? { ...r, enabled: !r.enabled } : r));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDDLPattern = (id: string) => {
|
|
||||||
setDdlPatterns(prev => prev.map(p => p.id === id ? { ...p, enabled: !p.enabled } : p));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateNotifWebhook = (id: string, value: string) => {
|
|
||||||
setNotifChannels(prev => prev.map(c => c.id === id ? { ...c, webhook: value } : c));
|
|
||||||
};
|
|
||||||
|
|
||||||
const actionLabels: Record<string, string> = {
|
|
||||||
block: '拦截',
|
|
||||||
warn: '警告',
|
|
||||||
require_approval: '需审批',
|
|
||||||
};
|
|
||||||
|
|
||||||
const actionColors: Record<string, string> = {
|
|
||||||
block: 'bg-red-500/15 text-red-400',
|
|
||||||
warn: 'bg-yellow-500/15 text-yellow-400',
|
|
||||||
require_approval: 'bg-blue-500/15 text-blue-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
const testWebhook = (id: string) => {
|
|
||||||
setNotifChannels(prev => prev.map(c => {
|
|
||||||
if (c.id === id) {
|
|
||||||
return { ...c, testStatus: Math.random() > 0.3 ? 'success' : 'error' };
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
}));
|
|
||||||
setTimeout(() => {
|
|
||||||
setNotifChannels(prev => prev.map(c => c.id === id ? { ...c, testStatus: 'none' } : c));
|
|
||||||
}, 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* 页面标题 */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">配置平台</h1>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">全局系统配置,影响所有用户的默认行为和功能开关</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
|
||||||
<TabsList className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] p-1">
|
|
||||||
<TabsTrigger value="notification" className="data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-400">
|
|
||||||
<Bell className="w-4 h-4 mr-2" /> 消息推送
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="ldap" className="data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-400">
|
|
||||||
<Server className="w-4 h-4 mr-2" /> LDAP 设置
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="query" className="data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-400">
|
|
||||||
<Search className="w-4 h-4 mr-2" /> 查询限制
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="ticket" className="data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-400">
|
|
||||||
<ClipboardList className="w-4 h-4 mr-2" /> 工单规则
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="ddl" className="data-[state=active]:bg-blue-500/20 data-[state=active]:text-blue-400">
|
|
||||||
<Database className="w-4 h-4 mr-2" /> DDL 拦截
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
{/* 消息推送 */}
|
|
||||||
<TabsContent value="notification" className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">消息推送配置</h2>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)]">配置各渠道的通知开关和 Webhook 地址</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSave('notification')}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
{savedTab === 'notification' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
|
||||||
{savedTab === 'notification' ? '已保存' : '保存配置'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4">
|
|
||||||
{notifChannels.map(channel => {
|
|
||||||
const Icon = channel.icon;
|
|
||||||
return (
|
|
||||||
<Card key={channel.id} className="dg-card">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-start gap-4">
|
|
||||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${channel.enabled ? 'bg-blue-500/15' : 'bg-[var(--dg-bg-tertiary)]'}`}>
|
|
||||||
<Icon className={`w-5 h-5 ${channel.enabled ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h3 className="font-semibold">{channel.name}</h3>
|
|
||||||
<Switch checked={channel.enabled} onCheckedChange={() => toggleNotifChannel(channel.id)} />
|
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${channel.enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
|
||||||
{channel.enabled ? '已启用' : '已禁用'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => testWebhook(channel.id)}
|
|
||||||
disabled={!channel.enabled || channel.testStatus !== 'none'}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
|
||||||
!channel.enabled ? 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] cursor-not-allowed' :
|
|
||||||
channel.testStatus === 'success' ? 'bg-green-500/15 text-green-400' :
|
|
||||||
channel.testStatus === 'error' ? 'bg-red-500/15 text-red-400' :
|
|
||||||
'bg-blue-500/15 text-blue-400 hover:bg-blue-500/25'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{channel.testStatus === 'success' ? '连接成功' : channel.testStatus === 'error' ? '连接失败' : '测试连接'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{channel.enabled && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{channel.id !== 'email' && (
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1 block">Webhook 地址</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={channel.webhook}
|
|
||||||
onChange={(e) => updateNotifWebhook(channel.id, e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:outline-none focus:border-blue-500 transition-colors"
|
|
||||||
placeholder={`请输入 ${channel.name} Webhook 地址`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{channel.id === 'email' && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-secondary)]">
|
|
||||||
<Info className="w-4 h-4 text-blue-400" />
|
|
||||||
<span>邮件通知通过系统 SMTP 配置发送,请在服务器环境变量中配置 SMTP 参数</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-2 block">通知事件</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{['工单创建', '审批通过', '审批拒绝', '执行完成', '执行失败'].map(event => (
|
|
||||||
<span
|
|
||||||
key={event}
|
|
||||||
className={`px-2.5 py-1 rounded text-xs ${
|
|
||||||
channel.events.includes(event)
|
|
||||||
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
|
|
||||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border border-[var(--dg-border)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{event}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* LDAP 设置 */}
|
|
||||||
<TabsContent value="ldap" className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">LDAP 认证设置</h2>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)]">配置 LDAP/AD 域账号登录集成</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSave('ldap')}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
{savedTab === 'ldap' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
|
||||||
{savedTab === 'ldap' ? '已保存' : '保存配置'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-center justify-between mb-6 pb-4 border-b border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-purple-500/15 flex items-center justify-center">
|
|
||||||
<UserSearch className="w-5 h-5 text-purple-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold">启用 LDAP 认证</h3>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)]">开启后用户可使用域账号登录系统</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={ldapConfig.enabled}
|
|
||||||
onCheckedChange={(v) => setLdapConfig(prev => ({ ...prev, enabled: v }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{ldapConfig.enabled && (
|
|
||||||
<div className="grid grid-cols-2 gap-5">
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">LDAP 服务器地址</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={ldapConfig.server}
|
|
||||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, server: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
placeholder="ldap://ldap.company.com:389"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">Bind DN</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={ldapConfig.bindDN}
|
|
||||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, bindDN: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
placeholder="cn=admin,dc=company,dc=com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">Bind 密码</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={ldapConfig.bindPassword}
|
|
||||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, bindPassword: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">Base DN</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={ldapConfig.baseDN}
|
|
||||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, baseDN: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
placeholder="ou=users,dc=company,dc=com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">用户过滤条件</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={ldapConfig.userFilter}
|
|
||||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, userFilter: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
placeholder="(objectClass=person)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">UID 字段</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={ldapConfig.uidField}
|
|
||||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, uidField: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">邮箱字段</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={ldapConfig.emailField}
|
|
||||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, emailField: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">姓名字段</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={ldapConfig.nameField}
|
|
||||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, nameField: e.target.value }))}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 flex items-center gap-4 pt-2 border-t border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
checked={ldapConfig.autoSync}
|
|
||||||
onCheckedChange={(v) => setLdapConfig(prev => ({ ...prev, autoSync: v }))}
|
|
||||||
/>
|
|
||||||
<span className="text-sm">自动同步用户</span>
|
|
||||||
</div>
|
|
||||||
{ldapConfig.autoSync && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-[var(--dg-text-muted)]">同步间隔</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={ldapConfig.syncInterval}
|
|
||||||
onChange={(e) => setLdapConfig(prev => ({ ...prev, syncInterval: parseInt(e.target.value) || 30 }))}
|
|
||||||
className="w-20 px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-sm text-center focus:outline-none focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
<span className="text-sm text-[var(--dg-text-muted)]">分钟</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* 查询限制 */}
|
|
||||||
<TabsContent value="query" className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">查询限制配置</h2>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)]">控制查询行为、限制和安全策略</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSave('query')}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
{savedTab === 'query' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
|
||||||
{savedTab === 'query' ? '已保存' : '保存配置'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5 space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-blue-500/15 flex items-center justify-center">
|
|
||||||
<Search className="w-5 h-5 text-blue-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold">查询审核审批</h3>
|
|
||||||
<p className="text-xs text-[var(--dg-text-secondary)]">开启后查询需要审批才能执行</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={queryConfig.requireApproval}
|
|
||||||
onCheckedChange={(v) => setQueryConfig(prev => ({ ...prev, requireApproval: v }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!queryConfig.requireApproval && (
|
|
||||||
<div className="flex items-start gap-2 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
|
||||||
<Info className="w-4 h-4 text-yellow-400 flex-shrink-0 mt-0.5" />
|
|
||||||
<p className="text-xs text-yellow-400">不开启查询审核审批时,用户可以查询已授权的数据库资源,但不会被记录审计日志</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="pt-3 border-t border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<label className="text-sm font-medium">最大查询 Limit 限制</label>
|
|
||||||
<span className="text-lg font-bold text-blue-400">{queryConfig.maxLimit.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[queryConfig.maxLimit]}
|
|
||||||
onValueChange={(v) => setQueryConfig(prev => ({ ...prev, maxLimit: v[0] }))}
|
|
||||||
min={100}
|
|
||||||
max={100000}
|
|
||||||
step={100}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
<div className="flex justify-between text-xs text-[var(--dg-text-muted)] mt-1">
|
|
||||||
<span>100</span>
|
|
||||||
<span>100,000</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-3 border-t border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<label className="text-sm font-medium">查询超时时间</label>
|
|
||||||
<span className="text-lg font-bold text-blue-400">{queryConfig.timeoutSeconds} 秒</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[queryConfig.timeoutSeconds]}
|
|
||||||
onValueChange={(v) => setQueryConfig(prev => ({ ...prev, timeoutSeconds: v[0] }))}
|
|
||||||
min={5}
|
|
||||||
max={300}
|
|
||||||
step={5}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-green-500/15 flex items-center justify-center">
|
|
||||||
<FileText className="w-5 h-5 text-green-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold">结果导出</h3>
|
|
||||||
<p className="text-xs text-[var(--dg-text-secondary)]">允许导出查询结果</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={queryConfig.allowExport}
|
|
||||||
onCheckedChange={(v) => setQueryConfig(prev => ({ ...prev, allowExport: v }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{queryConfig.allowExport && (
|
|
||||||
<div className="pt-3 border-t border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<label className="text-sm">最大导出行数</label>
|
|
||||||
<span className="text-sm font-bold text-green-400">{queryConfig.maxExportRows.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
<Slider
|
|
||||||
value={[queryConfig.maxExportRows]}
|
|
||||||
onValueChange={(v) => setQueryConfig(prev => ({ ...prev, maxExportRows: v[0] }))}
|
|
||||||
min={1000}
|
|
||||||
max={200000}
|
|
||||||
step={1000}
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5 space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-red-500/15 flex items-center justify-center">
|
|
||||||
<AlertTriangle className="w-5 h-5 text-red-400" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="font-semibold">写入操作</h3>
|
|
||||||
<p className="text-xs text-[var(--dg-text-secondary)]">允许执行 INSERT/UPDATE/DELETE</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={queryConfig.allowWriteOperation}
|
|
||||||
onCheckedChange={(v) => setQueryConfig(prev => ({ ...prev, allowWriteOperation: v }))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!queryConfig.allowWriteOperation && (
|
|
||||||
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
|
||||||
<Lock className="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" />
|
|
||||||
<p className="text-xs text-red-400">已禁止写入操作,查询模式为只读</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<h3 className="font-semibold mb-3">禁止的关键词</h3>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{queryConfig.forbiddenKeywords.map((kw, i) => (
|
|
||||||
<span key={i} className="px-2.5 py-1 bg-red-500/10 text-red-400 text-xs rounded-lg border border-red-500/20">
|
|
||||||
{kw}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* 工单规则 */}
|
|
||||||
<TabsContent value="ticket" className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">工单提交规则</h2>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)]">配置工单提交时的校验规则和约束条件</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSave('ticket')}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
{savedTab === 'ticket' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
|
||||||
{savedTab === 'ticket' ? '已保存' : '保存配置'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{submitRules.map(rule => (
|
|
||||||
<Card key={rule.id} className={`dg-card ${!rule.enabled ? 'opacity-60' : ''}`}>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${rule.enabled ? 'bg-blue-500/15' : 'bg-[var(--dg-bg-tertiary)]'}`}>
|
|
||||||
<Shield className={`w-4 h-4 ${rule.enabled ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h3 className="font-semibold text-sm">{rule.name}</h3>
|
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded-full ${rule.enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
|
||||||
{rule.enabled ? '已启用' : '已禁用'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--dg-text-secondary)] mt-0.5">{rule.description}</p>
|
|
||||||
{rule.config.maxRows && (
|
|
||||||
<p className="text-xs text-blue-400 mt-1">最大限制: {rule.config.maxRows.toLocaleString()} 行</p>
|
|
||||||
)}
|
|
||||||
{rule.config.threshold && (
|
|
||||||
<p className="text-xs text-blue-400 mt-1">阈值: {rule.config.threshold.toLocaleString()} 行</p>
|
|
||||||
)}
|
|
||||||
{rule.config.minLength && (
|
|
||||||
<p className="text-xs text-blue-400 mt-1">最小长度: {rule.config.minLength} 字符</p>
|
|
||||||
)}
|
|
||||||
{rule.config.forbiddenHours && (
|
|
||||||
<div className="flex items-center gap-1 mt-1">
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">禁止时段:</span>
|
|
||||||
{rule.config.forbiddenHours.map((h: string) => (
|
|
||||||
<span key={h} className="text-[11px] px-1.5 py-0.5 bg-red-500/10 text-red-400 rounded">{h}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Switch checked={rule.enabled} onCheckedChange={() => toggleSubmitRule(rule.id)} />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
{/* DDL 拦截 */}
|
|
||||||
<TabsContent value="ddl" className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">DDL 语句拦截规则</h2>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)]">配置 DDL 操作的安全策略和拦截规则</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleSave('ddl')}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
{savedTab === 'ddl' ? <CheckCircle className="w-4 h-4" /> : <Save className="w-4 h-4" />}
|
|
||||||
{savedTab === 'ddl' ? '已保存' : '保存配置'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{ddlPatterns.map(pattern => (
|
|
||||||
<Card key={pattern.id} className={`dg-card ${!pattern.enabled ? 'opacity-60' : ''}`}>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
|
|
||||||
pattern.action === 'block' ? 'bg-red-500/15' :
|
|
||||||
pattern.action === 'warn' ? 'bg-yellow-500/15' : 'bg-blue-500/15'
|
|
||||||
}`}>
|
|
||||||
<Database className={`w-4 h-4 ${
|
|
||||||
pattern.action === 'block' ? 'text-red-400' :
|
|
||||||
pattern.action === 'warn' ? 'text-yellow-400' : 'text-blue-400'
|
|
||||||
}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="text-sm font-mono bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded">{pattern.pattern}</code>
|
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${actionColors[pattern.action]}`}>
|
|
||||||
{actionLabels[pattern.action]}
|
|
||||||
</span>
|
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded-full ${pattern.enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
|
||||||
{pattern.enabled ? '已启用' : '已禁用'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--dg-text-secondary)] mt-1">{pattern.description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
value={pattern.action}
|
|
||||||
onChange={(e) => setDdlPatterns(prev => prev.map(p => p.id === pattern.id ? { ...p, action: e.target.value as any } : p))}
|
|
||||||
className="px-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-xs focus:outline-none focus:border-blue-500"
|
|
||||||
>
|
|
||||||
<option value="block">拦截</option>
|
|
||||||
<option value="warn">警告</option>
|
|
||||||
<option value="require_approval">需审批</option>
|
|
||||||
</select>
|
|
||||||
<Switch checked={pattern.enabled} onCheckedChange={() => toggleDDLPattern(pattern.id)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,237 +0,0 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
ClipboardList, Clock, CheckCircle, AlertTriangle,
|
|
||||||
TrendingUp, TrendingDown, Database
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { dashboardStats, dbInstances, statusMap, statusColorMap, dbTypeColorMap, tickets } from '@/lib/mock';
|
|
||||||
import * as echarts from 'echarts';
|
|
||||||
|
|
||||||
const stats = [
|
|
||||||
{ title: '今日工单', value: dashboardStats.todayTickets, trend: dashboardStats.todayTicketsTrend, icon: ClipboardList, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
|
||||||
{ title: '待审批', value: dashboardStats.pendingApproval, trend: dashboardStats.pendingTrend, icon: Clock, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
|
||||||
{ title: '查询审计', value: dashboardStats.queryAuditCount.toLocaleString(), trend: dashboardStats.queryTrend, icon: CheckCircle, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
|
||||||
{ title: '慢查询', value: dashboardStats.slowQueries, trend: dashboardStats.slowQueryTrend, icon: AlertTriangle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
|
||||||
const chartRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!chartRef.current) return;
|
|
||||||
const chart = echarts.init(chartRef.current);
|
|
||||||
const option: any = {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
tooltip: {
|
|
||||||
trigger: 'axis' as const,
|
|
||||||
backgroundColor: 'rgba(22, 27, 34, 0.95)',
|
|
||||||
borderColor: '#30363d',
|
|
||||||
textStyle: { color: '#f0f6fc' },
|
|
||||||
},
|
|
||||||
legend: {
|
|
||||||
data: ['提交工单', '执行成功', '执行失败'],
|
|
||||||
textStyle: { color: '#8b949e' },
|
|
||||||
bottom: 0,
|
|
||||||
itemWidth: 14,
|
|
||||||
itemHeight: 8,
|
|
||||||
},
|
|
||||||
grid: { left: '2%', right: '3%', bottom: '18%', top: '8%', containLabel: true },
|
|
||||||
xAxis: {
|
|
||||||
type: 'category' as const,
|
|
||||||
data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
|
|
||||||
axisLine: { lineStyle: { color: '#30363d' } },
|
|
||||||
axisLabel: { color: '#8b949e', fontSize: 11 },
|
|
||||||
},
|
|
||||||
yAxis: {
|
|
||||||
type: 'value' as const,
|
|
||||||
axisLine: { show: false },
|
|
||||||
splitLine: { lineStyle: { color: '#21262d' } },
|
|
||||||
axisLabel: { color: '#8b949e', fontSize: 11 },
|
|
||||||
},
|
|
||||||
series: [
|
|
||||||
{
|
|
||||||
name: '提交工单', type: 'line', smooth: true,
|
|
||||||
data: [120, 132, 101, 134, 90, 230, 210],
|
|
||||||
itemStyle: { color: '#58a6ff' },
|
|
||||||
lineStyle: { width: 2 },
|
|
||||||
areaStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{ offset: 0, color: 'rgba(88, 166, 255, 0.3)' },
|
|
||||||
{ offset: 1, color: 'rgba(88, 166, 255, 0)' },
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
symbol: 'none',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '执行成功', type: 'line', smooth: true,
|
|
||||||
data: [110, 125, 95, 128, 85, 215, 200],
|
|
||||||
itemStyle: { color: '#3fb950' },
|
|
||||||
lineStyle: { width: 2 },
|
|
||||||
areaStyle: {
|
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
|
||||||
{ offset: 0, color: 'rgba(63, 185, 80, 0.3)' },
|
|
||||||
{ offset: 1, color: 'rgba(63, 185, 80, 0)' },
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
symbol: 'none',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '执行失败', type: 'line', smooth: true,
|
|
||||||
data: [5, 3, 4, 2, 3, 8, 5],
|
|
||||||
itemStyle: { color: '#f85149' },
|
|
||||||
lineStyle: { width: 2 },
|
|
||||||
symbol: 'none',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
chart.setOption(option);
|
|
||||||
const handleResize = () => chart.resize();
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
return () => { window.removeEventListener('resize', handleResize); chart.dispose(); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onlineInstances = dbInstances.filter(i => i.status === 'online').length;
|
|
||||||
const warningInstances = dbInstances.filter(i => i.status === 'warning').length;
|
|
||||||
const offlineInstances = dbInstances.filter(i => i.status === 'offline').length;
|
|
||||||
const pendingTickets = tickets.filter(t => t.status === 'pending' || t.status === 'reviewing').slice(0, 4);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 统计卡片 */}
|
|
||||||
<div className="grid grid-cols-4 gap-5">
|
|
||||||
{stats.map((s, i) => (
|
|
||||||
<Card key={i} className="dg-card dg-card-hover">
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<span className="text-sm text-[var(--dg-text-secondary)] font-medium">{s.title}</span>
|
|
||||||
<div className={`w-10 h-10 rounded-xl ${s.bg} flex items-center justify-center`}>
|
|
||||||
<s.icon className={`w-5 h-5 ${s.color}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-3xl font-bold text-[var(--dg-text-primary)] mb-2">{s.value}</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<span className={`flex items-center gap-1 font-medium ${s.trend >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
|
||||||
{s.trend >= 0 ? <TrendingUp className="w-3.5 h-3.5" /> : <TrendingDown className="w-3.5 h-3.5" />}
|
|
||||||
{Math.abs(s.trend)}%
|
|
||||||
</span>
|
|
||||||
<span className="text-[var(--dg-text-muted)]">较昨日</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 图表 + 实例状态 */}
|
|
||||||
<div className="grid grid-cols-3 gap-5">
|
|
||||||
{/* 工单趋势图 */}
|
|
||||||
<Card className="dg-card col-span-2">
|
|
||||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-[15px] font-semibold">工单趋势</CardTitle>
|
|
||||||
<div className="flex gap-1.5">
|
|
||||||
{['近7天', '近30天', '近90天'].map((p, i) => (
|
|
||||||
<button key={p} className={`px-3 py-1.5 text-xs rounded-md transition-colors ${i === 0 ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'}`}>
|
|
||||||
{p}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div ref={chartRef} style={{ height: 300 }} />
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 实例状态 */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
|
||||||
<CardTitle className="text-[15px] font-semibold">实例状态</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="p-4 border-b border-[var(--dg-border-light)]">
|
|
||||||
<div className="grid grid-cols-3 gap-3 text-center">
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-green-400">{onlineInstances}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">在线</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-yellow-400">{warningInstances}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">告警</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-2xl font-bold text-red-400">{offlineInstances}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">离线</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{dbInstances.slice(0, 4).map(inst => (
|
|
||||||
<div key={inst.id} className="flex items-center px-5 py-3.5 border-b border-[var(--dg-border-light)] last:border-0 hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer">
|
|
||||||
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mr-3.5 ${inst.dbType === 'mysql' ? 'bg-[rgba(0,117,143,0.2)] text-[#26a69a]' : inst.dbType === 'redis' ? 'bg-[rgba(220,50,32,0.2)] text-[#ef5350]' : inst.dbType === 'postgres' ? 'bg-[rgba(51,103,145,0.2)] text-[#42a5f5]' : 'bg-[rgba(71,162,72,0.2)] text-[#66bb6a]'}`}>
|
|
||||||
<Database className="w-4.5 h-4.5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium truncate">{inst.name}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</div>
|
|
||||||
</div>
|
|
||||||
<span className={`px-2.5 py-1 rounded-full text-[11px] font-medium ${inst.status === 'online' ? 'bg-green-500/15 text-green-400' : inst.status === 'warning' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
|
||||||
{inst.status === 'online' ? '在线' : inst.status === 'warning' ? '告警' : '离线'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 待处理工单 */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
|
||||||
<CardTitle className="text-[15px] font-semibold">待处理工单</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">工单号</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">标题</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">数据库</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">申请人</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">状态</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">创建时间</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold text-[var(--dg-text-primary)]">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{pendingTickets.map(ticket => (
|
|
||||||
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
<td className="px-5 py-3.5 font-mono text-xs">{ticket.id}</td>
|
|
||||||
<td className="px-5 py-3.5 max-w-xs truncate">{ticket.title}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>
|
|
||||||
{ticket.dbType.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">{ticket.applicant}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' ? 'bg-blue-400 animate-pulse' : ticket.status === 'pending' ? 'bg-yellow-400' : ''}`} />
|
|
||||||
{statusMap[ticket.status]}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.createTime}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors">查看</button>
|
|
||||||
<button className="text-green-400 hover:text-green-300 text-xs font-medium transition-colors">审批</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Plus, Search, Edit2, Trash2, Database, Table, Filter, Shield } from 'lucide-react';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { dataPermissions } from '@/lib/mock';
|
|
||||||
|
|
||||||
export default function DataPermissionPage() {
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
|
|
||||||
const filtered = dataPermissions.filter(p =>
|
|
||||||
!search || p.user.includes(search) || p.instance.includes(search) || p.database.includes(search)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">数据权限</h1>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置用户对数据库、表、字段的访问权限,支持行级权限控制</p>
|
|
||||||
</div>
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
|
||||||
<Plus className="w-4 h-4" /> 添加权限
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{[
|
|
||||||
{ label: '权限规则数', value: dataPermissions.length, icon: Shield, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
|
||||||
{ label: '受控用户', value: new Set(dataPermissions.map(p => p.user)).size, icon: Database, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
|
||||||
{ label: '受控实例', value: new Set(dataPermissions.map(p => p.instance)).size, icon: Table, color: 'text-green-400', bg: 'bg-green-400/10' },
|
|
||||||
{ label: '行级过滤规则', value: dataPermissions.filter(p => p.rowFilter).length, icon: Filter, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
|
||||||
].map((s, i) => (
|
|
||||||
<Card key={i} className="dg-card">
|
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
|
||||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
|
|
||||||
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative max-w-xs">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
|
||||||
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none" placeholder="搜索用户/实例/数据库" value={search} onChange={e => setSearch(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">用户</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">实例</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">数据库</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">表</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">字段权限</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">行级过滤</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">操作权限</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filtered.map(p => (
|
|
||||||
<tr key={p.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
<td className="px-5 py-3.5 font-medium">{p.user}</td>
|
|
||||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{p.instance}</td>
|
|
||||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{p.database}</td>
|
|
||||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{p.table}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
{p.columns === '*' ? (
|
|
||||||
<span className="px-2 py-1 bg-green-500/15 text-green-400 text-xs rounded-md">全部字段</span>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{p.columns.split(',').map(c => (
|
|
||||||
<span key={c} className="px-2 py-1 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] text-xs rounded-md">{c}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
{p.rowFilter ? (
|
|
||||||
<span className="px-2 py-1 bg-yellow-500/15 text-yellow-400 text-xs rounded-md">{p.rowFilter}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-[var(--dg-text-muted)]">-</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{p.operations.map(op => (
|
|
||||||
<span key={op} className="px-2 py-1 bg-blue-500/15 text-blue-400 text-xs rounded-md">{op}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="text-blue-400 hover:text-blue-300"><Edit2 className="w-4 h-4" /></button>
|
|
||||||
<button className="text-red-400 hover:text-red-300"><Trash2 className="w-4 h-4" /></button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import '../App.css'
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const [count, setCount] = useState(0)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h1>Vite + React</h1>
|
|
||||||
<div className="card">
|
|
||||||
<button onClick={() => setCount((count) => count + 1)}>
|
|
||||||
count is {count}
|
|
||||||
</button>
|
|
||||||
<p>
|
|
||||||
Edit <code>src/App.tsx</code> and save to test HMR
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
import { useState, useMemo } from 'react';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import {
|
|
||||||
Plus, RefreshCw, Search, Database, Server, Zap, Leaf, MousePointerClick,
|
|
||||||
Activity, HardDrive, Cpu, MemoryStick, Clock, ChevronRight, Trash2, Edit2,
|
|
||||||
Undo2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { dbInstances } from '@/lib/mock';
|
|
||||||
import { useActionPermission } from '@/hooks/useAuth';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import EmptyState from '@/components/EmptyState';
|
|
||||||
import type { DBInstance } from '@/types';
|
|
||||||
|
|
||||||
const dbTypeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; label: string }> = {
|
|
||||||
mysql: { icon: Database, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' },
|
|
||||||
postgres: { icon: Server, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' },
|
|
||||||
redis: { icon: Zap, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]', label: 'Redis' },
|
|
||||||
mongo: { icon: Leaf, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' },
|
|
||||||
clickhouse: { icon: MousePointerClick, color: 'text-[#ffca28]', bg: 'bg-[rgba(255,202,40,0.15)]', label: 'ClickHouse' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function InstancePage() {
|
|
||||||
const { type = 'mysql' } = useParams<{ type: string }>();
|
|
||||||
const canAdd = useActionPermission('instance.add');
|
|
||||||
const canEdit = useActionPermission('instance.edit');
|
|
||||||
const canDelete = useActionPermission('instance.delete');
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [filterStatus, setFilterStatus] = useState('');
|
|
||||||
const [filterEnv, setFilterEnv] = useState('');
|
|
||||||
const [selectedInstance, setSelectedInstance] = useState<DBInstance | null>(null);
|
|
||||||
const [view, setView] = useState<'grid' | 'detail'>('grid');
|
|
||||||
|
|
||||||
const config: { icon: React.ElementType; color: string; bg: string; label: string } = dbTypeConfig[type] || dbTypeConfig.mysql;
|
|
||||||
|
|
||||||
const instances = useMemo(() => {
|
|
||||||
return dbInstances.filter(i => {
|
|
||||||
if (i.dbType !== type) return false;
|
|
||||||
if (filterStatus && i.status !== filterStatus) return false;
|
|
||||||
if (filterEnv && i.env !== filterEnv) return false;
|
|
||||||
if (searchQuery && !i.name.includes(searchQuery) && !i.host.includes(searchQuery)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [type, filterStatus, filterEnv, searchQuery]);
|
|
||||||
|
|
||||||
const statusCounts = useMemo(() => ({
|
|
||||||
online: instances.filter(i => i.status === 'online').length,
|
|
||||||
warning: instances.filter(i => i.status === 'warning').length,
|
|
||||||
offline: instances.filter(i => i.status === 'offline').length,
|
|
||||||
}), [instances]);
|
|
||||||
|
|
||||||
if (view === 'detail' && selectedInstance) {
|
|
||||||
return <InstanceDetail instance={selectedInstance} onBack={() => setView('grid')} config={config} canEdit={canEdit} canDelete={canDelete} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<PageHeader
|
|
||||||
title={`${config.label} 实例管理`}
|
|
||||||
description={`管理 ${config.label} 数据库实例,监控连接状态和性能指标`}
|
|
||||||
secondaryAction={{ label: '刷新', icon: RefreshCw, onClick: () => {} }}
|
|
||||||
action={canAdd ? { label: '添加实例', icon: Plus, onClick: () => {}, permission: 'instance.add' } : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 状态概览 */}
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center"><Activity className="w-5 h-5 text-green-400" /></div>
|
|
||||||
<div><div className="text-2xl font-bold">{statusCounts.online}</div><div className="text-xs text-[var(--dg-text-muted)]">在线实例</div></div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-yellow-500/10 flex items-center justify-center"><HardDrive className="w-5 h-5 text-yellow-400" /></div>
|
|
||||||
<div><div className="text-2xl font-bold">{statusCounts.warning}</div><div className="text-xs text-[var(--dg-text-muted)]">告警实例</div></div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-red-500/10 flex items-center justify-center"><HardDrive className="w-5 h-5 text-red-400" /></div>
|
|
||||||
<div><div className="text-2xl font-bold">{statusCounts.offline}</div><div className="text-xs text-[var(--dg-text-muted)]">离线实例</div></div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center"><Server className="w-5 h-5 text-blue-400" /></div>
|
|
||||||
<div><div className="text-2xl font-bold">{instances.length}</div><div className="text-xs text-[var(--dg-text-muted)]">总实例数</div></div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 筛选栏 */}
|
|
||||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
|
||||||
<div className="relative flex-1 max-w-xs">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
|
||||||
<input
|
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 transition-all"
|
|
||||||
placeholder="搜索实例名称/地址"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:border-blue-400 focus:outline-none" value={filterStatus} onChange={e => setFilterStatus(e.target.value)}>
|
|
||||||
<option value="">全部状态</option>
|
|
||||||
<option value="online">在线</option>
|
|
||||||
<option value="offline">离线</option>
|
|
||||||
<option value="warning">告警</option>
|
|
||||||
</select>
|
|
||||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] focus:border-blue-400 focus:outline-none" value={filterEnv} onChange={e => setFilterEnv(e.target.value)}>
|
|
||||||
<option value="">全部环境</option>
|
|
||||||
<option value="prod">生产</option>
|
|
||||||
<option value="test">测试</option>
|
|
||||||
<option value="dev">开发</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 实例卡片网格 */}
|
|
||||||
{instances.length === 0 ? (
|
|
||||||
<EmptyState type="search" />
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
{instances.map(instance => (
|
|
||||||
<div
|
|
||||||
key={instance.id}
|
|
||||||
className="dg-card p-5 cursor-pointer dg-card-hover group"
|
|
||||||
onClick={() => { setSelectedInstance(instance); setView('detail'); }}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className={`w-11 h-11 rounded-xl ${config.bg} flex items-center justify-center`}>
|
|
||||||
<config.icon className={`w-6 h-6 ${config.color}`} />
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{instance.tags.map(tag => (
|
|
||||||
<span key={tag} className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-[11px] rounded-md">{tag}</span>
|
|
||||||
))}
|
|
||||||
<div className={`w-2.5 h-2.5 rounded-full ${instance.status === 'online' ? 'bg-green-400 shadow-[0_0_0_3px_rgba(63,185,80,0.2)]' : instance.status === 'warning' ? 'bg-yellow-400' : 'bg-red-400'}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h3 className="text-base font-semibold mb-1">{instance.name}</h3>
|
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] mb-1">{instance.host}:{instance.port}</p>
|
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] mb-4">{config.label} {instance.version} · {instance.env === 'prod' ? '生产' : instance.env === 'test' ? '测试' : '开发'}</p>
|
|
||||||
<div className="grid grid-cols-3 gap-3 pt-4 border-t border-[var(--dg-border)]">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-lg font-semibold">{instance.connections}</div>
|
|
||||||
<div className="text-[11px] text-[var(--dg-text-muted)]">连接数</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-lg font-semibold">{instance.qps.toLocaleString()}</div>
|
|
||||||
<div className="text-[11px] text-[var(--dg-text-muted)]">QPS</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-lg font-semibold">{instance.latency}<span className="text-xs font-normal text-[var(--dg-text-muted)]">ms</span></div>
|
|
||||||
<div className="text-[11px] text-[var(--dg-text-muted)]">延迟</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
<div className="flex items-center gap-2 text-[11px]">
|
|
||||||
<Cpu className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
|
||||||
<div className="flex-1 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-blue-400 rounded-full transition-all" style={{ width: `${instance.cpu}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-[var(--dg-text-muted)] w-8 text-right">{instance.cpu}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-[11px]">
|
|
||||||
<MemoryStick className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
|
||||||
<div className="flex-1 h-1.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
|
||||||
<div className="h-full bg-purple-400 rounded-full transition-all" style={{ width: `${instance.memory}%` }} />
|
|
||||||
</div>
|
|
||||||
<span className="text-[var(--dg-text-muted)] w-8 text-right">{instance.memory}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function InstanceDetail({ instance, onBack, config, canEdit, canDelete }: {
|
|
||||||
instance: DBInstance; onBack: () => void; config: { icon: React.ElementType; color: string; bg: string };
|
|
||||||
canEdit: boolean; canDelete: boolean;
|
|
||||||
}) {
|
|
||||||
const DetailIcon = config.icon;
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button onClick={onBack} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
|
|
||||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className={`w-10 h-10 rounded-xl ${config.bg} flex items-center justify-center`}>
|
|
||||||
<DetailIcon className={`w-5 h-5 ${config.color}`} />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-semibold flex items-center gap-3">
|
|
||||||
{instance.name}
|
|
||||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${instance.status === 'online' ? 'bg-green-500/15 text-green-400' : instance.status === 'warning' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
|
||||||
{instance.status === 'online' ? '在线' : instance.status === 'warning' ? '告警' : '离线'}
|
|
||||||
</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{instance.host}:{instance.port} · 版本 {instance.version}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{canEdit && (
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
|
|
||||||
<Edit2 className="w-4 h-4" /> 编辑
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canDelete && (
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-red-400 hover:bg-red-500/10 hover:border-red-500/30 transition-all">
|
|
||||||
<Trash2 className="w-4 h-4" /> 删除
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 性能指标 */}
|
|
||||||
<div className="grid grid-cols-6 gap-4">
|
|
||||||
{[
|
|
||||||
{ label: '连接数', value: instance.connections, icon: Activity },
|
|
||||||
{ label: 'QPS', value: instance.qps.toLocaleString(), icon: Zap },
|
|
||||||
{ label: '延迟', value: `${instance.latency}ms`, icon: Clock },
|
|
||||||
{ label: 'CPU', value: `${instance.cpu}%`, icon: Cpu },
|
|
||||||
{ label: '内存', value: `${instance.memory}%`, icon: MemoryStick },
|
|
||||||
{ label: '磁盘', value: `${instance.disk}%`, icon: HardDrive },
|
|
||||||
].map((m, i) => (
|
|
||||||
<Card key={i} className="dg-card">
|
|
||||||
<CardContent className="p-4 text-center">
|
|
||||||
<m.icon className="w-5 h-5 text-[var(--dg-text-muted)] mx-auto mb-2" />
|
|
||||||
<div className="text-xl font-bold">{m.value}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{m.label}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-5">
|
|
||||||
{/* 连接信息 */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">连接信息</CardTitle></CardHeader>
|
|
||||||
<CardContent className="p-5 space-y-3">
|
|
||||||
{[
|
|
||||||
{ label: '主机地址', value: instance.host },
|
|
||||||
{ label: '端口', value: instance.port.toString() },
|
|
||||||
{ label: '数据库版本', value: instance.version },
|
|
||||||
{ label: '运行环境', value: instance.env === 'prod' ? '生产环境' : instance.env === 'test' ? '测试环境' : '开发环境' },
|
|
||||||
{ label: '运行时间', value: instance.uptime },
|
|
||||||
{ label: '标签', value: instance.tags.join(', ') },
|
|
||||||
...(instance.dbType === 'mysql' ? [
|
|
||||||
{ label: 'Binlog 格式', value: <span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded">ROW</span> },
|
|
||||||
{ label: 'Binlog 状态', value: <span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded">ON</span> },
|
|
||||||
{ label: '回滚支持', value: <span className="flex items-center gap-1"><Undo2 className="w-3 h-3 text-green-400" /> <span className="text-green-400 text-xs">支持</span></span> },
|
|
||||||
] : []),
|
|
||||||
].map(item => (
|
|
||||||
<div key={item.label} className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0">
|
|
||||||
<span className="text-sm text-[var(--dg-text-secondary)]">{item.label}</span>
|
|
||||||
<span className="text-sm font-medium">{item.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 资源使用趋势 */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">资源使用</CardTitle></CardHeader>
|
|
||||||
<CardContent className="p-5 space-y-5">
|
|
||||||
{[
|
|
||||||
{ label: 'CPU 使用率', value: instance.cpu, color: 'bg-blue-400' },
|
|
||||||
{ label: '内存使用率', value: instance.memory, color: 'bg-purple-400' },
|
|
||||||
{ label: '磁盘使用率', value: instance.disk, color: 'bg-orange-400' },
|
|
||||||
].map(item => (
|
|
||||||
<div key={item.label}>
|
|
||||||
<div className="flex justify-between text-sm mb-2">
|
|
||||||
<span className="text-[var(--dg-text-secondary)]">{item.label}</span>
|
|
||||||
<span className="font-medium">{item.value}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2.5 bg-[var(--dg-bg-tertiary)] rounded-full overflow-hidden">
|
|
||||||
<div className={`h-full ${item.color} rounded-full transition-all`} style={{ width: `${item.value}%` }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { Shield, Database, Code, GitBranch, Bell, History, UserCog, Sparkles } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleLogin = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!username || !password) return;
|
|
||||||
setLoading(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setLoading(false);
|
|
||||||
navigate('/dashboard');
|
|
||||||
}, 800);
|
|
||||||
};
|
|
||||||
|
|
||||||
const features = [
|
|
||||||
{ icon: Database, title: '多数据库支持', desc: 'MySQL、PostgreSQL、Redis、MongoDB、ClickHouse' },
|
|
||||||
{ icon: Code, title: '智能 SQL 审核', desc: '语法检查、规则校验、影响分析、自动评分' },
|
|
||||||
{ icon: GitBranch, title: '多级审批流程', desc: '会签、或签、条件审批灵活配置' },
|
|
||||||
{ icon: Bell, title: '多渠道通知', desc: '飞书、钉钉、企微、TG 即时推送' },
|
|
||||||
{ icon: History, title: '完整审计追踪', desc: '所有操作可追溯,支持 SQL 回滚' },
|
|
||||||
{ icon: UserCog, title: '细粒度权限', desc: 'RBAC 角色权限,字段级/行级访问控制' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex bg-[var(--dg-bg-primary)] relative overflow-hidden">
|
|
||||||
{/* 背景装饰 */}
|
|
||||||
<div className="absolute inset-0 pointer-events-none">
|
|
||||||
<div className="absolute top-0 left-0 w-full h-full bg-[radial-gradient(ellipse_at_20%_20%,rgba(88,166,255,0.08)_0%,transparent_50%),radial-gradient(ellipse_at_80%_80%,rgba(137,87,229,0.08)_0%,transparent_50%)]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 左侧 */}
|
|
||||||
<div className="flex-1 flex flex-col justify-center px-16 relative z-10">
|
|
||||||
<div className="mb-10">
|
|
||||||
<div className="flex items-center gap-3 mb-6">
|
|
||||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center">
|
|
||||||
<Shield className="w-7 h-7 text-white" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-5xl font-bold bg-gradient-to-r from-blue-400 via-purple-400 to-cyan-400 bg-clip-text text-transparent">
|
|
||||||
DataGuard Pro
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg text-[var(--dg-text-secondary)] leading-relaxed">
|
|
||||||
企业级数据审核与变更管理平台<br />
|
|
||||||
支持多数据库类型,提供完善的 SQL 审核、审批流程和审计追踪
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{features.map((f, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-3 p-4 rounded-xl bg-[var(--dg-bg-secondary)]/60 border border-[var(--dg-border)] hover:border-blue-500/50 hover:bg-[var(--dg-bg-secondary)] transition-all group">
|
|
||||||
<f.icon className="w-6 h-6 text-blue-400 mt-0.5 flex-shrink-0 group-hover:scale-110 transition-transform" />
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-semibold mb-1">{f.title}</h4>
|
|
||||||
<p className="text-xs text-[var(--dg-text-secondary)]">{f.desc}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧 */}
|
|
||||||
<div className="w-[480px] flex flex-col justify-center px-16 bg-[var(--dg-bg-secondary)]/80 backdrop-blur-xl border-l border-[var(--dg-border)] relative z-10">
|
|
||||||
<div className="w-full max-w-sm mx-auto">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Sparkles className="w-5 h-5 text-blue-400" />
|
|
||||||
<h2 className="text-2xl font-bold">欢迎回来</h2>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mb-8">请登录您的账户以继续</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">用户名 / 邮箱</label>
|
|
||||||
<div className="relative">
|
|
||||||
<UserCog className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
|
||||||
<input
|
|
||||||
className="w-full pl-10 pr-4 py-3 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 transition-all"
|
|
||||||
placeholder="admin"
|
|
||||||
value={username}
|
|
||||||
onChange={e => setUsername(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">密码</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Shield className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="w-full pl-10 pr-4 py-3 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 transition-all"
|
|
||||||
placeholder="admin123"
|
|
||||||
value={password}
|
|
||||||
onChange={e => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || !username || !password}
|
|
||||||
className="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-semibold text-white hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
|
||||||
) : '登录'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-6 p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] text-center">演示账号</p>
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<button onClick={() => { setUsername('admin'); setPassword('admin123'); }} className="flex-1 py-2 text-xs rounded-md bg-blue-500/10 text-blue-400 border border-blue-500/20 hover:bg-blue-500/20 transition-colors">
|
|
||||||
管理员
|
|
||||||
</button>
|
|
||||||
<button onClick={() => { setUsername('developer'); setPassword('dev123'); }} className="flex-1 py-2 text-xs rounded-md bg-green-500/10 text-green-400 border border-green-500/20 hover:bg-green-500/20 transition-colors">
|
|
||||||
开发者
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Send, Bell, MessageSquare, Navigation, Mail, Check, ToggleLeft, ToggleRight, Webhook, Settings, TestTube } from 'lucide-react';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { notificationChannels } from '@/lib/mock';
|
|
||||||
|
|
||||||
const iconMap: Record<string, React.ElementType> = {
|
|
||||||
Send, Bell, MessageSquare, Navigation, Mail,
|
|
||||||
};
|
|
||||||
|
|
||||||
const channelStyle: Record<string, { bg: string; color: string }> = {
|
|
||||||
feishu: { bg: 'bg-[rgba(0,150,255,0.15)]', color: 'text-[#00a8ff]' },
|
|
||||||
dingtalk: { bg: 'bg-[rgba(0,160,255,0.15)]', color: 'text-[#0089ff]' },
|
|
||||||
wechat: { bg: 'bg-[rgba(7,193,96,0.15)]', color: 'text-[#07c160]' },
|
|
||||||
telegram: { bg: 'bg-[rgba(0,136,204,0.15)]', color: 'text-[#0088cc]' },
|
|
||||||
email: { bg: 'bg-[rgba(220,50,32,0.15)]', color: 'text-[#ef5350]' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const allEvents = [
|
|
||||||
{ key: 'ticket_created', label: '工单创建' },
|
|
||||||
{ key: 'ticket_approved', label: '审批通过' },
|
|
||||||
{ key: 'ticket_rejected', label: '审批拒绝' },
|
|
||||||
{ key: 'ticket_executed', label: '执行完成' },
|
|
||||||
{ key: 'ticket_failed', label: '执行失败' },
|
|
||||||
{ key: 'ticket_rollback', label: '工单回滚' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
|
||||||
const [channels, setChannels] = useState(notificationChannels);
|
|
||||||
const [editChannel, setEditChannel] = useState<typeof notificationChannels[0] | null>(null);
|
|
||||||
|
|
||||||
const toggleChannel = (id: number) => {
|
|
||||||
setChannels(prev => prev.map(c => c.id === id ? { ...c, enabled: !c.enabled } : c));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">通知渠道配置</h1>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多渠道通知,支持飞书、钉钉、企业微信、Telegram</p>
|
|
||||||
</div>
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)] hover:border-[var(--dg-text-muted)] transition-all">
|
|
||||||
<Settings className="w-4 h-4" /> 全局设置
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-5 gap-4">
|
|
||||||
{[
|
|
||||||
{ label: '渠道总数', value: channels.length, icon: Webhook, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
|
||||||
{ label: '已启用', value: channels.filter(c => c.enabled).length, icon: Check, color: 'text-green-400', bg: 'bg-green-400/10' },
|
|
||||||
{ label: '未启用', value: channels.filter(c => !c.enabled).length, icon: Bell, color: 'text-gray-400', bg: 'bg-gray-400/10' },
|
|
||||||
].map((s, i) => (
|
|
||||||
<Card key={i} className="dg-card">
|
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
|
||||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
|
|
||||||
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
{channels.map(channel => {
|
|
||||||
const Icon = iconMap[channel.icon] || Bell;
|
|
||||||
const style = channelStyle[channel.type] || channelStyle.email;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={channel.id}
|
|
||||||
className={`dg-card p-5 cursor-pointer transition-all ${channel.enabled ? 'border-green-500/30 bg-green-500/5' : 'dg-card-hover'}`}
|
|
||||||
onClick={() => setEditChannel(channel)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className={`w-12 h-12 rounded-xl ${style.bg} flex items-center justify-center`}>
|
|
||||||
<Icon className={`w-6 h-6 ${style.color}`} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-base font-semibold">{channel.name}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{channel.description}</div>
|
|
||||||
{channel.enabled && channel.events && (
|
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
|
||||||
{channel.events.slice(0, 3).map(e => (
|
|
||||||
<span key={e} className="px-1.5 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-[10px] rounded">{allEvents.find(a => a.key === e)?.label || e}</span>
|
|
||||||
))}
|
|
||||||
{channel.events.length > 3 && <span className="px-1.5 py-0.5 text-[var(--dg-text-muted)] text-[10px]">+{channel.events.length - 3}</span>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${channel.enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
|
||||||
{channel.enabled ? '已启用' : '未启用'}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={e => { e.stopPropagation(); toggleChannel(channel.id); }}
|
|
||||||
className="transition-colors"
|
|
||||||
>
|
|
||||||
{channel.enabled ? <ToggleRight className="w-7 h-7 text-green-400" /> : <ToggleLeft className="w-7 h-7 text-[var(--dg-text-muted)]" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 编辑弹窗 */}
|
|
||||||
{editChannel && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setEditChannel(null)}>
|
|
||||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-lg w-full mx-4" onClick={e => e.stopPropagation()}>
|
|
||||||
<h3 className="text-xl font-semibold mb-5">配置 {editChannel.name}</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">Webhook URL</label>
|
|
||||||
<input
|
|
||||||
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
|
||||||
defaultValue={editChannel.webhook || ''}
|
|
||||||
placeholder={`请输入 ${editChannel.name} Webhook 地址`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">密钥 (Secret)</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none"
|
|
||||||
placeholder="请输入密钥"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-3">通知事件</label>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{allEvents.map(evt => (
|
|
||||||
<label key={evt.key} className="flex items-center gap-2 p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] cursor-pointer hover:border-[var(--dg-text-muted)] transition-colors">
|
|
||||||
<input type="checkbox" defaultChecked={editChannel.events?.includes(evt.key)} className="w-4 h-4 rounded border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] text-blue-500 focus:ring-blue-500/20" />
|
|
||||||
<span className="text-sm">{evt.label}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">@ 提醒用户</label>
|
|
||||||
<select className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
|
||||||
<option>审批人</option>
|
|
||||||
<option>申请人</option>
|
|
||||||
<option>执行人</option>
|
|
||||||
<option>全部相关人员</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-3 mt-6">
|
|
||||||
<button onClick={() => setEditChannel(null)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button className="flex items-center gap-2 px-5 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all">
|
|
||||||
<TestTube className="w-4 h-4" /> 测试
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setEditChannel(null)} className="px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,346 +0,0 @@
|
|||||||
import { useState, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
Search, Download, Plus, Clock, Eye, Copy, X, Database, FileText,
|
|
||||||
AlertTriangle, CheckCircle, XCircle, ChevronRight, Play
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { queryAudits, dbTypeColorMap } from '@/lib/mock';
|
|
||||||
import { useActionPermission } from '@/hooks/useAuth';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import EmptyState from '@/components/EmptyState';
|
|
||||||
import type { QueryAudit } from '@/types';
|
|
||||||
|
|
||||||
export default function QueriesPage() {
|
|
||||||
const canCreate = useActionPermission('query.create');
|
|
||||||
const canExport = useActionPermission('query.export');
|
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [dbTypeFilter, setDbTypeFilter] = useState('');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
|
||||||
const [selectedQuery, setSelectedQuery] = useState<QueryAudit | null>(null);
|
|
||||||
const [showNewQuery, setShowNewQuery] = useState(false);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
return queryAudits.filter(q => {
|
|
||||||
if (dbTypeFilter && q.dbType !== dbTypeFilter) return false;
|
|
||||||
if (statusFilter && q.status !== statusFilter) return false;
|
|
||||||
if (search && !q.sql.includes(search) && !q.user.includes(search) && !q.instance.includes(search)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [search, dbTypeFilter, statusFilter]);
|
|
||||||
|
|
||||||
const statusCounts = useMemo(() => ({
|
|
||||||
total: queryAudits.length,
|
|
||||||
success: queryAudits.filter(q => q.status === 'success').length,
|
|
||||||
slow: queryAudits.filter(q => q.status === 'slow').length,
|
|
||||||
failed: queryAudits.filter(q => q.status === 'failed').length,
|
|
||||||
}), []);
|
|
||||||
|
|
||||||
if (showNewQuery) {
|
|
||||||
return <NewQueryPanel onCancel={() => setShowNewQuery(false)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedQuery) {
|
|
||||||
return <QueryDetail query={selectedQuery} onBack={() => setSelectedQuery(null)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<PageHeader
|
|
||||||
title="查询审计"
|
|
||||||
description="记录所有 SQL 查询操作,支持敏感数据脱敏和性能分析"
|
|
||||||
secondaryAction={canExport ? { label: '导出日志', icon: Download, onClick: () => {} } : undefined}
|
|
||||||
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 统计 */}
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{[
|
|
||||||
{ label: '总查询数', value: statusCounts.total, icon: FileText, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
|
||||||
{ label: '成功', value: statusCounts.success, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
|
||||||
{ label: '慢查询', value: statusCounts.slow, icon: Clock, color: 'text-yellow-400', bg: 'bg-yellow-400/10' },
|
|
||||||
{ label: '失败', value: statusCounts.failed, icon: XCircle, color: 'text-red-400', bg: 'bg-red-400/10' },
|
|
||||||
].map((s, i) => (
|
|
||||||
<Card key={i} className="dg-card">
|
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
|
||||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
|
|
||||||
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 筛选 */}
|
|
||||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
|
||||||
<div className="relative flex-1 max-w-xs">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
|
||||||
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20" placeholder="搜索 SQL 关键词/用户/实例" value={search} onChange={e => setSearch(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={dbTypeFilter} onChange={e => setDbTypeFilter(e.target.value)}>
|
|
||||||
<option value="">全部数据库</option>
|
|
||||||
<option value="mysql">MySQL</option>
|
|
||||||
<option value="postgres">PostgreSQL</option>
|
|
||||||
<option value="redis">Redis</option>
|
|
||||||
<option value="mongo">MongoDB</option>
|
|
||||||
</select>
|
|
||||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
|
|
||||||
<option value="">全部状态</option>
|
|
||||||
<option value="success">成功</option>
|
|
||||||
<option value="slow">慢查询</option>
|
|
||||||
<option value="failed">失败</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 查询表格 */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<EmptyState type="search" />
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">查询ID</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">SQL 语句</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">数据库</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">执行用户</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">执行时长</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">返回行数</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">敏感数据</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filtered.map(q => (
|
|
||||||
<tr key={q.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer" onClick={() => setSelectedQuery(q)}>
|
|
||||||
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{q.id}</td>
|
|
||||||
<td className="px-5 py-3.5 max-w-xs">
|
|
||||||
<code className="text-xs font-mono text-blue-400 truncate block">{q.sqlPreview}</code>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<span className={`inline-flex px-2 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[q.dbType] || ''}`}>{q.dbType.toUpperCase()}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">{q.user}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<span className={q.executeTime > 1000 ? 'text-red-400 font-medium' : ''}>{q.executeTime}ms</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">{q.rowCount}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-full text-xs font-medium ${q.status === 'success' ? 'bg-green-500/15 text-green-400' : q.status === 'slow' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${q.status === 'slow' ? 'animate-pulse' : ''}`} />
|
|
||||||
{q.status === 'success' ? '成功' : q.status === 'slow' ? '慢查询' : '失败'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
{q.hasSensitive ? (
|
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/10 border border-yellow-500/30 rounded text-xs text-yellow-400">
|
|
||||||
<AlertTriangle className="w-3 h-3" /> 已脱敏
|
|
||||||
</span>
|
|
||||||
) : <span className="text-[var(--dg-text-muted)] text-xs">-</span>}
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<button className="text-blue-400 hover:text-blue-300 transition-colors"><Eye className="w-4 h-4" /></button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function QueryDetail({ query, onBack }: { query: QueryAudit; onBack: () => void }) {
|
|
||||||
const maskData = (value: string) => {
|
|
||||||
if (!value) return '***';
|
|
||||||
if (value.includes('@')) {
|
|
||||||
const [name, domain] = value.split('@');
|
|
||||||
return name.substring(0, 3) + '***@' + domain;
|
|
||||||
}
|
|
||||||
if (value.length > 7) return value.substring(0, 3) + '****' + value.substring(value.length - 4);
|
|
||||||
return '***';
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 max-w-5xl">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button onClick={onBack} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
|
|
||||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
|
||||||
</button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold flex items-center gap-3">
|
|
||||||
查询详情
|
|
||||||
<span className={`inline-flex px-2.5 py-1 rounded-full text-xs font-medium ${query.status === 'success' ? 'bg-green-500/15 text-green-400' : query.status === 'slow' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-red-500/15 text-red-400'}`}>
|
|
||||||
{query.status === 'success' ? '成功' : query.status === 'slow' ? '慢查询' : '失败'}
|
|
||||||
</span>
|
|
||||||
{query.hasSensitive && <span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/10 border border-yellow-500/30 rounded text-xs text-yellow-400"><AlertTriangle className="w-3 h-3" /> 包含敏感数据</span>}
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{query.id} · {query.user} · {query.timestamp}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-all">
|
|
||||||
<Copy className="w-4 h-4" /> 复制 SQL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 执行指标 */}
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{[
|
|
||||||
{ label: '执行时长', value: `${query.executeTime}ms`, isSlow: query.executeTime > 1000, icon: Clock },
|
|
||||||
{ label: '返回行数', value: query.rowCount.toString(), icon: FileText },
|
|
||||||
{ label: '扫描行数', value: query.scanRows.toLocaleString(), icon: Database },
|
|
||||||
{ label: '影响行数', value: query.affectedRows.toString(), icon: CheckCircle },
|
|
||||||
].map((m, i) => (
|
|
||||||
<Card key={i} className="dg-card">
|
|
||||||
<CardContent className="p-4 text-center">
|
|
||||||
<m.icon className="w-5 h-5 text-[var(--dg-text-muted)] mx-auto mb-2" />
|
|
||||||
<div className={`text-xl font-bold ${m.isSlow ? 'text-red-400' : ''}`}>{m.value}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-1">{m.label}</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-5">
|
|
||||||
{/* SQL 详情 */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">SQL 语句</CardTitle></CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap">{query.sql}</pre>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 连接信息 */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">连接信息</CardTitle></CardHeader>
|
|
||||||
<CardContent className="p-5 space-y-3">
|
|
||||||
{[
|
|
||||||
{ label: '数据库类型', value: query.dbType.toUpperCase() },
|
|
||||||
{ label: '目标实例', value: query.instance },
|
|
||||||
{ label: '执行用户', value: query.user },
|
|
||||||
{ label: '客户端 IP', value: query.clientIp },
|
|
||||||
{ label: '来源', value: query.source },
|
|
||||||
{ label: '执行时间', value: query.timestamp },
|
|
||||||
].map((item, idx) => (
|
|
||||||
<div key={idx} className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0">
|
|
||||||
<span className="text-sm text-[var(--dg-text-secondary)]">{item.label}</span>
|
|
||||||
<span className="text-sm font-medium">{item.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 查询结果 */}
|
|
||||||
{query.resultSet && query.resultColumns && (
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-[15px] font-semibold">查询结果</CardTitle>
|
|
||||||
{query.hasSensitive && <span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-500/10 border border-yellow-500/30 rounded text-xs text-yellow-400"><AlertTriangle className="w-3 h-3" /> 敏感字段已脱敏</span>}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
|
||||||
{query.resultColumns.map(col => (
|
|
||||||
<th key={col.prop} className="text-left px-4 py-3 font-semibold text-xs">
|
|
||||||
{col.label}
|
|
||||||
{col.sensitive && <span className="ml-1.5 px-1.5 py-0.5 bg-yellow-500/10 text-yellow-400 text-[10px] rounded">敏感</span>}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{query.resultSet.map((row, i) => (
|
|
||||||
<tr key={i} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
{query.resultColumns!.map(col => (
|
|
||||||
<td key={col.prop} className="px-4 py-3 text-xs">
|
|
||||||
{col.sensitive ? (
|
|
||||||
<span className="text-yellow-400 italic">{maskData(String(row[col.prop]))}</span>
|
|
||||||
) : (
|
|
||||||
String(row[col.prop])
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NewQueryPanel({ onCancel }: { onCancel: () => void }) {
|
|
||||||
const [sql, setSql] = useState('');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 max-w-4xl">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-semibold">新建查询</h1>
|
|
||||||
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><X className="w-6 h-6" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">数据库类型</label>
|
|
||||||
<select className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
|
||||||
<option>MySQL</option><option>PostgreSQL</option><option>Redis</option><option>MongoDB</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">目标实例</label>
|
|
||||||
<select className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
|
||||||
<option>生产 MySQL 主库</option><option>测试库</option><option>开发库</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">目标数据库</label>
|
|
||||||
<select className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
|
||||||
<option>user_db</option><option>order_db</option><option>log_db</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">SQL 语句</label>
|
|
||||||
<div className="dg-card overflow-hidden">
|
|
||||||
<textarea
|
|
||||||
className="w-full min-h-[250px] p-4 bg-[var(--dg-bg-primary)] text-sm font-mono text-[var(--dg-text-primary)] resize-y border-none outline-none placeholder:text-[var(--dg-text-muted)]"
|
|
||||||
placeholder="-- 请输入 SQL 查询语句\n-- 查询结果将自动脱敏敏感字段\n\nSELECT id, name, email, phone, status\nFROM users\nWHERE status = 'active'\nLIMIT 100;"
|
|
||||||
value={sql}
|
|
||||||
onChange={e => setSql(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
|
|
||||||
<span>{sql.length} 字符</span>
|
|
||||||
<span>Ctrl + Enter 执行查询</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
|
|
||||||
<AlertTriangle className="w-4 h-4 text-yellow-400 flex-shrink-0" />
|
|
||||||
<span className="text-xs text-[var(--dg-text-secondary)]">查询结果中的敏感字段(邮箱、手机号、身份证号等)将自动脱敏显示。完整数据需要额外权限。</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<button onClick={onCancel} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
|
||||||
<button className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
|
||||||
<Play className="w-4 h-4" /> 执行查询
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Crown, Database, Code, Eye, Shield, ChevronRight, Check, Save } from 'lucide-react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { roles, permissionTreeData } from '@/lib/mock';
|
|
||||||
import type { Role } from '@/types';
|
|
||||||
|
|
||||||
const iconMap: Record<string, React.ElementType> = { Crown, Database, Code, Eye };
|
|
||||||
|
|
||||||
export default function RolesPage() {
|
|
||||||
const [selectedRole, setSelectedRole] = useState<Role | null>(roles[0]);
|
|
||||||
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set(roles[0]?.permissions || []));
|
|
||||||
|
|
||||||
const handleRoleSelect = (role: Role) => {
|
|
||||||
setSelectedRole(role);
|
|
||||||
setCheckedKeys(new Set(role.permissions));
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePermission = (id: string) => {
|
|
||||||
const next = new Set(checkedKeys);
|
|
||||||
if (next.has(id)) next.delete(id);
|
|
||||||
else next.add(id);
|
|
||||||
setCheckedKeys(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleChildren = (node: typeof permissionTreeData[0], checked: boolean) => {
|
|
||||||
const next = new Set(checkedKeys);
|
|
||||||
const toggleNode = (n: typeof node) => {
|
|
||||||
if (checked) next.add(n.id);
|
|
||||||
else next.delete(n.id);
|
|
||||||
n.children?.forEach(toggleNode);
|
|
||||||
};
|
|
||||||
toggleNode(node);
|
|
||||||
setCheckedKeys(next);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">角色权限管理</h1>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置 RBAC 角色权限,控制用户对系统功能和数据的访问</p>
|
|
||||||
</div>
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
|
||||||
<Shield className="w-4 h-4" /> 新建角色
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 角色卡片 */}
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{roles.map(role => {
|
|
||||||
const Icon = iconMap[role.icon] || Eye;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={role.id}
|
|
||||||
onClick={() => handleRoleSelect(role)}
|
|
||||||
className={`dg-card p-5 cursor-pointer transition-all ${selectedRole?.id === role.id ? 'border-blue-500/50 bg-blue-500/5' : 'dg-card-hover'}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3 mb-3">
|
|
||||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${role.iconClass}`}>
|
|
||||||
<Icon className="w-5 h-5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-base font-semibold truncate">{role.name}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] truncate">{role.description}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-4 pt-3 border-t border-[var(--dg-border)]">
|
|
||||||
<div className="text-xs text-[var(--dg-text-secondary)]"><span className="text-[var(--dg-text-primary)] font-semibold">{role.userCount}</span> 用户</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-secondary)]"><span className="text-[var(--dg-text-primary)] font-semibold">{role.permissionCount}</span> 权限</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 权限配置 */}
|
|
||||||
{selectedRole && (
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardHeader className="border-b border-[var(--dg-border)] flex flex-row items-center justify-between">
|
|
||||||
<CardTitle className="text-[15px] font-semibold">{selectedRole.name} - 权限配置</CardTitle>
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all">
|
|
||||||
<Save className="w-4 h-4" /> 保存权限
|
|
||||||
</button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="space-y-2">
|
|
||||||
{permissionTreeData.map(node => (
|
|
||||||
<PermissionNodeComponent
|
|
||||||
key={node.id}
|
|
||||||
node={node}
|
|
||||||
checkedKeys={checkedKeys}
|
|
||||||
onToggle={togglePermission}
|
|
||||||
onToggleChildren={toggleChildren}
|
|
||||||
depth={0}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PermissionNodeComponent({
|
|
||||||
node, checkedKeys, onToggle, onToggleChildren, depth
|
|
||||||
}: {
|
|
||||||
node: typeof permissionTreeData[0];
|
|
||||||
checkedKeys: Set<string>;
|
|
||||||
onToggle: (id: string) => void;
|
|
||||||
onToggleChildren: (node: typeof permissionTreeData[0], checked: boolean) => void;
|
|
||||||
depth: number;
|
|
||||||
}) {
|
|
||||||
const [expanded, setExpanded] = useState(true);
|
|
||||||
const isChecked = checkedKeys.has(node.id);
|
|
||||||
const hasChildren = node.children && node.children.length > 0;
|
|
||||||
const allChildrenChecked = hasChildren && node.children!.every(c => checkedKeys.has(c.id));
|
|
||||||
const someChildrenChecked = hasChildren && node.children!.some(c => checkedKeys.has(c.id));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 py-2 px-3 rounded-lg hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer"
|
|
||||||
style={{ paddingLeft: `${depth * 20 + 12}px` }}
|
|
||||||
onClick={() => { if (hasChildren) setExpanded(!expanded); }}
|
|
||||||
>
|
|
||||||
{hasChildren && (
|
|
||||||
<ChevronRight className={`w-4 h-4 text-[var(--dg-text-muted)] transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
|
||||||
)}
|
|
||||||
{!hasChildren && <div className="w-4" />}
|
|
||||||
<div
|
|
||||||
className={`w-4 h-4 rounded border flex items-center justify-center transition-all ${isChecked || allChildrenChecked ? 'bg-blue-500 border-blue-500' : someChildrenChecked ? 'bg-blue-500/50 border-blue-500' : 'border-[var(--dg-text-muted)]'}`}
|
|
||||||
onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (hasChildren) onToggleChildren(node, !allChildrenChecked);
|
|
||||||
else onToggle(node.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(isChecked || allChildrenChecked || someChildrenChecked) && <Check className="w-3 h-3 text-white" />}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm">{node.label}</span>
|
|
||||||
</div>
|
|
||||||
{hasChildren && expanded && (
|
|
||||||
<div>
|
|
||||||
{node.children!.map(child => (
|
|
||||||
<PermissionNodeComponent
|
|
||||||
key={child.id}
|
|
||||||
node={child}
|
|
||||||
checkedKeys={checkedKeys}
|
|
||||||
onToggle={onToggle}
|
|
||||||
onToggleChildren={onToggleChildren}
|
|
||||||
depth={depth + 1}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,463 +0,0 @@
|
|||||||
import { useState, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
Plus, Search, Eye, CheckCircle, XCircle, Play, RotateCcw, Copy,
|
|
||||||
ChevronRight, Shield, FileCode, History, CircleDot, AlertTriangle,
|
|
||||||
Database, Key, Lock, Undo2, Info,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { tickets, statusMap, statusColorMap, dbTypeColorMap } from '@/lib/mock';
|
|
||||||
import { useAuth, useActionPermission } from '@/hooks/useAuth';
|
|
||||||
import PageHeader from '@/components/PageHeader';
|
|
||||||
import EmptyState from '@/components/EmptyState';
|
|
||||||
import type { Ticket } from '@/types';
|
|
||||||
|
|
||||||
export default function TicketsPage() {
|
|
||||||
const { isDeveloper } = useAuth();
|
|
||||||
const canCreate = useActionPermission('ticket.create');
|
|
||||||
const canApprove = useActionPermission('ticket.approve');
|
|
||||||
const canExecute = useActionPermission('ticket.execute');
|
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
|
||||||
const [dbTypeFilter, setDbTypeFilter] = useState('');
|
|
||||||
const [priorityFilter, setPriorityFilter] = useState('');
|
|
||||||
const [selectedTicket, setSelectedTicket] = useState<Ticket | null>(null);
|
|
||||||
const [showNewTicket, setShowNewTicket] = useState(false);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
return tickets.filter(t => {
|
|
||||||
if (statusFilter && t.status !== statusFilter) return false;
|
|
||||||
if (dbTypeFilter && t.dbType !== dbTypeFilter) return false;
|
|
||||||
if (priorityFilter && t.priority !== priorityFilter) return false;
|
|
||||||
if (search && !t.title.includes(search) && !t.id.includes(search) && !t.applicant.includes(search)) return false;
|
|
||||||
// developer 只能看到自己的工单
|
|
||||||
if (isDeveloper && t.applicant !== '王五' && t.applicant !== 'developer') return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [search, statusFilter, dbTypeFilter, priorityFilter, isDeveloper]);
|
|
||||||
|
|
||||||
const statusCounts = useMemo(() => {
|
|
||||||
const counts: Record<string, number> = {};
|
|
||||||
tickets.forEach(t => { counts[t.status] = (counts[t.status] || 0) + 1; });
|
|
||||||
return counts;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (showNewTicket) {
|
|
||||||
return <NewTicketForm onCancel={() => setShowNewTicket(false)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (selectedTicket) {
|
|
||||||
return <TicketDetail ticket={selectedTicket} onBack={() => setSelectedTicket(null)} canApprove={canApprove} canExecute={canExecute} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<PageHeader
|
|
||||||
title="数据变更工单"
|
|
||||||
description="管理 SQL 变更工单,提交审核、查看审批进度"
|
|
||||||
action={canCreate ? {
|
|
||||||
label: '新建工单', icon: Plus, onClick: () => setShowNewTicket(true), permission: 'ticket.create'
|
|
||||||
} : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 状态快捷筛选 */}
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{[
|
|
||||||
{ key: 'pending', label: '待审批', count: statusCounts.pending || 0, color: 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20' },
|
|
||||||
{ key: 'reviewing', label: '审批中', count: statusCounts.reviewing || 0, color: 'text-blue-400 bg-blue-400/10 border-blue-400/20' },
|
|
||||||
{ key: 'approved', label: '已通过', count: statusCounts.approved || 0, color: 'text-green-400 bg-green-400/10 border-green-400/20' },
|
|
||||||
{ key: 'executing', label: '执行中', count: statusCounts.executing || 0, color: 'text-purple-400 bg-purple-400/10 border-purple-400/20' },
|
|
||||||
{ key: 'success', label: '成功', count: statusCounts.success || 0, color: 'text-green-400 bg-green-400/10 border-green-400/20' },
|
|
||||||
{ key: 'failed', label: '失败', count: statusCounts.failed || 0, color: 'text-red-400 bg-red-400/10 border-red-400/20' },
|
|
||||||
].map(s => (
|
|
||||||
<button
|
|
||||||
key={s.key}
|
|
||||||
onClick={() => setStatusFilter(statusFilter === s.key ? '' : s.key)}
|
|
||||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${statusFilter === s.key ? s.color : 'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:border-[var(--dg-text-muted)]'}`}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
<span className={`px-1.5 py-0.5 rounded text-xs ${statusFilter === s.key ? 'bg-white/20' : 'bg-[var(--dg-bg-tertiary)]'}`}>{s.count}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 筛选栏 */}
|
|
||||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
|
||||||
<div className="relative flex-1 max-w-xs">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
|
||||||
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20" placeholder="搜索工单标题/编号/申请人" value={search} onChange={e => setSearch(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
|
|
||||||
<option value="">全部状态</option>
|
|
||||||
{Object.entries(statusMap).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
|
||||||
</select>
|
|
||||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={dbTypeFilter} onChange={e => setDbTypeFilter(e.target.value)}>
|
|
||||||
<option value="">全部数据库</option>
|
|
||||||
<option value="mysql">MySQL</option>
|
|
||||||
<option value="postgres">PostgreSQL</option>
|
|
||||||
<option value="redis">Redis</option>
|
|
||||||
</select>
|
|
||||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={priorityFilter} onChange={e => setPriorityFilter(e.target.value)}>
|
|
||||||
<option value="">全部优先级</option>
|
|
||||||
<option value="high">高</option>
|
|
||||||
<option value="medium">中</option>
|
|
||||||
<option value="low">低</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 工单表格 */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{filtered.length === 0 ? (
|
|
||||||
<EmptyState type="search" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">工单号</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">标题</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">数据库</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">实例</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">申请人</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">优先级</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">创建时间</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filtered.map(ticket => (
|
|
||||||
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
<td className="px-5 py-3.5 font-mono text-xs text-[var(--dg-text-secondary)]">{ticket.id}</td>
|
|
||||||
<td className="px-5 py-3.5 max-w-xs truncate font-medium">{ticket.title}</td>
|
|
||||||
<td className="px-5 py-3.5"><span className={`inline-flex px-2 py-1 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>{ticket.dbType.toUpperCase()}</span></td>
|
|
||||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.instance}</td>
|
|
||||||
<td className="px-5 py-3.5">{ticket.applicant}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<span className={`inline-flex px-2 py-1 rounded-md text-xs font-medium ${ticket.priority === 'high' ? 'bg-red-500/15 text-red-400' : ticket.priority === 'medium' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-blue-500/15 text-blue-400'}`}>
|
|
||||||
{ticket.priority === 'high' ? '高' : ticket.priority === 'medium' ? '中' : '低'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' || ticket.status === 'executing' ? 'animate-pulse' : ''}`} />
|
|
||||||
{statusMap[ticket.status]}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{ticket.createTime}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button onClick={() => setSelectedTicket(ticket)} className="text-blue-400 hover:text-blue-300 transition-colors"><Eye className="w-4 h-4" /></button>
|
|
||||||
{canApprove && ticket.status === 'pending' && <button className="text-green-400 hover:text-green-300 transition-colors"><CheckCircle className="w-4 h-4" /></button>}
|
|
||||||
{canApprove && ticket.status === 'pending' && <button className="text-red-400 hover:text-red-300 transition-colors"><XCircle className="w-4 h-4" /></button>}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
|
|
||||||
<span className="text-sm text-[var(--dg-text-muted)]">共 {filtered.length} 条记录</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{['上一页', '1', '2', '3', '下一页'].map((p, i) => (
|
|
||||||
<button key={i} className={`px-3 py-1.5 rounded-md text-sm ${i === 1 ? 'bg-blue-500 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'}`}>{p}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NewTicketForm({ onCancel }: { onCancel: () => void }) {
|
|
||||||
const [sqlTab, setSqlTab] = useState<'change' | 'rollback'>('change');
|
|
||||||
const [sql, setSql] = useState('');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 max-w-5xl">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-semibold">新建数据变更工单</h1>
|
|
||||||
<button onClick={onCancel} className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"><XCircle className="w-6 h-6" /></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">工单标题 <span className="text-red-400">*</span></label>
|
|
||||||
<input className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20" placeholder="请输入工单标题,如:更新用户状态字段" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">数据库类型 <span className="text-red-400">*</span></label>
|
|
||||||
<select className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
|
||||||
<option>MySQL</option><option>PostgreSQL</option><option>Redis</option><option>MongoDB</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">目标实例 <span className="text-red-400">*</span></label>
|
|
||||||
<select className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none">
|
|
||||||
<option>生产 MySQL 主库</option><option>测试 MySQL 从库</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">SQL 语句 <span className="text-red-400">*</span></label>
|
|
||||||
<div className="dg-card overflow-hidden">
|
|
||||||
<div className="flex items-center gap-1 p-2 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
|
||||||
<button onClick={() => setSqlTab('change')} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-colors ${sqlTab === 'change' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}>
|
|
||||||
<FileCode className="w-3.5 h-3.5" /> 变更 SQL
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setSqlTab('rollback')} className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-colors ${sqlTab === 'rollback' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'}`}>
|
|
||||||
<RotateCcw className="w-3.5 h-3.5" /> 回滚 SQL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
className="w-full min-h-[200px] p-4 bg-[var(--dg-bg-primary)] text-sm font-mono text-[var(--dg-text-primary)] resize-y border-none outline-none placeholder:text-[var(--dg-text-muted)]"
|
|
||||||
placeholder={sqlTab === 'change' ? "-- 请输入 SQL 语句,支持多条 SQL,使用分号分隔\n\nUPDATE users SET status = 'active' WHERE id = 1;\nINSERT INTO logs (action, user_id) VALUES ('update', 1);" : "-- 请输入回滚 SQL\n\nUPDATE users SET status = (SELECT status FROM users_backup WHERE id = users.id) WHERE id = 1;"}
|
|
||||||
value={sql}
|
|
||||||
onChange={e => setSql(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs text-[var(--dg-text-muted)]">
|
|
||||||
<span><span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-400 mr-1.5" />已输入 {sql.length} 个字符</span>
|
|
||||||
<span>Ctrl + Enter 快速提交</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2">变更原因 <span className="text-red-400">*</span></label>
|
|
||||||
<textarea className="w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm min-h-[80px] resize-y focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20 placeholder:text-[var(--dg-text-muted)]" placeholder="请描述本次数据变更的原因和背景,便于审批人理解" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SQL 审核预览 */}
|
|
||||||
<div className="dg-card overflow-hidden">
|
|
||||||
<div className="flex items-center justify-between p-4 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center gap-2"><Shield className="w-4 h-4 text-blue-400" /><span className="text-sm font-semibold">SQL 审核预览</span></div>
|
|
||||||
<div className="flex items-center gap-2 px-3 py-1 bg-green-500/15 rounded-full">
|
|
||||||
<CheckCircle className="w-4 h-4 text-green-400" /><span className="text-sm font-semibold text-green-400">92 分</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-[var(--dg-border-light)]">
|
|
||||||
{[
|
|
||||||
{ icon: XCircle, color: 'text-red-400 bg-red-400/10', title: '禁止无 WHERE 条件的 UPDATE/DELETE', desc: '检测到 UPDATE 语句缺少 WHERE 条件', suggestion: '建议: 添加 WHERE 条件限制影响范围' },
|
|
||||||
{ icon: FileCode, color: 'text-yellow-400 bg-yellow-400/10', title: '大表变更警告', desc: '目标表 users 数据量超过 100 万行', suggestion: '建议: 考虑分批执行或使用 pt-online-schema-change' },
|
|
||||||
{ icon: CheckCircle, color: 'text-green-400 bg-green-400/10', title: 'SQL 语法检查通过', desc: 'SQL 语法正确,可以正常解析' },
|
|
||||||
].map((item, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-3 p-4">
|
|
||||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 ${item.color}`}>
|
|
||||||
<item.icon className="w-3.5 h-3.5" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium">{item.title}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5">{item.desc}</div>
|
|
||||||
{item.suggestion && <div className="text-xs text-blue-400 mt-1">{item.suggestion}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
|
||||||
<button onClick={onCancel} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium hover:bg-[var(--dg-bg-hover)] transition-colors">取消</button>
|
|
||||||
<button className="px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">提交审核</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TicketDetail({ ticket, onBack, canApprove, canExecute }: {
|
|
||||||
ticket: Ticket; onBack: () => void; canApprove: boolean; canExecute: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 max-w-5xl">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<button onClick={onBack} className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-all">
|
|
||||||
<ChevronRight className="w-4 h-4 rotate-180" />
|
|
||||||
</button>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold flex items-center gap-3">
|
|
||||||
{ticket.title}
|
|
||||||
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>{statusMap[ticket.status]}</span>
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{ticket.id} · 由 {ticket.applicant} 创建于 {ticket.createTime}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{canApprove && ticket.status === 'pending' && (
|
|
||||||
<>
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-green-500/15 border border-green-500/30 rounded-lg text-sm font-medium text-green-400 hover:bg-green-500/25 transition-all">
|
|
||||||
<CheckCircle className="w-4 h-4" /> 通过
|
|
||||||
</button>
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-red-500/15 border border-red-500/30 rounded-lg text-sm font-medium text-red-400 hover:bg-red-500/25 transition-all">
|
|
||||||
<XCircle className="w-4 h-4" /> 拒绝
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{canExecute && ticket.status === 'approved' && (
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-purple-500/15 border border-purple-500/30 rounded-lg text-sm font-medium text-purple-400 hover:bg-purple-500/25 transition-all">
|
|
||||||
<Play className="w-4 h-4" /> 执行
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 审批流程 */}
|
|
||||||
{ticket.workflowSteps && (
|
|
||||||
<div className="dg-card p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{ticket.workflowSteps.map((step, i) => (
|
|
||||||
<div key={i} className="flex items-center flex-1">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center text-white text-sm font-medium ${step.status === 'completed' ? 'bg-green-500' : step.status === 'active' ? 'bg-blue-500 ring-4 ring-blue-500/20' : 'bg-[var(--dg-bg-tertiary)] border-2 border-[var(--dg-border)] text-[var(--dg-text-muted)]'}`}>
|
|
||||||
{step.status === 'completed' ? <CheckCircle className="w-5 h-5" /> : step.status === 'active' ? <CircleDot className="w-5 h-5" /> : <span className="text-xs">{i + 1}</span>}
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-xs font-medium whitespace-nowrap">{step.name}</div>
|
|
||||||
<div className="text-[11px] text-[var(--dg-text-muted)]">{step.time || '等待中'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{i < ticket.workflowSteps!.length - 1 && (
|
|
||||||
<div className={`flex-1 h-[2px] mx-3 mb-6 ${step.status === 'completed' ? 'bg-green-500' : 'bg-[var(--dg-border)]'}`} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-5">
|
|
||||||
{/* 工单信息 */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold">工单信息</CardTitle></CardHeader>
|
|
||||||
<CardContent className="p-5 space-y-3">
|
|
||||||
{[
|
|
||||||
{ label: '工单号', value: ticket.id },
|
|
||||||
{ label: '数据库类型', value: <span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${dbTypeColorMap[ticket.dbType] || ''}`}>{ticket.dbType.toUpperCase()}</span> },
|
|
||||||
{ label: '目标实例', value: ticket.instance },
|
|
||||||
{ label: '申请人', value: ticket.applicant },
|
|
||||||
{ label: '优先级', value: <span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${ticket.priority === 'high' ? 'bg-red-500/15 text-red-400' : ticket.priority === 'medium' ? 'bg-yellow-500/15 text-yellow-400' : 'bg-blue-500/15 text-blue-400'}`}>{ticket.priority === 'high' ? '高' : ticket.priority === 'medium' ? '中' : '低'}</span> },
|
|
||||||
{ label: '创建时间', value: ticket.createTime },
|
|
||||||
{ label: '变更原因', value: ticket.reason },
|
|
||||||
].map((item, idx) => (
|
|
||||||
<div key={idx} className="flex justify-between py-2 border-b border-[var(--dg-border-light)] last:border-0">
|
|
||||||
<span className="text-sm text-[var(--dg-text-secondary)]">{item.label}</span>
|
|
||||||
<span className="text-sm font-medium">{item.value}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* SQL 内容 */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold flex items-center gap-2"><FileCode className="w-4 h-4 text-blue-400" /> SQL 内容</CardTitle></CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap">{ticket.sql}</pre>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 回滚 SQL */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardHeader className="border-b border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-[15px] font-semibold flex items-center gap-2"><Undo2 className="w-4 h-4 text-purple-400" /> 回滚 SQL</CardTitle>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded flex items-center gap-1"><Database className="w-3 h-3" /> Binlog ROW</span>
|
|
||||||
<span className="px-2 py-0.5 bg-green-500/15 text-green-400 text-xs rounded flex items-center gap-1"><Key className="w-3 h-3" /> 有主键</span>
|
|
||||||
<span className="px-2 py-0.5 bg-blue-500/15 text-blue-400 text-xs rounded">支持回滚</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-0">
|
|
||||||
{ticket.rollbackSql ? (
|
|
||||||
<div>
|
|
||||||
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap">{ticket.rollbackSql}</pre>
|
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-t border-[var(--dg-border)]">
|
|
||||||
<div className="flex items-center gap-4 text-xs text-[var(--dg-text-muted)]">
|
|
||||||
<span className="flex items-center gap-1"><Info className="w-3 h-3" /> 基于 Binlog ROW 格式解析生成</span>
|
|
||||||
<span className="flex items-center gap-1"><Lock className="w-3 h-3" /> 备份库: backup_mysql_01</span>
|
|
||||||
</div>
|
|
||||||
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-purple-500/15 text-purple-400 text-xs rounded hover:bg-purple-500/25 transition-colors">
|
|
||||||
<Copy className="w-3 h-3" /> 复制
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-8 text-center">
|
|
||||||
<AlertTriangle className="w-8 h-8 text-yellow-400 mx-auto mb-3" />
|
|
||||||
<div className="text-sm text-[var(--dg-text-secondary)] mb-1">暂无回滚 SQL</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)]">工单执行后将自动生成基于 Binlog 的回滚语句</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 回滚限制说明 */}
|
|
||||||
<div className="dg-card p-5">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<AlertTriangle className="w-4 h-4 text-yellow-400" />
|
|
||||||
<h3 className="text-sm font-semibold">回滚功能限制说明</h3>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
{[
|
|
||||||
{ icon: Database, title: 'Binlog 配置', status: '已满足', desc: 'MySQL 必须开启 Binlog 且格式为 ROW', color: 'text-green-400 bg-green-400/10' },
|
|
||||||
{ icon: Key, title: '主键要求', status: '已满足', desc: '操作的表必须包含主键,用于精确定位行', color: 'text-green-400 bg-green-400/10' },
|
|
||||||
{ icon: Lock, title: '权限要求', status: '已满足', desc: '账号需有 REPLICATION SLAVE/CLIENT 权限', color: 'text-green-400 bg-green-400/10' },
|
|
||||||
].map((item, i) => (
|
|
||||||
<div key={i} className="flex items-start gap-3 p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
|
||||||
<div className={`w-8 h-8 rounded-lg ${item.color} flex items-center justify-center flex-shrink-0`}>
|
|
||||||
<item.icon className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium flex items-center gap-2">{item.title}<span className={`text-[11px] ${item.color.split(' ')[0]}`}>{item.status}</span></div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-0.5">{item.desc}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg">
|
|
||||||
<div className="text-xs text-[var(--dg-text-secondary)] flex items-start gap-2">
|
|
||||||
<AlertTriangle className="w-3.5 h-3.5 text-yellow-400 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<span className="font-medium text-yellow-400">重要提示:</span>
|
|
||||||
DDL操作(DROP/TRUNCATE)的回滚支持有限,仅能重建表结构无法恢复数据;
|
|
||||||
DML和DDL不可混合执行;WHERE条件可能导致非唯一标识冲突,恢复时务必仔细核对。
|
|
||||||
参考 Yearning 2.0 实现:AST语法解析 + Binlog逆向操作。
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 审批记录 */}
|
|
||||||
{ticket.approvalRecords && (
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardHeader className="border-b border-[var(--dg-border)]"><CardTitle className="text-[15px] font-semibold flex items-center gap-2"><History className="w-4 h-4 text-blue-400" /> 审批记录</CardTitle></CardHeader>
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="relative pl-6 space-y-6">
|
|
||||||
<div className="absolute left-[11px] top-2 bottom-2 w-[2px] bg-[var(--dg-border)]" />
|
|
||||||
{ticket.approvalRecords.map((record, i) => (
|
|
||||||
<div key={i} className="relative">
|
|
||||||
<div className={`absolute -left-6 top-0.5 w-5 h-5 rounded-full flex items-center justify-center ${record.action === 'approve' ? 'bg-green-500' : 'bg-red-500'}`}>
|
|
||||||
{record.action === 'approve' ? <CheckCircle className="w-3 h-3 text-white" /> : <XCircle className="w-3 h-3 text-white" />}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium">{record.approver} {record.action === 'approve' ? '通过审批' : '拒绝审批'}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)]">{record.time}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-1 p-2 bg-[var(--dg-bg-tertiary)] rounded-lg">{record.comment}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { useState, useMemo } from 'react';
|
|
||||||
import { Plus, Search, Edit2, Lock, Trash2, } from 'lucide-react';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { users, roles } from '@/lib/mock';
|
|
||||||
// type User is used implicitly
|
|
||||||
|
|
||||||
const roleTagStyle: Record<string, string> = {
|
|
||||||
admin: 'bg-purple-500/15 text-purple-400',
|
|
||||||
dba: 'bg-blue-500/15 text-blue-400',
|
|
||||||
developer: 'bg-green-500/15 text-green-400',
|
|
||||||
guest: 'bg-gray-500/15 text-gray-400',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function UsersPage() {
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [roleFilter, setRoleFilter] = useState('');
|
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
return users.filter(u => {
|
|
||||||
if (roleFilter && u.role !== roleFilter) return false;
|
|
||||||
if (statusFilter && u.status !== statusFilter) return false;
|
|
||||||
if (search && !u.username.includes(search) && !u.email.includes(search)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [search, roleFilter, statusFilter]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">用户管理</h1>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">管理系统用户,分配角色和数据库访问权限</p>
|
|
||||||
</div>
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity">
|
|
||||||
<Plus className="w-4 h-4" /> 新建用户
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 筛选 */}
|
|
||||||
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
|
||||||
<div className="relative flex-1 max-w-xs">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
|
||||||
<input className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400/20" placeholder="搜索用户名/邮箱" value={search} onChange={e => setSearch(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={roleFilter} onChange={e => setRoleFilter(e.target.value)}>
|
|
||||||
<option value="">全部角色</option>
|
|
||||||
{roles.map(r => <option key={r.id} value={r.id}>{r.name}</option>)}
|
|
||||||
</select>
|
|
||||||
<select className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
|
|
||||||
<option value="">全部状态</option>
|
|
||||||
<option value="active">启用</option>
|
|
||||||
<option value="disabled">禁用</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 用户表格 */}
|
|
||||||
<Card className="dg-card">
|
|
||||||
<CardContent className="p-0">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">用户</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">角色</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">部门</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">最后登录</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">状态</th>
|
|
||||||
<th className="text-left px-5 py-3 font-semibold">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filtered.map(user => (
|
|
||||||
<tr key={user.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-sm font-semibold">
|
|
||||||
{user.avatar}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{user.username}</div>
|
|
||||||
<div className="text-xs text-[var(--dg-text-muted)]">{user.email}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<span className={`inline-flex px-2.5 py-1 rounded-md text-xs font-medium ${(roleTagStyle as any)[user.role] || ''}`}>
|
|
||||||
{user.roleName}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{user.department}</td>
|
|
||||||
<td className="px-5 py-3.5 text-[var(--dg-text-secondary)]">{user.lastLogin}</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<button
|
|
||||||
className={`relative w-11 h-6 rounded-full transition-colors ${user.status === 'active' ? 'bg-green-500' : 'bg-[var(--dg-bg-hover)]'}`}
|
|
||||||
>
|
|
||||||
<div className={`absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform ${user.status === 'active' ? 'translate-x-5.5' : 'translate-x-0.5'}`} style={{ transform: user.status === 'active' ? 'translateX(20px)' : 'translateX(2px)' }} />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="px-5 py-3.5">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button className="text-blue-400 hover:text-blue-300 transition-colors"><Edit2 className="w-4 h-4" /></button>
|
|
||||||
<button className="text-purple-400 hover:text-purple-300 transition-colors"><Lock className="w-4 h-4" /></button>
|
|
||||||
<button className="text-red-400 hover:text-red-300 transition-colors"><Trash2 className="w-4 h-4" /></button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-t border-[var(--dg-border)]">
|
|
||||||
<span className="text-sm text-[var(--dg-text-muted)]">共 {filtered.length} 条记录</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,550 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import {
|
|
||||||
Plus, Edit2, Eye, ToggleLeft, ToggleRight, GitBranch, Users, CheckCircle,
|
|
||||||
ArrowRight, Shield, Trash2, X, ChevronDown, ChevronUp,
|
|
||||||
Save, Play
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { workflows as initialWorkflows, dbTypeColorMap } from '@/lib/mock';
|
|
||||||
|
|
||||||
type StepType = 'submit' | 'audit' | 'execute';
|
|
||||||
|
|
||||||
type FlowStep = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: StepType;
|
|
||||||
assignees: string[];
|
|
||||||
mode?: 'any' | 'all';
|
|
||||||
description?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WorkflowDetail = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
dbTypes: string[];
|
|
||||||
steps: FlowStep[];
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
conditions?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const stepTypeLabels: Record<StepType, string> = {
|
|
||||||
submit: '提交阶段',
|
|
||||||
audit: '审核阶段',
|
|
||||||
execute: '执行阶段',
|
|
||||||
};
|
|
||||||
|
|
||||||
const stepTypeColors: Record<StepType, string> = {
|
|
||||||
submit: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
|
||||||
audit: 'bg-purple-500/15 text-purple-400 border-purple-500/30',
|
|
||||||
execute: 'bg-green-500/15 text-green-400 border-green-500/30',
|
|
||||||
};
|
|
||||||
|
|
||||||
const stepTypeIcons: Record<StepType, React.ElementType> = {
|
|
||||||
submit: Users,
|
|
||||||
audit: Shield,
|
|
||||||
execute: Play,
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultSteps: FlowStep[] = [
|
|
||||||
{ id: 'step-1', name: '提交阶段', type: 'submit', assignees: ['工单提交人'], description: '申请人提交 SQL 变更工单' },
|
|
||||||
{ id: 'step-2', name: '一级审批', type: 'audit', assignees: ['部门负责人'], mode: 'any', description: '部门负责人进行一级审批' },
|
|
||||||
{ id: 'step-3', name: '二级审批', type: 'audit', assignees: ['DBA 团队'], mode: 'any', description: 'DBA 进行二级审批' },
|
|
||||||
{ id: 'step-4', name: 'SQL 执行', type: 'execute', assignees: ['DBA 执行人'], description: '审批通过后执行 SQL 变更' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function WorkflowPage() {
|
|
||||||
const [items, setItems] = useState<WorkflowDetail[]>(
|
|
||||||
initialWorkflows.map(w => ({
|
|
||||||
...w,
|
|
||||||
steps: defaultSteps.slice(0, Math.min(w.steps + 2, defaultSteps.length)),
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
const [previewWorkflow, setPreviewWorkflow] = useState<WorkflowDetail | null>(null);
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
||||||
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDetail | null>(null);
|
|
||||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// 新建/编辑表单状态
|
|
||||||
const [formName, setFormName] = useState('');
|
|
||||||
const [formDesc, setFormDesc] = useState('');
|
|
||||||
const [formDbTypes, setFormDbTypes] = useState<string[]>(['mysql']);
|
|
||||||
const [formSteps, setFormSteps] = useState<FlowStep[]>([defaultSteps[0]]);
|
|
||||||
const [formConditions, setFormConditions] = useState('');
|
|
||||||
|
|
||||||
const dbTypes = ['mysql', 'postgres', 'redis', 'mongo', 'clickhouse'];
|
|
||||||
|
|
||||||
const resetForm = () => {
|
|
||||||
setFormName('');
|
|
||||||
setFormDesc('');
|
|
||||||
setFormDbTypes(['mysql']);
|
|
||||||
setFormSteps([{ ...defaultSteps[0] }]);
|
|
||||||
setFormConditions('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const openCreate = () => {
|
|
||||||
resetForm();
|
|
||||||
setEditingWorkflow(null);
|
|
||||||
setShowCreateModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEdit = (wf: WorkflowDetail) => {
|
|
||||||
setFormName(wf.name);
|
|
||||||
setFormDesc(wf.description);
|
|
||||||
setFormDbTypes(wf.dbTypes);
|
|
||||||
setFormSteps([...wf.steps]);
|
|
||||||
setFormConditions(wf.conditions?.join(', ') || '');
|
|
||||||
setEditingWorkflow(wf);
|
|
||||||
setShowCreateModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleWorkflow = (id: number) => {
|
|
||||||
setItems(prev => prev.map(w => w.id === id ? { ...w, enabled: !w.enabled } : w));
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteWorkflow = (id: number) => {
|
|
||||||
setItems(prev => prev.filter(w => w.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addStep = () => {
|
|
||||||
const types: StepType[] = ['audit', 'execute'];
|
|
||||||
const nextType = types[Math.min(formSteps.length - 1, types.length - 1)];
|
|
||||||
setFormSteps(prev => [...prev, {
|
|
||||||
id: `step-${Date.now()}`,
|
|
||||||
name: nextType === 'audit' ? `第 ${prev.filter(s => s.type === 'audit').length + 1} 级审批` : '执行阶段',
|
|
||||||
type: nextType,
|
|
||||||
assignees: nextType === 'audit' ? ['审批人'] : ['DBA 执行人'],
|
|
||||||
mode: 'any',
|
|
||||||
description: '',
|
|
||||||
}]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeStep = (stepId: string) => {
|
|
||||||
if (formSteps.length <= 1) return;
|
|
||||||
setFormSteps(prev => prev.filter(s => s.id !== stepId));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateStep = (stepId: string, updates: Partial<FlowStep>) => {
|
|
||||||
setFormSteps(prev => prev.map(s => s.id === stepId ? { ...s, ...updates } : s));
|
|
||||||
};
|
|
||||||
|
|
||||||
const moveStep = (index: number, direction: 'up' | 'down') => {
|
|
||||||
if (direction === 'up' && index === 0) return;
|
|
||||||
if (direction === 'down' && index === formSteps.length - 1) return;
|
|
||||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
|
||||||
setFormSteps(prev => {
|
|
||||||
const arr = [...prev];
|
|
||||||
[arr[index], arr[newIndex]] = [arr[newIndex], arr[index]];
|
|
||||||
return arr;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveWorkflow = () => {
|
|
||||||
if (!formName.trim()) return;
|
|
||||||
const conditions = formConditions ? formConditions.split(',').map(s => s.trim()).filter(Boolean) : [];
|
|
||||||
if (editingWorkflow) {
|
|
||||||
setItems(prev => prev.map(w => w.id === editingWorkflow.id ? {
|
|
||||||
...w,
|
|
||||||
name: formName,
|
|
||||||
description: formDesc,
|
|
||||||
dbTypes: formDbTypes,
|
|
||||||
steps: [...formSteps],
|
|
||||||
conditions: conditions.length > 0 ? conditions : undefined,
|
|
||||||
} : w));
|
|
||||||
} else {
|
|
||||||
const newWf: WorkflowDetail = {
|
|
||||||
id: Date.now(),
|
|
||||||
name: formName,
|
|
||||||
description: formDesc,
|
|
||||||
dbTypes: formDbTypes,
|
|
||||||
steps: [...formSteps],
|
|
||||||
enabled: true,
|
|
||||||
conditions: conditions.length > 0 ? conditions : undefined,
|
|
||||||
};
|
|
||||||
setItems(prev => [...prev, newWf]);
|
|
||||||
}
|
|
||||||
setShowCreateModal(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDbType = (dt: string) => {
|
|
||||||
setFormDbTypes(prev =>
|
|
||||||
prev.includes(dt) ? prev.filter(d => d !== dt) : [...prev, dt]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-semibold">审批流程配置</h1>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">配置多级审批流程模板,支持提交阶段、审核阶段、执行阶段的自定义配置</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={openCreate}
|
|
||||||
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" /> 新建流程
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
{[
|
|
||||||
{ label: '流程总数', value: items.length, icon: GitBranch, color: 'text-blue-400', bg: 'bg-blue-400/10' },
|
|
||||||
{ label: '已启用', value: items.filter(w => w.enabled).length, icon: CheckCircle, color: 'text-green-400', bg: 'bg-green-400/10' },
|
|
||||||
{ label: '多级审批', value: items.filter(w => w.steps.filter(s => s.type === 'audit').length > 1).length, icon: Users, color: 'text-purple-400', bg: 'bg-purple-400/10' },
|
|
||||||
{ label: '单级审批', value: items.filter(w => w.steps.filter(s => s.type === 'audit').length === 1).length, icon: Shield, color: 'text-orange-400', bg: 'bg-orange-400/10' },
|
|
||||||
].map((s, i) => (
|
|
||||||
<Card key={i} className="dg-card">
|
|
||||||
<CardContent className="p-4 flex items-center gap-3">
|
|
||||||
<div className={`w-10 h-10 rounded-lg ${s.bg} flex items-center justify-center`}><s.icon className={`w-5 h-5 ${s.color}`} /></div>
|
|
||||||
<div><div className="text-2xl font-bold">{s.value}</div><div className="text-xs text-[var(--dg-text-muted)]">{s.label}</div></div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{items.map(wf => {
|
|
||||||
const isExpanded = expandedId === wf.id;
|
|
||||||
return (
|
|
||||||
<Card key={wf.id} className={`dg-card overflow-hidden ${!wf.enabled ? 'opacity-50' : ''}`}>
|
|
||||||
<CardContent className="p-5">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-3 mb-2">
|
|
||||||
<h3 className="text-lg font-semibold">{wf.name}</h3>
|
|
||||||
<span className={`px-2 py-0.5 rounded text-xs font-medium ${wf.enabled ? 'bg-green-500/15 text-green-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]'}`}>
|
|
||||||
{wf.enabled ? '已启用' : '已禁用'}
|
|
||||||
</span>
|
|
||||||
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] text-xs rounded">
|
|
||||||
{wf.steps.length} 个阶段
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)] mb-3">{wf.description}</p>
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">适用数据库:</span>
|
|
||||||
{wf.dbTypes.map(dt => (
|
|
||||||
<span key={dt} className={`inline-flex px-2 py-0.5 rounded text-[11px] font-medium ${dbTypeColorMap[dt] || ''}`}>{dt.toUpperCase()}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 流程可视化 */}
|
|
||||||
<div className="flex items-center gap-1 mt-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-lg overflow-x-auto">
|
|
||||||
{wf.steps.map((step, i) => {
|
|
||||||
const StepIcon = stepTypeIcons[step.type];
|
|
||||||
return (
|
|
||||||
<div key={step.id} className="flex items-center gap-1 flex-shrink-0">
|
|
||||||
<div className={`px-3 py-2 rounded-lg border ${stepTypeColors[step.type]} flex items-center gap-2`}>
|
|
||||||
<StepIcon className="w-4 h-4" />
|
|
||||||
<div>
|
|
||||||
<div className="text-xs font-medium whitespace-nowrap">{step.name}</div>
|
|
||||||
<div className="text-[10px] opacity-80 whitespace-nowrap">{step.assignees.join(' / ')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{i < wf.steps.length - 1 && <ArrowRight className="w-4 h-4 text-[var(--dg-text-muted)] flex-shrink-0" />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 展开详情 */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="mt-4 space-y-3 pt-4 border-t border-[var(--dg-border)]">
|
|
||||||
<h4 className="text-sm font-semibold">流程步骤详情</h4>
|
|
||||||
{wf.steps.map((step) => {
|
|
||||||
const StepIcon = stepTypeIcons[step.type];
|
|
||||||
return (
|
|
||||||
<div key={step.id} className="flex items-start gap-3 p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
|
|
||||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${stepTypeColors[step.type].split(' ')[0]}`}>
|
|
||||||
<StepIcon className={`w-4 h-4 ${stepTypeColors[step.type].split(' ')[1]}`} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="text-sm font-medium">{step.name}</span>
|
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded ${stepTypeColors[step.type]}`}>{stepTypeLabels[step.type]}</span>
|
|
||||||
{step.mode && (
|
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)]">
|
|
||||||
{step.mode === 'any' ? '或签(任一人)' : '会签(全部人)'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-[var(--dg-text-secondary)]">{step.description}</p>
|
|
||||||
<div className="flex items-center gap-1 mt-1">
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">执行人:</span>
|
|
||||||
{step.assignees.map((a, j) => (
|
|
||||||
<span key={j} className="text-xs text-blue-400 bg-blue-500/10 px-1.5 py-0.5 rounded">{a}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{wf.conditions && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">触发条件:</span>
|
|
||||||
{wf.conditions.map(c => (
|
|
||||||
<span key={c} className="px-2 py-0.5 bg-yellow-500/10 text-yellow-400 text-xs rounded">{c}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1 ml-4">
|
|
||||||
<button onClick={() => setPreviewWorkflow(wf)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-colors" title="预览">
|
|
||||||
<Eye className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => openEdit(wf)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)] transition-colors" title="编辑">
|
|
||||||
<Edit2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => toggleWorkflow(wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" title={wf.enabled ? '禁用' : '启用'}>
|
|
||||||
{wf.enabled ? <ToggleRight className="w-5 h-5 text-green-400" /> : <ToggleLeft className="w-5 h-5 text-[var(--dg-text-muted)]" />}
|
|
||||||
</button>
|
|
||||||
<button onClick={() => deleteWorkflow(wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-red-500/15 hover:text-red-400 transition-colors" title="删除">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setExpandedId(isExpanded ? null : wf.id)} className="w-8 h-8 rounded-lg flex items-center justify-center text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors" title="展开">
|
|
||||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 预览弹窗 */}
|
|
||||||
{previewWorkflow && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setPreviewWorkflow(null)}>
|
|
||||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-2xl w-full mx-4" onClick={e => e.stopPropagation()}>
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h3 className="text-xl font-semibold">{previewWorkflow.name} - 流程预览</h3>
|
|
||||||
<button onClick={() => setPreviewWorkflow(null)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{previewWorkflow.steps.map((step, i) => {
|
|
||||||
const StepIcon = stepTypeIcons[step.type];
|
|
||||||
return (
|
|
||||||
<div key={step.id} className="flex items-start gap-4">
|
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${stepTypeColors[step.type].split(' ')[0]}`}>
|
|
||||||
<StepIcon className={`w-5 h-5 ${stepTypeColors[step.type].split(' ')[1]}`} />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="font-medium">{step.name}</span>
|
|
||||||
<span className={`text-[11px] px-2 py-0.5 rounded ${stepTypeColors[step.type]}`}>{stepTypeLabels[step.type]}</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-[var(--dg-text-secondary)]">{step.description || '该阶段暂无详细描述'}</p>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">负责人:</span>
|
|
||||||
{step.assignees.map((a, j) => (
|
|
||||||
<span key={j} className="text-xs text-blue-400">{a}{j < step.assignees.length - 1 ? ' / ' : ''}</span>
|
|
||||||
))}
|
|
||||||
{step.mode && (
|
|
||||||
<span className="text-[11px] px-2 py-0.5 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] ml-2">
|
|
||||||
{step.mode === 'any' ? '或签' : '会签'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{i < previewWorkflow.steps.length - 1 && <ArrowRight className="w-5 h-5 text-[var(--dg-text-muted)] mt-2" />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end mt-6">
|
|
||||||
<button onClick={() => setPreviewWorkflow(null)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
关闭
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 新建/编辑流程弹窗 */}
|
|
||||||
{showCreateModal && (
|
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowCreateModal(false)}>
|
|
||||||
<div className="bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h3 className="text-xl font-semibold">{editingWorkflow ? '编辑流程' : '新建流程'}</h3>
|
|
||||||
<button onClick={() => setShowCreateModal(false)} className="w-8 h-8 rounded-lg flex items-center justify-center hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
<X className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* 基本信息 */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h4 className="text-sm font-semibold text-[var(--dg-text-secondary)] uppercase tracking-wider">基本信息</h4>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">流程名称 <span className="text-red-400">*</span></label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formName}
|
|
||||||
onChange={e => setFormName(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
placeholder="例如:钱包平台 SQL 变更流程"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">流程描述</label>
|
|
||||||
<textarea
|
|
||||||
value={formDesc}
|
|
||||||
onChange={e => setFormDesc(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 resize-none"
|
|
||||||
rows={2}
|
|
||||||
placeholder="描述该流程的用途和适用范围"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-2 block">适用数据库</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{dbTypes.map(dt => (
|
|
||||||
<button
|
|
||||||
key={dt}
|
|
||||||
onClick={() => toggleDbType(dt)}
|
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
|
||||||
formDbTypes.includes(dt)
|
|
||||||
? 'bg-blue-500/15 text-blue-400 border-blue-500/30'
|
|
||||||
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border-[var(--dg-border)] hover:border-[var(--dg-text-secondary)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{dt.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-xs text-[var(--dg-text-muted)] mb-1.5 block">触发条件(用逗号分隔)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formConditions}
|
|
||||||
onChange={e => setFormConditions(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
placeholder="例如:影响行数>1000, 执行时间>1s"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 步骤配置 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="text-sm font-semibold text-[var(--dg-text-secondary)] uppercase tracking-wider">流程步骤</h4>
|
|
||||||
<button
|
|
||||||
onClick={addStep}
|
|
||||||
className="flex items-center gap-1 px-3 py-1.5 bg-blue-500/15 text-blue-400 rounded-lg text-xs hover:bg-blue-500/25 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="w-3.5 h-3.5" /> 添加步骤
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formSteps.map((step, index) => (
|
|
||||||
<Card key={step.id} className="dg-card border-l-2 border-l-blue-500">
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div className="flex flex-col gap-1 pt-1">
|
|
||||||
<button onClick={() => moveStep(index, 'up')} disabled={index === 0} className="w-6 h-6 rounded flex items-center justify-center hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 transition-colors">
|
|
||||||
<ChevronUp className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => moveStep(index, 'down')} disabled={index === formSteps.length - 1} className="w-6 h-6 rounded flex items-center justify-center hover:bg-[var(--dg-bg-hover)] disabled:opacity-30 transition-colors">
|
|
||||||
<ChevronDown className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 space-y-3">
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">步骤名称</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={step.name}
|
|
||||||
onChange={e => updateStep(step.id, { name: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">步骤类型</label>
|
|
||||||
<select
|
|
||||||
value={step.type}
|
|
||||||
onChange={e => updateStep(step.id, { type: e.target.value as StepType })}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
>
|
|
||||||
<option value="submit">提交阶段</option>
|
|
||||||
<option value="audit">审核阶段</option>
|
|
||||||
<option value="execute">执行阶段</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">负责人(用/分隔)</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={step.assignees.join(' / ')}
|
|
||||||
onChange={e => updateStep(step.id, { assignees: e.target.value.split('/').map(s => s.trim()).filter(Boolean) })}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
placeholder="例如:张三 / 李四"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{step.type === 'audit' && (
|
|
||||||
<div>
|
|
||||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">审批模式</label>
|
|
||||||
<select
|
|
||||||
value={step.mode || 'any'}
|
|
||||||
onChange={e => updateStep(step.id, { mode: e.target.value as 'any' | 'all' })}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
>
|
|
||||||
<option value="any">或签(任一人通过)</option>
|
|
||||||
<option value="all">会签(全部人通过)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="text-[11px] text-[var(--dg-text-muted)] mb-1 block">步骤描述</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={step.description || ''}
|
|
||||||
onChange={e => updateStep(step.id, { description: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500"
|
|
||||||
placeholder="描述该步骤的具体操作..."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{formSteps.length > 1 && (
|
|
||||||
<button
|
|
||||||
onClick={() => removeStep(step.id)}
|
|
||||||
className="w-7 h-7 rounded-lg flex items-center justify-center text-[var(--dg-text-muted)] hover:bg-red-500/15 hover:text-red-400 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-[var(--dg-border)]">
|
|
||||||
<button onClick={() => setShowCreateModal(false)} className="px-5 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={saveWorkflow}
|
|
||||||
disabled={!formName.trim() || formDbTypes.length === 0}
|
|
||||||
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4" /> {editingWorkflow ? '保存修改' : '创建流程'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
export interface User {
|
|
||||||
id: number;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
role: 'admin' | 'dba' | 'developer' | 'guest';
|
|
||||||
roleName: string;
|
|
||||||
department: string;
|
|
||||||
lastLogin: string;
|
|
||||||
status: 'active' | 'disabled';
|
|
||||||
avatar: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Role {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
icon: string;
|
|
||||||
iconClass: string;
|
|
||||||
userCount: number;
|
|
||||||
permissionCount: number;
|
|
||||||
permissions: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DBInstance {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
status: 'online' | 'offline' | 'warning';
|
|
||||||
dbType: string;
|
|
||||||
version: string;
|
|
||||||
env: 'prod' | 'test' | 'dev';
|
|
||||||
connections: number;
|
|
||||||
qps: number;
|
|
||||||
latency: number;
|
|
||||||
cpu: number;
|
|
||||||
memory: number;
|
|
||||||
disk: number;
|
|
||||||
uptime: string;
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Ticket {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
dbType: string;
|
|
||||||
instance: string;
|
|
||||||
applicant: string;
|
|
||||||
approver?: string;
|
|
||||||
status: 'pending' | 'reviewing' | 'approved' | 'rejected' | 'executing' | 'success' | 'failed' | 'rollback';
|
|
||||||
priority: 'high' | 'medium' | 'low';
|
|
||||||
sql: string;
|
|
||||||
rollbackSql?: string;
|
|
||||||
reason: string;
|
|
||||||
createTime: string;
|
|
||||||
updateTime: string;
|
|
||||||
executeTime?: string;
|
|
||||||
auditScore?: number;
|
|
||||||
auditResults?: AuditResultItem[];
|
|
||||||
workflowSteps?: WorkflowStep[];
|
|
||||||
approvalRecords?: ApprovalRecord[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuditResultItem {
|
|
||||||
rule: string;
|
|
||||||
level: 'error' | 'warning' | 'info' | 'success';
|
|
||||||
description: string;
|
|
||||||
suggestion?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowStep {
|
|
||||||
name: string;
|
|
||||||
status: 'completed' | 'active' | 'pending';
|
|
||||||
time?: string;
|
|
||||||
assignee?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApprovalRecord {
|
|
||||||
step: number;
|
|
||||||
approver: string;
|
|
||||||
action: 'approve' | 'reject';
|
|
||||||
comment: string;
|
|
||||||
time: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueryAudit {
|
|
||||||
id: string;
|
|
||||||
sql: string;
|
|
||||||
sqlPreview: string;
|
|
||||||
dbType: string;
|
|
||||||
instance: string;
|
|
||||||
user: string;
|
|
||||||
executeTime: number;
|
|
||||||
rowCount: number;
|
|
||||||
scanRows: number;
|
|
||||||
affectedRows: number;
|
|
||||||
status: 'success' | 'slow' | 'failed';
|
|
||||||
hasSensitive: boolean;
|
|
||||||
timestamp: string;
|
|
||||||
resultColumns?: ResultColumn[];
|
|
||||||
resultSet?: Record<string, unknown>[];
|
|
||||||
clientIp: string;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResultColumn {
|
|
||||||
prop: string;
|
|
||||||
label: string;
|
|
||||||
sensitive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuditRule {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
level: 'error' | 'warning' | 'info';
|
|
||||||
dbTypes: string[];
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
category: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Workflow {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
dbTypes: string[];
|
|
||||||
steps: number;
|
|
||||||
mode: 'any' | 'all';
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
conditions?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NotificationChannel {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
type: string;
|
|
||||||
icon: string;
|
|
||||||
description: string;
|
|
||||||
enabled: boolean;
|
|
||||||
webhook?: string;
|
|
||||||
events?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DataPermission {
|
|
||||||
id: number;
|
|
||||||
user: string;
|
|
||||||
instance: string;
|
|
||||||
database: string;
|
|
||||||
table: string;
|
|
||||||
columns: string;
|
|
||||||
rowFilter: string;
|
|
||||||
operations: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PermissionNode {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
children?: PermissionNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardStats {
|
|
||||||
todayTickets: number;
|
|
||||||
todayTicketsTrend: number;
|
|
||||||
pendingApproval: number;
|
|
||||||
pendingTrend: number;
|
|
||||||
queryAuditCount: number;
|
|
||||||
queryTrend: number;
|
|
||||||
slowQueries: number;
|
|
||||||
slowQueryTrend: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MenuItem {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
icon: string;
|
|
||||||
section?: string;
|
|
||||||
children?: MenuItem[];
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
module.exports = {
|
|
||||||
darkMode: ["class"],
|
|
||||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
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) / <alpha-value>)",
|
|
||||||
foreground: "hsl(var(--destructive-foreground) / <alpha-value>)",
|
|
||||||
},
|
|
||||||
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))",
|
|
||||||
},
|
|
||||||
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: {
|
|
||||||
xl: "calc(var(--radius) + 4px)",
|
|
||||||
lg: "var(--radius)",
|
|
||||||
md: "calc(var(--radius) - 2px)",
|
|
||||||
sm: "calc(var(--radius) - 4px)",
|
|
||||||
xs: "calc(var(--radius) - 6px)",
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
xs: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
|
||||||
},
|
|
||||||
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" },
|
|
||||||
},
|
|
||||||
"caret-blink": {
|
|
||||||
"0%,70%,100%": { opacity: "1" },
|
|
||||||
"20%,50%": { opacity: "0" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
animation: {
|
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
|
||||||
"caret-blink": "caret-blink 1.25s ease-out infinite",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [require("tailwindcss-animate")],
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"target": "ES2022",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["vite/client"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"./src/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{
|
|
||||||
"path": "./tsconfig.app.json"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "./tsconfig.node.json"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
||||||
"target": "ES2023",
|
|
||||||
"lib": ["ES2023"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"types": ["node"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import path from "path"
|
|
||||||
import react from "@vitejs/plugin-react"
|
|
||||||
import { defineConfig } from "vite"
|
|
||||||
import { inspectAttr } from 'kimi-plugin-inspect-react'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
base: './',
|
|
||||||
plugins: [inspectAttr(), react()],
|
|
||||||
server: {
|
|
||||||
port: 3000,
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "./src"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user