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