init: Todo Monitor
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user