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.
- package/bundle.js +322 -264
- package/package.json +3 -3
- package/template/base/app/pages/Home.tsx +1 -1
- package/template/base/app/pages/TodoApp.tsx +431 -163
- package/template/base/app/pages/components/Layout.tsx +135 -29
- package/template/base/package.json +18 -19
- package/template/base/tsconfig.json +3 -4
- package/template/base/vite.config.ts +7 -14
- package/template/base/app/env.d.ts +0 -1
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { computed, type FC, ref, useState,
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
71
|
+
createdAt: new Date(Date.now() - 30 * hourInMs).toISOString(),
|
|
72
|
+
createdOrder: 1,
|
|
59
73
|
},
|
|
60
74
|
]
|
|
61
75
|
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
308
|
+
return null
|
|
85
309
|
}
|
|
86
310
|
}
|
|
87
311
|
|
|
88
|
-
const
|
|
89
|
-
|
|
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
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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.
|
|
182
|
-
|
|
183
|
-
return todos
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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.
|
|
421
|
+
const title = draft.trim()
|
|
207
422
|
if (!title) {
|
|
208
423
|
return
|
|
209
424
|
}
|
|
210
425
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
]
|
|
221
|
-
|
|
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
|
-
|
|
226
|
-
if (editingId
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
460
|
+
setEditingId(item.id)
|
|
461
|
+
setEditingTitle(item.title)
|
|
245
462
|
}
|
|
246
463
|
|
|
247
464
|
const cancelEditing = () => {
|
|
248
|
-
|
|
465
|
+
setEditingId(null)
|
|
466
|
+
setEditingTitle('')
|
|
249
467
|
}
|
|
250
468
|
|
|
251
|
-
const saveEditing = (id: number,
|
|
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
|
-
|
|
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
|
|
521
|
+
value={draft}
|
|
303
522
|
placeholder="例如:把日报页接入真实 API"
|
|
304
523
|
onInput={(event: any) => {
|
|
305
|
-
|
|
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
|
|
545
|
+
value={search}
|
|
326
546
|
placeholder="按标题筛选任务"
|
|
327
547
|
onInput={(event: any) => {
|
|
328
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
618
|
+
<span className="text-xs text-base-content/50">
|
|
619
|
+
创建于 {formatTodoCreatedAt(item.createdAt)}
|
|
620
|
+
</span>
|
|
395
621
|
</div>
|
|
396
622
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
)}
|
|
630
|
+
}`}
|
|
631
|
+
>
|
|
632
|
+
{item.title}
|
|
633
|
+
</h2>
|
|
408
634
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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.
|
|
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
|
|
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
|
-
<
|
|
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>
|