diff --git a/backend/src/queries.ts b/backend/src/queries.ts index a52aebe..477abc4 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -961,7 +961,17 @@ export async function deleteItem(id: number, userId: number) { }); } -export async function listRecipes() { +export async function listRecipes(paramsQuery: QueryParams = {}) { + const params: unknown[] = []; + const conditions: string[] = []; + const categoryId = Number(asString(paramsQuery.categoryId)); + + if (Number.isInteger(categoryId) && categoryId > 0) { + params.push(categoryId); + conditions.push(`result_item.category_id = $${params.length}`); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; return query(` SELECT r.id, @@ -976,8 +986,9 @@ export async function listRecipes() { FROM recipes r JOIN items result_item ON result_item.id = r.item_id ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')} + ${whereClause} ORDER BY result_item.name - `); + `, params); } export async function getRecipe(id: number) { diff --git a/backend/src/server.ts b/backend/src/server.ts index c96a60a..a0ec1a8 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -248,7 +248,7 @@ app.delete('/api/items/:id', async (request, reply) => { return deleted ? reply.code(204).send() : reply.code(404).send({ message: 'Not found' }); }); -app.get('/api/recipes', async () => listRecipes()); +app.get('/api/recipes', async (request) => listRecipes(request.query as Record)); app.get('/api/recipes/:id', async (request, reply) => { const { id } = request.params as { id: string }; diff --git a/frontend/src/components/Skeleton.vue b/frontend/src/components/Skeleton.vue new file mode 100644 index 0000000..81e2ba7 --- /dev/null +++ b/frontend/src/components/Skeleton.vue @@ -0,0 +1,22 @@ + + + diff --git a/frontend/src/components/Tabs.vue b/frontend/src/components/Tabs.vue new file mode 100644 index 0000000..4263245 --- /dev/null +++ b/frontend/src/components/Tabs.vue @@ -0,0 +1,90 @@ + + + diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 2b19e20..ee69019 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -308,7 +308,8 @@ export const api = { createItem: (payload: ItemPayload) => sendJson('/api/items', 'POST', payload), updateItem: (id: string | number, payload: ItemPayload) => sendJson(`/api/items/${id}`, 'PUT', payload), deleteItem: (id: string | number) => deleteJson(`/api/items/${id}`), - recipes: () => getJson('/api/recipes'), + recipes: (params: Record = {}) => + getJson(`/api/recipes${buildQuery(params)}`), recipeDetail: (id: string | number) => getJson(`/api/recipes/${id}`), createRecipe: (payload: RecipePayload) => sendJson('/api/recipes', 'POST', payload), updateRecipe: (id: string | number, payload: RecipePayload) => diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index e861d69..adc0517 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -627,7 +627,7 @@ button:disabled, gap: 8px; } -.tabs button { +.tabs > button { min-height: 42px; padding: 9px 13px; border: 2px solid var(--line); @@ -638,13 +638,102 @@ button:disabled, cursor: pointer; } -.tabs button.active { +.tabs > button.active { border-color: var(--line-strong); background: var(--pokemon-yellow); color: #172036; box-shadow: 0 2px 0 var(--line-strong); } +.tabs--component { + display: grid; + gap: 14px; +} + +.tab-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + border-bottom: 2px solid var(--line); +} + +.tab-button { + min-height: 42px; + padding: 9px 13px; + border-bottom: 3px solid transparent; + border-radius: var(--radius-control) var(--radius-control) 0 0; + background: transparent; + color: var(--ink-soft); + font-weight: 900; + cursor: pointer; +} + +.tab-button[aria-selected="true"] { + border-color: var(--pokemon-yellow); + background: var(--surface); + color: var(--pokemon-blue-deep); +} + +.skeleton { + display: grid; + gap: 10px; +} + +.skeleton-line, +.skeleton-box { + display: block; + background: linear-gradient(90deg, var(--line), var(--surface), var(--line)); + background-size: 200% 100%; + animation: shimmer 1.4s linear infinite; +} + +.skeleton-line { + height: 14px; + border-radius: 999px; +} + +.skeleton-box { + height: 128px; + border-radius: var(--radius-card); +} + +.tab-list--skeleton { + padding-bottom: 0; +} + +.skeleton-tab { + border-radius: var(--radius-control) var(--radius-control) 0 0; +} + +.filter-panel--skeleton { + pointer-events: none; +} + +.entity-card--skeleton { + pointer-events: none; +} + +.skeleton-entity-mark { + border-radius: var(--radius-control); + box-shadow: 0 3px 0 var(--line); +} + +.skeleton-chip-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.skeleton-chip { + height: 28px; +} + +@keyframes shimmer { + to { + background-position: -200% 0; + } +} + .entity-grid, .grid { display: grid; diff --git a/frontend/src/views/ItemsList.vue b/frontend/src/views/ItemsList.vue index 80354bd..b48e7a2 100644 --- a/frontend/src/views/ItemsList.vue +++ b/frontend/src/views/ItemsList.vue @@ -5,7 +5,8 @@ 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 StatusMessage from '../components/StatusMessage.vue'; +import Skeleton from '../components/Skeleton.vue'; +import Tabs, { type TabOption } from '../components/Tabs.vue'; import TagsSelect from '../components/TagsSelect.vue'; import { api, type Item, type Options, type Recipe } from '../services/api'; @@ -19,6 +20,19 @@ const categoryId = ref(''); const usageId = ref(''); const tagIds = ref([]); +const itemTypeTabs: TabOption[] = [ + { value: 'items', label: '物品' }, + { value: 'recipes', label: '材料单' } +]; +const categorySkeletonWidths = ['64px', '92px', '78px', '104px', '86px']; +const filterSkeletonWidths = ['52px', '48px', '48px']; +const skeletonCardCount = 6; + +const categoryTabs = computed(() => [ + { value: '', label: '全部' }, + ...(options.value?.itemCategories.map((item) => ({ value: String(item.id), label: item.name })) ?? []) +]); + const itemQuery = computed(() => ({ search: search.value, categoryId: categoryId.value, @@ -26,12 +40,16 @@ const itemQuery = computed(() => ({ tagIds: tagIds.value.join(',') })); +const recipeQuery = computed(() => ({ + categoryId: categoryId.value +})); + async function loadItems() { loading.value = true; if (tab.value === 'items') { items.value = await api.items(itemQuery.value); } else { - recipes.value = await api.recipes(); + recipes.value = await api.recipes(recipeQuery.value); } loading.value = false; } @@ -41,7 +59,7 @@ onMounted(async () => { await loadItems(); }); -watch([tab, itemQuery], loadItems); +watch([tab, itemQuery, recipeQuery], loadItems);