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 ( -
+
@@ -23,75 +26,95 @@ export default async function ItemsPage({ searchParams }: { searchParams: Promis
- {!result.ok ? : (() => { - const currentItems = result.value.items.filter((item) => item.kind !== "recipe"); - const groups = groupCurrentItems(currentItems); - return ( -
-
-
CURRENT ITEM SHOP

{query.q ? `“${query.q}” 的物品结果` : "当前物品目录"}

- -
- - {currentItems.length === 0 ? ( - - ) : ( -
- {groups.map((group) => ( -
-
SHOP CATEGORY

{group.label}

{group.items.length}
-
- {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 ( +
+
+
+ CURRENT ITEM SHOP +

{query.q ? `“${query.q}” 的物品结果` : "当前物品目录"}

+

{currentItems.length} 个实体 / {entries.length} 个目录入口

+
+ +
+ +
+
+ {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 - 账号查询与同步页。