# 罗盘项目重构方案 --- ## 一、项目现状分析 ### 1.1 当前架构特点 - **示例驱动**:目前使用 EXAMPLES 数组硬编码示例数据(角度数组 + 半径数组) - **简单数据结构**:通过角度分割点和半径列表简单定义层次和扇区 - **内置逻辑**:颜色、文字、填充等逻辑写死在代码中 - **测试导向**:现有实现主要为测试工具函数正确性而设计 ### 1.2 目标需求分析(基于 todolist.md 和 demo.json) 说明:`public/*.json` 为实际加载配置,`.json.conf` 仅作解释说明,不参与加载。 **核心变化:** 1. **配置驱动**:从 JSON 配置文件完全定义罗盘结构 2. **新增罗盘零改码**:新增罗盘只需在 `public/` 下增加 JSON 配置文件,无需修改代码 3. **配置下拉切换**:下拉菜单可选择 `public/` 下的配置(如 `demo.json`、`demo2.json`)切换罗盘 4. **复杂着色规则**:支持三级着色优先级(全局 → 层级规律填色 → 扇区独立) 5. **多文本单元**:扇区内容支持 `|` 分隔的多个文本单元,角度智能分配 6. **多文本比例可配**:在每个 layer 中可配置多文本比例(用于 `|` 分隔内容的角度分配) 7. **SVG 图标支持**:扇区内容可以是 SVG 文件 8. **中心图标**:作为 layer 类型,支持可旋转的中心 SVG 图标 9. **360度刻度环**:作为 layer 类型,支持多种刻度模式的度数环 10. **命名配色方案**:通过 theme.colorPalettes 定义可复用颜色 11. **规律填色机制**:通过 num + interval 实现周期性着色 12. **同组分割线控制**:groupSplit 参数控制组内分割线显示 13. **外半径兜底**:默认使用 layers 最大 rOuter,outerRadius 仅作为无层配置时的兜底 ### 1.3 配置清单与切换机制(新增) **问题:** 运行时无法直接读取 `public/` 目录文件列表,因此下拉菜单需要“配置清单”或“构建期生成清单”。 **推荐方案(满足“新增罗盘只需增加 JSON 文件”):** 1. **清单文件:** 构建期生成 `public/luopan-configs.json`,包含可选配置列表与默认项。 2. **生成逻辑:** 扫描 `public/` 下的 `*.json`,排除 `*.json.conf` 和 `luopan-configs.json` 本身。 3. **示例格式:** ```json { "default": "demo.json", "items": [ { "name": "示例罗盘一", "path": "/demo.json" }, { "name": "示例罗盘二", "path": "/demo2.json" } ] } ``` 4. **前端加载:** `Luopan.vue` 启动时拉取该清单,渲染下拉选项。 5. **切换逻辑:** 选择项变化时更新 `configPath` 并触发重新加载;同步更新 URL `?config=xxx.json` 便于分享。 6. **新增配置:** 仅需在 `public/` 放入新的 `*.json`,清单会在构建/开发启动时自动更新。 **备用方案(不启用生成脚本):** 手动维护 `public/luopan-configs.json`,仍然无需改代码,但需追加清单条目。 ### 1.4 主题抽离与引用(新增) **目标:** 罗盘配置通过 `themeRef` 引用统一的 `themes.json`,不再内嵌 theme。 **主题文件结构(public/themes.json):** ```json { "default": "五行", "items": [ { "name": "五行", "colorPalettes": { "黑": "#000000", "灰": "#757575" } } ] } ``` **罗盘 JSON 使用方式:** ```json { "name": "demo", "background": "白", "themeRef": "五行", "layers": [] } ``` **解析规则:** 1. 如果配置包含 `themeRef`,优先使用对应主题。 2. 若未指定 `themeRef`,使用 `themes.json.default`。 3. 若 `themeRef` 或 `default` 不存在:抛出配置错误(或回退到空主题,需在实现中约定)。 **实现改动点:** 1. `types.ts`: - `LuopanConfig.theme` 变为可选 - 新增 `themeRef?: string` 2. `configParser.ts`: - 支持解析 `themeRef` - 当 `theme` 缺失时暂不报错,由上层注入主题 3. `useLuopan.ts`: - 读取 `public/themes.json` - 根据 `themeRef/default` 注入主题到配置 - 处理错误与回退 4. 示例与文档: - `public/demo*.json` 替换为 `themeRef` - 新增 `public/themes.json` - 更新 `demo.json.conf` 说明 --- ## 二、整体架构设计 ### 2.1 分层架构 ``` ┌─────────────────────────────────────────────────┐ │ UI 层(Luopan.vue) │ │ - SVG 渲染 │ │ - 交互控制(缩放、平移) │ └─────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────┐ │ Composable 层(useLuopan.ts) │ │ - 配置解析协调 │ │ - 响应式数据管理 │ └─────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────┐ │ 配置解析层(新增) │ │ - configParser.ts:JSON → 内部数据结构 │ │ - colorResolver.ts:颜色解析引擎 │ │ - sectorBuilder.ts:扇区数据生成器 │ │ - multiTextParser.ts:多文本单元解析器 │ └─────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────┐ │ 工具函数层(utils.ts - 保持不变) │ │ - 几何计算(已完善) │ │ - 路径生成(已完善) │ │ - 文字布局计算(已完善) │ └─────────────────────────────────────────────────┘ ``` ### 2.2 核心模块划分 | 模块名 | 文件 | 职责 | |--------|------|------| | **类型定义** | `types.ts` | 扩展类型定义,支持 JSON 配置结构 | | **JSON 解析** | `configParser.ts` | 解析 JSON 配置,去注释,验证结构 | | **颜色解析器** | `colorResolver.ts` | 处理三级着色优先级、规律填色 | | **扇区构建器** | `sectorBuilder.ts` | 根据配置生成完整扇区数据 | | **多文本解析** | `multiTextParser.ts` | 处理 `|` 分隔文本,角度分配 | | **中心图标** | `centerIcon.ts` | 处理中心 SVG 图标加载和渲染 | | **刻度环** | `degreeRing.ts` | 生成 360度刻度环数据 | | **组合逻辑** | `useLuopan.ts` | 重构为配置驱动模式 | --- ## 三、JSON 配置解析流程 ### 3.1 解析流程图 ``` JSON 文本 (带注释) ↓ [configParser] 去除 -- 注释 ↓ 标准 JSON 对象 ↓ [configParser] 结构验证 ↓ 验证通过的 LuopanConfig ↓ [colorResolver] 解析颜色主题 ↓ 颜色映射表 (ColorPalette) ↓ [sectorBuilder] 遍历 layers ↓ 对每个 layer: - type=sectors: 1. 计算规律填色模式 (num, interval) 2. 生成所有扇区 3. 对每个扇区: - 应用颜色优先级 - 解析多文本单元 - 计算 SVG 路径 - type=degreeRing:生成刻度环数据 - type=centerIcon:加载中心图标 ↓ 最终渲染数据 ``` ### 3.2 关键算法 **规律填色算法:** ```typescript function applyPatternColoring( divisions: number, colorRef: string, num: number, interval: number ): Map { const colorMap = new Map(); if (interval === 0) { // 全部着色 for (let i = 0; i < divisions; i++) { colorMap.set(i, colorRef); } return colorMap; } // 周期性着色:num个着色 + interval个空白 let currentIndex = 0; while (currentIndex < divisions) { // 着色 num 个 for (let i = 0; i < num && currentIndex < divisions; i++) { colorMap.set(currentIndex, colorRef); currentIndex++; } // 跳过 interval 个 currentIndex += interval; } return colorMap; } ``` **多文本单元角度分配(支持 unitRatios):** ```typescript function splitMultiTextUnits( content: string, aStart: number, aEnd: number, unitRatios?: number[] ): TextUnit[] { const units = content.split('|'); const ratios = normalizeRatios(unitRatios, units.length) ?? getLayoutRatio(units.length); // 从 constants.ts const totalAngle = aEnd - aStart; const textUnits: TextUnit[] = []; let accumulatedAngle = aStart; for (let i = 0; i < units.length; i++) { const unitAngle = totalAngle * ratios[i]; textUnits.push({ content: units[i], aStart: accumulatedAngle, aEnd: accumulatedAngle + unitAngle }); accumulatedAngle += unitAngle; } return textUnits; } function normalizeRatios( ratios: number[] | undefined, count: number ): number[] | null { if (!ratios || ratios.length !== count) return null; if (ratios.some((value) => typeof value !== 'number' || value <= 0)) return null; const sum = ratios.reduce((acc, value) => acc + value, 0); if (sum <= 0) return null; return ratios.map((value) => value / sum); } ``` **示例(layer 级别配置比例):** ```json { "divisions": 24, "rInner": 200, "rOuter": 240, "unitRatios": [0.25, 0.5, 0.25] } ``` --- ## 四、数据流设计 ### 4.1 配置数据结构(新增类型) ```typescript // src/types.ts 扩展 /** JSON 配置根对象 */ export interface LuopanConfig { name: string; description?: string; background: string; // 全局背景色(支持主题色) strokeWidth?: number; // 扇区边界线宽度 strokeColor?: string; // 扇区边界线颜色(支持主题色) strokeOpacity?: number; // 扇区边界线透明度 outerRadius?: number; // 可选兜底外半径,默认使用 layers 最大 rOuter theme: ThemeConfig; layers: LayerConfig[]; // 扇区层 + 中心图标层 + 刻度环层 } /** 主题配置 */ export interface ThemeConfig { name?: string; colorPalettes: Record; // 命名颜色映射 } /** 中心图标配置 */ export interface CenterIconConfig { rIcon: number; opacity: number; name: string; // SVG 文件名 rotation?: number; // 可选旋转角度 } /** 刻度环配置 */ export interface DegreeRingConfig { rInner: number; rOuter: number; showDegree: 0 | 1; mode: 'inner' | 'outer' | 'both'; opacity: number; tickLength: number; tickLengthStep?: number; majorTick: number; minorTick: number; microTick: number; tickColor: string; ringColor: string; } /** 层配置 */ export type LayerConfig = | SectorLayerConfig | CenterIconLayerConfig | DegreeRingLayerConfig; /** 普通扇区层配置 */ export interface SectorLayerConfig { type?: 'sectors'; divisions: number; rInner: number; rOuter: number; startAngle?: number; colorRef?: string; innerFill?: 0 | 1; unitRatios?: number[]; // 多文本单元比例(对应 content 用 "|" 分隔) num?: number; interval?: number; groupSplit?: boolean; sectors?: SectorConfig[]; } /** 中心图标层配置 */ export interface CenterIconLayerConfig { type: 'centerIcon'; centerIcon: CenterIconConfig; } /** 刻度环层配置 */ export interface DegreeRingLayerConfig { type: 'degreeRing'; degreeRing: DegreeRingConfig; } /** 扇区配置 */ export interface SectorConfig { content?: string; // 支持 "|" 分隔 colorRef?: string; innerFill?: 0 | 1; } /** 文本单元(多文本拆分后) */ export interface TextUnit { content: string; aStart: number; aEnd: number; isSvg: boolean; // 是否为 SVG 文件名 } /** 刻度线数据 */ export interface TickMark { angle: number; type: 'major' | 'minor' | 'micro'; length: number; startR: number; endR: number; label?: string; // 度数标签(仅主刻度) } /** 扩展现有 Sector 接口 */ export interface Sector { // ... 现有字段保持 ... // 新增字段 textUnits?: TextUnit[]; // 多文本单元 groupSplitVisible?: boolean; // 是否显示与下一个扇区的分割线 isSvgContent?: boolean; // 内容是否为 SVG svgPath?: string; // SVG 文件路径 } ``` ### 4.2 数据流转示例 ```typescript // 输入:JSON 配置字符串 const jsonText = await fetch('/demo.json').then(r => r.text()); // 步骤1:解析配置 const config = parseConfig(jsonText); // 步骤2:解析颜色主题 const colorResolver = new ColorResolver(config.theme, config.background); // 步骤3:构建扇区 const sectorBuilder = new SectorBuilder(colorResolver); const sectors = config.layers.flatMap((layer, layerIndex) => layer.type === 'centerIcon' || layer.type === 'degreeRing' ? [] : sectorBuilder.buildLayer(layer, layerIndex) ); // 步骤4:生成刻度环(从 layers 中提取) const degreeRingLayer = config.layers.find(layer => layer.type === 'degreeRing'); const degreeRingData = degreeRingLayer ? buildDegreeRing(degreeRingLayer.degreeRing) : null; // 步骤5:加载中心图标(从 layers 中提取) const centerIconLayer = config.layers.find(layer => layer.type === 'centerIcon'); const centerIconData = centerIconLayer ? await loadCenterIcon(centerIconLayer.centerIcon) : null; // 输出:完整渲染数据 const renderData = { sectors, degreeRing: degreeRingData, centerIcon: centerIconData, background: config.background }; ``` --- ## 五、实施步骤(分阶段) ### 阶段 1:类型定义和配置解析(2-3天) **目标:** 建立配置解析基础 **任务:** 1. ✅ 扩展 `types.ts`,新增所有配置相关类型 2. ✅ 实现 `configParser.ts`: - 去除 `--` 注释 - JSON 解析 - 基础验证(必填字段检查) 3. ✅ 编写单元测试:`configParser.test.ts` 4. ✅ 测试用例:解析 `demo.json` **验收标准:** - 能成功解析 `demo.json` 为 LuopanConfig 对象 - 所有必填字段验证通过 - 测试覆盖率 > 80% --- ### 阶段 2:颜色解析引擎(2天) **目标:** 实现三级着色优先级 **任务:** 1. ✅ 实现 `colorResolver.ts`: - ColorResolver 类 - resolveColor(ref: string) 方法 - 规律填色算法 - 颜色优先级逻辑 2. ✅ 编写单元测试:`colorResolver.test.ts` 3. ✅ 测试用例覆盖: - 全局背景色 - 层级 colorRef - 扇区 colorRef - 规律填色(num + interval) **核心代码:** ```typescript export class ColorResolver { private colorPalettes: Record; private globalBackground: string; constructor(theme: ThemeConfig, background: string) { this.colorPalettes = theme.colorPalettes; this.globalBackground = background; } resolveColor(colorRef?: string): string { if (!colorRef) return this.globalBackground; return this.colorPalettes[colorRef] ?? colorRef; } resolveLayerColors(layer: LayerConfig): Map { const colorMap = new Map(); if (!layer.colorRef || !layer.num) { return colorMap; // 无规律填色 } return applyPatternColoring( layer.divisions, this.resolveColor(layer.colorRef), layer.num, layer.interval ?? 0 ); } } ``` **验收标准:** - 着色优先级正确 - 规律填色算法准确 - 边界情况处理(interval=0, num > divisions) --- ### 阶段 3:多文本单元解析(2天) **目标:** 支持 `|` 分隔文本的角度分配 **任务:** 1. ✅ 实现 `multiTextParser.ts`: - splitMultiTextUnits() 函数 - 角度分配逻辑 - SVG 文件检测 - 支持 layer.unitRatios 覆盖默认比例 2. ✅ 更新 `utils.ts`: - 适配多文本单元的路径生成 - 字体大小计算调整 3. ✅ 编写单元测试:`multiTextParser.test.ts` 4. ✅ 测试用例: - 1-5 个单元的角度分配 - LAYOUT_RATIO_PRESETS 验证 **核心代码:** ```typescript // 仅保留名称,具体实现参考前文 splitMultiTextUnits export function splitMultiTextUnits( content: string, aStart: number, aEnd: number, svgIconPath: string = 'src/assets/icons/' ): TextUnit[] { // 参考前面代码 return []; } ``` **验收标准:** - 角度分配与 LAYOUT_RATIO_PRESETS 一致 - 支持 SVG 文件名识别 - 边界情况(单个文本、空字符串) --- ### 阶段 4:扇区构建器(3-4天) **目标:** 整合所有逻辑,生成完整扇区数据 **任务:** 1. ✅ 实现 `sectorBuilder.ts`: - SectorBuilder 类 - buildLayer() 方法 - buildSector() 方法 - 整合颜色、多文本、innerFill、groupSplit 2. ✅ 更新 `Sector` 接口(types.ts) 3. ✅ 编写单元测试:`sectorBuilder.test.ts` 4. ✅ 集成测试:完整 layer 构建 **核心代码:** ```typescript export class SectorBuilder { constructor(private colorResolver: ColorResolver) {} buildLayer(layer: LayerConfig, layerIndex: number): Sector[] { const sectors: Sector[] = []; const layerColorMap = this.colorResolver.resolveLayerColors(layer); const angleStep = 360 / layer.divisions; const startAngle = layer.startAngle ?? 0; for (let i = 0; i < layer.divisions; i++) { const aStart = startAngle + i * angleStep; const aEnd = aStart + angleStep; // 确定颜色(优先级:sector > layer pattern > global) const sectorConfig = layer.sectors?.[i]; let fillColor: string; if (sectorConfig?.colorRef) { fillColor = this.colorResolver.resolveColor(sectorConfig.colorRef); } else if (layerColorMap.has(i)) { fillColor = layerColorMap.get(i)!; } else { fillColor = this.colorResolver.resolveColor(); } // 确定 innerFill const innerFill = sectorConfig?.innerFill ?? layer.innerFill ?? 0; // 解析多文本单元 const content = sectorConfig?.content ?? ''; const textUnits = content.includes('|') ? splitMultiTextUnits(content, aStart, aEnd) : [{ content, aStart, aEnd, isSvg: content.endsWith('.svg') }]; // 确定是否显示分割线 const groupSplitVisible = this.shouldShowGroupSplit( layer, i, layerColorMap ); // 生成完整扇区 const sector = this.buildSector({ layerIndex, pieIndex: i, rInner: layer.rInner, rOuter: layer.rOuter, aStart, aEnd, fillColor, innerFill, textUnits, groupSplitVisible, }); sectors.push(sector); } return sectors; } private shouldShowGroupSplit( layer: LayerConfig, sectorIndex: number, layerColorMap: Map ): boolean { if (layer.groupSplit !== false) return true; // 默认显示 // 如果 groupSplit=false,检查是否为组内分割线 if (!layer.num) return true; const cycleLength = layer.num + (layer.interval ?? 0); const posInCycle = sectorIndex % cycleLength; // 组内(0 到 num-1)不显示分割线 return posInCycle >= layer.num - 1; } } ``` **验收标准:** - 完整解析 `demo.json` 所有 layer - 颜色优先级正确 - groupSplit 逻辑正确 - 多文本单元正确拆分 --- ### 阶段 5:刻度环和中心图标(2-3天) **目标:** 实现刻度环和中心图标功能 **任务:** 1. ✅ 实现 `degreeRing.ts`: - buildDegreeRing() 函数 - 刻度线生成(major, minor, micro) - 度数标签生成 - mode 处理(inner, outer, both) 2. ✅ 实现 `centerIcon.ts`: - loadCenterIcon() 函数 - SVG 加载和解析 - 旋转处理 3. ✅ 更新 Luopan.vue: - 渲染刻度环 - 渲染中心图标 4. ✅ 编写单元测试 **核心代码(刻度环):** ```typescript export function buildDegreeRing(config: DegreeRingConfig): { ticks: TickMark[]; ring: { rInner: number; rOuter: number; color: string; opacity: number }; labels?: DegreeLabel[]; } { const ticks: TickMark[] = []; const { rInner, rOuter, mode, tickLength, tickLengthStep = 0 } = config; // 生成刻度线 for (let angle = 0; angle < 360; angle++) { let type: 'major' | 'minor' | 'micro'; let length: number; if (angle % config.majorTick === 0) { type = 'major'; length = tickLength; } else if (angle % config.minorTick === 0) { type = 'minor'; length = tickLength - tickLengthStep; } else if (angle % config.microTick === 0) { type = 'micro'; length = tickLength - 2 * tickLengthStep; } else { continue; } // 根据 mode 确定刻度线位置 let startR: number, endR: number; if (mode === 'inner') { startR = rInner; endR = rInner + length; } else if (mode === 'outer') { startR = rOuter - length; endR = rOuter; } else { // both startR = rInner; endR = rInner + length; // 还需要添加外侧刻度 } ticks.push({ angle, type, length, startR, endR }); } // 生成度数标签(使用 textPath 保持方向一致) const labels: DegreeLabel[] = []; if (config.showDegree === 1) { const r = (rInner + rOuter) / 2; for (let angle = 0; angle < 360; angle += config.majorTick) { labels.push({ angle, text: angle.toString(), r, textPathId: `degree-label-${angle}`, textPath: generateTextPath(r, r, angle - 4, angle + 4) }); } } return { ticks, ring: { rInner, rOuter, color: config.ringColor, opacity: config.opacity }, labels: config.showDegree ? labels : undefined }; } ``` **验收标准:** - 刻度线长度正确(major > minor > micro) - mode 模式正确(inner, outer, both) - 度数标签方向与扇区文字一致(自动翻转) - 中心图标加载和旋转正常 --- ### 阶段 6:重构 useLuopan 和 Luopan.vue(3-4天) **目标:** 适配新的配置驱动模式 **任务:** 1. ✅ 重构 `useLuopan.ts`: - 接收 JSON 配置路径或对象 - 异步加载和解析配置 - 调用各个解析器和构建器 - 返回完整渲染数据 2. ✅ 更新 `Luopan.vue`: - 使用配置下拉替换示例选择器(可切换 `public/*.json`) - 添加配置加载界面 - 渲染多文本单元 - 渲染刻度环 - 渲染中心图标 - 处理 groupSplit(条件渲染分割线) 3. ✅ 更新 `App.vue`: - 传入配置文件路径 4. ✅ E2E 测试 **核心代码(useLuopan 重构):** ```typescript export function useLuopan(configPathOrObject: string | LuopanConfig) { const config = ref(null); const sectors = ref([]); const degreeRing = ref(null); const centerIcon = ref(null); const loading = ref(true); const error = ref(null); const loadConfig = async () => { try { loading.value = true; // 加载配置 let configObj: LuopanConfig; if (typeof configPathOrObject === 'string') { const jsonText = await fetch(configPathOrObject).then(r => r.text()); configObj = parseConfig(jsonText); } else { configObj = configPathOrObject; } config.value = configObj; // 解析颜色 const colorResolver = new ColorResolver( configObj.theme, configObj.background ); // 构建扇区 const sectorBuilder = new SectorBuilder(colorResolver); sectors.value = configObj.layers.flatMap((layer, i) => layer.type === 'centerIcon' || layer.type === 'degreeRing' ? [] : sectorBuilder.buildLayer(layer, i) ); // 构建刻度环(从 layers 中提取) const degreeRingLayer = configObj.layers.find(layer => layer.type === 'degreeRing'); if (degreeRingLayer) { degreeRing.value = buildDegreeRing(degreeRingLayer.degreeRing); } // 加载中心图标(从 layers 中提取) const centerIconLayer = configObj.layers.find(layer => layer.type === 'centerIcon'); if (centerIconLayer) { centerIcon.value = await loadCenterIcon(centerIconLayer.centerIcon); } } catch (e) { error.value = e as Error; } finally { loading.value = false; } }; // 自动加载 loadConfig(); return { config: readonly(config), sectors: readonly(sectors), degreeRing: readonly(degreeRing), centerIcon: readonly(centerIcon), loading: readonly(loading), error: readonly(error), reload: loadConfig }; } ``` **Luopan.vue 关键更新:** ```vue ``` **验收标准:** - 成功加载和渲染 `demo.json` - 所有 layer 正确显示 - 多文本单元正确拆分显示 - 刻度环和中心图标正常渲染 - groupSplit 效果正确 --- ### 阶段 7:测试和优化(2-3天) **目标:** 确保质量和性能 **任务:** 1. ✅ 单元测试补全(目标覆盖率 > 85%) 2. ✅ 集成测试: - 完整配置文件解析 - 边界情况测试 3. ✅ 性能优化: - 大量扇区渲染优化(>1000个) - 配置解析缓存 4. ✅ 错误处理: - 配置格式错误提示 - SVG 加载失败处理 - 颜色引用不存在处理 5. ✅ 文档更新: - README.md - API 文档 - 配置示例 **测试策略:** ```typescript describe('完整流程测试', () => { it('应正确解析和渲染 demo.json', async () => { const config = await loadConfig('/demo.json'); const sectors = buildAllSectors(config); // 验证扇区数量 const expectedCount = config.layers.reduce( (sum, layer) => sum + layer.divisions, 0 ); expect(sectors).toHaveLength(expectedCount); // 验证颜色优先级 const layer3Sector0 = sectors.find( s => s.layerIndex === 2 && s.pieIndex === 0 ); expect(layer3Sector0?.fill).toBe('#0288D1'); // 水 // 验证多文本单元 const multiTextSector = sectors.find( s => s.textUnits && s.textUnits.length > 1 ); expect(multiTextSector?.textUnits).toHaveLength(3); }); it('应处理配置错误', async () => { const invalidJson = '{ "name": "test", "missing": "layers" }'; expect(() => parseConfig(invalidJson)).toThrow('缺少必填字段'); }); }); ``` **验收标准:** - 单元测试覆盖率 > 85% - 所有边界情况测试通过 - 大量扇区(120等分 × 5层)渲染流畅 - 错误信息清晰友好 --- ## 六、需要新增/修改的文件清单 ### 新增文件(共 8 个) | 文件路径 | 功能 | 优先级 | |---------|------|--------| | `src/configParser.ts` | JSON 配置解析和验证 | P0 | | `src/colorResolver.ts` | 颜色解析引擎 | P0 | | `src/sectorBuilder.ts` | 扇区数据构建器 | P0 | | `src/multiTextParser.ts` | 多文本单元解析 | P1 | | `src/degreeRing.ts` | 刻度环生成器 | P1 | | `src/centerIcon.ts` | 中心图标处理 | P2 | | `tests/configParser.test.ts` | 配置解析测试 | P0 | | `tests/sectorBuilder.test.ts` | 扇区构建测试 | P1 | ### 修改文件(共 5 个) | 文件路径 | 修改内容 | 影响范围 | |---------|---------|---------| | `src/types.ts` | 新增配置相关类型定义 | 扩展,不影响现有代码 | | `src/constants.ts` | 保持不变,已有 LAYOUT_RATIO_PRESETS | 无 | | `src/utils.ts` | 保持不变,工具函数完善 | 无 | | `src/composables/useLuopan.ts` | 重构为配置驱动模式 | 完全重写 | | `src/Luopan.vue` | 适配新数据结构和渲染逻辑 | 大幅修改 | ### 保持不变(共 2 个) | 文件路径 | 原因 | |---------|------| | `src/utils.ts` | 几何计算和路径生成函数已完善,无需修改 | | `src/constants.ts` | LAYOUT_RATIO_PRESETS 已定义,符合需求 | --- ## 七、技术难点和解决方案 ### 难点 1:多文本单元的路径生成 **问题:** 一个扇区内有多个文本单元,每个单元需要独立的 textPath,且角度分配要精确。 **解决方案:** 1. 在 `sectorBuilder` 中为每个文本单元生成独立的 `TextUnit` 对象 2. 每个 `TextUnit` 包含独立的 `aStart`, `aEnd`, `content` 3. 渲染时为每个单元生成独立的 `` 元素 4. 使用 `getLayoutRatio()` 确保角度分配准确 5. 字体大小与布局按单元独立计算:每个 `TextUnit` 用自身 `aStart/aEnd` + `content.length` 调用 `calculateSectorFontSize`,并基于相同角度范围生成 `generateTextPath` / `generateVerticalTextPath` **代码示例:** ```typescript // 为每个文本单元生成独立路径 textUnits.forEach((unit, index) => { unit.textPathId = `${sector.key}-unit-${index}`; unit.textPath = generateTextPath( sector.rInner, sector.rOuter, unit.aStart, unit.aEnd ); }); ``` --- ### 难点 2:规律填色算法的边界情况 **问题:** `num` + `interval` 组合可能产生边界情况(如 num > divisions)。 **解决方案:** 1. 算法中加入边界检查: ```typescript for (let i = 0; i < num && currentIndex < divisions; i++) { colorMap.set(currentIndex, colorRef); currentIndex++; } ``` 2. 单元测试覆盖所有边界情况: - `num = divisions, interval = 0`(全部着色) - `num = 1, interval = 1`(隔一个着色) - `num > divisions`(截断处理) - `interval = 0`(特殊情况:全部着色) --- ### 难点 3:groupSplit 的判断逻辑 **问题:** 需要判断两个相邻扇区是否属于同一着色组,仅在组边界显示分割线。 **解决方案:** 1. 在 `SectorBuilder` 中计算每个扇区在周期中的位置: ```typescript const cycleLength = num + interval; const posInCycle = sectorIndex % cycleLength; const isGroupBoundary = posInCycle === num - 1; ``` 2. 为每个扇区添加 `groupSplitVisible` 属性 3. 渲染时条件渲染分割线: ```vue ``` --- ### 难点 4:SVG 图标加载和缓存 **问题:** 多个扇区可能使用相同 SVG 图标,需要避免重复加载。 **解决方案:** 1. 实现 SVG 缓存机制: ```typescript const svgCache = new Map(); async function loadSvg(filename: string): Promise { if (svgCache.has(filename)) { return svgCache.get(filename)!; } const path = `/src/assets/icons/${filename}`; const content = await fetch(path).then(r => r.text()); svgCache.set(filename, content); return content; } ``` 2. 使用 `` 定义 SVG,`` 引用: ```vue ``` --- ### 难点 5:刻度环的刻度线长度分级 **问题:** major、minor、micro 三种刻度线长度需要根据 `tickLengthStep` 递减。 **解决方案:** 1. 统一计算逻辑: ```typescript const lengths = { major: config.tickLength, minor: config.tickLength - (config.tickLengthStep ?? 0), micro: config.tickLength - 2 * (config.tickLengthStep ?? 0) }; ``` 2. 确保最小长度不为负: ```typescript Object.keys(lengths).forEach(key => { lengths[key] = Math.max(1, lengths[key]); }); ``` --- ## 八、测试策略 ### 8.1 单元测试 | 模块 | 测试文件 | 测试重点 | |------|---------|---------| | 配置解析 | `configParser.test.ts` | 注释去除、JSON 解析、验证逻辑 | | 颜色解析 | `colorResolver.test.ts` | 优先级、规律填色、边界情况 | | 多文本解析 | `multiTextParser.test.ts` | 角度分配、SVG 识别 | | 扇区构建 | `sectorBuilder.test.ts` | 完整扇区生成、groupSplit | | 刻度环 | `degreeRing.test.ts` | 刻度线生成、度数标签 | **示例测试用例:** ```typescript describe('ColorResolver', () => { it('应正确解析颜色引用', () => { const resolver = new ColorResolver( { colorPalettes: { '木': '#43A047' } }, '#000000' ); expect(resolver.resolveColor('木')).toBe('#43A047'); }); it('应应用规律填色', () => { const layer: LayerConfig = { divisions: 12, colorRef: '土', num: 3, interval: 1, // ... }; const colorMap = resolver.resolveLayerColors(layer); // 扇区 0, 1, 2 着色 expect(colorMap.has(0)).toBe(true); expect(colorMap.has(1)).toBe(true); expect(colorMap.has(2)).toBe(true); // 扇区 3 空白 expect(colorMap.has(3)).toBe(false); // 扇区 4, 5, 6 着色 expect(colorMap.has(4)).toBe(true); }); }); ``` ### 8.2 集成测试 ```typescript describe('完整渲染流程', () => { it('应从 JSON 配置生成完整罗盘', async () => { const config = await loadConfig('/demo.json'); const { sectors, degreeRing, centerIcon } = buildLuopan(config); // 验证总扇区数 expect(sectors.length).toBeGreaterThan(0); // 验证刻度环 expect(degreeRing?.ticks.length).toBe(360); // 每度一个微刻度 // 验证中心图标 expect(centerIcon?.svgPath).toContain('centericon.svg'); }); }); ``` ### 8.3 E2E 测试(可选) 使用 Playwright 或 Cypress 测试完整用户流程: 1. 加载页面 2. 验证罗盘正确渲染 3. 测试缩放和平移 4. 验证多文本单元显示 5. 验证刻度环显示 --- ## 九、实施风险和缓解措施 | 风险 | 影响 | 概率 | 缓解措施 | |-----|------|------|---------| | 配置文件格式复杂,解析困难 | 高 | 中 | 分阶段实现,先支持基础字段,再扩展 | | 多文本单元渲染性能问题 | 中 | 低 | 使用虚拟化技术,仅渲染可见扇区 | | SVG 图标加载失败 | 中 | 中 | 实现错误处理和占位符显示 | | 规律填色算法边界情况 | 高 | 中 | 充分的单元测试覆盖 | | 现有代码兼容性 | 低 | 低 | `utils.ts` 保持不变,新旧模式共存 | --- ## 十、总结和后续规划 ### 10.1 重构价值 1. **配置驱动**:从硬编码到 JSON 配置,大幅提升灵活性 2. **功能完整**:支持复杂着色、多文本、SVG 图标、刻度环 3. **可维护性**:清晰的模块划分和职责分离 4. **可扩展性**:易于添加新功能(如动画、交互) ### 10.2 预期工期 - **总计:16-20 个工作日** - 基础架构(阶段1-2):4-5天 - 核心功能(阶段3-4):5-6天 - 高级功能(阶段5):2-3天 - 集成和优化(阶段6-7):5-6天 ### 10.3 后续优化方向 1. **性能优化**: - 虚拟滚动(大量扇区) - Web Worker 解析配置 - 配置加载缓存(页面刷新复用 JSON 与渲染结果,低优先级,可选) - Canvas 渲染模式(可选) 2. **功能扩展**: - 配置编辑器(可视化配置生成) - 动画效果(旋转、渐变) - 导出功能(PNG, SVG) - 多配置切换 3. **开发体验**: - VS Code 插件(JSON 配置提示) - 配置验证工具 - 在线预览工具 --- **本重构方案完全基于现有代码和需求文档制定,确保可行性和可执行性。建议按阶段逐步实施,每个阶段完成后进行验收,确保质量和进度。**