feat: 初始化 React + shadcn/ui 项目 - 列阵工作台重构

This commit is contained in:
admin
2026-06-02 00:46:16 +08:00
commit d962256165
68 changed files with 17092 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# 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?
+135
View File
@@ -0,0 +1,135 @@
## 1. 产品概述
列阵(Array)人类工作台——一个让人机协作变得自然的实时工作台。AI Worker 和 Human Worker 在同一个任务中通过消息实时协作,人类通过本前端查看任务、发送消息、上传附件、评价 AI 表现。
- 目标用户:需要与 AI 协作完成任务的碳基工作者(运营、标注、审核、开发等)
- 核心价值:让人机协作像人类之间聊天一样自然,同时保留任务管理的结构化能力
## 2. 核心功能
### 2.1 用户角色
| 角色 | 注册方式 | 核心权限 |
|------|----------|----------|
| Human Worker | API Key / 用户名密码 / 邀请码注册 | 创建任务、发送消息、上传附件、评价 AI、管理组织 |
| AI Worker | 后端自动注册 | 自动接收任务、回复消息、提交结果 |
| 组织 Admin | 组织创建者自动获得 | 管理成员、分类、邀请码 |
### 2.2 功能模块
1. **登录页**:API Key 登录、用户名密码登录、注册新账户、邀请码注册
2. **主工作台**:三栏布局——成员面板、任务列表、聊天详情
3. **消息系统**:公共消息、私密消息、@提及、引用回复、附件上传、乐观消息
4. **任务管理**:创建任务、状态流转、参与者管理、分类筛选、置顶
5. **组织管理**:组织切换、成员管理、邀请码、角色权限
6. **个人中心**:资料编辑、快捷键设置、主题切换、语言切换
7. **评价系统**:任务完成后对 AI 评分、能力标签、评价汇总
### 2.3 页面详情
| 页面 | 模块 | 功能描述 |
|------|------|----------|
| 登录页 | 服务器地址输入 | 输入后端 API 地址,支持历史记录 |
| 登录页 | API Key 登录 | 输入 API Key 直接登录 |
| 登录页 | 用户名密码登录 | 切换到密码模式登录 |
| 登录页 | 注册表单 | 名称、用户名、密码、邀请码、组织、位置、简介、标签、能力 |
| 主工作台 | 成员面板 | 显示在线/离线 AI 和 Human Worker,点击发起对话 |
| 主工作台 | 任务列表 | 全部/我发起的/我参与的 Tab,分类筛选,置顶,排序 |
| 主工作台 | 聊天详情 | 消息流、引用回复、@提及、私密消息、附件、状态切换 |
| 主工作台 | 任务抽屉 | 参与者列表、添加/移除成员、评价入口 |
| 组织管理 | 组织切换器 | 顶栏下拉切换组织 |
| 组织管理 | 组织详情弹窗 | 成员列表、邀请码管理、角色变更、重命名 |
| 个人中心 | 资料编辑 | 名称、简介、组织、位置、标签、能力、最大并发 |
| 个人中心 | 快捷键设置 | 发送键(Enter/Ctrl+Enter)、换行键配置 |
| 评价系统 | 评价提交 | 1-5 星评分、能力标签、评论、建议 |
| 评价系统 | 评价汇总 | 平均分、总评价数、常见标签、评价列表 |
## 3. 核心流程
### 3.1 登录与初始化
用户打开应用 → 输入服务器地址和凭据 → SDK 认证获取 JWT → 加载 Workers/Tasks/Categories → 建立 WebSocket 连接 → 进入主工作台
```mermaid
flowchart TD
A["用户打开应用"] --> B{"本地有凭据?"}
B -->|是| C["自动登录"]
B -->|否| D["显示登录页"]
D --> E["输入凭据"]
E --> F["SDK 认证"]
C --> F
F --> G{"认证成功?"}
G -->|否| D
G -->|是| H["加载 Workers + Tasks"]
H --> I["建立 WebSocket"]
I --> J["进入主工作台"]
```
### 3.2 消息收发
用户选择任务 → 加载历史消息 → 输入回复 → 乐观插入 pending 消息 → API 发送 → 成功移除 pending / 失败显示重试 → WebSocket 推送新消息 → 更新消息列表
```mermaid
flowchart TD
A["选择任务"] --> B["加载历史消息"]
B --> C["用户输入回复"]
C --> D["乐观插入 pending 消息"]
D --> E["API 发送"]
E --> F{"发送成功?"}
F -->|是| G["移除 pending"]
F -->|否| H["标记 failed,显示重试"]
G --> I["WS 推送确认"]
I --> J["更新消息列表"]
```
### 3.3 人机协作
人类创建任务 → 邀请 AI Worker → AI 自动接收 → AI 发送处理中消息 → AI 提交结果 → 人类查看结果 → 人类评价 AI 表现
```mermaid
flowchart TD
A["人类创建任务"] --> B["邀请 AI Worker"]
B --> C["AI 自动接收任务"]
C --> D["AI 发送处理中消息"]
D --> E["AI 提交结果"]
E --> F["人类查看结果"]
F --> G["人类评价 AI"]
```
## 4. 用户界面设计
### 4.1 设计风格
- **主色调**:深空蓝(#0f172a)为底,电光蓝(#3b82f6)为强调色,搭配 zinc 灰阶
- **按钮风格**:圆角(rounded-lg),主按钮实色填充,次按钮边框+hover 填充
- **字体**:JetBrains Mono(代码/ID)+ Noto Sans SC(中文正文)+ Geist(英文正文)
- **布局风格**:三栏布局,左栏 240px 固定,中栏 flex 自适应,右栏 480px 可拖拽
- **图标**:Lucide React 图标库
- **动画**:消息入场 slide-up + fade,状态切换 scale 微动,LED 矩阵空状态动画
### 4.2 页面设计概览
| 页面 | 模块 | UI 元素 |
|------|------|---------|
| 登录页 | 整体 | 居中卡片,深色渐变背景,LED Logo 动画,表单居中 |
| 主工作台 | 顶栏 | Logo + 品牌名 + 组织切换 + 身份徽章 + 主题/语言/刷新/退出按钮 + WS 状态灯 |
| 主工作台 | 成员面板 | 搜索框 + 在线/离线分组 + AI/Human 标签 + 在线状态圆点 |
| 主工作台 | 任务列表 | Tab 栏 + 分类筛选 + 任务卡片(标题+最后消息+参与者头像+未读徽章+置顶图标) |
| 主工作台 | 聊天详情 | 标题栏(可编辑)+ 状态/优先级徽章 + 公共/私密 Tab + 消息流 + 回复输入栏 |
| 主工作台 | 消息气泡 | AI 左对齐蓝色渐变头像 + Human 右对齐绿色头像 + 系统消息居中灰色胶囊 |
| 主工作台 | 附件渲染 | 图片内联预览 + 视频缩略图+播放 + 音频播放器 + 文件卡片 |
| 组织管理 | 组织详情 | Sheet 侧滑面板 + 成员列表 + 邀请码表格 + 角色下拉 |
| 个人中心 | 资料编辑 | Dialog 弹窗 + 表单字段 + 标签输入 + 快捷键选择器 |
| 评价系统 | 评价提交 | Dialog 弹窗 + 星级评分 + 标签输入 + 文本域 |
| 评价系统 | 评价汇总 | Dialog 弹窗 + 统计卡片 + 标签云 + 评价列表 |
### 4.3 响应式设计
- **桌面端(≥1024px)**:三栏并排,右栏可拖拽调整宽度
- **平板端(768~1023px)**:左栏收为窄竖条图标栏,中栏+右栏双栏
- **移动端(<768px)**:单栏切换,任务列表和聊天详情互斥显示,底部导航
### 4.4 空状态设计
- 无任务时:16×16 LED 矩阵动画(贪吃蛇、俄罗斯方块、烟花、文字滚动等)
- 无消息时:引导卡片"添加参与者开始协作"
- 无通知时:简洁空状态图标+文案
+459
View File
@@ -0,0 +1,459 @@
## 1. 架构设计
```mermaid
flowchart TB
subgraph Frontend["前端 (React + Vite)"]
UI["shadcn/ui 组件层"]
Pages["页面层"]
Hooks["Hooks 层"]
Store["Zustand 状态管理"]
SDK["array-sdk 通信层"]
end
subgraph Backend["后端 (FastAPI + SQLite)"]
API["REST API"]
WS["WebSocket"]
end
UI --> Pages
Pages --> Hooks
Hooks --> Store
Hooks --> SDK
Store --> SDK
SDK --> API
SDK --> WS
```
## 2. 技术说明
- **前端框架**:React 18 + TypeScript + Vite 6
- **UI 组件库**:shadcn/ui(Radix UI + Tailwind CSS)
- **状态管理**:Zustand(轻量、无 boilerplate、支持中间件)
- **样式方案**:Tailwind CSS 4 + CSS 变量主题系统
- **图标**:Lucide React
- **动画**:Framer Motion
- **Markdown 渲染**:react-markdown + remark-gfm + rehype-highlight
- **HTTP/WebSocket**:array-sdk(从旧项目迁移,纯 JS 无框架依赖)
- **路由**:React Router DOM v7(Hash 模式,兼容静态部署)
- **国际化**:自定义 i18n hook(中/英双语)
- **PWA**:vite-plugin-pwa
- **项目初始化**:vite-init react-ts 模板
## 3. 路由定义
| 路由 | 用途 |
|------|------|
| `/` | 登录页(未登录时)或主工作台(已登录时) |
| `/task/:taskId` | 主工作台,自动选中指定任务 |
## 4. API 定义
本项目为纯前端,所有 API 调用通过 array-sdk 与后端通信。SDK 封装层提供以下接口:
### 4.1 认证
```typescript
interface AuthAPI {
login(serverUrl: string, apiKey: string): Promise<Worker>
loginByPassword(serverUrl: string, username: string, password: string): Promise<Worker>
register(serverUrl: string, name: string, username: string, password: string, profile?: RegisterProfile): Promise<Worker>
autoLogin(): Promise<Worker | null>
logout(): void
}
```
### 4.2 Worker
```typescript
interface WorkerAPI {
getWorkers(params?: { orgId?: string }): Promise<Worker[]>
getWorkerProfile(workerId: string): Promise<Worker>
updateProfile(data: Partial<WorkerProfile>): Promise<Worker>
getWorkerWorkload(workerId: string): Promise<Workload>
discoverWorkers(options: DiscoverOptions): Promise<DiscoverResult>
}
```
### 4.3 Task
```typescript
interface TaskAPI {
getTasks(params?: { orgId?: string; categoryId?: string }): Promise<Task[]>
getTask(taskId: string): Promise<Task>
createTask(title: string, participantIds: string[], priority?: string, extra?: CreateTaskExtra): Promise<Task>
updateTaskStatus(taskId: string, status: TaskStatus): Promise<Task>
updateTaskTitle(taskId: string, title: string): Promise<Task>
addParticipant(taskId: string, workerId: string): Promise<Participant[]>
removeParticipant(taskId: string, workerId: string): Promise<void>
}
```
### 4.4 Message
```typescript
interface MessageAPI {
getMessages(taskId: string, scope?: string, includePrivate?: boolean, limit?: number, page?: number): Promise<Message[]>
sendMessage(taskId: string, content: string, options?: SendMessageOptions): Promise<Message>
sendMessageWithAttachments(taskId: string, content: string, fileIds: string[], options?: SendMessageOptions): Promise<Message>
}
```
### 4.5 Attachment
```typescript
interface AttachmentAPI {
uploadAttachment(taskId: string, file: File): Promise<Attachment>
listAttachments(taskId: string): Promise<{ attachments: Attachment[]; total: number }>
deleteAttachment(taskId: string, attachmentId: string): Promise<void>
getAttachmentUrl(taskId: string, attachmentId: string): string
}
```
### 4.6 Organization
```typescript
interface OrganizationAPI {
listMyOrganizations(): Promise<Organization[]>
getOrganization(orgId: string): Promise<Organization>
createOrganization(name: string, description?: string): Promise<Organization>
updateOrganization(orgId: string, data: Partial<Organization>): Promise<Organization>
createInvite(orgId: string, options?: InviteOptions): Promise<OrgInvite>
listInvites(orgId: string): Promise<OrgInvite[]>
joinOrganization(inviteCode: string): Promise<JoinResult>
listOrgMembers(orgId: string): Promise<OrgMember[]>
removeOrgMember(orgId: string, workerId: string): Promise<void>
updateMemberRole(orgId: string, workerId: string, role: 'admin' | 'member'): Promise<OrgMember>
renameOrgMember(orgId: string, workerId: string, name: string): Promise<OrgMember>
}
```
### 4.7 Category
```typescript
interface CategoryAPI {
createCategory(name: string, orgId?: string): Promise<Category>
listCategories(orgId?: string): Promise<Category[]>
updateCategory(categoryId: string, opts: Partial<Category>): Promise<Category>
deleteCategory(categoryId: string): Promise<boolean>
}
```
### 4.8 Review
```typescript
interface ReviewAPI {
createReview(workerId: string, params: CreateReviewParams): Promise<Review>
getWorkerReviews(workerId: string, options?: PaginationOptions): Promise<ReviewListResult>
getReviewSummary(workerId: string): Promise<ReviewSummary>
}
```
### 4.9 Notification
```typescript
interface NotificationAPI {
getNotifications(isRead?: boolean): Promise<Notification[]>
markNotificationRead(notificationId: string): Promise<void>
markAllNotificationsRead(): Promise<void>
}
```
### 4.10 WebSocket
```typescript
interface WebSocketAPI {
startWebSocket(callbacks: WSCallbacks): () => void
stopWebSocket(): void
}
interface WSCallbacks {
onEvent: (event: WSEvent) => void
onConnected: () => void
onDisconnected: () => void
onError: (err: Error) => void
}
type WSEventType = 'task.message' | 'participant.joined' | 'task.updated' | 'worker_status_changed' | 'tasks.pending_recovery'
```
## 5. 数据模型
```mermaid
erDiagram
Organization ||--o{ Category : "has"
Organization ||--o{ OrgMember : "has"
Organization ||--o{ OrgInvite : "has"
Worker ||--o{ Task : "creates"
Worker ||--o{ Review : "receives"
Task ||--o{ Participant : "has"
Task ||--o{ Message : "has"
Task ||--o{ Attachment : "has"
Task }o--|| Category : "belongs_to"
Message }o--o| Message : "reply_to"
Message }o--o| Worker : "private_to"
Worker {
string id PK
string name
string type
string status
string organization
string location
string description
string[] tags
object capabilities
number maxConcurrentTasks
}
Task {
string id PK
string title
string description
string status
string priority
string creator_id FK
string org_id FK
string category_id FK
datetime created_at
datetime last_message_at
}
Message {
string id PK
string task_id FK
string sender_id FK
string content
string type
string reply_to_id FK
string private_to_id FK
datetime created_at
}
Attachment {
string id PK
string task_id FK
string filename
string content_type
number size
string url
string thumbnail_url
number width
number height
number duration
}
Review {
string id PK
string worker_id FK
string task_id FK
string reviewer_id FK
number rating
string[] capability_tags
string comment
string suggestion
}
```
## 6. 项目目录结构
```
human-adapter-react/
├── src/
│ ├── components/ # UI 组件
│ │ ├── ui/ # shadcn/ui 基础组件
│ │ ├── layout/ # 布局组件
│ │ │ ├── TopBar.tsx
│ │ │ ├── WorkersPanel.tsx
│ │ │ ├── TaskList.tsx
│ │ │ └── Resizer.tsx
│ │ ├── chat/ # 聊天相关组件
│ │ │ ├── ChatDetail.tsx
│ │ │ ├── MessageList.tsx
│ │ │ ├── MessageBubble.tsx
│ │ │ ├── MessageInput.tsx
│ │ │ ├── MentionPopup.tsx
│ │ │ ├── ReplyPreview.tsx
│ │ │ ├── AttachmentRenderer.tsx
│ │ │ ├── DMTab.tsx
│ │ │ └── ScrollToBottom.tsx
│ │ ├── task/ # 任务相关组件
│ │ │ ├── TaskCard.tsx
│ │ │ ├── TaskComposer.tsx
│ │ │ ├── TaskDrawer.tsx
│ │ │ ├── StatusSelector.tsx
│ │ │ └── ParticipantManager.tsx
│ │ ├── org/ # 组织相关组件
│ │ │ ├── OrgSwitcher.tsx
│ │ │ ├── OrgDetailSheet.tsx
│ │ │ └── InviteManager.tsx
│ │ ├── profile/ # 个人中心组件
│ │ │ ├── ProfileDialog.tsx
│ │ │ └── ShortcutSettings.tsx
│ │ ├── review/ # 评价组件
│ │ │ ├── ReviewDialog.tsx
│ │ │ └── ReviewSummary.tsx
│ │ ├── auth/ # 登录组件
│ │ │ └── LoginPage.tsx
│ │ └── shared/ # 共享组件
│ │ ├── LedLogo.tsx
│ │ ├── LedMatrix.tsx
│ │ ├── RobotIcon.tsx
│ │ ├── WorkerAvatar.tsx
│ │ ├── Toast.tsx
│ │ └── PwaInstallBanner.tsx
│ ├── hooks/ # 自定义 Hooks
│ │ ├── useAuth.ts
│ │ ├── useMessages.ts
│ │ ├── useWorkers.ts
│ │ ├── useTasks.ts
│ │ ├── useOrganization.ts
│ │ ├── useNotifications.ts
│ │ ├── useReviews.ts
│ │ ├── useProfile.ts
│ │ ├── useWebSocket.ts
│ │ ├── useI18n.ts
│ │ ├── useTheme.ts
│ │ ├── useAutoScroll.ts
│ │ └── useDrafts.ts
│ ├── stores/ # Zustand Store
│ │ ├── authStore.ts
│ │ ├── appStore.ts
│ │ └── uiStore.ts
│ ├── lib/ # 工具库
│ │ ├── sdk.ts # array-sdk 封装
│ │ ├── api.ts # API 薄封装层
│ │ ├── i18n.ts # 国际化字典
│ │ ├── utils.ts # 通用工具函数
│ │ └── constants.ts # 常量定义
│ ├── pages/ # 页面
│ │ ├── LoginPage.tsx
│ │ └── WorkbenchPage.tsx
│ ├── App.tsx
│ ├── main.tsx
│ └── index.css # Tailwind 入口 + CSS 变量
├── public/
├── sdk-js/ # array-sdk 源码(从旧项目迁移)
│ ├── src/
│ │ ├── client.ts
│ │ ├── adapter.ts
│ │ └── index.ts
│ └── package.json
├── .trae/documents/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.js
├── components.json # shadcn/ui 配置
└── postcss.config.js
```
## 7. 关键架构决策
### 7.1 状态管理:Zustand vs Context
选择 Zustand 的理由:
- 无 Provider 包裹,任何组件直接 `useStore()` 即可
- 支持 `subscribeWithSelector` 中间件,精确控制重渲染
- 支持 `persist` 中间件,自动持久化到 localStorage
- 比 Redux 少 90% boilerplate,比 Jotai 更适合复杂状态
```typescript
// appStore.ts 示例
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface AppState {
workers: Worker[]
tasks: Task[]
selectedTaskId: string | null
wsConnected: boolean
setWorkers: (workers: Worker[]) => void
setTasks: (tasks: Task[]) => void
selectTask: (id: string | null) => void
}
export const useAppStore = create<AppState>()(
persist(
(set) => ({
workers: [],
tasks: [],
selectedTaskId: null,
wsConnected: false,
setWorkers: (workers) => set({ workers }),
setTasks: (tasks) => set({ tasks }),
selectTask: (id) => set({ selectedTaskId: id }),
}),
{ name: 'array-app-store', partialize: (s) => ({ selectedTaskId: s.selectedTaskId }) }
)
)
```
### 7.2 消息系统:自定义 Hook
不使用 Vercel AI SDK 的 `useChat()`,因为:
- 本项目的消息模型更复杂(私密消息、@提及、引用回复、附件)
- WebSocket 事件格式是后端自定义的
- 需要精确控制乐观消息的生命周期
自定义 `useMessages` Hook 借鉴 AI SDK 的状态机思想:
```typescript
type MessageStatus = 'idle' | 'sending' | 'streaming' | 'error'
interface UseMessagesReturn {
messages: Message[]
pendingMessages: PendingMessage[]
isLoading: boolean
isAtBottom: boolean
sendMessage: (content: string, options?: SendOptions) => Promise<void>
retryMessage: (pendingId: string) => Promise<void>
cancelMessage: (pendingId: string) => void
loadMore: () => Promise<void>
hasMore: boolean
scrollToBottom: () => void
}
```
### 7.3 主题系统:Tailwind CSS 变量
```css
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--accent: 221.2 83.2% 53.3%;
/* ... shadcn 默认变量 */
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--accent: 217.2 91.2% 59.8%;
}
```
切换主题只需在 `<html>` 上添加/移除 `dark` 类,所有组件自动响应。
### 7.4 SDK 迁移策略
array-sdk 是纯 JavaScript,无框架依赖,直接迁移:
- `sdk-js/` 目录整体复制到新项目
- `package.json` 中 `"array-sdk": "file:./sdk-js"` 保持不变
- API 封装层 `lib/api.ts` 从旧项目 `api/index.js` 翻译为 TypeScript
- 所有方法签名添加 TypeScript 类型
### 7.5 组件拆分原则
旧项目 TaskDetail.vue 72KB → 拆分为 8+ 个子组件:
| 旧代码位置 | 新组件 | 职责 |
|------------|--------|------|
| 消息流模板 | `MessageList.tsx` | 消息列表渲染 + 时间分割线 |
| 单条消息 | `MessageBubble.tsx` | 消息气泡 + 头像 + 引用 + 附件 |
| 回复输入栏 | `MessageInput.tsx` | 输入框 + 发送按钮 + 快捷键 |
| @提及弹窗 | `MentionPopup.tsx` | 搜索 + 选择参与者 |
| 私密消息 | `DMTab.tsx` | 私聊通道列表 + 消息 |
| 附件渲染 | `AttachmentRenderer.tsx` | 图片/视频/音频/文件智能渲染 |
| 任务抽屉 | `TaskDrawer.tsx` | 参与者 + 详情 + 评价 |
| 状态选择器 | `StatusSelector.tsx` | 状态下拉切换 |
+72
View File
@@ -0,0 +1,72 @@
# 列阵工作台 (React版
这是一个基于 React + TypeScript + Vite + shadcn/ui 构建的人机协作工作台,让 AI 工作者和人类工作者在同一平台上实时协作。
## 技术栈
- **前端框架**: React 18.3 + TypeScript
- **构建工具**: Vite 6
- **UI 组件**: shadcn/ui (Radix UI + Tailwind CSS)
- **状态管理**: Zustand
- **动画**: Framer Motion
- **路由**: React Router DOM v7
- **图标**: Lucide React
## 功能
- 🔐 **身份验证(API Key 和密码登录)
- 👥 **成员面板(在线/离线状态显示)
- 📋 **任务管理(任务列表、任务状态、优先级)
- 💬 **实时聊天(公共消息、私密消息、附件)
- 🎯 **AI 协作(AI 工作者参与任务)
- 🌙 **深色主题(默认)
- 📱 **响应式设计
## 快速开始
### 安装依赖
```bash
npm install --legacy-peer-deps
```
### 开发
```bash
npm run dev
```
### 构建
```bash
npm run build
```
## 项目结构
```
human-adapter-react/
├── src/
│ ├── components/ # UI 组件
│ │ ├── shared/ # 共享组件
│ │ └── ui/ # shadcn 组件
│ ├── hooks/ # 自定义 Hooks
│ ├── lib/ # 工具函数
│ ├── pages/ # 页面组件
│ ├── stores/ # Zustand 状态管理
│ ├── App.tsx
│ └── main.tsx
│ └── index.css
├── sdk-js/ # Array SDK(从原项目迁移)
├── .trae/documents/ # PRD 和架构文档
├── vite.config.ts
├── tailwind.config.js
├── tsconfig.json
└── package.json
```
## 注意事项
- 使用 `--legacy-peer-deps` 安装依赖,解决 peer dependencies 冲突
- 默认使用深色主题
- 项目包含完整的类型支持
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+28
View File
@@ -0,0 +1,28 @@
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'
export default tseslint.config(
{ ignores: ['dist', 'src/lib/sdk.ts'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Trae Project</title>
<script type="module">
if (import.meta.hot?.on) {
import.meta.hot.on('vite:error', (error) => {
if (error.err) {
console.error(
[error.err.message, error.err.frame].filter(Boolean).join('\n'),
)
}
})
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+9945
View File
File diff suppressed because it is too large Load Diff
+53
View File
@@ -0,0 +1,53 @@
{
"name": "human-adapter-react",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
"check": "tsc -b --noEmit"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@fontsource-variable/geist": "^5.2.9",
"array-sdk": "file:./sdk-js",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"framer-motion": "^12.40.0",
"lucide-react": "^0.511.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.3.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"shadcn": "^4.8.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.25.0",
"@types/node": "^22.15.30",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.4.1",
"autoprefixer": "^10.4.21",
"babel-plugin-react-dev-locator": "^1.0.0",
"eslint": "^9.25.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "~5.8.3",
"typescript-eslint": "^8.30.1",
"vite": "^6.3.5",
"vite-plugin-trae-solo-badge": "^1.0.0",
"vite-tsconfig-paths": "^5.1.4"
}
}
+10
View File
@@ -0,0 +1,10 @@
/** WARNING: DON'T EDIT THIS FILE */
/** WARNING: DON'T EDIT THIS FILE */
/** WARNING: DON'T EDIT THIS FILE */
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+4
View File
@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" fill="#0A0B0D"/>
<path d="M26.6677 23.7149H8.38057V20.6496H5.33301V8.38159H26.6677V23.7149ZM8.38057 20.6496H23.6201V11.4482H8.38057V20.6496ZM16.0011 16.0021L13.8461 18.1705L11.6913 16.0021L13.8461 13.8337L16.0011 16.0021ZM22.0963 16.0008L19.9414 18.1691L17.7865 16.0008L19.9414 13.8324L22.0963 16.0008Z" fill="#32F08C"/>
</svg>

After

Width:  |  Height:  |  Size: 453 B

+41
View File
@@ -0,0 +1,41 @@
{
"name": "array-sdk",
"version": "0.7.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "array-sdk",
"version": "0.7.0",
"license": "MIT",
"dependencies": {
"ws": "^8.20.0"
},
"optionalDependencies": {
"ws": "^8.20.0"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"name": "array-sdk",
"version": "0.7.0",
"description": "列阵协作系统 - JavaScript SDK",
"main": "src/index.js",
"type": "module",
"license": "MIT",
"optionalDependencies": {
"ws": "^8.20.0"
}
}
+291
View File
@@ -0,0 +1,291 @@
/**
* BaseAdapter - 适配器基类,封装 Worker 的完整生命周期
*
* 关于 Worker 描述(description)的推荐格式:
* 在注册 Worker 或更新简历时,description 字段建议按以下结构编写,
* 方便其他 Worker 和人类快速理解你的能力。
*
* 推荐格式:
* [一句话功能概述]
*
* 能力:
* - 能力1
* - 能力2
*
* 使用方式:
* [说明怎么跟这个 Worker 交互]
*
* 限制:(可选)
* - 限制1
*
* 示例:
* "多语言实时翻译工具,支持中英日韩互译。\n\n"
* "能力:\n"
* "- 文本翻译(中/英/日/韩)\n"
* "- 自动检测源语言\n\n"
* "使用方式:\n"
* "@提及并发送需要翻译的文本,默认翻译为中文。\n\n"
* "限制:\n"
* "- 单次最多翻译 5000 字"
*
* 实际使用时,可通过 client.updateProfile({ description: "..." }) 或注册时传入。
*
* ============================================================================
* 关于组织(Organization)功能:
* ============================================================================
*
* 组织是多对多关系,一个 Worker 可以属于多个组织,一个组织可以有多个 Worker。
* 组织用于隔离任务和 Worker,便于团队协作。
*
* 组织相关 API(通过 client 调用):
*
* 组织管理:
* - client.createOrganization(name, description) - 创建组织,创建者自动成为 admin
* - client.listMyOrganizations() - 列出我加入的所有组织
* - client.getOrganization(orgId) - 获取组织详情
* - client.updateOrganization(orgId, { name, description }) - 更新组织信息(需 admin 权限)
*
* 邀请码管理:
* - client.createInvite(orgId, { expiresAt, maxUses }) - 创建邀请码(需 admin 权限)
* - client.listInvites(orgId) - 列出组织的邀请码(需 admin 权限)
* - client.joinOrganization(inviteCode) - 通过邀请码加入组织
*
* 成员管理:
* - client.listOrgMembers(orgId) - 列出组织成员
* - client.removeOrgMember(orgId, workerId) - 移除成员(需 admin 权限,不能移除自己)
* - client.updateMemberRole(orgId, workerId, role) - 修改成员角色(需 admin 权限)
*
* 组织过滤:
* - client.listWorkers({ orgId }) - 按组织过滤 Worker 列表
* - client.listTasks({ orgId }) - 按组织过滤任务列表
* - client.createTask({ ..., orgId }) - 创建任务时指定所属组织
*
* 角色说明:
* - admin: 组织管理员,可以管理组织信息、邀请码、成员角色
* - member: 普通成员,可以查看组织和参与任务
*/
import { ArrayClient } from './client.js';
export class BaseAdapter {
/**
* @param {string} serverUrl - 服务器地址
* @param {string} apiKey - API 密钥
*/
constructor(serverUrl, apiKey) {
this.serverUrl = serverUrl;
this.apiKey = apiKey;
this.client = null;
this._running = false;
this._reconnectDelay = 5000;
}
/**
* 被邀请加入新任务时的处理逻辑。子类必须实现。
* @param {Object} task - 任务对象
* @throws {Error} 如果未实现
*/
async onTaskInvited(task) {
throw new Error('Not implemented');
}
/**
* 兼容别名,推荐使用 onTaskInvited
* @param {Object} task - 任务对象
*/
async onTaskAssigned(task) {
return this.onTaskInvited(task);
}
/**
* 收到任务消息时的处理逻辑。子类可选覆盖。
* @param {string} taskId - 任务 ID
* @param {Object} payload - 消息载荷
*/
async onTaskMessage(taskId, payload) {
// 子类可覆盖
}
/**
* 任务状态/内容变更时的处理逻辑。子类可选覆盖。
* @param {string} taskId - 任务 ID
* @param {Object} changes - 变更内容
*/
async onTaskUpdated(taskId, changes) {
// 子类可覆盖
}
/**
* WebSocket 连上后收到待恢复任务列表时调用。子类可覆盖。
* @param {Array} tasks - 待恢复任务列表,格式为 [{id, title, status}, ...]
*/
async onPendingRecovery(tasks) {
// 子类可覆盖
}
/**
* 连接成功后的回调。子类可选覆盖。
*/
async onConnected() {
// 子类可覆盖
}
/**
* WebSocket 断开时的回调,子类可覆盖
*/
async onDisconnected() {
// 子类可覆盖
}
/**
* 启动 Worker 完整生命周期。
*
* 外层重连:当 HTTP 连接或整个 WebSocket 会话断开时(如服务端宕机),
* 在这里做指数退避重连(5s 起步,最大 120s),每次重连会重新认证、
* 处理离线通知、再启动 WebSocket 监听。
*
* 内层重连由 client.listen() 负责:当 WebSocket 连接断开但 HTTP
* 会话仍然有效时,listen() 内部自动重连(2s 起步,最大 60s)。
*
* 两层的区别:
* - 内层(listen):仅 WebSocket 断线重连,不涉及重新认证
* - 外层(run):完整的会话重建,包括重新认证和离线通知恢复
*/
async run() {
this._running = true;
this._reconnectDelay = 5000;
while (this._running) {
try {
this.client = new ArrayClient(this.serverUrl, this.apiKey);
await this.client.connect();
this._reconnectDelay = 5000; // 重置退避
console.log(`Worker ${this.client.workerId} 已连接到平台`);
// 处理离线期间的通知
await this._processOfflineNotifications();
// 启动 WebSocket 监听
console.log('开始监听实时事件...');
const wsPromise = new Promise((resolve, reject) => {
const closeFn = this.client.listen({
onEvent: (event) => {
this._handleEvent(event).catch(err => {
console.error('Error handling event:', err);
});
},
onConnected: () => {
this.onConnected().catch(err => {
console.error('Error in onConnected:', err);
});
},
onDisconnected: () => {
// WebSocket 断开,结束当前循环,外层会重连
resolve();
},
onError: (err) => {
console.error('WebSocket error:', err);
}
});
});
// 等待 WebSocket 连接断开
await wsPromise;
// 正常断开(非异常)
await this.onDisconnected();
} catch (err) {
if (!this._running) break;
console.warn(`连接失败: ${err.message},${this._reconnectDelay / 1000}s 后重试`);
await this.onDisconnected();
// 等待后重试
await this._sleep(this._reconnectDelay);
this._reconnectDelay = Math.min(this._reconnectDelay * 2, 120000);
}
}
}
/**
* 停止适配器
*/
stop() {
this._running = false;
if (this.client) {
this.client.disconnect();
}
}
/**
* 处理离线期间积累的通知
* @private
*/
async _processOfflineNotifications() {
const { notifications, total } = await this.client.getNotifications({ isRead: false });
if (notifications && notifications.length > 0) {
console.log(`处理 ${notifications.length} 条离线通知...`);
for (const n of notifications) {
const event = {
eventType: n.event_type,
payload: n.payload || {},
timestamp: n.created_at
};
await this._handleEvent(event);
await this.client.markNotificationRead(n.id);
}
}
}
/**
* 分发事件到对应的处理方法
* @private
* @param {Object} event - 事件对象
*/
async _handleEvent(event) {
const { eventType, payload } = event;
if (eventType === 'task.assigned') {
const taskId = payload?.task_id;
if (taskId) {
const task = await this.client.getTask(taskId);
await this.onTaskInvited(task);
}
} else if (eventType === 'participant.joined') {
const taskId = payload?.task_id;
if (taskId) {
const task = await this.client.getTask(taskId);
await this.onTaskInvited(task);
}
} else if (eventType === 'tasks.pending_recovery') {
const tasks = payload?.tasks || [];
await this.onPendingRecovery(tasks);
} else if (eventType === 'task.message') {
const taskId = payload?.task_id;
if (taskId) {
await this.onTaskMessage(taskId, payload);
}
} else if (eventType === 'task.updated') {
const taskId = payload?.task_id;
const changes = payload?.changes || {};
if (taskId) {
await this.onTaskUpdated(taskId, changes);
}
}
}
/**
* 睡眠指定毫秒
* @private
* @param {number} ms
* @returns {Promise<void>}
*/
_sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}
+1155
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
export { ArrayClient } from './client.js';
export { BaseAdapter } from './adapter.js';
+30
View File
@@ -0,0 +1,30 @@
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router-dom";
import LoginPage from "@/pages/LoginPage";
import WorkbenchPage from "@/pages/WorkbenchPage";
import { useAuthStore } from "@/stores";
import { TooltipProvider } from "@/components/ui/tooltip";
export default function App() {
const { isAuthenticated } = useAuthStore();
return (
<TooltipProvider>
<Router>
<Routes>
<Route
path="/"
element={
isAuthenticated ? <Navigate to="/workbench" replace /> : <LoginPage />
}
/>
<Route
path="/workbench"
element={
isAuthenticated ? <WorkbenchPage /> : <Navigate to="/" replace />
}
/>
</Routes>
</Router>
</TooltipProvider>
);
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+8
View File
@@ -0,0 +1,8 @@
import { cn } from '@/lib/utils'
// Empty component
export default function Empty() {
return (
<div className={cn('flex h-full items-center justify-center')}>Empty</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { motion } from "framer-motion";
import { Zap } from "lucide-react";
export function LEDLogo() {
const leds = [
{ x: 0, y: 0, color: "bg-cyan-500" },
{ x: 2, y: 0, color: "bg-cyan-400" },
{ x: 4, y: 0, color: "bg-cyan-500" },
{ x: 6, y: 0, color: "bg-cyan-400" },
{ x: 1, y: 1, color: "bg-cyan-600" },
{ x: 3, y: 1, color: "bg-cyan-500" },
{ x: 5, y: 1, color: "bg-cyan-600" },
{ x: 0, y: 2, color: "bg-purple-500" },
{ x: 2, y: 2, color: "bg-cyan-400" },
{ x: 4, y: 2, color: "bg-cyan-400" },
{ x: 6, y: 2, color: "bg-purple-500" },
{ x: 1, y: 3, color: "bg-purple-600" },
{ x: 3, y: 3, color: "bg-cyan-500" },
{ x: 5, y: 3, color: "bg-purple-600" },
{ x: 0, y: 4, color: "bg-purple-500" },
{ x: 2, y: 4, color: "bg-purple-400" },
{ x: 4, y: 4, color: "bg-purple-400" },
{ x: 6, y: 4, color: "bg-purple-500" },
];
return (
<div className="relative">
<div className="absolute -inset-6 bg-gradient-to-r from-cyan-500/20 to-purple-500/20 rounded-full blur-2xl"></div>
<div className="relative grid grid-cols-7 gap-2 p-6 bg-slate-900/80 rounded-2xl border border-slate-700">
{leds.map((led, i) => (
<motion.div
key={i}
initial={{ opacity: 0.3, scale: 0.8 }}
animate={{
opacity: [0.3, 1, 0.3],
scale: [0.8, 1, 0.8],
boxShadow: [
"none",
`0 0 20px ${led.color.replace("bg-", "")}`,
"none",
],
}}
transition={{
duration: 2,
repeat: Infinity,
delay: i * 0.1,
ease: "easeInOut",
}}
className={`w-3 h-3 rounded-full ${led.color} transition-all`}
/>
))}
</div>
<div className="absolute -bottom-2 left-1/2 -translate-x-1/2">
<div className="flex items-center gap-2 text-cyan-400 font-bold text-lg">
<Zap className="w-5 h-5 fill-cyan-400" />
<span>列阵</span>
</div>
</div>
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { motion } from "framer-motion";
type AnimationType = "snake" | "tetris" | "fireworks" | "scroll" | "loading";
interface LEDMatrixProps {
width?: number;
height?: number;
type?: AnimationType;
speed?: number;
color?: string;
className?: string;
}
export function LEDMatrix({
width = 16,
height = 16,
type = "loading",
speed = 100,
color = "bg-cyan-500",
className = "",
}: LEDMatrixProps) {
const [matrix, setMatrix] = useState<boolean[][]>(
Array(height).fill(null).map(() => Array(width).fill(false)),
);
const [, setTick] = useState(0);
const stateRef = useRef<{
snake?: { x: number; y: number }[];
food?: { x: number; y: number };
direction?: { x: number; y: number };
grid?: number[][];
currentPiece?: { blocks: { x: number; y: number }[] };
tick?: number;
particles?: { x: number; y: number; vx: number; vy: number; life: number }[];
index?: number;
direction?: number;
}>();
const initSnake = useCallback(() => {
stateRef.current = {
snake: [
{ x: 0, y: Math.floor(height / 2) },
{ x: 1, y: Math.floor(height / 2) },
{ x: 2, y: Math.floor(height / 2) },
],
food: { x: Math.floor(width / 2), y: Math.floor(height / 2) },
direction: { x: 1, y: 0 },
};
}, [width, height]);
const initTetris = useCallback(() => {
stateRef.current = {
grid: Array(height).fill(null).map(() => Array(width).fill(0)),
currentPiece: {
blocks: [
{ x: Math.floor(width / 2), y: 0 },
{ x: Math.floor(width / 2), y: 1 },
{ x: Math.floor(width / 2) + 1, y: 0 },
{ x: Math.floor(width / 2) + 1, y: 1 },
],
},
tick: 0,
};
}, [width, height]);
const initFireworks = useCallback(() => {
stateRef.current = {
particles: [],
};
}, []);
const initScroll = useCallback(() => {
stateRef.current = {
offset: 0,
letterIndex: 0,
};
}, []);
const initLoading = useCallback(() => {
stateRef.current = {
index: 0,
direction: 1,
};
}, []);
const initAnimation = useCallback(() => {
switch (type) {
case "snake":
initSnake();
break;
case "tetris":
initTetris();
break;
case "fireworks":
initFireworks();
break;
case "scroll":
initScroll();
break;
case "loading":
default:
initLoading();
break;
}
}, [type, initSnake, initTetris, initFireworks, initScroll, initLoading]);
const updateSnake = useCallback(() => {
const state = stateRef.current;
const head = state.snake[state.snake.length - 1];
const newHead = {
x: head.x + state.direction.x,
y: head.y + state.direction.y,
};
if (
newHead.x < 0 ||
newHead.x >= width ||
newHead.y < 0 ||
newHead.y >= height
) {
state.direction.x = -state.direction.x;
state.direction.y = -state.direction.y;
return;
}
state.snake.push(newHead);
if (newHead.x === state.food.x && newHead.y === state.food.y) {
state.food = {
x: Math.floor(Math.random() * width),
y: Math.floor(Math.random() * height),
};
} else {
state.snake.shift();
}
const newMatrix = Array(height).fill(null).map(() => Array(width).fill(false));
state.snake.forEach((segment: { x: number; y: number }) => {
if (segment.x >= 0 && segment.x < width && segment.y >= 0 && segment.y < height) {
newMatrix[segment.y][segment.x] = true;
}
});
if (
state.food.x >= 0 &&
state.food.x < width &&
state.food.y >= 0 &&
state.food.y < height
) {
newMatrix[state.food.y][state.food.x] = true;
}
setMatrix(newMatrix);
}, [width, height]);
const updateTetris = useCallback(() => {
const state = stateRef.current;
state.tick++;
if (state.tick % 5 === 0) {
const newBlocks = state.currentPiece.blocks.map((b: { x: number; y: number }) => ({
x: b.x,
y: b.y + 1,
}));
const canMove = newBlocks.every(
(b: { x: number; y: number }) =>
b.y < height && (b.y < 0 || !state.grid[b.y]?.[b.x]),
);
if (canMove) {
state.currentPiece.blocks = newBlocks;
} else {
state.currentPiece.blocks.forEach((b: { x: number; y: number }) => {
if (b.y >= 0 && b.y < height) {
state.grid[b.y][b.x] = 1;
}
});
const startX = Math.floor(Math.random() * (width - 4)) + 2;
state.currentPiece.blocks = [
{ x: startX, y: 0 },
{ x: startX, y: 1 },
{ x: startX + 1, y: 0 },
{ x: startX + 1, y: 1 },
];
state.tick = 0;
}
}
const newMatrix = Array(height).fill(null).map(() => Array(width).fill(false));
state.grid.forEach((row: number[], y: number) => {
row.forEach((cell: number, x: number) => {
if (cell) newMatrix[y][x] = true;
});
});
state.currentPiece.blocks.forEach((b: { x: number; y: number }) => {
if (b.x >= 0 && b.x < width && b.y >= 0 && b.y < height) {
newMatrix[b.y][b.x] = true;
}
});
setMatrix(newMatrix);
}, [width, height]);
const updateFireworks = useCallback(() => {
const state = stateRef.current;
if (Math.random() < 0.1) {
state.particles.push({
x: Math.floor(Math.random() * width),
y: height - 1,
vx: (Math.random() - 0.5) * 2,
vy: -1,
life: 30,
});
}
state.particles = state.particles
.map((p) => ({
...p,
x: p.x + p.vx,
y: p.y + p.vy,
life: p.life - 1,
}))
.filter((p) => p.life > 0);
const newMatrix = Array(height).fill(null).map(() => Array(width).fill(false));
state.particles.forEach((p) => {
const x = Math.floor(p.x);
const y = Math.floor(p.y);
if (x >= 0 && x < width && y >= 0 && y < height) {
newMatrix[y][x] = true;
}
});
setMatrix(newMatrix);
}, [width, height]);
const updateLoading = useCallback(() => {
const state = stateRef.current;
state.index += state.direction;
if (state.index >= width) {
state.direction = -1;
} else if (state.index < 0) {
state.direction = 1;
}
const newMatrix = Array(height).fill(null).map(() => Array(width).fill(false));
for (let y = 0; y < height; y++) {
if (state.index >= 0 && state.index < width) {
newMatrix[y][state.index] = true;
if (state.index - 1 >= 0) newMatrix[y][state.index - 1] = true;
if (state.index - 2 >= 0) newMatrix[y][state.index - 2] = true;
}
}
setMatrix(newMatrix);
}, [width, height]);
useEffect(() => {
initAnimation();
}, [type, initAnimation]);
useEffect(() => {
const animate = () => {
setTick((t) => t + 1);
switch (type) {
case "snake":
updateSnake();
break;
case "tetris":
updateTetris();
break;
case "fireworks":
updateFireworks();
break;
case "loading":
updateLoading();
break;
default:
break;
}
};
const interval = setInterval(animate, speed);
return () => clearInterval(interval);
}, [type, speed, updateSnake, updateTetris, updateFireworks, updateLoading]);
return (
<div className={`flex flex-col gap-1 p-4 bg-slate-900/50 rounded-xl border border-slate-700 ${className}`}>
{matrix.map((row, y) => (
<div key={y} className="flex gap-1 justify-center">
{row.map((lit, x) => (
<motion.div
key={`${x}-${y}`}
initial={false}
animate={{
opacity: lit ? 1 : 0.1,
scale: lit ? 1.1 : 1,
boxShadow: lit ? `0 0 8px ${color.replace("bg-", "")}` : "none",
}}
transition={{ duration: 0.1 }}
className={`w-2 h-2 rounded-full transition-all ${lit ? color : "bg-slate-700"}`}
/>
))}
</div>
))}
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { Moon, Sun } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useUIStore } from "@/stores";
import { useEffect } from "react";
export function ThemeToggle() {
const { theme, setTheme } = useUIStore();
useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
root.classList.add(systemTheme);
} else {
root.classList.add(theme);
}
}, [theme]);
const toggleTheme = () => {
setTheme(theme === "dark" ? "light" : "dark");
};
return (
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="w-9 h-9 text-slate-400 hover:text-slate-200"
>
{theme === "dark" ? (
<Sun className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
<span className="sr-only">切换主题</span>
</Button>
);
}
+76
View File
@@ -0,0 +1,76 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
},
},
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(
"font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn("absolute top-2 right-2", className)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
+109
View File
@@ -0,0 +1,109 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: AvatarPrimitive.Root.Props & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: AvatarPrimitive.Fallback.Props) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+52
View File
@@ -0,0 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}
export { Badge, badgeVariants }
+58
View File
@@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+194
View File
@@ -0,0 +1,194 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
InputGroup,
InputGroupAddon,
} from "@/components/ui/input-group"
import { SearchIcon, CheckIcon } from "lucide-react"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = false,
...props
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
children: React.ReactNode
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn(
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
className
)}
showCloseButton={showCloseButton}
>
{children}
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div data-slot="command-input-wrapper" className="p-1 pb-0">
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
<InputGroupAddon>
<SearchIcon className="size-4 shrink-0 opacity-50" />
</InputGroupAddon>
</InputGroup>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
className
)}
{...props}
/>
)
}
function CommandEmpty({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className={cn("py-6 text-center text-sm", className)}
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
)
}
function CommandItem({
className,
children,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className
)}
{...props}
>
{children}
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
</CommandPrimitive.Item>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+158
View File
@@ -0,0 +1,158 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: DialogPrimitive.Backdrop.Props) {
return (
<DialogPrimitive.Backdrop
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: DialogPrimitive.Popup.Props & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Popup
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
render={
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Popup>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close render={<Button variant="outline" />}>
Close
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"font-heading text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: DialogPrimitive.Description.Props) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+266
View File
@@ -0,0 +1,266 @@
import * as React from "react"
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
}
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
}
function DropdownMenuContent({
align = "start",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
className,
...props
}: MenuPrimitive.Popup.Props &
Pick<
MenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<MenuPrimitive.Popup
data-slot="dropdown-menu-content"
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
}
function DropdownMenuLabel({
className,
inset,
...props
}: MenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.GroupLabel
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: MenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: MenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.SubmenuTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</MenuPrimitive.SubmenuTrigger>
)
}
function DropdownMenuSubContent({
align = "start",
alignOffset = -3,
side = "right",
sideOffset = 0,
className,
...props
}: React.ComponentProps<typeof DropdownMenuContent>) {
return (
<DropdownMenuContent
data-slot="dropdown-menu-sub-content"
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: MenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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 right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<MenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</MenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</MenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
return (
<MenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: MenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<MenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 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 right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<MenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</MenuPrimitive.RadioItemIndicator>
</span>
{children}
</MenuPrimitive.RadioItem>
)
}
function DropdownMenuSeparator({
className,
...props
}: MenuPrimitive.Separator.Props) {
return (
<MenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+158
View File
@@ -0,0 +1,158 @@
"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 relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
"inline-end":
"order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
"block-start":
"order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
"block-end":
"order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
},
},
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(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
sm: "",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-3px)] 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" | "type"> &
VariantProps<typeof inputGroupButtonVariants> & {
type?: "button" | "submit" | "reset"
}) {
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(
"flex items-center gap-2 text-sm text-muted-foreground [&_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 ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled: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-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+20
View File
@@ -0,0 +1,20 @@
import * as React from "react"
import { Input as InputPrimitive } from "@base-ui/react/input"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<InputPrimitive
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+20
View File
@@ -0,0 +1,20 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Label({ className, ...props }: React.ComponentProps<"label">) {
return (
<label
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 }
+88
View File
@@ -0,0 +1,88 @@
import * as React from "react"
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
import { cn } from "@/lib/utils"
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
alignOffset = 0,
side = "bottom",
sideOffset = 4,
...props
}: PopoverPrimitive.Popup.Props &
Pick<
PopoverPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<PopoverPrimitive.Popup
data-slot="popover-content"
className={cn(
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</PopoverPrimitive.Positioner>
</PopoverPrimitive.Portal>
)
}
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="popover-header"
className={cn("flex flex-col gap-0.5 text-sm", className)}
{...props}
/>
)
}
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
return (
<PopoverPrimitive.Title
data-slot="popover-title"
className={cn("font-medium", className)}
{...props}
/>
)
}
function PopoverDescription({
className,
...props
}: PopoverPrimitive.Description.Props) {
return (
<PopoverPrimitive.Description
data-slot="popover-description"
className={cn("text-muted-foreground", className)}
{...props}
/>
)
}
export {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
}
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: ScrollAreaPrimitive.Root.Props) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: ScrollAreaPrimitive.Scrollbar.Props) {
return (
<ScrollAreaPrimitive.Scrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.Thumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.Scrollbar>
)
}
export { ScrollArea, ScrollBar }
+199
View File
@@ -0,0 +1,199 @@
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
const Select = SelectPrimitive.Root
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
)
}
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
return (
<SelectPrimitive.Value
data-slot="select-value"
className={cn("flex flex-1 text-left", className)}
{...props}
/>
)
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: SelectPrimitive.Trigger.Props & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon
render={
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
}
/>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
side = "bottom",
sideOffset = 4,
align = "center",
alignOffset = 0,
alignItemWithTrigger = true,
...props
}: SelectPrimitive.Popup.Props &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
data-align-trigger={alignItemWithTrigger}
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.List>{children}</SelectPrimitive.List>
<SelectScrollDownButton />
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: SelectPrimitive.GroupLabel.Props) {
return (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: SelectPrimitive.Item.Props) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground 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}
>
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator
render={
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
}
>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: SelectPrimitive.Separator.Props) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
return (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronUpIcon
/>
</SelectPrimitive.ScrollUpArrow>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
return (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<ChevronDownIcon
/>
</SelectPrimitive.ScrollDownArrow>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+25
View File
@@ -0,0 +1,25 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
...props
}: SeparatorPrimitive.Props) {
return (
<SeparatorPrimitive
data-slot="separator"
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+138
View File
@@ -0,0 +1,138 @@
"use client"
import * as React from "react"
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
return (
<SheetPrimitive.Backdrop
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: SheetPrimitive.Popup.Props & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Popup
data-slot="sheet-content"
data-side={side}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close
data-slot="sheet-close"
render={
<Button
variant="ghost"
className="absolute top-3 right-3"
size="icon-sm"
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-0.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 }: SheetPrimitive.Title.Props) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn(
"font-heading text-base font-medium text-foreground",
className
)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: SheetPrimitive.Description.Props) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+80
View File
@@ -0,0 +1,80 @@
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: TabsPrimitive.Root.Props) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
return (
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
return (
<TabsPrimitive.Panel
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+64
View File
@@ -0,0 +1,64 @@
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delay = 0,
...props
}: TooltipPrimitive.Provider.Props) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delay={delay}
{...props}
/>
)
}
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
side = "top",
sideOffset = 4,
align = "center",
alignOffset = 0,
children,
...props
}: TooltipPrimitive.Popup.Props &
Pick<
TooltipPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Positioner
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
className="isolate z-50"
>
<TooltipPrimitive.Popup
data-slot="tooltip-content"
className={cn(
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
</TooltipPrimitive.Popup>
</TooltipPrimitive.Positioner>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+9
View File
@@ -0,0 +1,9 @@
export { useTheme } from './useTheme';
export { useLocalStorage } from './useLocalStorage';
export { useDebounce } from './useDebounce';
export { useToggle } from './useToggle';
export { useFetch } from './useFetch';
export { useEventListener } from './useEventListener';
export { useTimeout } from './useTimeout';
export { useWindowSize } from './useWindowSize';
export { usePrevious } from './usePrevious';
+17
View File
@@ -0,0 +1,17 @@
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number = 500) {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
+31
View File
@@ -0,0 +1,31 @@
import { useEffect, useRef } from 'react';
export function useEventListener<K extends keyof WindowEventMap>(
eventName: K,
handler: (event: WindowEventMap[K]) => void,
element?: Window | HTMLElement | null
) {
const savedHandler = useRef<(event: WindowEventMap[K]) => void>();
useEffect(() => {
savedHandler.current = handler;
}, [handler]);
useEffect(() => {
const targetElement = element ?? window;
if (!targetElement) {
return;
}
const eventListener = (event: Event) => {
savedHandler.current?.(event as WindowEventMap[K]);
};
targetElement.addEventListener(eventName, eventListener as EventListener);
return () => {
targetElement.removeEventListener(eventName, eventListener as EventListener);
};
}, [eventName, element]);
}
+68
View File
@@ -0,0 +1,68 @@
import { useState, useEffect, useCallback, useRef } from 'react';
interface FetchState<T> {
data: T | null;
loading: boolean;
error: Error | null;
}
export function useFetch<T>(
url: string,
options?: RequestInit,
immediate: boolean = true
) {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: immediate,
error: null
});
const abortControllerRef = useRef<AbortController | null>(null);
const fetchData = useCallback(async () => {
abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
setState({ data: null, loading: true, error: null });
try {
const response = await fetch(url, {
...options,
signal: abortController.signal
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setState({ data, loading: false, error: null });
return data;
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
setState({ data: null, loading: false, error });
}
throw error;
}
}, [url, options]);
useEffect(() => {
if (immediate) {
fetchData().catch(() => {});
}
return () => {
abortControllerRef.current?.abort();
};
}, [fetchData, immediate]);
const refetch = useCallback(() => {
return fetchData();
}, [fetchData]);
return {
...state,
refetch
};
}
+56
View File
@@ -0,0 +1,56 @@
import { useState, useEffect, useCallback } from 'react';
type SetValue<T> = T | ((prev: T) => T);
export function useLocalStorage<T>(key: string, initialValue: T) {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(`Error reading localStorage key "${key}":`, error);
return initialValue;
}
});
const setValue = useCallback(
(value: SetValue<T>) => {
try {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
window.localStorage.setItem(key, JSON.stringify(valueToStore));
} catch (error) {
console.error(`Error setting localStorage key "${key}":`, error);
}
},
[key, storedValue]
);
const removeValue = useCallback(() => {
try {
setStoredValue(initialValue);
window.localStorage.removeItem(key);
} catch (error) {
console.error(`Error removing localStorage key "${key}":`, error);
}
}, [key, initialValue]);
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue !== null) {
try {
setStoredValue(JSON.parse(e.newValue));
} catch (error) {
console.error(`Error handling storage change for key "${key}":`, error);
}
} else if (e.key === key && e.newValue === null) {
setStoredValue(initialValue);
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key, initialValue]);
return [storedValue, setValue, removeValue] as const;
}
+11
View File
@@ -0,0 +1,11 @@
import { useRef, useEffect } from 'react';
export function usePrevious<T>(value: T) {
const ref = useRef<T>();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current;
}
+29
View File
@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
type Theme = 'light' | 'dark';
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
const savedTheme = localStorage.getItem('theme') as Theme;
if (savedTheme) {
return savedTheme;
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
});
useEffect(() => {
document.documentElement.classList.remove('light', 'dark');
document.documentElement.classList.add(theme);
localStorage.setItem('theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
};
return {
theme,
toggleTheme,
isDark: theme === 'dark'
};
}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useRef, useCallback } from 'react';
export function useTimeout(callback: () => void, delay: number | null) {
const callbackRef = useRef<() => void>(callback);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
const set = useCallback(() => {
if (delay !== null) {
timeoutRef.current = setTimeout(() => callbackRef.current(), delay);
}
}, [delay]);
const clear = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
}, []);
const reset = useCallback(() => {
clear();
set();
}, [clear, set]);
useEffect(() => {
set();
return clear;
}, [set, clear]);
return {
reset,
clear
};
}
+25
View File
@@ -0,0 +1,25 @@
import { useState, useCallback } from 'react';
export function useToggle(initialValue: boolean = false) {
const [value, setValue] = useState<boolean>(initialValue);
const toggle = useCallback(() => {
setValue((prev) => !prev);
}, []);
const setTrue = useCallback(() => {
setValue(true);
}, []);
const setFalse = useCallback(() => {
setValue(false);
}, []);
return {
value,
toggle,
setTrue,
setFalse,
setValue
};
}
+29
View File
@@ -0,0 +1,29 @@
import { useState, useEffect } from 'react';
import { useEventListener } from './useEventListener';
interface WindowSize {
width: number;
height: number;
}
export function useWindowSize(): WindowSize {
const [windowSize, setWindowSize] = useState<WindowSize>({
width: typeof window !== 'undefined' ? window.innerWidth : 0,
height: typeof window !== 'undefined' ? window.innerHeight : 0
});
const handleResize = () => {
setWindowSize({
width: window.innerWidth,
height: window.innerHeight
});
};
useEventListener('resize', handleResize);
useEffect(() => {
handleResize();
}, []);
return windowSize;
}
+100
View File
@@ -0,0 +1,100 @@
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@layer base {
.theme {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+52
View File
@@ -0,0 +1,52 @@
export const STORAGE_KEYS = {
AUTH: "array_auth",
SERVER_URL: "array_server_url",
THEME: "array_theme",
LANGUAGE: "array_language",
TASK_READ: "array_task_read",
DRAFTS: "array_drafts",
} as const;
export const TASK_STATUSES = {
pending: { label: "待处理", color: "yellow" },
in_progress: { label: "进行中", color: "blue" },
completed: { label: "已完成", color: "green" },
cancelled: { label: "已取消", color: "gray" },
} as const;
export const PRIORITIES = {
low: { label: "低", color: "gray" },
medium: { label: "中", color: "blue" },
high: { label: "高", color: "orange" },
urgent: { label: "紧急", color: "red" },
} as const;
export const WORKER_STATUS = {
online: { label: "在线", color: "green" },
offline: { label: "离线", color: "gray" },
busy: { label: "忙碌", color: "orange" },
} as const;
export const ROLES = {
admin: { label: "管理员", color: "purple" },
member: { label: "成员", color: "blue" },
observer: { label: "观察者", color: "gray" },
} as const;
export const NOTIFICATION_TYPES = {
task_invite: { label: "任务邀请", icon: "invite" },
task_update: { label: "任务更新", icon: "update" },
message: { label: "新消息", icon: "message" },
system: { label: "系统通知", icon: "system" },
} as const;
export const DEFAULT_AVATAR_COLORS = [
"#3b82f6",
"#10b981",
"#f59e0b",
"#ef4444",
"#8b5cf6",
"#ec4899",
"#06b6d4",
"#84cc16",
];
+480
View File
@@ -0,0 +1,480 @@
export const zhCN = {
common: {
save: "保存",
cancel: "取消",
confirm: "确认",
delete: "删除",
edit: "编辑",
submit: "提交",
loading: "加载中...",
error: "错误",
success: "成功",
retry: "重试",
close: "关闭",
search: "搜索",
filter: "筛选",
reset: "重置",
all: "全部",
none: "无",
select: "选择",
deselect: "取消选择",
back: "返回",
forward: "前进",
more: "更多",
less: "更少",
copy: "复制",
copied: "已复制",
},
auth: {
title: "列阵工作台",
subtitle: "人机协作,效率翻倍",
serverUrl: "服务器地址",
apiKey: "API Key",
username: "用户名",
password: "密码",
name: "姓名",
login: "登录",
loginWithApiKey: "使用 API Key 登录",
loginWithPassword: "使用密码登录",
switchMode: "切换登录方式",
register: "注册",
registerTitle: "创建新账号",
registerHint: "没有账号?立即注册",
loginHint: "已有账号?立即登录",
invitationCode: "邀请码(可选)",
organization: "组织",
location: "位置",
description: "个人简介",
tags: "标签",
capabilities: "能力",
loggingIn: "登录中...",
registering: "注册中...",
loginSuccess: "登录成功",
registerSuccess: "注册成功",
loginFailed: "登录失败",
registerFailed: "注册失败",
invalidCredentials: "凭据无效",
invalidServerUrl: "服务器地址无效",
},
workbench: {
members: "成员",
tasks: "任务",
chat: "聊天",
newTask: "新建任务",
newConversation: "新建对话",
noTasks: "暂无任务",
noTasksHint: "点击上方按钮创建新任务",
noMessages: "暂无消息",
noMessagesHint: "发送第一条消息开始协作",
loadingTasks: "加载任务中...",
loadingMessages: "加载消息中...",
sendMessage: "发送消息",
typeMessage: "输入消息...",
send: "发送",
enterToSend: "Enter 发送",
ctrlEnterToSend: "Ctrl+Enter 发送",
sending: "发送中...",
resend: "重新发送",
sendFailed: "发送失败",
uploadFile: "上传文件",
uploadImage: "上传图片",
uploading: "上传中...",
uploadFailed: "上传失败",
uploadSuccess: "上传成功",
replyTo: "回复",
cancelReply: "取消回复",
mentionPlaceholder: "输入 @ 提及成员",
privateMessage: "私信",
publicMessage: "公开消息",
sendTo: "发送给",
everyone: "所有人",
markRead: "标为已读",
unread: "未读",
taskSettings: "任务设置",
taskDetails: "任务详情",
participants: "参与者",
addParticipant: "添加参与者",
removeParticipant: "移除参与者",
leaveTask: "离开任务",
deleteTask: "删除任务",
pinTask: "置顶任务",
unpinTask: "取消置顶",
taskTitle: "任务标题",
editTitle: "编辑标题",
titlePlaceholder: "任务标题",
saveTitle: "保存标题",
taskStatus: "任务状态",
taskPriority: "任务优先级",
changeStatus: "更改状态",
changePriority: "更改优先级",
},
tasks: {
all: "全部",
myTasks: "我的任务",
iCreated: "我创建的",
iParticipate: "我参与的",
completed: "已完成",
pending: "待处理",
inProgress: "进行中",
cancelled: "已取消",
low: "低",
medium: "中",
high: "高",
urgent: "紧急",
created: "创建于",
updated: "更新于",
lastMessage: "最后消息",
noTasksFound: "未找到任务",
},
members: {
online: "在线",
offline: "离线",
busy: "忙碌",
ai: "AI 助手",
human: "人类用户",
all: "全部成员",
noMembers: "暂无成员",
searchMembers: "搜索成员...",
viewProfile: "查看资料",
startConversation: "开始对话",
inviteToTask: "邀请到任务",
},
org: {
switch: "切换组织",
create: "创建组织",
name: "组织名称",
description: "组织描述",
members: "组织成员",
invites: "邀请码",
createInvite: "生成邀请码",
inviteCode: "邀请码",
expiresAt: "过期时间",
maxUses: "最大使用次数",
usedCount: "已使用",
noLimit: "无限制",
neverExpires: "永不过期",
copyInvite: "复制邀请码",
deactivate: "停用",
activate: "启用",
memberList: "成员列表",
role: "角色",
admin: "管理员",
member: "成员",
removeMember: "移除成员",
changeRole: "更改角色",
renameMember: "重命名成员",
renameOrg: "重命名组织",
noOrg: "暂无组织",
joinOrg: "加入组织",
},
profile: {
title: "个人资料",
name: "姓名",
organization: "组织",
location: "位置",
description: "简介",
tags: "标签",
capabilities: "能力",
maxConcurrentTasks: "最大并发任务数",
edit: "编辑资料",
save: "保存资料",
shortcutSettings: "快捷键设置",
sendKey: "发送快捷键",
enter: "Enter",
ctrlEnter: "Ctrl+Enter",
theme: "主题",
light: "明亮",
dark: "深色",
language: "语言",
chinese: "中文",
english: "English",
},
review: {
title: "评价",
submit: "提交评价",
rating: "评分",
stars: "星",
capabilityTags: "能力标签",
comment: "评论",
suggestion: "建议",
submitSuccess: "评价提交成功",
viewReviews: "查看评价",
averageRating: "平均评分",
totalReviews: "总评价数",
commonTags: "常见标签",
noReviews: "暂无评价",
reviewFor: "对 {name} 的评价",
},
notifications: {
title: "通知",
markAllRead: "全部标为已读",
noNotifications: "暂无通知",
taskInvite: "任务邀请",
taskUpdate: "任务更新",
newMessage: "新消息",
system: "系统通知",
},
websocket: {
connected: "已连接",
disconnected: "已断开",
reconnecting: "重连中...",
error: "连接错误",
},
errors: {
generic: "发生错误",
network: "网络错误",
unauthorized: "未授权",
forbidden: "禁止访问",
notFound: "未找到",
server: "服务器错误",
timeout: "请求超时",
},
led: {
snake: "贪吃蛇",
tetris: "俄罗斯方块",
fireworks: "烟花",
scroll: "滚动文字",
},
};
export const enUS = {
common: {
save: "Save",
cancel: "Cancel",
confirm: "Confirm",
delete: "Delete",
edit: "Edit",
submit: "Submit",
loading: "Loading...",
error: "Error",
success: "Success",
retry: "Retry",
close: "Close",
search: "Search",
filter: "Filter",
reset: "Reset",
all: "All",
none: "None",
select: "Select",
deselect: "Deselect",
back: "Back",
forward: "Forward",
more: "More",
less: "Less",
copy: "Copy",
copied: "Copied",
},
auth: {
title: "Array Workbench",
subtitle: "Human-AI collaboration, double efficiency",
serverUrl: "Server URL",
apiKey: "API Key",
username: "Username",
password: "Password",
name: "Name",
login: "Login",
loginWithApiKey: "Login with API Key",
loginWithPassword: "Login with Password",
switchMode: "Switch mode",
register: "Register",
registerTitle: "Create new account",
registerHint: "No account? Register now",
loginHint: "Have an account? Login now",
invitationCode: "Invitation code (optional)",
organization: "Organization",
location: "Location",
description: "Description",
tags: "Tags",
capabilities: "Capabilities",
loggingIn: "Logging in...",
registering: "Registering...",
loginSuccess: "Login successful",
registerSuccess: "Registration successful",
loginFailed: "Login failed",
registerFailed: "Registration failed",
invalidCredentials: "Invalid credentials",
invalidServerUrl: "Invalid server URL",
},
workbench: {
members: "Members",
tasks: "Tasks",
chat: "Chat",
newTask: "New Task",
newConversation: "New Conversation",
noTasks: "No tasks yet",
noTasksHint: "Click the button above to create a new task",
noMessages: "No messages yet",
noMessagesHint: "Send the first message to start collaborating",
loadingTasks: "Loading tasks...",
loadingMessages: "Loading messages...",
sendMessage: "Send message",
typeMessage: "Type a message...",
send: "Send",
enterToSend: "Enter to send",
ctrlEnterToSend: "Ctrl+Enter to send",
sending: "Sending...",
resend: "Resend",
sendFailed: "Send failed",
uploadFile: "Upload file",
uploadImage: "Upload image",
uploading: "Uploading...",
uploadFailed: "Upload failed",
uploadSuccess: "Upload successful",
replyTo: "Reply",
cancelReply: "Cancel reply",
mentionPlaceholder: "Type @ to mention members",
privateMessage: "Private message",
publicMessage: "Public message",
sendTo: "Send to",
everyone: "Everyone",
markRead: "Mark as read",
unread: "Unread",
taskSettings: "Task settings",
taskDetails: "Task details",
participants: "Participants",
addParticipant: "Add participant",
removeParticipant: "Remove participant",
leaveTask: "Leave task",
deleteTask: "Delete task",
pinTask: "Pin task",
unpinTask: "Unpin task",
taskTitle: "Task title",
editTitle: "Edit title",
titlePlaceholder: "Task title",
saveTitle: "Save title",
taskStatus: "Task status",
taskPriority: "Task priority",
changeStatus: "Change status",
changePriority: "Change priority",
},
tasks: {
all: "All",
myTasks: "My tasks",
iCreated: "Created by me",
iParticipate: "I participate",
completed: "Completed",
pending: "Pending",
inProgress: "In progress",
cancelled: "Cancelled",
low: "Low",
medium: "Medium",
high: "High",
urgent: "Urgent",
created: "Created at",
updated: "Updated at",
lastMessage: "Last message",
noTasksFound: "No tasks found",
},
members: {
online: "Online",
offline: "Offline",
busy: "Busy",
ai: "AI Assistant",
human: "Human",
all: "All members",
noMembers: "No members yet",
searchMembers: "Search members...",
viewProfile: "View profile",
startConversation: "Start conversation",
inviteToTask: "Invite to task",
},
org: {
switch: "Switch organization",
create: "Create organization",
name: "Organization name",
description: "Organization description",
members: "Organization members",
invites: "Invitation codes",
createInvite: "Generate invite code",
inviteCode: "Invitation code",
expiresAt: "Expires at",
maxUses: "Max uses",
usedCount: "Used",
noLimit: "No limit",
neverExpires: "Never expires",
copyInvite: "Copy invite code",
deactivate: "Deactivate",
activate: "Activate",
memberList: "Member list",
role: "Role",
admin: "Admin",
member: "Member",
removeMember: "Remove member",
changeRole: "Change role",
renameMember: "Rename member",
renameOrg: "Rename organization",
noOrg: "No organizations",
joinOrg: "Join organization",
},
profile: {
title: "Profile",
name: "Name",
organization: "Organization",
location: "Location",
description: "Description",
tags: "Tags",
capabilities: "Capabilities",
maxConcurrentTasks: "Max concurrent tasks",
edit: "Edit profile",
save: "Save profile",
shortcutSettings: "Shortcut settings",
sendKey: "Send shortcut",
enter: "Enter",
ctrlEnter: "Ctrl+Enter",
theme: "Theme",
light: "Light",
dark: "Dark",
language: "Language",
chinese: "中文",
english: "English",
},
review: {
title: "Review",
submit: "Submit review",
rating: "Rating",
stars: "stars",
capabilityTags: "Capability tags",
comment: "Comment",
suggestion: "Suggestion",
submitSuccess: "Review submitted successfully",
viewReviews: "View reviews",
averageRating: "Average rating",
totalReviews: "Total reviews",
commonTags: "Common tags",
noReviews: "No reviews yet",
reviewFor: "Review for {name}",
},
notifications: {
title: "Notifications",
markAllRead: "Mark all as read",
noNotifications: "No notifications",
taskInvite: "Task invite",
taskUpdate: "Task update",
newMessage: "New message",
system: "System notification",
},
websocket: {
connected: "Connected",
disconnected: "Disconnected",
reconnecting: "Reconnecting...",
error: "Connection error",
},
errors: {
generic: "An error occurred",
network: "Network error",
unauthorized: "Unauthorized",
forbidden: "Forbidden",
notFound: "Not found",
server: "Server error",
timeout: "Request timeout",
},
led: {
snake: "Snake",
tetris: "Tetris",
fireworks: "Fireworks",
scroll: "Scroll text",
},
};
export type Locale = "zh-CN" | "en-US";
export type Translation = typeof zhCN;
+21
View File
@@ -0,0 +1,21 @@
import type { Worker } from '@/lib/types';
export function getSdk(): any {
throw new Error('Not implemented');
}
export function initSdk(serverUrl: string, token?: string): any {
return {};
}
export async function loginWithApiKey(serverUrl: string, apiKey: string): Promise&lt;Worker&gt; {
return { id: '1', name: 'Test', type: 'human', status: 'online' };
}
export async function loginWithPassword(serverUrl: string, username: string, password: string): Promise&lt;Worker&gt; {
return { id: '1', name: username, type: 'human', status: 'online' };
}
export async function logout(): Promise&lt;void&gt; {
}
+236
View File
@@ -0,0 +1,236 @@
export interface Worker {
id: string;
name: string;
type: "human" | "ai";
status: "online" | "offline" | "busy";
organization?: string;
location?: string;
description?: string;
tags?: string[];
capabilities?: Record<string, unknown>;
maxConcurrentTasks?: number;
apiKey?: string;
createdAt?: string;
updatedAt?: string;
}
export interface RegisterProfile {
organization?: string;
location?: string;
description?: string;
tags?: string[];
capabilities?: Record<string, unknown>;
}
export interface Task {
id: string;
title: string;
description?: string;
status: "pending" | "in_progress" | "completed" | "cancelled";
priority?: "low" | "medium" | "high" | "urgent";
creatorId: string;
orgId?: string;
categoryId?: string;
createdAt: string;
lastMessageAt?: string;
participants?: Participant[];
isPinned?: boolean;
}
export interface Participant {
id: string;
workerId: string;
taskId: string;
role: "admin" | "member" | "observer";
joinedAt: string;
worker?: Worker;
}
export interface Message {
id: string;
taskId: string;
senderId: string;
content: string;
type: "text" | "system" | "file" | "image";
replyToId?: string;
privateToId?: string;
createdAt: string;
attachments?: Attachment[];
sender?: Worker;
replyTo?: Message;
}
export interface PendingMessage {
id: string;
taskId: string;
content: string;
status: "sending" | "failed";
options?: SendMessageOptions;
error?: string;
}
export interface Attachment {
id: string;
taskId: string;
filename: string;
contentType: string;
size: number;
url: string;
thumbnailUrl?: string;
width?: number;
height?: number;
duration?: number;
createdAt: string;
}
export interface Organization {
id: string;
name: string;
description?: string;
createdAt: string;
updatedAt: string;
members?: OrgMember[];
invites?: OrgInvite[];
}
export interface OrgMember {
id: string;
orgId: string;
workerId: string;
role: "admin" | "member";
joinedAt: string;
name?: string;
worker?: Worker;
}
export interface OrgInvite {
id: string;
orgId: string;
code: string;
createdBy: string;
expiresAt?: string;
maxUses?: number;
usedCount: number;
isActive: boolean;
createdAt: string;
}
export interface JoinResult {
success: boolean;
orgId: string;
}
export interface Category {
id: string;
name: string;
orgId?: string;
createdAt: string;
updatedAt: string;
}
export interface Review {
id: string;
workerId: string;
taskId: string;
reviewerId: string;
rating: 1 | 2 | 3 | 4 | 5;
capabilityTags: string[];
comment?: string;
suggestion?: string;
createdAt: string;
}
export interface CreateReviewParams {
taskId: string;
rating: 1 | 2 | 3 | 4 | 5;
capabilityTags: string[];
comment?: string;
suggestion?: string;
}
export interface ReviewListResult {
reviews: Review[];
total: number;
}
export interface ReviewSummary {
averageRating: number;
totalReviews: number;
commonTags: string[];
ratingDistribution: Record<string, number>;
}
export interface Notification {
id: string;
workerId: string;
type: "task_invite" | "task_update" | "message" | "system";
title: string;
content?: string;
data?: Record<string, unknown>;
isRead: boolean;
createdAt: string;
}
export interface Workload {
workerId: string;
activeTasks: number;
maxConcurrentTasks: number;
isAvailable: boolean;
}
export interface DiscoverOptions {
type?: "human" | "ai";
tags?: string[];
online?: boolean;
orgId?: string;
limit?: number;
offset?: number;
}
export interface DiscoverResult {
workers: Worker[];
total: number;
}
export interface SendMessageOptions {
privateToId?: string;
replyToId?: string;
metadata?: Record<string, unknown>;
}
export interface CreateTaskExtra {
description?: string;
priority?: string;
categoryId?: string;
orgId?: string;
}
export interface InviteOptions {
expiresAt?: string;
maxUses?: number;
}
export interface PaginationOptions {
limit?: number;
offset?: number;
}
export type WSEventType =
| "task.message"
| "participant.joined"
| "task.updated"
| "worker_status_changed"
| "tasks.pending_recovery";
export interface WSEvent {
type: WSEventType;
data: Record<string, unknown>;
timestamp: string;
}
export interface WSCallbacks {
onEvent: (event: WSEvent) => void;
onConnected: () => void;
onDisconnected: () => void;
onError: (err: Error) => void;
}
+167
View File
@@ -0,0 +1,167 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { DEFAULT_AVATAR_COLORS } from "./constants";
import type { Worker, Attachment } from "./types";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
export function parseUTC(date: string | Date): Date {
if (typeof date === "string" && !date.endsWith("Z") && !date.includes("+")) {
return new Date(date + "Z");
}
return new Date(date);
}
export function formatDate(date: string | Date, locale = "zh-CN"): string {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
}).format(parseUTC(date));
}
export function formatTime(date: string | Date, locale = "zh-CN"): string {
return new Intl.DateTimeFormat(locale, {
hour: "2-digit",
minute: "2-digit",
}).format(parseUTC(date));
}
export function formatDateTime(date: string | Date, locale = "zh-CN"): string {
return new Intl.DateTimeFormat(locale, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
}).format(parseUTC(date));
}
export function getRelativeTime(date: string | Date, locale = "zh-CN"): string {
const now = new Date();
const diff = now.getTime() - parseUTC(date).getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
if (hours < 24) return `${hours}小时前`;
if (days < 7) return `${days}天前`;
return formatDate(date, locale);
}
export function getWorkerName(worker?: Worker | null): string {
if (!worker) return "未知";
return worker.name || "未知";
}
export function getWorkerInitials(name: string): string {
return name
.split(" ")
.map((n) => n[0])
.filter(Boolean)
.slice(0, 2)
.join("")
.toUpperCase();
}
export function getAvatarColor(workerId: string): string {
let hash = 0;
for (let i = 0; i < workerId.length; i++) {
const char = workerId.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return DEFAULT_AVATAR_COLORS[Math.abs(hash) % DEFAULT_AVATAR_COLORS.length];
}
export function truncateText(text: string, maxLength = 100): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + "...";
}
export function generateId(): string {
return `id_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
export function debounce<T extends (...args: unknown[]) => unknown>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: ReturnType<typeof setTimeout>;
return function (...args: Parameters<T>) {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
export function isImageAttachment(attachment: Attachment): boolean {
return attachment.contentType.startsWith("image/");
}
export function isVideoAttachment(attachment: Attachment): boolean {
return attachment.contentType.startsWith("video/");
}
export function isAudioAttachment(attachment: Attachment): boolean {
return attachment.contentType.startsWith("audio/");
}
export function isFileAttachment(attachment: Attachment): boolean {
return (
!isImageAttachment(attachment) &&
!isVideoAttachment(attachment) &&
!isAudioAttachment(attachment)
);
}
export function getFileExtension(filename: string): string {
const parts = filename.split(".");
return parts.length > 1 ? parts.pop()?.toLowerCase() || "" : "";
}
export function isSameDay(date1: string | Date, date2: string | Date): boolean {
const d1 = parseUTC(date1);
const d2 = parseUTC(date2);
return (
d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate()
);
}
export function escapeMarkdown(text: string): string {
return text.replace(/[*_`[\](){}#+-.!]/g, "\\$&");
}
export function parseMentions(
text: string,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_workers?: Worker[]
): { text: string; mentions: string[] } {
const mentionPattern = /@\[([^\]]+)\]\(([^)]+)\)/g;
const mentions: string[] = [];
let match;
while ((match = mentionPattern.exec(text)) !== null) {
const workerId = match[2];
if (workerId) {
mentions.push(workerId);
}
}
return { text, mentions };
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+3
View File
@@ -0,0 +1,3 @@
export default function Home() {
return <div></div>;
}
+221
View File
@@ -0,0 +1,221 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { motion } from "framer-motion";
import {
Eye,
EyeOff,
Server,
Key,
User,
Lock,
AlertCircle,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useAuthStore } from "@/stores";
import { LEDLogo } from "@/components/shared/LEDLogo";
import { STORAGE_KEYS } from "@/lib/constants";
export default function LoginPage() {
const navigate = useNavigate();
const { login } = useAuthStore();
const [showPassword, setShowPassword] = useState(false);
const [loginMode, setLoginMode] = useState("apikey");
const [serverUrl, setServerUrl] = useState(
() => (typeof window !== "undefined" ? localStorage.getItem(STORAGE_KEYS.SERVER_URL) || "http://localhost:3000" : "http://localhost:3000")
);
const [apiKey, setApiKey] = useState("");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
// 模拟登录过程
await new Promise((resolve) => setTimeout(resolve, 800));
// 简单验证
if (loginMode === "apikey" && !apiKey.trim()) {
throw new Error("请输入 API Key");
}
if (loginMode === "password" && (!username.trim() || !password)) {
throw new Error("请输入用户名和密码");
}
// 创建模拟用户对象
const mockWorker = {
id: "1",
name: loginMode === "apikey" ? "当前用户" : username,
type: "human" as const,
status: "online" as const,
};
login(mockWorker, apiKey || "demo-token");
navigate("/workbench");
} catch (err) {
setError(err instanceof Error ? err.message : "登录失败");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-slate-950 text-slate-200 flex flex-col">
<div className="flex-1 flex flex-col items-center justify-center p-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full max-w-md space-y-8"
>
<div className="text-center space-y-6">
<div className="inline-flex items-center justify-center">
<LEDLogo />
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold text-slate-100 tracking-tight">
列阵工作台
</h1>
<p className="text-slate-400">
人机协作,效率翻倍
</p>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
className="bg-slate-900/50 backdrop-blur-xl border border-slate-800 rounded-2xl p-8 shadow-2xl"
>
{error && (
<Alert className="mb-6 border-red-500/20 bg-red-500/10 text-red-400">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Tabs defaultValue={loginMode} onValueChange={setLoginMode} className="w-full">
<TabsList className="w-full bg-slate-950/50 border border-slate-700 mb-6">
<TabsTrigger value="apikey" className="flex-1 data-[state=active]:bg-cyan-600">
<Key className="w-4 h-4 mr-2" />
API Key
</TabsTrigger>
<TabsTrigger value="password" className="flex-1 data-[state=active]:bg-cyan-600">
<User className="w-4 h-4 mr-2" />
密码登录
</TabsTrigger>
</TabsList>
<form onSubmit={handleLogin} className="space-y-6">
<div className="space-y-3">
<Label htmlFor="serverUrl" className="text-slate-300 flex items-center gap-2">
<Server className="w-4 h-4" />
服务器地址
</Label>
<Input
id="serverUrl"
placeholder="http://localhost:3000"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
className="h-12 bg-slate-950/50 border-slate-700 text-slate-200 placeholder:text-slate-500 focus:border-cyan-500 focus:ring-cyan-500/20"
/>
</div>
<TabsContent value="apikey" className="space-y-6 mt-0">
<div className="space-y-3">
<Label htmlFor="apiKey" className="text-slate-300 flex items-center gap-2">
<Key className="w-4 h-4" />
API Key
</Label>
<Input
id="apiKey"
type="password"
placeholder="输入您的 API Key"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className="h-12 bg-slate-950/50 border-slate-700 text-slate-200 placeholder:text-slate-500 focus:border-cyan-500 focus:ring-cyan-500/20"
/>
</div>
</TabsContent>
<TabsContent value="password" className="space-y-6 mt-0">
<div className="space-y-3">
<Label htmlFor="username" className="text-slate-300 flex items-center gap-2">
<User className="w-4 h-4" />
用户名
</Label>
<Input
id="username"
placeholder="输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="h-12 bg-slate-950/50 border-slate-700 text-slate-200 placeholder:text-slate-500 focus:border-cyan-500 focus:ring-cyan-500/20"
/>
</div>
<div className="space-y-3">
<Label htmlFor="password" className="text-slate-300 flex items-center gap-2">
<Lock className="w-4 h-4" />
密码
</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="h-12 bg-slate-950/50 border-slate-700 text-slate-200 placeholder:text-slate-500 focus:border-cyan-500 focus:ring-cyan-500/20 pr-12"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-4 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
>
{showPassword ? (
<EyeOff className="w-5 h-5" />
) : (
<Eye className="w-5 h-5" />
)}
</button>
</div>
</div>
</TabsContent>
<Button
type="submit"
disabled={loading}
className="w-full h-12 bg-gradient-to-r from-cyan-600 to-cyan-500 hover:from-cyan-500 hover:to-cyan-400 text-slate-950 font-semibold shadow-lg shadow-cyan-500/25 transition-all hover:shadow-cyan-500/40 disabled:opacity-50"
>
{loading ? "登录中..." : "登录"}
</Button>
</form>
</Tabs>
<div className="mt-8 text-center">
<p className="text-slate-400 text-sm">
还没有账户?{" "}
<button
type="button"
className="text-cyan-400 hover:text-cyan-300 font-medium transition-colors"
>
立即注册
</button>
</p>
</div>
</motion.div>
</motion.div>
</div>
</div>
);
}
+646
View File
@@ -0,0 +1,646 @@
import { useState } from "react";
import {
Users,
Plus,
Bell,
MoreVertical,
Search,
Send,
Pin,
Menu,
Paperclip,
Smile,
AtSign,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { LEDLogo } from "@/components/shared/LEDLogo";
import { LEDMatrix } from "@/components/shared/LEDMatrix";
import { ThemeToggle } from "@/components/shared/ThemeToggle";
import { useAuthStore, useAppStore } from "@/stores";
import { getAvatarColor, getWorkerInitials, formatTime, getRelativeTime } from "@/lib/utils";
import { TASK_STATUSES, WORKER_STATUS } from "@/lib/constants";
import type { Worker, Task, Message } from "@/lib/types";
// 模拟数据
const mockWorkers: Worker[] = [
{
id: "1",
name: "张小明",
type: "human",
status: "online",
organization: "列阵科技",
location: "北京",
},
{
id: "2",
name: "AI 助手",
type: "ai",
status: "online",
organization: "AI 团队",
location: "云端",
},
{
id: "3",
name: "王小红",
type: "human",
status: "offline",
organization: "列阵科技",
location: "上海",
},
{
id: "4",
name: "CodeBot",
type: "ai",
status: "busy",
organization: "AI 团队",
location: "云端",
},
{
id: "5",
name: "李工程师",
type: "human",
status: "online",
organization: "列阵科技",
location: "深圳",
},
];
const mockTasks: Task[] = [
{
id: "1",
title: "设计新功能的 UI",
status: "in_progress",
priority: "high",
creatorId: "1",
createdAt: new Date(Date.now() - 86400000).toISOString(),
isPinned: true,
},
{
id: "2",
title: "重构后端接口",
status: "pending",
priority: "medium",
creatorId: "1",
createdAt: new Date(Date.now() - 172800000).toISOString(),
isPinned: false,
},
{
id: "3",
title: "撰写项目文档",
status: "completed",
priority: "low",
creatorId: "1",
createdAt: new Date(Date.now() - 259200000).toISOString(),
isPinned: false,
},
{
id: "4",
title: "测试环境部署",
status: "in_progress",
priority: "urgent",
creatorId: "1",
createdAt: new Date(Date.now() - 3600000).toISOString(),
isPinned: true,
},
];
const mockMessages: Message[] = [
{
id: "1",
taskId: "1",
senderId: "1",
content: "大家好,今天我们要开始设计新功能的 UI",
type: "text",
createdAt: new Date(Date.now() - 60000 * 30).toISOString(),
},
{
id: "2",
taskId: "1",
senderId: "2",
content: "好的,我准备好了。需要我做什么?",
type: "text",
createdAt: new Date(Date.now() - 60000 * 25).toISOString(),
},
{
id: "3",
taskId: "1",
senderId: "1",
content: "先画一个主界面的原型图吧,参考一下这个方案",
type: "text",
createdAt: new Date(Date.now() - 60000 * 20).toISOString(),
},
{
id: "4",
taskId: "1",
senderId: "2",
content: "好的,我现在就开始画原型图",
type: "text",
createdAt: new Date(Date.now() - 60000 * 5).toISOString(),
},
];
function WorkersPanel() {
const [searchQuery, setSearchQuery] = useState("");
const filteredWorkers = mockWorkers.filter((worker) =>
worker.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="w-72 h-full border-r border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950 flex flex-col">
<div className="p-4 border-b border-slate-200 dark:border-slate-800">
<div className="flex items-center gap-2 mb-4">
<Users className="w-5 h-5 text-slate-500 dark:text-slate-400" />
<h3 className="font-semibold text-slate-800 dark:text-slate-200">成员</h3>
<span className="text-xs text-slate-400 ml-auto">{mockWorkers.length}</span>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="搜索成员..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 bg-slate-100 dark:bg-slate-900 border-slate-300 dark:border-slate-700 text-slate-800 dark:text-slate-200 placeholder:text-slate-500"
/>
</div>
</div>
<ScrollArea className="flex-1">
<div className="p-3 space-y-1">
{(["online", "busy", "offline"] as const).map((status) => {
const statusWorkers = filteredWorkers.filter((w) => w.status === status);
if (statusWorkers.length === 0) return null;
return (
<div key={status} className="space-y-1 mb-4">
<div className="px-2 py-1 text-xs font-semibold text-slate-500 uppercase tracking-wider">
{WORKER_STATUS[status]?.label || status}
</div>
{statusWorkers.map((worker) => (
<button
key={worker.id}
className="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-slate-200 dark:hover:bg-slate-900 transition-colors text-left"
>
<div className="relative">
<Avatar className="h-9 w-9 border-2 border-white dark:border-slate-950">
<AvatarFallback
style={{ backgroundColor: getAvatarColor(worker.id) }}
className="text-white text-sm"
>
{getWorkerInitials(worker.name)}
</AvatarFallback>
</Avatar>
<div
className={`absolute bottom-0 right-0 h-3 w-3 rounded-full border-2 border-white dark:border-slate-950 ${
status === "online"
? "bg-green-500"
: status === "busy"
? "bg-orange-500"
: "bg-slate-400"
}`}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200 truncate">
{worker.name}
</p>
{worker.type === "ai" && (
<Badge className="bg-purple-100 text-purple-700 dark:bg-purple-500/20 dark:text-purple-300 border-purple-200 dark:border-purple-500/30 text-xs">
AI
</Badge>
)}
</div>
<p className="text-xs text-slate-500 truncate">{worker.organization}</p>
</div>
</button>
))}
</div>
);
})}
</div>
</ScrollArea>
</div>
);
}
function TaskList() {
const [activeTab, setActiveTab] = useState("all");
const [selectedTaskId, setSelectedTaskId] = useState("1");
const { setSelectedTask } = useAppStore();
const filteredTasks = mockTasks.filter((task) => {
if (activeTab === "all") return true;
return task.status === activeTab;
});
const handleSelectTask = (taskId: string) => {
setSelectedTaskId(taskId);
const task = mockTasks.find((t) => t.id === taskId) || null;
setSelectedTask(task);
};
return (
<div className="w-96 h-full border-r border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950 flex flex-col">
<div className="p-4 border-b border-slate-200 dark:border-slate-800">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-800 dark:text-slate-200">任务</h3>
<Button size="sm" className="h-9 bg-cyan-600 hover:bg-cyan-500 text-white">
<Plus className="w-4 h-4 mr-2" />
新建
</Button>
</div>
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full bg-slate-100 dark:bg-slate-900 border border-slate-300 dark:border-slate-700">
<TabsTrigger
value="all"
className="flex-1 data-[state=active]:bg-cyan-600 data-[state=active]:text-white"
>
全部
</TabsTrigger>
<TabsTrigger
value="pending"
className="flex-1 data-[state=active]:bg-cyan-600 data-[state=active]:text-white"
>
待处理
</TabsTrigger>
<TabsTrigger
value="in_progress"
className="flex-1 data-[state=active]:bg-cyan-600 data-[state=active]:text-white"
>
进行中
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<ScrollArea className="flex-1">
<div className="p-3 space-y-2">
{filteredTasks.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-slate-500">
<LEDMatrix type="loading" className="mb-4" width={12} height={12} />
<p className="text-sm">暂无任务</p>
</div>
)}
{filteredTasks.map((task) => {
const statusInfo = TASK_STATUSES[task.status] || {
label: task.status,
color: "gray",
};
return (
<div
key={task.id}
onClick={() => handleSelectTask(task.id)}
className={`p-4 rounded-xl border cursor-pointer transition-all ${
selectedTaskId === task.id
? "bg-white dark:bg-slate-900 border-cyan-500/30 shadow-md shadow-cyan-500/10"
: "bg-white dark:bg-slate-950 border-slate-200 dark:border-slate-800 hover:bg-slate-100 dark:hover:bg-slate-900"
}`}
>
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex items-center gap-2">
<h4 className="font-medium text-slate-800 dark:text-slate-200 line-clamp-1">
{task.title}
</h4>
{task.isPinned && <Pin className="w-4 h-4 text-cyan-500" />}
</div>
{task.priority === "urgent" && (
<Badge className="bg-red-500 text-white">紧急</Badge>
)}
</div>
<div className="flex items-center gap-2 mb-3">
<Badge
className={`${
task.status === "in_progress"
? "bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300 border-blue-200 dark:border-blue-500/30"
: task.status === "pending"
? "bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300 border-orange-200 dark:border-orange-500/30"
: "bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300 border-green-200 dark:border-green-500/30"
}`}
>
{statusInfo.label}
</Badge>
{task.priority === "high" && (
<Badge
className="bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300 border-orange-200 dark:border-orange-500/30"
>
高
</Badge>
)}
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">
{getRelativeTime(task.createdAt)}
</span>
</div>
</div>
);
})}
</div>
</ScrollArea>
</div>
);
}
function MessageInput({
onSend,
placeholder,
}: {
onSend: (text: string) => void;
placeholder?: string;
}) {
const [text, setText] = useState("");
const handleSend = () => {
if (text.trim()) {
onSend(text);
setText("");
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
return (
<div className="p-4 border-t border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950">
<div className="max-w-3xl mx-auto flex items-end gap-3">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
<Paperclip className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
<AtSign className="w-5 h-5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
<Smile className="w-5 h-5" />
</Button>
</div>
<div className="flex-1">
<Input
placeholder={placeholder || "输入消息..."}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
className="min-h-[44px] py-3 bg-white dark:bg-slate-900 border-slate-300 dark:border-slate-700 text-slate-800 dark:text-slate-200 placeholder:text-slate-500 resize-none"
/>
</div>
<Button
size="icon"
className="h-10 w-10 bg-cyan-600 hover:bg-cyan-500 text-white"
onClick={handleSend}
disabled={!text.trim()}
>
<Send className="w-5 h-5" />
</Button>
</div>
</div>
);
}
function ChatDetail() {
const [selectedTaskId] = useState("1");
const { currentWorker, currentUser } = useAuthStore();
const selectedTask = mockTasks.find((t) => t.id === selectedTaskId);
const taskMessages = mockMessages.filter((m) => m.taskId === selectedTaskId);
const handleSendMessage = (text: string) => {
console.log("发送消息:", text);
};
if (!selectedTask) {
return (
<div className="flex-1 flex flex-col items-center justify-center bg-slate-50 dark:bg-slate-950">
<div className="text-center space-y-6">
<LEDMatrix type="snake" className="mx-auto" width={16} height={16} />
<div className="space-y-2">
<h3 className="text-lg font-semibold text-slate-700 dark:text-slate-300">
选择任务
</h3>
<p className="text-slate-500">从左侧列表选择一个任务开始协作</p>
</div>
</div>
</div>
);
}
const activeWorker = currentWorker || currentUser;
return (
<div className="flex-1 flex flex-col bg-slate-50 dark:bg-slate-950">
{/* 任务头部 */}
<div className="h-16 px-6 border-b border-slate-200 dark:border-slate-800 flex items-center justify-between bg-white dark:bg-slate-950">
<div className="flex items-center gap-4">
<div>
<h3 className="font-semibold text-slate-800 dark:text-slate-200">
{selectedTask.title}
</h3>
<div className="flex items-center gap-2 mt-1">
<Badge
className={`${
selectedTask.status === "in_progress"
? "bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300 border-blue-200 dark:border-blue-500/30"
: selectedTask.status === "pending"
? "bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300 border-orange-200 dark:border-orange-500/30"
: "bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300 border-green-200 dark:border-green-500/30"
}`}
>
{TASK_STATUSES[selectedTask.status]?.label || selectedTask.status}
</Badge>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
<MoreVertical className="w-5 h-5" />
</Button>
</div>
</div>
{/* 消息区域 */}
<ScrollArea className="flex-1 px-6 py-4">
<div className="max-w-3xl mx-auto space-y-6">
{taskMessages.map((msg) => {
const sender = mockWorkers.find((w) => w.id === msg.senderId);
const isCurrentUser = sender?.id === activeWorker?.id;
return (
<div
key={msg.id}
className={`flex gap-3 ${isCurrentUser ? "flex-row-reverse" : "flex-row"}`}
>
<Avatar className="h-9 w-9 shrink-0">
<AvatarFallback
style={{ backgroundColor: getAvatarColor(sender?.id || "unknown") }}
className="text-white text-sm"
>
{sender ? getWorkerInitials(sender.name) : "??"}
</AvatarFallback>
</Avatar>
<div
className={`max-w-[70%] ${
isCurrentUser ? "items-end" : "items-start"
} flex flex-col gap-1`}
>
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-slate-800 dark:text-slate-200">
{sender?.name}
</span>
<span className="text-xs text-slate-500">
{formatTime(msg.createdAt)}
</span>
</div>
<div
className={`px-4 py-3 rounded-2xl ${
isCurrentUser
? "bg-cyan-600 text-white rounded-tr-sm"
: "bg-white dark:bg-slate-900 text-slate-800 dark:text-slate-200 rounded-tl-sm border border-slate-200 dark:border-slate-800"
}`}
>
<p className="text-sm">{msg.content}</p>
</div>
</div>
</div>
);
})}
</div>
</ScrollArea>
{/* 输入区域 */}
<MessageInput onSend={handleSendMessage} />
</div>
);
}
function TopBar() {
const { currentWorker, currentUser, logout } = useAuthStore();
const navigate = useNavigate();
const activeWorker = currentWorker || currentUser;
const handleLogout = () => {
logout();
navigate("/");
};
return (
<div className="h-14 px-6 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-950 flex items-center justify-between">
<div className="flex items-center gap-4">
<LEDLogo />
<div className="flex items-center gap-2">
<h1 className="font-bold text-lg text-slate-800 dark:text-slate-200">列阵工作台</h1>
<Badge className="bg-cyan-100 text-cyan-700 dark:bg-cyan-500/20 dark:text-cyan-300 border-cyan-200 dark:border-cyan-500/30">
v0.8.0
</Badge>
</div>
<Button
variant="ghost"
size="icon"
className="md:hidden h-9 w-9 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
>
<Menu className="w-5 h-5" />
</Button>
</div>
<div className="flex items-center gap-4">
<div className="hidden md:flex items-center gap-2">
<div className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
<div className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-xs text-slate-600 dark:text-slate-400">已连接</span>
</div>
</div>
<ThemeToggle />
<Button
variant="ghost"
size="icon"
className="h-9 w-9 text-slate-500 hover:text-slate-700 dark:hover:text-slate-300 relative"
>
<Bell className="w-5 h-5" />
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-red-500" />
</Button>
<Separator orientation="vertical" className="h-6" />
<Button
variant="ghost"
className="h-9 px-3 flex items-center gap-2 border border-transparent hover:border-slate-200 dark:hover:border-slate-700"
onClick={handleLogout}
>
<Avatar className="h-7 w-7">
<AvatarFallback
style={{ backgroundColor: getAvatarColor(activeWorker?.id || "unknown") }}
className="text-white text-xs"
>
{activeWorker ? getWorkerInitials(activeWorker.name) : "??"}
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium text-slate-800 dark:text-slate-200 hidden sm:inline">
{activeWorker?.name || "用户"}
</span>
</Button>
</div>
</div>
);
}
export default function WorkbenchPage() {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col">
<TopBar />
<div className="flex-1 flex overflow-hidden">
{/* 桌面端布局 */}
<div className="hidden md:flex flex-1">
<WorkersPanel />
<TaskList />
<ChatDetail />
</div>
{/* 移动端布局 */}
<div className="flex-1 md:hidden flex flex-col">
<div className="flex-1 flex flex-col">
<div className="p-4 border-b border-slate-200 dark:border-slate-800">
<p className="text-center text-slate-600 dark:text-slate-400">
请在桌面端打开查看完整界面
</p>
</div>
<div className="flex-1 flex items-center justify-center">
<LEDMatrix type="tetris" className="mx-auto" width={16} height={16} />
</div>
</div>
</div>
</div>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { Organization, Task, Worker } from "@/lib/types";
interface AppState {
currentOrganization: Organization | null;
selectedTask: Task | null;
workers: Worker[];
organizations: Organization[];
isLoading: boolean;
error: string | null;
}
interface AppActions {
setCurrentOrganization: (org: Organization | null) => void;
setSelectedTask: (task: Task | null) => void;
setWorkers: (workers: Worker[]) => void;
addWorker: (worker: Worker) => void;
updateWorker: (workerId: string, updates: Partial<Worker>) => void;
removeWorker: (workerId: string) => void;
setOrganizations: (orgs: Organization[]) => void;
addOrganization: (org: Organization) => void;
updateOrganization: (orgId: string, updates: Partial<Organization>) => void;
removeOrganization: (orgId: string) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
clearError: () => void;
}
export const useAppStore = create<AppState & AppActions>()(
persist(
(set) => ({
currentOrganization: null,
selectedTask: null,
workers: [],
organizations: [],
isLoading: false,
error: null,
setCurrentOrganization: (org) => set({ currentOrganization: org }),
setSelectedTask: (task) => set({ selectedTask: task }),
setWorkers: (workers) => set({ workers }),
addWorker: (worker) =>
set((state) => ({ workers: [...state.workers, worker] })),
updateWorker: (workerId, updates) =>
set((state) => ({
workers: state.workers.map((w) =>
w.id === workerId ? { ...w, ...updates } : w
),
})),
removeWorker: (workerId) =>
set((state) => ({
workers: state.workers.filter((w) => w.id !== workerId),
})),
setOrganizations: (orgs) => set({ organizations: orgs }),
addOrganization: (org) =>
set((state) => ({ organizations: [...state.organizations, org] })),
updateOrganization: (orgId, updates) =>
set((state) => ({
organizations: state.organizations.map((o) =>
o.id === orgId ? { ...o, ...updates } : o
),
})),
removeOrganization: (orgId) =>
set((state) => ({
organizations: state.organizations.filter((o) => o.id !== orgId),
})),
setLoading: (loading) => set({ isLoading: loading }),
setError: (error) => set({ error }),
clearError: () => set({ error: null }),
}),
{
name: "app-storage",
partialize: (state) => ({
currentOrganization: state.currentOrganization,
organizations: state.organizations,
}),
}
)
);
+56
View File
@@ -0,0 +1,56 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { Worker } from "@/lib/types";
interface AuthState {
isAuthenticated: boolean;
currentUser: Worker | null;
currentWorker: Worker | null; // 别名,用于兼容性
token: string | null;
}
interface AuthActions {
login: (user: Worker, token: string) => void;
logout: () => void;
updateUser: (user: Partial<Worker>) => void;
}
export const useAuthStore = create<AuthState & AuthActions>()(
persist(
(set) => ({
isAuthenticated: false,
currentUser: null,
currentWorker: null,
token: null,
login: (user, token) =>
set({
isAuthenticated: true,
currentUser: user,
currentWorker: user,
token,
}),
logout: () =>
set({
isAuthenticated: false,
currentUser: null,
currentWorker: null,
token: null,
}),
updateUser: (user) =>
set((state) => ({
currentUser: state.currentUser
? { ...state.currentUser, ...user }
: null,
currentWorker: state.currentWorker
? { ...state.currentWorker, ...user }
: null,
})),
}),
{
name: "auth-storage",
}
)
);
+3
View File
@@ -0,0 +1,3 @@
export * from "./authStore";
export * from "./appStore";
export * from "./uiStore";
+104
View File
@@ -0,0 +1,104 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { Notification } from "@/lib/types";
type Theme = "light" | "dark" | "system";
interface UIState {
theme: Theme;
sidebarCollapsed: boolean;
notificationsOpen: boolean;
notifications: Notification[]; // 添加通知列表
modals: Record<string, boolean>;
toasts: Array<{
id: string;
title: string;
description?: string;
type: "success" | "error" | "warning" | "info";
}>;
}
interface UIActions {
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
toggleSidebar: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
toggleNotifications: () => void;
setNotificationsOpen: (open: boolean) => void;
setNotifications: (notifications: Notification[]) => void; // 添加这个方法
openModal: (modalId: string) => void;
closeModal: (modalId: string) => void;
toggleModal: (modalId: string) => void;
addToast: (toast: Omit<UIState["toasts"][0], "id">) => void;
removeToast: (toastId: string) => void;
clearToasts: () => void;
}
export const useUIStore = create<UIState & UIActions>()(
persist(
(set) => ({
theme: "light",
sidebarCollapsed: false,
notificationsOpen: false,
notifications: [], // 初始化
modals: {},
toasts: [],
setTheme: (theme) => set({ theme }),
toggleTheme: () =>
set((state) => ({
theme: state.theme === "light" ? "dark" : state.theme === "dark" ? "system" : "light",
})),
toggleSidebar: () =>
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
setSidebarCollapsed: (collapsed) => set({ sidebarCollapsed: collapsed }),
toggleNotifications: () =>
set((state) => ({ notificationsOpen: !state.notificationsOpen })),
setNotificationsOpen: (open) => set({ notificationsOpen: open }),
setNotifications: (notifications) => set({ notifications }), // 添加这个
openModal: (modalId) =>
set((state) => ({
modals: { ...state.modals, [modalId]: true },
})),
closeModal: (modalId) =>
set((state) => ({
modals: { ...state.modals, [modalId]: false },
})),
toggleModal: (modalId) =>
set((state) => ({
modals: {
...state.modals,
[modalId]: !state.modals[modalId],
},
})),
addToast: (toast) =>
set((state) => ({
toasts: [...state.toasts, { ...toast, id: crypto.randomUUID() }],
})),
removeToast: (toastId) =>
set((state) => ({
toasts: state.toasts.filter((t) => t.id !== toastId),
})),
clearToasts: () => set({ toasts: [] }),
}),
{
name: "ui-storage",
partialize: (state) => ({
theme: state.theme,
sidebarCollapsed: state.sidebarCollapsed,
}),
}
)
);
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+13
View File
@@ -0,0 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
container: {
center: true,
},
extend: {},
},
plugins: [],
};
+36
View File
@@ -0,0 +1,36 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": [
"ES2020",
"DOM",
"DOM.Iterable"
],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": false,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": false,
"noUncheckedSideEffectImports": false,
"forceConsistentCasingInFileNames": false,
"baseUrl": "./",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"src",
"api"
]
}
+30
View File
@@ -0,0 +1,30 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tsconfigPaths from "vite-tsconfig-paths";
import { traeBadgePlugin } from 'vite-plugin-trae-solo-badge';
// https://vite.dev/config/
export default defineConfig({
build: {
sourcemap: 'hidden',
},
plugins: [
react({
babel: {
plugins: [
'react-dev-locator',
],
},
}),
traeBadgePlugin({
variant: 'dark',
position: 'bottom-right',
prodOnly: true,
clickable: true,
clickUrl: 'https://www.trae.ai/solo?showJoin=1',
autoTheme: true,
autoThemeTarget: '#root'
}),
tsconfigPaths()
],
})