init: Todo Monitor

This commit is contained in:
2026-08-02 02:50:27 +08:00
parent 278a7e90b0
commit 81dd92cf22
43 changed files with 7483 additions and 0 deletions
@@ -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>