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:
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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="搜索物品"
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user