init: Todo Monitor
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
node_modules/
|
||||
dist/
|
||||
docs/
|
||||
|
||||
*.db
|
||||
*.db-*
|
||||
*.sqlite
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
@@ -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) |
|
||||
Generated
+1307
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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: '接口不存在' })
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Generated
+1545
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }),
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -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` | 取消关闭 |
|
||||
| 双击便签 | 打开编辑面板 |
|
||||
| 双击分组标题栏 | 打开分组编辑面板 |
|
||||
Reference in New Issue
Block a user