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:
16
DESIGN.md
16
DESIGN.md
@@ -232,7 +232,7 @@
|
|||||||
- 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
|
- 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
|
||||||
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联 / Trading 观察,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
|
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联 / Trading 观察,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
|
||||||
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
|
- 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 Ancient Artifacts 会清空物品上的 Ancient Artifact 分类并删除对应 Ancient Artifact 讨论;物品本身仍保留在 Items / Event Items 中。
|
||||||
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
|
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
|
||||||
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
|
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
|
||||||
@@ -240,7 +240,7 @@
|
|||||||
- Export 行为:
|
- Export 行为:
|
||||||
- 导出为版本化 JSON bundle,包含 `version`、`exportedAt`、`scopes` 和对应范围数据。
|
- 导出为版本化 JSON bundle,包含 `version`、`exportedAt`、`scopes` 和对应范围数据。
|
||||||
- JSON bundle 用于系统导入,不作为前台展示内容。
|
- JSON bundle 用于系统导入,不作为前台展示内容。
|
||||||
- 导出包含所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
|
- 导出包含所选范围的主数据、关联数据、物品染色预览、实体翻译、编辑历史、图片上传记录和实体讨论评论。
|
||||||
- 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落、Trading 观察和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。
|
- 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落、Trading 观察和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。
|
||||||
- JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。
|
- JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。
|
||||||
- Import 行为:
|
- Import 行为:
|
||||||
@@ -668,12 +668,13 @@ Pokemon 详情页展示:
|
|||||||
- Food
|
- Food
|
||||||
- 入手方式:可多选
|
- 入手方式:可多选
|
||||||
- 客制化:
|
- 客制化:
|
||||||
- 染色能力:`dyeability`,使用互斥枚举值维护:
|
- 染色能力:`dyeability` 表示物品包含多少个可独立染色部位,使用互斥枚举值维护:
|
||||||
- `0`:不可染色
|
- `0`:不可染色
|
||||||
- `1`:可染色
|
- `1`:1 个可独立染色部位
|
||||||
- `2`:可双区染色
|
- `2`:2 个可独立染色部位
|
||||||
- `3`:可三区染色
|
- `3`:3 个可独立染色部位
|
||||||
- 可改花纹
|
- 可改花纹
|
||||||
|
- 染色预览:当 `dyeability > 0` 时,物品可为每个染色部位维护不同颜色的预览图片;每条预览记录包含部位序号、颜色名称(例如 `None`、`Red`、`Blue`)和预览图片路径。部位序号必须在 `1..dyeability` 范围内;同一物品的同一部位同一颜色只能有一张预览图。
|
||||||
- 无材料单:`no_recipe`
|
- 无材料单:`no_recipe`
|
||||||
- 标签:使用喜欢的东西配置,可多选
|
- 标签:使用喜欢的东西配置,可多选
|
||||||
- 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录
|
- 图标图片:通过通用 Wiki 图片上传维护当前图标和历史上传记录
|
||||||
@@ -708,6 +709,7 @@ Items 与 Event Items 使用相同数据模型:
|
|||||||
- 基本信息
|
- 基本信息
|
||||||
- 当前图标图片;未配置图标时展示默认物品标记占位符
|
- 当前图标图片;未配置图标时展示默认物品标记占位符
|
||||||
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
|
- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图
|
||||||
|
- 染色预览:若已维护预览图,按染色部位分组展示各颜色预览,用户可查看每个独立染色部位在不同颜色下的效果
|
||||||
- 介绍
|
- 介绍
|
||||||
- Base Price
|
- Base Price
|
||||||
- Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示
|
- Ancient Artifact 分类:仅在物品已配置 Ancient Artifact 分类时展示
|
||||||
@@ -1232,7 +1234,7 @@ API 暴露边界:
|
|||||||
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回全部栖息地以兼容管理端和实体选择器。
|
- `GET /api/habitats`:支持 `isEventItem=true|false` 按普通栖息地 / Event Habitats 拆分列表;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回全部栖息地以兼容管理端和实体选择器。
|
||||||
- `GET /api/habitats/:id`
|
- `GET /api/habitats/:id`
|
||||||
- `GET /api/items`:支持 `isEventItem=true|false` 按普通 Items / Event Items 拆分列表;默认返回所有物品,包括已配置 Ancient Artifact 分类的物品;传入 `ancientArtifactCategoryId` 时可额外筛选对应 Ancient Artifact 分类下的物品;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容管理端、实体选择器和排序。
|
- `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`:支持 `search`、`categoryId` 和 `tagIds` 筛选;公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
|
||||||
- `GET /api/ancient-artifacts/:id`
|
- `GET /api/ancient-artifacts/:id`
|
||||||
- `GET /api/recipes`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
|
- `GET /api/recipes`:公开页面支持 `cursor` / `limit` 分页读取;未传分页参数时返回完整数组以兼容排序。
|
||||||
|
|||||||
@@ -1218,6 +1218,22 @@ CREATE TABLE IF NOT EXISTS items (
|
|||||||
CHECK (usage_key IS NULL OR usage_key IN ('decoration', 'relaxation', 'toy', 'road', 'food'))
|
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 (
|
CREATE TABLE IF NOT EXISTS recipes (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
item_id integer NOT NULL UNIQUE REFERENCES items(id),
|
item_id integer NOT NULL UNIQUE REFERENCES items(id),
|
||||||
@@ -1557,6 +1573,22 @@ ALTER TABLE skills
|
|||||||
ALTER TABLE items
|
ALTER TABLE items
|
||||||
ADD COLUMN IF NOT EXISTS dyeability integer NOT NULL DEFAULT 0 CHECK (dyeability IN (0, 1, 2, 3));
|
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
|
ALTER TABLE environments
|
||||||
ADD COLUMN IF NOT EXISTS description text NOT NULL DEFAULT '';
|
ADD COLUMN IF NOT EXISTS description text NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
|||||||
@@ -284,10 +284,18 @@ type ItemPayload = {
|
|||||||
acquisitionMethodIds: number[];
|
acquisitionMethodIds: number[];
|
||||||
tagIds: number[];
|
tagIds: number[];
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
|
dyePreviews: ItemDyePreviewPayload[];
|
||||||
insertBeforeItemId: number | null;
|
insertBeforeItemId: number | null;
|
||||||
insertAfterItemId: number | null;
|
insertAfterItemId: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ItemDyePreviewPayload = {
|
||||||
|
partIndex: number;
|
||||||
|
colorName: string;
|
||||||
|
imagePath: string;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
type AncientArtifactPayload = {
|
type AncientArtifactPayload = {
|
||||||
name: string;
|
name: string;
|
||||||
details: string;
|
details: string;
|
||||||
@@ -620,6 +628,7 @@ type ItemChangeSource = {
|
|||||||
noRecipe: boolean;
|
noRecipe: boolean;
|
||||||
acquisitionMethods: Array<{ name: string }>;
|
acquisitionMethods: Array<{ name: string }>;
|
||||||
tags: Array<{ name: string }>;
|
tags: Array<{ name: string }>;
|
||||||
|
dyePreviews: Array<{ partIndex: number; colorName: string; image: EntityImageValue | null }>;
|
||||||
} & TranslationChangeSource;
|
} & TranslationChangeSource;
|
||||||
type AncientArtifactChangeSource = {
|
type AncientArtifactChangeSource = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -2595,6 +2604,7 @@ async function itemEditChanges(
|
|||||||
pushChange(changes, 'No recipe', boolValue(before.noRecipe), boolValue(after.noRecipe));
|
pushChange(changes, 'No recipe', boolValue(before.noRecipe), boolValue(after.noRecipe));
|
||||||
pushChange(changes, 'Acquisition methods', namedListValue(before.acquisitionMethods), namesFromIds(after.acquisitionMethodIds, methodNames));
|
pushChange(changes, 'Acquisition methods', namedListValue(before.acquisitionMethods), namesFromIds(after.acquisitionMethodIds, methodNames));
|
||||||
pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));
|
pushChange(changes, 'Tags', namedListValue(before.tags), namesFromIds(after.tagIds, tagNames));
|
||||||
|
pushChange(changes, 'Dye previews', dyePreviewListValue(before.dyePreviews), dyePreviewListValue(after.dyePreviews));
|
||||||
|
|
||||||
return changes;
|
return changes;
|
||||||
}
|
}
|
||||||
@@ -6835,6 +6845,7 @@ export async function getItem(id: number, locale = defaultLocale) {
|
|||||||
droppedByPokemon,
|
droppedByPokemon,
|
||||||
allPossibleTags,
|
allPossibleTags,
|
||||||
possibleTagObservations,
|
possibleTagObservations,
|
||||||
|
dyePreviews,
|
||||||
editHistory,
|
editHistory,
|
||||||
imageHistory
|
imageHistory
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
@@ -7008,12 +7019,39 @@ export async function getItem(id: number, locale = defaultLocale) {
|
|||||||
[id]
|
[id]
|
||||||
)
|
)
|
||||||
: Promise.resolve([]),
|
: Promise.resolve([]),
|
||||||
|
listItemDyePreviews(id),
|
||||||
getEditHistory('items', id),
|
getEditHistory('items', id),
|
||||||
listEntityImageUploads('items', id)
|
listEntityImageUploads('items', id)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const possibleTags = inferItemPossibleTags(allPossibleTags, possibleTagObservations);
|
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 {
|
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
||||||
@@ -7035,6 +7073,8 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
|||||||
throw validationError('server.validation.invalidField');
|
throw validationError('server.validation.invalidField');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dyeability = cleanDyeability(payload);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
|
name: cleanName(payload.name, 'server.validation.itemNameRequired'),
|
||||||
details: cleanOptionalText(payload.details),
|
details: cleanOptionalText(payload.details),
|
||||||
@@ -7046,13 +7086,14 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
|
|||||||
categoryKey: category.key,
|
categoryKey: category.key,
|
||||||
usageId,
|
usageId,
|
||||||
usageKey: usage?.key ?? null,
|
usageKey: usage?.key ?? null,
|
||||||
dyeability: cleanDyeability(payload),
|
dyeability,
|
||||||
patternEditable: Boolean(payload.patternEditable),
|
patternEditable: Boolean(payload.patternEditable),
|
||||||
noRecipe: Boolean(payload.noRecipe),
|
noRecipe: Boolean(payload.noRecipe),
|
||||||
isEventItem: Boolean(payload.isEventItem),
|
isEventItem: Boolean(payload.isEventItem),
|
||||||
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
|
||||||
tagIds: cleanIds(payload.tagIds),
|
tagIds: cleanIds(payload.tagIds),
|
||||||
imagePath: cleanItemOrArtifactImagePath(payload.imagePath),
|
imagePath: cleanItemOrArtifactImagePath(payload.imagePath),
|
||||||
|
dyePreviews: cleanItemDyePreviews(payload.dyePreviews, dyeability),
|
||||||
insertBeforeItemId,
|
insertBeforeItemId,
|
||||||
insertAfterItemId
|
insertAfterItemId
|
||||||
};
|
};
|
||||||
@@ -7085,6 +7126,40 @@ function cleanDyeability(payload: Record<string, unknown>): number {
|
|||||||
return dyeability;
|
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 {
|
function dyeabilityValue(value: number): string {
|
||||||
if (value === 3) {
|
if (value === 3) {
|
||||||
return 'Triple dyeable';
|
return 'Triple dyeable';
|
||||||
@@ -7098,6 +7173,15 @@ function dyeabilityValue(value: number): string {
|
|||||||
return 'Not dyeable';
|
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[]> {
|
async function orderedItemIds(client: DbClient, isEventItem: boolean): Promise<number[]> {
|
||||||
const rows = await client.query<{ id: number }>(
|
const rows = await client.query<{ id: number }>(
|
||||||
'SELECT id FROM items WHERE is_event_item = $1 ORDER BY sort_order, id',
|
'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) {
|
export async function createItem(payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
|
||||||
const cleanPayload = cleanItemPayload(payload);
|
const cleanPayload = cleanItemPayload(payload);
|
||||||
|
|
||||||
@@ -7185,6 +7283,7 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
|
|||||||
const itemId = result.rows[0].id;
|
const itemId = result.rows[0].id;
|
||||||
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
|
await linkEntityImageUpload(client, 'items', itemId, cleanPayload.imagePath, cleanPayload.name);
|
||||||
await replaceItemRelations(client, itemId, cleanPayload);
|
await replaceItemRelations(client, itemId, cleanPayload);
|
||||||
|
await replaceItemDyePreviews(client, itemId, cleanPayload.dyePreviews);
|
||||||
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name', 'details']);
|
await replaceEntityTranslations(client, 'items', itemId, cleanPayload.translations, ['name', 'details']);
|
||||||
|
|
||||||
if (cleanPayload.insertBeforeItemId !== null || cleanPayload.insertAfterItemId !== null) {
|
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 linkEntityImageUpload(client, 'items', id, cleanPayload.imagePath, cleanPayload.name);
|
||||||
await replaceItemRelations(client, id, cleanPayload);
|
await replaceItemRelations(client, id, cleanPayload);
|
||||||
|
await replaceItemDyePreviews(client, id, cleanPayload.dyePreviews);
|
||||||
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name', 'details']);
|
await replaceEntityTranslations(client, 'items', id, cleanPayload.translations, ['name', 'details']);
|
||||||
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
|
const changes = before ? await itemEditChanges(client, before as unknown as ItemChangeSource, cleanPayload) : [];
|
||||||
await recordEditLog(client, 'items', id, 'update', userId, changes);
|
await recordEditLog(client, 'items', id, 'update', userId, changes);
|
||||||
@@ -8207,6 +8307,7 @@ const dataToolColumns = {
|
|||||||
'created_at',
|
'created_at',
|
||||||
'updated_at'
|
'updated_at'
|
||||||
],
|
],
|
||||||
|
itemDyePreviews: ['id', 'item_id', 'part_index', 'color_name', 'image_path', 'sort_order'],
|
||||||
itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
|
itemAcquisitionMethods: ['item_id', 'acquisition_method_id'],
|
||||||
itemFavoriteThings: ['item_id', 'favorite_thing_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'],
|
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 [
|
for (const tableName of [
|
||||||
'daily_checklist_items',
|
'daily_checklist_items',
|
||||||
'items',
|
'items',
|
||||||
|
'item_dye_previews',
|
||||||
'recipes',
|
'recipes',
|
||||||
'habitats',
|
'habitats',
|
||||||
'wiki_edit_logs',
|
'wiki_edit_logs',
|
||||||
@@ -8673,6 +8775,7 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
|
|||||||
if (scope === 'items') {
|
if (scope === 'items') {
|
||||||
return {
|
return {
|
||||||
items: await tableRows(client, 'SELECT * FROM items ORDER BY sort_order, id'),
|
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'),
|
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'),
|
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'),
|
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') {
|
if (scope === 'artifacts') {
|
||||||
return {
|
return {
|
||||||
artifacts: await tableRows(client, 'SELECT * FROM items WHERE ancient_artifact_category_key IS NOT NULL ORDER BY sort_order, id'),
|
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(
|
itemFavoriteThings: await tableRows(
|
||||||
client,
|
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_acquisition_methods', dataToolColumns.itemAcquisitionMethods, dataToolTableRows(itemData, 'itemAcquisitionMethods'));
|
||||||
await insertRows(client, 'item_favorite_things', dataToolColumns.itemFavoriteThings, dataToolTableRows(itemData, 'itemFavoriteThings'));
|
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_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_pokemon_types', dataToolColumns.pokemonTypeLinks, dataToolTableRows(pokemonData, 'pokemonTypeLinks'));
|
||||||
await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
|
await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
|
||||||
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));
|
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ const changeLabelKeys: Record<string, string> = {
|
|||||||
可双区染色: 'pages.items.dualDyeable',
|
可双区染色: 'pages.items.dualDyeable',
|
||||||
'Triple dyeable': 'pages.items.tripleDyeable',
|
'Triple dyeable': 'pages.items.tripleDyeable',
|
||||||
可三区染色: 'pages.items.tripleDyeable',
|
可三区染色: 'pages.items.tripleDyeable',
|
||||||
|
'Dye previews': 'pages.items.dyePreviews',
|
||||||
|
染色预览: 'pages.items.dyePreviews',
|
||||||
'Pattern editable': 'pages.items.patternEditable',
|
'Pattern editable': 'pages.items.patternEditable',
|
||||||
可改花纹: 'pages.items.patternEditable',
|
可改花纹: 'pages.items.patternEditable',
|
||||||
'No recipe': 'pages.items.noRecipe',
|
'No recipe': 'pages.items.noRecipe',
|
||||||
|
|||||||
@@ -360,6 +360,12 @@ export interface Item extends EditInfo {
|
|||||||
recipe: RecipeSummary | null;
|
recipe: RecipeSummary | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ItemDyePreview {
|
||||||
|
partIndex: number;
|
||||||
|
colorName: string;
|
||||||
|
image: EntityImage;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AncientArtifact extends EditInfo {
|
export interface AncientArtifact extends EditInfo {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -383,6 +389,7 @@ export interface ItemDetail extends Item {
|
|||||||
relatedRecipes: RecipeUsage[];
|
relatedRecipes: RecipeUsage[];
|
||||||
relatedHabitats: HabitatUsage[];
|
relatedHabitats: HabitatUsage[];
|
||||||
possibleTags: ItemPossibleTags;
|
possibleTags: ItemPossibleTags;
|
||||||
|
dyePreviews: ItemDyePreview[];
|
||||||
editHistory: EditHistoryEntry[];
|
editHistory: EditHistoryEntry[];
|
||||||
imageHistory: EntityImageUpload[];
|
imageHistory: EntityImageUpload[];
|
||||||
droppedByPokemon: Array<{
|
droppedByPokemon: Array<{
|
||||||
@@ -1025,6 +1032,7 @@ export interface ItemPayload {
|
|||||||
acquisitionMethodIds: number[];
|
acquisitionMethodIds: number[];
|
||||||
tagIds: number[];
|
tagIds: number[];
|
||||||
imagePath: string;
|
imagePath: string;
|
||||||
|
dyePreviews: Array<{ partIndex: number; colorName: string; imagePath: string }>;
|
||||||
insertBeforeItemId?: number | null;
|
insertBeforeItemId?: number | null;
|
||||||
insertAfterItemId?: number | null;
|
insertAfterItemId?: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,21 @@ const possibleTagSections = computed(() => [
|
|||||||
{ key: 'possible', title: t('pages.items.possibleTagsPossible'), tags: item.value?.possibleTags?.possible ?? [] },
|
{ key: 'possible', title: t('pages.items.possibleTagsPossible'), tags: item.value?.possibleTags?.possible ?? [] },
|
||||||
{ key: 'excluded', title: t('pages.items.excludedTags'), tags: item.value?.possibleTags?.excluded ?? [] }
|
{ 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(() => [
|
const possibleTagEvidenceSections = computed(() => [
|
||||||
{ key: 'likes', title: t('pages.pokemon.tradingLikes'), rows: item.value?.possibleTags?.evidence.likes ?? [] },
|
{ 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 ?? [] }
|
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] }
|
||||||
@@ -484,6 +499,20 @@ watch(initialItem, applyInitialItem, { immediate: true });
|
|||||||
</ul>
|
</ul>
|
||||||
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
<p v-else class="meta-line">{{ t('common.none') }}</p>
|
||||||
</DetailSection>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -499,3 +528,45 @@ watch(initialItem, applyInitialItem, { immediate: true });
|
|||||||
|
|
||||||
<ItemEdit v-if="showEditor" />
|
<ItemEdit v-if="showEditor" />
|
||||||
</template>
|
</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>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Icon } from '@iconify/vue';
|
import { Icon } from '@iconify/vue';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import ImageUploadField from '../components/ImageUploadField.vue';
|
import ImageUploadField from '../components/ImageUploadField.vue';
|
||||||
@@ -9,7 +9,7 @@ import Skeleton from '../components/Skeleton.vue';
|
|||||||
import StatusMessage from '../components/StatusMessage.vue';
|
import StatusMessage from '../components/StatusMessage.vue';
|
||||||
import TagsSelect from '../components/TagsSelect.vue';
|
import TagsSelect from '../components/TagsSelect.vue';
|
||||||
import TranslationFields from '../components/TranslationFields.vue';
|
import TranslationFields from '../components/TranslationFields.vue';
|
||||||
import { iconCancel, iconSave } from '../icons';
|
import { iconAdd, iconCancel, iconSave } from '../icons';
|
||||||
import {
|
import {
|
||||||
api,
|
api,
|
||||||
type AuthUser,
|
type AuthUser,
|
||||||
@@ -36,6 +36,11 @@ const message = ref('');
|
|||||||
const creatingSelect = ref('');
|
const creatingSelect = ref('');
|
||||||
|
|
||||||
type Dyeability = 0 | 1 | 2 | 3;
|
type Dyeability = 0 | 1 | 2 | 3;
|
||||||
|
type ItemDyePreviewDraft = {
|
||||||
|
partIndex: number;
|
||||||
|
colorName: string;
|
||||||
|
imagePath: string;
|
||||||
|
};
|
||||||
|
|
||||||
const itemForm = ref({
|
const itemForm = ref({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -51,7 +56,8 @@ const itemForm = ref({
|
|||||||
isEventItem: false,
|
isEventItem: false,
|
||||||
acquisitionMethodIds: [] as string[],
|
acquisitionMethodIds: [] as string[],
|
||||||
tagIds: [] as string[],
|
tagIds: [] as string[],
|
||||||
imagePath: ''
|
imagePath: '',
|
||||||
|
dyePreviews: [] as ItemDyePreviewDraft[]
|
||||||
});
|
});
|
||||||
|
|
||||||
type ItemCreateDefaults = {
|
type ItemCreateDefaults = {
|
||||||
@@ -107,6 +113,12 @@ const dyeabilityOptions = computed<Array<{ value: Dyeability; label: string }>>(
|
|||||||
{ value: 2, label: t('pages.items.dualDyeable') },
|
{ value: 2, label: t('pages.items.dualDyeable') },
|
||||||
{ value: 3, label: t('pages.items.tripleDyeable') }
|
{ 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 canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
|
||||||
const canUploadImage = computed(() => currentUser.value?.permissions.includes('items.upload') === 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;
|
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 {
|
function defaultDyeability(value: { dyeability?: unknown; dualDyeable?: unknown; dyeable?: unknown }): Dyeability {
|
||||||
const dyeability = Number(value.dyeability);
|
const dyeability = Number(value.dyeability);
|
||||||
if (Number.isInteger(dyeability) && dyeability >= 0 && dyeability <= 3) {
|
if (Number.isInteger(dyeability) && dyeability >= 0 && dyeability <= 3) {
|
||||||
@@ -259,7 +304,12 @@ async function loadEditor() {
|
|||||||
isEventItem: item.isEventItem,
|
isEventItem: item.isEventItem,
|
||||||
acquisitionMethodIds: item.acquisitionMethods.map((method) => String(method.id)),
|
acquisitionMethodIds: item.acquisitionMethods.map((method) => String(method.id)),
|
||||||
tagIds: item.tags.map((tag) => String(tag.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;
|
currentImage.value = item.image;
|
||||||
imageHistory.value = item.imageHistory;
|
imageHistory.value = item.imageHistory;
|
||||||
@@ -314,7 +364,8 @@ async function saveItem() {
|
|||||||
isEventItem: itemForm.value.isEventItem,
|
isEventItem: itemForm.value.isEventItem,
|
||||||
acquisitionMethodIds: toIds(itemForm.value.acquisitionMethodIds),
|
acquisitionMethodIds: toIds(itemForm.value.acquisitionMethodIds),
|
||||||
tagIds: toIds(itemForm.value.tagIds),
|
tagIds: toIds(itemForm.value.tagIds),
|
||||||
imagePath: itemForm.value.imagePath
|
imagePath: itemForm.value.imagePath,
|
||||||
|
dyePreviews: normalizedDyePreviews()
|
||||||
};
|
};
|
||||||
if (!isEditing.value && insertBeforeItemId.value !== null) {
|
if (!isEditing.value && insertBeforeItemId.value !== null) {
|
||||||
payload.insertBeforeItemId = insertBeforeItemId.value;
|
payload.insertBeforeItemId = insertBeforeItemId.value;
|
||||||
@@ -343,6 +394,15 @@ function handleImageUploaded(image: EntityImageUpload) {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadEditor();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -444,6 +504,44 @@ onMounted(() => {
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</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">
|
<div class="check-row">
|
||||||
<label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label>
|
<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>
|
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label>
|
||||||
@@ -516,10 +614,54 @@ onMounted(() => {
|
|||||||
min-width: 0;
|
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) {
|
@media (max-width: 720px) {
|
||||||
.item-edit-row--name-price,
|
.item-edit-row--name-price,
|
||||||
.item-edit-row--category-usage {
|
.item-edit-row--category-usage {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.field-header {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dye-preview-editor__row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -733,6 +733,13 @@ export const systemWordingMessages = {
|
|||||||
dyeable: 'Dyeable',
|
dyeable: 'Dyeable',
|
||||||
dualDyeable: 'Dual dyeable',
|
dualDyeable: 'Dual dyeable',
|
||||||
tripleDyeable: 'Triple 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',
|
patternEditable: 'Pattern editable',
|
||||||
noRecipe: 'No recipe',
|
noRecipe: 'No recipe',
|
||||||
eventItem: 'Event item',
|
eventItem: 'Event item',
|
||||||
@@ -2165,6 +2172,13 @@ export const systemWordingMessages = {
|
|||||||
dyeable: '可染色',
|
dyeable: '可染色',
|
||||||
dualDyeable: '可双区染色',
|
dualDyeable: '可双区染色',
|
||||||
tripleDyeable: '可三区染色',
|
tripleDyeable: '可三区染色',
|
||||||
|
dyePart: '染色部位',
|
||||||
|
dyePartLabel: '部位 {number}',
|
||||||
|
dyeColor: '染色颜色',
|
||||||
|
dyePreviews: '染色预览',
|
||||||
|
addDyePreview: '添加预览',
|
||||||
|
dyePreviewImagePath: '预览图片路径',
|
||||||
|
dyePreviewAlt: '{name} 部位 {part} 染成 {color}',
|
||||||
patternEditable: '可改花纹',
|
patternEditable: '可改花纹',
|
||||||
noRecipe: '无材料单',
|
noRecipe: '无材料单',
|
||||||
eventItem: '活动物品',
|
eventItem: '活动物品',
|
||||||
|
|||||||
Reference in New Issue
Block a user