create-rue 0.0.11 → 0.0.15

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
+ }
72
302
  }
73
303
 
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[]
304
+ if (!parsed || typeof parsed !== 'object') {
305
+ return null
306
+ }
307
+
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
+ }
171
381
 
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
- }))
382
+ watchEffect(() => {
383
+ void todosVersion.value
384
+ persistTodoState({
385
+ todos: todos.value,
386
+ search: search.value,
387
+ activeFilter: activeFilter.value,
388
+ })
389
+ })
179
390
 
180
- const visibleTodos = computed(() => {
181
- const keyword = search.value.trim().toLowerCase()
391
+ const counts = computed(() => {
392
+ void todosVersion.value
182
393
 
183
- return todos.value.filter(item => {
184
- const matchesKeyword = !keyword || item.title.toLowerCase().includes(keyword)
185
- if (!matchesKeyword) {
186
- return false
187
- }
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
+ })
188
402
 
189
- if (activeFilter.value === 'archived') {
190
- return item.archived
191
- }
403
+ const visibleTodos = computed(() => {
404
+ void todosVersion.value
405
+ const keyword = search.value.trim().toLowerCase()
192
406
 
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,61 +451,155 @@ 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">
263
598
  <div className="hero-content flex-col items-start gap-8 px-6 py-8 lg:flex-row lg:items-end lg:justify-between">
264
599
  <div className="max-w-3xl space-y-4">
265
- <h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">Todo App 已作为 base 模板内置页面</h1>
600
+ <h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
601
+ Todo App 已作为 base 模板内置页面
602
+ </h1>
266
603
  <div className="flex flex-wrap gap-3">
267
604
  <RouterLink to="/" className="btn btn-outline">
268
605
  返回默认简报
@@ -303,7 +640,8 @@ const TodoApp: FC = () => {
303
640
  draft.value = (event.target as HTMLInputElement).value
304
641
  }}
305
642
  onKeydown={(event: KeyboardEvent) => {
306
- if (event.key === 'Enter') {
643
+ if (event.key === 'Enter' && !event.isComposing) {
644
+ event.preventDefault()
307
645
  addTodo()
308
646
  }
309
647
  }}
@@ -330,7 +668,7 @@ const TodoApp: FC = () => {
330
668
  </div>
331
669
 
332
670
  <div className="flex flex-wrap gap-2">
333
- {filterOptions.map(filter => (
671
+ {filterOptions.map((filter) => (
334
672
  <button
335
673
  key={filter.key}
336
674
  className={`btn btn-sm ${
@@ -364,89 +702,130 @@ const TodoApp: FC = () => {
364
702
  </div>
365
703
  </section>
366
704
 
367
- <section className="grid gap-4">
368
- {visibleTodos.get().length ? (
369
- visibleTodos.get().map(item => {
370
- const meta = statusMeta[item.status]
371
- const isEditing = editingId.value === item.id
372
-
373
- return (
374
- <article
375
- key={item.id}
376
- className={`card border bg-base-100 shadow-sm transition-all ${meta.cardClass} ${
377
- item.archived ? 'opacity-75' : 'hover:-translate-y-0.5 hover:shadow-md'
378
- }`}
379
- >
380
- <div className="card-body gap-4">
381
- <div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
382
- <div className="flex-1 space-y-3">
383
- <div className="flex flex-wrap items-center gap-2">
384
- <span className={`inline-block h-2.5 w-2.5 rounded-full ${meta.dotClass}`}></span>
385
- <span className={meta.badgeClass}>{meta.label}</span>
386
- {item.archived && <span className="badge badge-secondary badge-outline">已归档</span>}
387
- <span className="text-xs text-base-content/50">创建于 {item.createdAt}</span>
388
- </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>
389
733
 
390
- {!isEditing && (
391
- <h2
392
- className={`text-xl font-semibold ${
393
- item.status === 'done'
394
- ? 'text-base-content/50 line-through'
395
- : 'text-base-content'
396
- }`}
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"
397
760
  >
398
- {item.title}
399
- </h2>
400
- )}
401
-
402
- {isEditing && (
403
- <EditingTitleInput
404
- key={item.id}
405
- initialTitle={item.title}
406
- onSave={title => saveEditing(item.id, title)}
407
- onCancel={cancelEditing}
408
- />
409
- )}
410
-
411
- <div className="flex flex-wrap gap-2">
412
- {statusOptions.map(option => (
413
- <button
414
- key={option.key}
415
- className={`btn btn-xs ${
416
- item.status === option.key
417
- ? 'btn-neutral'
418
- : 'btn-ghost border border-base-300'
419
- }`}
420
- onClick={() => updateStatus(item.id, option.key)}
421
- >
422
- {option.label}
423
- </button>
424
- ))}
761
+ 保存
762
+ </button>
763
+ <button
764
+ className="btn btn-ghost btn-sm"
765
+ data-todo-action="cancel-editing"
766
+ data-todo-id={String(card.item.id)}
767
+ type="button"
768
+ >
769
+ 取消
770
+ </button>
425
771
  </div>
426
772
  </div>
773
+ )}
427
774
 
428
- <div className="flex flex-wrap gap-2 lg:justify-end">
429
- {!isEditing && (
430
- <button className="btn btn-sm btn-outline" onClick={() => startEditing(item)}>
431
- 改名
432
- </button>
433
- )}
775
+ <div className="flex flex-wrap gap-2">
776
+ {statusOptions.map((option) => (
434
777
  <button
435
- className="btn btn-sm btn-outline btn-secondary"
436
- onClick={() => toggleArchived(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"
437
788
  >
438
- {item.archived ? '恢复' : '归档'}
439
- </button>
440
- <button className="btn btn-sm btn-outline btn-error" onClick={() => removeTodo(item.id)}>
441
- 删除
789
+ {option.actionLabel}
442
790
  </button>
443
- </div>
791
+ ))}
444
792
  </div>
445
793
  </div>
446
- </article>
447
- )
448
- })
449
- ) : (
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 && (
450
829
  <div className="card border border-dashed border-base-300 bg-base-100 shadow-sm">
451
830
  <div className="card-body items-center py-14 text-center">
452
831
  <h2 className="text-xl font-semibold">当前筛选下没有任务</h2>
@@ -461,4 +840,4 @@ const TodoApp: FC = () => {
461
840
  )
462
841
  }
463
842
 
464
- export default TodoApp
843
+ export default TodoApp