Release v0.1.0: 小程序使用建议跳转腾讯文档 + 配置导入导出功能
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
---
|
||||
name: "bugpack-deploy"
|
||||
description: "Deploy and configure BugPack (bug screenshot to AI instructions tool) on Windows with MSYS2. Invoke when user asks to setup/install/deploy BugPack or fix its build/runtime issues."
|
||||
---
|
||||
|
||||
# BugPack Deploy Skill
|
||||
|
||||
This skill guides the deployment of [BugPack](https://github.com/duhuazhu/BugPack) on Windows using MSYS2 environment.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Windows OS
|
||||
- MSYS2 installed (usually at `C:\msys64` or `D:\msys64`)
|
||||
- Internet connection for downloading packages
|
||||
|
||||
## Environment Setup
|
||||
|
||||
### 1. MSYS2 Configuration
|
||||
|
||||
Open MSYS2 terminal and update the system:
|
||||
|
||||
```bash
|
||||
pacman -Syu
|
||||
# If prompted to close terminal, close and reopen, then run:
|
||||
pacman -Su
|
||||
```
|
||||
|
||||
### 2. Install Required Tools
|
||||
|
||||
```bash
|
||||
pacman -S --noconfirm git curl make
|
||||
pacman -S --noconfirm mingw-w64-x86_64-nodejs
|
||||
pacman -S --noconfirm mingw-w64-x86_64-gcc
|
||||
```
|
||||
|
||||
### 3. Node.js Environment Variables
|
||||
|
||||
Add to `~/.bashrc` or export manually:
|
||||
|
||||
```bash
|
||||
export PATH="/mingw64/bin:$PATH"
|
||||
export LD_LIBRARY_PATH="/mingw64/bin:$LD_LIBRARY_PATH"
|
||||
```
|
||||
|
||||
**Note**: `LD_LIBRARY_PATH` is required because `libnode.dll` is in `/mingw64/bin`.
|
||||
|
||||
### 4. Create Node.js Symlinks (Optional but recommended)
|
||||
|
||||
```bash
|
||||
ln -sf /mingw64/bin/node.exe /usr/bin/node
|
||||
ln -sf /mingw64/lib/node_modules/npm/bin/npm-cli.js /usr/bin/npm
|
||||
ln -sf /mingw64/lib/node_modules/npm/bin/npx-cli.js /usr/bin/npx
|
||||
```
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
### 1. Download BugPack
|
||||
|
||||
```bash
|
||||
# Option A: Clone with git
|
||||
git clone https://github.com/duhuazhu/BugPack.git
|
||||
cd BugPack
|
||||
|
||||
# Option B: Download and extract ZIP
|
||||
curl -L -o BugPack.zip https://github.com/duhuazhu/BugPack/archive/refs/heads/main.zip
|
||||
unzip BugPack.zip
|
||||
cd BugPack-main
|
||||
```
|
||||
|
||||
### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Build Project
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 4. Fix Static File Path (Production Mode)
|
||||
|
||||
**Issue**: Server looks for `src/client/index.html` but built files are in `dist/client/`.
|
||||
|
||||
**Fix**: Edit `src/server/index.ts`:
|
||||
|
||||
```typescript
|
||||
// Before (line ~47):
|
||||
const clientDir = path.resolve(__dirname, '../client')
|
||||
|
||||
// After:
|
||||
const clientDir = path.resolve(__dirname, '../../dist/client')
|
||||
const devClientDir = path.resolve(__dirname, '../client')
|
||||
const staticDir = fs.existsSync(clientDir) ? clientDir : devClientDir
|
||||
|
||||
if (fs.existsSync(staticDir)) {
|
||||
app.use(express.static(staticDir))
|
||||
app.get('*', (req, res) => {
|
||||
if (!req.path.startsWith('/api') && !req.path.startsWith('/uploads')) {
|
||||
res.sendFile(path.join(staticDir, 'index.html'))
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Start Server
|
||||
|
||||
```bash
|
||||
# Development mode (with auto-reload)
|
||||
npm run dev:server
|
||||
|
||||
# Or specify custom port
|
||||
PORT=3458 npm run dev:server
|
||||
```
|
||||
|
||||
Server will start at `http://localhost:3457` (or your custom port).
|
||||
|
||||
## Common Issues & Solutions
|
||||
|
||||
### Issue 1: `node: command not found`
|
||||
|
||||
**Cause**: Node.js installed in `/mingw64/bin` but not in PATH.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
export PATH="/mingw64/bin:$PATH"
|
||||
```
|
||||
|
||||
### Issue 2: `libnode.dll: cannot open shared object file`
|
||||
|
||||
**Cause**: DLL not found in library path.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
export LD_LIBRARY_PATH="/mingw64/bin:$LD_LIBRARY_PATH"
|
||||
```
|
||||
|
||||
### Issue 3: `better-sqlite3` build fails with `make: cc: No such file`
|
||||
|
||||
**Cause**: C compiler not installed.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
pacman -S --noconfirm mingw-w64-x86_64-gcc make
|
||||
```
|
||||
|
||||
### Issue 4: `ENOENT: no such file or directory, stat '.../src/client/index.html'`
|
||||
|
||||
**Cause**: Server configured for dev mode but running production build.
|
||||
|
||||
**Solution**: Apply the static file path fix in Step 4 above.
|
||||
|
||||
### Issue 5: `EADDRINUSE: address already in use :::3457`
|
||||
|
||||
**Cause**: Another instance is running.
|
||||
|
||||
**Solution**:
|
||||
```bash
|
||||
# Use different port
|
||||
PORT=3458 npm run dev:server
|
||||
```
|
||||
|
||||
### Issue 6: MSYS2 mirror network issues
|
||||
|
||||
**Symptom**: All mirrors return "Could not resolve host".
|
||||
|
||||
**Solution**: Network environment issue. Try:
|
||||
1. Check internet connection
|
||||
2. Switch to different network
|
||||
3. Or configure specific mirror:
|
||||
```bash
|
||||
cat > /etc/pacman.d/mirrorlist.mingw64 << 'EOF'
|
||||
Server = https://mirrors.tuna.tsinghua.edu.cn/msys2/mingw/x86_64/
|
||||
Server = https://mirrors.ustc.edu.cn/msys2/mingw/x86_64/
|
||||
EOF
|
||||
pacman -Syy
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After deployment, verify:
|
||||
|
||||
```bash
|
||||
# Check server is running
|
||||
curl -s http://localhost:3457 | head -5
|
||||
|
||||
# Expected output: HTML content starting with <!DOCTYPE html>
|
||||
```
|
||||
|
||||
## MCP Server Configuration
|
||||
|
||||
To integrate with Trae/Cursor/Claude Code, add MCP config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Frontend | React 18 + TypeScript + Tailwind CSS |
|
||||
| Backend | Express + Node.js |
|
||||
| Database | SQLite (better-sqlite3) |
|
||||
| Annotation | Fabric.js v6 |
|
||||
| Build Tool | Vite |
|
||||
|
||||
## Data Storage
|
||||
|
||||
All data stored locally:
|
||||
- **Data directory**: `~/.bugpack/data/`
|
||||
- **Database**: `bugpack.db`
|
||||
- **Screenshots**: `uploads/{ProjectName}/{uuid}.{ext}`
|
||||
+1
@@ -0,0 +1 @@
|
||||
ko_fi: W7W51W5EN5
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Bug Report
|
||||
description: 报告一个 Bug / Report a bug
|
||||
labels: [bug]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 问题描述 / Description
|
||||
description: 请清楚描述遇到的问题 / Describe the bug clearly
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: 复现步骤 / Steps to Reproduce
|
||||
description: 如何复现这个问题 / How to reproduce
|
||||
placeholder: |
|
||||
1. ...
|
||||
2. ...
|
||||
3. ...
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: 期望行为 / Expected Behavior
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: BugPack 版本 / Version
|
||||
placeholder: "0.1.0"
|
||||
|
||||
- type: input
|
||||
id: node-version
|
||||
attributes:
|
||||
label: Node.js 版本 / Node.js Version
|
||||
placeholder: ">= 18.x"
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: 操作系统 / OS
|
||||
options:
|
||||
- macOS
|
||||
- Windows
|
||||
- Linux
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions & Discussions
|
||||
url: https://github.com/duhuazhu/BugPack/discussions
|
||||
about: Ask questions, share ideas, or discuss BugPack here
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Feature Request
|
||||
description: 提一个新功能建议 / Suggest a new feature
|
||||
labels: [enhancement]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: 功能描述 / Description
|
||||
description: 你希望 BugPack 增加什么功能?/ What feature would you like?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: 使用场景 / Use Case
|
||||
description: 这个功能解决什么问题?/ What problem does this solve?
|
||||
@@ -0,0 +1,13 @@
|
||||
## 改了什么 / What Changed
|
||||
|
||||
<!-- 简要描述你的改动 / Brief description of changes -->
|
||||
|
||||
## 为什么改 / Why
|
||||
|
||||
<!-- 解决了什么问题或实现了什么功能 / What problem does this solve -->
|
||||
|
||||
## 测试 / Testing
|
||||
|
||||
- [ ] `npx tsc --noEmit` 通过
|
||||
- [ ] `npm run build` 通过
|
||||
- [ ] 已在本地测试功能正常
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ">= 18"
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
@@ -0,0 +1,38 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-journal
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Project internal
|
||||
todo/
|
||||
.mcp.json
|
||||
/产品录屏/
|
||||
/录制项目的视频/
|
||||
BugPack-产品构思文档.md
|
||||
|
||||
# Build cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
*.pid
|
||||
@@ -0,0 +1,23 @@
|
||||
src/
|
||||
docs/
|
||||
todo/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-journal
|
||||
data/
|
||||
.env
|
||||
.env.*
|
||||
.idea/
|
||||
.vscode/
|
||||
.claude/
|
||||
.cursor/
|
||||
.windsurf/
|
||||
.mcp.json
|
||||
.gitignore
|
||||
tsconfig*.json
|
||||
vite.config.ts
|
||||
postcss.config.js
|
||||
tailwind.config.js
|
||||
BugPack-*.md
|
||||
node_modules/
|
||||
@@ -0,0 +1,25 @@
|
||||
English | [中文](CHANGELOG.zh-CN.md)
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [1.0.0] - 2026-03-18
|
||||
|
||||
### Added
|
||||
|
||||
- Bug management with multi-project support
|
||||
- Screenshot paste (Ctrl+V) and annotation tools (arrow, rectangle, text, numbering, etc.)
|
||||
- AI instruction generation from annotated screenshots
|
||||
- MCP Server integration for 10+ AI coding tools (Claude Code, Cursor, Windsurf, VS Code, Cline, etc.)
|
||||
- Batch operations (multi-select, batch delete, batch status change, batch export)
|
||||
- Import/Export (.bugpack zip format)
|
||||
- Third-party platform integration (Jira, Linear, Zentao, TAPD)
|
||||
- Dark/Light theme support
|
||||
- i18n support (Chinese / English)
|
||||
- Keyboard shortcuts (Ctrl+N, Ctrl+Enter, Ctrl+Z/Y, etc.)
|
||||
- OpenClaw Skills for AI agent workflows
|
||||
- GitHub Actions CI pipeline (Node >= 18)
|
||||
- Community files: LICENSE, CONTRIBUTING, CODE_OF_CONDUCT, SECURITY
|
||||
@@ -0,0 +1,25 @@
|
||||
[English](CHANGELOG.md) | 中文
|
||||
|
||||
# 更新日志
|
||||
|
||||
本项目的所有重要变更都将记录在此文件中。
|
||||
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/),版本号遵循 [语义化版本](https://semver.org/)。
|
||||
|
||||
## [1.0.0] - 2026-03-18
|
||||
|
||||
### 新增
|
||||
|
||||
- Bug 管理,支持多项目
|
||||
- 截图粘贴(Ctrl+V)和标注工具(箭头、矩形、文字、编号等)
|
||||
- 从标注截图生成 AI 修复指令
|
||||
- MCP Server 集成,支持 10+ AI 编程工具(Claude Code、Cursor、Windsurf、VS Code、Cline 等)
|
||||
- 批量操作(多选、批量删除、批量改状态、批量导出)
|
||||
- 导入/导出(.bugpack zip 格式)
|
||||
- 第三方平台集成(Jira、Linear、禅道、TAPD)
|
||||
- 深色/浅色主题
|
||||
- 国际化(中文/英文)
|
||||
- 快捷键(Ctrl+N、Ctrl+Enter、Ctrl+Z/Y 等)
|
||||
- OpenClaw 技能包,支持 AI 工作流
|
||||
- GitHub Actions CI 流水线(Node >= 18)
|
||||
- 社区文件:LICENSE、CONTRIBUTING、CODE_OF_CONDUCT、SECURITY
|
||||
@@ -0,0 +1,30 @@
|
||||
English | [中文](CODE_OF_CONDUCT.zh-CN.md)
|
||||
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to a positive environment:
|
||||
|
||||
- Using welcoming and inclusive language
|
||||
- Being respectful of differing viewpoints and experiences
|
||||
- Gracefully accepting constructive criticism
|
||||
- Focusing on what is best for the community
|
||||
|
||||
Examples of unacceptable behavior:
|
||||
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information without explicit permission
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting the maintainer directly.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org/), version 2.1.
|
||||
@@ -0,0 +1,30 @@
|
||||
[English](CODE_OF_CONDUCT.md) | 中文
|
||||
|
||||
# 贡献者行为准则
|
||||
|
||||
## 我们的承诺
|
||||
|
||||
作为成员、贡献者和领导者,我们承诺让每个人在社区中都能拥有无骚扰的参与体验,无论年龄、体型、残障与否、种族、性别特征、性别认同与表达、经验水平、教育程度、社会经济地位、国籍、外貌、宗教或性取向。
|
||||
|
||||
## 我们的标准
|
||||
|
||||
有助于营造积极环境的行为:
|
||||
|
||||
- 使用友好和包容的语言
|
||||
- 尊重不同的观点和经验
|
||||
- 优雅地接受建设性批评
|
||||
- 关注对社区最有利的事情
|
||||
|
||||
不可接受的行为:
|
||||
|
||||
- 挑衅、侮辱或贬损性评论,以及人身或政治攻击
|
||||
- 公开或私下骚扰
|
||||
- 未经明确许可发布他人的私人信息
|
||||
|
||||
## 执行
|
||||
|
||||
如遇到滥用、骚扰或其他不可接受的行为,可通过提交 Issue 或直接联系维护者进行举报。
|
||||
|
||||
## 归属
|
||||
|
||||
本行为准则改编自 [Contributor Covenant](https://www.contributor-covenant.org/) 2.1 版。
|
||||
@@ -0,0 +1,55 @@
|
||||
English | [中文](CONTRIBUTING.zh-CN.md)
|
||||
|
||||
# Contributing
|
||||
|
||||
Thanks for your interest in BugPack!
|
||||
|
||||
## Using BugPack
|
||||
|
||||
If you just want to use BugPack, no need to clone — run it directly via npm:
|
||||
|
||||
```bash
|
||||
# Start Web UI
|
||||
npx bugpack-mcp
|
||||
|
||||
# Start MCP Server (for AI coding tools)
|
||||
npx bugpack-mcp --mcp
|
||||
```
|
||||
|
||||
## Contributing Code
|
||||
|
||||
To contribute code to BugPack:
|
||||
|
||||
1. Fork and clone the repository
|
||||
2. `npm install`
|
||||
3. `npm run dev:all` to start dev mode (frontend + backend with hot reload)
|
||||
4. Develop on a `feature/xxx` branch
|
||||
5. Submit a PR
|
||||
|
||||
## Dev Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run dev:all` | Start frontend + backend (dev mode) |
|
||||
| `npm run build` | Production build |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── client/ # React frontend
|
||||
│ ├── components/ # UI components
|
||||
│ ├── stores/ # Zustand state management
|
||||
│ ├── hooks/ # Custom hooks
|
||||
│ ├── i18n/ # Internationalization (zh/en)
|
||||
│ └── utils/ # Utilities (instruction generation)
|
||||
├── server/ # Express backend
|
||||
│ ├── routes/ # API routes
|
||||
│ └── db.ts # SQLite database
|
||||
└── mcp/ # MCP Server (stdio transport)
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Ensure `npx tsc --noEmit` passes before submitting
|
||||
- Update i18n files in `src/client/i18n/` for both zh and en if applicable
|
||||
@@ -0,0 +1,55 @@
|
||||
[English](CONTRIBUTING.md) | 中文
|
||||
|
||||
# 贡献指南
|
||||
|
||||
感谢你对 BugPack 的关注!
|
||||
|
||||
## 使用 BugPack
|
||||
|
||||
如果只是使用 BugPack,无需克隆仓库,直接通过 npm 运行:
|
||||
|
||||
```bash
|
||||
# 启动 Web UI
|
||||
npx bugpack-mcp
|
||||
|
||||
# 启动 MCP Server(供 AI 编程工具使用)
|
||||
npx bugpack-mcp --mcp
|
||||
```
|
||||
|
||||
## 贡献代码
|
||||
|
||||
参与 BugPack 开发:
|
||||
|
||||
1. Fork 并克隆仓库
|
||||
2. `npm install`
|
||||
3. `npm run dev:all` 启动开发模式(前端 + 后端热重载)
|
||||
4. 在 `feature/xxx` 分支上开发
|
||||
5. 提交 PR
|
||||
|
||||
## 开发命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `npm run dev:all` | 启动前端 + 后端(开发模式) |
|
||||
| `npm run build` | 生产构建 |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── client/ # React 前端
|
||||
│ ├── components/ # UI 组件
|
||||
│ ├── stores/ # Zustand 状态管理
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ ├── i18n/ # 国际化(中文/英文)
|
||||
│ └── utils/ # 工具函数(指令生成)
|
||||
├── server/ # Express 后端
|
||||
│ ├── routes/ # API 路由
|
||||
│ └── db.ts # SQLite 数据库
|
||||
└── mcp/ # MCP Server(stdio 传输)
|
||||
```
|
||||
|
||||
## 规范
|
||||
|
||||
- 提交前确保 `npx tsc --noEmit` 通过
|
||||
- 如涉及界面文案,需同时更新 `src/client/i18n/` 中的中英文文件
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026-present duhuazhu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,317 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/public/favicon.svg" width="80" alt="BugPack">
|
||||
</p>
|
||||
|
||||
<h1 align="center">BugPack</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Package bug screenshots into AI-ready fix instructions in 30 seconds</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/duhuazhu/BugPack/actions/workflows/ci.yml"><img src="https://github.com/duhuazhu/BugPack/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://www.npmjs.com/package/bugpack-mcp"><img src="https://img.shields.io/npm/v/bugpack-mcp.svg" alt="npm version"></a>
|
||||
<a href="https://www.npmjs.com/package/bugpack-mcp"><img src="https://img.shields.io/npm/dm/bugpack-mcp.svg" alt="npm downloads"></a>
|
||||
<a href="https://github.com/duhuazhu/BugPack/blob/main/LICENSE"><img src="https://img.shields.io/github/license/duhuazhu/BugPack.svg" alt="license"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#quick-start">Quick Start</a> · <a href="#mcp-configuration">MCP Config</a> · <a href="#openclaw-skills">OpenClaw</a> · <a href="#features">Features</a> · <a href="#platform-integrations">Integrations</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
English | <a href="README.zh-CN.md">中文</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## What is BugPack?
|
||||
|
||||
BugPack is a **local-first** tool that packages bug screenshots into structured, AI-ready fix instructions.
|
||||
|
||||
QA drops a screenshot in the chat → you `Ctrl+V` paste it into BugPack → annotate the issue → generate structured instructions → feed them to your AI coding agent.
|
||||
|
||||
Or skip the copy-paste entirely: BugPack's built-in **MCP Server** lets any MCP-compatible AI coding tool (Claude Code, Cursor, Windsurf, Cline, etc.) **read bug context and fix code automatically**.
|
||||
|
||||
## Why BugPack?
|
||||
|
||||
AI coding agents changed how we write code, but not how we **communicate bug context**.
|
||||
|
||||
Every bug fix still requires: save screenshot → create file → write paths → describe the issue → paste to AI.
|
||||
10 bugs a day = **1-2 hours of pure repetition**.
|
||||
|
||||
BugPack compresses this to **30 seconds**.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js** >= 18
|
||||
- **OS** — Windows / macOS / Linux
|
||||
- **Browser** — Chrome / Edge / Firefox (Chrome recommended)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
npx bugpack-mcp
|
||||
```
|
||||
|
||||
Open `http://localhost:3456` and `Ctrl+V` your first bug screenshot to get started.
|
||||
|
||||
## MCP Configuration
|
||||
|
||||
BugPack works with **any MCP-compatible AI coding tool**. Here are common examples — configure other tools the same way.
|
||||
|
||||
**Claude Code** — add to `~/.claude.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Cursor / Windsurf / VS Code / Cline / Roo Code / Trae / MarsCode / Augment</b></summary>
|
||||
|
||||
**Cursor** (`.cursor/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Windsurf** (`~/.codeium/windsurf/mcp_config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**VS Code** (`.vscode/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Cline / Roo Code** (VS Code Settings):
|
||||
|
||||
```json
|
||||
{
|
||||
"cline.mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Trae** (`trae/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**MarsCode** (Settings → MCP):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Augment** (`augment/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All other MCP-compatible tools follow the same pattern — just point `command` to `npx` and `args` to `["bugpack-mcp", "--mcp"]`.
|
||||
|
||||
</details>
|
||||
|
||||
Once configured, just tell your AI:
|
||||
|
||||
- **"Show me pending bugs"** → AI calls `list_bugs`
|
||||
- **"Fix bug #3"** → AI calls `get_bug_context`, locates code, and fixes it
|
||||
- **"Mark bug #3 as fixed"** → AI calls `mark_bug_status`
|
||||
|
||||
## OpenClaw Skills
|
||||
|
||||
BugPack provides [OpenClaw](https://github.com/openclaw/openclaw) Skills for AI agents that support the OpenClaw protocol.
|
||||
|
||||
**Install via CLI:**
|
||||
|
||||
```bash
|
||||
clawhub install bugpack
|
||||
```
|
||||
|
||||
**Or add to `~/.openclaw/openclaw.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"entries": {
|
||||
"bugpack": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"extraDirs": ["./skills"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Or manually**: copy the `skills/` directory from this repo into your workspace or `~/.openclaw/skills/`.
|
||||
|
||||
BugPack includes 3 built-in skills:
|
||||
|
||||
| Skill | Triggers | Description |
|
||||
|-------|----------|-------------|
|
||||
| `bugpack-list-bugs` | "show me bugs" / "list bugs" | List all bugs with status filtering |
|
||||
| `bugpack-view-bug` | "view bug" / "bug context" | Get full bug details with screenshots and related files |
|
||||
| `bugpack-fix-bug` | "fix bug" / "repair bug" | Read context → locate code → apply fix → update status |
|
||||
|
||||
Once installed, just tell your AI:
|
||||
|
||||
- **"Show me bugs"** → AI calls `bugpack-list-bugs`
|
||||
- **"View bug details"** → AI calls `bugpack-view-bug`, shows screenshots and context
|
||||
- **"Fix this bug"** → AI calls `bugpack-fix-bug`, locates code, fixes it, and marks as done
|
||||
|
||||
> **Note:** OpenClaw Skills require BugPack server running (`npx bugpack-mcp`). Skills communicate with the local server via REST API on `http://localhost:3456`.
|
||||
|
||||
## Features
|
||||
|
||||
### Screenshots & Annotations
|
||||
|
||||
- **Clipboard paste** — `Ctrl+V` to paste screenshots directly from any chat tool
|
||||
- **Drag & drop** — drop image files onto the canvas
|
||||
- **9 annotation tools** — drag/pan, select, rectangle, arrow, text, numbering, highlight, pen, mosaic
|
||||
- **Compare mode** — side-by-side comparison of "current" vs "expected" behavior
|
||||
- **Undo / Redo** — full operation history
|
||||
|
||||
### AI Instruction Generation
|
||||
|
||||
- **One-click generation** — produces structured Markdown fix instructions
|
||||
- **Universal MCP support** — works with any MCP-compatible AI coding tool
|
||||
|
||||
### MCP Server
|
||||
|
||||
Built-in MCP Server lets AI coding agents **directly access bug context**:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_bugs` | List all bugs with status/project filtering |
|
||||
| `get_bug_context` | Get full bug context (description + screenshots + environment + files) |
|
||||
| `get_bug_screenshot` | Get a single annotated screenshot (base64) |
|
||||
| `mark_bug_status` | Update bug status |
|
||||
| `add_fix_note` | Add fix notes after repair |
|
||||
|
||||
### Platform Integrations
|
||||
|
||||
Import bugs from project management platforms, sync fix status back:
|
||||
|
||||
- **Zentao** · **Jira** · **Linear** · **TAPD**
|
||||
|
||||
### More
|
||||
|
||||
- **100% local** — data never leaves your machine, SQLite storage
|
||||
- **Multi-project** — manage bugs independently per project
|
||||
- **Dark / Light theme** — follow your preference
|
||||
- **i18n** — Chinese / English
|
||||
- **Keyboard shortcuts** — efficient workflow
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
Paste screenshot → Describe issue → Generate instructions → AI fixes code
|
||||
│ │ │
|
||||
│ ┌──────────────┘ │
|
||||
▼ ▼ ▼
|
||||
BugPack Copy Markdown MCP Server
|
||||
Canvas paste to AI tool AI reads & fixes directly
|
||||
```
|
||||
|
||||
## Data Storage
|
||||
|
||||
All data is stored locally:
|
||||
|
||||
- **Data directory**: `~/.bugpack/data/`
|
||||
- **Database**: `bugpack.db` (SQLite)
|
||||
- **Screenshots**: `uploads/{ProjectName}/{uuid}.{ext}`
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| Frontend | React 18 · TypeScript · Tailwind CSS · Zustand |
|
||||
| Annotation | Fabric.js v6 |
|
||||
| Backend | Node.js · Express |
|
||||
| Database | SQLite (better-sqlite3, WAL mode) |
|
||||
| MCP | @modelcontextprotocol/sdk |
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**If BugPack saves you time, give it a Star!**
|
||||
|
||||
[](https://ko-fi.com/W7W51W5EN5)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/alipay.jpg" width="180" alt="Alipay"> <img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/wechat.jpg" width="180" alt="WeChat Pay">
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,310 @@
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/public/favicon.svg" width="80" alt="BugPack">
|
||||
</p>
|
||||
|
||||
<h1 align="center">BugPack</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>30 秒将 Bug 截图打包为 AI 可读的修复指令</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/duhuazhu/BugPack/actions/workflows/ci.yml"><img src="https://github.com/duhuazhu/BugPack/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://www.npmjs.com/package/bugpack-mcp"><img src="https://img.shields.io/npm/v/bugpack-mcp.svg" alt="npm version"></a>
|
||||
<a href="https://www.npmjs.com/package/bugpack-mcp"><img src="https://img.shields.io/npm/dm/bugpack-mcp.svg" alt="npm downloads"></a>
|
||||
<a href="https://github.com/duhuazhu/BugPack/blob/main/LICENSE"><img src="https://img.shields.io/github/license/duhuazhu/BugPack.svg" alt="license"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#快速开始">快速开始</a> · <a href="#mcp-配置">MCP 配置</a> · <a href="#openclaw-技能">OpenClaw</a> · <a href="#功能特性">功能特性</a> · <a href="#平台集成">平台集成</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="README.md">English</a> | 中文
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## 什么是 BugPack?
|
||||
|
||||
BugPack 是一个**本地优先**的工具,将 Bug 截图打包为结构化的、AI 可读的修复指令。
|
||||
|
||||
测试人员在群里发了截图 → 你 `Ctrl+V` 粘贴到 BugPack → 标注问题区域 → 生成结构化指令 → 喂给 AI 编程助手。
|
||||
|
||||
或者跳过复制粘贴:BugPack 内置 **MCP Server**,让任何兼容 MCP 的 AI 编程工具(Claude Code、Cursor、Windsurf、Cline 等)**直接读取 Bug 上下文并自动修复代码**。
|
||||
|
||||
## 为什么用 BugPack?
|
||||
|
||||
AI 编程助手改变了我们写代码的方式,但没有改变我们**传递 Bug 上下文**的方式。
|
||||
|
||||
每次修 Bug 仍然需要:保存截图 → 创建文件 → 写路径 → 描述问题 → 粘贴给 AI。
|
||||
一天 10 个 Bug = **1-2 小时的纯重复劳动**。
|
||||
|
||||
BugPack 把这个过程压缩到 **30 秒**。
|
||||
|
||||
## 环境要求
|
||||
|
||||
- **Node.js** >= 18
|
||||
- **操作系统** — Windows / macOS / Linux
|
||||
- **浏览器** — Chrome / Edge / Firefox(推荐 Chrome)
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
npx bugpack-mcp
|
||||
```
|
||||
|
||||
打开 `http://localhost:3456`,`Ctrl+V` 粘贴你的第一张 Bug 截图即可开始。
|
||||
|
||||
## MCP 配置
|
||||
|
||||
BugPack 兼容**任何支持 MCP 的 AI 编程工具**。以下是常见配置示例。
|
||||
|
||||
**Claude Code** — 添加到 `~/.claude.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>Cursor / Windsurf / VS Code / Cline / Roo Code / Trae / MarsCode / Augment</b></summary>
|
||||
|
||||
**Cursor** (`.cursor/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Windsurf** (`~/.codeium/windsurf/mcp_config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**VS Code** (`.vscode/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Cline / Roo Code**(VS Code 设置):
|
||||
|
||||
```json
|
||||
{
|
||||
"cline.mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Trae** (`trae/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**MarsCode**(设置 → MCP):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Augment** (`augment/mcp.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"bugpack": {
|
||||
"command": "npx",
|
||||
"args": ["bugpack-mcp", "--mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
所有兼容 MCP 的工具配置方式相同 — `command` 指向 `npx`,`args` 设为 `["bugpack-mcp", "--mcp"]`。
|
||||
|
||||
</details>
|
||||
|
||||
配置完成后,直接告诉你的 AI:
|
||||
|
||||
- **"显示待修复的 Bug"** → AI 调用 `list_bugs`
|
||||
- **"修复 Bug #3"** → AI 调用 `get_bug_context`,定位代码并修复
|
||||
- **"标记 Bug #3 为已修复"** → AI 调用 `mark_bug_status`
|
||||
|
||||
## OpenClaw 技能
|
||||
|
||||
BugPack 提供 [OpenClaw](https://github.com/openclaw/openclaw) 技能包,支持 OpenClaw 协议的 AI 助手可直接使用。
|
||||
|
||||
**通过 CLI 安装:**
|
||||
|
||||
```bash
|
||||
clawhub install bugpack
|
||||
```
|
||||
|
||||
**或添加到 `~/.openclaw/openclaw.json`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"entries": {
|
||||
"bugpack": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"extraDirs": ["./skills"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**或手动安装**:将本仓库的 `skills/` 目录复制到你的工作区或 `~/.openclaw/skills/`。
|
||||
|
||||
BugPack 包含 3 个内置技能:
|
||||
|
||||
| 技能 | 触发方式 | 说明 |
|
||||
|------|----------|------|
|
||||
| `bugpack-list-bugs` | "显示 Bug" / "列出 Bug" | 列出所有 Bug,支持状态过滤 |
|
||||
| `bugpack-view-bug` | "查看 Bug" / "Bug 详情" | 获取完整 Bug 详情,包含截图和关联文件 |
|
||||
| `bugpack-fix-bug` | "修复 Bug" / "修 Bug" | 读取上下文 → 定位代码 → 修复 → 更新状态 |
|
||||
|
||||
> **注意:** OpenClaw 技能需要 BugPack 服务运行中(`npx bugpack-mcp`)。技能通过 REST API 与本地服务通信(`http://localhost:3456`)。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 截图与标注
|
||||
|
||||
- **剪贴板粘贴** — `Ctrl+V` 从任何聊天工具直接粘贴截图
|
||||
- **拖放上传** — 拖放图片文件到画布
|
||||
- **9 种标注工具** — 拖拽/平移、选择、矩形、箭头、文字、编号、高亮、画笔、马赛克
|
||||
- **对比模式** — 并排对比"当前效果"与"预期效果"
|
||||
- **撤销/重做** — 完整操作历史
|
||||
|
||||
### AI 指令生成
|
||||
|
||||
- **一键生成** — 生成结构化 Markdown 修复指令
|
||||
- **通用 MCP 支持** — 兼容任何支持 MCP 的 AI 编程工具
|
||||
|
||||
### MCP Server
|
||||
|
||||
内置 MCP Server 让 AI 编程助手**直接访问 Bug 上下文**:
|
||||
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `list_bugs` | 列出所有 Bug,支持状态/项目过滤 |
|
||||
| `get_bug_context` | 获取完整 Bug 上下文(描述 + 截图 + 环境 + 文件) |
|
||||
| `get_bug_screenshot` | 获取单张标注截图(base64) |
|
||||
| `mark_bug_status` | 更新 Bug 状态 |
|
||||
| `add_fix_note` | 修复后添加备注 |
|
||||
|
||||
### 平台集成
|
||||
|
||||
从项目管理平台导入 Bug,同步修复状态:
|
||||
|
||||
- **禅道** · **Jira** · **Linear** · **TAPD**
|
||||
|
||||
### 更多
|
||||
|
||||
- **100% 本地** — 数据不离开你的机器,SQLite 存储
|
||||
- **多项目管理** — 按项目独立管理 Bug
|
||||
- **深色/浅色主题** — 跟随你的偏好
|
||||
- **国际化** — 中文 / 英文
|
||||
- **快捷键** — 高效工作流
|
||||
|
||||
## 工作流
|
||||
|
||||
```
|
||||
粘贴截图 → 描述问题 → 生成指令 → AI 修复代码
|
||||
│ │ │
|
||||
│ ┌────────────┘ │
|
||||
▼ ▼ ▼
|
||||
BugPack 复制 Markdown MCP Server
|
||||
画布 粘贴给 AI 工具 AI 直接读取并修复
|
||||
```
|
||||
|
||||
## 数据存储
|
||||
|
||||
所有数据存储在本地:
|
||||
|
||||
- **数据目录**:`~/.bugpack/data/`
|
||||
- **数据库**:`bugpack.db`(SQLite)
|
||||
- **截图**:`uploads/{项目名}/{uuid}.{ext}`
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 前端 | React 18 · TypeScript · Tailwind CSS · Zustand |
|
||||
| 标注 | Fabric.js v6 |
|
||||
| 后端 | Node.js · Express |
|
||||
| 数据库 | SQLite (better-sqlite3, WAL 模式) |
|
||||
| MCP | @modelcontextprotocol/sdk |
|
||||
|
||||
## 贡献
|
||||
|
||||
请查看 [CONTRIBUTING.md](CONTRIBUTING.md) 了解贡献指南。
|
||||
|
||||
## 许可证
|
||||
|
||||
[MIT](LICENSE)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
|
||||
**如果 BugPack 帮到了你,请给个 Star!**
|
||||
|
||||
[](https://ko-fi.com/W7W51W5EN5)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/alipay.jpg" width="180" alt="支付宝"> <img src="https://raw.githubusercontent.com/duhuazhu/BugPack/main/assets/wechat.jpg" width="180" alt="微信支付">
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
English | [中文](SECURITY.zh-CN.md)
|
||||
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
If you discover a security vulnerability in BugPack, please report it responsibly:
|
||||
|
||||
1. **Do NOT** open a public GitHub issue
|
||||
2. Email the maintainer directly or use [GitHub private vulnerability reporting](https://github.com/duhuazhu/BugPack/security/advisories/new)
|
||||
3. Include steps to reproduce the issue
|
||||
|
||||
We will respond within 72 hours and work on a fix as soon as possible.
|
||||
|
||||
## Scope
|
||||
|
||||
BugPack runs **100% locally** on your machine. All data (SQLite database, screenshots) is stored in `~/.bugpack/data/` and never transmitted externally.
|
||||
|
||||
The MCP Server communicates via **stdio** only — no network exposure.
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
|---------|-----------|
|
||||
| 1.x | Yes |
|
||||
| < 1.0 | No |
|
||||
@@ -0,0 +1,26 @@
|
||||
[English](SECURITY.md) | 中文
|
||||
|
||||
# 安全策略
|
||||
|
||||
## 报告漏洞
|
||||
|
||||
如果你发现 BugPack 中的安全漏洞,请负责任地报告:
|
||||
|
||||
1. **不要**创建公开的 GitHub Issue
|
||||
2. 直接联系维护者,或使用 [GitHub 私密漏洞报告](https://github.com/duhuazhu/BugPack/security/advisories/new)
|
||||
3. 附上复现步骤
|
||||
|
||||
我们会在 72 小时内回复,并尽快修复。
|
||||
|
||||
## 范围
|
||||
|
||||
BugPack **100% 本地运行**。所有数据(SQLite 数据库、截图)存储在 `~/.bugpack/data/`,不会传输到外部。
|
||||
|
||||
MCP Server 仅通过 **stdio** 通信,无网络暴露。
|
||||
|
||||
## 支持版本
|
||||
|
||||
| 版本 | 是否支持 |
|
||||
|------|----------|
|
||||
| 1.x | 是 |
|
||||
| < 1.0 | 否 |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 345 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { fileURLToPath, pathToFileURL } from 'url'
|
||||
import path from 'path'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
if (process.argv.includes('--mcp')) {
|
||||
await import(pathToFileURL(path.join(__dirname, '../dist/mcp/index.js')).href)
|
||||
} else {
|
||||
// Default port 3456 for production
|
||||
if (!process.env.PORT) process.env.PORT = '3456'
|
||||
await import(pathToFileURL(path.join(__dirname, '../dist/server/index.js')).href)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<!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" />
|
||||
<meta name="description" content="Pack bug screenshots into AI-ready fix instructions with MCP Server integration" />
|
||||
<meta name="theme-color" content="#002FA7" />
|
||||
<meta property="og:title" content="BugPack" />
|
||||
<meta property="og:description" content="Pack bug screenshots into AI-ready fix instructions with MCP Server integration" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://github.com/duhuazhu/BugPack" />
|
||||
<title>BugPack</title>
|
||||
<script>
|
||||
// Sync theme before render to prevent flash
|
||||
try {
|
||||
var xhr = new XMLHttpRequest()
|
||||
xhr.open('GET', '/api/settings', false)
|
||||
xhr.send()
|
||||
if (xhr.status === 200) {
|
||||
var data = JSON.parse(xhr.responseText)
|
||||
if (data.theme === 'light') {
|
||||
document.documentElement.classList.add('light')
|
||||
}
|
||||
}
|
||||
} catch(e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-bg-primary text-text-primary">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/client/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+8061
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"name": "bugpack-mcp",
|
||||
"version": "1.0.5",
|
||||
"description": "Pack bug screenshots into AI coding instructions with MCP Server integration",
|
||||
"author": "duhuazhu",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/duhuazhu/BugPack"
|
||||
},
|
||||
"homepage": "https://github.com/duhuazhu/BugPack",
|
||||
"bugs": {
|
||||
"url": "https://github.com/duhuazhu/BugPack/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"mcp",
|
||||
"mcp-server",
|
||||
"model-context-protocol",
|
||||
"bug",
|
||||
"bug-tracking",
|
||||
"bug-report",
|
||||
"screenshot",
|
||||
"annotation",
|
||||
"ai",
|
||||
"claude",
|
||||
"cursor",
|
||||
"windsurf",
|
||||
"vscode",
|
||||
"cline",
|
||||
"trae",
|
||||
"marscode",
|
||||
"developer-tools",
|
||||
"qa",
|
||||
"testing",
|
||||
"fix-instructions",
|
||||
"code-fix"
|
||||
],
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"bugpack-mcp": "bin/bugpack.mjs"
|
||||
},
|
||||
"files": [
|
||||
"bin/",
|
||||
"dist/",
|
||||
"LICENSE",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:server": "tsx watch src/server/index.ts",
|
||||
"dev:all": "concurrently \"npm run dev\" \"npm run dev:server\"",
|
||||
"mcp": "tsx src/mcp/index.ts",
|
||||
"build": "vite build && tsc -p tsconfig.server.json",
|
||||
"prepublishOnly": "npm run build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^11.7.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"concurrently": "^9.1.2",
|
||||
"fabric": "^6.6.1",
|
||||
"lucide-react": "^0.469.0",
|
||||
"marked": "^17.0.4",
|
||||
"morphdom": "^2.7.8",
|
||||
"postcss": "^8.4.49",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.5",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="450 105 490 550">
|
||||
<path d="M 702.50 642.16 C694.92,644.85 689.33,644.29 681.00,640.02 C677.31,638.12 638.15,615.52 599.50,592.99 C503.93,537.26 476.74,521.16 473.00,518.04 C470.41,515.89 467.22,511.82 465.50,508.47 L 462.50 502.64 L 462.50 253.50 L 465.29 247.82 C468.69,240.89 472.25,237.67 484.24,230.67 C489.33,227.71 513.08,213.81 537.00,199.81 C560.92,185.80 590.85,168.34 603.50,161.02 C616.15,153.69 638.20,140.85 652.50,132.49 C678.83,117.09 683.42,114.15 688.26,113.64 C689.40,113.52 690.56,113.53 692.02,113.55 C692.62,113.56 693.27,113.57 694.00,113.57 C694.70,113.57 695.33,113.56 695.91,113.56 C697.46,113.54 698.67,113.53 699.86,113.66 C704.62,114.19 709.11,117.06 733.50,131.31 C746.70,139.02 772.35,153.95 790.50,164.49 C824.05,183.97 881.98,217.75 903.19,230.21 C915.17,237.24 918.45,240.19 922.30,247.38 L 924.50 251.50 L 924.50 504.50 L 921.64 509.66 C916.70,518.58 916.66,518.60 870.50,545.21 C863.35,549.33 823.75,572.43 782.50,596.54 C741.25,620.66 705.25,641.19 702.50,642.16 ZM 717.16 351.25 C716.97,359.36 717.09,366.00 717.43,366.00 C718.37,366.00 718.98,365.61 781.00,325.17 C799.97,312.80 828.10,294.59 843.50,284.71 C858.90,274.82 871.85,266.42 872.29,266.03 C873.08,265.32 834.82,242.08 828.50,239.44 C824.41,237.73 822.73,237.67 817.71,239.06 C813.09,240.35 808.52,244.25 771.50,278.49 C725.40,321.12 724.22,322.31 720.64,329.86 C717.63,336.22 717.49,337.13 717.16,351.25 ZM 663.00 361.57 C666.58,363.93 669.84,365.89 670.25,365.93 C671.64,366.06 671.05,343.62 669.51,337.69 C666.69,326.86 664.00,323.17 647.82,307.83 C622.45,283.77 577.69,242.89 574.65,240.98 C570.40,238.32 563.84,237.62 559.62,239.39 C557.67,240.20 546.79,246.41 535.45,253.18 C518.83,263.09 515.14,265.68 516.45,266.49 C520.99,269.30 650.45,353.30 663.00,361.57 ZM 728.28 386.39 C727.94,386.73 748.81,387.00 774.66,387.00 L 821.64 387.00 L 825.21 384.25 C827.17,382.74 836.75,374.75 846.50,366.50 C856.26,358.25 866.42,349.80 869.09,347.73 C871.76,345.65 875.19,341.83 876.72,339.23 L 879.50 334.50 L 879.86 311.75 C880.10,296.21 879.88,289.00 879.16,289.00 C877.81,289.00 863.66,298.04 809.77,333.31 C793.12,344.21 768.11,360.47 754.19,369.45 C740.28,378.43 728.61,386.05 728.28,386.39 ZM 563.40 384.75 L 566.39 387.00 L 613.39 387.00 C639.25,387.00 659.97,386.63 659.45,386.18 C658.93,385.73 652.65,381.61 645.50,377.02 C638.35,372.43 627.55,365.46 621.50,361.53 C615.45,357.60 606.33,351.71 601.24,348.44 C596.15,345.17 580.61,335.08 566.71,326.00 C517.54,293.89 509.90,289.00 508.95,289.00 C508.38,289.00 508.00,297.70 508.00,310.98 C508.00,328.98 508.30,333.70 509.63,337.04 C510.98,340.40 515.63,344.80 535.83,361.81 C549.35,373.19 561.75,383.51 563.40,384.75 ZM 532.00 508.17 C532.00,508.63 549.69,509.00 571.32,509.00 C602.96,509.00 611.60,508.71 615.57,507.53 C620.89,505.95 624.09,502.99 641.55,483.50 C646.48,478.00 653.93,469.67 658.12,465.00 C664.62,457.74 667.85,454.42 669.45,450.45 C671.03,446.52 671.02,441.96 671.00,432.32 C671.00,431.23 671.00,430.08 671.00,428.86 C671.00,417.94 670.70,409.00 670.33,409.00 C669.97,409.00 664.90,412.44 659.08,416.64 C617.21,446.89 562.08,486.31 543.75,499.12 C537.29,503.64 532.00,507.71 532.00,508.17 ZM 771.81 507.42 C775.54,508.56 784.52,508.88 815.58,508.93 C837.08,508.97 854.95,508.72 855.30,508.37 C855.92,507.75 849.23,502.83 804.50,471.01 C792.40,462.40 774.17,449.35 764.00,442.01 C725.01,413.88 718.15,409.00 717.58,409.00 C717.26,409.00 717.00,418.36 717.00,429.81 L 717.00 450.61 L 732.62 468.06 C764.31,503.46 766.69,505.84 771.81,507.42 ZM 543.00 476.25 C543.00,476.66 543.19,477.00 543.42,477.00 C543.66,477.00 552.09,471.29 562.17,464.31 C584.47,448.87 594.78,441.77 619.76,424.68 L 639.01 411.50 L 608.76 411.21 C592.12,411.05 577.10,411.17 575.39,411.48 C567.32,412.93 558.62,422.32 555.93,432.50 C555.35,434.70 552.20,445.27 548.93,456.00 C545.67,466.73 543.00,475.84 543.00,476.25 ZM 830.79 467.70 C837.23,472.21 843.00,475.92 843.61,475.95 C844.62,476.00 842.43,468.08 831.96,433.87 C829.54,425.96 828.20,423.33 824.48,419.19 C817.13,411.02 816.49,410.90 780.27,411.22 L 749.05 411.50 L 784.06 435.50 C803.32,448.70 824.35,463.19 830.79,467.70 Z" fill="#002FA7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,53 @@
|
||||
---
|
||||
name: bugpack-fix-bug
|
||||
description: "Fix a bug from BugPack by reading its context, locating code, applying fixes, and updating status. Use when: user asks to fix, repair, or resolve a bug. NOT for: just listing bugs (use bugpack-list-bugs) or just viewing bug details (use bugpack-view-bug)."
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: "\U0001F527"
|
||||
---
|
||||
|
||||
# BugPack - Fix Bug
|
||||
|
||||
Read bug context from BugPack, locate the relevant code, apply a fix, and mark the bug as fixed.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. **Get bug context**: Call `GET http://localhost:3456/api/bugs/:id` to fetch full bug details including description, screenshots, environment, and related files.
|
||||
|
||||
2. **Analyze the bug**: Read the description and examine the screenshots to understand what is broken and what the expected behavior should be.
|
||||
|
||||
3. **Locate code**: Use the `relatedFiles` array from the bug context to find the relevant source files. If `relatedFiles` is empty, use the `pagePath` and `description` to search the codebase.
|
||||
|
||||
4. **Apply fix**: Edit the source code to fix the described issue. Follow the project's existing code style and conventions.
|
||||
|
||||
5. **Mark as fixed**: After applying the fix, call `PATCH http://localhost:3456/api/bugs/:id` with:
|
||||
```json
|
||||
{ "status": "fixed" }
|
||||
```
|
||||
|
||||
6. **Add fix note** (optional): Call `PATCH http://localhost:3456/api/bugs/:id` with a description update to document what was changed.
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
# Step 1: Get bug context
|
||||
GET http://localhost:3456/api/bugs/abc-123
|
||||
|
||||
# Step 5: Mark as fixed
|
||||
PATCH http://localhost:3456/api/bugs/abc-123
|
||||
Content-Type: application/json
|
||||
|
||||
{ "status": "fixed" }
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": "abc-123",
|
||||
"status": "fixed"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: bugpack-list-bugs
|
||||
description: "List all tracked bugs from BugPack with status and project filtering. Use when: user asks about bugs, pending issues, bug lists, or wants to see what needs fixing. NOT for: viewing detailed bug context (use bugpack-view-bug) or fixing bugs (use bugpack-fix-bug)."
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: "\U0001F41B"
|
||||
---
|
||||
|
||||
# BugPack - List Bugs
|
||||
|
||||
Query the BugPack local server to list all tracked bugs.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Call `GET http://localhost:3456/api/bugs` to fetch all bugs.
|
||||
- Optional query param: `?project_id=<id>` to filter by project.
|
||||
2. Parse the JSON response. Each bug has: `id`, `title`, `description`, `status`, `priority`, `project_id`, `created_at`, `updated_at`.
|
||||
3. Present the list in a readable table format, grouped by status (`open` / `fixed` / `closed`).
|
||||
4. If no bugs are found, tell the user there are no tracked bugs.
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
GET http://localhost:3456/api/bugs
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": [
|
||||
{
|
||||
"id": "abc-123",
|
||||
"title": "Button click not working",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"created_at": "2026-03-15T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
name: bugpack-view-bug
|
||||
description: "View detailed bug context from BugPack including screenshots, environment info, and related files. Use when: user wants to see bug details, screenshots, or understand a specific bug before fixing. NOT for: listing all bugs (use bugpack-list-bugs) or directly fixing bugs (use bugpack-fix-bug)."
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: "\U0001F50D"
|
||||
---
|
||||
|
||||
# BugPack - View Bug Details
|
||||
|
||||
Fetch full bug context from BugPack, including description, screenshots, environment info, and related files.
|
||||
|
||||
## Instructions
|
||||
|
||||
1. Call `GET http://localhost:3456/api/bugs/:id` to get the full bug details.
|
||||
2. The response includes:
|
||||
- `title`, `description`, `status`, `priority`
|
||||
- `pagePath` — the page/route where the bug occurs
|
||||
- `device`, `browser` — environment info
|
||||
- `relatedFiles` — array of file paths related to the bug
|
||||
- `screenshots` — array of screenshot objects with `id`, `name`, `original_path`, `annotated_path`
|
||||
3. Display the bug info in a structured format.
|
||||
4. If the bug has screenshots, mention them and offer to show annotated versions.
|
||||
5. If `relatedFiles` are listed, use them to locate relevant source code.
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
GET http://localhost:3456/api/bugs/abc-123
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"id": "abc-123",
|
||||
"title": "Button click not working",
|
||||
"description": "The submit button on the login page does not respond to clicks",
|
||||
"status": "open",
|
||||
"priority": "high",
|
||||
"pagePath": "/login",
|
||||
"device": "Desktop",
|
||||
"browser": "Chrome 120",
|
||||
"relatedFiles": ["src/pages/Login.tsx", "src/components/SubmitButton.tsx"],
|
||||
"screenshots": [
|
||||
{
|
||||
"id": "ss-001",
|
||||
"name": "login-bug.png",
|
||||
"original_path": "/uploads/MyProject/original.png",
|
||||
"annotated_path": "/uploads/MyProject/annotated.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: bugpack
|
||||
description: "BugPack - AI-powered bug tracking and fixing toolkit. List bugs, view bug details with screenshots, and fix bugs automatically. Includes three workflows: list-bugs, view-bug, fix-bug. Requires BugPack server running locally."
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: "\U0001F4E6"
|
||||
---
|
||||
|
||||
# BugPack
|
||||
|
||||
AI-powered bug tracking and fixing toolkit. List, view, and fix bugs from BugPack.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Start BugPack server first:
|
||||
|
||||
```bash
|
||||
npx bugpack-mcp
|
||||
```
|
||||
|
||||
## Skill 1: List Bugs
|
||||
|
||||
Query all tracked bugs with optional filtering.
|
||||
|
||||
### Instructions
|
||||
|
||||
1. Call `GET http://localhost:3456/api/bugs` to fetch all bugs.
|
||||
- Optional: `?project_id=<id>` to filter by project.
|
||||
2. Each bug has: `id`, `title`, `description`, `status`, `priority`, `project_id`, `created_at`.
|
||||
3. Present results grouped by status (`pending` / `fixed` / `closed`).
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
GET http://localhost:3456/api/bugs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Skill 2: View Bug Details
|
||||
|
||||
Fetch full bug context including screenshots, environment, and related files.
|
||||
|
||||
### Instructions
|
||||
|
||||
1. Call `GET http://localhost:3456/api/bugs/:id` for full details.
|
||||
2. Response includes: `title`, `description`, `status`, `priority`, `pagePath`, `device`, `browser`, `relatedFiles`, `screenshots`.
|
||||
3. Use `relatedFiles` to locate relevant source code.
|
||||
4. Screenshots have `original_path` and `annotated_path`.
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
GET http://localhost:3456/api/bugs/abc-123
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Skill 3: Fix Bug
|
||||
|
||||
Read bug context, locate code, apply fix, and update status.
|
||||
|
||||
### Instructions
|
||||
|
||||
1. **Get context**: `GET http://localhost:3456/api/bugs/:id`
|
||||
2. **Analyze**: Read description and examine screenshots.
|
||||
3. **Locate code**: Use `relatedFiles` or search by `pagePath` and `description`.
|
||||
4. **Apply fix**: Edit source code following project conventions.
|
||||
5. **Mark fixed**: `PATCH http://localhost:3456/api/bugs/:id` with `{ "status": "fixed" }`
|
||||
6. **Add note** (optional): Update description to document what was changed.
|
||||
|
||||
### Example
|
||||
|
||||
```bash
|
||||
# Get bug context
|
||||
GET http://localhost:3456/api/bugs/abc-123
|
||||
|
||||
# Mark as fixed
|
||||
PATCH http://localhost:3456/api/bugs/abc-123
|
||||
Content-Type: application/json
|
||||
|
||||
{ "status": "fixed" }
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
startCommand:
|
||||
type: stdio
|
||||
configSchema:
|
||||
type: object
|
||||
properties: {}
|
||||
commandFunction: |-
|
||||
(config) => ({ command: 'npx', args: ['bugpack-mcp', '--mcp'] })
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useCallback, useState, useRef } from 'react'
|
||||
import { useStore } from './stores'
|
||||
import { useKeyboard } from './hooks/useKeyboard'
|
||||
import { Navbar } from './components/Navbar'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import { EditorArea } from './components/EditorArea'
|
||||
import { PropertyPanel } from './components/PropertyPanel'
|
||||
import { StatusBar } from './components/StatusBar'
|
||||
import { SettingsModal } from './components/SettingsModal'
|
||||
import { ShortcutsModal } from './components/ShortcutsModal'
|
||||
import { EmptyState } from './components/EmptyState'
|
||||
import { PreviewArea } from './components/PreviewArea'
|
||||
|
||||
export default function App() {
|
||||
const { bugs, selectedBugId, viewMode, settingsOpen, shortcutsOpen, projects, locale, fetchSettings, fetchProjects, createProject, createBug, pasteScreenshot } = useStore()
|
||||
const [initLoaded, setInitLoaded] = useState(false)
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
const initRef = useRef(false)
|
||||
|
||||
const selectedBug = bugs.find((b) => b.id === selectedBugId)
|
||||
|
||||
// Sidebar drag-to-resize
|
||||
const [sidebarWidth, setSidebarWidth] = useState(240)
|
||||
const [panelWidth, setPanelWidth] = useState(320)
|
||||
const isDraggingRef = useRef(false)
|
||||
|
||||
const makeResizeHandler = useCallback((setter: (w: number) => void, current: number, min: number, max: number, direction: 'left' | 'right') => {
|
||||
return (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
isDraggingRef.current = true
|
||||
const startX = e.clientX
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!isDraggingRef.current) return
|
||||
const delta = ev.clientX - startX
|
||||
const newWidth = direction === 'left'
|
||||
? current + delta // Left sidebar: wider on mouse right
|
||||
: current - delta // Right sidebar: wider on mouse left
|
||||
setter(Math.max(min, Math.min(max, newWidth)))
|
||||
}
|
||||
const onUp = () => {
|
||||
isDraggingRef.current = false
|
||||
document.removeEventListener('mousemove', onMove)
|
||||
document.removeEventListener('mouseup', onUp)
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
}
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
document.addEventListener('mousemove', onMove)
|
||||
document.addEventListener('mouseup', onUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useKeyboard()
|
||||
|
||||
// 初始化:加载设置(恢复上次项目ID),然后加载项目列表
|
||||
useEffect(() => {
|
||||
if (initRef.current) return
|
||||
initRef.current = true
|
||||
fetchSettings()
|
||||
.then(() => fetchProjects())
|
||||
.then(() => setInitLoaded(true))
|
||||
.catch((e) => { console.error('初始化失败:', e); setInitLoaded(true) })
|
||||
}, [])
|
||||
|
||||
// Global Ctrl+V paste screenshot (capture phase)
|
||||
const handlePaste = useCallback((e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
// Check for images
|
||||
let imageItem: DataTransferItem | null = null
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]
|
||||
if (item && item.type.startsWith('image/')) {
|
||||
imageItem = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!imageItem) return
|
||||
|
||||
// Prevent default when image found
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const blob = imageItem.getAsFile()
|
||||
if (!blob) return
|
||||
|
||||
const reader = new FileReader()
|
||||
// Capture current selectedBugId to avoid switching during read
|
||||
const capturedBugId = selectedBugId
|
||||
reader.onload = async () => {
|
||||
const dataUrl = reader.result as string
|
||||
let bugId = capturedBugId
|
||||
|
||||
// No bug selected, auto-create one
|
||||
if (!bugId) {
|
||||
const newBug = await createBug()
|
||||
bugId = newBug.id
|
||||
}
|
||||
|
||||
const ssName = locale === 'zh' ? '粘贴截图' : 'Pasted screenshot'
|
||||
await pasteScreenshot(bugId, dataUrl, ssName)
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
}, [selectedBugId, createBug, pasteScreenshot])
|
||||
|
||||
useEffect(() => {
|
||||
// capture: true ensures capture before all child elements
|
||||
window.addEventListener('paste', handlePaste, true)
|
||||
return () => window.removeEventListener('paste', handlePaste, true)
|
||||
}, [handlePaste])
|
||||
|
||||
// Create first project
|
||||
const handleCreateFirst = async () => {
|
||||
const name = newProjectName.trim()
|
||||
if (!name) return
|
||||
await createProject(name)
|
||||
setNewProjectName('')
|
||||
}
|
||||
|
||||
// Show blank until data loads to prevent flicker
|
||||
if (!initLoaded) {
|
||||
return <div className="h-screen bg-bg-primary" />
|
||||
}
|
||||
|
||||
// Show onboarding page when no projects
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg-primary text-text-primary font-sans items-center justify-center">
|
||||
<div className="text-center max-w-sm">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<img src="/favicon.svg" alt="BugPack" className="w-14 h-14" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-2">BugPack</h1>
|
||||
<p className="text-sm text-text-muted mb-8">
|
||||
{locale === 'zh' ? '创建你的第一个项目开始使用' : 'Create your first project to get started'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleCreateFirst() }}
|
||||
placeholder={locale === 'zh' ? '输入项目名称' : 'Project name'}
|
||||
className="flex-1 px-4 py-3 bg-bg-input border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={handleCreateFirst}
|
||||
className="px-6 py-3 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{locale === 'zh' ? '创建' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-bg-primary text-text-primary font-sans overflow-hidden">
|
||||
<Navbar />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Sidebar width={sidebarWidth} />
|
||||
{/* Left resize handle */}
|
||||
<div
|
||||
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors"
|
||||
onMouseDown={makeResizeHandler(setSidebarWidth, sidebarWidth, 160, 400, 'left')}
|
||||
/>
|
||||
<main className="flex-1 overflow-hidden">
|
||||
{!selectedBug ? (
|
||||
<EmptyState />
|
||||
) : viewMode === 'edit' ? (
|
||||
<EditorArea key={selectedBug.id} bug={selectedBug} />
|
||||
) : (
|
||||
<PreviewArea key={selectedBug.id} bug={selectedBug} />
|
||||
)}
|
||||
</main>
|
||||
{selectedBug && viewMode === 'edit' && (
|
||||
<>
|
||||
{/* Right resize handle */}
|
||||
<div
|
||||
className="w-1 shrink-0 cursor-col-resize hover:bg-accent/30 active:bg-accent/50 transition-colors"
|
||||
onMouseDown={makeResizeHandler(setPanelWidth, panelWidth, 240, 600, 'right')}
|
||||
/>
|
||||
<PropertyPanel key={selectedBug.id} bug={selectedBug} width={panelWidth} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<StatusBar />
|
||||
{settingsOpen && <SettingsModal />}
|
||||
{shortcutsOpen && <ShortcutsModal />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
const BASE = '/api'
|
||||
|
||||
// Generic request
|
||||
async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${url}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Request failed' }))
|
||||
throw new Error(err.error || `HTTP ${res.status}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Bug API types
|
||||
export interface ApiBug {
|
||||
id: string
|
||||
number: number
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
priority: string
|
||||
page_path: string
|
||||
device: string
|
||||
browser: string
|
||||
related_files?: string
|
||||
relatedFiles: string[]
|
||||
screenshots: ApiScreenshot[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ApiScreenshot {
|
||||
id: string
|
||||
url: string
|
||||
name: string
|
||||
annotated: boolean
|
||||
annotations: unknown[]
|
||||
}
|
||||
|
||||
// Project API types
|
||||
export interface ApiProject {
|
||||
id: string
|
||||
name: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// API methods
|
||||
export const api = {
|
||||
// Get all bugs (filtered by project)
|
||||
getBugs: (projectId?: string) =>
|
||||
request<ApiBug[]>(projectId ? `/bugs?project_id=${projectId}` : '/bugs'),
|
||||
|
||||
// Get single bug
|
||||
getBug: (id: string) => request<ApiBug>(`/bugs/${id}`),
|
||||
|
||||
// Create bug
|
||||
createBug: (data: { title?: string; project_id?: string }) =>
|
||||
request<ApiBug>('/bugs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Update bug
|
||||
updateBug: (id: string, data: Record<string, unknown>) =>
|
||||
request<ApiBug>(`/bugs/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Delete bug
|
||||
deleteBug: (id: string) =>
|
||||
request<{ ok: boolean }>(`/bugs/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Upload screenshot file
|
||||
uploadScreenshot: async (bugId: string, file: File, name?: string) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
if (name) form.append('name', name)
|
||||
|
||||
const res = await fetch(`${BASE}/bugs/${bugId}/screenshots`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
return res.json() as Promise<ApiScreenshot>
|
||||
},
|
||||
|
||||
// Paste screenshot (Base64)
|
||||
pasteScreenshot: (bugId: string, dataUrl: string, name?: string) =>
|
||||
request<ApiScreenshot>(`/bugs/${bugId}/screenshots/paste`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ dataUrl, name }),
|
||||
}),
|
||||
|
||||
// Rename screenshot
|
||||
renameScreenshot: (bugId: string, ssId: string, name: string) =>
|
||||
request<{ ok: boolean }>(`/bugs/${bugId}/screenshots/${ssId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
// Mark screenshot as annotated
|
||||
markScreenshotAnnotated: (bugId: string, ssId: string) =>
|
||||
request<{ ok: boolean }>(`/bugs/${bugId}/screenshots/${ssId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ annotated: true }),
|
||||
}),
|
||||
|
||||
// Save annotation data
|
||||
saveAnnotations: (bugId: string, ssId: string, annotations: unknown) =>
|
||||
request<{ ok: boolean }>(`/bugs/${bugId}/screenshots/${ssId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ annotations }),
|
||||
}),
|
||||
|
||||
// Save annotated render image (full screenshot with annotations)
|
||||
saveAnnotatedImage: (bugId: string, ssId: string, dataUrl: string) =>
|
||||
request<{ ok: boolean; annotatedFilename?: string }>(`/bugs/${bugId}/screenshots/${ssId}/annotated-image`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ dataUrl }),
|
||||
}),
|
||||
|
||||
// Reorder screenshots
|
||||
reorderScreenshots: (bugId: string, order: string[]) =>
|
||||
request<{ ok: boolean }>(`/bugs/${bugId}/screenshots/reorder`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ order }),
|
||||
}),
|
||||
|
||||
// Batch update status
|
||||
batchUpdateStatus: (ids: string[], status: string) =>
|
||||
request<{ ok: boolean }>('/bugs/batch/status', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ ids, status }),
|
||||
}),
|
||||
|
||||
// Batch delete
|
||||
batchDeleteBugs: (ids: string[]) =>
|
||||
request<{ ok: boolean }>('/bugs/batch/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
}),
|
||||
|
||||
// Delete screenshot
|
||||
deleteScreenshot: (bugId: string, ssId: string) =>
|
||||
request<{ ok: boolean }>(`/bugs/${bugId}/screenshots/${ssId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
// Get settings
|
||||
getSettings: () => request<Record<string, string>>('/settings'),
|
||||
|
||||
// Save settings
|
||||
saveSettings: (data: Record<string, string>) =>
|
||||
request<{ ok: boolean }>('/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Pick directory (server-side native dialog)
|
||||
pickDirectory: () =>
|
||||
request<{ path: string }>('/settings/pick-directory', { method: 'POST' }),
|
||||
|
||||
// Project management
|
||||
getProjects: () => request<ApiProject[]>('/projects'),
|
||||
|
||||
createProject: (name: string) =>
|
||||
request<ApiProject>('/projects', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
renameProject: (id: string, name: string) =>
|
||||
request<{ ok: boolean }>(`/projects/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
|
||||
deleteProject: (id: string) =>
|
||||
request<{ ok: boolean }>(`/projects/${id}`, { method: 'DELETE' }),
|
||||
|
||||
// Export project data
|
||||
exportProject: (id: string) => `/api/projects/${id}/export`,
|
||||
|
||||
// Import project data (ZIP file)
|
||||
importProject: async (id: string, file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const res = await fetch(`${BASE}/projects/${id}/import`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
return res.json() as Promise<{ ok: boolean; importedCount: number; error?: string }>
|
||||
},
|
||||
|
||||
// TAPD integration
|
||||
tapd: {
|
||||
test: (data: { apiUser: string; apiPassword: string; workspaceId?: string }) =>
|
||||
request<{ ok: boolean; error?: string }>('/tapd/test', { method: 'POST', body: JSON.stringify(data) }),
|
||||
getWorkspaces: () =>
|
||||
request<{ ok: boolean; workspaces: any[]; error?: string }>('/tapd/workspaces'),
|
||||
getBugs: () =>
|
||||
request<{ ok: boolean; bugs: any[]; total?: number; error?: string }>('/tapd/bugs'),
|
||||
importBug: (id: string, projectId: string) =>
|
||||
request<{ ok: boolean; bugId: string; number: number }>(`/tapd/import/${id}`, {
|
||||
method: 'POST', body: JSON.stringify({ projectId }),
|
||||
}),
|
||||
resolve: (id: string) =>
|
||||
request<{ ok: boolean }>(`/tapd/resolve/${id}`, { method: 'POST' }),
|
||||
},
|
||||
|
||||
// Linear integration
|
||||
linear: {
|
||||
test: (data: { token: string }) =>
|
||||
request<{ ok: boolean; user?: string; error?: string }>('/linear/test', { method: 'POST', body: JSON.stringify(data) }),
|
||||
getTeams: () =>
|
||||
request<{ ok: boolean; teams: any[]; error?: string }>('/linear/teams'),
|
||||
getBugs: () =>
|
||||
request<{ ok: boolean; bugs: any[]; total?: number; error?: string }>('/linear/bugs'),
|
||||
importBug: (id: string, projectId: string) =>
|
||||
request<{ ok: boolean; bugId: string; number: number }>(`/linear/import/${id}`, {
|
||||
method: 'POST', body: JSON.stringify({ projectId }),
|
||||
}),
|
||||
resolve: (id: string) =>
|
||||
request<{ ok: boolean }>(`/linear/resolve/${id}`, { method: 'POST' }),
|
||||
},
|
||||
|
||||
// Jira integration
|
||||
jira: {
|
||||
test: (data: { url: string; email: string; token: string }) =>
|
||||
request<{ ok: boolean; user?: string; error?: string }>('/jira/test', { method: 'POST', body: JSON.stringify(data) }),
|
||||
getProjects: () =>
|
||||
request<{ ok: boolean; projects: any[]; error?: string }>('/jira/projects'),
|
||||
getBugs: () =>
|
||||
request<{ ok: boolean; bugs: any[]; total?: number; error?: string }>('/jira/bugs'),
|
||||
importBug: (key: string, projectId: string) =>
|
||||
request<{ ok: boolean; bugId: string; number: number }>(`/jira/import/${key}`, {
|
||||
method: 'POST', body: JSON.stringify({ projectId }),
|
||||
}),
|
||||
resolve: (key: string) =>
|
||||
request<{ ok: boolean }>(`/jira/resolve/${key}`, { method: 'POST' }),
|
||||
},
|
||||
|
||||
// Zentao integration
|
||||
zentao: {
|
||||
test: (data: { url: string; httpUser?: string; httpPass?: string; account: string; password: string }) =>
|
||||
request<{ ok: boolean; error?: string }>('/zentao/test', { method: 'POST', body: JSON.stringify(data) }),
|
||||
getProducts: () =>
|
||||
request<{ ok: boolean; products: any[]; error?: string }>('/zentao/products'),
|
||||
getBugs: () =>
|
||||
request<{ ok: boolean; bugs: any[]; total?: number; error?: string }>('/zentao/bugs'),
|
||||
getBug: (id: number) =>
|
||||
request<{ ok: boolean; bug: any }>(`/zentao/bugs/${id}`),
|
||||
importBug: (id: number, projectId: string) =>
|
||||
request<{ ok: boolean; bugId: string; number: number }>(`/zentao/import/${id}`, {
|
||||
method: 'POST', body: JSON.stringify({ projectId }),
|
||||
}),
|
||||
resolve: (id: number, resolution?: string) =>
|
||||
request<{ ok: boolean }>(`/zentao/resolve/${id}`, {
|
||||
method: 'POST', body: JSON.stringify({ resolution }),
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,786 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import * as fabric from 'fabric'
|
||||
|
||||
export type AnnotationTool = 'drag' | 'select' | 'rect' | 'arrow' | 'text' | 'number' | 'highlight' | 'pen' | 'mosaic'
|
||||
|
||||
interface Props {
|
||||
imageUrl: string
|
||||
color: string
|
||||
tool: AnnotationTool
|
||||
lineWidth: number
|
||||
zoom: number
|
||||
onZoomChange: (zoom: number) => void
|
||||
onAnnotated?: () => void
|
||||
initialAnnotations?: unknown[]
|
||||
onSaveAnnotations?: (canvasJson: unknown, annotatedDataUrl: string | null) => void
|
||||
}
|
||||
|
||||
interface TextInputState {
|
||||
x: number
|
||||
y: number
|
||||
screenX: number
|
||||
screenY: number
|
||||
}
|
||||
|
||||
// Number counter
|
||||
let numberCounter = 1
|
||||
|
||||
export function AnnotationCanvas({ imageUrl, color, tool, lineWidth, zoom, onZoomChange, onAnnotated, initialAnnotations, onSaveAnnotations }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const fcRef = useRef<fabric.Canvas | null>(null)
|
||||
const bgImageRef = useRef<fabric.FabricImage | null>(null)
|
||||
const isDrawingRef = useRef(false)
|
||||
const startPointRef = useRef<{ x: number; y: number } | null>(null)
|
||||
const activeShapeRef = useRef<fabric.FabricObject | null>(null)
|
||||
const undoStackRef = useRef<string[]>([])
|
||||
const redoStackRef = useRef<string[]>([])
|
||||
const lastStateRef = useRef<string | null>(null)
|
||||
const initialStateRef = useRef<string | null>(null)
|
||||
const isRestoringRef = useRef(false)
|
||||
const [textInput, setTextInput] = useState<TextInputState | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !containerRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
const fc = new fabric.Canvas(canvasRef.current, {
|
||||
width: container.clientWidth,
|
||||
height: container.clientHeight,
|
||||
backgroundColor: getComputedStyle(document.documentElement).getPropertyValue('--bg-primary').trim() || '#0A0A0F',
|
||||
selection: true,
|
||||
})
|
||||
|
||||
fcRef.current = fc
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
fc.setDimensions({
|
||||
width: container.clientWidth,
|
||||
height: container.clientHeight,
|
||||
})
|
||||
fitImage()
|
||||
})
|
||||
observer.observe(container)
|
||||
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg-primary').trim()
|
||||
if (bg) {
|
||||
fc.backgroundColor = bg
|
||||
fc.renderAll()
|
||||
}
|
||||
})
|
||||
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
themeObserver.disconnect()
|
||||
fc.dispose()
|
||||
fcRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fitImage = useCallback(() => {
|
||||
const fc = fcRef.current
|
||||
const img = bgImageRef.current
|
||||
if (!fc || !img) return
|
||||
|
||||
const canvasW = fc.getWidth()
|
||||
const canvasH = fc.getHeight()
|
||||
const imgW = img.width || 1
|
||||
const imgH = img.height || 1
|
||||
|
||||
const scale = Math.min(canvasW / imgW, canvasH / imgH) * 0.85
|
||||
img.set({
|
||||
scaleX: scale,
|
||||
scaleY: scale,
|
||||
left: (canvasW - imgW * scale) / 2,
|
||||
top: (canvasH - imgH * scale) / 2,
|
||||
})
|
||||
fc.renderAll()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const fc = fcRef.current
|
||||
if (!fc || !imageUrl) return
|
||||
|
||||
fabric.FabricImage.fromURL(imageUrl).then((img) => {
|
||||
if (bgImageRef.current) fc.remove(bgImageRef.current)
|
||||
|
||||
img.set({
|
||||
selectable: false,
|
||||
evented: false,
|
||||
hasControls: false,
|
||||
})
|
||||
|
||||
bgImageRef.current = img
|
||||
fc.insertAt(0, img)
|
||||
fitImage()
|
||||
numberCounter = 1
|
||||
|
||||
initialStateRef.current = JSON.stringify(fc.toJSON())
|
||||
|
||||
if (initialAnnotations && Array.isArray(initialAnnotations) && initialAnnotations.length > 0) {
|
||||
const savedJson = initialAnnotations[0] as any
|
||||
if (savedJson && savedJson.objects) {
|
||||
isRestoringRef.current = true
|
||||
fc.loadFromJSON(savedJson).catch((err: unknown) => { console.error('Canvas restore failed:', err) }).then(() => {
|
||||
isRestoringRef.current = false
|
||||
const objs = fc.getObjects()
|
||||
if (objs[0]) {
|
||||
objs[0].selectable = false
|
||||
objs[0].evented = false
|
||||
bgImageRef.current = objs[0] as fabric.FabricImage
|
||||
}
|
||||
let maxNum = 0
|
||||
for (const obj of objs) {
|
||||
if (obj instanceof fabric.Group) {
|
||||
const groupObjs = obj.getObjects()
|
||||
for (const child of groupObjs) {
|
||||
if (child instanceof fabric.Text && /^\d+$/.test(child.text || '')) {
|
||||
maxNum = Math.max(maxNum, parseInt(child.text || '0'))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
numberCounter = maxNum + 1
|
||||
lastStateRef.current = JSON.stringify(fc.toJSON())
|
||||
undoStackRef.current = []
|
||||
redoStackRef.current = []
|
||||
|
||||
// After restore: re-scale background image + proportionally adjust annotations
|
||||
const bgImg = bgImageRef.current
|
||||
if (bgImg) {
|
||||
const oldScaleX = bgImg.scaleX || 1
|
||||
const oldScaleY = bgImg.scaleY || 1
|
||||
const oldLeft = bgImg.left || 0
|
||||
const oldTop = bgImg.top || 0
|
||||
|
||||
// Recalculate scale using fitImage logic
|
||||
const canvasW = fc.getWidth()
|
||||
const canvasH = fc.getHeight()
|
||||
const imgW = bgImg.width || 1
|
||||
const imgH = bgImg.height || 1
|
||||
const newScale = Math.min(canvasW / imgW, canvasH / imgH) * 0.85
|
||||
const newLeft = (canvasW - imgW * newScale) / 2
|
||||
const newTop = (canvasH - imgH * newScale) / 2
|
||||
bgImg.set({ scaleX: newScale, scaleY: newScale, left: newLeft, top: newTop })
|
||||
|
||||
// Proportionally adjust all annotation objects
|
||||
const ratioX = newScale / oldScaleX
|
||||
const ratioY = newScale / oldScaleY
|
||||
for (const obj of fc.getObjects()) {
|
||||
if (obj === bgImg) continue
|
||||
obj.set({
|
||||
left: (((obj.left || 0) - oldLeft) * ratioX) + newLeft,
|
||||
top: (((obj.top || 0) - oldTop) * ratioY) + newTop,
|
||||
scaleX: (obj.scaleX || 1) * ratioX,
|
||||
scaleY: (obj.scaleY || 1) * ratioY,
|
||||
})
|
||||
obj.setCoords()
|
||||
}
|
||||
}
|
||||
|
||||
fc.renderAll()
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const initJson = JSON.stringify(fc.toJSON())
|
||||
initialStateRef.current = initJson
|
||||
lastStateRef.current = initJson
|
||||
undoStackRef.current = []
|
||||
redoStackRef.current = []
|
||||
})
|
||||
}, [imageUrl, fitImage])
|
||||
|
||||
useEffect(() => {
|
||||
const fc = fcRef.current
|
||||
if (!fc) return
|
||||
fc.setZoom(zoom / 100)
|
||||
fc.renderAll()
|
||||
}, [zoom])
|
||||
|
||||
// Scroll wheel zoom
|
||||
useEffect(() => {
|
||||
const fc = fcRef.current
|
||||
if (!fc) return
|
||||
|
||||
const handleWheel = (opt: fabric.TEvent<WheelEvent>) => {
|
||||
const e = opt.e
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const delta = e.deltaY
|
||||
let newZoom = zoom + (delta > 0 ? -10 : 10)
|
||||
newZoom = Math.max(25, Math.min(300, newZoom))
|
||||
onZoomChange(newZoom)
|
||||
}
|
||||
|
||||
fc.on('mouse:wheel', handleWheel)
|
||||
return () => { fc.off('mouse:wheel', handleWheel) }
|
||||
}, [zoom, onZoomChange])
|
||||
|
||||
// Commit pending text input when switching tools
|
||||
useEffect(() => {
|
||||
if (textInput) commitTextInput()
|
||||
}, [tool])
|
||||
|
||||
useEffect(() => {
|
||||
const fc = fcRef.current
|
||||
if (!fc) return
|
||||
|
||||
if (tool === 'drag') {
|
||||
fc.isDrawingMode = false
|
||||
fc.selection = false
|
||||
fc.discardActiveObject()
|
||||
fc.forEachObject((obj) => {
|
||||
obj.selectable = false
|
||||
obj.evented = false
|
||||
})
|
||||
fc.defaultCursor = 'grab'
|
||||
fc.hoverCursor = 'grab'
|
||||
} else if (tool === 'select') {
|
||||
fc.isDrawingMode = false
|
||||
fc.selection = true
|
||||
fc.defaultCursor = 'default'
|
||||
fc.hoverCursor = 'move'
|
||||
fc.forEachObject((obj) => {
|
||||
if (obj !== bgImageRef.current) {
|
||||
obj.selectable = true
|
||||
obj.evented = true
|
||||
}
|
||||
})
|
||||
} else if (tool === 'pen') {
|
||||
fc.isDrawingMode = true
|
||||
fc.selection = false
|
||||
fc.defaultCursor = 'crosshair'
|
||||
fc.hoverCursor = 'crosshair'
|
||||
fc.discardActiveObject()
|
||||
fc.forEachObject((obj) => {
|
||||
obj.selectable = false
|
||||
obj.evented = false
|
||||
})
|
||||
const brush = new fabric.PencilBrush(fc)
|
||||
brush.width = lineWidth
|
||||
brush.color = color
|
||||
fc.freeDrawingBrush = brush
|
||||
} else if (tool === 'mosaic') {
|
||||
fc.isDrawingMode = true
|
||||
fc.selection = false
|
||||
fc.defaultCursor = 'crosshair'
|
||||
fc.hoverCursor = 'crosshair'
|
||||
fc.discardActiveObject()
|
||||
fc.forEachObject((obj) => {
|
||||
obj.selectable = false
|
||||
obj.evented = false
|
||||
})
|
||||
const brush = new fabric.PencilBrush(fc)
|
||||
brush.width = lineWidth * 6
|
||||
brush.color = 'rgba(128,128,128,0.35)'
|
||||
fc.freeDrawingBrush = brush
|
||||
} else {
|
||||
fc.isDrawingMode = false
|
||||
fc.selection = false
|
||||
fc.defaultCursor = 'crosshair'
|
||||
fc.hoverCursor = 'crosshair'
|
||||
fc.discardActiveObject()
|
||||
fc.forEachObject((obj) => {
|
||||
obj.selectable = false
|
||||
obj.evented = false
|
||||
})
|
||||
}
|
||||
fc.renderAll()
|
||||
}, [tool, lineWidth, color])
|
||||
|
||||
// Drag panning
|
||||
useEffect(() => {
|
||||
const fc = fcRef.current
|
||||
if (!fc || tool !== 'drag') return
|
||||
|
||||
let isDragging = false
|
||||
let lastX = 0
|
||||
let lastY = 0
|
||||
|
||||
const handleDown = (opt: fabric.TPointerEventInfo<fabric.TPointerEvent>) => {
|
||||
isDragging = true
|
||||
const e = opt.e as MouseEvent
|
||||
lastX = e.clientX
|
||||
lastY = e.clientY
|
||||
fc.defaultCursor = 'grabbing'
|
||||
fc.hoverCursor = 'grabbing'
|
||||
}
|
||||
|
||||
const handleMove = (opt: fabric.TPointerEventInfo<fabric.TPointerEvent>) => {
|
||||
if (!isDragging) return
|
||||
const e = opt.e as MouseEvent
|
||||
const vpt = fc.viewportTransform!
|
||||
vpt[4] += e.clientX - lastX
|
||||
vpt[5] += e.clientY - lastY
|
||||
lastX = e.clientX
|
||||
lastY = e.clientY
|
||||
fc.requestRenderAll()
|
||||
}
|
||||
|
||||
const handleUp = () => {
|
||||
isDragging = false
|
||||
fc.defaultCursor = 'grab'
|
||||
fc.hoverCursor = 'grab'
|
||||
}
|
||||
|
||||
fc.on('mouse:down', handleDown)
|
||||
fc.on('mouse:move', handleMove)
|
||||
fc.on('mouse:up', handleUp)
|
||||
return () => {
|
||||
fc.off('mouse:down', handleDown)
|
||||
fc.off('mouse:move', handleMove)
|
||||
fc.off('mouse:up', handleUp)
|
||||
}
|
||||
}, [tool])
|
||||
|
||||
// Drawing tools (rect/arrow/text/number/highlight)
|
||||
useEffect(() => {
|
||||
const fc = fcRef.current
|
||||
if (!fc) return
|
||||
if (tool === 'drag' || tool === 'select' || tool === 'pen' || tool === 'mosaic') return
|
||||
|
||||
const handleMouseDown = (opt: fabric.TPointerEventInfo<fabric.TPointerEvent>) => {
|
||||
const pointer = fc.getScenePoint(opt.e)
|
||||
isDrawingRef.current = true
|
||||
startPointRef.current = { x: pointer.x, y: pointer.y }
|
||||
|
||||
if (tool === 'text') {
|
||||
const target = fc.findTarget(opt.e)
|
||||
if (target && target !== bgImageRef.current) {
|
||||
fc.setActiveObject(target)
|
||||
fc.renderAll()
|
||||
isDrawingRef.current = false
|
||||
return
|
||||
}
|
||||
const e = opt.e as MouseEvent
|
||||
const container = containerRef.current
|
||||
if (container) {
|
||||
const rect = container.getBoundingClientRect()
|
||||
setTextInput({
|
||||
x: pointer.x,
|
||||
y: pointer.y,
|
||||
screenX: e.clientX - rect.left,
|
||||
screenY: e.clientY - rect.top,
|
||||
})
|
||||
setTimeout(() => textareaRef.current?.focus(), 0)
|
||||
}
|
||||
isDrawingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (tool === 'number') {
|
||||
const num = numberCounter++
|
||||
const circle = new fabric.Circle({
|
||||
radius: 14,
|
||||
fill: color,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
})
|
||||
const text = new fabric.Text(String(num), {
|
||||
fontSize: 14,
|
||||
fill: '#FFFFFF',
|
||||
fontWeight: 'bold',
|
||||
fontFamily: 'sans-serif',
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
})
|
||||
const group = new fabric.Group([circle, text], {
|
||||
left: pointer.x - 14,
|
||||
top: pointer.y - 14,
|
||||
})
|
||||
fc.add(group)
|
||||
isDrawingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
if (tool === 'rect' || tool === 'highlight') {
|
||||
const rect = new fabric.Rect({
|
||||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
fill: tool === 'highlight' ? `${color}4D` : 'transparent',
|
||||
stroke: tool === 'highlight' ? 'transparent' : color,
|
||||
strokeWidth: tool === 'highlight' ? 0 : lineWidth,
|
||||
})
|
||||
fc.add(rect)
|
||||
activeShapeRef.current = rect
|
||||
}
|
||||
|
||||
if (tool === 'arrow') {
|
||||
const line = new fabric.Line([pointer.x, pointer.y, pointer.x, pointer.y], {
|
||||
stroke: color,
|
||||
strokeWidth: lineWidth,
|
||||
selectable: false,
|
||||
})
|
||||
fc.add(line)
|
||||
activeShapeRef.current = line
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (opt: fabric.TPointerEventInfo<fabric.TPointerEvent>) => {
|
||||
if (!isDrawingRef.current || !startPointRef.current) return
|
||||
const pointer = fc.getScenePoint(opt.e)
|
||||
const start = startPointRef.current
|
||||
|
||||
if ((tool === 'rect' || tool === 'highlight') && activeShapeRef.current) {
|
||||
const rect = activeShapeRef.current as fabric.Rect
|
||||
const w = pointer.x - start.x
|
||||
const h = pointer.y - start.y
|
||||
rect.set({
|
||||
left: w > 0 ? start.x : pointer.x,
|
||||
top: h > 0 ? start.y : pointer.y,
|
||||
width: Math.abs(w),
|
||||
height: Math.abs(h),
|
||||
})
|
||||
fc.renderAll()
|
||||
}
|
||||
|
||||
if (tool === 'arrow' && activeShapeRef.current) {
|
||||
const line = activeShapeRef.current as fabric.Line
|
||||
line.set({ x2: pointer.x, y2: pointer.y })
|
||||
fc.renderAll()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseUp = () => {
|
||||
if (!isDrawingRef.current) return
|
||||
isDrawingRef.current = false
|
||||
|
||||
// After line complete, add arrowhead and group
|
||||
if (tool === 'arrow' && activeShapeRef.current) {
|
||||
const line = activeShapeRef.current as fabric.Line
|
||||
const x1 = line.x1 ?? 0, y1 = line.y1 ?? 0
|
||||
const x2 = line.x2 ?? 0, y2 = line.y2 ?? 0
|
||||
const angle = Math.atan2(y2 - y1, x2 - x1)
|
||||
const headLen = 12
|
||||
|
||||
const head = new fabric.Polygon([
|
||||
{ x: x2, y: y2 },
|
||||
{ x: x2 - headLen * Math.cos(angle - Math.PI / 6), y: y2 - headLen * Math.sin(angle - Math.PI / 6) },
|
||||
{ x: x2 - headLen * Math.cos(angle + Math.PI / 6), y: y2 - headLen * Math.sin(angle + Math.PI / 6) },
|
||||
], {
|
||||
fill: color,
|
||||
selectable: false,
|
||||
})
|
||||
|
||||
fc.remove(line)
|
||||
const group = new fabric.Group([line, head], { selectable: true })
|
||||
fc.add(group)
|
||||
}
|
||||
|
||||
activeShapeRef.current = null
|
||||
startPointRef.current = null
|
||||
fc.renderAll()
|
||||
}
|
||||
|
||||
fc.on('mouse:down', handleMouseDown)
|
||||
fc.on('mouse:move', handleMouseMove)
|
||||
fc.on('mouse:up', handleMouseUp)
|
||||
|
||||
return () => {
|
||||
fc.off('mouse:down', handleMouseDown)
|
||||
fc.off('mouse:move', handleMouseMove)
|
||||
fc.off('mouse:up', handleMouseUp)
|
||||
}
|
||||
}, [tool, color, lineWidth])
|
||||
|
||||
// Mosaic brush: pixelate brush area after path complete
|
||||
useEffect(() => {
|
||||
const fc = fcRef.current
|
||||
if (!fc || tool !== 'mosaic') return
|
||||
|
||||
const brushWidth = lineWidth * 6
|
||||
|
||||
const handlePathCreated = (opt: { path: fabric.Path }) => {
|
||||
if (!bgImageRef.current) return
|
||||
const drawnPath = opt.path
|
||||
const bound = drawnPath.getBoundingRect()
|
||||
const bgImg = bgImageRef.current
|
||||
const imgEl = bgImg.getElement() as HTMLImageElement
|
||||
const bgScale = bgImg.scaleX ?? 1
|
||||
const bgLeft = bgImg.left ?? 0
|
||||
const bgTop = bgImg.top ?? 0
|
||||
|
||||
const outW = Math.round(bound.width)
|
||||
const outH = Math.round(bound.height)
|
||||
if (outW < 2 || outH < 2) { fc.remove(drawnPath); return }
|
||||
|
||||
// Map coordinates to original image pixels
|
||||
const srcX = Math.max(0, (bound.left - bgLeft) / bgScale)
|
||||
const srcY = Math.max(0, (bound.top - bgTop) / bgScale)
|
||||
const srcW = Math.min(imgEl.naturalWidth - srcX, bound.width / bgScale)
|
||||
const srcH = Math.min(imgEl.naturalHeight - srcY, bound.height / bgScale)
|
||||
|
||||
if (srcW < 2 || srcH < 2) { fc.remove(drawnPath); return }
|
||||
|
||||
// Generate pixelated image
|
||||
const pixelSize = 10
|
||||
const smallW = Math.max(1, Math.ceil(srcW / pixelSize))
|
||||
const smallH = Math.max(1, Math.ceil(srcH / pixelSize))
|
||||
const offSmall = document.createElement('canvas')
|
||||
offSmall.width = smallW
|
||||
offSmall.height = smallH
|
||||
const ctxSmall = offSmall.getContext('2d')!
|
||||
ctxSmall.imageSmoothingEnabled = true
|
||||
ctxSmall.drawImage(imgEl, srcX, srcY, srcW, srcH, 0, 0, smallW, smallH)
|
||||
|
||||
// Offscreen compositing: brush path mask + source-in mosaic overlay
|
||||
const offResult = document.createElement('canvas')
|
||||
offResult.width = outW
|
||||
offResult.height = outH
|
||||
const ctx = offResult.getContext('2d')!
|
||||
|
||||
ctx.strokeStyle = '#000'
|
||||
ctx.lineWidth = brushWidth
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
const pathData = drawnPath.path
|
||||
if (Array.isArray(pathData)) {
|
||||
ctx.beginPath()
|
||||
for (const seg of pathData) {
|
||||
const cmd = seg[0]
|
||||
if (cmd === 'M') ctx.moveTo(seg[1] - bound.left, seg[2] - bound.top)
|
||||
else if (cmd === 'L') ctx.lineTo(seg[1] - bound.left, seg[2] - bound.top)
|
||||
else if (cmd === 'Q') ctx.quadraticCurveTo(seg[1] - bound.left, seg[2] - bound.top, seg[3] - bound.left, seg[4] - bound.top)
|
||||
else if (cmd === 'C') ctx.bezierCurveTo(seg[1] - bound.left, seg[2] - bound.top, seg[3] - bound.left, seg[4] - bound.top, seg[5] - bound.left, seg[6] - bound.top)
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
ctx.globalCompositeOperation = 'source-in'
|
||||
ctx.imageSmoothingEnabled = false
|
||||
ctx.drawImage(offSmall, 0, 0, smallW, smallH, 0, 0, outW, outH)
|
||||
|
||||
// Replace path with composited image, release offscreen canvas
|
||||
fc.remove(drawnPath)
|
||||
const dataUrl = offResult.toDataURL('image/png')
|
||||
offSmall.width = 0
|
||||
offSmall.height = 0
|
||||
offResult.width = 0
|
||||
offResult.height = 0
|
||||
fabric.FabricImage.fromURL(dataUrl).then((mosaicImg) => {
|
||||
mosaicImg.set({
|
||||
left: bound.left,
|
||||
top: bound.top,
|
||||
selectable: true,
|
||||
})
|
||||
fc.add(mosaicImg)
|
||||
fc.renderAll()
|
||||
})
|
||||
}
|
||||
|
||||
fc.on('path:created', handlePathCreated as any)
|
||||
return () => { fc.off('path:created', handlePathCreated as any) }
|
||||
}, [tool, lineWidth])
|
||||
|
||||
// Save state to undo stack
|
||||
const saveState = useCallback(() => {
|
||||
if (isRestoringRef.current) return
|
||||
const fc = fcRef.current
|
||||
if (!fc) return
|
||||
if (lastStateRef.current) {
|
||||
undoStackRef.current.push(lastStateRef.current)
|
||||
}
|
||||
redoStackRef.current = []
|
||||
const currentJson = fc.toJSON()
|
||||
lastStateRef.current = JSON.stringify(currentJson)
|
||||
onAnnotated?.()
|
||||
const dataUrl = fc.toDataURL({ format: 'png', multiplier: 1 })
|
||||
onSaveAnnotations?.(currentJson, dataUrl)
|
||||
}, [onAnnotated, onSaveAnnotations])
|
||||
|
||||
useEffect(() => {
|
||||
const fc = fcRef.current
|
||||
if (!fc) return
|
||||
const handler = () => saveState()
|
||||
fc.on('object:added', handler)
|
||||
fc.on('object:modified', handler)
|
||||
fc.on('object:removed', handler)
|
||||
return () => {
|
||||
fc.off('object:added', handler)
|
||||
fc.off('object:modified', handler)
|
||||
fc.off('object:removed', handler)
|
||||
}
|
||||
}, [saveState])
|
||||
|
||||
const fixBgAfterRestore = useCallback(() => {
|
||||
const fc = fcRef.current
|
||||
if (!fc) return
|
||||
const objs = fc.getObjects()
|
||||
if (objs[0]) {
|
||||
objs[0].selectable = false
|
||||
objs[0].evented = false
|
||||
bgImageRef.current = objs[0] as fabric.FabricImage
|
||||
}
|
||||
fc.renderAll()
|
||||
}, [])
|
||||
|
||||
const performUndo = useCallback(() => {
|
||||
const fc = fcRef.current
|
||||
if (!fc || undoStackRef.current.length === 0) return
|
||||
redoStackRef.current.push(lastStateRef.current || JSON.stringify(fc.toJSON()))
|
||||
const prev = undoStackRef.current.pop()!
|
||||
lastStateRef.current = prev
|
||||
isRestoringRef.current = true
|
||||
fc.loadFromJSON(prev).then(() => {
|
||||
isRestoringRef.current = false
|
||||
fixBgAfterRestore()
|
||||
}).catch((err: unknown) => { console.error('Undo failed:', err); isRestoringRef.current = false })
|
||||
}, [fixBgAfterRestore])
|
||||
|
||||
const performRedo = useCallback(() => {
|
||||
const fc = fcRef.current
|
||||
if (!fc || redoStackRef.current.length === 0) return
|
||||
undoStackRef.current.push(lastStateRef.current || JSON.stringify(fc.toJSON()))
|
||||
const next = redoStackRef.current.pop()!
|
||||
lastStateRef.current = next
|
||||
isRestoringRef.current = true
|
||||
fc.loadFromJSON(next).then(() => {
|
||||
isRestoringRef.current = false
|
||||
fixBgAfterRestore()
|
||||
}).catch((err: unknown) => { console.error('Redo failed:', err); isRestoringRef.current = false })
|
||||
}, [fixBgAfterRestore])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const fc = fcRef.current
|
||||
if (!fc) return
|
||||
|
||||
if (textareaRef.current && document.activeElement === textareaRef.current) return
|
||||
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
const active = fc.getActiveObject()
|
||||
if (active && active !== bgImageRef.current) {
|
||||
if (active instanceof fabric.Textbox && active.isEditing) return
|
||||
fc.remove(active)
|
||||
fc.renderAll()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
performUndo()
|
||||
return
|
||||
}
|
||||
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Z' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
performRedo()
|
||||
return
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [performUndo, performRedo])
|
||||
|
||||
// Reset annotations, push current state to undo stack
|
||||
const performReset = useCallback(() => {
|
||||
const fc = fcRef.current
|
||||
if (!fc || !initialStateRef.current) return
|
||||
undoStackRef.current.push(lastStateRef.current || JSON.stringify(fc.toJSON()))
|
||||
redoStackRef.current = []
|
||||
lastStateRef.current = initialStateRef.current
|
||||
isRestoringRef.current = true
|
||||
numberCounter = 1
|
||||
fc.loadFromJSON(initialStateRef.current).then(() => {
|
||||
isRestoringRef.current = false
|
||||
fixBgAfterRestore()
|
||||
onSaveAnnotations?.(null, null)
|
||||
}).catch((err: unknown) => { console.error('Reset failed:', err); isRestoringRef.current = false })
|
||||
}, [fixBgAfterRestore, onSaveAnnotations])
|
||||
|
||||
const commitTextInput = useCallback(() => {
|
||||
const fc = fcRef.current
|
||||
const textarea = textareaRef.current
|
||||
if (!fc || !textarea || !textInput) return
|
||||
const val = textarea.value.trim()
|
||||
if (val) {
|
||||
const fontSize = 16
|
||||
const text = new fabric.Text(val, {
|
||||
left: textInput.x,
|
||||
top: textInput.y,
|
||||
fontSize,
|
||||
fill: color,
|
||||
fontFamily: 'PingFang SC, Microsoft YaHei, sans-serif',
|
||||
shadow: new fabric.Shadow({ color: 'rgba(0,0,0,0.8)', blur: 3, offsetX: 1, offsetY: 1 }),
|
||||
})
|
||||
fc.add(text)
|
||||
fc.renderAll()
|
||||
}
|
||||
setTextInput(null)
|
||||
}, [textInput, color])
|
||||
|
||||
const cancelTextInput = useCallback(() => {
|
||||
setTextInput(null)
|
||||
}, [])
|
||||
|
||||
// Fit to window: reset zoom + viewport + refit image
|
||||
const handleFitWindow = useCallback(() => {
|
||||
const fc = fcRef.current
|
||||
if (!fc) return
|
||||
fc.viewportTransform = [1, 0, 0, 1, 0, 0]
|
||||
fc.setZoom(1)
|
||||
fitImage()
|
||||
onZoomChange(100)
|
||||
}, [fitImage, onZoomChange])
|
||||
|
||||
// Listen to toolbar events
|
||||
useEffect(() => {
|
||||
const handleUndo = () => performUndo()
|
||||
const handleRedo = () => performRedo()
|
||||
const handleReset = () => performReset()
|
||||
const handleFit = () => handleFitWindow()
|
||||
window.addEventListener('bugpack:undo', handleUndo)
|
||||
window.addEventListener('bugpack:redo', handleRedo)
|
||||
window.addEventListener('bugpack:reset', handleReset)
|
||||
window.addEventListener('bugpack:fitWindow', handleFit)
|
||||
return () => {
|
||||
window.removeEventListener('bugpack:undo', handleUndo)
|
||||
window.removeEventListener('bugpack:redo', handleRedo)
|
||||
window.removeEventListener('bugpack:reset', handleReset)
|
||||
window.removeEventListener('bugpack:fitWindow', handleFit)
|
||||
}
|
||||
}, [performUndo, performRedo, performReset, handleFitWindow])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full h-full relative">
|
||||
<canvas ref={canvasRef} />
|
||||
{textInput && (
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
className="absolute z-30 outline-none resize-none rounded"
|
||||
style={{
|
||||
left: textInput.screenX,
|
||||
top: textInput.screenY,
|
||||
minWidth: 120,
|
||||
minHeight: 32,
|
||||
fontSize: 16,
|
||||
lineHeight: '1.4',
|
||||
padding: '4px 6px',
|
||||
color: color,
|
||||
caretColor: color,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
border: `2px solid ${color}`,
|
||||
fontFamily: 'PingFang SC, Microsoft YaHei, sans-serif',
|
||||
textShadow: '1px 1px 2px rgba(0,0,0,0.8)',
|
||||
}}
|
||||
placeholder="Enter text..."
|
||||
onBlur={commitTextInput}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); cancelTextInput() }
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitTextInput() }
|
||||
e.stopPropagation()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function ConfirmDialog({ open, title, message, confirmText, cancelText, onConfirm, onCancel }: Props) {
|
||||
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
// Focus confirm button on open
|
||||
useEffect(() => {
|
||||
if (open) confirmRef.current?.focus()
|
||||
}, [open])
|
||||
|
||||
// Close on ESC
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancel()
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [open, onCancel])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm" onClick={onCancel}>
|
||||
<div className="w-[360px] bg-bg-card border border-border rounded-xl shadow-2xl p-6" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<div className="p-2 bg-red-500/10 rounded-lg shrink-0">
|
||||
<AlertTriangle className="w-5 h-5 text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-text-primary mb-1">{title}</h3>
|
||||
<p className="text-xs text-text-secondary leading-relaxed">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-bg-hover rounded-lg transition-colors"
|
||||
>
|
||||
{cancelText || 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
ref={confirmRef}
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 text-sm text-white bg-red-500 hover:bg-red-600 rounded-lg transition-colors"
|
||||
>
|
||||
{confirmText || 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
import { useState, useCallback, useRef, useEffect, useMemo, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { useStore, type Bug } from '../stores'
|
||||
import { api } from '../api'
|
||||
import { AnnotationCanvas, type AnnotationTool } from './AnnotationCanvas'
|
||||
import { ConfirmDialog } from './ConfirmDialog'
|
||||
import {
|
||||
Hand,
|
||||
MousePointer2,
|
||||
Square,
|
||||
MoveRight,
|
||||
Type,
|
||||
Hash,
|
||||
Highlighter,
|
||||
Pencil,
|
||||
Undo2,
|
||||
Redo2,
|
||||
RotateCcw,
|
||||
Minus,
|
||||
Plus,
|
||||
Maximize2,
|
||||
Clipboard,
|
||||
Plus as PlusIcon,
|
||||
X,
|
||||
ImageIcon,
|
||||
Columns2,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Mosaic pixel icon
|
||||
function MosaicIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 16 16" fill="currentColor">
|
||||
<rect x="0" y="0" width="4" height="4" />
|
||||
<rect x="8" y="0" width="4" height="4" />
|
||||
<rect x="4" y="4" width="4" height="4" />
|
||||
<rect x="12" y="4" width="4" height="4" />
|
||||
<rect x="0" y="8" width="4" height="4" />
|
||||
<rect x="8" y="8" width="4" height="4" />
|
||||
<rect x="4" y="12" width="4" height="4" />
|
||||
<rect x="12" y="12" width="4" height="4" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const toolColors = ['#EF4444', '#F59E0B', '#22C55E', '#3B82F6']
|
||||
|
||||
// Clamp index within bounds
|
||||
function clampIndex(idx: number, length: number) {
|
||||
return Math.max(0, Math.min(idx, length - 1))
|
||||
}
|
||||
|
||||
export function EditorArea({ bug }: { bug: Bug }) {
|
||||
const { t, locale, uploadScreenshot, deleteScreenshot, renameScreenshot, updateScreenshotAnnotated, saveAnnotations, reorderScreenshots, compareMode, setCompareMode, compareLeft, setCompareLeft, compareRight, setCompareRight } = useStore()
|
||||
const zh = locale === 'zh'
|
||||
|
||||
// 动态翻译默认截图名
|
||||
const displayName = (name: string) => {
|
||||
const m = name.match(/^(Screenshot|截图)\s*(\d+)$/)
|
||||
if (m) return zh ? `截图 ${m[2]}` : `Screenshot ${m[2]}`
|
||||
if (name === '粘贴截图' || name === 'Pasted screenshot') return zh ? '粘贴截图' : 'Pasted screenshot'
|
||||
return name
|
||||
}
|
||||
const [editingNameId, setEditingNameId] = useState<string | null>(null)
|
||||
const [activeTool, setActiveTool] = useState<AnnotationTool>('drag')
|
||||
const [activeColor, setActiveColor] = useState('#EF4444')
|
||||
const [activeLineWidth, setActiveLineWidth] = useState(2)
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [selectedScreenshot, setSelectedScreenshot] = useState(0)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const [resetConfirm, setResetConfirm] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const dragItemRef = useRef<number | null>(null)
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; imageUrl: string } | null>(null)
|
||||
const [copyToast, setCopyToast] = useState<string | null>(null)
|
||||
|
||||
const copyImageToClipboard = useCallback(async (imageUrl: string) => {
|
||||
try {
|
||||
const res = await fetch(imageUrl)
|
||||
const blob = await res.blob()
|
||||
const pngBlob = blob.type === 'image/png' ? blob : await new Promise<Blob>((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = img.naturalWidth
|
||||
canvas.height = img.naturalHeight
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(img, 0, 0)
|
||||
canvas.toBlob((b) => resolve(b!), 'image/png')
|
||||
}
|
||||
img.src = imageUrl
|
||||
})
|
||||
await navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })])
|
||||
setCopyToast(t.editor.copySuccess)
|
||||
} catch {
|
||||
setCopyToast(t.editor.copyFail)
|
||||
}
|
||||
setTimeout(() => setCopyToast(null), 1500)
|
||||
setContextMenu(null)
|
||||
}, [t])
|
||||
|
||||
const handleContextMenu = useCallback((e: ReactMouseEvent, imageUrl: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, imageUrl })
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return
|
||||
const close = () => setContextMenu(null)
|
||||
window.addEventListener('click', close)
|
||||
window.addEventListener('contextmenu', close)
|
||||
return () => {
|
||||
window.removeEventListener('click', close)
|
||||
window.removeEventListener('contextmenu', close)
|
||||
}
|
||||
}, [contextMenu])
|
||||
|
||||
// Reset screenshot index when bug changes
|
||||
useEffect(() => {
|
||||
setSelectedScreenshot(0)
|
||||
setCompareMode(false)
|
||||
setCompareLeft(0)
|
||||
setCompareRight(1)
|
||||
setZoom(100)
|
||||
}, [bug.id])
|
||||
|
||||
// Cleanup debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
||||
}, [])
|
||||
|
||||
// Listen for keyboard tool switching
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const tool = (e as CustomEvent).detail as AnnotationTool
|
||||
setActiveTool(tool)
|
||||
}
|
||||
window.addEventListener('bugpack:tool', handler)
|
||||
return () => window.removeEventListener('bugpack:tool', handler)
|
||||
}, [])
|
||||
|
||||
const hasScreenshots = bug.screenshots.length > 0
|
||||
const safeIdx = clampIndex(selectedScreenshot, bug.screenshots.length)
|
||||
const currentSS = hasScreenshots ? bug.screenshots[safeIdx] : undefined
|
||||
|
||||
const safeCompareLeft = clampIndex(compareLeft, bug.screenshots.length)
|
||||
const safeCompareRight = clampIndex(compareRight, bug.screenshots.length)
|
||||
|
||||
// Debounced save of annotation data + annotated render image
|
||||
const handleSaveAnnotations = useMemo(() => {
|
||||
return (ssId: string) => (canvasJson: unknown, annotatedDataUrl: string | null) => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
saveAnnotations(bug.id, ssId, [canvasJson])
|
||||
if (annotatedDataUrl) {
|
||||
api.saveAnnotatedImage(bug.id, ssId, annotatedDataUrl).catch(() => {})
|
||||
}
|
||||
}, 800)
|
||||
}
|
||||
}, [bug.id, saveAnnotations])
|
||||
|
||||
const tools: { key: AnnotationTool; icon: any; label: string }[] = [
|
||||
{ key: 'drag', icon: Hand, label: t.editor.tools.drag },
|
||||
{ key: 'select', icon: MousePointer2, label: t.editor.tools.select },
|
||||
{ key: 'rect', icon: Square, label: t.editor.tools.rect },
|
||||
{ key: 'arrow', icon: MoveRight, label: t.editor.tools.arrow },
|
||||
{ key: 'text', icon: Type, label: t.editor.tools.text },
|
||||
{ key: 'number', icon: Hash, label: t.editor.tools.number },
|
||||
{ key: 'highlight', icon: Highlighter, label: t.editor.tools.highlight },
|
||||
{ key: 'pen', icon: Pencil, label: t.editor.tools.pen },
|
||||
{ key: 'mosaic', icon: MosaicIcon, label: t.editor.tools.mosaic },
|
||||
]
|
||||
|
||||
// Drag-and-drop upload (max 10, sequential to avoid concurrency)
|
||||
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setDragOver(false)
|
||||
const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/')).slice(0, 10)
|
||||
for (const file of files) {
|
||||
try {
|
||||
await uploadScreenshot(bug.id, file, file.name)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
}
|
||||
}
|
||||
}, [bug.id, uploadScreenshot])
|
||||
|
||||
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
for (const file of files) {
|
||||
await uploadScreenshot(bug.id, file, file.name)
|
||||
}
|
||||
e.target.value = ''
|
||||
}, [bug.id, uploadScreenshot])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="h-12 bg-bg-input border-b border-border flex items-center px-4 shrink-0">
|
||||
<div className="flex items-center gap-0.5 bg-bg-primary/60 rounded-lg p-1">
|
||||
{tools.map(({ key, icon: Icon, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveTool(key)}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
activeTool === key
|
||||
? 'bg-bg-card text-accent shadow-sm'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-3" />
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{toolColors.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setActiveColor(color)}
|
||||
className={`w-5 h-5 rounded-full transition-all ${
|
||||
activeColor === color ? 'ring-2 ring-white/40 ring-offset-1 ring-offset-bg-input' : 'hover:ring-1 hover:ring-white/20'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
{/* Custom color picker */}
|
||||
<label
|
||||
className={`w-5 h-5 rounded-full cursor-pointer transition-all overflow-hidden relative ${
|
||||
!toolColors.includes(activeColor) ? 'ring-2 ring-white/40 ring-offset-1 ring-offset-bg-input' : 'hover:ring-1 hover:ring-white/20'
|
||||
}`}
|
||||
style={{ background: !toolColors.includes(activeColor) ? activeColor : `conic-gradient(red, yellow, lime, aqua, blue, magenta, red)` }}
|
||||
>
|
||||
<input
|
||||
type="color"
|
||||
value={activeColor}
|
||||
onChange={(e) => setActiveColor(e.target.value)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-6 bg-border mx-3" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new Event('bugpack:undo'))}
|
||||
className="p-2 rounded-md text-text-muted hover:text-text-secondary hover:bg-bg-hover transition-colors"
|
||||
title={t.editor.tools.undo}
|
||||
>
|
||||
<Undo2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.dispatchEvent(new Event('bugpack:redo'))}
|
||||
className="p-2 rounded-md text-text-muted hover:text-text-secondary hover:bg-bg-hover transition-colors"
|
||||
title={t.editor.tools.redo}
|
||||
>
|
||||
<Redo2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setResetConfirm(true)}
|
||||
className="p-2 rounded-md text-text-muted hover:text-red-400 hover:bg-bg-hover transition-colors"
|
||||
title={t.editor.tools.reset}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{([{ val: 1, size: 4 }, { val: 2, size: 7 }, { val: 4, size: 10 }]).map(({ val, size }) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setActiveLineWidth(val)}
|
||||
className={`w-6 h-6 flex items-center justify-center rounded-md transition-colors ${
|
||||
activeLineWidth === val ? 'bg-accent/20' : 'hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`block rounded-full ${activeLineWidth === val ? 'bg-accent' : 'bg-text-muted'}`}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{bug.screenshots.length >= 1 && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setCompareMode(!compareMode)
|
||||
if (!compareMode) {
|
||||
setCompareLeft(selectedScreenshot)
|
||||
setCompareRight(selectedScreenshot === 0 ? 1 : 0)
|
||||
}
|
||||
}}
|
||||
className={`p-2 rounded-md transition-colors ${
|
||||
compareMode ? 'bg-accent/20 text-accent' : 'text-text-muted hover:text-text-secondary hover:bg-bg-hover'
|
||||
}`}
|
||||
title={t.editor.compare}
|
||||
>
|
||||
<Columns2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex-1 relative overflow-hidden bg-bg-primary ${dragOver ? 'drop-active' : ''}`}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onContextMenu={(e) => {
|
||||
if (currentSS?.url) handleContextMenu(e as unknown as ReactMouseEvent, currentSS.url)
|
||||
}}
|
||||
>
|
||||
{dragOver && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-accent/5 border-2 border-dashed border-accent rounded-lg">
|
||||
<p className="text-accent text-lg font-medium">{t.editor.emptySubtitle}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasScreenshots && currentSS && compareMode ? (
|
||||
/* Compare mode: two screenshots side by side */
|
||||
<div className="absolute inset-0 flex">
|
||||
<div className="flex-1 flex flex-col border-r border-border">
|
||||
<div className="text-center py-1.5 bg-bg-input border-b border-border">
|
||||
<span className="text-xs text-text-muted">{t.editor.compareLeft}</span>
|
||||
<select
|
||||
value={safeCompareLeft}
|
||||
onChange={(e) => setCompareLeft(Number(e.target.value))}
|
||||
className="ml-2 text-xs bg-bg-card border border-border rounded px-1 py-0.5 text-text-primary"
|
||||
>
|
||||
{bug.screenshots.map((ss, i) => (
|
||||
<option key={ss.id} value={i}>{displayName(ss.name)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center p-4 overflow-auto">
|
||||
{bug.screenshots[safeCompareLeft] ? (
|
||||
<img src={bug.screenshots[safeCompareLeft].url} alt={displayName(bug.screenshots[safeCompareLeft].name)} className="max-w-full max-h-full object-contain rounded" />
|
||||
) : (
|
||||
<p className="text-text-muted text-sm">{t.editor.emptySubtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="text-center py-1.5 bg-bg-input border-b border-border">
|
||||
<span className="text-xs text-text-muted">{t.editor.compareRight}</span>
|
||||
<select
|
||||
value={safeCompareRight}
|
||||
onChange={(e) => setCompareRight(Number(e.target.value))}
|
||||
className="ml-2 text-xs bg-bg-card border border-border rounded px-1 py-0.5 text-text-primary"
|
||||
>
|
||||
{bug.screenshots.map((ss, i) => (
|
||||
<option key={ss.id} value={i}>{displayName(ss.name)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center p-4 overflow-auto">
|
||||
{bug.screenshots.length >= 2 && bug.screenshots[safeCompareRight] ? (
|
||||
<img src={bug.screenshots[safeCompareRight].url} alt={bug.screenshots[safeCompareRight].name} className="max-w-full max-h-full object-contain rounded" />
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex flex-col items-center justify-center gap-2 text-text-muted hover:text-accent transition-colors border-2 border-dashed border-border hover:border-accent/50 rounded-xl px-12 py-8"
|
||||
>
|
||||
<PlusIcon className="w-8 h-8" />
|
||||
<span className="text-sm">{zh ? '上传期望效果图' : 'Upload expected image'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : hasScreenshots && currentSS ? (
|
||||
<>
|
||||
<AnnotationCanvas
|
||||
key={currentSS.id}
|
||||
imageUrl={currentSS.url}
|
||||
color={activeColor}
|
||||
tool={activeTool}
|
||||
lineWidth={activeLineWidth}
|
||||
zoom={zoom}
|
||||
onZoomChange={setZoom}
|
||||
initialAnnotations={currentSS.annotations}
|
||||
onSaveAnnotations={handleSaveAnnotations(currentSS.id)}
|
||||
onAnnotated={() => {
|
||||
if (currentSS && !currentSS.annotated) {
|
||||
updateScreenshotAnnotated(bug.id, currentSS.id)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-4 right-4 flex items-center gap-1 bg-bg-card/90 backdrop-blur-sm rounded-lg px-2 py-1 border border-border z-10">
|
||||
<button onClick={() => setZoom(Math.max(25, zoom - 25))} className="p-1 text-text-muted hover:text-text-secondary">
|
||||
<Minus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span className="text-xs text-text-secondary w-10 text-center">{zoom}%</span>
|
||||
<button onClick={() => setZoom(Math.min(200, zoom + 25))} className="p-1 text-text-muted hover:text-text-secondary">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="w-px h-4 bg-border mx-1" />
|
||||
<button onClick={() => { window.dispatchEvent(new Event('bugpack:fitWindow')) }} className="p-1 text-text-muted hover:text-text-secondary" title={t.editor.fitWindow}>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center border-2 border-dashed border-border rounded-2xl px-16 py-12">
|
||||
<Clipboard className="w-12 h-12 text-text-muted mx-auto mb-4" />
|
||||
<p className="text-lg text-text-secondary mb-1">{t.editor.emptyTitle}</p>
|
||||
<p className="text-sm text-text-muted mb-3">{t.editor.emptySubtitle}</p>
|
||||
<p className="text-xs text-text-muted">{t.editor.emptyFormat}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.csv,.html,.md,.json,.xml,.zip"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
/>
|
||||
|
||||
<div className="h-[180px] border-t border-border bg-bg-sidebar shrink-0">
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<span className="text-sm text-text-secondary">
|
||||
{t.evidence.title} ({bug.screenshots.length})
|
||||
</span>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex items-center gap-1 text-xs text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
<PlusIcon className="w-3 h-3" />
|
||||
{t.evidence.addFile}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3 px-4 pb-3 overflow-x-auto">
|
||||
{bug.screenshots.map((ss, i) => (
|
||||
<button
|
||||
key={ss.id}
|
||||
draggable
|
||||
onClick={() => setSelectedScreenshot(i)}
|
||||
onDragStart={() => { dragItemRef.current = i }}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragOverIndex(i) }}
|
||||
onDragLeave={() => setDragOverIndex(null)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setDragOverIndex(null)
|
||||
const from = dragItemRef.current
|
||||
if (from === null || from === i) return
|
||||
const newOrder = [...bug.screenshots.map(s => s.id)]
|
||||
const moved = newOrder.splice(from, 1)[0]
|
||||
if (moved) newOrder.splice(i, 0, moved)
|
||||
reorderScreenshots(bug.id, newOrder)
|
||||
setSelectedScreenshot(i)
|
||||
dragItemRef.current = null
|
||||
}}
|
||||
onDragEnd={() => { dragItemRef.current = null; setDragOverIndex(null) }}
|
||||
className={`shrink-0 group relative transition-transform ${dragOverIndex === i ? 'scale-105 ring-2 ring-accent' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`w-[120px] h-[90px] rounded-lg overflow-hidden border-2 transition-colors ${
|
||||
selectedScreenshot === i ? 'border-accent' : 'border-border hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{ss.url ? (
|
||||
<img
|
||||
src={ss.url}
|
||||
alt={ss.name}
|
||||
className="w-full h-full object-cover"
|
||||
onContextMenu={(e) => handleContextMenu(e, ss.url)}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-bg-input to-bg-card flex items-center justify-center">
|
||||
<ImageIcon className="w-6 h-6 text-text-muted/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); deleteScreenshot(bug.id, ss.id) }}
|
||||
className="absolute top-1 right-1 w-5 h-5 bg-red-500 rounded-full items-center justify-center text-white hidden group-hover:flex z-10"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
{editingNameId === ss.id ? (
|
||||
<input
|
||||
autoFocus
|
||||
defaultValue={ss.name}
|
||||
className="text-[11px] text-text-primary mt-1.5 text-center w-[120px] bg-bg-input border border-accent rounded px-1 py-0.5 outline-none"
|
||||
onBlur={(e) => {
|
||||
const val = e.target.value.trim()
|
||||
if (val && val !== ss.name) renameScreenshot(bug.id, ss.id, val)
|
||||
setEditingNameId(null)
|
||||
}}
|
||||
onKeyDown={(e: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') (e.target as HTMLInputElement).blur()
|
||||
if (e.key === 'Escape') setEditingNameId(null)
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<p
|
||||
className="text-[11px] text-text-secondary mt-1.5 text-center truncate w-[120px] cursor-text"
|
||||
onDoubleClick={(e) => { e.stopPropagation(); setEditingNameId(ss.id) }}
|
||||
>
|
||||
{displayName(ss.name)}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="shrink-0 w-[120px] h-[90px] rounded-lg border-2 border-dashed border-border hover:border-accent/50 flex flex-col items-center justify-center text-text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
<PlusIcon className="w-6 h-6 mb-1" />
|
||||
<span className="text-[11px]">{t.evidence.addFile}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={resetConfirm}
|
||||
title={zh ? '重置标注' : 'Reset Annotations'}
|
||||
message={zh
|
||||
? `确定清除「${currentSS?.name || '当前截图'}」上的所有标注?此操作可通过撤销恢复。`
|
||||
: `Clear all annotations on "${currentSS?.name || 'current screenshot'}"? You can undo this action.`}
|
||||
confirmText={zh ? '确认重置' : 'Reset'}
|
||||
cancelText={zh ? '取消' : 'Cancel'}
|
||||
onConfirm={() => {
|
||||
window.dispatchEvent(new Event('bugpack:reset'))
|
||||
setResetConfirm(false)
|
||||
}}
|
||||
onCancel={() => setResetConfirm(false)}
|
||||
/>
|
||||
|
||||
{contextMenu && (
|
||||
<div
|
||||
className="fixed z-50 bg-bg-card border border-border rounded-lg shadow-lg py-1 min-w-[140px]"
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-sm text-text-primary hover:bg-bg-hover text-left"
|
||||
onClick={() => copyImageToClipboard(contextMenu.imageUrl)}
|
||||
>
|
||||
{t.editor.copyImage}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{copyToast && (
|
||||
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 bg-bg-card border border-border rounded-lg px-4 py-2 text-sm text-text-primary shadow-lg">
|
||||
{copyToast}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useStore } from '../stores'
|
||||
import { Lightbulb } from 'lucide-react'
|
||||
|
||||
export function EmptyState() {
|
||||
const { t, createBug } = useStore()
|
||||
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<img src="/favicon.svg" alt="BugPack" className="w-12 h-12" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold text-text-primary mb-2">
|
||||
{t.empty.title}
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted mb-6">{t.empty.subtitle}</p>
|
||||
|
||||
<button onClick={() => createBug()} className="px-6 py-2.5 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-lg transition-colors">
|
||||
{t.empty.createFirst}
|
||||
</button>
|
||||
|
||||
<div className="mt-8 mx-auto max-w-sm bg-bg-card border border-border rounded-xl p-4">
|
||||
<div className="flex items-start gap-2">
|
||||
<Lightbulb className="w-4 h-4 text-yellow-400 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-text-muted text-left leading-relaxed">
|
||||
{t.empty.tip}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useStore } from '../stores'
|
||||
import { api } from '../api'
|
||||
import { X, Download, RefreshCw, ExternalLink, AlertCircle, ChevronDown, CheckSquare, Square } from 'lucide-react'
|
||||
|
||||
interface JiraBug {
|
||||
id: string
|
||||
key: string
|
||||
title: string
|
||||
priority: string
|
||||
priorityId: string
|
||||
status: string
|
||||
statusCategory: string
|
||||
reporter: string
|
||||
created: string
|
||||
hasAttachments: boolean
|
||||
}
|
||||
|
||||
interface JiraProject {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function JiraModal({ onClose }: { onClose: () => void }) {
|
||||
const { locale, currentProjectId, fetchBugs, settings, saveSettings } = useStore()
|
||||
const hasImported = useRef(false)
|
||||
const zh = locale === 'zh'
|
||||
|
||||
const [projects, setProjects] = useState<JiraProject[]>([])
|
||||
const [selectedProjectKey, setSelectedProjectKey] = useState(settings.jiraProjectKey || '')
|
||||
const [bugs, setBugs] = useState<JiraBug[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [importing, setImporting] = useState<Set<string>>(new Set())
|
||||
const [imported, setImported] = useState<Set<string>>(new Set())
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchImporting, setBatchImporting] = useState(false)
|
||||
const [step, setStep] = useState<'projects' | 'bugs'>(settings.jiraProjectKey ? 'bugs' : 'projects')
|
||||
|
||||
// Load project list
|
||||
const loadProjects = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.jira.getProjects()
|
||||
if (!res.ok) throw new Error(res.error || 'Failed to fetch projects')
|
||||
setProjects(res.projects || [])
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load bug list
|
||||
const loadBugs = async (projectKey?: string) => {
|
||||
const pk = projectKey || selectedProjectKey
|
||||
if (!pk) { setStep('projects'); loadProjects(); return }
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
if (pk !== settings.jiraProjectKey) {
|
||||
await saveSettings({ jiraProjectKey: pk })
|
||||
}
|
||||
const res = await api.jira.getBugs()
|
||||
if (!res.ok) throw new Error(res.error || 'Failed to fetch')
|
||||
setBugs(res.bugs || [])
|
||||
setStep('bugs')
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.jiraProjectKey) {
|
||||
loadBugs(settings.jiraProjectKey)
|
||||
} else {
|
||||
loadProjects()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectProject = (key: string) => {
|
||||
setSelectedProjectKey(key)
|
||||
loadBugs(key)
|
||||
}
|
||||
|
||||
// Import single bug
|
||||
const handleImport = async (key: string) => {
|
||||
setImporting(prev => new Set(prev).add(key))
|
||||
try {
|
||||
const res = await api.jira.importBug(key, currentProjectId)
|
||||
if (!res.ok) throw new Error('Import failed')
|
||||
setImported(prev => new Set(prev).add(key))
|
||||
hasImported.current = true
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setImporting(prev => { const s = new Set(prev); s.delete(key); return s })
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (key: string) => {
|
||||
setSelected(prev => {
|
||||
const s = new Set(prev)
|
||||
s.has(key) ? s.delete(key) : s.add(key)
|
||||
return s
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const importable = bugs.filter(b => !imported.has(b.key)).map(b => b.key)
|
||||
const allSelected = importable.every(k => selected.has(k))
|
||||
setSelected(allSelected ? new Set() : new Set(importable))
|
||||
}
|
||||
|
||||
const handleBatchImport = async () => {
|
||||
if (selected.size === 0) return
|
||||
setBatchImporting(true)
|
||||
const keys = [...selected].filter(k => !imported.has(k))
|
||||
for (const key of keys) {
|
||||
setImporting(prev => new Set(prev).add(key))
|
||||
try {
|
||||
const res = await api.jira.importBug(key, currentProjectId)
|
||||
if (res.ok) setImported(prev => new Set(prev).add(key))
|
||||
} catch {
|
||||
// skip failed
|
||||
} finally {
|
||||
setImporting(prev => { const s = new Set(prev); s.delete(key); return s })
|
||||
}
|
||||
}
|
||||
setSelected(new Set())
|
||||
hasImported.current = true
|
||||
setBatchImporting(false)
|
||||
fetchBugs()
|
||||
onClose()
|
||||
}
|
||||
|
||||
// Priority color
|
||||
const priColor = (name: string) => {
|
||||
const n = name.toLowerCase()
|
||||
if (n.includes('high') || n.includes('critical') || n.includes('blocker')) return 'text-red-400'
|
||||
if (n.includes('medium')) return 'text-yellow-400'
|
||||
return 'text-text-muted'
|
||||
}
|
||||
|
||||
const curProject = projects.find(p => p.key === selectedProjectKey)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-[600px] max-h-[80vh] bg-bg-card border border-border rounded-2xl shadow-2xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="w-5 h-5 text-blue-400" />
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{zh ? '从 Jira 导入' : 'Import from Jira'}
|
||||
</h2>
|
||||
{step === 'bugs' && (
|
||||
<button
|
||||
onClick={() => { setStep('projects'); loadProjects() }}
|
||||
className="ml-2 flex items-center gap-1 px-2 py-0.5 text-xs text-blue-400 bg-blue-400/10 rounded hover:bg-blue-400/20 transition-colors"
|
||||
>
|
||||
{curProject ? `${curProject.name} (${curProject.key})` : selectedProjectKey}
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => step === 'bugs' ? loadBugs() : loadProjects()}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button onClick={() => { if (hasImported.current) fetchBugs(); onClose() }} className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-red-400">
|
||||
<p>{error}</p>
|
||||
<p className="text-xs text-red-400/60 mt-1">
|
||||
{zh ? '请检查设置中的 Jira 配置' : 'Check Jira settings'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex items-center justify-center py-12 text-text-muted text-sm">
|
||||
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
|
||||
{zh ? '加载中...' : 'Loading...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project selection */}
|
||||
{!loading && step === 'projects' && !error && (
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-text-secondary mb-3">
|
||||
{zh ? '选择 Jira 项目:' : 'Select a Jira project:'}
|
||||
</p>
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-sm text-text-muted text-center py-8">
|
||||
{zh ? '没有找到项目(当前账号可能无权限)' : 'No projects found (no permission?)'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{projects.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => selectProject(p.key)}
|
||||
className="w-full text-left px-4 py-3 bg-bg-input border border-border rounded-lg hover:border-blue-400 hover:bg-blue-400/5 transition-colors"
|
||||
>
|
||||
<span className="text-xs text-text-muted mr-2">{p.key}</span>
|
||||
<span className="text-sm text-text-primary">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bug list */}
|
||||
{!loading && step === 'bugs' && !error && bugs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-text-muted text-sm">
|
||||
<p>{zh ? '该项目下没有指派给你的 Bug' : 'No bugs assigned to you'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && step === 'bugs' && bugs.length > 0 && (
|
||||
<div className="divide-y divide-border">
|
||||
{bugs.map(bug => (
|
||||
<div key={bug.key} className="px-6 py-3 flex items-center gap-3 hover:bg-bg-hover transition-colors">
|
||||
<button
|
||||
onClick={() => toggleSelect(bug.key)}
|
||||
className={`shrink-0 ${imported.has(bug.key) ? 'text-text-muted/30 cursor-default' : 'text-text-muted hover:text-blue-400'}`}
|
||||
disabled={imported.has(bug.key)}
|
||||
>
|
||||
{selected.has(bug.key) ? <CheckSquare className="w-4 h-4 text-blue-400" /> : <Square className="w-4 h-4" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-xs text-text-muted">{bug.key}</span>
|
||||
<span className={`text-xs font-medium ${priColor(bug.priority)}`}>{bug.priority}</span>
|
||||
<span className="text-xs text-text-muted px-1.5 py-0.5 bg-bg-input rounded">{bug.status}</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-primary truncate">{bug.title}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted mt-0.5">
|
||||
{bug.reporter && <span>{bug.reporter}</span>}
|
||||
{bug.created && <span>{new Date(bug.created).toLocaleString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleImport(bug.key)}
|
||||
disabled={importing.has(bug.key) || imported.has(bug.key)}
|
||||
className={`shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
|
||||
imported.has(bug.key)
|
||||
? 'bg-green-500/20 text-green-400 cursor-default'
|
||||
: importing.has(bug.key)
|
||||
? 'bg-bg-input text-text-muted cursor-wait'
|
||||
: 'bg-blue-400/20 text-blue-400 hover:bg-blue-400/30'
|
||||
}`}
|
||||
>
|
||||
{imported.has(bug.key) ? (
|
||||
zh ? '已导入' : 'Imported'
|
||||
) : importing.has(bug.key) ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '导入中' : 'Importing'}</>
|
||||
) : (
|
||||
<><Download className="w-3 h-3" /> {zh ? '导入' : 'Import'}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-border shrink-0 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{step === 'bugs' && bugs.length > 0 && (
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
className="flex items-center gap-1.5 text-xs text-text-muted hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{bugs.filter(b => !imported.has(b.key)).every(b => selected.has(b.key)) && bugs.some(b => !imported.has(b.key))
|
||||
? <CheckSquare className="w-3.5 h-3.5 text-blue-400" />
|
||||
: <Square className="w-3.5 h-3.5" />}
|
||||
{zh ? '全选' : 'Select All'}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-text-muted">
|
||||
{step === 'bugs'
|
||||
? (zh
|
||||
? `指派给我 ${bugs.length} 个 Bug${selected.size > 0 ? `,已选 ${selected.size} 个` : ''}`
|
||||
: `${bugs.length} bugs assigned to me${selected.size > 0 ? `, ${selected.size} selected` : ''}`)
|
||||
: (zh ? `共 ${projects.length} 个项目` : `${projects.length} projects`)}
|
||||
</span>
|
||||
</div>
|
||||
{step === 'bugs' && selected.size > 0 && (
|
||||
<button
|
||||
onClick={handleBatchImport}
|
||||
disabled={batchImporting}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
|
||||
batchImporting
|
||||
? 'bg-bg-input text-text-muted cursor-wait'
|
||||
: 'bg-blue-400 text-white hover:bg-blue-500'
|
||||
}`}
|
||||
>
|
||||
{batchImporting ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '批量导入中...' : 'Importing...'}</>
|
||||
) : (
|
||||
<><Download className="w-3 h-3" /> {zh ? `批量导入 (${selected.size})` : `Import (${selected.size})`}</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useStore } from '../stores'
|
||||
import { api } from '../api'
|
||||
import { X, Download, RefreshCw, ExternalLink, AlertCircle, ChevronDown, CheckSquare, Square } from 'lucide-react'
|
||||
|
||||
interface LinearBug {
|
||||
id: string
|
||||
identifier: string
|
||||
title: string
|
||||
priority: number
|
||||
priorityLabel: string
|
||||
status: string
|
||||
statusType: string
|
||||
creator: string
|
||||
created: string
|
||||
hasAttachments: boolean
|
||||
}
|
||||
|
||||
interface LinearTeam {
|
||||
id: string
|
||||
key: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function LinearModal({ onClose }: { onClose: () => void }) {
|
||||
const { locale, currentProjectId, fetchBugs, settings, saveSettings } = useStore()
|
||||
const zh = locale === 'zh'
|
||||
const hasImported = useRef(false)
|
||||
|
||||
const [teams, setTeams] = useState<LinearTeam[]>([])
|
||||
const [selectedTeamId, setSelectedTeamId] = useState(settings.linearTeamId || '')
|
||||
const [bugs, setBugs] = useState<LinearBug[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [importing, setImporting] = useState<Set<string>>(new Set())
|
||||
const [imported, setImported] = useState<Set<string>>(new Set())
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchImporting, setBatchImporting] = useState(false)
|
||||
const [step, setStep] = useState<'teams' | 'bugs'>(settings.linearTeamId ? 'bugs' : 'teams')
|
||||
|
||||
const loadTeams = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.linear.getTeams()
|
||||
if (!res.ok) throw new Error(res.error || 'Failed to fetch teams')
|
||||
setTeams(res.teams || [])
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const loadBugs = async (teamId?: string) => {
|
||||
const tid = teamId || selectedTeamId
|
||||
if (!tid) { setStep('teams'); loadTeams(); return }
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
if (tid !== settings.linearTeamId) {
|
||||
await saveSettings({ linearTeamId: tid })
|
||||
}
|
||||
const res = await api.linear.getBugs()
|
||||
if (!res.ok) throw new Error(res.error || 'Failed to fetch')
|
||||
setBugs(res.bugs || [])
|
||||
setStep('bugs')
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.linearTeamId) {
|
||||
loadBugs(settings.linearTeamId)
|
||||
} else {
|
||||
loadTeams()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectTeam = (id: string) => {
|
||||
setSelectedTeamId(id)
|
||||
loadBugs(id)
|
||||
}
|
||||
|
||||
const handleImport = async (id: string) => {
|
||||
setImporting(prev => new Set(prev).add(id))
|
||||
try {
|
||||
const res = await api.linear.importBug(id, currentProjectId)
|
||||
if (!res.ok) throw new Error('Import failed')
|
||||
setImported(prev => new Set(prev).add(id))
|
||||
hasImported.current = true
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setImporting(prev => { const s = new Set(prev); s.delete(id); return s })
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected(prev => {
|
||||
const s = new Set(prev)
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
return s
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const importable = bugs.filter(b => !imported.has(b.id)).map(b => b.id)
|
||||
const allSelected = importable.every(id => selected.has(id))
|
||||
setSelected(allSelected ? new Set() : new Set(importable))
|
||||
}
|
||||
|
||||
const handleBatchImport = async () => {
|
||||
if (selected.size === 0) return
|
||||
setBatchImporting(true)
|
||||
const ids = [...selected].filter(id => !imported.has(id))
|
||||
for (const id of ids) {
|
||||
setImporting(prev => new Set(prev).add(id))
|
||||
try {
|
||||
const res = await api.linear.importBug(id, currentProjectId)
|
||||
if (res.ok) setImported(prev => new Set(prev).add(id))
|
||||
} catch {
|
||||
// skip failed
|
||||
} finally {
|
||||
setImporting(prev => { const s = new Set(prev); s.delete(id); return s })
|
||||
}
|
||||
}
|
||||
setSelected(new Set())
|
||||
hasImported.current = true
|
||||
setBatchImporting(false)
|
||||
fetchBugs()
|
||||
onClose()
|
||||
}
|
||||
|
||||
// Priority color: 1=Urgent 2=High 3=Medium 4=Low
|
||||
const priColor = (p: number) => {
|
||||
if (p <= 1) return 'text-red-400'
|
||||
if (p === 2) return 'text-orange-400'
|
||||
if (p === 3) return 'text-yellow-400'
|
||||
return 'text-text-muted'
|
||||
}
|
||||
|
||||
const curTeam = teams.find(t => t.id === selectedTeamId)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-[600px] max-h-[80vh] bg-bg-card border border-border rounded-2xl shadow-2xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="w-5 h-5 text-violet-400" />
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{zh ? '从 Linear 导入' : 'Import from Linear'}
|
||||
</h2>
|
||||
{step === 'bugs' && (
|
||||
<button
|
||||
onClick={() => { setStep('teams'); loadTeams() }}
|
||||
className="ml-2 flex items-center gap-1 px-2 py-0.5 text-xs text-violet-400 bg-violet-400/10 rounded hover:bg-violet-400/20 transition-colors"
|
||||
>
|
||||
{curTeam ? `${curTeam.name} (${curTeam.key})` : selectedTeamId}
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => step === 'bugs' ? loadBugs() : loadTeams()}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button onClick={() => { if (hasImported.current) fetchBugs(); onClose() }} className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-red-400">
|
||||
<p>{error}</p>
|
||||
<p className="text-xs text-red-400/60 mt-1">
|
||||
{zh ? '请检查设置中的 Linear 配置' : 'Check Linear settings'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex items-center justify-center py-12 text-text-muted text-sm">
|
||||
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
|
||||
{zh ? '加载中...' : 'Loading...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team selection */}
|
||||
{!loading && step === 'teams' && !error && (
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-text-secondary mb-3">
|
||||
{zh ? '选择 Linear 团队:' : 'Select a Linear team:'}
|
||||
</p>
|
||||
{teams.length === 0 ? (
|
||||
<p className="text-sm text-text-muted text-center py-8">
|
||||
{zh ? '没有找到团队' : 'No teams found'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{teams.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => selectTeam(t.id)}
|
||||
className="w-full text-left px-4 py-3 bg-bg-input border border-border rounded-lg hover:border-violet-400 hover:bg-violet-400/5 transition-colors"
|
||||
>
|
||||
<span className="text-xs text-text-muted mr-2">{t.key}</span>
|
||||
<span className="text-sm text-text-primary">{t.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issue list */}
|
||||
{!loading && step === 'bugs' && !error && bugs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-text-muted text-sm">
|
||||
<p>{zh ? '该团队下没有指派给你的 Issue' : 'No issues assigned to you'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && step === 'bugs' && bugs.length > 0 && (
|
||||
<div className="divide-y divide-border">
|
||||
{bugs.map(bug => (
|
||||
<div key={bug.id} className="px-6 py-3 flex items-center gap-3 hover:bg-bg-hover transition-colors">
|
||||
<button
|
||||
onClick={() => toggleSelect(bug.id)}
|
||||
className={`shrink-0 ${imported.has(bug.id) ? 'text-text-muted/30 cursor-default' : 'text-text-muted hover:text-violet-400'}`}
|
||||
disabled={imported.has(bug.id)}
|
||||
>
|
||||
{selected.has(bug.id) ? <CheckSquare className="w-4 h-4 text-violet-400" /> : <Square className="w-4 h-4" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-xs text-text-muted">{bug.identifier}</span>
|
||||
<span className={`text-xs font-medium ${priColor(bug.priority)}`}>{bug.priorityLabel}</span>
|
||||
<span className="text-xs text-text-muted px-1.5 py-0.5 bg-bg-input rounded">{bug.status}</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-primary truncate">{bug.title}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted mt-0.5">
|
||||
{bug.creator && <span>{bug.creator}</span>}
|
||||
{bug.created && <span>{new Date(bug.created).toLocaleString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleImport(bug.id)}
|
||||
disabled={importing.has(bug.id) || imported.has(bug.id)}
|
||||
className={`shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
|
||||
imported.has(bug.id)
|
||||
? 'bg-green-500/20 text-green-400 cursor-default'
|
||||
: importing.has(bug.id)
|
||||
? 'bg-bg-input text-text-muted cursor-wait'
|
||||
: 'bg-violet-400/20 text-violet-400 hover:bg-violet-400/30'
|
||||
}`}
|
||||
>
|
||||
{imported.has(bug.id) ? (
|
||||
zh ? '已导入' : 'Imported'
|
||||
) : importing.has(bug.id) ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '导入中' : 'Importing'}</>
|
||||
) : (
|
||||
<><Download className="w-3 h-3" /> {zh ? '导入' : 'Import'}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-border shrink-0 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{step === 'bugs' && bugs.length > 0 && (
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
className="flex items-center gap-1.5 text-xs text-text-muted hover:text-violet-400 transition-colors"
|
||||
>
|
||||
{bugs.filter(b => !imported.has(b.id)).every(b => selected.has(b.id)) && bugs.some(b => !imported.has(b.id))
|
||||
? <CheckSquare className="w-3.5 h-3.5 text-violet-400" />
|
||||
: <Square className="w-3.5 h-3.5" />}
|
||||
{zh ? '全选' : 'Select All'}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-text-muted">
|
||||
{step === 'bugs'
|
||||
? (zh
|
||||
? `指派给我 ${bugs.length} 个 Issue${selected.size > 0 ? `,已选 ${selected.size} 个` : ''}`
|
||||
: `${bugs.length} issues assigned to me${selected.size > 0 ? `, ${selected.size} selected` : ''}`)
|
||||
: (zh ? `共 ${teams.length} 个团队` : `${teams.length} teams`)}
|
||||
</span>
|
||||
</div>
|
||||
{step === 'bugs' && selected.size > 0 && (
|
||||
<button
|
||||
onClick={handleBatchImport}
|
||||
disabled={batchImporting}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
|
||||
batchImporting
|
||||
? 'bg-bg-input text-text-muted cursor-wait'
|
||||
: 'bg-violet-400 text-white hover:bg-violet-500'
|
||||
}`}
|
||||
>
|
||||
{batchImporting ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '批量导入中...' : 'Importing...'}</>
|
||||
) : (
|
||||
<><Download className="w-3 h-3" /> {zh ? `批量导入 (${selected.size})` : `Import (${selected.size})`}</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useStore } from '../stores'
|
||||
import { ConfirmDialog } from './ConfirmDialog'
|
||||
import { api } from '../api'
|
||||
import {
|
||||
Settings,
|
||||
ChevronDown,
|
||||
Plus,
|
||||
Trash2,
|
||||
Download,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
|
||||
export function Navbar() {
|
||||
const {
|
||||
t, locale, setSettingsOpen,
|
||||
currentProject, currentProjectId, projects,
|
||||
createProject, switchProject, deleteProject, fetchBugs,
|
||||
} = useStore()
|
||||
const zh = locale === 'zh'
|
||||
|
||||
// Project switching
|
||||
const [projDropdown, setProjDropdown] = useState(false)
|
||||
const [projCreating, setProjCreating] = useState(false)
|
||||
const [projNewName, setProjNewName] = useState('')
|
||||
const [projCreateLoading, setProjCreateLoading] = useState(false)
|
||||
const [projImportLoading, setProjImportLoading] = useState(false)
|
||||
const [projDeleteTarget, setProjDeleteTarget] = useState<{ id: string; name: string } | null>(null)
|
||||
const projDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const projImportRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (projDropdownRef.current && !projDropdownRef.current.contains(e.target as Node)) {
|
||||
setProjDropdown(false)
|
||||
setProjCreating(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [])
|
||||
|
||||
const handleProjCreate = async () => {
|
||||
const name = projNewName.trim()
|
||||
if (!name || projCreateLoading) return
|
||||
setProjCreateLoading(true)
|
||||
try {
|
||||
await createProject(name)
|
||||
setProjNewName('')
|
||||
setProjCreating(false)
|
||||
setProjDropdown(false)
|
||||
} finally {
|
||||
setProjCreateLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleProjImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || projImportLoading) return
|
||||
setProjImportLoading(true)
|
||||
try {
|
||||
const res = await api.importProject(currentProjectId, file)
|
||||
if (res.ok) {
|
||||
await fetchBugs()
|
||||
setProjDropdown(false)
|
||||
} else {
|
||||
console.error('Import failed:',res.error)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import failed:',err)
|
||||
} finally {
|
||||
setProjImportLoading(false)
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="h-12 bg-bg-primary border-b border-border flex items-center justify-between px-4 shrink-0">
|
||||
{/* Left Logo */}
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="BugPack" className="w-7 h-7" />
|
||||
<span className="text-base font-bold tracking-tight text-text-primary">{t.app.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Project switcher */}
|
||||
<div className="relative" ref={projDropdownRef}>
|
||||
<button
|
||||
onClick={() => setProjDropdown(!projDropdown)}
|
||||
className="flex items-center gap-2 px-4 py-1.5 rounded-full border border-border text-sm hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<span className="text-text-muted">{t.nav.project}:</span>
|
||||
<span className="text-text-primary font-medium">{currentProject}</span>
|
||||
<ChevronDown className={`w-3 h-3 text-text-muted transition-transform ${projDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{projDropdown && (
|
||||
<div className="absolute top-full right-0 mt-1 w-64 bg-bg-card border border-border rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
{/* Project list */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{projects.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={`flex items-center justify-between px-3 py-2 text-sm cursor-pointer transition-colors group ${
|
||||
p.id === currentProjectId ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:bg-bg-hover'
|
||||
}`}
|
||||
onClick={() => { switchProject(p.id); setProjDropdown(false) }}
|
||||
>
|
||||
<span className="truncate">{p.name}</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setProjDeleteTarget({ id: p.id, name: p.name }) }}
|
||||
className="p-1 text-text-muted hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* New project input (shown when expanded) */}
|
||||
{projCreating && (
|
||||
<div className="p-2">
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
autoFocus
|
||||
value={projNewName}
|
||||
onChange={(e) => setProjNewName(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleProjCreate(); if (e.key === 'Escape') setProjCreating(false) }}
|
||||
placeholder={zh ? '项目名称' : 'Project name'}
|
||||
className="flex-1 px-2 py-1.5 bg-bg-input border border-border rounded text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={handleProjCreate}
|
||||
disabled={projCreateLoading}
|
||||
className={`px-3 py-1.5 text-white text-xs rounded transition-colors ${projCreateLoading ? 'bg-accent/50 cursor-wait' : 'bg-accent hover:bg-accent-hover'}`}
|
||||
>
|
||||
{projCreateLoading ? '...' : (zh ? '创建' : 'OK')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* New / Export / Import in one row */}
|
||||
<div className="px-2 py-1.5 flex gap-1">
|
||||
{!projCreating && (
|
||||
<button
|
||||
onClick={() => setProjCreating(true)}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs text-accent bg-accent/10 hover:bg-accent/20 rounded transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{zh ? '新建' : 'New'}
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={api.exportProject(currentProjectId)}
|
||||
download
|
||||
onClick={() => setProjDropdown(false)}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs text-text-secondary hover:bg-bg-hover rounded transition-colors"
|
||||
>
|
||||
<Download className="w-3 h-3" />
|
||||
{zh ? '导出' : 'Export'}
|
||||
</a>
|
||||
<button
|
||||
onClick={() => { if (!projImportLoading) projImportRef.current?.click() }}
|
||||
disabled={projImportLoading}
|
||||
className={`flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs rounded transition-colors ${
|
||||
projImportLoading ? 'text-text-muted cursor-wait' : 'text-text-secondary hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
<Upload className={`w-3 h-3 ${projImportLoading ? 'animate-spin' : ''}`} />
|
||||
{projImportLoading ? '...' : (zh ? '导入' : 'Import')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<input ref={projImportRef} type="file" accept=".zip" className="hidden" onChange={handleProjImport} />
|
||||
</div>
|
||||
|
||||
{/* Settings */}
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="p-2 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors"
|
||||
title={t.nav.settings}
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete project confirmation */}
|
||||
<ConfirmDialog
|
||||
open={!!projDeleteTarget}
|
||||
title={zh ? '删除项目' : 'Delete Project'}
|
||||
message={zh
|
||||
? `确定删除项目「${projDeleteTarget?.name}」?关联的 Bug 也会被删除。`
|
||||
: `Delete project "${projDeleteTarget?.name}"? Related bugs will also be deleted.`}
|
||||
confirmText={zh ? '确认删除' : 'Delete'}
|
||||
cancelText={zh ? '取消' : 'Cancel'}
|
||||
onConfirm={() => { if (projDeleteTarget) deleteProject(projDeleteTarget.id); setProjDeleteTarget(null) }}
|
||||
onCancel={() => setProjDeleteTarget(null)}
|
||||
/>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMemo, useCallback, useState, useRef, useEffect } from 'react'
|
||||
import { marked } from 'marked'
|
||||
import morphdom from 'morphdom'
|
||||
import { useStore, type Bug } from '../stores'
|
||||
import { generateInstruction } from '../utils/generateInstruction'
|
||||
import { ArrowLeft, Copy, Download, Check } from 'lucide-react'
|
||||
|
||||
// Configure marked: add section id to h2
|
||||
const renderer = new marked.Renderer()
|
||||
renderer.heading = ({ text, depth }: { text: string; depth: number }) => {
|
||||
let id = ''
|
||||
if (depth === 2) {
|
||||
if (text.includes('Screenshot') || text.includes('截图')) id = 'section-screenshots'
|
||||
else if (text.includes('Environment') || text.includes('环境')) id = 'section-environment'
|
||||
else if (text.includes('File') || text.includes('文件')) id = 'section-files'
|
||||
else if (text.includes('Priority') || text.includes('优先') || text.includes('Instruction') || text.includes('指令')) id = 'section-ai'
|
||||
}
|
||||
return `<h${depth}${id ? ` id="${id}"` : ''}>${text}</h${depth}>`
|
||||
}
|
||||
marked.use({ renderer, gfm: true, breaks: false, async: false })
|
||||
|
||||
export function PreviewArea({ bug }: { bug: Bug }) {
|
||||
const { t, locale, setViewMode, compareMode, compareLeft, compareRight, currentProject } = useStore()
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// Generate Markdown source
|
||||
const markdown = useMemo(() => {
|
||||
return generateInstruction(bug, locale, {
|
||||
enabled: compareMode,
|
||||
leftIndex: compareLeft,
|
||||
rightIndex: compareRight,
|
||||
}, currentProject)
|
||||
}, [bug, locale, compareMode, compareLeft, compareRight, currentProject])
|
||||
|
||||
// Convert to HTML
|
||||
const html = useMemo(() => marked.parse(markdown) as string, [markdown])
|
||||
|
||||
// morphdom: only patch changed DOM nodes, keep unchanged elements in place
|
||||
const mdRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
if (!mdRef.current) return
|
||||
// Create temp div for new content
|
||||
const tmp = document.createElement('div')
|
||||
tmp.innerHTML = html
|
||||
// morphdom diffs old/new DOM, updates only changes
|
||||
morphdom(mdRef.current, tmp, { childrenOnly: true })
|
||||
}, [html])
|
||||
|
||||
// Copy to clipboard
|
||||
const handleCopy = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(markdown)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}, [markdown])
|
||||
|
||||
// Export .md file
|
||||
const handleExport = useCallback(() => {
|
||||
const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `bug-${String(bug.number).padStart(3, '0')}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [markdown, bug.number])
|
||||
|
||||
// Active navigation item
|
||||
const [activeNav, setActiveNav] = useState(0)
|
||||
|
||||
return (
|
||||
<div className="flex h-full">
|
||||
{/* Left sidebar table of contents */}
|
||||
<div className="w-48 bg-bg-sidebar border-r border-border p-4 shrink-0">
|
||||
<button
|
||||
onClick={() => setViewMode('edit')}
|
||||
className="flex items-center gap-1.5 text-sm text-accent hover:text-accent-hover mb-6 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t.preview.backToEdit}
|
||||
</button>
|
||||
|
||||
<p className="text-[10px] text-text-muted uppercase tracking-wider mb-3">{t.preview.contents}</p>
|
||||
<nav className="space-y-2">
|
||||
{[
|
||||
bug.screenshots.length > 0 && { label: t.preview.screenshots, id: 'section-screenshots' },
|
||||
(bug.pagePath || bug.device || bug.browser) && { label: t.preview.environment, id: 'section-environment' },
|
||||
bug.relatedFiles.length > 0 && { label: t.preview.relatedFilesSection, id: 'section-files' },
|
||||
{ label: t.preview.aiInstructions, id: 'section-ai' },
|
||||
].filter((x): x is { label: string; id: string } => !!x).map(({ label, id }, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
setActiveNav(i)
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}}
|
||||
className={`block text-sm text-left w-full transition-colors ${
|
||||
activeNav === i ? 'text-accent' : 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Center Markdown preview */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Action bar */}
|
||||
<div className="sticky top-0 bg-bg-primary/90 backdrop-blur-sm border-b border-border px-6 py-3 flex items-center justify-between z-10">
|
||||
<h2 className="text-sm text-text-secondary">
|
||||
Bug #{String(bug.number).padStart(3, '0')}
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-text-secondary bg-bg-input border border-border rounded-lg hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-green-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? (locale === 'zh' ? '已复制' : 'Copied') : t.preview.copy}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm text-text-secondary bg-bg-input border border-border rounded-lg hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t.preview.export}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Markdown render */}
|
||||
<div className="max-w-5xl mx-auto p-8 prose prose-invert prose-sm max-w-none
|
||||
prose-headings:text-text-primary prose-p:text-text-secondary prose-li:text-text-secondary
|
||||
prose-a:text-accent prose-strong:text-text-primary prose-code:text-accent
|
||||
prose-img:rounded-lg prose-img:border prose-img:border-border
|
||||
prose-h1:text-2xl prose-h1:font-bold prose-h1:mb-4
|
||||
prose-h2:text-lg prose-h2:font-semibold prose-h2:text-accent prose-h2:mt-8 prose-h2:mb-3
|
||||
prose-h3:text-base prose-h3:font-medium prose-h3:mt-4 prose-h3:mb-2
|
||||
prose-ul:my-2 prose-ol:my-2
|
||||
">
|
||||
<div ref={mdRef} />
|
||||
</div>
|
||||
|
||||
{/* Raw Markdown source */}
|
||||
<div className="max-w-5xl mx-auto px-8 pb-4">
|
||||
<details className="group">
|
||||
<summary className="text-xs text-text-muted cursor-pointer hover:text-text-secondary mb-2">
|
||||
{locale === 'zh' ? '查看原始 Markdown' : 'View Raw Markdown'}
|
||||
</summary>
|
||||
<pre className="text-xs text-text-muted bg-bg-card border border-border rounded-lg p-4 overflow-x-auto whitespace-pre-wrap">
|
||||
{markdown}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useStore, type Bug, type BugStatus, type Priority } from '../stores'
|
||||
import { generateInstruction } from '../utils/generateInstruction'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Zap,
|
||||
Copy,
|
||||
FileText,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Collapsible Section
|
||||
function Section({
|
||||
title,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
defaultOpen?: boolean
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-text-primary">{title}</span>
|
||||
{open ? (
|
||||
<ChevronUp className="w-4 h-4 text-text-muted" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-text-muted" />
|
||||
)}
|
||||
</button>
|
||||
{open && <div className="px-4 pb-4 space-y-3">{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const statusOptions: BugStatus[] = ['pending', 'fixed', 'closed']
|
||||
const priorityOptions: { key: Priority; color: string }[] = [
|
||||
{ key: 'high', color: 'bg-red-500/20 text-red-400 border-red-500/30' },
|
||||
{ key: 'medium', color: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30' },
|
||||
{ key: 'low', color: 'bg-green-500/20 text-green-400 border-green-500/30' },
|
||||
]
|
||||
|
||||
export function PropertyPanel({ bug, width }: { bug: Bug; width?: number }) {
|
||||
const { t, locale, setViewMode, updateBug, compareMode, compareLeft, compareRight, currentProject, bugs, selectBug, clearSelection } = useStore()
|
||||
// Debounced update
|
||||
const handleFieldBlur = useCallback((field: string, value: string) => {
|
||||
updateBug(bug.id, { [field]: value })
|
||||
}, [bug.id, updateBug])
|
||||
|
||||
return (
|
||||
<aside style={{ width: width ?? 320 }} className="bg-bg-sidebar border-l border-border flex flex-col shrink-0 overflow-y-auto">
|
||||
{/* Bug info */}
|
||||
<Section title={t.panel.bugInfo}>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">{t.panel.title}</label>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={bug.title}
|
||||
key={`title-${bug.id}`}
|
||||
placeholder={t.panel.titlePlaceholder}
|
||||
onBlur={(e) => handleFieldBlur('title', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">{t.panel.description}</label>
|
||||
<textarea
|
||||
defaultValue={bug.description}
|
||||
key={`desc-${bug.id}`}
|
||||
placeholder={t.panel.descriptionPlaceholder}
|
||||
rows={4}
|
||||
onBlur={(e) => handleFieldBlur('description', e.target.value)}
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border rounded-lg text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent resize-y min-h-[96px]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1.5">{t.panel.priority}</label>
|
||||
<div className="flex gap-2">
|
||||
{priorityOptions.map(({ key, color }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => updateBug(bug.id, { priority: key })}
|
||||
className={`flex-1 py-1.5 text-xs rounded-lg border transition-colors ${
|
||||
bug.priority === key ? color : 'border-border text-text-muted hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{t.priority[key]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted block mb-1">{t.panel.statusLabel}</label>
|
||||
<select
|
||||
defaultValue={bug.status}
|
||||
key={`status-${bug.id}`}
|
||||
onChange={(e) => {
|
||||
const newStatus = e.target.value
|
||||
updateBug(bug.id, { status: newStatus })
|
||||
// 状态变化时,自动跳转到下一个待处理的 bug
|
||||
const nextPending = bugs.find(b => b.id !== bug.id && (b.status === 'pending' || b.status === 'annotating'))
|
||||
if (nextPending) {
|
||||
selectBug(nextPending.id)
|
||||
} else {
|
||||
// 没有待处理的 bug,清空选中状态
|
||||
clearSelection()
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 bg-bg-input border border-border rounded-lg text-sm text-text-primary focus:outline-none focus:border-accent"
|
||||
>
|
||||
{statusOptions.map((s) => (
|
||||
<option key={s} value={s}>{t.status[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Bottom action buttons */}
|
||||
<div className="mt-auto p-4 space-y-2">
|
||||
<button
|
||||
onClick={() => setViewMode('preview')}
|
||||
className="w-full py-3 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
{t.panel.generateBtn}
|
||||
<span className="text-xs opacity-60 ml-1">{t.panel.generateShortcut}</span>
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const md = generateInstruction(bug, locale, { enabled: compareMode, leftIndex: compareLeft, rightIndex: compareRight }, currentProject)
|
||||
navigator.clipboard.writeText(md)
|
||||
}}
|
||||
className="flex-1 py-2 bg-bg-input border border-border text-text-secondary text-sm rounded-lg hover:bg-bg-hover transition-colors flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
{t.panel.copyBtn}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const md = generateInstruction(bug, locale, { enabled: compareMode, leftIndex: compareLeft, rightIndex: compareRight }, currentProject)
|
||||
const blob = new Blob([md], { type: 'text/markdown' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `bug-${String(bug.number).padStart(3, '0')}.md`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}}
|
||||
className="flex-1 py-2 bg-bg-input border border-border text-text-secondary text-sm rounded-lg hover:bg-bg-hover transition-colors flex items-center justify-center gap-1.5"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5" />
|
||||
{t.panel.exportBtn}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useStore } from '../stores'
|
||||
import { api } from '../api'
|
||||
import {
|
||||
X,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
Palette,
|
||||
|
||||
Globe,
|
||||
Search,
|
||||
Keyboard,
|
||||
Info,
|
||||
} from 'lucide-react'
|
||||
|
||||
type SettingsTab = 'project' | 'appearance' | 'integrations' | 'shortcuts' | 'about'
|
||||
|
||||
export function SettingsModal() {
|
||||
const { t, locale, setLocale, theme, setTheme, setSettingsOpen, settings, saveSettings, fetchSettings, currentProjectId, currentProject, projects } = useStore()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('project')
|
||||
|
||||
// Restore connection status from settings
|
||||
const [zentaoStatus, setZentaoStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>(settings.zentaoConnected === 'true' ? 'ok' : 'idle')
|
||||
const [zentaoError, setZentaoError] = useState('')
|
||||
const [jiraStatus, setJiraStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>(settings.jiraConnected === 'true' ? 'ok' : 'idle')
|
||||
const [jiraError, setJiraError] = useState('')
|
||||
const [linearStatus, setLinearStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>(settings.linearConnected === 'true' ? 'ok' : 'idle')
|
||||
const [linearError, setLinearError] = useState('')
|
||||
const [tapdStatus, setTapdStatus] = useState<'idle' | 'testing' | 'ok' | 'fail'>(settings.tapdConnected === 'true' ? 'ok' : 'idle')
|
||||
const [tapdError, setTapdError] = useState('')
|
||||
|
||||
// Search
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
// Local form state
|
||||
const [form, setForm] = useState({
|
||||
projectName: currentProject || '',
|
||||
rootDir: '',
|
||||
zentaoUrl: '',
|
||||
zentaoHttpUser: '',
|
||||
zentaoHttpPass: '',
|
||||
zentaoAccount: '',
|
||||
zentaoPassword: '',
|
||||
zentaoProductId: '',
|
||||
jiraUrl: '',
|
||||
jiraEmail: '',
|
||||
jiraToken: '',
|
||||
jiraProjectKey: '',
|
||||
linearToken: '',
|
||||
linearTeamId: '',
|
||||
tapdApiUser: '',
|
||||
tapdApiPassword: '',
|
||||
tapdWorkspaceId: '',
|
||||
})
|
||||
|
||||
useEffect(() => { fetchSettings() }, [fetchSettings])
|
||||
|
||||
useEffect(() => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
projectName: currentProject || '',
|
||||
rootDir: settings[`rootDir_${currentProjectId}`] || '',
|
||||
zentaoUrl: settings.zentaoUrl || '',
|
||||
zentaoHttpUser: settings.zentaoHttpUser || '',
|
||||
zentaoHttpPass: settings.zentaoHttpPass || '',
|
||||
zentaoAccount: settings.zentaoAccount || '',
|
||||
zentaoPassword: settings.zentaoPassword || '',
|
||||
zentaoProductId: settings.zentaoProductId || '',
|
||||
jiraUrl: settings.jiraUrl || '',
|
||||
jiraEmail: settings.jiraEmail || '',
|
||||
jiraToken: settings.jiraToken || '',
|
||||
jiraProjectKey: settings.jiraProjectKey || '',
|
||||
linearToken: settings.linearToken || '',
|
||||
linearTeamId: settings.linearTeamId || '',
|
||||
tapdApiUser: settings.tapdApiUser || '',
|
||||
tapdApiPassword: settings.tapdApiPassword || '',
|
||||
tapdWorkspaceId: settings.tapdWorkspaceId || '',
|
||||
}))
|
||||
}, [settings, currentProject, currentProjectId])
|
||||
|
||||
const updateField = (key: string, value: string) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
|
||||
const pickDirectory = async (field: string) => {
|
||||
try {
|
||||
const { path } = await api.pickDirectory()
|
||||
if (path) updateField(field, path)
|
||||
} catch { /* user cancelled */ }
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// Rename project
|
||||
if (form.projectName && form.projectName !== currentProject) {
|
||||
await api.renameProject(currentProjectId, form.projectName)
|
||||
// Sync local projects list and currentProject
|
||||
const updatedProjects = projects.map(p =>
|
||||
p.id === currentProjectId ? { ...p, name: form.projectName } : p
|
||||
)
|
||||
useStore.setState({ projects: updatedProjects, currentProject: form.projectName })
|
||||
}
|
||||
// rootDir stored per project
|
||||
const { projectName, rootDir, ...rest } = form
|
||||
await saveSettings({
|
||||
...rest,
|
||||
[`rootDir_${currentProjectId}`]: rootDir,
|
||||
})
|
||||
setSettingsOpen(false)
|
||||
}
|
||||
|
||||
const inputCls = "w-full px-3 py-2 bg-bg-input border border-border rounded text-sm text-text-primary focus:outline-none focus:border-accent"
|
||||
const selectCls = inputCls
|
||||
const labelCls = "text-sm font-medium text-text-secondary"
|
||||
|
||||
// Navigation items
|
||||
const navItems: { key: SettingsTab; label: string; icon: typeof FolderOpen }[] = [
|
||||
{ key: 'project', label: t.settings.projectConfig, icon: FolderOpen },
|
||||
|
||||
|
||||
{ key: 'appearance', label: t.settings.appearance, icon: Palette },
|
||||
{ key: 'integrations', label: t.settings.integrations, icon: Globe },
|
||||
{ key: 'shortcuts', label: t.settings.shortcuts, icon: Keyboard },
|
||||
{ key: 'about', label: t.settings.about, icon: Info },
|
||||
]
|
||||
|
||||
const filteredNav = searchQuery
|
||||
? navItems.filter(n => n.label.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: navItems
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-[900px] h-[640px] bg-bg-card border border-border rounded-xl shadow-2xl flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<Settings className="w-5 h-5 text-accent" />
|
||||
<h2 className="text-lg font-bold text-text-primary">{t.settings.title}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
className="text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body: two columns */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left navigation */}
|
||||
<div className="w-56 border-r border-border flex flex-col bg-bg-input/50 shrink-0">
|
||||
{/* Search */}
|
||||
<div className="p-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-secondary" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={locale === 'zh' ? '搜索设置...' : 'Search settings...'}
|
||||
className="w-full bg-bg-input border border-border rounded px-8 py-1.5 text-xs text-text-primary focus:border-accent outline-none transition-colors placeholder:text-text-secondary/50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Navigation list */}
|
||||
<nav className="flex-1 px-2 space-y-0.5 overflow-y-auto">
|
||||
{filteredNav.map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setActiveTab(key)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm whitespace-nowrap transition-colors ${
|
||||
activeTab === key
|
||||
? 'bg-accent/15 text-accent font-medium'
|
||||
: 'text-text-secondary hover:bg-bg-hover hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px]" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Right content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-xl">
|
||||
|
||||
{/* Project config */}
|
||||
{activeTab === 'project' && (
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-text-primary mb-6">{t.settings.projectConfig}</h3>
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls}>{t.settings.projectName}</label>
|
||||
<input type="text" value={form.projectName} onChange={(e) => updateField('projectName', e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls}>{t.settings.rootDir}</label>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={form.rootDir} onChange={(e) => updateField('rootDir', e.target.value)} placeholder="D:\projects\my-app" className={`flex-1 px-3 py-2 font-mono bg-bg-input border border-border rounded text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:border-accent`} />
|
||||
<button onClick={() => pickDirectory('rootDir')} className="px-3 bg-bg-hover hover:bg-accent/20 rounded border border-border transition-colors flex items-center">
|
||||
<FolderOpen className="w-4 h-4 text-text-secondary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Appearance */}
|
||||
{activeTab === 'appearance' && (
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-text-primary mb-6">{t.settings.appearance}</h3>
|
||||
<div className="space-y-5">
|
||||
{/* Theme toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={labelCls}>{locale === 'zh' ? '编辑器主题' : 'Editor Theme'}</span>
|
||||
<div className="flex gap-1 p-1 bg-bg-input border border-border rounded-md">
|
||||
<button
|
||||
onClick={() => setTheme('light')}
|
||||
className={`px-4 py-1.5 rounded text-xs font-medium transition-colors ${theme === 'light' ? 'bg-accent text-white shadow-sm' : 'text-text-secondary hover:text-text-primary'}`}
|
||||
>
|
||||
{locale === 'zh' ? '亮色' : 'Light'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme('dark')}
|
||||
className={`px-4 py-1.5 rounded text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-accent text-white shadow-sm' : 'text-text-secondary hover:text-text-primary'}`}
|
||||
>
|
||||
{locale === 'zh' ? '暗色' : 'Dark'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Language */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls}>{locale === 'zh' ? '语言' : 'Language'}</label>
|
||||
<select value={locale} onChange={(e) => setLocale(e.target.value as 'zh' | 'en')} className={selectCls}>
|
||||
<option value="zh">中文</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shortcuts */}
|
||||
{activeTab === 'shortcuts' && (
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-text-primary mb-6">{t.settings.shortcuts}</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ key: 'Ctrl+V', desc: locale === 'zh' ? '粘贴截图' : 'Paste Screenshot' },
|
||||
{ key: 'Ctrl+Enter', desc: locale === 'zh' ? '生成 AI 指令' : 'Generate AI Instructions' },
|
||||
{ key: 'Ctrl+Z', desc: locale === 'zh' ? '撤销标注' : 'Undo Annotation' },
|
||||
{ key: 'Ctrl+Shift+Z', desc: locale === 'zh' ? '重做标注' : 'Redo Annotation' },
|
||||
{ key: 'V', desc: locale === 'zh' ? '选择工具' : 'Select Tool' },
|
||||
{ key: 'R', desc: locale === 'zh' ? '矩形框' : 'Rectangle' },
|
||||
{ key: 'A', desc: locale === 'zh' ? '箭头' : 'Arrow' },
|
||||
{ key: 'T', desc: locale === 'zh' ? '文字' : 'Text' },
|
||||
{ key: 'N', desc: locale === 'zh' ? '序号' : 'Number' },
|
||||
{ key: 'H', desc: locale === 'zh' ? '高亮' : 'Highlight' },
|
||||
{ key: 'P', desc: locale === 'zh' ? '画笔' : 'Pen' },
|
||||
{ key: 'M', desc: locale === 'zh' ? '马赛克' : 'Mosaic' },
|
||||
{ key: 'Delete', desc: locale === 'zh' ? '删除选中' : 'Delete Selected' },
|
||||
].map(({ key, desc }) => (
|
||||
<div key={key} className="flex items-center justify-between py-1.5">
|
||||
<span className="text-sm text-text-secondary">{desc}</span>
|
||||
<kbd className="px-2 py-1 bg-bg-input border border-border rounded text-xs text-text-muted font-mono">{key}</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* About */}
|
||||
{activeTab === 'about' && (
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-text-primary mb-6">{t.settings.about}</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-text-secondary">{locale === 'zh' ? '版本' : 'Version'}</span>
|
||||
<span className="text-sm text-text-primary font-mono">{t.app.version}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-2">
|
||||
<span className="text-sm text-text-secondary">{locale === 'zh' ? '应用名称' : 'App Name'}</span>
|
||||
<span className="text-sm text-text-primary">{t.app.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External platform integrations */}
|
||||
{activeTab === 'integrations' && (
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-text-primary mb-6">{t.settings.integrations}</h3>
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Zentao */}
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<span className="relative inline-block text-sm font-medium text-text-primary">
|
||||
{locale === 'zh' ? '禅道' : 'Zentao'}
|
||||
<span className={`absolute -right-2.5 bottom-0 w-1.5 h-1.5 rounded-full ${
|
||||
zentaoStatus === 'ok' || settings.zentaoConnected === 'true' ? 'bg-green-500' :
|
||||
zentaoStatus === 'testing' ? 'bg-yellow-500 animate-pulse' :
|
||||
zentaoStatus === 'fail' ? 'bg-red-500' : 'bg-text-secondary/30'
|
||||
}`} />
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-text-secondary">{locale === 'zh' ? '禅道地址' : 'Zentao URL'}</label>
|
||||
<input type="text" value={form.zentaoUrl} onChange={(e) => updateField('zentaoUrl', e.target.value)} placeholder="http://zentao.company.com" className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-secondary block mb-1.5">{locale === 'zh' ? '公司网关认证(HTTP Basic Auth)' : 'Gateway Auth (HTTP Basic Auth)'}</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input type="text" value={form.zentaoHttpUser} onChange={(e) => updateField('zentaoHttpUser', e.target.value)} placeholder={locale === 'zh' ? '网关账号' : 'Gateway user'} className={inputCls} />
|
||||
<input type="password" value={form.zentaoHttpPass} onChange={(e) => updateField('zentaoHttpPass', e.target.value)} placeholder={locale === 'zh' ? '网关密码' : 'Gateway password'} className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-secondary block mb-1.5">{locale === 'zh' ? '禅道系统账号' : 'Zentao Account'}</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input type="text" value={form.zentaoAccount} onChange={(e) => updateField('zentaoAccount', e.target.value)} placeholder={locale === 'zh' ? '禅道用户名' : 'Username'} className={inputCls} />
|
||||
<input type="password" value={form.zentaoPassword} onChange={(e) => updateField('zentaoPassword', e.target.value)} placeholder={locale === 'zh' ? '禅道密码' : 'Password'} className={inputCls} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-text-secondary">{locale === 'zh' ? '产品 ID' : 'Product ID'}</label>
|
||||
<input type="text" value={form.zentaoProductId} onChange={(e) => updateField('zentaoProductId', e.target.value)} placeholder={locale === 'zh' ? '禅道产品编号' : 'Zentao product number'} className={inputCls} />
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!form.zentaoUrl || !form.zentaoAccount || !form.zentaoPassword) return
|
||||
setZentaoStatus('testing'); setZentaoError('')
|
||||
try {
|
||||
const res = await api.zentao.test({ url: form.zentaoUrl, httpUser: form.zentaoHttpUser, httpPass: form.zentaoHttpPass, account: form.zentaoAccount, password: form.zentaoPassword })
|
||||
setZentaoStatus(res.ok ? 'ok' : 'fail')
|
||||
if (res.ok) saveSettings({ zentaoConnected: 'true' })
|
||||
else { saveSettings({ zentaoConnected: '' }); if (res.error) setZentaoError(res.error) }
|
||||
} catch (e: any) { setZentaoStatus('fail'); setZentaoError(e.message || 'Connection failed') }
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-sm rounded transition-colors ${zentaoStatus === 'ok' ? 'bg-green-500/20 text-green-400' : zentaoStatus === 'fail' ? 'bg-red-500/20 text-red-400' : 'bg-accent/20 text-accent hover:bg-accent/30'}`}
|
||||
>
|
||||
{zentaoStatus === 'testing' ? (locale === 'zh' ? '测试中...' : 'Testing...') :
|
||||
zentaoStatus === 'ok' ? (locale === 'zh' ? '连接成功' : 'Connected') :
|
||||
zentaoStatus === 'fail' ? (locale === 'zh' ? '连接失败,重试' : 'Failed, retry') :
|
||||
(locale === 'zh' ? '测试连接' : 'Test Connection')}
|
||||
</button>
|
||||
{zentaoError && <p className="text-xs text-red-400 break-all">{zentaoError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Jira */}
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<span className="relative inline-block text-sm font-medium text-text-primary">
|
||||
Jira
|
||||
<span className={`absolute -right-2.5 bottom-0 w-1.5 h-1.5 rounded-full ${
|
||||
jiraStatus === 'ok' || settings.jiraConnected === 'true' ? 'bg-green-500' :
|
||||
jiraStatus === 'testing' ? 'bg-yellow-500 animate-pulse' :
|
||||
jiraStatus === 'fail' ? 'bg-red-500' : 'bg-text-secondary/30'
|
||||
}`} />
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-text-secondary">{locale === 'zh' ? 'Jira 地址' : 'Jira URL'}</label>
|
||||
<input type="text" value={form.jiraUrl} onChange={(e) => updateField('jiraUrl', e.target.value)} placeholder="https://your-team.atlassian.net" className={inputCls} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-secondary block mb-1.5">{locale === 'zh' ? 'Jira 账号' : 'Jira Account'}</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input type="text" value={form.jiraEmail} onChange={(e) => updateField('jiraEmail', e.target.value)} placeholder={locale === 'zh' ? '邮箱地址' : 'Email'} className={inputCls} />
|
||||
<input type="password" value={form.jiraToken} onChange={(e) => updateField('jiraToken', e.target.value)} placeholder="API Token" className={inputCls} />
|
||||
</div>
|
||||
<p className="text-[10px] text-text-secondary mt-1">
|
||||
{locale === 'zh' ? '前往 id.atlassian.com → 安全 → 创建 API 令牌' : 'Go to id.atlassian.com → Security → Create API token'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-text-secondary">{locale === 'zh' ? '项目 Key' : 'Project Key'}</label>
|
||||
<input type="text" value={form.jiraProjectKey} onChange={(e) => updateField('jiraProjectKey', e.target.value)} placeholder={locale === 'zh' ? '例: PROJ' : 'e.g. PROJ'} className={inputCls} />
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!form.jiraUrl || !form.jiraEmail || !form.jiraToken) return
|
||||
setJiraStatus('testing'); setJiraError('')
|
||||
try {
|
||||
const res = await api.jira.test({ url: form.jiraUrl, email: form.jiraEmail, token: form.jiraToken })
|
||||
setJiraStatus(res.ok ? 'ok' : 'fail')
|
||||
if (res.ok) saveSettings({ jiraConnected: 'true' })
|
||||
else { saveSettings({ jiraConnected: '' }); if (res.error) setJiraError(res.error) }
|
||||
} catch (e: any) { setJiraStatus('fail'); setJiraError(e.message || 'Connection failed') }
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-sm rounded transition-colors ${jiraStatus === 'ok' ? 'bg-green-500/20 text-green-400' : jiraStatus === 'fail' ? 'bg-red-500/20 text-red-400' : 'bg-blue-400/20 text-blue-400 hover:bg-blue-400/30'}`}
|
||||
>
|
||||
{jiraStatus === 'testing' ? (locale === 'zh' ? '测试中...' : 'Testing...') :
|
||||
jiraStatus === 'ok' ? (locale === 'zh' ? '连接成功' : 'Connected') :
|
||||
jiraStatus === 'fail' ? (locale === 'zh' ? '连接失败,重试' : 'Failed, retry') :
|
||||
(locale === 'zh' ? '测试连接' : 'Test Connection')}
|
||||
</button>
|
||||
{jiraError && <p className="text-xs text-red-400 break-all">{jiraError}</p>}
|
||||
</div>
|
||||
|
||||
{/* Linear */}
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<span className="relative inline-block text-sm font-medium text-text-primary">
|
||||
Linear
|
||||
<span className={`absolute -right-2.5 bottom-0 w-1.5 h-1.5 rounded-full ${
|
||||
linearStatus === 'ok' || settings.linearConnected === 'true' ? 'bg-green-500' :
|
||||
linearStatus === 'testing' ? 'bg-yellow-500 animate-pulse' :
|
||||
linearStatus === 'fail' ? 'bg-red-500' : 'bg-text-secondary/30'
|
||||
}`} />
|
||||
</span>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-text-secondary">API Key</label>
|
||||
<input type="password" value={form.linearToken} onChange={(e) => updateField('linearToken', e.target.value)} placeholder="lin_api_..." className={inputCls} />
|
||||
<p className="text-[10px] text-text-secondary">
|
||||
{locale === 'zh' ? '前往 Linear Settings → API → Personal API keys 生成' : 'Go to Linear Settings → API → Personal API keys'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!form.linearToken) return
|
||||
setLinearStatus('testing'); setLinearError('')
|
||||
try {
|
||||
const res = await api.linear.test({ token: form.linearToken })
|
||||
setLinearStatus(res.ok ? 'ok' : 'fail')
|
||||
if (res.ok) saveSettings({ linearConnected: 'true' })
|
||||
else { saveSettings({ linearConnected: '' }); if (res.error) setLinearError(res.error) }
|
||||
} catch (e: any) { setLinearStatus('fail'); setLinearError(e.message || 'Connection failed') }
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-sm rounded transition-colors ${linearStatus === 'ok' ? 'bg-green-500/20 text-green-400' : linearStatus === 'fail' ? 'bg-red-500/20 text-red-400' : 'bg-violet-400/20 text-violet-400 hover:bg-violet-400/30'}`}
|
||||
>
|
||||
{linearStatus === 'testing' ? (locale === 'zh' ? '测试中...' : 'Testing...') :
|
||||
linearStatus === 'ok' ? (locale === 'zh' ? '连接成功' : 'Connected') :
|
||||
linearStatus === 'fail' ? (locale === 'zh' ? '连接失败,重试' : 'Failed, retry') :
|
||||
(locale === 'zh' ? '测试连接' : 'Test Connection')}
|
||||
</button>
|
||||
{linearError && <p className="text-xs text-red-400 break-all">{linearError}</p>}
|
||||
</div>
|
||||
|
||||
{/* TAPD */}
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<span className="relative inline-block text-sm font-medium text-text-primary">
|
||||
TAPD
|
||||
<span className={`absolute -right-2.5 bottom-0 w-1.5 h-1.5 rounded-full ${
|
||||
tapdStatus === 'ok' || settings.tapdConnected === 'true' ? 'bg-green-500' :
|
||||
tapdStatus === 'testing' ? 'bg-yellow-500 animate-pulse' :
|
||||
tapdStatus === 'fail' ? 'bg-red-500' : 'bg-text-secondary/30'
|
||||
}`} />
|
||||
</span>
|
||||
<div>
|
||||
<label className="text-xs text-text-secondary block mb-1.5">{locale === 'zh' ? 'API 账号' : 'API Account'}</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input type="text" value={form.tapdApiUser} onChange={(e) => updateField('tapdApiUser', e.target.value)} placeholder={locale === 'zh' ? 'API 账号' : 'API User'} className={inputCls} />
|
||||
<input type="password" value={form.tapdApiPassword} onChange={(e) => updateField('tapdApiPassword', e.target.value)} placeholder={locale === 'zh' ? 'API 密码' : 'API Password'} className={inputCls} />
|
||||
</div>
|
||||
<p className="text-[10px] text-text-secondary mt-1">
|
||||
{locale === 'zh' ? '在 TAPD 项目设置 → 应用设置 → API 中获取' : 'Get from TAPD Project Settings → App Settings → API'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs text-text-secondary">{locale === 'zh' ? '项目 ID(workspace_id)' : 'Project ID (workspace_id)'}</label>
|
||||
<input type="text" value={form.tapdWorkspaceId} onChange={(e) => updateField('tapdWorkspaceId', e.target.value)} placeholder={locale === 'zh' ? '从 TAPD 项目 URL 中获取' : 'From TAPD project URL'} className={inputCls} />
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!form.tapdApiUser || !form.tapdApiPassword) return
|
||||
setTapdStatus('testing'); setTapdError('')
|
||||
try {
|
||||
const res = await api.tapd.test({ apiUser: form.tapdApiUser, apiPassword: form.tapdApiPassword, workspaceId: form.tapdWorkspaceId })
|
||||
setTapdStatus(res.ok ? 'ok' : 'fail')
|
||||
if (res.ok) saveSettings({ tapdConnected: 'true' })
|
||||
else { saveSettings({ tapdConnected: '' }); if (res.error) setTapdError(res.error) }
|
||||
} catch (e: any) { setTapdStatus('fail'); setTapdError(e.message || 'Connection failed') }
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-sm rounded transition-colors ${tapdStatus === 'ok' ? 'bg-green-500/20 text-green-400' : tapdStatus === 'fail' ? 'bg-red-500/20 text-red-400' : 'bg-cyan-400/20 text-cyan-400 hover:bg-cyan-400/30'}`}
|
||||
>
|
||||
{tapdStatus === 'testing' ? (locale === 'zh' ? '测试中...' : 'Testing...') :
|
||||
tapdStatus === 'ok' ? (locale === 'zh' ? '连接成功' : 'Connected') :
|
||||
tapdStatus === 'fail' ? (locale === 'zh' ? '连接失败,重试' : 'Failed, retry') :
|
||||
(locale === 'zh' ? '测试连接' : 'Test Connection')}
|
||||
</button>
|
||||
{tapdError && <p className="text-xs text-red-400 break-all">{tapdError}</p>}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 px-6 py-4 border-t border-border shrink-0">
|
||||
<button
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
className="px-5 py-2 rounded-md text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
{t.settings.cancel}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-6 py-2 rounded-md bg-accent hover:bg-accent-hover text-white text-sm font-bold shadow-lg shadow-accent/20 transition-colors"
|
||||
>
|
||||
{t.settings.save}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useStore } from '../stores'
|
||||
import { X, Keyboard } from 'lucide-react'
|
||||
|
||||
export function ShortcutsModal() {
|
||||
const { t, locale, setShortcutsOpen } = useStore()
|
||||
|
||||
// Dynamic i18n reference, synced with toolbar
|
||||
const data = [
|
||||
{ category: locale === 'zh' ? '通用' : 'General', items: [
|
||||
{ keys: 'Ctrl+V', desc: locale === 'zh' ? '粘贴截图' : 'Paste screenshot' },
|
||||
{ keys: 'Ctrl+N', desc: locale === 'zh' ? '新建 Bug' : 'New Bug' },
|
||||
{ keys: 'Ctrl+Enter', desc: locale === 'zh' ? '切换编辑/预览模式' : 'Toggle edit/preview' },
|
||||
{ keys: 'Delete', desc: locale === 'zh' ? '删除选中标注' : 'Delete annotation' },
|
||||
{ keys: 'Ctrl+Z', desc: t.editor.tools.undo },
|
||||
{ keys: 'Ctrl+Shift+Z', desc: t.editor.tools.redo },
|
||||
]},
|
||||
{ category: locale === 'zh' ? '标注工具' : 'Annotation Tools', items: [
|
||||
{ keys: 'D', desc: t.editor.tools.drag },
|
||||
{ keys: 'V', desc: t.editor.tools.select },
|
||||
{ keys: 'R', desc: t.editor.tools.rect },
|
||||
{ keys: 'A', desc: t.editor.tools.arrow },
|
||||
{ keys: 'T', desc: t.editor.tools.text },
|
||||
{ keys: 'N', desc: t.editor.tools.number },
|
||||
{ keys: 'H', desc: t.editor.tools.highlight },
|
||||
{ keys: 'P', desc: t.editor.tools.pen },
|
||||
{ keys: 'M', desc: t.editor.tools.mosaic },
|
||||
]},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShortcutsOpen(false)}>
|
||||
<div className="w-[400px] bg-bg-card border border-border rounded-2xl shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Keyboard className="w-5 h-5 text-text-muted" />
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{locale === 'zh' ? '快捷键' : 'Keyboard Shortcuts'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShortcutsOpen(false)}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-5 max-h-[60vh] overflow-y-auto">
|
||||
{data.map((group) => (
|
||||
<div key={group.category}>
|
||||
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-3">{group.category}</h3>
|
||||
<div className="space-y-2">
|
||||
{group.items.map((item) => (
|
||||
<div key={item.keys} className="flex items-center justify-between">
|
||||
<span className="text-sm text-text-secondary">{item.desc}</span>
|
||||
<div className="flex gap-1">
|
||||
{item.keys.split('+').map((k) => (
|
||||
<kbd key={k} className="px-2 py-0.5 bg-bg-input border border-border rounded text-xs text-text-primary font-mono">
|
||||
{k}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useStore, type BugStatus, type FilterTab } from '../stores'
|
||||
import { useVirtualList } from '../hooks/useVirtualList'
|
||||
import { ConfirmDialog } from './ConfirmDialog'
|
||||
import { ZentaoModal } from './ZentaoModal'
|
||||
import { JiraModal } from './JiraModal'
|
||||
import { LinearModal } from './LinearModal'
|
||||
import { TapdModal } from './TapdModal'
|
||||
import { Search, Camera, Trash2, ExternalLink, ChevronDown, CheckSquare, Square, ListChecks, Plus } from 'lucide-react'
|
||||
|
||||
// Status color mapping
|
||||
const statusColorMap: Record<BugStatus, string> = {
|
||||
pending: 'bg-red-500/20 text-red-400',
|
||||
annotating: 'bg-yellow-500/20 text-yellow-400',
|
||||
generated: 'bg-blue-500/20 text-blue-400',
|
||||
fixed: 'bg-green-500/20 text-green-400',
|
||||
closed: 'bg-gray-500/20 text-gray-400',
|
||||
}
|
||||
|
||||
// Time formatting
|
||||
function timeAgo(dateStr: string, suffix: string): string {
|
||||
const normalized = dateStr.includes('T') ? dateStr : dateStr.replace(' ', 'T') + 'Z'
|
||||
const diff = Date.now() - new Date(normalized).getTime()
|
||||
const minutes = Math.max(0, Math.floor(diff / 60000))
|
||||
const isZh = suffix === '前'
|
||||
if (minutes < 1) return isZh ? '刚刚' : 'just now'
|
||||
if (minutes < 60) return isZh ? `${minutes}分钟${suffix}` : `${minutes}m ${suffix}`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return isZh ? `${hours}小时${suffix}` : `${hours}h ${suffix}`
|
||||
const days = Math.floor(hours / 24)
|
||||
return isZh ? `${days}天${suffix}` : `${days}d ${suffix}`
|
||||
}
|
||||
|
||||
export function Sidebar({ width }: { width?: number }) {
|
||||
const {
|
||||
t,
|
||||
locale,
|
||||
bugs,
|
||||
selectedBugId,
|
||||
filterTab,
|
||||
searchQuery,
|
||||
selectBug,
|
||||
setFilterTab,
|
||||
setSearchQuery,
|
||||
createBug,
|
||||
deleteBug,
|
||||
updateBug,
|
||||
batchUpdateStatus,
|
||||
batchDeleteBugs,
|
||||
clearSelection,
|
||||
} = useStore()
|
||||
const zh = locale === 'zh'
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ id: string; number: number } | null>(null)
|
||||
const [zentaoOpen, setZentaoOpen] = useState(false)
|
||||
const [jiraOpen, setJiraOpen] = useState(false)
|
||||
const [linearOpen, setLinearOpen] = useState(false)
|
||||
const [tapdOpen, setTapdOpen] = useState(false)
|
||||
const [importDropdown, setImportDropdown] = useState(false)
|
||||
const importDropdownRef = useRef<HTMLDivElement>(null)
|
||||
// Single bug quick status toggle
|
||||
const [statusDropdown, setStatusDropdown] = useState<string | null>(null)
|
||||
const [statusDropdownPos, setStatusDropdownPos] = useState({ x: 0, y: 0 })
|
||||
const statusDropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close import dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!importDropdown) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (importDropdownRef.current && !importDropdownRef.current.contains(e.target as Node)) {
|
||||
setImportDropdown(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [importDropdown])
|
||||
|
||||
// Close status dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!statusDropdown) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (statusDropdownRef.current && !statusDropdownRef.current.contains(e.target as Node)) {
|
||||
setStatusDropdown(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [statusDropdown])
|
||||
|
||||
const statusOptions: BugStatus[] = ['pending', 'fixed', 'closed']
|
||||
|
||||
// Batch mode
|
||||
const [batchMode, setBatchMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const [batchConfirm, setBatchConfirm] = useState(false)
|
||||
|
||||
const BUG_ITEM_HEIGHT = 76
|
||||
|
||||
// Stats
|
||||
const pendingCount = bugs.filter((b) => b.status === 'pending' || b.status === 'annotating').length
|
||||
const fixedCount = bugs.filter((b) => b.status === 'fixed').length
|
||||
|
||||
// Filter
|
||||
const filteredBugs = bugs.filter((bug) => {
|
||||
if (filterTab === 'pending') return bug.status === 'pending' || bug.status === 'annotating'
|
||||
if (filterTab === 'fixed') return bug.status === 'fixed'
|
||||
return true
|
||||
}).filter((bug) => {
|
||||
if (!searchQuery) return true
|
||||
const q = searchQuery.toLowerCase()
|
||||
return bug.title.toLowerCase().includes(q) || String(bug.number).includes(q)
|
||||
})
|
||||
|
||||
const {
|
||||
containerRef: virtualContainerRef,
|
||||
onScroll: onVirtualScroll,
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalHeight,
|
||||
offsetY,
|
||||
scrollToIndex,
|
||||
} = useVirtualList({
|
||||
itemCount: filteredBugs.length,
|
||||
itemHeight: BUG_ITEM_HEIGHT,
|
||||
})
|
||||
|
||||
// Auto-scroll to selected bug when it changes
|
||||
useEffect(() => {
|
||||
if (!selectedBugId || batchMode) return
|
||||
const idx = filteredBugs.findIndex(b => b.id === selectedBugId)
|
||||
if (idx >= 0) scrollToIndex(idx)
|
||||
}, [selectedBugId])
|
||||
|
||||
const tabs: { key: FilterTab; label: string; count: number }[] = [
|
||||
{ key: 'all', label: t.sidebar.filterAll, count: bugs.length },
|
||||
{ key: 'pending', label: t.sidebar.filterPending, count: pendingCount },
|
||||
{ key: 'fixed', label: t.sidebar.filterFixed, count: fixedCount },
|
||||
]
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const s = new Set(prev)
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
return s
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const allIds = filteredBugs.map(b => b.id)
|
||||
const allSelected = allIds.every(id => selectedIds.has(id))
|
||||
setSelectedIds(allSelected ? new Set() : new Set(allIds))
|
||||
}
|
||||
|
||||
const exitBatchMode = () => {
|
||||
setBatchMode(false)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const handleBatchStatus = async (status: BugStatus) => {
|
||||
if (selectedIds.size === 0) return
|
||||
await batchUpdateStatus([...selectedIds], status)
|
||||
exitBatchMode()
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
await batchDeleteBugs([...selectedIds])
|
||||
exitBatchMode()
|
||||
}
|
||||
|
||||
return (
|
||||
<aside style={{ width: width ?? 240 }} className="bg-bg-sidebar border-r border-border flex flex-col shrink-0">
|
||||
{/* Search + New */}
|
||||
<div className="p-3 space-y-2">
|
||||
{/* Search bar */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t.sidebar.searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2.5 bg-bg-input border border-border rounded-xl text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
|
||||
/>
|
||||
</div>
|
||||
{/* New + Batch + Import in one row */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<button
|
||||
onClick={() => createBug()}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 bg-accent hover:bg-accent-hover text-white text-sm font-medium rounded-xl transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t.sidebar.newBug.replace('+ ', '')}
|
||||
</button>
|
||||
{/* Batch mode */}
|
||||
<button
|
||||
onClick={() => batchMode ? exitBatchMode() : setBatchMode(true)}
|
||||
className={`p-2.5 rounded-xl border transition-colors ${
|
||||
batchMode ? 'bg-accent/20 text-accent border-accent/30' : 'bg-bg-input border-border text-text-muted hover:text-text-secondary hover:bg-bg-hover'
|
||||
}`}
|
||||
title={t.sidebar.batchAction}
|
||||
>
|
||||
<ListChecks className="w-4 h-4" />
|
||||
</button>
|
||||
{/* Import */}
|
||||
<div className="relative" ref={importDropdownRef}>
|
||||
<button
|
||||
onClick={() => setImportDropdown(!importDropdown)}
|
||||
className="flex items-center gap-0.5 p-2.5 bg-bg-input border border-border text-text-muted hover:text-text-secondary hover:bg-bg-hover rounded-xl transition-colors"
|
||||
title={zh ? '从外部导入' : 'Import from external'}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<ChevronDown className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
{importDropdown && (
|
||||
<div className="absolute top-full left-0 mt-1 w-40 bg-bg-card border border-border rounded-lg shadow-xl z-50 overflow-hidden">
|
||||
<button
|
||||
onClick={() => { setZentaoOpen(true); setImportDropdown(false) }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-text-secondary hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
{zh ? '从禅道导入' : 'From Zentao'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setJiraOpen(true); setImportDropdown(false) }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-text-secondary hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
{zh ? '从 Jira 导入' : 'From Jira'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setLinearOpen(true); setImportDropdown(false) }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-text-secondary hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
{zh ? '从 Linear 导入' : 'From Linear'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setTapdOpen(true); setImportDropdown(false) }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-text-secondary hover:bg-bg-hover transition-colors"
|
||||
>
|
||||
{zh ? '从 TAPD 导入' : 'From TAPD'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter tabs */}
|
||||
<div className="flex px-3 gap-1 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setFilterTab(tab.key)}
|
||||
className={`flex-1 py-2 text-xs text-center transition-colors relative ${
|
||||
filterTab === tab.key
|
||||
? 'text-accent'
|
||||
: 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{tab.label} {tab.count}
|
||||
{filterTab === tab.key && (
|
||||
<span className="absolute bottom-0 left-1/4 right-1/4 h-0.5 bg-accent rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Bug list (virtual scroll) */}
|
||||
<div
|
||||
ref={virtualContainerRef}
|
||||
onScroll={onVirtualScroll}
|
||||
className="flex-1 overflow-y-auto"
|
||||
>
|
||||
<div style={{ height: totalHeight, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', top: offsetY, left: 0, right: 0 }}>
|
||||
{filteredBugs.slice(startIndex, endIndex + 1).map((bug) => (
|
||||
<div
|
||||
key={bug.id}
|
||||
style={{ height: BUG_ITEM_HEIGHT, boxSizing: 'border-box' }}
|
||||
onClick={() => batchMode ? toggleSelect(bug.id) : selectBug(bug.id)}
|
||||
className={`w-full text-left px-4 py-3 border-l-[3px] transition-colors cursor-pointer group relative ${
|
||||
batchMode && selectedIds.has(bug.id)
|
||||
? 'border-l-accent bg-accent/10'
|
||||
: selectedBugId === bug.id && !batchMode
|
||||
? 'border-l-accent bg-accent/10'
|
||||
: 'border-l-transparent hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
{/* Number + Status */}
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{batchMode && (
|
||||
selectedIds.has(bug.id)
|
||||
? <CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
: <Square className="w-3.5 h-3.5 text-text-muted" />
|
||||
)}
|
||||
<span className="text-xs text-text-muted">#{String(bug.number).padStart(3, '0')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (statusDropdown === bug.id) {
|
||||
setStatusDropdown(null)
|
||||
} else {
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
setStatusDropdownPos({ x: rect.right + 4, y: rect.bottom })
|
||||
setStatusDropdown(bug.id)
|
||||
}
|
||||
}}
|
||||
className={`text-[10px] px-1.5 py-0.5 rounded-full cursor-pointer hover:opacity-80 transition-opacity ${statusColorMap[bug.status]}`}
|
||||
>
|
||||
{t.status[bug.status]}
|
||||
</button>
|
||||
</div>
|
||||
{/* Title */}
|
||||
<p className="text-sm text-text-primary truncate mb-1">{bug.title || (zh ? '未命名 Bug' : 'Untitled Bug')}</p>
|
||||
{/* Screenshot count + time + delete */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted">
|
||||
<span className="flex items-center gap-1">
|
||||
<Camera className="w-3 h-3" />
|
||||
{bug.screenshots.length}{t.sidebar.screenshots}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(bug.createdAt, t.sidebar.timeAgo)}</span>
|
||||
</div>
|
||||
{!batchMode && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setDeleteTarget({ id: bug.id, number: bug.number })
|
||||
}}
|
||||
className="p-0.5 text-text-muted hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch action bar */}
|
||||
{batchMode && (
|
||||
<div className="px-3 py-2 border-t border-border bg-bg-input space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
className="flex items-center gap-1.5 text-xs text-text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
{filteredBugs.length > 0 && filteredBugs.every(b => selectedIds.has(b.id))
|
||||
? <CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
: <Square className="w-3.5 h-3.5" />}
|
||||
{zh ? '全选' : 'Select All'}
|
||||
</button>
|
||||
<span className="text-xs text-text-muted">
|
||||
{zh ? `已选 ${selectedIds.size} 个` : `${selectedIds.size} selected`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<select
|
||||
disabled={selectedIds.size === 0}
|
||||
defaultValue=""
|
||||
onChange={(e) => { if (e.target.value) { handleBatchStatus(e.target.value as BugStatus); e.target.value = '' } }}
|
||||
className="flex-1 px-2 py-1.5 bg-bg-input border border-border rounded-lg text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="" disabled>{zh ? '修改状态' : 'Set Status'}</option>
|
||||
{(['pending', 'fixed', 'closed'] as BugStatus[]).map(s => (
|
||||
<option key={s} value={s}>{t.status[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setBatchConfirm(true)}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="px-2 py-1.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{zh ? '删除' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete bug confirmation */}
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title={zh ? '删除 Bug' : 'Delete Bug'}
|
||||
message={zh
|
||||
? `确定删除 Bug #${String(deleteTarget?.number ?? 0).padStart(3, '0')}?此操作不可撤销。`
|
||||
: `Delete Bug #${String(deleteTarget?.number ?? 0).padStart(3, '0')}? This cannot be undone.`}
|
||||
confirmText={zh ? '确认删除' : 'Delete'}
|
||||
cancelText={zh ? '取消' : 'Cancel'}
|
||||
onConfirm={() => { if (deleteTarget) deleteBug(deleteTarget.id); setDeleteTarget(null) }}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
{/* Batch delete confirmation */}
|
||||
<ConfirmDialog
|
||||
open={batchConfirm}
|
||||
title={zh ? '批量删除' : 'Batch Delete'}
|
||||
message={zh
|
||||
? `确定删除选中的 ${selectedIds.size} 个 Bug?此操作不可撤销。`
|
||||
: `Delete ${selectedIds.size} selected bugs? This cannot be undone.`}
|
||||
confirmText={zh ? '确认删除' : 'Delete'}
|
||||
cancelText={zh ? '取消' : 'Cancel'}
|
||||
onConfirm={() => { handleBatchDelete(); setBatchConfirm(false) }}
|
||||
onCancel={() => setBatchConfirm(false)}
|
||||
/>
|
||||
|
||||
{/* Quick status toggle popover */}
|
||||
{statusDropdown && (
|
||||
<div
|
||||
ref={statusDropdownRef}
|
||||
className="fixed bg-bg-card border border-border rounded-md shadow-xl z-50 py-0.5 w-auto"
|
||||
style={{ left: statusDropdownPos.x, top: statusDropdownPos.y }}
|
||||
>
|
||||
{statusOptions.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const bugId = statusDropdown
|
||||
if (bugId) {
|
||||
const bug = bugs.find(b => b.id === bugId)
|
||||
if (bug && s !== bug.status) {
|
||||
updateBug(bugId, { status: s })
|
||||
// 状态变化时,自动跳转到下一个待处理的 bug
|
||||
const nextPending = bugs.find(b => b.id !== bugId && (b.status === 'pending' || b.status === 'annotating'))
|
||||
if (nextPending) {
|
||||
selectBug(nextPending.id)
|
||||
} else {
|
||||
// 没有待处理的 bug,清空选中状态
|
||||
clearSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
setStatusDropdown(null)
|
||||
}}
|
||||
className={`block text-left whitespace-nowrap px-2.5 py-1 text-[11px] transition-colors ${
|
||||
bugs.find(b => b.id === statusDropdown)?.status === s
|
||||
? 'bg-accent/10 text-accent'
|
||||
: 'text-text-secondary hover:bg-bg-hover'
|
||||
}`}
|
||||
>
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full mr-1.5 align-middle"
|
||||
style={{ backgroundColor: s === 'pending' ? '#ef4444' : s === 'fixed' ? '#22c55e' : '#6b7280' }} />
|
||||
{t.status[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import modals */}
|
||||
{zentaoOpen && <ZentaoModal onClose={() => setZentaoOpen(false)} />}
|
||||
{jiraOpen && <JiraModal onClose={() => setJiraOpen(false)} />}
|
||||
{linearOpen && <LinearModal onClose={() => setLinearOpen(false)} />}
|
||||
{tapdOpen && <TapdModal onClose={() => setTapdOpen(false)} />}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useStore } from '../stores'
|
||||
import { Folder } from 'lucide-react'
|
||||
|
||||
export function StatusBar() {
|
||||
const { bugs, settings, locale } = useStore()
|
||||
const zh = locale === 'zh'
|
||||
|
||||
const total = bugs.length
|
||||
const pending = bugs.filter((b) => b.status === 'pending' || b.status === 'annotating').length
|
||||
|
||||
return (
|
||||
<footer className="h-8 bg-bg-primary border-t border-border flex items-center justify-between px-4 text-xs text-text-muted shrink-0">
|
||||
{/* Left: workspace status + stats */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500" />
|
||||
<span>{zh ? '工作空间活跃' : 'Workspace Active'}</span>
|
||||
</div>
|
||||
<span>{zh ? `${total} 个 Bug · ${pending} 待处理` : `${total} bugs · ${pending} pending`}</span>
|
||||
</div>
|
||||
|
||||
{/* Right: storage path */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Folder className="w-3 h-3" />
|
||||
<span>{settings.dataDir || settings._dataDir || '~/.bugpack/data/'}</span>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useStore } from '../stores'
|
||||
import { api } from '../api'
|
||||
import { X, Download, RefreshCw, ExternalLink, AlertCircle, CheckSquare, Square } from 'lucide-react'
|
||||
|
||||
interface TapdBug {
|
||||
id: string
|
||||
title: string
|
||||
severity: string
|
||||
priority: string
|
||||
status: string
|
||||
reporter: string
|
||||
currentOwner: string
|
||||
created: string
|
||||
}
|
||||
|
||||
export function TapdModal({ onClose }: { onClose: () => void }) {
|
||||
const { locale, currentProjectId, fetchBugs, settings } = useStore()
|
||||
const zh = locale === 'zh'
|
||||
const hasImported = useRef(false)
|
||||
|
||||
const [bugs, setBugs] = useState<TapdBug[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [importing, setImporting] = useState<Set<string>>(new Set())
|
||||
const [imported, setImported] = useState<Set<string>>(new Set())
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [batchImporting, setBatchImporting] = useState(false)
|
||||
const loadBugs = async () => {
|
||||
if (!settings.tapdWorkspaceId) {
|
||||
setError(zh ? '请先在设置中填写 TAPD 项目 ID(workspace_id)' : 'Please set TAPD workspace_id in Settings first')
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.tapd.getBugs()
|
||||
if (!res.ok) throw new Error(res.error || 'Failed to fetch')
|
||||
setBugs(res.bugs || [])
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadBugs()
|
||||
}, [])
|
||||
|
||||
const handleImport = async (id: string) => {
|
||||
setImporting(prev => new Set(prev).add(id))
|
||||
try {
|
||||
const res = await api.tapd.importBug(id, currentProjectId)
|
||||
if (!res.ok) throw new Error('Import failed')
|
||||
setImported(prev => new Set(prev).add(id))
|
||||
hasImported.current = true
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setImporting(prev => { const s = new Set(prev); s.delete(id); return s })
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelected(prev => {
|
||||
const s = new Set(prev)
|
||||
s.has(id) ? s.delete(id) : s.add(id)
|
||||
return s
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const importable = bugs.filter(b => !imported.has(b.id)).map(b => b.id)
|
||||
const allSelected = importable.every(id => selected.has(id))
|
||||
setSelected(allSelected ? new Set() : new Set(importable))
|
||||
}
|
||||
|
||||
const handleBatchImport = async () => {
|
||||
if (selected.size === 0) return
|
||||
setBatchImporting(true)
|
||||
const ids = [...selected].filter(id => !imported.has(id))
|
||||
for (const id of ids) {
|
||||
setImporting(prev => new Set(prev).add(id))
|
||||
try {
|
||||
const res = await api.tapd.importBug(id, currentProjectId)
|
||||
if (res.ok) setImported(prev => new Set(prev).add(id))
|
||||
} catch {
|
||||
// skip failed
|
||||
} finally {
|
||||
setImporting(prev => { const s = new Set(prev); s.delete(id); return s })
|
||||
}
|
||||
}
|
||||
setSelected(new Set())
|
||||
hasImported.current = true
|
||||
setBatchImporting(false)
|
||||
fetchBugs()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const sevColor = (s: string) => {
|
||||
if (s === 'fatal' || s === '致命') return 'text-red-400'
|
||||
if (s === 'serious' || s === '严重') return 'text-orange-400'
|
||||
if (s === 'normal' || s === '一般') return 'text-yellow-400'
|
||||
return 'text-text-muted'
|
||||
}
|
||||
|
||||
const statusText = (s: string) => {
|
||||
const map: Record<string, string> = {
|
||||
new: zh ? '新建' : 'New',
|
||||
assigned: zh ? '已指派' : 'Assigned',
|
||||
reopened: zh ? '重新打开' : 'Reopened',
|
||||
resolved: zh ? '已解决' : 'Resolved',
|
||||
closed: zh ? '已关闭' : 'Closed',
|
||||
}
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-[600px] max-h-[80vh] bg-bg-card border border-border rounded-2xl shadow-2xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="w-5 h-5 text-cyan-400" />
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{zh ? '从 TAPD 导入' : 'Import from TAPD'}
|
||||
</h2>
|
||||
{settings.tapdWorkspaceId && (
|
||||
<span className="ml-2 px-2 py-0.5 text-xs text-cyan-400 bg-cyan-400/10 rounded">
|
||||
#{settings.tapdWorkspaceId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => loadBugs()}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button onClick={() => { if (hasImported.current) fetchBugs(); onClose() }} className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-red-400">
|
||||
<p>{error}</p>
|
||||
<p className="text-xs text-red-400/60 mt-1">
|
||||
{zh ? '请检查设置中的 TAPD 配置' : 'Check TAPD settings'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex items-center justify-center py-12 text-text-muted text-sm">
|
||||
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
|
||||
{zh ? '加载中...' : 'Loading...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bug list */}
|
||||
{!loading && !error && bugs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-text-muted text-sm">
|
||||
<p>{zh ? '该项目下没有待处理的 Bug' : 'No open bugs in this project'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && bugs.length > 0 && (
|
||||
<div className="divide-y divide-border">
|
||||
{bugs.map(bug => (
|
||||
<div key={bug.id} className="px-6 py-3 flex items-center gap-3 hover:bg-bg-hover transition-colors">
|
||||
<button
|
||||
onClick={() => toggleSelect(bug.id)}
|
||||
className={`shrink-0 ${imported.has(bug.id) ? 'text-text-muted/30 cursor-default' : 'text-text-muted hover:text-cyan-400'}`}
|
||||
disabled={imported.has(bug.id)}
|
||||
>
|
||||
{selected.has(bug.id) ? <CheckSquare className="w-4 h-4 text-cyan-400" /> : <Square className="w-4 h-4" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-xs text-text-muted">#{bug.id}</span>
|
||||
<span className={`text-xs font-medium ${sevColor(bug.severity)}`}>{bug.severity || bug.priority}</span>
|
||||
<span className="text-xs text-text-muted px-1.5 py-0.5 bg-bg-input rounded">{statusText(bug.status)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-primary truncate">{bug.title}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted mt-0.5">
|
||||
{bug.currentOwner && <span>{bug.currentOwner}</span>}
|
||||
{bug.created && <span>{new Date(bug.created).toLocaleString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleImport(bug.id)}
|
||||
disabled={importing.has(bug.id) || imported.has(bug.id)}
|
||||
className={`shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
|
||||
imported.has(bug.id)
|
||||
? 'bg-green-500/20 text-green-400 cursor-default'
|
||||
: importing.has(bug.id)
|
||||
? 'bg-bg-input text-text-muted cursor-wait'
|
||||
: 'bg-cyan-400/20 text-cyan-400 hover:bg-cyan-400/30'
|
||||
}`}
|
||||
>
|
||||
{imported.has(bug.id) ? (
|
||||
zh ? '已导入' : 'Imported'
|
||||
) : importing.has(bug.id) ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '导入中' : 'Importing'}</>
|
||||
) : (
|
||||
<><Download className="w-3 h-3" /> {zh ? '导入' : 'Import'}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-border shrink-0 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{bugs.length > 0 && (
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
className="flex items-center gap-1.5 text-xs text-text-muted hover:text-cyan-400 transition-colors"
|
||||
>
|
||||
{bugs.filter(b => !imported.has(b.id)).every(b => selected.has(b.id)) && bugs.some(b => !imported.has(b.id))
|
||||
? <CheckSquare className="w-3.5 h-3.5 text-cyan-400" />
|
||||
: <Square className="w-3.5 h-3.5" />}
|
||||
{zh ? '全选' : 'Select All'}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-text-muted">
|
||||
{zh
|
||||
? `共 ${bugs.length} 个 Bug${selected.size > 0 ? `,已选 ${selected.size} 个` : ''}`
|
||||
: `${bugs.length} bugs${selected.size > 0 ? `, ${selected.size} selected` : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
{selected.size > 0 && (
|
||||
<button
|
||||
onClick={handleBatchImport}
|
||||
disabled={batchImporting}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
|
||||
batchImporting
|
||||
? 'bg-bg-input text-text-muted cursor-wait'
|
||||
: 'bg-cyan-400 text-white hover:bg-cyan-500'
|
||||
}`}
|
||||
>
|
||||
{batchImporting ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '批量导入中...' : 'Importing...'}</>
|
||||
) : (
|
||||
<><Download className="w-3 h-3" /> {zh ? `批量导入 (${selected.size})` : `Import (${selected.size})`}</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useStore } from '../stores'
|
||||
import { api } from '../api'
|
||||
import { X, Download, RefreshCw, ExternalLink, AlertCircle, ChevronDown, CheckSquare, Square } from 'lucide-react'
|
||||
|
||||
interface ZentaoBug {
|
||||
id: number
|
||||
title: string
|
||||
severity: number
|
||||
pri: number
|
||||
status: string
|
||||
openedBy?: { realname?: string }
|
||||
openedDate?: string
|
||||
}
|
||||
|
||||
interface ZentaoProduct {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export function ZentaoModal({ onClose }: { onClose: () => void }) {
|
||||
const { locale, currentProjectId, fetchBugs, settings, saveSettings } = useStore()
|
||||
const zh = locale === 'zh'
|
||||
const hasImported = useRef(false)
|
||||
|
||||
const [products, setProducts] = useState<ZentaoProduct[]>([])
|
||||
const [selectedProductId, setSelectedProductId] = useState(settings.zentaoProductId || '')
|
||||
const [bugs, setBugs] = useState<ZentaoBug[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [importing, setImporting] = useState<Set<number>>(new Set())
|
||||
const [imported, setImported] = useState<Set<number>>(new Set())
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [batchImporting, setBatchImporting] = useState(false)
|
||||
const [step, setStep] = useState<'products' | 'bugs'>(settings.zentaoProductId ? 'bugs' : 'products')
|
||||
|
||||
// Load product list
|
||||
const loadProducts = async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.zentao.getProducts()
|
||||
if (!res.ok) throw new Error(res.error || 'Failed to fetch products')
|
||||
setProducts(res.products || [])
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load bug list
|
||||
const loadBugs = async (productId?: string) => {
|
||||
const pid = productId || selectedProductId
|
||||
if (!pid) { setStep('products'); loadProducts(); return }
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
// Save product ID to settings first
|
||||
if (pid !== settings.zentaoProductId) {
|
||||
await saveSettings({ zentaoProductId: pid })
|
||||
}
|
||||
const res = await api.zentao.getBugs()
|
||||
if (!res.ok) throw new Error(res.error || 'Failed to fetch')
|
||||
setBugs(res.bugs || [])
|
||||
setStep('bugs')
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (settings.zentaoProductId) {
|
||||
loadBugs(settings.zentaoProductId)
|
||||
} else {
|
||||
loadProducts()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Select product
|
||||
const selectProduct = (id: number) => {
|
||||
const pid = String(id)
|
||||
setSelectedProductId(pid)
|
||||
loadBugs(pid)
|
||||
}
|
||||
|
||||
// Import single bug
|
||||
const handleImport = async (bugId: number) => {
|
||||
setImporting(prev => new Set(prev).add(bugId))
|
||||
try {
|
||||
const res = await api.zentao.importBug(bugId, currentProjectId)
|
||||
if (!res.ok) throw new Error('Import failed')
|
||||
setImported(prev => new Set(prev).add(bugId))
|
||||
hasImported.current = true
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setImporting(prev => { const s = new Set(prev); s.delete(bugId); return s })
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle selection
|
||||
const toggleSelect = (bugId: number) => {
|
||||
setSelected(prev => {
|
||||
const s = new Set(prev)
|
||||
s.has(bugId) ? s.delete(bugId) : s.add(bugId)
|
||||
return s
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle select all (excluding imported)
|
||||
const toggleSelectAll = () => {
|
||||
const importable = bugs.filter(b => !imported.has(b.id)).map(b => b.id)
|
||||
const allSelected = importable.every(id => selected.has(id))
|
||||
setSelected(allSelected ? new Set() : new Set(importable))
|
||||
}
|
||||
|
||||
// Batch import
|
||||
const handleBatchImport = async () => {
|
||||
if (selected.size === 0) return
|
||||
setBatchImporting(true)
|
||||
const ids = [...selected].filter(id => !imported.has(id))
|
||||
for (const bugId of ids) {
|
||||
setImporting(prev => new Set(prev).add(bugId))
|
||||
try {
|
||||
const res = await api.zentao.importBug(bugId, currentProjectId)
|
||||
if (res.ok) setImported(prev => new Set(prev).add(bugId))
|
||||
} catch {
|
||||
// skip failed
|
||||
} finally {
|
||||
setImporting(prev => { const s = new Set(prev); s.delete(bugId); return s })
|
||||
}
|
||||
}
|
||||
setSelected(new Set())
|
||||
hasImported.current = true
|
||||
setBatchImporting(false)
|
||||
fetchBugs()
|
||||
onClose()
|
||||
}
|
||||
|
||||
// Severity color
|
||||
const sevColor = (s: number) => {
|
||||
if (s <= 1) return 'text-red-400'
|
||||
if (s === 2) return 'text-orange-400'
|
||||
if (s === 3) return 'text-yellow-400'
|
||||
return 'text-text-muted'
|
||||
}
|
||||
|
||||
const statusText = (s: string) => {
|
||||
const map: Record<string, string> = { active: zh ? '激活' : 'Active', resolved: zh ? '已解决' : 'Resolved', closed: zh ? '已关闭' : 'Closed' }
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
const curProduct = products.find(p => String(p.id) === selectedProductId)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-[600px] max-h-[80vh] bg-bg-card border border-border rounded-2xl shadow-2xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ExternalLink className="w-5 h-5 text-accent" />
|
||||
<h2 className="text-lg font-semibold text-text-primary">
|
||||
{zh ? '从禅道导入' : 'Import from Zentao'}
|
||||
</h2>
|
||||
{/* Product switcher */}
|
||||
{step === 'bugs' && (
|
||||
<button
|
||||
onClick={() => { setStep('products'); loadProducts() }}
|
||||
className="ml-2 flex items-center gap-1 px-2 py-0.5 text-xs text-accent bg-accent/10 rounded hover:bg-accent/20 transition-colors"
|
||||
>
|
||||
{curProduct?.name || `ID:${selectedProductId}`}
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => step === 'bugs' ? loadBugs() : loadProducts()}
|
||||
className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button onClick={() => { if (hasImported.current) fetchBugs(); onClose() }} className="p-1.5 rounded-lg text-text-muted hover:bg-bg-hover hover:text-text-secondary transition-colors">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-red-400 shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-red-400">
|
||||
<p>{error}</p>
|
||||
<p className="text-xs text-red-400/60 mt-1">
|
||||
{zh ? '请检查设置中的禅道配置' : 'Check Zentao settings'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && !error && (
|
||||
<div className="flex items-center justify-center py-12 text-text-muted text-sm">
|
||||
<RefreshCw className="w-4 h-4 animate-spin mr-2" />
|
||||
{zh ? '加载中...' : 'Loading...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Product selection */}
|
||||
{!loading && step === 'products' && !error && (
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-text-secondary mb-3">
|
||||
{zh ? '选择禅道项目:' : 'Select a Zentao project:'}
|
||||
</p>
|
||||
{products.length === 0 ? (
|
||||
<p className="text-sm text-text-muted text-center py-8">
|
||||
{zh ? '没有找到项目(当前账号可能无权限)' : 'No products found (no permission?)'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{products.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => selectProduct(p.id)}
|
||||
className="w-full text-left px-4 py-3 bg-bg-input border border-border rounded-lg hover:border-accent hover:bg-accent/5 transition-colors"
|
||||
>
|
||||
<span className="text-xs text-text-muted mr-2">#{p.id}</span>
|
||||
<span className="text-sm text-text-primary">{p.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bug list */}
|
||||
{!loading && step === 'bugs' && !error && bugs.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-text-muted text-sm">
|
||||
<p>{zh ? '该项目下没有指派给你的 Bug' : 'No bugs assigned to you'}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && step === 'bugs' && bugs.length > 0 && (
|
||||
<div className="divide-y divide-border">
|
||||
{bugs.map(bug => (
|
||||
<div key={bug.id} className="px-6 py-3 flex items-center gap-3 hover:bg-bg-hover transition-colors">
|
||||
{/* Checkbox */}
|
||||
<button
|
||||
onClick={() => toggleSelect(bug.id)}
|
||||
className={`shrink-0 ${imported.has(bug.id) ? 'text-text-muted/30 cursor-default' : 'text-text-muted hover:text-accent'}`}
|
||||
disabled={imported.has(bug.id)}
|
||||
>
|
||||
{selected.has(bug.id) ? <CheckSquare className="w-4 h-4 text-accent" /> : <Square className="w-4 h-4" />}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-xs text-text-muted">#{bug.id}</span>
|
||||
<span className={`text-xs font-medium ${sevColor(bug.severity)}`}>S{bug.severity}</span>
|
||||
<span className="text-xs text-text-muted px-1.5 py-0.5 bg-bg-input rounded">{statusText(bug.status)}</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-primary truncate">{bug.title}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-text-muted mt-0.5">
|
||||
{bug.openedBy?.realname && <span>{bug.openedBy.realname}</span>}
|
||||
{bug.openedDate && <span>{new Date(bug.openedDate).toLocaleString()}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleImport(bug.id)}
|
||||
disabled={importing.has(bug.id) || imported.has(bug.id)}
|
||||
className={`shrink-0 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
|
||||
imported.has(bug.id)
|
||||
? 'bg-green-500/20 text-green-400 cursor-default'
|
||||
: importing.has(bug.id)
|
||||
? 'bg-bg-input text-text-muted cursor-wait'
|
||||
: 'bg-accent/20 text-accent hover:bg-accent/30'
|
||||
}`}
|
||||
>
|
||||
{imported.has(bug.id) ? (
|
||||
zh ? '已导入' : 'Imported'
|
||||
) : importing.has(bug.id) ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '导入中' : 'Importing'}</>
|
||||
) : (
|
||||
<><Download className="w-3 h-3" /> {zh ? '导入' : 'Import'}</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3 border-t border-border shrink-0 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{step === 'bugs' && bugs.length > 0 && (
|
||||
<button
|
||||
onClick={toggleSelectAll}
|
||||
className="flex items-center gap-1.5 text-xs text-text-muted hover:text-accent transition-colors"
|
||||
>
|
||||
{bugs.filter(b => !imported.has(b.id)).every(b => selected.has(b.id)) && bugs.some(b => !imported.has(b.id))
|
||||
? <CheckSquare className="w-3.5 h-3.5 text-accent" />
|
||||
: <Square className="w-3.5 h-3.5" />}
|
||||
{zh ? '全选' : 'Select All'}
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-text-muted">
|
||||
{step === 'bugs'
|
||||
? (zh
|
||||
? `指派给我 ${bugs.length} 个 Bug${selected.size > 0 ? `,已选 ${selected.size} 个` : ''}`
|
||||
: `${bugs.length} bugs assigned to me${selected.size > 0 ? `, ${selected.size} selected` : ''}`)
|
||||
: (zh ? `共 ${products.length} 个项目` : `${products.length} projects`)}
|
||||
</span>
|
||||
</div>
|
||||
{step === 'bugs' && selected.size > 0 && (
|
||||
<button
|
||||
onClick={handleBatchImport}
|
||||
disabled={batchImporting}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors flex items-center gap-1 ${
|
||||
batchImporting
|
||||
? 'bg-bg-input text-text-muted cursor-wait'
|
||||
: 'bg-accent text-white hover:bg-accent-hover'
|
||||
}`}
|
||||
>
|
||||
{batchImporting ? (
|
||||
<><RefreshCw className="w-3 h-3 animate-spin" /> {zh ? '批量导入中...' : 'Importing...'}</>
|
||||
) : (
|
||||
<><Download className="w-3 h-3" /> {zh ? `批量导入 (${selected.size})` : `Import (${selected.size})`}</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useStore } from '../stores'
|
||||
|
||||
// Global keyboard shortcuts
|
||||
export function useKeyboard() {
|
||||
const { createBug, setViewMode, viewMode, selectedBugId } = useStore()
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const isInput = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT' || target.isContentEditable
|
||||
|
||||
// Ctrl combos (regardless of input focus)
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'n' || e.key === 'N') {
|
||||
e.preventDefault()
|
||||
createBug()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' && selectedBugId) {
|
||||
e.preventDefault()
|
||||
setViewMode(viewMode === 'edit' ? 'preview' : 'edit')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Single key shortcuts (only when not in input)
|
||||
if (isInput) return
|
||||
|
||||
// Tool switching via custom events, listened by EditorArea
|
||||
const toolMap: Record<string, string> = {
|
||||
d: 'drag',
|
||||
v: 'select',
|
||||
r: 'rect',
|
||||
a: 'arrow',
|
||||
t: 'text',
|
||||
n: 'number',
|
||||
h: 'highlight',
|
||||
p: 'pen',
|
||||
m: 'mosaic',
|
||||
}
|
||||
|
||||
const tool = toolMap[e.key.toLowerCase()]
|
||||
if (tool) {
|
||||
window.dispatchEvent(new CustomEvent('bugpack:tool', { detail: tool }))
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [createBug, setViewMode, viewMode, selectedBugId])
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
|
||||
|
||||
interface UseVirtualListOptions {
|
||||
itemCount: number
|
||||
itemHeight: number
|
||||
overscan?: number // extra items rendered above/below
|
||||
}
|
||||
|
||||
export function useVirtualList({ itemCount, itemHeight, overscan = 5 }: UseVirtualListOptions) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [scrollTop, setScrollTop] = useState(0)
|
||||
const [containerHeight, setContainerHeight] = useState(0)
|
||||
|
||||
const onScroll = useCallback(() => {
|
||||
if (containerRef.current) {
|
||||
setScrollTop(containerRef.current.scrollTop)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const observer = new ResizeObserver(() => {
|
||||
setContainerHeight(el.clientHeight)
|
||||
})
|
||||
observer.observe(el)
|
||||
setContainerHeight(el.clientHeight)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const { startIndex, endIndex, totalHeight, offsetY } = useMemo(() => {
|
||||
const totalHeight = itemCount * itemHeight
|
||||
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)
|
||||
const visibleCount = Math.ceil(containerHeight / itemHeight)
|
||||
const end = Math.min(itemCount - 1, start + visibleCount + overscan * 2)
|
||||
return {
|
||||
startIndex: start,
|
||||
endIndex: end,
|
||||
totalHeight,
|
||||
offsetY: start * itemHeight,
|
||||
}
|
||||
}, [itemCount, itemHeight, containerHeight, scrollTop, overscan])
|
||||
|
||||
// Scroll to given index
|
||||
const scrollToIndex = useCallback((index: number) => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const targetTop = index * itemHeight
|
||||
const targetBottom = targetTop + itemHeight
|
||||
if (targetTop < el.scrollTop) {
|
||||
el.scrollTop = targetTop
|
||||
} else if (targetBottom > el.scrollTop + el.clientHeight) {
|
||||
el.scrollTop = targetBottom - el.clientHeight
|
||||
}
|
||||
}, [itemHeight])
|
||||
|
||||
return {
|
||||
containerRef,
|
||||
onScroll,
|
||||
startIndex,
|
||||
endIndex,
|
||||
totalHeight,
|
||||
offsetY,
|
||||
scrollToIndex,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
export const en = {
|
||||
app: {
|
||||
name: 'BugPack',
|
||||
version: 'v1.0.0',
|
||||
},
|
||||
nav: {
|
||||
project: 'Project',
|
||||
settings: 'Settings',
|
||||
shortcuts: 'Shortcuts',
|
||||
},
|
||||
sidebar: {
|
||||
searchPlaceholder: 'Search bugs...',
|
||||
newBug: '+ New Bug',
|
||||
filterAll: 'All',
|
||||
filterPending: 'Pending',
|
||||
filterFixed: 'Fixed',
|
||||
batchAction: 'Batch Actions',
|
||||
screenshots: 'screenshots',
|
||||
timeAgo: 'ago',
|
||||
},
|
||||
status: {
|
||||
pending: 'Pending',
|
||||
annotating: 'Annotating',
|
||||
generated: 'Generated',
|
||||
fixed: 'Fixed',
|
||||
closed: 'Closed',
|
||||
},
|
||||
priority: {
|
||||
high: 'High',
|
||||
medium: 'Medium',
|
||||
low: 'Low',
|
||||
},
|
||||
editor: {
|
||||
tools: {
|
||||
drag: 'Drag',
|
||||
select: 'Select',
|
||||
rect: 'Rectangle',
|
||||
arrow: 'Arrow',
|
||||
text: 'Text',
|
||||
number: 'Number',
|
||||
highlight: 'Highlight',
|
||||
pen: 'Pen',
|
||||
mosaic: 'Mosaic',
|
||||
undo: 'Undo',
|
||||
redo: 'Redo',
|
||||
reset: 'Reset All Annotations',
|
||||
},
|
||||
lineWidth: {
|
||||
thin: 'Thin',
|
||||
medium: 'Medium',
|
||||
thick: 'Thick',
|
||||
},
|
||||
compare: 'Compare',
|
||||
compareLeft: 'Current',
|
||||
compareRight: 'Expected',
|
||||
fitWindow: 'Fit',
|
||||
emptyTitle: 'Ctrl+V to paste screenshot',
|
||||
emptySubtitle: 'or drag and drop images here',
|
||||
emptyFormat: 'Supports PNG / JPG / GIF',
|
||||
copyImage: 'Copy Image',
|
||||
copySuccess: 'Copied to clipboard',
|
||||
copyFail: 'Copy failed',
|
||||
},
|
||||
evidence: {
|
||||
title: 'Evidence',
|
||||
addFile: 'Add File',
|
||||
annotated: 'Annotated',
|
||||
notAnnotated: 'Not annotated',
|
||||
},
|
||||
panel: {
|
||||
bugInfo: 'Bug Details',
|
||||
title: 'Title',
|
||||
titlePlaceholder: 'Enter bug title...',
|
||||
description: 'Description',
|
||||
descriptionPlaceholder: 'Describe the bug in detail...',
|
||||
priority: 'Priority',
|
||||
statusLabel: 'Status',
|
||||
envInfo: 'Environment Context',
|
||||
pagePath: 'Page Path',
|
||||
pagePathPlaceholder: 'e.g. /login',
|
||||
device: 'Device / Resolution',
|
||||
devicePlaceholder: 'e.g. iPhone 14 Pro 390x844',
|
||||
browser: 'Browser',
|
||||
browserPlaceholder: 'e.g. Chrome 120.0',
|
||||
relatedFiles: 'Suggested Files to Fix',
|
||||
addFilePath: '+ Add file path',
|
||||
filePathPlaceholder: 'Enter file path...',
|
||||
smartSuggest: 'Smart Suggest',
|
||||
annotations: 'Annotations',
|
||||
generateBtn: 'Generate AI Instructions',
|
||||
generateShortcut: 'Ctrl+Enter',
|
||||
copyBtn: 'Copy',
|
||||
exportBtn: 'Export MD',
|
||||
},
|
||||
preview: {
|
||||
backToEdit: 'Back to Edit',
|
||||
copy: 'Copy to Clipboard',
|
||||
export: 'Export .md',
|
||||
|
||||
|
||||
regenerate: 'Re-generate',
|
||||
contents: 'Contents',
|
||||
screenshots: 'Issue Screenshots',
|
||||
annotationsSection: 'Annotations',
|
||||
environment: 'Environment Info',
|
||||
requirements: 'Requirements',
|
||||
relatedFilesSection: 'Related Files',
|
||||
aiInstructions: 'AI Agent Instructions',
|
||||
},
|
||||
statusBar: {
|
||||
total: 'Total Bugs',
|
||||
pending: 'Pending',
|
||||
fixed: 'Fixed',
|
||||
mcp: 'MCP',
|
||||
storage: 'Storage',
|
||||
},
|
||||
empty: {
|
||||
title: 'No bugs yet, awesome!',
|
||||
subtitle: 'Ctrl+V to paste and create quickly',
|
||||
createFirst: '+ Create First Bug',
|
||||
tip: 'Tip: Copy bug screenshots from chat apps, then Ctrl+V to paste',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
projectConfig: 'Project Config',
|
||||
projectName: 'Project Name',
|
||||
rootDir: 'Root Directory',
|
||||
dataDir: 'Data Directory',
|
||||
browse: 'Browse',
|
||||
|
||||
integrations: 'Integrations',
|
||||
|
||||
appearance: 'Appearance',
|
||||
shortcuts: 'Shortcuts',
|
||||
about: 'About',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { zh } from './zh'
|
||||
import { en } from './en'
|
||||
|
||||
export type Locale = 'zh' | 'en'
|
||||
export type TranslationKeys = typeof zh
|
||||
|
||||
const messages: Record<Locale, TranslationKeys> = { zh, en }
|
||||
|
||||
export function getMessages(locale: Locale): TranslationKeys {
|
||||
return messages[locale]
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
export const zh = {
|
||||
app: {
|
||||
name: 'BugPack',
|
||||
version: 'v1.0.0',
|
||||
},
|
||||
nav: {
|
||||
project: '项目',
|
||||
settings: '设置',
|
||||
shortcuts: '快捷键',
|
||||
},
|
||||
sidebar: {
|
||||
searchPlaceholder: '搜索 Bug...',
|
||||
newBug: '+ 新建 Bug',
|
||||
filterAll: '全部',
|
||||
filterPending: '待处理',
|
||||
filterFixed: '已修复',
|
||||
batchAction: '批量操作',
|
||||
screenshots: '张截图',
|
||||
timeAgo: '前',
|
||||
},
|
||||
status: {
|
||||
pending: '待处理',
|
||||
annotating: '标注中',
|
||||
generated: '已生成',
|
||||
fixed: '已修复',
|
||||
closed: '已关闭',
|
||||
},
|
||||
priority: {
|
||||
high: '高',
|
||||
medium: '中',
|
||||
low: '低',
|
||||
},
|
||||
editor: {
|
||||
tools: {
|
||||
drag: '拖拽',
|
||||
select: '选择',
|
||||
rect: '矩形框',
|
||||
arrow: '箭头',
|
||||
text: '文字',
|
||||
number: '序号',
|
||||
highlight: '高亮',
|
||||
pen: '画笔',
|
||||
mosaic: '马赛克',
|
||||
undo: '撤销',
|
||||
redo: '重做',
|
||||
reset: '重置所有标注',
|
||||
},
|
||||
lineWidth: {
|
||||
thin: '细',
|
||||
medium: '中',
|
||||
thick: '粗',
|
||||
},
|
||||
compare: '对比标注',
|
||||
compareLeft: '当前效果',
|
||||
compareRight: '期望效果',
|
||||
fitWindow: '适应窗口',
|
||||
emptyTitle: 'Ctrl+V 粘贴截图',
|
||||
emptySubtitle: '或拖拽图片到此处',
|
||||
emptyFormat: '支持 PNG / JPG / GIF',
|
||||
copyImage: '复制图片',
|
||||
copySuccess: '已复制到剪贴板',
|
||||
copyFail: '复制失败',
|
||||
},
|
||||
evidence: {
|
||||
title: '证据文件',
|
||||
addFile: '添加文件',
|
||||
annotated: '已标注',
|
||||
notAnnotated: '未标注',
|
||||
},
|
||||
panel: {
|
||||
bugInfo: 'Bug 信息',
|
||||
title: '标题',
|
||||
titlePlaceholder: '输入 Bug 标题...',
|
||||
description: '描述',
|
||||
descriptionPlaceholder: '详细描述 Bug 现象...',
|
||||
priority: '优先级',
|
||||
statusLabel: '状态',
|
||||
envInfo: '环境信息',
|
||||
pagePath: '页面路径',
|
||||
pagePathPlaceholder: '例: /login',
|
||||
device: '设备/分辨率',
|
||||
devicePlaceholder: '例: iPhone 14 Pro 390x844',
|
||||
browser: '浏览器',
|
||||
browserPlaceholder: '例: Chrome 120.0',
|
||||
relatedFiles: '关联文件',
|
||||
addFilePath: '+ 添加文件路径',
|
||||
filePathPlaceholder: '输入文件路径...',
|
||||
smartSuggest: '智能推荐',
|
||||
annotations: '标注说明',
|
||||
generateBtn: '生成 AI 指令',
|
||||
generateShortcut: 'Ctrl+Enter',
|
||||
copyBtn: '复制',
|
||||
exportBtn: '导出 MD',
|
||||
},
|
||||
preview: {
|
||||
backToEdit: '返回编辑',
|
||||
copy: '复制到剪贴板',
|
||||
export: '导出 .md',
|
||||
|
||||
|
||||
regenerate: '重新生成',
|
||||
contents: '目录',
|
||||
screenshots: '问题截图',
|
||||
annotationsSection: '标注',
|
||||
environment: '环境信息',
|
||||
requirements: '修改要求',
|
||||
relatedFilesSection: '相关文件',
|
||||
aiInstructions: 'AI 指令',
|
||||
},
|
||||
statusBar: {
|
||||
total: 'Bug 总数',
|
||||
pending: '待处理',
|
||||
fixed: '已修复',
|
||||
mcp: 'MCP',
|
||||
storage: '存储',
|
||||
},
|
||||
empty: {
|
||||
title: '还没有 Bug,太棒了!',
|
||||
subtitle: 'Ctrl+V 粘贴截图快速创建',
|
||||
createFirst: '+ 创建第一个 Bug',
|
||||
tip: '提示:从微信/钉钉复制 bug 截图,直接 Ctrl+V 粘贴即可',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
projectConfig: '项目配置',
|
||||
projectName: '项目名称',
|
||||
rootDir: '代码根目录',
|
||||
dataDir: '数据目录',
|
||||
browse: '浏览',
|
||||
|
||||
integrations: '外部平台对接',
|
||||
|
||||
appearance: '外观',
|
||||
shortcuts: '快捷键',
|
||||
about: '关于',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Theme variables - dark (default) */
|
||||
:root {
|
||||
--bg-primary: #0A0A0F;
|
||||
--bg-sidebar: #0F0F17;
|
||||
--bg-card: #13131A;
|
||||
--bg-input: #1A1A26;
|
||||
--bg-hover: #15151F;
|
||||
--border: #1E1E2E;
|
||||
--accent: #6366F1;
|
||||
--accent-hover: #818CF8;
|
||||
--text-primary: #E4E4E7;
|
||||
--text-secondary: #A1A1AA;
|
||||
--text-muted: #6B7280;
|
||||
--scrollbar-track: #0A0A0F;
|
||||
--scrollbar-thumb: #2E2E3E;
|
||||
--scrollbar-thumb-hover: #3E3E4E;
|
||||
}
|
||||
|
||||
/* Light theme */
|
||||
:root.light {
|
||||
--bg-primary: #F8F9FA;
|
||||
--bg-sidebar: #FFFFFF;
|
||||
--bg-card: #FFFFFF;
|
||||
--bg-input: #F1F3F5;
|
||||
--bg-hover: #E9ECEF;
|
||||
--border: #DEE2E6;
|
||||
--accent: #6366F1;
|
||||
--accent-hover: #4F46E5;
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #495057;
|
||||
--text-muted: #868E96;
|
||||
--scrollbar-track: #F8F9FA;
|
||||
--scrollbar-thumb: #CED4DA;
|
||||
--scrollbar-thumb-hover: #ADB5BD;
|
||||
}
|
||||
|
||||
/* Global scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* Drop zone highlight */
|
||||
.drop-active {
|
||||
border: 2px dashed var(--accent) !important;
|
||||
background: color-mix(in srgb, var(--accent) 5%, transparent) !important;
|
||||
}
|
||||
|
||||
/* Collapsible section transition */
|
||||
.section-content {
|
||||
overflow: hidden;
|
||||
transition: max-height 0.2s ease-out;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,406 @@
|
||||
import { create } from 'zustand'
|
||||
import { type Locale, getMessages, type TranslationKeys } from '../i18n'
|
||||
import { api, type ApiBug, type ApiProject } from '../api'
|
||||
|
||||
export type BugStatus = 'pending' | 'annotating' | 'generated' | 'fixed' | 'closed'
|
||||
export type Priority = 'high' | 'medium' | 'low'
|
||||
export type FilterTab = 'all' | 'pending' | 'fixed'
|
||||
|
||||
// Filter bugs by tab
|
||||
function filterByTab(bugs: Bug[], tab: FilterTab): Bug[] {
|
||||
if (tab === 'pending') return bugs.filter(b => b.status === 'pending' || b.status === 'annotating')
|
||||
if (tab === 'fixed') return bugs.filter(b => b.status === 'fixed')
|
||||
return bugs
|
||||
}
|
||||
|
||||
export interface Screenshot {
|
||||
id: string
|
||||
url: string
|
||||
name: string
|
||||
annotated: boolean
|
||||
annotations: unknown[]
|
||||
}
|
||||
|
||||
export interface Bug {
|
||||
id: string
|
||||
number: number
|
||||
title: string
|
||||
description: string
|
||||
status: BugStatus
|
||||
priority: Priority
|
||||
screenshots: Screenshot[]
|
||||
pagePath: string
|
||||
device: string
|
||||
browser: string
|
||||
relatedFiles: string[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
function toBug(raw: ApiBug): Bug {
|
||||
return {
|
||||
id: raw.id,
|
||||
number: raw.number,
|
||||
title: raw.title,
|
||||
description: raw.description,
|
||||
status: raw.status as BugStatus,
|
||||
priority: raw.priority as Priority,
|
||||
screenshots: raw.screenshots || [],
|
||||
pagePath: raw.page_path ?? '',
|
||||
device: raw.device ?? '',
|
||||
browser: raw.browser ?? '',
|
||||
relatedFiles: raw.relatedFiles || [],
|
||||
createdAt: raw.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
export type Theme = 'dark' | 'light'
|
||||
export type ViewMode = 'edit' | 'preview'
|
||||
|
||||
interface AppState {
|
||||
locale: Locale
|
||||
t: TranslationKeys
|
||||
setLocale: (locale: Locale) => void
|
||||
|
||||
bugs: Bug[]
|
||||
selectedBugId: string | null
|
||||
filterTab: FilterTab
|
||||
searchQuery: string
|
||||
loading: boolean
|
||||
|
||||
settings: Record<string, string>
|
||||
|
||||
projects: ApiProject[]
|
||||
currentProjectId: string
|
||||
|
||||
theme: Theme
|
||||
viewMode: ViewMode
|
||||
settingsOpen: boolean
|
||||
shortcutsOpen: boolean
|
||||
|
||||
currentProject: string
|
||||
|
||||
compareMode: boolean
|
||||
compareLeft: number
|
||||
compareRight: number
|
||||
|
||||
fetchBugs: () => Promise<void>
|
||||
createBug: (title?: string) => Promise<Bug>
|
||||
updateBug: (id: string, data: Record<string, unknown>) => Promise<void>
|
||||
deleteBug: (id: string) => Promise<void>
|
||||
pasteScreenshot: (bugId: string, dataUrl: string, name?: string) => Promise<void>
|
||||
uploadScreenshot: (bugId: string, file: File, name?: string) => Promise<void>
|
||||
deleteScreenshot: (bugId: string, ssId: string) => Promise<void>
|
||||
renameScreenshot: (bugId: string, ssId: string, name: string) => Promise<void>
|
||||
updateScreenshotAnnotated: (bugId: string, ssId: string) => Promise<void>
|
||||
saveAnnotations: (bugId: string, ssId: string, annotations: unknown) => Promise<void>
|
||||
reorderScreenshots: (bugId: string, order: string[]) => Promise<void>
|
||||
batchUpdateStatus: (ids: string[], status: BugStatus) => Promise<void>
|
||||
batchDeleteBugs: (ids: string[]) => Promise<void>
|
||||
|
||||
fetchSettings: () => Promise<void>
|
||||
saveSettings: (data: Record<string, string>) => Promise<void>
|
||||
|
||||
fetchProjects: () => Promise<void>
|
||||
createProject: (name: string) => Promise<ApiProject>
|
||||
switchProject: (id: string) => Promise<void>
|
||||
deleteProject: (id: string) => Promise<void>
|
||||
|
||||
selectBug: (id: string) => void
|
||||
clearSelection: () => void
|
||||
setFilterTab: (tab: FilterTab) => void
|
||||
setSearchQuery: (query: string) => void
|
||||
setViewMode: (mode: ViewMode) => void
|
||||
setTheme: (theme: Theme) => void
|
||||
setSettingsOpen: (open: boolean) => void
|
||||
setShortcutsOpen: (open: boolean) => void
|
||||
setCompareMode: (on: boolean) => void
|
||||
setCompareLeft: (idx: number) => void
|
||||
setCompareRight: (idx: number) => void
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set, get) => ({
|
||||
locale: 'en',
|
||||
t: getMessages('en'),
|
||||
setLocale: (locale) => {
|
||||
set({ locale, t: getMessages(locale) })
|
||||
api.saveSettings({ locale }).catch(() => {})
|
||||
},
|
||||
|
||||
bugs: [],
|
||||
selectedBugId: null,
|
||||
filterTab: 'all',
|
||||
searchQuery: '',
|
||||
loading: false,
|
||||
|
||||
settings: {},
|
||||
|
||||
projects: [],
|
||||
currentProjectId: '',
|
||||
|
||||
theme: 'dark' as Theme,
|
||||
viewMode: 'edit',
|
||||
settingsOpen: false,
|
||||
shortcutsOpen: false,
|
||||
|
||||
currentProject: '',
|
||||
|
||||
compareMode: false,
|
||||
compareLeft: 0,
|
||||
compareRight: 1,
|
||||
|
||||
fetchBugs: async () => {
|
||||
set({ loading: true })
|
||||
try {
|
||||
const projectId = get().currentProjectId
|
||||
const raw = await api.getBugs(projectId)
|
||||
const bugs = raw.map(toBug)
|
||||
const state = get()
|
||||
const filtered = filterByTab(bugs, state.filterTab)
|
||||
const selectedBugId = state.selectedBugId && filtered.find(b => b.id === state.selectedBugId)
|
||||
? state.selectedBugId
|
||||
: filtered[0]?.id ?? null
|
||||
set({ bugs, selectedBugId, loading: false })
|
||||
} catch (e) {
|
||||
console.error('Failed to load bug list:', e)
|
||||
set({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
createBug: async (title) => {
|
||||
const raw = await api.createBug({ title: title || '', project_id: get().currentProjectId })
|
||||
const bug = toBug(raw)
|
||||
set((s) => ({ bugs: [bug, ...s.bugs], selectedBugId: bug.id, viewMode: 'edit' }))
|
||||
return bug
|
||||
},
|
||||
|
||||
updateBug: async (id, data) => {
|
||||
const raw = await api.updateBug(id, data)
|
||||
const updated = toBug(raw)
|
||||
set((s) => ({
|
||||
bugs: s.bugs.map(b => b.id === id ? updated : b),
|
||||
}))
|
||||
},
|
||||
|
||||
deleteBug: async (id) => {
|
||||
await api.deleteBug(id)
|
||||
set((s) => {
|
||||
const bugs = s.bugs.filter(b => b.id !== id)
|
||||
// 根据当前 tab 过滤后选中第一个
|
||||
const filtered = filterByTab(bugs, s.filterTab)
|
||||
return {
|
||||
bugs,
|
||||
selectedBugId: s.selectedBugId === id ? (filtered[0]?.id ?? null) : s.selectedBugId,
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
pasteScreenshot: async (bugId, dataUrl, name) => {
|
||||
await api.pasteScreenshot(bugId, dataUrl, name)
|
||||
await get().fetchBugs()
|
||||
},
|
||||
|
||||
uploadScreenshot: async (bugId, file, name) => {
|
||||
await api.uploadScreenshot(bugId, file, name)
|
||||
await get().fetchBugs()
|
||||
},
|
||||
|
||||
deleteScreenshot: async (bugId, ssId) => {
|
||||
await api.deleteScreenshot(bugId, ssId)
|
||||
await get().fetchBugs()
|
||||
},
|
||||
|
||||
renameScreenshot: async (bugId, ssId, name) => {
|
||||
await api.renameScreenshot(bugId, ssId, name)
|
||||
await get().fetchBugs()
|
||||
},
|
||||
|
||||
updateScreenshotAnnotated: async (bugId, ssId) => {
|
||||
await api.markScreenshotAnnotated(bugId, ssId)
|
||||
set((s) => ({
|
||||
bugs: s.bugs.map(b => b.id === bugId ? {
|
||||
...b,
|
||||
screenshots: b.screenshots.map(ss => ss.id === ssId ? { ...ss, annotated: true } : ss),
|
||||
} : b),
|
||||
}))
|
||||
},
|
||||
|
||||
saveAnnotations: async (bugId, ssId, annotations) => {
|
||||
await api.saveAnnotations(bugId, ssId, annotations)
|
||||
set((s) => ({
|
||||
bugs: s.bugs.map(b => b.id === bugId ? {
|
||||
...b,
|
||||
screenshots: b.screenshots.map(ss => ss.id === ssId ? { ...ss, annotations: annotations as unknown[] } : ss),
|
||||
} : b),
|
||||
}))
|
||||
},
|
||||
|
||||
reorderScreenshots: async (bugId, order) => {
|
||||
await api.reorderScreenshots(bugId, order)
|
||||
set((s) => ({
|
||||
bugs: s.bugs.map(b => {
|
||||
if (b.id !== bugId) return b
|
||||
const sorted = order.map(id => b.screenshots.find(ss => ss.id === id)!).filter(Boolean)
|
||||
return { ...b, screenshots: sorted }
|
||||
}),
|
||||
}))
|
||||
},
|
||||
|
||||
// Optimistic update, rollback on failure
|
||||
batchUpdateStatus: async (ids, status) => {
|
||||
const prevBugs = get().bugs
|
||||
set((s) => {
|
||||
const bugs = s.bugs.map(b => ids.includes(b.id) ? { ...b, status } : b)
|
||||
const filtered = filterByTab(bugs, s.filterTab)
|
||||
const stillVisible = filtered.some(b => b.id === s.selectedBugId)
|
||||
return {
|
||||
bugs,
|
||||
selectedBugId: stillVisible ? s.selectedBugId : (filtered[0]?.id ?? null),
|
||||
}
|
||||
})
|
||||
try {
|
||||
await api.batchUpdateStatus(ids, status)
|
||||
} catch (e) {
|
||||
set({ bugs: prevBugs })
|
||||
console.error('Batch status update failed:', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
// Optimistic update, rollback on failure
|
||||
batchDeleteBugs: async (ids) => {
|
||||
const prevBugs = get().bugs
|
||||
const prevSelected = get().selectedBugId
|
||||
set((s) => {
|
||||
const bugs = s.bugs.filter(b => !ids.includes(b.id))
|
||||
const filtered = filterByTab(bugs, s.filterTab)
|
||||
return {
|
||||
bugs,
|
||||
selectedBugId: ids.includes(s.selectedBugId || '') ? (filtered[0]?.id ?? null) : s.selectedBugId,
|
||||
}
|
||||
})
|
||||
try {
|
||||
await api.batchDeleteBugs(ids)
|
||||
} catch (e) {
|
||||
set({ bugs: prevBugs, selectedBugId: prevSelected })
|
||||
console.error('Batch delete failed:', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
fetchSettings: async () => {
|
||||
try {
|
||||
const settings = await api.getSettings()
|
||||
set({ settings })
|
||||
if (settings.theme === 'light' || settings.theme === 'dark') {
|
||||
const theme = settings.theme as Theme
|
||||
if (theme === 'light') {
|
||||
document.documentElement.classList.add('light')
|
||||
} else {
|
||||
document.documentElement.classList.remove('light')
|
||||
}
|
||||
set({ theme })
|
||||
}
|
||||
if (settings.locale === 'zh' || settings.locale === 'en') {
|
||||
const locale = settings.locale as Locale
|
||||
set({ locale, t: getMessages(locale) })
|
||||
}
|
||||
if (settings.currentProjectId) {
|
||||
set({ currentProjectId: settings.currentProjectId })
|
||||
}
|
||||
if (settings.filterTab === 'all' || settings.filterTab === 'pending' || settings.filterTab === 'fixed') {
|
||||
set({ filterTab: settings.filterTab as FilterTab })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load settings:', e)
|
||||
}
|
||||
},
|
||||
|
||||
// Rollback on failure
|
||||
saveSettings: async (data) => {
|
||||
const prevSettings = get().settings
|
||||
set({ settings: { ...prevSettings, ...data } })
|
||||
try {
|
||||
await api.saveSettings(data)
|
||||
} catch (e) {
|
||||
set({ settings: prevSettings })
|
||||
console.error('Failed to save settings:', e)
|
||||
throw e
|
||||
}
|
||||
},
|
||||
|
||||
fetchProjects: async () => {
|
||||
try {
|
||||
const projects = await api.getProjects()
|
||||
set({ projects })
|
||||
const savedId = get().currentProjectId
|
||||
if (projects.length > 0) {
|
||||
const targetId = savedId && projects.find(p => p.id === savedId) ? savedId : projects[0]!.id
|
||||
await get().switchProject(targetId)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load project list:', e)
|
||||
}
|
||||
},
|
||||
|
||||
createProject: async (name) => {
|
||||
const project = await api.createProject(name)
|
||||
set((s) => ({ projects: [project, ...s.projects] }))
|
||||
await get().switchProject(project.id)
|
||||
return project
|
||||
},
|
||||
|
||||
switchProject: async (id) => {
|
||||
const project = get().projects.find(p => p.id === id)
|
||||
set({
|
||||
currentProjectId: id,
|
||||
currentProject: project?.name || '',
|
||||
bugs: [],
|
||||
selectedBugId: null,
|
||||
loading: true,
|
||||
})
|
||||
api.saveSettings({ currentProjectId: id }).catch(() => {})
|
||||
await get().fetchBugs()
|
||||
},
|
||||
|
||||
deleteProject: async (id) => {
|
||||
await api.deleteProject(id)
|
||||
const remaining = get().projects.filter(p => p.id !== id)
|
||||
set({ projects: remaining })
|
||||
if (get().currentProjectId === id) {
|
||||
if (remaining.length > 0) {
|
||||
await get().switchProject(remaining[0]!.id)
|
||||
} else {
|
||||
set({ currentProjectId: '', currentProject: '', bugs: [], selectedBugId: null })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
selectBug: (id) => set({ selectedBugId: id, viewMode: 'edit' }),
|
||||
clearSelection: () => set({ selectedBugId: null }),
|
||||
setTheme: (theme) => {
|
||||
if (theme === 'light') {
|
||||
document.documentElement.classList.add('light')
|
||||
} else {
|
||||
document.documentElement.classList.remove('light')
|
||||
}
|
||||
set({ theme })
|
||||
api.saveSettings({ theme }).catch(() => {})
|
||||
},
|
||||
setFilterTab: (tab) => {
|
||||
const { bugs, selectedBugId } = get()
|
||||
const filtered = filterByTab(bugs, tab)
|
||||
const stillVisible = filtered.some(b => b.id === selectedBugId)
|
||||
set({
|
||||
filterTab: tab,
|
||||
selectedBugId: stillVisible ? selectedBugId : (filtered[0]?.id ?? null),
|
||||
})
|
||||
api.saveSettings({ filterTab: tab }).catch(() => {})
|
||||
},
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
setViewMode: (mode) => set({ viewMode: mode }),
|
||||
setSettingsOpen: (open) => set({ settingsOpen: open }),
|
||||
setShortcutsOpen: (open) => set({ shortcutsOpen: open }),
|
||||
setCompareMode: (on) => set({ compareMode: on }),
|
||||
setCompareLeft: (idx) => set({ compareLeft: idx }),
|
||||
setCompareRight: (idx) => set({ compareRight: idx }),
|
||||
}))
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { Bug } from '../stores'
|
||||
|
||||
// Compare mode info
|
||||
export interface CompareInfo {
|
||||
enabled: boolean
|
||||
leftIndex: number
|
||||
rightIndex: number
|
||||
}
|
||||
|
||||
// Generate structured Markdown instruction
|
||||
export function generateInstruction(
|
||||
bug: Bug,
|
||||
locale: 'zh' | 'en' = 'en',
|
||||
compare?: CompareInfo,
|
||||
projectName?: string,
|
||||
): string {
|
||||
const isZh = locale === 'zh'
|
||||
const lines: string[] = []
|
||||
|
||||
// Title (with project name)
|
||||
const projectPrefix = projectName ? `[${projectName}] ` : ''
|
||||
lines.push(`# ${projectPrefix}Bug #${String(bug.number).padStart(3, '0')}: ${bug.title || (isZh ? '未命名 Bug' : 'Untitled Bug')}`)
|
||||
lines.push('')
|
||||
|
||||
// Bug description
|
||||
if (bug.description) {
|
||||
const hasHistory = bug.description.includes('## History')
|
||||
lines.push(`## ${isZh ? '问题描述' : 'Description'}`)
|
||||
lines.push(bug.description)
|
||||
lines.push('')
|
||||
if (hasHistory) {
|
||||
lines.push(isZh
|
||||
? '**注意:** 历史记录按时间排序,最新的评论反映当前需要解决的问题,较早的评论对应的问题可能已修复。请优先关注最新评论。'
|
||||
: '**Note:** History is sorted chronologically. The latest comments reflect the current issue to fix — earlier comments may already be resolved. Focus on the most recent entries.')
|
||||
} else {
|
||||
lines.push(isZh ? '**期望行为:** 请根据截图和描述修复此问题。' : '**Expected behavior:** Please fix this issue based on the screenshots and description.')
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Screenshots
|
||||
if (bug.screenshots.length > 0) {
|
||||
lines.push(`## ${isZh ? '问题截图' : 'Issue Screenshots'}`)
|
||||
lines.push('')
|
||||
|
||||
// Compare mode: annotate current vs expected state
|
||||
if (compare?.enabled && bug.screenshots.length >= 2) {
|
||||
const safeLeft = Math.max(0, Math.min(compare.leftIndex, bug.screenshots.length - 1))
|
||||
const safeRight = Math.max(0, Math.min(compare.rightIndex, bug.screenshots.length - 1))
|
||||
const leftSS = bug.screenshots[safeLeft]
|
||||
const rightSS = bug.screenshots[safeRight]
|
||||
if (leftSS) {
|
||||
const leftLabel = leftSS.name || `${isZh ? '截图' : 'Screenshot'} ${compare.leftIndex + 1}`
|
||||
lines.push(`### ${isZh ? '当前效果' : 'Current State'}(${leftLabel})`)
|
||||
lines.push(``)
|
||||
lines.push('')
|
||||
}
|
||||
if (rightSS) {
|
||||
const rightLabel = rightSS.name || `${isZh ? '截图' : 'Screenshot'} ${compare.rightIndex + 1}`
|
||||
lines.push(`### ${isZh ? '期望效果' : 'Expected Result'}(${rightLabel})`)
|
||||
lines.push(``)
|
||||
lines.push('')
|
||||
}
|
||||
// Remaining screenshots as supplementary
|
||||
bug.screenshots.forEach((ss, i) => {
|
||||
if (i === safeLeft || i === safeRight) return
|
||||
const label = ss.name || `${isZh ? '截图' : 'Screenshot'} ${i + 1}`
|
||||
lines.push(`### ${label}`)
|
||||
lines.push(``)
|
||||
lines.push('')
|
||||
})
|
||||
} else {
|
||||
bug.screenshots.forEach((ss, i) => {
|
||||
const label = ss.name || `${isZh ? '截图' : 'Screenshot'} ${i + 1}`
|
||||
lines.push(`### ${label}`)
|
||||
lines.push(``)
|
||||
lines.push(isZh ? `_${label} - 请仔细查看标注区域_` : `_${label} - Please examine the annotated areas_`)
|
||||
lines.push('')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Environment info
|
||||
if (bug.pagePath || bug.device || bug.browser) {
|
||||
lines.push(`## ${isZh ? '环境信息' : 'Environment Info'}`)
|
||||
if (bug.pagePath) lines.push(`- ${isZh ? '页面路径' : 'Page'}: ${bug.pagePath}`)
|
||||
if (bug.device) lines.push(`- ${isZh ? '设备' : 'Device'}: ${bug.device}`)
|
||||
if (bug.browser) lines.push(`- ${isZh ? '浏览器' : 'Browser'}: ${bug.browser}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Related files
|
||||
if (bug.relatedFiles.length > 0) {
|
||||
lines.push(`## ${isZh ? '相关文件' : 'Related Files'}`)
|
||||
bug.relatedFiles.forEach((f) => lines.push(`- ${f}`))
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
// Priority
|
||||
const priorityMap = {
|
||||
high: isZh ? '高' : 'High',
|
||||
medium: isZh ? '中' : 'Medium',
|
||||
low: isZh ? '低' : 'Low',
|
||||
}
|
||||
lines.push(`## ${isZh ? '优先级' : 'Priority'}`)
|
||||
lines.push(priorityMap[bug.priority])
|
||||
lines.push('')
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import { z } from 'zod'
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
|
||||
// Database path: ~/.bugpack/data/ (shared with server)
|
||||
const DATA_DIR = path.join(os.homedir(), '.bugpack', 'data')
|
||||
const DB_PATH = path.join(DATA_DIR, 'bugpack.db')
|
||||
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
||||
|
||||
// Map file extension to MIME type
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif',
|
||||
webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf',
|
||||
doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
txt: 'text/plain', csv: 'text/csv', html: 'text/html', md: 'text/markdown',
|
||||
json: 'application/json', xml: 'application/xml', zip: 'application/zip',
|
||||
}
|
||||
function extToMime(ext: string): string {
|
||||
return MIME_MAP[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
function getDb() {
|
||||
if (!fs.existsSync(DB_PATH)) {
|
||||
throw new Error(`Database not found: ${DB_PATH}, please start BugPack Server first`)
|
||||
}
|
||||
return new Database(DB_PATH, { readonly: true })
|
||||
}
|
||||
|
||||
const server = new McpServer({
|
||||
name: 'bugpack',
|
||||
version: '1.0.0',
|
||||
})
|
||||
|
||||
// Find project ID by project name
|
||||
function findProjectId(db: Database.Database, projectName?: string): string | null {
|
||||
if (!projectName) return null
|
||||
const project: any = db.prepare('SELECT id FROM projects WHERE name = ?').get(projectName)
|
||||
return project?.id || null
|
||||
}
|
||||
|
||||
// Find bug by bug_number + project (number is unique within project)
|
||||
function findBug(db: Database.Database, bugNumber: number, projectName?: string): any {
|
||||
if (projectName) {
|
||||
const projectId = findProjectId(db, projectName)
|
||||
if (projectId) {
|
||||
return db.prepare('SELECT * FROM bugs WHERE number = ? AND project_id = ?').get(bugNumber, projectId)
|
||||
}
|
||||
return null
|
||||
}
|
||||
// No project name: return first match (backward compatible)
|
||||
return db.prepare('SELECT * FROM bugs WHERE number = ?').get(bugNumber)
|
||||
}
|
||||
|
||||
// Get project name
|
||||
function getProjectName(db: Database.Database, projectId: string): string {
|
||||
const project: any = db.prepare('SELECT name FROM projects WHERE id = ?').get(projectId)
|
||||
return project?.name || ''
|
||||
}
|
||||
|
||||
// ---- list_bugs: list all bugs to fix ----
|
||||
server.tool(
|
||||
'list_bugs',
|
||||
'List all bugs grouped by project. Filter by project name or status',
|
||||
{
|
||||
status: z.string().optional().describe('Filter by status: pending/annotating/generated/fixed/closed'),
|
||||
project: z.string().optional().describe('Filter by project name'),
|
||||
},
|
||||
async ({ status, project }) => {
|
||||
const db = getDb()
|
||||
try {
|
||||
const conditions: string[] = []
|
||||
const params: any[] = []
|
||||
|
||||
const validStatuses = ['pending', 'annotating', 'generated', 'fixed', 'closed']
|
||||
if (status) {
|
||||
if (!validStatuses.includes(status)) {
|
||||
return { content: [{ type: 'text', text: `Invalid status: ${status}` }] }
|
||||
}
|
||||
conditions.push('b.status = ?')
|
||||
params.push(status)
|
||||
}
|
||||
if (project) {
|
||||
const projectId = findProjectId(db, project)
|
||||
if (!projectId) {
|
||||
return { content: [{ type: 'text', text: `Project "${project}" not found` }] }
|
||||
}
|
||||
conditions.push('b.project_id = ?')
|
||||
params.push(projectId)
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? ' WHERE ' + conditions.join(' AND ') : ''
|
||||
const query = `
|
||||
SELECT b.id, b.number, b.title, b.status, b.priority, b.project_id, b.created_at,
|
||||
(SELECT COUNT(*) FROM screenshots WHERE bug_id = b.id) as screenshot_count
|
||||
FROM bugs b${where}
|
||||
ORDER BY b.project_id, b.number DESC
|
||||
`
|
||||
const bugs = db.prepare(query).all(...params) as any[]
|
||||
|
||||
if (bugs.length === 0) {
|
||||
return { content: [{ type: 'text', text: 'No bugs found' }] }
|
||||
}
|
||||
|
||||
// Group by project
|
||||
const groups: Record<string, { name: string; bugs: any[] }> = {}
|
||||
for (const b of bugs) {
|
||||
if (!groups[b.project_id]) {
|
||||
groups[b.project_id] = { name: getProjectName(db, b.project_id), bugs: [] }
|
||||
}
|
||||
groups[b.project_id]!.bugs.push(b)
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
for (const group of Object.values(groups)) {
|
||||
lines.push(`## ${group.name || 'Uncategorized'}`)
|
||||
for (const b of group.bugs) {
|
||||
lines.push(` #${String(b.number).padStart(3, '0')} [${b.status}] [${b.priority}] ${b.title} (${b.screenshot_count} screenshots)`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text', text: lines.join('\n').trim() }],
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ---- get_bug_context: get full bug context ----
|
||||
server.tool(
|
||||
'get_bug_context',
|
||||
'Get full bug context (annotated screenshots + fix instructions) for AI repair',
|
||||
{
|
||||
bug_id: z.string().optional().describe('Bug ID'),
|
||||
bug_number: z.coerce.number().optional().describe('Bug number within project, e.g. 1, 2, 3'),
|
||||
project: z.string().optional().describe('Project name to locate bug number within'),
|
||||
},
|
||||
async ({ bug_id, bug_number, project }) => {
|
||||
const db = getDb()
|
||||
try {
|
||||
let bug: any
|
||||
if (bug_id) {
|
||||
bug = db.prepare('SELECT * FROM bugs WHERE id = ?').get(bug_id)
|
||||
} else if (bug_number) {
|
||||
bug = findBug(db, bug_number, project)
|
||||
}
|
||||
|
||||
if (!bug) {
|
||||
return { content: [{ type: 'text', text: 'Bug not found' }] }
|
||||
}
|
||||
|
||||
const screenshots = db.prepare('SELECT * FROM screenshots WHERE bug_id = ? ORDER BY sort_order').all(bug.id) as any[]
|
||||
const projectName = getProjectName(db, bug.project_id)
|
||||
|
||||
let relatedFiles: string[] = []
|
||||
try { relatedFiles = JSON.parse(bug.related_files || '[]') } catch { /* ignore */ }
|
||||
|
||||
// Generate structured Markdown
|
||||
const lines: string[] = []
|
||||
const projectPrefix = projectName ? `[${projectName}] ` : ''
|
||||
lines.push(`# ${projectPrefix}Bug #${String(bug.number).padStart(3, '0')}: ${bug.title}`)
|
||||
lines.push('')
|
||||
|
||||
if (bug.description) {
|
||||
lines.push('## Description')
|
||||
lines.push(bug.description)
|
||||
lines.push('')
|
||||
if (bug.description.includes('## History')) {
|
||||
lines.push('**Note:** History is sorted chronologically. The latest comments reflect the current issue to fix — earlier comments may already be resolved. Focus on the most recent entries.')
|
||||
lines.push('')
|
||||
}
|
||||
}
|
||||
|
||||
if (screenshots.length > 0) {
|
||||
lines.push('## Screenshots')
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (bug.page_path || bug.device || bug.browser) {
|
||||
lines.push('## Environment')
|
||||
if (bug.page_path) lines.push(`- Page: ${bug.page_path}`)
|
||||
if (bug.device) lines.push(`- Device: ${bug.device}`)
|
||||
if (bug.browser) lines.push(`- Browser: ${bug.browser}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (relatedFiles.length > 0) {
|
||||
lines.push('## Related Files')
|
||||
relatedFiles.forEach((f: string) => lines.push(`- ${f}`))
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push(`## Priority`)
|
||||
lines.push(bug.priority)
|
||||
|
||||
// Build response content (text + images)
|
||||
const content: any[] = [{ type: 'text', text: lines.join('\n') }]
|
||||
|
||||
// Attach evidence files
|
||||
const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']
|
||||
const MAX_BASE64_SIZE = 5 * 1024 * 1024 // 5MB, larger images use path
|
||||
for (const ss of screenshots) {
|
||||
const annotatedPath = ss.annotated_filename ? path.join(UPLOADS_DIR, ss.annotated_filename) : ''
|
||||
const originalPath = path.join(UPLOADS_DIR, ss.filename)
|
||||
const filePath = (annotatedPath && fs.existsSync(annotatedPath)) ? annotatedPath : originalPath
|
||||
if (!fs.existsSync(filePath)) continue
|
||||
|
||||
const ext = path.extname(filePath).slice(1).toLowerCase()
|
||||
const fileSize = fs.statSync(filePath).size
|
||||
|
||||
if (IMAGE_EXTS.includes(ext) && fileSize <= MAX_BASE64_SIZE) {
|
||||
const data = fs.readFileSync(filePath)
|
||||
const mimeType = extToMime(ext)
|
||||
content.push({ type: 'image', data: data.toString('base64'), mimeType })
|
||||
} else {
|
||||
content.push({ type: 'text', text: `[${ss.name}] ${filePath}` })
|
||||
}
|
||||
}
|
||||
|
||||
return { content }
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ---- get_bug_screenshot: get bug screenshot ----
|
||||
server.tool(
|
||||
'get_bug_screenshot',
|
||||
'Get annotated screenshot of a bug',
|
||||
{
|
||||
bug_number: z.coerce.number().describe('Bug number within project'),
|
||||
screenshot_index: z.coerce.number().optional().describe('Screenshot index (0-based), defaults to first'),
|
||||
project: z.string().optional().describe('Project name'),
|
||||
},
|
||||
async ({ bug_number, screenshot_index = 0, project }) => {
|
||||
const db = getDb()
|
||||
let bug: any
|
||||
let screenshots: any[]
|
||||
try {
|
||||
bug = findBug(db, bug_number, project)
|
||||
if (!bug) {
|
||||
return { content: [{ type: 'text', text: 'Bug not found' }] }
|
||||
}
|
||||
screenshots = db.prepare('SELECT * FROM screenshots WHERE bug_id = ? ORDER BY sort_order').all(bug.id) as any[]
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
|
||||
const ss = screenshots[screenshot_index]
|
||||
if (!ss) {
|
||||
return { content: [{ type: 'text', text: `Screenshot #${screenshot_index} not found` }] }
|
||||
}
|
||||
|
||||
// Prefer annotated render image
|
||||
const annotatedPath = ss.annotated_filename ? path.join(UPLOADS_DIR, ss.annotated_filename) : ''
|
||||
const originalPath = path.join(UPLOADS_DIR, ss.filename)
|
||||
const filePath = (annotatedPath && fs.existsSync(annotatedPath)) ? annotatedPath : originalPath
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { content: [{ type: 'text', text: 'File not found' }] }
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).slice(1).toLowerCase()
|
||||
const label = `Bug #${String(bug.number).padStart(3, '0')} - ${ss.name}${ss.annotated_filename ? ' (annotated)' : ''}`
|
||||
const IMAGE_EXTS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg']
|
||||
const fileSize = fs.statSync(filePath).size
|
||||
|
||||
if (IMAGE_EXTS.includes(ext) && fileSize <= 5 * 1024 * 1024) {
|
||||
const data = fs.readFileSync(filePath)
|
||||
const mimeType = extToMime(ext)
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: label },
|
||||
{ type: 'image', data: data.toString('base64'), mimeType },
|
||||
],
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
content: [
|
||||
{ type: 'text', text: `${label}\n${filePath}` },
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ---- mark_bug_status: update status ----
|
||||
server.tool(
|
||||
'mark_bug_status',
|
||||
'Update bug status (pending/fixed/closed etc.)',
|
||||
{
|
||||
bug_number: z.coerce.number().describe('Bug number within project'),
|
||||
status: z.enum(['pending', 'annotating', 'generated', 'fixed', 'closed']).describe('New status'),
|
||||
project: z.string().optional().describe('Project name'),
|
||||
},
|
||||
async ({ bug_number, status, project }) => {
|
||||
const dbPath = path.join(DATA_DIR, 'bugpack.db')
|
||||
const db = new Database(dbPath)
|
||||
try {
|
||||
const bug: any = findBug(db, bug_number, project)
|
||||
if (!bug) {
|
||||
return { content: [{ type: 'text', text: 'Bug not found' }] }
|
||||
}
|
||||
db.prepare("UPDATE bugs SET status = ?, updated_at = datetime('now') WHERE id = ?").run(status, bug.id)
|
||||
const projectName = getProjectName(db, bug.project_id)
|
||||
const prefix = projectName ? `[${projectName}] ` : ''
|
||||
return {
|
||||
content: [{ type: 'text', text: `${prefix}Bug #${String(bug_number).padStart(3, '0')} status updated to: ${status}` }],
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ---- add_fix_note: add fix notes ----
|
||||
server.tool(
|
||||
'add_fix_note',
|
||||
'Add fix notes to bug description after AI repair',
|
||||
{
|
||||
bug_number: z.coerce.number().describe('Bug number within project'),
|
||||
note: z.string().describe('Fix notes'),
|
||||
project: z.string().optional().describe('Project name'),
|
||||
},
|
||||
async ({ bug_number, note, project }) => {
|
||||
const dbPath = path.join(DATA_DIR, 'bugpack.db')
|
||||
const db = new Database(dbPath)
|
||||
try {
|
||||
const bug: any = findBug(db, bug_number, project)
|
||||
if (!bug) {
|
||||
return { content: [{ type: 'text', text: 'Bug not found' }] }
|
||||
}
|
||||
|
||||
const newDesc = bug.description
|
||||
? `${bug.description}\n\n---\n## Fix Notes\n${note}`
|
||||
: `## Fix Notes\n${note}`
|
||||
|
||||
db.prepare("UPDATE bugs SET description = ?, updated_at = datetime('now') WHERE id = ?").run(newDesc, bug.id)
|
||||
const projectName = getProjectName(db, bug.project_id)
|
||||
const prefix = projectName ? `[${projectName}] ` : ''
|
||||
return {
|
||||
content: [{ type: 'text', text: `${prefix}Fix notes added to Bug #${String(bug_number).padStart(3, '0')}` }],
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Start
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport()
|
||||
await server.connect(transport)
|
||||
}
|
||||
|
||||
main().catch((err) => { console.error('MCP server failed to start:', err); process.exit(1) })
|
||||
@@ -0,0 +1,108 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import os from 'os'
|
||||
|
||||
const DATA_DIR = path.join(os.homedir(), '.bugpack', 'data')
|
||||
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads')
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true })
|
||||
fs.mkdirSync(UPLOADS_DIR, { recursive: true })
|
||||
|
||||
const db = new Database(path.join(DATA_DIR, 'bugpack.db'))
|
||||
|
||||
db.pragma('journal_mode = WAL')
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bugs (
|
||||
id TEXT PRIMARY KEY,
|
||||
number INTEGER UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
page_path TEXT NOT NULL DEFAULT '',
|
||||
device TEXT NOT NULL DEFAULT '',
|
||||
browser TEXT NOT NULL DEFAULT '',
|
||||
related_files TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS screenshots (
|
||||
id TEXT PRIMARY KEY,
|
||||
bug_id TEXT NOT NULL REFERENCES bugs(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
annotated INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
annotations TEXT NOT NULL DEFAULT '[]',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_screenshots_bug_id ON screenshots(bug_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`)
|
||||
|
||||
// Migration: add project_id
|
||||
try {
|
||||
db.exec(`ALTER TABLE bugs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'`)
|
||||
} catch {
|
||||
// Column already exists, ignore
|
||||
}
|
||||
|
||||
// Migration: make number unique per project
|
||||
try {
|
||||
const hasOldUnique = db.prepare(
|
||||
`SELECT sql FROM sqlite_master WHERE type='table' AND name='bugs'`
|
||||
).get() as any
|
||||
if (hasOldUnique?.sql?.includes('number INTEGER UNIQUE')) {
|
||||
// Disable FK to prevent cascade on DROP
|
||||
db.pragma('foreign_keys = OFF')
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS bugs_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
number INTEGER NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
priority TEXT NOT NULL DEFAULT 'medium',
|
||||
page_path TEXT NOT NULL DEFAULT '',
|
||||
device TEXT NOT NULL DEFAULT '',
|
||||
browser TEXT NOT NULL DEFAULT '',
|
||||
related_files TEXT NOT NULL DEFAULT '[]',
|
||||
project_id TEXT NOT NULL DEFAULT 'default',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
INSERT INTO bugs_new SELECT id, number, title, description, status, priority, page_path, device, browser, related_files, project_id, created_at, updated_at FROM bugs;
|
||||
DROP TABLE bugs;
|
||||
ALTER TABLE bugs_new RENAME TO bugs;
|
||||
`)
|
||||
db.pragma('foreign_keys = ON')
|
||||
}
|
||||
db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_bugs_project_number ON bugs(project_id, number)`)
|
||||
} catch {
|
||||
// Already migrated, ignore
|
||||
}
|
||||
|
||||
// Migration: add annotated_filename
|
||||
try {
|
||||
db.exec(`ALTER TABLE screenshots ADD COLUMN annotated_filename TEXT NOT NULL DEFAULT ''`)
|
||||
} catch {
|
||||
// Column already exists, ignore
|
||||
}
|
||||
|
||||
|
||||
export { db, DATA_DIR, UPLOADS_DIR }
|
||||
@@ -0,0 +1,63 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { bugsRouter } from './routes/bugs.js'
|
||||
import { settingsRouter } from './routes/settings.js'
|
||||
import { projectsRouter } from './routes/projects.js'
|
||||
import { zentaoRouter } from './routes/zentao.js'
|
||||
import { jiraRouter } from './routes/jira.js'
|
||||
import { linearRouter } from './routes/linear.js'
|
||||
import { tapdRouter } from './routes/tapd.js'
|
||||
import { UPLOADS_DIR } from './db.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const app = express()
|
||||
const PORT = parseInt(process.env.PORT || '3457', 10)
|
||||
|
||||
app.use(cors())
|
||||
app.use(express.json({ limit: '50mb' }))
|
||||
|
||||
// Security headers
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff')
|
||||
res.setHeader('X-Frame-Options', 'DENY')
|
||||
next()
|
||||
})
|
||||
|
||||
// Static files: screenshots
|
||||
app.use('/uploads', express.static(UPLOADS_DIR, { dotfiles: 'deny' }))
|
||||
|
||||
// API routes
|
||||
app.use('/api/bugs', bugsRouter)
|
||||
app.use('/api/settings', settingsRouter)
|
||||
app.use('/api/projects', projectsRouter)
|
||||
app.use('/api/zentao', zentaoRouter)
|
||||
app.use('/api/jira', jiraRouter)
|
||||
app.use('/api/linear', linearRouter)
|
||||
app.use('/api/tapd', tapdRouter)
|
||||
|
||||
// Health check
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', version: '1.0.0' })
|
||||
})
|
||||
|
||||
// Production: serve frontend static files
|
||||
// Try dist/client first (built files), fallback to src/client (dev)
|
||||
const clientDir = path.resolve(__dirname, '../../dist/client')
|
||||
const devClientDir = path.resolve(__dirname, '../client')
|
||||
const staticDir = fs.existsSync(clientDir) ? clientDir : devClientDir
|
||||
|
||||
if (fs.existsSync(staticDir)) {
|
||||
app.use(express.static(staticDir))
|
||||
app.get('*', (req, res) => {
|
||||
if (!req.path.startsWith('/api') && !req.path.startsWith('/uploads')) {
|
||||
res.sendFile(path.join(staticDir, 'index.html'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`BugPack server running at http://localhost:${PORT}`)
|
||||
})
|
||||
@@ -0,0 +1,368 @@
|
||||
import { Router } from 'express'
|
||||
import multer from 'multer'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import { db, UPLOADS_DIR } from '../db.js'
|
||||
|
||||
export const bugsRouter = Router()
|
||||
|
||||
// Safe JSON parse
|
||||
function safeJsonParse(str: string | null | undefined, fallback: any = []) {
|
||||
if (!str) return fallback
|
||||
try { return JSON.parse(str) } catch { return fallback }
|
||||
}
|
||||
|
||||
// Get the project upload directory for a bug
|
||||
function getProjectUploadsDir(bugId: string): string {
|
||||
const bug: any = db.prepare('SELECT project_id FROM bugs WHERE id = ?').get(bugId)
|
||||
const projectId = bug?.project_id || 'default'
|
||||
const project: any = db.prepare('SELECT name FROM projects WHERE id = ?').get(projectId)
|
||||
const projectName = (project?.name || 'default').replace(/[<>:"/\\|?*]/g, '_')
|
||||
const dir = path.join(UPLOADS_DIR, projectName)
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
// File upload config
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, _file, cb) => {
|
||||
const dir = getProjectUploadsDir(req.params.id as string)
|
||||
cb(null, dir)
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname) || '.png'
|
||||
cb(null, `${uuid()}${ext}`)
|
||||
},
|
||||
})
|
||||
const ALLOWED_MIMES = [
|
||||
// Images
|
||||
'image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/bmp', 'image/svg+xml',
|
||||
// Documents
|
||||
'application/pdf',
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
// Text
|
||||
'text/plain', 'text/csv', 'text/html', 'text/markdown',
|
||||
// Data
|
||||
'application/json', 'application/xml', 'text/xml',
|
||||
// Archives (for logs etc.)
|
||||
'application/zip',
|
||||
]
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: 20 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
cb(null, ALLOWED_MIMES.includes(file.mimetype))
|
||||
},
|
||||
})
|
||||
|
||||
// ---- List all bugs ----
|
||||
bugsRouter.get('/', (req, res) => {
|
||||
const projectId = (req.query.project_id as string) || undefined
|
||||
const sql = projectId
|
||||
? `SELECT b.*, (SELECT COUNT(*) FROM screenshots WHERE bug_id = b.id) as screenshot_count FROM bugs b WHERE b.project_id = ? ORDER BY b.created_at DESC`
|
||||
: `SELECT b.*, (SELECT COUNT(*) FROM screenshots WHERE bug_id = b.id) as screenshot_count FROM bugs b ORDER BY b.created_at DESC`
|
||||
const bugs = projectId ? db.prepare(sql).all(projectId) : db.prepare(sql).all()
|
||||
|
||||
// Get screenshots for each bug
|
||||
const getScreenshots = db.prepare('SELECT * FROM screenshots WHERE bug_id = ? ORDER BY sort_order')
|
||||
|
||||
const result = bugs.map((bug: any) => ({
|
||||
...bug,
|
||||
relatedFiles: safeJsonParse(bug.related_files, []),
|
||||
screenshots: getScreenshots.all(bug.id).map((s: any) => ({
|
||||
id: s.id,
|
||||
url: `/uploads/${s.filename}`,
|
||||
name: s.name,
|
||||
annotated: !!s.annotated,
|
||||
annotations: safeJsonParse(s.annotations, []),
|
||||
})),
|
||||
}))
|
||||
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
// ---- Get single bug ----
|
||||
bugsRouter.get('/:id', (req, res) => {
|
||||
const bug: any = db.prepare('SELECT * FROM bugs WHERE id = ?').get(req.params.id)
|
||||
if (!bug) return res.status(404).json({ error: 'Bug not found' })
|
||||
|
||||
const screenshots = db.prepare('SELECT * FROM screenshots WHERE bug_id = ? ORDER BY sort_order').all(bug.id)
|
||||
|
||||
res.json({
|
||||
...bug,
|
||||
relatedFiles: safeJsonParse(bug.related_files, []),
|
||||
screenshots: screenshots.map((s: any) => ({
|
||||
id: s.id,
|
||||
url: `/uploads/${s.filename}`,
|
||||
name: s.name,
|
||||
annotated: !!s.annotated,
|
||||
annotations: safeJsonParse(s.annotations, []),
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Create bug ----
|
||||
bugsRouter.post('/', (req, res) => {
|
||||
const id = uuid()
|
||||
const { title = '', description = '', priority = 'medium', pagePath = '', device = '', browser = '', project_id = 'default' } = req.body
|
||||
|
||||
const maxNum: any = db.prepare('SELECT MAX(number) as n FROM bugs WHERE project_id = ?').get(project_id)
|
||||
const number = (maxNum?.n || 0) + 1
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO bugs (id, number, title, description, priority, page_path, device, browser, project_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, number, title, description, priority, pagePath, device, browser, project_id)
|
||||
|
||||
const bug = db.prepare('SELECT * FROM bugs WHERE id = ?').get(id) as Record<string, unknown>
|
||||
res.status(201).json({ ...bug, relatedFiles: [], screenshots: [] })
|
||||
})
|
||||
|
||||
// ---- Batch update status ----
|
||||
bugsRouter.patch('/batch/status', (req, res) => {
|
||||
const { ids, status } = req.body as { ids: string[]; status: string }
|
||||
if (!Array.isArray(ids) || !status) return res.status(400).json({ error: 'Invalid parameters' })
|
||||
const stmt = db.prepare("UPDATE bugs SET status = ?, updated_at = datetime('now') WHERE id = ?")
|
||||
const updateAll = db.transaction(() => {
|
||||
for (const id of ids) stmt.run(status, id)
|
||||
})
|
||||
updateAll()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ---- Batch delete ----
|
||||
bugsRouter.post('/batch/delete', (req, res) => {
|
||||
const { ids } = req.body as { ids: string[] }
|
||||
if (!Array.isArray(ids)) return res.status(400).json({ error: 'Invalid parameters' })
|
||||
const deleteAll = db.transaction(() => {
|
||||
for (const id of ids) {
|
||||
const screenshots: any[] = db.prepare('SELECT filename, annotated_filename FROM screenshots WHERE bug_id = ?').all(id)
|
||||
for (const ss of screenshots) {
|
||||
const filePath = path.join(UPLOADS_DIR, ss.filename)
|
||||
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath) } catch { /* ignore file deletion error */ }
|
||||
if (ss.annotated_filename) {
|
||||
const annotatedPath = path.join(UPLOADS_DIR, ss.annotated_filename)
|
||||
try { if (fs.existsSync(annotatedPath)) fs.unlinkSync(annotatedPath) } catch { /* ignore file deletion error */ }
|
||||
}
|
||||
}
|
||||
db.prepare('DELETE FROM screenshots WHERE bug_id = ?').run(id)
|
||||
db.prepare('DELETE FROM bugs WHERE id = ?').run(id)
|
||||
}
|
||||
})
|
||||
deleteAll()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ---- Update bug ----
|
||||
bugsRouter.patch('/:id', (req, res) => {
|
||||
const bug: any = db.prepare('SELECT * FROM bugs WHERE id = ?').get(req.params.id)
|
||||
if (!bug) return res.status(404).json({ error: 'Bug not found' })
|
||||
|
||||
const { title, description, status, priority, pagePath, device, browser, relatedFiles } = req.body
|
||||
|
||||
const updates: string[] = []
|
||||
const values: any[] = []
|
||||
|
||||
if (title !== undefined) { updates.push('title = ?'); values.push(title) }
|
||||
if (description !== undefined) { updates.push('description = ?'); values.push(description) }
|
||||
if (status !== undefined) { updates.push('status = ?'); values.push(status) }
|
||||
if (priority !== undefined) { updates.push('priority = ?'); values.push(priority) }
|
||||
if (pagePath !== undefined) { updates.push('page_path = ?'); values.push(pagePath) }
|
||||
if (device !== undefined) { updates.push('device = ?'); values.push(device) }
|
||||
if (browser !== undefined) { updates.push('browser = ?'); values.push(browser) }
|
||||
if (relatedFiles !== undefined) { updates.push('related_files = ?'); values.push(JSON.stringify(relatedFiles)) }
|
||||
|
||||
if (updates.length > 0) {
|
||||
updates.push("updated_at = datetime('now')")
|
||||
values.push(req.params.id)
|
||||
db.prepare(`UPDATE bugs SET ${updates.join(', ')} WHERE id = ?`).run(...values)
|
||||
}
|
||||
|
||||
// Return full bug data (with screenshots and relatedFiles)
|
||||
const updated: any = db.prepare('SELECT * FROM bugs WHERE id = ?').get(req.params.id)
|
||||
const screenshots = db.prepare('SELECT * FROM screenshots WHERE bug_id = ? ORDER BY sort_order').all(req.params.id)
|
||||
res.json({
|
||||
...updated,
|
||||
relatedFiles: safeJsonParse(updated.related_files, []),
|
||||
screenshots: screenshots.map((s: any) => ({
|
||||
id: s.id,
|
||||
url: `/uploads/${s.filename}`,
|
||||
name: s.name,
|
||||
annotated: !!s.annotated,
|
||||
annotations: safeJsonParse(s.annotations, []),
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Delete bug ----
|
||||
bugsRouter.delete('/:id', (req, res) => {
|
||||
const bug: any = db.prepare('SELECT * FROM bugs WHERE id = ?').get(req.params.id)
|
||||
if (!bug) return res.status(404).json({ error: 'Bug not found' })
|
||||
|
||||
// Delete associated screenshot files from disk
|
||||
const screenshots: any[] = db.prepare('SELECT filename, annotated_filename FROM screenshots WHERE bug_id = ?').all(req.params.id)
|
||||
for (const ss of screenshots) {
|
||||
const filePath = path.join(UPLOADS_DIR, ss.filename)
|
||||
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath) } catch { /* ignore file deletion error */ }
|
||||
if (ss.annotated_filename) {
|
||||
const annotatedPath = path.join(UPLOADS_DIR, ss.annotated_filename)
|
||||
try { if (fs.existsSync(annotatedPath)) fs.unlinkSync(annotatedPath) } catch { /* ignore file deletion error */ }
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM screenshots WHERE bug_id = ?').run(req.params.id)
|
||||
db.prepare('DELETE FROM bugs WHERE id = ?').run(req.params.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ---- Upload screenshot ----
|
||||
bugsRouter.post('/:id/screenshots', upload.single('file'), (req, res) => {
|
||||
const bug: any = db.prepare('SELECT * FROM bugs WHERE id = ?').get(req.params.id)
|
||||
if (!bug) return res.status(404).json({ error: 'Bug not found' })
|
||||
if (!req.file) return res.status(400).json({ error: 'No file selected' })
|
||||
|
||||
const id = uuid()
|
||||
const maxOrder: any = db.prepare('SELECT MAX(sort_order) as n FROM screenshots WHERE bug_id = ?').get(req.params.id)
|
||||
const sortOrder = (maxOrder?.n || 0) + 1
|
||||
const name = req.body.name || req.file.originalname
|
||||
|
||||
// Calculate path relative to UPLOADS_DIR
|
||||
const relPath = path.relative(UPLOADS_DIR, req.file.path).replace(/\\/g, '/')
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO screenshots (id, bug_id, filename, original_name, name, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id, req.params.id, relPath, req.file.originalname, name, sortOrder)
|
||||
|
||||
res.status(201).json({
|
||||
id,
|
||||
url: `/uploads/${relPath}`,
|
||||
name,
|
||||
annotated: false,
|
||||
annotations: [],
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Paste screenshot (Base64) ----
|
||||
bugsRouter.post('/:id/screenshots/paste', (req, res) => {
|
||||
const bug: any = db.prepare('SELECT * FROM bugs WHERE id = ?').get(req.params.id)
|
||||
if (!bug) return res.status(404).json({ error: 'Bug not found' })
|
||||
|
||||
const { dataUrl, name = 'Pasted screenshot' } = req.body
|
||||
if (!dataUrl) return res.status(400).json({ error: 'Missing image data' })
|
||||
|
||||
// Parse base64
|
||||
const matches = dataUrl.match(/^data:image\/([\w+]+);base64,(.+)$/)
|
||||
if (!matches) return res.status(400).json({ error: 'Invalid image format' })
|
||||
|
||||
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1]
|
||||
const buffer = Buffer.from(matches[2], 'base64')
|
||||
const filename = `${uuid()}.${ext}`
|
||||
const projectDir = getProjectUploadsDir(req.params.id)
|
||||
|
||||
const fullPath = path.join(projectDir, filename)
|
||||
const relPath = path.relative(UPLOADS_DIR, fullPath).replace(/\\/g, '/')
|
||||
if (relPath.includes('..')) return res.status(400).json({ error: 'Invalid file path' })
|
||||
|
||||
fs.writeFileSync(fullPath, buffer)
|
||||
|
||||
const id = uuid()
|
||||
const maxOrder: any = db.prepare('SELECT MAX(sort_order) as n FROM screenshots WHERE bug_id = ?').get(req.params.id)
|
||||
const sortOrder = (maxOrder?.n || 0) + 1
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO screenshots (id, bug_id, filename, original_name, name, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(id, req.params.id, relPath, 'paste.png', name, sortOrder)
|
||||
|
||||
res.status(201).json({
|
||||
id,
|
||||
url: `/uploads/${relPath}`,
|
||||
name,
|
||||
annotated: false,
|
||||
annotations: [],
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Update screenshot ----
|
||||
bugsRouter.patch('/:bugId/screenshots/:ssId', (req, res) => {
|
||||
const { name, annotated, annotations } = req.body
|
||||
if (name !== undefined) {
|
||||
db.prepare('UPDATE screenshots SET name = ? WHERE id = ? AND bug_id = ?').run(name, req.params.ssId, req.params.bugId)
|
||||
}
|
||||
if (annotated !== undefined) {
|
||||
db.prepare('UPDATE screenshots SET annotated = ? WHERE id = ? AND bug_id = ?').run(annotated ? 1 : 0, req.params.ssId, req.params.bugId)
|
||||
}
|
||||
if (annotations !== undefined) {
|
||||
db.prepare('UPDATE screenshots SET annotations = ? WHERE id = ? AND bug_id = ?').run(JSON.stringify(annotations), req.params.ssId, req.params.bugId)
|
||||
}
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ---- Save annotated render image ----
|
||||
bugsRouter.post('/:bugId/screenshots/:ssId/annotated-image', (req, res) => {
|
||||
const { dataUrl } = req.body as { dataUrl: string }
|
||||
if (!dataUrl || !dataUrl.startsWith('data:image/')) {
|
||||
return res.status(400).json({ error: 'Invalid image data' })
|
||||
}
|
||||
|
||||
const ss: any = db.prepare('SELECT filename, annotated_filename FROM screenshots WHERE id = ? AND bug_id = ?')
|
||||
.get(req.params.ssId, req.params.bugId)
|
||||
if (!ss) return res.status(404).json({ error: 'Screenshot not found' })
|
||||
|
||||
// Parse base64
|
||||
const matches = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/)
|
||||
if (!matches || !matches[1] || !matches[2]) return res.status(400).json({ error: 'Cannot parse image data' })
|
||||
const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1]
|
||||
const buffer = Buffer.from(matches[2], 'base64')
|
||||
|
||||
// Save to same directory as original image
|
||||
const dir = path.dirname(path.join(UPLOADS_DIR, ss.filename))
|
||||
const baseName = path.basename(ss.filename, path.extname(ss.filename))
|
||||
const annotatedFilename = path.dirname(ss.filename) + '/' + baseName + '_annotated.' + ext
|
||||
const annotatedPath = path.join(UPLOADS_DIR, annotatedFilename)
|
||||
|
||||
// Path security check
|
||||
const relCheck = path.relative(UPLOADS_DIR, annotatedPath)
|
||||
if (relCheck.includes('..')) return res.status(400).json({ error: 'Invalid file path' })
|
||||
|
||||
fs.mkdirSync(dir, { recursive: true })
|
||||
fs.writeFileSync(annotatedPath, buffer)
|
||||
|
||||
db.prepare('UPDATE screenshots SET annotated_filename = ? WHERE id = ? AND bug_id = ?')
|
||||
.run(annotatedFilename, req.params.ssId, req.params.bugId)
|
||||
|
||||
res.json({ ok: true, annotatedFilename })
|
||||
})
|
||||
|
||||
// ---- Reorder screenshots ----
|
||||
bugsRouter.put('/:bugId/screenshots/reorder', (req, res) => {
|
||||
const { order } = req.body as { order: string[] }
|
||||
if (!Array.isArray(order)) return res.status(400).json({ error: 'order array required' })
|
||||
const stmt = db.prepare('UPDATE screenshots SET sort_order = ? WHERE id = ? AND bug_id = ?')
|
||||
const updateAll = db.transaction(() => {
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
stmt.run(i, order[i], req.params.bugId)
|
||||
}
|
||||
})
|
||||
updateAll()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// ---- Delete screenshot ----
|
||||
bugsRouter.delete('/:bugId/screenshots/:ssId', (req, res) => {
|
||||
const ss: any = db.prepare('SELECT filename, annotated_filename FROM screenshots WHERE id = ? AND bug_id = ?').get(req.params.ssId, req.params.bugId)
|
||||
if (ss) {
|
||||
const filePath = path.join(UPLOADS_DIR, ss.filename)
|
||||
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath) } catch { /* ignore file deletion error */ }
|
||||
if (ss.annotated_filename) {
|
||||
const annotatedPath = path.join(UPLOADS_DIR, ss.annotated_filename)
|
||||
try { if (fs.existsSync(annotatedPath)) fs.unlinkSync(annotatedPath) } catch { /* ignore file deletion error */ }
|
||||
}
|
||||
}
|
||||
db.prepare('DELETE FROM screenshots WHERE id = ? AND bug_id = ?').run(req.params.ssId, req.params.bugId)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Router } from 'express'
|
||||
import crypto from 'crypto'
|
||||
import { db } from '../db.js'
|
||||
|
||||
export const jiraRouter = Router()
|
||||
|
||||
// Get Jira config
|
||||
function getJiraConfig() {
|
||||
const rows = db.prepare('SELECT key, value FROM settings WHERE key LIKE ?').all('jira%') as { key: string; value: string }[]
|
||||
const config: Record<string, string> = {}
|
||||
for (const row of rows) config[row.key] = row.value
|
||||
return {
|
||||
url: (config.jiraUrl || '').replace(/\/+$/, ''),
|
||||
email: config.jiraEmail || '',
|
||||
token: config.jiraToken || '',
|
||||
projectKey: config.jiraProjectKey || '',
|
||||
}
|
||||
}
|
||||
|
||||
// Build Basic Auth header (Jira Cloud: email + API Token)
|
||||
function makeHeaders(email: string, token: string, extra?: Record<string, string>): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`,
|
||||
'Content-Type': 'application/json',
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
// Request timeout (15s)
|
||||
const TIMEOUT = 15000
|
||||
|
||||
// Jira API request (v3)
|
||||
async function jiraFetch(baseUrl: string, email: string, token: string, path: string) {
|
||||
const headers = makeHeaders(email, token)
|
||||
const res = await fetch(`${baseUrl}/rest/api/3${path}`, { headers, signal: AbortSignal.timeout(TIMEOUT) })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`Jira request failed: HTTP ${res.status} ${text.slice(0, 200)}`)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Test connection
|
||||
jiraRouter.post('/test', async (req, res) => {
|
||||
try {
|
||||
const { url, email, token } = req.body
|
||||
const baseUrl = (url || '').replace(/\/+$/, '')
|
||||
if (!baseUrl || !email || !token) {
|
||||
return res.json({ ok: false, error: 'Please fill in all Jira configuration fields' })
|
||||
}
|
||||
// Test by fetching current user
|
||||
const data = await jiraFetch(baseUrl, email, token, '/myself') as any
|
||||
res.json({ ok: true, user: data.displayName || data.emailAddress })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Get project list
|
||||
jiraRouter.get('/projects', async (_req, res) => {
|
||||
try {
|
||||
const config = getJiraConfig()
|
||||
if (!config.url) return res.json({ ok: false, error: 'Jira URL not configured' })
|
||||
const data = await jiraFetch(config.url, config.email, config.token, '/project') as any[]
|
||||
res.json({
|
||||
ok: true,
|
||||
projects: data.map((p: any) => ({ id: p.id, key: p.key, name: p.name })),
|
||||
})
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Get bug list (JQL: bugs assigned to current user)
|
||||
jiraRouter.get('/bugs', async (_req, res) => {
|
||||
try {
|
||||
const config = getJiraConfig()
|
||||
if (!config.url) return res.json({ ok: false, error: 'Jira URL not configured' })
|
||||
if (!config.projectKey) return res.json({ ok: false, error: 'Please select a project first' })
|
||||
|
||||
const jql = `project = "${config.projectKey}" AND assignee = currentUser() AND issuetype = Bug AND statusCategory != Done ORDER BY created DESC`
|
||||
const data = await jiraFetch(
|
||||
config.url, config.email, config.token,
|
||||
`/search/jql?jql=${encodeURIComponent(jql)}&maxResults=100&fields=summary,priority,status,reporter,created,attachment,description`
|
||||
) as any
|
||||
|
||||
const bugs = (data.issues || []).map((issue: any) => ({
|
||||
id: issue.id,
|
||||
key: issue.key,
|
||||
title: issue.fields.summary,
|
||||
priority: issue.fields.priority?.name || '',
|
||||
priorityId: issue.fields.priority?.id || '',
|
||||
status: issue.fields.status?.name || '',
|
||||
statusCategory: issue.fields.status?.statusCategory?.key || '',
|
||||
reporter: issue.fields.reporter?.displayName || '',
|
||||
created: issue.fields.created || '',
|
||||
hasAttachments: (issue.fields.attachment || []).length > 0,
|
||||
}))
|
||||
|
||||
res.json({ ok: true, bugs, total: data.total || bugs.length })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Get single issue details
|
||||
jiraRouter.get('/bugs/:key', async (req, res) => {
|
||||
try {
|
||||
const config = getJiraConfig()
|
||||
if (!config.url) return res.json({ ok: false, error: 'Jira not configured' })
|
||||
const data = await jiraFetch(config.url, config.email, config.token, `/issue/${req.params.key}?fields=summary,description,priority,status,attachment,reporter,created`)
|
||||
res.json({ ok: true, issue: data })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Import bug from Jira into BugPack
|
||||
jiraRouter.post('/import/:key', async (req, res) => {
|
||||
try {
|
||||
const config = getJiraConfig()
|
||||
if (!config.url) return res.json({ ok: false, error: 'Jira not configured' })
|
||||
|
||||
const data = await jiraFetch(
|
||||
config.url, config.email, config.token,
|
||||
`/issue/${req.params.key}?fields=summary,description,priority,status,attachment`
|
||||
) as any
|
||||
|
||||
const fields = data.fields
|
||||
const projectId = req.body.projectId || ''
|
||||
const bugId = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Get next number
|
||||
const last = db.prepare('SELECT MAX(number) as maxNum FROM bugs WHERE project_id = ?').get(projectId) as any
|
||||
const number = (last?.maxNum || 0) + 1
|
||||
|
||||
// Description: strip simple Jira markup
|
||||
const rawDesc = fields.description || ''
|
||||
const desc = `[Imported from Jira ${data.key}]\n\n${fields.summary}\n\n${rawDesc}`.trim()
|
||||
|
||||
// Priority mapping: Jira Highest/High -> high, Medium -> medium, Low/Lowest -> low
|
||||
const priName = (fields.priority?.name || '').toLowerCase()
|
||||
let priority = 'medium'
|
||||
if (priName.includes('high') || priName.includes('critical') || priName.includes('blocker')) priority = 'high'
|
||||
else if (priName.includes('low') || priName.includes('trivial')) priority = 'low'
|
||||
|
||||
db.prepare(`INSERT INTO bugs (id, number, title, description, status, priority, page_path, device, browser, related_files, project_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, '', '', '', '[]', ?, ?, ?)`).run(
|
||||
bugId, number, fields.summary, desc, priority, projectId, now, now
|
||||
)
|
||||
|
||||
// Download image attachments
|
||||
const { writeFileSync, mkdirSync } = await import('fs')
|
||||
const pathMod = await import('path')
|
||||
const { UPLOADS_DIR } = await import('../db.js')
|
||||
|
||||
const project: any = projectId ? db.prepare('SELECT name FROM projects WHERE id = ?').get(projectId) : null
|
||||
const projectName = (project?.name || 'default').replace(/[<>:"/\\|?*]/g, '_')
|
||||
const projectDir = pathMod.join(UPLOADS_DIR, projectName)
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
|
||||
const headers = makeHeaders(config.email, config.token)
|
||||
let imgIndex = 0
|
||||
|
||||
const attachments = fields.attachment || []
|
||||
for (const att of attachments) {
|
||||
const mimeType = (att.mimeType || '').toLowerCase()
|
||||
if (!mimeType.startsWith('image/')) continue
|
||||
try {
|
||||
const imgRes = await fetch(att.content, { headers })
|
||||
if (!imgRes.ok) continue
|
||||
const buffer = Buffer.from(await imgRes.arrayBuffer())
|
||||
if (buffer.length > 20 * 1024 * 1024) continue // Skip images over 20MB
|
||||
const ext = mimeType.includes('jpeg') ? 'jpg' : mimeType.includes('png') ? 'png' : mimeType.includes('webp') ? 'webp' : mimeType.includes('gif') ? 'gif' : 'png'
|
||||
const fname = `${bugId}-${imgIndex}.${ext}`
|
||||
const filePath = pathMod.join(projectDir, fname)
|
||||
writeFileSync(filePath, buffer)
|
||||
const relPath = `${projectName}/${fname}`
|
||||
const ssId = crypto.randomUUID()
|
||||
db.prepare(`INSERT INTO screenshots (id, bug_id, filename, original_name, name, annotated, sort_order, annotations, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, '[]', ?)`).run(
|
||||
ssId, bugId, relPath, att.filename || fname, att.filename || `Screenshot ${imgIndex + 1}`, imgIndex, now
|
||||
)
|
||||
imgIndex++
|
||||
} catch {
|
||||
// Skip failed downloads
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true, bugId, number })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Sync status back to Jira (mark Issue as Done)
|
||||
jiraRouter.post('/resolve/:key', async (req, res) => {
|
||||
try {
|
||||
const config = getJiraConfig()
|
||||
if (!config.url) return res.json({ ok: false, error: 'Jira not configured' })
|
||||
const headers = makeHeaders(config.email, config.token)
|
||||
|
||||
// Get available transitions
|
||||
const transRes = await fetch(`${config.url}/rest/api/3/issue/${req.params.key}/transitions`, { headers, signal: AbortSignal.timeout(TIMEOUT) })
|
||||
if (!transRes.ok) throw new Error(`HTTP ${transRes.status}`)
|
||||
const transData = await transRes.json() as any
|
||||
|
||||
// Find Done/Resolved type transition
|
||||
const doneTrans = (transData.transitions || []).find((t: any) =>
|
||||
t.to?.statusCategory?.key === 'done' ||
|
||||
/done|resolved|完成|关闭/i.test(t.name)
|
||||
)
|
||||
if (!doneTrans) {
|
||||
return res.json({ ok: false, error: 'No available done transition found' })
|
||||
}
|
||||
|
||||
const doRes = await fetch(`${config.url}/rest/api/3/issue/${req.params.key}/transitions`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ transition: { id: doneTrans.id } }),
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
})
|
||||
if (!doRes.ok) throw new Error(`HTTP ${doRes.status}`)
|
||||
res.json({ ok: true })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,271 @@
|
||||
import { Router } from 'express'
|
||||
import crypto from 'crypto'
|
||||
import { db } from '../db.js'
|
||||
|
||||
export const linearRouter = Router()
|
||||
|
||||
// Get Linear config
|
||||
function getLinearConfig() {
|
||||
const rows = db.prepare('SELECT key, value FROM settings WHERE key LIKE ?').all('linear%') as { key: string; value: string }[]
|
||||
const config: Record<string, string> = {}
|
||||
for (const row of rows) config[row.key] = row.value
|
||||
return {
|
||||
token: config.linearToken || '',
|
||||
teamId: config.linearTeamId || '',
|
||||
}
|
||||
}
|
||||
|
||||
// Request timeout (15s)
|
||||
const TIMEOUT = 15000
|
||||
|
||||
// Linear GraphQL request
|
||||
async function linearQuery(token: string, query: string, variables?: Record<string, unknown>) {
|
||||
const res = await fetch('https://api.linear.app/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: token,
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
})
|
||||
const json = await res.json() as any
|
||||
if (json.errors?.length) {
|
||||
throw new Error(json.errors[0].message || 'Linear GraphQL error')
|
||||
}
|
||||
return json.data
|
||||
}
|
||||
|
||||
// Test connection
|
||||
linearRouter.post('/test', async (req, res) => {
|
||||
try {
|
||||
const { token } = req.body
|
||||
if (!token) return res.json({ ok: false, error: 'Please enter API Key' })
|
||||
const data = await linearQuery(token, `query { viewer { id name email } }`)
|
||||
res.json({ ok: true, user: data.viewer?.name || data.viewer?.email })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Get team list
|
||||
linearRouter.get('/teams', async (_req, res) => {
|
||||
try {
|
||||
const config = getLinearConfig()
|
||||
if (!config.token) return res.json({ ok: false, error: 'Linear API Key not configured' })
|
||||
const data = await linearQuery(config.token, `query { teams { nodes { id name key } } }`)
|
||||
res.json({
|
||||
ok: true,
|
||||
teams: (data.teams?.nodes || []).map((t: any) => ({ id: t.id, key: t.key, name: t.name })),
|
||||
})
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Get bug list (issues assigned to current user)
|
||||
linearRouter.get('/bugs', async (_req, res) => {
|
||||
try {
|
||||
const config = getLinearConfig()
|
||||
if (!config.token) return res.json({ ok: false, error: 'Linear API Key not configured' })
|
||||
if (!config.teamId) return res.json({ ok: false, error: 'Please select a team first' })
|
||||
|
||||
const data = await linearQuery(config.token, `
|
||||
query($teamId: ID!) {
|
||||
viewer {
|
||||
assignedIssues(
|
||||
filter: {
|
||||
team: { id: { eq: $teamId } }
|
||||
state: { type: { nin: ["completed", "canceled"] } }
|
||||
}
|
||||
first: 100
|
||||
orderBy: createdAt
|
||||
) {
|
||||
nodes {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
priority
|
||||
priorityLabel
|
||||
state { id name type }
|
||||
creator { name }
|
||||
createdAt
|
||||
attachments { nodes { id url title } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, { teamId: config.teamId })
|
||||
|
||||
const issues = data.viewer?.assignedIssues?.nodes || []
|
||||
const bugs = issues.map((issue: any) => ({
|
||||
id: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
priority: issue.priority,
|
||||
priorityLabel: issue.priorityLabel || '',
|
||||
status: issue.state?.name || '',
|
||||
statusType: issue.state?.type || '',
|
||||
creator: issue.creator?.name || '',
|
||||
created: issue.createdAt || '',
|
||||
hasAttachments: (issue.attachments?.nodes || []).length > 0,
|
||||
}))
|
||||
|
||||
res.json({ ok: true, bugs, total: bugs.length })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Import issue from Linear into BugPack
|
||||
linearRouter.post('/import/:id', async (req, res) => {
|
||||
try {
|
||||
const config = getLinearConfig()
|
||||
if (!config.token) return res.json({ ok: false, error: 'Linear not configured' })
|
||||
|
||||
const data = await linearQuery(config.token, `
|
||||
query($id: String!) {
|
||||
issue(id: $id) {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
description
|
||||
priority
|
||||
priorityLabel
|
||||
attachments { nodes { id url title metadata } }
|
||||
}
|
||||
}
|
||||
`, { id: req.params.id })
|
||||
|
||||
const issue = data.issue
|
||||
if (!issue) return res.json({ ok: false, error: 'Issue not found' })
|
||||
|
||||
const projectId = req.body.projectId || ''
|
||||
const bugId = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Get next number
|
||||
const last = db.prepare('SELECT MAX(number) as maxNum FROM bugs WHERE project_id = ?').get(projectId) as any
|
||||
const number = (last?.maxNum || 0) + 1
|
||||
|
||||
const desc = `[Imported from Linear ${issue.identifier}]\n\n${issue.title}\n\n${issue.description || ''}`.trim()
|
||||
|
||||
// Priority mapping: Linear 1=Urgent 2=High 3=Medium 4=Low 0=None
|
||||
const priMap: Record<number, string> = { 0: 'medium', 1: 'high', 2: 'high', 3: 'medium', 4: 'low' }
|
||||
const priority = priMap[issue.priority] || 'medium'
|
||||
|
||||
db.prepare(`INSERT INTO bugs (id, number, title, description, status, priority, page_path, device, browser, related_files, project_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, '', '', '', '[]', ?, ?, ?)`).run(
|
||||
bugId, number, issue.title, desc, priority, projectId, now, now
|
||||
)
|
||||
|
||||
// Download image attachments
|
||||
const { writeFileSync, mkdirSync } = await import('fs')
|
||||
const pathMod = await import('path')
|
||||
const { UPLOADS_DIR } = await import('../db.js')
|
||||
|
||||
const project: any = projectId ? db.prepare('SELECT name FROM projects WHERE id = ?').get(projectId) : null
|
||||
const projectName = (project?.name || 'default').replace(/[<>:"/\\|?*]/g, '_')
|
||||
const projectDir = pathMod.join(UPLOADS_DIR, projectName)
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
|
||||
let imgIndex = 0
|
||||
const attachments = issue.attachments?.nodes || []
|
||||
|
||||
for (const att of attachments) {
|
||||
if (!att.url) continue
|
||||
try {
|
||||
// Try downloading attachment, check if image
|
||||
const imgRes = await fetch(att.url, {
|
||||
headers: { Authorization: config.token },
|
||||
})
|
||||
if (!imgRes.ok) continue
|
||||
const contentType = (imgRes.headers.get('content-type') || '').toLowerCase()
|
||||
if (!contentType.startsWith('image/')) continue
|
||||
|
||||
const buffer = Buffer.from(await imgRes.arrayBuffer())
|
||||
if (buffer.length > 20 * 1024 * 1024) continue // Skip images over 20MB
|
||||
const ext = contentType.includes('jpeg') ? 'jpg' : contentType.includes('png') ? 'png' : contentType.includes('webp') ? 'webp' : contentType.includes('gif') ? 'gif' : 'png'
|
||||
const fname = `${bugId}-${imgIndex}.${ext}`
|
||||
const filePath = pathMod.join(projectDir, fname)
|
||||
writeFileSync(filePath, buffer)
|
||||
const relPath = `${projectName}/${fname}`
|
||||
const ssId = crypto.randomUUID()
|
||||
db.prepare(`INSERT INTO screenshots (id, bug_id, filename, original_name, name, annotated, sort_order, annotations, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, '[]', ?)`).run(
|
||||
ssId, bugId, relPath, fname, att.title || `Screenshot ${imgIndex + 1}`, imgIndex, now
|
||||
)
|
||||
imgIndex++
|
||||
} catch {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
// Also extract Markdown image links from description
|
||||
const descImages = (issue.description || '').matchAll(/!\[.*?\]\((https?:\/\/[^\s)]+)\)/g)
|
||||
for (const match of descImages) {
|
||||
const imgUrl = match[1]
|
||||
try {
|
||||
const imgRes = await fetch(imgUrl, {
|
||||
headers: { Authorization: config.token },
|
||||
})
|
||||
if (!imgRes.ok) continue
|
||||
const contentType = (imgRes.headers.get('content-type') || '').toLowerCase()
|
||||
if (!contentType.startsWith('image/')) continue
|
||||
|
||||
const buffer = Buffer.from(await imgRes.arrayBuffer())
|
||||
if (buffer.length > 20 * 1024 * 1024) continue // Skip images over 20MB
|
||||
const ext = contentType.includes('jpeg') ? 'jpg' : contentType.includes('png') ? 'png' : contentType.includes('webp') ? 'webp' : contentType.includes('gif') ? 'gif' : 'png'
|
||||
const fname = `${bugId}-${imgIndex}.${ext}`
|
||||
const filePath = pathMod.join(projectDir, fname)
|
||||
writeFileSync(filePath, buffer)
|
||||
const relPath = `${projectName}/${fname}`
|
||||
const ssId = crypto.randomUUID()
|
||||
db.prepare(`INSERT INTO screenshots (id, bug_id, filename, original_name, name, annotated, sort_order, annotations, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, '[]', ?)`).run(
|
||||
ssId, bugId, relPath, fname, `Screenshot ${imgIndex + 1}`, imgIndex, now
|
||||
)
|
||||
imgIndex++
|
||||
} catch {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true, bugId, number })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Sync status back to Linear (mark as completed)
|
||||
linearRouter.post('/resolve/:id', async (req, res) => {
|
||||
try {
|
||||
const config = getLinearConfig()
|
||||
if (!config.token) return res.json({ ok: false, error: 'Linear not configured' })
|
||||
if (!config.teamId) return res.json({ ok: false, error: 'No team selected' })
|
||||
|
||||
// Find Done type status
|
||||
const statesData = await linearQuery(config.token, `
|
||||
query($teamId: ID!) {
|
||||
team(id: $teamId) {
|
||||
states { nodes { id name type } }
|
||||
}
|
||||
}
|
||||
`, { teamId: config.teamId })
|
||||
|
||||
const doneState = (statesData.team?.states?.nodes || []).find((s: any) => s.type === 'completed')
|
||||
if (!doneState) return res.json({ ok: false, error: 'No completed state found' })
|
||||
|
||||
await linearQuery(config.token, `
|
||||
mutation($id: String!, $stateId: String!) {
|
||||
issueUpdate(id: $id, input: { stateId: $stateId }) {
|
||||
success
|
||||
}
|
||||
}
|
||||
`, { id: req.params.id, stateId: doneState.id })
|
||||
|
||||
res.json({ ok: true })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
import { Router } from 'express'
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import { db, UPLOADS_DIR } from '../db.js'
|
||||
import crypto from 'crypto'
|
||||
import { v4 as uuid } from 'uuid'
|
||||
import archiver from 'archiver'
|
||||
import AdmZip from 'adm-zip'
|
||||
import multer from 'multer'
|
||||
|
||||
export const projectsRouter = Router()
|
||||
|
||||
// ZIP file upload (in-memory, max 200MB)
|
||||
const zipUpload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 200 * 1024 * 1024 } })
|
||||
|
||||
// Get all projects
|
||||
projectsRouter.get('/', (_req, res) => {
|
||||
const projects = db.prepare('SELECT * FROM projects ORDER BY created_at DESC').all()
|
||||
res.json(projects)
|
||||
})
|
||||
|
||||
// Create project
|
||||
projectsRouter.post('/', (req, res) => {
|
||||
const { name } = req.body
|
||||
if (!name) return res.status(400).json({ error: 'Project name is required' })
|
||||
|
||||
const id = crypto.randomUUID()
|
||||
db.prepare('INSERT INTO projects (id, name) VALUES (?, ?)').run(id, name)
|
||||
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(id)
|
||||
res.json(project)
|
||||
})
|
||||
|
||||
// Rename project
|
||||
projectsRouter.patch('/:id', (req, res) => {
|
||||
const { name } = req.body
|
||||
db.prepare('UPDATE projects SET name = ? WHERE id = ?').run(name, req.params.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// Delete project (also deletes associated bugs and screenshot files)
|
||||
projectsRouter.delete('/:id', (req, res) => {
|
||||
const bugs: any[] = db.prepare('SELECT id FROM bugs WHERE project_id = ?').all(req.params.id)
|
||||
for (const bug of bugs) {
|
||||
const screenshots: any[] = db.prepare('SELECT filename FROM screenshots WHERE bug_id = ?').all(bug.id)
|
||||
for (const ss of screenshots) {
|
||||
const filePath = path.join(UPLOADS_DIR, ss.filename)
|
||||
try { if (fs.existsSync(filePath)) fs.unlinkSync(filePath) } catch { /* ignore file deletion error */ }
|
||||
}
|
||||
db.prepare('DELETE FROM screenshots WHERE bug_id = ?').run(bug.id)
|
||||
}
|
||||
db.prepare('DELETE FROM bugs WHERE project_id = ?').run(req.params.id)
|
||||
db.prepare('DELETE FROM projects WHERE id = ?').run(req.params.id)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
// Export project data (ZIP: manifest.json + original image files, streaming)
|
||||
projectsRouter.get('/:id/export', (req, res) => {
|
||||
const project: any = db.prepare('SELECT * FROM projects WHERE id = ?').get(req.params.id)
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' })
|
||||
|
||||
const bugs: any[] = db.prepare('SELECT * FROM bugs WHERE project_id = ?').all(req.params.id)
|
||||
|
||||
const manifest: any = {
|
||||
version: '2.0',
|
||||
exportedAt: new Date().toISOString(),
|
||||
project: { name: project.name, created_at: project.created_at },
|
||||
bugs: [],
|
||||
}
|
||||
|
||||
// Collect screenshot file info
|
||||
const imageFiles: { zipPath: string; diskPath: string }[] = []
|
||||
|
||||
for (const bug of bugs) {
|
||||
const screenshots: any[] = db.prepare('SELECT * FROM screenshots WHERE bug_id = ? ORDER BY sort_order').all(bug.id)
|
||||
|
||||
const ssExport = screenshots.map((ss: any, i: number) => {
|
||||
const ext = path.extname(ss.filename).toLowerCase() || '.png'
|
||||
const zipPath = `images/${bug.number}/${i}${ext}`
|
||||
const diskPath = path.join(UPLOADS_DIR, ss.filename)
|
||||
|
||||
if (fs.existsSync(diskPath)) {
|
||||
imageFiles.push({ zipPath, diskPath })
|
||||
}
|
||||
|
||||
return {
|
||||
original_name: ss.original_name,
|
||||
name: ss.name,
|
||||
annotated: ss.annotated,
|
||||
sort_order: ss.sort_order,
|
||||
annotations: ss.annotations,
|
||||
imagePath: fs.existsSync(diskPath) ? zipPath : null,
|
||||
}
|
||||
})
|
||||
|
||||
manifest.bugs.push({
|
||||
number: bug.number,
|
||||
title: bug.title,
|
||||
description: bug.description,
|
||||
status: bug.status,
|
||||
priority: bug.priority,
|
||||
page_path: bug.page_path,
|
||||
device: bug.device,
|
||||
browser: bug.browser,
|
||||
related_files: bug.related_files,
|
||||
created_at: bug.created_at,
|
||||
updated_at: bug.updated_at,
|
||||
screenshots: ssExport,
|
||||
})
|
||||
}
|
||||
|
||||
// Stream ZIP output
|
||||
const filename = `bugpack-${project.name}-${new Date().toISOString().split('T')[0]}.zip`
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`)
|
||||
res.setHeader('Content-Type', 'application/zip')
|
||||
|
||||
const archive = archiver('zip', { zlib: { level: 6 } })
|
||||
archive.on('error', (err: Error) => {
|
||||
console.error('ZIP archive error:', err)
|
||||
if (!res.headersSent) res.status(500).json({ error: err.message })
|
||||
})
|
||||
archive.pipe(res)
|
||||
|
||||
// Write manifest
|
||||
archive.append(JSON.stringify(manifest, null, 2), { name: 'manifest.json' })
|
||||
|
||||
// Stream image files (no full memory load)
|
||||
for (const img of imageFiles) {
|
||||
archive.file(img.diskPath, { name: img.zipPath })
|
||||
}
|
||||
|
||||
archive.finalize()
|
||||
})
|
||||
|
||||
// Import project data (receive ZIP file)
|
||||
projectsRouter.post('/:id/import', zipUpload.single('file'), (req, res) => {
|
||||
try {
|
||||
const projectId = req.params.id
|
||||
const project: any = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId)
|
||||
if (!project) return res.status(404).json({ error: 'Project not found' })
|
||||
|
||||
if (!req.file) return res.status(400).json({ error: 'Please upload a .zip file' })
|
||||
|
||||
const zip = new AdmZip(req.file.buffer)
|
||||
const manifestEntry = zip.getEntry('manifest.json')
|
||||
if (!manifestEntry) return res.status(400).json({ error: 'Invalid BugPack backup file (missing manifest.json)' })
|
||||
|
||||
const manifest = JSON.parse(manifestEntry.getData().toString('utf-8'))
|
||||
if (!manifest.bugs || !Array.isArray(manifest.bugs)) {
|
||||
return res.status(400).json({ error: 'Invalid manifest data' })
|
||||
}
|
||||
if (manifest.bugs.length > 5000) {
|
||||
return res.status(400).json({ error: 'Too many bugs in import (max 5000)' })
|
||||
}
|
||||
|
||||
const projectName = (project.name || 'default').replace(/[<>:"/\\|?*]/g, '_')
|
||||
const projectDir = path.join(UPLOADS_DIR, projectName)
|
||||
fs.mkdirSync(projectDir, { recursive: true })
|
||||
|
||||
let importedCount = 0
|
||||
|
||||
const importAll = db.transaction(() => {
|
||||
for (const bugData of manifest.bugs) {
|
||||
const bugId = uuid()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const last: any = db.prepare('SELECT MAX(number) as maxNum FROM bugs WHERE project_id = ?').get(projectId)
|
||||
const number = (last?.maxNum || 0) + 1
|
||||
|
||||
db.prepare(`INSERT INTO bugs (id, number, title, description, status, priority, page_path, device, browser, related_files, project_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
bugId, number,
|
||||
bugData.title || '',
|
||||
bugData.description || '',
|
||||
bugData.status || 'pending',
|
||||
bugData.priority || 'medium',
|
||||
bugData.page_path || '',
|
||||
bugData.device || '',
|
||||
bugData.browser || '',
|
||||
bugData.related_files || '[]',
|
||||
projectId,
|
||||
bugData.created_at || now,
|
||||
bugData.updated_at || now,
|
||||
)
|
||||
|
||||
// Import screenshots
|
||||
if (Array.isArray(bugData.screenshots)) {
|
||||
for (let i = 0; i < bugData.screenshots.length; i++) {
|
||||
const ssData = bugData.screenshots[i]
|
||||
if (!ssData?.imagePath) continue
|
||||
|
||||
// Path traversal protection
|
||||
if (ssData.imagePath.includes('..') || path.isAbsolute(ssData.imagePath)) continue
|
||||
|
||||
// Extract image from ZIP
|
||||
const imgEntry = zip.getEntry(ssData.imagePath)
|
||||
if (!imgEntry) continue
|
||||
if (imgEntry.header.size > 50 * 1024 * 1024) continue // Skip files > 50MB
|
||||
|
||||
const buffer = imgEntry.getData()
|
||||
const ext = path.extname(ssData.imagePath) || '.png'
|
||||
const fname = `${bugId}-${i}${ext}`
|
||||
const filePath = path.join(projectDir, fname)
|
||||
|
||||
// Verify resolved path is within project dir
|
||||
const resolved = path.resolve(filePath)
|
||||
if (!resolved.startsWith(path.resolve(projectDir) + path.sep)) continue
|
||||
|
||||
fs.writeFileSync(filePath, buffer)
|
||||
|
||||
const relPath = `${projectName}/${fname}`
|
||||
const ssId = uuid()
|
||||
db.prepare(`INSERT INTO screenshots (id, bug_id, filename, original_name, name, annotated, sort_order, annotations, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
|
||||
ssId, bugId, relPath,
|
||||
ssData.original_name || fname,
|
||||
ssData.name || `截图 ${i + 1}`,
|
||||
ssData.annotated ? 1 : 0,
|
||||
ssData.sort_order ?? i,
|
||||
ssData.annotations || '[]',
|
||||
now,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
importedCount++
|
||||
}
|
||||
})
|
||||
|
||||
importAll()
|
||||
res.json({ ok: true, importedCount })
|
||||
} catch (e: any) {
|
||||
console.error('Import failed:', e)
|
||||
res.json({ ok: false, error: 'Import failed. Please check the file format.' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Router } from 'express'
|
||||
import { execFile } from 'child_process'
|
||||
import { writeFileSync, readFileSync, unlinkSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import path from 'path'
|
||||
import { db, DATA_DIR } from '../db.js'
|
||||
|
||||
export const settingsRouter = Router()
|
||||
|
||||
// Get all settings
|
||||
settingsRouter.get('/', (_req, res) => {
|
||||
const rows = db.prepare('SELECT key, value FROM settings').all() as { key: string; value: string }[]
|
||||
const result: Record<string, string> = {}
|
||||
for (const row of rows) {
|
||||
result[row.key] = row.value
|
||||
}
|
||||
// Return server data directory
|
||||
result._dataDir = DATA_DIR
|
||||
result._cwd = process.cwd()
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
// Pick directory (native Windows dialog, foreground, returns full path)
|
||||
settingsRouter.post('/pick-directory', (_req, res) => {
|
||||
const resultPath = path.join(tmpdir(), 'bugpack-pick-result.txt')
|
||||
const scriptPath = path.join(tmpdir(), 'bugpack-pick-dir.ps1')
|
||||
// Create a TopMost hidden form as parent to ensure dialog appears in foreground
|
||||
const ps = `
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
$form = New-Object System.Windows.Forms.Form
|
||||
$form.TopMost = $true
|
||||
$form.WindowState = 'Minimized'
|
||||
$form.ShowInTaskbar = $false
|
||||
$form.Show()
|
||||
$dialog = New-Object System.Windows.Forms.FolderBrowserDialog
|
||||
$dialog.Description = 'Select Directory'
|
||||
$dialog.ShowNewFolderButton = $true
|
||||
$result = $dialog.ShowDialog($form)
|
||||
$form.Close()
|
||||
if ($result -eq 'OK') {
|
||||
[System.IO.File]::WriteAllText('${resultPath.replace(/\\/g, '\\\\')}', $dialog.SelectedPath)
|
||||
} else {
|
||||
[System.IO.File]::WriteAllText('${resultPath.replace(/\\/g, '\\\\')}', '')
|
||||
}
|
||||
`
|
||||
writeFileSync(scriptPath, ps, 'utf-8')
|
||||
execFile('powershell', ['-ExecutionPolicy', 'Bypass', '-File', scriptPath], {
|
||||
timeout: 60000,
|
||||
}, (err) => {
|
||||
try { unlinkSync(scriptPath) } catch {}
|
||||
if (err) {
|
||||
console.error('Failed to open directory picker:', err)
|
||||
try { unlinkSync(resultPath) } catch {}
|
||||
return res.json({ path: '' })
|
||||
}
|
||||
let selected = ''
|
||||
try {
|
||||
selected = readFileSync(resultPath, 'utf-8').trim()
|
||||
unlinkSync(resultPath)
|
||||
} catch {}
|
||||
res.json({ path: selected })
|
||||
})
|
||||
})
|
||||
|
||||
// Batch save settings
|
||||
settingsRouter.put('/', (req, res) => {
|
||||
const data = req.body as Record<string, string>
|
||||
const upsert = db.prepare('INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?')
|
||||
const tx = db.transaction(() => {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
upsert.run(key, value, value)
|
||||
}
|
||||
})
|
||||
tx()
|
||||
res.json({ ok: true })
|
||||
})
|
||||
@@ -0,0 +1,280 @@
|
||||
import { Router } from 'express'
|
||||
import crypto from 'crypto'
|
||||
import { db } from '../db.js'
|
||||
|
||||
export const tapdRouter = Router()
|
||||
|
||||
// Get TAPD config
|
||||
function getTapdConfig() {
|
||||
const rows = db.prepare('SELECT key, value FROM settings WHERE key LIKE ?').all('tapd%') as { key: string; value: string }[]
|
||||
const config: Record<string, string> = {}
|
||||
for (const row of rows) config[row.key] = row.value
|
||||
return {
|
||||
apiUser: config.tapdApiUser || '',
|
||||
apiPassword: config.tapdApiPassword || '',
|
||||
workspaceId: config.tapdWorkspaceId || '',
|
||||
}
|
||||
}
|
||||
|
||||
// Build Basic Auth header
|
||||
function makeHeaders(apiUser: string, apiPassword: string): Record<string, string> {
|
||||
return {
|
||||
Authorization: `Basic ${Buffer.from(`${apiUser}:${apiPassword}`).toString('base64')}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Request timeout (15s)
|
||||
const TIMEOUT = 15000
|
||||
|
||||
// TAPD API request
|
||||
async function tapdFetch(apiUser: string, apiPassword: string, path: string) {
|
||||
const headers = makeHeaders(apiUser, apiPassword)
|
||||
const res = await fetch(`https://api.tapd.cn${path}`, { headers, signal: AbortSignal.timeout(TIMEOUT) })
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '')
|
||||
throw new Error(`TAPD request failed: HTTP ${res.status} ${text.slice(0, 200)}`)
|
||||
}
|
||||
const json = await res.json() as any
|
||||
if (json.status !== 1) {
|
||||
throw new Error(json.info || 'TAPD API returned error')
|
||||
}
|
||||
return json.data
|
||||
}
|
||||
|
||||
// Test connection
|
||||
tapdRouter.post('/test', async (req, res) => {
|
||||
try {
|
||||
const { apiUser, apiPassword } = req.body
|
||||
if (!apiUser || !apiPassword) {
|
||||
return res.json({ ok: false, error: 'Please enter API credentials' })
|
||||
}
|
||||
const headers = makeHeaders(apiUser, apiPassword)
|
||||
const testRes = await fetch('https://api.tapd.cn/quickstart/testauth', { headers, signal: AbortSignal.timeout(TIMEOUT) })
|
||||
if (!testRes.ok) {
|
||||
if (testRes.status === 401) throw new Error('Invalid API credentials')
|
||||
throw new Error(`HTTP ${testRes.status}`)
|
||||
}
|
||||
const json = await testRes.json() as any
|
||||
if (json.status !== 1) throw new Error(json.info || 'Authentication failed')
|
||||
res.json({ ok: true })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Get project (workspace) list
|
||||
// TAPD project list API requires company_id; use configured workspace_id instead
|
||||
tapdRouter.get('/workspaces', async (_req, res) => {
|
||||
try {
|
||||
const config = getTapdConfig()
|
||||
if (!config.apiUser) return res.json({ ok: false, error: 'TAPD API account not configured' })
|
||||
if (!config.workspaceId) return res.json({ ok: false, error: 'Please set TAPD workspace ID in settings' })
|
||||
// Return configured workspace directly, skip project selection
|
||||
res.json({ ok: true, workspaces: [{ id: config.workspaceId, name: `Project #${config.workspaceId}` }] })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Get bug list
|
||||
tapdRouter.get('/bugs', async (_req, res) => {
|
||||
try {
|
||||
const config = getTapdConfig()
|
||||
if (!config.apiUser) return res.json({ ok: false, error: 'TAPD not configured' })
|
||||
if (!config.workspaceId) return res.json({ ok: false, error: 'Please select a project first' })
|
||||
|
||||
const data = await tapdFetch(
|
||||
config.apiUser, config.apiPassword,
|
||||
`/bugs?workspace_id=${config.workspaceId}&limit=100&order=created desc`
|
||||
)
|
||||
|
||||
const bugs = (data || []).map((item: any) => {
|
||||
const b = item.Bug || item
|
||||
return {
|
||||
id: b.id,
|
||||
title: b.title,
|
||||
severity: b.severity || '',
|
||||
priority: b.priority_label || b.priority || '',
|
||||
status: b.status || '',
|
||||
reporter: b.reporter || '',
|
||||
currentOwner: b.current_owner || '',
|
||||
created: b.created || '',
|
||||
}
|
||||
})
|
||||
|
||||
res.json({ ok: true, bugs, total: bugs.length })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Get single bug details
|
||||
tapdRouter.get('/bugs/:id', async (req, res) => {
|
||||
try {
|
||||
const config = getTapdConfig()
|
||||
if (!config.apiUser) return res.json({ ok: false, error: 'TAPD not configured' })
|
||||
const data = await tapdFetch(
|
||||
config.apiUser, config.apiPassword,
|
||||
`/bugs?workspace_id=${config.workspaceId}&id=${req.params.id}`
|
||||
)
|
||||
const bug = data?.[0]?.Bug || null
|
||||
res.json({ ok: true, bug })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Import bug from TAPD into BugPack
|
||||
tapdRouter.post('/import/:id', async (req, res) => {
|
||||
try {
|
||||
const config = getTapdConfig()
|
||||
if (!config.apiUser) return res.json({ ok: false, error: 'TAPD not configured' })
|
||||
|
||||
// Get bug details
|
||||
const bugData = await tapdFetch(
|
||||
config.apiUser, config.apiPassword,
|
||||
`/bugs?workspace_id=${config.workspaceId}&id=${req.params.id}`
|
||||
)
|
||||
const tapdBug = bugData?.[0]?.Bug
|
||||
if (!tapdBug) return res.json({ ok: false, error: 'Bug not found' })
|
||||
|
||||
const projectId = req.body.projectId || ''
|
||||
const bugId = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Get next number
|
||||
const last = db.prepare('SELECT MAX(number) as maxNum FROM bugs WHERE project_id = ?').get(projectId) as any
|
||||
const number = (last?.maxNum || 0) + 1
|
||||
|
||||
// Description: strip HTML tags
|
||||
const rawDesc = (tapdBug.description || '').replace(/<[^>]+>/g, '')
|
||||
const desc = `[Imported from TAPD #${tapdBug.id}]\n\n${tapdBug.title}\n\n${rawDesc}`.trim()
|
||||
|
||||
// Priority mapping
|
||||
const priLabel = (tapdBug.priority_label || tapdBug.priority || '').toLowerCase()
|
||||
let priority = 'medium'
|
||||
if (priLabel.includes('紧急') || priLabel.includes('urgent') || priLabel.includes('high')) priority = 'high'
|
||||
else if (priLabel.includes('低') || priLabel.includes('low')) priority = 'low'
|
||||
|
||||
db.prepare(`INSERT INTO bugs (id, number, title, description, status, priority, page_path, device, browser, related_files, project_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, '', '', '', '[]', ?, ?, ?)`).run(
|
||||
bugId, number, tapdBug.title, desc, priority, projectId, now, now
|
||||
)
|
||||
|
||||
// Download image attachments
|
||||
const { writeFileSync, mkdirSync } = await import('fs')
|
||||
const pathMod = await import('path')
|
||||
const { UPLOADS_DIR } = await import('../db.js')
|
||||
|
||||
const project: any = projectId ? db.prepare('SELECT name FROM projects WHERE id = ?').get(projectId) : null
|
||||
const projectName = (project?.name || 'default').replace(/[<>:"/\\|?*]/g, '_')
|
||||
const projectDir = pathMod.join(UPLOADS_DIR, projectName)
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
|
||||
let imgIndex = 0
|
||||
|
||||
// Save image helper
|
||||
const saveImage = (buffer: Buffer, ext: string, name: string) => {
|
||||
const fname = `${bugId}-${imgIndex}.${ext}`
|
||||
const filePath = pathMod.join(projectDir, fname)
|
||||
writeFileSync(filePath, buffer)
|
||||
const relPath = `${projectName}/${fname}`
|
||||
const ssId = crypto.randomUUID()
|
||||
db.prepare(`INSERT INTO screenshots (id, bug_id, filename, original_name, name, annotated, sort_order, annotations, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, '[]', ?)`).run(
|
||||
ssId, bugId, relPath, fname, name, imgIndex, now
|
||||
)
|
||||
imgIndex++
|
||||
}
|
||||
|
||||
// Get temporary download link via get_image API
|
||||
const downloadViaGetImage = async (imagePath: string, name: string) => {
|
||||
const imgData = await tapdFetch(
|
||||
config.apiUser, config.apiPassword,
|
||||
`/files/get_image?workspace_id=${config.workspaceId}&image_path=${encodeURIComponent(imagePath)}`
|
||||
)
|
||||
const downloadUrl = imgData?.Attachment?.download_url
|
||||
if (!downloadUrl) return false
|
||||
// download_url is a temporary link (300s), no auth needed
|
||||
const imgRes = await fetch(downloadUrl)
|
||||
if (!imgRes.ok) return false
|
||||
const contentType = (imgRes.headers.get('content-type') || '').toLowerCase()
|
||||
if (!contentType.startsWith('image')) return false
|
||||
const buffer = Buffer.from(await imgRes.arrayBuffer())
|
||||
if (buffer.length > 20 * 1024 * 1024) return false // Skip images over 20MB
|
||||
const ext = contentType.includes('jpeg') ? 'jpg' : contentType.includes('png') ? 'png' : contentType.includes('gif') ? 'gif' : 'png'
|
||||
saveImage(buffer, ext, name)
|
||||
return true
|
||||
}
|
||||
|
||||
// 1) Extract /tfl/ path images from description HTML, download via get_image API
|
||||
const descHtml = tapdBug.description || ''
|
||||
const tflRegex = /(?:src=["']|")(\/tfl\/[^"']+)["']/gi
|
||||
let match
|
||||
while ((match = tflRegex.exec(descHtml)) !== null) {
|
||||
try {
|
||||
await downloadViaGetImage(match[1]!, `Screenshot ${imgIndex + 1}`)
|
||||
} catch {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Get bug attachment list, download using filename path
|
||||
try {
|
||||
const attData = await tapdFetch(
|
||||
config.apiUser, config.apiPassword,
|
||||
`/attachments?workspace_id=${config.workspaceId}&entry_id=${req.params.id}&limit=50`
|
||||
)
|
||||
for (const item of (attData || [])) {
|
||||
const att = item.Attachment || item
|
||||
const filename = (att.filename || '').toLowerCase()
|
||||
if (!filename.match(/\.(png|jpg|jpeg|gif|webp|bmp)$/)) continue
|
||||
try {
|
||||
// Try downloading via get_image using attachment filename
|
||||
const success = await downloadViaGetImage(att.filename, att.filename || `Screenshot ${imgIndex + 1}`)
|
||||
if (!success) {
|
||||
// Fallback: try common TAPD file path
|
||||
await downloadViaGetImage(`/tfl/pictures/${att.filename}`, att.filename || `Screenshot ${imgIndex + 1}`)
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Attachment fetch failure does not block import
|
||||
}
|
||||
|
||||
res.json({ ok: true, bugId, number })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Sync status back to TAPD (close bug)
|
||||
tapdRouter.post('/resolve/:id', async (req, res) => {
|
||||
try {
|
||||
const config = getTapdConfig()
|
||||
if (!config.apiUser) return res.json({ ok: false, error: 'TAPD not configured' })
|
||||
const headers = makeHeaders(config.apiUser, config.apiPassword)
|
||||
|
||||
const apiRes = await fetch('https://api.tapd.cn/bugs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
workspace_id: config.workspaceId,
|
||||
id: req.params.id,
|
||||
status: 'resolved',
|
||||
}).toString(),
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
})
|
||||
if (!apiRes.ok) throw new Error(`HTTP ${apiRes.status}`)
|
||||
const json = await apiRes.json() as any
|
||||
if (json.status !== 1) throw new Error(json.info || 'Update failed')
|
||||
res.json({ ok: true })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,306 @@
|
||||
import { Router } from 'express'
|
||||
import crypto from 'crypto'
|
||||
import { db } from '../db.js'
|
||||
|
||||
export const zentaoRouter = Router()
|
||||
|
||||
// Clean URL: strip trailing /my.html, /index.html, / etc.
|
||||
function cleanUrl(raw: string): string {
|
||||
let url = raw.trim().replace(/\/+$/, '')
|
||||
// Strip trailing .html/.php page path
|
||||
url = url.replace(/\/[^/]*\.(html|php)$/i, '')
|
||||
return url
|
||||
}
|
||||
|
||||
// Get Zentao config
|
||||
function getZentaoConfig() {
|
||||
const rows = db.prepare('SELECT key, value FROM settings WHERE key LIKE ?').all('zentao%') as { key: string; value: string }[]
|
||||
const config: Record<string, string> = {}
|
||||
for (const row of rows) config[row.key] = row.value
|
||||
return {
|
||||
url: cleanUrl(config.zentaoUrl || ''),
|
||||
// HTTP Basic Auth (company gateway auth)
|
||||
httpUser: config.zentaoHttpUser || '',
|
||||
httpPass: config.zentaoHttpPass || '',
|
||||
// Zentao system account
|
||||
account: config.zentaoAccount || '',
|
||||
password: config.zentaoPassword || '',
|
||||
productId: config.zentaoProductId || '',
|
||||
}
|
||||
}
|
||||
|
||||
// Build Basic Auth header (company gateway auth)
|
||||
function makeBasicHeaders(httpUser: string, httpPass: string, extra?: Record<string, string>): Record<string, string> {
|
||||
const headers: Record<string, string> = { ...extra }
|
||||
if (httpUser) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${httpUser}:${httpPass}`).toString('base64')}`
|
||||
}
|
||||
return headers
|
||||
}
|
||||
|
||||
// Request timeout (15s)
|
||||
const TIMEOUT = 15000
|
||||
|
||||
// Get token
|
||||
async function getToken(baseUrl: string, account: string, password: string, httpUser: string, httpPass: string): Promise<string> {
|
||||
const res = await fetch(`${baseUrl}/api.php/v1/tokens`, {
|
||||
method: 'POST',
|
||||
headers: makeBasicHeaders(httpUser, httpPass, { 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ account, password }),
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
})
|
||||
const text = await res.text().catch(() => '')
|
||||
let json: any = null
|
||||
try { json = JSON.parse(text) } catch {}
|
||||
if (!res.ok || json?.error) {
|
||||
const msg = json?.error || `HTTP ${res.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
if (!json?.token) throw new Error('Zentao did not return a token')
|
||||
return json.token
|
||||
}
|
||||
|
||||
// Zentao API request
|
||||
async function zentaoFetch(baseUrl: string, token: string, path: string, httpUser: string, httpPass: string) {
|
||||
const headers = makeBasicHeaders(httpUser, httpPass, { Token: token })
|
||||
const res = await fetch(`${baseUrl}/api.php/v1${path}`, { headers, signal: AbortSignal.timeout(TIMEOUT) })
|
||||
if (!res.ok) throw new Error(`Zentao request failed: HTTP ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// Test connection
|
||||
zentaoRouter.post('/test', async (req, res) => {
|
||||
try {
|
||||
const { url, account, password, httpUser, httpPass } = req.body
|
||||
const base = cleanUrl(url)
|
||||
const token = await getToken(base, account, password, httpUser || '', httpPass || '')
|
||||
res.json({ ok: true, token })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Get project list (for selection)
|
||||
zentaoRouter.get('/products', async (_req, res) => {
|
||||
try {
|
||||
const config = getZentaoConfig()
|
||||
if (!config.url) return res.json({ ok: false, error: 'Zentao URL not configured' })
|
||||
const token = await getToken(config.url, config.account, config.password, config.httpUser, config.httpPass)
|
||||
const data = await zentaoFetch(config.url, token, '/projects?limit=100', config.httpUser, config.httpPass) as any
|
||||
// Return project list (reuse 'products' field name for frontend compatibility)
|
||||
res.json({ ok: true, products: (data.projects || []).map((p: any) => ({ id: p.id, name: p.name })) })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Get bug list (by project ID, filter to current user)
|
||||
zentaoRouter.get('/bugs', async (_req, res) => {
|
||||
try {
|
||||
const config = getZentaoConfig()
|
||||
if (!config.url) return res.json({ ok: false, error: 'Zentao URL not configured' })
|
||||
if (!config.productId) return res.json({ ok: false, error: 'Please select a project first' })
|
||||
const token = await getToken(config.url, config.account, config.password, config.httpUser, config.httpPass)
|
||||
const data = await zentaoFetch(config.url, token, `/projects/${config.productId}/bugs?limit=200`, config.httpUser, config.httpPass) as any
|
||||
// Filter bugs assigned to current user
|
||||
const allBugs = data.bugs || []
|
||||
const myBugs = allBugs.filter((b: any) => {
|
||||
const assigned = b.assignedTo
|
||||
const account = typeof assigned === 'string' ? assigned : assigned?.account
|
||||
return account === config.account
|
||||
})
|
||||
res.json({ ok: true, bugs: myBugs, total: allBugs.length })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Get single bug details (raw data for debugging image fields)
|
||||
zentaoRouter.get('/bugs/:id', async (req, res) => {
|
||||
try {
|
||||
const config = getZentaoConfig()
|
||||
if (!config.url) return res.json({ ok: false, error: 'Zentao not configured' })
|
||||
const token = await getToken(config.url, config.account, config.password, config.httpUser, config.httpPass)
|
||||
const data = await zentaoFetch(config.url, token, `/bugs/${req.params.id}`, config.httpUser, config.httpPass)
|
||||
res.json({ ok: true, bug: data })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Import bug from Zentao into BugPack
|
||||
zentaoRouter.post('/import/:id', async (req, res) => {
|
||||
try {
|
||||
const config = getZentaoConfig()
|
||||
if (!config.url) return res.json({ ok: false, error: 'Zentao not configured' })
|
||||
const token = await getToken(config.url, config.account, config.password, config.httpUser, config.httpPass)
|
||||
const data = await zentaoFetch(config.url, token, `/bugs/${req.params.id}`, config.httpUser, config.httpPass) as any
|
||||
const projectId = req.body.projectId || ''
|
||||
const bugId = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
|
||||
// Get next number
|
||||
const last = db.prepare('SELECT MAX(number) as maxNum FROM bugs WHERE project_id = ?').get(projectId) as any
|
||||
const number = (last?.maxNum || 0) + 1
|
||||
|
||||
// Build description
|
||||
const steps = data.steps ? data.steps.replace(/<[^>]+>/g, '') : ''
|
||||
let desc = `[Imported from Zentao #${data.id}]\n\n${data.title}\n\n${steps}`.trim()
|
||||
|
||||
// Append history/comments from bug detail, collect comment image fileIDs
|
||||
const commentFileIds: string[] = []
|
||||
try {
|
||||
const actionList = data.actions || []
|
||||
if (Array.isArray(actionList) && actionList.length > 0) {
|
||||
const historyLines: string[] = ['\n\n---\n## History']
|
||||
for (const act of actionList) {
|
||||
const time = act.date || ''
|
||||
const actor = act.actor || ''
|
||||
const action = act.action || ''
|
||||
const rawComment = act.comment || ''
|
||||
// Extract image fileIDs from comment HTML
|
||||
const fidRegex = /fileID=(\d+)/g
|
||||
let fidMatch
|
||||
while ((fidMatch = fidRegex.exec(rawComment)) !== null) {
|
||||
if (fidMatch[1]) commentFileIds.push(fidMatch[1])
|
||||
}
|
||||
const comment = rawComment.replace(/<[^>]+>/g, '').trim()
|
||||
let line = `- **${time}** ${actor} ${action}`
|
||||
if (comment) line += `\n > ${comment}`
|
||||
historyLines.push(line)
|
||||
}
|
||||
desc += historyLines.join('\n')
|
||||
}
|
||||
} catch {
|
||||
// History parse failed, skip
|
||||
}
|
||||
|
||||
// Priority mapping: Zentao 1=Highest 2=High 3=Medium 4=Low -> BugPack high/medium/low
|
||||
const priMap: Record<number, string> = { 1: 'high', 2: 'high', 3: 'medium', 4: 'low' }
|
||||
const priority = priMap[data.pri] || 'medium'
|
||||
|
||||
db.prepare(`INSERT INTO bugs (id, number, title, description, status, priority, page_path, device, browser, related_files, project_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', ?, '', '', '', '[]', ?, ?, ?)`).run(
|
||||
bugId, number, data.title, desc, priority, projectId, now, now
|
||||
)
|
||||
|
||||
// Download images: extract inline images from steps HTML + file attachments
|
||||
const { writeFileSync, mkdirSync } = await import('fs')
|
||||
const pathMod = await import('path')
|
||||
const { UPLOADS_DIR } = await import('../db.js')
|
||||
|
||||
// Get project upload directory (consistent with bugs route)
|
||||
const project: any = projectId ? db.prepare('SELECT name FROM projects WHERE id = ?').get(projectId) : null
|
||||
const projectName = (project?.name || 'default').replace(/[<>:"/\\|?*]/g, '_')
|
||||
const projectDir = pathMod.join(UPLOADS_DIR, projectName)
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
|
||||
const fileHeaders = makeBasicHeaders(config.httpUser, config.httpPass, { Token: token })
|
||||
let imgIndex = 0
|
||||
|
||||
// Save image helper
|
||||
const saveImage = (buffer: Buffer, ext: string, name: string) => {
|
||||
const fname = `${bugId}-${imgIndex}.${ext}`
|
||||
const filePath = pathMod.join(projectDir, fname)
|
||||
writeFileSync(filePath, buffer)
|
||||
// Store path relative to UPLOADS_DIR
|
||||
const relPath = `${projectName}/${fname}`
|
||||
const ssId = crypto.randomUUID()
|
||||
db.prepare(`INSERT INTO screenshots (id, bug_id, filename, original_name, name, annotated, sort_order, annotations, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, 0, ?, '[]', ?)`).run(
|
||||
ssId, bugId, relPath, fname, name, imgIndex, now
|
||||
)
|
||||
imgIndex++
|
||||
}
|
||||
|
||||
// 1) Extract images from steps (via fileID parameter)
|
||||
const stepsHtml = data.steps || ''
|
||||
// Match fileID=number pattern (Zentao standard image reference)
|
||||
const fileIdRegex = /fileID=(\d+)/g
|
||||
const seenFileIds = new Set<string>()
|
||||
let match
|
||||
while ((match = fileIdRegex.exec(stepsHtml)) !== null) {
|
||||
const fileId = match[1] ?? ''
|
||||
if (!fileId || seenFileIds.has(fileId)) continue
|
||||
seenFileIds.add(fileId)
|
||||
try {
|
||||
// Use Zentao standard file download API
|
||||
const imgUrl = `${config.url}/api.php?m=file&f=read&fileID=${fileId}`
|
||||
const imgRes = await fetch(imgUrl, { headers: fileHeaders })
|
||||
if (!imgRes.ok) continue
|
||||
const contentType = imgRes.headers.get('content-type') || ''
|
||||
if (!contentType.startsWith('image/')) continue
|
||||
const contentLength = parseInt(imgRes.headers.get('content-length') || '0')
|
||||
if (contentLength > 20 * 1024 * 1024) continue // Skip images over 20MB
|
||||
const buffer = Buffer.from(await imgRes.arrayBuffer())
|
||||
if (buffer.length > 20 * 1024 * 1024) continue
|
||||
const ext = contentType.includes('jpeg') ? 'jpg' : contentType.includes('png') ? 'png' : contentType.includes('webp') ? 'webp' : 'png'
|
||||
saveImage(buffer, ext, `Screenshot ${imgIndex + 1}`)
|
||||
} catch {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Images from history/comments
|
||||
for (const fileId of commentFileIds) {
|
||||
if (seenFileIds.has(fileId)) continue
|
||||
seenFileIds.add(fileId)
|
||||
try {
|
||||
const imgUrl = `${config.url}/api.php?m=file&f=read&fileID=${fileId}`
|
||||
const imgRes = await fetch(imgUrl, { headers: fileHeaders })
|
||||
if (!imgRes.ok) continue
|
||||
const contentType = imgRes.headers.get('content-type') || ''
|
||||
if (!contentType.startsWith('image/')) continue
|
||||
const buffer = Buffer.from(await imgRes.arrayBuffer())
|
||||
if (buffer.length > 20 * 1024 * 1024) continue
|
||||
const ext = contentType.includes('jpeg') ? 'jpg' : contentType.includes('png') ? 'png' : contentType.includes('webp') ? 'webp' : 'png'
|
||||
saveImage(buffer, ext, `Screenshot ${imgIndex + 1}`)
|
||||
} catch {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Images from file attachments
|
||||
if (data.files && Array.isArray(data.files)) {
|
||||
for (const file of data.files) {
|
||||
if (!file.pathname) continue
|
||||
const ext = (file.extension || '').toLowerCase()
|
||||
if (!['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(ext)) continue
|
||||
try {
|
||||
const fileUrl = `${config.url}/data/upload/${file.pathname}`
|
||||
const fileRes = await fetch(fileUrl, { headers: fileHeaders })
|
||||
if (!fileRes.ok) continue
|
||||
const buffer = Buffer.from(await fileRes.arrayBuffer())
|
||||
if (buffer.length > 20 * 1024 * 1024) continue // Skip images over 20MB
|
||||
saveImage(buffer, ext, file.title || `Screenshot ${imgIndex + 1}`)
|
||||
} catch {
|
||||
// Skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true, bugId, number })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Sync status back to Zentao (resolve bug)
|
||||
zentaoRouter.post('/resolve/:id', async (req, res) => {
|
||||
try {
|
||||
const config = getZentaoConfig()
|
||||
if (!config.url) return res.json({ ok: false, error: 'Zentao not configured' })
|
||||
const token = await getToken(config.url, config.account, config.password, config.httpUser, config.httpPass)
|
||||
const headers = makeBasicHeaders(config.httpUser, config.httpPass, { 'Content-Type': 'application/json', Token: token })
|
||||
const apiRes = await fetch(`${config.url}/api.php/v1/bugs/${req.params.id}/resolve`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ resolution: req.body.resolution || 'fixed' }),
|
||||
signal: AbortSignal.timeout(TIMEOUT),
|
||||
})
|
||||
if (!apiRes.ok) throw new Error(`HTTP ${apiRes.status}`)
|
||||
res.json({ ok: true })
|
||||
} catch (e: any) {
|
||||
res.json({ ok: false, error: e.message })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
title BugPack 一键启动脚本
|
||||
|
||||
:: ============================================
|
||||
:: BugPack 一键启动脚本
|
||||
:: ============================================
|
||||
|
||||
setlocal EnableDelayedExpansion
|
||||
|
||||
:: 项目目录(脚本所在目录)
|
||||
set "BUGPACK_DIR=%~dp0"
|
||||
set "PORT=3457"
|
||||
set "MSYS2_PATH=C:\msys64"
|
||||
|
||||
echo [信息] MSYS2 环境: !MSYS2_PATH!
|
||||
|
||||
:: 检查 node_modules
|
||||
if not exist "%BUGPACK_DIR%node_modules" (
|
||||
echo [警告] 未找到 node_modules,需要安装依赖...
|
||||
echo [信息] 正在安装依赖,请稍候...
|
||||
"!MSYS2_PATH!\usr\bin\bash.exe" -c "cd '%BUGPACK_DIR%' && export PATH='/mingw64/bin:/usr/bin:$PATH' && export LD_LIBRARY_PATH='/mingw64/bin:$LD_LIBRARY_PATH' && npm install"
|
||||
if errorlevel 1 (
|
||||
echo [错误] 依赖安装失败!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [信息] 依赖安装完成
|
||||
) else (
|
||||
echo [信息] node_modules 已存在
|
||||
)
|
||||
|
||||
:: 检查 dist
|
||||
if not exist "%BUGPACK_DIR%dist\client\index.html" (
|
||||
echo [警告] 未找到构建产物,需要构建...
|
||||
"!MSYS2_PATH!\usr\bin\bash.exe" -c "cd '%BUGPACK_DIR%' && export PATH='/mingw64/bin:/usr/bin:$PATH' && export LD_LIBRARY_PATH='/mingw64/bin:$LD_LIBRARY_PATH' && npm run build"
|
||||
if errorlevel 1 (
|
||||
echo [错误] 构建失败!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [信息] 构建完成
|
||||
) else (
|
||||
echo [信息] 构建产物已存在
|
||||
)
|
||||
|
||||
:: 检查端口
|
||||
set "ORIGINAL_PORT=%PORT%"
|
||||
:check_port
|
||||
netstat -ano | findstr ":%PORT% " | findstr "LISTENING" >nul
|
||||
if not errorlevel 1 (
|
||||
set /a PORT+=1
|
||||
goto :check_port
|
||||
)
|
||||
|
||||
:: 启动
|
||||
echo.
|
||||
echo ============================================
|
||||
echo BugPack 服务启动中...
|
||||
echo ============================================
|
||||
echo.
|
||||
if not "%ORIGINAL_PORT%"=="%PORT%" (
|
||||
echo [提示] 端口 %ORIGINAL_PORT% 被占用,已切换到 %PORT%
|
||||
echo.
|
||||
)
|
||||
echo [信息] 访问地址: http://localhost:%PORT%
|
||||
echo.
|
||||
|
||||
"!MSYS2_PATH!\usr\bin\bash.exe" -c "cd '%BUGPACK_DIR%' && export PATH='/mingw64/bin:/usr/bin:$PATH' && export LD_LIBRARY_PATH='/mingw64/bin:$LD_LIBRARY_PATH' && PORT=%PORT% npm run dev:server"
|
||||
|
||||
echo.
|
||||
echo [信息] 服务已停止
|
||||
echo.
|
||||
pause
|
||||
@@ -0,0 +1,128 @@
|
||||
# BugPack 一键启动脚本
|
||||
# 功能:自动配置环境并启动 BugPack 服务
|
||||
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$Host.UI.RawUI.WindowTitle = "BugPack 一键启动脚本"
|
||||
|
||||
# 项目目录
|
||||
$BUGPACK_DIR = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$PORT = 3457
|
||||
|
||||
# 自动检测 MSYS2 安装路径
|
||||
$MSYS2_PATHS = @("C:\msys64", "D:\msys64", "E:\msys64")
|
||||
$MSYS2_PATH = $null
|
||||
|
||||
foreach ($path in $MSYS2_PATHS) {
|
||||
if (Test-Path "$path\usr\bin\bash.exe") {
|
||||
$MSYS2_PATH = $path
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $MSYS2_PATH) {
|
||||
Write-Host "[错误] 未找到 MSYS2 环境!" -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "请确认 MSYS2 已安装,或手动修改脚本中的 MSYS2_PATHS 变量。"
|
||||
Write-Host "常见安装路径:C:\msys64, D:\msys64"
|
||||
Write-Host ""
|
||||
Read-Host "按回车键退出"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "[信息] MSYS2 环境已找到: $MSYS2_PATH" -ForegroundColor Green
|
||||
|
||||
# 检查 node_modules 是否存在
|
||||
if (-not (Test-Path "$BUGPACK_DIR\node_modules")) {
|
||||
Write-Host "[警告] 未找到 node_modules,需要安装依赖..." -ForegroundColor Yellow
|
||||
Write-Host "[信息] 正在安装依赖,请稍候(可能需要几分钟)..."
|
||||
Write-Host ""
|
||||
|
||||
$env:MSYSTEM = "MINGW64"
|
||||
$env:PATH = "$MSYS2_PATH\mingw64\bin;$MSYS2_PATH\usr\bin;$env:PATH"
|
||||
|
||||
Set-Location $BUGPACK_DIR
|
||||
& "$MSYS2_PATH\usr\bin\bash.exe" -lc "export PATH='/mingw64/bin:$PATH' && export LD_LIBRARY_PATH='/mingw64/bin:$LD_LIBRARY_PATH' && npm install"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host ""
|
||||
Write-Host "[错误] 依赖安装失败!" -ForegroundColor Red
|
||||
Write-Host "[提示] 请检查网络连接,或手动运行:npm install"
|
||||
Read-Host "按回车键退出"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[信息] 依赖安装完成" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "[信息] node_modules 已存在,跳过安装" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# 检查 dist 目录是否存在(构建产物)
|
||||
if (-not (Test-Path "$BUGPACK_DIR\dist\client\index.html")) {
|
||||
Write-Host "[警告] 未找到构建产物,需要构建项目..." -ForegroundColor Yellow
|
||||
Write-Host "[信息] 正在构建项目,请稍候..."
|
||||
Write-Host ""
|
||||
|
||||
$env:MSYSTEM = "MINGW64"
|
||||
$env:PATH = "$MSYS2_PATH\mingw64\bin;$MSYS2_PATH\usr\bin;$env:PATH"
|
||||
|
||||
Set-Location $BUGPACK_DIR
|
||||
& "$MSYS2_PATH\usr\bin\bash.exe" -lc "export PATH='/mingw64/bin:$PATH' && export LD_LIBRARY_PATH='/mingw64/bin:$LD_LIBRARY_PATH' && npm run build"
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host ""
|
||||
Write-Host "[错误] 项目构建失败!" -ForegroundColor Red
|
||||
Write-Host "[提示] 请检查 src/server/index.ts 中的静态文件路径配置"
|
||||
Read-Host "按回车键退出"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "[信息] 项目构建完成" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "[信息] 构建产物已存在,跳过构建" -ForegroundColor Green
|
||||
}
|
||||
|
||||
# 检查端口是否被占用,如果被占用则端口号+1,直到找到可用端口
|
||||
$ORIGINAL_PORT = $PORT
|
||||
Write-Host "[信息] 检查端口 $PORT 是否可用..."
|
||||
|
||||
while ($true) {
|
||||
$connection = Get-NetTCPConnection -LocalPort $PORT -ErrorAction SilentlyContinue
|
||||
if (-not $connection) {
|
||||
break
|
||||
}
|
||||
Write-Host "[警告] 端口 $PORT 已被占用,尝试端口 $($PORT+1)..." -ForegroundColor Yellow
|
||||
$PORT++
|
||||
}
|
||||
|
||||
Write-Host "[信息] 端口 $PORT 可用!" -ForegroundColor Green
|
||||
|
||||
# 启动服务
|
||||
Write-Host ""
|
||||
Write-Host "============================================"
|
||||
Write-Host " BugPack 服务启动中..."
|
||||
Write-Host "============================================"
|
||||
Write-Host ""
|
||||
|
||||
if ($ORIGINAL_PORT -ne $PORT) {
|
||||
Write-Host "[提示] 默认端口 $ORIGINAL_PORT 被占用,已自动切换到端口 $PORT" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
}
|
||||
|
||||
Write-Host "[信息] 项目目录: $BUGPACK_DIR"
|
||||
Write-Host "[信息] 访问地址: http://localhost:$PORT" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host "[提示] 按 Ctrl+C 可停止服务"
|
||||
Write-Host ""
|
||||
|
||||
# 使用 MSYS2 bash 启动服务
|
||||
$env:MSYSTEM = "MINGW64"
|
||||
$env:PATH = "$MSYS2_PATH\mingw64\bin;$MSYS2_PATH\usr\bin;$env:PATH"
|
||||
|
||||
Set-Location $BUGPACK_DIR
|
||||
& "$MSYS2_PATH\usr\bin\bash.exe" -lc "export PATH='/mingw64/bin:$PATH' && export LD_LIBRARY_PATH='/mingw64/bin:$LD_LIBRARY_PATH' && PORT=$PORT npm run dev:server"
|
||||
|
||||
# 服务停止后的提示
|
||||
Write-Host ""
|
||||
Write-Host "============================================"
|
||||
Write-Host "[信息] BugPack 服务已停止"
|
||||
Write-Host "============================================"
|
||||
Write-Host ""
|
||||
Read-Host "按回车键退出"
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
export default {
|
||||
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: {
|
||||
primary: 'var(--bg-primary)',
|
||||
sidebar: 'var(--bg-sidebar)',
|
||||
card: 'var(--bg-card)',
|
||||
input: 'var(--bg-input)',
|
||||
hover: 'var(--bg-hover)',
|
||||
},
|
||||
border: {
|
||||
DEFAULT: 'var(--border)',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'var(--accent)',
|
||||
hover: 'var(--accent-hover)',
|
||||
},
|
||||
cyan: {
|
||||
DEFAULT: '#22D3EE',
|
||||
},
|
||||
status: {
|
||||
pending: '#EF4444',
|
||||
annotating: '#F59E0B',
|
||||
generated: '#3B82F6',
|
||||
fixed: '#22C55E',
|
||||
closed: '#6B7280',
|
||||
},
|
||||
text: {
|
||||
primary: 'var(--text-primary)',
|
||||
secondary: 'var(--text-secondary)',
|
||||
muted: 'var(--text-muted)',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'PingFang SC',
|
||||
'Microsoft YaHei',
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'sans-serif',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/client/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": false
|
||||
},
|
||||
"include": ["src/server", "src/mcp"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist/client',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src/client'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3456,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3457',
|
||||
'/uploads': 'http://localhost:3457',
|
||||
},
|
||||
},
|
||||
})
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+102
@@ -0,0 +1,102 @@
|
||||
# QuanYiXiaoZhuShou 测试项目
|
||||
|
||||
## 项目概述
|
||||
|
||||
本项目是一个**后端测试项目**,主要用于对后端 SDK API 进行自动化测试、微信小程序交互黑盒测试以及 Web 端管理后台的自动化测试。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
test/
|
||||
├── .trae/
|
||||
│ └── skills/
|
||||
│ └── bugpack-deploy/ # BugPack 部署 Skill
|
||||
├── BugPack-main/ # BugPack Bug 管理工具(已部署)
|
||||
│ ├── src/
|
||||
│ │ ├── client/ # 前端 React 应用
|
||||
│ │ ├── server/ # 后端 Express API
|
||||
│ │ └── mcp/ # MCP Server 实现
|
||||
│ ├── dist/ # 构建产物
|
||||
│ ├── start-bugpack.bat # 一键启动脚本
|
||||
│ └── package.json
|
||||
├── BugPack.zip # BugPack 源码包
|
||||
├── msys2-base.tar.xz # MSYS2 基础包
|
||||
├── msys2-installer.exe # MSYS2 安装程序
|
||||
└── node.zip # Node.js 便携包
|
||||
```
|
||||
|
||||
## 当前状态
|
||||
|
||||
**阶段:项目初始化 - 尚未开始开发**
|
||||
|
||||
本项目目前处于初始化阶段,主要完成了以下准备工作:
|
||||
- ✅ BugPack 工具部署(用于 Bug 管理)
|
||||
- ✅ MSYS2 环境配置
|
||||
- ✅ Node.js 环境配置
|
||||
- ⬜ 后端 SDK API 测试用例编写
|
||||
- ⬜ 微信小程序测试用例编写
|
||||
- ⬜ Web 管理后台测试用例编写
|
||||
|
||||
## 开发里程碑
|
||||
|
||||
| 里程碑 | 阶段 | 状态 | 计划完成时间 | 实际完成时间 | 备注 |
|
||||
|--------|------|------|-------------|-------------|------|
|
||||
| M1 | 项目初始化与环境搭建 | 🟢 已完成 | 2026-05-01 | 2026-05-01 | BugPack部署、MSYS2配置 |
|
||||
| M2 | 后端 SDK API 测试框架搭建 | ⬜ 未开始 | - | - | 等待 API 文档 |
|
||||
| M3 | 后端 SDK API 测试用例编写 | ⬜ 未开始 | - | - | 等待 API 文档 |
|
||||
| M4 | 微信小程序测试用例编写 | ⬜ 未开始 | - | - | 等待交互设计文档 |
|
||||
| M5 | Web 管理后台自动化测试 | ⬜ 未开始 | - | - | 等待交互设计文档 |
|
||||
| M6 | 测试执行与 Bug 上报 | ⬜ 未开始 | - | - | 依赖 M2-M5 |
|
||||
| M7 | 测试报告生成与归档 | ⬜ 未开始 | - | - | 项目收尾 |
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 |
|
||||
|------|------|
|
||||
| Bug 管理 | BugPack (React + Express + SQLite) |
|
||||
| 测试框架 | Playwright (Web 自动化测试) |
|
||||
| 脚本环境 | MSYS2 + Node.js |
|
||||
| 包管理 | npm |
|
||||
|
||||
## 强制要求
|
||||
|
||||
1. **上下文加载**:每次会话开始时,必须在上下文中加载 `project.md` 文件,了解项目当前状态和里程碑进度。
|
||||
|
||||
2. **意图确认**:每次接收到任务后,必须仔细分析用户意图。如有不理解或认为意图模糊时,**必须反问用户**,确认好意图后再执行。
|
||||
|
||||
3. **Agent 协同**:执行任务时,应在当前合适的 Agent 配置中选取 Agent 调用。如果能同时协同多个 Agent,则协同调用多个 Agent 完成任务,提升任务效率。
|
||||
|
||||
4. **Bug 管理**:**强制使用 BugPack MCP 服务**来维护 BUG 信息。所有发现的 Bug 必须通过 BugPack 记录和跟踪。
|
||||
|
||||
## 核心原则
|
||||
|
||||
> **你只负责根据用户提供的 API 文档、交互设计文档,编写测试用例,不涉及任何问题分析和业务代码修改。**
|
||||
|
||||
### 测试范围界定
|
||||
|
||||
| 测试类型 | 职责 | 输出物 |
|
||||
|---------|------|--------|
|
||||
| **后端 SDK API 测试** | 编写测试用例及相关测试代码,完成测试并上报 BUG | 测试代码 + BugPack Bug 记录 |
|
||||
| **微信小程序黑盒测试** | 根据交互文档编写测试用例,告知用户执行即可 | 测试用例文档 |
|
||||
| **Web 端管理后台测试** | 使用 Playwright MCP 服务自主进行测试,完成 Bug 上报 | 自动化测试脚本 + BugPack Bug 记录 |
|
||||
|
||||
### 禁止事项
|
||||
|
||||
- ❌ 不允许修改业务代码
|
||||
- ❌ 不允许进行问题根因分析(只记录现象)
|
||||
- ❌ 不允许超出测试用例范围的代码编写
|
||||
- ❌ 不允许手动执行微信小程序测试(仅提供用例)
|
||||
|
||||
## BugPack 服务信息
|
||||
|
||||
- **服务地址**:http://localhost:3459
|
||||
- **项目名**:QuanYiXiaoZhuShou
|
||||
- **启动方式**:双击 `BugPack-main/start-bugpack.bat`
|
||||
|
||||
## 待办事项
|
||||
|
||||
- [ ] 获取后端 SDK API 文档
|
||||
- [ ] 获取微信小程序交互设计文档
|
||||
- [ ] 获取 Web 管理后台交互设计文档
|
||||
- [ ] 搭建 API 测试框架
|
||||
- [ ] 编写第一批测试用例
|
||||
Reference in New Issue
Block a user