diff --git a/DESIGN.md b/DESIGN.md index 23ed2f5..1294820 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -71,6 +71,10 @@ Pokemon 可配置: - 稀有度:1 ~ 3 星 - 地图关联 +每日 CheckList 可配置: +- Task +- Task 顺序 + ## 功能 - Pokemon 列表 @@ -116,6 +120,12 @@ Pokemon 可配置: - 基本信息 - 入手方式 - 需要材料列表 +- 每日 CheckList + - 展示每日做什么 + - 每个 Task 可勾选 + - 每天自动清空勾选状态,不删除 Task + - 管理中可新增 Task 到列表 + - 管理中可通过 Handle 拖曳排序 ## 用户系统 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 4033c9f..887996f 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -38,6 +38,19 @@ CREATE TABLE IF NOT EXISTS user_sessions ( CREATE INDEX IF NOT EXISTS user_sessions_user_id_idx ON user_sessions(user_id); +CREATE TABLE IF NOT EXISTS daily_checklist_items ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + title text NOT NULL, + sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), + created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS daily_checklist_items_sort_order_idx + ON daily_checklist_items(sort_order, id); + CREATE TABLE IF NOT EXISTS skills ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, diff --git a/backend/src/queries.ts b/backend/src/queries.ts index cb94ae2..6946ebb 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -60,6 +60,10 @@ type RecipePayload = { materials: IdQuantity[]; }; +type DailyChecklistPayload = { + title: string; +}; + type HabitatPayload = { name: string; recipeItems: IdQuantity[]; @@ -522,6 +526,127 @@ export async function getOptions() { }; } +function cleanDailyChecklistPayload(payload: Record): DailyChecklistPayload { + return { + title: cleanName(payload.title, '请输入 Task') + }; +} + +export async function listDailyChecklistItems() { + return query( + ` + SELECT c.id, c.title + FROM daily_checklist_items c + ORDER BY c.sort_order, c.id + ` + ); +} + +async function getDailyChecklistItemById(id: number) { + return queryOne( + ` + SELECT c.id, c.title + FROM daily_checklist_items c + WHERE c.id = $1 + `, + [id] + ); +} + +export async function createDailyChecklistItem(payload: Record, userId: number) { + const cleanPayload = cleanDailyChecklistPayload(payload); + + const id = await withTransaction(async (client) => { + const orderResult = await client.query<{ sortOrder: number }>( + 'SELECT COALESCE(MAX(sort_order), 0) + 10 AS "sortOrder" FROM daily_checklist_items' + ); + const sortOrder = orderResult.rows[0]?.sortOrder ?? 10; + + const result = await client.query<{ id: number }>( + ` + INSERT INTO daily_checklist_items (title, sort_order, created_by_user_id, updated_by_user_id) + VALUES ($1, $2, $3, $3) + RETURNING id + `, + [cleanPayload.title, sortOrder, userId] + ); + + const createdId = result.rows[0].id; + await recordEditLog(client, 'daily-checklist-items', createdId, 'create', userId); + return createdId; + }); + + return getDailyChecklistItemById(id); +} + +export async function updateDailyChecklistItem(id: number, payload: Record, userId: number) { + const cleanPayload = cleanDailyChecklistPayload(payload); + + const updated = await withTransaction(async (client) => { + const result = await client.query( + ` + UPDATE daily_checklist_items + SET title = $1, updated_by_user_id = $2, updated_at = now() + WHERE id = $3 + `, + [cleanPayload.title, userId, id] + ); + + if (result.rowCount === 0) { + return false; + } + + await recordEditLog(client, 'daily-checklist-items', id, 'update', userId); + return true; + }); + + return updated ? getDailyChecklistItemById(id) : null; +} + +export async function reorderDailyChecklistItems(payload: Record, userId: number) { + const ids = cleanIds(payload.ids); + if (ids.length === 0) { + throw validationError('请选择 Task'); + } + + await withTransaction(async (client) => { + const existing = await client.query<{ id: number }>( + 'SELECT id FROM daily_checklist_items WHERE id = ANY($1::integer[])', + [ids] + ); + + if (existing.rowCount !== ids.length) { + throw validationError('Task 不存在'); + } + + for (const [index, id] of ids.entries()) { + await client.query( + ` + UPDATE daily_checklist_items + SET sort_order = $1, updated_by_user_id = $2, updated_at = now() + WHERE id = $3 + `, + [(index + 1) * 10, userId, id] + ); + await recordEditLog(client, 'daily-checklist-items', id, 'update', userId); + } + }); + + return listDailyChecklistItems(); +} + +export async function deleteDailyChecklistItem(id: number, userId: number) { + return withTransaction(async (client) => { + const result = await client.query<{ id: number }>('DELETE FROM daily_checklist_items WHERE id = $1 RETURNING id', [id]); + if (result.rowCount === 0) { + return false; + } + + await recordEditLog(client, 'daily-checklist-items', id, 'delete', userId); + return true; + }); +} + export function isConfigType(type: string): type is ConfigType { return Object.hasOwn(configDefinitions, type); } diff --git a/backend/src/server.ts b/backend/src/server.ts index a0ec1a8..91b59ea 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -5,11 +5,13 @@ import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEm import { initializeDatabase, pool } from './db.ts'; import { createConfig, + createDailyChecklistItem, createHabitat, createItem, createPokemon, createRecipe, deleteConfig, + deleteDailyChecklistItem, deleteHabitat, deleteItem, deletePokemon, @@ -21,11 +23,14 @@ import { getRecipe, isConfigType, listConfig, + listDailyChecklistItems, listHabitats, listItems, listPokemon, listRecipes, + reorderDailyChecklistItems, updateConfig, + updateDailyChecklistItem, updateHabitat, updateItem, updatePokemon, @@ -119,6 +124,8 @@ app.post('/api/auth/logout', async (request, reply) => { app.get('/api/options', async () => getOptions()); +app.get('/api/daily-checklist', async () => listDailyChecklistItems()); + app.get('/api/pokemon', async (request) => listPokemon(request.query as Record)); app.get('/api/pokemon/:id', async (request, reply) => { @@ -291,6 +298,36 @@ app.delete('/api/recipes/:id', async (request, reply) => { return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); +app.post('/api/admin/daily-checklist', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + return user ? reply.code(201).send(await createDailyChecklistItem(request.body as Record, user.id)) : undefined; +}); + +app.put('/api/admin/daily-checklist/order', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + return user ? reorderDailyChecklistItems(request.body as Record, user.id) : undefined; +}); + +app.put('/api/admin/daily-checklist/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + const item = await updateDailyChecklistItem(Number(id), request.body as Record, user.id); + return item ? item : reply.code(404).send({ message: 'Not found' }); +}); + +app.delete('/api/admin/daily-checklist/:id', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + const { id } = request.params as { id: string }; + const deleted = await deleteDailyChecklistItem(Number(id), user.id); + return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); +}); + app.get('/api/admin/config/:type', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0f2806b..21c1154 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -9,6 +9,7 @@ const navItems = [ { label: '栖息地', to: '/habitats' }, { label: '物品', to: '/items' }, { label: '材料单', to: '/recipes' }, + { label: 'CheckList', to: '/checklist' }, { label: '管理', to: '/admin' } ]; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 293ef68..cb13c40 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -11,6 +11,7 @@ import ItemEdit from '../views/ItemEdit.vue'; import RecipeList from '../views/RecipeList.vue'; import RecipeDetail from '../views/RecipeDetail.vue'; import RecipeEdit from '../views/RecipeEdit.vue'; +import DailyChecklistView from '../views/DailyChecklistView.vue'; import AdminView from '../views/AdminView.vue'; import LoginView from '../views/LoginView.vue'; import RegisterView from '../views/RegisterView.vue'; @@ -37,6 +38,7 @@ export const router = createRouter({ { path: '/recipes/new', component: RecipeEdit, meta: { requiresVerified: true } }, { path: '/recipes/:id/edit', component: RecipeEdit, meta: { requiresVerified: true } }, { path: '/recipes/:id', component: RecipeDetail }, + { path: '/checklist', component: DailyChecklistView }, { path: '/admin', component: AdminView, meta: { requiresVerified: true } }, { path: '/login', component: LoginView }, { path: '/register', component: RegisterView }, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2d756f6..feef30c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -126,6 +126,11 @@ export interface Recipe extends EditInfo { materials: Array; } +export interface DailyChecklistItem { + id: number; + title: string; +} + export interface RecipeDetail extends Recipe { acquisition_methods: NamedEntity[]; editHistory: EditHistoryEntry[]; @@ -212,6 +217,10 @@ export interface HabitatPayload { }>; } +export interface DailyChecklistPayload { + title: string; +} + export function buildQuery(params: Record): string { const search = new URLSearchParams(); @@ -329,6 +338,14 @@ export const api = { me: () => getJson<{ user: AuthUser }>('/api/auth/me'), logout: () => postEmpty('/api/auth/logout'), options: () => getJson('/api/options'), + dailyChecklist: () => getJson('/api/daily-checklist'), + createDailyChecklistItem: (payload: DailyChecklistPayload) => + sendJson('/api/admin/daily-checklist', 'POST', payload), + updateDailyChecklistItem: (id: string | number, payload: DailyChecklistPayload) => + sendJson(`/api/admin/daily-checklist/${id}`, 'PUT', payload), + reorderDailyChecklistItems: (ids: number[]) => + sendJson('/api/admin/daily-checklist/order', 'PUT', { ids }), + deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`), config: (type: ConfigType) => getJson>(`/api/admin/config/${type}`), createConfig: (type: ConfigType, payload: { name: string; hasItemDrop?: boolean }) => sendJson(`/api/admin/config/${type}`, 'POST', payload), diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 2f2e99d..5ddf9c5 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -874,6 +874,175 @@ button:disabled, font-weight: 750; } +.checklist-list { + display: grid; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.checklist-item { + padding: 14px; + border: 1px solid var(--line); + border-radius: var(--radius-card); + background: var(--surface-soft); +} + +.checklist-check { + min-height: 34px; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: center; + color: var(--ink); + font-weight: 850; + cursor: pointer; +} + +.checklist-check input { + width: 20px; + height: 20px; + accent-color: var(--pokemon-blue); +} + +.checklist-check span { + overflow-wrap: anywhere; +} + +.checklist-item.is-checked .checklist-check span { + color: var(--muted); + text-decoration: line-through; +} + +.checklist-skeleton-list li { + justify-content: flex-start; +} + +.admin-checklist-row { + position: relative; + flex-wrap: wrap; + align-items: flex-start; + border-radius: var(--radius-card); + transition: + background 0.16s ease, + box-shadow 0.16s ease, + opacity 0.16s ease, + transform 0.16s ease; +} + +.admin-checklist-row.is-dragging { + z-index: 2; + background: color-mix(in srgb, var(--pokemon-yellow) 12%, var(--surface)); + box-shadow: var(--shadow-soft); + opacity: 0.68; + transform: scale(0.99); +} + +.admin-checklist-row.is-drop-target::before { + content: ""; + position: absolute; + right: 0; + left: 0; + height: 3px; + border-radius: 999px; + background: var(--pokemon-blue); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--pokemon-blue) 18%, transparent); +} + +.admin-checklist-row.is-drop-before::before { + top: -2px; +} + +.admin-checklist-row.is-drop-after::before { + bottom: -2px; +} + +.admin-checklist-move, +.admin-checklist-enter-active, +.admin-checklist-leave-active { + transition: + opacity 0.18s ease, + transform 0.18s ease; +} + +.admin-checklist-enter-from, +.admin-checklist-leave-to { + opacity: 0; + transform: translateY(6px); +} + +.admin-checklist-leave-active { + position: absolute; + right: 0; + left: 0; +} + +.drag-handle { + width: 44px; + min-height: 44px; + flex: 0 0 auto; + display: inline-grid; + place-items: center; + padding: 0; + border: 1px solid var(--line); + border-radius: var(--radius-control); + background: var(--surface-soft); + color: var(--muted); + cursor: grab; + touch-action: manipulation; + transition: + background 0.14s ease, + border-color 0.14s ease, + color 0.14s ease, + transform 0.14s ease; +} + +.drag-handle:hover, +.drag-handle:focus-visible { + border-color: var(--pokemon-blue); + background: color-mix(in srgb, var(--pokemon-blue) 9%, var(--surface)); + color: var(--pokemon-blue-deep); +} + +.drag-handle:active { + cursor: grabbing; + transform: scale(0.96); +} + +.drag-handle:disabled { + cursor: not-allowed; + opacity: 0.54; +} + +.admin-checklist-title { + flex: 1 1 180px; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + color: var(--ink-soft); + font-weight: 850; + overflow-wrap: anywhere; +} + +@media (prefers-reduced-motion: reduce) { + .admin-checklist-row, + .admin-checklist-move, + .admin-checklist-enter-active, + .admin-checklist-leave-active, + .drag-handle { + transition: none; + } + + .admin-checklist-row.is-dragging, + .admin-checklist-enter-from, + .admin-checklist-leave-to, + .drag-handle:active { + transform: none; + } +} + .config-flag { display: inline-flex; align-items: center; diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 4e2ee76..5de1142 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -8,6 +8,7 @@ import { api, type AuthUser, type ConfigType, + type DailyChecklistItem, type Habitat, type Item, type NamedEntity, @@ -16,11 +17,12 @@ import { type Skill } from '../services/api'; -type AdminTab = 'config' | 'pokemon' | 'items' | 'recipes' | 'habitats'; +type AdminTab = 'config' | 'checklist' | 'pokemon' | 'items' | 'recipes' | 'habitats'; type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean }; const tabs: Array<{ key: AdminTab; label: string }> = [ { key: 'config', label: '系统配置' }, + { key: 'checklist', label: 'CheckList' }, { key: 'pokemon', label: 'Pokemon' }, { key: 'items', label: '物品' }, { key: 'recipes', label: '材料单' }, @@ -40,6 +42,7 @@ const configTypes: Array<{ key: ConfigType; label: string; supportsItemDrop?: bo const activeTab = ref('config'); const activeConfigType = ref('skills'); const configRows = ref([]); +const checklistRows = ref([]); const pokemonRows = ref([]); const itemRows = ref([]); const recipeRows = ref([]); @@ -49,6 +52,12 @@ const busy = ref(false); const contentLoading = ref(false); const message = ref(''); const configForm = ref({ id: 0, name: '', hasItemDrop: false }); +const checklistForm = ref({ id: 0, title: '' }); +const draggingChecklistId = ref(null); +const dragOverChecklistId = ref(null); +const dragInsertAfterTarget = ref(false); +const dragSourceChecklistRows = ref([]); +const dragDropCommitted = ref(false); const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]); const configTabs = computed(() => configTypes.map((item) => ({ value: item.key, label: item.label }))); @@ -90,10 +99,59 @@ function resetConfigForm() { configForm.value = { id: 0, name: '', hasItemDrop: false }; } +function resetChecklistForm() { + checklistForm.value = { id: 0, title: '' }; +} + function editConfig(item: EditableConfig) { configForm.value = { id: item.id, name: item.name, hasItemDrop: item.hasItemDrop === true }; } +function editChecklistItem(item: DailyChecklistItem) { + checklistForm.value = { id: item.id, title: item.title }; +} + +function hasChecklistOrderChanged(rows: DailyChecklistItem[], nextRows: DailyChecklistItem[]) { + return rows.length !== nextRows.length || rows.some((item, index) => item.id !== nextRows[index]?.id); +} + +function reorderedChecklistRows( + rows: DailyChecklistItem[], + draggedId: number, + targetId: number, + insertAfterTarget: boolean +) { + if (draggedId === targetId) { + return rows; + } + + const draggedItem = rows.find((item) => item.id === draggedId); + if (!draggedItem) { + return rows; + } + + const nextRows = rows.filter((item) => item.id !== draggedId); + const targetIndex = nextRows.findIndex((item) => item.id === targetId); + if (targetIndex < 0) { + return rows; + } + + nextRows.splice(targetIndex + (insertAfterTarget ? 1 : 0), 0, draggedItem); + return nextRows; +} + +async function persistChecklistOrder(nextRows: DailyChecklistItem[], fallbackRows: DailyChecklistItem[]) { + checklistRows.value = nextRows; + await run(async () => { + try { + checklistRows.value = await api.reorderDailyChecklistItems(nextRows.map((item) => item.id)); + } catch (error) { + checklistRows.value = fallbackRows; + throw error; + } + }); +} + async function saveConfig() { await run(async () => { const payload = { @@ -112,6 +170,30 @@ async function saveConfig() { }); } +async function loadChecklist() { + checklistRows.value = await api.dailyChecklist(); + if (!checklistForm.value.id && checklistForm.value.title.trim() === '') { + resetChecklistForm(); + } +} + +async function saveChecklistItem() { + await run(async () => { + const payload = { + title: checklistForm.value.title + }; + + if (checklistForm.value.id) { + await api.updateDailyChecklistItem(checklistForm.value.id, payload); + } else { + await api.createDailyChecklistItem(payload); + } + + await loadChecklist(); + resetChecklistForm(); + }); +} + async function loadPokemon() { pokemonRows.value = await api.pokemon({}); } @@ -135,6 +217,7 @@ async function loadCurrentTab(showSkeleton = false) { try { if (activeTab.value === 'config') await loadConfig(); + if (activeTab.value === 'checklist') await loadChecklist(); if (activeTab.value === 'pokemon') await loadPokemon(); if (activeTab.value === 'items') await loadItems(); if (activeTab.value === 'recipes') await loadRecipes(); @@ -178,6 +261,129 @@ async function removeConfig(id: number) { }); } +async function removeChecklistItem(id: number) { + await run(async () => { + await api.deleteDailyChecklistItem(id); + if (checklistForm.value.id === id) { + resetChecklistForm(); + } + await loadChecklist(); + }); +} + +function startChecklistDrag(item: DailyChecklistItem, event: Event) { + draggingChecklistId.value = item.id; + dragSourceChecklistRows.value = [...checklistRows.value]; + dragDropCommitted.value = false; + const dragEvent = event instanceof DragEvent ? event : null; + dragEvent?.dataTransfer?.setData('text/plain', String(item.id)); + if (dragEvent?.dataTransfer) { + dragEvent.dataTransfer.effectAllowed = 'move'; + dragEvent.dataTransfer.dropEffect = 'move'; + } +} + +function clearChecklistDragState() { + draggingChecklistId.value = null; + dragOverChecklistId.value = null; + dragInsertAfterTarget.value = false; + dragSourceChecklistRows.value = []; + dragDropCommitted.value = false; +} + +function endChecklistDrag() { + if (draggingChecklistId.value !== null && !dragDropCommitted.value && dragSourceChecklistRows.value.length) { + checklistRows.value = dragSourceChecklistRows.value; + } + + clearChecklistDragState(); +} + +function previewChecklistDrop(targetItem: DailyChecklistItem, event: Event) { + const dragEvent = event instanceof DragEvent ? event : null; + const draggedId = draggingChecklistId.value ?? Number(dragEvent?.dataTransfer?.getData('text/plain')); + if (!draggedId || busy.value) { + return; + } + + if (draggedId === targetItem.id) { + dragOverChecklistId.value = null; + dragInsertAfterTarget.value = false; + return; + } + + if (dragEvent?.dataTransfer) { + dragEvent.dataTransfer.dropEffect = 'move'; + } + + const targetElement = event.currentTarget instanceof HTMLElement ? event.currentTarget : null; + const insertAfterTarget = targetElement + ? (dragEvent?.clientY ?? 0) > targetElement.getBoundingClientRect().top + targetElement.getBoundingClientRect().height / 2 + : false; + + dragOverChecklistId.value = targetItem.id; + dragInsertAfterTarget.value = insertAfterTarget; + const nextRows = reorderedChecklistRows(checklistRows.value, draggedId, targetItem.id, insertAfterTarget); + if (hasChecklistOrderChanged(checklistRows.value, nextRows)) { + checklistRows.value = nextRows; + } +} + +async function dropChecklistItem(targetItem: DailyChecklistItem, event: Event) { + if (!draggingChecklistId.value || busy.value) { + endChecklistDrag(); + return; + } + + previewChecklistDrop(targetItem, event); + + const nextRows = [...checklistRows.value]; + const fallbackRows = dragSourceChecklistRows.value.length ? [...dragSourceChecklistRows.value] : nextRows; + dragDropCommitted.value = true; + clearChecklistDragState(); + + if (!hasChecklistOrderChanged(fallbackRows, nextRows)) { + return; + } + + await persistChecklistOrder(nextRows, fallbackRows); +} + +async function moveChecklistItemByKeyboard(item: DailyChecklistItem, offset: -1 | 1) { + if (busy.value) { + return; + } + + const currentIndex = checklistRows.value.findIndex((row) => row.id === item.id); + const targetIndex = currentIndex + offset; + if (currentIndex < 0 || targetIndex < 0 || targetIndex >= checklistRows.value.length) { + return; + } + + const fallbackRows = [...checklistRows.value]; + const nextRows = [...checklistRows.value]; + const [movedItem] = nextRows.splice(currentIndex, 1); + nextRows.splice(targetIndex, 0, movedItem); + await persistChecklistOrder(nextRows, fallbackRows); +} + +function handleChecklistHandleKey(item: DailyChecklistItem, event: Event) { + const keyboardEvent = event instanceof KeyboardEvent ? event : null; + if (!keyboardEvent) { + return; + } + + if (keyboardEvent.key === 'ArrowUp') { + keyboardEvent.preventDefault(); + void moveChecklistItemByKeyboard(item, -1); + } + + if (keyboardEvent.key === 'ArrowDown') { + keyboardEvent.preventDefault(); + void moveChecklistItemByKeyboard(item, 1); + } +} + async function removePokemon(id: number) { await run(async () => { await api.deletePokemon(id); @@ -237,6 +443,59 @@ onMounted(() => { +
+

CheckList

+ +
+

{{ checklistForm.id ? '编辑 Task' : '新增 Task' }}

+
+ + +
+
+ + +
+
+ +

每日做什么

+ +
  • + + {{ item.title }} + + + + +
  • +
    +

    暂无记录

    +
    +

    系统配置

    diff --git a/frontend/src/views/DailyChecklistView.vue b/frontend/src/views/DailyChecklistView.vue new file mode 100644 index 0000000..b86676f --- /dev/null +++ b/frontend/src/views/DailyChecklistView.vue @@ -0,0 +1,141 @@ + + +