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);