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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user