create-rue 0.1.4 → 0.7.2
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 +263 -321
- package/package.json +3 -3
- package/template/base/app/pages/TodoApp.tsx +210 -474
- package/template/base/app/pages/components/Layout.tsx +29 -135
- package/template/base/package.json +17 -20
- package/template/base/tsconfig.json +3 -2
- package/template/base/vite.config.ts +4 -7
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
import { computed, type FC, ref, useState,
|
|
1
|
+
import { computed, type FC, ref, useState, watch } 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
|
|
8
5
|
|
|
9
6
|
type TodoStatus = 'todo' | 'doing' | 'done'
|
|
10
7
|
type TodoFilter = 'all' | 'todo' | 'doing' | 'done' | 'archived'
|
|
@@ -15,13 +12,6 @@ type TodoItem = {
|
|
|
15
12
|
archived: boolean
|
|
16
13
|
status: TodoStatus
|
|
17
14
|
createdAt: string
|
|
18
|
-
createdOrder: number
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
type PersistedTodoState = {
|
|
22
|
-
todos: TodoItem[]
|
|
23
|
-
search: string
|
|
24
|
-
activeFilter: TodoFilter
|
|
25
15
|
}
|
|
26
16
|
|
|
27
17
|
const filterOptions: Array<{ key: TodoFilter; label: string }> = [
|
|
@@ -32,10 +22,10 @@ const filterOptions: Array<{ key: TodoFilter; label: string }> = [
|
|
|
32
22
|
{ key: 'archived', label: '已归档' },
|
|
33
23
|
]
|
|
34
24
|
|
|
35
|
-
const statusOptions: Array<{ key: TodoStatus; label: string
|
|
36
|
-
{ key: 'todo', label: '待开始'
|
|
37
|
-
{ key: 'doing', label: '进行中'
|
|
38
|
-
{ key: 'done', label: '已完成'
|
|
25
|
+
const statusOptions: Array<{ key: TodoStatus; label: string }> = [
|
|
26
|
+
{ key: 'todo', label: '待开始' },
|
|
27
|
+
{ key: 'doing', label: '进行中' },
|
|
28
|
+
{ key: 'done', label: '已完成' },
|
|
39
29
|
]
|
|
40
30
|
|
|
41
31
|
const initialTodos: TodoItem[] = [
|
|
@@ -44,281 +34,59 @@ const initialTodos: TodoItem[] = [
|
|
|
44
34
|
title: '补充报表首页字段与展示规则',
|
|
45
35
|
status: 'doing',
|
|
46
36
|
archived: false,
|
|
47
|
-
createdAt:
|
|
48
|
-
createdOrder: 4,
|
|
37
|
+
createdAt: '今天 09:30',
|
|
49
38
|
},
|
|
50
39
|
{
|
|
51
40
|
id: 2,
|
|
52
41
|
title: '接入真实接口替换示例数据',
|
|
53
42
|
status: 'todo',
|
|
54
43
|
archived: false,
|
|
55
|
-
createdAt:
|
|
56
|
-
createdOrder: 3,
|
|
44
|
+
createdAt: '今天 10:05',
|
|
57
45
|
},
|
|
58
46
|
{
|
|
59
47
|
id: 3,
|
|
60
48
|
title: '复查主题切换和头部隐藏体验',
|
|
61
49
|
status: 'done',
|
|
62
50
|
archived: false,
|
|
63
|
-
createdAt:
|
|
64
|
-
createdOrder: 2,
|
|
51
|
+
createdAt: '昨天 18:20',
|
|
65
52
|
},
|
|
66
53
|
{
|
|
67
54
|
id: 4,
|
|
68
55
|
title: '归档旧版原型页面',
|
|
69
56
|
status: 'done',
|
|
70
57
|
archived: true,
|
|
71
|
-
createdAt:
|
|
72
|
-
createdOrder: 1,
|
|
58
|
+
createdAt: '昨天 14:05',
|
|
73
59
|
},
|
|
74
60
|
]
|
|
75
61
|
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
try {
|
|
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
|
|
62
|
+
const getStoredTodos = (): TodoItem[] => {
|
|
63
|
+
const raw = localStorage.getItem(todoStorageKey)
|
|
64
|
+
if (!raw) {
|
|
65
|
+
return initialTodos
|
|
275
66
|
}
|
|
276
67
|
|
|
277
68
|
try {
|
|
278
|
-
const
|
|
279
|
-
if (!
|
|
280
|
-
return
|
|
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
|
|
69
|
+
const parsed = JSON.parse(raw)
|
|
70
|
+
if (!Array.isArray(parsed)) {
|
|
71
|
+
return initialTodos
|
|
300
72
|
}
|
|
301
73
|
|
|
302
|
-
return
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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[]
|
|
307
83
|
} catch {
|
|
308
|
-
return
|
|
84
|
+
return initialTodos
|
|
309
85
|
}
|
|
310
86
|
}
|
|
311
87
|
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
if (!storage) {
|
|
315
|
-
return
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
try {
|
|
319
|
-
storage.setItem(todoStorageKey, JSON.stringify(state))
|
|
320
|
-
} catch {}
|
|
321
|
-
}
|
|
88
|
+
const getNextTodoId = (todos: TodoItem[]) =>
|
|
89
|
+
todos.reduce((maxId, item) => Math.max(maxId, item.id), 0) + 1
|
|
322
90
|
|
|
323
91
|
const statusMeta: Record<
|
|
324
92
|
TodoStatus,
|
|
@@ -349,72 +117,89 @@ const statusMeta: Record<
|
|
|
349
117
|
},
|
|
350
118
|
}
|
|
351
119
|
|
|
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
|
+
|
|
352
156
|
const TodoApp: FC = () => {
|
|
353
|
-
const
|
|
354
|
-
const
|
|
355
|
-
const
|
|
356
|
-
const
|
|
357
|
-
const
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
watchEffect(() => {
|
|
369
|
-
persistTodoState({
|
|
370
|
-
todos,
|
|
371
|
-
search: search.value,
|
|
372
|
-
activeFilter: activeFilter.value,
|
|
373
|
-
})
|
|
374
|
-
})
|
|
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
|
+
)
|
|
375
171
|
|
|
376
|
-
const counts = computed(() => {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
}
|
|
384
|
-
})
|
|
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
|
+
}))
|
|
385
179
|
|
|
386
180
|
const visibleTodos = computed(() => {
|
|
387
181
|
const keyword = search.value.trim().toLowerCase()
|
|
388
182
|
|
|
389
|
-
return todos
|
|
390
|
-
.
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
-
})
|
|
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
|
+
})
|
|
418
203
|
})
|
|
419
204
|
|
|
420
205
|
const addTodo = () => {
|
|
@@ -423,56 +208,52 @@ const TodoApp: FC = () => {
|
|
|
423
208
|
return
|
|
424
209
|
}
|
|
425
210
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
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 = ''
|
|
437
222
|
}
|
|
438
223
|
|
|
439
224
|
const removeTodo = (id: number) => {
|
|
440
|
-
|
|
225
|
+
todos.value = todos.value.filter((item) => item.id !== id)
|
|
441
226
|
if (editingId.value === id) {
|
|
442
|
-
|
|
443
|
-
setEditingTitle('')
|
|
227
|
+
editingId.value = null
|
|
444
228
|
}
|
|
445
229
|
}
|
|
446
230
|
|
|
447
231
|
const updateStatus = (id: number, status: TodoStatus) => {
|
|
448
|
-
|
|
449
|
-
|
|
232
|
+
todos.value = todos.value.map((item) =>
|
|
233
|
+
item.id === id ? { ...item, status, archived: false } : item,
|
|
450
234
|
)
|
|
451
235
|
}
|
|
452
236
|
|
|
453
237
|
const toggleArchived = (id: number) => {
|
|
454
|
-
|
|
455
|
-
|
|
238
|
+
todos.value = todos.value.map((item) =>
|
|
239
|
+
item.id === id ? { ...item, archived: !item.archived } : item,
|
|
456
240
|
)
|
|
457
241
|
}
|
|
458
242
|
|
|
459
243
|
const startEditing = (item: TodoItem) => {
|
|
460
|
-
|
|
461
|
-
setEditingTitle(item.title)
|
|
244
|
+
editingId.value = item.id
|
|
462
245
|
}
|
|
463
246
|
|
|
464
247
|
const cancelEditing = () => {
|
|
465
|
-
|
|
466
|
-
setEditingTitle('')
|
|
248
|
+
editingId.value = null
|
|
467
249
|
}
|
|
468
250
|
|
|
469
|
-
const saveEditing = (id: number,
|
|
470
|
-
const title = (titleOverride ?? editingTitle.value).trim()
|
|
251
|
+
const saveEditing = (id: number, title: string) => {
|
|
471
252
|
if (!title) {
|
|
472
253
|
return
|
|
473
254
|
}
|
|
474
255
|
|
|
475
|
-
|
|
256
|
+
todos.value = todos.value.map((item) => (item.id === id ? { ...item, title } : item))
|
|
476
257
|
cancelEditing()
|
|
477
258
|
}
|
|
478
259
|
|
|
@@ -521,11 +302,10 @@ const TodoApp: FC = () => {
|
|
|
521
302
|
value={draft.value}
|
|
522
303
|
placeholder="例如:把日报页接入真实 API"
|
|
523
304
|
onInput={(event: any) => {
|
|
524
|
-
|
|
305
|
+
draft.value = (event.target as HTMLInputElement).value
|
|
525
306
|
}}
|
|
526
307
|
onKeydown={(event: KeyboardEvent) => {
|
|
527
|
-
if (event.key === 'Enter'
|
|
528
|
-
event.preventDefault()
|
|
308
|
+
if (event.key === 'Enter') {
|
|
529
309
|
addTodo()
|
|
530
310
|
}
|
|
531
311
|
}}
|
|
@@ -545,7 +325,7 @@ const TodoApp: FC = () => {
|
|
|
545
325
|
value={search.value}
|
|
546
326
|
placeholder="按标题筛选任务"
|
|
547
327
|
onInput={(event: any) => {
|
|
548
|
-
|
|
328
|
+
search.value = (event.target as HTMLInputElement).value
|
|
549
329
|
}}
|
|
550
330
|
/>
|
|
551
331
|
</label>
|
|
@@ -561,7 +341,7 @@ const TodoApp: FC = () => {
|
|
|
561
341
|
: 'btn-ghost border border-base-300'
|
|
562
342
|
}`}
|
|
563
343
|
onClick={() => {
|
|
564
|
-
|
|
344
|
+
activeFilter.value = filter.key
|
|
565
345
|
}}
|
|
566
346
|
>
|
|
567
347
|
{filter.label}
|
|
@@ -587,144 +367,100 @@ const TodoApp: FC = () => {
|
|
|
587
367
|
</section>
|
|
588
368
|
|
|
589
369
|
<section className="grid gap-4">
|
|
590
|
-
{visibleTodos.get().
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
370
|
+
{visibleTodos.get().length ? (
|
|
371
|
+
visibleTodos.get().map((item) => {
|
|
372
|
+
const meta = statusMeta[item.status]
|
|
373
|
+
const isEditing = editingId.value === item.id
|
|
374
|
+
|
|
375
|
+
return (
|
|
376
|
+
<article
|
|
377
|
+
key={item.id}
|
|
378
|
+
className={`card border bg-base-100 shadow-sm transition-all ${meta.cardClass} ${
|
|
379
|
+
item.archived ? 'opacity-75' : 'hover:-translate-y-0.5 hover:shadow-md'
|
|
380
|
+
}`}
|
|
381
|
+
>
|
|
382
|
+
<div className="card-body gap-4">
|
|
383
|
+
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
384
|
+
<div className="flex-1 space-y-3">
|
|
385
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
386
|
+
<span
|
|
387
|
+
className={`inline-block h-2.5 w-2.5 rounded-full ${meta.dotClass}`}
|
|
388
|
+
></span>
|
|
389
|
+
<span className={meta.badgeClass}>{meta.label}</span>
|
|
390
|
+
{item.archived && (
|
|
391
|
+
<span className="badge badge-secondary badge-outline">已归档</span>
|
|
392
|
+
)}
|
|
393
|
+
<span className="text-xs text-base-content/50">
|
|
394
|
+
创建于 {item.createdAt}
|
|
395
|
+
</span>
|
|
396
|
+
</div>
|
|
599
397
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
<div className="flex flex-wrap items-center gap-2">
|
|
611
|
-
<span
|
|
612
|
-
className={`inline-block h-2.5 w-2.5 rounded-full ${meta.dotClass}`}
|
|
613
|
-
></span>
|
|
614
|
-
<span className={meta.badgeClass}>{meta.label}</span>
|
|
615
|
-
{item.archived && (
|
|
616
|
-
<span className="badge badge-secondary badge-outline">已归档</span>
|
|
398
|
+
{!isEditing && (
|
|
399
|
+
<h2
|
|
400
|
+
className={`text-xl font-semibold ${
|
|
401
|
+
item.status === 'done'
|
|
402
|
+
? 'text-base-content/50 line-through'
|
|
403
|
+
: 'text-base-content'
|
|
404
|
+
}`}
|
|
405
|
+
>
|
|
406
|
+
{item.title}
|
|
407
|
+
</h2>
|
|
617
408
|
)}
|
|
618
|
-
<span className="text-xs text-base-content/50">
|
|
619
|
-
创建于 {formatTodoCreatedAt(item.createdAt)}
|
|
620
|
-
</span>
|
|
621
|
-
</div>
|
|
622
409
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
if (event.key === 'Enter') {
|
|
647
|
-
commitEditing((event.target as HTMLInputElement).value)
|
|
648
|
-
}
|
|
649
|
-
if (event.key === 'Escape') {
|
|
650
|
-
cancelEditing()
|
|
651
|
-
}
|
|
652
|
-
}}
|
|
653
|
-
/>
|
|
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>
|
|
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
|
+
))}
|
|
675
433
|
</div>
|
|
676
434
|
</div>
|
|
677
435
|
|
|
678
|
-
<div className="flex flex-wrap gap-2">
|
|
679
|
-
{
|
|
436
|
+
<div className="flex flex-wrap gap-2 lg:justify-end">
|
|
437
|
+
{!isEditing && (
|
|
680
438
|
<button
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
item.status === option.key
|
|
684
|
-
? 'btn-neutral'
|
|
685
|
-
: 'btn-ghost border border-base-300'
|
|
686
|
-
}`}
|
|
687
|
-
onClick={() => updateStatus(item.id, option.key)}
|
|
688
|
-
type="button"
|
|
439
|
+
className="btn btn-sm btn-outline"
|
|
440
|
+
onClick={() => startEditing(item)}
|
|
689
441
|
>
|
|
690
|
-
|
|
442
|
+
改名
|
|
691
443
|
</button>
|
|
692
|
-
)
|
|
693
|
-
</div>
|
|
694
|
-
</div>
|
|
695
|
-
|
|
696
|
-
<div className="flex flex-wrap gap-2 lg:justify-end">
|
|
697
|
-
{!isEditing && (
|
|
444
|
+
)}
|
|
698
445
|
<button
|
|
699
|
-
className="btn btn-sm btn-outline"
|
|
700
|
-
onClick={() =>
|
|
701
|
-
type="button"
|
|
446
|
+
className="btn btn-sm btn-outline btn-secondary"
|
|
447
|
+
onClick={() => toggleArchived(item.id)}
|
|
702
448
|
>
|
|
703
|
-
|
|
449
|
+
{item.archived ? '恢复' : '归档'}
|
|
704
450
|
</button>
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
</button>
|
|
713
|
-
<button
|
|
714
|
-
className="btn btn-sm btn-outline btn-error"
|
|
715
|
-
onClick={() => removeTodo(item.id)}
|
|
716
|
-
type="button"
|
|
717
|
-
>
|
|
718
|
-
删除
|
|
719
|
-
</button>
|
|
451
|
+
<button
|
|
452
|
+
className="btn btn-sm btn-outline btn-error"
|
|
453
|
+
onClick={() => removeTodo(item.id)}
|
|
454
|
+
>
|
|
455
|
+
删除
|
|
456
|
+
</button>
|
|
457
|
+
</div>
|
|
720
458
|
</div>
|
|
721
459
|
</div>
|
|
722
|
-
</
|
|
723
|
-
|
|
724
|
-
)
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
{!visibleTodos.get().length && (
|
|
460
|
+
</article>
|
|
461
|
+
)
|
|
462
|
+
})
|
|
463
|
+
) : (
|
|
728
464
|
<div className="card border border-dashed border-base-300 bg-base-100 shadow-sm">
|
|
729
465
|
<div className="card-body items-center py-14 text-center">
|
|
730
466
|
<h2 className="text-xl font-semibold">当前筛选下没有任务</h2>
|