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:
@@ -17,7 +17,7 @@ import {
|
||||
iconPokemon,
|
||||
iconRecipe
|
||||
} from './icons';
|
||||
import { getCurrentLocale, onLocaleChange, setCurrentLocale } from './i18n';
|
||||
import { getCurrentLocale, loadSystemWordings, onLocaleChange, setCurrentLocale } from './i18n';
|
||||
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
@@ -87,12 +87,15 @@ async function loadLanguages() {
|
||||
if (!languages.value.some((language) => language.code === getCurrentLocale() && language.enabled)) {
|
||||
setCurrentLocale('en');
|
||||
}
|
||||
|
||||
await loadSystemWordings(getCurrentLocale());
|
||||
} catch {
|
||||
// Keep the built-in language list when the API is not ready yet.
|
||||
}
|
||||
}
|
||||
|
||||
function updateLocale(value: string) {
|
||||
async function updateLocale(value: string) {
|
||||
await loadSystemWordings(value);
|
||||
setCurrentLocale(value);
|
||||
}
|
||||
|
||||
|
||||
1017
frontend/src/i18n.ts
1017
frontend/src/i18n.ts
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,21 @@ export interface Language {
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export type SystemWordingSurface = 'frontend' | 'backend' | 'email';
|
||||
|
||||
export interface SystemWording {
|
||||
key: string;
|
||||
module: string;
|
||||
surface: SystemWordingSurface;
|
||||
description: string;
|
||||
placeholders: string[];
|
||||
value: string;
|
||||
defaultValue: string;
|
||||
missing: boolean;
|
||||
updatedAt: string | null;
|
||||
updatedBy: UserSummary | null;
|
||||
}
|
||||
|
||||
export interface NamedEntity {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -508,6 +523,10 @@ export const api = {
|
||||
sendJson<Language[]>(`/api/admin/languages/${code}`, 'PUT', payload),
|
||||
reorderLanguages: (codes: string[]) => sendJson<Language[]>('/api/admin/languages/order', 'PUT', { codes }),
|
||||
deleteLanguage: (code: string) => deleteJson(`/api/admin/languages/${code}`),
|
||||
systemWordings: (params: { locale?: string; module?: string; surface?: string; missing?: string } = {}) =>
|
||||
getJson<SystemWording[]>(`/api/admin/system-wordings${buildQuery(params)}`),
|
||||
updateSystemWording: (key: string, payload: { locale: string; value: string }) =>
|
||||
sendJson<SystemWording[]>(`/api/admin/system-wordings/${encodeURIComponent(key)}`, 'PUT', payload),
|
||||
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
|
||||
verifyEmail: (token: string) =>
|
||||
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
||||
|
||||
@@ -2409,6 +2409,50 @@ button:disabled,
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.system-wording-toolbar {
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.system-wording-toolbar__check {
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.system-wording-list li {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.system-wording-row {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.system-wording-row strong {
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.system-wording-row__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.system-wording-row__meta .config-flag {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.system-wording-row__value {
|
||||
color: var(--ink-soft);
|
||||
font-size: 14px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -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