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
This commit is contained in:
2026-04-30 16:52:59 +08:00
parent a7086823ff
commit 45e0276158
7 changed files with 232 additions and 42 deletions

View File

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

View File

@@ -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<string, unknown>): ItemPayload {
@@ -942,11 +990,23 @@ function cleanItemPayload(payload: Record<string, unknown>): 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<void> {
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<void> {
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<string, unknown>, 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<string, unknown>, userId: numbe
cleanPayload.dyeable,
cleanPayload.dualDyeable,
cleanPayload.patternEditable,
cleanPayload.noRecipe,
userId
]
);
@@ -1007,6 +1069,7 @@ export async function updateItem(id: number, payload: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<void> {
const result = await client.query('SELECT 1 FROM items WHERE id = $1', [itemId]);
async function ensureItemCanHaveRecipe(client: DbClient, itemId: number): Promise<void> {
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<string, unknown>, 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<string, unknown>,
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]

View File

@@ -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<NamedEntity & { quantity: number }>;
}
export interface HabitatUsage {
id: number;
name: string;
recipe: Array<NamedEntity & { quantity: number }>;
}
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<NamedEntity & { quantity: number }>;
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[];
}

View File

@@ -114,6 +114,22 @@ onMounted(async () => {
<RouterLink :to="`/recipes/${item.recipe.id}`">{{ item.recipe.name }}</RouterLink>
<EntityChips :items="item.recipe.materials" />
</template>
<p v-else-if="item.noRecipe" class="meta-line">无材料单</p>
<template v-else>
<p class="meta-line"></p>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/recipes/new?itemId=${item.id}`">
创建材料单
</RouterLink>
</template>
</DetailSection>
<DetailSection title="相关材料单">
<ul v-if="item.relatedRecipes.length" class="row-list">
<li v-for="recipe in item.relatedRecipes" :key="recipe.id">
<RouterLink :to="`/recipes/${recipe.id}`">{{ recipe.name }}</RouterLink>
<EntityChips :items="recipe.materials" />
</li>
</ul>
<p v-else class="meta-line"></p>
</DetailSection>
@@ -121,7 +137,7 @@ onMounted(async () => {
<ul v-if="item.relatedHabitats.length" class="row-list">
<li v-for="habitat in item.relatedHabitats" :key="habitat.id">
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
<span>× {{ habitat.quantity }}</span>
<EntityChips :items="habitat.recipe" />
</li>
</ul>
<p v-else class="meta-line"></p>

View File

@@ -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(() => {
<label><input v-model="itemForm.dyeable" type="checkbox" /> 可染色</label>
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> 可双区染色</label>
<label><input v-model="itemForm.patternEditable" type="checkbox" /> 可改花纹</label>
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> 无材料单</label>
</div>
<div class="field">

View File

@@ -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(() => {
<TagsSelect
id="recipe-item"
v-model="recipeForm.itemId"
:options="itemSelectOptions"
:options="resultItemOptions"
:multiple="false"
placeholder="请选择"
search-placeholder="搜索物品"
@@ -161,7 +177,7 @@ onMounted(() => {
<TagsSelect
:id="`recipe-material-${index}`"
v-model="row.itemId"
:options="itemSelectOptions"
:options="materialItemOptions"
:multiple="false"
placeholder="请选择"
search-placeholder="搜索物品"

View File

@@ -1,19 +1,24 @@
<script setup lang="ts">
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<Options | null>(null);
const recipes = ref<Recipe[]>([]);
const items = ref<Item[]>([]);
const loading = ref(true);
const search = ref('');
const categoryId = ref('');
const usageId = ref('');
const tagIds = ref<string[]>([]);
const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px'];
const filterSkeletonWidths = ['52px', '48px', '48px'];
const skeletonCardCount = 6;
const categoryTabs = computed<TabOption[]>(() => [
@@ -21,27 +26,50 @@ const categoryTabs = computed<TabOption[]>(() => [
...(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);
</script>
<template>
<section class="page-stack">
<PageHeader title="材料单" subtitle="按分类浏览材料单和需要材料。">
<PageHeader title="材料单" subtitle="按分类、用途、标签查看材料单。">
<template #kicker>Recipes</template>
<template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" to="/recipes/new">新增</RouterLink>
@@ -62,28 +90,60 @@ watch(recipeQuery, loadRecipes);
</div>
</div>
<FilterPanel v-if="options">
<div class="field">
<label for="recipe-search">搜索</label>
<input id="recipe-search" v-model="search" type="search" placeholder="名称" />
</div>
<div class="field">
<label for="recipe-usage">用途</label>
<TagsSelect
id="recipe-usage"
v-model="usageId"
:options="options.itemUsages"
:multiple="false"
placeholder="全部"
search-placeholder="搜索用途"
/>
</div>
<div class="field">
<label for="recipe-tags">标签</label>
<TagsSelect id="recipe-tags" v-model="tagIds" :options="options.itemTags" placeholder="搜索标签" />
</div>
</FilterPanel>
<FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true">
<div v-for="(width, index) in filterSkeletonWidths" :key="index" class="field">
<Skeleton :width="width" />
<Skeleton variant="box" height="44px" />
</div>
</FilterPanel>
<div v-if="loading" class="entity-grid" aria-busy="true" aria-label="正在加载材料单列表">
<article v-for="index in skeletonCardCount" :key="`recipe-skeleton-${index}`" class="entity-card entity-card--skeleton">
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
<div class="entity-card__content">
<Skeleton width="72%" height="24px" />
<Skeleton width="52%" />
<Skeleton width="64%" />
<div class="skeleton-chip-row">
<Skeleton
v-for="chipIndex in 2"
:key="chipIndex"
:width="chipIndex === 1 ? '74px' : '58px'"
class="skeleton-chip"
/>
</div>
</div>
</article>
</div>
<div v-else class="entity-grid">
<EntityCard v-for="item in recipes" :key="item.id" :title="item.name" :to="`/recipes/${item.id}`" marker="▦">
<EditMeta :entity="item" />
<EntityChips :items="item.materials" />
<EntityCard
v-for="item in items"
:key="item.id"
:title="item.name"
:subtitle="itemSubtitle(item)"
:to="recipeTarget(item)"
:marker="itemMarker(item)"
>
<EditMeta v-if="item.recipe" :entity="item.recipe" />
<RouterLink v-else-if="!item.noRecipe" class="ui-button ui-button--primary ui-button--small" :to="createRecipeTarget(item)">
创建材料单
</RouterLink>
</EntityCard>
</div>
</section>