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.
- package/bundle.js +4 -19
- package/package.json +2 -2
- package/template/base/app/pages/TodoApp.tsx +563 -198
- package/template/base/app/pages/components/Layout.tsx +131 -27
- package/template/base/package-lock.json +617 -432
- package/template/base/package.json +19 -17
- package/template/base/pnpm-lock.yaml +550 -480
- package/template/base/tsconfig.json +1 -2
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { computed, type FC, ref,
|
|
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:
|
|
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 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
|
|
70
|
-
if (!
|
|
71
|
-
return
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
320
|
+
return null
|
|
85
321
|
}
|
|
86
322
|
}
|
|
87
323
|
|
|
88
|
-
const
|
|
89
|
-
|
|
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
|
|
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
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
438
|
+
const visibleTodoCards = computed(() => {
|
|
439
|
+
const currentEditingId = editingId.value
|
|
200
440
|
|
|
201
|
-
|
|
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
|
-
|
|
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
|
|
469
|
+
syncTodos(todos.value.filter((item) => item.id !== id))
|
|
226
470
|
if (editingId.value === id) {
|
|
227
|
-
|
|
471
|
+
cancelEditing()
|
|
228
472
|
}
|
|
229
473
|
}
|
|
230
474
|
|
|
231
475
|
const updateStatus = (id: number, status: TodoStatus) => {
|
|
232
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
|
|
407
|
-
</
|
|
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-
|
|
440
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
</button>
|
|
771
|
+
</div>
|
|
772
|
+
</div>
|
|
773
|
+
)}
|
|
774
|
+
|
|
775
|
+
<div className="flex flex-wrap gap-2">
|
|
776
|
+
{statusOptions.map((option) => (
|
|
451
777
|
<button
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
791
|
+
))}
|
|
458
792
|
</div>
|
|
459
793
|
</div>
|
|
460
|
-
|
|
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>
|