feat(checklist): add daily checklist feature with admin management

Add daily checklist view for users to track daily tasks
Support creating, editing, deleting, and drag-and-drop reordering in admin panel
This commit is contained in:
2026-05-01 09:40:00 +08:00
parent 60cad3f5e8
commit 91dd834413
10 changed files with 775 additions and 1 deletions

View File

@@ -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<AdminTab>('config');
const activeConfigType = ref<ConfigType>('skills');
const configRows = ref<EditableConfig[]>([]);
const checklistRows = ref<DailyChecklistItem[]>([]);
const pokemonRows = ref<Pokemon[]>([]);
const itemRows = ref<Item[]>([]);
const recipeRows = ref<Recipe[]>([]);
@@ -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<number | null>(null);
const dragOverChecklistId = ref<number | null>(null);
const dragInsertAfterTarget = ref(false);
const dragSourceChecklistRows = ref<DailyChecklistItem[]>([]);
const dragDropCommitted = ref(false);
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
const configTabs = computed<TabOption[]>(() => 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(() => {
</ul>
</section>
<section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section">
<h2>CheckList</h2>
<form class="detail-section__body" @submit.prevent="saveChecklistItem">
<h3 class="section-subtitle">{{ checklistForm.id ? '编辑 Task' : '新增 Task' }}</h3>
<div class="field">
<label for="checklist-title">Task</label>
<input id="checklist-title" v-model="checklistForm.title" required />
</div>
<div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
<button type="button" class="plain-button" :disabled="busy" @click="resetChecklistForm">新建</button>
</div>
</form>
<h3 class="section-subtitle">每日做什么</h3>
<TransitionGroup v-if="checklistRows.length" name="admin-checklist" tag="ul" class="row-list admin-checklist-list">
<li
v-for="item in checklistRows"
:key="item.id"
class="admin-checklist-row"
:class="{
'is-dragging': draggingChecklistId === item.id,
'is-drop-target': dragOverChecklistId === item.id,
'is-drop-after': dragOverChecklistId === item.id && dragInsertAfterTarget,
'is-drop-before': dragOverChecklistId === item.id && !dragInsertAfterTarget
}"
@dragover.prevent="previewChecklistDrop(item, $event)"
@drop.prevent="dropChecklistItem(item, $event)"
>
<button
type="button"
class="drag-handle"
draggable="true"
:aria-label="`拖曳排序:${item.title}`"
title="拖曳排序"
:disabled="busy"
@dragstart="startChecklistDrag(item, $event)"
@dragend="endChecklistDrag"
@keydown="handleChecklistHandleKey(item, $event)"
>
<span aria-hidden="true"></span>
</button>
<span class="admin-checklist-title">{{ item.title }}</span>
<span class="row-actions">
<button type="button" :disabled="busy" @click="editChecklistItem(item)">编辑</button>
<button type="button" :disabled="busy" @click="removeChecklistItem(item.id)">删除</button>
</span>
</li>
</TransitionGroup>
<p v-else class="meta-line">暂无记录</p>
</section>
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
<h2>系统配置</h2>
<Tabs id="admin-config-type" v-model="activeConfigTab" :tabs="configTabs" label="系统配置类型" />

View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import { api, type DailyChecklistItem } from '../services/api';
type ChecklistState = {
date: string;
checkedIds: number[];
};
const checklistStateKey = 'pokopia_daily_checklist_state';
const stateRefreshIntervalMs = 60_000;
const checklistItems = ref<DailyChecklistItem[]>([]);
const checkedTaskIds = ref<Set<number>>(new Set());
const loading = ref(true);
const skeletonRows = 5;
let stateRefreshTimer: number | null = null;
function todayKey() {
const today = new Date();
const year = today.getFullYear();
const month = String(today.getMonth() + 1).padStart(2, '0');
const day = String(today.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function persistChecklistState() {
if (typeof localStorage === 'undefined') {
return;
}
const state: ChecklistState = {
date: todayKey(),
checkedIds: [...checkedTaskIds.value]
};
localStorage.setItem(checklistStateKey, JSON.stringify(state));
}
function loadChecklistState() {
if (typeof localStorage === 'undefined') {
checkedTaskIds.value = new Set();
return;
}
try {
const state = JSON.parse(localStorage.getItem(checklistStateKey) ?? 'null') as Partial<ChecklistState> | null;
checkedTaskIds.value = state?.date === todayKey() ? new Set(state.checkedIds?.filter((id) => Number.isInteger(id))) : new Set();
} catch {
checkedTaskIds.value = new Set();
}
persistChecklistState();
}
function syncChecklistState() {
const checklistIds = new Set(checklistItems.value.map((item) => item.id));
const nextCheckedIds = new Set([...checkedTaskIds.value].filter((id) => checklistIds.has(id)));
if (nextCheckedIds.size !== checkedTaskIds.value.size) {
checkedTaskIds.value = nextCheckedIds;
persistChecklistState();
}
}
function isTaskChecked(id: number) {
return checkedTaskIds.value.has(id);
}
function toggleTask(id: number, checked: boolean) {
const nextCheckedIds = new Set(checkedTaskIds.value);
if (checked) {
nextCheckedIds.add(id);
} else {
nextCheckedIds.delete(id);
}
checkedTaskIds.value = nextCheckedIds;
persistChecklistState();
}
function handleTaskChange(id: number, event: Event) {
const checkbox = event.target instanceof HTMLInputElement ? event.target : null;
toggleTask(id, checkbox?.checked === true);
}
async function loadDailyChecklist() {
loading.value = true;
try {
checklistItems.value = await api.dailyChecklist();
syncChecklistState();
} finally {
loading.value = false;
}
}
onMounted(() => {
loadChecklistState();
stateRefreshTimer = window.setInterval(loadChecklistState, stateRefreshIntervalMs);
void loadDailyChecklist();
});
onUnmounted(() => {
if (stateRefreshTimer !== null) {
window.clearInterval(stateRefreshTimer);
}
});
</script>
<template>
<section class="page-stack">
<PageHeader title="每日清单" subtitle="查看每天可以完成的事项。">
<template #kicker>CheckList</template>
</PageHeader>
<section class="detail-section" :aria-busy="loading">
<h2>每日做什么</h2>
<ul v-if="loading" class="row-list skeleton-row-list checklist-skeleton-list" aria-label="正在加载每日清单">
<li v-for="index in skeletonRows" :key="index">
<Skeleton variant="box" width="34px" height="34px" />
<Skeleton :width="index % 2 === 0 ? '220px' : '160px'" />
</li>
</ul>
<ul v-else-if="checklistItems.length" class="checklist-list">
<li v-for="item in checklistItems" :key="item.id" class="checklist-item" :class="{ 'is-checked': isTaskChecked(item.id) }">
<label class="checklist-check">
<input
type="checkbox"
:checked="isTaskChecked(item.id)"
@change="handleTaskChange(item.id, $event)"
/>
<span>{{ item.title }}</span>
</label>
</li>
</ul>
<p v-else class="meta-line">暂无每日清单</p>
</section>
</section>
</template>