From 27100fbd2251bd6f5778842c3a329fc83ab887fa Mon Sep 17 00:00:00 2001 From: xiaomai Date: Fri, 1 May 2026 12:04:49 +0800 Subject: [PATCH] feat(i18n): add full-stack internationalization support Add languages and entity_translations tables to database schema Implement localized queries and translation management in backend Integrate frontend i18n and add translation UI components --- backend/db/schema.sql | 46 + backend/src/auth.ts | 123 +- backend/src/queries.ts | 878 +++++-- backend/src/server.ts | 169 +- frontend/index.html | 2 +- frontend/package.json | 2 + frontend/src/App.vue | 64 +- frontend/src/components/AppShell.vue | 96 +- frontend/src/components/EditHistoryPanel.vue | 99 +- frontend/src/components/EditMeta.vue | 7 +- frontend/src/components/FilterPanel.vue | 8 +- frontend/src/components/ReorderableList.vue | 211 ++ frontend/src/components/TagsSelect.vue | 25 +- frontend/src/components/TranslationFields.vue | 76 + frontend/src/i18n.ts | 565 +++++ frontend/src/main.ts | 3 +- frontend/src/services/api.ts | 52 +- frontend/src/styles/main.css | 145 +- frontend/src/views/AdminView.vue | 527 ++-- frontend/src/views/DailyChecklistView.vue | 10 +- frontend/src/views/HabitatDetail.vue | 47 +- frontend/src/views/HabitatEdit.vue | 99 +- frontend/src/views/HabitatList.vue | 8 +- frontend/src/views/ItemDetail.vue | 44 +- frontend/src/views/ItemEdit.vue | 78 +- frontend/src/views/ItemsList.vue | 26 +- frontend/src/views/LoginView.vue | 16 +- frontend/src/views/PokemonDetail.vue | 63 +- frontend/src/views/PokemonEdit.vue | 71 +- frontend/src/views/PokemonList.vue | 45 +- frontend/src/views/RecipeDetail.vue | 14 +- frontend/src/views/RecipeEdit.vue | 46 +- frontend/src/views/RecipeList.vue | 28 +- frontend/src/views/RegisterView.vue | 18 +- frontend/src/views/VerifyEmailView.vue | 12 +- pnpm-lock.yaml | 2198 +++++++++++++++++ 36 files changed, 5055 insertions(+), 866 deletions(-) create mode 100644 frontend/src/components/ReorderableList.vue create mode 100644 frontend/src/components/TranslationFields.vue create mode 100644 frontend/src/i18n.ts create mode 100644 pnpm-lock.yaml diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 887996f..8ef8e1f 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -3,6 +3,52 @@ CREATE TABLE IF NOT EXISTS environments ( name text NOT NULL UNIQUE ); +CREATE TABLE IF NOT EXISTS languages ( + code text PRIMARY KEY, + name text NOT NULL, + enabled boolean NOT NULL DEFAULT true, + is_default boolean NOT NULL DEFAULT false, + sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), + CHECK (code ~ '^[a-z]{2}(-[A-Z]{2})?$'), + CHECK (length(name) BETWEEN 1 AND 80) +); + +CREATE UNIQUE INDEX IF NOT EXISTS languages_single_default_idx + ON languages (is_default) + WHERE is_default = true; + +INSERT INTO languages (code, name, enabled, is_default, sort_order) +VALUES + ('en', 'English', true, true, 10), + ('zh-CN', '简体中文', true, false, 20) +ON CONFLICT (code) DO NOTHING; + +CREATE TABLE IF NOT EXISTS entity_translations ( + entity_type text NOT NULL CHECK ( + entity_type IN ( + 'pokemon', + 'skills', + 'environments', + 'favorite-things', + 'item-categories', + 'item-usages', + 'acquisition-methods', + 'items', + 'maps', + 'habitats', + 'daily-checklist-items' + ) + ), + entity_id integer NOT NULL, + locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE, + field_name text NOT NULL CHECK (field_name IN ('name', 'title')), + value text NOT NULL, + PRIMARY KEY (entity_type, entity_id, locale, field_name) +); + +CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx + ON entity_translations (entity_type, entity_id, field_name, locale); + CREATE TABLE IF NOT EXISTS users ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, email text NOT NULL UNIQUE, diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 0c11be5..eed2da5 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -7,6 +7,7 @@ const scrypt = promisify(scryptCallback); const passwordKeyLength = 64; const verificationTokenHours = 24; const sessionDays = 30; +const defaultLocale = 'en'; type DbClient = PoolClient; @@ -23,6 +24,22 @@ type LoginUserRow = UserRow & { password_hash: string; }; +type AuthMessageKey = + | 'emailRequired' + | 'invalidEmail' + | 'displayNameRequired' + | 'displayNameLength' + | 'passwordLength' + | 'invalidToken' + | 'emailAlreadyRegistered' + | 'checkVerificationEmail' + | 'emailVerified' + | 'invalidCredentials' + | 'verifyEmailFirst' + | 'emailSubject' + | 'emailHtml' + | 'emailText'; + export type AuthUser = { id: number; email: string; @@ -36,43 +53,87 @@ function statusError(message: string, statusCode: number): StatusError { return error; } -function cleanEmail(value: unknown): string { +function authMessage(locale: string, key: AuthMessageKey, params: Record = {}): string { + const messages: Record> = { + en: { + emailRequired: 'Email is required', + invalidEmail: 'Email format is invalid', + displayNameRequired: 'Display name is required', + displayNameLength: 'Display name must be 1 to 40 characters', + passwordLength: 'Password must be at least 8 characters', + invalidToken: 'The verification link is invalid or expired', + emailAlreadyRegistered: 'This email is already registered', + checkVerificationEmail: 'Please check your verification email', + emailVerified: 'Email verified', + invalidCredentials: 'Email or password is incorrect', + verifyEmailFirst: 'Please complete email verification first', + emailSubject: 'Verify your Pokopia Wiki email', + emailHtml: + '

Open the link below to verify your email:

Verify email

The link expires in {hours} hours.

', + emailText: 'Open this link to verify your Pokopia Wiki email: {url}\nThe link expires in {hours} hours.' + }, + 'zh-CN': { + emailRequired: '请输入邮箱', + invalidEmail: '邮箱格式不正确', + displayNameRequired: '请输入显示名', + displayNameLength: '显示名长度需为 1 到 40 个字符', + passwordLength: '密码至少需要 8 个字符', + invalidToken: '验证链接无效或已过期', + emailAlreadyRegistered: '该邮箱已注册', + checkVerificationEmail: '请查收验证邮件', + emailVerified: '邮箱已验证', + invalidCredentials: '邮箱或密码不正确', + verifyEmailFirst: '请先完成邮箱验证', + emailSubject: '验证你的 Pokopia Wiki 邮箱', + emailHtml: '

请点击下面的链接完成邮箱验证:

验证邮箱

链接将在 {hours} 小时后失效。

', + emailText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n链接将在 {hours} 小时后失效。' + } + }; + + let message = messages[locale]?.[key] ?? messages[defaultLocale][key]; + for (const [paramKey, paramValue] of Object.entries(params)) { + message = message.replaceAll(`{${paramKey}}`, String(paramValue)); + } + return message; +} + +function cleanEmail(value: unknown, locale: string): string { if (typeof value !== 'string') { - throw statusError('请输入邮箱', 400); + throw statusError(authMessage(locale, 'emailRequired'), 400); } const email = value.trim().toLowerCase(); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { - throw statusError('邮箱格式不正确', 400); + throw statusError(authMessage(locale, 'invalidEmail'), 400); } return email; } -function cleanDisplayName(value: unknown): string { +function cleanDisplayName(value: unknown, locale: string): string { if (typeof value !== 'string') { - throw statusError('请输入显示名', 400); + throw statusError(authMessage(locale, 'displayNameRequired'), 400); } const displayName = value.trim(); if (displayName.length < 1 || displayName.length > 40) { - throw statusError('显示名长度需为 1 到 40 个字符', 400); + throw statusError(authMessage(locale, 'displayNameLength'), 400); } return displayName; } -function cleanPassword(value: unknown): string { +function cleanPassword(value: unknown, locale: string): string { if (typeof value !== 'string' || value.length < 8) { - throw statusError('密码至少需要 8 个字符', 400); + throw statusError(authMessage(locale, 'passwordLength'), 400); } return value; } -function cleanToken(value: unknown): string { +function cleanToken(value: unknown, locale: string): string { if (typeof value !== 'string' || value.trim().length < 32) { - throw statusError('验证链接无效或已过期', 400); + throw statusError(authMessage(locale, 'invalidToken'), 400); } return value.trim(); @@ -155,7 +216,7 @@ function buildVerificationUrl(token: string): string { return url.toString(); } -async function sendVerificationEmail(email: string, token: string): Promise { +async function sendVerificationEmail(email: string, token: string, locale: string): Promise { const { apiKey, from } = getEmailConfig(); const verificationUrl = buildVerificationUrl(token); const response = await fetch('https://api.resend.com/emails', { @@ -167,9 +228,9 @@ async function sendVerificationEmail(email: string, token: string): Promise请点击下面的链接完成邮箱验证:

验证邮箱

链接将在 ${verificationTokenHours} 小时后失效。

`, - text: `请打开以下链接完成 Pokopia Wiki 邮箱验证:${verificationUrl}\n链接将在 ${verificationTokenHours} 小时后失效。` + subject: authMessage(locale, 'emailSubject'), + html: authMessage(locale, 'emailHtml', { url: verificationUrl, hours: verificationTokenHours }), + text: authMessage(locale, 'emailText', { url: verificationUrl, hours: verificationTokenHours }) }) }); @@ -179,10 +240,10 @@ async function sendVerificationEmail(email: string, token: string): Promise) { - const email = cleanEmail(payload.email); - const displayName = cleanDisplayName(payload.displayName); - const password = cleanPassword(payload.password); +export async function registerUser(payload: Record, locale = defaultLocale) { + const email = cleanEmail(payload.email, locale); + const displayName = cleanDisplayName(payload.displayName, locale); + const password = cleanPassword(payload.password, locale); const passwordHash = await hashPassword(password); const verificationToken = createPlainToken(); const verificationTokenHash = hashToken(verificationToken); @@ -195,7 +256,7 @@ export async function registerUser(payload: Record) { ); if (existingUser?.email_verified_at) { - throw statusError('该邮箱已注册', 409); + throw statusError(authMessage(locale, 'emailAlreadyRegistered'), 409); } const user = existingUser @@ -233,12 +294,12 @@ export async function registerUser(payload: Record) { ); }); - await sendVerificationEmail(email, verificationToken); - return { message: '请查收验证邮件' }; + await sendVerificationEmail(email, verificationToken, locale); + return { message: authMessage(locale, 'checkVerificationEmail') }; } -export async function verifyEmail(payload: Record) { - const token = cleanToken(payload.token); +export async function verifyEmail(payload: Record, locale = defaultLocale) { + const token = cleanToken(payload.token, locale); const tokenHash = hashToken(token); return withTransaction(async (client) => { @@ -256,7 +317,7 @@ export async function verifyEmail(payload: Record) { ); if (!tokenRow) { - throw statusError('验证链接无效或已过期', 400); + throw statusError(authMessage(locale, 'invalidToken'), 400); } const user = await clientQueryOne( @@ -271,31 +332,31 @@ export async function verifyEmail(payload: Record) { ); if (!user) { - throw statusError('验证链接无效或已过期', 400); + throw statusError(authMessage(locale, 'invalidToken'), 400); } await client.query('UPDATE email_verification_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [ user.id ]); - return { message: '邮箱已验证', user: toPublicUser(user) }; + return { message: authMessage(locale, 'emailVerified'), user: toPublicUser(user) }; }); } -export async function loginUser(payload: Record) { - const email = cleanEmail(payload.email); - const password = cleanPassword(payload.password); +export async function loginUser(payload: Record, locale = defaultLocale) { + const email = cleanEmail(payload.email, locale); + const password = cleanPassword(payload.password, locale); const user = await queryOne( 'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1', [email] ); if (!user || !(await verifyPassword(password, user.password_hash))) { - throw statusError('邮箱或密码不正确', 401); + throw statusError(authMessage(locale, 'invalidCredentials'), 401); } if (!user.email_verified_at) { - throw statusError('请先完成邮箱验证', 403); + throw statusError(authMessage(locale, 'verifyEmailFirst'), 403); } const sessionToken = createPlainToken(); diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 6946ebb..32f60cb 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -8,6 +8,21 @@ type QueryParams = Record; type DbClient = PoolClient; +type TranslationField = 'name' | 'title'; +type TranslationInput = Record>>; +type EntityType = + | 'pokemon' + | 'skills' + | 'environments' + | 'favorite-things' + | 'item-categories' + | 'item-usages' + | 'acquisition-methods' + | 'items' + | 'maps' + | 'habitats' + | 'daily-checklist-items'; + type ConfigType = | 'skills' | 'environments' @@ -19,7 +34,7 @@ type ConfigType = type ConfigDefinition = { table: string; - order: string; + entityType: EntityType; hasItemDrop?: boolean; }; @@ -36,6 +51,7 @@ type SkillItemDrop = { type PokemonPayload = { id: number; name: string; + translations: TranslationInput; environmentId: number; skillIds: number[]; favoriteThingIds: number[]; @@ -44,6 +60,7 @@ type PokemonPayload = { type ItemPayload = { name: string; + translations: TranslationInput; categoryId: number; usageId: number | null; dyeable: boolean; @@ -62,10 +79,12 @@ type RecipePayload = { type DailyChecklistPayload = { title: string; + translations: TranslationInput; }; type HabitatPayload = { name: string; + translations: TranslationInput; recipeItems: IdQuantity[]; pokemonAppearances: Array<{ pokemonId: number; @@ -76,6 +95,14 @@ type HabitatPayload = { }>; }; +type LanguagePayload = { + code: string; + name: string; + enabled: boolean; + isDefault: boolean; + sortOrder: number; +}; + type ValidationError = Error & { statusCode: number }; type EditAction = 'create' | 'update' | 'delete'; type EditChange = { @@ -117,27 +144,170 @@ type RecipeChangeSource = { const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const weathers = ['晴天', '阴天', '雨天']; +const defaultLocale = 'en'; +const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/; const configDefinitions: Record = { - skills: { table: 'skills', order: 'name', hasItemDrop: true }, - environments: { table: 'environments', order: 'name' }, - 'favorite-things': { table: 'favorite_things', order: 'name' }, - 'item-categories': { table: 'item_categories', order: 'name' }, - 'item-usages': { table: 'item_usages', order: 'name' }, - 'acquisition-methods': { table: 'acquisition_methods', order: 'name' }, - maps: { table: 'maps', order: 'name' } + skills: { table: 'skills', entityType: 'skills', hasItemDrop: true }, + environments: { table: 'environments', entityType: 'environments' }, + 'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' }, + 'item-categories': { table: 'item_categories', entityType: 'item-categories' }, + 'item-usages': { table: 'item_usages', entityType: 'item-usages' }, + 'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' }, + maps: { table: 'maps', entityType: 'maps' } }; function asString(value: QueryValue): string | undefined { return Array.isArray(value) ? value[0] : value; } -function optionSelect(tableName: string): Promise> { - return query(`SELECT id, name FROM ${tableName} ORDER BY name`); +export function cleanLocale(value: unknown): string { + const locale = typeof value === 'string' ? value.trim() : ''; + return localePattern.test(locale) ? locale : defaultLocale; } -function skillOptions(): Promise> { - return query('SELECT id, name, has_item_drop AS "hasItemDrop" FROM skills ORDER BY name'); +function sqlLiteral(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function localizedField( + entityType: EntityType, + entityIdExpression: string, + baseExpression: string, + fieldName: TranslationField, + locale: string +): string { + const entity = sqlLiteral(entityType); + const field = sqlLiteral(fieldName); + const requestedLocale = sqlLiteral(cleanLocale(locale)); + const defaultLocaleSql = sqlLiteral(defaultLocale); + + return ` + COALESCE( + ( + SELECT et.value + FROM entity_translations et + WHERE et.entity_type = ${entity} + AND et.entity_id = ${entityIdExpression} + AND et.locale = ${requestedLocale} + AND et.field_name = ${field} + ), + ( + SELECT et.value + FROM entity_translations et + WHERE et.entity_type = ${entity} + AND et.entity_id = ${entityIdExpression} + AND et.locale = ${defaultLocaleSql} + AND et.field_name = ${field} + ), + ${baseExpression} + ) + `; +} + +function localizedName(entityType: EntityType, entityAlias: string, locale: string): string { + return localizedField(entityType, `${entityAlias}.id`, `${entityAlias}.name`, 'name', locale); +} + +function translationsSelect(entityType: EntityType, entityIdExpression: string): string { + return ` + COALESCE(( + SELECT jsonb_object_agg(locale, fields) + FROM ( + SELECT locale, jsonb_object_agg(field_name, value) AS fields + FROM entity_translations + WHERE entity_type = ${sqlLiteral(entityType)} + AND entity_id = ${entityIdExpression} + GROUP BY locale + ) translation_rows + ), '{}'::jsonb) + `; +} + +function cleanTranslations(value: unknown, allowedFields: TranslationField[]): TranslationInput { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + + const translations: TranslationInput = {}; + const allowedFieldSet = new Set(allowedFields); + + for (const [locale, fields] of Object.entries(value as Record)) { + if (!localePattern.test(locale) || locale === defaultLocale || !fields || typeof fields !== 'object' || Array.isArray(fields)) { + continue; + } + + const cleanFields: Partial> = {}; + for (const [fieldName, fieldValue] of Object.entries(fields as Record)) { + if (!allowedFieldSet.has(fieldName as TranslationField) || typeof fieldValue !== 'string') { + continue; + } + + const cleanValue = fieldValue.trim(); + if (cleanValue !== '') { + cleanFields[fieldName as TranslationField] = cleanValue; + } + } + + if (Object.keys(cleanFields).length > 0) { + translations[locale] = cleanFields; + } + } + + return translations; +} + +async function replaceEntityTranslations( + client: DbClient, + entityType: EntityType, + entityId: number, + translations: TranslationInput, + fields: TranslationField[] +): Promise { + await client.query( + ` + DELETE FROM entity_translations + WHERE entity_type = $1 + AND entity_id = $2 + AND field_name = ANY($3::text[]) + `, + [entityType, entityId, fields] + ); + + for (const [locale, translatedFields] of Object.entries(translations)) { + for (const fieldName of fields) { + const value = translatedFields[fieldName]; + if (typeof value !== 'string' || value.trim() === '') { + continue; + } + + await client.query( + ` + INSERT INTO entity_translations (entity_type, entity_id, locale, field_name, value) + VALUES ($1, $2, $3, $4, $5) + `, + [entityType, entityId, locale, fieldName, value.trim()] + ); + } + } +} + +async function deleteEntityTranslations(client: DbClient, entityType: EntityType, entityId: number): Promise { + await client.query('DELETE FROM entity_translations WHERE entity_type = $1 AND entity_id = $2', [entityType, entityId]); +} + +function optionSelect( + tableName: string, + entityType: EntityType, + locale: string +): Promise> { + const name = localizedName(entityType, 'o', locale); + return query(`SELECT o.id, ${name} AS name FROM ${tableName} o ORDER BY ${name}`); +} + +function skillOptions(locale: string): Promise> { + const name = localizedName('skills', 's', locale); + return query(`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop" FROM skills s ORDER BY ${name}`); } function auditSelect(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string { @@ -162,15 +332,16 @@ function auditJoins(entityAlias: string, createdAlias = 'created_user', updatedA `; } -function configOrder(definition: ConfigDefinition): string { - return definition.order - .split(', ') - .map((column) => `c.${column}`) - .join(', '); +function configOrder(definition: ConfigDefinition, locale: string): string { + return localizedName(definition.entityType, 'c', locale); } -function configSelect(definition: ConfigDefinition): string { - return definition.hasItemDrop ? 'c.id, c.name, c.has_item_drop AS "hasItemDrop"' : 'c.id, c.name'; +function configSelect(definition: ConfigDefinition, locale: string): string { + const name = localizedName(definition.entityType, 'c', locale); + const translations = translationsSelect(definition.entityType, 'c.id'); + return definition.hasItemDrop + ? `c.id, ${name} AS name, c.name AS "baseName", ${translations} AS translations, c.has_item_drop AS "hasItemDrop"` + : `c.id, ${name} AS name, c.name AS "baseName", ${translations} AS translations`; } function validationError(message: string): ValidationError { @@ -187,7 +358,7 @@ function requirePositiveInteger(value: unknown, message: string): number { return numberValue; } -function cleanName(value: unknown, message = '请输入名称'): string { +function cleanName(value: unknown, message = 'Name is required'): string { if (typeof value !== 'string' || value.trim() === '') { throw validationError(message); } @@ -259,9 +430,165 @@ async function recordEditLog( ); } +function cleanLanguagePayload(payload: Record, requireCode: boolean): LanguagePayload { + const code = typeof payload.code === 'string' ? payload.code.trim() : ''; + if (requireCode && !localePattern.test(code)) { + throw validationError('Language code is invalid'); + } + + const sortOrder = Number(payload.sortOrder ?? 0); + + return { + code, + name: cleanName(payload.name, 'Language name is required'), + enabled: payload.enabled !== false, + isDefault: Boolean(payload.isDefault), + sortOrder: Number.isInteger(sortOrder) && sortOrder >= 0 ? sortOrder : 0 + }; +} + +function requireLanguageCode(value: unknown): string { + const code = typeof value === 'string' ? value.trim() : ''; + if (!localePattern.test(code)) { + throw validationError('Language code is invalid'); + } + return code; +} + +export async function listLanguages(includeDisabled = false) { + return query( + ` + SELECT code, name, enabled, is_default AS "isDefault", sort_order AS "sortOrder" + FROM languages + ${includeDisabled ? '' : 'WHERE enabled = true'} + ORDER BY sort_order, name + ` + ); +} + +export async function createLanguage(payload: Record) { + const cleanPayload = cleanLanguagePayload(payload, true); + if (cleanPayload.isDefault && cleanPayload.code !== defaultLocale) { + throw validationError('Default language must be English'); + } + if (!cleanPayload.enabled && cleanPayload.isDefault) { + throw validationError('Default language must be enabled'); + } + + await withTransaction(async (client) => { + if (cleanPayload.isDefault) { + await client.query('UPDATE languages SET is_default = false'); + } + + await client.query( + ` + INSERT INTO languages (code, name, enabled, is_default, sort_order) + VALUES ($1, $2, $3, $4, $5) + `, + [cleanPayload.code, cleanPayload.name, cleanPayload.enabled, cleanPayload.isDefault, cleanPayload.sortOrder] + ); + }); + + return listLanguages(true); +} + +export async function updateLanguage(code: string, payload: Record) { + const locale = requireLanguageCode(code); + const cleanPayload = cleanLanguagePayload({ ...payload, code: locale }, false); + if (cleanPayload.isDefault && locale !== defaultLocale) { + throw validationError('Default language must be English'); + } + if (!cleanPayload.enabled && cleanPayload.isDefault) { + throw validationError('Default language must be enabled'); + } + + await withTransaction(async (client) => { + const current = await client.query<{ isDefault: boolean }>( + 'SELECT is_default AS "isDefault" FROM languages WHERE code = $1', + [locale] + ); + + if (current.rowCount === 0) { + throw validationError('Language not found'); + } + + if (!cleanPayload.enabled && current.rows[0].isDefault) { + throw validationError('Default language must be enabled'); + } + + if (current.rows[0].isDefault && !cleanPayload.isDefault) { + throw validationError('A default language is required'); + } + + if (cleanPayload.isDefault) { + await client.query('UPDATE languages SET is_default = false WHERE code <> $1', [locale]); + } + + await client.query( + ` + UPDATE languages + SET name = $1, + enabled = $2, + is_default = $3, + sort_order = $4 + WHERE code = $5 + `, + [cleanPayload.name, cleanPayload.enabled, cleanPayload.isDefault, cleanPayload.sortOrder, locale] + ); + }); + + return listLanguages(true); +} + +export async function deleteLanguage(code: string) { + const locale = requireLanguageCode(code); + if (locale === defaultLocale) { + throw validationError('Default language cannot be deleted'); + } + + return withTransaction(async (client) => { + const result = await client.query<{ isDefault: boolean }>( + 'DELETE FROM languages WHERE code = $1 AND is_default = false RETURNING is_default AS "isDefault"', + [locale] + ); + return (result.rowCount ?? 0) > 0; + }); +} + +export async function reorderLanguages(payload: Record) { + const codes = Array.isArray(payload.codes) ? payload.codes.map(requireLanguageCode) : []; + if (codes.length === 0) { + throw validationError('Please select a language'); + } + + await withTransaction(async (client) => { + const existing = await client.query<{ code: string }>( + 'SELECT code FROM languages WHERE code = ANY($1::text[])', + [codes] + ); + + if (existing.rowCount !== codes.length) { + throw validationError('Language does not exist'); + } + + for (const [index, code] of codes.entries()) { + await client.query( + ` + UPDATE languages + SET sort_order = $1 + WHERE code = $2 + `, + [(index + 1) * 10, code] + ); + } + }); + + return listLanguages(true); +} + function displayValue(value: string | null | undefined): string { const cleanValue = value?.trim() ?? ''; - return cleanValue === '' ? '无' : cleanValue; + return cleanValue === '' ? 'None' : cleanValue; } function pushChange(changes: EditChange[], label: string, before: string | null | undefined, after: string | null | undefined): void { @@ -274,12 +601,12 @@ function pushChange(changes: EditChange[], label: string, before: string | null } function boolValue(value: boolean): string { - return value ? '是' : '否'; + return value ? 'Yes' : 'No'; } function namedListValue(items: Array<{ name: string }> | null | undefined): string { if (!items?.length) { - return '无'; + return 'None'; } return [...new Set(items.map((item) => item.name))] @@ -289,7 +616,7 @@ function namedListValue(items: Array<{ name: string }> | null | undefined): stri function quantityListValue(items: Array<{ name: string; quantity: number }> | null | undefined): string { if (!items?.length) { - return '无'; + return 'None'; } return items @@ -302,21 +629,21 @@ function quantityListValue(items: Array<{ name: string; quantity: number }> | nu function skillDropListValue(skills: Array<{ name: string; itemDrop?: { name: string } | null }> | null | undefined): string { const rows = skills ?.filter((skill) => skill.itemDrop) - .map((skill) => `${skill.name}:${skill.itemDrop?.name}`) + .map((skill) => `${skill.name}: ${skill.itemDrop?.name}`) .sort((a, b) => a.localeCompare(b)) ?? []; - return rows.length ? rows.join(' / ') : '无'; + return rows.length ? rows.join(' / ') : 'None'; } function appearanceListValue( rows: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }> | null | undefined ): string { if (!rows?.length) { - return '无'; + return 'None'; } return rows - .map((row) => `${row.name}:${row.time_of_day} / ${row.weather} / ${row.rarity} 星 / ${row.map.name}`) + .map((row) => `${row.name}: ${row.time_of_day} / ${row.weather} / ${row.rarity} stars / ${row.map.name}`) .sort((a, b) => a.localeCompare(b)) .join(' / '); } @@ -341,7 +668,7 @@ function namesFromIds(ids: number[], namesById: Map): string { .filter((name): name is string => Boolean(name)) .sort((a, b) => a.localeCompare(b)); - return names.length ? names.join(' / ') : '无'; + return names.length ? names.join(' / ') : 'None'; } async function quantityPayloadValue(client: DbClient, rows: IdQuantity[]): Promise { @@ -371,17 +698,17 @@ async function pokemonEditChanges( .map((drop) => { const skillName = dropSkillNames.get(drop.skillId); const itemName = dropItemNames.get(drop.itemId); - return skillName && itemName ? `${skillName}:${itemName}` : null; + return skillName && itemName ? `${skillName}: ${itemName}` : null; }) .filter((drop): drop is string => drop !== null) .sort((a, b) => a.localeCompare(b)) .join(' / '); - pushChange(changes, '名字', before.name, after.name); - pushChange(changes, '喜欢的环境', before.environment.name, environmentNames.get(after.environmentId)); - pushChange(changes, '特长', namedListValue(before.skills), namesFromIds(after.skillIds, skillNames)); - pushChange(changes, '喜欢的东西', namedListValue(before.favorite_things), namesFromIds(after.favoriteThingIds, favoriteThingNames)); - pushChange(changes, '特长掉落物', skillDropListValue(before.skills), afterDrops); + pushChange(changes, 'Name', before.name, after.name); + pushChange(changes, 'Ideal Habitat', before.environment.name, environmentNames.get(after.environmentId)); + pushChange(changes, 'Specialities', namedListValue(before.skills), namesFromIds(after.skillIds, skillNames)); + pushChange(changes, 'Favourites', namedListValue(before.favorite_things), namesFromIds(after.favoriteThingIds, favoriteThingNames)); + pushChange(changes, 'Speciality drops', skillDropListValue(before.skills), afterDrops); return changes; } @@ -397,15 +724,15 @@ async function itemEditChanges( const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds); const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds); - pushChange(changes, '名称', before.name, after.name); - pushChange(changes, '分类', before.category.name, categoryNames.get(after.categoryId)); - pushChange(changes, '用途', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null); - pushChange(changes, '可染色', boolValue(before.customization.dyeable), boolValue(after.dyeable)); - pushChange(changes, '可双区染色', boolValue(before.customization.dualDyeable), boolValue(after.dualDyeable)); - pushChange(changes, '可改花纹', boolValue(before.customization.patternEditable), boolValue(after.patternEditable)); - pushChange(changes, '无材料单', boolValue(before.noRecipe), boolValue(after.noRecipe)); - pushChange(changes, '入手方式', namedListValue(before.acquisitionMethods), namesFromIds(after.acquisitionMethodIds, methodNames)); - pushChange(changes, '标签', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames)); + pushChange(changes, 'Name', before.name, after.name); + pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId)); + pushChange(changes, 'Usage', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null); + pushChange(changes, 'Dyeable', boolValue(before.customization.dyeable), boolValue(after.dyeable)); + pushChange(changes, 'Dual dyeable', boolValue(before.customization.dualDyeable), boolValue(after.dualDyeable)); + pushChange(changes, 'Pattern editable', boolValue(before.customization.patternEditable), boolValue(after.patternEditable)); + pushChange(changes, 'No recipe', boolValue(before.noRecipe), boolValue(after.noRecipe)); + pushChange(changes, 'Acquisition methods', namedListValue(before.acquisitionMethods), namesFromIds(after.acquisitionMethodIds, methodNames)); + pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames)); return changes; } @@ -422,15 +749,15 @@ async function habitatEditChanges( .map((row) => { const pokemonName = pokemonNames.get(row.pokemonId); const mapName = mapNames.get(row.mapId); - return pokemonName && mapName ? `${pokemonName}:${row.timeOfDay} / ${row.weather} / ${row.rarity} 星 / ${mapName}` : null; + return pokemonName && mapName ? `${pokemonName}: ${row.timeOfDay} / ${row.weather} / ${row.rarity} stars / ${mapName}` : null; }) .filter((row): row is string => row !== null) .sort((a, b) => a.localeCompare(b)) .join(' / '); - pushChange(changes, '名称', before.name, after.name); - pushChange(changes, '配方', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems)); - pushChange(changes, '可能出现的宝可梦', appearanceListValue(before.pokemon), afterAppearances); + pushChange(changes, 'Name', before.name, after.name); + pushChange(changes, 'Recipe', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems)); + pushChange(changes, 'Possible Pokemon', appearanceListValue(before.pokemon), afterAppearances); return changes; } @@ -444,9 +771,9 @@ async function recipeEditChanges( const itemNames = await entityNameMap(client, 'items', [after.itemId]); const methodNames = await entityNameMap(client, 'acquisition_methods', after.acquisitionMethodIds); - pushChange(changes, '物品', before.item.name, itemNames.get(after.itemId)); - pushChange(changes, '入手方式', namedListValue(before.acquisition_methods), namesFromIds(after.acquisitionMethodIds, methodNames)); - pushChange(changes, '需要材料', quantityListValue(before.materials), await quantityPayloadValue(client, after.materials)); + pushChange(changes, 'Item', before.item.name, itemNames.get(after.itemId)); + pushChange(changes, 'Acquisition methods', namedListValue(before.acquisition_methods), namesFromIds(after.acquisitionMethodIds, methodNames)); + pushChange(changes, 'Materials', quantityListValue(before.materials), await quantityPayloadValue(client, after.materials)); return changes; } @@ -472,30 +799,38 @@ function getEditHistory(entityType: string, entityId: number): Promise): DailyChecklistPayload { return { - title: cleanName(payload.title, '请输入 Task') + title: cleanName(payload.title, 'Please enter a task'), + translations: cleanTranslations(payload.translations, ['title']) }; } -export async function listDailyChecklistItems() { +export async function listDailyChecklistItems(locale = defaultLocale) { + const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale); return query( ` - SELECT c.id, c.title + SELECT c.id, ${title} AS title, ${translationsSelect('daily-checklist-items', 'c.id')} AS translations FROM daily_checklist_items c ORDER BY c.sort_order, c.id ` ); } -async function getDailyChecklistItemById(id: number) { +async function getDailyChecklistItemById(id: number, locale = defaultLocale) { + const title = localizedField('daily-checklist-items', 'c.id', 'c.title', 'title', locale); return queryOne( ` - SELECT c.id, c.title + SELECT c.id, ${title} AS title, ${translationsSelect('daily-checklist-items', 'c.id')} AS translations FROM daily_checklist_items c WHERE c.id = $1 `, @@ -553,7 +891,7 @@ async function getDailyChecklistItemById(id: number) { ); } -export async function createDailyChecklistItem(payload: Record, userId: number) { +export async function createDailyChecklistItem(payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanDailyChecklistPayload(payload); const id = await withTransaction(async (client) => { @@ -572,14 +910,20 @@ export async function createDailyChecklistItem(payload: Record, ); const createdId = result.rows[0].id; + await replaceEntityTranslations(client, 'daily-checklist-items', createdId, cleanPayload.translations, ['title']); await recordEditLog(client, 'daily-checklist-items', createdId, 'create', userId); return createdId; }); - return getDailyChecklistItemById(id); + return getDailyChecklistItemById(id, locale); } -export async function updateDailyChecklistItem(id: number, payload: Record, userId: number) { +export async function updateDailyChecklistItem( + id: number, + payload: Record, + userId: number, + locale = defaultLocale +) { const cleanPayload = cleanDailyChecklistPayload(payload); const updated = await withTransaction(async (client) => { @@ -596,17 +940,18 @@ export async function updateDailyChecklistItem(id: number, payload: Record, userId: number) { +export async function reorderDailyChecklistItems(payload: Record, userId: number, locale = defaultLocale) { const ids = cleanIds(payload.ids); if (ids.length === 0) { - throw validationError('请选择 Task'); + throw validationError('Please select a task'); } await withTransaction(async (client) => { @@ -616,7 +961,7 @@ export async function reorderDailyChecklistItems(payload: Record, userId: number) { +export async function createConfig(type: ConfigType, payload: Record, userId: number, locale = defaultLocale) { const definition = configDefinitions[type]; const name = cleanName(payload.name); + const translations = cleanTranslations(payload.translations, ['name']); const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; const id = await withTransaction(async (client) => { @@ -701,16 +1048,24 @@ export async function createConfig(type: ConfigType, payload: Record, userId: number) { +export async function updateConfig( + type: ConfigType, + id: number, + payload: Record, + userId: number, + locale = defaultLocale +) { const definition = configDefinitions[type]; const name = cleanName(payload.name); + const translations = cleanTranslations(payload.translations, ['name']); const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; const updated = await withTransaction(async (client) => { @@ -740,11 +1095,12 @@ export async function updateConfig(type: ConfigType, id: number, payload: Record await client.query('DELETE FROM pokemon_skill_item_drops WHERE skill_id = $1', [id]); } + await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']); await recordEditLog(client, type, id, 'update', userId); return true; }); - return updated ? getConfigById(type, id) : null; + return updated ? getConfigById(type, id, locale) : null; } export async function deleteConfig(type: ConfigType, id: number, userId: number) { @@ -755,12 +1111,13 @@ export async function deleteConfig(type: ConfigType, id: number, userId: number) return false; } + await deleteEntityTranslations(client, definition.entityType, id); await recordEditLog(client, type, id, 'delete', userId); return true; }); } -export async function listPokemon(paramsQuery: QueryParams) { +export async function listPokemon(paramsQuery: QueryParams, locale = defaultLocale) { const params: unknown[] = []; const conditions: string[] = []; const search = asString(paramsQuery.search)?.trim(); @@ -770,7 +1127,7 @@ export async function listPokemon(paramsQuery: QueryParams) { if (search) { params.push(`%${search}%`); - conditions.push(`p.name ILIKE $${params.length}`); + conditions.push(`${localizedName('pokemon', 'p', locale)} ILIKE $${params.length}`); } if (Number.isInteger(environmentId) && environmentId > 0) { @@ -805,42 +1162,48 @@ export async function listPokemon(paramsQuery: QueryParams) { } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - return query(`${pokemonProjection} ${whereClause} ORDER BY p.id`, params); + return query(`${pokemonProjection(locale)} ${whereClause} ORDER BY p.id`, params); } -export async function getPokemon(id: number) { - const pokemon = await queryOne(`${pokemonProjection} WHERE p.id = $1`, [id]); +export async function getPokemon(id: number, locale = defaultLocale) { + const pokemon = await queryOne(`${pokemonProjection(locale)} WHERE p.id = $1`, [id]); if (!pokemon) { return null; } + const habitatName = localizedName('habitats', 'h', locale); + const mapName = localizedName('maps', 'm', locale); + const itemName = localizedName('items', 'i', locale); + const categoryName = localizedName('item-categories', 'c', locale); + const tagName = localizedName('favorite-things', 'ft', locale); + const [habitats, itemDrops, favoriteThingItems, editHistory] = await Promise.all([ query( ` SELECT h.id, - h.name, + ${habitatName} AS name, hp.time_of_day, hp.weather, hp.rarity, - json_build_object('id', m.id, 'name', m.name) AS map + json_build_object('id', m.id, 'name', ${mapName}) AS map FROM habitat_pokemon hp JOIN habitats h ON h.id = hp.habitat_id JOIN maps m ON m.id = hp.map_id WHERE hp.pokemon_id = $1 - ORDER BY h.name, hp.rarity, m.name + ORDER BY ${habitatName}, hp.rarity, ${mapName} `, [id] ), query<{ skillId: number; id: number; name: string }>( ` - SELECT psid.skill_id AS "skillId", i.id, i.name + SELECT psid.skill_id AS "skillId", i.id, ${itemName} AS name FROM pokemon_skill_item_drops psid JOIN skills s ON s.id = psid.skill_id JOIN items i ON i.id = psid.item_id WHERE psid.pokemon_id = $1 AND s.has_item_drop = true - ORDER BY psid.skill_id, i.name + ORDER BY psid.skill_id, ${itemName} `, [id] ), @@ -848,9 +1211,9 @@ export async function getPokemon(id: number) { ` SELECT i.id, - i.name, - json_build_object('id', c.id, 'name', c.name) AS category, - json_agg(json_build_object('id', ft.id, 'name', ft.name) ORDER BY ft.name) AS tags + ${itemName} AS name, + json_build_object('id', c.id, 'name', ${categoryName}) AS category, + json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${tagName}) AS tags FROM pokemon_favorite_things pft JOIN item_favorite_things ift ON ift.favorite_thing_id = pft.favorite_thing_id JOIN favorite_things ft ON ft.id = pft.favorite_thing_id @@ -858,7 +1221,7 @@ export async function getPokemon(id: number) { JOIN item_categories c ON c.id = i.category_id WHERE pft.pokemon_id = $1 GROUP BY i.id, i.name, c.id, c.name - ORDER BY c.name, i.name + ORDER BY ${categoryName}, ${itemName} `, [id] ), @@ -887,10 +1250,10 @@ function cleanPokemonPayload(payload: Record): PokemonPayload { const skillItemDrops = new Map(); if (skillIds.length > 2) { - throw validationError('特长最多选择 2 个'); + throw validationError('Choose at most 2 specialities'); } if (favoriteThingIds.length > 6) { - throw validationError('喜欢的东西最多选择 6 个'); + throw validationError('Choose at most 6 favourites'); } if (Array.isArray(payload.skillItemDrops)) { @@ -904,7 +1267,7 @@ function cleanPokemonPayload(payload: Record): PokemonPayload { } if (!Number.isInteger(skillId) || skillId <= 0 || !selectedSkillIds.has(skillId)) { - throw validationError('掉落物品必须关联已选择的特长'); + throw validationError('Drop items must be linked to selected specialities'); } skillItemDrops.set(String(skillId), { skillId, itemId }); @@ -912,9 +1275,10 @@ function cleanPokemonPayload(payload: Record): PokemonPayload { } return { - id: requirePositiveInteger(payload.id, '请输入 Pokemon ID'), - name: cleanName(payload.name, '请输入 Pokemon 名字'), - environmentId: requirePositiveInteger(payload.environmentId, '请选择喜欢的环境'), + id: requirePositiveInteger(payload.id, 'Pokemon ID is required'), + name: cleanName(payload.name, 'Pokemon name is required'), + translations: cleanTranslations(payload.translations, ['name']), + environmentId: requirePositiveInteger(payload.environmentId, 'Ideal Habitat is required'), skillIds, favoriteThingIds, skillItemDrops: [...skillItemDrops.values()] @@ -945,7 +1309,7 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl const allowedDropSkillIds = new Set(allowedDrops.rows.map((row) => row.id)); if (payload.skillItemDrops.some((drop) => !allowedDropSkillIds.has(drop.skillId))) { - throw validationError('该特长不能配置掉落物'); + throw validationError('This speciality cannot have a drop item'); } } @@ -957,7 +1321,7 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl } } -export async function createPokemon(payload: Record, userId: number) { +export async function createPokemon(payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanPokemonPayload(payload); const id = await withTransaction(async (client) => { @@ -969,15 +1333,16 @@ export async function createPokemon(payload: Record, userId: nu [cleanPayload.id, cleanPayload.name, cleanPayload.environmentId, userId] ); await replacePokemonRelations(client, cleanPayload.id, cleanPayload); + await replaceEntityTranslations(client, 'pokemon', cleanPayload.id, cleanPayload.translations, ['name']); await recordEditLog(client, 'pokemon', cleanPayload.id, 'create', userId); return cleanPayload.id; }); - return getPokemon(id); + return getPokemon(id, locale); } -export async function updatePokemon(id: number, payload: Record, userId: number) { +export async function updatePokemon(id: number, payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanPokemonPayload({ ...payload, id }); - const before = await getPokemon(id); + const before = await getPokemon(id, locale); const updated = await withTransaction(async (client) => { const result = await client.query( @@ -992,11 +1357,12 @@ export async function updatePokemon(id: number, payload: Record return false; } await replacePokemonRelations(client, id, cleanPayload); + await replaceEntityTranslations(client, 'pokemon', id, cleanPayload.translations, ['name']); const changes = before ? await pokemonEditChanges(client, before as unknown as PokemonChangeSource, cleanPayload) : []; await recordEditLog(client, 'pokemon', id, 'update', userId, changes); return true; }); - return updated ? getPokemon(id) : null; + return updated ? getPokemon(id, locale) : null; } export async function deletePokemon(id: number, userId: number) { @@ -1006,44 +1372,56 @@ export async function deletePokemon(id: number, userId: number) { return false; } + await deleteEntityTranslations(client, 'pokemon', id); await recordEditLog(client, 'pokemon', id, 'delete', userId); return true; }); } -export async function listHabitats() { +export async function listHabitats(locale = defaultLocale) { + const habitatName = localizedName('habitats', 'h', locale); + const itemName = localizedName('items', 'i', locale); + const pokemonName = localizedName('pokemon', 'p', locale); + return query(` SELECT h.id, - h.name, + ${habitatName} AS name, + ${translationsSelect('habitats', 'h.id')} AS translations, ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')}, COALESCE(( - SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name) + SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${itemName}) FROM habitat_recipe_items hri JOIN items i ON i.id = hri.item_id WHERE hri.habitat_id = h.id ), '[]'::json) AS recipe, COALESCE(( - SELECT json_agg(DISTINCT jsonb_build_object('id', p.id, 'name', p.name)) + SELECT json_agg(DISTINCT jsonb_build_object('id', p.id, 'name', ${pokemonName})) FROM habitat_pokemon hp JOIN pokemon p ON p.id = hp.pokemon_id WHERE hp.habitat_id = h.id ), '[]'::json) AS pokemon FROM habitats h ${auditJoins('h', 'habitat_created_user', 'habitat_updated_user')} - ORDER BY h.name + ORDER BY ${habitatName} `); } -export async function getHabitat(id: number) { +export async function getHabitat(id: number, locale = defaultLocale) { + const habitatName = localizedName('habitats', 'h', locale); + const itemName = localizedName('items', 'i', locale); + const pokemonName = localizedName('pokemon', 'p', locale); + const mapName = localizedName('maps', 'm', locale); + const habitat = await queryOne( ` SELECT h.id, - h.name, + ${habitatName} AS name, + ${translationsSelect('habitats', 'h.id')} AS translations, ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')}, COALESCE(( - SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', hri.quantity) ORDER BY i.name) + SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${itemName}) FROM habitat_recipe_items hri JOIN items i ON i.id = hri.item_id WHERE hri.habitat_id = h.id @@ -1064,16 +1442,16 @@ export async function getHabitat(id: number) { ` SELECT p.id, - p.name, + ${pokemonName} AS name, hp.time_of_day, hp.weather, hp.rarity, - json_build_object('id', m.id, 'name', m.name) AS map + json_build_object('id', m.id, 'name', ${mapName}) AS map FROM habitat_pokemon hp JOIN pokemon p ON p.id = hp.pokemon_id JOIN maps m ON m.id = hp.map_id WHERE hp.habitat_id = $1 - ORDER BY hp.rarity, p.id, m.name + ORDER BY hp.rarity, p.id, ${mapName} `, [id] ), @@ -1115,7 +1493,8 @@ function cleanHabitatPayload(payload: Record): HabitatPayload { } return { - name: cleanName(payload.name, '请输入栖息地名字'), + name: cleanName(payload.name, 'Habitat name is required'), + translations: cleanTranslations(payload.translations, ['name']), recipeItems: cleanQuantities(payload.recipeItems), pokemonAppearances: [...pokemonAppearances.values()] }; @@ -1144,7 +1523,7 @@ async function replaceHabitatRelations(client: DbClient, habitatId: number, payl } } -export async function createHabitat(payload: Record, userId: number) { +export async function createHabitat(payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanHabitatPayload(payload); const id = await withTransaction(async (client) => { @@ -1158,15 +1537,16 @@ export async function createHabitat(payload: Record, userId: nu ); const habitatId = result.rows[0].id; await replaceHabitatRelations(client, habitatId, cleanPayload); + await replaceEntityTranslations(client, 'habitats', habitatId, cleanPayload.translations, ['name']); await recordEditLog(client, 'habitats', habitatId, 'create', userId); return habitatId; }); - return getHabitat(id); + return getHabitat(id, locale); } -export async function updateHabitat(id: number, payload: Record, userId: number) { +export async function updateHabitat(id: number, payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanHabitatPayload(payload); - const before = await getHabitat(id); + const before = await getHabitat(id, locale); const updated = await withTransaction(async (client) => { const result = await client.query( @@ -1177,11 +1557,12 @@ export async function updateHabitat(id: number, payload: Record return false; } await replaceHabitatRelations(client, id, cleanPayload); + await replaceEntityTranslations(client, 'habitats', id, cleanPayload.translations, ['name']); const changes = before ? await habitatEditChanges(client, before as unknown as HabitatChangeSource, cleanPayload) : []; await recordEditLog(client, 'habitats', id, 'update', userId, changes); return true; }); - return updated ? getHabitat(id) : null; + return updated ? getHabitat(id, locale) : null; } export async function deleteHabitat(id: number, userId: number) { @@ -1191,56 +1572,65 @@ export async function deleteHabitat(id: number, userId: number) { return false; } + await deleteEntityTranslations(client, 'habitats', id); await recordEditLog(client, 'habitats', id, 'delete', userId); return true; }); } -const itemProjection = ` - SELECT - i.id, - i.name, - ${auditSelect('i', 'item_created_user', 'item_updated_user')}, - json_build_object('id', c.id, 'name', c.name) AS category, - CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', u.name) END AS usage, - json_build_object( - 'dyeable', i.dyeable, - 'dualDyeable', i.dual_dyeable, - 'patternEditable', i.pattern_editable - ) AS customization, - i.no_recipe AS "noRecipe", - COALESCE(( - SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY t.name) - FROM item_favorite_things ift - JOIN favorite_things t ON t.id = ift.favorite_thing_id - WHERE ift.item_id = i.id - ), '[]'::json) AS tags, - CASE - WHEN item_recipe.id IS NULL THEN NULL - ELSE json_build_object( - 'id', item_recipe.id, - 'createdAt', item_recipe.created_at, - 'updatedAt', item_recipe.updated_at, - 'createdBy', CASE - WHEN recipe_created_user.id IS NULL THEN NULL - ELSE json_build_object('id', recipe_created_user.id, 'displayName', recipe_created_user.display_name) - END, - 'updatedBy', CASE - WHEN recipe_updated_user.id IS NULL THEN NULL - ELSE json_build_object('id', recipe_updated_user.id, 'displayName', recipe_updated_user.display_name) - END - ) - END AS recipe - FROM items i - JOIN item_categories c ON c.id = i.category_id - LEFT JOIN item_usages u ON u.id = i.usage_id - LEFT JOIN recipes item_recipe ON item_recipe.item_id = i.id - LEFT JOIN users recipe_created_user ON recipe_created_user.id = item_recipe.created_by_user_id - LEFT JOIN users recipe_updated_user ON recipe_updated_user.id = item_recipe.updated_by_user_id - ${auditJoins('i', 'item_created_user', 'item_updated_user')} -`; +function itemProjection(locale: string): string { + const itemName = localizedName('items', 'i', locale); + const categoryName = localizedName('item-categories', 'c', locale); + const usageName = localizedName('item-usages', 'u', locale); + const tagName = localizedName('favorite-things', 't', locale); -export async function listItems(paramsQuery: QueryParams) { + return ` + SELECT + i.id, + ${itemName} AS name, + ${translationsSelect('items', 'i.id')} AS translations, + ${auditSelect('i', 'item_created_user', 'item_updated_user')}, + json_build_object('id', c.id, 'name', ${categoryName}) AS category, + CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', ${usageName}) END AS usage, + json_build_object( + 'dyeable', i.dyeable, + 'dualDyeable', i.dual_dyeable, + 'patternEditable', i.pattern_editable + ) AS customization, + i.no_recipe AS "noRecipe", + COALESCE(( + SELECT json_agg(json_build_object('id', t.id, 'name', ${tagName}) ORDER BY ${tagName}) + FROM item_favorite_things ift + JOIN favorite_things t ON t.id = ift.favorite_thing_id + WHERE ift.item_id = i.id + ), '[]'::json) AS tags, + CASE + WHEN item_recipe.id IS NULL THEN NULL + ELSE json_build_object( + 'id', item_recipe.id, + 'createdAt', item_recipe.created_at, + 'updatedAt', item_recipe.updated_at, + 'createdBy', CASE + WHEN recipe_created_user.id IS NULL THEN NULL + ELSE json_build_object('id', recipe_created_user.id, 'displayName', recipe_created_user.display_name) + END, + 'updatedBy', CASE + WHEN recipe_updated_user.id IS NULL THEN NULL + ELSE json_build_object('id', recipe_updated_user.id, 'displayName', recipe_updated_user.display_name) + END + ) + END AS recipe + FROM items i + JOIN item_categories c ON c.id = i.category_id + LEFT JOIN item_usages u ON u.id = i.usage_id + LEFT JOIN recipes item_recipe ON item_recipe.item_id = i.id + LEFT JOIN users recipe_created_user ON recipe_created_user.id = item_recipe.created_by_user_id + LEFT JOIN users recipe_updated_user ON recipe_updated_user.id = item_recipe.updated_by_user_id + ${auditJoins('i', 'item_created_user', 'item_updated_user')} + `; +} + +export async function listItems(paramsQuery: QueryParams, locale = defaultLocale) { const params: unknown[] = []; const conditions: string[] = []; const categoryId = Number(asString(paramsQuery.categoryId)); @@ -1250,7 +1640,7 @@ export async function listItems(paramsQuery: QueryParams) { if (search) { params.push(`%${search}%`); - conditions.push(`i.name ILIKE $${params.length}`); + conditions.push(`${localizedName('items', 'i', locale)} ILIKE $${params.length}`); } if (Number.isInteger(categoryId) && categoryId > 0) { @@ -1277,23 +1667,31 @@ export async function listItems(paramsQuery: QueryParams) { } const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; - return query(`${itemProjection} ${whereClause} ORDER BY c.name, i.name`, params); + return query(`${itemProjection(locale)} ${whereClause} ORDER BY ${localizedName('item-categories', 'c', locale)}, ${localizedName('items', 'i', locale)}`, params); } -export async function getItem(id: number) { - const item = await queryOne(`${itemProjection} WHERE i.id = $1`, [id]); +export async function getItem(id: number, locale = defaultLocale) { + const item = await queryOne(`${itemProjection(locale)} WHERE i.id = $1`, [id]); if (!item) { return null; } + const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale); + const resultItemName = localizedName('items', 'result_item', locale); + const materialItemName = localizedName('items', 'mi', locale); + const habitatName = localizedName('habitats', 'h', locale); + const recipeItemName = localizedName('items', 'recipe_item', locale); + const pokemonName = localizedName('pokemon', 'p', locale); + const skillName = localizedName('skills', 's', locale); + const [acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory] = await Promise.all([ query( ` - SELECT am.id, am.name + SELECT am.id, ${acquisitionMethodName} AS name FROM item_acquisition_methods iam JOIN acquisition_methods am ON am.id = iam.acquisition_method_id WHERE iam.item_id = $1 - ORDER BY am.name + ORDER BY ${acquisitionMethodName} `, [id] ), @@ -1301,21 +1699,21 @@ export async function getItem(id: number) { ` SELECT r.id, - result_item.name, + ${resultItemName} AS name, ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')}, COALESCE(( - SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name) + SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${acquisitionMethodName}) FROM recipe_acquisition_methods ram JOIN acquisition_methods am ON am.id = ram.acquisition_method_id WHERE ram.recipe_id = r.id ), '[]'::json) AS acquisition_methods, COALESCE(( - SELECT json_agg(json_build_object('id', mi.id, 'name', mi.name, 'quantity', rm.quantity) ORDER BY mi.name) + SELECT json_agg(json_build_object('id', mi.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${materialItemName}) FROM recipe_materials rm JOIN items mi ON mi.id = rm.item_id WHERE rm.recipe_id = r.id ), '[]'::json) AS materials, - json_build_object('id', result_item.id, 'name', result_item.name) AS item + json_build_object('id', result_item.id, 'name', ${resultItemName}) AS item FROM recipes r JOIN items result_item ON result_item.id = r.item_id ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')} @@ -1327,9 +1725,9 @@ export async function getItem(id: number) { ` SELECT r.id, - result_item.name, + ${resultItemName} AS name, COALESCE(( - SELECT json_agg(json_build_object('id', mi.id, 'name', mi.name, 'quantity', recipe_material.quantity) ORDER BY mi.name) + SELECT json_agg(json_build_object('id', mi.id, 'name', ${materialItemName}, 'quantity', recipe_material.quantity) ORDER BY ${materialItemName}) FROM recipe_materials recipe_material JOIN items mi ON mi.id = recipe_material.item_id WHERE recipe_material.recipe_id = r.id @@ -1338,7 +1736,7 @@ export async function getItem(id: number) { JOIN recipes r ON r.id = used_material.recipe_id JOIN items result_item ON result_item.id = r.item_id WHERE used_material.item_id = $1 - ORDER BY result_item.name + ORDER BY ${resultItemName} `, [id] ), @@ -1346,9 +1744,9 @@ export async function getItem(id: number) { ` SELECT h.id, - h.name, + ${habitatName} AS name, COALESCE(( - SELECT json_agg(json_build_object('id', recipe_item.id, 'name', recipe_item.name, 'quantity', recipe_item_row.quantity) ORDER BY recipe_item.name) + SELECT json_agg(json_build_object('id', recipe_item.id, 'name', ${recipeItemName}, 'quantity', recipe_item_row.quantity) ORDER BY ${recipeItemName}) FROM habitat_recipe_items recipe_item_row JOIN items recipe_item ON recipe_item.id = recipe_item_row.item_id WHERE recipe_item_row.habitat_id = h.id @@ -1356,21 +1754,21 @@ export async function getItem(id: number) { FROM habitat_recipe_items used_item JOIN habitats h ON h.id = used_item.habitat_id WHERE used_item.item_id = $1 - ORDER BY h.name + ORDER BY ${habitatName} `, [id] ), query( ` SELECT - json_build_object('id', p.id, 'name', p.name) AS pokemon, - json_build_object('id', s.id, 'name', s.name) AS skill + json_build_object('id', p.id, 'name', ${pokemonName}) AS pokemon, + json_build_object('id', s.id, 'name', ${skillName}) AS skill FROM pokemon_skill_item_drops psid JOIN pokemon p ON p.id = psid.pokemon_id JOIN skills s ON s.id = psid.skill_id WHERE psid.item_id = $1 AND s.has_item_drop = true - ORDER BY p.id, s.name + ORDER BY p.id, ${skillName} `, [id] ), @@ -1383,11 +1781,12 @@ export async function getItem(id: number) { function cleanItemPayload(payload: Record): ItemPayload { const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined ? null - : requirePositiveInteger(payload.usageId, '请选择用途'); + : requirePositiveInteger(payload.usageId, 'Usage is required'); return { - name: cleanName(payload.name, '请输入物品名字'), - categoryId: requirePositiveInteger(payload.categoryId, '请选择分类'), + name: cleanName(payload.name, 'Item name is required'), + translations: cleanTranslations(payload.translations, ['name']), + categoryId: requirePositiveInteger(payload.categoryId, 'Category is required'), usageId, dyeable: Boolean(payload.dyeable), dualDyeable: Boolean(payload.dualDyeable), @@ -1405,7 +1804,7 @@ async function ensureItemCanDisableRecipe(client: DbClient, itemId: number, noRe const result = await client.query('SELECT 1 FROM recipes WHERE item_id = $1', [itemId]); if (result.rowCount && result.rowCount > 0) { - throw validationError('已有材料单的物品不能设置为无材料单'); + throw validationError('An item with a recipe cannot be marked as recipe-free'); } } @@ -1428,7 +1827,7 @@ async function replaceItemRelations(client: DbClient, itemId: number, payload: I } } -export async function createItem(payload: Record, userId: number) { +export async function createItem(payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanItemPayload(payload); const id = await withTransaction(async (client) => { @@ -1461,15 +1860,16 @@ export async function createItem(payload: Record, userId: numbe ); const itemId = result.rows[0].id; await replaceItemRelations(client, itemId, cleanPayload); + await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name']); await recordEditLog(client, 'items', itemId, 'create', userId); return itemId; }); - return getItem(id); + return getItem(id, locale); } -export async function updateItem(id: number, payload: Record, userId: number) { +export async function updateItem(id: number, payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanItemPayload(payload); - const before = await getItem(id); + const before = await getItem(id, locale); const updated = await withTransaction(async (client) => { await ensureItemCanDisableRecipe(client, id, cleanPayload.noRecipe); @@ -1503,11 +1903,12 @@ export async function updateItem(id: number, payload: Record, u return false; } await replaceItemRelations(client, id, cleanPayload); + await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name']); const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : []; await recordEditLog(client, 'items', id, 'update', userId, changes); return true; }); - return updated ? getItem(id) : null; + return updated ? getItem(id, locale) : null; } export async function deleteItem(id: number, userId: number) { @@ -1517,15 +1918,18 @@ export async function deleteItem(id: number, userId: number) { return false; } + await deleteEntityTranslations(client, 'items', id); await recordEditLog(client, 'items', id, 'delete', userId); return true; }); } -export async function listRecipes(paramsQuery: QueryParams = {}) { +export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaultLocale) { const params: unknown[] = []; const conditions: string[] = []; const categoryId = Number(asString(paramsQuery.categoryId)); + const resultItemName = localizedName('items', 'result_item', locale); + const materialItemName = localizedName('items', 'i', locale); if (Number.isInteger(categoryId) && categoryId > 0) { params.push(categoryId); @@ -1536,10 +1940,10 @@ export async function listRecipes(paramsQuery: QueryParams = {}) { return query(` SELECT r.id, - result_item.name, + ${resultItemName} AS name, ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')}, COALESCE(( - SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name) + SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${materialItemName}) FROM recipe_materials rm JOIN items i ON i.id = rm.item_id WHERE rm.recipe_id = r.id @@ -1548,30 +1952,34 @@ export async function listRecipes(paramsQuery: QueryParams = {}) { JOIN items result_item ON result_item.id = r.item_id ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')} ${whereClause} - ORDER BY result_item.name + ORDER BY ${resultItemName} `, params); } -export async function getRecipe(id: number) { +export async function getRecipe(id: number, locale = defaultLocale) { + const resultItemName = localizedName('items', 'result_item', locale); + const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale); + const materialItemName = localizedName('items', 'i', locale); + const recipe = await queryOne( ` SELECT r.id, - result_item.name, + ${resultItemName} AS name, ${auditSelect('r', 'recipe_created_user', 'recipe_updated_user')}, COALESCE(( - SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name) + SELECT json_agg(json_build_object('id', am.id, 'name', ${acquisitionMethodName}) ORDER BY ${acquisitionMethodName}) FROM recipe_acquisition_methods ram JOIN acquisition_methods am ON am.id = ram.acquisition_method_id WHERE ram.recipe_id = r.id ), '[]'::json) AS acquisition_methods, COALESCE(( - SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name) + SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${materialItemName}) FROM recipe_materials rm JOIN items i ON i.id = rm.item_id WHERE rm.recipe_id = r.id ), '[]'::json) AS materials, - json_build_object('id', result_item.id, 'name', result_item.name) AS item + json_build_object('id', result_item.id, 'name', ${resultItemName}) AS item FROM recipes r JOIN items result_item ON result_item.id = r.item_id ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')} @@ -1590,7 +1998,7 @@ export async function getRecipe(id: number) { function cleanRecipePayload(payload: Record): RecipePayload { return { - itemId: requirePositiveInteger(payload.itemId, '请选择物品'), + itemId: requirePositiveInteger(payload.itemId, 'Item is required'), acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds), materials: cleanQuantities(payload.materials) }; @@ -1619,15 +2027,15 @@ async function replaceRecipeRelations(client: DbClient, recipeId: number, payloa async function ensureItemCanHaveRecipe(client: DbClient, itemId: number): Promise { const result = await client.query<{ no_recipe: boolean }>('SELECT no_recipe FROM items WHERE id = $1', [itemId]); if (result.rowCount === 0) { - throw validationError('请选择物品'); + throw validationError('Item is required'); } if (result.rows[0].no_recipe) { - throw validationError('该物品已设置为无材料单'); + throw validationError('This item is marked as recipe-free'); } } -export async function createRecipe(payload: Record, userId: number) { +export async function createRecipe(payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanRecipePayload(payload); const id = await withTransaction(async (client) => { @@ -1645,12 +2053,12 @@ export async function createRecipe(payload: Record, userId: num await recordEditLog(client, 'recipes', recipeId, 'create', userId); return recipeId; }); - return getRecipe(id); + return getRecipe(id, locale); } -export async function updateRecipe(id: number, payload: Record, userId: number) { +export async function updateRecipe(id: number, payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanRecipePayload(payload); - const before = await getRecipe(id); + const before = await getRecipe(id, locale); const updated = await withTransaction(async (client) => { await ensureItemCanHaveRecipe(client, cleanPayload.itemId); @@ -1666,7 +2074,7 @@ export async function updateRecipe(id: number, payload: Record, await recordEditLog(client, 'recipes', id, 'update', userId, changes); return true; }); - return updated ? getRecipe(id) : null; + return updated ? getRecipe(id, locale) : null; } export async function deleteRecipe(id: number, userId: number) { diff --git a/backend/src/server.ts b/backend/src/server.ts index 91b59ea..10bb5f5 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -4,16 +4,19 @@ import type { FastifyReply, FastifyRequest } from 'fastify'; import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail, type AuthUser } from './auth.ts'; import { initializeDatabase, pool } from './db.ts'; import { + cleanLocale, createConfig, createDailyChecklistItem, createHabitat, createItem, + createLanguage, createPokemon, createRecipe, deleteConfig, deleteDailyChecklistItem, deleteHabitat, deleteItem, + deleteLanguage, deletePokemon, deleteRecipe, getHabitat, @@ -26,13 +29,16 @@ import { listDailyChecklistItems, listHabitats, listItems, + listLanguages, listPokemon, listRecipes, reorderDailyChecklistItems, + reorderLanguages, updateConfig, updateDailyChecklistItem, updateHabitat, updateItem, + updateLanguage, updatePokemon, updateRecipe } from './queries.ts'; @@ -42,24 +48,25 @@ const app = Fastify({ }); await app.register(cors, { - allowedHeaders: ['Authorization', 'Content-Type'], + allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], origin: process.env.FRONTEND_ORIGIN ?? true }); app.setErrorHandler(async (error, _request, reply) => { const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number }; + const locale = requestLocale(_request); if (pgError.code === '23503') { - return reply.code(409).send({ message: '引用的数据不存在,或当前记录正在被使用' }); + return reply.code(409).send({ message: serverMessage(locale, 'foreignKey') }); } if (pgError.code === '23505') { - return reply.code(409).send({ message: '同名或相同 ID 的记录已存在' }); + return reply.code(409).send({ message: serverMessage(locale, 'duplicate') }); } if (pgError.code === '23514') { - return reply.code(400).send({ message: '字段值不合法' }); + return reply.code(400).send({ message: serverMessage(locale, 'invalidField') }); } if (pgError.statusCode && pgError.statusCode < 500) { @@ -67,7 +74,7 @@ app.setErrorHandler(async (error, _request, reply) => { } app.log.error(error); - return reply.code(500).send({ message: '服务器错误' }); + return reply.code(500).send({ message: serverMessage(locale, 'serverError') }); }); app.get('/health', async () => ({ ok: true })); @@ -77,17 +84,48 @@ function getBearerToken(authorization: string | undefined): string | null { return scheme === 'Bearer' && token ? token : null; } +function requestLocale(request: FastifyRequest): string { + const query = request.query as Record; + const queryLocale = Array.isArray(query.locale) ? query.locale[0] : query.locale; + const headerLocale = request.headers['x-locale']; + return cleanLocale(queryLocale ?? (Array.isArray(headerLocale) ? headerLocale[0] : headerLocale)); +} + +function serverMessage(locale: string, key: 'foreignKey' | 'duplicate' | 'invalidField' | 'serverError' | 'loginRequired' | 'verifyEmailFirst'): string { + const messages = { + en: { + foreignKey: 'Referenced data does not exist or the record is currently in use', + duplicate: 'A record with the same name or ID already exists', + invalidField: 'Field value is invalid', + serverError: 'Server error', + loginRequired: 'Please log in first', + verifyEmailFirst: 'Please complete email verification first' + }, + 'zh-CN': { + foreignKey: '引用的数据不存在,或当前记录正在被使用', + duplicate: '同名或相同 ID 的记录已存在', + invalidField: '字段值不合法', + serverError: '服务器错误', + loginRequired: '请先登录', + verifyEmailFirst: '请先完成邮箱验证' + } + }; + + return messages[locale as keyof typeof messages]?.[key] ?? messages.en[key]; +} + async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise { const token = getBearerToken(request.headers.authorization); const user = token ? await getUserBySessionToken(token) : null; + const locale = requestLocale(request); if (!user) { - reply.code(401).send({ message: '请先登录' }); + reply.code(401).send({ message: serverMessage(locale, 'loginRequired') }); return null; } if (!user.emailVerified) { - reply.code(403).send({ message: '请先完成邮箱验证' }); + reply.code(403).send({ message: serverMessage(locale, 'verifyEmailFirst') }); return null; } @@ -95,19 +133,19 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply) } app.post('/api/auth/register', async (request, reply) => - reply.code(201).send(await registerUser(request.body as Record)) + reply.code(201).send(await registerUser(request.body as Record, requestLocale(request))) ); -app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record)); +app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record, requestLocale(request))); -app.post('/api/auth/login', async (request) => loginUser(request.body as Record)); +app.post('/api/auth/login', async (request) => loginUser(request.body as Record, requestLocale(request))); app.get('/api/auth/me', async (request, reply) => { const token = getBearerToken(request.headers.authorization); const user = token ? await getUserBySessionToken(token) : null; if (!user) { - return reply.code(401).send({ message: '请先登录' }); + return reply.code(401).send({ message: serverMessage(requestLocale(request), 'loginRequired') }); } return { user }; @@ -122,15 +160,19 @@ app.post('/api/auth/logout', async (request, reply) => { return reply.code(204).send(); }); -app.get('/api/options', async () => getOptions()); +app.get('/api/languages', async () => listLanguages()); -app.get('/api/daily-checklist', async () => listDailyChecklistItems()); +app.get('/api/options', async (request) => getOptions(requestLocale(request))); -app.get('/api/pokemon', async (request) => listPokemon(request.query as Record)); +app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request))); + +app.get('/api/pokemon', async (request) => + listPokemon(request.query as Record, requestLocale(request)) +); app.get('/api/pokemon/:id', async (request, reply) => { const { id } = request.params as { id: string }; - const pokemon = await getPokemon(Number(id)); + const pokemon = await getPokemon(Number(id), requestLocale(request)); if (!pokemon) { return reply.code(404).send({ message: 'Not found' }); @@ -141,7 +183,9 @@ app.get('/api/pokemon/:id', async (request, reply) => { app.post('/api/pokemon', async (request, reply) => { const user = await requireVerifiedUser(request, reply); - return user ? reply.code(201).send(await createPokemon(request.body as Record, user.id)) : undefined; + return user + ? reply.code(201).send(await createPokemon(request.body as Record, user.id, requestLocale(request))) + : undefined; }); app.put('/api/pokemon/:id', async (request, reply) => { @@ -150,7 +194,7 @@ app.put('/api/pokemon/:id', async (request, reply) => { return; } const { id } = request.params as { id: string }; - const pokemon = await updatePokemon(Number(id), request.body as Record, user.id); + const pokemon = await updatePokemon(Number(id), request.body as Record, user.id, requestLocale(request)); if (!pokemon) { return reply.code(404).send({ message: 'Not found' }); @@ -169,11 +213,11 @@ app.delete('/api/pokemon/:id', async (request, reply) => { return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); -app.get('/api/habitats', async () => listHabitats()); +app.get('/api/habitats', async (request) => listHabitats(requestLocale(request))); app.get('/api/habitats/:id', async (request, reply) => { const { id } = request.params as { id: string }; - const habitat = await getHabitat(Number(id)); + const habitat = await getHabitat(Number(id), requestLocale(request)); if (!habitat) { return reply.code(404).send({ message: 'Not found' }); @@ -184,7 +228,9 @@ app.get('/api/habitats/:id', async (request, reply) => { app.post('/api/habitats', async (request, reply) => { const user = await requireVerifiedUser(request, reply); - return user ? reply.code(201).send(await createHabitat(request.body as Record, user.id)) : undefined; + return user + ? reply.code(201).send(await createHabitat(request.body as Record, user.id, requestLocale(request))) + : undefined; }); app.put('/api/habitats/:id', async (request, reply) => { @@ -193,7 +239,7 @@ app.put('/api/habitats/:id', async (request, reply) => { return; } const { id } = request.params as { id: string }; - const habitat = await updateHabitat(Number(id), request.body as Record, user.id); + const habitat = await updateHabitat(Number(id), request.body as Record, user.id, requestLocale(request)); if (!habitat) { return reply.code(404).send({ message: 'Not found' }); @@ -212,11 +258,13 @@ app.delete('/api/habitats/:id', async (request, reply) => { return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); -app.get('/api/items', async (request) => listItems(request.query as Record)); +app.get('/api/items', async (request) => + listItems(request.query as Record, requestLocale(request)) +); app.get('/api/items/:id', async (request, reply) => { const { id } = request.params as { id: string }; - const item = await getItem(Number(id)); + const item = await getItem(Number(id), requestLocale(request)); if (!item) { return reply.code(404).send({ message: 'Not found' }); @@ -227,7 +275,9 @@ app.get('/api/items/:id', async (request, reply) => { app.post('/api/items', async (request, reply) => { const user = await requireVerifiedUser(request, reply); - return user ? reply.code(201).send(await createItem(request.body as Record, user.id)) : undefined; + return user + ? reply.code(201).send(await createItem(request.body as Record, user.id, requestLocale(request))) + : undefined; }); app.put('/api/items/:id', async (request, reply) => { @@ -236,7 +286,7 @@ app.put('/api/items/:id', async (request, reply) => { return; } const { id } = request.params as { id: string }; - const item = await updateItem(Number(id), request.body as Record, user.id); + const item = await updateItem(Number(id), request.body as Record, user.id, requestLocale(request)); if (!item) { return reply.code(404).send({ message: 'Not found' }); @@ -255,11 +305,13 @@ app.delete('/api/items/:id', async (request, reply) => { return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); -app.get('/api/recipes', async (request) => listRecipes(request.query as Record)); +app.get('/api/recipes', async (request) => + listRecipes(request.query as Record, requestLocale(request)) +); app.get('/api/recipes/:id', async (request, reply) => { const { id } = request.params as { id: string }; - const recipe = await getRecipe(Number(id)); + const recipe = await getRecipe(Number(id), requestLocale(request)); if (!recipe) { return reply.code(404).send({ message: 'Not found' }); @@ -270,7 +322,9 @@ app.get('/api/recipes/:id', async (request, reply) => { app.post('/api/recipes', async (request, reply) => { const user = await requireVerifiedUser(request, reply); - return user ? reply.code(201).send(await createRecipe(request.body as Record, user.id)) : undefined; + return user + ? reply.code(201).send(await createRecipe(request.body as Record, user.id, requestLocale(request))) + : undefined; }); app.put('/api/recipes/:id', async (request, reply) => { @@ -279,7 +333,7 @@ app.put('/api/recipes/:id', async (request, reply) => { return; } const { id } = request.params as { id: string }; - const recipe = await updateRecipe(Number(id), request.body as Record, user.id); + const recipe = await updateRecipe(Number(id), request.body as Record, user.id, requestLocale(request)); if (!recipe) { return reply.code(404).send({ message: 'Not found' }); @@ -300,12 +354,16 @@ app.delete('/api/recipes/:id', async (request, reply) => { app.post('/api/admin/daily-checklist', async (request, reply) => { const user = await requireVerifiedUser(request, reply); - return user ? reply.code(201).send(await createDailyChecklistItem(request.body as Record, user.id)) : undefined; + return user + ? reply + .code(201) + .send(await createDailyChecklistItem(request.body as Record, user.id, requestLocale(request))) + : undefined; }); app.put('/api/admin/daily-checklist/order', async (request, reply) => { const user = await requireVerifiedUser(request, reply); - return user ? reorderDailyChecklistItems(request.body as Record, user.id) : undefined; + return user ? reorderDailyChecklistItems(request.body as Record, user.id, requestLocale(request)) : undefined; }); app.put('/api/admin/daily-checklist/:id', async (request, reply) => { @@ -314,7 +372,12 @@ app.put('/api/admin/daily-checklist/:id', async (request, reply) => { return; } const { id } = request.params as { id: string }; - const item = await updateDailyChecklistItem(Number(id), request.body as Record, user.id); + const item = await updateDailyChecklistItem( + Number(id), + request.body as Record, + user.id, + requestLocale(request) + ); return item ? item : reply.code(404).send({ message: 'Not found' }); }); @@ -328,6 +391,40 @@ app.delete('/api/admin/daily-checklist/:id', async (request, reply) => { return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); +app.get('/api/admin/languages', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + return user ? listLanguages(true) : undefined; +}); + +app.post('/api/admin/languages', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + return user ? reply.code(201).send(await createLanguage(request.body as Record)) : undefined; +}); + +app.put('/api/admin/languages/order', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + return user ? reorderLanguages(request.body as Record) : undefined; +}); + +app.put('/api/admin/languages/:code', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + const { code } = request.params as { code: string }; + return updateLanguage(code, request.body as Record); +}); + +app.delete('/api/admin/languages/:code', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + if (!user) { + return; + } + const { code } = request.params as { code: string }; + const deleted = await deleteLanguage(code); + return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); +}); + app.get('/api/admin/config/:type', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { @@ -337,7 +434,7 @@ app.get('/api/admin/config/:type', async (request, reply) => { if (!isConfigType(type)) { return reply.code(404).send({ message: 'Not found' }); } - return listConfig(type); + return listConfig(type, requestLocale(request)); }); app.post('/api/admin/config/:type', async (request, reply) => { @@ -349,7 +446,9 @@ app.post('/api/admin/config/:type', async (request, reply) => { if (!isConfigType(type)) { return reply.code(404).send({ message: 'Not found' }); } - return reply.code(201).send(await createConfig(type, request.body as Record, user.id)); + return reply + .code(201) + .send(await createConfig(type, request.body as Record, user.id, requestLocale(request))); }); app.put('/api/admin/config/:type/:id', async (request, reply) => { @@ -361,7 +460,7 @@ app.put('/api/admin/config/:type/:id', async (request, reply) => { if (!isConfigType(type)) { return reply.code(404).send({ message: 'Not found' }); } - const config = await updateConfig(type, Number(id), request.body as Record, user.id); + const config = await updateConfig(type, Number(id), request.body as Record, user.id, requestLocale(request)); return config ? config : reply.code(404).send({ message: 'Not found' }); }); diff --git a/frontend/index.html b/frontend/index.html index b79715a..b8f4327 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,5 +1,5 @@ - + diff --git a/frontend/package.json b/frontend/package.json index 0430955..0638adb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,9 +12,11 @@ "test": "vitest run" }, "dependencies": { + "@iconify/vue": "^5.0.0", "@vitejs/plugin-vue": "latest", "vite": "latest", "vue": "latest", + "vue-i18n": "^11.4.0", "vue-router": "latest" }, "devDependencies": { diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 21c1154..b76d5f6 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,21 +1,30 @@ diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index 1fd82d1..43a46ee 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -1,15 +1,67 @@