feat(i18n): implement dynamic system wording management
Add database schema and API endpoints for system wording keys and values Replace hardcoded translations in frontend and backend with dynamic messages Add System Wordings management interface to Admin view
This commit is contained in:
@@ -24,7 +24,7 @@ import {
|
||||
iconTranslate,
|
||||
type AppIcon
|
||||
} from '../icons';
|
||||
import { defaultLocale, getCurrentLocale, setCurrentLocale } from '../i18n';
|
||||
import { defaultLocale, getCurrentLocale, loadSystemWordings, setCurrentLocale } from '../i18n';
|
||||
import {
|
||||
api,
|
||||
type AuthUser,
|
||||
@@ -37,15 +37,18 @@ import {
|
||||
type Pokemon,
|
||||
type Recipe,
|
||||
type Skill,
|
||||
type SystemWording,
|
||||
type SystemWordingSurface,
|
||||
type TranslationMap
|
||||
} from '../services/api';
|
||||
|
||||
type AdminTab = 'config' | 'languages' | 'checklist' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||
type AdminTab = 'config' | 'languages' | 'wordings' | 'checklist' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
|
||||
|
||||
const adminTabIcons: Record<AdminTab, AppIcon> = {
|
||||
config: iconAdmin,
|
||||
languages: iconTranslate,
|
||||
wordings: iconTranslate,
|
||||
checklist: iconChecklist,
|
||||
pokemon: iconPokemon,
|
||||
items: iconItem,
|
||||
@@ -58,6 +61,7 @@ const { locale, t } = useI18n();
|
||||
const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
|
||||
{ key: 'config', label: t('pages.admin.config') },
|
||||
{ key: 'languages', label: t('pages.admin.languages') },
|
||||
{ key: 'wordings', label: t('pages.admin.wordings') },
|
||||
{ key: 'checklist', label: t('pages.admin.checklist') },
|
||||
{ key: 'pokemon', label: 'Pokemon' },
|
||||
{ key: 'items', label: t('pages.items.title') },
|
||||
@@ -86,6 +90,7 @@ const pokemonRows = ref<Pokemon[]>([]);
|
||||
const itemRows = ref<Item[]>([]);
|
||||
const recipeRows = ref<Recipe[]>([]);
|
||||
const habitatRows = ref<Habitat[]>([]);
|
||||
const wordingRows = ref<SystemWording[]>([]);
|
||||
const currentUser = ref<AuthUser | null>(null);
|
||||
const busy = ref(false);
|
||||
const contentLoading = ref(false);
|
||||
@@ -93,10 +98,16 @@ const message = ref('');
|
||||
const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false });
|
||||
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
|
||||
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
|
||||
const wordingForm = ref({ key: '', locale: defaultLocale, value: '', defaultValue: '', placeholders: [] as string[] });
|
||||
const editingLanguageCode = ref('');
|
||||
const configModalOpen = ref(false);
|
||||
const checklistModalOpen = ref(false);
|
||||
const languageModalOpen = ref(false);
|
||||
const wordingModalOpen = ref(false);
|
||||
const wordingLocale = ref(getCurrentLocale());
|
||||
const wordingModule = ref('');
|
||||
const wordingSurface = ref<SystemWordingSurface | ''>('');
|
||||
const wordingMissingOnly = ref(false);
|
||||
|
||||
const selectedConfig = computed(() => configTypes.value.find((item) => item.key === activeConfigType.value) ?? configTypes.value[0]);
|
||||
const configTabs = computed<TabOption[]>(() => configTypes.value.map((item) => ({ value: item.key, label: item.label })));
|
||||
@@ -140,6 +151,24 @@ const configModalTitle = computed(() =>
|
||||
);
|
||||
const checklistModalTitle = computed(() => (checklistForm.value.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask')));
|
||||
const languageModalTitle = computed(() => (editingLanguageCode.value ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage')));
|
||||
const wordingModalTitle = computed(() => t('pages.admin.editWording'));
|
||||
const wordingLocaleOptions = computed(() =>
|
||||
languageRows.value.length
|
||||
? languageRows.value
|
||||
: [
|
||||
{ code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 },
|
||||
{ code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 }
|
||||
]
|
||||
);
|
||||
const wordingModules = computed(() => [...new Set(wordingRows.value.map((item) => item.module))].sort((a, b) => a.localeCompare(b)));
|
||||
const filteredWordingRows = computed(() =>
|
||||
wordingRows.value.filter((item) => {
|
||||
if (wordingModule.value && item.module !== wordingModule.value) return false;
|
||||
if (wordingSurface.value && item.surface !== wordingSurface.value) return false;
|
||||
if (wordingMissingOnly.value && !item.missing) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
const checklistKey = (item: DailyChecklistItem) => item.id;
|
||||
const checklistLabel = (item: DailyChecklistItem) => item.title;
|
||||
const languageKey = (item: Language) => item.code;
|
||||
@@ -197,6 +226,10 @@ function resetLanguageForm() {
|
||||
editingLanguageCode.value = '';
|
||||
}
|
||||
|
||||
function resetWordingForm() {
|
||||
wordingForm.value = { key: '', locale: wordingLocale.value || defaultLocale, value: '', defaultValue: '', placeholders: [] };
|
||||
}
|
||||
|
||||
function openNewConfig() {
|
||||
resetConfigForm();
|
||||
configModalOpen.value = true;
|
||||
@@ -237,6 +270,11 @@ function closeLanguageModal() {
|
||||
resetLanguageForm();
|
||||
}
|
||||
|
||||
function closeWordingModal() {
|
||||
wordingModalOpen.value = false;
|
||||
resetWordingForm();
|
||||
}
|
||||
|
||||
function editLanguage(item: Language) {
|
||||
editingLanguageCode.value = item.code;
|
||||
languageForm.value = {
|
||||
@@ -249,6 +287,17 @@ function editLanguage(item: Language) {
|
||||
languageModalOpen.value = true;
|
||||
}
|
||||
|
||||
function editWording(item: SystemWording) {
|
||||
wordingForm.value = {
|
||||
key: item.key,
|
||||
locale: wordingLocale.value || defaultLocale,
|
||||
value: item.value,
|
||||
defaultValue: item.defaultValue,
|
||||
placeholders: item.placeholders
|
||||
};
|
||||
wordingModalOpen.value = true;
|
||||
}
|
||||
|
||||
function updateConfigTranslation(localeCode: string, value: string) {
|
||||
const nextTranslations: TranslationMap = { ...configForm.value.translations };
|
||||
const nextFields = { ...(nextTranslations[localeCode] ?? {}) };
|
||||
@@ -456,6 +505,7 @@ async function saveLanguage() {
|
||||
? await api.updateLanguage(editingLanguageCode.value, payload)
|
||||
: await api.createLanguage(payload);
|
||||
closeLanguageModal();
|
||||
await loadSystemWordings(getCurrentLocale(), true);
|
||||
setCurrentLocale(getCurrentLocale());
|
||||
});
|
||||
}
|
||||
@@ -484,6 +534,32 @@ async function loadHabitats() {
|
||||
habitatRows.value = await api.habitats();
|
||||
}
|
||||
|
||||
async function loadWordings() {
|
||||
await loadLanguages();
|
||||
if (!wordingLocaleOptions.value.some((language) => language.code === wordingLocale.value)) {
|
||||
wordingLocale.value = defaultLocale;
|
||||
}
|
||||
wordingRows.value = await api.systemWordings({ locale: wordingLocale.value });
|
||||
}
|
||||
|
||||
async function reloadWordings() {
|
||||
await run(loadWordings);
|
||||
}
|
||||
|
||||
async function saveWording() {
|
||||
await run(async () => {
|
||||
wordingRows.value = await api.updateSystemWording(wordingForm.value.key, {
|
||||
locale: wordingForm.value.locale,
|
||||
value: wordingForm.value.value
|
||||
});
|
||||
await loadSystemWordings(wordingForm.value.locale, true);
|
||||
if (wordingForm.value.locale === getCurrentLocale()) {
|
||||
setCurrentLocale(getCurrentLocale());
|
||||
}
|
||||
closeWordingModal();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCurrentTab(showSkeleton = false) {
|
||||
if (showSkeleton) {
|
||||
contentLoading.value = true;
|
||||
@@ -492,6 +568,7 @@ async function loadCurrentTab(showSkeleton = false) {
|
||||
try {
|
||||
if (activeTab.value === 'config') await loadConfig();
|
||||
if (activeTab.value === 'languages') await loadLanguages();
|
||||
if (activeTab.value === 'wordings') await loadWordings();
|
||||
if (activeTab.value === 'checklist') await loadChecklist();
|
||||
if (activeTab.value === 'pokemon') await loadPokemon();
|
||||
if (activeTab.value === 'items') await loadItems();
|
||||
@@ -739,6 +816,64 @@ onMounted(() => {
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'wordings'" class="detail-section">
|
||||
<div class="detail-section__header">
|
||||
<h2>{{ t('pages.admin.wordings') }}</h2>
|
||||
</div>
|
||||
<div class="toolbar system-wording-toolbar">
|
||||
<div class="field">
|
||||
<label for="wording-locale">{{ t('pages.admin.wordingLocale') }}</label>
|
||||
<select id="wording-locale" v-model="wordingLocale" :disabled="busy" @change="reloadWordings">
|
||||
<option v-for="language in wordingLocaleOptions" :key="language.code" :value="language.code">
|
||||
{{ language.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="wording-module">{{ t('pages.admin.wordingModule') }}</label>
|
||||
<select id="wording-module" v-model="wordingModule" :disabled="busy">
|
||||
<option value="">{{ t('pages.admin.allModules') }}</option>
|
||||
<option v-for="module in wordingModules" :key="module" :value="module">{{ module }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="wording-surface">{{ t('pages.admin.wordingSurface') }}</label>
|
||||
<select id="wording-surface" v-model="wordingSurface" :disabled="busy">
|
||||
<option value="">{{ t('pages.admin.allSurfaces') }}</option>
|
||||
<option value="frontend">{{ t('pages.admin.surfaceFrontend') }}</option>
|
||||
<option value="backend">{{ t('pages.admin.surfaceBackend') }}</option>
|
||||
<option value="email">{{ t('pages.admin.surfaceEmail') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="check-row system-wording-toolbar__check">
|
||||
<label>
|
||||
<input v-model="wordingMissingOnly" type="checkbox" :disabled="busy" />
|
||||
{{ t('pages.admin.wordingMissingOnly') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<ul v-if="filteredWordingRows.length" class="row-list system-wording-list">
|
||||
<li v-for="item in filteredWordingRows" :key="item.key">
|
||||
<span class="system-wording-row">
|
||||
<strong>{{ item.key }}</strong>
|
||||
<span class="system-wording-row__meta">
|
||||
<span class="config-flag">{{ item.module }}</span>
|
||||
<span class="config-flag">{{ t(`pages.admin.surface${item.surface.charAt(0).toUpperCase()}${item.surface.slice(1)}`) }}</span>
|
||||
<span v-if="item.missing" class="config-flag">{{ t('pages.admin.missingTranslation') }}</span>
|
||||
</span>
|
||||
<span class="system-wording-row__value">{{ item.value || item.defaultValue }}</span>
|
||||
</span>
|
||||
<span class="row-actions">
|
||||
<button type="button" :disabled="busy" @click="editWording(item)">
|
||||
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
|
||||
<h2>{{ t('pages.admin.pokemonList') }}</h2>
|
||||
<ReorderableList
|
||||
@@ -932,5 +1067,39 @@ onMounted(() => {
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<Modal v-if="wordingModalOpen" :title="wordingModalTitle" :close-label="t('common.close')" size="wide" @close="closeWordingModal">
|
||||
<form id="admin-wording-form" class="modal-edit-form" @submit.prevent="saveWording">
|
||||
<div class="field">
|
||||
<label for="wording-key">{{ t('pages.admin.wordingKey') }}</label>
|
||||
<input id="wording-key" :value="wordingForm.key" disabled />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="wording-default-value">{{ t('pages.admin.defaultValue') }}</label>
|
||||
<textarea id="wording-default-value" :value="wordingForm.defaultValue" disabled></textarea>
|
||||
</div>
|
||||
<div v-if="wordingForm.placeholders.length" class="field">
|
||||
<span class="field-label">{{ t('pages.admin.placeholders') }}</span>
|
||||
<span class="chips">
|
||||
<span v-for="placeholder in wordingForm.placeholders" :key="placeholder" class="chip">{{ placeholder }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="wording-value">{{ t('pages.admin.wordingValue') }}</label>
|
||||
<textarea id="wording-value" v-model="wordingForm.value" :required="wordingForm.locale === defaultLocale"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="submit" form="admin-wording-form" class="link-button" :disabled="busy">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="closeWordingModal">
|
||||
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
</template>
|
||||
</Modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user