diff --git a/AGENTS.md b/AGENTS.md index 1c8dd07..6c2b42f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,11 +223,19 @@ This project is developed from WSL, but runtime validation is done through Docke Agent workflow: -* Run when practical: +* Run once when practical: * `pnpm lint` * `pnpm typecheck` +* Do not repeatedly retry failed validation commands. +* If lint/typecheck fails because of missing dependencies, native optional bindings, WSL, network, registry, filesystem permission, or other environment/setup issues: + + * Do not repeatedly run `pnpm install`, force reinstall dependencies, delete `node_modules`, or otherwise spend time repairing the local environment unless the user explicitly asks. + * Report the exact command attempted and the key error lines. + * Tell the user what command to run locally or in Docker. + * Wait for the user to paste `docker compose up --build`, lint, typecheck, or runtime output before fixing follow-up errors. + * Do NOT run tests in WSL. * Do NOT require local test execution before finishing a task. * The user will run `docker compose up --build`. @@ -246,7 +254,7 @@ A task is complete ONLY IF: * Minimal diff, with no unrelated changes. * No UI leaks of internal info. * Code is readable and concise. -* Passes lint/typecheck when practical. +* Lint/typecheck has been run once when practical, or the environment blocker and user-side validation command have been reported. * Docker runtime issues are handled from user-provided `docker compose up --build` output. --- diff --git a/DESIGN.md b/DESIGN.md index 90a6505..eb5cfb0 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -12,6 +12,7 @@ - 全局顶部导航栏承载语言切换、通知、User Profile 和登录 / 退出等账号操作;除 User Profile 可展示用户名外,顶部操作以图标按钮呈现。 - 全局顶部导航栏提供全站搜索。搜索结果按内容类型分组展示,覆盖 Pokemon、Habitats、Items、Ancient Artifacts、Recipes、Daily CheckList、公开可见的 Life Post 和公开用户 Profile;结果跳转到对应公开详情页、页面锚点或 `/profile/:id`。 - 管理入口用于维护全局配置、语言、系统文案、列表排序和每日 CheckList。 +- 管理员可在管理入口启用或禁用模块级功能开关;Trading 模块关闭时保留 `has_trading` 和 Trading 观察数据,但前台与编辑界面不展示 Trading 相关功能。 ## 技术栈 @@ -459,10 +460,25 @@ - 名称 - 是否有掉落物:`has_item_drop` - 是否支持 Trading:`has_trading` +- `has_trading` 是特长自身能力配置,不作为模块显示开关;Trading 模块关闭时该字段和已有 Trading 观察数据保留。 - 已移除 `subcategory` 字段。 - 当特长允许掉落物时,Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。 - 当 Pokemon 选择了至少一个支持 Trading 的特长时,Pokemon 详情页可直接维护该 Pokemon 对物品的 Trading 偏好观察。 +### 模块设置 + +- 模块设置存储在 `module_settings`,当前包含: + - `trading_enabled`:控制 Trading 相关界面和推断展示,默认启用。 +- 管理端查看模块设置需要 `admin.config.read`,更新模块设置需要 `admin.config.update`。 +- 关闭 Trading 模块时: + - 不删除、不清空 `skills.has_trading`。 + - 不删除、不清空 `pokemon_trading_items`。 + - Pokemon 详情页不展示 Trading 区块或管理 Trading 入口。 + - Pokemon 创建 / 编辑流程不展示 Trading 相关编辑能力。 + - Item 详情页不展示基于 Trading 观察推断的 Possible Tags 和证据区块。 + - 管理端 Skill 配置不展示 `has_trading` 勾选项或列表标记。 +- 重新启用 Trading 模块后,已有配置和观察数据恢复参与界面展示与推断。 + ### Pokemon Types - 名称 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 827564d..215a7b1 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -219,6 +219,18 @@ INSERT INTO rate_limit_settings (id) VALUES (true) ON CONFLICT (id) DO NOTHING; +CREATE TABLE IF NOT EXISTS module_settings ( + id boolean PRIMARY KEY DEFAULT true CHECK (id = true), + trading_enabled boolean NOT NULL DEFAULT true, + updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +INSERT INTO module_settings (id) +VALUES (true) +ON CONFLICT (id) DO NOTHING; + INSERT INTO permissions (key, name, description, category, system_permission) VALUES ('admin.access', 'Access admin', 'Open the management area.', 'Admin', true), diff --git a/backend/src/queries.ts b/backend/src/queries.ts index abe0915..063e8a2 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -569,6 +569,10 @@ type LanguagePayload = { sortOrder: number; }; +export type ModuleSettings = { + tradingEnabled: boolean; +}; + type ValidationError = Error & { statusCode: number }; type EditAction = 'create' | 'update' | 'delete'; type EditChange = { @@ -1072,6 +1076,44 @@ function systemListJsonSql(expression: string, options: readonly SystemListOptio return `json_build_object('id', ${systemListIdSql(expression, options)}, 'key', ${expression}, 'name', ${systemListNameSql(expression, options, locale)})`; } +function publicModuleSettings(row: { tradingEnabled: boolean } | null): ModuleSettings { + return { + tradingEnabled: row?.tradingEnabled ?? true + }; +} + +async function moduleSettingsForClient(client: DbClient): Promise { + const result = await client.query<{ tradingEnabled: boolean }>( + 'SELECT trading_enabled AS "tradingEnabled" FROM module_settings WHERE id = true' + ); + return publicModuleSettings(result.rows[0] ?? null); +} + +export async function getModuleSettings(): Promise { + const row = await queryOne<{ tradingEnabled: boolean }>( + 'SELECT trading_enabled AS "tradingEnabled" FROM module_settings WHERE id = true' + ); + return publicModuleSettings(row); +} + +export async function updateModuleSettings(payload: Record, userId: number): Promise { + const current = await getModuleSettings(); + const tradingEnabled = typeof payload.tradingEnabled === 'boolean' ? payload.tradingEnabled : current.tradingEnabled; + const row = await queryOne<{ tradingEnabled: boolean }>( + ` + INSERT INTO module_settings (id, trading_enabled, updated_by_user_id, updated_at) + VALUES (true, $1, $2, now()) + ON CONFLICT (id) DO UPDATE SET + trading_enabled = EXCLUDED.trading_enabled, + updated_by_user_id = EXCLUDED.updated_by_user_id, + updated_at = now() + RETURNING trading_enabled AS "tradingEnabled" + `, + [tradingEnabled, userId] + ); + return publicModuleSettings(row); +} + function gameVersionOptions(locale: string): Promise> { const name = localizedName('game-versions', 'gv', locale); return query(`SELECT gv.id, ${name} AS name, gv.change_log AS "changeLog" FROM game_versions gv ORDER BY ${orderByEntity('gv')}`); @@ -2793,7 +2835,8 @@ export async function getOptions(locale = defaultLocale) { acquisitionMethods, maps, gameVersions, - dishFlavors + dishFlavors, + moduleSettings ] = await Promise.all([ optionSelect('pokemon_types', 'pokemon-types', locale), skillOptions(locale), @@ -2802,7 +2845,8 @@ export async function getOptions(locale = defaultLocale) { optionSelect('acquisition_methods', 'acquisition-methods', locale), optionSelect('maps', 'maps', locale), gameVersionOptions(locale), - optionSelect('dish_flavors', 'dish-flavors', locale) + optionSelect('dish_flavors', 'dish-flavors', locale), + getModuleSettings() ]); return { @@ -2817,7 +2861,8 @@ export async function getOptions(locale = defaultLocale) { itemTags: favoriteThings, maps, gameVersions, - dishFlavors + dishFlavors, + moduleSettings }; } @@ -5569,10 +5614,14 @@ export async function updateConfig( const translations = cleanTranslations(payload.translations, ['name']); const description = definition.hasDescription ? cleanOptionalText(payload.description) : ''; const oppositeId = definition.oppositeColumn ? cleanOptionalPositiveInteger(payload.oppositeId) : null; - const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; - const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false; - const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : ''; const before = await getConfigById(type, id, defaultLocale); + const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; + const hasTrading = definition.hasTrading + ? Object.hasOwn(payload, 'hasTrading') + ? Boolean(payload.hasTrading) + : Boolean((before as { hasTrading?: boolean } | null)?.hasTrading) + : false; + const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : ''; if (oppositeId === id) { throw validationError('server.validation.invalidField'); @@ -5775,6 +5824,7 @@ export async function getPokemon(id: number, locale = defaultLocale) { const relatedSkillName = localizedName('skills', 'related_skill', locale); const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale); const tradingItemName = localizedName('items', 'trading_item', locale); + const moduleSettings = await getModuleSettings(); const [habitats, itemDrops, favoriteThingItems, tradingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([ query( @@ -5825,28 +5875,30 @@ export async function getPokemon(id: number, locale = defaultLocale) { `, [id] ), - query( - ` - SELECT - ti.item_id AS "itemId", - ti.preference, - trading_item.id, - ${tradingItemName} AS name, - ${uploadedImageJson('trading_item.image_path')} AS image - FROM pokemon_trading_items ti - JOIN items trading_item ON trading_item.id = ti.item_id - WHERE ti.pokemon_id = $1 - AND EXISTS ( - SELECT 1 - FROM pokemon_skills ps - JOIN skills trading_skill ON trading_skill.id = ps.skill_id - WHERE ps.pokemon_id = ti.pokemon_id - AND trading_skill.has_trading = true - ) - ORDER BY ti.preference DESC, ${orderByEntity('trading_item')} - `, - [id] - ), + moduleSettings.tradingEnabled + ? query( + ` + SELECT + ti.item_id AS "itemId", + ti.preference, + trading_item.id, + ${tradingItemName} AS name, + ${uploadedImageJson('trading_item.image_path')} AS image + FROM pokemon_trading_items ti + JOIN items trading_item ON trading_item.id = ti.item_id + WHERE ti.pokemon_id = $1 + AND EXISTS ( + SELECT 1 + FROM pokemon_skills ps + JOIN skills trading_skill ON trading_skill.id = ps.skill_id + WHERE ps.pokemon_id = ti.pokemon_id + AND trading_skill.has_trading = true + ) + ORDER BY ti.preference DESC, ${orderByEntity('trading_item')} + `, + [id] + ) + : Promise.resolve([]), query( ` WITH current_pokemon AS ( @@ -6088,12 +6140,14 @@ async function normalizePokemonDataIdentity(payload: PokemonPayload): Promise { +async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload, replaceTrading: boolean): Promise { await client.query('DELETE FROM pokemon_skill_item_drops WHERE pokemon_id = $1', [pokemonId]); await client.query('DELETE FROM pokemon_pokemon_types WHERE pokemon_id = $1', [pokemonId]); await client.query('DELETE FROM pokemon_skills WHERE pokemon_id = $1', [pokemonId]); await client.query('DELETE FROM pokemon_favorite_things WHERE pokemon_id = $1', [pokemonId]); - await client.query('DELETE FROM pokemon_trading_items WHERE pokemon_id = $1', [pokemonId]); + if (replaceTrading) { + await client.query('DELETE FROM pokemon_trading_items WHERE pokemon_id = $1', [pokemonId]); + } for (const [index, typeId] of payload.typeIds.entries()) { await client.query('INSERT INTO pokemon_pokemon_types (pokemon_id, type_id, slot_order) VALUES ($1, $2, $3)', [ @@ -6114,10 +6168,10 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl ]); } - const tradingSkillResult = payload.skillIds.length + const tradingSkillResult = replaceTrading && payload.skillIds.length ? await client.query<{ id: number }>('SELECT id FROM skills WHERE id = ANY($1::integer[]) AND has_trading = true', [payload.skillIds]) : { rows: [] }; - const hasTradingSkill = tradingSkillResult.rows.length > 0; + const hasTradingSkill = replaceTrading && tradingSkillResult.rows.length > 0; if (hasTradingSkill) { for (const tradingItem of payload.tradingItems) { @@ -6215,7 +6269,8 @@ export async function createPokemon(payload: Record, userId: nu ] ); await linkEntityImageUpload(client, 'pokemon', pokemonId, cleanPayload.image?.path, cleanPayload.name); - await replacePokemonRelations(client, pokemonId, cleanPayload); + const moduleSettings = await moduleSettingsForClient(client); + await replacePokemonRelations(client, pokemonId, cleanPayload, moduleSettings.tradingEnabled); await replaceEntityTranslations(client, 'pokemon', pokemonId, cleanPayload.translations, ['name', 'details', 'genus']); await recordEditLog(client, 'pokemon', pokemonId, 'create', userId); return pokemonId; @@ -6291,7 +6346,8 @@ export async function updatePokemon(id: number, payload: Record return false; } await linkEntityImageUpload(client, 'pokemon', id, cleanPayload.image?.path, cleanPayload.name); - await replacePokemonRelations(client, id, cleanPayload); + const moduleSettings = await moduleSettingsForClient(client); + await replacePokemonRelations(client, id, cleanPayload, moduleSettings.tradingEnabled); await replaceEntityTranslations(client, 'pokemon', id, cleanPayload.translations, ['name', 'details', 'genus']); const changes = before ? await pokemonEditChanges(client, before as unknown as PokemonChangeSource, cleanPayload) : []; await recordEditLog(client, 'pokemon', id, 'update', userId, changes); @@ -6701,6 +6757,7 @@ export async function getItem(id: number, locale = defaultLocale) { const skillName = localizedName('skills', 's', locale); const possibleTagName = localizedName('favorite-things', 'possible_tag', locale); const evidenceTagName = localizedName('favorite-things', 'evidence_tag', locale); + const moduleSettings = await getModuleSettings(); const [ acquisitionMethods, @@ -6841,44 +6898,48 @@ export async function getItem(id: number, locale = defaultLocale) { `, [id] ), - query( - ` - SELECT possible_tag.id, ${possibleTagName} AS name - FROM favorite_things possible_tag - ORDER BY ${orderByEntity('possible_tag')} - ` - ), - query( - ` - SELECT - json_build_object( - 'id', p.id, - 'displayId', p.display_id, - 'name', ${pokemonName}, - 'isEventItem', p.is_event_item, - 'image', ${pokemonImageJson('p')} - ) AS pokemon, - pti.preference, - COALESCE(( - SELECT json_agg(json_build_object('id', evidence_tag.id, 'name', ${evidenceTagName}) ORDER BY ${orderByEntity('evidence_tag')}) - FROM pokemon_favorite_things pft - JOIN favorite_things evidence_tag ON evidence_tag.id = pft.favorite_thing_id - WHERE pft.pokemon_id = p.id - ), '[]'::json) AS tags - FROM pokemon_trading_items pti - JOIN pokemon p ON p.id = pti.pokemon_id - WHERE pti.item_id = $1 - AND EXISTS ( - SELECT 1 - FROM pokemon_skills ps - JOIN skills trading_skill ON trading_skill.id = ps.skill_id - WHERE ps.pokemon_id = p.id - AND trading_skill.has_trading = true - ) - ORDER BY pti.preference DESC, p.display_id, p.id - `, - [id] - ), + moduleSettings.tradingEnabled + ? query( + ` + SELECT possible_tag.id, ${possibleTagName} AS name + FROM favorite_things possible_tag + ORDER BY ${orderByEntity('possible_tag')} + ` + ) + : Promise.resolve([]), + moduleSettings.tradingEnabled + ? query( + ` + SELECT + json_build_object( + 'id', p.id, + 'displayId', p.display_id, + 'name', ${pokemonName}, + 'isEventItem', p.is_event_item, + 'image', ${pokemonImageJson('p')} + ) AS pokemon, + pti.preference, + COALESCE(( + SELECT json_agg(json_build_object('id', evidence_tag.id, 'name', ${evidenceTagName}) ORDER BY ${orderByEntity('evidence_tag')}) + FROM pokemon_favorite_things pft + JOIN favorite_things evidence_tag ON evidence_tag.id = pft.favorite_thing_id + WHERE pft.pokemon_id = p.id + ), '[]'::json) AS tags + FROM pokemon_trading_items pti + JOIN pokemon p ON p.id = pti.pokemon_id + WHERE pti.item_id = $1 + AND EXISTS ( + SELECT 1 + FROM pokemon_skills ps + JOIN skills trading_skill ON trading_skill.id = ps.skill_id + WHERE ps.pokemon_id = p.id + AND trading_skill.has_trading = true + ) + ORDER BY pti.preference DESC, p.display_id, p.id + `, + [id] + ) + : Promise.resolve([]), getEditHistory('items', id), listEntityImageUploads('items', id) ]); diff --git a/backend/src/server.ts b/backend/src/server.ts index b483219..ba1ad9a 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -89,6 +89,7 @@ import { getItem, listDish, getLifePost, + getModuleSettings, getOptions, getPokemon, getPublicUserProfile, @@ -149,6 +150,7 @@ import { updateItem, updateLanguage, updateLifePost, + updateModuleSettings, updatePokemon, updateRecipe, updateAdminThreadChannel, @@ -1309,6 +1311,8 @@ app.get('/api/languages', async () => listLanguages()); app.get('/api/system-wordings', async (request) => getSystemWordings(requestLocale(request))); +app.get('/api/module-settings', async () => getModuleSettings()); + app.get('/api/options', async (request) => getOptions(requestLocale(request))); app.get('/api/project-updates', async (request) => @@ -2385,6 +2389,19 @@ app.put('/api/admin/ai-moderation', async (request, reply) => { return updateAiModerationSettings(request.body as Record, user.id); }); +app.get('/api/admin/module-settings', async (request, reply) => { + const user = await requirePermission(request, reply, 'admin.config.read'); + return user ? getModuleSettings() : undefined; +}); + +app.put('/api/admin/module-settings', async (request, reply) => { + const user = await requirePermissionWithRateLimits(request, reply, 'admin.config.update', 'adminWrite'); + if (!user) { + return; + } + return updateModuleSettings(request.body as Record, user.id); +}); + app.get('/api/admin/rate-limits', async (request, reply) => { const user = await requirePermission(request, reply, 'admin.rate-limits.read'); return user ? getRateLimitSettings() : undefined; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 40c4210..1aee876 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -67,6 +67,10 @@ export interface SystemWording { updatedBy: UserSummary | null; } +export interface ModuleSettings { + tradingEnabled: boolean; +} + export interface NamedEntity { id: number; name: string; @@ -768,6 +772,7 @@ export interface Options { maps: NamedEntity[]; gameVersions: GameVersion[]; dishFlavors: NamedEntity[]; + moduleSettings: ModuleSettings; } export interface AuthUser { @@ -1325,6 +1330,7 @@ export const api = { globalSearch: (query: string, signal?: AbortSignal) => getJson(`/api/search${buildQuery({ query: query.trim() })}`, signal), languages: () => getJson('/api/languages'), + moduleSettings: () => getJson('/api/module-settings'), projectUpdates: (params: ProjectUpdatesParams = {}) => getJson( `/api/project-updates${buildQuery({ @@ -1346,6 +1352,9 @@ export const api = { aiModerationSettings: () => getJson('/api/admin/ai-moderation'), updateAiModerationSettings: (payload: AiModerationSettingsPayload) => sendJson('/api/admin/ai-moderation', 'PUT', payload), + adminModuleSettings: () => getJson('/api/admin/module-settings'), + updateAdminModuleSettings: (payload: ModuleSettings) => + sendJson('/api/admin/module-settings', 'PUT', payload), rateLimitSettings: () => getJson('/api/admin/rate-limits'), updateRateLimitSettings: (payload: RateLimitSettingsPayload) => sendJson('/api/admin/rate-limits', 'PUT', payload), diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 6ed7fdb..e941204 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -55,6 +55,7 @@ import { type Habitat, type Item, type Language, + type ModuleSettings, type NamedEntity, type Permission, type PermissionPayload, @@ -245,6 +246,7 @@ const wordingRows = ref([]); const aiModerationSettings = ref(null); const rateLimitSettings = ref(null); const dataToolsSummary = ref(null); +const moduleSettings = ref({ tradingEnabled: true }); const currentUser = ref(null); const busy = ref(false); const contentLoading = ref(false); @@ -375,6 +377,7 @@ const canEdit = computed(() => can('admin.access')); const canUseViewAs = computed(() => currentUser.value?.roles.some((role) => role.key === 'owner') === true && !currentUser.value?.viewAs); const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value)); const canSetLanguageDefault = computed(() => languageForm.value.code === 'en'); +const tradingModuleEnabled = computed(() => moduleSettings.value.tradingEnabled); const configModalTitle = computed(() => configForm.value.id ? t('pages.admin.editConfig', { name: selectedConfig.value.label }) : t('pages.admin.newConfig', { name: selectedConfig.value.label }) ); @@ -616,7 +619,7 @@ async function run(action: () => Promise) { } async function loadConfig() { - await loadLanguages(); + await Promise.all([loadLanguages(), loadModuleSettings()]); configRows.value = (await api.config(activeConfigType.value)) as EditableConfig[]; } @@ -624,6 +627,10 @@ async function loadLanguages() { languageRows.value = await api.adminLanguages(); } +async function loadModuleSettings() { + moduleSettings.value = await api.adminModuleSettings(); +} + function resetConfigForm() { configForm.value = { id: 0, name: '', description: '', oppositeId: '', translations: {}, hasItemDrop: false, hasTrading: false, changeLog: '' }; } @@ -1139,7 +1146,7 @@ async function saveConfig() { description: selectedConfig.value.supportsDescription ? configForm.value.description : undefined, oppositeId: selectedConfig.value.supportsOpposite && configForm.value.oppositeId ? Number(configForm.value.oppositeId) : null, hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined, - hasTrading: selectedConfig.value.supportsTrading ? configForm.value.hasTrading : undefined, + hasTrading: selectedConfig.value.supportsTrading && tradingModuleEnabled.value ? configForm.value.hasTrading : undefined, changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined }; @@ -1154,6 +1161,14 @@ async function saveConfig() { }); } +async function saveModuleSettings() { + await run(async () => { + moduleSettings.value = await api.updateAdminModuleSettings({ + tradingEnabled: moduleSettings.value.tradingEnabled + }); + }); +} + async function loadChecklist() { await loadLanguages(); checklistRows.value = await api.dailyChecklist(); @@ -2183,6 +2198,21 @@ onMounted(() => { {{ t('common.new') }} +
+
+

