feat(admin): add module settings to toggle trading feature

Introduce module_settings table to store global feature flags
Add admin UI to enable or disable the Trading module
Hide trading-related UI and skip data fetching when disabled
This commit is contained in:
2026-05-10 16:59:07 +08:00
parent 26bef1b749
commit 42319695e9
11 changed files with 271 additions and 85 deletions

View File

@@ -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<GlobalSearchResults>(`/api/search${buildQuery({ query: query.trim() })}`, signal),
languages: () => getJson<Language[]>('/api/languages'),
moduleSettings: () => getJson<ModuleSettings>('/api/module-settings'),
projectUpdates: (params: ProjectUpdatesParams = {}) =>
getJson<ProjectUpdates>(
`/api/project-updates${buildQuery({
@@ -1346,6 +1352,9 @@ export const api = {
aiModerationSettings: () => getJson<AiModerationSettings>('/api/admin/ai-moderation'),
updateAiModerationSettings: (payload: AiModerationSettingsPayload) =>
sendJson<AiModerationSettings>('/api/admin/ai-moderation', 'PUT', payload),
adminModuleSettings: () => getJson<ModuleSettings>('/api/admin/module-settings'),
updateAdminModuleSettings: (payload: ModuleSettings) =>
sendJson<ModuleSettings>('/api/admin/module-settings', 'PUT', payload),
rateLimitSettings: () => getJson<RateLimitSettings>('/api/admin/rate-limits'),
updateRateLimitSettings: (payload: RateLimitSettingsPayload) =>
sendJson<RateLimitSettings>('/api/admin/rate-limits', 'PUT', payload),

View File

@@ -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<SystemWording[]>([]);
const aiModerationSettings = ref<AiModerationSettings | null>(null);
const rateLimitSettings = ref<RateLimitSettings | null>(null);
const dataToolsSummary = ref<DataToolsSummary | null>(null);
const moduleSettings = ref<ModuleSettings>({ tradingEnabled: true });
const currentUser = ref<AuthUser | null>(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<void>) {
}
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') }}
</button>
</div>
<form class="admin-inline-form" @submit.prevent="saveModuleSettings">
<div class="detail-section__header">
<h3 class="section-subtitle">{{ t('pages.admin.moduleSettings') }}</h3>
<button v-if="can('admin.config.update')" type="submit" class="ui-button ui-button--primary ui-button--small" :disabled="busy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ t('common.save') }}
</button>
</div>
<div class="check-row">
<label>
<input v-model="moduleSettings.tradingEnabled" type="checkbox" :disabled="busy || !can('admin.config.update')" />
{{ t('pages.admin.tradingModule') }}
</label>
</div>
</form>
<Tabs id="admin-config-type" v-model="activeConfigTab" :tabs="configTabs" :label="t('pages.admin.configType')" />
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
<ReorderableList
@@ -2203,7 +2233,7 @@ onMounted(() => {
{{ item.name }}
<span v-if="item.opposite" class="config-flag">{{ t('pages.admin.opposite') }}: {{ item.opposite.name }}</span>
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
<span v-if="item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span>
<span v-if="tradingModuleEnabled && item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span>
</span>
<span v-if="item.description" class="meta-line">{{ item.description }}</span>
<span class="row-actions">
@@ -3054,7 +3084,7 @@ onMounted(() => {
{{ t('pages.admin.hasItemDrop') }}
</label>
</div>
<div v-if="selectedConfig.supportsTrading" class="check-row">
<div v-if="selectedConfig.supportsTrading && tradingModuleEnabled" class="check-row">
<label>
<input v-model="configForm.hasTrading" type="checkbox" />
{{ t('pages.admin.hasTrading') }}

View File

@@ -13,7 +13,7 @@ import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconAdd, iconBack, iconEdit, iconHabitat, iconItem } from '../icons';
import { applySeo, resolvedSeoHead, resolveSeo } from '../seo';
import { api, type AuthUser, type ItemDetail } from '../services/api';
import { api, type AuthUser, type ItemDetail, type ModuleSettings } from '../services/api';
import ItemEdit from './ItemEdit.vue';
const route = useRoute();
@@ -73,6 +73,20 @@ const possibleTagEvidenceSections = computed(() => [
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] }
]);
const { data: moduleSettings } = useAsyncData<ModuleSettings>(
'module-settings',
async () => {
try {
return await api.moduleSettings();
} catch {
return { tradingEnabled: true };
}
},
{ default: () => ({ tradingEnabled: true }) }
);
const tradingModuleEnabled = computed(() => moduleSettings.value?.tradingEnabled ?? true);
const { data: initialItem } = useAsyncData<ItemDetail | null>(
`item-detail:${String(route.name)}:${activeItemRouteId() ?? 'none'}:${locale.value}`,
async () => {
@@ -347,7 +361,7 @@ watch(initialItem, applyInitialItem, { immediate: true });
</div>
</div>
<DetailSection :title="t('pages.items.possibleTags')">
<DetailSection v-if="tradingModuleEnabled" :title="t('pages.items.possibleTags')">
<div class="possible-tags-grid">
<div v-for="section in possibleTagSections" :key="section.key" class="possible-tags-group">
<h3 class="section-subtitle">{{ section.title }}</h3>

View File

@@ -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<PokemonDetail | null>(
{ default: () => null }
);
const { data: moduleSettings } = useAsyncData<ModuleSettings>(
'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<HabitatRow[]>(() => {
});
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;
}

View File

@@ -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 = [];
}
}