From 45e027615838b8dfbdda002b72cbbcc8752c4567 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Thu, 30 Apr 2026 16:52:59 +0800 Subject: [PATCH] feat: add noRecipe flag to items and revamp recipe list Add noRecipe toggle to item editor to prevent recipe creation Change RecipeList to display items and their recipe status Show recipe details and related recipes directly in ItemDetail --- backend/db/schema.sql | 5 +- backend/src/queries.ts | 97 ++++++++++++++++++++++++---- frontend/src/services/api.ts | 22 ++++++- frontend/src/views/ItemDetail.vue | 18 +++++- frontend/src/views/ItemEdit.vue | 6 ++ frontend/src/views/RecipeEdit.vue | 24 +++++-- frontend/src/views/RecipeList.vue | 102 ++++++++++++++++++++++++------ 7 files changed, 232 insertions(+), 42 deletions(-) diff --git a/backend/db/schema.sql b/backend/db/schema.sql index fba4281..123f225 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -93,10 +93,13 @@ CREATE TABLE IF NOT EXISTS items ( usage_id integer REFERENCES item_usages(id), dyeable boolean NOT NULL DEFAULT false, dual_dyeable boolean NOT NULL DEFAULT false, - pattern_editable boolean NOT NULL DEFAULT false + pattern_editable boolean NOT NULL DEFAULT false, + no_recipe boolean NOT NULL DEFAULT false ); ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL; +ALTER TABLE items ADD COLUMN IF NOT EXISTS no_recipe boolean NOT NULL DEFAULT false; +ALTER TABLE items DROP COLUMN IF EXISTS no_habitat; CREATE TABLE IF NOT EXISTS recipes ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, diff --git a/backend/src/queries.ts b/backend/src/queries.ts index d77f1e5..4e90867 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -49,6 +49,7 @@ type ItemPayload = { dyeable: boolean; dualDyeable: boolean; patternEditable: boolean; + noRecipe: boolean; acquisitionMethodIds: number[]; tagIds: number[]; }; @@ -806,15 +807,35 @@ const itemProjection = ` 'dualDyeable', i.dual_dyeable, 'patternEditable', i.pattern_editable ) AS customization, + i.no_recipe AS "noRecipe", COALESCE(( SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY t.name) FROM item_favorite_things ift JOIN favorite_things t ON t.id = ift.favorite_thing_id WHERE ift.item_id = i.id - ), '[]'::json) AS tags + ), '[]'::json) AS tags, + CASE + WHEN item_recipe.id IS NULL THEN NULL + ELSE json_build_object( + 'id', item_recipe.id, + 'createdAt', item_recipe.created_at, + 'updatedAt', item_recipe.updated_at, + 'createdBy', CASE + WHEN recipe_created_user.id IS NULL THEN NULL + ELSE json_build_object('id', recipe_created_user.id, 'displayName', recipe_created_user.display_name) + END, + 'updatedBy', CASE + WHEN recipe_updated_user.id IS NULL THEN NULL + ELSE json_build_object('id', recipe_updated_user.id, 'displayName', recipe_updated_user.display_name) + END + ) + END AS recipe FROM items i JOIN item_categories c ON c.id = i.category_id LEFT JOIN item_usages u ON u.id = i.usage_id + LEFT JOIN recipes item_recipe ON item_recipe.item_id = i.id + LEFT JOIN users recipe_created_user ON recipe_created_user.id = item_recipe.created_by_user_id + LEFT JOIN users recipe_updated_user ON recipe_updated_user.id = item_recipe.updated_by_user_id ${auditJoins('i', 'item_created_user', 'item_updated_user')} `; @@ -864,7 +885,7 @@ export async function getItem(id: number) { return null; } - const [acquisitionMethods, recipe, relatedHabitats, droppedByPokemon] = await Promise.all([ + const [acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon] = await Promise.all([ query( ` SELECT am.id, am.name @@ -903,10 +924,37 @@ export async function getItem(id: number) { ), query( ` - SELECT h.id, h.name, hri.quantity - FROM habitat_recipe_items hri - JOIN habitats h ON h.id = hri.habitat_id - WHERE hri.item_id = $1 + SELECT + r.id, + result_item.name, + COALESCE(( + SELECT json_agg(json_build_object('id', mi.id, 'name', mi.name, 'quantity', recipe_material.quantity) ORDER BY mi.name) + FROM recipe_materials recipe_material + JOIN items mi ON mi.id = recipe_material.item_id + WHERE recipe_material.recipe_id = r.id + ), '[]'::json) AS materials + FROM recipe_materials used_material + JOIN recipes r ON r.id = used_material.recipe_id + JOIN items result_item ON result_item.id = r.item_id + WHERE used_material.item_id = $1 + ORDER BY result_item.name + `, + [id] + ), + query( + ` + SELECT + h.id, + h.name, + COALESCE(( + SELECT json_agg(json_build_object('id', recipe_item.id, 'name', recipe_item.name, 'quantity', recipe_item_row.quantity) ORDER BY recipe_item.name) + FROM habitat_recipe_items recipe_item_row + JOIN items recipe_item ON recipe_item.id = recipe_item_row.item_id + WHERE recipe_item_row.habitat_id = h.id + ), '[]'::json) AS recipe + FROM habitat_recipe_items used_item + JOIN habitats h ON h.id = used_item.habitat_id + WHERE used_item.item_id = $1 ORDER BY h.name `, [id] @@ -927,7 +975,7 @@ export async function getItem(id: number) { ) ]); - return { ...item, acquisitionMethods, recipe, relatedHabitats, droppedByPokemon }; + return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon }; } function cleanItemPayload(payload: Record): ItemPayload { @@ -942,11 +990,23 @@ function cleanItemPayload(payload: Record): ItemPayload { dyeable: Boolean(payload.dyeable), dualDyeable: Boolean(payload.dualDyeable), patternEditable: Boolean(payload.patternEditable), + noRecipe: Boolean(payload.noRecipe), acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds), tagIds: cleanIds(payload.tagIds) }; } +async function ensureItemCanDisableRecipe(client: DbClient, itemId: number, noRecipe: boolean): Promise { + if (!noRecipe) { + return; + } + + const result = await client.query('SELECT 1 FROM recipes WHERE item_id = $1', [itemId]); + if (result.rowCount && result.rowCount > 0) { + throw validationError('已有材料单的物品不能设置为无材料单'); + } +} + async function replaceItemRelations(client: DbClient, itemId: number, payload: ItemPayload): Promise { await client.query('DELETE FROM item_acquisition_methods WHERE item_id = $1', [itemId]); await client.query('DELETE FROM item_favorite_things WHERE item_id = $1', [itemId]); @@ -979,10 +1039,11 @@ export async function createItem(payload: Record, userId: numbe dyeable, dual_dyeable, pattern_editable, + no_recipe, created_by_user_id, updated_by_user_id ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $7) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) RETURNING id `, [ @@ -992,6 +1053,7 @@ export async function createItem(payload: Record, userId: numbe cleanPayload.dyeable, cleanPayload.dualDyeable, cleanPayload.patternEditable, + cleanPayload.noRecipe, userId ] ); @@ -1007,6 +1069,7 @@ export async function updateItem(id: number, payload: Record, u const cleanPayload = cleanItemPayload(payload); const updated = await withTransaction(async (client) => { + await ensureItemCanDisableRecipe(client, id, cleanPayload.noRecipe); const result = await client.query( ` UPDATE items @@ -1016,9 +1079,10 @@ export async function updateItem(id: number, payload: Record, u dyeable = $4, dual_dyeable = $5, pattern_editable = $6, - updated_by_user_id = $7, + no_recipe = $7, + updated_by_user_id = $8, updated_at = now() - WHERE id = $8 + WHERE id = $9 `, [ cleanPayload.name, @@ -1027,6 +1091,7 @@ export async function updateItem(id: number, payload: Record, u cleanPayload.dyeable, cleanPayload.dualDyeable, cleanPayload.patternEditable, + cleanPayload.noRecipe, userId, id ] @@ -1140,18 +1205,22 @@ async function replaceRecipeRelations(client: DbClient, recipeId: number, payloa } } -async function ensureItemExists(client: DbClient, itemId: number): Promise { - const result = await client.query('SELECT 1 FROM items WHERE id = $1', [itemId]); +async function ensureItemCanHaveRecipe(client: DbClient, itemId: number): Promise { + const result = await client.query<{ no_recipe: boolean }>('SELECT no_recipe FROM items WHERE id = $1', [itemId]); if (result.rowCount === 0) { throw validationError('请选择物品'); } + + if (result.rows[0].no_recipe) { + throw validationError('该物品已设置为无材料单'); + } } export async function createRecipe(payload: Record, userId: number) { const cleanPayload = cleanRecipePayload(payload); const id = await withTransaction(async (client) => { - await ensureItemExists(client, cleanPayload.itemId); + await ensureItemCanHaveRecipe(client, cleanPayload.itemId); const result = await client.query<{ id: number }>( ` INSERT INTO recipes (item_id, created_by_user_id, updated_by_user_id) @@ -1172,7 +1241,7 @@ export async function updateRecipe(id: number, payload: Record, const cleanPayload = cleanRecipePayload(payload); const updated = await withTransaction(async (client) => { - await ensureItemExists(client, cleanPayload.itemId); + await ensureItemCanHaveRecipe(client, cleanPayload.itemId); const result = await client.query( 'UPDATE recipes SET item_id = $1, updated_by_user_id = $2, updated_at = now() WHERE id = $3', [cleanPayload.itemId, userId, id] diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 8cac8c3..2253930 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -59,6 +59,22 @@ export interface HabitatDetail extends Habitat { }>; } +export interface RecipeSummary extends EditInfo { + id: number; +} + +export interface RecipeUsage { + id: number; + name: string; + materials: Array; +} + +export interface HabitatUsage { + id: number; + name: string; + recipe: Array; +} + export interface Item extends EditInfo { id: number; name: string; @@ -69,13 +85,16 @@ export interface Item extends EditInfo { dualDyeable: boolean; patternEditable: boolean; }; + noRecipe: boolean; tags: NamedEntity[]; + recipe: RecipeSummary | null; } export interface ItemDetail extends Item { acquisitionMethods: NamedEntity[]; recipe: RecipeDetail | null; - relatedHabitats: Array; + relatedRecipes: RecipeUsage[]; + relatedHabitats: HabitatUsage[]; droppedByPokemon: Array<{ pokemon: NamedEntity; skill: NamedEntity; @@ -150,6 +169,7 @@ export interface ItemPayload { dyeable: boolean; dualDyeable: boolean; patternEditable: boolean; + noRecipe: boolean; acquisitionMethodIds: number[]; tagIds: number[]; } diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index 550467c..74f3849 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -114,6 +114,22 @@ onMounted(async () => { {{ item.recipe.name }} +

无材料单

+ + + + +
    +
  • + {{ recipe.name }} + +
  • +

@@ -121,7 +137,7 @@ onMounted(async () => {
  • {{ habitat.name }} - × {{ habitat.quantity }} +

diff --git a/frontend/src/views/ItemEdit.vue b/frontend/src/views/ItemEdit.vue index da1300a..6a412cb 100644 --- a/frontend/src/views/ItemEdit.vue +++ b/frontend/src/views/ItemEdit.vue @@ -21,6 +21,7 @@ const itemForm = ref({ dyeable: false, dualDyeable: false, patternEditable: false, + noRecipe: false, acquisitionMethodIds: [] as string[], tagIds: [] as string[] }); @@ -29,6 +30,7 @@ const routeId = computed(() => (typeof route.params.id === 'string' ? route.para const isEditing = computed(() => routeId.value !== ''); const pageTitle = computed(() => (isEditing.value ? `编辑 ${itemForm.value.name || '物品'}` : '新增物品')); const cancelTo = computed(() => (isEditing.value ? `/items/${routeId.value}` : '/items')); +const hasRecipe = ref(false); function toIds(values: string[]): number[] { return values.map(Number).filter((item) => Number.isInteger(item) && item > 0); @@ -57,9 +59,11 @@ async function loadEditor() { dyeable: item.customization.dyeable, dualDyeable: item.customization.dualDyeable, patternEditable: item.customization.patternEditable, + noRecipe: item.noRecipe, acquisitionMethodIds: item.acquisitionMethods.map((method) => String(method.id)), tagIds: item.tags.map((tag) => String(tag.id)) }; + hasRecipe.value = item.recipe !== null; } } catch (error) { message.value = errorText(error, '加载失败'); @@ -117,6 +121,7 @@ async function saveItem() { dyeable: itemForm.value.dyeable, dualDyeable: itemForm.value.dualDyeable, patternEditable: itemForm.value.patternEditable, + noRecipe: itemForm.value.noRecipe, acquisitionMethodIds: toIds(itemForm.value.acquisitionMethodIds), tagIds: toIds(itemForm.value.tagIds) }; @@ -185,6 +190,7 @@ onMounted(() => { +
diff --git a/frontend/src/views/RecipeEdit.vue b/frontend/src/views/RecipeEdit.vue index 121b4b7..8cfb793 100644 --- a/frontend/src/views/RecipeEdit.vue +++ b/frontend/src/views/RecipeEdit.vue @@ -23,8 +23,13 @@ const recipeForm = ref({ const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : '')); const isEditing = computed(() => routeId.value !== ''); -const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name }))); -const selectedItemName = computed(() => itemSelectOptions.value.find((item) => String(item.id) === recipeForm.value.itemId)?.name ?? ''); +const materialItemOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name }))); +const resultItemOptions = computed(() => + itemRows.value + .filter((item) => !item.noRecipe || String(item.id) === recipeForm.value.itemId) + .map((item) => ({ id: item.id, name: item.name })) +); +const selectedItemName = computed(() => resultItemOptions.value.find((item) => String(item.id) === recipeForm.value.itemId)?.name ?? ''); const pageTitle = computed(() => (isEditing.value ? `编辑 ${selectedItemName.value || '材料单'}` : '新增材料单')); const cancelTo = computed(() => (isEditing.value ? `/recipes/${routeId.value}` : '/recipes')); @@ -42,6 +47,15 @@ function errorText(error: unknown, fallback: string) { return error instanceof Error && error.message ? error.message : fallback; } +function preselectedItemId() { + const itemId = route.query.itemId; + if (typeof itemId !== 'string') { + return ''; + } + + return resultItemOptions.value.some((item) => String(item.id) === itemId) ? itemId : ''; +} + async function loadEditor() { loading.value = true; message.value = ''; @@ -58,6 +72,8 @@ async function loadEditor() { acquisitionMethodIds: recipe.acquisition_methods.map((method) => String(method.id)), materials: recipe.materials.map((material) => ({ itemId: String(material.id), quantity: material.quantity })) }; + } else { + recipeForm.value.itemId = preselectedItemId(); } } catch (error) { message.value = errorText(error, '加载失败'); @@ -135,7 +151,7 @@ onMounted(() => { { import { computed, onMounted, ref, watch } from 'vue'; import EditMeta from '../components/EditMeta.vue'; -import EntityChips from '../components/EntityChips.vue'; import EntityCard from '../components/EntityCard.vue'; +import FilterPanel from '../components/FilterPanel.vue'; import PageHeader from '../components/PageHeader.vue'; import Skeleton from '../components/Skeleton.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue'; -import { api, type Options, type Recipe } from '../services/api'; +import TagsSelect from '../components/TagsSelect.vue'; +import { api, type Item, type Options } from '../services/api'; const options = ref(null); -const recipes = ref([]); +const items = ref([]); const loading = ref(true); +const search = ref(''); const categoryId = ref(''); +const usageId = ref(''); +const tagIds = ref([]); const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px']; +const filterSkeletonWidths = ['52px', '48px', '48px']; const skeletonCardCount = 6; const categoryTabs = computed(() => [ @@ -21,27 +26,50 @@ const categoryTabs = computed(() => [ ...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? []) ]); -const recipeQuery = computed(() => ({ - categoryId: categoryId.value +const itemQuery = computed(() => ({ + search: search.value, + categoryId: categoryId.value, + usageId: usageId.value, + tagIds: tagIds.value.join(',') })); -async function loadRecipes() { +function recipeTarget(item: Item) { + return item.recipe ? `/recipes/${item.recipe.id}` : undefined; +} + +function itemSubtitle(item: Item) { + return item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name; +} + +function createRecipeTarget(item: Item) { + return `/recipes/new?itemId=${item.id}`; +} + +function itemMarker(item: Item) { + if (item.recipe) { + return '▦'; + } + + return item.noRecipe ? '×' : '+'; +} + +async function loadItems() { loading.value = true; - recipes.value = await api.recipes(recipeQuery.value); + items.value = await api.items(itemQuery.value); loading.value = false; } onMounted(async () => { options.value = await api.options(); - await loadRecipes(); + await loadItems(); }); -watch(recipeQuery, loadRecipes); +watch(itemQuery, loadItems);