{{ t('pages.admin.moduleSettings') }}

+ +
+
+ +
+

{{ selectedConfig.label }}

{ {{ item.name }} {{ t('pages.admin.opposite') }}: {{ item.opposite.name }} {{ t('pages.admin.hasItemDrop') }} - {{ t('pages.admin.hasTrading') }} + {{ t('pages.admin.hasTrading') }} {{ item.description }} @@ -3054,7 +3084,7 @@ onMounted(() => { {{ t('pages.admin.hasItemDrop') }} -
+
- +

{{ section.title }}

diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index 48f2416..d3e78cf 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -16,7 +16,7 @@ import StatusMessage from '../components/StatusMessage.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue'; import { iconAdd, iconBack, iconCancel, iconCheck, iconEdit, iconHabitat, iconItem } from '../icons'; import { applySeo, resolvedSeoHead, resolveSeo } from '../seo'; -import { api, type AuthUser, type Item, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api'; +import { api, type AuthUser, type Item, type ModuleSettings, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api'; import PokemonEdit from './PokemonEdit.vue'; const route = useRoute(); @@ -59,6 +59,18 @@ const { data: initialPokemon } = useAsyncData( { default: () => null } ); +const { data: moduleSettings } = useAsyncData( + 'module-settings', + async () => { + try { + return await api.moduleSettings(); + } catch { + return { tradingEnabled: true }; + } + }, + { default: () => ({ tradingEnabled: true }) } +); + const initialPokemonLoaded = ref(false); const pokemonSeo = computed(() => pokemon.value && route.meta.editorModal !== true @@ -180,7 +192,8 @@ const habitatRows = computed(() => { }); const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.hasItemDrop) ?? []); const hasItemDropSkill = computed(() => skillDropRows.value.length > 0); -const hasTradingSkill = computed(() => pokemon.value?.skills.some((skill) => skill.hasTrading) === true); +const tradingModuleEnabled = computed(() => moduleSettings.value?.tradingEnabled ?? true); +const hasTradingSkill = computed(() => tradingModuleEnabled.value && pokemon.value?.skills.some((skill) => skill.hasTrading) === true); const tradingGroups = computed(() => ({ likes: pokemon.value?.tradingItems.filter((item) => item.preference === 'like') ?? [], neutral: pokemon.value?.tradingItems.filter((item) => item.preference === 'neutral') ?? [] @@ -583,7 +596,7 @@ watch([filteredTradingItems, tradingDraftPreferenceByItemId], () => { watch(tradingActiveItemIndex, scrollActiveTradingItemIntoView); async function openTradingModal() { - if (!pokemon.value) { + if (!pokemon.value || !hasTradingSkill.value) { return; } diff --git a/frontend/src/views/PokemonEdit.vue b/frontend/src/views/PokemonEdit.vue index f55c7bb..81bf411 100644 --- a/frontend/src/views/PokemonEdit.vue +++ b/frontend/src/views/PokemonEdit.vue @@ -145,7 +145,9 @@ const selectedUploadImage = computed(() => (selectedPokemonImage.value?.source = const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true); const canFetchPokemon = computed(() => currentUser.value?.permissions.includes('pokemon.fetch') === true); const canUploadImage = computed(() => currentUser.value?.permissions.includes('pokemon.upload') === true); -const hasTradingSkill = computed(() => pokemonForm.value.skillIds.some((skillId) => skillSupportsTrading(skillId))); +const tradingModuleEnabled = computed(() => options.value?.moduleSettings.tradingEnabled ?? true); +const selectedTradingSkill = computed(() => pokemonForm.value.skillIds.some((skillId) => skillSupportsTrading(skillId))); +const hasTradingSkill = computed(() => tradingModuleEnabled.value && selectedTradingSkill.value); function toIds(values: string[]): number[] { return values.map(Number).filter((item) => Number.isInteger(item) && item > 0); @@ -227,7 +229,7 @@ function skillSupportsTrading(skillId: string) { function syncSkillFeatures() { syncSkillItemDrops(); - if (!hasTradingSkill.value) { + if (tradingModuleEnabled.value && !hasTradingSkill.value) { pokemonForm.value.tradingItems = []; } } diff --git a/system-wordings.ts b/system-wordings.ts index f382a55..d278e01 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -1142,6 +1142,8 @@ export const systemWordingMessages = { languages: 'Languages', newConfig: 'New {name}', editConfig: 'Edit {name}', + moduleSettings: 'Module settings', + tradingModule: 'Trading', hasItemDrop: 'Has item drop', hasTrading: 'Has trading', opposite: 'Opposite', @@ -2572,6 +2574,8 @@ export const systemWordingMessages = { languages: '语言', newConfig: '新增{name}', editConfig: '编辑{name}', + moduleSettings: '模块设置', + tradingModule: 'Trading', hasItemDrop: '有掉落物', hasTrading: '有 Trading', opposite: '反义词',