create-rue 0.8.0 → 0.9.7

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, useState, 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,281 @@ 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 getTodoStorage = () => {
215
+ if (typeof globalThis === 'undefined' || !('localStorage' in globalThis)) {
216
+ return null
66
217
  }
67
218
 
68
219
  try {
69
- const parsed = JSON.parse(raw)
70
- if (!Array.isArray(parsed)) {
71
- return initialTodos
220
+ return globalThis.localStorage
221
+ } catch {
222
+ return null
223
+ }
224
+ }
225
+
226
+ const parsePersistedTodoItem = (value: unknown, fallbackCreatedOrder: number): TodoItem | null => {
227
+ if (!value || typeof value !== 'object') {
228
+ return null
229
+ }
230
+
231
+ const candidate = value as Partial<TodoItem>
232
+ if (
233
+ typeof candidate.id !== 'number' ||
234
+ typeof candidate.title !== 'string' ||
235
+ typeof candidate.archived !== 'boolean' ||
236
+ typeof candidate.createdAt !== 'string' ||
237
+ !isTodoStatus(candidate.status)
238
+ ) {
239
+ return null
240
+ }
241
+
242
+ return {
243
+ id: candidate.id,
244
+ title: candidate.title,
245
+ archived: candidate.archived,
246
+ status: candidate.status,
247
+ createdAt: normalizeCreatedAt(candidate.createdAt, fallbackCreatedOrder),
248
+ createdOrder:
249
+ typeof candidate.createdOrder === 'number' && Number.isFinite(candidate.createdOrder)
250
+ ? candidate.createdOrder
251
+ : fallbackCreatedOrder,
252
+ }
253
+ }
254
+
255
+ const loadPersistedTodos = (value: unknown) => {
256
+ if (!Array.isArray(value)) {
257
+ return null
258
+ }
259
+
260
+ const todos = value
261
+ .map((item, index, source) => parsePersistedTodoItem(item, source.length - index))
262
+ .filter((item): item is TodoItem => item !== null)
263
+
264
+ if (value.length === 0 || todos.length > 0) {
265
+ return todos
266
+ }
267
+
268
+ return initialTodos
269
+ }
270
+
271
+ const loadPersistedTodoState = (): PersistedTodoState | null => {
272
+ const storage = getTodoStorage()
273
+ if (!storage) {
274
+ return null
275
+ }
276
+
277
+ try {
278
+ const raw = storage.getItem(todoStorageKey)
279
+ if (!raw) {
280
+ return null
281
+ }
282
+
283
+ const parsed = JSON.parse(raw) as unknown
284
+ if (Array.isArray(parsed)) {
285
+ return {
286
+ todos: loadPersistedTodos(parsed) ?? initialTodos,
287
+ search: '',
288
+ activeFilter: 'all',
289
+ }
290
+ }
291
+
292
+ if (!parsed || typeof parsed !== 'object') {
293
+ return null
294
+ }
295
+
296
+ const candidate = parsed as {
297
+ todos?: unknown
298
+ search?: unknown
299
+ activeFilter?: unknown
72
300
  }
73
301
 
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[]
302
+ return {
303
+ todos: loadPersistedTodos(candidate.todos) ?? initialTodos,
304
+ search: typeof candidate.search === 'string' ? candidate.search : '',
305
+ activeFilter: isTodoFilter(candidate.activeFilter) ? candidate.activeFilter : 'all',
306
+ }
83
307
  } catch {
84
- return initialTodos
308
+ return null
85
309
  }
86
310
  }
87
311
 
88
- const getNextTodoId = (todos: TodoItem[]) =>
89
- todos.reduce((maxId, item) => Math.max(maxId, item.id), 0) + 1
312
+ const persistTodoState = (state: PersistedTodoState) => {
313
+ const storage = getTodoStorage()
314
+ if (!storage) {
315
+ return
316
+ }
317
+
318
+ try {
319
+ storage.setItem(todoStorageKey, JSON.stringify(state))
320
+ } catch {}
321
+ }
90
322
 
91
323
  const statusMeta: Record<
92
324
  TodoStatus,
@@ -117,143 +349,130 @@ const statusMeta: Record<
117
349
  },
118
350
  }
