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
This commit is contained in:
2026-05-01 12:04:49 +08:00
parent 91dd834413
commit 27100fbd22
36 changed files with 5055 additions and 866 deletions

View File

@@ -3,6 +3,52 @@ CREATE TABLE IF NOT EXISTS environments (
name text NOT NULL UNIQUE 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 ( CREATE TABLE IF NOT EXISTS users (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE, email text NOT NULL UNIQUE,

View File

@@ -7,6 +7,7 @@ const scrypt = promisify(scryptCallback);
const passwordKeyLength = 64; const passwordKeyLength = 64;
const verificationTokenHours = 24; const verificationTokenHours = 24;
const sessionDays = 30; const sessionDays = 30;
const defaultLocale = 'en';
type DbClient = PoolClient; type DbClient = PoolClient;
@@ -23,6 +24,22 @@ type LoginUserRow = UserRow & {
password_hash: string; password_hash: string;
}; };
type AuthMessageKey =
| 'emailRequired'
| 'invalidEmail'
| 'displayNameRequired'
| 'displayNameLength'
| 'passwordLength'
| 'invalidToken'
| 'emailAlreadyRegistered'
| 'checkVerificationEmail'
| 'emailVerified'
| 'invalidCredentials'
| 'verifyEmailFirst'
| 'emailSubject'
| 'emailHtml'
| 'emailText';
export type AuthUser = { export type AuthUser = {
id: number; id: number;
email: string; email: string;
@@ -36,43 +53,87 @@ function statusError(message: string, statusCode: number): StatusError {
return error; return error;
} }
function cleanEmail(value: unknown): string { function authMessage(locale: string, key: AuthMessageKey, params: Record<string, string | number> = {}): string {
const messages: Record<string, Record<AuthMessageKey, string>> = {
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:
'<p>Open the link below to verify your email:</p><p><a href="{url}">Verify email</a></p><p>The link expires in {hours} hours.</p>',
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: '<p>请点击下面的链接完成邮箱验证:</p><p><a href="{url}">验证邮箱</a></p><p>链接将在 {hours} 小时后失效。</p>',
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') { if (typeof value !== 'string') {
throw statusError('请输入邮箱', 400); throw statusError(authMessage(locale, 'emailRequired'), 400);
} }
const email = value.trim().toLowerCase(); const email = value.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
throw statusError('邮箱格式不正确', 400); throw statusError(authMessage(locale, 'invalidEmail'), 400);
} }
return email; return email;
} }
function cleanDisplayName(value: unknown): string { function cleanDisplayName(value: unknown, locale: string): string {
if (typeof value !== 'string') { if (typeof value !== 'string') {
throw statusError('请输入显示名', 400); throw statusError(authMessage(locale, 'displayNameRequired'), 400);
} }
const displayName = value.trim(); const displayName = value.trim();
if (displayName.length < 1 || displayName.length > 40) { if (displayName.length < 1 || displayName.length > 40) {
throw statusError('显示名长度需为 1 到 40 个字符', 400); throw statusError(authMessage(locale, 'displayNameLength'), 400);
} }
return displayName; return displayName;
} }
function cleanPassword(value: unknown): string { function cleanPassword(value: unknown, locale: string): string {
if (typeof value !== 'string' || value.length < 8) { if (typeof value !== 'string' || value.length < 8) {
throw statusError('密码至少需要 8 个字符', 400); throw statusError(authMessage(locale, 'passwordLength'), 400);
} }
return value; return value;
} }
function cleanToken(value: unknown): string { function cleanToken(value: unknown, locale: string): string {
if (typeof value !== 'string' || value.trim().length < 32) { if (typeof value !== 'string' || value.trim().length < 32) {
throw statusError('验证链接无效或已过期', 400); throw statusError(authMessage(locale, 'invalidToken'), 400);
} }
return value.trim(); return value.trim();
@@ -155,7 +216,7 @@ function buildVerificationUrl(token: string): string {
return url.toString(); return url.toString();
} }
async function sendVerificationEmail(email: string, token: string): Promise<void> { async function sendVerificationEmail(email: string, token: string, locale: string): Promise<void> {
const { apiKey, from } = getEmailConfig(); const { apiKey, from } = getEmailConfig();
const verificationUrl = buildVerificationUrl(token); const verificationUrl = buildVerificationUrl(token);
const response = await fetch('https://api.resend.com/emails', { const response = await fetch('https://api.resend.com/emails', {
@@ -167,9 +228,9 @@ async function sendVerificationEmail(email: string, token: string): Promise<void
body: JSON.stringify({ body: JSON.stringify({
from, from,
to: [email], to: [email],
subject: '验证你的 Pokopia Wiki 邮箱', subject: authMessage(locale, 'emailSubject'),
html: `<p>请点击下面的链接完成邮箱验证:</p><p><a href="${verificationUrl}">验证邮箱</a></p><p>链接将在 ${verificationTokenHours} 小时后失效。</p>`, html: authMessage(locale, 'emailHtml', { url: verificationUrl, hours: verificationTokenHours }),
text: `请打开以下链接完成 Pokopia Wiki 邮箱验证:${verificationUrl}\n链接将在 ${verificationTokenHours} 小时后失效。` text: authMessage(locale, 'emailText', { url: verificationUrl, hours: verificationTokenHours })
}) })
}); });
@@ -179,10 +240,10 @@ async function sendVerificationEmail(email: string, token: string): Promise<void
} }
} }
export async function registerUser(payload: Record<string, unknown>) { export async function registerUser(payload: Record<string, unknown>, locale = defaultLocale) {
const email = cleanEmail(payload.email); const email = cleanEmail(payload.email, locale);
const displayName = cleanDisplayName(payload.displayName); const displayName = cleanDisplayName(payload.displayName, locale);
const password = cleanPassword(payload.password); const password = cleanPassword(payload.password, locale);
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
const verificationToken = createPlainToken(); const verificationToken = createPlainToken();
const verificationTokenHash = hashToken(verificationToken); const verificationTokenHash = hashToken(verificationToken);
@@ -195,7 +256,7 @@ export async function registerUser(payload: Record<string, unknown>) {
); );
if (existingUser?.email_verified_at) { if (existingUser?.email_verified_at) {
throw statusError('该邮箱已注册', 409); throw statusError(authMessage(locale, 'emailAlreadyRegistered'), 409);
} }
const user = existingUser const user = existingUser
@@ -233,12 +294,12 @@ export async function registerUser(payload: Record<string, unknown>) {
); );
}); });
await sendVerificationEmail(email, verificationToken); await sendVerificationEmail(email, verificationToken, locale);
return { message: '请查收验证邮件' }; return { message: authMessage(locale, 'checkVerificationEmail') };
} }
export async function verifyEmail(payload: Record<string, unknown>) { export async function verifyEmail(payload: Record<string, unknown>, locale = defaultLocale) {
const token = cleanToken(payload.token); const token = cleanToken(payload.token, locale);
const tokenHash = hashToken(token); const tokenHash = hashToken(token);
return withTransaction(async (client) => { return withTransaction(async (client) => {
@@ -256,7 +317,7 @@ export async function verifyEmail(payload: Record<string, unknown>) {
); );
if (!tokenRow) { if (!tokenRow) {
throw statusError('验证链接无效或已过期', 400); throw statusError(authMessage(locale, 'invalidToken'), 400);
} }
const user = await clientQueryOne<UserRow>( const user = await clientQueryOne<UserRow>(
@@ -271,31 +332,31 @@ export async function verifyEmail(payload: Record<string, unknown>) {
); );
if (!user) { 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', [ await client.query('UPDATE email_verification_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [
user.id user.id
]); ]);
return { message: '邮箱已验证', user: toPublicUser(user) }; return { message: authMessage(locale, 'emailVerified'), user: toPublicUser(user) };
}); });
} }
export async function loginUser(payload: Record<string, unknown>) { export async function loginUser(payload: Record<string, unknown>, locale = defaultLocale) {
const email = cleanEmail(payload.email); const email = cleanEmail(payload.email, locale);
const password = cleanPassword(payload.password); const password = cleanPassword(payload.password, locale);
const user = await queryOne<LoginUserRow>( const user = await queryOne<LoginUserRow>(
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1', 'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
[email] [email]
); );
if (!user || !(await verifyPassword(password, user.password_hash))) { if (!user || !(await verifyPassword(password, user.password_hash))) {
throw statusError('邮箱或密码不正确', 401); throw statusError(authMessage(locale, 'invalidCredentials'), 401);
} }
if (!user.email_verified_at) { if (!user.email_verified_at) {
throw statusError('请先完成邮箱验证', 403); throw statusError(authMessage(locale, 'verifyEmailFirst'), 403);
} }
const sessionToken = createPlainToken(); const sessionToken = createPlainToken();

File diff suppressed because it is too large Load Diff

View File

@@ -4,16 +4,19 @@ import type { FastifyReply, FastifyRequest } from 'fastify';
import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail, type AuthUser } from './auth.ts'; import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail, type AuthUser } from './auth.ts';
import { initializeDatabase, pool } from './db.ts'; import { initializeDatabase, pool } from './db.ts';
import { import {
cleanLocale,
createConfig, createConfig,
createDailyChecklistItem, createDailyChecklistItem,
createHabitat, createHabitat,
createItem, createItem,
createLanguage,
createPokemon, createPokemon,
createRecipe, createRecipe,
deleteConfig, deleteConfig,
deleteDailyChecklistItem, deleteDailyChecklistItem,
deleteHabitat, deleteHabitat,
deleteItem, deleteItem,
deleteLanguage,
deletePokemon, deletePokemon,
deleteRecipe, deleteRecipe,
getHabitat, getHabitat,
@@ -26,13 +29,16 @@ import {
listDailyChecklistItems, listDailyChecklistItems,
listHabitats, listHabitats,
listItems, listItems,
listLanguages,
listPokemon, listPokemon,
listRecipes, listRecipes,
reorderDailyChecklistItems, reorderDailyChecklistItems,
reorderLanguages,
updateConfig, updateConfig,
updateDailyChecklistItem, updateDailyChecklistItem,
updateHabitat, updateHabitat,
updateItem, updateItem,
updateLanguage,
updatePokemon, updatePokemon,
updateRecipe updateRecipe
} from './queries.ts'; } from './queries.ts';
@@ -42,24 +48,25 @@ const app = Fastify({
}); });
await app.register(cors, { await app.register(cors, {
allowedHeaders: ['Authorization', 'Content-Type'], allowedHeaders: ['Authorization', 'Content-Type', 'X-Locale'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
origin: process.env.FRONTEND_ORIGIN ?? true origin: process.env.FRONTEND_ORIGIN ?? true
}); });
app.setErrorHandler(async (error, _request, reply) => { app.setErrorHandler(async (error, _request, reply) => {
const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number }; const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number };
const locale = requestLocale(_request);
if (pgError.code === '23503') { if (pgError.code === '23503') {
return reply.code(409).send({ message: '引用的数据不存在,或当前记录正在被使用' }); return reply.code(409).send({ message: serverMessage(locale, 'foreignKey') });
} }
if (pgError.code === '23505') { if (pgError.code === '23505') {
return reply.code(409).send({ message: '同名或相同 ID 的记录已存在' }); return reply.code(409).send({ message: serverMessage(locale, 'duplicate') });
} }
if (pgError.code === '23514') { 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) { if (pgError.statusCode && pgError.statusCode < 500) {
@@ -67,7 +74,7 @@ app.setErrorHandler(async (error, _request, reply) => {
} }
app.log.error(error); 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 })); app.get('/health', async () => ({ ok: true }));
@@ -77,17 +84,48 @@ function getBearerToken(authorization: string | undefined): string | null {
return scheme === 'Bearer' && token ? token : null; return scheme === 'Bearer' && token ? token : null;
} }
function requestLocale(request: FastifyRequest): string {
const query = request.query as Record<string, string | string[] | undefined>;
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<AuthUser | null> { async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply): Promise<AuthUser | null> {
const token = getBearerToken(request.headers.authorization); const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null; const user = token ? await getUserBySessionToken(token) : null;
const locale = requestLocale(request);
if (!user) { if (!user) {
reply.code(401).send({ message: '请先登录' }); reply.code(401).send({ message: serverMessage(locale, 'loginRequired') });
return null; return null;
} }
if (!user.emailVerified) { if (!user.emailVerified) {
reply.code(403).send({ message: '请先完成邮箱验证' }); reply.code(403).send({ message: serverMessage(locale, 'verifyEmailFirst') });
return null; return null;
} }
@@ -95,19 +133,19 @@ async function requireVerifiedUser(request: FastifyRequest, reply: FastifyReply)
} }
app.post('/api/auth/register', async (request, reply) => app.post('/api/auth/register', async (request, reply) =>
reply.code(201).send(await registerUser(request.body as Record<string, unknown>)) reply.code(201).send(await registerUser(request.body as Record<string, unknown>, requestLocale(request)))
); );
app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record<string, unknown>)); app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body as Record<string, unknown>, requestLocale(request)));
app.post('/api/auth/login', async (request) => loginUser(request.body as Record<string, unknown>)); app.post('/api/auth/login', async (request) => loginUser(request.body as Record<string, unknown>, requestLocale(request)));
app.get('/api/auth/me', async (request, reply) => { app.get('/api/auth/me', async (request, reply) => {
const token = getBearerToken(request.headers.authorization); const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null; const user = token ? await getUserBySessionToken(token) : null;
if (!user) { if (!user) {
return reply.code(401).send({ message: '请先登录' }); return reply.code(401).send({ message: serverMessage(requestLocale(request), 'loginRequired') });
} }
return { user }; return { user };
@@ -122,15 +160,19 @@ app.post('/api/auth/logout', async (request, reply) => {
return reply.code(204).send(); 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<string, string | string[] | undefined>)); app.get('/api/daily-checklist', async (request) => listDailyChecklistItems(requestLocale(request)));
app.get('/api/pokemon', async (request) =>
listPokemon(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
app.get('/api/pokemon/:id', async (request, reply) => { app.get('/api/pokemon/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const pokemon = await getPokemon(Number(id)); const pokemon = await getPokemon(Number(id), requestLocale(request));
if (!pokemon) { if (!pokemon) {
return reply.code(404).send({ message: 'Not found' }); 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) => { app.post('/api/pokemon', async (request, reply) => {
const user = await requireVerifiedUser(request, reply); const user = await requireVerifiedUser(request, reply);
return user ? reply.code(201).send(await createPokemon(request.body as Record<string, unknown>, user.id)) : undefined; return user
? reply.code(201).send(await createPokemon(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
}); });
app.put('/api/pokemon/:id', async (request, reply) => { app.put('/api/pokemon/:id', async (request, reply) => {
@@ -150,7 +194,7 @@ app.put('/api/pokemon/:id', async (request, reply) => {
return; return;
} }
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const pokemon = await updatePokemon(Number(id), request.body as Record<string, unknown>, user.id); const pokemon = await updatePokemon(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
if (!pokemon) { if (!pokemon) {
return reply.code(404).send({ message: 'Not found' }); 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' }); 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) => { app.get('/api/habitats/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const habitat = await getHabitat(Number(id)); const habitat = await getHabitat(Number(id), requestLocale(request));
if (!habitat) { if (!habitat) {
return reply.code(404).send({ message: 'Not found' }); 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) => { app.post('/api/habitats', async (request, reply) => {
const user = await requireVerifiedUser(request, reply); const user = await requireVerifiedUser(request, reply);
return user ? reply.code(201).send(await createHabitat(request.body as Record<string, unknown>, user.id)) : undefined; return user
? reply.code(201).send(await createHabitat(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
}); });
app.put('/api/habitats/:id', async (request, reply) => { app.put('/api/habitats/:id', async (request, reply) => {
@@ -193,7 +239,7 @@ app.put('/api/habitats/:id', async (request, reply) => {
return; return;
} }
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const habitat = await updateHabitat(Number(id), request.body as Record<string, unknown>, user.id); const habitat = await updateHabitat(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
if (!habitat) { if (!habitat) {
return reply.code(404).send({ message: 'Not found' }); 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' }); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
}); });
app.get('/api/items', async (request) => listItems(request.query as Record<string, string | string[] | undefined>)); app.get('/api/items', async (request) =>
listItems(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
app.get('/api/items/:id', async (request, reply) => { app.get('/api/items/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const item = await getItem(Number(id)); const item = await getItem(Number(id), requestLocale(request));
if (!item) { if (!item) {
return reply.code(404).send({ message: 'Not found' }); 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) => { app.post('/api/items', async (request, reply) => {
const user = await requireVerifiedUser(request, reply); const user = await requireVerifiedUser(request, reply);
return user ? reply.code(201).send(await createItem(request.body as Record<string, unknown>, user.id)) : undefined; return user
? reply.code(201).send(await createItem(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
}); });
app.put('/api/items/:id', async (request, reply) => { app.put('/api/items/:id', async (request, reply) => {
@@ -236,7 +286,7 @@ app.put('/api/items/:id', async (request, reply) => {
return; return;
} }
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const item = await updateItem(Number(id), request.body as Record<string, unknown>, user.id); const item = await updateItem(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
if (!item) { if (!item) {
return reply.code(404).send({ message: 'Not found' }); 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' }); return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' });
}); });
app.get('/api/recipes', async (request) => listRecipes(request.query as Record<string, string | string[] | undefined>)); app.get('/api/recipes', async (request) =>
listRecipes(request.query as Record<string, string | string[] | undefined>, requestLocale(request))
);
app.get('/api/recipes/:id', async (request, reply) => { app.get('/api/recipes/:id', async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const recipe = await getRecipe(Number(id)); const recipe = await getRecipe(Number(id), requestLocale(request));
if (!recipe) { if (!recipe) {
return reply.code(404).send({ message: 'Not found' }); 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) => { app.post('/api/recipes', async (request, reply) => {
const user = await requireVerifiedUser(request, reply); const user = await requireVerifiedUser(request, reply);
return user ? reply.code(201).send(await createRecipe(request.body as Record<string, unknown>, user.id)) : undefined; return user
? reply.code(201).send(await createRecipe(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
}); });
app.put('/api/recipes/:id', async (request, reply) => { app.put('/api/recipes/:id', async (request, reply) => {
@@ -279,7 +333,7 @@ app.put('/api/recipes/:id', async (request, reply) => {
return; return;
} }
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const recipe = await updateRecipe(Number(id), request.body as Record<string, unknown>, user.id); const recipe = await updateRecipe(Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
if (!recipe) { if (!recipe) {
return reply.code(404).send({ message: 'Not found' }); 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) => { app.post('/api/admin/daily-checklist', async (request, reply) => {
const user = await requireVerifiedUser(request, reply); const user = await requireVerifiedUser(request, reply);
return user ? reply.code(201).send(await createDailyChecklistItem(request.body as Record<string, unknown>, user.id)) : undefined; return user
? reply
.code(201)
.send(await createDailyChecklistItem(request.body as Record<string, unknown>, user.id, requestLocale(request)))
: undefined;
}); });
app.put('/api/admin/daily-checklist/order', async (request, reply) => { app.put('/api/admin/daily-checklist/order', async (request, reply) => {
const user = await requireVerifiedUser(request, reply); const user = await requireVerifiedUser(request, reply);
return user ? reorderDailyChecklistItems(request.body as Record<string, unknown>, user.id) : undefined; return user ? reorderDailyChecklistItems(request.body as Record<string, unknown>, user.id, requestLocale(request)) : undefined;
}); });
app.put('/api/admin/daily-checklist/:id', async (request, reply) => { 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; return;
} }
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const item = await updateDailyChecklistItem(Number(id), request.body as Record<string, unknown>, user.id); const item = await updateDailyChecklistItem(
Number(id),
request.body as Record<string, unknown>,
user.id,
requestLocale(request)
);
return item ? item : reply.code(404).send({ message: 'Not found' }); 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' }); 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<string, unknown>)) : undefined;
});
app.put('/api/admin/languages/order', async (request, reply) => {
const user = await requireVerifiedUser(request, reply);
return user ? reorderLanguages(request.body as Record<string, unknown>) : 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<string, unknown>);
});
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) => { app.get('/api/admin/config/:type', async (request, reply) => {
const user = await requireVerifiedUser(request, reply); const user = await requireVerifiedUser(request, reply);
if (!user) { if (!user) {
@@ -337,7 +434,7 @@ app.get('/api/admin/config/:type', async (request, reply) => {
if (!isConfigType(type)) { if (!isConfigType(type)) {
return reply.code(404).send({ message: 'Not found' }); 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) => { 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)) { if (!isConfigType(type)) {
return reply.code(404).send({ message: 'Not found' }); return reply.code(404).send({ message: 'Not found' });
} }
return reply.code(201).send(await createConfig(type, request.body as Record<string, unknown>, user.id)); return reply
.code(201)
.send(await createConfig(type, request.body as Record<string, unknown>, user.id, requestLocale(request)));
}); });
app.put('/api/admin/config/:type/:id', async (request, reply) => { 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)) { if (!isConfigType(type)) {
return reply.code(404).send({ message: 'Not found' }); return reply.code(404).send({ message: 'Not found' });
} }
const config = await updateConfig(type, Number(id), request.body as Record<string, unknown>, user.id); const config = await updateConfig(type, Number(id), request.body as Record<string, unknown>, user.id, requestLocale(request));
return config ? config : reply.code(404).send({ message: 'Not found' }); return config ? config : reply.code(404).send({ message: 'Not found' });
}); });

View File

@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@@ -12,9 +12,11 @@
"test": "vitest run" "test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@iconify/vue": "^5.0.0",
"@vitejs/plugin-vue": "latest", "@vitejs/plugin-vue": "latest",
"vite": "latest", "vite": "latest",
"vue": "latest", "vue": "latest",
"vue-i18n": "^11.4.0",
"vue-router": "latest" "vue-router": "latest"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,21 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'; import { computed, onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import AppShell from './components/AppShell.vue'; import AppShell from './components/AppShell.vue';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser } from './services/api'; import { getCurrentLocale, onLocaleChange, setCurrentLocale } from './i18n';
import { api, getAuthToken, onAuthTokenChange, setAuthToken, type AuthUser, type Language } from './services/api';
const navItems = [ const { t, locale } = useI18n();
{ label: 'Pokemon', to: '/pokemon' },
{ label: '栖息地', to: '/habitats' },
{ label: '物品', to: '/items' },
{ label: '材料单', to: '/recipes' },
{ label: 'CheckList', to: '/checklist' },
{ label: '管理', to: '/admin' }
];
const router = useRouter(); const router = useRouter();
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const languages = ref<Language[]>([
{ code: 'en', name: 'English', enabled: true, isDefault: true, sortOrder: 10 },
{ code: 'zh-CN', name: '简体中文', enabled: true, isDefault: false, sortOrder: 20 }
]);
let removeAuthListener: (() => void) | null = null; let removeAuthListener: (() => void) | null = null;
let removeLocaleListener: (() => void) | null = null;
const navItems = computed(() => [
{ label: t('nav.pokemon'), to: '/pokemon' },
{ label: t('nav.habitats'), to: '/habitats' },
{ label: t('nav.items'), to: '/items' },
{ label: t('nav.recipes'), to: '/recipes' },
{ label: t('nav.checklist'), to: '/checklist' },
{ label: t('nav.admin'), to: '/admin' }
]);
async function loadCurrentUser() { async function loadCurrentUser() {
if (!getAuthToken()) { if (!getAuthToken()) {
@@ -44,20 +53,51 @@ async function logout() {
await router.push('/pokemon'); await router.push('/pokemon');
} }
async function loadLanguages() {
try {
const loadedLanguages = await api.languages();
if (loadedLanguages.length) {
languages.value = loadedLanguages;
}
if (!languages.value.some((language) => language.code === getCurrentLocale() && language.enabled)) {
setCurrentLocale('en');
}
} catch {
// Keep the built-in language list when the API is not ready yet.
}
}
function updateLocale(value: string) {
setCurrentLocale(value);
}
onMounted(() => { onMounted(() => {
void loadLanguages();
void loadCurrentUser(); void loadCurrentUser();
removeAuthListener = onAuthTokenChange(() => { removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser(); void loadCurrentUser();
}); });
removeLocaleListener = onLocaleChange(() => {
void loadLanguages();
});
}); });
onUnmounted(() => { onUnmounted(() => {
removeAuthListener?.(); removeAuthListener?.();
removeLocaleListener?.();
}); });
</script> </script>
<template> <template>
<AppShell :current-user="currentUser" :nav-items="navItems" @logout="logout"> <AppShell
<RouterView /> :current-user="currentUser"
:languages="languages"
:locale="locale"
:nav-items="navItems"
@logout="logout"
@update:locale="updateLocale"
>
<RouterView :key="locale" />
</AppShell> </AppShell>
</template> </template>

View File

@@ -1,15 +1,67 @@
<script setup lang="ts"> <script setup lang="ts">
import type { AuthUser } from '../services/api'; import { Icon } from '@iconify/vue';
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { AuthUser, Language } from '../services/api';
import PokeBallMark from './PokeBallMark.vue'; import PokeBallMark from './PokeBallMark.vue';
defineProps<{ defineProps<{
currentUser: AuthUser | null; currentUser: AuthUser | null;
languages: Language[];
locale: string;
navItems: Array<{ label: string; to: string }>; navItems: Array<{ label: string; to: string }>;
}>(); }>();
defineEmits<{ const emit = defineEmits<{
logout: []; logout: [];
'update:locale': [value: string];
}>(); }>();
const { t } = useI18n();
const translateIcon = {
width: 24,
height: 24,
body: '<path fill="currentColor" d="m12.65 15.65l-2.85-2.8q.7-.8 1.225-1.75t.825-2.1H15V7h-5V5H8v2H3v2h6.95q-.275.8-.687 1.5T8.3 11.8q-.5-.55-.875-1.125T6.8 9h-2q.35.95.913 1.763T7 13.2l-5.65 5.55L2.75 20l5.55-5.55l3.45 3.4zm5.1 4.35L17 18h-3.5l-.75 2h-2l3.5-9h2l3.5 9zm-2.15-4h2.8L17 12.25z"/>'
};
const languageMenu = ref<HTMLElement | null>(null);
const languageMenuButton = ref<HTMLButtonElement | null>(null);
const languageMenuOpen = ref(false);
function closeLanguageMenu() {
languageMenuOpen.value = false;
}
function toggleLanguageMenu() {
languageMenuOpen.value = !languageMenuOpen.value;
}
function selectLocale(value: string) {
emit('update:locale', value);
closeLanguageMenu();
languageMenuButton.value?.focus();
}
function onDocumentPointerDown(event: PointerEvent) {
if (languageMenu.value && !languageMenu.value.contains(event.target as Node)) {
closeLanguageMenu();
}
}
function onLanguageMenuKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault();
closeLanguageMenu();
languageMenuButton.value?.focus();
}
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown);
});
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown);
});
</script> </script>
<template> <template>
@@ -24,20 +76,52 @@ defineEmits<{
</span> </span>
</RouterLink> </RouterLink>
<nav class="nav-links" aria-label="主导航"> <nav class="nav-links" :aria-label="t('nav.main')">
<RouterLink v-for="item in navItems" :key="item.to" :to="item.to"> <RouterLink v-for="item in navItems" :key="item.to" :to="item.to">
{{ item.label }} {{ item.label }}
</RouterLink> </RouterLink>
</nav> </nav>
<div class="auth-actions"> <div class="auth-actions">
<div ref="languageMenu" class="language-menu" @keydown="onLanguageMenuKeydown">
<button
ref="languageMenuButton"
class="language-menu__trigger"
type="button"
:aria-label="t('nav.language')"
:aria-expanded="languageMenuOpen"
aria-haspopup="menu"
@click="toggleLanguageMenu"
>
<Icon :icon="translateIcon" class="language-menu__icon" aria-hidden="true" />
<span class="language-menu__glyph" aria-hidden="true">/A</span>
</button>
<div v-if="languageMenuOpen" class="language-menu__dropdown" role="menu">
<button
v-for="language in languages"
:key="language.code"
class="language-menu__item"
:class="{ active: language.code === locale }"
type="button"
role="menuitemradio"
:aria-checked="language.code === locale"
@click="selectLocale(language.code)"
>
<span>{{ language.name }}</span>
<span class="language-menu__code">{{ language.code }}</span>
</button>
</div>
</div>
<template v-if="currentUser"> <template v-if="currentUser">
<span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span> <span class="auth-user">{{ currentUser.displayName || currentUser.email }}</span>
<button class="ui-button ui-button--ghost ui-button--small" type="button" @click="$emit('logout')">退出</button> <button class="ui-button ui-button--ghost ui-button--small" type="button" @click="$emit('logout')">
{{ t('nav.logout') }}
</button>
</template> </template>
<template v-else> <template v-else>
<RouterLink class="ui-button ui-button--ghost ui-button--small" to="/login">登录</RouterLink> <RouterLink class="ui-button ui-button--ghost ui-button--small" to="/login">{{ t('nav.login') }}</RouterLink>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/register">注册</RouterLink> <RouterLink class="ui-button ui-button--primary ui-button--small" to="/register">{{ t('nav.register') }}</RouterLink>
</template> </template>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api'; import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
defineProps<{ defineProps<{
@@ -6,22 +7,76 @@ defineProps<{
history: EditHistoryEntry[]; history: EditHistoryEntry[];
}>(); }>();
const actionLabels: Record<EditHistoryAction, string> = { const { locale, t } = useI18n();
create: '创建', const changeLabelKeys: Record<string, string> = {
update: '编辑', Name: 'common.name',
delete: '删除' 名字: 'common.name',
名称: 'common.name',
'Ideal Habitat': 'pages.pokemon.environment',
'Favorite environment': 'pages.pokemon.environment',
喜欢的环境: 'pages.pokemon.environment',
Specialities: 'pages.pokemon.skills',
Skills: 'pages.pokemon.skills',
特长: 'pages.pokemon.skills',
Favourites: 'pages.pokemon.favoriteThings',
'Favorite things': 'pages.pokemon.favoriteThings',
喜欢的东西: 'pages.pokemon.favoriteThings',
'Speciality drops': 'pages.pokemon.skillDrops',
'Skill drops': 'pages.pokemon.skillDrops',
特长掉落物: 'pages.pokemon.skillDrops',
Category: 'pages.items.category',
分类: 'pages.items.category',
Usage: 'pages.items.usage',
用途: 'pages.items.usage',
Dyeable: 'pages.items.dyeable',
可染色: 'pages.items.dyeable',
'Dual dyeable': 'pages.items.dualDyeable',
可双区染色: 'pages.items.dualDyeable',
'Pattern editable': 'pages.items.patternEditable',
可改花纹: 'pages.items.patternEditable',
'No recipe': 'pages.items.noRecipe',
无材料单: 'pages.items.noRecipe',
'Acquisition methods': 'pages.items.acquisitionMethods',
入手方式: 'pages.items.acquisitionMethods',
Tags: 'pages.items.tags',
标签: 'pages.items.tags',
Recipe: 'pages.habitats.recipe',
配方: 'pages.habitats.recipe',
'Possible Pokemon': 'pages.habitats.possiblePokemon',
可能出现的宝可梦: 'pages.habitats.possiblePokemon',
Item: 'pages.recipes.item',
物品: 'pages.recipes.item',
Materials: 'pages.recipes.materials',
需要材料: 'pages.recipes.materials'
}; };
function displayName(user: UserSummary | null): string { function displayName(user: UserSummary | null): string {
return user?.displayName ?? '系统'; return user?.displayName ?? t('common.system');
} }
function actionLabel(action: EditHistoryAction): string { function actionLabel(action: EditHistoryAction): string {
return actionLabels[action]; return t(`history.${action}`);
} }
function actionMark(action: EditHistoryAction): string { function actionMark(action: EditHistoryAction): string {
return actionLabels[action].charAt(0); return actionLabel(action).charAt(0);
}
function changeLabel(label: string): string {
const key = changeLabelKeys[label];
return key ? t(key) : label;
}
function changeValue(value: string): string {
const values: Record<string, string> = {
None: t('common.none'),
: t('common.none'),
Yes: locale.value === 'zh-CN' ? '是' : 'Yes',
: locale.value === 'zh-CN' ? '是' : 'Yes',
No: locale.value === 'zh-CN' ? '否' : 'No',
: locale.value === 'zh-CN' ? '否' : 'No'
};
return values[value] ?? value;
} }
function historySummary(entry: EditHistoryEntry): string { function historySummary(entry: EditHistoryEntry): string {
@@ -29,11 +84,11 @@ function historySummary(entry: EditHistoryEntry): string {
return actionLabel(entry.action); return actionLabel(entry.action);
} }
return entry.changes.map((change) => change.label).join('、'); return entry.changes.map((change) => changeLabel(change.label)).join(locale.value === 'zh-CN' ? '、' : ', ');
} }
function formatDateTime(value: string): string { function formatDateTime(value: string): string {
return new Intl.DateTimeFormat('zh-CN', { return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short' timeStyle: 'short'
}).format(new Date(value)); }).format(new Date(value));
@@ -43,19 +98,19 @@ function formatDateTime(value: string): string {
<template> <template>
<aside class="edit-history-panel" aria-labelledby="edit-history-panel-title"> <aside class="edit-history-panel" aria-labelledby="edit-history-panel-title">
<div class="edit-history-panel__header"> <div class="edit-history-panel__header">
<h2 id="edit-history-panel-title">贡献记录</h2> <h2 id="edit-history-panel-title">{{ t('history.title') }}</h2>
</div> </div>
<dl class="edit-history-summary"> <dl class="edit-history-summary">
<div> <div>
<dt>由谁创建</dt> <dt>{{ t('history.createdBy') }}</dt>
<dd> <dd>
<strong>{{ displayName(entity.createdBy) }}</strong> <strong>{{ displayName(entity.createdBy) }}</strong>
<time :datetime="entity.createdAt">{{ formatDateTime(entity.createdAt) }}</time> <time :datetime="entity.createdAt">{{ formatDateTime(entity.createdAt) }}</time>
</dd> </dd>
</div> </div>
<div> <div>
<dt>最后编辑</dt> <dt>{{ t('history.lastEdited') }}</dt>
<dd> <dd>
<strong>{{ displayName(entity.updatedBy) }}</strong> <strong>{{ displayName(entity.updatedBy) }}</strong>
<time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time> <time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
@@ -64,7 +119,7 @@ function formatDateTime(value: string): string {
</dl> </dl>
<section class="edit-history-list" aria-labelledby="edit-history-list-title"> <section class="edit-history-list" aria-labelledby="edit-history-list-title">
<h3 id="edit-history-list-title">编辑历史</h3> <h3 id="edit-history-list-title">{{ t('history.editHistory') }}</h3>
<ol v-if="history.length" class="edit-timeline"> <ol v-if="history.length" class="edit-timeline">
<li v-for="entry in history" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`"> <li v-for="entry in history" :key="`${entry.action}-${entry.createdAt}-${entry.user?.id ?? 'system'}`">
<span class="edit-timeline__avatar" aria-hidden="true">{{ actionMark(entry.action) }}</span> <span class="edit-timeline__avatar" aria-hidden="true">{{ actionMark(entry.action) }}</span>
@@ -77,27 +132,27 @@ function formatDateTime(value: string): string {
<div class="edit-history-entry__content"> <div class="edit-history-entry__content">
<dl v-if="entry.changes.length" class="edit-change-list"> <dl v-if="entry.changes.length" class="edit-change-list">
<div v-for="change in entry.changes" :key="`${change.label}-${change.before}-${change.after}`"> <div v-for="change in entry.changes" :key="`${change.label}-${change.before}-${change.after}`">
<dt>{{ change.label }}</dt> <dt>{{ changeLabel(change.label) }}</dt>
<dd> <dd>
<span class="edit-change-list__label">修改前</span> <span class="edit-change-list__label">{{ t('history.before') }}</span>
<span>{{ change.before }}</span> <span>{{ changeValue(change.before) }}</span>
<span class="edit-change-list__label">修改后</span> <span class="edit-change-list__label">{{ t('history.after') }}</span>
<span>{{ change.after }}</span> <span>{{ changeValue(change.after) }}</span>
</dd> </dd>
</div> </div>
</dl> </dl>
<dl class="edit-history-detail-meta"> <dl class="edit-history-detail-meta">
<div> <div>
<dt>作者</dt> <dt>{{ t('history.author') }}</dt>
<dd>{{ displayName(entry.user) }}</dd> <dd>{{ displayName(entry.user) }}</dd>
</div> </div>
<div> <div>
<dt>时间</dt> <dt>{{ t('history.time') }}</dt>
<dd><time :datetime="entry.createdAt">{{ formatDateTime(entry.createdAt) }}</time></dd> <dd><time :datetime="entry.createdAt">{{ formatDateTime(entry.createdAt) }}</time></dd>
</div> </div>
<div> <div>
<dt>动作</dt> <dt>{{ t('history.action') }}</dt>
<dd>{{ actionLabel(entry.action) }}</dd> <dd>{{ actionLabel(entry.action) }}</dd>
</div> </div>
</dl> </dl>
@@ -106,7 +161,7 @@ function formatDateTime(value: string): string {
</div> </div>
</li> </li>
</ol> </ol>
<p v-else class="meta-line">暂无编辑历史</p> <p v-else class="meta-line">{{ t('history.empty') }}</p>
</section> </section>
</aside> </aside>
</template> </template>

View File

@@ -1,12 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { EditInfo } from '../services/api'; import type { EditInfo } from '../services/api';
defineProps<{ defineProps<{
entity: EditInfo; entity: EditInfo;
}>(); }>();
const { locale, t } = useI18n();
function formatDateTime(value: string): string { function formatDateTime(value: string): string {
return new Intl.DateTimeFormat('zh-CN', { return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium', dateStyle: 'medium',
timeStyle: 'short' timeStyle: 'short'
}).format(new Date(value)); }).format(new Date(value));
@@ -15,6 +18,6 @@ function formatDateTime(value: string): string {
<template> <template>
<p class="edit-meta"> <p class="edit-meta">
最后编辑{{ entity.updatedBy?.displayName ?? '系统' }} / {{ formatDateTime(entity.updatedAt) }} {{ t('history.lastEdited') }}: {{ entity.updatedBy?.displayName ?? t('common.system') }} / {{ formatDateTime(entity.updatedAt) }}
</p> </p>
</template> </template>

View File

@@ -1,5 +1,11 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template> <template>
<section class="filter-panel" aria-label="筛选"> <section class="filter-panel" :aria-label="t('common.filters')">
<slot></slot> <slot></slot>
</section> </section>
</template> </template>

View File

@@ -0,0 +1,211 @@
<script setup lang="ts" generic="T">
import { ref, shallowRef } from 'vue';
const props = withDefaults(defineProps<{
items: T[];
itemKey: (item: T) => string | number;
itemLabel: (item: T) => string;
disabled?: boolean;
handleLabel: (name: string) => string;
handleTitle: string;
}>(), {
disabled: false
});
const emit = defineEmits<{
reorder: [items: T[], originalItems: T[]];
preview: [items: T[]];
cancel: [items: T[]];
}>();
const draggingKey = ref<string | number | null>(null);
const dropTargetKey = ref<string | number | null>(null);
const insertAfterTarget = ref(false);
const sourceItems = shallowRef<T[]>([]);
const dropCommitted = ref(false);
function keyFor(item: T): string | number {
return props.itemKey(item);
}
function sameKey(first: string | number, second: string | number): boolean {
return String(first) === String(second);
}
function reorderedItems(items: T[], draggedKeyValue: string | number, targetKeyValue: string | number, insertAfter: boolean): T[] {
if (sameKey(draggedKeyValue, targetKeyValue)) {
return items;
}
const draggedItem = items.find((item) => sameKey(keyFor(item), draggedKeyValue));
if (!draggedItem) {
return items;
}
const nextItems = items.filter((item) => !sameKey(keyFor(item), draggedKeyValue));
const targetIndex = nextItems.findIndex((item) => sameKey(keyFor(item), targetKeyValue));
if (targetIndex < 0) {
return items;
}
nextItems.splice(targetIndex + (insertAfter ? 1 : 0), 0, draggedItem);
return nextItems;
}
function hasOrderChanged(currentItems: T[], nextItems: T[]): boolean {
return currentItems.length !== nextItems.length || currentItems.some((item, index) => !sameKey(keyFor(item), keyFor(nextItems[index])));
}
function clearDragState() {
draggingKey.value = null;
dropTargetKey.value = null;
insertAfterTarget.value = false;
sourceItems.value = [];
dropCommitted.value = false;
}
function startDrag(item: T, event: Event) {
if (props.disabled) {
return;
}
const key = keyFor(item);
draggingKey.value = key;
sourceItems.value = [...props.items];
dropCommitted.value = false;
const dragEvent = event instanceof DragEvent ? event : null;
dragEvent?.dataTransfer?.setData('text/plain', String(key));
if (dragEvent?.dataTransfer) {
dragEvent.dataTransfer.effectAllowed = 'move';
dragEvent.dataTransfer.dropEffect = 'move';
}
}
function endDrag() {
if (draggingKey.value !== null && !dropCommitted.value && sourceItems.value.length) {
emit('cancel', [...sourceItems.value]);
}
clearDragState();
}
function previewDrop(targetItem: T, event: Event) {
if (props.disabled) {
return;
}
const dragEvent = event instanceof DragEvent ? event : null;
const draggedKey = draggingKey.value ?? dragEvent?.dataTransfer?.getData('text/plain');
const targetKey = keyFor(targetItem);
if (draggedKey === null || draggedKey === undefined || draggedKey === '') {
return;
}
if (sameKey(draggedKey, targetKey)) {
dropTargetKey.value = null;
insertAfterTarget.value = false;
return;
}
if (dragEvent?.dataTransfer) {
dragEvent.dataTransfer.dropEffect = 'move';
}
const targetElement = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
const insertAfter = targetElement
? (dragEvent?.clientY ?? 0) > targetElement.getBoundingClientRect().top + targetElement.getBoundingClientRect().height / 2
: false;
dropTargetKey.value = targetKey;
insertAfterTarget.value = insertAfter;
const nextItems = reorderedItems(props.items, draggedKey, targetKey, insertAfter);
if (hasOrderChanged(props.items, nextItems)) {
emit('preview', nextItems);
}
}
function dropItem(targetItem: T, event: Event) {
if (props.disabled || draggingKey.value === null) {
endDrag();
return;
}
previewDrop(targetItem, event);
const nextItems = [...props.items];
const originalItems = sourceItems.value.length ? [...sourceItems.value] : nextItems;
dropCommitted.value = true;
clearDragState();
if (!hasOrderChanged(originalItems, nextItems)) {
return;
}
emit('reorder', nextItems, originalItems);
}
function moveByKeyboard(item: T, offset: -1 | 1) {
if (props.disabled) {
return;
}
const key = keyFor(item);
const currentIndex = props.items.findIndex((row) => sameKey(keyFor(row), key));
const targetIndex = currentIndex + offset;
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= props.items.length) {
return;
}
const nextItems = [...props.items];
const [movedItem] = nextItems.splice(currentIndex, 1);
nextItems.splice(targetIndex, 0, movedItem);
emit('reorder', nextItems, [...props.items]);
}
function handleKeydown(item: T, event: KeyboardEvent) {
if (event.key === 'ArrowUp') {
event.preventDefault();
moveByKeyboard(item, -1);
}
if (event.key === 'ArrowDown') {
event.preventDefault();
moveByKeyboard(item, 1);
}
}
</script>
<template>
<TransitionGroup name="reorderable-list" tag="ul" class="row-list reorderable-list">
<li
v-for="item in items"
:key="keyFor(item)"
class="reorderable-row"
:class="{
'is-dragging': draggingKey === keyFor(item),
'is-drop-target': dropTargetKey === keyFor(item),
'is-drop-after': dropTargetKey === keyFor(item) && insertAfterTarget,
'is-drop-before': dropTargetKey === keyFor(item) && !insertAfterTarget
}"
@dragover.prevent="previewDrop(item, $event)"
@drop.prevent="dropItem(item, $event)"
>
<button
type="button"
class="drag-handle"
draggable="true"
:aria-label="handleLabel(itemLabel(item))"
:title="handleTitle"
:disabled="disabled"
@dragstart="startDrag(item, $event)"
@dragend="endDrag"
@keydown="handleKeydown(item, $event)"
>
<span aria-hidden="true"></span>
</button>
<slot :item="item" />
</li>
</TransitionGroup>
</template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
export type TagsSelectOption = { export type TagsSelectOption = {
id: number | string; id: number | string;
@@ -32,12 +33,8 @@ const props = withDefaults(
{ {
multiple: true, multiple: true,
max: 0, max: 0,
placeholder: '搜索或选择',
searchPlaceholder: '搜索',
emptyText: '没有匹配项',
allowCreate: false, allowCreate: false,
creating: false, creating: false
createLabel: '添加「{name}」'
} }
); );
@@ -46,6 +43,7 @@ const emit = defineEmits<{
create: [name: string]; create: [name: string];
}>(); }>();
const { t } = useI18n();
const root = ref<HTMLElement | null>(null); const root = ref<HTMLElement | null>(null);
const searchInput = ref<HTMLInputElement | null>(null); const searchInput = ref<HTMLInputElement | null>(null);
const isOpen = ref(false); const isOpen = ref(false);
@@ -85,7 +83,10 @@ const hasExactMatch = computed(() => {
return optionRows.value.some((option) => option.label.toLowerCase() === keyword); return optionRows.value.some((option) => option.label.toLowerCase() === keyword);
}); });
const canCreate = computed(() => props.allowCreate && createName.value !== '' && !hasExactMatch.value && !maxReached.value); const canCreate = computed(() => props.allowCreate && createName.value !== '' && !hasExactMatch.value && !maxReached.value);
const createText = computed(() => props.createLabel.replace('{name}', createName.value)); const placeholderText = computed(() => props.placeholder ?? t('common.searchOrSelect'));
const searchPlaceholderText = computed(() => props.searchPlaceholder ?? t('common.search'));
const emptyTextValue = computed(() => props.emptyText ?? t('common.noMatches'));
const createText = computed(() => props.createLabel?.replace('{name}', createName.value) ?? t('common.createNamed', { name: createName.value }));
const optionsListId = computed(() => `${props.id}-options`); const optionsListId = computed(() => `${props.id}-options`);
const createOptionId = computed(() => `${props.id}-create`); const createOptionId = computed(() => `${props.id}-create`);
const candidateRows = computed<CandidateRow[]>(() => { const candidateRows = computed<CandidateRow[]>(() => {
@@ -252,7 +253,7 @@ watch(candidateRows, clampActiveIndex);
class="tags-select__remove" class="tags-select__remove"
role="button" role="button"
tabindex="0" tabindex="0"
:aria-label="`移除${option.label}`" :aria-label="t('common.removeNamed', { name: option.label })"
@click.stop="remove(option.value)" @click.stop="remove(option.value)"
@keydown.enter.stop.prevent="remove(option.value)" @keydown.enter.stop.prevent="remove(option.value)"
@keydown.space.stop.prevent="remove(option.value)" @keydown.space.stop.prevent="remove(option.value)"
@@ -263,7 +264,7 @@ watch(candidateRows, clampActiveIndex);
</template> </template>
<span v-else class="tags-select__single-value">{{ selectedLabel }}</span> <span v-else class="tags-select__single-value">{{ selectedLabel }}</span>
</span> </span>
<span v-else class="tags-select__placeholder">{{ placeholder }}</span> <span v-else class="tags-select__placeholder">{{ placeholderText }}</span>
<span class="tags-select__arrow" aria-hidden="true"></span> <span class="tags-select__arrow" aria-hidden="true"></span>
</button> </button>
@@ -273,7 +274,7 @@ watch(candidateRows, clampActiveIndex);
v-model="search" v-model="search"
class="tags-select__search" class="tags-select__search"
type="search" type="search"
:placeholder="searchPlaceholder" :placeholder="searchPlaceholderText"
:aria-activedescendant="activeDescendant" :aria-activedescendant="activeDescendant"
:aria-controls="optionsListId" :aria-controls="optionsListId"
aria-autocomplete="list" aria-autocomplete="list"
@@ -297,7 +298,7 @@ watch(candidateRows, clampActiveIndex);
@click="selectOption(option.value)" @click="selectOption(option.value)"
> >
<span>{{ option.label }}</span> <span>{{ option.label }}</span>
<span v-if="selectedValues.has(option.value)" class="tags-select__state">已选</span> <span v-if="selectedValues.has(option.value)" class="tags-select__state">{{ t('common.selected') }}</span>
</button> </button>
<button <button
v-if="canCreate" v-if="canCreate"
@@ -309,9 +310,9 @@ watch(candidateRows, clampActiveIndex);
@click="createOption" @click="createOption"
> >
<span>{{ createText }}</span> <span>{{ createText }}</span>
<span v-if="creating" class="tags-select__state">添加中</span> <span v-if="creating" class="tags-select__state">{{ t('common.creating') }}</span>
</button> </button>
<p v-if="!filteredRows.length && !canCreate" class="tags-select__empty">{{ emptyText }}</p> <p v-if="!filteredRows.length && !canCreate" class="tags-select__empty">{{ emptyTextValue }}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,76 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import type { Language, TranslationField, TranslationMap } from '../services/api';
const props = defineProps<{
idPrefix: string;
field: TranslationField;
label: string;
baseValue: string;
translations: TranslationMap;
languages: Language[];
required?: boolean;
}>();
const emit = defineEmits<{
'update:baseValue': [value: string];
'update:translations': [value: TranslationMap];
}>();
const { t } = useI18n();
const visibleLanguages = computed(() => props.languages.filter((language) => language.enabled));
const defaultLanguage = computed(() => visibleLanguages.value.find((language) => language.isDefault) ?? visibleLanguages.value[0]);
function fieldValue(language: Language): string {
if (language.code === defaultLanguage.value?.code) {
return props.baseValue;
}
return props.translations[language.code]?.[props.field] ?? '';
}
function updateField(language: Language, value: string) {
if (language.code === defaultLanguage.value?.code) {
emit('update:baseValue', value);
return;
}
const nextTranslations: TranslationMap = { ...props.translations };
const nextFields = { ...(nextTranslations[language.code] ?? {}) };
if (value.trim() === '') {
delete nextFields[props.field];
} else {
nextFields[props.field] = value;
}
if (Object.keys(nextFields).length) {
nextTranslations[language.code] = nextFields;
} else {
delete nextTranslations[language.code];
}
emit('update:translations', nextTranslations);
}
function inputValue(event: Event): string {
return event.target instanceof HTMLInputElement ? event.target.value : '';
}
</script>
<template>
<div class="translation-fields">
<div v-for="language in visibleLanguages" :key="language.code" class="field">
<label :for="`${idPrefix}-${language.code}`">
{{ t('common.fieldForLanguage', { field: label, language: language.name }) }}
</label>
<input
:id="`${idPrefix}-${language.code}`"
:value="fieldValue(language)"
:required="required && language.code === defaultLanguage?.code"
@input="updateField(language, inputValue($event))"
/>
</div>
</div>
</template>

565
frontend/src/i18n.ts Normal file
View File

@@ -0,0 +1,565 @@
import { createI18n } from 'vue-i18n';
export const defaultLocale = 'en';
const localeStorageKey = 'pokopia_locale';
const localeChangeEvent = 'pokopia-locale-change';
const messages = {
en: {
common: {
add: 'Add',
admin: 'Admin',
all: 'All',
back: 'Back',
backToList: 'Back to list',
cancel: 'Cancel',
create: 'Create',
delete: 'Delete',
edit: 'Edit',
filters: 'Filters',
loading: 'Loading',
name: 'Name',
new: 'New',
none: 'None',
save: 'Save',
saving: 'Saving',
search: 'Search',
select: 'Select',
selected: 'Selected',
system: 'System',
noRecords: 'No records',
fieldForLanguage: '{field} ({language})',
searchOrSelect: 'Search or select',
noMatches: 'No matches',
createNamed: 'Add "{name}"',
creating: 'Adding',
removeNamed: 'Remove {name}',
quantity: 'Quantity',
required: 'Required'
},
nav: {
pokemon: 'Pokemon',
habitats: 'Habitats',
items: 'Items',
recipes: 'Recipes',
checklist: 'CheckList',
admin: 'Admin',
main: 'Main navigation',
language: 'Language',
login: 'Log in',
logout: 'Log out',
register: 'Register'
},
auth: {
email: 'Email',
password: 'Password',
displayName: 'Display name',
loginTitle: 'Log in',
loginSubtitle: 'Use a verified email to enter Pokopia Wiki.',
loggingIn: 'Logging in',
loginFailed: 'Login failed',
noAccount: 'No account yet?',
registerTitle: 'Register',
registerSubtitle: 'Verify your email after creating an account.',
registerFailed: 'Registration failed',
sending: 'Sending',
sendVerification: 'Send verification email',
hasAccount: 'Already have an account?',
verifyTitle: 'Email verification',
verifySubtitle: 'You can log in after verification is complete.',
verifyingEmail: 'Verifying email',
invalidVerification: 'The verification link is invalid or expired.',
verifyFailed: 'Email verification failed',
goLogin: 'Go to login'
},
errors: {
requestFailed: 'Request failed ({status})',
operationFailed: 'Operation failed',
loadFailed: 'Load failed',
addFailed: 'Add failed',
saveFailed: 'Save failed',
completeEmailVerification: 'Please complete email verification first.'
},
pages: {
pokemon: {
title: 'Pokemon',
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
detailKicker: 'Pokédex Detail',
editKicker: 'Pokédex Edit',
editSubtitle: 'Maintain Pokemon profile, specialities, and favourites.',
newTitle: 'New Pokemon',
editTitle: 'Edit #{id} {name}',
loadingList: 'Loading Pokemon list',
loadingDetail: 'Loading Pokemon detail',
loadingEdit: 'Loading Pokemon editor',
environmentPrefix: 'Ideal Habitat: {name}',
environment: 'Ideal Habitat',
skills: 'Specialities',
skillMatchMode: 'Speciality match mode',
any: 'Any',
all: 'All',
favoriteThings: 'Favourites',
favoriteThingMatchMode: 'Favourites match mode',
skillDrops: 'Speciality drops',
skillDrop: '{name} drop',
dropItem: 'Drop item',
searchPokemon: 'Search Pokemon',
relatedItems: 'Related items',
relatedItemCategory: 'Related item category',
habitats: 'Habitats',
namePlaceholder: 'Name',
searchEnvironment: 'Search ideal habitats',
searchSkills: 'Search specialities',
searchFavoriteThings: 'Search favourites',
searchItems: 'Search items'
},
habitats: {
title: 'Habitats',
subtitle: 'View recipes and Pokemon that may appear.',
detailSubtitle: 'Habitat detail',
editSubtitle: 'Maintain habitat recipes and possible Pokemon appearances.',
newTitle: 'New habitat',
editTitle: 'Edit {name}',
fallbackName: 'Habitat',
loadingList: 'Loading habitat list',
loadingDetail: 'Loading habitat detail',
loadingEdit: 'Loading habitat editor',
recipe: 'Recipe',
recipeList: 'Recipe list',
possiblePokemon: 'Possible Pokemon',
addItem: 'Add item',
addPokemon: 'Add Pokemon',
maps: 'Maps',
searchMaps: 'Search maps'
},
items: {
title: 'Items',
subtitle: 'Browse items by category, usage, and tags.',
detailKicker: 'Item Detail',
detailSubtitle: 'Item detail',
editKicker: 'Item Edit',
editSubtitle: 'Maintain item category, usage, acquisition methods, customization, and tags.',
newTitle: 'New item',
editTitle: 'Edit {name}',
fallbackName: 'Item',
loadingList: 'Loading item list',
loadingDetail: 'Loading item detail',
loadingEdit: 'Loading item editor',
category: 'Category',
usage: 'Usage',
tags: 'Tags',
acquisitionMethods: 'Acquisition methods',
customization: 'Customization',
dyeable: 'Dyeable',
dualDyeable: 'Dual dyeable',
patternEditable: 'Pattern editable',
noRecipe: 'No recipe',
recipeInfo: 'Recipe info',
relatedRecipes: 'Related recipes',
relatedHabitats: 'Related habitats',
pokemonDrops: 'Pokemon drops',
createRecipe: 'Create recipe',
searchCategory: 'Search categories',
searchUsage: 'Search usages',
searchMethods: 'Search acquisition methods',
searchTags: 'Search tags'
},
recipes: {
title: 'Recipes',
subtitle: 'Browse recipes by category, usage, and tags.',
detailKicker: 'Recipe Detail',
detailSubtitle: 'Recipe detail',
editKicker: 'Recipe Edit',
editSubtitle: 'Maintain result item, acquisition methods, and materials.',
newTitle: 'New recipe',
editTitle: 'Edit {name}',
fallbackName: 'Recipe',
loadingList: 'Loading recipe list',
loadingDetail: 'Loading recipe detail',
loadingEdit: 'Loading recipe editor',
item: 'Item',
materials: 'Materials',
addMaterial: 'Add material'
},
checklist: {
title: 'Daily checklist',
subtitle: 'See what can be completed each day.',
sectionTitle: 'Daily tasks',
empty: 'No daily checklist',
loading: 'Loading daily checklist',
task: 'Task',
newTask: 'New task',
editTask: 'Edit task'
},
admin: {
title: 'Admin',
subtitle: 'Maintain system configuration and manage Wiki records.',
modules: 'Admin modules',
loading: 'Loading admin list',
config: 'System config',
configType: 'System config type',
checklist: 'CheckList',
pokemonList: 'Pokemon list',
itemList: 'Item list',
recipeList: 'Recipe list',
habitatList: 'Habitat list',
languages: 'Languages',
newConfig: 'New {name}',
editConfig: 'Edit {name}',
hasItemDrop: 'Has item drop',
dragSort: 'Drag to reorder: {name}',
dragSortTitle: 'Drag to reorder',
languageCode: 'Code',
languageName: 'Language name',
enabled: 'Enabled',
defaultLanguage: 'Default language',
sortOrder: 'Sort order',
newLanguage: 'New language',
editLanguage: 'Edit language'
}
},
config: {
skills: 'Specialities',
environments: 'Ideal Habitats',
favoriteThings: 'Favourites / tags',
itemCategories: 'Item categories',
itemUsages: 'Item usages',
acquisitionMethods: 'Acquisition methods',
maps: 'Maps'
},
appearance: {
time: 'Time',
weather: 'Weather',
rarity: 'Rarity',
map: 'Map',
maps: 'Maps',
morning: 'Morning',
noon: 'Noon',
evening: 'Evening',
night: 'Night',
sunny: 'Sunny',
cloudy: 'Cloudy',
rainy: 'Rainy',
stars: '{count} stars'
},
history: {
title: 'Contribution records',
createdBy: 'Created by',
lastEdited: 'Last edited',
editHistory: 'Edit history',
before: 'Before',
after: 'After',
author: 'Author',
time: 'Time',
action: 'Action',
create: 'Create',
update: 'Edit',
delete: 'Delete',
empty: 'No edit history'
}
},
'zh-CN': {
common: {
add: '添加',
admin: '管理',
all: '全部',
back: '返回',
backToList: '返回列表',
cancel: '取消',
create: '创建',
delete: '删除',
edit: '编辑',
filters: '筛选',
loading: '加载中',
name: '名称',
new: '新建',
none: '无',
save: '保存',
saving: '保存中',
search: '搜索',
select: '请选择',
selected: '已选',
system: '系统',
noRecords: '暂无记录',
fieldForLanguage: '{field}{language}',
searchOrSelect: '搜索或选择',
noMatches: '没有匹配项',
createNamed: '添加「{name}」',
creating: '添加中',
removeNamed: '移除{name}',
quantity: '数量',
required: '必填'
},
nav: {
pokemon: 'Pokemon',
habitats: '栖息地',
items: '物品',
recipes: '材料单',
checklist: 'CheckList',
admin: '管理',
main: '主导航',
language: '语言',
login: '登录',
logout: '退出',
register: '注册'
},
auth: {
email: '邮箱',
password: '密码',
displayName: '显示名',
loginTitle: '登录',
loginSubtitle: '使用已验证邮箱进入 Pokopia Wiki',
loggingIn: '登录中',
loginFailed: '登录失败',
noAccount: '还没有账号?',
registerTitle: '注册',
registerSubtitle: '创建账号后需要完成邮箱验证',
registerFailed: '注册失败',
sending: '发送中',
sendVerification: '发送验证邮件',
hasAccount: '已有账号?',
verifyTitle: '邮箱验证',
verifySubtitle: '完成验证后即可登录',
verifyingEmail: '正在验证邮箱',
invalidVerification: '验证链接无效或已过期',
verifyFailed: '邮箱验证失败',
goLogin: '去登录'
},
errors: {
requestFailed: '请求失败({status}',
operationFailed: '操作失败',
loadFailed: '加载失败',
addFailed: '添加失败',
saveFailed: '保存失败',
completeEmailVerification: '请先完成邮箱验证'
},
pages: {
pokemon: {
title: 'Pokemon',
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
detailKicker: 'Pokédex Detail',
editKicker: 'Pokédex Edit',
editSubtitle: '维护 Pokemon 基本资料、特长和喜欢的东西。',
newTitle: '新增 Pokemon',
editTitle: '编辑 #{id} {name}',
loadingList: '正在加载 Pokemon 列表',
loadingDetail: '正在加载 Pokemon 详情',
loadingEdit: '正在加载 Pokemon 编辑内容',
environmentPrefix: '喜欢的环境:{name}',
environment: '喜欢的环境',
skills: '特长',
skillMatchMode: '特长匹配方式',
any: '任意',
all: '全部',
favoriteThings: '喜欢的东西',
favoriteThingMatchMode: '喜欢的东西匹配方式',
skillDrops: '特长掉落物',
skillDrop: '{name}掉落物',
dropItem: '掉落物',
searchPokemon: '搜索 Pokemon',
relatedItems: '关联物品',
relatedItemCategory: '关联物品分类',
habitats: '栖息地',
namePlaceholder: '名字',
searchEnvironment: '搜索喜欢的环境',
searchSkills: '搜索特长',
searchFavoriteThings: '搜索喜欢的东西',
searchItems: '搜索物品'
},
habitats: {
title: '栖息地',
subtitle: '查看配方和可能出现的宝可梦。',
detailSubtitle: '栖息地详情',
editSubtitle: '维护栖息地配方和可能出现的 Pokemon。',
newTitle: '新增栖息地',
editTitle: '编辑 {name}',
fallbackName: '栖息地',
loadingList: '正在加载栖息地列表',
loadingDetail: '正在加载栖息地详情',
loadingEdit: '正在加载栖息地编辑内容',
recipe: '配方',
recipeList: '配方列表',
possiblePokemon: '可能出现的宝可梦',
addItem: '添加物品',
addPokemon: '添加 Pokemon',
maps: '地图',
searchMaps: '搜索地图'
},
items: {
title: '物品',
subtitle: '按分类、用途、标签查看物品。',
detailKicker: 'Item Detail',
detailSubtitle: '物品详情',
editKicker: 'Item Edit',
editSubtitle: '维护物品分类、用途、入手方式、自定义和标签。',
newTitle: '新增物品',
editTitle: '编辑 {name}',
fallbackName: '物品',
loadingList: '正在加载列表',
loadingDetail: '正在加载物品详情',
loadingEdit: '正在加载物品编辑内容',
category: '分类',
usage: '用途',
tags: '标签',
acquisitionMethods: '入手方式',
customization: '自定义',
dyeable: '可染色',
dualDyeable: '可双区染色',
patternEditable: '可改花纹',
noRecipe: '无材料单',
recipeInfo: '材料单信息',
relatedRecipes: '相关材料单',
relatedHabitats: '相关栖息地',
pokemonDrops: 'Pokemon 掉落',
createRecipe: '创建材料单',
searchCategory: '搜索分类',
searchUsage: '搜索用途',
searchMethods: '搜索入手方式',
searchTags: '搜索标签'
},
recipes: {
title: '材料单',
subtitle: '按分类、用途、标签查看材料单。',
detailKicker: 'Recipe Detail',
detailSubtitle: '材料单详情',
editKicker: 'Recipe Edit',
editSubtitle: '维护材料单结果物品、入手方式和需要材料。',
newTitle: '新增材料单',
editTitle: '编辑 {name}',
fallbackName: '材料单',
loadingList: '正在加载材料单列表',
loadingDetail: '正在加载材料单详情',
loadingEdit: '正在加载材料单编辑内容',
item: '物品',
materials: '需要材料',
addMaterial: '添加材料'
},
checklist: {
title: '每日清单',
subtitle: '查看每天可以完成的事项。',
sectionTitle: '每日做什么',
empty: '暂无每日清单',
loading: '正在加载每日清单',
task: 'Task',
newTask: '新增 Task',
editTask: '编辑 Task'
},
admin: {
title: '管理',
subtitle: '维护系统配置,查看并删除 Wiki 数据记录。',
modules: '管理模块',
loading: '正在加载管理列表',
config: '系统配置',
configType: '系统配置类型',
checklist: 'CheckList',
pokemonList: 'Pokemon 列表',
itemList: '物品列表',
recipeList: '材料单列表',
habitatList: '栖息地列表',
languages: '语言',
newConfig: '新增{name}',
editConfig: '编辑{name}',
hasItemDrop: '有掉落物',
dragSort: '拖曳排序:{name}',
dragSortTitle: '拖曳排序',
languageCode: 'Code',
languageName: '语言名称',
enabled: '启用',
defaultLanguage: '默认语言',
sortOrder: '排序',
newLanguage: '新增语言',
editLanguage: '编辑语言'
}
},
config: {
skills: '特长',
environments: '喜欢的环境',
favoriteThings: '喜欢的东西 / 标签',
itemCategories: '物品分类',
itemUsages: '物品用途',
acquisitionMethods: '入手方式',
maps: '地图'
},
appearance: {
time: '时段',
weather: '天气',
rarity: '稀有度',
map: '地图',
maps: '出现地图',
morning: '早晨',
noon: '中午',
evening: '傍晚',
night: '晚上',
sunny: '晴天',
cloudy: '阴天',
rainy: '雨天',
stars: '{count} 星'
},
history: {
title: '贡献记录',
createdBy: '由谁创建',
lastEdited: '最后编辑',
editHistory: '编辑历史',
before: '修改前',
after: '修改后',
author: '作者',
time: '时间',
action: '动作',
create: '创建',
update: '编辑',
delete: '删除',
empty: '暂无编辑历史'
}
}
};
export type MessageKey = keyof typeof messages.en;
export const i18n = createI18n({
legacy: false,
globalInjection: true,
locale: readStoredLocale(),
fallbackLocale: defaultLocale,
messages
});
function readStoredLocale(): string {
if (typeof localStorage === 'undefined') {
return defaultLocale;
}
const storedLocale = localStorage.getItem(localeStorageKey);
return storedLocale && storedLocale.trim() !== '' ? storedLocale : defaultLocale;
}
function globalLocaleRef() {
return i18n.global.locale as unknown as { value: string };
}
export function getCurrentLocale(): string {
return globalLocaleRef().value || defaultLocale;
}
export function setCurrentLocale(locale: string): void {
const nextLocale = locale || defaultLocale;
globalLocaleRef().value = nextLocale;
if (typeof document !== 'undefined') {
document.documentElement.lang = nextLocale;
}
if (typeof localStorage !== 'undefined') {
localStorage.setItem(localeStorageKey, nextLocale);
}
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event(localeChangeEvent));
}
}
export function onLocaleChange(callback: () => void): () => void {
window.addEventListener(localeChangeEvent, callback);
return () => window.removeEventListener(localeChangeEvent, callback);
}
setCurrentLocale(getCurrentLocale());

View File

@@ -1,6 +1,7 @@
import { createApp } from 'vue'; import { createApp } from 'vue';
import App from './App.vue'; import App from './App.vue';
import { i18n } from './i18n';
import { router } from './router'; import { router } from './router';
import './styles/main.css'; import './styles/main.css';
createApp(App).use(router).mount('#app'); createApp(App).use(i18n).use(router).mount('#app');

View File

@@ -1,10 +1,25 @@
import { getCurrentLocale } from '../i18n';
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001'; const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token'; const authTokenKey = 'pokopia_auth_token';
const authChangeEvent = 'pokopia-auth-change'; const authChangeEvent = 'pokopia-auth-change';
export type TranslationField = 'name' | 'title';
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
export interface Language {
code: string;
name: string;
enabled: boolean;
isDefault: boolean;
sortOrder: number;
}
export interface NamedEntity { export interface NamedEntity {
id: number; id: number;
name: string; name: string;
baseName?: string;
translations?: TranslationMap;
} }
export interface Skill extends NamedEntity { export interface Skill extends NamedEntity {
@@ -41,6 +56,7 @@ export interface EditHistoryEntry {
export interface Pokemon extends EditInfo { export interface Pokemon extends EditInfo {
id: number; id: number;
name: string; name: string;
translations?: TranslationMap;
environment: NamedEntity; environment: NamedEntity;
skills: Skill[]; skills: Skill[];
favorite_things: NamedEntity[]; favorite_things: NamedEntity[];
@@ -63,6 +79,7 @@ export interface PokemonDetail extends Pokemon {
export interface Habitat extends EditInfo { export interface Habitat extends EditInfo {
id: number; id: number;
name: string; name: string;
translations?: TranslationMap;
recipe: Array<NamedEntity & { quantity: number }>; recipe: Array<NamedEntity & { quantity: number }>;
pokemon?: NamedEntity[]; pokemon?: NamedEntity[];
} }
@@ -96,6 +113,7 @@ export interface HabitatUsage {
export interface Item extends EditInfo { export interface Item extends EditInfo {
id: number; id: number;
name: string; name: string;
translations?: TranslationMap;
category: NamedEntity; category: NamedEntity;
usage: NamedEntity | null; usage: NamedEntity | null;
customization: { customization: {
@@ -129,6 +147,7 @@ export interface Recipe extends EditInfo {
export interface DailyChecklistItem { export interface DailyChecklistItem {
id: number; id: number;
title: string; title: string;
translations?: TranslationMap;
} }
export interface RecipeDetail extends Recipe { export interface RecipeDetail extends Recipe {
@@ -181,6 +200,7 @@ export type ConfigType =
export interface PokemonPayload { export interface PokemonPayload {
id: number; id: number;
name: string; name: string;
translations?: TranslationMap;
environmentId: number; environmentId: number;
skillIds: number[]; skillIds: number[];
favoriteThingIds: number[]; favoriteThingIds: number[];
@@ -189,6 +209,7 @@ export interface PokemonPayload {
export interface ItemPayload { export interface ItemPayload {
name: string; name: string;
translations?: TranslationMap;
categoryId: number; categoryId: number;
usageId: number | null; usageId: number | null;
dyeable: boolean; dyeable: boolean;
@@ -207,6 +228,7 @@ export interface RecipePayload {
export interface HabitatPayload { export interface HabitatPayload {
name: string; name: string;
translations?: TranslationMap;
recipeItems: Array<{ itemId: number; quantity: number }>; recipeItems: Array<{ itemId: number; quantity: number }>;
pokemonAppearances: Array<{ pokemonAppearances: Array<{
pokemonId: number; pokemonId: number;
@@ -219,6 +241,7 @@ export interface HabitatPayload {
export interface DailyChecklistPayload { export interface DailyChecklistPayload {
title: string; title: string;
translations?: TranslationMap;
} }
export function buildQuery(params: Record<string, string | number | undefined>): string { export function buildQuery(params: Record<string, string | number | undefined>): string {
@@ -261,9 +284,12 @@ export function onAuthTokenChange(callback: () => void): () => void {
return () => window.removeEventListener(authChangeEvent, callback); return () => window.removeEventListener(authChangeEvent, callback);
} }
function authHeaders(): HeadersInit { function requestHeaders(): HeadersInit {
const token = getAuthToken(); const token = getAuthToken();
return token ? { Authorization: `Bearer ${token}` } : {}; return {
'X-Locale': getCurrentLocale(),
...(token ? { Authorization: `Bearer ${token}` } : {})
};
} }
async function getErrorMessage(response: Response): Promise<string> { async function getErrorMessage(response: Response): Promise<string> {
@@ -276,12 +302,12 @@ async function getErrorMessage(response: Response): Promise<string> {
// Ignore invalid or empty error bodies and use the status fallback. // Ignore invalid or empty error bodies and use the status fallback.
} }
return `请求失败(${response.status}`; return `Request failed (${response.status})`;
} }
async function getJson<T>(path: string): Promise<T> { async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, { const response = await fetch(`${apiBaseUrl}${path}`, {
headers: authHeaders() headers: requestHeaders()
}); });
if (!response.ok) { if (!response.ok) {
@@ -296,7 +322,7 @@ async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown):
method, method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...authHeaders() ...requestHeaders()
}, },
body: JSON.stringify(body) body: JSON.stringify(body)
}); });
@@ -311,7 +337,7 @@ async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown):
async function postEmpty(path: string): Promise<void> { async function postEmpty(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, { const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'POST', method: 'POST',
headers: authHeaders() headers: requestHeaders()
}); });
if (!response.ok) { if (!response.ok) {
@@ -322,7 +348,7 @@ async function postEmpty(path: string): Promise<void> {
async function deleteJson(path: string): Promise<void> { async function deleteJson(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, { const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'DELETE', method: 'DELETE',
headers: authHeaders() headers: requestHeaders()
}); });
if (!response.ok) { if (!response.ok) {
@@ -331,6 +357,14 @@ async function deleteJson(path: string): Promise<void> {
} }
export const api = { export const api = {
languages: () => getJson<Language[]>('/api/languages'),
adminLanguages: () => getJson<Language[]>('/api/admin/languages'),
createLanguage: (payload: Omit<Language, 'sortOrder'> & { sortOrder?: number }) =>
sendJson<Language[]>('/api/admin/languages', 'POST', payload),
updateLanguage: (code: string, payload: Partial<Language> & { name: string }) =>
sendJson<Language[]>(`/api/admin/languages/${code}`, 'PUT', payload),
reorderLanguages: (codes: string[]) => sendJson<Language[]>('/api/admin/languages/order', 'PUT', { codes }),
deleteLanguage: (code: string) => deleteJson(`/api/admin/languages/${code}`),
register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload), register: (payload: RegisterPayload) => sendJson<{ message: string }>('/api/auth/register', 'POST', payload),
verifyEmail: (token: string) => verifyEmail: (token: string) =>
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }), sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
@@ -347,9 +381,9 @@ export const api = {
sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }), sendJson<DailyChecklistItem[]>('/api/admin/daily-checklist/order', 'PUT', { ids }),
deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`), deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`),
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`), config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: (type: ConfigType, payload: { name: string; hasItemDrop?: boolean }) => createConfig: (type: ConfigType, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload), sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
updateConfig: (type: ConfigType, id: number, payload: { name: string; hasItemDrop?: boolean }) => updateConfig: (type: ConfigType, id: number, payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean }) =>
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload), sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`), deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
pokemon: (params: Record<string, string | number | undefined>) => pokemon: (params: Record<string, string | number | undefined>) =>

View File

@@ -184,6 +184,109 @@ svg {
gap: 8px; gap: 8px;
} }
.language-menu {
position: relative;
}
.language-menu__trigger {
min-height: 38px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 7px 10px;
border: 2px solid var(--line);
border-radius: var(--radius-control);
background: var(--surface);
color: var(--ink-soft);
font-size: 14px;
font-weight: 850;
line-height: 1;
cursor: pointer;
transition:
background 0.14s ease,
border-color 0.14s ease,
box-shadow 0.14s ease,
color 0.14s ease;
}
.language-menu__trigger:hover,
.language-menu__trigger[aria-expanded="true"] {
border-color: var(--pokemon-blue);
background: rgba(255, 203, 5, 0.22);
color: var(--pokemon-blue-deep);
}
.language-menu__trigger:focus-visible {
outline: none;
border-color: var(--pokemon-blue);
box-shadow: 0 0 0 4px rgba(42, 117, 187, 0.16);
}
.language-menu__icon {
width: 18px;
height: 18px;
}
.language-menu__glyph {
white-space: nowrap;
}
.language-menu__dropdown {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 60;
display: grid;
gap: 4px;
min-width: 180px;
padding: 8px;
border: 2px solid var(--line-strong);
border-radius: var(--radius-card);
background: var(--surface);
box-shadow: var(--shadow-raised);
}
.language-menu__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 0;
border-radius: var(--radius-small);
background: transparent;
color: var(--ink);
font-size: 14px;
font-weight: 800;
text-align: left;
cursor: pointer;
}
.language-menu__item:hover,
.language-menu__item.active {
background: rgba(255, 203, 5, 0.22);
color: var(--pokemon-blue-deep);
}
.language-menu__item:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 1px;
}
.language-menu__item.active {
box-shadow: inset 0 0 0 2px rgba(42, 117, 187, 0.2);
}
.language-menu__code {
color: var(--muted);
font-size: 12px;
font-weight: 900;
text-transform: uppercase;
}
.auth-user { .auth-user {
max-width: 180px; max-width: 180px;
overflow: hidden; overflow: hidden;
@@ -919,7 +1022,7 @@ button:disabled,
justify-content: flex-start; justify-content: flex-start;
} }
.admin-checklist-row { .reorderable-row {
position: relative; position: relative;
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-start; align-items: flex-start;
@@ -931,7 +1034,7 @@ button:disabled,
transform 0.16s ease; transform 0.16s ease;
} }
.admin-checklist-row.is-dragging { .reorderable-row.is-dragging {
z-index: 2; z-index: 2;
background: color-mix(in srgb, var(--pokemon-yellow) 12%, var(--surface)); background: color-mix(in srgb, var(--pokemon-yellow) 12%, var(--surface));
box-shadow: var(--shadow-soft); box-shadow: var(--shadow-soft);
@@ -939,7 +1042,7 @@ button:disabled,
transform: scale(0.99); transform: scale(0.99);
} }
.admin-checklist-row.is-drop-target::before { .reorderable-row.is-drop-target::before {
content: ""; content: "";
position: absolute; position: absolute;
right: 0; right: 0;
@@ -950,29 +1053,29 @@ button:disabled,
box-shadow: 0 0 0 3px color-mix(in srgb, var(--pokemon-blue) 18%, transparent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--pokemon-blue) 18%, transparent);
} }
.admin-checklist-row.is-drop-before::before { .reorderable-row.is-drop-before::before {
top: -2px; top: -2px;
} }
.admin-checklist-row.is-drop-after::before { .reorderable-row.is-drop-after::before {
bottom: -2px; bottom: -2px;
} }
.admin-checklist-move, .reorderable-list-move,
.admin-checklist-enter-active, .reorderable-list-enter-active,
.admin-checklist-leave-active { .reorderable-list-leave-active {
transition: transition:
opacity 0.18s ease, opacity 0.18s ease,
transform 0.18s ease; transform 0.18s ease;
} }
.admin-checklist-enter-from, .reorderable-list-enter-from,
.admin-checklist-leave-to { .reorderable-list-leave-to {
opacity: 0; opacity: 0;
transform: translateY(6px); transform: translateY(6px);
} }
.admin-checklist-leave-active { .reorderable-list-leave-active {
position: absolute; position: absolute;
right: 0; right: 0;
left: 0; left: 0;
@@ -1015,7 +1118,7 @@ button:disabled,
opacity: 0.54; opacity: 0.54;
} }
.admin-checklist-title { .reorderable-row-title {
flex: 1 1 180px; flex: 1 1 180px;
min-width: 0; min-width: 0;
display: flex; display: flex;
@@ -1027,17 +1130,17 @@ button:disabled,
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.admin-checklist-row, .reorderable-row,
.admin-checklist-move, .reorderable-list-move,
.admin-checklist-enter-active, .reorderable-list-enter-active,
.admin-checklist-leave-active, .reorderable-list-leave-active,
.drag-handle { .drag-handle {
transition: none; transition: none;
} }
.admin-checklist-row.is-dragging, .reorderable-row.is-dragging,
.admin-checklist-enter-from, .reorderable-list-enter-from,
.admin-checklist-leave-to, .reorderable-list-leave-to,
.drag-handle:active { .drag-handle:active {
transform: none; transform: none;
} }
@@ -1650,6 +1753,10 @@ button:disabled,
gap: 10px; gap: 10px;
} }
.translation-fields {
display: contents;
}
.skill-drop-row { .skill-drop-row {
display: grid; display: grid;
gap: 8px; gap: 8px;

View File

@@ -1,9 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import ReorderableList from '../components/ReorderableList.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { defaultLocale, getCurrentLocale, setCurrentLocale } from '../i18n';
import { import {
api, api,
type AuthUser, type AuthUser,
@@ -11,37 +15,43 @@ import {
type DailyChecklistItem, type DailyChecklistItem,
type Habitat, type Habitat,
type Item, type Item,
type Language,
type NamedEntity, type NamedEntity,
type Pokemon, type Pokemon,
type Recipe, type Recipe,
type Skill type Skill,
type TranslationMap
} from '../services/api'; } from '../services/api';
type AdminTab = 'config' | 'checklist' | 'pokemon' | 'items' | 'recipes' | 'habitats'; type AdminTab = 'config' | 'languages' | 'checklist' | 'pokemon' | 'items' | 'recipes' | 'habitats';
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean }; type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
const tabs: Array<{ key: AdminTab; label: string }> = [ const { locale, t } = useI18n();
{ key: 'config', label: '系统配置' },
{ key: 'checklist', label: 'CheckList' },
{ key: 'pokemon', label: 'Pokemon' },
{ key: 'items', label: '物品' },
{ key: 'recipes', label: '材料单' },
{ key: 'habitats', label: '栖息地' }
];
const configTypes: Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }> = [ const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
{ key: 'skills', label: '特长', supportsItemDrop: true }, { key: 'config', label: t('pages.admin.config') },
{ key: 'environments', label: '喜欢的环境' }, { key: 'languages', label: t('pages.admin.languages') },
{ key: 'favorite-things', label: '喜欢的东西 / 标签' }, { key: 'checklist', label: t('pages.admin.checklist') },
{ key: 'item-categories', label: '物品分类' }, { key: 'pokemon', label: 'Pokemon' },
{ key: 'item-usages', label: '物品用途' }, { key: 'items', label: t('pages.items.title') },
{ key: 'acquisition-methods', label: '入手方式' }, { key: 'recipes', label: t('pages.recipes.title') },
{ key: 'maps', label: '地图' } { key: 'habitats', label: t('pages.habitats.title') }
]; ]);
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }>>(() => [
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
{ key: 'environments', label: t('config.environments') },
{ key: 'favorite-things', label: t('config.favoriteThings') },
{ key: 'item-categories', label: t('config.itemCategories') },
{ key: 'item-usages', label: t('config.itemUsages') },
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') },
{ key: 'maps', label: t('config.maps') }
]);
const activeTab = ref<AdminTab>('config'); const activeTab = ref<AdminTab>('config');
const activeConfigType = ref<ConfigType>('skills'); const activeConfigType = ref<ConfigType>('skills');
const configRows = ref<EditableConfig[]>([]); const configRows = ref<EditableConfig[]>([]);
const languageRows = ref<Language[]>([]);
const checklistRows = ref<DailyChecklistItem[]>([]); const checklistRows = ref<DailyChecklistItem[]>([]);
const pokemonRows = ref<Pokemon[]>([]); const pokemonRows = ref<Pokemon[]>([]);
const itemRows = ref<Item[]>([]); const itemRows = ref<Item[]>([]);
@@ -51,20 +61,37 @@ const currentUser = ref<AuthUser | null>(null);
const busy = ref(false); const busy = ref(false);
const contentLoading = ref(false); const contentLoading = ref(false);
const message = ref(''); const message = ref('');
const configForm = ref({ id: 0, name: '', hasItemDrop: false }); const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false });
const checklistForm = ref({ id: 0, title: '' }); const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
const draggingChecklistId = ref<number | null>(null); const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
const dragOverChecklistId = ref<number | null>(null); const editingLanguageCode = ref('');
const dragInsertAfterTarget = ref(false);
const dragSourceChecklistRows = ref<DailyChecklistItem[]>([]);
const dragDropCommitted = ref(false);
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]); const selectedConfig = computed(() => configTypes.value.find((item) => item.key === activeConfigType.value) ?? configTypes.value[0]);
const configTabs = computed<TabOption[]>(() => configTypes.map((item) => ({ value: item.key, label: item.label }))); const configTabs = computed<TabOption[]>(() => configTypes.value.map((item) => ({ value: item.key, label: item.label })));
const currentConfigLocale = computed(() => String(locale.value || defaultLocale));
const isConfigDefaultLocale = computed(() => currentConfigLocale.value === defaultLocale);
const configNameRequired = computed(() => isConfigDefaultLocale.value || !configForm.value.id);
const configNameInput = computed({
get: () => {
if (isConfigDefaultLocale.value) {
return configForm.value.name;
}
return configForm.value.translations[currentConfigLocale.value]?.name ?? configForm.value.name;
},
set: (value: string) => {
if (isConfigDefaultLocale.value) {
configForm.value.name = value;
return;
}
updateConfigTranslation(currentConfigLocale.value, value);
}
});
const activeConfigTab = computed({ const activeConfigTab = computed({
get: () => activeConfigType.value, get: () => activeConfigType.value,
set: (value: string) => { set: (value: string) => {
const nextConfig = configTypes.find((item) => item.key === value); const nextConfig = configTypes.value.find((item) => item.key === value);
if (!nextConfig || nextConfig.key === activeConfigType.value) return; if (!nextConfig || nextConfig.key === activeConfigType.value) return;
activeConfigType.value = nextConfig.key; activeConfigType.value = nextConfig.key;
@@ -74,6 +101,15 @@ const activeConfigTab = computed({
}); });
const canEdit = computed(() => currentUser.value?.emailVerified === true); const canEdit = computed(() => currentUser.value?.emailVerified === true);
const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value)); const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value));
const canSetLanguageDefault = computed(() => languageForm.value.code === 'en');
const checklistKey = (item: DailyChecklistItem) => item.id;
const checklistLabel = (item: DailyChecklistItem) => item.title;
const languageKey = (item: Language) => item.code;
const languageLabel = (item: Language) => item.name;
function dragSortLabel(name: string) {
return t('pages.admin.dragSort', { name });
}
function errorText(error: unknown, fallback: string) { function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback; return error instanceof Error && error.message ? error.message : fallback;
@@ -85,59 +121,86 @@ async function run(action: () => Promise<void>) {
try { try {
await action(); await action();
} catch (error) { } catch (error) {
message.value = errorText(error, '操作失败'); message.value = errorText(error, t('errors.operationFailed'));
} finally { } finally {
busy.value = false; busy.value = false;
} }
} }
async function loadConfig() { async function loadConfig() {
await loadLanguages();
configRows.value = (await api.config(activeConfigType.value)) as EditableConfig[]; configRows.value = (await api.config(activeConfigType.value)) as EditableConfig[];
} }
async function loadLanguages() {
languageRows.value = await api.adminLanguages();
}
function resetConfigForm() { function resetConfigForm() {
configForm.value = { id: 0, name: '', hasItemDrop: false }; configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false };
} }
function resetChecklistForm() { function resetChecklistForm() {
checklistForm.value = { id: 0, title: '' }; checklistForm.value = { id: 0, title: '', translations: {} };
}
function resetLanguageForm() {
languageForm.value = { code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 };
editingLanguageCode.value = '';
} }
function editConfig(item: EditableConfig) { function editConfig(item: EditableConfig) {
configForm.value = { id: item.id, name: item.name, hasItemDrop: item.hasItemDrop === true }; configForm.value = { id: item.id, name: item.baseName ?? item.name, translations: item.translations ?? {}, hasItemDrop: item.hasItemDrop === true };
} }
function editChecklistItem(item: DailyChecklistItem) { function editChecklistItem(item: DailyChecklistItem) {
checklistForm.value = { id: item.id, title: item.title }; checklistForm.value = { id: item.id, title: item.title, translations: item.translations ?? {} };
} }
function hasChecklistOrderChanged(rows: DailyChecklistItem[], nextRows: DailyChecklistItem[]) { function editLanguage(item: Language) {
return rows.length !== nextRows.length || rows.some((item, index) => item.id !== nextRows[index]?.id); editingLanguageCode.value = item.code;
languageForm.value = {
code: item.code,
name: item.name,
enabled: item.enabled,
isDefault: item.isDefault,
sortOrder: item.sortOrder
};
} }
function reorderedChecklistRows( function updateConfigTranslation(localeCode: string, value: string) {
rows: DailyChecklistItem[], const nextTranslations: TranslationMap = { ...configForm.value.translations };
draggedId: number, const nextFields = { ...(nextTranslations[localeCode] ?? {}) };
targetId: number,
insertAfterTarget: boolean if (value.trim() === '') {
) { delete nextFields.name;
if (draggedId === targetId) { } else {
return rows; nextFields.name = value;
} }
const draggedItem = rows.find((item) => item.id === draggedId); if (Object.keys(nextFields).length) {
if (!draggedItem) { nextTranslations[localeCode] = nextFields;
return rows; } else {
delete nextTranslations[localeCode];
} }
const nextRows = rows.filter((item) => item.id !== draggedId); configForm.value.translations = nextTranslations;
const targetIndex = nextRows.findIndex((item) => item.id === targetId); }
if (targetIndex < 0) {
return rows; function configBaseNameForSave() {
if (configForm.value.name.trim() !== '' || isConfigDefaultLocale.value) {
return configForm.value.name;
} }
nextRows.splice(targetIndex + (insertAfterTarget ? 1 : 0), 0, draggedItem); return configForm.value.translations[currentConfigLocale.value]?.name ?? '';
return nextRows; }
function previewChecklistOrder(rows: DailyChecklistItem[]) {
checklistRows.value = rows;
}
function previewLanguageOrder(rows: Language[]) {
languageRows.value = rows;
} }
async function persistChecklistOrder(nextRows: DailyChecklistItem[], fallbackRows: DailyChecklistItem[]) { async function persistChecklistOrder(nextRows: DailyChecklistItem[], fallbackRows: DailyChecklistItem[]) {
@@ -152,10 +215,24 @@ async function persistChecklistOrder(nextRows: DailyChecklistItem[], fallbackRow
}); });
} }
async function persistLanguageOrder(nextRows: Language[], fallbackRows: Language[]) {
languageRows.value = nextRows;
await run(async () => {
try {
languageRows.value = await api.reorderLanguages(nextRows.map((item) => item.code));
setCurrentLocale(getCurrentLocale());
} catch (error) {
languageRows.value = fallbackRows;
throw error;
}
});
}
async function saveConfig() { async function saveConfig() {
await run(async () => { await run(async () => {
const payload = { const payload = {
name: configForm.value.name, name: configBaseNameForSave(),
translations: configForm.value.translations,
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined
}; };
@@ -171,6 +248,7 @@ async function saveConfig() {
} }
async function loadChecklist() { async function loadChecklist() {
await loadLanguages();
checklistRows.value = await api.dailyChecklist(); checklistRows.value = await api.dailyChecklist();
if (!checklistForm.value.id && checklistForm.value.title.trim() === '') { if (!checklistForm.value.id && checklistForm.value.title.trim() === '') {
resetChecklistForm(); resetChecklistForm();
@@ -180,7 +258,8 @@ async function loadChecklist() {
async function saveChecklistItem() { async function saveChecklistItem() {
await run(async () => { await run(async () => {
const payload = { const payload = {
title: checklistForm.value.title title: checklistForm.value.title,
translations: checklistForm.value.translations
}; };
if (checklistForm.value.id) { if (checklistForm.value.id) {
@@ -194,6 +273,32 @@ async function saveChecklistItem() {
}); });
} }
async function saveLanguage() {
await run(async () => {
const payload = {
code: languageForm.value.code,
name: languageForm.value.name,
enabled: languageForm.value.enabled,
isDefault: languageForm.value.isDefault,
sortOrder: languageSortOrderForSave()
};
languageRows.value = editingLanguageCode.value
? await api.updateLanguage(editingLanguageCode.value, payload)
: await api.createLanguage(payload);
resetLanguageForm();
setCurrentLocale(getCurrentLocale());
});
}
function languageSortOrderForSave() {
if (editingLanguageCode.value) {
return languageRows.value.find((item) => item.code === editingLanguageCode.value)?.sortOrder ?? languageForm.value.sortOrder;
}
return languageRows.value.reduce((maxOrder, item) => Math.max(maxOrder, item.sortOrder), 0) + 10;
}
async function loadPokemon() { async function loadPokemon() {
pokemonRows.value = await api.pokemon({}); pokemonRows.value = await api.pokemon({});
} }
@@ -217,6 +322,7 @@ async function loadCurrentTab(showSkeleton = false) {
try { try {
if (activeTab.value === 'config') await loadConfig(); if (activeTab.value === 'config') await loadConfig();
if (activeTab.value === 'languages') await loadLanguages();
if (activeTab.value === 'checklist') await loadChecklist(); if (activeTab.value === 'checklist') await loadChecklist();
if (activeTab.value === 'pokemon') await loadPokemon(); if (activeTab.value === 'pokemon') await loadPokemon();
if (activeTab.value === 'items') await loadItems(); if (activeTab.value === 'items') await loadItems();
@@ -231,7 +337,7 @@ async function loadCurrentTab(showSkeleton = false) {
function setTab(tab: AdminTab) { function setTab(tab: AdminTab) {
if (!canEdit.value) { if (!canEdit.value) {
message.value = '请先完成邮箱验证'; message.value = t('errors.completeEmailVerification');
return; return;
} }
@@ -244,13 +350,24 @@ async function loadAdmin() {
currentUser.value = response.user; currentUser.value = response.user;
if (!response.user.emailVerified) { if (!response.user.emailVerified) {
message.value = '请先完成邮箱验证'; message.value = t('errors.completeEmailVerification');
return; return;
} }
await loadCurrentTab(true); await loadCurrentTab(true);
} }
async function removeLanguage(code: string) {
await run(async () => {
await api.deleteLanguage(code);
if (editingLanguageCode.value === code) {
resetLanguageForm();
}
await loadLanguages();
setCurrentLocale(getCurrentLocale());
});
}
async function removeConfig(id: number) { async function removeConfig(id: number) {
await run(async () => { await run(async () => {
await api.deleteConfig(activeConfigType.value, id); await api.deleteConfig(activeConfigType.value, id);
@@ -271,119 +388,6 @@ async function removeChecklistItem(id: number) {
}); });
} }
function startChecklistDrag(item: DailyChecklistItem, event: Event) {
draggingChecklistId.value = item.id;
dragSourceChecklistRows.value = [...checklistRows.value];
dragDropCommitted.value = false;
const dragEvent = event instanceof DragEvent ? event : null;
dragEvent?.dataTransfer?.setData('text/plain', String(item.id));
if (dragEvent?.dataTransfer) {
dragEvent.dataTransfer.effectAllowed = 'move';
dragEvent.dataTransfer.dropEffect = 'move';
}
}
function clearChecklistDragState() {
draggingChecklistId.value = null;
dragOverChecklistId.value = null;
dragInsertAfterTarget.value = false;
dragSourceChecklistRows.value = [];
dragDropCommitted.value = false;
}
function endChecklistDrag() {
if (draggingChecklistId.value !== null && !dragDropCommitted.value && dragSourceChecklistRows.value.length) {
checklistRows.value = dragSourceChecklistRows.value;
}
clearChecklistDragState();
}
function previewChecklistDrop(targetItem: DailyChecklistItem, event: Event) {
const dragEvent = event instanceof DragEvent ? event : null;
const draggedId = draggingChecklistId.value ?? Number(dragEvent?.dataTransfer?.getData('text/plain'));
if (!draggedId || busy.value) {
return;
}
if (draggedId === targetItem.id) {
dragOverChecklistId.value = null;
dragInsertAfterTarget.value = false;
return;
}
if (dragEvent?.dataTransfer) {
dragEvent.dataTransfer.dropEffect = 'move';
}
const targetElement = event.currentTarget instanceof HTMLElement ? event.currentTarget : null;
const insertAfterTarget = targetElement
? (dragEvent?.clientY ?? 0) > targetElement.getBoundingClientRect().top + targetElement.getBoundingClientRect().height / 2
: false;
dragOverChecklistId.value = targetItem.id;
dragInsertAfterTarget.value = insertAfterTarget;
const nextRows = reorderedChecklistRows(checklistRows.value, draggedId, targetItem.id, insertAfterTarget);
if (hasChecklistOrderChanged(checklistRows.value, nextRows)) {
checklistRows.value = nextRows;
}
}
async function dropChecklistItem(targetItem: DailyChecklistItem, event: Event) {
if (!draggingChecklistId.value || busy.value) {
endChecklistDrag();
return;
}
previewChecklistDrop(targetItem, event);
const nextRows = [...checklistRows.value];
const fallbackRows = dragSourceChecklistRows.value.length ? [...dragSourceChecklistRows.value] : nextRows;
dragDropCommitted.value = true;
clearChecklistDragState();
if (!hasChecklistOrderChanged(fallbackRows, nextRows)) {
return;
}
await persistChecklistOrder(nextRows, fallbackRows);
}
async function moveChecklistItemByKeyboard(item: DailyChecklistItem, offset: -1 | 1) {
if (busy.value) {
return;
}
const currentIndex = checklistRows.value.findIndex((row) => row.id === item.id);
const targetIndex = currentIndex + offset;
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= checklistRows.value.length) {
return;
}
const fallbackRows = [...checklistRows.value];
const nextRows = [...checklistRows.value];
const [movedItem] = nextRows.splice(currentIndex, 1);
nextRows.splice(targetIndex, 0, movedItem);
await persistChecklistOrder(nextRows, fallbackRows);
}
function handleChecklistHandleKey(item: DailyChecklistItem, event: Event) {
const keyboardEvent = event instanceof KeyboardEvent ? event : null;
if (!keyboardEvent) {
return;
}
if (keyboardEvent.key === 'ArrowUp') {
keyboardEvent.preventDefault();
void moveChecklistItemByKeyboard(item, -1);
}
if (keyboardEvent.key === 'ArrowDown') {
keyboardEvent.preventDefault();
void moveChecklistItemByKeyboard(item, 1);
}
}
async function removePokemon(id: number) { async function removePokemon(id: number) {
await run(async () => { await run(async () => {
await api.deletePokemon(id); await api.deletePokemon(id);
@@ -419,11 +423,11 @@ onMounted(() => {
<template> <template>
<section class="page-stack"> <section class="page-stack">
<PageHeader title="管理" subtitle="维护系统配置,查看并删除 Wiki 数据记录。"> <PageHeader :title="t('pages.admin.title')" :subtitle="t('pages.admin.subtitle')">
<template #kicker>Admin</template> <template #kicker>Admin</template>
</PageHeader> </PageHeader>
<div v-if="canEdit" class="tabs" role="tablist" aria-label="管理模块"> <div v-if="canEdit" class="tabs" role="tablist" :aria-label="t('pages.admin.modules')">
<button v-for="tab in tabs" :key="tab.key" :class="{ active: activeTab === tab.key }" type="button" @click="setTab(tab.key)"> <button v-for="tab in tabs" :key="tab.key" :class="{ active: activeTab === tab.key }" type="button" @click="setTab(tab.key)">
{{ tab.label }} {{ tab.label }}
</button> </button>
@@ -431,7 +435,7 @@ onMounted(() => {
<StatusMessage v-if="message" variant="warning">{{ message }}</StatusMessage> <StatusMessage v-if="message" variant="warning">{{ message }}</StatusMessage>
<section v-if="showAdminSkeleton" class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载管理列表"> <section v-if="showAdminSkeleton" class="detail-section skeleton-detail-section" aria-busy="true" :aria-label="t('pages.admin.loading')">
<h2><Skeleton width="120px" height="24px" /></h2> <h2><Skeleton width="120px" height="24px" /></h2>
<ul class="row-list skeleton-row-list"> <ul class="row-list skeleton-row-list">
<li v-for="index in 6" :key="index"> <li v-for="index in 6" :key="index">
@@ -444,143 +448,188 @@ onMounted(() => {
</section> </section>
<section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'checklist'" class="detail-section">
<h2>CheckList</h2> <h2>{{ t('pages.admin.checklist') }}</h2>
<form class="detail-section__body" @submit.prevent="saveChecklistItem"> <form class="detail-section__body" @submit.prevent="saveChecklistItem">
<h3 class="section-subtitle">{{ checklistForm.id ? '编辑 Task' : '新增 Task' }}</h3> <h3 class="section-subtitle">{{ checklistForm.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask') }}</h3>
<div class="field"> <TranslationFields
<label for="checklist-title">Task</label> id-prefix="checklist-title"
<input id="checklist-title" v-model="checklistForm.title" required /> v-model:base-value="checklistForm.title"
</div> v-model:translations="checklistForm.translations"
field="title"
:label="t('pages.checklist.task')"
:languages="languageRows"
required
/>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button> <button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
<button type="button" class="plain-button" :disabled="busy" @click="resetChecklistForm">新建</button> <button type="button" class="plain-button" :disabled="busy" @click="resetChecklistForm">{{ t('common.new') }}</button>
</div> </div>
</form> </form>
<h3 class="section-subtitle">每日做什么</h3> <h3 class="section-subtitle">{{ t('pages.checklist.sectionTitle') }}</h3>
<TransitionGroup v-if="checklistRows.length" name="admin-checklist" tag="ul" class="row-list admin-checklist-list"> <ReorderableList
<li v-if="checklistRows.length"
v-for="item in checklistRows" :items="checklistRows"
:key="item.id" :item-key="checklistKey"
class="admin-checklist-row" :item-label="checklistLabel"
:class="{
'is-dragging': draggingChecklistId === item.id,
'is-drop-target': dragOverChecklistId === item.id,
'is-drop-after': dragOverChecklistId === item.id && dragInsertAfterTarget,
'is-drop-before': dragOverChecklistId === item.id && !dragInsertAfterTarget
}"
@dragover.prevent="previewChecklistDrop(item, $event)"
@drop.prevent="dropChecklistItem(item, $event)"
>
<button
type="button"
class="drag-handle"
draggable="true"
:aria-label="`拖曳排序:${item.title}`"
title="拖曳排序"
:disabled="busy" :disabled="busy"
@dragstart="startChecklistDrag(item, $event)" :handle-label="dragSortLabel"
@dragend="endChecklistDrag" :handle-title="t('pages.admin.dragSortTitle')"
@keydown="handleChecklistHandleKey(item, $event)" @preview="previewChecklistOrder"
@cancel="previewChecklistOrder"
@reorder="persistChecklistOrder"
> >
<span aria-hidden="true"></span> <template #default="{ item }">
</button> <span class="reorderable-row-title">{{ item.title }}</span>
<span class="admin-checklist-title">{{ item.title }}</span>
<span class="row-actions"> <span class="row-actions">
<button type="button" :disabled="busy" @click="editChecklistItem(item)">编辑</button> <button type="button" :disabled="busy" @click="editChecklistItem(item)">{{ t('common.edit') }}</button>
<button type="button" :disabled="busy" @click="removeChecklistItem(item.id)">删除</button> <button type="button" :disabled="busy" @click="removeChecklistItem(item.id)">{{ t('common.delete') }}</button>
</span> </span>
</li> </template>
</TransitionGroup> </ReorderableList>
<p v-else class="meta-line">暂无记录</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
<h2>系统配置</h2> <h2>{{ t('pages.admin.config') }}</h2>
<Tabs id="admin-config-type" v-model="activeConfigTab" :tabs="configTabs" label="系统配置类型" /> <Tabs id="admin-config-type" v-model="activeConfigTab" :tabs="configTabs" :label="t('pages.admin.configType')" />
<form class="detail-section__body" @submit.prevent="saveConfig"> <form class="detail-section__body" @submit.prevent="saveConfig">
<h3 class="section-subtitle">{{ configForm.id ? `编辑${selectedConfig.label}` : `新增${selectedConfig.label}` }}</h3> <h3 class="section-subtitle">
{{ configForm.id ? t('pages.admin.editConfig', { name: selectedConfig.label }) : t('pages.admin.newConfig', { name: selectedConfig.label }) }}
</h3>
<div class="field"> <div class="field">
<label for="config-name">名称</label> <label for="config-name">{{ t('common.name') }}</label>
<input id="config-name" v-model="configForm.name" required /> <input id="config-name" v-model="configNameInput" :required="configNameRequired" />
</div> </div>
<div v-if="selectedConfig.supportsItemDrop" class="check-row"> <div v-if="selectedConfig.supportsItemDrop" class="check-row">
<label> <label>
<input v-model="configForm.hasItemDrop" type="checkbox" /> <input v-model="configForm.hasItemDrop" type="checkbox" />
有掉落物 {{ t('pages.admin.hasItemDrop') }}
</label> </label>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button> <button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
<button type="button" class="plain-button" :disabled="busy" @click="resetConfigForm">新建</button> <button type="button" class="plain-button" :disabled="busy" @click="resetConfigForm">{{ t('common.new') }}</button>
</div> </div>
</form> </form>
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3> <h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
<ul v-if="configRows.length" class="row-list"> <ul v-if="configRows.length" class="row-list">
<li v-for="item in configRows" :key="item.id"> <li v-for="item in configRows" :key="item.id">
<span>{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">有掉落物</span></span> <span>{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span></span>
<span class="row-actions"> <span class="row-actions">
<button type="button" @click="editConfig(item)">编辑</button> <button type="button" @click="editConfig(item)">{{ t('common.edit') }}</button>
<button type="button" @click="removeConfig(item.id)">删除</button> <button type="button" @click="removeConfig(item.id)">{{ t('common.delete') }}</button>
</span> </span>
</li> </li>
</ul> </ul>
<p v-else class="meta-line">暂无记录</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'languages'" class="detail-section">
<h2>{{ t('pages.admin.languages') }}</h2>
<form class="detail-section__body" @submit.prevent="saveLanguage">
<h3 class="section-subtitle">{{ editingLanguageCode ? t('pages.admin.editLanguage') : t('pages.admin.newLanguage') }}</h3>
<div class="field">
<label for="language-code">{{ t('pages.admin.languageCode') }}</label>
<input id="language-code" v-model="languageForm.code" :disabled="Boolean(editingLanguageCode)" required />
</div>
<div class="field">
<label for="language-name">{{ t('pages.admin.languageName') }}</label>
<input id="language-name" v-model="languageForm.name" required />
</div>
<div class="check-row">
<label><input v-model="languageForm.enabled" type="checkbox" /> {{ t('pages.admin.enabled') }}</label>
<label>
<input v-model="languageForm.isDefault" type="checkbox" :disabled="!canSetLanguageDefault" />
{{ t('pages.admin.defaultLanguage') }}
</label>
</div>
<div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
<button type="button" class="plain-button" :disabled="busy" @click="resetLanguageForm">{{ t('common.new') }}</button>
</div>
</form>
<ReorderableList
v-if="languageRows.length"
:items="languageRows"
:item-key="languageKey"
:item-label="languageLabel"
:disabled="busy"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewLanguageOrder"
@cancel="previewLanguageOrder"
@reorder="persistLanguageOrder"
>
<template #default="{ item }">
<span class="reorderable-row-title">
{{ item.name }} <span class="meta-line">{{ item.code }}</span>
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultLanguage') }}</span>
</span>
<span class="row-actions">
<button type="button" @click="editLanguage(item)">{{ t('common.edit') }}</button>
<button type="button" :disabled="item.isDefault" @click="removeLanguage(item.code)">{{ t('common.delete') }}</button>
</span>
</template>
</ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
<section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'pokemon'" class="detail-section">
<h2>Pokemon 列表</h2> <h2>{{ t('pages.admin.pokemonList') }}</h2>
<ul v-if="pokemonRows.length" class="row-list"> <ul v-if="pokemonRows.length" class="row-list">
<li v-for="item in pokemonRows" :key="item.id"> <li v-for="item in pokemonRows" :key="item.id">
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.id }} {{ item.name }}</RouterLink> <RouterLink :to="`/pokemon/${item.id}`">#{{ item.id }} {{ item.name }}</RouterLink>
<span class="row-actions"> <span class="row-actions">
<button type="button" @click="removePokemon(item.id)">删除</button> <button type="button" @click="removePokemon(item.id)">{{ t('common.delete') }}</button>
</span> </span>
</li> </li>
</ul> </ul>
<p v-else class="meta-line">暂无记录</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
<section v-else-if="canEdit && activeTab === 'items'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'items'" class="detail-section">
<h2>物品列表</h2> <h2>{{ t('pages.admin.itemList') }}</h2>
<ul v-if="itemRows.length" class="row-list"> <ul v-if="itemRows.length" class="row-list">
<li v-for="item in itemRows" :key="item.id"> <li v-for="item in itemRows" :key="item.id">
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink> <RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions"> <span class="row-actions">
<button type="button" @click="removeItem(item.id)">删除</button> <button type="button" @click="removeItem(item.id)">{{ t('common.delete') }}</button>
</span> </span>
</li> </li>
</ul> </ul>
<p v-else class="meta-line">暂无记录</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
<section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'recipes'" class="detail-section">
<h2>材料单列表</h2> <h2>{{ t('pages.admin.recipeList') }}</h2>
<ul v-if="recipeRows.length" class="row-list"> <ul v-if="recipeRows.length" class="row-list">
<li v-for="item in recipeRows" :key="item.id"> <li v-for="item in recipeRows" :key="item.id">
<RouterLink :to="`/recipes/${item.id}`">{{ item.name }}</RouterLink> <RouterLink :to="`/recipes/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions"> <span class="row-actions">
<button type="button" @click="removeRecipe(item.id)">删除</button> <button type="button" @click="removeRecipe(item.id)">{{ t('common.delete') }}</button>
</span> </span>
</li> </li>
</ul> </ul>
<p v-else class="meta-line">暂无记录</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
<section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section"> <section v-else-if="canEdit && activeTab === 'habitats'" class="detail-section">
<h2>栖息地列表</h2> <h2>{{ t('pages.admin.habitatList') }}</h2>
<ul v-if="habitatRows.length" class="row-list"> <ul v-if="habitatRows.length" class="row-list">
<li v-for="item in habitatRows" :key="item.id"> <li v-for="item in habitatRows" :key="item.id">
<RouterLink :to="`/habitats/${item.id}`">{{ item.name }}</RouterLink> <RouterLink :to="`/habitats/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions"> <span class="row-actions">
<button type="button" @click="removeHabitat(item.id)">删除</button> <button type="button" @click="removeHabitat(item.id)">{{ t('common.delete') }}</button>
</span> </span>
</li> </li>
</ul> </ul>
<p v-else class="meta-line">暂无记录</p> <p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section> </section>
</section> </section>
</template> </template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'; import { onMounted, onUnmounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import { api, type DailyChecklistItem } from '../services/api'; import { api, type DailyChecklistItem } from '../services/api';
@@ -10,6 +11,7 @@ type ChecklistState = {
}; };
const checklistStateKey = 'pokopia_daily_checklist_state'; const checklistStateKey = 'pokopia_daily_checklist_state';
const { t } = useI18n();
const stateRefreshIntervalMs = 60_000; const stateRefreshIntervalMs = 60_000;
const checklistItems = ref<DailyChecklistItem[]>([]); const checklistItems = ref<DailyChecklistItem[]>([]);
const checkedTaskIds = ref<Set<number>>(new Set()); const checkedTaskIds = ref<Set<number>>(new Set());
@@ -108,14 +110,14 @@ onUnmounted(() => {
<template> <template>
<section class="page-stack"> <section class="page-stack">
<PageHeader title="每日清单" subtitle="查看每天可以完成的事项。"> <PageHeader :title="t('pages.checklist.title')" :subtitle="t('pages.checklist.subtitle')">
<template #kicker>CheckList</template> <template #kicker>CheckList</template>
</PageHeader> </PageHeader>
<section class="detail-section" :aria-busy="loading"> <section class="detail-section" :aria-busy="loading">
<h2>每日做什么</h2> <h2>{{ t('pages.checklist.sectionTitle') }}</h2>
<ul v-if="loading" class="row-list skeleton-row-list checklist-skeleton-list" aria-label="正在加载每日清单"> <ul v-if="loading" class="row-list skeleton-row-list checklist-skeleton-list" :aria-label="t('pages.checklist.loading')">
<li v-for="index in skeletonRows" :key="index"> <li v-for="index in skeletonRows" :key="index">
<Skeleton variant="box" width="34px" height="34px" /> <Skeleton variant="box" width="34px" height="34px" />
<Skeleton :width="index % 2 === 0 ? '220px' : '160px'" /> <Skeleton :width="index % 2 === 0 ? '220px' : '160px'" />
@@ -135,7 +137,7 @@ onUnmounted(() => {
</li> </li>
</ul> </ul>
<p v-else class="meta-line">暂无每日清单</p> <p v-else class="meta-line">{{ t('pages.checklist.empty') }}</p>
</section> </section>
</section> </section>
</template> </template>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue';
@@ -9,6 +10,7 @@ import Skeleton from '../components/Skeleton.vue';
import { api, type HabitatDetail } from '../services/api'; import { api, type HabitatDetail } from '../services/api';
const route = useRoute(); const route = useRoute();
const { t } = useI18n();
const habitat = ref<HabitatDetail | null>(null); const habitat = ref<HabitatDetail | null>(null);
const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天']; const weathers = ['晴天', '阴天', '雨天'];
@@ -33,6 +35,25 @@ function sortByOrder(values: Set<string>, order: string[]) {
}); });
} }
function timeLabel(value: string): string {
const labels: Record<string, string> = {
早晨: t('appearance.morning'),
中午: t('appearance.noon'),
傍晚: t('appearance.evening'),
晚上: t('appearance.night')
};
return labels[value] ?? value;
}
function weatherLabel(value: string): string {
const labels: Record<string, string> = {
晴天: t('appearance.sunny'),
阴天: t('appearance.cloudy'),
雨天: t('appearance.rainy')
};
return labels[value] ?? value;
}
const pokemonRows = computed<PokemonRow[]>(() => { const pokemonRows = computed<PokemonRow[]>(() => {
if (!habitat.value) return []; if (!habitat.value) return [];
@@ -81,7 +102,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<section v-if="!habitat" class="page-stack" aria-busy="true" aria-label="正在加载栖息地详情"> <section v-if="!habitat" class="page-stack" aria-busy="true" :aria-label="t('pages.habitats.loadingDetail')">
<div class="page-header page-header--skeleton" aria-hidden="true"> <div class="page-header page-header--skeleton" aria-hidden="true">
<div class="page-header__copy"> <div class="page-header__copy">
<Skeleton width="132px" /> <Skeleton width="132px" />
@@ -127,39 +148,39 @@ onMounted(async () => {
</div> </div>
</section> </section>
<section v-else class="page-stack"> <section v-else class="page-stack">
<PageHeader :title="habitat.name" subtitle="栖息地详情"> <PageHeader :title="habitat.name" :subtitle="t('pages.habitats.detailSubtitle')">
<template #kicker>Habitat Detail</template> <template #kicker>Habitat Detail</template>
<template #actions> <template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">编辑</RouterLink> <RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">{{ t('common.edit') }}</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/habitats">返回列表</RouterLink> <RouterLink class="ui-button ui-button--blue ui-button--small" to="/habitats">{{ t('common.backToList') }}</RouterLink>
</template> </template>
</PageHeader> </PageHeader>
<div class="detail-with-sidebar"> <div class="detail-with-sidebar">
<div class="habitat-detail-stack"> <div class="habitat-detail-stack">
<DetailSection title="配方列表"> <DetailSection :title="t('pages.habitats.recipeList')">
<EntityChips :items="habitat.recipe" /> <EntityChips :items="habitat.recipe" />
</DetailSection> </DetailSection>
<DetailSection title="可能出现的宝可梦"> <DetailSection :title="t('pages.habitats.possiblePokemon')">
<ul class="row-list appearance-list"> <ul class="row-list appearance-list">
<li v-for="item in pokemonRows" :key="`${item.id}-${item.rarity}`"> <li v-for="item in pokemonRows" :key="`${item.id}-${item.rarity}`">
<RouterLink class="appearance-name" :to="`/pokemon/${item.id}`">{{ item.name }}</RouterLink> <RouterLink class="appearance-name" :to="`/pokemon/${item.id}`">{{ item.name }}</RouterLink>
<dl class="appearance-summary"> <dl class="appearance-summary">
<div> <div>
<dt>时段</dt> <dt>{{ t('appearance.time') }}</dt>
<dd>{{ item.timeOfDays.join(' / ') }}</dd> <dd>{{ item.timeOfDays.map(timeLabel).join(' / ') }}</dd>
</div> </div>
<div> <div>
<dt>天气</dt> <dt>{{ t('appearance.weather') }}</dt>
<dd>{{ item.weathers.join(' / ') }}</dd> <dd>{{ item.weathers.map(weatherLabel).join(' / ') }}</dd>
</div> </div>
<div> <div>
<dt>稀有度</dt> <dt>{{ t('appearance.rarity') }}</dt>
<dd>{{ item.rarity }} </dd> <dd>{{ t('appearance.stars', { count: item.rarity }) }}</dd>
</div> </div>
<div> <div>
<dt>出现地图</dt> <dt>{{ t('appearance.maps') }}</dt>
<dd>{{ item.maps.join(' / ') }}</dd> <dd>{{ item.maps.join(' / ') }}</dd>
</div> </div>
</dl> </dl>

View File

@@ -1,19 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import SwitchGroup from '../components/SwitchGroup.vue'; import SwitchGroup from '../components/SwitchGroup.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { import {
api, api,
type ConfigType, type ConfigType,
type HabitatDetail, type HabitatDetail,
type HabitatPayload, type HabitatPayload,
type Item, type Item,
type Language,
type Options, type Options,
type Pokemon type Pokemon,
type TranslationMap
} from '../services/api'; } from '../services/api';
type HabitatAppearanceForm = { type HabitatAppearanceForm = {
@@ -26,30 +30,46 @@ type HabitatAppearanceForm = {
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { t } = useI18n();
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const itemRows = ref<Item[]>([]); const itemRows = ref<Item[]>([]);
const pokemonRows = ref<Pokemon[]>([]); const pokemonRows = ref<Pokemon[]>([]);
const languages = ref<Language[]>([]);
const loading = ref(true); const loading = ref(true);
const busy = ref(false); const busy = ref(false);
const message = ref(''); const message = ref('');
const creatingSelect = ref(''); const creatingSelect = ref('');
const habitatForm = ref({ const habitatForm = ref({
name: '', name: '',
translations: {} as TranslationMap,
recipeItems: [] as Array<{ itemId: string; quantity: number }>, recipeItems: [] as Array<{ itemId: string; quantity: number }>,
pokemonAppearances: [] as HabitatAppearanceForm[] pokemonAppearances: [] as HabitatAppearanceForm[]
}); });
const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天']; const weathers = ['晴天', '阴天', '雨天'];
const timeOfDayOptions = timeOfDays.map((value) => ({ value, label: value })); const timeOfDayOptions = computed(() => [
const weatherOptions = weathers.map((value) => ({ value, label: value })); { value: '早晨', label: t('appearance.morning') },
{ value: '中午', label: t('appearance.noon') },
{ value: '傍晚', label: t('appearance.evening') },
{ value: '晚上', label: t('appearance.night') }
]);
const weatherOptions = computed(() => [
{ value: '晴天', label: t('appearance.sunny') },
{ value: '阴天', label: t('appearance.cloudy') },
{ value: '雨天', label: t('appearance.rainy') }
]);
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : '')); const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== ''); const isEditing = computed(() => routeId.value !== '');
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name }))); const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
const pokemonSelectOptions = computed(() => const pokemonSelectOptions = computed(() =>
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` })) pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` }))
); );
const pageTitle = computed(() => (isEditing.value ? `编辑 ${habitatForm.value.name || '栖息地'}` : '新增栖息地')); const pageTitle = computed(() =>
isEditing.value
? t('pages.habitats.editTitle', { name: habitatForm.value.name || t('pages.habitats.fallbackName') })
: t('pages.habitats.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : '/habitats')); const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : '/habitats'));
function toIds(values: string[]): number[] { function toIds(values: string[]): number[] {
@@ -108,21 +128,28 @@ async function loadEditor() {
message.value = ''; message.value = '';
try { try {
const [loadedOptions, loadedItems, loadedPokemon] = await Promise.all([api.options(), api.items({}), api.pokemon({})]); const [loadedOptions, loadedItems, loadedPokemon, loadedLanguages] = await Promise.all([
api.options(),
api.items({}),
api.pokemon({}),
api.languages()
]);
options.value = loadedOptions; options.value = loadedOptions;
itemRows.value = loadedItems; itemRows.value = loadedItems;
pokemonRows.value = loadedPokemon; pokemonRows.value = loadedPokemon;
languages.value = loadedLanguages;
if (isEditing.value) { if (isEditing.value) {
const habitat = await api.habitatDetail(routeId.value); const habitat = await api.habitatDetail(routeId.value);
habitatForm.value = { habitatForm.value = {
name: habitat.name, name: habitat.name,
translations: habitat.translations ?? {},
recipeItems: habitat.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })), recipeItems: habitat.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
pokemonAppearances: groupPokemonAppearances(habitat) pokemonAppearances: groupPokemonAppearances(habitat)
}; };
} }
} catch (error) { } catch (error) {
message.value = errorText(error, '加载失败'); message.value = errorText(error, t('errors.loadFailed'));
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -146,7 +173,7 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
values.push(value); values.push(value);
} }
} catch (error) { } catch (error) {
message.value = errorText(error, '添加失败'); message.value = errorText(error, t('errors.addFailed'));
} finally { } finally {
creatingSelect.value = ''; creatingSelect.value = '';
} }
@@ -159,6 +186,7 @@ async function saveHabitat() {
try { try {
const payload: HabitatPayload = { const payload: HabitatPayload = {
name: habitatForm.value.name, name: habitatForm.value.name,
translations: habitatForm.value.translations,
recipeItems: toQuantityRows(habitatForm.value.recipeItems), recipeItems: toQuantityRows(habitatForm.value.recipeItems),
pokemonAppearances: habitatForm.value.pokemonAppearances pokemonAppearances: habitatForm.value.pokemonAppearances
.map((item) => ({ .map((item) => ({
@@ -173,7 +201,7 @@ async function saveHabitat() {
const saved = isEditing.value ? await api.updateHabitat(routeId.value, payload) : await api.createHabitat(payload); const saved = isEditing.value ? await api.updateHabitat(routeId.value, payload) : await api.createHabitat(payload);
await router.push(`/habitats/${saved.id}`); await router.push(`/habitats/${saved.id}`);
} catch (error) { } catch (error) {
message.value = errorText(error, '保存失败'); message.value = errorText(error, t('errors.saveFailed'));
} finally { } finally {
busy.value = false; busy.value = false;
} }
@@ -186,40 +214,45 @@ onMounted(() => {
<template> <template>
<section class="page-stack"> <section class="page-stack">
<PageHeader :title="pageTitle" subtitle="维护栖息地配方和可能出现的 Pokemon。"> <PageHeader :title="pageTitle" :subtitle="t('pages.habitats.editSubtitle')">
<template #kicker>Habitat Edit</template> <template #kicker>Habitat Edit</template>
<template #actions> <template #actions>
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">返回</RouterLink> <RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">{{ t('common.back') }}</RouterLink>
</template> </template>
</PageHeader> </PageHeader>
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage> <StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" class="detail-section" @submit.prevent="saveHabitat"> <form v-if="!loading && options" class="detail-section" @submit.prevent="saveHabitat">
<div class="field"> <TranslationFields
<label for="habitat-name">名称</label> id-prefix="habitat-name"
<input id="habitat-name" v-model="habitatForm.name" required /> v-model:base-value="habitatForm.name"
</div> v-model:translations="habitatForm.translations"
field="name"
:label="t('common.name')"
:languages="languages"
required
/>
<div class="field"> <div class="field">
<label>配方</label> <label>{{ t('pages.habitats.recipe') }}</label>
<div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row"> <div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row">
<TagsSelect <TagsSelect
:id="`habitat-recipe-item-${index}`" :id="`habitat-recipe-item-${index}`"
v-model="row.itemId" v-model="row.itemId"
:options="itemSelectOptions" :options="itemSelectOptions"
:multiple="false" :multiple="false"
placeholder="请选择" :placeholder="t('common.select')"
search-placeholder="搜索物品" :search-placeholder="t('pages.pokemon.searchItems')"
/> />
<input v-model.number="row.quantity" aria-label="数量" type="number" min="1" /> <input v-model.number="row.quantity" :aria-label="t('common.quantity')" type="number" min="1" />
<button type="button" @click="habitatForm.recipeItems.splice(index, 1)">删除</button> <button type="button" @click="habitatForm.recipeItems.splice(index, 1)">{{ t('common.delete') }}</button>
</div> </div>
<button type="button" class="plain-button" @click="addHabitatRecipeItem">添加物品</button> <button type="button" class="plain-button" @click="addHabitatRecipeItem">{{ t('pages.habitats.addItem') }}</button>
</div> </div>
<div class="field"> <div class="field">
<label>可出现的 Pokemon</label> <label>{{ t('pages.habitats.possiblePokemon') }}</label>
<div v-for="(row, index) in habitatForm.pokemonAppearances" :key="index" class="appearance-row"> <div v-for="(row, index) in habitatForm.pokemonAppearances" :key="index" class="appearance-row">
<div class="appearance-row__main"> <div class="appearance-row__main">
<div class="field appearance-row__pokemon"> <div class="field appearance-row__pokemon">
@@ -230,43 +263,45 @@ onMounted(() => {
:options="pokemonSelectOptions" :options="pokemonSelectOptions"
:multiple="false" :multiple="false"
placeholder="Pokemon" placeholder="Pokemon"
search-placeholder="搜索 Pokemon" :search-placeholder="t('pages.pokemon.searchPokemon')"
/> />
</div> </div>
<SwitchGroup :id="`appearance-times-${index}`" v-model="row.timeOfDays" label="时间" :options="timeOfDayOptions" /> <SwitchGroup :id="`appearance-times-${index}`" v-model="row.timeOfDays" :label="t('appearance.time')" :options="timeOfDayOptions" />
<SwitchGroup :id="`appearance-weathers-${index}`" v-model="row.weathers" label="天气" :options="weatherOptions" /> <SwitchGroup :id="`appearance-weathers-${index}`" v-model="row.weathers" :label="t('appearance.weather')" :options="weatherOptions" />
<div class="field appearance-row__rarity"> <div class="field appearance-row__rarity">
<label :for="`appearance-rarity-${index}`">稀有度</label> <label :for="`appearance-rarity-${index}`">{{ t('appearance.rarity') }}</label>
<input :id="`appearance-rarity-${index}`" v-model.number="row.rarity" type="number" min="1" max="3" /> <input :id="`appearance-rarity-${index}`" v-model.number="row.rarity" type="number" min="1" max="3" />
</div> </div>
<button type="button" class="appearance-row__delete" @click="habitatForm.pokemonAppearances.splice(index, 1)">删除</button> <button type="button" class="appearance-row__delete" @click="habitatForm.pokemonAppearances.splice(index, 1)">
{{ t('common.delete') }}
</button>
</div> </div>
<div class="field appearance-row__maps"> <div class="field appearance-row__maps">
<label :for="`appearance-maps-${index}`">地图</label> <label :for="`appearance-maps-${index}`">{{ t('appearance.map') }}</label>
<TagsSelect <TagsSelect
:id="`appearance-maps-${index}`" :id="`appearance-maps-${index}`"
v-model="row.mapIds" v-model="row.mapIds"
:options="options.maps" :options="options.maps"
allow-create allow-create
:creating="creatingSelect === `appearance-maps-${index}`" :creating="creatingSelect === `appearance-maps-${index}`"
placeholder="搜索地图" :placeholder="t('pages.habitats.searchMaps')"
@create="createMultiOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)" @create="createMultiOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)"
/> />
</div> </div>
</div> </div>
<button type="button" class="plain-button" @click="addPokemonAppearance">添加 Pokemon</button> <button type="button" class="plain-button" @click="addPokemonAppearance">{{ t('pages.habitats.addPokemon') }}</button>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button> <button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink> <RouterLink class="plain-button" :to="cancelTo">{{ t('common.cancel') }}</RouterLink>
</div> </div>
</form> </form>
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载栖息地编辑内容"> <section v-else class="detail-section skeleton-detail-section" aria-busy="true" :aria-label="t('pages.habitats.loadingEdit')">
<div v-for="index in 5" :key="index" class="field"> <div v-for="index in 5" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '112px'" /> <Skeleton :width="index === 1 ? '52px' : '112px'" />
<Skeleton variant="box" height="44px" /> <Skeleton variant="box" height="44px" />

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import EditMeta from '../components/EditMeta.vue'; import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import EntityCard from '../components/EntityCard.vue'; import EntityCard from '../components/EntityCard.vue';
@@ -8,6 +9,7 @@ import Skeleton from '../components/Skeleton.vue';
import { api, type Habitat } from '../services/api'; import { api, type Habitat } from '../services/api';
const habitats = ref<Habitat[]>([]); const habitats = ref<Habitat[]>([]);
const { t } = useI18n();
const loading = ref(true); const loading = ref(true);
const skeletonCardCount = 6; const skeletonCardCount = 6;
@@ -19,14 +21,14 @@ onMounted(async () => {
<template> <template>
<section class="page-stack"> <section class="page-stack">
<PageHeader title="栖息地" subtitle="查看配方和可能出现的宝可梦。"> <PageHeader :title="t('pages.habitats.title')" :subtitle="t('pages.habitats.subtitle')">
<template #kicker>Habitats</template> <template #kicker>Habitats</template>
<template #actions> <template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/habitats/new">新增</RouterLink> <RouterLink class="ui-button ui-button--primary ui-button--small" to="/habitats/new">{{ t('common.add') }}</RouterLink>
</template> </template>
</PageHeader> </PageHeader>
<div v-if="loading" class="entity-grid" aria-busy="true" aria-label="正在加载栖息地列表"> <div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.habitats.loadingList')">
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton"> <article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" /> <Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
<div class="entity-card__content"> <div class="entity-card__content">

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue';
@@ -9,6 +10,7 @@ import Skeleton from '../components/Skeleton.vue';
import { api, type ItemDetail } from '../services/api'; import { api, type ItemDetail } from '../services/api';
const route = useRoute(); const route = useRoute();
const { t } = useI18n();
const item = ref<ItemDetail | null>(null); const item = ref<ItemDetail | null>(null);
const customization = computed(() => { const customization = computed(() => {
@@ -17,9 +19,9 @@ const customization = computed(() => {
} }
return [ return [
item.value.customization.dyeable ? '可染色' : '', item.value.customization.dyeable ? t('pages.items.dyeable') : '',
item.value.customization.dualDyeable ? '可双区染色' : '', item.value.customization.dualDyeable ? t('pages.items.dualDyeable') : '',
item.value.customization.patternEditable ? '可改花纹' : '' item.value.customization.patternEditable ? t('pages.items.patternEditable') : ''
].filter(Boolean); ].filter(Boolean);
}); });
@@ -29,7 +31,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<section v-if="!item" class="page-stack" aria-busy="true" aria-label="正在加载物品详情"> <section v-if="!item" class="page-stack" aria-busy="true" :aria-label="t('pages.items.loadingDetail')">
<div class="page-header page-header--skeleton" aria-hidden="true"> <div class="page-header page-header--skeleton" aria-hidden="true">
<div class="page-header__copy"> <div class="page-header__copy">
<Skeleton width="96px" /> <Skeleton width="96px" />
@@ -85,70 +87,70 @@ onMounted(async () => {
<PageHeader :title="item.name" :subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name"> <PageHeader :title="item.name" :subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name">
<template #kicker>Item Detail</template> <template #kicker>Item Detail</template>
<template #actions> <template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">编辑</RouterLink> <RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/items/${item.id}/edit`">{{ t('common.edit') }}</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/items">返回列表</RouterLink> <RouterLink class="ui-button ui-button--blue ui-button--small" to="/items">{{ t('common.backToList') }}</RouterLink>
</template> </template>
</PageHeader> </PageHeader>
<div class="detail-with-sidebar"> <div class="detail-with-sidebar">
<div class="detail-grid"> <div class="detail-grid">
<DetailSection title="入手方式"> <DetailSection :title="t('pages.items.acquisitionMethods')">
<EntityChips :items="item.acquisitionMethods" /> <EntityChips :items="item.acquisitionMethods" />
</DetailSection> </DetailSection>
<DetailSection title="自定义"> <DetailSection :title="t('pages.items.customization')">
<div v-if="customization.length" class="chips"> <div v-if="customization.length" class="chips">
<span v-for="entry in customization" :key="entry" class="chip">{{ entry }}</span> <span v-for="entry in customization" :key="entry" class="chip">{{ entry }}</span>
</div> </div>
<p v-else class="meta-line"></p> <p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection> </DetailSection>
<DetailSection title="标签"> <DetailSection :title="t('pages.items.tags')">
<EntityChips :items="item.tags" /> <EntityChips :items="item.tags" />
</DetailSection> </DetailSection>
<DetailSection title="材料单信息"> <DetailSection :title="t('pages.items.recipeInfo')">
<template v-if="item.recipe"> <template v-if="item.recipe">
<RouterLink :to="`/recipes/${item.recipe.id}`">{{ item.recipe.name }}</RouterLink> <RouterLink :to="`/recipes/${item.recipe.id}`">{{ item.recipe.name }}</RouterLink>
<EntityChips :items="item.recipe.materials" /> <EntityChips :items="item.recipe.materials" />
</template> </template>
<p v-else-if="item.noRecipe" class="meta-line">无材料单</p> <p v-else-if="item.noRecipe" class="meta-line">{{ t('pages.items.noRecipe') }}</p>
<template v-else> <template v-else>
<p class="meta-line"></p> <p class="meta-line">{{ t('common.none') }}</p>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`"> <RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`">
创建材料单 {{ t('pages.items.createRecipe') }}
</RouterLink> </RouterLink>
</template> </template>
</DetailSection> </DetailSection>
<DetailSection title="相关材料单"> <DetailSection :title="t('pages.items.relatedRecipes')">
<ul v-if="item.relatedRecipes.length" class="row-list"> <ul v-if="item.relatedRecipes.length" class="row-list">
<li v-for="recipe in item.relatedRecipes" :key="recipe.id"> <li v-for="recipe in item.relatedRecipes" :key="recipe.id">
<RouterLink :to="`/recipes/${recipe.id}`">{{ recipe.name }}</RouterLink> <RouterLink :to="`/recipes/${recipe.id}`">{{ recipe.name }}</RouterLink>
<EntityChips :items="recipe.materials" /> <EntityChips :items="recipe.materials" />
</li> </li>
</ul> </ul>
<p v-else class="meta-line"></p> <p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection> </DetailSection>
<DetailSection title="相关栖息地"> <DetailSection :title="t('pages.items.relatedHabitats')">
<ul v-if="item.relatedHabitats.length" class="row-list"> <ul v-if="item.relatedHabitats.length" class="row-list">
<li v-for="habitat in item.relatedHabitats" :key="habitat.id"> <li v-for="habitat in item.relatedHabitats" :key="habitat.id">
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink> <RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
<EntityChips :items="habitat.recipe" /> <EntityChips :items="habitat.recipe" />
</li> </li>
</ul> </ul>
<p v-else class="meta-line"></p> <p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection> </DetailSection>
<DetailSection title="Pokemon 掉落"> <DetailSection :title="t('pages.items.pokemonDrops')">
<ul v-if="item.droppedByPokemon.length" class="row-list"> <ul v-if="item.droppedByPokemon.length" class="row-list">
<li v-for="entry in item.droppedByPokemon" :key="`${entry.pokemon.id}-${entry.skill.id}`"> <li v-for="entry in item.droppedByPokemon" :key="`${entry.pokemon.id}-${entry.skill.id}`">
<RouterLink :to="`/pokemon/${entry.pokemon.id}`">#{{ entry.pokemon.id }} {{ entry.pokemon.name }}</RouterLink> <RouterLink :to="`/pokemon/${entry.pokemon.id}`">#{{ entry.pokemon.id }} {{ entry.pokemon.name }}</RouterLink>
<span>{{ entry.skill.name }}掉落物</span> <span>{{ t('pages.pokemon.skillDrop', { name: entry.skill.name }) }}</span>
</li> </li>
</ul> </ul>
<p v-else class="meta-line"></p> <p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection> </DetailSection>
</div> </div>

View File

@@ -1,21 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { api, type ConfigType, type ItemPayload, type Options } from '../services/api'; import TranslationFields from '../components/TranslationFields.vue';
import { api, type ConfigType, type ItemPayload, type Language, type Options, type TranslationMap } from '../services/api';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { t } = useI18n();
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const languages = ref<Language[]>([]);
const loading = ref(true); const loading = ref(true);
const busy = ref(false); const busy = ref(false);
const message = ref(''); const message = ref('');
const creatingSelect = ref(''); const creatingSelect = ref('');
const itemForm = ref({ const itemForm = ref({
name: '', name: '',
translations: {} as TranslationMap,
categoryId: '', categoryId: '',
usageId: '', usageId: '',
dyeable: false, dyeable: false,
@@ -28,7 +33,11 @@ const itemForm = ref({
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : '')); const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== ''); const isEditing = computed(() => routeId.value !== '');
const pageTitle = computed(() => (isEditing.value ? `编辑 ${itemForm.value.name || '物品'}` : '新增物品')); const pageTitle = computed(() =>
isEditing.value
? t('pages.items.editTitle', { name: itemForm.value.name || t('pages.items.fallbackName') })
: t('pages.items.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items')); const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items'));
const hasRecipe = ref(false); const hasRecipe = ref(false);
@@ -41,7 +50,9 @@ function errorText(error: unknown, fallback: string) {
} }
async function loadOptions() { async function loadOptions() {
options.value = await api.options(); const [loadedOptions, loadedLanguages] = await Promise.all([api.options(), api.languages()]);
options.value = loadedOptions;
languages.value = loadedLanguages;
} }
async function loadEditor() { async function loadEditor() {
@@ -54,6 +65,7 @@ async function loadEditor() {
const item = await api.itemDetail(routeId.value); const item = await api.itemDetail(routeId.value);
itemForm.value = { itemForm.value = {
name: item.name, name: item.name,
translations: item.translations ?? {},
categoryId: String(item.category.id), categoryId: String(item.category.id),
usageId: item.usage ? String(item.usage.id) : '', usageId: item.usage ? String(item.usage.id) : '',
dyeable: item.customization.dyeable, dyeable: item.customization.dyeable,
@@ -66,7 +78,7 @@ async function loadEditor() {
hasRecipe.value = item.recipe !== null; hasRecipe.value = item.recipe !== null;
} }
} catch (error) { } catch (error) {
message.value = errorText(error, '加载失败'); message.value = errorText(error, t('errors.loadFailed'));
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -83,7 +95,7 @@ async function createSingleOption(selectKey: string, type: ConfigType, name: str
await loadOptions(); await loadOptions();
assign(String(created.id)); assign(String(created.id));
} catch (error) { } catch (error) {
message.value = errorText(error, '添加失败'); message.value = errorText(error, t('errors.addFailed'));
} finally { } finally {
creatingSelect.value = ''; creatingSelect.value = '';
} }
@@ -103,7 +115,7 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
values.push(value); values.push(value);
} }
} catch (error) { } catch (error) {
message.value = errorText(error, '添加失败'); message.value = errorText(error, t('errors.addFailed'));
} finally { } finally {
creatingSelect.value = ''; creatingSelect.value = '';
} }
@@ -116,6 +128,7 @@ async function saveItem() {
try { try {
const payload: ItemPayload = { const payload: ItemPayload = {
name: itemForm.value.name, name: itemForm.value.name,
translations: itemForm.value.translations,
categoryId: Number(itemForm.value.categoryId), categoryId: Number(itemForm.value.categoryId),
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null, usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
dyeable: itemForm.value.dyeable, dyeable: itemForm.value.dyeable,
@@ -128,7 +141,7 @@ async function saveItem() {
const saved = isEditing.value ? await api.updateItem(routeId.value, payload) : await api.createItem(payload); const saved = isEditing.value ? await api.updateItem(routeId.value, payload) : await api.createItem(payload);
await router.push(`/items/${saved.id}`); await router.push(`/items/${saved.id}`);
} catch (error) { } catch (error) {
message.value = errorText(error, '保存失败'); message.value = errorText(error, t('errors.saveFailed'));
} finally { } finally {
busy.value = false; busy.value = false;
} }
@@ -141,23 +154,28 @@ onMounted(() => {
<template> <template>
<section class="page-stack"> <section class="page-stack">
<PageHeader :title="pageTitle" subtitle="维护物品分类、用途、入手方式、自定义和标签。"> <PageHeader :title="pageTitle" :subtitle="t('pages.items.editSubtitle')">
<template #kicker>Item Edit</template> <template #kicker>Item Edit</template>
<template #actions> <template #actions>
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">返回</RouterLink> <RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">{{ t('common.back') }}</RouterLink>
</template> </template>
</PageHeader> </PageHeader>
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage> <StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" class="detail-section" @submit.prevent="saveItem"> <form v-if="!loading && options" class="detail-section" @submit.prevent="saveItem">
<div class="field"> <TranslationFields
<label for="item-name">名称</label> id-prefix="item-name"
<input id="item-name" v-model="itemForm.name" required /> v-model:base-value="itemForm.name"
</div> v-model:translations="itemForm.translations"
field="name"
:label="t('common.name')"
:languages="languages"
required
/>
<div class="field"> <div class="field">
<label for="item-category">分类</label> <label for="item-category">{{ t('pages.items.category') }}</label>
<TagsSelect <TagsSelect
id="item-category" id="item-category"
v-model="itemForm.categoryId" v-model="itemForm.categoryId"
@@ -165,14 +183,14 @@ onMounted(() => {
:multiple="false" :multiple="false"
allow-create allow-create
:creating="creatingSelect === 'item-category'" :creating="creatingSelect === 'item-category'"
placeholder="请选择" :placeholder="t('common.select')"
search-placeholder="搜索分类" :search-placeholder="t('pages.items.searchCategory')"
@create="createSingleOption('item-category', 'item-categories', $event, (value) => (itemForm.categoryId = value))" @create="createSingleOption('item-category', 'item-categories', $event, (value) => (itemForm.categoryId = value))"
/> />
</div> </div>
<div class="field"> <div class="field">
<label for="item-usage">用途</label> <label for="item-usage">{{ t('pages.items.usage') }}</label>
<TagsSelect <TagsSelect
id="item-usage" id="item-usage"
v-model="itemForm.usageId" v-model="itemForm.usageId"
@@ -180,52 +198,52 @@ onMounted(() => {
:multiple="false" :multiple="false"
allow-create allow-create
:creating="creatingSelect === 'item-usage'" :creating="creatingSelect === 'item-usage'"
placeholder="" :placeholder="t('common.none')"
search-placeholder="搜索用途" :search-placeholder="t('pages.items.searchUsage')"
@create="createSingleOption('item-usage', 'item-usages', $event, (value) => (itemForm.usageId = value))" @create="createSingleOption('item-usage', 'item-usages', $event, (value) => (itemForm.usageId = value))"
/> />
</div> </div>
<div class="check-row"> <div class="check-row">
<label><input v-model="itemForm.dyeable" type="checkbox" /> 可染色</label> <label><input v-model="itemForm.dyeable" type="checkbox" /> {{ t('pages.items.dyeable') }}</label>
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> 可双区染色</label> <label><input v-model="itemForm.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label>
<label><input v-model="itemForm.patternEditable" type="checkbox" /> 可改花纹</label> <label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label>
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> 无材料单</label> <label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label>
</div> </div>
<div class="field"> <div class="field">
<label for="item-methods">入手方式</label> <label for="item-methods">{{ t('pages.items.acquisitionMethods') }}</label>
<TagsSelect <TagsSelect
id="item-methods" id="item-methods"
v-model="itemForm.acquisitionMethodIds" v-model="itemForm.acquisitionMethodIds"
:options="options.acquisitionMethods" :options="options.acquisitionMethods"
allow-create allow-create
:creating="creatingSelect === 'item-methods'" :creating="creatingSelect === 'item-methods'"
placeholder="搜索入手方式" :placeholder="t('pages.items.searchMethods')"
@create="createMultiOption('item-methods', 'acquisition-methods', $event, itemForm.acquisitionMethodIds)" @create="createMultiOption('item-methods', 'acquisition-methods', $event, itemForm.acquisitionMethodIds)"
/> />
</div> </div>
<div class="field"> <div class="field">
<label for="item-tags">标签</label> <label for="item-tags">{{ t('pages.items.tags') }}</label>
<TagsSelect <TagsSelect
id="item-tags" id="item-tags"
v-model="itemForm.tagIds" v-model="itemForm.tagIds"
:options="options.itemTags" :options="options.itemTags"
allow-create allow-create
:creating="creatingSelect === 'item-tags'" :creating="creatingSelect === 'item-tags'"
placeholder="搜索标签" :placeholder="t('pages.items.searchTags')"
@create="createMultiOption('item-tags', 'favorite-things', $event, itemForm.tagIds)" @create="createMultiOption('item-tags', 'favorite-things', $event, itemForm.tagIds)"
/> />
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button> <button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink> <RouterLink class="plain-button" :to="cancelTo">{{ t('common.cancel') }}</RouterLink>
</div> </div>
</form> </form>
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载物品编辑内容"> <section v-else class="detail-section skeleton-detail-section" aria-busy="true" :aria-label="t('pages.items.loadingEdit')">
<div v-for="index in 6" :key="index" class="field"> <div v-for="index in 6" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '88px'" /> <Skeleton :width="index === 1 ? '52px' : '88px'" />
<Skeleton variant="box" height="44px" /> <Skeleton variant="box" height="44px" />

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import EditMeta from '../components/EditMeta.vue'; import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import EntityCard from '../components/EntityCard.vue'; import EntityCard from '../components/EntityCard.vue';
@@ -11,6 +12,7 @@ import TagsSelect from '../components/TagsSelect.vue';
import { api, type Item, type Options } from '../services/api'; import { api, type Item, type Options } from '../services/api';
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const { t } = useI18n();
const items = ref<Item[]>([]); const items = ref<Item[]>([]);
const loading = ref(true); const loading = ref(true);
const search = ref(''); const search = ref('');
@@ -23,7 +25,7 @@ const filterSkeletonWidths = ['52px', '48px', '48px'];
const skeletonCardCount = 6; const skeletonCardCount = 6;
const categoryTabs = computed<TabOption[]>(() => [ const categoryTabs = computed<TabOption[]>(() => [
{ value: '', label: '全部' }, { value: '', label: t('common.all') },
...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? []) ...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
]); ]);
@@ -50,14 +52,14 @@ watch(itemQuery, loadItems);
<template> <template>
<section class="page-stack"> <section class="page-stack">
<PageHeader title="物品" subtitle="按分类、用途、标签查看物品。"> <PageHeader :title="t('pages.items.title')" :subtitle="t('pages.items.subtitle')">
<template #kicker>Bag</template> <template #kicker>Bag</template>
<template #actions> <template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/items/new">新增</RouterLink> <RouterLink class="ui-button ui-button--primary ui-button--small" to="/items/new">{{ t('common.add') }}</RouterLink>
</template> </template>
</PageHeader> </PageHeader>
<Tabs v-if="options" id="item-category" v-model="categoryId" :tabs="categoryTabs" label="分类" /> <Tabs v-if="options" id="item-category" v-model="categoryId" :tabs="categoryTabs" :label="t('pages.items.category')" />
<div v-else class="tabs tabs--component" aria-hidden="true"> <div v-else class="tabs tabs--component" aria-hidden="true">
<div class="tab-list tab-list--skeleton"> <div class="tab-list tab-list--skeleton">
<Skeleton <Skeleton
@@ -73,25 +75,25 @@ watch(itemQuery, loadItems);
<FilterPanel v-if="options"> <FilterPanel v-if="options">
<div class="field"> <div class="field">
<label for="item-search">搜索</label> <label for="item-search">{{ t('common.search') }}</label>
<input id="item-search" v-model="search" type="search" placeholder="名称" /> <input id="item-search" v-model="search" type="search" :placeholder="t('common.name')" />
</div> </div>
<div class="field"> <div class="field">
<label for="usage">用途</label> <label for="usage">{{ t('pages.items.usage') }}</label>
<TagsSelect <TagsSelect
id="usage" id="usage"
v-model="usageId" v-model="usageId"
:options="options.itemUsages" :options="options.itemUsages"
:multiple="false" :multiple="false"
placeholder="全部" :placeholder="t('common.all')"
search-placeholder="搜索用途" :search-placeholder="t('pages.items.searchUsage')"
/> />
</div> </div>
<div class="field"> <div class="field">
<label for="tags">标签</label> <label for="tags">{{ t('pages.items.tags') }}</label>
<TagsSelect id="tags" v-model="tagIds" :options="options.itemTags" placeholder="搜索标签" /> <TagsSelect id="tags" v-model="tagIds" :options="options.itemTags" :placeholder="t('pages.items.searchTags')" />
</div> </div>
</FilterPanel> </FilterPanel>
<FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true"> <FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true">
@@ -101,7 +103,7 @@ watch(itemQuery, loadItems);
</div> </div>
</FilterPanel> </FilterPanel>
<div v-if="loading" class="entity-grid" aria-busy="true" aria-label="正在加载列表"> <div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.items.loadingList')">
<article v-for="index in skeletonCardCount" :key="`item-skeleton-${index}`" class="entity-card entity-card--skeleton"> <article v-for="index in skeletonCardCount" :key="`item-skeleton-${index}`" class="entity-card entity-card--skeleton">
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" /> <Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
<div class="entity-card__content"> <div class="entity-card__content">

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
@@ -7,6 +8,7 @@ import { api, setAuthToken } from '../services/api';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { t } = useI18n();
const email = ref(''); const email = ref('');
const password = ref(''); const password = ref('');
const busy = ref(false); const busy = ref(false);
@@ -29,7 +31,7 @@ async function submitLogin() {
: '/pokemon'; : '/pokemon';
await router.push(redirect); await router.push(redirect);
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : '登录失败'; errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.loginFailed');
} finally { } finally {
busy.value = false; busy.value = false;
} }
@@ -39,31 +41,31 @@ async function submitLogin() {
<template> <template>
<section class="auth-page"> <section class="auth-page">
<div class="auth-panel"> <div class="auth-panel">
<PageHeader title="登录" subtitle="使用已验证邮箱进入 Pokopia Wiki"> <PageHeader :title="t('auth.loginTitle')" :subtitle="t('auth.loginSubtitle')">
<template #kicker>Trainer Pass</template> <template #kicker>Trainer Pass</template>
</PageHeader> </PageHeader>
<form class="auth-form" @submit.prevent="submitLogin"> <form class="auth-form" @submit.prevent="submitLogin">
<div class="field"> <div class="field">
<label for="login-email">邮箱</label> <label for="login-email">{{ t('auth.email') }}</label>
<input id="login-email" v-model="email" autocomplete="email" required type="email" /> <input id="login-email" v-model="email" autocomplete="email" required type="email" />
</div> </div>
<div class="field"> <div class="field">
<label for="login-password">密码</label> <label for="login-password">{{ t('auth.password') }}</label>
<input id="login-password" v-model="password" autocomplete="current-password" required type="password" /> <input id="login-password" v-model="password" autocomplete="current-password" required type="password" />
</div> </div>
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage> <StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
<button class="ui-button ui-button--primary" :disabled="busy" type="submit"> <button class="ui-button ui-button--primary" :disabled="busy" type="submit">
{{ busy ? '登录中' : '登录' }} {{ busy ? t('auth.loggingIn') : t('nav.login') }}
</button> </button>
</form> </form>
<p class="auth-switch"> <p class="auth-switch">
还没有账号 {{ t('auth.noAccount') }}
<RouterLink to="/register">注册</RouterLink> <RouterLink to="/register">{{ t('nav.register') }}</RouterLink>
</p> </p>
</div> </div>
</section> </section>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue';
@@ -10,6 +11,7 @@ import Tabs, { type TabOption } from '../components/Tabs.vue';
import { api, type PokemonDetail } from '../services/api'; import { api, type PokemonDetail } from '../services/api';
const route = useRoute(); const route = useRoute();
const { t } = useI18n();
const pokemon = ref<PokemonDetail | null>(null); const pokemon = ref<PokemonDetail | null>(null);
const itemCategoryTab = ref(''); const itemCategoryTab = ref('');
const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
@@ -35,6 +37,25 @@ function sortByOrder(values: Set<string>, order: string[]) {
}); });
} }
function timeLabel(value: string): string {
const labels: Record<string, string> = {
早晨: t('appearance.morning'),
中午: t('appearance.noon'),
傍晚: t('appearance.evening'),
晚上: t('appearance.night')
};
return labels[value] ?? value;
}
function weatherLabel(value: string): string {
const labels: Record<string, string> = {
晴天: t('appearance.sunny'),
阴天: t('appearance.cloudy'),
雨天: t('appearance.rainy')
};
return labels[value] ?? value;
}
const habitatRows = computed<HabitatRow[]>(() => { const habitatRows = computed<HabitatRow[]>(() => {
if (!pokemon.value) return []; if (!pokemon.value) return [];
@@ -88,7 +109,7 @@ const itemCategoryTabs = computed<TabOption[]>(() => {
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB)) .sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([value, label]) => ({ value, label })); .map(([value, label]) => ({ value, label }));
return tabs.length > 1 ? [{ value: '', label: '全部' }, ...tabs] : []; return tabs.length > 1 ? [{ value: '', label: t('common.all') }, ...tabs] : [];
}); });
const favoriteThingItems = computed(() => { const favoriteThingItems = computed(() => {
const items = pokemon.value?.favoriteThingItems ?? []; const items = pokemon.value?.favoriteThingItems ?? [];
@@ -106,7 +127,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<section v-if="!pokemon" class="page-stack" aria-busy="true" aria-label="正在加载 Pokemon 详情"> <section v-if="!pokemon" class="page-stack" aria-busy="true" :aria-label="t('pages.pokemon.loadingDetail')">
<div class="page-header page-header--skeleton" aria-hidden="true"> <div class="page-header page-header--skeleton" aria-hidden="true">
<div class="page-header__copy"> <div class="page-header__copy">
<Skeleton width="142px" /> <Skeleton width="142px" />
@@ -163,41 +184,41 @@ onMounted(async () => {
</div> </div>
</section> </section>
<section v-else class="page-stack"> <section v-else class="page-stack">
<PageHeader :title="`#${pokemon.id} ${pokemon.name}`" :subtitle="`喜欢的环境:${pokemon.environment.name}`"> <PageHeader :title="`#${pokemon.id} ${pokemon.name}`" :subtitle="t('pages.pokemon.environmentPrefix', { name: pokemon.environment.name })">
<template #kicker>Pokédex Detail</template> <template #kicker>Pokédex Detail</template>
<template #actions> <template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">编辑</RouterLink> <RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">{{ t('common.edit') }}</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/pokemon">返回列表</RouterLink> <RouterLink class="ui-button ui-button--blue ui-button--small" to="/pokemon">{{ t('common.backToList') }}</RouterLink>
</template> </template>
</PageHeader> </PageHeader>
<div class="detail-with-sidebar"> <div class="detail-with-sidebar">
<div class="detail-grid detail-grid--stack"> <div class="detail-grid detail-grid--stack">
<DetailSection title="特长"> <DetailSection :title="t('pages.pokemon.skills')">
<EntityChips :items="pokemon.skills" /> <EntityChips :items="pokemon.skills" />
</DetailSection> </DetailSection>
<DetailSection v-if="skillDropRows.length" title="特长掉落物"> <DetailSection v-if="skillDropRows.length" :title="t('pages.pokemon.skillDrops')">
<ul class="row-list skill-drop-summary"> <ul class="row-list skill-drop-summary">
<li v-for="skill in skillDropRows" :key="skill.id"> <li v-for="skill in skillDropRows" :key="skill.id">
<span>{{ skill.name }}掉落物</span> <span>{{ t('pages.pokemon.skillDrop', { name: skill.name }) }}</span>
<RouterLink v-if="skill.itemDrop" :to="`/items/${skill.itemDrop.id}`">{{ skill.itemDrop.name }}</RouterLink> <RouterLink v-if="skill.itemDrop" :to="`/items/${skill.itemDrop.id}`">{{ skill.itemDrop.name }}</RouterLink>
</li> </li>
</ul> </ul>
</DetailSection> </DetailSection>
<DetailSection title="喜欢的东西"> <DetailSection :title="t('pages.pokemon.favoriteThings')">
<EntityChips :items="pokemon.favorite_things" /> <EntityChips :items="pokemon.favorite_things" />
</DetailSection> </DetailSection>
<DetailSection title="关联物品"> <DetailSection :title="t('pages.pokemon.relatedItems')">
<template v-if="pokemon.favoriteThingItems.length"> <template v-if="pokemon.favoriteThingItems.length">
<Tabs <Tabs
v-if="itemCategoryTabs.length" v-if="itemCategoryTabs.length"
id="pokemon-favorite-items" id="pokemon-favorite-items"
v-model="itemCategoryTab" v-model="itemCategoryTab"
:tabs="itemCategoryTabs" :tabs="itemCategoryTabs"
label="关联物品分类" :label="t('pages.pokemon.relatedItemCategory')"
/> />
<ul v-if="favoriteThingItems.length" class="row-list"> <ul v-if="favoriteThingItems.length" class="row-list">
<li v-for="item in favoriteThingItems" :key="item.id"> <li v-for="item in favoriteThingItems" :key="item.id">
@@ -205,30 +226,30 @@ onMounted(async () => {
<EntityChips :items="item.tags" /> <EntityChips :items="item.tags" />
</li> </li>
</ul> </ul>
<p v-else class="meta-line"></p> <p v-else class="meta-line">{{ t('common.none') }}</p>
</template> </template>
<p v-else class="meta-line"></p> <p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection> </DetailSection>
<DetailSection title="栖息地"> <DetailSection :title="t('pages.pokemon.habitats')">
<ul class="row-list appearance-list"> <ul class="row-list appearance-list">
<li v-for="habitat in habitatRows" :key="`${habitat.id}-${habitat.rarity}`"> <li v-for="habitat in habitatRows" :key="`${habitat.id}-${habitat.rarity}`">
<RouterLink class="appearance-name" :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink> <RouterLink class="appearance-name" :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
<dl class="appearance-summary"> <dl class="appearance-summary">
<div> <div>
<dt>时段</dt> <dt>{{ t('appearance.time') }}</dt>
<dd>{{ habitat.timeOfDays.join(' / ') }}</dd> <dd>{{ habitat.timeOfDays.map(timeLabel).join(' / ') }}</dd>
</div> </div>
<div> <div>
<dt>天气</dt> <dt>{{ t('appearance.weather') }}</dt>
<dd>{{ habitat.weathers.join(' / ') }}</dd> <dd>{{ habitat.weathers.map(weatherLabel).join(' / ') }}</dd>
</div> </div>
<div> <div>
<dt>稀有度</dt> <dt>{{ t('appearance.rarity') }}</dt>
<dd>{{ habitat.rarity }} </dd> <dd>{{ t('appearance.stars', { count: habitat.rarity }) }}</dd>
</div> </div>
<div> <div>
<dt>出现地图</dt> <dt>{{ t('appearance.maps') }}</dt>
<dd>{{ habitat.maps.join(' / ') }}</dd> <dd>{{ habitat.maps.join(' / ') }}</dd>
</div> </div>
</dl> </dl>

View File

@@ -1,11 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { api, type ConfigType, type NamedEntity, type Options, type PokemonPayload } from '../services/api'; import TranslationFields from '../components/TranslationFields.vue';
import { api, type ConfigType, type Language, type NamedEntity, type Options, type PokemonPayload, type TranslationMap } from '../services/api';
type SkillItemDropForm = { type SkillItemDropForm = {
skillId: string; skillId: string;
@@ -14,8 +16,10 @@ type SkillItemDropForm = {
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { t } = useI18n();
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const itemOptions = ref<NamedEntity[]>([]); const itemOptions = ref<NamedEntity[]>([]);
const languages = ref<Language[]>([]);
const loading = ref(true); const loading = ref(true);
const busy = ref(false); const busy = ref(false);
const message = ref(''); const message = ref('');
@@ -23,6 +27,7 @@ const creatingSelect = ref('');
const pokemonForm = ref({ const pokemonForm = ref({
id: '', id: '',
name: '', name: '',
translations: {} as TranslationMap,
environmentId: '', environmentId: '',
skillIds: [] as string[], skillIds: [] as string[],
favoriteThingIds: [] as string[], favoriteThingIds: [] as string[],
@@ -31,7 +36,11 @@ const pokemonForm = ref({
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : '')); const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== ''); const isEditing = computed(() => routeId.value !== '');
const pageTitle = computed(() => (isEditing.value ? `编辑 #${pokemonForm.value.id || routeId.value} ${pokemonForm.value.name}` : '新增 Pokemon')); const pageTitle = computed(() =>
isEditing.value
? t('pages.pokemon.editTitle', { id: pokemonForm.value.id || routeId.value, name: pokemonForm.value.name })
: t('pages.pokemon.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` : '/pokemon')); const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` : '/pokemon'));
const selectedSkillDropRows = computed(() => const selectedSkillDropRows = computed(() =>
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId)) pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
@@ -46,9 +55,10 @@ function errorText(error: unknown, fallback: string) {
} }
async function loadOptions() { async function loadOptions() {
const [loadedOptions, loadedItems] = await Promise.all([api.options(), api.items({})]); const [loadedOptions, loadedItems, loadedLanguages] = await Promise.all([api.options(), api.items({}), api.languages()]);
options.value = loadedOptions; options.value = loadedOptions;
itemOptions.value = loadedItems.map((item) => ({ id: item.id, name: item.name })); itemOptions.value = loadedItems.map((item) => ({ id: item.id, name: item.name }));
languages.value = loadedLanguages;
} }
function syncSkillItemDrops() { function syncSkillItemDrops() {
@@ -74,7 +84,7 @@ function skillSupportsItemDrop(skillId: string) {
function skillDropLabel(skillId: string) { function skillDropLabel(skillId: string) {
const name = skillName(skillId); const name = skillName(skillId);
return name ? `${name}掉落物` : '掉落物'; return name ? t('pages.pokemon.skillDrop', { name }) : t('pages.pokemon.dropItem');
} }
async function loadEditor() { async function loadEditor() {
@@ -88,6 +98,7 @@ async function loadEditor() {
pokemonForm.value = { pokemonForm.value = {
id: String(pokemon.id), id: String(pokemon.id),
name: pokemon.name, name: pokemon.name,
translations: pokemon.translations ?? {},
environmentId: String(pokemon.environment.id), environmentId: String(pokemon.environment.id),
skillIds: pokemon.skills.map((skill) => String(skill.id)), skillIds: pokemon.skills.map((skill) => String(skill.id)),
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id)), favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id)),
@@ -99,7 +110,7 @@ async function loadEditor() {
syncSkillItemDrops(); syncSkillItemDrops();
} }
} catch (error) { } catch (error) {
message.value = errorText(error, '加载失败'); message.value = errorText(error, t('errors.loadFailed'));
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -116,7 +127,7 @@ async function createSingleOption(selectKey: string, type: ConfigType, name: str
await loadOptions(); await loadOptions();
assign(String(created.id)); assign(String(created.id));
} catch (error) { } catch (error) {
message.value = errorText(error, '添加失败'); message.value = errorText(error, t('errors.addFailed'));
} finally { } finally {
creatingSelect.value = ''; creatingSelect.value = '';
} }
@@ -136,7 +147,7 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
values.push(value); values.push(value);
} }
} catch (error) { } catch (error) {
message.value = errorText(error, '添加失败'); message.value = errorText(error, t('errors.addFailed'));
} finally { } finally {
creatingSelect.value = ''; creatingSelect.value = '';
} }
@@ -150,6 +161,7 @@ async function savePokemon() {
const payload: PokemonPayload = { const payload: PokemonPayload = {
id: Number(isEditing.value ? routeId.value : pokemonForm.value.id), id: Number(isEditing.value ? routeId.value : pokemonForm.value.id),
name: pokemonForm.value.name, name: pokemonForm.value.name,
translations: pokemonForm.value.translations,
environmentId: Number(pokemonForm.value.environmentId), environmentId: Number(pokemonForm.value.environmentId),
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)), skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)), favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
@@ -160,7 +172,7 @@ async function savePokemon() {
const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload); const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload);
await router.push(`/pokemon/${saved.id}`); await router.push(`/pokemon/${saved.id}`);
} catch (error) { } catch (error) {
message.value = errorText(error, '保存失败'); message.value = errorText(error, t('errors.saveFailed'));
} finally { } finally {
busy.value = false; busy.value = false;
} }
@@ -175,10 +187,10 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
<template> <template>
<section class="page-stack"> <section class="page-stack">
<PageHeader :title="pageTitle" subtitle="维护 Pokemon 基本资料、特长和喜欢的东西。"> <PageHeader :title="pageTitle" :subtitle="t('pages.pokemon.editSubtitle')">
<template #kicker>Pokédex Edit</template> <template #kicker>Pokédex Edit</template>
<template #actions> <template #actions>
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">返回</RouterLink> <RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">{{ t('common.back') }}</RouterLink>
</template> </template>
</PageHeader> </PageHeader>
@@ -190,13 +202,18 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" /> <input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
</div> </div>
<div class="field"> <TranslationFields
<label for="pokemon-name">名字</label> id-prefix="pokemon-name"
<input id="pokemon-name" v-model="pokemonForm.name" required /> v-model:base-value="pokemonForm.name"
</div> v-model:translations="pokemonForm.translations"
field="name"
:label="t('common.name')"
:languages="languages"
required
/>
<div class="field"> <div class="field">
<label for="pokemon-environment">喜欢的环境</label> <label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
<TagsSelect <TagsSelect
id="pokemon-environment" id="pokemon-environment"
v-model="pokemonForm.environmentId" v-model="pokemonForm.environmentId"
@@ -204,14 +221,14 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
:multiple="false" :multiple="false"
allow-create allow-create
:creating="creatingSelect === 'pokemon-environment'" :creating="creatingSelect === 'pokemon-environment'"
placeholder="请选择" :placeholder="t('common.select')"
search-placeholder="搜索喜欢的环境" :search-placeholder="t('pages.pokemon.searchEnvironment')"
@create="createSingleOption('pokemon-environment', 'environments', $event, (value) => (pokemonForm.environmentId = value))" @create="createSingleOption('pokemon-environment', 'environments', $event, (value) => (pokemonForm.environmentId = value))"
/> />
</div> </div>
<div class="field"> <div class="field">
<label for="pokemon-skills">特长</label> <label for="pokemon-skills">{{ t('pages.pokemon.skills') }}</label>
<TagsSelect <TagsSelect
id="pokemon-skills" id="pokemon-skills"
v-model="pokemonForm.skillIds" v-model="pokemonForm.skillIds"
@@ -219,13 +236,13 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
:max="2" :max="2"
allow-create allow-create
:creating="creatingSelect === 'pokemon-skills'" :creating="creatingSelect === 'pokemon-skills'"
placeholder="搜索特长" :placeholder="t('pages.pokemon.searchSkills')"
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)" @create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
/> />
</div> </div>
<div class="field"> <div class="field">
<label for="pokemon-things">喜欢的东西</label> <label for="pokemon-things">{{ t('pages.pokemon.favoriteThings') }}</label>
<TagsSelect <TagsSelect
id="pokemon-things" id="pokemon-things"
v-model="pokemonForm.favoriteThingIds" v-model="pokemonForm.favoriteThingIds"
@@ -233,13 +250,13 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
:max="6" :max="6"
allow-create allow-create
:creating="creatingSelect === 'pokemon-things'" :creating="creatingSelect === 'pokemon-things'"
placeholder="搜索喜欢的东西" :placeholder="t('pages.pokemon.searchFavoriteThings')"
@create="createMultiOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)" @create="createMultiOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
/> />
</div> </div>
<div v-if="selectedSkillDropRows.length" class="field"> <div v-if="selectedSkillDropRows.length" class="field">
<span class="field-label">特长掉落物</span> <span class="field-label">{{ t('pages.pokemon.skillDrops') }}</span>
<div class="skill-drop-list"> <div class="skill-drop-list">
<div v-for="row in selectedSkillDropRows" :key="row.skillId" class="skill-drop-row"> <div v-for="row in selectedSkillDropRows" :key="row.skillId" class="skill-drop-row">
<label :for="`pokemon-skill-drops-${row.skillId}`">{{ skillDropLabel(row.skillId) }}</label> <label :for="`pokemon-skill-drops-${row.skillId}`">{{ skillDropLabel(row.skillId) }}</label>
@@ -248,20 +265,20 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
v-model="row.itemId" v-model="row.itemId"
:options="itemOptions" :options="itemOptions"
:multiple="false" :multiple="false"
placeholder="选择掉落物品" :placeholder="t('pages.pokemon.dropItem')"
search-placeholder="搜索物品" :search-placeholder="t('pages.pokemon.searchItems')"
/> />
</div> </div>
</div> </div>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button> <button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink> <RouterLink class="plain-button" :to="cancelTo">{{ t('common.cancel') }}</RouterLink>
</div> </div>
</form> </form>
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载 Pokemon 编辑内容"> <section v-else class="detail-section skeleton-detail-section" aria-busy="true" :aria-label="t('pages.pokemon.loadingEdit')">
<div v-for="index in 5" :key="index" class="field"> <div v-for="index in 5" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '88px'" /> <Skeleton :width="index === 1 ? '52px' : '88px'" />
<Skeleton variant="box" height="44px" /> <Skeleton variant="box" height="44px" />

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import EditMeta from '../components/EditMeta.vue'; import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import EntityCard from '../components/EntityCard.vue'; import EntityCard from '../components/EntityCard.vue';
@@ -10,6 +11,7 @@ import TagsSelect from '../components/TagsSelect.vue';
import { api, type Options, type Pokemon } from '../services/api'; import { api, type Options, type Pokemon } from '../services/api';
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const { t } = useI18n();
const pokemon = ref<Pokemon[]>([]); const pokemon = ref<Pokemon[]>([]);
const loading = ref(true); const loading = ref(true);
const search = ref(''); const search = ref('');
@@ -46,49 +48,54 @@ watch(query, loadPokemon);
<template> <template>
<section class="page-stack"> <section class="page-stack">
<PageHeader title="Pokemon" subtitle="搜索宝可梦,并按特长、环境、喜欢的东西筛选。"> <PageHeader :title="t('pages.pokemon.title')" :subtitle="t('pages.pokemon.subtitle')">
<template #kicker>Pokédex</template> <template #kicker>Pokédex</template>
<template #actions> <template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/pokemon/new">新增</RouterLink> <RouterLink class="ui-button ui-button--primary ui-button--small" to="/pokemon/new">{{ t('common.add') }}</RouterLink>
</template> </template>
</PageHeader> </PageHeader>
<FilterPanel v-if="options"> <FilterPanel v-if="options">
<div class="field"> <div class="field">
<label for="pokemon-search">搜索</label> <label for="pokemon-search">{{ t('common.search') }}</label>
<input id="pokemon-search" v-model="search" type="search" placeholder="名字" /> <input id="pokemon-search" v-model="search" type="search" :placeholder="t('pages.pokemon.namePlaceholder')" />
</div> </div>
<div class="field"> <div class="field">
<label for="environment">喜欢的环境</label> <label for="environment">{{ t('pages.pokemon.environment') }}</label>
<TagsSelect <TagsSelect
id="environment" id="environment"
v-model="environmentId" v-model="environmentId"
:options="options.environments" :options="options.environments"
:multiple="false" :multiple="false"
placeholder="全部" :placeholder="t('common.all')"
search-placeholder="搜索喜欢的环境" :search-placeholder="t('pages.pokemon.searchEnvironment')"
/> />
</div> </div>
<div class="field"> <div class="field">
<label for="skills">特长</label> <label for="skills">{{ t('pages.pokemon.skills') }}</label>
<TagsSelect id="skills" v-model="skillIds" :options="options.skills" placeholder="搜索特长" /> <TagsSelect id="skills" v-model="skillIds" :options="options.skills" :placeholder="t('pages.pokemon.searchSkills')" />
<div class="segmented" aria-label="特长匹配方式"> <div class="segmented" :aria-label="t('pages.pokemon.skillMatchMode')">
<button :class="{ active: skillMode === 'any' }" type="button" @click="skillMode = 'any'">任意</button> <button :class="{ active: skillMode === 'any' }" type="button" @click="skillMode = 'any'">{{ t('pages.pokemon.any') }}</button>
<button :class="{ active: skillMode === 'all' }" type="button" @click="skillMode = 'all'">全部</button> <button :class="{ active: skillMode === 'all' }" type="button" @click="skillMode = 'all'">{{ t('pages.pokemon.all') }}</button>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label for="favorite-things">喜欢的东西</label> <label for="favorite-things">{{ t('pages.pokemon.favoriteThings') }}</label>
<TagsSelect id="favorite-things" v-model="favoriteThingIds" :options="options.favoriteThings" placeholder="搜索喜欢的东西" /> <TagsSelect
<div class="segmented" aria-label="喜欢的东西匹配方式"> id="favorite-things"
v-model="favoriteThingIds"
:options="options.favoriteThings"
:placeholder="t('pages.pokemon.searchFavoriteThings')"
/>
<div class="segmented" :aria-label="t('pages.pokemon.favoriteThingMatchMode')">
<button :class="{ active: favoriteThingMode === 'any' }" type="button" @click="favoriteThingMode = 'any'"> <button :class="{ active: favoriteThingMode === 'any' }" type="button" @click="favoriteThingMode = 'any'">
任意 {{ t('pages.pokemon.any') }}
</button> </button>
<button :class="{ active: favoriteThingMode === 'all' }" type="button" @click="favoriteThingMode = 'all'"> <button :class="{ active: favoriteThingMode === 'all' }" type="button" @click="favoriteThingMode = 'all'">
全部 {{ t('pages.pokemon.all') }}
</button> </button>
</div> </div>
</div> </div>
@@ -104,7 +111,7 @@ watch(query, loadPokemon);
</div> </div>
</FilterPanel> </FilterPanel>
<div v-if="loading" class="entity-grid" aria-busy="true" aria-label="正在加载 Pokemon 列表"> <div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.pokemon.loadingList')">
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton"> <article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" /> <Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
<div class="entity-card__content"> <div class="entity-card__content">
@@ -125,7 +132,7 @@ watch(query, loadPokemon);
v-for="item in pokemon" v-for="item in pokemon"
:key="item.id" :key="item.id"
:title="`#${item.id} ${item.name}`" :title="`#${item.id} ${item.name}`"
:subtitle="`喜欢的环境:${item.environment.name}`" :subtitle="t('pages.pokemon.environmentPrefix', { name: item.environment.name })"
:to="`/pokemon/${item.id}`" :to="`/pokemon/${item.id}`"
> >
<EditMeta :entity="item" /> <EditMeta :entity="item" />

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue'; import EditHistoryPanel from '../components/EditHistoryPanel.vue';
@@ -9,6 +10,7 @@ import Skeleton from '../components/Skeleton.vue';
import { api, type RecipeDetail } from '../services/api'; import { api, type RecipeDetail } from '../services/api';
const route = useRoute(); const route = useRoute();
const { t } = useI18n();
const recipe = ref<RecipeDetail | null>(null); const recipe = ref<RecipeDetail | null>(null);
onMounted(async () => { onMounted(async () => {
@@ -17,7 +19,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<section v-if="!recipe" class="page-stack" aria-busy="true" aria-label="正在加载材料单详情"> <section v-if="!recipe" class="page-stack" aria-busy="true" :aria-label="t('pages.recipes.loadingDetail')">
<div class="page-header page-header--skeleton" aria-hidden="true"> <div class="page-header page-header--skeleton" aria-hidden="true">
<div class="page-header__copy"> <div class="page-header__copy">
<Skeleton width="112px" /> <Skeleton width="112px" />
@@ -44,21 +46,21 @@ onMounted(async () => {
</div> </div>
</section> </section>
<section v-else class="page-stack"> <section v-else class="page-stack">
<PageHeader :title="recipe.name" subtitle="材料单详情"> <PageHeader :title="recipe.name" :subtitle="t('pages.recipes.detailSubtitle')">
<template #kicker>Recipe Detail</template> <template #kicker>Recipe Detail</template>
<template #actions> <template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">编辑</RouterLink> <RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">{{ t('common.edit') }}</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/recipes">返回列表</RouterLink> <RouterLink class="ui-button ui-button--blue ui-button--small" to="/recipes">{{ t('common.backToList') }}</RouterLink>
</template> </template>
</PageHeader> </PageHeader>
<div class="detail-with-sidebar"> <div class="detail-with-sidebar">
<div class="detail-grid"> <div class="detail-grid">
<DetailSection title="入手方式"> <DetailSection :title="t('pages.items.acquisitionMethods')">
<EntityChips :items="recipe.acquisition_methods" /> <EntityChips :items="recipe.acquisition_methods" />
</DetailSection> </DetailSection>
<DetailSection title="需要材料"> <DetailSection :title="t('pages.recipes.materials')">
<EntityChips :items="recipe.materials" /> <EntityChips :items="recipe.materials" />
</DetailSection> </DetailSection>
</div> </div>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
@@ -9,6 +10,7 @@ import { api, type ConfigType, type Item, type Options, type RecipePayload } fro
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const { t } = useI18n();
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const itemRows = ref<Item[]>([]); const itemRows = ref<Item[]>([]);
const loading = ref(true); const loading = ref(true);
@@ -30,7 +32,11 @@ const resultItemOptions = computed(() =>
.map((item) => ({ id: item.id, name: item.name })) .map((item) => ({ id: item.id, name: item.name }))
); );
const selectedItemName = computed(() => resultItemOptions.value.find((item) => String(item.id) === recipeForm.value.itemId)?.name ?? ''); const selectedItemName = computed(() => resultItemOptions.value.find((item) => String(item.id) === recipeForm.value.itemId)?.name ?? '');
const pageTitle = computed(() => (isEditing.value ? `编辑 ${selectedItemName.value || '材料单'}` : '新增材料单')); const pageTitle = computed(() =>
isEditing.value
? t('pages.recipes.editTitle', { name: selectedItemName.value || t('pages.recipes.fallbackName') })
: t('pages.recipes.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/recipes/${routeId.value}` : '/recipes')); const cancelTo = computed(() => (isEditing.value ? `/recipes/${routeId.value}` : '/recipes'));
function toIds(values: string[]): number[] { function toIds(values: string[]): number[] {
@@ -76,7 +82,7 @@ async function loadEditor() {
recipeForm.value.itemId = preselectedItemId(); recipeForm.value.itemId = preselectedItemId();
} }
} catch (error) { } catch (error) {
message.value = errorText(error, '加载失败'); message.value = errorText(error, t('errors.loadFailed'));
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -104,7 +110,7 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
values.push(value); values.push(value);
} }
} catch (error) { } catch (error) {
message.value = errorText(error, '添加失败'); message.value = errorText(error, t('errors.addFailed'));
} finally { } finally {
creatingSelect.value = ''; creatingSelect.value = '';
} }
@@ -123,7 +129,7 @@ async function saveRecipe() {
const saved = isEditing.value ? await api.updateRecipe(routeId.value, payload) : await api.createRecipe(payload); const saved = isEditing.value ? await api.updateRecipe(routeId.value, payload) : await api.createRecipe(payload);
await router.push(`/recipes/${saved.id}`); await router.push(`/recipes/${saved.id}`);
} catch (error) { } catch (error) {
message.value = errorText(error, '保存失败'); message.value = errorText(error, t('errors.saveFailed'));
} finally { } finally {
busy.value = false; busy.value = false;
} }
@@ -136,10 +142,10 @@ onMounted(() => {
<template> <template>
<section class="page-stack"> <section class="page-stack">
<PageHeader :title="pageTitle" subtitle="维护材料单结果物品、入手方式和需要材料。"> <PageHeader :title="pageTitle" :subtitle="t('pages.recipes.editSubtitle')">
<template #kicker>Recipe Edit</template> <template #kicker>Recipe Edit</template>
<template #actions> <template #actions>
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">返回</RouterLink> <RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">{{ t('common.back') }}</RouterLink>
</template> </template>
</PageHeader> </PageHeader>
@@ -147,54 +153,54 @@ onMounted(() => {
<form v-if="!loading && options" class="detail-section" @submit.prevent="saveRecipe"> <form v-if="!loading && options" class="detail-section" @submit.prevent="saveRecipe">
<div class="field"> <div class="field">
<label for="recipe-item">物品</label> <label for="recipe-item">{{ t('pages.recipes.item') }}</label>
<TagsSelect <TagsSelect
id="recipe-item" id="recipe-item"
v-model="recipeForm.itemId" v-model="recipeForm.itemId"
:options="resultItemOptions" :options="resultItemOptions"
:multiple="false" :multiple="false"
placeholder="请选择" :placeholder="t('common.select')"
search-placeholder="搜索物品" :search-placeholder="t('pages.pokemon.searchItems')"
/> />
</div> </div>
<div class="field"> <div class="field">
<label for="recipe-methods">入手方式</label> <label for="recipe-methods">{{ t('pages.items.acquisitionMethods') }}</label>
<TagsSelect <TagsSelect
id="recipe-methods" id="recipe-methods"
v-model="recipeForm.acquisitionMethodIds" v-model="recipeForm.acquisitionMethodIds"
:options="options.acquisitionMethods" :options="options.acquisitionMethods"
allow-create allow-create
:creating="creatingSelect === 'recipe-methods'" :creating="creatingSelect === 'recipe-methods'"
placeholder="搜索入手方式" :placeholder="t('pages.items.searchMethods')"
@create="createMultiOption('recipe-methods', 'acquisition-methods', $event, recipeForm.acquisitionMethodIds)" @create="createMultiOption('recipe-methods', 'acquisition-methods', $event, recipeForm.acquisitionMethodIds)"
/> />
</div> </div>
<div class="field"> <div class="field">
<label>需要材料</label> <label>{{ t('pages.recipes.materials') }}</label>
<div v-for="(row, index) in recipeForm.materials" :key="index" class="inline-row"> <div v-for="(row, index) in recipeForm.materials" :key="index" class="inline-row">
<TagsSelect <TagsSelect
:id="`recipe-material-${index}`" :id="`recipe-material-${index}`"
v-model="row.itemId" v-model="row.itemId"
:options="materialItemOptions" :options="materialItemOptions"
:multiple="false" :multiple="false"
placeholder="请选择" :placeholder="t('common.select')"
search-placeholder="搜索物品" :search-placeholder="t('pages.pokemon.searchItems')"
/> />
<input v-model.number="row.quantity" aria-label="数量" type="number" min="1" /> <input v-model.number="row.quantity" :aria-label="t('common.quantity')" type="number" min="1" />
<button type="button" @click="recipeForm.materials.splice(index, 1)">删除</button> <button type="button" @click="recipeForm.materials.splice(index, 1)">{{ t('common.delete') }}</button>
</div> </div>
<button type="button" class="plain-button" @click="addRecipeMaterial">添加材料</button> <button type="button" class="plain-button" @click="addRecipeMaterial">{{ t('pages.recipes.addMaterial') }}</button>
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button> <button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink> <RouterLink class="plain-button" :to="cancelTo">{{ t('common.cancel') }}</RouterLink>
</div> </div>
</form> </form>
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" aria-label="正在加载材料单编辑内容"> <section v-else class="detail-section skeleton-detail-section" aria-busy="true" :aria-label="t('pages.recipes.loadingEdit')">
<div v-for="index in 4" :key="index" class="field"> <div v-for="index in 4" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '88px'" /> <Skeleton :width="index === 1 ? '52px' : '88px'" />
<Skeleton variant="box" height="44px" /> <Skeleton variant="box" height="44px" />

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import EditMeta from '../components/EditMeta.vue'; import EditMeta from '../components/EditMeta.vue';
import EntityCard from '../components/EntityCard.vue'; import EntityCard from '../components/EntityCard.vue';
import FilterPanel from '../components/FilterPanel.vue'; import FilterPanel from '../components/FilterPanel.vue';
@@ -10,6 +11,7 @@ import TagsSelect from '../components/TagsSelect.vue';
import { api, type Item, type Options } from '../services/api'; import { api, type Item, type Options } from '../services/api';
const options = ref<Options | null>(null); const options = ref<Options | null>(null);
const { t } = useI18n();
const items = ref<Item[]>([]); const items = ref<Item[]>([]);
const loading = ref(true); const loading = ref(true);
const search = ref(''); const search = ref('');
@@ -22,7 +24,7 @@ const filterSkeletonWidths = ['52px', '48px', '48px'];
const skeletonCardCount = 6; const skeletonCardCount = 6;
const categoryTabs = computed<TabOption[]>(() => [ const categoryTabs = computed<TabOption[]>(() => [
{ value: '', label: '全部' }, { value: '', label: t('common.all') },
...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? []) ...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
]); ]);
@@ -69,14 +71,14 @@ watch(itemQuery, loadItems);
<template> <template>
<section class="page-stack"> <section class="page-stack">
<PageHeader title="材料单" subtitle="按分类、用途、标签查看材料单。"> <PageHeader :title="t('pages.recipes.title')" :subtitle="t('pages.recipes.subtitle')">
<template #kicker>Recipes</template> <template #kicker>Recipes</template>
<template #actions> <template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/recipes/new">新增</RouterLink> <RouterLink class="ui-button ui-button--primary ui-button--small" to="/recipes/new">{{ t('common.add') }}</RouterLink>
</template> </template>
</PageHeader> </PageHeader>
<Tabs v-if="options" id="recipe-category" v-model="categoryId" :tabs="categoryTabs" label="分类" /> <Tabs v-if="options" id="recipe-category" v-model="categoryId" :tabs="categoryTabs" :label="t('pages.items.category')" />
<div v-else class="tabs tabs--component" aria-hidden="true"> <div v-else class="tabs tabs--component" aria-hidden="true">
<div class="tab-list tab-list--skeleton"> <div class="tab-list tab-list--skeleton">
<Skeleton <Skeleton
@@ -92,25 +94,25 @@ watch(itemQuery, loadItems);
<FilterPanel v-if="options"> <FilterPanel v-if="options">
<div class="field"> <div class="field">
<label for="recipe-search">搜索</label> <label for="recipe-search">{{ t('common.search') }}</label>
<input id="recipe-search" v-model="search" type="search" placeholder="名称" /> <input id="recipe-search" v-model="search" type="search" :placeholder="t('common.name')" />
</div> </div>
<div class="field"> <div class="field">
<label for="recipe-usage">用途</label> <label for="recipe-usage">{{ t('pages.items.usage') }}</label>
<TagsSelect <TagsSelect
id="recipe-usage" id="recipe-usage"
v-model="usageId" v-model="usageId"
:options="options.itemUsages" :options="options.itemUsages"
:multiple="false" :multiple="false"
placeholder="全部" :placeholder="t('common.all')"
search-placeholder="搜索用途" :search-placeholder="t('pages.items.searchUsage')"
/> />
</div> </div>
<div class="field"> <div class="field">
<label for="recipe-tags">标签</label> <label for="recipe-tags">{{ t('pages.items.tags') }}</label>
<TagsSelect id="recipe-tags" v-model="tagIds" :options="options.itemTags" placeholder="搜索标签" /> <TagsSelect id="recipe-tags" v-model="tagIds" :options="options.itemTags" :placeholder="t('pages.items.searchTags')" />
</div> </div>
</FilterPanel> </FilterPanel>
<FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true"> <FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true">
@@ -120,7 +122,7 @@ watch(itemQuery, loadItems);
</div> </div>
</FilterPanel> </FilterPanel>
<div v-if="loading" class="entity-grid" aria-busy="true" aria-label="正在加载材料单列表"> <div v-if="loading" class="entity-grid" aria-busy="true" :aria-label="t('pages.recipes.loadingList')">
<article v-for="index in skeletonCardCount" :key="`recipe-skeleton-${index}`" class="entity-card entity-card--skeleton"> <article v-for="index in skeletonCardCount" :key="`recipe-skeleton-${index}`" class="entity-card entity-card--skeleton">
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" /> <Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
<div class="entity-card__content"> <div class="entity-card__content">
@@ -142,7 +144,7 @@ watch(itemQuery, loadItems);
> >
<EditMeta v-if="item.recipe" :entity="item.recipe" /> <EditMeta v-if="item.recipe" :entity="item.recipe" />
<RouterLink v-else-if="!item.noRecipe" class="ui-button ui-button--primary ui-button--small" :to="createRecipeTarget(item)"> <RouterLink v-else-if="!item.noRecipe" class="ui-button ui-button--primary ui-button--small" :to="createRecipeTarget(item)">
创建材料单 {{ t('pages.items.createRecipe') }}
</RouterLink> </RouterLink>
</EntityCard> </EntityCard>
</div> </div>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import { api } from '../services/api'; import { api } from '../services/api';
@@ -10,6 +11,7 @@ const password = ref('');
const busy = ref(false); const busy = ref(false);
const message = ref(''); const message = ref('');
const errorMessage = ref(''); const errorMessage = ref('');
const { t } = useI18n();
async function submitRegister() { async function submitRegister() {
busy.value = true; busy.value = true;
@@ -24,7 +26,7 @@ async function submitRegister() {
}); });
message.value = response.message; message.value = response.message;
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : '注册失败'; errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.registerFailed');
} finally { } finally {
busy.value = false; busy.value = false;
} }
@@ -34,23 +36,23 @@ async function submitRegister() {
<template> <template>
<section class="auth-page"> <section class="auth-page">
<div class="auth-panel"> <div class="auth-panel">
<PageHeader title="注册" subtitle="创建账号后需要完成邮箱验证"> <PageHeader :title="t('auth.registerTitle')" :subtitle="t('auth.registerSubtitle')">
<template #kicker>Trainer Pass</template> <template #kicker>Trainer Pass</template>
</PageHeader> </PageHeader>
<form class="auth-form" @submit.prevent="submitRegister"> <form class="auth-form" @submit.prevent="submitRegister">
<div class="field"> <div class="field">
<label for="register-email">邮箱</label> <label for="register-email">{{ t('auth.email') }}</label>
<input id="register-email" v-model="email" autocomplete="email" required type="email" /> <input id="register-email" v-model="email" autocomplete="email" required type="email" />
</div> </div>
<div class="field"> <div class="field">
<label for="register-display-name">显示名</label> <label for="register-display-name">{{ t('auth.displayName') }}</label>
<input id="register-display-name" v-model="displayName" autocomplete="nickname" maxlength="40" required /> <input id="register-display-name" v-model="displayName" autocomplete="nickname" maxlength="40" required />
</div> </div>
<div class="field"> <div class="field">
<label for="register-password">密码</label> <label for="register-password">{{ t('auth.password') }}</label>
<input <input
id="register-password" id="register-password"
v-model="password" v-model="password"
@@ -65,13 +67,13 @@ async function submitRegister() {
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage> <StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
<button class="ui-button ui-button--primary" :disabled="busy" type="submit"> <button class="ui-button ui-button--primary" :disabled="busy" type="submit">
{{ busy ? '发送中' : '发送验证邮件' }} {{ busy ? t('auth.sending') : t('auth.sendVerification') }}
</button> </button>
</form> </form>
<p class="auth-switch"> <p class="auth-switch">
已有账号 {{ t('auth.hasAccount') }}
<RouterLink to="/login">登录</RouterLink> <RouterLink to="/login">{{ t('nav.login') }}</RouterLink>
</p> </p>
</div> </div>
</section> </section>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
@@ -10,13 +11,14 @@ const route = useRoute();
const busy = ref(true); const busy = ref(true);
const message = ref(''); const message = ref('');
const errorMessage = ref(''); const errorMessage = ref('');
const { t } = useI18n();
onMounted(async () => { onMounted(async () => {
const token = typeof route.query.token === 'string' ? route.query.token : ''; const token = typeof route.query.token === 'string' ? route.query.token : '';
if (!token) { if (!token) {
busy.value = false; busy.value = false;
errorMessage.value = '验证链接无效或已过期'; errorMessage.value = t('auth.invalidVerification');
return; return;
} }
@@ -24,7 +26,7 @@ onMounted(async () => {
const response = await api.verifyEmail(token); const response = await api.verifyEmail(token);
message.value = response.message; message.value = response.message;
} catch (error) { } catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : '邮箱验证失败'; errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.verifyFailed');
} finally { } finally {
busy.value = false; busy.value = false;
} }
@@ -34,11 +36,11 @@ onMounted(async () => {
<template> <template>
<section class="auth-page"> <section class="auth-page">
<div class="auth-panel"> <div class="auth-panel">
<PageHeader title="邮箱验证" subtitle="完成验证后即可登录"> <PageHeader :title="t('auth.verifyTitle')" :subtitle="t('auth.verifySubtitle')">
<template #kicker>Trainer Pass</template> <template #kicker>Trainer Pass</template>
</PageHeader> </PageHeader>
<div v-if="busy" class="skeleton-auth-state" aria-busy="true" aria-label="正在验证邮箱"> <div v-if="busy" class="skeleton-auth-state" aria-busy="true" :aria-label="t('auth.verifyingEmail')">
<Skeleton width="62%" /> <Skeleton width="62%" />
<Skeleton width="84%" /> <Skeleton width="84%" />
<Skeleton variant="box" width="110px" height="44px" /> <Skeleton variant="box" width="110px" height="44px" />
@@ -46,7 +48,7 @@ onMounted(async () => {
<StatusMessage v-else-if="message" variant="success">{{ message }}</StatusMessage> <StatusMessage v-else-if="message" variant="success">{{ message }}</StatusMessage>
<StatusMessage v-else variant="danger">{{ errorMessage }}</StatusMessage> <StatusMessage v-else variant="danger">{{ errorMessage }}</StatusMessage>
<RouterLink v-if="!busy" class="ui-button ui-button--primary" to="/login">去登录</RouterLink> <RouterLink v-if="!busy" class="ui-button ui-button--primary" to="/login">{{ t('auth.goLogin') }}</RouterLink>
</div> </div>
</section> </section>
</template> </template>

2198
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff