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

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