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

@@ -1,21 +1,30 @@
<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 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 = [
{ label: 'Pokemon', to: '/pokemon' },
{ label: '栖息地', to: '/habitats' },
{ label: '物品', to: '/items' },
{ label: '材料单', to: '/recipes' },
{ label: 'CheckList', to: '/checklist' },
{ label: '管理', to: '/admin' }
];
const { t, locale } = useI18n();
const router = useRouter();
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 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() {
if (!getAuthToken()) {
@@ -44,20 +53,51 @@ async function logout() {
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(() => {
void loadLanguages();
void loadCurrentUser();
removeAuthListener = onAuthTokenChange(() => {
void loadCurrentUser();
});
removeLocaleListener = onLocaleChange(() => {
void loadLanguages();
});
});
onUnmounted(() => {
removeAuthListener?.();
removeLocaleListener?.();
});
</script>
<template>
<AppShell :current-user="currentUser" :nav-items="navItems" @logout="logout">
<RouterView />
<AppShell
:current-user="currentUser"
:languages="languages"
:locale="locale"
:nav-items="navItems"
@logout="logout"
@update:locale="updateLocale"
>
<RouterView :key="locale" />
</AppShell>
</template>

View File

@@ -1,15 +1,67 @@
<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';
defineProps<{
currentUser: AuthUser | null;
languages: Language[];
locale: string;
navItems: Array<{ label: string; to: string }>;
}>();
defineEmits<{
const emit = defineEmits<{
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>
<template>
@@ -24,20 +76,52 @@ defineEmits<{
</span>
</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">
{{ item.label }}
</RouterLink>
</nav>
<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">
<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 v-else>
<RouterLink class="ui-button ui-button--ghost ui-button--small" to="/login">登录</RouterLink>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/register">注册</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">{{ t('nav.register') }}</RouterLink>
</template>
</div>
</div>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { EditHistoryAction, EditHistoryEntry, EditInfo, UserSummary } from '../services/api';
defineProps<{
@@ -6,22 +7,76 @@ defineProps<{
history: EditHistoryEntry[];
}>();
const actionLabels: Record<EditHistoryAction, string> = {
create: '创建',
update: '编辑',
delete: '删除'
const { locale, t } = useI18n();
const changeLabelKeys: Record<string, string> = {
Name: 'common.name',
名字: '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 {
return user?.displayName ?? '系统';
return user?.displayName ?? t('common.system');
}
function actionLabel(action: EditHistoryAction): string {
return actionLabels[action];
return t(`history.${action}`);
}
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 {
@@ -29,11 +84,11 @@ function historySummary(entry: EditHistoryEntry): string {
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 {
return new Intl.DateTimeFormat('zh-CN', {
return new Intl.DateTimeFormat(locale.value, {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
@@ -43,19 +98,19 @@ function formatDateTime(value: string): string {
<template>
<aside class="edit-history-panel" aria-labelledby="edit-history-panel-title">
<div class="edit-history-panel__header">
<h2 id="edit-history-panel-title">贡献记录</h2>
<h2 id="edit-history-panel-title">{{ t('history.title') }}</h2>
</div>
<dl class="edit-history-summary">
<div>
<dt>由谁创建</dt>
<dt>{{ t('history.createdBy') }}</dt>
<dd>
<strong>{{ displayName(entity.createdBy) }}</strong>
<time :datetime="entity.createdAt">{{ formatDateTime(entity.createdAt) }}</time>
</dd>
</div>
<div>
<dt>最后编辑</dt>
<dt>{{ t('history.lastEdited') }}</dt>
<dd>
<strong>{{ displayName(entity.updatedBy) }}</strong>
<time :datetime="entity.updatedAt">{{ formatDateTime(entity.updatedAt) }}</time>
@@ -64,7 +119,7 @@ function formatDateTime(value: string): string {
</dl>
<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">
<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>
@@ -77,27 +132,27 @@ function formatDateTime(value: string): string {
<div class="edit-history-entry__content">
<dl v-if="entry.changes.length" class="edit-change-list">
<div v-for="change in entry.changes" :key="`${change.label}-${change.before}-${change.after}`">
<dt>{{ change.label }}</dt>
<dt>{{ changeLabel(change.label) }}</dt>
<dd>
<span class="edit-change-list__label">修改前</span>
<span>{{ change.before }}</span>
<span class="edit-change-list__label">修改后</span>
<span>{{ change.after }}</span>
<span class="edit-change-list__label">{{ t('history.before') }}</span>
<span>{{ changeValue(change.before) }}</span>
<span class="edit-change-list__label">{{ t('history.after') }}</span>
<span>{{ changeValue(change.after) }}</span>
</dd>
</div>
</dl>
<dl class="edit-history-detail-meta">
<div>
<dt>作者</dt>
<dt>{{ t('history.author') }}</dt>
<dd>{{ displayName(entry.user) }}</dd>
</div>
<div>
<dt>时间</dt>
<dt>{{ t('history.time') }}</dt>
<dd><time :datetime="entry.createdAt">{{ formatDateTime(entry.createdAt) }}</time></dd>
</div>
<div>
<dt>动作</dt>
<dt>{{ t('history.action') }}</dt>
<dd>{{ actionLabel(entry.action) }}</dd>
</div>
</dl>
@@ -106,7 +161,7 @@ function formatDateTime(value: string): string {
</div>
</li>
</ol>
<p v-else class="meta-line">暂无编辑历史</p>
<p v-else class="meta-line">{{ t('history.empty') }}</p>
</section>
</aside>
</template>

View File

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

View File

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

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 App from './App.vue';
import { i18n } from './i18n';
import { router } from './router';
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 authTokenKey = 'pokopia_auth_token';
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 {
id: number;
name: string;
baseName?: string;
translations?: TranslationMap;
}
export interface Skill extends NamedEntity {
@@ -41,6 +56,7 @@ export interface EditHistoryEntry {
export interface Pokemon extends EditInfo {
id: number;
name: string;
translations?: TranslationMap;
environment: NamedEntity;
skills: Skill[];
favorite_things: NamedEntity[];
@@ -63,6 +79,7 @@ export interface PokemonDetail extends Pokemon {
export interface Habitat extends EditInfo {
id: number;
name: string;
translations?: TranslationMap;
recipe: Array<NamedEntity & { quantity: number }>;
pokemon?: NamedEntity[];
}
@@ -96,6 +113,7 @@ export interface HabitatUsage {
export interface Item extends EditInfo {
id: number;
name: string;
translations?: TranslationMap;
category: NamedEntity;
usage: NamedEntity | null;
customization: {
@@ -129,6 +147,7 @@ export interface Recipe extends EditInfo {
export interface DailyChecklistItem {
id: number;
title: string;
translations?: TranslationMap;
}
export interface RecipeDetail extends Recipe {
@@ -181,6 +200,7 @@ export type ConfigType =
export interface PokemonPayload {
id: number;
name: string;
translations?: TranslationMap;
environmentId: number;
skillIds: number[];
favoriteThingIds: number[];
@@ -189,6 +209,7 @@ export interface PokemonPayload {
export interface ItemPayload {
name: string;
translations?: TranslationMap;
categoryId: number;
usageId: number | null;
dyeable: boolean;
@@ -207,6 +228,7 @@ export interface RecipePayload {
export interface HabitatPayload {
name: string;
translations?: TranslationMap;
recipeItems: Array<{ itemId: number; quantity: number }>;
pokemonAppearances: Array<{
pokemonId: number;
@@ -219,6 +241,7 @@ export interface HabitatPayload {
export interface DailyChecklistPayload {
title: string;
translations?: TranslationMap;
}
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);
}
function authHeaders(): HeadersInit {
function requestHeaders(): HeadersInit {
const token = getAuthToken();
return token ? { Authorization: `Bearer ${token}` } : {};
return {
'X-Locale': getCurrentLocale(),
...(token ? { Authorization: `Bearer ${token}` } : {})
};
}
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.
}
return `请求失败(${response.status}`;
return `Request failed (${response.status})`;
}
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
headers: authHeaders()
headers: requestHeaders()
});
if (!response.ok) {
@@ -296,7 +322,7 @@ async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown):
method,
headers: {
'Content-Type': 'application/json',
...authHeaders()
...requestHeaders()
},
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> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'POST',
headers: authHeaders()
headers: requestHeaders()
});
if (!response.ok) {
@@ -322,7 +348,7 @@ async function postEmpty(path: string): Promise<void> {
async function deleteJson(path: string): Promise<void> {
const response = await fetch(`${apiBaseUrl}${path}`, {
method: 'DELETE',
headers: authHeaders()
headers: requestHeaders()
});
if (!response.ok) {
@@ -331,6 +357,14 @@ async function deleteJson(path: string): Promise<void> {
}
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),
verifyEmail: (token: string) =>
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 }),
deleteDailyChecklistItem: (id: string | number) => deleteJson(`/api/admin/daily-checklist/${id}`),
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),
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),
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
pokemon: (params: Record<string, string | number | undefined>) =>

View File

@@ -184,6 +184,109 @@ svg {
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 {
max-width: 180px;
overflow: hidden;
@@ -919,7 +1022,7 @@ button:disabled,
justify-content: flex-start;
}
.admin-checklist-row {
.reorderable-row {
position: relative;
flex-wrap: wrap;
align-items: flex-start;
@@ -931,7 +1034,7 @@ button:disabled,
transform 0.16s ease;
}
.admin-checklist-row.is-dragging {
.reorderable-row.is-dragging {
z-index: 2;
background: color-mix(in srgb, var(--pokemon-yellow) 12%, var(--surface));
box-shadow: var(--shadow-soft);
@@ -939,7 +1042,7 @@ button:disabled,
transform: scale(0.99);
}
.admin-checklist-row.is-drop-target::before {
.reorderable-row.is-drop-target::before {
content: "";
position: absolute;
right: 0;
@@ -950,29 +1053,29 @@ button:disabled,
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;
}
.admin-checklist-row.is-drop-after::before {
.reorderable-row.is-drop-after::before {
bottom: -2px;
}
.admin-checklist-move,
.admin-checklist-enter-active,
.admin-checklist-leave-active {
.reorderable-list-move,
.reorderable-list-enter-active,
.reorderable-list-leave-active {
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
.admin-checklist-enter-from,
.admin-checklist-leave-to {
.reorderable-list-enter-from,
.reorderable-list-leave-to {
opacity: 0;
transform: translateY(6px);
}
.admin-checklist-leave-active {
.reorderable-list-leave-active {
position: absolute;
right: 0;
left: 0;
@@ -1015,7 +1118,7 @@ button:disabled,
opacity: 0.54;
}
.admin-checklist-title {
.reorderable-row-title {
flex: 1 1 180px;
min-width: 0;
display: flex;
@@ -1027,17 +1130,17 @@ button:disabled,
}
@media (prefers-reduced-motion: reduce) {
.admin-checklist-row,
.admin-checklist-move,
.admin-checklist-enter-active,
.admin-checklist-leave-active,
.reorderable-row,
.reorderable-list-move,
.reorderable-list-enter-active,
.reorderable-list-leave-active,
.drag-handle {
transition: none;
}
.admin-checklist-row.is-dragging,
.admin-checklist-enter-from,
.admin-checklist-leave-to,
.reorderable-row.is-dragging,
.reorderable-list-enter-from,
.reorderable-list-leave-to,
.drag-handle:active {
transform: none;
}
@@ -1650,6 +1753,10 @@ button:disabled,
gap: 10px;
}
.translation-fields {
display: contents;
}
.skill-drop-row {
display: grid;
gap: 8px;

View File

@@ -1,9 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import PageHeader from '../components/PageHeader.vue';
import ReorderableList from '../components/ReorderableList.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { defaultLocale, getCurrentLocale, setCurrentLocale } from '../i18n';
import {
api,
type AuthUser,
@@ -11,37 +15,43 @@ import {
type DailyChecklistItem,
type Habitat,
type Item,
type Language,
type NamedEntity,
type Pokemon,
type Recipe,
type Skill
type Skill,
type TranslationMap
} 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 };
const tabs: Array<{ key: AdminTab; label: string }> = [
{ key: 'config', label: '系统配置' },
{ key: 'checklist', label: 'CheckList' },
{ key: 'pokemon', label: 'Pokemon' },
{ key: 'items', label: '物品' },
{ key: 'recipes', label: '材料单' },
{ key: 'habitats', label: '栖息地' }
];
const { locale, t } = useI18n();
const configTypes: Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }> = [
{ key: 'skills', label: '特长', supportsItemDrop: true },
{ key: 'environments', label: '喜欢的环境' },
{ key: 'favorite-things', label: '喜欢的东西 / 标签' },
{ key: 'item-categories', label: '物品分类' },
{ key: 'item-usages', label: '物品用途' },
{ key: 'acquisition-methods', label: '入手方式' },
{ key: 'maps', label: '地图' }
];
const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
{ key: 'config', label: t('pages.admin.config') },
{ key: 'languages', label: t('pages.admin.languages') },
{ key: 'checklist', label: t('pages.admin.checklist') },
{ key: 'pokemon', label: 'Pokemon' },
{ key: 'items', label: t('pages.items.title') },
{ key: 'recipes', label: t('pages.recipes.title') },
{ 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 activeConfigType = ref<ConfigType>('skills');
const configRows = ref<EditableConfig[]>([]);
const languageRows = ref<Language[]>([]);
const checklistRows = ref<DailyChecklistItem[]>([]);
const pokemonRows = ref<Pokemon[]>([]);
const itemRows = ref<Item[]>([]);
@@ -51,20 +61,37 @@ const currentUser = ref<AuthUser | null>(null);
const busy = ref(false);
const contentLoading = ref(false);
const message = ref('');
const configForm = ref({ id: 0, name: '', hasItemDrop: false });
const checklistForm = ref({ id: 0, title: '' });
const draggingChecklistId = ref<number | null>(null);
const dragOverChecklistId = ref<number | null>(null);
const dragInsertAfterTarget = ref(false);
const dragSourceChecklistRows = ref<DailyChecklistItem[]>([]);
const dragDropCommitted = ref(false);
const configForm = ref({ id: 0, name: '', translations: {} as TranslationMap, hasItemDrop: false });
const checklistForm = ref({ id: 0, title: '', translations: {} as TranslationMap });
const languageForm = ref({ code: '', name: '', enabled: true, isDefault: false, sortOrder: 0 });
const editingLanguageCode = ref('');
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
const configTabs = computed<TabOption[]>(() => configTypes.map((item) => ({ value: item.key, label: item.label })));
const selectedConfig = computed(() => configTypes.value.find((item) => item.key === activeConfigType.value) ?? configTypes.value[0]);
const configTabs = computed<TabOption[]>(() => configTypes.value.map((item) => ({ value: item.key, label: item.label })));
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({
get: () => activeConfigType.value,
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;
activeConfigType.value = nextConfig.key;
@@ -74,6 +101,15 @@ const activeConfigTab = computed({
});
const canEdit = computed(() => currentUser.value?.emailVerified === true);
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) {
return error instanceof Error && error.message ? error.message : fallback;
@@ -85,59 +121,86 @@ async function run(action: () => Promise<void>) {
try {
await action();
} catch (error) {
message.value = errorText(error, '操作失败');
message.value = errorText(error, t('errors.operationFailed'));
} finally {
busy.value = false;
}
}
async function loadConfig() {
await loadLanguages();
configRows.value = (await api.config(activeConfigType.value)) as EditableConfig[];
}
async function loadLanguages() {
languageRows.value = await api.adminLanguages();
}
function resetConfigForm() {
configForm.value = { id: 0, name: '', hasItemDrop: false };
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false };
}
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) {
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) {
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[]) {
return rows.length !== nextRows.length || rows.some((item, index) => item.id !== nextRows[index]?.id);
function editLanguage(item: Language) {
editingLanguageCode.value = item.code;
languageForm.value = {
code: item.code,
name: item.name,
enabled: item.enabled,
isDefault: item.isDefault,
sortOrder: item.sortOrder
};
}
function reorderedChecklistRows(
rows: DailyChecklistItem[],
draggedId: number,
targetId: number,
insertAfterTarget: boolean
) {
if (draggedId === targetId) {
return rows;
function updateConfigTranslation(localeCode: string, value: string) {
const nextTranslations: TranslationMap = { ...configForm.value.translations };
const nextFields = { ...(nextTranslations[localeCode] ?? {}) };
if (value.trim() === '') {
delete nextFields.name;
} else {
nextFields.name = value;
}
const draggedItem = rows.find((item) => item.id === draggedId);
if (!draggedItem) {
return rows;
if (Object.keys(nextFields).length) {
nextTranslations[localeCode] = nextFields;
} else {
delete nextTranslations[localeCode];
}
const nextRows = rows.filter((item) => item.id !== draggedId);
const targetIndex = nextRows.findIndex((item) => item.id === targetId);
if (targetIndex < 0) {
return rows;
configForm.value.translations = nextTranslations;
}
function configBaseNameForSave() {
if (configForm.value.name.trim() !== '' || isConfigDefaultLocale.value) {
return configForm.value.name;
}
nextRows.splice(targetIndex + (insertAfterTarget ? 1 : 0), 0, draggedItem);
return nextRows;
return configForm.value.translations[currentConfigLocale.value]?.name ?? '';
}
function previewChecklistOrder(rows: DailyChecklistItem[]) {
checklistRows.value = rows;
}
function previewLanguageOrder(rows: Language[]) {
languageRows.value = rows;
}
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() {
await run(async () => {
const payload = {
name: configForm.value.name,
name: configBaseNameForSave(),
translations: configForm.value.translations,
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined
};
@@ -171,6 +248,7 @@ async function saveConfig() {
}
async function loadChecklist() {
await loadLanguages();
checklistRows.value = await api.dailyChecklist();
if (!checklistForm.value.id && checklistForm.value.title.trim() === '') {
resetChecklistForm();
@@ -180,7 +258,8 @@ async function loadChecklist() {
async function saveChecklistItem() {
await run(async () => {
const payload = {
title: checklistForm.value.title
title: checklistForm.value.title,
translations: checklistForm.value.translations
};
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() {
pokemonRows.value = await api.pokemon({});
}
@@ -217,6 +322,7 @@ async function loadCurrentTab(showSkeleton = false) {
try {
if (activeTab.value === 'config') await loadConfig();
if (activeTab.value === 'languages') await loadLanguages();
if (activeTab.value === 'checklist') await loadChecklist();
if (activeTab.value === 'pokemon') await loadPokemon();
if (activeTab.value === 'items') await loadItems();
@@ -231,7 +337,7 @@ async function loadCurrentTab(showSkeleton = false) {
function setTab(tab: AdminTab) {
if (!canEdit.value) {
message.value = '请先完成邮箱验证';
message.value = t('errors.completeEmailVerification');
return;
}
@@ -244,13 +350,24 @@ async function loadAdmin() {
currentUser.value = response.user;
if (!response.user.emailVerified) {
message.value = '请先完成邮箱验证';
message.value = t('errors.completeEmailVerification');
return;
}
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) {
await run(async () => {
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) {
await run(async () => {
await api.deletePokemon(id);
@@ -419,11 +423,11 @@ onMounted(() => {
<template>
<section class="page-stack">
<PageHeader title="管理" subtitle="维护系统配置,查看并删除 Wiki 数据记录。">
<PageHeader :title="t('pages.admin.title')" :subtitle="t('pages.admin.subtitle')">
<template #kicker>Admin</template>
</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)">
{{ tab.label }}
</button>
@@ -431,7 +435,7 @@ onMounted(() => {
<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>
<ul class="row-list skeleton-row-list">
<li v-for="index in 6" :key="index">
@@ -444,143 +448,188 @@ onMounted(() => {
</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">
<h3 class="section-subtitle">{{ checklistForm.id ? '编辑 Task' : '新增 Task' }}</h3>
<div class="field">
<label for="checklist-title">Task</label>
<input id="checklist-title" v-model="checklistForm.title" required />
</div>
<h3 class="section-subtitle">{{ checklistForm.id ? t('pages.checklist.editTask') : t('pages.checklist.newTask') }}</h3>
<TranslationFields
id-prefix="checklist-title"
v-model:base-value="checklistForm.title"
v-model:translations="checklistForm.translations"
field="title"
:label="t('pages.checklist.task')"
:languages="languageRows"
required
/>
<div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
<button type="button" class="plain-button" :disabled="busy" @click="resetChecklistForm">新建</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">{{ t('common.new') }}</button>
</div>
</form>
<h3 class="section-subtitle">每日做什么</h3>
<TransitionGroup v-if="checklistRows.length" name="admin-checklist" tag="ul" class="row-list admin-checklist-list">
<li
v-for="item in checklistRows"
:key="item.id"
class="admin-checklist-row"
: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"
@dragstart="startChecklistDrag(item, $event)"
@dragend="endChecklistDrag"
@keydown="handleChecklistHandleKey(item, $event)"
>
<span aria-hidden="true"></span>
</button>
<span class="admin-checklist-title">{{ item.title }}</span>
<h3 class="section-subtitle">{{ t('pages.checklist.sectionTitle') }}</h3>
<ReorderableList
v-if="checklistRows.length"
:items="checklistRows"
:item-key="checklistKey"
:item-label="checklistLabel"
:disabled="busy"
:handle-label="dragSortLabel"
:handle-title="t('pages.admin.dragSortTitle')"
@preview="previewChecklistOrder"
@cancel="previewChecklistOrder"
@reorder="persistChecklistOrder"
>
<template #default="{ item }">
<span class="reorderable-row-title">{{ item.title }}</span>
<span class="row-actions">
<button type="button" :disabled="busy" @click="editChecklistItem(item)">编辑</button>
<button type="button" :disabled="busy" @click="removeChecklistItem(item.id)">删除</button>
<button type="button" :disabled="busy" @click="editChecklistItem(item)">{{ t('common.edit') }}</button>
<button type="button" :disabled="busy" @click="removeChecklistItem(item.id)">{{ t('common.delete') }}</button>
</span>
</li>
</TransitionGroup>
<p v-else class="meta-line">暂无记录</p>
</template>
</ReorderableList>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
<section v-else-if="canEdit && activeTab === 'config'" class="detail-section">
<h2>系统配置</h2>
<Tabs id="admin-config-type" v-model="activeConfigTab" :tabs="configTabs" label="系统配置类型" />
<h2>{{ t('pages.admin.config') }}</h2>
<Tabs id="admin-config-type" v-model="activeConfigTab" :tabs="configTabs" :label="t('pages.admin.configType')" />
<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">
<label for="config-name">名称</label>
<input id="config-name" v-model="configForm.name" required />
<label for="config-name">{{ t('common.name') }}</label>
<input id="config-name" v-model="configNameInput" :required="configNameRequired" />
</div>
<div v-if="selectedConfig.supportsItemDrop" class="check-row">
<label>
<input v-model="configForm.hasItemDrop" type="checkbox" />
有掉落物
{{ t('pages.admin.hasItemDrop') }}
</label>
</div>
<div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
<button type="button" class="plain-button" :disabled="busy" @click="resetConfigForm">新建</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">{{ t('common.new') }}</button>
</div>
</form>
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
<ul v-if="configRows.length" class="row-list">
<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">
<button type="button" @click="editConfig(item)">编辑</button>
<button type="button" @click="removeConfig(item.id)">删除</button>
<button type="button" @click="editConfig(item)">{{ t('common.edit') }}</button>
<button type="button" @click="removeConfig(item.id)">{{ t('common.delete') }}</button>
</span>
</li>
</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 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">
<li v-for="item in pokemonRows" :key="item.id">
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.id }} {{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="removePokemon(item.id)">删除</button>
<button type="button" @click="removePokemon(item.id)">{{ t('common.delete') }}</button>
</span>
</li>
</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 === 'items'" class="detail-section">
<h2>物品列表</h2>
<h2>{{ t('pages.admin.itemList') }}</h2>
<ul v-if="itemRows.length" class="row-list">
<li v-for="item in itemRows" :key="item.id">
<RouterLink :to="`/items/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="removeItem(item.id)">删除</button>
<button type="button" @click="removeItem(item.id)">{{ t('common.delete') }}</button>
</span>
</li>
</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 === 'recipes'" class="detail-section">
<h2>材料单列表</h2>
<h2>{{ t('pages.admin.recipeList') }}</h2>
<ul v-if="recipeRows.length" class="row-list">
<li v-for="item in recipeRows" :key="item.id">
<RouterLink :to="`/recipes/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="removeRecipe(item.id)">删除</button>
<button type="button" @click="removeRecipe(item.id)">{{ t('common.delete') }}</button>
</span>
</li>
</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 === 'habitats'" class="detail-section">
<h2>栖息地列表</h2>
<h2>{{ t('pages.admin.habitatList') }}</h2>
<ul v-if="habitatRows.length" class="row-list">
<li v-for="item in habitatRows" :key="item.id">
<RouterLink :to="`/habitats/${item.id}`">{{ item.name }}</RouterLink>
<span class="row-actions">
<button type="button" @click="removeHabitat(item.id)">删除</button>
<button type="button" @click="removeHabitat(item.id)">{{ t('common.delete') }}</button>
</span>
</li>
</ul>
<p v-else class="meta-line">暂无记录</p>
<p v-else class="meta-line">{{ t('common.noRecords') }}</p>
</section>
</section>
</template>

View File

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

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
@@ -9,6 +10,7 @@ import Skeleton from '../components/Skeleton.vue';
import { api, type HabitatDetail } from '../services/api';
const route = useRoute();
const { t } = useI18n();
const habitat = ref<HabitatDetail | null>(null);
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
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[]>(() => {
if (!habitat.value) return [];
@@ -81,7 +102,7 @@ onMounted(async () => {
</script>
<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__copy">
<Skeleton width="132px" />
@@ -127,39 +148,39 @@ onMounted(async () => {
</div>
</section>
<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 #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/habitats/${habitat.id}/edit`">编辑</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/habitats">返回列表</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">{{ t('common.backToList') }}</RouterLink>
</template>
</PageHeader>
<div class="detail-with-sidebar">
<div class="habitat-detail-stack">
<DetailSection title="配方列表">
<DetailSection :title="t('pages.habitats.recipeList')">
<EntityChips :items="habitat.recipe" />
</DetailSection>
<DetailSection title="可能出现的宝可梦">
<DetailSection :title="t('pages.habitats.possiblePokemon')">
<ul class="row-list appearance-list">
<li v-for="item in pokemonRows" :key="`${item.id}-${item.rarity}`">
<RouterLink class="appearance-name" :to="`/pokemon/${item.id}`">{{ item.name }}</RouterLink>
<dl class="appearance-summary">
<div>
<dt>时段</dt>
<dd>{{ item.timeOfDays.join(' / ') }}</dd>
<dt>{{ t('appearance.time') }}</dt>
<dd>{{ item.timeOfDays.map(timeLabel).join(' / ') }}</dd>
</div>
<div>
<dt>天气</dt>
<dd>{{ item.weathers.join(' / ') }}</dd>
<dt>{{ t('appearance.weather') }}</dt>
<dd>{{ item.weathers.map(weatherLabel).join(' / ') }}</dd>
</div>
<div>
<dt>稀有度</dt>
<dd>{{ item.rarity }} </dd>
<dt>{{ t('appearance.rarity') }}</dt>
<dd>{{ t('appearance.stars', { count: item.rarity }) }}</dd>
</div>
<div>
<dt>出现地图</dt>
<dt>{{ t('appearance.maps') }}</dt>
<dd>{{ item.maps.join(' / ') }}</dd>
</div>
</dl>

View File

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

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue';
import EntityCard from '../components/EntityCard.vue';
@@ -8,6 +9,7 @@ import Skeleton from '../components/Skeleton.vue';
import { api, type Habitat } from '../services/api';
const habitats = ref<Habitat[]>([]);
const { t } = useI18n();
const loading = ref(true);
const skeletonCardCount = 6;
@@ -19,14 +21,14 @@ onMounted(async () => {
<template>
<section class="page-stack">
<PageHeader title="栖息地" subtitle="查看配方和可能出现的宝可梦。">
<PageHeader :title="t('pages.habitats.title')" :subtitle="t('pages.habitats.subtitle')">
<template #kicker>Habitats</template>
<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>
</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">
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
<div class="entity-card__content">

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.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';
const options = ref<Options | null>(null);
const { t } = useI18n();
const items = ref<Item[]>([]);
const loading = ref(true);
const search = ref('');
@@ -23,7 +25,7 @@ const filterSkeletonWidths = ['52px', '48px', '48px'];
const skeletonCardCount = 6;
const categoryTabs = computed<TabOption[]>(() => [
{ value: '', label: '全部' },
{ value: '', label: t('common.all') },
...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
]);
@@ -50,14 +52,14 @@ watch(itemQuery, loadItems);
<template>
<section class="page-stack">
<PageHeader title="物品" subtitle="按分类、用途、标签查看物品。">
<PageHeader :title="t('pages.items.title')" :subtitle="t('pages.items.subtitle')">
<template #kicker>Bag</template>
<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>
</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 class="tab-list tab-list--skeleton">
<Skeleton
@@ -73,25 +75,25 @@ watch(itemQuery, loadItems);
<FilterPanel v-if="options">
<div class="field">
<label for="item-search">搜索</label>
<input id="item-search" v-model="search" type="search" placeholder="名称" />
<label for="item-search">{{ t('common.search') }}</label>
<input id="item-search" v-model="search" type="search" :placeholder="t('common.name')" />
</div>
<div class="field">
<label for="usage">用途</label>
<label for="usage">{{ t('pages.items.usage') }}</label>
<TagsSelect
id="usage"
v-model="usageId"
:options="options.itemUsages"
:multiple="false"
placeholder="全部"
search-placeholder="搜索用途"
:placeholder="t('common.all')"
:search-placeholder="t('pages.items.searchUsage')"
/>
</div>
<div class="field">
<label for="tags">标签</label>
<TagsSelect id="tags" v-model="tagIds" :options="options.itemTags" placeholder="搜索标签" />
<label for="tags">{{ t('pages.items.tags') }}</label>
<TagsSelect id="tags" v-model="tagIds" :options="options.itemTags" :placeholder="t('pages.items.searchTags')" />
</div>
</FilterPanel>
<FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true">
@@ -101,7 +103,7 @@ watch(itemQuery, loadItems);
</div>
</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">
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
<div class="entity-card__content">

View File

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

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.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';
const route = useRoute();
const { t } = useI18n();
const pokemon = ref<PokemonDetail | null>(null);
const itemCategoryTab = ref('');
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[]>(() => {
if (!pokemon.value) return [];
@@ -88,7 +109,7 @@ const itemCategoryTabs = computed<TabOption[]>(() => {
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.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 items = pokemon.value?.favoriteThingItems ?? [];
@@ -106,7 +127,7 @@ onMounted(async () => {
</script>
<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__copy">
<Skeleton width="142px" />
@@ -163,41 +184,41 @@ onMounted(async () => {
</div>
</section>
<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 #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">编辑</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/pokemon">返回列表</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">{{ t('common.backToList') }}</RouterLink>
</template>
</PageHeader>
<div class="detail-with-sidebar">
<div class="detail-grid detail-grid--stack">
<DetailSection title="特长">
<DetailSection :title="t('pages.pokemon.skills')">
<EntityChips :items="pokemon.skills" />
</DetailSection>
<DetailSection v-if="skillDropRows.length" title="特长掉落物">
<DetailSection v-if="skillDropRows.length" :title="t('pages.pokemon.skillDrops')">
<ul class="row-list skill-drop-summary">
<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>
</li>
</ul>
</DetailSection>
<DetailSection title="喜欢的东西">
<DetailSection :title="t('pages.pokemon.favoriteThings')">
<EntityChips :items="pokemon.favorite_things" />
</DetailSection>
<DetailSection title="关联物品">
<DetailSection :title="t('pages.pokemon.relatedItems')">
<template v-if="pokemon.favoriteThingItems.length">
<Tabs
v-if="itemCategoryTabs.length"
id="pokemon-favorite-items"
v-model="itemCategoryTab"
:tabs="itemCategoryTabs"
label="关联物品分类"
:label="t('pages.pokemon.relatedItemCategory')"
/>
<ul v-if="favoriteThingItems.length" class="row-list">
<li v-for="item in favoriteThingItems" :key="item.id">
@@ -205,30 +226,30 @@ onMounted(async () => {
<EntityChips :items="item.tags" />
</li>
</ul>
<p v-else class="meta-line"></p>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</template>
<p v-else class="meta-line"></p>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
<DetailSection title="栖息地">
<DetailSection :title="t('pages.pokemon.habitats')">
<ul class="row-list appearance-list">
<li v-for="habitat in habitatRows" :key="`${habitat.id}-${habitat.rarity}`">
<RouterLink class="appearance-name" :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
<dl class="appearance-summary">
<div>
<dt>时段</dt>
<dd>{{ habitat.timeOfDays.join(' / ') }}</dd>
<dt>{{ t('appearance.time') }}</dt>
<dd>{{ habitat.timeOfDays.map(timeLabel).join(' / ') }}</dd>
</div>
<div>
<dt>天气</dt>
<dd>{{ habitat.weathers.join(' / ') }}</dd>
<dt>{{ t('appearance.weather') }}</dt>
<dd>{{ habitat.weathers.map(weatherLabel).join(' / ') }}</dd>
</div>
<div>
<dt>稀有度</dt>
<dd>{{ habitat.rarity }} </dd>
<dt>{{ t('appearance.rarity') }}</dt>
<dd>{{ t('appearance.stars', { count: habitat.rarity }) }}</dd>
</div>
<div>
<dt>出现地图</dt>
<dt>{{ t('appearance.maps') }}</dt>
<dd>{{ habitat.maps.join(' / ') }}</dd>
</div>
</dl>

View File

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

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.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';
const options = ref<Options | null>(null);
const { t } = useI18n();
const pokemon = ref<Pokemon[]>([]);
const loading = ref(true);
const search = ref('');
@@ -46,49 +48,54 @@ watch(query, loadPokemon);
<template>
<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 #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>
</PageHeader>
<FilterPanel v-if="options">
<div class="field">
<label for="pokemon-search">搜索</label>
<input id="pokemon-search" v-model="search" type="search" placeholder="名字" />
<label for="pokemon-search">{{ t('common.search') }}</label>
<input id="pokemon-search" v-model="search" type="search" :placeholder="t('pages.pokemon.namePlaceholder')" />
</div>
<div class="field">
<label for="environment">喜欢的环境</label>
<label for="environment">{{ t('pages.pokemon.environment') }}</label>
<TagsSelect
id="environment"
v-model="environmentId"
:options="options.environments"
:multiple="false"
placeholder="全部"
search-placeholder="搜索喜欢的环境"
:placeholder="t('common.all')"
:search-placeholder="t('pages.pokemon.searchEnvironment')"
/>
</div>
<div class="field">
<label for="skills">特长</label>
<TagsSelect id="skills" v-model="skillIds" :options="options.skills" placeholder="搜索特长" />
<div class="segmented" aria-label="特长匹配方式">
<button :class="{ active: skillMode === 'any' }" type="button" @click="skillMode = 'any'">任意</button>
<button :class="{ active: skillMode === 'all' }" type="button" @click="skillMode = 'all'">全部</button>
<label for="skills">{{ t('pages.pokemon.skills') }}</label>
<TagsSelect id="skills" v-model="skillIds" :options="options.skills" :placeholder="t('pages.pokemon.searchSkills')" />
<div class="segmented" :aria-label="t('pages.pokemon.skillMatchMode')">
<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'">{{ t('pages.pokemon.all') }}</button>
</div>
</div>
<div class="field">
<label for="favorite-things">喜欢的东西</label>
<TagsSelect id="favorite-things" v-model="favoriteThingIds" :options="options.favoriteThings" placeholder="搜索喜欢的东西" />
<div class="segmented" aria-label="喜欢的东西匹配方式">
<label for="favorite-things">{{ t('pages.pokemon.favoriteThings') }}</label>
<TagsSelect
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'">
任意
{{ t('pages.pokemon.any') }}
</button>
<button :class="{ active: favoriteThingMode === 'all' }" type="button" @click="favoriteThingMode = 'all'">
全部
{{ t('pages.pokemon.all') }}
</button>
</div>
</div>
@@ -104,7 +111,7 @@ watch(query, loadPokemon);
</div>
</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">
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
<div class="entity-card__content">
@@ -125,7 +132,7 @@ watch(query, loadPokemon);
v-for="item in pokemon"
:key="item.id"
:title="`#${item.id} ${item.name}`"
:subtitle="`喜欢的环境:${item.environment.name}`"
:subtitle="t('pages.pokemon.environmentPrefix', { name: item.environment.name })"
:to="`/pokemon/${item.id}`"
>
<EditMeta :entity="item" />

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
@@ -9,6 +10,7 @@ import Skeleton from '../components/Skeleton.vue';
import { api, type RecipeDetail } from '../services/api';
const route = useRoute();
const { t } = useI18n();
const recipe = ref<RecipeDetail | null>(null);
onMounted(async () => {
@@ -17,7 +19,7 @@ onMounted(async () => {
</script>
<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__copy">
<Skeleton width="112px" />
@@ -44,21 +46,21 @@ onMounted(async () => {
</div>
</section>
<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 #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/${recipe.id}/edit`">编辑</RouterLink>
<RouterLink class="ui-button ui-button--blue ui-button--small" to="/recipes">返回列表</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">{{ t('common.backToList') }}</RouterLink>
</template>
</PageHeader>
<div class="detail-with-sidebar">
<div class="detail-grid">
<DetailSection title="入手方式">
<DetailSection :title="t('pages.items.acquisitionMethods')">
<EntityChips :items="recipe.acquisition_methods" />
</DetailSection>
<DetailSection title="需要材料">
<DetailSection :title="t('pages.recipes.materials')">
<EntityChips :items="recipe.materials" />
</DetailSection>
</div>

View File

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

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import EditMeta from '../components/EditMeta.vue';
import EntityCard from '../components/EntityCard.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';
const options = ref<Options | null>(null);
const { t } = useI18n();
const items = ref<Item[]>([]);
const loading = ref(true);
const search = ref('');
@@ -22,7 +24,7 @@ const filterSkeletonWidths = ['52px', '48px', '48px'];
const skeletonCardCount = 6;
const categoryTabs = computed<TabOption[]>(() => [
{ value: '', label: '全部' },
{ value: '', label: t('common.all') },
...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? [])
]);
@@ -69,14 +71,14 @@ watch(itemQuery, loadItems);
<template>
<section class="page-stack">
<PageHeader title="材料单" subtitle="按分类、用途、标签查看材料单。">
<PageHeader :title="t('pages.recipes.title')" :subtitle="t('pages.recipes.subtitle')">
<template #kicker>Recipes</template>
<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>
</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 class="tab-list tab-list--skeleton">
<Skeleton
@@ -92,25 +94,25 @@ watch(itemQuery, loadItems);
<FilterPanel v-if="options">
<div class="field">
<label for="recipe-search">搜索</label>
<input id="recipe-search" v-model="search" type="search" placeholder="名称" />
<label for="recipe-search">{{ t('common.search') }}</label>
<input id="recipe-search" v-model="search" type="search" :placeholder="t('common.name')" />
</div>
<div class="field">
<label for="recipe-usage">用途</label>
<label for="recipe-usage">{{ t('pages.items.usage') }}</label>
<TagsSelect
id="recipe-usage"
v-model="usageId"
:options="options.itemUsages"
:multiple="false"
placeholder="全部"
search-placeholder="搜索用途"
:placeholder="t('common.all')"
:search-placeholder="t('pages.items.searchUsage')"
/>
</div>
<div class="field">
<label for="recipe-tags">标签</label>
<TagsSelect id="recipe-tags" v-model="tagIds" :options="options.itemTags" placeholder="搜索标签" />
<label for="recipe-tags">{{ t('pages.items.tags') }}</label>
<TagsSelect id="recipe-tags" v-model="tagIds" :options="options.itemTags" :placeholder="t('pages.items.searchTags')" />
</div>
</FilterPanel>
<FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true">
@@ -120,7 +122,7 @@ watch(itemQuery, loadItems);
</div>
</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">
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
<div class="entity-card__content">
@@ -142,7 +144,7 @@ watch(itemQuery, loadItems);
>
<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)">
创建材料单
{{ t('pages.items.createRecipe') }}
</RouterLink>
</EntityCard>
</div>

View File

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

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
@@ -10,13 +11,14 @@ const route = useRoute();
const busy = ref(true);
const message = ref('');
const errorMessage = ref('');
const { t } = useI18n();
onMounted(async () => {
const token = typeof route.query.token === 'string' ? route.query.token : '';
if (!token) {
busy.value = false;
errorMessage.value = '验证链接无效或已过期';
errorMessage.value = t('auth.invalidVerification');
return;
}
@@ -24,7 +26,7 @@ onMounted(async () => {
const response = await api.verifyEmail(token);
message.value = response.message;
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : '邮箱验证失败';
errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.verifyFailed');
} finally {
busy.value = false;
}
@@ -34,11 +36,11 @@ onMounted(async () => {
<template>
<section class="auth-page">
<div class="auth-panel">
<PageHeader title="邮箱验证" subtitle="完成验证后即可登录">
<PageHeader :title="t('auth.verifyTitle')" :subtitle="t('auth.verifySubtitle')">
<template #kicker>Trainer Pass</template>
</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="84%" />
<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 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>
</section>
</template>