feat(ui): extract entity forms into dedicated edit views

Move entity creation and editing from AdminView to separate pages.
Simplify AdminView to focus on system configuration and record deletion.
Add action buttons to list/detail views and protect routes via meta tags.
This commit is contained in:
2026-04-30 15:12:32 +08:00
parent 47b9b25032
commit 3e8265e0c8
15 changed files with 1048 additions and 635 deletions

View File

@@ -1,12 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router';
import PokemonList from '../views/PokemonList.vue';
import PokemonDetail from '../views/PokemonDetail.vue';
import PokemonEdit from '../views/PokemonEdit.vue';
import HabitatList from '../views/HabitatList.vue';
import HabitatDetail from '../views/HabitatDetail.vue';
import HabitatEdit from '../views/HabitatEdit.vue';
import ItemsList from '../views/ItemsList.vue';
import ItemDetail from '../views/ItemDetail.vue';
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 AdminView from '../views/AdminView.vue';
import LoginView from '../views/LoginView.vue';
import RegisterView from '../views/RegisterView.vue';
@@ -18,14 +22,22 @@ export const router = createRouter({
routes: [
{ path: '/', redirect: '/pokemon' },
{ path: '/pokemon', component: PokemonList },
{ path: '/pokemon/new', component: PokemonEdit, meta: { requiresVerified: true } },
{ path: '/pokemon/:id/edit', component: PokemonEdit, meta: { requiresVerified: true } },
{ path: '/pokemon/:id', component: PokemonDetail },
{ path: '/habitats', component: HabitatList },
{ path: '/habitats/new', component: HabitatEdit, meta: { requiresVerified: true } },
{ path: '/habitats/:id/edit', component: HabitatEdit, meta: { requiresVerified: true } },
{ path: '/habitats/:id', component: HabitatDetail },
{ path: '/items', component: ItemsList },
{ path: '/items/new', component: ItemEdit, meta: { requiresVerified: true } },
{ path: '/items/:id/edit', component: ItemEdit, meta: { requiresVerified: true } },
{ path: '/items/:id', component: ItemDetail },
{ path: '/recipes', component: RecipeList },
{ path: '/recipes/new', component: RecipeEdit, meta: { requiresVerified: true } },
{ path: '/recipes/:id/edit', component: RecipeEdit, meta: { requiresVerified: true } },
{ path: '/recipes/:id', component: RecipeDetail },
{ path: '/admin', component: AdminView },
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
{ path: '/login', component: LoginView },
{ path: '/register', component: RegisterView },
{ path: '/verify-email', component: VerifyEmailView }
@@ -34,7 +46,7 @@ export const router = createRouter({
});
router.beforeEach(async (to) => {
if (to.path !== '/admin') {
if (!to.matched.some((record) => record.meta.requiresVerified === true)) {
return true;
}
@@ -43,8 +55,8 @@ router.beforeEach(async (to) => {
}
try {
await api.me();
return true;
const response = await api.me();
return response.user.emailVerified ? true : { path: '/login', query: { redirect: to.fullPath } };
} catch {
setAuthToken(null);
return { path: '/login', query: { redirect: to.fullPath } };

View File

@@ -906,6 +906,13 @@ button:disabled,
line-height: 1.12;
}
.section-subtitle {
margin: 0;
color: var(--ink-soft);
font-size: 16px;
font-weight: 900;
}
.detail-section__body {
display: grid;
gap: 12px;

View File

@@ -3,34 +3,21 @@ 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 TagsSelect from '../components/TagsSelect.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import {
api,
type AuthUser,
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: '系统配置' },
@@ -40,24 +27,18 @@ const tabs: Array<{ key: AdminTab; label: string }> = [
{ 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: '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[]>([]);
@@ -67,50 +48,26 @@ const currentUser = ref<AuthUser | null>(null);
const busy = ref(false);
const contentLoading = 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}` }))
);
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 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);
function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
async function run(action: () => Promise<void>) {
@@ -119,40 +76,40 @@ async function run(action: () => Promise<void>) {
try {
await action();
} catch (error) {
message.value = error instanceof Error && error.message ? error.message : '操作失败';
message.value = errorText(error, '操作失败');
} 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;
function resetConfigForm() {
configForm.value = { id: 0, name: '', subcategory: '' };
}
creatingSelect.value = selectKey;
try {
function editConfig(item: EditableConfig) {
configForm.value = { id: item.id, name: item.name, subcategory: item.subcategory ?? '' };
}
async function saveConfig() {
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);
const payload = {
name: configForm.value.name,
subcategory: selectedConfig.value.hasSubcategory ? configForm.value.subcategory || null : null
};
if (configForm.value.id) {
await api.updateConfig(activeConfigType.value, configForm.value.id, payload);
} else {
await api.createConfig(activeConfigType.value, payload);
}
if (activeConfigType.value === type) {
resetConfigForm();
await loadConfig();
}
});
} finally {
creatingSelect.value = '';
}
}
async function loadPokemon() {
@@ -172,26 +129,18 @@ async function loadHabitats() {
}
async function loadCurrentTab(showSkeleton = false) {
const shouldShowSkeleton = showSkeleton || !options.value;
if (shouldShowSkeleton) {
if (showSkeleton) {
contentLoading.value = true;
}
try {
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()]);
}
if (activeTab.value === 'items') await loadItems();
if (activeTab.value === 'recipes') await loadRecipes();
if (activeTab.value === 'habitats') await loadHabitats();
} finally {
if (shouldShowSkeleton) {
if (showSkeleton) {
contentLoading.value = false;
}
}
@@ -219,65 +168,13 @@ async function loadAdmin() {
await loadCurrentTab(true);
}
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);
if (configForm.value.id === id) {
resetConfigForm();
}
resetPokemonForm();
await loadPokemon();
await loadConfig();
});
}
@@ -288,59 +185,6 @@ async function removePokemon(id: number) {
});
}
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);
@@ -348,43 +192,6 @@ async function removeItem(id: number) {
});
}
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);
@@ -392,84 +199,6 @@ async function removeRecipe(id: number) {
});
}
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);
@@ -484,7 +213,7 @@ onMounted(() => {
<template>
<section class="page-stack">
<PageHeader title="管理" subtitle="维护 Wiki 数据和系统配置。">
<PageHeader title="管理" subtitle="维护系统配置,查看并删除 Wiki 数据记录。">
<template #kicker>Admin</template>
</PageHeader>
@@ -496,61 +225,40 @@ onMounted(() => {
<StatusMessage v-if="message" variant="warning">{{ message }}</StatusMessage>
<section v-if="showAdminSkeleton" class="admin-layout" aria-busy="true" aria-label="正在加载管理内容">
<div class="detail-section skeleton-detail-section" aria-hidden="true">
<h2><Skeleton width="96px" height="24px" /></h2>
<div class="skeleton-form-stack">
<div v-for="index in 5" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '72px'" />
<Skeleton variant="box" height="44px" />
</div>
<div class="form-actions">
<Skeleton variant="box" width="64px" height="42px" />
<Skeleton variant="box" width="64px" height="42px" />
</div>
</div>
</div>
<div class="detail-section skeleton-detail-section" aria-hidden="true">
<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" />
<Skeleton variant="box" width="50px" height="34px" />
</span>
</li>
</ul>
</div>
</section>
<section v-else-if="canEdit && activeTab === 'config'" class="admin-layout">
<form class="detail-section" @submit.prevent="saveConfig">
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
<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>
<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" />
<input id="config-name" v-model="configForm.name" required />
</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>
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
<button type="button" class="plain-button" :disabled="busy" @click="resetConfigForm">新建</button>
</div>
</form>
<div class="detail-section">
<h2>{{ selectedConfig.label }}</h2>
<ul class="row-list">
<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.subcategory"> · {{ item.subcategory }}</span></span>
<span class="row-actions">
@@ -559,273 +267,59 @@ onMounted(() => {
</span>
</li>
</ul>
</div>
<p v-else class="meta-line">暂无记录</p>
</section>
<section v-else-if="canEdit && 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">
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
<h2>Pokemon 列表</h2>
<ul class="row-list">
<ul v-if="pokemonRows.length" class="row-list">
<li v-for="item in pokemonRows" :key="item.id">
<span>#{{ item.id }} {{ item.name }}</span>
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.id }} {{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="editPokemon(item)">编辑</button>
<button type="button" @click="removePokemon(item.id)">删除</button>
</span>
</li>
</ul>
</div>
<p v-else class="meta-line">暂无记录</p>
</section>
<section v-else-if="canEdit && 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">
<section v-else-if="canEdit && activeTab === 'items'" class="detail-section">
<h2>物品列表</h2>
<ul class="row-list">
<ul v-if="itemRows.length" class="row-list">
<li v-for="item in itemRows" :key="item.id">
<span>{{ item.name }}</span>
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="editItem(item)">编辑</button>
<button type="button" @click="removeItem(item.id)">删除</button>
</span>
</li>
</ul>
</div>
<p v-else class="meta-line">暂无记录</p>
</section>
<section v-else-if="canEdit && 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">
<section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section">
<h2>材料单列表</h2>
<ul class="row-list">
<ul v-if="recipeRows.length" class="row-list">
<li v-for="item in recipeRows" :key="item.id">
<span>{{ item.name }}</span>
<RouterLink :to="`/recipes/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="editRecipe(item)">编辑</button>
<button type="button" @click="removeRecipe(item.id)">删除</button>
</span>
</li>
</ul>
</div>
<p v-else class="meta-line">暂无记录</p>
</section>
<section v-else-if="canEdit && 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">
<section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section">
<h2>栖息地列表</h2>
<ul class="row-list">
<ul v-if="habitatRows.length" class="row-list">
<li v-for="item in habitatRows" :key="item.id">
<span>{{ item.name }}</span>
<RouterLink :to="`/habitats/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="editHabitat(item)">编辑</button>
<button type="button" @click="removeHabitat(item.id)">删除</button>
</span>
</li>
</ul>
</div>
<p v-else class="meta-line">暂无记录</p>
</section>
</section>
</template>

View File

@@ -133,6 +133,7 @@ onMounted(async () => {
<EditMeta :entity="habitat" />
</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">编辑</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/habitats">返回列表</RouterLink>
</template>
</PageHeader>

View File

@@ -0,0 +1,261 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import {
api,
type ConfigType,
type HabitatDetail,
type HabitatPayload,
type Item,
type Options,
type Pokemon
} from '../services/api';
type HabitatAppearanceForm = {
pokemonId: string;
mapIds: string[];
timeOfDays: string[];
weathers: string[];
rarity: number;
};
const route = useRoute();
const router = useRouter();
const options = ref<Options | null>(null);
const itemRows = ref<Item[]>([]);
const pokemonRows = ref<Pokemon[]>([]);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
const habitatForm = ref({
name: '',
recipeItems: [] as Array<{ itemId: string; quantity: number }>,
pokemonAppearances: [] as HabitatAppearanceForm[]
});
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const timeOfDayOptions = timeOfDays.map((name) => ({ id: name, name }));
const weatherOptions = weathers.map((name) => ({ id: name, name }));
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
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}` }))
);
const pageTitle = computed(() => (isEditing.value ? `编辑 ${habitatForm.value.name || '栖息地'}` : '新增栖息地'));
const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : '/habitats'));
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);
}
function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
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 loadEditor() {
loading.value = true;
message.value = '';
try {
const [loadedOptions, loadedItems, loadedPokemon] = await Promise.all([api.options(), api.items({}), api.pokemon({})]);
options.value = loadedOptions;
itemRows.value = loadedItems;
pokemonRows.value = loadedPokemon;
if (isEditing.value) {
const habitat = await api.habitatDetail(routeId.value);
habitatForm.value = {
name: habitat.name,
recipeItems: habitat.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
pokemonAppearances: groupPokemonAppearances(habitat)
};
}
} catch (error) {
message.value = errorText(error, '加载失败');
} finally {
loading.value = false;
}
}
async function loadOptions() {
options.value = await api.options();
}
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName) return;
creatingSelect.value = selectKey;
message.value = '';
try {
const created = await api.createConfig(type, { name: cleanName, subcategory: null });
await loadOptions();
const value = String(created.id);
if (!values.includes(value)) {
values.push(value);
}
} catch (error) {
message.value = errorText(error, '添加失败');
} finally {
creatingSelect.value = '';
}
}
async function saveHabitat() {
busy.value = true;
message.value = '';
try {
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)
};
const saved = isEditing.value ? await api.updateHabitat(routeId.value, payload) : await api.createHabitat(payload);
await router.push(`/habitats/${saved.id}`);
} catch (error) {
message.value = errorText(error, '保存失败');
} finally {
busy.value = false;
}
}
onMounted(() => {
void loadEditor();
});
</script>
<template>
<section class="page-stack">
<PageHeader :title="pageTitle" subtitle="维护栖息地配方和可能出现的 Pokemon。">
<template #kicker>Habitat Edit</template>
<template #actions>
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">返回</RouterLink>
</template>
</PageHeader>
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" class="detail-section" @submit.prevent="saveHabitat">
<div class="field">
<label for="habitat-name">名称</label>
<input id="habitat-name" v-model="habitatForm.name" required />
</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" aria-label="数量" 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>可出现的 Pokemon</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="createMultiOption(`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" aria-label="稀有度" 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">{{ busy ? '保存中' : '保存' }}</button>
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink>
</div>
</form>
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载栖息地编辑内容">
<div v-for="index in 5" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '112px'" />
<Skeleton variant="box" height="44px" />
</div>
</section>
</section>
</template>

View File

@@ -21,6 +21,9 @@ onMounted(async () => {
<section class="page-stack">
<PageHeader title="栖息地" subtitle="查看配方和可能出现的宝可梦。">
<template #kicker>Habitats</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/habitats/new">新增</RouterLink>
</template>
</PageHeader>
<div v-if="loading" class="entity-grid" aria-busy="true" aria-label="正在加载栖息地列表">

View File

@@ -88,6 +88,7 @@ onMounted(async () => {
<EditMeta :entity="item" />
</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">编辑</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/items">返回列表</RouterLink>
</template>
</PageHeader>

View File

@@ -0,0 +1,229 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { api, type ConfigType, type ItemPayload, type Options } from '../services/api';
const route = useRoute();
const router = useRouter();
const options = ref<Options | null>(null);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
const itemForm = ref({
name: '',
categoryId: '',
usageId: '',
dyeable: false,
dualDyeable: false,
patternEditable: false,
acquisitionMethodIds: [] as string[],
tagIds: [] as string[]
});
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const pageTitle = computed(() => (isEditing.value ? `编辑 ${itemForm.value.name || '物品'}` : '新增物品'));
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items'));
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
}
function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
async function loadOptions() {
options.value = await api.options();
}
async function loadEditor() {
loading.value = true;
message.value = '';
try {
await loadOptions();
if (isEditing.value) {
const item = await api.itemDetail(routeId.value);
itemForm.value = {
name: item.name,
categoryId: String(item.category.id),
usageId: item.usage ? String(item.usage.id) : '',
dyeable: item.customization.dyeable,
dualDyeable: item.customization.dualDyeable,
patternEditable: item.customization.patternEditable,
acquisitionMethodIds: item.acquisitionMethods.map((method) => String(method.id)),
tagIds: item.tags.map((tag) => String(tag.id))
};
}
} catch (error) {
message.value = errorText(error, '加载失败');
} finally {
loading.value = false;
}
}
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
const cleanName = name.trim();
if (!cleanName) return;
creatingSelect.value = selectKey;
message.value = '';
try {
const created = await api.createConfig(type, { name: cleanName, subcategory: null });
await loadOptions();
assign(String(created.id));
} catch (error) {
message.value = errorText(error, '添加失败');
} finally {
creatingSelect.value = '';
}
}
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName) return;
creatingSelect.value = selectKey;
message.value = '';
try {
const created = await api.createConfig(type, { name: cleanName, subcategory: null });
await loadOptions();
const value = String(created.id);
if (!values.includes(value)) {
values.push(value);
}
} catch (error) {
message.value = errorText(error, '添加失败');
} finally {
creatingSelect.value = '';
}
}
async function saveItem() {
busy.value = true;
message.value = '';
try {
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)
};
const saved = isEditing.value ? await api.updateItem(routeId.value, payload) : await api.createItem(payload);
await router.push(`/items/${saved.id}`);
} catch (error) {
message.value = errorText(error, '保存失败');
} finally {
busy.value = false;
}
}
onMounted(() => {
void loadEditor();
});
</script>
<template>
<section class="page-stack">
<PageHeader :title="pageTitle" subtitle="维护物品分类、用途、入手方式、自定义和标签。">
<template #kicker>Item Edit</template>
<template #actions>
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">返回</RouterLink>
</template>
</PageHeader>
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" class="detail-section" @submit.prevent="saveItem">
<div class="field">
<label for="item-name">名称</label>
<input id="item-name" v-model="itemForm.name" required />
</div>
<div class="field">
<label for="item-category">分类</label>
<TagsSelect
id="item-category"
v-model="itemForm.categoryId"
:options="options.itemCategories"
:multiple="false"
allow-create
:creating="creatingSelect === 'item-category'"
placeholder="请选择"
search-placeholder="搜索分类"
@create="createSingleOption('item-category', 'item-categories', $event, (value) => (itemForm.categoryId = value))"
/>
</div>
<div class="field">
<label for="item-usage">用途</label>
<TagsSelect
id="item-usage"
v-model="itemForm.usageId"
:options="options.itemUsages"
:multiple="false"
allow-create
:creating="creatingSelect === 'item-usage'"
placeholder="无"
search-placeholder="搜索用途"
@create="createSingleOption('item-usage', 'item-usages', $event, (value) => (itemForm.usageId = value))"
/>
</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="createMultiOption('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="createMultiOption('item-tags', 'favorite-things', $event, itemForm.tagIds)"
/>
</div>
<div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink>
</div>
</form>
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载物品编辑内容">
<div v-for="index in 6" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '88px'" />
<Skeleton variant="box" height="44px" />
</div>
</section>
</section>
</template>

View File

@@ -52,6 +52,9 @@ watch(itemQuery, loadItems);
<section class="page-stack">
<PageHeader title="物品" subtitle="按分类、用途、标签查看物品。">
<template #kicker>Bag</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/items/new">新增</RouterLink>
</template>
</PageHeader>
<Tabs v-if="options" id="item-category" v-model="categoryId" :tabs="categoryTabs" label="分类" />

View File

@@ -144,6 +144,7 @@ onMounted(async () => {
<EditMeta :entity="pokemon" />
</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">编辑</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/pokemon">返回列表</RouterLink>
</template>
</PageHeader>

View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { api, type ConfigType, type Options, type PokemonPayload } from '../services/api';
const route = useRoute();
const router = useRouter();
const options = ref<Options | null>(null);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
const pokemonForm = ref({
id: '',
name: '',
environmentId: '',
skillIds: [] as string[],
favoriteThingIds: [] as string[]
});
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const pageTitle = computed(() => (isEditing.value ? `编辑 #${pokemonForm.value.id || routeId.value} ${pokemonForm.value.name}` : '新增 Pokemon'));
const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` : '/pokemon'));
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
}
function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
async function loadOptions() {
options.value = await api.options();
}
async function loadEditor() {
loading.value = true;
message.value = '';
try {
await loadOptions();
if (isEditing.value) {
const pokemon = await api.pokemonDetail(routeId.value);
pokemonForm.value = {
id: String(pokemon.id),
name: pokemon.name,
environmentId: String(pokemon.environment.id),
skillIds: pokemon.skills.map((skill) => String(skill.id)),
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id))
};
}
} catch (error) {
message.value = errorText(error, '加载失败');
} finally {
loading.value = false;
}
}
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
const cleanName = name.trim();
if (!cleanName) return;
creatingSelect.value = selectKey;
message.value = '';
try {
const created = await api.createConfig(type, { name: cleanName, subcategory: null });
await loadOptions();
assign(String(created.id));
} catch (error) {
message.value = errorText(error, '添加失败');
} finally {
creatingSelect.value = '';
}
}
async function createMultiOption(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;
message.value = '';
try {
const created = await api.createConfig(type, { name: cleanName, subcategory: null });
await loadOptions();
const value = String(created.id);
if (!values.includes(value)) {
values.push(value);
}
} catch (error) {
message.value = errorText(error, '添加失败');
} finally {
creatingSelect.value = '';
}
}
async function savePokemon() {
busy.value = true;
message.value = '';
try {
const payload: PokemonPayload = {
id: Number(isEditing.value ? routeId.value : 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 saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload);
await router.push(`/pokemon/${saved.id}`);
} catch (error) {
message.value = errorText(error, '保存失败');
} finally {
busy.value = false;
}
}
onMounted(() => {
void loadEditor();
});
</script>
<template>
<section class="page-stack">
<PageHeader :title="pageTitle" subtitle="维护 Pokemon 基本资料、特长和喜欢的东西。">
<template #kicker>Pokédex Edit</template>
<template #actions>
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">返回</RouterLink>
</template>
</PageHeader>
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" class="detail-section" @submit.prevent="savePokemon">
<div class="field">
<label for="pokemon-id">ID</label>
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
</div>
<div class="field">
<label for="pokemon-name">名字</label>
<input id="pokemon-name" v-model="pokemonForm.name" required />
</div>
<div class="field">
<label for="pokemon-environment">喜欢的环境</label>
<TagsSelect
id="pokemon-environment"
v-model="pokemonForm.environmentId"
:options="options.environments"
:multiple="false"
allow-create
:creating="creatingSelect === 'pokemon-environment'"
placeholder="请选择"
search-placeholder="搜索喜欢的环境"
@create="createSingleOption('pokemon-environment', 'environments', $event, (value) => (pokemonForm.environmentId = value))"
/>
</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="createMultiOption('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="createMultiOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
/>
</div>
<div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink>
</div>
</form>
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载 Pokemon 编辑内容">
<div v-for="index in 5" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '88px'" />
<Skeleton variant="box" height="44px" />
</div>
</section>
</section>
</template>

View File

@@ -48,6 +48,9 @@ watch(query, loadPokemon);
<section class="page-stack">
<PageHeader title="Pokemon" subtitle="搜索宝可梦,并按特长、环境、喜欢的东西筛选。">
<template #kicker>Pokédex</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/pokemon/new">新增</RouterLink>
</template>
</PageHeader>
<FilterPanel v-if="options">

View File

@@ -50,6 +50,7 @@ onMounted(async () => {
<EditMeta :entity="recipe" />
</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">编辑</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/recipes">返回列表</RouterLink>
</template>
</PageHeader>

View File

@@ -0,0 +1,188 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { api, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
const route = useRoute();
const router = useRouter();
const options = ref<Options | null>(null);
const itemRows = ref<Item[]>([]);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
const recipeForm = ref({
itemId: '',
acquisitionMethodIds: [] as string[],
materials: [] as Array<{ itemId: string; quantity: number }>
});
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
const selectedItemName = computed(() => itemSelectOptions.value.find((item) => String(item.id) === recipeForm.value.itemId)?.name ?? '');
const pageTitle = computed(() => (isEditing.value ? `编辑 ${selectedItemName.value || '材料单'}` : '新增材料单'));
const cancelTo = computed(() => (isEditing.value ? `/recipes/${routeId.value}` : '/recipes'));
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);
}
function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
async function loadEditor() {
loading.value = true;
message.value = '';
try {
const [loadedOptions, loadedItems] = await Promise.all([api.options(), api.items({})]);
options.value = loadedOptions;
itemRows.value = loadedItems;
if (isEditing.value) {
const recipe = await api.recipeDetail(routeId.value);
recipeForm.value = {
itemId: String(recipe.item.id),
acquisitionMethodIds: recipe.acquisition_methods.map((method) => String(method.id)),
materials: recipe.materials.map((material) => ({ itemId: String(material.id), quantity: material.quantity }))
};
}
} catch (error) {
message.value = errorText(error, '加载失败');
} finally {
loading.value = false;
}
}
function addRecipeMaterial() {
recipeForm.value.materials.push({ itemId: '', quantity: 1 });
}
async function loadOptions() {
options.value = await api.options();
}
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
const cleanName = name.trim();
if (!cleanName) return;
creatingSelect.value = selectKey;
message.value = '';
try {
const created = await api.createConfig(type, { name: cleanName, subcategory: null });
await loadOptions();
const value = String(created.id);
if (!values.includes(value)) {
values.push(value);
}
} catch (error) {
message.value = errorText(error, '添加失败');
} finally {
creatingSelect.value = '';
}
}
async function saveRecipe() {
busy.value = true;
message.value = '';
try {
const payload: RecipePayload = {
itemId: Number(recipeForm.value.itemId),
acquisitionMethodIds: toIds(recipeForm.value.acquisitionMethodIds),
materials: toQuantityRows(recipeForm.value.materials)
};
const saved = isEditing.value ? await api.updateRecipe(routeId.value, payload) : await api.createRecipe(payload);
await router.push(`/recipes/${saved.id}`);
} catch (error) {
message.value = errorText(error, '保存失败');
} finally {
busy.value = false;
}
}
onMounted(() => {
void loadEditor();
});
</script>
<template>
<section class="page-stack">
<PageHeader :title="pageTitle" subtitle="维护材料单结果物品、入手方式和需要材料。">
<template #kicker>Recipe Edit</template>
<template #actions>
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">返回</RouterLink>
</template>
</PageHeader>
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" class="detail-section" @submit.prevent="saveRecipe">
<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="createMultiOption('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" aria-label="数量" 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">{{ busy ? '保存中' : '保存' }}</button>
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink>
</div>
</form>
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载材料单编辑内容">
<div v-for="index in 4" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '88px'" />
<Skeleton variant="box" height="44px" />
</div>
</section>
</section>
</template>

View File

@@ -43,6 +43,9 @@ watch(recipeQuery, loadRecipes);
<section class="page-stack">
<PageHeader title="材料单" subtitle="按分类浏览材料单和需要材料。">
<template #kicker>Recipes</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/recipes/new">新增</RouterLink>
</template>
</PageHeader>
<Tabs v-if="options" id="recipe-category" v-model="categoryId" :tabs="categoryTabs" label="分类" />