From 3e8265e0c84d131017e8cc9a564d236189ebdbe2 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Thu, 30 Apr 2026 15:12:32 +0800 Subject: [PATCH] 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. --- frontend/src/router/index.ts | 20 +- frontend/src/styles/main.css | 7 + frontend/src/views/AdminView.vue | 756 +++++---------------------- frontend/src/views/HabitatDetail.vue | 1 + frontend/src/views/HabitatEdit.vue | 261 +++++++++ frontend/src/views/HabitatList.vue | 3 + frontend/src/views/ItemDetail.vue | 1 + frontend/src/views/ItemEdit.vue | 229 ++++++++ frontend/src/views/ItemsList.vue | 3 + frontend/src/views/PokemonDetail.vue | 1 + frontend/src/views/PokemonEdit.vue | 206 ++++++++ frontend/src/views/PokemonList.vue | 3 + frontend/src/views/RecipeDetail.vue | 1 + frontend/src/views/RecipeEdit.vue | 188 +++++++ frontend/src/views/RecipeList.vue | 3 + 15 files changed, 1048 insertions(+), 635 deletions(-) create mode 100644 frontend/src/views/HabitatEdit.vue create mode 100644 frontend/src/views/ItemEdit.vue create mode 100644 frontend/src/views/PokemonEdit.vue create mode 100644 frontend/src/views/RecipeEdit.vue diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1cde678..293ef68 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 } }; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 45c1421..576cbdb 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -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; diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index a806b27..fdf9ac4 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -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('config'); const activeConfigType = ref('skills'); -const options = ref(null); const configRows = ref([]); const pokemonRows = ref([]); const itemRows = ref([]); @@ -67,50 +48,26 @@ const currentUser = ref(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(() => 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) { @@ -119,40 +76,40 @@ async function run(action: () => Promise) { 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 { - 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 = ''; - } +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: 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); + } + + resetConfigForm(); + await loadConfig(); + }); } 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(); - - 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(() => { diff --git a/frontend/src/views/HabitatDetail.vue b/frontend/src/views/HabitatDetail.vue index e369cdf..712353b 100644 --- a/frontend/src/views/HabitatDetail.vue +++ b/frontend/src/views/HabitatDetail.vue @@ -133,6 +133,7 @@ onMounted(async () => { diff --git a/frontend/src/views/HabitatEdit.vue b/frontend/src/views/HabitatEdit.vue new file mode 100644 index 0000000..d577bed --- /dev/null +++ b/frontend/src/views/HabitatEdit.vue @@ -0,0 +1,261 @@ + + + diff --git a/frontend/src/views/HabitatList.vue b/frontend/src/views/HabitatList.vue index 67477a9..6fa023d 100644 --- a/frontend/src/views/HabitatList.vue +++ b/frontend/src/views/HabitatList.vue @@ -21,6 +21,9 @@ onMounted(async () => {
+
diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index 8e13a5e..d870554 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -88,6 +88,7 @@ onMounted(async () => { diff --git a/frontend/src/views/ItemEdit.vue b/frontend/src/views/ItemEdit.vue new file mode 100644 index 0000000..abbda18 --- /dev/null +++ b/frontend/src/views/ItemEdit.vue @@ -0,0 +1,229 @@ + + + diff --git a/frontend/src/views/ItemsList.vue b/frontend/src/views/ItemsList.vue index 567ecde..e7cf589 100644 --- a/frontend/src/views/ItemsList.vue +++ b/frontend/src/views/ItemsList.vue @@ -52,6 +52,9 @@ watch(itemQuery, loadItems);
+ diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index 338839a..12ac337 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -144,6 +144,7 @@ onMounted(async () => { diff --git a/frontend/src/views/PokemonEdit.vue b/frontend/src/views/PokemonEdit.vue new file mode 100644 index 0000000..f5dc8da --- /dev/null +++ b/frontend/src/views/PokemonEdit.vue @@ -0,0 +1,206 @@ + + + diff --git a/frontend/src/views/PokemonList.vue b/frontend/src/views/PokemonList.vue index 4937df3..58a651c 100644 --- a/frontend/src/views/PokemonList.vue +++ b/frontend/src/views/PokemonList.vue @@ -48,6 +48,9 @@ watch(query, loadPokemon);
+ diff --git a/frontend/src/views/RecipeDetail.vue b/frontend/src/views/RecipeDetail.vue index 3b8e22a..c86c37b 100644 --- a/frontend/src/views/RecipeDetail.vue +++ b/frontend/src/views/RecipeDetail.vue @@ -50,6 +50,7 @@ onMounted(async () => { diff --git a/frontend/src/views/RecipeEdit.vue b/frontend/src/views/RecipeEdit.vue new file mode 100644 index 0000000..33619e8 --- /dev/null +++ b/frontend/src/views/RecipeEdit.vue @@ -0,0 +1,188 @@ + + + diff --git a/frontend/src/views/RecipeList.vue b/frontend/src/views/RecipeList.vue index fdaf8e7..18489b0 100644 --- a/frontend/src/views/RecipeList.vue +++ b/frontend/src/views/RecipeList.vue @@ -43,6 +43,9 @@ watch(recipeQuery, loadRecipes);
+