From 89b4c5f236dbdd1581c6a653beb5e005ca4f6af7 Mon Sep 17 00:00:00 2001 From: chun_qiu Date: Wed, 5 Aug 2026 21:45:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=8C=87=E9=92=88?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E5=A4=8D=E5=88=B6=E7=B2=98=E8=B4=B4=E3=80=81?= =?UTF-8?q?Markdown=20=E9=93=BE=E6=8E=A5=E6=B8=B2=E6=9F=93=E4=B8=8E?= =?UTF-8?q?=E5=A4=9A=E9=A1=B9=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除 Google Fonts 依赖,改用系统字体栈(优化国内访问) - 指针模式下支持 Ctrl+C / Ctrl+V 复制粘贴选中便签与分组(自动重映射分组归属、偏移 24px 放置) - 卡片渲染 Markdown 描述(marked + DOMPurify 消毒),新增「关联链接」按钮显示 link 字段 - 里程碑卡补渲染标签 - 忽略 *.tsbuildinfo 构建产物 --- .gitignore | 2 + frontend/index.html | 1 - frontend/src/assets/main.css | 2 +- frontend/src/components/canvas/CanvasView.vue | 69 +++++++++++++++++++ .../src/components/dashboard/MetricCard.vue | 31 ++++++++- .../components/dashboard/MilestoneCard.vue | 48 ++++++++++++- .../src/components/dashboard/TaskCard.vue | 33 ++++++++- frontend/src/components/shared/CardLink.vue | 47 +++++++++++++ frontend/src/utils/md.ts | 17 +++++ 9 files changed, 244 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/shared/CardLink.vue create mode 100644 frontend/src/utils/md.ts 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}` +}