From 5015ca3a7b97d48d2489b89a59d320f43c378d07 Mon Sep 17 00:00:00 2001
From: RayLi <161552348+RayLi-Muye@users.noreply.github.com>
Date: Tue, 14 Jul 2026 11:34:40 +0800
Subject: [PATCH] feat: refine hero and item catalogs
---
.impeccable.md | 3 +
apps/web/app/globals.css | 215 +++++++++++++++----
apps/web/app/items/[itemId]/page.tsx | 38 +++-
apps/web/app/items/page.tsx | 163 ++++++++------
apps/web/components/hero-catalog-browser.tsx | 12 +-
apps/web/components/item-detail-panel.tsx | 119 ++++++++++
apps/web/lib/item-catalog.ts | 151 +++++++++++++
apps/web/test/api.test.ts | 11 +-
apps/web/test/item-catalog.test.ts | 108 ++++++++++
apps/web/test/ui-contract.test.ts | 37 +++-
docs/orchestration/workboard.md | 25 +++
docs/prd/mvp.md | 4 +
12 files changed, 762 insertions(+), 124 deletions(-)
create mode 100644 apps/web/components/item-detail-panel.tsx
create mode 100644 apps/web/lib/item-catalog.ts
create mode 100644 apps/web/test/item-catalog.test.ts
diff --git a/.impeccable.md b/.impeccable.md
index 2a470ec..bb9850c 100644
--- a/.impeccable.md
+++ b/.impeccable.md
@@ -16,6 +16,9 @@ Dota 2 玩家会在桌面与竖屏手机上高频查看自己的公开比赛、
- 深浅表面主要通过明度、细边框和留白区分,不依赖通用阴影或玻璃拟态。
- 英雄头像、物品图标与地图内容承担主要鲜艳色彩。
- 英雄与物品百科参考 Dota 2 客户端的英雄名册和商店信息架构:紧凑头像/图标矩阵、明确属性或商店分区、低饱和金属质感层级;不逐像素复制客户端面板或装饰素材。
+- 英雄目录在宽屏优先呈现四个并列属性列和纯头像矩阵;名称、定位等次级文字通过悬停、焦点或竖屏重排提供,不能挤占名册密度。
+- 物品目录在宽屏采用“基础分类 / 合成分类 / 中立物品 / 常驻详情”工作台;点击物品留在目录上下文中查看详情,不强迫用户在列表与详情页之间反复跳转。
+- 可升级物品在目录中折叠为一个入口,详情用等级选择和斜杠数值展示整条升级曲线,并明确高亮当前等级;固定物品继续使用单值表现,不因文本中出现斜杠而误判为升级链。
- 绿色表示正向、胜利或上升,红色表示负向、失败或下降;颜色必须同时搭配符号、文字或形状,不能成为唯一信息通道。
- 数据模块具有明确职责:账号概览、英雄、比赛、物品、地图分别组织;模块内部可通过 segmented control、tab 或时间窗口按钮切换数据。
- 桌面采用不对称高密度仪表盘;竖屏重新排列优先级,先显示账号结论与关键趋势,再显示可横向滚动或折叠的明细,不简单压缩桌面网格。
diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css
index 98ac72b..024c5e3 100644
--- a/apps/web/app/globals.css
+++ b/apps/web/app/globals.css
@@ -2009,6 +2009,7 @@ button.text-action {
}
.catalog-workbench__header h2 { font-size: var(--text-lg); }
+.catalog-workbench__header p { margin: 0; color: var(--text-muted); font-size: var(--text-xs); }
.hero-browser,
.hero-attribute-groups,
@@ -2104,47 +2105,52 @@ button.text-action {
.hero-armory-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
- gap: var(--space-xs);
- padding: var(--space-sm);
+ grid-template-columns: repeat(auto-fill, minmax(4.25rem, 1fr));
+ gap: var(--space-2xs);
+ padding: var(--space-xs);
}
.hero-armory-tile {
- display: grid;
- align-content: start;
+ position: relative;
+ display: block;
min-width: 0;
border: 1px solid var(--line);
background: var(--bg);
+ isolation: isolate;
transition: border-color 150ms var(--ease-out), background 150ms var(--ease-out), transform 150ms var(--ease-out);
}
.hero-armory-tile__image {
width: 100%;
- aspect-ratio: 16 / 9;
+ aspect-ratio: 3 / 4;
object-fit: cover;
+ object-position: 50% 30%;
}
.hero-armory-tile__name,
.hero-armory-tile small {
overflow: hidden;
- padding-inline: var(--space-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.hero-armory-tile__name {
- padding-top: var(--space-xs);
+ position: absolute;
+ right: -1px;
+ bottom: -1px;
+ left: -1px;
+ z-index: 2;
+ padding: 0.25rem 0.35rem;
+ overflow: hidden;
+ background: oklch(14% 0.012 252 / 92%);
color: var(--text-strong);
- font-family: var(--font-display);
- font-size: var(--text-sm);
+ font-size: 0.625rem;
font-weight: 600;
line-height: 1.15;
-}
-
-.hero-armory-tile small {
- padding-bottom: var(--space-xs);
- color: var(--text-muted);
- font-size: 0.625rem;
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(0.25rem);
+ transition: opacity 120ms var(--ease-out), transform 120ms var(--ease-out);
}
.custom-hero-groups > header,
@@ -2250,53 +2256,156 @@ button.text-action {
}
.custom-group-editor form { border-top: 1px solid var(--line); border-bottom: 0; background: var(--surface); }
-.item-shop-group > header h3 { font-size: var(--text-lg); }
+.item-shop-group > header h3 { font-size: var(--text-sm); }
.item-shop-grid {
display: grid;
- grid-template-columns: repeat(auto-fill, minmax(5.25rem, 1fr));
- gap: var(--space-xs);
- padding: var(--space-sm);
+ grid-template-columns: repeat(auto-fill, minmax(3.5rem, 1fr));
+ gap: var(--space-2xs);
+ padding: var(--space-xs);
}
.item-shop-tile {
- display: grid;
- align-content: start;
+ position: relative;
+ display: block;
min-width: 0;
border: 1px solid var(--line);
background: var(--bg);
transition: border-color 150ms var(--ease-out), background 150ms var(--ease-out), transform 150ms var(--ease-out);
}
+.item-shop-tile[aria-current="true"] {
+ border-color: var(--accent);
+ background: var(--accent-muted);
+}
+
.item-shop-tile__image {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
}
-.item-shop-tile > span,
-.item-shop-tile > strong {
- overflow: hidden;
- padding-inline: var(--space-xs);
- text-overflow: ellipsis;
- white-space: nowrap;
+.item-shop-tile > small {
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ padding: 0.1rem 0.25rem;
+ background: oklch(14% 0.012 252 / 92%);
+ color: var(--accent);
+ font-family: var(--font-display);
+ font-size: 0.625rem;
+ font-weight: 700;
+ line-height: 1.15;
}
-.item-shop-tile > span {
- padding-top: var(--space-xs);
+.item-workbench,
+.item-workbench__catalog,
+.item-zone { min-width: 0; }
+
+.item-workbench { display: flex; flex-direction: column; }
+
+.item-workbench__catalog { display: grid; }
+
+.item-zone {
+ align-content: start;
+ border-bottom: 1px solid var(--line-strong);
+}
+
+.item-zone > header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: var(--space-sm);
+ padding: var(--space-sm) var(--space-md);
+ border-bottom: 1px solid var(--line-strong);
+ background: var(--surface);
+}
+
+.item-zone > header h2 {
+ margin: 0;
color: var(--text-strong);
- font-size: var(--text-xs);
+ font-family: var(--font-display);
+ font-size: var(--text-lg);
font-weight: 600;
+ line-height: 1;
}
-.item-shop-tile > strong {
- padding-bottom: var(--space-xs);
- color: var(--accent);
- font-family: var(--font-display);
- font-size: var(--text-sm);
- font-variant-numeric: tabular-nums;
+.item-zone > header span { color: var(--text-muted); font-size: var(--text-xs); }
+.item-zone .item-shop-group { border-top: 0; border-bottom: 1px solid var(--line); }
+.item-zone .item-shop-group:last-child { border-bottom: 0; }
+.item-zone .item-shop-group > header { padding: var(--space-xs); }
+.item-zone .item-shop-group > header > strong { font-size: var(--text-sm); }
+
+.item-inspector {
+ order: -1;
+ min-width: 0;
+ border-top: 1px solid var(--line-strong);
+ background: var(--surface-low);
}
-.item-shop-tile > strong small { color: var(--text-muted); font-size: 0.625rem; }
+.item-inspector__profile {
+ display: grid;
+ grid-template-columns: 5rem minmax(0, 1fr);
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-md);
+ border-bottom: 1px solid var(--line-strong);
+ background: var(--surface);
+}
+
+.item-inspector__image { width: 5rem; aspect-ratio: 3 / 2; object-fit: cover; }
+.item-inspector__profile > div { display: grid; gap: var(--space-2xs); min-width: 0; }
+.item-inspector__profile span { color: var(--text-muted); font-size: var(--text-xs); }
+.item-inspector__profile h2 { margin: 0; color: var(--text-strong); font-family: var(--font-display); font-size: var(--text-lg); line-height: 1; }
+.item-inspector__profile strong { color: var(--accent); font-family: var(--font-display); font-size: var(--text-base); font-variant-numeric: tabular-nums; }
+.item-inspector__profile strong > span { color: var(--accent); font-size: 0.625rem; }
+.item-inspector__profile strong small { color: var(--text-muted); font-size: 0.625rem; }
+
+.item-level-switcher { display: grid; grid-template-columns: repeat(auto-fit, minmax(3.25rem, 1fr)); border-bottom: 1px solid var(--line); }
+.item-level-switcher a {
+ display: grid;
+ justify-items: center;
+ gap: 0;
+ min-height: 3rem;
+ padding: var(--space-2xs);
+ border-right: 1px solid var(--line);
+ color: var(--text-muted);
+}
+.item-level-switcher a:last-child { border-right: 0; }
+.item-level-switcher a[aria-current="page"] { background: var(--surface-active); color: var(--text-strong); }
+.item-level-switcher span { font-size: 0.625rem; }
+.item-level-switcher strong { font-family: var(--font-display); font-size: var(--text-base); line-height: 1; }
+
+.item-inspector__section { padding: var(--space-md); border-bottom: 1px solid var(--line); }
+.item-inspector__section h3 {
+ margin: 0 0 var(--space-sm);
+ color: var(--text-muted);
+ font-family: var(--font-display);
+ font-size: var(--text-xs);
+ font-weight: 600;
+ letter-spacing: 0.1em;
+}
+.item-effect-list { display: grid; gap: var(--space-2xs); margin: 0; }
+.item-effect-list > div { display: grid; grid-template-columns: minmax(6rem, 0.7fr) minmax(0, 1.3fr); gap: var(--space-sm); }
+.item-effect-list dt { color: var(--text-muted); font-size: var(--text-xs); }
+.item-effect-list dd { margin: 0; overflow-wrap: anywhere; color: var(--text-strong); font-family: var(--font-display); font-size: var(--text-sm); text-align: right; font-variant-numeric: tabular-nums; }
+.level-values { display: inline-flex; flex-wrap: wrap; justify-content: end; gap: 0.15rem; }
+.level-values > span { display: inline-flex; gap: 0.15rem; color: var(--text-muted); }
+.level-values i { color: var(--line-strong); font-style: normal; font-weight: 400; }
+.level-values b { font-weight: 500; }
+.level-values .is-current { color: var(--text-strong); }
+.level-values .is-current b { font-weight: 700; }
+.item-inspector__description { margin: var(--space-sm) 0 0; color: var(--text); font-size: var(--text-xs); line-height: 1.7; }
+.item-inspector__empty { margin: 0; color: var(--text-muted); font-size: var(--text-xs); }
+.item-inspector__components { display: grid; grid-template-columns: repeat(auto-fill, minmax(4.5rem, 1fr)); gap: var(--space-xs); }
+.item-inspector__components a { display: grid; min-width: 0; border: 1px solid var(--line); background: var(--bg); }
+.item-inspector__components img { width: 100%; aspect-ratio: 3 / 2; object-fit: cover; }
+.item-inspector__components a > span { display: grid; padding: var(--space-2xs); overflow: hidden; color: var(--text-strong); font-size: 0.625rem; text-overflow: ellipsis; white-space: nowrap; }
+.item-inspector__components small { color: var(--text-muted); }
+.item-inspector__missing { display: grid; place-items: center; min-height: 3rem; border: 1px solid var(--line); color: var(--text-muted); font-size: var(--text-xs); }
+.item-inspector__disclaimer { margin: 0; padding: var(--space-sm) var(--space-md); border-bottom: 1px solid var(--warning-line); color: var(--warning); font-size: var(--text-xs); }
+.item-inspector .module-quality { margin: var(--space-sm); }
+.item-inspector > .dodo-meta-line { padding: var(--space-sm) var(--space-md); border-top: 1px solid var(--line); }
+.item-inspector__full-link { display: flex; align-items: center; min-height: 2.75rem; padding: var(--space-sm) var(--space-md); border-top: 1px solid var(--line); color: var(--accent); font-size: var(--text-xs); }
@media (min-width: 40rem) {
.custom-group-create,
@@ -2906,6 +3015,8 @@ circle.map-feature,
transform: translateY(-1px);
}
+ .hero-armory-tile:hover .hero-armory-tile__name { opacity: 1; transform: translateY(0); }
+
.button-secondary:hover,
.window-switcher a:hover,
.map-filter a:hover {
@@ -2931,6 +3042,15 @@ circle.map-feature,
}
}
+.hero-armory-tile:focus-visible .hero-armory-tile__name { opacity: 1; transform: translateY(0); }
+
+@media (max-width: 39.999rem) {
+ .hero-armory-tile__name { opacity: 1; transform: translateY(0); }
+ .item-effect-list > div { grid-template-columns: minmax(0, 1fr); gap: 0; }
+ .item-effect-list dd { text-align: left; }
+ .level-values { justify-content: start; }
+}
+
@media (min-width: 40rem) {
.patch-filter { grid-template-columns: auto minmax(12rem, 1fr) auto auto; align-items: end; }
.patch-filter label { grid-column: 1 / -1; }
@@ -2990,6 +3110,8 @@ circle.map-feature,
.map-audit-grid > div:nth-child(odd):not(.map-audit-grid__wide) { border-right: 1px solid var(--line); }
.map-audit-grid__wide { grid-column: 1 / -1; }
.map-layout { grid-template-columns: minmax(22rem, 1.25fr) minmax(18rem, 0.75fr); }
+ .item-workbench__catalog { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .item-zone--neutral { grid-column: 1 / -1; }
}
@media (min-width: 64rem) {
@@ -3015,6 +3137,23 @@ circle.map-feature,
.detail-grid { grid-template-columns: minmax(0, 1.35fr) minmax(20rem, 0.65fr); align-items: start; }
.detail-grid__side { position: sticky; top: 6rem; }
.map-evidence-grid { grid-template-columns: minmax(18rem, 0.75fr) minmax(28rem, 1.25fr); align-items: start; }
+ .hero-attribute-groups { grid-template-columns: repeat(4, minmax(0, 1fr)); align-items: start; }
+ .hero-attribute-group { border-top: 0; border-right: 1px solid var(--line-strong); }
+ .hero-attribute-group:last-child { border-right: 0; }
+ .hero-attribute-group > header { grid-template-columns: 2.25rem minmax(0, 1fr) auto; padding: var(--space-xs); }
+ .hero-attribute-group > header > span { width: 2.25rem; height: 2.25rem; }
+ .hero-attribute-group > header p { display: none; }
+ .hero-armory-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); }
+}
+
+@media (min-width: 75rem) {
+ .encyclopedia-page--items { width: min(100%, 110rem); }
+ .item-workbench { display: grid; grid-template-columns: minmax(0, 1fr) minmax(20rem, 22rem); align-items: start; }
+ .item-workbench__catalog { grid-template-columns: minmax(15rem, 0.8fr) minmax(18rem, 1.15fr) minmax(14rem, 0.8fr); }
+ .item-zone { min-height: 100%; border-right: 1px solid var(--line-strong); border-bottom: 0; }
+ .item-zone--neutral { grid-column: auto; border-right: 0; }
+ .item-inspector { position: sticky; top: 5rem; order: 0; max-height: calc(100vh - 6rem); overflow-y: auto; border-top: 0; border-left: 1px solid var(--line-strong); scrollbar-width: thin; }
+ .item-shop-grid { grid-template-columns: repeat(auto-fill, minmax(3rem, 1fr)); }
}
@media (pointer: coarse) {
diff --git a/apps/web/app/items/[itemId]/page.tsx b/apps/web/app/items/[itemId]/page.tsx
index 11392e6..05d9ab6 100644
--- a/apps/web/app/items/[itemId]/page.tsx
+++ b/apps/web/app/items/[itemId]/page.tsx
@@ -5,10 +5,12 @@ import Link from "next/link";
import { AssetImage } from "../../../components/asset-image";
import { DataState } from "../../../components/data-state";
import { EntityRecentUpdates } from "../../../components/entity-recent-updates";
+import { LevelValues } from "../../../components/item-detail-panel";
import { PageHeading } from "../../../components/page-heading";
import { QualityNotice } from "../../../components/quality-notice";
-import { api, settle } from "../../../lib/api";
+import { api, collectAllItemsWithMeta, settle } from "../../../lib/api";
import { encyclopediaVersionLabel, officialDescription } from "../../../lib/format";
+import { buildItemCatalogEntries, findItemCatalogEntry, levelAttributeValues } from "../../../lib/item-catalog";
const itemKindLabel = {
item: "普通物品定义",
@@ -19,9 +21,10 @@ const itemKindLabel = {
export default async function ItemDetailPage({ params }: { params: Promise<{ itemId: string }> }) {
const { itemId } = await params;
- const [itemResult, updatesResult] = await Promise.all([
+ const [itemResult, updatesResult, catalogResult] = await Promise.all([
settle(api.item(itemId)),
settle(api.itemUpdates(itemId)),
+ settle(collectAllItemsWithMeta()),
]);
if (!itemResult.ok) {
return (
@@ -32,6 +35,15 @@ export default async function ItemDetailPage({ params }: { params: Promise<{ ite
);
}
const item = itemResult.value;
+ const catalogEntries = catalogResult.ok ? buildItemCatalogEntries(catalogResult.value.items) : [];
+ const familyEntry = findItemCatalogEntry(catalogEntries, item.data.id);
+ const familyDetailResults = familyEntry && familyEntry.members.length > 1
+ ? await Promise.all(familyEntry.members.map((member) => member.item.id === item.data.id ? itemResult : settle(api.item(member.item.id))))
+ : [];
+ const familyDetails = familyDetailResults.flatMap((candidate) => candidate.ok ? [candidate.value.data] : []);
+ const selectedLevel = familyEntry?.members.find((member) => member.item.id === item.data.id)?.level ?? 1;
+ const isUpgradeFamily = Boolean(familyEntry && familyEntry.members.length > 1);
+ const hasCompleteFamilyDetails = Boolean(familyEntry && familyDetails.length === familyEntry.members.length);
const versionLabel = encyclopediaVersionLabel(item.data.officialVersion);
const componentResults = await Promise.all(item.data.components.map((id) => settle(api.item(id))));
const components = componentResults.flatMap((component) => component.ok ? [component.value.data] : []);
@@ -48,7 +60,7 @@ export default async function ItemDetailPage({ params }: { params: Promise<{ ite
ITEM / {item.data.id} / {versionLabel}
{item.data.localizedName}
{officialDescription(item.data.description)}
- {categoryLabel}{itemKindLabel[item.data.kind]}{versionLabel}
+ {categoryLabel}{itemKindLabel[item.data.kind]}{versionLabel}{isUpgradeFamily ? 等级 {selectedLevel} : null}
COST{item.data.cost.toLocaleString("zh-CN")}金币
@@ -57,12 +69,30 @@ export default async function ItemDetailPage({ params }: { params: Promise<{ ite
{item.data.availabilityStatus === "unverified" ? (
官方定义存在不等于当前商店可购买;该物品的当前可购买性尚未核验。
) : null}
+ {!catalogResult.ok ? (
+ 物品目录暂不可用;当前详情仍保留,但可升级物品的等级导航可能暂时缺失。
+ ) : isUpgradeFamily && !hasCompleteFamilyDetails ? (
+ 升级族已识别,但部分等级详情暂不可用;等级导航仍保留,当前仅展示所选等级的原始属性。
+ ) : null}
+
+ {isUpgradeFamily && familyEntry ? (
+
+ ) : null}
{item.data.attributes.length > 0 ? (
- {item.data.attributes.map((attribute, index) => - {attribute.label}
- {attribute.value}
)}
+ {item.data.attributes.map((attribute, index) => {
+ const values = hasCompleteFamilyDetails ? levelAttributeValues(familyDetails, index) : null;
+ return - {attribute.label}
- {values ? : attribute.value}
;
+ })}
) : 当前快照没有结构化属性。
}
diff --git a/apps/web/app/items/page.tsx b/apps/web/app/items/page.tsx
index 90438c2..06242e1 100644
--- a/apps/web/app/items/page.tsx
+++ b/apps/web/app/items/page.tsx
@@ -1,20 +1,23 @@
-import type { ItemSummary } from "@dodo/contracts";
import { MetaLine } from "@dodo/ui";
import Link from "next/link";
import { AssetImage } from "../../components/asset-image";
import { DataState, EmptyState } from "../../components/data-state";
+import { ItemDetailPanel } from "../../components/item-detail-panel";
import { PageHeading } from "../../components/page-heading";
import { QualityNotice } from "../../components/quality-notice";
-import { collectAllItemsWithMeta, settle } from "../../lib/api";
+import { api, collectAllItemsWithMeta, settle } from "../../lib/api";
+import { buildItemCatalogEntries, filterItemCatalogEntries, findItemCatalogEntry, groupItemCatalogEntries, itemCatalogHref } from "../../lib/item-catalog";
-export default async function ItemsPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
+type ItemQuery = { q?: string; selected?: string };
+
+export default async function ItemsPage({ searchParams }: { searchParams: Promise
}) {
const query = await searchParams;
- const result = await settle(collectAllItemsWithMeta(query.q));
- const currentQuery = new URLSearchParams({ ...(query.q ? { q: query.q } : {}) }).toString();
+ const result = await settle(collectAllItemsWithMeta());
+ const currentQuery = new URLSearchParams({ ...(query.q ? { q: query.q } : {}), ...(query.selected ? { selected: query.selected } : {}) }).toString();
const currentHref = currentQuery ? `/items?${currentQuery}` : "/items";
return (
-
+
- {!result.ok ?
: (() => {
- const currentItems = result.value.items.filter((item) => item.kind !== "recipe");
- const groups = groupCurrentItems(currentItems);
- return (
-
-
-
- {currentItems.length === 0 ? (
-
- ) : (
-
- {groups.map((group) => (
-
-
-
- {group.items.map((item) => (
-
-
-
{item.localizedName}
-
{item.cost.toLocaleString("zh-CN")} 金
-
- ))}
-
-
- ))}
-
- )}
-
- );
- })()}
+ {!result.ok ?
: (
+
+ )}
);
}
-const officialCategory: Record = {
- official_quality_0: { label: "消耗用品", rank: 10 },
- official_quality_1: { label: "基础组件", rank: 11 },
- official_quality_2: { label: "常规装备", rank: 12 },
- official_quality_3: { label: "进阶装备", rank: 13 },
- official_quality_4: { label: "高阶装备", rank: 14 },
- official_quality_5: { label: "特殊装备", rank: 15 },
- official_quality_6: { label: "神秘商店组件", rank: 16 },
-};
+async function ItemCatalogResult({ query, result }: {
+ query: ItemQuery;
+ result: Awaited>;
+}) {
+ const entries = filterItemCatalogEntries(
+ buildItemCatalogEntries(result.items.filter((item) => item.kind !== "recipe")),
+ query.q,
+ );
+ const currentItems = entries.flatMap((entry) => entry.members.map(({ item }) => item));
+ const zones = groupItemCatalogEntries(entries);
+ const selectedEntry = findItemCatalogEntry(entries, query.selected) ?? entries[0];
+ const selectedMember = selectedEntry?.members.find((member) => member.item.id === query.selected) ?? selectedEntry?.members[0];
-function itemDisplayGroup(item: ItemSummary) {
- if (item.kind === "neutral_enhancement") {
- return { key: "neutral_enhancement", label: "中立附魔", rank: 90 };
- }
- if (item.kind === "neutral_item") {
- const tier = /^neutral_tier_(\d+)$/.exec(item.category)?.[1];
- return tier
- ? { key: `neutral_item_${tier}`, label: `中立物品 · ${tier} 级`, rank: 80 + Number(tier) }
- : { key: "neutral_item", label: "中立物品", rank: 89 };
+ if (currentItems.length === 0 || !selectedEntry || !selectedMember) {
+ return ;
}
- const official = officialCategory[item.category];
- if (official) return { key: item.category, ...official };
- return { key: "other", label: "其他当前物品", rank: 100 };
-}
-function groupCurrentItems(items: ItemSummary[]) {
- const groups = new Map();
- for (const item of items) {
- const descriptor = itemDisplayGroup(item);
- const group = groups.get(descriptor.key);
- if (group) group.items.push(item);
- else groups.set(descriptor.key, { ...descriptor, items: [item] });
- }
- return [...groups.values()].sort((left, right) => left.rank - right.rank || left.label.localeCompare(right.label, "zh-CN"));
+ const detailResults = await Promise.all(selectedEntry.members.map((member) => settle(api.item(member.item.id))));
+ const familyDetails = detailResults.flatMap((candidate) => candidate.ok ? [candidate.value.data] : []);
+ const selectedDetailResult = detailResults[selectedEntry.members.indexOf(selectedMember)];
+ const selectedResponse = selectedDetailResult?.ok ? selectedDetailResult.value : null;
+ const componentResults = selectedResponse
+ ? await Promise.all(selectedResponse.data.components.map((id) => settle(api.item(id))))
+ : [];
+ const components = componentResults.flatMap((candidate) => candidate.ok ? [candidate.value.data] : []);
+
+ return (
+
+
+
+
+
+ {zones.map((zone) => (
+
+ {zone.label}
{zone.groups.reduce((total, group) => total + group.entries.length, 0)} 项
+ {zone.groups.map((group) => (
+
+ {group.label}
{group.entries.length}
+
+ {group.entries.map((entry) => {
+ const selected = entry.id === selectedEntry.id;
+ return (
+
1 ? `,${entry.members.length} 级可升级物品` : ""}`}
+ className="item-shop-tile"
+ href={itemCatalogHref(entry.item.id, query.q)}
+ key={entry.id}
+ title={entry.item.localizedName}
+ >
+
+
{entry.item.localizedName}
+ {entry.members.length > 1 ?
{entry.members.length}级 : null}
+
+ );
+ })}
+
+
+ ))}
+
+ ))}
+
+ {selectedResponse ? (
+
+ ) : (
+
+ )}
+
+
+ );
}
diff --git a/apps/web/components/hero-catalog-browser.tsx b/apps/web/components/hero-catalog-browser.tsx
index 3d0ff66..3c7c12f 100644
--- a/apps/web/components/hero-catalog-browser.tsx
+++ b/apps/web/components/hero-catalog-browser.tsx
@@ -5,7 +5,6 @@ import Link from "next/link";
import { type FormEvent, useEffect, useMemo, useState } from "react";
import { AssetImage } from "./asset-image";
-import { heroRoleLabel } from "../lib/format";
import { canCreateHeroGroup, type HeroGroup, readHeroGroups, writeHeroGroups } from "../lib/hero-groups";
const attributeGroups = [
{ key: "strength", label: "力量", short: "STR", note: "坚韧与正面交锋" },
@@ -15,11 +14,16 @@ const attributeGroups = [
] as const;
function HeroTile({ hero }: { hero: HeroSummary }) {
+ const attackLabel = hero.attackType === "melee" ? "近战" : "远程";
return (
-
-
+
+
{hero.localizedName}
- {hero.attackType === "melee" ? "近战" : "远程"} · {hero.roles.slice(0, 2).map(heroRoleLabel).join(" / ") || "定位未标注"}
);
}
diff --git a/apps/web/components/item-detail-panel.tsx b/apps/web/components/item-detail-panel.tsx
new file mode 100644
index 0000000..f8025d1
--- /dev/null
+++ b/apps/web/components/item-detail-panel.tsx
@@ -0,0 +1,119 @@
+import type { ItemDetail } from "@dodo/contracts";
+import { MetaLine } from "@dodo/ui";
+import Link from "next/link";
+
+import type { ItemCatalogEntry } from "../lib/item-catalog";
+import { itemCatalogHref, levelAttributeValues } from "../lib/item-catalog";
+import { officialDescription } from "../lib/format";
+import { AssetImage } from "./asset-image";
+import { QualityNotice } from "./quality-notice";
+
+export function ItemDetailPanel({
+ components,
+ detail,
+ entry,
+ familyDetails,
+ meta,
+ q,
+}: {
+ components: ItemDetail[];
+ detail: ItemDetail;
+ entry: ItemCatalogEntry;
+ familyDetails: ItemDetail[];
+ meta: { quality: "complete" | "partial" | "stale"; sources: string[]; updatedAt: string };
+ q: string | undefined;
+}) {
+ const selectedLevel = entry.members.find((member) => member.item.id === detail.id)?.level ?? 1;
+ const isUpgradeFamily = entry.members.length > 1;
+ const hasCompleteFamilyDetails = familyDetails.length === entry.members.length;
+ return (
+
+ );
+}
+
+export function LevelValues({ currentLevel, values }: { currentLevel: number; values: string[] }) {
+ return (
+ `等级 ${index + 1}:${value}`).join(";")} className="level-values">
+ {values.map((value, index) => (
+
+ {index > 0 ? / : null}{value}
+
+ ))}
+
+ );
+}
+
+function itemCategoryLabel(item: ItemDetail): string {
+ if (item.kind === "neutral_enhancement") return "中立附魔";
+ if (item.kind === "neutral_item") return "中立物品";
+ return "固定物品";
+}
diff --git a/apps/web/lib/item-catalog.ts b/apps/web/lib/item-catalog.ts
new file mode 100644
index 0000000..141b897
--- /dev/null
+++ b/apps/web/lib/item-catalog.ts
@@ -0,0 +1,151 @@
+import type { ItemDetail, ItemSummary } from "@dodo/contracts";
+
+export type ItemCatalogEntry = {
+ id: string;
+ item: ItemSummary;
+ members: Array<{ item: ItemSummary; level: number }>;
+};
+
+export type ItemCatalogGroup = {
+ entries: ItemCatalogEntry[];
+ key: string;
+ label: string;
+};
+
+export type ItemCatalogZone = {
+ groups: ItemCatalogGroup[];
+ key: "basic" | "crafted" | "neutral";
+ label: string;
+};
+
+const categoryDefinitions: Record = {
+ official_quality_0: { groupLabel: "消耗用品", rank: 10, zone: "basic" },
+ official_quality_1: { groupLabel: "基础组件", rank: 11, zone: "basic" },
+ official_quality_6: { groupLabel: "神秘商店", rank: 12, zone: "basic" },
+ official_quality_2: { groupLabel: "常规装备", rank: 20, zone: "crafted" },
+ official_quality_3: { groupLabel: "进阶装备", rank: 21, zone: "crafted" },
+ official_quality_4: { groupLabel: "高阶装备", rank: 22, zone: "crafted" },
+ official_quality_5: { groupLabel: "特殊装备", rank: 23, zone: "crafted" },
+};
+
+const zoneOrder: ItemCatalogZone["key"][] = ["basic", "crafted", "neutral"];
+const zoneLabels: Record = {
+ basic: "基础分类",
+ crafted: "合成分类",
+ neutral: "中立物品",
+};
+
+export function buildItemCatalogEntries(items: ItemSummary[]): ItemCatalogEntry[] {
+ const currentItems = items.filter((item) => item.kind !== "recipe");
+ const itemByName = new Map(currentItems.map((item) => [item.name, item]));
+ const suffixesByBase = new Map>();
+
+ for (const item of currentItems) {
+ const match = /^(.*)_(\d+)$/.exec(item.name);
+ const level = match ? Number(match[2]) : 0;
+ if (!match || level < 2 || !Number.isSafeInteger(level)) continue;
+ const levels = suffixesByBase.get(match[1]!) ?? new Map();
+ levels.set(level, item);
+ suffixesByBase.set(match[1]!, levels);
+ }
+
+ const familyByItemId = new Map();
+ for (const [baseName, suffixes] of suffixesByBase) {
+ const base = itemByName.get(baseName);
+ const maxLevel = Math.max(...suffixes.keys());
+ if (!base || maxLevel < 2) continue;
+ const members = [{ item: base, level: 1 }];
+ let valid = true;
+ for (let level = 2; level <= maxLevel; level += 1) {
+ const member = suffixes.get(level);
+ if (!member || member.localizedName !== base.localizedName) {
+ valid = false;
+ break;
+ }
+ members.push({ item: member, level });
+ }
+ if (!valid || members.length !== suffixes.size + 1) continue;
+ const family = { id: base.id, item: base, members };
+ for (const member of members) familyByItemId.set(member.item.id, family);
+ }
+
+ const entries: ItemCatalogEntry[] = [];
+ const emitted = new Set();
+ for (const item of currentItems) {
+ const family = familyByItemId.get(item.id);
+ const entry = family ?? { id: item.id, item, members: [{ item, level: 1 }] };
+ if (emitted.has(entry.id)) continue;
+ emitted.add(entry.id);
+ entries.push(entry);
+ }
+ return entries;
+}
+
+export function groupItemCatalogEntries(entries: ItemCatalogEntry[]): ItemCatalogZone[] {
+ const groups = new Map();
+ for (const entry of entries) {
+ const descriptor = itemGroupDescriptor(entry.item);
+ const groupKey = `${descriptor.zone}:${descriptor.key}`;
+ const existing = groups.get(groupKey);
+ if (existing) existing.entries.push(entry);
+ else groups.set(groupKey, { entries: [entry], key: descriptor.key, label: descriptor.label, rank: descriptor.rank, zone: descriptor.zone });
+ }
+ return zoneOrder.map((zone) => ({
+ key: zone,
+ label: zoneLabels[zone],
+ groups: [...groups.values()]
+ .filter((group) => group.zone === zone)
+ .sort((left, right) => left.rank - right.rank || left.label.localeCompare(right.label, "zh-CN")),
+ }));
+}
+
+export function findItemCatalogEntry(entries: ItemCatalogEntry[], itemId: string | undefined): ItemCatalogEntry | undefined {
+ return itemId ? entries.find((entry) => entry.members.some((member) => member.item.id === itemId)) : undefined;
+}
+
+export function filterItemCatalogEntries(entries: ItemCatalogEntry[], query: string | undefined): ItemCatalogEntry[] {
+ const search = query?.trim().toLocaleLowerCase();
+ if (!search) return entries;
+ return entries.filter((entry) => entry.members.some(({ item }) => (
+ item.name.toLocaleLowerCase().includes(search) || item.localizedName.toLocaleLowerCase().includes(search)
+ )));
+}
+
+export function itemCatalogHref(itemId: string, q?: string): string {
+ const params = new URLSearchParams();
+ if (q) params.set("q", q);
+ params.set("selected", itemId);
+ return `/items?${params.toString()}`;
+}
+
+export function levelAttributeValues(details: ItemDetail[], attributeIndex: number): string[] | null {
+ if (details.length < 2) return null;
+ const label = details[0]?.attributes[attributeIndex]?.label;
+ if (!label) return null;
+ const occurrence = details[0]!.attributes.slice(0, attributeIndex + 1).filter((attribute) => attribute.label === label).length - 1;
+ const values = details.map((detail) => detail.attributes.filter((attribute) => attribute.label === label)[occurrence]?.value);
+ const first = values[0];
+ if (!first) return null;
+ const tokens = first.split(/\s*\/\s*/).map((token) => token.trim());
+ if (tokens.length === details.length && tokens.every(Boolean)) return tokens;
+ if (values.every((value): value is string => typeof value === "string" && value.length > 0 && !value.includes("/"))) {
+ return new Set(values).size > 1 ? values : null;
+ }
+ return null;
+}
+
+function itemGroupDescriptor(item: ItemSummary) {
+ if (item.kind === "neutral_enhancement") {
+ return { key: "neutral_enhancement", label: "中立附魔", rank: 99, zone: "neutral" as const };
+ }
+ if (item.kind === "neutral_item") {
+ const tier = /^neutral_tier_(\d+)$/.exec(item.category)?.[1];
+ return tier
+ ? { key: `neutral_${tier}`, label: `${tier} 级`, rank: 80 + Number(tier), zone: "neutral" as const }
+ : { key: "neutral", label: "其他中立物品", rank: 98, zone: "neutral" as const };
+ }
+ const category = categoryDefinitions[item.category];
+ return category
+ ? { key: item.category, label: category.groupLabel, rank: category.rank, zone: category.zone }
+ : { key: "other", label: "其他当前物品", rank: 100, zone: "crafted" as const };
+}
diff --git a/apps/web/test/api.test.ts b/apps/web/test/api.test.ts
index e163303..8bb181f 100644
--- a/apps/web/test/api.test.ts
+++ b/apps/web/test/api.test.ts
@@ -132,12 +132,17 @@ describe("server API client", () => {
primaryAttribute: "strength" as const,
roles: [],
});
+ const firstPage = Array.from({ length: 100 }, (_, index) => hero(String(index + 1)));
+ const secondPage = Array.from({ length: 27 }, (_, index) => hero(String(index + 101)));
const fetchMock = vi.fn()
- .mockResolvedValueOnce(new Response(JSON.stringify({ data: { items: [hero("1")], nextCursor: "page-2" }, meta }), { status: 200, headers: { "Content-Type": "application/json" } }))
- .mockResolvedValueOnce(new Response(JSON.stringify({ data: { items: [hero("2")], nextCursor: null }, meta }), { status: 200, headers: { "Content-Type": "application/json" } }));
+ .mockResolvedValueOnce(new Response(JSON.stringify({ data: { items: firstPage, nextCursor: "page-2" }, meta }), { status: 200, headers: { "Content-Type": "application/json" } }))
+ .mockResolvedValueOnce(new Response(JSON.stringify({ data: { items: secondPage, nextCursor: null }, meta }), { status: 200, headers: { "Content-Type": "application/json" } }));
vi.stubGlobal("fetch", fetchMock);
- await expect(collectAllHeroesWithMeta()).resolves.toMatchObject({ items: [{ id: "1" }, { id: "2" }], meta });
+ const result = await collectAllHeroesWithMeta();
+ expect(result.items).toHaveLength(127);
+ expect(result.items.at(-1)?.id).toBe("127");
+ expect(result.meta).toEqual(meta);
expect(fetchMock).toHaveBeenNthCalledWith(2, expect.stringContaining("cursor=page-2"), expect.anything());
});
diff --git a/apps/web/test/item-catalog.test.ts b/apps/web/test/item-catalog.test.ts
new file mode 100644
index 0000000..8d93bb4
--- /dev/null
+++ b/apps/web/test/item-catalog.test.ts
@@ -0,0 +1,108 @@
+import type { ItemDetail, ItemSummary } from "@dodo/contracts";
+import { describe, expect, it } from "vitest";
+
+import { buildItemCatalogEntries, filterItemCatalogEntries, findItemCatalogEntry, itemCatalogHref, levelAttributeValues } from "../lib/item-catalog";
+
+function item(id: string, name: string, localizedName: string): ItemSummary {
+ return { availabilityStatus: "verified_current", category: "official_quality_4", cost: 1, id, kind: "item", localizedName, name, officialVersion: "7.41d" };
+}
+
+function detail(summary: ItemSummary, value: string): ItemDetail {
+ return { ...summary, attributes: [{ label: "伤害", value }], components: [], description: "", sourceSnapshot: "fixture" };
+}
+
+describe("item catalog upgrade families", () => {
+ it("folds a continuous, same-name Dagon chain without deleting its entities", () => {
+ const dagons = [
+ item("1", "dagon", "达贡之神力"),
+ item("2", "dagon_2", "达贡之神力"),
+ item("3", "dagon_3", "达贡之神力"),
+ item("4", "dagon_4", "达贡之神力"),
+ item("5", "dagon_5", "达贡之神力"),
+ ];
+ const entries = buildItemCatalogEntries(dagons);
+
+ expect(entries).toHaveLength(1);
+ expect(entries[0]?.members.map(({ item: member, level }) => [member.id, level])).toEqual([
+ ["1", 1], ["2", 2], ["3", 3], ["4", 4], ["5", 5],
+ ]);
+ expect(findItemCatalogEntry(entries, "4")?.id).toBe("1");
+ });
+
+ it("folds the two-level Boots of Travel chain", () => {
+ const entries = buildItemCatalogEntries([
+ item("48", "travel_boots", "远行鞋"),
+ item("220", "travel_boots_2", "远行鞋"),
+ ]);
+ expect(entries).toHaveLength(1);
+ expect(entries[0]?.members.map(({ level }) => level)).toEqual([1, 2]);
+ });
+
+ it("reports 263 visible entries for the production-shaped 268 entity inventory", () => {
+ const dagons = Array.from({ length: 5 }, (_, index) => item(String(104 + index), index === 0 ? "dagon" : `dagon_${index + 1}`, "达贡之神力"));
+ const travelBoots = [item("48", "travel_boots", "远行鞋"), item("220", "travel_boots_2", "远行鞋")];
+ const fixedItems = Array.from({ length: 261 }, (_, index) => item(`fixed-${index}`, `current_fixed_item_${index}`, `固定物品 ${index}`));
+ const entities = [...dagons, ...travelBoots, ...fixedItems];
+
+ expect(entities).toHaveLength(268);
+ expect(buildItemCatalogEntries(entities)).toHaveLength(263);
+ });
+
+ it("does not fold missing levels, different localized names, recipes, or fixed BKB text", () => {
+ const entries = buildItemCatalogEntries([
+ item("1", "dagon", "达贡之神力"),
+ item("2", "dagon_2", "达贡之神力"),
+ item("4", "dagon_4", "达贡之神力"),
+ item("bkb", "black_king_bar", "黑皇杖"),
+ { ...item("r", "recipe_dagon", "达贡之神力图纸"), kind: "recipe" },
+ ]);
+
+ expect(entries.map((entry) => entry.item.name)).toEqual(["dagon", "dagon_2", "dagon_4", "black_king_bar"]);
+ expect(entries.find((entry) => entry.id === "bkb")?.members).toHaveLength(1);
+ });
+
+ it("only tokenizes slash values when a recognized multi-level family supplies the level count", () => {
+ const base = item("1", "dagon", "达贡之神力");
+ const levels = Array.from({ length: 5 }, (_, index) => detail({ ...base, id: String(index + 1) }, "400 / 500 / 600 / 700 / 800"));
+ expect(levelAttributeValues(levels, 0)).toEqual(["400", "500", "600", "700", "800"]);
+ expect(levelAttributeValues([detail(item("bkb", "black_king_bar", "黑皇杖"), "9 / 8 / 7")], 0)).toBeNull();
+ });
+
+ it("keeps repeated Dagon attribute labels aligned by occurrence", () => {
+ const base = item("1", "dagon", "达贡之神力");
+ const levels = Array.from({ length: 5 }, (_, index): ItemDetail => ({
+ ...base,
+ id: String(index + 1),
+ attributes: [
+ { label: "施法距离:", value: "640" },
+ { label: "施法距离:", value: "+60 / +90 / +120 / +150 / +180" },
+ ],
+ components: [],
+ description: "",
+ sourceSnapshot: "fixture",
+ }));
+
+ expect(levelAttributeValues(levels, 0)).toBeNull();
+ expect(levelAttributeValues(levels, 1)).toEqual(["+60", "+90", "+120", "+150", "+180"]);
+ });
+
+ it("preserves search and selected item context in catalog URLs", () => {
+ expect(itemCatalogHref("4", "达贡 / dagon")).toBe("/items?q=%E8%BE%BE%E8%B4%A1+%2F+dagon&selected=4");
+ expect(itemCatalogHref("4")).toBe("/items?selected=4");
+ });
+
+ it("filters after folding so a level-specific search keeps the complete upgrade family", () => {
+ const entries = buildItemCatalogEntries([
+ item("1", "dagon", "达贡之神力"),
+ item("2", "dagon_2", "达贡之神力"),
+ item("3", "dagon_3", "达贡之神力"),
+ item("4", "dagon_4", "达贡之神力"),
+ item("5", "dagon_5", "达贡之神力"),
+ item("bkb", "black_king_bar", "黑皇杖"),
+ ]);
+
+ const filtered = filterItemCatalogEntries(entries, "DAGON_4");
+ expect(filtered).toHaveLength(1);
+ expect(filtered[0]?.members.map(({ level }) => level)).toEqual([1, 2, 3, 4, 5]);
+ });
+});
diff --git a/apps/web/test/ui-contract.test.ts b/apps/web/test/ui-contract.test.ts
index 84868bc..860166e 100644
--- a/apps/web/test/ui-contract.test.ts
+++ b/apps/web/test/ui-contract.test.ts
@@ -156,6 +156,7 @@ describe("Web UI copy and touch contract", () => {
const heroList = source("../app/heroes/page.tsx");
const heroDetail = source("../app/heroes/[heroId]/page.tsx");
const itemList = source("../app/items/page.tsx");
+ const itemCatalog = source("../lib/item-catalog.ts");
const itemDetail = source("../app/items/[itemId]/page.tsx");
const patchesPage = source("../app/patches/page.tsx");
@@ -166,7 +167,7 @@ describe("Web UI copy and touch contract", () => {
expect(heroList).toContain("collectAllHeroesWithMeta");
expect(itemList).toContain("collectAllItemsWithMeta");
expect(itemList).toContain('item.kind !== "recipe"');
- expect(itemList).toContain('label: "其他当前物品"');
+ expect(itemCatalog).toContain('label: "其他当前物品"');
expect(itemDetail).toContain('item.data.availabilityStatus === "unverified"');
expect(itemDetail).toContain("官方定义存在不等于当前商店可购买");
expect(patchesPage).toContain("updatedAt={detail.meta.updatedAt}");
@@ -180,7 +181,7 @@ describe("Web UI copy and touch contract", () => {
const patchesPage = source("../app/patches/page.tsx");
expect(heroList).toContain(" {
const heroList = source("../app/heroes/page.tsx");
const heroBrowser = source("../components/hero-catalog-browser.tsx");
const itemList = source("../app/items/page.tsx");
+ const css = source("../app/globals.css");
expect(heroList).toContain("collectAllHeroesWithMeta(query.q)");
for (const attribute of ["strength", "agility", "intelligence", "universal"]) {
@@ -201,9 +203,34 @@ describe("Web UI copy and touch contract", () => {
expect(heroBrowser).toContain('useState<"official" | "custom">("official")');
expect(heroBrowser).toContain("readHeroGroups(window.localStorage)");
expect(heroBrowser).toContain("writeHeroGroups(window.localStorage, customGroups)");
- expect(itemList).toContain("groupCurrentItems(currentItems)");
- expect(itemList).toContain('kind === "neutral_enhancement"');
- expect(itemList).toContain('kind === "neutral_item"');
+ expect(itemList).toContain("buildItemCatalogEntries(result.items.filter((item) => item.kind !== \"recipe\"))");
+ expect(itemList).toContain("filterItemCatalogEntries(");
+ expect(itemList).toContain("groupItemCatalogEntries(entries)");
+ expect(itemList).toContain("itemCatalogHref(entry.item.id, query.q)");
+ expect(itemList).toContain("{currentItems.length} 个实体 / {entries.length} 个目录入口");
+ expect(css).toMatch(/\.hero-armory-tile__image\s*\{[^}]*aspect-ratio:\s*3\s*\/\s*4;[^}]*object-fit:\s*cover;/s);
+ expect(css).toMatch(/@media \(min-width: 64rem\)[\s\S]*?\.hero-attribute-groups\s*\{[^}]*grid-template-columns:\s*repeat\(4,/s);
+ expect(css).toMatch(/@media \(min-width: 64rem\)[\s\S]*?\.hero-armory-grid\s*\{[^}]*grid-template-columns:\s*repeat\(6,/s);
+ });
+
+ it("keeps the item inspector immediate on small screens and sticky on desktop", () => {
+ const css = source("../app/globals.css");
+ const itemList = source("../app/items/page.tsx");
+ const itemPanel = source("../components/item-detail-panel.tsx");
+ const itemDetail = source("../app/items/[itemId]/page.tsx");
+
+ expect(css).toMatch(/\.item-workbench\s*\{[^}]*display:\s*flex;[^}]*flex-direction:\s*column;/s);
+ expect(css).toMatch(/\.item-inspector\s*\{[^}]*order:\s*-1;/s);
+ expect(css).toMatch(/@media \(min-width: 75rem\)[\s\S]*?\.item-inspector\s*\{[^}]*position:\s*sticky;[^}]*order:\s*0;/s);
+ expect(css).toContain(".item-inspector > .dodo-meta-line");
+ expect(itemList).toContain("findItemCatalogEntry(entries, query.selected)");
+ expect(itemPanel).toContain("const isUpgradeFamily = entry.members.length > 1");
+ expect(itemPanel).toContain("const values = hasCompleteFamilyDetails ? levelAttributeValues(familyDetails, index) : null");
+ expect(itemPanel).toContain("升级族已识别,但部分等级详情暂不可用");
+ expect(itemDetail).toContain("const isUpgradeFamily = Boolean(familyEntry && familyEntry.members.length > 1)");
+ expect(itemDetail).toContain("物品目录暂不可用");
+ expect(itemDetail).toContain("levelAttributeValues(familyDetails, index)");
+ expect(itemDetail).toContain('className="item-level-switcher item-level-switcher--detail"');
});
it("shows the official hero profile and base-stat fields in a responsive reference grid", () => {
diff --git a/docs/orchestration/workboard.md b/docs/orchestration/workboard.md
index f8c1f93..85c70db 100644
--- a/docs/orchestration/workboard.md
+++ b/docs/orchestration/workboard.md
@@ -8,6 +8,31 @@
## Active wave
+### Wave 15: Dota-client catalog composition
+
+以用户提供的 Dota 2 客户端英雄名册和物品训练页截图为视觉参考,不复制客户端装饰素材。本波只调整 Web 信息架构与交互;现有 127 个英雄、268 个当前物品、API 合同和数据过滤保持不变。
+
+| Task | Owner | Scope | Depends on | State |
+|---|---|---|---|---|
+| ROOT-019 reference layout contract | Root | 参考图分析、PRD 与视觉合同;升级族定义 | Wave 14 | ACCEPTED |
+| WEB-019 catalog composition | Frontend/Web Agent | `apps/web/**`、`packages/ui/**`;四列英雄名册、物品工作台、常驻详情 | ROOT-019 | ACCEPTED |
+| QA-019 visual and upgrade acceptance | QA Agent | 只读;桌面、390px、键盘、达贡/黑皇杖对照 | WEB-019 | ACCEPTED |
+| DEPLOY-019 overseas rollout | Root | 全仓门禁、Vercel、生产 smoke | QA-019 | IN_PROGRESS |
+
+验收目标:
+
+- 宽屏英雄目录按力量、敏捷、智力、全才并列成四个纯头像矩阵,127 个英雄全部可达;姓名仍可被键盘和辅助技术读取。
+- 宽屏物品页同时呈现基础分类、合成分类、中立物品和常驻详情;选择物品保留搜索上下文,详情面板在长目录中可持续访问。
+- 达贡在目录中只占一个升级族入口,详情提供 1–5 级选择;多级属性按斜杠显示并高亮所选等级。黑皇杖等固定物品保持单值展示。
+- 390px 不横向溢出,不删除目录、详情、升级等级选择或自定义英雄分组功能。
+
+验收证据:
+
+- 生产形态快照为 127 位英雄(力量/敏捷/智力/全才 `36/35/34/22`)和 268 个物品实体 / 263 个目录入口。
+- 严格升级族仅识别 Dagon 1–5 与远行鞋 1–2;BKB 保持固定物品,不将说明内的斜杠值误判为等级。
+- `dagon_4` 搜索仍返回完整五级族;某个 sibling 详情失败时保留族身份与导航,并显式标记部分不可用。
+- QA 结论无 P0/P1;Web typecheck、16 个测试文件 / 102 项测试、production build 与 `git diff --check` 通过。
+
### Wave 14: Complete catalogs and Dota-style information architecture
地图与动态胜率继续不在范围内。英雄目录必须消费全部游标页;物品目录必须在数据源边界收敛到当前普通比赛可见内容;前端采用 Dota 客户端式紧凑层级,但不复制受版权保护的客户端面板素材。
diff --git a/docs/prd/mvp.md b/docs/prd/mvp.md
index 7de3064..6c0f1a2 100644
--- a/docs/prd/mvp.md
+++ b/docs/prd/mvp.md
@@ -18,6 +18,10 @@
物品百科只面向当前普通比赛中有正向可见性证据的商店物品、当前中立物品与当前中立附魔。图纸、旧版本遗留、活动、测试、英雄专属和其他内部定义不进入目录或详情路由;“官方 datafeed 中仍有定义”不是当前可见性的充分证据。过滤必须发生在数据源标准化与快照替换边界,不能只靠前端隐藏。
+英雄目录的宽屏信息架构采用四个官方属性并列的纯头像名册,避免在每个头像下重复常驻名称、攻击类型和定位;键盘焦点、悬停和竖屏布局仍必须提供可识别名称。物品目录的宽屏信息架构采用基础分类、合成分类、中立物品与常驻详情四个区域,物品选择不得丢失当前搜索和目录上下文。
+
+官方以同一名称和递增内部后缀提供的升级物品(例如 `dagon`、`dagon_2` 至 `dagon_5`)在目录中折叠为一个升级族入口,但底层实体和详情路由继续保留。搜索在升级族构建后执行;任一等级的中文名或内部名命中时,结果仍保留完整等级族。详情面板提供等级选择,并按官方顺序用斜杠紧凑展示各级属性值,当前等级同时使用字重或明度高亮。固定物品不根据普通说明中的斜杠文本推断升级族。
+
## P0 pages
- 账号查询与同步页。