This commit is contained in:
2026-08-19 19:45:04 +08:00
parent 4de893ca94
commit cd24d68481
37 changed files with 1937 additions and 746 deletions
+70
View File
@@ -0,0 +1,70 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
## Users
- 主要用户(依据仓库功能推断,待产品负责人确认):需要比较 DNF 装备搭配、打造方案与技能输出的玩家。
- 核心使用场景:在角色、装备和技能配置之间反复调整,快速比较伤害、冷却与装备效果结果。
## Product Purpose
DCalc 是免费的 DNF 装备搭配与伤害计算工具。它把装备选择、技能设置、打造设置和结果计算集中在同一工作流中,减少玩家手工换算和跨页面比对的成本。
成功意味着用户可以完整配置一个角色方案,理解关键计算结果,并可靠地比较不同搭配,而不是只得到缺少上下文的单一总分。
## Positioning
DCalc 将 DNF 的装备、技能、打造和效果数据组织为可交互的角色计算工作台,并保留接近游戏客户端的视觉与数据语义。该定位来自当前仓库实现;对外传播口径仍待产品负责人确认。
## Operating Context
- 用户围绕角色进行装备、技能与打造配置,并在结果区查看技能伤害、技能等级、技能冷却、装备特效及效果点数。
- `frontend` 是既有业务行为基线;`frontend-ui` 是独立、渐进式的 Vue 3 UI 重构目标。
- `/components` 是组件库的可执行目录页,承担交互状态、双主题、窄屏与 Portal 回归验证。
- 产品包含大量 DNF 原始像素素材、品质颜色和紧凑数据表,桌面密集操作是当前明确的主场景;移动 Web 需要可访问和无页面级横向溢出,但不是把所有组件放大为通用移动应用风格。
## Capabilities and Constraints
- 已实现:武器、套装、贴膜、通宝、称号宠物、秘宝选择。
- 已实现:技能等级、觉醒绑定和技能次数设置;技能次数自动计算与 Buff 数值设置仍未完成。
- 已实现:增幅、强化、锻造、徽章、附魔、调适、时装、皮肤、光环、宠物装备、辟邪玉、勋章及杂项设置。
- 已实现:技能伤害、技能等级、技能冷却、装备特效、装备效果/点数和角色基础信息计算。
- `frontend-ui` 使用 Vue 3、TypeScript、Vue Router、Pinia、Vite、Reka UI、Tailwind CSS 4 与语义 CSS Variables。
- 通用 UI 组件不得承载 API、Store、路由或 DNF 业务规则;这些逻辑留在页面、领域组件与 composable 中。
- 迁移必须保留既有业务行为,并将 `design` 目录中的结构、状态、动效与真实素材作为视觉事实来源。
## Brand Commitments
- 产品名称为 DCalc / DNF计算器。
- 当前界面保留 DNF 客户端式的暗色、黄铜、蓝黑面板与像素素材语言。
- 品质颜色是数据语义,不得当作普通装饰色随意替换。
- 第二套 `midnight` 主题用于验证组件没有耦合到经典 DCalc 皮肤,而不是另起一套组件实现。
## Evidence on Hand
- 产品能力与未完成项:`README.md`。
- 新旧前端边界、技术栈、主题模型与组件目录:`frontend-ui/README.md`。
- 迁移和验收规则:`frontend-ui/docs/ui-migration-standard.md`。
- 视觉实现基线与素材:`design/src-web`。
- 当前组件实现与语义 Token:`frontend-ui/src/components/ui`、`frontend-ui/src/style`。
- 仓库内没有可用于宣传的真实用户数据、性能基准、合作方背书或商业承诺;后续界面不得虚构这些内容。
## Product Principles
1. 计算可信度优先:配置、边界和结果必须可追踪、可复核。
2. 保留业务完整性:UI 迁移不能丢失既有操作、快捷方式或数据语义。
3. 密集但可扫描:为高频比较保留紧凑信息密度,同时建立清晰层级和稳定状态反馈。
4. 组件与业务解耦:交互基础能力进入组件库,DNF 规则留在领域层。
5. 真实素材优先:存在可靠原始素材时使用原素材,不用近似图标或通用模板替代。
## Accessibility & Inclusion
- 所有交互组件需要可读名称、可见焦点和键盘路径。
- 普通文本与背景对比度至少达到 WCAG AA;不可用状态仍承载信息时,不使用不可辨认的低对比文本。
- 触屏窄视口上的独立操作目标至少为 24 × 24 CSS px;标签包裹的选择控件可以由标签提供等效点击面积。
- 动效必须尊重 `prefers-reduced-motion`,Portal 内容必须保持可见、可关闭并正确恢复焦点。
+217
View File
@@ -0,0 +1,217 @@
---
name: DCalc frontend-ui
description: DNF 客户端质感的紧凑型双主题 Vue 组件系统
colors:
canvas-deep: "#050505"
panel-charcoal: "#131515"
row-graphite: "#1f1f1e"
title-steel: "#415272"
control-navy: "#1a2b4e"
border-brass: "#504029"
text-primary: "#f6f8f4"
text-secondary: "#d6d3c6"
text-muted: "#a7a396"
selected-gold: "#daa41d"
focus-cyan: "#4ba7d3"
danger-readable: "#ef8883"
midnight-canvas: "#080b12"
midnight-panel: "#111827"
midnight-accent: "#315c97"
typography:
body:
fontFamily: "dcalc, SimSun, serif"
fontSize: "12px"
fontWeight: 400
lineHeight: 1.4
control:
fontFamily: "dcalc, SimSun, serif"
fontSize: "13px"
fontWeight: 400
lineHeight: 1
catalog-title:
fontFamily: "Segoe UI, Microsoft YaHei, sans-serif"
fontSize: "clamp(24px, 2vw, 30px)"
fontWeight: 650
lineHeight: 1.1
rounded:
sm: "2px"
md: "2px"
lg: "2px"
spacing:
compact: "4px"
sm: "7px"
md: "10px"
lg: "14px"
components:
control-default:
backgroundColor: "{colors.control-navy}"
textColor: "{colors.text-primary}"
rounded: "{rounded.sm}"
height: "30px"
padding: "0 14px"
control-compact:
backgroundColor: "{colors.control-navy}"
textColor: "{colors.text-secondary}"
rounded: "{rounded.sm}"
height: "24px"
padding: "0 10px"
data-row:
backgroundColor: "{colors.row-graphite}"
textColor: "{colors.text-secondary}"
rounded: "{rounded.sm}"
height: "38px"
---
# Design System: DCalc frontend-ui
## Overview
**Creative North Star: "游戏内的精密计算台"**
当前系统不是通用 SaaS 皮肤,而是一套面向 DNF 密集配置与结果比对的操作界面。视觉语言来自游戏客户端:深黑工作区、蓝黑控件、黄铜边缘、像素素材和高密度数据。装饰必须服务于状态识别,不能遮盖数值、品质和选择关系。
`dcalc` 与 `midnight` 共用同一组件和 `--ui-*` 语义契约。经典主题保留黄铜与金色强调,Midnight 主题转为冷蓝层级;组件禁止按主题名分支。
**Key Characteristics:**
- 紧凑、像素对齐、数据优先。
- 近方形轮廓与一像素分层边缘。
- 品质色、选择色、焦点色和状态色职责分离。
- 原始位图素材与 CSS 构造图形并存,但不使用近似 emoji 或随意替代图标。
- 组件目录是可交互回归面,不是静态样例墙。
## Colors
经典 DCalc 使用暖金和黄铜在近黑表面上建立层级,Midnight 使用冷蓝映射相同语义。
### Primary
- **控制台深蓝:**用于主控件静止态与可操作区域,保持沉稳,不扩散成大面积装饰。
- **选中金:**只表达已选内容和关键数值,不兼任键盘焦点。
- **焦点青:**只表达 focus-visible、输入聚焦和可访问性定位。
### Secondary
- **黄铜边缘:**用于经典主题中的边框、分隔和像素层次。
- **午夜蓝:**用于第二主题的激活面、浮层和控件状态,保持与经典主题相同的信息结构。
### Tertiary
- **可读危险色:**用于深色表面上的失败与危险文本;破坏性按钮表面继续使用独立的 destructive Token。
### Neutral
- **深黑画布:**页面最底层背景。
- **炭黑面板:**承载组件组、表格和对话框内容。
- **主文本 / 次文本 / 弱文本:**形成三级阅读层级;弱文本仍需达到普通文本对比度要求。
### Named Rules
**The Semantic Boundary Rule.** 组件只消费 `--ui-*` 语义 Token;`--dcalc-*` 只保留物理色和品质数据语义。
**The Rarity Is Data Rule.** 品质颜色代表物品数据,不得用于普通按钮、装饰线或营销强调。
## Typography
**Display Font:** Segoe UI(Microsoft YaHei 回退,仅用于组件目录标题)
**Body Font:** dcalc(SimSun 回退)
**Label/Mono Font:** Consolas(仅用于编号、索引和测量信息)
**Character:** 业务界面以 DCalc 像素字体保持游戏内质感;目录页标题使用清晰的系统无衬线字体承担文档层级。等宽字体只服务于技术索引与数值,不作为“科技感”装饰。
### Hierarchy
- **Catalog title:**中高字重、流式 24–30px,只用于目录页主标题。
- **Panel heading:**由 46px 标题栏承载,文本层级高于内容但不追求超大字号。
- **Control:**13px/1,适用于默认高度控件和密集输入。
- **Body:**12px/1.4,适用于说明、数据标签和表格内容。
- **Micro label:**原则上不低于 10px;低于该值只允许用于已有像素图形内部且同时提供可访问名称的装饰性字形。
### Named Rules
**The Readable Density Rule.** 紧凑来自间距和布局,不以牺牲正文可读性换取密度。
## Layout
页面采用流式网格承载组件组合,像素素材、1px 边框、控件高度和纹理对齐保留物理尺寸。默认控件高度分为 24px、30px 和 44px;页面 gutter、列宽和面板间距使用 `clamp()` 与 `minmax(0, 1fr)` 响应变化。
桌面允许多列高密度工作区;900px 以下将主要双列组合收为单列;520px 以下优先保证独立操作目标和可读顺序。宽表格与状态矩阵使用组件内部横向滚动,页面本身不得出现横向溢出。
应用根到路由页的 Flex/Grid 高度链必须保留 `min-height: 0`,每个页面只有一个主要纵向滚动所有者。Portal 内容不能进入会裁剪它的局部容器。
### Calculation Workspace
角色计算页遵循“配置 → 结果”的连续任务路径。901px 及以上使用两列工作区:左列承载装备、技能或打造配置,右列结果占满剩余空间;角色背景只作为氛围层,不得用空白结构列把输入和结果分隔到视口两端。全局计算开关与导入操作放在顶部导航工具区,避免为少量操作保留整条底栏。
900px 及以下改为单一纵向滚动流:配置在前、结果在后,配置面板按路由使用有界宽度并居中。结果概览与技能表依次排列且都保留独立内部滚动;页面宽度不得超过视口。伤害表使用容器宽度而非浏览器宽度决定信息密度:容器达到 620px 时直接展示技能、CD、次数、秒伤、总伤害和占比,窄容器保留技能、次数和占比三项核心列。
## Elevation & Depth
系统以色调分层、内描边和一像素边缘为主,阴影只用于浮层、选中反馈和必要的空间分离。普通面板不叠加“边框加大面积柔光”的通用卡片效果。
### Shadow Vocabulary
- **Control inset:**一像素内高光,表达游戏控件的凹凸边缘。
- **Popup lift:**带向下偏移的柔和阴影,仅用于 Select、Popover、Menu 与 Tooltip 等浮层。
- **Selected glow:**低范围辉光,只跟随选中或焦点状态,不作为静态装饰。
### Named Rules
**The State Earns Depth Rule.** 深度必须对应浮层、焦点、悬停或选择状态;静止内容默认依靠色调和边缘分层。
## Shapes
组件轮廓以 2px 近方角为默认,配合一像素边框、内描边和少量 `clip-path` 斜切形成游戏客户端的机械感。圆形仅用于单选点、滑块拇指和评分胶囊等具有明确交互语义的部件;普通卡片与面板不使用大圆角或药丸轮廓。
## Components
### Buttons
- **Shape:** 近方角、30px 默认高度,紧凑与大型尺寸分别为 24px 和 44px。
- **Primary:** 蓝黑渐变表面、黄铜边缘和高对比文本;变体由 CVA 管理。
- **Hover / Focus:** 悬停提升边缘与表面亮度;focus-visible 使用语义焦点色;按下只产生 1px 位移。
- **Disabled:** 同时移除可操作暗示并保留可辨识标签,不使用不可读文字伪装禁用。
### Cards / Containers
- **Corner Style:** 2px 近方角或像素斜切。
- **Background:** 画布、面板、行三层深色表面。
- **Shadow Strategy:** 默认平面分层,浮层和状态反馈才使用阴影。
- **Border:** 1px 语义边缘;避免与宽柔光重复表达同一层级。
- **Internal Padding:** 以 4、7、10、14px 为主要密度节奏,具体像素素材可保留源尺寸。
### Inputs / Fields
- **Style:** 30px 默认高度、深色内凹表面、1px 边框与紧凑内边距。
- **Focus:** 边缘切换到 focus Token,并增加一像素可见外沿。
- **Error / Disabled:** 错误和不可用分别使用独立语义前景;不可用信息仍需可读。
### Navigation
导航使用与内容一致的像素字体和表面层级。当前项使用 selected 语义,键盘位置使用 ring 语义;两者不可合并。窄屏允许重新编排,但不改变路由与业务层级。
### Data and Selection Components
表格、树、级联、商店行和物品槽是系统的签名组件。它们保留品质色、像素素材、紧凑行高与明确的 selected/disabled 状态。含有次级操作的可选行必须把“选择”和“查看”实现为两个同级可聚焦操作,禁止在 `option` 内嵌套按钮。
### Portal Components
Select、Autocomplete、Tooltip、DropdownMenu、Popover 与 Dialog 通过 Reka UI 保留键盘和焦点语义。普通浮层使用受控层级 80,Tooltip 使用更高但有界的层级;必须验证四边碰撞、Escape 关闭和焦点恢复。
## Do's and Don'ts
### Do:
- **Do** 使用 Primitive → Semantic 两层 Token,并用同一组件代码验证 DCalc 与 Midnight。
- **Do** 保留真实 PNG、字体、光标、品质色和像素对齐尺寸。
- **Do** 在组件目录展示默认、悬停、按下、焦点、禁用、选中、打开和 reduced-motion 状态。
- **Do** 为图形按钮、评分项和复合行操作提供独立的可读名称与焦点位置。
- **Do** 把连续几何变化交给 CSS Variable 或 transform,把有限变体交给 CVA。
### Don't:
- **Don't** 在通用组件中请求接口、读取业务 Store、绑定路由或加入单页面兼容逻辑。
- **Don't** 按主题名分支组件样式,也不要在组件中直接使用主题专属装饰色。
- **Don't** 用 emoji、近似图标、大圆角卡片或通用 SaaS 模板替代 DNF 视觉事实。
- **Don't** 通过缩小正文、隐藏焦点或使用低对比禁用色来换取紧凑感。
- **Don't** 用构建成功代替浏览器验证;Portal、滚动、键盘、主题和窄屏都必须实际操作。
+7 -3
View File
@@ -221,9 +221,11 @@
const groupPadding = parseFloat(tabsStyle.paddingLeft) + parseFloat(tabsStyle.paddingRight); const groupPadding = parseFloat(tabsStyle.paddingLeft) + parseFloat(tabsStyle.paddingRight);
const itemPadding = parseFloat(buttonStyle.paddingLeft) + parseFloat(buttonStyle.paddingRight); const itemPadding = parseFloat(buttonStyle.paddingLeft) + parseFloat(buttonStyle.paddingRight);
const contentWidth = Math.ceil(widestLabel + itemPadding); const contentWidth = Math.ceil(widestLabel + itemPadding);
const availableWidth = Math.max(0, tabs.clientWidth - groupPadding); const parentWidth = tabs.parentElement?.clientWidth ?? tabs.clientWidth;
const availableWidth = Math.max(0, parentWidth - groupPadding);
const itemWidth = Math.min(contentWidth, availableWidth / labels.length); const itemWidth = Math.min(contentWidth, availableWidth / labels.length);
tabs.style.setProperty('--tab-item-width', `${itemWidth}px`); tabs.style.setProperty('--tab-item-width', `${itemWidth}px`);
tabs.style.setProperty('--tab-group-width', `${itemWidth * labels.length + groupPadding}px`);
}; };
const positionMarker = (tab, animate = true) => { const positionMarker = (tab, animate = true) => {
if (!marker || !tab) return; if (!marker || !tab) return;
@@ -251,10 +253,12 @@
}); });
}); });
if ('ResizeObserver' in window) { if ('ResizeObserver' in window) {
new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
equalizeItems(); equalizeItems();
positionMarker(tabs.querySelector('.game-tab.selected'), false); positionMarker(tabs.querySelector('.game-tab.selected'), false);
}).observe(tabs); });
resizeObserver.observe(tabs);
if (tabs.parentElement) resizeObserver.observe(tabs.parentElement);
} }
}); });
+1 -1
View File
@@ -1,4 +1,4 @@
(() => { (() => {
window.componentRegistry ||= {}; window.componentRegistry ||= {};
window.componentRegistry["src-web/tabs/index.html"] = {"showcase":"<div class=\"panel\">\n <div class=\"panel-heading\"><div class=\"panel-title\">Tabs / Pagination</div><div class=\"panel-note\">CSS 126 hover base + CSS 667 selected overlay · CSS 127/128/180</div></div>\n <div class=\"panel-body\">\n <div class=\"tabs-showcase\">\n <div>\n <div class=\"sample-label\">Tabs · Marker = Arrow · first tab selected</div>\n <div class=\"game-tabs dnf-ui\" data-tabs data-marker=\"arrow\"><button class=\"game-tab selected\"><span class=\"tab-label\">角色</span></button><button class=\"game-tab\"><span class=\"tab-label\">誓约</span></button><button class=\"game-tab\"><span class=\"tab-label\">装扮/宠物</span></button><button class=\"game-tab\"><span class=\"tab-label\">徽章</span></button><span class=\"tab-marker\" aria-hidden=\"true\"></span></div>\n </div>\n <div>\n <div class=\"sample-label\">Tabs · Marker = Line</div>\n <div class=\"game-tabs marker-line dnf-ui\" data-tabs data-marker=\"line\"><button class=\"game-tab selected\"><span class=\"tab-label\">装备</span></button><button class=\"game-tab\"><span class=\"tab-label\">消耗品</span></button><button class=\"game-tab\"><span class=\"tab-label\">材料</span></button><button class=\"game-tab\"><span class=\"tab-label\">副职业</span></button><button class=\"game-tab\"><span class=\"tab-label\">任务</span></button><span class=\"tab-marker tab-marker-line\" aria-hidden=\"true\"></span></div>\n </div>\n </div>\n <div class=\"tabs-pager-row\">\n <div class=\"pager dnf-ui\"><span class=\"pager-btn\">◀</span><span>1 / 15</span><span class=\"pager-btn active\">▶</span><span style=\"color:#8e7b45\">搜索出 142 个</span></div>\n <div class=\"spec-row\"><span class=\"spec-chip\">ITEM WIDTH = EQUALIZED MAX CONTENT</span><span class=\"spec-chip captured\">DEFAULT / HOVER / CLICK CAPTURED</span></div>\n </div>\n </div>\n </div>"}; window.componentRegistry["src-web/tabs/index.html"] = {"showcase":"<div class=\"panel\">\n <div class=\"panel-heading\"><div class=\"panel-title\">Tabs / Pagination</div><div class=\"panel-note\">CSS 126 hover base + CSS 667 selected overlay · CSS 127/128/180</div></div>\n <div class=\"panel-body\">\n <div class=\"tabs-showcase\">\n <div>\n <div class=\"sample-label\">Tabs · Compact 32/24 · Marker = Arrow</div>\n <div class=\"game-tabs dnf-ui\" data-tabs data-marker=\"arrow\"><button class=\"game-tab selected\"><span class=\"tab-label\">角色</span></button><button class=\"game-tab\"><span class=\"tab-label\">誓约</span></button><button class=\"game-tab\"><span class=\"tab-label\">装扮/宠物</span></button><button class=\"game-tab\"><span class=\"tab-label\">徽章</span></button><span class=\"tab-marker\" aria-hidden=\"true\"></span></div>\n </div>\n <div>\n <div class=\"sample-label\">Tabs · Marker = Line</div>\n <div class=\"game-tabs marker-line dnf-ui\" data-tabs data-marker=\"line\"><button class=\"game-tab selected\"><span class=\"tab-label\">装备</span></button><button class=\"game-tab\"><span class=\"tab-label\">消耗品</span></button><button class=\"game-tab\"><span class=\"tab-label\">材料</span></button><button class=\"game-tab\"><span class=\"tab-label\">副职业</span></button><button class=\"game-tab\"><span class=\"tab-label\">任务</span></button><span class=\"tab-marker tab-marker-line\" aria-hidden=\"true\"></span></div>\n </div>\n </div>\n <div class=\"tabs-pager-row\">\n <div class=\"pager dnf-ui\"><span class=\"pager-btn\">◀</span><span>1 / 15</span><span class=\"pager-btn active\">▶</span><span style=\"color:#8e7b45\">搜索出 142 个</span></div>\n <div class=\"spec-row\"><span class=\"spec-chip\">GROUP = CONTENT WIDTH · ITEMS = EQUALIZED MAX CONTENT</span><span class=\"spec-chip captured\">LIST 32 · ITEM 24 · TEXT 14</span></div>\n </div>\n </div>\n </div>"};
})(); })();
+2 -2
View File
@@ -15,7 +15,7 @@
<div class="panel-body"> <div class="panel-body">
<div class="tabs-showcase"> <div class="tabs-showcase">
<div> <div>
<div class="sample-label">Tabs · Marker = Arrow · first tab selected</div> <div class="sample-label">Tabs · Compact 32/24 · Marker = Arrow</div>
<div class="game-tabs dnf-ui" data-tabs data-marker="arrow"><button class="game-tab selected"><span class="tab-label">角色</span></button><button class="game-tab"><span class="tab-label">誓约</span></button><button class="game-tab"><span class="tab-label">装扮/宠物</span></button><button class="game-tab"><span class="tab-label">徽章</span></button><span class="tab-marker" aria-hidden="true"></span></div> <div class="game-tabs dnf-ui" data-tabs data-marker="arrow"><button class="game-tab selected"><span class="tab-label">角色</span></button><button class="game-tab"><span class="tab-label">誓约</span></button><button class="game-tab"><span class="tab-label">装扮/宠物</span></button><button class="game-tab"><span class="tab-label">徽章</span></button><span class="tab-marker" aria-hidden="true"></span></div>
</div> </div>
<div> <div>
@@ -25,7 +25,7 @@
</div> </div>
<div class="tabs-pager-row"> <div class="tabs-pager-row">
<div class="pager dnf-ui"><span class="pager-btn">◀</span><span>1 / 15</span><span class="pager-btn active">▶</span><span style="color:#8e7b45">搜索出 142 个</span></div> <div class="pager dnf-ui"><span class="pager-btn">◀</span><span>1 / 15</span><span class="pager-btn active">▶</span><span style="color:#8e7b45">搜索出 142 个</span></div>
<div class="spec-row"><span class="spec-chip">ITEM WIDTH = EQUALIZED MAX CONTENT</span><span class="spec-chip captured">DEFAULT / HOVER / CLICK CAPTURED</span></div> <div class="spec-row"><span class="spec-chip">GROUP = CONTENT WIDTH · ITEMS = EQUALIZED MAX CONTENT</span><span class="spec-chip captured">LIST 32 · ITEM 24 · TEXT 14</span></div>
</div> </div>
</div> </div>
</div> </div>
+15 -14
View File
@@ -8,12 +8,13 @@
.tabs-showcase { display:grid;grid-template-columns:1fr 1fr;gap:18px; } .tabs-showcase { display:grid;grid-template-columns:1fr 1fr;gap:18px; }
.game-tabs { .game-tabs {
position:relative; position:relative;
width:100%; width:var(--tab-group-width,max-content);
height:40px; max-width:100%;
height:32px;
display:flex; display:flex;
justify-content:flex-start; justify-content:flex-start;
align-items:flex-start; align-items:flex-start;
padding:5px 7px; padding:4px 6px;
overflow:visible; overflow:visible;
border:0; border:0;
border-radius:0; border-radius:0;
@@ -24,9 +25,9 @@
content:""; content:"";
position:absolute; position:absolute;
z-index:3; z-index:3;
left:7px; left:6px;
right:7px; right:6px;
top:35px; top:28px;
height:1px; height:1px;
background:#584840; background:#584840;
pointer-events:none; pointer-events:none;
@@ -54,12 +55,12 @@
align-items:center; align-items:center;
justify-content:center; justify-content:center;
z-index:auto; z-index:auto;
height:30px; height:24px;
padding:0 12px; padding:0 10px;
border:0; border:0;
background:#080808; background:#080808;
color:#9c815a; color:#9c815a;
font:16px/1 "DCalc",SimSun,sans-serif; font:14px/1 "DCalc",SimSun,sans-serif;
text-shadow:1px 1px 0 #000,0 0 2px #000; text-shadow:1px 1px 0 #000,0 0 2px #000;
white-space:nowrap; white-space:nowrap;
cursor:pointer; cursor:pointer;
@@ -67,10 +68,10 @@
.game-tab:not(:last-of-type)::after { .game-tab:not(:last-of-type)::after {
content:""; content:"";
position:absolute; position:absolute;
top:5px; top:4px;
right:0; right:0;
width:1px; width:1px;
height:19px; height:15px;
background:linear-gradient(180deg, background:linear-gradient(180deg,
rgba(52,47,42,.153) 0%, rgba(52,47,42,.153) 0%,
rgba(52,47,42,.376) 5.56%, rgba(52,47,42,.376) 5.56%,
@@ -97,7 +98,7 @@
.game-tab:active { transform:none; } .game-tab:active { transform:none; }
.game-tab:active .tab-label, .game-tab:active .tab-label,
.game-tab.is-pressed .tab-label { transform:translateY(1px); } .game-tab.is-pressed .tab-label { transform:translateY(1px); }
.game-tabs > .tab-marker { position:absolute;z-index:4;left:0;bottom:0;width:39px;height:5px;opacity:0;transform:translateX(-50%);pointer-events:none; } .game-tabs > .tab-marker { position:absolute;z-index:4;left:0;bottom:0;width:33px;height:4px;opacity:0;transform:translateX(-50%);pointer-events:none; }
.game-tabs > .tab-marker::before { .game-tabs > .tab-marker::before {
content:""; content:"";
position:absolute; position:absolute;
@@ -120,8 +121,8 @@
position:absolute; position:absolute;
left:50%; left:50%;
top:1px; top:1px;
width:9px; width:7px;
height:4px; height:3px;
background:linear-gradient(180deg,#9d7a35 0%,#a7823f 46%,#d0a56a 100%); background:linear-gradient(180deg,#9d7a35 0%,#a7823f 46%,#d0a56a 100%);
clip-path:polygon(0 0,100% 0,50% 100%); clip-path:polygon(0 0,100% 0,50% 100%);
transform:translateX(-50%); transform:translateX(-50%);
+22 -1
View File
@@ -8,6 +8,16 @@
- Density normalization: devicePixelRatio 1; no resampling required - Density normalization: devicePixelRatio 1; no resampling required
- State: default catalog with Button state matrix; interactive Dialog state captured separately at `catalog-audit-dialog-animated.png` - State: default catalog with Button state matrix; interactive Dialog state captured separately at `catalog-audit-dialog-animated.png`
## Calculator workbench QA (2026-08-19)
- Design reference: `/Users/xigua/.codex/visualizations/2026/08/19/01a01882-9a2a-7d63-ade8-34a8104c8a6d/calculator-workbench-concept.html`.
- Equipment evidence: `frontend-ui-implemented-equipment-1440.png`; the 1440 px workspace keeps the equipment dictionary, character overview and damage table visible without document-level horizontal overflow.
- Skill evidence: `frontend-ui-implemented-skill-detail-1440.png`; the 778 px configuration rail resolves to a 556 px skill-tree panel and a 210 px resident detail panel, while the 642 px result rail remains independently readable. The standard tree no longer creates a horizontal scrollbar at 1440 px.
- Forge evidence: `frontend-ui-implemented-forge-1440.png`; the existing forge editors remain the source of truth and the new read-only summary rail does not mutate calculation data. Multi-control Sundry rows span the editor width and no longer overflow horizontally.
- Special-job evidence: `frontend-ui-implemented-creator-skill-1440.png` and `frontend-ui-implemented-dark-knight-skill-1440.png`. Creator keeps its original coordinate-based skill-tree structure and extra awakening; Dark Knight keeps its coordinate-based tree plus six shortcuts and six five-stage combo columns. The skill picker is captured at `frontend-ui-implemented-dark-knight-picker-1440.png` and remains inside the 210 px context rail.
- Responsive evidence: document overflow measured 0 px at 1440, 1280 and 390 widths. At 1280 the standard skill tree uses an intentional internal horizontal scroll surface (528 px content in a 471 px viewport); at 390 the context panel is hidden, both main rails measure 371 px, and document width remains 390 px.
- Compact collapsibles (2026-08-19): equipment catalog groups and equipment detail sections now use the shared Reka-based DataSection at a measured 24 px header height. Equipment groups collapse independently while retaining the Forge-style state label. Skill-detail sections use the same compact anatomy with the 13 px indicator on the right and no expanded/collapsed copy; `基本信息` was collapsed independently without hiding `技能描述` or `技能属性`. Browser evidence is captured at `frontend-ui-compact-collapsibles-1440.png`.
## Full-view comparison ## Full-view comparison
The implementation keeps the source board's full-width dark surface, 46 px panel headings, brass dividers, DCalc typography, square corners, compact 30 px controls, and dense state matrices. The catalog intentionally adds a component index, theme controls, and implementation-specific copy; these are catalog navigation rather than component anatomy changes. The implementation keeps the source board's full-width dark surface, 46 px panel headings, brass dividers, DCalc typography, square corners, compact 30 px controls, and dense state matrices. The catalog intentionally adds a component index, theme controls, and implementation-specific copy; these are catalog navigation rather than component anatomy changes.
@@ -16,7 +26,12 @@ The implementation keeps the source board's full-width dark surface, 46 px panel
- Button: three five-column state rows now match the source's Rest, Hover, Pressed, Disabled, and tone-family organization. Panel height is 281.5 px versus the 292 px source module target; the difference is the catalog's smaller bottom spacing and is visually non-material. - Button: three five-column state rows now match the source's Rest, Hover, Pressed, Disabled, and tone-family organization. Panel height is 281.5 px versus the 292 px source module target; the difference is the catalog's smaller bottom spacing and is visually non-material.
- NumberField: 30 px field, 23 px spinner, visible up/down arrows, and two 14 px `+10`/`-10` fast-step buttons match the source anatomy. - NumberField: 30 px field, 23 px spinner, visible up/down arrows, and two 14 px `+10`/`-10` fast-step buttons match the source anatomy.
- Tabs: 40 px list, 30 px triggers, glow layers, divider line, animated 39 × 5 px arrow marker, and line-marker variant are present. Marker movement follows Reka selection state and respects reduced motion. - Tabs: the current compact-density implementation uses a 32 px list, 24 px triggers, 14 px text,
glow layers, divider line, and an animated line marker without a downward triangle by default.
The arrow marker remains available only as an explicit compatibility variant. Each list sizes
itself from its longest label and gives every trigger the same width; marker movement still follows
Reka selection state and respects reduced motion. The cited full-page image predates this compact-density
refresh.
- Dialog: the titlebar uses the exact 45 raster frames from `windowheadereffect.img`, advances every 66 ms while focused, and hides the effect in the unfocused/reduced-motion state. - Dialog: the titlebar uses the exact 45 raster frames from `windowheadereffect.img`, advances every 66 ms while focused, and hides the effect in the unfocused/reduced-motion state.
- TreeSelect: the Reka tree now opens the selected path, uses the source's flush 2 px row rhythm, - TreeSelect: the Reka tree now opens the selected path, uses the source's flush 2 px row rhythm,
eight rarity colors, pixel branch glyph, selected border, and distinct hover/active/focus-visible eight rarity colors, pixel branch glyph, selected border, and distinct hover/active/focus-visible
@@ -39,6 +54,12 @@ The implementation keeps the source board's full-width dark surface, 46 px panel
- NumberField fast step: 1 → 11. - NumberField fast step: 1 → 11.
- Tabs: line marker moved to `来源`; selected state became active. - Tabs: line marker moved to `来源`; selected state became active.
- Tabs compact refresh (2026-08-19): the variable-label catalog group measured 4 × 68 px inside a
284 px list instead of filling its 1155 px parent; the line-marker group measured 3 × 44 px inside
144 px. At 390 px both groups retained equal item widths, 32/24 px heights and zero document-level
horizontal overflow. The character navigation measured 3 × 68 px inside a 216 px list. DCalc and
Midnight themes retained the same geometry. The default marker now uses the line treatment, so
business tabs no longer render a downward triangle unless a caller explicitly requests `arrow`.
- Checkbox: unchecked → checked. - Checkbox: unchecked → checked.
- Switch: checked → unchecked. - Switch: checked → unchecked.
- Rating: 1 → 3. - Rating: 1 → 3.
+2
View File
@@ -108,6 +108,8 @@ export interface ISkill {
name: string name: string
desc: string desc: string
}[] }[]
/** 后端下发的技能树坐标:[列, 行]。缔造者与黑暗武士使用扩展网格。 */
cellPosition: number[]
uuid: string uuid: string
upType: 'damage' | 'heal' upType: 'damage' | 'heal'
} }
@@ -47,6 +47,6 @@ const props = withDefaults(
} }
.ui-data-row.is-disabled { .ui-data-row.is-disabled {
color: var(--ui-control-disabled-foreground); color: var(--ui-unavailable-foreground);
} }
</style> </style>
@@ -12,8 +12,17 @@ const props = withDefaults(
disabled?: boolean disabled?: boolean
expandedLabel?: string expandedLabel?: string
collapsedLabel?: string collapsedLabel?: string
compact?: boolean
showState?: boolean
indicatorPosition?: 'start' | 'end'
}>(), }>(),
{ expandedLabel: '已展开', collapsedLabel: '未展开' }, {
expandedLabel: '已展开',
collapsedLabel: '未展开',
compact: false,
showState: true,
indicatorPosition: 'start',
},
) )
const open = defineModel<boolean>('open', { default: true }) const open = defineModel<boolean>('open', { default: true })
@@ -23,12 +32,21 @@ const open = defineModel<boolean>('open', { default: true })
<CollapsibleRoot <CollapsibleRoot
v-model:open="open" v-model:open="open"
:disabled="disabled" :disabled="disabled"
:class="cn('ui-data-section', props.class)" :class="
cn(
'ui-data-section',
compact && 'ui-data-section--compact',
indicatorPosition === 'end' && 'ui-data-section--indicator-end',
props.class,
)
"
> >
<CollapsibleTrigger class="ui-data-section__trigger"> <CollapsibleTrigger class="ui-data-section__trigger">
<img :src="open ? expandedIcon : collapsedIcon" alt="" aria-hidden="true" /> <img :src="open ? expandedIcon : collapsedIcon" alt="" aria-hidden="true" />
<span class="ui-data-section__title">{{ title }}</span> <span class="ui-data-section__title">{{ title }}</span>
<span class="ui-data-section__state">{{ open ? expandedLabel : collapsedLabel }}</span> <span v-if="showState" class="ui-data-section__state">
{{ open ? expandedLabel : collapsedLabel }}
</span>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent class="ui-data-section__content"> <CollapsibleContent class="ui-data-section__content">
<slot /> <slot />
@@ -99,4 +117,12 @@ const open = defineModel<boolean>('open', { default: true })
.ui-data-section__content { .ui-data-section__content {
padding: var(--ui-disclosure-content-padding-block) var(--ui-field-padding-inline); padding: var(--ui-disclosure-content-padding-block) var(--ui-field-padding-inline);
} }
.ui-data-section--compact .ui-data-section__trigger {
min-height: var(--ui-control-height-compact);
}
.ui-data-section--indicator-end .ui-data-section__trigger > img {
order: 3;
}
</style> </style>
@@ -207,4 +207,11 @@ function fastChange(direction: 1 | -1) {
.ui-number-field__fast-step button + button span { .ui-number-field__fast-step button + button span {
top: 2px; top: 2px;
} }
@media (max-width: 520px) {
.ui-number-field {
height: 50px;
grid-template-columns: minmax(0, 1fr) 25px;
}
}
</style> </style>
@@ -66,7 +66,7 @@ const page = defineModel<number>('page', { default: 1 })
border: 1px solid var(--ui-pagination-border); border: 1px solid var(--ui-pagination-border);
outline: none; outline: none;
background: var(--ui-pagination-background); background: var(--ui-pagination-background);
color: var(--ui-control-disabled-foreground); color: var(--ui-muted-foreground);
cursor: pointer; cursor: pointer;
} }
@@ -82,6 +82,7 @@ const page = defineModel<number>('page', { default: 1 })
} }
.ui-pagination__button:disabled { .ui-pagination__button:disabled {
color: var(--ui-control-disabled-foreground);
cursor: not-allowed; cursor: not-allowed;
opacity: 0.45; opacity: 0.45;
} }
@@ -31,7 +31,10 @@ const visibleMarkers = computed(() => {
<template> <template>
<ProgressRoot v-bind="delegatedProps" :class="cn('ui-progress', props.class)"> <ProgressRoot v-bind="delegatedProps" :class="cn('ui-progress', props.class)">
<ProgressIndicator class="ui-progress__indicator" :style="{ width: `${percentage}%` }" /> <ProgressIndicator
class="ui-progress__indicator"
:style="{ transform: `scaleX(${percentage / 100})` }"
/>
<span <span
v-for="milestone in visibleMarkers" v-for="milestone in visibleMarkers"
:key="milestone" :key="milestone"
@@ -52,10 +55,12 @@ const visibleMarkers = computed(() => {
} }
.ui-progress__indicator { .ui-progress__indicator {
width: 100%;
height: 100%; height: 100%;
background: var(--ui-progress-fill); background: var(--ui-progress-fill);
box-shadow: inset 0 0 0 1px var(--ui-progress-fill-inset); box-shadow: inset 0 0 0 1px var(--ui-progress-fill-inset);
transition: width 180ms ease-out; transform-origin: left center;
transition: transform 180ms ease-out;
} }
.ui-progress__marker { .ui-progress__marker {
@@ -68,4 +73,10 @@ const visibleMarkers = computed(() => {
border-left: 4px solid transparent; border-left: 4px solid transparent;
transform: translateX(-50%); transform: translateX(-50%);
} }
@media (prefers-reduced-motion: reduce) {
.ui-progress__indicator {
transition: none;
}
}
</style> </style>
@@ -26,7 +26,13 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template> <template>
<RatingRoot v-bind="forwarded" :class="cn('ui-rating', props.class)" :aria-label="props.label"> <RatingRoot v-bind="forwarded" :class="cn('ui-rating', props.class)" :aria-label="props.label">
<RatingItem v-for="item in props.length" :key="item" :item="item" class="ui-rating__item"> <RatingItem
v-for="item in props.length"
:key="item"
:item="item"
class="ui-rating__item"
:aria-label="`${props.label}:${item} / ${props.length}`"
>
<img :src="emptyStar" alt="" /> <img :src="emptyStar" alt="" />
<RatingItemIndicator :step="item" class="ui-rating__indicator"> <RatingItemIndicator :step="item" class="ui-rating__indicator">
<img :src="activeStar" alt="" /> <img :src="activeStar" alt="" />
@@ -31,7 +31,7 @@ function selectRow(row: UiShopItem) {
</script> </script>
<template> <template>
<div :class="cn('ui-shop-list', props.class)" role="listbox" aria-label="物品列表"> <div :class="cn('ui-shop-list', props.class)" role="grid" aria-label="物品列表">
<div class="ui-shop-list__surface"> <div class="ui-shop-list__surface">
<div <div
v-for="(row, index) in rows" v-for="(row, index) in rows"
@@ -42,7 +42,7 @@ function selectRow(row: UiShopItem) {
'is-selected': selected === row.id, 'is-selected': selected === row.id,
'is-disabled': row.disabled, 'is-disabled': row.disabled,
}" }"
role="option" role="row"
:aria-selected="selected === row.id" :aria-selected="selected === row.id"
:aria-disabled="row.disabled || undefined" :aria-disabled="row.disabled || undefined"
:tabindex="row.disabled ? -1 : 0" :tabindex="row.disabled ? -1 : 0"
@@ -51,11 +51,11 @@ function selectRow(row: UiShopItem) {
@keydown.space.prevent="selectRow(row)" @keydown.space.prevent="selectRow(row)"
> >
<i class="ui-shop-row__frame" aria-hidden="true" /> <i class="ui-shop-row__frame" aria-hidden="true" />
<div class="ui-shop-row__icon"><img :src="row.image" alt="" /></div> <div class="ui-shop-row__icon" role="gridcell"><img :src="row.image" alt="" /></div>
<div>{{ row.reinforce }}</div> <div role="gridcell">{{ row.reinforce }}</div>
<div>{{ row.amplify }}</div> <div role="gridcell">{{ row.amplify }}</div>
<div :class="row.rarity && `quality-${row.rarity}`">{{ row.name }}</div> <div role="gridcell" :class="row.rarity && `quality-${row.rarity}`">{{ row.name }}</div>
<div> <div role="gridcell">
<button <button
type="button" type="button"
class="ui-shop-row__inspect" class="ui-shop-row__inspect"
@@ -65,8 +65,10 @@ function selectRow(row: UiShopItem) {
<img :src="inspectIcon" alt="" /> <img :src="inspectIcon" alt="" />
</button> </button>
</div> </div>
<div :class="row.statusTone && `status-${row.statusTone}`">{{ row.status }}</div> <div role="gridcell" :class="row.statusTone && `status-${row.statusTone}`">
<div class="ui-shop-row__owner">{{ row.owner }}</div> {{ row.status }}
</div>
<div class="ui-shop-row__owner" role="gridcell">{{ row.owner }}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -305,6 +307,6 @@ function selectRow(row: UiShopItem) {
color: var(--ui-success); color: var(--ui-success);
} }
.status-danger { .status-danger {
color: var(--ui-destructive); color: var(--ui-danger-foreground);
} }
</style> </style>
@@ -12,7 +12,7 @@ import { cn } from '@/lib/utils'
const props = withDefaults( const props = withDefaults(
defineProps<TabsListProps & { class?: HTMLAttributes['class']; marker?: 'arrow' | 'line' }>(), defineProps<TabsListProps & { class?: HTMLAttributes['class']; marker?: 'arrow' | 'line' }>(),
{ marker: 'arrow' }, { marker: 'line' },
) )
const delegatedProps = computed(() => { const delegatedProps = computed(() => {
const { class: _class, marker: _marker, ...delegated } = props const { class: _class, marker: _marker, ...delegated } = props
@@ -22,6 +22,12 @@ const delegatedProps = computed(() => {
const listElement = ref<HTMLElement | ComponentPublicInstance>() const listElement = ref<HTMLElement | ComponentPublicInstance>()
const markerLeft = ref('0px') const markerLeft = ref('0px')
const markerReady = ref(false) const markerReady = ref(false)
const itemWidth = ref('auto')
const groupWidth = ref('max-content')
const geometryStyle = computed(() => ({
'--ui-tab-item-width': itemWidth.value,
'--ui-tabs-group-width': groupWidth.value,
}))
let resizeObserver: ResizeObserver | undefined let resizeObserver: ResizeObserver | undefined
let mutationObserver: MutationObserver | undefined let mutationObserver: MutationObserver | undefined
@@ -42,19 +48,60 @@ function positionMarker(animate = true) {
requestAnimationFrame(() => (markerReady.value = true)) requestAnimationFrame(() => (markerReady.value = true))
} }
function equalizeItems() {
const list = getListElement()
const triggers = list ? [...list.querySelectorAll<HTMLElement>('[role="tab"]')] : []
if (!list || !triggers.length) return
const labels = triggers.map(
(trigger) => trigger.querySelector<HTMLElement>('.ui-tabs-trigger__label') ?? trigger,
)
const widestLabel = Math.max(
0,
...labels.map((label) => Math.max(label.scrollWidth, label.getBoundingClientRect().width)),
)
const listStyle = getComputedStyle(list)
const triggerStyle = getComputedStyle(triggers[0])
const groupPadding =
Number.parseFloat(listStyle.paddingLeft) + Number.parseFloat(listStyle.paddingRight)
const itemPadding =
Number.parseFloat(triggerStyle.paddingLeft) + Number.parseFloat(triggerStyle.paddingRight)
const parentWidth = list.parentElement?.clientWidth ?? list.clientWidth
const availableWidth = Math.max(0, parentWidth - groupPadding)
if (!availableWidth) return
const equalizedItemWidth = Math.min(
Math.ceil(widestLabel + itemPadding),
availableWidth / triggers.length,
)
itemWidth.value = `${equalizedItemWidth}px`
groupWidth.value = `${equalizedItemWidth * triggers.length + groupPadding}px`
}
async function updateGeometry(animate = true) {
equalizeItems()
await nextTick()
positionMarker(animate)
}
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
positionMarker(false) await updateGeometry(false)
document.fonts?.ready.then(() => positionMarker(false)) document.fonts?.ready.then(() => updateGeometry(false))
resizeObserver = new ResizeObserver(() => positionMarker(false)) resizeObserver = new ResizeObserver(() => updateGeometry(false))
const list = getListElement() const list = getListElement()
if (list) resizeObserver.observe(list) if (list) {
mutationObserver = new MutationObserver(() => positionMarker()) resizeObserver.observe(list)
if (list.parentElement) resizeObserver.observe(list.parentElement)
}
mutationObserver = new MutationObserver(() => updateGeometry())
if (list) { if (list) {
mutationObserver.observe(list, { mutationObserver.observe(list, {
subtree: true, subtree: true,
attributes: true, attributes: true,
attributeFilter: ['data-state'], attributeFilter: ['data-state'],
childList: true,
characterData: true,
}) })
} }
}) })
@@ -66,7 +113,12 @@ onBeforeUnmount(() => {
</script> </script>
<template> <template>
<TabsList ref="listElement" v-bind="delegatedProps" :class="cn('ui-tabs-list', props.class)"> <TabsList
ref="listElement"
v-bind="delegatedProps"
:class="cn('ui-tabs-list', props.class)"
:style="geometryStyle"
>
<slot /> <slot />
<span <span
aria-hidden="true" aria-hidden="true"
@@ -84,19 +136,22 @@ onBeforeUnmount(() => {
.ui-tabs-list { .ui-tabs-list {
position: relative; position: relative;
display: flex; display: flex;
width: 100%; box-sizing: border-box;
width: var(--ui-tabs-group-width, max-content);
max-width: 100%;
height: var(--ui-tabs-height); height: var(--ui-tabs-height);
align-items: center; align-items: center;
padding: 5px 7px; justify-content: flex-start;
padding: 4px 6px;
background: var(--ui-tab-list-background); background: var(--ui-tab-list-background);
} }
.ui-tabs-list::after { .ui-tabs-list::after {
content: ''; content: '';
position: absolute; position: absolute;
right: 7px; right: 6px;
top: 35px; top: 28px;
left: 7px; left: 6px;
height: 1px; height: 1px;
background: var(--ui-tab-divider); background: var(--ui-tab-divider);
} }
@@ -23,18 +23,20 @@ const delegatedProps = computed(() => {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: inline-flex; display: inline-flex;
box-sizing: border-box;
width: var(--ui-tab-item-width, auto);
height: var(--ui-tab-height); height: var(--ui-tab-height);
align-items: center; align-items: center;
justify-content: center; justify-content: center;
min-width: 0; min-width: 0;
flex: 1 1 0; flex: 0 0 var(--ui-tab-item-width, auto);
padding: 0 12px; padding: 0 10px;
border: 0; border: 0;
outline: none; outline: none;
background: var(--ui-tab-background); background: var(--ui-tab-background);
color: var(--ui-tab-foreground); color: var(--ui-tab-foreground);
font: font:
16px / var(--ui-control-line-height) 'DCalc', 14px / var(--ui-control-line-height) 'DCalc',
SimSun, SimSun,
sans-serif; sans-serif;
text-shadow: 1px 1px 0 var(--ui-dialog-titlebar-text-shadow); text-shadow: 1px 1px 0 var(--ui-dialog-titlebar-text-shadow);
@@ -44,10 +46,10 @@ const delegatedProps = computed(() => {
.ui-tabs-trigger:not(:last-of-type)::before { .ui-tabs-trigger:not(:last-of-type)::before {
content: ''; content: '';
position: absolute; position: absolute;
top: 5px; top: 4px;
right: 0; right: 0;
width: 1px; width: 1px;
height: 19px; height: 15px;
background: linear-gradient(180deg, transparent, var(--ui-tab-separator) 22% 78%, transparent); background: linear-gradient(180deg, transparent, var(--ui-tab-separator) 22% 78%, transparent);
} }
@@ -72,7 +74,11 @@ const delegatedProps = computed(() => {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: inline-block; display: inline-block;
max-width: 100%;
overflow: hidden;
line-height: var(--ui-control-line-height); line-height: var(--ui-control-line-height);
text-overflow: ellipsis;
white-space: nowrap;
transform: translateY(var(--ui-control-text-offset-y)); transform: translateY(var(--ui-control-text-offset-y));
} }
</style> </style>
@@ -16,8 +16,20 @@ export default defineComponent({
const { state, execute, isLoading } = useAsyncState( const { state, execute, isLoading } = useAsyncState(
async () => { async () => {
const res = await api.getSkillFromCode(skillCode.value) const res = await api.getSkillFromCode(skillCode.value)
// console.log(res)
configStore.config.skills = res.skills configStore.config.skills = res.skills
const shortcuts = (res.shortcuts ?? []) as { skill_id: number; slot_index: number }[]
const combos = (res.combos ?? []) as {
slot_index: number
skills: { skill_id: number }[]
}[]
const importedShortcuts = res.job?.name === 'dark_knight' ? shortcuts.slice(6) : shortcuts
configStore.config.skillShortcuts = {
shortcuts: importedShortcuts.map((item) => item.skill_id?.toString() ?? ''),
combos:
res.job?.name === 'dark_knight'
? combos.map((item) => item.skills.map((skill) => skill.skill_id?.toString() ?? ''))
: [],
}
const { alert } = useDialog() const { alert } = useDialog()
visible.value = false visible.value = false
await alert({ await alert({
@@ -58,8 +58,12 @@
<div class="equipment-groups"> <div class="equipment-groups">
<template v-for="group in equs" :key="group.name"> <template v-for="group in equs" :key="group.name">
<section v-if="group.equs.length" class="equipment-group"> <UiDataSection
<h2 class="equipment-group__heading">{{ group.name }}</h2> v-if="group.equs.length"
class="equipment-group"
:title="group.name"
compact
>
<div class="equipment-grid"> <div class="equipment-grid">
<button <button
v-for="(equipment, index) in group.equs" v-for="(equipment, index) in group.equs"
@@ -82,7 +86,7 @@
<EquipmentIcon :inactive="!isActive(equipment)" :equipment="equipment" /> <EquipmentIcon :inactive="!isActive(equipment)" :equipment="equipment" />
</button> </button>
</div> </div>
</section> </UiDataSection>
</template> </template>
<div v-if="!hasEquipmentResults" class="equipment-empty">没有符合当前条件的装备</div> <div v-if="!hasEquipmentResults" class="equipment-empty">没有符合当前条件的装备</div>
@@ -109,6 +113,7 @@
v-model:open="baseOpen" v-model:open="baseOpen"
class="equipment-detail-section" class="equipment-detail-section"
title="基础信息" title="基础信息"
compact
> >
<div class="equipment-detail-content"> <div class="equipment-detail-content">
<Info :equipment="curEquInfo" /> <Info :equipment="curEquInfo" />
@@ -120,6 +125,7 @@
v-model:open="suitOpen" v-model:open="suitOpen"
class="equipment-detail-section" class="equipment-detail-section"
title="套装信息" title="套装信息"
compact
> >
<LevelControls <LevelControls
:rarity="curSuitLv.rarity" :rarity="curSuitLv.rarity"
@@ -137,6 +143,7 @@
v-model:open="oathOpen" v-model:open="oathOpen"
class="equipment-detail-section" class="equipment-detail-section"
title="誓约信息" title="誓约信息"
compact
> >
<LevelControls <LevelControls
:rarity="curOathSkillLv.rarity" :rarity="curOathSkillLv.rarity"
@@ -531,21 +538,8 @@ function chooseEqu(equipment?: IEquipment) {
margin-top: var(--ui-control-gap); margin-top: var(--ui-control-gap);
} }
.equipment-group__heading { .equipment-group :deep(.ui-data-section__content) {
box-sizing: border-box; padding: var(--ui-autocomplete-trigger-gap);
display: flex;
min-height: var(--ui-disclosure-header-min-block-size);
align-items: center;
justify-content: center;
margin: 0 0 var(--ui-autocomplete-trigger-gap);
padding-inline: var(--ui-field-padding-inline);
border-top: 1px solid var(--ui-disclosure-border);
border-bottom: 1px solid var(--ui-disclosure-divider);
background: var(--ui-disclosure-header-background);
color: var(--ui-settings-section-foreground);
font-size: var(--ui-control-font-size-default);
font-weight: 400;
line-height: var(--ui-control-line-height);
} }
.equipment-grid { .equipment-grid {
@@ -1,6 +1,6 @@
<template> <template>
<div class="flex flex-wrap equ-profile"> <div class="flex flex-wrap equ-profile">
<div class="equ-profile-item"> <div class="equ-profile-item equ-profile-item--wide">
<div class="row-name">迷雾系统</div> <div class="row-name">迷雾系统</div>
<ForgeSlider <ForgeSlider
v-model="sundryData['fog']" v-model="sundryData['fog']"
@@ -11,7 +11,7 @@
:step="1" :step="1"
/> />
</div> </div>
<div class="equ-profile-item"> <div class="equ-profile-item equ-profile-item--wide">
<div class="row-name">冒险团</div> <div class="row-name">冒险团</div>
<ForgeSlider <ForgeSlider
v-model="sundryData['adventure']" v-model="sundryData['adventure']"
@@ -22,7 +22,7 @@
:step="1" :step="1"
/> />
</div> </div>
<div class="equ-profile-item"> <div class="equ-profile-item equ-profile-item--wide">
<div class="row-name">结婚系统</div> <div class="row-name">结婚系统</div>
<ForgeSelect <ForgeSelect
v-for="(key, index) in ['marriage_house', 'marriage_ring']" v-for="(key, index) in ['marriage_house', 'marriage_ring']"
@@ -40,7 +40,7 @@
:options="sundryOptions('contract')" :options="sundryOptions('contract')"
/> />
</div> </div>
<div class="equ-profile-item"> <div class="equ-profile-item equ-profile-item--wide">
<div class="row-name">收集箱</div> <div class="row-name">收集箱</div>
<ForgeSelect <ForgeSelect
v-for="(key, index) in ['collection_type', 'collection_num_0', 'collection_num_1']" v-for="(key, index) in ['collection_type', 'collection_num_0', 'collection_num_1']"
+128 -25
View File
@@ -1,33 +1,59 @@
<template> <template>
<main class="forge-page"> <main class="forge-page">
<UiDataSection title="装备/时装/徽章打造(点击部位切换)"> <div class="forge-page__editor">
<TabsRoot v-model="partTab"> <UiDataSection title="装备/时装/徽章打造(点击部位切换)">
<UiTabsList class="forge-tabs" aria-label="打造类型"> <TabsRoot v-model="partTab">
<UiTabsTrigger :value="0">装备</UiTabsTrigger> <UiTabsList class="forge-tabs" aria-label="打造类型">
<UiTabsTrigger :value="2">誓约</UiTabsTrigger> <UiTabsTrigger :value="0">装备</UiTabsTrigger>
<UiTabsTrigger :value="1">时装</UiTabsTrigger> <UiTabsTrigger :value="2">誓约</UiTabsTrigger>
<UiTabsTrigger :value="3">徽章</UiTabsTrigger> <UiTabsTrigger :value="1">时装</UiTabsTrigger>
</UiTabsList> <UiTabsTrigger :value="3">徽章</UiTabsTrigger>
</TabsRoot> </UiTabsList>
<Equipment v-if="partTab === 0" /> </TabsRoot>
<Avatars v-else-if="partTab === 1" /> <Equipment v-if="partTab === 0" />
<Oaths v-else-if="partTab === 2" /> <Avatars v-else-if="partTab === 1" />
<Emblems v-else /> <Oaths v-else-if="partTab === 2" />
</UiDataSection> <Emblems v-else />
</UiDataSection>
<UiDataSection title="辟邪玉"> <UiDataSection title="辟邪玉">
<Jades /> <Jades />
</UiDataSection> </UiDataSection>
<UiDataSection title="其它"> <UiDataSection title="其它">
<Sundry /> <Sundry />
</UiDataSection> </UiDataSection>
</div>
<aside class="forge-page__summary" aria-label="打造摘要">
<header>打造摘要</header>
<UiDataRow
><span>已穿戴装备</span><strong>{{ equippedCount }}</strong></UiDataRow
>
<UiDataRow
><span>已配置增幅</span><strong>{{ reinforcedCount }}</strong></UiDataRow
>
<UiDataRow
><span>已配置附魔</span><strong>{{ enchantedCount }}</strong></UiDataRow
>
<UiDataRow
><span>已配置融合</span><strong>{{ fusionCount }}</strong></UiDataRow
>
<UiDataRow
><span>已配置时装</span><strong>{{ avatarCount }}</strong></UiDataRow
>
<UiDataRow
><span>辟邪玉槽位</span><strong>{{ jadeCount }}</strong></UiDataRow
>
<p>左侧继续使用现有打造组件;摘要只读取当前配置,不改变计算数据。</p>
</aside>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { UiDataSection } from '@/components/ui/data-section' import { UiDataRow, UiDataSection } from '@/components/ui/data-section'
import { TabsRoot, UiTabsList, UiTabsTrigger } from '@/components/ui/tabs' import { TabsRoot, UiTabsList, UiTabsTrigger } from '@/components/ui/tabs'
import { useConfigStore } from '@/stores'
import Avatars from './components/Avatars.vue' import Avatars from './components/Avatars.vue'
import Emblems from './components/Emblems.vue' import Emblems from './components/Emblems.vue'
import Equipment from './components/Equipments.vue' import Equipment from './components/Equipments.vue'
@@ -36,21 +62,90 @@ import Oaths from './components/Oaths.vue'
import Sundry from './components/Sundry.vue' import Sundry from './components/Sundry.vue'
const partTab = ref(0) const partTab = ref(0)
const configStore = useConfigStore()
const equipmentValues = computed(() => Object.values(configStore.config.equips))
const equippedCount = computed(() => equipmentValues.value.filter((item) => item.id).length)
const reinforcedCount = computed(
() => equipmentValues.value.filter((item) => item.reinforce > 0 || item.refine > 0).length,
)
const enchantedCount = computed(
() => equipmentValues.value.filter((item) => Boolean(item.enchant)).length,
)
const fusionCount = computed(
() =>
equipmentValues.value.filter((item) => Boolean(item.fusion) || item.weaponFusion > 0).length,
)
const avatarCount = computed(
() => Object.values(configStore.config.avatar).filter((item) => Boolean(item.id)).length,
)
const jadeCount = computed(
() => Object.values(configStore.config.jades).filter((item) => item.id >= 0).length,
)
</script> </script>
<style lang="scss"> <style lang="scss">
.forge-page { .forge-page {
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
max-width: 760px; max-width: 840px;
min-width: 0; min-width: 0;
padding-right: clamp(4px, 0.8vw, 12px);
display: grid; display: grid;
align-content: start; grid-template-columns: minmax(0, 1fr) minmax(170px, 0.3fr);
gap: clamp(6px, 0.8vw, 12px); gap: clamp(6px, 0.8vw, 12px);
overflow: auto; overflow: hidden;
container-type: inline-size; container-type: inline-size;
.forge-page__editor,
.forge-page__summary {
min-width: 0;
min-height: 0;
}
.forge-page__editor {
display: grid;
align-content: start;
gap: clamp(6px, 0.8vw, 12px);
overflow: auto;
overscroll-behavior: contain;
}
.forge-page__summary {
border: 1px solid var(--ui-composition-border);
background: var(--ui-composition-background);
overflow: auto;
> header {
display: flex;
min-height: var(--ui-disclosure-header-min-block-size);
align-items: center;
padding-inline: var(--ui-field-padding-inline);
border-bottom: 1px solid var(--ui-disclosure-divider);
background: var(--ui-disclosure-header-background);
color: var(--ui-settings-section-foreground);
}
.ui-data-row {
justify-content: space-between;
padding-inline: var(--ui-field-padding-inline);
}
strong {
color: var(--ui-disclosure-row-selected-foreground);
font:
400 12px Consolas,
monospace;
}
p {
margin: 0;
padding: var(--ui-control-padding-inline-default);
color: var(--ui-muted-foreground);
font-size: 11px;
line-height: 1.5;
}
}
.forge-tabs { .forge-tabs {
margin-bottom: clamp(8px, 1.2cqi, 14px); margin-bottom: clamp(8px, 1.2cqi, 14px);
} }
@@ -136,5 +231,13 @@ const partTab = ref(0)
grid-column: 1 / -1; grid-column: 1 / -1;
} }
} }
@container (max-width: 650px) {
grid-template-columns: minmax(0, 1fr);
.forge-page__summary {
display: none;
}
}
} }
</style> </style>
+92 -61
View File
@@ -10,37 +10,37 @@
@update:model-value="navigateCharacterTab" @update:model-value="navigateCharacterTab"
> >
<UiTabsList aria-label="角色配置导航"> <UiTabsList aria-label="角色配置导航">
<UiTabsTrigger :value="`/character/equipment/${props.alter}`">装备设置</UiTabsTrigger> <UiTabsTrigger :value="`/character/equipment/${props.alter}`">装备</UiTabsTrigger>
<UiTabsTrigger :value="`/character/skill/${props.alter}`">技能设置</UiTabsTrigger> <UiTabsTrigger :value="`/character/skill/${props.alter}`">技能</UiTabsTrigger>
<UiTabsTrigger :value="`/character/forge/${props.alter}`">打造设置</UiTabsTrigger> <UiTabsTrigger :value="`/character/forge/${props.alter}`">打造</UiTabsTrigger>
</UiTabsList> </UiTabsList>
</TabsRoot> </TabsRoot>
<div class="character-shell__tools" aria-label="计算工具">
<label class="character-toggle">
<UiSwitch v-model="configStore.config.DSB" />
<span>系统奶</span>
</label>
<label class="character-toggle">
<UiSwitch v-model="configStore.config.BUFF" />
<span>组队</span>
</label>
<UiButton size="icon" title="打造导入" aria-label="打造导入" @click="showImport">
<img data-icon class="character-toolbar-icon" :src="importIcon" alt="" />
</UiButton>
<UiButton size="icon" title="技能导入" aria-label="技能导入" @click="showImportSkill">
<img data-icon class="character-toolbar-icon" :src="exchangeIcon" alt="" />
</UiButton>
<UiButton :disabled="isExecuting" @click="recalculate">开始计算</UiButton>
</div>
</header> </header>
<div class="character-shell__workspace"> <div class="character-shell__workspace">
<section v-if="!!infoStore.infos?.alter" class="character-shell__config"> <section v-if="!!infoStore.infos?.alter" class="character-shell__config">
<RouterView></RouterView> <RouterView></RouterView>
</section> </section>
<div class="character-shell__stage" aria-hidden="true"></div>
<aside class="character-shell__result" aria-label="计算结果"> <aside class="character-shell__result" aria-label="计算结果">
<Result></Result> <Result :show-overview="route.name === 'equipment'" />
</aside> </aside>
</div> </div>
<footer class="character-shell__footer">
<label class="character-toggle">
<UiSwitch v-model="configStore.config.DSB" />
<span>系统奶</span>
</label>
<label class="character-toggle">
<UiSwitch v-model="configStore.config.BUFF" />
<span>组队</span>
</label>
<UiButton size="icon" title="打造导入" aria-label="打造导入" @click="showImport">
<img data-icon class="character-toolbar-icon" :src="importIcon" alt="" />
</UiButton>
<UiButton size="icon" title="技能导入" aria-label="技能导入" @click="showImportSkill">
<img data-icon class="character-toolbar-icon" :src="exchangeIcon" alt="" />
</UiButton>
</footer>
</div> </div>
<ImportDialog v-model:open="importVisible" title="打造导入"> <ImportDialog v-model:open="importVisible" title="打造导入">
@@ -110,6 +110,11 @@ const showImportSkill = () => {
importSkillVisible.value = true importSkillVisible.value = true
} }
const recalculate = () => {
if (isExecuting.value) return
void configStore.result.execute()
}
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -150,13 +155,12 @@ onUnmounted(() => {
--ui-control-height-compact: clamp(24px, 1.25vw, 26px); --ui-control-height-compact: clamp(24px, 1.25vw, 26px);
--ui-control-height-default: clamp(28px, 1.5vw, 32px); --ui-control-height-default: clamp(28px, 1.5vw, 32px);
--ui-control-height-large: clamp(42px, 2.3vw, 48px); --ui-control-height-large: clamp(42px, 2.3vw, 48px);
--ui-tabs-height: clamp(40px, 2.05vw, 44px); --ui-tabs-height: 32px;
--ui-tab-height: clamp(30px, 1.5vw, 32px); --ui-tab-height: 24px;
--ui-control-gap: clamp(5px, 0.4vw, 8px); --ui-control-gap: clamp(5px, 0.4vw, 8px);
--ui-item-slot-size: clamp(30px, 1.6vw, 34px); --ui-item-slot-size: clamp(30px, 1.6vw, 34px);
--ui-item-image-size: clamp(28px, 1.45vw, 30px); --ui-item-image-size: clamp(28px, 1.45vw, 30px);
--ui-character-config-inline-size: clamp(340px, 21vw, 480px); --ui-character-config-inline-size: clamp(360px, 28vw, 520px);
--ui-character-footer-min-block-size: clamp(38px, 2vw, 42px);
position: relative; position: relative;
isolation: isolate; isolation: isolate;
@@ -181,8 +185,7 @@ onUnmounted(() => {
pointer-events: none; pointer-events: none;
} }
.character-shell__header, .character-shell__header {
.character-shell__footer {
position: relative; position: relative;
z-index: 3; z-index: 3;
flex: 0 0 auto; flex: 0 0 auto;
@@ -195,12 +198,24 @@ onUnmounted(() => {
display: flex; display: flex;
min-height: var(--ui-tabs-height); min-height: var(--ui-tabs-height);
align-items: center; align-items: center;
justify-content: space-between;
gap: var(--ui-control-gap);
padding-inline-end: var(--ui-character-workspace-padding);
border-bottom-width: var(--ui-component-stroke-size); border-bottom-width: var(--ui-component-stroke-size);
border-bottom-style: solid; border-bottom-style: solid;
} }
.character-shell__navigation { .character-shell__navigation {
width: 100%; min-width: 0;
overflow-x: auto;
overscroll-behavior-inline: contain;
}
.character-shell__tools {
display: flex;
min-width: max-content;
align-items: center;
gap: var(--ui-control-gap);
} }
.character-shell__workspace { .character-shell__workspace {
@@ -212,8 +227,7 @@ onUnmounted(() => {
flex: 1; flex: 1;
grid-template-columns: grid-template-columns:
minmax(0, var(--ui-character-config-inline-size)) minmax(0, var(--ui-character-config-inline-size))
minmax(0, 1fr) minmax(0, 1fr);
max-content;
gap: var(--ui-character-workspace-gap); gap: var(--ui-character-workspace-gap);
padding: var(--ui-character-workspace-padding); padding: var(--ui-character-workspace-padding);
overflow: hidden; overflow: hidden;
@@ -234,42 +248,15 @@ onUnmounted(() => {
max-height: 100%; max-height: 100%;
} }
.character-shell__stage {
position: relative;
min-width: 0;
pointer-events: none;
}
.character-shell__stage::before {
content: '';
position: absolute;
inset: 7% -12% 4%;
background: var(--ui-character-stage-glow);
filter: blur(var(--ui-character-stage-glow-blur));
opacity: var(--ui-character-stage-glow-opacity);
}
.character-shell__result { .character-shell__result {
position: relative; position: relative;
z-index: 2; z-index: 2;
overflow: visible; overflow: hidden;
}
.character-shell__footer {
box-sizing: border-box;
display: flex;
min-height: var(--ui-character-footer-min-block-size);
align-items: center;
flex-wrap: wrap;
gap: var(--ui-control-gap);
padding: var(--ui-character-footer-padding);
border-top-width: var(--ui-component-stroke-size);
border-top-style: solid;
} }
.character-shell--skill, .character-shell--skill,
.character-shell--forge { .character-shell--forge {
--ui-character-config-inline-size: clamp(400px, 36vw, 760px); --ui-character-config-inline-size: clamp(700px, 54vw, 840px);
} }
@media (min-width: 1600px) { @media (min-width: 1600px) {
@@ -295,14 +282,58 @@ onUnmounted(() => {
@media (max-width: 1179px) { @media (max-width: 1179px) {
.character-shell { .character-shell {
--ui-character-config-inline-size: clamp(332px, 34vw, 360px); --ui-character-config-inline-size: clamp(332px, 34vw, 380px);
--ui-character-workspace-gap: 4px; --ui-character-workspace-gap: 4px;
--ui-character-workspace-padding: 4px; --ui-character-workspace-padding: 4px;
} }
.character-shell--skill, .character-shell--skill,
.character-shell--forge { .character-shell--forge {
--ui-character-config-inline-size: clamp(380px, 40vw, 420px); --ui-character-config-inline-size: clamp(620px, 58vw, 700px);
}
}
@media (max-width: 900px) {
.character-shell {
--ui-character-config-inline-size: min(100%, 520px);
}
.character-shell--skill,
.character-shell--forge {
--ui-character-config-inline-size: min(100%, 760px);
}
.character-shell__header {
align-items: stretch;
flex-direction: column;
padding-inline-end: 0;
}
.character-shell__tools {
min-height: var(--ui-control-height-large);
justify-content: flex-end;
padding-inline: var(--ui-character-workspace-padding);
border-top: var(--ui-component-stroke-size) solid var(--ui-character-chrome-border);
}
.character-shell__workspace {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(560px, calc(100dvh - 96px)) minmax(1124px, auto);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
}
.character-shell__config,
.character-shell__result {
width: 100%;
height: 100%;
overflow: hidden;
}
.character-shell__config > * {
max-width: min(100%, var(--ui-character-config-inline-size));
margin-inline: auto;
} }
} }
</style> </style>
@@ -2,9 +2,12 @@
<section <section
v-if="configStore.result.state" v-if="configStore.result.state"
class="result-shell" class="result-shell"
:class="{ 'result-shell--buffer': configStore.result.state.buffer }" :class="{
'result-shell--buffer': configStore.result.state.buffer,
'result-shell--without-overview': !showOverview,
}"
> >
<div class="result-overview"> <div v-if="showOverview" class="result-overview">
<div class="result-overview__actions"> <div class="result-overview__actions">
<UiButton class="flex-1" @click="setStandard">设为基准</UiButton> <UiButton class="flex-1" @click="setStandard">设为基准</UiButton>
<UiButton class="flex-1" variant="outline" @click="clearStandard">清空基准</UiButton> <UiButton class="flex-1" variant="outline" @click="clearStandard">清空基准</UiButton>
@@ -54,6 +57,13 @@ const configStore = useConfigStore()
const infoStore = useInfoStore() const infoStore = useInfoStore()
const type = ref<'equ' | 'oath'>('equ') const type = ref<'equ' | 'oath'>('equ')
withDefaults(
defineProps<{
showOverview?: boolean
}>(),
{ showOverview: true },
)
const setStandard = () => { const setStandard = () => {
if (!configStore?.result?.state?.uuid) return if (!configStore?.result?.state?.uuid) return
infoStore.setStandard(configStore.result.state, configStore.skillCountConfig) infoStore.setStandard(configStore.result.state, configStore.skillCountConfig)
@@ -68,20 +78,22 @@ const clearStandard = () => {
.result-shell { .result-shell {
position: relative; position: relative;
display: grid; display: grid;
width: calc( width: 100%;
var(--ui-character-result-info-inline-size) + var(--ui-character-result-summary-inline-size) +
var(--ui-character-result-toggle-inline-size)
);
min-width: 0; min-width: 0;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
grid-template-columns: grid-template-columns:
var(--ui-character-result-info-inline-size) var(--ui-character-result-info-inline-size)
var(--ui-character-result-summary-inline-size); minmax(0, 1fr);
gap: var(--ui-character-result-toggle-inline-size); gap: var(--ui-character-result-toggle-inline-size);
overflow: visible; overflow: visible;
} }
.result-shell--without-overview {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.result-overview { .result-overview {
display: grid; display: grid;
min-width: 0; min-width: 0;
@@ -109,15 +121,18 @@ const clearStandard = () => {
min-height: 0; min-height: 0;
} }
@media (min-width: 1600px) { @media (min-width: 1180px) {
.result-shell { .result-shell {
width: calc( gap: var(--ui-character-workspace-gap);
var(--ui-character-result-info-inline-size) + var(--ui-character-result-full-inline-size) + }
var(--ui-character-workspace-gap) }
);
grid-template-columns: @media (max-width: 900px) {
var(--ui-character-result-info-inline-size) .result-shell {
var(--ui-character-result-full-inline-size); height: 100%;
min-height: 100%;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: repeat(2, minmax(560px, 1fr));
gap: var(--ui-character-workspace-gap); gap: var(--ui-character-workspace-gap);
} }
} }
@@ -118,7 +118,7 @@
</div> </div>
<footer class="damage-panel__total" :class="toneClass(totalTone)"> <footer class="damage-panel__total" :class="toneClass(totalTone)">
<span>{{ totalStandard ? "相对基准" : "总伤害" }}</span> <span>{{ totalStandard ? '相对基准' : '总伤害' }}</span>
<strong>{{ showTotalText }}</strong> <strong>{{ showTotalText }}</strong>
</footer> </footer>
</div> </div>
@@ -126,166 +126,166 @@
</template> </template>
<script setup lang="ts" name="Skill"> <script setup lang="ts" name="Skill">
import type { IResultSkill, IResultSkillCount } from "@/api/calc/type"; import type { IResultSkill, IResultSkillCount } from '@/api/calc/type'
import { UiNumberField } from "@/components/ui/number-field"; import { UiNumberField } from '@/components/ui/number-field'
import { useConfigStore } from "@/stores"; import { useConfigStore } from '@/stores'
import { getImageURL } from "@/utils/images"; import { getImageURL } from '@/utils/images'
type SortKey = "learnLv" | "cd" | "dps" | "total"; type SortKey = 'learnLv' | 'cd' | 'dps' | 'total'
type Tone = "positive" | "negative" | undefined; type Tone = 'positive' | 'negative' | undefined
const props = defineProps<{ const props = defineProps<{
data: IResultSkill[]; data: IResultSkill[]
standard?: { standard?: {
skills: IResultSkill[]; skills: IResultSkill[]
skillCount: IResultSkillCount[]; skillCount: IResultSkillCount[]
}; }
}>(); }>()
const expanded = ref(false); const expanded = ref(false)
const sort = ref<SortKey>("learnLv"); const sort = ref<SortKey>('learnLv')
const order = ref<"asc" | "desc">("desc"); const order = ref<'asc' | 'desc'>('desc')
const config = useConfigStore(); const config = useConfigStore()
watchEffect(() => { watchEffect(() => {
props.data.forEach((skill) => { props.data.forEach((skill) => {
const exists = config.skillCountConfig.some( const exists = config.skillCountConfig.some(
(item) => item.name === skill.name && item.mode === skill.mode, (item) => item.name === skill.name && item.mode === skill.mode,
); )
if (!exists) config.skillCountConfig.push({ name: skill.name, mode: skill.mode, count: 1 }); if (!exists) config.skillCountConfig.push({ name: skill.name, mode: skill.mode, count: 1 })
}); })
}); })
function skillCountValue(skill: IResultSkill) { function skillCountValue(skill: IResultSkill) {
return ( return (
config.skillCountConfig.find((item) => item.name === skill.name && item.mode === skill.mode) config.skillCountConfig.find((item) => item.name === skill.name && item.mode === skill.mode)
?.count ?? 1 ?.count ?? 1
); )
} }
function setSkillCount(skill: IResultSkill, value: number | undefined) { function setSkillCount(skill: IResultSkill, value: number | undefined) {
const count = typeof value === "number" && Number.isFinite(value) ? value : 0; const count = typeof value === 'number' && Number.isFinite(value) ? value : 0
const target = config.skillCountConfig.find( const target = config.skillCountConfig.find(
(item) => item.name === skill.name && item.mode === skill.mode, (item) => item.name === skill.name && item.mode === skill.mode,
); )
if (target) target.count = count; if (target) target.count = count
else config.skillCountConfig.push({ name: skill.name, mode: skill.mode, count }); else config.skillCountConfig.push({ name: skill.name, mode: skill.mode, count })
} }
const totalDamage = computed(() => const totalDamage = computed(() =>
props.data.reduce((sum, skill) => sum + skill.damage * skillCountValue(skill), 0), props.data.reduce((sum, skill) => sum + skill.damage * skillCountValue(skill), 0),
); )
const totalStandard = computed(() => { const totalStandard = computed(() => {
if (!props.standard) return undefined; if (!props.standard) return undefined
return props.standard.skills.reduce((sum, skill) => { return props.standard.skills.reduce((sum, skill) => {
const count = props.standard?.skillCount.find( const count = props.standard?.skillCount.find(
(item) => item.name === skill.name && item.mode === skill.mode, (item) => item.name === skill.name && item.mode === skill.mode,
)?.count; )?.count
return sum + skill.damage * (count ?? 0); return sum + skill.damage * (count ?? 0)
}, 0); }, 0)
}); })
function standardValues(skill: IResultSkill) { function standardValues(skill: IResultSkill) {
const standardSkill = props.standard?.skills.find( const standardSkill = props.standard?.skills.find(
(item) => item.name === skill.name && item.mode === skill.mode, (item) => item.name === skill.name && item.mode === skill.mode,
); )
const standardCount = props.standard?.skillCount.find( const standardCount = props.standard?.skillCount.find(
(item) => item.name === skill.name && item.mode === skill.mode, (item) => item.name === skill.name && item.mode === skill.mode,
)?.count; )?.count
if (!standardSkill || standardSkill.damage <= 0 || standardSkill.cd <= 0 || !standardCount) { if (!standardSkill || standardSkill.damage <= 0 || standardSkill.cd <= 0 || !standardCount) {
return undefined; return undefined
} }
return { skill: standardSkill, count: standardCount }; return { skill: standardSkill, count: standardCount }
} }
function comparisonTone(value: number): Tone { function comparisonTone(value: number): Tone {
return value >= 0 ? "positive" : "negative"; return value >= 0 ? 'positive' : 'negative'
} }
function formatPercent(value: number) { function formatPercent(value: number) {
return `${(value * 100).toFixed(2)}%`; return `${(value * 100).toFixed(2)}%`
} }
function sortMetric(skill: IResultSkill, key: SortKey) { function sortMetric(skill: IResultSkill, key: SortKey) {
if (key === "total") return skill.damage * skillCountValue(skill); if (key === 'total') return skill.damage * skillCountValue(skill)
if (key === "dps") return skill.cd > 0 ? skill.damage / skill.cd : 0; if (key === 'dps') return skill.cd > 0 ? skill.damage / skill.cd : 0
return Number(skill[key] ?? 0); return Number(skill[key] ?? 0)
} }
const sortedSkills = computed(() => const sortedSkills = computed(() =>
[...props.data].sort((left, right) => { [...props.data].sort((left, right) => {
const leftValue = sortMetric(left, sort.value); const leftValue = sortMetric(left, sort.value)
const rightValue = sortMetric(right, sort.value); const rightValue = sortMetric(right, sort.value)
return order.value === "asc" ? leftValue - rightValue : rightValue - leftValue; return order.value === 'asc' ? leftValue - rightValue : rightValue - leftValue
}), }),
); )
const rows = computed(() => const rows = computed(() =>
sortedSkills.value.map((skill) => { sortedSkills.value.map((skill) => {
const count = skillCountValue(skill); const count = skillCountValue(skill)
const currentTotal = skill.damage * count; const currentTotal = skill.damage * count
const currentDps = skill.cd > 0 ? skill.damage / skill.cd : 0; const currentDps = skill.cd > 0 ? skill.damage / skill.cd : 0
const standard = standardValues(skill); const standard = standardValues(skill)
let dps = Math.round(currentDps).toLocaleString(); let dps = Math.round(currentDps).toLocaleString()
let total = Math.round(currentTotal).toLocaleString(); let total = Math.round(currentTotal).toLocaleString()
let dpsTone: Tone; let dpsTone: Tone
let totalTone: Tone; let totalTone: Tone
if (standard) { if (standard) {
const standardDps = standard.skill.damage / standard.skill.cd; const standardDps = standard.skill.damage / standard.skill.cd
const standardTotal = standard.skill.damage * standard.count; const standardTotal = standard.skill.damage * standard.count
const dpsDelta = standardDps === 0 ? 0 : (currentDps - standardDps) / standardDps; const dpsDelta = standardDps === 0 ? 0 : (currentDps - standardDps) / standardDps
const totalDelta = standardTotal === 0 ? 0 : (currentTotal - standardTotal) / standardTotal; const totalDelta = standardTotal === 0 ? 0 : (currentTotal - standardTotal) / standardTotal
dps = formatPercent(dpsDelta); dps = formatPercent(dpsDelta)
total = formatPercent(totalDelta); total = formatPercent(totalDelta)
dpsTone = comparisonTone(dpsDelta); dpsTone = comparisonTone(dpsDelta)
totalTone = comparisonTone(totalDelta); totalTone = comparisonTone(totalDelta)
} }
const share = totalDamage.value === 0 ? 0 : currentTotal / totalDamage.value; const share = totalDamage.value === 0 ? 0 : currentTotal / totalDamage.value
return { return {
key: `${skill.name}-${skill.mode ?? ""}`, key: `${skill.name}-${skill.mode ?? ''}`,
skill, skill,
displayName: `${skill.name}${skill.mode ? ` [${skill.mode}]` : ""}`, displayName: `${skill.name}${skill.mode ? ` [${skill.mode}]` : ''}`,
count, count,
cd: typeof skill.cd === "number" ? `${skill.cd.toFixed(1)}s` : "-", cd: typeof skill.cd === 'number' ? `${skill.cd.toFixed(1)}s` : '-',
dps, dps,
total, total,
dpsTone, dpsTone,
totalTone, totalTone,
share: formatPercent(share), share: formatPercent(share),
}; }
}), }),
); )
const showTotal = computed(() => { const showTotal = computed(() => {
if (!totalStandard.value) return totalDamage.value; if (!totalStandard.value) return totalDamage.value
return ((totalDamage.value - totalStandard.value) / totalStandard.value) * 100; return ((totalDamage.value - totalStandard.value) / totalStandard.value) * 100
}); })
const showTotalText = computed(() => const showTotalText = computed(() =>
totalStandard.value totalStandard.value
? `${showTotal.value.toFixed(2)}%` ? `${showTotal.value.toFixed(2)}%`
: Math.round(showTotal.value).toLocaleString(), : Math.round(showTotal.value).toLocaleString(),
); )
const totalTone = computed<Tone>(() => const totalTone = computed<Tone>(() =>
totalStandard.value ? comparisonTone(showTotal.value) : undefined, totalStandard.value ? comparisonTone(showTotal.value) : undefined,
); )
function toneClass(tone: Tone) { function toneClass(tone: Tone) {
return { return {
"is-positive": tone === "positive", 'is-positive': tone === 'positive',
"is-negative": tone === "negative", 'is-negative': tone === 'negative',
}; }
} }
function sortValue(key: SortKey) { function sortValue(key: SortKey) {
if (sort.value === key) order.value = order.value === "asc" ? "desc" : "asc"; if (sort.value === key) order.value = order.value === 'asc' ? 'desc' : 'asc'
else { else {
sort.value = key; sort.value = key
order.value = "desc"; order.value = 'desc'
} }
} }
</script> </script>
@@ -293,6 +293,7 @@ function sortValue(key: SortKey) {
<style scoped lang="scss"> <style scoped lang="scss">
.damage-panel { .damage-panel {
position: relative; position: relative;
container: damage-result / inline-size;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
height: 100%; height: 100%;
@@ -572,6 +573,38 @@ button.damage-panel__heading {
} }
} }
@media (max-width: 900px) {
.damage-panel__toggle {
display: none;
}
}
@container damage-result (min-width: 620px) {
.damage-panel__toggle {
display: none;
}
.damage-panel.is-expanded .damage-panel__surface {
position: relative;
inset: auto;
width: 100%;
}
.damage-panel__grid {
grid-template-columns:
minmax(180px, 1fr)
var(--ui-character-result-cd-inline-size)
var(--ui-character-result-count-inline-size)
minmax(var(--ui-character-result-value-min-inline-size), 0.9fr)
minmax(var(--ui-character-result-value-min-inline-size), 1fr)
var(--ui-character-result-share-inline-size);
}
.damage-panel__full-column {
display: flex;
}
}
@media (min-width: 1600px) { @media (min-width: 1600px) {
.damage-panel__toggle { .damage-panel__toggle {
display: none; display: none;
@@ -0,0 +1,435 @@
<template>
<aside ref="settingRef" class="dark-knight-setting" :class="{ 'is-collapsed': collapsed }">
<UiButton
v-if="collapsed"
size="icon"
class="dark-knight-setting__launcher"
title="展开技能排列"
aria-label="展开技能排列"
@click="collapsed = false"
>
<img data-icon :src="shortcutIcon" alt="" />
</UiButton>
<template v-else>
<header class="dark-knight-setting__header">
<span>技能排列</span>
<UiButton size="icon" variant="ghost" aria-label="收起技能排列" @click="collapse">
−
</UiButton>
</header>
<section class="dark-knight-setting__section">
<h3>快捷栏</h3>
<div class="shortcut-grid">
<button
v-for="(key, index) in shortcutKeys"
:key="key"
type="button"
class="skill-slot"
:class="{ 'is-selected': isEditing('shortcut', index) }"
:title="getSkill(arrangement.shortcuts[index])?.name || `快捷键 ${key}`"
@click="openPicker('shortcut', index)"
>
<img
v-if="getSkill(arrangement.shortcuts[index])"
:src="getImageURL(getSkill(arrangement.shortcuts[index])!.icon)"
:alt="getSkill(arrangement.shortcuts[index])!.name"
/>
<span v-else>{{ key }}</span>
</button>
</div>
</section>
<section class="dark-knight-setting__section dark-knight-setting__combos">
<h3>连续技</h3>
<div class="combo-grid">
<div v-for="(key, column) in comboKeys" :key="key" class="combo-column">
<template v-for="stage in stageCount" :key="stage">
<button
type="button"
class="skill-slot combo-slot"
:class="{
'is-selected': isEditing('combo', column, stage - 1),
'is-disabled': isStageDisabled(column, stage - 1),
}"
:disabled="isStageDisabled(column, stage - 1)"
:title="comboTitle(column, stage - 1, key)"
@click="openPicker('combo', column, stage - 1)"
>
<img
v-if="getSkill(arrangement.combos[column][stage - 1])"
:src="getImageURL(getSkill(arrangement.combos[column][stage - 1])!.icon)"
:alt="getSkill(arrangement.combos[column][stage - 1])!.name"
/>
<span v-else>{{ stage }}</span>
</button>
<span
v-if="stage < stageCount"
class="stage-arrow"
:class="{ 'is-connected': isStageConnected(column, stage - 1) }"
aria-hidden="true"
></span>
</template>
<UiButton
size="sm"
variant="outline"
:aria-label="`重置 ${key} 连续技`"
@click="resetCombo(column)"
>
↻
</UiButton>
</div>
</div>
</section>
<section v-if="editingSlot" class="skill-picker" aria-label="选择主动技能">
<header>
<span>选择主动技能</span>
<div>
<UiButton size="sm" variant="outline" :disabled="!currentValue" @click="selectSkill('')"
>清空</UiButton
>
<UiButton
size="icon"
variant="ghost"
aria-label="关闭技能选择"
@click="editingSlot = null"
>×</UiButton
>
</div>
</header>
<div class="skill-picker__grid">
<button
v-for="skill in selectableSkills"
:key="skill.id"
type="button"
class="skill-picker__item"
:class="{ 'is-active': currentValue === skill.id.toString() }"
:disabled="isSkillUsed(skill.id.toString())"
:title="skill.name"
@click="selectSkill(skill.id.toString())"
>
<img :src="getImageURL(skill.icon)" :alt="skill.name" />
<span>{{ skill.name }}</span>
</button>
</div>
</section>
</template>
</aside>
</template>
<script setup lang="ts">
import type { ISkill } from '@/api/info/type'
import { UiButton } from '@/components/ui/button'
import type { ISkillShortcuts } from '@/stores/config'
import { getImageURL } from '@/utils/images'
import { onClickOutside, useEventListener } from '@vueuse/core'
import shortcutIcon from '../img/shortcut.png'
type EditingSlot =
| { type: 'shortcut'; index: number }
| { type: 'combo'; column: number; stage: number }
const shortcutKeys = ['Q', 'W', 'E', 'R', 'T', 'Y']
const comboKeys = ['A', 'S', 'D', 'F', 'G', 'H']
const stageCount = 5
const excludedSkillNames = new Set(['后跳', '受身蹲伏', '跃翔', '黑暗释放'])
const excludedAwakeLevels = new Set([85, 100])
const props = defineProps<{
skills: ISkill[]
modelValue?: ISkillShortcuts
}>()
const emit = defineEmits<{
'update:modelValue': [value: ISkillShortcuts]
}>()
const settingRef = ref<HTMLElement>()
const editingSlot = ref<EditingSlot | null>(null)
const collapsed = ref(false)
const arrangement = computed<ISkillShortcuts>(() => ({
shortcuts: Array.from(
{ length: shortcutKeys.length },
(_, index) => props.modelValue?.shortcuts?.[index] ?? '',
),
combos: Array.from({ length: comboKeys.length }, (_, column) =>
Array.from(
{ length: stageCount },
(_, stage) => props.modelValue?.combos?.[column]?.[stage] ?? '',
),
),
}))
const skillMap = computed(() => new Map(props.skills.map((skill) => [skill.id.toString(), skill])))
const selectableSkills = computed(() =>
[...props.skills]
.filter(
(skill) =>
skill.type === 'active' &&
!excludedSkillNames.has(skill.name) &&
!excludedAwakeLevels.has(skill.learnLv),
)
.sort((left, right) => {
const [leftColumn = -1, leftRow = -1] = left.cellPosition ?? []
const [rightColumn = -1, rightRow = -1] = right.cellPosition ?? []
return leftRow - rightRow || leftColumn - rightColumn || left.learnLv - right.learnLv
}),
)
const selectedSkillIds = computed(
() =>
new Set([...arrangement.value.shortcuts, ...arrangement.value.combos.flat()].filter(Boolean)),
)
const currentValue = computed(() => {
const slot = editingSlot.value
if (!slot) return ''
return slot.type === 'shortcut'
? arrangement.value.shortcuts[slot.index]
: arrangement.value.combos[slot.column][slot.stage]
})
const getSkill = (skillId: string) => skillMap.value.get(skillId)
const isEditing = (type: EditingSlot['type'], index: number, stage?: number) => {
const slot = editingSlot.value
if (!slot) return false
return type === 'shortcut'
? slot.type === 'shortcut' && slot.index === index
: slot.type === 'combo' && slot.column === index && slot.stage === stage
}
const isStageDisabled = (column: number, stage: number) =>
stage > 0 && !arrangement.value.combos[column][stage - 1]
const isStageConnected = (column: number, stage: number) =>
Boolean(arrangement.value.combos[column][stage] && arrangement.value.combos[column][stage + 1])
const comboTitle = (column: number, stage: number, key: string) =>
isStageDisabled(column, stage)
? `请先设置 ${stage} 阶段`
: getSkill(arrangement.value.combos[column][stage])?.name || `${stage + 1}阶段 ${key}`
const openPicker = (type: EditingSlot['type'], index: number, stage = 0) => {
if (type === 'combo' && isStageDisabled(index, stage)) return
editingSlot.value = type === 'shortcut' ? { type, index } : { type, column: index, stage }
}
const isSkillUsed = (skillId: string) =>
selectedSkillIds.value.has(skillId) && currentValue.value !== skillId
const selectSkill = (skillId: string) => {
const slot = editingSlot.value
if (!slot || isSkillUsed(skillId)) return
const next: ISkillShortcuts = {
shortcuts: [...arrangement.value.shortcuts],
combos: arrangement.value.combos.map((column) => [...column]),
}
if (slot.type === 'shortcut') next.shortcuts[slot.index] = skillId
else {
next.combos[slot.column][slot.stage] = skillId
if (!skillId) next.combos[slot.column].fill('', slot.stage)
}
emit('update:modelValue', next)
if (slot.type === 'combo' && skillId) {
editingSlot.value = slot.stage < stageCount - 1 ? { ...slot, stage: slot.stage + 1 } : null
}
}
const resetCombo = (column: number) => {
emit('update:modelValue', {
shortcuts: [...arrangement.value.shortcuts],
combos: arrangement.value.combos.map((items, index) =>
index === column ? Array.from({ length: stageCount }, () => '') : [...items],
),
})
if (editingSlot.value?.type === 'combo' && editingSlot.value.column === column) {
editingSlot.value = { type: 'combo', column, stage: 0 }
}
}
const collapse = () => {
editingSlot.value = null
collapsed.value = true
}
onClickOutside(settingRef, () => (editingSlot.value = null))
useEventListener(window, 'blur', () => (editingSlot.value = null))
</script>
<style scoped lang="scss">
.dark-knight-setting {
position: relative;
box-sizing: border-box;
width: 100%;
min-width: 0;
border: 1px solid var(--ui-disclosure-border);
background: var(--ui-disclosure-background);
color: var(--ui-disclosure-state);
&.is-collapsed {
width: var(--ui-control-height-large);
border: 0;
background: transparent;
}
}
.dark-knight-setting__launcher img {
width: 30px;
height: 30px;
object-fit: contain;
}
.dark-knight-setting__header,
.skill-picker header {
display: flex;
min-height: var(--ui-disclosure-header-min-block-size);
align-items: center;
justify-content: space-between;
padding-inline: var(--ui-field-padding-inline);
border-bottom: 1px solid var(--ui-disclosure-divider);
background: var(--ui-disclosure-header-background);
}
.dark-knight-setting__header .ui-button {
--ui-control-height-default: 20px;
}
.dark-knight-setting__section {
padding: var(--ui-control-gap);
}
.dark-knight-setting__section h3 {
margin: 0 0 var(--ui-control-gap);
color: var(--ui-settings-section-foreground);
font-size: 12px;
font-weight: 400;
}
.shortcut-grid,
.combo-grid {
display: grid;
grid-template-columns: repeat(6, 28px);
justify-content: center;
gap: 5px;
}
.combo-column {
display: flex;
align-items: center;
flex-direction: column;
}
.skill-slot {
display: grid;
width: 28px;
height: 28px;
padding: 0;
place-items: center;
border: 1px solid var(--ui-border);
outline: 0;
background: var(--ui-item-slot-background);
color: var(--ui-muted-foreground);
font: inherit;
cursor: pointer;
box-shadow: inset 0 0 0 1px #000;
img {
width: 26px;
height: 26px;
object-fit: contain;
}
&:hover,
&:focus-visible,
&.is-selected {
border-color: var(--ui-ring);
}
&.is-disabled {
opacity: 0.35;
cursor: not-allowed;
}
}
.stage-arrow {
position: relative;
width: 20px;
height: 11px;
}
.stage-arrow::before {
content: '';
position: absolute;
top: 0;
left: 7px;
width: 6px;
height: 6px;
background: var(--ui-muted);
}
.stage-arrow::after {
content: '';
position: absolute;
top: 5px;
left: 3px;
border-top: 6px solid var(--ui-muted);
border-right: 7px solid transparent;
border-left: 7px solid transparent;
}
.stage-arrow.is-connected::before {
background: var(--ui-primary);
}
.stage-arrow.is-connected::after {
border-top-color: var(--ui-primary);
}
.combo-column > .ui-button {
--ui-control-height-compact: 18px;
width: 28px;
min-width: 28px;
margin-top: 4px;
padding-inline: 0;
}
.skill-picker {
position: absolute;
z-index: var(--ui-popup-z-index);
right: 0;
bottom: 0;
box-sizing: border-box;
width: min(232px, 100%);
border: 1px solid var(--ui-popover-border);
background: var(--ui-popover-background);
box-shadow: 0 5px 18px var(--ui-popover-shadow);
}
.skill-picker header > div {
display: flex;
gap: 4px;
}
.skill-picker__grid {
display: grid;
max-height: 390px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
padding: 5px;
overflow-y: auto;
}
.skill-picker__item {
display: flex;
min-width: 0;
padding: 3px 2px;
align-items: center;
flex-direction: column;
gap: 2px;
border: 1px solid var(--ui-option-divider);
outline: 0;
background: var(--ui-popover-background);
color: var(--ui-popover-foreground);
font: inherit;
cursor: pointer;
img {
width: 28px;
height: 28px;
}
span {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover:not(:disabled),
&:focus-visible,
&.is-active {
border-color: var(--ui-ring);
color: var(--ui-option-highlight-foreground);
}
&:disabled {
filter: grayscale(1);
opacity: 0.3;
cursor: not-allowed;
}
}
</style>
@@ -1,6 +1,7 @@
import { asyncComputed } from '@vueuse/core' import { asyncComputed } from '@vueuse/core'
import api from '@/api' import api from '@/api'
import './style.scss' import './style.scss'
import { UiDataSection } from '@/components/ui/data-section'
import { TabsRoot, UiTabsList, UiTabsTrigger } from '@/components/ui/tabs' import { TabsRoot, UiTabsList, UiTabsTrigger } from '@/components/ui/tabs'
export default defineComponent({ export default defineComponent({
@@ -86,32 +87,48 @@ export default defineComponent({
const renderSkillDetail = () => ( const renderSkillDetail = () => (
<> <>
<div class="skill_title">基本信息</div> <UiDataSection
<div class="skill-detail__stack"> class="skill-detail__section"
{basic.value.map( title="基本信息"
(item) => compact
!!item.value && ( showState={false}
<div class="item skill-detail__row"> indicatorPosition="end"
<span class="label">{item.label}</span> >
<span class="value">{item.value}</span> <div class="skill-detail__stack">
</div> {basic.value.map(
), (item) =>
)} !!item.value && (
</div> <div class="item skill-detail__row">
<div class="skill_title">技能描述</div> <span class="label">{item.label}</span>
<div class="skill-detail__description-frame"> <span class="value">{item.value}</span>
<div class="skill-detail__scroll-content"> </div>
{desc.value?.map((item) => ( ),
// item.startsWith('[') || item.startsWith('-') || item.startsWith('<') ? ( )}
// <div class="min-h-10px">{item}</div>
// ) :
<div class="skill-detail__description-line">{item}</div>
))}
</div> </div>
</div> </UiDataSection>
<UiDataSection
class="skill-detail__section"
title="技能描述"
compact
showState={false}
indicatorPosition="end"
>
<div class="skill-detail__description-frame">
<div class="skill-detail__scroll-content">
{desc.value?.map((item) => (
<div class="skill-detail__description-line">{item}</div>
))}
</div>
</div>
</UiDataSection>
{detail.value.length > 0 && ( {detail.value.length > 0 && (
<> <UiDataSection
<div class="skill_title">技能属性</div> class="skill-detail__section skill-detail__section--grow"
title="技能属性"
compact
showState={false}
indicatorPosition="end"
>
<div class="skill-detail__flex-scroll"> <div class="skill-detail__flex-scroll">
<div class="skill-detail__stack"> <div class="skill-detail__stack">
{detail.value.map((item) => ( {detail.value.map((item) => (
@@ -119,7 +136,7 @@ export default defineComponent({
))} ))}
</div> </div>
</div> </div>
</> </UiDataSection>
)} )}
</> </>
) )
@@ -132,10 +149,17 @@ export default defineComponent({
<> <>
<div class="skill-detail__stack"> <div class="skill-detail__stack">
<div class="skill_vp_title">{item.name}</div> <div class="skill_vp_title">{item.name}</div>
<div class="skill_title">技能描述</div> <UiDataSection
<div class="skill-detail__evolution-scroll"> class="skill-detail__section"
<div class="skill-detail__evolution-copy">{item.descDetail}</div> title="技能描述"
</div> compact
showState={false}
indicatorPosition="end"
>
<div class="skill-detail__evolution-scroll">
<div class="skill-detail__evolution-copy">{item.descDetail}</div>
</div>
</UiDataSection>
</div> </div>
</> </>
))} ))}
@@ -46,6 +46,29 @@
overflow-y: auto; overflow-y: auto;
} }
.skill-detail__section {
flex: 0 0 auto;
border-color: #3e4040;
}
.skill-detail__section--grow {
min-height: 0;
flex: 1;
}
.skill-detail__section .ui-data-section__content {
min-height: 0;
padding: 0;
}
.skill-detail__section--grow .ui-data-section__content {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.skill-detail__evolution { .skill-detail__evolution {
height: auto; height: auto;
} }
@@ -136,16 +159,6 @@
} }
} }
.skill_title {
border: 1px solid #3e4040;
color: #c1a238;
background: linear-gradient(180deg, #2b292b 0%, #0b0b0b 100%);
height: 20px;
display: flex;
align-items: center;
padding: 0px 5px;
}
.item { .item {
background-color: #171616; background-color: #171616;
padding: 0px 5px; padding: 0px 5px;
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

@@ -1,13 +1,17 @@
import { getImageURL } from '@/utils/images'
import type { ISkill } from '@/api/info/type' import type { ISkill } from '@/api/info/type'
import { useVModel } from '@vueuse/core'
import { UiButton } from '@/components/ui/button' import { UiButton } from '@/components/ui/button'
import { getImageURL } from '@/utils/images'
import { useVModel } from '@vueuse/core'
import addIcon from './img/add.svg' import addIcon from './img/add.svg'
import maxIcon from './img/max.svg' import maxIcon from './img/max.svg'
import minIcon from './img/min.svg' import minIcon from './img/min.svg'
import subIcon from './img/sub.svg' import subIcon from './img/sub.svg'
import './style.scss' import './style.scss'
const rowPrefixes = ['通用', 1, '', 10, '', 20, '', 30, '', 40, 50, 60, 70, 80, 90, 100]
const defaultColumnCount = 11
const altersWithoutRowPrefix = new Set(['creator', 'dark_knight'])
export default defineComponent({ export default defineComponent({
name: 'SkillTree', name: 'SkillTree',
props: { props: {
@@ -21,269 +25,259 @@ export default defineComponent({
}, },
bindAwake: { bindAwake: {
type: Number, type: Number,
default: () => 50, default: 50,
},
alter: {
type: String,
default: '',
}, },
}, },
emits: ['chooseSkill'], emits: ['chooseSkill', 'update:lvInfo', 'update:bindAwake'],
setup(props, { emit }) { setup(props, { emit }) {
const skills = computed(() => props.skills)
const lvInfo = useVModel(props, 'lvInfo') const lvInfo = useVModel(props, 'lvInfo')
const bindAwake = useVModel(props, 'bindAwake') const bindAwake = useVModel(props, 'bindAwake')
// const emit = defineEmits(['chooseSkill']) const activeSkill = ref(-1)
const getSkill = (lvStart: number, lvEnd: number, position: number) => { const skillGrid = computed(() => {
if (position >= 0) const grid = new Map<string, ISkill>()
return skills.value.find( const occupiedRows = new Set<number>()
(item) => let maxColumn = defaultColumnCount - 1
item.line > lvStart - 5 &&
item.line <= Math.max(lvEnd - 5, 1) && props.skills.forEach((skill) => {
item.position == position, const [column, row] = skill.cellPosition ?? []
) if (!Number.isInteger(column) || !Number.isInteger(row) || column < 0 || row < 0) return
else grid.set(`${row}:${column}`, skill)
return skills.value.filter( occupiedRows.add(row)
(item) => item.line > lvStart - 5 && item.line <= Math.max(lvEnd - 5, 1), maxColumn = Math.max(maxColumn, column)
) })
return {
grid,
columns: Array.from({ length: maxColumn + 1 }, (_, index) => index),
rows: Array.from(occupiedRows).sort((left, right) => left - right),
}
})
const showRowPrefix = computed(() => !altersWithoutRowPrefix.has(props.alter))
const isExtraAlter = computed(() => altersWithoutRowPrefix.has(props.alter))
const getRowPrefix = (row: number) => {
const lastRow = skillGrid.value.rows.at(-1) ?? -1
if (lastRow >= rowPrefixes.length && row >= rowPrefixes.length - 1) {
return row === lastRow ? rowPrefixes.at(-1) : ''
}
return rowPrefixes[row]
} }
const lvList = [15, 20, 25, 30, 35, 40, 45, 50, 60, 70, 80, 90, 100] const getLastPositionedSkill = (learnLv: number) =>
props.skills
.filter((skill) => skill.learnLv === learnLv && skill.type === 'active' && skill.bind)
.sort((left, right) => {
const [leftColumn = -1, leftRow = -1] = left.cellPosition ?? []
const [rightColumn = -1, rightRow = -1] = right.cellPosition ?? []
return leftRow - rightRow || leftColumn - rightColumn
})
.at(-1)
const columns = Array.from({ length: 11 }, (_, index) => index) const awakeSkill1 = computed(() =>
props.skills.find((skill) => skill.learnLv === 50 && skill.type === 'active' && skill.bind),
)
const awakeSkill2 = computed(() =>
props.skills.find((skill) => skill.learnLv === 85 && skill.type === 'active' && skill.bind),
)
const extraAwakeSkill2 = computed(() =>
props.alter === 'creator'
? props.skills.find((skill) => skill.learnLv === 85 && skill.name === '生命之树')
: getLastPositionedSkill(85),
)
const extraAwakeSkill3 = computed(() => getLastPositionedSkill(100))
const activeSkill = ref<number>(-1) watchEffect(() => {
if (isExtraAlter.value && bindAwake.value !== 85) bindAwake.value = 85
})
const chooseSkill = (skill?: ISkill, operation: string = 'add') => { const actionSkillLevel = (skill: ISkill, operation: string) => {
const key = skill.id.toString()
const current = lvInfo.value[key] ?? { lv: 0, vp: 0, up: 0 }
if (!lvInfo.value[key]) lvInfo.value[key] = current
if (operation === 'add') current.lv = Math.min(current.lv + 1, skill.maxLv)
if (operation === 'addMax') current.lv = skill.maxLearnLv
if (operation === 'sub') current.lv = Math.max(current.lv - 1, 0)
if (operation === 'subMax') lvInfo.value[key] = { lv: 0, vp: 0, up: 0 }
}
const chooseSkill = (skill?: ISkill, operation = 'add') => {
if (!skill) { if (!skill) {
activeSkill.value = -1 activeSkill.value = -1
return return
} }
if (activeSkill.value == skill.id) actionSkillLv(skill, operation) if (activeSkill.value === skill.id) actionSkillLevel(skill, operation)
else activeSkill.value = skill.id else activeSkill.value = skill.id
emit('chooseSkill', skill.id) emit('chooseSkill', skill.id)
} }
const awakeSkill_1 = computed(() => const hasAwakeBinding = (skill: ISkill) =>
skills.value.find((item) => item.learnLv == 50 && item.type == 'active' && item.bind), isExtraAlter.value
) ? skill.id === extraAwakeSkill3.value?.id
const awakeSkill_2 = computed(() => : skill.learnLv === 100 && skill.bind
skills.value.find((item) => item.learnLv == 85 && item.type == 'active' && item.bind),
)
const actionSkillLv = (skill: ISkill, operation: string) => { const renderSkillAction = (skill: ISkill) => {
if (operation == 'add') { const level = lvInfo.value[skill.id.toString()]?.lv ?? 0
lvInfo.value[skill.id.toString()].lv = Math.min( const action = (event: Event, operation: string) => {
lvInfo.value[skill.id.toString()]?.lv + 1, event.stopPropagation()
skill.maxLv, actionSkillLevel(skill, operation)
)
return
} }
if (operation == 'addMax') {
lvInfo.value[skill.id.toString()].lv = skill.maxLearnLv
return
}
if (operation == 'sub') {
lvInfo.value[skill.id.toString()].lv = Math.max(
lvInfo.value[skill.id.toString()]?.lv - 1,
0,
)
return
}
if (operation == 'subMax') {
lvInfo.value[skill.id.toString()] = { lv: 0, vp: 0, up: 0 }
return
}
emit('chooseSkill', skill.id)
}
const chooseBindAwake = (lv: number) => {
bindAwake.value = lv
}
const renderSkill = (skill: ISkill, index: number) => {
const info = lvInfo.value?.[skill.id.toString()]
let color = 'white'
if (info?.lv < skill.maxLearnLv) color = '#32E432'
if (info?.lv > skill.maxLearnLv) color = '#58E0FC'
return ( return (
<> <div class="skill-active skill-tree__actions">
<div class="skill-tree__slot"> <div class="skill-tree__action-list">
<div class="skill-tree__enhancements"> <UiButton
{info?.vp > 0 && <div class={`vp_${info.vp}`}></div>} size="icon"
{info?.up > 0 && <div class={`up_${info.up}`}></div>} class="skill-action-button"
</div> aria-label="技能等级归零"
{activeSkill.value == skill.id && renderSkillAction(skill)} disabled={level === 0}
<div onClick={(event: Event) => action(event, 'subMax')}
class={['skill-tree__skill', skill.learnLv == 100 && skill.bind ? '' : 'skill']}
onClick={(e) => {
e.stopPropagation()
chooseSkill(skill)
}}
onContextmenu={(e) => {
e.stopPropagation()
e.preventDefault()
chooseSkill(skill, 'sub')
}}
key={index}
> >
<img <img data-icon src={minIcon} alt="" />
class={['skill-tree__icon', skill.type === 'passive' ? 'passive' : '']} </UiButton>
src={getImageURL(skill.icon)} <UiButton
alt={skill.name} size="icon"
></img> class="skill-action-button"
<div class="skill-tree__level" style={{ color }}> aria-label="技能等级减一"
{info?.lv ?? 0} disabled={level === 0}
</div> onClick={(event: Event) => action(event, 'sub')}
</div> >
{skill.learnLv == 100 && skill.bind && ( <img data-icon src={subIcon} alt="" />
<> </UiButton>
<div class={`skill-tree__awake awake_${bindAwake.value}`}> <UiButton
<div class="skill-tree__awake-option" onClick={() => chooseBindAwake(50)}> size="icon"
<img class="skill-action-button"
class="skill-tree__icon skill-tree__awake-icon" aria-label="技能等级加一"
src={getImageURL(awakeSkill_1.value?.icon ?? '')} onClick={(event: Event) => action(event, 'add')}
></img> >
<div class="skill-tree__awake-label">1次</div> <img data-icon src={addIcon} alt="" />
</div> </UiButton>
<div class="skill-tree__awake-option" onClick={() => chooseBindAwake(85)}> <UiButton
<img size="icon"
class="skill-tree__icon skill-tree__awake-icon" class="skill-action-button"
src={getImageURL(awakeSkill_2.value?.icon ?? '')} aria-label="技能等级加满"
></img> disabled={level >= skill.maxLearnLv}
<div class="skill-tree__awake-label">2次</div> onClick={(event: Event) => action(event, 'addMax')}
</div> >
</div> <img data-icon src={maxIcon} alt="" />
</> </UiButton>
)}
</div> </div>
</> </div>
) )
} }
const renderSkillAction = (skill: ISkill) => ( const renderNormalAwakeBinding = () => (
<> <div class={`skill-tree__awake awake_${bindAwake.value}`}>
<> <button
<div class="skill-active skill-tree__actions"> type="button"
<div class="skill-tree__action-list"> class="skill-tree__awake-option"
<UiButton aria-pressed={bindAwake.value === 50}
size="icon" onClick={() => (bindAwake.value = 50)}
class="skill-action-button" >
aria-label="技能等级归零" <img
disabled={lvInfo.value[skill.id.toString()]?.lv == 0} class="skill-tree__icon skill-tree__awake-icon"
onClick={(e: Event) => { src={getImageURL(awakeSkill1.value?.icon ?? '')}
e.stopPropagation() alt={awakeSkill1.value?.name ?? '一觉'}
actionSkillLv(skill, 'subMax') />
}} <span class="skill-tree__awake-label">1次</span>
> </button>
<img data-icon src={minIcon} alt="" /> <button
</UiButton> type="button"
<UiButton class="skill-tree__awake-option"
size="icon" aria-pressed={bindAwake.value === 85}
class="skill-action-button" onClick={() => (bindAwake.value = 85)}
aria-label="技能等级减一" >
disabled={lvInfo.value[skill.id.toString()]?.lv == 0} <img
onClick={(e: Event) => { class="skill-tree__icon skill-tree__awake-icon"
e.stopPropagation() src={getImageURL(awakeSkill2.value?.icon ?? '')}
actionSkillLv(skill, 'sub') alt={awakeSkill2.value?.name ?? '二觉'}
}} />
> <span class="skill-tree__awake-label">2次</span>
<img data-icon src={subIcon} alt="" /> </button>
</UiButton> </div>
<UiButton
size="icon"
class="skill-action-button"
aria-label="技能等级加一"
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'add')
}}
>
<img data-icon src={addIcon} alt="" />
</UiButton>
<UiButton
size="icon"
class="skill-action-button"
aria-label="技能等级加满"
disabled={lvInfo.value[skill.id.toString()]?.lv >= skill.maxLearnLv}
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'addMax')
}}
>
<img data-icon src={maxIcon} alt="" />
</UiButton>
{/* <img
src={`${new URL('./img/min.svg', import.meta.url).href}`}
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'subMax')
}}
></img>
<img
src={`${new URL('./img/sub.svg', import.meta.url).href}`}
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'sub')
}}
></img>
<img
src={`${new URL('./img/add.svg', import.meta.url).href}`}
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'add')
}}
></img>
<img
src={`${new URL('./img/max.svg', import.meta.url).href}`}
onClick={(e: Event) => {
e.stopPropagation()
actionSkillLv(skill, 'addMax')
}}
></img> */}
</div>
</div>
</>
</>
) )
return () => ( const renderExtraAwakeBinding = () => (
<> <div class="skill-tree__extra-awake">
<div class="skill-tree" onClick={() => (activeSkill.value = -1)}> <div class="skill-tree__extra-awake-option">
{((getSkill(0, 15, -1) ?? []) as ISkill[]).length > 0 && ( <img
<> class="skill-tree__icon"
<div class="skill-tree-line"> src={getImageURL(extraAwakeSkill2.value?.icon ?? '')}
<div class="skill-tree__row-label"> alt={extraAwakeSkill2.value?.name ?? '二觉'}
其<br />余 />
</div> <span>2次</span>
{((getSkill(0, 15, -1) ?? []) as ISkill[])?.map((skill, index) =>
renderSkill(skill, index),
)}
</div>
</>
)}
{lvList.map((lv, index) =>
((getSkill(lv, lvList[index + 1] ?? 150, -1) as ISkill[])?.length ?? 0) > 0 ? (
<div class="skill-tree-line" key={index}>
{lv == 1 || lv % 10 == 0 ? (
<div class="skill-tree__row-label">{lv}</div>
) : (
<div class="skill-tree__placeholder"></div>
)}
{columns.map((column) => {
const skill = getSkill(lv, lvList[index + 1] ?? 150, column) as ISkill
return skill ? (
renderSkill(skill, index)
) : (
<div class="skill-tree__placeholder"></div>
)
})}
<div class="skill-tree__row-end" onClick={() => (activeSkill.value = -1)}></div>
</div>
) : (
<></>
),
)}
</div> </div>
</> </div>
)
const renderSkill = (skill: ISkill) => {
const info = lvInfo.value[skill.id.toString()]
const color =
(info?.lv ?? 0) < skill.maxLearnLv
? '#32e432'
: (info?.lv ?? 0) > skill.maxLearnLv
? '#58e0fc'
: 'white'
return (
<div class="skill-tree__slot" key={skill.id}>
<div class="skill-tree__enhancements">
{(info?.vp ?? 0) > 0 && <span class={`vp_${info.vp}`}></span>}
{(info?.up ?? 0) > 0 && <span class={`up_${info.up}`}></span>}
</div>
{activeSkill.value === skill.id && renderSkillAction(skill)}
<button
type="button"
class={['skill-tree__skill', hasAwakeBinding(skill) ? '' : 'skill']}
aria-label={`${skill.name},当前等级 ${info?.lv ?? 0}`}
onClick={(event) => {
event.stopPropagation()
chooseSkill(skill)
}}
onContextmenu={(event) => {
event.stopPropagation()
event.preventDefault()
chooseSkill(skill, 'sub')
}}
>
<img
class={['skill-tree__icon', skill.type === 'passive' ? 'passive' : '']}
src={getImageURL(skill.icon)}
alt=""
/>
<span class="skill-tree__level" style={{ color }}>
{info?.lv ?? 0}
</span>
</button>
{hasAwakeBinding(skill) &&
(isExtraAlter.value ? renderExtraAwakeBinding() : renderNormalAwakeBinding())}
</div>
)
}
return () => (
<div class="skill-tree" onClick={() => (activeSkill.value = -1)}>
{skillGrid.value.rows.map((row) => (
<div class="skill-tree-line" key={row}>
<div class="skill-tree__row-label">{showRowPrefix.value ? getRowPrefix(row) : ''}</div>
{skillGrid.value.columns.map((column) => {
const skill = skillGrid.value.grid.get(`${row}:${column}`)
return skill ? (
renderSkill(skill)
) : (
<div class="skill-tree__placeholder" key={column} />
)
})}
<div class="skill-tree__row-end" />
</div>
))}
</div>
) )
}, },
}) })
@@ -1,6 +1,7 @@
.skill-tree { .skill-tree {
/* Preserve the original 11-column tree rhythm while allowing its host to scroll on narrow screens. */ /* Fill the standard tree viewport; expanded special-job grids keep their intrinsic width and scroll. */
min-width: 550px; width: max-content;
min-width: 100%;
} }
.skill-tree-line { .skill-tree-line {
@@ -48,6 +49,10 @@
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
cursor: pointer; cursor: pointer;
border: 0;
background-color: transparent;
color: inherit;
font: inherit;
} }
.skill-tree__icon { .skill-tree__icon {
@@ -89,6 +94,38 @@
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
cursor: pointer; cursor: pointer;
border: 0;
background: transparent;
color: inherit;
font: inherit;
}
.skill-tree__extra-awake {
position: absolute;
top: -3px;
left: 0;
width: 117px;
height: 54px;
background: url('./img/extra_awake_85.png') 0 0 / 100% 100% no-repeat;
}
.skill-tree__extra-awake-option {
position: absolute;
z-index: 2;
top: 3px;
left: 78px;
display: flex;
width: 36px;
height: 48px;
box-sizing: border-box;
padding-block: 3px;
align-items: center;
flex-direction: column;
color: #ffc701;
span {
margin-top: 1px;
}
} }
.skill-tree__awake-icon { .skill-tree__awake-icon {
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+211 -201
View File
@@ -1,168 +1,145 @@
<template> <template>
<div ref="skillTreeRef" class="skill-page"> <main class="skill-page" :class="{ 'skill-page--dark-knight': showDarkKnightSkillSetting }">
<div class="skill-page__panel"> <section class="skill-page__panel" aria-label="技能配置">
<div class="skill-page__tabs"> <TabsRoot v-model="tab" class="skill-page__tabs">
<div class="skill-page__tab" @click="tab = 0" :class="{ active: tab === 0 }">技能加点</div> <UiTabsList aria-label="技能配置类别">
<div class="skill-page__tab-divider">|</div> <UiTabsTrigger value="tree">技能加点</UiTabsTrigger>
<div class="skill-page__tab" @click="tab = 1" :class="{ active: tab === 1 }">技能定制</div> <UiTabsTrigger value="customize">技能定制</UiTabsTrigger>
</div> </UiTabsList>
</TabsRoot>
<div class="skill-page__viewport"> <div class="skill-page__viewport">
<div v-if="tab === 0" class="skill-page__tree-scroll"> <div v-if="tab === 'tree'" class="skill-page__tree-scroll">
<SkillTree <SkillTree
:skills="infoStore.skills" :skills="infoStore.skills"
v-model:lvInfo="configStore.config.skills" :alter="infoStore.infos?.alter"
v-model:bindAwake="configStore.config.bindAwake" v-model:lv-info="configStore.config.skills"
@chooseSkill="setCurrentSkill" v-model:bind-awake="configStore.config.bindAwake"
></SkillTree> @choose-skill="setCurrentSkill"
/>
</div> </div>
<div v-if="tab === 1" class="skill-page__customize">
<div class="item-head" 角色等级>技能突破</div>
<SkillUP
class="skill-page__customize-section"
:skills="infoStore.skills"
v-model:lvInfo="configStore.config.skills"
v-model:bindAwake="configStore.config.bindAwake"
:buffer="infoStore.infos?.buffer"
></SkillUP>
<div class="item-head" 角色等级>技能进化</div>
<div class="skill-page__customize-section skill-page__vp-scroll">
<SkillVP
:skills="infoStore.skills"
v-model:lvInfo="configStore.config.skills"
v-model:bindAwake="configStore.config.bindAwake"
@chooseSkill="setCurrentSkill"
></SkillVP>
</div>
</div>
</div>
</div>
<div <div v-else class="skill-page__customize">
class="skill-page__detail-launcher" <UiDataSection title="技能突破" class="skill-page__customize-section">
v-if="curCurrentSkill.skillId && curCurrentSkill.level > 0 && !showSkillDetail" <SkillUP
> :skills="infoStore.skills"
<div class="skill-page__detail-launcher-frame"> v-model:lv-info="configStore.config.skills"
<div class="skill-page__detail-launcher-border"> v-model:bind-awake="configStore.config.bindAwake"
<div :buffer="infoStore.infos?.buffer"
class="skill-page__detail-launcher-button" />
style="background: linear-gradient(to bottom, #313330 0%, #0f110f 100%)" </UiDataSection>
@click="showSkillDetail = true" <UiDataSection title="技能进化" class="skill-page__customize-section">
> <div class="skill-page__vp-scroll">
<img src="./components/img/detail.png" alt="" /> <SkillVP
技能详情 :skills="infoStore.skills"
</div> v-model:lv-info="configStore.config.skills"
v-model:bind-awake="configStore.config.bindAwake"
@choose-skill="setCurrentSkill"
/>
</div>
</UiDataSection>
</div> </div>
</div> </div>
</div> </section>
</div>
<div <aside class="skill-page__context" aria-label="技能详情">
class="skill-page__detail" <header class="skill-page__context-header">
:style="style" <span>技能详情</span>
ref="SkillDetailRef" <span v-if="currentSkill" class="skill-page__context-level">
v-if="curCurrentSkill.skillId && curCurrentSkill.level > 0 && showSkillDetail" Lv{{ currentSkillLevel }} / {{ currentSkill.maxLearnLv }}
> </span>
<SkillDetail :skillId="curCurrentSkill.skillId" :level="curCurrentSkill.level"></SkillDetail> </header>
</div>
<div v-if="currentSkill && currentSkillLevel > 0" class="skill-page__detail">
<div class="skill-page__selected-skill">
<img :src="getImageURL(currentSkill.icon)" :alt="currentSkill.name" />
<div>
<strong>{{ currentSkill.name }}</strong>
<span>{{ currentSkill.type === 'passive' ? '被动技能' : '主动技能' }}</span>
</div>
</div>
<div class="skill-page__detail-scroll">
<SkillDetail :skill-id="currentSkill.uuid" :level="currentSkillLevel" />
</div>
</div>
<div v-else class="skill-page__empty">
<img src="./components/img/detail.png" alt="" />
<span>在技能树中选择技能查看详情</span>
</div>
<DarkKnightSkillSetting
v-if="showDarkKnightSkillSetting"
:skills="infoStore.skills"
v-model="configStore.config.skillShortcuts"
class="skill-page__dark-knight-setting"
/>
</aside>
</main>
</template> </template>
<script lang="ts" setup name="Index"> <script setup lang="ts">
import { useInfoStore } from '@/stores/info' import { UiDataSection } from '@/components/ui/data-section'
import SkillTree from './components/SkillTree' import { TabsRoot, UiTabsList, UiTabsTrigger } from '@/components/ui/tabs'
import SkillVP from './components/SkillVP' import { useConfigStore, useInfoStore } from '@/stores'
import SkillUP from './components/SkillUP' import { getImageURL } from '@/utils/images'
import { useConfigStore } from '@/stores' import DarkKnightSkillSetting from './components/DarkKnightSkillSetting/index.vue'
import SkillDetail from './components/SkillDetail' import SkillDetail from './components/SkillDetail'
import { onClickOutside } from '@vueuse/core' import SkillTree from './components/SkillTree'
const props = defineProps<{ import SkillUP from './components/SkillUP'
import SkillVP from './components/SkillVP'
defineProps<{
alter: string alter: string
}>() }>()
const tab = ref(0)
const tab = ref<'tree' | 'customize'>('tree')
const infoStore = useInfoStore() const infoStore = useInfoStore()
const configStore = useConfigStore() const configStore = useConfigStore()
const currentSkillId = ref<number>()
const curSkillId = ref() const showDarkKnightSkillSetting = computed(() => infoStore.infos?.alter === 'dark_knight')
const currentSkill = computed(() =>
const curCurrentSkill = computed(() => { infoStore.skills.find((skill) => skill.id === currentSkillId.value),
const skill = infoStore.skills.find((skill) => skill.uuid === curSkillId.value) )
return { const currentSkillLevel = computed(() =>
skillId: skill?.uuid ?? '', currentSkill.value ? (configStore.config.skills[currentSkill.value.id]?.lv ?? 0) : 0,
level: configStore.config.skills[skill?.id || 0]?.lv || 0, )
}
})
const showSkillDetail = ref(false)
const skillTreeRef = ref<HTMLHtmlElement>()
const SkillDetailRef = ref<HTMLHtmlElement>()
const style = computed(() => {
return {
zIndex: 99,
height: `${skillTreeRef.value?.clientHeight ?? 0}px`,
left: `${(skillTreeRef.value?.clientWidth ?? 0) + 5}px`,
}
})
const setCurrentSkill = (skillId: number) => { const setCurrentSkill = (skillId: number) => {
curSkillId.value = infoStore.skills.find((skill) => skill.id === skillId)?.uuid || '' currentSkillId.value = skillId
} }
onClickOutside(
SkillDetailRef,
(event) => {
showSkillDetail.value = false
},
{
ignore: [skillTreeRef],
},
)
</script> </script>
<style lang="scss" scoped> <style scoped lang="scss">
.skill-page { .skill-page {
position: relative; display: grid;
display: flex;
width: 100%; width: 100%;
min-width: 0; min-width: 0;
height: 100%; height: 100%;
min-height: 0;
grid-template-columns: minmax(0, 1fr) minmax(210px, 0.38fr);
gap: var(--ui-character-workspace-gap);
overflow: hidden;
container-type: inline-size;
}
.skill-page__panel,
.skill-page__context {
min-width: 0;
min-height: 0;
border: 1px solid var(--ui-composition-border);
background: var(--ui-skill-page-background, var(--ui-composition-background));
} }
.skill-page__panel { .skill-page__panel {
display: flex; display: flex;
width: 100%;
min-width: 0;
height: 100%;
flex-direction: column; flex-direction: column;
background: var(--ui-skill-page-background, #101012); overflow: hidden;
} }
.skill-page__tabs { .skill-page__tabs {
display: flex;
min-height: 40px;
flex: 0 0 40px;
color: var(--ui-skill-page-muted, #6c5e4a);
font-weight: 700;
}
.skill-page__tab,
.skill-page__tab-divider {
display: flex;
align-items: center;
justify-content: center;
}
.skill-page__tab {
position: relative;
min-width: 0;
flex: 1;
font-size: 15px;
cursor: pointer;
}
.skill-page__tab-divider {
flex: 0 0 auto; flex: 0 0 auto;
font-size: 18px; border-bottom: 1px solid var(--ui-disclosure-divider);
font-weight: 400;
} }
.skill-page__viewport, .skill-page__viewport,
@@ -174,6 +151,7 @@ onClickOutside(
} }
.skill-page__viewport { .skill-page__viewport {
display: flex;
overflow: hidden; overflow: hidden;
} }
@@ -182,105 +160,137 @@ onClickOutside(
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: auto; overflow: auto;
overscroll-behavior: contain;
} }
.skill-page__customize { .skill-page__customize {
display: flex; display: grid;
width: 100%;
height: 100%; height: 100%;
flex-direction: column; grid-template-rows: repeat(2, minmax(0, 1fr));
gap: var(--ui-control-gap);
padding: var(--ui-control-gap);
overflow: hidden; overflow: hidden;
} }
.skill-page__customize-section { .skill-page__customize-section {
min-height: 0; min-height: 0;
flex: 1; overflow: hidden;
} }
.skill-page__customize-section :deep(.ui-data-section__content),
.skill-page__vp-scroll { .skill-page__vp-scroll {
width: 100%; height: 100%;
min-height: 0;
overflow: auto; overflow: auto;
} }
.skill-page__detail-launcher { .skill-page__context {
position: absolute;
z-index: 20;
top: 40px;
right: -64px;
}
.skill-page__detail-launcher-frame {
box-sizing: border-box;
display: flex; display: flex;
padding: 1px 1px 1px 2px;
color: var(--ui-skill-page-accent, #ae8d5a);
}
.skill-page__detail-launcher-border {
box-sizing: border-box;
display: flex;
padding: 1px 1px 1px 0;
border: 1px solid #322e20;
border-left: 0;
}
.skill-page__detail-launcher-button {
display: flex;
padding: 5px;
align-items: center;
flex-direction: column; flex-direction: column;
justify-content: center; overflow: hidden;
border: 1px solid #514531; }
border-radius: 2px;
background: linear-gradient(to bottom, #313330 0%, #0f110f 100%);
cursor: pointer;
img { .skill-page__context-header {
width: 30px; display: flex;
height: 30px; min-height: var(--ui-disclosure-header-min-block-size);
} flex: 0 0 auto;
align-items: center;
justify-content: space-between;
gap: var(--ui-control-gap);
padding-inline: var(--ui-field-padding-inline);
border-bottom: 1px solid var(--ui-disclosure-divider);
background: var(--ui-disclosure-header-background);
color: var(--ui-settings-section-foreground);
}
.skill-page__context-level {
color: var(--ui-disclosure-state);
font-family: Consolas, monospace;
font-size: 10px;
} }
.skill-page__detail { .skill-page__detail {
position: absolute; display: flex;
width: min(300px, 45%); min-height: 0;
height: 100%; flex: 1;
background: var(--ui-skill-page-background, #101012); flex-direction: column;
} }
.active { .skill-page__selected-skill {
color: #ccc088; display: grid;
position: relative; grid-template-columns: var(--ui-control-height-large) minmax(0, 1fr);
align-items: center;
gap: var(--ui-control-gap);
padding: var(--ui-control-padding-inline-default);
border-bottom: 1px solid var(--ui-disclosure-divider);
&::before { img {
content: ''; width: var(--ui-control-height-large);
position: absolute; height: var(--ui-control-height-large);
width: 80px; object-fit: contain;
height: 15px;
opacity: 0.35;
background: #bf8d20;
filter: blur(7px);
z-index: 2;
} }
&::after { strong,
content: ''; span {
position: absolute; display: block;
background: url('@/assets/img/active.png') no-repeat; }
width: 148px;
height: 16px; strong {
bottom: 2px; overflow: hidden;
transform: translate(0, 50%); color: var(--ui-settings-section-foreground);
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
span {
margin-top: 4px;
color: var(--ui-muted-foreground);
font-size: 10px;
} }
} }
.item-head { .skill-page__detail-scroll {
background: linear-gradient(#2b2817, #171407); min-height: 0;
border-top: 1px solid #423d2c; flex: 1;
border-bottom: 1px solid #211d15; overflow: auto;
overscroll-behavior: contain;
}
.skill-page__empty {
display: flex;
min-height: 150px;
flex: 1;
align-items: center;
flex-direction: column;
justify-content: center;
gap: var(--ui-control-gap);
padding: var(--ui-control-padding-inline-default);
color: var(--ui-muted-foreground);
text-align: center; text-align: center;
// 以下后面注释掉
height: 20px; img {
line-height: 20px; width: 34px;
font-weight: 14px; height: 34px;
object-fit: contain;
opacity: 0.72;
}
}
.skill-page__dark-knight-setting {
max-height: 58%;
flex: 0 0 auto;
overflow: visible;
}
@container (max-width: 650px) {
.skill-page {
grid-template-columns: minmax(0, 1fr);
}
.skill-page__context {
display: none;
}
} }
</style> </style>
@@ -538,8 +538,8 @@ function removeFavorite(item: UiFavoriteDrawerItem) {
<UiTabsList aria-label="物品目录" <UiTabsList aria-label="物品目录"
><UiTabsTrigger value="equipment">装备</UiTabsTrigger ><UiTabsTrigger value="equipment">装备</UiTabsTrigger
><UiTabsTrigger value="consumable">消耗品</UiTabsTrigger ><UiTabsTrigger value="consumable">消耗品</UiTabsTrigger
><UiTabsTrigger value="material">材料</UiTabsTrigger ><UiTabsTrigger value="material">制作材料</UiTabsTrigger
><UiTabsTrigger value="quest">任务</UiTabsTrigger></UiTabsList ><UiTabsTrigger value="quest">任务物品</UiTabsTrigger></UiTabsList
> >
<UiTabsContent value="equipment">展示武器、防具与首饰词条。</UiTabsContent> <UiTabsContent value="equipment">展示武器、防具与首饰词条。</UiTabsContent>
<UiTabsContent value="consumable">展示药剂、宝珠与特殊消耗品。</UiTabsContent> <UiTabsContent value="consumable">展示药剂、宝珠与特殊消耗品。</UiTabsContent>
@@ -990,7 +990,7 @@ footer {
background: #101111; background: #101111;
color: var(--ui-muted-foreground); color: var(--ui-muted-foreground);
font: font:
9px/1.4 Consolas, 11px/1.4 Consolas,
monospace; monospace;
} }
.catalog-content { .catalog-content {
@@ -1197,7 +1197,7 @@ label > span,
color: var(--dcalc-success); color: var(--dcalc-success);
} }
.status-danger { .status-danger {
color: var(--dcalc-danger); color: var(--ui-danger-foreground);
} }
.table-icon { .table-icon {
display: block; display: block;
@@ -1319,9 +1319,9 @@ footer {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-top: 14px; margin-top: 14px;
color: #81755f; color: var(--ui-muted-foreground);
font: font:
9px/1 Consolas, 11px/1 Consolas,
monospace; monospace;
letter-spacing: 1.2px; letter-spacing: 1.2px;
} }
+47 -1
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { useInfoStore } from './info' import { useInfoStore } from './info'
import api from '@/api' import api from '@/api'
import type { IEquipment } from '@/api/info/type' import type { IEquipment, ISkill } from '@/api/info/type'
import type { IResultSkillCount } from '@/api/calc/type' import type { IResultSkillCount } from '@/api/calc/type'
// | '上衣' // | '上衣'
// | '头肩' // | '头肩'
@@ -42,6 +42,11 @@ export interface IConfigEmblem {
items: string[] items: string[]
} }
export interface ISkillShortcuts {
shortcuts: string[]
combos: string[][]
}
export interface IConfig { export interface IConfig {
skills: Record<string, { lv: number; vp: number; up: number }> skills: Record<string, { lv: number; vp: number; up: number }>
equips: Record<string, IConfigEquip> equips: Record<string, IConfigEquip>
@@ -73,6 +78,7 @@ export interface IConfig {
options: Record<string, number> options: Record<string, number>
avatarLink: string avatarLink: string
emblems: Record<string, IConfigEmblem> emblems: Record<string, IConfigEmblem>
skillShortcuts: ISkillShortcuts
} }
const defaultEqusConfig = { const defaultEqusConfig = {
@@ -113,6 +119,39 @@ const createDefaultEmblemConfig = (): IConfigEmblem => ({
const defaultEmblemConfig = createDefaultEmblemConfig() const defaultEmblemConfig = createDefaultEmblemConfig()
const createDefaultSkillShortcuts = (): ISkillShortcuts => ({
shortcuts: Array.from({ length: 6 }, () => ''),
combos: Array.from({ length: 6 }, () => Array.from({ length: 5 }, () => '')),
})
const normalizeSkillShortcuts = (
arrangement?: Partial<ISkillShortcuts>,
skills: ISkill[] = [],
): ISkillShortcuts => {
const validSkillIds = new Set(skills.map((skill) => skill.id.toString()))
const skillIdByUuid = new Map(skills.map((skill) => [skill.uuid, skill.id.toString()]))
const normalizeSkillId = (value: unknown) => {
const key =
typeof value === 'number' ? value.toString() : typeof value === 'string' ? value : ''
if (!key) return ''
return validSkillIds.has(key) ? key : (skillIdByUuid.get(key) ?? '')
}
return {
shortcuts: Array.from({ length: 6 }, (_, index) =>
normalizeSkillId(arrangement?.shortcuts?.[index]),
),
combos: Array.from({ length: 6 }, (_, column) => {
const combo = Array.from({ length: 5 }, (_, stage) =>
normalizeSkillId(arrangement?.combos?.[column]?.[stage]),
)
const firstEmptyStage = combo.findIndex((skillId) => !skillId)
if (firstEmptyStage >= 0) combo.fill('', firstEmptyStage)
return combo
}),
}
}
export const useConfigStore = defineStore('configStore', () => { export const useConfigStore = defineStore('configStore', () => {
const config = ref<IConfig>({ const config = ref<IConfig>({
skills: {}, skills: {},
@@ -128,6 +167,7 @@ export const useConfigStore = defineStore('configStore', () => {
options: {}, options: {},
avatarLink: '', avatarLink: '',
emblems: {}, emblems: {},
skillShortcuts: createDefaultSkillShortcuts(),
}) })
const skillCountConfig = ref<IResultSkillCount[]>([]) const skillCountConfig = ref<IResultSkillCount[]>([])
@@ -161,6 +201,7 @@ export const useConfigStore = defineStore('configStore', () => {
options: {}, options: {},
avatarLink: '', avatarLink: '',
emblems: {}, emblems: {},
skillShortcuts: createDefaultSkillShortcuts(),
} }
} }
if (!config.value?.equips) config.value.equips = {} if (!config.value?.equips) config.value.equips = {}
@@ -172,6 +213,10 @@ export const useConfigStore = defineStore('configStore', () => {
if (!config.value?.options) config.value.options = {} if (!config.value?.options) config.value.options = {}
if (!config.value?.oaths) config.value.oaths = {} if (!config.value?.oaths) config.value.oaths = {}
if (!config.value?.emblems) config.value.emblems = {} if (!config.value?.emblems) config.value.emblems = {}
config.value.skillShortcuts = normalizeSkillShortcuts(
config.value.skillShortcuts,
infoStore.skills,
)
const part = [...infoStore.parts, '宠物', '副武器'].filter( const part = [...infoStore.parts, '宠物', '副武器'].filter(
(a) => !config.value?.equips.hasOwnProperty(a), (a) => !config.value?.equips.hasOwnProperty(a),
@@ -291,5 +336,6 @@ export const useConfigStore = defineStore('configStore', () => {
createDefaultOathConfig, createDefaultOathConfig,
defaultEmblemConfig, defaultEmblemConfig,
createDefaultEmblemConfig, createDefaultEmblemConfig,
createDefaultSkillShortcuts,
} }
}) })
+2
View File
@@ -19,6 +19,8 @@
--ui-accent-foreground: #fee97d; --ui-accent-foreground: #fee97d;
--ui-destructive: var(--dcalc-danger); --ui-destructive: var(--dcalc-danger);
--ui-destructive-foreground: #fff4f4; --ui-destructive-foreground: #fff4f4;
--ui-danger-foreground: #ef8883;
--ui-unavailable-foreground: #8f8b83;
--ui-border: var(--dcalc-brass-dark); --ui-border: var(--dcalc-brass-dark);
--ui-input: #5b472a; --ui-input: #5b472a;
--ui-ring: var(--dcalc-focus); --ui-ring: var(--dcalc-focus);
@@ -19,6 +19,8 @@
--ui-accent-foreground: #ffffff; --ui-accent-foreground: #ffffff;
--ui-destructive: #a73f45; --ui-destructive: #a73f45;
--ui-destructive-foreground: #fff4f4; --ui-destructive-foreground: #fff4f4;
--ui-danger-foreground: #f08b94;
--ui-unavailable-foreground: #8da0bc;
--ui-border: #40577a; --ui-border: #40577a;
--ui-input: #354b6e; --ui-input: #354b6e;
--ui-ring: #6fb5e6; --ui-ring: #6fb5e6;
+17 -6
View File
@@ -62,8 +62,8 @@
--ui-popup-z-index: 80; --ui-popup-z-index: 80;
--ui-field-padding-inline: 9px; --ui-field-padding-inline: 9px;
--ui-option-height: 27px; --ui-option-height: 27px;
--ui-tabs-height: 40px; --ui-tabs-height: 32px;
--ui-tab-height: 30px; --ui-tab-height: 24px;
/* /*
* Component geometry defaults. Every value is consumed through var(), so a component instance * Component geometry defaults. Every value is consumed through var(), so a component instance
@@ -132,10 +132,10 @@
100dvh - 16px - var(--ui-dialog-header-block-size) - var(--ui-dialog-footer-min-block-size) 100dvh - 16px - var(--ui-dialog-header-block-size) - var(--ui-dialog-footer-min-block-size)
); );
--ui-dialog-body-padding: 12px; --ui-dialog-body-padding: 12px;
--ui-tab-marker-inline-size: 39px; --ui-tab-marker-inline-size: 33px;
--ui-tab-marker-block-size: 5px; --ui-tab-marker-block-size: 4px;
--ui-tab-marker-arrow-inline-size: 9px; --ui-tab-marker-arrow-inline-size: 7px;
--ui-tab-marker-arrow-block-size: 4px; --ui-tab-marker-arrow-block-size: 3px;
--ui-number-fast-inline-size: 26px; --ui-number-fast-inline-size: 26px;
--ui-number-fast-block-size: 28px; --ui-number-fast-block-size: 28px;
--ui-number-fast-step-block-size: 14px; --ui-number-fast-step-block-size: 14px;
@@ -300,3 +300,14 @@
--ui-shop-action-block-size: 24px; --ui-shop-action-block-size: 24px;
--ui-shop-action-icon-size: 16px; --ui-shop-action-icon-size: 16px;
} }
@media (max-width: 520px) {
:root {
/* Preserve pixel artwork while giving standalone touch controls a 24px minimum hit box. */
--ui-slider-control-size: 24px;
--ui-select-icon-inline-size: 24px;
--ui-select-icon-block-size: 24px;
--ui-number-fast-block-size: 48px;
--ui-number-fast-step-block-size: 24px;
}
}