Files
pokopiawiki.tootaio.com/frontend/src/views/AdminView.vue
xiaomai 91dd834413 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
2026-05-01 09:40:00 +08:00

587 lines
19 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import {
api,
type AuthUser,
type ConfigType,
type DailyChecklistItem,
type Habitat,
type Item,
type NamedEntity,
type Pokemon,
type Recipe,
type Skill
} from '../services/api';
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: '材料单' },
{ key: 'habitats', label: '栖息地' }
];
const configTypes: Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }> = [
{ key: 'skills', label: '特长', supportsItemDrop: true },
{ key: 'environments', label: '喜欢的环境' },
{ key: 'favorite-things', label: '喜欢的东西 / 标签' },
{ key: 'item-categories', label: '物品分类' },
{ key: 'item-usages', label: '物品用途' },
{ key: 'acquisition-methods', label: '入手方式' },
{ key: 'maps', label: '地图' }
];
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[]>([]);
const habitatRows = ref<Habitat[]>([]);
const currentUser = ref<AuthUser | null>(null);
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 })));
const activeConfigTab = computed({
get: () => activeConfigType.value,
set: (value: string) => {
const nextConfig = configTypes.find((item) => item.key === value);
if (!nextConfig || nextConfig.key === activeConfigType.value) return;
activeConfigType.value = nextConfig.key;
resetConfigForm();
void run(loadConfig);
}
});
const canEdit = computed(() => currentUser.value?.emailVerified === true);
const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value));
function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
async function run(action: () => Promise<void>) {
busy.value = true;
message.value = '';
try {
await action();
} catch (error) {
message.value = errorText(error, '操作失败');
} finally {
busy.value = false;
}
}
async function loadConfig() {
configRows.value = (await api.config(activeConfigType.value)) as EditableConfig[];
}
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 = {
name: configForm.value.name,
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined
};
if (configForm.value.id) {
await api.updateConfig(activeConfigType.value, configForm.value.id, payload);
} else {
await api.createConfig(activeConfigType.value, payload);
}
resetConfigForm();
await loadConfig();
});
}
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({});
}
async function loadItems() {
itemRows.value = await api.items({});
}
async function loadRecipes() {
recipeRows.value = await api.recipes();
}
async function loadHabitats() {
habitatRows.value = await api.habitats();
}
async function loadCurrentTab(showSkeleton = false) {
if (showSkeleton) {
contentLoading.value = true;
}
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();
if (activeTab.value === 'habitats') await loadHabitats();
} finally {
if (showSkeleton) {
contentLoading.value = false;
}
}
}
function setTab(tab: AdminTab) {
if (!canEdit.value) {
message.value = '请先完成邮箱验证';
return;
}
activeTab.value = tab;
void run(() => loadCurrentTab(true));
}
async function loadAdmin() {
const response = await api.me();
currentUser.value = response.user;
if (!response.user.emailVerified) {
message.value = '请先完成邮箱验证';
return;
}
await loadCurrentTab(true);
}
async function removeConfig(id: number) {
await run(async () => {
await api.deleteConfig(activeConfigType.value, id);
if (configForm.value.id === id) {
resetConfigForm();
}
await loadConfig();
});
}
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);
await loadPokemon();
});
}
async function removeItem(id: number) {
await run(async () => {
await api.deleteItem(id);
await loadItems();
});
}
async function removeRecipe(id: number) {
await run(async () => {
await api.deleteRecipe(id);
await loadRecipes();
});
}
async function removeHabitat(id: number) {
await run(async () => {
await api.deleteHabitat(id);
await loadHabitats();
});
}
onMounted(() => {
void run(loadAdmin);
});
</script>
<template>
<section class="page-stack">
<PageHeader title="管理" subtitle="维护系统配置,查看并删除 Wiki 数据记录。">
<template #kicker>Admin</template>
</PageHeader>
<div v-if="canEdit" class="tabs" role="tablist" aria-label="管理模块">
<button v-for="tab in tabs" :key="tab.key" :class="{ active: activeTab === tab.key }" type="button" @click="setTab(tab.key)">
{{ tab.label }}
</button>
</div>
<StatusMessage v-if="message" variant="warning">{{ message }}</StatusMessage>
<section v-if="showAdminSkeleton" class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载管理列表">
<h2><Skeleton width="120px" height="24px" /></h2>
<ul class="row-list skeleton-row-list">
<li v-for="index in 6" :key="index">
<Skeleton :width="index % 2 === 0 ? '180px' : '132px'" />
<span class="row-actions">
<Skeleton variant="box" width="50px" height="34px" />
</span>
</li>
</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="系统配置类型" />
<form class="detail-section__body" @submit.prevent="saveConfig">
<h3 class="section-subtitle">{{ configForm.id ? `编辑${selectedConfig.label}` : `新增${selectedConfig.label}` }}</h3>
<div class="field">
<label for="config-name">名称</label>
<input id="config-name" v-model="configForm.name" required />
</div>
<div v-if="selectedConfig.supportsItemDrop" class="check-row">
<label>
<input v-model="configForm.hasItemDrop" type="checkbox" />
有掉落物
</label>
</div>
<div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
<button type="button" class="plain-button" :disabled="busy" @click="resetConfigForm">新建</button>
</div>
</form>
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
<ul v-if="configRows.length" class="row-list">
<li v-for="item in configRows" :key="item.id">
<span>{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">有掉落物</span></span>
<span class="row-actions">
<button type="button" @click="editConfig(item)">编辑</button>
<button type="button" @click="removeConfig(item.id)">删除</button>
</span>
</li>
</ul>
<p v-else class="meta-line">暂无记录</p>
</section>
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
<h2>Pokemon 列表</h2>
<ul v-if="pokemonRows.length" class="row-list">
<li v-for="item in pokemonRows" :key="item.id">
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.id }} {{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="removePokemon(item.id)">删除</button>
</span>
</li>
</ul>
<p v-else class="meta-line">暂无记录</p>
</section>
<section v-else-if="canEdit && activeTab === 'items'" class="detail-section">
<h2>物品列表</h2>
<ul v-if="itemRows.length" class="row-list">
<li v-for="item in itemRows" :key="item.id">
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="removeItem(item.id)">删除</button>
</span>
</li>
</ul>
<p v-else class="meta-line">暂无记录</p>
</section>
<section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section">
<h2>材料单列表</h2>
<ul v-if="recipeRows.length" class="row-list">
<li v-for="item in recipeRows" :key="item.id">
<RouterLink :to="`/recipes/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="removeRecipe(item.id)">删除</button>
</span>
</li>
</ul>
<p v-else class="meta-line">暂无记录</p>
</section>
<section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section">
<h2>栖息地列表</h2>
<ul v-if="habitatRows.length" class="row-list">
<li v-for="item in habitatRows" :key="item.id">
<RouterLink :to="`/habitats/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="removeHabitat(item.id)">删除</button>
</span>
</li>
</ul>
<p v-else class="meta-line">暂无记录</p>
</section>
</section>
</template>