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}` +}