Release v0.1.0: 小程序使用建议跳转腾讯文档 + 配置导入导出功能

This commit is contained in:
权益小助手开发
2026-05-04 17:09:07 +08:00
commit 7d90523164
89 changed files with 18289 additions and 0 deletions
+195
View File
@@ -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>
)
}
+264
View File
@@ -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' ? '项目 IDworkspace_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 项目 IDworkspace_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,
}
}
+139
View File
@@ -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]
}
+139
View File
@@ -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: '保存',
},
}
+67
View File
@@ -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;
}
+10
View File
@@ -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(`![${leftLabel}](${leftSS.url})`)
lines.push('')
}
if (rightSS) {
const rightLabel = rightSS.name || `${isZh ? '截图' : 'Screenshot'} ${compare.rightIndex + 1}`
lines.push(`### ${isZh ? '期望效果' : 'Expected Result'}${rightLabel}`)
lines.push(`![${rightLabel}](${rightSS.url})`)
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(`![${label}](${ss.url})`)
lines.push('')
})
} else {
bug.screenshots.forEach((ss, i) => {
const label = ss.name || `${isZh ? '截图' : 'Screenshot'} ${i + 1}`
lines.push(`### ${label}`)
lines.push(`![${label}](${ss.url})`)
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')
}
+365
View File
@@ -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) })
+108
View File
@@ -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 }
+63
View File
@@ -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}`)
})
+368
View File
@@ -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 })
})
+229
View File
@@ -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 })
})
+280
View File
@@ -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 })
}
})