From 23a730159803b37518d923c57a8d4efe3c7e528d Mon Sep 17 00:00:00 2001 From: xiaomai Date: Thu, 7 May 2026 10:17:45 +0800 Subject: [PATCH] feat(items): replace dyeable booleans with dyeability level Add dyeability integer field to support up to triple dyeable items Update frontend forms to use a radio group for dyeability selection --- DESIGN.md | 7 +- backend/db/schema.sql | 13 +++ backend/src/queries.ts | 87 ++++++++++++---- frontend/src/components/EditHistoryPanel.vue | 12 +++ frontend/src/services/api.ts | 6 +- frontend/src/styles/main.css | 40 +++++++ frontend/src/views/ItemDetail.vue | 9 +- frontend/src/views/ItemEdit.vue | 104 ++++++++----------- frontend/src/views/ItemsList.vue | 46 ++++++-- system-wordings.ts | 6 ++ 10 files changed, 230 insertions(+), 100 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 2a1f62c..3d08b9a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -645,8 +645,11 @@ Pokemon 详情页展示: - Road - 入手方式:可多选 - 客制化: - - 可染色 - - 可双区染色 + - 染色能力:`dyeability`,使用互斥枚举值维护: + - `0`:不可染色 + - `1`:可染色 + - `2`:可双区染色 + - `3`:可三区染色 - 可改花纹 - 无材料单:`no_recipe` - 标签:使用喜欢的东西配置,可多选 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 2c7fa69..9895d1b 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -905,6 +905,7 @@ CREATE TABLE IF NOT EXISTS items ( ancient_artifact_category_key text, category_key text NOT NULL DEFAULT 'other', usage_key text, + dyeability integer NOT NULL DEFAULT 0 CHECK (dyeability IN (0, 1, 2, 3)), dyeable boolean NOT NULL DEFAULT false, dual_dyeable boolean NOT NULL DEFAULT false, pattern_editable boolean NOT NULL DEFAULT false, @@ -1276,3 +1277,15 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_language_idx ALTER TABLE skills ADD COLUMN IF NOT EXISTS has_trading boolean NOT NULL DEFAULT false; + +ALTER TABLE items + ADD COLUMN IF NOT EXISTS dyeability integer NOT NULL DEFAULT 0 CHECK (dyeability IN (0, 1, 2, 3)); + +UPDATE items +SET dyeability = CASE + WHEN dual_dyeable THEN 2 + WHEN dyeable THEN 1 + ELSE 0 +END +WHERE dyeability = 0 + AND (dual_dyeable OR dyeable); diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 14a53ff..939117b 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -223,8 +223,7 @@ type ItemPayload = { categoryKey: string; usageId: number | null; usageKey: string | null; - dyeable: boolean; - dualDyeable: boolean; + dyeability: number; patternEditable: boolean; noRecipe: boolean; isEventItem: boolean; @@ -565,7 +564,7 @@ type ItemChangeSource = { image: EntityImageValue | null; category: { name: string }; usage: { name: string } | null; - customization: { dyeable: boolean; dualDyeable: boolean; patternEditable: boolean }; + customization: { dyeability: number; patternEditable: boolean }; noRecipe: boolean; acquisitionMethods: Array<{ name: string }>; tags: Array<{ name: string }>; @@ -2436,8 +2435,7 @@ async function itemEditChanges( pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath)); pushChange(changes, 'Category', before.category.name, systemListNameByKey(itemCategoryOptions, after.categoryKey)); pushChange(changes, 'Usage', before.usage?.name, systemListNameByKey(itemUsageOptions, after.usageKey)); - pushChange(changes, 'Dyeable', boolValue(before.customization.dyeable), boolValue(after.dyeable)); - pushChange(changes, 'Dual dyeable', boolValue(before.customization.dualDyeable), boolValue(after.dualDyeable)); + pushChange(changes, 'Dyeability', dyeabilityValue(before.customization.dyeability), dyeabilityValue(after.dyeability)); pushChange(changes, 'Pattern editable', boolValue(before.customization.patternEditable), boolValue(after.patternEditable)); pushChange(changes, 'No recipe', boolValue(before.noRecipe), boolValue(after.noRecipe)); pushChange(changes, 'Acquisition methods', namedListValue(before.acquisitionMethods), namesFromIds(after.acquisitionMethodIds, methodNames)); @@ -6597,8 +6595,7 @@ function itemProjection(locale: string): string { ELSE ${systemListJsonSql('i.usage_key', itemUsageOptions, locale)} END AS usage, json_build_object( - 'dyeable', i.dyeable, - 'dualDyeable', i.dual_dyeable, + 'dyeability', i.dyeability, 'patternEditable', i.pattern_editable ) AS customization, i.no_recipe AS "noRecipe", @@ -6928,8 +6925,7 @@ function cleanItemPayload(payload: Record): ItemPayload { categoryKey: category.key, usageId, usageKey: usage?.key ?? null, - dyeable: Boolean(payload.dyeable), - dualDyeable: Boolean(payload.dualDyeable), + dyeability: cleanDyeability(payload), patternEditable: Boolean(payload.patternEditable), noRecipe: Boolean(payload.noRecipe), isEventItem: Boolean(payload.isEventItem), @@ -6949,6 +6945,38 @@ function cleanOptionalPositiveInteger(value: unknown): number | null { return requirePositiveInteger(value, 'server.validation.invalidField'); } +function cleanDyeability(payload: Record): number { + if (payload.dyeability === undefined || payload.dyeability === null || payload.dyeability === '') { + if (payload.dualDyeable === true) { + return 2; + } + if (payload.dyeable === true) { + return 1; + } + return 0; + } + + const dyeability = Number(payload.dyeability); + if (!Number.isInteger(dyeability) || dyeability < 0 || dyeability > 3) { + throw validationError('server.validation.invalidField'); + } + + return dyeability; +} + +function dyeabilityValue(value: number): string { + if (value === 3) { + return 'Triple dyeable'; + } + if (value === 2) { + return 'Dual dyeable'; + } + if (value === 1) { + return 'Dyeable'; + } + return 'Not dyeable'; +} + async function orderedItemIds(client: DbClient, isEventItem: boolean): Promise { const rows = await client.query<{ id: number }>( 'SELECT id FROM items WHERE is_event_item = $1 ORDER BY sort_order, id', @@ -7001,6 +7029,7 @@ export async function createItem(payload: Record, userId: numbe base_price, category_key, usage_key, + dyeability, dyeable, dual_dyeable, pattern_editable, @@ -7011,7 +7040,7 @@ 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, $13, $14, $14) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15) RETURNING id `, [ @@ -7021,8 +7050,9 @@ export async function createItem(payload: Record, userId: numbe cleanPayload.basePrice, cleanPayload.categoryKey, cleanPayload.usageKey, - cleanPayload.dyeable, - cleanPayload.dualDyeable, + cleanPayload.dyeability, + cleanPayload.dyeability >= 1, + cleanPayload.dyeability >= 2, cleanPayload.patternEditable, cleanPayload.noRecipe, cleanPayload.isEventItem, @@ -7078,15 +7108,16 @@ export async function updateItem(id: number, payload: Record, u base_price = $4, category_key = $5, usage_key = $6, - dyeable = $7, - dual_dyeable = $8, - pattern_editable = $9, - no_recipe = $10, - is_event_item = $11, - image_path = $12, - updated_by_user_id = $13, + dyeability = $7, + dyeable = $8, + dual_dyeable = $9, + pattern_editable = $10, + no_recipe = $11, + is_event_item = $12, + image_path = $13, + updated_by_user_id = $14, updated_at = now() - WHERE id = $14 + WHERE id = $15 `, [ cleanPayload.name, @@ -7095,8 +7126,9 @@ export async function updateItem(id: number, payload: Record, u cleanPayload.basePrice, cleanPayload.categoryKey, cleanPayload.usageKey, - cleanPayload.dyeable, - cleanPayload.dualDyeable, + cleanPayload.dyeability, + cleanPayload.dyeability >= 1, + cleanPayload.dyeability >= 2, cleanPayload.patternEditable, cleanPayload.noRecipe, cleanPayload.isEventItem, @@ -8041,6 +8073,7 @@ const dataToolColumns = { 'ancient_artifact_category_key', 'category_key', 'usage_key', + 'dyeability', 'dyeable', 'dual_dyeable', 'pattern_editable', @@ -8306,6 +8339,16 @@ function normalizeImportValue(value: unknown): unknown { } function normalizeImportColumnValue(row: Record, column: string): unknown { + if (column === 'dyeability' && row[column] === undefined) { + if (row.dual_dyeable === true) { + return 2; + } + if (row.dyeable === true) { + return 1; + } + return 0; + } + return normalizeImportValue(row[column]); } diff --git a/frontend/src/components/EditHistoryPanel.vue b/frontend/src/components/EditHistoryPanel.vue index f287815..dfdcf92 100644 --- a/frontend/src/components/EditHistoryPanel.vue +++ b/frontend/src/components/EditHistoryPanel.vue @@ -55,10 +55,14 @@ const changeLabelKeys: Record = { 'Base Price': 'pages.items.basePrice', 'Base price': 'pages.items.basePrice', 基础价格: 'pages.items.basePrice', + Dyeability: 'pages.items.dyeability', + 染色能力: 'pages.items.dyeability', Dyeable: 'pages.items.dyeable', 可染色: 'pages.items.dyeable', 'Dual dyeable': 'pages.items.dualDyeable', 可双区染色: 'pages.items.dualDyeable', + 'Triple dyeable': 'pages.items.tripleDyeable', + 可三区染色: 'pages.items.tripleDyeable', 'Pattern editable': 'pages.items.patternEditable', 可改花纹: 'pages.items.patternEditable', 'No recipe': 'pages.items.noRecipe', @@ -117,6 +121,14 @@ function changeValue(value: string): string { const values: Record = { None: t('common.none'), 无: t('common.none'), + 'Not dyeable': t('pages.items.notDyeable'), + 不可染色: t('pages.items.notDyeable'), + Dyeable: t('pages.items.dyeable'), + 可染色: t('pages.items.dyeable'), + 'Dual dyeable': t('pages.items.dualDyeable'), + 可双区染色: t('pages.items.dualDyeable'), + 'Triple dyeable': t('pages.items.tripleDyeable'), + 可三区染色: t('pages.items.tripleDyeable'), Yes: locale.value === 'zh-CN' ? '是' : 'Yes', 是: locale.value === 'zh-CN' ? '是' : 'Yes', No: locale.value === 'zh-CN' ? '否' : 'No', diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 32f4f93..b685bc7 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -325,8 +325,7 @@ export interface Item extends EditInfo { category: NamedEntity; usage: NamedEntity | null; customization: { - dyeable: boolean; - dualDyeable: boolean; + dyeability: number; patternEditable: boolean; }; noRecipe: boolean; @@ -873,8 +872,7 @@ export interface ItemPayload { translations?: TranslationMap; categoryId: number; usageId: number | null; - dyeable: boolean; - dualDyeable: boolean; + dyeability: number; patternEditable: boolean; noRecipe: boolean; isEventItem: boolean; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 0d2573e..dd371c0 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -7477,6 +7477,46 @@ button:disabled, align-items: center; } +.radio-group { + display: grid; + gap: 7px; + min-width: 0; + min-inline-size: 0; + margin: 0; + padding: 0; + border: 0; +} + +.radio-group legend { + padding: 0; + color: var(--ink-soft); + font-size: 14px; + font-weight: 850; +} + +.radio-group__options { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: center; +} + +.radio-group__option { + display: inline-flex; + align-items: center; + gap: 7px; + min-height: 36px; + color: var(--ink-soft); + font-weight: 850; + cursor: pointer; +} + +.radio-group__option input { + width: 18px; + height: 18px; + accent-color: var(--pokemon-blue); +} + .row-actions { flex: 0 0 auto; flex-wrap: wrap; diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index de9dc90..dc53d3e 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -117,9 +117,14 @@ const customization = computed(() => { return []; } + const dyeabilityLabels: Record = { + 1: t('pages.items.dyeable'), + 2: t('pages.items.dualDyeable'), + 3: t('pages.items.tripleDyeable') + }; + return [ - item.value.customization.dyeable ? t('pages.items.dyeable') : '', - item.value.customization.dualDyeable ? t('pages.items.dualDyeable') : '', + dyeabilityLabels[item.value.customization.dyeability] ?? '', item.value.customization.patternEditable ? t('pages.items.patternEditable') : '' ].filter(Boolean); }); diff --git a/frontend/src/views/ItemEdit.vue b/frontend/src/views/ItemEdit.vue index a5ed98c..b51c19e 100644 --- a/frontend/src/views/ItemEdit.vue +++ b/frontend/src/views/ItemEdit.vue @@ -34,6 +34,9 @@ const loading = ref(true); const busy = ref(false); const message = ref(''); const creatingSelect = ref(''); + +type Dyeability = 0 | 1 | 2 | 3; + const itemForm = ref({ name: '', details: '', @@ -42,8 +45,7 @@ const itemForm = ref({ translations: {} as TranslationMap, categoryId: '', usageId: '', - dyeable: false, - dualDyeable: false, + dyeability: 0 as Dyeability, patternEditable: false, noRecipe: false, isEventItem: false, @@ -55,8 +57,7 @@ const itemForm = ref({ type ItemCreateDefaults = { categoryId: string; usageId: string; - dyeable: boolean; - dualDyeable: boolean; + dyeability: Dyeability; patternEditable: boolean; noRecipe: boolean; acquisitionMethodIds: string[]; @@ -100,6 +101,12 @@ const ancientArtifactOptions = computed(() => [ { value: '', label: t('common.no') }, ...(options.value?.ancientArtifactCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? []) ]); +const dyeabilityOptions = computed>(() => [ + { value: 0, label: t('pages.items.notDyeable') }, + { value: 1, label: t('pages.items.dyeable') }, + { value: 2, label: t('pages.items.dualDyeable') }, + { value: 3, label: t('pages.items.tripleDyeable') } +]); const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true); const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === true); @@ -117,13 +124,26 @@ function errorText(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } +function defaultDyeability(value: { dyeability?: unknown; dualDyeable?: unknown; dyeable?: unknown }): Dyeability { + const dyeability = Number(value.dyeability); + if (Number.isInteger(dyeability) && dyeability >= 0 && dyeability <= 3) { + return dyeability as Dyeability; + } + if (value.dualDyeable === true) { + return 2; + } + if (value.dyeable === true) { + return 1; + } + return 0; +} + function readItemCreateDefaults(): ItemCreateDefaults { if (typeof sessionStorage === 'undefined') { return { categoryId: '', usageId: '', - dyeable: false, - dualDyeable: false, + dyeability: 0, patternEditable: false, noRecipe: false, acquisitionMethodIds: [] @@ -136,8 +156,7 @@ function readItemCreateDefaults(): ItemCreateDefaults { return { categoryId: '', usageId: '', - dyeable: false, - dualDyeable: false, + dyeability: 0, patternEditable: false, noRecipe: false, acquisitionMethodIds: [] @@ -148,8 +167,7 @@ function readItemCreateDefaults(): ItemCreateDefaults { return { categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '', usageId: typeof parsedValue.usageId === 'string' ? parsedValue.usageId : '', - dyeable: parsedValue.dyeable === true, - dualDyeable: parsedValue.dualDyeable === true, + dyeability: defaultDyeability(parsedValue), patternEditable: parsedValue.patternEditable === true, noRecipe: parsedValue.noRecipe === true, acquisitionMethodIds: Array.isArray(parsedValue.acquisitionMethodIds) @@ -160,8 +178,7 @@ function readItemCreateDefaults(): ItemCreateDefaults { return { categoryId: '', usageId: '', - dyeable: false, - dualDyeable: false, + dyeability: 0, patternEditable: false, noRecipe: false, acquisitionMethodIds: [] @@ -185,8 +202,7 @@ function applyItemCreateDefaults(isEventItem: boolean) { categoryId: categoryIds.has(defaults.categoryId) ? defaults.categoryId : '', usageId: usageIds.has(defaults.usageId) ? defaults.usageId : '', ancientArtifactCategoryId: isAncientArtifactCreate.value ? String(loadedOptions.ancientArtifactCategories[0]?.id ?? '') : '', - dyeable: defaults.dyeable, - dualDyeable: defaults.dualDyeable, + dyeability: defaults.dyeability, patternEditable: defaults.patternEditable, noRecipe: defaults.noRecipe, isEventItem, @@ -237,8 +253,7 @@ async function loadEditor() { translations: item.translations ?? {}, categoryId: String(item.category.id), usageId: item.usage ? String(item.usage.id) : '', - dyeable: item.customization.dyeable, - dualDyeable: item.customization.dualDyeable, + dyeability: defaultDyeability(item.customization), patternEditable: item.customization.patternEditable, noRecipe: item.noRecipe, isEventItem: item.isEventItem, @@ -293,8 +308,7 @@ async function saveItem() { translations: itemForm.value.translations, categoryId: Number(itemForm.value.categoryId), usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null, - dyeable: itemForm.value.dyeable, - dualDyeable: itemForm.value.dualDyeable, + dyeability: itemForm.value.dyeability, patternEditable: itemForm.value.patternEditable, noRecipe: itemForm.value.noRecipe, isEventItem: itemForm.value.isEventItem, @@ -418,9 +432,19 @@ onMounted(() => { +
+
+ {{ t('pages.items.dyeability') }} +
+ +
+
+
+
- - @@ -492,46 +516,6 @@ onMounted(() => { min-width: 0; } -.radio-group { - display: grid; - gap: 7px; - min-width: 0; - min-inline-size: 0; - margin: 0; - padding: 0; - border: 0; -} - -.radio-group legend { - padding: 0; - color: var(--ink-soft); - font-size: 14px; - font-weight: 850; -} - -.radio-group__options { - display: flex; - flex-wrap: wrap; - gap: 8px 12px; - align-items: center; -} - -.radio-group__option { - display: inline-flex; - align-items: center; - gap: 7px; - min-height: 36px; - color: var(--ink-soft); - font-weight: 850; - cursor: pointer; -} - -.radio-group__option input { - width: 18px; - height: 18px; - accent-color: var(--pokemon-blue); -} - @media (max-width: 720px) { .item-edit-row--name-price, .item-edit-row--category-usage { diff --git a/frontend/src/views/ItemsList.vue b/frontend/src/views/ItemsList.vue index 573ba50..7d355d8 100644 --- a/frontend/src/views/ItemsList.vue +++ b/frontend/src/views/ItemsList.vue @@ -48,11 +48,12 @@ const suppressNextItemClick = ref(false); const dragSourceItems = ref([]); const dropCommitted = ref(false); +type Dyeability = 0 | 1 | 2 | 3; + type ItemCreateDefaults = { categoryId: string; usageId: string; - dyeable: boolean; - dualDyeable: boolean; + dyeability: Dyeability; patternEditable: boolean; noRecipe: boolean; acquisitionMethodIds: string[]; @@ -63,8 +64,7 @@ const itemCreateDefaultsStorageKey = 'pokopia_item_create_defaults'; const emptyItemCreateDefaults = (): ItemCreateDefaults => ({ categoryId: '', usageId: '', - dyeable: false, - dualDyeable: false, + dyeability: 0, patternEditable: false, noRecipe: false, acquisitionMethodIds: [] @@ -96,6 +96,12 @@ const categoryTabs = computed(() => [ { value: '', label: t('common.all') }, ...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? []) ]); +const dyeabilityOptions = computed>(() => [ + { value: 0, label: t('pages.items.notDyeable') }, + { value: 1, label: t('pages.items.dyeable') }, + { value: 2, label: t('pages.items.dualDyeable') }, + { value: 3, label: t('pages.items.tripleDyeable') } +]); const itemQuery = computed(() => ({ search: search.value, @@ -156,8 +162,7 @@ const hasItemCreateDefaults = computed( () => itemCreateDefaults.value.categoryId !== '' || itemCreateDefaults.value.usageId !== '' || - itemCreateDefaults.value.dyeable || - itemCreateDefaults.value.dualDyeable || + itemCreateDefaults.value.dyeability !== 0 || itemCreateDefaults.value.patternEditable || itemCreateDefaults.value.noRecipe || itemCreateDefaults.value.acquisitionMethodIds.length > 0 @@ -218,6 +223,20 @@ function menuPositionForEvent(event: MouseEvent | KeyboardEvent) { return clampMenuPosition(window.innerWidth / 2, window.innerHeight / 2); } +function defaultDyeability(value: { dyeability?: unknown; dualDyeable?: unknown; dyeable?: unknown }): Dyeability { + const dyeability = Number(value.dyeability); + if (Number.isInteger(dyeability) && dyeability >= 0 && dyeability <= 3) { + return dyeability as Dyeability; + } + if (value.dualDyeable === true) { + return 2; + } + if (value.dyeable === true) { + return 1; + } + return 0; +} + function readItemCreateDefaults(): ItemCreateDefaults { if (typeof sessionStorage === 'undefined') { return emptyItemCreateDefaults(); @@ -233,8 +252,7 @@ function readItemCreateDefaults(): ItemCreateDefaults { return { categoryId: typeof parsedValue.categoryId === 'string' ? parsedValue.categoryId : '', usageId: typeof parsedValue.usageId === 'string' ? parsedValue.usageId : '', - dyeable: parsedValue.dyeable === true, - dualDyeable: parsedValue.dualDyeable === true, + dyeability: defaultDyeability(parsedValue), patternEditable: parsedValue.patternEditable === true, noRecipe: parsedValue.noRecipe === true, acquisitionMethodIds: Array.isArray(parsedValue.acquisitionMethodIds) @@ -638,9 +656,17 @@ watch(itemSortingAllowed, (allowed) => { />
+
+ {{ t('pages.items.dyeability') }} +
+ +
+
+
- -
diff --git a/system-wordings.ts b/system-wordings.ts index 2f4c3d9..da22f14 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -708,8 +708,11 @@ export const systemWordingMessages = { tags: 'Tags', acquisitionMethods: 'Acquisition methods', customization: 'Customization', + dyeability: 'Dyeability', + notDyeable: 'Not dyeable', dyeable: 'Dyeable', dualDyeable: 'Dual dyeable', + tripleDyeable: 'Triple dyeable', patternEditable: 'Pattern editable', noRecipe: 'No recipe', eventItem: 'Event item', @@ -2075,8 +2078,11 @@ export const systemWordingMessages = { tags: '标签', acquisitionMethods: '入手方式', customization: '自定义', + dyeability: '染色能力', + notDyeable: '不可染色', dyeable: '可染色', dualDyeable: '可双区染色', + tripleDyeable: '可三区染色', patternEditable: '可改花纹', noRecipe: '无材料单', eventItem: '活动物品',