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:
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user