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:
@@ -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="系统配置类型" />
|
||||
|
||||
Reference in New Issue
Block a user