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);
+