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

@@ -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')"
/>