119
351
 
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
352
  const TodoApp: FC = () => {
157
- const todos = ref<TodoItem[]>(getStoredTodos())
158
- const draft = ref('')
159
- const search = ref('')
160
- const activeFilter = ref<TodoFilter>('all')
161
- 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
- )
353
+ const persistedState = loadPersistedTodoState()
354
+ const initialStateTodos = persistedState?.todos ?? initialTodos
355
+ const initialActiveFilter: TodoFilter = persistedState?.activeFilter ?? 'all'
356
+ const [todos, setTodos] = useState<TodoItem[]>(initialStateTodos)
357
+ const [draft, setDraft] = useState('')
358
+ const [search, setSearch] = useState(persistedState?.search ?? '')
359
+ const activeFilter = ref<TodoFilter>(initialActiveFilter)
360
+ const setActiveFilter = (nextFilter: TodoFilter) => {
361
+ activeFilter.value = nextFilter
362
+ }
363
+ const [editingId, setEditingId] = useState<number | null>(null)
364
+ const [editingTitle, setEditingTitle] = useState('')
365
+ const nextId = ref(getNextTodoId(initialStateTodos))
366
+ const nextCreatedOrder = ref(getNextCreatedOrder(initialStateTodos))
367
+
368
+ watchEffect(() => {
369
+ persistTodoState({
370
+ todos,
371
+ search,
372
+ activeFilter: activeFilter.value,
373
+ })
374
+ })
171
375
 
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
- }))
376
+ const counts = computed(() => {
377
+ return {
378
+ total: todos.filter((item) => !item.archived).length,
379
+ todo: todos.filter((item) => !item.archived && item.status === 'todo').length,
380
+ doing: todos.filter((item) => !item.archived && item.status === 'doing').length,
381
+ done: todos.filter((item) => !item.archived && item.status === 'done').length,
382
+ archived: todos.filter((item) => item.archived).length,
383
+ }
384
+ })
179
385
 
180
386
  const visibleTodos = computed(() => {
181
- const keyword = search.value.trim().toLowerCase()
182
-
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
- }
196
-
197
- if (activeFilter.value === 'all') {
198
- return true
199
- }
200
-
201
- return item.status === activeFilter.value
202
- })
387
+ const keyword = search.trim().toLowerCase()
388
+
389
+ return todos
390
+ .filter((item) => {
391
+ const matchesKeyword = !keyword || item.title.toLowerCase().includes(keyword)
392
+ if (!matchesKeyword) {
393
+ return false
394
+ }
395
+
396
+ if (activeFilter.value === 'archived') {
397
+ return item.archived
398
+ }
399
+
400
+ if (item.archived) {
401
+ return false
402
+ }
403
+
404
+ if (activeFilter.value === 'all') {
405
+ return true
406
+ }
407
+
408
+ return item.status === activeFilter.value
409
+ })
410
+ .sort((left, right) => {
411
+ const createdAtDiff = getTodoCreatedAtTime(right) - getTodoCreatedAtTime(left)
412
+ if (createdAtDiff !== 0) {
413
+ return createdAtDiff
414
+ }
415
+
416
+ return right.createdOrder - left.createdOrder
417
+ })
203
418
  })
204
419
 
205
420
  const addTodo = () => {
206
- const title = draft.value.trim()
421
+ const title = draft.trim()
207
422
  if (!title) {
208
423
  return
209
424
  }
210
425
 
211
- todos.value = [
212
- {
213
- id: nextId.value++,
214
- title,
215
- status: 'todo',
216
- archived: false,
217
- createdAt: '刚刚',
218
- },
219
- ...todos.value,
220
- ]
221
- draft.value = ''
426
+ const nextTodo: TodoItem = {
427
+ id: nextId.value++,
428
+ title,
429
+ status: 'todo',
430
+ archived: false,
431
+ createdAt: new Date().toISOString(),
432
+ createdOrder: nextCreatedOrder.value++,
433
+ }
434
+
435
+ setTodos((current) => [nextTodo, ...current])
436
+ setDraft('')
222
437
  }
223
438
 
