feat(items): add dye previews support

Add item_dye_previews table to store color preview images per dyeable part
Update item detail and edit views to support managing dye previews
This commit is contained in:
2026-05-13 17:06:49 +08:00
parent c15905bafd
commit a42c8ef5c8
8 changed files with 400 additions and 14 deletions

View File

@@ -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` 分页读取;未传分页参数时返回完整数组以兼容排序。

View File

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

View File

@@ -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<string, unknown>): ItemPayload {
@@ -7035,6 +7073,8 @@ function cleanItemPayload(payload: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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<string>();
return value.map((entry, index) => {
if (!entry || typeof entry !== 'object') {
throw validationError('server.validation.invalidField');
}
const row = entry as Record<string, unknown>;
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<number[]> {
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<void> {
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<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanItemPayload(payload);
@@ -7185,6 +7283,7 @@ export async function createItem(payload: Record<string, unknown>, 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<string, unknown>, 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<void> {
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'));

View File

@@ -63,6 +63,8 @@ const changeLabelKeys: Record<string, string> = {
可双区染色: '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',

View File

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

View File

@@ -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<number, NonNullable<ItemDetail['dyePreviews']>>();
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 });
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
<DetailSection v-if="dyePreviewGroups.length" :title="t('pages.items.dyePreviews')">
<div class="dye-preview-groups">
<section v-for="group in dyePreviewGroups" :key="group.partIndex" class="dye-preview-group">
<h3 class="section-subtitle">{{ group.title }}</h3>
<div class="dye-preview-grid">
<figure v-for="preview in group.previews" :key="`${preview.partIndex}-${preview.colorName}`" class="dye-preview-card">
<img :src="preview.image.url" :alt="t('pages.items.dyePreviewAlt', { name: item.name, part: group.partIndex, color: preview.colorName })" loading="lazy" />
<figcaption>{{ preview.colorName }}</figcaption>
</figure>
</div>
</section>
</div>
</DetailSection>
</div>
</div>
@@ -499,3 +528,45 @@ watch(initialItem, applyInitialItem, { immediate: true });
<ItemEdit v-if="showEditor" />
</template>
<style scoped>
.dye-preview-groups {
display: grid;
gap: 18px;
}
.dye-preview-group {
display: grid;
gap: 10px;
}
.dye-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
gap: 10px;
}
.dye-preview-card {
display: grid;
gap: 8px;
margin: 0;
min-width: 0;
}
.dye-preview-card img {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
}
.dye-preview-card figcaption {
color: var(--ink-soft);
font-size: 0.86rem;
font-weight: 800;
line-height: 1.25;
overflow-wrap: anywhere;
}
</style>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import ImageUploadField from '../components/ImageUploadField.vue';
@@ -9,7 +9,7 @@ import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave } from '../icons';
import { iconAdd, iconCancel, iconSave } from '../icons';
import {
api,
type AuthUser,
@@ -36,6 +36,11 @@ const message = ref('');
const creatingSelect = ref('');
type Dyeability = 0 | 1 | 2 | 3;
type ItemDyePreviewDraft = {
partIndex: number;
colorName: string;
imagePath: string;
};
const itemForm = ref({
name: '',
@@ -51,7 +56,8 @@ const itemForm = ref({
isEventItem: false,
acquisitionMethodIds: [] as string[],
tagIds: [] as string[],
imagePath: ''
imagePath: '',
dyePreviews: [] as ItemDyePreviewDraft[]
});
type ItemCreateDefaults = {
@@ -107,6 +113,12 @@ const dyeabilityOptions = computed<Array<{ value: Dyeability; label: string }>>(
{ value: 2, label: t('pages.items.dualDyeable') },
{ value: 3, label: t('pages.items.tripleDyeable') }
]);
const dyePartOptions = computed(() =>
Array.from({ length: itemForm.value.dyeability }, (_, index) => ({
value: index + 1,
label: t('pages.items.dyePartLabel', { number: index + 1 })
}))
);
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === true);
@@ -124,6 +136,39 @@ function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
function normalizedDyePreviews() {
return itemForm.value.dyePreviews
.map((preview) => ({
partIndex: Number(preview.partIndex),
colorName: preview.colorName.trim(),
imagePath: preview.imagePath.trim()
}))
.filter(
(preview) =>
Number.isInteger(preview.partIndex) &&
preview.partIndex >= 1 &&
preview.partIndex <= itemForm.value.dyeability &&
preview.colorName !== '' &&
preview.imagePath !== ''
);
}
function addDyePreview() {
if (itemForm.value.dyeability < 1) {
return;
}
itemForm.value.dyePreviews.push({
partIndex: 1,
colorName: '',
imagePath: ''
});
}
function removeDyePreview(index: number) {
itemForm.value.dyePreviews.splice(index, 1);
}
function defaultDyeability(value: { dyeability?: unknown; dualDyeable?: unknown; dyeable?: unknown }): Dyeability {
const dyeability = Number(value.dyeability);
if (Number.isInteger(dyeability) && dyeability >= 0 && dyeability <= 3) {
@@ -259,7 +304,12 @@ async function loadEditor() {
isEventItem: item.isEventItem,
acquisitionMethodIds: item.acquisitionMethods.map((method) => String(method.id)),
tagIds: item.tags.map((tag) => String(tag.id)),
imagePath: item.image?.path ?? ''
imagePath: item.image?.path ?? '',
dyePreviews: (item.dyePreviews ?? []).map((preview) => ({
partIndex: preview.partIndex,
colorName: preview.colorName,
imagePath: preview.image.path
}))
};
currentImage.value = item.image;
imageHistory.value = item.imageHistory;
@@ -314,7 +364,8 @@ async function saveItem() {
isEventItem: itemForm.value.isEventItem,
acquisitionMethodIds: toIds(itemForm.value.acquisitionMethodIds),
tagIds: toIds(itemForm.value.tagIds),
imagePath: itemForm.value.imagePath
imagePath: itemForm.value.imagePath,
dyePreviews: normalizedDyePreviews()
};
if (!isEditing.value && insertBeforeItemId.value !== null) {
payload.insertBeforeItemId = insertBeforeItemId.value;
@@ -343,6 +394,15 @@ function handleImageUploaded(image: EntityImageUpload) {
onMounted(() => {
void loadEditor();
});
watch(
() => itemForm.value.dyeability,
(dyeability) => {
itemForm.value.dyePreviews = itemForm.value.dyePreviews
.filter((preview) => preview.partIndex <= dyeability)
.map((preview) => ({ ...preview, partIndex: Math.max(1, Math.min(preview.partIndex, dyeability || 1)) }));
}
);
</script>
<template>
@@ -444,6 +504,44 @@ onMounted(() => {
</fieldset>
</div>
<div v-if="itemForm.dyeability > 0" class="field dye-preview-editor">
<div class="field-header">
<label>{{ t('pages.items.dyePreviews') }}</label>
<button type="button" class="ui-button ui-button--blue ui-button--small" :disabled="busy" @click="addDyePreview">
<Icon :icon="iconAdd" class="ui-icon" aria-hidden="true" />
{{ t('pages.items.addDyePreview') }}
</button>
</div>
<div v-if="itemForm.dyePreviews.length" class="dye-preview-editor__rows">
<div v-for="(preview, index) in itemForm.dyePreviews" :key="index" class="dye-preview-editor__row">
<div class="field">
<label :for="`item-dye-preview-part-${index}`">{{ t('pages.items.dyePart') }}</label>
<select :id="`item-dye-preview-part-${index}`" v-model.number="preview.partIndex" :disabled="busy">
<option v-for="option in dyePartOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div class="field">
<label :for="`item-dye-preview-color-${index}`">{{ t('pages.items.dyeColor') }}</label>
<input :id="`item-dye-preview-color-${index}`" v-model="preview.colorName" type="text" maxlength="80" required :disabled="busy" />
</div>
<div class="field dye-preview-editor__path">
<label :for="`item-dye-preview-image-${index}`">{{ t('pages.items.dyePreviewImagePath') }}</label>
<input :id="`item-dye-preview-image-${index}`" v-model="preview.imagePath" type="text" required :disabled="busy" />
</div>
<button type="button" class="plain-button dye-preview-editor__remove" :disabled="busy" @click="removeDyePreview(index)">
{{ t('common.delete') }}
</button>
</div>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
<div class="check-row">
<label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label>
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label>
@@ -516,10 +614,54 @@ onMounted(() => {
min-width: 0;
}
.field-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.dye-preview-editor {
gap: 10px;
}
.dye-preview-editor__rows {
display: grid;
gap: 10px;
}
.dye-preview-editor__row {
display: grid;
grid-template-columns: minmax(120px, 160px) minmax(140px, 180px) minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
}
.dye-preview-editor__path {
min-width: 0;
}
.dye-preview-editor__remove {
min-height: 38px;
}
@media (max-width: 720px) {
.item-edit-row--name-price,
.item-edit-row--category-usage {
grid-template-columns: 1fr;
}
.field-header {
align-items: stretch;
flex-direction: column;
}
.dye-preview-editor__row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -733,6 +733,13 @@ export const systemWordingMessages = {
dyeable: 'Dyeable',
dualDyeable: 'Dual dyeable',
tripleDyeable: 'Triple dyeable',
dyePart: 'Dye part',
dyePartLabel: 'Part {number}',
dyeColor: 'Dye color',
dyePreviews: 'Dye previews',
addDyePreview: 'Add preview',
dyePreviewImagePath: 'Preview image path',
dyePreviewAlt: '{name} part {part} dyed {color}',
patternEditable: 'Pattern editable',
noRecipe: 'No recipe',
eventItem: 'Event item',
@@ -2165,6 +2172,13 @@ export const systemWordingMessages = {
dyeable: '可染色',
dualDyeable: '可双区染色',
tripleDyeable: '可三区染色',
dyePart: '染色部位',
dyePartLabel: '部位 {number}',
dyeColor: '染色颜色',
dyePreviews: '染色预览',
addDyePreview: '添加预览',
dyePreviewImagePath: '预览图片路径',
dyePreviewAlt: '{name} 部位 {part} 染成 {color}',
patternEditable: '可改花纹',
noRecipe: '无材料单',
eventItem: '活动物品',