init: Todo Monitor

This commit is contained in:
2026-08-02 02:50:27 +08:00
parent 278a7e90b0
commit 81dd92cf22
43 changed files with 7483 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
node_modules/
dist/
docs/
*.db
*.db-*
*.sqlite
.DS_Store
Thumbs.db
*.log
.env
.env.local
+112
View File
@@ -0,0 +1,112 @@
# Todo Monitor
双视图待办管理工具——在无限画布上创建 Markdown 便签,一键切换为结构化监控大屏。
## 技术栈
| 层级 | 技术 |
|------|------|
| 前端 | Vue 3 + Composition API + TypeScript |
| 画布 | Konva.js |
| 状态管理 | Pinia |
| 构建 | Vite |
| 后端 | Express + better-sqlite3 |
| 数据库 | SQLite |
## 快速开始
```bash
# 启动后端(端口 3001
cd backend
npm install
npm run dev
# 启动前端(端口 5173
cd frontend
npm install
npm run dev
```
浏览器打开 `http://localhost:5173`
## 核心功能
### 无限画布
- 双击空画布创建便签,支持 Markdown 编辑
- 拖拽便签移动 / 右下角缩放
- 滚轮缩放画布,空画布拖拽平移视角
- 分组框:拖拽绘制矩形区域,便签落入框内自动归属分组
### 监控大屏
- 便签自动解析为监控卡片,5 种类型:任务卡、里程碑卡、指标卡、倒计时卡、项目卡
- 看板 / 列表 / 网格三种布局
- 按状态、优先级、标签等维度筛选排序
- 统计概览栏:总数 / 进行中 / 已完成 / 逾期 / 阻塞
### 选择系统
- 指针 / 便签 / 分组 / 框选 四种工具模式
- Ctrl+点击选中对象,框选批量选中
- 选中多个对象后整体拖拽移动
### 语法解析
支持三种 Markdown 解析模式,详见 [语法说明](语法说明.md)
1. **YAML Front Matter** — 结构化元数据
2. **约定式 Markdown** — 按自然书写风格自动识别
3. **行内快捷字段**`@字段名(值)` 快速标注
编辑器内 `Ctrl+S` 保存。
## 项目结构
```
todomonitor/
├── backend/
│ └── src/
│ ├── index.js # Express 入口
│ ├── db.js # SQLite 初始化
│ └── routes/
│ ├── notes.js # 便签 CRUD
│ ├── groups.js # 分组 CRUD
│ └── batch.js # 批量操作
├── frontend/
│ └── src/
│ ├── App.vue
│ ├── components/
│ │ ├── canvas/ # 画布视图组件
│ │ ├── dashboard/ # 大屏视图组件
│ │ ├── shared/ # 共享 UI 组件
│ │ └── layout/ # 导航栏
│ ├── stores/ # Pinia 状态管理
│ ├── utils/ # API 客户端 / 解析引擎
│ ├── types/ # TypeScript 类型
│ └── composables/ # 组合式函数
├── 需求.md
├── 语法说明.md
└── README.md
```
## 数据模型
### 便签 (notes)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER | 主键 |
| content | TEXT | Markdown 内容 |
| x, y | REAL | 画布坐标 |
| width, height | REAL | 尺寸 |
| color | TEXT | 背景色 |
| group_id | INTEGER | 所属分组 |
| created_at | TEXT | 创建时间 |
| updated_at | TEXT | 更新时间 |
### 分组 (groups)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | INTEGER | 主键 |
| name | TEXT | 分组名称 |
| x, y | REAL | 坐标 |
| width, height | REAL | 尺寸 |
| color | TEXT | 边框颜色 |
| priority | INTEGER | 排序优先级 (0-99) |
+1307
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
{
"name": "todomonitor-backend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch src/index.js",
"start": "node src/index.js"
},
"dependencies": {
"better-sqlite3": "^11.0.0",
"cors": "^2.8.5",
"express": "^4.21.0",
"js-yaml": "^4.1.0"
}
}
+65
View File
@@ -0,0 +1,65 @@
import Database from 'better-sqlite3'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DB_PATH = path.join(__dirname, '..', 'todomonitor.db')
let db
export function getDb() {
if (!db) {
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
initTables()
}
return db
}
function initTables() {
db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL DEFAULT '',
x REAL NOT NULL DEFAULT 0,
y REAL NOT NULL DEFAULT 0,
width REAL NOT NULL DEFAULT 280,
height REAL NOT NULL DEFAULT 200,
color TEXT NOT NULL DEFAULT '#fef9c3',
group_id INTEGER,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '新分组',
x REAL NOT NULL DEFAULT 0,
y REAL NOT NULL DEFAULT 0,
width REAL NOT NULL DEFAULT 400,
height REAL NOT NULL DEFAULT 300,
color TEXT NOT NULL DEFAULT '#e2e8f0',
priority INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
`)
try {
db.exec('ALTER TABLE groups ADD COLUMN priority INTEGER NOT NULL DEFAULT 0')
} catch (_) {}
}
export function closeDb() {
if (db) {
db.close()
db = null
}
}
+38
View File
@@ -0,0 +1,38 @@
import express from 'express'
import cors from 'cors'
import notesRouter from './routes/notes.js'
import groupsRouter from './routes/groups.js'
import batchRouter from './routes/batch.js'
import { errorHandler, notFound } from './middleware/errorHandler.js'
import { closeDb } from './db.js'
const app = express()
const PORT = process.env.PORT || 3001
app.use(cors())
app.use(express.json({ limit: '1mb' }))
app.use('/api/notes', notesRouter)
app.use('/api/groups', groupsRouter)
app.use('/api/batch', batchRouter)
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() })
})
app.use(notFound)
app.use(errorHandler)
const server = app.listen(PORT, () => {
console.log(`[TodoMonitor Backend] 运行在 http://localhost:${PORT}`)
})
process.on('SIGTERM', () => {
closeDb()
server.close()
})
process.on('SIGINT', () => {
closeDb()
server.close()
})
+10
View File
@@ -0,0 +1,10 @@
export function errorHandler(err, _req, res, _next) {
console.error('[Error]', err.message)
res.status(err.status || 500).json({
error: err.message || '服务器内部错误'
})
}
export function notFound(_req, res) {
res.status(404).json({ error: '接口不存在' })
}
+27
View File
@@ -0,0 +1,27 @@
import { Router } from 'express'
import { getDb } from '../db.js'
const router = Router()
router.post('/', (req, res) => {
const db = getDb()
const { notes = [] } = req.body
const updateNote = db.prepare(`
UPDATE notes
SET x = ?, y = ?, width = ?, height = ?, group_id = ?,
updated_at = datetime('now', 'localtime')
WHERE id = ?
`)
const transaction = db.transaction((items) => {
for (const item of items) {
updateNote.run(item.x, item.y, item.width, item.height, item.group_id ?? null, item.id)
}
})
transaction(notes)
res.json({ success: true, count: notes.length })
})
export default router
+68
View File
@@ -0,0 +1,68 @@
import { Router } from 'express'
import { getDb } from '../db.js'
const router = Router()
router.get('/', (_req, res) => {
const db = getDb()
const groups = db.prepare('SELECT * FROM groups ORDER BY created_at ASC').all()
res.json(groups)
})
router.post('/', (req, res) => {
const db = getDb()
const { name = '新分组', x = 100, y = 100, width = 400, height = 300, color = '#e2e8f0', priority = 0 } = req.body
const stmt = db.prepare(`
INSERT INTO groups (name, x, y, width, height, color, priority)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
const result = stmt.run(name, x, y, width, height, color, priority)
const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(result.lastInsertRowid)
res.status(201).json(group)
})
router.put('/:id', (req, res) => {
const db = getDb()
const { id } = req.params
const existing = db.prepare('SELECT * FROM groups WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({ error: '分组不存在' })
}
const name = req.body.name ?? existing.name
const x = req.body.x ?? existing.x
const y = req.body.y ?? existing.y
const width = req.body.width ?? existing.width
const height = req.body.height ?? existing.height
const color = req.body.color ?? existing.color
const priority = req.body.priority ?? existing.priority
db.prepare(`
UPDATE groups
SET name = ?, x = ?, y = ?, width = ?, height = ?, color = ?, priority = ?,
updated_at = datetime('now', 'localtime')
WHERE id = ?
`).run(name, x, y, width, height, color, priority, id)
const updated = db.prepare('SELECT * FROM groups WHERE id = ?').get(id)
res.json(updated)
})
router.delete('/:id', (req, res) => {
const db = getDb()
const { id } = req.params
const existing = db.prepare('SELECT * FROM groups WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({ error: '分组不存在' })
}
db.prepare('UPDATE notes SET group_id = NULL WHERE group_id = ?').run(id)
db.prepare('DELETE FROM groups WHERE id = ?').run(id)
res.json({ success: true })
})
export default router
+67
View File
@@ -0,0 +1,67 @@
import { Router } from 'express'
import { getDb } from '../db.js'
const router = Router()
router.get('/', (_req, res) => {
const db = getDb()
const notes = db.prepare('SELECT * FROM notes ORDER BY updated_at DESC').all()
res.json(notes)
})
router.post('/', (req, res) => {
const db = getDb()
const { content = '', x = 200, y = 200, width = 280, height = 200, color = '#fef9c3', group_id = null } = req.body
const stmt = db.prepare(`
INSERT INTO notes (content, x, y, width, height, color, group_id)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
const result = stmt.run(content, x, y, width, height, color, group_id)
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(result.lastInsertRowid)
res.status(201).json(note)
})
router.put('/:id', (req, res) => {
const db = getDb()
const { id } = req.params
const existing = db.prepare('SELECT * FROM notes WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({ error: '便签不存在' })
}
const content = req.body.content ?? existing.content
const x = req.body.x ?? existing.x
const y = req.body.y ?? existing.y
const width = req.body.width ?? existing.width
const height = req.body.height ?? existing.height
const color = req.body.color ?? existing.color
const group_id = req.body.group_id !== undefined ? req.body.group_id : existing.group_id
db.prepare(`
UPDATE notes
SET content = ?, x = ?, y = ?, width = ?, height = ?, color = ?, group_id = ?,
updated_at = datetime('now', 'localtime')
WHERE id = ?
`).run(content, x, y, width, height, color, group_id, id)
const updated = db.prepare('SELECT * FROM notes WHERE id = ?').get(id)
res.json(updated)
})
router.delete('/:id', (req, res) => {
const db = getDb()
const { id } = req.params
const existing = db.prepare('SELECT * FROM notes WHERE id = ?').get(id)
if (!existing) {
return res.status(404).json({ error: '便签不存在' })
}
db.prepare('DELETE FROM notes WHERE id = ?').run(id)
res.json({ success: true })
})
export default router
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%234dabf7'/><text x='16' y='22' text-anchor='middle' font-size='18' font-weight='bold' fill='white'>T</text></svg>" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<title>Todo Monitor</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1545
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "todomonitor-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"dompurify": "^3.1.0",
"js-yaml": "^5.2.3",
"konva": "^9.3.6",
"marked": "^14.0.0",
"pinia": "^2.2.0",
"vue": "^3.5.0"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "~5.6.0",
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"
}
}
+39
View File
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import TopNav from '@/components/layout/TopNav.vue'
import CanvasView from '@/components/canvas/CanvasView.vue'
import DashboardView from '@/components/dashboard/DashboardView.vue'
import { useUiStore } from '@/stores/ui'
import { useNotesStore } from '@/stores/notes'
import { useGroupsStore } from '@/stores/groups'
const ui = useUiStore()
const notesStore = useNotesStore()
const groupsStore = useGroupsStore()
onMounted(() => {
notesStore.fetchNotes()
groupsStore.fetchGroups()
})
</script>
<template>
<TopNav />
<main class="app-main" :class="{ 'is-dashboard': ui.viewMode === 'dashboard' }">
<CanvasView v-if="ui.viewMode === 'canvas'" />
<DashboardView v-else />
</main>
</template>
<style scoped>
.app-main {
flex: 1;
overflow: hidden;
background: var(--bg-canvas);
transition: background 0.3s ease;
}
.app-main.is-dashboard {
background: var(--bg-dashboard);
}
</style>
+94
View File
@@ -0,0 +1,94 @@
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-dashboard: #0b1119;
--bg-canvas: #f5f6f8;
--bg-nav: #0b1119;
--surface-dashboard: #141c26;
--surface-canvas: #ffffff;
--border-dashboard: #1e293b;
--border-canvas: #e2e6ea;
--accent: #38bdf8;
--accent-hover: #0ea5e9;
--accent-soft: rgba(56, 189, 248, 0.12);
--status-todo: #64748b;
--status-progress: #38bdf8;
--status-done: #22c55e;
--status-blocked: #f59e0b;
--status-overdue: #ef4444;
--priority-urgent: #ef4444;
--priority-high: #f97316;
--priority-medium: #38bdf8;
--priority-low: #64748b;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-canvas: #1e293b;
--text-canvas-secondary: #64748b;
--font-ui: 'Inter', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--nav-height: 48px;
}
html, body {
height: 100%;
overflow: hidden;
font-family: var(--font-ui);
font-size: 14px;
line-height: 1.5;
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
height: 100%;
display: flex;
flex-direction: column;
}
button {
font-family: inherit;
cursor: pointer;
border: none;
background: none;
color: inherit;
}
input, textarea {
font-family: inherit;
font-size: inherit;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #334155;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #475569;
}
@@ -0,0 +1,916 @@
<script setup lang="ts">
import { shallowRef, onMounted, onUnmounted, watch } from 'vue'
import Konva from 'konva'
import { useNotesStore } from '@/stores/notes'
import { useGroupsStore } from '@/stores/groups'
import { useUiStore } from '@/stores/ui'
import type { Note, Group } from '@/types/note'
import NoteEditor from './NoteEditor.vue'
import GroupEditor from './GroupEditor.vue'
type ToolMode = 'pointer' | 'note' | 'frame' | 'select'
const notesStore = useNotesStore()
const groupsStore = useGroupsStore()
const ui = useUiStore()
const containerRef = shallowRef<HTMLDivElement | null>(null)
const editingNote = shallowRef<Note | null>(null)
const editorVisible = shallowRef(false)
const editingGroup = shallowRef<Group | null>(null)
const groupEditorVisible = shallowRef(false)
const activeTool = shallowRef<ToolMode>('pointer')
const selectedNoteIds = shallowRef(new Set<number>())
const selectedGroupIds = shallowRef(new Set<number>())
let stage: Konva.Stage | null = null
let noteLayer: Konva.Layer | null = null
let frameLayer: Konva.Layer | null = null
let selectionLayer: Konva.Layer | null = null
let isPanning = false
let lastPointer = { x: 0, y: 0 }
let drawStartPoint: { x: number; y: number } | null = null
let drawRect: Konva.Rect | null = null
let lastUiWriteTime = 0
let isDragMovingSelected = false
let dragMoveDelta = { x: 0, y: 0 }
let headerDragActive = false
function throttledWriteUi(x: number, y: number, scale?: number) {
const now = Date.now()
if (now - lastUiWriteTime < 200) return
lastUiWriteTime = now
ui.canvasOffsetX = x
ui.canvasOffsetY = y
if (scale !== undefined) ui.canvasScale = scale
}
const noteNodeMap = new Map<number, Konva.Group>()
const frameNodeMap = new Map<number, Konva.Group>()
function initStage() {
if (!containerRef.value) return
stage = new Konva.Stage({
container: containerRef.value,
width: containerRef.value.clientWidth,
height: containerRef.value.clientHeight,
})
frameLayer = new Konva.Layer()
selectionLayer = new Konva.Layer()
noteLayer = new Konva.Layer()
stage.add(frameLayer)
stage.add(selectionLayer)
stage.add(noteLayer)
setupZoom()
setupPan()
setupDrawing()
stage.on('click tap', handleCanvasClick)
window.addEventListener('resize', handleResize)
updateCursor()
}
function setupZoom() {
if (!stage) return
stage.on('wheel', (e) => {
e.evt.preventDefault()
const oldScale = stage!.scaleX()
const pointer = stage!.getPointerPosition()
if (!pointer) return
const factor = 1.08
const newScale = e.evt.deltaY > 0 ? oldScale / factor : oldScale * factor
const clamped = Math.max(0.15, Math.min(3.5, newScale))
const mouseTo = {
x: (pointer.x - stage!.x()) / oldScale,
y: (pointer.y - stage!.y()) / oldScale,
}
stage!.scale({ x: clamped, y: clamped })
stage!.position({ x: pointer.x - mouseTo.x * clamped, y: pointer.y - mouseTo.y * clamped })
throttledWriteUi(stage!.x(), stage!.y(), clamped)
})
}
function setupPan() {
if (!stage) return
const container = stage.container()
container.tabIndex = 0
container.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.code === 'Space' && !editorVisible.value) {
e.preventDefault()
isPanning = true
container.style.cursor = 'grab'
}
})
container.addEventListener('keyup', (e: KeyboardEvent) => {
if (e.code === 'Space') {
isPanning = false
updateCursor()
}
})
stage.on('mousedown', (e) => {
if (isPanning || drawRect || e.evt.button !== 0) return
if (e.target !== stage) return
const tool = activeTool.value
if ((tool === 'note' || tool === 'pointer') && !editorVisible.value) {
isPanning = true
lastPointer = stage!.getPointerPosition() || lastPointer
}
})
stage.on('mouseup', () => {
if (isPanning && (activeTool.value === 'note' || activeTool.value === 'pointer')) {
isPanning = false
updateCursor()
}
})
stage.on('mousemove', () => {
if (!isPanning) return
const p = stage!.getPointerPosition()
if (!p) return
stage!.position({ x: stage!.x() + (p.x - lastPointer.x), y: stage!.y() + (p.y - lastPointer.y) })
lastPointer = p
throttledWriteUi(stage!.x(), stage!.y())
})
}
function updateCursor() {
if (!stage) return
const container = stage.container()
if (activeTool.value === 'frame' || activeTool.value === 'select') {
container.style.cursor = 'crosshair'
} else if (isPanning) {
container.style.cursor = 'grabbing'
} else if (activeTool.value === 'note' || activeTool.value === 'pointer') {
container.style.cursor = 'grab'
}
}
function setupDrawing() {
if (!stage) return
stage.on('mousedown', (e) => {
if (isPanning || e.evt.button !== 0) return
if (e.target !== stage) return
const tool = activeTool.value
if ((tool === 'frame' || tool === 'select') && !drawRect) {
const pointer = stage!.getPointerPosition()
if (!pointer) return
const s = stage!.scaleX()
drawStartPoint = {
x: (pointer.x - stage!.x()) / s,
y: (pointer.y - stage!.y()) / s,
}
const color = tool === 'select' ? '#a78bfa' : 'var(--accent)'
drawRect = new Konva.Rect({
x: drawStartPoint.x, y: drawStartPoint.y,
width: 0, height: 0,
fill: tool === 'select' ? 'rgba(167,139,250,0.08)' : 'rgba(56,189,248,0.06)',
stroke: color,
strokeWidth: 1, dash: [6, 3],
listening: false,
})
selectionLayer?.add(drawRect)
}
})
stage.on('mousemove', () => {
if (!drawRect || !drawStartPoint) return
const pointer = stage!.getPointerPosition()
if (!pointer) return
const s = stage!.scaleX()
const cx = (pointer.x - stage!.x()) / s
const cy = (pointer.y - stage!.y()) / s
const x = Math.min(drawStartPoint.x, cx)
const y = Math.min(drawStartPoint.y, cy)
drawRect.position({ x, y })
drawRect.width(Math.abs(cx - drawStartPoint.x))
drawRect.height(Math.abs(cy - drawStartPoint.y))
selectionLayer?.batchDraw()
})
stage.on('mouseup', async () => {
if (!drawRect || !drawStartPoint) return
const tool = activeTool.value
if (drawRect.width() > 10 && drawRect.height() > 10) {
if (tool === 'frame') {
const group = await groupsStore.createGroup({
x: drawRect.x(), y: drawRect.y(),
width: drawRect.width(), height: drawRect.height(),
})
assignNotesToFrame(group)
} else if (tool === 'select') {
selectInRect(drawRect.x(), drawRect.y(), drawRect.width(), drawRect.height())
}
}
drawRect?.destroy()
drawRect = null
drawStartPoint = null
selectionLayer?.batchDraw()
})
stage.on('dblclick', (e) => {
if (e.target !== stage) return
const tool = activeTool.value
if (tool !== 'note') return
const pointer = stage!.getPointerPosition()
if (!pointer) return
const s = stage!.scaleX()
createNote(
(pointer.x - stage!.x()) / s,
(pointer.y - stage!.y()) / s,
)
})
}
function handleCanvasClick(e: Konva.KonvaEventObject<MouseEvent>) {
if (e.target !== stage) return
if (e.evt.ctrlKey || e.evt.metaKey) return
clearSelection()
}
function clearSelection() {
if (selectedNoteIds.value.size === 0 && selectedGroupIds.value.size === 0) return
const oldNoteIds = new Set(selectedNoteIds.value)
const oldGroupIds = new Set(selectedGroupIds.value)
selectedNoteIds.value = new Set()
selectedGroupIds.value = new Set()
for (const id of oldNoteIds) refreshNoteNode(id)
for (const id of oldGroupIds) refreshFrameNode(id)
}
function selectInRect(x: number, y: number, w: number, h: number) {
selectedNoteIds.value = new Set()
selectedGroupIds.value = new Set()
for (const note of notesStore.notes) {
const cx = note.x + note.width / 2
const cy = note.y + note.height / 2
if (cx >= x && cx <= x + w && cy >= y && cy <= y + h) {
selectedNoteIds.value.add(note.id)
}
}
for (const group of groupsStore.groups) {
const cx = group.x + group.width / 2
const cy = group.y + group.height / 2
if (cx >= x && cx <= x + w && cy >= y && cy <= y + h) {
selectedGroupIds.value.add(group.id)
}
}
for (const note of notesStore.notes) refreshNoteNode(note.id)
for (const group of groupsStore.groups) refreshFrameNode(group.id)
}
function toggleNoteSelection(noteId: number) {
const next = new Set(selectedNoteIds.value)
if (next.has(noteId)) next.delete(noteId)
else next.add(noteId)
selectedNoteIds.value = next
refreshNoteNode(noteId)
}
function toggleGroupSelection(groupId: number) {
const next = new Set(selectedGroupIds.value)
if (next.has(groupId)) next.delete(groupId)
else next.add(groupId)
selectedGroupIds.value = next
refreshFrameNode(groupId)
}
function isNoteSelected(id: number) { return selectedNoteIds.value.has(id) }
function isGroupSelected(id: number) { return selectedGroupIds.value.has(id) }
function hasAnySelection() { return selectedNoteIds.value.size > 0 || selectedGroupIds.value.size > 0 }
function refreshNoteNode(id: number) {
const existing = noteNodeMap.get(id)
if (!existing) return
const bg = existing.findOne('.bg') as Konva.Rect | undefined
if (!bg) return
const selected = isNoteSelected(id)
bg.stroke(selected ? '#38bdf8' : 'rgba(0,0,0,0.06)')
bg.strokeWidth(selected ? 2 : 1)
noteLayer?.batchDraw()
}
function refreshFrameNode(id: number) {
const existing = frameNodeMap.get(id)
if (!existing) return
const rect = existing.findOne('.frame-bg') as Konva.Rect | undefined
if (!rect) return
const group = groupsStore.groups.find((g) => g.id === id)
const selected = isGroupSelected(id)
rect.stroke(selected ? '#38bdf8' : (group?.color ?? '#e2e8f0'))
rect.opacity(selected ? 0.9 : 0.6)
frameLayer?.batchDraw()
}
function getToolForObject(_target: Konva.Shape): ToolMode | null {
const tool = activeTool.value
if (tool === 'frame' || tool === 'select') return null
return tool
}
function handleResize() {
if (!stage || !containerRef.value) return
stage.width(containerRef.value.clientWidth)
stage.height(containerRef.value.clientHeight)
}
async function createNote(x: number, y: number) {
const note = await notesStore.createNote({ x, y, content: '' })
renderNote(note)
openEditor(note)
}
async function assignNotesToFrame(group: Group) {
for (const note of notesStore.notes) {
const cx = note.x + note.width / 2
const cy = note.y + note.height / 2
if (cx >= group.x && cx <= group.x + group.width && cy >= group.y && cy <= group.y + group.height) {
if (note.group_id !== group.id) {
await notesStore.updateNote(note.id, { group_id: group.id })
}
}
}
}
function checkGroupMembership(nx: number, ny: number, nw: number, nh: number): number | null {
for (const group of groupsStore.groups) {
const cx = nx + nw / 2
const cy = ny + nh / 2
if (cx >= group.x && cx <= group.x + group.width && cy >= group.y && cy <= group.y + group.height) {
return group.id
}
}
return null
}
function renderNote(note: Note) {
if (!noteLayer) return
const existing = noteNodeMap.get(note.id)
if (existing) { existing.destroy(); noteNodeMap.delete(note.id) }
const selected = isNoteSelected(note.id)
const kg = new Konva.Group({
id: `note-${note.id}`,
x: note.x, y: note.y,
width: note.width, height: note.height,
draggable: true,
})
const bg = new Konva.Rect({
name: 'bg',
width: note.width, height: note.height,
fill: note.color, cornerRadius: 8,
stroke: selected ? '#38bdf8' : 'rgba(0,0,0,0.06)',
strokeWidth: selected ? 2 : 1,
})
kg.add(bg)
const titleLine = getFirstLine(note.content) || '双击编辑...'
const titleText = new Konva.Text({
name: 'title', text: titleLine,
x: 14, y: 14, width: note.width - 28,
fontSize: 14, fontStyle: 'bold',
fontFamily: '-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif',
fill: '#1e293b', lineHeight: 1.4,
})
kg.add(titleText)
const bodyLines = note.content.split('\n').slice(1).join('\n').trim()
if (bodyLines) {
const bodyText = new Konva.Text({
name: 'body', text: truncate(bodyLines, 200),
x: 14, y: 14 + titleText.height() + 6, width: note.width - 28,
height: Math.max(20, note.height - titleText.height() - 72),
fontSize: 12,
fontFamily: '-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif',
fill: '#64748b', lineHeight: 1.55,
})
kg.add(bodyText)
}
const timeText = new Konva.Text({
name: 'time',
text: `创建 ${fmtDate(note.created_at)}\n修改 ${fmtDate(note.updated_at)}`,
x: 14, y: note.height - 36,
fontSize: 9,
fontFamily: '-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif',
fill: '#94a3b8', lineHeight: 1.5,
})
kg.add(timeText)
const resizeDot = new Konva.Circle({
name: 'resize',
x: note.width - 6, y: note.height - 6,
radius: 5,
fill: 'rgba(100,116,139,0.15)',
stroke: 'rgba(100,116,139,0.25)',
strokeWidth: 1, hitStrokeWidth: 12,
draggable: true,
})
kg.add(resizeDot)
resizeDot.on('dragmove', () => {
const newW = Math.max(180, resizeDot.x() + 10)
const newH = Math.max(140, resizeDot.y() + 10)
updateNoteNodeSizes(kg, newW, newH)
noteLayer!.batchDraw()
})
resizeDot.on('dragend', () => {
notesStore.updateNote(note.id, { width: kg.width(), height: kg.height() })
})
kg.on('dragmove', () => {
if (!isNoteSelected(note.id)) return
isDragMovingSelected = true
const dx = kg.x() - note.x
const dy = kg.y() - note.y
dragMoveDelta = { x: dx, y: dy }
for (const id of selectedNoteIds.value) {
if (id === note.id) continue
const nk = noteNodeMap.get(id)
if (nk) {
const n = notesStore.notes.find((x) => x.id === id)
if (n) nk.position({ x: n.x + dx, y: n.y + dy })
}
}
})
kg.on('dragend', async () => {
if (isNoteSelected(note.id) && isDragMovingSelected) {
const dx = dragMoveDelta.x
const dy = dragMoveDelta.y
for (const id of selectedNoteIds.value) {
const n = notesStore.notes.find((x) => x.id === id)
if (n) {
await notesStore.updateNote(id, { x: n.x + dx, y: n.y + dy })
}
}
isDragMovingSelected = false
} else {
await notesStore.updateNote(note.id, { x: kg.x(), y: kg.y() })
const gid = checkGroupMembership(kg.x(), kg.y(), kg.width(), kg.height())
if (gid !== note.group_id) {
await notesStore.updateNote(note.id, { group_id: gid })
}
}
})
kg.on('mousedown', (e) => {
if (e.evt.ctrlKey || e.evt.metaKey) {
e.evt.preventDefault()
toggleNoteSelection(note.id)
}
})
kg.on('dblclick', () => openEditor(note))
kg.on('mouseenter', () => {
if (!isNoteSelected(note.id)) {
bg.stroke('rgba(56,189,248,0.2)')
bg.strokeWidth(1.5)
}
if (stage) stage.container().style.cursor = 'default'
noteLayer!.batchDraw()
})
kg.on('mouseleave', () => {
if (!isNoteSelected(note.id)) {
bg.stroke('rgba(0,0,0,0.06)')
bg.strokeWidth(1)
}
updateCursor()
noteLayer!.batchDraw()
})
noteNodeMap.set(note.id, kg)
noteLayer.add(kg)
noteLayer.batchDraw()
}
function updateNoteNodeSizes(kg: Konva.Group, newW: number, newH: number) {
const children = kg.getChildren()
for (const child of children) {
if (child.name() === 'bg') {
(child as Konva.Rect).width(newW)
;(child as Konva.Rect).height(newH)
} else if (child.name() === 'body') {
const title = kg.findOne('.title') as Konva.Text | undefined
if (title) {
const y = 14 + title.height() + 6
;(child as Konva.Text).width(newW - 28)
;(child as Konva.Text).height(Math.max(20, newH - title.height() - 72))
;(child as Konva.Text).y(y)
}
} else if (child.name() === 'time') {
;(child as Konva.Text).y(newH - 36)
} else if (child.name() === 'resize') {
;(child as Konva.Circle).position({ x: newW - 6, y: newH - 6 })
}
}
kg.width(newW)
kg.height(newH)
}
function renderFrame(group: Group) {
if (!frameLayer) return
const existing = frameNodeMap.get(group.id)
if (existing) { existing.destroy(); frameNodeMap.delete(group.id) }
const selected = isGroupSelected(group.id)
const displayName = group.name.length > 20 ? group.name.slice(0, 20) + '\u2026' : group.name
const fg = new Konva.Group({
id: `frame-${group.id}`,
x: group.x, y: group.y,
width: group.width, height: group.height,
})
const rect = new Konva.Rect({
name: 'frame-bg',
width: group.width, height: group.height,
fill: 'transparent',
stroke: selected ? '#38bdf8' : group.color,
strokeWidth: 2,
cornerRadius: 6, opacity: selected ? 0.9 : 0.6,
})
fg.add(rect)
const header = new Konva.Rect({
name: 'frame-header',
width: group.width, height: 28,
fill: group.color, opacity: 0.15,
cornerRadius: [6, 6, 0, 0],
draggable: true,
})
fg.add(header)
const priorityLabel = group.priority > 0 ? ` [P${group.priority}] ` : ' '
const label = new Konva.Text({
name: 'frame-label',
text: priorityLabel + displayName,
x: 10, y: 6,
fontSize: 12, fontStyle: 'bold',
fontFamily: '-apple-system, "PingFang SC", "Microsoft YaHei", sans-serif',
fill: '#475569',
listening: false,
})
fg.add(label)
const resizeDot = new Konva.Circle({
name: 'frame-resize',
x: group.width - 6, y: group.height - 6,
radius: 6,
fill: group.color, opacity: 0.4,
hitStrokeWidth: 10, draggable: true,
})
fg.add(resizeDot)
resizeDot.on('dragmove', () => {
const newW = Math.max(100, resizeDot.x() + 10)
const newH = Math.max(80, resizeDot.y() + 10)
const bgChild = fg.findOne('.frame-bg') as Konva.Rect | undefined
const hChild = fg.findOne('.frame-header') as Konva.Rect | undefined
if (bgChild) { bgChild.width(newW); bgChild.height(newH) }
if (hChild) hChild.width(newW)
resizeDot.position({ x: newW - 6, y: newH - 6 })
fg.width(newW); fg.height(newH)
frameLayer!.batchDraw()
})
resizeDot.on('dragend', async () => {
await groupsStore.updateGroup(group.id, { width: fg.width(), height: fg.height() })
})
header.on('dragmove', () => {
headerDragActive = true
const dx = header.x()
const dy = header.y()
fg.x(fg.x() + dx)
fg.y(fg.y() + dy)
header.position({ x: 0, y: 0 })
if (isGroupSelected(group.id)) {
for (const gid of selectedGroupIds.value) {
if (gid === group.id) continue
const fk = frameNodeMap.get(gid)
if (fk) fk.position({ x: fk.x() + dx, y: fk.y() + dy })
}
for (const nid of selectedNoteIds.value) {
const nk = noteNodeMap.get(nid)
if (nk) nk.position({ x: nk.x() + dx, y: nk.y() + dy })
}
}
frameLayer!.batchDraw()
noteLayer?.batchDraw()
})
header.on('dragend', async () => {
if (headerDragActive && isGroupSelected(group.id)) {
for (const gid of selectedGroupIds.value) {
const fk = frameNodeMap.get(gid)
if (fk) await groupsStore.updateGroup(gid, { x: fk.x(), y: fk.y() })
}
for (const nid of selectedNoteIds.value) {
const nk = noteNodeMap.get(nid)
if (nk) await notesStore.updateNote(nid, { x: nk.x(), y: nk.y() })
}
headerDragActive = false
} else {
const updated = await groupsStore.updateGroup(group.id, { x: fg.x(), y: fg.y() })
assignNotesToFrame(updated)
}
})
header.on('mousedown', (e) => {
if (e.evt.ctrlKey || e.evt.metaKey) {
e.evt.preventDefault()
toggleGroupSelection(group.id)
}
})
header.on('dblclick', () => {
editingGroup.value = group
groupEditorVisible.value = true
})
rect.on('mouseenter', () => {
if (stage) stage.container().style.cursor = 'default'
})
rect.on('mouseleave', () => {
updateCursor()
})
frameNodeMap.set(group.id, fg)
frameLayer.add(fg)
frameLayer.batchDraw()
}
function openEditor(note: Note) {
editingNote.value = note
editorVisible.value = true
}
async function handleSave(content: string) {
if (!editingNote.value) return
const updated = await notesStore.updateNote(editingNote.value.id, { content })
renderNote(updated)
}
function handleNoteDelete() {
if (!editingNote.value) return
notesStore.deleteNote(editingNote.value.id)
editorVisible.value = false
}
function handleGroupSave(args: { name: string; priority: number; color: string }) {
if (!editingGroup.value) return
groupsStore.updateGroup(editingGroup.value.id, args)
}
function handleGroupDelete() {
if (!editingGroup.value) return
groupsStore.deleteGroup(editingGroup.value.id)
groupEditorVisible.value = false
}
function getFirstLine(content: string): string {
return content.split('\n').find((l) => l.trim()) || ''
}
function truncate(text: string, max: number): string {
return text.length <= max ? text : text.slice(0, max) + '\u2026'
}
function fmtDate(dateStr: string): string {
const d = new Date(dateStr)
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
let prevNoteIds: number[] = []
let prevGroupIds: number[] = []
watch(() => notesStore.notes, (notes) => {
if (!noteLayer) return
const currentIds = notes.map((n) => n.id)
for (const id of prevNoteIds) {
if (!currentIds.includes(id)) {
noteNodeMap.get(id)?.destroy()
noteNodeMap.delete(id)
const sel = new Set(selectedNoteIds.value)
if (sel.has(id)) { sel.delete(id); selectedNoteIds.value = sel }
}
}
for (const note of notes) {
const existing = noteNodeMap.get(note.id)
if (existing) {
existing.position({ x: note.x, y: note.y })
existing.width(note.width); existing.height(note.height)
const bg = existing.findOne('.bg') as Konva.Rect | undefined
if (bg) { bg.width(note.width); bg.height(note.height) }
} else {
renderNote(note)
}
}
prevNoteIds = currentIds
noteLayer.batchDraw()
}, { deep: false, immediate: true })
watch(() => groupsStore.groups, (groups) => {
if (!frameLayer) return
const currentIds = groups.map((g) => g.id)
for (const id of prevGroupIds) {
if (!currentIds.includes(id)) {
frameNodeMap.get(id)?.destroy()
frameNodeMap.delete(id)
const sel = new Set(selectedGroupIds.value)
if (sel.has(id)) { sel.delete(id); selectedGroupIds.value = sel }
}
}
for (const group of groups) {
const existing = frameNodeMap.get(group.id)
if (existing) {
existing.position({ x: group.x, y: group.y })
existing.width(group.width); existing.height(group.height)
const bg = existing.findOne('.frame-bg') as Konva.Rect | undefined
if (bg) { bg.width(group.width); bg.height(group.height) }
} else {
renderFrame(group)
}
}
prevGroupIds = currentIds
frameLayer.batchDraw()
}, { deep: false, immediate: true })
watch(editorVisible, (visible) => {
if (!containerRef.value) return
containerRef.value.style.cursor = visible ? 'default' : ''
if (!visible) updateCursor()
})
watch(activeTool, () => {
clearSelection()
updateCursor()
})
watch(selectedNoteIds, () => {}, { deep: false })
watch(selectedGroupIds, () => {}, { deep: false })
onMounted(() => {
initStage()
notesStore.fetchNotes().then(() => {
prevNoteIds = notesStore.notes.map((n) => n.id)
for (const note of notesStore.notes) renderNote(note)
})
groupsStore.fetchGroups().then(() => {
prevGroupIds = groupsStore.groups.map((g) => g.id)
for (const group of groupsStore.groups) renderFrame(group)
})
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
stage?.destroy()
})
</script>
<template>
<div class="canvas-wrapper">
<div class="canvas-toolbar">
<button
class="tool-btn"
:class="{ active: activeTool === 'pointer' }"
@click="activeTool = 'pointer'"
title="指针:拖拽平移画布,双击打开对象,Ctrl+点击选中"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="m3 3 7.07 16.97 2.51-7.39 7.39-2.51L3 3z" />
<path d="m13 13 6 6" />
</svg>
<span class="tool-label">指针</span>
</button>
<button
class="tool-btn"
:class="{ active: activeTool === 'note' }"
@click="activeTool = 'note'"
title="便签:双击画布空白处创建便签"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="3" />
<line x1="8" y1="8" x2="16" y2="8" />
<line x1="8" y1="12" x2="16" y2="12" />
<line x1="8" y1="16" x2="12" y2="16" />
</svg>
<span class="tool-label">便签</span>
</button>
<button
class="tool-btn"
:class="{ active: activeTool === 'frame' }"
@click="activeTool = 'frame'"
title="分组:拖拽绘制分组框"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" stroke-dasharray="3 2" />
</svg>
<span class="tool-label">分组</span>
</button>
<button
class="tool-btn"
:class="{ active: activeTool === 'select' }"
@click="activeTool = 'select'"
title="框选:拖拽选取便签和分组"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" stroke-dasharray="4 2" />
</svg>
<span class="tool-label">框选</span>
</button>
</div>
<div
ref="containerRef"
class="canvas-container"
tabindex="0"
/>
<NoteEditor
:content="editingNote?.content ?? ''"
:visible="editorVisible"
@close="editorVisible = false"
@save="handleSave"
@delete="handleNoteDelete"
/>
<GroupEditor
:group="editingGroup"
:visible="groupEditorVisible"
@close="groupEditorVisible = false"
@save="handleGroupSave"
@delete="handleGroupDelete"
/>
</div>
</template>
<style scoped>
.canvas-wrapper {
width: 100%;
height: 100%;
position: relative;
}
.canvas-toolbar {
position: absolute;
top: 12px;
left: 50%;
transform: translateX(-50%);
z-index: 10;
display: flex;
gap: 4px;
background: #fff;
border-radius: var(--radius-md);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
padding: 4px;
}
.tool-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
color: #64748b;
transition: all 0.15s;
}
.tool-btn:hover {
background: #f1f5f9;
color: #334155;
}
.tool-btn.active {
background: #e0f2fe;
color: var(--accent);
}
.tool-label {
font-size: 12px;
}
.canvas-container {
width: 100%;
height: 100%;
outline: none;
overflow: hidden;
background-color: var(--bg-canvas);
background-image: radial-gradient(circle, #e2e8f0 0.8px, transparent 0.8px);
background-size: 24px 24px;
}
</style>
@@ -0,0 +1,334 @@
<script setup lang="ts">
import { shallowRef, watch } from 'vue'
import type { Group } from '@/types/note'
const props = defineProps<{
group: Group | null
visible: boolean
}>()
const emit = defineEmits<{
close: []
save: [args: { name: string; priority: number; color: string }]
delete: []
}>()
const PREDEFINED_COLORS = [
'#e2e8f0', '#bfdbfe', '#bbf7d0', '#fecaca', '#ddd6fe',
'#fed7aa', '#cbd5e1', '#e9d5ff', '#a5f3fc', '#fde68a',
'#d1d5db', '#fbcfe8', '#a7f3d0', '#fca5a5', '#c4b5fd',
'#fdba74', '#94a3b8', '#d8b4fe',
]
const name = shallowRef('')
const priority = shallowRef(0)
const color = shallowRef('#e2e8f0')
const hexInput = shallowRef('')
const deleteConfirm = shallowRef(false)
watch(() => props.visible, (val) => {
if (val && props.group) {
name.value = props.group.name
priority.value = props.group.priority
color.value = props.group.color
hexInput.value = props.group.color
deleteConfirm.value = false
}
})
function applyColor(c: string) {
color.value = c
hexInput.value = c
}
function handleHexInput(e: Event) {
const val = (e.target as HTMLInputElement).value
hexInput.value = val
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
color.value = val
}
}
function handleSave() {
emit('save', {
name: name.value.trim() || (props.group?.name ?? '新分组'),
priority: Math.max(0, Math.min(99, priority.value || 0)),
color: color.value,
})
emit('close')
}
function handleDelete() {
if (!deleteConfirm.value) {
deleteConfirm.value = true
} else {
emit('delete')
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); handleSave() }
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="editor-overlay" @click.self="emit('close')">
<div class="editor-panel" @keydown="handleKeydown">
<div class="editor-header">
<span class="editor-title">编辑分组</span>
<div class="editor-hint">Ctrl+S 保存 · Esc 取消</div>
<button class="editor-close" @click="emit('close')" title="关闭">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<div class="editor-body">
<div class="field">
<label class="field-label">分组名称</label>
<input v-model="name" class="field-input" placeholder="输入分组名称" autofocus />
</div>
<div class="field">
<label class="field-label">优先级 (0-99, 越小越高)</label>
<input v-model.number="priority" type="number" min="0" max="99" class="field-input field-input-sm" />
</div>
<div class="field">
<label class="field-label">颜色</label>
<div class="color-section">
<div class="color-swatches">
<button
v-for="c in PREDEFINED_COLORS"
:key="c"
class="color-swatch"
:class="{ active: color === c }"
:style="{ background: c }"
@click="applyColor(c)"
>
<svg v-if="color === c" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(0,0,0,0.5)" stroke-width="3">
<path d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
<div class="color-custom">
<div class="color-preview" :style="{ background: color }" />
<input
:value="hexInput"
class="field-input field-input-hex"
placeholder="#RRGGBB"
@input="handleHexInput"
/>
</div>
</div>
</div>
</div>
<div class="editor-footer">
<button
class="delete-btn"
:class="{ 'delete-confirm': deleteConfirm }"
@click="handleDelete"
>
<svg v-if="!deleteConfirm" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/>
</svg>
{{ deleteConfirm ? '确认删除?' : '删除分组' }}
</button>
<button class="save-btn" @click="handleSave">保存</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.editor-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.editor-panel {
background: var(--surface-canvas);
border-radius: var(--radius-lg);
width: 460px;
max-width: 90vw;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.editor-header {
display: flex;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--border-canvas);
}
.editor-title {
font-size: 14px;
font-weight: 600;
color: var(--text-canvas);
}
.editor-hint {
font-size: 11px;
color: var(--text-canvas-secondary);
margin-left: 12px;
}
.editor-close {
margin-left: auto;
padding: 4px;
border-radius: 4px;
color: var(--text-canvas-secondary);
display: flex;
align-items: center;
}
.editor-close:hover {
background: #f1f5f9;
color: var(--text-canvas);
}
.editor-body {
padding: 18px;
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-label {
font-size: 12px;
font-weight: 600;
color: var(--text-canvas-secondary);
}
.field-input {
padding: 8px 12px;
border: 1px solid var(--border-canvas);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--text-canvas);
outline: none;
font-family: inherit;
}
.field-input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.field-input-sm {
width: 100px;
}
.color-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.color-swatches {
display: grid;
grid-template-columns: repeat(9, 1fr);
gap: 6px;
}
.color-swatch {
width: 100%;
aspect-ratio: 1;
border-radius: 5px;
border: 2px solid transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.1s;
}
.color-swatch:hover {
transform: scale(1.12);
}
.color-swatch.active {
border-color: var(--text-canvas);
box-shadow: 0 0 0 2px var(--accent);
}
.color-custom {
display: flex;
align-items: center;
gap: 10px;
}
.color-preview {
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-canvas);
flex-shrink: 0;
}
.field-input-hex {
flex: 1;
}
.editor-footer {
display: flex;
align-items: center;
padding: 12px 18px;
border-top: 1px solid var(--border-canvas);
gap: 10px;
}
.delete-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
color: var(--status-overdue);
transition: all 0.15s;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.08);
}
.delete-btn.delete-confirm {
background: var(--status-overdue);
color: white;
font-weight: 600;
}
.save-btn {
margin-left: auto;
padding: 6px 20px;
background: var(--accent);
color: white;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
transition: background 0.15s;
}
.save-btn:hover {
background: var(--accent-hover);
}
</style>
@@ -0,0 +1,210 @@
<script setup lang="ts">
import { shallowRef, watch } from 'vue'
const props = defineProps<{
content: string
visible: boolean
}>()
const emit = defineEmits<{
close: []
save: [content: string]
delete: []
}>()
const text = shallowRef(props.content)
const deleteConfirm = shallowRef(false)
watch(() => props.content, (val) => {
text.value = val
})
watch(() => props.visible, (val) => {
if (val) {
text.value = props.content
deleteConfirm.value = false
}
})
function handleSave() {
emit('save', text.value)
emit('close')
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
emit('close')
}
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault()
handleSave()
}
}
</script>
<template>
<Teleport to="body">
<div v-if="visible" class="editor-overlay" @click.self="emit('close')">
<div class="editor-panel" @keydown="handleKeydown">
<div class="editor-header">
<span class="editor-title">编辑便签</span>
<div class="editor-hint">Ctrl+S 保存 · Esc 取消</div>
<button class="editor-close" @click="emit('close')" title="关闭 (Esc)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
<textarea
ref="textRef"
class="editor-textarea"
v-model="text"
placeholder="输入 Markdown 内容..."
autofocus
/>
<div class="editor-footer">
<button
class="delete-btn"
:class="{ 'delete-confirm': deleteConfirm }"
@click="!deleteConfirm ? deleteConfirm = true : emit('delete')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M10 11v6M14 11v6"/>
</svg>
{{ deleteConfirm ? '确认删除?' : '删除便签' }}
</button>
<div class="editor-markdown-hint">
支持 Markdown**粗体** #标题 - [ ] 复选框
</div>
<button class="editor-save-btn" @click="handleSave">
保存 (Ctrl+S)
</button>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.editor-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.editor-panel {
background: var(--surface-canvas);
border-radius: var(--radius-lg);
width: 600px;
max-width: 90vw;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.editor-header {
display: flex;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid var(--border-canvas);
}
.editor-title {
font-size: 14px;
font-weight: 600;
color: var(--text-canvas);
}
.editor-hint {
font-size: 11px;
color: var(--text-canvas-secondary);
margin-left: 12px;
}
.editor-close {
margin-left: auto;
padding: 4px;
border-radius: 4px;
color: var(--text-canvas-secondary);
display: flex;
align-items: center;
}
.editor-close:hover {
background: #f1f5f9;
color: var(--text-canvas);
}
.editor-textarea {
flex: 1;
padding: 16px 18px;
border: none;
outline: none;
resize: none;
font-family: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
font-size: 13px;
line-height: 1.7;
color: var(--text-canvas);
min-height: 300px;
}
.editor-textarea::placeholder {
color: #cbd5e1;
}
.editor-footer {
display: flex;
align-items: center;
padding: 10px 18px;
border-top: 1px solid var(--border-canvas);
}
.editor-markdown-hint {
font-size: 11px;
color: var(--text-canvas-secondary);
margin-left: auto;
margin-right: 10px;
}
.delete-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
color: var(--status-overdue);
transition: all 0.15s;
}
.delete-btn:hover {
background: rgba(239, 68, 68, 0.08);
}
.delete-btn.delete-confirm {
background: var(--status-overdue);
color: white;
font-weight: 600;
}
.editor-save-btn {
padding: 6px 16px;
background: var(--accent);
color: white;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
transition: background 0.15s;
}
.editor-save-btn:hover {
background: var(--accent-hover);
}
</style>
@@ -0,0 +1,405 @@
<script setup lang="ts">
import { computed, shallowRef } from 'vue'
import { useNotesStore } from '@/stores/notes'
import { useGroupsStore } from '@/stores/groups'
import { parseNote } from '@/utils/noteParser'
import type { StatusType, PriorityType } from '@/types/card'
import TaskCard from './TaskCard.vue'
import MilestoneCard from './MilestoneCard.vue'
import MetricCard from './MetricCard.vue'
import GroupSection from './GroupSection.vue'
import StatsBar from './StatsBar.vue'
import FilterBar from './FilterBar.vue'
type DashboardView = 'grid' | 'list' | 'kanban'
const notesStore = useNotesStore()
const groupsStore = useGroupsStore()
const collapsedGroups = shallowRef<Set<number>>(new Set())
const dashboardView = shallowRef<DashboardView>('grid')
const filter = shallowRef({
search: '',
status: [] as StatusType[],
priority: null as PriorityType | null,
sortBy: 'updated',
})
const parsedCards = computed(() =>
notesStore.notes.map((note) => ({
card: parseNote(note.content),
noteId: note.id,
groupId: note.group_id,
}))
)
const filteredCards = computed(() => {
let items = parsedCards.value
if (filter.value.search) {
const q = filter.value.search.toLowerCase()
items = items.filter(
(item) =>
item.card.title.toLowerCase().includes(q) ||
item.card.description.toLowerCase().includes(q) ||
item.card.rawContent.toLowerCase().includes(q)
)
}
if (filter.value.status.length > 0) {
items = items.filter((item) => item.card.status && filter.value.status.includes(item.card.status))
}
if (filter.value.priority) {
items = items.filter((item) => item.card.priority === filter.value.priority)
}
items = [...items].sort((a, b) => {
switch (filter.value.sortBy) {
case 'status': {
const order: Record<string, number> = { '延期': 0, '阻塞': 1, '进行中': 2, '待办': 3, '已完成': 4 }
return (order[a.card.status ?? ''] ?? 5) - (order[b.card.status ?? ''] ?? 5)
}
case 'progress': return (b.card.progress ?? 0) - (a.card.progress ?? 0)
case 'priority': {
const order: Record<string, number> = { '紧急': 0, '高': 1, '中': 2, '低': 3 }
return (order[a.card.priority ?? ''] ?? 4) - (order[b.card.priority ?? ''] ?? 4)
}
case 'deadline': {
if (!a.card.deadline) return 1
if (!b.card.deadline) return -1
return new Date(a.card.deadline).getTime() - new Date(b.card.deadline).getTime()
}
default: return b.noteId - a.noteId
}
})
return items
})
const stats = computed(() => {
const cards = parsedCards.value.map((p) => p.card)
const total = cards.length
const done = cards.filter((c) => c.status === '已完成').length
const inProgress = cards.filter((c) => c.status === '进行中').length
const overdue = cards.filter((c) => c.status === '延期').length
const blocked = cards.filter((c) => c.status === '阻塞').length
return { total, done, inProgress, overdue, blocked }
})
const ungroupedCards = computed(() => filteredCards.value.filter((c) => c.groupId === null))
const groupedSections = computed(() => {
const groupMap = new Map<number, typeof filteredCards.value>()
for (const item of filteredCards.value) {
if (item.groupId !== null) {
if (!groupMap.has(item.groupId)) groupMap.set(item.groupId, [])
groupMap.get(item.groupId)!.push(item)
}
}
return [...groupMap.entries()]
.map(([gid, cards]) => {
const group = groupsStore.groups.find((g) => g.id === gid)
return { group, cards, gid }
})
.sort((a, b) => (a.group?.priority ?? 50) - (b.group?.priority ?? 50))
})
const kanbanColumns = computed(() => {
const statuses: StatusType[] = ['待办', '进行中', '阻塞', '延期', '已完成']
return statuses.map((s) => ({
status: s,
items: filteredCards.value.filter((item) => item.card.status === s),
}))
})
function toggleGroup(gid: number) {
const next = new Set(collapsedGroups.value)
if (next.has(gid)) next.delete(gid)
else next.add(gid)
collapsedGroups.value = next
}
function renderCard(item: { card: any; noteId: number }) {
if (item.card.type === 'milestone') return MilestoneCard
if (item.card.type === 'metric') return MetricCard
return TaskCard
}
</script>
<template>
<div class="dashboard">
<StatsBar :stats="stats" />
<div class="dashboard-controls">
<FilterBar v-model="filter" />
<div class="view-switcher-mini">
<button class="vs-btn" :class="{ active: dashboardView === 'grid' }" @click="dashboardView = 'grid'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" /><rect x="14" y="3" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" /><rect x="14" y="14" width="7" height="7" />
</svg>
</button>
<button class="vs-btn" :class="{ active: dashboardView === 'list' }" @click="dashboardView = 'list'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" />
<line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
</svg>
</button>
<button class="vs-btn" :class="{ active: dashboardView === 'kanban' }" @click="dashboardView = 'kanban'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="5" height="18" rx="1" /><rect x="13" y="6" width="5" height="15" rx="1" /><rect x="23" y="10" width="5" height="11" rx="1" transform="rotate(180 28 15.5)" />
</svg>
</button>
</div>
</div>
<div v-if="filteredCards.length === 0 && notesStore.notes.length > 0" class="empty-state">
<p class="empty-title">没有匹配的卡片</p>
</div>
<div v-else-if="notesStore.notes.length === 0" class="empty-state">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
</div>
<p class="empty-title">暂无监控卡片</p>
<p class="empty-sub">切换到画布视图双击空白处创建便签</p>
</div>
<template v-else>
<div v-if="dashboardView === 'kanban'" class="kanban-board">
<div class="kanban-column" v-for="col in kanbanColumns" :key="col.status">
<div class="kanban-col-header">
<span class="kanban-col-dot" :class="'status-' + col.status" />
<span class="kanban-col-name">{{ col.status }}</span>
<span class="kanban-col-count">{{ col.items.length }}</span>
</div>
<div class="kanban-col-items">
<component
v-for="item in col.items"
:key="item.noteId"
:is="renderCard(item)"
:card="item.card"
:note-id="item.noteId"
class="kanban-card"
/>
</div>
</div>
</div>
<div v-else-if="dashboardView === 'list'" class="list-view">
<div
v-for="item in filteredCards"
:key="item.noteId"
class="list-item"
>
<span class="list-status" :class="'status-' + (item.card.status || '待办')" />
<span class="list-title">{{ item.card.title || '未命名' }}</span>
<span class="list-meta">{{ item.card.status }} {{ item.card.progress !== null ? item.card.progress + '%' : '' }}</span>
</div>
</div>
<template v-else>
<GroupSection
v-for="section in groupedSections"
:key="section.gid"
:group="section.group ?? null"
:name="section.group?.name ?? '未命名分组'"
:cards="section.cards"
:collapsed="collapsedGroups.has(section.gid)"
@toggle="toggleGroup(section.gid)"
/>
<GroupSection
v-if="groupedSections.length > 0 && ungroupedCards.length > 0"
:group="null"
name="未分组"
:cards="ungroupedCards"
:collapsed="collapsedGroups.has(-1)"
@toggle="toggleGroup(-1)"
/>
<div class="dashboard-grid" v-if="groupedSections.length === 0">
<component
v-for="item in filteredCards"
:key="item.noteId"
:is="renderCard(item)"
:card="item.card"
:note-id="item.noteId"
/>
</div>
</template>
</template>
</div>
</template>
<style scoped>
.dashboard {
height: 100%;
overflow-y: auto;
padding: 24px 32px;
background: radial-gradient(circle, rgba(30, 41, 59, 0.35) 1px, transparent 1px);
background-size: 36px 36px;
}
.dashboard-controls {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.view-switcher-mini {
display: flex;
gap: 2px;
background: var(--surface-dashboard);
border: 1px solid var(--border-dashboard);
border-radius: var(--radius-sm);
padding: 3px;
flex-shrink: 0;
margin-top: 12px;
}
.vs-btn {
padding: 5px 8px;
border-radius: 4px;
color: var(--text-secondary);
display: flex;
align-items: center;
transition: all 0.15s;
}
.vs-btn:hover { color: var(--text-primary); }
.vs-btn.active { background: rgba(255,255,255,0.06); color: var(--accent); }
.dashboard-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
margin-top: 20px;
}
.kanban-board {
display: flex;
gap: 16px;
margin-top: 20px;
overflow-x: auto;
padding-bottom: 8px;
min-height: 400px;
}
.kanban-column {
flex: 1;
min-width: 280px;
max-width: 360px;
}
.kanban-col-header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
margin-bottom: 10px;
background: var(--surface-dashboard);
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.kanban-col-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.kanban-col-dot.status-待办 { background: var(--status-todo); }
.kanban-col-dot.status-进行中 { background: var(--status-progress); }
.kanban-col-dot.status-已完成 { background: var(--status-done); }
.kanban-col-dot.status-阻塞 { background: var(--status-blocked); }
.kanban-col-dot.status-延期 { background: var(--status-overdue); }
.kanban-col-count {
font-size: 11px;
color: var(--text-secondary);
background: rgba(255,255,255,0.04);
padding: 1px 6px;
border-radius: 8px;
margin-left: auto;
}
.kanban-col-items {
display: flex;
flex-direction: column;
gap: 10px;
}
.kanban-card {
font-size: 13px;
}
.kanban-card :deep(.task-card) {
font-size: 12px;
padding: 12px 14px;
}
.list-view {
margin-top: 20px;
display: flex;
flex-direction: column;
gap: 2px;
}
.list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: var(--surface-dashboard);
border: 1px solid var(--border-dashboard);
border-radius: var(--radius-sm);
transition: background 0.15s;
}
.list-item:hover {
background: rgba(255,255,255,0.02);
}
.list-status {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
.list-status.status-待办 { background: var(--status-todo); }
.list-status.status-进行中 { background: var(--status-progress); }
.list-status.status-已完成 { background: var(--status-done); }
.list-status.status-阻塞 { background: var(--status-blocked); }
.list-status.status-延期 { background: var(--status-overdue); }
.list-title {
flex: 1;
font-size: 13px;
color: var(--text-primary);
font-weight: 500;
}
.list-meta {
font-size: 12px;
color: var(--text-secondary);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: calc(100% - 200px);
color: var(--text-secondary);
text-align: center;
}
.empty-icon { opacity: 0.25; margin-bottom: 16px; }
.empty-title { font-size: 16px; font-weight: 600; color: var(--text-primary); margin-bottom: 4px; }
.empty-sub { font-size: 13px; max-width: 320px; }
</style>
@@ -0,0 +1,196 @@
<script setup lang="ts">
import { shallowRef, computed } from 'vue'
import type { StatusType, PriorityType } from '@/types/card'
const props = defineProps<{
modelValue: { search: string; status: StatusType[]; priority: PriorityType | null; sortBy: string }
}>()
const emit = defineEmits<{
'update:modelValue': [value: typeof props.modelValue]
}>()
const statusOptions: StatusType[] = ['待办', '进行中', '已完成', '阻塞', '延期']
const priorityOptions: PriorityType[] = ['紧急', '高', '中', '低']
const sortOptions = [
{ value: 'updated', label: '更新时间' },
{ value: 'status', label: '状态' },
{ value: 'progress', label: '进度' },
{ value: 'priority', label: '优先级' },
{ value: 'deadline', label: '截止日期' },
]
function toggleStatus(s: StatusType) {
const idx = props.modelValue.status.indexOf(s)
const next = idx >= 0
? props.modelValue.status.filter((x) => x !== s)
: [...props.modelValue.status, s]
emit('update:modelValue', { ...props.modelValue, status: next })
}
function setPriority(p: PriorityType | null) {
emit('update:modelValue', { ...props.modelValue, priority: p })
}
function updateSearch(e: Event) {
const val = (e.target as HTMLInputElement).value
emit('update:modelValue', { ...props.modelValue, search: val })
}
function updateSort(e: Event) {
const val = (e.target as HTMLSelectElement).value
emit('update:modelValue', { ...props.modelValue, sortBy: val })
}
</script>
<template>
<div class="filter-bar">
<div class="filter-search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
<input
type="text"
class="search-input"
placeholder="搜索标题或内容..."
:value="modelValue.search"
@input="updateSearch"
/>
</div>
<div class="filter-group">
<span class="filter-label">状态</span>
<div class="status-chips">
<button
v-for="s in statusOptions"
:key="s"
class="chip"
:class="{ active: modelValue.status.includes(s) }"
@click="toggleStatus(s)"
>
{{ s }}
</button>
</div>
</div>
<div class="filter-group">
<span class="filter-label">优先级</span>
<div class="status-chips">
<button
class="chip"
:class="{ active: modelValue.priority === null }"
@click="setPriority(null)"
>全部</button>
<button
v-for="p in priorityOptions"
:key="p"
class="chip"
:class="{ active: modelValue.priority === p }"
@click="setPriority(p)"
>
{{ p }}
</button>
</div>
</div>
<div class="filter-group">
<span class="filter-label">排序</span>
<select class="sort-select" :value="modelValue.sortBy" @change="updateSort">
<option v-for="opt in sortOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
</div>
</template>
<style scoped>
.filter-bar {
display: flex;
align-items: center;
gap: 20px;
padding: 12px 0;
flex-wrap: wrap;
}
.filter-search {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface-dashboard);
border: 1px solid var(--border-dashboard);
border-radius: var(--radius-sm);
padding: 6px 12px;
color: var(--text-secondary);
}
.search-input {
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: 13px;
width: 180px;
}
.search-input::placeholder {
color: var(--text-secondary);
opacity: 0.6;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-label {
font-size: 11px;
color: var(--text-secondary);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
}
.status-chips {
display: flex;
gap: 4px;
}
.chip {
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.03);
border: 1px solid transparent;
transition: all 0.15s;
white-space: nowrap;
}
.chip:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-primary);
}
.chip.active {
background: var(--accent-soft);
color: var(--accent);
border-color: rgba(56, 189, 248, 0.2);
}
.sort-select {
background: var(--surface-dashboard);
border: 1px solid var(--border-dashboard);
border-radius: var(--radius-sm);
padding: 5px 10px;
font-size: 12px;
color: var(--text-primary);
outline: none;
cursor: pointer;
font-family: inherit;
}
</style>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { Group } from '@/types/note'
import type { ParsedNote } from '@/types/card'
import TaskCard from './TaskCard.vue'
import MilestoneCard from './MilestoneCard.vue'
import MetricCard from './MetricCard.vue'
const props = defineProps<{
group: Group | null
name: string
cards: Array<{ card: ParsedNote; noteId: number }>
collapsed: boolean
}>()
const emit = defineEmits<{
toggle: []
}>()
</script>
<template>
<div class="group-section">
<div class="group-header" @click="emit('toggle')">
<svg
class="group-chevron"
:class="{ collapsed: collapsed }"
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
>
<path d="m6 9 6 6 6-6" />
</svg>
<div class="group-color-dot" :style="{ background: group?.color ?? 'var(--border-dashboard)' }" />
<h3 class="group-name">{{ name }}</h3>
<span class="group-count">{{ cards.length }}</span>
</div>
<div class="group-cards" v-if="!collapsed && cards.length > 0">
<template v-for="item in cards" :key="item.noteId">
<MilestoneCard v-if="item.card.type === 'milestone'" :card="item.card" />
<MetricCard v-else-if="item.card.type === 'metric'" :card="item.card" />
<TaskCard v-else :card="item.card" :note-id="item.noteId" />
</template>
</div>
<div class="group-cards group-empty" v-else-if="!collapsed">
<p class="empty-hint">此分组暂无卡片</p>
</div>
</div>
</template>
<style scoped>
.group-section {
margin-bottom: 20px;
}
.group-header {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 0;
cursor: pointer;
user-select: none;
}
.group-chevron {
transition: transform 0.2s;
color: var(--text-secondary);
}
.group-chevron.collapsed {
transform: rotate(-90deg);
}
.group-color-dot {
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
.group-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.group-count {
font-size: 12px;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.04);
padding: 1px 8px;
border-radius: 10px;
}
.group-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
.group-empty {
padding: 24px;
text-align: center;
}
.empty-hint {
font-size: 13px;
color: var(--text-secondary);
}
</style>
@@ -0,0 +1,136 @@
<script setup lang="ts">
import type { ParsedNote } from '@/types/card'
import { computed } from 'vue'
import StatusBadge from '@/components/shared/StatusBadge.vue'
import PriorityBadge from '@/components/shared/PriorityBadge.vue'
const props = defineProps<{
card: ParsedNote
}>()
const achievement = computed(() => {
if (props.card.metric === null || props.card.target === null || props.card.target === 0) return null
return Math.round((props.card.metric / props.card.target) * 100)
})
const achievementColor = computed(() => {
if (achievement.value === null) return 'var(--accent)'
if (achievement.value >= 100) return 'var(--status-done)'
if (achievement.value >= 75) return 'var(--accent)'
return 'var(--status-blocked)'
})
</script>
<template>
<div class="metric-card">
<div class="metric-header">
<StatusBadge v-if="card.status" :status="card.status" />
<PriorityBadge v-if="card.priority" :priority="card.priority" />
<span class="metric-type-tag">指标</span>
</div>
<h3 class="metric-title" v-if="card.title">{{ card.title }}</h3>
<div class="metric-body">
<div class="metric-value" :style="{ color: achievementColor }">
{{ card.metric ?? '-' }}
</div>
<div class="metric-target-row">
<span class="metric-label" v-if="card.target !== null">目标 {{ card.target }}</span>
<span class="metric-rate" v-if="achievement !== null" :style="{ color: achievementColor }">
达成率 {{ achievement }}%
</span>
</div>
<div class="metric-bar" v-if="achievement !== null">
<div
class="metric-bar-fill"
:style="{ width: Math.min(achievement, 100) + '%', background: achievementColor }"
/>
</div>
</div>
<p class="metric-desc" v-if="card.description">{{ card.description }}</p>
</div>
</template>
<style scoped>
.metric-card {
background: var(--surface-dashboard);
border: 1px solid var(--border-dashboard);
border-radius: var(--radius-md);
padding: 18px 20px;
}
.metric-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 14px;
text-align: center;
}
.metric-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.metric-type-tag {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 8px;
background: rgba(168, 85, 247, 0.12);
color: #a855f7;
text-transform: uppercase;
}
.metric-body {
text-align: center;
padding: 10px 0;
}
.metric-value {
font-size: 42px;
font-weight: 800;
letter-spacing: -1px;
line-height: 1;
font-variant-numeric: tabular-nums;
}
.metric-target-row {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 6px;
}
.metric-label {
font-size: 12px;
color: var(--text-secondary);
}
.metric-rate {
font-size: 12px;
font-weight: 600;
}
.metric-bar {
height: 4px;
background: rgba(255, 255, 255, 0.05);
border-radius: 2px;
margin-top: 10px;
overflow: hidden;
}
.metric-bar-fill {
height: 100%;
border-radius: 2px;
transition: width 0.6s ease;
}
.metric-desc {
font-size: 13px;
color: var(--text-secondary);
margin-top: 12px;
text-align: center;
}
</style>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { ParsedNote } from '@/types/card'
import { computed } from 'vue'
import ProgressBar from '@/components/shared/ProgressBar.vue'
import StatusBadge from '@/components/shared/StatusBadge.vue'
import PriorityBadge from '@/components/shared/PriorityBadge.vue'
const props = defineProps<{
card: ParsedNote
}>()
const remainingDays = computed(() => {
if (!props.card.deadline) return null
const diff = Math.ceil((new Date(props.card.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
return diff
})
const progress = computed(() => {
if (props.card.subTasks.length === 0) return null
const done = props.card.subTasks.filter((s) => s.done).length
return Math.round((done / props.card.subTasks.length) * 100)
})
</script>
<template>
<div class="milestone-card" :class="{ overdue: remainingDays !== null && remainingDays < 0 }">
<div class="milestone-ring">
<svg viewBox="0 0 100 100">
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--border-dashboard)" stroke-width="6" />
<circle
cx="50" cy="50" r="42"
fill="none"
:stroke="remainingDays !== null && remainingDays < 0 ? 'var(--status-overdue)' : 'var(--accent)'"
stroke-width="6"
stroke-linecap="round"
:stroke-dasharray="264"
:stroke-dashoffset="remainingDays !== null ? Math.max(0, 264 - (Math.min(Math.abs(remainingDays), 30) / 30) * 264) : 264"
transform="rotate(-90 50 50)"
/>
<text x="50" y="46" text-anchor="middle" fill="var(--text-primary)" font-size="20" font-weight="700">
{{ remainingDays !== null ? Math.abs(remainingDays) : '-' }}
</text>
<text x="50" y="64" text-anchor="middle" fill="var(--text-secondary)" font-size="10" font-weight="500">
{{ remainingDays !== null && remainingDays < 0 ? '逾期(天)' : '剩余(天)' }}
</text>
</svg>
</div>
<div class="milestone-content">
<div class="milestone-header">
<StatusBadge v-if="card.status" :status="card.status" />
<PriorityBadge v-if="card.priority" :priority="card.priority" />
<h3 class="milestone-title">{{ card.title || '未命名里程碑' }}</h3>
</div>
<p class="milestone-date">{{ card.deadline || '无截止日期' }}</p>
<ProgressBar v-if="progress !== null" :value="progress" :status="card.status || undefined" />
<p class="milestone-desc" v-if="card.description">{{ card.description }}</p>
</div>
</div>
</template>
<style scoped>
.milestone-card {
background: var(--surface-dashboard);
border: 1px solid var(--border-dashboard);
border-radius: var(--radius-md);
padding: 20px;
display: flex;
gap: 16px;
align-items: center;
}
.milestone-card.overdue {
border-color: rgba(239, 68, 68, 0.2);
}
.milestone-ring {
flex-shrink: 0;
width: 90px;
height: 90px;
}
.milestone-content {
flex: 1;
min-width: 0;
}
.milestone-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.milestone-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.milestone-date {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 8px;
}
.milestone-desc {
font-size: 13px;
color: var(--text-secondary);
margin-top: 8px;
}
</style>
@@ -0,0 +1,99 @@
<script setup lang="ts">
defineProps<{ stats: { total: number; done: number; inProgress: number; overdue: number; blocked: number } }>()
</script>
<template>
<div class="stats-bar">
<div class="stat-item">
<span class="stat-value">{{ stats.total }}</span>
<span class="stat-label">总计</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-value text-progress">{{ stats.inProgress }}</span>
<span class="stat-label">进行中</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-value text-done">{{ stats.done }}</span>
<span class="stat-label">已完成</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-value text-overdue">{{ stats.overdue }}</span>
<span class="stat-label">逾期</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-value text-blocked">{{ stats.blocked }}</span>
<span class="stat-label">阻塞</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<div class="completion-circle" :style="{ '--pct': stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0 }">
<span class="completion-text">{{ stats.total > 0 ? Math.round((stats.done / stats.total) * 100) : 0 }}%</span>
</div>
<span class="stat-label">完成率</span>
</div>
</div>
</template>
<style scoped>
.stats-bar {
display: flex;
align-items: center;
gap: 0;
background: var(--surface-dashboard);
border: 1px solid var(--border-dashboard);
border-radius: var(--radius-md);
padding: 14px 24px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.stat-value {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.5px;
}
.stat-label {
font-size: 11px;
color: var(--text-secondary);
margin-top: 2px;
}
.stat-divider {
width: 1px;
height: 36px;
background: var(--border-dashboard);
}
.text-progress { color: var(--status-progress); }
.text-done { color: var(--status-done); }
.text-overdue { color: var(--status-overdue); }
.text-blocked { color: var(--status-blocked); }
.completion-circle {
width: 36px;
height: 36px;
border-radius: 50%;
border: 2px solid var(--border-dashboard);
display: flex;
align-items: center;
justify-content: center;
background: conic-gradient(var(--status-done) calc(var(--pct) * 1%), transparent 0);
}
.completion-text {
font-size: 10px;
font-weight: 700;
color: var(--text-primary);
}
</style>
@@ -0,0 +1,279 @@
<script setup lang="ts">
import type { ParsedNote } from '@/types/card'
import { computed } from 'vue'
import { useNotesStore } from '@/stores/notes'
import ProgressBar from '@/components/shared/ProgressBar.vue'
import StatusBadge from '@/components/shared/StatusBadge.vue'
import PriorityBadge from '@/components/shared/PriorityBadge.vue'
const props = defineProps<{
card: ParsedNote
noteId: number
}>()
const notesStore = useNotesStore()
const remainingDays = computed(() => {
if (!props.card.deadline) return null
const now = new Date()
const deadline = new Date(props.card.deadline)
const diff = Math.ceil((deadline.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
return diff
})
const remainingDaysText = computed(() => {
if (remainingDays.value === null) return ''
if (remainingDays.value < 0) return `逾期 ${Math.abs(remainingDays.value)}`
if (remainingDays.value === 0) return '今天截止'
return `剩余 ${remainingDays.value}`
})
const isOverdue = computed(() => remainingDays.value !== null && remainingDays.value < 0)
async function toggleSubTask(index: number) {
const subTasks = [...props.card.subTasks]
subTasks[index] = { ...subTasks[index], done: !subTasks[index].done }
let newContent = props.card.rawContent
const lines = newContent.split('\n')
let subTaskIndex = 0
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim()
if (trimmed.startsWith('- [') || trimmed.startsWith('- [')) {
if (subTaskIndex === index) {
const prefix = subTasks[index].done ? '- [x]' : '- [ ]'
lines[i] = lines[i].replace(/(- \[.)[ x]\]/, `$1${subTasks[index].done ? 'x' : ' '}]`)
}
subTaskIndex++
}
}
newContent = lines.join('\n')
const doneCount = subTasks.filter((s) => s.done).length
const totalCount = subTasks.length
const progress = totalCount > 0 ? Math.round((doneCount / totalCount) * 100) : null
const payload: any = { content: newContent }
if (progress !== null) {
payload.content = newContent.replace(/\*\*进度:\*\* \d+%/, `**进度:** ${progress}%`)
}
await notesStore.updateNote(props.noteId, payload)
}
</script>
<template>
<div class="task-card" :class="{ 'is-overdue': isOverdue }">
<div class="card-header">
<div class="card-title-row">
<StatusBadge v-if="card.status" :status="card.status" />
<h3 class="card-title">{{ card.title || '未命名' }}</h3>
</div>
<PriorityBadge v-if="card.priority" :priority="card.priority" />
</div>
<p class="card-subtitle" v-if="card.subtitle">{{ card.subtitle }}</p>
<div class="card-meta-row" v-if="card.owner || card.priority">
<span class="card-owner" v-if="card.owner">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
{{ card.owner }}
</span>
</div>
<ProgressBar
v-if="card.progress !== null"
:value="card.progress"
:status="card.status || undefined"
/>
<div class="card-description" v-if="card.description">
{{ card.description }}
</div>
<ul class="subtask-list" v-if="card.subTasks.length > 0">
<li
v-for="(task, idx) in card.subTasks"
:key="idx"
class="subtask-item"
@click="toggleSubTask(idx)"
>
<span class="subtask-check" :class="{ done: task.done }">
<svg v-if="task.done" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<path d="M5 13l4 4L19 7" />
</svg>
</span>
<span class="subtask-text" :class="{ done: task.done }">{{ task.text }}</span>
</li>
</ul>
<div class="card-footer">
<div class="card-tags" v-if="card.tags.length > 0">
<span class="tag" v-for="tag in card.tags.slice(0, 5)" :key="tag">{{ tag }}</span>
</div>
<span class="card-deadline" :class="{ overdue: isOverdue }" v-if="remainingDaysText">
{{ remainingDaysText }}
</span>
</div>
</div>
</template>
<style scoped>
.task-card {
background: var(--surface-dashboard);
border: 1px solid var(--border-dashboard);
border-radius: var(--radius-md);
padding: 18px 20px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.task-card:hover {
border-color: rgba(56, 189, 248, 0.2);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.task-card.is-overdue {
border-left: 3px solid var(--status-overdue);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 4px;
}
.card-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-subtitle {
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.card-meta-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.card-owner {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--text-secondary);
}
.card-description {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.6;
margin: 12px 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.subtask-list {
list-style: none;
margin: 12px 0;
}
.subtask-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
}
.subtask-item:hover {
background: rgba(255, 255, 255, 0.03);
}
.subtask-check {
width: 16px;
height: 16px;
border-radius: 3px;
border: 1.5px solid var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: all 0.15s;
}
.subtask-check.done {
background: var(--status-done);
border-color: var(--status-done);
color: white;
}
.subtask-text {
font-size: 13px;
color: var(--text-primary);
transition: color 0.15s;
}
.subtask-text.done {
color: var(--text-secondary);
text-decoration: line-through;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--border-dashboard);
}
.card-tags {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: var(--accent-soft);
color: var(--accent);
font-weight: 500;
}
.card-deadline {
font-size: 12px;
color: var(--text-secondary);
font-weight: 500;
}
.card-deadline.overdue {
color: var(--status-overdue);
}
</style>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useUiStore } from '@/stores/ui'
const ui = useUiStore()
</script>
<template>
<nav class="top-nav">
<div class="nav-left">
<div class="logo">
<svg width="22" height="22" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="7" :fill="'var(--accent)'" />
<path d="M9 16.5L14 21.5L23 11.5" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<span class="logo-text">Todo Monitor</span>
</div>
</div>
<div class="nav-center">
<div class="view-switcher">
<button
class="switcher-btn"
:class="{ active: ui.viewMode === 'canvas' }"
@click="ui.setViewMode('canvas')"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="3" x2="9" y2="21" />
</svg>
画布
</button>
<button
class="switcher-btn"
:class="{ active: ui.viewMode === 'dashboard' }"
@click="ui.setViewMode('dashboard')"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
<rect x="14" y="14" width="7" height="7" rx="1" />
</svg>
大屏
</button>
</div>
</div>
<div class="nav-right" />
</nav>
</template>
<style scoped>
.top-nav {
height: var(--nav-height);
background: var(--bg-nav);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid var(--border-dashboard);
z-index: 100;
flex-shrink: 0;
user-select: none;
}
.nav-left {
display: flex;
align-items: center;
gap: 12px;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
}
.logo-text {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--text-primary);
}
.view-switcher {
display: flex;
background: rgba(255, 255, 255, 0.04);
border-radius: var(--radius-sm);
padding: 3px;
gap: 2px;
}
.switcher-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
transition: all 0.18s ease;
}
.switcher-btn:hover {
color: var(--text-primary);
}
.switcher-btn.active {
background: rgba(255, 255, 255, 0.08);
color: var(--text-primary);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.nav-right {
width: 100px;
}
</style>
@@ -0,0 +1,111 @@
<script setup lang="ts">
import { shallowRef } from 'vue'
const props = defineProps<{
modelValue: string
colors: string[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const open = shallowRef(false)
function select(color: string) {
emit('update:modelValue', color)
open.value = false
}
</script>
<template>
<div class="color-picker">
<button class="picker-trigger" :style="{ background: modelValue }" @click="open = !open" />
<div class="picker-dropdown" v-if="open">
<div class="picker-arrow" />
<div class="picker-grid">
<button
v-for="c in colors"
:key="c"
class="picker-swatch"
:class="{ selected: c === modelValue }"
:style="{ background: c }"
@click="select(c)"
>
<svg v-if="c === modelValue" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="rgba(0,0,0,0.5)" stroke-width="3">
<path d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.color-picker {
position: relative;
display: inline-block;
}
.picker-trigger {
width: 22px;
height: 22px;
border-radius: 5px;
border: 1.5px solid rgba(0, 0, 0, 0.12);
cursor: pointer;
transition: transform 0.12s;
}
.picker-trigger:hover {
transform: scale(1.1);
}
.picker-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
z-index: 50;
background: #fff;
border-radius: 8px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
padding: 8px;
}
.picker-arrow {
position: absolute;
top: -5px;
left: 50%;
transform: translateX(-50%);
width: 10px;
height: 10px;
background: #fff;
transform: translateX(-50%) rotate(45deg);
}
.picker-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
}
.picker-swatch {
width: 28px;
height: 28px;
border-radius: 4px;
border: 1px solid rgba(0, 0, 0, 0.08);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.1s;
}
.picker-swatch:hover {
transform: scale(1.15);
}
.picker-swatch.selected {
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.2);
}
</style>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { PriorityType } from '@/types/card'
const props = defineProps<{ priority: PriorityType }>()
const HEX_MAP: Record<PriorityType, string> = {
'紧急': '#ef4444',
'高': '#f97316',
'中': '#38bdf8',
'低': '#64748b',
}
const hexColor = computed(() => HEX_MAP[props.priority] || '#64748b')
const isUrgent = computed(() => props.priority === '紧急')
const label = computed(() => {
const map: Record<PriorityType, string> = {
'紧急': '!!',
'高': '!',
'中': '\u00b7',
'低': '',
}
return map[props.priority]
})
</script>
<template>
<span
class="priority-badge"
:class="{ urgent: isUrgent }"
:style="{ color: hexColor, background: hexColor + '2A', borderColor: hexColor + '40' }"
>
<span class="pri-symbol" v-if="label">{{ label }}</span>
{{ priority }}
</span>
</template>
<style scoped>
.priority-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 12px;
font-weight: 700;
padding: 3px 10px;
border-radius: 6px;
border: 1px solid;
white-space: nowrap;
transition: transform 0.12s;
}
.priority-badge.urgent {
font-size: 13px;
padding: 4px 12px;
animation: pulse 2s ease-in-out infinite;
}
.pri-symbol {
font-size: 14px;
font-weight: 700;
line-height: 1;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.75; }
}
</style>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
value: number
status?: string
}>()
const color = computed(() => {
if (props.status === '延期' || props.status === '阻塞') return 'var(--status-overdue)'
if (props.value >= 100) return 'var(--status-done)'
if (props.value > 0) return 'var(--accent)'
return 'var(--status-todo)'
})
const bgColor = computed(() => {
if (props.status === '延期' || props.status === '阻塞') return 'rgba(239, 68, 68, 0.15)'
if (props.value >= 100) return 'rgba(34, 197, 94, 0.15)'
if (props.value > 0) return 'var(--accent-soft)'
return 'rgba(100, 116, 139, 0.12)'
})
</script>
<template>
<div class="progress-wrapper">
<div class="progress-track" :style="{ background: bgColor }">
<div
class="progress-fill"
:style="{ width: Math.min(value, 100) + '%', background: color }"
/>
</div>
<span class="progress-value" :style="{ color }">{{ value }}%</span>
</div>
</template>
<style scoped>
.progress-wrapper {
display: flex;
align-items: center;
gap: 10px;
margin: 10px 0;
}
.progress-track {
flex: 1;
height: 6px;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
}
.progress-value {
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
min-width: 36px;
text-align: right;
}
</style>
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { StatusType } from '@/types/card'
const props = defineProps<{ status: StatusType }>()
const statusColor = computed(() => {
const map: Record<StatusType, string> = {
'待办': 'var(--status-todo)',
'进行中': 'var(--status-progress)',
'已完成': 'var(--status-done)',
'阻塞': 'var(--status-blocked)',
'延期': 'var(--status-overdue)',
}
return map[props.status] || 'var(--status-todo)'
})
</script>
<template>
<span class="status-badge" :style="{ '--status-color': statusColor }">
<span class="status-dot" />
{{ status }}
</span>
</template>
<style scoped>
.status-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
padding: 2px 10px;
border-radius: 10px;
background: color-mix(in srgb, var(--status-color) 12%, transparent);
color: var(--status-color);
white-space: nowrap;
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--status-color);
}
</style>
+37
View File
@@ -0,0 +1,37 @@
import { shallowRef, watch, type WatchSource } from 'vue'
export function useDebouncedSave(
source: WatchSource<unknown>,
saveFn: (value: unknown) => Promise<void>,
delay = 500,
) {
let timer: ReturnType<typeof setTimeout> | null = null
watch(source, (value) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
saveFn(value)
}, delay)
})
}
export function useDebounce<T>(fn: (arg: T) => void, delay = 300) {
let timer: ReturnType<typeof setTimeout> | null = null
return (arg: T) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => fn(arg), delay)
}
}
export function useThrottle<T>(fn: (arg: T) => void, interval = 100) {
let lastTime = 0
return (arg: T) => {
const now = Date.now()
if (now - lastTime >= interval) {
lastTime = now
fn(arg)
}
}
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import './assets/main.css'
const app = createApp(App)
app.use(createPinia())
app.mount('#app')
+37
View File
@@ -0,0 +1,37 @@
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'
import type { Group } from '@/types/note'
import { api } from '@/utils/api'
export const useGroupsStore = defineStore('groups', () => {
const groups = shallowRef<Group[]>([])
const loading = shallowRef(false)
async function fetchGroups() {
loading.value = true
try {
groups.value = await api.get<Group[]>('/groups')
} finally {
loading.value = false
}
}
async function createGroup(payload: Partial<Group> = {}) {
const group = await api.post<Group>('/groups', payload)
groups.value = [...groups.value, group]
return group
}
async function updateGroup(id: number, payload: Partial<Group>) {
const updated = await api.put<Group>(`/groups/${id}`, payload)
groups.value = groups.value.map((g) => (g.id === id ? updated : g))
return updated
}
async function deleteGroup(id: number) {
await api.delete(`/groups/${id}`)
groups.value = groups.value.filter((g) => g.id !== id)
}
return { groups, loading, fetchGroups, createGroup, updateGroup, deleteGroup }
})
+41
View File
@@ -0,0 +1,41 @@
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'
import type { Note, CreateNotePayload, UpdateNotePayload } from '@/types/note'
import { api } from '@/utils/api'
export const useNotesStore = defineStore('notes', () => {
const notes = shallowRef<Note[]>([])
const loading = shallowRef(false)
async function fetchNotes() {
loading.value = true
try {
notes.value = await api.get<Note[]>('/notes')
} finally {
loading.value = false
}
}
async function createNote(payload: CreateNotePayload = {}) {
const note = await api.post<Note>('/notes', payload)
notes.value = [note, ...notes.value]
return note
}
async function updateNote(id: number, payload: UpdateNotePayload) {
const updated = await api.put<Note>(`/notes/${id}`, payload)
notes.value = notes.value.map((n) => (n.id === id ? updated : n))
return updated
}
async function deleteNote(id: number) {
await api.delete(`/notes/${id}`)
notes.value = notes.value.filter((n) => n.id !== id)
}
function getNote(id: number): Note | undefined {
return notes.value.find((n) => n.id === id)
}
return { notes, loading, fetchNotes, createNote, updateNote, deleteNote, getNote }
})
+20
View File
@@ -0,0 +1,20 @@
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'
import type { ViewMode } from '@/types/note'
export const useUiStore = defineStore('ui', () => {
const viewMode = shallowRef<ViewMode>('canvas')
const canvasScale = shallowRef(1)
const canvasOffsetX = shallowRef(0)
const canvasOffsetY = shallowRef(0)
function setViewMode(mode: ViewMode) {
viewMode.value = mode
}
function toggleView() {
viewMode.value = viewMode.value === 'canvas' ? 'dashboard' : 'canvas'
}
return { viewMode, canvasScale, canvasOffsetX, canvasOffsetY, setViewMode, toggleView }
})
+29
View File
@@ -0,0 +1,29 @@
export type CardType = 'task' | 'milestone' | 'metric' | 'countdown' | 'project'
export type StatusType = '待办' | '进行中' | '已完成' | '阻塞' | '延期'
export type PriorityType = '紧急' | '高' | '中' | '低'
export interface SubTask {
text: string
done: boolean
}
export interface ParsedNote {
title: string
subtitle: string
status: StatusType | null
progress: number | null
priority: PriorityType | null
deadline: string | null
startDate: string | null
owner: string | null
tags: string[]
type: CardType | null
metric: number | null
target: number | null
link: string | null
description: string
subTasks: SubTask[]
rawContent: string
}
+47
View File
@@ -0,0 +1,47 @@
export interface Note {
id: number
content: string
x: number
y: number
width: number
height: number
color: string
group_id: number | null
created_at: string
updated_at: string
}
export interface Group {
id: number
name: string
x: number
y: number
width: number
height: number
color: string
priority: number
created_at: string
updated_at: string
}
export type ViewMode = 'canvas' | 'dashboard'
export interface CreateNotePayload {
content?: string
x?: number
y?: number
width?: number
height?: number
color?: string
group_id?: number | null
}
export interface UpdateNotePayload {
content?: string
x?: number
y?: number
width?: number
height?: number
color?: string
group_id?: number | null
}
+22
View File
@@ -0,0 +1,22 @@
const BASE_URL = '/api'
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
headers: { 'Content-Type': 'application/json' },
...options,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: '请求失败' }))
throw new Error(err.error || `HTTP ${res.status}`)
}
return res.json()
}
export const api = {
get: <T>(url: string) => request<T>(url),
post: <T>(url: string, data?: unknown) =>
request<T>(url, { method: 'POST', body: data ? JSON.stringify(data) : undefined }),
put: <T>(url: string, data?: unknown) =>
request<T>(url, { method: 'PUT', body: data ? JSON.stringify(data) : undefined }),
delete: <T>(url: string) => request<T>(url, { method: 'DELETE' }),
}
+302
View File
@@ -0,0 +1,302 @@
import { load as yamlLoad } from 'js-yaml'
import type { ParsedNote, StatusType, PriorityType, CardType } from '@/types/card'
export function parseNote(content: string): ParsedNote {
const result: ParsedNote = {
title: '',
subtitle: '',
status: null,
progress: null,
priority: null,
deadline: null,
startDate: null,
owner: null,
tags: [],
type: null,
metric: null,
target: null,
link: null,
description: '',
subTasks: [],
rawContent: content,
}
if (!content || !content.trim()) return result
let remainingContent = content
let priorityLevel: number | null = null
priorityLevel = parseYamlFrontMatter(remainingContent, result)
if (priorityLevel !== null) {
remainingContent = remainingContent.slice(remainingContent.indexOf('---', 3) + 3)
}
priorityLevel = parseConventionMarkdown(remainingContent, result)
parseInlineFields(remainingContent, result)
extractTags(remainingContent, result)
applyInference(result)
return result
}
function parseYamlFrontMatter(content: string, result: ParsedNote): number | null {
const trimmed = content.trimStart()
if (!trimmed.startsWith('---')) return null
const secondDelim = trimmed.indexOf('---', 3)
if (secondDelim === -1) return null
const yamlBlock = trimmed.slice(3, secondDelim).trim()
if (!yamlBlock) return null
try {
const parsed = yamlLoad(yamlBlock) as Record<string, unknown>
if (!parsed || typeof parsed !== 'object') return null
if (typeof parsed.title === 'string') result.title = parsed.title
if (typeof parsed.subtitle === 'string') result.subtitle = parsed.subtitle
if (typeof parsed.status === 'string' && isValidStatus(parsed.status)) result.status = parsed.status as StatusType
if (typeof parsed.progress === 'number') result.progress = Math.max(0, Math.min(100, parsed.progress))
if (typeof parsed.priority === 'string' && isValidPriority(parsed.priority)) result.priority = parsed.priority as PriorityType
if (typeof parsed.deadline === 'string') result.deadline = parsed.deadline
if (typeof parsed.startDate === 'string') result.startDate = parsed.startDate
if (typeof parsed.owner === 'string') result.owner = parsed.owner
if (Array.isArray(parsed.tags)) result.tags = parsed.tags.map(String).filter(Boolean)
if (typeof parsed.type === 'string' && isValidCardType(parsed.type)) result.type = parsed.type as CardType
if (typeof parsed.metric === 'number') result.metric = parsed.metric
if (typeof parsed.target === 'number') result.target = parsed.target
if (typeof parsed.link === 'string') result.link = parsed.link
return 1
} catch {
return null
}
}
function parseConventionMarkdown(content: string, result: ParsedNote): number | null {
const lines = content.split('\n')
const descriptionLines: string[] = []
let headingFound = false
let secondHeadingFound = false
let matchedCount = 0
for (const line of lines) {
const trimmed = line.trim()
if (!trimmed) continue
if (trimmed.startsWith('# ') && !headingFound && !result.title) {
result.title = trimmed.slice(2).trim()
headingFound = true
matchedCount++
continue
}
if (trimmed.startsWith('## ') && !secondHeadingFound && !result.subtitle) {
result.subtitle = trimmed.slice(3).trim()
secondHeadingFound = true
matchedCount++
continue
}
if (trimmed.startsWith('- [') && trimmed.length > 5) {
const done = trimmed[3] === 'x' || trimmed[4] === 'x'
const text = trimmed.replace(/- \[[ x]\]\s*/, '').trim()
result.subTasks.push({ text, done })
matchedCount++
continue
}
const boldMatch = trimmed.match(/^\*\*(.+?)\*\*[:]?\s*(.+)$/)
if (boldMatch) {
const key = boldMatch[1].replace(/[:]$/, '').trim()
const value = boldMatch[2].trim()
if (applyFieldByKey(key, value, result)) {
matchedCount++
continue
}
}
descriptionLines.push(trimmed)
}
if (result.description) {
result.description += '\n' + descriptionLines.join('\n')
} else {
result.description = descriptionLines.join('\n')
}
return matchedCount > 0 ? 2 : null
}
function parseInlineFields(content: string, result: ParsedNote): void {
const inlinePatterns: Array<{ regex: RegExp; handler: (m: RegExpMatchArray) => void }> = [
{
regex: /@状态\((.+?)\)/,
handler: (m) => { if (isValidStatus(m[1])) result.status = m[1] as StatusType },
},
{
regex: /@进度\((\d+)%\)/,
handler: (m) => { const v = parseInt(m[1]); if (v >= 0 && v <= 100) result.progress = v },
},
{
regex: /@优先级\((.+?)\)/,
handler: (m) => { if (isValidPriority(m[1])) result.priority = m[1] as PriorityType },
},
{
regex: /@截止\((.+?)\)/,
handler: (m) => { result.deadline = m[1] },
},
{
regex: /@开始\((.+?)\)/,
handler: (m) => { result.startDate = m[1] },
},
{
regex: /@负责人\((.+?)\)/,
handler: (m) => { result.owner = m[1] },
},
{
regex: /@类型\((.+?)\)/,
handler: (m) => { if (isValidCardType(m[1])) result.type = m[1] as CardType },
},
{
regex: /@进度\(([\d.]+)\)/,
handler: (m) => { result.metric = parseFloat(m[1]) },
},
{
regex: /@指标\(([\d.]+)\)/,
handler: (m) => { result.metric = parseFloat(m[1]) },
},
{
regex: /@目标\(([\d.]+)\)/,
handler: (m) => { result.target = parseFloat(m[1]) },
},
]
for (const { regex, handler } of inlinePatterns) {
const match = content.match(regex)
if (match) handler(match)
}
}
function extractTags(content: string, result: ParsedNote): void {
const tagMatches = [...content.matchAll(/#([\w\u4e00-\u9fff]+)/g)]
for (const m of tagMatches) {
if (m.index !== undefined && m.index > 0) {
const before = content[m.index - 1]
if (before !== '#' && before !== ' ' && before !== '\n' && before !== '\t' && before !== undefined) continue
}
const tag = m[1]
if (!result.tags.includes(tag)) result.tags.push(tag)
}
}
function applyFieldByKey(key: string, value: string, result: ParsedNote): boolean {
const k = key.toLowerCase()
if (includesAny(k, ['状态', 'status'])) {
if (isValidStatus(value)) { result.status = value as StatusType; return true }
}
if (includesAny(k, ['进度', 'progress'])) {
if (/%/.test(value)) {
const pct = parseInt(value.replace('%', '').trim())
if (!isNaN(pct) && pct >= 0 && pct <= 100) { result.progress = pct; return true }
} else {
const n = parseFloat(value)
if (!isNaN(n)) { result.metric = n; return true }
}
}
if (includesAny(k, ['指标', 'metric', 'output', '输出'])) {
const n = parseFloat(value)
if (!isNaN(n)) { result.metric = n; return true }
}
if (includesAny(k, ['优先'])) {
if (isValidPriority(value)) { result.priority = value as PriorityType; return true }
}
if (includesAny(k, ['截止', 'deadline', '到期'])) {
result.deadline = value; return true
}
if (includesAny(k, ['开始', 'start'])) {
result.startDate = value; return true
}
if (includesAny(k, ['负责', 'owner', '执行'])) {
result.owner = value; return true
}
if (includesAny(k, ['标签', 'tags'])) {
result.tags = value.split(/[,\s]+/).map((t) => t.replace(/^#/, '')).filter(Boolean); return true
}
if (includesAny(k, ['类型', 'type'])) {
if (isValidCardType(value)) { result.type = value as CardType; return true }
}
if (includesAny(k, ['目标', 'target'])) {
const n = parseFloat(value); if (!isNaN(n)) { result.target = n; return true }
}
if (includesAny(k, ['链接', 'link', 'url'])) {
result.link = value; return true
}
return false
}
function includesAny(str: string, keys: string[]): boolean {
return keys.some((key) => str.includes(key))
}
function applyInference(result: ParsedNote): void {
if (result.subTasks.length > 0 && result.progress === null) {
const done = result.subTasks.filter((s) => s.done).length
result.progress = Math.round((done / result.subTasks.length) * 100)
}
if (result.status === null) {
if (/blocked|阻塞|卡壳/.test(result.rawContent)) {
result.status = '阻塞'
} else if (result.deadline && new Date(result.deadline) < new Date() && (result.progress ?? 100) < 100) {
result.status = '延期'
} else if ((result.progress ?? 0) >= 100) {
result.status = '已完成'
} else if ((result.progress ?? 0) > 0) {
result.status = '进行中'
} else {
result.status = '待办'
}
}
if (result.type === null) {
if (result.metric !== null || result.target !== null) {
result.type = 'metric'
} else if (result.subTasks.length >= 3) {
result.type = 'project'
} else if (result.deadline && result.subTasks.length === 0) {
const diffDays = Math.ceil((new Date(result.deadline).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
if (diffDays <= 60) result.type = 'milestone'
else result.type = 'task'
} else {
result.type = 'task'
}
}
}
function isValidStatus(v: string): v is StatusType {
return ['待办', '进行中', '已完成', '阻塞', '延期'].includes(v)
}
function isValidPriority(v: string): v is PriorityType {
return ['紧急', '高', '中', '低'].includes(v)
}
function isValidCardType(v: string): v is CardType {
return ['task', 'milestone', 'metric', 'countdown', 'project'].includes(v)
}
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": false,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "src/env.d.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
})
+244
View File
@@ -0,0 +1,244 @@
# Todo Monitor 语法说明
便签内容的 Markdown 文本支持三种解析模式,优先级从高到低为:
> YAML Front Matter > 约定式 Markdown > 行内快捷字段
同一字段若同时在多种模式中存在,高优先级覆盖低优先级。
---
## 一、支持的字段总览
| 字段 | 说明 | 类型 | 可选值 |
|------|------|------|--------|
| `title` | 卡片标题 | 字符串 | — |
| `subtitle` | 副标题 | 字符串 | — |
| `status` | 状态 | 枚举 | `待办` `进行中` `已完成` `阻塞` `延期` |
| `progress` | 进度百分比 | 数字 0-100 | — |
| `priority` | 优先级 | 枚举 | `紧急` `高` `中` `低` |
| `deadline` | 截止日期 | 日期字符串 | 如 `2026-08-15` |
| `startDate` | 开始日期 | 日期字符串 | 如 `2026-08-01` |
| `owner` | 负责人 | 字符串 | 如 `张三` |
| `tags` | 标签 | 字符串数组 | 如 `前端, 核心模块` |
| `type` | 卡片类型 | 枚举 | `task` `milestone` `metric` `countdown` `project` |
| `metric` | 指标数值 | 数字 | — |
| `target` | 目标值 | 数字 | — |
| `link` | 关联链接 | URL 字符串 | — |
---
## 二、模式一:YAML Front Matter(推荐)
在便签顶部用 `---` 包裹 YAML 元数据块:
````markdown
---
title: 登录模块开发
subtitle: 用户系统重构
status: 进行中
priority: 高
progress: 65
deadline: 2026-08-15
startDate: 2026-08-01
owner: 张三
tags: [前端, 核心模块]
type: task
metric: 1500
target: 3000
link: https://example.com
---
## 项目说明
这里是便签正文,支持 Markdown。
- [ ] 接口对接
- [x] 页面布局
- [ ] 单元测试
````
**规则:**
- `---` 必须独占一行,且位于便签最开头
- YAML 块内可以使用任意子集
- YAML 解析失败时自动回退到模式二/三
---
## 三、模式二:约定式 Markdown
无需特殊语法,按约定书写即可自动识别。以下为完整示例:
````markdown
# 登录模块开发
## 用户系统重构
**状态:** 进行中
**进度:** 65%
**优先级:** 高
**截止:** 2026-08-15
**开始:** 2026-08-01
**负责人:** 张三
**标签:** 前端, 核心模块
**类型:** task
**指标:** 1500
**目标:** 3000
**链接:** https://example.com
正文描述区域,所有未被识别的行均归为描述。
支持多段文本。
- [ ] 接口对接
- [x] 页面布局
- [ ] 单元测试
````
### 关键词表(近义词兼容)
系统使用**包含匹配**识别字段,只要关键词出现在 `**key**` 中即可识别,无需精确一致:
| 匹配关键词 | 对应字段 | 示例写法 |
|------------|----------|----------|
| `标题` / `title` | title | (仅限 `# ` 开头) |
| `状态` / `status` | status | `**状态:** 进行中` |
| `进度` / `progress` | progress 或 metric | 见下方说明 |
| `优先` | priority | `**优先级:** 高` `**优先:** 紧急` |
| `截止` / `deadline` / `到期` | deadline | `**截止日期:** 2026-08-15` `**截止时间:**` |
| `开始` / `start` | startDate | `**开始日期:** 2026-08-01` `**开始时间:**` |
| `负责` / `owner` / `执行` | owner | `**负责人:** 张三` `**执行人:**` |
| `标签` / `tags` | tags | `**标签:** 前端, 核心` |
| `类型` / `type` | type | `**类型:** milestone` |
| `指标` / `metric` / `进度` / `输出` | metric | `**指标值:** 1500` `**输出:**` |
| `目标` / `target` | target | `**目标值:** 3000` |
| `链接` / `link` / `url` | link | `**链接:** https://...` |
> **`进度` 字段智能识别**:若 value 含 `%` 符号(如 `65%`)→ 解析为 progress 百分比;若为纯数字(如 `1500`)→ 解析为 metric 指标值。
**兼容性:**
- 冒号支持中文 `` 和英文 `:`,也支持冒号位于 `**` 内侧(如 `**负责人:** 张三`
- 标签分隔符支持 逗号/顿号/空格:`前端, 核心模块` `前端、核心模块` `#前端 #核心`
### 子任务
| Markdown 写法 | 含义 |
|---------------|------|
| `- [ ] 未完成任务` | subTask `{ done: false }` |
| `- [x] 已完成任务` | subTask `{ done: true }` |
---
## 四、模式三:行内快捷字段
适合快速记录,在行内任意位置使用 `@字段名(值)` 语法:
````markdown
# 登录模块开发
@状态(进行中) @进度(65%) @优先级(高)
@截止(2026-08-15) @负责人(张三)
这里写描述文字 #前端 #核心模块
- [ ] 接口对接
- [x] 页面布局
````
**可用的行内语法:**
| 写法 | 说明 |
|------|------|
| `@状态(进行中)` | 设置 status |
| `@进度(65%)` | 设置 progress(需带 `%` |
| `@进度(1500)` | 设置 metric(不带 `%` |
| `@指标(1500)` | 设置 metric(同上) |
| `@目标(3000)` | 设置 target |
| `@优先级(高)` | 设置 priority |
| `@截止(2026-08-15)` | 设置 deadline |
| `@开始(2026-08-01)` | 设置 startDate |
| `@负责人(张三)` | 设置 owner |
| `@类型(milestone)` | 设置 type |
| `#标签名` | 自动提取到 tags 数组 |
---
## 五、智能推断规则
部分字段可以不写,系统自动推断:
### 进度自动计算
存在子任务但未写 progress → 自动按 **已完成数 / 总数** 计算进度百分比。
### 状态自动推断
若有子任务的显式进度(或通过子任务推算),系统优先用进度反推状态。除非用户显式写了 `**状态:**xxx`(或 YAML 中的 status),否则按以下规则:
| 条件 | 推断结果 |
|------|----------|
| 内容含 `阻塞` / `blocked` / `卡壳` | `阻塞` |
| 截止日期已过 且 progress < 100 | `延期` |
| progress >= 100 | `已完成` |
| progress > 0 | `进行中` |
| 其他 | `待办` |
> **注意**`阻塞``延期` 优先级最高,即使 progress 为其他值也会覆盖。
### 卡片类型推断
| 条件 | 类型 |
|------|------|
| 有 metric 或 target 字段 | `metric` |
| 子任务 ≥ 3 个 | `project` |
| 有 deadline 且距离 ≤ 60 天 | `milestone` |
| 其他 | `task` |
---
## 六、颜色映射
### 状态
| 状态 | 颜色 |
|------|------|
| 待办 | 灰色 `#64748b` |
| 进行中 | 蓝色 `#38bdf8` |
| 已完成 | 绿色 `#22c55e` |
| 阻塞 | 橙色 `#f59e0b` |
| 延期 | 红色 `#ef4444` |
### 进度条
| 进度值 | 颜色 |
|--------|------|
| 0% | 灰色 `#64748b` |
| 1% ~ 99% | 蓝色 `#38bdf8` |
| 100% | 绿色 `#22c55e` |
| 阻塞 / 延期(覆盖上述) | 红色 `#ef4444` |
### 优先级
| 优先级 | 颜色 | 视觉特征 |
|--------|------|----------|
| 紧急 | 红色 `#ef4444` | 字号加大 + 呼吸闪烁动画 |
| 高 | 橙色 `#f97316` | `!` 前缀 |
| 中 | 蓝色 `#38bdf8` | `·` 前缀 |
| 低 | 灰色 `#64748b` | 无前缀 |
---
## 七、卡片类型说明
| 类型 | 触发字段 | 展示方式 |
|------|----------|----------|
| `task` | 默认 | 标题 + 状态徽章 + 优先级徽章 + 负责人 + 进度条 + 子任务列表 + 描述 + 标签 |
| `milestone` | deadline 字段 | 倒计时圆环 + 截止日期 + 标题 |
| `metric` | metric + target 字段 | 大号数值 + 达成率 + 标题 |
| `countdown` | 仅 deadline,无子任务 | 全屏倒计时数字 |
| `project` | 子任务 ≥ 3 个 | 整体进度 + 子任务汇总 |
---
## 八、编辑器快捷键
| 快捷键 | 功能 |
|--------|------|
| `Ctrl+S` | 保存并关闭 |
| `Esc` | 取消关闭 |
| 双击便签 | 打开编辑面板 |
| 双击分组标题栏 | 打开分组编辑面板 |