224
439
  const removeTodo = (id: number) => {
225
- todos.value = todos.value.filter(item => item.id !== id)
226
- if (editingId.value === id) {
227
- editingId.value = null
440
+ setTodos((current) => current.filter((item) => item.id !== id))
441
+ if (editingId === id) {
442
+ setEditingId(null)
443
+ setEditingTitle('')
228
444
  }
229
445
  }
230
446
 
231
447
  const updateStatus = (id: number, status: TodoStatus) => {
232
- todos.value = todos.value.map(item =>
233
- item.id === id ? { ...item, status, archived: false } : item,
448
+ setTodos((current) =>
449
+ current.map((item) => (item.id === id ? { ...item, status, archived: false } : item)),
234
450
  )
235
451
  }
236
452
 
237
453
  const toggleArchived = (id: number) => {
238
- todos.value = todos.value.map(item =>
239
- item.id === id ? { ...item, archived: !item.archived } : item,
454
+ setTodos((current) =>
455
+ current.map((item) => (item.id === id ? { ...item, archived: !item.archived } : item)),
240
456
  )
241
457
  }
242
458
 
243
459
  const startEditing = (item: TodoItem) => {
244
- editingId.value = item.id
460
+ setEditingId(item.id)
461
+ setEditingTitle(item.title)
245
462
  }
246
463
 
247
464
  const cancelEditing = () => {
248
- editingId.value = null
465
+ setEditingId(null)
466
+ setEditingTitle('')
249
467
  }
250
468
 
251
- const saveEditing = (id: number, title: string) => {
469
+ const saveEditing = (id: number, titleOverride?: string) => {
470
+ const title = (titleOverride ?? editingTitle).trim()
252
471
  if (!title) {
253
472
  return
254
473
  }
255
474
 
256
- todos.value = todos.value.map(item => (item.id === id ? { ...item, title } : item))
475
+ setTodos((current) => current.map((item) => (item.id === id ? { ...item, title } : item)))
257
476
  cancelEditing()
258
477
  }
259
478
 
@@ -299,13 +518,14 @@ const TodoApp: FC = () => {
299
518
  <div className="join w-full">
300
519
  <input
301
520
  className="input input-bordered join-item w-full"
302
- value={draft.value}
521
+ value={draft}
303
522
  placeholder="例如:把日报页接入真实 API"
304
523
  onInput={(event: any) => {
305
- draft.value = (event.target as HTMLInputElement).value
524
+ setDraft((event.target as HTMLInputElement).value)
306
525
  }}
307
526
  onKeydown={(event: KeyboardEvent) => {
308
- if (event.key === 'Enter') {
527
+ if (event.key === 'Enter' && !event.isComposing) {
528
+ event.preventDefault()
309
529
  addTodo()
310
530
  }
311
531
  }}
@@ -322,17 +542,17 @@ const TodoApp: FC = () => {
322
542
  </div>
323
543
  <input
324
544
  className="input input-bordered w-full"
325
- value={search.value}
545
+ value={search}
326
546
  placeholder="按标题筛选任务"
327
547
  onInput={(event: any) => {
328
- search.value = (event.target as HTMLInputElement).value
548
+ setSearch((event.target as HTMLInputElement).value)
329
549
  }}
330
550
  />
331
551
  </label>
332
552
  </div>
333
553
 
334
554
  <div className="flex flex-wrap gap-2">
335
- {filterOptions.map(filter => (
555
+ {filterOptions.map((filter) => (
336
556
  <button
337
557
  key={filter.key}
338
558
  className={`btn btn-sm ${
@@ -341,7 +561,7 @@ const TodoApp: FC = () => {
341
561
  : 'btn-ghost border border-base-300'
342
562
  }`}
343
563
  onClick={() => {
344
- activeFilter.value = filter.key
564
+ setActiveFilter(filter.key)
345
565
  }}
346
566
  >
347
567
  {filter.label}
@@ -367,15 +587,19 @@ const TodoApp: FC = () => {
367
587
  </section>
368
588
 
369
589
  <section className="grid gap-4">
370
- {visibleTodos.get().map(item => {
590
+ {visibleTodos.get().map((item) => {
591
+ const isEditing = editingId === item.id
592
+ const editingValue = isEditing ? editingTitle : item.title
371
593
  const meta = statusMeta[item.status]
372
- const isEditing = editingId.value === item.id
594
+
595
+ const commitEditing = (latestValue: string) => {
596
+ setEditingTitle(latestValue)
597
+ saveEditing(item.id, latestValue)
598
+ }
373
599
 
374
600
  return (
375
601
  <article
376
- key={`${item.id}-${item.title}-${item.status}-${item.archived ? 'archived' : 'active'}-${
377
- isEditing ? 'editing' : 'view'
378
- }`}
602
+ key={`${item.id}-${item.title}-${item.status}-${item.archived ? 'archived' : 'active'}-${isEditing ? 'editing' : 'view'}`}
379
603
  className={`card border bg-base-100 shadow-sm transition-all ${meta.cardClass} ${
380
604
  item.archived ? 'opacity-75' : 'hover:-translate-y-0.5 hover:shadow-md'
381
605
  }`}
@@ -391,32 +615,68 @@ const TodoApp: FC = () => {
391
615
  {item.archived && (
392
616
  <span className="badge badge-secondary badge-outline">已归档</span>
393
617
  )}
394
- <span className="text-xs text-base-content/50">创建于 {item.createdAt}</span>
618
+ <span className="text-xs text-base-content/50">
619
+ 创建于 {formatTodoCreatedAt(item.createdAt)}
620
+ </span>
395
621
  </div>
396
622
 
397
- {!isEditing && (
398
- <h2
399
- className={`text-xl font-semibold ${
400
- item.status === 'done'
623
+ <h2
624
+ className={`text-xl font-semibold ${
625
+ isEditing
626
+ ? 'hidden'
627
+ : item.status === 'done'
401
628
  ? 'text-base-content/50 line-through'
402
629
  : 'text-base-content'
403
- }`}
404
- >
405
- {item.title}
406
- </h2>
407
- )}
630
+ }`}
631
+ >
632
+ {item.title}
633
+ </h2>
408
634
 
409
- {isEditing && (
410
- <EditingTitleInput
411
- key={item.id}
412
- initialTitle={item.title}
413
- onSave={title => saveEditing(item.id, title)}
414
- onCancel={cancelEditing}
635
+ <div
636
+ data-todo-edit-row="true"
637
+ className={`flex flex-col gap-3 sm:flex-row ${isEditing ? '' : 'hidden'}`}
638
+ >
639
+ <input
640
+ className="input input-bordered w-full"
641
+ value={editingValue}
642
+ onInput={(event: any) => {
643
+ setEditingTitle((event.target as HTMLInputElement).value)
644
+ }}
645
+ onKeydown={(event: KeyboardEvent) => {
646
+ if (event.key === 'Enter') {
647
+ commitEditing((event.target as HTMLInputElement).value)
648
+ }
649
+ if (event.key === 'Escape') {
650
+ cancelEditing()
651
+ }
652
+ }}
415
653
  />
416
- )}
654
+ <div className="flex gap-2">
655
+ <button
656
+ className="btn btn-primary btn-sm"
657
+ onClick={(event: any) => {
658
+ const editRow = (event.currentTarget as HTMLElement).closest(
659
+ '[data-todo-edit-row="true"]',
660
+ ) as HTMLElement | null
661
+ const input = editRow?.querySelector('input') as HTMLInputElement | null
662
+ commitEditing(input?.value ?? editingValue)
663
+ }}
664
+ type="button"
665
+ >
666
+ 保存
667
+ </button>
668
+ <button
669
+ className="btn btn-ghost btn-sm"
670
+ onClick={cancelEditing}
671
+ type="button"
672
+ >
673
+ 取消
674
+ </button>
675
+ </div>
676
+ </div>
417
677
 
418
678
  <div className="flex flex-wrap gap-2">
419
- {statusOptions.map(option => (
679
+ {statusOptions.map((option) => (
420
680
  <button
421
681
  key={option.key}
422
682
  className={`btn btn-xs ${
@@ -425,8 +685,9 @@ const TodoApp: FC = () => {
425
685
  : 'btn-ghost border border-base-300'
426
686
  }`}
427
687
  onClick={() => updateStatus(item.id, option.key)}
688
+ type="button"
428
689
  >
429
- {option.label}
690
+ {option.actionLabel}
430
691
  </button>
431
692
  ))}
432
693
  </div>
@@ -434,19 +695,25 @@ const TodoApp: FC = () => {
434
695
 
435
696
  <div className="flex flex-wrap gap-2 lg:justify-end">
436
697
  {!isEditing && (
437
- <button className="btn btn-sm btn-outline" onClick={() => startEditing(item)}>
698
+ <button
699
+ className="btn btn-sm btn-outline"
700
+ onClick={() => startEditing(item)}
701
+ type="button"
702
+ >
438
703
  改名
439
704
  </button>
440
705
  )}
441
706
  <button
442
707
  className="btn btn-sm btn-outline btn-secondary"
443
708
  onClick={() => toggleArchived(item.id)}
709
+ type="button"
444
710
  >
445
711
  {item.archived ? '恢复' : '归档'}
446
712
  </button>
447
713
  <button
448
714
  className="btn btn-sm btn-outline btn-error"
449
715
  onClick={() => removeTodo(item.id)}
716
+ type="button"
450
717
  >
451
718
  删除
452
719
  </button>
@@ -456,10 +723,11 @@ const TodoApp: FC = () => {
456
723
  </article>
457
724
  )
458
725
  })}
726
+
459
727
  {!visibleTodos.get().length && (
460
728
  <div className="card border border-dashed border-base-300 bg-base-100 shadow-sm">
461
729
  <div className="card-body items-center py-14 text-center">
462
- <h3 className="text-xl font-semibold">当前筛选下没有任务</h3>
730
+ <h2 className="text-xl font-semibold">当前筛选下没有任务</h2>
463
731
  <p className="max-w-md text-sm leading-6 text-base-content/70">
464
732
  试试切换筛选、搜索关键字,或者直接新增一条任务。
465
733
  </p>