diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 5dd8e6d..2d30436 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -48,23 +48,11 @@ CREATE TABLE IF NOT EXISTS acquisition_methods ( name text NOT NULL UNIQUE ); -CREATE TABLE IF NOT EXISTS recipes ( - id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - name text NOT NULL UNIQUE -); - -CREATE TABLE IF NOT EXISTS recipe_acquisition_methods ( - recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, - acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id), - PRIMARY KEY (recipe_id, acquisition_method_id) -); - CREATE TABLE IF NOT EXISTS items ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, category_id integer NOT NULL REFERENCES item_categories(id), usage_id integer REFERENCES item_usages(id), - recipe_id integer REFERENCES recipes(id), dyeable boolean NOT NULL DEFAULT false, dual_dyeable boolean NOT NULL DEFAULT false, pattern_editable boolean NOT NULL DEFAULT false @@ -72,6 +60,49 @@ CREATE TABLE IF NOT EXISTS items ( ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL; +CREATE TABLE IF NOT EXISTS recipes ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + item_id integer NOT NULL UNIQUE REFERENCES items(id) +); + +ALTER TABLE recipes ADD COLUMN IF NOT EXISTS item_id integer REFERENCES items(id); + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'items' + AND column_name = 'recipe_id' + ) THEN + EXECUTE ' + UPDATE recipes r + SET item_id = linked.item_id + FROM ( + SELECT DISTINCT ON (recipe_id) recipe_id, id AS item_id + FROM items + WHERE recipe_id IS NOT NULL + ORDER BY recipe_id, id + ) linked + WHERE r.id = linked.recipe_id + AND r.item_id IS NULL + '; + END IF; +END $$; + +DELETE FROM recipes WHERE item_id IS NULL; + +ALTER TABLE recipes ALTER COLUMN item_id SET NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS recipes_item_id_key ON recipes(item_id); +ALTER TABLE recipes DROP COLUMN IF EXISTS name; +ALTER TABLE items DROP COLUMN IF EXISTS recipe_id; + +CREATE TABLE IF NOT EXISTS recipe_acquisition_methods ( + recipe_id integer NOT NULL REFERENCES recipes(id) ON DELETE CASCADE, + acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id), + PRIMARY KEY (recipe_id, acquisition_method_id) +); + DROP TABLE IF EXISTS item_item_tags; DROP TABLE IF EXISTS item_tags; diff --git a/backend/src/queries.ts b/backend/src/queries.ts index ed76fb1..a0f67a3 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -41,7 +41,6 @@ type ItemPayload = { name: string; categoryId: number; usageId: number | null; - recipeId: number | null; dyeable: boolean; dualDyeable: boolean; patternEditable: boolean; @@ -50,7 +49,7 @@ type ItemPayload = { }; type RecipePayload = { - name: string; + itemId: number; acquisitionMethodIds: number[]; materials: IdQuantity[]; }; @@ -649,7 +648,7 @@ export async function getItem(id: number) { ` SELECT r.id, - r.name, + result_item.name, COALESCE(( SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name) FROM recipe_acquisition_methods ram @@ -661,10 +660,11 @@ export async function getItem(id: number) { FROM recipe_materials rm JOIN items mi ON mi.id = rm.item_id WHERE rm.recipe_id = r.id - ), '[]'::json) AS materials - FROM items i - JOIN recipes r ON r.id = i.recipe_id - WHERE i.id = $1 + ), '[]'::json) AS materials, + json_build_object('id', result_item.id, 'name', result_item.name) AS item + FROM recipes r + JOIN items result_item ON result_item.id = r.item_id + WHERE r.item_id = $1 `, [id] ), @@ -684,9 +684,6 @@ export async function getItem(id: number) { } function cleanItemPayload(payload: Record): ItemPayload { - const recipeId = payload.recipeId === null || payload.recipeId === '' || payload.recipeId === undefined - ? null - : requirePositiveInteger(payload.recipeId, '请选择材料单'); const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined ? null : requirePositiveInteger(payload.usageId, '请选择用途'); @@ -695,7 +692,6 @@ function cleanItemPayload(payload: Record): ItemPayload { name: cleanName(payload.name, '请输入物品名字'), categoryId: requirePositiveInteger(payload.categoryId, '请选择分类'), usageId, - recipeId, dyeable: Boolean(payload.dyeable), dualDyeable: Boolean(payload.dualDyeable), patternEditable: Boolean(payload.patternEditable), @@ -729,15 +725,14 @@ export async function createItem(payload: Record) { const id = await withTransaction(async (client) => { const result = await client.query<{ id: number }>( ` - INSERT INTO items (name, category_id, usage_id, recipe_id, dyeable, dual_dyeable, pattern_editable) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO items (name, category_id, usage_id, dyeable, dual_dyeable, pattern_editable) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id `, [ cleanPayload.name, cleanPayload.categoryId, cleanPayload.usageId, - cleanPayload.recipeId, cleanPayload.dyeable, cleanPayload.dualDyeable, cleanPayload.patternEditable @@ -760,17 +755,15 @@ export async function updateItem(id: number, payload: Record) { SET name = $1, category_id = $2, usage_id = $3, - recipe_id = $4, - dyeable = $5, - dual_dyeable = $6, - pattern_editable = $7 - WHERE id = $8 + dyeable = $4, + dual_dyeable = $5, + pattern_editable = $6 + WHERE id = $7 `, [ cleanPayload.name, cleanPayload.categoryId, cleanPayload.usageId, - cleanPayload.recipeId, cleanPayload.dyeable, cleanPayload.dualDyeable, cleanPayload.patternEditable, @@ -795,7 +788,7 @@ export async function listRecipes() { return query(` SELECT r.id, - r.name, + result_item.name, COALESCE(( SELECT json_agg(json_build_object('id', i.id, 'name', i.name, 'quantity', rm.quantity) ORDER BY i.name) FROM recipe_materials rm @@ -803,7 +796,8 @@ export async function listRecipes() { WHERE rm.recipe_id = r.id ), '[]'::json) AS materials FROM recipes r - ORDER BY r.name + JOIN items result_item ON result_item.id = r.item_id + ORDER BY result_item.name `); } @@ -812,7 +806,7 @@ export async function getRecipe(id: number) { ` SELECT r.id, - r.name, + result_item.name, COALESCE(( SELECT json_agg(json_build_object('id', am.id, 'name', am.name) ORDER BY am.name) FROM recipe_acquisition_methods ram @@ -824,8 +818,10 @@ export async function getRecipe(id: number) { FROM recipe_materials rm JOIN items i ON i.id = rm.item_id WHERE rm.recipe_id = r.id - ), '[]'::json) AS materials + ), '[]'::json) AS materials, + json_build_object('id', result_item.id, 'name', result_item.name) AS item FROM recipes r + JOIN items result_item ON result_item.id = r.item_id WHERE r.id = $1 `, [id] @@ -834,7 +830,7 @@ export async function getRecipe(id: number) { function cleanRecipePayload(payload: Record): RecipePayload { return { - name: cleanName(payload.name, '请输入材料单名字'), + itemId: requirePositiveInteger(payload.itemId, '请选择物品'), acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds), materials: cleanQuantities(payload.materials) }; @@ -860,12 +856,20 @@ 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]); + if (result.rowCount === 0) { + throw validationError('请选择物品'); + } +} + export async function createRecipe(payload: Record) { const cleanPayload = cleanRecipePayload(payload); const id = await withTransaction(async (client) => { - const result = await client.query<{ id: number }>('INSERT INTO recipes (name) VALUES ($1) RETURNING id', [ - cleanPayload.name + await ensureItemExists(client, cleanPayload.itemId); + const result = await client.query<{ id: number }>('INSERT INTO recipes (item_id) VALUES ($1) RETURNING id', [ + cleanPayload.itemId ]); const recipeId = result.rows[0].id; await replaceRecipeRelations(client, recipeId, cleanPayload); @@ -878,7 +882,8 @@ export async function updateRecipe(id: number, payload: Record) const cleanPayload = cleanRecipePayload(payload); const updated = await withTransaction(async (client) => { - const result = await client.query('UPDATE recipes SET name = $1 WHERE id = $2', [cleanPayload.name, id]); + await ensureItemExists(client, cleanPayload.itemId); + const result = await client.query('UPDATE recipes SET item_id = $1 WHERE id = $2', [cleanPayload.itemId, id]); if (result.rowCount === 0) { return false; } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d36baca..d9300e3 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -71,6 +71,7 @@ export interface Recipe { export interface RecipeDetail extends Recipe { acquisition_methods: NamedEntity[]; + item: NamedEntity; } export interface Options { @@ -105,7 +106,6 @@ export interface ItemPayload { name: string; categoryId: number; usageId: number | null; - recipeId: number | null; dyeable: boolean; dualDyeable: boolean; patternEditable: boolean; @@ -114,7 +114,7 @@ export interface ItemPayload { } export interface RecipePayload { - name: string; + itemId: number; acquisitionMethodIds: number[]; materials: Array<{ itemId: number; quantity: number }>; } diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 336bb53..a29c38c 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -70,7 +70,6 @@ const itemForm = ref({ name: '', categoryId: '', usageId: '', - recipeId: '', dyeable: false, dualDyeable: false, patternEditable: false, @@ -79,7 +78,7 @@ const itemForm = ref({ }); const recipeForm = ref({ id: 0, - name: '', + itemId: '', acquisitionMethodIds: [] as string[], materials: [] as Array<{ itemId: string; quantity: number }> }); @@ -255,7 +254,6 @@ function resetItemForm() { name: '', categoryId: '', usageId: '', - recipeId: '', dyeable: false, dualDyeable: false, patternEditable: false, @@ -272,7 +270,6 @@ async function editItem(item: Item) { name: detail.name, categoryId: String(detail.category.id), usageId: detail.usage ? String(detail.usage.id) : '', - recipeId: detail.recipe ? String(detail.recipe.id) : '', dyeable: detail.customization.dyeable, dualDyeable: detail.customization.dualDyeable, patternEditable: detail.customization.patternEditable, @@ -288,7 +285,6 @@ async function saveItem() { name: itemForm.value.name, categoryId: Number(itemForm.value.categoryId), usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null, - recipeId: itemForm.value.recipeId ? Number(itemForm.value.recipeId) : null, dyeable: itemForm.value.dyeable, dualDyeable: itemForm.value.dualDyeable, patternEditable: itemForm.value.patternEditable, @@ -313,7 +309,7 @@ async function removeItem(id: number) { } function resetRecipeForm() { - recipeForm.value = { id: 0, name: '', acquisitionMethodIds: [], materials: [] }; + recipeForm.value = { id: 0, itemId: '', acquisitionMethodIds: [], materials: [] }; } function addRecipeMaterial() { @@ -325,7 +321,7 @@ async function editRecipe(item: Recipe) { const detail = await api.recipeDetail(item.id); recipeForm.value = { id: detail.id, - name: detail.name, + itemId: String(detail.item.id), acquisitionMethodIds: detail.acquisition_methods.map((method) => String(method.id)), materials: detail.materials.map((material) => ({ itemId: String(material.id), quantity: material.quantity })) }; @@ -335,7 +331,7 @@ async function editRecipe(item: Recipe) { async function saveRecipe() { await run(async () => { const payload: RecipePayload = { - name: recipeForm.value.name, + itemId: Number(recipeForm.value.itemId), acquisitionMethodIds: toIds(recipeForm.value.acquisitionMethodIds), materials: toQuantityRows(recipeForm.value.materials) }; @@ -345,7 +341,7 @@ async function saveRecipe() { await api.createRecipe(payload); } resetRecipeForm(); - await loadRecipes(); + await Promise.all([loadRecipes(), loadItems()]); }); } @@ -576,13 +572,6 @@ onMounted(() => { -
- - -
@@ -635,7 +624,13 @@ onMounted(() => {

材料单

-
+
+ + +