create-rue 0.0.14 → 0.0.16

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.
@@ -1,7 +1,10 @@
1
- import { computed, type FC, ref, useState, watch } from '@rue-js/rue'
1
+ import { computed, type FC, ref, watchEffect } from '@rue-js/rue'
2
2
  import { RouterLink } from '@rue-js/router'
3
3
 
4
4
  const todoStorageKey = 'rue.base.todos'
5
+ const minuteInMs = 60 * 1000
6
+ const hourInMs = 60 * minuteInMs
7
+ const dayInMs = 24 * hourInMs
5
8
 
6
9
  type TodoStatus = 'todo' | 'doing' | 'done'
7
10
  type TodoFilter = 'all' | 'todo' | 'doing' | 'done' | 'archived'
@@ -12,6 +15,13 @@ type TodoItem = {
12
15
  archived: boolean
13
16
  status: TodoStatus
14
17
  createdAt: string
18
+ createdOrder: number
19
+ }
20
+
21
+ type PersistedTodoState = {
22
+ todos: TodoItem[]
23
+ search: string
24
+ activeFilter: TodoFilter
15
25
  }
16
26
 
17
27
  const filterOptions: Array<{ key: TodoFilter; label: string }> = [
@@ -22,10 +32,10 @@ const filterOptions: Array<{ key: TodoFilter; label: string }> = [
22
32
  { key: 'archived', label: '已归档' },
23
33
  ]
24
34
 
25
- const statusOptions: Array<{ key: TodoStatus; label: string }> = [
26
- { key: 'todo', label: '待开始' },
27
- { key: 'doing', label: '进行中' },
28
- { key: 'done', label: '已完成' },
35
+ const statusOptions: Array<{ key: TodoStatus; label: string; actionLabel: string }> = [
36
+ { key: 'todo', label: '待开始', actionLabel: '设为待开始' },
37
+ { key: 'doing', label: '进行中', actionLabel: '设为进行中' },
38
+ { key: 'done', label: '已完成', actionLabel: '设为已完成' },
29
39
  ]
30
40
 
31
41
  const initialTodos: TodoItem[] = [
@@ -34,59 +44,293 @@ const initialTodos: TodoItem[] = [
34
44
  title: '补充报表首页字段与展示规则',
35
45
  status: 'doing',
36
46
  archived: false,
37
- createdAt: '今天 09:30',
47
+ createdAt: new Date(Date.now() - 110 * minuteInMs).toISOString(),
48
+ createdOrder: 4,
38
49
  },
39
50
  {
40
51
  id: 2,
41
52
  title: '接入真实接口替换示例数据',
42
53
  status: 'todo',
43
54
  archived: false,
44
- createdAt: '今天 10:05',
55
+ createdAt: new Date(Date.now() - 65 * minuteInMs).toISOString(),
56
+ createdOrder: 3,
45
57
  },
46
58
  {
47
59
  id: 3,
48
60
  title: '复查主题切换和头部隐藏体验',
49
61
  status: 'done',
50
62
  archived: false,
51
- createdAt: '昨天 18:20',
63
+ createdAt: new Date(Date.now() - 20 * hourInMs).toISOString(),
64
+ createdOrder: 2,
52
65
  },
53
66
  {
54
67
  id: 4,
55
68
  title: '归档旧版原型页面',
56
69
  status: 'done',
57
70
  archived: true,
58
- createdAt: '昨天 14:05',
71
+ createdAt: new Date(Date.now() - 30 * hourInMs).toISOString(),
72
+ createdOrder: 1,
59
73
  },
60
74
  ]
61
75
 
62
- const getStoredTodos = (): TodoItem[] => {
63
- const raw = localStorage.getItem(todoStorageKey)
64
- if (!raw) {
65
- return initialTodos
76
+ const padDatePart = (value: number) => String(value).padStart(2, '0')
77
+
78
+ const formatCalendarDateTime = (value: Date, includeYear = true) => {
79
+ const month = padDatePart(value.getMonth() + 1)
80
+ const day = padDatePart(value.getDate())
81
+ const hours = padDatePart(value.getHours())
82
+ const minutes = padDatePart(value.getMinutes())
83
+
84
+ if (includeYear) {
85
+ return `${value.getFullYear()}-${month}-${day} ${hours}:${minutes}`
86
+ }
87
+
88
+ return `${month}-${day} ${hours}:${minutes}`
89
+ }
90
+
91
+ const getNextTodoId = (todos: TodoItem[]) =>
92
+ todos.reduce((maxId, item) => Math.max(maxId, item.id), 0) + 1
93
+
94
+ const getNextCreatedOrder = (todos: TodoItem[]) =>
95
+ todos.reduce((maxOrder, item) => Math.max(maxOrder, item.createdOrder), 0) + 1
96
+
97
+ const isSameCalendarDay = (left: Date, right: Date) =>
98
+ left.getFullYear() === right.getFullYear() &&
99
+ left.getMonth() === right.getMonth() &&
100
+ left.getDate() === right.getDate()
101
+
102
+ const isTodoStatus = (value: unknown): value is TodoStatus =>
103
+ value === 'todo' || value === 'doing' || value === 'done'
104
+
105
+ const isTodoFilter = (value: unknown): value is TodoFilter =>
106
+ value === 'all' ||
107
+ value === 'todo' ||
108
+ value === 'doing' ||
109
+ value === 'done' ||
110
+ value === 'archived'
111
+
112
+ const parseCreatedAtValue = (value: string, now = new Date()) => {
113
+ const directDate = new Date(value)
114
+ if (!Number.isNaN(directDate.getTime())) {
115
+ return directDate
116
+ }
117
+
118
+ if (value === '刚刚') {
119
+ return new Date(now.getTime() - 30 * 1000)
120
+ }
121
+
122
+ const minutesAgoMatch = /^(\d+)\s*分钟前$/.exec(value)
123
+ if (minutesAgoMatch) {
124
+ return new Date(now.getTime() - Number(minutesAgoMatch[1]) * minuteInMs)
125
+ }
126
+
127
+ const hoursAgoMatch = /^(\d+)\s*小时前$/.exec(value)
128
+ if (hoursAgoMatch) {
129
+ return new Date(now.getTime() - Number(hoursAgoMatch[1]) * hourInMs)
130
+ }
131
+
132
+ const todayMatch = /^今天\s+(\d{1,2}):(\d{2})$/.exec(value)
133
+ if (todayMatch) {
134
+ return new Date(
135
+ now.getFullYear(),
136
+ now.getMonth(),
137
+ now.getDate(),
138
+ Number(todayMatch[1]),
139
+ Number(todayMatch[2]),
140
+ )
141
+ }
142
+
143
+ const yesterdayMatch = /^昨天\s+(\d{1,2}):(\d{2})$/.exec(value)
144
+ if (yesterdayMatch) {
145
+ const yesterday = new Date(now)
146
+ yesterday.setDate(yesterday.getDate() - 1)
147
+
148
+ return new Date(
149
+ yesterday.getFullYear(),
150
+ yesterday.getMonth(),
151
+ yesterday.getDate(),
152
+ Number(yesterdayMatch[1]),
153
+ Number(yesterdayMatch[2]),
154
+ )
155
+ }
156
+
157
+ return null
158
+ }
159
+
160
+ const normalizeCreatedAt = (value: string, fallbackCreatedOrder: number) => {
161
+ const parsed = parseCreatedAtValue(value)
162
+ if (parsed) {
163
+ return parsed.toISOString()
164
+ }
165
+
166
+ return new Date(Date.now() - Math.max(1, fallbackCreatedOrder) * minuteInMs).toISOString()
167
+ }
168
+
169
+ const formatTodoCreatedAt = (value: string, now = new Date()) => {
170
+ const parsed = parseCreatedAtValue(value, now)
171
+ if (!parsed) {
172
+ return value
173
+ }
174
+
175
+ const diffMs = now.getTime() - parsed.getTime()
176
+ if (diffMs < 0) {
177
+ return formatCalendarDateTime(parsed)
178
+ }
179
+
180
+ if (diffMs < minuteInMs) {
181
+ return '刚刚'
182
+ }
183
+
184
+ if (diffMs < hourInMs) {
185
+ return `${Math.max(1, Math.floor(diffMs / minuteInMs))} 分钟前`
186
+ }
187
+
188
+ if (isSameCalendarDay(parsed, now)) {
189
+ return `${Math.max(1, Math.floor(diffMs / hourInMs))} 小时前`
190
+ }
191
+
192
+ const yesterday = new Date(now)
193
+ yesterday.setDate(yesterday.getDate() - 1)
194
+ if (diffMs < 2 * dayInMs && isSameCalendarDay(parsed, yesterday)) {
195
+ return `昨天 ${padDatePart(parsed.getHours())}:${padDatePart(parsed.getMinutes())}`
196
+ }
197
+
198
+ if (parsed.getFullYear() === now.getFullYear()) {
199
+ return formatCalendarDateTime(parsed, false)
200
+ }
201
+
202
+ return formatCalendarDateTime(parsed)
203
+ }
204
+
205
+ const getTodoCreatedAtTime = (item: TodoItem) => {
206
+ const parsed = parseCreatedAtValue(item.createdAt)
207
+ if (!parsed) {
208
+ return 0
209
+ }
210
+
211
+ return parsed.getTime()
212
+ }
213
+
214
+ const getClosestTodoActionElement = (target: EventTarget | null) => {
215
+ if (target instanceof Element) {
216
+ return target.closest<HTMLElement>('[data-todo-action]')
217
+ }
218
+
219
+ if (target instanceof Node) {
220
+ return target.parentElement?.closest<HTMLElement>('[data-todo-action]') ?? null
221
+ }
222
+
223
+ return null
224
+ }
225
+
226
+ const getTodoStorage = () => {
227
+ if (typeof globalThis === 'undefined' || !('localStorage' in globalThis)) {
228
+ return null
229
+ }
230
+
231
+ try {
232
+ return globalThis.localStorage
233
+ } catch {
234
+ return null
235
+ }
236
+ }
237
+
238
+ const parsePersistedTodoItem = (value: unknown, fallbackCreatedOrder: number): TodoItem | null => {
239
+ if (!value || typeof value !== 'object') {
240
+ return null
241
+ }
242
+
243
+ const candidate = value as Partial<TodoItem>
244
+ if (
245
+ typeof candidate.id !== 'number' ||
246
+ typeof candidate.title !== 'string' ||
247
+ typeof candidate.archived !== 'boolean' ||
248
+ typeof candidate.createdAt !== 'string' ||
249
+ !isTodoStatus(candidate.status)
250
+ ) {
251
+ return null
252
+ }
253
+
254
+ return {
255
+ id: candidate.id,
256
+ title: candidate.title,
257
+ archived: candidate.archived,
258
+ status: candidate.status,
259
+ createdAt: normalizeCreatedAt(candidate.createdAt, fallbackCreatedOrder),
260
+ createdOrder:
261
+ typeof candidate.createdOrder === 'number' && Number.isFinite(candidate.createdOrder)
262
+ ? candidate.createdOrder
263
+ : fallbackCreatedOrder,
264
+ }
265
+ }
266
+
267
+ const loadPersistedTodos = (value: unknown) => {
268
+ if (!Array.isArray(value)) {
269
+ return null
270
+ }
271
+
272
+ const todos = value
273
+ .map((item, index, source) => parsePersistedTodoItem(item, source.length - index))
274
+ .filter((item): item is TodoItem => item !== null)
275
+
276
+ if (value.length === 0 || todos.length > 0) {
277
+ return todos
278
+ }
279
+
280
+ return initialTodos
281
+ }
282
+
283
+ const loadPersistedTodoState = (): PersistedTodoState | null => {
284
+ const storage = getTodoStorage()
285
+ if (!storage) {
286
+ return null
66
287
  }
67
288
 
68
289
  try {
69
- const parsed = JSON.parse(raw)
70
- if (!Array.isArray(parsed)) {
71
- return initialTodos
290
+ const raw = storage.getItem(todoStorageKey)
291
+ if (!raw) {
292
+ return null
293
+ }
294
+
295
+ const parsed = JSON.parse(raw) as unknown
296
+ if (Array.isArray(parsed)) {
297
+ return {
298
+ todos: loadPersistedTodos(parsed) ?? initialTodos,
299
+ search: '',
300
+ activeFilter: 'all',
301
+ }
302
+ }
303
+
304
+ if (!parsed || typeof parsed !== 'object') {
305
+ return null
72
306
  }
73
307
 
74
- return parsed.filter(
75
- (item) =>
76
- item &&
77
- typeof item.id === 'number' &&
78
- typeof item.title === 'string' &&
79
- typeof item.archived === 'boolean' &&
80
- typeof item.status === 'string' &&
81
- typeof item.createdAt === 'string',
82
- ) as TodoItem[]
308
+ const candidate = parsed as {
309
+ todos?: unknown
310
+ search?: unknown
311
+ activeFilter?: unknown
312
+ }
313
+
314
+ return {
315
+ todos: loadPersistedTodos(candidate.todos) ?? initialTodos,
316
+ search: typeof candidate.search === 'string' ? candidate.search : '',
317
+ activeFilter: isTodoFilter(candidate.activeFilter) ? candidate.activeFilter : 'all',
318
+ }
83
319
  } catch {
84
- return initialTodos
320
+ return null
85
321
  }
86
322
  }
87
323
 
88
- const getNextTodoId = (todos: TodoItem[]) =>
89
- todos.reduce((maxId, item) => Math.max(maxId, item.id), 0) + 1
324
+ const persistTodoState = (state: PersistedTodoState) => {
325
+ const storage = getTodoStorage()
326
+ if (!storage) {
327
+ return
328
+ }
329
+
330
+ try {
331
+ storage.setItem(todoStorageKey, JSON.stringify(state))
332
+ } catch {}
333
+ }
90
334
 
91
335
  const statusMeta: Record<
92
336
  TodoStatus,
@@ -117,89 +361,88 @@ const statusMeta: Record<
117
361
  },
118
362
  }
119
363
 
120
- const EditingTitleInput: FC<{
121
- initialTitle: string
122
- onSave: (title: string) => void
123
- onCancel: () => void
124
- }> = (props) => {
125
- const [title, setTitle] = useState(props.initialTitle)
126
-
127
- return (
128
- <div className="flex flex-col gap-3 sm:flex-row">
129
- <input
130
- className="input input-bordered w-full"
131
- value={title.value}
132
- onInput={(event: any) => {
133
- setTitle((event.target as HTMLInputElement).value)
134
- }}
135
- onKeydown={(event: KeyboardEvent) => {
136
- if (event.key === 'Enter') {
137
- props.onSave(title.value.trim())
138
- }
139
- if (event.key === 'Escape') {
140
- props.onCancel()
141
- }
142
- }}
143
- />
144
- <div className="flex gap-2">
145
- <button className="btn btn-primary btn-sm" onClick={() => props.onSave(title.value.trim())}>
146
- 保存
147
- </button>
148
- <button className="btn btn-ghost btn-sm" onClick={props.onCancel}>
149
- 取消
150
- </button>
151
- </div>
152
- </div>
153
- )
154
- }
155
-
156
364
  const TodoApp: FC = () => {
157
- const todos = ref<TodoItem[]>(getStoredTodos())
365
+ const persistedState = loadPersistedTodoState()
366
+ const initialStateTodos = persistedState?.todos ?? initialTodos
367
+ const todos = ref<TodoItem[]>(initialStateTodos)
158
368
  const draft = ref('')
159
- const search = ref('')
160
- const activeFilter = ref<TodoFilter>('all')
369
+ const search = ref(persistedState?.search ?? '')
370
+ const activeFilter = ref<TodoFilter>(persistedState?.activeFilter ?? 'all')
161
371
  const editingId = ref<number | null>(null)
162
- const nextId = ref(getNextTodoId(todos.value))
163
-
164
- watch(
165
- () => todos.value,
166
- () => {
167
- localStorage.setItem(todoStorageKey, JSON.stringify(todos.value))
168
- nextId.value = getNextTodoId(todos.value)
169
- },
170
- )
372
+ const editingTitle = ref('')
373
+ const nextId = ref(getNextTodoId(initialStateTodos))
374
+ const nextCreatedOrder = ref(getNextCreatedOrder(initialStateTodos))
375
+ const todosVersion = ref(0)
376
+
377
+ const syncTodos = (nextTodos: TodoItem[]) => {
378
+ todos.value = nextTodos
379
+ todosVersion.value += 1
380
+ }
381
+
382
+ watchEffect(() => {
383
+ void todosVersion.value
384
+ persistTodoState({
385
+ todos: todos.value,
386
+ search: search.value,
387
+ activeFilter: activeFilter.value,
388
+ })
389
+ })
390
+
391
+ const counts = computed(() => {
392
+ void todosVersion.value
171
393
 
172
- const counts = computed(() => ({
173
- total: todos.value.filter((item) => !item.archived).length,
174
- todo: todos.value.filter((item) => !item.archived && item.status === 'todo').length,
175
- doing: todos.value.filter((item) => !item.archived && item.status === 'doing').length,
176
- done: todos.value.filter((item) => !item.archived && item.status === 'done').length,
177
- archived: todos.value.filter((item) => item.archived).length,
178
- }))
394
+ return {
395
+ total: todos.value.filter((item) => !item.archived).length,
396
+ todo: todos.value.filter((item) => !item.archived && item.status === 'todo').length,
397
+ doing: todos.value.filter((item) => !item.archived && item.status === 'doing').length,
398
+ done: todos.value.filter((item) => !item.archived && item.status === 'done').length,
399
+ archived: todos.value.filter((item) => item.archived).length,
400
+ }
401
+ })
179
402
 
180
403
  const visibleTodos = computed(() => {
404
+ void todosVersion.value
181
405
  const keyword = search.value.trim().toLowerCase()
182
406
 
183
- return todos.value.filter((item) => {
184
- const matchesKeyword = !keyword || item.title.toLowerCase().includes(keyword)
185
- if (!matchesKeyword) {
186
- return false
187
- }
188
-
189
- if (activeFilter.value === 'archived') {
190
- return item.archived
191
- }
192
-
193
- if (item.archived) {
194
- return false
195
- }
407
+ return todos.value
408
+ .filter((item) => {
409
+ const matchesKeyword = !keyword || item.title.toLowerCase().includes(keyword)
410
+ if (!matchesKeyword) {
411
+ return false
412
+ }
413
+
414
+ if (activeFilter.value === 'archived') {
415
+ return item.archived
416
+ }
417
+
418
+ if (item.archived) {
419
+ return false
420
+ }
421
+
422
+ if (activeFilter.value === 'all') {
423
+ return true
424
+ }
425
+
426
+ return item.status === activeFilter.value
427
+ })
428
+ .sort((left, right) => {
429
+ const createdAtDiff = getTodoCreatedAtTime(right) - getTodoCreatedAtTime(left)
430
+ if (createdAtDiff !== 0) {
431
+ return createdAtDiff
432
+ }
433
+
434
+ return right.createdOrder - left.createdOrder
435
+ })
436
+ })
196
437
 
197
- if (activeFilter.value === 'all') {
198
- return true
199
- }
438
+ const visibleTodoCards = computed(() => {
439
+ const currentEditingId = editingId.value
200
440
 
201
- return item.status === activeFilter.value
202
- })
441
+ return visibleTodos.get().map((item) => ({
442
+ item,
443
+ meta: statusMeta[item.status],
444
+ isEditing: currentEditingId === item.id,
445
+ }))
203
446
  })
204
447
 
205
448
  const addTodo = () => {
@@ -208,55 +451,147 @@ const TodoApp: FC = () => {
208
451
  return
209
452
  }
210
453
 
211
- todos.value = [
454
+ syncTodos([
212
455
  {
213
456
  id: nextId.value++,
214
457
  title,
215
458
  status: 'todo',
216
459
  archived: false,
217
- createdAt: '刚刚',
460
+ createdAt: new Date().toISOString(),
461
+ createdOrder: nextCreatedOrder.value++,
218
462
  },
219
463
  ...todos.value,
220
- ]
464
+ ])
221
465
  draft.value = ''
222
466
  }
223
467
 
224
468
  const removeTodo = (id: number) => {
225
- todos.value = todos.value.filter((item) => item.id !== id)
469
+ syncTodos(todos.value.filter((item) => item.id !== id))
226
470
  if (editingId.value === id) {
227
- editingId.value = null
471
+ cancelEditing()
228
472
  }
229
473
  }
230
474
 
231
475
  const updateStatus = (id: number, status: TodoStatus) => {
232
- todos.value = todos.value.map((item) =>
233
- item.id === id ? { ...item, status, archived: false } : item,
476
+ syncTodos(
477
+ todos.value.map((item) => (item.id === id ? { ...item, status, archived: false } : item)),
234
478
  )
235
479
  }
236
480
 
237
481
  const toggleArchived = (id: number) => {
238
- todos.value = todos.value.map((item) =>
239
- item.id === id ? { ...item, archived: !item.archived } : item,
482
+ syncTodos(
483
+ todos.value.map((item) => (item.id === id ? { ...item, archived: !item.archived } : item)),
240
484
  )
241
485
  }
242
486
 
243
487
  const startEditing = (item: TodoItem) => {
244
488
  editingId.value = item.id
489
+ editingTitle.value = item.title
245
490
  }
246
491
 
247
492
  const cancelEditing = () => {
248
493
  editingId.value = null
494
+ editingTitle.value = ''
249
495
  }
250
496
 
251
- const saveEditing = (id: number, title: string) => {
497
+ const saveEditing = (id: number) => {
498
+ const title = editingTitle.value.trim()
252
499
  if (!title) {
253
500
  return
254
501
  }
255
502
 
256
- todos.value = todos.value.map((item) => (item.id === id ? { ...item, title } : item))
503
+ syncTodos(todos.value.map((item) => (item.id === id ? { ...item, title } : item)))
257
504
  cancelEditing()
258
505
  }
259
506
 
507
+ const handleTodoListClick = (event: MouseEvent) => {
508
+ const actionElement = getClosestTodoActionElement(event.target)
509
+
510
+ if (!actionElement) {
511
+ return
512
+ }
513
+
514
+ const action = actionElement.dataset.todoAction
515
+ const id = Number(actionElement.dataset.todoId)
516
+
517
+ if (!Number.isFinite(id)) {
518
+ return
519
+ }
520
+
521
+ if (action === 'status') {
522
+ const status = actionElement.dataset.todoStatus
523
+ if (isTodoStatus(status)) {
524
+ updateStatus(id, status)
525
+ }
526
+ return
527
+ }
528
+
529
+ if (action === 'start-editing') {
530
+ const item = todos.value.find((candidate) => candidate.id === id)
531
+ if (item) {
532
+ startEditing(item)
533
+ }
534
+ return
535
+ }
536
+
537
+ if (action === 'save-editing') {
538
+ saveEditing(id)
539
+ return
540
+ }
541
+
542
+ if (action === 'cancel-editing') {
543
+ cancelEditing()
544
+ return
545
+ }
546
+
547
+ if (action === 'toggle-archived') {
548
+ toggleArchived(id)
549
+ return
550
+ }
551
+
552
+ if (action === 'remove') {
553
+ removeTodo(id)
554
+ }
555
+ }
556
+
557
+ const handleTodoListInput = (event: Event) => {
558
+ if (!(event.target instanceof HTMLInputElement)) {
559
+ return
560
+ }
561
+
562
+ const target = event.target
563
+ if (!target.matches('[data-todo-edit-input]')) {
564
+ return
565
+ }
566
+
567
+ editingTitle.value = target.value
568
+ }
569
+
570
+ const handleTodoListKeydown = (event: KeyboardEvent) => {
571
+ if (!(event.target instanceof HTMLInputElement)) {
572
+ return
573
+ }
574
+
575
+ const target = event.target
576
+ if (!target.matches('[data-todo-edit-input]')) {
577
+ return
578
+ }
579
+
580
+ const id = Number(target.dataset.todoId)
581
+ if (!Number.isFinite(id)) {
582
+ return
583
+ }
584
+
585
+ if (event.key === 'Enter' && !event.isComposing) {
586
+ event.preventDefault()
587
+ saveEditing(id)
588
+ }
589
+
590
+ if (event.key === 'Escape') {
591
+ cancelEditing()
592
+ }
593
+ }
594
+
260
595
  return (
261
596
  <div className="space-y-6 pb-4">
262
597
  <section className="hero border border-base-300 bg-base-100 shadow-xl">
@@ -305,7 +640,8 @@ const TodoApp: FC = () => {
305
640
  draft.value = (event.target as HTMLInputElement).value
306
641
  }}
307
642
  onKeydown={(event: KeyboardEvent) => {
308
- if (event.key === 'Enter') {
643
+ if (event.key === 'Enter' && !event.isComposing) {
644
+ event.preventDefault()
309
645
  addTodo()
310
646
  }
311
647
  }}
@@ -366,101 +702,130 @@ const TodoApp: FC = () => {
366
702
  </div>
367
703
  </section>
368
704
 
369
- <section className="grid gap-4">
370
- {visibleTodos.get().length ? (
371
- visibleTodos.get().map((item) => {
372
- const meta = statusMeta[item.status]
373
- const isEditing = editingId.value === item.id
374
-
375
- return (
376
- <article
377
- key={item.id}
378
- className={`card border bg-base-100 shadow-sm transition-all ${meta.cardClass} ${
379
- item.archived ? 'opacity-75' : 'hover:-translate-y-0.5 hover:shadow-md'
380
- }`}
381
- >
382
- <div className="card-body gap-4">
383
- <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
384
- <div className="flex-1 space-y-3">
385
- <div className="flex flex-wrap items-center gap-2">
386
- <span
387
- className={`inline-block h-2.5 w-2.5 rounded-full ${meta.dotClass}`}
388
- ></span>
389
- <span className={meta.badgeClass}>{meta.label}</span>
390
- {item.archived && (
391
- <span className="badge badge-secondary badge-outline">已归档</span>
392
- )}
393
- <span className="text-xs text-base-content/50">
394
- 创建于 {item.createdAt}
395
- </span>
396
- </div>
705
+ <section
706
+ className="grid gap-4"
707
+ onClick={handleTodoListClick}
708
+ onInput={handleTodoListInput}
709
+ onKeydown={handleTodoListKeydown}
710
+ >
711
+ {visibleTodoCards.get().map((card) => (
712
+ <article
713
+ key={card.item.id}
714
+ className={`card border bg-base-100 shadow-sm transition-all ${card.meta.cardClass} ${
715
+ card.item.archived ? 'opacity-75' : 'hover:-translate-y-0.5 hover:shadow-md'
716
+ }`}
717
+ >
718
+ <div className="card-body gap-4">
719
+ <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
720
+ <div className="flex-1 space-y-3">
721
+ <div className="flex flex-wrap items-center gap-2">
722
+ <span
723
+ className={`inline-block h-2.5 w-2.5 rounded-full ${card.meta.dotClass}`}
724
+ ></span>
725
+ <span className={card.meta.badgeClass}>{card.meta.label}</span>
726
+ {card.item.archived && (
727
+ <span className="badge badge-secondary badge-outline">已归档</span>
728
+ )}
729
+ <span className="text-xs text-base-content/50">
730
+ 创建于 {formatTodoCreatedAt(card.item.createdAt)}
731
+ </span>
732
+ </div>
397
733
 
398
- {!isEditing && (
399
- <h2
400
- className={`text-xl font-semibold ${
401
- item.status === 'done'
402
- ? 'text-base-content/50 line-through'
403
- : 'text-base-content'
404
- }`}
734
+ {!card.isEditing && (
735
+ <h2
736
+ className={`text-xl font-semibold ${
737
+ card.item.status === 'done'
738
+ ? 'text-base-content/50 line-through'
739
+ : 'text-base-content'
740
+ }`}
741
+ >
742
+ {card.item.title}
743
+ </h2>
744
+ )}
745
+
746
+ {card.isEditing && (
747
+ <div className="flex flex-col gap-3 sm:flex-row">
748
+ <input
749
+ className="input input-bordered w-full"
750
+ data-todo-edit-input="true"
751
+ data-todo-id={String(card.item.id)}
752
+ value={editingTitle.value}
753
+ />
754
+ <div className="flex gap-2">
755
+ <button
756
+ className="btn btn-primary btn-sm"
757
+ data-todo-action="save-editing"
758
+ data-todo-id={String(card.item.id)}
759
+ type="button"
405
760
  >
406
- {item.title}
407
- </h2>
408
- )}
409
-
410
- {isEditing && (
411
- <EditingTitleInput
412
- key={item.id}
413
- initialTitle={item.title}
414
- onSave={(title) => saveEditing(item.id, title)}
415
- onCancel={cancelEditing}
416
- />
417
- )}
418
-
419
- <div className="flex flex-wrap gap-2">
420
- {statusOptions.map((option) => (
421
- <button
422
- key={option.key}
423
- className={`btn btn-xs ${
424
- item.status === option.key
425
- ? 'btn-neutral'
426
- : 'btn-ghost border border-base-300'
427
- }`}
428
- onClick={() => updateStatus(item.id, option.key)}
429
- >
430
- {option.label}
431
- </button>
432
- ))}
433
- </div>
434
- </div>
435
-
436
- <div className="flex flex-wrap gap-2 lg:justify-end">
437
- {!isEditing && (
761
+ 保存
762
+ </button>
438
763
  <button
439
- className="btn btn-sm btn-outline"
440
- onClick={() => startEditing(item)}
764
+ className="btn btn-ghost btn-sm"
765
+ data-todo-action="cancel-editing"
766
+ data-todo-id={String(card.item.id)}
767
+ type="button"
441
768
  >
442
- 改名
769
+ 取消
443
770
  </button>
444
- )}
445
- <button
446
- className="btn btn-sm btn-outline btn-secondary"
447
- onClick={() => toggleArchived(item.id)}
448
- >
449
- {item.archived ? '恢复' : '归档'}
450
- </button>
771
+ </div>
772
+ </div>
773
+ )}
774
+
775
+ <div className="flex flex-wrap gap-2">
776
+ {statusOptions.map((option) => (
451
777
  <button
452
- className="btn btn-sm btn-outline btn-error"
453
- onClick={() => removeTodo(item.id)}
778
+ key={option.key}
779
+ className={`btn btn-xs ${
780
+ card.item.status === option.key
781
+ ? 'btn-neutral'
782
+ : 'btn-ghost border border-base-300'
783
+ }`}
784
+ data-todo-action="status"
785
+ data-todo-id={String(card.item.id)}
786
+ data-todo-status={option.key}
787
+ type="button"
454
788
  >
455
- 删除
789
+ {option.actionLabel}
456
790
  </button>
457
- </div>
791
+ ))}
458
792
  </div>
459
793
  </div>
460
- </article>
461
- )
462
- })
463
- ) : (
794
+
795
+ <div className="flex flex-wrap gap-2 lg:justify-end">
796
+ {!card.isEditing && (
797
+ <button
798
+ className="btn btn-sm btn-outline"
799
+ data-todo-action="start-editing"
800
+ data-todo-id={String(card.item.id)}
801
+ type="button"
802
+ >
803
+ 改名
804
+ </button>
805
+ )}
806
+ <button
807
+ className="btn btn-sm btn-outline btn-secondary"
808
+ data-todo-action="toggle-archived"
809
+ data-todo-id={String(card.item.id)}
810
+ type="button"
811
+ >
812
+ {card.item.archived ? '恢复' : '归档'}
813
+ </button>
814
+ <button
815
+ className="btn btn-sm btn-outline btn-error"
816
+ data-todo-action="remove"
817
+ data-todo-id={String(card.item.id)}
818
+ type="button"
819
+ >
820
+ 删除
821
+ </button>
822
+ </div>
823
+ </div>
824
+ </div>
825
+ </article>
826
+ ))}
827
+
828
+ {!visibleTodoCards.get().length && (
464
829
  <div className="card border border-dashed border-base-300 bg-base-100 shadow-sm">
465
830
  <div className="card-body items-center py-14 text-center">
466
831
  <h2 className="text-xl font-semibold">当前筛选下没有任务</h2>