sillyspec 3.9.1 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.claude/skills/sillyspec-commit/SKILL.md +1 -1
  2. package/.claude/skills/sillyspec-continue/SKILL.md +1 -1
  3. package/.claude/skills/sillyspec-explore/SKILL.md +9 -0
  4. package/.claude/skills/sillyspec-plan/SKILL.md +0 -35
  5. package/.claude/skills/sillyspec-workspace/SKILL.md +1 -1
  6. package/README.md +7 -6
  7. package/SKILL.md +15 -10
  8. package/package.json +1 -1
  9. package/packages/dashboard/dist/assets/index-BcM2J-hv.css +1 -0
  10. package/packages/dashboard/dist/assets/{index-RsLVPAy7.js → index-DpLHK4jv.js} +974 -974
  11. package/packages/dashboard/dist/index.html +16 -17
  12. package/packages/dashboard/dist/prototype-dashboard.html +836 -0
  13. package/packages/dashboard/dist/prototype-overview.html +256 -0
  14. package/packages/dashboard/public/prototype-dashboard.html +836 -0
  15. package/packages/dashboard/public/prototype-overview.html +256 -0
  16. package/packages/dashboard/server/index.js +18 -13
  17. package/packages/dashboard/server/parser.js +109 -1
  18. package/packages/dashboard/server/watcher.js +14 -6
  19. package/packages/dashboard/src/App.vue +414 -186
  20. package/packages/dashboard/src/components/ActionBar.vue +10 -1
  21. package/packages/dashboard/src/components/CommandPalette.vue +5 -1
  22. package/packages/dashboard/src/components/DocPreview.vue +105 -8
  23. package/packages/dashboard/src/components/DocTree.vue +75 -19
  24. package/packages/dashboard/src/components/HResizeHandle.vue +48 -0
  25. package/packages/dashboard/src/components/PipelineView.vue +23 -4
  26. package/packages/dashboard/src/components/ProjectCard.vue +187 -0
  27. package/packages/dashboard/src/components/ProjectOverview.vue +113 -139
  28. package/packages/dashboard/src/components/VResizeHandle.vue +61 -0
  29. package/packages/dashboard/src/composables/useDashboard.js +28 -0
  30. package/packages/dashboard/src/composables/useLayout.js +131 -0
  31. package/src/index.js +7 -0
  32. package/src/init.js +17 -10
  33. package/src/migrate.js +5 -5
  34. package/src/progress.js +2 -1
  35. package/src/run.js +72 -61
  36. package/src/stages/brainstorm.js +28 -3
  37. package/src/stages/execute.js +52 -27
  38. package/src/stages/explore.js +34 -0
  39. package/src/stages/index.js +3 -1
  40. package/src/stages/plan.js +86 -14
  41. package/src/stages/scan.js +11 -11
  42. package/src/stages/status.js +1 -1
  43. package/.sillyspec/changes/archive/2026-04-08-derive-state/design.md +0 -97
  44. package/.sillyspec/changes/archive/2026-04-08-derive-state/plan.md +0 -51
  45. package/.sillyspec/changes/archive/2026-04-08-derive-state/proposal.md +0 -29
  46. package/.sillyspec/changes/archive/2026-04-08-derive-state/requirements.md +0 -34
  47. package/.sillyspec/changes/archive/2026-04-08-derive-state/tasks.md +0 -13
  48. package/.sillyspec/changes/archive/2026-04-08-derive-state/verify-result.md +0 -43
  49. package/.sillyspec/changes/auto-mode/design.md +0 -50
  50. package/.sillyspec/changes/auto-mode/proposal.md +0 -19
  51. package/.sillyspec/changes/auto-mode/requirements.md +0 -21
  52. package/.sillyspec/changes/auto-mode/tasks.md +0 -7
  53. package/.sillyspec/changes/brainstorm-archive/2026-04-05-dashboard-design.md +0 -206
  54. package/.sillyspec/changes/brainstorm-archive/2026-04-05-unified-docs-design.md +0 -199
  55. package/.sillyspec/changes/dashboard/design.md +0 -219
  56. package/.sillyspec/changes/dashboard/design.md.braindraft +0 -206
  57. package/.sillyspec/changes/run-command-design/design.md +0 -1230
  58. package/.sillyspec/changes/unified-docs-design/design.md +0 -199
  59. package/.sillyspec/docs/sillyspec/scan/.gitkeep +0 -0
  60. package/.sillyspec/knowledge/INDEX.md +0 -8
  61. package/.sillyspec/knowledge/uncategorized.md +0 -3
  62. package/.sillyspec/plans/2026-04-05-dashboard.md +0 -737
  63. package/.sillyspec/projects/sillyspec.yaml +0 -3
  64. package/packages/dashboard/dist/assets/index-CntACGUN.css +0 -1
