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 @@
@@ -24,20 +76,52 @@ defineEmits<{
-