diff --git a/.gitignore b/.gitignore
index f23b09a..10a5ae3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@ node_modules/
dist/
docs/
+*.tsbuildinfo
+
*.db
*.db-*
*.sqlite
diff --git a/frontend/index.html b/frontend/index.html
index 2420674..09f90e5 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -4,7 +4,6 @@
-
Todo Monitor
diff --git a/frontend/src/assets/main.css b/frontend/src/assets/main.css
index 3a8bcf8..6f65bbf 100644
--- a/frontend/src/assets/main.css
+++ b/frontend/src/assets/main.css
@@ -35,7 +35,7 @@
--text-canvas: #1e293b;
--text-canvas-secondary: #64748b;
- --font-ui: 'Inter', -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif;
+ --font-ui: -apple-system, 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', sans-serif;
--font-mono: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
--radius-sm: 6px;
diff --git a/frontend/src/components/canvas/CanvasView.vue b/frontend/src/components/canvas/CanvasView.vue
index 7659200..f097b04 100644
--- a/frontend/src/components/canvas/CanvasView.vue
+++ b/frontend/src/components/canvas/CanvasView.vue
@@ -22,6 +22,7 @@ const groupEditorVisible = shallowRef(false)
const activeTool = shallowRef('pointer')
const selectedNoteIds = shallowRef(new Set())
const selectedGroupIds = shallowRef(new Set())
+const clipboard = shallowRef<{ notes: Note[]; groups: Group[] } | null>(null)
let stage: Konva.Stage | null = null
let noteLayer: Konva.Layer | null = null
@@ -98,6 +99,19 @@ function setupPan() {
container.tabIndex = 0
container.addEventListener('keydown', (e: KeyboardEvent) => {
+ if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey) {
+ const key = e.key.toLowerCase()
+ if (key === 'c') {
+ copySelection()
+ e.preventDefault()
+ return
+ }
+ if (key === 'v') {
+ pasteClipboard()
+ e.preventDefault()
+ return
+ }
+ }
if (e.code === 'Space' && !editorVisible.value) {
e.preventDefault()
isPanning = true
@@ -330,6 +344,61 @@ async function createNote(x: number, y: number) {
openEditor(note)
}
+function copySelection() {
+ if (activeTool.value !== 'pointer') return
+ const notes = notesStore.notes
+ .filter((n) => selectedNoteIds.value.has(n.id))
+ .map((n) => ({ ...n }))
+ const groups = groupsStore.groups
+ .filter((g) => selectedGroupIds.value.has(g.id))
+ .map((g) => ({ ...g }))
+ if (notes.length === 0 && groups.length === 0) return
+ clipboard.value = { notes, groups }
+}
+
+async function pasteClipboard() {
+ const snapshot = clipboard.value
+ if (!snapshot || activeTool.value !== 'pointer') return
+ if (editorVisible.value || groupEditorVisible.value) return
+ const offset = 24
+ const groupIdMap = new Map()
+ const newNoteIds: number[] = []
+ const newGroupIds: number[] = []
+ for (const g of snapshot.groups) {
+ const created = await groupsStore.createGroup({
+ name: g.name,
+ x: g.x + offset,
+ y: g.y + offset,
+ width: g.width,
+ height: g.height,
+ color: g.color,
+ priority: g.priority,
+ })
+ groupIdMap.set(g.id, created.id)
+ renderFrame(created)
+ newGroupIds.push(created.id)
+ }
+ for (const n of snapshot.notes) {
+ const newGroupId = n.group_id != null ? groupIdMap.get(n.group_id) ?? n.group_id : null
+ const created = await notesStore.createNote({
+ content: n.content,
+ x: n.x + offset,
+ y: n.y + offset,
+ width: n.width,
+ height: n.height,
+ color: n.color,
+ group_id: newGroupId,
+ })
+ renderNote(created)
+ newNoteIds.push(created.id)
+ }
+ clearSelection()
+ selectedNoteIds.value = new Set(newNoteIds)
+ selectedGroupIds.value = new Set(newGroupIds)
+ for (const id of newNoteIds) refreshNoteNode(id)
+ for (const id of newGroupIds) refreshFrameNode(id)
+}
+
async function assignNotesToFrame(group: Group) {
for (const note of notesStore.notes) {
const cx = note.x + note.width / 2
diff --git a/frontend/src/components/dashboard/MetricCard.vue b/frontend/src/components/dashboard/MetricCard.vue
index d9f410e..5e569db 100644
--- a/frontend/src/components/dashboard/MetricCard.vue
+++ b/frontend/src/components/dashboard/MetricCard.vue
@@ -3,6 +3,8 @@ import type { ParsedNote } from '@/types/card'
import { computed } from 'vue'
import StatusBadge from '@/components/shared/StatusBadge.vue'
import PriorityBadge from '@/components/shared/PriorityBadge.vue'
+import CardLink from '@/components/shared/CardLink.vue'
+import { renderMarkdown } from '@/utils/md'
const props = defineProps<{
card: ParsedNote
@@ -46,7 +48,10 @@ const achievementColor = computed(() => {
/>
- {{ card.description }}
+
+
+
+
@@ -133,4 +138,28 @@ const achievementColor = computed(() => {
margin-top: 12px;
text-align: center;
}
+
+.metric-link {
+ display: flex;
+ justify-content: center;
+ margin-top: 10px;
+}
+
+.metric-desc :deep(a) {
+ color: var(--accent);
+ text-decoration: none;
+ word-break: break-all;
+}
+
+.metric-desc :deep(a:hover) {
+ text-decoration: underline;
+}
+
+.metric-desc :deep(strong) {
+ color: var(--text-primary);
+}
+
+.metric-desc :deep(p) {
+ margin: 4px 0;
+}
diff --git a/frontend/src/components/dashboard/MilestoneCard.vue b/frontend/src/components/dashboard/MilestoneCard.vue
index b5b1d86..d355244 100644
--- a/frontend/src/components/dashboard/MilestoneCard.vue
+++ b/frontend/src/components/dashboard/MilestoneCard.vue
@@ -4,6 +4,8 @@ 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'
+import CardLink from '@/components/shared/CardLink.vue'
+import { renderMarkdown } from '@/utils/md'
const props = defineProps<{
card: ParsedNote
@@ -54,7 +56,13 @@ const progress = computed(() => {
{{ card.deadline || '无截止日期' }}
- {{ card.description }}
+
+ {{ tag }}
+
+
+
+
+
@@ -104,9 +112,47 @@ const progress = computed(() => {
margin-bottom: 8px;
}
+.milestone-tags {
+ display: flex;
+ gap: 4px;
+ flex-wrap: wrap;
+ margin-bottom: 4px;
+}
+
+.milestone-tags .tag {
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 10px;
+ background: var(--accent-soft);
+ color: var(--accent);
+ font-weight: 500;
+}
+
.milestone-desc {
font-size: 13px;
color: var(--text-secondary);
margin-top: 8px;
}
+
+.milestone-link {
+ margin-top: 8px;
+}
+
+.milestone-desc :deep(a) {
+ color: var(--accent);
+ text-decoration: none;
+ word-break: break-all;
+}
+
+.milestone-desc :deep(a:hover) {
+ text-decoration: underline;
+}
+
+.milestone-desc :deep(strong) {
+ color: var(--text-primary);
+}
+
+.milestone-desc :deep(p) {
+ margin: 4px 0;
+}
diff --git a/frontend/src/components/dashboard/TaskCard.vue b/frontend/src/components/dashboard/TaskCard.vue
index c9bc9e4..9b7cbd1 100644
--- a/frontend/src/components/dashboard/TaskCard.vue
+++ b/frontend/src/components/dashboard/TaskCard.vue
@@ -5,6 +5,8 @@ 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'
+import CardLink from '@/components/shared/CardLink.vue'
+import { renderMarkdown } from '@/utils/md'
const props = defineProps<{
card: ParsedNote
@@ -91,8 +93,10 @@ async function toggleSubTask(index: number) {
:status="card.status || undefined"
/>
-
- {{ card.description }}
+
+
+
+
@@ -195,6 +199,31 @@ async function toggleSubTask(index: number) {
overflow: hidden;
}
+.card-link-row {
+ display: flex;
+ align-items: center;
+ margin: 12px 0;
+}
+
+.md-body :deep(a) {
+ color: var(--accent);
+ text-decoration: none;
+ word-break: break-all;
+}
+
+.md-body :deep(a:hover) {
+ text-decoration: underline;
+}
+
+.md-body :deep(strong) {
+ color: var(--text-primary);
+ font-weight: 600;
+}
+
+.md-body :deep(p) {
+ margin: 4px 0;
+}
+
.subtask-list {
list-style: none;
margin: 12px 0;
diff --git a/frontend/src/components/shared/CardLink.vue b/frontend/src/components/shared/CardLink.vue
new file mode 100644
index 0000000..8fcec2e
--- /dev/null
+++ b/frontend/src/components/shared/CardLink.vue
@@ -0,0 +1,47 @@
+
+
+
+
+
+ 关联链接
+
+
+
+
diff --git a/frontend/src/utils/md.ts b/frontend/src/utils/md.ts
new file mode 100644
index 0000000..01eef63
--- /dev/null
+++ b/frontend/src/utils/md.ts
@@ -0,0 +1,17 @@
+import { marked } from 'marked'
+import DOMPurify from 'dompurify'
+
+DOMPurify.addHook('afterSanitizeAttributes', (node) => {
+ if (node.tagName === 'A') {
+ node.setAttribute('target', '_blank')
+ node.setAttribute('rel', 'noopener noreferrer')
+ }
+})
+
+export function renderMarkdown(md: string): string {
+ return DOMPurify.sanitize(marked.parse(md, { breaks: true, async: false }) as string)
+}
+
+export function normalizeUrl(url: string): string {
+ return /^[a-z][a-z0-9+.-]*:/i.test(url) ? url : `https://${url}`
+}