@@ -78,7 +78,16 @@ function getProjectStatus() {
78
78
 
79
79
  function stageLabel() {
80
80
  const stage = props.project?.state?.currentStage
81
- const labels = { 'brainstorm': '头脑风暴', 'plan': '规划', 'execute': '执行', 'verify': '验证' }
81
+ const labels = {
82
+ scan: '代码扫描',
83
+ brainstorm: '头脑风暴',
84
+ plan: '规划',
85
+ execute: '执行',
86
+ verify: '验证',
87
+ archive: '归档',
88
+ quick: '快速任务',
89
+ explore: '自由探索'
90
+ }
82
91
  return labels[stage] || stage || '未知'
83
92
  }
84
93
  </script>
@@ -58,10 +58,14 @@ const searchInput = ref(null)
58
58
  const selectedIndex = ref(0)
59
59
 
60
60
  const stageNames = [
61
+ { id: 'scan', name: '代码扫描' },
61
62
  { id: 'brainstorm', name: '头脑风暴' },
62
63
  { id: 'plan', name: '规划' },
63
64
  { id: 'execute', name: '执行' },
64
- { id: 'verify', name: '验证' }
65
+ { id: 'verify', name: '验证' },
66
+ { id: 'archive', name: '归档' },
67
+ { id: 'quick', name: '快速任务' },
68
+ { id: 'explore', name: '自由探索' }
65
69
  ]
66
70
 
67
71
  const filteredItems = computed(() => {
@@ -1,29 +1,59 @@
1
1
  <template>
2
- <div class="h-full overflow-y-auto px-6 py-4">
3
- <div v-if="!content && !loading" class="flex items-center justify-center h-full">
4
- <p class="text-[12px] font-[JetBrains_Mono,monospace]" style="color: #636366;">选择一个文档查看内容</p>
2
+ <div class="doc-preview-shell">
3
+ <div v-if="!content && !loading" class="doc-empty">
4
+ <p>选择一个文档查看内容</p>
5
5
  </div>
6
- <div v-else-if="loading" class="flex items-center justify-center h-full">
7
- <p class="text-[12px] font-[JetBrains_Mono,monospace]" style="color: #636366;">加载中...</p>
6
+ <div v-else-if="loading" class="doc-empty">
7
+ <p>加载中...</p>
8
8
  </div>
9
- <div v-else class="doc-preview" v-html="renderedContent"></div>
9
+ <template v-else>
10
+ <div class="doc-toolbar">
11
+ <div class="doc-title" :title="fileTitle">{{ fileTitle }}</div>
12
+ <n-button size="tiny" type="primary" @click="isModalOpen = true">弹窗阅读</n-button>
13
+ </div>
14
+ <div class="doc-preview-scroll">
15
+ <div class="doc-preview" v-html="renderedContent"></div>
16
+ </div>
17
+ </template>
18
+
19
+ <n-modal
20
+ v-model:show="isModalOpen"
21
+ preset="card"
22
+ :title="fileTitle"
23
+ style="width: min(920px, calc(100vw - 48px));"
24
+ >
25
+ <div class="doc-modal-body">
26
+ <div class="doc-preview doc-preview-large" v-html="renderedContent"></div>
27
+ </div>
28
+ </n-modal>
10
29
  </div>
11
30
  </template>
12
31
 
13
32
  <script setup>
14
- import { computed } from 'vue'
33
+ import { computed, ref, watch } from 'vue'
15
34
  import { marked } from 'marked'
16
35
 
17
36
  const props = defineProps({
18
37
  content: { type: String, default: '' },
19
- loading: { type: Boolean, default: false }
38
+ loading: { type: Boolean, default: false },
39
+ selectedFile: { type: Object, default: null }
20
40
  })
21
41
 
42
+ const isModalOpen = ref(false)
43
+
22
44
  marked.setOptions({
23
45
  breaks: true,
24
46
  gfm: true
25
47
  })
26
48
 
49
+ watch(() => props.selectedFile?.path, () => {
50
+ isModalOpen.value = false
51
+ })
52
+
53
+ const fileTitle = computed(() => {
54
+ return props.selectedFile?.title || props.selectedFile?.name || '文档'
55
+ })
56
+
27
57
  const renderedContent = computed(() => {
28
58
  if (!props.content) return ''
29
59
  return marked.parse(props.content)
@@ -31,10 +61,69 @@ const renderedContent = computed(() => {
31
61
  </script>
32
62
 
33
63
  <style scoped>
64
+ .doc-preview-shell {
65
+ height: 100%;
66
+ min-height: 0;
67
+ display: flex;
68
+ flex-direction: column;
69
+ background: #FFFFFF;
70
+ }
71
+
72
+ .doc-empty {
73
+ height: 100%;
74
+ display: flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ padding: 24px;
78
+ text-align: center;
79
+ font-size: 12px;
80
+ color: #636366;
81
+ font-family: 'JetBrains Mono', monospace;
82
+ }
83
+
84
+ .doc-toolbar {
85
+ flex-shrink: 0;
86
+ display: flex;
87
+ align-items: center;
88
+ justify-content: space-between;
89
+ gap: 12px;
90
+ padding: 10px 14px;
91
+ border-bottom: 1px solid #F0F0F3;
92
+ }
93
+
94
+ .doc-title {
95
+ min-width: 0;
96
+ overflow: hidden;
97
+ text-overflow: ellipsis;
98
+ white-space: nowrap;
99
+ font-size: 12px;
100
+ font-weight: 600;
101
+ color: #1C1C1E;
102
+ }
103
+
104
+ .doc-preview-scroll {
105
+ flex: 1;
106
+ min-height: 0;
107
+ overflow: auto;
108
+ padding: 14px 16px 22px;
109
+ }
110
+
111
+ .doc-modal-body {
112
+ max-height: min(72vh, 760px);
113
+ overflow: auto;
114
+ padding-right: 4px;
115
+ }
116
+
34
117
  .doc-preview {
35
118
  font-size: 13px;
36
119
  line-height: 1.7;
37
120
  color: #374151;
121
+ overflow-wrap: anywhere;
122
+ }
123
+
124
+ .doc-preview-large {
125
+ font-size: 14px;
126
+ line-height: 1.75;
38
127
  }
39
128
 
40
129
  .doc-preview :deep(h1) {
@@ -46,6 +135,10 @@ const renderedContent = computed(() => {
46
135
  padding-bottom: 8px;
47
136
  }
48
137
 
138
+ .doc-preview-large :deep(h1) {
139
+ font-size: 22px;
140
+ }
141
+
49
142
  .doc-preview :deep(h2) {
50
143
  color: #1C1C1E;
51
144
  font-size: 15px;
@@ -53,6 +146,10 @@ const renderedContent = computed(() => {
53
146
  margin: 20px 0 10px;
54
147
  }
55
148
 
149
+ .doc-preview-large :deep(h2) {
150
+ font-size: 17px;
151
+ }
152
+
56
153
  .doc-preview :deep(h3) {
57
154
  color: #D97706;
58
155
  font-size: 14px;
@@ -1,22 +1,31 @@
1
1
  <template>
2
- <div class="flex flex-col h-full">
2
+ <div class="doc-tree">
3
+ <div class="doc-tree-search">
4
+ <n-input
5
+ v-model:value="query"
6
+ size="small"
7
+ clearable
8
+ placeholder="搜索文档"
9
+ />
10
+ </div>
11
+
3
12
  <n-empty v-if="groups.length === 0" description="暂无文档" style="margin: auto;" />
13
+ <n-empty v-else-if="treeData.length === 0" description="无匹配文档" style="margin: auto;" />
4
14
  <n-tree
5
15
  v-else
16
+ class="doc-tree-list"
6
17
  :data="treeData"
7
18
  :selected-keys="selectedKeys"
8
19
  :default-expand-all="true"
9
20
  selectable
10
21
  block-line
11
22
  @update:selected-keys="handleSelect"
12
- style="padding: 8px 12px;"
13
23
  />
14
24
  </div>
15
25
  </template>
16
26
 
17
27
  <script setup>
18
- import { computed, h } from 'vue'
19
- import { NIcon } from 'naive-ui'
28
+ import { computed, ref } from 'vue'
20
29
 
21
30
  const props = defineProps({
22
31
  groups: { type: Array, default: () => [] },
@@ -24,30 +33,48 @@ const props = defineProps({
24
33
  })
25
34
 
26
35
  const emit = defineEmits(['select-file'])
36
+ const query = ref('')
27
37
 
28
38
  const selectedKeys = computed(() => {
29
39
  return props.selectedFile?.path ? [props.selectedFile.path] : []
30
40
  })
31
41
 
32
42
  const groupIcons = {
33
- '设计文档': '📋',
34
- '实现计划': '📐',
35
- '归档': '📦',
36
- ' proposals': '📝'
43
+ design: '📋',
44
+ plan: '🧾',
45
+ archive: '📦',
46
+ changes: '⚙️',
47
+ scan: '🔍',
48
+ quicklog: '⚡'
37
49
  }
38
50
 
39
51
  const treeData = computed(() => {
40
- return props.groups.map(group => ({
41
- key: `group-${group.key}`,
42
- label: group.project ? `${group.label} (${group.project})` : group.label,
43
- prefix: () => groupIcons[group.label] || '📄',
44
- children: group.files.map(file => ({
45
- key: file.path,
46
- label: file.title,
47
- prefix: () => '📄',
48
- data: file
49
- }))
50
- }))
52
+ const keyword = query.value.trim().toLowerCase()
53
+
54
+ return props.groups
55
+ .map(group => {
56
+ const groupLabel = group.project ? `${group.label} (${group.project})` : group.label
57
+ const groupMatched = !keyword || groupLabel.toLowerCase().includes(keyword)
58
+ const files = group.files.filter(file => {
59
+ if (groupMatched) return true
60
+ return `${file.title || ''} ${file.name || ''}`.toLowerCase().includes(keyword)
61
+ })
62
+
63
+ if (files.length === 0) return null
64
+
65
+ return {
66
+ key: `group-${group.key}`,
67
+ label: groupLabel,
68
+ prefix: () => groupIcons[group.key] || '📄',
69
+ children: files.map(file => ({
70
+ key: file.path,
71
+ label: file.title || file.name,
72
+ prefix: () => '📄',
73
+ data: file
74
+ }))
75
+ }
76
+ })
77
+ .filter(Boolean)
51
78
  })
52
79
 
53
80
  function handleSelect(keys, option) {
@@ -56,3 +83,32 @@ function handleSelect(keys, option) {
56
83
  }
57
84
  }
58
85
  </script>
86
+
87
+ <style scoped>
88
+ .doc-tree {
89
+ height: 100%;
90
+ min-height: 0;
91
+ display: flex;
92
+ flex-direction: column;
93
+ background: #FFFFFF;
94
+ }
95
+
96
+ .doc-tree-search {
97
+ flex-shrink: 0;
98
+ padding: 10px 12px 8px;
99
+ border-bottom: 1px solid #F0F0F3;
100
+ }
101
+
102
+ .doc-tree-list {
103
+ flex: 1;
104
+ min-height: 0;
105
+ overflow: auto;
106
+ padding: 8px 12px 14px;
107
+ }
108
+
109
+ .doc-tree-list :deep(.n-tree-node-content__text) {
110
+ white-space: normal;
111
+ line-height: 1.35;
112
+ overflow-wrap: anywhere;
113
+ }
114
+ </style>
@@ -0,0 +1,48 @@
1
+ <template>
2
+ <div
3
+ class="h-resize-handle"
4
+ :class="{ 'dragging': isDragging }"
5
+ @mousedown="handleMouseDown"
6
+ ></div>
7
+ </template>
8
+
9
+ <script setup>
10
+ const props = defineProps({
11
+ isDragging: { type: Boolean, default: false },
12
+ colIndex: { type: Number, required: true }
13
+ })
14
+
15
+ const emit = defineEmits(['drag-start', 'drag-end', 'resize'])
16
+
17
+ function handleMouseDown(e) {
18
+ e.preventDefault()
19
+ emit('drag-start', { type: 'horizontal', colIndex: props.colIndex, startX: e.clientX })
20
+
21
+ const onMove = (ev) => {
22
+ emit('resize', { deltaX: ev.clientX - e.clientX })
23
+ }
24
+
25
+ const onUp = () => {
26
+ emit('drag-end')
27
+ window.removeEventListener('mousemove', onMove)
28
+ window.removeEventListener('mouseup', onUp)
29
+ }
30
+
31
+ window.addEventListener('mousemove', onMove)
32
+ window.addEventListener('mouseup', onUp)
33
+ }
34
+ </script>
35
+
36
+ <style scoped>
37
+ .h-resize-handle {
38
+ width: 4px;
39
+ background: #E5E7EB;
40
+ cursor: col-resize;
41
+ transition: background 0.2s;
42
+ }
43
+
44
+ .h-resize-handle:hover,
45
+ .h-resize-handle.dragging {
46
+ background: #D97706;
47
+ }
48
+ </style>
@@ -26,6 +26,8 @@
26
26
 
27
27
  <!-- Stages -->
28
28
  <div v-else class="flex-1 overflow-y-auto px-6 pb-5 space-y-5">
29
+ <PipelineStage name="scan" title="代码扫描" :steps="getStageSteps('scan')" :status="getStageStatus('scan')" :is-active="currentStage === 'scan'" :active-step="activeStep" @select-step="handleSelectStep" />
30
+ <div v-if="hasStage('brainstorm')" class="flex items-center pl-[3px]"><div class="w-px h-4" style="background: #F0F0F3;" /></div>
29
31
  <PipelineStage name="brainstorm" title="头脑风暴" :steps="getStageSteps('brainstorm')" :status="getStageStatus('brainstorm')" :is-active="currentStage === 'brainstorm'" :active-step="activeStep" @select-step="handleSelectStep" />
30
32
  <div v-if="hasStage('plan')" class="flex items-center pl-[3px]"><div class="w-px h-4" style="background: #F0F0F3;" /></div>
31
33
  <PipelineStage name="plan" title="规划" :steps="getStageSteps('plan')" :status="getStageStatus('plan')" :is-active="currentStage === 'plan'" :active-step="activeStep" @select-step="handleSelectStep" />
@@ -33,6 +35,8 @@
33
35
  <PipelineStage name="execute" title="执行" :steps="getStageSteps('execute')" :status="getStageStatus('execute')" :is-active="currentStage === 'execute'" :active-step="activeStep" @select-step="handleSelectStep" />
34
36
  <div v-if="hasStage('verify')" class="flex items-center pl-[3px]"><div class="w-px h-4" style="background: #F0F0F3;" /></div>
35
37
  <PipelineStage name="verify" title="验证" :steps="getStageSteps('verify')" :status="getStageStatus('verify')" :is-active="currentStage === 'verify'" :active-step="activeStep" @select-step="handleSelectStep" />
38
+ <div v-if="hasStage('archive')" class="flex items-center pl-[3px]"><div class="w-px h-4" style="background: #F0F0F3;" /></div>
39
+ <PipelineStage name="archive" title="归档" :steps="getStageSteps('archive')" :status="getStageStatus('archive')" :is-active="currentStage === 'archive'" :active-step="activeStep" @select-step="handleSelectStep" />
36
40
  </div>
37
41
 
38
42
  <!-- Activity Log -->
@@ -62,12 +66,12 @@
62
66
  </div>
63
67
 
64
68
  <!-- Docs Tab -->
65
- <div v-if="activeTab === 'docs'" class="flex-1 flex overflow-hidden">
66
- <div class="w-[200px] flex-shrink-0 overflow-hidden" style="border-right: 1px solid #F0F0F3;">
69
+ <div v-if="activeTab === 'docs'" class="docs-panel flex-1 flex overflow-hidden">
70
+ <div class="docs-tree-pane flex-shrink-0 overflow-hidden" style="border-right: 1px solid #F0F0F3;">
67
71
  <DocTree :groups="docs.groups" :selected-file="selectedDocFile" @select-file="$emit('select-doc-file', $event)" />
68
72
  </div>
69
73
  <div class="flex-1 overflow-hidden">
70
- <DocPreview :content="docContent" :loading="docLoading" />
74
+ <DocPreview :content="docContent" :loading="docLoading" :selected-file="selectedDocFile" />
71
75
  </div>
72
76
  </div>
73
77
  </div>
@@ -96,10 +100,14 @@ const progress = computed(() => props.project?.state?.progress || {})
96
100
  const stages = computed(() => progress.value.stages || {})
97
101
 
98
102
  const stageNameMap = {
103
+ scan: '代码扫描',
99
104
  brainstorm: '头脑风暴',
100
105
  plan: '规划',
101
106
  execute: '执行',
102
- verify: '验证'
107
+ verify: '验证',
108
+ archive: '归档',
109
+ quick: '快速任务',
110
+ explore: '自由探索'
103
111
  }
104
112
 
105
113
  const activityLogs = computed(() => {
@@ -127,3 +135,14 @@ function getStageStatus(n) {
127
135
  }
128
136
  function handleSelectStep(step) { emit('select-step', step) }
129
137
  </script>
138
+
139
+ <style scoped>
140
+ .docs-panel {
141
+ min-height: 0;
142
+ }
143
+
144
+ .docs-tree-pane {
145
+ width: clamp(220px, 42%, 320px);
146
+ min-width: 180px;
147
+ }
148
+ </style>
@@ -0,0 +1,187 @@
1
+ <template>
2
+ <div
3
+ class="project-card"
4
+ :class="{ selected: isSelected }"
5
+ @click="$emit('select', project)"
6
+ >
7
+ <!-- 卡片头部 -->
8
+ <div class="card-header">
9
+ <span class="project-name">{{ project.name }}</span>
10
+ <span class="last-active">{{ formatTime(project.lastActive) }}</span>
11
+ </div>
12
+
13
+ <!-- 阶段标签 -->
14
+ <div>
15
+ <span class="stage-badge" :class="stageClass">{{ stageLabel }}</span>
16
+ </div>
17
+
18
+ <!-- 进度条 -->
19
+ <div class="progress-section">
20
+ <div class="progress-bar">
21
+ <div class="progress-fill" :class="stageClass" :style="{ width: progressPercent + '%' }"></div>
22
+ </div>
23
+ <span class="progress-text">{{ progressPercent }}%</span>
24
+ </div>
25
+ </div>
26
+ </template>
27
+
28
+ <script setup>
29
+ import { computed } from 'vue'
30
+
31
+ const props = defineProps({
32
+ project: { type: Object, required: true },
33
+ isSelected: { type: Boolean, default: false }
34
+ })
35
+
36
+ defineEmits(['select'])
37
+
38
+ const stageLabel = computed(() => {
39
+ const stage = props.project?.state?.currentStage
40
+ if (!stage) return '未开始'
41
+ const stageNames = {
42
+ brainstorm: '需求探索',
43
+ plan: '实现计划',
44
+ execute: '波次执行',
45
+ verify: '验证确认'
46
+ }
47
+ return stageNames[stage] || stage
48
+ })
49
+
50
+ const stageClass = computed(() => {
51
+ const stage = props.project?.state?.currentStage
52
+ if (!stage) return 'pending'
53
+ const progress = props.project?.state?.progress?.stages?.[stage]
54
+ if (progress?.status === 'completed') return 'completed'
55
+ if (progress?.status === 'in-progress') return 'in-progress'
56
+ return 'pending'
57
+ })
58
+
59
+ const progressPercent = computed(() => {
60
+ const stage = props.project?.state?.currentStage
61
+ if (!stage) return 0
62
+ const progress = props.project?.state?.progress?.stages?.[stage]
63
+ if (!progress) return 0
64
+ if (progress.completedSteps !== undefined && progress.steps !== undefined) {
65
+ return Math.round((progress.completedSteps / progress.steps) * 100)
66
+ }
67
+ if (progress.status === 'completed') return 100
68
+ if (progress.status === 'in-progress') return 50
69
+ return 0
70
+ })
71
+
72
+ function formatTime(iso) {
73
+ if (!iso) return '昨天'
74
+ const d = new Date(iso)
75
+ const now = new Date()
76
+ const diffMs = now - d
77
+ if (diffMs < 60000) return '刚刚'
78
+ if (diffMs < 3600000) return `${Math.floor(diffMs / 60000)}分钟前`
79
+ if (diffMs < 86400000) return `${Math.floor(diffMs / 3600000)}小时前`
80
+ return '昨天'
81
+ }
82
+ </script>
83
+
84
+ <style scoped>
85
+ .project-card {
86
+ width: 280px;
87
+ height: 120px;
88
+ background: white;
89
+ border: 2px solid #E5E7EB;
90
+ border-radius: 12px;
91
+ padding: 16px;
92
+ display: flex;
93
+ flex-direction: column;
94
+ justify-content: space-between;
95
+ cursor: pointer;
96
+ transition: all 0.2s;
97
+ flex-shrink: 0;
98
+ }
99
+
100
+ .project-card:hover {
101
+ border-color: #D97706;
102
+ box-shadow: 0 4px 12px rgba(217, 119, 6, 0.15);
103
+ }
104
+
105
+ .project-card.selected {
106
+ border-color: #D97706;
107
+ box-shadow: 0 0 0 3px rgba(217, 119, 6, 0.2);
108
+ }
109
+
110
+ .card-header {
111
+ display: flex;
112
+ justify-content: space-between;
113
+ align-items: flex-start;
114
+ }
115
+
116
+ .project-name {
117
+ font-size: 16px;
118
+ font-weight: 600;
119
+ color: #1A1A1A;
120
+ }
121
+
122
+ .last-active {
123
+ font-size: 11px;
124
+ color: #9CA3AF;
125
+ }
126
+
127
+ .stage-badge {
128
+ display: inline-block;
129
+ padding: 4px 10px;
130
+ border-radius: 12px;
131
+ font-size: 12px;
132
+ font-weight: 500;
133
+ }
134
+
135
+ .stage-badge.in-progress {
136
+ background: #DBEAFE;
137
+ color: #1D4ED8;
138
+ }
139
+
140
+ .stage-badge.completed {
141
+ background: #D1FAE5;
142
+ color: #047857;
143
+ }
144
+
145
+ .stage-badge.pending {
146
+ background: #F3F4F6;
147
+ color: #6B7280;
148
+ }
149
+
150
+ .progress-section {
151
+ display: flex;
152
+ align-items: center;
153
+ gap: 8px;
154
+ }
155
+
156
+ .progress-bar {
157
+ flex: 1;
158
+ height: 6px;
159
+ background: #E5E7EB;
160
+ border-radius: 3px;
161
+ overflow: hidden;
162
+ }
163
+
164
+ .progress-fill {
165
+ height: 100%;
166
+ border-radius: 3px;
167
+ transition: width 0.3s;
168
+ }
169
+
170
+ .progress-fill.in-progress {
171
+ background: #3B82F6;
172
+ }
173
+
174
+ .progress-fill.completed {
175
+ background: #10B981;
176
+ }
177
+
178
+ .progress-fill.pending {
179
+ background: #9CA3AF;
180
+ }
181
+
182
+ .progress-text {
183
+ font-size: 12px;
184
+ font-weight: 600;
185
+ color: #374151;
186
+ }
187
+ </style>