Files
pokopiawiki.tootaio.com/frontend/src/views/AdminView.vue
xiaomai 193b4e3fd5 feat(ui): add single selection and keyboard navigation to TagsSelect
Add `multiple` prop to support single-value selection
Implement keyboard navigation (Up/Down/Enter) for dropdown options
Replace native select elements with TagsSelect across views
2026-04-30 11:07:28 +08:00

770 lines
26 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import TagsSelect from '../components/TagsSelect.vue';
import {
api,
type ConfigType,
type Habitat,
type HabitatDetail,
type HabitatPayload,
type Item,
type ItemPayload,
type NamedEntity,
type Options,
type Pokemon,
type PokemonPayload,
type Recipe,
type RecipePayload,
type Skill
} from '../services/api';
type AdminTab = 'config' | 'pokemon' | 'items' | 'recipes' | 'habitats';
type EditableConfig = (NamedEntity | Skill) & { subcategory?: string | null };
type HabitatAppearanceForm = {
pokemonId: string;
mapIds: string[];
timeOfDays: string[];
weathers: string[];
rarity: number;
};
const tabs: Array<{ key: AdminTab; label: string }> = [
{ key: 'config', label: '系统配置' },
{ key: 'pokemon', label: 'Pokemon' },
{ key: 'items', label: '物品' },
{ key: 'recipes', label: '材料单' },
{ key: 'habitats', label: '栖息地' }
];
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const timeOfDayOptions = timeOfDays.map((name) => ({ id: name, name }));
const weatherOptions = weathers.map((name) => ({ id: name, name }));
const configTypes: Array<{ key: ConfigType; label: string; hasSubcategory?: boolean }> = [
{ key: 'skills', label: '特长', hasSubcategory: 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 options = ref<Options | null>(null);
const configRows = ref<EditableConfig[]>([]);
const pokemonRows = ref<Pokemon[]>([]);
const itemRows = ref<Item[]>([]);
const recipeRows = ref<Recipe[]>([]);
const habitatRows = ref<Habitat[]>([]);
const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
const configForm = ref({ id: 0, name: '', subcategory: '' });
const pokemonForm = ref({ id: '', name: '', environmentId: '', skillIds: [] as string[], favoriteThingIds: [] as string[] });
const itemForm = ref({
id: 0,
name: '',
categoryId: '',
usageId: '',
dyeable: false,
dualDyeable: false,
patternEditable: false,
acquisitionMethodIds: [] as string[],
tagIds: [] as string[]
});
const recipeForm = ref({
id: 0,
itemId: '',
acquisitionMethodIds: [] as string[],
materials: [] as Array<{ itemId: string; quantity: number }>
});
const habitatForm = ref({
id: 0,
name: '',
recipeItems: [] as Array<{ itemId: string; quantity: number }>,
pokemonAppearances: [] as HabitatAppearanceForm[]
});
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
const pokemonSelectOptions = computed(() =>
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` }))
);
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
}
function toQuantityRows(rows: Array<{ itemId: string; quantity: number }>) {
return rows
.map((item) => ({ itemId: Number(item.itemId), quantity: Number(item.quantity) }))
.filter((item) => Number.isInteger(item.itemId) && item.itemId > 0 && Number.isInteger(item.quantity) && item.quantity > 0);
}
async function run(action: () => Promise<void>) {
busy.value = true;
message.value = '';
try {
await action();
} catch (error) {
message.value = error instanceof Error && error.message ? error.message : '操作失败';
} finally {
busy.value = false;
}
}
async function loadOptions() {
options.value = await api.options();
}
async function loadConfig() {
configRows.value = (await api.config(activeConfigType.value)) as EditableConfig[];
}
async function createTagsOption(selectKey: string, type: ConfigType, name: string, values: string[], max = 0) {
const cleanName = name.trim();
if (!cleanName || (max > 0 && values.length >= max)) return;
creatingSelect.value = selectKey;
try {
await run(async () => {
const created = await api.createConfig(type, { name: cleanName, subcategory: null });
await loadOptions();
const value = String(created.id);
if (!values.includes(value)) {
values.push(value);
}
if (activeConfigType.value === type) {
await loadConfig();
}
});
} finally {
creatingSelect.value = '';
}
}
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() {
await loadOptions();
if (activeTab.value === 'config') await loadConfig();
if (activeTab.value === 'pokemon') await loadPokemon();
if (activeTab.value === 'items') {
await Promise.all([loadItems(), loadRecipes()]);
}
if (activeTab.value === 'recipes') {
await Promise.all([loadRecipes(), loadItems()]);
}
if (activeTab.value === 'habitats') {
await Promise.all([loadHabitats(), loadPokemon(), loadItems()]);
}
}
function setTab(tab: AdminTab) {
activeTab.value = tab;
void run(loadCurrentTab);
}
function resetConfigForm() {
configForm.value = { id: 0, name: '', subcategory: '' };
}
function editConfig(item: EditableConfig) {
configForm.value = { id: item.id, name: item.name, subcategory: item.subcategory ?? '' };
}
async function saveConfig() {
await run(async () => {
const payload = { name: configForm.value.name, subcategory: configForm.value.subcategory || null };
if (configForm.value.id) {
await api.updateConfig(activeConfigType.value, configForm.value.id, payload);
} else {
await api.createConfig(activeConfigType.value, payload);
}
resetConfigForm();
await loadCurrentTab();
});
}
async function removeConfig(id: number) {
await run(async () => {
await api.deleteConfig(activeConfigType.value, id);
await loadCurrentTab();
});
}
function resetPokemonForm() {
pokemonForm.value = { id: '', name: '', environmentId: '', skillIds: [], favoriteThingIds: [] };
}
function editPokemon(item: Pokemon) {
pokemonForm.value = {
id: String(item.id),
name: item.name,
environmentId: String(item.environment.id),
skillIds: item.skills.map((skill) => String(skill.id)),
favoriteThingIds: item.favorite_things.map((thing) => String(thing.id))
};
}
async function savePokemon() {
await run(async () => {
const payload: PokemonPayload = {
id: Number(pokemonForm.value.id),
name: pokemonForm.value.name,
environmentId: Number(pokemonForm.value.environmentId),
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6))
};
const exists = pokemonRows.value.some((item) => item.id === payload.id);
if (exists) {
await api.updatePokemon(payload.id, payload);
} else {
await api.createPokemon(payload);
}
resetPokemonForm();
await loadPokemon();
});
}
async function removePokemon(id: number) {
await run(async () => {
await api.deletePokemon(id);
await loadPokemon();
});
}
function resetItemForm() {
itemForm.value = {
id: 0,
name: '',
categoryId: '',
usageId: '',
dyeable: false,
dualDyeable: false,
patternEditable: false,
acquisitionMethodIds: [],
tagIds: []
};
}
async function editItem(item: Item) {
await run(async () => {
const detail = await api.itemDetail(item.id);
itemForm.value = {
id: detail.id,
name: detail.name,
categoryId: String(detail.category.id),
usageId: detail.usage ? String(detail.usage.id) : '',
dyeable: detail.customization.dyeable,
dualDyeable: detail.customization.dualDyeable,
patternEditable: detail.customization.patternEditable,
acquisitionMethodIds: detail.acquisitionMethods.map((method) => String(method.id)),
tagIds: detail.tags.map((tag) => String(tag.id))
};
});
}
async function saveItem() {
await run(async () => {
const payload: ItemPayload = {
name: itemForm.value.name,
categoryId: Number(itemForm.value.categoryId),
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
dyeable: itemForm.value.dyeable,
dualDyeable: itemForm.value.dualDyeable,
patternEditable: itemForm.value.patternEditable,
acquisitionMethodIds: toIds(itemForm.value.acquisitionMethodIds),
tagIds: toIds(itemForm.value.tagIds)
};
if (itemForm.value.id) {
await api.updateItem(itemForm.value.id, payload);
} else {
await api.createItem(payload);
}
resetItemForm();
await loadItems();
});
}
async function removeItem(id: number) {
await run(async () => {
await api.deleteItem(id);
await loadItems();
});
}
function resetRecipeForm() {
recipeForm.value = { id: 0, itemId: '', acquisitionMethodIds: [], materials: [] };
}
function addRecipeMaterial() {
recipeForm.value.materials.push({ itemId: '', quantity: 1 });
}
async function editRecipe(item: Recipe) {
await run(async () => {
const detail = await api.recipeDetail(item.id);
recipeForm.value = {
id: detail.id,
itemId: String(detail.item.id),
acquisitionMethodIds: detail.acquisition_methods.map((method) => String(method.id)),
materials: detail.materials.map((material) => ({ itemId: String(material.id), quantity: material.quantity }))
};
});
}
async function saveRecipe() {
await run(async () => {
const payload: RecipePayload = {
itemId: Number(recipeForm.value.itemId),
acquisitionMethodIds: toIds(recipeForm.value.acquisitionMethodIds),
materials: toQuantityRows(recipeForm.value.materials)
};
if (recipeForm.value.id) {
await api.updateRecipe(recipeForm.value.id, payload);
} else {
await api.createRecipe(payload);
}
resetRecipeForm();
await Promise.all([loadRecipes(), loadItems()]);
});
}
async function removeRecipe(id: number) {
await run(async () => {
await api.deleteRecipe(id);
await loadRecipes();
});
}
function resetHabitatForm() {
habitatForm.value = { id: 0, name: '', recipeItems: [], pokemonAppearances: [] };
}
function addHabitatRecipeItem() {
habitatForm.value.recipeItems.push({ itemId: '', quantity: 1 });
}
function addPokemonAppearance() {
habitatForm.value.pokemonAppearances.push({
pokemonId: '',
mapIds: [],
timeOfDays: ['早晨'],
weathers: ['晴天'],
rarity: 1
});
}
function groupPokemonAppearances(detail: HabitatDetail): HabitatAppearanceForm[] {
const rows = new Map<string, HabitatAppearanceForm>();
detail.pokemon.forEach((pokemon) => {
const key = `${pokemon.id}:${pokemon.rarity}`;
const row = rows.get(key) ?? {
pokemonId: String(pokemon.id),
mapIds: [],
timeOfDays: [],
weathers: [],
rarity: pokemon.rarity
};
const mapId = String(pokemon.map.id);
if (!row.mapIds.includes(mapId)) row.mapIds.push(mapId);
if (!row.timeOfDays.includes(pokemon.time_of_day)) row.timeOfDays.push(pokemon.time_of_day);
if (!row.weathers.includes(pokemon.weather)) row.weathers.push(pokemon.weather);
rows.set(key, row);
});
return [...rows.values()];
}
async function editHabitat(item: Habitat) {
await run(async () => {
const detail = await api.habitatDetail(item.id);
habitatForm.value = {
id: detail.id,
name: detail.name,
recipeItems: detail.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
pokemonAppearances: groupPokemonAppearances(detail)
};
});
}
async function saveHabitat() {
await run(async () => {
const payload: HabitatPayload = {
name: habitatForm.value.name,
recipeItems: toQuantityRows(habitatForm.value.recipeItems),
pokemonAppearances: habitatForm.value.pokemonAppearances
.map((item) => ({
pokemonId: Number(item.pokemonId),
mapIds: toIds(item.mapIds),
timeOfDays: item.timeOfDays.filter((entry) => timeOfDays.includes(entry)),
weathers: item.weathers.filter((entry) => weathers.includes(entry)),
rarity: Number(item.rarity)
}))
.filter((item) => item.pokemonId > 0 && item.mapIds.length > 0 && item.timeOfDays.length > 0 && item.weathers.length > 0)
};
if (habitatForm.value.id) {
await api.updateHabitat(habitatForm.value.id, payload);
} else {
await api.createHabitat(payload);
}
resetHabitatForm();
await loadHabitats();
});
}
async function removeHabitat(id: number) {
await run(async () => {
await api.deleteHabitat(id);
await loadHabitats();
});
}
onMounted(() => {
void run(loadCurrentTab);
});
</script>
<template>
<section>
<div class="page-header">
<div>
<h1 class="page-title">管理</h1>
<p class="page-subtitle">维护 Wiki 数据和系统配置</p>
</div>
</div>
<div 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>
<p v-if="message" class="status">{{ message }}</p>
<section v-if="activeTab === 'config'" class="admin-layout">
<form class="detail-section" @submit.prevent="saveConfig">
<h2>系统配置</h2>
<div class="field">
<label for="config-type">类型</label>
<select id="config-type" v-model="activeConfigType" @change="run(loadConfig)">
<option v-for="item in configTypes" :key="item.key" :value="item.key">{{ item.label }}</option>
</select>
</div>
<div class="field">
<label for="config-name">名称</label>
<input id="config-name" v-model="configForm.name" />
</div>
<div v-if="selectedConfig.hasSubcategory" class="field">
<label for="config-subcategory">二级分类</label>
<input id="config-subcategory" v-model="configForm.subcategory" />
</div>
<div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">保存</button>
<button type="button" class="plain-button" @click="resetConfigForm">新建</button>
</div>
</form>
<div class="detail-section">
<h2>{{ selectedConfig.label }}</h2>
<ul class="row-list">
<li v-for="item in configRows" :key="item.id">
<span>{{ item.name }}<span v-if="item.subcategory"> · {{ item.subcategory }}</span></span>
<span class="row-actions">
<button type="button" @click="editConfig(item)">编辑</button>
<button type="button" @click="removeConfig(item.id)">删除</button>
</span>
</li>
</ul>
</div>
</section>
<section v-if="activeTab === 'pokemon' && options" class="admin-layout">
<form class="detail-section" @submit.prevent="savePokemon">
<h2>Pokemon</h2>
<div class="field"><label for="pokemon-id">ID</label><input id="pokemon-id" v-model="pokemonForm.id" type="number" /></div>
<div class="field"><label for="pokemon-name">名字</label><input id="pokemon-name" v-model="pokemonForm.name" /></div>
<div class="field">
<label for="pokemon-environment">喜欢的环境</label>
<TagsSelect
id="pokemon-environment"
v-model="pokemonForm.environmentId"
:options="options.environments"
:multiple="false"
placeholder="请选择"
search-placeholder="搜索喜欢的环境"
/>
</div>
<div class="field">
<label for="pokemon-skills">特长</label>
<TagsSelect
id="pokemon-skills"
v-model="pokemonForm.skillIds"
:options="options.skills"
:max="2"
allow-create
:creating="creatingSelect === 'pokemon-skills'"
placeholder="搜索特长"
@create="createTagsOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
/>
</div>
<div class="field">
<label for="pokemon-things">喜欢的东西</label>
<TagsSelect
id="pokemon-things"
v-model="pokemonForm.favoriteThingIds"
:options="options.favoriteThings"
:max="6"
allow-create
:creating="creatingSelect === 'pokemon-things'"
placeholder="搜索喜欢的东西"
@create="createTagsOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
/>
</div>
<div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">保存</button>
<button type="button" class="plain-button" @click="resetPokemonForm">新建</button>
</div>
</form>
<div class="detail-section">
<h2>Pokemon 列表</h2>
<ul class="row-list">
<li v-for="item in pokemonRows" :key="item.id">
<span>#{{ item.id }} {{ item.name }}</span>
<span class="row-actions">
<button type="button" @click="editPokemon(item)">编辑</button>
<button type="button" @click="removePokemon(item.id)">删除</button>
</span>
</li>
</ul>
</div>
</section>
<section v-if="activeTab === 'items' && options" class="admin-layout">
<form class="detail-section" @submit.prevent="saveItem">
<h2>物品</h2>
<div class="field"><label for="item-name">名称</label><input id="item-name" v-model="itemForm.name" /></div>
<div class="field">
<label for="item-category">分类</label>
<TagsSelect
id="item-category"
v-model="itemForm.categoryId"
:options="options.itemCategories"
:multiple="false"
placeholder="请选择"
search-placeholder="搜索分类"
/>
</div>
<div class="field">
<label for="item-usage">用途</label>
<TagsSelect
id="item-usage"
v-model="itemForm.usageId"
:options="options.itemUsages"
:multiple="false"
placeholder="无"
search-placeholder="搜索用途"
/>
</div>
<div class="check-row">
<label><input v-model="itemForm.dyeable" type="checkbox" /> 可染色</label>
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> 可双区染色</label>
<label><input v-model="itemForm.patternEditable" type="checkbox" /> 可改花纹</label>
</div>
<div class="field">
<label for="item-methods">入手方式</label>
<TagsSelect
id="item-methods"
v-model="itemForm.acquisitionMethodIds"
:options="options.acquisitionMethods"
allow-create
:creating="creatingSelect === 'item-methods'"
placeholder="搜索入手方式"
@create="createTagsOption('item-methods', 'acquisition-methods', $event, itemForm.acquisitionMethodIds)"
/>
</div>
<div class="field">
<label for="item-tags">标签</label>
<TagsSelect
id="item-tags"
v-model="itemForm.tagIds"
:options="options.itemTags"
allow-create
:creating="creatingSelect === 'item-tags'"
placeholder="搜索标签"
@create="createTagsOption('item-tags', 'favorite-things', $event, itemForm.tagIds)"
/>
</div>
<div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">保存</button>
<button type="button" class="plain-button" @click="resetItemForm">新建</button>
</div>
</form>
<div class="detail-section">
<h2>物品列表</h2>
<ul class="row-list">
<li v-for="item in itemRows" :key="item.id">
<span>{{ item.name }}</span>
<span class="row-actions">
<button type="button" @click="editItem(item)">编辑</button>
<button type="button" @click="removeItem(item.id)">删除</button>
</span>
</li>
</ul>
</div>
</section>
<section v-if="activeTab === 'recipes' && options" class="admin-layout">
<form class="detail-section" @submit.prevent="saveRecipe">
<h2>材料单</h2>
<div class="field">
<label for="recipe-item">物品</label>
<TagsSelect
id="recipe-item"
v-model="recipeForm.itemId"
:options="itemSelectOptions"
:multiple="false"
placeholder="请选择"
search-placeholder="搜索物品"
/>
</div>
<div class="field">
<label for="recipe-methods">入手方式</label>
<TagsSelect
id="recipe-methods"
v-model="recipeForm.acquisitionMethodIds"
:options="options.acquisitionMethods"
allow-create
:creating="creatingSelect === 'recipe-methods'"
placeholder="搜索入手方式"
@create="createTagsOption('recipe-methods', 'acquisition-methods', $event, recipeForm.acquisitionMethodIds)"
/>
</div>
<div class="field">
<label>需要材料</label>
<div v-for="(row, index) in recipeForm.materials" :key="index" class="inline-row">
<TagsSelect
:id="`recipe-material-${index}`"
v-model="row.itemId"
:options="itemSelectOptions"
:multiple="false"
placeholder="请选择"
search-placeholder="搜索物品"
/>
<input v-model.number="row.quantity" type="number" min="1" />
<button type="button" @click="recipeForm.materials.splice(index, 1)">删除</button>
</div>
<button type="button" class="plain-button" @click="addRecipeMaterial">添加材料</button>
</div>
<div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">保存</button>
<button type="button" class="plain-button" @click="resetRecipeForm">新建</button>
</div>
</form>
<div class="detail-section">
<h2>材料单列表</h2>
<ul class="row-list">
<li v-for="item in recipeRows" :key="item.id">
<span>{{ item.name }}</span>
<span class="row-actions">
<button type="button" @click="editRecipe(item)">编辑</button>
<button type="button" @click="removeRecipe(item.id)">删除</button>
</span>
</li>
</ul>
</div>
</section>
<section v-if="activeTab === 'habitats' && options" class="admin-layout">
<form class="detail-section" @submit.prevent="saveHabitat">
<h2>栖息地</h2>
<div class="field"><label for="habitat-name">名称</label><input id="habitat-name" v-model="habitatForm.name" /></div>
<div class="field">
<label>配方</label>
<div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row">
<TagsSelect
:id="`habitat-recipe-item-${index}`"
v-model="row.itemId"
:options="itemSelectOptions"
:multiple="false"
placeholder="请选择"
search-placeholder="搜索物品"
/>
<input v-model.number="row.quantity" type="number" min="1" />
<button type="button" @click="habitatForm.recipeItems.splice(index, 1)">删除</button>
</div>
<button type="button" class="plain-button" @click="addHabitatRecipeItem">添加物品</button>
</div>
<div class="field">
<label>可出现的宝可梦</label>
<div v-for="(row, index) in habitatForm.pokemonAppearances" :key="index" class="appearance-row">
<TagsSelect
:id="`appearance-pokemon-${index}`"
v-model="row.pokemonId"
:options="pokemonSelectOptions"
:multiple="false"
placeholder="Pokemon"
search-placeholder="搜索 Pokemon"
/>
<TagsSelect
:id="`appearance-maps-${index}`"
v-model="row.mapIds"
:options="options.maps"
allow-create
:creating="creatingSelect === `appearance-maps-${index}`"
placeholder="搜索地图"
@create="createTagsOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)"
/>
<TagsSelect :id="`appearance-times-${index}`" v-model="row.timeOfDays" :options="timeOfDayOptions" placeholder="搜索时间" />
<TagsSelect :id="`appearance-weathers-${index}`" v-model="row.weathers" :options="weatherOptions" placeholder="搜索天气" />
<input v-model.number="row.rarity" type="number" min="1" max="3" />
<button type="button" @click="habitatForm.pokemonAppearances.splice(index, 1)">删除</button>
</div>
<button type="button" class="plain-button" @click="addPokemonAppearance">添加 Pokemon</button>
</div>
<div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">保存</button>
<button type="button" class="plain-button" @click="resetHabitatForm">新建</button>
</div>
</form>
<div class="detail-section">
<h2>栖息地列表</h2>
<ul class="row-list">
<li v-for="item in habitatRows" :key="item.id">
<span>{{ item.name }}</span>
<span class="row-actions">
<button type="button" @click="editHabitat(item)">编辑</button>
<button type="button" @click="removeHabitat(item.id)">删除</button>
</span>
</li>
</ul>
</div>
</section>
</section>
</template>