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.
- package/bundle.js +4 -19
- package/package.json +3 -3
- package/template/base/app/pages/Home.tsx +14 -7
- package/template/base/app/pages/Report.tsx +10 -6
- package/template/base/app/pages/TodoApp.tsx +568 -189
- package/template/base/app/pages/components/Layout.tsx +79 -24
- 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/style.css +10 -2
- 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
|
+
}
|
|
72
302
|
}
|
|
73
303
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
+
}
|
|
171
381
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
181
|
-
|
|
391
|
+
const counts = computed(() => {
|
|
392
|
+
void todosVersion.value
|
|
182
393
|
|
|
183
|
-
return
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
403
|
+
const visibleTodos = computed(() => {
|
|
404
|
+
void todosVersion.value
|
|
405
|
+
const keyword = search.value.trim().toLowerCase()
|
|
192
406
|
|
|
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,61 +451,155 @@ 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">
|
|
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">
|
|
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
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
399
|
-
</
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
-
{
|
|
439
|
-
</button>
|
|
440
|
-
<button className="btn btn-sm btn-outline btn-error" onClick={() => removeTodo(item.id)}>
|
|
441
|
-
删除
|
|
789
|
+
{option.actionLabel}
|
|
442
790
|
</button>
|
|
443
|
-
|
|
791
|
+
))}
|
|
444
792
|
</div>
|
|
445
793
|
</div>
|
|
446
|
-
|
|
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
|