create-rue 0.0.8 → 0.0.14

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.
@@ -0,0 +1,478 @@
1
+ import { computed, type FC, ref, useState, watch } from '@rue-js/rue'
2
+ import { RouterLink } from '@rue-js/router'
3
+
4
+ const todoStorageKey = 'rue.base.todos'
5
+
6
+ type TodoStatus = 'todo' | 'doing' | 'done'
7
+ type TodoFilter = 'all' | 'todo' | 'doing' | 'done' | 'archived'
8
+
9
+ type TodoItem = {
10
+ id: number
11
+ title: string
12
+ archived: boolean
13
+ status: TodoStatus
14
+ createdAt: string
15
+ }
16
+
17
+ const filterOptions: Array<{ key: TodoFilter; label: string }> = [
18
+ { key: 'all', label: '全部' },
19
+ { key: 'todo', label: '待开始' },
20
+ { key: 'doing', label: '进行中' },
21
+ { key: 'done', label: '已完成' },
22
+ { key: 'archived', label: '已归档' },
23
+ ]
24
+
25
+ const statusOptions: Array<{ key: TodoStatus; label: string }> = [
26
+ { key: 'todo', label: '待开始' },
27
+ { key: 'doing', label: '进行中' },
28
+ { key: 'done', label: '已完成' },
29
+ ]
30
+
31
+ const initialTodos: TodoItem[] = [
32
+ {
33
+ id: 1,
34
+ title: '补充报表首页字段与展示规则',
35
+ status: 'doing',
36
+ archived: false,
37
+ createdAt: '今天 09:30',
38
+ },
39
+ {
40
+ id: 2,
41
+ title: '接入真实接口替换示例数据',
42
+ status: 'todo',
43
+ archived: false,
44
+ createdAt: '今天 10:05',
45
+ },
46
+ {
47
+ id: 3,
48
+ title: '复查主题切换和头部隐藏体验',
49
+ status: 'done',
50
+ archived: false,
51
+ createdAt: '昨天 18:20',
52
+ },
53
+ {
54
+ id: 4,
55
+ title: '归档旧版原型页面',
56
+ status: 'done',
57
+ archived: true,
58
+ createdAt: '昨天 14:05',
59
+ },
60
+ ]
61
+
62
+ const getStoredTodos = (): TodoItem[] => {
63
+ const raw = localStorage.getItem(todoStorageKey)
64
+ if (!raw) {
65
+ return initialTodos
66
+ }
67
+
68
+ try {
69
+ const parsed = JSON.parse(raw)
70
+ if (!Array.isArray(parsed)) {
71
+ return initialTodos
72
+ }
73
+
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[]
83
+ } catch {
84
+ return initialTodos
85
+ }
86
+ }
87
+
88
+ const getNextTodoId = (todos: TodoItem[]) =>
89
+ todos.reduce((maxId, item) => Math.max(maxId, item.id), 0) + 1
90
+
91
+ const statusMeta: Record<
92
+ TodoStatus,
93
+ {
94
+ label: string
95
+ badgeClass: string
96
+ dotClass: string
97
+ cardClass: string
98
+ }
99
+ > = {
100
+ todo: {
101
+ label: '待开始',
102
+ badgeClass: 'badge badge-warning badge-outline',
103
+ dotClass: 'bg-warning',
104
+ cardClass: 'border-warning/30',
105
+ },
106
+ doing: {
107
+ label: '进行中',
108
+ badgeClass: 'badge badge-info badge-outline',
109
+ dotClass: 'bg-info',
110
+ cardClass: 'border-info/30',
111
+ },
112
+ done: {
113
+ label: '已完成',
114
+ badgeClass: 'badge badge-success badge-outline',
115
+ dotClass: 'bg-success',
116
+ cardClass: 'border-success/30',
117
+ },
118
+ }
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
+
156
+ const TodoApp: FC = () => {
157
+ const todos = ref<TodoItem[]>(getStoredTodos())
158
+ const draft = ref('')
159
+ const search = ref('')
160
+ const activeFilter = ref<TodoFilter>('all')
161
+ const editingId = ref<number | null>(null)
162
+ const nextId = ref(getNextTodoId(todos.value))
163
+
164
+ watch(
165
+ () => todos.value,
166
+ () => {
167
+ localStorage.setItem(todoStorageKey, JSON.stringify(todos.value))
168
+ nextId.value = getNextTodoId(todos.value)
169
+ },
170
+ )
171
+
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
+ }))
179
+
180
+ const visibleTodos = computed(() => {
181
+ const keyword = search.value.trim().toLowerCase()
182
+
183
+ return todos.value.filter((item) => {
184
+ const matchesKeyword = !keyword || item.title.toLowerCase().includes(keyword)
185
+ if (!matchesKeyword) {
186
+ return false
187
+ }
188
+
189
+ if (activeFilter.value === 'archived') {
190
+ return item.archived
191
+ }
192
+
193
+ if (item.archived) {
194
+ return false
195
+ }
196
+
197
+ if (activeFilter.value === 'all') {
198
+ return true
199
+ }
200
+
201
+ return item.status === activeFilter.value
202
+ })
203
+ })
204
+
205
+ const addTodo = () => {
206
+ const title = draft.value.trim()
207
+ if (!title) {
208
+ return
209
+ }
210
+
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 = ''
222
+ }
223
+
224
+ const removeTodo = (id: number) => {
225
+ todos.value = todos.value.filter((item) => item.id !== id)
226
+ if (editingId.value === id) {
227
+ editingId.value = null
228
+ }
229
+ }
230
+
231
+ const updateStatus = (id: number, status: TodoStatus) => {
232
+ todos.value = todos.value.map((item) =>
233
+ item.id === id ? { ...item, status, archived: false } : item,
234
+ )
235
+ }
236
+
237
+ const toggleArchived = (id: number) => {
238
+ todos.value = todos.value.map((item) =>
239
+ item.id === id ? { ...item, archived: !item.archived } : item,
240
+ )
241
+ }
242
+
243
+ const startEditing = (item: TodoItem) => {
244
+ editingId.value = item.id
245
+ }
246
+
247
+ const cancelEditing = () => {
248
+ editingId.value = null
249
+ }
250
+
251
+ const saveEditing = (id: number, title: string) => {
252
+ if (!title) {
253
+ return
254
+ }
255
+
256
+ todos.value = todos.value.map((item) => (item.id === id ? { ...item, title } : item))
257
+ cancelEditing()
258
+ }
259
+
260
+ return (
261
+ <div className="space-y-6 pb-4">
262
+ <section className="hero border border-base-300 bg-base-100 shadow-xl">
263
+ <div className="hero-content flex-col items-start gap-8 px-6 py-8 lg:flex-row lg:items-end lg:justify-between">
264
+ <div className="max-w-3xl space-y-4">
265
+ <h1 className="text-4xl font-semibold tracking-tight sm:text-5xl">
266
+ Todo App 已作为 base 模板内置页面
267
+ </h1>
268
+ <div className="flex flex-wrap gap-3">
269
+ <RouterLink to="/" className="btn btn-outline">
270
+ 返回默认简报
271
+ </RouterLink>
272
+ </div>
273
+ </div>
274
+
275
+ <div className="stats stats-vertical bg-base-200 shadow sm:stats-horizontal">
276
+ <div className="stat px-6 py-4">
277
+ <div className="stat-title">活跃任务</div>
278
+ <div className="stat-value text-primary">{counts.get().total}</div>
279
+ </div>
280
+ <div className="stat px-6 py-4">
281
+ <div className="stat-title">已完成</div>
282
+ <div className="stat-value text-success">{counts.get().done}</div>
283
+ </div>
284
+ <div className="stat px-6 py-4">
285
+ <div className="stat-title">已归档</div>
286
+ <div className="stat-value text-secondary">{counts.get().archived}</div>
287
+ </div>
288
+ </div>
289
+ </div>
290
+ </section>
291
+
292
+ <section className="card overflow-hidden border border-base-300 bg-base-100 shadow-sm">
293
+ <div className="card-body gap-6">
294
+ <div className="grid gap-4 lg:grid-cols-[1.3fr_0.9fr]">
295
+ <label className="form-control w-full">
296
+ <div className="label">
297
+ <span className="label-text font-medium">新增任务</span>
298
+ </div>
299
+ <div className="join w-full">
300
+ <input
301
+ className="input input-bordered join-item w-full"
302
+ value={draft.value}
303
+ placeholder="例如:把日报页接入真实 API"
304
+ onInput={(event: any) => {
305
+ draft.value = (event.target as HTMLInputElement).value
306
+ }}
307
+ onKeydown={(event: KeyboardEvent) => {
308
+ if (event.key === 'Enter') {
309
+ addTodo()
310
+ }
311
+ }}
312
+ />
313
+ <button className="btn btn-primary join-item" onClick={addTodo}>
314
+ 添加
315
+ </button>
316
+ </div>
317
+ </label>
318
+
319
+ <label className="form-control w-full">
320
+ <div className="label">
321
+ <span className="label-text font-medium">搜索任务</span>
322
+ </div>
323
+ <input
324
+ className="input input-bordered w-full"
325
+ value={search.value}
326
+ placeholder="按标题筛选任务"
327
+ onInput={(event: any) => {
328
+ search.value = (event.target as HTMLInputElement).value
329
+ }}
330
+ />
331
+ </label>
332
+ </div>
333
+
334
+ <div className="flex flex-wrap gap-2">
335
+ {filterOptions.map((filter) => (
336
+ <button
337
+ key={filter.key}
338
+ className={`btn btn-sm ${
339
+ activeFilter.value === filter.key
340
+ ? 'btn-primary'
341
+ : 'btn-ghost border border-base-300'
342
+ }`}
343
+ onClick={() => {
344
+ activeFilter.value = filter.key
345
+ }}
346
+ >
347
+ {filter.label}
348
+ </button>
349
+ ))}
350
+ </div>
351
+
352
+ <div className="grid gap-4 md:grid-cols-3">
353
+ <div className="rounded-2xl border border-warning/30 bg-warning/10 p-5">
354
+ <p className="text-sm text-base-content/70">待开始</p>
355
+ <p className="mt-2 text-3xl font-semibold">{counts.get().todo}</p>
356
+ </div>
357
+ <div className="rounded-2xl border border-info/30 bg-info/10 p-5">
358
+ <p className="text-sm text-base-content/70">进行中</p>
359
+ <p className="mt-2 text-3xl font-semibold">{counts.get().doing}</p>
360
+ </div>
361
+ <div className="rounded-2xl border border-success/30 bg-success/10 p-5">
362
+ <p className="text-sm text-base-content/70">已完成</p>
363
+ <p className="mt-2 text-3xl font-semibold">{counts.get().done}</p>
364
+ </div>
365
+ </div>
366
+ </div>
367
+ </section>
368
+
369
+ <section className="grid gap-4">
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>
397
+
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>
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 && (
438
+ <button
439
+ className="btn btn-sm btn-outline"
440
+ onClick={() => startEditing(item)}
441
+ >
442
+ 改名
443
+ </button>
444
+ )}
445
+ <button
446
+ className="btn btn-sm btn-outline btn-secondary"
447
+ onClick={() => toggleArchived(item.id)}
448
+ >
449
+ {item.archived ? '恢复' : '归档'}
450
+ </button>
451
+ <button
452
+ className="btn btn-sm btn-outline btn-error"
453
+ onClick={() => removeTodo(item.id)}
454
+ >
455
+ 删除
456
+ </button>
457
+ </div>
458
+ </div>
459
+ </div>
460
+ </article>
461
+ )
462
+ })
463
+ ) : (
464
+ <div className="card border border-dashed border-base-300 bg-base-100 shadow-sm">
465
+ <div className="card-body items-center py-14 text-center">
466
+ <h2 className="text-xl font-semibold">当前筛选下没有任务</h2>
467
+ <p className="max-w-md text-sm leading-6 text-base-content/70">
468
+ 试试切换筛选、搜索关键字,或者直接新增一条任务。
469
+ </p>
470
+ </div>
471
+ </div>
472
+ )}
473
+ </section>
474
+ </div>
475
+ )
476
+ }
477
+
478
+ export default TodoApp