diff --git a/DESIGN.md b/DESIGN.md index 6a3338b..a0b903b 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -232,7 +232,7 @@ - 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。 - Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联 / Trading 观察,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。 - Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。 - - Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项、Pokemon 掉落关联和 Trading 观察。 + - Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、染色预览、栖息地配方项、Pokemon 掉落关联和 Trading 观察。 - Wipe Ancient Artifacts 会清空物品上的 Ancient Artifact 分类并删除对应 Ancient Artifact 讨论;物品本身仍保留在 Items / Event Items 中。 - Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。 - Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。 @@ -240,7 +240,7 @@ - Export 行为: - 导出为版本化 JSON bundle,包含 `version`、`exportedAt`、`scopes` 和对应范围数据。 - JSON bundle 用于系统导入,不作为前台展示内容。 - - 导出包含所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。 + - 导出包含所选范围的主数据、关联数据、物品染色预览、实体翻译、编辑历史、图片上传记录和实体讨论评论。 - 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落、Trading 观察和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。 - JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。 - Import 行为: @@ -668,12 +668,13 @@ Pokemon 详情页展示: - Food - 入手方式:可多选 - 客制化: - - 染色能力:`dyeability`,使用互斥枚举值维护: + - 染色能力:`dyeability` 表示物品包含多少个可独立染色部位,使用互斥枚举值维护: - `0`:不可染色 - - `1`:可染色 - - `2`:可双区染色 - - `3`:可三区染色 + - `1`:1 个可独立染色部位 + - `2`:2 个可独立染色部位 + - `3`:3 个可独立染色部位 - 可改花纹 +- 染色预览:当 `dyeability > 0` 时,物品可为每个染色部位维护不同颜色的预览图片;每条预览记录包含部位序号、颜色名称(例如 `None`、`Red`、`Blue`)和预览图片路径。部位序号必须在 `1..dyeability` 范围内;同一物品的同一部位同一颜色只能有一张预览图。 - 无材料单:`no_recipe` - 标签:使用喜欢的东西配置,可多选 - 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录 @@ -708,6 +709,7 @@ Items 与 Event Items 使用相同数据模型: - 基本信息 - 当前图标图片;未配置图标时展示默认物品标记占位符 - 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图 +- 染色预览:若已维护预览图,按染色部位分组展示各颜色预览,用户可查看每个独立染色部位在不同颜色下的效果 - 介绍 - Base Price - Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示 @@ -1232,7 +1234,7 @@ API 暴露边界: - `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回全部栖息地以兼容管理端和实体选择器。 - `GET /api/habitats/:id` - `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端、实体选择器和排序。 -- `GET /api/items/:id` +- `GET /api/items/:id`:返回物品详情、材料单关联、相关内容、编辑历史、图片历史和染色预览;染色预览只包含公开展示所需的部位序号、颜色名称和图片 URL / 路径。 - `GET /api/ancient-artifacts`:支持 `search`、`categoryId` 和 `tagIds` 筛选;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。 - `GET /api/ancient-artifacts/:id` - `GET /api/recipes`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 960d773..cb2cfea 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -1218,6 +1218,22 @@ CREATE TABLE IF NOT EXISTS items ( CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road', 'food')) ); +CREATE TABLE IF NOT EXISTS item_dye_previews ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE, + part_index integer NOT NULL CHECK (part_index BETWEEN 1 AND 3), + color_name text NOT NULL, + image_path text NOT NULL, + sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), + CHECK (length(color_name) BETWEEN 1 AND 80) +); + +CREATE UNIQUE INDEX IF NOT EXISTS item_dye_previews_item_part_color_idx + ON item_dye_previews(item_id, part_index, lower(color_name)); + +CREATE INDEX IF NOT EXISTS item_dye_previews_item_order_idx + ON item_dye_previews(item_id, part_index, sort_order, id); + CREATE TABLE IF NOT EXISTS recipes ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, item_id integer NOT NULL UNIQUE REFERENCES items(id), @@ -1557,6 +1573,22 @@ ALTER TABLE skills ALTER TABLE items ADD COLUMN IF NOT EXISTS dyeability integer NOT NULL DEFAULT 0 CHECK (dyeability IN (0, 1, 2, 3)); +CREATE TABLE IF NOT EXISTS item_dye_previews ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE, + part_index integer NOT NULL CHECK (part_index BETWEEN 1 AND 3), + color_name text NOT NULL, + image_path text NOT NULL, + sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), + CHECK (length(color_name) BETWEEN 1 AND 80) +); + +CREATE UNIQUE INDEX IF NOT EXISTS item_dye_previews_item_part_color_idx + ON item_dye_previews(item_id, part_index, lower(color_name)); + +CREATE INDEX IF NOT EXISTS item_dye_previews_item_order_idx + ON item_dye_previews(item_id, part_index, sort_order, id); + ALTER TABLE environments ADD COLUMN IF NOT EXISTS description text NOT NULL DEFAULT ''; diff --git a/backend/src/queries.ts b/backend/src/queries.ts index c6600f0..ef57206 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -284,10 +284,18 @@ type ItemPayload = { acquisitionMethodIds: number[]; tagIds: number[]; imagePath: string; + dyePreviews: ItemDyePreviewPayload[]; insertBeforeItemId: number | null; insertAfterItemId: number | null; }; +type ItemDyePreviewPayload = { + partIndex: number; + colorName: string; + imagePath: string; + sortOrder: number; +}; + type AncientArtifactPayload = { name: string; details: string; @@ -620,6 +628,7 @@ type ItemChangeSource = { noRecipe: boolean; acquisitionMethods: Array<{ name: string }>; tags: Array<{ name: string }>; + dyePreviews: Array<{ partIndex: number; colorName: string; image: EntityImageValue | null }>; } & TranslationChangeSource; type AncientArtifactChangeSource = { name: string; @@ -2595,6 +2604,7 @@ async function itemEditChanges( pushChange(changes, 'No recipe', boolValue(before.noRecipe), boolValue(after.noRecipe)); pushChange(changes, 'Acquisition methods', namedListValue(before.acquisitionMethods), namesFromIds(after.acquisitionMethodIds, methodNames)); pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames)); + pushChange(changes, 'Dye previews', dyePreviewListValue(before.dyePreviews), dyePreviewListValue(after.dyePreviews)); return changes; } @@ -6835,6 +6845,7 @@ export async function getItem(id: number, locale = defaultLocale) { droppedByPokemon, allPossibleTags, possibleTagObservations, + dyePreviews, editHistory, imageHistory ] = await Promise.all([ @@ -7008,12 +7019,39 @@ export async function getItem(id: number, locale = defaultLocale) { [id] ) : Promise.resolve([]), + listItemDyePreviews(id), getEditHistory('items', id), listEntityImageUploads('items', id) ]); const possibleTags = inferItemPossibleTags(allPossibleTags, possibleTagObservations); - return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, possibleTags, editHistory, imageHistory }; + return { + ...item, + acquisitionMethods, + recipe, + relatedRecipes, + relatedHabitats, + droppedByPokemon, + possibleTags, + dyePreviews, + editHistory, + imageHistory + }; +} + +async function listItemDyePreviews(itemId: number) { + return query( + ` + SELECT + idp.part_index AS "partIndex", + idp.color_name AS "colorName", + ${uploadedImageJson('idp.image_path')} AS image + FROM item_dye_previews idp + WHERE idp.item_id = $1 + ORDER BY idp.part_index, idp.sort_order, idp.id + `, + [itemId] + ); } function cleanItemPayload(payload: Record): ItemPayload { @@ -7035,6 +7073,8 @@ function cleanItemPayload(payload: Record): ItemPayload { throw validationError('server.validation.invalidField'); } + const dyeability = cleanDyeability(payload); + return { name: cleanName(payload.name, 'server.validation.itemNameRequired'), details: cleanOptionalText(payload.details), @@ -7046,13 +7086,14 @@ function cleanItemPayload(payload: Record): ItemPayload { categoryKey: category.key, usageId, usageKey: usage?.key ?? null, - dyeability: cleanDyeability(payload), + dyeability, patternEditable: Boolean(payload.patternEditable), noRecipe: Boolean(payload.noRecipe), isEventItem: Boolean(payload.isEventItem), acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds), tagIds: cleanIds(payload.tagIds), imagePath: cleanItemOrArtifactImagePath(payload.imagePath), + dyePreviews: cleanItemDyePreviews(payload.dyePreviews, dyeability), insertBeforeItemId, insertAfterItemId }; @@ -7085,6 +7126,40 @@ function cleanDyeability(payload: Record): number { return dyeability; } +function cleanItemDyePreviews(value: unknown, dyeability: number): ItemDyePreviewPayload[] { + if (value === undefined || value === null) { + return []; + } + if (!Array.isArray(value)) { + throw validationError('server.validation.invalidField'); + } + + const seen = new Set(); + return value.map((entry, index) => { + if (!entry || typeof entry !== 'object') { + throw validationError('server.validation.invalidField'); + } + + const row = entry as Record; + const partIndex = requirePositiveInteger(row.partIndex, 'server.validation.invalidField'); + const colorName = cleanName(row.colorName, 'server.validation.invalidField'); + const imagePath = cleanItemOrArtifactImagePath(row.imagePath); + const key = `${partIndex}:${colorName.toLowerCase()}`; + + if (partIndex > dyeability || partIndex > 3 || colorName.length > 80 || imagePath === '' || seen.has(key)) { + throw validationError('server.validation.invalidField'); + } + + seen.add(key); + return { + partIndex, + colorName, + imagePath, + sortOrder: index * 10 + }; + }); +} + function dyeabilityValue(value: number): string { if (value === 3) { return 'Triple dyeable'; @@ -7098,6 +7173,15 @@ function dyeabilityValue(value: number): string { return 'Not dyeable'; } +function dyePreviewListValue(previews: Array<{ partIndex: number; colorName: string; imagePath?: string; image?: EntityImageValue | null }>): string { + return previews + .map((preview) => { + const imagePath = preview.imagePath ?? preview.image?.path ?? ''; + return `Part ${preview.partIndex} ${preview.colorName}: ${imagePathLabel(imagePath)}`; + }) + .join(', '); +} + 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', @@ -7136,6 +7220,20 @@ async function replaceItemRelations(client: DbClient, itemId: number, payload: I } } +async function replaceItemDyePreviews(client: DbClient, itemId: number, previews: ItemDyePreviewPayload[]): Promise { + await client.query('DELETE FROM item_dye_previews WHERE item_id = $1', [itemId]); + + for (const preview of previews) { + await client.query( + ` + INSERT INTO item_dye_previews (item_id, part_index, color_name, image_path, sort_order) + VALUES ($1, $2, $3, $4, $5) + `, + [itemId, preview.partIndex, preview.colorName, preview.imagePath, preview.sortOrder] + ); + } +} + export async function createItem(payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanItemPayload(payload); @@ -7185,6 +7283,7 @@ export async function createItem(payload: Record, userId: numbe const itemId = result.rows[0].id; await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name); await replaceItemRelations(client, itemId, cleanPayload); + await replaceItemDyePreviews(client, itemId, cleanPayload.dyePreviews); await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name', 'details']); if (cleanPayload.insertBeforeItemId !== null || cleanPayload.insertAfterItemId !== null) { @@ -7263,6 +7362,7 @@ export async function updateItem(id: number, payload: Record, u } await linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name); await replaceItemRelations(client, id, cleanPayload); + await replaceItemDyePreviews(client, id, cleanPayload.dyePreviews); await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name', 'details']); const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : []; await recordEditLog(client, 'items', id, 'update', userId, changes); @@ -8207,6 +8307,7 @@ const dataToolColumns = { 'created_at', 'updated_at' ], + itemDyePreviews: ['id', 'item_id', 'part_index', 'color_name', 'image_path', 'sort_order'], itemAcquisitionMethods: ['item_id', 'acquisition_method_id'], itemFavoriteThings: ['item_id', 'favorite_thing_id'], recipes: ['id', 'item_id', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'], @@ -8517,6 +8618,7 @@ async function resetDataToolIdentities(client: DbClient): Promise { for (const tableName of [ 'daily_checklist_items', 'items', + 'item_dye_previews', 'recipes', 'habitats', 'wiki_edit_logs', @@ -8673,6 +8775,7 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise< if (scope === 'items') { return { items: await tableRows(client, 'SELECT * FROM items ORDER BY sort_order, id'), + itemDyePreviews: await tableRows(client, 'SELECT * FROM item_dye_previews ORDER BY item_id, part_index, sort_order, id'), itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'), itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'), pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'), @@ -8685,6 +8788,16 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise< if (scope === 'artifacts') { return { artifacts: await tableRows(client, 'SELECT * FROM items WHERE ancient_artifact_category_key IS NOT NULL ORDER BY sort_order, id'), + itemDyePreviews: await tableRows( + client, + ` + SELECT idp.* + FROM item_dye_previews idp + JOIN items i ON i.id = idp.item_id + WHERE i.ancient_artifact_category_key IS NOT NULL + ORDER BY idp.item_id, idp.part_index, idp.sort_order, idp.id + ` + ), itemFavoriteThings: await tableRows( client, ` @@ -8803,7 +8916,9 @@ async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle await insertRows(client, 'item_acquisition_methods', dataToolColumns.itemAcquisitionMethods, dataToolTableRows(itemData, 'itemAcquisitionMethods')); await insertRows(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(itemData, 'itemFavoriteThings')); + await insertRows(client, 'item_dye_previews', dataToolColumns.itemDyePreviews, dataToolTableRows(itemData, 'itemDyePreviews')); await insertRowsIgnoreConflicts(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(artifactData, 'itemFavoriteThings')); + await insertRowsIgnoreConflicts(client, 'item_dye_previews', dataToolColumns.itemDyePreviews, dataToolTableRows(artifactData, 'itemDyePreviews')); await insertRows(client, 'pokemon_pokemon_types', dataToolColumns.pokemonTypeLinks, dataToolTableRows(pokemonData, 'pokemonTypeLinks')); await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills')); await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings')); diff --git a/frontend/src/components/EditHistoryPanel.vue b/frontend/src/components/EditHistoryPanel.vue index c75dad5..13fa72c 100644 --- a/frontend/src/components/EditHistoryPanel.vue +++ b/frontend/src/components/EditHistoryPanel.vue @@ -63,6 +63,8 @@ const changeLabelKeys: Record = { 可双区染色: 'pages.items.dualDyeable', 'Triple dyeable': 'pages.items.tripleDyeable', 可三区染色: 'pages.items.tripleDyeable', + 'Dye previews': 'pages.items.dyePreviews', + 染色预览: 'pages.items.dyePreviews', 'Pattern editable': 'pages.items.patternEditable', 可改花纹: 'pages.items.patternEditable', 'No recipe': 'pages.items.noRecipe', diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 38a811c..53a3e03 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -360,6 +360,12 @@ export interface Item extends EditInfo { recipe: RecipeSummary | null; } +export interface ItemDyePreview { + partIndex: number; + colorName: string; + image: EntityImage; +} + export interface AncientArtifact extends EditInfo { id: number; name: string; @@ -383,6 +389,7 @@ export interface ItemDetail extends Item { relatedRecipes: RecipeUsage[]; relatedHabitats: HabitatUsage[]; possibleTags: ItemPossibleTags; + dyePreviews: ItemDyePreview[]; editHistory: EditHistoryEntry[]; imageHistory: EntityImageUpload[]; droppedByPokemon: Array<{ @@ -1025,6 +1032,7 @@ export interface ItemPayload { acquisitionMethodIds: number[]; tagIds: number[]; imagePath: string; + dyePreviews: Array<{ partIndex: number; colorName: string; imagePath: string }>; insertBeforeItemId?: number | null; insertAfterItemId?: number | null; } diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index a3fc637..3c4abb3 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -68,6 +68,21 @@ const possibleTagSections = computed(() => [ { key: 'possible', title: t('pages.items.possibleTagsPossible'), tags: item.value?.possibleTags?.possible ?? [] }, { key: 'excluded', title: t('pages.items.excludedTags'), tags: item.value?.possibleTags?.excluded ?? [] } ]); +const dyePreviewGroups = computed(() => { + const groups = new Map>(); + + for (const preview of item.value?.dyePreviews ?? []) { + groups.set(preview.partIndex, [...(groups.get(preview.partIndex) ?? []), preview]); + } + + return [...groups.entries()] + .sort(([leftPart], [rightPart]) => leftPart - rightPart) + .map(([partIndex, previews]) => ({ + partIndex, + title: t('pages.items.dyePartLabel', { number: partIndex }), + previews + })); +}); const possibleTagEvidenceSections = computed(() => [ { key: 'likes', title: t('pages.pokemon.tradingLikes'), rows: item.value?.possibleTags?.evidence.likes ?? [] }, { key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] } @@ -484,6 +499,20 @@ watch(initialItem, applyInitialItem, { immediate: true });

{{ t('common.none') }}

+ + +
+
+

{{ group.title }}

+
+
+ +
{{ preview.colorName }}
+
+
+
+
+
@@ -499,3 +528,45 @@ watch(initialItem, applyInitialItem, { immediate: true }); + + diff --git a/frontend/src/views/ItemEdit.vue b/frontend/src/views/ItemEdit.vue index b51c19e..6ce9895 100644 --- a/frontend/src/views/ItemEdit.vue +++ b/frontend/src/views/ItemEdit.vue @@ -1,6 +1,6 @@