dev
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: frontend-migration
|
||||
applyTo: "frontendNew/src/pages/Dcalc/Character/**/*.vue,frontendNew/src/stores/**/*.ts,frontendNew/src/api/**/*.ts"
|
||||
description: "Use when migrating frontend Character pages to frontendNew, preserving config->execute->save flow, keeping /calc and IResult contract stable, and implementing same-page config+result with minimal behavior changes for Skill/Forge."
|
||||
---
|
||||
|
||||
# Frontend Migration Rules (frontend -> frontendNew)
|
||||
|
||||
## Core Target
|
||||
|
||||
- 迁移目标是行为保真,不是推倒重做。
|
||||
- 结果必须保持:配置变化可触发计算,结果区稳定刷新,配置可持久化。
|
||||
|
||||
## Required Data Flow
|
||||
|
||||
- 保持以下链路成立:
|
||||
- config mutate
|
||||
- Character 容器 watch config
|
||||
- `result.execute()`
|
||||
- `saveConfig()`
|
||||
- 不在 Skill/Forge 子页直接做并发调度或替代容器级触发策略。
|
||||
|
||||
## API/Type Contract
|
||||
|
||||
- 不修改 `/calc` 入参与返回结构。
|
||||
- 不破坏 `IResult` 的关键字段语义:`skills`、`info`、`suits`、`buffer`。
|
||||
|
||||
## Skill/Forge Migration Constraints
|
||||
|
||||
- Skill、Forge 优先沿用现有业务逻辑与限制条件(例如 VP/UP 数量限制、等级限制)。
|
||||
- 允许替换 UI 结构与组件,但不改核心规则语义。
|
||||
- 新行为必须可追踪到 `pushChangeSource`,便于结果回溯。
|
||||
|
||||
## Result Panel Requirements
|
||||
|
||||
- 必须具备 4 态:loading、error、empty、success。
|
||||
- 同页可查看结果,不依赖跳页。
|
||||
- 允许以 Tab 汇总展示(如详情/结果切换),但不得导致计算链路断开。
|
||||
|
||||
## Source of Truth
|
||||
|
||||
- 迁移策略以以下文件为准:
|
||||
- [frontendNew Migration Plan](frontendNew/MIGRATION_PLAN.md)
|
||||
- [角色信息与结果链路专项分析](frontend-角色信息与计算结果展示链路-专项分析.md)
|
||||
- [装备技能打造交互与布局设计](frontend-装备技能打造-完整交互与布局设计文档.md)
|
||||
|
||||
## Existing Pattern to Reuse
|
||||
|
||||
- 优先参考已迁移页面模式:
|
||||
- [EquipmentDictionary](frontendNew/src/pages/Dcalc/Character/EquipmentDictionary/Index.vue)
|
||||
- [Character Container](frontendNew/src/pages/Dcalc/Character/Index.vue)
|
||||
- [Config Store](frontendNew/src/stores/config.ts)
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
name: frontend-ui-chatgpt-layout
|
||||
applyTo: "frontendNew/src/pages/Dcalc/Character/**/*.vue,frontendNew/src/components/**/*.vue"
|
||||
description: "Use when implementing ChatGPT-like responsive layout and interactions in frontendNew: same-page configuration and result viewing, desktop dual-column + mobile sheet, minimal-disruption UX updates for Skill/Forge, and shadcn-vue-first component usage."
|
||||
---
|
||||
|
||||
# Frontend UI/Layout Rules (ChatGPT-like)
|
||||
|
||||
## Layout Goals
|
||||
|
||||
- 同页完成配置与结果查看。
|
||||
- 桌面端:配置主列 + 结果侧栏。
|
||||
- 移动端:结果区可切换为 Sheet/抽屉,避免拥挤。
|
||||
|
||||
## Responsive Baseline
|
||||
|
||||
- Desktop: 双栏布局(主内容 + 结果侧栏)。
|
||||
- Tablet: 允许上下堆叠或 Tab 汇总。
|
||||
- Mobile: 结果面板抽屉化,保留快速打开入口。
|
||||
|
||||
## Interaction Principles
|
||||
|
||||
- Skill/Forge 交互尽量小改,保持用户已有心智。
|
||||
- 新交互优先低侵入:Tab、Sheet、折叠区、局部详情面板。
|
||||
- 避免把“配置编辑”和“结果观察”分离到不同页面。
|
||||
|
||||
## Component Policy
|
||||
|
||||
- shadcn-vue 优先。
|
||||
- 复用 `frontendNew/src/components/ui` 现有能力。
|
||||
- 不引入额外 UI 框架(Element Plus、Ant Design Vue、Vuetify 等)。
|
||||
|
||||
## Result Panel UX
|
||||
|
||||
- 结果面板至少包含:
|
||||
- 结果状态反馈(loading/error/empty)
|
||||
- 技能结果主视图
|
||||
- 必要时通过 Tab 汇总详情视图
|
||||
- 排序、切换、基准对比操作要可见、可恢复、可撤销(清空基准)。
|
||||
|
||||
## Suggested Placement
|
||||
|
||||
- 角色容器布局锚点:
|
||||
- [Character Container](frontendNew/src/pages/Dcalc/Character/Index.vue)
|
||||
- 结果展示锚点:
|
||||
- [Result Panel](frontendNew/src/pages/Dcalc/Character/Result/Index.vue)
|
||||
- 迁移一致性参考:
|
||||
- [EquipmentDictionary](frontendNew/src/pages/Dcalc/Character/EquipmentDictionary/Index.vue)
|
||||
|
||||
## Design References
|
||||
|
||||
- [frontendNew Design](frontendNew/DESIGN.md)
|
||||
- [角色信息与结果链路专项分析](frontend-角色信息与计算结果展示链路-专项分析.md)
|
||||
- [装备技能打造交互与布局设计](frontend-装备技能打造-完整交互与布局设计文档.md)
|
||||
@@ -0,0 +1,55 @@
|
||||
# dcalc Agent Guide
|
||||
|
||||
本仓库前端迁移主线是将 frontend 迁移到 frontendNew。
|
||||
|
||||
## Scope
|
||||
|
||||
- 主线目录:frontendNew
|
||||
- 对照目录:frontend(仅用于行为对照,不作为新实现落点)
|
||||
- 本仓库优先维护 Dcalc Character 模块:equipment、skill、forge、result
|
||||
|
||||
## Runbook
|
||||
|
||||
- frontendNew 开发:`cd frontendNew && pnpm install && pnpm dev`
|
||||
- frontendNew 质量检查:`pnpm type-check && pnpm lint && pnpm build`
|
||||
- backend 开发:`cd backend && uv sync && python main.py`
|
||||
|
||||
## Non-Negotiables
|
||||
|
||||
- 不要改动 /calc 的 API 契约与 IResult 数据结构。
|
||||
- 不要打破配置链路:config 变化 -> result.execute -> saveConfig。
|
||||
- 不要为了 UI 重构而改写 Skill/Forge 的核心业务逻辑。
|
||||
- 优先复用 frontendNew 现有 shadcn-vue 组件,不引入新的 UI 框架。
|
||||
|
||||
## Frontend Migration Priorities
|
||||
|
||||
1. P0:Skill 配置回写完整(VP/UP 约束与持久化一致)。
|
||||
2. P0:Forge 配置编辑完整(装备/誓约/时装/徽章联动计算)。
|
||||
3. P0:Result 完整展示(loading/error/empty/success 四态与伤害明细)。
|
||||
4. P1:响应式体验优化(桌面双栏,移动端结果抽屉)。
|
||||
5. P1:Tab 汇总(装备详情/结果、技能详情/结果切换)。
|
||||
|
||||
## Design/Docs Entry
|
||||
|
||||
- [README](README.md)
|
||||
- [frontendNew README](frontendNew/README.md)
|
||||
- [frontendNew Migration Plan](frontendNew/MIGRATION_PLAN.md)
|
||||
- [frontendNew Design](frontendNew/DESIGN.md)
|
||||
- [角色信息与结果链路专项分析](frontend-角色信息与计算结果展示链路-专项分析.md)
|
||||
- [装备技能打造交互与布局设计](frontend-装备技能打造-完整交互与布局设计文档.md)
|
||||
|
||||
## Key Code Entry
|
||||
|
||||
- [Character Container](frontendNew/src/pages/Dcalc/Character/Index.vue)
|
||||
- [Result Panel](frontendNew/src/pages/Dcalc/Character/Result/Index.vue)
|
||||
- [Skill Page](frontendNew/src/pages/Dcalc/Character/Skill/index.vue)
|
||||
- [Forge Page](frontendNew/src/pages/Dcalc/Character/Forge/index.vue)
|
||||
- [Config Store](frontendNew/src/stores/config.ts)
|
||||
- [Router](frontendNew/src/router/index.ts)
|
||||
|
||||
## Implementation Bias
|
||||
|
||||
- 同页配置 + 结果展示优先。
|
||||
- Skill/Forge 在交互上尽量小改,优先行为兼容。
|
||||
- 新增交互优先使用低侵入方式:Tab、Sheet、折叠区。
|
||||
- 文档遵循 link-not-embed:链接现有文档,不重复长文说明。
|
||||
@@ -0,0 +1,302 @@
|
||||
# DCAlc 前端迁移探索分析
|
||||
|
||||
## 项目状态
|
||||
- **frontend**:旧项目,功能完整
|
||||
- Pages: Home, Character(Equipment/Skill/Forge)
|
||||
- 组件库:自研 calc-* 组件库(UnoCSS)
|
||||
- 样式:SCSS,暗黑 DNF 风格(棕金色)
|
||||
- API: /info, /calc
|
||||
|
||||
- **frontendNew**:新项目,早期阶段
|
||||
- Pages: Home, Dcalc(DnfHelper)
|
||||
- 组件库:shadcn-vue + reka-ui + lucide-vue
|
||||
- 样式:Tailwind CSS 4
|
||||
- 仅实现了 DnfHelper 模块
|
||||
|
||||
## 主要技术栈差异
|
||||
| 方面 | frontend | frontendNew |
|
||||
|------|----------|-----------|
|
||||
| Vue Router | 4.5.0 | 5.0.4 |
|
||||
| TypeScript | ~5.8.0 | ~6.0.0 |
|
||||
| 样式方案 | UnoCSS + SCSS | Tailwind CSS 4 |
|
||||
| 组件库 | 自研 calc-* | shadcn-vue + reka-ui |
|
||||
| 图标库 | 内置 | lucide-vue-next |
|
||||
| 状态管理 | Pinia (同版本) | Pinia (同版本) |
|
||||
|
||||
## 业务模块复杂度评估
|
||||
1. **Home 页面** - 简单
|
||||
2. **DnfHelper** - 中等(已部分实现)
|
||||
3. **Equipment 装备** - 高(复杂组件层级)
|
||||
4. **Skill 技能** - 中等(树形/列表)
|
||||
5. **Forge 打造** - 高(多标签页面)
|
||||
|
||||
## 迁移风险
|
||||
- calc-* 组件完全重写(shadcn-vue)
|
||||
- 样式系统从 UnoCSS 转换到 Tailwind
|
||||
- 路由结构升级(Vue Router 5)
|
||||
- 组件复用和嵌套关系复杂
|
||||
|
||||
|
||||
## Equipment 完整文件清单
|
||||
|
||||
### Frontend 版本
|
||||
- 页面层: `src/pages/Character/EquipmentDictionary/Index.vue` (主页面)
|
||||
- 页面工具: `src/pages/Character/EquipmentDictionary/formatData.ts`
|
||||
- 页面选项: `src/pages/Character/EquipmentDictionary/components/options.vue`
|
||||
- 组件层:
|
||||
- `src/components/dnf/Equipment/Icon/index.vue`
|
||||
- `src/components/dnf/Equipment/Info/index.vue`
|
||||
- `src/components/dnf/Equipment/List/index.vue`
|
||||
- `src/components/dnf/Equipment/Oath/index.vue`
|
||||
- `src/components/dnf/Equipment/PartSelect/index.vue`
|
||||
- `src/components/dnf/Equipment/RaritySelect/index.vue`
|
||||
- `src/components/dnf/Equipment/SuitInfo/index.vue`
|
||||
|
||||
### 已迁移模式分析 (DnfHelper)
|
||||
- 位置: frontendNew/src/pages/Dcalc/DnfHelper/
|
||||
- 使用 shadcn-vue 原生组件
|
||||
- Tailwind CSS 4 样式
|
||||
- useLocalStorage 本地存储模式
|
||||
|
||||
## Equipment 迁移状态
|
||||
- **未迁移**:frontendNew 中没有对应的 Equipment 页面和组件
|
||||
- **frontend 中的关键文件**:
|
||||
- Pages: `/pages/Character/Equipment/` (已禁用), `/pages/Character/EquipmentDictionary/Index.vue` (当前使用)
|
||||
- 组件库 (7 子组件): Icon, Info, List, Oath, PartSelect, RaritySelect, SuitInfo
|
||||
- 工具函数: `formatData.ts` (formatEqu, formatOath)
|
||||
- 数据处理: 复杂的排列、分组、筛选逻辑
|
||||
|
||||
## DnfHelper 迁移分析(已实现)
|
||||
- **Pages**: `/pages/Dcalc/DnfHelper/`
|
||||
- bind.vue: 表单组件 (InputGroup, Button, Field)
|
||||
- list.vue: 网格列表 (Card, Grid)
|
||||
- index.vue: 容器编排
|
||||
- **Store**: `dnfhelper.ts` - 本地存储 + API 调用
|
||||
- **UI 组件复用**:shadcn-vue Button/Card/Field 直接使用
|
||||
- **样式**:Tailwind 原生类名(grid-cols, gap, padding等)
|
||||
- **图标**:lucide-vue-next (RefreshCcw, GalleryVerticalEnd, Shuffle)
|
||||
|
||||
|
||||
## 修正记录(2026-05-11)
|
||||
- 纠正:frontendNew 已存在并接入 equipment 页面。
|
||||
- 实际路径:frontendNew/src/pages/Dcalc/Character/EquipmentDictionary/Index.vue
|
||||
- 实际路由:frontendNew/src/router/index.ts 中 character/equipment 子路由已配置。
|
||||
- 结论:equipment 迁移状态应定义为“主体完成,后续做一致性优化”。
|
||||
|
||||
---
|
||||
|
||||
# 详细模块分析
|
||||
|
||||
## 1. SKILL 模块详解
|
||||
|
||||
### Frontend 旧版结构
|
||||
**文件清单**:
|
||||
- `src/pages/Character/Skill/index.vue` (650+ 行)
|
||||
- 子组件: SkillTree, SkillVP, SkillUP, SkillDetail
|
||||
- `src/pages/Character/Skill/components/`:
|
||||
- `SkillTree/index.tsx` - TSX 渲染函数,技能树面板(等级/技能筛选)
|
||||
- `SkillTree/img/` - 美术资源
|
||||
- `SkillVP/index.tsx` - TSX 技能进化选择(5个VP槽位)
|
||||
- `SkillUP/index.tsx` - TSX 技能突破选择(多个UP槽位)
|
||||
- `SkillDetail/index.tsx` - 技能详情弹窗
|
||||
- 依赖 calc-* 组件(button/checkbox/tabs)
|
||||
|
||||
**职责分层**:
|
||||
- **L0(展示层)**: index.vue 主容器 + 6 个 tab 组织
|
||||
- **L1(交互层)**: SkillTree/SkillUP/SkillVP/SkillDetail
|
||||
- **L2(核心)**: TSX 条件渲染 + 计算属性 + 样式定制
|
||||
|
||||
**关键依赖**:
|
||||
- `useInfoStore.skills` - 技能数据源
|
||||
- `useConfigStore.config.skills` - 技能配置 { lv, vp, up }
|
||||
- `onClickOutside` VueUse 钩子
|
||||
- Calc-UI 组件库
|
||||
|
||||
### FrontendNew 占位状态
|
||||
- **文件**: `src/pages/Dcalc/Character/Skill/index.vue` (48 行)
|
||||
- **内容**: 搜索框 + 技能卡片网格(展示级)
|
||||
- **缺失**:
|
||||
- SkillTree 树形选择
|
||||
- SkillVP/SkillUP 配置面板
|
||||
- 技能详情弹窗
|
||||
- 数据编辑交互
|
||||
|
||||
### 迁移差距
|
||||
| 功能 | 旧版 | 新版 | 缺失 |
|
||||
|-----|------|------|------|
|
||||
| 技能列表搜索 | SkillTree | 简单搜索框 | 树形结构 |
|
||||
| VP 进化配置 | SkillVP | ❌ | 5 槽位选择 |
|
||||
| UP 突破配置 | SkillUP | ❌ | 多槽选择 |
|
||||
| 详情展示 | SkillDetail 弹窗 | ❌ | 弹窗界面 |
|
||||
|
||||
---
|
||||
|
||||
## 2. FORGE 模块详解
|
||||
|
||||
### Frontend 旧版结构
|
||||
**文件清单**:
|
||||
- `src/pages/Character/Forge/index.vue` (32 行) - 简洁容器
|
||||
- `src/pages/Character/Forge/components/`:
|
||||
- `Equipments.vue` - 装备打造子面板
|
||||
- `Avatars.vue` - 时装打造子面板
|
||||
- `Emblems.vue` - 徽章打造子面板
|
||||
- `Oaths.vue` - 誓约打造子面板
|
||||
- `Jades.vue` - 辟邪玉子面板
|
||||
- `Sundry.vue` - 其他配置子面板
|
||||
- 使用 calc-collapse/calc-tabs 容器化
|
||||
|
||||
**职责分层**:
|
||||
- **L0(入口)**: Forge/index.vue - 6 个 collapse 分类
|
||||
- **L1(子模块)**: 6 个 .vue 组件
|
||||
- Equipments: 装备强化/升级/精炼/魔石编辑
|
||||
- Avatars: 时装附魔/徽章选择
|
||||
- Oaths: 誓约配置
|
||||
- Jades: 玉石配置
|
||||
- Sundry: 杂项选项(绑定觉醒等)
|
||||
|
||||
**关键依赖**:
|
||||
- `useConfigStore` - 装备配置状态
|
||||
- 各自与对应的 List 组件嵌套
|
||||
- Calc-UI collapse/tabs
|
||||
|
||||
### FrontendNew 占位状态
|
||||
- **文件**: `src/pages/Dcalc/Character/Forge/index.vue` (45 行)
|
||||
- **内容**: 3 张 Card 统计(装备数、誓约数、时装徽章数)
|
||||
- **缺失**:
|
||||
- 所有 6 个子面板
|
||||
- 装备编辑界面
|
||||
- 誓约/玉石/时装编辑
|
||||
- 数据交互
|
||||
|
||||
### 迁移差距
|
||||
| 功能 | 旧版 | 新版 | 缺失 |
|
||||
|-----|------|------|------|
|
||||
| 装备编辑 | Equipments | 仅统计 | 完整编辑器 |
|
||||
| 时装编辑 | Avatars | 仅统计 | 完整编辑器 |
|
||||
| 徽章编辑 | Emblems | 仅统计 | 完整编辑器 |
|
||||
| 誓约配置 | Oaths | ❌ | 完整面板 |
|
||||
| 玉石配置 | Jades | ❌ | 完整面板 |
|
||||
| 其他选项 | Sundry | ❌ | 完整面板 |
|
||||
|
||||
---
|
||||
|
||||
## 3. RESULT 模块详解
|
||||
|
||||
### Frontend 旧版结构
|
||||
**文件清单**:
|
||||
- `src/pages/Character/Result/Index.vue` (72 行) - 主容器,管理分类切换
|
||||
- `src/pages/Character/Result/components/`:
|
||||
- `Info.vue` (200+ 行) - 左侧信息面板
|
||||
- EquList 子组件 (装备列表)
|
||||
- Oath 子组件 (誓约信息)
|
||||
- 基础属性数值展示
|
||||
- `Skill.vue` (180+ 行) - 右侧技能伤害表
|
||||
- 技能名/CD/次数/秒伤/总伤害/占比
|
||||
- 可编辑技能次数
|
||||
- 排序功能(按 dps/total 等)
|
||||
- `BufferSkill.vue` (190+ 行) - 组队模式技能表
|
||||
- 不同的表头字段(MAIN/ATK/STRINT 等)
|
||||
- 不可编辑模式
|
||||
|
||||
**职责分层**:
|
||||
- **L0(入口)**: Result/Index.vue - 切换 Equipment/Oath 视图 + 标准对比
|
||||
- **L1(左侧)**: Info.vue - 装备信息 + 基础属性
|
||||
- EquList: 装备图标列表
|
||||
- Oath: 誓约信息
|
||||
- 属性值网格
|
||||
- **L1(右侧)**: Skill.vue / BufferSkill.vue - 技能伤害统计
|
||||
- 表格渲染(固定列宽)
|
||||
- 排序交互
|
||||
- 伤害数值编辑
|
||||
|
||||
**关键依赖**:
|
||||
- `useConfigStore.result.state` - 计算结果数据
|
||||
- `useInfoStore.standard` - 标准对比数据
|
||||
- `configStore.skillCountConfig` - 技能次数配置
|
||||
- 复杂的数据处理和样式定制
|
||||
|
||||
### FrontendNew 缺失状态
|
||||
- **不存在**: 零文件,没有 Result 模块
|
||||
- **缺失**:
|
||||
- 整个右侧结果面板
|
||||
- 技能伤害表展示
|
||||
- 标准对比功能
|
||||
- 装备信息展示
|
||||
|
||||
### 完整缺失情况
|
||||
Result 是 **三个模块中缺失最严重** 的:
|
||||
- 没有代码框架
|
||||
- 没有占位符
|
||||
- 没有数据绑定
|
||||
- 完全的新建工作
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据依赖图
|
||||
|
||||
### Skill 依赖
|
||||
```
|
||||
infoStore.skills (API)
|
||||
↓
|
||||
SkillTree/SkillVP/SkillUP
|
||||
↓
|
||||
configStore.config.skills
|
||||
↓
|
||||
Result 右侧 Skill 表展示
|
||||
```
|
||||
|
||||
### Forge 依赖
|
||||
```
|
||||
configStore.config.equips/avatar/oaths
|
||||
↓
|
||||
6 个 Forge 子面板编辑
|
||||
↓
|
||||
Result 左侧 Info 面板展示
|
||||
↓
|
||||
API /calc 计算
|
||||
↓
|
||||
Result 右侧 Skill 表
|
||||
```
|
||||
|
||||
### Result 依赖
|
||||
```
|
||||
configStore.result.state (来自 API /calc)
|
||||
infoStore.standard (标准对比数据)
|
||||
↓
|
||||
Info.vue 左侧展示
|
||||
Skill.vue / BufferSkill.vue 右侧表格
|
||||
↓
|
||||
setStandard() 保存对比基准
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 组件库差异影响
|
||||
|
||||
### Frontend 原生组件映射
|
||||
|
||||
**calc-* 组件** → **shadcn-vue 替代**:
|
||||
- `calc-tabs` → `shadcn Tabs + TabsList + TabsTrigger`
|
||||
- `calc-collapse` → `shadcn Accordion` 或自定义 collapsible
|
||||
- `calc-button` → `shadcn Button`
|
||||
- `calc-checkbox` → `shadcn Checkbox`
|
||||
- `calc-input` → `shadcn Input`
|
||||
- `calc-dialog` → `shadcn Dialog`
|
||||
|
||||
**样式转换**:
|
||||
- UnoCSS shorthand (如 `h-40px`) → Tailwind (如 `h-10`)
|
||||
- DNF 暗色主题 `#101012` → Tailwind `bg-black` + custom 深灰
|
||||
- 金色强调 `#836832` / `#6C5E4A` → Tailwind 或 CSS variables
|
||||
|
||||
---
|
||||
|
||||
## 6. 核心差异总结
|
||||
|
||||
| 方面 | Frontend | FrontendNew | 迁移工作 |
|
||||
|------|----------|-----------|---------|
|
||||
| **Skill** | 完整 TSX + UI | 占位(搜索框) | ★★★★ 高 |
|
||||
| **Forge** | 6 个子面板 | 占位(统计卡) | ★★★★★ 最高 |
|
||||
| **Result** | Info + Skill 双面板 | ❌ 不存在 | ★★★★★ 最高 |
|
||||
| **路由** | `/character/xxx` | `/dcalc/character/xxx` | ★ 低 |
|
||||
| **Store** | 全部就位 | 基础就位 | ★ 低 |
|
||||
| **UI 库** | 自研 calc-* | shadcn-vue | ★★ 中 |
|
||||
@@ -0,0 +1,225 @@
|
||||
# Frontend 装备流派/职业分支/打造阶段 完整交互与布局设计文档
|
||||
|
||||
## 1. 分析范围与假设
|
||||
|
||||
- 分析范围仅覆盖 frontend。
|
||||
- 目标模块包含:装备、技能、打造、角色信息、计算结果展示。
|
||||
- 术语固定:装备流派、职业分支、打造阶段。
|
||||
- 输出重点:交互链路、分列布局、结果渲染、代码定位证据。
|
||||
|
||||
入口证据:
|
||||
- frontend/src/router/index.ts:13
|
||||
- frontend/src/router/index.ts:22
|
||||
- frontend/src/router/index.ts:34
|
||||
- frontend/src/router/index.ts:40
|
||||
|
||||
## 2. 模块清单与职责映射
|
||||
|
||||
### 2.1 路由与页面
|
||||
|
||||
- 角色主容器页:frontend/src/pages/Character/Index.vue:1
|
||||
- 装备页:frontend/src/pages/Character/EquipmentDictionary/Index.vue:1
|
||||
- 技能页:frontend/src/pages/Character/Skill/index.vue:1
|
||||
- 打造页:frontend/src/pages/Character/Forge/index.vue:1
|
||||
- 结果页:frontend/src/pages/Character/Result/Index.vue:1
|
||||
|
||||
### 2.2 状态管理
|
||||
|
||||
- 配置与计算入口:frontend/src/stores/config.ts:45
|
||||
- 角色信息与职业上下文:frontend/src/stores/info.ts:9
|
||||
- 角色创建与职业参数接入:frontend/src/stores/info.ts:165
|
||||
|
||||
### 2.3 API 与结果类型
|
||||
|
||||
- 计算结果结构定义:frontend/src/api/calc/type.ts:1
|
||||
- 计算请求触发:frontend/src/stores/config.ts:236
|
||||
|
||||
## 3. 交互链路分解(装备流派/职业分支/打造阶段)
|
||||
|
||||
### 3.1 装备流派链路
|
||||
|
||||
1. 触发动作:点击装备图标。
|
||||
- 证据:frontend/src/pages/Character/EquipmentDictionary/Index.vue:67
|
||||
2. 状态变化:调用 chooseEqu 更新 config.equips。
|
||||
- 证据:frontend/src/stores/config.ts:243
|
||||
3. 请求与计算:watch config 变化后执行 result.execute -> api.calc。
|
||||
- 证据:frontend/src/pages/Character/Index.vue:61
|
||||
- 证据:frontend/src/pages/Character/Index.vue:70
|
||||
- 证据:frontend/src/stores/config.ts:236
|
||||
4. 结果渲染:Info 与 Skill/BufferSkill 刷新。
|
||||
- 证据:frontend/src/pages/Character/Result/Index.vue:25
|
||||
- 证据:frontend/src/pages/Character/Result/Index.vue:35
|
||||
- 证据:frontend/src/pages/Character/Result/Index.vue:40
|
||||
|
||||
说明:当前“装备流派”主要通过装备组合隐式形成,未见独立流派枚举模型。
|
||||
|
||||
### 3.2 职业分支链路
|
||||
|
||||
1. 触发动作:路由 alter 参数进入职业上下文。
|
||||
- 证据:frontend/src/router/index.ts:13
|
||||
2. 状态变化:createCharacter(alter) 加载职业信息与配置。
|
||||
- 证据:frontend/src/stores/info.ts:165
|
||||
3. 请求与计算:技能加点/定制写入 config.skills,触发重算。
|
||||
- 证据:frontend/src/pages/Character/Skill/index.vue:23
|
||||
- 证据:frontend/src/pages/Character/Skill/index.vue:32
|
||||
- 证据:frontend/src/pages/Character/Skill/index.vue:41
|
||||
- 证据:frontend/src/pages/Character/Index.vue:61
|
||||
4. 结果渲染:根据 buffer 分支渲染输出职业或辅助职业结果。
|
||||
- 证据:frontend/src/pages/Character/Result/Index.vue:35
|
||||
- 证据:frontend/src/pages/Character/Result/Index.vue:40
|
||||
|
||||
说明:当前“职业分支”语义主要依赖路由 alter 与角色数据,无单独分支选择器。
|
||||
|
||||
### 3.3 打造阶段链路
|
||||
|
||||
1. 触发动作:在打造页切换装备/誓约/时装/徽章并录入参数。
|
||||
- 证据:frontend/src/pages/Character/Forge/index.vue:5
|
||||
- 证据:frontend/src/pages/Character/Forge/index.vue:6
|
||||
- 证据:frontend/src/pages/Character/Forge/index.vue:7
|
||||
- 证据:frontend/src/pages/Character/Forge/index.vue:8
|
||||
- 证据:frontend/src/pages/Character/Forge/index.vue:30
|
||||
2. 状态变化:reinforce、upgrade 等字段写入 equips。
|
||||
- 证据:frontend/src/stores/config.ts:21
|
||||
- 证据:frontend/src/stores/config.ts:26
|
||||
3. 请求与计算:配置变化统一触发 execute 与 calc。
|
||||
- 证据:frontend/src/pages/Character/Index.vue:61
|
||||
- 证据:frontend/src/stores/config.ts:236
|
||||
4. 结果渲染:计算结果映射到 info 与 skills 区域。
|
||||
- 证据:frontend/src/api/calc/type.ts:3
|
||||
- 证据:frontend/src/api/calc/type.ts:4
|
||||
- 证据:frontend/src/pages/Character/Result/Index.vue:25
|
||||
- 证据:frontend/src/pages/Character/Result/Index.vue:35
|
||||
|
||||
说明:当前“打造阶段”以数值字段组合表达,未见显式阶段枚举。
|
||||
|
||||
## 4. 页面布局与分列展示逻辑
|
||||
|
||||
### 4.1 页面骨架
|
||||
|
||||
- 顶部:装备设置/技能设置/打造设置页签。
|
||||
- 证据:frontend/src/pages/Character/Index.vue:4
|
||||
- 中部:content 为 flex 双列结构。
|
||||
- 左列:RouterView 承载配置页面。
|
||||
- 右列:Result 承载结果面板。
|
||||
- 证据:frontend/src/pages/Character/Index.vue:10
|
||||
- 证据:frontend/src/pages/Character/Index.vue:15
|
||||
- 证据:frontend/src/pages/Character/Index.vue:18
|
||||
- 底部:系统奶、组队、导入按钮。
|
||||
- 证据:frontend/src/pages/Character/Index.vue:21
|
||||
|
||||
### 4.2 分列与联动
|
||||
|
||||
- 主列联动:左侧输入变化驱动右侧结果更新。
|
||||
- 证据:frontend/src/pages/Character/Index.vue:61
|
||||
- 证据:frontend/src/pages/Character/Index.vue:70
|
||||
- 结果区二级分列:Info 与 Skill/BufferSkill 并排。
|
||||
- 证据:frontend/src/pages/Character/Result/Index.vue:1
|
||||
- 装备页分列:装备列表区 + 右侧信息区(基础/套装/誓约)。
|
||||
- 证据:frontend/src/pages/Character/EquipmentDictionary/Index.vue:1
|
||||
- 技能页分列:技能树主区 + 技能详情侧浮层。
|
||||
- 证据:frontend/src/pages/Character/Skill/index.vue:1
|
||||
- 证据:frontend/src/pages/Character/Skill/index.vue:58
|
||||
|
||||
## 5. 角色信息与计算结果展示逻辑
|
||||
|
||||
### 5.1 数据源
|
||||
|
||||
- 角色基础数据:frontend/src/stores/info.ts:9
|
||||
- 技能与装备源:frontend/src/stores/info.ts:21
|
||||
- 计算输入配置:frontend/src/stores/config.ts:45
|
||||
- 计算结果结构(skills/info/buffer):frontend/src/api/calc/type.ts:3
|
||||
- 角色信息项结构:frontend/src/api/calc/type.ts:27
|
||||
|
||||
### 5.2 渲染链路
|
||||
|
||||
1. 输入端:装备、技能、打造等写入 config。
|
||||
2. 计算端:result.execute 调用 calc,返回 IResult。
|
||||
- 证据:frontend/src/stores/config.ts:260
|
||||
- 证据:frontend/src/stores/config.ts:236
|
||||
3. 展示端:
|
||||
- 角色信息由 Info 消费 result.state.info。
|
||||
- 证据:frontend/src/pages/Character/Result/Index.vue:25
|
||||
- 结果明细由 Skill/BufferSkill 消费 result.state.skills。
|
||||
- 证据:frontend/src/pages/Character/Result/Index.vue:35
|
||||
- 证据:frontend/src/pages/Character/Result/Index.vue:40
|
||||
|
||||
## 6. 证据索引(按路由/store/API/页面组件分类)
|
||||
|
||||
### 路由
|
||||
- frontend/src/router/index.ts:13
|
||||
- frontend/src/router/index.ts:22
|
||||
- frontend/src/router/index.ts:34
|
||||
- frontend/src/router/index.ts:40
|
||||
|
||||
### Store
|
||||
- frontend/src/stores/config.ts:21
|
||||
- frontend/src/stores/config.ts:26
|
||||
- frontend/src/stores/config.ts:45
|
||||
- frontend/src/stores/config.ts:236
|
||||
- frontend/src/stores/config.ts:243
|
||||
- frontend/src/stores/config.ts:260
|
||||
- frontend/src/stores/info.ts:9
|
||||
- frontend/src/stores/info.ts:21
|
||||
- frontend/src/stores/info.ts:165
|
||||
|
||||
### API
|
||||
- frontend/src/api/calc/type.ts:3
|
||||
- frontend/src/api/calc/type.ts:4
|
||||
- frontend/src/api/calc/type.ts:10
|
||||
- frontend/src/api/calc/type.ts:27
|
||||
|
||||
### 页面组件
|
||||
- frontend/src/pages/Character/Index.vue:4
|
||||
- frontend/src/pages/Character/Index.vue:15
|
||||
- frontend/src/pages/Character/Index.vue:18
|
||||
- frontend/src/pages/Character/Index.vue:61
|
||||
- frontend/src/pages/Character/Index.vue:70
|
||||
- frontend/src/pages/Character/EquipmentDictionary/Index.vue:13
|
||||
- frontend/src/pages/Character/EquipmentDictionary/Index.vue:21
|
||||
- frontend/src/pages/Character/EquipmentDictionary/Index.vue:30
|
||||
- frontend/src/pages/Character/EquipmentDictionary/Index.vue:33
|
||||
- frontend/src/pages/Character/EquipmentDictionary/Index.vue:38
|
||||
- frontend/src/pages/Character/EquipmentDictionary/Index.vue:67
|
||||
- frontend/src/pages/Character/Skill/index.vue:10
|
||||
- frontend/src/pages/Character/Skill/index.vue:18
|
||||
- frontend/src/pages/Character/Skill/index.vue:23
|
||||
- frontend/src/pages/Character/Skill/index.vue:32
|
||||
- frontend/src/pages/Character/Skill/index.vue:41
|
||||
- frontend/src/pages/Character/Forge/index.vue:5
|
||||
- frontend/src/pages/Character/Forge/index.vue:6
|
||||
- frontend/src/pages/Character/Forge/index.vue:7
|
||||
- frontend/src/pages/Character/Forge/index.vue:8
|
||||
- frontend/src/pages/Character/Forge/index.vue:30
|
||||
- frontend/src/pages/Character/Result/Index.vue:25
|
||||
- frontend/src/pages/Character/Result/Index.vue:35
|
||||
- frontend/src/pages/Character/Result/Index.vue:40
|
||||
|
||||
## 7. 问题清单与优先级改造建议
|
||||
|
||||
### P0
|
||||
|
||||
1. 装备流派缺少显式数据模型与入口。
|
||||
- 影响:流派复用、分享、回放能力弱。
|
||||
- 建议:新增流派实体(名称、标签、约束、快照),并在装备页增加流派管理入口。
|
||||
|
||||
2. 职业分支语义依赖路由参数,缺少可视化分支状态。
|
||||
- 影响:评审和调试容易混淆当前分支上下文。
|
||||
- 建议:在角色页头部补充职业与分支状态条。
|
||||
|
||||
### P1
|
||||
|
||||
1. 打造阶段没有统一阶段语义层。
|
||||
- 影响:规则解释成本高,阶段对比困难。
|
||||
- 建议:建立阶段映射(阶段名 -> 字段集合),并在打造页展示阶段摘要。
|
||||
|
||||
2. 结果展示缺少标准化加载态/错误态反馈。
|
||||
- 影响:请求延迟或失败时可解释性不足。
|
||||
- 建议:结果区增加执行中、失败重试、最近成功时间。
|
||||
|
||||
### P2
|
||||
|
||||
1. 多模块分列宽度与信息密度策略存在差异。
|
||||
- 建议:统一主次列比例与标题层级规范。
|
||||
|
||||
2. 证据索引依赖人工维护。
|
||||
- 建议:后续补充自动化证据采集脚本,降低维护成本。
|
||||
@@ -0,0 +1,171 @@
|
||||
# Frontend 角色信息与计算结果展示链路专项分析
|
||||
|
||||
## 1. 分析范围与假设
|
||||
|
||||
- 范围仅覆盖 frontend。
|
||||
- 本文重点是角色信息与计算结果展示链路,补充分列布局、联动逻辑、异常反馈与改造建议。
|
||||
- 职业上下文由路由参数 alter 驱动,计算请求统一走 /calc。
|
||||
|
||||
核心入口证据:
|
||||
- 路由入口与职业参数:[frontend/src/router/index.ts](frontend/src/router/index.ts#L13)
|
||||
- 结果面板挂载位置:[frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L18)
|
||||
- 计算请求入口:[frontend/src/api/calc/index.ts](frontend/src/api/calc/index.ts#L7)
|
||||
|
||||
## 2. 模块清单与职责映射
|
||||
|
||||
### 2.1 主流程文件
|
||||
|
||||
- 角色页主容器(输入区 + 结果区 + 开关区):[frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue)
|
||||
- 结果容器(Info 与 Skill/BufferSkill 分支):[frontend/src/pages/Character/Result/Index.vue](frontend/src/pages/Character/Result/Index.vue)
|
||||
- 角色信息渲染组件: [frontend/src/pages/Character/Result/components/Info.vue](frontend/src/pages/Character/Result/components/Info.vue)
|
||||
- 输出职业结果组件: [frontend/src/pages/Character/Result/components/Skill.vue](frontend/src/pages/Character/Result/components/Skill.vue)
|
||||
- 辅助职业结果组件: [frontend/src/pages/Character/Result/components/BufferSkill.vue](frontend/src/pages/Character/Result/components/BufferSkill.vue)
|
||||
|
||||
### 2.2 数据与状态
|
||||
|
||||
- 配置输入与计算执行: [frontend/src/stores/config.ts](frontend/src/stores/config.ts#L236)
|
||||
- 角色基础信息与职业上下文: [frontend/src/stores/info.ts](frontend/src/stores/info.ts#L9)
|
||||
- 结果数据类型(skills/info/suits/buffer): [frontend/src/api/calc/type.ts](frontend/src/api/calc/type.ts#L3)
|
||||
|
||||
## 3. 角色信息与计算结果展示链路
|
||||
|
||||
### 3.1 触发到计算
|
||||
|
||||
1. 用户在装备、技能、打造区域改动配置后,主容器监听 config 变化。
|
||||
- 证据: [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L61)
|
||||
|
||||
2. 监听回调触发 execute,进入异步计算流程。
|
||||
- 证据: [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L70)
|
||||
|
||||
3. config store 调用 calc,向 /calc 提交 IConfig。
|
||||
- 证据: [frontend/src/stores/config.ts](frontend/src/stores/config.ts#L236)
|
||||
- 证据: [frontend/src/api/calc/index.ts](frontend/src/api/calc/index.ts#L7)
|
||||
|
||||
### 3.2 计算结果入仓与消费
|
||||
|
||||
1. 结果写入 result.state,结构包含 skills、info、suits、buffer。
|
||||
- 证据: [frontend/src/stores/config.ts](frontend/src/stores/config.ts#L260)
|
||||
- 证据: [frontend/src/api/calc/type.ts](frontend/src/api/calc/type.ts#L3)
|
||||
- 证据: [frontend/src/api/calc/type.ts](frontend/src/api/calc/type.ts#L4)
|
||||
- 证据: [frontend/src/api/calc/type.ts](frontend/src/api/calc/type.ts#L5)
|
||||
- 证据: [frontend/src/api/calc/type.ts](frontend/src/api/calc/type.ts#L10)
|
||||
|
||||
2. 角色信息由 Info 组件消费并渲染 info 列表。
|
||||
- 证据(绑定入口): [frontend/src/pages/Character/Result/Index.vue](frontend/src/pages/Character/Result/Index.vue#L25)
|
||||
- 证据(渲染循环): [frontend/src/pages/Character/Result/components/Info.vue](frontend/src/pages/Character/Result/components/Info.vue#L13)
|
||||
- 证据(字段定义): [frontend/src/api/calc/type.ts](frontend/src/api/calc/type.ts#L27)
|
||||
|
||||
3. 技能结果根据 buffer 分支渲染 Skill 或 BufferSkill。
|
||||
- 证据(普通输出): [frontend/src/pages/Character/Result/Index.vue](frontend/src/pages/Character/Result/Index.vue#L35)
|
||||
- 证据(辅助输出): [frontend/src/pages/Character/Result/Index.vue](frontend/src/pages/Character/Result/Index.vue#L40)
|
||||
|
||||
## 4. 分列布局与联动逻辑
|
||||
|
||||
### 4.1 顶层分列
|
||||
|
||||
- 角色页中部为双列:左列 RouterView 输入,右列 Result 输出。
|
||||
- 证据: [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L10)
|
||||
- 证据: [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L15)
|
||||
- 证据: [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L18)
|
||||
|
||||
### 4.2 结果区分列
|
||||
|
||||
- 结果容器内部再次分列:左侧 Info(角色信息与穿戴信息),右侧 Skill/BufferSkill(技能结果)。
|
||||
- 证据: [frontend/src/pages/Character/Result/Index.vue](frontend/src/pages/Character/Result/Index.vue#L1)
|
||||
- 证据: [frontend/src/pages/Character/Result/Index.vue](frontend/src/pages/Character/Result/Index.vue#L25)
|
||||
- 证据: [frontend/src/pages/Character/Result/Index.vue](frontend/src/pages/Character/Result/Index.vue#L35)
|
||||
|
||||
### 4.3 联动规则
|
||||
|
||||
- 输入任意改动 -> config 变化 -> execute -> result.state 刷新 -> Info 与 Skill 重新渲染。
|
||||
- 结果显示类型联动:buffer 布尔值驱动结果面板分支。
|
||||
- 证据: [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L61)
|
||||
- 证据: [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L70)
|
||||
- 证据: [frontend/src/api/calc/type.ts](frontend/src/api/calc/type.ts#L10)
|
||||
|
||||
## 5. 异常反馈现状评估
|
||||
|
||||
### 5.1 已有反馈
|
||||
|
||||
- 结果组件存在数据门控:无数据时不渲染主表。
|
||||
- 证据(Skill): [frontend/src/pages/Character/Result/components/Skill.vue](frontend/src/pages/Character/Result/components/Skill.vue#L4)
|
||||
- 证据(BufferSkill): [frontend/src/pages/Character/Result/components/BufferSkill.vue](frontend/src/pages/Character/Result/components/BufferSkill.vue#L4)
|
||||
|
||||
### 5.2 缺口
|
||||
|
||||
- 缺少请求失败显式提示(无 toast/错误区/错误码展示)。
|
||||
- 缺少结果区加载态反馈(执行中与完成时刻不可见)。
|
||||
- 缺少重试按钮或自动降级策略。
|
||||
- 缺少字段级异常占位(比如 info 值缺失时统一展示策略)。
|
||||
|
||||
侧面证据:
|
||||
- execute 在主容器中仅做并发互斥,不包含错误反馈 UI: [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L61)
|
||||
- calc 直接返回接口数据,未见 UI 级异常转换: [frontend/src/api/calc/index.ts](frontend/src/api/calc/index.ts#L7)
|
||||
|
||||
## 6. 重点问题与改造建议
|
||||
|
||||
### P0
|
||||
|
||||
1. 结果计算缺少显式异常反馈链路。
|
||||
- 影响:接口失败时用户难以判断是无结果还是请求异常。
|
||||
- 建议:在 config store 增加 status 字段(idle/loading/success/error),并在 Result 区显示错误提示与重试按钮。
|
||||
|
||||
2. 结果刷新过程不可感知。
|
||||
- 影响:连续编辑时体验抖动,用户无法确认是否正在计算。
|
||||
- 建议:在 Result 容器增加轻量 loading 条或角标,绑定 execute 生命周期。
|
||||
|
||||
### P1
|
||||
|
||||
1. 角色信息面板缺少异常值占位规范。
|
||||
- 影响:后端字段不全时易出现空白或排版错位。
|
||||
- 建议:Info 渲染时对 name/value 增加兜底格式化层。
|
||||
|
||||
2. 分列宽度与自适应策略未统一抽象。
|
||||
- 影响:不同分辨率下信息密度不稳定。
|
||||
- 建议:将主容器与结果列宽抽为布局 token,统一响应式断点行为。
|
||||
|
||||
### P2
|
||||
|
||||
1. 结果组件中计算与展示逻辑耦合较高。
|
||||
- 影响:后续扩展“多维对比/快照回放”时修改成本上升。
|
||||
- 建议:提取结果计算 composable,将排序、汇总、同比逻辑从视图组件中抽离。
|
||||
|
||||
## 7. 证据索引
|
||||
|
||||
### 路由
|
||||
- [frontend/src/router/index.ts](frontend/src/router/index.ts#L13)
|
||||
|
||||
### Store
|
||||
- [frontend/src/stores/config.ts](frontend/src/stores/config.ts#L236)
|
||||
- [frontend/src/stores/config.ts](frontend/src/stores/config.ts#L260)
|
||||
- [frontend/src/stores/info.ts](frontend/src/stores/info.ts#L9)
|
||||
|
||||
### API
|
||||
- [frontend/src/api/calc/index.ts](frontend/src/api/calc/index.ts#L7)
|
||||
- [frontend/src/api/calc/type.ts](frontend/src/api/calc/type.ts#L3)
|
||||
- [frontend/src/api/calc/type.ts](frontend/src/api/calc/type.ts#L4)
|
||||
- [frontend/src/api/calc/type.ts](frontend/src/api/calc/type.ts#L5)
|
||||
- [frontend/src/api/calc/type.ts](frontend/src/api/calc/type.ts#L10)
|
||||
- [frontend/src/api/calc/type.ts](frontend/src/api/calc/type.ts#L27)
|
||||
|
||||
### 页面与组件
|
||||
- [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L10)
|
||||
- [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L18)
|
||||
- [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L22)
|
||||
- [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L23)
|
||||
- [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L61)
|
||||
- [frontend/src/pages/Character/Index.vue](frontend/src/pages/Character/Index.vue#L70)
|
||||
- [frontend/src/pages/Character/Result/Index.vue](frontend/src/pages/Character/Result/Index.vue#L25)
|
||||
- [frontend/src/pages/Character/Result/Index.vue](frontend/src/pages/Character/Result/Index.vue#L35)
|
||||
- [frontend/src/pages/Character/Result/Index.vue](frontend/src/pages/Character/Result/Index.vue#L40)
|
||||
- [frontend/src/pages/Character/Result/components/Info.vue](frontend/src/pages/Character/Result/components/Info.vue#L13)
|
||||
- [frontend/src/pages/Character/Result/components/Info.vue](frontend/src/pages/Character/Result/components/Info.vue#L166)
|
||||
- [frontend/src/pages/Character/Result/components/Info.vue](frontend/src/pages/Character/Result/components/Info.vue#L212)
|
||||
- [frontend/src/pages/Character/Result/components/Skill.vue](frontend/src/pages/Character/Result/components/Skill.vue#L4)
|
||||
- [frontend/src/pages/Character/Result/components/Skill.vue](frontend/src/pages/Character/Result/components/Skill.vue#L47)
|
||||
- [frontend/src/pages/Character/Result/components/Skill.vue](frontend/src/pages/Character/Result/components/Skill.vue#L150)
|
||||
- [frontend/src/pages/Character/Result/components/Skill.vue](frontend/src/pages/Character/Result/components/Skill.vue#L166)
|
||||
- [frontend/src/pages/Character/Result/components/Skill.vue](frontend/src/pages/Character/Result/components/Skill.vue#L173)
|
||||
- [frontend/src/pages/Character/Result/components/BufferSkill.vue](frontend/src/pages/Character/Result/components/BufferSkill.vue#L4)
|
||||
- [frontend/src/pages/Character/Result/components/BufferSkill.vue](frontend/src/pages/Character/Result/components/BufferSkill.vue#L89)
|
||||
- [frontend/src/pages/Character/Result/components/BufferSkill.vue](frontend/src/pages/Character/Result/components/BufferSkill.vue#L159)
|
||||
@@ -0,0 +1,562 @@
|
||||
---
|
||||
version: alpha
|
||||
name: Apple
|
||||
description: A photography-first interface that turns marketing into a museum gallery. Edge-to-edge product tiles alternate light and dark canvases, framed by SF Pro Display headlines with negative letter-spacing and a single Action Blue (#0066cc) interactive color. UI chrome recedes so the product can speak — no decorative gradients, no shadows on chrome, only the one signature drop-shadow under product imagery resting on a surface.
|
||||
|
||||
colors:
|
||||
primary: "#0066cc"
|
||||
primary-focus: "#0071e3"
|
||||
primary-on-dark: "#2997ff"
|
||||
ink: "#1d1d1f"
|
||||
body: "#1d1d1f"
|
||||
body-on-dark: "#ffffff"
|
||||
body-muted: "#cccccc"
|
||||
ink-muted-80: "#333333"
|
||||
ink-muted-48: "#7a7a7a"
|
||||
divider-soft: "#f0f0f0"
|
||||
hairline: "#e0e0e0"
|
||||
canvas: "#ffffff"
|
||||
canvas-parchment: "#f5f5f7"
|
||||
surface-pearl: "#fafafc"
|
||||
surface-tile-1: "#272729"
|
||||
surface-tile-2: "#2a2a2c"
|
||||
surface-tile-3: "#252527"
|
||||
surface-black: "#000000"
|
||||
surface-chip-translucent: "#d2d2d7"
|
||||
on-primary: "#ffffff"
|
||||
on-dark: "#ffffff"
|
||||
|
||||
typography:
|
||||
hero-display:
|
||||
fontFamily: "SF Pro Display, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 56px
|
||||
fontWeight: 600
|
||||
lineHeight: 1.07
|
||||
letterSpacing: -0.28px
|
||||
display-lg:
|
||||
fontFamily: "SF Pro Display, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 40px
|
||||
fontWeight: 600
|
||||
lineHeight: 1.1
|
||||
letterSpacing: 0
|
||||
display-md:
|
||||
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 34px
|
||||
fontWeight: 600
|
||||
lineHeight: 1.47
|
||||
letterSpacing: -0.374px
|
||||
lead:
|
||||
fontFamily: "SF Pro Display, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 28px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.14
|
||||
letterSpacing: 0.196px
|
||||
lead-airy:
|
||||
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 24px
|
||||
fontWeight: 300
|
||||
lineHeight: 1.5
|
||||
letterSpacing: 0
|
||||
tagline:
|
||||
fontFamily: "SF Pro Display, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 21px
|
||||
fontWeight: 600
|
||||
lineHeight: 1.19
|
||||
letterSpacing: 0.231px
|
||||
body-strong:
|
||||
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 17px
|
||||
fontWeight: 600
|
||||
lineHeight: 1.24
|
||||
letterSpacing: -0.374px
|
||||
body:
|
||||
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 17px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.47
|
||||
letterSpacing: -0.374px
|
||||
dense-link:
|
||||
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 17px
|
||||
fontWeight: 400
|
||||
lineHeight: 2.41
|
||||
letterSpacing: 0
|
||||
caption:
|
||||
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 14px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.43
|
||||
letterSpacing: -0.224px
|
||||
caption-strong:
|
||||
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 14px
|
||||
fontWeight: 600
|
||||
lineHeight: 1.29
|
||||
letterSpacing: -0.224px
|
||||
button-large:
|
||||
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 18px
|
||||
fontWeight: 300
|
||||
lineHeight: 1.0
|
||||
letterSpacing: 0
|
||||
button-utility:
|
||||
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 14px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.29
|
||||
letterSpacing: -0.224px
|
||||
fine-print:
|
||||
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 12px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.0
|
||||
letterSpacing: -0.12px
|
||||
micro-legal:
|
||||
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 10px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.3
|
||||
letterSpacing: -0.08px
|
||||
nav-link:
|
||||
fontFamily: "SF Pro Text, system-ui, -apple-system, sans-serif"
|
||||
fontSize: 12px
|
||||
fontWeight: 400
|
||||
lineHeight: 1.0
|
||||
letterSpacing: -0.12px
|
||||
|
||||
rounded:
|
||||
none: 0px
|
||||
xs: 5px
|
||||
sm: 8px
|
||||
md: 11px
|
||||
lg: 18px
|
||||
pill: 9999px
|
||||
full: 9999px
|
||||
|
||||
spacing:
|
||||
xxs: 4px
|
||||
xs: 8px
|
||||
sm: 12px
|
||||
md: 17px
|
||||
lg: 24px
|
||||
xl: 32px
|
||||
xxl: 48px
|
||||
section: 80px
|
||||
|
||||
components:
|
||||
button-primary:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.on-primary}"
|
||||
typography: "{typography.body}"
|
||||
rounded: "{rounded.pill}"
|
||||
padding: 11px 22px
|
||||
button-primary-focus:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.on-primary}"
|
||||
rounded: "{rounded.pill}"
|
||||
button-primary-active:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.on-primary}"
|
||||
rounded: "{rounded.pill}"
|
||||
button-secondary-pill:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.primary}"
|
||||
typography: "{typography.body}"
|
||||
rounded: "{rounded.pill}"
|
||||
padding: 11px 22px
|
||||
button-dark-utility:
|
||||
backgroundColor: "{colors.ink}"
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.button-utility}"
|
||||
rounded: "{rounded.sm}"
|
||||
padding: 8px 15px
|
||||
button-pearl-capsule:
|
||||
backgroundColor: "{colors.surface-pearl}"
|
||||
textColor: "{colors.ink-muted-80}"
|
||||
typography: "{typography.caption}"
|
||||
rounded: "{rounded.md}"
|
||||
padding: 8px 14px
|
||||
button-store-hero:
|
||||
backgroundColor: "{colors.primary}"
|
||||
textColor: "{colors.on-primary}"
|
||||
typography: "{typography.button-large}"
|
||||
rounded: "{rounded.pill}"
|
||||
padding: 14px 28px
|
||||
button-icon-circular:
|
||||
backgroundColor: "{colors.surface-chip-translucent}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.full}"
|
||||
size: 44px
|
||||
text-link:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.primary}"
|
||||
typography: "{typography.body}"
|
||||
text-link-on-dark:
|
||||
backgroundColor: transparent
|
||||
textColor: "{colors.primary-on-dark}"
|
||||
typography: "{typography.body}"
|
||||
global-nav:
|
||||
backgroundColor: "{colors.surface-black}"
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.nav-link}"
|
||||
height: 44px
|
||||
sub-nav-frosted:
|
||||
backgroundColor: "{colors.canvas-parchment}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.tagline}"
|
||||
height: 52px
|
||||
product-tile-light:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.display-lg}"
|
||||
rounded: "{rounded.none}"
|
||||
padding: 80px
|
||||
product-tile-parchment:
|
||||
backgroundColor: "{colors.canvas-parchment}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.display-lg}"
|
||||
rounded: "{rounded.none}"
|
||||
padding: 80px
|
||||
product-tile-dark:
|
||||
backgroundColor: "{colors.surface-tile-1}"
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.display-lg}"
|
||||
rounded: "{rounded.none}"
|
||||
padding: 80px
|
||||
product-tile-dark-2:
|
||||
backgroundColor: "{colors.surface-tile-2}"
|
||||
textColor: "{colors.on-dark}"
|
||||
rounded: "{rounded.none}"
|
||||
product-tile-dark-3:
|
||||
backgroundColor: "{colors.surface-tile-3}"
|
||||
textColor: "{colors.on-dark}"
|
||||
rounded: "{rounded.none}"
|
||||
store-utility-card:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.body-strong}"
|
||||
rounded: "{rounded.lg}"
|
||||
padding: 24px
|
||||
configurator-option-chip:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.caption}"
|
||||
rounded: "{rounded.pill}"
|
||||
padding: 12px 16px
|
||||
configurator-option-chip-selected:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
rounded: "{rounded.pill}"
|
||||
search-input:
|
||||
backgroundColor: "{colors.canvas}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.body}"
|
||||
rounded: "{rounded.pill}"
|
||||
padding: 12px 20px
|
||||
height: 44px
|
||||
floating-sticky-bar:
|
||||
backgroundColor: "{colors.canvas-parchment}"
|
||||
textColor: "{colors.ink}"
|
||||
typography: "{typography.body}"
|
||||
height: 64px
|
||||
padding: 12px 32px
|
||||
environment-quote-card:
|
||||
backgroundColor: "{colors.surface-tile-1}"
|
||||
textColor: "{colors.on-dark}"
|
||||
typography: "{typography.display-lg}"
|
||||
rounded: "{rounded.none}"
|
||||
padding: 80px
|
||||
footer:
|
||||
backgroundColor: "{colors.canvas-parchment}"
|
||||
textColor: "{colors.ink-muted-80}"
|
||||
typography: "{typography.fine-print}"
|
||||
padding: 64px
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Apple's web presence is a masterclass in **reverent product photography framed by near-invisible UI**. Every page is a stack of edge-to-edge product "tiles" — alternating light and dark canvases, each centered on a hero headline, a one-line tagline, two tiny blue pill CTAs, and an impossibly crisp product render. Nothing competes with the product. Typography is confident but quiet; color is either pure white, an off-white parchment, or a near-black tile; interactive elements are a single, quiet blue.
|
||||
|
||||
Density is unusually low even by contemporary SaaS standards. Each tile occupies roughly one viewport, and there is no decorative chrome — no borders, no gradients, no decorative frames, no shadows on headlines. Elevation appears only when a product image rests on a surface (a single soft `rgba(0, 0, 0, 0.22) 3px 5px 30px` drop for visual weight). The result is a catalog that feels more like a museum gallery: the wall disappears and the artifact takes over.
|
||||
|
||||
Store and shop surfaces retain the same chassis but switch modes. The product configurator (iPhone 17 Pro, accessories grid) introduces a tight grid of white utility cards at `{rounded.lg}` (18px) radius with a thin border, paired with a persistent thin sub-nav strip. The environment page leans darker and more editorial. Across all five surfaces the typographic system, spacing rhythm, and the single blue accent are consistent — this is one design language expressed at different volumes.
|
||||
|
||||
**Key Characteristics:**
|
||||
- Photography-first presentation; UI recedes so the product can speak.
|
||||
- Alternating full-bleed tile sections: white/parchment ↔ near-black, with the color change itself acting as the section divider.
|
||||
- Single blue accent (`{colors.primary}` — #0066cc) carries every interactive element. No second brand color exists.
|
||||
- Two button grammars: tiny blue pill CTAs (`{rounded.pill}`) and compact utility rects (`{rounded.sm}`).
|
||||
- SF Pro Display + SF Pro Text — negative letter-spacing at display sizes for the signature "Apple tight" headline feel.
|
||||
- Whisper-soft elevation used only when a product image needs to breathe — exactly one drop-shadow in the entire system.
|
||||
- Tight two-row nav: slim `{component.global-nav}` + product-specific `{component.sub-nav-frosted}` with persistent right-aligned primary CTA.
|
||||
- Section rhythm across multiple pages: light hero → dark product tile → light utility tile → dark tile → parchment footer — a predictable pulse.
|
||||
|
||||
## Colors
|
||||
|
||||
> **Source pages analyzed:** homepage, environment, store, iPhone 17 Pro buy page, accessories index. The color system is identical across all five surfaces; only the surface-mode mix differs.
|
||||
|
||||
### Brand & Accent
|
||||
- **Action Blue** (`{colors.primary}` — #0066cc): The single brand-level interactive color. All text links, all blue pill CTAs ("Learn more", "Buy"), and the focus ring root. This is Apple's quiet but universal "click me" signal. Press state shifts to a slightly darker variant via the active scale transform rather than a hex change.
|
||||
- **Focus Blue** (`{colors.primary-focus}` — #0071e3): A marginally brighter sibling of Action Blue, reserved for the keyboard focus ring on buttons (`outline: 2px solid`).
|
||||
- **Sky Link Blue** (`{colors.primary-on-dark}` — #2997ff): A brighter blue used on dark surfaces for in-copy links and inline callouts, where Action Blue would disappear against the tile background.
|
||||
|
||||
### Surface
|
||||
- **Pure White** (`{colors.canvas}` — #ffffff): The dominant canvas. Content, utility cards, store tiles, configurator grids.
|
||||
- **Parchment** (`{colors.canvas-parchment}` — #f5f5f7): The signature Apple off-white. Used for alternating light tiles, footer region, and the default page canvas in store utility sections. Just different enough from white to create rhythm.
|
||||
- **Pearl Button** (`{colors.surface-pearl}` — #fafafc): A near-white used as the fill for secondary "ghost" buttons — lighter than the parchment canvas so the button still reads as a button against `{colors.canvas-parchment}`.
|
||||
- **Near-Black Tile 1** (`{colors.surface-tile-1}` — #272729): The primary dark-tile surface on the homepage product grid.
|
||||
- **Near-Black Tile 2** (`{colors.surface-tile-2}` — #2a2a2c): A micro-step lighter — used where a dark tile sits directly above or below Tile 1 to create the faintest separation.
|
||||
- **Near-Black Tile 3** (`{colors.surface-tile-3}` — #252527): A micro-step darker — used at the bottom of the stack and in embedded video/player frames.
|
||||
- **Pure Black** (`{colors.surface-black}` — #000000): Reserved for true void — video player backgrounds, edge-to-edge photographic overlays, the global nav bar background.
|
||||
- **Translucent Chip Gray** (`{colors.surface-chip-translucent}` — #d2d2d7): The base hex of the translucent gray chip used over photography for circular control buttons. In production, applied at ~64% alpha as `rgba(210, 210, 215, 0.64)`.
|
||||
|
||||
### Text
|
||||
- **Near-Black Ink** (`{colors.ink}` — #1d1d1f): The voice of every headline, every body paragraph, and the dark utility button's fill. Chosen instead of pure black to keep the page feeling photographic rather than printed.
|
||||
- **Body** (`{colors.body}` — #1d1d1f): Same hex as ink — Apple uses one near-black tone for all text on light surfaces.
|
||||
- **Body On Dark** (`{colors.body-on-dark}` — #ffffff): All text on dark tiles and on the global nav bar.
|
||||
- **Body Muted** (`{colors.body-muted}` — #cccccc): Secondary copy on dark tiles where pure white would be too loud.
|
||||
- **Ink Muted 80** (`{colors.ink-muted-80}` — #333333): Body text on the white Pearl Button surface — slightly softer than pure black.
|
||||
- **Ink Muted 48** (`{colors.ink-muted-48}` — #7a7a7a): Disabled button text and legal fine-print.
|
||||
|
||||
### Hairlines & Borders
|
||||
- **Divider Soft** (`{colors.divider-soft}` — #f0f0f0): The "border" tone on secondary buttons — functions as a ring shadow rather than a hard line. In production, often applied as `rgba(0, 0, 0, 0.04)`.
|
||||
- **Hairline** (`{colors.hairline}` — #e0e0e0): The 1px hairline border on store utility cards and configurator chips.
|
||||
|
||||
### Brand Gradient
|
||||
**No decorative gradients.** Atmospheric depth on product photography (the iPhone 17 Pro camera plate, the Apple Watch bands, AirPods reflections) is inherent to the imagery, not a CSS gradient overlay. The environment page's hero uses photographic atmosphere (mountain vista at dawn) but no gradient tokens are defined. Apple is the rare luxury-brand site with zero gradient-based design tokens.
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Family
|
||||
- **Display**: `SF Pro Display, system-ui, -apple-system, sans-serif` — Apple's proprietary display face, optimized for sizes ≥ 19px. Defines the voice of every headline.
|
||||
- **Body / UI**: `SF Pro Text, system-ui, -apple-system, sans-serif` — the text-optimized variant used for body copy, captions, buttons, and links below 20px.
|
||||
- **OpenType features**: `font-variant-numeric: numerator` is enabled on numeric links (pricing tables, spec sheets). Display sizes rely on tight tracking rather than contextual ligatures.
|
||||
|
||||
### Hierarchy
|
||||
|
||||
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|
||||
|---|---|---|---|---|---|
|
||||
| `{typography.hero-display}` | 56px | 600 | 1.07 | -0.28px | Hero headline; the signature "Apple tight" tracking |
|
||||
| `{typography.display-lg}` | 40px | 600 | 1.10 | 0 | Tile headlines atop every product tile |
|
||||
| `{typography.display-md}` | 34px | 600 | 1.47 | -0.374px | Section heads (SF Pro Text at display proportions) |
|
||||
| `{typography.lead}` | 28px | 400 | 1.14 | 0.196px | Product tile subcopy |
|
||||
| `{typography.lead-airy}` | 24px | 300 | 1.5 | 0 | Environment-page lead paragraphs (the rare weight 300) |
|
||||
| `{typography.tagline}` | 21px | 600 | 1.19 | 0.231px | Sub-tile tagline; sub-nav category name |
|
||||
| `{typography.body-strong}` | 17px | 600 | 1.24 | -0.374px | Inline strong emphasis |
|
||||
| `{typography.body}` | 17px | 400 | 1.47 | -0.374px | Default paragraph |
|
||||
| `{typography.dense-link}` | 17px | 400 | 2.41 | 0 | Footer / store utility link lists (relaxed leading) |
|
||||
| `{typography.caption}` | 14px | 400 | 1.43 | -0.224px | Secondary captions, button text |
|
||||
| `{typography.caption-strong}` | 14px | 600 | 1.29 | -0.224px | Emphasized captions |
|
||||
| `{typography.button-large}` | 18px | 300 | 1.0 | 0 | Store hero CTAs (the rare weight 300) |
|
||||
| `{typography.button-utility}` | 14px | 400 | 1.29 | -0.224px | Utility/nav button labels |
|
||||
| `{typography.fine-print}` | 12px | 400 | 1.0 | -0.12px | Fine-print, footer body |
|
||||
| `{typography.micro-legal}` | 10px | 400 | 1.3 | -0.08px | Micro legal disclaimers |
|
||||
| `{typography.nav-link}` | 12px | 400 | 1.0 | -0.12px | Global nav menu items |
|
||||
|
||||
### Principles
|
||||
|
||||
- **Negative letter-spacing at display sizes.** Every headline at 17px and up carries a slight tracking tighten (`-0.12 → -0.374px`). This produces the iconic "Apple tight" headline cadence. Never used at 12px or below.
|
||||
- **Body copy at 17px, not 16px.** Apple breaks the SaaS convention and runs paragraph text at 17px. The extra pixel gives the page an unmistakable "reading, not scanning" pace.
|
||||
- **Weight 300 is real and rare.** Used deliberately on a handful of large-size reads (`{typography.button-large}` at 18px/300 and `{typography.lead-airy}` at 24px/300). It's not an accident — it's a light-atmosphere cue reserved for moments where the content should feel airy.
|
||||
- **Weight 600, not 700, for headlines.** Apple's headlines sit at weight 600. Weight 700 is used sparingly for `{typography.tagline}` (21px) when a touch more assertion is needed.
|
||||
- **Line-height is context-specific.** Display sizes use 1.07–1.19 (tight). Body uses 1.47. Utility link stacks in the footer/store use an unusually relaxed 2.41 (`{typography.dense-link}`). The 2.41 is not a bug — it's how the footer's dense link columns breathe.
|
||||
- **Weight 500 is deliberately absent.** The ladder is 300 / 400 / 600 / 700. Mid-weight readings always use 600.
|
||||
|
||||
### Note on Font Substitutes
|
||||
SF Pro is Apple's proprietary system font. When building off-system:
|
||||
|
||||
- Use `system-ui, -apple-system, BlinkMacSystemFont` as the first stack entry — on macOS/iOS/Safari this resolves to the real SF Pro.
|
||||
- For non-Apple platforms, **Inter** (Google Fonts, variable) is the closest open-source equivalent. Inter at weight 600 with `font-feature-settings: "ss03"` approximates SF Pro's rounded "a" character.
|
||||
- Nudge `letter-spacing` down by `-0.01em` on display sizes to re-create the Apple tight feel; Inter's default tracking runs slightly wider than SF Pro.
|
||||
- For body text, tighten line-height by `0.03` (from 1.47 → 1.44) when substituting Inter — Inter's taller x-height needs less leading.
|
||||
|
||||
## Layout
|
||||
|
||||
### Spacing System
|
||||
- **Base unit:** 8px. Sub-base values (2, 4, 5, 6, 7) are used for tight typographic adjustments; structural layout snaps to 8/12/16/20/24.
|
||||
- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.md}` 17px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 80px.
|
||||
- **Section vertical padding:** `{spacing.section}` (80px) inside a product tile; tiles stack edge-to-edge with 0 gap (the color change provides the break).
|
||||
- **Card padding:** `{spacing.lg}` (24px) inside utility grid cards.
|
||||
- **Button padding:** 8–11px vertical, 15–22px horizontal.
|
||||
- **Universal rhythm constants:** the 17px body line-height multiplier (~25px line) and 21px tagline size show up on every analyzed page.
|
||||
|
||||
### Grid & Container
|
||||
- **Max content width:** ~980px on text-heavy sections (environment), ~1440px on product grids (store, accessories), full-bleed for product tiles (homepage).
|
||||
- **Column patterns:** 3 to 5 column utility card grid on store/accessories; 2-column side-by-side tiles on homepage occasional sections; single-column centered stack on product tile heroes.
|
||||
- **Gutters:** 20–24px between cards in a utility grid.
|
||||
|
||||
### Whitespace Philosophy
|
||||
Apple's whitespace is the product's pedestal. Every tile begins with at least 64px of air above its headline and 48–64px below. Product renders are never crowded; the nearest content to a product image is at least 40px away. The footer is the only area that breaks this — there, Apple goes deliberately dense to make the full information architecture visible at a glance.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
| Level | Treatment | Use |
|
||||
|---|---|---|
|
||||
| Flat | No shadow, no border | Full-bleed tiles, global nav, footer, body sections |
|
||||
| Soft hairline | 1px `rgba(0, 0, 0, 0.08)` border | Utility cards, sub-nav frosted-glass separator |
|
||||
| Backdrop blur | `backdrop-filter: blur(N)` on Parchment 80% | Sub-nav and the iPhone buy floating sticky bar |
|
||||
| Product shadow | `rgba(0, 0, 0, 0.22) 3px 5px 30px 0` | Product renders resting on a surface (the only true "shadow" in the system) |
|
||||
|
||||
**Shadow philosophy.** Apple uses **exactly one** drop-shadow, and it is applied to photographic product imagery — never to cards, never to buttons, never to text. Elevation in the UI comes from (a) surface-color change (light tile ↔ dark tile) and (b) backdrop-blur on sticky bars. The single shadow is about giving the product weight, not about UI hierarchy.
|
||||
|
||||
### Decorative Depth
|
||||
- **Atmospheric imagery** on the environment page (photographic vista) supplies mood; no CSS gradient involved.
|
||||
- **Edge-to-edge tile alternation** creates rhythm without borders or shadows — the color change itself is the divider.
|
||||
- **Backdrop-filter blur** on `{component.sub-nav-frosted}` and `{component.floating-sticky-bar}` creates a "floating over content" effect that's functional, not decorative.
|
||||
|
||||
## Shapes
|
||||
|
||||
### Border Radius Scale
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `{rounded.none}` | 0px | Full-bleed product tiles (no corner rounding) |
|
||||
| `{rounded.xs}` | 5px | Inline links when styled as subtle chips (rare) |
|
||||
| `{rounded.sm}` | 8px | Dark utility buttons (Sign In, Bag), inline card imagery |
|
||||
| `{rounded.md}` | 11px | White Pearl Button capsules |
|
||||
| `{rounded.lg}` | 18px | Store utility cards, accessories grid cards |
|
||||
| `{rounded.pill}` | 9999px | Primary blue pill CTAs, sub-nav buy button, configurator option chips, search input — the signature Apple pill |
|
||||
| `{rounded.full}` | 9999px / 50% | Circular control chips floating over photography |
|
||||
|
||||
### Photography Geometry
|
||||
- **Hero imagery**: full-bleed, 21:9 or taller on the homepage; 16:9 on environment and shop pages. Product renders are photographic-realistic, often shot on a tinted surface that becomes the tile background.
|
||||
- **Product renders**: PNG/WebP with transparency; rest on a surface tile and pick up the system shadow.
|
||||
- **Accessory grid**: square 1:1 crops at `{rounded.lg}` (18px) radius, light neutral backgrounds, product centered with 20–40px internal padding.
|
||||
- **No rounded imagery in hero tiles** — images are full-bleed rectangular. Rounding (`{rounded.sm}`, `{rounded.lg}`) appears only on inline card imagery.
|
||||
- Lazy-loading via responsive `srcset` and `sizes` across all breakpoints; CDN-optimized WebP.
|
||||
|
||||
## Components
|
||||
|
||||
### Top Navigation
|
||||
|
||||
**`global-nav`** — Persistent, ultra-thin black nav bar pinned to the top of every page. Background `{colors.surface-black}`, height 44px, text `{colors.on-dark}` in `{typography.nav-link}` (12px / 400 / -0.12px tracking). Links are quiet, spaced ~20px apart, running edge-to-edge across the top. Right-aligned cluster: Search, Bag icons — always visible. On mobile, collapses to hamburger at ~834px and the Apple logo centers.
|
||||
|
||||
**`sub-nav-frosted`** — Surface-specific nav that sticks below the global nav. Background `{colors.canvas-parchment}` at 80% opacity with backdrop-filter blur, creating a frosted-glass effect. Height 52px. Content on left: product category name ("iPhone", "Store", "Accessories") in `{typography.tagline}` (21px / 600). Content right: inline nav links in `{typography.button-utility}` (14px), ending in a persistent `{component.button-primary}` ("Buy") or a utility link.
|
||||
|
||||
### Buttons
|
||||
|
||||
**`button-primary`** — The signature Apple action. Background `{colors.primary}` (Action Blue #0066cc), text `{colors.on-primary}` in `{typography.body}` (SF Pro Text 17px / 400), rounded `{rounded.pill}` (full pill — capsule-shaped), padding 11px × 22px. The full-pill radius IS the brand action signal.
|
||||
- Active state: `{component.button-primary-active}` — `transform: scale(0.95)` (the system-wide micro-interaction).
|
||||
- Focus state: `{component.button-primary-focus}` — 2px solid `{colors.primary-focus}` outline.
|
||||
|
||||
**`button-secondary-pill`** — Used as the second CTA when two blue pills appear together ("Learn more" / "Buy"). Background transparent, text `{colors.primary}`, 1px solid `{colors.primary}` border, rounded `{rounded.pill}`, padding 11px × 22px. Reads as a "ghost pill."
|
||||
|
||||
**`button-dark-utility`** — Global nav actions (Sign In, Bag, language selector). Background `{colors.ink}` (#1d1d1f), text `{colors.on-dark}` in `{typography.button-utility}` (14px / 400 / -0.224px tracking), rounded `{rounded.sm}` (8px), padding 8px × 15px. Active state shrinks via `transform: scale(0.95)`.
|
||||
|
||||
**`button-pearl-capsule`** — Product-card secondary button. Background `{colors.surface-pearl}` (#fafafc), text `{colors.ink-muted-80}` in `{typography.caption}` (14px), 3px solid `{colors.divider-soft}` border (functions as a soft ring rather than a visible line), rounded `{rounded.md}` (11px), padding 8px × 14px.
|
||||
|
||||
**`button-store-hero`** — A larger primary CTA used on store hero surfaces. Same Action Blue + Paper White as `{component.button-primary}`, but with `{typography.button-large}` (18px / 300 — note the rare weight 300) and slightly more padding (14px × 28px). Used sparingly on the store landing.
|
||||
|
||||
**`button-icon-circular`** — Floats over photography. 44 × 44px, background `{colors.surface-chip-translucent}` at ~64% alpha, icon in `{colors.ink}`, rounded `{rounded.full}`. Used for carousel controls, close buttons, and in-image controls (product image thumbnails on the iPhone buy page).
|
||||
|
||||
**`text-link`** — Inline body links in `{colors.primary}` (Action Blue). Underlined or non-underlined per context.
|
||||
|
||||
**`text-link-on-dark`** — Inline body links on dark tiles in `{colors.primary-on-dark}` (Sky Link Blue #2997ff) — Action Blue would disappear against `{colors.surface-tile-1}`.
|
||||
|
||||
### Cards & Containers
|
||||
|
||||
**`product-tile-light`** — Full-bleed light tile. Background `{colors.canvas}` (white), text `{colors.ink}`, rounded `{rounded.none}` (0 — tiles touch edges), vertical padding `{spacing.section}` (80px). Centered stack: product name in `{typography.display-lg}` (40px / 600) → one-line tagline in `{typography.lead}` (28px / 400) → two `{component.button-primary}` CTAs ("Learn more" / "Buy") → product render resting on the surface with the system shadow.
|
||||
|
||||
**`product-tile-parchment`** — Same as `{component.product-tile-light}` but on `{colors.canvas-parchment}` (#f5f5f7). Used to break two consecutive white tiles.
|
||||
|
||||
**`product-tile-dark`** — Full-bleed dark tile. Background `{colors.surface-tile-1}` (#272729), text `{colors.on-dark}`, rounded `{rounded.none}`, vertical padding `{spacing.section}` (80px). Same content stack as the light tile but with `{component.text-link-on-dark}` for inline copy and `{component.button-primary}` (Action Blue still works on the dark surface). Used on the homepage product grid as the alternating dark band.
|
||||
|
||||
**`product-tile-dark-2`** — Variant on `{colors.surface-tile-2}` (#2a2a2c). Used where a dark tile sits directly above or below `{component.product-tile-dark}` to create the faintest separation through micro-step lightness change.
|
||||
|
||||
**`product-tile-dark-3`** — Variant on `{colors.surface-tile-3}` (#252527). Used at the bottom of the stack and in embedded video/player frames.
|
||||
|
||||
**`store-utility-card`** — Used in store grid and accessories grid. Background `{colors.canvas}` (white), 1px solid `{colors.hairline}` border, rounded `{rounded.lg}` (18px), padding `{spacing.lg}` (24px). Top: product image (1:1 crop with `{rounded.sm}` (8px) inner image radius). Below: product name in `{typography.body-strong}` (17px / 600), price in `{typography.body}` (17px / 400), and a `{component.text-link}` ("Buy" or "Learn more"). No shadow by default; product render itself carries the system product-shadow.
|
||||
|
||||
**`configurator-option-chip`** — Pill-shaped tappable cell used in the iPhone 17 Pro buy page. Background `{colors.canvas}`, text `{colors.ink}` in `{typography.caption}`, rounded `{rounded.pill}`, padding 12px × 16px. Contains a small product thumbnail + label + price delta. Arranged in a grid of 4–5 options per row.
|
||||
|
||||
**`configurator-option-chip-selected`** — Selected state. Border upgrades to 2px solid `{colors.primary-focus}`. Same shape, same content.
|
||||
|
||||
**`environment-quote-card`** — A photographic-canvas hero specific to the environment page. Dark photographic backdrop (mountain vista at dawn) with `{colors.surface-tile-1}` as the fallback color, centered white-text headline in `{typography.display-lg}` (40px), small green "Apple 2030" pictographic logo above the headline, single `{component.button-primary}` below. Padding `{spacing.section}` (80px).
|
||||
|
||||
**`floating-sticky-bar`** — Floats at the bottom of the viewport on the iPhone 17 Pro buy page during scroll. Background `{colors.canvas-parchment}` at 80% opacity with `backdrop-filter: blur(N)`, height 64px, padding 12px × 32px. Left: running price total in `{typography.body}`. Right: `{component.button-primary}` ("Add to Bag").
|
||||
|
||||
### Inputs & Forms
|
||||
|
||||
**`search-input`** — The accessories search input. Background `{colors.canvas}`, text `{colors.ink}` in `{typography.body}` (17px), 1px solid `rgba(0, 0, 0, 0.08)` border, rounded `{rounded.pill}` (full pill — search is also pill-shaped, matching the CTA grammar), padding 12px × 20px, height 44px. Leading icon: search glyph at 14px, muted tint.
|
||||
|
||||
Error and validation states were not surfaced in the analyzed pages.
|
||||
|
||||
### Footer
|
||||
|
||||
**`footer`** — Background `{colors.canvas-parchment}` (#f5f5f7), text `{colors.ink-muted-80}`. Link columns in `{typography.dense-link}` (17px / 400 / 2.41 line-height — the relaxed leading is what makes the dense columns scannable). Column headings in `{typography.caption-strong}` (14px / 600). Legal row at the very bottom in `{typography.fine-print}` (12px / 400) with `{colors.ink-muted-48}` text. Vertical padding 64px.
|
||||
|
||||
## Do's and Don'ts
|
||||
|
||||
### Do
|
||||
- Use `{colors.primary}` (Action Blue #0066cc) for every interactive element — links, pill CTAs, focus signals — and nothing else. The single accent is non-negotiable.
|
||||
- Set headlines in `{typography.hero-display}` or `{typography.display-lg}` with negative letter-spacing (`-0.28 → -0.374px`) to get the signature "Apple tight" cadence.
|
||||
- Run body copy at `{typography.body}` (17px / 400 / 1.47 / -0.374px) — not 16px. The extra pixel defines the brand's reading pace.
|
||||
- Alternate `{component.product-tile-light}` (or parchment) and `{component.product-tile-dark}` for full-bleed section rhythm. The color change IS the divider.
|
||||
- Reserve `{rounded.pill}` for the primary blue CTA and any other element that should read as an "action" (configurator chips, search input, sticky bar CTA).
|
||||
- Apply the single product-shadow (`rgba(0, 0, 0, 0.22) 3px 5px 30px`) only to product renders resting on a surface — never on cards, buttons, or text.
|
||||
- Use `transform: scale(0.95)` as the active/press state on every button — it's the system-wide micro-interaction.
|
||||
- Keep the global nav `{colors.surface-black}` (true black) — it's the only place pure black appears on most pages.
|
||||
|
||||
### Don't
|
||||
- Don't introduce a second accent color; every "click me" signal is `{colors.primary}` (Action Blue).
|
||||
- Don't add shadows to cards, buttons, or text — shadow is reserved for product imagery.
|
||||
- Don't use gradients as decorative backgrounds; atmosphere comes from photography.
|
||||
- Don't set body copy at weight 500 — Apple's ladder is 300 / 400 / 600 / 700, with 500 deliberately absent. Body is always 400; strong inline is 600; display is 600.
|
||||
- Don't round full-bleed tiles — tiles are rectangular and edge-to-edge; the color change is the divider.
|
||||
- Don't tighten line-height below 1.47 for body copy — the editorial leading is part of the brand.
|
||||
- Don't mix radii grammars — use `{rounded.sm}` for compact utility, `{rounded.lg}` for utility cards, `{rounded.pill}` for pills, and nothing in between (except the rare `{rounded.md}` Pearl Button).
|
||||
- Don't use `{colors.primary-on-dark}` (Sky Link Blue) on light surfaces — it's the dark-tile-only variant. Action Blue is for light surfaces.
|
||||
|
||||
## Responsive Behavior
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| Name | Width | Key Changes |
|
||||
|---|---|---|
|
||||
| Small phone | ≤ 419px | Single-column tiles; sub-nav collapses to category name + primary CTA only; hero typography drops to 28px |
|
||||
| Phone | 420–640px | Single-column stack; product renders scale to 80% of tile width; hero h1 drops to 34px |
|
||||
| Large phone | 641–735px | Tiles transition to tighter padding (48px vertical vs 80px); fine-print wraps |
|
||||
| Tablet portrait | 736–833px | Global nav collapses to hamburger; sub-nav hides category chips, keeps primary CTA |
|
||||
| Tablet landscape | 834–1023px | Global nav returns fully expanded; 3-column utility grids become 2-column |
|
||||
| Small desktop | 1024–1068px | Product tiles use 2/3 width with margin gutters; hero h1 stays at 40px |
|
||||
| Desktop | 1069–1440px | Full layout; 4–5 column store grids; 1440px content max |
|
||||
| Wide desktop | ≥ 1441px | Content locks at 1440px, margins absorb extra width |
|
||||
|
||||
The structural breakpoints that matter for agents: 1440px (content lock), 1068px (small-desktop), 833px (tablet landscape switch), 734px (tablet portrait), 640px (phone), 480px (small phone).
|
||||
|
||||
### Touch Targets
|
||||
- Minimum 44 × 44px. `{component.button-primary}` lands at ~44 × 100px (with the full-pill radius making the visible hit area more generous than the label suggests).
|
||||
- `{component.button-icon-circular}` is exactly 44 × 44px.
|
||||
- Global nav utility links are smaller (~32 × 80px) — they deliberately sit at a tighter target because they're precision desktop actions, and the mobile hamburger replaces them at ≤ 833px.
|
||||
|
||||
### Collapsing Strategy
|
||||
- **Global nav**: full horizontal link row on desktop → collapses to Apple logo + hamburger + bag icon at 834px and below.
|
||||
- **Sub-nav**: category name + inline links + primary CTA → category name + primary CTA only at mobile; inline links move into a hamburger tray.
|
||||
- **Product tiles**: stack from 2-column to 1-column at 834px; vertical padding tightens from 80px → 48px at small-phone.
|
||||
- **Utility grids** (store, accessories): 5-col → 4-col (1440px) → 3-col (1068px) → 2-col (834px) → 1-col (640px).
|
||||
- **Hero typography**: `{typography.hero-display}` (56px) → `{typography.display-lg}` (40px) at 1068px → 34px at 640px → 28px at 419px.
|
||||
|
||||
### Image Behavior
|
||||
- All product imagery uses responsive `srcset` with breakpoint-matched crops.
|
||||
- Hero photography may switch art direction at mobile (e.g., the environment page's vista crops to a taller aspect ratio on mobile, framing the subject differently).
|
||||
- Product renders maintain their 1:1 or 4:3 aspect ratios across breakpoints; only scale changes.
|
||||
- Lazy-loading is default; the above-fold hero loads eagerly.
|
||||
|
||||
## Iteration Guide
|
||||
|
||||
1. Focus on ONE component at a time. Reference its YAML key directly (`{component.product-tile-dark}`, `{component.search-input}`).
|
||||
2. Variants of an existing component (`-active`, `-focus`, `-2`, `-3`) live as separate entries in `components:`.
|
||||
3. Use `{token.refs}` everywhere — never inline hex.
|
||||
4. Never document hover. Default and Active/Pressed states only.
|
||||
5. Display headlines stay SF Pro Display 600 with negative letter-spacing. Body stays SF Pro Text 400 at 17px. The boundary is unbreakable.
|
||||
6. The single drop-shadow (`rgba(0, 0, 0, 0.22) 3px 5px 30px`) is reserved for product photography only.
|
||||
7. When in doubt about emphasis: alternate surface (light → dark tile) before adding chrome.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
- Form validation and error states were not surfaced on the analyzed pages; only the neutral search input is documented.
|
||||
- The homepage's embedded video/player frame uses `{colors.surface-black}`; interior player controls are not documented (they're a platform widget, not a web-design token).
|
||||
- Some component imagery is dynamic (rotating product hero) and its specific copy varies per surface — component specs name the structure, not the rotating content.
|
||||
- Dark-mode counterparts for store and accessories utility cards were not surfaced on the analyzed pages; the system documented is the daytime/light-dominant variant Apple ships by default.
|
||||
- Atmospheric photography (environment page mountain vista) is a content asset, not a design token; the documented `{component.environment-quote-card}` describes the structural surface only.
|
||||
- The exact backdrop-filter blur radius on `{component.sub-nav-frosted}` and `{component.floating-sticky-bar}` is platform-dependent; production CSS uses `saturate(180%) blur(20px)` as a typical baseline but the value isn't formalized as a token.
|
||||
@@ -25,6 +25,7 @@ import EquList from "@/components/dnf/Equipment/List/index.vue";
|
||||
import OathList from "@/components/dnf/Equipment/Oath/index.vue";
|
||||
import EquipmentCell from "./components/EquipmentCell.vue";
|
||||
import { getImageURL } from "@/utils";
|
||||
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
|
||||
|
||||
type WeaponTab = "weapon" | "else" | "subweapon";
|
||||
type EquipTab = "suit" | "universal";
|
||||
@@ -1153,34 +1154,19 @@ const toggleOathRarity = (value: unknown) => {
|
||||
<CardTitle class="text-base">
|
||||
<Tabs v-model="sidePanelTab" class="gap-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="equipped">当前已穿戴</TabsTrigger>
|
||||
<TabsTrigger value="equipped">结果面板</TabsTrigger>
|
||||
<TabsTrigger value="detail">装备属性</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs></CardTitle
|
||||
>
|
||||
|
||||
<CardDescription v-if="sidePanelTab === 'equipped'"
|
||||
>按部位查看当前选择,点击可切到属性预览。</CardDescription
|
||||
>通过 Tab 查看装备/誓约穿戴与计算结果。</CardDescription
|
||||
>
|
||||
<CardDescription v-else>当前聚焦的装备会展示单件与套装属性。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent v-if="sidePanelTab === 'equipped'" class="min-h-0 flex-1 overflow-hidden">
|
||||
<div class="h-full space-y-3 overflow-y-auto pr-1">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-muted-foreground">装备</div>
|
||||
<EquList
|
||||
v-model:part="equippedPart"
|
||||
:detail="configStore.config.equips"
|
||||
:with-sub-weapon="canUseSubWeapon"
|
||||
:with-pet="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-muted-foreground">誓约</div>
|
||||
<OathList v-model:part="equippedOathPart" :detail="configStore.config.oaths" />
|
||||
</div>
|
||||
</div>
|
||||
<CardContent v-if="sidePanelTab === 'equipped'" class="min-h-0 flex-1 overflow-hidden p-0">
|
||||
<ResultPanel />
|
||||
</CardContent>
|
||||
|
||||
<CardContent
|
||||
|
||||
@@ -5,11 +5,13 @@ import { useConfigStore, useInfoStore } from "@/stores";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const infoStore = useInfoStore();
|
||||
|
||||
const moduleMode = ref<"equip" | "oath" | "avatar" | "global">("equip");
|
||||
const forgeViewTab = ref<"config" | "result">("result");
|
||||
const notice = ref("");
|
||||
const noticeType = ref<"error" | "info">("info");
|
||||
|
||||
@@ -337,7 +339,7 @@ const resetAllForge = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="flex h-full min-h-0 flex-col gap-4 overflow-hidden">
|
||||
<Card class="border-border/60 bg-gradient-to-b from-background to-muted/30">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex flex-wrap items-center justify-between gap-2">
|
||||
@@ -351,6 +353,30 @@ const resetAllForge = () => {
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="inline-flex flex-wrap rounded-md border p-1">
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="forgeViewTab === 'result' ? 'default' : 'ghost'"
|
||||
@click="forgeViewTab = 'result'"
|
||||
>
|
||||
结果面板
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="forgeViewTab === 'config' ? 'default' : 'ghost'"
|
||||
@click="forgeViewTab = 'config'"
|
||||
>
|
||||
配置编辑
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="forgeViewTab === 'result' ? 'default' : 'ghost'"
|
||||
@click="forgeViewTab = 'result'"
|
||||
>
|
||||
计算结果
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="forgeViewTab === 'config'" class="inline-flex flex-wrap rounded-md border p-1">
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="moduleMode === 'equip' ? 'default' : 'ghost'"
|
||||
@@ -402,7 +428,17 @@ const resetAllForge = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div v-if="moduleMode === 'equip'" class="grid grid-cols-1 gap-4">
|
||||
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
|
||||
<div v-if="forgeViewTab === 'result'" class="h-full min-h-[20rem] overflow-hidden">
|
||||
<div class="h-full overflow-hidden">
|
||||
<ResultPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="forgeViewTab === 'config' && moduleMode === 'equip'"
|
||||
class="grid grid-cols-1 gap-4"
|
||||
>
|
||||
<Card class="border-border/60 bg-card/90">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
@@ -451,7 +487,7 @@ const resetAllForge = () => {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-if="moduleMode === 'oath'" class="grid grid-cols-1 gap-4">
|
||||
<div v-if="forgeViewTab === 'config' && moduleMode === 'oath'" class="grid grid-cols-1 gap-4">
|
||||
<Card class="border-border/60 bg-card/90">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
@@ -484,7 +520,10 @@ const resetAllForge = () => {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-if="moduleMode === 'avatar'" class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<div
|
||||
v-if="forgeViewTab === 'config' && moduleMode === 'avatar'"
|
||||
class="grid grid-cols-1 gap-4 xl:grid-cols-2"
|
||||
>
|
||||
<Card class="border-border/60 bg-card/90">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
@@ -691,7 +730,10 @@ const resetAllForge = () => {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div v-if="moduleMode === 'global'" class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div
|
||||
v-if="forgeViewTab === 'config' && moduleMode === 'global'"
|
||||
class="grid grid-cols-1 gap-4 lg:grid-cols-2"
|
||||
>
|
||||
<Card class="border-border/60 bg-card/90">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center gap-2">
|
||||
@@ -727,5 +769,6 @@ const resetAllForge = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { LoaderCircle, RefreshCcw } from "lucide-vue-next";
|
||||
import { LoaderCircle, PanelRight, RefreshCcw } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import Tabs from "@/components/ui/tabs/Tabs.vue";
|
||||
import TabsList from "@/components/ui/tabs/TabsList.vue";
|
||||
import TabsTrigger from "@/components/ui/tabs/TabsTrigger.vue";
|
||||
@@ -20,6 +21,17 @@ const configStore = useConfigStore();
|
||||
const loading = ref(false);
|
||||
const loadError = ref("");
|
||||
const isExecuting = ref(false);
|
||||
const isMobileLayout = ref(false);
|
||||
const isResultSheetOpen = ref(false);
|
||||
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
|
||||
const updateMobileLayout = () => {
|
||||
isMobileLayout.value = mediaQuery?.matches ?? false;
|
||||
if (!isMobileLayout.value) {
|
||||
isResultSheetOpen.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ label: "装备", key: "equipment" },
|
||||
@@ -34,6 +46,8 @@ const activeTab = computed(() => {
|
||||
return "equipment";
|
||||
});
|
||||
|
||||
const showGlobalResultPanel = computed(() => activeTab.value !== "equipment");
|
||||
|
||||
const loadCharacter = async () => {
|
||||
loading.value = true;
|
||||
loadError.value = "";
|
||||
@@ -53,6 +67,9 @@ const switchTab = (tab: (typeof tabs)[number]["key"]) => {
|
||||
onMounted(() => {
|
||||
loadCharacter();
|
||||
window.addEventListener("beforeunload", configStore.saveConfig);
|
||||
mediaQuery = window.matchMedia("(max-width: 1279px)");
|
||||
updateMobileLayout();
|
||||
mediaQuery.addEventListener("change", updateMobileLayout);
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -62,6 +79,12 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
watch(showGlobalResultPanel, (show) => {
|
||||
if (!show) {
|
||||
isResultSheetOpen.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const stopConfigWatch = watch(
|
||||
() => JSON.stringify(configStore.config),
|
||||
async () => {
|
||||
@@ -79,6 +102,7 @@ const stopConfigWatch = watch(
|
||||
|
||||
onUnmounted(() => {
|
||||
stopConfigWatch();
|
||||
mediaQuery?.removeEventListener("change", updateMobileLayout);
|
||||
window.removeEventListener("beforeunload", configStore.saveConfig);
|
||||
configStore.saveConfig();
|
||||
});
|
||||
@@ -89,15 +113,12 @@ onUnmounted(() => {
|
||||
<Card class="flex-none">
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center justify-between">
|
||||
<span>角色配置 · {{ alter }}</span>
|
||||
<span>角色配置</span>
|
||||
<Button variant="outline" size="sm" :disabled="loading" @click="loadCharacter">
|
||||
<RefreshCcw :class="{ 'animate-spin': loading }" />
|
||||
刷新
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
>此页面承接旧版 character 模块,逐步迁移装备/技能/打造功能。</CardDescription
|
||||
>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<Tabs :model-value="activeTab" class="gap-0">
|
||||
@@ -123,13 +144,35 @@ onUnmounted(() => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]">
|
||||
<div
|
||||
:class="[
|
||||
'grid min-h-0 flex-1 grid-cols-1 gap-4',
|
||||
showGlobalResultPanel ? 'xl:grid-cols-[minmax(0,1fr)_24rem]' : 'xl:grid-cols-1',
|
||||
]"
|
||||
>
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
<router-view />
|
||||
</div>
|
||||
<aside class="min-h-0">
|
||||
<aside v-if="!isMobileLayout && showGlobalResultPanel" class="min-h-0">
|
||||
<ResultPanel />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Sheet v-if="isMobileLayout && showGlobalResultPanel" v-model:open="isResultSheetOpen">
|
||||
<SheetTrigger as-child>
|
||||
<Button class="fixed bottom-5 right-4 z-30 shadow-lg" size="sm">
|
||||
<PanelRight class="size-4" />
|
||||
查看结果
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" class="h-[78vh] overflow-hidden px-3 pb-3">
|
||||
<SheetHeader class="pb-2">
|
||||
<SheetTitle>实时计算结果</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div class="h-[calc(78vh-4.5rem)] overflow-hidden">
|
||||
<ResultPanel />
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,28 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import type { IResultSkill } from "@/api/calc/type";
|
||||
import { useConfigStore, useInfoStore } from "@/stores";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import Tabs from "@/components/ui/tabs/Tabs.vue";
|
||||
import TabsList from "@/components/ui/tabs/TabsList.vue";
|
||||
import TabsTrigger from "@/components/ui/tabs/TabsTrigger.vue";
|
||||
import EquList from "@/components/dnf/Equipment/List/index.vue";
|
||||
import OathList from "@/components/dnf/Equipment/Oath/index.vue";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const infoStore = useInfoStore();
|
||||
|
||||
const displayTab = ref<"equ" | "oath">("equ");
|
||||
const skillSort = ref<"damage" | "cd">("damage");
|
||||
const equippedPart = ref("");
|
||||
const equippedOathPart = ref("0");
|
||||
const skillSort = ref<"damage" | "cd" | "buff-main" | "buff-atk" | "buff-str" | "buff-ratio">(
|
||||
"damage",
|
||||
);
|
||||
|
||||
const resultState = computed(() => configStore.result.state);
|
||||
const isLoading = computed(() => configStore.result.isLoading);
|
||||
const isBufferClass = computed(() => !!resultState.value?.buffer);
|
||||
const standard = computed(() => infoStore.standard);
|
||||
|
||||
const resultError = computed(() => {
|
||||
const error = configStore.result.error;
|
||||
if (!error) return "";
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
});
|
||||
|
||||
const retryCalc = async () => {
|
||||
await configStore.result.execute();
|
||||
};
|
||||
|
||||
const getSkillCountEntry = (skill: IResultSkill) => {
|
||||
const name = skill.name;
|
||||
const mode = skill.mode;
|
||||
const existed = configStore.skillCountConfig.find(
|
||||
(item) => item.name === name && item.mode === mode,
|
||||
);
|
||||
if (existed) return existed;
|
||||
const created = { name, mode, count: 1 };
|
||||
configStore.skillCountConfig.push(created);
|
||||
return created;
|
||||
};
|
||||
|
||||
const getSkillCount = (skill: IResultSkill) => {
|
||||
return Math.max(0, Number(getSkillCountEntry(skill).count) || 0);
|
||||
};
|
||||
|
||||
const setSkillCount = (skill: IResultSkill, raw: string | number) => {
|
||||
const parsed = Number(raw);
|
||||
const entry = getSkillCountEntry(skill);
|
||||
entry.count = Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
|
||||
};
|
||||
|
||||
const getStandardSkill = (skill: IResultSkill) => {
|
||||
return standard.value?.skills.find(
|
||||
(item) => item.name === skill.name && item.mode === skill.mode,
|
||||
);
|
||||
};
|
||||
|
||||
const getStandardSkillCount = (skill: IResultSkill) => {
|
||||
const count = standard.value?.skillCount.find(
|
||||
(item) => item.name === skill.name && item.mode === skill.mode,
|
||||
)?.count;
|
||||
return Number(count ?? 0);
|
||||
};
|
||||
|
||||
const sortedSkills = computed(() => {
|
||||
const skills = [...(resultState.value?.skills ?? [])];
|
||||
if (isBufferClass.value) {
|
||||
const metricIndexMap: Record<string, number> = {
|
||||
"buff-main": 0,
|
||||
"buff-atk": 1,
|
||||
"buff-str": 2,
|
||||
"buff-ratio": 3,
|
||||
};
|
||||
const metricIndex = metricIndexMap[skillSort.value] ?? 2;
|
||||
return skills.sort(
|
||||
(left, right) => (right.buff?.[metricIndex] ?? 0) - (left.buff?.[metricIndex] ?? 0),
|
||||
);
|
||||
}
|
||||
if (skillSort.value === "cd") {
|
||||
return skills.sort((left, right) => left.cd - right.cd);
|
||||
}
|
||||
@@ -33,6 +95,35 @@ const totalDamage = computed(() => {
|
||||
return sortedSkills.value.reduce((sum, item) => sum + item.damage, 0);
|
||||
});
|
||||
|
||||
const totalWeightedDamage = computed(() => {
|
||||
return sortedSkills.value.reduce((sum, item) => sum + item.damage * getSkillCount(item), 0);
|
||||
});
|
||||
|
||||
const totalStandardDamage = computed(() => {
|
||||
if (!standard.value) return 0;
|
||||
return sortedSkills.value.reduce((sum, item) => {
|
||||
const base = getStandardSkill(item);
|
||||
if (!base) return sum;
|
||||
const count = getStandardSkillCount(item);
|
||||
return sum + base.damage * count;
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const totalDiffLabel = computed(() => {
|
||||
if (!totalStandardDamage.value) return formatNumber(totalWeightedDamage.value);
|
||||
const diff =
|
||||
((totalWeightedDamage.value - totalStandardDamage.value) / totalStandardDamage.value) * 100;
|
||||
const prefix = diff >= 0 ? "+" : "";
|
||||
return `${prefix}${diff.toFixed(2)}%`;
|
||||
});
|
||||
|
||||
const totalDiffClass = computed(() => {
|
||||
if (!totalStandardDamage.value) return "text-foreground";
|
||||
return totalWeightedDamage.value >= totalStandardDamage.value
|
||||
? "text-emerald-600"
|
||||
: "text-red-600";
|
||||
});
|
||||
|
||||
const formatNumber = (value: number) => {
|
||||
if (!Number.isFinite(value)) return "0";
|
||||
if (Math.abs(value) >= 100000000) {
|
||||
@@ -55,24 +146,52 @@ const changeSources = computed(() => configStore.changeSourceLogs.slice(0, 8));
|
||||
const clearChangeSources = () => {
|
||||
configStore.clearChangeSource();
|
||||
};
|
||||
|
||||
const formatPercent = (value: number) => `${value.toFixed(2)}%`;
|
||||
|
||||
const formatDeltaPercent = (current: number, baseline: number) => {
|
||||
if (!baseline) return formatNumber(current);
|
||||
const ratio = ((current - baseline) / baseline) * 100;
|
||||
return `${ratio >= 0 ? "+" : ""}${ratio.toFixed(2)}%`;
|
||||
};
|
||||
|
||||
const diffClass = (current: number, baseline: number) => {
|
||||
if (!baseline) return "text-foreground";
|
||||
return current >= baseline ? "text-emerald-600" : "text-red-600";
|
||||
};
|
||||
|
||||
const hasVisibleResult = computed(() => {
|
||||
return (resultState.value?.skills?.length ?? 0) > 0 || (resultState.value?.info?.length ?? 0) > 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid h-full min-h-0 grid-rows-[auto_auto_1fr] gap-3">
|
||||
<div class="flex h-full min-h-0 flex-col gap-3">
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-base">结果面板</CardTitle>
|
||||
<CardDescription>展示当前配置的实时计算结果。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="flex gap-2">
|
||||
<Button size="sm" variant="outline" @click="setStandard">设为基准</Button>
|
||||
<Button size="sm" variant="outline" @click="clearStandard">清空基准</Button>
|
||||
<CardContent class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-8 px-2.5 text-xs sm:text-sm"
|
||||
@click="setStandard"
|
||||
>设为基准</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-8 px-2.5 text-xs sm:text-sm"
|
||||
@click="clearStandard"
|
||||
>清空基准</Button
|
||||
>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm">套装摘要</CardTitle>
|
||||
<CardTitle class="text-sm">穿戴信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2 text-xs">
|
||||
<Tabs :model-value="displayTab">
|
||||
@@ -83,27 +202,16 @@ const clearChangeSources = () => {
|
||||
</Tabs>
|
||||
|
||||
<div v-if="displayTab === 'equ'" class="rounded-md border p-2">
|
||||
<div v-if="resultState?.suits?.suit">
|
||||
<div class="font-medium">{{ resultState.suits.suit.name }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ resultState.suits.suit.rarity }} · {{ resultState.suits.suit.point }}点
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted-foreground">暂无装备套装信息</div>
|
||||
<EquList
|
||||
v-model:part="equippedPart"
|
||||
:detail="configStore.config.equips"
|
||||
:with-sub-weapon="(infoStore.infos?.alter?.includes('vegabond') ?? false)"
|
||||
:with-pet="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-md border p-2">
|
||||
<div v-if="resultState?.suits?.oath">
|
||||
<div class="font-medium">{{ resultState.suits.oath.name }}</div>
|
||||
<div class="text-muted-foreground">
|
||||
{{ resultState.suits.oath.rarity }} · {{ resultState.suits.oath.point }}点
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-muted-foreground">暂无誓约套装信息</div>
|
||||
</div>
|
||||
|
||||
<div v-if="resultState?.suits?.correction" class="text-muted-foreground">
|
||||
套装积分修正:{{ resultState.suits.correction }}
|
||||
<OathList v-model:part="equippedOathPart" :detail="configStore.config.oaths" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -134,73 +242,255 @@ const clearChangeSources = () => {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="min-h-0">
|
||||
<Card class="min-h-0 flex-1">
|
||||
<CardHeader class="pb-2">
|
||||
<CardTitle class="text-sm">技能伤害</CardTitle>
|
||||
<CardDescription class="flex items-center gap-2">
|
||||
<CardTitle class="text-sm">{{ isBufferClass ? "技能加成" : "技能伤害" }}</CardTitle>
|
||||
<CardDescription class="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
v-if="!isBufferClass"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-xs"
|
||||
:class="{ 'text-foreground': skillSort === 'damage' }"
|
||||
@click="skillSort = 'damage'"
|
||||
>
|
||||
按伤害
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!isBufferClass"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-xs"
|
||||
:class="{ 'text-foreground': skillSort === 'cd' }"
|
||||
@click="skillSort = 'cd'"
|
||||
>
|
||||
按 CD
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isBufferClass"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-xs"
|
||||
:class="{ 'text-foreground': skillSort === 'buff-main' }"
|
||||
@click="skillSort = 'buff-main'"
|
||||
>
|
||||
适用
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isBufferClass"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-xs"
|
||||
:class="{ 'text-foreground': skillSort === 'buff-atk' }"
|
||||
@click="skillSort = 'buff-atk'"
|
||||
>
|
||||
三攻
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isBufferClass"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-xs"
|
||||
:class="{ 'text-foreground': skillSort === 'buff-str' }"
|
||||
@click="skillSort = 'buff-str'"
|
||||
>
|
||||
力智
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isBufferClass"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
class="h-7 px-2 text-xs"
|
||||
:class="{ 'text-foreground': skillSort === 'buff-ratio' }"
|
||||
@click="skillSort = 'buff-ratio'"
|
||||
>
|
||||
增伤
|
||||
</Button>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="min-h-0">
|
||||
<CardContent class="min-h-0 flex h-full flex-col">
|
||||
<div v-if="isLoading" class="py-6 text-center text-sm text-muted-foreground">计算中...</div>
|
||||
<div v-else-if="resultError" class="py-6 text-center text-sm text-destructive">
|
||||
{{ resultError }}
|
||||
<div
|
||||
v-else-if="resultError"
|
||||
class="flex flex-col items-center gap-3 py-6 text-center text-sm text-destructive"
|
||||
>
|
||||
<span>{{ resultError }}</span>
|
||||
<Button size="sm" variant="outline" @click="retryCalc">重试计算</Button>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="!resultState || sortedSkills.length === 0"
|
||||
v-else-if="!hasVisibleResult || sortedSkills.length === 0"
|
||||
class="py-6 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无结果,请先编辑配置。
|
||||
</div>
|
||||
<div v-else class="max-h-[48vh] overflow-auto rounded-md border">
|
||||
<table class="w-full table-auto text-xs">
|
||||
<thead class="bg-muted/60 sticky top-0 z-10">
|
||||
<tr>
|
||||
<th class="px-2 py-2 text-left">技能</th>
|
||||
<th class="px-2 py-2 text-right">CD</th>
|
||||
<th class="px-2 py-2 text-right">伤害</th>
|
||||
<th class="px-2 py-2 text-right">占比</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="skill in sortedSkills"
|
||||
:key="`${skill.name}-${skill.mode}`"
|
||||
class="border-t hover:bg-muted/30"
|
||||
>
|
||||
<td class="px-2 py-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
:src="skill.icon"
|
||||
:alt="skill.name"
|
||||
class="size-5 rounded-sm object-cover"
|
||||
/>
|
||||
<span class="truncate">{{ skill.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right">{{ skill.cd.toFixed(1) }}</td>
|
||||
<td class="px-2 py-1.5 text-right font-medium">{{ formatNumber(skill.damage) }}</td>
|
||||
<td class="px-2 py-1.5 text-right text-muted-foreground">
|
||||
{{ totalDamage > 0 ? ((skill.damage / totalDamage) * 100).toFixed(1) : "0.0" }}%
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-else class="flex min-h-0 h-full flex-col">
|
||||
<div class="min-h-0 flex-1 overflow-auto rounded-md border">
|
||||
<table class="w-full table-fixed text-[11px] sm:text-xs">
|
||||
<colgroup v-if="!isBufferClass">
|
||||
<col class="w-[9.5rem] sm:w-[11rem]" />
|
||||
<col class="hidden sm:table-column sm:w-[4.25rem]" />
|
||||
<col class="w-[4.5rem]" />
|
||||
<col class="hidden md:table-column md:w-[5.5rem]" />
|
||||
<col class="w-[5.5rem] sm:w-[6rem]" />
|
||||
<col class="w-[4.5rem]" />
|
||||
</colgroup>
|
||||
<colgroup v-else>
|
||||
<col class="w-[9rem] sm:w-[10rem]" />
|
||||
<col class="hidden sm:table-column sm:w-[4.25rem]" />
|
||||
<col class="w-[4.75rem]" />
|
||||
<col class="w-[4.75rem]" />
|
||||
<col class="w-[4.75rem]" />
|
||||
<col class="w-[4.75rem]" />
|
||||
</colgroup>
|
||||
<thead class="bg-muted/60 sticky top-0 z-10">
|
||||
<tr v-if="!isBufferClass">
|
||||
<th class="px-1.5 py-1.5 text-left">技能</th>
|
||||
<th class="hidden px-1.5 py-1.5 text-right sm:table-cell">CD</th>
|
||||
<th class="px-1.5 py-1.5 text-right">次数</th>
|
||||
<th class="hidden px-1.5 py-1.5 text-right md:table-cell">秒伤</th>
|
||||
<th class="px-1.5 py-1.5 text-right">总伤害</th>
|
||||
<th class="px-1.5 py-1.5 text-right">占比</th>
|
||||
</tr>
|
||||
<tr v-else>
|
||||
<th class="px-1.5 py-1.5 text-left">技能</th>
|
||||
<th class="hidden px-1.5 py-1.5 text-right sm:table-cell">CD</th>
|
||||
<th class="px-1.5 py-1.5 text-right">适用</th>
|
||||
<th class="px-1.5 py-1.5 text-right">三攻</th>
|
||||
<th class="px-1.5 py-1.5 text-right">力智</th>
|
||||
<th class="px-1.5 py-1.5 text-right">增伤</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="skill in sortedSkills"
|
||||
:key="`${skill.name}-${skill.mode}`"
|
||||
class="border-t hover:bg-muted/30"
|
||||
>
|
||||
<td class="px-1.5 py-1.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
:src="skill.icon"
|
||||
:alt="skill.name"
|
||||
class="size-4 rounded-sm object-cover sm:size-5"
|
||||
/>
|
||||
<span class="max-w-[6.5rem] truncate sm:max-w-[8.5rem]"
|
||||
>{{ skill.name }}{{ skill.mode ? ` [${skill.mode}]` : "" }}</span
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td class="hidden px-1.5 py-1.5 text-right sm:table-cell">
|
||||
{{ skill.cd.toFixed(1) }}
|
||||
</td>
|
||||
<template v-if="!isBufferClass">
|
||||
<td class="px-1.5 py-1.5 text-right">
|
||||
<Input
|
||||
class="ml-auto h-7 w-12 px-1 text-right sm:w-14"
|
||||
type="number"
|
||||
:model-value="getSkillCount(skill)"
|
||||
@update:model-value="(value) => setSkillCount(skill, value)"
|
||||
/>
|
||||
</td>
|
||||
<td class="hidden px-1.5 py-1.5 text-right text-muted-foreground md:table-cell">
|
||||
{{
|
||||
formatNumber(
|
||||
skill.cd > 0 ? (skill.damage * getSkillCount(skill)) / skill.cd : 0,
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td
|
||||
class="px-1.5 py-1.5 text-right font-medium"
|
||||
:class="
|
||||
diffClass(
|
||||
skill.damage * getSkillCount(skill),
|
||||
(getStandardSkill(skill)?.damage ?? 0) * getStandardSkillCount(skill),
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatDeltaPercent(
|
||||
skill.damage * getSkillCount(skill),
|
||||
(getStandardSkill(skill)?.damage ?? 0) * getStandardSkillCount(skill),
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td class="px-1.5 py-1.5 text-right text-muted-foreground">
|
||||
{{
|
||||
totalWeightedDamage > 0
|
||||
? formatPercent(
|
||||
(skill.damage * getSkillCount(skill) * 100) / totalWeightedDamage,
|
||||
)
|
||||
: "0.00%"
|
||||
}}
|
||||
</td>
|
||||
</template>
|
||||
<template v-else>
|
||||
<td class="hidden px-1.5 py-1.5 text-right sm:table-cell">
|
||||
{{ skill.cd.toFixed(1) }}
|
||||
</td>
|
||||
<td
|
||||
class="px-1.5 py-1.5 text-right"
|
||||
:class="
|
||||
diffClass(skill.buff?.[0] ?? 0, getStandardSkill(skill)?.buff?.[0] ?? 0)
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatDeltaPercent(
|
||||
skill.buff?.[0] ?? 0,
|
||||
getStandardSkill(skill)?.buff?.[0] ?? 0,
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td
|
||||
class="px-1.5 py-1.5 text-right"
|
||||
:class="
|
||||
diffClass(skill.buff?.[1] ?? 0, getStandardSkill(skill)?.buff?.[1] ?? 0)
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatDeltaPercent(
|
||||
skill.buff?.[1] ?? 0,
|
||||
getStandardSkill(skill)?.buff?.[1] ?? 0,
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td
|
||||
class="px-1.5 py-1.5 text-right"
|
||||
:class="
|
||||
diffClass(skill.buff?.[2] ?? 0, getStandardSkill(skill)?.buff?.[2] ?? 0)
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatDeltaPercent(
|
||||
skill.buff?.[2] ?? 0,
|
||||
getStandardSkill(skill)?.buff?.[2] ?? 0,
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
<td
|
||||
class="px-1.5 py-1.5 text-right"
|
||||
:class="
|
||||
diffClass(skill.buff?.[3] ?? 0, getStandardSkill(skill)?.buff?.[3] ?? 0)
|
||||
"
|
||||
>
|
||||
{{
|
||||
formatDeltaPercent(
|
||||
skill.buff?.[3] ?? 0,
|
||||
getStandardSkill(skill)?.buff?.[3] ?? 0,
|
||||
)
|
||||
}}
|
||||
</td>
|
||||
</template>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="!isBufferClass"
|
||||
class="mt-2 text-right text-sm font-semibold"
|
||||
:class="totalDiffClass"
|
||||
>
|
||||
合计:{{ totalDiffLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
import { computed, ref } from "vue";
|
||||
import { useConfigStore, useInfoStore } from "@/stores";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Tabs from "@/components/ui/tabs/Tabs.vue";
|
||||
import TabsList from "@/components/ui/tabs/TabsList.vue";
|
||||
import TabsTrigger from "@/components/ui/tabs/TabsTrigger.vue";
|
||||
import { getImageURL } from "@/utils";
|
||||
import type { ISkill } from "@/api/info/type";
|
||||
import ResultPanel from "@/pages/Dcalc/Character/Result/Index.vue";
|
||||
|
||||
const infoStore = useInfoStore();
|
||||
const configStore = useConfigStore();
|
||||
@@ -14,6 +18,10 @@ const activeSkillId = ref("");
|
||||
const notice = ref("");
|
||||
const noticeType = ref<"error" | "info">("info");
|
||||
const openUpPickerSlot = ref<number | null>(null);
|
||||
const rightPanelTab = ref<"detail" | "result">("result");
|
||||
const leftPanelWidthClass = computed(() =>
|
||||
rightPanelTab.value === "result" ? "xl:w-[460px]" : "xl:w-[550px]",
|
||||
);
|
||||
|
||||
/* ─── Constants ─── */
|
||||
const VP_LIMIT = 5;
|
||||
@@ -34,14 +42,11 @@ interface ITreeRow {
|
||||
}
|
||||
|
||||
const getSkillsInRange = (lvStart: number, lvEnd: number): ISkill[] =>
|
||||
infoStore.skills.filter(
|
||||
(s) => s.line > lvStart - 5 && s.line <= Math.max(lvEnd - 5, 1),
|
||||
);
|
||||
infoStore.skills.filter((s) => s.line > lvStart - 5 && s.line <= Math.max(lvEnd - 5, 1));
|
||||
|
||||
const getSkillAt = (lvStart: number, lvEnd: number, col: number): ISkill | null =>
|
||||
infoStore.skills.find(
|
||||
(s) =>
|
||||
s.line > lvStart - 5 && s.line <= Math.max(lvEnd - 5, 1) && s.position === col,
|
||||
(s) => s.line > lvStart - 5 && s.line <= Math.max(lvEnd - 5, 1) && s.position === col,
|
||||
) ?? null;
|
||||
|
||||
const treeRows = computed((): ITreeRow[] => {
|
||||
@@ -93,8 +98,12 @@ const lvColor = (id: string, maxLearnLv: number): string => {
|
||||
const selectedSkill = computed(() =>
|
||||
activeSkillId.value ? skillById(activeSkillId.value) : undefined,
|
||||
);
|
||||
const chooseSkill = (id: string) => { activeSkillId.value = id; };
|
||||
const clearActive = () => { activeSkillId.value = ""; };
|
||||
const chooseSkill = (id: string) => {
|
||||
activeSkillId.value = id;
|
||||
};
|
||||
const clearActive = () => {
|
||||
activeSkillId.value = "";
|
||||
};
|
||||
|
||||
/* ─── Level actions ─── */
|
||||
const updateLv = (id: string, value: number) => {
|
||||
@@ -115,11 +124,11 @@ const setMaxLv = (id: string) => updateLv(id, skillById(id)?.maxLearnLv ?? 0);
|
||||
const resetLv = (id: string) => updateLv(id, 0);
|
||||
|
||||
/* ─── VP ─── */
|
||||
const usedVPCount = computed(() =>
|
||||
infoStore.skills
|
||||
.filter((s) => s.hasVP)
|
||||
.filter((s) => (configStore.config.skills[s.id.toString()]?.vp ?? 0) > 0)
|
||||
.length,
|
||||
const usedVPCount = computed(
|
||||
() =>
|
||||
infoStore.skills
|
||||
.filter((s) => s.hasVP)
|
||||
.filter((s) => (configStore.config.skills[s.id.toString()]?.vp ?? 0) > 0).length,
|
||||
);
|
||||
|
||||
const vpSkills = computed(() =>
|
||||
@@ -129,7 +138,10 @@ const vpSkills = computed(() =>
|
||||
const setVPMode = (id: string, mode: 0 | 1 | 2) => {
|
||||
const skill = skillById(id);
|
||||
if (!skill) return;
|
||||
if (!skill.hasVP && mode !== 0) { setNotice(`${skill.name} 不支持 VP。`, "error"); return; }
|
||||
if (!skill.hasVP && mode !== 0) {
|
||||
setNotice(`${skill.name} 不支持 VP。`, "error");
|
||||
return;
|
||||
}
|
||||
const target = skillCfg(id);
|
||||
if (target.vp === 0 && mode > 0 && usedVPCount.value >= VP_LIMIT) {
|
||||
setNotice(`VP 最多选 ${VP_LIMIT} 个技能。`, "error");
|
||||
@@ -187,11 +199,18 @@ const setUPSlotSkill = (slotIdx: number, nextId: string) => {
|
||||
}
|
||||
const target = skillById(nextId);
|
||||
if (!target) return;
|
||||
if ((skillCfg(nextId).lv ?? 0) <= 0) { setNotice(`${target.name} 等级为 0,无法 UP。`, "error"); return; }
|
||||
if ((skillCfg(nextId).lv ?? 0) <= 0) {
|
||||
setNotice(`${target.name} 等级为 0,无法 UP。`, "error");
|
||||
return;
|
||||
}
|
||||
if (upSlots.value.some((s, i) => i !== slotIdx && s.skillId === nextId)) {
|
||||
setNotice(`${target.name} 已在其他槽位。`, "error"); return;
|
||||
setNotice(`${target.name} 已在其他槽位。`, "error");
|
||||
return;
|
||||
}
|
||||
if (!prevId && usedUPCount.value >= UP_LIMIT) {
|
||||
setNotice(`UP 最多选 ${UP_LIMIT} 个技能。`, "error");
|
||||
return;
|
||||
}
|
||||
if (!prevId && usedUPCount.value >= UP_LIMIT) { setNotice(`UP 最多选 ${UP_LIMIT} 个技能。`, "error"); return; }
|
||||
if (prevId && prevId !== nextId) skillCfg(prevId).up = 0;
|
||||
skillCfg(nextId).up = slot.mode;
|
||||
track(`${target.name} 设置 UP 槽位 ${slotIdx + 1} 形态 ${slot.mode}`);
|
||||
@@ -200,7 +219,10 @@ const setUPSlotSkill = (slotIdx: number, nextId: string) => {
|
||||
|
||||
const setUPSlotMode = (slotIdx: number, mode: 1 | 2) => {
|
||||
const skillId = upSlots.value[slotIdx]?.skillId;
|
||||
if (!skillId) { setNotice(`请先为槽位 ${slotIdx + 1} 选择技能。`, "error"); return; }
|
||||
if (!skillId) {
|
||||
setNotice(`请先为槽位 ${slotIdx + 1} 选择技能。`, "error");
|
||||
return;
|
||||
}
|
||||
skillCfg(skillId).up = mode;
|
||||
track(`${skillById(skillId)?.name} UP 形态 → ${mode}`);
|
||||
};
|
||||
@@ -220,9 +242,15 @@ const resetAllSkills = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full overflow-hidden" @click.self="clearActive">
|
||||
<div class="flex h-full flex-col overflow-hidden xl:flex-row" @click.self="clearActive">
|
||||
<!-- ══ LEFT PANEL ══ -->
|
||||
<div class="w-[550px] shrink-0 flex flex-col border-r border-border/50 overflow-hidden">
|
||||
<div
|
||||
:class="[
|
||||
'max-h-[52vh] w-full shrink-0 border-b border-border/50 xl:max-h-none xl:border-b-0 xl:border-r',
|
||||
leftPanelWidthClass,
|
||||
]"
|
||||
>
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<!-- Tab bar -->
|
||||
<div
|
||||
class="h-10 shrink-0 flex border-b border-border/50 bg-muted/20 text-[15px] font-bold select-none"
|
||||
@@ -279,8 +307,12 @@ const resetAllSkills = () => {
|
||||
@click.stop="chooseSkill(skill.id.toString())"
|
||||
>
|
||||
<div class="badge-row">
|
||||
<span v-if="skillCfg(skill.id.toString()).vp > 0" class="badge badge-vp">VP</span>
|
||||
<span v-if="skillCfg(skill.id.toString()).up > 0" class="badge badge-up">UP</span>
|
||||
<span v-if="skillCfg(skill.id.toString()).vp > 0" class="badge badge-vp"
|
||||
>VP</span
|
||||
>
|
||||
<span v-if="skillCfg(skill.id.toString()).up > 0" class="badge badge-up"
|
||||
>UP</span
|
||||
>
|
||||
</div>
|
||||
<img
|
||||
:src="getImageURL(skill.icon)"
|
||||
@@ -295,8 +327,12 @@ const resetAllSkills = () => {
|
||||
/>
|
||||
<div v-if="activeSkillId === skill.id.toString()" class="action-row" @click.stop>
|
||||
<button class="skill-btn" @click.stop="resetLv(skill.id.toString())">«</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(skill.id.toString(), -1)">‹</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(skill.id.toString(), 1)">›</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(skill.id.toString(), -1)">
|
||||
‹
|
||||
</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(skill.id.toString(), 1)">
|
||||
›
|
||||
</button>
|
||||
<button class="skill-btn" @click.stop="setMaxLv(skill.id.toString())">»</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -321,8 +357,12 @@ const resetAllSkills = () => {
|
||||
>
|
||||
<template v-if="cell">
|
||||
<div class="badge-row">
|
||||
<span v-if="skillCfg(cell.id.toString()).vp > 0" class="badge badge-vp">VP</span>
|
||||
<span v-if="skillCfg(cell.id.toString()).up > 0" class="badge badge-up">UP</span>
|
||||
<span v-if="skillCfg(cell.id.toString()).vp > 0" class="badge badge-vp"
|
||||
>VP</span
|
||||
>
|
||||
<span v-if="skillCfg(cell.id.toString()).up > 0" class="badge badge-up"
|
||||
>UP</span
|
||||
>
|
||||
</div>
|
||||
<img
|
||||
:src="getImageURL(cell.icon)"
|
||||
@@ -337,9 +377,15 @@ const resetAllSkills = () => {
|
||||
/>
|
||||
<div v-if="activeSkillId === cell.id.toString()" class="action-row" @click.stop>
|
||||
<button class="skill-btn" @click.stop="resetLv(cell.id.toString())">«</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(cell.id.toString(), -1)">‹</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(cell.id.toString(), 1)">›</button>
|
||||
<button class="skill-btn" @click.stop="setMaxLv(cell.id.toString())">»</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(cell.id.toString(), -1)">
|
||||
‹
|
||||
</button>
|
||||
<button class="skill-btn" @click.stop="stepLv(cell.id.toString(), 1)">
|
||||
›
|
||||
</button>
|
||||
<button class="skill-btn" @click.stop="setMaxLv(cell.id.toString())">
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
@@ -360,7 +406,9 @@ const resetAllSkills = () => {
|
||||
<div v-show="tab === 1" class="flex-1 overflow-y-auto flex flex-col min-h-0">
|
||||
<!-- UP section -->
|
||||
<div class="shrink-0 border-b border-border/50">
|
||||
<div class="px-3 pt-3 pb-1 text-xs font-semibold text-muted-foreground flex justify-between">
|
||||
<div
|
||||
class="px-3 pt-3 pb-1 text-xs font-semibold text-muted-foreground flex justify-between"
|
||||
>
|
||||
<span>技能突破 (UP)</span>
|
||||
<span>{{ usedUPCount }}/{{ UP_LIMIT }}</span>
|
||||
</div>
|
||||
@@ -370,7 +418,9 @@ const resetAllSkills = () => {
|
||||
:key="idx"
|
||||
class="flex items-center gap-2 rounded-md border border-border/50 bg-muted/10 p-2"
|
||||
>
|
||||
<div class="text-[10px] text-muted-foreground w-6 shrink-0 text-center">{{ idx + 1 }}</div>
|
||||
<div class="text-[10px] text-muted-foreground w-6 shrink-0 text-center">
|
||||
{{ idx + 1 }}
|
||||
</div>
|
||||
|
||||
<!-- Mode 1 icon -->
|
||||
<button
|
||||
@@ -387,8 +437,15 @@ const resetAllSkills = () => {
|
||||
"
|
||||
>
|
||||
<template v-if="slot.skillId">
|
||||
<img :src="getImageURL(skillById(slot.skillId)?.icon ?? '')" class="w-7 h-7 rounded object-cover" />
|
||||
<span v-if="slot.mode === 1" class="absolute -bottom-1 -right-1 text-[8px] bg-purple-500 text-white rounded-sm px-0.5 leading-3">1</span>
|
||||
<img
|
||||
:src="getImageURL(skillById(slot.skillId)?.icon ?? '')"
|
||||
class="w-7 h-7 rounded object-cover"
|
||||
/>
|
||||
<span
|
||||
v-if="slot.mode === 1"
|
||||
class="absolute -bottom-1 -right-1 text-[8px] bg-purple-500 text-white rounded-sm px-0.5 leading-3"
|
||||
>1</span
|
||||
>
|
||||
</template>
|
||||
<span v-else class="text-[10px] text-muted-foreground">1</span>
|
||||
</button>
|
||||
@@ -399,7 +456,9 @@ const resetAllSkills = () => {
|
||||
:disabled="!slot.skillId"
|
||||
title="切换形态"
|
||||
@click="exchangeUPMode(idx)"
|
||||
>⇄</button>
|
||||
>
|
||||
⇄
|
||||
</button>
|
||||
|
||||
<!-- Mode 2 icon -->
|
||||
<button
|
||||
@@ -416,15 +475,25 @@ const resetAllSkills = () => {
|
||||
"
|
||||
>
|
||||
<template v-if="slot.skillId">
|
||||
<img :src="getImageURL(skillById(slot.skillId)?.icon ?? '')" class="w-7 h-7 rounded object-cover" />
|
||||
<span v-if="slot.mode === 2" class="absolute -bottom-1 -right-1 text-[8px] bg-purple-500 text-white rounded-sm px-0.5 leading-3">2</span>
|
||||
<img
|
||||
:src="getImageURL(skillById(slot.skillId)?.icon ?? '')"
|
||||
class="w-7 h-7 rounded object-cover"
|
||||
/>
|
||||
<span
|
||||
v-if="slot.mode === 2"
|
||||
class="absolute -bottom-1 -right-1 text-[8px] bg-purple-500 text-white rounded-sm px-0.5 leading-3"
|
||||
>2</span
|
||||
>
|
||||
</template>
|
||||
<span v-else class="text-[10px] text-muted-foreground">2</span>
|
||||
</button>
|
||||
|
||||
<!-- Skill name -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs truncate" :title="slot.skillId ? (skillById(slot.skillId)?.name ?? '') : ''">
|
||||
<div
|
||||
class="text-xs truncate"
|
||||
:title="slot.skillId ? (skillById(slot.skillId)?.name ?? '') : ''"
|
||||
>
|
||||
{{ slot.skillId ? skillById(slot.skillId)?.name : "未选择技能" }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -433,16 +502,23 @@ const resetAllSkills = () => {
|
||||
<button
|
||||
class="text-[10px] border border-border/50 rounded px-1.5 py-0.5 text-muted-foreground hover:text-foreground shrink-0"
|
||||
@click="openUpPickerSlot = openUpPickerSlot === idx ? null : idx"
|
||||
>{{ slot.skillId ? "更换" : "选择" }}</button>
|
||||
>
|
||||
{{ slot.skillId ? "更换" : "选择" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Icon picker -->
|
||||
<div v-if="openUpPickerSlot !== null" class="rounded-md border border-border/50 bg-muted/20 p-2">
|
||||
<div
|
||||
v-if="openUpPickerSlot !== null"
|
||||
class="rounded-md border border-border/50 bg-muted/20 p-2"
|
||||
>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
class="w-7 h-7 rounded border border-dashed border-border/60 text-[9px] text-muted-foreground hover:bg-muted/50"
|
||||
@click="setUPSlotSkill(openUpPickerSlot!, '')"
|
||||
>清</button>
|
||||
>
|
||||
清
|
||||
</button>
|
||||
<button
|
||||
v-for="item in upOptionsForSlot(openUpPickerSlot!)"
|
||||
:key="item.id"
|
||||
@@ -455,7 +531,11 @@ const resetAllSkills = () => {
|
||||
:title="item.name"
|
||||
@click="setUPSlotSkill(openUpPickerSlot!, item.id.toString())"
|
||||
>
|
||||
<img :src="getImageURL(item.icon)" :alt="item.name" class="w-full h-full rounded object-cover" />
|
||||
<img
|
||||
:src="getImageURL(item.icon)"
|
||||
:alt="item.name"
|
||||
class="w-full h-full rounded object-cover"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -464,19 +544,32 @@ const resetAllSkills = () => {
|
||||
|
||||
<!-- VP section -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0">
|
||||
<div class="px-3 pt-3 pb-1 text-xs font-semibold text-muted-foreground flex justify-between sticky top-0 bg-background/80 backdrop-blur-sm z-10">
|
||||
<div
|
||||
class="px-3 pt-3 pb-1 text-xs font-semibold text-muted-foreground flex justify-between sticky top-0 bg-background/80 backdrop-blur-sm z-10"
|
||||
>
|
||||
<span>技能进化 (VP)</span>
|
||||
<span>{{ usedVPCount }}/{{ VP_LIMIT }}</span>
|
||||
</div>
|
||||
<div class="divide-y divide-border/30">
|
||||
<div v-for="skill in vpSkills" :key="skill.id" class="px-3 py-2.5 flex items-start gap-2">
|
||||
<div
|
||||
v-for="skill in vpSkills"
|
||||
:key="skill.id"
|
||||
class="px-3 py-2.5 flex items-start gap-2"
|
||||
>
|
||||
<!-- Lv + icon -->
|
||||
<div class="shrink-0 flex flex-col items-center gap-1 pt-0.5">
|
||||
<span class="text-[10px] text-muted-foreground">{{ skill.learnLv }}</span>
|
||||
<img :src="getImageURL(skill.icon)" :alt="skill.name" class="w-7 h-7 rounded object-cover" />
|
||||
<img
|
||||
:src="getImageURL(skill.icon)"
|
||||
:alt="skill.name"
|
||||
class="w-7 h-7 rounded object-cover"
|
||||
/>
|
||||
</div>
|
||||
<!-- Name -->
|
||||
<div class="shrink-0 w-[4.5rem] text-[11px] text-muted-foreground truncate pt-1.5" :title="skill.name">
|
||||
<div
|
||||
class="shrink-0 w-[4.5rem] text-[11px] text-muted-foreground truncate pt-1.5"
|
||||
:title="skill.name"
|
||||
>
|
||||
{{ skill.name }}
|
||||
</div>
|
||||
<!-- Two VP choice cards -->
|
||||
@@ -490,12 +583,23 @@ const resetAllSkills = () => {
|
||||
? 'border-purple-400 bg-purple-500/10'
|
||||
: 'border-border/50'
|
||||
"
|
||||
@click="setVPMode(skill.id.toString(), skillCfg(skill.id.toString()).vp === mode ? 0 : mode)"
|
||||
@click="
|
||||
setVPMode(
|
||||
skill.id.toString(),
|
||||
skillCfg(skill.id.toString()).vp === mode ? 0 : mode,
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="text-[10px] font-semibold"
|
||||
:class="skillCfg(skill.id.toString()).vp === mode ? 'text-purple-400' : 'text-muted-foreground'"
|
||||
>形态 {{ mode }}</div>
|
||||
:class="
|
||||
skillCfg(skill.id.toString()).vp === mode
|
||||
? 'text-purple-400'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
>
|
||||
形态 {{ mode }}
|
||||
</div>
|
||||
<div class="mt-0.5 text-[10px] text-muted-foreground leading-tight line-clamp-2">
|
||||
{{ skill.vps[mode - 1]?.name || "—" }}
|
||||
</div>
|
||||
@@ -505,104 +609,158 @@ const resetAllSkills = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ RIGHT PANEL: Skill Detail ══ -->
|
||||
<div class="flex-1 flex flex-col overflow-y-auto" @click.self="clearActive">
|
||||
<!-- Notice bar -->
|
||||
<div
|
||||
v-if="notice"
|
||||
class="px-4 py-2 text-xs border-b shrink-0"
|
||||
:class="
|
||||
noticeType === 'error'
|
||||
? 'bg-destructive/10 text-destructive border-destructive/30'
|
||||
: 'bg-muted/30 text-muted-foreground border-border/40'
|
||||
"
|
||||
>{{ notice }}</div>
|
||||
<div class="flex-1 min-h-0 flex flex-col overflow-hidden" @click.self="clearActive">
|
||||
<div class="border-b border-border/50 px-3 py-2">
|
||||
<Tabs :model-value="rightPanelTab" class="gap-0">
|
||||
<TabsList>
|
||||
<TabsTrigger value="result" @click="rightPanelTab = 'result'">结果面板</TabsTrigger>
|
||||
<TabsTrigger value="detail" @click="rightPanelTab = 'detail'">技能详情</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<!-- Skill detail -->
|
||||
<div v-if="selectedSkill" class="p-5 space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
:src="getImageURL(selectedSkill.icon)"
|
||||
:alt="selectedSkill.name"
|
||||
class="w-14 h-14 rounded-lg object-cover border border-border/60"
|
||||
/>
|
||||
<div v-if="rightPanelTab === 'result'" class="min-h-0 flex-1 overflow-hidden p-3">
|
||||
<ResultPanel />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Notice bar -->
|
||||
<div
|
||||
v-if="notice"
|
||||
class="px-4 py-2 text-xs border-b shrink-0"
|
||||
:class="
|
||||
noticeType === 'error'
|
||||
? 'bg-destructive/10 text-destructive border-destructive/30'
|
||||
: 'bg-muted/30 text-muted-foreground border-border/40'
|
||||
"
|
||||
>
|
||||
{{ notice }}
|
||||
</div>
|
||||
|
||||
<!-- Skill detail -->
|
||||
<div v-if="selectedSkill" class="flex-1 overflow-y-auto p-5 space-y-5">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
:src="getImageURL(selectedSkill.icon)"
|
||||
:alt="selectedSkill.name"
|
||||
class="w-14 h-14 rounded-lg object-cover border border-border/60"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-base font-semibold">{{ selectedSkill.name }}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
{{ selectedSkill.type === "passive" ? "被动" : "主动" }} · Lv
|
||||
{{ selectedSkill.learnLv }} 解锁
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Level control -->
|
||||
<div>
|
||||
<div class="text-base font-semibold">{{ selectedSkill.name }}</div>
|
||||
<div class="text-xs text-muted-foreground mt-1">
|
||||
{{ selectedSkill.type === "passive" ? "被动" : "主动" }} ·
|
||||
Lv {{ selectedSkill.learnLv }} 解锁
|
||||
<div class="flex items-baseline gap-2 mb-2.5">
|
||||
<span
|
||||
class="text-4xl font-bold tabular-nums"
|
||||
:style="{ color: lvColor(selectedSkill.id.toString(), selectedSkill.maxLearnLv) }"
|
||||
>{{ skillCfg(selectedSkill.id.toString()).lv }}</span
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">/ 满 {{ selectedSkill.maxLearnLv }}</span>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 px-2.5 text-xs"
|
||||
@click="resetLv(selectedSkill.id.toString())"
|
||||
>归零</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 px-2.5 text-xs"
|
||||
@click="stepLv(selectedSkill.id.toString(), -1)"
|
||||
>−1</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 px-2.5 text-xs"
|
||||
@click="stepLv(selectedSkill.id.toString(), 1)"
|
||||
>+1</Button
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="h-7 px-2.5 text-xs"
|
||||
@click="setMaxLv(selectedSkill.id.toString())"
|
||||
>满级</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- VP -->
|
||||
<div v-if="selectedSkill.hasVP">
|
||||
<div class="text-sm font-medium mb-2">VP 进化形态</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="mode in [1, 2] as const"
|
||||
:key="mode"
|
||||
class="flex-1 rounded-md border p-2.5 text-left text-xs transition-colors hover:bg-muted/30"
|
||||
:class="
|
||||
skillCfg(selectedSkill.id.toString()).vp === mode
|
||||
? 'border-purple-400 bg-purple-500/10'
|
||||
: 'border-border/50'
|
||||
"
|
||||
@click="
|
||||
setVPMode(
|
||||
selectedSkill.id.toString(),
|
||||
skillCfg(selectedSkill.id.toString()).vp === mode ? 0 : mode,
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="font-semibold"
|
||||
:class="
|
||||
skillCfg(selectedSkill.id.toString()).vp === mode
|
||||
? 'text-purple-400'
|
||||
: 'text-muted-foreground'
|
||||
"
|
||||
>
|
||||
形态 {{ mode }}
|
||||
</div>
|
||||
<div class="mt-1.5 text-muted-foreground leading-snug">
|
||||
{{ selectedSkill.vps[mode - 1]?.name || "—" }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- UP -->
|
||||
<div v-if="selectedSkill.hasUP">
|
||||
<div class="text-sm font-medium mb-1">UP 突破状态</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{
|
||||
skillCfg(selectedSkill.id.toString()).up === 0
|
||||
? "未配置"
|
||||
: `已配置 形态 ${skillCfg(selectedSkill.id.toString()).up}`
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Level control -->
|
||||
<div>
|
||||
<div class="flex items-baseline gap-2 mb-2.5">
|
||||
<span
|
||||
class="text-4xl font-bold tabular-nums"
|
||||
:style="{ color: lvColor(selectedSkill.id.toString(), selectedSkill.maxLearnLv) }"
|
||||
>{{ skillCfg(selectedSkill.id.toString()).lv }}</span>
|
||||
<span class="text-xs text-muted-foreground">/ 满 {{ selectedSkill.maxLearnLv }}</span>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" @click="resetLv(selectedSkill.id.toString())">归零</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" @click="stepLv(selectedSkill.id.toString(), -1)">−1</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" @click="stepLv(selectedSkill.id.toString(), 1)">+1</Button>
|
||||
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" @click="setMaxLv(selectedSkill.id.toString())">满级</Button>
|
||||
<!-- Empty state -->
|
||||
<div v-else class="flex-1 flex flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<div class="text-sm text-muted-foreground">点击左侧技能查看详情</div>
|
||||
<div class="text-xs text-muted-foreground space-y-1">
|
||||
<div>VP 已用: {{ usedVPCount }} / {{ VP_LIMIT }}</div>
|
||||
<div>UP 已用: {{ usedUPCount }} / {{ UP_LIMIT }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" @click="resetAllSkills">重置全部技能</Button>
|
||||
</div>
|
||||
|
||||
<!-- VP -->
|
||||
<div v-if="selectedSkill.hasVP">
|
||||
<div class="text-sm font-medium mb-2">VP 进化形态</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-for="mode in [1, 2] as const"
|
||||
:key="mode"
|
||||
class="flex-1 rounded-md border p-2.5 text-left text-xs transition-colors hover:bg-muted/30"
|
||||
:class="
|
||||
skillCfg(selectedSkill.id.toString()).vp === mode
|
||||
? 'border-purple-400 bg-purple-500/10'
|
||||
: 'border-border/50'
|
||||
"
|
||||
@click="setVPMode(selectedSkill.id.toString(), skillCfg(selectedSkill.id.toString()).vp === mode ? 0 : mode)"
|
||||
>
|
||||
<div
|
||||
class="font-semibold"
|
||||
:class="skillCfg(selectedSkill.id.toString()).vp === mode ? 'text-purple-400' : 'text-muted-foreground'"
|
||||
>形态 {{ mode }}</div>
|
||||
<div class="mt-1.5 text-muted-foreground leading-snug">
|
||||
{{ selectedSkill.vps[mode - 1]?.name || "—" }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- UP -->
|
||||
<div v-if="selectedSkill.hasUP">
|
||||
<div class="text-sm font-medium mb-1">UP 突破状态</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{
|
||||
skillCfg(selectedSkill.id.toString()).up === 0
|
||||
? "未配置"
|
||||
: `已配置 形态 ${skillCfg(selectedSkill.id.toString()).up}`
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="flex-1 flex flex-col items-center justify-center gap-4 p-6 text-center">
|
||||
<div class="text-sm text-muted-foreground">点击左侧技能查看详情</div>
|
||||
<div class="text-xs text-muted-foreground space-y-1">
|
||||
<div>VP 已用: {{ usedVPCount }} / {{ VP_LIMIT }}</div>
|
||||
<div>UP 已用: {{ usedUPCount }} / {{ UP_LIMIT }}</div>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" @click="resetAllSkills">重置全部技能</Button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -635,8 +793,12 @@ const resetAllSkills = () => {
|
||||
line-height: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge-vp { color: #c084fc; } /* purple-400 */
|
||||
.badge-up { color: #fb923c; } /* amber-400 */
|
||||
.badge-vp {
|
||||
color: #c084fc;
|
||||
} /* purple-400 */
|
||||
.badge-up {
|
||||
color: #fb923c;
|
||||
} /* amber-400 */
|
||||
|
||||
/* 28×28px icon at y=14px */
|
||||
.skill-icon {
|
||||
|
||||
@@ -67,15 +67,12 @@ watch(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle class="flex items-center justify-between">
|
||||
<span>角色配置 · {{ alter }}</span>
|
||||
<span>角色配置</span>
|
||||
<Button variant="outline" size="sm" :disabled="loading" @click="loadCharacter">
|
||||
<RefreshCcw :class="{ 'animate-spin': loading }" />
|
||||
刷新
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<CardDescription
|
||||
>此页面承接旧版 character 模块,逐步迁移装备/技能/打造功能。</CardDescription
|
||||
>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="inline-flex gap-2">
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
## Plan: frontend 到 frontendNew 后续迁移
|
||||
|
||||
基于现有代码状态,采用“核心功能优先、视觉后置、双端并行”策略:将 equipment 作为已完成基线,优先完成 Skill 与 Forge 的可计算配置能力,再接入 Result 面板,最后统一交互与视觉细节。这样能最早恢复端到端计算链路,并降低一次性重构风险。
|
||||
|
||||
**Steps**
|
||||
1. Phase 0 现状校准与冻结(依赖:无)
|
||||
1) 将 equipment 迁移状态定为“功能完成,仅优化”。
|
||||
2) 明确本轮范围:Skill、Forge、Result;非本轮范围:视觉深度重构、大规模设计改版。
|
||||
3) 设定双端并行策略:frontend 继续可用,frontendNew 增量可用。
|
||||
2. Phase 1 共享依赖补齐(依赖:1)
|
||||
1) 统一 Character 容器内的数据刷新与计算触发策略,避免 Skill/Forge/Result 各自重复实现。
|
||||
2) 在 frontendNew 校准 configStore 的结果执行入口与错误态表达,作为 Result 接入前置。
|
||||
3) 明确 API /calc 返回结构在前端类型中的映射与兜底字段。
|
||||
3. Phase 2 Skill 迁移(依赖:2,可与 Phase 3 局部并行)
|
||||
1) 先达成 Skill MVP:技能列表、搜索、基础 VP/UP 配置写回 store。
|
||||
2) 将旧版 Skill 组件逻辑按“面板组件 + 可复用 composable”拆分,避免单文件过大。
|
||||
3) 完成与 Character 路由子页的交互一致性(切页不丢状态、刷新可恢复)。
|
||||
4) 第二轮再补齐细节交互(限制规则、提示、边界校验)。
|
||||
4. Phase 3 Forge 迁移(依赖:2,可与 Phase 2 并行推进不同子项)
|
||||
1) 先达成 Forge MVP:装备打造、时装基础配置、誓约基础配置。
|
||||
2) 子模块优先级:Equipments > Avatars > Oaths > Emblems/Jades/Sundry。
|
||||
3) 每个子模块完成即做 store 回写验证,确保配置可直接进入计算。
|
||||
4) 最后统一容器结构与分组折叠/分栏交互。
|
||||
5. Phase 4 Result 集成(依赖:3 和 4,阻塞)
|
||||
1) 新增 Result 模块并接入 Character 页面右侧并行展示区。
|
||||
2) 先上只读 MVP:基础属性、套装/誓约信息、技能伤害排序展示。
|
||||
3) 第二轮补可编辑项(技能次数)与标准对比能力。
|
||||
4) 处理 Buffer 与非 Buffer 两种展示分支。
|
||||
6. Phase 5 equipment 优化与一致性收口(依赖:5,可与测试并行)
|
||||
1) 按 Skill/Forge/Result 的新交互规范回调优化 equipment 文案、间距、加载/空态。
|
||||
2) 对齐 sidebar 导航入口、面包屑与页面命名。
|
||||
7. Phase 6 联调与灰度验收(依赖:5、6)
|
||||
1) 双端并行回归:同角色、同配置下 frontend 与 frontendNew 结果可比对。
|
||||
2) 关键路径验收:角色加载 -> 装备/技能/打造配置 -> 结果计算 -> 切页返回。
|
||||
3) 输出差异清单并分级修复(阻塞/高/中/低)。
|
||||
|
||||
**并行建议**
|
||||
1. 可并行 A:Skill 面板拆分 与 Forge 子模块拆分。
|
||||
2. 可并行 B:Result 只读表格骨架 与 API 结果类型校准(在 Skill/Forge 进入后期时启动)。
|
||||
3. 串行阻塞点:Result 正式接入必须等待 Skill + Forge 至少 MVP 完成。
|
||||
|
||||
**Relevant files**
|
||||
- /Users/xigua/Desktop/code/dnftools/dcalc/frontendNew/src/router/index.ts — 已含 character 子路由,后续仅扩展守卫与异常路由策略。
|
||||
- /Users/xigua/Desktop/code/dnftools/dcalc/frontendNew/src/pages/Dcalc/Character/Index.vue — Character 总容器,需接入 Result 并统一计算触发节奏。
|
||||
- /Users/xigua/Desktop/code/dnftools/dcalc/frontendNew/src/pages/Dcalc/Character/EquipmentDictionary/Index.vue — 作为已迁移基线与样式/交互参考。
|
||||
- /Users/xigua/Desktop/code/dnftools/dcalc/frontendNew/src/pages/Dcalc/Character/Skill/index.vue — 当前占位版,迁移主入口。
|
||||
- /Users/xigua/Desktop/code/dnftools/dcalc/frontendNew/src/pages/Dcalc/Character/Forge/index.vue — 当前占位版,迁移主入口。
|
||||
- /Users/xigua/Desktop/code/dnftools/dcalc/frontendNew/src/stores/config.ts — Skill/Forge 回写与 Result 读取的核心状态源。
|
||||
- /Users/xigua/Desktop/code/dnftools/dcalc/frontendNew/src/stores/info.ts — 角色数据、技能/装备元数据来源。
|
||||
- /Users/xigua/Desktop/code/dnftools/dcalc/frontend/src/pages/Character/Skill/index.vue — 旧版 Skill 行为参考。
|
||||
- /Users/xigua/Desktop/code/dnftools/dcalc/frontend/src/pages/Character/Forge/index.vue — 旧版 Forge 容器参考。
|
||||
- /Users/xigua/Desktop/code/dnftools/dcalc/frontend/src/pages/Character/Result/Index.vue — 旧版 Result 结构与交互参考。
|
||||
|
||||
**Verification**
|
||||
1. 路由验证:访问 /dcalc/character/:alter/equipment|skill|forge 均可加载并保持切换状态。
|
||||
2. 数据流验证:Skill 与 Forge 操作后,configStore 对应字段实时变化并可持久化。
|
||||
3. 计算验证:触发计算后,Result 能正确展示 info/skills/suits,异常时有明确错误态。
|
||||
4. 对比验证:选定 3 个角色样本,在 frontend 与 frontendNew 下输出结果关键字段对齐。
|
||||
5. 回归验证:切换角色、刷新页面、返回导航、深链接进入均不丢关键配置。
|
||||
|
||||
**Decisions**
|
||||
- 已确认:equipment 视为已迁移完成,本轮仅做一致性优化,不重做主体。
|
||||
- 已确认:本轮优先 Skill、Forge、Result。
|
||||
- 已确认:迁移策略为核心功能优先,视觉后置。
|
||||
- 已确认:frontend 与 frontendNew 在迁移期间并行可用。
|
||||
- 范围外:完整视觉重设计、非关键页面重构、跨端统一主题工程化改造。
|
||||
|
||||
**Further Considerations**
|
||||
1. Result 的编辑能力建议分两期:先只读上线稳定计算链路,再上技能次数编辑,减少联调风险。
|
||||
2. Forge 的 Emblems/Jades 可在 MVP 后与视觉优化一起做,避免前期阻塞主链路。
|
||||
3. 若后续需要压缩周期,可将 Skill 与 Forge 分配两条并行开发线,Result 由第三条线在第 2 周后介入。
|
||||
Reference in New Issue
Block a user