diff --git a/DESIGN.md b/DESIGN.md index edbdcf4..d835d33 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -596,6 +596,7 @@ Pokemon 详情页展示: - 名称 - 介绍 +- Base Price:可为空 - 是否为 Event Item:`is_event_item` - 分类:必填,使用系统固定列表,不在管理端配置: - Furniture @@ -640,7 +641,7 @@ Items 与 Event Items 使用相同数据模型: - 按标签筛选 - 按自定义排序展示 - All 视图在满足写入权限时支持对 Grid Item 右键插入新物品到前/后,并支持直接拖曳 Item 调整排序;插入与拖曳只作用于当前展示的 Items 列表,不影响 Event Items 入口。 -- 新增物品入口支持当前浏览器 Session 的默认值菜单;用户可为新建物品预设分类、客制化勾选项和入手方式。默认值只影响 `/items/new` 与 `/event-items/new` 的新建表单初始值,不影响编辑已有物品,不改变 API、数据库模型、权限或审计行为;Event Items 仍由 `/event-items/new` 入口决定 `is_event_item`。 +- 新增物品入口支持当前浏览器 Session 的默认值菜单;用户可为新建物品预设分类、用途、客制化勾选项和入手方式。默认值只影响 `/items/new` 与 `/event-items/new` 的新建表单初始值,不影响编辑已有物品,不改变 API、数据库模型、权限或审计行为;Event Items 仍由 `/event-items/new` 入口决定 `is_event_item`。 - 物品列表桌面端使用 12 列紧凑 Grid,每个格子只展示物品图标;有用途的物品在卡片左上角以斜 Ribbon 展示用途名称;物品名称通过 hover / focus Tooltip 展示。 - 物品列表移动端保持常规卡片布局,展示物品图标、名称和分类。 - 物品列表不展示标签、入手方式或编辑元信息。 @@ -652,6 +653,7 @@ Items 与 Event Items 使用相同数据模型: - 当前图标图片;未配置图标时展示默认物品标记占位符 - 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图 - 介绍 +- Base Price - 分类 - 用途 - 入手方式 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index c9a7287..fc74e6b 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -976,6 +976,7 @@ CREATE TABLE IF NOT EXISTS items ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, details text NOT NULL DEFAULT '', + base_price integer, category_key text NOT NULL DEFAULT 'other', usage_key text, category_id integer REFERENCES item_categories(id), @@ -1220,9 +1221,14 @@ ALTER TABLE life_tags ALTER TABLE items ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS base_price integer, ADD COLUMN IF NOT EXISTS category_key text, ADD COLUMN IF NOT EXISTS usage_key text; +UPDATE items +SET base_price = NULL +WHERE base_price < 0; + ALTER TABLE ancient_artifacts ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT ''; @@ -1301,10 +1307,12 @@ ALTER TABLE items ALTER TABLE items DROP CONSTRAINT IF EXISTS items_display_id_positive, + DROP CONSTRAINT IF EXISTS items_base_price_check, DROP CONSTRAINT IF EXISTS items_category_key_check, DROP CONSTRAINT IF EXISTS items_usage_key_check; ALTER TABLE items + ADD CONSTRAINT items_base_price_check CHECK (base_price IS NULL OR base_price >= 0), ADD CONSTRAINT items_category_key_check CHECK (category_key IN ( 'furniture', 'misc', diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 54694ea..e3a224d 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -203,6 +203,7 @@ type PokemonCsvData = { type ItemPayload = { name: string; details: string; + basePrice: number | null; translations: TranslationInput; categoryId: number; categoryKey: string; @@ -543,6 +544,7 @@ type PokemonChangeSource = { type ItemChangeSource = { name: string; details: string; + basePrice: number | null; isEventItem: boolean; image: EntityImageValue | null; category: { name: string }; @@ -1077,6 +1079,20 @@ function cleanNonNegativeNumber(value: unknown, message: string): number { return numberValue; } +function cleanOptionalNonNegativeInteger(value: unknown, message: string): number | null { + const rawValue = typeof value === 'string' ? value.trim() : value; + if (rawValue === undefined || rawValue === null || rawValue === '') { + return null; + } + + const numberValue = Number(rawValue); + if (!Number.isInteger(numberValue) || numberValue < 0) { + throw validationError(message); + } + + return numberValue; +} + function cleanQuantities(value: unknown): IdQuantity[] { if (!Array.isArray(value)) { return []; @@ -2246,6 +2262,12 @@ async function itemEditChanges( pushChange(changes, 'Name', before.name, after.name); pushChange(changes, 'Description', before.details, after.details); + pushChange( + changes, + 'Base Price', + before.basePrice === null ? null : String(before.basePrice), + after.basePrice === null ? null : String(after.basePrice) + ); pushTranslationChanges(changes, before.translations, after.translations, ['name', 'details']); pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem)); pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath)); @@ -6216,6 +6238,7 @@ function itemProjection(locale: string): string { i.name AS "baseName", ${itemDetails} AS details, i.details AS "baseDetails", + i.base_price AS "basePrice", i.is_event_item AS "isEventItem", ${translationsSelect('items', 'i.id')} AS translations, ${auditSelect('i', 'item_created_user', 'item_updated_user')}, @@ -6484,6 +6507,7 @@ function cleanItemPayload(payload: Record): ItemPayload { return { name: cleanName(payload.name, 'server.validation.itemNameRequired'), details: cleanOptionalText(payload.details), + basePrice: cleanOptionalNonNegativeInteger(payload.basePrice, 'server.validation.invalidField'), translations: cleanTranslations(payload.translations, ['name', 'details']), categoryId, categoryKey: category.key, @@ -6558,6 +6582,7 @@ export async function createItem(payload: Record, userId: numbe INSERT INTO items ( name, details, + base_price, category_key, usage_key, dyeable, @@ -6570,12 +6595,13 @@ export async function createItem(payload: Record, userId: numbe created_by_user_id, updated_by_user_id ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $12) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $13) RETURNING id `, [ cleanPayload.name, cleanPayload.details, + cleanPayload.basePrice, cleanPayload.categoryKey, cleanPayload.usageKey, cleanPayload.dyeable, @@ -6631,21 +6657,23 @@ export async function updateItem(id: number, payload: Record, u UPDATE items SET name = $1, details = $2, - category_key = $3, - usage_key = $4, - dyeable = $5, - dual_dyeable = $6, - pattern_editable = $7, - no_recipe = $8, - is_event_item = $9, - image_path = $10, - updated_by_user_id = $11, + base_price = $3, + category_key = $4, + usage_key = $5, + dyeable = $6, + dual_dyeable = $7, + pattern_editable = $8, + no_recipe = $9, + is_event_item = $10, + image_path = $11, + updated_by_user_id = $12, updated_at = now() - WHERE id = $12 + WHERE id = $13 `, [ cleanPayload.name, cleanPayload.details, + cleanPayload.basePrice, cleanPayload.categoryKey, cleanPayload.usageKey, cleanPayload.dyeable, @@ -7586,6 +7614,7 @@ const dataToolColumns = { 'id', 'name', 'details', + 'base_price', 'category_key', 'usage_key', 'dyeable', diff --git a/frontend/src/components/EditHistoryPanel.vue b/frontend/src/components/EditHistoryPanel.vue index 239c445..d9ff12d 100644 --- a/frontend/src/components/EditHistoryPanel.vue +++ b/frontend/src/components/EditHistoryPanel.vue @@ -49,6 +49,9 @@ const changeLabelKeys: Record = { 分类: 'pages.items.category', Usage: 'pages.items.usage', 用途: 'pages.items.usage', + 'Base Price': 'pages.items.basePrice', + 'Base price': 'pages.items.basePrice', + 基础价格: 'pages.items.basePrice', Dyeable: 'pages.items.dyeable', 可染色: 'pages.items.dyeable', 'Dual dyeable': 'pages.items.dualDyeable', diff --git a/frontend/src/components/TagsSelect.vue b/frontend/src/components/TagsSelect.vue index 2c5d4a6..402b084 100644 --- a/frontend/src/components/TagsSelect.vue +++ b/frontend/src/components/TagsSelect.vue @@ -33,12 +33,14 @@ const props = withDefaults( creating?: boolean; createLabel?: string; dropdownStrategy?: DropdownStrategy; + clearable?: boolean; }>(), { multiple: true, max: 0, allowCreate: false, - creating: false + creating: false, + clearable: false } ); @@ -167,6 +169,12 @@ function updateValue(values: string[]) { function selectOption(value: string) { if (!props.multiple) { + if (props.clearable && selectedValues.value.has(value)) { + updateValue([]); + closeDropdown(); + return; + } + updateValue([value]); closeDropdown(); return; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index a99a117..46b5df5 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -257,6 +257,7 @@ export interface Item extends EditInfo { baseName?: string; details: string; baseDetails?: string; + basePrice: number | null; isEventItem: boolean; translations?: TranslationMap; image: EntityImage | null; @@ -789,6 +790,7 @@ export interface PokemonImageOptionsResult { export interface ItemPayload { name: string; details: string; + basePrice: number | null; translations?: TranslationMap; categoryId: number; usageId: number | null; diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index b1571f7..85636a3 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -17,7 +17,7 @@ import { api, getAuthToken, type AuthUser, type ItemDetail } from '../services/a import ItemEdit from './ItemEdit.vue'; const route = useRoute(); -const { t } = useI18n(); +const { locale, t } = useI18n(); const item = ref(null); const currentUser = ref(null); const detailTab = ref('details'); @@ -38,6 +38,10 @@ const itemSubtitle = computed(() => { }); const detailKicker = computed(() => (item.value?.isEventItem ? t('pages.eventItems.detailKicker') : t('pages.items.detailKicker'))); const listTarget = computed(() => (item.value?.isEventItem ? '/event-items' : '/items')); +const basePriceDisplay = computed(() => { + const price = item.value?.basePrice; + return price === null || price === undefined ? t('common.none') : new Intl.NumberFormat(locale.value).format(price); +}); const customization = computed(() => { if (!item.value) { @@ -190,6 +194,10 @@ watch(
{{ t('pages.items.usage') }}
{{ item.usage?.name ?? t('common.none') }}
+
+
{{ t('pages.items.basePrice') }}
+
{{ basePriceDisplay }}
+
{{ t('pages.items.recipeInfo') }}
{{ item.noRecipe ? t('pages.items.noRecipe') : item.recipe ? item.recipe.name : t('common.none') }}
diff --git a/frontend/src/views/ItemEdit.vue b/frontend/src/views/ItemEdit.vue index 464e8f5..ca9bfc5 100644 --- a/frontend/src/views/ItemEdit.vue +++ b/frontend/src/views/ItemEdit.vue @@ -38,6 +38,7 @@ const creatingSelect = ref(''); const itemForm = ref({ name: '', details: '', + basePrice: '', translations: {} as TranslationMap, categoryId: '', usageId: '', @@ -53,6 +54,7 @@ const itemForm = ref({ type ItemCreateDefaults = { categoryId: string; + usageId: string; dyeable: boolean; dualDyeable: boolean; patternEditable: boolean; @@ -98,6 +100,7 @@ function readItemCreateDefaults(): ItemCreateDefaults { if (typeof sessionStorage === 'undefined') { return { categoryId: '', + usageId: '', dyeable: false, dualDyeable: false, patternEditable: false, @@ -111,6 +114,7 @@ function readItemCreateDefaults(): ItemCreateDefaults { if (!rawValue) { return { categoryId: '', + usageId: '', dyeable: false, dualDyeable: false, patternEditable: false, @@ -122,6 +126,7 @@ function readItemCreateDefaults(): ItemCreateDefaults { const parsedValue = JSON.parse(rawValue) as Partial; return { categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '', + usageId: typeof parsedValue.usageId === 'string' ? parsedValue.usageId : '', dyeable: parsedValue.dyeable === true, dualDyeable: parsedValue.dualDyeable === true, patternEditable: parsedValue.patternEditable === true, @@ -133,6 +138,7 @@ function readItemCreateDefaults(): ItemCreateDefaults { } catch { return { categoryId: '', + usageId: '', dyeable: false, dualDyeable: false, patternEditable: false, @@ -151,10 +157,12 @@ function applyItemCreateDefaults(isEventItem: boolean) { const defaults = readItemCreateDefaults(); const categoryIds = new Set(loadedOptions.itemCategories.map((item) => String(item.id))); + const usageIds = new Set(loadedOptions.itemUsages.map((item) => String(item.id))); const methodIds = new Set(loadedOptions.acquisitionMethods.map((item) => String(item.id))); itemForm.value = { ...itemForm.value, categoryId: categoryIds.has(defaults.categoryId) ? defaults.categoryId : '', + usageId: usageIds.has(defaults.usageId) ? defaults.usageId : '', dyeable: defaults.dyeable, dualDyeable: defaults.dualDyeable, patternEditable: defaults.patternEditable, @@ -207,6 +215,7 @@ async function loadEditor() { itemForm.value = { name: item.baseName ?? item.name, details: item.baseDetails ?? item.details, + basePrice: item.basePrice === null || item.basePrice === undefined ? '' : String(item.basePrice), translations: item.translations ?? {}, categoryId: String(item.category.id), usageId: item.usage ? String(item.usage.id) : '', @@ -260,6 +269,7 @@ async function saveItem() { const payload: ItemPayload = { name: itemNameForSave(), details: itemForm.value.details, + basePrice: itemForm.value.basePrice.trim() === '' ? null : Number(itemForm.value.basePrice), translations: itemForm.value.translations, categoryId: Number(itemForm.value.categoryId), usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null, @@ -342,6 +352,11 @@ onMounted(() => { @error="message = $event" /> +
+ + +
+
{ v-model="itemForm.usageId" :options="options.itemUsages" :multiple="false" + clearable :placeholder="t('common.none')" :search-placeholder="t('pages.items.searchUsage')" /> diff --git a/frontend/src/views/ItemsList.vue b/frontend/src/views/ItemsList.vue index e356e65..80d4b0b 100644 --- a/frontend/src/views/ItemsList.vue +++ b/frontend/src/views/ItemsList.vue @@ -46,6 +46,7 @@ const dropCommitted = ref(false); type ItemCreateDefaults = { categoryId: string; + usageId: string; dyeable: boolean; dualDyeable: boolean; patternEditable: boolean; @@ -57,6 +58,7 @@ const itemCreateDefaultsStorageKey = 'pokopia_item_create_defaults'; const emptyItemCreateDefaults = (): ItemCreateDefaults => ({ categoryId: '', + usageId: '', dyeable: false, dualDyeable: false, patternEditable: false, @@ -101,6 +103,7 @@ const canCreateItem = computed(() => currentUser.value?.permissions.includes('it const hasItemCreateDefaults = computed( () => itemCreateDefaults.value.categoryId !== '' || + itemCreateDefaults.value.usageId !== '' || itemCreateDefaults.value.dyeable || itemCreateDefaults.value.dualDyeable || itemCreateDefaults.value.patternEditable || @@ -177,6 +180,7 @@ function readItemCreateDefaults(): ItemCreateDefaults { const parsedValue = JSON.parse(rawValue) as Partial; return { categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '', + usageId: typeof parsedValue.usageId === 'string' ? parsedValue.usageId : '', dyeable: parsedValue.dyeable === true, dualDyeable: parsedValue.dualDyeable === true, patternEditable: parsedValue.patternEditable === true, @@ -209,10 +213,12 @@ function sanitizeItemCreateDefaults() { } const categoryIds = new Set(options.value.itemCategories.map((item) => String(item.id))); + const usageIds = new Set(options.value.itemUsages.map((item) => String(item.id))); const methodIds = new Set(options.value.acquisitionMethods.map((item) => String(item.id))); const nextDefaults = { ...itemCreateDefaults.value, categoryId: categoryIds.has(itemCreateDefaults.value.categoryId) ? itemCreateDefaults.value.categoryId : '', + usageId: usageIds.has(itemCreateDefaults.value.usageId) ? itemCreateDefaults.value.usageId : '', acquisitionMethodIds: itemCreateDefaults.value.acquisitionMethodIds.filter((item) => methodIds.has(item)) }; @@ -509,6 +515,19 @@ watch(itemSortingAllowed, (allowed) => { />
+
+ + +
+
@@ -557,6 +576,7 @@ watch(itemSortingAllowed, (allowed) => { v-model="usageId" :options="options.itemUsages" :multiple="false" + clearable :placeholder="t('common.all')" :search-placeholder="t('pages.items.searchUsage')" /> diff --git a/system-wordings.ts b/system-wordings.ts index 96d6340..48ac543 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -134,7 +134,7 @@ export const systemWordingMessages = { pokemonDetailDescription: 'Read {name} details in Pokopia Wiki, including habitat, types, specialities, favourites, stats, related items, discussions, and edit history.', itemDetailDescription: - 'Browse {name} item details in Pokopia Wiki, including category, usage, acquisition methods, customization, related recipes, habitats, and Pokemon drops.', + 'Browse {name} item details in Pokopia Wiki, including base price, category, usage, acquisition methods, customization, related recipes, habitats, and Pokemon drops.', ancientArtifactDetailDescription: 'Browse {name} Ancient Artifact details in Pokopia Wiki, including category, tags, description, discussions, and edit history.', habitatDetailDescription: @@ -680,7 +680,7 @@ export const systemWordingMessages = { detailKicker: 'Item Detail', detailSubtitle: 'Item detail', editKicker: 'Item Edit', - editSubtitle: 'Maintain item category, usage, acquisition methods, customization, and tags.', + editSubtitle: 'Maintain item base price, category, usage, acquisition methods, customization, and tags.', newTitle: 'New item', editTitle: 'Edit {name}', fallbackName: 'Item', @@ -688,6 +688,7 @@ export const systemWordingMessages = { loadingDetail: 'Loading item detail', loadingEdit: 'Loading item editor', description: 'Description', + basePrice: 'Base Price', category: 'Category', usage: 'Usage', tags: 'Tags', @@ -720,7 +721,7 @@ export const systemWordingMessages = { subtitle: 'Browse event items by category, usage, and tags.', kicker: 'Event Items', detailKicker: 'Event Item Detail', - editSubtitle: 'Maintain event item category, usage, acquisition methods, customization, and tags.', + editSubtitle: 'Maintain event item base price, category, usage, acquisition methods, customization, and tags.', newTitle: 'New event item' }, ancientArtifacts: { @@ -1495,7 +1496,7 @@ export const systemWordingMessages = { seo: { siteDescription: '浏览 Pokopia Wiki 的 Pokemon、Event Pokemon、栖息地、Event Habitats、物品、Event Items、Ancient Artifacts、材料单、每日清单和 Life 社区动态。', pokemonDetailDescription: '查看 {name} 在 Pokopia Wiki 中的栖息地、属性、特长、喜欢的东西、六维、相关物品、讨论和编辑历史。', - itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。', + itemDetailDescription: '查看 {name} 在 Pokopia Wiki 中的基础价格、分类、用途、入手方式、自定义、相关材料单、栖息地和 Pokemon 掉落。', ancientArtifactDetailDescription: '查看 {name} 在 Pokopia Wiki 中的分类、标签、介绍、讨论和编辑历史。', habitatDetailDescription: '查看 {name} 在 Pokopia Wiki 中的配方、可能出现的 Pokemon、地图、时间、天气、讨论和编辑历史。', recipeDetailDescription: '查看 {name} 材料单在 Pokopia Wiki 中的结果物品、入手方式、需要材料、讨论和编辑历史。' @@ -2018,7 +2019,7 @@ export const systemWordingMessages = { detailKicker: 'Item Detail', detailSubtitle: '物品详情', editKicker: 'Item Edit', - editSubtitle: '维护物品分类、用途、入手方式、自定义和标签。', + editSubtitle: '维护物品基础价格、分类、用途、入手方式、自定义和标签。', newTitle: '新增物品', editTitle: '编辑 {name}', fallbackName: '物品', @@ -2026,6 +2027,7 @@ export const systemWordingMessages = { loadingDetail: '正在加载物品详情', loadingEdit: '正在加载物品编辑内容', description: '介绍', + basePrice: '基础价格', category: '分类', usage: '用途', tags: '标签', @@ -2058,7 +2060,7 @@ export const systemWordingMessages = { subtitle: '按分类、用途、标签查看活动物品。', kicker: 'Event Items', detailKicker: 'Event Item Detail', - editSubtitle: '维护 Event Item 分类、用途、入手方式、自定义和标签。', + editSubtitle: '维护 Event Item 基础价格、分类、用途、入手方式、自定义和标签。', newTitle: '新增 Event Item' }, ancientArtifacts: {