feat(items): add base price and support usage in creation defaults

Add `base_price` to items schema, API, and edit history
Display and edit base price in item details and forms
Add `clearable` prop to TagsSelect for optional single selections
Include usage in item creation session defaults
This commit is contained in:
2026-05-05 08:59:36 +08:00
parent 8ee29e9549
commit 9312156a3c
10 changed files with 118 additions and 20 deletions

View File

@@ -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
- 分类
- 用途
- 入手方式

View File

@@ -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',

View File

@@ -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<string, unknown>): 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<string, unknown>, userId: numbe
INSERT INTO items (
name,
details,
base_price,
category_key,
usage_key,
dyeable,
@@ -6570,12 +6595,13 @@ export async function createItem(payload: Record<string, unknown>, 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<string, unknown>, 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',

View File

@@ -49,6 +49,9 @@ const changeLabelKeys: Record<string, string> = {
分类: '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',

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<ItemDetail | null>(null);
const currentUser = ref<AuthUser | null>(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(
<dt>{{ t('pages.items.usage') }}</dt>
<dd>{{ item.usage?.name ?? t('common.none') }}</dd>
</div>
<div>
<dt>{{ t('pages.items.basePrice') }}</dt>
<dd>{{ basePriceDisplay }}</dd>
</div>
<div>
<dt>{{ t('pages.items.recipeInfo') }}</dt>
<dd>{{ item.noRecipe ? t('pages.items.noRecipe') : item.recipe ? item.recipe.name : t('common.none') }}</dd>

View File

@@ -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<ItemCreateDefaults>;
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"
/>
<div class="field">
<label for="item-base-price">{{ t('pages.items.basePrice') }}</label>
<input id="item-base-price" v-model="itemForm.basePrice" type="number" min="0" step="1" inputmode="numeric" />
</div>
<div class="field">
<label for="item-category">{{ t('pages.items.category') }}</label>
<TagsSelect
@@ -361,6 +376,7 @@ onMounted(() => {
v-model="itemForm.usageId"
:options="options.itemUsages"
:multiple="false"
clearable
:placeholder="t('common.none')"
:search-placeholder="t('pages.items.searchUsage')"
/>

View File

@@ -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<ItemCreateDefaults>;
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) => {
/>
</div>
<div class="field">
<label for="item-default-usage">{{ t('pages.items.usage') }}</label>
<TagsSelect
id="item-default-usage"
v-model="itemCreateDefaults.usageId"
:options="options.itemUsages"
:multiple="false"
clearable
:placeholder="t('common.none')"
:search-placeholder="t('pages.items.searchUsage')"
/>
</div>
<div class="check-row item-create-defaults-menu__checks">
<label><input v-model="itemCreateDefaults.dyeable" type="checkbox" /> {{ t('pages.items.dyeable') }}</label>
<label><input v-model="itemCreateDefaults.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label>
@@ -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')"
/>

View File

@@ -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: {