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

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