From 1de0d1937fa59f85be2f43f1876b92216f763508 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:41:12 +0800 Subject: [PATCH 01/82] =?UTF-8?q?feat(fleet):=20=E5=AE=8C=E5=96=84?= =?UTF-8?q?=E7=BC=96=E9=98=9F=E8=A7=84=E5=88=99=E4=B8=8E=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/TeamPlanCodec.ts | 89 ++- scripts/backend-fleet-contract.py | 1 + scripts/main-services/test-combat-plan.js | 47 +- scripts/main-services/test-team-plan.js | 87 +++ scripts/test-api-contract.js | 24 + scripts/test-fleet-domain.mjs | 544 +++++++++++++++++- scripts/test-settings-persistence.js | 8 + src/controller/app/ConfigController.ts | 2 + src/controller/plan/FleetPlannerController.ts | 1 + src/controller/plan/PlanController.ts | 1 + src/controller/plan/fleetViewObjects.ts | 2 + src/model/ConfigModel.ts | 4 + src/model/PlanModel.ts | 3 + src/model/fleet/FleetDraft.ts | 59 +- src/model/fleet/FleetDraftEditor.ts | 28 +- src/model/fleet/FleetPresetIdentity.ts | 2 + src/model/fleet/FleetRuleMapper.ts | 2 + src/types/api.ts | 1 + src/types/fleetEditor.ts | 1 + src/types/ipc.ts | 2 + src/types/model.ts | 3 + src/types/view.ts | 2 + src/view/config/ConfigView.ts | 3 + src/view/index.html | 54 +- src/view/plan/FleetEditorView.ts | 50 +- src/view/plan/FleetRuleView.ts | 20 + .../styles/pages/plan/_fleet-planner.scss | 86 ++- src/view/styles/styles.css | 82 ++- 28 files changed, 1102 insertions(+), 106 deletions(-) diff --git a/electron/services/TeamPlanCodec.ts b/electron/services/TeamPlanCodec.ts index 0cc7931..9f9b16a 100644 --- a/electron/services/TeamPlanCodec.ts +++ b/electron/services/TeamPlanCodec.ts @@ -20,6 +20,7 @@ export interface UserTeamShipRule { ship_type?: string[]; min_level?: number; max_level?: number; + relaxed?: boolean; } export interface UserTeamPlanSlot { @@ -28,6 +29,7 @@ export interface UserTeamPlanSlot { ship_type?: string[]; min_level?: number; max_level?: number; + relaxed?: boolean; candidates?: UserTeamShipRule[]; } @@ -99,6 +101,7 @@ export class TeamPlanCodec { 'ship_type', 'min_level', 'max_level', + 'relaxed', 'candidates', 'priority', ]); @@ -108,14 +111,9 @@ export class TeamPlanCodec { !knownSlotKeys.has(key) && value !== undefined ), ); - const anonymousEntries = Object.entries(slot) - .filter( - ([key, value]) => ( - key !== 'candidates' - && key !== 'priority' - && value !== undefined - ), - ); + const anonymousEntries = Object.entries( + this.serializableAnonymousSlot(slot), + ); if (slot.name !== undefined) { lines.push(` - name: ${this.inlineYaml(slot.name)}`); if (slot.search_name !== undefined) { @@ -134,6 +132,9 @@ export class TeamPlanCodec { if (slot.max_level !== undefined) { lines.push(` max_level: ${slot.max_level}`); } + if (slot.relaxed === true) { + lines.push(' relaxed: true'); + } } if (candidateOnly && anonymousEntries.length > 0) { const [first, ...rest] = anonymousEntries; @@ -153,7 +154,9 @@ export class TeamPlanCodec { : ' candidates:', ); for (const candidate of slot.candidates) { - lines.push(` - ${this.inlineYaml(candidate)}`); + lines.push( + ` - ${this.inlineYaml(this.serializableShipRule(candidate))}`, + ); } } } @@ -247,6 +250,10 @@ export class TeamPlanCodec { raw.max_level, '无固定舰名位置.max_level', ); + const relaxed = this.optionalBoolean( + raw.relaxed, + '无固定舰名位置.relaxed', + ); if ( minLevel !== undefined && maxLevel !== undefined @@ -269,6 +276,7 @@ export class TeamPlanCodec { ...(shipTypes === undefined ? {} : { ship_type: shipTypes }), ...(minLevel === undefined ? {} : { min_level: minLevel }), ...(maxLevel === undefined ? {} : { max_level: maxLevel }), + ...(relaxed === undefined ? {} : { relaxed }), candidates, }; } @@ -325,8 +333,10 @@ export class TeamPlanCodec { raw.max_level, `${field}.max_level`, ); + const relaxed = this.optionalBoolean(raw.relaxed, `${field}.relaxed`); if (minLevel !== undefined) result.min_level = minLevel; if (maxLevel !== undefined) result.max_level = maxLevel; + if (relaxed !== undefined) result.relaxed = relaxed; if ( minLevel !== undefined && maxLevel !== undefined @@ -389,6 +399,67 @@ export class TeamPlanCodec { return Number(value); } + private optionalBoolean( + value: unknown, + field: string, + ): boolean | undefined { + if (value === undefined) return undefined; + if (typeof value !== 'boolean') { + throw new Error(`${field} 必须是布尔值`); + } + return value; + } + + /** 固定舰船规则字段顺序;false 使用后端缺省值,不写入 YAML。 */ + private serializableShipRule( + rule: UserTeamShipRule, + ): Record { + const result: Record = { name: rule.name }; + if (rule.search_name !== undefined) result.search_name = rule.search_name; + if (rule.ship_type !== undefined) result.ship_type = rule.ship_type; + if (rule.min_level !== undefined) result.min_level = rule.min_level; + if (rule.max_level !== undefined) result.max_level = rule.max_level; + if (rule.relaxed === true) result.relaxed = true; + const knownKeys = new Set([ + 'name', + 'search_name', + 'ship_type', + 'min_level', + 'max_level', + 'relaxed', + ]); + for (const [key, value] of Object.entries(rule)) { + if (!knownKeys.has(key) && value !== undefined) result[key] = value; + } + return result; + } + + /** 无主选位置同样使用固定字段顺序,但不继承候选规则。 */ + private serializableAnonymousSlot( + slot: UserTeamPlanSlot, + ): Record { + const result: Record = {}; + if (slot.search_name !== undefined) result.search_name = slot.search_name; + if (slot.ship_type !== undefined) result.ship_type = slot.ship_type; + if (slot.min_level !== undefined) result.min_level = slot.min_level; + if (slot.max_level !== undefined) result.max_level = slot.max_level; + if (slot.relaxed === true) result.relaxed = true; + const knownKeys = new Set([ + 'name', + 'search_name', + 'ship_type', + 'min_level', + 'max_level', + 'relaxed', + 'candidates', + 'priority', + ]); + for (const [key, value] of Object.entries(slot)) { + if (!knownKeys.has(key) && value !== undefined) result[key] = value; + } + return result; + } + private inlineYaml(value: unknown): string { return yaml.dump(value, { flowLevel: 0, diff --git a/scripts/backend-fleet-contract.py b/scripts/backend-fleet-contract.py index 8c97dbc..933dd10 100644 --- a/scripts/backend-fleet-contract.py +++ b/scripts/backend-fleet-contract.py @@ -33,6 +33,7 @@ def _selector_contract(selector: Any) -> dict[str, Any] | None: 'ship_type': [value.value for value in selector.ship_types], 'min_level': selector.min_level, 'max_level': selector.max_level, + 'relaxed': selector.relaxed_constraints, } diff --git a/scripts/main-services/test-combat-plan.js b/scripts/main-services/test-combat-plan.js index c840166..68f87bd 100644 --- a/scripts/main-services/test-combat-plan.js +++ b/scripts/main-services/test-combat-plan.js @@ -148,8 +148,16 @@ function testCombatPlanServices() { ' - name: 测试舰队', ' ships:', ' - name: 重庆', + ' ship_type: [CL]', + ' min_level: 20', + ' max_level: 90', + ' relaxed: true', ' candidates:', ' - name: U-47', + ' ship_type: [SS]', + ' min_level: 30', + ' max_level: 80', + ' relaxed: true', '', ].join('\n'); const saved = management.saveManaged( @@ -165,6 +173,30 @@ function testCombatPlanServices() { assert.deepEqual(yaml.load(savedMap).fleet_presets, [{ name: '测试舰队', }]); + const savedTeamContent = fs.readFileSync(path.join( + appPaths.userTeamPlansDir(), + 'team-测试舰队.yaml', + ), 'utf8'); + const savedTeam = yaml.load(savedTeamContent); + assert.equal(savedTeamContent.includes([ + ' - name: 重庆', + ' ship_type: [cl]', + ' min_level: 20', + ' max_level: 90', + ' relaxed: true', + ].join('\n')), true); + assert.equal(savedTeamContent.includes([ + '{name: U-47, ship_type: [ss], min_level: 30,', + 'max_level: 80, relaxed: true}', + ].join(' ')), true); + assert.deepEqual(savedTeam.ships[0].ship_type, ['cl']); + assert.equal(savedTeam.ships[0].min_level, 20); + assert.equal(savedTeam.ships[0].max_level, 90); + assert.equal(savedTeam.ships[0].relaxed, true); + assert.deepEqual(savedTeam.ships[0].candidates[0].ship_type, ['ss']); + assert.equal(savedTeam.ships[0].candidates[0].min_level, 30); + assert.equal(savedTeam.ships[0].candidates[0].max_level, 80); + assert.equal(savedTeam.ships[0].candidates[0].relaxed, true); // 旧 renderer 即使继续多传 system,也只能保存为用户计划。 const readonlyBoundary = management.saveManaged( @@ -205,10 +237,17 @@ function testCombatPlanServices() { prepared.runtimePath, /测试计划-123456-1\.yaml$/, ); - assert.equal( - yaml.load(prepared.content).fleet_presets[0].ships[0].name, - '重庆', - ); + const preparedPlan = yaml.load(prepared.content); + const preparedSlot = preparedPlan.fleet_presets[0].ships[0]; + assert.equal(preparedSlot.name, '重庆'); + assert.deepEqual(preparedSlot.ship_type, ['cl']); + assert.equal(preparedSlot.min_level, 20); + assert.equal(preparedSlot.max_level, 90); + assert.equal(preparedSlot.relaxed, true); + assert.deepEqual(preparedSlot.candidates[0].ship_type, ['ss']); + assert.equal(preparedSlot.candidates[0].min_level, 30); + assert.equal(preparedSlot.candidates[0].max_level, 80); + assert.equal(preparedSlot.candidates[0].relaxed, true); const duplicate = management.saveManaged( '测试计划', diff --git a/scripts/main-services/test-team-plan.js b/scripts/main-services/test-team-plan.js index d19a822..7b70cce 100644 --- a/scripts/main-services/test-team-plan.js +++ b/scripts/main-services/test-team-plan.js @@ -86,6 +86,93 @@ function testTeamPlanServices() { serializedObject.ships[0].candidates[0].customCandidate, true, ); + const relaxedRules = codec.normalize({ + name: '宽泛校验', + ships: [{ + name: '重庆', + relaxed: true, + candidates: [ + '长春', + { name: '昆西', relaxed: true }, + ], + }], + }); + assert.equal(relaxedRules.ships[0].relaxed, true); + assert.equal(relaxedRules.ships[0].candidates[0].relaxed, undefined); + assert.equal(relaxedRules.ships[0].candidates[1].relaxed, true); + const serializedRelaxedRule = yaml.load( + codec.serialize(relaxedRules), + ).ships[0]; + assert.equal(serializedRelaxedRule.relaxed, true); + assert.equal( + Object.hasOwn(serializedRelaxedRule.candidates[0], 'relaxed'), + false, + ); + assert.equal(serializedRelaxedRule.candidates[1].relaxed, true); + const orderedRelaxedContent = codec.serialize(codec.normalize({ + name: '宽泛字段顺序', + ships: [{ + relaxed: true, + max_level: 90, + min_level: 20, + ship_type: ['CL'], + name: '重庆', + candidates: [{ + relaxed: true, + max_level: 80, + min_level: 30, + ship_type: ['DD'], + name: '昆西', + }], + }], + })); + assert.equal(orderedRelaxedContent, [ + 'name: 宽泛字段顺序', + 'ships:', + ' - name: 重庆', + ' ship_type: [cl]', + ' min_level: 20', + ' max_level: 90', + ' relaxed: true', + ' candidates:', + ' - {name: 昆西, ship_type: [dd], min_level: 30, max_level: 80, relaxed: true}', + '', + ].join('\n')); + const disabledRelaxedContent = codec.serialize(codec.normalize({ + name: '关闭宽泛校验', + ships: [{ + name: '重庆', + relaxed: false, + candidates: [{ name: '昆西', relaxed: false }], + }], + })); + assert.doesNotMatch(disabledRelaxedContent, /relaxed/); + const loadedRelaxedPath = path.join( + appPaths.userTeamPlansDir(), + 'team-加载宽泛校验.yaml', + ); + repository.write(loadedRelaxedPath, orderedRelaxedContent); + const loadedRelaxedPlan = repository.read(loadedRelaxedPath); + assert.equal(loadedRelaxedPlan.ships[0].relaxed, true); + assert.equal(loadedRelaxedPlan.ships[0].candidates[0].relaxed, true); + repository.remove(loadedRelaxedPath); + assert.throws( + () => codec.normalize({ + name: '非法宽泛校验', + ships: [{ name: '重庆', relaxed: 'true' }], + }), + /relaxed 必须是布尔值/, + ); + assert.throws( + () => codec.normalize({ + name: '非法备选宽泛校验', + ships: [{ + name: '重庆', + candidates: [{ name: '昆西', relaxed: 1 }], + }], + }), + /relaxed 必须是布尔值/, + ); const legacyCandidateOnly = codec.normalize({ name: '旧版纯候选', ships: [ diff --git a/scripts/test-api-contract.js b/scripts/test-api-contract.js index a271694..a59c119 100644 --- a/scripts/test-api-contract.js +++ b/scripts/test-api-contract.js @@ -297,6 +297,23 @@ const crossRepoCases = [ ' max_level: 90', '', ].join('\n')), + buildFleetContractCase('relaxed-primary-and-candidate', [ + 'chapter: 1', + 'map: 1', + 'fleet_presets:', + ' - name: 宽泛规则', + ' ships:', + ' - name: 重庆', + ' search_name: 重庆', + ' ship_type: [kp]', + ' relaxed: true', + ' candidates:', + ' - 长春', + ' - name: 昆西', + ' ship_type: [cl]', + ' relaxed: true', + '', + ].join('\n')), ]; const legacyApiRules = crossRepoCases[1].request.plan.fleet_rules; @@ -396,4 +413,11 @@ assert.deepEqual( backendContracts[1].api[2].candidates.map(rule => rule.name), ['胡德'], ); +const relaxedApiRule = crossRepoCases[4].request.plan.fleet_rules[0]; +assert.equal(relaxedApiRule.relaxed, true); +assert.equal(relaxedApiRule.candidates[0].relaxed, undefined); +assert.equal(relaxedApiRule.candidates[1].relaxed, true); +assert.equal(backendContracts[4].api[0].primary.relaxed, true); +assert.equal(backendContracts[4].api[0].candidates[0].relaxed, false); +assert.equal(backendContracts[4].api[0].candidates[1].relaxed, true); console.log('GUI/AutoWSGR API contract tests passed'); diff --git a/scripts/test-fleet-domain.mjs b/scripts/test-fleet-domain.mjs index e6f6a96..fc41938 100644 --- a/scripts/test-fleet-domain.mjs +++ b/scripts/test-fleet-domain.mjs @@ -469,24 +469,62 @@ assert.equal( ); const positionBindingDraft = createFleetDraft(); -positionBindingDraft.slots[0].primary = testShip(10, '位置模式主选'); -positionBindingDraft.slots[0].candidates[0] = +const positionSource = positionBindingDraft.slots[0]; +positionSource.primary = testShip(10, '位置模式主选'); +positionSource.candidates[0] = createFleetCandidateDraft(testShip(21, '来源位置保留备选')); const positionTarget = positionBindingDraft.slots[1]; positionTarget.candidates[0] = createFleetCandidateDraft(testShip(22, '目标位置备选')); moveFleetPrimary(positionBindingDraft.slots, 0, 1, 'position'); -assert.equal(positionTarget.primary.name, '位置模式主选'); -assert.equal(positionTarget.candidates[0].ship.name, '目标位置备选'); +assert.equal(positionBindingDraft.slots[0], positionTarget); +assert.equal(positionBindingDraft.slots[1], positionSource); assert.equal(positionBindingDraft.slots[0].primary, null); assert.equal( positionBindingDraft.slots[0].candidates[0].ship.name, '来源位置保留备选', ); +assert.equal(positionBindingDraft.slots[1].primary.name, '位置模式主选'); +assert.equal( + positionBindingDraft.slots[1].candidates[0].ship.name, + '目标位置备选', +); + +const movedPrimaryFromReservedPosition = createFleetDraft(); +movedPrimaryFromReservedPosition.slots[0].primary = + testShip(23, '待右移主选'); +movedPrimaryFromReservedPosition.slots[0].relaxed = true; +movedPrimaryFromReservedPosition.slots[0].candidates[0] = + createFleetCandidateDraft(testShip(24, '留在位置1的备选')); +moveFleetPrimary( + movedPrimaryFromReservedPosition.slots, + 0, + 3, + 'position', +); +assert.equal(movedPrimaryFromReservedPosition.slots[0].primary, null); +assert.equal( + movedPrimaryFromReservedPosition.slots[0].candidates[0].ship.name, + '留在位置1的备选', +); +assert.equal( + movedPrimaryFromReservedPosition.slots[1].primary.name, + '待右移主选', +); +assert.equal(movedPrimaryFromReservedPosition.slots[1].relaxed, true); +assert.equal(movedPrimaryFromReservedPosition.slots[2].primary, null); +assert.equal(movedPrimaryFromReservedPosition.slots[3].primary, null); + +const movedOnlyPrimary = createFleetDraft(); +const onlyPrimary = testShip(25, '无备选主选'); +movedOnlyPrimary.slots[0].primary = onlyPrimary; +moveFleetPrimary(movedOnlyPrimary.slots, 0, 3, 'position'); +assert.equal(movedOnlyPrimary.slots[0].primary, onlyPrimary); +assert.equal(movedOnlyPrimary.slots[1].primary, null); const candidateOnlyReorderDraft = createFleetDraft(); -const candidateOnlySlots = candidateOnlyReorderDraft.slots.slice(0, 3); -candidateOnlySlots.forEach((slot, index) => { +const candidateOnlySlots = [...candidateOnlyReorderDraft.slots]; +candidateOnlySlots.slice(0, 3).forEach((slot, index) => { slot.candidates[0] = createFleetCandidateDraft( testShip(51 + index, `纯备选位置${index + 1}`), ); @@ -498,19 +536,34 @@ assert.equal( assert.equal(candidateOnlyReorderDraft.slots[0], candidateOnlySlots[2]); assert.equal(candidateOnlyReorderDraft.slots[1], candidateOnlySlots[1]); assert.equal(candidateOnlyReorderDraft.slots[2], candidateOnlySlots[0]); +assert.deepEqual( + candidateOnlyReorderDraft.slots.slice(0, 3).map(slot => ( + slot.candidates[0].ship.name + )), + ['纯备选位置1', '纯备选位置2', '纯备选位置3'], +); assert.equal( moveFleetPrimary(candidateOnlyReorderDraft.slots, 2, 0, 'ship'), - null, + candidateOnlySlots[0], +); +assert.deepEqual( + candidateOnlyReorderDraft.slots.slice(0, 3).map(slot => ( + slot.candidates[0].ship.name + )), + ['纯备选位置3', '纯备选位置2', '纯备选位置1'], ); const candidateOnlyMoveToEndDraft = createFleetDraft(); -const candidateOnlyMoveSlots = candidateOnlyMoveToEndDraft.slots.slice(0, 3); -candidateOnlyMoveSlots.forEach((slot, index) => { +const candidateOnlyMoveSlots = [...candidateOnlyMoveToEndDraft.slots]; +candidateOnlyMoveSlots.slice(0, 3).forEach((slot, index) => { slot.candidates[0] = createFleetCandidateDraft( testShip(61 + index, `移动纯备选${index + 1}`), ); }); -moveFleetPrimary(candidateOnlyMoveToEndDraft.slots, 0, 5, 'position'); +assert.equal( + moveFleetPrimary(candidateOnlyMoveToEndDraft.slots, 0, 5, 'position'), + candidateOnlyMoveSlots[0], +); assert.equal( candidateOnlyMoveToEndDraft.slots[0], candidateOnlyMoveSlots[1], @@ -523,6 +576,315 @@ assert.equal( candidateOnlyMoveToEndDraft.slots[2], candidateOnlyMoveSlots[0], ); +assert.deepEqual( + candidateOnlyMoveToEndDraft.slots.slice(0, 3).map(slot => ( + slot.candidates[0].ship.name + )), + ['移动纯备选1', '移动纯备选2', '移动纯备选3'], +); +}); + +await runScenario('位置模式重排时备选队列固定在原位置', () => { +const insertedShiftDraft = createFleetDraft(); +insertedShiftDraft.slots[0].primary = testShip(201, '位置1主选'); +insertedShiftDraft.slots[0].candidates[0] = createFleetCandidateDraft( + testShip(202, '位置1备选'), +); +insertedShiftDraft.slots[1].candidates[0] = createFleetCandidateDraft( + testShip(203, '位置2纯备选'), +); +const insertedShiftSlot = createFleetDraft().slots[0]; +insertedShiftSlot.primary = testShip(204, '插入位置2'); +insertFleetPrimary( + insertedShiftDraft.slots, + 1, + insertedShiftSlot, + 'position', +); +assert.equal(insertedShiftDraft.slots[1].primary.name, '插入位置2'); +assert.equal( + insertedShiftDraft.slots[1].candidates[0].ship.name, + '位置2纯备选', +); +assert.equal(insertedShiftDraft.slots[2].primary, null); +assert.equal( + insertedShiftDraft.slots[2].candidates.some(candidate => candidate.ship), + false, +); + +const insertedShiftPrimaryDraft = createFleetDraft(); +insertedShiftPrimaryDraft.slots[0].primary = testShip(211, '主选1'); +insertedShiftPrimaryDraft.slots[0].candidates[0] = createFleetCandidateDraft( + testShip(212, '备选1'), +); +insertedShiftPrimaryDraft.slots[1].primary = testShip(213, '主选2'); +insertedShiftPrimaryDraft.slots[1].candidates[0] = createFleetCandidateDraft( + testShip(214, '备选2'), +); +const insertedShiftPrimarySlot = createFleetDraft().slots[0]; +insertedShiftPrimarySlot.primary = testShip(215, '插入主选'); +insertFleetPrimary( + insertedShiftPrimaryDraft.slots, + 1, + insertedShiftPrimarySlot, + 'position', +); +assert.equal(insertedShiftPrimaryDraft.slots[1].primary.name, '插入主选'); +assert.equal(insertedShiftPrimaryDraft.slots[2].primary.name, '主选2'); +assert.equal( + insertedShiftPrimaryDraft.slots[1].candidates[0].ship.name, + '备选2', +); +assert.equal( + insertedShiftPrimaryDraft.slots[2].candidates + .some(candidate => candidate.ship), + false, +); +}); + +await runScenario('顶层集合卡片遵守跟随与升降级规则', () => { +const positionCandidateOnlyDraft = createFleetDraft(); +for (const [index, names] of [ + [0, ['位置1备选A', '位置1备选B', '位置1备选C']], + [1, ['位置2备选A', '位置2备选B']], +]) { + names.forEach((name, candidateIndex) => { + positionCandidateOnlyDraft.slots[index].candidates[candidateIndex] = + createFleetCandidateDraft(testShip(220 + index * 10 + candidateIndex, name)); + }); +} +const positionSourceSlot = positionCandidateOnlyDraft.slots[0]; +const positionTargetSlot = positionCandidateOnlyDraft.slots[1]; +const positionCandidateOnlyResult = applyFleetDraftEdit( + positionCandidateOnlyDraft, + { + type: 'drop-formation', + source: { group: 'formation', position: 0 }, + targetPosition: 1, + selection: { + group: 'formation', + position: 0, + candidateIndex: 0, + }, + backupFollowMode: 'position', + }, +); + +const shipCandidateOnlyDraft = createFleetDraft(); +shipCandidateOnlyDraft.slots[0].candidates[0] = createFleetCandidateDraft( + testShip(240, '舰船模式位置1备选'), +); +shipCandidateOnlyDraft.slots[1].candidates[0] = createFleetCandidateDraft( + testShip(241, '舰船模式位置2备选'), +); +const shipCandidateOnlySlots = [...shipCandidateOnlyDraft.slots]; +const shipCandidateOnlyResult = applyFleetDraftEdit(shipCandidateOnlyDraft, { + type: 'drop-formation', + source: { group: 'formation', position: 0 }, + targetPosition: 1, + selection: { + group: 'formation', + position: 0, + candidateIndex: 0, + }, + backupFollowMode: 'ship', +}); + +for (const backupFollowMode of ['ship', 'position']) { + const inheritedQueueDraft = createFleetDraft(); + const inheritedPrimary = testShip( + backupFollowMode === 'ship' ? 242 : 243, + `${backupFollowMode}模式单主选`, + ); + inheritedQueueDraft.slots[0].primary = inheritedPrimary; + inheritedQueueDraft.slots[1].candidates[0] = createFleetCandidateDraft( + testShip(244, '继承备选A'), + ); + inheritedQueueDraft.slots[1].candidates[1] = createFleetCandidateDraft( + testShip(245, '继承备选B'), + ); + const inheritedCollection = inheritedQueueDraft.slots[1]; + const inheritedQueueResult = applyFleetDraftEdit(inheritedQueueDraft, { + type: 'drop-formation', + source: { group: 'formation', position: 0 }, + targetPosition: 1, + selection: { + group: 'formation', + position: 0, + candidateIndex: 0, + }, + backupFollowMode, + }); + assert.equal(inheritedQueueResult.changed, true); + assert.equal(inheritedQueueDraft.slots[0], inheritedCollection); + assert.equal(inheritedQueueDraft.slots[0].primary, inheritedPrimary); + assert.deepEqual( + inheritedQueueDraft.slots[0].candidates + .filter(candidate => candidate.ship) + .map(candidate => candidate.ship.name), + ['继承备选A', '继承备选B'], + ); + assert.equal(isFleetSlotEmpty(inheritedQueueDraft.slots[1]), true); +} + +for (const backupFollowMode of ['ship', 'position']) { + const exchangedCollectionDraft = createFleetDraft(); + const candidateCollection = exchangedCollectionDraft.slots[0]; + candidateCollection.candidates[0] = createFleetCandidateDraft( + testShip(246, '合集备选'), + ); + const primaryCollection = exchangedCollectionDraft.slots[1]; + primaryCollection.primary = testShip(247, '合集主选'); + primaryCollection.candidates[0] = createFleetCandidateDraft( + testShip(248, '主选队列备选'), + ); + const exchangedCollectionResult = applyFleetDraftEdit( + exchangedCollectionDraft, + { + type: 'drop-formation', + source: { group: 'formation', position: 0 }, + targetPosition: 1, + selection: { + group: 'formation', + position: 0, + candidateIndex: 0, + }, + backupFollowMode, + }, + ); + assert.equal(exchangedCollectionResult.changed, true); + assert.equal(exchangedCollectionDraft.slots[0], primaryCollection); + assert.equal(exchangedCollectionDraft.slots[1], candidateCollection); + assert.equal( + exchangedCollectionDraft.slots[0].candidates[0].ship.name, + backupFollowMode === 'ship' ? '主选队列备选' : '合集备选', + ); + assert.equal( + exchangedCollectionDraft.slots[1].candidates[0].ship.name, + backupFollowMode === 'ship' ? '合集备选' : '主选队列备选', + ); +} + +const promotionDraft = createFleetDraft(); +promotionDraft.slots[0].primary = testShip(249, '待降级主选'); +promotionDraft.slots[0].candidates[0] = createFleetCandidateDraft( + testShip(250, '待升级备选'), +); +const promotionResult = applyFleetDraftEdit(promotionDraft, { + type: 'drop-formation', + source: { group: 'backup', position: 0, candidateIndex: 0 }, + targetPosition: 0, + selection: { + group: 'formation', + position: 0, + candidateIndex: 0, + }, + backupFollowMode: 'ship', +}); +assert.equal(promotionResult.changed, true); +assert.equal(promotionDraft.slots[0].primary.name, '待升级备选'); +assert.equal( + promotionDraft.slots[0].candidates[0].ship.name, + '待降级主选', +); + +const demotionDraft = createFleetDraft(); +demotionDraft.slots[0].primary = testShip(251, '主动降级主选'); +const demotionResult = applyFleetDraftEdit(demotionDraft, { + type: 'drop-backup', + source: { group: 'formation', position: 0 }, + targetPosition: 0, + targetCandidateIndex: 0, +}); +assert.equal(demotionResult.changed, true); +assert.equal(demotionDraft.slots[0].primary, null); +assert.equal( + demotionDraft.slots[0].candidates[0].ship.name, + '主动降级主选', +); + +const galleryBindingDraft = createFleetDraft(); +galleryBindingDraft.slots[0].candidates[0] = createFleetCandidateDraft( + testShip(252, '待绑定备选A'), +); +galleryBindingDraft.slots[0].candidates[1] = createFleetCandidateDraft( + testShip(253, '待绑定备选B'), +); +galleryBindingDraft.slots[0].candidates[2] = createFleetCandidateDraft( + testShip(254, '待绑定备选C'), +); +galleryBindingDraft.slots[1].primary = testShip(255, '位置2主选'); +const galleryPrimary = testShip(256, '图鉴插入主选'); +const galleryBindingResult = applyFleetDraftEdit(galleryBindingDraft, { + type: 'drop-formation', + source: { group: 'gallery', ship: galleryPrimary }, + targetPosition: 0, + selection: { + group: 'formation', + position: 0, + candidateIndex: 0, + }, + backupFollowMode: 'ship', +}); + +const promotionFocusDraft = createFleetDraft(); +promotionFocusDraft.slots[0].primary = testShip(260, '晋升来源主选'); +promotionFocusDraft.slots[0].candidates[0] = createFleetCandidateDraft( + testShip(261, '待晋升备选'), +); +promotionFocusDraft.slots[1].primary = testShip(262, '晋升前位置2主选'); +const promotionFocusResult = applyFleetDraftEdit(promotionFocusDraft, { + type: 'drop-formation', + source: { + group: 'backup', + position: 0, + candidateIndex: 0, + }, + targetPosition: 2, + selection: { + group: 'backup', + position: 0, + candidateIndex: 0, + }, + backupFollowMode: 'ship', +}); + +assert.equal(positionCandidateOnlyResult.changed, true); +assert.equal(positionCandidateOnlyDraft.slots[0], positionTargetSlot); +assert.equal(positionCandidateOnlyDraft.slots[1], positionSourceSlot); +assert.deepEqual( + positionCandidateOnlyDraft.slots.slice(0, 2).map(slot => ( + slot.candidates + .filter(candidate => candidate.ship) + .map(candidate => candidate.ship.name) + )), + [ + ['位置1备选A', '位置1备选B', '位置1备选C'], + ['位置2备选A', '位置2备选B'], + ], +); +assert.equal(shipCandidateOnlyResult.changed, true); +assert.equal(shipCandidateOnlyDraft.slots[0], shipCandidateOnlySlots[1]); +assert.equal(shipCandidateOnlyDraft.slots[1], shipCandidateOnlySlots[0]); +assert.deepEqual( + shipCandidateOnlyDraft.slots.slice(0, 2).map(slot => ( + slot.candidates[0].ship.name + )), + ['舰船模式位置2备选', '舰船模式位置1备选'], +); +assert.equal(galleryBindingResult.changed, true); +assert.equal(galleryBindingDraft.slots[0].primary, galleryPrimary); +assert.deepEqual( + galleryBindingDraft.slots[0].candidates + .filter(candidate => candidate.ship) + .map(candidate => candidate.ship.name), + ['待绑定备选A', '待绑定备选B', '待绑定备选C'], +); +assert.deepEqual(promotionFocusResult.selection, { + group: 'formation', + position: 2, + candidateIndex: 0, +}); }); await runScenario('统一编辑入口处理赋值规则拖拽复制与清空', () => { @@ -576,18 +938,21 @@ assert.equal(applyFleetDraftEdit(editorAssignmentDraft, { levelEnabled: true, minLevel: 30, maxLevel: 90, + relaxed: true, }, }).changed, true); assert.equal(editorAssignmentDraft.slots[0].levelEnabled, true); assert.equal(editorAssignmentDraft.slots[0].minLevel, 30); assert.equal(editorAssignmentDraft.slots[0].maxLevel, 90); +assert.equal(editorAssignmentDraft.slots[0].relaxed, true); assert.equal(applyFleetDraftEdit(editorAssignmentDraft, { type: 'update-rule', position: 0, candidateIndex: 0, - update: { minLevel: 50 }, + update: { minLevel: 50, relaxed: true }, }).changed, true); assert.equal(editorAssignmentDraft.slots[0].candidates[0].minLevel, 50); +assert.equal(editorAssignmentDraft.slots[0].candidates[0].relaxed, true); const editorShipFollowDraft = createFollowModeDraft(); const shipFollowResult = applyFleetDraftEdit(editorShipFollowDraft, { @@ -627,6 +992,39 @@ assert.equal( ); assert.equal(positionFollowResult.selection.position, 1); +for (const backupFollowMode of ['ship', 'position']) { + const promotedBackupDraft = createFleetDraft(); + promotedBackupDraft.slots[0].primary = testShip(123, '已有主选1'); + promotedBackupDraft.slots[1].primary = testShip(124, '已有主选2'); + promotedBackupDraft.slots[0].candidates[0] = createFleetCandidateDraft( + testShip(125, '待晋升备选'), + ); + promotedBackupDraft.slots[0].candidates[0].relaxed = true; + const promotedBackupResult = applyFleetDraftEdit(promotedBackupDraft, { + type: 'drop-formation', + source: { + group: 'backup', + position: 0, + candidateIndex: 0, + }, + targetPosition: 3, + selection: { + group: 'formation', + position: 0, + candidateIndex: 0, + }, + backupFollowMode, + }); + assert.equal(promotedBackupResult.changed, true); + assert.equal(promotedBackupDraft.slots[2].primary.name, '待晋升备选'); + assert.equal(promotedBackupDraft.slots[2].relaxed, true); + assert.equal(promotedBackupDraft.slots[3].primary, null); + assert.equal( + promotedBackupDraft.slots[0].candidates[0].ship, + null, + ); +} + const editorCrossGroupDraft = createFollowModeDraft(); const movedToBackup = applyFleetDraftEdit(editorCrossGroupDraft, { type: 'drop-backup', @@ -676,6 +1074,7 @@ editorCopyDraft.slots[0].candidates[0] = createFleetCandidateDraft( ); editorCopyDraft.slots[0].candidates[0].levelEnabled = true; editorCopyDraft.slots[0].candidates[0].minLevel = 40; +editorCopyDraft.slots[0].candidates[0].relaxed = true; assert.equal(applyFleetDraftEdit(editorCopyDraft, { type: 'copy-backups', sourcePosition: 0, @@ -686,6 +1085,7 @@ assert.equal( '复制备选', ); assert.equal(editorCopyDraft.slots[1].candidates[0].minLevel, 40); +assert.equal(editorCopyDraft.slots[1].candidates[0].relaxed, true); assert.notEqual( editorCopyDraft.slots[1].candidates[0], editorCopyDraft.slots[0].candidates[0], @@ -706,11 +1106,13 @@ persistedDraft.slots[0].primary = testShip(201, '主选持久化'); persistedDraft.slots[0].shipTypes = ['cl']; persistedDraft.slots[0].levelEnabled = true; persistedDraft.slots[0].minLevel = 20; +persistedDraft.slots[0].relaxed = true; persistedDraft.slots[0].candidates[0] = createFleetCandidateDraft( testShip(202, '主选的备选'), ); persistedDraft.slots[0].candidates[0].levelEnabled = true; persistedDraft.slots[0].candidates[0].maxLevel = 90; +persistedDraft.slots[0].candidates[0].relaxed = true; persistedDraft.slots[1].candidates[0] = createFleetCandidateDraft( testShip(203, '纯候选'), ); @@ -728,9 +1130,11 @@ assert.deepEqual(persistedPlan.ships[0], { name: '主选持久化', ship_type: ['cl'], min_level: 20, + relaxed: true, candidates: [{ name: '主选的备选', max_level: 90, + relaxed: true, }], }); assert.equal('name' in persistedPlan.ships[1], false); @@ -757,11 +1161,104 @@ assert.equal(restoredDraft.slots[1].candidates[0].ship.name, '纯候选'); assert.deepEqual(restoredDraft.slots[1].shipTypes, ['dd']); assert.equal(restoredDraft.slots[1].minLevel, 30); assert.equal(restoredDraft.slots[1].maxLevel, 80); +assert.equal(restoredDraft.slots[0].relaxed, true); +assert.equal(restoredDraft.slots[0].candidates[0].relaxed, true); assert.deepEqual( fleetDraftToTeamPlan(restoredDraft, restoredDraft.name), persistedPlan, ); +const toggledDraft = createFleetDraft(); +toggledDraft.slots[0].primary = testShip(204, '开关主选'); +toggledDraft.slots[0].candidates[0] = createFleetCandidateDraft( + testShip(205, '开关备选'), +); +applyFleetDraftEdit(toggledDraft, { + type: 'update-rule', + position: 0, + update: { relaxed: true }, +}); +applyFleetDraftEdit(toggledDraft, { + type: 'update-rule', + position: 0, + candidateIndex: 0, + update: { relaxed: true }, +}); +const enabledRelaxedPlan = fleetDraftToTeamPlan(toggledDraft, '开启宽泛校验'); +assert.equal(enabledRelaxedPlan.ships[0].relaxed, true); +assert.equal(enabledRelaxedPlan.ships[0].candidates[0].relaxed, true); +applyFleetDraftEdit(toggledDraft, { + type: 'update-rule', + position: 0, + update: { relaxed: false }, +}); +applyFleetDraftEdit(toggledDraft, { + type: 'update-rule', + position: 0, + candidateIndex: 0, + update: { relaxed: false }, +}); +const disabledRelaxedPlan = fleetDraftToTeamPlan(toggledDraft, '关闭宽泛校验'); +assert.equal(Object.hasOwn(disabledRelaxedPlan.ships[0], 'relaxed'), false); +assert.equal( + Object.hasOwn(disabledRelaxedPlan.ships[0].candidates[0], 'relaxed'), + false, +); + +let combinationId = 300; +for (const withShipType of [false, true]) { + for (const withLevel of [false, true]) { + for (const withRelaxed of [false, true]) { + const combinationDraft = createFleetDraft(); + const primary = testShip(combinationId++, `组合主选${combinationId}`); + const candidate = testShip(combinationId++, `组合备选${combinationId}`); + combinationDraft.slots[0].primary = primary; + combinationDraft.slots[0].candidates[0] = createFleetCandidateDraft( + candidate, + ); + for (const rule of [ + combinationDraft.slots[0], + combinationDraft.slots[0].candidates[0], + ]) { + rule.shipTypes = withShipType ? ['dd'] : []; + rule.levelEnabled = withLevel; + rule.minLevel = withLevel ? 20 : null; + rule.maxLevel = withLevel ? 90 : null; + rule.relaxed = withRelaxed; + } + const combinationPlan = fleetDraftToTeamPlan( + combinationDraft, + `属性组合${combinationId}`, + ); + const expectedKeys = [ + 'name', + ...(withShipType ? ['ship_type'] : []), + ...(withLevel ? ['min_level', 'max_level'] : []), + ...(withRelaxed ? ['relaxed'] : []), + ]; + assert.deepEqual(Object.keys(combinationPlan.ships[0]), [ + ...expectedKeys, + 'candidates', + ]); + assert.deepEqual( + Object.keys(combinationPlan.ships[0].candidates[0]), + expectedKeys, + ); + const loadedCombinationDraft = fleetDraftFromTeamPlan( + combinationPlan, + [primary, candidate], + ); + assert.deepEqual( + fleetDraftToTeamPlan( + loadedCombinationDraft, + combinationPlan.name, + ), + combinationPlan, + ); + } + } +} + assert.throws( () => fleetDraftToTeamPlan(createFleetDraft(), '空舰队'), /至少需要一艘/, @@ -937,6 +1434,24 @@ assert.notEqual( ], }), ); +assert.notEqual( + fleetPresetIdentityKey(existingFleetPresets[0]), + fleetPresetIdentityKey({ + name: '已有编队', + ships: [ + '海伦娜', + { + candidates: [ + { name: '昆西', ship_type: ['cl'], relaxed: true }, + { name: '克利夫兰' }, + ], + ship_type: ['cl', 'ca'], + min_level: 10, + relaxed: true, + }, + ], + }), +); }); const manifest = { @@ -1010,7 +1525,11 @@ const catalogPlans = [ source: 'system', name: '纯候选编队', ships: [{ - candidates: [{ name: '海伦娜', min_level: 100 }], + candidates: [{ + name: '海伦娜', + min_level: 100, + relaxed: true, + }], }], }, ]; @@ -1072,6 +1591,7 @@ const secondAppend = presetController.appendPreset( ); assert.ok(firstAppend); assert.ok(secondAppend); +assert.equal(firstAppend[0].ships[0].candidates[0].relaxed, true); firstAppend[0].ships[0].candidates[0].name = '修改副本'; assert.equal( secondAppend[0].ships[0].candidates[0].name, diff --git a/scripts/test-settings-persistence.js b/scripts/test-settings-persistence.js index bafb673..79cd223 100644 --- a/scripts/test-settings-persistence.js +++ b/scripts/test-settings-persistence.js @@ -157,6 +157,7 @@ async function runRendererTest(root, tempDirectory) { ocrGpuMode: 'cuda', ocrGpu: true, ocrMirror: 'github', + enhancedShipOcr: true, ocrConfidence: 0.73, shipNameAliasesText: '测试别名:U-47', shipNameCorrectionsText: '测试错字:U-81', @@ -821,6 +822,11 @@ async function runRendererTest(root, tempDirectory) { ' keep: true', '', ].join('\n')); + rendererAssert.equal( + model.current.ocr.enhanced_ship_ocr, + false, + '旧配置缺少 enhanced_ship_ocr 时必须保持默认关闭', + ); rendererAssert.equal( model.migratedGuiAutomation.lootPlanId, 'bettle-old-8-5AI六潜胖次.yaml', @@ -1255,6 +1261,7 @@ async function runRendererTest(root, tempDirectory) { rendererAssert.deepStrictEqual(savedYaml.ocr, { gpu: sample.ocrGpu, mirror: sample.ocrMirror, + enhanced_ship_ocr: sample.enhancedShipOcr, ship_name_match_confidence: sample.ocrConfidence, ship_name_corrections: { ...shipNameCorrections, @@ -1542,6 +1549,7 @@ async function runRendererTest(root, tempDirectory) { 'cfg-ocr-gpu-mode', 'cfg-cuda-path', 'cfg-ocr-gpu', + 'cfg-enhanced-ship-ocr', 'cfg-ocr-confidence-range', 'cfg-ocr-confidence', 'cfg-ship-name-aliases', diff --git a/src/controller/app/ConfigController.ts b/src/controller/app/ConfigController.ts index 6544402..b5456a5 100644 --- a/src/controller/app/ConfigController.ts +++ b/src/controller/app/ConfigController.ts @@ -340,6 +340,7 @@ export class ConfigController { ocrGpuMode: this.gateway?.getOcrGpuMode() ?? 'auto', ocrGpu: cfg.ocr.gpu, ocrMirror: cfg.ocr.mirror, + enhancedShipOcr: cfg.ocr.enhanced_ship_ocr, ocrConfidence: cfg.ocr.ship_name_match_confidence, shipNameAliasesText: formatStringMap( cfg.ocr.ship_name_aliases, @@ -510,6 +511,7 @@ export class ConfigController { ...model.current.ocr, gpu: collected.ocrGpu, mirror: collected.ocrMirror, + enhanced_ship_ocr: collected.enhancedShipOcr, ship_name_match_confidence: collected.ocrConfidence, ship_name_aliases: shipNameAliases, ship_name_corrections: shipNameCorrections, diff --git a/src/controller/plan/FleetPlannerController.ts b/src/controller/plan/FleetPlannerController.ts index e4455d5..aba07cb 100644 --- a/src/controller/plan/FleetPlannerController.ts +++ b/src/controller/plan/FleetPlannerController.ts @@ -168,6 +168,7 @@ export class FleetPlannerController { levelEnabled: rule.levelEnabled, minLevel: rule.minLevel, maxLevel: rule.maxLevel, + relaxed: rule.relaxed, }; } diff --git a/src/controller/plan/PlanController.ts b/src/controller/plan/PlanController.ts index 2bbde23..f45141a 100644 --- a/src/controller/plan/PlanController.ts +++ b/src/controller/plan/PlanController.ts @@ -264,6 +264,7 @@ export class PlanController { : undefined, min_level: slot.min_level, max_level: slot.max_level, + relaxed: slot.relaxed, } )), })); diff --git a/src/controller/plan/fleetViewObjects.ts b/src/controller/plan/fleetViewObjects.ts index a6aad9d..525ee31 100644 --- a/src/controller/plan/fleetViewObjects.ts +++ b/src/controller/plan/fleetViewObjects.ts @@ -50,6 +50,7 @@ function userTeamSlotToShipSlot(slot: UserTeamPlanSlot): ShipFilter { candidates: slot.candidates?.map(cloneShipRule), min_level: slot.min_level, max_level: slot.max_level, + relaxed: slot.relaxed, }; } @@ -117,6 +118,7 @@ export function toTeamPlanSlotViewObject( ship_type: slot.ship_type, min_level: slot.min_level, max_level: slot.max_level, + relaxed: slot.relaxed, }, primaryName, ) diff --git a/src/model/ConfigModel.ts b/src/model/ConfigModel.ts index 865a443..9a98bce 100644 --- a/src/model/ConfigModel.ts +++ b/src/model/ConfigModel.ts @@ -38,6 +38,7 @@ const DEFAULT_SETTINGS: UserSettings = { ocr: { gpu: false, mirror: 'modelscope', + enhanced_ship_ocr: false, ship_name_match_confidence: 0.65, ship_name_corrections: {}, ship_name_aliases: {}, @@ -168,6 +169,9 @@ export class ConfigModel { if (['origin', 'github', 'tencent', 'modelscope'].includes(String(ocr.mirror))) { base.ocr.mirror = String(ocr.mirror) as UserSettings['ocr']['mirror']; } + if (typeof ocr.enhanced_ship_ocr === 'boolean') { + base.ocr.enhanced_ship_ocr = ocr.enhanced_ship_ocr; + } base.ocr.ship_name_match_confidence = this.clampNumber( ocr.ship_name_match_confidence, 0, diff --git a/src/model/PlanModel.ts b/src/model/PlanModel.ts index c829e45..07eadde 100644 --- a/src/model/PlanModel.ts +++ b/src/model/PlanModel.ts @@ -431,6 +431,7 @@ export class PlanModel { 'candidates', 'min_level', 'max_level', + 'relaxed', ]; for (const key of managedKeys) delete output[key]; Object.assign(output, structuredClone(slot)); @@ -486,6 +487,7 @@ export class PlanModel { const shipTypes = PlanModel.parseShipTypes(obj.ship_type); if (shipTypes) filter.ship_type = shipTypes; PlanModel.assignLevelRange(filter, obj); + if (typeof obj.relaxed === 'boolean') filter.relaxed = obj.relaxed; const rawCandidates = Array.isArray(obj.candidates) ? obj.candidates @@ -537,6 +539,7 @@ export class PlanModel { const shipTypes = PlanModel.parseShipTypes(obj.ship_type); if (shipTypes) rule.ship_type = shipTypes; PlanModel.assignLevelRange(rule, obj); + if (typeof obj.relaxed === 'boolean') rule.relaxed = obj.relaxed; return rule; } diff --git a/src/model/fleet/FleetDraft.ts b/src/model/fleet/FleetDraft.ts index a032321..f2f83b2 100644 --- a/src/model/fleet/FleetDraft.ts +++ b/src/model/fleet/FleetDraft.ts @@ -22,6 +22,7 @@ export interface FleetRuleDraft { levelEnabled: boolean; minLevel: number | null; maxLevel: number | null; + relaxed: boolean; } export interface FleetCandidateDraft extends FleetRuleDraft { @@ -264,7 +265,8 @@ export function insertFleetCandidate( } /** - * 移动编队卡片。位置模式拖动纯备选卡时交换完整位置。 + * 移动顶层集合卡片。 + * 单主选可以接管备选合集,其余已占用集合按跟随模式交换。 */ export function moveFleetPrimary( slots: FleetSlotDraft[], @@ -277,26 +279,17 @@ export function moveFleetPrimary( if (!source || !target || isFleetSlotEmpty(source)) return null; if (sourcePosition === targetPosition) return source; - if (!source.primary) { - if (mode !== 'position') return null; - if (!isFleetSlotEmpty(target)) { - [slots[sourcePosition], slots[targetPosition]] = [ - target, - source, - ]; - return source; - } - - slots.splice(sourcePosition, 1); - const firstEmpty = slots.findIndex(isFleetSlotEmpty); - slots.splice(firstEmpty < 0 ? slots.length : firstEmpty, 0, source); - return source; - } - - if ( - mode === 'position' - && !target.primary + const targetIsCandidateCollection = ( + !target.primary && !isFleetSlotEmpty(target) + ); + const sourceHasCandidates = source.candidates.some(candidate => ( + candidate.ship !== null + )); + if ( + source.primary + && !sourceHasCandidates + && targetIsCandidateCollection ) { target.primary = source.primary; copyFleetRule(target, source); @@ -306,6 +299,25 @@ export function moveFleetPrimary( return target; } + if ( + source.primary + && mode === 'position' + && isFleetSlotEmpty(target) + ) { + const destinationPosition = resolveGalleryFormationDropTarget( + slots, + targetPosition, + ); + const destination = slots[destinationPosition]; + if (!destination || !isFleetSlotEmpty(destination)) return null; + destination.primary = source.primary; + copyFleetRule(destination, source); + source.primary = null; + copyFleetRule(source, createFleetRuleDraft()); + if (isFleetSlotEmpty(source)) compactFleetDraftSlots(slots); + return destination; + } + return reorderFleetSlots(slots, mode, () => { if (!isFleetSlotEmpty(target)) { [slots[sourcePosition], slots[targetPosition]] = [ @@ -328,6 +340,7 @@ export function createFleetRuleDraft(): FleetRuleDraft { levelEnabled: false, minLevel: null, maxLevel: null, + relaxed: false, }; } @@ -369,6 +382,7 @@ export function cloneFleetRule(source: FleetRuleDraft): FleetRuleDraft { levelEnabled: source.levelEnabled, minLevel: source.minLevel, maxLevel: source.maxLevel, + relaxed: source.relaxed, }; } @@ -380,6 +394,7 @@ export function copyFleetRule( target.levelEnabled = source.levelEnabled; target.minLevel = source.minLevel; target.maxLevel = source.maxLevel; + target.relaxed = source.relaxed; } export function fleetDraftSnapshot(draft: FleetDraft): string { @@ -388,6 +403,7 @@ export function fleetDraftSnapshot(draft: FleetDraft): string { levelEnabled: rule.levelEnabled, minLevel: rule.minLevel, maxLevel: rule.maxLevel, + relaxed: rule.relaxed, }); return JSON.stringify({ name: draft.name, @@ -451,6 +467,7 @@ function teamRuleConstraintsFromDraft( if (rule.levelEnabled && rule.maxLevel !== null) { result.max_level = rule.maxLevel; } + if (rule.relaxed) result.relaxed = true; return result; } @@ -539,6 +556,7 @@ function fleetCandidateFromTeamRule( ), minLevel: rule.min_level ?? null, maxLevel: rule.max_level ?? null, + relaxed: rule.relaxed === true, }; } @@ -573,6 +591,7 @@ function fleetSlotFromTeamPlan( ), minLevel: slot.min_level ?? null, maxLevel: slot.max_level ?? null, + relaxed: slot.relaxed === true, }; } diff --git a/src/model/fleet/FleetDraftEditor.ts b/src/model/fleet/FleetDraftEditor.ts index 883f82d..3c209db 100644 --- a/src/model/fleet/FleetDraftEditor.ts +++ b/src/model/fleet/FleetDraftEditor.ts @@ -231,6 +231,10 @@ function insertFormationShip( if (target !== targetPosition) { return assignFormationShip(draft, ship, target); } + const targetSlot = draft.slots[target]; + if (targetSlot && !targetSlot.primary && !isFleetSlotEmpty(targetSlot)) { + return assignFormationShip(draft, ship, target); + } const inserted = createFleetSlotDraft(); inserted.primary = ship; applyDefaultShipType(inserted, ship); @@ -305,17 +309,20 @@ function moveBackupToFormation( sourcePosition: number, sourceIndex: number, targetPosition: number, - selection: FleetEditorSelection, ): FleetDraftEditResult { - const focused = draft.slots[selection.position]; const source = draft.slots[sourcePosition]; - const target = draft.slots[targetPosition]; + const requestedTarget = draft.slots[targetPosition]; const candidate = source?.candidates[sourceIndex]; - if (!candidate?.ship || !target || !focused) return unchanged(); + if (!candidate?.ship || !requestedTarget) return unchanged(); + const destinationPosition = isFleetSlotEmpty(requestedTarget) + ? resolveGalleryFormationDropTarget(draft.slots, targetPosition) + : targetPosition; + const target = draft.slots[destinationPosition]; + if (!target) return unchanged(); if (hasOtherPrimaryShip( draft.slots, candidate.ship.search_name, - targetPosition, + destinationPosition, )) { return { changed: false, @@ -334,12 +341,9 @@ function moveBackupToFormation( compactCandidates(source.candidates); if (isFleetSlotEmpty(source)) compactFleetDraftSlots(draft.slots); } - const position = resolveFleetSlotPosition( - draft.slots, - focused, - selection.position, - ); - return changed(selectionAt(selection, position)); + return changed(formationSelection( + Math.max(0, draft.slots.indexOf(target)), + )); } function moveFormationToBackup( @@ -434,7 +438,6 @@ function dropOnFormation( source.position, source.candidateIndex, intent.targetPosition, - selection, ); } @@ -566,6 +569,7 @@ function updateRule( } if (update.minLevel !== undefined) target.minLevel = update.minLevel; if (update.maxLevel !== undefined) target.maxLevel = update.maxLevel; + if (update.relaxed !== undefined) target.relaxed = update.relaxed; return changed(); } diff --git a/src/model/fleet/FleetPresetIdentity.ts b/src/model/fleet/FleetPresetIdentity.ts index df7ec79..a4c195f 100644 --- a/src/model/fleet/FleetPresetIdentity.ts +++ b/src/model/fleet/FleetPresetIdentity.ts @@ -26,6 +26,7 @@ function normalizeRule(rule: ShipRule): Record { ship_type: normalizeShipTypes(rule.ship_type), min_level: rule.min_level ?? null, max_level: rule.max_level ?? null, + relaxed: rule.relaxed === true, }; } @@ -48,6 +49,7 @@ function normalizeFilter(filter: ShipFilter): Record { ship_type: normalizeShipTypes(filter.ship_type), min_level: filter.min_level ?? null, max_level: filter.max_level ?? null, + relaxed: filter.relaxed === true, }; } diff --git a/src/model/fleet/FleetRuleMapper.ts b/src/model/fleet/FleetRuleMapper.ts index 8138b4f..5905e45 100644 --- a/src/model/fleet/FleetRuleMapper.ts +++ b/src/model/fleet/FleetRuleMapper.ts @@ -22,6 +22,7 @@ function toFleetShipRule( if (rule.ship_type) result.ship_type = [...rule.ship_type]; if (rule.min_level != null && Number.isFinite(rule.min_level)) result.min_level = Math.max(1, Math.floor(rule.min_level)); if (rule.max_level != null && Number.isFinite(rule.max_level)) result.max_level = Math.max(1, Math.floor(rule.max_level)); + if (rule.relaxed === true) result.relaxed = true; return result; } @@ -56,6 +57,7 @@ export function resolveFleetPresetRules( if (slot.ship_type) rule.ship_type = [...slot.ship_type]; if (slot.min_level != null && Number.isFinite(slot.min_level)) rule.min_level = Math.max(1, Math.floor(slot.min_level)); if (slot.max_level != null && Number.isFinite(slot.max_level)) rule.max_level = Math.max(1, Math.floor(slot.max_level)); + if (slot.relaxed === true) rule.relaxed = true; if (slot.candidates?.length) { rule.candidates = slot.candidates.map( candidate => toFleetShipRule(candidate, aliases), diff --git a/src/types/api.ts b/src/types/api.ts index a022fc0..8434898 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -158,6 +158,7 @@ export interface FleetShipRuleReq { ship_type?: string[]; min_level?: number; max_level?: number; + relaxed?: boolean; } export interface FleetRuleReq extends Omit { diff --git a/src/types/fleetEditor.ts b/src/types/fleetEditor.ts index ac592aa..61e10c1 100644 --- a/src/types/fleetEditor.ts +++ b/src/types/fleetEditor.ts @@ -23,6 +23,7 @@ export interface FleetRuleUpdate { readonly levelEnabled?: boolean; readonly minLevel?: number | null; readonly maxLevel?: number | null; + readonly relaxed?: boolean; } export type FleetDraftEditIntent = diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 81ab158..2e0786d 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -99,6 +99,7 @@ export interface UserTeamShipRule { ship_type?: string[]; min_level?: number; max_level?: number; + relaxed?: boolean; } export interface UserTeamPlanSlot { @@ -107,6 +108,7 @@ export interface UserTeamPlanSlot { ship_type?: string[]; min_level?: number; max_level?: number; + relaxed?: boolean; candidates?: UserTeamShipRule[]; } diff --git a/src/types/model.ts b/src/types/model.ts index 389828a..91a6374 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -46,6 +46,7 @@ export interface DailyAutomationConfig { export interface OCRConfig { gpu: boolean; mirror: 'origin' | 'github' | 'tencent' | 'modelscope'; + enhanced_ship_ocr: boolean; ship_name_match_confidence: number; ship_name_corrections: Record; ship_name_aliases: Record; @@ -124,6 +125,7 @@ export interface ShipRule { ship_type?: string[]; min_level?: number; max_level?: number; + relaxed?: boolean; } export interface ShipFilter { @@ -134,6 +136,7 @@ export interface ShipFilter { candidates?: ShipRule[]; min_level?: number; max_level?: number; + relaxed?: boolean; } export type ShipSlot = string | ShipFilter | null; diff --git a/src/types/view.ts b/src/types/view.ts index acc5467..2d66dab 100644 --- a/src/types/view.ts +++ b/src/types/view.ts @@ -52,6 +52,7 @@ export interface ConfigViewObject { ocrGpuMode: 'auto' | 'cpu' | 'cuda'; ocrGpu: boolean; ocrMirror: 'origin' | 'github' | 'tencent' | 'modelscope'; + enhancedShipOcr: boolean; ocrConfidence: number; shipNameAliasesText: string; shipNameCorrectionsText: string; @@ -137,6 +138,7 @@ export interface FleetRuleDraftViewObject { readonly levelEnabled: boolean; readonly minLevel: number | null; readonly maxLevel: number | null; + readonly relaxed: boolean; } export interface FleetCandidateDraftViewObject diff --git a/src/view/config/ConfigView.ts b/src/view/config/ConfigView.ts index 805b8e2..fda7dfe 100644 --- a/src/view/config/ConfigView.ts +++ b/src/view/config/ConfigView.ts @@ -90,6 +90,7 @@ export class ConfigView { private ocrGpuMode = element('cfg-ocr-gpu-mode'); private ocrGpu = element('cfg-ocr-gpu'); private ocrMirror = element('cfg-ocr-mirror'); + private enhancedShipOcr = element('cfg-enhanced-ship-ocr'); private ocrConfidence = element('cfg-ocr-confidence'); private ocrConfidenceRange = element('cfg-ocr-confidence-range'); private shipNameAliases = element('cfg-ship-name-aliases'); @@ -234,6 +235,7 @@ export class ConfigView { this.ocrGpuMode.value = vo.ocrGpuMode; this.ocrGpu.checked = vo.ocrGpu; this.ocrMirror.value = vo.ocrMirror; + this.enhancedShipOcr.checked = vo.enhancedShipOcr; this.setRangeValue(this.ocrConfidenceRange, this.ocrConfidence, vo.ocrConfidence); this.shipNameAliases.value = vo.shipNameAliasesText; this.shipNameCorrections.value = vo.shipNameCorrectionsText; @@ -312,6 +314,7 @@ export class ConfigView { ocrGpuMode: this.ocrGpuMode.value as ConfigViewObject['ocrGpuMode'], ocrGpu: this.ocrGpu.checked, ocrMirror: this.ocrMirror.value as ConfigViewObject['ocrMirror'], + enhancedShipOcr: this.enhancedShipOcr.checked, ocrConfidence: this.clamp(this.ocrConfidence.value, 0, 1, 0.65), shipNameAliasesText: this.shipNameAliases.value, shipNameCorrectionsText: this.shipNameCorrections.value, diff --git a/src/view/index.html b/src/view/index.html index 8d2db84..1bd02d4 100644 --- a/src/view/index.html +++ b/src/view/index.html @@ -244,7 +244,7 @@

日志预览

-

舰队编队

+

主选队列

- -
@@ -291,6 +301,11 @@

舰船跟随 +

+
+
+ 增强船只识别 + 开启后将提升选船识别率。 +
+
+ +
+
舰名匹配置信度 diff --git a/src/view/plan/FleetEditorView.ts b/src/view/plan/FleetEditorView.ts index f09bbaf..41cceb7 100644 --- a/src/view/plan/FleetEditorView.ts +++ b/src/view/plan/FleetEditorView.ts @@ -201,13 +201,7 @@ export class FleetEditorView { if (!slot || !event.dataTransfer) return; const position = Number(slot.dataset['fleetSlot']); const sourceSlot = this.currentFleet().slots[position]; - const movableCandidateOnly = ( - this.backupFollowMode === 'position' - && sourceSlot - && !sourceSlot.primary - && !this.isSlotEmpty(sourceSlot) - ); - if (!sourceSlot?.primary && !movableCandidateOnly) return; + if (!sourceSlot || this.isSlotEmpty(sourceSlot)) return; this.rememberBackupScroll(); event.dataTransfer.effectAllowed = 'copyMove'; event.dataTransfer.setData( @@ -483,7 +477,8 @@ export class FleetEditorView { && sameShipTypes && candidate.levelEnabled === targetCandidate.levelEnabled && candidate.minLevel === targetCandidate.minLevel - && candidate.maxLevel === targetCandidate.maxLevel; + && candidate.maxLevel === targetCandidate.maxLevel + && candidate.relaxed === targetCandidate.relaxed; }); } @@ -546,12 +541,18 @@ export class FleetEditorView { update: FleetRuleUpdate, candidateIndex?: number, ): void { - this.applyEdit({ + const result = this.applyEdit({ type: 'update-rule', position: this.activePosition, candidateIndex, update, }); + if (!result.changed) return; + if (candidateIndex === undefined) { + this.renderSlots(); + } else { + this.renderBackupSlots(); + } } private renderSlots(): void { @@ -563,6 +564,7 @@ export class FleetEditorView { slot.primary, index, 'formation', + slot.relaxed, slot.primary === null && !this.isSlotEmpty(slot), )); }); @@ -575,7 +577,12 @@ export class FleetEditorView { ?? captureScrollPosition(this.backupScroll); const fragment = document.createDocumentFragment(); this.currentSlot().candidates.forEach((candidate, index) => { - fragment.append(this.createFleetSlot(candidate.ship, index, 'backup')); + fragment.append(this.createFleetSlot( + candidate.ship, + index, + 'backup', + candidate.relaxed, + )); }); this.backupSlotList.replaceChildren(fragment); restoreScrollPosition(this.backupScroll, preservedScroll); @@ -618,6 +625,7 @@ export class FleetEditorView { ship: ShipLibraryShip | null, index: number, group: FleetEditorSlotGroup, + relaxed: boolean, candidateOnly = false, ): HTMLButtonElement { const slot = document.createElement('button'); @@ -634,12 +642,7 @@ export class FleetEditorView { && index === this.activeBackupIndex; slot.classList.toggle('active', active); slot.classList.toggle('candidate-only', candidateOnly); - slot.draggable = Boolean(ship) - || ( - group === 'formation' - && candidateOnly - && this.backupFollowMode === 'position' - ); + slot.draggable = Boolean(ship) || candidateOnly; if (candidateOnly) { slot.setAttribute( 'aria-label', @@ -657,10 +660,21 @@ export class FleetEditorView { } if (ship) { - slot.append(createShipArtwork( + const artwork = createShipArtwork( ship, this.host.shipTypeDisplay(ship), - )); + ); + const validationPreview = document.createElement('span'); + validationPreview.className = 'fleet-validation-preview'; + const validationBadge = document.createElement('span'); + validationBadge.className = [ + 'fleet-validation-badge', + relaxed ? 'is-relaxed' : 'is-strict', + ].join(' '); + validationBadge.textContent = relaxed ? '弱校验' : '强校验'; + validationPreview.append(validationBadge); + artwork.append(validationPreview); + slot.append(artwork); const remove = document.createElement('span'); remove.className = 'fleet-slot-remove'; if (group === 'formation') { diff --git a/src/view/plan/FleetRuleView.ts b/src/view/plan/FleetRuleView.ts index b408356..3190168 100644 --- a/src/view/plan/FleetRuleView.ts +++ b/src/view/plan/FleetRuleView.ts @@ -28,6 +28,9 @@ export class FleetRuleView { private readonly maxLevel = document.getElementById( 'fleet-max-level', ) as HTMLInputElement; + private readonly relaxedEnabled = document.getElementById( + 'fleet-relaxed-enabled', + ) as HTMLInputElement; private readonly backupLevelEnabled = document.getElementById( 'fleet-backup-level-enabled', ) as HTMLInputElement; @@ -40,6 +43,9 @@ export class FleetRuleView { private readonly backupMaxLevel = document.getElementById( 'fleet-backup-max-level', ) as HTMLInputElement; + private readonly backupRelaxedEnabled = document.getElementById( + 'fleet-backup-relaxed-enabled', + ) as HTMLInputElement; constructor(private readonly host: FleetRuleViewHost) { this.bindActions(); @@ -59,6 +65,8 @@ export class FleetRuleView { this.maxLevel.value = primary.maxLevel === null ? '' : String(primary.maxLevel); + this.relaxedEnabled.checked = hasPrimary && primary.relaxed; + this.relaxedEnabled.disabled = !hasPrimary; this.updateLevelValidity(primary, this.minLevel, this.maxLevel); const backup = this.host.backupRule(); @@ -74,6 +82,8 @@ export class FleetRuleView { this.backupMaxLevel.value = backup.maxLevel === null ? '' : String(backup.maxLevel); + this.backupRelaxedEnabled.checked = hasBackup && backup.relaxed; + this.backupRelaxedEnabled.disabled = !hasBackup; this.updateLevelValidity( backup, this.backupMinLevel, @@ -108,6 +118,11 @@ export class FleetRuleView { this.maxLevel, ); }); + this.relaxedEnabled.addEventListener('change', () => { + this.host.updatePrimaryRule({ + relaxed: this.relaxedEnabled.checked, + }); + }); this.backupLevelEnabled.addEventListener('change', () => { const rule = this.host.backupRule(); const levelEnabled = rule.ship !== null @@ -136,6 +151,11 @@ export class FleetRuleView { this.backupMaxLevel, ); }); + this.backupRelaxedEnabled.addEventListener('change', () => { + this.host.updateBackupRule({ + relaxed: this.backupRelaxedEnabled.checked, + }); + }); } private readLevel(input: HTMLInputElement): number | null { diff --git a/src/view/styles/pages/plan/_fleet-planner.scss b/src/view/styles/pages/plan/_fleet-planner.scss index 77e8753..3131155 100644 --- a/src/view/styles/pages/plan/_fleet-planner.scss +++ b/src/view/styles/pages/plan/_fleet-planner.scss @@ -585,7 +585,7 @@ } .fleet-planner-layout > .card.fleet-planner-fleet { - padding: 3px; + padding: 5px; } .fleet-planner-fleet, @@ -610,18 +610,19 @@ } .fleet-planner-toolbar { + width: 100%; gap: 6px; padding-inline-start: 3px; - overflow-x: auto; - scrollbar-width: thin; - white-space: nowrap; + overflow: visible; + white-space: normal; } .fleet-preset-name-field { display: flex; + flex: 0 0 auto; align-items: center; gap: 5px; - min-width: 0; + margin-left: auto; color: var(--text-secondary); font-size: 10px; font-weight: 600; @@ -632,7 +633,7 @@ .input { width: 120px; - min-width: 80px; + min-width: calc(8em + 14px); padding: 4px 6px; font-size: 10px; } @@ -653,6 +654,19 @@ gap: 6px; } +.fleet-planner-toolbar .fleet-toolbar-level { + display: grid; + flex-basis: 100%; + grid-template-columns: max-content minmax(0, 1fr); + min-width: 0; +} + +.fleet-primary-rule-controls { + display: flex; + align-items: center; + gap: 6px; +} + .fleet-planner-layout .btn-small { padding: 4px 8px; border-radius: 6px; @@ -678,17 +692,31 @@ overflow: hidden; } +.fleet-validation-note { + align-self: center; + justify-self: end; + max-width: 100%; + margin: 0 0 0 auto; + color: var(--danger); + font-size: 11px; + font-weight: 600; + line-height: 1.35; + text-align: right; + white-space: normal; +} + .fleet-formation-heading { display: flex; flex: 0 0 auto; + flex-wrap: wrap; align-items: center; justify-content: flex-start; gap: 6px; + width: 100%; min-height: 30px; margin-bottom: 3px; - overflow-x: auto; - scrollbar-width: thin; - white-space: nowrap; + overflow: visible; + white-space: normal; } .fleet-backup-heading { @@ -696,6 +724,7 @@ } .fleet-backup-actions { + flex-wrap: wrap; min-width: 0; } @@ -925,6 +954,45 @@ font-size: clamp(6px, 7.8cqw, 10px); } +.fleet-validation-preview { + position: absolute; + z-index: 8; + top: 15%; + left: 4%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: clamp(2px, 2cqw, 3px); + pointer-events: none; +} + +.fleet-validation-badge { + min-width: clamp(31px, 31cqw, 45px); + padding: clamp(1px, 1.5cqw, 2px) clamp(2px, 3cqw, 4px); + overflow: hidden; + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.42); + border-radius: 4px; + box-shadow: + 0 1px 4px rgba(0, 0, 0, 0.42), + inset 0 1px 0 rgba(255, 255, 255, 0.18); + backdrop-filter: blur(7px) saturate(130%); + font-size: clamp(6px, 7.8cqw, 10px); + font-weight: 700; + line-height: 1.2; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.58); + white-space: nowrap; + + &.is-relaxed { + background: rgba(25, 117, 218, 0.76); + } + + &.is-strict { + background: rgba(198, 57, 66, 0.76); + } +} + .fleet-slot .fleet-ship-type-icon { top: 2%; right: 2%; diff --git a/src/view/styles/styles.css b/src/view/styles/styles.css index b57b17d..a516574 100644 --- a/src/view/styles/styles.css +++ b/src/view/styles/styles.css @@ -4959,7 +4959,7 @@ input[type=checkbox]:focus-visible { } .fleet-planner-layout > .card.fleet-planner-fleet { - padding: 3px; + padding: 5px; } .fleet-planner-fleet, @@ -4984,18 +4984,19 @@ input[type=checkbox]:focus-visible { } .fleet-planner-toolbar { + width: 100%; gap: 6px; padding-inline-start: 3px; - overflow-x: auto; - scrollbar-width: thin; - white-space: nowrap; + overflow: visible; + white-space: normal; } .fleet-preset-name-field { display: flex; + flex: 0 0 auto; align-items: center; gap: 5px; - min-width: 0; + margin-left: auto; color: var(--text-secondary); font-size: 10px; font-weight: 600; @@ -5005,7 +5006,7 @@ input[type=checkbox]:focus-visible { } .fleet-preset-name-field .input { width: 120px; - min-width: 80px; + min-width: calc(8em + 14px); padding: 4px 6px; font-size: 10px; } @@ -5025,6 +5026,19 @@ input[type=checkbox]:focus-visible { gap: 6px; } +.fleet-planner-toolbar .fleet-toolbar-level { + display: grid; + flex-basis: 100%; + grid-template-columns: max-content minmax(0, 1fr); + min-width: 0; +} + +.fleet-primary-rule-controls { + display: flex; + align-items: center; + gap: 6px; +} + .fleet-planner-layout .btn-small { padding: 4px 8px; border-radius: 6px; @@ -5050,17 +5064,31 @@ input[type=checkbox]:focus-visible { overflow: hidden; } +.fleet-validation-note { + align-self: center; + justify-self: end; + max-width: 100%; + margin: 0 0 0 auto; + color: var(--danger); + font-size: 11px; + font-weight: 600; + line-height: 1.35; + text-align: right; + white-space: normal; +} + .fleet-formation-heading { display: flex; flex: 0 0 auto; + flex-wrap: wrap; align-items: center; justify-content: flex-start; gap: 6px; + width: 100%; min-height: 30px; margin-bottom: 3px; - overflow-x: auto; - scrollbar-width: thin; - white-space: nowrap; + overflow: visible; + white-space: normal; } .fleet-backup-heading { @@ -5068,6 +5096,7 @@ input[type=checkbox]:focus-visible { } .fleet-backup-actions { + flex-wrap: wrap; min-width: 0; } @@ -5271,6 +5300,41 @@ input[type=checkbox]:focus-visible { font-size: clamp(6px, 7.8cqw, 10px); } +.fleet-validation-preview { + position: absolute; + z-index: 8; + top: 15%; + left: 4%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: clamp(2px, 2cqw, 3px); + pointer-events: none; +} + +.fleet-validation-badge { + min-width: clamp(31px, 31cqw, 45px); + padding: clamp(1px, 1.5cqw, 2px) clamp(2px, 3cqw, 4px); + overflow: hidden; + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.42); + border-radius: 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.42), inset 0 1px 0 rgba(255, 255, 255, 0.18); + backdrop-filter: blur(7px) saturate(130%); + font-size: clamp(6px, 7.8cqw, 10px); + font-weight: 700; + line-height: 1.2; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.58); + white-space: nowrap; +} +.fleet-validation-badge.is-relaxed { + background: rgba(25, 117, 218, 0.76); +} +.fleet-validation-badge.is-strict { + background: rgba(198, 57, 66, 0.76); +} + .fleet-slot .fleet-ship-type-icon { top: 2%; right: 2%; From 2c67af835e4fcf8da02a8d6915136d428987e3a0 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:41:22 +0800 Subject: [PATCH 02/82] =?UTF-8?q?fix(statistics):=20=E4=BB=85=E4=B8=BA?= =?UTF-8?q?=E6=98=86=E8=A5=BF=E8=A7=A6=E5=8F=91=E6=8E=89=E8=90=BD=E5=BD=A9?= =?UTF-8?q?=E8=9B=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/test-daily-sortie-stats.mjs | 19 +++++++++++++++++++ src/model/statistics/DailySortieStats.ts | 13 ++++++++----- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/scripts/test-daily-sortie-stats.mjs b/scripts/test-daily-sortie-stats.mjs index 587e88f..c01bacf 100644 --- a/scripts/test-daily-sortie-stats.mjs +++ b/scripts/test-daily-sortie-stats.mjs @@ -73,9 +73,15 @@ assert.deepEqual(snapshot.dropNotice, { dailyIndex: 28, visibleUntil: now + 60_000, }); +const firstQuincyNotice = snapshot.dropNotice; now += 2_000; stats.consume('[Combat] 获得舰船: 海伦娜', now); +assert.deepEqual( + stats.getSnapshot(now).dropNotice, + firstQuincyNotice, + '其他舰船不得触发或延长昆西彩蛋', +); now += 2_000; stats.consume('[Combat] 获得舰船: 昆西', now); const latestDropAt = now; @@ -133,4 +139,17 @@ assert.equal(nextDaySnapshot.shipCount, 0); assert.equal(nextDaySnapshot.expeditionCount, 0); assert.deepEqual(nextDaySnapshot.shipDrops, []); +// 非昆西掉落正常计数,但不得单独触发 LED 彩蛋。 +const nonQuincyDropAt = nextDay + 100; +assert.equal( + restored.consume('[Combat] 获得舰船: 海伦娜', nonQuincyDropAt), + true, +); +const nonQuincySnapshot = restored.getSnapshot(nonQuincyDropAt); +assert.equal(nonQuincySnapshot.shipCount, 1); +assert.deepEqual(nonQuincySnapshot.shipDrops, [ + { name: '海伦娜', count: 1 }, +]); +assert.equal(nonQuincySnapshot.dropNotice, null); + console.log('daily sortie stats tests passed'); diff --git a/src/model/statistics/DailySortieStats.ts b/src/model/statistics/DailySortieStats.ts index ef29024..46ca062 100644 --- a/src/model/statistics/DailySortieStats.ts +++ b/src/model/statistics/DailySortieStats.ts @@ -15,6 +15,7 @@ const STORAGE_KEY = 'daily_sortie_stats_v1'; const STORAGE_VERSION = 1; const LOG_DEDUP_WINDOW_MS = 1_200; const DROP_NOTICE_DURATION_MS = 60_000; +const QUINCY_SHIP_NAME = '昆西'; const DEFAULT_LOOT_LIMIT = 50; const DEFAULT_SHIP_LIMIT = 500; @@ -239,11 +240,13 @@ export class DailySortieStats { this.state.shipCount += 1; this.state.shipDrops[event.shipName] = (this.state.shipDrops[event.shipName] ?? 0) + 1; - this.dropNotice = { - shipName: event.shipName, - dailyIndex: this.state.shipCount, - visibleUntil: timestamp + DROP_NOTICE_DURATION_MS, - }; + if (event.shipName === QUINCY_SHIP_NAME) { + this.dropNotice = { + shipName: event.shipName, + dailyIndex: this.state.shipCount, + visibleUntil: timestamp + DROP_NOTICE_DURATION_MS, + }; + } break; case 'expedition': this.state.expeditionCount += safeCount(event.count); From 1a5ec93f0a70c5959946759b7999f4efda31e7f4 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:41:36 +0800 Subject: [PATCH 03/82] =?UTF-8?q?feat(release):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=8F=8C=E5=8F=91=E8=A1=8C=E5=8C=85=E4=B8=8E=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 13 +- build/backend-distributions/personal.json | 7 + build/backend-distributions/public.json | 7 + build/electron-builder.personal.cjs | 23 ++ build/electron-builder.public.cjs | 23 ++ build/installer-personal.nsh | 14 + electron/main.ts | 39 ++- electron/pythonEnv/backendRequirement.ts | 60 +++- electron/pythonEnv/envCheck.ts | 33 +- electron/pythonEnv/updater.ts | 35 ++- electron/services/LegacyMigrationPrompt.ts | 176 +++++++++++ electron/services/LegacyPlanMigration.ts | 62 +++- electron/services/UserDataMigrationService.ts | 66 ++-- package-lock.json | 4 +- package.json | 12 +- scripts/main-services/test-migration.js | 197 ++++++++++++ .../test-updater-and-shutdown.js | 3 +- scripts/test-backend-distributions.js | 86 +++++ scripts/test-python-environment.js | 12 +- scripts/test-release-package.js | 296 +++++++++++------- 20 files changed, 981 insertions(+), 187 deletions(-) create mode 100644 build/backend-distributions/personal.json create mode 100644 build/backend-distributions/public.json create mode 100644 build/electron-builder.personal.cjs create mode 100644 build/electron-builder.public.cjs create mode 100644 build/installer-personal.nsh create mode 100644 electron/services/LegacyMigrationPrompt.ts create mode 100644 scripts/test-backend-distributions.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 20b16dc..1f76cb8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -87,14 +87,15 @@ jobs: shell: pwsh run: | $channel = "${{ steps.version.outputs.CHANNEL }}" - $expected = Join-Path release "$channel.yml" + $publicRelease = Join-Path release "public" + $expected = Join-Path $publicRelease "$channel.yml" if (-not (Test-Path $expected)) { throw "缺少 $channel 频道更新清单: $expected" } @('latest', 'alpha', 'beta', 'dev') | Where-Object { $_ -ne $channel } | ForEach-Object { - $unexpected = Join-Path release "$_.yml" + $unexpected = Join-Path $publicRelease "$_.yml" if (Test-Path $unexpected) { throw "发布产物混入其他频道清单: $unexpected" } @@ -108,14 +109,14 @@ jobs: prerelease: ${{ steps.version.outputs.PRERELEASE == 'true' }} generate_release_notes: true files: | - release/*-Setup-*.exe - release/*.exe.blockmap - release/${{ steps.version.outputs.CHANNEL }}.yml + release/public/*-Setup-*.exe + release/public/*.exe.blockmap + release/public/${{ steps.version.outputs.CHANNEL }}.yml body: | ## AutoWSGR-GUI v${{ steps.version.outputs.VERSION }} ### 使用方法 - 1. 下载 `AutoWSGR-GUI-Setup-*.exe`(安装版) + 1. 下载 `AutoWSGR-GUI-Public-Setup-*.exe`(公用安装版) 2. 软件已内置 Python 3.12,无需单独安装 3. 首次运行时程序会自动安装 Python 依赖(需联网) 4. 已安装用户将自动收到增量更新提示 diff --git a/build/backend-distributions/personal.json b/build/backend-distributions/personal.json new file mode 100644 index 0000000..2e1fbef --- /dev/null +++ b/build/backend-distributions/personal.json @@ -0,0 +1,7 @@ +{ + "id": "personal", + "repository": "ShiinaKuroko/AutoWSGR", + "ref": "ShiinaKuroko", + "commit": "32b5cb2cb4cc20f4c1255a8d42784aaf24e1f432", + "forceUpdateOnInstall": true +} diff --git a/build/backend-distributions/public.json b/build/backend-distributions/public.json new file mode 100644 index 0000000..392b479 --- /dev/null +++ b/build/backend-distributions/public.json @@ -0,0 +1,7 @@ +{ + "id": "public", + "repository": "OpenWSGR/AutoWSGR", + "ref": "main", + "commit": "824a6203172fcc535d0aa8e4f828349fdbd48e06", + "forceUpdateOnInstall": false +} diff --git a/build/electron-builder.personal.cjs b/build/electron-builder.personal.cjs new file mode 100644 index 0000000..5fa9ea5 --- /dev/null +++ b/build/electron-builder.personal.cjs @@ -0,0 +1,23 @@ +const packageMetadata = require('../package.json'); + +const base = packageMetadata.build; + +module.exports = { + ...base, + directories: { + ...base.directories, + output: 'release/personal', + }, + extraResources: [ + ...base.extraResources, + { + from: 'build/backend-distributions/personal.json', + to: 'backend-distribution.json', + }, + ], + nsis: { + ...base.nsis, + include: 'build/installer-personal.nsh', + artifactName: 'AutoWSGR-GUI-Personal-Setup-${version}.${ext}', + }, +}; diff --git a/build/electron-builder.public.cjs b/build/electron-builder.public.cjs new file mode 100644 index 0000000..ffeea0d --- /dev/null +++ b/build/electron-builder.public.cjs @@ -0,0 +1,23 @@ +const packageMetadata = require('../package.json'); + +const base = packageMetadata.build; + +module.exports = { + ...base, + directories: { + ...base.directories, + output: 'release/public', + }, + extraResources: [ + ...base.extraResources, + { + from: 'build/backend-distributions/public.json', + to: 'backend-distribution.json', + }, + ], + nsis: { + ...base.nsis, + include: 'build/installer.nsh', + artifactName: 'AutoWSGR-GUI-Public-Setup-${version}.${ext}', + }, +}; diff --git a/build/installer-personal.nsh b/build/installer-personal.nsh new file mode 100644 index 0000000..c008bd8 --- /dev/null +++ b/build/installer-personal.nsh @@ -0,0 +1,14 @@ +; AutoWSGR-GUI 自用包安装脚本 +; 安装完成后清除环境标记,首次启动时强制重装 ShiinaKuroko 后端。 + +!macro customInstall + IfFileExists "$SYSDIR\vcruntime140.dll" VCRedistInstalled 0 + DetailPrint "正在安装 Microsoft Visual C++ Redistributable..." + nsExec::ExecToLog '"$INSTDIR\redist\vc_redist.x64.exe" /install /quiet /norestart' + Pop $0 + DetailPrint "Visual C++ Redistributable 安装完成 (exit code: $0)" + VCRedistInstalled: + + Delete "$INSTDIR\.env_ready" + DetailPrint "已安排首次启动时强制更新 ShiinaKuroko 后端" +!macroend diff --git a/electron/main.ts b/electron/main.ts index d7e2292..d613219 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -23,7 +23,14 @@ import { SafePathService } from './services/SafePathService'; import { SecureFileService } from './services/SecureFileService'; import { WindowService } from './services/WindowService'; import { SingleInstanceService } from './services/SingleInstanceService'; -import { UserDataMigrationService } from './services/UserDataMigrationService'; +import { + DEFAULT_LEGACY_MIGRATION_SELECTION, + UserDataMigrationService, + type LegacyMigrationSelection, +} from './services/UserDataMigrationService'; +import { + LegacyMigrationPrompt, +} from './services/LegacyMigrationPrompt'; import { MigrationStateStore } from './services/MigrationStateStore'; import { LEGACY_PLAN_MIGRATION_STAGE, @@ -105,9 +112,10 @@ const migrationConflictService = new MigrationConflictService( appPaths, atomicFileStore, ); -const legacyUserDataMigration = isPrimaryInstance - ? userDataMigrationService.migrateLegacyUserDataFiles() - : emptyLegacyMigrationSummary(); +let legacyUserDataMigration = emptyLegacyMigrationSummary(); +const legacyMigrationPrompt = new LegacyMigrationPrompt({ + createWindow: options => new BrowserWindow(options), +}); const guiSettingsStore = new GuiSettingsStore( () => path.join(appPaths.userDataRoot(), 'gui_settings.json'), atomicFileStore, @@ -335,7 +343,24 @@ function sendProgress(msg: string): void { if (isPrimaryInstance) initializeApplicationLifecycle(); function initializeApplicationLifecycle(): void { - app.whenReady().then(() => { + app.whenReady().then(async () => { + let migrationSelection: LegacyMigrationSelection = { + ...DEFAULT_LEGACY_MIGRATION_SELECTION, + }; + if (userDataMigrationService.shouldMigrateLegacyInstallation()) { + const selected = await legacyMigrationPrompt.show(); + if (!selected) { + app.quit(); + return; + } + migrationSelection = selected; + legacyUserDataMigration = ( + userDataMigrationService.migrateLegacyUserDataFiles( + migrationSelection, + ) + ); + } + initPythonEnv({ appRoot, sendProgress, @@ -366,7 +391,9 @@ function initializeApplicationLifecycle(): void { const presetInventoryResult = ( userDataMigrationService.migratePresetInventory() ); - const legacyPlanResult = legacyPlanMigration.migrate(); + const legacyPlanResult = legacyPlanMigration.migrate( + migrationSelection, + ); const legacyMigrationResult = mergeLegacyMigrationSummaries( legacyUserDataMigration, legacyPlanResult, diff --git a/electron/pythonEnv/backendRequirement.ts b/electron/pythonEnv/backendRequirement.ts index 3331f43..a40d7c1 100644 --- a/electron/pythonEnv/backendRequirement.ts +++ b/electron/pythonEnv/backendRequirement.ts @@ -1,14 +1,62 @@ /** * GUI 管理模式使用的 AutoWSGR 后端来源。 * - * 此提交提供 GUI 所需的 OCR 与截图环境变量契约。 - * 安装和自动更新必须共用这里的来源,避免更新后退回不兼容版本。 + * 打包版本从 resources/backend-distribution.json 读取不可变发行清单。 + * 开发环境默认使用个人 ShiinaKuroko 分支对应的已验证提交。 */ -export const MANAGED_AUTOWSGR_COMMIT = ( - 'b0f473fb1ec5318c2c4cff4795a804a3d2dd25bd' -); +import * as fs from 'fs'; +import * as path from 'path'; + +export interface BackendDistribution { + id: 'personal' | 'public'; + repository: string; + ref: string; + commit: string; + forceUpdateOnInstall: boolean; +} + +const DEFAULT_DISTRIBUTION: BackendDistribution = { + id: 'personal', + repository: 'ShiinaKuroko/AutoWSGR', + ref: 'ShiinaKuroko', + commit: '32b5cb2cb4cc20f4c1255a8d42784aaf24e1f432', + forceUpdateOnInstall: true, +}; +/** 校验打包器写入的后端发行清单,损坏时回退到开发默认值。 */ +function readBackendDistribution(): BackendDistribution { + const resourcesPath = process.resourcesPath; + if (!resourcesPath) return DEFAULT_DISTRIBUTION; + const manifestPath = path.join( + resourcesPath, + 'backend-distribution.json', + ); + try { + const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); + if ( + (raw.id === 'personal' || raw.id === 'public') + && typeof raw.repository === 'string' + && /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(raw.repository) + && typeof raw.ref === 'string' + && /^[A-Za-z0-9._/-]+$/.test(raw.ref) + && typeof raw.commit === 'string' + && /^[0-9a-f]{40}$/.test(raw.commit) + && typeof raw.forceUpdateOnInstall === 'boolean' + ) { + return raw as BackendDistribution; + } + } catch { + // 开发模式和旧包没有发行清单,继续使用已验证默认值。 + } + return DEFAULT_DISTRIBUTION; +} + +export const BACKEND_DISTRIBUTION = readBackendDistribution(); +export const MANAGED_AUTOWSGR_COMMIT = BACKEND_DISTRIBUTION.commit; export const MANAGED_AUTOWSGR_REQUIREMENT = ( - 'https://github.com/ShiinaKuroko/AutoWSGR/archive/' + `https://github.com/${BACKEND_DISTRIBUTION.repository}/archive/` + `${MANAGED_AUTOWSGR_COMMIT}.zip` ); +export const FORCE_MANAGED_AUTOWSGR_UPDATE_ON_INSTALL = ( + BACKEND_DISTRIBUTION.forceUpdateOnInstall +); diff --git a/electron/pythonEnv/envCheck.ts b/electron/pythonEnv/envCheck.ts index a3b80a0..56dcaf7 100644 --- a/electron/pythonEnv/envCheck.ts +++ b/electron/pythonEnv/envCheck.ts @@ -25,6 +25,9 @@ import { buildBackendRuntimeContractProbeLines, } from './backendContractProbe'; import { PYTHON_DEPENDENCY_SPECS } from './dependencies'; +import { + FORCE_MANAGED_AUTOWSGR_UPDATE_ON_INSTALL, +} from './backendRequirement'; const execAsync = promisify(exec); @@ -138,6 +141,16 @@ function shouldAutoUpdate(environment: PythonEnvironment): boolean { && ctx.getUpdateMode() !== 'manual'; } +/** 自用包在安装器清除环境标记后必须重新安装一次指定后端。 */ +function shouldForceManagedBackendInstall( + environment: PythonEnvironment, +): boolean { + return ( + environment.startupMode === 'managed' + && FORCE_MANAGED_AUTOWSGR_UPDATE_ON_INSTALL + ); +} + function autoUpdateSkipMessage(environment: PythonEnvironment): string { return environment.startupMode === 'external' ? '本地后端调试模式:跳过 autowsgr 自动更新检查' @@ -411,9 +424,25 @@ export async function checkEnvironment(): Promise { // 自动模式下检查并更新 autowsgr。 let finalVer = autowsgrVersion; - if (shouldAutoUpdate(environment)) { - const updatedVer = await autoUpdateAutowsgr(pythonCmd, buildAutoUpdateDeps()); + const forceBackendInstall = ( + shouldForceManagedBackendInstall(environment) + ); + if (shouldAutoUpdate(environment) || forceBackendInstall) { + const updatedVer = await autoUpdateAutowsgr( + pythonCmd, + buildAutoUpdateDeps(), + forceBackendInstall, + ); finalVer = updatedVer || autowsgrVersion; + if (forceBackendInstall && !updatedVer) { + ctx.sendProgress('WARNING 个人分支后端强制更新未完成,下次启动将重试'); + return { + pythonCmd, + pythonVersion, + missingPackages: [], + allReady: true, + }; + } } else { ctx.sendProgress(autoUpdateSkipMessage(environment)); } diff --git a/electron/pythonEnv/updater.ts b/electron/pythonEnv/updater.ts index 4a72e22..872c7a0 100644 --- a/electron/pythonEnv/updater.ts +++ b/electron/pythonEnv/updater.ts @@ -24,10 +24,12 @@ export interface AutoUpdateDeps { /** 生成 managed 后端更新参数,确保自动更新不会改装 PyPI 裸包。 */ export function buildManagedAutowsgrUpdateArgs( targetDir: string, + forceInstall = false, ): string[] { return [ '-m', 'pip', 'install', '--upgrade', + ...(forceInstall ? ['--force-reinstall'] : []), '--target', targetDir, '--no-build-isolation', '--no-deps', @@ -38,9 +40,17 @@ export function buildManagedAutowsgrUpdateArgs( } /** 确保 managed 环境使用 GUI 明确支持的后端版本。 */ -export async function autoUpdateAutowsgr(pythonCmd: string, deps: AutoUpdateDeps): Promise { +export async function autoUpdateAutowsgr( + pythonCmd: string, + deps: AutoUpdateDeps, + forceInstall = false, +): Promise { try { - deps.sendProgress('正在检查 autowsgr 更新…'); + deps.sendProgress( + forceInstall + ? '正在强制更新个人分支 autowsgr…' + : '正在检查 autowsgr 更新…', + ); // 单次 Python 调用检查本地版本、活动资源和 GUI 运行契约。 const spFwd = deps.localSitePackages().replace(/\\/g, '\\\\'); @@ -81,6 +91,8 @@ export async function autoUpdateAutowsgr(pythonCmd: string, deps: AutoUpdateDeps const supportsRuntimeContract = info.runtime_contract === true; if ( + !forceInstall + && localVer && supportsLatestEvent && supportsRuntimeContract @@ -95,15 +107,18 @@ export async function autoUpdateAutowsgr(pythonCmd: string, deps: AutoUpdateDeps ...(!supportsRuntimeContract ? ['缺少 GUI 运行契约'] : []), ]; deps.sendProgress( - `当前 autowsgr ${incompatibilities.join('、')},正在安装 GUI 兼容版本…`, + forceInstall + ? '正在重新安装本包指定的个人分支后端…' + : `当前 autowsgr ${incompatibilities.join('、')},正在安装 GUI 兼容版本…`, ); + const failureVersion = forceInstall ? null : localVer; const targetDir = deps.localSitePackages(); if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); // 确保 pip 可用。 if (!(await deps.ensurePip(pythonCmd))) { deps.sendProgress('WARNING pip 不可用,autowsgr 升级跳过'); - return localVer; + return failureVersion; } const buildDepsCode = await new Promise((resolve) => { @@ -126,13 +141,13 @@ export async function autoUpdateAutowsgr(pythonCmd: string, deps: AutoUpdateDeps }); if (buildDepsCode !== 0) { deps.sendProgress('WARNING GUI 兼容后端构建依赖安装失败'); - return localVer; + return failureVersion; } const exitCode = await new Promise((resolve) => { const proc = spawn( pythonCmd, - buildManagedAutowsgrUpdateArgs(targetDir), + buildManagedAutowsgrUpdateArgs(targetDir, forceInstall), { cwd: deps.appRoot(), windowsHide: true, @@ -148,7 +163,7 @@ export async function autoUpdateAutowsgr(pythonCmd: string, deps: AutoUpdateDeps if (exitCode !== 0) { deps.sendProgress('WARNING autowsgr 升级失败,使用当前版本继续'); - return localVer; + return failureVersion; } // 升级后一次性验证版本和关键依赖。 @@ -191,11 +206,11 @@ export async function autoUpdateAutowsgr(pythonCmd: string, deps: AutoUpdateDeps if (!eventReady) { deps.sendProgress('WARNING GUI 兼容后端安装后仍缺少 20260730 活动资源'); - return localVer; + return failureVersion; } if (!runtimeContractReady) { deps.sendProgress('WARNING GUI 兼容后端安装后仍不支持运行契约'); - return localVer; + return failureVersion; } if (missing.length > 0) { @@ -254,7 +269,7 @@ export async function autoUpdateAutowsgr(pythonCmd: string, deps: AutoUpdateDeps } deps.sendProgress('WARNING 无法验证 GUI 兼容后端安装结果'); - return localVer; + return failureVersion; } catch { deps.sendProgress('autowsgr 更新检查跳过(环境不可用或检查超时)'); return null; diff --git a/electron/services/LegacyMigrationPrompt.ts b/electron/services/LegacyMigrationPrompt.ts new file mode 100644 index 0000000..e92ae54 --- /dev/null +++ b/electron/services/LegacyMigrationPrompt.ts @@ -0,0 +1,176 @@ +/** + * 在首次启动时收集旧配置迁移类别。 + */ +import type { + BrowserWindow, + BrowserWindowConstructorOptions, +} from 'electron'; +import type { + LegacyMigrationSelection, +} from './UserDataMigrationService'; + +export interface LegacyMigrationPromptDependencies { + createWindow(options: BrowserWindowConstructorOptions): BrowserWindow; +} + +/** 只有明确提交选择时返回结果,直接关闭窗口返回 null。 */ +export class LegacyMigrationPrompt { + constructor( + private readonly dependencies: LegacyMigrationPromptDependencies, + ) {} + + show(): Promise { + return new Promise(resolve => { + const window = this.dependencies.createWindow({ + width: 500, + height: 410, + resizable: false, + maximizable: false, + minimizable: false, + show: false, + autoHideMenuBar: true, + title: '旧配置迁移', + backgroundColor: '#f5f8fc', + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }); + let settled = false; + const finish = (result: LegacyMigrationSelection | null): void => { + if (settled) return; + settled = true; + resolve(result); + if (!window.isDestroyed()) window.close(); + }; + + window.webContents.on('will-navigate', (event, target) => { + let url: URL; + try { + url = new URL(target); + } catch { + event.preventDefault(); + return; + } + if ( + url.protocol !== 'legacy-migration:' + || url.hostname !== 'decision' + ) { + event.preventDefault(); + return; + } + event.preventDefault(); + finish({ + dailyPlans: url.searchParams.get('daily') === '1', + taskQueue: url.searchParams.get('queue') === '1', + taskYamls: url.searchParams.get('tasks') === '1', + }); + }); + window.once('closed', () => finish(null)); + window.once('ready-to-show', () => window.show()); + void window.loadURL(this.pageUrl()).catch(() => finish(null)); + }); + } + + private pageUrl(): string { + const html = ` + + + + + 旧配置迁移 + + + +

发现旧版配置

+

请选择要迁移的数据。关闭此窗口不会记录决定,下次启动仍会询问。

+
设置文件会自动迁移,不需要勾选。
+
+ + + +
+
+ + +
+ + +`; + return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}`; + } +} diff --git a/electron/services/LegacyPlanMigration.ts b/electron/services/LegacyPlanMigration.ts index 6fb72e7..219d51c 100644 --- a/electron/services/LegacyPlanMigration.ts +++ b/electron/services/LegacyPlanMigration.ts @@ -12,8 +12,10 @@ import { type LegacyMigrationSummary, } from './LegacyMigrationSummary'; import { + DEFAULT_LEGACY_MIGRATION_SELECTION, PRESET_INVENTORY_MIGRATION_STAGE, UserDataMigrationService, + type LegacyMigrationSelection, type LegacyPlanReferenceTarget, } from './UserDataMigrationService'; import { MigrationStateStore } from './MigrationStateStore'; @@ -78,8 +80,12 @@ export class LegacyPlanMigration { private readonly dependencies: LegacyPlanMigrationDependencies, ) {} - /** 扫描旧安装中的有效 YAML,并返回本次实际迁移结果。 */ - migrate(): LegacyMigrationSummary { + /** 按用户选择扫描旧安装中的有效 YAML。 */ + migrate( + selection: LegacyMigrationSelection = ( + DEFAULT_LEGACY_MIGRATION_SELECTION + ), + ): LegacyMigrationSummary { const legacyDetected = ( this.userDataMigration.shouldMigrateLegacyInstallation() ); @@ -103,6 +109,7 @@ export class LegacyPlanMigration { this.migrationState.isStageComplete( LEGACY_PLAN_MIGRATION_STAGE, ) + && !legacyDetected ) { return summary; } @@ -117,14 +124,19 @@ export class LegacyPlanMigration { const state = this.migrationState.read(); const completed = new Set(state.completed); const fileMap = new Map(); - const decisiveFailed = legacyDetected + const decisiveFailed = legacyDetected && selection.dailyPlans ? this.migrateLegacyDecisiveSettings(completed, summary) : false; - const teamsFailed = legacyDetected + const teamsFailed = legacyDetected && selection.taskYamls ? this.migrateLegacyTeams(completed, summary) : false; const plansFailed = legacyDetected - ? this.migrateLegacyPlans(completed, fileMap, summary) + ? this.migrateLegacyPlans( + completed, + fileMap, + summary, + selection, + ) : false; const misplacedFailed = this.migrateMisplacedDailyPlans( misplacedDailyPlans, @@ -133,10 +145,17 @@ export class LegacyPlanMigration { summary, ); const additionalFailed = legacyDetected - ? this.migrateAdditionalYaml(completed, fileMap, summary) + ? this.migrateAdditionalYaml( + completed, + fileMap, + summary, + selection, + ) : false; - this.userDataMigration.migrateLegacyTaskGroupPlanPaths(fileMap); + if (selection.taskQueue) { + this.userDataMigration.migrateLegacyTaskGroupPlanPaths(fileMap); + } const failed = ( decisiveFailed || teamsFailed @@ -204,6 +223,7 @@ export class LegacyPlanMigration { completed: Set, fileMap: Map, summary: LegacyMigrationSummary, + selection: LegacyMigrationSelection, ): boolean { const targetDirectory = this.appPaths.userBattlePlansDir(); let failed = false; @@ -217,6 +237,7 @@ export class LegacyPlanMigration { ] ) { for (const source of this.legacyYamlFiles(legacyDirectory)) { + if (!selection.dailyPlans && !selection.taskYamls) continue; const file = path.basename(source); const content = fs.readFileSync(source, 'utf-8'); const key = this.migrationKey('plan', source, content); @@ -228,7 +249,6 @@ export class LegacyPlanMigration { ); continue; } - summary.total += 1; try { const parsed = yaml.load(content); if (!this.isPlainObject(parsed)) { @@ -237,6 +257,12 @@ export class LegacyPlanMigration { const standalone = ( this.dependencies.isStandaloneTaskPreset?.(parsed) === true ); + if ( + !this.shouldMigratePlan(parsed, standalone, selection) + ) { + continue; + } + summary.total += 1; const target = standalone ? this.migrateTaskPreset(file, content, parsed) : this.migrateCombatPlan( @@ -376,6 +402,7 @@ export class LegacyPlanMigration { completed: Set, fileMap: Map, summary: LegacyMigrationSummary, + selection: LegacyMigrationSelection, ): boolean { let failed = false; for (const source of this.recursiveLegacyYamlFiles()) { @@ -399,6 +426,13 @@ export class LegacyPlanMigration { ); const isPlan = 'chapter' in parsed && 'map' in parsed; if (!isTeam && !isPreset && !isPlan) continue; + if ( + isTeam + ? !selection.taskYamls + : !this.shouldMigratePlan(parsed, isPreset, selection) + ) { + continue; + } const kind = isTeam ? 'team' : 'plan'; const key = this.migrationKey(kind, source, content); @@ -440,6 +474,18 @@ export class LegacyPlanMigration { return failed; } + /** 将独立日常 YAML 与普通任务 YAML 映射到各自勾选项。 */ + private shouldMigratePlan( + raw: Record, + standalone: boolean, + selection: LegacyMigrationSelection, + ): boolean { + if (!standalone) return selection.taskYamls; + return this.isDailyTaskType(raw.task_type) + ? selection.dailyPlans + : selection.taskYamls; + } + private migrateAdditionalTeam(raw: Record): void { const team = this.dependencies.normalizeUserTeamPlan(raw); const resolved = this.resolveTeamWrite( diff --git a/electron/services/UserDataMigrationService.ts b/electron/services/UserDataMigrationService.ts index c121ac1..014242f 100644 --- a/electron/services/UserDataMigrationService.ts +++ b/electron/services/UserDataMigrationService.ts @@ -44,6 +44,21 @@ export type LegacyPlanReferenceTarget = taskType: 'exercise' | 'campaign' | 'decisive'; }; +/** 用户在旧配置迁移窗口中选择的数据类别。 */ +export interface LegacyMigrationSelection { + dailyPlans: boolean; + taskQueue: boolean; + taskYamls: boolean; +} + +export const DEFAULT_LEGACY_MIGRATION_SELECTION: Readonly< +LegacyMigrationSelection +> = { + dailyPlans: true, + taskQueue: true, + taskYamls: true, +}; + /** 当前旧用户数据迁移版本。 */ export const USER_DATA_MIGRATION_VERSION = 6; @@ -107,6 +122,7 @@ export class UserDataMigrationService { 'plans', 'templates', path.join('resource', 'user_battle_plans'), + path.join('resource', 'user_daily_plans'), path.join('resource', 'user_team_plans'), ].some(relativePath => fs.existsSync( path.join(legacyRoot, relativePath), @@ -182,8 +198,12 @@ export class UserDataMigrationService { return report; } - /** 合并旧设置、任务组和模板,并保留旧目录中的源文件。 */ - migrateLegacyUserDataFiles(): LegacyMigrationSummary { + /** 始终迁移设置,并按用户选择迁移任务队列及其模板。 */ + migrateLegacyUserDataFiles( + selection: LegacyMigrationSelection = ( + DEFAULT_LEGACY_MIGRATION_SELECTION + ), + ): LegacyMigrationSummary { const allowed = this.shouldMigrateLegacyInstallation(); const summary = emptyLegacyMigrationSummary(allowed); if (!allowed) return summary; @@ -216,29 +236,31 @@ export class UserDataMigrationService { path.join(targetRoot, 'gui_settings.json'), () => this.reconcilePreviouslyMigratedLootPlanSelection(), ); - this.recordResult( - summary, - path.join(legacyRoot, 'task_groups.json'), - () => this.migrateLegacyTaskGroups( + if (selection.taskQueue) { + this.recordResult( + summary, path.join(legacyRoot, 'task_groups.json'), - path.join(targetRoot, 'task_groups.json'), - ), - ); - const legacyTemplates = path.join(legacyRoot, 'templates'); - if (fs.existsSync(legacyTemplates)) { - for (const source of this.regularFiles(legacyTemplates)) { - this.recordResult( - summary, - source, - () => this.migrateTemplate( + () => this.migrateLegacyTaskGroups( + path.join(legacyRoot, 'task_groups.json'), + path.join(targetRoot, 'task_groups.json'), + ), + ); + const legacyTemplates = path.join(legacyRoot, 'templates'); + if (fs.existsSync(legacyTemplates)) { + for (const source of this.regularFiles(legacyTemplates)) { + this.recordResult( + summary, source, - path.join( - targetRoot, - 'templates', - path.relative(legacyTemplates, source), + () => this.migrateTemplate( + source, + path.join( + targetRoot, + 'templates', + path.relative(legacyTemplates, source), + ), ), - ), - ); + ); + } } } if (summary.failed === 0) { diff --git a/package-lock.json b/package-lock.json index 9853273..5b8ad6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wsgrgui", - "version": "2.0.0-alpha", + "version": "2.0.5-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wsgrgui", - "version": "2.0.0-alpha", + "version": "2.0.5-dev", "dependencies": { "electron-updater": "^6.8.3", "js-yaml": "^4.1.0", diff --git a/package.json b/package.json index 7e1ec61..2025417 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wsgrgui", - "version": "2.0.0-alpha", + "version": "2.0.5-dev", "description": "AutoWSGR YAML 管理与自动化桌面界面", "main": "dist/electron/main.js", "scripts": { @@ -28,13 +28,17 @@ "test:main-services": "npm run build && node scripts/test-main-services.js", "test:main-ipc": "npm run build && node scripts/test-main-ipc.js", "test:python-environment": "npm run build && node scripts/test-python-environment.js", + "test:backend-distributions": "npm run build && node scripts/test-backend-distributions.js", "test:release-package": "node scripts/test-release-package.js", "start": "chcp 65001 >nul && npm run build && electron .", "dev": "chcp 65001 >nul && npm run clean && npm run build:css && tsc && node scripts/bundle.js && electron .", "prepare-python": "node scripts/prepare-python.js", "prepare-adb": "node scripts/prepare-adb.js", - "pack": "npm run clean:release && npm run build && electron-builder --dir --publish never", - "dist": "npm run clean:release && npm run prepare-python && npm run prepare-adb && npm run build && electron-builder --publish never" + "pack": "npm run clean:release && npm run build && electron-builder --dir --config build/electron-builder.public.cjs --publish never", + "dist": "npm run clean:release && npm run dist:public", + "dist:personal": "npm run prepare-python && npm run prepare-adb && npm run build && electron-builder --config build/electron-builder.personal.cjs --publish never", + "dist:public": "npm run prepare-python && npm run prepare-adb && npm run build && electron-builder --config build/electron-builder.public.cjs --publish never", + "dist:all": "npm run clean:release && npm run prepare-python && npm run prepare-adb && npm run build && electron-builder --config build/electron-builder.personal.cjs --publish never && electron-builder --config build/electron-builder.public.cjs --publish never" }, "devDependencies": { "@electron/asar": "^3.4.1", @@ -143,7 +147,7 @@ "provider": "github", "owner": "yltx", "repo": "AutoWSGR-GUI", - "channel": "alpha" + "channel": "dev" } } } diff --git a/scripts/main-services/test-migration.js b/scripts/main-services/test-migration.js index d387f20..cbb857a 100644 --- a/scripts/main-services/test-migration.js +++ b/scripts/main-services/test-migration.js @@ -124,9 +124,206 @@ function testMigrationStateStore() { }); } +/** 验证分类选择、异常退出重问和明确跳过的完成边界。 */ +function testMigrationSelection() { + const root = path.join(temporaryDirectory, 'migration-selection'); + const projectRoot = path.join(root, 'project'); + const userData = path.join(root, 'user-data'); + const appPaths = new AppPaths({ + moduleDirectory: path.join(projectRoot, 'dist', 'electron'), + isPackaged: () => false, + getPath: name => name === 'exe' + ? path.join(projectRoot, 'AutoWSGR.exe') + : userData, + getResourcesPath: () => path.join(projectRoot, 'resources'), + }); + const atomicFiles = new AtomicFileStore(); + + fs.rmSync(root, { recursive: true, force: true }); + fs.mkdirSync(path.join(projectRoot, 'plans'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, 'templates'), { recursive: true }); + fs.mkdirSync( + path.join(projectRoot, 'resource', 'user_team_plans'), + { recursive: true }, + ); + fs.writeFileSync( + path.join(projectRoot, 'usersettings.yaml'), + 'legacy_setting: true\n', + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'task_groups.json'), + JSON.stringify({ version: 4, groups: [] }), + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'templates', 'legacy.json'), + '{"legacy":true}', + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'plans', 'daily.yaml'), + 'task_type: exercise\nfleet_id: 1\n', + 'utf8', + ); + fs.writeFileSync( + path.join(projectRoot, 'plans', 'battle.yaml'), + 'chapter: 1\nmap: 1\nfleet_presets: []\n', + 'utf8', + ); + fs.writeFileSync( + path.join( + projectRoot, + 'resource', + 'user_team_plans', + 'team-selection.yaml', + ), + 'name: Selection Team\nships:\n - name: 吹雪\n', + 'utf8', + ); + + const dailySelection = { + dailyPlans: true, + taskQueue: false, + taskYamls: false, + }; + const firstMigration = new UserDataMigrationService( + appPaths, + atomicFiles, + ); + firstMigration.migrateLegacyUserDataFiles(dailySelection); + firstMigration.migratePresetInventory(); + createLegacyPlanMigration( + appPaths, + atomicFiles, + firstMigration, + ).migrate(dailySelection); + + assert.equal( + fs.existsSync(path.join( + appPaths.userDailyPlansDir(), + 'exercise-队伍1演习.yaml', + )), + true, + ); + assert.equal(fs.existsSync( + path.join(userData, 'task_groups.json'), + ), false); + assert.equal(fs.existsSync( + path.join(appPaths.userBattlePlansDir(), 'bettle-battle.yaml'), + ), false); + assert.equal(fs.existsSync( + path.join(appPaths.userTeamPlansDir(), 'team-Selection Team.yaml'), + ), false); + + // 模拟计划阶段完成后、旧来源最终封存前异常退出。 + const resumedMigration = new UserDataMigrationService( + appPaths, + atomicFiles, + ); + assert.equal( + resumedMigration.shouldMigrateLegacyInstallation(), + true, + ); + const remainingSelection = { + dailyPlans: false, + taskQueue: true, + taskYamls: true, + }; + fs.mkdirSync(appPaths.userBattlePlansDir(), { recursive: true }); + fs.mkdirSync(appPaths.userTeamPlansDir(), { recursive: true }); + resumedMigration.migrateLegacyUserDataFiles(remainingSelection); + resumedMigration.migratePresetInventory(); + const resumedResult = createLegacyPlanMigration( + appPaths, + atomicFiles, + resumedMigration, + ).migrate(remainingSelection); + assert.equal(resumedResult.failed, 0); + resumedMigration.completeLegacySourceMigration(); + + assert.equal(fs.existsSync( + path.join(userData, 'task_groups.json'), + ), true); + assert.equal(fs.existsSync( + path.join(userData, 'templates', 'legacy.json'), + ), true); + assert.equal(fs.existsSync( + path.join(appPaths.userBattlePlansDir(), 'bettle-battle.yaml'), + ), true); + assert.equal( + fs.readdirSync(appPaths.userTeamPlansDir()).length, + 1, + ); + const completedMigration = new UserDataMigrationService( + appPaths, + atomicFiles, + ); + assert.equal( + completedMigration.shouldMigrateLegacyInstallation(), + false, + ); + + const skipRoot = path.join(temporaryDirectory, 'migration-skip'); + const skipProject = path.join(skipRoot, 'project'); + const skipUserData = path.join(skipRoot, 'user-data'); + const skipPaths = new AppPaths({ + moduleDirectory: path.join(skipProject, 'dist', 'electron'), + isPackaged: () => false, + getPath: name => name === 'exe' + ? path.join(skipProject, 'AutoWSGR.exe') + : skipUserData, + getResourcesPath: () => path.join(skipProject, 'resources'), + }); + fs.mkdirSync(skipProject, { recursive: true }); + fs.writeFileSync( + path.join(skipProject, 'usersettings.yaml'), + 'always_migrate: true\n', + 'utf8', + ); + fs.writeFileSync( + path.join(skipProject, 'task_groups.json'), + JSON.stringify({ version: 4, groups: [] }), + 'utf8', + ); + const skippedMigration = new UserDataMigrationService( + skipPaths, + atomicFiles, + ); + const skipSelection = { + dailyPlans: false, + taskQueue: false, + taskYamls: false, + }; + skippedMigration.migrateLegacyUserDataFiles(skipSelection); + skippedMigration.migratePresetInventory(); + createLegacyPlanMigration( + skipPaths, + atomicFiles, + skippedMigration, + ).migrate(skipSelection); + skippedMigration.completeLegacySourceMigration(); + assert.equal( + fs.existsSync(path.join(skipUserData, 'usersettings.yaml')), + true, + ); + assert.equal( + fs.existsSync(path.join(skipUserData, 'task_groups.json')), + false, + ); + assert.equal( + new UserDataMigrationService( + skipPaths, + atomicFiles, + ).shouldMigrateLegacyInstallation(), + false, + ); +} + /** 验证旧配置、旧计划和任务组引用迁移保持幂等。 */ function testUserDataMigration() { testMigrationStateStore(); + testMigrationSelection(); const migrationRoot = path.join(temporaryDirectory, 'migration'); const projectRoot = path.join(migrationRoot, 'project'); const moduleDirectory = path.join(projectRoot, 'dist', 'electron'); diff --git a/scripts/main-services/test-updater-and-shutdown.js b/scripts/main-services/test-updater-and-shutdown.js index f988047..c71f58c 100644 --- a/scripts/main-services/test-updater-and-shutdown.js +++ b/scripts/main-services/test-updater-and-shutdown.js @@ -137,8 +137,9 @@ function testGuiUpdatePolicy() { assert.match(workflow, /X\.Y\.Z-dev\.N/); assert.match( workflow, - /release\/\$\{\{ steps\.version\.outputs\.CHANNEL \}\}\.yml/, + /release\/public\/\$\{\{ steps\.version\.outputs\.CHANNEL \}\}\.yml/, ); + assert.match(workflow, /AutoWSGR-GUI-Public-Setup-/); assert.doesNotMatch(workflow, /^\s+release\/latest\.yml\s*$/m); } diff --git a/scripts/test-backend-distributions.js b/scripts/test-backend-distributions.js new file mode 100644 index 0000000..3ee5daa --- /dev/null +++ b/scripts/test-backend-distributions.js @@ -0,0 +1,86 @@ +/** + * 双发行包后端来源回归测试。 + * + * 分别模拟 personal/public 包的 resources 目录,验证运行时读取到的仓库、 + * 提交和安装强制更新策略与打包配置一致。 + */ +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const root = path.resolve(__dirname, '..'); +const modulePath = path.join( + root, + 'dist', + 'electron', + 'pythonEnv', + 'backendRequirement.js', +); +const temporaryDirectory = fs.mkdtempSync( + path.join(os.tmpdir(), 'autowsgr-backend-distributions-'), +); + +function loadDistribution(name) { + const resources = path.join(temporaryDirectory, name); + fs.mkdirSync(resources, { recursive: true }); + fs.copyFileSync( + path.join(root, 'build', 'backend-distributions', `${name}.json`), + path.join(resources, 'backend-distribution.json'), + ); + const script = [ + "Object.defineProperty(process, 'resourcesPath', {", + ' value: process.argv[1],', + '});', + 'const requirement = require(process.argv[2]);', + 'process.stdout.write(JSON.stringify({', + ' distribution: requirement.BACKEND_DISTRIBUTION,', + ' requirement: requirement.MANAGED_AUTOWSGR_REQUIREMENT,', + '}));', + ].join('\n'); + return JSON.parse(execFileSync( + process.execPath, + ['-e', script, resources, modulePath], + { encoding: 'utf8' }, + )); +} + +try { + const personal = loadDistribution('personal'); + assert.equal(personal.distribution.id, 'personal'); + assert.equal( + personal.distribution.repository, + 'ShiinaKuroko/AutoWSGR', + ); + assert.equal(personal.distribution.ref, 'ShiinaKuroko'); + assert.equal(personal.distribution.forceUpdateOnInstall, true); + assert.match(personal.requirement, /ShiinaKuroko\/AutoWSGR/); + + const publicDistribution = loadDistribution('public'); + assert.equal(publicDistribution.distribution.id, 'public'); + assert.equal( + publicDistribution.distribution.repository, + 'OpenWSGR/AutoWSGR', + ); + assert.equal(publicDistribution.distribution.ref, 'main'); + assert.equal( + publicDistribution.distribution.forceUpdateOnInstall, + false, + ); + assert.match(publicDistribution.requirement, /OpenWSGR\/AutoWSGR/); + + const personalInstaller = fs.readFileSync( + path.join(root, 'build', 'installer-personal.nsh'), + 'utf8', + ); + const publicInstaller = fs.readFileSync( + path.join(root, 'build', 'installer.nsh'), + 'utf8', + ); + assert.match(personalInstaller, /Delete "\$INSTDIR\\\.env_ready"/); + assert.doesNotMatch(publicInstaller, /\.env_ready/); + console.log('backend distribution tests passed'); +} finally { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/scripts/test-python-environment.js b/scripts/test-python-environment.js index bc09830..31e1bd1 100644 --- a/scripts/test-python-environment.js +++ b/scripts/test-python-environment.js @@ -37,6 +37,8 @@ const { buildDependencyInstallPlan, } = require('../dist/electron/pythonEnv/installer.js'); const { + BACKEND_DISTRIBUTION, + FORCE_MANAGED_AUTOWSGR_UPDATE_ON_INSTALL, MANAGED_AUTOWSGR_COMMIT, MANAGED_AUTOWSGR_REQUIREMENT, } = require('../dist/electron/pythonEnv/backendRequirement.js'); @@ -121,8 +123,11 @@ try { ); assert.equal( MANAGED_AUTOWSGR_COMMIT, - 'b0f473fb1ec5318c2c4cff4795a804a3d2dd25bd', + '32b5cb2cb4cc20f4c1255a8d42784aaf24e1f432', ); + assert.equal(BACKEND_DISTRIBUTION.id, 'personal'); + assert.equal(BACKEND_DISTRIBUTION.ref, 'ShiinaKuroko'); + assert.equal(FORCE_MANAGED_AUTOWSGR_UPDATE_ON_INSTALL, true); assert.equal( MANAGED_AUTOWSGR_REQUIREMENT.endsWith( `${MANAGED_AUTOWSGR_COMMIT}.zip`, @@ -133,6 +138,11 @@ try { assert.equal(managedUpdateArgs.at(-1), MANAGED_AUTOWSGR_REQUIREMENT); assert.equal(managedUpdateArgs.includes('autowsgr'), false); assert.equal(managedUpdateArgs.includes(localSite), true); + const forcedUpdateArgs = buildManagedAutowsgrUpdateArgs( + localSite, + true, + ); + assert.equal(forcedUpdateArgs.includes('--force-reinstall'), true); const contractProbeLines = buildBackendRuntimeContractProbeLines(); assert.equal( contractProbeLines.some( diff --git a/scripts/test-release-package.js b/scripts/test-release-package.js index 0691e34..117483c 100644 --- a/scripts/test-release-package.js +++ b/scripts/test-release-package.js @@ -21,16 +21,17 @@ const asar = require('@electron/asar'); const yaml = require('js-yaml'); const root = path.resolve(__dirname, '..'); -const unpacked = path.join(root, 'release', 'win-unpacked'); -const packagedResources = path.join( - unpacked, - 'resources', - 'resource', -); +const releaseRoot = path.join(root, 'release'); const sourceResources = path.join(root, 'resource'); const packageJson = JSON.parse( fs.readFileSync(path.join(root, 'package.json'), 'utf8'), ); +const releaseDistributions = [ + { id: 'personal', artifactLabel: 'Personal' }, + { id: 'public', artifactLabel: 'Public' }, +].filter(distribution => ( + fs.existsSync(path.join(releaseRoot, distribution.id)) +)); const v6MigrationPlans = [ 'bettle-E1炸鱼.yaml', 'bettle-E5夜战.yaml', @@ -64,7 +65,7 @@ function countFiles(directory) { return total; } -function assertResourceDirectory(name) { +function assertResourceDirectory(name, packagedResources) { const source = path.join(sourceResources, name); const packaged = path.join(packagedResources, name); const sourceCount = countFiles(source); @@ -77,129 +78,186 @@ function assertResourceDirectory(name) { ); } -assertFile( - path.join(unpacked, 'AutoWSGR-GUI.exe'), - 'GUI 可执行文件', -); -assertFile( - path.join(unpacked, 'resources', 'app.asar'), - 'GUI app.asar', -); -const asarFiles = asar.listPackage( - path.join(unpacked, 'resources', 'app.asar'), -).map(file => file.replaceAll('\\', '/').replace(/^\/+/, '')); -for (const file of [ - 'dist/electron/main.js', - 'dist/electron/preload.js', - 'dist/renderer.bundle.js', - 'dist/src/shared/taskPreset.js', - 'src/view/index.html', - 'src/view/styles/styles.css', -]) { - assert.equal(asarFiles.includes(file), true, `ASAR 缺失: ${file}`); -} -for (const directory of [ - 'dist/src/adapter/', - 'dist/src/controller/', - 'dist/src/model/', - 'dist/src/types/', - 'dist/src/utils/', - 'dist/src/view/', -]) { - assert.equal( - asarFiles.some(file => file.startsWith(directory)), - false, - `ASAR 不应重复包含 Renderer 模块: ${directory}`, +function assertReleasePackage(distribution) { + const releaseDirectory = path.join(releaseRoot, distribution.id); + const unpacked = path.join(releaseDirectory, 'win-unpacked'); + const resources = path.join(unpacked, 'resources'); + const packagedResources = path.join(resources, 'resource'); + const artifactName = ( + `AutoWSGR-GUI-${distribution.artifactLabel}` + + `-Setup-${packageJson.version}.exe` ); -} -assertFile( - path.join(unpacked, 'python', 'python.exe'), - '内置 Python', -); -assertFile( - path.join(unpacked, 'python', 'python312._pth'), - 'Python 路径配置', -); -assertFile(path.join(unpacked, 'adb', 'adb.exe'), '内置 ADB'); -assertFile( - path.join(unpacked, 'redist', 'vc_redist.x64.exe'), - 'VC++ 运行库', -); + const label = `${distribution.id} 包`; -for (const directory of [ - 'maps', - 'system_battle_plans', - 'system_team_plans', - 'system_daily_plans', - 'migrations/v6/system_battle_plans', - 'ship-library', -]) { - assertResourceDirectory(directory); -} + assertFile( + path.join(unpacked, 'AutoWSGR-GUI.exe'), + `${label} GUI 可执行文件`, + ); + const asarPath = path.join(resources, 'app.asar'); + assertFile(asarPath, `${label} GUI app.asar`); + const asarFiles = asar.listPackage(asarPath) + .map(file => file.replaceAll('\\', '/').replace(/^\/+/, '')); + for (const file of [ + 'dist/electron/main.js', + 'dist/electron/preload.js', + 'dist/renderer.bundle.js', + 'dist/src/shared/taskPreset.js', + 'src/view/index.html', + 'src/view/styles/styles.css', + ]) { + assert.equal( + asarFiles.includes(file), + true, + `${label} ASAR 缺失: ${file}`, + ); + } + for (const directory of [ + 'dist/src/adapter/', + 'dist/src/controller/', + 'dist/src/model/', + 'dist/src/types/', + 'dist/src/utils/', + 'dist/src/view/', + ]) { + assert.equal( + asarFiles.some(file => file.startsWith(directory)), + false, + `${label} ASAR 不应重复包含 Renderer 模块: ${directory}`, + ); + } -for (const file of v6MigrationPlans) { assertFile( + path.join(unpacked, 'python', 'python.exe'), + `${label}内置 Python`, + ); + assertFile( + path.join(unpacked, 'python', 'python312._pth'), + `${label} Python 路径配置`, + ); + assertFile( + path.join(unpacked, 'adb', 'adb.exe'), + `${label}内置 ADB`, + ); + assertFile( + path.join(unpacked, 'redist', 'vc_redist.x64.exe'), + `${label} VC++ 运行库`, + ); + + for (const directory of [ + 'maps', + 'system_battle_plans', + 'system_team_plans', + 'system_daily_plans', + 'migrations/v6/system_battle_plans', + 'ship-library', + ]) { + assertResourceDirectory(directory, packagedResources); + } + + for (const file of v6MigrationPlans) { + assertFile( + path.join( + packagedResources, + 'migrations', + 'v6', + 'system_battle_plans', + file, + ), + `${label} v6 迁移快照 ${file}`, + ); + } + + for (const file of [ + 'builtin_templates.json', + 'ship-library/manifest.json', + 'ship-library/labels.zh-CN.json', + 'ship-library/database/ships.sqlite3', + ]) { + assertFile( + path.join(packagedResources, file), + `${label}内置资源 ${file}`, + ); + } + + for (const directory of [ + 'user_battle_plans', + 'user_team_plans', + ]) { + assert.equal( + fs.existsSync(path.join(packagedResources, directory)), + false, + `${label}不应打入用户可写目录: ${directory}`, + ); + } + + const packagedBackendManifest = JSON.parse(fs.readFileSync( + path.join(resources, 'backend-distribution.json'), + 'utf8', + )); + const expectedBackendManifest = JSON.parse(fs.readFileSync( path.join( - packagedResources, - 'migrations', - 'v6', - 'system_battle_plans', - file, + root, + 'build', + 'backend-distributions', + `${distribution.id}.json`, ), - `v6 迁移快照 ${file}`, + 'utf8', + )); + assert.deepEqual( + packagedBackendManifest, + expectedBackendManifest, + `${label}后端发行清单不一致`, + ); + assert.match( + packagedBackendManifest.commit, + /^[0-9a-f]{40}$/, + `${label}后端必须固定到明确提交`, ); -} - -for (const file of [ - 'builtin_templates.json', - 'ship-library/manifest.json', - 'ship-library/labels.zh-CN.json', - 'ship-library/database/ships.sqlite3', -]) { - assertFile(path.join(packagedResources, file), `内置资源 ${file}`); -} -for (const directory of [ - 'user_battle_plans', - 'user_team_plans', -]) { + const channel = packageJson.build.publish.channel; + const appUpdate = yaml.load(fs.readFileSync( + path.join(resources, 'app-update.yml'), + 'utf8', + )); assert.equal( - fs.existsSync(path.join(packagedResources, directory)), - false, - `用户可写目录不应打入安装资源: ${directory}`, + appUpdate.channel, + channel, + `${label} app-update.yml 频道不一致`, ); -} - -const backendRequirement = fs.readFileSync( - path.join(root, 'electron', 'pythonEnv', 'backendRequirement.ts'), - 'utf8', -); -assert.match( - backendRequirement, - /MANAGED_AUTOWSGR_COMMIT[\s\S]*[0-9a-f]{40}/, - 'managed AutoWSGR 必须固定到明确提交', -); -const channel = packageJson.build.publish.channel; -const appUpdate = yaml.load(fs.readFileSync( - path.join(unpacked, 'resources', 'app-update.yml'), - 'utf8', -)); -assert.equal(appUpdate.channel, channel, 'app-update.yml 频道不一致'); + const channelManifestPath = path.join( + releaseDirectory, + `${channel}.yml`, + ); + assertFile(channelManifestPath, `${label} ${channel} 更新清单`); + const channelManifest = yaml.load(fs.readFileSync( + channelManifestPath, + 'utf8', + )); + assert.equal( + channelManifest.path, + artifactName, + `${label}更新清单安装包名称不一致`, + ); + assertFile( + path.join(releaseDirectory, artifactName), + `${label} NSIS 安装包`, + ); + assertFile( + path.join(releaseDirectory, `${artifactName}.blockmap`), + `${label} NSIS blockmap`, + ); -assertFile( - path.join(root, 'release', `${channel}.yml`), - `${channel} 更新清单`, -); -assertFile( - path.join( - root, - 'release', - `AutoWSGR-GUI-Setup-${packageJson.version}.exe`, - ), - 'NSIS 安装包', -); + console.log( + `${distribution.id} release package passed: ` + + `${packageJson.version} (${channel})`, + ); +} -console.log( - `release package tests passed: ${packageJson.version} (${channel})`, +assert.ok( + releaseDistributions.length > 0, + '未找到 release/personal 或 release/public 打包产物', ); +for (const distribution of releaseDistributions) { + assertReleasePackage(distribution); +} From e889de4e3e4d84caf5b95532285f7e54604c1c2f Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:41:43 +0800 Subject: [PATCH 04/82] =?UTF-8?q?docs(gui):=20=E6=9B=B4=E6=96=B0=E5=8F=91?= =?UTF-8?q?=E8=A1=8C=E4=B8=8E=E8=BF=81=E7=A7=BB=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../architecture/07-environment-management.md | 12 +++++---- docs/architecture/08-dev-setup.md | 26 ++++++++++++++----- docs/features/gui-2.0.md | 12 +++++---- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/docs/architecture/07-environment-management.md b/docs/architecture/07-environment-management.md index 170e1fd..4407712 100644 --- a/docs/architecture/07-environment-management.md +++ b/docs/architecture/07-environment-management.md @@ -18,7 +18,7 @@ Python 环境管理位于 `electron/pythonEnv/` 子目录,采用依赖注入 | 文件 | 职责 | |------|------| -| `backendRequirement.ts` | 固定 managed 模式使用的 AutoWSGR 来源,安装和自动更新共用同一契约 | +| `backendRequirement.ts` | 读取包内后端发行清单,安装和自动更新共用同一契约 | | `backendContractProbe.ts` | 在隔离检查进程中验证外部后端是否支持 GUI 所需的正式运行时契约 | | `context.ts` | 共享上下文与缓存状态(`PythonEnvContext` 接口、缓存变量) | | `dependencies.ts` | 集中声明 GUI 运行时和舰船资料库工具所需的 Python 依赖 | @@ -152,12 +152,14 @@ AutoWSGR 是否具备 GUI 所需的运行时接口和活动资源: 1. 单次隔离检查读取本地版本、活动资源和正式运行契约。 2. 已兼容时保留当前后端,不做无意义重装。 -3. 不兼容时安装 `backendRequirement.ts` 固定的 AutoWSGR 提交。 +3. 不兼容时安装包内 `backend-distribution.json` 固定的 AutoWSGR 提交。 4. 安装后重新验证运行契约、活动资源、FastAPI 和 Uvicorn。 -该流程只在 managed + 自动更新模式执行。external 模式始终使用用户选择的 -本地仓库,手动更新模式也不会在启动时改动后端。固定提交提高了 GUI 与后端的 -可复现性,但首次安装或修复不兼容环境时仍需要联网。 +公用包清单指向 `OpenWSGR/AutoWSGR` 的 `main` 提交,按兼容性决定是否更新。 +自用包清单指向 `ShiinaKuroko/AutoWSGR` 的 `ShiinaKuroko` 提交;安装脚本会 +删除 `.env_ready`,使安装后的首次完整检查使用 `--force-reinstall` 强制更新。 +强制更新成功后重新写入标记,后续启动恢复普通兼容检查;失败时不写标记,下次 +启动继续重试。external 模式始终使用用户选择的本地仓库。 --- diff --git a/docs/architecture/08-dev-setup.md b/docs/architecture/08-dev-setup.md index 52d17c9..c03c1b9 100644 --- a/docs/architecture/08-dev-setup.md +++ b/docs/architecture/08-dev-setup.md @@ -43,9 +43,13 @@ npm run dev | `npm run test:scheduler-domain` | 验证逻辑任务身份、后触发、取消和排序 | | `npm run test:ocr-log-analyzer` | 验证独立 OCR 日志提取、复核和纠错规则生成工具 | | `npm run test:python-environment` | 验证 managed/external Python、CUDA 和后端环境一致性 | +| `npm run test:backend-distributions` | 验证自用包和公用包的后端来源及强制更新策略 | | `npm run test:task-group-migration` | 构建后验证任务组迁移和往返兼容 | | `npm start` | 等同于 `build` + `electron .`(含 chcp 65001) | -| `npm run dist` | 完整打包:下载 Python + ADB → 编译 → electron-builder NSIS 安装包 | +| `npm run dist` | 完整生成公用 NSIS 安装包 | +| `npm run dist:personal` | 生成指向 ShiinaKuroko 分支的自用安装包 | +| `npm run dist:public` | 生成指向默认主库 main 的公用安装包 | +| `npm run dist:all` | 一次生成自用和公用两个安装包 | | `npm run pack` | 编译 + `electron-builder --dir`(生成目录,不打安装包) | | `npm run prepare-python` | 单独下载便携版 Python | | `npm run prepare-adb` | 单独下载 ADB 工具 | @@ -118,7 +122,10 @@ flowchart LR } ``` -**打包目标**:Windows NSIS 安装包 (`release/AutoWSGR-GUI-Setup-x.x.x.exe`) +**打包目标**:Windows NSIS 安装包: + +- 自用包:`release/personal/AutoWSGR-GUI-Personal-Setup-x.x.x.exe` +- 公用包:`release/public/AutoWSGR-GUI-Public-Setup-x.x.x.exe` **包含内容**: - `dist/` — 编译后的 JS @@ -133,7 +140,9 @@ flowchart LR ### NSIS 自定义 -`build/installer.nsh` 包含 NSIS 安装程序的自定义脚本(如安装向导页面定制)。 +`build/installer.nsh` 用于公用包;`build/installer-personal.nsh` 还会清除 +`.env_ready`,使自用包首次启动时强制更新个人分支后端。两个包分别携带 +`backend-distribution.json`,版本号相同也不会混用后端来源。 --- @@ -157,14 +166,17 @@ flowchart LR | `userData/task_groups.json` | Electron userData | Electron userData | | `userData/templates/` | Electron userData | Electron userData | -启动迁移会扫描本次运行所在的旧项目根目录。即使 `userData` 已存在,也会 -深度合并旧设置,并迁移旧任务组、模板和递归扫描发现的有效计划 YAML。 +启动迁移会扫描本次运行所在的旧项目根目录。未初始化的 `userData` 或同一来源 +未完成的迁移会先显示分类选择窗口;旧设置始终深度合并,用户可分别选择日常 +任务 YAML、任务队列、任务 YAML。任务队列选项包含队列依赖的自定义模板,任务 +YAML 选项包含作战计划引用的编队 YAML。 旧字段覆盖同名当前字段,当前版本独有字段继续保留。不同内容的同名任务组、 计划、舰队和模板以“(旧版)”保留,不会覆盖现有文件。迁移按旧来源路径和 内容哈希记录完成状态及实际输出文件名,状态保存在 `userData/.migration-state.json`,并由 `MigrationStateStore` 独占读写。源文件 -不会删除;本次实际执行迁移后会弹窗展示总数、成功数、失败数,失败项在下次 -启动继续尝试。 +不会删除。关闭选择窗口或迁移未完整结束时异常退出,不封存旧来源,下次启动 +仍会询问;明确不迁移或所选项目全部完成后才停止询问。本次实际执行迁移后会 +弹窗展示总数、成功数、失败数,失败项在下次启动继续尝试。 迁移状态最高版本当前为 **v7**。`UserDataMigrationService` 维护旧来源和 v6 库存迁移,升级系统预设库存、保存仍被引用的已删除系统计划,并把旧胖次数字 diff --git a/docs/features/gui-2.0.md b/docs/features/gui-2.0.md index 35baddf..66b43f9 100644 --- a/docs/features/gui-2.0.md +++ b/docs/features/gui-2.0.md @@ -574,10 +574,12 @@ fleet_presets: 保持不变;同名目标必须由用户确认后才能覆盖。旧格式不会作为新编辑器的 输出格式,外部文件路径也不会直接进入任务队列。 -v5 启动迁移还会递归识别旧安装目录中的有效计划 YAML,并合并旧设置、任务组 -和模板。旧字段覆盖同名字段,当前版本新增字段保留默认值。迁移状态按来源 -路径和内容哈希记录,失败项下次启动重试;本次有实际迁移项时弹窗展示总数、 -成功数、失败数,并说明旧版原始文件均已保留。 +v5 启动迁移还会递归识别旧安装目录中的有效计划 YAML。旧设置始终合并;首次 +启动窗口允许分别勾选日常任务 YAML、任务队列和任务 YAML,队列依赖模板与计划 +引用编队跟随对应类别。旧字段覆盖同名字段,当前版本新增字段保留默认值。 +关闭选择窗口或迁移中异常退出不会封存来源,下次启动继续询问;明确不迁移或 +所选项目全部完成后不再询问。本次有实际迁移项时弹窗展示总数、成功数、失败数, +并说明旧版原始文件均已保留。 ## 8. 计划管理 @@ -1093,7 +1095,7 @@ YAML 往返、IPC 和构建。不同 Windows 缩放比例仍建议在发布前 ### 18.1 旧出征计划 -- v5 启动迁移自动升级,不再提供手工转换入口。 +- v5 启动迁移先提供三类勾选,不再提供独立手工转换入口。 - 不覆盖不同内容的同名目标,旧内容保存为“(旧版)”副本。 - 迁移后使用标准受管目录和 `bettle-*`、`team-*` 文件名。 - 旧源文件保留,迁移失败会在下次启动重试。 From f456a2fc4e1205bc8dc323193521e8971ec7b684 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Thu, 6 Aug 2026 03:54:54 +0800 Subject: [PATCH 05/82] =?UTF-8?q?fix(security):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=B7=A8=E5=B9=B3=E5=8F=B0=E7=9B=98=E7=AC=A6=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/SafePathService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/electron/services/SafePathService.ts b/electron/services/SafePathService.ts index 8bce89c..0e45f83 100644 --- a/electron/services/SafePathService.ts +++ b/electron/services/SafePathService.ts @@ -33,10 +33,10 @@ export class SafePathService { if (/^[\\/]{2}/.test(raw)) throw new Error('不允许使用 UNC 路径'); const hasDrivePrefix = /^[a-zA-Z]:/.test(raw); + const windowsAbsolute = path.win32.isAbsolute(raw); const nativeAbsolute = path.isAbsolute(raw); - const portableAbsolute = path.win32.isAbsolute(raw) - || path.posix.isAbsolute(raw); - if (hasDrivePrefix && !nativeAbsolute) { + const portableAbsolute = windowsAbsolute || path.posix.isAbsolute(raw); + if (hasDrivePrefix && !windowsAbsolute) { throw new Error('不允许使用盘符相对路径'); } if (portableAbsolute && !nativeAbsolute) { From 3eb8c532a49d929ac298156587881f669c624891 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:20:37 +0800 Subject: [PATCH 06/82] fix(python): install FastOCR runtime dependencies --- electron/pythonEnv/dependencies.ts | 10 ++++++ electron/pythonEnv/envCheck.ts | 2 ++ electron/pythonEnv/installer.ts | 38 +++++++++++++++++++--- electron/pythonEnv/updater.ts | 51 ++++++++++++++++++++++++++++-- scripts/test-python-environment.js | 12 +++++++ src/view/index.html | 2 +- 6 files changed, 106 insertions(+), 9 deletions(-) diff --git a/electron/pythonEnv/dependencies.ts b/electron/pythonEnv/dependencies.ts index 828916a..1909d3b 100644 --- a/electron/pythonEnv/dependencies.ts +++ b/electron/pythonEnv/dependencies.ts @@ -25,6 +25,11 @@ export const PYTHON_DEPENDENCY_SPECS = Object.freeze([ importName: 'bs4', packageName: 'beautifulsoup4', }, + { + key: 'maafw', + importName: 'maa', + packageName: 'maafw>=5.12.3,<6.0', + }, ] as const); /** 舰船资料库更新器不由 AutoWSGR 后端依赖间接保证的包。 */ @@ -32,3 +37,8 @@ export const SHIP_LIBRARY_REQUIREMENTS: readonly string[] = Object.freeze([ 'requests>=2.32.5', 'beautifulsoup4>=4.12.0', ]); + +/** 使用 --no-deps 更新后端前必须显式安装的运行依赖。 */ +export const BACKEND_RUNTIME_REQUIREMENTS: readonly string[] = Object.freeze([ + 'maafw>=5.12.3,<6.0', +]); diff --git a/electron/pythonEnv/envCheck.ts b/electron/pythonEnv/envCheck.ts index 56dcaf7..8b447df 100644 --- a/electron/pythonEnv/envCheck.ts +++ b/electron/pythonEnv/envCheck.ts @@ -163,6 +163,7 @@ type CoreDepProbeResult = { scipy: boolean; requests: boolean; beautifulSoup: boolean; + maafw: boolean; autowsgr: string | null; backendRuntimeContract: boolean; }; @@ -252,6 +253,7 @@ async function probeCoreDependencies( scipy: Boolean(depResult.scipy), requests: Boolean(depResult.requests), beautifulSoup: Boolean(depResult.beautifulSoup), + maafw: Boolean(depResult.maafw), autowsgr: depResult.autowsgr == null || !usesExpectedAutowsgr ? null : String(depResult.autowsgr), diff --git a/electron/pythonEnv/installer.ts b/electron/pythonEnv/installer.ts index 60d7efe..bdd2500 100644 --- a/electron/pythonEnv/installer.ts +++ b/electron/pythonEnv/installer.ts @@ -15,8 +15,12 @@ import { type PythonEnvironment, resolvePythonEnvironment, } from './environment'; -import { SHIP_LIBRARY_REQUIREMENTS } from './dependencies'; +import { + BACKEND_RUNTIME_REQUIREMENTS, + SHIP_LIBRARY_REQUIREMENTS, +} from './dependencies'; import { MANAGED_AUTOWSGR_REQUIREMENT } from './backendRequirement'; +import { buildBackendRuntimeInstallArgs } from './updater'; const execAsync = promisify(exec); @@ -194,6 +198,7 @@ export function buildDependencyInstallPlan( '--upgrade', ...targetArgs, ...SHIP_LIBRARY_REQUIREMENTS, + ...BACKEND_RUNTIME_REQUIREMENTS, ], backendArgs: [ '-m', 'pip', 'install', @@ -274,7 +279,7 @@ export async function installDependencies(pythonCmd: string): Promise<{ success: return { success: false, output: buildDeps.output.slice(-500) }; } - ctx.sendProgress('正在安装舰船资料库更新依赖…'); + ctx.sendProgress('正在安装工具与后端运行依赖…'); const toolDeps = await runPip(installPlan.toolArgs); if (toolDeps.code !== 0) { ctx.sendProgress('ERROR 舰船资料库更新依赖安装失败'); @@ -313,10 +318,33 @@ export async function pullUpdates(): Promise<{ success: boolean; output: string return { success: false, output: `活动热修复构建依赖安装失败: ${output}` }; } - return new Promise((resolve) => { - const targetDir = localSitePackages(); - if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); + const targetDir = localSitePackages(); + if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); + const runtimeDeps = await new Promise<{ code: number; output: string }>((resolve) => { + const proc = spawn( + pythonCmd, + buildBackendRuntimeInstallArgs(targetDir), + { + cwd: ctx.appRoot(), + windowsHide: true, + stdio: 'pipe', + env: pipEnv(), + }, + ); + let output = ''; + proc.stdout?.on('data', (d: Buffer) => { output += d.toString(); }); + proc.stderr?.on('data', (d: Buffer) => { output += d.toString(); }); + proc.on('close', (code) => resolve({ code: code ?? 1, output })); + proc.on('error', (error) => resolve({ code: 1, output: error.message })); + }); + if (runtimeDeps.code !== 0) { + return { + success: false, + output: `后端运行依赖安装失败: ${runtimeDeps.output.slice(-500)}`, + }; + } + return new Promise((resolve) => { // 先删除旧版,再安装且不级联更新依赖。 try { for (const entry of fs.readdirSync(targetDir)) { diff --git a/electron/pythonEnv/updater.ts b/electron/pythonEnv/updater.ts index 872c7a0..37b9f58 100644 --- a/electron/pythonEnv/updater.ts +++ b/electron/pythonEnv/updater.ts @@ -9,6 +9,10 @@ import { MANAGED_AUTOWSGR_REQUIREMENT } from './backendRequirement'; import { buildBackendRuntimeContractProbeLines, } from './backendContractProbe'; +import { + BACKEND_RUNTIME_REQUIREMENTS, + PYTHON_DEPENDENCY_SPECS, +} from './dependencies'; const execAsync = promisify(exec); @@ -21,6 +25,20 @@ export interface AutoUpdateDeps { ensurePip: (pythonCmd: string) => Promise; } +/** 生成后端运行依赖安装参数,允许 pip 拉取传递依赖。 */ +export function buildBackendRuntimeInstallArgs( + targetDir: string, +): string[] { + return [ + '-m', 'pip', 'install', + '--upgrade', + '--target', targetDir, + '-i', 'https://pypi.tuna.tsinghua.edu.cn/simple', + '--trusted-host', 'pypi.tuna.tsinghua.edu.cn', + ...BACKEND_RUNTIME_REQUIREMENTS, + ]; +} + /** 生成 managed 后端更新参数,确保自动更新不会改装 PyPI 裸包。 */ export function buildManagedAutowsgrUpdateArgs( targetDir: string, @@ -144,6 +162,28 @@ export async function autoUpdateAutowsgr( return failureVersion; } + deps.sendProgress('正在安装后端运行依赖…'); + const runtimeDepsCode = await new Promise((resolve) => { + const proc = spawn( + pythonCmd, + buildBackendRuntimeInstallArgs(targetDir), + { + cwd: deps.appRoot(), + windowsHide: true, + stdio: 'pipe', + env: deps.pipEnv(), + }, + ); + proc.stdout?.on('data', (d: Buffer) => { for (const l of d.toString().split('\n')) { if (l.trim()) deps.sendProgress(l.trim()); } }); + proc.stderr?.on('data', (d: Buffer) => { for (const l of d.toString().split('\n')) { if (l.trim()) deps.sendProgress(l.trim()); } }); + proc.on('close', (code) => resolve(code ?? 1)); + proc.on('error', () => resolve(1)); + }); + if (runtimeDepsCode !== 0) { + deps.sendProgress('WARNING 后端运行依赖安装失败'); + return failureVersion; + } + const exitCode = await new Promise((resolve) => { const proc = spawn( pythonCmd, @@ -186,9 +226,14 @@ export async function autoUpdateAutowsgr( ' r["runtime_contract"] = True', 'except Exception:', ' r["runtime_contract"] = False', - "for m in ['fastapi', 'uvicorn']:", - ' try: __import__(m)', - ' except Exception: r["missing"].append(m)', + `checks = ${JSON.stringify( + PYTHON_DEPENDENCY_SPECS.map( + dependency => [dependency.importName, dependency.packageName], + ), + )}`, + 'for mod, package in checks:', + ' try: __import__(mod)', + ' except Exception: r["missing"].append(package)', 'print(json.dumps(r))', ].join('\n'), 'utf-8'); diff --git a/scripts/test-python-environment.js b/scripts/test-python-environment.js index 31e1bd1..5263757 100644 --- a/scripts/test-python-environment.js +++ b/scripts/test-python-environment.js @@ -43,6 +43,7 @@ const { MANAGED_AUTOWSGR_REQUIREMENT, } = require('../dist/electron/pythonEnv/backendRequirement.js'); const { + buildBackendRuntimeInstallArgs, buildManagedAutowsgrUpdateArgs, } = require('../dist/electron/pythonEnv/updater.js'); const { @@ -121,6 +122,10 @@ try { managedPlan.toolArgs.includes('beautifulsoup4>=4.12.0'), true, ); + assert.equal( + managedPlan.toolArgs.includes('maafw>=5.12.3,<6.0'), + true, + ); assert.equal( MANAGED_AUTOWSGR_COMMIT, '32b5cb2cb4cc20f4c1255a8d42784aaf24e1f432', @@ -138,6 +143,13 @@ try { assert.equal(managedUpdateArgs.at(-1), MANAGED_AUTOWSGR_REQUIREMENT); assert.equal(managedUpdateArgs.includes('autowsgr'), false); assert.equal(managedUpdateArgs.includes(localSite), true); + const runtimeInstallArgs = buildBackendRuntimeInstallArgs(localSite); + assert.equal( + runtimeInstallArgs.includes('maafw>=5.12.3,<6.0'), + true, + ); + assert.equal(runtimeInstallArgs.includes(localSite), true); + assert.equal(runtimeInstallArgs.includes('--no-deps'), false); const forcedUpdateArgs = buildManagedAutowsgrUpdateArgs( localSite, true, diff --git a/src/view/index.html b/src/view/index.html index 1bd02d4..25fe777 100644 --- a/src/view/index.html +++ b/src/view/index.html @@ -1695,7 +1695,7 @@

OCR 设置

From 8ca0049c6c2295e9058344a2a393ae88bf7d8f58 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:07:56 +0800 Subject: [PATCH 07/82] feat(ship-library): sync ship names to backend --- electron/ipc/ShipLibraryIpc.ts | 14 +- electron/main.ts | 55 ++++ electron/preload.ts | 5 +- electron/services/ShipLibraryService.ts | 4 + electron/services/ShipLibraryUpdater.ts | 67 ++++- electron/services/ShipNameSynchronizer.ts | 294 +++++++++++++++++++++ scripts/main-services/test-context.js | 4 + scripts/main-services/test-ship-library.js | 196 ++++++++++++++ scripts/test-main-services.js | 7 +- src/controller/app/SettingsController.ts | 118 +++++---- src/types/ipc.ts | 11 +- src/view/config/ConfigView.ts | 13 +- src/view/index.html | 2 +- 13 files changed, 735 insertions(+), 55 deletions(-) create mode 100644 electron/services/ShipNameSynchronizer.ts diff --git a/electron/ipc/ShipLibraryIpc.ts b/electron/ipc/ShipLibraryIpc.ts index d090637..d72a9e7 100644 --- a/electron/ipc/ShipLibraryIpc.ts +++ b/electron/ipc/ShipLibraryIpc.ts @@ -1,13 +1,17 @@ /** * 连接舰船资料库 IPC 与查询、更新服务。 */ -import type { ShipLibraryService } from '../services/ShipLibraryService'; +import type { + ShipLibraryService, + ShipLibraryStatus, +} from '../services/ShipLibraryService'; import type { ShipLibraryUpdater } from '../services/ShipLibraryUpdater'; import type { IpcRegistrar } from './IpcRegistrar'; export interface ShipLibraryIpcDependencies { library: ShipLibraryService; updater: ShipLibraryUpdater; + getStatus?(): ShipLibraryStatus | Promise; } /** 注册舰船资料库读取和更新 IPC。 */ @@ -15,7 +19,8 @@ export function registerShipLibraryIpc( ipc: IpcRegistrar, dependencies: ShipLibraryIpcDependencies, ): void { - ipc.handle('get-ship-library-status', () => { + ipc.handle('get-ship-library-status', async () => { + if (dependencies.getStatus) return await dependencies.getStatus(); return dependencies.library.getStatus(); }); @@ -23,7 +28,10 @@ export function registerShipLibraryIpc( return dependencies.library.getManifest(); }); - ipc.handle('update-ship-library', async () => { + ipc.handle('update-ship-library', async (_event, target?: unknown) => { + if (target === 'backend') { + return await dependencies.updater.syncBackend(); + } return await dependencies.updater.update(); }); } diff --git a/electron/main.ts b/electron/main.ts index d613219..8dbb641 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -7,6 +7,7 @@ import { initPythonEnv, clearPythonCache, isAllowedPythonVersion, findPython, checkEnvironment, installDependencies, installPortablePython, + resolvePythonEnvironment, } from './pythonEnv'; import { detectEmulator } from './emulatorDetect'; import { @@ -61,6 +62,7 @@ import { TaskPresetCodec } from '../src/shared/taskPreset'; import { DailyPlanService } from './services/DailyPlanService'; import { ShipLibraryService } from './services/ShipLibraryService'; import { ShipLibraryUpdater } from './services/ShipLibraryUpdater'; +import { ShipNameSynchronizer } from './services/ShipNameSynchronizer'; import { AdbService } from './services/AdbService'; import { CudaEnvironmentService } from './services/CudaEnvironmentService'; import { GuiConfigurationService } from './services/GuiConfigurationService'; @@ -175,6 +177,7 @@ const planExportService = new PlanExportService( const shipLibraryService = new ShipLibraryService(appPaths, { processId: process.pid, }); +const shipNameSynchronizer = new ShipNameSynchronizer(atomicFileStore); const adbService = new AdbService(appPaths); const cudaEnvironmentService = new CudaEnvironmentService( CudaEnvironmentService.createDependencies(findPython), @@ -225,6 +228,18 @@ const shipLibraryUpdater = new ShipLibraryUpdater( { message }, ); }, + compareShipNames: pythonCmd => { + return shipNameSynchronizer.compare( + backendShipNamesPath(pythonCmd), + shipLibraryService.getManifest().ships, + ); + }, + syncShipNames: pythonCmd => { + return shipNameSynchronizer.sync( + backendShipNamesPath(pythonCmd), + shipLibraryService.getManifest().ships, + ); + }, }, ); @@ -233,6 +248,18 @@ function appRoot(): string { return appPaths.appRoot(); } +/** 返回当前 managed 或 external 后端实际使用的舰名库路径。 */ +function backendShipNamesPath(pythonCmd: string): string { + const environment = resolvePythonEnvironment(pythonCmd); + const backendRoot = environment.backendRoot ?? environment.localSite; + return path.join( + backendRoot, + 'autowsgr', + 'data', + 'shipnames.yaml', + ); +} + /** 返回包含 resource 和 setup.bat 的 extraResources 目录。 */ function resourceRoot(): string { return appPaths.resourceRoot(); @@ -287,6 +314,34 @@ registerCombatPlanIpc(ipcMain, { registerShipLibraryIpc(ipcMain, { library: shipLibraryService, updater: shipLibraryUpdater, + getStatus: async () => { + const status = shipLibraryService.getStatus(); + if ( + !status.exists + || status.error + || status.shipCount <= 0 + || status.missingAssets > 0 + ) { + return status; + } + try { + const backend = await shipLibraryUpdater.getBackendSyncStatus(); + return { + ...status, + backendSynchronized: backend.synchronized, + backendMissingRecords: backend.missingRecords, + backendMissingAliases: backend.missingAliases, + }; + } catch (error) { + return { + ...status, + backendSynchronized: false, + backendError: error instanceof Error + ? error.message + : String(error), + }; + } + }, }); registerBackendIpc(ipcMain, { getBackendProcess, diff --git a/electron/preload.ts b/electron/preload.ts index 158e3fe..92f9477 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -18,6 +18,7 @@ import type { GuiSettingsCommitRequest, GuiSettingsCommitResult, GuiUpdateStatus, + ShipLibraryUpdateTarget, } from '../src/types/ipc'; const electronBridge = { @@ -150,8 +151,8 @@ const electronBridge = { return ipcRenderer.invoke('get-ship-library-manifest'); }, - updateShipLibrary: () => { - return ipcRenderer.invoke('update-ship-library'); + updateShipLibrary: (target: ShipLibraryUpdateTarget = 'wiki') => { + return ipcRenderer.invoke('update-ship-library', target); }, onShipLibraryUpdateProgress: (callback: (progress: { message: string }) => void) => { diff --git a/electron/services/ShipLibraryService.ts b/electron/services/ShipLibraryService.ts index 4005560..6ade9af 100644 --- a/electron/services/ShipLibraryService.ts +++ b/electron/services/ShipLibraryService.ts @@ -24,6 +24,10 @@ export interface ShipLibraryStatus { shipCount: number; assetCount: number; missingAssets: number; + backendSynchronized?: boolean; + backendMissingRecords?: number; + backendMissingAliases?: number; + backendError?: string; error?: string; } diff --git a/electron/services/ShipLibraryUpdater.ts b/electron/services/ShipLibraryUpdater.ts index 358201e..7f29bf2 100644 --- a/electron/services/ShipLibraryUpdater.ts +++ b/electron/services/ShipLibraryUpdater.ts @@ -5,6 +5,10 @@ import * as fs from 'fs'; import * as path from 'path'; import { spawn } from 'child_process'; import { ShipLibraryService } from './ShipLibraryService'; +import type { + ShipNameComparisonResult, + ShipNameSyncResult, +} from './ShipNameSynchronizer'; export interface ShipLibraryUpdateResult { success: boolean; @@ -18,6 +22,7 @@ export interface ShipLibraryUpdateResult { downloaded?: number; failed?: number; failures?: string[]; + shipnames_sync_error?: string; error?: string; } @@ -25,6 +30,8 @@ export interface ShipLibraryUpdaterDependencies { findPython(): Promise; appRoot(): string; sendProgress(message: string): void; + compareShipNames(pythonCmd: string): ShipNameComparisonResult; + syncShipNames(pythonCmd: string): ShipNameSyncResult; spawnProcess?: typeof spawn; } @@ -53,6 +60,50 @@ export class ShipLibraryUpdater { } } + /** 只读核对 GUI 船库与当前后端舰名库。 */ + async getBackendSyncStatus(): Promise { + const pythonCmd = await this.dependencies.findPython(); + if (!pythonCmd) { + throw new Error('找不到可用的 Python 3.12 或 3.13'); + } + return this.dependencies.compareShipNames(pythonCmd); + } + + /** 不访问 Wiki,只把当前 GUI 舰名增量同步到后端。 */ + async syncBackend(): Promise { + if (this.running) { + return { + success: false, + error: '舰船资料库正在更新,请稍候', + }; + } + this.running = true; + try { + const pythonCmd = await this.dependencies.findPython(); + if (!pythonCmd) { + return { + success: false, + error: '找不到可用的 Python 3.12 或 3.13', + }; + } + this.dependencies.sendProgress('正在同步后端舰名库…'); + const syncResult = this.dependencies.syncShipNames(pythonCmd); + return { + success: true, + ship_count: syncResult.totalRecords, + }; + } catch (error) { + return { + success: false, + error: `后端舰名同步失败: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } finally { + this.running = false; + } + } + /** 启动 Python 更新脚本并解析机器可读输出。 */ private async run(): Promise { const pythonCmd = await this.dependencies.findPython(); @@ -70,7 +121,7 @@ export class ShipLibraryUpdater { }; } - return new Promise((resolve) => { + const updateResult = await new Promise((resolve) => { const spawnProcess = this.dependencies.spawnProcess ?? spawn; // 便携版 Python 的 ._pth 隔离模式不会自动加入脚本目录。 const bootstrap = [ @@ -163,5 +214,19 @@ export class ShipLibraryUpdater { }); }); }); + if (!updateResult.success) return updateResult; + + this.dependencies.sendProgress('正在同步后端舰名库…'); + try { + this.dependencies.syncShipNames(pythonCmd); + return updateResult; + } catch (error) { + return { + ...updateResult, + shipnames_sync_error: error instanceof Error + ? error.message + : String(error), + }; + } } } diff --git a/electron/services/ShipNameSynchronizer.ts b/electron/services/ShipNameSynchronizer.ts new file mode 100644 index 0000000..612eff4 --- /dev/null +++ b/electron/services/ShipNameSynchronizer.ts @@ -0,0 +1,294 @@ +/** + * 把 GUI 舰船清单增量同步到本地 AutoWSGR 舰名库。 + * + * 同编号缺失时按编号插入新组;同编号已有组但缺少当前名称时追加别名。 + * 旧编号、旧名称和用户已有别名始终保留,避免资料库更新造成兼容性回退。 + */ +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import { AtomicFileStore } from './AtomicFileStore'; + +export interface ShipNameSyncResult { + path: string; + addedRecords: number; + addedAliases: number; + totalRecords: number; + changed: boolean; +} + +export interface ShipNameComparisonResult { + path: string; + missingRecords: number; + missingAliases: number; + totalRecords: number; + synchronized: boolean; +} + +interface ManifestShipName { + id: number; + searchName: string; +} + +interface ShipNameBlock { + id: number; + start: number; + end: number; +} + +interface TextEdit { + order: number; + text: string; +} + +type ShipNameDocument = Record; + +/** 非破坏性同步 GUI 舰名到后端 shipnames.yaml。 */ +export class ShipNameSynchronizer { + constructor(private readonly atomicFiles: AtomicFileStore) {} + + /** 只读核对 GUI 舰名与后端舰名库是否一致。 */ + compare( + shipnamesPath: string, + ships: Array>, + ): ShipNameComparisonResult { + const target = this.resolveTarget(shipnamesPath); + const document = this.loadDocument( + fs.readFileSync(target, 'utf-8'), + ); + const manifestShips = this.normalizeManifestShips(ships); + let missingRecords = 0; + let missingAliases = 0; + + for (const ship of manifestShips) { + const key = `No.${ship.id}`; + const existing = document[key]; + if (existing === undefined) { + missingRecords += 1; + continue; + } + if ( + !Array.isArray(existing) + || existing.some(name => typeof name !== 'string') + ) { + throw new Error(`后端舰名记录格式无效: ${key}`); + } + if (!existing.includes(ship.searchName)) missingAliases += 1; + } + + return { + path: target, + missingRecords, + missingAliases, + totalRecords: manifestShips.length, + synchronized: missingRecords === 0 && missingAliases === 0, + }; + } + + sync( + shipnamesPath: string, + ships: Array>, + ): ShipNameSyncResult { + const target = this.resolveTarget(shipnamesPath); + const original = fs.readFileSync(target, 'utf-8'); + const document = this.loadDocument(original); + const manifestShips = this.normalizeManifestShips(ships); + const blocks = this.findBlocks(original); + const blockById = new Map(blocks.map(block => [block.id, block])); + const lineEnding = original.includes('\r\n') ? '\r\n' : '\n'; + const otherStart = this.findOtherStart(original); + const edits = new Map(); + let addedRecords = 0; + let addedAliases = 0; + + for (const ship of manifestShips) { + const key = `No.${ship.id}`; + const existing = document[key]; + if (existing === undefined) { + const anchor = blocks.find(block => block.id > ship.id)?.start + ?? otherStart + ?? original.length; + this.addEdit( + edits, + anchor, + ship.id, + this.newRecord(ship, lineEnding), + ); + addedRecords += 1; + continue; + } + + if ( + !Array.isArray(existing) + || existing.some(name => typeof name !== 'string') + ) { + throw new Error(`后端舰名记录格式无效: ${key}`); + } + if (existing.includes(ship.searchName)) continue; + + const block = blockById.get(ship.id); + if (!block) { + throw new Error(`无法定位后端舰名记录: ${key}`); + } + this.addEdit( + edits, + block.end, + Number.MIN_SAFE_INTEGER, + ` - ${JSON.stringify(ship.searchName)}${lineEnding}`, + ); + addedAliases += 1; + } + + if (edits.size === 0) { + return { + path: target, + addedRecords, + addedAliases, + totalRecords: manifestShips.length, + changed: false, + }; + } + + const updated = this.applyEdits(original, edits); + const updatedDocument = this.loadDocument(updated); + this.verifyDocument(updatedDocument, manifestShips); + this.verifyOrder(updatedDocument); + this.atomicFiles.write(target, updated); + return { + path: target, + addedRecords, + addedAliases, + totalRecords: manifestShips.length, + changed: true, + }; + } + + private resolveTarget(shipnamesPath: string): string { + const target = path.resolve(shipnamesPath); + if ( + path.basename(target).toLowerCase() !== 'shipnames.yaml' + || path.basename(path.dirname(target)).toLowerCase() !== 'data' + || path.basename(path.dirname(path.dirname(target))).toLowerCase() + !== 'autowsgr' + ) { + throw new Error(`后端舰名库路径无效: ${target}`); + } + if (!fs.existsSync(target)) { + throw new Error(`本地后端舰名库不存在: ${target}`); + } + return fs.realpathSync(target); + } + + private loadDocument(content: string): ShipNameDocument { + const value = yaml.load(content); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error('后端舰名库不是有效的 YAML 映射'); + } + return value as ShipNameDocument; + } + + private normalizeManifestShips( + ships: Array>, + ): ManifestShipName[] { + const records = new Map(); + for (const ship of ships) { + const id = Number(ship.id); + const searchName = typeof ship.search_name === 'string' + ? ship.search_name.trim() + : ''; + if (!Number.isSafeInteger(id) || id <= 0 || !searchName) { + throw new Error('GUI 舰船清单包含无效的 id 或 search_name'); + } + const existing = records.get(id); + if (existing !== undefined && existing !== searchName) { + throw new Error(`GUI 舰船编号 ${id} 对应多个搜索名称`); + } + records.set(id, searchName); + } + return [...records.entries()] + .sort(([left], [right]) => left - right) + .map(([id, searchName]) => ({ id, searchName })); + } + + private findBlocks(content: string): ShipNameBlock[] { + const pattern = /^(?:\uFEFF)?No\.(\d+):[^\r\n]*(?:\r?\n|$)/gm; + const matches = [...content.matchAll(pattern)]; + return matches.map((match, index) => ({ + id: Number(match[1]), + start: match.index, + end: matches[index + 1]?.index + ?? this.findOtherStart(content) + ?? content.length, + })); + } + + private findOtherStart(content: string): number | null { + const match = /^Other:[^\r\n]*(?:\r?\n|$)/m.exec(content); + return match?.index ?? null; + } + + private addEdit( + edits: Map, + position: number, + order: number, + text: string, + ): void { + const entries = edits.get(position) ?? []; + entries.push({ order, text }); + edits.set(position, entries); + } + + private newRecord( + ship: ManifestShipName, + lineEnding: string, + ): string { + const comment = ship.searchName.replace(/[\r\n#]/g, ' ').trim(); + return [ + `No.${ship.id}: # ${comment}`, + ` - ${JSON.stringify(ship.searchName)}`, + '', + ].join(lineEnding); + } + + private applyEdits( + content: string, + edits: Map, + ): string { + let updated = content; + const positions = [...edits.keys()].sort((left, right) => right - left); + for (const position of positions) { + const inserted = (edits.get(position) ?? []) + .sort((left, right) => left.order - right.order) + .map(edit => edit.text) + .join(''); + updated = updated.slice(0, position) + + inserted + + updated.slice(position); + } + return updated; + } + + private verifyDocument( + document: ShipNameDocument, + ships: ManifestShipName[], + ): void { + for (const ship of ships) { + const value = document[`No.${ship.id}`]; + if (!Array.isArray(value) || !value.includes(ship.searchName)) { + throw new Error( + `后端舰名同步校验失败: No.${ship.id} -> ${ship.searchName}`, + ); + } + } + } + + private verifyOrder(document: ShipNameDocument): void { + const ids = Object.keys(document) + .filter(key => /^No\.\d+$/.test(key)) + .map(key => Number(key.slice(3))); + const sorted = [...ids].sort((left, right) => left - right); + if (ids.some((id, index) => id !== sorted[index])) { + throw new Error('后端舰名同步后编号顺序无效'); + } + } +} diff --git a/scripts/main-services/test-context.js b/scripts/main-services/test-context.js index c00eb1c..b377f32 100644 --- a/scripts/main-services/test-context.js +++ b/scripts/main-services/test-context.js @@ -70,6 +70,9 @@ const { const { ShipLibraryUpdater, } = require('../../dist/electron/services/ShipLibraryUpdater.js'); +const { + ShipNameSynchronizer, +} = require('../../dist/electron/services/ShipNameSynchronizer.js'); const { AdbService, } = require('../../dist/electron/services/AdbService.js'); @@ -115,6 +118,7 @@ module.exports = { TaskPresetCodec, ShipLibraryService, ShipLibraryUpdater, + ShipNameSynchronizer, AdbService, CudaEnvironmentService, GuiConfigurationService, diff --git a/scripts/main-services/test-ship-library.js b/scripts/main-services/test-ship-library.js index 263c8ab..7cffa49 100644 --- a/scripts/main-services/test-ship-library.js +++ b/scripts/main-services/test-ship-library.js @@ -29,6 +29,7 @@ const { PlanManagementService, ShipLibraryService, ShipLibraryUpdater, + ShipNameSynchronizer, AdbService, CudaEnvironmentService, GuiConfigurationService, @@ -363,6 +364,130 @@ function testShipLibraryService() { ); } +/** 验证 GUI 舰名以编号为准非破坏性同步到后端 YAML。 */ +function testShipNameSynchronizer() { + const backendRoot = path.join( + temporaryDirectory, + 'ship-name-sync-backend', + ); + const shipnamesPath = path.join( + backendRoot, + 'autowsgr', + 'data', + 'shipnames.yaml', + ); + fs.mkdirSync(path.dirname(shipnamesPath), { recursive: true }); + const original = [ + 'No.1: # 旧名称', + ' - "旧名称"', + ' - "用户别名"', + '', + 'No.3: # 旧三号', + ' - "旧三号"', + '', + 'Other:', + ' - "技能名称"', + '', + ].join('\n'); + fs.writeFileSync(shipnamesPath, original, 'utf8'); + const synchronizer = new ShipNameSynchronizer(new AtomicFileStore()); + const ships = [ + { id: 4, name: '四号舰(改)', search_name: '四号舰' }, + { id: 2, name: '测试舰(CVA-01)', search_name: '测试舰' }, + { id: 1, name: '旧名称(00)', search_name: '旧名称' }, + { id: 3, name: '三号舰(CV)', search_name: '三号舰' }, + ]; + + assert.deepEqual(synchronizer.compare(shipnamesPath, ships), { + path: fs.realpathSync(shipnamesPath), + missingRecords: 2, + missingAliases: 1, + totalRecords: 4, + synchronized: false, + }); + assert.equal(fs.readFileSync(shipnamesPath, 'utf8'), original); + + assert.deepEqual(synchronizer.sync(shipnamesPath, ships), { + path: fs.realpathSync(shipnamesPath), + addedRecords: 2, + addedAliases: 1, + totalRecords: 4, + changed: true, + }); + const updatedText = fs.readFileSync(shipnamesPath, 'utf8'); + const updated = yaml.load(updatedText); + assert.deepEqual(Object.keys(updated), [ + 'No.1', + 'No.2', + 'No.3', + 'No.4', + 'Other', + ]); + assert.deepEqual(updated['No.1'], ['旧名称', '用户别名']); + assert.deepEqual(updated['No.2'], ['测试舰']); + assert.deepEqual(updated['No.3'], ['旧三号', '三号舰']); + assert.deepEqual(updated['No.4'], ['四号舰']); + assert.deepEqual(updated.Other, ['技能名称']); + assert.doesNotMatch(updatedText, /测试舰\(CVA-01\)|四号舰\(改\)/); + + assert.deepEqual(synchronizer.sync(shipnamesPath, ships), { + path: fs.realpathSync(shipnamesPath), + addedRecords: 0, + addedAliases: 0, + totalRecords: 4, + changed: false, + }); + assert.deepEqual(synchronizer.compare(shipnamesPath, ships), { + path: fs.realpathSync(shipnamesPath), + missingRecords: 0, + missingAliases: 0, + totalRecords: 4, + synchronized: true, + }); + + const invalidPath = path.join( + backendRoot, + 'not-autowsgr', + 'data', + 'shipnames.yaml', + ); + fs.mkdirSync(path.dirname(invalidPath), { recursive: true }); + fs.writeFileSync(invalidPath, original, 'utf8'); + assert.throws( + () => synchronizer.sync(invalidPath, ships), + /后端舰名库路径无效/, + ); + + fs.writeFileSync(shipnamesPath, '{invalid', 'utf8'); + assert.throws(() => synchronizer.sync(shipnamesPath, ships)); + fs.writeFileSync(shipnamesPath, original, 'utf8'); + assert.throws( + () => synchronizer.sync( + shipnamesPath, + [{ id: 0, search_name: '无效编号' }], + ), + /GUI 舰船清单包含无效的 id 或 search_name/, + ); + assert.throws( + () => synchronizer.sync( + shipnamesPath, + [ + { id: 1, search_name: '名称一' }, + { id: 1, search_name: '名称二' }, + ], + ), + /GUI 舰船编号 1 对应多个搜索名称/, + ); + fs.writeFileSync(shipnamesPath, 'No.1: "错误格式"\n', 'utf8'); + assert.throws( + () => synchronizer.sync( + shipnamesPath, + [{ id: 1, search_name: '名称一' }], + ), + /后端舰名记录格式无效: No.1/, + ); +} + /** 创建可由更新服务监听的最小子进程替身。 */ function createFakeChild() { const child = new EventEmitter(); @@ -410,11 +535,33 @@ async function testShipLibraryUpdater() { fs.writeFileSync(library.updaterPath(), '# updater', 'utf8'); const progress = []; const spawnCalls = []; + const compareCalls = []; + const syncCalls = []; let spawnCount = 0; const updater = new ShipLibraryUpdater(library, { findPython: async () => 'python.exe', appRoot: () => projectRoot, sendProgress: message => progress.push(message), + compareShipNames: pythonCmd => { + compareCalls.push(pythonCmd); + return { + path: 'shipnames.yaml', + missingRecords: 2, + missingAliases: 1, + totalRecords: 12, + synchronized: false, + }; + }, + syncShipNames: pythonCmd => { + syncCalls.push(pythonCmd); + return { + path: 'shipnames.yaml', + addedRecords: 2, + addedAliases: 1, + totalRecords: 12, + changed: true, + }; + }, spawnProcess: (command, args, options) => { spawnCount += 1; spawnCalls.push({ command, args, options }); @@ -452,7 +599,9 @@ async function testShipLibraryUpdater() { '正在获取舰R百科数据…', '已读取 12 艘舰船,正在检查本地资源…', '正在检查资源 1/2,已下载 1,失败 0', + '正在同步后端舰名库…', ]); + assert.deepEqual(syncCalls, ['python.exe']); assert.equal(spawnCalls[0].command, 'python.exe'); assert.equal(spawnCalls[0].args[0], '-c'); assert.match( @@ -476,6 +625,52 @@ async function testShipLibraryUpdater() { ship_count: 12, }); assert.equal(spawnCount, 2); + assert.deepEqual(syncCalls, ['python.exe', 'python.exe']); + assert.deepEqual(await updater.getBackendSyncStatus(), { + path: 'shipnames.yaml', + missingRecords: 2, + missingAliases: 1, + totalRecords: 12, + synchronized: false, + }); + assert.deepEqual(compareCalls, ['python.exe']); + assert.deepEqual(await updater.syncBackend(), { + success: true, + ship_count: 12, + }); + assert.equal(spawnCount, 2); + assert.deepEqual( + syncCalls, + ['python.exe', 'python.exe', 'python.exe'], + ); + + const syncFailure = new ShipLibraryUpdater(library, { + findPython: async () => 'python.exe', + appRoot: () => projectRoot, + sendProgress: () => {}, + syncShipNames: () => { + throw new Error('shipnames locked'); + }, + spawnProcess: () => { + const child = createFakeChild(); + setImmediate(() => { + child.stdout.write( + 'RESULT_JSON={"success":true,"ship_count":12}\n', + ); + child.emit('close', 0); + }); + return child; + }, + }); + assert.deepEqual(await syncFailure.update(), { + success: true, + ship_count: 12, + shipnames_sync_error: 'shipnames locked', + }); + assert.deepEqual(await syncFailure.syncBackend(), { + success: false, + error: '后端舰名同步失败: shipnames locked', + }); const invalidResult = new ShipLibraryUpdater(library, { findPython: async () => 'python.exe', @@ -548,5 +743,6 @@ async function testShipLibraryUpdater() { module.exports = { testShipLibraryService, + testShipNameSynchronizer, testShipLibraryUpdater, }; diff --git a/scripts/test-main-services.js b/scripts/test-main-services.js index 76fd4d4..a676630 100644 --- a/scripts/test-main-services.js +++ b/scripts/test-main-services.js @@ -12,7 +12,11 @@ const { testTeamPlanServices } = require('./main-services/test-team-plan'); const { testCombatPlanServices } = require('./main-services/test-combat-plan'); const { testPlanExportService } = require('./main-services/test-plan-export'); const { testPythonEnvironmentService, testAdbService, testCudaEnvironmentService } = require('./main-services/test-environment-and-device'); -const { testShipLibraryService, testShipLibraryUpdater } = require('./main-services/test-ship-library'); +const { + testShipLibraryService, + testShipNameSynchronizer, + testShipLibraryUpdater, +} = require('./main-services/test-ship-library'); const { testUpdaterAndBackendShutdown } = require('./main-services/test-updater-and-shutdown'); const { testSingleInstanceService } = require('./main-services/test-single-instance'); @@ -32,6 +36,7 @@ async function main() { await testAdbService(); await testCudaEnvironmentService(); testShipLibraryService(); + testShipNameSynchronizer(); await testShipLibraryUpdater(); await testUpdaterAndBackendShutdown(); console.log('main services tests passed'); diff --git a/src/controller/app/SettingsController.ts b/src/controller/app/SettingsController.ts index 14ffa7d..3d9d2d8 100644 --- a/src/controller/app/SettingsController.ts +++ b/src/controller/app/SettingsController.ts @@ -1,6 +1,9 @@ /** 编排设置页的环境检测、设备连接、资料库更新和主题交互。 */ import type { ConfigView } from '../../view/config/ConfigView'; -import type { ManagedBattlePlanSelection } from '../../types/ipc.js'; +import type { + ManagedBattlePlanSelection, + ShipLibraryUpdateTarget, +} from '../../types/ipc.js'; import type { LootAutomationPlan } from '../../shared/lootPlans.js'; import { ApiClient } from '../../model/ApiClient'; import { Logger } from '../../utils/Logger'; @@ -24,6 +27,7 @@ export interface SettingsControllerHost { export class SettingsController { private shipLibraryUpdating = false; + private shipLibraryUpdateTarget: ShipLibraryUpdateTarget = 'wiki'; constructor( private readonly host: SettingsControllerHost, @@ -121,24 +125,40 @@ export class SettingsController { } } - async refreshShipLibraryStatus(): Promise { - if (this.shipLibraryUpdating) return; + async refreshShipLibraryStatus(ignoreUpdating = false): Promise { + if (this.shipLibraryUpdating && !ignoreUpdating) return; if (!this.gateway) return; try { const status = await this.gateway.getShipLibraryStatus(); if (status.error) { + this.setShipLibraryUpdateAction('wiki', '更新舰船数据库'); this.host.configView.setShipLibraryStatus(status.error, 'error'); - } else if (!status.exists) { + } else if (!status.exists || status.shipCount <= 0) { + this.setShipLibraryUpdateAction('wiki', '更新舰船数据库'); this.host.configView.setShipLibraryStatus( - '尚未建立本地资料库', - 'unknown', + 'Wiki 船库尚未同步', + 'error', ); } else if (status.missingAssets > 0) { + this.setShipLibraryUpdateAction('wiki', '更新舰船数据库'); + this.host.configView.setShipLibraryStatus( + `Wiki 船库未同步完整,缺少 ${status.missingAssets} 个资源`, + 'error', + ); + } else if (status.backendSynchronized !== true) { + this.setShipLibraryUpdateAction('backend', '同步后端'); + const missingRecords = status.backendMissingRecords ?? 0; + const missingAliases = status.backendMissingAliases ?? 0; + const missing = missingRecords + missingAliases; + const detail = status.backendError + ? `后端核对失败:${status.backendError}` + : `后端缺少 ${missing} 条舰名`; this.host.configView.setShipLibraryStatus( - `已收录 ${status.shipCount} 艘,缺少 ${status.missingAssets} 个资源`, + `Wiki 与 GUI 已同步,${detail}`, 'error', ); } else { + this.setShipLibraryUpdateAction('wiki', '检查更新'); const updatedAt = status.generatedAt ? new Date(status.generatedAt).toLocaleString( 'zh-CN', @@ -146,7 +166,7 @@ export class SettingsController { ) : '时间未知'; this.host.configView.setShipLibraryStatus( - `已收录 ${status.shipCount} 艘 · ${updatedAt}`, + `Wiki、GUI、后端已同步 · ${status.shipCount} 艘 · ${updatedAt}`, 'ok', ); } @@ -159,6 +179,14 @@ export class SettingsController { } } + private setShipLibraryUpdateAction( + target: ShipLibraryUpdateTarget, + label: string, + ): void { + this.shipLibraryUpdateTarget = target; + this.host.configView.setShipLibraryUpdateLabel(label); + } + private async browseDirectory( title: string, applyPath: (path: string) => void, @@ -396,14 +424,17 @@ export class SettingsController { private async updateShipLibrary(): Promise { if (!this.gateway || this.shipLibraryUpdating) return; + const updateTarget = this.shipLibraryUpdateTarget; this.shipLibraryUpdating = true; this.host.configView.setShipLibraryUpdateLoading(true); this.host.configView.setShipLibraryStatus( - '正在准备更新…', + updateTarget === 'backend' + ? '正在同步后端舰名库…' + : '正在检查 Wiki 更新…', 'unknown', ); try { - const result = await this.gateway.updateShipLibrary(); + const result = await this.gateway.updateShipLibrary(updateTarget); if (!result.success) { const message = result.error || result.failures?.[0] || '未知错误'; this.host.configView.setShipLibraryStatus( @@ -413,25 +444,25 @@ export class SettingsController { Logger.error(`舰船资料库更新失败: ${message}`); return; } - const summary = [ - `${result.ship_count ?? 0} 艘`, - `新增 ${result.added ?? 0}`, - `变化 ${result.updated ?? 0}`, - `下载 ${result.downloaded ?? 0}`, - ].join(','); - this.host.configView.setShipLibraryStatus( - `更新完成:${summary}`, - 'ok', + if (updateTarget === 'wiki') await this.host.reloadShipLibrary(); + if (result.shipnames_sync_error) { + Logger.error( + `舰船资料库已更新,但后端舰名同步失败: ${result.shipnames_sync_error}`, + ); + } + Logger.info( + updateTarget === 'backend' + ? '后端舰名库同步完成' + : 'Wiki 舰船资料库检查完成', ); - Logger.info(`舰船资料库更新完成:${summary}`); - await this.host.reloadShipLibrary(); + await this.refreshShipLibraryStatus(true); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.host.configView.setShipLibraryStatus( `更新失败: ${message}`, 'error', ); - Logger.error(`舰船资料库更新异常: ${message}`); + Logger.error(`舰船资料库更新失败: ${message}`); } finally { this.shipLibraryUpdating = false; this.host.configView.setShipLibraryUpdateLoading(false); @@ -451,35 +482,32 @@ export class SettingsController { Logger.warn(`GUI 更新检查失败: ${guiUpdate.message}`); return; } - if (updateMode === 'auto') { - if (guiUpdate.status === 'available') { - Logger.info( - `检测到 GUI 新版本 v${guiUpdate.version},自动模式下将自动下载`, - ); - } else { - Logger.info('GUI 已是最新版本'); - } + // 无新版本:auto 与 manual 模式统一提示 + if (guiUpdate.status !== 'available') { + Logger.info('GUI 已是最新版本'); return; } - if (guiUpdate.status === 'available') { - const confirmed = await showConfirm( - 'GUI 更新', - `发现 GUI 新版本 v${guiUpdate.version},是否立即下载?`, + if (updateMode === 'auto') { + Logger.info( + `检测到 GUI 新版本 v${guiUpdate.version},自动模式下将自动下载`, ); - if (confirmed) { - const result = await this.gateway.downloadGuiUpdate(); - if (result.success) { - Logger.info(`GUI 更新下载开始: v${guiUpdate.version}`); - } else { - Logger.warn( - `GUI 更新下载失败: ${result?.message || '未知错误'}`, - ); - } + return; + } + const confirmed = await showConfirm( + 'GUI 更新', + `发现 GUI 新版本 v${guiUpdate.version},是否立即下载?`, + ); + if (confirmed) { + const result = await this.gateway.downloadGuiUpdate(); + if (result.success) { + Logger.info(`GUI 更新下载开始: v${guiUpdate.version}`); } else { - Logger.info('已取消 GUI 更新下载'); + Logger.warn( + `GUI 更新下载失败: ${result?.message || '未知错误'}`, + ); } } else { - Logger.info('GUI 已是最新版本'); + Logger.info('已取消 GUI 更新下载'); } } catch { Logger.warn('GUI 更新检查失败'); diff --git a/src/types/ipc.ts b/src/types/ipc.ts index 2e0786d..3ae4ac9 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -259,9 +259,15 @@ export interface ShipLibraryStatus { shipCount: number; assetCount: number; missingAssets: number; + backendSynchronized?: boolean; + backendMissingRecords?: number; + backendMissingAliases?: number; + backendError?: string; error?: string; } +export type ShipLibraryUpdateTarget = 'wiki' | 'backend'; + export interface ShipLibraryUpdateResult { success: boolean; output?: string; @@ -274,6 +280,7 @@ export interface ShipLibraryUpdateResult { downloaded?: number; failed?: number; failures?: string[]; + shipnames_sync_error?: string; error?: string; } @@ -418,7 +425,9 @@ export interface ElectronBridge { setUpdateMode: (mode: 'auto' | 'manual') => Promise; getShipLibraryStatus: () => Promise; getShipLibraryManifest: () => Promise; - updateShipLibrary: () => Promise; + updateShipLibrary: ( + target?: ShipLibraryUpdateTarget, + ) => Promise; onShipLibraryUpdateProgress: ( callback: (progress: { message: string }) => void, ) => void; diff --git a/src/view/config/ConfigView.ts b/src/view/config/ConfigView.ts index fda7dfe..6c901ae 100644 --- a/src/view/config/ConfigView.ts +++ b/src/view/config/ConfigView.ts @@ -107,6 +107,7 @@ export class ConfigView { private validatePythonBtn = document.getElementById('btn-validate-python') as HTMLButtonElement | null; private shipLibraryStatus = document.getElementById('ship-library-status'); private updateShipLibraryBtn = document.getElementById('btn-update-ship-library') as HTMLButtonElement | null; + private shipLibraryUpdateLabel = '更新舰船数据库'; private defaultWindowWidth = element('cfg-window-width'); private defaultWindowHeight = element('cfg-window-height'); private rememberWindowBounds = element('cfg-remember-window-bounds'); @@ -548,6 +549,12 @@ export class ConfigView { setPythonStatus(text: string, status: StatusKind): void { this.setStatus(this.pythonStatus, text, status); } setBackendStatus(text: string, status: StatusKind): void { this.setStatus(this.backendStatus, text, status); } setShipLibraryStatus(text: string, status: StatusKind): void { this.setStatus(this.shipLibraryStatus, text, status); } + setShipLibraryUpdateLabel(label: string): void { + this.shipLibraryUpdateLabel = label; + if (this.updateShipLibraryBtn && !this.updateShipLibraryBtn.disabled) { + this.updateShipLibraryBtn.textContent = label; + } + } setAdbStatus(text: string, status: 'online' | 'offline' | 'unknown'): void { if (!this.adbStatus) return; this.adbStatus.title = text; @@ -576,7 +583,11 @@ export class ConfigView { setShipLibraryUpdateLoading(loading: boolean): void { if (!this.updateShipLibraryBtn) return; this.updateShipLibraryBtn.disabled = loading; - this.updateShipLibraryBtn.textContent = loading ? '正在更新…' : '更新舰船数据库'; + this.updateShipLibraryBtn.textContent = loading + ? this.shipLibraryUpdateLabel === '同步后端' + ? '正在同步…' + : '正在检查…' + : this.shipLibraryUpdateLabel; } setBackendCheckLoading(loading: boolean): void { diff --git a/src/view/index.html b/src/view/index.html index 25fe777..154172a 100644 --- a/src/view/index.html +++ b/src/view/index.html @@ -1346,7 +1346,7 @@

更新与界面

更新舰船数据库 - 从舰R百科获取最新舰船资料,只下载新增、变化或缺失的立绘、背景和分类数据。 + 从舰R百科获取最新舰船资料,只下载新增、变化或缺失的数据;点击“同步后端”会把 GUI 新增舰名增量写入本地后端船库。
From 226652cde7d14f6c0abca64b551c5408bc7e9c59 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:08:07 +0800 Subject: [PATCH 08/82] fix(gui): reduce noisy status logging --- src/controller/app/ConfigController.ts | 25 ++++++++----------- src/controller/startup/envAndUpdates.ts | 5 ++-- src/controller/template/TemplateController.ts | 2 +- src/controller/template/selectors.ts | 18 +++++++------ src/utils/Logger.ts | 9 +++++-- 5 files changed, 32 insertions(+), 27 deletions(-) diff --git a/src/controller/app/ConfigController.ts b/src/controller/app/ConfigController.ts index b5456a5..6c6a0ae 100644 --- a/src/controller/app/ConfigController.ts +++ b/src/controller/app/ConfigController.ts @@ -89,18 +89,18 @@ export class ConfigController { async loadConfig(): Promise { const bridge = this.gateway; if (!bridge) return; + + let yamlStr = ''; try { - const yamlStr = await bridge.readFile('usersettings.yaml'); - if (!yamlStr.trim()) { - Logger.debug('usersettings.yaml 未找到,自动创建默认配置'); - const defaultYaml = this.host.configModel.toYaml(); - await bridge.saveFile('usersettings.yaml', defaultYaml); - Logger.info(`已创建默认配置文件: ${this.host.configDir}\\usersettings.yaml`); - } else { - this.host.configModel.loadFromYaml(yamlStr); - Logger.debug('usersettings.yaml 已加载'); - } + yamlStr = await bridge.readFile('usersettings.yaml'); } catch { + // 读取异常(文件缺失/损坏)与内容为空同等处理:创建默认配置 + yamlStr = ''; + } + if (yamlStr.trim()) { + this.host.configModel.loadFromYaml(yamlStr); + Logger.debug('usersettings.yaml 已加载'); + } else { Logger.debug('usersettings.yaml 未找到,自动创建默认配置'); const defaultYaml = this.host.configModel.toYaml(); await bridge.saveFile('usersettings.yaml', defaultYaml); @@ -171,9 +171,6 @@ export class ConfigController { if (hasLegacyGuiAutomation) { this.host.configModel.markLegacyGuiAutomationMigrated(); guiAutomationMigrated = true; - Logger.info( - '已按字段优先级将旧版 GUI 调度字段迁移到 gui_settings.json', - ); } } } @@ -184,6 +181,7 @@ export class ConfigController { || decisiveMigrated ) { await bridge.saveFile('usersettings.yaml', this.host.configModel.toYaml()); + Logger.info('已将旧版配置迁移到 gui_settings.json'); } } @@ -255,7 +253,6 @@ export class ConfigController { this.host.configModel.markLegacyDecisiveAutomationMigrated( settings, ); - Logger.info('已将旧版决战自动化原值迁移到 gui_settings.json'); return true; } catch (error) { Logger.warn( diff --git a/src/controller/startup/envAndUpdates.ts b/src/controller/startup/envAndUpdates.ts index 11c58bf..269922f 100644 --- a/src/controller/startup/envAndUpdates.ts +++ b/src/controller/startup/envAndUpdates.ts @@ -48,8 +48,9 @@ export async function checkAndPrepareEnv( const installResult = await bridge.installDeps(); if (!installResult.success) { - Logger.error('依赖安装失败'); - Logger.error(installResult.output.slice(-200)); + Logger.error('依赖安装失败,请检查日志'); + // 原始 pip 输出仅写入日志文件,不推送到 UI 面板(可能含乱码,不适合展示) + Logger.logToFile(installResult.output.slice(-200)); return false; } diff --git a/src/controller/template/TemplateController.ts b/src/controller/template/TemplateController.ts index 426693e..aff08d6 100644 --- a/src/controller/template/TemplateController.ts +++ b/src/controller/template/TemplateController.ts @@ -184,8 +184,8 @@ export class TemplateController { } if (added > 0) { this.wizardView.renderPlanList(this.wizardPlanPathsRef.value); + Logger.info(`扫描到 ${files.length} 个方案文件,新增 ${added} 个`); } - Logger.info(`扫描到 ${files.length} 个方案文件,新增 ${added} 个`); } // ════════════════════════════════════════ diff --git a/src/controller/template/selectors.ts b/src/controller/template/selectors.ts index 58bb3d9..17db260 100644 --- a/src/controller/template/selectors.ts +++ b/src/controller/template/selectors.ts @@ -76,6 +76,14 @@ export async function showPlanSelector( const planPath = paths[idx]; const planName = planPath.split(/[\\/]/).pop()?.replace(/\.ya?ml$/i, '') ?? planPath; + // 选完方案后直接加入任务列表(无需编队预设选择)的公共步骤 + const addPlanDirect = (): void => { + addPlanToTaskList(tpl, planPath, groupName, taskGroupModel); + taskGroupModel.save(); + renderTaskGroup(); + Logger.info(`模板「${tpl.name}」→ 已加入任务列表「${groupName}」(方案: ${planName})`); + }; + let parsedPlan: Partial | undefined; if (repository) { @@ -95,10 +103,7 @@ export async function showPlanSelector( const selectedFleetId = await showNormalFightFleetSelector(tpl.name, planName, defaultFleetId, wizardView); if (selectedFleetId === null) return; if (selectedFleetId === undefined) { - addPlanToTaskList(tpl, planPath, groupName, taskGroupModel); - taskGroupModel.save(); - renderTaskGroup(); - Logger.info(`模板「${tpl.name}」→ 已加入任务列表「${groupName}」(方案: ${planName})`); + addPlanDirect(); return; } @@ -117,10 +122,7 @@ export async function showPlanSelector( } catch { /* 忽略读取失败 */ } } - addPlanToTaskList(tpl, planPath, groupName, taskGroupModel); - taskGroupModel.save(); - renderTaskGroup(); - Logger.info(`模板「${tpl.name}」→ 已加入任务列表「${groupName}」(方案: ${planName})`); + addPlanDirect(); } /** 编队预设多选器 */ diff --git a/src/utils/Logger.ts b/src/utils/Logger.ts index 6c6b88f..de83cec 100644 --- a/src/utils/Logger.ts +++ b/src/utils/Logger.ts @@ -72,6 +72,11 @@ class LoggerImpl { this.log(l, channel, message); } + /** 仅写入日志文件和控制台,不推送到 UI 面板(用于原始输出等不适合展示的内容) */ + logToFile(message: string): void { + this.log('error', 'GUI', message, false); + } + /** 手动刷新缓冲区到文件 */ flush(): void { if (!this.opts || this.buffer.length === 0) return; @@ -83,7 +88,7 @@ class LoggerImpl { }); } - private log(level: LogLevel, channel: string, message: string): void { + private log(level: LogLevel, channel: string, message: string, showInUi = true): void { const ts = formatTimestamp(); // 1. 文件 @@ -101,7 +106,7 @@ class LoggerImpl { } // 3. UI - this.opts?.uiCallback(level, channel, message); + if (showInUi) this.opts?.uiCallback(level, channel, message); } } From b874e8168b19177558e2d4792f27646bc693463f Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:08:16 +0800 Subject: [PATCH 09/82] refactor(scheduler): reuse expedition check --- src/model/scheduler/Scheduler.ts | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/model/scheduler/Scheduler.ts b/src/model/scheduler/Scheduler.ts index e007c71..875005c 100644 --- a/src/model/scheduler/Scheduler.ts +++ b/src/model/scheduler/Scheduler.ts @@ -156,6 +156,17 @@ export class Scheduler { ?? null; } + /** 执行一次远征检查(系统启动时与远征任务消费时共用) */ + private async checkExpedition(): Promise { + this.emitLog('info', '正在检查远征...'); + try { + await this.api.expeditionCheck(); + this.emitLog('info', '远征检查完成'); + } catch { + this.emitLog('debug', '远征检查跳过'); + } + } + /** 启动系统 (连接模拟器 + 启动游戏) */ async start(configPath?: string): Promise { const resp = await this.api.systemStart(configPath, 300_000); @@ -168,13 +179,7 @@ export class Scheduler { if (this.autoExpedition) { // 系统启动后立即检查远征,确保远征页面不会阻碍后续任务 - this.emitLog('info', '正在检查远征...'); - try { - await this.api.expeditionCheck(); - this.emitLog('info', '远征检查完成'); - } catch { - this.emitLog('debug', '远征检查跳过'); - } + await this.checkExpedition(); this.expeditionTimer.start(); } return true; @@ -483,13 +488,7 @@ export class Scheduler { // 远征任务: 直接调用远征 API,不走 taskStart 流程 if (task.type === 'expedition') { - try { - this.emitLog('info', '正在检查远征...'); - await this.api.expeditionCheck(); - this.emitLog('info', '远征检查完成'); - } catch { - this.emitLog('debug', '远征检查跳过'); - } + await this.checkExpedition(); if (!this.systemActive || this.currentTask?.id !== task.id) return; await this.handlePostExpedition(); From 0db9f44f2277ebab503c0e588d7807552f660a6b Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:08:16 +0800 Subject: [PATCH 10/82] build(release): pin personal backend revision --- build/backend-distributions/personal.json | 2 +- electron/pythonEnv/backendRequirement.ts | 2 +- scripts/test-python-environment.js | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build/backend-distributions/personal.json b/build/backend-distributions/personal.json index 2e1fbef..33408e9 100644 --- a/build/backend-distributions/personal.json +++ b/build/backend-distributions/personal.json @@ -2,6 +2,6 @@ "id": "personal", "repository": "ShiinaKuroko/AutoWSGR", "ref": "ShiinaKuroko", - "commit": "32b5cb2cb4cc20f4c1255a8d42784aaf24e1f432", + "commit": "de2187d6149c1115fae92f4282181e973974f55a", "forceUpdateOnInstall": true } diff --git a/electron/pythonEnv/backendRequirement.ts b/electron/pythonEnv/backendRequirement.ts index a40d7c1..6131773 100644 --- a/electron/pythonEnv/backendRequirement.ts +++ b/electron/pythonEnv/backendRequirement.ts @@ -19,7 +19,7 @@ const DEFAULT_DISTRIBUTION: BackendDistribution = { id: 'personal', repository: 'ShiinaKuroko/AutoWSGR', ref: 'ShiinaKuroko', - commit: '32b5cb2cb4cc20f4c1255a8d42784aaf24e1f432', + commit: 'de2187d6149c1115fae92f4282181e973974f55a', forceUpdateOnInstall: true, }; diff --git a/scripts/test-python-environment.js b/scripts/test-python-environment.js index 5263757..ad7f8de 100644 --- a/scripts/test-python-environment.js +++ b/scripts/test-python-environment.js @@ -128,7 +128,7 @@ try { ); assert.equal( MANAGED_AUTOWSGR_COMMIT, - '32b5cb2cb4cc20f4c1255a8d42784aaf24e1f432', + 'de2187d6149c1115fae92f4282181e973974f55a', ); assert.equal(BACKEND_DISTRIBUTION.id, 'personal'); assert.equal(BACKEND_DISTRIBUTION.ref, 'ShiinaKuroko'); From 335581687886f5bad9b31bbeaa7f4d0f5ae79e0b Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:13:21 +0800 Subject: [PATCH 11/82] build(release): set 2.0.1 alpha version --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5b8ad6c..506e03b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wsgrgui", - "version": "2.0.5-dev", + "version": "2.0.1-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wsgrgui", - "version": "2.0.5-dev", + "version": "2.0.1-alpha", "dependencies": { "electron-updater": "^6.8.3", "js-yaml": "^4.1.0", diff --git a/package.json b/package.json index 2025417..7026326 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wsgrgui", - "version": "2.0.5-dev", + "version": "2.0.1-alpha", "description": "AutoWSGR YAML 管理与自动化桌面界面", "main": "dist/electron/main.js", "scripts": { @@ -147,7 +147,7 @@ "provider": "github", "owner": "yltx", "repo": "AutoWSGR-GUI", - "channel": "dev" + "channel": "alpha" } } } From e6d37e46bc469e7035caded8dc0816589d214339 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:20:33 +0800 Subject: [PATCH 12/82] fix(logging): use logs as GUI default directory --- scripts/test-settings-persistence.js | 5 +++++ src/controller/app/AppController.ts | 2 +- src/model/ConfigModel.ts | 2 +- src/view/config/ConfigView.ts | 2 +- src/view/index.html | 2 +- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/scripts/test-settings-persistence.js b/scripts/test-settings-persistence.js index 79cd223..6c23cd5 100644 --- a/scripts/test-settings-persistence.js +++ b/scripts/test-settings-persistence.js @@ -776,6 +776,11 @@ async function runRendererTest(root, tempDirectory) { view.render(sample); const model = new ConfigModel(); + rendererAssert.equal( + model.current.log.root, + 'logs', + '新建配置的默认日志目录必须为 logs', + ); model.loadFromYaml([ 'emulator:', ' type: 雷电', diff --git a/src/controller/app/AppController.ts b/src/controller/app/AppController.ts index 432c5b0..709df6f 100644 --- a/src/controller/app/AppController.ts +++ b/src/controller/app/AppController.ts @@ -278,7 +278,7 @@ export class AppController { const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`; this.mainView.appendLog({ time, level, channel, message }); }, - logDir: `${this.configDir}/log`, + logDir: `${this.configDir}/logs`, }); }, loadConfigAndSync: async () => { diff --git a/src/model/ConfigModel.ts b/src/model/ConfigModel.ts index 9a98bce..c834ffe 100644 --- a/src/model/ConfigModel.ts +++ b/src/model/ConfigModel.ts @@ -45,7 +45,7 @@ const DEFAULT_SETTINGS: UserSettings = { }, log: { level: 'INFO', - root: 'log', + root: 'logs', }, daily_automation: { auto_expedition: false, diff --git a/src/view/config/ConfigView.ts b/src/view/config/ConfigView.ts index 6c901ae..3fcd560 100644 --- a/src/view/config/ConfigView.ts +++ b/src/view/config/ConfigView.ts @@ -305,7 +305,7 @@ export class ConfigView { lootPlans: structuredClone(this.lootPlans), lootStopCount: Math.trunc(this.clamp(this.lootStopCount.value, 1, 50, 50)), logLevel: this.logLevel.value as ConfigViewObject['logLevel'], - logRoot: this.logRoot.value.trim() || 'log', + logRoot: this.logRoot.value.trim() || 'logs', themeMode: this.themeMode.value as ConfigViewObject['themeMode'], accentColor: this.accentColor.value, debugMode: this.debugMode.checked, diff --git a/src/view/index.html b/src/view/index.html index 154172a..27ce8a0 100644 --- a/src/view/index.html +++ b/src/view/index.html @@ -1220,7 +1220,7 @@

日志与调试

- +
From 32b1da883893791fe6bb896d4e173cdcf75e018c Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:53:12 +0800 Subject: [PATCH 13/82] fix(data): correct Selfridge ship name --- resource/ship-library/database/ships.sqlite3 | Bin 954368 -> 954368 bytes resource/ship-library/manifest.json | 8 +++--- .../bettle-\345\221\250\345\270\270-6-4.yaml" | 4 +-- ...\345\267\245\347\250\213\351\230\237.yaml" | 4 +-- src/data/ship_details.json | 4 +-- .../ship_library/test_update_ship_library.py | 24 ++++++++++++++++++ tools/ship_library/update_ship_library.py | 17 +++++++++++-- 7 files changed, 49 insertions(+), 12 deletions(-) diff --git a/resource/ship-library/database/ships.sqlite3 b/resource/ship-library/database/ships.sqlite3 index fa176efb7b401fa51d4f865d61495768fda82323..4fdae42e9865c99989cd8d1b3a364f99999e115b 100644 GIT binary patch delta 430 zcmZwBze|Ea7zc1)?|psMQV?oT$c7f7p}Tv3-5aPNwS+=jTk^i|-AlAJMhQZa1r0^Q z4K0q14aE@%S)kD39}%cUTTsx}%@RDH;ro1f;AwW(n{rx8D|!zUs7AjcPh5jwz()}D zqF`H`A+WEg+GivuelRE`70Q>3U{g)?Ba33M2nwbA`@_xnt~2iOkM_m*@_Z)ptG5^t zlOu4Vlsykq$3zIRkir0B(z`Ijgo2C2fC;a z;?>iY|E^|s+{?ssynCoD@$PprA=1^vu~KG^V_`%Y4qeB#8MQrZn8?Q@@QLX|Y`Y-} pTp1uUAR*WY5it<;sgJ$Dr`HFs`S!Gykdo4zG%uyZw3g0R{s1}Ft?B>( delta 384 zcmZ9`u}i~16b5jUOH5mBL2#*HRa^=#cS)|f2yLO@*3ORga+jmCn`oh6F(58t;Wnv* z&JMyMbf`i*IQT~hX%RO;XV10pemuU%8@}Ffzo(~f^vx$=K+FDGdXY8+6D!ZnZ}|qn zNL?b(F{~oMruub3IcJRJRuvpr>tN4Rohm5TO6lEg`Z!2O@-w+g?~{cnuRoTFnw^4k zqaO1Rd02=jh#A7vrr%fCsxRow0MMqOtRC-E1FuNqgd`_9WB1*j=dfs%C@dg`1>nnGHESF*fD! zCzn-)X7 None: "assets/type-icons/cf.webp", ) + def test_wiki_ship_name_is_corrected_to_game_text(self) -> None: + ships = module_ships("DD", "DD") + ships[1]["name"] = "塞尔弗里奇" + ships[2]["name"] = "塞尔弗里奇·改" + + records = build_ship_records( + index_html(1, "DD") + index_html(2, "DD"), + ships, + "native", + ) + + self.assertEqual( + [record.source_name for record in records], + ["塞尔弗里奇", "塞尔弗里奇·改"], + ) + self.assertEqual( + [record.display_name_zh for record in records], + ["赛尔弗里吉", "赛尔弗里吉·改"], + ) + self.assertEqual( + [record.search_name for record in records], + ["赛尔弗里吉", "赛尔弗里吉"], + ) + def test_bundled_library_keeps_canonical_types_and_all_assets(self) -> None: library_root = Path(__file__).resolve().parents[2] / "resource" / "ship-library" manifest = json.loads( diff --git a/tools/ship_library/update_ship_library.py b/tools/ship_library/update_ship_library.py index 4603bac..2e9c80f 100644 --- a/tools/ship_library/update_ship_library.py +++ b/tools/ship_library/update_ship_library.py @@ -359,6 +359,18 @@ def variant_code(ship_id: int, source_name: str) -> str: return "normal" +SHIP_NAME_CORRECTIONS = { + "塞尔弗里奇": "赛尔弗里吉", +} + + +def game_ship_name(source_name: str) -> str: + """修正 Wiki 中与游戏实际文本不一致的舰名。""" + suffix = "·改" if source_name.endswith("·改") else "" + base_name = source_name.removesuffix(suffix) + return f"{SHIP_NAME_CORRECTIONS.get(base_name, base_name)}{suffix}" + + def search_name(source_name: str) -> str: name = source_name[:-2] if source_name.endswith("·改") else source_name return SHIP_NAME_NOTE_SUFFIX_RE.sub("", name).strip() @@ -418,12 +430,13 @@ def build_ship_records( wiki_url = urljoin(WIKI_ROOT, str(anchor.get("href", ""))) if anchor else "" size_code, role_code = ship_type_groups(ship_type_code) source_name = str(source["name"]) + display_name = game_ship_name(source_name) rarity = int(source["rarity"]) data = { "ship_id": ship_id, "source_name": source_name, - "display_name_zh": source_name, - "search_name": search_name(source_name), + "display_name_zh": display_name, + "search_name": search_name(display_name), "variant_code": variant_code(ship_id, source_name), "rarity": rarity, "ship_type_code": ship_type_code, From ac8d61b598e51d64014b869fc9b7e79957c111c2 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Sat, 8 Aug 2026 22:00:39 +0800 Subject: [PATCH 14/82] =?UTF-8?q?refactor(gui):=20=E6=B8=85=E7=90=86?= =?UTF-8?q?=E6=9C=AA=E4=BD=BF=E7=94=A8=E6=8E=A5=E5=8F=A3=E4=B8=8E=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=BE=85=E5=8A=A9=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/pythonEnv/finder.ts | 46 +--------- electron/pythonEnv/index.ts | 4 +- electron/pythonEnv/installer.ts | 87 +------------------ electron/pythonEnv/utils.ts | 7 -- electron/services/CombatPlanRepository.ts | 10 --- electron/services/SingleInstanceService.ts | 4 - electron/services/TeamPlanRepository.ts | 5 -- scripts/main-services/test-combat-plan.js | 9 +- scripts/main-services/test-single-instance.js | 10 ++- scripts/main-services/test-team-plan.js | 5 +- scripts/test-scheduler-domain.mjs | 2 +- scripts/test-support/directories.js | 16 ++++ scripts/test-task-group-migration.js | 15 ++-- src/adapter/YamlAdapter.ts | 30 +------ src/controller/app/SchedulerBinder.ts | 4 + src/controller/app/constants.ts | 15 +--- src/controller/startup/StartupController.ts | 14 +-- src/controller/taskGroup/addItems.ts | 41 --------- src/model/ApiClient.ts | 3 + src/model/TaskGroupModel.ts | 5 -- src/model/scheduler/CronScheduler.ts | 58 +------------ src/model/scheduler/ExpeditionTimer.ts | 2 - src/model/scheduler/RepairManager.ts | 10 --- src/model/scheduler/Scheduler.ts | 23 ++--- src/model/scheduler/TaskQueue.ts | 5 -- src/shared/lootPlans.ts | 5 -- src/shared/shipCatalog.ts | 18 ---- src/types/model.ts | 8 -- src/types/view.ts | 6 -- src/view/index.html | 1 - src/view/migration/MigrationConflictView.ts | 4 - src/view/plan/FleetPlannerView.ts | 5 -- src/view/plan/FleetPresetView.ts | 3 +- src/view/plan/NodeEditorView.ts | 42 ++------- src/view/plan/PlanPreviewView.ts | 6 +- src/view/shared/ShipAutocomplete.ts | 12 --- src/view/styles/pages/plan/_layout.scss | 36 +------- src/view/styles/styles.css | 43 +-------- 38 files changed, 75 insertions(+), 544 deletions(-) create mode 100644 scripts/test-support/directories.js diff --git a/electron/pythonEnv/finder.ts b/electron/pythonEnv/finder.ts index 0d1f0cb..8d8efc0 100644 --- a/electron/pythonEnv/finder.ts +++ b/electron/pythonEnv/finder.ts @@ -3,7 +3,7 @@ */ import * as path from 'path'; import * as fs from 'fs'; -import { exec, execSync } from 'child_process'; +import { exec } from 'child_process'; import { promisify } from 'util'; import { getCtx, getCachedPythonCmd, setCachedPythonCmd } from './context'; @@ -72,47 +72,3 @@ export async function findPython(): Promise { setCachedPythonCmd(found); return found; } - -/** 同步判断 Python 版本是否兼容。 */ -function isAllowedPythonVersionSync(pythonCmd: string): boolean { - try { - const output = execSync( - `"${pythonCmd}" --version 2>&1`, - { encoding: 'utf-8', windowsHide: true, shell: 'cmd.exe' }, - ); - return isAllowedPythonVersion(output); - } catch { - return false; - } -} - -/** 为同步调用方查找并缓存 Python。 */ -export function findPythonSync(): string | null { - if (getCachedPythonCmd() !== undefined) return getCachedPythonCmd()!; - const ctx = getCtx(); - // 优先使用用户配置的 Python。 - const configured = ctx.getConfiguredPythonPath(); - if (configured && fs.existsSync(configured) && isAllowedPythonVersionSync(configured)) { - setCachedPythonCmd(configured); - return configured; - } - const localPython = path.join(ctx.appRoot(), 'python', 'python.exe'); - if (fs.existsSync(localPython) && isAllowedPythonVersionSync(localPython)) { - setCachedPythonCmd(localPython); - return localPython; - } - for (const cmd of ['python', 'python3']) { - try { - if (!isAllowedPythonVersionSync(cmd)) continue; - // 解析 pyenv 或 .bat shim 指向的真实路径。 - const resolved = execSync( - `${cmd} -c "import sys; print(sys.executable)"`, - { windowsHide: true, encoding: 'utf-8' }, - ).trim(); - const result = (resolved && fs.existsSync(resolved)) ? resolved : cmd; - setCachedPythonCmd(result); - return result; - } catch { /* 当前命令不可用时继续查找。 */ } - } - return null; -} diff --git a/electron/pythonEnv/index.ts b/electron/pythonEnv/index.ts index f901e03..43df428 100644 --- a/electron/pythonEnv/index.ts +++ b/electron/pythonEnv/index.ts @@ -4,11 +4,10 @@ export { type PythonEnvContext, initPythonEnv, clearPythonCache } from './context'; -export { isAllowedPythonVersion, findPython, findPythonSync } from './finder'; +export { isAllowedPythonVersion, findPython } from './finder'; export { type EnvCheckResult, - sysPathInsert, ensurePthFile, pipEnv, localSitePackages, @@ -41,7 +40,6 @@ export { checkForUpdates, buildDependencyInstallPlan, installDependencies, - pullUpdates, } from './installer'; export { diff --git a/electron/pythonEnv/installer.ts b/electron/pythonEnv/installer.ts index bdd2500..a443f88 100644 --- a/electron/pythonEnv/installer.ts +++ b/electron/pythonEnv/installer.ts @@ -7,7 +7,7 @@ import { exec, spawn } from 'child_process'; import { promisify } from 'util'; import { getCtx, setCachedPythonCmd } from './context'; import { findPython } from './finder'; -import { ensurePthFile, localSitePackages, pipEnv, ensurePip, ensureSslCertForPython } from './utils'; +import { ensurePthFile, pipEnv, ensurePip, ensureSslCertForPython } from './utils'; import { ENV_READY_MARKER } from './envCheck'; import { buildPythonProcessEnv, @@ -20,7 +20,6 @@ import { SHIP_LIBRARY_REQUIREMENTS, } from './dependencies'; import { MANAGED_AUTOWSGR_REQUIREMENT } from './backendRequirement'; -import { buildBackendRuntimeInstallArgs } from './updater'; const execAsync = promisify(exec); @@ -295,87 +294,3 @@ export async function installDependencies(pythonCmd: string): Promise<{ success: else ctx.sendProgress('ERROR 依赖安装失败'); return { success: install.code === 0, output: install.output.slice(-500) }; } - -/** 更新 autowsgr 本体且不级联重装依赖。 */ -export async function pullUpdates(): Promise<{ success: boolean; output: string }> { - const ctx = getCtx(); - // 更新后清除环境标记。 - try { fs.unlinkSync(ENV_READY_MARKER()); } catch { /* 忽略清理失败。 */ } - const pythonCmd = await findPython(); - if (!pythonCmd) return { success: false, output: '找不到 Python' }; - - const certFile = await ensureSslCertForPython(pythonCmd); - if (certFile) ctx.sendProgress(`TLS 证书已就绪: ${certFile}`); - else ctx.sendProgress('WARNING 未检测到 TLS 根证书,后续联网操作可能失败'); - - try { - await execAsync( - `"${pythonCmd}" -m pip install --upgrade --target "${localSitePackages()}" hatchling hatch-vcs`, - { cwd: ctx.appRoot(), windowsHide: true, timeout: 120000, env: pipEnv() }, - ); - } catch (e) { - const output = e instanceof Error ? e.message : String(e); - return { success: false, output: `活动热修复构建依赖安装失败: ${output}` }; - } - - const targetDir = localSitePackages(); - if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true }); - const runtimeDeps = await new Promise<{ code: number; output: string }>((resolve) => { - const proc = spawn( - pythonCmd, - buildBackendRuntimeInstallArgs(targetDir), - { - cwd: ctx.appRoot(), - windowsHide: true, - stdio: 'pipe', - env: pipEnv(), - }, - ); - let output = ''; - proc.stdout?.on('data', (d: Buffer) => { output += d.toString(); }); - proc.stderr?.on('data', (d: Buffer) => { output += d.toString(); }); - proc.on('close', (code) => resolve({ code: code ?? 1, output })); - proc.on('error', (error) => resolve({ code: 1, output: error.message })); - }); - if (runtimeDeps.code !== 0) { - return { - success: false, - output: `后端运行依赖安装失败: ${runtimeDeps.output.slice(-500)}`, - }; - } - - return new Promise((resolve) => { - // 先删除旧版,再安装且不级联更新依赖。 - try { - for (const entry of fs.readdirSync(targetDir)) { - if (entry === 'autowsgr' || entry.startsWith('autowsgr-')) { - fs.rmSync(path.join(targetDir, entry), { recursive: true, force: true }); - } - } - } catch { /* 忽略清理失败。 */ } - - const proc = spawn(pythonCmd, [ - '-m', 'pip', 'install', - '--target', targetDir, - '--no-build-isolation', - '--no-deps', - '-i', 'https://pypi.tuna.tsinghua.edu.cn/simple', - '--trusted-host', 'pypi.tuna.tsinghua.edu.cn', - MANAGED_AUTOWSGR_REQUIREMENT, - ], { - cwd: ctx.appRoot(), - windowsHide: true, - stdio: 'pipe', - env: pipEnv(), - }); - let output = ''; - proc.stdout?.on('data', (d: Buffer) => { output += d.toString(); }); - proc.stderr?.on('data', (d: Buffer) => { output += d.toString(); }); - proc.on('close', (code) => { - resolve({ success: code === 0, output: output.slice(-500) }); - }); - proc.on('error', (err) => { - resolve({ success: false, output: err.message }); - }); - }); -} diff --git a/electron/pythonEnv/utils.ts b/electron/pythonEnv/utils.ts index 2d0917f..f4f13f5 100644 --- a/electron/pythonEnv/utils.ts +++ b/electron/pythonEnv/utils.ts @@ -34,13 +34,6 @@ export function localSitePackages(): string { return path.join(getCtx().appRoot(), 'python', 'site-packages'); } -/** 生成向 sys.path 插入 site-packages 的 Python 代码。 */ -export function sysPathInsert(): string { - // 嵌入式 Python 会忽略 PYTHONPATH,直接写入 sys.path 更可靠。 - const sp = localSitePackages().replace(/\\/g, '\\\\'); - return `import sys; sys.path.insert(0, r'${sp}'); `; -} - /** 构造优先使用项目包目录的 pip 环境。 */ export function pipEnv(): NodeJS.ProcessEnv { const localSite = localSitePackages(); diff --git a/electron/services/CombatPlanRepository.ts b/electron/services/CombatPlanRepository.ts index e6d2c1a..c162201 100644 --- a/electron/services/CombatPlanRepository.ts +++ b/electron/services/CombatPlanRepository.ts @@ -19,16 +19,6 @@ export class CombatPlanRepository { fs.mkdirSync(this.appPaths.userBattlePlansDir(), { recursive: true }); } - /** 初始化系统出征计划目录。 */ - initializeSystemDirectory(): void { - fs.mkdirSync(this.appPaths.systemBattlePlansDir(), { recursive: true }); - } - - /** 初始化指定来源的出征计划目录。 */ - initializeDirectory(source: PlanPresetSource): void { - fs.mkdirSync(this.directory(source), { recursive: true }); - } - /** 返回指定来源的权威出征计划目录。 */ directory(source: PlanPresetSource): string { return source === 'system' diff --git a/electron/services/SingleInstanceService.ts b/electron/services/SingleInstanceService.ts index d61da34..c5be05a 100644 --- a/electron/services/SingleInstanceService.ts +++ b/electron/services/SingleInstanceService.ts @@ -42,10 +42,6 @@ export class SingleInstanceService { return true; } - isPrimary(): boolean { - return this.primary; - } - setMainWindowProvider(provider: MainWindowProvider): void { this.mainWindowProvider = provider; } diff --git a/electron/services/TeamPlanRepository.ts b/electron/services/TeamPlanRepository.ts index c4513e2..93a1fc0 100644 --- a/electron/services/TeamPlanRepository.ts +++ b/electron/services/TeamPlanRepository.ts @@ -34,11 +34,6 @@ export class TeamPlanRepository { fs.mkdirSync(this.appPaths.userTeamPlansDir(), { recursive: true }); } - /** 初始化系统编队目录。 */ - initializeSystemDirectory(): void { - fs.mkdirSync(this.appPaths.systemTeamPlansDir(), { recursive: true }); - } - /** 返回指定来源的权威编队目录。 */ directory(source: PlanPresetSource): string { return source === 'system' diff --git a/scripts/main-services/test-combat-plan.js b/scripts/main-services/test-combat-plan.js index 68f87bd..0615fcb 100644 --- a/scripts/main-services/test-combat-plan.js +++ b/scripts/main-services/test-combat-plan.js @@ -4,6 +4,9 @@ * 复用同一隔离临时目录,不读取或修改真实用户数据。 */ const context = require('./test-context'); +const { + createDirectories, +} = require('../test-support/directories'); const { assert, EventEmitter, @@ -87,9 +90,11 @@ function testCombatPlanServices() { settings, new TaskPresetCodec(), ); - combatRepository.initializeSystemDirectory(); + createDirectories( + appPaths.systemBattlePlansDir(), + appPaths.systemTeamPlansDir(), + ); combatRepository.initializeUserDirectory(); - teamRepository.initializeSystemDirectory(); teamRepository.initializeUserDirectory(); const split = combatCodec.normalizeFleetPresets({ diff --git a/scripts/main-services/test-single-instance.js b/scripts/main-services/test-single-instance.js index b10aab1..9333593 100644 --- a/scripts/main-services/test-single-instance.js +++ b/scripts/main-services/test-single-instance.js @@ -28,8 +28,9 @@ function testSingleInstanceService() { listeners.set(event, listener); }, }); - assert.equal(primary.acquire(), true); - if (primary.isPrimary()) primaryStarts += 1; + const primaryAcquired = primary.acquire(); + assert.equal(primaryAcquired, true); + if (primaryAcquired) primaryStarts += 1; const secondary = new SingleInstanceService({ requestSingleInstanceLock() { @@ -42,8 +43,9 @@ function testSingleInstanceService() { throw new Error('次实例不应注册生命周期事件'); }, }); - assert.equal(secondary.acquire(), false); - if (secondary.isPrimary()) secondaryStarts += 1; + const secondaryAcquired = secondary.acquire(); + assert.equal(secondaryAcquired, false); + if (secondaryAcquired) secondaryStarts += 1; assert.equal(primaryStarts, 1); assert.equal(secondaryStarts, 0); diff --git a/scripts/main-services/test-team-plan.js b/scripts/main-services/test-team-plan.js index 7b70cce..209e27d 100644 --- a/scripts/main-services/test-team-plan.js +++ b/scripts/main-services/test-team-plan.js @@ -4,6 +4,9 @@ * 复用同一隔离临时目录,不读取或修改真实用户数据。 */ const context = require('./test-context'); +const { + createDirectories, +} = require('../test-support/directories'); const { assert, EventEmitter, @@ -55,7 +58,7 @@ function testTeamPlanServices() { codec, ); const service = new TeamPlanService(codec, repository); - repository.initializeSystemDirectory(); + createDirectories(appPaths.systemTeamPlansDir()); repository.initializeUserDirectory(); const candidateOnly = codec.normalize({ diff --git a/scripts/test-scheduler-domain.mjs b/scripts/test-scheduler-domain.mjs index b26a9a9..0239cd0 100644 --- a/scripts/test-scheduler-domain.mjs +++ b/scripts/test-scheduler-domain.mjs @@ -59,7 +59,7 @@ const cronConfig = { lootPlanId: 'bettle-周常-9-2.yaml', lootStopCount: 0, }; const cron = new cronModule.CronScheduler(cronConfig, storage); -cron.markBattleCompleted(); +cron.markBattleHandled(); const restoredCron = new cronModule.CronScheduler(cronConfig, storage); restoredCron.start(); restoredCron.stop(); diff --git a/scripts/test-support/directories.js b/scripts/test-support/directories.js new file mode 100644 index 0000000..1a1e92c --- /dev/null +++ b/scripts/test-support/directories.js @@ -0,0 +1,16 @@ +/** + * 测试目录准备工具。 + * + * 只用于在隔离临时目录中创建测试所需结构。 + */ +const fs = require('node:fs'); + +function createDirectories(...directories) { + for (const directory of directories) { + fs.mkdirSync(directory, { recursive: true }); + } +} + +module.exports = { + createDirectories, +}; diff --git a/scripts/test-task-group-migration.js b/scripts/test-task-group-migration.js index c1e9ad7..7b39c23 100644 --- a/scripts/test-task-group-migration.js +++ b/scripts/test-task-group-migration.js @@ -217,7 +217,7 @@ async function verifyRealLegacyFixtureLifecycle() { const model = new TaskGroupModel(); await model.load(); - const migrated = model.toJSON(); + const migrated = JSON.parse(state.stored); assert.equal(migrated.version, 4); assert.equal(state.saves, 1); @@ -284,7 +284,7 @@ async function verifyRealLegacyFixtureLifecycle() { const reloaded = new TaskGroupModel(); await reloaded.load(); - const reloadedItem = reloaded.toJSON().groups[0].items[0]; + const reloadedItem = reloaded.groups[0].items[0]; assert.equal(reloadedItem.managedSource, 'user'); assert.equal(reloadedItem.managedFile, '9-4胖次6SS.yaml'); assert.equal(state.saves, 2); @@ -313,7 +313,7 @@ async function verifyActivityAliases() { const model = new TaskGroupModel(); await model.load(); - const migrated = model.toJSON(); + const migrated = JSON.parse(state.stored); assert.equal(migrated.rootExtension.preserved, true); assert.equal(migrated.groups[0].groupExtension, 'keep'); migrated.groups[0].items.forEach((item, index) => { @@ -349,7 +349,7 @@ async function verifyLegacyWeeklyMapSemantics() { const model = new TaskGroupModel(); await model.load(); - const items = model.toJSON().groups[0].items; + const items = model.groups[0].items; legacyWeeklyPlans.forEach(( [legacyFile, managedFile, chapter, map], index, @@ -434,13 +434,14 @@ async function verifyInterimVersion() { const model = new TaskGroupModel(); await model.load(); - assert.equal(model.toJSON().version, 4); + const migrated = JSON.parse(state.stored); + assert.equal(migrated.version, 4); assert.equal( - model.toJSON().groups[0].items[0].managedFile, + model.groups[0].items[0].managedFile, 'bettle-E1炸鱼.yaml', ); assert.equal( - model.toJSON().groups[0].items[0].managedSource, + model.groups[0].items[0].managedSource, 'user', ); assert.equal( diff --git a/src/adapter/YamlAdapter.ts b/src/adapter/YamlAdapter.ts index f3981cb..f953bed 100644 --- a/src/adapter/YamlAdapter.ts +++ b/src/adapter/YamlAdapter.ts @@ -1,4 +1,4 @@ -/** 统一 YAML 编解码及作战方案元数据读取。 */ +/** 统一 YAML 编解码和结构校验。 */ import * as yaml from 'js-yaml'; function isRecord(value: unknown): value is Record { @@ -92,31 +92,3 @@ function parseLineStringMap( } return output; } - -export interface PlanMetadata { - chapter: number | string; - map: number | string; - event?: string; - mode?: string; - times?: number; - gap?: number; - task_type?: string; - campaign_name?: string; -} - -export function readPlanMetadata(content: string): PlanMetadata { - const raw = parseYamlRecord(content, '方案文件'); - - const chapterRaw = raw.chapter; - const mapRaw = raw.map; - return { - chapter: typeof chapterRaw === 'string' ? chapterRaw : Number(chapterRaw) || 0, - map: typeof mapRaw === 'string' ? mapRaw : Number(mapRaw) || 0, - event: typeof raw.event === 'string' ? raw.event : undefined, - mode: typeof raw.mode === 'string' ? raw.mode : undefined, - times: raw.times == null ? undefined : Number(raw.times), - gap: raw.gap == null ? undefined : Number(raw.gap), - task_type: typeof raw.task_type === 'string' ? raw.task_type : undefined, - campaign_name: typeof raw.campaign_name === 'string' ? raw.campaign_name : undefined, - }; -} diff --git a/src/controller/app/SchedulerBinder.ts b/src/controller/app/SchedulerBinder.ts index 33e20f6..67aea85 100644 --- a/src/controller/app/SchedulerBinder.ts +++ b/src/controller/app/SchedulerBinder.ts @@ -59,6 +59,10 @@ export class SchedulerBinder { this.runtime.reset(); } + /** + * 解绑运行状态监听。 + * 当前没有生产调用方,保留为正式生命周期清理接口。 + */ dispose(): void { this.runtime.dispose(); } diff --git a/src/controller/app/constants.ts b/src/controller/app/constants.ts index 82df283..fca6f5b 100644 --- a/src/controller/app/constants.ts +++ b/src/controller/app/constants.ts @@ -1,6 +1,5 @@ -/** 维护任务优先级、状态文案和修理模式等界面常量。 */ +/** 维护任务优先级和状态文案等界面常量。 */ import { TaskPriority } from '../../model/scheduler'; -import { REPAIR_MODE_NAMES } from '../../types/model.js'; /** 优先级 → 中文标签 */ export const PRIORITY_LABELS: Record = { @@ -16,15 +15,3 @@ export const STATUS_TEXT: Record = { stopping: '正在停止…', not_connected: '未连接', }; - -/** 将 repair_mode(数字或数组)转换为显示文本 */ -export function resolveRepairModeLabel(mode: number | number[]): string { - if (Array.isArray(mode)) { - const unique = [...new Set(mode)]; - if (unique.length === 1) return REPAIR_MODE_NAMES[unique[0]] ?? '中破就修'; - const circled = ['①','②','③','④','⑤','⑥']; - const short: Record = { 1: '中破', 2: '大破' }; - return mode.map((v, i) => `${circled[i] ?? (i+1)}${short[v] ?? v}`).join(' '); - } - return REPAIR_MODE_NAMES[mode] ?? '中破就修'; -} diff --git a/src/controller/startup/StartupController.ts b/src/controller/startup/StartupController.ts index 5ef63ea..2b7e993 100644 --- a/src/controller/startup/StartupController.ts +++ b/src/controller/startup/StartupController.ts @@ -8,7 +8,7 @@ import { type StartupGateway, } from '../../adapter/IpcAdapter.js'; import { Logger } from '../../utils/Logger'; -import { checkAndPrepareEnv, runSetupScript, checkForUpdates } from './envAndUpdates'; +import { checkAndPrepareEnv, checkForUpdates } from './envAndUpdates'; import { waitForBackendAndConnect } from './connection'; import type { StartupHost } from '../contracts.js'; @@ -83,18 +83,6 @@ export class StartupController { waitForBackendAndConnect(this.host); } - /** 代理: 环境检查 */ - async checkAndPrepareEnv( - bridge: StartupGateway, - ): Promise { - return checkAndPrepareEnv(bridge); - } - - /** 代理: 运行 setup.bat */ - async runSetupScript(bridge: StartupGateway): Promise { - return runSetupScript(bridge); - } - /** 代理: 等待后端并连接 */ waitForBackendAndConnect(retries?: number): void { waitForBackendAndConnect(this.host, retries); diff --git a/src/controller/taskGroup/addItems.ts b/src/controller/taskGroup/addItems.ts index 552332f..481eee0 100644 --- a/src/controller/taskGroup/addItems.ts +++ b/src/controller/taskGroup/addItems.ts @@ -10,7 +10,6 @@ import type { ManagedBattlePlan, } from '../../types/ipc.js'; import { Logger } from '../../utils/Logger'; -import { yamlCodec } from '../../adapter'; import { getTaskGroupRepository, type TaskGroupRepository, @@ -113,46 +112,6 @@ export async function addCurrentPlanToGroup( Logger.info(`已将「${label} ×${times}」加入任务组「${group.name}」`); } -/** 从文件选择器添加方案/预设文件到任务组 */ -export async function addFileToGroup( - taskGroupModel: TaskGroupModel, - plansDir: string, - render: () => void, - repository: TaskGroupRepository | undefined = - getTaskGroupRepository(), -): Promise { - if (!repository) return; - const group = ensureActiveGroup(taskGroupModel); - - const result = await repository.openFileDialog!([ - { name: 'YAML 方案/预设', extensions: ['yaml', 'yml'] }, - ], plansDir || undefined); - if (!result) return; - - const parsed = yamlCodec.parse>(result.content); - let itemKind: 'plan' | 'preset' = 'plan'; - if ( - parsed - && typeof parsed === 'object' - && 'task_type' in parsed - && !('map' in parsed) - ) { - itemKind = 'preset'; - } - - const label = result.path.split(/[\\/]/).pop()?.replace(/\.ya?ml$/i, '') ?? result.path; - const parsedTimes = Number(parsed?.times); - taskGroupModel.addItem(group.name, { - path: result.path, - kind: itemKind, - times: Number.isFinite(parsedTimes) && parsedTimes > 0 ? parsedTimes : 1, - label, - }); - taskGroupModel.save(); - render(); - Logger.info(`已添加「${label}」到任务组「${group.name}」`); -} - /** 将当前任务预设添加到任务组 */ export function addPresetToGroup( taskGroupModel: TaskGroupModel, diff --git a/src/model/ApiClient.ts b/src/model/ApiClient.ts index 3c092c1..d70d5e8 100644 --- a/src/model/ApiClient.ts +++ b/src/model/ApiClient.ts @@ -81,6 +81,7 @@ export class ApiClient { return this.request('GET', '/api/system/status'); } + /** 当前没有生产调用方引用,保留给模拟器设备选择功能。 */ async emulatorDevices(): Promise> { return this.request('GET', '/api/system/emulator/devices', undefined, 15000); } @@ -191,6 +192,7 @@ export class ApiClient { return this.request('POST', '/api/build/collect'); } + /** 当前没有生产调用方引用,保留给建造启动功能。 */ async buildStart(fuel = 30, ammo = 30, steel = 30, bauxite = 30): Promise { return this.request('POST', '/api/build/start', { fuel, ammo, steel, bauxite }); } @@ -214,6 +216,7 @@ export class ApiClient { return this.request('POST', '/api/repair/ship', { ship_name: shipName }); } + /** 当前没有生产调用方引用,保留给批量解体功能。 */ async destroy(shipTypes?: string[], removeEquipment = true): Promise { return this.request('POST', '/api/destroy', { ship_types: shipTypes ?? null, remove_equipment: removeEquipment }); } diff --git a/src/model/TaskGroupModel.ts b/src/model/TaskGroupModel.ts index 32561cc..368b874 100644 --- a/src/model/TaskGroupModel.ts +++ b/src/model/TaskGroupModel.ts @@ -299,9 +299,4 @@ export class TaskGroupModel { return false; } } - - /** 序列化 (用于调试) */ - toJSON(): TaskGroupsData { - return structuredClone(this.data); - } } diff --git a/src/model/scheduler/CronScheduler.ts b/src/model/scheduler/CronScheduler.ts index d194472..f04675a 100644 --- a/src/model/scheduler/CronScheduler.ts +++ b/src/model/scheduler/CronScheduler.ts @@ -199,16 +199,6 @@ export class CronScheduler { this.log('info', '演习任务已取消,本刷新时段不再重复触发'); } - /** Controller 在战役任务成功完成后调用 */ - markBattleCompleted(): void { - this.lastBattleRun = this.dateKey(new Date()); - this.battlePending = false; - try { - this.storage.set(LS_KEY_LAST_BATTLE_RUN, this.lastBattleRun); - } catch { /* ignore */ } - this.log('info', '战役任务完成,已记录运行时间'); - } - /** * Controller 在战役任务结束(成功或失败)后调用。 * @@ -234,16 +224,6 @@ export class CronScheduler { this.battlePending = false; } - /** Controller 在常规出击任务全部完成后调用 */ - markNormalFightCompleted(): void { - this.lastNormalFightRun = this.dateKey(new Date()); - this.normalFightPending = false; - try { - this.storage.set(LS_KEY_LAST_NORMAL_FIGHT_RUN, this.lastNormalFightRun); - } catch { /* ignore */ } - this.log('info', '自动常规出击完成,已记录运行时间'); - } - /** 常规出击任务已处理(成功或失败),今日不再重复 */ markNormalFightHandled(): void { this.lastNormalFightRun = this.dateKey(new Date()); @@ -258,16 +238,6 @@ export class CronScheduler { this.normalFightPending = false; } - /** 自动决战成功完成,记录今日执行状态。 */ - markDecisiveCompleted(): void { - this.lastDecisiveRun = this.dateKey(new Date()); - this.decisivePending = false; - try { - this.storage.set(LS_KEY_LAST_DECISIVE_RUN, this.lastDecisiveRun); - } catch { /* ignore */ } - this.log('info', '自动决战完成,已记录运行时间'); - } - /** * 自动决战已结束,无论成功与否今日均不重复。 * 决战可能在失败前消耗票数,禁止同日盲目重试。 @@ -286,16 +256,6 @@ export class CronScheduler { this.decisivePending = false; } - /** Controller 在战利品任务完成后调用 */ - markLootCompleted(): void { - this.lastLootRun = this.dateKey(new Date()); - this.lootPending = false; - try { - this.storage.set(LS_KEY_LAST_LOOT_RUN, this.lastLootRun); - } catch { /* ignore */ } - this.log('info', '自动战利品任务完成,已记录运行时间'); - } - /** 战利品任务已处理(成功或失败),今日不再重复 */ markLootHandled(): void { this.lastLootRun = this.dateKey(new Date()); @@ -327,32 +287,18 @@ export class CronScheduler { } catch { /* ignore */ } } - /** 注册一个定时方案任务 */ + /** 当前没有生产调用方引用,保留给 scheduled_time 后续接入。 */ registerScheduledTask(key: string, time: string): void { // 去重 if (this.scheduledTasks.some(t => t.key === key)) return; this.scheduledTasks.push({ key, time, firedToday: false }); } - /** 移除定时方案任务 */ + /** 当前没有生产调用方引用,保留给 scheduled_time 后续接入。 */ unregisterScheduledTask(key: string): void { this.scheduledTasks = this.scheduledTasks.filter(t => t.key !== key); } - /** 获取下一个演习时间点 (供 UI 显示) */ - getNextExerciseTime(): Date | null { - if (!this.config.autoExercise) return null; - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - - for (const h of EXERCISE_REFRESH_HOURS) { - const t = new Date(today.getTime() + h * 3600_000); - if (t > now) return t; - } - // 下一个是明天 0 点 - return new Date(today.getTime() + 24 * 3600_000); - } - // ── 核心 tick ── private tick(): void { diff --git a/src/model/scheduler/ExpeditionTimer.ts b/src/model/scheduler/ExpeditionTimer.ts index 23c5293..aa166d6 100644 --- a/src/model/scheduler/ExpeditionTimer.ts +++ b/src/model/scheduler/ExpeditionTimer.ts @@ -25,8 +25,6 @@ export class ExpeditionTimer { this.callbacks = callbacks; } - get intervalMs(): number { return this._intervalMs; } - /** 更新间隔(毫秒),如果正在运行则自动重启 */ setInterval(ms: number): void { this._intervalMs = ms; diff --git a/src/model/scheduler/RepairManager.ts b/src/model/scheduler/RepairManager.ts index 404f555..a6882ff 100644 --- a/src/model/scheduler/RepairManager.ts +++ b/src/model/scheduler/RepairManager.ts @@ -225,16 +225,6 @@ export class RepairManager { return -1; } - /** 是否有舰船正在泡澡 */ - get hasBathingShips(): boolean { - return this.bathingShips.size > 0; - } - - /** 获取正在泡澡的舰船列表 */ - get bathingShipNames(): string[] { - return Array.from(this.bathingShips.keys()); - } - /** 获取泡澡中舰船列表(只读引用,供 TaskQueue 计算动态延迟) */ getBathingShips(): ReadonlyMap { return this.bathingShips; diff --git a/src/model/scheduler/Scheduler.ts b/src/model/scheduler/Scheduler.ts index 875005c..e734663 100644 --- a/src/model/scheduler/Scheduler.ts +++ b/src/model/scheduler/Scheduler.ts @@ -207,7 +207,10 @@ export class Scheduler { if (this.autoExpedition) this.expeditionTimer.start(); } - /** 停止系统 */ + /** + * 停止系统并释放调度资源。 + * 当前没有生产调用方,保留为正式生命周期清理接口。 + */ async stop(): Promise { const hadRunningTask = this.currentTask !== null; const canceledLogicalIds = this.collectLogicalIds([ @@ -248,7 +251,10 @@ export class Scheduler { } } - /** 添加任务到队列 */ + /** + * 添加任务到队列。 + * 当前没有生产调用方传入 bathRepairConfig,保留给泡澡维修后续接入。 + */ addTask( name: string, type: SchedulerTaskType, @@ -311,14 +317,6 @@ export class Scheduler { return true; } - /** 请求停止当前正在运行的任务 */ - async stopCurrentTask(): Promise { - if (!this.currentTask) return false; - this.setStatus('stopping'); - const resp = await this.api.taskStop(); - return resp.success; - } - /** * 请求停止当前任务,等待后端 worker 退出后再放回队列。 * WebSocket 完成事件和状态轮询任一确认停止即可结束等待。 @@ -455,11 +453,6 @@ export class Scheduler { this.notifyQueueChange(); } - /** 获取延迟任务列表(只读) */ - get deferredTaskList(): ReadonlyArray { - return this._taskQueue.deferredItems; - } - // ── 内部: 消费循环 ── private async consumeNext(): Promise { diff --git a/src/model/scheduler/TaskQueue.ts b/src/model/scheduler/TaskQueue.ts index ef13439..d36093a 100644 --- a/src/model/scheduler/TaskQueue.ts +++ b/src/model/scheduler/TaskQueue.ts @@ -178,11 +178,6 @@ export class TaskQueue { } } - /** 清空队列(不清延迟任务) */ - clearQueue(): void { - this.queue = []; - } - // ── 延迟任务管理 ── /** 将任务放入延迟列表,不消耗 remainingTimes */ diff --git a/src/shared/lootPlans.ts b/src/shared/lootPlans.ts index 0ac8159..d042e2a 100644 --- a/src/shared/lootPlans.ts +++ b/src/shared/lootPlans.ts @@ -101,11 +101,6 @@ export function migrateLootPlanId(value: unknown): LootPlanId | null { : null; } -/** 只接受当前内置资源中明确支持的稳定计划标识。 */ -export function normalizeLootPlanId(value: unknown): LootPlanId { - return migrateLootPlanId(value) ?? DEFAULT_LOOT_PLAN_ID; -} - /** 生成来源与文件名共同组成的稳定选择值。 */ export function lootAutomationPlanKey( plan: Pick, diff --git a/src/shared/shipCatalog.ts b/src/shared/shipCatalog.ts index 2747494..4e55411 100644 --- a/src/shared/shipCatalog.ts +++ b/src/shared/shipCatalog.ts @@ -12,21 +12,3 @@ export const ALL_SHIPS: readonly ShipInfo[] = rawShips.map(ship => ({ nation: ship.nation, ship_type: ship.ship_type, })); - -export const ALL_NATIONS: readonly string[] = (() => { - const priority = [ - '中国', - '日本', - '德国', - '美国', - '英国', - '苏联', - '法国', - '意大利', - ]; - const all = [...new Set(ALL_SHIPS.map(ship => ship.nation))]; - return [ - ...priority.filter(nation => all.includes(nation)), - ...all.filter(nation => !priority.includes(nation)), - ]; -})(); diff --git a/src/types/model.ts b/src/types/model.ts index 91a6374..ca7ec72 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -192,14 +192,6 @@ export const FORMATION_NAMES: Record = { 5: '单横阵', }; -export const FIGHT_CONDITION_NAMES: Record = { - 1: '稳步前进', - 2: '火力万岁', - 3: '小心翼翼', - 4: '瞄准', - 5: '搜索阵型', -}; - export const REPAIR_MODE_NAMES: Record = { 1: '中破就修', 2: '大破才修', diff --git a/src/types/view.ts b/src/types/view.ts index 2d66dab..301f400 100644 --- a/src/types/view.ts +++ b/src/types/view.ts @@ -8,7 +8,6 @@ import type { import type { EventMapCatalogEntry, NormalFightTaskConfig, - ShipSlot, } from './model.js'; import type { DailySortieStatsSnapshot } from './statistics.js'; import type { @@ -270,11 +269,6 @@ export interface PlanPreviewViewObject { shipCountGe?: number; } -export interface FleetPresetVO { - name: string; - ships: ShipSlot[]; -} - export type FleetPresetCatalogStatus = 'loading' | 'ready' | 'error'; export interface PlanFleetPresetBindingViewObject { diff --git a/src/view/index.html b/src/view/index.html index 27ce8a0..534eb1e 100644 --- a/src/view/index.html +++ b/src/view/index.html @@ -685,7 +685,6 @@

节点路线

节点信息

点击地图上的节点查看和编辑

- -
- -
保留(0)
- - diff --git a/src/view/plan/BattlePlanLoaderView.ts b/src/view/plan/BattlePlanLoaderView.ts index 790f6d5..8ccf018 100644 --- a/src/view/plan/BattlePlanLoaderView.ts +++ b/src/view/plan/BattlePlanLoaderView.ts @@ -9,7 +9,7 @@ import { } from '../../shared/lootPlans.js'; import { MAX_NORMAL_FIGHT_DAILY_EXECUTIONS, -} from '../../model/scheduler/NormalFightDailyQuota.js'; +} from '../../shared/normalFightQuota.js'; import { LoaderDialog } from '../shared/LoaderDialog'; import { captureScrollPosition, diff --git a/src/view/plan/DecisivePlanView.ts b/src/view/plan/DecisivePlanView.ts index 12fa16a..d215904 100644 --- a/src/view/plan/DecisivePlanView.ts +++ b/src/view/plan/DecisivePlanView.ts @@ -16,14 +16,9 @@ * 保存时空槽不会写入 gui_settings.json。 */ import type { - ShipLibraryLabels, ShipLibraryManifest, ShipLibraryShip, } from '../../types/ipc.js'; -import { - SHIP_TYPE_FILTER_ORDER, - TYPE_LABELS, -} from '../../shared/fleetShipTypes'; import { showAlert, showConfirm, @@ -34,14 +29,11 @@ import { restoreScrollPosition, } from '../shared/scrollPosition'; import { - calculateGalleryBatchSize, - filterAndSortGalleryShips, - type GallerySortField, -} from './GalleryShipCollection'; + ShipGalleryView, +} from './ShipGalleryView'; import { createShipArtwork } from './ShipArtwork'; export type DecisiveLevel = 'level1' | 'level2'; -type FilterKind = 'group' | 'type' | 'country'; export interface DecisivePlanViewState { chapter: number; @@ -94,13 +86,6 @@ const LEVEL_LABELS: Record = { const MAIN_SLOT_COUNT = 6; const DEFAULT_BACKUP_SLOT_COUNT = 6; const DECISIVE_DRAG_MIME = 'application/x-autowsgr-decisive-ship'; -const EMPTY_LABELS: ShipLibraryLabels = { - ship_types: {}, - size_classes: {}, - role_classes: {}, - countries: {}, - variants: {}, -}; function requiredElement(id: string): T { const element = document.getElementById(id); @@ -134,60 +119,62 @@ export class DecisivePlanView { 'decisive-level2-list', ); private readonly backupScroll = this.backupList.parentElement!; - private readonly gallery = requiredElement( - 'decisive-ship-gallery', - ); - private readonly gallerySearch = requiredElement( - 'decisive-gallery-search', - ); - private readonly filterButtons = Array.from( - document.querySelectorAll( - '[data-decisive-filter-trigger]', - ), - ); - private readonly filterCount = requiredElement( - 'decisive-filter-count', - ); - private readonly filterPopover = requiredElement( - 'decisive-filter-popover', - ); - private readonly typeOptions = requiredElement( - 'decisive-filter-types', - ); - private readonly countryOptions = requiredElement( - 'decisive-filter-countries', - ); - private readonly refitFilter = requiredElement( - 'decisive-filter-refit-only', - ); - private readonly sortDescending = requiredElement( - 'decisive-sort-desc', - ); - private readonly galleryCount = requiredElement( - 'decisive-gallery-count', - ); - private readonly galleryResizeObserver: ResizeObserver; - private ships: ShipLibraryShip[] = []; - private labels: ShipLibraryLabels = EMPTY_LABELS; - private visibleGalleryShips: ShipLibraryShip[] = []; - private renderedGalleryCount = 0; + private readonly galleryView: ShipGalleryView; private galleryLevel: DecisiveLevel = 'level1'; private activeMainIndex = 0; private activeBackupIndex = 0; private backupSlotCount = DEFAULT_BACKUP_SLOT_COUNT; - private groupFilter: string | null = 'all'; - private typeFilters = new Set(); - private countryFilters = new Set(); - private refitOnly = false; - private sortField: GallerySortField = 'id'; - private descending = false; - private galleryDragScrollTop: number | null = null; private backupDragScroll: { top: number; left: number } | null = null; + constructor(private readonly host: DecisivePlanViewHost) { - this.galleryResizeObserver = new ResizeObserver( - () => this.ensureGalleryFilled(), - ); - this.galleryResizeObserver.observe(this.gallery); + this.galleryView = new ShipGalleryView({ + gallery: requiredElement('decisive-ship-gallery'), + countLabel: requiredElement('decisive-gallery-count'), + searchInput: requiredElement('decisive-gallery-search'), + filterButtons: Array.from( + document.querySelectorAll( + '[data-decisive-filter-trigger]', + ), + ), + filterCount: requiredElement('decisive-filter-count'), + filterPopover: requiredElement('decisive-filter-popover'), + typeOptions: requiredElement('decisive-filter-types'), + countryOptions: requiredElement('decisive-filter-countries'), + refitFilter: requiredElement( + 'decisive-filter-refit-only', + ), + sortDescending: requiredElement('decisive-sort-desc'), + resetButton: requiredElement( + 'btn-reset-decisive-filter', + ), + confirmButton: requiredElement( + 'btn-confirm-decisive-filter', + ), + }, { + activeSlotDescription: () => this.activeSlotDescription(), + isExcluded: ship => { + const state = this.host.getState(); + return state.level1.includes(ship.search_name) + || state.level2.includes(ship.search_name); + }, + assignShip: ship => this.assignGalleryShip(ship), + shipDisplayName: ship => ship.search_name, + isInteractionEnabled: () => this.editEnabled.checked, + drag: { + mime: DECISIVE_DRAG_MIME, + serialize: ship => JSON.stringify({ + source: 'gallery', + shipId: ship.id, + } satisfies DecisiveDragData), + onStart: () => this.rememberBackupScroll(), + onEnd: () => { + this.backupDragScroll = null; + this.clearDragOver(); + }, + }, + unavailableCountLabel: '图鉴不可用', + unavailableMessage: '舰船资料库不可用', + }); } bindActions(): void { @@ -216,111 +203,15 @@ export class DecisivePlanView { this.renderQueues(); this.renderGalleryTarget(); }); - this.gallerySearch.addEventListener('input', () => { - this.renderGallery(); - }); - this.filterButtons.forEach(button => { - button.addEventListener('click', event => { - event.stopPropagation(); - this.setFilterPopoverOpen(this.filterPopover.hidden); - }); - }); - this.filterPopover.addEventListener('click', event => { - const option = (event.target as HTMLElement).closest( - '[data-filter-kind]', - ); - if (option) { - this.setFilter( - option.dataset['filterKind'] as FilterKind, - option.dataset['filterValue'] ?? 'all', - ); - } - const sortOption = (event.target as HTMLElement).closest( - '[data-sort-field]', - ); - if (sortOption) { - this.sortField = sortOption.dataset['sortField'] as GallerySortField; - this.updateFilterControls(); - this.renderGallery(); - } - }); - this.sortDescending.addEventListener('change', () => { - this.descending = this.sortDescending.checked; - this.updateFilterControls(); - this.renderGallery(); - }); - this.refitFilter.addEventListener('change', () => { - this.refitOnly = this.refitFilter.checked; - this.updateFilterControls(); - this.renderGallery(); - }); - requiredElement('btn-reset-decisive-filter') - .addEventListener('click', () => { - this.groupFilter = 'all'; - this.typeFilters.clear(); - this.countryFilters.clear(); - this.refitOnly = false; - this.sortField = 'id'; - this.descending = false; - this.updateFilterControls(); - this.renderGallery(); - }); - requiredElement('btn-confirm-decisive-filter') - .addEventListener('click', () => this.setFilterPopoverOpen(false)); - document.addEventListener('click', event => { - const target = event.target as Node; - if ( - !this.filterPopover.hidden - && !this.filterPopover.contains(target) - && !this.filterButtons.some(button => button.contains(target)) - ) { - this.setFilterPopoverOpen(false); - } - }); - document.addEventListener('keydown', event => { - if (event.key === 'Escape') this.setFilterPopoverOpen(false); - }); - this.gallery.addEventListener('click', event => { - const card = (event.target as HTMLElement).closest( - '[data-decisive-gallery-ship-id]', - ); - if (!card) return; - const shipId = Number(card.dataset['decisiveGalleryShipId']); - const ship = this.ships.find(item => item.id === shipId); - if (ship) this.assignGalleryShip(ship); - }); - this.gallery.addEventListener('dragstart', event => { - const card = (event.target as HTMLElement).closest( - '[data-decisive-gallery-ship-id]', - ); - if (!card || !event.dataTransfer || !this.editEnabled.checked) return; - this.galleryDragScrollTop = this.gallery.scrollTop; - this.rememberBackupScroll(); - event.dataTransfer.effectAllowed = 'copyMove'; - event.dataTransfer.setData( - DECISIVE_DRAG_MIME, - JSON.stringify({ - source: 'gallery', - shipId: Number(card.dataset['decisiveGalleryShipId']), - } satisfies DecisiveDragData), - ); - }); - this.gallery.addEventListener('dragend', () => { - this.galleryDragScrollTop = null; - this.backupDragScroll = null; - this.clearDragOver(); - }); - this.gallery.addEventListener('scroll', () => { - const remaining = this.gallery.scrollHeight - - this.gallery.scrollTop - - this.gallery.clientHeight; - if (remaining < 480) this.appendGalleryBatch(); - }); requiredElement('btn-save-decisive-plan') .addEventListener('click', () => void this.save()); this.resetButton.addEventListener('click', () => void this.resetTeam()); } + dispose(): void { + this.galleryView.dispose(); + } + showLoaded(manifest: ShipLibraryManifest): void { this.loadShipLibrary(manifest); this.backupSlotCount = Math.max( @@ -376,23 +267,20 @@ export class DecisivePlanView { private loadShipLibrary(manifest: ShipLibraryManifest | null): void { if (!manifest?.ships) { - this.galleryCount.textContent = '图鉴不可用'; - this.labels = EMPTY_LABELS; - this.ships = []; + this.galleryView.clearLibrary(); + this.galleryView.showLoadError('舰船资料库不可用'); return; } - this.labels = { - ...EMPTY_LABELS, - ...manifest.labels, - }; - this.ships = manifest.ships.filter(ship => ( - Number.isFinite(ship.id) - && Boolean(ship.name) - && Boolean(ship.search_name) - && Boolean(ship.portraitUrl) - )); - this.renderFilterOptions(); + this.galleryView.showLibrary({ + labels: manifest.labels, + ships: manifest.ships.filter(ship => ( + Number.isFinite(ship.id) + && Boolean(ship.name) + && Boolean(ship.search_name) + && Boolean(ship.portraitUrl) + )), + }); } private render(): void { @@ -402,7 +290,7 @@ export class DecisivePlanView { this.editEnabled.checked = false; this.renderEditState(); this.renderQueues(); - this.renderGallery(); + this.galleryView.render(); this.renderGalleryTarget(); } @@ -410,13 +298,7 @@ export class DecisivePlanView { const editing = this.editEnabled.checked; this.resetButton.disabled = !editing; this.addBackupButton.disabled = !editing; - this.gallery.classList.toggle('is-locked', !editing); - this.gallery - .querySelectorAll('[data-decisive-gallery-ship-id]') - .forEach(card => { - card.disabled = !editing; - card.draggable = editing; - }); + this.galleryView.refreshInteractionState(); } private renderQueues(): void { @@ -481,7 +363,7 @@ export class DecisivePlanView { if (ship) { slot.append(createShipArtwork( ship, - this.shipTypeDisplay(ship), + this.galleryView.shipTypeDisplay(ship), )); } else { const fallback = document.createElement('span'); @@ -641,7 +523,7 @@ export class DecisivePlanView { } this.markDirty(); this.renderQueues(); - this.renderGallery(false); + this.galleryView.renderSelection(); this.renderGalleryTarget(); if (level === 'level2') this.scrollBackupSlotIntoView(this.activeBackupIndex); } @@ -662,7 +544,7 @@ export class DecisivePlanView { } this.markDirty(); this.renderQueues(); - this.renderGallery(false); + this.galleryView.renderSelection(); } private handleDrop( @@ -674,7 +556,7 @@ export class DecisivePlanView { try { const source = JSON.parse(raw) as DecisiveDragData; if (source.source === 'gallery') { - const ship = this.ships.find(item => item.id === Number(source.shipId)); + const ship = this.galleryView.shipById(Number(source.shipId)); if (ship) this.placeGalleryShip(ship, targetLevel, targetIndex, false); return; } @@ -694,7 +576,7 @@ export class DecisivePlanView { } catch { // 忽略非本页面产生的拖拽数据。 } finally { - this.galleryDragScrollTop = null; + this.galleryView.clearDragScroll(); this.backupDragScroll = null; this.clearDragOver(); } @@ -727,7 +609,7 @@ export class DecisivePlanView { ); this.markDirty(); this.renderQueues(); - this.renderGallery(false); + this.galleryView.renderSelection(); this.renderGalleryTarget(); if (targetLevel === 'level2') this.scrollBackupSlotIntoView(targetIndex); } @@ -754,9 +636,9 @@ export class DecisivePlanView { } private findDisplayShip(name: string): ShipLibraryShip | undefined { - return this.ships.find(ship => ( + return this.galleryView.ships().find(ship => ( ship.search_name === name && ship.variant === 'normal' - )) ?? this.ships.find(ship => ship.search_name === name); + )) ?? this.galleryView.ships().find(ship => ship.search_name === name); } private findConfiguredShip( @@ -766,233 +648,7 @@ export class DecisivePlanView { } private renderGalleryTarget(): void { - this.gallery - .querySelectorAll('[data-decisive-gallery-ship-id]') - .forEach(card => { - const shipId = Number(card.dataset['decisiveGalleryShipId']); - const ship = this.ships.find(item => item.id === shipId); - if (ship) this.updateGalleryCard(card, ship); - }); - this.updateGalleryCount(); - } - - private setFilter(kind: FilterKind, value: string): void { - if (kind === 'group') { - this.groupFilter = value; - this.typeFilters.clear(); - if (value !== 'all') { - this.ships.forEach(ship => { - if ( - (ship.size_class === value || ship.role_class === value) - && SHIP_TYPE_FILTER_ORDER.includes(ship.ship_type) - ) { - this.typeFilters.add(ship.ship_type); - } - }); - } - } else { - const filters = kind === 'type' - ? this.typeFilters - : this.countryFilters; - if (kind === 'type') this.groupFilter = null; - if (value === 'all') { - filters.clear(); - } else if (filters.has(value)) { - filters.delete(value); - } else { - filters.add(value); - } - } - this.updateFilterControls(); - this.renderGallery(); - } - - private setFilterPopoverOpen(open: boolean): void { - this.filterPopover.hidden = !open; - this.filterButtons.forEach(button => { - button.setAttribute( - 'aria-expanded', - String(open && button.dataset['decisiveFilterTrigger'] === 'filter'), - ); - }); - } - - private renderFilterOptions(): void { - this.typeOptions.replaceChildren( - this.createFilterOption('type', 'all', '全部'), - ...SHIP_TYPE_FILTER_ORDER - .filter(code => this.labels.ship_types[code] !== undefined) - .map(code => this.createFilterOption( - 'type', - code, - TYPE_LABELS[code] ?? this.labels.ship_types[code]!, - )), - ); - this.countryOptions.replaceChildren( - this.createFilterOption('country', 'all', '全部'), - ...Object.entries(this.labels.countries) - .map(([code, label]) => ( - this.createFilterOption('country', code, label) - )), - ); - this.updateFilterControls(); - } - - private createFilterOption( - kind: FilterKind, - value: string, - label: string, - ): HTMLButtonElement { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'fleet-filter-option'; - button.dataset['filterKind'] = kind; - button.dataset['filterValue'] = value; - button.setAttribute('aria-pressed', 'false'); - button.textContent = label; - return button; - } - - private updateFilterControls(): void { - this.filterPopover - .querySelectorAll('[data-filter-kind]') - .forEach(item => { - const kind = item.dataset['filterKind']; - const value = item.dataset['filterValue'] ?? 'all'; - const active = kind === 'group' - ? value === this.groupFilter - : kind === 'type' - ? value === 'all' - ? this.typeFilters.size === 0 - : this.typeFilters.has(value) - : value === 'all' - ? this.countryFilters.size === 0 - : this.countryFilters.has(value); - item.classList.toggle('active', active); - item.setAttribute('aria-pressed', String(active)); - }); - this.filterPopover - .querySelectorAll('[data-sort-field]') - .forEach(item => { - item.classList.toggle( - 'active', - item.dataset['sortField'] === this.sortField, - ); - }); - this.sortDescending.checked = this.descending; - this.refitFilter.checked = this.refitOnly; - const activeFilterCount = [ - this.typeFilters.size > 0, - this.countryFilters.size > 0, - this.refitOnly, - ].filter(Boolean).length; - this.filterCount.textContent = activeFilterCount > 0 - ? String(activeFilterCount) - : ''; - this.filterButtons.forEach(button => { - button.classList.toggle( - 'active', - activeFilterCount > 0 - || this.sortField !== 'id' - || this.descending, - ); - }); - } - - private renderGallery(resetScroll = true): void { - const preservedScroll = captureScrollPosition(this.gallery); - if (!resetScroll && this.galleryDragScrollTop !== null) { - preservedScroll.top = this.galleryDragScrollTop; - } - const previousRenderedCount = resetScroll - ? 0 - : this.renderedGalleryCount; - const state = this.host.getState(); - const selectedNames = new Set([ - ...state.level1, - ...state.level2, - ]); - this.visibleGalleryShips = filterAndSortGalleryShips(this.ships, { - searchText: this.gallerySearch.value, - typeFilters: this.typeFilters, - countryFilters: this.countryFilters, - refitOnly: this.refitOnly, - sortField: this.sortField, - descending: this.descending, - shipTypeLabels: this.labels.ship_types, - isExcluded: ship => selectedNames.has(ship.search_name), - }); - - this.renderedGalleryCount = 0; - this.gallery.replaceChildren(); - this.updateGalleryCount(); - if (this.visibleGalleryShips.length === 0) { - this.showGalleryMessage( - this.ships.length === 0 - ? '舰船资料库不可用' - : '没有符合当前条件的舰娘', - ); - return; - } - this.appendGalleryBatch(Math.max( - this.galleryBatchSize(), - previousRenderedCount, - )); - restoreScrollPosition( - this.gallery, - resetScroll ? { top: 0, left: 0 } : preservedScroll, - ); - } - - /** 根据图鉴当前宽高计算首屏和下一批需要的卡片数量。 */ - private galleryBatchSize(): number { - return calculateGalleryBatchSize( - this.gallery.clientWidth, - this.gallery.clientHeight, - ); - } - - /** 窗口变大时补齐新增列和可见行,不重建图鉴。 */ - private ensureGalleryFilled(): void { - if (this.visibleGalleryShips.length === 0) return; - const missingCount = this.galleryBatchSize() - this.renderedGalleryCount; - if (missingCount > 0) this.appendGalleryBatch(missingCount); - } - - private appendGalleryBatch(count = this.galleryBatchSize()): void { - if (this.renderedGalleryCount >= this.visibleGalleryShips.length) return; - const preservedScroll = captureScrollPosition(this.gallery); - const fragment = document.createDocumentFragment(); - const end = Math.min( - this.renderedGalleryCount + count, - this.visibleGalleryShips.length, - ); - for (let index = this.renderedGalleryCount; index < end; index += 1) { - fragment.append(this.createGalleryCard(this.visibleGalleryShips[index])); - } - this.renderedGalleryCount = end; - this.gallery.append(fragment); - restoreScrollPosition(this.gallery, preservedScroll); - } - - private createGalleryCard(ship: ShipLibraryShip): HTMLButtonElement { - const card = document.createElement('button'); - card.type = 'button'; - card.className = 'fleet-ship-card'; - card.dataset['decisiveGalleryShipId'] = String(ship.id); - card.append(createShipArtwork(ship, this.shipTypeDisplay(ship))); - this.updateGalleryCard(card, ship); - return card; - } - - private updateGalleryCard( - card: HTMLButtonElement, - ship: ShipLibraryShip, - ): void { - const editing = this.editEnabled.checked; - card.disabled = !editing; - card.draggable = editing; - card.title = `将 ${ship.search_name} 放入${this.activeSlotDescription()}`; + this.galleryView.updateCardTargets(); } private activeSlotDescription(): string { @@ -1002,15 +658,6 @@ export class DecisivePlanView { return `第 ${this.activeBackupIndex + 1} 个备选槽位`; } - private updateGalleryCount(): void { - if (this.ships.length === 0) { - this.galleryCount.textContent = '图鉴不可用'; - return; - } - this.galleryCount.textContent = - `显示 ${this.visibleGalleryShips.length} / ${this.ships.length} 艘`; - } - private async resetTeam(): Promise { if (!this.editEnabled.checked) return; const confirmed = await showConfirm( @@ -1038,7 +685,7 @@ export class DecisivePlanView { ); this.markDirty(); this.renderQueues(); - this.renderGallery(false); + this.galleryView.renderSelection(); this.renderGalleryTarget(); } @@ -1109,17 +756,4 @@ export class DecisivePlanView { ); } - private shipTypeDisplay(ship: ShipLibraryShip): string { - const typeName = TYPE_LABELS[ship.ship_type] - ?? this.labels.ship_types[ship.ship_type] - ?? ship.ship_type; - return `${typeName}-${ship.ship_type.toUpperCase()}`; - } - - private showGalleryMessage(message: string): void { - const empty = document.createElement('div'); - empty.className = 'fleet-library-empty'; - empty.textContent = message; - this.gallery.replaceChildren(empty); - } } diff --git a/src/view/plan/FleetGalleryView.ts b/src/view/plan/FleetGalleryView.ts index 3d1ee78..bbab778 100644 --- a/src/view/plan/FleetGalleryView.ts +++ b/src/view/plan/FleetGalleryView.ts @@ -1,34 +1,11 @@ -/** 展示舰船图鉴并管理筛选、排序和选择等 UI 状态。 */ -import type { - ShipLibraryLabels, - ShipLibraryShip, -} from '../../types/ipc.js'; -import type { - FleetShipLibraryViewObject, -} from '../../types/view.js'; +/** 将舰队规划的编辑契约接入共享舰船图库。 */ +import type { ShipLibraryShip } from '../../types/ipc.js'; import { - SHIP_TYPE_FILTER_ORDER, - TYPE_LABELS, -} from '../../shared/fleetShipTypes'; -import { - calculateGalleryBatchSize, - filterAndSortGalleryShips, - type GallerySortField, -} from './GalleryShipCollection'; -import { createShipArtwork } from './ShipArtwork'; - -type FilterKind = 'group' | 'type' | 'country'; + ShipGalleryView, +} from './ShipGalleryView'; export const FLEET_DRAG_MIME = 'application/x-autowsgr-fleet'; -const EMPTY_LABELS: ShipLibraryLabels = { - ship_types: {}, - size_classes: {}, - role_classes: {}, - countries: {}, - variants: {}, -}; - export interface FleetGalleryViewHost { getRefitFilter(): boolean; setRefitFilter(enabled: boolean): void; @@ -39,439 +16,48 @@ export interface FleetGalleryViewHost { clearBackupDragScroll(): void; } -export class FleetGalleryView { - private readonly gallery = document.getElementById( - 'fleet-ship-gallery', - )!; - private readonly countLabel = document.getElementById( - 'fleet-library-count', - )!; - private readonly searchInput = document.getElementById( - 'fleet-ship-search', - ) as HTMLInputElement; - private readonly filterButtons = Array.from( - document.querySelectorAll( - '[data-fleet-filter-trigger]', - ), - ); - private readonly filterCount = document.getElementById( - 'fleet-filter-count', - )!; - private readonly filterPopover = document.getElementById( - 'fleet-filter-popover', - )!; - private readonly typeOptions = document.getElementById( - 'fleet-filter-types', - )!; - private readonly countryOptions = document.getElementById( - 'fleet-filter-countries', - )!; - private readonly refitFilter = document.getElementById( - 'fleet-filter-refit-only', - ) as HTMLInputElement; - private readonly sortDescending = document.getElementById( - 'fleet-sort-desc', - ) as HTMLInputElement; - private readonly resizeObserver: ResizeObserver; - - private labels: ShipLibraryLabels = EMPTY_LABELS; - private shipItems: ShipLibraryShip[] = []; - private visibleShips: ShipLibraryShip[] = []; - private backgroundUrl = ''; - private renderedShipCount = 0; - private groupFilter: string | null = 'all'; - private typeFilters = new Set(); - private countryFilters = new Set(); - private refitOnly: boolean; - private sortField: GallerySortField = 'id'; - private descending = false; - private searchText = ''; - private dragScrollTop: number | null = null; - - constructor(private readonly host: FleetGalleryViewHost) { - this.refitOnly = this.host.getRefitFilter(); - this.refitFilter.checked = this.refitOnly; - this.bindActions(); - this.resizeObserver = new ResizeObserver( - () => this.ensureGalleryFilled(), - ); - this.resizeObserver.observe(this.gallery); - } - - ships(): readonly ShipLibraryShip[] { - return this.shipItems; - } - - shipById(id: number): ShipLibraryShip | undefined { - return this.shipItems.find(ship => ship.id === id); - } - - colorfulBackgroundUrl(): string { - return this.backgroundUrl; - } - - shipTypeDisplay(ship: ShipLibraryShip): string { - const typeName = TYPE_LABELS[ship.ship_type] - ?? this.labels.ship_types[ship.ship_type] - ?? ship.ship_type; - return `${typeName}-${ship.ship_type.toUpperCase()}`; - } - - renderSelection(): void { - const scrollTop = this.dragScrollTop ?? this.gallery.scrollTop; - this.applyFilters(false, scrollTop); - requestAnimationFrame(() => { - this.gallery.scrollTop = scrollTop; - }); - } - - updateCardTargets(): void { - const description = this.host.activeSlotDescription(); - const shipsById = new Map(this.visibleShips.map(ship => [ship.id, ship])); - this.gallery - .querySelectorAll('[data-ship-id]') - .forEach(card => { - const ship = shipsById.get(Number(card.dataset['shipId'])); - if (ship) card.title = `将 ${ship.name} 放入${description}`; - }); - } - - showLibrary(library: FleetShipLibraryViewObject): void { - this.labels = { - ...EMPTY_LABELS, - ...library.labels, - }; - this.shipItems = [...library.ships]; - this.backgroundUrl = library.colorfulBackgroundUrl; - this.renderFilterOptions(); - this.applyFilters(); - } - - showLoading(): void { - this.countLabel.textContent = '正在读取资料库…'; - } - - showLoadError(message: string): void { - this.countLabel.textContent = '资料库不可用'; - this.showMessage(message); - } +function element(id: string): T { + const target = document.getElementById(id); + if (!target) throw new Error(`缺少舰队图库元素: #${id}`); + return target as T; +} - private bindActions(): void { - this.gallery.addEventListener('click', (event) => { - const card = (event.target as HTMLElement).closest( - '[data-ship-id]', - ); - if (!card) return; - const ship = this.shipById(Number(card.dataset['shipId'])); - if (ship) this.host.assignShip(ship); - }); - this.gallery.addEventListener('dragstart', (event) => { - const card = (event.target as HTMLElement).closest( - '[data-ship-id]', - ); - if (!card || !event.dataTransfer) return; - this.dragScrollTop = this.gallery.scrollTop; - this.host.rememberBackupScroll(); - event.dataTransfer.effectAllowed = 'copyMove'; - event.dataTransfer.setData( - FLEET_DRAG_MIME, - JSON.stringify({ +export class FleetGalleryView extends ShipGalleryView { + constructor(host: FleetGalleryViewHost) { + super({ + gallery: element('fleet-ship-gallery'), + countLabel: element('fleet-library-count'), + searchInput: element('fleet-ship-search'), + filterButtons: Array.from( + document.querySelectorAll( + '[data-fleet-filter-trigger]', + ), + ), + filterCount: element('fleet-filter-count'), + filterPopover: element('fleet-filter-popover'), + typeOptions: element('fleet-filter-types'), + countryOptions: element('fleet-filter-countries'), + refitFilter: element('fleet-filter-refit-only'), + sortDescending: element('fleet-sort-desc'), + resetButton: element('btn-reset-fleet-filter'), + confirmButton: element('btn-confirm-fleet-filter'), + }, { + getRefitFilter: () => host.getRefitFilter(), + setRefitFilter: enabled => host.setRefitFilter(enabled), + activeSlotDescription: () => host.activeSlotDescription(), + isExcluded: ship => ( + host.selectedShips().some(selected => selected.id === ship.id) + ), + assignShip: ship => host.assignShip(ship), + drag: { + mime: FLEET_DRAG_MIME, + serialize: ship => JSON.stringify({ source: 'gallery', - shipId: Number(card.dataset['shipId']), + shipId: ship.id, }), - ); - }); - this.gallery.addEventListener('dragend', () => { - this.dragScrollTop = null; - this.host.clearBackupDragScroll(); - }); - this.gallery.addEventListener('scroll', () => { - const remaining = this.gallery.scrollHeight - - this.gallery.scrollTop - - this.gallery.clientHeight; - if (remaining < 480) this.appendGalleryBatch(); - }); - - this.searchInput.addEventListener('input', () => { - this.searchText = this.searchInput.value; - this.applyFilters(); - }); - this.filterButtons.forEach((button) => { - button.addEventListener('click', (event) => { - event.stopPropagation(); - this.setFilterPopoverOpen( - this.filterPopover.hidden ? 'filter' : null, - ); - }); - }); - this.filterPopover.addEventListener('click', (event) => { - const option = (event.target as HTMLElement).closest( - '[data-filter-kind]', - ); - if (option) { - this.setFilter( - option.dataset['filterKind'] as FilterKind, - option.dataset['filterValue'] ?? 'all', - ); - } - const sortOption = (event.target as HTMLElement).closest( - '[data-sort-field]', - ); - if (sortOption) { - this.sortField = sortOption.dataset['sortField'] as GallerySortField; - this.updateFilterControls(); - this.applyFilters(); - } - }); - document.addEventListener('click', (event) => { - const target = event.target as Node; - if ( - !this.filterPopover.hidden - && !this.filterPopover.contains(target) - && !this.filterButtons.some(button => button.contains(target)) - ) { - this.setFilterPopoverOpen(null); - } - }); - document.addEventListener('keydown', (event) => { - if (event.key === 'Escape') this.setFilterPopoverOpen(null); - }); - document.getElementById('btn-confirm-fleet-filter')?.addEventListener( - 'click', - () => this.setFilterPopoverOpen(null), - ); - this.sortDescending.addEventListener('change', () => { - this.descending = this.sortDescending.checked; - this.updateFilterControls(); - this.applyFilters(); - }); - this.refitFilter.addEventListener('change', () => { - this.refitOnly = this.refitFilter.checked; - this.host.setRefitFilter(this.refitOnly); - this.updateFilterControls(); - this.applyFilters(); - }); - document.getElementById('btn-reset-fleet-filter')?.addEventListener( - 'click', - () => { - this.groupFilter = 'all'; - this.typeFilters.clear(); - this.countryFilters.clear(); - this.refitOnly = false; - this.host.setRefitFilter(false); - this.sortField = 'id'; - this.descending = false; - this.updateFilterControls(); - this.applyFilters(); + onStart: () => host.rememberBackupScroll(), + onEnd: () => host.clearBackupDragScroll(), }, - ); - } - - private setFilter(kind: FilterKind, value: string): void { - if (kind === 'group') { - this.groupFilter = value; - this.typeFilters.clear(); - if (value !== 'all') { - this.shipItems.forEach((ship) => { - if ( - (ship.size_class === value || ship.role_class === value) - && SHIP_TYPE_FILTER_ORDER.includes(ship.ship_type) - ) { - this.typeFilters.add(ship.ship_type); - } - }); - } - } else { - const filters = kind === 'type' - ? this.typeFilters - : this.countryFilters; - if (kind === 'type') this.groupFilter = null; - if (value === 'all') { - filters.clear(); - } else if (filters.has(value)) { - filters.delete(value); - } else { - filters.add(value); - } - } - this.updateFilterControls(); - this.applyFilters(); - } - - private setFilterPopoverOpen(target: 'filter' | null): void { - this.filterPopover.hidden = target !== 'filter'; - this.filterButtons.forEach((button) => { - button.setAttribute( - 'aria-expanded', - String(button.dataset['fleetFilterTrigger'] === target), - ); }); } - - private renderFilterOptions(): void { - this.typeOptions.replaceChildren( - this.createFilterOption('type', 'all', '全部'), - ...SHIP_TYPE_FILTER_ORDER - .filter(code => this.labels.ship_types[code] !== undefined) - .map(code => this.createFilterOption( - 'type', - code, - TYPE_LABELS[code] ?? this.labels.ship_types[code]!, - )), - ); - this.countryOptions.replaceChildren( - this.createFilterOption('country', 'all', '全部'), - ...Object.entries(this.labels.countries) - .map(([code, label]) => ( - this.createFilterOption('country', code, label) - )), - ); - this.updateFilterControls(); - } - - private createFilterOption( - kind: FilterKind, - value: string, - label: string, - ): HTMLButtonElement { - const button = document.createElement('button'); - button.type = 'button'; - button.className = 'fleet-filter-option'; - button.dataset['filterKind'] = kind; - button.dataset['filterValue'] = value; - button.setAttribute('aria-pressed', 'false'); - button.textContent = label; - return button; - } - - private updateFilterControls(): void { - this.filterPopover - .querySelectorAll('[data-filter-kind]') - .forEach((item) => { - const kind = item.dataset['filterKind']; - const value = item.dataset['filterValue'] ?? 'all'; - const active = kind === 'group' - ? value === this.groupFilter - : kind === 'type' - ? value === 'all' - ? this.typeFilters.size === 0 - : this.typeFilters.has(value) - : value === 'all' - ? this.countryFilters.size === 0 - : this.countryFilters.has(value); - item.classList.toggle('active', active); - item.setAttribute('aria-pressed', String(active)); - }); - this.filterPopover - .querySelectorAll('[data-sort-field]') - .forEach((item) => { - item.classList.toggle( - 'active', - item.dataset['sortField'] === this.sortField, - ); - }); - this.sortDescending.checked = this.descending; - this.refitFilter.checked = this.refitOnly; - const activeFilterCount = [ - this.typeFilters.size > 0, - this.countryFilters.size > 0, - this.refitOnly, - ].filter(Boolean).length; - this.filterCount.textContent = activeFilterCount > 0 - ? String(activeFilterCount) - : ''; - this.filterButtons.forEach((button) => { - const active = activeFilterCount > 0 - || this.sortField !== 'id' - || this.descending; - button.classList.toggle('active', active); - }); - } - - private applyFilters( - resetScroll = true, - preservedScrollTop?: number, - ): void { - const previousScrollTop = resetScroll - ? 0 - : preservedScrollTop ?? this.gallery.scrollTop; - const previousRenderedShipCount = resetScroll - ? 0 - : this.renderedShipCount; - const selectedIds = new Set( - this.host.selectedShips().map(ship => ship.id), - ); - this.visibleShips = filterAndSortGalleryShips(this.shipItems, { - searchText: this.searchText, - typeFilters: this.typeFilters, - countryFilters: this.countryFilters, - refitOnly: this.refitOnly, - sortField: this.sortField, - descending: this.descending, - shipTypeLabels: this.labels.ship_types, - isExcluded: ship => selectedIds.has(ship.id), - }); - - this.countLabel.textContent = ( - `显示 ${this.visibleShips.length} / ${this.shipItems.length} 艘` - ); - this.renderedShipCount = 0; - this.gallery.replaceChildren(); - if (this.visibleShips.length === 0) { - this.showMessage('没有符合当前条件的舰娘'); - return; - } - this.appendGalleryBatch(Math.max( - this.galleryBatchSize(), - previousRenderedShipCount, - )); - this.gallery.scrollTop = previousScrollTop; - } - - private galleryBatchSize(): number { - return calculateGalleryBatchSize( - this.gallery.clientWidth, - this.gallery.clientHeight, - ); - } - - private ensureGalleryFilled(): void { - if (this.visibleShips.length === 0) return; - const missingCount = this.galleryBatchSize() - this.renderedShipCount; - if (missingCount > 0) this.appendGalleryBatch(missingCount); - } - - private appendGalleryBatch(count = this.galleryBatchSize()): void { - if (this.renderedShipCount >= this.visibleShips.length) return; - const scrollTop = this.gallery.scrollTop; - const fragment = document.createDocumentFragment(); - const end = Math.min( - this.renderedShipCount + count, - this.visibleShips.length, - ); - for (let index = this.renderedShipCount; index < end; index += 1) { - fragment.append(this.createShipCard(this.visibleShips[index])); - } - this.renderedShipCount = end; - this.gallery.append(fragment); - this.gallery.scrollTop = scrollTop; - } - - private createShipCard(ship: ShipLibraryShip): HTMLButtonElement { - const card = document.createElement('button'); - card.type = 'button'; - card.className = 'fleet-ship-card'; - card.dataset['shipId'] = String(ship.id); - card.draggable = true; - card.title = `将 ${ship.name} 放入${this.host.activeSlotDescription()}`; - card.append(createShipArtwork(ship, this.shipTypeDisplay(ship))); - return card; - } - - private showMessage(message: string): void { - const empty = document.createElement('div'); - empty.className = 'fleet-library-empty'; - empty.textContent = message; - this.gallery.replaceChildren(empty); - } } diff --git a/src/view/plan/FleetPlannerView.ts b/src/view/plan/FleetPlannerView.ts index be69d60..16ad3c5 100644 --- a/src/view/plan/FleetPlannerView.ts +++ b/src/view/plan/FleetPlannerView.ts @@ -125,6 +125,10 @@ export class FleetPlannerView { return this.host.hasUnsavedDraftChanges(this.presetNameInput.value); } + dispose(): void { + this.galleryView.dispose(); + } + private async newTeamPlan(): Promise { if (this.hasUnsavedFleetChanges()) { const confirmed = await showConfirm( diff --git a/src/view/plan/ShipGalleryView.ts b/src/view/plan/ShipGalleryView.ts new file mode 100644 index 0000000..6f943c6 --- /dev/null +++ b/src/view/plan/ShipGalleryView.ts @@ -0,0 +1,547 @@ +/** 管理可复用舰船图库的筛选、排序、增量渲染和卡片交互。 */ +import type { + ShipLibraryLabels, + ShipLibraryShip, +} from '../../types/ipc.js'; +import { + SHIP_TYPE_FILTER_ORDER, + TYPE_LABELS, +} from '../../shared/fleetShipTypes'; +import { + captureScrollPosition, + restoreScrollPosition, +} from '../shared/scrollPosition'; +import { + calculateGalleryBatchSize, + filterAndSortGalleryShips, + type GallerySortField, +} from './GalleryShipCollection'; +import { createShipArtwork } from './ShipArtwork'; + +type FilterKind = 'group' | 'type' | 'country'; + +const EMPTY_LABELS: ShipLibraryLabels = { + ship_types: {}, + size_classes: {}, + role_classes: {}, + countries: {}, + variants: {}, +}; + +export interface ShipGalleryElements { + gallery: HTMLElement; + countLabel: HTMLElement; + searchInput: HTMLInputElement; + filterButtons: readonly HTMLButtonElement[]; + filterCount: HTMLElement; + filterPopover: HTMLElement; + typeOptions: HTMLElement; + countryOptions: HTMLElement; + refitFilter: HTMLInputElement; + sortDescending: HTMLInputElement; + resetButton: HTMLButtonElement; + confirmButton: HTMLButtonElement; +} + +export interface ShipGalleryLibrary { + labels: ShipLibraryLabels; + ships: readonly ShipLibraryShip[]; + colorfulBackgroundUrl?: string; +} + +export interface ShipGalleryDragBehavior { + mime: string; + serialize(ship: ShipLibraryShip): string; + onStart?(): void; + onEnd?(): void; +} + +export interface ShipGalleryViewHost { + activeSlotDescription(): string; + isExcluded(ship: ShipLibraryShip): boolean; + assignShip(ship: ShipLibraryShip): void; + shipDisplayName?(ship: ShipLibraryShip): string; + getRefitFilter?(): boolean; + setRefitFilter?(enabled: boolean): void; + isInteractionEnabled?(): boolean; + drag?: ShipGalleryDragBehavior; + unavailableCountLabel?: string; + unavailableMessage?: string; +} + +export class ShipGalleryView { + private readonly eventController = new AbortController(); + private readonly resizeObserver: ResizeObserver; + private disposed = false; + private labels: ShipLibraryLabels = EMPTY_LABELS; + private shipItems: ShipLibraryShip[] = []; + private visibleShips: ShipLibraryShip[] = []; + private backgroundUrl = ''; + private renderedShipCount = 0; + private groupFilter: string | null = 'all'; + private typeFilters = new Set(); + private countryFilters = new Set(); + private refitOnly: boolean; + private sortField: GallerySortField = 'id'; + private descending = false; + private dragScrollTop: number | null = null; + + constructor( + private readonly elements: ShipGalleryElements, + private readonly host: ShipGalleryViewHost, + ) { + this.refitOnly = this.host.getRefitFilter?.() ?? false; + this.elements.refitFilter.checked = this.refitOnly; + this.bindActions(); + this.resizeObserver = new ResizeObserver( + () => this.ensureGalleryFilled(), + ); + this.resizeObserver.observe(this.elements.gallery); + } + + ships(): readonly ShipLibraryShip[] { + return this.shipItems; + } + + shipById(id: number): ShipLibraryShip | undefined { + return this.shipItems.find(ship => ship.id === id); + } + + colorfulBackgroundUrl(): string { + return this.backgroundUrl; + } + + shipTypeDisplay(ship: ShipLibraryShip): string { + const typeName = TYPE_LABELS[ship.ship_type] + ?? this.labels.ship_types[ship.ship_type] + ?? ship.ship_type; + return `${typeName}-${ship.ship_type.toUpperCase()}`; + } + + showLibrary(library: ShipGalleryLibrary): void { + this.labels = { + ...EMPTY_LABELS, + ...library.labels, + }; + this.shipItems = [...library.ships]; + this.backgroundUrl = library.colorfulBackgroundUrl ?? ''; + this.renderFilterOptions(); + this.render(); + } + + clearLibrary(): void { + this.labels = EMPTY_LABELS; + this.shipItems = []; + this.visibleShips = []; + this.backgroundUrl = ''; + } + + showLoading(): void { + this.elements.countLabel.textContent = '正在读取资料库…'; + } + + showLoadError(message: string): void { + this.elements.countLabel.textContent = ( + this.host.unavailableCountLabel ?? '资料库不可用' + ); + this.showMessage(message); + } + + render(resetScroll = true): void { + this.applyFilters(resetScroll); + } + + renderSelection(): void { + this.applyFilters(false); + } + + updateCardTargets(): void { + const shipsById = new Map(this.visibleShips.map(ship => [ship.id, ship])); + this.elements.gallery + .querySelectorAll('[data-ship-id]') + .forEach(card => { + const ship = shipsById.get(Number(card.dataset['shipId'])); + if (ship) this.updateCard(card, ship); + }); + } + + refreshInteractionState(): void { + const enabled = this.isInteractionEnabled(); + this.elements.gallery.classList.toggle('is-locked', !enabled); + this.updateCardTargets(); + } + + clearDragScroll(): void { + this.dragScrollTop = null; + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.eventController.abort(); + this.resizeObserver.disconnect(); + } + + private bindActions(): void { + const { + gallery, + searchInput, + filterButtons, + filterPopover, + sortDescending, + refitFilter, + resetButton, + confirmButton, + } = this.elements; + const listenerOptions = { + signal: this.eventController.signal, + }; + + gallery.addEventListener('click', event => { + const card = (event.target as HTMLElement).closest( + '[data-ship-id]', + ); + if (!card || !this.isInteractionEnabled()) return; + const ship = this.shipById(Number(card.dataset['shipId'])); + if (ship) this.host.assignShip(ship); + }, listenerOptions); + gallery.addEventListener('dragstart', event => { + const card = (event.target as HTMLElement).closest( + '[data-ship-id]', + ); + const drag = this.host.drag; + if ( + !card + || !drag + || !event.dataTransfer + || !this.isInteractionEnabled() + ) { + return; + } + const ship = this.shipById(Number(card.dataset['shipId'])); + if (!ship) return; + this.dragScrollTop = gallery.scrollTop; + drag.onStart?.(); + event.dataTransfer.effectAllowed = 'copyMove'; + event.dataTransfer.setData(drag.mime, drag.serialize(ship)); + }, listenerOptions); + gallery.addEventListener('dragend', () => { + this.dragScrollTop = null; + this.host.drag?.onEnd?.(); + }, listenerOptions); + gallery.addEventListener('scroll', () => { + const remaining = gallery.scrollHeight + - gallery.scrollTop + - gallery.clientHeight; + if (remaining < 480) this.appendGalleryBatch(); + }, listenerOptions); + + searchInput.addEventListener( + 'input', + () => this.render(), + listenerOptions, + ); + filterButtons.forEach(button => { + button.addEventListener('click', event => { + event.stopPropagation(); + this.setFilterPopoverOpen(filterPopover.hidden); + }, listenerOptions); + }); + filterPopover.addEventListener('click', event => { + const option = (event.target as HTMLElement).closest( + '[data-filter-kind]', + ); + if (option) { + this.setFilter( + option.dataset['filterKind'] as FilterKind, + option.dataset['filterValue'] ?? 'all', + ); + } + const sortOption = (event.target as HTMLElement).closest( + '[data-sort-field]', + ); + if (sortOption) { + this.sortField = sortOption.dataset['sortField'] as GallerySortField; + this.updateFilterControls(); + this.render(); + } + }, listenerOptions); + document.addEventListener('click', event => { + const target = event.target as Node; + if ( + !filterPopover.hidden + && !filterPopover.contains(target) + && !filterButtons.some(button => button.contains(target)) + ) { + this.setFilterPopoverOpen(false); + } + }, listenerOptions); + document.addEventListener('keydown', event => { + if (event.key === 'Escape') this.setFilterPopoverOpen(false); + }, listenerOptions); + confirmButton.addEventListener( + 'click', + () => this.setFilterPopoverOpen(false), + listenerOptions, + ); + sortDescending.addEventListener('change', () => { + this.descending = sortDescending.checked; + this.updateFilterControls(); + this.render(); + }, listenerOptions); + refitFilter.addEventListener('change', () => { + this.refitOnly = refitFilter.checked; + this.host.setRefitFilter?.(this.refitOnly); + this.updateFilterControls(); + this.render(); + }, listenerOptions); + resetButton.addEventListener('click', () => { + this.groupFilter = 'all'; + this.typeFilters.clear(); + this.countryFilters.clear(); + this.refitOnly = false; + this.host.setRefitFilter?.(false); + this.sortField = 'id'; + this.descending = false; + this.updateFilterControls(); + this.render(); + }, listenerOptions); + } + + private isInteractionEnabled(): boolean { + return this.host.isInteractionEnabled?.() ?? true; + } + + private setFilter(kind: FilterKind, value: string): void { + if (kind === 'group') { + this.groupFilter = value; + this.typeFilters.clear(); + if (value !== 'all') { + this.shipItems.forEach(ship => { + if ( + (ship.size_class === value || ship.role_class === value) + && SHIP_TYPE_FILTER_ORDER.includes(ship.ship_type) + ) { + this.typeFilters.add(ship.ship_type); + } + }); + } + } else { + const filters = kind === 'type' + ? this.typeFilters + : this.countryFilters; + if (kind === 'type') this.groupFilter = null; + if (value === 'all') { + filters.clear(); + } else if (filters.has(value)) { + filters.delete(value); + } else { + filters.add(value); + } + } + this.updateFilterControls(); + this.render(); + } + + private setFilterPopoverOpen(open: boolean): void { + this.elements.filterPopover.hidden = !open; + this.elements.filterButtons.forEach(button => { + button.setAttribute('aria-expanded', String(open)); + }); + } + + private renderFilterOptions(): void { + this.elements.typeOptions.replaceChildren( + this.createFilterOption('type', 'all', '全部'), + ...SHIP_TYPE_FILTER_ORDER + .filter(code => this.labels.ship_types[code] !== undefined) + .map(code => this.createFilterOption( + 'type', + code, + TYPE_LABELS[code] ?? this.labels.ship_types[code]!, + )), + ); + this.elements.countryOptions.replaceChildren( + this.createFilterOption('country', 'all', '全部'), + ...Object.entries(this.labels.countries) + .map(([code, label]) => ( + this.createFilterOption('country', code, label) + )), + ); + this.updateFilterControls(); + } + + private createFilterOption( + kind: FilterKind, + value: string, + label: string, + ): HTMLButtonElement { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'fleet-filter-option'; + button.dataset['filterKind'] = kind; + button.dataset['filterValue'] = value; + button.setAttribute('aria-pressed', 'false'); + button.textContent = label; + return button; + } + + private updateFilterControls(): void { + const { + filterPopover, + sortDescending, + refitFilter, + filterCount, + filterButtons, + } = this.elements; + filterPopover + .querySelectorAll('[data-filter-kind]') + .forEach(item => { + const kind = item.dataset['filterKind']; + const value = item.dataset['filterValue'] ?? 'all'; + const active = kind === 'group' + ? value === this.groupFilter + : kind === 'type' + ? value === 'all' + ? this.typeFilters.size === 0 + : this.typeFilters.has(value) + : value === 'all' + ? this.countryFilters.size === 0 + : this.countryFilters.has(value); + item.classList.toggle('active', active); + item.setAttribute('aria-pressed', String(active)); + }); + filterPopover + .querySelectorAll('[data-sort-field]') + .forEach(item => { + item.classList.toggle( + 'active', + item.dataset['sortField'] === this.sortField, + ); + }); + sortDescending.checked = this.descending; + refitFilter.checked = this.refitOnly; + const activeFilterCount = [ + this.typeFilters.size > 0, + this.countryFilters.size > 0, + this.refitOnly, + ].filter(Boolean).length; + filterCount.textContent = activeFilterCount > 0 + ? String(activeFilterCount) + : ''; + filterButtons.forEach(button => { + button.classList.toggle( + 'active', + activeFilterCount > 0 + || this.sortField !== 'id' + || this.descending, + ); + }); + } + + private applyFilters(resetScroll: boolean): void { + const gallery = this.elements.gallery; + const preservedScroll = captureScrollPosition(gallery); + if (!resetScroll && this.dragScrollTop !== null) { + preservedScroll.top = this.dragScrollTop; + } + const previousRenderedCount = resetScroll + ? 0 + : this.renderedShipCount; + this.visibleShips = filterAndSortGalleryShips(this.shipItems, { + searchText: this.elements.searchInput.value, + typeFilters: this.typeFilters, + countryFilters: this.countryFilters, + refitOnly: this.refitOnly, + sortField: this.sortField, + descending: this.descending, + shipTypeLabels: this.labels.ship_types, + isExcluded: ship => this.host.isExcluded(ship), + }); + + this.renderedShipCount = 0; + gallery.replaceChildren(); + this.updateCount(); + if (this.visibleShips.length === 0) { + this.showMessage( + this.shipItems.length === 0 + ? this.host.unavailableMessage ?? '没有符合当前条件的舰娘' + : '没有符合当前条件的舰娘', + ); + return; + } + this.appendGalleryBatch(Math.max( + this.galleryBatchSize(), + previousRenderedCount, + )); + restoreScrollPosition( + gallery, + resetScroll ? { top: 0, left: 0 } : preservedScroll, + ); + } + + private galleryBatchSize(): number { + return calculateGalleryBatchSize( + this.elements.gallery.clientWidth, + this.elements.gallery.clientHeight, + ); + } + + private ensureGalleryFilled(): void { + if (this.visibleShips.length === 0) return; + const missingCount = this.galleryBatchSize() - this.renderedShipCount; + if (missingCount > 0) this.appendGalleryBatch(missingCount); + } + + private appendGalleryBatch(count = this.galleryBatchSize()): void { + if (this.renderedShipCount >= this.visibleShips.length) return; + const gallery = this.elements.gallery; + const preservedScroll = captureScrollPosition(gallery); + const fragment = document.createDocumentFragment(); + const end = Math.min( + this.renderedShipCount + count, + this.visibleShips.length, + ); + for (let index = this.renderedShipCount; index < end; index += 1) { + fragment.append(this.createShipCard(this.visibleShips[index])); + } + this.renderedShipCount = end; + gallery.append(fragment); + restoreScrollPosition(gallery, preservedScroll); + } + + private createShipCard(ship: ShipLibraryShip): HTMLButtonElement { + const card = document.createElement('button'); + card.type = 'button'; + card.className = 'fleet-ship-card'; + card.dataset['shipId'] = String(ship.id); + card.append(createShipArtwork(ship, this.shipTypeDisplay(ship))); + this.updateCard(card, ship); + return card; + } + + private updateCard( + card: HTMLButtonElement, + ship: ShipLibraryShip, + ): void { + const enabled = this.isInteractionEnabled(); + card.disabled = !enabled; + card.draggable = enabled; + const shipName = this.host.shipDisplayName?.(ship) ?? ship.name; + card.title = `将 ${shipName} 放入${this.host.activeSlotDescription()}`; + } + + private updateCount(): void { + this.elements.countLabel.textContent = this.shipItems.length === 0 + && this.host.unavailableCountLabel + ? this.host.unavailableCountLabel + : `显示 ${this.visibleShips.length} / ${this.shipItems.length} 艘`; + } + + private showMessage(message: string): void { + const empty = document.createElement('div'); + empty.className = 'fleet-library-empty'; + empty.textContent = message; + this.elements.gallery.replaceChildren(empty); + } +} diff --git a/src/view/styles/pages/_config.scss b/src/view/styles/pages/_config.scss index 59aa329..5eb2b06 100644 --- a/src/view/styles/pages/_config.scss +++ b/src/view/styles/pages/_config.scss @@ -1,1103 +1,7 @@ -/* ═══ 配置页布局 ═══ */ -#page-config { - --config-control-height: 28px; - --config-text-field-width: 320px; - --config-short-field-width: 84px; - --config-row-width: 460px; - - height: calc(100vh - var(--nav-height) - var(--page-block-space)); - overflow: auto; - - input.input:not([type='range']):not([type='color']), - select.input { - height: var(--config-control-height); - min-height: var(--config-control-height); - padding: 0 8px; - font-size: 11px; - line-height: 1.2; - } - - select.input { - width: var(--config-select-width, 124px); - min-width: 0; - max-width: min(100%, var(--config-select-width, 124px)); - flex: 0 1 var(--config-select-width, 124px); - } - - textarea.input { - padding: 6px 8px; - font-size: 11px; - line-height: 1.4; - } - - .color-picker { - width: 36px; - height: var(--config-control-height); - } - - .accent-label { - font-size: 11px; - } - - .btn { - display: inline-flex; - align-items: center; - justify-content: center; - height: var(--config-control-height); - min-height: var(--config-control-height); - padding: 0 10px; - font-size: 11px; - line-height: 1; - white-space: nowrap; - } -} - -.config-header-actions { - display: flex; - flex: 0 0 auto; - align-items: center; - gap: 8px; -} - -.config-navigation-row { - position: sticky; - z-index: 20; - top: 0; - display: flex; - align-items: center; - width: min(100%, 1080px); - gap: 8px; - margin: 0 auto 5px; - padding: 4px 0; - background: var(--bg); -} - -.config-tabs { - display: flex; - flex: 0 0 auto; - gap: 3px; - padding: 3px; - overflow-x: auto; - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: var(--radius); -} - -.config-tab { - display: inline-flex; - flex: 0 0 auto; - align-items: center; - justify-content: center; - height: var(--config-control-height); - padding: 0 12px; - border: none; - border-radius: var(--radius); - color: var(--text-muted); - background: transparent; - font-size: 11px; - font-weight: 600; - cursor: pointer; - - &:hover { - color: var(--text-secondary); - background: var(--bg-hover); - } - - &.active { - color: var(--text); - background: var(--bg-card-solid); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12); - } -} - -.config-tab-description { - flex: 1 1 260px; - min-width: 0; - margin: 0; - overflow: hidden; - color: var(--text-muted); - font-size: 11px; - line-height: 1.45; - text-overflow: ellipsis; - white-space: nowrap; -} - -.config-list-panels { - width: min(100%, 1080px); - margin: 0 auto; - padding-bottom: 10px; - - [hidden] { - display: none !important; - } -} - -.config-list-panel { - display: flex; - flex-direction: column; - gap: 6px; -} - -.config-list-section { - overflow: hidden; - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--radius); -} - -.config-section-heading { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 8px; - padding: 8px 12px; - background: var(--bg-inset); - border-bottom: 1px solid var(--border); - - h3 { - flex: 0 0 auto; - margin: 0; - color: var(--text); - font-size: 13px; - } - - p { - flex: 1 1 260px; - min-width: 0; - margin: 0; - color: var(--text-muted); - font-size: 11px; - line-height: 1.45; - } -} - -.config-setting-list { - display: flex; - flex-direction: column; -} - -.config-setting-row { - display: grid; - grid-template-columns: minmax(220px, 0.9fr) minmax(300px, 1.1fr); - gap: 14px; - align-items: center; - min-height: 46px; - padding: 7px 12px; - border-bottom: 1px solid var(--border); - - &:last-child { - border-bottom: none; - } -} - -.config-setting-copy { - display: block; - line-height: 1.45; - - strong { - display: inline; - margin-right: 8px; - color: var(--text); - font-size: 12px; - } - - span { - display: inline; - color: var(--text-muted); - font-size: 11px; - } -} - -.config-setting-control { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - min-width: 0; - - > .input-row { - width: min(100%, var(--config-row-width)); - } - - > input.input { - width: min(100%, var(--config-text-field-width)); - max-width: var(--config-text-field-width); - } - - .input { - padding: 6px 9px; - font-size: 12px; - } - - .btn-small { - flex: 0 0 auto; - } -} - -.config-cuda-path-row { - flex-wrap: nowrap; - - .config-cuda-input-wrap { - position: relative; - flex: 1 1 auto; - min-width: 0; - - .input { - width: 100%; - padding-right: 82px; - } - - #cfg-cuda-status { - position: absolute; - top: 50%; - right: 6px; - max-width: 72px; - margin: 0; - padding: 2px 6px; - overflow: hidden; - font-size: 9px; - line-height: 1.2; - white-space: nowrap; - text-overflow: ellipsis; - transform: translateY(-50%); - } - } -} - -.config-setting-control-stack { - flex-direction: column; - align-items: flex-end; -} - -.config-auto-task-control { - flex-wrap: nowrap; - - > .config-switch { - flex: 0 0 auto; - } -} - -.config-auto-loot-control { - min-width: 0; - gap: 6px; - - .config-auto-task-options { - min-width: 0; - flex: 1 1 auto; - justify-content: flex-end; - gap: 6px; - } - - #cfg-loot-stop-count { - width: 56px !important; - flex-basis: 56px; - } -} - -.config-adb-controls { - flex-wrap: wrap; - justify-content: flex-end; - - .config-adb-input-wrap { - position: relative; - width: min(100%, 210px); - min-width: 180px; - max-width: 210px; - flex: 0 1 210px; - - .input { - width: 100%; - padding-right: 66px; - } - - .adb-status { - position: absolute; - top: 50%; - right: 6px; - margin: 0; - padding: 2px 6px; - font-size: 10px; - pointer-events: none; - transform: translateY(-50%); - } - } -} - -.config-setting-control > .config-compact-path-row { - width: min(100%, 360px); -} - -.config-setting-control > .config-backend-controls { - width: min(100%, 300px); - justify-content: flex-end; - - #cfg-backend-port { - width: 72px !important; - flex-basis: 72px; - } -} - -.config-setting-control > .config-update-controls { - flex-wrap: nowrap; - justify-content: flex-end; -} - -.config-update-progress { - display: grid; - flex: 0 1 160px; - gap: 4px; - width: 160px; - min-width: 120px; - - &[hidden] { - display: none; - } - - &[data-state='complete'] { - .config-update-progress-copy { - color: var(--success); - } - - .config-update-progress-track > span { - background: var(--success); - } - } - - &[data-state='error'] { - .config-update-progress-copy { - color: var(--danger); - } - - .config-update-progress-track > span { - background: var(--danger); - } - } -} - -.config-update-progress-copy { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - min-width: 0; - color: var(--text-muted); - font-size: 10px; - - #gui-update-status { - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - } - - #gui-update-percent { - flex: 0 0 auto; - font-variant-numeric: tabular-nums; - } -} - -.config-update-progress-track { - height: 4px; - overflow: hidden; - background: var(--bg-inset); - border-radius: var(--radius-pill); - - > span { - display: block; - width: 0; - height: 100%; - background: var(--accent); - border-radius: inherit; - transition: width 180ms ease; - } - - &.is-indeterminate > span { - width: 35% !important; - animation: config-update-progress-slide 1.1s ease-in-out infinite; - } -} - -@keyframes config-update-progress-slide { - from { - transform: translateX(-100%); - } - - to { - transform: translateX(300%); - } -} - -.config-inline-options { - display: flex; - align-items: center; - gap: 8px; - - > span { - color: var(--text-muted); - font-size: 11px; - white-space: nowrap; - } -} - -.config-auto-task-options { - min-width: 0; -} - -.config-status-badge { - flex: 0 0 auto; - padding: 2px 7px; - color: var(--warning); - background: var(--warning-bg); - border-radius: var(--radius-pill); - font-size: 10px; - white-space: nowrap; -} - -.config-status-readonly { - color: var(--text-muted); - background: var(--bg-inset); -} - -.config-prefix { - color: var(--text-muted); - font-size: 12px; - white-space: nowrap; -} - -.config-advanced-box { - display: flex; - flex-direction: column; - gap: 10px; - margin: 0 16px 12px; - padding: 12px; - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: var(--radius); -} - -.config-range-preview { - width: min(100%, 345px); - justify-self: end; - flex-direction: column; - align-items: stretch; - - label { - display: grid; - grid-template-columns: 40px minmax(90px, 1fr) 64px; - gap: 8px; - align-items: center; - color: var(--text-muted); - font-size: 11px; - } - - input[type='number'] { - width: 64px; - } -} - -.config-confidence-preview { - input[type='range'] { - min-width: 160px; - } - - input[type='number'] { - width: var(--config-short-field-width); - flex: 0 0 var(--config-short-field-width); - } -} - -.config-normal-fight-editor { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 6px; - min-width: 0; - max-width: 410px; -} - -#page-config #btn-add-normal-fight-task { - display: inline-flex; - align-items: center; - justify-content: center; - height: 24px; - min-height: 24px; - padding: 0 9px; - font-size: 11px; - line-height: 1; -} - -.config-task-list { - display: flex; - min-width: 0; - max-width: 320px; - align-items: center; - gap: 5px; -} - -.config-task-name { - display: block; - min-width: 0; - flex: 0 1 auto; - overflow: hidden; - color: var(--text); - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.config-task-remaining { - flex: 0 0 auto; - color: var(--text-muted); - font-size: 11px; - white-space: nowrap; -} - -.config-empty-note { - color: var(--text-muted); - font-size: 11px; - text-align: right; -} - -.config-map-editor { - width: min(100%, 460px) !important; - max-width: 460px !important; - min-height: 60px; - resize: vertical; - font-family: Consolas, monospace; -} - -.config-chip-list { - display: flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 4px; - max-width: 620px; - - label { - display: inline-flex; - align-items: center; - gap: 3px; - padding: 3px 6px; - color: var(--text); - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: var(--radius-pill); - font-size: 10px; - cursor: pointer; - } - - input { - margin: 0; - accent-color: var(--accent); - } -} - -.config-disabled-control { - color: var(--text-muted); - cursor: default; -} - -.config-switch { - position: relative; - display: inline-flex; - align-items: center; - gap: 8px; - color: var(--text); - font-size: 12px; - font-weight: 600; - cursor: pointer; - user-select: none; - - input[type='checkbox'] { - position: absolute; - width: 1px; - height: 1px; - opacity: 0; - } - - input[type='checkbox']:focus-visible + .config-switch-track { - box-shadow: 0 0 0 3px var(--accent-subtle); - } - - input[type='checkbox']:checked + .config-switch-track { - background: var(--accent); - border-color: var(--accent); - - &::after { - transform: translateX(16px); - } - } - - input[type='checkbox']:disabled + .config-switch-track, - input[type='checkbox']:disabled ~ span { - opacity: 0.45; - } -} - -.config-switch-track { - position: relative; - flex: 0 0 36px; - width: 36px; - height: 20px; - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: 10px; - transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; - - &::after { - content: ''; - position: absolute; - top: 2px; - left: 2px; - width: 14px; - height: 14px; - background: #fff; - border-radius: 50%; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28); - transition: transform 0.16s ease; - } -} - -.config-compat-controls { - display: none !important; -} - -.ship-library-update-control { - justify-content: flex-end; - - .adb-status { - margin-left: 0; - max-width: 360px; - white-space: normal; - text-align: right; - } -} - -.window-size-control { - justify-content: flex-end; - - label { - display: flex; - align-items: center; - gap: 6px; - color: var(--text-muted); - font-size: 11px; - } - - input[type='number'] { - width: var(--config-short-field-width); - } -} - -.config-grid { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 6px; - align-items: stretch; -} - -.config-grid-tabs { - .config-col-left, - .config-col-right { - display: contents; - } - - [hidden] { - display: none !important; - } -} - -.config-card-wide { - grid-column: 1 / -1; -} - -.config-col-left { - display: flex; - flex-direction: column; - gap: 6px; -} - -.config-col-right { - display: flex; - flex-direction: column; - gap: 6px; -} - -.config-col-right > .card { - flex: 1; -} - -.config-col-left > .card, -.config-col-right > .card { - margin-bottom: 0; - padding: 8px 12px; -} - -.config-grid .card h3 { - margin-bottom: 4px; - font-size: 13px; -} - -.config-grid .form-group { - margin-bottom: 4px; -} - -.config-grid .form-group:last-child { - margin-bottom: 0; -} - -.config-grid .form-group > label { - font-size: 10.5px; - margin-bottom: 3px; -} - -.config-grid .input { - padding: 5px 8px; - font-size: 12px; -} - -.config-grid .btn-small { - padding: 5px 10px; - font-size: 12px; -} - -.config-grid .card-divider { - margin: 8px 0; -} - -.config-runtime-row { - align-items: center; -} - -.config-inline-number { - width: var(--config-short-field-width) !important; - flex: 0 0 var(--config-short-field-width); -} - -.config-inline-note { - margin: 0; - font-size: 11px; - line-height: 1.35; -} - -.config-accent-row { - align-items: center; -} - -.config-theme-accent-row { - justify-content: flex-end; - - .accent-label { - flex: 0 0 auto; - } -} - -.config-ui-theme-row { - display: flex; - align-items: center; - gap: 8px; -} - -.config-ui-theme-row .input { - min-width: 0; - flex: 1; -} - -.config-ui-debug-toggle { - margin: 0; - display: inline-flex; - align-items: center; - gap: 2px; - text-transform: none; - letter-spacing: 0; - font-size: 11px; - color: var(--text-muted); - white-space: nowrap; -} - -/* 自动化分区 */ -.auto-list { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0 16px; - align-content: start; -} - -.auto-item { - display: flex; - flex-direction: column; - gap: 4px; - align-items: stretch; - padding: 10px 0; - position: relative; - - &::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - right: 0; - border-bottom: 1px solid var(--border); - } - - &:nth-child(odd) { - padding-right: 16px; - border-right: 1px solid var(--border); - - &::after { - right: 8px; - } - } - - &:nth-child(n+5)::after { - display: none; - } -} - -.auto-item-inline { - /* no extra fields */ -} - -.auto-item-body { - display: flex; - flex-direction: column; - gap: 6px; - padding-left: 20px; -} - -.auto-item-row { - display: grid; - grid-template-columns: 72px 1fr; - gap: 8px; - align-items: center; -} - -.auto-item label { - font-size: 12px; -} - -.auto-item .input { - padding: 6px 10px; - font-size: 12px; -} - -.auto-toggle { - font-weight: 600; - font-size: 12px; - cursor: pointer; -} - -.auto-toggle input[type="checkbox"] { - margin-right: 6px; -} - -@media (max-width: 980px) { - .auto-list { - grid-template-columns: 1fr; - } - - .auto-item:nth-child(odd) { - padding-right: 0; - border-right: none; - - &::after { - right: 0; - } - } - - .auto-item:last-child::after { - display: none; - } - - .auto-item-row { - grid-template-columns: 1fr; - } -} - -/* ═══ ADB 状态指示器 ═══ */ -.adb-status { - display: inline-block; - font-size: 12px; - padding: 2px 8px; - border-radius: var(--radius-pill); - margin-left: 8px; - vertical-align: middle; -} -.adb-status-online { - color: var(--success); - background: var(--success-bg); -} -.adb-status-offline { - color: var(--danger); - background: var(--danger-bg); -} -.adb-status-unknown { - color: var(--text-muted); - background: var(--bg-inset); -} - -.config-hint { - font-size: 11px; - color: var(--text-muted); - margin: 0 0 2px 0; - line-height: 1.25; -} - -.config-draft-warning { - margin: 0 0 6px; - padding: 6px 8px; - color: var(--warning); - background: var(--warning-bg); - border-radius: var(--radius); - font-size: 11px; - line-height: 1.4; -} - -.config-draft-list { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px; - margin-top: 8px; - - > div { - display: flex; - flex-direction: column; - gap: 2px; - padding: 9px 10px; - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: var(--radius); - } - - strong { - color: var(--text); - font-size: 12px; - } - - span { - color: var(--text-muted); - font-size: 11px; - line-height: 1.4; - } -} - -@media (max-width: 1180px) { - .config-grid { - grid-template-columns: 1fr; - align-items: start; - padding-right: 4px; - } - - .config-col-right > .card { - flex: none; - } - - .config-card-wide { - grid-column: auto; - } -} - -@media (max-width: 720px) { - .config-section-heading { - align-items: flex-start; - gap: 5px; - } - - .config-tab-description { - display: none; - } - - .config-draft-list { - grid-template-columns: 1fr; - } - - .config-setting-row { - grid-template-columns: 1fr; - gap: 8px; - } - - .config-setting-control, - .config-setting-control-stack { - align-items: stretch; - justify-content: flex-start; - } - - .config-setting-control > input.input:not(.config-inline-number), - .config-setting-control > select.input, - .config-setting-control > .input-row { - width: 100%; - max-width: none; - } - - .config-inline-options { - align-items: stretch; - flex-direction: column; - - &:not(.config-auto-task-options) select.input { - width: 100%; - max-width: none; - flex-basis: auto; - } - } - - .config-auto-task-control { - align-items: center; - flex-direction: row; - justify-content: flex-start; - } - - .config-auto-task-options { - align-items: center; - flex-direction: row; - } - - .config-theme-accent-row { - flex-wrap: wrap; - justify-content: flex-start; - - #cfg-theme-mode { - flex-basis: 100%; - } - } - - .ship-library-update-control { - align-items: flex-start; - flex-direction: column; - - .adb-status { - text-align: left; - } - } - - .config-update-controls { - flex-wrap: wrap; - - .config-update-progress { - flex: 1 1 160px; - width: auto; - } - } - - .config-confidence-preview { - display: grid; - grid-template-columns: 1fr 84px; - - .config-status-badge { - grid-column: 1 / -1; - justify-self: start; - } - } - - .config-normal-fight-editor, - .config-map-editor { - width: 100% !important; - max-width: none !important; - } - - .config-task-list { - max-width: min(230px, 40vw); - } - - .config-chip-list { - justify-content: flex-start; - } -} +@use 'config/layout'; +@use 'config/setting-controls'; +@use 'config/automation-summary'; +@use 'config/form-controls'; +@use 'config/automation-list'; +@use 'config/status-and-drafts'; +@use 'config/responsive'; diff --git a/src/view/styles/pages/config/_automation-list.scss b/src/view/styles/pages/config/_automation-list.scss new file mode 100644 index 0000000..e771de7 --- /dev/null +++ b/src/view/styles/pages/config/_automation-list.scss @@ -0,0 +1,98 @@ +/* 自动化分区 */ +.auto-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 16px; + align-content: start; +} + +.auto-item { + display: flex; + flex-direction: column; + gap: 4px; + align-items: stretch; + padding: 10px 0; + position: relative; + + &::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + right: 0; + border-bottom: 1px solid var(--border); + } + + &:nth-child(odd) { + padding-right: 16px; + border-right: 1px solid var(--border); + + &::after { + right: 8px; + } + } + + &:nth-child(n+5)::after { + display: none; + } +} + +.auto-item-inline { + /* no extra fields */ +} + +.auto-item-body { + display: flex; + flex-direction: column; + gap: 6px; + padding-left: 20px; +} + +.auto-item-row { + display: grid; + grid-template-columns: 72px 1fr; + gap: 8px; + align-items: center; +} + +.auto-item label { + font-size: 12px; +} + +.auto-item .input { + padding: 6px 10px; + font-size: 12px; +} + +.auto-toggle { + font-weight: 600; + font-size: 12px; + cursor: pointer; +} + +.auto-toggle input[type="checkbox"] { + margin-right: 6px; +} + +@media (max-width: 980px) { + .auto-list { + grid-template-columns: 1fr; + } + + .auto-item:nth-child(odd) { + padding-right: 0; + border-right: none; + + &::after { + right: 0; + } + } + + .auto-item:last-child::after { + display: none; + } + + .auto-item-row { + grid-template-columns: 1fr; + } +} diff --git a/src/view/styles/pages/config/_automation-summary.scss b/src/view/styles/pages/config/_automation-summary.scss new file mode 100644 index 0000000..18eeb08 --- /dev/null +++ b/src/view/styles/pages/config/_automation-summary.scss @@ -0,0 +1,90 @@ +.config-normal-fight-editor { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + min-width: 0; + max-width: 410px; +} + +#page-config #btn-add-normal-fight-task { + display: inline-flex; + align-items: center; + justify-content: center; + height: 24px; + min-height: 24px; + padding: 0 9px; + font-size: 11px; + line-height: 1; +} + +.config-task-list { + display: flex; + min-width: 0; + max-width: 320px; + align-items: center; + gap: 5px; +} + +.config-task-name { + display: block; + min-width: 0; + flex: 0 1 auto; + overflow: hidden; + color: var(--text); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.config-task-remaining { + flex: 0 0 auto; + color: var(--text-muted); + font-size: 11px; + white-space: nowrap; +} + +.config-empty-note { + color: var(--text-muted); + font-size: 11px; + text-align: right; +} + +.config-map-editor { + width: min(100%, 460px) !important; + max-width: 460px !important; + min-height: 60px; + resize: vertical; + font-family: Consolas, monospace; +} + +.config-chip-list { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; + max-width: 620px; + + label { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 3px 6px; + color: var(--text); + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + font-size: 10px; + cursor: pointer; + } + + input { + margin: 0; + accent-color: var(--accent); + } +} + +.config-disabled-control { + color: var(--text-muted); + cursor: default; +} diff --git a/src/view/styles/pages/config/_form-controls.scss b/src/view/styles/pages/config/_form-controls.scss new file mode 100644 index 0000000..e3d499b --- /dev/null +++ b/src/view/styles/pages/config/_form-controls.scss @@ -0,0 +1,217 @@ +.config-switch { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text); + font-size: 12px; + font-weight: 600; + cursor: pointer; + user-select: none; + + input[type='checkbox'] { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + } + + input[type='checkbox']:focus-visible + .config-switch-track { + box-shadow: 0 0 0 3px var(--accent-subtle); + } + + input[type='checkbox']:checked + .config-switch-track { + background: var(--accent); + border-color: var(--accent); + + &::after { + transform: translateX(16px); + } + } + + input[type='checkbox']:disabled + .config-switch-track, + input[type='checkbox']:disabled ~ span { + opacity: 0.45; + } +} + +.config-switch-track { + position: relative; + flex: 0 0 36px; + width: 36px; + height: 20px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 10px; + transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; + + &::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + background: #fff; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.28); + transition: transform 0.16s ease; + } +} + +.config-compat-controls { + display: none !important; +} + +.ship-library-update-control { + justify-content: flex-end; + + .adb-status { + margin-left: 0; + max-width: 360px; + white-space: normal; + text-align: right; + } +} + +.window-size-control { + justify-content: flex-end; + + label { + display: flex; + align-items: center; + gap: 6px; + color: var(--text-muted); + font-size: 11px; + } + + input[type='number'] { + width: var(--config-short-field-width); + } +} + +.config-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 6px; + align-items: stretch; +} + +.config-grid-tabs { + .config-col-left, + .config-col-right { + display: contents; + } + + [hidden] { + display: none !important; + } +} + +.config-card-wide { + grid-column: 1 / -1; +} + +.config-col-left { + display: flex; + flex-direction: column; + gap: 6px; +} + +.config-col-right { + display: flex; + flex-direction: column; + gap: 6px; +} + +.config-col-right > .card { + flex: 1; +} + +.config-col-left > .card, +.config-col-right > .card { + margin-bottom: 0; + padding: 8px 12px; +} + +.config-grid .card h3 { + margin-bottom: 4px; + font-size: 13px; +} + +.config-grid .form-group { + margin-bottom: 4px; +} + +.config-grid .form-group:last-child { + margin-bottom: 0; +} + +.config-grid .form-group > label { + font-size: 10.5px; + margin-bottom: 3px; +} + +.config-grid .input { + padding: 5px 8px; + font-size: 12px; +} + +.config-grid .btn-small { + padding: 5px 10px; + font-size: 12px; +} + +.config-grid .card-divider { + margin: 8px 0; +} + +.config-runtime-row { + align-items: center; +} + +.config-inline-number { + width: var(--config-short-field-width) !important; + flex: 0 0 var(--config-short-field-width); +} + +.config-inline-note { + margin: 0; + font-size: 11px; + line-height: 1.35; +} + +.config-accent-row { + align-items: center; +} + +.config-theme-accent-row { + justify-content: flex-end; + + .accent-label { + flex: 0 0 auto; + } +} + +.config-ui-theme-row { + display: flex; + align-items: center; + gap: 8px; +} + +.config-ui-theme-row .input { + min-width: 0; + flex: 1; +} + +.config-ui-debug-toggle { + margin: 0; + display: inline-flex; + align-items: center; + gap: 2px; + text-transform: none; + letter-spacing: 0; + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; +} diff --git a/src/view/styles/pages/config/_layout.scss b/src/view/styles/pages/config/_layout.scss new file mode 100644 index 0000000..6cb1cc2 --- /dev/null +++ b/src/view/styles/pages/config/_layout.scss @@ -0,0 +1,177 @@ +/* ═══ 配置页布局 ═══ */ +#page-config { + --config-control-height: 28px; + --config-text-field-width: 320px; + --config-short-field-width: 84px; + --config-row-width: 460px; + + height: calc(100vh - var(--nav-height) - var(--page-block-space)); + overflow: auto; + + input.input:not([type='range']):not([type='color']), + select.input { + height: var(--config-control-height); + min-height: var(--config-control-height); + padding: 0 8px; + font-size: 11px; + line-height: 1.2; + } + + select.input { + width: var(--config-select-width, 124px); + min-width: 0; + max-width: min(100%, var(--config-select-width, 124px)); + flex: 0 1 var(--config-select-width, 124px); + } + + textarea.input { + padding: 6px 8px; + font-size: 11px; + line-height: 1.4; + } + + .color-picker { + width: 36px; + height: var(--config-control-height); + } + + .accent-label { + font-size: 11px; + } + + .btn { + display: inline-flex; + align-items: center; + justify-content: center; + height: var(--config-control-height); + min-height: var(--config-control-height); + padding: 0 10px; + font-size: 11px; + line-height: 1; + white-space: nowrap; + } +} + +.config-header-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 8px; +} + +.config-navigation-row { + position: sticky; + z-index: 20; + top: 0; + display: flex; + align-items: center; + width: min(100%, 1080px); + gap: 8px; + margin: 0 auto 5px; + padding: 4px 0; + background: var(--bg); +} + +.config-tabs { + display: flex; + flex: 0 0 auto; + gap: 3px; + padding: 3px; + overflow-x: auto; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.config-tab { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + height: var(--config-control-height); + padding: 0 12px; + border: none; + border-radius: var(--radius); + color: var(--text-muted); + background: transparent; + font-size: 11px; + font-weight: 600; + cursor: pointer; + + &:hover { + color: var(--text-secondary); + background: var(--bg-hover); + } + + &.active { + color: var(--text); + background: var(--bg-card-solid); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12); + } +} + +.config-tab-description { + flex: 1 1 260px; + min-width: 0; + margin: 0; + overflow: hidden; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; + text-overflow: ellipsis; + white-space: nowrap; +} + +.config-list-panels { + width: min(100%, 1080px); + margin: 0 auto; + padding-bottom: 10px; + + [hidden] { + display: none !important; + } +} + +.config-list-panel { + display: flex; + flex-direction: column; + gap: 6px; +} + +.config-list-section { + overflow: hidden; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.config-section-heading { + display: flex; + align-items: baseline; + flex-wrap: wrap; + gap: 8px; + padding: 8px 12px; + background: var(--bg-inset); + border-bottom: 1px solid var(--border); + + h3 { + flex: 0 0 auto; + margin: 0; + color: var(--text); + font-size: 13px; + } + + p { + flex: 1 1 260px; + min-width: 0; + margin: 0; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; + } +} + +.config-setting-list { + display: flex; + flex-direction: column; +} diff --git a/src/view/styles/pages/config/_responsive.scss b/src/view/styles/pages/config/_responsive.scss new file mode 100644 index 0000000..f9b78d4 --- /dev/null +++ b/src/view/styles/pages/config/_responsive.scss @@ -0,0 +1,121 @@ +@media (max-width: 1180px) { + .config-grid { + grid-template-columns: 1fr; + align-items: start; + padding-right: 4px; + } + + .config-col-right > .card { + flex: none; + } + + .config-card-wide { + grid-column: auto; + } +} + +@media (max-width: 720px) { + .config-section-heading { + align-items: flex-start; + gap: 5px; + } + + .config-tab-description { + display: none; + } + + .config-draft-list { + grid-template-columns: 1fr; + } + + .config-setting-row { + grid-template-columns: 1fr; + gap: 8px; + } + + .config-setting-control, + .config-setting-control-stack { + align-items: stretch; + justify-content: flex-start; + } + + .config-setting-control > input.input:not(.config-inline-number), + .config-setting-control > select.input, + .config-setting-control > .input-row { + width: 100%; + max-width: none; + } + + .config-inline-options { + align-items: stretch; + flex-direction: column; + + &:not(.config-auto-task-options) select.input { + width: 100%; + max-width: none; + flex-basis: auto; + } + } + + .config-auto-task-control { + align-items: center; + flex-direction: row; + justify-content: flex-start; + } + + .config-auto-task-options { + align-items: center; + flex-direction: row; + } + + .config-theme-accent-row { + flex-wrap: wrap; + justify-content: flex-start; + + #cfg-theme-mode { + flex-basis: 100%; + } + } + + .ship-library-update-control { + align-items: flex-start; + flex-direction: column; + + .adb-status { + text-align: left; + } + } + + .config-update-controls { + flex-wrap: wrap; + + .config-update-progress { + flex: 1 1 160px; + width: auto; + } + } + + .config-confidence-preview { + display: grid; + grid-template-columns: 1fr 84px; + + .config-status-badge { + grid-column: 1 / -1; + justify-self: start; + } + } + + .config-normal-fight-editor, + .config-map-editor { + width: 100% !important; + max-width: none !important; + } + + .config-task-list { + max-width: min(230px, 40vw); + } + + .config-chip-list { + justify-content: flex-start; + } +} diff --git a/src/view/styles/pages/config/_setting-controls.scss b/src/view/styles/pages/config/_setting-controls.scss new file mode 100644 index 0000000..b673b7d --- /dev/null +++ b/src/view/styles/pages/config/_setting-controls.scss @@ -0,0 +1,328 @@ +.config-setting-row { + display: grid; + grid-template-columns: minmax(220px, 0.9fr) minmax(300px, 1.1fr); + gap: 14px; + align-items: center; + min-height: 46px; + padding: 7px 12px; + border-bottom: 1px solid var(--border); + + &:last-child { + border-bottom: none; + } +} + +.config-setting-copy { + display: block; + line-height: 1.45; + + strong { + display: inline; + margin-right: 8px; + color: var(--text); + font-size: 12px; + } + + span { + display: inline; + color: var(--text-muted); + font-size: 11px; + } +} + +.config-setting-control { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; + min-width: 0; + + > .input-row { + width: min(100%, var(--config-row-width)); + } + + > input.input { + width: min(100%, var(--config-text-field-width)); + max-width: var(--config-text-field-width); + } + + .input { + padding: 6px 9px; + font-size: 12px; + } + + .btn-small { + flex: 0 0 auto; + } +} + +.config-cuda-path-row { + flex-wrap: nowrap; + + .config-cuda-input-wrap { + position: relative; + flex: 1 1 auto; + min-width: 0; + + .input { + width: 100%; + padding-right: 82px; + } + + #cfg-cuda-status { + position: absolute; + top: 50%; + right: 6px; + max-width: 72px; + margin: 0; + padding: 2px 6px; + overflow: hidden; + font-size: 9px; + line-height: 1.2; + white-space: nowrap; + text-overflow: ellipsis; + transform: translateY(-50%); + } + } +} + +.config-setting-control-stack { + flex-direction: column; + align-items: flex-end; +} + +.config-auto-task-control { + flex-wrap: nowrap; + + > .config-switch { + flex: 0 0 auto; + } +} + +.config-auto-loot-control { + min-width: 0; + gap: 6px; + + .config-auto-task-options { + min-width: 0; + flex: 1 1 auto; + justify-content: flex-end; + gap: 6px; + } + + #cfg-loot-stop-count { + width: 56px !important; + flex-basis: 56px; + } +} + +.config-adb-controls { + flex-wrap: wrap; + justify-content: flex-end; + + .config-adb-input-wrap { + position: relative; + width: min(100%, 210px); + min-width: 180px; + max-width: 210px; + flex: 0 1 210px; + + .input { + width: 100%; + padding-right: 66px; + } + + .adb-status { + position: absolute; + top: 50%; + right: 6px; + margin: 0; + padding: 2px 6px; + font-size: 10px; + pointer-events: none; + transform: translateY(-50%); + } + } +} + +.config-setting-control > .config-compact-path-row { + width: min(100%, 360px); +} + +.config-setting-control > .config-backend-controls { + width: min(100%, 300px); + justify-content: flex-end; + + #cfg-backend-port { + width: 72px !important; + flex-basis: 72px; + } +} + +.config-setting-control > .config-update-controls { + flex-wrap: nowrap; + justify-content: flex-end; +} + +.config-update-progress { + display: grid; + flex: 0 1 160px; + gap: 4px; + width: 160px; + min-width: 120px; + + &[hidden] { + display: none; + } + + &[data-state='complete'] { + .config-update-progress-copy { + color: var(--success); + } + + .config-update-progress-track > span { + background: var(--success); + } + } + + &[data-state='error'] { + .config-update-progress-copy { + color: var(--danger); + } + + .config-update-progress-track > span { + background: var(--danger); + } + } +} + +.config-update-progress-copy { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; + color: var(--text-muted); + font-size: 10px; + + #gui-update-status { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + #gui-update-percent { + flex: 0 0 auto; + font-variant-numeric: tabular-nums; + } +} + +.config-update-progress-track { + height: 4px; + overflow: hidden; + background: var(--bg-inset); + border-radius: var(--radius-pill); + + > span { + display: block; + width: 0; + height: 100%; + background: var(--accent); + border-radius: inherit; + transition: width 180ms ease; + } + + &.is-indeterminate > span { + width: 35% !important; + animation: config-update-progress-slide 1.1s ease-in-out infinite; + } +} + +@keyframes config-update-progress-slide { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(300%); + } +} + +.config-inline-options { + display: flex; + align-items: center; + gap: 8px; + + > span { + color: var(--text-muted); + font-size: 11px; + white-space: nowrap; + } +} + +.config-auto-task-options { + min-width: 0; +} + +.config-status-badge { + flex: 0 0 auto; + padding: 2px 7px; + color: var(--warning); + background: var(--warning-bg); + border-radius: var(--radius-pill); + font-size: 10px; + white-space: nowrap; +} + +.config-status-readonly { + color: var(--text-muted); + background: var(--bg-inset); +} + +.config-prefix { + color: var(--text-muted); + font-size: 12px; + white-space: nowrap; +} + +.config-advanced-box { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0 16px 12px; + padding: 12px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.config-range-preview { + width: min(100%, 345px); + justify-self: end; + flex-direction: column; + align-items: stretch; + + label { + display: grid; + grid-template-columns: 40px minmax(90px, 1fr) 64px; + gap: 8px; + align-items: center; + color: var(--text-muted); + font-size: 11px; + } + + input[type='number'] { + width: 64px; + } +} + +.config-confidence-preview { + input[type='range'] { + min-width: 160px; + } + + input[type='number'] { + width: var(--config-short-field-width); + flex: 0 0 var(--config-short-field-width); + } +} diff --git a/src/view/styles/pages/config/_status-and-drafts.scss b/src/view/styles/pages/config/_status-and-drafts.scss new file mode 100644 index 0000000..dc63aee --- /dev/null +++ b/src/view/styles/pages/config/_status-and-drafts.scss @@ -0,0 +1,66 @@ +/* ═══ ADB 状态指示器 ═══ */ +.adb-status { + display: inline-block; + font-size: 12px; + padding: 2px 8px; + border-radius: var(--radius-pill); + margin-left: 8px; + vertical-align: middle; +} +.adb-status-online { + color: var(--success); + background: var(--success-bg); +} +.adb-status-offline { + color: var(--danger); + background: var(--danger-bg); +} +.adb-status-unknown { + color: var(--text-muted); + background: var(--bg-inset); +} + +.config-hint { + font-size: 11px; + color: var(--text-muted); + margin: 0 0 2px 0; + line-height: 1.25; +} + +.config-draft-warning { + margin: 0 0 6px; + padding: 6px 8px; + color: var(--warning); + background: var(--warning-bg); + border-radius: var(--radius); + font-size: 11px; + line-height: 1.4; +} + +.config-draft-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; + margin-top: 8px; + + > div { + display: flex; + flex-direction: column; + gap: 2px; + padding: 9px 10px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: var(--radius); + } + + strong { + color: var(--text); + font-size: 12px; + } + + span { + color: var(--text-muted); + font-size: 11px; + line-height: 1.4; + } +} diff --git a/src/view/styles/pages/plan/_fleet-dialogs.scss b/src/view/styles/pages/plan/_fleet-dialogs.scss new file mode 100644 index 0000000..c289767 --- /dev/null +++ b/src/view/styles/pages/plan/_fleet-dialogs.scss @@ -0,0 +1,355 @@ +.fleet-team-loader-dialog { + width: min(780px, 94vw); + padding: 16px; + + .modal-actions { + margin-top: 5px; + } +} + +.fleet-dialog-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 10px; + + h3 { + flex: 0 0 auto; + margin: 0; + } + + p { + overflow: hidden; + margin: 0; + color: var(--text-secondary); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.fleet-dialog-heading, +.fleet-team-loader-header-actions { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; +} + +.fleet-team-loader-header-actions { + flex: 0 0 auto; + + > span { + color: var(--text-secondary); + font-size: 10px; + white-space: nowrap; + } +} + +.fleet-team-loader-content { + display: grid; + grid-template-columns: minmax(190px, 0.8fr) minmax(300px, 1.2fr); + height: min(470px, calc(100vh - 230px)); + min-height: 270px; + gap: 8px; + margin-top: 8px; +} + +.fleet-team-loader-list { + display: flex; + min-height: 0; + flex-direction: column; + gap: 4px; + padding: 4px; + overflow-y: auto; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.fleet-team-loader-item { + display: flex; + flex: 0 0 auto; + width: 100%; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 7px 8px; + color: var(--text); + text-align: left; + background: var(--bg-card-solid); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + + &:hover { + background: var(--bg-hover); + border-color: var(--border-strong); + } + + &.active { + background: var(--accent-subtle); + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent-subtle); + } + + strong { + width: 100%; + overflow: hidden; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; + } + + span { + color: var(--text-secondary); + font-size: 9px; + } +} + +.fleet-team-loader-item-heading { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 6px; + + strong { + flex: 1 1 auto; + width: auto; + min-width: 0; + } +} + +.fleet-team-source-badge { + display: inline-flex; + box-sizing: border-box; + height: 16px; + flex: 0 0 auto; + align-self: center; + align-items: center; + justify-content: center; + padding: 0 5px; + background: transparent; + border: 1px solid; + border-radius: var(--radius-pill); + font-size: 9px; + line-height: 1; + + &.system { + color: #e76b70; + border-color: #e76b70; + } + + &.user { + color: #55b77a; + border-color: #55b77a; + } + + &.deleted { + color: #fff; + background: #15171a; + border-color: #15171a; + } +} + +.fleet-team-loader-preview-column { + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); + gap: 6px; +} + +.fleet-team-loader-tools { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 6px; +} + +.fleet-team-loader-system-filter { + display: flex; + min-height: 26px; + align-items: center; + gap: 7px; + padding: 3px 7px; + color: var(--text-secondary); + background: var(--bg-card-solid); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 10px; + white-space: nowrap; +} + +.fleet-team-loader-sort { + display: flex; + min-height: 26px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 3px 7px; + color: var(--text-secondary); + background: var(--bg-card-solid); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 10px; +} + +.fleet-team-loader-sort-controls, +.fleet-team-loader-sort-fields { + display: flex; + align-items: center; +} + +.fleet-team-loader-sort-controls { + gap: 8px; +} + +.fleet-team-loader-sort-fields { + overflow: hidden; + border: 1px solid var(--border); + border-radius: 5px; +} + +.fleet-team-loader-sort-option { + height: 20px; + padding: 0 7px; + color: var(--text-secondary); + background: var(--bg-inset); + border: 0; + border-radius: 0; + font-size: 9px; + cursor: pointer; + + + .fleet-team-loader-sort-option { + border-left: 1px solid var(--border); + } + + &:hover { + color: var(--text); + background: var(--bg-hover); + } + + &.active { + color: var(--accent); + background: var(--accent-subtle); + } +} + +.fleet-team-loader-preview { + min-width: 0; + min-height: 0; + padding: 8px; + overflow: hidden; + background: var(--bg-card-solid); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.fleet-team-loader-preview.fleet-team-preview { + display: block; + overflow-x: hidden; + overflow-y: auto; +} + +.fleet-team-loader-preview .fleet-team-main-preview { + grid-template-columns: repeat(6, minmax(0, 1fr)); + max-width: none; + gap: 4px; +} + +.fleet-team-loader-preview .fleet-team-backup-preview { + max-width: none; +} + +.fleet-team-loader-preview-empty { + grid-column: 1 / -1; + display: grid; + min-height: 72px; + place-items: center; + padding: 10px; + color: var(--text-muted); + background: var(--bg-inset); + border: 1px dashed var(--border); + border-radius: 6px; + font-size: 10px; + text-align: center; +} + +.fleet-dialog-status { + min-height: 0; + margin: 5px 0 0; + color: var(--warning); + font-size: 10px; + + &:empty { + display: none; + } +} + +.fleet-backup-copy-dialog { + width: min(430px, 94vw); + padding: 16px; + + h3 { + margin: 0 0 6px; + } + + > p { + margin: 0 0 10px; + color: var(--text-secondary); + font-size: 11px; + } + + .modal-actions { + margin-top: 10px; + } +} + +.fleet-backup-copy-targets { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 6px; +} + +.fleet-backup-copy-target { + min-width: 0; + padding: 8px; + color: var(--text); + text-align: left; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + + &:hover:not(:disabled) { + background: var(--accent-subtle); + border-color: var(--accent); + } + + &:disabled { + color: var(--text-muted); + cursor: not-allowed; + opacity: 0.62; + } + + strong, + span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { + font-size: 11px; + } + + span { + margin-top: 3px; + color: var(--text-secondary); + font-size: 9px; + } +} diff --git a/src/view/styles/pages/plan/_fleet-editor.scss b/src/view/styles/pages/plan/_fleet-editor.scss new file mode 100644 index 0000000..2156c6c --- /dev/null +++ b/src/view/styles/pages/plan/_fleet-editor.scss @@ -0,0 +1,516 @@ +/* ═══ 舰队规划分屏 ═══ */ +.fleet-planner-layout { + display: grid; + grid-template-columns: + minmax(0, min(66.666%, 860px)) + minmax(0, 1fr); + gap: 8px; + height: 100%; + min-height: 0; +} + +.fleet-planner-layout > .card { + min-width: 0; + min-height: 0; + margin-bottom: 0; + padding: 8px; +} + +.fleet-planner-layout > .card.fleet-planner-fleet { + padding: 5px; +} + +.fleet-planner-fleet, +.fleet-planner-library { + display: flex; + flex-direction: column; +} + +.fleet-planner-fleet { + overflow: hidden; + container-type: inline-size; + scrollbar-gutter: stable; + scrollbar-width: thin; +} + +.fleet-planner-library { + overflow: visible; +} + +.fleet-planner-library { + container-type: inline-size; +} + +.fleet-planner-toolbar { + width: 100%; + gap: 6px; + padding-inline-start: 3px; + overflow: visible; + white-space: normal; +} + +.fleet-preset-name-field { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 5px; + margin-left: auto; + color: var(--text-secondary); + font-size: 10px; + font-weight: 600; + + span { + flex: 0 0 auto; + } + + .input { + width: 120px; + min-width: calc(8em + 14px); + padding: 4px 6px; + font-size: 10px; + } +} + +.fleet-preset-actions { + display: flex; + flex: 0 0 auto; + justify-content: flex-end; + gap: 3px; +} + +.fleet-toolbar-level, +.fleet-backup-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 6px; +} + +.fleet-planner-toolbar .fleet-toolbar-level { + display: grid; + flex-basis: 100%; + grid-template-columns: max-content minmax(0, 1fr); + min-width: 0; +} + +.fleet-primary-rule-controls { + display: flex; + align-items: center; + gap: 6px; +} + +.fleet-planner-layout .btn-small { + padding: 4px 8px; + border-radius: 6px; + font-size: 10px; + line-height: 1.2; +} + +.fleet-formation-section { + display: flex; + flex: 0 0 auto; + flex-direction: column; + min-height: 0; + padding: 3px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 6px; +} + +.fleet-backup-section { + flex: 1 1 0; + min-height: 216px; + margin-top: 0; + overflow: hidden; +} + +.fleet-validation-note { + align-self: center; + justify-self: end; + max-width: 100%; + margin: 0 0 0 auto; + color: var(--danger); + font-size: 11px; + font-weight: 600; + line-height: 1.35; + text-align: right; + white-space: normal; +} + +.fleet-formation-heading { + display: flex; + flex: 0 0 auto; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 6px; + width: 100%; + min-height: 30px; + margin-bottom: 3px; + overflow: visible; + white-space: normal; +} + +.fleet-backup-heading { + margin-top: 3px; +} + +.fleet-backup-actions { + flex-wrap: wrap; + min-width: 0; +} + +.fleet-backup-hint { + color: var(--text-secondary); + font-size: 10px; +} + +.fleet-planner-layout .fleet-formation-tag { + align-self: center; + margin: 0 0 4px; + color: var(--text); + font-size: 11px; + font-weight: 700; + letter-spacing: normal; + line-height: 1.2; + text-transform: none; +} + +.fleet-planner-layout .fleet-formation-heading .fleet-formation-tag { + margin-bottom: 0; +} + +.fleet-planner-layout .fleet-queue-badge { + display: inline-flex; + min-width: 68px; + min-height: 21px; + align-items: center; + justify-content: center; + padding: 2px 8px; + border: 1px solid; + border-radius: 4px; + font-size: 10px; + + &.is-primary { + color: var(--accent); + background: var(--accent-subtle); + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + } + + &.is-backup { + color: var(--success); + background: color-mix(in srgb, var(--success) 10%, var(--bg-card-solid)); + border-color: color-mix(in srgb, var(--success) 55%, var(--border)); + + &[data-follow-mode="position"] { + color: var(--accent); + background: var(--accent-subtle); + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + } + } +} + +#btn-backup-follow-mode { + &[data-mode="ship"] { + color: var(--success); + background: color-mix(in srgb, var(--success) 10%, var(--bg-card-solid)); + border-color: color-mix(in srgb, var(--success) 55%, var(--border)); + } + + &[data-mode="position"] { + color: var(--accent); + background: var(--accent-subtle); + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + } +} + +.fleet-slot-rule { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 5px 10px; + margin-top: 4px; + padding: 5px 7px; + color: var(--text-secondary); + background: color-mix(in srgb, var(--bg-card-solid) 64%, transparent); + border: 1px solid var(--border); + border-radius: 5px; + font-size: 9px; + + > strong { + color: var(--text); + font-size: 10px; + } + + > label, + .fleet-level-fields label { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--text); + } + + .input { + width: 88px; + padding: 3px 5px; + font-size: 9px; + } +} + +.fleet-level-switch { + gap: 5px !important; + font-size: 10px; +} + +.fleet-level-fields { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text); + font-size: 10px; + + &[hidden] { + display: none; + } + + label { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: inherit; + } + + .input { + width: 60px; + padding: 4px 6px; + font-size: inherit; + } +} + +.fleet-slot-scroll, +.fleet-backup-scroll { + min-width: 0; + padding-bottom: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-gutter: stable both-edges; + scrollbar-width: thin; +} + +.fleet-backup-scroll { + flex: 1 1 0; + min-height: 0; + overflow: auto; +} + +.fleet-slot-list, +.fleet-backup-slot-list { + display: grid; + grid-template-columns: repeat(6, minmax(0, 128px)); + grid-auto-rows: auto; + justify-content: center; + gap: 3px; + width: 100%; + min-height: 0; + margin: 0 auto; +} + +.fleet-slot { + position: relative; + display: flex; + width: 100%; + height: auto; + aspect-ratio: 128 / 200; + container-type: inline-size; + align-items: center; + justify-content: center; + min-width: 0; + padding: 0; + overflow: hidden; + color: var(--text); + background: var(--bg-card-solid); + border: 0; + border-radius: var(--radius-sm); + box-shadow: inset 0 0 0 1px var(--border); + text-align: center; + cursor: pointer; + + &:hover { + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 50%, var(--border)); + } + + &.active { + box-shadow: + 0 0 0 2px rgba(255, 255, 255, 0.72), + 0 0 16px color-mix(in srgb, var(--accent-hover) 72%, transparent); + } + + &.active::after, + &.drag-over::after { + content: ''; + position: absolute; + z-index: 10; + inset: 0; + border: 4px solid var(--accent-hover); + border-radius: inherit; + pointer-events: none; + } + + &.drag-over::after { + border-color: var(--success); + background: color-mix(in srgb, var(--success) 16%, transparent); + box-shadow: inset 0 0 18px color-mix(in srgb, var(--success) 36%, transparent); + } + + &.candidate-only { + background: + linear-gradient( + 145deg, + #b65b8f, + #4f87b9 46%, + #8b63b5 + ); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 34%, var(--border)); + } +} + +.fleet-slot > .fleet-ship-artwork { + width: 100%; + height: 100%; +} + +.fleet-slot .fleet-ship-number { + top: 3%; + left: 4%; + padding: clamp(1px, 1.5cqw, 2px) clamp(2px, 3cqw, 4px); + font-size: clamp(6px, 7.8cqw, 10px); +} + +.fleet-validation-preview { + position: absolute; + z-index: 8; + top: 15%; + left: 4%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: clamp(2px, 2cqw, 3px); + pointer-events: none; +} + +.fleet-validation-badge { + min-width: clamp(31px, 31cqw, 45px); + padding: clamp(1px, 1.5cqw, 2px) clamp(2px, 3cqw, 4px); + overflow: hidden; + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.42); + border-radius: 4px; + box-shadow: + 0 1px 4px rgba(0, 0, 0, 0.42), + inset 0 1px 0 rgba(255, 255, 255, 0.18); + backdrop-filter: blur(7px) saturate(130%); + font-size: clamp(6px, 7.8cqw, 10px); + font-weight: 700; + line-height: 1.2; + text-align: center; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.58); + white-space: nowrap; + + &.is-relaxed { + background: rgba(25, 117, 218, 0.76); + } + + &.is-strict { + background: rgba(198, 57, 66, 0.76); + } +} + +.fleet-slot .fleet-ship-type-icon { + top: 2%; + right: 2%; + width: 30%; + height: auto; + aspect-ratio: 1; +} + +.fleet-slot .fleet-ship-name { + right: 4%; + bottom: 3%; + left: 4%; + min-height: 18%; + padding: clamp(1px, 2cqw, 4px) clamp(2px, 3cqw, 6px); +} + +.fleet-slot .fleet-ship-name-text { + font-size: clamp(6px, 8.5cqw, 11px); +} + +.fleet-slot-placeholder-background { + position: absolute; + z-index: 0; + top: -8%; + left: 50%; + width: 132%; + height: auto; + max-width: none; + transform: translateX(-50%); + pointer-events: none; +} + +.fleet-slot-position { + display: none; +} + +.fleet-slot-empty { + padding: 10px; + color: var(--text-secondary); + font-size: 11px; + font-weight: 600; + text-align: center; +} + +.fleet-slot.candidate-only .fleet-slot-empty { + position: relative; + z-index: 2; + padding: 8px 14px; + color: #ffd75e; + background: rgba(0, 0, 0, 0.68); + border: 1px solid rgba(255, 215, 94, 0.3); + border-radius: 7px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.42); + backdrop-filter: blur(8px); + font-size: 12px; + letter-spacing: 0.04em; + text-shadow: 0 1px 3px #000; + user-select: none; +} + +.fleet-slot-remove { + position: absolute; + z-index: 8; + top: 6px; + right: 46px; + display: grid; + width: 18px; + height: 18px; + place-items: center; + color: #fff; + background: rgba(0, 0, 0, 0.72); + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 50%; + font-size: 14px; + line-height: 1; + + &:hover { + color: var(--danger); + background: var(--danger-bg); + } +} + +.fleet-planner-note { + flex: 0 0 auto; + margin: 5px 0 0; + color: var(--text-secondary); + font-size: 9px; + line-height: 1.35; +} diff --git a/src/view/styles/pages/plan/_fleet-planner.scss b/src/view/styles/pages/plan/_fleet-planner.scss index 3131155..508e37b 100644 --- a/src/view/styles/pages/plan/_fleet-planner.scss +++ b/src/view/styles/pages/plan/_fleet-planner.scss @@ -1,2089 +1,6 @@ -/* ═══ 计划页二级导航 ═══ */ -#page-plan { - height: calc(100vh - var(--nav-height) - var(--page-block-space)); - min-height: 0; -} - -#page-plan.active { - display: flex; - flex-direction: column; -} - -.plan-subtabs { - display: flex; - flex: 0 0 auto; - gap: 3px; - margin-bottom: 6px; - padding: 3px; - overflow-x: auto; - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: var(--radius-sm); -} - -.plan-subtab { - flex: 0 0 auto; - padding: 5px 12px; - border: 0; - border-radius: 6px; - color: var(--text-muted); - background: transparent; - font-size: 11px; - font-weight: 600; - cursor: pointer; - - &:hover { - color: var(--text-secondary); - background: var(--bg-hover); - } - - &.active { - color: var(--text); - background: var(--bg-card-solid); - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.14); - } -} - -.plan-tab-panel { - flex: 1 1 0; - min-height: 0; - - &[hidden] { - display: none !important; - } -} - -.plan-tab-panel.active { - display: block; -} - -.plan-management-layout, -.legacy-decisive-card { - height: 100%; - margin-bottom: 0; -} - -.plan-management-layout { - display: flex; - flex-direction: column; - min-height: 0; - overflow: hidden; -} - -.plan-team-management-card { - display: flex; - flex: 1 1 0; - flex-direction: column; - min-height: 180px; - margin-bottom: 0; - overflow: hidden; -} - -.plan-management-heading { - display: flex; - flex: 0 0 auto; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 8px; - - h3 { - margin: 0; - font-size: 13px; - } - - p { - margin: 2px 0 0; - color: var(--text-muted); - font-size: 10px; - } - - .btn { - flex: 0 0 auto; - height: 26px; - padding: 4px 10px; - } -} - -.plan-management-title { - display: flex; - min-width: 0; - align-items: baseline; - gap: 8px; - - h3 { - flex: 0 0 auto; - } - - p { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -.plan-management-summary { - display: grid; - flex: 0 0 auto; - grid-template-columns: repeat(4, minmax(90px, 1fr)); - gap: 5px; - margin-bottom: 7px; -} - -.plan-management-stat { - display: flex; - min-height: 46px; - padding: 6px 10px; - flex-direction: column; - justify-content: center; - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: 6px; - - span { - color: var(--text-muted); - font-size: 9px; - } - - strong { - margin-top: 1px; - color: var(--text); - font-size: 17px; - line-height: 1.1; - } - - &.is-linked { - border-color: color-mix(in srgb, var(--success) 42%, var(--border)); - - strong { - color: var(--success); - } - } - - &.is-warning { - border-color: color-mix(in srgb, var(--warning) 48%, var(--border)); - - strong { - color: var(--warning); - } - } -} - -.plan-management-toolbar { - display: flex; - flex: 0 0 auto; - align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 7px; -} - -.plan-management-subtabs { - display: flex; - flex: 0 0 auto; - gap: 4px; -} - -.plan-management-subtab { - height: 26px; - padding: 0 10px; - color: var(--text-secondary); - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: 5px; - font-size: 11px; - cursor: pointer; - - &:hover, - &.active { - color: var(--text); - background: var(--accent-subtle); - border-color: var(--accent); - } -} - -.plan-management-export, -.plan-management-delete { - height: 26px; - padding: 0 10px; - font-size: 11px; -} - -.plan-management-filters { - display: flex; - min-width: 0; - align-items: center; - justify-content: flex-end; - gap: 5px; -} - -.plan-management-search { - width: clamp(150px, 23vw, 250px); - height: 26px; - padding: 3px 8px; - font-size: 10px; -} - -.plan-management-kind-filter { - display: flex; - flex: 0 0 auto; - gap: 2px; - padding: 2px; - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: 5px; - - button { - height: 20px; - padding: 0 7px; - color: var(--text-muted); - background: transparent; - border: 0; - border-radius: 3px; - font-size: 9px; - cursor: pointer; - - &:hover { - color: var(--text-secondary); - background: var(--bg-hover); - } - - &.active { - color: var(--accent-text); - background: var(--accent-subtle); - } - } -} - -.plan-management-attention-filter { - flex: 0 0 auto; - gap: 5px; - white-space: nowrap; - - > span:last-child { - font-size: 10px; - } -} - -.plan-management-warning { - flex: 0 0 auto; - margin: 0 0 7px; - padding: 5px 8px; - color: var(--warning); - background: var(--warning-bg); - border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border)); - border-radius: 5px; - font-size: 10px; -} - -.plan-team-management-table-wrap { - flex: 1 1 0; - min-height: 0; - overflow: auto; - border: 1px solid var(--border); - border-radius: 6px; - scrollbar-width: thin; -} - -.plan-team-management-table { - width: 100%; - min-width: 1020px; - table-layout: fixed; - border-collapse: collapse; - font-size: 11px; - - th, - td { - padding: 7px 9px; - border-bottom: 1px solid var(--border); - text-align: left; - vertical-align: middle; - } - - th { - position: sticky; - z-index: 2; - top: 0; - color: var(--text); - background: var(--bg-card-solid); - box-shadow: 0 1px 0 var(--border-strong); - font-size: 11px; - } - - th:nth-child(1) { - width: 38px; - } - - th:nth-child(2) { - width: 23%; - } - - th:nth-child(3) { - width: 70px; - } - - th:nth-child(4) { - width: 22%; - } - - th:nth-child(5) { - width: 18%; - } - - th:nth-child(6) { - width: 118px; - } - - th:nth-child(7), - td:last-child { - width: 204px; - white-space: nowrap; - } - - tbody tr { - transition: background 0.12s ease; - } - - tbody tr:hover { - background: var(--bg-hover); - } - - tbody tr.needs-attention { - background: color-mix(in srgb, var(--warning-bg) 55%, transparent); - } -} - -.plan-management-selection-column, -.plan-management-selection-cell { - padding-right: 5px !important; - padding-left: 5px !important; - text-align: center !important; - - input { - width: 14px; - height: 14px; - margin: 0; - accent-color: var(--accent); - cursor: pointer; - vertical-align: middle; - } -} - -.plan-kind-badge { - display: inline-block; - margin-right: 6px; - padding: 1px 5px; - color: var(--text-muted); - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: var(--radius-pill); - font-size: 9px; - - &.battle { - color: var(--warning); - border-color: color-mix(in srgb, var(--warning) 50%, var(--border)); - } - - &.team { - color: #55b77a; - border-color: color-mix(in srgb, #55b77a 50%, var(--border)); - } -} - -.plan-management-identity { - display: inline-flex; - max-width: calc(100% - 48px); - flex-direction: column; - vertical-align: middle; - - strong, - small { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - strong { - color: var(--text); - font-size: 11px; - } - - small { - margin-top: 2px; - color: var(--text-muted); - font-size: 9px; - } -} - -.plan-source-badge, -.plan-management-status, -.plan-relation-chip { - display: inline-flex; - align-items: center; - border-radius: var(--radius-pill); - white-space: nowrap; -} - -.plan-source-badge { - padding: 2px 7px; - font-size: 9px; - - &.system { - color: var(--danger); - background: var(--danger-bg); - } - - &.user { - color: var(--success); - background: var(--success-bg); - } -} - -.plan-management-relations { - display: flex; - flex-wrap: wrap; - gap: 3px; -} - -.plan-relation-chip { - max-width: 100%; - padding: 2px 7px; - overflow: hidden; - color: var(--text-secondary); - background: var(--bg-inset); - border: 1px solid var(--border); - font-size: 9px; - text-overflow: ellipsis; - - &.missing { - color: var(--danger); - background: var(--danger-bg); - border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); - } -} - -.plan-management-relation-empty { - color: var(--text-muted); - font-size: 10px; -} - -.plan-management-relation-error { - overflow: hidden; - color: var(--danger); - font-size: 10px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.plan-management-status { - padding: 2px 7px; - font-size: 9px; - - &.ok { - color: var(--success); - background: var(--success-bg); - } - - &.warning { - color: var(--warning); - background: var(--warning-bg); - } - - &.muted { - color: var(--text-muted); - background: var(--bg-inset); - } -} - -.plan-management-actions { - .btn { - height: 24px; - padding: 3px 7px; - font-size: 10px; - } - - .btn + .btn { - margin-left: 4px; - } -} - -.plan-management-empty { - color: var(--text-muted); - text-align: center !important; -} - -@media (max-width: 900px) { - .plan-management-summary { - grid-template-columns: repeat(2, minmax(90px, 1fr)); - } - - .plan-management-toolbar { - align-items: stretch; - flex-direction: column; - } - - .plan-management-filters { - justify-content: flex-start; - } - - .plan-management-search { - flex: 1 1 150px; - width: auto; - } -} - -@media (max-width: 620px) { - .plan-management-heading p { - display: none; - } - - .plan-management-filters { - flex-wrap: wrap; - } - - .plan-management-search { - flex-basis: 100%; - } -} - -.legacy-decisive-card { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - text-align: center; - - h2 { - margin-bottom: 12px; - font-size: 18px; - } - - p { - max-width: 620px; - color: var(--text-muted); - font-size: 13px; - line-height: 1.7; - } -} - -/* ═══ 舰队规划分屏 ═══ */ -.fleet-planner-layout { - display: grid; - grid-template-columns: - minmax(0, min(66.666%, 860px)) - minmax(0, 1fr); - gap: 8px; - height: 100%; - min-height: 0; -} - -.fleet-planner-layout > .card { - min-width: 0; - min-height: 0; - margin-bottom: 0; - padding: 8px; -} - -.fleet-planner-layout > .card.fleet-planner-fleet { - padding: 5px; -} - -.fleet-planner-fleet, -.fleet-planner-library { - display: flex; - flex-direction: column; -} - -.fleet-planner-fleet { - overflow: hidden; - container-type: inline-size; - scrollbar-gutter: stable; - scrollbar-width: thin; -} - -.fleet-planner-library { - overflow: visible; -} - -.fleet-planner-library { - container-type: inline-size; -} - -.fleet-planner-toolbar { - width: 100%; - gap: 6px; - padding-inline-start: 3px; - overflow: visible; - white-space: normal; -} - -.fleet-preset-name-field { - display: flex; - flex: 0 0 auto; - align-items: center; - gap: 5px; - margin-left: auto; - color: var(--text-secondary); - font-size: 10px; - font-weight: 600; - - span { - flex: 0 0 auto; - } - - .input { - width: 120px; - min-width: calc(8em + 14px); - padding: 4px 6px; - font-size: 10px; - } -} - -.fleet-preset-actions { - display: flex; - flex: 0 0 auto; - justify-content: flex-end; - gap: 3px; -} - -.fleet-toolbar-level, -.fleet-backup-actions { - display: flex; - flex: 0 0 auto; - align-items: center; - gap: 6px; -} - -.fleet-planner-toolbar .fleet-toolbar-level { - display: grid; - flex-basis: 100%; - grid-template-columns: max-content minmax(0, 1fr); - min-width: 0; -} - -.fleet-primary-rule-controls { - display: flex; - align-items: center; - gap: 6px; -} - -.fleet-planner-layout .btn-small { - padding: 4px 8px; - border-radius: 6px; - font-size: 10px; - line-height: 1.2; -} - -.fleet-formation-section { - display: flex; - flex: 0 0 auto; - flex-direction: column; - min-height: 0; - padding: 3px; - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: 6px; -} - -.fleet-backup-section { - flex: 1 1 0; - min-height: 216px; - margin-top: 0; - overflow: hidden; -} - -.fleet-validation-note { - align-self: center; - justify-self: end; - max-width: 100%; - margin: 0 0 0 auto; - color: var(--danger); - font-size: 11px; - font-weight: 600; - line-height: 1.35; - text-align: right; - white-space: normal; -} - -.fleet-formation-heading { - display: flex; - flex: 0 0 auto; - flex-wrap: wrap; - align-items: center; - justify-content: flex-start; - gap: 6px; - width: 100%; - min-height: 30px; - margin-bottom: 3px; - overflow: visible; - white-space: normal; -} - -.fleet-backup-heading { - margin-top: 3px; -} - -.fleet-backup-actions { - flex-wrap: wrap; - min-width: 0; -} - -.fleet-backup-hint { - color: var(--text-secondary); - font-size: 10px; -} - -.fleet-planner-layout .fleet-formation-tag { - align-self: center; - margin: 0 0 4px; - color: var(--text); - font-size: 11px; - font-weight: 700; - letter-spacing: normal; - line-height: 1.2; - text-transform: none; -} - -.fleet-planner-layout .fleet-formation-heading .fleet-formation-tag { - margin-bottom: 0; -} - -.fleet-planner-layout .fleet-queue-badge { - display: inline-flex; - min-width: 68px; - min-height: 21px; - align-items: center; - justify-content: center; - padding: 2px 8px; - border: 1px solid; - border-radius: 4px; - font-size: 10px; - - &.is-primary { - color: var(--accent); - background: var(--accent-subtle); - border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); - } - - &.is-backup { - color: var(--success); - background: color-mix(in srgb, var(--success) 10%, var(--bg-card-solid)); - border-color: color-mix(in srgb, var(--success) 55%, var(--border)); - - &[data-follow-mode="position"] { - color: var(--accent); - background: var(--accent-subtle); - border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); - } - } -} - -#btn-backup-follow-mode { - &[data-mode="ship"] { - color: var(--success); - background: color-mix(in srgb, var(--success) 10%, var(--bg-card-solid)); - border-color: color-mix(in srgb, var(--success) 55%, var(--border)); - } - - &[data-mode="position"] { - color: var(--accent); - background: var(--accent-subtle); - border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); - } -} - -.fleet-slot-rule { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 5px 10px; - margin-top: 4px; - padding: 5px 7px; - color: var(--text-secondary); - background: color-mix(in srgb, var(--bg-card-solid) 64%, transparent); - border: 1px solid var(--border); - border-radius: 5px; - font-size: 9px; - - > strong { - color: var(--text); - font-size: 10px; - } - - > label, - .fleet-level-fields label { - display: inline-flex; - align-items: center; - gap: 4px; - color: var(--text); - } - - .input { - width: 88px; - padding: 3px 5px; - font-size: 9px; - } -} - -.fleet-level-switch { - gap: 5px !important; - font-size: 10px; -} - -.fleet-level-fields { - display: inline-flex; - align-items: center; - gap: 8px; - color: var(--text); - font-size: 10px; - - &[hidden] { - display: none; - } - - label { - display: inline-flex; - align-items: center; - gap: 4px; - font-size: inherit; - } - - .input { - width: 60px; - padding: 4px 6px; - font-size: inherit; - } -} - -.fleet-slot-scroll, -.fleet-backup-scroll { - min-width: 0; - padding-bottom: 0; - overflow-x: auto; - overflow-y: hidden; - scrollbar-gutter: stable both-edges; - scrollbar-width: thin; -} - -.fleet-backup-scroll { - flex: 1 1 0; - min-height: 0; - overflow: auto; -} - -.fleet-slot-list, -.fleet-backup-slot-list { - display: grid; - grid-template-columns: repeat(6, minmax(0, 128px)); - grid-auto-rows: auto; - justify-content: center; - gap: 3px; - width: 100%; - min-height: 0; - margin: 0 auto; -} - -.fleet-slot { - position: relative; - display: flex; - width: 100%; - height: auto; - aspect-ratio: 128 / 200; - container-type: inline-size; - align-items: center; - justify-content: center; - min-width: 0; - padding: 0; - overflow: hidden; - color: var(--text); - background: var(--bg-card-solid); - border: 0; - border-radius: var(--radius-sm); - box-shadow: inset 0 0 0 1px var(--border); - text-align: center; - cursor: pointer; - - &:hover { - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 50%, var(--border)); - } - - &.active { - box-shadow: - 0 0 0 2px rgba(255, 255, 255, 0.72), - 0 0 16px color-mix(in srgb, var(--accent-hover) 72%, transparent); - } - - &.active::after, - &.drag-over::after { - content: ''; - position: absolute; - z-index: 10; - inset: 0; - border: 4px solid var(--accent-hover); - border-radius: inherit; - pointer-events: none; - } - - &.drag-over::after { - border-color: var(--success); - background: color-mix(in srgb, var(--success) 16%, transparent); - box-shadow: inset 0 0 18px color-mix(in srgb, var(--success) 36%, transparent); - } - - &.candidate-only { - background: - linear-gradient( - 145deg, - #b65b8f, - #4f87b9 46%, - #8b63b5 - ); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 34%, var(--border)); - } -} - -.fleet-slot > .fleet-ship-artwork { - width: 100%; - height: 100%; -} - -.fleet-slot .fleet-ship-number { - top: 3%; - left: 4%; - padding: clamp(1px, 1.5cqw, 2px) clamp(2px, 3cqw, 4px); - font-size: clamp(6px, 7.8cqw, 10px); -} - -.fleet-validation-preview { - position: absolute; - z-index: 8; - top: 15%; - left: 4%; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: clamp(2px, 2cqw, 3px); - pointer-events: none; -} - -.fleet-validation-badge { - min-width: clamp(31px, 31cqw, 45px); - padding: clamp(1px, 1.5cqw, 2px) clamp(2px, 3cqw, 4px); - overflow: hidden; - color: #fff; - border: 1px solid rgba(255, 255, 255, 0.42); - border-radius: 4px; - box-shadow: - 0 1px 4px rgba(0, 0, 0, 0.42), - inset 0 1px 0 rgba(255, 255, 255, 0.18); - backdrop-filter: blur(7px) saturate(130%); - font-size: clamp(6px, 7.8cqw, 10px); - font-weight: 700; - line-height: 1.2; - text-align: center; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.58); - white-space: nowrap; - - &.is-relaxed { - background: rgba(25, 117, 218, 0.76); - } - - &.is-strict { - background: rgba(198, 57, 66, 0.76); - } -} - -.fleet-slot .fleet-ship-type-icon { - top: 2%; - right: 2%; - width: 30%; - height: auto; - aspect-ratio: 1; -} - -.fleet-slot .fleet-ship-name { - right: 4%; - bottom: 3%; - left: 4%; - min-height: 18%; - padding: clamp(1px, 2cqw, 4px) clamp(2px, 3cqw, 6px); -} - -.fleet-slot .fleet-ship-name-text { - font-size: clamp(6px, 8.5cqw, 11px); -} - -.fleet-slot-placeholder-background { - position: absolute; - z-index: 0; - top: -8%; - left: 50%; - width: 132%; - height: auto; - max-width: none; - transform: translateX(-50%); - pointer-events: none; -} - -.fleet-slot-position { - display: none; -} - -.fleet-slot-empty { - padding: 10px; - color: var(--text-secondary); - font-size: 11px; - font-weight: 600; - text-align: center; -} - -.fleet-slot.candidate-only .fleet-slot-empty { - position: relative; - z-index: 2; - padding: 8px 14px; - color: #ffd75e; - background: rgba(0, 0, 0, 0.68); - border: 1px solid rgba(255, 215, 94, 0.3); - border-radius: 7px; - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.42); - backdrop-filter: blur(8px); - font-size: 12px; - letter-spacing: 0.04em; - text-shadow: 0 1px 3px #000; - user-select: none; -} - -.fleet-slot-remove { - position: absolute; - z-index: 8; - top: 6px; - right: 46px; - display: grid; - width: 18px; - height: 18px; - place-items: center; - color: #fff; - background: rgba(0, 0, 0, 0.72); - border: 1px solid rgba(255, 255, 255, 0.24); - border-radius: 50%; - font-size: 14px; - line-height: 1; - - &:hover { - color: var(--danger); - background: var(--danger-bg); - } -} - -.fleet-planner-note { - flex: 0 0 auto; - margin: 5px 0 0; - color: var(--text-secondary); - font-size: 9px; - line-height: 1.35; -} - -/* ═══ 图鉴工具栏与悬浮筛选 ═══ */ -.fleet-library-header { - position: relative; - display: grid; - grid-template-columns: minmax(88px, 1fr) minmax(230px, 340px); - gap: 5px; - align-items: center; - flex: 0 0 auto; - margin-bottom: 6px; -} - -.fleet-library-title { - display: flex; - align-items: baseline; - gap: 6px; - min-width: 0; - - h2 { - flex: 0 0 auto; - margin: 0; - font-size: 12px; - font-weight: 700; - line-height: 1.3; - } - - span { - overflow: hidden; - color: var(--text-secondary); - font-size: 10px; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -.fleet-library-search-actions { - display: flex; - width: 100%; - min-width: 0; - align-items: center; - justify-self: stretch; - gap: 5px; -} - -.fleet-library-filter-triggers { - display: flex; - flex: 1 0 50%; - justify-content: flex-end; - gap: 3px; - min-width: 138px; - - > .btn { - flex: 1 1 0; - width: 0; - min-width: 0; - white-space: nowrap; - } -} - -.fleet-planner-layout .fleet-filter-trigger { - position: relative; - display: inline-flex; - flex: 1 1 0; - align-items: center; - justify-content: center; - min-width: 0; - padding: 3px 6px; - border-radius: 5px; - font-size: 10px; - line-height: 1.2; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - &.active { - color: var(--accent-foreground); - background: var(--accent); - border-color: var(--accent); - } -} - -#fleet-filter-count:not(:empty) { - display: inline-grid; - min-width: 14px; - height: 14px; - margin-left: 3px; - place-items: center; - color: var(--accent); - background: #fff; - border-radius: 7px; - font-size: 8px; - font-weight: 800; -} - -.fleet-search { - display: flex; - flex: 1 1 50%; - align-items: center; - gap: 5px; - width: auto; - min-width: 0; - padding: 0 7px; - color: var(--text-secondary); - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: 6px; - - &:focus-within { - border-color: var(--accent); - box-shadow: 0 0 0 2px var(--accent-subtle); - } - - input { - width: 100%; - min-width: 0; - padding: 4px 0; - color: var(--text); - background: transparent; - border: 0; - outline: 0; - font: inherit; - font-size: 11px; - } -} - -.fleet-filter-popover { - position: absolute; - z-index: 40; - top: calc(100% + 5px); - right: 0; - width: min(620px, calc(100vw - 24px)); - max-width: calc(100vw - 24px); - max-height: min(420px, calc(100vh - var(--nav-height) - 112px)); - padding: 10px; - overflow-y: auto; - color: var(--text); - background: var(--bg-card-solid); - border: 1px solid var(--border-strong); - border-radius: 8px; - box-shadow: var(--shadow-elevated); - transform-origin: top right; - animation: floating-popover-in 140ms cubic-bezier(0.2, 0.72, 0.2, 1) both; - - &[hidden] { - display: none; - } -} - -@media (prefers-reduced-motion: reduce) { - .fleet-filter-popover { - animation-duration: 1ms; - } -} - -.fleet-filter-popover-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 8px; - color: var(--text); - font-size: 13px; -} - -.fleet-filter-popover-heading { - display: flex; - align-items: center; - gap: 12px; -} - -.fleet-refit-filter { - gap: 5px; - font-size: 11px; - font-weight: 600; - white-space: nowrap; -} - -.fleet-filter-popover-actions { - display: flex; - align-items: center; - gap: 5px; - - #btn-confirm-fleet-filter { - min-width: 88px; - } -} - -.fleet-filter-section { - display: grid; - grid-template-columns: 64px minmax(0, 1fr); - gap: 6px; - padding: 6px 0; - border-top: 1px solid var(--border); -} - -.fleet-filter-label { - padding-top: 3px; - color: var(--text); - font-size: 11px; - font-weight: 700; -} - -.fleet-filter-options { - display: flex; - flex-wrap: wrap; - gap: 4px; -} - -.fleet-filter-options-scroll { - max-height: 94px; - overflow-y: auto; - scrollbar-width: thin; -} - -.fleet-filter-option { - padding: 3px 7px; - color: var(--text-secondary); - background: var(--bg-subtle); - border: 1px solid var(--border); - border-radius: var(--radius-pill); - font-size: 11px; - cursor: pointer; - - &:hover { - color: var(--text); - background: var(--bg-hover); - } - - &.active { - color: var(--accent-foreground); - background: var(--accent); - border-color: var(--accent); - } -} - -.fleet-filter-sort-section { - align-items: center; -} - -.fleet-filter-sort-controls { - display: flex; - min-width: 0; - align-items: center; - justify-content: space-between; - gap: 10px; - white-space: nowrap; - - .fleet-filter-options { - flex-wrap: nowrap; - } -} - -.fleet-sort-direction { - display: flex; - flex: 0 0 auto; - align-items: center; - gap: 4px; - color: var(--text); - font-size: 11px; - cursor: pointer; - - input { - position: absolute; - width: 1px; - height: 1px; - opacity: 0; - } - - input:checked + .fleet-sort-track::after { - transform: translateX(16px); - } -} - -.fleet-sort-track { - position: relative; - width: 34px; - height: 18px; - background: var(--accent); - border-radius: 10px; - - &::after { - content: ''; - position: absolute; - top: 2px; - left: 2px; - width: 14px; - height: 14px; - background: #fff; - border-radius: 50%; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); - transition: transform 0.16s ease; - } -} - -/* ═══ 舰娘图鉴卡片 ═══ */ -.fleet-ship-gallery { - display: grid; - grid-template-columns: repeat(auto-fill, 128px); - grid-auto-rows: max-content; - justify-content: center; - gap: 6px; - align-content: start; - flex: 1 1 0; - min-height: 0; - padding: 2px 6px 10px 2px; - overflow-y: scroll; - scrollbar-gutter: stable; - scrollbar-width: thin; -} - -.fleet-ship-card { - position: relative; - display: block; - width: 128px; - height: 200px; - padding: 0; - overflow: hidden; - color: var(--text); - background: var(--bg-inset); - border: 0; - border-radius: 6px; - box-shadow: inset 0 0 0 1px var(--border); - cursor: pointer; - transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; - - &:hover { - z-index: 1; - box-shadow: - inset 0 0 0 1px var(--accent), - 0 6px 18px rgba(0, 0, 0, 0.3); - transform: translateY(-2px); - } - - &.in-active-fleet { - box-shadow: - inset 0 0 0 2px var(--success), - 0 0 0 2px color-mix(in srgb, var(--success) 20%, transparent); - } -} - -.fleet-ship-artwork { - position: relative; - display: block; - width: 128px; - height: 200px; - overflow: hidden; - background-color: var(--bg-card-solid); - pointer-events: none; -} - -.fleet-ship-frame { - position: absolute; - inset: 0; - width: 100%; - height: auto; - pointer-events: none; -} - -.fleet-ship-background { - position: absolute; - z-index: 0; - inset: 0; - width: 100%; - height: auto; - pointer-events: none; -} - -.fleet-ship-portrait-window { - position: absolute; - z-index: 1; - top: 1%; - right: 8%; - bottom: 1%; - left: 8%; - display: block; - overflow: hidden; - pointer-events: none; -} - -.fleet-ship-portrait { - display: block; - width: 100%; - height: auto; - object-position: center top; - pointer-events: none; -} - -.fleet-ship-frame { - z-index: 2; -} - -.fleet-ship-number { - position: absolute; - z-index: 6; - top: 6px; - left: 6px; - max-width: 72px; - padding: 2px 4px; - overflow: hidden; - color: #fff; - background: rgba(0, 0, 0, 0.74); - border: 1px solid rgba(255, 255, 255, 0.3); - border-radius: 4px; - box-shadow: 0 1px 4px rgba(0, 0, 0, 0.48); - font-size: 10px; - font-weight: 800; - line-height: 1.2; - text-overflow: ellipsis; - text-shadow: 0 1px 2px #000; - white-space: nowrap; - font-family: "Cascadia Code", "Consolas", monospace; -} - -.fleet-ship-type-icon { - position: absolute; - z-index: 7; - top: 4px; - right: 4px; - width: 38px; - height: 38px; - object-fit: contain; - pointer-events: none; - filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.8)); -} - -.fleet-ship-name { - position: absolute; - z-index: 5; - right: 6px; - bottom: 6px; - left: 6px; - display: grid; - min-height: 28px; - place-items: center; - padding: 4px 6px; - overflow: hidden; - background: rgba(0, 0, 0, 0.68); - border: 1px solid rgba(255, 255, 255, 0.16); - border-radius: 5px; - box-shadow: 0 2px 7px rgba(0, 0, 0, 0.34); - backdrop-filter: blur(7px); -} - -.fleet-ship-name-text { - display: block; - max-width: 100%; - overflow: hidden; - font-size: 11px; - font-weight: 800; - text-align: center; - text-overflow: ellipsis; - text-shadow: 0 1px 1px rgba(0, 0, 0, 0.78); - -webkit-text-stroke: 0.2px rgba(255, 255, 255, 0.35); - white-space: nowrap; -} - -.rarity-1 { - color: #fff; -} - -.rarity-2 { - color: #a0ffb3; -} - -.rarity-3 { - color: #91dcff; -} - -.rarity-4 { - color: #e1bdff; -} - -.rarity-5 { - color: #ffeb91; -} - -.rarity-6 { - color: #ff5c78; -} - -.fleet-library-empty { - grid-column: 1 / -1; - display: grid; - min-height: 120px; - place-items: center; - padding: 16px; - color: var(--text-muted); - background: var(--bg-inset); - border: 1px dashed var(--border-strong); - border-radius: var(--radius-sm); - font-size: 11px; - text-align: center; -} - -.fleet-team-loader-dialog { - width: min(780px, 94vw); - padding: 16px; - - .modal-actions { - margin-top: 5px; - } -} - -.fleet-dialog-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin-bottom: 10px; - - h3 { - flex: 0 0 auto; - margin: 0; - } - - p { - overflow: hidden; - margin: 0; - color: var(--text-secondary); - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; - } -} - -.fleet-dialog-heading, -.fleet-team-loader-header-actions { - display: flex; - min-width: 0; - align-items: center; - gap: 8px; -} - -.fleet-team-loader-header-actions { - flex: 0 0 auto; - - > span { - color: var(--text-secondary); - font-size: 10px; - white-space: nowrap; - } -} - -.fleet-team-loader-content { - display: grid; - grid-template-columns: minmax(190px, 0.8fr) minmax(300px, 1.2fr); - height: min(470px, calc(100vh - 230px)); - min-height: 270px; - gap: 8px; - margin-top: 8px; -} - -.fleet-team-loader-list { - display: flex; - min-height: 0; - flex-direction: column; - gap: 4px; - padding: 4px; - overflow-y: auto; - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: var(--radius-sm); -} - -.fleet-team-loader-item { - display: flex; - flex: 0 0 auto; - width: 100%; - flex-direction: column; - align-items: flex-start; - gap: 2px; - padding: 7px 8px; - color: var(--text); - text-align: left; - background: var(--bg-card-solid); - border: 1px solid var(--border); - border-radius: 6px; - cursor: pointer; - - &:hover { - background: var(--bg-hover); - border-color: var(--border-strong); - } - - &.active { - background: var(--accent-subtle); - border-color: var(--accent); - box-shadow: 0 0 0 1px var(--accent-subtle); - } - - strong { - width: 100%; - overflow: hidden; - font-size: 11px; - text-overflow: ellipsis; - white-space: nowrap; - } - - span { - color: var(--text-secondary); - font-size: 9px; - } -} - -.fleet-team-loader-item-heading { - display: flex; - width: 100%; - min-width: 0; - align-items: center; - justify-content: space-between; - gap: 6px; - - strong { - flex: 1 1 auto; - width: auto; - min-width: 0; - } -} - -.fleet-team-source-badge { - display: inline-flex; - box-sizing: border-box; - height: 16px; - flex: 0 0 auto; - align-self: center; - align-items: center; - justify-content: center; - padding: 0 5px; - background: transparent; - border: 1px solid; - border-radius: var(--radius-pill); - font-size: 9px; - line-height: 1; - - &.system { - color: #e76b70; - border-color: #e76b70; - } - - &.user { - color: #55b77a; - border-color: #55b77a; - } - - &.deleted { - color: #fff; - background: #15171a; - border-color: #15171a; - } -} - -.fleet-team-loader-preview-column { - display: grid; - min-width: 0; - min-height: 0; - grid-template-rows: auto minmax(0, 1fr); - gap: 6px; -} - -.fleet-team-loader-tools { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 6px; -} - -.fleet-team-loader-system-filter { - display: flex; - min-height: 26px; - align-items: center; - gap: 7px; - padding: 3px 7px; - color: var(--text-secondary); - background: var(--bg-card-solid); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - font-size: 10px; - white-space: nowrap; -} - -.fleet-team-loader-sort { - display: flex; - min-height: 26px; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 3px 7px; - color: var(--text-secondary); - background: var(--bg-card-solid); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - font-size: 10px; -} - -.fleet-team-loader-sort-controls, -.fleet-team-loader-sort-fields { - display: flex; - align-items: center; -} - -.fleet-team-loader-sort-controls { - gap: 8px; -} - -.fleet-team-loader-sort-fields { - overflow: hidden; - border: 1px solid var(--border); - border-radius: 5px; -} - -.fleet-team-loader-sort-option { - height: 20px; - padding: 0 7px; - color: var(--text-secondary); - background: var(--bg-inset); - border: 0; - border-radius: 0; - font-size: 9px; - cursor: pointer; - - + .fleet-team-loader-sort-option { - border-left: 1px solid var(--border); - } - - &:hover { - color: var(--text); - background: var(--bg-hover); - } - - &.active { - color: var(--accent); - background: var(--accent-subtle); - } -} - -.fleet-team-loader-preview { - min-width: 0; - min-height: 0; - padding: 8px; - overflow: hidden; - background: var(--bg-card-solid); - border: 1px solid var(--border); - border-radius: var(--radius-sm); -} - -.fleet-team-loader-preview.fleet-team-preview { - display: block; - overflow-x: hidden; - overflow-y: auto; -} - -.fleet-team-loader-preview .fleet-team-main-preview { - grid-template-columns: repeat(6, minmax(0, 1fr)); - max-width: none; - gap: 4px; -} - -.fleet-team-loader-preview .fleet-team-backup-preview { - max-width: none; -} - -.fleet-team-loader-preview-empty { - grid-column: 1 / -1; - display: grid; - min-height: 72px; - place-items: center; - padding: 10px; - color: var(--text-muted); - background: var(--bg-inset); - border: 1px dashed var(--border); - border-radius: 6px; - font-size: 10px; - text-align: center; -} - -.fleet-dialog-status { - min-height: 0; - margin: 5px 0 0; - color: var(--warning); - font-size: 10px; - - &:empty { - display: none; - } -} - -.fleet-backup-copy-dialog { - width: min(430px, 94vw); - padding: 16px; - - h3 { - margin: 0 0 6px; - } - - > p { - margin: 0 0 10px; - color: var(--text-secondary); - font-size: 11px; - } - - .modal-actions { - margin-top: 10px; - } -} - -.fleet-backup-copy-targets { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 6px; -} - -.fleet-backup-copy-target { - min-width: 0; - padding: 8px; - color: var(--text); - text-align: left; - background: var(--bg-inset); - border: 1px solid var(--border); - border-radius: 6px; - cursor: pointer; - - &:hover:not(:disabled) { - background: var(--accent-subtle); - border-color: var(--accent); - } - - &:disabled { - color: var(--text-muted); - cursor: not-allowed; - opacity: 0.62; - } - - strong, - span { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - strong { - font-size: 11px; - } - - span { - margin-top: 3px; - color: var(--text-secondary); - font-size: 9px; - } -} - -@media (max-width: 620px) { - .fleet-team-loader-content { - grid-template-columns: 1fr; - height: min(650px, calc(100vh - 230px)); - overflow-y: auto; - } - - .fleet-team-loader-list { - min-height: 120px; - max-height: 150px; - } - - .fleet-team-loader-preview-column { - min-height: 420px; - } - - .fleet-team-loader-tools { - grid-template-columns: 1fr; - } -} - -@container (max-width: 620px) { - .fleet-planner-toolbar { - flex-wrap: wrap; - row-gap: 3px; - overflow-x: hidden; - white-space: normal; - } - - .fleet-toolbar-level { - flex: 0 0 100%; - } -} - -@container (max-width: 420px) { - .fleet-library-header { - grid-template-columns: auto minmax(0, 1fr); - gap: 5px; - } - - .fleet-library-title span { - display: none; - } - - .fleet-library-search-actions { - justify-self: stretch; - } - - .fleet-library-filter-triggers { - flex-wrap: nowrap; - } - - .fleet-planner-layout .fleet-filter-trigger { - flex: 1 1 0; - padding-right: 4px; - padding-left: 4px; - } - - .fleet-filter-trigger-separator, - .fleet-filter-trigger-value { - display: none; - } -} - -@container (min-width: 421px) and (max-width: 620px) { - .fleet-library-header { - grid-template-columns: minmax(88px, 1fr) minmax(230px, 320px); - } - - .fleet-planner-layout .fleet-filter-trigger { - padding-right: 4px; - padding-left: 4px; - font-size: 10px; - } -} - -@media (max-height: 600px) { - .fleet-planner-layout > .card { - padding: 6px; - } - - .fleet-formation-heading { - min-height: 28px; - } - - .fleet-formation-section { - padding: 3px; - } - - .fleet-backup-section { - flex: 0 0 auto; - min-height: 216px; - margin-top: 4px; - } - - .fleet-planner-fleet { - overflow-x: hidden; - overflow-y: auto; - } - - .fleet-backup-scroll { - flex: 0 0 auto; - height: 206px; - } - - .fleet-formation-heading, - .fleet-planner-layout .fleet-formation-tag { - margin-bottom: 3px; - } - - .fleet-library-header { - margin-bottom: 4px; - } - - .fleet-filter-popover { - max-height: calc(100vh - var(--nav-height) - 100px); - } -} - -@media (max-width: 1120px) { - .fleet-preset-name-field .input { - width: 96px; - } -} - -@media (max-height: 800px) { - .fleet-formation-section { - padding: 3px; - } - - .fleet-planner-layout .fleet-formation-tag { - margin-bottom: 2px; - } - - .fleet-formation-heading { - margin-bottom: 2px; - } - - .fleet-backup-hint { - display: none; - } - - .fleet-slot-scroll { - padding-bottom: 0; - } - - .fleet-slot-rule { - gap: 3px 8px; - margin-top: 2px; - padding: 2px 5px; - } - - .fleet-planner-note { - display: none; - } -} +@use 'plan-navigation'; +@use 'plan-management'; +@use 'fleet-editor'; +@use 'ship-gallery'; +@use 'fleet-dialogs'; +@use 'fleet-responsive'; diff --git a/src/view/styles/pages/plan/_fleet-responsive.scss b/src/view/styles/pages/plan/_fleet-responsive.scss new file mode 100644 index 0000000..a36e0fd --- /dev/null +++ b/src/view/styles/pages/plan/_fleet-responsive.scss @@ -0,0 +1,156 @@ +@media (max-width: 620px) { + .fleet-team-loader-content { + grid-template-columns: 1fr; + height: min(650px, calc(100vh - 230px)); + overflow-y: auto; + } + + .fleet-team-loader-list { + min-height: 120px; + max-height: 150px; + } + + .fleet-team-loader-preview-column { + min-height: 420px; + } + + .fleet-team-loader-tools { + grid-template-columns: 1fr; + } +} + +@container (max-width: 620px) { + .fleet-planner-toolbar { + flex-wrap: wrap; + row-gap: 3px; + overflow-x: hidden; + white-space: normal; + } + + .fleet-toolbar-level { + flex: 0 0 100%; + } +} + +@container (max-width: 420px) { + .fleet-library-header { + grid-template-columns: auto minmax(0, 1fr); + gap: 5px; + } + + .fleet-library-title span { + display: none; + } + + .fleet-library-search-actions { + justify-self: stretch; + } + + .fleet-library-filter-triggers { + flex-wrap: nowrap; + } + + .fleet-planner-layout .fleet-filter-trigger { + flex: 1 1 0; + padding-right: 4px; + padding-left: 4px; + } + + .fleet-filter-trigger-separator, + .fleet-filter-trigger-value { + display: none; + } +} + +@container (min-width: 421px) and (max-width: 620px) { + .fleet-library-header { + grid-template-columns: minmax(88px, 1fr) minmax(230px, 320px); + } + + .fleet-planner-layout .fleet-filter-trigger { + padding-right: 4px; + padding-left: 4px; + font-size: 10px; + } +} + +@media (max-height: 600px) { + .fleet-planner-layout > .card { + padding: 6px; + } + + .fleet-formation-heading { + min-height: 28px; + } + + .fleet-formation-section { + padding: 3px; + } + + .fleet-backup-section { + flex: 0 0 auto; + min-height: 216px; + margin-top: 4px; + } + + .fleet-planner-fleet { + overflow-x: hidden; + overflow-y: auto; + } + + .fleet-backup-scroll { + flex: 0 0 auto; + height: 206px; + } + + .fleet-formation-heading, + .fleet-planner-layout .fleet-formation-tag { + margin-bottom: 3px; + } + + .fleet-library-header { + margin-bottom: 4px; + } + + .fleet-filter-popover { + max-height: calc(100vh - var(--nav-height) - 100px); + } +} + +@media (max-width: 1120px) { + .fleet-preset-name-field .input { + width: 96px; + } +} + +@media (max-height: 800px) { + .fleet-formation-section { + padding: 3px; + } + + .fleet-planner-layout .fleet-formation-tag { + margin-bottom: 2px; + } + + .fleet-formation-heading { + margin-bottom: 2px; + } + + .fleet-backup-hint { + display: none; + } + + .fleet-slot-scroll { + padding-bottom: 0; + } + + .fleet-slot-rule { + gap: 3px 8px; + margin-top: 2px; + padding: 2px 5px; + } + + .fleet-planner-note { + display: none; + } +} diff --git a/src/view/styles/pages/plan/_plan-management.scss b/src/view/styles/pages/plan/_plan-management.scss new file mode 100644 index 0000000..293e1ff --- /dev/null +++ b/src/view/styles/pages/plan/_plan-management.scss @@ -0,0 +1,508 @@ +.plan-management-layout, +.legacy-decisive-card { + height: 100%; + margin-bottom: 0; +} + +.plan-management-layout { + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} + +.plan-team-management-card { + display: flex; + flex: 1 1 0; + flex-direction: column; + min-height: 180px; + margin-bottom: 0; + overflow: hidden; +} + +.plan-management-heading { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 8px; + + h3 { + margin: 0; + font-size: 13px; + } + + p { + margin: 2px 0 0; + color: var(--text-muted); + font-size: 10px; + } + + .btn { + flex: 0 0 auto; + height: 26px; + padding: 4px 10px; + } +} + +.plan-management-title { + display: flex; + min-width: 0; + align-items: baseline; + gap: 8px; + + h3 { + flex: 0 0 auto; + } + + p { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.plan-management-summary { + display: grid; + flex: 0 0 auto; + grid-template-columns: repeat(4, minmax(90px, 1fr)); + gap: 5px; + margin-bottom: 7px; +} + +.plan-management-stat { + display: flex; + min-height: 46px; + padding: 6px 10px; + flex-direction: column; + justify-content: center; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 6px; + + span { + color: var(--text-muted); + font-size: 9px; + } + + strong { + margin-top: 1px; + color: var(--text); + font-size: 17px; + line-height: 1.1; + } + + &.is-linked { + border-color: color-mix(in srgb, var(--success) 42%, var(--border)); + + strong { + color: var(--success); + } + } + + &.is-warning { + border-color: color-mix(in srgb, var(--warning) 48%, var(--border)); + + strong { + color: var(--warning); + } + } +} + +.plan-management-toolbar { + display: flex; + flex: 0 0 auto; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 7px; +} + +.plan-management-subtabs { + display: flex; + flex: 0 0 auto; + gap: 4px; +} + +.plan-management-subtab { + height: 26px; + padding: 0 10px; + color: var(--text-secondary); + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 5px; + font-size: 11px; + cursor: pointer; + + &:hover, + &.active { + color: var(--text); + background: var(--accent-subtle); + border-color: var(--accent); + } +} + +.plan-management-export, +.plan-management-delete { + height: 26px; + padding: 0 10px; + font-size: 11px; +} + +.plan-management-filters { + display: flex; + min-width: 0; + align-items: center; + justify-content: flex-end; + gap: 5px; +} + +.plan-management-search { + width: clamp(150px, 23vw, 250px); + height: 26px; + padding: 3px 8px; + font-size: 10px; +} + +.plan-management-kind-filter { + display: flex; + flex: 0 0 auto; + gap: 2px; + padding: 2px; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 5px; + + button { + height: 20px; + padding: 0 7px; + color: var(--text-muted); + background: transparent; + border: 0; + border-radius: 3px; + font-size: 9px; + cursor: pointer; + + &:hover { + color: var(--text-secondary); + background: var(--bg-hover); + } + + &.active { + color: var(--accent-text); + background: var(--accent-subtle); + } + } +} + +.plan-management-attention-filter { + flex: 0 0 auto; + gap: 5px; + white-space: nowrap; + + > span:last-child { + font-size: 10px; + } +} + +.plan-management-warning { + flex: 0 0 auto; + margin: 0 0 7px; + padding: 5px 8px; + color: var(--warning); + background: var(--warning-bg); + border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border)); + border-radius: 5px; + font-size: 10px; +} + +.plan-team-management-table-wrap { + flex: 1 1 0; + min-height: 0; + overflow: auto; + border: 1px solid var(--border); + border-radius: 6px; + scrollbar-width: thin; +} + +.plan-team-management-table { + width: 100%; + min-width: 1020px; + table-layout: fixed; + border-collapse: collapse; + font-size: 11px; + + th, + td { + padding: 7px 9px; + border-bottom: 1px solid var(--border); + text-align: left; + vertical-align: middle; + } + + th { + position: sticky; + z-index: 2; + top: 0; + color: var(--text); + background: var(--bg-card-solid); + box-shadow: 0 1px 0 var(--border-strong); + font-size: 11px; + } + + th:nth-child(1) { + width: 38px; + } + + th:nth-child(2) { + width: 23%; + } + + th:nth-child(3) { + width: 70px; + } + + th:nth-child(4) { + width: 22%; + } + + th:nth-child(5) { + width: 18%; + } + + th:nth-child(6) { + width: 118px; + } + + th:nth-child(7), + td:last-child { + width: 204px; + white-space: nowrap; + } + + tbody tr { + transition: background 0.12s ease; + } + + tbody tr:hover { + background: var(--bg-hover); + } + + tbody tr.needs-attention { + background: color-mix(in srgb, var(--warning-bg) 55%, transparent); + } +} + +.plan-management-selection-column, +.plan-management-selection-cell { + padding-right: 5px !important; + padding-left: 5px !important; + text-align: center !important; + + input { + width: 14px; + height: 14px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; + vertical-align: middle; + } +} + +.plan-kind-badge { + display: inline-block; + margin-right: 6px; + padding: 1px 5px; + color: var(--text-muted); + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + font-size: 9px; + + &.battle { + color: var(--warning); + border-color: color-mix(in srgb, var(--warning) 50%, var(--border)); + } + + &.team { + color: #55b77a; + border-color: color-mix(in srgb, #55b77a 50%, var(--border)); + } +} + +.plan-management-identity { + display: inline-flex; + max-width: calc(100% - 48px); + flex-direction: column; + vertical-align: middle; + + strong, + small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + strong { + color: var(--text); + font-size: 11px; + } + + small { + margin-top: 2px; + color: var(--text-muted); + font-size: 9px; + } +} + +.plan-source-badge, +.plan-management-status, +.plan-relation-chip { + display: inline-flex; + align-items: center; + border-radius: var(--radius-pill); + white-space: nowrap; +} + +.plan-source-badge { + padding: 2px 7px; + font-size: 9px; + + &.system { + color: var(--danger); + background: var(--danger-bg); + } + + &.user { + color: var(--success); + background: var(--success-bg); + } +} + +.plan-management-relations { + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.plan-relation-chip { + max-width: 100%; + padding: 2px 7px; + overflow: hidden; + color: var(--text-secondary); + background: var(--bg-inset); + border: 1px solid var(--border); + font-size: 9px; + text-overflow: ellipsis; + + &.missing { + color: var(--danger); + background: var(--danger-bg); + border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); + } +} + +.plan-management-relation-empty { + color: var(--text-muted); + font-size: 10px; +} + +.plan-management-relation-error { + overflow: hidden; + color: var(--danger); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.plan-management-status { + padding: 2px 7px; + font-size: 9px; + + &.ok { + color: var(--success); + background: var(--success-bg); + } + + &.warning { + color: var(--warning); + background: var(--warning-bg); + } + + &.muted { + color: var(--text-muted); + background: var(--bg-inset); + } +} + +.plan-management-actions { + .btn { + height: 24px; + padding: 3px 7px; + font-size: 10px; + } + + .btn + .btn { + margin-left: 4px; + } +} + +.plan-management-empty { + color: var(--text-muted); + text-align: center !important; +} + +@media (max-width: 900px) { + .plan-management-summary { + grid-template-columns: repeat(2, minmax(90px, 1fr)); + } + + .plan-management-toolbar { + align-items: stretch; + flex-direction: column; + } + + .plan-management-filters { + justify-content: flex-start; + } + + .plan-management-search { + flex: 1 1 150px; + width: auto; + } +} + +@media (max-width: 620px) { + .plan-management-heading p { + display: none; + } + + .plan-management-filters { + flex-wrap: wrap; + } + + .plan-management-search { + flex-basis: 100%; + } +} + +.legacy-decisive-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + + h2 { + margin-bottom: 12px; + font-size: 18px; + } + + p { + max-width: 620px; + color: var(--text-muted); + font-size: 13px; + line-height: 1.7; + } +} diff --git a/src/view/styles/pages/plan/_plan-navigation.scss b/src/view/styles/pages/plan/_plan-navigation.scss new file mode 100644 index 0000000..7ee7b18 --- /dev/null +++ b/src/view/styles/pages/plan/_plan-navigation.scss @@ -0,0 +1,58 @@ +/* ═══ 计划页二级导航 ═══ */ +#page-plan { + height: calc(100vh - var(--nav-height) - var(--page-block-space)); + min-height: 0; +} + +#page-plan.active { + display: flex; + flex-direction: column; +} + +.plan-subtabs { + display: flex; + flex: 0 0 auto; + gap: 3px; + margin-bottom: 6px; + padding: 3px; + overflow-x: auto; + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} + +.plan-subtab { + flex: 0 0 auto; + padding: 5px 12px; + border: 0; + border-radius: 6px; + color: var(--text-muted); + background: transparent; + font-size: 11px; + font-weight: 600; + cursor: pointer; + + &:hover { + color: var(--text-secondary); + background: var(--bg-hover); + } + + &.active { + color: var(--text); + background: var(--bg-card-solid); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.14); + } +} + +.plan-tab-panel { + flex: 1 1 0; + min-height: 0; + + &[hidden] { + display: none !important; + } +} + +.plan-tab-panel.active { + display: block; +} diff --git a/src/view/styles/pages/plan/_ship-gallery.scss b/src/view/styles/pages/plan/_ship-gallery.scss new file mode 100644 index 0000000..d835a24 --- /dev/null +++ b/src/view/styles/pages/plan/_ship-gallery.scss @@ -0,0 +1,491 @@ +/* ═══ 图鉴工具栏与悬浮筛选 ═══ */ +.fleet-library-header { + position: relative; + display: grid; + grid-template-columns: minmax(88px, 1fr) minmax(230px, 340px); + gap: 5px; + align-items: center; + flex: 0 0 auto; + margin-bottom: 6px; +} + +.fleet-library-title { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; + + h2 { + flex: 0 0 auto; + margin: 0; + font-size: 12px; + font-weight: 700; + line-height: 1.3; + } + + span { + overflow: hidden; + color: var(--text-secondary); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.fleet-library-search-actions { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + justify-self: stretch; + gap: 5px; +} + +.fleet-library-filter-triggers { + display: flex; + flex: 1 0 50%; + justify-content: flex-end; + gap: 3px; + min-width: 138px; + + > .btn { + flex: 1 1 0; + width: 0; + min-width: 0; + white-space: nowrap; + } +} + +.fleet-planner-layout .fleet-filter-trigger { + position: relative; + display: inline-flex; + flex: 1 1 0; + align-items: center; + justify-content: center; + min-width: 0; + padding: 3px 6px; + border-radius: 5px; + font-size: 10px; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &.active { + color: var(--accent-foreground); + background: var(--accent); + border-color: var(--accent); + } +} + +#fleet-filter-count:not(:empty) { + display: inline-grid; + min-width: 14px; + height: 14px; + margin-left: 3px; + place-items: center; + color: var(--accent); + background: #fff; + border-radius: 7px; + font-size: 8px; + font-weight: 800; +} + +.fleet-search { + display: flex; + flex: 1 1 50%; + align-items: center; + gap: 5px; + width: auto; + min-width: 0; + padding: 0 7px; + color: var(--text-secondary); + background: var(--bg-inset); + border: 1px solid var(--border); + border-radius: 6px; + + &:focus-within { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-subtle); + } + + input { + width: 100%; + min-width: 0; + padding: 4px 0; + color: var(--text); + background: transparent; + border: 0; + outline: 0; + font: inherit; + font-size: 11px; + } +} + +.fleet-filter-popover { + position: absolute; + z-index: 40; + top: calc(100% + 5px); + right: 0; + width: min(620px, calc(100vw - 24px)); + max-width: calc(100vw - 24px); + max-height: min(420px, calc(100vh - var(--nav-height) - 112px)); + padding: 10px; + overflow-y: auto; + color: var(--text); + background: var(--bg-card-solid); + border: 1px solid var(--border-strong); + border-radius: 8px; + box-shadow: var(--shadow-elevated); + transform-origin: top right; + animation: floating-popover-in 140ms cubic-bezier(0.2, 0.72, 0.2, 1) both; + + &[hidden] { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .fleet-filter-popover { + animation-duration: 1ms; + } +} + +.fleet-filter-popover-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + color: var(--text); + font-size: 13px; +} + +.fleet-filter-popover-heading { + display: flex; + align-items: center; + gap: 12px; +} + +.fleet-refit-filter { + gap: 5px; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.fleet-filter-popover-actions { + display: flex; + align-items: center; + gap: 5px; + + #btn-confirm-fleet-filter { + min-width: 88px; + } +} + +.fleet-filter-section { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + gap: 6px; + padding: 6px 0; + border-top: 1px solid var(--border); +} + +.fleet-filter-label { + padding-top: 3px; + color: var(--text); + font-size: 11px; + font-weight: 700; +} + +.fleet-filter-options { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.fleet-filter-options-scroll { + max-height: 94px; + overflow-y: auto; + scrollbar-width: thin; +} + +.fleet-filter-option { + padding: 3px 7px; + color: var(--text-secondary); + background: var(--bg-subtle); + border: 1px solid var(--border); + border-radius: var(--radius-pill); + font-size: 11px; + cursor: pointer; + + &:hover { + color: var(--text); + background: var(--bg-hover); + } + + &.active { + color: var(--accent-foreground); + background: var(--accent); + border-color: var(--accent); + } +} + +.fleet-filter-sort-section { + align-items: center; +} + +.fleet-filter-sort-controls { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 10px; + white-space: nowrap; + + .fleet-filter-options { + flex-wrap: nowrap; + } +} + +.fleet-sort-direction { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 4px; + color: var(--text); + font-size: 11px; + cursor: pointer; + + input { + position: absolute; + width: 1px; + height: 1px; + opacity: 0; + } + + input:checked + .fleet-sort-track::after { + transform: translateX(16px); + } +} + +.fleet-sort-track { + position: relative; + width: 34px; + height: 18px; + background: var(--accent); + border-radius: 10px; + + &::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + background: #fff; + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + transition: transform 0.16s ease; + } +} + +/* ═══ 舰娘图鉴卡片 ═══ */ +.fleet-ship-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, 128px); + grid-auto-rows: max-content; + justify-content: center; + gap: 6px; + align-content: start; + flex: 1 1 0; + min-height: 0; + padding: 2px 6px 10px 2px; + overflow-y: scroll; + scrollbar-gutter: stable; + scrollbar-width: thin; +} + +.fleet-ship-card { + position: relative; + display: block; + width: 128px; + height: 200px; + padding: 0; + overflow: hidden; + color: var(--text); + background: var(--bg-inset); + border: 0; + border-radius: 6px; + box-shadow: inset 0 0 0 1px var(--border); + cursor: pointer; + transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; + + &:hover { + z-index: 1; + box-shadow: + inset 0 0 0 1px var(--accent), + 0 6px 18px rgba(0, 0, 0, 0.3); + transform: translateY(-2px); + } + + &.in-active-fleet { + box-shadow: + inset 0 0 0 2px var(--success), + 0 0 0 2px color-mix(in srgb, var(--success) 20%, transparent); + } +} + +.fleet-ship-artwork { + position: relative; + display: block; + width: 128px; + height: 200px; + overflow: hidden; + background-color: var(--bg-card-solid); + pointer-events: none; +} + +.fleet-ship-frame { + position: absolute; + inset: 0; + width: 100%; + height: auto; + pointer-events: none; +} + +.fleet-ship-background { + position: absolute; + z-index: 0; + inset: 0; + width: 100%; + height: auto; + pointer-events: none; +} + +.fleet-ship-portrait-window { + position: absolute; + z-index: 1; + top: 1%; + right: 8%; + bottom: 1%; + left: 8%; + display: block; + overflow: hidden; + pointer-events: none; +} + +.fleet-ship-portrait { + display: block; + width: 100%; + height: auto; + object-position: center top; + pointer-events: none; +} + +.fleet-ship-frame { + z-index: 2; +} + +.fleet-ship-number { + position: absolute; + z-index: 6; + top: 6px; + left: 6px; + max-width: 72px; + padding: 2px 4px; + overflow: hidden; + color: #fff; + background: rgba(0, 0, 0, 0.74); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 4px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.48); + font-size: 10px; + font-weight: 800; + line-height: 1.2; + text-overflow: ellipsis; + text-shadow: 0 1px 2px #000; + white-space: nowrap; + font-family: "Cascadia Code", "Consolas", monospace; +} + +.fleet-ship-type-icon { + position: absolute; + z-index: 7; + top: 4px; + right: 4px; + width: 38px; + height: 38px; + object-fit: contain; + pointer-events: none; + filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.8)); +} + +.fleet-ship-name { + position: absolute; + z-index: 5; + right: 6px; + bottom: 6px; + left: 6px; + display: grid; + min-height: 28px; + place-items: center; + padding: 4px 6px; + overflow: hidden; + background: rgba(0, 0, 0, 0.68); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 5px; + box-shadow: 0 2px 7px rgba(0, 0, 0, 0.34); + backdrop-filter: blur(7px); +} + +.fleet-ship-name-text { + display: block; + max-width: 100%; + overflow: hidden; + font-size: 11px; + font-weight: 800; + text-align: center; + text-overflow: ellipsis; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.78); + -webkit-text-stroke: 0.2px rgba(255, 255, 255, 0.35); + white-space: nowrap; +} + +.rarity-1 { + color: #fff; +} + +.rarity-2 { + color: #a0ffb3; +} + +.rarity-3 { + color: #91dcff; +} + +.rarity-4 { + color: #e1bdff; +} + +.rarity-5 { + color: #ffeb91; +} + +.rarity-6 { + color: #ff5c78; +} + +.fleet-library-empty { + grid-column: 1 / -1; + display: grid; + min-height: 120px; + place-items: center; + padding: 16px; + color: var(--text-muted); + background: var(--bg-inset); + border: 1px dashed var(--border-strong); + border-radius: var(--radius-sm); + font-size: 11px; + text-align: center; +} From 2e8afa73b492cad2e36acbbce5c184b16602292e Mon Sep 17 00:00:00 2001 From: chenxuan Date: Tue, 11 Aug 2026 22:12:38 +0800 Subject: [PATCH 58/82] docs: align agent guidance and architecture references --- AGENTS.md | 356 ++++---- CLAUDE.md | 2 +- CONTRIBUTING.md | 4 +- README.md | 3 +- docs/TODO.md | 12 +- docs/architecture/00-overview.md | 463 ++++------ docs/architecture/01-controller-layer.md | 364 ++++---- docs/architecture/02-task-scheduling.md | 362 ++++---- docs/architecture/03-configuration.md | 373 ++++----- docs/architecture/04-battle-plan.md | 434 ++++------ .../architecture/05-template-and-taskgroup.md | 320 +++---- docs/architecture/06-backend-communication.md | 457 ++++------ .../architecture/07-environment-management.md | 436 ++++------ docs/architecture/08-dev-setup.md | 432 ++++------ .../architecture/09-src-typescript-catalog.md | 376 +++++---- .../architecture/10-runtime-boundaries-adr.md | 216 +++-- .../11-renderer-visual-architecture.md | 224 +++++ docs/architecture/12-agent-entry-guide.md | 203 +++++ docs/architecture/README.md | 66 +- docs/engineering-standards.md | 663 --------------- docs/features/gui-2.0-alpha-pr-report.md | 2 +- docs/features/gui-2.0.md | 4 +- docs/plan-guide.md | 789 +++++++++++------- .../2026-08-03-pr18-migration-ledger.md | 6 +- ...26-08-04-src-module-split-agent-runbook.md | 25 +- .../2026-08-04-src-module-split-plan.md | 5 +- docs/reviews/2026-08-10-fix-guide.md | 10 +- docs/teaching/00-overview.md | 268 +++--- docs/teaching/01-extract-class.md | 291 ++++--- docs/teaching/02-host-interface.md | 301 ++++--- docs/teaching/03-viewobject-flow.md | 300 ++++--- docs/teaching/04-electron-split.md | 343 ++++---- docs/teaching/05-view-layer.md | 345 +++++--- docs/teaching/06-model-layer.md | 347 ++++---- docs/teaching/07-type-system.md | 372 +++++---- docs/teaching/README.md | 72 +- docs/user-guide.md | 157 +++- 37 files changed, 4652 insertions(+), 4751 deletions(-) create mode 100644 docs/architecture/11-renderer-visual-architecture.md create mode 100644 docs/architecture/12-agent-entry-guide.md delete mode 100644 docs/engineering-standards.md diff --git a/AGENTS.md b/AGENTS.md index 166e979..23afd2e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,202 +1,200 @@ # AutoWSGR-GUI Agent 约束 -本文件是所有人工辅助 Agent、自动化 Agent、代码生成/审查工具和 Issue 分析工具的统一入口。它规定加载顺序和项目特有约束;不得替代、改写或削弱权威规范。 +本文件是所有人工辅助 Agent、自动化 Agent、代码生成/审查工具和 Issue 分析工具的项目入口。用户明确要求优先于本文件;本文件中的 Coding 原则适用于所有分支和发布频道,`alpha` 不是降低质量门槛的理由。 -## Alpha 自主实验边界 +## 1. Coding 最高原则 -`alpha` 是面向用户反馈的自主产品实验分支,不是等待整体合入 `main` 的功能分支。Agent 可以优先追求快速试验,不要求保持 `main` 的架构、实现兼容性或常规质量门禁;本文件其余面向稳定主线的工程约束,在 `alpha` 上仅作为建议,除非同时属于下列硬性安全底线: +### 1.1 四大原则 -- 不得泄露、传输或提交密钥、凭据、Token、用户隐私数据、含隐私的日志或未经授权的本地配置。 -- 不得执行破坏性系统或 Git 操作,不得改写或删除受保护分支历史,并须保留无关工作。 -- 用户配置迁移前必须备份;迁移须可恢复,失败时保留此前可用数据,不得静默丢弃用户数据。 -- 强化、解装、购买等不可逆或消耗舰船、货币、道具的游戏操作必须默认关闭,要求用户明确确认;目标、状态或确认不确定时必须 fail closed。 -- 实验构建和发布必须显著标注 `alpha` 或“实验版”,不得让用户误认为稳定版本。 -- 发布前至少必须成功构建并实际启动;无法完成启动验证时不得发布。 +每一次代码改动,无论大小,都必须同时满足: -禁止将 `alpha` 整体 merge、squash 或 rebase 到 `main`。向 `main` 晋升功能时,只能独立选择有效资产或行为,在稳定基线上规范化、验证并以独立 PR 合入。 +1. **最小改动**:只修改实现目标所需的代码、契约和测试,不顺手重构、格式化或清理无关内容。 +2. **最大复用**:优先复用职责、生命周期、输入输出和副作用均匹配的现有实现;不得为了表面复用扩大原模块职责。 +3. **最低影响现有功能**:业务逻辑、现有能力、UI 元素、交互、样式、配置和已发布数据契约默认保持不变。 +4. **严格控制代码边界**:改动必须留在清晰的功能和架构边界内,不得引入跨层补偿、重复状态源或隐式耦合。 -## 1. 接入协议 +业务逻辑正确性和现有行为兼容性为第一优先级。用户提出修复功能 A,只代表授权修改 A 的目标行为,不自动授权改变关联业务规则;若根因修复必须改变业务实现、默认行为、数据含义、交互或功能 B,写入前必须说明现状、拟议变化、影响范围和验证方式,并取得用户确认。 -开始分析、写入文件或执行可能改变仓库状态的命令前,必须: +### 1.2 分批改动 -1. 执行 `git status --short --branch`,确认分支、worktree 和未提交修改。 -2. 完整读取 `docs/engineering-standards.md`,以及 `.editorconfig`、`.gitattributes`、`tsconfig.json`、`package.json` 和 `CONTRIBUTING.md`。 -3. 按任务范围读取第 3 节的架构文档和现有测试。 -4. 搜索已有实现、同领域规则、历史 workaround 和相关数据契约。 -5. 修改前明确行为目标/非目标、状态所有者、受影响架构层、当前止损等级和验证方式。 +- 涉及多个独立功能、多个架构边界或大量手写文件时,必须先给出分批方案,经用户确认后实施。 +- 每一批只解决一个可描述、可验证、可审查、可回滚的行为边界,并独立遵守四大原则。 +- 每批完成后先检查 diff、运行匹配测试并确认没有相邻回归,再开始下一批。 +- 不得先批量搬迁或重写,再依靠后续批次恢复功能;任何中间批次都不能故意处于已知损坏状态。 +- 生成入口文件可随对应源文件在同一批更新,但机械生成内容与手写行为代码必须分开说明。 -无法读取强制规范,或无法确认工作树是否含有他人修改时,必须停止写入并报告阻塞原因。 +### 1.3 功能边界与防回归 -## 2. 权威规则与不可削弱要求 +- 修改前必须搜索功能 A 的全部调用方、导入方、事件订阅、DOM ID/Class、CSS 选择器、DTO、持久化字段和测试,列出依赖其实现或契约的功能 B。 +- 修复 A 时不得破坏 B 对公共行为的合理依赖;若公共实现必须变化,应先建立兼容边界或同步修改并验证所有消费者。 +- 局部问题不得通过修改全局刷新、渲染、调度、持久化或错误处理语义来解决。 +- 状态只能有一个权威所有者;重渲染、重新绑定、切页、弹窗开关和异步刷新不得意外重置或复制状态。 +- **历史高频回归**:拖动条/滑块在刷新、重渲染或修改其他设置后复位。涉及控件初始化、配置渲染、事件绑定或 View 拆分时,必须验证当前值保持、重复初始化不重置、事件不重复绑定,并回归依赖同一渲染链路的其他控件。 +- Bug 修复必须提供修复前可失败、修复后可通过的复现证据;不能只证明目标路径“现在能跑”。 -### 2.1 权威顺序 +## 2. 接入与事实来源 -冲突按以下顺序处理: +开始写入前必须: -1. `package.json`、`tsconfig.json`、`.editorconfig`、`.gitattributes` 等可执行配置; -2. `docs/engineering-standards.md`; -3. `docs/architecture/` 当前架构文档; -4. `CONTRIBUTING.md`; -5. `docs/teaching/` 历史教学。 +1. 执行 `git status --short --branch`,识别当前分支和用户已有修改;不得覆盖、回退或整理非当前任务内容。 +2. 首次进入项目或开始新的非平凡实现前,按 `docs/architecture/README.md` 的路由读取 `00-overview.md`、`12-agent-entry-guide.md`、`10-runtime-boundaries-adr.md` 和任务对应专题。后续局部任务不要求重复通读无关专题。 +3. 按任务读取 `package.json`、`tsconfig.json`、构建脚本、CI、受影响源码、直接调用方和最近的专项测试。 +4. 说明行为目标、非目标、可复用实现、最少修改文件、状态所有者、功能消费者、风险和验证计划。 +5. 搜索同领域规则、历史 workaround 和兼容契约;第三次修复或重复回归必须先分析此前失败原因,不能继续叠加 guard、retry、delay 或 fallback。 -本文件和工具入口文件只负责加载规则,不能覆盖上述来源。工程规范中的 `MUST/MUST NOT` 是合并门槛,`SHOULD/SHOULD NOT` 默认必须遵守,偏离须在 PR 说明理由。修改强制规范须保留完整语义;删除、降级或豁免须经维护者书面批准。 +事实与约束分开判断: -### 2.2 必须完整遵守 +- 用户明确要求最高;本文件是 Coding、质量和协作流程的唯一规范入口。 +- 当前 GUI 的目录、依赖、命令和运行行为以可执行配置、CI、测试和生产源码为准;不得用主库旧基线反向改造当前结构。 +- `docs/architecture/**` 描述当前架构,`10-runtime-boundaries-adr.md` 定义应保持的设计边界;发现文档与实现冲突时先报告并判断是实现偏离还是文档过期,不得静默选择一方。 +- 本文件 4.1 节是基于主库 1.4.4 路线的有效并行协作契约,不是当前 GUI 的目录或完成状态清单。 +- 架构、公共契约或协作职责发生变化时,同一批更新对应架构/工程文档;仅定位文件时可使用 `09-src-typescript-catalog.md`,不得把数量快照当稳定边界。 -必须执行 `docs/engineering-standards.md` 全部章节,尤其是:文档权威顺序和技术债务、正确性证据、单一状态所有者、根因修复、最小充分实现与反堆积门禁、可回滚行为单元、MVC + ViewObject 边界、公共数据契约和迁移、用户目录/原子写入/文件 IPC 安全、Python 环境与进程树、Patch 止损及 L0-L4、第三次修复门槛、干净基线重写、AI Agent 协议、PR/提交/规模审查、构建和确定性验证、临时 containment 例外、合并审查清单。 +### 2.1 修复止损 -不可弱化的核心检查: +- 声称修复后问题仍可复现、验证失败、需要绕过上次错误假设,或保留旧 workaround 后再增加特殊处理,均算一次失败尝试;纯诊断日志和不改变行为的 instrumentation 不计入。 -- 每份可变数据只有一个权威所有者;不得用标志、影子集合或重复缓存掩盖所有权。 -- 数据流为 `Model -> Controller -> ViewObject -> View`,用户意图由 View 返回 Controller。 -- View 不得直接调用 Model、ApiClient、文件 IPC 或持久化;Controller 不得成为第二个 Model 或实现 parser、路径安全、环境发现;Model 不得依赖具体 View、DOM、Electron 或 Node 文件系统。 -- ViewObject、领域模型、API DTO、IPC DTO 必须区分;不得用 `any` 或类型断言逃避契约。 -- `electron/main.ts` 只负责生命周期、IPC 注册、模块初始化和依赖注入。 -- GUI 只能依赖后端公开、版本化接口;跨仓结构必须有契约测试或固定 fixture。 -- 已发布格式、目录、模板 ID、任务索引和默认行为属于兼容契约;安装资源只读,用户数据写入系统用户目录,原子替换失败须保留旧文件。 -- renderer 不得通过通用 IPC 操作任意绝对路径;路径必须 canonicalize 并检查 containment。 -- Bug 修复须有修复前失败、修复后通过的可复现证据;已失败 workaround 必须删除,不得外围叠加 guard、retry、delay 或 fallback。 -- 达到止损条件必须暂停;Agent 不得自行批准 L2 以上工作继续实施。 +| 等级 | 触发条件 | 必须执行的动作 | +|---|---|---| +| L0 正常变更 | 无失败尝试、无意外扩散、有直接证据 | 正常实现和审查 | +| L1 记录修正 | 一次失败,或出现一个止损信号 | 记录原假设、失败证据、状态所有者和新验证计划 | +| L2 维护者检查点 | 两次连续失败,或同时出现两个止损信号 | 暂停实现;用户批准重新设计、拆分或干净重写后才能继续 | +| L3 Patch Freeze | 三次失败且仍有止损信号,或出现竞争状态源 | 禁止叠加补丁,先建立确定性复现和替代设计 | +| L4 干净重写 | 无法删除失败 workaround、恢复单一状态源或证明端到端行为 | 从最后已知正常基线建立隔离分支/worktree,先固定行为契约再重新实现 | -### 2.3 最低验证 +- 止损信号包括范围外跨层补偿、新可写状态/同步标志/影子缓存、规则重复、retry/delay/catch-and-ignore/多级 fallback、放宽类型或测试,以及无法解释完整因果链。 +- 同一问题的失败次数跨 Agent、会话、分支和实现方案累计,不能通过换人、换文件或改名重置。 +- 第三次尝试前必须说明前两次为什么失败、原因链如何变化、新证据如何区分假设;没有新因果模型不得继续补 guard、retry、delay 或 fallback。 +- 修改中出现新状态源、跨层补偿、公共契约变化或范围升级时,立即重新评估 Patch Level,并按更高等级执行。 +- L4 不得复制失败分支或覆盖用户工作树;必须记录正常基线 SHA/Tag,仅迁移仍被当前契约和测试证明需要的行为,并保留旧失败补丁供只读对照。 -- TypeScript/SCSS 变更至少执行 `npm run build`。 -- 打包、安装资源或发布流程变更应执行 `npm run pack`;无法执行须说明原因。 -- 行为变更还须有确定性验证,不能只依赖 build、lint、截图或一次手工运行。 -- 已有专项测试必须按行为风险选择;涉及 Electron、端口、`userData`、临时目录、进程或共享缓存时默认串行执行,除非已证明资源隔离。 -- Bug 测试须证明旧实现失败、新实现通过。 -- 架构、目录、命令、数据格式、后端契约或发布流程变更须同步文档。 +## 3. 当前源码边界 -## 3. 架构文档路由 - -任务开始至少读取受影响领域文档;跨领域任务读取全部相关文档。实现与文档不一致时,修正实现或在同一变更更新文档,不得无说明地二选一。 - -| 影响范围 | 必读文档 | +| 边界 | 当前职责与入口 | |---|---| -| 分层、目录、启动、Types | `docs/architecture/00-overview.md` | -| Controller、Host、依赖注入、启动 | `docs/architecture/01-controller-layer.md` | -| Scheduler、TaskQueue、定时任务、修理 | `docs/architecture/02-task-scheduling.md` | -| 配置、主题、设置持久化 | `docs/architecture/03-configuration.md` | -| 计划、地图、节点、舰队预设 | `docs/architecture/04-battle-plan.md` | -| 模板、任务组、队列加载 | `docs/architecture/05-template-and-taskgroup.md` | -| IPC、HTTP、WebSocket、后端契约 | `docs/architecture/06-backend-communication.md` | -| Python、模拟器、后端、更新生命周期 | `docs/architecture/07-environment-management.md` | -| 环境、构建、打包、SCSS | `docs/architecture/08-dev-setup.md` | - -`docs/teaching/` 只用于理解历史动机;不得把历史文件数、行数或快照当作当前事实,也不得覆盖强制规范或架构文档。修改应优先在现有职责边界内形成最小行为闭环;不得混入无关重构、格式化、生成文件或资源。跨 Model、Controller、Types、View 的自然功能可形成垂直切片,但必须保持依赖方向;历史违规只处理与当前目标和风险相称的范围,并在 PR 披露剩余债务。 - -## 4. 风格、工具与执行记录 - -- 使用 UTF-8、LF、文件末尾换行;TS/SCSS/JSON/Markdown/YAML 2 空格,Python 4 空格。 -- TypeScript 使用 strict;新代码不得借 `noImplicitAny: false` 引入隐式 `any`。类文件 PascalCase,工具/类型文件 camelCase;样式用 SCSS,遵循现有 BEM 和目录结构。 -- 分支使用 `feat/`、`fix/`、`chore/`、`docs/` 等语义前缀;Commit 使用 Conventional Commits,一个 commit 一个逻辑变更。 -- 不得手工修改构建生成物,除非仓库明确要求提交;lockfile 变更须由明确依赖变更产生。大型资源、fixture、图片和机械生成内容须与手写行为代码分别统计说明。 -- 不得提交密钥、Token、用户配置、日志、运行时数据或本地环境文件;不得关闭 SSL、路径、类型、Schema、测试或权限校验来绕过问题。 -- 证据不足时区分事实与推测;Issue 分析须给出实现位置、行为链路、最小修复和验证步骤。数据迁移、文件 IPC、更新器、发布或跨仓契约须优先验证失败和回滚路径。 - -### 4.1 最小充分实现原则 - -在不降低正确性、安全性、可维护性、类型约束和验证完整性的前提下,Agent 必须用满足目标所需的最少代码完成任务: - -- 能通过修改现有代码实现的,不得新增重复代码、包装层、状态源或辅助模块。 -- 优先合理复用职责、输入输出、生命周期和副作用均匹配的现有模块、函数、类型、组件、测试工具和数据契约;不得为表面复用强行扩大原模块职责。 -- 遵循最小拆分原则:仅在职责独立、生命周期不同、复用价值明确或现有模块违反职责边界时拆分;不得为减少单文件行数、追求形式分层或制造抽象而拆分。 -- 不得为实现小功能顺手重构无关代码、引入无必要抽象、重复已有规则或堆积防御性分支。 -- 新增的每个函数、类型、模块、状态源、依赖和测试都必须能说明必要性;无法说明必要性的代码不得加入。 -- 实现前必须搜索现有实现和调用链,并明确可直接复用的代码、只能复用思路的代码、必须新增的主要符号及不能采用更小修改范围的原因。 -- 当最小实现与架构边界、公共契约、安全要求或可验证性冲突时,优先满足后者,并在交付记录中说明增加代码的原因。 - -### 4.2 Agent 编码门禁 - -Agent 必须按以下顺序工作,不能跳过“最小方案”直接堆积实现: - -1. **先搜索再设计**:搜索现有调用链、同领域规则、类型、适配器、组件、测试和失败 workaround。 -2. **先写最小方案**:明确唯一行为目标、非目标、可复用代码、最少修改文件、计划新增符号及其必要性;默认方案是修改现有实现。 -3. **先做最小闭环**:只实现能证明行为的最小路径,不先搭目录、接口、Facade、Factory、Manager、Registry、EventBus、兼容层或未来扩展点。 -4. **逐项批准新增物**:每新增函数、类型、文件、依赖、状态源、缓存或错误分支,都必须说明当前调用方/测试证据、不能复用的原因和不能采用更小修改的原因。 -5. **发现堆积立即停**:出现无独立职责的 wrapper/helper、无调用方的预留代码、形式拆分、重复转换/校验/状态、或为未来扩展增加的代码时,停止继续写入并回到最小方案。 -6. **交付前删减**:检查是否能删除新增代码而不影响目标;能删除的必须删除。新增文件必须有真实调用方、独立职责或独立行为契约。 - -以下理由不得用于增加代码:“以后可能扩展”“方便复用”“先搭起来再整理”“这样更规范”“文件太长”“以后可能会用到”。 - -修改前记录目标、非目标、工作树/隔离方式、已读规范、状态所有者/数据流、止损等级和验证计划。修改中说明是在替代旧尝试、删除 workaround 还是新增行为;出现新状态源、跨层补偿或范围扩散立即重新评估,不得用“先跑起来”保留无法解释的 fallback。交付前检查 diff 仅含任务文件,执行强制构建和风险匹配的确定性验证,更新文档,并记录命令、结果、未验证路径、失败尝试、状态源变化、回滚方式和工程规范第 12 节审查结果。 - -## 5. Git、发布与专用规则 - -### 5.1 工作区安全 - -- 不得覆盖、回退、删除或暂存非当前任务修改;有无关修改时用独立 branch/worktree 隔离。 -- 未经用户明确要求,不得 `git reset --hard`、`git checkout -- `、强制清理或历史重写;不得 `git push --force`、`--no-verify` 或其他绕过保护参数。 -- 不得 `git add .`;按逻辑变更显式暂存。推送前检查待推送 commit 范围和工作树状态。 - -### 5.2 发布 - -只有用户明确要求发布时,才能改版本、创建 Tag 或触发发布。发布前须确认功能修改已独立提交并推送且工作树干净;Release commit 只含版本/发布元数据;Tag、`package.json` 版本和产物版本一致;不得修改、删除或覆盖已有远程 Tag。 - -### 5.3 专用入口边界 - -- `.github/agents/code-length-audit.agent.md` 只在代码长度审计中生效,不是所有 PR 的行数门槛。 -- `.github/skills/commit-and-release/SKILL.md` 仅在提交、推送、发布任务中生效。 -- `.claude/skills/generic-issue-log-analysis/SKILL.md` 仅在 Issue/日志分析中生效。 -- `.github/workflows/` 定义实际自动化;修改 workflow 前说明权限、Secret、触发条件和发布影响。 - -### 5.4 ShiinaKuroko Fork - -- GUI:`C:\ShiinaKuroko\01.Project\AutoWSGR-GUI`,远端 `https://github.com/ShiinaKuroko/AutoWSGR-GUI.git`;后端:`C:\ShiinaKuroko\01.Project\AutoWSGR`,远端 `https://github.com/ShiinaKuroko/AutoWSGR.git`;两者发布分支均为 `ShiinaKuroko`。 -- GitHub fetch/pull/push 前读取 `HKCU:\Software\Microsoft\Windows\CurrentVersion\Internet Settings` 的 `ProxyServer`。当前代理为 `127.0.0.1:7897`;端口监听时优先临时使用 `git -c http.proxy=http://127.0.0.1:7897 -c https.proxy=http://127.0.0.1:7897 ...`,不得改全局 Git 配置;未启用时可直连,并按实际错误区分网络、认证和远端冲突。 -- `main` 只同步 `OpenWSGR/AutoWSGR-GUI:main`,不得在其直接开发、提交或推送功能。`ShiinaKuroko` 是唯一允许 `git push origin ShiinaKuroko` 的本地发布入口;独立分支/worktree 只编码、测试、审查,完成后合并、cherry-pick 或 rebase 到本地发布分支。不得推送 `origin `,除非维护者明确授权。 -- 每次更新发布分支前创建 `backup/YYYYMMDD-`:更新前稳定提交和更新后新版本各备份,最多保留两个;备份不得移动、覆盖或追加,只删除最旧备份,不删除当前/上一版本备份。 -- 推送前确认当前为本地 `ShiinaKuroko`,检查 `git status --short --branch`、`git diff --check`、`git log --oneline -5`、`git log origin/ShiinaKuroko..ShiinaKuroko`;先备份,再以 `git push --force-with-lease` 更新,禁止无条件 `--force`。删除远程分支前列出分支、提交和原因;不得删除 `main`、`ShiinaKuroko` 或未授权分支。 - -## 6. GUI 2.0 合并边界 - -### 6.1 合并前负责范围 - -目标是让 GUI 2.0 现有功能安全、兼容、可合并,不实现自动强化。优先修复: - -- 主进程文件 IPC 路径 containment,禁止穿越和受管目录外访问;AtomicFileStore 失败保留旧文件。 -- 外部 Python 安装目录与启动目录统一。 -- 兼容 v1.4.1 和当前稳定版计划格式,不要求手工重写;完成旧 path-form 任务组迁移,失败保留原数据并给出明确错误。 -- 设置 YAML round-trip 保留未知字段,尤其未知嵌套字段。 -- candidate-only:无顶层 `name` 时所有 `candidates` 平等,不得将第一个提升为 primary。 - -必须保持:舰队方案与出征计划分离;编辑态计划可引用独立舰队 YAML;运行前由 `RuntimePlanService` 展开为后端可执行 YAML;系统计划与用户计划读写边界明确;`SchedulerRepairPolicy`、`SchedulerTaskPolicy`、`RepairManager`、`TaskQueue.switchTaskPreset()` 职责清晰;编队轮换只用统一 Scheduler,不得新增并行状态机。 - -旧目录中的活动计划不得靠删除以下文件并放置相似文件来宣称迁移完成: - -- `resource/builtin_plans/活动20260730-E1炸鱼.yaml` -- `resource/builtin_plans/活动20260730-E5夜战.yaml` -- `resource/builtin_plans/活动20260730-H1炸鱼.yaml` -- `resource/builtin_plans/活动20260730-H5夜战.yaml` - -合并前须验证旧计划可读取、执行或显式迁移;新旧行为等价;用户修改不会被静默覆盖;迁移失败保留原文件并显示可理解错误。 - -### 6.2 合并验收 - -须通过 `npm run build` 和 `git diff --check`,并覆盖:legacy plan 读取/迁移、candidate-only 舰队槽位、设置未知字段保留、原子写入失败恢复、文件 IPC 越界拒绝、旧任务组迁移、四个活动计划兼容性、舰队引用展开后的运行时 YAML 合法性。未接 UI 的执行入口必须接通、删除死入口或明确标记暂不支持,不得保留看似可用的无调用链路。提交应尽量按 Electron 服务、舰队 YAML、运行时计划、Scheduler 规则、UI、舰船资源拆分,至少可分别审查。 - -### 6.3 自动强化边界 - -合并前不要求自动强化业务、后端 API/设备 lease、`intensify` 任务类型、保护舰集合、与轮换/泡澡/战斗优先级、设置 UI/最终接线、日志/重试/状态展示,或后端唯一舰船 ID(未提供时可先用舰名)。但 Scheduler task type、API 请求和设置 schema 必须预留扩展边界,不能封闭设计。 - -自动强化由另一协作方负责:后端 API;全局 device lease(不得与战斗、泡澡、解装并发);统一 Scheduler 正式任务(不得独立定时器);强化前重新读取并保护当前编队、启用轮换预设、泡澡舰、收藏/锁定舰、强化目标舰和用户名单;素材筛选/上限/失败/重试;接入 `ApiClient`、API/ Scheduler types、`ConfigModel`、`ConfigController`、`SchedulerBinder`;真实坐标/OCR 未完成前 fail closed,不点击或消耗舰船;GUI 2.0 合并后再接最终 Scheduler 和配置架构,避免双方修改同一状态机。 - -## 7. Windows 打包约束 - -### 7.1 winCodeSign - -无 Windows 开发者模式或符号链接权限时,Electron Builder 解压 `winCodeSign-2.6.0.7z` 会因 `darwin/10.12/lib/libcrypto.dylib`、`libssl.dylib` 创建失败而阻断,即使文件属于 macOS 也不能忽略。打包前检查开发者模式或当前终端权限;确认缺失后不得重复相同命令/下载。 - -正式解决方式是启用开发者模式或使用有权限终端执行 `npm run dist`;不得把本地工具路径覆盖当正式解决。启用后用 Electron Builder 自带 `7za.exe` 和 `winCodeSign-2.6.0.7z` 做一次带 `-snld` 的解压探针,确认两个 `.dylib` 符号链接可创建后直接执行原始 `npm run dist`。PowerShell 5 的 `New-Item -ItemType SymbolicLink` 可能误报,不作为唯一前置检查。 - -不得用 `--config.win.signAndEditExecutable=false` 生成正式包;`ELECTRON_BUILDER_RCEDIT_PATH` 单独设置,或与 `SIGNTOOL_PATH` 同时设置,均不足以完成 NSIS 打包,不得重复尝试。失败后不得以重试、延时或运行时文件写入 fallback 掩盖问题,须先区分工具权限错误和应用持久化错误。 - -### 7.2 单 EXE 交付 - -用户要求“只有 EXE”时目标是 NSIS 安装程序而非 `--dir`:关闭所有 `release/win-unpacked` GUI;确认 `package.json` 与 `package-lock.json` 版本一致;用完整 `winCodeSign` 权限执行 `npm run dist`;从 `release/` 单独取出 `AutoWSGR-GUI-Setup-.exe`,不得混入 `win-unpacked`、更新清单或 blockmap;检查版本、哈希、签名并实际启动安装后的 GUI。安装包、`release/` 和本地 Electron Builder 缓存不得提交。 - -## 8. 维护 - -- 强制规范正文只维护于 `docs/engineering-standards.md`;架构变化先更新架构文档,再更新本文件路由/摘要。 -- 工具入口只应指向本文件,不得复制整套规则。新增规则须标明属于强制、约束或高风险宽泛规则。 -- 本文件与权威来源冲突时按第 2.1 节处理,并在同一变更修复冲突。 +| Electron 组合根 | `electron/main.ts`:单实例、生命周期、服务装配、IPC 注册和退出协调 | +| Electron 服务 | `electron/services/**`:Service 编排用例,Repository 管理路径/来源/文件,Codec 管理格式和兼容转换 | +| IPC 边界 | `electron/ipc/**` + `electron/preload.ts`:注册受限通道并向 Renderer 暴露白名单 | +| Renderer 组合根 | `src/controller/app/AppController.ts`:构造 Model、Adapter、Controller、View 并绑定生命周期 | +| Adapter | `src/adapter/**`:HTTP、WebSocket、IPC、序列化和浏览器存储等外部能力 | +| Model | `src/model/**`:领域状态、规则和数据转换,不依赖 DOM、具体 View 或 Electron | +| Controller | `src/controller/**`:业务编排、用户意图、Model/View 协调,不直接操作 DOM 或底层 IPC | +| View | `src/view/**`:DOM、组件和局部 UI 状态,不直接调用 Model、ApiClient 或 Electron Bridge | +| Types / Shared | `src/types/**` 定义 DTO/契约;`src/shared/**` 放置跨运行时纯逻辑,不依赖 DOM、Electron 或 Node 专属能力 | + +依赖和实现规则: + +- 外部通信必须经 Adapter;Renderer 不得绕过 Adapter 直接调用 `window.electronBridge`。 +- Controller 可以协调 Model、Adapter 和 View,但不得复制领域状态、解析文件格式或实现路径安全。 +- View 只能消费 ViewObject、Types、Shared 和 View 内组件。当前特例仅是 `src/view/theme.ts` 通过 `StorageAdapter` 保存纯 UI 偏好,不得扩展为通用持久化入口。 +- ViewObject、领域模型、API DTO 和 IPC DTO 必须区分;不得用 `any`、双重类型断言或可选字段堆积掩盖契约。 +- `electron/main.ts` 新增代码应限于装配和生命周期;可独立测试的行为放入 `electron/services/**`,传输适配放入 `electron/ipc/**`。 +- GUI 只能依赖后端公开接口;前后端一起变化时必须检查 `src/model/ApiClient.ts`、Adapter、DTO 和跨仓契约测试。 +- View 创建的事件监听器、Observer、Timer、`requestAnimationFrame`、订阅和缓存必须有唯一所有者、幂等初始化和幂等 `dispose()`,并接入 `AppController.dispose()` 清理链。 +- 拆分复杂 View 时保持原 Facade 公共 API;共享视觉组件至少需要两个真实消费者,并完整负责自身绑定、更新和释放。 +- 新建抽象必须有当前真实调用方,并能减少实质重复或隔离明确边界;不得为未来可能需求预留空接口、Manager、Factory、Registry 或 EventBus。 + +## 4. 高风险不变量 + +- 安装资源只读;用户配置、计划、迁移状态和运行数据写入 Electron `userData`。 +- 文件入口必须经 canonicalize 和 containment 检查;Renderer 不得通过通用 IPC 读写任意绝对路径。 +- 用户数据使用原子写入,替换失败必须保留旧文件;迁移只能在全部文件成功写入后记录阶段完成。 +- 已发布格式、目录、模板 ID、任务索引、配置默认值和未知字段属于兼容契约;不得静默丢弃或覆盖。 +- 单实例锁必须早于迁移、后端启动和窗口初始化;退出、更新和强制关闭必须停止后端、ADB 和相关进程树。 +- Scheduler 是定时、轮换和任务执行的唯一调度所有者;不得增加并行定时器或第二套任务状态机。 +- 强化、解装、购买等不可逆或消耗资源的操作默认关闭,必须由用户明确确认;目标、状态或确认不确定时 fail closed。 +- 不得泄露或提交密钥、Token、用户配置、含隐私日志、运行时数据或本地环境文件。 + +### 4.1 主库 1.4.4 并行协作边界 + +- 当前 GUI 的实际结构和现有业务行为优先;配合主库开发者时复用当前 Model、Adapter、Controller、View、Service、Repository 和 Codec 边界,不建立与当前架构竞争的第二套实现。 +- 当前路线负责让 GUI 2.0 已有功能安全、兼容、可合并。自动强化的后端 API、device lease、正式 Scheduler 任务、业务规则和最终接线由主库开发者负责;未经用户重新分配,不得越界实现或修改其业务语义。 +- 修改双方共享的 Scheduler task type、API 请求、设置 Schema、`ApiClient`、`ConfigModel`、`ConfigController`、`SchedulerBinder`、Fleet/Plan Types 前,必须说明所有权、双方调用方、合并影响和验证方式并取得用户确认。 +- 必须保持舰队方案与出征计划分离、运行前由 `RuntimePlanService` 展开、系统/用户数据边界清晰、candidate-only 平等候选、未知 YAML 字段保留,以及单一 Scheduler 所有权。 +- 仅允许为已经确认的主库协作功能保留最小扩展余量。例如自动强化可在现有 task type、API 和设置 Schema 上保留兼容入口,但不得提前建立空框架、独立定时器、第二状态机或未接线实现。 +- 协作兼容清单中的 `resource/builtin_plans/活动20260730-*.yaml` 是旧安装输入路径,不是当前仓库应恢复的目录;通过现有迁移映射、兼容资源和专项测试验证,不得重新复制一套来源。 +- 第 13 节协作契约在用户明确宣布合并完成或职责调整前持续有效;主库开发者提交的改动也必须通过当前 GUI 的四大原则、架构门禁和非回归验证。 + +## 5. 源文件与生成文件 + +- HTML 源位于 `src/view/html/**`,由 `scripts/build-view-html.js` 生成并校验已提交的 `src/view/index.html`。 +- SCSS 源位于 `src/view/styles/**/*.scss`,构建生成并提交 `src/view/styles/styles.css`。 +- TypeScript 由 `tsc` 编译到 `dist/**`,Renderer 由 `scripts/bundle.js` 生成 `dist/renderer.bundle.js`;`dist/**` 和 `release/**` 不提交。 +- 不得手工修改 `src/view/index.html`、`src/view/styles/styles.css` 或 `dist/**`。修改 HTML/SCSS/TypeScript 源后运行 `npm run build`,提交需要跟踪的生成入口。 +- lockfile 只能随明确依赖变更更新;大型资源、fixture、图片和机械生成内容必须与手写行为代码分开说明。 + +## 6. 风格与实现门禁 + +- 使用 UTF-8、LF 和文件末尾换行;TS/SCSS/JSON/Markdown/YAML 2 空格,Python 4 空格。 +- TypeScript 保持 strict;新代码不得引入隐式 `any` 或用断言绕过边界。类文件 PascalCase,工具/类型文件 camelCase,样式遵循现有 SCSS/BEM 结构。 +- 能修改现有实现时不得新增重复模块、包装层、状态源或兼容分支;每个新增文件、函数、类型、依赖和缓存都必须说明必要性。 +- 除 4.1 节已确认的跨分支协作契约外,不得以“以后可能扩展”“先搭起来”“文件太长”作为增加抽象或拆分文件的理由。 +- 注释解释设计原因、限制和兼容背景,不复述代码;临时 workaround 必须说明触发条件、移除条件和对应测试。 +- 防御式代码只放在真实外部边界或已验证失败路径;不得用宽泛 `try/catch`、静默降级和多层 fallback 掩盖状态所有权或契约错误。 +- 为测试新增注入点时优先使用构造参数或显式依赖,不得把调试开关、测试状态或仅测试使用的 API 暴露到生产路径。 +- 发现范围扩大、新状态源、跨层补偿或依赖功能回归时立即停止,重新说明范围并取得用户确认。 +- 不得关闭 SSL、路径、类型、Schema、测试、签名或权限校验来绕过问题。 + +## 7. 验证路由 + +所有行为修改都需要确定性验证;build、截图或一次手工运行不能替代专项测试。 + +| 改动范围 | 最低验证 | +|---|---| +| TypeScript / SCSS / HTML | `npm run test:build` | +| HTML、DOM ID、View 拆分 | `npm run test:renderer-contract` | +| Controller、View、共享边界 | `npm run test:architecture-boundaries` | +| Electron Service / IPC | `npm run test:main-services`、`npm run test:main-ipc` | +| Scheduler / Fleet / 地图 | `npm run test:scheduler-domain`、`npm run test:fleet-domain`、`npm run check:fleet-types`、`npm run check:maps` | +| 配置 / 迁移 / 活动资源 | `npm run test:settings`、`npm run test:migrations`、`npm run test:event-resources` | +| Python / 后端分发 / API | 选择 `npm run test:python-environment`、`npm run test:backend-distribution`、`npm run test:api-contract` | +| 打包 / 安装 / 发布 | `npm run dist`、`npm run test:release-package`,并实际启动安装后的 GUI | + +- 测试命令以当前 `package.json` 为准;已有专项测试按风险选择,不得只运行最容易通过的测试。 +- 涉及 Electron、端口、`userData`、临时目录、进程或共享缓存的测试默认串行执行。 +- 修改 GUI 交互时必须回归目标功能、共享组件消费者、重复初始化和状态保持;涉及游戏执行链路还须通过模拟器验证。无法验证时明确列出未验证路径。 +- 交付前执行 `git diff --check`,确认 diff 只含本批任务文件,并记录命令、结果、失败尝试和剩余风险。 + +## 8. Git 与发布 + +### 8.1 工作区和提交 + +- 不得覆盖、回退、删除、暂存或格式化用户已有修改;发现无关修改时忽略,只有确实妨碍任务时才请求处理方式。 +- 未经明确要求,不得执行 `git reset --hard`、`git checkout -- `、强制清理、历史重写、`--no-verify` 或无条件 `git push --force`。 +- 不得 `git add .`;按逻辑变更显式暂存。分支使用语义前缀,Commit 使用 Conventional Commits,一个 commit 对应一个可独立审查的逻辑变更。 + +### 8.2 ShiinaKuroko Fork + +- 路径以仓库为基准:GUI 为当前仓库根目录,后端默认是同级 `../AutoWSGR`;不得固化某台机器的绝对路径。 +- 当前仓库是个人 Fork。`main` 只作为同步主库代码的中转分支,不直接开发功能;同步上游后再更新个人 Fork 的 `origin/main`。 +- `ShiinaKuroko` 是主力开发分支,代表个人 Fork 的最新有效代码。功能实现、正式自动化测试和必要生成入口必须先合入该分支并完成验证,再由该分支执行普通 push。 +- 本地工作分支可按任务自由创建。临时实验、一次性验证和调试代码不得合入;与功能对应、需要长期保留的自动化测试属于有效改动,应与实现一起合入 `ShiinaKuroko`。 +- 临时 PR 分支是唯一允许额外推送的功能分支,格式为 `feat/<功能>-PR` 或 `fix/<功能>-PR`;Git 分支名不得使用反斜杠。创建下一条 PR 前必须检查上一条临时 PR 的状态,确认代码已经合入目标分支后,删除对应的本地和远端 PR 分支;未合入或状态不明确时不得删除。 +- GitHub 网络操作前读取系统当前代理设置;只允许命令级临时代理,不得硬编码端口或修改全局 Git 配置。 +- 每次 push `ShiinaKuroko` 前必须先 `git fetch origin`。本地落后或出现分叉时,先审查并整合远端提交,不得用强推覆盖远端代码。 +- push 前以当前 `origin/ShiinaKuroko` 为指针创建远端不可移动备份 `backup/YYYYMMDD-`。日期精确到日,短 SHA 用于区分同一天的多次 push;必须先确认远端备份存在且提交正确,再 push 新代码。 +- 远端 backup 分支最多保留两个。创建第三个备份并验证成功后,按名称日期和提交时间删除最旧的一个;不得移动、复用或覆盖已有 backup 分支。 +- 普通 push 禁止 `--force` 和 `--force-with-lease`。只有用户明确授权历史改写、已说明将被替换的远端提交且备份完成时,才允许使用 `--force-with-lease`。 +- push 完成后检查其他本地工作分支:只有确认有效提交已进入 `ShiinaKuroko`、没有独有未推送提交、没有未提交修改且未被 worktree 使用时才能删除。不得批量强删;保留 `main`、`ShiinaKuroko` 和仍未合入的活动 PR 分支。 + +### 8.3 Alpha 与正式发布 + +- `alpha` 是预发布版本/更新频道,不是质量豁免分支;四大原则、测试门禁、数据安全和兼容要求全部生效。 +- 只有用户明确要求时,才能改版本、创建 Tag、触发发布或上传产物。 +- Release commit 只含版本和发布元数据;Tag、`package.json`、`package-lock.json` 和产物版本必须一致,不得覆盖已有远程 Tag。 +- 发布前必须完成构建、专项测试、安装包验证和实际启动;无法完成时不得宣称发布可用。 + +## 9. Windows 打包 + +- 正式 Windows 产物使用 `npm run dist` 生成 NSIS 安装程序;`npm run pack` 的 unpacked 目录不是“单 EXE”交付物。 +- 不得通过关闭 `signAndEditExecutable`、签名、权限或产物校验来绕过打包问题。 +- `winCodeSign` 解压或符号链接失败时先区分权限、缓存和工具链问题;不得重复相同命令或用运行时 fallback 掩盖。 +- 单 EXE 交付只取 `AutoWSGR-GUI-Setup-.exe`,检查版本、哈希并实际安装启动;`release/**` 和 Electron Builder 缓存不得提交。 + +## 10. 专用入口与维护 + +- `.github/agents/code-length-audit.agent.md` 只用于代码长度审计;`.github/skills/commit-and-release/SKILL.md` 只用于提交发布;`.claude/skills/generic-issue-log-analysis/SKILL.md` 只用于 Issue/日志分析。 +- `.github/workflows/**` 定义实际自动化;修改前必须说明权限、Secret、触发条件和发布影响。 +- 本文件维护长期工程门禁和主库 1.4.4 协作契约,`docs/architecture/**` 维护当前 GUI 结构与 ADR;发现两者与实现不一致时按第 2 节处理。 +- 工具入口只指向本文件,不复制整套规则。阶段性迁移清单、历史事故和已结束的单次分工放入对应任务文档,不写入长期 Agent 约束。 +- 修改本文件前必须先审查当前代码和门禁;新增、删除或降低规则前,先向用户列明具体动作、原因和影响并取得确认。 diff --git a/CLAUDE.md b/CLAUDE.md index 137540f..467ac5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,4 +2,4 @@ 在分析、修改文件或执行命令前,必须完整读取并遵守根目录 `AGENTS.md`。 -`AGENTS.md` 要求继续完整读取 `docs/engineering-standards.md`、可执行配置、`CONTRIBUTING.md` 和任务相关架构文档。不得用本入口文件或任何摘要替代这些原始规则。 +`AGENTS.md` 是唯一 Coding 规范入口;继续按其中路由读取可执行配置、`CONTRIBUTING.md` 和任务相关架构文档。不得用本入口文件或其他摘要替代其规则。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b54981c..2f2db50 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,7 @@ docs/ → 架构文档 & 教程 ## 代码风格与规范 -完整、强制性的工程规范见 [docs/engineering-standards.md](docs/engineering-standards.md)。其中包括架构边界、状态所有权、数据迁移、Electron 安全、验证要求、AI Agent 规则,以及连续修补失败时的 Patch 止损和干净基线重写流程。本文仅保留快速上手摘要;发生冲突时以工程规范和可执行配置为准。 +完整、强制性的工程规范见 [AGENTS.md](AGENTS.md)。其中包括架构边界、状态所有权、数据迁移、Electron 安全、验证要求、AI Agent 规则,以及连续修补失败时的 Patch 止损和干净基线重写流程。本文仅保留快速上手摘要;发生冲突时按 `AGENTS.md` 的事实来源规则处理。 ### 通用规范 @@ -111,4 +111,4 @@ docs: 更新架构文档 - 一个 commit 对应一个逻辑变更,避免把不相关的修改混在一起 - 如果一个功能涉及多层修改(model + controller + view),可以放在同一个 commit 中 -- 同一问题连续修补失败或开始引入额外状态源、跨层补偿时,必须按照[工程规范的 Patch 止损机制](docs/engineering-standards.md#6-patch-止损机制)暂停并升级审查,不得继续叠加 workaround +- 同一问题连续修补失败或开始引入额外状态源、跨层补偿时,必须按照 [AGENTS.md 的修复止损机制](AGENTS.md#21-修复止损)暂停并升级审查,不得继续叠加 workaround diff --git a/README.md b/README.md index 408aa01..8971b65 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,7 @@ GUI 保存的 YAML 必须通过当前前端校验,并最终符合 AutoWSGR 后 ## 文档 +- [用户使用指南(含节点索敌规则说明)](docs/user-guide.md) - [战斗方案 YAML 说明](docs/plan-guide.md) - [架构文档索引](docs/architecture/README.md) - [开发环境搭建](docs/architecture/08-dev-setup.md) @@ -267,7 +268,7 @@ GUI 保存的 YAML 必须通过当前前端校验,并最终符合 AutoWSGR 后 ## 贡献 -欢迎参与开发!请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 和强制性的[工程与代码规范](docs/engineering-standards.md)。 +欢迎参与开发!请先阅读 [CONTRIBUTING.md](CONTRIBUTING.md) 和强制性的 [AGENTS.md](AGENTS.md)。 ## 许可证 diff --git a/docs/TODO.md b/docs/TODO.md index 3d4ec00..078fe15 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -85,7 +85,7 @@ 说明:已支持自动演习、舰队编号配置、刷新窗口触发与离线补发。 - [x] 自动战役调度(空闲时,调度打哪个) - 说明:已支持自动战役、战役类型选择与次数配置。 + 说明:已支持自动战役与战役类型选择,每日固定完成 8 次结算。 #### 模拟器设置 @@ -203,8 +203,8 @@ - [x] 自动演习:支持 0:00 / 12:00 / 18:00 刷新窗口检测,按时段自动触发 说明:已支持错过时段后的重启补发。 -- [x] 自动战役:每天自动触发,可配置次数 - 说明:已接入配置页和调度器。 +- [x] 自动战役:每天自动触发,固定完成 8 次结算 + 说明:已接入配置页和调度器;历史次数字段仅保留持久化兼容。 - [x] YAML scheduled_time 字段:方案支持定时启动(HH:MM) 说明:当前已有基础定时启动支持。 @@ -215,8 +215,8 @@ - [x] 离线演习补发:localStorage 缓存关闭时间,重启后检测错过窗口并自动补发 说明:已完成。 -- [x] 配置页 UI:演习舰队选择(1-4)、战役次数输入 - 说明:配置项已提供可视化编辑入口。 +- [x] 配置页 UI:演习舰队选择(1-4)、战役类型选择 + 说明:配置项已提供可视化编辑入口,战役结算次数固定为每日 8 次。 - [x] 日常自动化 UI 分块:远征 / 战役 / 演习三段分隔 说明:界面已具备较清晰的分区布局。 @@ -423,7 +423,7 @@ 说明:当前已支持自动演习和舰队配置。 - [x] 对用户展示:启用自动战役(选择类型) - 说明:当前已支持自动战役开关、类型与次数。 + 说明:当前已支持自动战役开关和类型选择,每日固定完成 8 次结算。 - [ ] 对用户展示:启用自动日常(选择做哪些任务) 说明:前端尚无自动日常配置区。 diff --git a/docs/architecture/00-overview.md b/docs/architecture/00-overview.md index a66a05c..f705d10 100644 --- a/docs/architecture/00-overview.md +++ b/docs/architecture/00-overview.md @@ -1,346 +1,199 @@ -# AutoWSGR-GUI 总架构文档 +# AutoWSGR-GUI 总架构 -## 项目简介 +## 项目定位 -AutoWSGR-GUI 是一个基于 **Electron** 的桌面应用,为 [AutoWSGR](https://github.com/huan-yp/Auto-WSGR)(战舰少女R 自动化框架)提供图形化操作界面。 +AutoWSGR-GUI 是 AutoWSGR 的 Windows Electron 桌面前端。它负责配置、方案、 +编队、任务队列、环境安装和运行状态展示;实际游戏自动化由 Python AutoWSGR +后端执行。 -- **前端**:TypeScript,经典 MVC 架构,esbuild 打包 -- **后端**:Python FastAPI + uvicorn,由 Electron 主进程作为子进程管理 -- **通信**:Electron IPC(主进程 ↔ 渲染进程)+ HTTP/WebSocket(渲染进程 ↔ Python 后端) +当前技术栈: ---- +- Electron 33、Node.js 22、TypeScript 5.6 +- 原生 HTML、DOM API、SCSS +- esbuild Renderer Bundle +- Python 3.12/3.13、FastAPI/ASGI、Uvicorn +- Electron IPC、HTTP REST、WebSocket +- YAML、JSON、electron-builder、NSIS -## 整体分层架构 +## 运行时全景 ```mermaid -graph TB - subgraph Renderer["渲染进程 (src/)"] - View["View 层
MainView(Facade) · PlanPreviewView(Facade)
ConfigView · TaskGroupView
TemplateLibraryView · SetupWizardView"] - Controller["Controller 层
AppController · StartupController
PlanController · FleetPlannerController
TaskGroupController · TemplateController
── 各功能最小 Host 接口 ──"] - Model["Model 层
Scheduler · CronScheduler · TaskQueue
ApiClient · ConfigModel · PlanModel
TemplateModel · TaskGroupModel · DailySortieStats
RepairManager · StopConditionChecker"] +flowchart TB + subgraph Renderer["Electron Renderer"] + View["View
DOM、事件、动画"] + Controller["Controller
用例编排"] + Model["Model
领域状态与规则"] + Adapter["Adapter
IPC/HTTP/WS/存储"] + Shared["Shared
跨层纯逻辑"] + View -->|"用户意图"| Controller + Controller -->|"ViewObject"| View + Controller --> Model + Model --> Adapter + Controller --> Adapter + Controller --> Shared + Model --> Shared end - subgraph Main["Electron 主进程 (electron/)"] - Root["组合根
main.ts"] - IPC["IPC Adapter
ipc/*.ts · preload.ts"] - Service["用例与领域服务
services/*.ts"] - PyEnv["Python 环境管理
pythonEnv/"] - Backend["后端进程管理
services/BackendService.ts"] - Emulator["模拟器检测
emulatorDetect.ts"] - end - - subgraph PythonBackend["Python 后端"] - ASGI["uvicorn + FastAPI
autowsgr.server.main"] - end - - View -->|"用户操作回调"| Controller - Controller -->|"ViewObject 单向传递"| View - Controller -->|"调用 / 订阅"| Model - Model -->|"HTTP / WebSocket"| ASGI - Controller -->|"contextBridge (preload.ts)"| IPC - Root --> IPC - Root --> Service - IPC --> Service - Service --> PyEnv - Service --> Backend - IPC --> Emulator - Backend -->|"spawn 子进程"| ASGI + Adapter -->|"window.electronBridge"| Preload["electron/preload.ts"] + Preload -->|"ipcRenderer"| Main["Electron Main
electron/main.ts"] + Main --> IPC["electron/ipc"] + IPC --> Service["electron/services"] + Service --> FS["文件系统 / 更新 / ADB / Python"] + Service --> Backend["AutoWSGR Python 后端"] + Adapter -->|"HTTP + WebSocket"| Backend ``` -### 分层职责 - -| 层 | 位置 | 职责 | -|----|------|------| -| **View** | `src/view/` | 纯 UI 渲染,接收 ViewObject 显示;不含业务逻辑。大型视图采用 Facade 模式内部拆分 | -| **Controller** | `src/controller/` | 从 Model 提取数据 → 拼装 ViewObject → 调用 View 渲染;处理用户事件 → 调用 Model / Adapter。各功能声明自己的最小 Host | -| **Model** | `src/model/` | 业务实体 + 领域服务:调度、配置、方案、Fleet 规则、后端通信 | -| **Adapter** | `src/adapter/` | YAML/JSON、IPC、HTTP/WS 和浏览器存储边界 | -| **Types** | `src/types/` | API、IPC、Model、View、Scheduler 和 Statistics 六个领域类型文件 | -| **主进程** | `electron/` | `main.ts` 负责装配;`ipc/` 保持通道契约;`services/` 承担窗口、配置、计划、环境和后端用例 | -| **Python 后端** | 外部 | 游戏自动化核心逻辑:模拟器连接、战斗执行、OCR 识别 | +通信有两条独立链路: ---- +- Renderer 到 Electron Main:文件、对话框、环境、计划仓储、更新等系统能力。 +- Renderer 到 Python:任务执行、游戏上下文、实时日志和任务状态。 -## 目录结构 +## 分层职责 -``` -AutoWSGR-GUI/ -├── electron/ # Electron 主进程 -│ ├── main.ts # 组合根:依赖装配、IPC 注册、迁移与生命周期 -│ ├── preload.ts # contextBridge 安全 API 暴露 -│ ├── emulatorDetect.ts # 模拟器注册表检测 -│ ├── ipc/ # IPC Adapter,不实现领域规则 -│ │ ├── FileIpc.ts # 文件、路径和系统对话框 -│ │ ├── DeviceIpc.ts # 模拟器与 ADB -│ │ ├── ConfigurationIpc.ts # 同步 getter 与配置 setter -│ │ ├── TeamPlanIpc.ts # 编队计划 -│ │ ├── CombatPlanIpc.ts # 作战计划 -│ │ ├── DailyPlanIpc.ts # 演习、战役和决战日常计划 -│ │ ├── ShipLibraryIpc.ts # 舰船资料库 -│ │ ├── EnvironmentIpc.ts # Python 环境 -│ │ ├── BackendIpc.ts # 后端进程 -│ │ ├── UpdaterIpc.ts # GUI 自动更新 -│ │ └── IpcRegistrar.ts # 最小注册接口 -│ ├── services/ # 用例、领域和基础设施服务 -│ │ ├── AppPaths.ts · SafePathService.ts · SecureFileService.ts -│ │ ├── AtomicFileStore.ts · GuiSettingsStore.ts -│ │ ├── WindowService.ts · SingleInstanceService.ts -│ │ ├── MigrationStateStore.ts · UserDataMigrationService.ts -│ │ ├── TeamPlanCodec.ts · TeamPlanRepository.ts · TeamPlanService.ts -│ │ ├── CombatPlanCodec.ts · CombatPlanRepository.ts -│ │ ├── RuntimePlanService.ts · PlanManagementService.ts -│ │ ├── DailyPlanService.ts -│ │ ├── ShipLibraryService.ts · ShipLibraryUpdater.ts -│ │ ├── AdbService.ts · CudaEnvironmentService.ts -│ │ ├── GuiConfigurationService.ts · GuiSettingsCommitService.ts -│ │ ├── PythonEnvironmentService.ts -│ │ ├── LegacyPlanMigration.ts -│ │ ├── BackendService.ts · BackendShutdownService.ts -│ │ └── GuiUpdatePolicy.ts # 更新版本和频道策略 -│ └── pythonEnv/ # Python 环境管理子模块 -│ ├── backendRequirement.ts # 支持的后端版本范围 -│ ├── backendContractProbe.ts # 外部后端能力探测 -│ ├── context.ts # 共享上下文与缓存状态 -│ ├── dependencies.ts # Python 依赖声明 -│ ├── finder.ts # Python 可执行文件发现 -│ ├── environment.ts # 统一安装、检查和启动环境 -│ ├── cuda.ts # CUDA 路径与环境变量 -│ ├── envCheck.ts # 环境验证主流程 -│ ├── installer.ts # Python 安装与依赖管理 -│ ├── updater.ts # autowsgr 自动更新 -│ ├── utils.ts # 工具函数与共享接口 -│ └── index.ts # 聚合导出 -├── src/ # 渲染进程 (MVC) -│ ├── controller/ # 控制器(6 个子目录) -│ │ ├── app/ # 应用控制器:AppController · SchedulerBinder · SchedulerRuntimeTracker · ScheduledTaskLoader -│ │ ├── startup/ # 启动流程:StartupController · connection · envAndUpdates -│ │ ├── plan/ # 方案控制器:PlanController · BattlePlanLoaderController · FleetPlannerController · DecisivePlanController -│ │ ├── taskGroup/ # 任务组:TaskGroupController · DailyTaskLoaderController · queueLoader -│ │ ├── template/ # 模板:TemplateController · crud · selectors · useTemplate · wizard -│ │ └── shared/ # 共享 UI 边界:DialogHelper -│ ├── adapter/ # YAML/JSON · IPC · HTTP/WS · Storage -│ ├── model/ # 数据模型 + 业务服务 -│ │ ├── fleet/ # FleetDraft · DecisiveFleetDraft · FleetPresetIdentity · FleetRuleMapper -│ │ ├── scheduler/ # 调度子模块:Scheduler · CronScheduler · TaskQueue · ExpeditionTimer · StopConditionChecker · RepairManager -│ │ ├── statistics/ # 今日出征统计 -│ │ ├── ApiClient.ts # HTTP/WebSocket 后端通信 -│ │ ├── ConfigModel.ts # 配置数据模型 -│ │ ├── PlanModel.ts # 方案解析/序列化 -│ │ ├── TemplateModel.ts # 模板管理 -│ │ ├── TaskGroupModel.ts # 任务组持久化 -│ │ └── MapDataLoader.ts # 地图数据加载与缓存 -│ ├── view/ # UI 视图(8 个子目录) -│ │ ├── main/ # 主页面 Facade:MainView · LogView · TaskQueueView · StatusBar -│ │ ├── plan/ # 方案与舰队:PlanPreviewView · FleetPlannerView · FleetEditorView · FleetGalleryView · PlanManagementView -│ │ ├── config/ # 配置页:ConfigView -│ │ ├── taskGroup/ # 任务组:TaskGroupView -│ │ ├── template/ # 模板:TemplateLibraryView · TemplateWizardView · SelectorDialog -│ │ ├── setup/ # 初始化向导:SetupWizardView -│ │ ├── shared/ # 共享组件:ShipAutocomplete · AnimatedSelect -│ │ └── styles/ # SCSS 样式(base/ · components/ · pages/) -│ ├── types/ # 6 个按完整领域合并的类型文件 -│ │ ├── api.ts # API / WebSocket DTO -│ │ ├── ipc.ts # IPC DTO、ElectronBridge 和 Window 声明 -│ │ ├── model.ts # Plan、Config、Template、Repair 领域类型 -│ │ ├── view.ts # 页面 ViewObject 和表单值 -│ │ ├── scheduler.ts # 调度器公共类型 -│ │ └── statistics.ts # 今日出征统计类型 -│ ├── shared/ # 决战、任务预设、胖次和舰种等跨层稳定契约 -│ ├── data/ # 舰船静态 JSON;运行时规则位于 model/fleet -│ └── utils/ # 工具类(Logger) -├── resource/ # 只读资源 -│ ├── system_battle_plans/ # 系统作战计划 (.yaml) -│ ├── system_team_plans/ # 系统编队计划 (.yaml) -│ ├── system_daily_plans/ # 演习、战役和决战日常计划 -│ ├── user_battle_plans/ # 兼容旧目录,运行时不写入 -│ ├── user_team_plans/ # 兼容旧目录,运行时不写入 -│ ├── migrations/v6/ # 已下架系统计划的只读迁移快照 -│ ├── ship-library/ # 打包舰船资料库种子 -│ ├── builtin_templates.json # 内置模板 -│ ├── maps/ # 地图 JSON(节点坐标、连线) -│ └── images/ # 图片资源 -├── templates/ # 用户自定义模板(历史兼容来源) -├── scripts/ # 构建脚本 -├── tools/ # 独立开发者工具;仅明确白名单文件进入安装包 -├── build/ # electron-builder 配置 -├── usersettings.yaml # 历史兼容来源,运行时写入 userData -├── gui_settings.json # 历史兼容来源,运行时写入 userData -├── task_groups.json # 历史兼容来源,运行时写入 userData -└── package.json # 项目配置 -``` +| 层 | 目录 | 责任 | +|---|---|---| +| Renderer 入口 | `src/controller/app/AppController.ts` | 创建 Renderer 对象并连接生命周期 | +| Controller | `src/controller/` | 编排 Model、View、Adapter,不拥有 DOM | +| View | `src/view/` | DOM、浏览器事件、局部视觉状态和资源释放 | +| Model | `src/model/` | 配置、方案、舰队、调度、模板和任务组领域状态 | +| Adapter | `src/adapter/` | 裁剪 ElectronBridge,封装 HTTP、WS、YAML、JSON、Storage | +| Shared | `src/shared/` | Renderer 和 Main 可复用的无状态规则 | +| Types | `src/types/` | API、IPC、Model、Scheduler、ViewObject 契约 | +| Preload | `electron/preload.ts` | 唯一 `window.electronBridge` 暴露点 | +| Main IPC | `electron/ipc/` | 校验输入、保持通道契约、调用 Service | +| Main Service | `electron/services/` | 文件、配置、计划、迁移、更新和进程业务 | +| Python 环境 | `electron/pythonEnv/` | 解释器、依赖、后端来源和 CUDA 环境 | +| Main 组合根 | `electron/main.ts` | 装配依赖、注册 IPC、编排主进程生命周期 | -用户可变计划、舰队、日常计划、设置和迁移状态写入 Electron `userData`: -`user_battle_plans/`、`user_team_plans/`、`user_daily_plans/`、`gui_settings.json` 和 -`.migration-state.json`。迁移账本由 `MigrationStateStore` 独占读写。安装目录和 -`resource/` 只读;v5 迁移会合并当前 -安装目录的旧设置、任务组和模板,递归识别有效计划 YAML,并按新规范重命名 -后纳入 GUI 管理。不同内容的同名配置保存为“(旧版)”副本,源文件始终保留。 -v6 继续升级系统预设库存、旧系统计划引用和胖次稳定计划标识,v7 再分类迁移 -旧计划。每一阶段只有全部成功才写入独立完成键并推进版本;失败项在下次启动 -重试。本次存在实际迁移项时,主窗口创建后会显示成功、失败数量和失败文件说明。 - -### 主进程依赖方向 +标准 Renderer 数据流: ```text -main.ts - ↓ -ipc/*Ipc.ts - ↓ -Service - ↓ -Codec / Repository - ↓ -AppPaths / SafePathService / AtomicFileStore +Repository / Model -> Controller -> ViewObject -> View +View -> 用户意图 -> Controller ``` -`main.ts` 不包含 IPC 业务分支,并在迁移旧配置前通过 -`SingleInstanceService` 获取 Electron 单实例锁。重复启动的进程立即退出并通知 -主实例恢复、显示和聚焦已有窗口,不会再次执行配置迁移、环境检查或 pip 安装。 -可变状态有唯一所有者:后端子进程在 `BackendService`,资料库更新互斥在 -`ShipLibraryUpdater`,运行时计划序号在 `RuntimePlanService`,Python 发现缓存 -在 `pythonEnv/context.ts`。配置服务每次读取唯一的 `gui_settings.json`,不建立 -第二份内存配置。 - ---- +## 关键入口 -## 启动流程 - -```mermaid -sequenceDiagram - participant Startup as StartupController - participant App as AppController - participant Bridge as ElectronBridge (IPC) - participant Main as 主进程 (main.ts) - participant Py as Python 后端 - - Startup->>Bridge: getAppRoot() / getPlansDir() - Startup->>App: loadConfigAndSync() - App->>Bridge: readFile('usersettings.yaml') - App->>App: ConfigModel.loadFromYaml() - - Startup->>App: detectAndApplyEmulator() - Bridge->>Main: 注册表查询模拟器 - Main-->>App: 模拟器信息 - - Note over Startup: 首次运行显示引导向导 - - Startup->>App: loadModelsAndRender() - App->>App: 加载模板 + 任务组 + renderMain() - - Startup->>Bridge: checkEnvironment() - Bridge->>Main: findPython() + 依赖检查 - Main-->>Startup: {pythonCmd, allReady} - - alt 环境未就绪 - Startup->>Bridge: installPortablePython() / installDeps() - end +| 场景 | 入口 | +|---|---| +| Electron 启动 | `electron/main.ts` | +| 安全桥接 | `electron/preload.ts` | +| Renderer Bundle 入口 | `src/controller/app/AppController.ts` | +| Renderer 应用装配 | `src/controller/app/AppController.ts` 中的 `AppController` | +| Python 后端进程 | `electron/services/BackendService.ts` | +| 后端正式运行契约 | `electron/services/BackendRuntimeContract.ts` | +| IPC 类型总契约 | `src/types/ipc.ts` | +| HTTP/WS 客户端 | `src/model/ApiClient.ts` | +| 页面静态源 | `src/view/html/index.html` | +| 样式入口 | `src/view/styles/main.scss` | - Startup->>Bridge: startBackend() - Bridge->>Main: spawn Python 子进程 - Main->>Py: uvicorn.run() - Py-->>Main: HTTP 服务就绪 +## 主要目录 - Startup->>App: waitForBackendAndConnect() - App->>Py: POST /api/system/start - Py-->>App: 连接成功 - - Startup->>App: cronScheduler.start() - Note over App: 应用就绪,等待用户操作 +```text +AutoWSGR-GUI/ +├─ electron/ +│ ├─ main.ts # Main 组合根和生命周期 +│ ├─ preload.ts # contextBridge +│ ├─ ipc/ # IPC 边界 +│ ├─ services/ # 主进程用例与持久化 +│ └─ pythonEnv/ # Python/AutoWSGR 环境 +├─ src/ +│ ├─ adapter/ +│ ├─ controller/ +│ ├─ model/ +│ ├─ shared/ +│ ├─ types/ +│ ├─ view/ +│ │ ├─ html/ # HTML 开发源 +│ │ ├─ styles/ # SCSS 开发源及生成 CSS +│ │ └─ index.html # 生成的运行入口 +│ └─ data/ +├─ resource/ # 打包只读资源 +├─ scripts/ +│ ├─ tests/ # 构建、领域、服务和契约测试 +│ ├─ build-view-html.js +│ └─ bundle.js +├─ build/ # electron-builder、NSIS、后端清单 +└─ .github/workflows/ # PR 与发布流水线 ``` ---- +## 状态与持久化 -## 关键架构模式 +| 数据 | 权威位置 | +|---|---| +| AutoWSGR 业务配置 | `userData/usersettings.yaml` | +| GUI、窗口、Python、CUDA、自动化 | `userData/gui_settings.json` | +| 用户作战计划 | `userData/user_battle_plans/` | +| 用户编队计划 | `userData/user_team_plans/` | +| 用户日常计划 | `userData/user_daily_plans/` | +| 用户模板 | `userData/templates/templates.json` | +| 任务组 | `userData/task_groups.json` | +| 舰船资料库工作副本 | `userData/ship-library/` | +| 迁移账本 | `userData/.migration-state.json` | +| Cron、额度、轻量 UI 偏好 | Renderer `localStorage` | +| 系统方案、地图、内置模板 | `resource/`,只读 | +| 执行前展开方案 | 系统 temp 下的进程专属目录 | -### 最小 Host 依赖注入 +安装目录中的同名配置只作为旧版本迁移来源,不能成为新的运行时写入目标。 -子控制器不直接依赖 `AppController`,而是在所属功能模块中声明最小 Host,例如: +## 主进程启动顺序 -```typescript -interface PlanHost { - readonly scheduler: Scheduler; - plansDir: string; - renderMain(): void; - switchPage(page: string): void; -} +```text +获取单实例锁 + -> app.whenReady() + -> 处理待安装 GUI 更新 + -> 询问并迁移旧安装数据 + -> 初始化 Python 环境上下文 + -> 初始化后端上下文 + -> 初始化用户方案目录和舰船资料库 + -> 执行预设库存 v6 与旧计划 v7 迁移 + -> 写迁移报告、准备冲突复核 + -> 注册更新 IPC + -> 创建主窗口 ``` -`PlanHost`、`StartupHost`、`TaskGroupHost` 等接口彼此独立,不再存在通用 -`ControllerHost` 基接口。详见 [Controller 层](01-controller-layer.md)。 - -### ViewObject 单向数据传递 +IPC 的大部分注册在 `whenReady` 前完成,但更新 IPC 依赖启动处理结果,在迁移后 +注册。次实例只唤醒主窗口,不执行迁移、环境检查或 pip。 -Controller 从 Model 提取数据,拼装为 **ViewObject**(定义在 -`src/types/view.ts`),单向传递给 View 渲染。View 不访问有状态 Model;类型、 -不可变目录和无状态领域函数可以直接复用,但不能成为第二个状态所有者。 +Renderer 启动由 `StartupController` 编排: +```text +同步路径和配置 + -> 检测模拟器、必要时显示引导 + -> 加载模板/任务组/方案状态并渲染 + -> 检查 Python 与依赖 + -> 检查 GUI 更新 + -> 启动后端 + -> 等待 /api/health + -> POST /api/system/start + -> 启动 Cron、心跳和任务调度 ``` -Model → Controller.extractViewObject() → ViewObject → View.render(vo) -``` - -### View Facade 模式 - -大型视图组件采用 Facade 模式:`MainView` 组合日志、队列和状态栏; -`PlanPreviewView` 组合地图和节点编辑;`FleetPlannerView` 组合舰队编辑、规则、 -图鉴、计划管理和方案选择。普通舰队草稿只由 `FleetPlannerController` 的单个 -`FleetDraft` 持有,决战草稿由 `DecisivePlanController` 的 -`DecisiveFleetDraft` 独立持有。舰船库读取、编队计划 DTO 转换、保存覆盖和 -`file/source` identity 都在 Controller/Model;Fleet View 只接收 ViewObject 和 -不透明计划 ID,并上报用户意图。 -### 优先级任务队列 +## 退出与释放 -`Scheduler` 实现三级优先级队列,保证远征收取不会被用户任务阻塞: +Renderer `beforeunload` 会释放 `SchedulerBinder`、普通舰队和决战页持有的长生命 +周期资源,再保存任务组和刷新日志。 -| 优先级 | 值 | 说明 | -|--------|---|------| -| `EXPEDITION` | 0 | 最高优先级:远征收取 | -| `USER_TASK` | 10 | 用户手动添加的战斗任务 | -| `DAILY` | 20 | 定时触发的日常任务 | +Main `before-quit` 顺序固定: -每个队列轮次使用独立 `id`,同一有限或无限任务的后触发轮次共享稳定 -`logicalId`。单轮完成、逻辑任务完成和逻辑取消是三个不同事件,cron/pending -状态只在逻辑任务结束或明确取消时清理。 - -### 本地 Python 隔离 - -managed 模式和内置 Python 使用 `{appRoot}/python/site-packages/`;external -模式使用用户选定解释器时,依赖安装到该解释器自身环境。安装、依赖检查、 -CUDA 检测和后端启动复用同一环境描述。通过 `.env_ready` 标记缓存已验证环境。 - -### 双层通信 - -- **IPC 层**:渲染进程 ↔ 主进程,用于文件 I/O、环境管理、系统对话框 -- **HTTP/WS 层**:渲染进程 ↔ Python 后端,用于游戏操作和实时日志 - ---- - -## Types 层组织 - -类型定义从各模块提取为独立的 `src/types/` 层,按领域划分: +```text +记录并保存窗口位置 + -> POST /api/system/stop + -> 终止并等待后端进程树 + -> 停止 GUI 内置 ADB server + -> 确认资源释放后 app.quit() +``` -| 路径 | 内容 | 被谁引用 | -|------|------|----------| -| `api.ts` | API 响应、TaskRequest、WebSocket 消息类型 | ApiClient、Controller | -| `ipc.ts` | IPC DTO、`ElectronBridge` 与全局 Window 声明 | Adapter、Controller | -| `model.ts` | Plan、Config、Template、Repair 领域类型 | Model、Controller | -| `scheduler.ts` | 调度器:TaskPriority、SchedulerTask、SchedulerCallbacks | Scheduler、SchedulerBinder | -| `statistics.ts` | 今日出征次数、评级和掉落提示 | DailySortieStats、SchedulerBinder、View | -| `view.ts` | Main、Plan、Config、Template、TaskGroup ViewObject | Controller → View | +无法确认后端退出时应用保持运行并报错,不能假装关闭成功。 ---- +## 不可绕过的边界 -## 子模块文档导航 +1. `electron/main.ts` 是组合根,不放 YAML、路径或业务规则。 +2. View 不访问 Model、ApiClient、Adapter、ElectronBridge 或持久化。 +3. Controller 不访问 DOM、浏览器存储和 ElectronBridge。 +4. IPC 只做边界工作,文件与业务策略进入 Service/Repository/Codec。 +5. 用户可变数据只写 `userData`,系统资源只读。 +6. `src/view/index.html`、`styles.css` 和 `dist/**` 都是生成物。 +7. 修改公共契约时同步检查 `src/types/`、preload、IPC、Adapter 和契约测试。 -| 文档 | 功能域 | -|------|--------| -| [Controller 层](01-controller-layer.md) | 最小 Host 接口 · 6 个子目录结构 · StartupController 启动编排 | -| [任务调度系统](02-task-scheduling.md) | Scheduler · TaskQueue · CronScheduler · ExpeditionTimer · StopCondition · RepairManager | -| [配置系统](03-configuration.md) | ConfigModel · ConfigView · usersettings.yaml · gui_settings.json | -| [出击计划系统](04-battle-plan.md) | PlanModel · PlanController · PlanPreviewView(Facade) · MapDataLoader | -| [模板与任务组](05-template-and-taskgroup.md) | TemplateModel · TaskGroupModel · 创建向导 · 队列加载 | -| [后端通信](06-backend-communication.md) | IPC Bridge · ApiClient · REST API · WebSocket 事件 | -| [环境管理](07-environment-management.md) | Python 发现/安装 (pythonEnv/) · 模拟器检测 · 后端生命周期 | -| [开发环境搭建](08-dev-setup.md) | 依赖安装 · 开发/构建/打包命令 · SCSS 架构 · 调试技巧 | -| [`src` TypeScript 模块目录](09-src-typescript-catalog.md) | 当前 `src` 模块和逐文件职责;数量以该文档为准 | -| [当前运行时边界 ADR](10-runtime-boundaries-adr.md) | 存储 · IPC 权限 · 迁移 · 调度身份 · 更新安装决策 | +下一步按 [AGENT 进入指南](12-agent-entry-guide.md) 定位修改范围。 diff --git a/docs/architecture/01-controller-layer.md b/docs/architecture/01-controller-layer.md index 11b5cf8..2f8fce5 100644 --- a/docs/architecture/01-controller-layer.md +++ b/docs/architecture/01-controller-layer.md @@ -1,256 +1,188 @@ # Controller 层 -> 涉及文件:`src/controller/` 的 6 个业务子目录 +> 主要目录:`src/controller/` -## 概述 +## 定位 -Controller 层采用 **最小 Host 接口依赖注入** 模式组织。`AppController` 是唯一 -Renderer 组合根,创建子控制器时传入只包含所需能力的对象。 +Controller 是 Renderer 的用例编排层。它读取 Model,通过 Adapter 调用外部能力, +把数据转换成 ViewObject 交给 View,并接收 View 上报的用户意图。 -每个子控制器内部进一步按职责拆分为多个模块文件,主控制器类保持精简("瘦身版"),核心逻辑委托给同目录下的模块。 +Controller 不负责: ---- +- DOM 查询、浏览器事件、动画和元素类型。 +- 直接读取 `window.electronBridge`。 +- 直接读写 `localStorage`。 +- 在 Controller 内重新实现 Model 的业务规则。 +- 把有状态 Model 或 Repository 暴露给 View。 -## 最小 Host 接口 +这些边界由 `scripts/tests/test-renderer-architecture.js` 静态检查。 -```typescript -// src/controller/contracts.ts -interface PlanHost { - readonly scheduler: Scheduler; - plansDir: string; - renderMain(): void; - switchPage(page: string): void; -} -``` - -`StartupHost`、`PlanHost`、`TaskGroupHost` 集中定义在 -`src/controller/contracts.ts`,流程模块只依赖这些最小能力,不反向导入主 -Controller。接口彼此独立,不继承公共基接口;Controller 内部使用明确模块 -路径,不通过 barrel 获取依赖。 - ---- - -## 子目录结构 - -### controller/contracts.ts — 编排契约 - -集中定义 Controller 流程模块使用的最小 Host 接口,避免 -`queueLoader → TaskGroupController`、`presetFlow → PlanController` 等类型 -反向依赖。对话框属于 View 能力,位于 `src/view/shared/DialogHelper.ts`。 - ---- +## 组合根 -### controller/app/ — 主控制器 +`src/controller/app/AppController.ts` 是 Renderer 唯一组合根,负责创建: -顶层协调器,创建并持有所有子控制器实例,实现各 Host 接口。 +- `ApiClient`、配置和领域 Model。 +- `MainView`、`ConfigView`、方案与任务组 View。 +- `PlanController`、`FleetPlannerController`、`DecisivePlanController`。 +- `TaskGroupController`、`TemplateController`、`StartupController`。 +- `Scheduler`、`CronScheduler`、`SchedulerBinder` 及每日额度对象。 -| 文件 | 职责 | -|------|------| -| `AppController.ts` | 根控制器类:初始化子控制器、实现 Host 接口、协调全局状态 | -| `AutomaticDecisiveTask.ts` | 分别从用户决战计划和系统预设构造自动决战请求 | -| `CurrentFleetController.ts` | 读取舰船库并把当前任务舰队转换为主页面 ViewObject | -| `ConfigController.ts` | 配置保存逻辑:从表单收集 → 更新 ConfigModel → 同步 CronScheduler/Scheduler → 写文件 | -| `SettingsController.ts` | 设置页操作:环境与 ADB 检测、舰船库、更新检查和主题交互 | -| `NavigationController.ts` | 页面和方案标签导航 | -| `OperationsController.ts` | 远征收取和奖励领取等常用操作 | -| `SchedulerBinder.ts` | 绑定 Scheduler/CronScheduler 回调,按稳定 `logicalId` 管理等待任务并维护今日出征统计 | -| `rendering.ts` | 渲染分发:构建 `MainViewObject` → 调用 `MainView.render()` | -| `constants.ts` | 常量定义 | - -导航、队列按钮、快捷操作、主题和浏览器生命周期事件由 `src/view` 持有。 -Controller 只接收 View 上报的用户意图并编排用例。 - -**SchedulerBinder Host 接口**: +业务子 Controller 不能反向获取整个 `AppController`。所需能力通过 +`src/controller/contracts.ts` 或功能目录内的最小 Host 接口注入。 ```typescript -interface SchedulerBinderHost { +interface PlanHost { readonly scheduler: Scheduler; - readonly cronScheduler: CronScheduler; - readonly api: ApiClient; - readonly templateModel: TemplateModel; - readonly configModel: ConfigModel; + plansDir: string; renderMain(): void; - updateOpsAvailability(connected: boolean): void; + switchPage(page: string): void; } ``` ---- - -### controller/startup/ — 启动流程 - -从 AppController 独立出来的启动编排控制器。 +Host 表达能力,不表达具体实现。不要为了省参数创建万能 Host 或让子 Controller +依赖另一个具体 Controller。 -| 文件 | 职责 | -|------|------| -| `StartupController.ts` | 启动流程主编排,并持有后端心跳和自动重启定时器 | -| `envAndUpdates.ts` | 环境检查与更新:调用 IPC `checkEnvironment()` / `installDeps()` / `checkForUpdates()` | -| `connection.ts` | 后端连接:`waitForBackendAndConnect()` 轮询等待后端 HTTP 就绪,然后发送系统启动请求 | +## 目录职责 -**StartupHost 接口**(由 AppController 实现): - -```typescript -interface StartupHost { - readonly scheduler: Scheduler; - readonly cronScheduler: CronScheduler; - readonly configModel: ConfigModel; - appRoot: string; - plansDir: string; - configDir: string; - - syncPaths(appRoot: string, plansDir: string, configDir: string): void; - initLogger(gateway: StartupGateway): void; - loadConfigAndSync(): Promise; - detectAndApplyEmulator(): Promise; - showSetupWizard(): Promise; - loadModelsAndRender(gateway: StartupGateway): Promise; - reviewMigrationConflicts(): Promise; - bindBackendLog(gateway: StartupGateway): void; - renderMain(): void; - startHeartbeat(): void; -} +```text +src/controller/ +├─ app/ # Renderer 顶层流程、设置、导航、调度绑定 +├─ migration/ # 迁移冲突复核流程 +├─ plan/ # 作战方案、编队、决战和方案管理 +├─ startup/ # 环境检查、后端连接和启动编排 +├─ taskGroup/ # 任务组、日常任务选择和队列加载 +├─ template/ # 模板兼容链路与向导 +└─ contracts.ts # 跨流程最小 Host 契约 ``` -**启动时序**: - -```mermaid -flowchart TD - A["StartupController.run()"] --> B["获取目录路径"] - B --> C["loadConfigAndSync()"] - C --> D["detectAndApplyEmulator()"] - D --> E{"首次运行?"} - E -->|是| F["showSetupWizard()"] - E -->|否| G["loadModelsAndRender()"] - F --> G - G --> H["bindBackendLog()"] - H --> I["checkAndPrepareEnv()"] - I --> J{"环境就绪?"} - J -->|否| K["安装 Python / 依赖"] - K --> I - J -->|是| L["checkForUpdates()"] - L --> M["startBackend()"] - M --> N["waitForBackendAndConnect()"] - N --> O["cronScheduler.start()"] - O --> P["startHeartbeat()"] +### `controller/app` + +| 文件 | 责任 | +|---|---| +| `AppController.ts` | Renderer 对象装配、全局 Host 实现和卸载清理 | +| `StartupController` 的 Host 方法 | 同步路径、配置、模型和后端状态 | +| `ConfigController.ts` | 配置候选值、事务提交和调度同步 | +| `SettingsController.ts` | Python/CUDA/ADB、资料库、更新和主题操作 | +| `SchedulerBinder.ts` | 连接 Scheduler、Cron、日志和主页状态 | +| `SchedulerRuntimeTracker.ts` | 从日志派生进度、掉落和运行状态 | +| `ScheduledTaskLoader.ts` | 把自动化配置转换为 SchedulerTask | +| `AutomaticDecisiveTask.ts` | 用户决战计划与系统预设两种来源 | +| `CurrentFleetController.ts` | 当前任务舰队的 ViewObject | +| `NavigationController.ts` | 页面和标签导航能力 | +| `OperationsController.ts` | 远征、奖励等快捷操作 | +| `rendering.ts` | 主页面 ViewObject 构造 | + +`AppController.onBeforeUnload` 是 Renderer 生命周期终点,当前必须调用: + +```text +SchedulerBinder.dispose() +FleetPlannerController.dispose() +DecisivePlanController.dispose() +TaskGroupModel.save() +Logger.flush() ``` ---- - -### controller/plan/ — 方案控制器 +新增监听器、Observer 或长生命周期资源时,必须沿所有权链补齐 `dispose()`。 -管理受管方案的新建、加载、编辑、保存和预览渲染。 +### `controller/startup` -| 文件 | 职责 | -|------|------| -| `PlanController.ts` | 方案子控制器类:持有当前方案状态,协调下属模块 | -| `BattlePlanLoaderController.ts` | 独立持有受管方案选择器状态,加载、筛选并返回最终选择 | -| `DecisivePlanController.ts` | 持有决战舰队草稿,协调设置读取、编辑和保存 | -| `FleetPlannerController.ts` | 持有普通舰队草稿,协调舰船库、计划持久化、覆盖冲突和文件 identity | -| `PlanFleetPresetController.ts` | 维护出征方案引用的舰队预设清单和只读 ViewObject | -| `PlanManagementController.ts` | 持有计划管理目录状态,编排导出、删除、重命名和打开操作 | -| `fleetViewObjects.ts` | 把舰队持久化对象映射为只读展示对象 | -| `planManagementViewObjects.ts` | 推导计划关联、缺失引用、任务组引用和删除影响 | -| `presetFlow.ts` | 任务预设的导入/查看/关闭/执行流程 | -| `nodeEditor.ts` | 节点编辑器:从 UI 收集节点阵型/夜战/索敌规则并写回 PlanData | -| `rendering.ts` | 构建 `PlanPreviewViewObject`,协调地图数据和方案数据的合并 | -| `selectedNodes.ts` | 规范化节点选择顺序并生成后端需要的节点列表 | +`StartupController.ts` 只编排启动流程,具体步骤拆到: -**PlanHost 接口**: +- `envAndUpdates.ts`:环境准备和 GUI 更新检查。 +- `connection.ts`:等待后端健康、调用系统启动和 WebSocket 连接。 -```typescript -interface PlanHost { - readonly scheduler: Scheduler; - plansDir: string; - renderMain(): void; - switchPage(page: string): void; -} +```mermaid +flowchart LR + A["读取路径/配置"] --> B["检测模拟器/引导"] + B --> C["加载模型并渲染"] + C --> D["环境检查与安装"] + D --> E["检查更新"] + E --> F["启动后端"] + F --> G["健康检查与系统启动"] + G --> H["Cron/心跳"] ``` ---- - -### controller/taskGroup/ — 任务组控制器 - -管理任务组的 CRUD、拖拽排序、队列加载。 - -| 文件 | 职责 | -|------|------| -| `TaskGroupController.ts` | 任务组子控制器类:绑定视图事件,协调下属模块 | -| `addItems.ts` | 向任务组添加项目:从当前方案/文件/预设添加 | -| `DailyTaskLoaderController.ts` | 管理日常任务浮窗的分类、参数和加入列表/队列动作 | -| `TaskListLoaderController.ts` | 解析外部任务列表并协调批量载入 | -| `managedPlanReader.ts` | 读取受管作战计划并统一用户/系统来源信息 | -| `queueLoader.ts` | 加载任务组到调度队列:逐项构建 TaskRequest → `Scheduler.addTask()` | -| `metaLoader.ts` | 加载任务项的元数据(方案标题、模板名称)用于 UI 显示 | -| `contextMenu.ts` | 右键上下文菜单:编辑/删除/复制任务项 | - -**TaskGroupHost 接口**: - -```typescript -interface TaskGroupHost { - readonly scheduler: Scheduler; - plansDir: string; - renderMain(): void; - switchPage(page: string): void; - importTaskPreset(preset: TaskPreset, filePath: string): void; - getCurrentPlan(): PlanModel | null; - setCurrentPlan(plan: PlanModel, mapData: MapData | null): void; - renderPlanPreview(): void; - closePresetDetail(): void; - executePreset(): void; - getCurrentPresetInfo(): { preset: TaskPreset; filePath: string } | null; - pickManagedBattlePlan(): Promise; - openManagedPlan(file: string, source: PlanPresetSource): Promise; -} +启动流程只通过 `StartupGateway` 使用主进程能力。不要在该 Controller 中导入 +preload 或 Node API。 + +### `controller/plan` + +| 文件 | 状态所有权或用例 | +|---|---| +| `PlanController.ts` | 当前作战方案和地图状态 | +| `BattlePlanLoaderController.ts` | 受管方案选择浮窗状态 | +| `FleetPlannerController.ts` | 普通编队唯一 `FleetDraft` 和文件 identity | +| `DecisivePlanController.ts` | 决战唯一 `DecisiveFleetDraft` | +| `PlanFleetPresetController.ts` | 当前方案引用的舰队预设清单 | +| `PlanManagementController.ts` | 方案管理目录与操作 | +| `selectedNodes.ts` | 新计划节点、后端节点规范化、执行前校验 | +| `nodeEditor.ts` | 节点表单到 PlanModel 的写入 | +| `rendering.ts` | PlanModel 与地图到 ViewObject | +| `presetFlow.ts` | 独立任务预设详情和执行 | + +普通编队和决战可以共享视觉组件,但不能共享草稿状态。文件名、来源、覆盖保存和 +DTO 转换属于 Controller/Model,不属于 View。 + +### `controller/taskGroup` + +| 文件 | 责任 | +|---|---| +| `TaskGroupController.ts` | 任务组选择、CRUD 和 ViewObject | +| `TaskListLoaderController.ts` | 任务列表文件选择与批量载入 | +| `DailyTaskLoaderController.ts` | 日常计划选择、参数和提交 | +| `queueLoader.ts` | 四类条目解析成 SchedulerTask | +| `managedPlanReader.ts` | 统一读取受管作战/日常方案 | +| `addItems.ts` | 添加方案、预设、日常和模板条目 | +| `metaLoader.ts` | 批量读取展示元数据 | +| `contextMenu.ts` | 编辑、复制、删除和打开来源 | + +`queueLoader.ts` 是任务组到 Scheduler 的唯一集中转换点。新增条目类型时,应同时 +修改 Model 迁移、ViewObject、添加入口、读取逻辑和队列构建。 + +### `controller/template` + +`TemplateController.ts` 与 `crud.ts`、`selectors.ts`、`useTemplate.ts`、 +`wizard.ts` 维护旧用户模板和 `kind: "template"` 任务组兼容。当前没有独立模板 +库页面入口,不代表该链路可以删除。 + +## ViewObject 边界 + +View 接收 `src/types/view.ts` 中的展示数据,或功能目录定义的只读 ViewObject。 + +```text +Model snapshot + -> Controller 映射 + -> readonly ViewObject + -> View.render() + -> 用户事件回调 + -> Controller 应用意图 ``` ---- +不要让 View 为了展示方便直接读取 Model。若多个 Controller/View 需要同一纯 +计算,优先放到 `src/shared/` 或无状态映射模块。 -### controller/template/ — 模板控制器 +## Adapter 边界 -管理模板库的 CRUD、创建向导、使用模板。 +`src/adapter/IpcAdapter.ts` 用 `Pick` 按用例裁剪能力,例如: -| 文件 | 职责 | -|------|------| -| `TemplateController.ts` | 模板子控制器类:绑定库视图/向导视图事件 | -| `wizard.ts` | 4 步创建向导:选类型 → 配参数 → 设默认值 → 命名确认 | -| `useTemplate.ts` | "使用模板"流程:展示选项弹窗 → 添加到任务组 / 加入队列 / 直接执行 | -| `selectors.ts` | 选择弹窗:方案选择、战役选择、舰队选择、决战章节选择 | -| `crud.ts` | 模板的编辑/删除/重命名/批量导入 | +- `StartupGateway` +- `ConfigurationGateway` +- `SettingsGateway` +- `ScheduledTaskRepository` +- `FleetPlannerRepository` +- `DecisivePlanRepository` ---- +Controller 应依赖这些窄契约。新增 IPC 后,不要把完整 ElectronBridge 直接传入 +所有控制器。 -## 依赖关系 +## 修改检查 -```mermaid -graph TD - AppCtrl["controller/app/
AppController"] - Startup["controller/startup/
StartupController"] - Plan["controller/plan/
PlanController"] - TG["controller/taskGroup/
TaskGroupController"] - Tpl["controller/template/
TemplateController"] - - AppCtrl -->|"创建 & 持有"| Plan - AppCtrl -->|"创建 & 持有"| TG - AppCtrl -->|"创建 & 持有"| Tpl - AppCtrl -->|"创建 & 持有"| Startup - - AppCtrl -->|"注入 PlanHost"| Plan - AppCtrl -->|"注入 TaskGroupHost"| TG - AppCtrl -->|"注入 StartupHost"| Startup - - Plan -.->|"显式功能依赖"| Tpl -``` - -**关键设计**:共享能力通过最小 Host 注入;确有业务关系的模块使用明确 import 或 -回调,不通过通用根控制器或隐藏 barrel 获取依赖。 +修改 Controller 至少执行: ---- - -## 与其他系统的关系 - -- **Model 层**:Controller 持有 Model 实例引用,通过 Model 的公共方法读写数据 -- **View 层**:Controller 构建 ViewObject 传递给 View 渲染,View 通过回调将用户操作传回 Controller -- **IPC 层**:Adapter 独占 `window.electronBridge`;Controller 仅注入 `StartupGateway`、`ConfigurationGateway` 等窄能力 -- **调度系统**:SchedulerBinder 封装 Scheduler/CronScheduler 的回调绑定;各子控制器通过 Host 的 `scheduler` 属性添加任务 +```powershell +npm run test:architecture-boundaries +npm run test:build +``` -`npm run test:controller-boundaries` 会扫描全部 Controller,禁止 DOM 全局、DOM -实现类型、浏览器事件所有权和直接 `window.electronBridge` 访问重新进入该层。 +再按业务运行 Scheduler、配置、舰队、迁移或 IPC 专项测试。若测试要求 +Controller 获得 DOM 类型,通常说明责任放错层,应先重新确认边界。 diff --git a/docs/architecture/02-task-scheduling.md b/docs/architecture/02-task-scheduling.md index 2608fd1..9562c2e 100644 --- a/docs/architecture/02-task-scheduling.md +++ b/docs/architecture/02-task-scheduling.md @@ -1,272 +1,206 @@ # 任务调度系统 -> 涉及文件:`src/model/scheduler/` 子目录(Scheduler.ts · TaskQueue.ts · CronScheduler.ts · ExpeditionTimer.ts · StopConditionChecker.ts · RepairManager.ts)· `src/types/scheduler.ts` - -## 概述 - -任务调度系统是 AutoWSGR-GUI 的核心运行引擎,负责将用户的战斗计划、日常自动化任务按优先级排队,逐个发送到 Python 后端执行。 - -调度模块位于 `src/model/scheduler/`,通过 `index.ts` 聚合导出(外部统一从 `'../model/scheduler'` 导入)。类型定义位于 `src/types/scheduler.ts`。 - -系统由六个组件构成: - -| 组件 | 职责 | -|------|------| -| `Scheduler` | 核心调度器,管理任务消费/重试/后触发,持有 TaskQueue || -| `TaskQueue` | 优先级任务队列数据结构,从 Scheduler 提取,封装入队/出队/查找操作 | -| `CronScheduler` | 基于系统时钟的定时触发器,在演习/战役/出击刷新时间自动生成任务 | -| `ExpeditionTimer` | 远征收取定时器,按固定间隔(默认 15 分钟)触发远征检查 | -| `StopConditionChecker` | 多阶段停止条件检查器,通过 OCR/日志/API 判断是否提前终止 | -| `RepairManager` | 泡澡修理管理器,检查舰船血量、送入泡澡、编队预设轮换 | - ---- - -## 核心组件 - -### Scheduler — 优先级任务队列 - -`Scheduler` 采用**带优先级的生产者-消费者模型**,同一时间只有一个任务在后端执行。 - -#### 优先级体系 +> 主要目录:`src/model/scheduler/`、`src/controller/app/SchedulerBinder.ts` + +## 组件与所有权 + +| 组件 | 责任 | +|---|---| +| `Scheduler` | 消费任务、调用后端、停止、重试、后续轮次和回调 | +| `TaskQueue` | 就绪队列、修理延迟队列、优先级插入和舰队切换 | +| `SchedulerTaskPolicy` | 纯任务构建、后续轮次复制和插入策略 | +| `SchedulerRepairPolicy` | 修理结果到调度动作的纯策略 | +| `CronScheduler` | 每分钟检查自动任务触发条件 | +| `ExpeditionTimer` | 远征间隔和秒级倒计时 | +| `StopConditionChecker` | 启动前、运行中和轮次后的停止条件 | +| `RepairManager` | 泡澡状态、阈值和轮换编队 | +| `CampaignDailyQuota` | 自动战役当日正常结算次数 | +| `NormalFightDailyQuota` | 自动常规出击按计划/舰队的每日有效次数 | +| `SchedulerBinder` | 将 Scheduler/Cron、日志、额度和 UI 生命周期连接起来 | +| `SchedulerRuntimeTracker` | 保存由后端日志派生的当前运行展示状态 | + +Scheduler 是任务生命周期的权威所有者;Cron 只决定“何时应触发”,不直接执行 +后端请求。 + +## 优先级 ```typescript -// src/types/scheduler.ts -enum TaskPriority { - EXPEDITION = 0, // 远征检查(最高) - USER_TASK = 10, // 用户手动发起的战斗 - DAILY = 20, // 日常自动任务 +export enum TaskPriority { + EXPEDITION = 0, + USER_TASK = 10, + DAILY = 20, } ``` -新任务按优先级值**升序插入**队列,确保远征检查不会被长时间的用户任务阻塞。 +数值越小优先级越高。队列在同优先级内由 `allowPolling` 决定: -#### 任务结构 +- `false` 或未设置:后续轮次插回同优先级前部,连续执行。 +- `true`:插到同优先级尾部,与同级任务轮询。 +- `forceRetry: true`:失败重试优先回到同级前部。 + +## 任务身份 + +后端每次只执行一轮,多轮逻辑由 GUI 拆分。 ```typescript interface SchedulerTask { - id: string; // 当前单轮的唯一标识 - logicalId: string; // 所有后触发轮次共享的稳定逻辑任务标识 - name: string; // 显示名称 - type: SchedulerTaskType; // normal_fight | campaign | exercise | decisive | expedition - priority: TaskPriority; - request: TaskRequest; // 发送给后端的 API 请求体 - remainingTimes: number; // 剩余执行次数 - totalTimes: number; // 总次数(用于显示进度) - unlimited?: boolean; // 无限任务每轮后继续,但 logicalId 不变 - backendTaskId?: string; // 当前轮对应的后端任务 ID - stopCondition?: StopCondition; // 可选的提前终止条件 - bathRepairConfig?: BathRepairConfig; // 可选的泡澡修理配置 - fleetPresets?: FleetPreset[]; // 可轮换的编队预设列表 - maxRetries: number; // 最大重试次数(默认 2) - retryCount: number; // 当前已重试次数 + id: string; // 当前物理轮次 + logicalId: string; // 整个逻辑任务,后续轮次保持稳定 + remainingTimes: number; + totalTimes: number; + unlimited?: boolean; + maxRetries: number; // 默认 2 + retryCount: number; + forceRetry?: boolean; + allowPolling?: boolean; } ``` -#### 生产者 +必须区分三个事件: + +| 事件 | 含义 | +|---|---| +| `onTaskCompleted(id)` | 一轮后端任务结束 | +| `onLogicalTaskCompleted(logicalId)` | 已无后续轮次,整个逻辑任务结束 | +| `onLogicalTaskCanceled(logicalId, reason)` | 用户删除、清空或系统停止 | -三类生产者向队列添加任务: -1. **用户手动**:通过 UI 选择受管方案或从任务组加载(`USER_TASK` 优先级) -2. **定时触发**:`CronScheduler` 生成演习、战役、出击、决战和胖次任务(`DAILY` 优先级) -3. **后触发**:单轮完成后按剩余次数或无限标记追加下一轮;新轮次生成新 `id`,但继承原 `logicalId` +Cron pending、等待条目和 UI 逻辑任务状态使用 `logicalId`。不能用单轮 `id` +提前清理整个任务。 -#### 消费流程 +## 消费流程 ```mermaid flowchart TD - A[队列非空 & 状态=idle] --> B[取出队首任务] - B --> C{需要泡澡修理?} - C -->|是| D[RepairManager.checkFleetHealth] - D --> E{有舰船需修理?} - E -->|是| F[尝试编队预设轮换] - F --> G{有可用预设?} - G -->|是| H[切换预设, 继续执行] - G -->|否| I[任务延迟, 30s 后重试] - E -->|否| J{有停止条件?} - C -->|否| J - H --> J - J -->|是| K[preflightCheck: OCR 预飞检查] - K --> L{已满足?} - L -->|是| M[跳过任务, 标记完成] - L -->|否| N[发送 API taskStart] - J -->|否| N - N --> O[状态=running, 等待后端完成] - O --> P{成功?} - P -->|是| Q{还有剩余次数
或无限任务?} - Q -->|是| R[后触发: 重新入队] - Q -->|否| S[逻辑任务完成, 消费下一个] - P -->|否| T{retryCount < maxRetries?} - T -->|是| U[retryCount++, 5s 后重试] - T -->|否| V[任务失败, 消费下一个] + A["consumeNext"] --> B["取最高优先级任务"] + B --> C{"需要修理检查?"} + C -->|是| D["RepairManager"] + D --> E{"可继续?"} + E -->|轮换| F["替换舰队预设"] + E -->|等待| G["TaskQueue 延迟 30 秒"] + C -->|否| H + F --> H{"停止条件预检?"} + H -->|已满足| I["逻辑完成"] + H -->|未满足| J["POST /api/task/start"] + J --> K["等待 WebSocket 完成"] + K --> L{"成功?"} + L -->|否| M{"retryCount < maxRetries?"} + M -->|是| N["5 秒后重试"] + M -->|否| O["逻辑失败结束"] + L -->|是| P["终点/战果/停止条件结算"] + P --> Q{"还有有效轮次?"} + Q -->|是| R["生成新 id,保留 logicalId"] + Q -->|否| I ``` ---- +gap、retry 和修理等待都必须保持可见、可取消,并仍属于原 `logicalId`。 +`Scheduler.isCompletelyIdle` 只有在运行、就绪、gap/retry 和修理延迟全部为空时 +才为真。 -### 任务身份与生命周期 +## 有效轮次计数 -调度器严格区分物理轮次和逻辑任务: +普通出击可带: -| 事件 | 标识 | 含义 | -|------|------|------| -| `onTaskCompleted` | `id` | 一轮后端任务结束,可继续生成后触发轮次 | -| `onLogicalTaskCompleted` | `logicalId` | 已无后触发、满足停止条件或重试耗尽,整个逻辑任务结束 | -| `onLogicalTaskCanceled` | `logicalId` | 用户删除、清空队列或系统停止;不等同于成功或失败 | +- `endpointNodes`:应到达的终点节点。 +- `endpointResult`:终点战斗最低战果。 -有限和无限任务的每个后触发轮次都有新的 `id`,但共享创建任务时生成的 -`logicalId`。`SchedulerBinder` 只在逻辑完成时清理 cron/pending 状态;单轮 -完成只重置当前进度。取消时还会根据原因区分行为:用户删除或清空表示主动 -放弃,`system_stopped` 只释放 pending,允许下次启动重新触发。 +成功响应不等于有效轮次。若没有到达终点,或终点战果不满足要求: -延迟间隔、失败重试和修理等待中的任务也属于同一逻辑任务。删除或清空操作会 -同时检查运行中、就绪队列和等待队列,保证一个 `logicalId` 的停止语义可追踪。 +- `remainingTimes` 不减少。 +- 生成后续轮次继续执行。 +- 日志明确说明本轮不计数。 ---- +失败轮次在重试耗尽后按既有失败结算结束,避免异常状态无限循环。 -### CronScheduler — 定时触发 +终点判定优先使用任务显式 `endpointNodes`,否则根据计划数据推导。修改该逻辑 +必须覆盖多节点、无战斗终点、事件列表和旧后端结果格式。 -`CronScheduler` 每分钟检查一次系统时间,在特定时间点自动向 `Scheduler` 添加日常任务。 +## 停止条件 -#### 触发规则 +`StopConditionChecker` 支持战利品数量和舰船数量条件,分三处执行: -| 任务类型 | 触发时间 | 去重机制 | -|----------|----------|----------| -| 演习 | 0:00 / 12:00 / 18:00 后 | `localStorage` 记录**实际完成**时间戳 | -| 战役 | 每日 0:00 后 | `localStorage` 记录完成日期 (YYYY-MM-DD) | -| 常规出击 | 每分钟检查;调度器完全空闲时 | 单轮 pending;本轮全部逻辑任务结束后释放 | -| 决战 | 每日 0:00 后 | 实际任务结束后记录完成日期 | -| 刷战利品 | 每日 0:00 后 | 同上 | -| 定时方案 | YAML 中 `scheduled_time: "HH:MM"` | 当日 `firedToday` 标志 | +| 阶段 | 数据来源 | 目的 | +|---|---|---| +| 启动前预检 | `/api/game/acquisition` | 已满足时不发起新轮次 | +| 运行中 | 后端 `[UI]` 日志 | 尽早请求停止当前任务 | +| 轮次完成后 | acquisition/context/结果 | 决定是否生成后续轮次 | -**关键设计**:记录的是任务**实际完成**的时间戳而非"是否已触发"。这样即使 App 因 ADB 断开等原因重启,只要任务未真正完成,下次启动后仍会补发。 +Controller 只协调检查结果;OCR/后端异常不能被伪装成“已满足”或业务回退。 -同类自动任务仍在排队或执行时,跨刷新时段或跨日也不会重复入队。旧任务在 -新时段执行会消耗新时段的次数,因此由它代表当前时段;完成、用户取消或系统 -停止后,再按对应结算规则记录处理时间或释放 pending。 +## 重试与停止 -自动决战固定生成一轮(`decisive_rounds: 1`),不查询或推测剩余票数。来源 -只能是 `user_plan` 或 `system_preset`:前者读取决战计划页保存的 -`decisive_plan`,后者读取只读系统预设 `builtin_decisive_6`。任务入队前读取 -或配置失败会清除 pending,允许后续 tick 重试;逻辑任务实际结束后,无论成功 -或失败,当天都不再重复,以免失败前已消耗票数。 +- 默认最大重试 2 次。 +- 每次失败等待 5 秒再入队。 +- `forceRetry` 控制是否优先重试当前任务。 +- `allowPolling` 控制同优先级任务是连续还是轮询。 +- 删除任务和清空队列会同步清理就绪、等待和运行中的逻辑任务。 +- `system_stopped` 释放 Cron pending,使下次启动可以重新触发。 +- 用户删除或清空表示主动放弃,Cron 按对应业务规则处理。 -自动出征只在 `Scheduler` 无运行、排队、重试等待或修理延迟任务时触发。每次 -触发将配置中的所有 YAML 计划各加入一个单轮任务;计划加载期间如果调度器变为 -非空闲,本轮不入队并等待下一次检查。 +## CronScheduler -#### 事件回调 +Cron 每分钟 tick 一次,负责: -`CronScheduler` 通过回调通知 `AppController`,由 Controller 调用 `Scheduler.addTask()` 入队: +| 自动任务 | 触发和持久化 | +|---|---| +| 演习 | 0:00、12:00、18:00 时段;记录已处理时段 | +| 战役 | 每日触发;固定目标为 8 次正常结算 | +| 常规出击 | 调度器完全空闲时触发配置列表 | +| 决战 | 每日触发用户计划或系统预设 | +| 战利品 | 每日触发稳定计划 ID | +| 定时方案 | 按方案 `scheduled_time` | -```typescript -import type { LootPlanSource } from '../../src/shared/lootPlans'; -import type { - DecisiveAutomationSource, -} from '../../src/shared/decisiveAutomation'; - -interface CronCallbacks { - canStartNormalFight?: () => boolean; - onExerciseDue?: (fleetId: number) => void; - onCampaignDue?: (campaignName: string, times: number) => void; - onNormalFightDue?: () => void; - onDecisiveDue?: (source: DecisiveAutomationSource) => void; - onLootDue?: ( - source: LootPlanSource, - planId: string, - stopCount: number, - ) => void; -} -``` - ---- +Cron 记录实际完成或明确处理,不是在“刚入队”时就标记完成。加载失败或系统停止 +应清除 pending,让后续 tick 可以重试。 -### ExpeditionTimer — 远征定时器 +### 自动战役 -独立的间隔定时器,默认每 15 分钟触发一次远征收取检查。 +`src/shared/campaign.ts` 定义: -- 间隔可配置(1~120 分钟,通过配置页设定) -- 每秒发出 `onTick` 回调用于 UI 倒计时显示 -- 到期时发出 `onTrigger`,`Scheduler` 据此插入 `EXPEDITION` 优先级任务 - -```mermaid -sequenceDiagram - participant Timer as ExpeditionTimer - participant Sched as Scheduler - participant API as ApiClient - participant Backend as Python 后端 - - Timer->>Timer: 每秒 onTick(remainingSeconds) - Note over Timer: 倒计时归零 - Timer->>Sched: onTrigger() - Sched->>Sched: addTask(expedition, EXPEDITION) - Note over Sched: 优先级=0, 插入队首 - Sched->>API: POST /api/expedition/check - API->>Backend: 收取远征 - Backend-->>Sched: 完成 +```typescript +export const DAILY_CAMPAIGN_TIMES = 8; ``` ---- +`battleTimes` 仅为旧持久化结构兼容,`ConfigModel`、 +`GuiConfigurationService` 和 `CronScheduler` 都强制归一化为 8。C/D 等可正常 +结算的结果都计入当日完成次数,文案和额度语义是“正常结算”,不是只计某一种 +战果。 -### StopConditionChecker — 停止条件检查 +### 自动常规出击 -支持两种停止条件:`loot_count_ge`(战利品数量 ≥ N)和 `ship_count_ge`(舰船数量 ≥ N)。 +配置中的每个任务有独立每日上限。任务 key 由受管计划来源/文件和舰队覆盖组成: -检查分三个阶段: +- `src/shared/normalFightQuota.ts`:纯限制、key 和去重规则。 +- `NormalFightDailyQuota`:浏览器存储状态和日期重置。 -| 阶段 | 时机 | 数据来源 | 说明 | -|------|------|----------|------| -| **预飞 (preflight)** | 任务发送前 | `GET /api/game/acquisition` (OCR) | 已满足则跳过任务 | -| **运行时 (running)** | 任务执行中 | 后端日志 `[UI] 战利品数量: N/M` | 实时解析日志触发停止 | -| **任务后 (post)** | 单轮完成后 | `GET /api/game/context` | 决定是否继续后触发 | +同一计划和舰队的重复配置先去重。`canStartNormalFight` 必须先读取任务与额度, +同时确认 `Scheduler.isCompletelyIdle`。 ---- +## 远征与修理 -### RepairManager — 泡澡修理 +`ExpeditionTimer` 默认每 15 分钟触发一次,配置范围 1~120 分钟。它每秒提供倒 +计时,触发后生成 `EXPEDITION` 优先级任务。 -在任务执行前检查编队舰船血量,将受损舰船送入泡澡修理。 +`RepairManager` 在任务前检查舰队状态: -#### 修理流程 +1. 读取游戏上下文。 +2. 按默认和单船阈值判定。 +3. 发送修理请求。 +4. 有备用编队时轮换。 +5. 无可用编队时延迟任务,稍后重新检查。 -1. **血量检查**:调用 `GET /api/game/context` 获取编队舰船 HP -2. **阈值匹配**:按舰船名查找修理阈值配置(支持"·改"名称规范化) -3. **送入泡澡**:调用 `POST /api/repair` 发送修理请求 -4. **编队轮换**:若有多组 `FleetPreset`,尝试切换到未受损的预设继续战斗 -5. **延迟重试**:若无可用预设,任务延迟 30 秒后重新检查 +延迟不能消耗 `remainingTimes`。 -```typescript -interface BathRepairConfig { - enabled: boolean; - defaultThreshold: RepairThreshold; // 默认修理阈值 - shipThresholds?: Record; // 按舰船名定制阈值 -} -``` +## 生命周期与验证 ---- +`SchedulerBinder.dispose()` 在 Renderer 卸载时释放运行状态监听。新增日志订阅、 +计时器或回调时必须提供幂等清理。 -## 组件交互全景 +修改调度领域至少执行: -```mermaid -graph LR - User["用户操作"] -->|添加任务| Scheduler - Cron["CronScheduler
(每分钟检查)"] -->|日常任务| Scheduler - ExpTimer["ExpeditionTimer
(15min 间隔)"] -->|远征任务| Scheduler - - Scheduler -->|执行前| StopCheck["StopConditionChecker
(预飞检查)"] - Scheduler -->|执行前| Repair["RepairManager
(血量检查)"] - Scheduler -->|taskStart| Backend["Python 后端"] - Backend -->|日志 / 完成| Scheduler - - Scheduler -->|运行时日志| StopCheck - StopCheck -->|满足条件| Scheduler - Repair -->|编队轮换 / 延迟| Scheduler - - Scheduler -->|回调| Controller["AppController
(更新 UI)"] +```powershell +npm run test:scheduler-domain +npm run test:build ``` ---- - -## 与其他系统的关系 - -- **Controller 层**:`SchedulerBinder` 绑定 Scheduler/CronScheduler 回调并管理待完成任务 ID;`SchedulerRuntimeTracker` 持有日志派生的主页运行状态;`ScheduledTaskLoader` 负责读取自动化计划并入队 -- **配置系统**:开关类后端设置来自 `usersettings.yaml.daily_automation`;远征间隔、战役次数、自动决战和胖次设置来自 `gui_settings.json.automation` -- **模板与任务组**:任务组通过 `loadGroupToQueue()`(`controller/taskGroup/queueLoader.ts`)批量向 `Scheduler` 添加任务 -- **出击计划**:方案解析后构建 `TaskRequest`,通过 `Scheduler.addTask()` 入队 -- **后端通信**:`Scheduler` 持有 `ApiClient` 引用,通过 REST API 发起任务、通过 WebSocket 接收进度和完成通知 +涉及后端 DTO 再执行 `npm run test:api-contract`;涉及配置持久化再执行 +`npm run test:settings`。 diff --git a/docs/architecture/03-configuration.md b/docs/architecture/03-configuration.md index 63a1b32..e5b8506 100644 --- a/docs/architecture/03-configuration.md +++ b/docs/architecture/03-configuration.md @@ -1,23 +1,41 @@ # 配置系统 -> 涉及文件:`src/model/ConfigModel.ts` · `src/view/config/ConfigView.ts` · `src/view/theme.ts` · `src/controller/app/ConfigController.ts` · `src/types/model.ts` · `electron/services/GuiSettingsStore.ts` · `electron/services/GuiConfigurationService.ts` · `electron/ipc/ConfigurationIpc.ts` +> 主要文件:`src/model/ConfigModel.ts`、`src/controller/app/ConfigController.ts`、 +> `src/view/config/`、`electron/services/Gui*Settings*.ts` -## 概述 +## 配置不是一个文件 -配置系统采用**双层存储**: +| 存储 | 责任 | 所有者 | +|---|---|---| +| `userData/usersettings.yaml` | AutoWSGR 业务配置:模拟器、账号、日常自动化 | `ConfigModel` + 安全文件 IPC | +| `userData/gui_settings.json` | GUI、窗口、Python、CUDA、后端模式、更新和 GUI 自动化 | Main 配置 Service | +| Renderer `localStorage` | 主题、调试、Cron/额度等轻量可恢复状态 | `StorageAdapter` 和对应 Model | -| 层级 | 文件 | 内容 | 读写方式 | -|------|------|------|----------| -| **GUI 级** | `userData/gui_settings.json` | 后端、Python、CUDA、窗口和 GUI 自动化设置 | Electron 配置服务读写 | -| **用户级** | `userData/usersettings.yaml` | 模拟器、账号、日常自动化 | 渲染进程通过 IPC 读写 | +不能把三者合并成一个配置源,也不能让 Renderer 直接用 Node 文件 API。 -另外,主题/调试模式等纯 UI 偏好存储在浏览器 `localStorage` 中。 +## Renderer 模型 ---- +`ConfigModel` 维护两部分: -## 数据模型 +- `UserSettings`:对应 `usersettings.yaml`。 +- `GuiAutomationSettings`:从 Main 读取,对应 JSON 的 `automation`。 -### UserSettings 结构 +主要行为: + +| 方法/属性 | 作用 | +|---|---| +| `loadFromYaml()` | 解析 YAML,与默认值合并并升级旧字段 | +| `toYaml()` | 输出后端业务配置 | +| `update()` | 合并经过校验的表单值 | +| `current` | 当前只读业务配置 | +| GUI automation 访问器 | 读取和规范化 GUI 调度参数 | + +`ConfigModel` 不负责磁盘写入。缺失字段应回填默认值;未知业务字段不能因为 GUI +未建模而被无意清空。 + +## 配置域 + +### `usersettings.yaml` ```typescript interface UserSettings { @@ -27,225 +45,164 @@ interface UserSettings { } ``` -#### EmulatorConfig — 模拟器配置 +`daily_automation` 包含远征、奖励、浴室、支援、演习、战役和自动常规出击等 +AutoWSGR 业务设置。自动常规出击条目使用受管计划来源、文件、舰队覆盖和每日 +上限。 + +### `gui_settings.json` + +主进程管理的主要字段: + +```text +backend_port +python_path +update_mode +backend_startup_mode +backend_repo_path +ocr_gpu_mode +cuda_path +save_backend_screenshots +window +automation +decisive_plan +legacy_decisive_automation +``` -| 字段 | 类型 | 说明 | -|------|------|------| -| `type` | `string` | 模拟器类型:`"MuMu"` / `"雷电"` / `"蓝叠"` 等 | -| `path` | `string?` | 模拟器可执行文件路径 | -| `serial` | `string?` | ADB 连接串口,如 `"127.0.0.1:16384"` | +`GuiSettingsStore` 是 JSON 存储入口,写入时顶层浅合并,保留调用方未更新的未知 +顶层字段。字段默认、类型、边界和旧字段升级由 +`GuiConfigurationService` 负责。 -#### AccountConfig — 账号配置 +`automation` 当前包含: -| 字段 | 类型 | 说明 | -|------|------|------| -| `game_app` | `string` | 服务器:`"官服"` 等 | -| `account` | `string?` | 账号 | -| `password` | `string?` | 密码 | +| 字段 | 语义 | +|---|---| +| `expeditionInterval` | 远征检查间隔,1~120 分钟 | +| `battleTimes` | 旧结构兼容字段,运行时固定为 8 | +| `autoDecisive` | 每日自动决战 | +| `decisiveTemplateId` | `user_plan` 或 `system_preset` | +| `autoLoot` | 自动战利品任务 | +| `lootPlanId` | 稳定的系统/用户计划标识 | +| `lootStopCount` | 停止数量 | -#### DailyAutomation — 日常自动化 +旧 `battle_times` 不再控制实际次数,读取和保存都会归一化为 +`DAILY_CAMPAIGN_TIMES`。 -| 字段 | 类型 | 默认 | 说明 | -|------|------|------|------| -| `auto_expedition` | `boolean` | `false` | 自动远征 | -| `auto_gain_bonus` | `boolean` | `false` | 自动领取奖励 | -| `auto_bath_repair` | `boolean` | `false` | 自动浴室修理 | -| `auto_set_support` | `boolean` | `false` | 自动设置支援 | -| `bath_repair_blacklist` | `string[]` | `[]` | 浴室修理黑名单 | -| `auto_exercise` | `boolean` | `false` | 自动演习 | -| `exercise_fleet_id` | `number?` | `null` | 演习舰队 (1~4) | -| `auto_battle` | `boolean` | `false` | 自动战役 | -| `battle_type` | `string` | `"困难潜艇"` | 战役类型 | -| `auto_normal_fight` | `boolean` | `false` | 自动常规出击 | -| `normal_fight_tasks` | `NormalFightTaskConfig[]` | `[]` | 自动出击任务及顺序 | -| `quick_repair_limit` | `number?` | `null` | 快修限制 | -| `stop_max_ship` | `boolean` | `false` | 船坞满时停止 | -| `stop_max_loot` | `boolean` | `false` | 战利品满时停止 | +## 加载流程 -#### GuiAutomationSettings — GUI 自动化 +```mermaid +sequenceDiagram + participant Startup + participant Adapter + participant Main + participant ConfigModel + participant View + + Startup->>Adapter: readFile(usersettings.yaml) + Adapter->>ConfigModel: loadFromYaml() + Startup->>Adapter: getGuiAutomationSettings() + Adapter->>Main: IPC + Main-->>ConfigModel: normalized automation + ConfigModel-->>View: ConfigViewObject +``` -这些字段仅属于 GUI 调度器,保存在 `gui_settings.json.automation`,不会写回 -`usersettings.yaml`: +文件不存在时创建默认配置。迁移逻辑必须发生在明确的 Model/Service 边界,不能 +在 View 的 `render()` 中偷偷改持久化数据。 -| 字段 | 类型 | 默认 | 说明 | -|------|------|------|------| -| `expeditionInterval` | `number` | `15` | 远征检查间隔(分钟,1~120) | -| `battleTimes` | `number` | `3` | 每日战役次数 | -| `autoDecisive` | `boolean` | `false` | 每日自动决战 | -| `decisiveTemplateId` | `string` | `"builtin_decisive_6"` | 决战模板 ID | -| `autoLoot` | `boolean` | `false` | 每日自动刷胖次 | -| `lootPlanId` | `string` | 内置稳定 ID | 系统出征计划文件名 | -| `lootStopCount` | `number` | `50` | 胖次停止数量(1~50) | +## 保存事务 -旧版 YAML 中的 `expedition_interval`、`battle_times`、胖次字段和决战字段会在 -启动时迁移。`auto_decisive` 与 `decisive_template_id` 升级为正式 automation; -`decisive_ticket_reserve` 只原样归档到 `legacy_decisive_automation`,不参与 -执行轮数。独立的 `decisive_plan` 不会被旧字段覆盖。 +`ConfigController` 先构造候选配置并校验,再调用 +`ConfigurationGateway.commitGuiSettings()`。Main 侧 +`GuiSettingsCommitService` 执行: ---- +```text +读取原 usersettings.yaml + -> 写入新 usersettings.yaml + -> 原子写入 gui_settings.json + -> JSON 成功:提交完成 + -> JSON 失败:恢复原 usersettings.yaml,再抛错 +``` -## ConfigModel +只有整个事务成功后,Renderer 才: -`ConfigModel` 是配置数据的内存表示,提供加载/更新/序列化接口: +- 替换内存 `ConfigModel`。 +- 更新 `CronScheduler` 配置。 +- 更新远征间隔。 +- 写入主题等 UI 偏好。 +- 刷新设置页和主页。 -| 方法 | 说明 | -|------|------| -| `loadFromYaml(yamlStr)` | 解析 YAML 字符串,与默认值合并(缺失字段保留默认) | -| `toYaml()` | 序列化为 YAML 字符串 | -| `update(partial)` | 深合并部分更新 | -| `current` | 只读属性,返回当前 `UserSettings` 对象 | +不得先更新内存再希望磁盘保存成功,否则失败后 UI 与运行时会出现两套配置。 -**关键行为**:`loadFromYaml` 对缺失字段做**默认值回填**,确保旧版配置文件升级后新字段不会为 `undefined`。 +## Main 配置服务 ---- +| 模块 | 责任 | +|---|---| +| `GuiSettingsStore` | JSON 读取、根对象校验、浅合并和原子写入 | +| `GuiConfigurationService` | 默认值、规范化、旧字段升级和同步 getter | +| `GuiSettingsCommitService` | YAML + JSON 事务、窗口偏好提交 | +| `AtomicFileStore` | 临时文件、替换和原子持久化 | +| `SecureFileService` | userData/resource 路径权限和文件操作 | +| `ConfigurationIpc` | 通道注册和参数边界 | -## 数据流 +preload 中的启动配置 getter 使用 `sendSync`,Main 必须使用 `ipcMain.on`: -```mermaid -flowchart LR - subgraph Storage["持久化存储"] - YAML["userData/usersettings.yaml"] - GUI["userData/gui_settings.json"] - LS["localStorage"] - end - - subgraph Model["Model 层"] - CM["ConfigModel"] - end - - subgraph View["View 层"] - CV["ConfigView"] - end - - subgraph Controller["控制器层"] - CC["ConfigController
controller/app/ConfigController.ts"] - AC["AppController"] - end - - subgraph Side["副作用"] - Cron["CronScheduler"] - Sched["Scheduler"] - IPC["Electron IPC / GuiConfigurationService"] - end - - YAML -->|"bridge.readFile()"| AC - AC -->|"loadFromYaml()"| CM - CM -->|"extractViewObject()"| CC - CC -->|"render(vo)"| CV - CV -->|"用户编辑"| CV - CV -->|"collect()"| CC - CC -->|"update()"| CM - CM -->|"toYaml()"| CC - CC -->|"bridge.saveFile()"| YAML - CC -->|"setGuiAutomation()"| IPC - IPC -->|"顶层浅合并"| GUI - CC -->|"updateConfig()"| Cron - CC -->|"setExpeditionInterval()"| Sched - AC -->|"setBackendPort()"| IPC - AC ---|"主题/调试/端口"| LS -``` +- 应用版本 +- 后端端口和启动模式 +- Python 路径 +- OCR GPU/CUDA +- 截图保存 +- 更新模式 +- 窗口偏好 -### 加载流程 - -1. `AppController.loadConfigAndSync()` 通过 IPC 读取 `usersettings.yaml` -2. 调用 `ConfigModel.loadFromYaml()` 解析并合并默认值 -3. 若文件不存在,用默认值创建新文件 - -### 保存流程 - -1. 用户点击“保存配置” -2. `ConfigView.collect()` 从表单提取当前值 → `ConfigViewObject` -3. `ConfigController.saveConfig()` 执行以下操作: - - 在候选 `ConfigModel` 中校验并合并表单字段 - - 通过 `ConfigurationGateway.commitGuiSettings()` 提交 YAML、GUI 设置和窗口偏好 - - Main 进程先写 YAML,再原子提交 JSON;JSON 失败时恢复原 YAML - - 提交成功后才更新内存模型和 `localStorage` UI 偏好 - - 同步 `CronScheduler.updateConfig()` 更新定时任务规则 - - 同步 `Scheduler.setExpeditionInterval()` 更新远征检查间隔 - -### 主题管理 - -主题 DOM 逻辑位于 `view/theme.ts`,通过 `StorageAdapter` 读取偏好,支持亮色、 -暗色、自动切换和强调色应用。 - ---- - -## ConfigView 表单结构 - -配置页 UI 分为三个区域,对应 `UserSettings` 的三个子配置: - -### 模拟器设置 -- 模拟器类型下拉 (`#cfg-emu-type`) -- 安装路径 (`#cfg-emu-path`) + 文件浏览按钮 -- ADB 串口 (`#cfg-emu-serial`) + 自动检测按钮 - -### 账号设置 -- 服务器选择 (`#cfg-game-app`) -- 账号/密码(可选) - -### 自动化设置 -- 远征开关 + 间隔滑块 -- 演习开关 + 舰队选择 -- 战役开关 + 类型 + 次数 -- 决战开关 + 模板选择(下拉从 `TemplateModel` 动态填充) -- 战利品开关 + 方案 + 停止数量 - -### 附加设置(localStorage 存储) -- 主题模式:自动 / 亮色 / 暗色 -- 强调色 -- 调试模式 -- 后端端口 - ---- - -## gui_settings.json - -由 Electron 主进程直接管理的配置,位于 Electron `userData`: - -```json -{ - "backend_port": 8438, - "python_path": "", - "update_mode": "auto", - "backend_startup_mode": "managed", - "backend_repo_path": "", - "ocr_gpu_mode": "auto", - "cuda_path": "", - "save_backend_screenshots": false, - "automation": { - "expeditionInterval": 15, - "battleTimes": 3, - "autoDecisive": false, - "decisiveTemplateId": "builtin_decisive_6", - "autoLoot": false, - "lootPlanId": "bettle-old-8-5AI六潜胖次.yaml", - "lootStopCount": 50 - } -} +不要单方面把同步 getter 改成 Promise;需要同时修改 preload、IPC 类型、Adapter、 +调用点和 IPC 契约测试。 + +## 配置页 View + +`ConfigView` 是 Controller 面向的稳定 Facade,保留完整 `render()`、`collect()` +和事件回调 API。当前拆分为: + +| 模块 | 局部责任 | +|---|---| +| `ConfigAutomationView` | 自动出击摘要、剩余次数、舰队显示和战利品计划选择 | +| `ConfigRuntimeView` | Python/CUDA/Backend/ADB/资料库状态、按钮 loading、更新进度 | +| `settingSelectWidth.ts` | 根据选项文案设置受控分级宽度 | + +子 View 只持有表单和视觉状态。环境检测、持久化、默认值、调度同步和业务校验仍 +属于 Controller/Model/Main Service。 + +HTML 源位于: + +```text +src/view/html/pages/config/ +├─ index.html +├─ behavior.html +└─ system.html ``` -- `backend_port`:Python 后端 HTTP 服务端口 -- `python_path`:用户手动指定的 Python 路径(空字符串 = 自动检测) -- `backend_startup_mode`:`managed` 使用 GUI 管理的环境,`external` 使用指定仓库 -- `ocr_gpu_mode` / `cuda_path`:OCR GPU 模式与 CUDA 路径 -- `automation`:GUI 私有自动化设置 -- `decisive_plan`:决战计划页单独维护的当前配置 -- `legacy_decisive_automation`:旧决战字段无损归档 -- `window`:窗口位置与尺寸偏好 +SCSS 由 `src/view/styles/pages/_config.scss` 聚合 +`src/view/styles/pages/config/` 下的职责 partial。 + +## localStorage 边界 + +允许进入 localStorage 的数据必须可丢失、可重建,不得成为文件 identity 或核心 +业务配置的唯一来源。当前包括: -`GuiSettingsStore` 是唯一 JSON 存储入口,每次写入执行顶层浅合并,因此未参与 -本次更新的未知顶层字段不会丢失。`GuiConfigurationService` 负责默认值、边界、 -旧决战字段迁移和 Python 缓存清理;`ConfigurationIpc` 只保持通道契约。 +- 主题和强调色。 +- Scheduler/Cron 已处理时段。 +- 自动战役、常规出击每日额度。 +- 修理和轻量运行恢复状态。 -渲染进程通过 `get-backend-port-sync`、`get-python-path-sync` 等同步 getter -读取启动配置,通过 `set-backend-port`、`set-python-path` 等异步 setter 写入。 -同步 getter 必须继续使用 `ipcMain.on` / `sendSync`,不能改成 Promise。 +通过 `StorageAdapter` 注入,Controller 和普通 View 不直接调用 +`localStorage.*`。`view/theme.ts` 是经过架构测试允许的 UI 偏好例外。 ---- +## 修改与验证 -## 与其他系统的关系 +| 修改 | 最小验证 | +|---|---| +| ConfigModel 或字段规范化 | `npm run test:settings` | +| Main 配置 Service | `npm run test:main-services` | +| preload/IPC 配置方法 | `npm run test:main-ipc` | +| 配置页 HTML/View/SCSS | `npm run test:build`、`npm run test:settings` | +| 旧字段迁移 | `npm run test:migrations` | -- **任务调度**:`daily_automation` 提供后端自动化开关和出击任务;`automation` 提供远征间隔、战役次数、自动决战和胖次参数 -- **环境管理**:`gui_settings.json` 中的 `python_path` 影响 Python 发现优先级 -- **后端通信**:`backend_port` 决定 `ApiClient` 的连接地址 -- **模拟器检测**:初始化时若 `emulator` 字段为空,自动调用 `detectEmulator()` 填充 +配置改动完成后还应确认 YAML 保存失败和 JSON 保存失败都不会留下半提交状态。 diff --git a/docs/architecture/04-battle-plan.md b/docs/architecture/04-battle-plan.md index abc05f3..d33fd90 100644 --- a/docs/architecture/04-battle-plan.md +++ b/docs/architecture/04-battle-plan.md @@ -1,320 +1,208 @@ -# 出击计划系统 +# 方案与编队系统 -> 涉及文件:`src/model/PlanModel.ts` · `src/controller/plan/` · `src/view/plan/` · `src/model/MapDataLoader.ts` · `electron/services/CombatPlanCodec.ts` · `electron/services/CombatPlanRepository.ts` · `electron/services/PlanManagementService.ts` · `electron/services/RuntimePlanService.ts` · `electron/ipc/CombatPlanIpc.ts` · `resource/system_battle_plans/` · `resource/maps/` +> 主要目录:`src/model/PlanModel.ts`、`src/model/fleet/`、 +> `src/controller/plan/`、`electron/services/*Plan*.ts` -## 概述 +## 三类受管方案 -出击计划(Plan)是 AutoWSGR-GUI 的核心数据结构,定义了一次战斗出击的完整策略:打哪张地图、经过哪些节点、每个节点用什么阵型、是否夜战、迂回规则、修理策略等。 +| 类型 | 系统只读目录 | 用户可写目录 | +|---|---|---| +| 作战方案 | `resource/system_battle_plans/` | `userData/user_battle_plans/` | +| 编队方案 | `resource/system_team_plans/` | `userData/user_team_plans/` | +| 日常方案 | `resource/system_daily_plans/` | `userData/user_daily_plans/` | -计划以 YAML 文件存储,可通过 GUI 的可视化地图编辑器进行查看和修改。 +系统和用户方案通过同一管理 UI 展示,但来源 identity 必须保留。系统方案不能 +原地覆盖;编辑后保存为用户副本。 ---- +## 作战方案模型 -## 数据结构 - -### PlanData — 方案主体 +`PlanModel` 负责 Renderer 中的 YAML 解析、编辑和序列化。重要字段包括: ```typescript interface PlanData { - chapter: number; // 章节号 - map: number; // 地图号 - selected_nodes: string[]; // 选中的节点列表,如 ["A", "D", "G", "H"] - fight_condition?: 1|2|3|4|5; // 出击条件 - repair_mode?: number | number[]; // 修理策略(单值或按舰位数组) - fleet_id?: number; // 编队号 (1-4) - node_defaults?: NodeArgs; // 节点默认参数 - node_args?: Record; // 按节点覆盖的参数 - fleet_presets?: FleetPreset[]; // 内嵌的编队预设 - times?: number; // 执行次数 - stop_condition?: StopCondition; // 停止条件 + chapter: number | string; + map: number | string; + selected_nodes: string[]; + endpoint_nodes?: string[]; + node_defaults?: NodeArgs; + node_args?: Record; + fleet_presets?: FleetPreset[]; + times?: number; + gap?: number; + fleet_id?: number; + repair_mode?: number | number[]; } ``` -### NodeArgs — 节点参数 +### 未建模字段保留 + +`PlanModel.fromYaml(content, path)` 保存原始根对象 `rawRoot`。`toYaml()` 以 +`rawRoot` 为基底,仅覆盖 GUI 管理字段,因此后端扩展字段、根注释和 GUI 尚未 +认识的内容不会被整份重建丢失。 + +修改序列化时必须保留这一行为。不要用一个只包含 TypeScript 已知字段的新对象 +替换原始 YAML 根。 + +### 节点默认值 + +节点执行参数必须继承 `node_defaults`: ```typescript -interface NodeArgs { - formation?: 1|2|3|4|5; // 阵型 - night?: boolean; // 是否夜战 - proceed?: boolean; // 是否继续前进 - enemy_rules?: [string, string|number][]; // 索敌规则 +getNodeArgs(nodeId: string): NodeArgs { + const defaults = this.data.node_defaults ?? {}; + const overrides = this.data.node_args?.[nodeId] ?? {}; + const args = { ...defaults, ...overrides }; + if (this.data.endpoint_nodes?.includes(nodeId)) { + args.proceed = false; + } + return args; } ``` -**阵型映射**:`1=单纵阵` `2=复纵阵` `3=轮形阵` `4=梯形阵` `5=单横阵` +`node_args` 只覆盖节点特有字段;终点节点强制 `proceed = false`。 -**出击条件**:`1=稳步前进` `2=火力万岁` `3=全速前进` `4=跛行前进` `5=连续作战` +### 新计划路线 -**索敌规则**示例: -```yaml -enemy_rules: - - [AP >= 1, 4] # 有补给舰 → 梯形阵 - - [AP < 1, detour] # 无补给舰 → 迂回 -``` +`src/controller/plan/selectedNodes.ts` 固定以下规则: -### FleetPreset — 编队预设 +- 新计划只启用节点 `0`。 +- 后端执行的节点白名单始终包含 `0`。 +- 只有 `0` 时禁止入队,提示至少开启一个路线节点。 -```typescript -interface FleetPreset { - name: string; - ships: ShipSlot[]; // 6 个舰位:具体舰名、结构化规则或空位 -} +这区分“节点追踪尚未识别字母节点”和“用户尚未选择实际路线”。新增或切换地图 +时不能默认全选路线。 -interface ShipFilter { - name?: string; - search_name?: string; - nation?: string; // 国籍筛选 - ship_type?: string[]; // 舰型筛选 - candidates?: ShipRule[]; - min_level?: number; - max_level?: number; -} +## 地图 -type ShipSlot = string | ShipFilter | null; -``` +`MapDataLoader` 从 `resource/maps/` 加载普通和活动地图,缓存读取结果。 +`controller/plan/rendering.ts` 将地图、PlanModel 和节点编辑状态组合成 +`PlanPreviewViewObject`。 -编队预设支持**具体舰名**(如 `"85工程"`)和**模糊筛选**(如 `{nation: "苏联", ship_type: "dd"}`),后者在执行时由 `resolveFleetPreset()` 解析为实际舰船。 - -没有顶层 `name` 的槽位可以只包含结构化 `candidates`。此时候选项是平等 -替代项,GUI 不得把第一个候选提升为主选;只有旧版字符串候选列表才按旧 -格式推断第一项为主选。 - ---- - -## YAML 示例 - -```yaml -# 捞胖次 9-2 -chapter: 9 -map: 2 -selected_nodes: [A, D, G, H, M, O, E, K] -fight_condition: 1 -repair_mode: 2 -fleet_id: 1 -node_defaults: - formation: 4 - night: false - proceed: true -node_args: - A: - enemy_rules: - - [AP >= 1, 4] - - [AP < 1, detour] -fleet_presets: - - name: 三响岛风 - ships: [85工程, AIII, 岛风, 科罗廖夫, 列宁格勒, 伏尔加格勒] -``` +`MapView` 只渲染节点、连线和选择意图,不决定后端执行参数。地图同步由 +`scripts/sync-map-resources.js` 负责,修改资源后运行 `npm run check:maps`。 ---- +无效空地图数据不能覆盖已有有效快照。 -## 方案类型 +## Renderer 方案控制器 -| 类型 | `task_type` | 说明 | -|------|-------------|------| -| 常规出击 | 无 / `normal_fight` | 标准章节出击 | -| 战役 | `campaign` | 战役任务 | -| 演习 | `exercise` | 自动演习 | -| 决战 | `decisive` | 决战模式,含 `level1` / `level2` 目标舰队 | -| 活动 | `event_fight` | 活动地图出击 | +| 模块 | 所有权 | +|---|---| +| `PlanController` | 当前作战方案和地图 | +| `BattlePlanLoaderController` | 受管方案选择和筛选 | +| `PlanFleetPresetController` | 当前方案引用的编队列表 | +| `PlanManagementController` | 管理目录、关联和删除影响 | +| `FleetPlannerController` | 普通编队唯一草稿及文件 identity | +| `DecisivePlanController` | 决战独立草稿 | +| `presetFlow.ts` | 独立任务预设详情和执行 | +| `nodeEditor.ts` | 节点编辑意图 | +| `selectedNodes.ts` | 路线选择与执行校验 | ---- +Controller 负责 `file/source`、保存覆盖和持久化 DTO;View 只看到不透明 ID 和 +ViewObject。 -## 核心组件 +## 编队领域 -### PlanModel — 方案解析器 +`src/model/fleet/` 是编队业务规则边界: -| 方法 | 说明 | -|------|------| -| `fromYaml(yamlStr)` | 解析 YAML → `PlanData` 对象 | -| `toYaml(plan)` | 序列化 `PlanData` → YAML 字符串 | -| `getNodeArgs(plan, node)` | 获取指定节点的合并后参数(node_args 覆盖 node_defaults) | -| `mergeFleetPreset(plan, presetIndex)` | 将编队预设合并到方案的 fleet_id 中 | +| 文件 | 责任 | +|---|---| +| `FleetDraft.ts` | 普通舰队草稿、校验、与 `UserTeamPlan` 双向转换 | +| `DecisiveFleetDraft.ts` | 决战 level1/level2 草稿 | +| `FleetDraftEditor.ts` | 显式编辑意图的唯一应用入口 | +| `FleetPresetIdentity.ts` | 预设身份和引用 | +| `FleetRuleMapper.ts` | GUI 规则到 API 规则 | +| `ShipMatcher.ts` | 舰船匹配和展示标签 | -### MapDataLoader — 地图数据加载 +普通舰队与决战舰队不能共享同一草稿。它们只共享 +`ShipGalleryView` 的搜索、筛选、排序、增量渲染和卡片交互。 -地图 JSON 存放在 `resource/maps/` 目录,包含节点坐标、类型、连线等信息。 +### 舰位语义 -| 方法 | 说明 | -|------|------| -| `load(chapter, map)` | 通过 IPC 加载地图 JSON,返回 `MapData`,结果缓存 | -| `loadEx(chapter)` | 加载 Ex 章节地图 | +舰位可以是: -```typescript -interface MapData { - nodes: MapNode[]; // 节点列表 - edges: MapEdge[]; // 连线列表 -} +- 空位。 +- 明确主选舰船。 +- 带国籍、舰种、等级等约束的结构化主选。 +- `candidates` 备选规则。 +- candidate-only 槽位。 -interface MapNode { - id: string; // 节点标识,如 "A", "B" - x: number; // 坐标 X - y: number; // 坐标 Y - type: string; // 节点类型(战斗、资源、Boss) - detour?: boolean; // 是否可迂回 - night?: boolean; // 是否夜战节点 -} +candidate-only 槽位没有顶层 `name`,候选项地位平等;不能把第一项自动提升为 +主选。全局唯一分配优先保留主选,主选不可用后才使用候选并重新执行全局分配。 + +舰种只使用原生 0.3 定义的 22 个 canonical code 和业务组合 +`ss_or_ssg`。导巡 canonical code 为 `KP/kp`。舰种同步快照由: + +```powershell +npm run sync:fleet-types +npm run check:fleet-types ``` -### PlanController — 方案控制器 - -方案控制器位于 `src/controller/plan/`,拆分为多个模块: - -| 文件 | 职责 | -|------|------| -| `PlanController.ts` | 主控制器:持有当前方案状态,协调编辑、加载和保存 | -| `BattlePlanLoaderController.ts` | 持有受管方案选择器状态并协调读取、筛选和选择结果 | -| `PlanFleetPresetController.ts` | 管理当前方案引用的舰队预设,并生成只读选择器 ViewObject | -| `PlanManagementController.ts` | 持有计划目录状态并编排导出、删除、重命名和打开操作 | -| `planManagementViewObjects.ts` | 推导计划关联、任务组引用、缺失文件和删除影响 | -| `presetFlow.ts` | 任务预设的导入/查看/关闭/执行流程 | -| `nodeEditor.ts` | 从 UI 收集节点阵型/夜战/索敌规则并写回 PlanData | -| `rendering.ts` | 构建 `PlanPreviewViewObject`,协调地图数据和方案数据的合并 | - -### PlanPreviewView — 方案预览视图 (Facade) - -`PlanPreviewView` 作为 Facade 持有三个子视图,Controller 只与 Facade 交互: - -| 子视图 | 文件 | 职责 | -|--------|------|------| -| `MapView` | `view/plan/MapView.ts` | 地图节点/连线渲染、节点类型图标/名称常量 | -| `NodeEditorView` | `view/plan/NodeEditorView.ts` | 节点详细编辑器(阵形、夜战、继续条件) | -| `FleetPresetView` | `view/plan/FleetPresetView.ts` | 渲染只读预设清单并上报添加、删除和选择意图 | -| `BattlePlanLoaderView` | `view/plan/BattlePlanLoaderView.ts` | 受管方案列表、筛选、预览和舰队选择弹窗 | - -### Fleet Planner — 舰队计划 - -普通舰队草稿的唯一所有者是 `FleetPlannerController` 中的单个 `FleetDraft`。 -`FleetPlannerView` 只组合完整业务子视图并转发用户意图: - -| 子视图 | 职责 | -|--------|------| -| `FleetEditorView` | 舰位、主选/备选和拖拽编辑 | -| `FleetRuleView` | 舰种、国籍、等级等规则输入 | -| `FleetGalleryView` | 图鉴筛选、排序和展示缓存 | -| `TeamPlanLoaderView` | 系统/用户编队方案选择 | - -`FleetPlannerController` 通过 `FleetPlannerRepository` 读取舰船库和编队计划, -处理保存、同名覆盖确认、系统预设另存为用户副本,以及当前草稿的 -`file/source` identity。Controller 将持久化 `UserTeamPlan` 转换为 -`TeamPlanViewObject`,View 只接收不透明的计划 `id`,不知道真实文件路径。 - -`FleetDraft.fleetDraftToTeamPlan()` 和 `fleetDraftFromTeamPlan()` 是草稿与持久化 -DTO 的唯一双向转换边界。名称、舰种、等级和 candidate-only 规则在这里校验; -View 不拼装 YAML/IPC DTO,也不更新文件 identity。 - -`FleetDraftEditor.applyFleetDraftEdit()` 是普通舰队草稿的唯一编辑入口。 -`FleetEditorView` 和 `FleetRuleView` 只发送显式编辑意图,Controller 应用意图后 -重新发布快照。`PlanManagementController` 独立持有管理目录原始数据, -`buildPlanManagementViewObject()` 统一推导关联状态;`PlanManagementView` -只渲染清单并上报用户操作,不读取 Repository 或保存文件。 - -决战舰队由 `DecisivePlanController` 的独立 `DecisiveFleetDraft` 管理,不与普通 -舰队共享草稿。`DecisivePlanController` 负责设置和舰船库 Repository 调用, -`DecisivePlanView` 只通过显式 Host 上报编辑和保存意图。两类 View 都不直接 -访问 Model、IPC 或持久化。 - -Fleet 领域规则集中在 `src/model/fleet/`:`ShipMatcher` 负责匹配和显示标签, -`FleetRuleMapper` 负责 API rule 映射,`FleetDraftEditor` 负责编辑意图, -`FleetDraft` 负责草稿和持久化编队 DTO 的转换。跨层只读舰船目录和舰名规范化 -位于 `src/shared/`。candidate-only 槽位不会生成顶层 `name`,槽位约束、候选 -顺序和各候选的舰种/等级规则均保持。 - ---- - -## 主进程计划流水线 - -作战计划在主进程按职责拆分,IPC Adapter 不直接解析或写 YAML: - -| 模块 | 职责 | -|------|------| -| `CombatPlanIpc` | 处理本地 YAML 选择、冲突确认及受管计划 IPC 边界 | -| `PlanManagementService` | 管理页汇总、导入升级、保存、重命名、删除和运行时准备 | -| `LegacyPlanMigration` | 启动时升级旧计划、拆分舰队并迁移任务组引用 | -| `CombatPlanCodec` | YAML 根校验、未知字段保留、编队引用拆分与展开 | -| `CombatPlanRepository` | 系统/用户目录、受管路径和原子文件操作 | -| `RuntimePlanService` | 展开舰队引用并写入当前进程的临时执行目录 | - -系统计划从只读 `resource/system_battle_plans/` 读取,用户计划写入 Electron -`userData/user_battle_plans/`,用户舰队写入 -`userData/user_team_plans/`。两类计划均通过 GUI 统一清单加载和管理。 -执行前生成的临时计划位于 -`/AutoWSGR-GUI/runtime_battle_plans//`,文件序号只由 -`RuntimePlanService` 持有。 - -保存计划时,内嵌 `fleet_presets` 被拆成独立编队文件;运行时再按来源优先级 -展开引用。YAML 根对象、文件开头注释和不认识的字段必须保留。 - -旧计划迁移状态 v5 会扫描旧 `plans/`、`resource/user_*`,并递归识别安装目录中的有效 -计划 YAML。旧 YAML 经当前 Codec 升级并按 `bettle-*`、`team-*` 规范重命名 -后写入用户目录,源文件保留。不同内容的同名计划或舰队保存为“(旧版)” -副本,计划中的舰队引用和旧任务组的受管文件名同步更新。完成项按旧来源路径 -和内容哈希记录,实际输出文件名也写入迁移状态;第二次启动不会重复处理或把 -引用改回默认文件名。若同名目标与升级结果一致,则直接复用目标,并保留用户 -之后修改的编队。 - -计划加载浮窗提供“添加本地 YAML”。用户显式选择的 YAML 会经过同一套 -Codec 升级、拆分舰队并按规范命名后写入用户受管目录,源文件保持不变; -同名目标必须由用户确认后才会覆盖。外部文件路径不会进入任务队列,运行时 -仍只读取受管计划。计划页不再提供独立的手工转换入口。 - ---- - -## 数据流 - -```mermaid -flowchart TB - subgraph Input["输入"] - YAML["方案 YAML 文件"] - Builtin["系统方案
resource/system_battle_plans/"] - end - - subgraph Parse["解析"] - PM["PlanModel.fromYaml()"] - ML["MapDataLoader.load()"] - end - - subgraph Edit["编辑 & 预览"] - PC["PlanController"] - PV["PlanPreviewView"] - end - - subgraph Execute["执行"] - REQ["构建 TaskRequest"] - SCHED["Scheduler.addTask()"] - end - - YAML --> PM - Builtin --> PM - PM --> PC - PC --> ML - ML --> PC - PC -->|"ViewObject"| PV - PV -->|"用户编辑节点"| PC - PC -->|"更新 PlanData"| PM - PM -->|"toYaml()"| YAML - - PC -->|"executePreset()"| REQ - REQ --> SCHED +维护,API 契约测试会与 AutoWSGR 仓库交叉验证。 + +## Main 计划流水线 + +作战方案: + +```text +CombatPlanIpc + -> PlanManagementService / PlanExportService + -> CombatPlanCodec + -> CombatPlanRepository + -> AtomicFileStore / AppPaths ``` ---- +编队方案: + +```text +TeamPlanIpc + -> TeamPlanService + -> TeamPlanCodec + -> TeamPlanRepository +``` + +日常方案: + +```text +DailyPlanIpc + -> DailyPlanService + -> CombatPlanCodec / TaskPresetCodec +``` + +IPC 不直接解析 YAML 或决定命名。Codec 负责结构和兼容,Repository 负责来源目录 +和原子文件操作,Service 负责用例。 + +## 保存与执行 + +保存作战方案时,内嵌 `fleet_presets` 可拆成受管编队方案并建立引用。运行时: -## 系统方案 +1. `PlanManagementService` 读取受管方案。 +2. `CombatPlanCodec` 解析并解析编队引用。 +3. `RuntimePlanService` 展开成后端可读 YAML。 +4. 写入 `/AutoWSGR-GUI/runtime_battle_plans//`。 +5. Scheduler 只把临时运行路径发送给后端。 -`resource/system_battle_plans/` 当前包含 10 份周常预制方案: +运行时临时文件序号由 `RuntimePlanService` 独占,外部用户选择路径不能直接进入 +任务队列。 -| 分类 | 数量 | 示例 | -|------|------|------| -| 周常 | 10 | `bettle-周常-1-1.yaml` ~ `bettle-周常-10-1.yaml` | +## 导入、导出与删除 -活动地图资源由 `scripts/sync-map-resources.js` 同步;活动出征 YAML 只有在经过 -当前 Codec 校验并明确纳入发布资源后,才应写入系统计划清单。 +- 用户显式选择的本地 YAML 通过当前 Codec 升级后导入用户目录。 +- 源文件保持不变。 +- 同名覆盖需要用户确认。 +- 删除编队前必须计算作战方案引用。 +- 删除作战方案前必须计算任务组引用。 +- 系统来源只读,导出只选择用户方案。 ---- +方案管理 View 不读取 Repository;关联状态统一由 +`planManagementViewObjects.ts` 推导。 -## 与其他系统的关系 +## 验证 -- **任务调度**:方案通过 `controller/plan/presetFlow.ts` 的 `executePresetFlow()` 构建 `TaskRequest` 后交给 `Scheduler` -- **模板与任务组**:模板的 `planPaths` 引用方案文件;任务组 item 可以是 `kind: "plan"` 类型 -- **配置系统**:方案中的 `fleet_id` 和 `repair_mode` 可被配置页覆盖 -- **共享组件**:`view/shared/ShipAutocomplete.ts` 为模板舰名输入提供自动补全 +| 修改 | 最小验证 | +|---|---| +| PlanModel、路线和 YAML | `npm run test:api-contract`、`npm run test:main-services` | +| FleetDraft/舰种/候选 | `npm run test:fleet-domain`、`npm run check:fleet-types` | +| 方案管理删除 | `npm run test:plan-management-delete` | +| Main Codec/Repository/Service | `npm run test:main-services` | +| 迁移兼容 | `npm run test:migrations` | +| 方案/编队 View | `npm run test:build`,并在 Electron 中回归交互 | diff --git a/docs/architecture/05-template-and-taskgroup.md b/docs/architecture/05-template-and-taskgroup.md index 8efc36b..65ae58b 100644 --- a/docs/architecture/05-template-and-taskgroup.md +++ b/docs/architecture/05-template-and-taskgroup.md @@ -1,230 +1,186 @@ # 模板与任务组 -> 涉及文件:`src/model/TemplateModel.ts` · `src/controller/template/`(TemplateController · wizard · useTemplate · selectors · crud)· `src/model/TaskGroupModel.ts` · `src/controller/taskGroup/`(TaskGroupController · addItems · queueLoader · metaLoader · contextMenu)· `src/view/template/`(TemplateLibraryView · TemplateWizardView · SelectorDialog)· `src/view/taskGroup/TaskGroupView.ts` · `src/view/shared/ShipAutocomplete.ts` · `resource/builtin_templates.json` · `templates/templates.json` · `task_groups.json` +> 主要文件:`src/model/TemplateModel.ts`、`src/model/TaskGroupModel.ts`、 +> `src/controller/template/`、`src/controller/taskGroup/` -## 概述 +## 角色 -模板和任务组共同构成 AutoWSGR-GUI 的**任务组织层**: +- 模板描述可复用的任务参数。 +- 任务组保存一个有序任务列表。 +- 受管作战/日常方案保存实际 YAML。 +- `queueLoader.ts` 把上述引用转换成 SchedulerTask。 -- **模板 (Template)**:定义"怎么打"——一种可复用的任务配置,包含任务类型、方案路径、停止条件等 -- **任务组 (Task Group)**:定义"打哪些"——由多个任务项组成的有序列表,可一键加载到调度队列 - -``` -模板库 ──(引用)──→ 任务组 ──(加载)──→ 调度队列 - ↑ - 方案文件也可直接加入 +```text +方案 / 日常方案 / 模板 / 独立预设 + -> TaskGroupItem[] + -> queueLoader + -> Scheduler ``` ---- - -## 模板系统 - -### 模板类型 +## 模板兼容链路 -| 类型 | `type` 值 | 说明 | -|------|-----------|------| -| 常规出击 | `normal_fight` | 包含一到多个方案路径 (`planPaths`) | -| 演习 | `exercise` | 指定舰队 ID | -| 战役 | `campaign` | 战役类型名 | -| 决战 | `decisive` | 章节号 + 目标舰列表 | +模板来源: -### 模板结构 +| 来源 | 位置 | 权限 | +|---|---|---| +| 内置模板 | `resource/builtin_templates.json` | 只读 | +| 用户模板 | `userData/templates/templates.json` | 可写 | -```typescript -interface TaskTemplate { - id: string; // 唯一标识 - name: string; // 显示名称 - type: TemplateType; // normal_fight | exercise | campaign | decisive - builtin: boolean; // 是否内置 - planPaths?: string[]; // 方案文件列表(常规出击) - defaultTimes: number; // 默认执行次数 - defaultStopCondition?: StopCondition; - fleet_id?: number; // 演习用舰队 - chapter?: number; // 决战章节 - level1?: string[]; // 决战第一阶段目标舰 - level2?: string[]; // 决战第二阶段目标舰 - flagship_priority?: string[]; // 旗舰优先级 -} -``` +`TemplateModel` 合并两个来源,`TemplateController` 与以下模块提供用例: -### 存储 +- `crud.ts`:创建、编辑、删除、导入和导出。 +- `selectors.ts`:方案、舰队、战役和决战参数选择。 +- `useTemplate.ts`:实例化并加入任务组/队列。 +- `wizard.ts`:模板向导。 -| 来源 | 文件 | 可写 | -|------|------|------| -| **内置** | `resource/builtin_templates.json` | 只读 | -| **用户** | `userData/templates/templates.json` | 可读写 | +当前 Renderer 没有挂载独立模板库页面,但以下依赖仍存在: -`TemplateModel.init()` 在启动时合并两个来源,内置模板的 `builtin: true` 标识确保不可删除。 +- 旧任务组 `kind: "template"`。 +- 用户模板持久化和迁移。 +- 系统自动决战预设。 -### 内置模板 +因此不能把未挂载 UI 误判为死代码。只有完成数据迁移和执行链路替换后,才能 +删除模板模块。 -| ID | 名称 | 说明 | -|----|------|------| -| `builtin_farm_loot` | 刷胖次 | 6 个方案路径可选,`stopCondition: {loot_count_ge: 50}` | -| `builtin_weekly_v2` | 周常任务 | 10 个章节方案 | -| `builtin_exercise` | 自动演习 | 舰队 ID 可配置 | -| `builtin_campaign` | 战役 | 战役类型任务 | -| `builtin_decisive` | 决战 | 决战模式 | +## 任务组 v4 -### 创建向导 +`TaskGroupModel` 的当前持久化版本是 4: -向导逻辑位于 `controller/template/wizard.ts`,`TemplateController` 协调调用,视图由 `TemplateWizardView`(`view/template/TemplateWizardView.ts`)渲染: +```typescript +interface TaskGroupsData { + version: 4; + activeGroup: string; + groups: TaskGroup[]; +} -```mermaid -flowchart LR - S1["① 选择类型
normal_fight / exercise
campaign / decisive"] --> S2["② 配置详情
方案列表 / 舰队 / 章节"] - S2 --> S3["③ 默认参数
次数 / 间隔 / 停止条件"] - S3 --> S4["④ 命名
确认创建"] +interface TaskGroup { + name: string; + items: TaskGroupItem[]; +} ``` -### 模板视图 +任务组写入 `userData/task_groups.json`。`TaskGroupModel` 是 Renderer 中该文件 +的状态所有者,加载旧版本时迁移并立即保存 v4。 -模板视图位于 `src/view/template/`,拆分为三个组件: +### 四类条目 -| 组件 | 文件 | 职责 | -|--------|------|------| -| `TemplateLibraryView` | `TemplateLibraryView.ts` | 模板库卡片列表渲染(纯渲染,通过回调通知 Controller) | -| `TemplateWizardView` | `TemplateWizardView.ts` | 创建向导多步骤表单(含舰船自动补全) | -| `SelectorDialog` | `SelectorDialog.ts` | 通用选择器弹窗(单选/多选方案、战役、舰队等) | +```typescript +interface TaskGroupItem { + kind: 'plan' | 'preset' | 'template' | 'daily'; + label: string; + times: number; + + managedSource?: 'system' | 'user'; + managedFile?: string; + dailySource?: 'system' | 'user'; + dailyFile?: string; + dailyTaskType?: 'exercise' | 'campaign' | 'decisive'; + templateId?: string; + + forceRetry?: boolean; + allowPolling?: boolean; + fleetPresetIndex?: number; +} +``` -### 当前兼容状态 +其他按任务类型使用的覆盖字段包括 `campaignName`、`fleet_id`、`chapter` 和 +`useQuickRepair`。接口保留索引签名以无损保存未知扩展字段。 -`TemplateModel` 和 `TemplateController` 仍在应用启动时初始化,这是兼容性要求, -不是可删除的闲置代码: +| `kind` | 身份字段 | 读取方式 | +|---|---|---| +| `plan` | `managedSource + managedFile` | 受管作战方案 | +| `preset` | `managedSource + managedFile` | 独立任务预设 | +| `daily` | `dailySource + dailyFile` | 受管日常方案 | +| `template` | `templateId` | TemplateModel | -- 旧任务组的 `kind: "template"` 条目仍通过 `TemplateModel` 解析和执行。 -- 自动决战的 `system_preset` 仍读取内置模板 `builtin_decisive_6`。 -- 用户模板文件仍需加载、保存和迁移,不能在没有数据迁移的情况下删除。 +`path` 仅用于旧数据兼容和迁移,新数据不要持久化绝对路径。 -当前计划页没有挂载独立模板库导航和 `template-library-*` 容器,因此 -`TemplateLibraryView` 的列表与创建/导入入口不会作为独立页面显示。模板系统的 -新入口和创建流程由后续界面方案承接;在新方案完成数据与执行迁移前,必须保留 -上述 Model、Controller 和 `kind: "template"` 执行链路。 +## Model 行为 ---- +`TaskGroupModel` 提供: -## 任务组系统 +- 组的新增、更新、重命名、删除和激活。 +- 条目新增、删除、移动和次数更新。 +- v1~v3/无版本数据到 v4 的规范化。 +- 旧系统方案文件名映射。 +- 未知组字段和条目字段保留。 +- `beforeunload` 保存。 -### 数据结构 +迁移失败不能删除原任务组。旧安装目录中的任务组由 Main 的 +`UserDataMigrationService` 合并到 userData,同名不同内容使用“(旧版)”保留。 -```typescript -// task_groups.json v3 结构 -{ - version: 3, - activeGroup: string; // 当前激活的组名 - groups: TaskGroup[]; -} +## Controller 结构 -interface TaskGroup { - name: string; // 组名 - items: TaskGroupItem[]; // 有序任务项列表 -} +| 文件 | 责任 | +|---|---| +| `TaskGroupController.ts` | 组状态、CRUD、ViewObject 和事件协调 | +| `TaskListLoaderController.ts` | 选择和批量载入任务列表 | +| `DailyTaskLoaderController.ts` | 日常方案分类、参数和提交 | +| `addItems.ts` | 添加 plan/preset/daily/template | +| `queueLoader.ts` | 解析条目并创建 SchedulerTask | +| `managedPlanReader.ts` | 统一读取受管作战和日常文件 | +| `metaLoader.ts` | 加载条目展示摘要 | +| `contextMenu.ts` | 编辑、复制、删除和打开来源 | -interface TaskGroupItem { - kind: 'plan' | 'preset' | 'template'; - managedSource?: 'system' | 'user'; // 当前受管计划来源 - managedFile?: string; // 当前受管计划文件名 - path?: string; // 只为旧格式兼容保留 - templateId?: string; // 模板 ID(kind=template) - times: number; // 执行次数 - label: string; // 显示标签 - fleet_id?: number; // 可选舰队覆盖 - forceRetry?: boolean; // 可选强制重试 - allowPolling?: boolean; // 可选同优先级轮询 - fleetPresetIndex?: number; // 可选的编队预设覆盖 -} -``` +View 只展示 TaskGroup ViewObject 并上报意图,不直接读取方案文件。 -### 持久化 +## 入队流程 -`TaskGroupModel` 通过 IPC 读写 `task_groups.json`。加载 v1、v2 或无版本旧 -数据时,会把旧 `path` 推断为 `managedSource + managedFile`,并将 v1.4.1 -的四个 `活动20260730-*.yaml` 引用映射到当前 `bettle-*.yaml` 系统计划。 -迁移保留原始 `path` 和未知条目字段,保存时写出 v3。文件写入仍由唯一 Model -所有者控制,失败时不会删除旧文件。 +```mermaid +flowchart TD + A["选择任务组条目"] --> B{"kind"} + B -->|plan/preset| C["读取 managedSource/managedFile"] + B -->|daily| D["读取 dailySource/dailyFile"] + B -->|template| E["TemplateModel.getTemplate"] + C --> F["Codec/PlanModel 解析"] + D --> G["按 exercise/campaign/decisive 构建请求"] + E --> H["应用模板与条目覆盖"] + F --> I["执行前路线与编队校验"] + G --> J["Scheduler.addTask"] + H --> J + I --> J +``` -启动时还会把当前安装目录根部的旧 `task_groups.json` 合并到 `userData`。 -目标文件已存在时不会覆盖:新组直接追加,同名但内容不同的组以“(旧版)” -重命名保留。完成记录包含旧文件来源和内容摘要,重复启动不会重复导入。 +`queueLoader.ts` 负责把: -| 方法 | 说明 | -|------|------| -| `load()` | 从文件加载所有组 | -| `save()` | 写回文件(在 `beforeunload` 时自动调用) | -| `addGroup(name)` | 新建空组 | -| `removeGroup(name)` | 删除组 | -| `renameGroup(old, new)` | 重命名 | -| `addItem(groupName, item)` | 添加任务项 | -| `removeItem(groupName, index)` | 移除任务项 | -| `reorderItem(groupName, from, to)` | 拖拽重排 | +- `times` +- `forceRetry` +- `allowPolling` +- `fleetPresetIndex` +- 终点和战果要求 +- 修理与停止条件 -### TaskGroupView — 任务组视图 +传入 Scheduler。不要在 View 或 TaskGroupModel 中复制这套执行转换。 -UI 包含: -- **组选择器**:下拉菜单 + 新建/重命名/删除按钮 -- **任务列表**:每项显示名称、次数、类型标签 -- **操作**: - - 拖拽排序 - - 右键上下文菜单(编辑/删除/复制) - - "全部加载到队列" / "单项加载" 按钮 - - 任务组导入/导出 +## 日常方案 ---- +日常方案与作战方案使用独立目录和 identity: -## 任务组控制器 +- `exercise` +- `campaign` +- `decisive` -任务组控制器位于 `src/controller/taskGroup/`,拆分为多个模块: +`DailyTaskLoaderController` 决定哪些字段可编辑。任务组保存实际来源和文件,不把 +日常方案硬编码成模板 ID。用户决战计划也通过 Main 的 +`DailyPlanService` 保存到用户日常目录。 -| 文件 | 职责 | -|------|------| -| `TaskGroupController.ts` | 主控制器:绑定视图事件,协调下属模块 | -| `addItems.ts` | 向任务组添加项目:从当前方案/文件/预设添加 | -| `queueLoader.ts` | 加载任务组到调度队列:逐项构建 TaskRequest → `Scheduler.addTask()` | -| `metaLoader.ts` | 加载任务项元数据(方案标题、模板名称) | -| `contextMenu.ts` | 右键上下文菜单:编辑/删除/复制任务项 | +## 文件身份原则 ---- +1. 任务组引用受管来源和文件名,不保存运行时临时路径。 +2. 系统和用户同名文件仍是两个来源,不能只按 basename 判断。 +3. 方案重命名/迁移必须同步任务组引用。 +4. 缺失引用应保留并显示错误,不能静默改成另一个同名文件。 +5. 读取失败时不生成残缺 SchedulerTask。 -## 加载到调度队列 +## 验证 -从任务组加载任务到 `Scheduler` 的流程: +修改任务组或模板至少执行: -```mermaid -sequenceDiagram - participant User as 用户 - participant TGCtrl as TaskGroupController - participant TGModel as TaskGroupModel - participant TplModel as TemplateModel - participant PlanModel as PlanModel - participant Sched as Scheduler - - User->>TGCtrl: 点击"全部加载" - TGCtrl->>TGModel: getActiveGroup() - TGModel-->>TGCtrl: items[] - - loop 逐项处理 - alt kind = plan - TGCtrl->>TGCtrl: bridge.readFile(path) - TGCtrl->>PlanModel: fromYaml(content) - PlanModel-->>TGCtrl: PlanData - TGCtrl->>TGCtrl: 构建 NormalFightReq - else kind = template - TGCtrl->>TplModel: getTemplate(templateId) - TplModel-->>TGCtrl: TaskTemplate - TGCtrl->>TGCtrl: 构建对应类型的 TaskRequest - end - TGCtrl->>Sched: addTask(name, type, request, USER_TASK, times) - end - - TGCtrl->>TGCtrl: renderMain() - Note over Sched: 队列已填充,等待用户点击"开始执行" +```powershell +npm run test:migrations +npm run test:scheduler-domain +npm run test:build ``` ---- - -## 与其他系统的关系 - -- **出击计划**:`kind: "plan"` 类型的任务项直接引用方案 YAML 文件 -- **任务调度**:加载后的任务以 `USER_TASK` 优先级进入 `Scheduler` 队列 -- **配置系统**:`CronScheduler` 的 `autoNormalFight` 开关可以自动执行当前活跃任务组 -- **后端通信**:模板中的配置最终被构建为 `TaskRequest`,通过 `ApiClient` 发送到后端 +涉及日常计划 Main Service 时增加 `npm run test:main-services`;涉及 API 请求时 +增加 `npm run test:api-contract`。 diff --git a/docs/architecture/06-backend-communication.md b/docs/architecture/06-backend-communication.md index 6b147a3..5d8cb8e 100644 --- a/docs/architecture/06-backend-communication.md +++ b/docs/architecture/06-backend-communication.md @@ -1,356 +1,219 @@ -# 后端通信 +# 通信边界 -> 涉及文件:`electron/preload.ts` · `electron/main.ts`(组合根)· `electron/ipc/` · `electron/services/` · `src/model/ApiClient.ts` · `src/types/api.ts` · `src/types/ipc.ts` +## 两条通信链路 -## 概述 +```text +Renderer + -> window.electronBridge + -> preload / ipcRenderer + -> Electron Main IPC + -> Service -AutoWSGR-GUI 的通信分为**两层**: - -```mermaid -graph LR - subgraph Renderer["渲染进程"] - View["View / Controller"] - Api["ApiClient"] - end - - subgraph Main["Electron 主进程"] - IPC["IPC Adapter
electron/ipc/"] - Service["Service
electron/services/"] - end - - subgraph Py["Python 后端"] - REST["REST API"] - WS["WebSocket"] - end - - View -->|"contextBridge"| IPC - IPC --> Service - Service -->|"fs / spawn / exec"| Main - Api -->|"HTTP fetch"| REST - Api -->|"WebSocket"| WS +Renderer + -> ApiClient + -> HTTP / WebSocket + -> AutoWSGR Python 后端 ``` -| 层 | 路径 | 用途 | -|----|------|------| -| **IPC** | 渲染进程 ↔ Electron 主进程 | 文件 I/O、系统对话框、环境管理、后端进程控制 | -| **HTTP/WS** | 渲染进程 ↔ Python 后端 | 游戏操作、任务执行、实时日志 | - ---- - -## IPC 通信层 +IPC 处理桌面系统能力;HTTP/WS 处理游戏自动化。不要把文件系统能力加到 Python +API,也不要让 Main 代替 Renderer 转发所有后端请求。 -external 后端模式使用用户指定的本地 AutoWSGR 仓库。路径不存在、仓库结构 -无效或缺少 `autowsgr/server/main.py` 时启动明确失败,不会静默回退到 managed -后端;检测和实际启动使用同一解释器与环境变量。 +## Electron IPC -### 暴露机制 +### 契约层 -`preload.ts` 通过 Electron 的 `contextBridge.exposeInMainWorld()` 安全地将 IPC 方法暴露到 `window.electronBridge` 对象上。渲染进程只能通过预定义的方法调用主进程,无法直接访问 Node.js API。 +`src/types/ipc.ts` 定义: -### Adapter 组织 - -`main.ts` 只创建 Service 并调用注册函数。通道按领域位于 -`electron/ipc/`:`FileIpc`、`DeviceIpc`、`ConfigurationIpc`、 -`TeamPlanIpc`、`CombatPlanIpc`、`DailyPlanIpc`、`ShipLibraryIpc`、 -`EnvironmentIpc`、`BackendIpc` 和 `UpdaterIpc`。 +- `ElectronBridge` +- 配置、窗口、更新、ADB DTO +- 作战、编队、日常方案 DTO +- 舰船资料库 DTO +- 通用文件操作结果 -Adapter 允许处理 Electron 对话框、同步 `event.returnValue` 和边界异常转换, -但不得实现配置默认值、YAML 解析、路径规则或进程状态。同步 getter 使用 -`ipcMain.on`,其余调用使用 `ipcMain.handle`;通道名、参数顺序和返回结构属于 -兼容性契约,由 `scripts/test-main-ipc.js` 自动与 `preload.ts` 对照。 +这是 preload、Renderer Adapter 和调用方共同依赖的类型来源。新增 IPC 需要同步: -### API 分类 +```text +src/types/ipc.ts + -> electron/preload.ts + -> electron/ipc/Ipc.ts + -> src/adapter/IpcAdapter.ts + -> 调用方 + -> scripts/tests/test-main-ipc.js +``` -#### 文件操作 +### Preload -| 方法 | 参数 | 返回 | 说明 | -|------|------|------|------| -| `readFile(path)` | 受控路径 | `string` | 只读取 `userData` 或打包后的 `resource/` | -| `saveFile(path, content)` | 受控路径 + 内容 | `void` | 只写入 `userData` | -| `appendFile(path, content)` | 受控路径 + 内容 | `void` | 只追加到 `userData` | -| `openFileDialog(filters, defaultDir?)` | 文件过滤器 | `{path, content} \| null` | 打开文件选择对话框 | -| `saveFileDialog(name, content, filters)` | 默认名 + 内容 | `string \| null` | 保存文件对话框 | -| `openDirectoryDialog(title?)` | 对话框标题 | `string \| null` | 文件夹选择 | +`electron/preload.ts` 是唯一允许直接使用 `ipcRenderer` 的 Renderer 桥接文件: -通用文件 IPC 先拒绝 `..`、UNC、盘符相对路径和 NTFS ADS,再使用 -`path.resolve` 与 `realpath` 展开现有祖先中的目录链接,最后验证 canonical -目标仍位于对应允许根目录。安装资源目录只读;renderer 不能通过 -`saveFile` 或 `appendFile` 修改它。文件选择和保存对话框属于用户在当前操作 -中明确确认的外部文件能力,不复用通用文件 IPC 的路径参数。 +```typescript +contextBridge.exposeInMainWorld('electronBridge', electronBridge); +``` -#### 作战计划管理 +Renderer 其他模块不得导入 Electron。同步 getter 使用 `sendSync`,命令和文件 +操作使用 `invoke`,Main 事件使用 `ipcRenderer.on`。 -| 方法 | 返回 | 说明 | -|------|------|------| -| `getPlanManagement()` | `PlanManagementResult` | 汇总系统和用户计划清单 | -| `exportUserPlans(selections)` | `UserPlanExportResult` | 将勾选的用户计划按类型打包为 ZIP | -| `importLocalCombatPlan()` | `PlanFileOperationResult` | 选择本地 YAML,升级后写入用户受管目录 | -| `readManagedCombatPlan(source, file)` | `PlanFileOperationResult` | 读取受管计划并生成运行时展开文件 | -| `saveManagedCombatPlan(...)` | `PlanFileOperationResult` | 保存计划并拆分内嵌舰队 | +### Renderer Adapter -`importLocalCombatPlan()` 的文件路径仅在主进程对话框和计划服务之间传递。 -渲染进程不能提交外部绝对路径;冲突覆盖也由主进程在同一次用户操作中确认。 -`exportUserPlans()` 只接收计划类型和文件名,主进程从用户受管目录重新定位并 -校验文件;系统预设不能导出,ZIP 输出路径由保存对话框授权。 +`src/adapter/IpcAdapter.ts` 不把完整 bridge 到处传播,而是按用例裁剪: -#### 日常计划管理 +| 契约 | 用途 | +|---|---| +| `StartupGateway` | 路径、环境、后端和更新启动流程 | +| `ConfigurationGateway` | 配置事务 | +| `SettingsGateway` | 设置页系统操作 | +| `ManagedCombatPlanRepository` | 作战方案 | +| `ScheduledTaskRepository` | 自动任务计划 | +| `FleetPlannerRepository` | 编队与舰船资料库 | +| `DecisivePlanRepository` | 决战设置 | +| `MigrationConflictRepository` | 迁移冲突 | +| `FileRepository` | 受限文本读写 | -| 方法 | 说明 | -|------|------| -| `listDailyPlans()` | 合并只读系统计划和用户计划,同名时用户版本优先 | -| `readDailyPlan(source, file)` | 按受管来源和文件名读取日常计划 | -| `getDailyDecisivePlan(chapter)` | 读取用户优先的指定章节决战计划 | -| `getSystemDailyDecisivePlan(chapter)` | 读取只读系统决战计划 | -| `saveDailyDecisivePlan(settings)` | 写入 `userData/user_daily_plans/` 并同步决战设置 | +Controller 依赖窄接口,View 不依赖任何 Adapter。 -`DailyPlanService` 只接受演习、战役和决战三类计划。Renderer 只提交受管来源、 -文件名或结构化设置,不能把任意路径传给日常计划 IPC。 +### Main IPC -#### 路径查询 +| 文件 | 领域 | +|---|---| +| `FileIpc.ts` | 受限文件、对话框、目录打开 | +| `ConfigurationIpc.ts` | GUI 设置、窗口、Python/CUDA 配置 | +| `CombatPlanIpc.ts` | 作战方案管理、导入、导出和运行时准备 | +| `TeamPlanIpc.ts` | 编队方案 | +| `DailyPlanIpc.ts` | 日常方案 | +| `ShipLibraryIpc.ts` | 舰船资料库和更新 | +| `EnvironmentIpc.ts` | Python 环境检查和安装 | +| `DeviceIpc.ts` | 模拟器与 ADB | +| `BackendIpc.ts` | 后端启动和 setup | +| `MigrationConflictIpc.ts` | 迁移冲突复核 | +| `UpdaterIpc.ts` | GUI 更新 | -| 方法 | 返回 | 说明 | -|------|------|------| -| `getAppRoot()` | `string` | 应用工作目录 | -| `getPlansDir()` | `string` | 方案文件目录 | -| `getConfigDir()` | `string` | 配置文件目录 | -| `listPlanFiles()` | `{name, file}[]` | 列出方案文件 | -| `openFolder(path)` | `void` | 仅打开 `userData` 内经过 canonical 校验的目录 | +IPC 文件只处理参数、结果和异常边界。路径、安全、YAML、持久化和更新策略放入 +Service/Codec/Repository。 -#### 环境管理 +## 文件能力与安全 -| 方法 | 返回 | 说明 | -|------|------|------| -| `checkEnvironment()` | `{pythonCmd, pythonVersion, missingPackages, allReady}` | 检查 Python 环境 | -| `installDeps()` | `{success, output}` | 安装 Python 依赖 | -| `installPortablePython()` | `{success}` | 安装便携版 Python | -| `checkUpdates()` | - | preload 兼容入口,主进程 handler 当前停用 | -| `pullUpdates()` | - | preload 兼容入口,主进程 handler 当前停用 | +通用文件 IPC 经过 `SafePathService` 和 `SecureFileService`: -#### Python 路径配置 +- 读取限定在 `userData` 和资源目录。 +- 写入限定在 `userData`。 +- 拒绝 `..`、UNC、盘符跳转和 NTFS ADS。 +- 检查符号链接/junction 的真实目标。 +- 文件对话框返回的外部文件是单次用户授权,不扩大通用访问根。 +- 写入使用 `AtomicFileStore`。 -| 方法 | 说明 | -|------|------| -| `getPythonPath()` | 同步获取用户配置的 Python 路径(`null` = 自动检测) | -| `setPythonPath(path)` | 设置 Python 路径并清除缓存 | -| `validatePython(path)` | 验证指定路径的 Python 版本是否兼容 | +异常应直接返回或抛出,不得因为文件/IPC/页面异常而触发业务 fallback。 -#### 后端控制 +## HTTP 客户端 -| 方法 | 说明 | -|------|------| -| `startBackend()` | 启动 Python 后端子进程 | -| `detectEmulator()` | 自动检测模拟器 | -| `checkAdbDevices()` | 查询 ADB 设备列表 | -| `runSetup()` | 运行 setup.bat 脚本 | - -#### GUI 自动更新 - -| 方法 | 说明 | -|------|------| -| `checkGuiUpdates()` | 检查 GUI 应用更新 | -| `downloadGuiUpdate()` | 下载更新包 | -| `installGuiUpdate()` | 安装更新并重启 | -| `onUpdateStatus(callback)` | 监听更新状态变化 | - -`checkGuiUpdates()` 返回严格三态,不允许把异常当成“最新版”: +`src/model/ApiClient.ts` 使用 `src/adapter/ApiAdapter.ts` 提供的传输接口。默认: -```typescript -type GuiUpdateCheckResult = - | { status: 'available'; version: string } - | { status: 'up-to-date' } - | { status: 'error'; message: string }; +```text +http://localhost: +ws://localhost: ``` -`GuiUpdatePolicy` 根据当前应用版本选择并校验频道:稳定版 `X.Y.Z` 使用 -`latest`,Alpha 版 `X.Y.Z-alpha` 或 `X.Y.Z-alpha.N` 使用 `alpha`, -Beta 版 `X.Y.Z-beta.N` 使用 `beta`,开发版 `X.Y.Z-dev` 或 -`X.Y.Z-dev.N` 使用 `dev`。候选版本不属于当前频道时检查失败,不允许回退 -读取其他频道清单。 +这是 Renderer 客户端地址;Uvicorn 在 Main 启动的 Python 进程内监听 +`127.0.0.1:`。 -安装更新前,`UpdaterIpc` 必须等待共享的 `stopBackend()` 完成。关闭流程依次 -调用后端 `/api/system/stop`、终止服务进程树、等待 `close`;超时后才强制 -终止并再次等待。任何阶段无法确认进程树退出都会返回错误并阻止 -`quitAndInstall()`,避免任务运行中安装或 Windows 文件锁损坏升级。 +当前 HTTP 端点按代码分组: -#### 事件监听 +| 领域 | 端点 | +|---|---| +| 系统 | `/api/system/start`、`stop`、`status` | +| 任务 | `/api/task/start`、`stop`、`status` | +| 远征 | `/api/expedition/check` | +| 游戏状态 | `/api/game/context`、`acquisition` | +| 建造/奖励/食堂 | `/api/build/*`、`/api/reward/collect`、`/api/cook` | +| 修理/解体 | `/api/repair/*`、`/api/destroy` | +| 健康检查 | `/api/health` | -| 方法 | 事件 | 说明 | -|------|------|------| -| `onBackendLog(callback)` | `backend-log` | 接收 Python 后端日志 | -| `onSetupLog(callback)` | `setup-log` | 接收 setup.bat 输出 | +具体请求类型定义在 `src/types/api.ts`。新增字段先确认 AutoWSGR 正式 API 契约, +再修改 GUI DTO 和契约 fixture。 -#### 同步方法 +### TaskRequest -| 方法 | 说明 | -|------|------| -| `getAppVersion()` | 同步获取应用版本号 | -| `getBackendPort()` | 同步获取后端端口 | -| `setBackendPort(port)` | 设置后端端口 | +`TaskRequest` 是联合类型: ---- +- `normal_fight` +- `event_fight` +- `campaign` +- `exercise` +- `decisive` -## HTTP REST API - -`ApiClient` 封装与 Python 后端的所有 HTTP 通信。 - -### 基础配置 - -- 默认地址:`http://localhost:8438` -- 端口可通过 `gui_settings.json` 配置 -- 所有请求/响应使用 JSON 格式 - -### 统一响应结构 - -```typescript -interface ApiResponse { - success: boolean; - data?: T; - message?: string; - error?: string; -} -``` +Scheduler 对多轮任务每轮只发送一次后端请求。GUI 的 `remainingTimes` 和 +`logicalId` 不应混入后端业务 DTO。 -### 端点列表 +`ApiClient.taskStart()` 保留必要的旧后端兼容重试。新增兼容分支时必须限定明确 +错误条件,不能对 Controller、页面导航或 OCR 引擎异常做静默 fallback。 -#### 系统管理 +## WebSocket -| 方法 | 端点 | 超时 | 说明 | -|------|------|------|------| -| POST | `/api/system/start` | 300s | 连接模拟器 + 启动游戏 | -| POST | `/api/system/stop` | - | 优雅停止当前系统任务并释放后端资源 | -| GET | `/api/system/status` | - | 系统状态查询 | -| GET | `/api/system/emulator/devices` | 15s | ADB 设备列表 | +| 路径 | 内容 | +|---|---| +| `/ws/logs` | 后端日志 | +| `/ws/task` | 任务进度与完成 | -#### 任务执行 +类型位于 `src/types/api.ts`: -| 方法 | 端点 | Body | 说明 | -|------|------|------|------| -| POST | `/api/task/start` | `TaskRequest` | 启动战斗/演习/战役/决战 | -| POST | `/api/task/stop` | - | 停止当前任务 | -| GET | `/api/task/status` | - | 当前任务状态 | +- `WsLogMessage` +- `WsTaskUpdate` +- `WsTaskCompleted` -`TaskRequest` 为联合类型,支持 5 种任务: +`ApiClient` 负责连接、3 秒重连和消息解析;`SchedulerBinder` 与 +`SchedulerRuntimeTracker` 解释业务日志并更新 Scheduler/UI。 -```typescript -type TaskRequest = - | NormalFightReq // {type: 'normal_fight', plan, times, gap} - | EventFightReq // {type: 'event_fight', plan, times} - | CampaignReq // {type: 'campaign', campaign_name, times} - | ExerciseReq // {type: 'exercise', fleet_id} - | DecisiveReq // {type: 'decisive', chapter, level1, level2} +```mermaid +sequenceDiagram + participant Backend + participant ApiClient + participant Scheduler + participant Binder + participant View + + Backend->>ApiClient: /ws/logs + ApiClient->>Binder: 日志 + Binder->>Scheduler: 停止条件/节点/战果 + Binder->>View: 日志与运行状态 + Backend->>ApiClient: /ws/task task_completed + ApiClient->>Scheduler: handleTaskFinished + Scheduler->>Binder: 单轮/逻辑事件 + Binder->>View: ViewObject ``` -#### 远征 - -| 方法 | 端点 | 说明 | -|------|------|------| -| POST | `/api/expedition/check` | 收取所有已完成的远征 | +WebSocket 完成事件的后端 `task_id` 与 GUI 的轮次 `id/logicalId` 是不同层的身份, +不能混用。 -#### 游戏状态 +## 后端来源与正式契约 -| 方法 | 端点 | 返回数据 | 说明 | -|------|------|----------|------| -| GET | `/api/game/context` | 编队/资源/远征/建造槽 | 全局游戏状态 | -| GET | `/api/game/acquisition` | 战利品/舰船 OCR 数量 | 出征面板读数 | +Main 启动前由 `BackendRuntimeContract` 验证: -#### 操作端点 +- 实际导入的 `autowsgr` 位于声明的唯一来源。 +- `AUTOWSGR_OCR_GPU_MODE` 行为可用。 +- `AUTOWSGR_SAVE_IMAGES` 行为可用。 +- `autowsgr.server.main:app` 是可调用 ASGI 应用。 -| 方法 | 端点 | 说明 | -|------|------|------| -| POST | `/api/build/collect` | 收取建造 | -| POST | `/api/build/start` | 开始建造 | -| POST | `/api/reward/collect` | 收取每日奖励 | -| POST | `/api/cook` | 食堂烹饪 | -| POST | `/api/repair/bath` | 浴室快速修理 | -| POST | `/api/repair/ship` | 单船泡澡修理 | -| POST | `/api/destroy` | 解体舰船 | +通过后才使用 Uvicorn 绑定 `127.0.0.1:`。GUI 不修改 AutoWSGR 私有类或 +日志实现。 -#### 健康检查 +## 错误边界 -| 方法 | 端点 | 说明 | -|------|------|------| -| GET | `/api/health` | 后端健康状态、运行时间 | +以下错误直接失败并向上报告: ---- +- preload/IPC 不可用。 +- 文件路径或结构非法。 +- Controller、页面导航或 View 生命周期异常。 +- 后端来源或 ASGI 契约不符。 +- OCR 引擎异常。 +- 方案 Codec/Repository 失败。 -## WebSocket 通信 +业务 fallback 只用于已有明确语义的兼容场景,例如受控的旧 API 请求格式。 -`ApiClient` 维护两条 WebSocket 连接,支持断线自动重连(3 秒延迟): +## 验证 -### 连接 - -| 路径 | 用途 | -|------|------| -| `ws://localhost:8438/ws/logs` | 实时日志流 | -| `ws://localhost:8438/ws/task` | 任务进度 + 完成通知 | - -### 消息类型 - -```typescript -// 日志消息 (/ws/logs) -interface WsLogMessage { - type: 'log'; - timestamp: string; - level: string; - channel: string; - message: string; -} - -// 任务进度更新 (/ws/task) -interface WsTaskUpdate { - type: 'task_update'; - task_id: string; - status: string; - progress?: { current: number; total: number; node: string | null }; -} - -// 任务完成 (/ws/task) -interface WsTaskCompleted { - type: 'task_completed'; - task_id: string; - success: boolean; - result?: TaskResult; - error?: string; -} +```powershell +npm run test:main-ipc +npm run test:api-contract +npm run test:main-services ``` -### 数据流 - -```mermaid -sequenceDiagram - participant Backend as Python 后端 - participant WsLog as ws/logs - participant WsTask as ws/task - participant Api as ApiClient - participant Sched as Scheduler - participant App as AppController - participant UI as MainView - - Backend->>WsLog: 日志消息 - WsLog->>Api: onLog - Api->>Sched: 解析 [UI] 行 → StopConditionChecker - Api->>App: 日志回调 - App->>UI: appendLog() - - Backend->>WsTask: 进度更新 - WsTask->>Api: onTaskUpdate - Api->>Sched: onProgressUpdate - Sched->>App: 回调 - App->>UI: renderMain() - - Backend->>WsTask: 任务完成 - WsTask->>Api: onTaskCompleted - Api->>Sched: handleTaskFinished() - Sched->>Sched: 后触发 / 重试 / 消费下一个 - Sched->>App: 回调 - App->>UI: renderMain() -``` - ---- - -## 与其他系统的关系 - -- **任务调度**:`Scheduler` 持有 `ApiClient` 实例,通过 REST API 发起/停止任务,通过 WebSocket 接收进度和完成通知 -- **配置系统**:`backend_port` 配置决定 `ApiClient` 的连接地址 -- **环境管理**:所有环境相关操作(Python 检测/安装、后端启停)通过 IPC 层完成 -- **出击计划**:方案数据被构建为 `CombatPlanReq` 嵌入 `TaskRequest` 中 +修改 preload 后还应运行 `npm run test:build`,确认编译产物、桥接和打包入口仍 +正确连接。 diff --git a/docs/architecture/07-environment-management.md b/docs/architecture/07-environment-management.md index 243fe37..49d2298 100644 --- a/docs/architecture/07-environment-management.md +++ b/docs/architecture/07-environment-management.md @@ -1,343 +1,209 @@ -# 环境管理 +# 环境与运行生命周期 -> 涉及文件:`electron/pythonEnv/` · `electron/services/PythonEnvironmentService.ts` · `electron/services/CudaEnvironmentService.ts` · `electron/services/AdbService.ts` · `electron/services/BackendService.ts` · `electron/services/BackendShutdownService.ts` · `electron/ipc/EnvironmentIpc.ts` · `electron/ipc/DeviceIpc.ts` · `electron/ipc/BackendIpc.ts` · `electron/emulatorDetect.ts` +> 主要目录:`electron/pythonEnv/`、`electron/services/Backend*.ts`、 +> `electron/main.ts` -## 概述 +## Python 环境 -环境管理负责三个核心任务: +GUI 只接受 Python 3.12 或 3.13。查找顺序: -1. **Python 环境**:发现/安装/验证 Python,管理依赖包 -2. **模拟器检测**:通过 Windows 注册表自动识别已安装的模拟器 -3. **后端生命周期**:启动/停止 Python 后端子进程 +1. `gui_settings.json.python_path` 指定解释器。 +2. GUI 安装目录内置 `python/python.exe`。 +3. 系统 `python`/`python3`,再解析真实 `sys.executable`。 ---- +查找缓存位于 `electron/pythonEnv/context.ts`,切换 Python、模式或路径时由配置 +服务清除。不要在 Service 中建立第二份 Python 缓存。 -## Python 环境管理 +## `pythonEnv` 模块 -Python 环境管理位于 `electron/pythonEnv/` 子目录,采用依赖注入模式,通过 `index.ts` 聚合导出: +| 文件 | 责任 | +|---|---| +| `context.ts` | 环境依赖和唯一查找缓存 | +| `finder.ts` | 解释器发现与版本校验 | +| `environment.ts` | Python 来源、安装目标和后端来源描述 | +| `dependencies.ts` | GUI/后端/资料库 Python 依赖清单 | +| `envCheck.ts` | 完整检查和 `.env_ready` | +| `installer.ts` | pip、便携 Python 和依赖安装 | +| `backendRequirement.ts` | 打包后端发行清单 | +| `backendContractProbe.ts` | AutoWSGR 正式运行契约探测 | +| `updater.ts` | managed 后端兼容检查和固定提交安装 | +| `cuda.ts` | CUDA 环境变量和 PyTorch 能力 | +| `utils.ts` | `_pth`、pip、环境变量和路径辅助 | +| `index.ts` | 对 Main Service 的聚合出口 | -| 文件 | 职责 | -|------|------| -| `backendRequirement.ts` | 读取包内后端发行清单,安装和自动更新共用同一契约 | -| `backendContractProbe.ts` | 在隔离检查进程中验证外部后端是否支持 GUI 所需的正式运行时契约 | -| `context.ts` | 共享上下文与缓存状态(`PythonEnvContext` 接口、缓存变量) | -| `dependencies.ts` | 集中声明 GUI 运行时和舰船资料库工具所需的 Python 依赖 | -| `finder.ts` | Python 可执行文件发现(用户配置 → 便携版 → 系统全局) | -| `environment.ts` | 统一描述解释器、源码、安装目标和运行路径 | -| `cuda.ts` | 校验 CUDA 路径并在同一个 Python 环境上叠加 CUDA 变量 | -| `envCheck.ts` | 环境验证主流程(VC++ Redistributable 检查、标记文件管理、依赖包验证) | -| `installer.ts` | Python 安装与依赖管理(pip 设置、autowsgr 安装) | -| `updater.ts` | managed 模式后端契约检查与固定提交安装 | -| `utils.ts` | 工具函数与共享接口(路径工具、环境变量、pip 命令、.pth 文件处理) | -| `index.ts` | 聚合导出 | +IPC 通过 `PythonEnvironmentService` 使用这些能力。 -`PythonEnvironmentService` 是 IPC 使用的无状态用例入口,负责保持 Python -路径校验结果、环境检查和安装返回结构。它不复制 `pythonEnv/context.ts` 的缓存; -Python 发现状态仍只有一个所有者。`CudaEnvironmentService` 负责 Toolkit、 -Runtime DLL 和当前 Python/PyTorch CUDA 能力检测。 +## managed 与 external -### 发现优先级 +| 模式 | 后端来源 | 依赖位置 | +|---|---|---| +| `managed` | `build/backend-distribution.json` 指定的 GUI 受控 AutoWSGR | `{appRoot}/python/site-packages` | +| `external` + 内置 Python | 用户指定本地 AutoWSGR 仓库 | GUI `site-packages` + 仓库 | +| `external` + 外部 Python | 用户指定仓库和解释器 | 解释器自身环境 + 仓库 | -`finder.ts` 中的 `findPython()` 按以下顺序查找可用的 Python: +external 仓库无效时直接失败,不能回退 managed,也不能把 GUI site-packages +偷偷混入外部解释器。 -```mermaid -flowchart TD - A["① 用户配置路径
gui_settings.json → python_path"] -->|存在且版本匹配| Z["使用该 Python"] - A -->|不存在/版本不匹配| B["② 本地便携版
{appRoot}/python/python.exe"] - B -->|存在且版本匹配| Z - B -->|不存在| C["③ 系统 Python
python / python3"] - C -->|找到且版本匹配| D["解析真实路径
python -c 'import sys; print(sys.executable)'"] - D --> Z - C -->|未找到| E["返回 null"] -``` - -**版本要求**:仅接受 Python **3.12** 或 **3.13**。 - -**Shim 解析**:pyenv 等工具使用 `.bat` shim 文件,Node.js `spawn()` 无法直接执行。通过 Python 自身的 `sys.executable` 获取真实 `.exe` 路径。 - -**缓存**:发现结果缓存在 `context.ts` 的 `PythonEnvContext` 中,用户切换路径时调用 `clearPythonCache()` 清除。 +发布流程把 Alpha 后端固定到 `ShiinaKuroko/AutoWSGR` 的指定分支提交。安装后 +清除 `.env_ready`,首次启动按 `forceUpdateOnInstall` 完成受控更新和复核。 -### 统一环境描述 +## `.env_ready` -`environment.ts` 生成的同一个环境描述同时用于依赖安装、依赖检查和 -`BackendService` 启动。安装规则如下: +`{appRoot}/.env_ready` 缓存已验证的环境身份,包括: -| 后端模式 | Python 来源 | 依赖安装位置 | 启动路径 | -|---------|-------------|-------------|---------| -| managed | 任意兼容解释器 | `{appRoot}/python/site-packages` | GUI site-packages | -| external | GUI 内置 Python | `{appRoot}/python/site-packages` | GUI site-packages + 本地仓库 | -| external | 用户选择的外部 Python | 该解释器自身环境 | 解释器自身环境 + 本地仓库 | +- Python 路径和版本。 +- AutoWSGR 版本/来源。 +- managed/external 模式和仓库。 +- 依赖安装目标。 -external 仓库无效时,环境检查、依赖安装和后端启动都会明确失败,不会回退到 -managed 后端,也不会把 GUI 的依赖目录混入外部解释器。 +快速路径仍会检查解释器、环境身份和后端契约。配置、安装目标或后端来源变化后 +删除标记;失败时不写完成标记,使下次启动继续检查。 -### 便携版 Python +## CUDA 与 OCR -应用打包时内置 Python 3.12.8 embed 发行版,位于 `{appRoot}/python/`。 +配置: -**安装流程** (`installer.ts` 中的 `installPortablePython()`): -1. 检查 `python/python.exe` 是否存在 -2. 若存在:确保 `._pth` 配置正确 → 检查 pip → 安装 pip(如缺失) -3. 若不存在:在线下载 Python embed zip → 解压 → 安装 pip +- `ocr_gpu_mode`: `auto | cpu | cuda` +- `cuda_path` -**PTH 文件处理** (`utils.ts` 中的 `ensurePthFile()`): -- Python embed 版默认禁用 `import site` -- 此函数取消注释 `python312._pth` 中的 `import site` 行 -- 添加 `site-packages` 路径条目 -- 使 `site.addsitedir()` 可用于加载 `.pth` 文件 +启动前使用同一 Python 探测 `torch.cuda.is_available()`。最终只向后端传明确 +模式 `cpu` 或 `cuda`: -### 环境检查 +- 强制 `cpu` 始终使用 CPU。 +- `auto` 有 CUDA 时用 CUDA,否则用 CPU。 +- 强制 `cuda` 但探测失败时直接报错。 -`envCheck.ts` 中的 `checkEnvironment()` 检测 Python 和依赖包是否就绪,并包含 VC++ Redistributable 检查: +正式环境变量: -```mermaid -flowchart TD - A["checkEnvironment()"] --> B{".env_ready 标记存在?"} - B -->|是| C["读取缓存: pythonCmd, version, autowsgrVersion"] - C --> D{"Python、环境身份和
GUI 运行契约仍有效?"} - D -->|是| E["自动模式检查固定提交契约
不兼容时安装兼容版本"] - E --> F["返回 {allReady: true}
⚡ 快速路径"] - D -->|否| G["删除标记, 走完整路径"] - - B -->|否| H["findPython()"] - H --> I{"找到 Python?"} - I -->|否| J["返回 {allReady: false, pythonCmd: null}"] - I -->|是| K["resolvePythonEnvironment()"] - K --> L["按环境描述检查
uvicorn/fastapi/autowsgr"] - L --> M{"所有依赖就绪?"} - M -->|否| N["返回 {allReady: false, missingPackages}"] - M -->|是| O["managed 自动模式校验
固定提交运行契约"] - O --> P["写入 .env_ready 标记"] - P --> F +```text +AUTOWSGR_OCR_GPU_MODE=cpu|cuda +AUTOWSGR_SAVE_IMAGES=true|false ``` -### .env_ready 标记文件 +GUI 不通过 monkey patch 控制 OCR。 -缓存环境状态,避免每次启动的重复检查: +## ADB 与模拟器 -```json -{ - "pythonCmd": "C:\\path\\to\\python.exe", - "pythonVersion": "Python 3.12.8", - "autowsgrVersion": "2.1.9", - "environmentIdentity": "{\"startupMode\":\"managed\",...}" -} -``` - -- **路径**:`{appRoot}/.env_ready` -- **失效时机**:安装依赖后删除;Python、模式、仓库或安装目标变化后失效 -- **验证条件**:Python 文件存在、环境身份一致、依赖可导入,且 AutoWSGR - 提供 GUI 所需运行契约和活动资源 +| 能力 | 所有者 | +|---|---| +| 注册表检测模拟器 | `electron/emulatorDetect.ts` | +| ADB 路径、devices、connect/disconnect | `AdbService` | +| Renderer IPC | `DeviceIpc` | -### 依赖安装 +当前检测 MuMu、雷电和 BlueStacks。后端启动前读取用户配置的 serial 并连接。 +退出时只停止 GUI 内置 ADB server,不应杀死系统或其他工具的 ADB。 -`installer.ts` 中的 `installDependencies()`: -1. 删除 `.env_ready` 标记 -2. 确保 pip 可用 (`ensurePip()`) -3. 按统一环境描述安装: - ``` - managed/内置 Python: - pip install --target {appRoot}/python/site-packages ... +NSIS 覆盖安装也只按完整可执行路径停止安装目录中的 `adb.exe`。 - external + 外部 Python: - -m pip install ... - ``` +## 后端启动 -外部模式选中的 Python、pip 安装目标、依赖检查解释器和后端启动解释器必须一致。 +`BackendService.startBackend()` 顺序: -### managed 后端兼容更新 +```text +解析 PythonEnvironment + -> 构建 PATH/CUDA/ADB 环境 + -> 探测 torch CUDA + -> 选择明确 OCR 模式 + -> 验证 AutoWSGR 实际导入来源 + -> 验证正式环境变量行为 + -> 验证 autowsgr.server.main:app 是 ASGI + -> spawn python -X utf8 -c + -> uvicorn 绑定 127.0.0.1: +``` -`updater.ts` 中的 `autoUpdateAutowsgr()` 不追随 PyPI 最新版本,而是验证当前 -AutoWSGR 是否具备 GUI 所需的运行时接口和活动资源: +`BackendService` 独占活动子进程引用。`BackendIpc` 不能保存另一个进程状态。 -1. 单次隔离检查读取本地版本、活动资源和正式运行契约。 -2. 已兼容时保留当前后端,不做无意义重装。 -3. 不兼容时安装包内 `backend-distribution.json` 固定的 AutoWSGR 提交。 -4. 安装后重新验证运行契约、活动资源、FastAPI 和 Uvicorn。 +stdout/stderr 日志由 Main 过滤 access/debug 噪声后发送 Renderer;原始进程错误 +仍应保留足够上下文用于启动失败诊断。 -Alpha 包清单固定到 `ShiinaKuroko/AutoWSGR` 的 `ShiinaKuroko` 分支提交; -发布工作流会在打包时自动读取该分支的最新提交; -安装脚本会删除 `.env_ready`,使安装后的首次完整检查使用 -`--force-reinstall` 强制更新。 -强制更新成功后重新写入标记,后续启动恢复普通兼容检查;失败时不写标记,下次 -启动继续重试。external 模式始终使用用户选择的本地仓库。 +## Main 启动生命周期 ---- +主进程顺序不可随意交换: -## 模拟器检测 +```text +SingleInstanceService.acquire() + -> 处理 pending GUI update + -> 旧安装迁移选择 + -> initPythonEnv() + -> initBackend() + -> 初始化作战/编队用户目录 + -> 初始化舰船资料库 + -> v6 预设库存迁移 + -> v7 旧方案迁移 + -> 迁移报告与冲突状态 + -> registerUpdaterIpc() + -> WindowService.createWindow() +``` -`detectEmulator()` 通过 Windows 注册表自动识别已安装的模拟器: +次实例立即退出并唤醒已有窗口。更新安装中的次实例只显示更新提示,不能执行 +配置迁移、pip 或创建旧窗口。 -`DeviceIpc` 只保持通道和异常边界。ADB 可执行文件选择、设备列表解析以及 -connect/disconnect 的结果由 `AdbService` 统一处理。 +## 迁移 -### 支持的模拟器 +`MigrationStateStore` 独占: -| 模拟器 | 检测方式 | 默认 ADB 串口 | -|--------|----------|---------------| -| **MuMu 12** | 注册表 `Uninstall` 项的 `UninstallString` | `127.0.0.1:16384` | -| **雷电 (LDPlayer)** | 注册表 `HKLM\SOFTWARE\leidian\InstallDir` | `127.0.0.1:5555` | -| **BlueStacks** | 注册表 `HKLM\SOFTWARE\BlueStacks_nxt*\InstallDir` | `127.0.0.1:5555` | +```text +userData/.migration-state.json +``` -### 返回结构 +当前主阶段: -```typescript -interface EmulatorDetectResult { - type: string; // "MuMu" | "雷电" | "蓝叠" - path: string; // 模拟器安装路径 - serial: string; // ADB 连接串口 - adbPath: string; // 模拟器自带的 ADB 路径 -} -``` +- `UserDataMigrationService`:用户数据迁移版本 6。 +- `migration:v6:preset-inventory:complete`:预设库存。 +- `LegacyPlanMigration`:旧方案版本 7。 +- `migration:v7:legacy-plans:complete`:旧方案分类。 +- 每个旧安装来源的 `started`、`configuration-complete`、`complete`。 -### 检测流程 - -```mermaid -flowchart TD - A["detectEmulator()"] --> B["reg query Uninstall /s"] - B --> C{"有 MuMu 条目?"} - C -->|是| D["提取 shell/ 路径
组装 ADB 串口"] - D --> Z["返回 MuMu 结果"] - C -->|否| E["reg query leidian"] - E --> F{"有 InstallDir?"} - F -->|是| G["返回雷电结果"] - F -->|否| H["reg query BlueStacks_nxt"] - H --> I{"有 InstallDir?"} - I -->|是| J["返回蓝叠结果"] - I -->|否| K["返回 null"] -``` +规则: ---- +1. `mergeCompleted()` 合并旧 marker,不覆盖已完成项。 +2. 所有文件原子写入成功后才完成阶段。 +3. 失败时只重试未完成阶段/文件。 +4. 源文件不删除、不修改。 +5. 同名不同内容以“(旧版)”保留。 +6. 引用随实际迁移目标同步。 +7. 实际发生迁移时显示总数、成功数和失败项。 -## 后端生命周期 +新的配置转换必须使用独立 stage key,不能复用或覆盖已有完成标记。 -### 启动流程 +## GUI 更新 -`startBackend()` (`electron/services/BackendService.ts`) 负责启动 Python 后端。 -`BackendIpc` 只转换启动结果,后端子进程引用仍只存在于 `BackendService`。 +`GuiUpdatePolicy` 支持严格版本/频道: -```mermaid -sequenceDiagram - participant Main as 主进程 - participant FS as 文件系统 - participant ADB as ADB - participant Py as Python 子进程 +| 版本 | 频道 | +|---|---| +| `X.Y.Z` | `latest` | +| `X.Y.Z-alpha[.N]` | `alpha` | +| `X.Y.Z-beta.N` | `beta` | +| `X.Y.Z-dev[.N]` | `dev` | - Main->>Main: ensurePthFile()
确保 ._pth 配置正确 - Main->>Main: findPython()
获取 Python 路径 +当前发布 workflow 只构建 Alpha。客户端仍校验候选版本属于当前频道,更新检查 +返回 `available | up-to-date | error`,网络错误不能显示为最新版。 - Main->>Main: 构建 bootstrap 代码 - Note right of Main: sys.path.insert(0, localSite)
site.addsitedir(localSite)
uvicorn.run(..., port=8438) +下载完成后用户选择立即重启或下次启动。pending 更新必须在任何迁移和窗口创建前 +处理。 - Main->>FS: 读取 usersettings.yaml
提取 emulator.serial - Main->>ADB: adb connect {serial} - Note right of ADB: MuMu 多开需要主动连接 +## 停止与退出 - Main->>Py: spawn(pythonCmd, ['-X', 'utf8', '-c', bootstrap]) - Note right of Py: env: PYTHONUTF8=1, PATH+=adb/ +`BackendShutdownService` 的固定顺序: - Py->>Py: uvicorn 启动 FastAPI - Py-->>Main: stdout/stderr 日志流 - Main->>Main: 解析 loguru 格式日志
过滤 DEBUG + access log
转发到渲染进程 -``` +1. `POST /api/system/stop`,等待正式清理。 +2. Windows 使用 `taskkill /PID /T` 终止进程树。 +3. 等待 `close`。 +4. 超时后 `/T /F` 强制终止并再次等待。 +5. 仍无法确认退出时抛错,保留活动进程引用。 -### 启动参数 - -| 参数 | 说明 | -|------|------| -| `-X utf8` | 启用 UTF-8 模式 | -| `-c bootstrap` | 内联 Python 代码(注入 site-packages 路径 + 启动 uvicorn) | - -### 环境变量 - -| 变量 | 值 | 说明 | -|------|-----|------| -| `PYTHONUTF8` | `1` | 强制 UTF-8 编码 | -| `PYTHONIOENCODING` | `utf-8` | I/O 编码 | -| `PATH` | 原始 PATH + `{appRoot}/adb/` | 内置 ADB 可被后端发现 | - -### 日志转发 - -后端 stdout/stderr 输出经过处理后转发到渲染进程: -1. 按 loguru 格式(`HH:mm:ss.SSS | LEVEL | module | message`)识别新日志行 -2. 过滤掉 `DEBUG` 级别日志及其多行续行 -3. 过滤掉 uvicorn access log(`GET /api/...` 格式) -4. 通过 `mainWindow.webContents.send('backend-log', line)` 转发 - -### 停止 - -`stopBackend()` 与更新安装共用 `BackendShutdownService`,关闭步骤固定为: - -1. `POST /api/system/stop`,给运行中的任务最多 35 秒执行正式清理。 -2. 若服务进程仍在,Windows 执行 `taskkill /PID /T` 终止完整进程树; - 其他平台发送 `SIGTERM`。 -3. 等待进程 `close` 最多 5 秒,确认操作系统已释放进程资源和文件锁。 -4. 超时后 Windows 执行 `/T /F`,其他平台发送 `SIGKILL`,再等待 5 秒。 -5. 仍无法确认退出时抛出错误,不清空活动进程引用。 - -后端停止接口失败只会进入进程树终止回退,不会假装关闭成功。应用退出时 -`before-quit` 会阻止立即退出并等待完整流程;失败时应用保持运行并显示错误。 -更新安装也必须等待同一流程,失败时取消 `quitAndInstall()`。 - ---- - -## 启动时序(完整视角) - -主进程先获取 Electron 单实例锁。只有持锁进程会迁移旧配置、创建窗口并进入下列 -环境流程;重复启动只会唤醒已有窗口,不会并发执行 pip。 - -```mermaid -sequenceDiagram - participant Main as Electron 主进程 - participant App as AppController - participant IPC as IPC Bridge - participant PyEnv as pythonEnv/ - participant Back as BackendService - participant Py as Python 后端 - - Main->>Main: requestSingleInstanceLock() - alt 重复启动 - Main->>Main: 退出次实例并聚焦已有窗口 - else 主实例 - Main->>Main: 迁移旧配置并创建窗口 - App->>IPC: checkEnvironment() - IPC->>PyEnv: checkEnvironment() - - alt .env_ready 有效 - PyEnv-->>App: {allReady: true} - else 环境缺失 - PyEnv-->>App: {allReady: false} - App->>IPC: installPortablePython() - IPC->>PyEnv: 安装便携版 Python + pip - App->>IPC: installDeps() - IPC->>PyEnv: pip install autowsgr - App->>IPC: checkEnvironment() (重试) - PyEnv-->>App: {allReady: true} - end - - App->>IPC: startBackend() - IPC->>Back: startBackend() - Back->>Back: resolvePythonEnvironment() - Back->>Back: ADB connect - Back->>Py: spawn 子进程 - - App->>App: waitForBackendAndConnect() - loop 轮询直到就绪 - App->>Py: GET /api/health - end - - App->>Py: POST /api/system/start - Note over App,Py: 连接模拟器 + 启动游戏 - - App->>App: scheduler.start() - App->>App: cronScheduler.start() - end -``` +Main `before-quit` 再停止内置 ADB,成功后才调用 `app.quit()`。GUI 更新安装复用 +同一资源停止流程。 ---- +## 验证 -## 与其他系统的关系 +```powershell +npm run test:python-environment +npm run test:backend-distribution +npm run test:main-services +npm run test:migrations +``` -- **配置系统**:`gui_settings.json` 的 `python_path` 影响 Python 发现优先级;`backend_port` 决定 uvicorn 监听端口 -- **后端通信**:`startBackend()` 的成功是 `ApiClient` 能连接的前提 -- **任务调度**:`Scheduler.start()` 在后端就绪后调用 `POST /api/system/start` 完成最终连接 +修改安装/更新资源后还应执行 `npm run pack` 和 +`npm run test:release-package`。 diff --git a/docs/architecture/08-dev-setup.md b/docs/architecture/08-dev-setup.md index 9fbdc47..00731e0 100644 --- a/docs/architecture/08-dev-setup.md +++ b/docs/architecture/08-dev-setup.md @@ -1,295 +1,201 @@ -# 开发环境搭建 +# 开发、构建与验证 -> 涉及文件:`package.json` · `tsconfig.json` · `scripts/` · `build/installer.nsh` +## 前置环境 -## 前置要求 +- Windows 10/11 x64 +- Node.js 22 +- npm +- 仅在 external 后端或后端联调时需要本地 Python 3.12/3.13 -| 工具 | 版本 | 说明 | -|------|------|------| -| **Node.js** | 18+ | 推荐使用 LTS 版本 | -| **Python** | 3.12 / 3.13 | 用于运行 AutoWSGR 后端 | -| **模拟器** | MuMu 12 / 雷电 / 蓝叠 | 运行战舰少女R | +```powershell +npm ci +npm start +``` ---- +`npm start` 会完整构建后启动 Electron。项目没有 HMR,源码修改后需要重新构建 +并启动。 -## 快速开始 +## 开发源与生成物 -```powershell -# 1. 克隆仓库 -git clone https://github.com/yltx/AutoWSGR-GUI.git -cd AutoWSGR-GUI +| 开发源 | 生成物 | +|---|---| +| `src/view/html/**/*.html` | `src/view/index.html` | +| `src/view/styles/**/*.scss` | `src/view/styles/styles.css` | +| `electron/**/*.ts`、`src/**/*.ts` | `dist/**` | +| 编译后的 Renderer 模块 | `dist/renderer.bundle.js` | -# 2. 安装 Node 依赖 -npm install +规则: -# 3. 开发模式运行 -npm run dev -``` +1. 不手工修改 `src/view/index.html`。 +2. 不手工修改 `src/view/styles/styles.css`。 +3. 不提交或依赖手工修改的 `dist/**`。 +4. HTML/SCSS/TypeScript 改动后运行 `npm run build`。 +5. 生成的 HTML/CSS 是 Electron 运行入口,需要和源码一起提交。 ---- - -## NPM Scripts - -| 命令 | 说明 | -|------|------| -| `npm run dev` | 编译 TypeScript + esbuild 打包 + 启动 Electron(开发日常使用) | -| `npm run build` | 仅编译(`tsc` + `esbuild`),不运行 | -| `npm run test:main-services` | 构建后验证主进程 Service 的路径、配置、计划、环境和资料库行为 | -| `npm run test:main-ipc` | 构建后核对 preload 与 IPC Adapter 的通道及同步/异步契约 | -| `npm run test:legacy-config-upgrade` | 验证旧设置、决战配置和任务组升级 | -| `npm run test:legacy-plan` | 验证旧计划、舰队拆分和受管引用迁移 | -| `npm run test:api-contract` | 构建后验证 GUI 与 AutoWSGR API 契约 fixture | -| `npm run test:fleet-domain` | 验证舰队草稿、候选槽位和持久化 DTO 往返 | -| `npm run test:scheduler-domain` | 验证逻辑任务身份、后触发、取消和排序 | -| `npm run test:ocr-log-analyzer` | 验证独立 OCR 日志提取、复核和纠错规则生成工具 | -| `npm run test:python-environment` | 验证 managed/external Python、CUDA 和后端环境一致性 | -| `npm run test:backend-distribution` | 验证 Alpha 包的后端来源及强制更新策略 | -| `npm run test:task-group-migration` | 构建后验证任务组迁移和往返兼容 | -| `npm start` | 等同于 `build` + `electron .`(含 chcp 65001) | -| `npm run dist` | 完整生成 Alpha NSIS 安装包 | -| `npm run pack` | 编译 + `electron-builder --dir`(生成目录,不打安装包) | -| `npm run prepare-python` | 单独下载便携版 Python | -| `npm run prepare-adb` | 单独下载 ADB 工具 | - ---- - -## 构建流程 - -### 编译管线 +## 构建管线 ```mermaid flowchart LR - TS["TypeScript 源码
electron/ + src/"] -->|"tsc"| JS["dist/
CommonJS 输出"] - SRC["src/view/*.ts
(渲染进程)"] -->|"esbuild
(scripts/bundle.js)"| Bundle["dist/renderer.bundle.js
浏览器兼容单文件"] + HTMLSrc["src/view/html/index.html"] -->|"build-view-html.js"| HTML["src/view/index.html"] + SCSS["src/view/styles/main.scss"] -->|"sass"| CSS["src/view/styles/styles.css"] + TS["electron + src TypeScript"] -->|"tsc"| Dist["dist/electron + dist/src"] + Dist -->|"esbuild"| Bundle["dist/renderer.bundle.js"] ``` -- **tsc**:将所有 TypeScript 编译到 `dist/` 目录(主进程 + 渲染进程) -- **esbuild**:将渲染进程代码打包为单个浏览器兼容的 `renderer.bundle.js` +`scripts/build-view-html.js` 递归展开 ``,拒绝目录逃逸和循环 +include。`--check` 只检查生成文件是否过期。 + +`scripts/bundle.js` 从编译后的 +`dist/src/controller/app/AppController.js` 打包浏览器 IIFE。 + +## 常用命令 + +| 命令 | 用途 | +|---|---| +| `npm run build` | HTML、CSS、TypeScript 和 Renderer Bundle 完整构建 | +| `npm run build:html` | 生成 `src/view/index.html` | +| `npm run build:css` | 生成 `styles.css` | +| `npm start` | 完整构建并启动 Electron | +| `npm run dev` | 清理、构建并启动 Electron | +| `npm run pack` | 生成 unpacked electron-builder 目录 | +| `npm run dist` | 准备 Python/ADB 并生成 Alpha NSIS 安装包 | +| `npm run prepare-python` | 下载便携 Python | +| `npm run prepare-adb` | 下载 ADB | +| `npm run check:fleet-types` | 校验 22 舰种快照 | +| `npm run check:maps` | 校验地图资源同步 | + +## 测试布局 + +```text +scripts/tests/ +├─ fixtures/ # 版本化输入语料 +├─ main-services/ # Main Service 分领域测试 +├─ test-support/ # 共享测试目录辅助 +├─ test-build-output-contract.js +├─ test-renderer-dom-contract.js +├─ test-renderer-architecture.js +├─ test-main-services.js +├─ test-main-ipc.js +├─ test-migrations.js +├─ test-api-contract.js +├─ test-fleet-domain.mjs +├─ test-scheduler-domain.mjs +└─ ... +``` -### 构建脚本 +构建/测试脚本统一放在 `scripts/`,测试实现放在 `scripts/tests/`。不要把新测试 +重新放回 `scripts/test-*.js` 根目录旧布局。 + +## 测试命令 + +| 命令 | 覆盖范围 | +|---|---| +| `npm run test:build` | 构建、生成文件、DOM、架构和打包白名单总门禁 | +| `npm run test:renderer-contract` | HTML 新鲜度、重复/缺失 DOM ID | +| `npm run test:architecture-boundaries` | Controller/View 依赖和共享图库释放 | +| `npm run test:settings` | Electron 设置页渲染、收集和持久化 | +| `npm run test:scheduler-domain` | 调度身份、排序、重试、取消、额度 | +| `npm run test:fleet-domain` | 编队草稿、候选和 DTO 往返 | +| `npm run test:main-services` | Main 路径、配置、方案、环境和资料库 | +| `npm run test:main-ipc` | preload、IPC 通道和同步/异步契约 | +| `npm run test:migrations` | userData、旧方案、任务组和真实语料 | +| `npm run test:api-contract` | GUI 与 AutoWSGR API/舰种契约 | +| `npm run test:python-environment` | managed/external、CUDA 和环境一致性 | +| `npm run test:backend-distribution` | 打包后端来源和更新策略 | +| `npm run test:release-package` | 安装包运行时和资源 | +| `npm run test:event-resources` | 活动资源和地图加载 | +| `npm run test:map-intel` | 地图情报同步、校验和原子快照 | + +`test:build` 证明产品可以正确生成和连接,不代替业务领域测试或 Electron 交互 +回归。 + +## PR CI + +`.github/workflows/pull-request-checks.yml` 当前有两个 job: + +### Windows 构建与迁移 + +```text +npm ci +npm run test:build +node scripts/tests/test-scheduler-domain.mjs +node scripts/tests/test-migrations.js +``` -#### `scripts/bundle.js` +`test:build` 已完成构建,后两个步骤直接运行编译产物测试,避免重复构建。 -用 esbuild 将 `src/` 下的渲染进程代码打包为 `dist/renderer.bundle.js`,配置 `platform: 'browser'`,排除 Node.js 内置模块。 +### Linux 后端舰种契约 -#### `scripts/prepare-python.js` +```text +Checkout GUI + AutoWSGR +Node 22 + Python 3.12 + uv +npm ci +uv sync --project AutoWSGR --no-dev +npm run build +node scripts/tests/test-main-services.js +npm run check:fleet-types +node scripts/tests/test-api-contract.js +``` -下载 Python 3.12.8 embed 发行版,解压到 `python/` 目录。在 `npm run dist` 时自动调用。 +通过 `AUTOWSGR_REPO` 和 `AUTOWSGR_PYTHON` 指向候选 AutoWSGR 仓库。 -#### `scripts/prepare-adb.js` +## 发布 -下载 Android Platform-Tools(含 `adb.exe`),解压到 `adb/` 目录。在 `npm run dist` 时自动调用。 +`.github/workflows/release.yml` 当前: ---- +1. 接受 `v*` tag 或手动触发。 +2. 只允许 `X.Y.Z-alpha` 或 `X.Y.Z-alpha.N`。 +3. 固定 Alpha 后端分支的当前提交。 +4. `npm run dist`。 +5. `npm run test:release-package`。 +6. 校验只生成 `alpha.yml`。 +7. 发布 NSIS exe、blockmap 和 Alpha 更新清单。 -## 打包配置 +`build/electron-builder.alpha.cjs` 将输出放到 `release/alpha`,并打包 +`build/backend-distribution.json`。 -### electron-builder +## 安装包边界 -打包配置在 `package.json` 的 `build` 字段中: +`package.json.build.files` 只包含: -```json -{ - "appId": "com.autowsgr.gui", - "productName": "AutoWSGR-GUI", - "directories": { "output": "release" }, - "files": [ - "dist/**/*", - "src/view/index.html", - "src/view/styles/styles.css" - ], - "extraResources": [ - { - "from": "resource", - "to": "resource", - "filter": [ - "**/*", - "!user_battle_plans/**/*", - "!user_team_plans/**/*" - ] - }, - { "from": "setup.bat", "to": "setup.bat" }, - { "from": "tools/ship_library", "to": "tools/ship_library" } - ], - "extraFiles": [ - { "from": "python", "to": "python" }, - { "from": "redist", "to": "redist" }, - { "from": "adb", "to": "adb" } - ] -} +```text +dist/electron/**/* +dist/src/shared/**/* +dist/renderer.bundle.js +src/view/index.html +src/view/styles/styles.css ``` -**打包目标**:Windows Alpha NSIS 安装包: - -- `release/alpha/AutoWSGR-GUI-Setup-x.x.x-alpha.exe` - -**包含内容**: -- `dist/` — 编译后的 JS -- `src/view/` — HTML/CSS -- `resource/` — 内置方案、模板、地图、舰船资料库种子和只读迁移快照 -- `tools/ship_library/` — 仅打包资料库更新器白名单文件 -- `python/` — 便携版 Python -- `adb/` — ADB 工具 - -`tools/ocr_log_analyzer.py` 仅属于源码仓库开发者工具,不在 GUI、后端或安装包 -运行链路中。 - -### NSIS 自定义 - -`build/installer.nsh` 会清除 `.env_ready`,使新版 Alpha 包首次启动时强制 -更新 `ShiinaKuroko/AutoWSGR` 的指定提交。安装包只携带唯一的 -`backend-distribution.json`。 - ---- - -## 目录约定 - -| 目录 | 运行时 (开发) | 运行时 (打包) | -|------|--------------|--------------| -| `appRoot()` | 项目根目录 | `%LOCALAPPDATA%/autowsgr-gui/` 或安装目录 | -| `resourceRoot()` | 同 appRoot | `resources/` (extraResources) | -| `resource/system_battle_plans/` | 项目根 `resource/system_battle_plans/` | extraResources `resource/system_battle_plans/` | -| `resource/system_daily_plans/` | 项目根 `resource/system_daily_plans/` | extraResources `resource/system_daily_plans/` | -| `resource/migrations/v6/` | 项目根 `resource/migrations/v6/`,只读 | extraResources `resource/migrations/v6/`,只读 | -| `resource/ship-library/` | 打包资料库种子 | extraResources `resource/ship-library/` | -| `userData/user_battle_plans/` | Electron userData | Electron userData | -| `userData/user_team_plans/` | Electron userData | Electron userData | -| `userData/user_daily_plans/` | Electron userData | Electron userData | -| `python/` | 项目根 `python/` | extraResources `python/` | -| `adb/` | 项目根 `adb/` | extraResources `adb/` | -| `userData/usersettings.yaml` | Electron userData | Electron userData | -| `userData/gui_settings.json` | Electron userData | Electron userData | -| `userData/task_groups.json` | Electron userData | Electron userData | -| `userData/templates/` | Electron userData | Electron userData | - -启动迁移会扫描本次运行所在的旧项目根目录。未初始化的 `userData` 或同一来源 -未完成的迁移会先显示分类选择窗口;旧设置始终深度合并,用户可分别选择日常 -任务 YAML、任务队列、任务 YAML。任务队列选项包含队列依赖的自定义模板,任务 -YAML 选项包含作战计划引用的编队 YAML。 -旧字段覆盖同名当前字段,当前版本独有字段继续保留。不同内容的同名任务组、 -计划、舰队和模板以“(旧版)”保留,不会覆盖现有文件。迁移按旧来源路径和 -内容哈希记录完成状态及实际输出文件名,状态保存在 -`userData/.migration-state.json`,并由 `MigrationStateStore` 独占读写。源文件 -不会删除。关闭选择窗口或迁移未完整结束时异常退出,不封存旧来源,下次启动 -仍会询问;明确不迁移或所选项目全部完成后才停止询问。本次实际执行迁移后会 -弹窗展示总数、成功数、失败数,失败项在下次启动继续尝试。 - -迁移状态最高版本当前为 **v7**。`UserDataMigrationService` 维护旧来源和 v6 -库存迁移,升级系统预设库存、保存仍被引用的已删除系统计划,并把旧胖次数字 -索引迁移为稳定计划标识;`LegacyPlanMigration` 负责 v7 计划分类迁移。每一阶段 -只有全部成功才写入完成键并推进版本,失败项下一次启动继续重试。安装目录和 -迁移资源始终只读。 - -GUI 发布严格使用三个版本规范和更新频道: - -- 稳定版 `X.Y.Z` 使用 `latest.yml`。 -- 预发布版 `X.Y.Z-beta.N` 使用 `beta.yml`。 -- 开发版 `X.Y.Z-dev` 或 `X.Y.Z-dev.N` 使用 `dev.yml`。 - -发布工作流会拒绝其他格式,并验证产物只能包含当前频道的更新清单。客户端也会 -校验候选版本的频道,禁止开发版或预发布版回退到稳定频道。 - -完整升级生命周期如下: - -1. release workflow 从 tag 解析版本、阶段和频道,覆盖构建时 publish channel。 -2. 打包后验证只生成当前频道的 YAML 清单,再创建对应 stable/prerelease Release。 -3. 客户端检查返回“有更新 / 已是最新 / 检查失败”三态,网络或频道错误不能显示成最新版。 -4. 下载前再次校验候选版本频道;没有已确认版本时拒绝下载和安装。 -5. 安装前调用后端正式停止接口,终止并等待完整进程树退出。 -6. 无法确认后端退出或文件锁释放时取消安装;成功后才调用 `quitAndInstall()`。 - ---- - -## 调试技巧 - -### 后端日志 - -- Python 后端使用 loguru 格式输出日志到 stdout -- 主进程控制台(终端 / VS Code Debug Console)可看到带颜色的原始日志 -- 渲染进程日志面板可看到经过滤的 INFO 及以上级别日志 -- 启用配置页的"调试模式"可在日志面板显示 DEBUG 级别 - -### IPC 调试 - -- `electronBridge` 对象在渲染进程 DevTools 控制台中可直接访问: - ```javascript - // 在 DevTools Console 中 - await window.electronBridge.checkEnvironment() - await window.electronBridge.getAppRoot() - ``` - -### 热重载 - -项目未配置 HMR。修改代码后需要: -1. 终止 Electron 进程 -2. 运行 `npm run dev` 重新编译并启动 - -### 常见问题 - -| 问题 | 原因 | 解决 | -|------|------|------| -| Python 未找到 | 未安装 Python 3.12/3.13 或便携版缺失 | 运行 `npm run prepare-python` 或在配置页手动设置路径 | -| 后端启动失败 | autowsgr 未安装 | 通过 GUI 环境检查自动安装,或手动 `pip install autowsgr` | -| ADB 连接失败 | 模拟器串口不匹配 | 在配置页手动填写 ADB 串口 | -| 端口冲突 | 8438 端口被占用 | 在配置页更改后端端口 | -| TypeScript 编译错误 | 类型定义不匹配 | 确认 Node.js 类型版本与 `@types/node` 一致 | - ---- - -## 技术栈一览 - -| 组件 | 技术 | 版本 | -|------|------|------| -| 桌面框架 | Electron | 33+ | -| 前端语言 | TypeScript | 5.6+ | -| 打包工具 | esbuild | 0.27+ | -| 安装包 | electron-builder (NSIS) | 26+ | -| 自动更新 | electron-updater | 6+ | -| YAML 解析 | js-yaml | 4+ | -| 样式预处理 | Sass (SCSS) | — | -| 后端框架 | Python FastAPI + uvicorn | — | -| 自动化核心 | autowsgr | 2.1.0+ | - ---- - -## SCSS 样式架构 - -样式位于 `src/view/styles/`,采用三层组织: +`src/view/html/**`、SCSS partial 和 TypeScript 源码不进入安装包。 +extraResources 包含只读 `resource/`、setup、调试依赖和明确白名单的舰船资料库 +工具;便携 Python、VC++ redist 和 ADB 作为 extraFiles 放到安装目录。 + +## SCSS 结构 + +```text +src/view/styles/ +├─ main.scss +├─ base/ +├─ components/ +├─ pages/ +│ ├─ main-page/ +│ ├─ config/ +│ └─ plan/ +└─ themes/ ``` -styles/ -├── main.scss # 入口:@use 引入所有子模块 -├── styles.css # 编译产物 -├── base/ # 基础层 -│ ├── _variables.scss # CSS 变量、主题色、断点 -│ └── _base.scss # 全局重置、基础样式 -├── components/ # 组件层(跨页面复用) -│ ├── _buttons.scss # 按钮样式 -│ ├── _forms.scss # 表单控件 -│ ├── _modal.scss # 模态弹窗 -│ ├── _nav.scss # 导航栏 -│ ├── _autocomplete.scss # 自动补全下拉 -│ ├── _task-group.scss # 任务组组件 -│ └── _template.scss # 模板卡片/向导 -└── pages/ # 页面层(特定页面布局) - ├── _config.scss # 配置页 - ├── main-page/ # 主页面 - │ ├── _index.scss # 入口 - │ ├── _layout.scss # 布局 - │ ├── _log.scss # 日志面板 - │ └── _task-queue.scss # 任务队列 - └── plan/ # 方案编辑页 - ├── _index.scss # 入口 - ├── _layout.scss # 布局 - ├── _header.scss # 头部 - ├── _node-map.scss # 地图节点 - ├── _node-types.scss # 节点类型图标 - ├── _node-editor.scss # 节点编辑器 - ├── _fleet-preset.scss # 编队预设 - └── _task-config.scss # 任务配置区域 + +`pages/_config.scss` 和 `pages/plan/_fleet-planner.scss` 是聚合入口。移动选择器时 +保持加载顺序和视觉效果;共享样式只有在多个页面实际复用时才进入 +`components/`。 + +## 提交前 + +```powershell +npm run test:build +git diff --check +git status --short ``` -**组织原则**: -- `base/`:全局变量和重置,晚于 `main.scss` 中最先 @use -- `components/`:跨页面复用的 UI 组件样式 -- `pages/`:特定页面的布局和元素样式,复杂页面进一步拆分为子目录 +然后按改动范围增加专项测试。构建后确认生成的 HTML/CSS 已更新,且没有把 +`userData`、临时目录、release 或无关文件带入改动。 diff --git a/docs/architecture/09-src-typescript-catalog.md b/docs/architecture/09-src-typescript-catalog.md index a5412f3..faf9172 100644 --- a/docs/architecture/09-src-typescript-catalog.md +++ b/docs/architecture/09-src-typescript-catalog.md @@ -1,177 +1,249 @@ -# `src` TypeScript 模块目录 +# `src` TypeScript 模块索引 -当前 `src` 共有 124 个 TypeScript 文件。每个模块均有文件头职责说明;本目录由 -这些说明汇总,用于快速定位功能所有者。 - -## 当前结构 +本索引按当前工作区统计,共 134 个 TypeScript 文件。用途是快速定位,不替代 +具体专题文档。 ```text src/ -├─ adapter/ 6 个:API、IPC、JSON、YAML、Storage 边界 -├─ controller/ 42 个:页面和用例编排 -├─ model/ 23 个:领域状态、Fleet、Scheduler 和统计规则 -├─ view/ 35 个:DOM 渲染、表单输入和 UI 意图 -├─ types/ 7 个:API、IPC、Model、View、Fleet、Scheduler、统计类型 -├─ shared/ 10 个:跨层无状态业务契约 -├─ data/ 舰船静态 JSON -└─ utils/ 1 个:统一日志 +├─ adapter/ 6 +├─ controller/ 42 +├─ model/ 25 +├─ view/ 40 +├─ types/ 7 +├─ shared/ 13 +└─ utils/ 1 ``` ## Adapter(6) -| 文件 | 职责 | +| 文件 | 责任 | |---|---| -| `src/adapter/ApiAdapter.ts` | 封装后端 HTTP 请求和 WebSocket 连接,向 Model 提供传输能力。 | -| `src/adapter/index.ts` | 集中导出 Renderer 使用的 Adapter 实例和边界类型。 | -| `src/adapter/IpcAdapter.ts` | 封装 Electron IPC 文件与计划操作,提供 Renderer 侧仓储接口。 | -| `src/adapter/JsonAdapter.ts` | 统一 JSON 序列化、解析和对象类型校验。 | -| `src/adapter/StorageAdapter.ts` | 定义键值存储契约,并封装浏览器 localStorage 实现。 | -| `src/adapter/YamlAdapter.ts` | 统一 YAML 编解码及作战方案元数据读取。 | +| `ApiAdapter.ts` | HTTP 与 WebSocket 传输实现 | +| `IpcAdapter.ts` | 将 ElectronBridge 裁剪为用例 Gateway/Repository | +| `JsonAdapter.ts` | JSON 编解码 | +| `YamlAdapter.ts` | YAML 编解码和结构辅助 | +| `StorageAdapter.ts` | 键值存储契约与 localStorage 实现 | +| `index.ts` | Adapter 实例和类型集中导出 | ## Controller(42) -| 文件 | 职责 | +### `controller/app`(12) + +| 文件 | 责任 | +|---|---| +| `AppController.ts` | Renderer 组合根和全局生命周期 | +| `AutomaticDecisiveTask.ts` | 自动决战两种来源的请求构造 | +| `ConfigController.ts` | 配置候选、事务保存和运行时同步 | +| `constants.ts` | 应用层展示和任务常量 | +| `CurrentFleetController.ts` | 当前任务舰队 ViewObject | +| `NavigationController.ts` | 页面与方案标签导航 | +| `OperationsController.ts` | 远征、奖励等快捷操作 | +| `rendering.ts` | 主页面 ViewObject | +| `ScheduledTaskLoader.ts` | 自动化设置到 SchedulerTask | +| `SchedulerBinder.ts` | Scheduler/Cron/日志/UI 联结 | +| `SchedulerRuntimeTracker.ts` | 运行日志派生状态 | +| `SettingsController.ts` | 环境、设备、资料库、更新和主题 | + +### `controller/startup`(3) + +| 文件 | 责任 | +|---|---| +| `StartupController.ts` | Renderer 启动总编排 | +| `connection.ts` | 后端健康、系统启动与连接 | +| `envAndUpdates.ts` | 环境准备和 GUI 更新检查 | + +### `controller/plan`(12) + +| 文件 | 责任 | +|---|---| +| `BattlePlanLoaderController.ts` | 受管方案选择器 | +| `DecisivePlanController.ts` | 决战草稿和持久化 | +| `FleetPlannerController.ts` | 普通编队草稿和持久化 | +| `fleetViewObjects.ts` | 编队 DTO 到 ViewObject | +| `nodeEditor.ts` | 节点编辑意图 | +| `PlanController.ts` | 当前作战方案和地图 | +| `PlanFleetPresetController.ts` | 方案舰队预设清单 | +| `PlanManagementController.ts` | 方案管理用例 | +| `planManagementViewObjects.ts` | 关联和删除影响推导 | +| `presetFlow.ts` | 独立任务预设流程 | +| `rendering.ts` | 方案预览 ViewObject | +| `selectedNodes.ts` | 路线默认、规范化和执行校验 | + +### `controller/taskGroup`(8) + +| 文件 | 责任 | +|---|---| +| `TaskGroupController.ts` | 任务组 CRUD 和 ViewObject | +| `TaskListLoaderController.ts` | 任务列表批量载入 | +| `DailyTaskLoaderController.ts` | 日常方案选择 | +| `addItems.ts` | 添加四类任务条目 | +| `contextMenu.ts` | 条目上下文操作 | +| `managedPlanReader.ts` | 读取受管作战/日常方案 | +| `metaLoader.ts` | 条目展示元数据 | +| `queueLoader.ts` | 条目到 SchedulerTask | + +### `controller/template`(5) + +| 文件 | 责任 | +|---|---| +| `TemplateController.ts` | 模板兼容链路协调 | +| `crud.ts` | 模板 CRUD/导入导出 | +| `selectors.ts` | 模板参数选择 | +| `useTemplate.ts` | 模板实例化 | +| `wizard.ts` | 模板创建向导 | + +### 其他(2) + +| 文件 | 责任 | +|---|---| +| `contracts.ts` | 跨流程最小 Host 契约 | +| `migration/MigrationConflictController.ts` | 迁移冲突复核 | + +## Model(25) + +### 根模型(6) + +| 文件 | 责任 | +|---|---| +| `ApiClient.ts` | AutoWSGR HTTP/WS 客户端 | +| `ConfigModel.ts` | YAML 配置和 GUI automation | +| `MapDataLoader.ts` | 地图读取与缓存 | +| `PlanModel.ts` | 作战方案解析、编辑和未知字段保留 | +| `TaskGroupModel.ts` | 任务组 v4 状态、迁移和持久化 | +| `TemplateModel.ts` | 内置/用户模板兼容 | + +### `model/fleet`(7) + +| 文件 | 责任 | |---|---| -| `src/controller/app/AppController.ts` | 作为 Renderer 组合根,初始化并协调页面、模型和功能控制器。 | -| `src/controller/app/AutomaticDecisiveTask.ts` | 分别从用户决战计划和系统预设构造自动决战请求。 | -| `src/controller/app/ConfigController.ts` | 编排设置加载、环境检测、表单保存和配置持久化。 | -| `src/controller/app/constants.ts` | 维护任务优先级、状态文案和修理模式等界面常量。 | -| `src/controller/app/CurrentFleetController.ts` | 加载舰船资料并把当前任务中的舰队规则转换为主页 ViewObject。 | -| `src/controller/app/NavigationController.ts` | 处理主页面导航、标签切换和当前页面状态。 | -| `src/controller/app/OperationsController.ts` | 编排远征收取、奖励领取等常用自动化操作。 | -| `src/controller/app/rendering.ts` | 把调度器和游戏状态转换为主页面 ViewObject。 | -| `src/controller/app/ScheduledTaskLoader.ts` | 读取自动决战、出征和战利品计划并转换为 Scheduler 任务。 | -| `src/controller/app/SchedulerBinder.ts` | 绑定 Scheduler 与 CronScheduler 回调并协调任务生命周期。 | -| `src/controller/app/SchedulerRuntimeTracker.ts` | 跟踪运行日志派生出的进度、掉落、统计和连接状态。 | -| `src/controller/app/SettingsController.ts` | 编排设置页的环境检测、设备连接、资料库更新和主题交互。 | -| `src/controller/contracts.ts` | 定义跨 Controller 流程共享的最小 Host 契约,避免反向依赖主 Controller。 | -| `src/controller/migration/MigrationConflictController.ts` | 协调迁移冲突弹窗与主进程安全文件操作。 | -| `src/controller/plan/BattlePlanLoaderController.ts` | 管理受管作战方案选择器的加载、筛选、选择和结果返回流程。 | -| `src/controller/plan/DecisivePlanController.ts` | 独立持有决战舰队草稿并协调加载、编辑和保存。 | -| `src/controller/plan/FleetPlannerController.ts` | 持有普通舰队草稿并协调舰船库、规则编辑和计划管理。 | -| `src/controller/plan/fleetViewObjects.ts` | 将舰队持久化 DTO 和领域预设转换为只读展示对象。 | -| `src/controller/plan/nodeEditor.ts` | 编排节点编辑表单与 PlanModel 节点规则更新。 | -| `src/controller/plan/PlanController.ts` | 协调作战方案加载、预览、编辑和保存。 | -| `src/controller/plan/PlanFleetPresetController.ts` | 管理计划页舰队目录,并把添加、移除意图转换为新的预设列表。 | -| `src/controller/plan/PlanManagementController.ts` | 管理计划目录状态,并把页面操作转换为受控仓储调用。 | -| `src/controller/plan/planManagementViewObjects.ts` | 将计划目录和任务组引用转换为只读计划管理行。 | -| `src/controller/plan/presetFlow.ts` | 把任务预设和舰队预设转换为可调度的作战任务。 | -| `src/controller/plan/rendering.ts` | 把作战方案、地图和节点数据转换为预览 ViewObject。 | -| `src/controller/plan/selectedNodes.ts` | 维护地图节点选择顺序并同步方案的选中节点。 | -| `src/controller/startup/connection.ts` | 检测后端可用性并管理启动阶段的连接等待。 | -| `src/controller/startup/envAndUpdates.ts` | 编排运行环境准备、依赖安装和 GUI 更新检查。 | -| `src/controller/startup/StartupController.ts` | 统一协调应用启动、后端连接、环境检查和资源释放。 | -| `src/controller/taskGroup/addItems.ts` | 将方案、模板和预设添加为任务组条目。 | -| `src/controller/taskGroup/contextMenu.ts` | 处理任务组条目的右键菜单、编辑、复制和删除。 | -| `src/controller/taskGroup/DailyTaskLoaderController.ts` | 管理日常任务浮窗的加载、分类、参数和提交动作。 | -| `src/controller/taskGroup/managedPlanReader.ts` | 读取受管作战方案并统一返回内容和来源信息。 | -| `src/controller/taskGroup/metaLoader.ts` | 批量读取任务条目元数据并生成界面展示摘要。 | -| `src/controller/taskGroup/queueLoader.ts` | 把任务组条目解析为 Scheduler 可执行任务并加入队列。 | -| `src/controller/taskGroup/TaskGroupController.ts` | 管理任务组选择、增删改和 Model 到 ViewObject 的映射。 | -| `src/controller/taskGroup/TaskListLoaderController.ts` | 协调任务列表文件选择、解析和批量载入。 | -| `src/controller/template/crud.ts` | 实现模板创建、编辑、删除、导入和导出用例。 | -| `src/controller/template/selectors.ts` | 提供方案、舰队和任务参数的模板选择流程。 | -| `src/controller/template/TemplateController.ts` | 协调模板库、模板向导和任务组添加流程。 | -| `src/controller/template/useTemplate.ts` | 把模板参数实例化为可加入任务组的任务条目。 | -| `src/controller/template/wizard.ts` | 维护模板向导步骤、预填数据和表单提交。 | - -## Model(23) - -| 文件 | 职责 | +| `DecisiveFleetDraft.ts` | 决战草稿 | +| `FleetDraft.ts` | 普通编队草稿和 DTO 转换 | +| `FleetDraftEditor.ts` | 编辑意图应用 | +| `FleetPresetIdentity.ts` | 编队预设身份 | +| `FleetRuleMapper.ts` | 规则到 API 映射 | +| `ShipMatcher.ts` | 舰船匹配 | +| `index.ts` | Fleet 领域出口 | + +### `model/scheduler`(11) + +| 文件 | 责任 | |---|---| -| `src/model/ApiClient.ts` | 提供后端业务 API、任务控制和 WebSocket 事件客户端。 | -| `src/model/ConfigModel.ts` | 持有用户配置状态并负责默认值、迁移和 YAML 转换。 | -| `src/model/fleet/DecisiveFleetDraft.ts` | 维护决战舰队独立草稿及决战配置转换规则。 | -| `src/model/fleet/FleetDraft.ts` | 定义普通舰队槽位、候选舰和规则草稿结构。 | -| `src/model/fleet/FleetDraftEditor.ts` | 在 Controller 持有的唯一舰队草稿上应用显式编辑意图。 | -| `src/model/fleet/FleetPresetIdentity.ts` | 统一编队规则格式并生成稳定的预设身份标识。 | -| `src/model/fleet/FleetRuleMapper.ts` | 在舰队槽位规则和后端 fleet_rules 请求之间转换。 | -| `src/model/fleet/index.ts` | 导出舰队草稿、匹配、名称和规则映射等领域能力。 | -| `src/model/fleet/ShipMatcher.ts` | 解析舰队预设、匹配舰船规则并生成槽位显示文本。 | -| `src/model/MapDataLoader.ts` | 加载、缓存并查询地图节点、连线和位置信息。 | -| `src/model/PlanModel.ts` | 持有作战方案状态并处理节点规则、迁移和 YAML 往返。 | -| `src/model/scheduler/CronScheduler.ts` | 维护定时任务触发器、最后执行时间和 pending 状态。 | -| `src/model/scheduler/ExpeditionTimer.ts` | 根据远征状态计算并发布下一次收取倒计时。 | -| `src/model/scheduler/index.ts` | 导出调度器、任务队列、定时器和修理管理器公共接口。 | -| `src/model/scheduler/RepairManager.ts` | 跟踪泡澡舰船并编排修理、替换和状态恢复。 | -| `src/model/scheduler/Scheduler.ts` | 持有当前任务和运行状态,驱动任务消费、重试与后续任务。 | -| `src/model/scheduler/SchedulerRepairPolicy.ts` | 提供舰船损伤阈值、替换候选和修理时长的纯规则。 | -| `src/model/scheduler/SchedulerTaskPolicy.ts` | 提供任务完成、重试、轮询和后续任务构造的纯规则。 | -| `src/model/scheduler/StopConditionChecker.ts` | 读取游戏统计并判断掉落、舰船上限等停止条件。 | -| `src/model/scheduler/TaskQueue.ts` | 唯一持有就绪与延迟队列,并实现优先级和轮询排序。 | -| `src/model/statistics/DailySortieStats.ts` | 从后端成功日志维护可持久化的今日出征统计。 | -| `src/model/TaskGroupModel.ts` | 持有任务组及条目状态,并负责 JSON 迁移和持久化。 | -| `src/model/TemplateModel.ts` | 管理内置与用户模板,负责校验、CRUD 和持久化。 | - -## View(35) - -| 文件 | 职责 | +| `CampaignDailyQuota.ts` | 战役每日正常结算额度 | +| `CronScheduler.ts` | 每分钟自动任务触发 | +| `ExpeditionTimer.ts` | 远征倒计时 | +| `NormalFightDailyQuota.ts` | 自动出击每日额度状态 | +| `RepairManager.ts` | 泡澡和轮换编队 | +| `Scheduler.ts` | 任务生命周期 | +| `SchedulerRepairPolicy.ts` | 修理调度纯策略 | +| `SchedulerTaskPolicy.ts` | 任务构建和插入纯策略 | +| `StopConditionChecker.ts` | 三阶段停止条件 | +| `TaskQueue.ts` | 就绪/延迟队列 | +| `index.ts` | Scheduler 领域出口 | + +### 统计(1) + +| 文件 | 责任 | |---|---| -| `src/view/config/ConfigView.ts` | 渲染设置页面、收集表单输入并发出保存和检测意图。 | -| `src/view/main/FleetPreviewView.ts` | 渲染当前舰队舰船、等级和损伤状态预览。 | -| `src/view/main/LogView.ts` | 追加、筛选并滚动展示运行日志。 | -| `src/view/main/MainView.ts` | 组合主页面状态栏、舰队、队列和日志子视图。 | -| `src/view/main/NavigationView.ts` | 持有主导航和计划子标签的 DOM 状态并上报选择意图。 | -| `src/view/main/StatusBar.ts` | 渲染连接、运行状态、当前任务和远征倒计时。 | -| `src/view/main/TaskQueueView.ts` | 渲染任务队列进度并发出删除和停止操作意图。 | -| `src/view/migration/MigrationConflictView.ts` | 渲染迁移 YAML 冲突的双列表选择弹窗并上报保留意图。 | -| `src/view/plan/BattlePlanLoaderView.ts` | 渲染受管作战方案选择器,并收集搜索、筛选和舰队选择操作。 | -| `src/view/plan/DecisivePlanView.ts` | 渲染决战舰队配置并向 Controller 提交编辑意图。 | -| `src/view/plan/FleetEditorView.ts` | 渲染舰队槽位并处理选择、清空和拖拽排序意图。 | -| `src/view/plan/FleetGalleryView.ts` | 展示舰船图鉴并管理筛选、排序和选择等 UI 状态。 | -| `src/view/plan/FleetPlannerView.ts` | 组合舰队编辑、规则、图鉴、计划管理和编队加载视图。 | -| `src/view/plan/FleetPresetView.ts` | 渲染方案内舰队预设并提供应用、编辑和任务创建入口。 | -| `src/view/plan/FleetRuleView.ts` | 渲染主选、候选、舰种和等级规则编辑区。 | -| `src/view/plan/GalleryShipCollection.ts` | 提供普通与决战舰船图库共用的搜索、筛选、排序和批量计算纯函数。 | -| `src/view/plan/MapView.ts` | 绘制作战地图节点与连线并发出节点选择意图。 | -| `src/view/plan/NodeEditorView.ts` | 渲染节点属性和敌舰规则编辑对话框。 | -| `src/view/plan/PlanManagementView.ts` | 渲染本地方案列表并发出导入、导出、重命名和删除意图。 | -| `src/view/plan/PlanPreviewView.ts` | 组合地图、节点编辑、舰队预设和方案参数预览。 | -| `src/view/plan/ShipArtwork.ts` | 创建舰船立绘元素并处理资源路径和加载失败回退。 | -| `src/view/plan/TeamPlanListUi.ts` | 渲染编队方案卡片并实现搜索、筛选和排序。 | -| `src/view/plan/TeamPlanLoaderView.ts` | 展示编队方案选择器并发出加载方案意图。 | -| `src/view/setup/SetupWizardView.ts` | 渲染首次启动向导并收集模拟器和 Python 配置。 | -| `src/view/shared/AnimatedSelect.ts` | 在不改变原生表单数据源的前提下提供统一动画下拉面板。 | -| `src/view/shared/DialogHelper.ts` | 封装 Renderer 通用确认、提示、输入对话框和保存成功提示。 | -| `src/view/shared/scrollPosition.ts` | 保存和恢复可滚动容器的界面位置。 | -| `src/view/shared/ShipAutocomplete.ts` | 为舰名输入框提供搜索建议、键盘选择和补全。 | -| `src/view/taskGroup/DailyTaskLoaderView.ts` | 渲染日常任务浮窗并上报页签、参数和提交意图。 | -| `src/view/taskGroup/TaskGroupView.ts` | 渲染任务组和任务条目,并发出选择、排序和菜单意图。 | -| `src/view/taskGroup/TaskListLoaderView.ts` | 渲染任务列表加载浮窗并上报分组、删除、排序和确认意图。 | -| `src/view/template/SelectorDialog.ts` | 渲染通用选项对话框并返回用户选择。 | -| `src/view/template/TemplateLibraryView.ts` | 渲染模板库卡片并发出使用、编辑和删除意图。 | -| `src/view/template/TemplateWizardView.ts` | 渲染模板创建向导、计划列表和分步表单。 | -| `src/view/theme.ts` | 通过存储 Adapter 读取主题偏好,并应用主题、强调色和系统主题变化。 | +| `statistics/DailySortieStats.ts` | 今日出征和评级统计 | + +## View(40) + +### 配置(4) + +| 文件 | 责任 | +|---|---| +| `config/ConfigView.ts` | 设置页 Facade | +| `config/ConfigAutomationView.ts` | 自动任务局部视觉 | +| `config/ConfigRuntimeView.ts` | 环境与更新局部视觉 | +| `config/settingSelectWidth.ts` | 下拉宽度纯 DOM 辅助 | + +### 主页面(6) + +`main/MainView.ts` 组合 `NavigationView.ts`、`StatusBar.ts`、 +`TaskQueueView.ts`、`LogView.ts` 和 `FleetPreviewView.ts`。 + +### 方案与编队(16) + +| 文件 | 责任 | +|---|---| +| `BattlePlanLoaderView.ts` | 作战方案选择浮窗 | +| `DecisivePlanView.ts` | 决战页 | +| `FleetEditorView.ts` | 舰位和拖放编辑 | +| `FleetGalleryView.ts` | 普通舰队图库适配 | +| `FleetPlannerView.ts` | 普通舰队 Facade | +| `FleetPresetView.ts` | 方案内舰队预设 | +| `FleetRuleView.ts` | 舰位规则编辑 | +| `GalleryShipCollection.ts` | 图库筛选/排序纯计算 | +| `MapView.ts` | 地图 | +| `NodeEditorView.ts` | 节点编辑器 | +| `PlanManagementView.ts` | 方案管理 | +| `PlanPreviewView.ts` | 方案页 Facade | +| `ShipArtwork.ts` | 舰船卡片图片结构 | +| `ShipGalleryView.ts` | 两页面共享舰船图库 | +| `TeamPlanListUi.ts` | 编队列表纯 UI 辅助 | +| `TeamPlanLoaderView.ts` | 编队方案选择 | + +### 任务组与模板(6) + +- `taskGroup/DailyTaskLoaderView.ts` +- `taskGroup/TaskGroupView.ts` +- `taskGroup/TaskListLoaderView.ts` +- `template/SelectorDialog.ts` +- `template/TemplateLibraryView.ts` +- `template/TemplateWizardView.ts` + +### 共享、迁移、引导和主题(8) + +- `shared/AnimatedSelect.ts` +- `shared/DialogHelper.ts` +- `shared/LoaderDialog.ts` +- `shared/scrollPosition.ts` +- `shared/ShipAutocomplete.ts` +- `migration/MigrationConflictView.ts` +- `setup/SetupWizardView.ts` +- `theme.ts` ## Types(7) -| 文件 | 职责 | +| 文件 | 契约 | |---|---| -| `src/types/api.ts` | 定义 Renderer 与后端 HTTP/WebSocket 通信使用的请求、响应和事件类型。 | -| `src/types/fleetEditor.ts` | 定义 Fleet 编辑器跨层意图、选择位置和编辑结果。 | -| `src/types/ipc.ts` | 定义 Renderer 与 Electron 主进程之间的桥接方法、文件结果和资源契约。 | -| `src/types/model.ts` | 定义配置、作战方案、舰队规则、模板和修理等领域数据结构。 | -| `src/types/scheduler.ts` | 定义任务队列、调度状态及 Scheduler 对外回调契约。 | -| `src/types/statistics.ts` | 定义今日出征统计、战斗评级和掉落提示结构。 | -| `src/types/view.ts` | 定义 Controller 交给各页面渲染的 ViewObject、表单值和展示状态。 | +| `api.ts` | AutoWSGR REST/WS 和 TaskRequest | +| `fleetEditor.ts` | 编队编辑意图 | +| `ipc.ts` | ElectronBridge 与 Main DTO | +| `model.ts` | 配置、方案、模板等领域类型 | +| `scheduler.ts` | SchedulerTask、状态和回调 | +| `statistics.ts` | 出征统计 | +| `view.ts` | Controller 到 View 的 ViewObject | -## Shared(10) +## Shared(13) -| 文件 | 职责 | +| 文件 | 责任 | |---|---| -| `src/shared/decisiveAutomation.ts` | 定义自动决战的用户计划、系统预设来源和稳定标识。 | -| `src/shared/decisivePlan.ts` | 定义 Main、Preload、Renderer 共用的决战计划设置契约和默认值。 | -| `src/shared/fleetShipTypes.ts` | 提供 22 种规范舰种代码、标签、映射和契约校验。 | -| `src/shared/legacyDecisiveAutomation.ts` | 定义旧决战自动化配置的无损迁移结构。 | -| `src/shared/lootPlans.ts` | 定义刷胖次稳定计划标识、来源和旧数字索引映射。 | -| `src/shared/migrationConflicts.ts` | 定义迁移后需要用户确认的 YAML 冲突契约。 | -| `src/shared/nativeFleetShipTypes.generated.ts` | 保存由 autowsgr_native 生成的舰种代码,供前端漂移检查。 | -| `src/shared/shipCatalog.ts` | 加载静态舰船资料并提供舰名和国籍只读目录。 | -| `src/shared/shipNameNormalizer.ts` | 统一舰名别名、后端标准名和搜索名转换。 | -| `src/shared/taskPreset.ts` | 统一识别和校验 Main、Renderer 共用的独立任务预设。 | +| `campaign.ts` | 每日战役固定次数 | +| `decisiveAutomation.ts` | 自动决战来源 | +| `decisivePlan.ts` | 决战持久化契约 | +| `fleetShipTypes.ts` | 22 舰种公开规则 | +| `legacyDecisiveAutomation.ts` | 旧决战字段归档 | +| `lootPlans.ts` | 战利品计划稳定标识 | +| `migrationConflicts.ts` | 迁移冲突 DTO | +| `nativeFleetShipTypes.generated.ts` | 从 AutoWSGR 同步的生成快照 | +| `nodeDecision.ts` | 节点决策纯规则 | +| `normalFightQuota.ts` | 自动出击额度纯规则 | +| `shipCatalog.ts` | 只读舰船目录辅助 | +| `shipNameNormalizer.ts` | 舰名规范化 | +| `taskPreset.ts` | 独立任务预设 Codec | + +`shared` 只能放无状态、无 DOM、无 Electron、无浏览器存储的跨层逻辑。 ## Utils(1) -| 文件 | 职责 | -|---|---| -| `src/utils/Logger.ts` | 提供统一日志级别、频道和控制台输出格式。 | +`utils/Logger.ts` 统一 Renderer 日志缓冲、输出和刷新。 + +## 定位方法 + +文件数会随实现变化。新增、删除或移动模块后,用以下命令核对本索引: + +```powershell +rg --files src -g "*.ts" +rg -n "export (class|interface|type|function|const|enum)" src +``` diff --git a/docs/architecture/10-runtime-boundaries-adr.md b/docs/architecture/10-runtime-boundaries-adr.md index 4790646..2c31105 100644 --- a/docs/architecture/10-runtime-boundaries-adr.md +++ b/docs/architecture/10-runtime-boundaries-adr.md @@ -1,116 +1,156 @@ -# ADR-001:当前运行时边界与生命周期 +# ADR-001:当前运行时边界 -- **状态**:已接受 -- **日期**:2026-08-05 -- **适用范围**:当前 AutoWSGR-GUI 主进程、渲染进程、用户数据迁移、任务调度和 GUI 更新 +- 状态:已接受 +- 基线:当前工作区代码,包括未提交改动 +- 范围:Renderer、Electron Main、Python 后端、存储、迁移和更新 ## 背景 -GUI 已完成大规模模块拆分。为了防止实现继续演进而工程文档停留在旧结构,本 -决策记录固定当前版本的所有权和生命周期边界。代码是最终契约;相关变更必须 -同时更新本 ADR 和对应专题文档。 +项目同时包含浏览器运行时、Node/Electron 运行时和 Python 后端。方案、配置和 +任务又存在多个持久化来源。若状态所有权或依赖方向不明确,容易出现两份状态、 +View 直连文件系统、安装目录被写入、迁移不可重试等问题。 -## 决策 +以下决策是当前实现必须保持的边界。 -### 1. 渲染进程遵循单向边界 - -标准数据流为: +## 决策 1:Renderer 单向数据流 ```text -Model / Repository → Controller → ViewObject → View -View → 用户意图 → Controller +Repository / Model -> Controller -> ViewObject -> View +View -> 用户意图 -> Controller ``` -View 不读取 Electron bridge,不拼装持久化 DTO,也不拥有文件 identity。 -`FleetPlannerController` 负责舰船库和编队计划读取、保存覆盖、DTO 映射及 -`file/source`;`FleetDraft` 负责草稿与 `UserTeamPlan` 的双向转换和规则校验。 -View 只看到 ViewObject 和不透明计划 ID。`DecisivePlanController` 以同样方式 -拥有设置 Repository 和 `DecisiveFleetDraft`。 - -### 2. `electron/main.ts` 只做组合根 - -`main.ts` 可以创建 Service、注入依赖、注册 IPC、编排启动迁移和处理 Electron -生命周期,不实现文件策略、YAML 规则、计划归一化、资料库升级、环境检测或 -更新策略。业务边界分别由以下模块承担: - -| 边界 | 所有者 | -|------|--------| -| 普通文件能力 | `SafePathService`、`SecureFileService` | -| GUI 设置 | `GuiSettingsStore`、`GuiConfigurationService` | -| 迁移状态账本 | `MigrationStateStore` | -| 作战计划 | `CombatPlanCodec`、`CombatPlanRepository`、计划 Service | -| 编队计划 | `TeamPlanCodec`、`TeamPlanRepository`、`TeamPlanService` | -| 舰船资料库 | `ShipLibraryService`、`ShipLibraryUpdater` | -| Python/CUDA/ADB | `pythonEnv/*`、环境 Service | -| 后端进程 | `BackendService`、`BackendShutdownService` | -| GUI 更新 | `UpdaterIpc`、`GuiUpdatePolicy` | - -### 3. 安装资源只读,用户数据写入 `userData` - -| 数据 | 运行时位置 | -|------|------------| -| 系统作战/舰队/日常计划、地图、内置模板 | `resource/`,只读 | -| 已下架系统计划的迁移快照 | `resource/migrations/v6/`,只读 | -| 用户作战计划 | `userData/user_battle_plans/` | -| 用户舰队计划 | `userData/user_team_plans/` | -| 用户日常计划 | `userData/user_daily_plans/` | -| 设置、任务组、模板 | `userData/` | +- Controller 编排,不拥有 DOM。 +- View 拥有 DOM 和局部视觉状态,不访问有状态 Model、Adapter、ApiClient 或 + ElectronBridge。 +- Model 拥有领域状态和规则,不操作 DOM。 +- Adapter 隔离 IPC、HTTP、WebSocket、序列化和浏览器存储。 +- Shared 只包含无状态、跨运行时可复用逻辑。 + +边界由 `test-renderer-architecture.js` 强制。 + +## 决策 2:组合根不承载业务规则 + +`AppController` 是 Renderer 组合根,`electron/main.ts` 是 Main 组合根。它们 +允许创建对象、注入依赖、注册生命周期和协调顶层流程,不实现: + +- YAML Codec。 +- 路径安全。 +- 方案归一化。 +- Python/更新策略。 +- 舰队分配规则。 +- 文件持久化细节。 + +业务分别进入 Controller 用例模块、Model、Service、Repository 或 Codec。 + +## 决策 3:Preload 是唯一 Electron 桥 + +Renderer 只通过 `window.electronBridge` 使用 Main 能力,且 Controller 依赖 +`IpcAdapter` 裁剪后的窄 Gateway。`src/types/ipc.ts` 是桥接 DTO 的类型来源。 + +新增通道必须同步 preload、Main IPC、Adapter 和契约测试。同步 getter 的 +`sendSync/ipcMain.on` 配对不能单侧修改。 + +## 决策 4:系统资源只读,用户数据写 userData + +| 数据 | 位置 | +|---|---| +| 系统作战/编队/日常方案、地图、内置模板 | `resource/` | +| 用户作战方案 | `userData/user_battle_plans/` | +| 用户编队方案 | `userData/user_team_plans/` | +| 用户日常方案 | `userData/user_daily_plans/` | +| YAML/GUI 设置、任务组、用户模板 | `userData/` | | 舰船资料库工作副本 | `userData/ship-library/` | -| 迁移状态 | `userData/.migration-state.json` | -| 临时展开计划 | 系统 temp 下的进程专属目录 | +| 迁移账本 | `userData/.migration-state.json` | +| 执行计划 | temp 的进程专属目录 | + +安装目录中的可变文件只作为旧迁移来源。通用文件 IPC 不获得任意磁盘读写权。 + +## 决策 5:配置是跨文件事务 + +`usersettings.yaml` 和 `gui_settings.json` 是不同域,但设置页一次保存必须保持 +一致: + +```text +写 YAML -> 原子写 JSON -> 失败则恢复 YAML -> 成功后更新 Renderer 内存 +``` + +`GuiSettingsStore` 保留未知顶层字段;新的旧配置转换需要独立迁移标记。 + +## 决策 6:方案使用 Codec/Repository/Service + +- Codec:结构、兼容和未知字段保留。 +- Repository:系统/用户来源、路径、文件和原子写入。 +- Service:导入、保存、重命名、删除、关联和运行时准备。 +- IPC:参数和结果边界。 + +Renderer 的 `PlanModel.rawRoot` 保留未建模 YAML。系统和用户同名文件仍是不同 +identity。运行前由 `RuntimePlanService` 展开到临时目录。 + +## 决策 7:普通编队与决战状态独立 + +`FleetPlannerController` 独占普通 `FleetDraft`, +`DecisivePlanController` 独占 `DecisiveFleetDraft`。两者共享 +`ShipGalleryView` 视觉行为,但不共享草稿、文件 identity 或保存状态。 + +共享图库必须用 `AbortController` 和 `ResizeObserver.disconnect()` 完整释放, +释放链到达 `AppController.onBeforeUnload`。 + +## 决策 8:调度区分轮次与逻辑任务 + +- `id`:物理轮次。 +- `logicalId`:有限/无限任务、重试、gap 和修理等待的稳定身份。 +- Cron pending 和取消使用 `logicalId`。 +- 未到终点或战果不满足的成功轮次不减少 `remainingTimes`。 +- 自动任务记录实际完成/处理,不在入队时提前标记。 + +自动战役固定每日 8 次正常结算。常规出击额度按计划来源、文件和舰队去重。 -舰船资料库按 manifest 的 schema 版本和生成时间升级,通过临时目录、备份和 -重命名切换,失败时恢复旧目录。 +## 决策 9:迁移是可重试状态机 -通用文件 IPC 只允许读取 `userData` 和 `resource`,只允许写入 `userData`。 -路径验证拒绝 `..`、UNC、盘符跳转和 NTFS ADS,并通过 `realpath` 检查符号链接 -或 junction 的真实目标。系统文件对话框授予的单次外部文件能力不扩展通用 IPC。 +`MigrationStateStore` 独占 `.migration-state.json`: -### 4. 迁移是可重试的版本状态机 +1. 完成 marker 只合并,不覆盖。 +2. 文件全部成功后才完成阶段。 +3. 失败只重试未完成项。 +4. 源文件不修改、不删除。 +5. 同名不同内容保留“(旧版)”。 +6. 旧来源用 started/configuration-complete/complete 封存。 -`MigrationStateStore` 独占 `.migration-state.json` 的解析、合并和原子写入。 -`UserDataMigrationService` 负责旧来源和 v6 库存迁移, -`LegacyPlanMigration` 负责 v7 计划分类迁移。迁移遵循: +当前主要版本为用户数据 v6、旧方案 v7。 -1. 源文件不修改、不删除。 -2. 同名不同内容保存为“(旧版)”副本,并同步受管引用。 -3. 设置按字段深度合并,未知扩展字段保留。 -4. 完成项包含来源和内容摘要,重复启动不重复迁移。 -5. 每一阶段全部成功后才写入完成键并推进版本;失败项下次启动重试。 -6. 实际发生迁移时显示总数、成功数、失败数和失败文件。 +## 决策 10:后端来源与能力先验证 -### 5. 调度器区分轮次与逻辑任务 +managed 和 external 使用唯一明确 AutoWSGR 来源。启动前检查: -`SchedulerTask.id` 只标识当前物理轮次;`logicalId` 在所有后触发、重试、间隔 -等待和无限轮次之间保持稳定。事件分为: +- 实际 import 路径。 +- OCR GPU 和截图环境变量行为。 +- `autowsgr.server.main:app` 的 ASGI 能力。 +- Python 3.12/3.13、依赖和 CUDA。 -- `onTaskCompleted(id)`:单轮完成。 -- `onLogicalTaskCompleted(logicalId)`:整个任务不再有后续轮次。 -- `onLogicalTaskCanceled(logicalId, reason)`:删除、清空或系统停止。 +不满足时直接失败,不回退到另一后端来源。 -`SchedulerBinder` 只根据逻辑事件更新 cron/pending。系统停止允许下次重新触发, -用户删除或清空表示主动放弃。 +## 决策 11:启动与退出顺序固定 -### 6. 模板兼容链路暂时保留 +单实例锁必须早于更新、迁移、pip 和窗口。pending 更新必须早于迁移和窗口。 -`TemplateModel`、`TemplateController`、用户模板文件和 -`kind: "template"` 仍是旧任务组及系统决战预设的执行依赖。当前计划页没有 -独立模板库入口;新入口按后续界面方案实现。在完成数据和执行迁移之前,不得 -删除或停止初始化兼容链路。 +退出时必须先保存窗口状态,再正式停止后端、等待进程树、停止内置 ADB,最后 +退出。无法确认释放时阻止退出和更新安装。 -### 7. 发布和安装使用严格生命周期 +## 决策 12:生成源与运行产物分离 -版本只允许 `X.Y.Z`、`X.Y.Z-beta.N`、`X.Y.Z-dev` 或 `X.Y.Z-dev.N`, -分别对应 `latest`、`beta`、`dev`。发布工作流和客户端使用同一解析规则, -产物不得混入其他频道清单。 +- HTML 源:`src/view/html/**` +- SCSS 源:`src/view/styles/**/*.scss` +- Electron 运行:生成的 `src/view/index.html` 和 `styles.css` +- Renderer 运行:`dist/renderer.bundle.js` -更新检查必须返回有更新、已是最新或失败三态。安装前调用 -`BackendShutdownService`:后端正式停止接口、进程树终止、等待 `close`、超时 -强制回退。无法确认退出时阻止 `quitAndInstall()`。 +生成的 HTML/CSS 提交到仓库;partial 和 TypeScript 不打入安装包。生成文件不 +手改。 ## 后果 -- 新功能需要明确状态所有者,不能把 Repository 或 Electron bridge 重新放回 View。 -- 新的用户可变文件必须先定义 `userData` 位置和 IPC 能力,不能写安装目录。 -- 调度回调不能用单轮 `id` 清理逻辑任务状态。 -- 修改迁移、频道或关闭顺序时,必须补对应 Service 测试和旧版本 fixture。 +- 新状态必须先确定唯一所有者。 +- 新文件必须先确定只读资源或 userData 位置。 +- 新共享组件必须有真实复用和完整生命周期。 +- 新迁移必须有独立 marker 与失败重试测试。 +- 新 IPC 必须补桥接契约测试。 +- 修改调度身份、后端来源或退出顺序时必须增加对应领域/Service 测试。 diff --git a/docs/architecture/11-renderer-visual-architecture.md b/docs/architecture/11-renderer-visual-architecture.md new file mode 100644 index 0000000..b25eba1 --- /dev/null +++ b/docs/architecture/11-renderer-visual-architecture.md @@ -0,0 +1,224 @@ +# Renderer 视觉架构 + +> 目录:`src/view/html/`、`src/view/`、`src/view/styles/` + +## 运行形态 + +Renderer 使用原生 TypeScript、DOM API 和 SCSS,没有运行时组件框架。 + +```mermaid +flowchart LR + Partial["HTML partial"] --> BuildHtml["build-view-html.js"] + BuildHtml --> HTML["src/view/index.html"] + SCSS["main.scss + partial"] --> Sass["sass"] + Sass --> CSS["styles.css"] + TS["View/Controller/Model"] --> TSC["tsc + esbuild"] + TSC --> Bundle["renderer.bundle.js"] + HTML --> Window["单一 BrowserWindow"] + CSS --> Window + Bundle --> Window +``` + +Electron 始终加载一个 HTML、一个 CSS 和一个 Renderer Bundle。HTML partial +只在构建期展开,不能改成运行时 fetch、iframe 或多页面入口。 + +## HTML 所有权 + +```text +src/view/html/ +├─ index.html +├─ layout/ +│ └─ navigation.html +├─ pages/ +│ ├─ main.html +│ ├─ config/ +│ └─ plan/ +└─ dialogs/ +``` + +规则: + +1. 只编辑 `src/view/html/**`,不手改 `src/view/index.html`。 +2. partial 按页面/区域职责拆分,不按行数拆分。 +3. View 引用的 DOM ID 是契约。 +4. include 不能逃出 HTML 源目录,也不能循环。 +5. 保持展开后的 DOM 顺序,避免事件和 CSS 选择器行为变化。 +6. 运行时创建的节点放在明确的 View 创建函数。 + +`test-renderer-dom-contract.js` 检查重复 ID、静态 View 引用缺失和例外白名单是否 +过期。 + +## View 边界 + +View 负责: + +- DOM 查找、渲染和浏览器事件。 +- 表单局部值、搜索、筛选、排序、展开和 loading。 +- 动画、滚动和 Observer。 +- 把用户意图通过回调上报。 +- 释放自己创建的监听器和资源。 + +View 不负责: + +- 业务默认值和跨页面状态。 +- 文件 identity 和持久化 DTO。 +- Scheduler、配置或舰队草稿的权威状态。 +- Electron IPC、ApiClient 或 Repository。 +- 导入有状态 Model。 + +架构测试仅允许 `view/theme.ts` 通过 `StorageAdapter` 管理纯 UI 偏好。 + +## 视觉组件类型 + +| 类型 | 作用 | 示例 | +|---|---|---| +| Facade | 保持 Controller 公共 API,组合子 View | `ConfigView`、`FleetPlannerView`、`PlanPreviewView` | +| 职责子 View | 独占一个局部视觉区域 | `ConfigAutomationView`、`ConfigRuntimeView` | +| 共享组件 | 两个以上真实调用方共享完整视觉行为 | `ShipGalleryView` | +| 页面适配 | 把页面差异转换成共享组件 Host | `FleetGalleryView` | +| 纯 UI 函数 | 无状态转换或 DOM 创建 | `GalleryShipCollection`、`ShipArtwork` | + +不要为缩短文件创建只转发一层的包装器。只有存在清晰视觉责任或真实复用时才 +拆分。 + +## 配置页 + +`ConfigView` 保持设置页 Facade API,内部使用: + +- `ConfigAutomationView`:自动出击列表、摘要、剩余次数和战利品计划。 +- `ConfigRuntimeView`:Python、CUDA、后端、ADB、资料库和更新进度。 +- `settingSelectWidth.ts`:根据选项文案计算受控宽度。 + +Controller 仍只面对 `ConfigView`。子 View 不获得 ConfigModel、配置 Gateway 或 +Scheduler。 + +HTML: + +```text +pages/config/index.html +pages/config/behavior.html +pages/config/system.html +``` + +SCSS: + +```text +pages/_config.scss + -> config/layout + -> config/setting-controls + -> config/automation-summary + -> config/form-controls + -> config/automation-list + -> config/status-and-drafts + -> config/responsive +``` + +## 舰船图库 + +`ShipGalleryView` 在普通舰队和决战页复用: + +- 搜索、舰种/国家/改造筛选。 +- 排序与降序。 +- 批量增量渲染。 +- 卡片创建与交互。 +- 滚动位置恢复。 +- 拖拽起点。 + +页面差异由 `ShipGalleryViewHost` 注入: + +- 当前舰位说明。 +- 排除规则。 +- 点击分配。 +- 展示名。 +- 改造筛选偏好。 +- 编辑是否可用。 +- 可选拖拽协议。 + +普通舰队的主选/候选、决战的 level1/level2、脏状态和保存都不能放进共享图库。 +也不能在 `FleetGalleryView` 或 `DecisivePlanView` 复制一套筛选渲染循环。 + +## 生命周期 + +`ShipGalleryView` 创建: + +- 一组带同一 `AbortSignal` 的 DOM/document 监听器。 +- 一个 `ResizeObserver`。 + +`dispose()` 必须幂等: + +```text +eventController.abort() +resizeObserver.disconnect() +``` + +释放链: + +```text +AppController.onBeforeUnload + -> FleetPlannerController.dispose() + -> FleetPlannerView.dispose() + -> ShipGalleryView.dispose() + +AppController.onBeforeUnload + -> DecisivePlanController.dispose() + -> DecisivePlanView.dispose() + -> ShipGalleryView.dispose() +``` + +新增 window/document 监听器、Observer、interval 或 requestAnimationFrame 时, +必须定义所有者和释放入口。 + +## SCSS 所有权 + +```text +src/view/styles/ +├─ base/ # 变量、重置、基础元素 +├─ components/ # 多页面复用组件 +├─ pages/ # 页面布局和页面组件 +├─ themes/ # 主题覆盖 +└─ main.scss # 唯一聚合入口 +``` + +舰队页面聚合: + +```text +pages/plan/_fleet-planner.scss + -> plan-navigation + -> plan-management + -> fleet-editor + -> ship-gallery + -> fleet-dialogs + -> fleet-responsive +``` + +移动 SCSS 时保持选择器、属性和加载顺序。机械拆分不应同时改变视觉效果。 +只有两个以上页面共享的独立组件才进入 `components/`。 + +## ViewObject 与意图 + +View 读取只读展示对象,不保存业务对象引用。编辑流程应是: + +```text +View 用户动作 + -> 显式 intent / callback + -> Controller + -> Model 应用规则 + -> 新 snapshot/ViewObject + -> View render +``` + +表单草稿可属于 View;保存后的配置、方案和舰队草稿不属于 View。 + +## 修改门禁 + +| 范围 | 验证 | +|---|---| +| HTML partial/DOM ID | `npm run build:html`、`npm run test:renderer-contract` | +| View TypeScript | `npm run test:architecture-boundaries` | +| 配置页 | 上述命令 + `npm run test:settings` | +| 舰队/决战图库 | 上述命令 + `npm run test:fleet-domain` | +| SCSS | `npm run build` + 人工检查页面和响应式 | +| 打包入口 | `npm run test:build`、`npm run pack` | + +最终运行 `npm run build`,提交同步生成的 `index.html` 和 `styles.css`。静态测试 +不能替代 Electron 中的点击、拖放、弹窗、滚动和窗口关闭回归。 diff --git a/docs/architecture/12-agent-entry-guide.md b/docs/architecture/12-agent-entry-guide.md new file mode 100644 index 0000000..b78a19d --- /dev/null +++ b/docs/architecture/12-agent-entry-guide.md @@ -0,0 +1,203 @@ +# AGENT 进入指南 + +这份文档用于让第一次进入仓库的 AGENT 在不破坏边界的前提下定位和修改代码。 + +## 先确认基线 + +本项目经常存在未提交实现。不要只看 HEAD: + +```powershell +git status --short +git diff --stat +git diff --cached --stat +``` + +以当前工作区文件为事实来源。遇到已有改动: + +- 不回退用户改动。 +- 阅读当前完整文件和 diff。 +- 在已有实现上继续。 +- 只修改请求涉及的范围。 + +## 5 分钟建立上下文 + +```powershell +Get-Content package.json -Raw +rg --files src electron scripts/tests +rg -n "目标类名|目标方法|界面文案" src electron scripts +``` + +然后阅读: + +1. [总架构](00-overview.md)。 +2. [运行时边界 ADR](10-runtime-boundaries-adr.md)。 +3. 对应专题。 +4. 目标文件的调用方、类型和测试。 + +不要从生成的 `src/view/index.html`、`styles.css` 或 `dist/**` 反推源代码。 + +## 需求到文件 + +| 需求 | 首要入口 | 通常还要检查 | +|---|---|---| +| 应用启动/退出 | `electron/main.ts` | `SingleInstanceService`、`WindowService`、Backend shutdown | +| Renderer 启动 | `StartupController.ts` | `startup/connection.ts`、`envAndUpdates.ts` | +| 页面导航 | `NavigationController.ts`、对应 View | HTML partial、`AppController` 装配 | +| 设置字段 | `ConfigModel.ts`、`ConfigController.ts` | Config View、IPC 类型、GuiConfigurationService | +| 配置持久化 | `GuiSettingsCommitService.ts` | Store、ConfigurationIpc、preload | +| 自动任务 | `CronScheduler.ts`、`SchedulerBinder.ts` | ScheduledTaskLoader、额度 Model、配置 | +| 队列/重试/次数 | `Scheduler.ts` | TaskQueue、SchedulerTaskPolicy、scheduler types | +| 作战方案 YAML | `PlanModel.ts` | CombatPlanCodec、Controller rendering | +| 方案管理 | `PlanManagementController.ts` | Main PlanManagementService、Repository | +| 编队规则 | `src/model/fleet/` | FleetPlannerController、fleetEditor types | +| 决战编队 | `DecisiveFleetDraft.ts` | DecisivePlanController/View、DailyPlanService | +| 舰船图库 | `ShipGalleryView.ts` | FleetGalleryView、DecisivePlanView、SCSS | +| 任务组 | `TaskGroupModel.ts` | `controller/taskGroup/*`、迁移测试 | +| 模板 | `TemplateModel.ts` | `controller/template/*`、旧任务组兼容 | +| IPC 方法 | `src/types/ipc.ts` | preload、Main IPC、IpcAdapter | +| 后端 API | `src/types/api.ts`、`ApiClient.ts` | Scheduler/Controller、API 契约测试 | +| Python/CUDA | `electron/pythonEnv/` | Environment Service、BackendRuntimeContract | +| ADB/模拟器 | `AdbService.ts`、`emulatorDetect.ts` | DeviceIpc、设置页 | +| 迁移 | `UserDataMigrationService.ts` / `LegacyPlanMigration.ts` | MigrationStateStore、fixtures | +| GUI 更新 | `UpdaterIpc.ts`、`GuiUpdatePolicy.ts` | Installer、state store、main lifecycle | +| HTML | `src/view/html/` | 对应 View 和 DOM 契约 | +| 样式 | `src/view/styles/` | HTML/View 所有权和聚合入口 | +| 构建/打包 | `package.json` | scripts、builder config、workflow | + +## 判断代码应该放哪 + +问四个问题: + +1. 是否操作 DOM、浏览器事件或动画?放 View。 +2. 是否是领域状态、校验或纯业务规则?放 Model 或 Shared。 +3. 是否协调多个对象完成一个用例?放 Controller。 +4. 是否访问 Electron、文件、HTTP、WS 或浏览器存储?放 Adapter/Main Service。 + +Main 侧再区分: + +- IPC:输入输出边界。 +- Service:用例和业务策略。 +- Repository:目录、文件和来源。 +- Codec:YAML/JSON 结构和兼容。 +- `main.ts`:只装配和生命周期。 + +## 禁止依赖 + +### Controller 禁止 + +```text +document.* +window.electronBridge +localStorage.* +window.addEventListener +window.matchMedia +HTMLElement / HTMLInputElement / ResizeObserver 等 DOM 类型 +``` + +### View 禁止 + +```text +stateful Model import +ApiClient import +Adapter import +window.electronBridge +localStorage.* +``` + +`view/theme.ts` 通过 StorageAdapter 管理 UI 偏好是已有例外。 + +### Shared 禁止 + +```text +DOM +Electron +Node 文件系统 +浏览器存储 +有状态 singleton +``` + +## 修改步骤 + +1. 用 `rg` 找定义、调用方、类型和测试。 +2. 阅读当前文件,不只读 Git 版本。 +3. 确定状态唯一所有者和依赖方向。 +4. 写出最小变更范围。 +5. 先改开发源,再生成产物。 +6. 跑最小专项测试。 +7. 跑构建/架构门禁。 +8. 检查 diff 是否只包含预期文件。 + +```powershell +git diff --check +git status --short +git diff -- +``` + +## 最小验证矩阵 + +| 改动范围 | 必跑 | +|---|---| +| 任意 TypeScript/HTML/SCSS | `npm run test:build` | +| Controller/View 边界 | `npm run test:architecture-boundaries` | +| HTML/DOM ID | `npm run test:renderer-contract` | +| 设置页 | `npm run test:settings` | +| Scheduler/Cron/额度 | `npm run test:scheduler-domain` | +| Fleet/舰种/候选 | `npm run test:fleet-domain`、`npm run check:fleet-types` | +| Main Service | `npm run test:main-services` | +| preload/IPC | `npm run test:main-ipc` | +| 迁移 | `npm run test:migrations` | +| 后端请求/DTO | `npm run test:api-contract` | +| Python/CUDA/后端来源 | `npm run test:python-environment` | +| 打包资源 | `npm run pack`、`npm run test:release-package` | + +专项测试通过不代表构建门禁可以省略。涉及用户交互时还要运行 Electron 做实际 +页面回归。 + +## 生成文件 + +| 修改源 | 必须生成 | +|---|---| +| `src/view/html/**` | `npm run build:html` -> `src/view/index.html` | +| `src/view/styles/**/*.scss` | `npm run build:css` -> `styles.css` | +| TypeScript | `npm run build` -> `dist/**`,但 `dist` 不作为手改源 | + +安装包只包含生成后的 HTML/CSS 和 Bundle,不包含 HTML/SCSS/TS 源。 + +## 高风险不变量 + +- 新/切换地图计划默认只有节点 `0`;只有 `0` 时禁止入队。 +- 节点参数继承 `node_defaults`。 +- `PlanModel.rawRoot` 和 Main Codec 保留未知 YAML 字段。 +- 普通舰队与决战草稿独立。 +- candidate-only 不提升第一候选为主选。 +- 舰种使用 22 canonical code,导巡为 `KP/kp`。 +- 调度用 `logicalId` 管理整个逻辑任务。 +- 未到终点或战果不足不减少轮次。 +- 自动战役固定每日 8 次正常结算。 +- 系统资源只读,用户数据只写 userData。 +- 迁移完成 marker 不覆盖,写完文件后才标记。 +- 次实例不能执行迁移、pip 或创建第二个主窗口。 +- 退出/安装更新前必须确认后端进程树和内置 ADB 已释放。 + +## 何时停止扩大改动 + +出现以下情况时,回到边界重新设计,而不是继续加特殊判断: + +- View 需要完整 Model 或 Repository。 +- Controller 需要 DOM 元素。 +- IPC 开始解析 YAML 或写业务规则。 +- 同一状态在两个对象中都可写。 +- 新 fallback 吞掉 Controller、页面、OCR 或环境异常。 +- 为一个调用方创建“共享”组件。 +- 修改生成文件才能让源码工作。 + +## 完成标准 + +一个修改只有同时满足以下条件才算完成: + +- 行为由正确层负责。 +- 当前工作区已有改动未被覆盖。 +- 生成文件与开发源一致。 +- 受影响专项测试和构建门禁通过。 +- Electron 交互在需要时已回归。 +- 文档、类型、preload、IPC 和调用方没有遗漏的契约变化。 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 9009c2a..3f97148 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1,27 +1,43 @@ # 架构文档 -面向 AutoWSGR-GUI 开发者的架构参考文档。 - -> 本目录描述当前系统结构。强制性的依赖方向、状态所有权、迁移、验证和 Patch 止损要求见[工程与代码规范](../engineering-standards.md)。 - -## 目录 - -| # | 文档 | 说明 | -|---|------|------| -| 0 | [总架构文档](00-overview.md) | 项目简介、主进程组合根/IPC/Service 分层、渲染进程架构、启动流程 | -| 1 | [Controller 层](01-controller-layer.md) | 最小 Host 接口、6 个子目录结构、StartupController 启动编排 | -| 2 | [任务调度系统](02-task-scheduling.md) | Scheduler、TaskQueue、CronScheduler、远征轮询、停止条件、修理轮换 | -| 3 | [配置系统](03-configuration.md) | ConfigModel、ConfigController、ConfigView、usersettings.yaml、gui_settings.json | -| 4 | [出击计划系统](04-battle-plan.md) | PlanModel、PlanController(拆分)、PlanPreviewView(Facade)、MapDataLoader | -| 5 | [模板与任务组](05-template-and-taskgroup.md) | TemplateController(拆分)、TaskGroupController(拆分)、模板视图、队列加载 | -| 6 | [后端通信](06-backend-communication.md) | IPC Bridge、ApiClient、REST API、WebSocket 事件 | -| 7 | [环境管理](07-environment-management.md) | pythonEnv/ 子模块(12 文件)、Python/CUDA/ADB 服务、后端进程生命周期 | -| 8 | [开发环境搭建](08-dev-setup.md) | 依赖安装、开发/构建/打包命令、SCSS 架构、调试技巧 | -| 9 | [`src` TypeScript 模块目录](09-src-typescript-catalog.md) | 当前 `src` 模块及其职责索引;文件数量以该文档为准 | -| 10 | [当前运行时边界 ADR](10-runtime-boundaries-adr.md) | 存储、IPC 权限、迁移、调度身份和更新安装决策 | - -## 阅读建议 - -- **新上手**:从 [总架构文档](00-overview.md) 开始,了解整体结构和启动流程 -- **改某个功能**:直接跳到对应的子模块文档;Controller 层组织参见 [Controller 层](01-controller-layer.md) -- **搭建开发环境**:参见 [开发环境搭建](08-dev-setup.md) +本目录描述 **当前工作区代码** 的实际架构,包括尚未提交或尚未 push 的实现。 +代码、`package.json`、构建脚本和 CI 配置是最终事实来源;文档与代码冲突时, +先按代码确认行为,再同步修正文档。 + +## 第一次进入项目 + +按以下顺序阅读: + +1. [总架构](00-overview.md):先建立进程、分层、目录和启动顺序的全局认识。 +2. [AGENT 进入指南](12-agent-entry-guide.md):按需求定位文件,确定最小修改和验证命令。 +3. [运行时边界 ADR](10-runtime-boundaries-adr.md):确认不能破坏的状态、存储和生命周期边界。 +4. 再阅读对应业务专题,不需要从头读完全部文档。 + +## 专题目录 + +| # | 文档 | 主要内容 | +|---|---|---| +| 00 | [总架构](00-overview.md) | Electron、Renderer、Python 后端、目录、存储和启动流程 | +| 01 | [Controller 层](01-controller-layer.md) | 组合根、最小 Host、单向数据流和层级禁区 | +| 02 | [任务调度](02-task-scheduling.md) | Scheduler、Cron、逻辑任务、重试、终点计数和每日额度 | +| 03 | [配置系统](03-configuration.md) | YAML/JSON 双存储、事务保存、配置页拆分和迁移 | +| 04 | [方案与编队](04-battle-plan.md) | 作战、编队、日常方案,PlanModel、Codec/Repository/Service | +| 05 | [模板与任务组](05-template-and-taskgroup.md) | 模板兼容链路、任务组 v4、四类条目和入队 | +| 06 | [通信边界](06-backend-communication.md) | Preload/IPC、Adapter、HTTP、WebSocket 和异常边界 | +| 07 | [环境与生命周期](07-environment-management.md) | Python、CUDA、ADB、后端启动、迁移、更新和退出 | +| 08 | [开发与验证](08-dev-setup.md) | 构建、生成文件、测试、CI 和打包 | +| 09 | [`src` 模块索引](09-src-typescript-catalog.md) | Renderer 各目录和关键文件定位 | +| 10 | [运行时边界 ADR](10-runtime-boundaries-adr.md) | 当前必须保持的架构决策 | +| 11 | [Renderer 视觉架构](11-renderer-visual-architecture.md) | HTML partial、View、共享图库、SCSS 和生命周期 | +| 12 | [AGENT 进入指南](12-agent-entry-guide.md) | 需求到文件映射、修改步骤、验证矩阵和止损规则 | + +## 三条先决规则 + +1. 不直接修改生成文件 `src/view/index.html`、`src/view/styles/styles.css` 或 + `dist/**`;修改源文件后运行 `npm run build`。 +2. Renderer Controller 不接触 DOM、`window.electronBridge` 或 + `localStorage`;View 不导入有状态 Model、Adapter 或 ApiClient。 +3. 系统资源只读,用户可变数据写入 Electron `userData`;主进程文件能力必须 + 经过 Service 和 IPC 边界。 + +工程级强制规范见项目根目录的 [AGENTS.md](../../AGENTS.md)。 diff --git a/docs/engineering-standards.md b/docs/engineering-standards.md deleted file mode 100644 index 92cb77a..0000000 --- a/docs/engineering-standards.md +++ /dev/null @@ -1,663 +0,0 @@ -# AutoWSGR-GUI 工程与代码规范 - -本文档定义 AutoWSGR-GUI 的强制工程规范。它适用于人工编写、AI 辅助生成和完全由自动化 Agent 生成的所有代码。 - -本文使用以下规范性术语: - -- **必须 / 不得(MUST / MUST NOT)**:合并前必须满足,除非维护者按“例外流程”书面批准。 -- **应当 / 不应(SHOULD / SHOULD NOT)**:默认必须遵守;偏离时必须在 PR 中说明理由。 -- **可以(MAY)**:允许采用的实现方式。 - -## 1. 文档权威顺序 - -发生冲突时,按以下顺序判断: - -1. `package.json`、`tsconfig.json`、`.editorconfig`、`.gitattributes` 等可执行配置决定机器实际执行的命令和规则。 -2. 本文决定贡献者义务、架构约束、验证要求和合并门槛。 -3. [架构文档](architecture/README.md)描述当前系统结构。 -4. [贡献指南](../CONTRIBUTING.md)提供环境搭建、分支和提交入门说明。 -5. [重构教学](teaching/README.md)解释历史设计动机,不是规范来源;其中的历史行数和文件数不得被视为当前事实。 - -如果实现、架构文档和本文不一致,PR 必须修正实现或同步更新文档,不得无说明地选择其中一套。 - -### 1.1 现有技术债务 - -本规范生效前已经存在的违规不自动阻塞所有后续开发,但也不构成先例或许可。 - -- 新代码不得复制、扩大或依赖已有违规模式。 -- PR 触及现有违规代码时,必须在描述中列为已知技术债务,并说明本次是消除、缩小还是保持其影响范围。 -- 如果完成当前行为目标必须扩大既有违规,必须先获得维护者批准并给出后续收敛方案。 -- “主分支原来就这样写”不是偏离规范的有效理由。 -- 审查者不得要求一个小型、无关 PR 顺便清理整个历史系统;整改范围应与当前行为目标和风险相称。 - -## 2. 基本原则 - -### 2.1 正确性必须可证明 - -- 编译成功只能证明代码可以编译,不能证明行为正确。 -- 截图只能证明某一时刻的显示结果,不能证明持久化、调度、异步生命周期或升级兼容正确。 -- “在我的机器上能运行”不是可复现证据。 -- Bug 修复必须提供修复前失败、修复后通过的可复现证据。 -- 无法自动测试时,必须提供确定性的复现步骤、输入、预期输出和实际输出,并说明为什么暂时不能自动化。 - -### 2.2 一份可变数据只能有一个权威所有者 - -任何能够独立写入并影响行为的表示都是一个**状态源**。缓存、镜像字段、生命周期标志、持久化副本和 UI 本地副本都可能成为状态源。 - -- 每份可变状态必须有唯一权威所有者。 -- 派生状态必须可以从权威状态重新计算,且不得独立写入。 -- 跨 View、Controller、Model、Electron 主进程和 Python 后端镜像状态时,必须声明所有者、写入路径、初始化、失效和协调规则。 -- 修复 Bug 时原则上不得新增状态源;确有必要时,必须由维护者明确批准。 -- 禁止使用 `alreadyHandled`、`isSyncing`、影子集合或重复缓存掩盖顺序和所有权缺陷。 - -### 2.3 修复根因,不叠加补丁 - -- 每个修复必须说明因果链:输入如何进入权威状态、经过哪些转换、最终产生什么可观察行为。 -- guard、retry、delay、fallback、兼容分支和重复校验均必须说明所保护的明确不变量。 -- 已被证明错误的 workaround 必须在替代实现中删除,不得以“以防万一”为由保留。 -- 不得通过移动文件、重命名 helper、拆分 commit 或 squash 历史,把补丁累积伪装成干净设计。 - -### 2.4 改动应形成可回滚的行为单元 - -一个 PR 应当完成一个可独立验证和回滚的行为目标。是否过大不只由行数决定,而由审查者能否完成以下工作决定: - -- 用一句话描述该 PR 的主要行为目标; -- 追踪每份状态的唯一所有者; -- 区分必要修改与顺手重构; -- 使用有限且明确的证据完成验收; -- 在不撤销无关功能的情况下回滚。 - -无法满足任一项时,PR 必须拆分或重新设计。 - -### 2.5 最小充分实现与反堆积门禁 - -Agent 不得采用“先搭框架、再填功能、最后清理”的开发方式。实现必须从能够证明行为的最小闭环开始,并以满足需求、架构、安全、兼容和验证要求的最少新增代码为目标。这里的“最少”指概念、符号、模块、状态和分支的最少充分集合,不是机械压缩字符数。 - -#### 修改前:先提交最小方案 - -开始写代码前,Agent 必须明确以下内容;缺少任一项不得进入实现: - -- 用户可观察的唯一行为目标,以及明确不做的范围; -- 已有实现、调用链、类型、适配器、组件和测试中可直接复用的部分; -- 预计修改的最少文件和最少架构层; -- 每个计划新增的函数、类型、模块、依赖、状态源和测试的必要性; -- 不新增模块、不新增状态源、不新增抽象时的最小实现路径; -- 如果认为必须拆分或新增抽象,说明现有边界为何无法承载,以及拆分后谁拥有状态和行为; -- 与行为目标匹配的最小验证集合。 - -默认方案必须是“修改现有实现”。Agent 不得先创建空接口、通用基类、未来扩展点、目录骨架、Facade、Factory、Manager、Registry、EventBus 或兼容层,再寻找实际调用方。没有当前调用方、当前契约或当前测试需求支撑的预留设计不得加入。 - -#### 修改中:新增代码逐项过门 - -每新增一个主要符号或文件,Agent 必须能够回答: - -1. 它解决的是哪个当前行为问题; -2. 为什么现有符号不能直接承载; -3. 为什么不能用更小的修改完成; -4. 它是否新增状态、依赖、跨层调用或失败路径; -5. 哪个测试或调用方证明它不是死代码或预留代码。 - -以下情况视为代码堆积,必须停止并回到最小方案重新设计: - -- 为一个调用方新增一层没有独立职责的 wrapper、helper、Facade、Factory 或 Service; -- 把一个简单条件包装成策略、管理器、注册表、事件系统或多级回调; -- 为“以后可能扩展”新增未被当前契约使用的接口、泛型、配置项、分支或插件点; -- 复制 DTO、ViewObject、缓存、状态字段或转换函数,只为绕开现有类型或所有权分析; -- 通过新增默认值、retry、delay、catch、fallback、兼容分支或 guard 覆盖未解释的根因; -- 将不相似的功能合并到同一模块,或将一个完整职责拆成互相转发的微型模块; -- 为了降低单文件行数、满足目录外观或制造“分层感”而拆分; -- 新增代码没有真实调用方、行为测试或明确的公共契约; -- 同一规则、字段归一化、序列化或错误处理在多个层重复实现。 - -发现以上情况时,Agent 必须停止继续添加代码,删除尚未证明必要的新增部分,或报告维护者批准后再继续。不得用后续重构承诺为当前堆积辩护。 - -#### 拆分与复用判定 - -新增模块至少必须满足以下一项: - -- 有独立的业务职责或变化原因; -- 有独立的生命周期或状态所有权; -- 有两个以上真实调用方,且复用语义一致; -- 需要隔离文件、IPC、HTTP、WebSocket、YAML、JSON 或其他基础设施边界; -- 需要独立测试一个当前存在的行为契约。 - -以下情况不得新增模块: - -- 只有一个调用方且没有独立生命周期; -- 仅为降低文件行数、包装一个函数或复制一个类型; -- 拆分后原模块仍持有全部状态和业务决策; -- 复用方的职责、输入输出或副作用并不真正一致; -- 新模块只是把原有代码搬家,没有减少耦合或明确边界。 - -复用优先级为:直接复用现有行为 > 修改现有行为以支持当前需求 > 提取当前已有的共同规则 > 新增独立实现。不得为了“复用”强行扩大模块职责,也不得为了避免重复而引入比重复几行局部代码更复杂的抽象;这种情况下应记录取舍。 - -#### 交付前:反堆积复核 - -交付前必须检查 diff,并逐项确认: - -- 删除任意新增函数、类型、模块或分支后,当前行为是否仍能正确完成;能删除的必须删除; -- 新增文件是否有真实调用方和独立职责; -- 是否出现重复状态、重复转换、重复校验、重复错误处理或无调用方出口; -- 是否混入未来扩展、无关重构、形式拆分、预留接口或生成代码; -- 实际文件数、符号数和依赖数是否超过最小方案;超出时必须解释每项增加的原因; -- 代码是否仍能用一句话说明输入、权威状态、输出和验证证据。 - -无法通过该复核时,不得宣称完成;应删除多余代码或暂停并升级审查。 - -## 3. 架构约束 - -项目采用 **MVC + ViewObject**。标准数据流为: - -```text -Model → Controller → ViewObject → View - ↑ │ - └─ 用户意图 ┘ -``` - -### 3.1 View - -View 只负责 DOM 渲染、读取用户输入并上报用户意图。 - -View 不得: - -- 直接调用 Model、ApiClient 或 Electron 文件 IPC; -- 读写配置、计划、模板、任务组或其他持久化数据; -- 决定业务默认值、迁移规则、任务状态或调度语义; -- 在多个页面中重复领域归一化和序列化规则; -- 持有与 Model 可独立修改的业务状态副本。 - -复杂 View 可以使用 Facade 组织子 View,但 Facade 不得借此成为业务层。 - -### 3.2 Controller - -Controller 负责用例编排、把 Model 数据转换为 ViewObject,并把用户意图转成 Model 或系统能力调用。 - -- 子 Controller 之间不得直接调用具体实现;跨域协调必须通过最小 Host 接口或由组合根协调。 -- Controller 不得实现 YAML/JSON parser、路径安全、原子文件替换、Python 环境发现等基础设施细节。 -- Controller 不得成为第二个 Model;长期状态必须由明确的 Model 或专用模块持有。 -- Host 接口必须保持最小,不得为了方便逐渐暴露整个 `AppController`。 - -### 3.3 Model - -Model 负责业务实体、领域规则、调度、配置语义和后端通信,不得感知具体 View 或 DOM。 - -- 同一条领域规则必须有一个实现位置。 -- YAML、JSON、IPC 和 HTTP 的格式转换应放在明确的 adapter 中,不得复制到多个调用方。 -- 调度任务必须区分单轮完成、逻辑任务完成、取消、失败和重试;不得用同一个布尔值表达不同生命周期。 -- 领域模型不得依赖 Electron 或 Node 文件系统实现。 - -### 3.4 Types - -- `src/types/` 中的类型按领域和通信方向组织,不得成为无边界的全局类型垃圾场。 -- ViewObject 与领域模型、API DTO、IPC DTO 必须区分。 -- 不得使用 `any`、宽泛的字典或类型断言绕过契约设计。 -- 当前 `tsconfig.json` 虽启用 `strict`,但仍有 `noImplicitAny: false` 的显式例外;新代码不得以该例外为理由引入隐式 `any`。 - -### 3.5 Electron 主进程 - -`electron/main.ts` 只负责: - -1. 应用和窗口生命周期; -2. IPC 注册; -3. 模块初始化与依赖注入。 - -文件存储、数据迁移、Python 环境、后端进程、更新器和资源管理必须位于独立模块中。IPC handler 应当只做参数校验、调用模块和返回结构化结果。 - -不得把业务逻辑重新集中到 `main.ts`,也不得让子模块反向读取 `main.ts` 的全局变量。 - -### 3.6 Python 后端与跨仓契约 - -- GUI 只能依赖后端公开、版本化的接口,不得 monkey-patch 后端私有方法或读取私有内部结构。 -- GUI 与后端共同使用的数据结构必须有跨仓契约测试或固定 fixture。 -- `name` 缺失、空值、候选列表顺序等语义必须显式定义,不得由两端各自猜测。 -- PR 必须声明支持的后端最低版本、最高已验证版本和不兼容变化。 -- 后端不可用或版本不匹配时必须明确失败,禁止静默切换到另一个后端来源。 - -## 4. 持久化、迁移与资源规范 - -### 4.1 已发布格式是公共契约 - -已发布版本中的以下内容均属于兼容契约: - -- 用户配置字段和未知扩展字段; -- 计划、模板和任务组格式; -- 文件目录; -- 模板 ID 和任务索引的业务语义; -- 调度行为和默认舰队等用户可见默认值。 - -改变契约时必须提供显式迁移。不得通过删除字段、移动目录或复用旧 ID 静默改变行为。 - -### 4.2 迁移要求 - -每个迁移必须: - -- 有版本号和明确的起止版本; -- 可重复执行且结果幂等; -- 在中途失败时保留原始数据; -- 保留未知字段; -- 有真实旧版本 fixture; -- 测试首次迁移、重复迁移、部分数据和失败回滚; -- 记录发生了什么,并在无法安全迁移时停止而不是猜测。 - -手工选择旧文件不是自动升级迁移的替代方案。 - -### 4.3 文件和目录 - -- 安装资源目录视为只读。 -- 用户生成数据必须写入操作系统提供的用户数据目录。 -- 缓存和可重建资源不得与不可丢失的用户数据混放。 -- 原子写入失败时必须保留旧文件;不得先删除有效目标再尝试替换。 -- 打包资源更新必须有 manifest 或版本规则,不得永远采用“不覆盖已有文件”。 - -### 4.4 Electron 文件安全 - -- renderer 不得向通用 IPC 提交任意绝对路径。 -- 每类文件操作必须声明允许的根目录和能力范围。 -- 路径必须 canonicalize 后检查 containment。 -- 必须防止 `..`、盘符切换、UNC、符号链接和大小写差异导致的目录逃逸。 -- preload 只暴露最小、面向用例的能力,不暴露通用文件系统。 -- 路径安全和写入失败必须有自动测试。 - -## 5. Python 环境和进程生命周期 - -- bundled、managed、system 和 external Python 必须是明确区分的环境模式。 -- 一个环境描述必须同时用于检测、安装、CUDA/OCR 检查和后端启动。 -- 不得把依赖安装到一个环境,却使用另一个 `sys.path` 启动。 -- external 模式失效时必须阻止启动并报告原因,不得静默回退。 -- 停止后端时应先优雅停止,再等待,超时后终止完整进程树。 -- 更新安装前必须确认后端及子进程退出、文件锁释放。 -- 环境缓存必须声明失效条件;缓存不得掩盖解释器或依赖变化。 - -## 6. Patch 止损机制 - -### 6.1 什么算一次失败的修复尝试 - -以下任一情况都算一次失败尝试: - -- 声称修复的行为仍可复现; -- 验证没有通过; -- 下一次修改必须绕过或补偿上一次修改的错误假设; -- 上一次 workaround 被保留,新代码又在外围增加一层特殊处理。 - -诊断日志、测试夹具和不改变行为的 instrumentation 不计入修复尝试。一个概念性补丁拆成多个 commit 仍只按其实际尝试计数,也不得借此重置计数。 - -### 6.2 止损信号 - -以下是架构恶化信号: - -1. 修复开始触及原计划之外的架构层,只为了补偿其他层的新行为; -2. 新增可写状态源、同步标志、影子集合、缓存或生命周期分支; -3. 保留已经失败的 workaround; -4. 同一规则开始在多个层重复实现; -5. 新增 retry、delay、catch-and-ignore 或多级 fallback 才能“稳定运行”; -6. 为通过验证而放宽类型、校验或测试期望; -7. 无法通过确定性测试或复现步骤证明行为; -8. 作者无法解释从输入到输出的完整因果链; -9. 删除其中一个补丁会导致另一个补丁失效; -10. 审查者已无法分辨哪些分支是产品需求,哪些只是历史补偿。 - -### 6.3 升级等级 - -| 等级 | 触发条件 | 必须执行的动作 | -|------|----------|----------------| -| L0 正常变更 | 无失败尝试、无意外扩散、有直接证据 | 正常实现和审查 | -| L1 记录修正 | 一次失败尝试,或出现一个止损信号 | 记录原假设、失败证据、状态所有者和新的验证计划 | -| L2 维护者检查点 | 两次连续失败,或同时出现两个止损信号 | 暂停继续实现;维护者批准重新设计、拆分或干净重写后才能继续 | -| L3 Patch Freeze | 三次失败且存在任一止损信号;或已有多个竞争状态源 | 禁止在当前实现上继续叠加修复,先建立复现和替代设计 | -| L4 干净重写 | 现有分支无法删除失败 workaround、恢复单一状态源或证明端到端行为 | 从最后已知正常基线创建干净分支重新实现 | - -计数是触发器,不是可以优化或规避的 KPI: - -- 即使只失败一次,若已出现多个竞争状态源,也可以直接进入 L3。 -- 即使失败三次,若旧尝试已被完整删除,当前实现恢复了清晰且可证明的模型,也不必机械重写。 -- 不得通过 squash、换 Agent、换会话、换文件或新开 PR 把尝试次数归零。 - -### 6.4 第三次修复的强制门槛 - -同一根因连续失败两次后,第三次修复必须先提交以下内容供维护者确认: - -- 两次失败分别基于什么假设; -- 哪些证据证明假设错误; -- 新的因果模型与旧模型有何实质差异; -- 将删除哪些旧 workaround; -- 状态所有者和架构交互是否改变; -- 哪项测试将在旧实现失败、在新实现通过。 - -缺少其中任一项,不得继续提交第三层补丁。 - -## 7. 干净基线重写协议 - -干净重写不是“把整个旧分支复制到新分支”,也不是因为代码难看就推倒重来。满足 L4 时必须执行以下流程。 - -### 7.1 固定基线 - -- 记录最后一个不包含失败 patch 链的已接受 commit SHA 或 release tag。 -- 从该 commit 创建独立 branch 或 worktree。 -- 使用 `git diff --exit-code ` 证明起点干净。 -- 不得从失败 PR 的 head、merge commit 或 squash commit 创建重写分支。 -- 不得覆盖维护者工作树中的未提交文件。 - -### 7.2 先固定行为契约 - -实现前必须记录: - -- 原始问题的确定性复现; -- 必须实现的用户行为; -- 不得回归的旧行为; -- 每份状态的所有者和生命周期; -- 跨层或跨仓交互顺序; -- 失败、取消、回滚和恢复语义; -- 自动测试和手工验证矩阵。 - -条件允许时,回归测试应当先于替代实现单独提交。 - -### 7.3 旧代码复用清单 - -对失败分支必须先进行只读分析,并建立功能—代码映射。每项至少包含: - -- 功能 ID 和用户价值; -- 对应旧文件、类和函数; -- 输入、输出和持久化格式; -- 后端或 IPC 依赖; -- 可原样复用、可复用思路但需重写、仅保留需求、必须丢弃四类结论; -- 兼容要求和验收测试。 - -旧分支中的实现默认不可信。只有被逐项批准的代码才能移植;不得整文件、整目录或整提交搬运后再清理。 - -### 7.4 按垂直切片重建 - -每个重建 PR 必须形成最小闭环,例如: - -```text -加载一份计划 → 编辑一个字段 → 校验 → 保存 → 重新加载 -``` - -存储迁移、安全 IPC、领域契约、UI、环境管理和更新机制原则上应分开提交。每个切片必须能独立构建、验证和回滚。 - -### 7.5 重写合并门槛 - -重写只有同时满足以下条件才能合并: - -- 原始复现在基线失败,并在重写后通过; -- 非回归行为通过验证; -- 失败 workaround 已不存在; -- 每份可变状态只有一个权威所有者; -- 不依赖废弃分支中的隐藏行为; -- PR 对比说明旧假设、删除的 workaround、前后状态源和架构差异; -- 受影响模块的维护者批准。 - -## 8. AI Agent 专用规则 - -模型能力不能代替工程流程。无论使用何种模型,Agent 都必须遵守以下要求。 - -### 8.1 修改前 - -Agent 必须先: - -1. 阅读本规范、相关架构文档和现有测试; -2. 搜索已有实现和历史 workaround; -3. 描述行为不变量、状态所有者、受影响层和验证方式; -4. 区分根因修复与临时 containment; -5. 检查当前工作树,保护无关的未提交修改; -6. 提交最小变更方案:目标、非目标、复用点、最少文件、预计新增符号及其必要性; -7. 说明为什么不采用更小的修改范围,以及为什么不新增模块、状态源或抽象。 - -不得在完成上述分析前直接生成大规模实现。默认先修改现有实现;除非最小方案证明必要,Agent 不得先搭目录、接口、Facade、Factory、Manager、Registry、EventBus、兼容层或未来扩展点。 - -### 8.2 修改中 - -Agent 必须声明当前修改是替代旧尝试、叠加旧尝试,还是删除旧 workaround。 - -Agent 不得: - -- 用 retry、sleep、debounce、catch-and-ignore 或 fallback 隐藏未知根因; -- 新增平行状态源以绕过所有权分析; -- 使用 `any`、类型断言、关闭校验或修改测试期望使失败消失; -- 修改测试去接受当前错误输出,除非先证明原期望错误; -- 生成无法判断实际采用哪条路径的 fallback 链; -- 仅凭 build、lint 或一次手工运行宣称完成; -- 把行为修改藏在格式化、生成文件或无关重构中; -- 为规避 PR 规模和止损审查而人为拆散同一个 workaround; -- 为单个调用方新增没有独立职责的 wrapper、helper、Facade、Factory 或 Service; -- 为未来可能需求新增未被当前契约使用的接口、配置、泛型、分支或扩展点; -- 为减少单文件行数、制造形式分层或搬运代码而拆分模块; -- 新增没有真实调用方、当前行为契约或独立测试支撑的代码; -- 在未说明必要性的情况下新增函数、类型、文件、依赖、缓存、状态字段或错误分支; -- 用“后续重构”“以后复用”或“方便扩展”作为当前堆积代码的理由。 - -每新增一个主要符号或文件,Agent 必须在工作记录中说明其当前行为目标、不能复用现有实现的原因、不能采用更小修改的原因、调用方或测试证据,以及新增的状态和失败路径。 - -### 8.3 强制暂停 - -出现以下任一情况,Agent 必须停止继续写修复代码并报告维护者: - -- 两次失败后仍无法提出实质不同的因果模型; -- 下一步需要新增可变标志或第二状态源; -- 修改意外跨入新的架构层; -- 验证无法排除时间巧合、旧缓存、模拟数据或偶然成功; -- 正准备保留失败 workaround“以防万一”; -- 无法解释完整状态转换; -- 当前上下文不足以知道已经做过多少次尝试。 -- 无法证明新增代码比修改现有代码更小、更清晰或更可验证。 - -报告必须列出观察证据、失败假设、剩余不确定性和建议的下一步。Agent 可以建议重写,但不得自行批准 L2 以上工作的继续实施。 - -### 8.4 AI 交付记录 - -AI 参与的 PR 必须记录: - -- 使用的分析和实现范围; -- 执行的命令和测试; -- 修复前后复现结果; -- 未验证路径; -- 影响的文件和架构层; -- 新增、删除和保留的状态源; -- 本任务中发生的失败尝试; -- 人工复核者。 - -不得把模型名称或流畅的解释当作质量证据。 - -## 9. PR 和提交规范 - -### 9.1 PR 必填信息 - -非平凡 PR 必须说明: - -- 用户可见或外部可观察的行为变化; -- 明确不包含的范围; -- 状态所有权; -- 涉及的架构层和依赖方向; -- 数据格式、目录、模板 ID 或默认行为变化; -- 兼容与迁移方案; -- 回滚方式; -- 自动测试、手工验证和未验证风险; -- 当前 patch 止损等级。 - -### 9.2 规模审查触发器 - -以下情况不是自动拒绝条件,但必须在实现前获得维护者确认并提供拆分理由: - -- 同时修改 UI、持久化、迁移、运行时和发布机制; -- 大量手写逻辑与资源文件混在同一提交; -- 修改范围使单个审查者无法在有限上下文中追踪完整行为; -- 一个 PR 包含多个可独立回滚的产品功能; -- 需要多个仓库尚未合并的补偿性修改才能运行。 - -生成文件、lockfile、fixture、图片和机械重命名应与手写行为代码分别统计和说明。 - -### 9.3 Commit - -- 遵循 Conventional Commits。 -- 一个 commit 对应一个逻辑变更。 -- 同一垂直切片自然涉及 Model、Controller、Types 和 View 时可以同 commit,但必须保持标准依赖方向。 -- 不得用 squash 隐藏决定止损等级的失败历史;可以在审查完成后 squash,但 PR 记录必须保留失败假设和整改过程。 - -## 10. 验证与 CI - -### 10.1 当前最低要求 - -仓库当前没有统一的 `npm test`、`lint`、`format` 入口,也没有 GitHub workflow;但已有按领域划分的确定性测试脚本。测试能力不足不是只执行 build 的理由: - -- 所有 TypeScript/SCSS 变更必须至少执行 `npm run build`; -- 变更打包和安装资源时,应执行 `npm run pack` 或说明无法执行的原因; -- 行为修改必须提供额外的确定性验证,不能以缺少测试框架为由只做 build; -- 新增复杂、可回归的行为时,应当同时引入最小必要测试基础设施,而不是继续扩大无测试代码。 -- 测试脚本涉及 Electron、端口、`userData`、临时目录、进程或共享缓存时,默认串行执行;只有明确隔离这些资源后才能并行。 - -### 10.2 新增测试的原则 - -- 测试应通过受影响的最近公共接口验证行为。 -- IPC、持久化、调度、异步顺序、序列化和跨仓通信问题不得只使用 mock-only 测试。 -- 迁移测试必须使用真实旧版 fixture。 -- 测试名称描述业务行为,不绑定废弃实现结构。 -- 修复 Bug 的测试必须证明它在修复前确实失败。 - -### 10.3 文档同步 - -修改架构、目录、命令、数据格式、后端契约或发布流程时,必须在同一 PR 更新对应文档。文档不得继续描述已经不存在的结构。 - -## 11. 例外流程 - -只有为阻止数据丢失、安全暴露、损坏发布或同等级严重事故,才可以先提交临时 containment。 - -例外必须: - -- 明确标记为临时 containment,不得声称已修复根因; -- 最小化行为变化; -- 说明回滚条件; -- 尽量不增加持久化状态; -- 在合并前关联根因整改项; -- 写明责任人和移除期限; -- 获得受影响模块维护者批准。 - -维护者批准例外时必须写明:豁免哪条规则、等待或重写为何风险更高、仍缺少什么证据、由谁在何时移除。AI Agent 不得自行批准例外。 - -## 12. 审查清单 - -合并前,作者和审查者应确认: - -- [ ] 行为目标和非目标清晰; -- [ ] 状态所有者唯一且写入路径明确; -- [ ] View、Controller、Model、Types、Electron 和后端依赖方向符合规范; -- [ ] 没有新增未经批准的 guard、retry、fallback、缓存或影子状态; -- [ ] 旧 workaround 已删除,而不是继续叠加; -- [ ] 数据格式和用户目录变化有版本化迁移; -- [ ] 未知字段和旧用户数据不会丢失; -- [ ] IPC 路径和文件写入失败模式经过验证; -- [ ] 后端契约和兼容版本明确; -- [ ] 修复前失败、修复后通过的证据可复现; -- [ ] 当前止损等级和失败尝试已披露; -- [ ] 已执行最小方案和反堆积复核; -- [ ] 每个新增文件、符号、依赖、状态源和测试均有必要性证据; -- [ ] 没有无调用方的预留接口、抽象、兼容层或扩展点; -- [ ] 没有因文件行数、目录外观或形式分层而拆分模块; -- [ ] 架构和使用文档已同步; -- [ ] 无关文件、用户数据和本地未提交修改未被包含。 - -## 13. GUI 2.0 合并协作边界 - -我这边已经梳理过 GUI 2.0 和当前稳定分支的差异。为了避免我们分别修改 Scheduler、配置模型和舰队语义,后续按下面的边界协作。 - -### 你在合并前需要完成的范围 - -你负责把 GUI 2.0 当前已经实现的功能收敛到“可合并、不会破坏已有数据”的状态,重点是现有功能和基础架构,不需要替我实现自动强化。 - -#### 1. 先解决当前 review 中会造成数据损坏、安全问题或兼容性破坏的阻塞项 - -- 给主进程文件 IPC 加严格的路径边界校验,禁止路径穿越和访问受管目录以外的文件。 -- 修复 AtomicFileStore:写入失败时必须保留原文件,不能出现旧文件先被删除、临时文件又未成功替换的情况。 -- 统一外部 Python 环境的依赖安装目录和实际启动目录。 -- 保持 v1.4.1 及当前稳定版计划格式兼容,不能要求用户手工重写旧计划。 -- 完成旧 path-form 任务组迁移,迁移失败时要保留原数据并给出明确错误。 -- 设置 YAML round-trip 时保留 GUI 尚不认识的字段,尤其是未知嵌套字段。 -- 修复 candidate-only 语义:槽位没有顶层 `name` 时,所有 `candidates` 都是平等备选,不能把第一个 candidate 自动提升成 primary。 - -#### 2. 稳定 GUI 2.0 已经引入的核心契约 - -- 舰队方案与出征计划保持分离。 -- 编辑态计划可以引用独立舰队 YAML。 -- 运行前由 `RuntimePlanService` 展开为后端可执行的完整 YAML。 -- 系统计划与用户计划有明确的读写边界。 -- `SchedulerRepairPolicy`、`SchedulerTaskPolicy`、`RepairManager`、`TaskQueue.switchTaskPreset()` 的责任边界保持清晰。 -- 编队轮换继续使用统一 Scheduler,不再增加另一套并行状态机。 - -#### 3. 处理旧活动计划兼容性 - -当前旧目录中有用户正在使用的活动计划,不能仅通过删除: - -- `resource/builtin_plans/活动20260730-E1炸鱼.yaml` -- `resource/builtin_plans/活动20260730-E5夜战.yaml` -- `resource/builtin_plans/活动20260730-H1炸鱼.yaml` -- `resource/builtin_plans/活动20260730-H5夜战.yaml` - -并在 `resource/system_battle_plans` 放几个相似文件,就认为迁移完成。 - -合并前需要验证: - -- 旧计划仍可读取、执行或显式迁移。 -- 新旧计划行为等价。 -- 用户修改过的旧文件不会被安装、升级或迁移流程静默覆盖。 -- 无法迁移时保留原文件,并显示可理解的错误。 - -#### 4. 合并前最低验收程度 - -- `npm run build` 通过。 -- `git diff --check` 通过。 -- 至少覆盖以下回归测试: - - legacy plan 读取和迁移; - - candidate-only 舰队槽位; - - 设置 YAML 未知字段保留; - - 原子写入失败恢复; - - 文件 IPC 路径越界拒绝; - - 任务组旧格式迁移; - - 四个活动计划的兼容性; - - 舰队引用展开后的运行时 YAML 合法性。 -- 把 PR 中尚未接到 UI 的执行入口明确处理:要么接通,要么删除死入口,要么明确标记为暂不支持,不能保留一个看似可用但实际没有调用方的链路。 -- 最好按领域拆分提交,至少让 Electron 服务、舰队 YAML、运行时计划、Scheduler 规则、UI 和舰船资源可以分别审查。 - -### 可以在合并后交给我处理的内容 - -以下内容不要求你在 GUI 2.0 合并前完成: - -- 自动强化的业务实现。 -- 自动强化后端 API 和设备独占租约。 -- intensify Scheduler 任务类型。 -- 自动强化的保护舰集合计算。 -- 自动强化与轮换、泡澡、战斗任务之间的优先级。 -- 自动强化设置 UI 和最终 Scheduler 接线。 -- 自动强化运行日志、失败重试和任务状态展示。 -- 将后端唯一舰船 ID 引入自动强化保护规则;在后端尚未提供 ID 时先兼容舰名。 - -但请预留正常的扩展边界,不要把 Scheduler task type、API 请求或设置 schema 写成无法扩展的封闭分支。 - -### 我这边会负责什么 - -我负责自动强化以及它与稳定分支公共调度层的最终集成: - -- 后端定义并实现自动强化 API。 -- 所有强化设备操作使用全局 device lease,不能与战斗、泡澡或解装并发。 -- 自动强化只作为统一 Scheduler 的正式任务执行,不启动独立后台定时器。 -- 强化前重新读取保护集合,至少保护: - - 当前游戏编队; - - 所有启用轮换预设; - - 正在泡澡的舰船; - - 收藏或锁定舰; - - 强化目标舰; - - 用户自定义保护名单。 -- 设计素材舰筛选、上限、失败和重试规则。 -- 接入 `ApiClient`、API types、Scheduler types、`ConfigModel`、`ConfigController` 和 `SchedulerBinder`。 -- 最终处理自动强化与 GUI 2.0 合并后的公共文件冲突。 - -### 我在 GUI 2.0 合并前会做到的程度 - -我会优先在后端和独立领域层工作,避免现在修改你的舰队编辑器、计划管理 UI 和 Scheduler 核心: - -- 先稳定自动强化的 HTTP contract。 -- 接入后端全局设备租约。 -- 建立 ops 层和 UI controller 的责任边界。 -- 定义目标舰、允许的素材舰种、素材数量上限和保护名单。 -- 编写无设备单元测试和 route contract 测试。 -- 未完成真实设备坐标与 OCR 标定前,功能必须 fail closed,不能尝试点击或消耗任何舰船。 -- GUI 2.0 合并后,我再基于你的最终 Scheduler 和配置架构完成 GUI 任务接线,避免双方同时修改同一套状态机。 - -总体原则是:你负责让 GUI 2.0 已有功能安全、兼容、可合并;我负责自动强化及合并后的公共调度集成。编队轮换核心继续以你的 GUI 2.0 实现为准,我不会另写一套。 diff --git a/docs/features/gui-2.0-alpha-pr-report.md b/docs/features/gui-2.0-alpha-pr-report.md index befe954..1e2008e 100644 --- a/docs/features/gui-2.0-alpha-pr-report.md +++ b/docs/features/gui-2.0-alpha-pr-report.md @@ -57,7 +57,7 @@ AutoWSGR 运行环境整理为可校验、可迁移、可维护的完整流程 此前审计发现的 Controller 直接 DOM/全局 bridge `major` 问题已经整改。42 个 Controller 文件的 DOM、DOM 类型、浏览器事件和 `window.electronBridge` 扫描均为 -0;`npm run test:controller-boundaries` 已把该约束固化为回归门禁。 +0;`npm run test:architecture-boundaries` 已把该约束固化为回归门禁。 ## 2. 新增功能 diff --git a/docs/features/gui-2.0.md b/docs/features/gui-2.0.md index 66b43f9..4f6b69d 100644 --- a/docs/features/gui-2.0.md +++ b/docs/features/gui-2.0.md @@ -1005,7 +1005,7 @@ npm run build ### 16.2 旧计划迁移测试 ```powershell -npm run test:legacy-plan +npm run test:migrations ``` 测试在系统临时目录中: @@ -1204,7 +1204,7 @@ View ### 工程 - [ ] `npm run build` 通过。 -- [ ] `npm run test:legacy-plan` 通过。 +- [ ] `npm run test:migrations` 通过。 - [ ] `npm run test:settings` 通过。 - [ ] `git diff --check` 通过。 - [ ] `npm run dist` 生成 `2.0.0-alpha` 安装包和 `alpha.yml`。 diff --git a/docs/plan-guide.md b/docs/plan-guide.md index ea6a5b1..cb3d2c3 100644 --- a/docs/plan-guide.md +++ b/docs/plan-guide.md @@ -1,400 +1,555 @@ -# 战斗方案 YAML 编写指南 +# 计划与出征 GUI 使用指南 -本文档介绍 AutoWSGR-GUI 的战斗方案 YAML。系统预设来自只读 -`resource/system_battle_plans/`,GUI 保存的用户方案位于 Electron -`userData/user_battle_plans/`,并通过计划管理页统一加载。旧目录中的 YAML -会在启动时自动升级迁移;其他本地 YAML 可通过计划加载浮窗的“添加本地 -YAML”升级并复制到用户计划目录,原文件不会被修改。 +本文档按当前 AutoWSGR-GUI 的实际界面和工作区实现编写。普通用户应通过 GUI +创建、保存、管理和执行计划,不再以手写 YAML 作为主要工作流。 + +完整流程分为四步: + +```text +计划 -> 舰队规划 -> 保存舰队方案 +计划 -> 出征规划 -> 保存出征计划 +主页 -> 任务列表 -> 加载计划并加入执行队列 +主页 -> 开始执行 +``` + +> 演习、战役和决战属于独立日常任务。它们从主页的“加载日常任务”进入, +> 不进入普通出征计划管理。 --- -## 方案类型 +## 1. “计划”页概览 -每个 YAML 文件对应一种任务类型。通过 `task_type` 字段(或直接使用 `chapter` + `map` 隐含常规战斗)来区分。 +主导航进入“计划”后,可以看到四个标签: -常规战斗方案可以直接内嵌任务控制字段(`times`、`stop_condition` 等),无需拆分为两个文件。 +| 标签 | 用途 | +|------|------| +| 舰队规划 | 创建主选舰船、备选队列和选船规则,并保存为舰队方案 | +| 出征规划 | 配置地图、路线、节点行为、执行次数和关联舰队方案 | +| 决战计划(旧) | 保留的旧决战编辑入口,不是普通出征计划的主流程 | +| 计划管理 | 查看系统与用户 YAML、引用关系、异常状态,并执行导出或删除 | -### 1. 常规战斗 (normal_fight) +“舰队方案”和“出征计划”是两个独立对象: -最常用的方案类型,用于地图出击。任务控制字段(`times`、`stop_condition` 等)可直接写在方案中。 +- 舰队方案回答“使用哪些舰船,找不到主选时按什么顺序替换”。 +- 出征计划回答“去哪里、走哪些节点、每个节点如何战斗、执行多少次”。 +- 一个出征计划可以关联一个或多个舰队方案。 +- “使用舰队 1~4”表示游戏内舰队编号,不等于关联的舰队方案。 -```yaml -chapter: 9 -map: 2 -selected_nodes: [A, D, G, H, M, O, E, K] -fight_condition: 1 -repair_mode: 1 -fleet_id: 1 -times: 9999 -stop_condition: - loot_count_ge: 50 +--- -node_defaults: - formation: 4 - night: false - proceed: true +## 2. 首次创建计划 -node_args: - E: - enemy_rules: - - [NAP < 1, retreat] -``` +推荐按以下顺序操作: -也可以引用内置方案(纯任务预设,不包含地图数据): +1. 在“舰队规划”中新建并保存至少一个舰队方案。 +2. 切换到“出征规划”,点击“新建”。 +3. 先选择章节和关卡,再配置节点路线。 +4. 填写配置名称、执行次数、战况和维修策略。 +5. 点击“选择预设队伍”,关联已保存的舰队方案。 +6. 至少启用一个实际地图节点,并根据需要设置终点。 +7. 点击“保存”生成用户出征计划。 +8. 返回主页,点击“加载计划”加入任务列表,再将单项或全部任务加入队列。 +9. 检查执行队列后点击“开始执行”。 -```yaml -task_type: normal_fight -plan_id: 2-1捞胖次 -times: 10 -``` +先选地图再编辑路线很重要。切换章节或关卡会清空原路线和节点配置。 -### 2. 战役 (campaign) +--- -```yaml -task_type: campaign -campaign_name: 困难航母 -times: 3 -``` +## 3. 创建舰队方案 -`campaign_name` 可选值:`简单驱逐`、`简单巡洋`、`简单战列`、`简单航母`、`简单潜艇`、`困难驱逐`、`困难巡洋`、`困难战列`、`困难航母`、`困难潜艇`。 +### 3.1 新建和命名 -### 3. 演习 (exercise) +进入“计划 -> 舰队规划”: -```yaml -task_type: exercise -fleet_id: 4 -``` +1. 点击“新建”清空当前草稿。 +2. 在“预设名称”中输入名称。 +3. 配置主选和备选舰船。 +4. 点击“保存”。 -### 4. 决战 (decisive) - -```yaml -task_type: decisive -chapter: 6 -level1: - - U-1206 - - U-96 - - 鹦鹉螺 -level2: - - 大青花鱼 -flagship_priority: - - U-1206 -``` +保存时必须满足: + +- 预设名称不能为空。 +- 至少存在一艘主选或备选舰船。 +- 同一艘舰船不能在一个方案中被重复分配。 + +“加载”可以读取系统和用户舰队方案。系统方案只读;加载系统方案后保存, +会生成用户方案,不会覆盖系统文件。 + +### 3.2 配置主选舰船 + +左侧“主选队列”固定提供六个位置,右侧是“舰娘图鉴”。 + +基本操作: + +1. 点击要编辑的主选位置。 +2. 在图鉴中搜索或筛选舰船。 +3. 点击目标舰船,将其分配到当前选中位置。 +4. 可拖动已分配的主选位置调整顺序。 +5. 删除主选后,完全为空的位置会自动向左压缩。 + +图鉴支持: -### 5. 活动 (event_fight) +- 按舰名或编号搜索。 +- 按大型舰、中型舰、小型舰、主力舰和护卫舰分组筛选。 +- 按具体舰种、国籍和是否改造筛选。 +- 按类型、名称或编号排序。 +- 使用“恢复默认”清除筛选条件。 -与常规战斗类似,但需指定 `task_type: event_fight` 和 `event_name`。 +### 3.3 配置备选队列 + +每个主选位置都有独立的备选队列。运行时会优先保留主选,主选不可用时才 +按备选顺序尝试替换。 + +操作方法: + +1. 点击一个主选位置。 +2. 在“位置 N 的备选队列”中点击“增加备选”。 +3. 点击新增的备选槽位。 +4. 从图鉴中选择舰船。 +5. 拖动备选槽位可调整尝试顺序。 + +“复制备选”可以将当前位置的备选队列复制到其他主选位置。 + +舰队也支持“纯备选位置”:主选保持为空,只配置备选舰船。此时系统不会 +自动把第一艘备选提升为主选,运行时仍按纯备选规则处理。 + +### 3.4 舰船跟随和位置跟随 + +工具栏按钮可以在两种模式之间切换: + +| 模式 | 拖动主选位置时的行为 | +|------|----------------------| +| 舰船跟随 | 主选舰船和它的备选队列一起移动 | +| 位置跟随 | 只移动主选及其规则,备选队列保留在原位置 | + +调整舰位前先确认当前模式,避免主选重排后备选关系与预期不一致。 + +### 3.5 宽泛校验和等级限制 + +主选和每个备选都可以独立设置: + +- “宽泛校验”:编队识别时要求名称一致;等级和舰种仍会检查,但不作为 + 强制失败条件。 +- “等级限制”:启用后可填写最低和最高等级。 + +这些设置作用于当前选中的主选或备选目标,不是整个舰队的统一开关。 + +建议: + +- 有明确舰船要求时保持严格校验。 +- 只有在名称可信、但资料库等级或舰种信息可能不一致时使用宽泛校验。 +- 最低等级不能高于最高等级。 --- -## 常规战斗字段详解 +## 4. 创建出征计划 -以下是常规战斗方案的完整字段说明。 +进入“计划 -> 出征规划”,点击节点路线右上角的“新建”。 -### 顶层字段 +新计划只启用内部起始节点 `0`。节点 `0` 用于后端追踪起点,不代表已经配置 +了可执行路线;保存前应继续启用至少一个实际地图节点。 -| 字段 | 类型 | 必填 | 说明 | -|------|------|------|------| -| `chapter` | 数字 | 是 | 地图章节 (如 `9`) | -| `map` | 数字 | 是 | 地图编号 (如 `2` 表示 9-2) | -| `selected_nodes` | 字符串列表 | 是 | 途经的节点列表,如 `[A, D, G, H, M, O]` | -| `fleet_id` | 数字 | 否 | 使用的舰队编号 (1-4),默认 `1` | -| `fight_condition` | 数字 | 否 | 战况条件,见下表 | -| `repair_mode` | 数字或数组 | 否 | 修理策略,见下表 | -| `node_defaults` | 对象 | 否 | 所有节点的默认配置 | -| `node_args` | 对象 | 否 | 按节点名覆盖的个性化配置 | -| `times` | 数字 | 否 | 循环执行次数,默认 `1` | -| `gap` | 数字 | 否 | 每次执行间隔(秒),默认 `0` | -| `stop_condition` | 对象 | 否 | 停止条件,满足时自动停止循环 | +### 4.1 基础配置 -### fight_condition 战况条件 +| GUI 字段 | 说明 | +|----------|------| +| 当前配置名称 | 用户方案名称,保存时必填 | +| 章节、关卡 | 当前地图;切换后会重置路线和节点配置 | +| 执行次数 | 计划默认循环次数,最少为 1 | +| 轮次间隔 | 相邻轮次之间等待的秒数 | +| 使用舰队 | 游戏内第 1~4 舰队 | +| 战况处理 | 游戏内战况选择 | +| 维修策略 | 中破就修或大破才修 | +| 战利品检测 | 今日战利品达到指定数量后停止 | +| 掉落检测 | 今日获得舰船达到指定数量后停止 | -| 值 | 含义 | -|----|------| -| `1` | 稳步前进 | -| `2` | 火力万岁 | -| `3` | 小心翼翼 | -| `4` | 瞄准 | -| `5` | 搜索阵型 | +“战况处理”当前提供: -### repair_mode 修理策略 +1. 稳步前进 +2. 火力万岁 +3. 全力以赴 +4. 深入敌阵 +5. 以退为进 -| 值 | 含义 | -|----|------| -| `1` | 中破就修 | -| `2` | 大破才修 | +“维修方式”下拉框当前显示“快速维修”和“泡澡维修”,但泡澡维修尚未接入 +完整的计划保存与生产执行链路。当前版本不要依赖该选项设计任务,应以已生效 +的“维修策略”和默认快速维修流程为准。 -可以设为单个数字(所有舰位统一),也可以设为 6 个数字的列表(每个舰位独立设置): +### 4.2 关联舰队方案 -```yaml -repair_mode: 2 # 所有位置大破才修 -repair_mode: [2, 2, 2, 1, 2, 2] # 4号位中破就修,其余大破才修 -``` +出征计划可以引用“舰队规划”中保存的方案: -### stop_condition 停止条件 +1. 点击“选择预设队伍”。 +2. 使用搜索、系统方案过滤和排序找到目标方案。 +3. 点击方案可预览六个主选位置和各位置备选队列。 +4. 将方案卡片拖到左侧“编队配置”列表。 +5. 点击已关联卡片右上角的 `×` 可以移除引用。 -| 字段 | 类型 | 说明 | -|------|------|------| -| `loot_count_ge` | 数字 | 战利品数量 ≥ 该值时停止 | -| `ship_count_ge` | 数字 | 获取舰船数量 ≥ 该值时停止 | +可以关联多个舰队方案。计划加入任务列表时,可从这些关联方案中选择本次 +使用的方案;计划中的“使用舰队 1~4”仍决定游戏内操作哪个舰队。 -示例: +如果选择器提示没有编队预设,请先回到“舰队规划”保存方案。 -```yaml -stop_condition: - loot_count_ge: 50 -``` +--- -### 编队预设与模糊选船 (fleet_presets) - -常规战斗支持在方案里写 `fleet_presets`,每个槽位可写为固定舰名(字符串)或模糊筛选对象。 - -模糊筛选对象常用字段: - -| 字段 | 类型 | 说明 | -|------|------|------| -| `name` | 字符串 | 指定舰名(可写改造名) | -| `nation` | 字符串 | 国籍筛选,如 `日本`、`德国` | -| `ship_type` | 字符串 | 舰种筛选,如 `dd`、`ss` | -| `min_level` | 数字 | 最低等级 | -| `max_level` | 数字 | 最高等级 | -| `priority` | 字符串列表 | 候选优先顺序(按顺序尝试) | - -示例: - -```yaml -fleet_presets: - - name: 岛风日驱队 - ships: - - { name: 岛风(岛风型驱逐舰)·改, min_level: 100 } - - { - nation: 日本, - ship_type: dd, - min_level: 100, - priority: [岛风(岛风型驱逐舰)·改, 黑潮·改, 阳炎·改, 早春·改] - } -``` +## 5. 配置地图路线和节点 + +### 5.1 地图切换 + +修改章节或关卡时,GUI 会重置: + +- 已选择路线,只保留内部起始节点 `0`。 +- 终点节点。 +- 终点最低战果。 +- 所有节点个性化配置。 + +因此应先确定地图,再编辑节点。地图切换后需要重新检查整条路线。 + +### 5.2 启用节点 + +点击地图上的节点会打开“节点信息”: + +1. 打开“启用节点”,将该节点加入路线。 +2. 配置该节点支持的选项。 +3. 点击“应用”保存当前节点修改。 +4. 完成全部节点编辑后,再点击页面右上角“保存”保存整个出征计划。 + +关闭“启用节点”会将该节点从路线中移除。 -说明: +非战斗节点只支持启用或关闭,不显示阵型、夜战等战斗配置。 -- `priority` 建议直接写在 YAML 中,便于按任务场景维护。 -- 当同一编队有多个同类模糊槽位时,可重复写同一组 `priority`;系统会自动跳过已被前序槽位占用的舰船。 +### 5.3 终点节点和战果 + +启用节点后,可以将其标记为“终点节点”。 + +- 终点默认不再继续前进。 +- “战果判断”为“无”时,经过该节点即认定本轮完成。 +- 选择 `D`、`C`、`B`、`A`、`S` 或 `SS` 时,只有达到最低战果才算本轮完成。 +- 未到达终点,或终点战果不足时,不会扣减剩余执行次数。 + +终点应与实际路线一致。仅启用路线节点但没有正确设置终点,可能导致任务没有 +按预期计次。 + +### 5.4 战斗节点设置 + +| 设置 | 作用 | +|------|------| +| 阵型选择 | 单纵阵、复纵阵、轮型阵、梯形阵或单横阵 | +| 前进 | 战斗结束后继续推进 | +| 夜战 | 进入夜战 | +| 远程打击 | 在支持的节点使用远程打击 | +| 迂回 | 在支持迂回的节点尝试绕过战斗 | +| 迂回失败 S/L | 迂回失败时执行 S/L | +| 索敌规则 | 根据敌方舰队条件撤退、迂回或改用指定阵型 | + +迂回相关选项只会在地图数据支持的节点显示。 + +加载已有 YAML 时,节点会先继承方案中的默认设置,再应用该节点自己的覆盖 +设置。通过 GUI 点击“应用”修改的是当前节点配置。 --- -## 节点配置 (node_defaults / node_args) +## 6. 编写索敌规则 + +索敌规则在节点编辑器中按行填写。每行格式为: -`node_defaults` 设置所有节点的默认行为,`node_args` 为特定节点覆盖配置。两者使用相同的字段。 +```text +条件, 动作 +``` + +也可以使用中文逗号、`=>` 或 `->` 分隔: -| 字段 | 类型 | 说明 | -|------|------|------| -| `formation` | 数字 | 阵型选择 | -| `night` | 布尔 | 是否进行夜战 | -| `proceed` | 布尔 | 战斗后是否继续推进 | -| `proceed_stop` | 数字列表 | 各位置的推进/撤退阈值 (6 个值,对应 6 个舰位) | -| `enemy_rules` | 列表 | 索敌规则,根据敌方编队决定行为 | +```text +CV >= 1, retreat +DD >= 1 and CL >= 2 => 4 +CV == 0 -> detour +``` -### formation 阵型 +### 6.1 支持的动作 -| 值 | 阵型 | -|----|------| +| 动作 | 说明 | +|------|------| +| `retreat` 或 `撤退` | 撤退 | +| `detour` 或 `迂回` | 尝试迂回 | +| `1`~`5` | 使用对应编号的阵型战斗 | + +阵型编号: + +| 编号 | 阵型 | +|------|------| | `1` | 单纵阵 | | `2` | 复纵阵 | | `3` | 轮型阵 | | `4` | 梯形阵 | | `5` | 单横阵 | -### proceed_stop 推进阈值 +### 6.2 条件写法 + +支持的比较符: -6 个数字的列表,分别对应舰队 6 个位置。每个值表示该位置舰船受损到何种程度时停止推进: +```text +> >= < <= == != +``` + +注意: -| 值 | 含义 | -|----|------| -| `1` | 中破停止 | -| `2` | 大破停止 | +- 比较符必须使用英文半角字符,不支持 `≥` 和 `≤`。 +- 舰种代码使用后端接受的大写代码,例如 `CV`、`DD`、`CL`。 +- 多个条件可以使用 `and` 连接。 +- 空行和以 `#` 开头的注释行会被忽略。 +- 规则按填写顺序传递,应把更具体的条件放在前面。 -示例:`[2, 2, 2, 2, 2, 2]` 表示所有位置大破才停。 +无效规则不会阻止整个计划保存,而是会被忽略并写入警告日志。保存后应检查 +日志,确认没有规则解析警告。 --- -## 索敌规则 (enemy_rules) +## 7. 保存、加载和导入 -索敌规则是方案中最重要的部分,决定了索敌成功后根据敌方舰队组成采取的行动。 +### 7.1 保存 -### 格式 +点击“保存”时: -```yaml -enemy_rules: - - [条件表达式, 动作] - - [条件表达式, 动作] -``` +- 配置名称不能为空。 +- 系统方案不会被覆盖。 +- 从系统方案加载后保存,会创建用户配置。 +- 已加载的用户方案会更新对应用户文件。 -规则**按顺序匹配**,第一条满足条件的规则生效。如果所有规则都不满足,则按默认阵型战斗。 +GUI 保存的是受管 YAML,但用户不需要手工选择保存路径。 -### 动作 +### 7.2 加载 -| 动作 | 说明 | -|------|------| -| `1` ~ `5` | 使用对应阵型战斗 (1=单纵 2=复纵 3=轮型 4=梯形 5=单横) | -| `retreat` | 撤退 | +点击“加载”会打开“加载出征配置”: -### 条件表达式 +- 列表同时显示合法的系统方案和用户方案。 +- 可以按名称或关卡搜索。 +- 可以过滤系统预设。 +- 可以按名称或修改时间排序。 +- 右侧预览显示所选方案摘要。 -条件表达式基于敌方舰队中的**舰种数量**进行判断。 +系统方案用于查看和派生用户配置,不能直接改写。 -#### 可用舰种代号 +### 7.3 添加本地 YAML -| 代号 | 舰种 | -|------|------| -| `CV` | 航母 | -| `CVL` | 轻母 | -| `AV` | 装母 | -| `BB` | 战列 | -| `BBV` | 航战 | -| `BC` | 战巡 | -| `CA` | 重巡 | -| `CAV` | 航巡 | -| `CLT` | 雷巡 | -| `CL` | 轻巡 | -| `DD` | 驱逐 | -| `SS` | 潜艇 | -| `SSG` | 导潜 | -| `SC` | 炮潜 | -| `NAP` | 补给舰 | -| `BM` | 重炮 | - -#### 运算符 - -| 运算符 | 说明 | 示例 | -|--------|------|------| -| `+` | 多舰种数量求和 | `DD + CL` | -| `>=` | 大于等于 | `NAP >= 1` | -| `<=` | 小于等于 | `DD + CL <= 1` | -| `>` | 大于 | `CV > 0` | -| `<` | 小于 | `NAP < 1` | -| `==` | 等于 | `CVL == 1` | -| `and` | 逻辑与 | `CVL == 1 and CV == 0` | - -### 迂回节点的特殊行为 - -地图数据中某些节点标记为**迂回节点**(在 GUI 方案预览中以虚线边框显示)。对于迂回节点: - -- **默认行为:尝试迂回**(不战斗直接绕过) -- 如果 `enemy_rules` 命中某条规则且动作为**阵型编号**(1-5),则**取消迂回,改为战斗** -- 如果 `enemy_rules` 命中 `retreat`,则**撤退** -- **迂回失败时默认继续战斗**(除非另有配置) +“添加本地 YAML”用于导入旧方案或外部方案: + +1. 选择一个本地 YAML 文件。 +2. GUI 将其升级为当前受管格式。 +3. 升级结果复制到用户方案目录。 +4. 如果文件包含内嵌舰队,GUI 可将其升级为关联的用户舰队方案。 +5. 后续运行使用复制后的受管文件,不再依赖原文件。 + +原始本地文件不会成为运行时权威来源。导入后应在“出征规划”和“计划管理” +中检查地图、路线、节点、舰队引用和异常状态。 + +演习、战役和决战 YAML 不能导入普通出征管理,应使用主页的“加载日常任务”。 --- -## 完整示例 - -### 示例 1:9-2 捞胖次 - -迂回节点有补给舰时战斗,否则迂回;E/K 点无补给舰则撤退。内嵌循环 9999 次、战利品 ≥ 50 自动停止。 - -```yaml -# 9-2 捞胖次 -chapter: 9 -map: 2 -selected_nodes: [A, D, G, H, M, O, E, K] -fight_condition: 1 -repair_mode: 2 -fleet_id: 1 -times: 9999 -gap: 0 -stop_condition: - loot_count_ge: 50 - -node_defaults: - formation: 4 - night: false - proceed: true - -node_args: - A: - enemy_rules: - - [AP >= 1, 4] - - [AP < 1, detour] - D: - enemy_rules: - - [AP >= 1, 4] - - [AP < 1, detour] - G: - enemy_rules: - - [AP >= 1, 4] - - [AP < 1, detour] - H: - enemy_rules: - - [AP >= 1, 4] - - [AP < 1, detour] - M: - enemy_rules: - - [AP >= 1, 4] - - [AP < 1, detour] - E: - enemy_rules: - - [AP < 1, retreat] - K: - enemy_rules: - - [AP < 1, retreat] - O: - enemy_rules: - - [AP < 1, retreat] -``` +## 8. 计划管理 -### 示例 2:7-4 漂流捞胖次 - -根据敌方编队组成选择不同阵型,Boss 点夜战。 - -```yaml -# 7-4 漂流捞胖次 -chapter: 7 -map: 4 -selected_nodes: [A, B, C, E, D, F, G, H, I, J, L, M, K] -fight_condition: 4 -repair_mode: 1 -fleet_id: 3 - -node_defaults: - enemy_rules: - - [DD + CL <= 1, 4] - - [CVL == 1 and CV == 0, 4] - formation: 2 - night: false - proceed: true - proceed_stop: [2, 2, 2, 2, 2, 2] - -node_args: - M: - enemy_rules: - - [SAP < 1, retreat] - formation: 4 - night: true - I: - enemy_rules: - - [SAP < 1, retreat] -``` +“计划 -> 计划管理”统一展示出征计划和舰队方案。 + +### 8.1 筛选和状态 + +页面支持: + +- 按全部、系统、用户来源筛选。 +- 按出征计划或舰队方案筛选。 +- 按计划名、舰队名或文件名搜索。 +- 使用“过滤非异常”只查看需要处理的项目。 +- 查看出征计划与舰队方案的引用关系。 +- 查看任务列表对计划的引用。 + +“未被引用”不一定表示配置损坏。独立保存、准备后续使用的舰队方案可以点击 +“忽略提示”;需要重新纳入检查时点击“恢复检查”。 + +### 8.2 系统配置和用户配置 + +| 来源 | 可查看 | 可编辑 | 可删除 | 可批量导出 | +|------|--------|--------|--------|------------| +| 系统 | 是 | 只读查看 | 否 | 否 | +| 用户 | 是 | 是 | 是 | 是 | + +编辑系统配置后保存,会生成用户配置,不会修改安装目录中的系统文件。 + +### 8.3 重命名 -### 示例 3:简单挂机方案 +- 重命名用户出征计划时,只修改受管文件名,不改写 YAML 中的后端业务字段。 +- 重命名用户舰队方案时,会同步更新用户出征计划中对该方案名称的精确引用。 +- 系统计划和系统舰队方案不能重命名。 -引用内置方案,设置次数。 +重命名后仍应检查任务列表中的引用状态,尤其是长期保存的旧任务列表。 -```yaml -task_type: normal_fight -plan_id: 2-1捞胖次 -times: 10 +### 8.4 删除 + +删除是不可撤销操作,并且不会级联删除引用方: + +- 删除出征计划不会自动删除引用它的任务列表条目。 +- 删除舰队方案不会自动删除引用它的出征计划。 +- 删除后,引用方可能显示文件缺失或关联异常。 +- 批量删除只处理当前筛选结果中已勾选的用户配置。 + +删除前应先查看“关联关系”和“任务分组”两列。 + +### 8.5 批量导出 + +勾选用户配置后点击“批量导出”,GUI 会生成 ZIP: + +```text +user_battle_plans/ +user_team_plans/ ``` -### 示例 4:战役 +系统配置不会进入导出选择。 + +--- + +## 9. 加入任务列表并执行 + +保存计划后回到主页,在“任务列表”中操作。 + +### 9.1 加载普通出征计划 + +点击“加载计划”: + +1. 选择一个出征计划。 +2. 如果计划关联了多个舰队方案,选择本次使用的方案。 +3. 确认执行次数。 +4. 确认后将计划加入当前任务列表。 + +任务列表中的单项可以: + +- 修改执行次数。 +- 调整顺序。 +- 点击“加入队列”单独加载。 +- 拖入执行队列。 +- 从列表中移除。 + +点击“全部加入队列”会按当前任务列表顺序批量加载。 + +主页空闲区的“导入战斗方案 (.yaml)”是直接入队入口。它会打开受管出征计划 +选择器,选定计划和舰队方案后直接加入执行队列,不经过当前任务列表。 + +### 9.2 保存任务列表 + +任务列表可以作为一组常用任务保存: + +- 输入任务列表名称。 +- 点击“保存”。 +- “新建”用于清空并创建新列表。 +- “队列管理”用于加载或管理已保存的任务列表。 + +任务列表保存的是计划来源、文件名和舰队方案选择等引用,不会复制被引用的 +计划文件。删除或重命名计划后,应重新检查任务列表。 + +### 9.3 开始和停止 + +执行队列确认无误后点击“开始执行”。 + +运行区域会显示: + +- 当前任务和进度。 +- 后续排队任务。 +- 轮次间隔等待。 +- 失败重试等待。 +- 修理等待。 + +等待中的任务仍属于队列,可以取消。运行中可点击“停止任务”;停止完成前 +按钮会显示“停止中”。 + +执行前系统会再次验证路线。如果计划只有内部起始节点 `0`,会拒绝入队并提示: -```yaml -task_type: campaign -campaign_name: 困难航母 -times: 3 +```text +出征计划只启用了起始节点,请至少开启一个路线节点 ``` -### 示例 5:演习 +--- + +## 10. 日常任务和旧决战入口 + +主页的“加载日常任务”提供: + +- 演习 +- 战役 +- 决战 + +选择配置后,可以“加入任务列表”或“加入执行队列”。 + +这些配置使用独立的日常任务目录和加载流程,不进入普通出征计划管理。不要 +为了执行日常任务,把演习、战役或决战 YAML 导入“出征规划”。 + +“计划”页中的“决战计划(旧)”是保留的旧编辑入口。新建日常执行任务时, +以主页“加载日常任务”的实际可用配置为准。 + +--- + +## 11. 常见问题 + +### 保存提示“请先填写预设名称” + +舰队方案和出征计划都要求名称非空。先填写页面顶部的名称,再点击保存。 + +### 新计划为什么看起来没有路线 + +新计划只包含内部起始节点 `0`。选择地图后,必须手动启用至少一个实际节点。 + +### 切换地图后原节点配置为什么消失 + +这是当前 GUI 的明确行为。切换章节或关卡会重置路线、终点、战果和节点配置, +防止旧地图节点错误套用到新地图。 + +### 为什么找不到舰队方案 + +先在“舰队规划”中保存方案,再刷新出征规划的队伍选择器。还应检查是否开启了 +“过滤系统预设”或输入了搜索条件。 -```yaml -task_type: exercise -fleet_id: 4 +### 为什么系统方案不能直接修改 + +系统方案属于安装资源,只读。加载后修改并保存,会生成新的用户配置。 + +### 舰队方案显示“未被引用”是否必须删除 + +不是。它只表示当前没有出征计划引用。确认是备用方案后,可以使用“忽略提示”。 + +### 删除舰队方案后出征计划还在吗 + +仍在,但引用会变成缺失或异常。删除操作不会自动修改或删除引用方。 + +### 索敌规则保存后没有生效 + +检查是否使用英文半角比较符、合法动作和大写舰种代码。无效规则会被忽略, +应查看日志中的解析警告。 + +### 可以使用泡澡维修吗 + +当前 GUI 有“泡澡维修”选项,但尚未接入完整保存和生产执行链路。不要依赖它 +安排任务。 + +--- + +## 12. YAML 和存储边界 + +GUI 仍使用 YAML 作为出征计划和舰队方案的持久化格式: + +```text +resource/system_battle_plans/ 系统出征计划,只读 +resource/system_team_plans/ 系统舰队方案,只读 +resource/system_daily_plans/ 系统日常任务,只读 +userData/user_battle_plans/ 用户出征计划 +userData/user_team_plans/ 用户舰队方案 +userData/user_daily_plans/ 用户日常任务 ``` + +其中 `userData` 是 Electron 的用户数据目录,不是项目源码目录。 + +维护原则: + +- 日常使用通过 GUI 修改,不直接编辑系统目录。 +- 外部 YAML 通过“添加本地 YAML”导入和升级。 +- GUI 保存时会基于原始方案更新受管理字段,并尽量保留未由 GUI 管理的根字段。 +- 手工修改 YAML 后仍必须重新加载并检查计划管理状态。 +- 系统和用户同名方案是不同来源,保存系统方案的修改只会创建用户副本。 + +开发者如需了解字段继承、受管目录和运行时展开规则,参见 +[方案与舰队架构](architecture/04-battle-plan.md)。 diff --git a/docs/reviews/2026-08-03-pr18-migration-ledger.md b/docs/reviews/2026-08-03-pr18-migration-ledger.md index 29a4ad5..2c5b054 100644 --- a/docs/reviews/2026-08-03-pr18-migration-ledger.md +++ b/docs/reviews/2026-08-03-pr18-migration-ledger.md @@ -54,14 +54,14 @@ `dist` 文件锁出现 `EPERM`;清理残留 Electron 进程后串行执行通过。后续 Electron 测试必须串行运行,避免测试进程竞争构建目录。 - 当前没有使用这个失败结果放宽测试;根因是测试资源生命周期竞争,不是设置持久化断言失败。 -- `npm run test:legacy-config-upgrade` 独立覆盖已有 `userData`、不同安装目录、 +- `npm run test:migrations` 统一覆盖已有 `userData`、不同安装目录、 同名任务组合并、旧 YAML 升级、队列引用更新和重复启动幂等。PR 工作流 - `Legacy configuration upgrade compatibility` 会在 Windows runner 自动执行。 + `Build and migration contracts` 会在 Windows runner 自动执行。 - 使用 `AutoWSGR-GUI-old` 的真实数据在隔离临时目录完成两轮演练:空计划目标 下迁移 4 个作战计划和 8 个编队;复制当前完整 `userData` 后再次迁移时, 当前队列和用户修改过的编队均保留,旧任务组引用升级成功。演练未修改真实 `%APPDATA%` 和旧目录,临时目录已清理。 - 主进程拆分完成后已通过: `test:main-services`、`test:main-ipc`、`test:settings`、 - `test:legacy-plan`、`test:api-contract`、`test:task-group-migration`、 + `test:migrations`、`test:api-contract`、 `test:python-environment` 和 `test:event-resources`。 diff --git a/docs/reviews/2026-08-04-src-module-split-agent-runbook.md b/docs/reviews/2026-08-04-src-module-split-agent-runbook.md index e3dad88..2792e79 100644 --- a/docs/reviews/2026-08-04-src-module-split-agent-runbook.md +++ b/docs/reviews/2026-08-04-src-module-split-agent-runbook.md @@ -54,15 +54,14 @@ Agent 每次只执行一个阶段,例如 `S2`。阶段完成并经过维护者 下一个阶段。开始阶段前必须完整读取: 1. `AGENTS.md` -2. `docs/engineering-standards.md` -3. `.editorconfig` -4. `.gitattributes` -5. `tsconfig.json` -6. `package.json` -7. `CONTRIBUTING.md` -8. 原拆分方案 -9. 本任务书 -10. 当前阶段卡指定的架构文档和现有测试 +2. `.editorconfig` +3. `.gitattributes` +4. `tsconfig.json` +5. `package.json` +6. `CONTRIBUTING.md` +7. 原拆分方案 +8. 本任务书 +9. 当前阶段卡指定的架构文档和现有测试 ### 3.2 修改前报告 @@ -245,9 +244,7 @@ S0 只读审计与行为基线 ```powershell npm run build -npm run test:legacy-config-upgrade -npm run test:legacy-plan -npm run test:task-group-migration +npm run test:migrations npm run test:api-contract npm run test:settings npm run test:main-services @@ -309,9 +306,7 @@ src/model/ApiClient.ts **验收**: ```powershell -npm run test:legacy-config-upgrade -npm run test:legacy-plan -npm run test:task-group-migration +npm run test:migrations npm run test:api-contract npm run test:main-ipc ``` diff --git a/docs/reviews/2026-08-04-src-module-split-plan.md b/docs/reviews/2026-08-04-src-module-split-plan.md index 9adbdf9..0aefc59 100644 --- a/docs/reviews/2026-08-04-src-module-split-plan.md +++ b/docs/reviews/2026-08-04-src-module-split-plan.md @@ -286,8 +286,7 @@ git diff --check 按改动范围追加: ```powershell -npm run test:task-group-migration -npm run test:legacy-plan +npm run test:migrations npm run test:settings npm run test:main-services npm run test:main-ipc @@ -296,7 +295,7 @@ npm run test:main-ipc 最终静态边界检查: ```powershell -npm run test:controller-boundaries +npm run test:architecture-boundaries rg -n "window\.electronBridge|\(window as any\)" src/model src/view rg -n "js-yaml|yaml\.load|yaml\.dump" src/controller src/model src/view rg -n "\bas any\b" src/controller src/model src/view diff --git a/docs/reviews/2026-08-10-fix-guide.md b/docs/reviews/2026-08-10-fix-guide.md index c6250d7..005f8db 100644 --- a/docs/reviews/2026-08-10-fix-guide.md +++ b/docs/reviews/2026-08-10-fix-guide.md @@ -11,7 +11,7 @@ 2. **最大复用**:能复用现有方法/接口的,不新增重复逻辑。 3. **最小影响**:不改变现有 IPC 通道名、配置字段名、返回数据结构。 4. **明确边界**:每个 Agent 任务只负责自己那部分,不跨任务改其他文件。 -5. **验证必做**:每项改完跑 `npm run build`;若涉及调度器,跑 `node scripts/test-scheduler-domain.mjs`。 +5. **验证必做**:每项改完跑 `npm run build`;若涉及调度器,跑 `node scripts/tests/test-scheduler-domain.mjs`。 --- @@ -152,7 +152,7 @@ if (logicalId === this.pendingLootTaskId) { ### 验证方式 1. `npm run build` 通过 -2. `node scripts/test-scheduler-domain.mjs` 通过 +2. `node scripts/tests/test-scheduler-domain.mjs` 通过 3. 模拟战役任务失败 → 确认 `markBattleHandled` 未被调用、`clearBattlePending` 被调用 4. 下次 tick 应能重新触发战役 @@ -503,7 +503,7 @@ migrationOrchestrator.run(); 1. `npm run build` 通过 2. 首次启动应用(无旧数据):迁移流程正常跳过 3. 模拟旧数据:迁移流程正常执行,顺序与改前一致 -4. `node scripts/test-migration.js` 通过 +4. `npm run test:migrations` 通过 --- @@ -787,7 +787,7 @@ ensureDefaultPlan(): void { ### 验证方式 1. `npm run build` 通过 2. 切换到计划页:确认默认计划被选中 -3. `node scripts/test-controller-boundaries.mjs` 通过 +3. `npm run test:architecture-boundaries` 通过 --- @@ -865,7 +865,7 @@ ensureDefaultPlan(): void { 2. **再修功能性防护**(第 4 项 H4):避免后端运行时装依赖出错 3. **最后做架构/规范迁移**(第 5-15 项):纯规范性,可分批 -每项改完跑 `npm run build` 验证。涉及调度器的(第 1、2 项)额外跑 `node scripts/test-scheduler-domain.mjs`。 +每项改完跑 `npm run build` 验证。涉及调度器的(第 1、2 项)额外跑 `node scripts/tests/test-scheduler-domain.mjs`。 --- diff --git a/docs/teaching/00-overview.md b/docs/teaching/00-overview.md index 42dbcc3..f58d548 100644 --- a/docs/teaching/00-overview.md +++ b/docs/teaching/00-overview.md @@ -1,116 +1,198 @@ -# 00 — 全局概览 - -> 建议先读此文,再按顺序阅读后续章节。 - ---- +# 00:全局概览 + +> 本章先回答三个问题:系统运行在哪里、状态由谁拥有、一次用户操作如何穿过各层。 + +## 为什么需要分层 + +桌面自动化应用同时包含: + +- DOM、表单、动画和拖放。 +- 配置、方案、编队、调度等领域状态。 +- 文件系统、Python、ADB、更新和窗口生命周期。 +- AutoWSGR HTTP 与 WebSocket。 +- YAML/JSON 持久化和旧版本迁移。 + +如果这些能力都放进一个 Controller 或 `electron/main.ts`,常见结果是: + +- 页面刷新顺手修改业务状态。 +- 文件异常触发错误的业务 fallback。 +- 一个设置字段需要在多个对象中重复保存。 +- 单元测试必须启动 Electron、DOM 和 Python 才能运行。 +- 关闭窗口后监听器、Observer 或子进程仍然存活。 + +分层的目的不是增加目录,而是让每种状态和副作用只有一个明确所有者。 + +## 运行时全景 + +```mermaid +flowchart TB + subgraph Renderer + View["View
DOM 与局部视觉状态"] + Controller["Controller
用例编排"] + Model["Model
领域状态与规则"] + Adapter["Adapter
HTTP / WS / IPC / Storage"] + Shared["Shared
跨层纯逻辑"] + + View -->|"用户意图"| Controller + Controller -->|"ViewObject"| View + Controller --> Model + Controller --> Adapter + Model --> Adapter + Controller --> Shared + Model --> Shared + end + + Adapter --> Preload["electron/preload.ts"] + Preload --> Main["Electron Main"] + Main --> IPC["electron/ipc"] + IPC --> Service["electron/services"] + Service --> Files["文件 / 更新 / ADB / Python"] + Adapter --> Backend["AutoWSGR HTTP + WebSocket"] + Service --> Backend +``` -## 问题出在哪里 +Renderer 有两条外部通信链路: -重构前 `main` 分支的项目能正常运行,但存在三个核心维护痛点: +1. 通过 `window.electronBridge` 请求 Electron 能力。 +2. 通过 `ApiClient` 请求 AutoWSGR 后端。 -### 巨型文件 +它们不能混成一条“万能服务”,因为权限、错误语义和生命周期不同。 -| 文件 | 重构前 | 重构后 | -|------|--------|--------| -| `src/controller/AppController.ts` | **3052 行** | 430 行 + 33 个子模块 | -| `electron/main.ts` | **1192 行** | 401 行 + 6 个子模块 | -| `src/model/Scheduler.ts` | **单文件** | 7 个文件的 `scheduler/` 子系统 | +## 当前源码边界 -一个 3000+ 行的文件意味着: -- **定位困难** — 修 bug 要全文搜索,改动可能波及几百行外的逻辑 -- **合并冲突频繁** — 多人协作时所有改动集中在同一文件 -- **心智负担** — 需要在脑中维护几十个状态变量 +| 边界 | 当前入口 | 所有权 | +|---|---|---| +| Renderer 组合根 | `src/controller/app/AppController.ts` | 创建对象并连接生命周期 | +| Controller | `src/controller/**` | 用户用例和跨对象协调 | +| View | `src/view/**` | DOM、浏览器事件和局部视觉状态 | +| Model | `src/model/**` | 配置、方案、舰队和调度状态 | +| Adapter | `src/adapter/**` | IPC、HTTP、WS、YAML、JSON 和 Storage | +| Types | `src/types/**` | 层间契约 | +| Shared | `src/shared/**` | 无 DOM、Electron、Node 副作用的纯逻辑 | +| Preload | `electron/preload.ts` | 唯一 Electron Bridge 暴露点 | +| Main IPC | `electron/ipc/**` | 通道、参数、结果和异常边界 | +| Main Service | `electron/services/**` | 主进程业务与可测试策略 | +| Main 组合根 | `electron/main.ts` | 服务装配、启动和退出顺序 | -### 隐式依赖 +## 两条数据流 -原始 `AppController` 中所有方法直接访问 `this` 的几十个字段,任何方法都可能读写任意状态,职责边界模糊。 +### 展示流 -### 数据流不清晰 +```text +Repository / Model + -> Controller + -> ViewObject + -> View.render() + -> DOM +``` -View 可能直接读 Model 状态、调用 API,Controller 同时承担渲染和业务逻辑,数据流向是"哪里方便写在哪里"。 +例如主页面由 +`src/controller/app/rendering.ts` 的 `buildMainViewObject()` 把 Scheduler、 +舰队、统计和连接状态转换成 `MainViewObject`,然后交给 `MainView.render()`。 ---- +View 不需要知道 Scheduler 如何重试,也不需要知道后端响应结构。 -## 重构后架构 +### 意图流 +```text +DOM event + -> View callback / intent + -> Controller + -> Model 或 Repository + -> 新 snapshot / ViewObject + -> View.render() ``` -╔══════════════════════════════════════════════════════╗ -║ Electron 主进程 ║ -║ main.ts ─── 窗口 + IPC 注册 ║ -║ ├── backend.ts ────── 后端子进程生命周期 ║ -║ ├── pythonEnv/ ────── Python 发现/安装/更新 ║ -║ └── emulatorDetect.ts 模拟器检测 ║ -╠══════════════════════════════════════════════════════╣ -║ Renderer Process (MVC) ║ -║ ║ -║ ┌─ View 层 ──────────────────────────────────────┐ ║ -║ │ main/ plan/ template/ config/ setup/ │ ║ -║ │ MainView PlanPreviewView ... (纯渲染,无逻辑) │ ║ -║ └────────────────────────────────────────────────┘ ║ -║ ▲ render(ViewObject) ║ -║ ┌─ Controller 层 ────────────────────────────────┐ ║ -║ │ AppController (协调者) │ ║ -║ │ ├── PlanController │ ║ -║ │ ├── TaskGroupController │ ║ -║ │ ├── TemplateController │ ║ -║ │ ├── StartupController │ ║ -║ │ ├── ConfigController │ ║ -║ │ └── SchedulerBinder │ ║ -║ └────────────────────────────────────────────────┘ ║ -║ │ 读取 Model, 拼装 VO ║ -║ ┌─ Model 层 ─────────────────────────────────────┐ ║ -║ │ ConfigModel PlanModel TemplateModel │ ║ -║ │ TaskGroupModel ApiClient MapDataLoader │ ║ -║ │ scheduler/ (Scheduler + 5 个子模块) │ ║ -║ └────────────────────────────────────────────────┘ ║ -║ ║ -║ ┌─ Types 层 ─────────────────────────────────────┐ ║ -║ │ model.ts view.ts api.ts scheduler.ts │ ║ -║ │ electronBridge.ts │ ║ -║ └────────────────────────────────────────────────┘ ║ -╠══════════════════════════════════════════════════════╣ -║ Python 后端 (uvicorn + FastAPI) ║ -╚══════════════════════════════════════════════════════╝ + +例如舰队编辑器发出 `FleetDraftEditIntent`,Controller 将意图交给 Fleet 领域 +函数,随后重新生成只读草稿快照。View 不直接修改持久化对象。 + +## 状态所有权 + +判断代码放哪,先问“谁能修改这个状态”: + +| 状态 | 权威所有者 | +|---|---| +| 调度队列、运行任务、等待重试 | `Scheduler` | +| Cron 配置和触发时钟 | `CronScheduler` | +| 普通舰队草稿 | `FleetPlannerController` + Fleet 领域对象 | +| 决战舰队草稿 | `DecisivePlanController` + `DecisiveFleetDraft` | +| 作战方案内容 | `PlanModel` | +| 后端业务配置 | `ConfigModel.current` | +| GUI 自动化配置 | `ConfigModel.currentGuiAutomation` | +| 搜索、筛选、弹窗展开 | 对应 View | +| 用户文件 | Main Repository / Service | +| 窗口和子进程 | Electron Main Service | + +同一状态出现两个可写副本,通常不是“缓存优化”,而是潜在同步 Bug。 + +## 一次设置保存如何流动 + +```mermaid +sequenceDiagram + participant User + participant View as ConfigView + participant Controller as ConfigController + participant Adapter as ConfigurationGateway + participant IPC as ConfigurationIpc + participant Service as GuiSettingsCommitService + participant Store as SecureFile/GuiConfiguration + + User->>View: 修改并点击保存 + View->>Controller: onSave + Controller->>View: 收集表单 + Controller->>Adapter: commitGuiSettings + Adapter->>IPC: preload invoke + IPC->>Service: commitAtomic + Service->>Store: 保存 YAML 与 JSON + Store-->>Service: 成功或异常 + Service-->>Controller: 提交结果 + Controller->>View: 刷新展示 ``` -## 重构后文件分布 +这个流程说明: -### Controller 层 — 33 个文件分 6 个子目录 +- View 只知道表单。 +- Controller 知道保存用例。 +- IPC 不解析业务 YAML。 +- Service 保证跨文件事务和回滚。 +- 成功前 Renderer 不应提前更新权威内存。 -``` -src/controller/ -├── app/ AppController(430) + SchedulerBinder(179) + ConfigController(226) -│ + rendering(71) + constants(26) + theme(30) -├── plan/ PlanController(215) + importExport(122) + presetFlow(121) -│ + nodeEditor(47) + rendering(109) -├── taskGroup/ TaskGroupController(149) + queueLoader(154) + contextMenu(101) -│ + addItems(78) + importExport(71) + metaLoader(59) -├── template/ TemplateController(191) + selectors(152) + wizard(113) -│ + crud(89) + useTemplate(80) -├── startup/ StartupController(89) + envAndUpdates(100) + connection(64) -└── shared/ ControllerHost(12) + DialogHelper(90) -``` +## 构建时和运行时要分开 -### Electron 主进程 — 从 1 个文件变成 4 + 6 个 +Renderer 开发源并不是 Electron 最终直接加载的全部文件: +```text +src/view/html/** -> scripts/build-view-html.js -> src/view/index.html +src/view/styles/**/*.scss -> Sass -> src/view/styles/styles.css +TypeScript -> tsc -> dist/** +AppController.js -> esbuild -> dist/renderer.bundle.js ``` -electron/ -├── main.ts(401) backend.ts(176) emulatorDetect.ts(114) preload.ts(104) -└── pythonEnv/ - ├── context.ts(33) finder.ts(90) envCheck.ts(208) - ├── installer.ts(225) updater.ts(178) utils.ts(101) index.ts(15) -``` ---- +因此: + +- 修改 HTML partial 后生成 `index.html`。 +- 修改 SCSS 后生成 `styles.css`。 +- 不手工编辑 `dist/**`。 +- 运行时仍然只有一个 HTML、一个 CSS 和一个 Renderer Bundle。 + +## 后续章节地图 + +| 需要理解的问题 | 章节 | +|---|---| +| 一个大文件应该怎么拆 | [01](01-extract-class.md) | +| 子模块怎样避免依赖整个宿主 | [02](02-host-interface.md) | +| Model 数据怎样变成页面展示 | [03](03-viewobject-flow.md) | +| Main 中的文件和进程能力放哪 | [04](04-electron-split.md) | +| View、HTML、SCSS 和共享组件怎么组织 | [05](05-view-layer.md) | +| 领域状态、纯策略和 Scheduler 怎么区分 | [06](06-model-layer.md) | +| DTO、Intent、ViewObject 为什么不能混用 | [07](07-type-system.md) | + +## 本章检查 -## 核心理念速查 +进入一个新需求前,应能回答: -| 序号 | 模式 | 一句话 | 详细文档 | -|------|------|--------|----------| -| 1 | Extract Class | 按职责边界拆,不是按行数平均分 | [01](01-extract-class.md) | -| 2 | Host 接口 | 子控制器通过最小接口与宿主通信,禁止反向依赖 | [02](02-host-interface.md) | -| 3 | ViewObject | Controller 拼装 VO → 单向传递 → View 纯渲染 | [03](03-viewobject-flow.md) | -| 4 | Context 注入 | Electron 各模块通过 init() 注入运行上下文 | [04](04-electron-split.md) | -| 5 | Facade | View 内部可拆子视图,对外保持统一 API | [05](05-view-layer.md) | -| 6 | 领域子系统 | 相关类组成 `scheduler/` 目录,通过 index barrel 导出 | [06](06-model-layer.md) | -| 7 | 类型隔离 | `types/view.ts` 和 `types/model.ts` 严格分离 | [07](07-type-system.md) | +1. 它改变的是视觉状态、领域状态还是外部资源? +2. 权威状态当前由哪个对象持有? +3. 用户意图从哪个 View 进入? +4. 外部副作用经过哪个 Adapter、IPC 或 Service? +5. 哪些消费者和持久化契约会受到影响? diff --git a/docs/teaching/01-extract-class.md b/docs/teaching/01-extract-class.md index 7e63e1b..058e842 100644 --- a/docs/teaching/01-extract-class.md +++ b/docs/teaching/01-extract-class.md @@ -1,149 +1,220 @@ -# 01 — Extract Class:按职责拆分大文件 +# 01:按职责拆分类 -> **前置阅读**:[00-overview](00-overview.md) -> **核心原则**:一个类/模块只负责一件事;当文件超过 300-400 行时,大概率需要拆分。 +> 前置阅读:[00 全局概览](00-overview.md) ---- +Extract Class 的目标不是减少单文件行数,而是把不同的状态、变化原因和生命周期 +分开。拆完后如果两个文件仍共同修改同一批字段,只是多了一层转发,边界并没有 +改善。 -## 拆分的思路 +## 先找责任,不先找行数 -不是"按行数平均分",而是**按业务职责边界切割**。判断标准: +适合提取的信号: -| 信号 | 行动 | -|------|------| -| 一组方法总是一起被调用 | 提取为独立模块 | -| 一组状态变量只被少数方法读写 | 连同方法一起提取成类 | -| 不同业务域的代码混在一起 | 拆成子控制器 | -| `import` 列表超长 | 职责大概率太多了 | +- 一组字段只被一组方法使用。 +- 一段逻辑有独立输入输出,可以不依赖宿主内部状态。 +- 某个区域有自己的资源生命周期,如事件监听器或 Observer。 +- 某个用例只需要宿主的少量能力。 +- 同一视觉行为被两个真实页面重复实现。 +- 一组规则可以单独测试,不需要启动 DOM、Electron 或后端。 ---- +不适合提取的理由: -## 案例 1:SchedulerBinder 的提取 +- “文件超过 N 行”。 +- “以后可能复用”。 +- “每个方法都应该有一个类”。 +- “先建 Manager/Factory,后面再接功能”。 -原始 `AppController` 中有一大段调度器回调绑定代码和相关状态变量。它们逻辑上是一个整体——**跟踪调度器事件、管理临时渲染状态**——和 AppController 的其他逻辑(导航、配置、任务组…)无关。 +## 案例一:AppController 只做组合 -### 提取了什么 +当前 Renderer 入口是: -```typescript -// src/controller/app/SchedulerBinder.ts - -export class SchedulerBinder { - // ── 这些状态原来散落在 AppController 的字段里 ── - private pendingExerciseTaskId: string | null = null; - private pendingBattleTaskId: string | null = null; - private pendingLootTaskId: string | null = null; - currentProgress = ''; - trackedLoot = ''; - trackedShip = ''; - wsConnected = false; - expeditionTimerText = '--:--'; - - constructor(private readonly host: SchedulerBinderHost) {} - - bindSchedulerCallbacks(): void { - this.host.scheduler.setCallbacks({ - onProgressUpdate: (_taskId, progress) => { - this.currentProgress = `${progress.current}/${progress.total}`; - this.host.renderMain(); // 通过 Host 接口回调主控制器 - }, - onTaskCompleted: (taskId, success) => { - this.currentProgress = ''; - this.trackedLoot = ''; - // ...处理定时任务完成态 - }, - }); - } -} +`src/controller/app/AppController.ts` + +它仍然持有核心对象,但细分行为由明确模块负责: + +```text +src/controller/app/ +├─ AppController.ts +├─ ConfigController.ts +├─ CurrentFleetController.ts +├─ NavigationController.ts +├─ OperationsController.ts +├─ ScheduledTaskLoader.ts +├─ SchedulerBinder.ts +├─ SchedulerRuntimeTracker.ts +├─ SettingsController.ts +└─ rendering.ts ``` -### 为什么有效 +拆分判断依据不是“AppController 太长”,而是变化原因不同: -- 这些状态(`currentProgress`、`trackedLoot`…)**只被调度器回调读写**,和 AppController 其他逻辑无关 -- 提取后,`AppController` 不再需要了解"调度器回调具体做了什么" -- 调度器逻辑有 bug?直接看 `SchedulerBinder.ts` 一个文件 +| 模块 | 独立变化原因 | +|---|---| +| `NavigationController` | 页面和标签导航 | +| `ConfigController` | 配置加载、转换和提交 | +| `SettingsController` | Python、CUDA、ADB、更新和资料库 | +| `SchedulerBinder` | Scheduler/Cron/后端事件接线 | +| `SchedulerRuntimeTracker` | 从日志派生运行状态 | +| `rendering.ts` | 状态到 `MainViewObject` 的纯转换 | ---- +`AppController` 保留对象创建、依赖连接和启动/退出生命周期。这是组合根应承担的 +责任,不能为了“更短”再把对象创建随机搬到多个全局单例。 -## 案例 2:子控制器 + 模块委托 +## 案例二:有状态核心与纯策略分开 -`PlanController` 自身只有 215 行,但它不是把逻辑写在自己体内——而是把**具体流程委托给独立的纯函数模块**: +Scheduler 的权威状态仍在: -``` -src/controller/plan/ -├── PlanController.ts (215 行,事件绑定 + 状态持有) -├── importExport.ts (122 行,方案导入/导出流程) -├── presetFlow.ts (121 行,任务预设流程) -├── nodeEditor.ts (47 行,节点编辑器保存) -└── rendering.ts (109 行,ViewObject 拼装) -``` +`src/model/scheduler/Scheduler.ts` -```typescript -// PlanController.ts — 只做委托 +它拥有运行任务、队列、等待任务、状态和子模块。可独立计算的规则被提取为: -import { importPlanFlow, exportPlanFlow } from './importExport'; -import { executePresetFlow } from './presetFlow'; +- `SchedulerTaskPolicy.ts` +- `SchedulerRepairPolicy.ts` -export class PlanController { - async importPlan(): Promise { - return importPlanFlow(this.planView, this.host, this.planSetters); - } +例如任务创建规则不需要访问 Scheduler 私有状态: - closePresetDetail(): void { - closePresetDetailFlow(this.planView, this.presetState); - } +```typescript +export function createSchedulerTask( + options: SchedulerTaskOptions, +): SchedulerTask { + const times = options.times ?? 1; + const unlimited = !Number.isFinite(times); + const normalizedTimes = unlimited ? 1 : Math.max(1, Math.trunc(times)); + return { + id: options.id, + logicalId: options.id, + remainingTimes: normalizedTimes, + totalTimes: normalizedTimes, + maxRetries: 2, + retryCount: 0, + // 其余字段来自显式输入 + }; } ``` -**模式**:控制器是"调度员",具体"干活"的是独立函数。 +这种拆分有三个收益: -好处: -- 控制器只关注"谁来做",不关注"怎么做" -- 流程函数可以独立测试,不需要实例化整个控制器 -- 同一模式在 `taskGroup/`、`template/` 中复用 +1. 状态所有者仍然唯一。 +2. 规则可以用普通输入输出测试。 +3. Scheduler 只负责何时调用规则和如何推进状态机。 ---- +反例是把 `currentTask`、`waitingTasks` 分别放进多个“Manager”,再让它们相互 +回调修改。那会产生多个可写状态源。 -## 案例 3:Scheduler 子系统拆分 +## 案例三:View Facade 与职责子 View -原始 `Scheduler.ts` 是单一大文件。重构后按职责拆成 7 个文件: +当前设置页由 `ConfigView` 对 Controller 保持统一 API,内部组合: -``` -src/model/scheduler/ -├── Scheduler.ts 主调度器,持有队列 + 消费循环 -├── TaskQueue.ts 优先级队列的增删改查 -├── CronScheduler.ts 基于系统时钟的定时触发 -├── ExpeditionTimer.ts 远征检查倒计时 -├── RepairManager.ts 泡澡修理管理 -├── StopConditionChecker.ts 停止条件判断 -└── index.ts barrel re-export +- `ConfigAutomationView` +- `ConfigRuntimeView` +- `settingSelectWidth.ts` + +这次拆分的边界是视觉责任: + +- 自动任务列表和额度摘要一起变化。 +- Python、CUDA、ADB、更新状态一起变化。 +- 下拉框宽度计算是独立纯 DOM 辅助。 + +Controller 仍只依赖 `ConfigView`,没有因为视觉拆分而获得三个新依赖。 + +这种结构是 Facade: + +```text +Controller -> ConfigView + ├─ ConfigAutomationView + ├─ ConfigRuntimeView + └─ settingSelectWidth ``` -`Scheduler` 本体通过组合持有子模块: +Facade 的价值是保持外部契约稳定,而不是把每个 DOM 元素包装成一个类。 -```typescript -// Scheduler.ts -export class Scheduler { - private _taskQueue: TaskQueue; - private expeditionTimer: ExpeditionTimer; - private stopChecker: StopConditionChecker; - // ... -} +## 案例四:共享组件必须有真实复用 + +普通舰队页和决战页原本都需要: + +- 舰船搜索和筛选。 +- 排序和批量渲染。 +- 卡片交互和拖拽。 +- 滚动位置恢复。 + +这些完整视觉行为进入: + +`src/view/plan/ShipGalleryView.ts` + +页面差异通过 `ShipGalleryViewHost` 注入。普通舰队的主选/候选规则和决战的 +level1/level2 草稿仍留在各自领域,不进入共享图库。 + +共享边界成立是因为: + +1. 已有两个真实消费者。 +2. 共享的是完整视觉行为,不是相似名称。 +3. 页面业务差异仍由 Host 隔离。 +4. 共享组件拥有完整 `dispose()` 生命周期。 + +如果只有一个调用方,或提取后充满 `if (page === ...)`,就不应创建共享组件。 + +## 案例五:源文件拆分不能改变运行结构 + +HTML 和 SCSS 也按职责拆分: + +```text +src/view/html/** # HTML 开发源 +src/view/styles/pages/config/** # 设置页 SCSS partial +src/view/styles/pages/plan/** # 方案页 SCSS partial ``` -外部导入不需要关心内部拆分细节: +构建后 Electron 仍加载: -```typescript -// 外部使用 — 导入路径没变 -import { Scheduler, CronScheduler } from '../../model/scheduler'; +```text +src/view/index.html +src/view/styles/styles.css ``` ---- +机械拆分必须保持: + +- DOM 顺序和 ID。 +- CSS 选择器、属性和加载顺序。 +- 事件绑定时机。 +- 页面行为和视觉效果。 + +因此源文件拆分和功能修改应分批进行。 -## 速查:拆分前后对比 +## 安全提取步骤 -| 区域 | 重构前 | 重构后 | 拆分依据 | -|------|--------|--------|---------| -| Controller | 1 个 3052 行文件 | 6 个子目录 33 个文件 | 按业务域(Plan/TaskGroup/Template/Startup) | -| Scheduler | 1 个大文件 | 7 个文件 | 按调度子职责(队列/定时/修理/停止条件) | -| Electron | 1 个 1192 行文件 | 4 + 6 个文件 | 按关注点(窗口/后端/Python/模拟器) | -| View | 混在一起 | 6 个子目录 16 个文件 | 按功能页面(main/plan/template/config…) | +1. 用 `rg` 找字段、方法、调用方、DOM ID、类型和测试。 +2. 写清楚待提取责任,以及仍留在宿主的责任。 +3. 确认状态唯一所有者不变。 +4. 先定义最小输入输出或 Host。 +5. 移动一条完整责任,不顺手改业务规则。 +6. 保持原公共 API,或同步修改所有消费者。 +7. 补齐监听器、Observer、定时器等释放链。 +8. 跑专项测试并检查 diff。 + +## 拆分完成的判断 + +一次有效拆分应满足: + +- 提取模块能用一句话描述责任。 +- 不需要访问宿主大部分私有字段。 +- 没有新增第二份可写业务状态。 +- 错误仍在原来的业务边界报告。 +- 外部调用方没有被迫理解内部拆分。 +- 测试能更直接地验证该责任。 + +## 验证 + +Renderer 类和 View 拆分至少执行: + +```powershell +npm run test:architecture-boundaries +npm run test:renderer-contract +npm run test:build +git diff --check +``` + +涉及 Scheduler 或 Fleet 时再执行: + +```powershell +npm run test:scheduler-domain +npm run test:fleet-domain +``` diff --git a/docs/teaching/02-host-interface.md b/docs/teaching/02-host-interface.md index 47044f2..c2589fa 100644 --- a/docs/teaching/02-host-interface.md +++ b/docs/teaching/02-host-interface.md @@ -1,151 +1,230 @@ -# 02 — Host 接口与依赖注入 +# 02:Host 接口与依赖注入 -> **前置阅读**:[01-extract-class](01-extract-class.md) -> **核心原则**:子控制器不直接引用 `AppController`,通过最小化的 Host 接口与宿主通信。 +> 前置阅读:[01 按职责拆分类](01-extract-class.md) ---- +提取子模块后,最容易出现的新问题是:子模块为了完成工作,直接依赖整个 +`AppController`、具体 Repository 或具体页面。Host 接口通过声明“我只需要这些 +能力”限制依赖面。 -## 问题场景 +## 从具体对象改为能力 -`PlanController` 需要:调度器实例、方案目录路径、触发主视图重渲染、切换页面。 +反例: -在重构前可能的做法: -- 直接 `this.scheduler`(因为在同一个类里)→ 拆分后无法用 -- 传入整个 `AppController` 引用 → 双向依赖,子控制器知道太多 - ---- +```typescript +class NavigationController { + constructor(private readonly app: AppController) {} +} +``` -## 解决方案:Host 接口 +问题不是类型名字,而是 `NavigationController` 可以访问 AppController 的全部 +状态,未来任何字段都可能成为隐式依赖。 -### 基接口 +当前实现使用局部能力接口: ```typescript -// src/controller/shared/ControllerHost.ts - -export interface ControllerHost { - readonly scheduler: Scheduler; - plansDir: string; - renderMain(): void; - switchPage(page: string): void; +export interface NavigationControllerHost { + loadFleetPlanner(): Promise; + ensureDefaultPlan(): Promise; + loadPlanManagement(): Promise; + refreshAdbStatus(): Promise; + refreshShipLibraryStatus(): Promise; } ``` -### 子控制器声明自己的 Host +`NavigationController` 只知道导航触发后要调用哪些能力,不知道能力由哪个具体 +Controller 提供。 -```typescript -// src/controller/plan/PlanController.ts +## 四种常见注入边界 -export interface PlanHost { - readonly scheduler: Scheduler; - plansDir: string; - renderMain(): void; - switchPage(page: string): void; -} +### Controller Host -export class PlanController { - constructor( - private readonly planView: PlanPreviewView, - readonly host: PlanHost, // 只依赖接口,不依赖具体类 - ) {} -} +跨 Controller 流程使用 Host。共享契约集中在: + +`src/controller/contracts.ts` + +当前包括: + +- `TaskGroupHost` +- `PlanHost` +- `StartupHost` + +只服务一个 Controller 的 Host 通常与该 Controller 放在一起,例如: + +- `NavigationControllerHost` +- `SettingsControllerHost` +- `SchedulerBinderHost` +- `ScheduledTaskLoaderHost` + +不需要把所有 Host 都塞进一个全局 `ControllerHost`。 + +### Repository 能力裁剪 + +Controller 不应依赖完整 `ElectronBridge`。当前使用 `Pick` 裁剪: + +```typescript +export type PlanManagementRepository = Pick< + FleetPlannerRepository, + | 'getPlanManagement' + | 'exportUserPlans' + | 'setPlanUnlinkedIgnored' + | 'renameUserCombatPlan' + | 'deleteUserCombatPlan' + | 'deleteUserTeamPlan' +>; ``` -### AppController 在创建时注入实现 +这样测试只需提供六个相关方法,新增无关 IPC 方法不会扩大 Controller 权限。 + +Renderer 侧这些 Gateway/Repository 主要定义在: + +`src/adapter/IpcAdapter.ts` + +### View Host + +复杂 View 通过 Host 发送明确意图,不直接获得 Model。 + +例如 `ShipGalleryViewHost` 提供: ```typescript -// src/controller/app/AppController.ts — init() - -this.planCtrl = new PlanController(this.planView, { - scheduler: this.scheduler, - plansDir: '', - renderMain: () => this.renderMain(), - switchPage: (p) => this.switchPage(p), -}); +export interface ShipGalleryViewHost { + activeSlotDescription(): string; + isExcluded(ship: ShipLibraryShip): boolean; + assignShip(ship: ShipLibraryShip): void; + getRefitFilter?(): boolean; + setRefitFilter?(enabled: boolean): void; + isInteractionEnabled?(): boolean; +} ``` ---- +图库知道如何询问展示差异和发出分配意图,但不知道普通舰队或决战草稿的内部 +结构。 -## 依赖方向 +### Main Service 依赖 + +Electron Service 使用构造参数或依赖对象接收外部能力。例如: +```typescript +export interface WindowServiceDependencies { + readonly backendPort: number; + readonly moduleDirectory: string; + createBrowserWindow(options: Electron.BrowserWindowConstructorOptions): + options: BrowserWindowConstructorOptions, + getDisplays(): Display[]; + getDisplays(): WindowDisplay[]; + isPackaged(): boolean; + resourceRoot(): string; +} + showMessageBox(options: MessageBoxOptions): void; ``` -┌─────────────────────────────────┐ -│ AppController │ -│ - 创建子控制器 │ -│ - 实现 Host 接口 │ -└──────┬──────┬──────┬────────────┘ - │ │ │ 传入 Host 对象 - ▼ ▼ ▼ - PlanCtrl TaskGroupCtrl TemplateCtrl - host:{..} host:{..} (各自的 Host) - │ │ - ▼ ▼ 纯函数委托 - importExport queueLoader - presetFlow contextMenu ``` -**依赖规则**: -- 子控制器 → Host 接口(✅ 依赖抽象) -- 子控制器 → AppController(❌ 禁止直接依赖具体类) -- 子控制器 → 子控制器(❌ 禁止直接对话,由 AppController 桥接) - ---- +Service 不应反向导入 `electron/main.ts` 的全局变量。 -## 实际案例:桥接子控制器 +## Host 方法应该长什么样 -`TaskGroupController` 需要调用 `PlanController` 的功能(如导入预设、获取当前方案)。但它**不直接引用 PlanController**,而是通过 Host 桥接: +优先使用业务能力: ```typescript -// TaskGroupController 的 Host — 比 PlanHost 更丰富 -export interface TaskGroupHost extends ControllerHost { - importTaskPreset(preset: TaskPreset, filePath: string): void; - getCurrentPlan(): PlanModel | null; - setCurrentPlan(plan: PlanModel, mapData: MapData | null): void; - renderPlanPreview(): void; - closePresetDetail(): void; - executePreset(): void; - getCurrentPresetInfo(): { preset: TaskPreset; filePath: string } | null; -} +loadPlanManagement(): Promise; +saveTeamPlan(name: string): Promise; +refreshShipLibraryStatus(): Promise; ``` +避免暴露实现细节: + ```typescript -// AppController 做中介桥接 -this.taskGroupCtrl = new TaskGroupController( - this.taskGroupModel, this.taskGroupView, this.templateModel, - this.mainView, { - scheduler: this.scheduler, - plansDir: '', - renderMain: () => this.renderMain(), - switchPage: (p) => this.switchPage(p), - // 桥接到 PlanController - importTaskPreset: (preset, fp) => this.planCtrl.importTaskPreset(preset, fp), - getCurrentPlan: () => this.planCtrl.getCurrentPlan(), - setCurrentPlan: (plan, mapData) => this.planCtrl.setCurrentPlan(plan, mapData), - renderPlanPreview: () => this.planCtrl.renderPlanPreview(), - closePresetDetail: () => this.planCtrl.closePresetDetail(), - executePreset: () => this.planCtrl.executePreset(), - getCurrentPresetInfo: () => this.planCtrl.getCurrentPresetInfo(), - }, -); +getPlanController(): PlanController; +getDocument(): Document; +getMainWindow(): BrowserWindow; +getAllPrivateState(): AppState; ``` ---- +前一种接口限制“能做什么”,后一种接口只是把具体对象藏在 getter 后面。 -## 为什么不用继承 +## 依赖方向 -| 方案 | 问题 | -|------|------| -| `PlanController extends AppController` | 紧耦合 — 子类继承全部实现细节 | -| 深继承链 | 脆弱基类 — 父类改动破坏子类 | -| Host 接口 | ✅ 扁平组合,最小知识,易测试 | +```mermaid +flowchart LR + App["AppController"] -->|"实现能力"| Host["NavigationControllerHost"] + Nav["NavigationController"] --> Host + Nav --> View["NavigationView"] -测试时只需 Mock 一个 Host 对象,不需要实例化整个 AppController: + Controller["PlanManagementController"] --> Repo["PlanManagementRepository"] + Adapter["IpcAdapter"] -->|"实现"| Repo -```typescript -const mockHost: PlanHost = { - scheduler: fakeScheduler, - plansDir: '/tmp/plans', - renderMain: vi.fn(), - switchPage: vi.fn(), -}; -const ctrl = new PlanController(fakeView, mockHost); + Page["FleetPlannerView"] --> GalleryHost["ShipGalleryViewHost"] + Gallery["ShipGalleryView"] --> GalleryHost +``` + +接口应定义在消费者附近。消费者决定自己需要什么,而不是提供者公布一个巨大 +公共对象让所有人挑选。 + +## 为什么有些方法是可选的 + +`ShipGalleryViewHost` 的拖拽、改造筛选等能力只对部分页面存在,因此使用可选 +成员。可选能力是合理的前提: + +- 缺失时有明确且安全的行为。 +- 不会掩盖配置或运行错误。 +- 不是为了兼容多个不相关业务而堆积开关。 + +Controller、页面导航、OCR 或环境异常不能通过可选 Host 静默降级。 + +## 用注入改善测试 + +`PlanManagementController` 的构造函数允许注入: + +- Repository。 +- View。 +- Dialogs。 + +测试可以提供内存对象并断言: + +- 删除前是否确认。 +- Repository 收到哪个文件 identity。 +- 失败是否展示明确错误。 +- 成功后是否重新加载。 + +这比 mock 全局 `window.electronBridge` 更精确,也更不容易污染其他测试。 + +## 常见误区 + +### 巨型 Host + +如果一个 Host 有几十个互不相关的方法,说明拆出的模块仍承担多个用例。继续 +增加方法前,应重新检查责任边界。 + +### 回调代替所有类型 + +不是每个依赖都要写成 `(arg) => result`。已有稳定领域接口时,使用 +`Pick` 更容易保持方法语义和类型一致。 + +### Host 持有 DOM + +Controller Host 不应返回 `HTMLElement` 或接收浏览器事件。DOM 所有权属于 +View,Controller 只接收业务意图。 + +### Host 复制状态 + +`getState()` + `setState()` 的万能接口通常会制造第二状态源。优先提供一个完整 +用例能力,或让领域 Model 继续拥有状态。 + +## 新增 Host 的步骤 + +1. 列出消费者实际调用的能力。 +2. 删除“可能以后用到”的成员。 +3. 把接口放在消费者所在模块或稳定共享契约中。 +4. 在组合根注入实现,不在子模块中查找全局对象。 +5. 使用 `Pick` 保留已有 Repository 契约。 +6. 写一个最小 fake 验证接口确实可独立使用。 +7. 搜索是否仍导入了被替代的具体类。 + +## 验证 + +```powershell +rg -n "Host|Repository = Pick" src/controller src/view electron +npm run test:architecture-boundaries +npm run test:main-services +npm run test:main-ipc +git diff --check ``` diff --git a/docs/teaching/03-viewobject-flow.md b/docs/teaching/03-viewobject-flow.md index 9a1d050..8650e87 100644 --- a/docs/teaching/03-viewobject-flow.md +++ b/docs/teaching/03-viewobject-flow.md @@ -1,153 +1,227 @@ -# 03 — ViewObject 单向数据流 +# 03:ViewObject 单向数据流 -> **前置阅读**:[02-host-interface](02-host-interface.md) -> **核心原则**:View 层不允许直接读取 Model,所有数据通过 Controller 拼装成 ViewObject 后单向传递。 +> 前置阅读:[02 Host 接口与依赖注入](02-host-interface.md) ---- +ViewObject,简称 VO,是 Controller 为页面准备的只读展示数据。它的价值不是给 +Model 类型换一个名字,而是阻止 View 理解业务状态机、存储格式和后端 DTO。 -## 数据流示意 +## 完整数据流 -``` -Model (业务数据) Controller (拼装) View (渲染) - │ │ │ - scheduler.status ───────► │ │ - scheduler.taskQueue ────► │ │ - trackedLoot ────────────► buildMainViewObject() │ - wsConnected ────────────► │ │ - ▼ │ - MainViewObject ──────────► render(vo) - (纯数据,无方法) (纯DOM操作) +```mermaid +flowchart LR + Repository --> Model + Model --> Controller + Controller -->|"构建 ViewObject"| View + View -->|"DOM render"| User + User -->|"event"| View + View -->|"callback / intent"| Controller + Controller --> Model ``` ---- +核心约束: -## ViewObject 接口 +- 数据向 View 单向流动。 +- 用户动作以意图返回 Controller。 +- View 不持有可写 Model 引用。 +- Controller 不读取 DOM 来补业务状态。 -```typescript -// src/types/view.ts — View 层唯一认识的数据结构 - -export interface MainViewObject { - status: AppStatus; // 'idle' | 'running' | ... - statusText: string; // "空闲" | "运行中" | ... - currentTask: TaskViewObject | null; - expeditionTimer: string; // "12:34" - taskQueue: TaskQueueItemVO[]; - wsConnected: boolean; - runningTaskId: string | null; -} - -export interface TaskQueueItemVO { - id: string; - name: string; - priorityLabel: string; // "远征" | "用户" | "日常" - remaining: number; - totalTimes: number; - progress?: string; // "2/5" - progressPercent?: number; // 0~1 - acquisitionText?: string; // "装备 3/200 | 舰船 253/500" -} -``` +## 主页面示例 -**设计要点**: -- VO 是**纯数据接口**,没有方法 -- 字段使用 View 能直接展示的格式(`priorityLabel: "远征"` 而非 `priority: 0`) -- View 层**完全不知道** `Scheduler`、`ApiClient` 的存在 +主页面展示来源包括 Scheduler、舰队、统计和 WebSocket 状态。转换函数位于: ---- +`src/controller/app/rendering.ts` -## 拼装函数 +输入是明确的 `RenderingState`: ```typescript -// src/controller/app/rendering.ts - export interface RenderingState { readonly scheduler: Scheduler; + currentFleet: CurrentFleetShipVO[]; currentProgress: string; trackedLoot: string; trackedShip: string; + dailySortieStats: DailySortieStatsSnapshot; wsConnected: boolean; expeditionTimerText: string; } +``` + +`buildMainViewObject()` 负责: + +- 把调度状态转换成中文状态文本。 +- 合并运行中、排队中和等待中的任务。 +- 计算展示进度。 +- 把 OCR 追踪结果转换成资源摘要。 +- 生成 `MainViewObject`。 -export function buildMainViewObject(state: RenderingState): MainViewObject { - const { scheduler, currentProgress, wsConnected, expeditionTimerText } = state; - const running = scheduler.currentRunningTask; - - const taskQueueVo: TaskQueueItemVO[] = []; - if (running) { - taskQueueVo.push({ - id: running.id, - name: running.name, - priorityLabel: PRIORITY_LABELS[running.priority] ?? '用户', - remaining: running.remainingTimes, - totalTimes: running.totalTimes, - progress: currentProgress || undefined, - // ... - }); - } - for (const t of scheduler.taskQueue) { - taskQueueVo.push({ /* 同上结构 */ }); - } - - return { - status: scheduler.status, - statusText: STATUS_TEXT[scheduler.status] ?? '未知', - currentTask: running ? { name: running.name, /* ... */ } : null, - expeditionTimer: expeditionTimerText, - taskQueue: taskQueueVo, - wsConnected, - runningTaskId: running?.id ?? null, - }; +随后 `MainView` 只按 VO 渲染: + +```typescript +render(vo: MainViewObject): void { + this.statusBar.render(vo); + this.taskQueueView.render(vo); + this.fleetPreviewView.render( + vo.currentFleet, + vo.currentTask !== null, + vo.dailySortieStats, + ); } ``` -**关键**:拼装函数是**纯函数**——输入 `RenderingState`,输出 `MainViewObject`。不修改任何状态,不操作 DOM。 +`MainView` 不需要访问 Scheduler,也不需要解析后端日志。 ---- +## 为什么不直接把 Scheduler 传给 View -## View 层:只接收 VO,只做渲染 +直接传 Model 会让 View 必须理解: -```typescript -// src/view/main/MainView.ts +- `currentRunningTask` 与 `taskQueue` 的合并顺序。 +- `waitingTaskList` 的 gap/retry 文案。 +- `remainingTimes`、`totalTimes` 和 `unlimited` 的关系。 +- `idle` 且队列非空时为何显示“队列已暂停”。 -export class MainView { - render(vo: MainViewObject): void { - this.statusBar.render(vo); // 委托子视图 - this.taskQueueView.render(vo); - } +这些都是业务解释,不是 DOM 渲染。把它们集中在 Controller 转换层后,页面只 +关心“显示什么”。 - appendLog(entry: LogEntryVO): void { - this.logView.appendLog(entry); - } -} +## 方案页面示例 + +方案 VO 由: + +`src/controller/plan/rendering.ts` + +构建为 `PlanPreviewViewObject`。转换层负责把: + +- `PlanModel` 节点和默认值。 +- 地图节点和边。 +- 阵型、修理模式等业务值。 +- 当前选择和终点规则。 + +转换成 View 可直接使用的数据。 + +View 不应自己调用 `PlanModel.getNodeArgs()`,因为那会把节点继承规则泄漏到 +页面层。 + +## 用户意图返回 + +只读 VO 不代表 View 没有交互。View 通过回调发送明确动作: + +```text +用户拖动舰船 + -> FleetEditorView + -> FleetDraftEditIntent + -> FleetPlannerController + -> Fleet 领域函数 + -> FleetDraftViewObject + -> FleetPlannerView ``` -View 的职责**只有两件事**: -1. 接收 VO → 渲染 DOM -2. 用户操作 → 触发回调(如 `onRemoveQueueItem`) +`FleetDraftEditIntent` 定义在 `src/types/fleetEditor.ts`,它表达: + +- 要执行什么编辑动作。 +- 目标舰位或拖拽来源。 +- 必要的规则更新值。 + +它不携带整个 Model,也不允许 View 任意改写草稿。 + +## View 可以拥有的状态 + +ViewObject 流并不要求 View 完全无状态。以下状态可以留在 View: ---- +- 当前打开的标签。 +- 搜索关键字。 +- 筛选和排序。 +- 弹窗展开状态。 +- 滚动位置。 +- 表单尚未保存的局部输入。 +- loading 和视觉动画状态。 -## 为什么禁止 View 直接读 Model +判断标准是:关闭页面或重新从业务状态渲染后,这些值是否可以安全重建或丢弃。 -| 做法 | 后果 | -|------|------| -| View 直接 `import Scheduler` | View 和 Model 紧耦合,改字段名要同时改 View | -| View 调用 `api.getStatus()` | View 包含业务逻辑,不再是纯渲染层 | -| View 通过 VO 接收数据 | ✅ Model 变了只需改拼装函数,View 无感 | +以下状态不能留在 View: ---- +- 已保存方案。 +- Scheduler 任务。 +- 普通或决战舰队权威草稿。 +- 文件来源和 identity。 +- 配置提交是否成功。 +- 迁移阶段。 -## 调用链全貌 +## 异步操作的流向 +异步加载时仍保持同一边界: + +```text +View.onRefresh + -> Controller.load() + -> View.showLoading() + -> Repository.get... + -> Controller 保存结果 + -> build...ViewObject() + -> View.render() +``` + +以 `PlanManagementController` 为例,Repository 异常由 Controller 捕获并调用 +`view.showError()`。View 不直接调用 Repository,也不会因为加载失败修改文件。 + +## 四类数据不要混用 + +| 数据 | 示例 | 用途 | +|---|---|---| +| API DTO | `TaskRequest` | GUI 与 AutoWSGR 通信 | +| IPC DTO | `ManagedBattlePlan` | Renderer 与 Main 通信 | +| Model | `PlanData`、`SchedulerTask` | 领域状态和规则 | +| ViewObject | `PlanPreviewViewObject` | 页面展示 | + +同一个概念可以在四层有不同形状。例如任务在后端请求里不需要 GUI 的 +`logicalId`,在页面 VO 里也不需要完整 `TaskRequest`。 + +## 常见反例 + +### View 导入 Model + +```typescript +// 错误:页面开始理解 PlanModel 的继承和持久化语义 +render(plan: PlanModel): void {} +``` + +应改为: + +```typescript +render(vo: PlanPreviewViewObject | null): void {} ``` -状态变化 (onTaskCompleted 等) - ↓ -SchedulerBinder.callback → host.renderMain() - ↓ -AppController.renderMain() - ↓ -buildMainViewObject(state) ← 纯函数拼装 - ↓ -mainView.render(vo) ← 纯 DOM 渲染 + +### Controller 查询 DOM + +```typescript +// 错误:Controller 通过页面找回业务状态 +const value = document.getElementById('cfg-backend-port'); +``` + +应由 View 的受控收集方法或明确 intent 返回值。 + +### VO 只是 Model 的别名 + +如果 VO 仍包含完整 `PlanData`,并让 View 自己转换文案、继承默认值和处理未知 +字段,说明转换边界没有建立。 + +### 为避免转换而使用 `any` + +`any` 只会把层间不匹配推迟到运行时。应明确增加 DTO、Model 或 VO 字段,并 +更新转换函数。 + +## 新增展示字段的步骤 + +1. 确认字段来自哪个权威状态。 +2. 在对应 VO 中增加展示所需的最小字段。 +3. 在 Controller rendering 函数中完成转换。 +4. 让 View 只消费新字段。 +5. 验证其他 VO 构造点和测试 fixture。 +6. 不把 Model 或 API 对象整体透传给 View。 + +## 验证 + +```powershell +rg -n "build.*ViewObject|ViewObject|VO" src/controller src/types/view.ts +npm run test:architecture-boundaries +npm run test:renderer-contract +npm run test:build ``` diff --git a/docs/teaching/04-electron-split.md b/docs/teaching/04-electron-split.md index 3a21b6e..98ecd34 100644 --- a/docs/teaching/04-electron-split.md +++ b/docs/teaching/04-electron-split.md @@ -1,214 +1,229 @@ -# 04 — Electron 主进程拆分 +# 04:Electron Main 分层 -> **前置阅读**:[00-overview](00-overview.md) -> **核心原则**:`main.ts` 是组合根,只负责组装服务、注册 IPC 和应用生命周期; -> 文件、配置、计划、环境和后端进程逻辑由独立 Service 承担。 +> 前置阅读:[00 全局概览](00-overview.md) ---- +Electron Main 同时能访问文件、进程、窗口和系统 API,因此最需要控制权限边界。 +当前架构不是简单地把 `main.ts` 拆成多个文件,而是区分组合、传输、用例、来源 +和格式。 -## 重构前后 +## 当前分层 -**重构前**: - -``` +```text electron/ -├── main.ts (1192 行 — 窗口 + IPC + Python + 后端 + 模拟器全在一起) -└── preload.ts +├─ main.ts # 组合根和生命周期 +├─ preload.ts # 唯一 Renderer Bridge +├─ ipc/ # 通道和参数边界 +├─ services/ # 主进程用例、策略和持久化 +└─ pythonEnv/ # Python、依赖、CUDA 和后端来源 ``` -**重构后**: +Main 内部常见职责: -``` -electron/ -├── main.ts (服务组装 + IPC 注册 + 生命周期) -├── preload.ts (受限渲染进程桥接) -├── emulatorDetect.ts (模拟器检测) -├── ipc/ (薄 IPC 适配器) -│ ├── BackendIpc.ts -│ ├── EnvironmentIpc.ts -│ └── ... -├── services/ (主进程业务用例) -│ ├── BackendService.ts -│ ├── BackendShutdownService.ts -│ ├── PythonEnvironmentService.ts -│ └── ... -└── pythonEnv/ (Python 环境领域实现) - ├── index.ts (barrel re-export) - ├── context.ts (共享运行上下文) - ├── environment.ts (统一环境描述) - ├── finder.ts (Python 查找) - ├── envCheck.ts (环境检查) - ├── installer.ts (依赖安装) - ├── updater.ts (managed 后端契约更新) - └── utils.ts (底层工具) +| 层 | 负责 | 不负责 | +|---|---|---| +| `main.ts` | 创建服务、注入依赖、注册 IPC、启动/退出顺序 | 文件格式和业务规则 | +| preload | 暴露白名单方法、转发 invoke/sendSync/event | 主进程业务 | +| IPC | 通道契约、输入输出和异常边界 | 持久化细节 | +| Service | 一个主进程用例或策略 | Renderer DOM | +| Repository | 目录、文件 identity、系统/用户来源 | 页面展示 | +| Codec | YAML/JSON 结构、校验、升级、未知字段 | 文件选择和窗口 | + +## main.ts 是组合根 + +`electron/main.ts` 创建具体实现并把它们连接起来,例如: + +```typescript +registerConfigurationIpc(ipcMain, { + getAppVersion: () => app.getVersion(), + backendPort: BACKEND_PORT, + configuration: guiConfigurationService, + settingsCommit: guiSettingsCommitService, + cudaEnvironment: cudaEnvironmentService, + pythonEnvironment: pythonEnvironmentService, + windows: windowService, +}); ``` ---- +这段代码表达对象关系,实际配置提交、CUDA 检测和窗口逻辑分别在 Service 中。 -## 模式:组合根 + 依赖注入 +组合根允许依赖具体类,因为它的责任就是选择实现。子 Service 不应反向导入 +`main.ts` 获取全局变量。 -子模块不通过 `import` 读取 `main.ts` 的全局变量。长期运行的后端和 Python -领域模块通过 Context 接收 Electron 能力;普通用例 Service 通过构造函数接收 -文件系统、Repository 或底层函数;IPC 层只转换通道输入输出。 +## preload 是唯一桥 -### services/BackendService.ts +`electron/preload.ts` 通过 `contextBridge` 暴露: ```typescript -// electron/services/BackendService.ts +window.electronBridge +``` -export interface BackendContext { - appRoot: () => string; - userDataRoot: () => string; - resourceRoot: () => string; - BACKEND_PORT: number; - getMainWindow: () => BrowserWindow | null; -} +完整契约定义在: -let ctx: BackendContext; +`src/types/ipc.ts` -export function initBackend(context: BackendContext): void { - ctx = context; -} +Renderer Controller 和 View 不直接访问该全局对象。Adapter 使用 `Pick` 将完整 +Bridge 裁剪成不同用例需要的 Gateway/Repository。 -export function getBackendProcess(): ChildProcess | null { - return backendProcess; -} +这样可以同时限制: -// startBackend()、stopBackend() 通过 ctx 访问路径和窗口能力。 -``` +- Renderer 能调用哪些系统能力。 +- 某个 Controller 能看到哪些 IPC 方法。 +- IPC DTO 如何跨进程序列化。 -### pythonEnv/context.ts +## IPC 必须保持薄 -```typescript -// electron/pythonEnv/context.ts - -export interface PythonEnvContext { - appRoot: () => string; - sendProgress: (msg: string) => void; - getConfiguredPythonPath: () => string | null; - getUpdateMode: () => 'auto' | 'manual'; - getBackendStartupMode: () => 'managed' | 'external'; - getBackendRepoPath: () => string | null; - getTempDir: () => string; -} - -let ctx: PythonEnvContext; - -export function initPythonEnv(context: PythonEnvContext): void { - ctx = context; -} - -export function getCtx(): PythonEnvContext { - return ctx; -} +以 `electron/ipc/ConfigurationIpc.ts` 为例,它负责: + +- 注册同步 getter。 +- 注册异步 handler。 +- 把请求交给配置、环境、窗口和提交服务。 + +事务保存由: + +`electron/services/GuiSettingsCommitService.ts` + +处理,而不是写在 IPC handler 中。 + +判断逻辑放错位置的信号: + +- IPC 开始解析 YAML。 +- IPC 直接拼用户目录。 +- IPC 决定迁移策略。 +- IPC 捕获异常后改走业务 fallback。 + +这些行为应进入 Codec、Repository 或 Service。 + +## 配置事务案例 + +设置页一次提交会同时影响: + +- `userData/usersettings.yaml` +- `userData/gui_settings.json` + +`GuiSettingsCommitService.commitAtomic()` 的顺序是: + +```text +准备窗口配置 + -> 快照 usersettings.yaml + -> 保存新 YAML + -> 提交 gui_settings.json + -> JSON 失败时恢复 YAML + -> 全部成功后返回提交结果 ``` -### services/PythonEnvironmentService.ts +事务属于 Service,因为它协调多个存储并定义失败恢复语义。若放在 Renderer, +窗口关闭、IPC 中断或 Main 写入失败时就无法可靠回滚。 -无状态用例 Service 使用构造函数依赖,不持有第二份 Python 发现缓存: +## 方案的 Codec / Repository / Service -```typescript -export class PythonEnvironmentService { - constructor( - private readonly dependencies: PythonEnvironmentDependencies, - ) {} - - check(): Promise { - return this.dependencies.checkEnvironment(); - } -} +作战和编队方案展示了三层分工: + +```text +CombatPlanCodec + 解析、规范化、拆分、展开和序列化 + +CombatPlanRepository / TeamPlanRepository + 系统与用户目录、文件 identity 和读写 + +PlanManagementService / TeamPlanService / RuntimePlanService + 管理、保存、删除影响和运行时准备 ``` -### main.ts 的组装流程 +例如运行前展开舰队引用: -```typescript -app.whenReady().then(() => { - initPythonEnv({ - appRoot, - sendProgress, - getConfiguredPythonPath: () => - guiConfigurationService.configuredPythonPath(), - getUpdateMode: () => guiConfigurationService.updateMode(), - getBackendStartupMode: () => - guiConfigurationService.backendStartupMode(), - getBackendRepoPath: () => - guiConfigurationService.backendRepoPath(), - getTempDir: () => app.getPath('temp'), - }); - initBackend({ - appRoot, - userDataRoot, - resourceRoot, - BACKEND_PORT, - getMainWindow: () => mainWindow, - }); - - // 初始化用户目录、执行迁移、注册更新 IPC。 - createWindow(); -}); +```text +受管作战方案 + -> Repository 读取 + -> Codec 校验和展开独立舰队 + -> RuntimePlanService 写入临时执行方案 + -> 后端执行 ``` -### ipc/ 薄适配器 +Codec 不弹文件对话框,Repository 不构建页面 VO,Service 不手写 YAML 字符串。 -IPC 文件只处理通道和错误边界,实际行为由注入依赖负责: +## 文件能力为什么不能直接暴露 -```typescript -registerBackendIpc(ipcMain, { - getBackendProcess, - startBackend, - runSetupScript, -}); +通用文件 IPC 经过: -registerEnvironmentIpc( - ipcMain, - pythonEnvironmentService, -); -``` +- `SafePathService` +- `SecureFileService` +- `AtomicFileStore` ---- +它们共同保证: -## emulatorDetect.ts — 零依赖纯函数 +- 读取只在允许的资源和 `userData` 根内。 +- 写入只进入 `userData`。 +- 拒绝路径穿越、UNC、盘符跳转和 ADS。 +- 检查符号链接或 junction 的真实目标。 +- 写入使用临时文件和原子替换。 -最简单的提取案例:模拟器检测逻辑完全独立,不需要任何 Context: +“用户在对话框选过一个文件”只授权该次操作,不能顺便扩大通用文件 IPC 的根 +目录。 -```typescript -// electron/emulatorDetect.ts +## 生命周期也是架构边界 -export interface EmulatorDetectResult { - type: string; - path: string; - serial: string; - adbPath: string; -} +启动顺序不能随意移动: -export function detectEmulator(): EmulatorDetectResult[] { /* ... */ } +```text +单实例锁 + -> 更新恢复 + -> userData 迁移 + -> Python / 后端环境 + -> 方案和资料库 + -> IPC + -> BrowserWindow ``` ---- +退出时 Main 保存窗口状态,并停止后端和 GUI 内置 ADB。次实例必须在迁移、pip、 +后端和窗口初始化之前退出。 -## pythonEnv/ — barrel re-export +生命周期逻辑可以在 `main.ts` 编排,但可测试的停止、更新和单实例行为分别由 +Service 实现。 -提取多个文件后,外部导入路径保持不变: +## Python 环境为什么单独成域 -```typescript -// electron/pythonEnv/index.ts +`electron/pythonEnv/**` 负责: -export { initPythonEnv, clearPythonCache } from './context'; -export { findPython } from './finder'; -export { checkEnvironment } from './envCheck'; -export { installPortablePython, installDependencies } from './installer'; -export { autoUpdateAutowsgr } from './updater'; -``` +- 解释器来源和版本。 +- managed/external 后端模式。 +- 依赖安装和 `.env_ready`。 +- CUDA 环境。 +- AutoWSGR 唯一来源和正式运行契约。 -`main.ts` 中的 `import { findPython } from './pythonEnv'` 无需改动。 +`BackendService` 只在环境和契约通过后启动 Uvicorn。环境检查失败不能静默切换 +到另一个不明确来源。 ---- +## 新逻辑应该放哪 -## 要点 +| 新需求 | 放置位置 | +|---|---| +| 新 IPC 方法 | `src/types/ipc.ts`、preload、对应 IPC | +| 文件命名和来源 | Repository | +| YAML 字段兼容 | Codec | +| 跨文件事务 | Service | +| 启动先后顺序 | `main.ts` | +| Python 查找或安装 | `electron/pythonEnv/**` | +| 后端进程控制 | Backend Service | +| 页面显示 | Renderer View/Controller | + +## 常见反例 + +- 在 `main.ts` 直接实现一个完整 CRUD 用例。 +- preload 暴露 Node `fs` 或任意路径读取。 +- IPC 同时校验参数、解析 YAML、写文件和刷新窗口。 +- Repository 返回页面文案。 +- Codec 根据 Electron 对话框选择文件。 +- 捕获 Main 异常后在 Renderer 触发另一个业务流程。 + +## 验证 + +```powershell +npm run test:main-services +npm run test:main-ipc +npm run test:migrations +npm run test:python-environment +npm run test:build +git diff --check +``` -| 规则 | 说明 | -|------|------| -| `main.ts` 只做三件事 | 创建窗口、注册 IPC、管理生命周期 | -| 子模块通过 `init()` 接收上下文 | 不直接 import main.ts 的全局变量 | -| 纯函数优先 | 如 `emulatorDetect.ts`,不需要上下文就不用 | -| barrel re-export | 外部导入路径不变,内部自由拆分 | +涉及进程、窗口、更新或安装时,还需要实际启动 Electron 并验证关闭和强制退出。 diff --git a/docs/teaching/05-view-layer.md b/docs/teaching/05-view-layer.md index 784a8e4..a517555 100644 --- a/docs/teaching/05-view-layer.md +++ b/docs/teaching/05-view-layer.md @@ -1,173 +1,250 @@ -# 05 — View 层组织 +# 05:View 层组织 -> **前置阅读**:[03-viewobject-flow](03-viewobject-flow.md) -> **核心原则**:View 按功能页面分子目录;复杂视图用 Facade 模式对外暴露统一 API;View 层零 Model 依赖。 +> 前置阅读:[03 ViewObject 单向数据流](03-viewobject-flow.md) ---- +View 层的边界不是“所有前端代码”,而是 DOM、浏览器事件、局部视觉状态和资源 +生命周期。它不负责文件、调度、持久化或领域规则。 -## 目录结构 +## 三类开发源 -``` +```text src/view/ -├── main/ # 主页面 -│ ├── MainView.ts (Facade) -│ ├── LogView.ts (日志面板) -│ ├── TaskQueueView.ts (任务队列) -│ ├── StatusBar.ts (状态栏) -│ └── FleetPreviewView.ts (当前舰队) -├── plan/ # 方案预览 -│ ├── PlanPreviewView.ts (Facade) -│ ├── MapView.ts (地图可视化) -│ ├── NodeEditorView.ts (节点编辑器) -│ ├── FleetPresetView.ts (编队预设) -│ ├── FleetPlannerView.ts (舰队规划 Facade) -│ └── PlanManagementView.ts(计划管理) -├── migration/ -│ └── MigrationConflictView.ts (迁移冲突) -├── template/ # 模板库 -│ ├── TemplateWizardView.ts(向导) -│ ├── TemplateLibraryView.ts(库列表) -│ └── SelectorDialog.ts (选择器对话框) -├── config/ -│ └── ConfigView.ts (配置页) -├── setup/ -│ └── SetupWizardView.ts (首次配置向导) -├── taskGroup/ -│ └── TaskGroupView.ts (任务组面板) -└── shared/ - ├── AnimatedSelect.ts (统一下拉框) - ├── DialogHelper.ts (通用对话框) - ├── scrollPosition.ts (滚动位置) - └── ShipAutocomplete.ts (舰船自动补全) +├─ html/** # HTML partial +├─ styles/** # SCSS partial 和生成 CSS +└─ **/*.ts # View、Facade、共享视觉组件 ``` ---- +构建后 Electron 加载: -## Facade 模式:MainView +```text +src/view/index.html +src/view/styles/styles.css +dist/renderer.bundle.js +``` -`MainView` 内部由 3 个子视图组成,但 Controller 只看到一个统一入口: +HTML partial 和 SCSS partial 是开发结构,不会在运行时 fetch 或动态 include。 -```typescript -// src/view/main/MainView.ts - -export class MainView { - private logView: LogView; - private taskQueueView: TaskQueueView; - private statusBar: StatusBar; - private fleetPreviewView: FleetPreviewView; - - constructor() { - this.logView = new LogView(); - this.taskQueueView = new TaskQueueView(); - this.statusBar = new StatusBar(); - this.fleetPreviewView = new FleetPreviewView(); - } - - // ── 渲染:Controller 只调这一个方法 ── - render(vo: MainViewObject): void { - this.statusBar.render(vo); - this.taskQueueView.render(vo); - this.fleetPreviewView.render( - vo.currentFleet, - vo.currentTask !== null, - vo.dailySortieStats, - ); - } - - appendLog(entry: LogEntryVO): void { - this.logView.appendLog(entry); - } - - // ── 回调转发:用 setter 传递给内部子视图 ── - set onRemoveQueueItem(fn: ((taskId: string) => void) | undefined) { - this.taskQueueView.onRemoveQueueItem = fn; - } - set onMoveQueueItem(fn: ((from: number, to: number) => void) | undefined) { - this.taskQueueView.onMoveQueueItem = fn; - } -} -``` +## View 负责什么 + +- 查找和更新 DOM。 +- 绑定 DOM、window、document 事件。 +- 管理搜索、筛选、排序、展开和 loading。 +- 管理滚动、动画和 Observer。 +- 收集表单草稿。 +- 通过 callback 或 intent 上报用户动作。 +- 释放自己创建的事件、Observer 和定时资源。 + +View 不负责: + +- Scheduler 和任务重试。 +- 配置或方案持久化。 +- 文件 identity 和系统/用户来源。 +- HTTP、IPC 或 Electron Bridge。 +- 可写领域 Model。 + +该边界由 `scripts/tests/test-renderer-architecture.js` 静态检查。 + +## Facade:保持外部 API 稳定 + +Facade 对 Controller 提供稳定入口,内部组合职责子 View。 + +### MainView + +`src/view/main/MainView.ts` 内部组合: -Controller 的使用方式: +- `StatusBar` +- `TaskQueueView` +- `FleetPreviewView` +- `LogView` + +Controller 只调用: ```typescript -// AppController 中 -this.mainView.render(vo); // 不需要知道内部有几个子视图 -this.mainView.appendLog(logEntry); -this.mainView.onRemoveQueueItem = (id) => this.scheduler.removeTask(id); +this.mainView.render(vo); +this.mainView.appendLog(entry); ``` ---- +新增一个主页面局部视觉模块时,优先由 `MainView` 组合,而不是让 +`AppController` 直接管理更多 DOM 组件。 -## Facade 模式:PlanPreviewView +### ConfigView -同样的模式用在方案预览页: +`src/view/config/ConfigView.ts` 保持设置页公共 API,内部组合: -```typescript -// src/view/plan/PlanPreviewView.ts +- `ConfigAutomationView` +- `ConfigRuntimeView` +- `settingSelectWidth.ts` + +拆分后 Controller 仍面对一个 `ConfigView`,配置状态也没有转移到子 View。 + +### FleetPlannerView + +`src/view/plan/FleetPlannerView.ts` 组合: + +- `FleetEditorView` +- `FleetGalleryView` +- `TeamPlanLoaderView` + +它通过 `FleetPlannerViewHost` 发送编辑和保存意图,业务草稿仍由 Controller 和 +Fleet 领域维护。 + +## 职责子 View 和共享组件不同 + +职责子 View 只服务一个 Facade,例如 `ConfigRuntimeView`。 + +共享组件必须有多个真实消费者,例如: + +`src/view/plan/ShipGalleryView.ts` + +它同时服务普通舰队和决战页面,复用: + +- 搜索和筛选。 +- 排序。 +- 增量渲染。 +- 舰船卡片。 +- 滚动恢复。 +- 拖拽起点。 -export class PlanPreviewView { - private mapView: MapView; - private nodeEditor: NodeEditorView; - private fleetPresetView: FleetPresetView; +页面差异由 `ShipGalleryViewHost` 提供。共享组件不能持有: - render(vo: PlanPreviewViewObject | null): void { - // 统一渲染,内部协调三个子视图 - } +- 普通舰队主选/候选语义。 +- 决战 level1/level2 草稿。 +- 保存状态和文件 identity。 - renderFleetPresets(vo: PlanFleetPresetSelectorViewObject): void { - // 只接收 Controller 生成的只读目录和绑定快照 - } +共享的是视觉行为,不是业务状态。 + +## 生命周期必须完整 + +`ShipGalleryView` 创建一组事件监听器和一个 `ResizeObserver`: + +```typescript +private readonly eventController = new AbortController(); +private readonly resizeObserver: ResizeObserver; + +dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.eventController.abort(); + this.resizeObserver.disconnect(); } ``` ---- +释放链是: + +```text +AppController.onBeforeUnload + -> FleetPlannerController.dispose() + -> FleetPlannerView.dispose() + -> ShipGalleryView.dispose() + +AppController.onBeforeUnload + -> DecisivePlanController.dispose() + -> DecisivePlanView.dispose() + -> ShipGalleryView.dispose() +``` + +创建 `window/document` 监听器、Observer、interval 或 animation frame 时,必须 +同时确定所有者和释放入口。 + +## 局部状态和业务状态 + +可以留在 View: + +- 图库搜索词。 +- 当前筛选和排序。 +- 弹窗展开。 +- 拖拽中的滚动位置。 +- 尚未保存的输入框文本。 + +必须交回 Controller/Model: + +- 舰队槽位内容。 +- 计划选中节点。 +- 已保存配置。 +- Scheduler 队列。 +- 任务组条目。 -## Facade 的价值 +局部状态不应在 `render()` 时被无条件重置,否则其他字段变化会导致滑块、选择 +或滚动位置回退。 -| 不用 Facade | 用 Facade | -|-------------|-----------| -| Controller 要知道 `LogView` / `TaskQueueView` / `StatusBar` | Controller 只知道 `MainView` | -| 新增子视图需改 Controller | 新增子视图只改 `MainView` 内部 | -| Controller import 列表膨胀 | 只 import 一个 Facade | +## HTML partial 是 DOM 契约 ---- +HTML 源位于: -## 共享组件 +`src/view/html/**` -跨多个视图复用的 UI 组件放在 `shared/`: +由 `scripts/build-view-html.js` 递归展开 include。构建脚本拒绝: +- include 逃出源目录。 +- 循环 include。 +- 生成结果与已提交 `src/view/index.html` 不一致。 + +View 使用的 DOM ID 是契约。移动 HTML 时要检查: + +```powershell +rg -n "getElementById|querySelector|data-" src/view ``` -src/view/shared/ -├── AnimatedSelect.ts (统一动画下拉框) -├── DialogHelper.ts (确认、提示、输入和保存成功提示) -├── scrollPosition.ts (动态列表滚动位置) -└── ShipAutocomplete.ts (舰船名自动补全输入框) + +`test-renderer-dom-contract.js` 检查重复 ID、View 引用缺失和例外白名单。 + +## SCSS 按所有权组织 + +```text +src/view/styles/ +├─ base/ +├─ components/ +├─ pages/ +├─ themes/ +└─ main.scss ``` -舰队规划、模板向导和受管计划选择器复用这些组件,不各自实现对话框、 -滚动恢复或舰船检索。 +规则: + +- 页面特有布局留在 `pages/`。 +- 两个以上页面共享的独立视觉组件进入 `components/`。 +- 拆分 partial 时保持选择器、属性和加载顺序。 +- 不在机械拆分中同时修改视觉效果。 +- 只编辑 SCSS 源,不手工改 `styles.css`。 ---- +## View 可以导入什么 -View 可以导入: +允许: -- `../../types/view` 和其他 `types/*` 中的只读类型。 -- `../../shared/*` 中不访问持久化状态的纯契约或纯函数。 -- `../shared/*` 中的共享 UI 组件。 -- 同目录的子视图。 -- `../shared/*` — 共享 UI 组件 -View **禁止**: +- `src/types/view.ts` +- `src/types/fleetEditor.ts` 等明确 intent 类型 +- `src/shared/**` 中纯逻辑和只读契约 +- `src/view/shared/**` 中共享 UI +- 同功能域子 View +禁止: -- `../../adapter/*` -- `../../controller/*` -- Repository 或 Electron bridge -- 文件读写、业务关系推导和可写领域状态 +- 有状态 `src/model/**` +- `ApiClient` +- `src/adapter/**` +- `window.electronBridge` +- 直接 `localStorage` + +当前唯一受控例外是 `src/view/theme.ts` 通过 `StorageAdapter` 保存纯 UI 偏好。 + +## 提取 View 的步骤 + +1. 确定一个完整视觉责任和 DOM 所有权。 +2. 搜索该区域全部 ID、class、事件和 CSS。 +3. 保持 Controller 对外 API 不变。 +4. 不移动业务状态。 +5. 定义最小 callback/Host。 +6. 明确创建和释放生命周期。 +7. 拆 HTML/SCSS 时保持生成结果和顺序。 +8. 回归所有共享组件消费者。 + +## 验证 + +```powershell +npm run build:html +npm run test:renderer-contract +npm run test:architecture-boundaries +npm run test:build +git diff --check +``` -View 只接收 Controller 生成的 ViewObject,并通过回调发送明确用户意图。例如 -`FleetEditorView` 发送 `FleetDraftEditIntent`,`PlanManagementView` 发送导出、 -重命名和删除意图;实际修改由 Controller 和 Repository 完成。 -- `../../types/model` -这通过依赖扫描和类型系统共同保障,详见 -[03-viewobject-flow](03-viewobject-flow.md) 与 [07-type-system](07-type-system.md)。 +涉及交互时还要在 Electron 中回归点击、输入、拖放、滚动、弹窗和窗口关闭。 diff --git a/docs/teaching/06-model-layer.md b/docs/teaching/06-model-layer.md index 0c41a7f..5fc937b 100644 --- a/docs/teaching/06-model-layer.md +++ b/docs/teaching/06-model-layer.md @@ -1,206 +1,251 @@ -# 06 — Model 层组织 +# 06:Model 与领域状态 -> **前置阅读**:[01-extract-class](01-extract-class.md) -> **核心原则**:Model 层分两类 — 独立领域模型 + Scheduler 子系统。Model 不依赖 View,通过回调通知 Controller。 +> 前置阅读:[01 按职责拆分类](01-extract-class.md) ---- +Model 层保存领域状态并执行领域规则。判断一个类是否属于 Model,不看它是否 +“处理数据”,而看它是否拥有业务含义、状态不变量和转换规则。 -## 目录结构 +## 当前领域分布 -``` +```text src/model/ -├── ConfigModel.ts (91 行) 用户配置: YAML 加载/导出/局部更新 -├── PlanModel.ts 战斗方案: 解析 YAML, 节点操作 -├── TemplateModel.ts (146 行) 模板库: 内置 + 用户模板 CRUD -├── TaskGroupModel.ts (185 行) 任务组: 定义/持久化/排序 -├── ApiClient.ts HTTP + WebSocket 后端通信 -├── MapDataLoader.ts (71 行) 地图数据加载 + 缓存 -└── scheduler/ 调度器子系统 (7 个文件) - ├── Scheduler.ts 主调度器 - ├── TaskQueue.ts 优先级队列 - ├── CronScheduler.ts 定时触发 - ├── ExpeditionTimer.ts 远征倒计时 - ├── RepairManager.ts 泡澡修理 - ├── StopConditionChecker.ts 停止条件 - └── index.ts barrel re-export +├─ ConfigModel.ts +├─ PlanModel.ts +├─ TaskGroupModel.ts +├─ TemplateModel.ts +├─ MapDataLoader.ts +├─ ApiClient.ts +├─ fleet/ +├─ scheduler/ +└─ statistics/ ``` ---- +主要状态所有者: -## 独立领域模型 +| 领域 | 权威状态 | +|---|---| +| 配置 | `ConfigModel` | +| 当前作战方案 | `PlanModel` | +| 任务组 | `TaskGroupModel` | +| 模板 | `TemplateModel` | +| 普通舰队草稿 | Fleet 领域 + `FleetPlannerController` | +| 决战草稿 | `DecisiveFleetDraft` + `DecisivePlanController` | +| 调度 | `Scheduler` | +| 每日额度 | `CampaignDailyQuota`、`NormalFightDailyQuota` | +| 出征统计 | `DailySortieStats` | -每个 Model 类只负责一个数据领域,职责清晰: +Controller 可以持有 Model,但不复制 Model 的可写状态。 -| 类 | 职责 | 关键方法 | -|----|------|---------| -| `ConfigModel` | 配置数据的加载/导出/更新 | `loadFromYaml()`, `toYaml()`, `update()` | -| `PlanModel` | 方案文件解析 + 节点操作 | `parseYaml()`, `getNodeArgs()` | -| `TemplateModel` | 模板库读写 | `loadBuiltin()`, `add()`, `remove()` | -| `TaskGroupModel` | 任务组定义 + 持久化 | `load()`, `save()`, `reorder()` | -| `ApiClient` | 后端 HTTP/WebSocket 通信 | `taskStart()`, `gameAcquisition()` | -| `MapDataLoader` | 地图 JSON 加载 + 内存缓存 | `loadMapData()`, `getNodeType()` | +## ConfigModel:两个配置域 -### ConfigModel 示例 +`ConfigModel` 同时维护两个明确分开的域: ```typescript -// src/model/ConfigModel.ts +private settings: UserSettings; +private guiAutomation: GuiAutomationSettings; +``` -export class ConfigModel { - private settings: UserSettings; +- `settings` 对应 AutoWSGR `usersettings.yaml`。 +- `guiAutomation` 对应 GUI 自身自动化设置。 - get current(): UserSettings { return this.settings; } +`rawRoot` 保存 GUI 尚未建模的 YAML 字段: - loadFromYaml(yamlStr: string): void { - const parsed = yaml.load(yamlStr) as Record | null; - const base = structuredClone(DEFAULT_SETTINGS); - if (parsed?.emulator) Object.assign(base.emulator, parsed.emulator); - if (parsed?.account) Object.assign(base.account, parsed.account); - if (parsed?.daily_automation) Object.assign(base.daily_automation, parsed.daily_automation); - this.settings = base; - } +```typescript +private rawRoot: Record = {}; +``` - toYaml(): string { - return yaml.dump(this.settings, { lineWidth: -1, noRefs: true }); - } +这样读取、编辑和写回时不会静默删除后端新增或用户手写字段。 - update(partial: Partial): void { - if (partial.emulator) Object.assign(this.settings.emulator, partial.emulator); - if (partial.account) Object.assign(this.settings.account, partial.account); - if (partial.daily_automation) Object.assign(this.settings.daily_automation, partial.daily_automation); +这里的教学重点是:Model 不只保存“已知字段”,还维护 round-trip 不变量和旧 +字段迁移语义。 + +## PlanModel:继承规则属于领域 + +节点参数由默认值和节点覆盖合并: + +```typescript +getNodeArgs(nodeId: string): NodeArgs { + const defaults = this.data.node_defaults ?? {}; + const overrides = this.data.node_args?.[nodeId] ?? {}; + const args = { ...defaults, ...overrides }; + if (this.data.endpoint_nodes?.includes(nodeId)) { + args.proceed = false; } + return args; } ``` -特点:纯数据操作,不引用任何 View 或 Controller。 +这段逻辑属于 `PlanModel`,因为: ---- +- 它解释方案字段语义。 +- Controller 和 View 都需要一致结果。 +- 持久化结构变化时只应改一处。 -## Scheduler 子系统 +View 不应自行合并 `node_defaults`,Controller 也不应为每个页面复制规则。 -原始的 `Scheduler.ts` 是一个大文件,重构后拆成 7 个文件,每个负责一个调度子域。 +`PlanModel.rawRoot` 同样保留未知 YAML 字段,保存时只覆盖 GUI 管理的字段。 -### 职责分工 +## 独立纯规则放在哪里 -| 类 | 职责 | -|----|------| -| `Scheduler` | 主调度器: 持有队列、消费循环、WebSocket 连接 | -| `TaskQueue` | 优先级队列的增删改查、ID 生成 | -| `CronScheduler` | 基于系统时钟的定时触发(演习/战役/刷闪) | -| `ExpeditionTimer` | 远征检查的倒计时 + 定时触发 | -| `RepairManager` | 泡澡修理: 检查血量、送入修理、预设轮换 | -| `StopConditionChecker` | 判断任务是否满足停止条件(预飞检查 + 实时检查) | +有些规则属于一个领域,但不需要读取领域对象状态。例如: -### 组合关系 +- `SchedulerTaskPolicy.ts` +- `SchedulerRepairPolicy.ts` +- Fleet 目录中的草稿变换函数 +- `src/controller/plan/selectedNodes.ts` -`Scheduler` 通过**组合**持有子模块,不是继承: +`selectedNodes.ts` 维护路线用例规则: ```typescript -// src/model/scheduler/Scheduler.ts - -export class Scheduler { - private _taskQueue: TaskQueue; - private expeditionTimer: ExpeditionTimer; - private stopChecker: StopConditionChecker; - private repairMgr: RepairManager; - - constructor(api: ApiClient) { - this._taskQueue = new TaskQueue(); - this.expeditionTimer = new ExpeditionTimer(DEFAULT_INTERVAL, { - onTick: (sec) => this.callbacks.onExpeditionTimerTick?.(sec), - onTrigger: () => this.insertExpeditionTask(), - }); - this.stopChecker = new StopConditionChecker(api, (level, msg) => { /* ... */ }); - this.repairMgr = new RepairManager(api); +export function initialSelectedNodesForNewPlan(): string[] { + return ['0']; +} + +export function assertPlanRouteReadyForExecution( + selectedNodes: readonly string[], +): void { + if (selectedNodes.length === 1 && selectedNodes[0] === '0') { + throw new Error('出征计划只启用了起始节点,请至少开启一个路线节点'); } } ``` -### StopConditionChecker 示例 +它位于 Controller 领域辅助模块,是因为“新建/执行方案”属于应用用例边界; +`PlanModel` 仍只解释方案本身。 -从 Scheduler 中提取的独立职责——只负责判断,不负责执行: +位置判断取决于规则语义,不是所有纯函数都必须进入 `shared/`。 -```typescript -// src/model/scheduler/StopConditionChecker.ts - -export class StopConditionChecker { - trackedLootCount: number | null = null; - trackedShipCount: number | null = null; - - /** 任务执行中实时检查 */ - checkRunning(cond: StopCondition): boolean { - if (cond.loot_count_ge != null - && this.trackedLootCount != null - && this.trackedLootCount >= cond.loot_count_ge) { - return true; - } - return false; - } +## Fleet:草稿、编辑和持久化分开 - /** 预飞检查:发起任务前确认条件是否已满足 */ - async preflightCheck(cond: StopCondition, taskName: string): Promise { - const resp = await this.api.gameAcquisition(); - // ...OCR 读取出征面板数量 - } -} -``` +`src/model/fleet/**` 包含: -### ExpeditionTimer 示例 +- `FleetDraft` +- `DecisiveFleetDraft` +- `FleetDraftEditor` +- `FleetPresetIdentity` +- `FleetRuleMapper` +- `ShipMatcher` -从 Scheduler 中提取的纯定时逻辑: +普通舰队与决战舰队共享舰船资料和部分视觉行为,但草稿状态独立。 -```typescript -// src/model/scheduler/ExpeditionTimer.ts - -export class ExpeditionTimer { - start(): void { - this.timer = setInterval(() => { - this.callbacks.onTrigger(); // 触发远征检查 - }, this._intervalMs); - - this.tickTimer = setInterval(() => { - const remaining = this._intervalMs - (Date.now() - this.lastCheck); - this.callbacks.onTick?.(Math.ceil(remaining / 1000)); - }, 1000); - } +关键不变量: - stop(): void { /* 清理定时器 */ } +- 主选舰优先于候选舰。 +- candidate-only 槽位不能把第一个候选自动提升为主选。 +- 全局唯一分配优先保留主选。 +- 主选失败后再用候选重新执行全局分配。 +- 舰种只接受规定的 canonical code。 - setInterval(ms: number): void { - this._intervalMs = ms; - if (this.timer) this.start(); // 运行中自动重启 - } -} -``` +这些规则属于 Model/Fleet 领域,不应放进卡片点击事件或 Main IPC。 ---- +## Scheduler:唯一任务状态机 -## barrel re-export +`Scheduler` 组合: -外部导入不需要知道内部拆分: +- `TaskQueue` +- `RepairManager` +- `StopConditionChecker` +- `ExpeditionTimer` +- 任务和修理纯策略 -```typescript -// src/model/scheduler/index.ts -export { Scheduler } from './Scheduler'; -export { CronScheduler } from './CronScheduler'; -export { TaskPriority, type SchedulerTask, type SchedulerStatus } from '../../types/scheduler'; +它拥有: + +- 当前物理轮次。 +- 就绪队列。 +- 延迟和等待任务。 +- 系统和停止状态。 +- 重试和后续轮次推进。 + +任务身份分两层: + +```text +id 一次物理执行轮次 +logicalId 整个多轮逻辑任务 ``` -```typescript -// 外部使用 -import { Scheduler, CronScheduler, TaskPriority } from '../../model/scheduler'; +`buildFollowUpTask()` 创建下一物理轮次时生成新 `id`,但保留 `logicalId`。 + +这使取消、完成和每日额度可以针对整个逻辑任务,而不是误把每轮都当成独立用户 +任务。 + +## Model 通过事件通知,不操作页面 + +Scheduler 使用 `SchedulerCallbacks` 通知 Controller。Controller 的 +`SchedulerBinder` 再更新页面、Cron 和统计。 + +```text +Scheduler 状态变化 + -> SchedulerCallbacks + -> SchedulerBinder + -> Controller 状态 / ViewObject + -> MainView ``` ---- +Model 不调用 `document.*`,也不 import 具体 View。 -## Model 的通信方式 +## Model、Shared 和 Adapter 的区别 -Model 不 import View,通过**回调**通知 Controller: +| 位置 | 适合内容 | +|---|---| +| `src/model/**` | 有领域状态、领域不变量或领域内策略 | +| `src/shared/**` | Renderer/Main 都能用的无状态纯逻辑 | +| `src/adapter/**` | HTTP、WS、IPC、Storage、YAML/JSON 技术边界 | -```typescript -// Scheduler 通过回调通知,不直接操作 UI -scheduler.setCallbacks({ - onStatusChange: (status) => { /* Controller 处理 */ }, - onProgressUpdate: (taskId, progress) => { /* Controller 处理 */ }, - onTaskCompleted: (taskId, success) => { /* Controller 处理 */ }, -}); +例如: + +- `fleetShipTypes.ts` 在 Shared,因为 Main 和 Renderer 都需同一舰种规则。 +- `SchedulerTaskPolicy.ts` 在 Scheduler 子系统,因为规则只服务调度领域。 +- `YamlAdapter.ts` 是技术编解码边界,不拥有 Plan 业务。 + +`ApiClient` 当前位于 Model,但网络传输由 `ApiAdapter` 注入。新增后端字段时仍需 +保持 API DTO 与领域状态分开。 + +## Model 的持久化原则 + +领域 Model 负责: + +- 校验和规范化。 +- 默认值。 +- 未知字段保留。 +- 旧字段兼容。 +- 领域对象到可持久化结构的转换。 + +Main Repository/Service 负责: + +- 文件位置和来源。 +- 读写权限。 +- 原子写入。 +- 导入导出。 +- 多文件事务。 + +Model 不应自行拼 `userData` 路径。 + +## 常见反例 + +- View 持有并修改 `PlanModel.data`。 +- Controller 同时维护一份 Scheduler 队列副本。 +- Repository 决定 candidate-only 业务语义。 +- Shared 模块读取 DOM 或 Electron。 +- 一个“Manager”同时拥有配置、任务和页面状态。 +- 为了复用,把普通舰队和决战草稿合成一个可写对象。 + +## 新增领域规则的步骤 + +1. 确认规则属于哪个领域和状态所有者。 +2. 搜索所有现有实现和兼容分支。 +3. 判断规则需要状态还是可做纯输入输出。 +4. 在唯一所有者或领域策略中实现。 +5. 保持序列化未知字段和旧格式兼容。 +6. 通过 Controller 转换成 VO,不让 View 重复解释。 +7. 添加领域专项测试。 + +## 验证 + +```powershell +npm run test:scheduler-domain +npm run test:fleet-domain +npm run test:settings +npm run test:migrations +npm run test:build +git diff --check ``` diff --git a/docs/teaching/07-type-system.md b/docs/teaching/07-type-system.md index 2f6a1e3..f45ba3b 100644 --- a/docs/teaching/07-type-system.md +++ b/docs/teaching/07-type-system.md @@ -1,192 +1,264 @@ -# 07 — 类型系统分层 +# 07:类型系统分层 -> **前置阅读**:[03-viewobject-flow](03-viewobject-flow.md)、[06-model-layer](06-model-layer.md) -> **核心原则**:类型定义按层分文件 — `model.ts` 给 Model/Controller 用,`view.ts` 给 View/Controller 用,两者不互相引用。 +> 前置阅读:[03 ViewObject 单向数据流](03-viewobject-flow.md)、[06 Model 与领域状态](06-model-layer.md) ---- +TypeScript 类型不仅用于消除编译错误,还用于限制数据穿过哪些边界。API DTO、 +IPC DTO、领域状态、编辑意图和 ViewObject 即使描述同一功能,也不应合并成一个 +万能接口。 -## 目录结构 +## 当前类型目录 -``` +```text src/types/ -├── model.ts 业务领域实体 (Plan, Config, FleetPreset...) -├── view.ts ViewObject 接口 (MainViewObject, TaskQueueItemVO...) -├── api.ts 后端 API 请求/响应类型 -├── scheduler.ts 调度器公共类型 (SchedulerTask, TaskPriority...) -└── electronBridge.ts IPC 桥方法签名 +├─ api.ts +├─ fleetEditor.ts +├─ ipc.ts +├─ model.ts +├─ scheduler.ts +├─ statistics.ts +└─ view.ts ``` ---- - -## 类型流向图 - -``` - 后端 (Python) - │ - ▼ - types/api.ts ← 后端通信契约 (TaskRequest, TaskResult, WsLogMessage) - │ - ▼ - Model 层 ← types/model.ts (PlanData, UserSettings, FleetPreset...) - │ ← types/scheduler.ts (SchedulerTask, TaskPriority...) - │ - ▼ - Controller 层 ← 同时引用 model.ts + view.ts - (拼装 VO) ← 把 Model 类型转换为 View 类型 - │ - ▼ - View 层 ← types/view.ts (MainViewObject, TaskQueueItemVO...) - ← 不允许引用 model.ts 或 api.ts +`ElectronBridge` 和全部 IPC DTO 定义在 `src/types/ipc.ts`。 + +## 类型流向 + +```mermaid +flowchart LR + Backend["AutoWSGR"] --> Api["types/api.ts"] + Main["Electron Main"] --> Ipc["types/ipc.ts"] + Api --> Model["Model"] + Ipc --> Adapter["IpcAdapter"] + Adapter --> Controller + Model --> Controller + Scheduler["types/scheduler.ts"] --> Controller + Intent["types/fleetEditor.ts"] --> Controller + Controller --> ViewType["types/view.ts"] + ViewType --> View ``` ---- +Controller 是主要转换边界,因此可以同时认识领域类型和 ViewObject。View 只 +应看到展示和明确交互所需的类型。 -## 各文件职责 +## api.ts:AutoWSGR 契约 -### types/model.ts — 业务实体 +包含: -对应后端 AutoWSGR 的数据结构,Controller 和 Model 层使用: +- `ApiResponse` +- 游戏上下文和资源响应。 +- `TaskRequest` 联合类型。 +- `TaskResult` 和战斗轮次。 +- WebSocket 消息。 +- `ApiClientCallbacks` + +`TaskRequest` 是联合: ```typescript -export interface PlanData { - chapter: number; - map: number; - selected_nodes: string[]; - node_args?: Record; - fleet_presets?: FleetPreset[]; - times?: number; - stop_condition?: StopCondition; - // ... -} - -export interface UserSettings { - emulator: EmulatorConfig; - account: AccountConfig; - daily_automation: DailyAutomation; -} +export type TaskRequest = + | NormalFightReq + | EventFightReq + | CampaignReq + | ExerciseReq + | DecisiveReq; ``` -### types/view.ts — ViewObject +新增任务字段时必须先确认后端公开契约,再更新 GUI DTO、`ApiClient` 和 API +契约测试。不能用 `Record` 绕过联合类型。 -Controller 拼装后传给 View 的纯数据,View 层唯一认识的类型: +## ipc.ts:跨进程契约 -```typescript -export interface MainViewObject { - status: AppStatus; - statusText: string; // 已转换为中文 - taskQueue: TaskQueueItemVO[]; - wsConnected: boolean; - // ... -} - -export interface NodeViewObject { - id: string; - formation: string; // 已转换为中文阵型名 - nodeType: MapNodeType; - // ... -} -``` +包含: -**设计要点**:VO 字段是**展示友好**的格式,不暴露内部枚举值。 +- `ElectronBridge` +- GUI 配置提交 DTO。 +- 方案、编队、日常方案 DTO。 +- 舰船资料库 DTO。 +- 更新、CUDA、ADB 和窗口 DTO。 -### types/api.ts — 后端 API 契约 +IPC 类型必须可结构化克隆,不能携带 DOM、函数实现、Node Stream 或具体 +Service 实例。 -定义请求/响应类型,只在 Model 层和 Controller 层使用: +调用链: -```typescript -export interface TaskRequest { - type: string; - fleet_id?: number; - // ... -} - -export interface ApiResponse { - success: boolean; - data: T; - error?: string; -} +```text +src/types/ipc.ts + -> electron/preload.ts + -> electron/ipc/** + -> src/adapter/IpcAdapter.ts + -> Controller ``` -### types/scheduler.ts — 调度器公共类型 +新增 Bridge 方法时,这条链路必须同步更新。 -从 `Scheduler.ts` 中提取,供 Controller 和 View 层引用: +## model.ts:领域数据 -```typescript -export enum TaskPriority { - EXPEDITION = 0, - USER_TASK = 10, - DAILY = 20, -} - -export interface SchedulerTask { - id: string; - name: string; - type: SchedulerTaskType; - priority: TaskPriority; - remainingTimes: number; - totalTimes: number; - // ... -} - -export type SchedulerStatus = 'idle' | 'running' | 'stopping' | 'not_connected'; -``` +包含: -### types/electronBridge.ts — IPC 方法签名 +- `UserSettings` +- `GuiAutomationSettings` +- `PlanData` +- `NodeArgs` +- `FleetPreset` +- `StopCondition` +- `TaskPreset` +- 修理和模板领域类型 -定义 `window.electronBridge` 的完整接口,Renderer 进程通过此接口与主进程通信: +这些类型表达领域和持久化含义,不是页面展示结构。 -```typescript -export interface ElectronBridge { - openDirectoryDialog: (title?: string) => Promise; - startBackend: () => Promise<{ success: boolean; message: string }>; - detectEmulator: () => Promise; - getAppVersion: () => string; - getBackendPort: () => number; - // ... 40+ 个方法 -} +例如 `PlanData.node_args` 可以包含节点覆盖,但 View 不应直接读取它来决定显示 +文案。Controller 先生成 `NodeViewObject`。 + +## scheduler.ts:任务状态机契约 + +包含: + +- `TaskPriority` +- `SchedulerTaskType` +- `SchedulerTask` +- `SchedulerStatus` +- `SchedulerWaitingTask` +- 逻辑任务完成/取消原因。 +- `SchedulerCallbacks` + +`SchedulerTask` 同时包含物理 `id` 和逻辑 `logicalId`,这是调度领域不变量,不 +应塞入后端 `TaskRequest`。 + +## fleetEditor.ts:编辑意图 + +包含: + +- `FleetEditorSelection` +- `FleetEditorDragSource` +- `FleetRuleUpdate` +- `FleetDraftEditIntent` +- `FleetDraftEditResult` + +Intent 表达用户要做的动作,Model 类型表达当前状态。两者分开后,View 不需要 +拿到可写草稿对象。 + +例如: + +```text +FleetDraftViewObject 页面看到什么 +FleetDraftEditIntent 用户想改什么 +FleetDraftEditResult 领域是否接受这次修改 ``` ---- +## statistics.ts:统计快照 + +定义战果等级、掉落和 `DailySortieStatsSnapshot`。统计快照可进入 VO,但统计 +累加逻辑仍由 `DailySortieStats` 持有。 + +## view.ts:展示契约 + +包含: + +- `ConfigViewObject` +- `MainViewObject` +- `TaskQueueItemVO` +- Fleet 和 Team Plan ViewObject。 +- `PlanPreviewViewObject` +- 任务组、模板和向导 ViewObject。 + +VO 字段应是 View 能直接渲染的格式: + +- 已转换的文案。 +- 已合并的列表。 +- 明确的 loading/error 状态。 +- 只读展示 identity。 + +VO 不应把完整 Model、Repository 或 API 响应包进去。 + +## 同一概念为何需要多个类型 + +以作战任务为例: + +| 边界 | 类型关注点 | +|---|---| +| API | 后端执行需要的请求字段 | +| Scheduler | 优先级、轮次、重试、logicalId | +| Model | 方案、停止条件和舰队规则 | +| ViewObject | 名称、剩余次数、进度和等待文案 | + +如果全部合成一个 `Task`: + +- 后端会看到 GUI 私有字段。 +- View 会依赖请求内部结构。 +- 持久化兼容字段会污染运行状态。 +- 大量成员只能被标为可选,类型失去约束力。 ## 引用规则 -| 层 | 可引用 | 禁止引用 | -|----|--------|----------| -| View | `types/view.ts` | `types/model.ts`, `types/api.ts` | -| Controller | `types/view.ts`, `types/model.ts`, `types/api.ts`, `types/scheduler.ts` | — | -| Model | `types/model.ts`, `types/api.ts`, `types/scheduler.ts` | `types/view.ts` | -| Electron 主进程 | `types/electronBridge.ts` | `types/view.ts` | +| 消费方 | 主要可引用 | 不应引用 | +|---|---|---| +| View | `view.ts`、明确 Intent、纯共享 DTO | 有状态 Model、ApiClient、Bridge | +| Controller | Model、Scheduler、API、IPC、View 类型 | DOM 实现类型 | +| Model | Model、Scheduler、API 类型 | ViewObject | +| Adapter | API/IPC 契约 | 页面实现 | +| Electron Main | IPC DTO、Shared 契约 | ViewObject 和 DOM | ---- +架构测试会拒绝 Controller 中的 `HTMLElement`、`ResizeObserver` 等 DOM 实现 +类型,也会拒绝 View 导入有状态 Model、ApiClient 或 Adapter。 -## 为什么要分开 +## 新增字段的正确路径 -一个反例:如果 `PlanPreviewView` 直接 import `types/model.ts` 中的 `PlanData`—— +先确定字段属于哪条契约: -```typescript -// ❌ 反例 -import type { PlanData } from '../../types/model'; - -class PlanPreviewView { - render(plan: PlanData): void { - // View 要自己处理 plan.node_args 的格式转换 - // View 要知道 formation 数字对应什么阵型名 - // 后端改了 PlanData 结构,View 也要改 - } -} +### 只影响展示 + +```text +types/view.ts + -> Controller rendering + -> View ``` -正确做法:Controller 完成所有转换,View 只接收 `PlanPreviewViewObject`: +### 后端 API 字段 -```typescript -// ✅ 正确 -import type { PlanPreviewViewObject } from '../../types/view'; - -class PlanPreviewView { - render(vo: PlanPreviewViewObject | null): void { - // vo.mapName 已经是 "7-4" 格式 - // vo.selectedNodes[0].formation 已经是 "单纵" - // View 不需要任何转换逻辑 - } -} +```text +types/api.ts + -> ApiClient + -> 请求构建方 + -> API contract test +``` + +### Electron IPC 字段 + +```text +types/ipc.ts + -> preload + -> Main IPC / Service + -> IpcAdapter / Controller +``` + +### 领域持久化字段 + +```text +types/model.ts + -> Model parse/default/serialize + -> Controller + -> 必要时再生成 VO +``` + +不要因为一个字段最终会显示在页面上,就直接把它同时加入所有类型。 + +## 常见反例 + +- 用 `any` 或双重断言连接不兼容层。 +- 给万能接口堆几十个可选字段。 +- View 直接消费 `ApiResponse`。 +- Main IPC 返回具体 Service 对象。 +- SchedulerTask 直接作为后端 TaskRequest。 +- 为避免转换,令 VO 继承完整 Model 类型。 +- 在 Renderer 和 Main 各复制一份不同的 IPC 接口。 + +## 验证 + +```powershell +npm run build +npm run test:architecture-boundaries +npm run test:main-ipc +npm run test:api-contract +git diff --check ``` + +编译通过只能证明结构类型兼容;API、IPC、持久化和页面语义仍需对应专项测试。 diff --git a/docs/teaching/README.md b/docs/teaching/README.md index 52196f8..1aa04c0 100644 --- a/docs/teaching/README.md +++ b/docs/teaching/README.md @@ -1,21 +1,61 @@ -# 重构实践教学系列 +# 重构与架构实践教学 -> **目标读者**:编写 `main` 分支原始代码的初级工程师 -> **文档定位**:循序渐进的教学,用架构图 + 关键代码片段讲清楚"为什么改、怎么改" +本目录用 AutoWSGR-GUI 的**当前工作区代码**讲解架构设计和安全重构方法, +包括尚未提交或尚未 push 的实现。示例不是历史快照,也不使用旧文件数和旧行数 +描述当前系统。 -> **规范说明**:本目录包含历史重构案例和教学快照,不是合并规范。当前强制要求以[工程与代码规范](../engineering-standards.md)为准。 +## 文档定位 + +- 想知道“项目现在怎么组成”,阅读[架构文档](../architecture/README.md)。 +- 准备修改代码,先遵守项目根目录的 [AGENTS.md](../../AGENTS.md)。 +- 想理解“为什么这样拆、如何判断代码放哪”,阅读本教学系列。 + +可执行配置、测试和生产源码是最终事实来源。代码变化后,教学示例也必须同步 +更新,不能为了匹配文档而改回旧架构。 ## 阅读顺序 -| # | 文档 | 内容 | 建议用时 | -|---|------|------|---------| -| 0 | [全局概览](00-overview.md) | 重构前后数据对比 + 完整架构图 | 10 min | -| 1 | [Extract Class — 拆分大文件](01-extract-class.md) | 按职责边界拆分 3000 行巨型文件 | 15 min | -| 2 | [Host 接口与依赖注入](02-host-interface.md) | 用最小接口替代隐式耦合 | 15 min | -| 3 | [ViewObject 单向数据流](03-viewobject-flow.md) | Model → Controller 拼装 → View 纯渲染 | 10 min | -| 4 | [Electron 主进程拆分](04-electron-split.md) | main.ts → backend / pythonEnv / emulatorDetect | 10 min | -| 5 | [View 层组织](05-view-layer.md) | Facade 模式 + 按功能域分子目录 | 10 min | -| 6 | [Model 层组织](06-model-layer.md) | Scheduler 子系统 + 独立领域模型 | 10 min | -| 7 | [类型系统分层](07-type-system.md) | model / view / api / scheduler 类型隔离 | 10 min | - -**建议路线**:先读 00 → 01 → 02 → 03 了解核心理念,再按需阅读 04-07。 +| # | 文档 | 学习目标 | +|---|---|---| +| 00 | [全局概览](00-overview.md) | 建立进程、分层、数据流和状态所有权认知 | +| 01 | [按职责拆分类](01-extract-class.md) | 判断何时拆分,以及如何保持行为不变 | +| 02 | [Host 接口与依赖注入](02-host-interface.md) | 用最小能力接口解除具体对象耦合 | +| 03 | [ViewObject 单向数据流](03-viewobject-flow.md) | 从 Model 到 View,再把用户意图送回 Controller | +| 04 | [Electron Main 分层](04-electron-split.md) | 区分组合根、IPC、Service、Repository 和 Codec | +| 05 | [View 层组织](05-view-layer.md) | 组织 Facade、局部 View、共享组件、HTML 和 SCSS | +| 06 | [Model 与领域状态](06-model-layer.md) | 确定唯一状态所有者,拆分领域规则和调度策略 | +| 07 | [类型系统分层](07-type-system.md) | 区分 API、IPC、Model、Scheduler、Intent 和 ViewObject | + +建议第一次按 `00 -> 01 -> 02 -> 03` 阅读,再按工作范围选择 `04~07`。 + +## 学习方式 + +每章都按同一顺序使用: + +1. 先看要解决的耦合或状态问题。 +2. 打开章节列出的当前源码。 +3. 理解为什么边界放在这里。 +4. 对照反例判断哪些“拆分”只是在搬代码。 +5. 使用章节末尾命令验证依赖、构建和行为。 + +阅读代码时优先使用: + +```powershell +rg -n "目标类名|目标方法|界面文案" src electron scripts/tests +rg --files src electron scripts/tests +git status --short +``` + +不要只查看 Git HEAD。本项目允许工作区存在尚未提交的连续开发,当前文件才是 +正在生效的实现。 + +## 核心原则 + +1. 按职责和状态所有权拆分,不按行数平均拆分。 +2. Controller 编排用例,但不拥有 DOM 或底层 IPC。 +3. View 拥有 DOM 和局部视觉状态,但不拥有业务状态。 +4. Model 拥有领域状态和规则,不依赖具体 View。 +5. Renderer 外部能力经过 Adapter;Electron 能力经过 preload 和 IPC。 +6. Main 的可测试行为进入 Service,文件来源进入 Repository,格式进入 Codec。 +7. 新抽象必须有真实调用方,并减少实质重复或隔离明确边界。 +8. 拆分前后状态来源、持久化契约、交互和错误语义保持一致。 diff --git a/docs/user-guide.md b/docs/user-guide.md index 3a12659..b054070 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -113,13 +113,164 @@ - 索敌规则(每行一条,支持 and 组合) 3. 点击“应用”保存节点设置。 -索敌规则示例: +#### 索敌规则是做什么的 + +进入索敌画面并识别敌方舰队后,系统根据索敌规则决定: + +- 使用什么阵型战斗。 +- 撤退并结束本轮出击。 +- 在可以迂回的节点跳过本次战斗。 + +索敌规则只判断敌方舰种和数量,不判断己方舰队,也不负责选择地图路线。 +如果没有任何规则满足条件,系统使用该节点原本设置的阵型和行为。 + +地图上的“自定义规则”标记表示该节点单独保存过设置。修改阵型、夜战、前进或 +索敌规则中的任意一项,都可能出现这个标记;它不表示索敌规则一定已经填写。 + +#### 最简单的填写方法 + +每行填写一条规则,格式为: + +```text +看到什么敌人, 采取什么动作 +``` + +例如: + +```text +CV >= 1, retreat +``` + +意思是:敌方有一艘或更多航母时撤退。 + +推荐统一使用英文逗号分隔。界面也支持中文逗号、`=>` 和 `->`: + +```text +CV >= 1,撤退 +SS >= 1 => 5 +AP < 1 -> detour +``` + +空行会被忽略。 + +#### 可以填写的动作 + +| 填写内容 | 实际效果 | +|----------|----------| +| `1` | 使用单纵阵战斗 | +| `2` | 使用复纵阵战斗 | +| `3` | 使用轮型阵战斗 | +| `4` | 使用梯形阵战斗 | +| `5` | 使用单横阵战斗 | +| `retreat` 或 `撤退` | 撤退,结束本轮出击 | +| `detour` 或 `迂回` | 点击迂回,跳过当前战斗并继续走图 | + +`撤退`只结束当前这一轮出击,不等于停止整个任务队列。如果任务还有剩余执行 +次数,后续轮次仍可能继续执行。 + +`迂回`只应写在地图本身支持迂回的节点。普通节点无法执行迂回,可能导致任务 +报错。 + +#### 可以填写的判断条件 + +支持以下比较方式: + +| 写法 | 含义 | +|------|------| +| `CV > 0` | 航母数量大于 0 | +| `SS >= 1` | 潜艇数量大于等于 1 | +| `CVL == 1` | 轻母数量正好为 1 | +| `BB != 2` | 战列数量不等于 2 | +| `DD + CL >= 3` | 驱逐和轻巡总数大于等于 3 | +| `SS >= 1 and CV == 0` | 有潜艇,并且没有航母 | +| `ALL == 6` | 敌方舰船总数为 6 | + +支持的比较符为: + +```text +> >= < <= == != +``` + +多个条件只能用 `and` 连接,不支持 `or`。舰种代号必须使用下表中的大写形式。 + +#### 敌方舰种代号 + +| 代号 | 舰种 | 代号 | 舰种 | +|------|------|------|------| +| `CV` | 航母 | `CVL` | 轻母 | +| `AV` | 装母 | `BB` | 战列 | +| `BBV` | 航战 | `BC` | 战巡 | +| `CA` | 重巡 | `CAV` | 航巡 | +| `CLT` | 雷巡 | `CL` | 轻巡 | +| `BM` | 重炮 | `DD` | 驱逐 | +| `SS` | 潜艇 | `SSG` | 导潜 | +| `SC` | 炮潜 | `AP` | 补给 | +| `ASDG` | 导驱 | `AADG` | 防驱 | +| `KP` | 导巡 | `CG` | 防巡 | +| `CBG` | 大巡 | `BBG` | 导战 | +| `ALL` | 敌方舰船总数 | | | + +补给舰推荐写 `AP`,旧写法 `NAP` 也能识别。大巡必须写 `CBG`,不要写 `BG`。 +`ss_or_ssg`、中文舰种名称以及其他缩写不能用于索敌规则。 + +#### 多条规则怎么判断 + +系统从上往下判断,第一条满足条件的规则生效,后面的规则不再判断。 + +```text +CV >= 1, retreat +SS >= 1, 5 +ALL >= 0, 2 +``` + +这三条规则表示: + +1. 有航母时撤退。 +2. 没有航母但有潜艇时,使用单横阵。 +3. 其他情况使用复纵阵。 + +因此,范围越小、越重要的规则应放在上面,兜底规则应放在最后。`ALL >= 0` +永远成立,可以作为兜底条件。 + +#### 常用场景 + +只在发现补给舰时战斗,否则迂回: + +```text +AP >= 1, 4 +AP < 1, detour +``` + +发现航母就撤退: + +```text +CV >= 1, retreat +``` + +发现潜艇或导潜时使用单横阵: + +```text +SS + SSG >= 1, 5 +``` + +同时满足两个条件才使用梯形阵: ```text -DD >= 1, 4 DD >= 1 and CL >= 2, 4 ``` +#### 当前版本使用建议 + +- 想让规则决定“战斗还是迂回”时,关闭节点自身的“迂回”开关,再用 + `AP < 1, detour` 这样的规则明确指定何时迂回。 +- 如果方案设置了全局默认索敌规则,节点输入框清空后仍可能继续继承全局规则。 + 需要让该节点始终按固定阵型战斗时,可填写 `ALL >= 0, 阵型编号`,例如 + `ALL >= 0, 4`。 +- 不要使用单个等号,例如 `CV = 1`;判断相等必须写成 `CV == 1`。 +- 不要填写中文舰种名称,例如“航母 >= 1”。应写成 `CV >= 1`。 +- 条件格式不正确时,界面可能仍能保存,但任务加入队列或开始运行时会被后端 + 拒绝。遇到这种情况,先检查舰种代号、比较符和动作。 + ### 3.7 保存并投递任务 编辑完成后,你可以选择: @@ -181,7 +332,7 @@ DD >= 1 and CL >= 2, 4 ### 6.5 自动化设置 - 自动远征(含检查间隔) -- 自动战役(战役类型、次数) +- 自动战役(战役类型;每日固定完成 8 次) - 自动演习(演习舰队) - 自动常规出击 - 自动决战(决战模板) From 14d3e1829e5b028285c203b02d9b5e4acbf8c749 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:22:55 +0800 Subject: [PATCH 59/82] fix(ui): constrain overview panel heights --- src/view/styles/pages/main-page/_layout.scss | 10 +++++++++- src/view/styles/pages/plan/_node-editor.scss | 2 ++ src/view/styles/styles.css | 11 ++++++++++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/view/styles/pages/main-page/_layout.scss b/src/view/styles/pages/main-page/_layout.scss index 76e8b5b..f82bfb7 100644 --- a/src/view/styles/pages/main-page/_layout.scss +++ b/src/view/styles/pages/main-page/_layout.scss @@ -49,6 +49,9 @@ /* ═══ 作战任务与舰队预览 ═══ */ .battle-overview-grid { + --battle-overview-collapsed-height: 165px; + --battle-overview-expanded-height: 260px; + display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: stretch; @@ -58,14 +61,19 @@ .battle-overview-grid > .card { min-width: 0; - min-height: 126px; margin-bottom: 0; padding: 14px; } +.battle-overview-grid > :is(#task-area-card, .fleet-preview-card) { + min-height: var(--battle-overview-collapsed-height); + max-height: var(--battle-overview-expanded-height); +} + .fleet-preview-card { display: flex; flex-direction: column; + overflow-y: auto; padding: 8px !important; } diff --git a/src/view/styles/pages/plan/_node-editor.scss b/src/view/styles/pages/plan/_node-editor.scss index 6eef698..9debf4f 100644 --- a/src/view/styles/pages/plan/_node-editor.scss +++ b/src/view/styles/pages/plan/_node-editor.scss @@ -248,6 +248,8 @@ .node-rules-textarea { resize: vertical; font-family: "Cascadia Code", "Consolas", "Courier New", monospace; + font-feature-settings: "liga" 0, "calt" 0; + font-variant-ligatures: none; min-height: 70px; line-height: 1.6; } diff --git a/src/view/styles/styles.css b/src/view/styles/styles.css index a58e2fa..305b641 100644 --- a/src/view/styles/styles.css +++ b/src/view/styles/styles.css @@ -989,6 +989,8 @@ input[type=checkbox]:focus-visible { /* ═══ 作战任务与舰队预览 ═══ */ .battle-overview-grid { + --battle-overview-collapsed-height: 165px; + --battle-overview-expanded-height: 260px; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: stretch; @@ -998,14 +1000,19 @@ input[type=checkbox]:focus-visible { .battle-overview-grid > .card { min-width: 0; - min-height: 126px; margin-bottom: 0; padding: 14px; } +.battle-overview-grid > :is(#task-area-card, .fleet-preview-card) { + min-height: var(--battle-overview-collapsed-height); + max-height: var(--battle-overview-expanded-height); +} + .fleet-preview-card { display: flex; flex-direction: column; + overflow-y: auto; padding: 8px !important; } @@ -4420,6 +4427,8 @@ input[type=checkbox]:focus-visible { .node-rules-textarea { resize: vertical; font-family: "Cascadia Code", "Consolas", "Courier New", monospace; + font-feature-settings: "liga" 0, "calt" 0; + font-variant-ligatures: none; min-height: 70px; line-height: 1.6; } From 88f95c6998e03daf1cde45d614c6885a602e041e Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:57:44 +0800 Subject: [PATCH 60/82] fix(ci): use backend virtualenv for fleet contract --- .github/workflows/pull-request-checks.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pull-request-checks.yml b/.github/workflows/pull-request-checks.yml index f1648b3..0470a04 100644 --- a/.github/workflows/pull-request-checks.yml +++ b/.github/workflows/pull-request-checks.yml @@ -84,6 +84,8 @@ jobs: run: node scripts/tests/test-main-services.js - name: Check fleet type snapshot + env: + AUTOWSGR_PYTHON: ${{ github.workspace }}/AutoWSGR/.venv/bin/python run: npm run check:fleet-types - name: Test GUI to AutoWSGR fleet contract From 8ce3abdba0d409126b2860251d3cae2491690923 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:05:48 +0800 Subject: [PATCH 61/82] docs: update agent branch workflow rules --- AGENTS.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 23afd2e..b9374bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,12 +168,11 @@ - 路径以仓库为基准:GUI 为当前仓库根目录,后端默认是同级 `../AutoWSGR`;不得固化某台机器的绝对路径。 - 当前仓库是个人 Fork。`main` 只作为同步主库代码的中转分支,不直接开发功能;同步上游后再更新个人 Fork 的 `origin/main`。 - `ShiinaKuroko` 是主力开发分支,代表个人 Fork 的最新有效代码。功能实现、正式自动化测试和必要生成入口必须先合入该分支并完成验证,再由该分支执行普通 push。 -- 本地工作分支可按任务自由创建。临时实验、一次性验证和调试代码不得合入;与功能对应、需要长期保留的自动化测试属于有效改动,应与实现一起合入 `ShiinaKuroko`。 -- 临时 PR 分支是唯一允许额外推送的功能分支,格式为 `feat/<功能>-PR` 或 `fix/<功能>-PR`;Git 分支名不得使用反斜杠。创建下一条 PR 前必须检查上一条临时 PR 的状态,确认代码已经合入目标分支后,删除对应的本地和远端 PR 分支;未合入或状态不明确时不得删除。 +- 未经维护者事先明确允许,不得创建任何本地或远程分支、备份分支、worktree 或发布克隆;不得以隔离脏工作树、备份、测试、打包或发布为理由自行创建。 +- 只有维护者明确要求并批准 PR 时,才允许创建对应 PR 分支;格式为 `feat/<功能>-PR` 或 `fix/<功能>-PR`,Git 分支名不得使用反斜杠。创建下一条 PR 前必须检查上一条临时 PR 的状态,确认代码已经合入目标分支后,删除对应的本地和远端 PR 分支;未合入或状态不明确时不得删除。 - GitHub 网络操作前读取系统当前代理设置;只允许命令级临时代理,不得硬编码端口或修改全局 Git 配置。 - 每次 push `ShiinaKuroko` 前必须先 `git fetch origin`。本地落后或出现分叉时,先审查并整合远端提交,不得用强推覆盖远端代码。 -- push 前以当前 `origin/ShiinaKuroko` 为指针创建远端不可移动备份 `backup/YYYYMMDD-`。日期精确到日,短 SHA 用于区分同一天的多次 push;必须先确认远端备份存在且提交正确,再 push 新代码。 -- 远端 backup 分支最多保留两个。创建第三个备份并验证成功后,按名称日期和提交时间删除最旧的一个;不得移动、复用或覆盖已有 backup 分支。 +- 只有维护者明确要求并批准时,才以当前 `origin/ShiinaKuroko` 为指针创建远端不可移动备份 `backup/YYYYMMDD-`;不得擅自创建、移动、复用或删除 backup 分支。 - 普通 push 禁止 `--force` 和 `--force-with-lease`。只有用户明确授权历史改写、已说明将被替换的远端提交且备份完成时,才允许使用 `--force-with-lease`。 - push 完成后检查其他本地工作分支:只有确认有效提交已进入 `ShiinaKuroko`、没有独有未推送提交、没有未提交修改且未被 worktree 使用时才能删除。不得批量强删;保留 `main`、`ShiinaKuroko` 和仍未合入的活动 PR 分支。 From dcc18b3d59595b2d9d156db24aefabe0ec49b5f1 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:09:37 +0800 Subject: [PATCH 62/82] chore(release): bump version to 2.0.15-alpha --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1ba57fe..d3e58ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wsgrgui", - "version": "2.0.14-alpha", + "version": "2.0.15-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wsgrgui", - "version": "2.0.14-alpha", + "version": "2.0.15-alpha", "dependencies": { "electron-updater": "^6.8.3", "js-yaml": "^4.1.0", diff --git a/package.json b/package.json index 2c572a0..fea5329 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wsgrgui", - "version": "2.0.14-alpha", + "version": "2.0.15-alpha", "description": "AutoWSGR YAML 管理与自动化桌面界面", "main": "dist/electron/main.js", "scripts": { From bee2e312ba1289b7f98806252bf55a2c49a443d3 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Tue, 11 Aug 2026 23:17:44 +0800 Subject: [PATCH 63/82] docs(agent): pin main sync to alpha --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index b9374bb..61c29ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,7 +166,7 @@ ### 8.2 ShiinaKuroko Fork - 路径以仓库为基准:GUI 为当前仓库根目录,后端默认是同级 `../AutoWSGR`;不得固化某台机器的绝对路径。 -- 当前仓库是个人 Fork。`main` 只作为同步主库代码的中转分支,不直接开发功能;同步上游后再更新个人 Fork 的 `origin/main`。 +- 当前仓库是个人 Fork。`main` 只用于同步 `yltx/AutoWSGR-GUI:alpha`,不直接开发功能;同步上游 `alpha` 后再更新个人 Fork 的 `origin/main`。 - `ShiinaKuroko` 是主力开发分支,代表个人 Fork 的最新有效代码。功能实现、正式自动化测试和必要生成入口必须先合入该分支并完成验证,再由该分支执行普通 push。 - 未经维护者事先明确允许,不得创建任何本地或远程分支、备份分支、worktree 或发布克隆;不得以隔离脏工作树、备份、测试、打包或发布为理由自行创建。 - 只有维护者明确要求并批准 PR 时,才允许创建对应 PR 分支;格式为 `feat/<功能>-PR` 或 `fix/<功能>-PR`,Git 分支名不得使用反斜杠。创建下一条 PR 前必须检查上一条临时 PR 的状态,确认代码已经合入目标分支后,删除对应的本地和远端 PR 分支;未合入或状态不明确时不得删除。 From d5eca09cabc1d1aeec25e20a7cb2acbda85e04e9 Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:34:56 +0800 Subject: [PATCH 64/82] feat: refine fleet presets and decisive plan workflows --- electron/main.ts | 4 +- electron/services/CombatPlanCodec.ts | 32 ++- electron/services/LegacyPlanMigration.ts | 12 +- electron/services/MigrationConflictService.ts | 4 +- electron/services/ShipNameSynchronizer.ts | 4 +- electron/services/TeamPlanCodec.ts | 133 ++++------- electron/services/TeamPlanRepository.ts | 4 +- electron/services/UserDataMigrationService.ts | 21 +- .../bettle-\345\221\250\345\270\270-1-1.yaml" | 100 ++------ ...bettle-\345\221\250\345\270\270-10-1.yaml" | 111 ++++----- .../bettle-\345\221\250\345\270\270-2-1.yaml" | 99 ++------ .../bettle-\345\221\250\345\270\270-3-1.yaml" | 102 +++----- .../bettle-\345\221\250\345\270\270-4-1.yaml" | 66 +++--- .../bettle-\345\221\250\345\270\270-5-5.yaml" | 99 ++------ .../bettle-\345\221\250\345\270\270-6-4.yaml" | 78 ++----- .../bettle-\345\221\250\345\270\270-7-4.yaml" | 111 +++------ .../bettle-\345\221\250\345\270\270-8-2.yaml" | 46 ++-- .../bettle-\345\221\250\345\270\270-9-2.yaml" | 98 +++----- ...\345\267\245\347\250\213\351\230\237.yaml" | 54 +++-- ...\345\221\250\345\270\270\351\230\237.yaml" | 44 ++-- ...\345\221\250\345\270\270\351\230\237.yaml" | 44 ++-- ...\346\227\245\351\251\261\351\230\237.yaml" | 90 ++++---- ...\346\275\234\350\211\207\351\230\237.yaml" | 132 +++++------ ...\351\230\262\351\251\261\351\230\237.yaml" | 70 +++--- ...\345\276\267\351\251\261\351\230\237.yaml" | 40 ++-- ...44\247\346\267\20085AIII\351\230\237.yaml" | 33 +-- ...\345\221\250\345\270\270\351\230\237.yaml" | 95 ++++---- .../tests/main-services/test-combat-plan.js | 193 +++++++++++++++- scripts/tests/main-services/test-migration.js | 28 ++- scripts/tests/main-services/test-team-plan.js | 55 ++++- scripts/tests/test-api-contract.js | 218 +++++++++++++++--- scripts/tests/test-fleet-domain.mjs | 36 +++ scripts/tests/test-renderer-architecture.js | 35 ++- scripts/tests/test-scheduler-domain.mjs | 156 ++++++++++++- scripts/tests/test-settings-persistence.js | 100 ++++++++ src/adapter/IpcAdapter.ts | 3 + src/adapter/YamlAdapter.ts | 11 +- src/controller/app/AppController.ts | 3 + src/controller/app/AutomaticDecisiveTask.ts | 37 +-- src/controller/app/ConfigController.ts | 24 ++ src/controller/app/CurrentFleetController.ts | 35 +-- src/controller/app/NavigationController.ts | 26 ++- src/controller/app/ScheduledTaskLoader.ts | 7 +- src/controller/contracts.ts | 6 +- src/controller/plan/DecisivePlanController.ts | 22 ++ src/controller/plan/FleetPlannerController.ts | 14 ++ src/controller/plan/PlanController.ts | 60 ++++- src/controller/plan/galleryStateStorage.ts | 73 ++++++ src/controller/plan/presetFlow.ts | 29 ++- src/controller/taskGroup/addItems.ts | 8 +- src/controller/taskGroup/queueLoader.ts | 119 ++++++++-- src/model/PlanModel.ts | 117 +--------- src/shared/shipLibrary.ts | 70 ++++++ src/shared/shipNameNormalizer.ts | 33 +++ src/shared/yamlSerializer.ts | 201 ++++++++++++++++ src/types/view.ts | 13 ++ src/view/main/FleetPreviewView.ts | 9 +- src/view/main/NavigationView.ts | 5 + src/view/plan/DecisivePlanView.ts | 74 ++++-- src/view/plan/FleetEditorView.ts | 7 +- src/view/plan/FleetGalleryView.ts | 5 + src/view/plan/FleetPlannerView.ts | 5 + src/view/plan/FleetPresetView.ts | 30 +-- src/view/plan/ShipArtwork.ts | 62 +++-- src/view/plan/ShipGalleryView.ts | 85 ++++++- src/view/plan/TeamPlanLoaderView.ts | 27 +-- src/view/styles/pages/main-page/_layout.scss | 4 - src/view/styles/pages/plan/_fleet-preset.scss | 41 +--- src/view/styles/pages/plan/_ship-gallery.scss | 23 ++ src/view/styles/styles.css | 65 +++--- 70 files changed, 2408 insertions(+), 1492 deletions(-) create mode 100644 src/controller/plan/galleryStateStorage.ts create mode 100644 src/shared/shipLibrary.ts create mode 100644 src/shared/yamlSerializer.ts diff --git a/electron/main.ts b/electron/main.ts index 856b1bf..e640820 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -432,7 +432,7 @@ const legacyPlanMigration = new LegacyPlanMigration( { yamlFiles: directory => combatPlanRepository.yamlFiles(directory), safePlanBaseName: value => combatPlanCodec.safeBaseName(value), - normalizeUserTeamPlan: raw => teamPlanCodec.normalize(raw), + normalizeUserTeamPlan: raw => teamPlanCodec.normalizeLegacy(raw), teamPlanMatches: (filePath, team) => ( teamPlanRepository.matches(filePath, team) ), @@ -445,7 +445,7 @@ const legacyPlanMigration = new LegacyPlanMigration( root, source, requireEmbeddedShips, - ) => combatPlanCodec.normalizeFleetPresets( + ) => combatPlanCodec.normalizeLegacyFleetPresets( root, source, requireEmbeddedShips, diff --git a/electron/services/CombatPlanCodec.ts b/electron/services/CombatPlanCodec.ts index 925ca5f..3d1fa66 100644 --- a/electron/services/CombatPlanCodec.ts +++ b/electron/services/CombatPlanCodec.ts @@ -1,10 +1,13 @@ /** * 校验、拆分、展开并序列化出征计划。 */ -import * as yaml from 'js-yaml'; import { normalizeLegacyNodeDecisionFields, } from '../../src/shared/nodeDecision'; +import { + parseYaml, + serializePlanYaml, +} from '../../src/shared/yamlSerializer'; import { type PlanPresetSource, TeamPlanCodec, @@ -36,7 +39,7 @@ export class CombatPlanCodec { content: string, invalidRootMessage: string, ): Record { - const parsed = yaml.load(content); + const parsed = parseYaml(content); if (!this.isPlainObject(parsed)) { throw new Error(invalidRootMessage); } @@ -57,18 +60,12 @@ export class CombatPlanCodec { } break; } - const body = yaml.dump(root, { - lineWidth: -1, - noCompatMode: true, - noRefs: true, - sortKeys: false, - }); const prefix = leadingComments.some( line => line.trimStart().startsWith('#'), ) ? `${leadingComments.join('\n').replace(/\s+$/, '')}\n` : ''; - return `${prefix}${body}`; + return `${prefix}${serializePlanYaml(root)}`; } /** 把内嵌舰队拆成独立编队,并在地图中只保留名称引用。 */ @@ -142,6 +139,23 @@ export class CombatPlanCodec { }; } + /** 拆分旧计划,并让其中缺少校验模式的内嵌舰队默认使用弱校验。 */ + normalizeLegacyFleetPresets( + root: Record, + source: PlanPresetSource, + requireEmbeddedShips: boolean, + ): SplitCombatPlan { + const split = this.normalizeFleetPresets( + root, + source, + requireEmbeddedShips, + ); + return { + ...split, + teams: split.teams.map(team => this.teamCodec.normalizeLegacy(team)), + }; + } + /** 为后端执行展开舰队引用,并保留引用对象的未知字段。 */ expandRoot( root: Record, diff --git a/electron/services/LegacyPlanMigration.ts b/electron/services/LegacyPlanMigration.ts index 219d51c..67abd9a 100644 --- a/electron/services/LegacyPlanMigration.ts +++ b/electron/services/LegacyPlanMigration.ts @@ -4,7 +4,7 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; -import * as yaml from 'js-yaml'; +import { parseYaml } from '../../src/shared/yamlSerializer'; import { AppPaths } from './AppPaths'; import { AtomicFileStore } from './AtomicFileStore'; import { @@ -193,7 +193,7 @@ export class LegacyPlanMigration { summary.total += 1; try { const team = this.dependencies.normalizeUserTeamPlan( - yaml.load(content), + parseYaml(content), ); const resolved = this.resolveTeamWrite( team, @@ -250,7 +250,7 @@ export class LegacyPlanMigration { continue; } try { - const parsed = yaml.load(content); + const parsed = parseYaml(content); if (!this.isPlainObject(parsed)) { throw new Error('旧计划根节点必须是对象'); } @@ -311,7 +311,7 @@ export class LegacyPlanMigration { } summary.total += 1; try { - const parsed = yaml.load(content); + const parsed = parseYaml(content); if (!this.isPlainObject(parsed)) { throw new Error('旧日常任务根节点必须是对象'); } @@ -410,7 +410,7 @@ export class LegacyPlanMigration { const content = fs.readFileSync(source, 'utf-8'); let parsed: unknown; try { - parsed = yaml.load(content); + parsed = parseYaml(content); } catch { // 非计划目录中的普通 YAML 不属于用户迁移失败。 continue; @@ -849,7 +849,7 @@ export class LegacyPlanMigration { return this.dependencies.yamlFiles(directory).flatMap(file => { const source = path.join(directory, file); try { - const parsed = yaml.load(fs.readFileSync(source, 'utf-8')); + const parsed = parseYaml(fs.readFileSync(source, 'utf-8')); return ( this.isPlainObject(parsed) && this.dependencies.isStandaloneTaskPreset?.(parsed) === true diff --git a/electron/services/MigrationConflictService.ts b/electron/services/MigrationConflictService.ts index 482725a..558b21d 100644 --- a/electron/services/MigrationConflictService.ts +++ b/electron/services/MigrationConflictService.ts @@ -17,7 +17,6 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import { isDeepStrictEqual } from 'util'; -import * as yaml from 'js-yaml'; import type { MigrationConflictItem, MigrationConflictKind, @@ -25,6 +24,7 @@ import type { MigrationConflictReason, MigrationConflictResolutionResult, } from '../../src/shared/migrationConflicts'; +import { parseYaml } from '../../src/shared/yamlSerializer'; import { AppPaths } from './AppPaths'; import { AtomicFileStore } from './AtomicFileStore'; @@ -287,7 +287,7 @@ export class MigrationConflictService { try { return [{ file: entry.name, - value: yaml.load( + value: parseYaml( fs.readFileSync(path.join(directory, entry.name), 'utf-8'), ), }]; diff --git a/electron/services/ShipNameSynchronizer.ts b/electron/services/ShipNameSynchronizer.ts index 612eff4..3e4ea07 100644 --- a/electron/services/ShipNameSynchronizer.ts +++ b/electron/services/ShipNameSynchronizer.ts @@ -6,7 +6,7 @@ */ import * as fs from 'fs'; import * as path from 'path'; -import * as yaml from 'js-yaml'; +import { parseYaml } from '../../src/shared/yamlSerializer'; import { AtomicFileStore } from './AtomicFileStore'; export interface ShipNameSyncResult { @@ -180,7 +180,7 @@ export class ShipNameSynchronizer { } private loadDocument(content: string): ShipNameDocument { - const value = yaml.load(content); + const value = parseYaml(content); if (!value || typeof value !== 'object' || Array.isArray(value)) { throw new Error('后端舰名库不是有效的 YAML 映射'); } diff --git a/electron/services/TeamPlanCodec.ts b/electron/services/TeamPlanCodec.ts index 9f9b16a..31984fa 100644 --- a/electron/services/TeamPlanCodec.ts +++ b/electron/services/TeamPlanCodec.ts @@ -1,9 +1,8 @@ /** * 归一化、校验并序列化独立编队计划。 */ -import * as yaml from 'js-yaml'; - import { normalizeFleetShipTypeCode } from '../../src/shared/fleetShipTypes'; +import { serializeTeamYaml } from '../../src/shared/yamlSerializer'; export type PlanPresetSource = 'system' | 'user'; @@ -77,90 +76,57 @@ export class TeamPlanCodec { return { ...raw, name: raw.name.trim(), ships }; } + /** + * 归一化兼容升级导入的旧编队。 + * 旧规则未声明校验模式时使用弱校验,显式 true/false 均保持不变。 + */ + normalizeLegacy(raw: unknown): UserTeamPlan { + const plan = this.normalize(raw); + const ships = plan.ships.map((slot) => { + const candidates = slot.candidates?.map(candidate => ({ + ...candidate, + relaxed: candidate.relaxed ?? true, + })); + return { + ...slot, + ...( + slot.name !== undefined && slot.relaxed === undefined + ? { relaxed: true } + : {} + ), + ...(candidates === undefined ? {} : { candidates }), + }; + }); + return { ...plan, ships }; + } + /** 序列化编队计划并保留未知业务字段。 */ serialize(plan: UserTeamPlan): string { + const ships = plan.ships.map((slot) => { + const output = slot.name === undefined + ? this.serializableAnonymousSlot(slot) + : this.serializableShipRule({ + ...slot, + name: slot.name, + }); + delete output.candidates; + delete output.priority; + if (slot.candidates?.length) { + output.candidates = slot.candidates.map( + candidate => this.serializableShipRule(candidate), + ); + } + return output; + }); const output: Record = { ...plan, name: plan.name, - ships: plan.ships, + ships, }; delete output.file; delete output.modifiedAt; delete output.source; - const lines = [ - ...Object.entries(output) - .filter(([key]) => key !== 'ships') - .map(([key, value]) => `${key}: ${this.inlineYaml(value)}`), - 'ships:', - ]; - for (const slot of plan.ships) { - const candidateOnly = slot.name === undefined; - const knownSlotKeys = new Set([ - 'name', - 'search_name', - 'ship_type', - 'min_level', - 'max_level', - 'relaxed', - 'candidates', - 'priority', - ]); - const extraEntries = Object.entries(slot) - .filter( - ([key, value]) => ( - !knownSlotKeys.has(key) && value !== undefined - ), - ); - const anonymousEntries = Object.entries( - this.serializableAnonymousSlot(slot), - ); - if (slot.name !== undefined) { - lines.push(` - name: ${this.inlineYaml(slot.name)}`); - if (slot.search_name !== undefined) { - lines.push( - ` search_name: ${this.inlineYaml(slot.search_name)}`, - ); - } - if (slot.ship_type !== undefined) { - lines.push( - ` ship_type: ${this.inlineYaml(slot.ship_type)}`, - ); - } - if (slot.min_level !== undefined) { - lines.push(` min_level: ${slot.min_level}`); - } - if (slot.max_level !== undefined) { - lines.push(` max_level: ${slot.max_level}`); - } - if (slot.relaxed === true) { - lines.push(' relaxed: true'); - } - } - if (candidateOnly && anonymousEntries.length > 0) { - const [first, ...rest] = anonymousEntries; - lines.push(` - ${first[0]}: ${this.inlineYaml(first[1])}`); - for (const [key, value] of rest) { - lines.push(` ${key}: ${this.inlineYaml(value)}`); - } - } else if (!candidateOnly) { - for (const [key, value] of extraEntries) { - lines.push(` ${key}: ${this.inlineYaml(value)}`); - } - } - if (slot.candidates?.length) { - lines.push( - candidateOnly && anonymousEntries.length === 0 - ? ' - candidates:' - : ' candidates:', - ); - for (const candidate of slot.candidates) { - lines.push( - ` - ${this.inlineYaml(this.serializableShipRule(candidate))}`, - ); - } - } - } - return `${lines.join('\n')}\n`; + return serializeTeamYaml(output); } /** 把编队名称转换为既有 team-*.yaml 文件名。 */ @@ -410,7 +376,7 @@ export class TeamPlanCodec { return value; } - /** 固定舰船规则字段顺序;false 使用后端缺省值,不写入 YAML。 */ + /** 固定舰船规则字段顺序,并显式记录强校验或弱校验。 */ private serializableShipRule( rule: UserTeamShipRule, ): Record { @@ -419,7 +385,7 @@ export class TeamPlanCodec { if (rule.ship_type !== undefined) result.ship_type = rule.ship_type; if (rule.min_level !== undefined) result.min_level = rule.min_level; if (rule.max_level !== undefined) result.max_level = rule.max_level; - if (rule.relaxed === true) result.relaxed = true; + result.relaxed = rule.relaxed === true; const knownKeys = new Set([ 'name', 'search_name', @@ -460,15 +426,6 @@ export class TeamPlanCodec { return result; } - private inlineYaml(value: unknown): string { - return yaml.dump(value, { - flowLevel: 0, - lineWidth: -1, - noRefs: true, - sortKeys: false, - }).trim(); - } - private isPlainObject( value: unknown, ): value is Record { diff --git a/electron/services/TeamPlanRepository.ts b/electron/services/TeamPlanRepository.ts index 5191c40..939181f 100644 --- a/electron/services/TeamPlanRepository.ts +++ b/electron/services/TeamPlanRepository.ts @@ -3,7 +3,7 @@ */ import * as fs from 'fs'; import * as path from 'path'; -import * as yaml from 'js-yaml'; +import { parseYaml } from '../../src/shared/yamlSerializer'; import { AppPaths } from './AppPaths'; import { AtomicFileStore } from './AtomicFileStore'; import { @@ -44,7 +44,7 @@ export class TeamPlanRepository { /** 读取并归一化一份编队 YAML。 */ read(filePath: string): UserTeamPlan { return this.codec.normalize( - yaml.load(this.readContent(filePath)), + parseYaml(this.readContent(filePath)), ); } diff --git a/electron/services/UserDataMigrationService.ts b/electron/services/UserDataMigrationService.ts index 014242f..2dbc51a 100644 --- a/electron/services/UserDataMigrationService.ts +++ b/electron/services/UserDataMigrationService.ts @@ -4,7 +4,6 @@ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; -import * as yaml from 'js-yaml'; import { isDeepStrictEqual } from 'util'; import { DEFAULT_LOOT_PLAN_ID, @@ -15,6 +14,10 @@ import { parseLootPlanIndex, type LootPlanId, } from '../../src/shared/lootPlans'; +import { + parseYaml, + serializeYaml, +} from '../../src/shared/yamlSerializer'; import { AppPaths } from './AppPaths'; import { AtomicFileStore } from './AtomicFileStore'; import { @@ -602,12 +605,7 @@ export class UserDataMigrationService { automation[key] = migrated; const content = format === 'yaml' - ? yaml.dump(root, { - lineWidth: -1, - noCompatMode: true, - noRefs: true, - sortKeys: false, - }) + ? serializeYaml(root) : JSON.stringify(root, null, 2); this.atomicFiles.write(target, content); return true; @@ -769,12 +767,7 @@ export class UserDataMigrationService { : {}; const merged = this.deepMerge(current, legacy); const content = format === 'yaml' - ? yaml.dump(merged, { - lineWidth: -1, - noCompatMode: true, - noRefs: true, - sortKeys: false, - }) + ? serializeYaml(merged) : JSON.stringify(merged, null, 2); fs.mkdirSync(path.dirname(target), { recursive: true }); this.atomicFiles.write(target, content); @@ -962,7 +955,7 @@ export class UserDataMigrationService { label: string, ): Record { const parsed = format === 'yaml' - ? yaml.load(content) + ? parseYaml(content) : JSON.parse(content); if (!this.isPlainObject(parsed)) { throw new Error(`${label}根节点必须是对象`); diff --git "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-1-1.yaml" "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-1-1.yaml" index ea6e661..51e2138 100644 --- "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-1-1.yaml" +++ "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-1-1.yaml" @@ -6,93 +6,25 @@ fight_condition: 1 repair_mode: 1 node_defaults: {formation: 2, night: false, proceed: true} node_args: - B: {formation: 4, night: true, long_missile_support: false, proceed: true, detour: false} - A: {formation: 2, night: false, long_missile_support: false, proceed: true, detour: false} + A: + formation: 2 + night: false + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] + B: + formation: 4 + night: true + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] times: 3 fleet_id: 3 result: S fleet_presets: - name: 4日驱周常队 - ships: - - name: 岛风(岛风型驱逐舰)·改 - candidates: - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100} - - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - search_name: 岛风 - ship_type: [dd] - min_level: 100 - - name: 初夏·改 - candidates: - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100} - - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - search_name: 初夏 - ship_type: [dd] - min_level: 100 - - name: 高波·改 - candidates: - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100} - - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - search_name: 高波 - ship_type: [dd] - min_level: 100 - - name: 阳炎·改 - candidates: - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100} - - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - search_name: 阳炎 - ship_type: [dd] - min_level: 100 - name: 4德驱周常队 - ships: - - name: Z28·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - search_name: Z28 - ship_type: [dd] - min_level: 100 - - name: T-23·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - search_name: T-23 - ship_type: [dd] - min_level: 100 - - name: 吕贝克·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - search_name: 吕贝克 - ship_type: [dd] - min_level: 100 - - name: Z46·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - search_name: Z46 - ship_type: [dd] - min_level: 100 diff --git "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-10-1.yaml" "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-10-1.yaml" index ce85e44..23c5a0c 100644 --- "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-10-1.yaml" +++ "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-10-1.yaml" @@ -1,5 +1,3 @@ -# 周常第10章 — 地图 10-1 -# 路线: B-D-F-G-H-L-M-O (L 迂回) chapter: 10 map: 1 selected_nodes: ['0', O, A, I, K, N] @@ -8,77 +6,48 @@ fight_condition: 1 repair_mode: 1 node_defaults: {formation: 2, night: false, proceed: true} node_args: - O: {formation: 4, night: true, long_missile_support: false, proceed: true, detour: false} - A: {formation: 2, night: false, long_missile_support: false, proceed: true, detour: true} - I: {formation: 2, night: false, long_missile_support: false, proceed: true, detour: false} - K: {formation: 2, night: false, long_missile_support: false, proceed: true, detour: false} - N: {formation: 2, night: false, long_missile_support: false, proceed: true, detour: false} + O: + formation: 4 + night: true + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] + A: + formation: 2 + night: false + long_missile_support: false + proceed: true + detour: true + enemy_rules: + - [SS >= 1, 5] + I: + formation: 2 + night: false + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] + K: + formation: 2 + night: false + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] + N: + formation: 2 + night: false + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] times: 3 fleet_id: 3 result: S fleet_presets: - name: 重巡周常队 - ships: - - name: 巴尔的摩·改 - candidates: - - {name: 旧金山·改, search_name: 旧金山, ship_type: [ca], min_level: 100} - - {name: 彭萨科拉·改, search_name: 彭萨科拉, ship_type: [ca], min_level: 100} - - {name: 昆西·改, search_name: 昆西, ship_type: [ca], min_level: 100} - - {name: 威奇塔·改, search_name: 威奇塔, ship_type: [ca], min_level: 100} - - {name: 萨勒姆, ship_type: [ca], min_level: 100} - - {name: 德梅因, ship_type: [ca], min_level: 100} - - {name: 什罗普郡·改, search_name: 什罗普郡, ship_type: [ca], min_level: 100} - - {name: 埃克赛特·改, search_name: 埃克赛特, ship_type: [ca], min_level: 100} - - {name: 约克·改, search_name: 约克, ship_type: [ca], min_level: 100} - - {name: 欧根亲王·改, search_name: 欧根亲王, ship_type: [ca], min_level: 100} - search_name: 巴尔的摩 - ship_type: [ca] - min_level: 100 - - name: 昆西·改 - candidates: - - {name: 旧金山·改, search_name: 旧金山, ship_type: [ca], min_level: 100} - - {name: 彭萨科拉·改, search_name: 彭萨科拉, ship_type: [ca], min_level: 100} - - {name: 昆西·改, search_name: 昆西, ship_type: [ca], min_level: 100} - - {name: 威奇塔·改, search_name: 威奇塔, ship_type: [ca], min_level: 100} - - {name: 萨勒姆, ship_type: [ca], min_level: 100} - - {name: 德梅因, ship_type: [ca], min_level: 100} - - {name: 什罗普郡·改, search_name: 什罗普郡, ship_type: [ca], min_level: 100} - - {name: 埃克赛特·改, search_name: 埃克赛特, ship_type: [ca], min_level: 100} - - {name: 约克·改, search_name: 约克, ship_type: [ca], min_level: 100} - - {name: 欧根亲王·改, search_name: 欧根亲王, ship_type: [ca], min_level: 100} - search_name: 昆西 - ship_type: [ca] - min_level: 100 - - name: 海伦娜·改 - candidates: - - {name: 圣胡安·改, search_name: 圣胡安, ship_type: [cl], min_level: 100} - - {name: 朱诺(CL-52)·改, search_name: 朱诺, ship_type: [cl], min_level: 100} - - {name: 亚特兰大·改, search_name: 亚特兰大, ship_type: [cl], min_level: 100} - search_name: 海伦娜 - ship_type: [cl] - min_level: 100 - - name: 欧根亲王·改 - candidates: - - {name: 旧金山·改, search_name: 旧金山, ship_type: [ca], min_level: 100} - - {name: 彭萨科拉·改, search_name: 彭萨科拉, ship_type: [ca], min_level: 100} - - {name: 昆西·改, search_name: 昆西, ship_type: [ca], min_level: 100} - - {name: 威奇塔·改, search_name: 威奇塔, ship_type: [ca], min_level: 100} - - {name: 萨勒姆, ship_type: [ca], min_level: 100} - - {name: 德梅因, ship_type: [ca], min_level: 100} - - {name: 什罗普郡·改, search_name: 什罗普郡, ship_type: [ca], min_level: 100} - - {name: 埃克赛特·改, search_name: 埃克赛特, ship_type: [ca], min_level: 100} - - {name: 约克·改, search_name: 约克, ship_type: [ca], min_level: 100} - - {name: 欧根亲王·改, search_name: 欧根亲王, ship_type: [ca], min_level: 100} - search_name: 欧根亲王 - ship_type: [ca] - min_level: 100 - - name: 塔林 - candidates: - - {name: 圣胡安·改, search_name: 圣胡安, ship_type: [cl], min_level: 100} - - {name: 朱诺(CL-52)·改, search_name: 朱诺, ship_type: [cl], min_level: 100} - - {name: 亚特兰大·改, search_name: 亚特兰大, ship_type: [cl], min_level: 100} - ship_type: [cl] - min_level: 100 - - name: 85工程 - ship_type: [cv] - min_level: 100 diff --git "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-2-1.yaml" "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-2-1.yaml" index 335a3a6..45a07b3 100644 --- "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-2-1.yaml" +++ "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-2-1.yaml" @@ -1,5 +1,3 @@ -# 周常第2章 — 地图 2-1 -# 默认复纵阵 chapter: 2 map: 1 selected_nodes: ['0', B, D, F] @@ -8,92 +6,23 @@ fight_condition: 1 repair_mode: 1 node_defaults: {formation: 2, night: false, proceed: true} node_args: - F: {formation: 2, night: true, long_missile_support: false, proceed: true, detour: false} + B: + enemy_rules: + - [SS >= 1, 5] + D: + enemy_rules: + - [SS >= 1, 5] + F: + formation: 2 + night: true + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] times: 3 result: S fleet_id: 3 fleet_presets: - name: 4日驱周常队 - ships: - - name: 岛风(岛风型驱逐舰)·改 - candidates: - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100} - - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - search_name: 岛风 - ship_type: [dd] - min_level: 100 - - name: 初夏·改 - candidates: - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100} - - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - search_name: 初夏 - ship_type: [dd] - min_level: 100 - - name: 高波·改 - candidates: - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100} - - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - search_name: 高波 - ship_type: [dd] - min_level: 100 - - name: 阳炎·改 - candidates: - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100} - - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - search_name: 阳炎 - ship_type: [dd] - min_level: 100 - name: 4德驱周常队 - ships: - - name: Z28·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - search_name: Z28 - ship_type: [dd] - min_level: 100 - - name: T-23·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - search_name: T-23 - ship_type: [dd] - min_level: 100 - - name: 吕贝克·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - search_name: 吕贝克 - ship_type: [dd] - min_level: 100 - - name: Z46·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - search_name: Z46 - ship_type: [dd] - min_level: 100 diff --git "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-3-1.yaml" "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-3-1.yaml" index 9626d2a..6f7660b 100644 --- "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-3-1.yaml" +++ "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-3-1.yaml" @@ -1,5 +1,3 @@ -# 周常第3章 — 地图 3-1 -# 默认复纵阵 chapter: 3 map: 1 selected_nodes: ['0', A, B, C, E, F, H, I] @@ -8,82 +6,34 @@ fight_condition: 1 repair_mode: 1 node_defaults: {formation: 2, night: false, proceed: true} node_args: - I: {formation: 4, night: true, long_missile_support: false, proceed: true, detour: false} + A: + enemy_rules: + - [SS >= 1, 5] + B: + enemy_rules: + - [SS >= 1, 5] + C: + enemy_rules: + - [SS >= 1, 5] + E: + enemy_rules: + - [SS >= 1, 5] + F: + enemy_rules: + - [SS >= 1, 5] + H: + enemy_rules: + - [SS >= 1, 5] + I: + formation: 4 + night: true + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] times: 3 fleet_id: 3 result: S fleet_presets: - name: 6日驱队 - ships: - - name: 岛风(岛风型驱逐舰)·改 - candidates: - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100} - - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100} - - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100} - search_name: 岛风 - ship_type: [dd] - min_level: 100 - - name: 初夏·改 - candidates: - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100} - - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100} - - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100} - search_name: 初夏 - ship_type: [dd] - min_level: 100 - - name: 高波·改 - candidates: - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100} - - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100} - - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100} - search_name: 高波 - ship_type: [dd] - min_level: 100 - - name: 早春·改 - candidates: - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100} - - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100} - - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100} - search_name: 早春 - ship_type: [dd] - min_level: 100 - - name: 吹雪·改 - candidates: - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100} - - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100} - - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100} - search_name: 吹雪 - ship_type: [dd] - min_level: 100 - - name: 阳炎·改 - candidates: - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100} - - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100} - - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100} - search_name: 阳炎 - ship_type: [dd] - min_level: 100 diff --git "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-4-1.yaml" "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-4-1.yaml" index fc5d8fa..4df0ec1 100644 --- "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-4-1.yaml" +++ "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-4-1.yaml" @@ -1,5 +1,3 @@ -# 周常第4章 — 地图 4-1 -# 默认复纵阵 chapter: 4 map: 1 selected_nodes: ['0', A, B, C, D, F, G, I] @@ -8,46 +6,34 @@ fight_condition: 1 repair_mode: 1 node_defaults: {formation: 2, night: false, proceed: true} node_args: - I: {formation: 4, night: true, long_missile_support: false, proceed: true, detour: false} + A: + enemy_rules: + - [SS >= 1, 5] + B: + enemy_rules: + - [SS >= 1, 5] + C: + enemy_rules: + - [SS >= 1, 5] + D: + enemy_rules: + - [SS >= 1, 5] + F: + enemy_rules: + - [SS >= 1, 5] + G: + enemy_rules: + - [SS >= 1, 5] + I: + formation: 4 + night: true + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] times: 3 fleet_id: 3 result: S fleet_presets: - name: 85工程+3德驱队 - ships: - - name: 85工程 - ship_type: [cv] - min_level: 100 - - name: Z28·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} - search_name: Z28 - ship_type: [dd] - min_level: 100 - - name: T-23·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} - search_name: T-23 - ship_type: [dd] - min_level: 100 - - name: 吕贝克·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} - search_name: 吕贝克 - ship_type: [dd] - min_level: 100 diff --git "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-5-5.yaml" "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-5-5.yaml" index dfb48de..c73dc30 100644 --- "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-5-5.yaml" +++ "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-5-5.yaml" @@ -1,5 +1,3 @@ -# 周常第5章 — 地图 5-5 -# 默认复纵阵 chapter: 5 map: 5 selected_nodes: ['0', C, F, H, I] @@ -8,88 +6,25 @@ fight_condition: 1 repair_mode: 1 node_defaults: {formation: 2, night: false, proceed: true} node_args: - I: {formation: 4, night: true, long_missile_support: false, proceed: true, detour: false} + C: + enemy_rules: + - [SS >= 1, 5] + F: + enemy_rules: + - [SS >= 1, 5] + H: + enemy_rules: + - [SS >= 1, 5] + I: + formation: 4 + night: true + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] times: 3 fleet_id: 3 result: S fleet_presets: - name: 6潜艇队 - ships: - - min_level: 100 - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} - - min_level: 100 - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} - - min_level: 100 - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} - - min_level: 100 - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} - - min_level: 100 - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} - - min_level: 100 - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} diff --git "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-6-4.yaml" "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-6-4.yaml" index d281f0d..d7b7f40 100644 --- "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-6-4.yaml" +++ "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-6-4.yaml" @@ -1,5 +1,3 @@ -# 周常第6章 — 地图 6-4 -# 默认复纵阵 chapter: 6 map: 4 selected_nodes: ['0', A, C, F, H, J, K] @@ -8,61 +6,31 @@ fight_condition: 1 repair_mode: 1 node_defaults: {formation: 2, night: false, proceed: true} node_args: - K: {formation: 2, night: true, long_missile_support: false, proceed: true, detour: false} + A: + enemy_rules: + - [SS >= 1, 5] + C: + enemy_rules: + - [SS >= 1, 5] + F: + enemy_rules: + - [SS >= 1, 5] + H: + enemy_rules: + - [SS >= 1, 5] + J: + enemy_rules: + - [SS >= 1, 5] + K: + formation: 2 + night: true + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] times: 3 fleet_id: 3 result: S fleet_presets: - name: 3驱克劳85工程队 - ships: - - name: Z28·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - search_name: Z28 - ship_type: [dd] - min_level: 100 - - name: T-23·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - search_name: T-23 - ship_type: [dd] - min_level: 100 - - name: 吕贝克·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - search_name: 吕贝克 - ship_type: [dd] - min_level: 100 - - name: 克劳塞维茨·改 - search_name: 克劳塞维茨 - ship_type: [bc] - min_level: 100 - - name: 赛尔弗里吉·改 - candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - search_name: 赛尔弗里吉 - ship_type: [dd] - min_level: 100 - - name: 85工程 - ship_type: [cv] - min_level: 100 diff --git "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-7-4.yaml" "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-7-4.yaml" index 4bbe5d9..2c2cbf4 100644 --- "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-7-4.yaml" +++ "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-7-4.yaml" @@ -1,5 +1,3 @@ -# 周常第7章 — 地图 7-4 -# 默认复纵阵,Boss(M点)梯形阵+夜战 chapter: 7 map: 4 selected_nodes: ['0', B, D, E, G, H, L, K, M] @@ -8,88 +6,37 @@ fight_condition: 1 repair_mode: 1 node_defaults: {formation: 2, night: false, proceed: true} node_args: - M: {formation: 4, night: true, long_missile_support: false, proceed: true, detour: false} + B: + enemy_rules: + - [SS >= 1, 5] + D: + enemy_rules: + - [SS >= 1, 5] + E: + enemy_rules: + - [SS >= 1, 5] + G: + enemy_rules: + - [SS >= 1, 5] + H: + enemy_rules: + - [SS >= 1, 5] + L: + enemy_rules: + - [SS >= 1, 5] + K: + enemy_rules: + - [SS >= 1, 5] + M: + formation: 4 + night: true + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] times: 3 fleet_id: 3 result: S fleet_presets: - name: 6潜艇队 - ships: - - min_level: 100 - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} - - min_level: 100 - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} - - min_level: 100 - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} - - min_level: 100 - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} - - min_level: 100 - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} - - min_level: 100 - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} diff --git "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-8-2.yaml" "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-8-2.yaml" index ebc3394..127bdf1 100644 --- "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-8-2.yaml" +++ "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-8-2.yaml" @@ -1,5 +1,3 @@ -# 周常第8章 — 地图 8-2 -# 默认复纵阵 chapter: 8 map: 2 selected_nodes: ['0', B, J, M] @@ -8,38 +6,22 @@ fight_condition: 1 repair_mode: 1 node_defaults: {formation: 2, night: false, proceed: true} node_args: - M: {formation: 4, night: true, long_missile_support: false, proceed: true, detour: false} + B: + enemy_rules: + - [SS >= 1, 5] + J: + enemy_rules: + - [SS >= 1, 5] + M: + formation: 4 + night: true + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] times: 3 fleet_id: 3 result: S fleet_presets: - name: 大淀85AIII队 - ships: - - name: 大淀(苍青幻影) - candidates: - - {name: 海伦娜·改, search_name: 海伦娜, ship_type: [cl], min_level: 100} - - {name: 圣胡安·改, search_name: 圣胡安, ship_type: [cl], min_level: 100} - - {name: 朱诺(CL-52)·改, search_name: 朱诺, ship_type: [cl], min_level: 100} - - {name: 亚特兰大·改, search_name: 亚特兰大, ship_type: [cl], min_level: 100} - search_name: 大淀 - ship_type: [cl] - min_level: 100 - - name: 85工程 - ship_type: [cv] - min_level: 100 - - name: AIII - ship_type: [bbv] - min_level: 100 - - min_level: 100 - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} diff --git "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-9-2.yaml" "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-9-2.yaml" index ee637da..cbf1e6f 100644 --- "a/resource/system_battle_plans/bettle-\345\221\250\345\270\270-9-2.yaml" +++ "b/resource/system_battle_plans/bettle-\345\221\250\345\270\270-9-2.yaml" @@ -1,5 +1,3 @@ -# 周常第9章 — 地图 9-2 -# 默认复纵阵 chapter: 9 map: 2 selected_nodes: ['0', A, D, G, H, M, O] @@ -8,71 +6,41 @@ fight_condition: 1 repair_mode: 1 node_defaults: {formation: 2, night: false, proceed: true} node_args: - A: {SL_when_detour_fails: true, detour: true} - D: {SL_when_detour_fails: true, detour: true} - G: {SL_when_detour_fails: true, detour: true} - H: {SL_when_detour_fails: true, detour: true} - M: {SL_when_detour_fails: true, detour: true} - O: {formation: 4, night: true, long_missile_support: false, proceed: true, detour: false} + A: + SL_when_detour_fails: true + detour: true + enemy_rules: + - [SS >= 1, 5] + D: + SL_when_detour_fails: true + detour: true + enemy_rules: + - [SS >= 1, 5] + G: + SL_when_detour_fails: true + detour: true + enemy_rules: + - [SS >= 1, 5] + H: + SL_when_detour_fails: true + detour: true + enemy_rules: + - [SS >= 1, 5] + M: + SL_when_detour_fails: true + detour: true + enemy_rules: + - [SS >= 1, 5] + O: + formation: 4 + night: true + long_missile_support: false + proceed: true + detour: false + enemy_rules: + - [SS >= 1, 5] times: 3 fleet_id: 3 result: S fleet_presets: - name: 85AIII4防驱队 - ships: - - name: 85工程 - ship_type: [cv] - min_level: 100 - - name: AIII - ship_type: [bbv] - min_level: 100 - - name: 炽热·改 - candidates: - - {name: 米切尔·改, search_name: 米切尔, ship_type: [aadg], min_level: 100} - - {name: 威武·改, search_name: 威武, ship_type: [aadg], min_level: 100} - - {name: 谦逊·改, search_name: 谦逊, ship_type: [aadg], min_level: 100} - - {name: 炽热·改, search_name: 炽热, ship_type: [aadg], min_level: 100} - - {name: 基阿特·改, search_name: 基阿特, ship_type: [aadg], min_level: 100} - - {name: 考文垂, ship_type: [aadg], min_level: 100} - - {name: 不惧(防空导弹驱逐舰), search_name: 不惧, ship_type: [aadg], min_level: 100} - - {name: 絮弗伦, ship_type: [aadg], min_level: 100} - search_name: 炽热 - ship_type: [aadg] - min_level: 100 - - name: 考文垂 - candidates: - - {name: 米切尔·改, search_name: 米切尔, ship_type: [aadg], min_level: 100} - - {name: 威武·改, search_name: 威武, ship_type: [aadg], min_level: 100} - - {name: 谦逊·改, search_name: 谦逊, ship_type: [aadg], min_level: 100} - - {name: 炽热·改, search_name: 炽热, ship_type: [aadg], min_level: 100} - - {name: 基阿特·改, search_name: 基阿特, ship_type: [aadg], min_level: 100} - - {name: 考文垂, ship_type: [aadg], min_level: 100} - - {name: 不惧(防空导弹驱逐舰), search_name: 不惧, ship_type: [aadg], min_level: 100} - - {name: 絮弗伦, ship_type: [aadg], min_level: 100} - ship_type: [aadg] - min_level: 100 - - name: 絮弗伦 - candidates: - - {name: 米切尔·改, search_name: 米切尔, ship_type: [aadg], min_level: 100} - - {name: 威武·改, search_name: 威武, ship_type: [aadg], min_level: 100} - - {name: 谦逊·改, search_name: 谦逊, ship_type: [aadg], min_level: 100} - - {name: 炽热·改, search_name: 炽热, ship_type: [aadg], min_level: 100} - - {name: 基阿特·改, search_name: 基阿特, ship_type: [aadg], min_level: 100} - - {name: 考文垂, ship_type: [aadg], min_level: 100} - - {name: 不惧(防空导弹驱逐舰), search_name: 不惧, ship_type: [aadg], min_level: 100} - - {name: 絮弗伦, ship_type: [aadg], min_level: 100} - ship_type: [aadg] - min_level: 100 - - name: 基阿特·改 - candidates: - - {name: 米切尔·改, search_name: 米切尔, ship_type: [aadg], min_level: 100} - - {name: 威武·改, search_name: 威武, ship_type: [aadg], min_level: 100} - - {name: 谦逊·改, search_name: 谦逊, ship_type: [aadg], min_level: 100} - - {name: 炽热·改, search_name: 炽热, ship_type: [aadg], min_level: 100} - - {name: 基阿特·改, search_name: 基阿特, ship_type: [aadg], min_level: 100} - - {name: 考文垂, ship_type: [aadg], min_level: 100} - - {name: 不惧(防空导弹驱逐舰), search_name: 不惧, ship_type: [aadg], min_level: 100} - - {name: 絮弗伦, ship_type: [aadg], min_level: 100} - search_name: 基阿特 - ship_type: [aadg] - min_level: 100 diff --git "a/resource/system_team_plans/team-3\351\251\261\345\205\213\345\212\26385\345\267\245\347\250\213\351\230\237.yaml" "b/resource/system_team_plans/team-3\351\251\261\345\205\213\345\212\26385\345\267\245\347\250\213\351\230\237.yaml" index 742287d..da68abb 100644 --- "a/resource/system_team_plans/team-3\351\251\261\345\205\213\345\212\26385\345\267\245\347\250\213\351\230\237.yaml" +++ "b/resource/system_team_plans/team-3\351\251\261\345\205\213\345\212\26385\345\267\245\347\250\213\351\230\237.yaml" @@ -4,50 +4,56 @@ ships: search_name: Z28 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} + - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100, relaxed: false} - name: T-23·改 search_name: T-23 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} + - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100, relaxed: false} - name: 吕贝克·改 search_name: 吕贝克 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} + - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100, relaxed: false} - name: 克劳塞维茨·改 search_name: 克劳塞维茨 ship_type: [bc] min_level: 100 + relaxed: false - name: 赛尔弗里吉·改 search_name: 赛尔弗里吉 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} + - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100, relaxed: false} - name: 85工程 ship_type: [cv] min_level: 100 + relaxed: false diff --git "a/resource/system_team_plans/team-4\345\276\267\351\251\261\345\221\250\345\270\270\351\230\237.yaml" "b/resource/system_team_plans/team-4\345\276\267\351\251\261\345\221\250\345\270\270\351\230\237.yaml" index fb3c463..3748371 100644 --- "a/resource/system_team_plans/team-4\345\276\267\351\251\261\345\221\250\345\270\270\351\230\237.yaml" +++ "b/resource/system_team_plans/team-4\345\276\267\351\251\261\345\221\250\345\270\270\351\230\237.yaml" @@ -4,39 +4,43 @@ ships: search_name: Z28 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} + - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100, relaxed: false} - name: T-23·改 search_name: T-23 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} + - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100, relaxed: false} - name: 吕贝克·改 search_name: 吕贝克 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} + - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100, relaxed: false} - name: Z46·改 search_name: Z46 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} + - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100, relaxed: false} diff --git "a/resource/system_team_plans/team-4\346\227\245\351\251\261\345\221\250\345\270\270\351\230\237.yaml" "b/resource/system_team_plans/team-4\346\227\245\351\251\261\345\221\250\345\270\270\351\230\237.yaml" index 965f719..0f41f83 100644 --- "a/resource/system_team_plans/team-4\346\227\245\351\251\261\345\221\250\345\270\270\351\230\237.yaml" +++ "b/resource/system_team_plans/team-4\346\227\245\351\251\261\345\221\250\345\270\270\351\230\237.yaml" @@ -4,39 +4,43 @@ ships: search_name: 岛风 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100} - - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} + - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100, relaxed: false} - name: 初夏·改 search_name: 初夏 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100} - - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} + - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100, relaxed: false} - name: 高波·改 search_name: 高波 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100} - - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} + - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100, relaxed: false} - name: 阳炎·改 search_name: 阳炎 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100} - - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} + - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 吹雪·改, search_name: 吹雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 早春·改, search_name: 早春, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100, relaxed: false} diff --git "a/resource/system_team_plans/team-6\346\227\245\351\251\261\351\230\237.yaml" "b/resource/system_team_plans/team-6\346\227\245\351\251\261\351\230\237.yaml" index 0d33d51..d9629cc 100644 --- "a/resource/system_team_plans/team-6\346\227\245\351\251\261\351\230\237.yaml" +++ "b/resource/system_team_plans/team-6\346\227\245\351\251\261\351\230\237.yaml" @@ -4,71 +4,77 @@ ships: search_name: 岛风 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100} - - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100} - - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100} + - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100, relaxed: false} - name: 初夏·改 search_name: 初夏 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100} - - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100} - - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100} + - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100, relaxed: false} - name: 高波·改 search_name: 高波 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100} - - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100} - - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100} + - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100, relaxed: false} - name: 早春·改 search_name: 早春 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100} - - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100} - - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100} + - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100, relaxed: false} - name: 吹雪·改 search_name: 吹雪 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100} - - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100} - - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100} + - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100, relaxed: false} - name: 阳炎·改 search_name: 阳炎 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100} - - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100} - - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100} - - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100} - - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100} - - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100} - - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100} + - {name: 黑潮·改, search_name: 黑潮, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 不知火·改, search_name: 不知火, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 敷波·改, search_name: 敷波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 绫波·改, search_name: 绫波, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 初雪·改, search_name: 初雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 深雪·改, search_name: 深雪, ship_type: [dd], min_level: 100, relaxed: false} + - {name: 白雪·改, search_name: 白雪, ship_type: [dd], min_level: 100, relaxed: false} diff --git "a/resource/system_team_plans/team-6\346\275\234\350\211\207\351\230\237.yaml" "b/resource/system_team_plans/team-6\346\275\234\350\211\207\351\230\237.yaml" index e9b0957..e4e420d 100644 --- "a/resource/system_team_plans/team-6\346\275\234\350\211\207\351\230\237.yaml" +++ "b/resource/system_team_plans/team-6\346\275\234\350\211\207\351\230\237.yaml" @@ -1,74 +1,74 @@ name: 6潜艇队 ships: - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} + - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100, relaxed: false} - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} + - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100, relaxed: false} - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} + - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100, relaxed: false} - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} + - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100, relaxed: false} - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} + - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100, relaxed: false} - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} + - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100, relaxed: false} diff --git "a/resource/system_team_plans/team-85AIII4\351\230\262\351\251\261\351\230\237.yaml" "b/resource/system_team_plans/team-85AIII4\351\230\262\351\251\261\351\230\237.yaml" index d0fe89c..0de6c80 100644 --- "a/resource/system_team_plans/team-85AIII4\351\230\262\351\251\261\351\230\237.yaml" +++ "b/resource/system_team_plans/team-85AIII4\351\230\262\351\251\261\351\230\237.yaml" @@ -3,56 +3,62 @@ ships: - name: 85工程 ship_type: [cv] min_level: 100 + relaxed: false - name: AIII ship_type: [bbv] min_level: 100 + relaxed: false - name: 炽热·改 search_name: 炽热 ship_type: [aadg] min_level: 100 + relaxed: false candidates: - - {name: 米切尔·改, search_name: 米切尔, ship_type: [aadg], min_level: 100} - - {name: 威武·改, search_name: 威武, ship_type: [aadg], min_level: 100} - - {name: 谦逊·改, search_name: 谦逊, ship_type: [aadg], min_level: 100} - - {name: 炽热·改, search_name: 炽热, ship_type: [aadg], min_level: 100} - - {name: 基阿特·改, search_name: 基阿特, ship_type: [aadg], min_level: 100} - - {name: 考文垂, ship_type: [aadg], min_level: 100} - - {name: 不惧(防空导弹驱逐舰), search_name: 不惧, ship_type: [aadg], min_level: 100} - - {name: 絮弗伦, ship_type: [aadg], min_level: 100} + - {name: 米切尔·改, search_name: 米切尔, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 威武·改, search_name: 威武, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 谦逊·改, search_name: 谦逊, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 炽热·改, search_name: 炽热, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 基阿特·改, search_name: 基阿特, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 考文垂, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 不惧(防空导弹驱逐舰), search_name: 不惧, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 絮弗伦, ship_type: [aadg], min_level: 100, relaxed: false} - name: 考文垂 ship_type: [aadg] min_level: 100 + relaxed: false candidates: - - {name: 米切尔·改, search_name: 米切尔, ship_type: [aadg], min_level: 100} - - {name: 威武·改, search_name: 威武, ship_type: [aadg], min_level: 100} - - {name: 谦逊·改, search_name: 谦逊, ship_type: [aadg], min_level: 100} - - {name: 炽热·改, search_name: 炽热, ship_type: [aadg], min_level: 100} - - {name: 基阿特·改, search_name: 基阿特, ship_type: [aadg], min_level: 100} - - {name: 考文垂, ship_type: [aadg], min_level: 100} - - {name: 不惧(防空导弹驱逐舰), search_name: 不惧, ship_type: [aadg], min_level: 100} - - {name: 絮弗伦, ship_type: [aadg], min_level: 100} + - {name: 米切尔·改, search_name: 米切尔, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 威武·改, search_name: 威武, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 谦逊·改, search_name: 谦逊, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 炽热·改, search_name: 炽热, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 基阿特·改, search_name: 基阿特, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 考文垂, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 不惧(防空导弹驱逐舰), search_name: 不惧, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 絮弗伦, ship_type: [aadg], min_level: 100, relaxed: false} - name: 絮弗伦 ship_type: [aadg] min_level: 100 + relaxed: false candidates: - - {name: 米切尔·改, search_name: 米切尔, ship_type: [aadg], min_level: 100} - - {name: 威武·改, search_name: 威武, ship_type: [aadg], min_level: 100} - - {name: 谦逊·改, search_name: 谦逊, ship_type: [aadg], min_level: 100} - - {name: 炽热·改, search_name: 炽热, ship_type: [aadg], min_level: 100} - - {name: 基阿特·改, search_name: 基阿特, ship_type: [aadg], min_level: 100} - - {name: 考文垂, ship_type: [aadg], min_level: 100} - - {name: 不惧(防空导弹驱逐舰), search_name: 不惧, ship_type: [aadg], min_level: 100} - - {name: 絮弗伦, ship_type: [aadg], min_level: 100} + - {name: 米切尔·改, search_name: 米切尔, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 威武·改, search_name: 威武, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 谦逊·改, search_name: 谦逊, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 炽热·改, search_name: 炽热, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 基阿特·改, search_name: 基阿特, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 考文垂, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 不惧(防空导弹驱逐舰), search_name: 不惧, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 絮弗伦, ship_type: [aadg], min_level: 100, relaxed: false} - name: 基阿特·改 search_name: 基阿特 ship_type: [aadg] min_level: 100 + relaxed: false candidates: - - {name: 米切尔·改, search_name: 米切尔, ship_type: [aadg], min_level: 100} - - {name: 威武·改, search_name: 威武, ship_type: [aadg], min_level: 100} - - {name: 谦逊·改, search_name: 谦逊, ship_type: [aadg], min_level: 100} - - {name: 炽热·改, search_name: 炽热, ship_type: [aadg], min_level: 100} - - {name: 基阿特·改, search_name: 基阿特, ship_type: [aadg], min_level: 100} - - {name: 考文垂, ship_type: [aadg], min_level: 100} - - {name: 不惧(防空导弹驱逐舰), search_name: 不惧, ship_type: [aadg], min_level: 100} - - {name: 絮弗伦, ship_type: [aadg], min_level: 100} + - {name: 米切尔·改, search_name: 米切尔, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 威武·改, search_name: 威武, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 谦逊·改, search_name: 谦逊, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 炽热·改, search_name: 炽热, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 基阿特·改, search_name: 基阿特, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 考文垂, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 不惧(防空导弹驱逐舰), search_name: 不惧, ship_type: [aadg], min_level: 100, relaxed: false} + - {name: 絮弗伦, ship_type: [aadg], min_level: 100, relaxed: false} diff --git "a/resource/system_team_plans/team-85\345\267\245\347\250\213+3\345\276\267\351\251\261\351\230\237.yaml" "b/resource/system_team_plans/team-85\345\267\245\347\250\213+3\345\276\267\351\251\261\351\230\237.yaml" index ca8f6a1..7dc7fa4 100644 --- "a/resource/system_team_plans/team-85\345\267\245\347\250\213+3\345\276\267\351\251\261\351\230\237.yaml" +++ "b/resource/system_team_plans/team-85\345\267\245\347\250\213+3\345\276\267\351\251\261\351\230\237.yaml" @@ -3,36 +3,40 @@ ships: - name: 85工程 ship_type: [cv] min_level: 100 + relaxed: false - name: Z28·改 search_name: Z28 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} + - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100, relaxed: false} - name: T-23·改 search_name: T-23 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} + - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100, relaxed: false} - name: 吕贝克·改 search_name: 吕贝克 ship_type: [dd] min_level: 100 + relaxed: false candidates: - - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100} - - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100} - - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100} - - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100} - - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100} - - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100} + - {name: Z1·改, search_name: Z1, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z16·改, search_name: Z16, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z21·改, search_name: Z21, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z22·改, search_name: Z22, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z31·改, search_name: Z31, ship_type: [dd], min_level: 100, relaxed: false} + - {name: Z46·改, search_name: Z46, ship_type: [dd], min_level: 100, relaxed: false} diff --git "a/resource/system_team_plans/team-\345\244\247\346\267\20085AIII\351\230\237.yaml" "b/resource/system_team_plans/team-\345\244\247\346\267\20085AIII\351\230\237.yaml" index ad90d43..7ce85fd 100644 --- "a/resource/system_team_plans/team-\345\244\247\346\267\20085AIII\351\230\237.yaml" +++ "b/resource/system_team_plans/team-\345\244\247\346\267\20085AIII\351\230\237.yaml" @@ -4,26 +4,29 @@ ships: search_name: 大淀 ship_type: [cl] min_level: 100 + relaxed: false candidates: - - {name: 海伦娜·改, search_name: 海伦娜, ship_type: [cl], min_level: 100} - - {name: 圣胡安·改, search_name: 圣胡安, ship_type: [cl], min_level: 100} - - {name: 朱诺(CL-52)·改, search_name: 朱诺, ship_type: [cl], min_level: 100} - - {name: 亚特兰大·改, search_name: 亚特兰大, ship_type: [cl], min_level: 100} + - {name: 海伦娜·改, search_name: 海伦娜, ship_type: [cl], min_level: 100, relaxed: false} + - {name: 圣胡安·改, search_name: 圣胡安, ship_type: [cl], min_level: 100, relaxed: false} + - {name: 朱诺(CL-52)·改, search_name: 朱诺, ship_type: [cl], min_level: 100, relaxed: false} + - {name: 亚特兰大·改, search_name: 亚特兰大, ship_type: [cl], min_level: 100, relaxed: false} - name: 85工程 ship_type: [cv] min_level: 100 + relaxed: false - name: AIII ship_type: [bbv] min_level: 100 + relaxed: false - candidates: - - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100} - - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100} - - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100} - - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100} - - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100} - - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100} - - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100} - - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100} - - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100} - - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100} - - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100} + - {name: U-47·改, search_name: U-47, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-81·改, search_name: U-81, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-96·改, search_name: U-96, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-2540·改, search_name: U-2540, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1206·改, search_name: U-1206, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-1405·改, search_name: U-1405, ship_type: [ss], min_level: 100, relaxed: false} + - {name: U-505·改, search_name: U-505, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鹦鹉螺·改, search_name: 鹦鹉螺, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 射水鱼·改, search_name: 射水鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 大青花鱼·改, search_name: 大青花鱼, ship_type: [ss], min_level: 100, relaxed: false} + - {name: 鲃鱼·改, search_name: 鲃鱼, ship_type: [ss], min_level: 100, relaxed: false} diff --git "a/resource/system_team_plans/team-\351\207\215\345\267\241\345\221\250\345\270\270\351\230\237.yaml" "b/resource/system_team_plans/team-\351\207\215\345\267\241\345\221\250\345\270\270\351\230\237.yaml" index 1f7813b..be25e48 100644 --- "a/resource/system_team_plans/team-\351\207\215\345\267\241\345\221\250\345\270\270\351\230\237.yaml" +++ "b/resource/system_team_plans/team-\351\207\215\345\267\241\345\221\250\345\270\270\351\230\237.yaml" @@ -4,62 +4,69 @@ ships: search_name: 巴尔的摩 ship_type: [ca] min_level: 100 + relaxed: false candidates: - - {name: 旧金山·改, search_name: 旧金山, ship_type: [ca], min_level: 100} - - {name: 彭萨科拉·改, search_name: 彭萨科拉, ship_type: [ca], min_level: 100} - - {name: 昆西·改, search_name: 昆西, ship_type: [ca], min_level: 100} - - {name: 威奇塔·改, search_name: 威奇塔, ship_type: [ca], min_level: 100} - - {name: 萨勒姆, ship_type: [ca], min_level: 100} - - {name: 德梅因, ship_type: [ca], min_level: 100} - - {name: 什罗普郡·改, search_name: 什罗普郡, ship_type: [ca], min_level: 100} - - {name: 埃克赛特·改, search_name: 埃克赛特, ship_type: [ca], min_level: 100} - - {name: 约克·改, search_name: 约克, ship_type: [ca], min_level: 100} - - {name: 欧根亲王·改, search_name: 欧根亲王, ship_type: [ca], min_level: 100} + - {name: 旧金山·改, search_name: 旧金山, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 彭萨科拉·改, search_name: 彭萨科拉, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 昆西·改, search_name: 昆西, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 威奇塔·改, search_name: 威奇塔, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 萨勒姆, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 德梅因, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 什罗普郡·改, search_name: 什罗普郡, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 埃克赛特·改, search_name: 埃克赛特, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 约克·改, search_name: 约克, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 欧根亲王·改, search_name: 欧根亲王, ship_type: [ca], min_level: 100, relaxed: false} + - name: 欧根亲王·改 + search_name: 欧根亲王 + ship_type: [ca] + min_level: 100 + relaxed: false + candidates: + - {name: 旧金山·改, search_name: 旧金山, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 彭萨科拉·改, search_name: 彭萨科拉, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 昆西·改, search_name: 昆西, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 威奇塔·改, search_name: 威奇塔, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 萨勒姆, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 德梅因, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 什罗普郡·改, search_name: 什罗普郡, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 埃克赛特·改, search_name: 埃克赛特, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 约克·改, search_name: 约克, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 欧根亲王·改, search_name: 欧根亲王, ship_type: [ca], min_level: 100, relaxed: false} - name: 昆西·改 search_name: 昆西 ship_type: [ca] min_level: 100 + relaxed: false candidates: - - {name: 旧金山·改, search_name: 旧金山, ship_type: [ca], min_level: 100} - - {name: 彭萨科拉·改, search_name: 彭萨科拉, ship_type: [ca], min_level: 100} - - {name: 昆西·改, search_name: 昆西, ship_type: [ca], min_level: 100} - - {name: 威奇塔·改, search_name: 威奇塔, ship_type: [ca], min_level: 100} - - {name: 萨勒姆, ship_type: [ca], min_level: 100} - - {name: 德梅因, ship_type: [ca], min_level: 100} - - {name: 什罗普郡·改, search_name: 什罗普郡, ship_type: [ca], min_level: 100} - - {name: 埃克赛特·改, search_name: 埃克赛特, ship_type: [ca], min_level: 100} - - {name: 约克·改, search_name: 约克, ship_type: [ca], min_level: 100} - - {name: 欧根亲王·改, search_name: 欧根亲王, ship_type: [ca], min_level: 100} - - name: 海伦娜·改 - search_name: 海伦娜 + - {name: 旧金山·改, search_name: 旧金山, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 彭萨科拉·改, search_name: 彭萨科拉, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 昆西·改, search_name: 昆西, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 威奇塔·改, search_name: 威奇塔, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 萨勒姆, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 德梅因, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 什罗普郡·改, search_name: 什罗普郡, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 埃克赛特·改, search_name: 埃克赛特, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 约克·改, search_name: 约克, ship_type: [ca], min_level: 100, relaxed: false} + - {name: 欧根亲王·改, search_name: 欧根亲王, ship_type: [ca], min_level: 100, relaxed: false} + - name: 亚特兰大·改 + search_name: 亚特兰大 ship_type: [cl] min_level: 100 + relaxed: false candidates: - - {name: 圣胡安·改, search_name: 圣胡安, ship_type: [cl], min_level: 100} - - {name: 朱诺(CL-52)·改, search_name: 朱诺, ship_type: [cl], min_level: 100} - - {name: 亚特兰大·改, search_name: 亚特兰大, ship_type: [cl], min_level: 100} - - name: 欧根亲王·改 - search_name: 欧根亲王 - ship_type: [ca] - min_level: 100 - candidates: - - {name: 旧金山·改, search_name: 旧金山, ship_type: [ca], min_level: 100} - - {name: 彭萨科拉·改, search_name: 彭萨科拉, ship_type: [ca], min_level: 100} - - {name: 昆西·改, search_name: 昆西, ship_type: [ca], min_level: 100} - - {name: 威奇塔·改, search_name: 威奇塔, ship_type: [ca], min_level: 100} - - {name: 萨勒姆, ship_type: [ca], min_level: 100} - - {name: 德梅因, ship_type: [ca], min_level: 100} - - {name: 什罗普郡·改, search_name: 什罗普郡, ship_type: [ca], min_level: 100} - - {name: 埃克赛特·改, search_name: 埃克赛特, ship_type: [ca], min_level: 100} - - {name: 约克·改, search_name: 约克, ship_type: [ca], min_level: 100} - - {name: 欧根亲王·改, search_name: 欧根亲王, ship_type: [ca], min_level: 100} - - name: 塔林 + - {name: 海伦娜·改, search_name: 海伦娜, ship_type: [cl], min_level: 100, relaxed: false} + - {name: 圣胡安·改, search_name: 圣胡安, ship_type: [cl], min_level: 100, relaxed: false} + - {name: 朱诺(CL-52)·改, search_name: 朱诺, ship_type: [cl], min_level: 100, relaxed: false} + - name: 圣胡安·改 + search_name: 圣胡安 ship_type: [cl] min_level: 100 + relaxed: false candidates: - - {name: 圣胡安·改, search_name: 圣胡安, ship_type: [cl], min_level: 100} - - {name: 朱诺(CL-52)·改, search_name: 朱诺, ship_type: [cl], min_level: 100} - - {name: 亚特兰大·改, search_name: 亚特兰大, ship_type: [cl], min_level: 100} + - {name: 塔林, ship_type: [cl], min_level: 100, relaxed: false} + - {name: 亚特兰大·改, search_name: 亚特兰大, ship_type: [cl], min_level: 100, relaxed: false} + - {name: 朱诺(CL-52)·改, search_name: 朱诺, ship_type: [cl], min_level: 100, relaxed: false} - name: 85工程 ship_type: [cv] min_level: 100 + relaxed: false diff --git a/scripts/tests/main-services/test-combat-plan.js b/scripts/tests/main-services/test-combat-plan.js index 1885145..f0a9b41 100644 --- a/scripts/tests/main-services/test-combat-plan.js +++ b/scripts/tests/main-services/test-combat-plan.js @@ -7,6 +7,10 @@ const context = require('./test-context'); const { createDirectories, } = require('../test-support/directories'); +const { + serializePlanYaml, + serializeTeamYaml, +} = require('../../../dist/src/shared/yamlSerializer.js'); const { assert, EventEmitter, @@ -40,8 +44,134 @@ const { temporaryDirectory, } = context; +/** 生产代码只能通过共享模块编解码 YAML,避免各功能重复维护实现。 */ +function testYamlSerializerOwnership() { + const projectRoot = path.resolve(__dirname, '../../..'); + const serializerPath = path.join( + projectRoot, + 'src/shared/yamlSerializer.ts', + ); + const pending = [ + path.join(projectRoot, 'src'), + path.join(projectRoot, 'electron'), + ]; + + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) { + pending.push(entryPath); + continue; + } + if (!entry.name.endsWith('.ts') || entryPath === serializerPath) { + continue; + } + const content = fs.readFileSync(entryPath, 'utf8'); + assert.doesNotMatch( + content, + /\byaml\.dump\s*\(/, + `${path.relative(projectRoot, entryPath)} 应使用共享 YAML 生成器`, + ); + assert.doesNotMatch( + content, + /\byaml\.load\s*\(/, + `${path.relative(projectRoot, entryPath)} 应使用共享 YAML 解析器`, + ); + assert.equal( + content.includes('shared/planYaml'), + false, + `${path.relative(projectRoot, entryPath)} 不应引用旧计划生成器`, + ); + } + } +} + +/** 系统作战预设使用统一格式,并为每个启用作战节点设置反潜阵型规则。 */ +function testSystemBattlePlanResources() { + const teamsDirectory = path.resolve( + __dirname, + '../../../resource/system_team_plans', + ); + const teamNames = new Set(); + fs.readdirSync(teamsDirectory) + .filter(file => file.endsWith('.yaml')) + .forEach((file) => { + const content = fs.readFileSync(path.join(teamsDirectory, file), 'utf8'); + const team = yaml.load(content); + teamNames.add(team.name); + assert.equal( + content, + serializeTeamYaml(team), + `${file} 应使用统一的 YAML 格式`, + ); + team.ships.forEach((slot, slotIndex) => { + if (slot.name !== undefined) { + assert.equal( + slot.relaxed, + false, + `${file} 的位置 ${slotIndex + 1} 主选应默认强校验`, + ); + } + (slot.candidates ?? []).forEach((candidate, candidateIndex) => { + assert.equal( + candidate.relaxed, + false, + `${file} 的位置 ${slotIndex + 1} 备选 ` + + `${candidateIndex + 1} 应默认强校验`, + ); + }); + }); + }); + + const plansDirectory = path.resolve( + __dirname, + '../../../resource/system_battle_plans', + ); + const files = fs.readdirSync(plansDirectory) + .filter(file => file.endsWith('.yaml')); + assert.ok(files.length > 0); + + files.forEach((file) => { + const content = fs.readFileSync(path.join(plansDirectory, file), 'utf8'); + const plan = yaml.load(content); + const body = content.replace(/^(?:#.*\r?\n)+/, ''); + assert.equal( + body, + serializePlanYaml(plan), + `${file} 应使用统一的 YAML 格式`, + ); + (plan.fleet_presets ?? []).forEach((preset) => { + assert.deepEqual( + Object.keys(preset), + ['name'], + `${file} 的舰队预设应只保存名称引用`, + ); + assert.equal( + teamNames.has(preset.name), + true, + `${file} 引用的系统舰队不存在: ${preset.name}`, + ); + }); + + plan.selected_nodes + .map(String) + .filter(node => node !== '0') + .forEach((node) => { + assert.deepEqual( + plan.node_args?.[node]?.enemy_rules?.[0], + ['SS >= 1', 5], + `${file} 的 ${node} 节点应在敌方有潜艇时使用单横阵`, + ); + }); + assert.equal(plan.node_args?.['0']?.enemy_rules, undefined); + }); +} + /** 验证出征计划格式、运行时展开和管理流程保持既有语义。 */ function testCombatPlanServices() { + testYamlSerializerOwnership(); + testSystemBattlePlanResources(); const projectRoot = path.join(temporaryDirectory, 'combat-project'); const userData = path.join(temporaryDirectory, 'combat-user-data'); const tempDirectory = path.join(temporaryDirectory, 'combat-temp'); @@ -150,6 +280,63 @@ function testCombatPlanServices() { ); assert.match(serialized, /^# 保留注释\n/); assert.equal(yaml.load(serialized).rootExtension.preserved, true); + const compactPlan = { + chapter: 10, + map: 1, + selected_nodes: ['0', 'O', 'A', 'I', 'K', 'N'], + endpoint_nodes: ['O', 'A'], + node_defaults: { + formation: 2, + night: false, + }, + node_args: { + O: { + formation: 1, + enemy_rules: [ + ['SS >= 1', 5], + ['CV >= 2', 3], + ], + }, + A: { + formation: 2, + proceed: true, + }, + }, + fleet_presets: [{ + name: '测试舰队', + ships: [{ + name: '主选舰', + candidates: [{ + name: '备选舰', + ship_type: ['ca'], + min_level: 10, + }], + }], + }], + }; + const compactContent = combatCodec.serialize(compactPlan); + assert.match( + compactContent, + /selected_nodes: \['0', O, A, I, K, N\]/, + ); + assert.match(compactContent, /endpoint_nodes: \[O, A\]/); + assert.match( + compactContent, + /enemy_rules:\n - \[SS >= 1, 5\]\n - \[CV >= 2, 3\]/, + ); + assert.match( + compactContent, + /node_defaults: {formation: 2, night: false}/, + ); + assert.match( + compactContent, + / A: {formation: 2, proceed: true}/, + ); + assert.match( + compactContent, + /- {name: 备选舰, ship_type: \[ca\], min_level: 10}/, + ); + assert.deepEqual(yaml.load(compactContent), compactPlan); assert.equal(combatCodec.safeBaseName('bettle-测试?.yaml'), '测试_'); assert.equal( @@ -439,12 +626,12 @@ function testCombatPlanServices() { assert.deepEqual( yaml.load(fs.readFileSync(importedTeamPath, 'utf8')).ships, [ - { name: 'U-47' }, + { name: 'U-47', relaxed: false }, { ship_type: ['ss'], candidates: [ - { name: 'U-96', ship_type: ['ss'] }, - { name: 'U-81', ship_type: ['ss'] }, + { name: 'U-96', ship_type: ['ss'], relaxed: false }, + { name: 'U-81', ship_type: ['ss'], relaxed: false }, ], }, { diff --git a/scripts/tests/main-services/test-migration.js b/scripts/tests/main-services/test-migration.js index affc214..9e22b5d 100644 --- a/scripts/tests/main-services/test-migration.js +++ b/scripts/tests/main-services/test-migration.js @@ -70,7 +70,7 @@ function createLegacyPlanMigration( { yamlFiles: directory => combatRepository.yamlFiles(directory), safePlanBaseName: value => combatCodec.safeBaseName(value), - normalizeUserTeamPlan: raw => teamCodec.normalize(raw), + normalizeUserTeamPlan: raw => teamCodec.normalizeLegacy(raw), teamPlanMatches: (filePath, team) => ( teamRepository.matches(filePath, team) ), @@ -83,7 +83,7 @@ function createLegacyPlanMigration( root, source, requireEmbeddedShips, - ) => combatCodec.normalizeFleetPresets( + ) => combatCodec.normalizeLegacyFleetPresets( root, source, requireEmbeddedShips, @@ -615,7 +615,7 @@ function testUserDataMigration() { { yamlFiles: directory => combatRepository.yamlFiles(directory), safePlanBaseName: value => combatCodec.safeBaseName(value), - normalizeUserTeamPlan: raw => teamCodec.normalize(raw), + normalizeUserTeamPlan: raw => teamCodec.normalizeLegacy(raw), teamPlanMatches: (filePath, team) => ( teamRepository.matches(filePath, team) ), @@ -628,7 +628,7 @@ function testUserDataMigration() { root, source, requireEmbeddedShips, - ) => combatCodec.normalizeFleetPresets( + ) => combatCodec.normalizeLegacyFleetPresets( root, source, requireEmbeddedShips, @@ -729,12 +729,12 @@ function testUserDataMigration() { assert.deepEqual( yaml.load(fs.readFileSync(migratedTeamPath, 'utf8')).ships, [ - { name: 'U-47' }, + { name: 'U-47', relaxed: true }, { ship_type: ['ss'], candidates: [ - { name: 'U-96', ship_type: ['ss'] }, - { name: 'U-81', ship_type: ['ss'] }, + { name: 'U-96', ship_type: ['ss'], relaxed: true }, + { name: 'U-81', ship_type: ['ss'], relaxed: true }, ], }, ], @@ -743,6 +743,12 @@ function testUserDataMigration() { yaml.load(fs.readFileSync(referencedPlanPath, 'utf8')).fleet_presets, [{ name: 'Referenced Team' }], ); + assert.deepEqual( + yaml.load(fs.readFileSync(referencedTeamPath, 'utf8')).ships, + [{ + candidates: [{ name: 'U-505', relaxed: true }], + }], + ); assert.equal( fs.existsSync(path.join(projectRoot, 'plans', 'legacy.yaml')), true, @@ -1603,7 +1609,7 @@ function testLegacyPlanConflictRetry() { { yamlFiles: directory => combatRepository.yamlFiles(directory), safePlanBaseName: value => combatCodec.safeBaseName(value), - normalizeUserTeamPlan: raw => teamCodec.normalize(raw), + normalizeUserTeamPlan: raw => teamCodec.normalizeLegacy(raw), teamPlanMatches: (filePath, team) => ( teamRepository.matches(filePath, team) ), @@ -1616,7 +1622,7 @@ function testLegacyPlanConflictRetry() { planRoot, source, requireEmbeddedShips, - ) => combatCodec.normalizeFleetPresets( + ) => combatCodec.normalizeLegacyFleetPresets( planRoot, source, requireEmbeddedShips, @@ -1839,7 +1845,7 @@ function testInitializedUserDataBlocksLegacyInstall() { { yamlFiles: directory => combatRepository.yamlFiles(directory), safePlanBaseName: value => combatCodec.safeBaseName(value), - normalizeUserTeamPlan: raw => teamCodec.normalize(raw), + normalizeUserTeamPlan: raw => teamCodec.normalizeLegacy(raw), teamPlanMatches: (filePath, team) => ( teamRepository.matches(filePath, team) ), @@ -1852,7 +1858,7 @@ function testInitializedUserDataBlocksLegacyInstall() { planRoot, source, requireEmbeddedShips, - ) => combatCodec.normalizeFleetPresets( + ) => combatCodec.normalizeLegacyFleetPresets( planRoot, source, requireEmbeddedShips, diff --git a/scripts/tests/main-services/test-team-plan.js b/scripts/tests/main-services/test-team-plan.js index 2eef451..213653a 100644 --- a/scripts/tests/main-services/test-team-plan.js +++ b/scripts/tests/main-services/test-team-plan.js @@ -120,10 +120,7 @@ function testTeamPlanServices() { codec.serialize(relaxedRules), ).ships[0]; assert.equal(serializedRelaxedRule.relaxed, true); - assert.equal( - Object.hasOwn(serializedRelaxedRule.candidates[0], 'relaxed'), - false, - ); + assert.equal(serializedRelaxedRule.candidates[0].relaxed, false); assert.equal(serializedRelaxedRule.candidates[1].relaxed, true); const orderedRelaxedContent = codec.serialize(codec.normalize({ name: '宽泛字段顺序', @@ -162,7 +159,45 @@ function testTeamPlanServices() { candidates: [{ name: '昆西', relaxed: false }], }], })); - assert.doesNotMatch(disabledRelaxedContent, /relaxed/); + const disabledRelaxedRule = yaml.load( + disabledRelaxedContent, + ).ships[0]; + assert.equal(disabledRelaxedRule.relaxed, false); + assert.equal(disabledRelaxedRule.candidates[0].relaxed, false); + const migratedLegacyRules = codec.normalizeLegacy({ + name: '兼容升级弱校验', + ships: [ + 'U-47', + { + name: '重庆', + relaxed: false, + candidates: [ + '长春', + { name: '昆西', relaxed: false }, + { name: '亚特兰大', relaxed: true }, + ], + }, + { + candidates: [{ name: 'U-505' }], + }, + ], + }); + assert.equal(migratedLegacyRules.ships[0].relaxed, true); + assert.equal(migratedLegacyRules.ships[1].relaxed, false); + assert.deepEqual( + migratedLegacyRules.ships[1].candidates.map(rule => rule.relaxed), + [true, false, true], + ); + assert.equal(migratedLegacyRules.ships[2].relaxed, undefined); + assert.equal( + migratedLegacyRules.ships[2].candidates[0].relaxed, + true, + ); + const newPlanRule = yaml.load(codec.serialize(codec.normalize({ + name: '新建强校验', + ships: [{ name: 'U-96' }], + }))).ships[0]; + assert.equal(newPlanRule.relaxed, false); const loadedRelaxedPath = path.join( appPaths.userTeamPlansDir(), 'team-加载宽泛校验.yaml', @@ -281,7 +316,10 @@ function testTeamPlanServices() { assert.equal(serializedLegacy.ships[2].search_name, undefined); assert.deepEqual( serializedLegacy.ships[2].candidates, - legacyCandidateOnly.ships[2].candidates, + legacyCandidateOnly.ships[2].candidates.map(rule => ({ + ...rule, + relaxed: false, + })), ); const nativeShipTypes = [ 'aadg', 'ap', 'asdg', 'av', 'bb', 'bbg', 'bbv', 'bc', 'bg', 'bm', @@ -363,7 +401,10 @@ function testTeamPlanServices() { appPaths.userTeamPlansDir(), systemCopyFile, )), - systemCopyPlan, + { + ...systemCopyPlan, + ships: [{ name: '系统原版', relaxed: false }], + }, ); const systemCopy = service.save({ name: '系统另存', diff --git a/scripts/tests/test-api-contract.js b/scripts/tests/test-api-contract.js index 3ed40b0..32ef32c 100644 --- a/scripts/tests/test-api-contract.js +++ b/scripts/tests/test-api-contract.js @@ -19,7 +19,16 @@ const { const { CurrentFleetController, } = require('../../dist/src/controller/app/CurrentFleetController.js'); +const { + buildAutomaticDecisivePlanRequest, +} = require('../../dist/src/controller/app/AutomaticDecisiveTask.js'); +const { + createShipArtwork, +} = require('../../dist/src/view/plan/ShipArtwork.js'); const { ALL_SHIPS } = require('../../dist/src/shared/shipCatalog.js'); +const { + findShipLibraryShip, +} = require('../../dist/src/shared/shipLibrary.js'); const { FLEET_SHIP_TYPE_CODES, NATIVE_FLEET_SHIP_TYPE_CODES, @@ -29,6 +38,131 @@ const { TYPE_LABELS, } = require('../../dist/src/shared/fleetShipTypes.js'); +const shipLibraryFixtures = [ + { + id: 1, + name: 'U-47', + search_name: 'U-47', + variant: 'normal', + rarity: 4, + ship_type: 'SS', + portraitUrl: 'normal.png', + backgroundUrl: 'background.png', + frameUrl: 'frame.png', + typeIconUrl: 'type.png', + }, + { + id: 2, + name: 'U-47·改', + search_name: 'U-47', + variant: 'refit', + rarity: 5, + ship_type: 'SS', + portraitUrl: 'refit.png', + backgroundUrl: 'background.png', + frameUrl: 'frame.png', + typeIconUrl: 'type.png', + }, +]; +assert.equal( + findShipLibraryShip(shipLibraryFixtures, { + name: 'U-47·改', + searchName: 'U-47', + })?.id, + 2, +); +assert.equal( + findShipLibraryShip(shipLibraryFixtures, { + searchName: 'U-47', + })?.id, + 1, +); +const decisivePlan = { + chapter: 6, + useQuickRepair: true, + level1: ['U-47·改', '未收录舰船'], + level2: ['U-47'], +}; +const decisiveRequest = buildAutomaticDecisivePlanRequest( + decisivePlan, + shipLibraryFixtures, +); +assert.deepEqual(decisiveRequest.level1, ['U-47', '未收录舰船']); +assert.deepEqual(decisiveRequest.level2, ['U-47']); +assert.deepEqual(decisivePlan.level1, ['U-47·改', '未收录舰船']); +assert.equal( + findShipLibraryShip(shipLibraryFixtures, { + name: 'U-47·旧称', + allowBaseNameFallback: true, + })?.id, + 1, +); + +function fakeDomElement(tagName) { + return { + tagName, + className: '', + dataset: {}, + children: [], + append(...children) { + this.children.push(...children); + }, + }; +} + +function findElementByClass(element, className) { + if (element.className.split(/\s+/).includes(className)) return element; + for (const child of element.children) { + const match = findElementByClass(child, className); + if (match) return match; + } + return null; +} + +const previousDocument = global.document; +global.document = { + createElement: fakeDomElement, +}; +try { + const compactArtwork = createShipArtwork(shipLibraryFixtures[1], { + shipTypeLabel: '潜艇', + showNumber: false, + showName: false, + }); + assert.equal(compactArtwork.dataset.searchName, 'U-47'); + assert.equal(findElementByClass( + compactArtwork, + 'fleet-ship-number', + ), null); + assert.equal(findElementByClass( + compactArtwork, + 'fleet-ship-name', + ), null); + assert.ok(findElementByClass( + compactArtwork, + 'fleet-ship-type-icon', + )); + + const namedArtwork = createShipArtwork(shipLibraryFixtures[1], { + displayName: 'U-47·改', + nameStyle: 'plain', + }); + assert.ok( + findElementByClass(namedArtwork, 'fleet-ship-name') + .className.split(/\s+/).includes('is-plain'), + ); + assert.equal( + findElementByClass(namedArtwork, 'fleet-ship-name-text').textContent, + 'U-47·改', + ); +} finally { + if (previousDocument === undefined) { + delete global.document; + } else { + global.document = previousDocument; + } +} + function buildFleetContractCase(name, sourceYaml) { const contractPlan = PlanModel.fromYaml(sourceYaml, `${name}.yaml`); const contractRequest = { @@ -140,6 +274,21 @@ assert.deepEqual( pureBackupRuleOnlyPreview.map(ship => ship.name), pureBackupNames, ); +const exactVariantPreview = new CurrentFleetController().resolve({ + type: 'normal_fight', + times: 1, + plan: { + fleet: ['巴尔的摩'], + fleet_rules: [{ + name: '巴尔的摩·改', + search_name: '巴尔的摩', + }], + }, +}); +assert.deepEqual( + exactVariantPreview.map(ship => ship.name), + ['巴尔的摩·改'], +); assert.deepEqual( new CurrentFleetController().resolve({ type: 'normal_fight', @@ -380,37 +529,54 @@ assert.equal( false, ); -let queuedDecisiveTask = null; -const decisivePresetState = { - currentPreset: { - task_type: 'decisive', - }, - currentPresetFilePath: 'decisive-10.yaml', -}; -executePresetFlow( +for (const { source, times, expected } of [ { - collectPresetFormValues: () => ({ - times: 10, - chapter: 6, - level1: [], - level2: [], - flagshipPriority: [], - useQuickRepair: true, - }), - hidePresetDetail: () => {}, + source: 'user', + times: 10, + expected: [['U-47'], ['未收录舰名'], ['U-47']], }, { - scheduler: { - addTask: (...args) => { - queuedDecisiveTask = args; + source: 'system', + times: 1, + expected: [['U-47·改'], ['未收录舰名'], ['U-47·改']], + }, +]) { + let queuedDecisiveTask = null; + executePresetFlow( + { + collectPresetFormValues: () => ({ + times, + chapter: 6, + level1: ['U-47·改'], + level2: ['未收录舰名'], + flagshipPriority: ['U-47·改'], + useQuickRepair: true, + }), + hidePresetDetail: () => {}, + }, + { + scheduler: { + addTask: (...args) => { + queuedDecisiveTask = args; + }, }, + switchPage: () => {}, + renderMain: () => {}, }, - switchPage: () => {}, - renderMain: () => {}, - }, - decisivePresetState, -); -assert.equal(queuedDecisiveTask[4], 10); + { + currentPreset: { task_type: 'decisive' }, + currentPresetFilePath: `decisive-${source}.yaml`, + currentPresetSource: source, + }, + shipLibraryFixtures, + ); + assert.equal(queuedDecisiveTask[4], times); + assert.deepEqual([ + queuedDecisiveTask[2].level1, + queuedDecisiveTask[2].level2, + queuedDecisiveTask[2].flagship_priority, + ], expected); +} const rotatedAliasRequest = { type: 'normal_fight', diff --git a/scripts/tests/test-fleet-domain.mjs b/scripts/tests/test-fleet-domain.mjs index 8ba1ee6..ad595f7 100644 --- a/scripts/tests/test-fleet-domain.mjs +++ b/scripts/tests/test-fleet-domain.mjs @@ -12,7 +12,9 @@ const result = esbuild.buildSync({ "export { PlanFleetPresetController } from './src/controller/plan/PlanFleetPresetController.ts';", "export { PlanManagementController } from './src/controller/plan/PlanManagementController.ts';", "export { buildPlanManagementViewObject } from './src/controller/plan/planManagementViewObjects.ts';", + "export * from './src/controller/plan/galleryStateStorage.ts';", "export * from './src/view/plan/GalleryShipCollection.ts';", + "export { decisiveGalleryShipName } from './src/view/plan/DecisivePlanView.ts';", ].join('\n'), loader: 'ts', resolveDir: process.cwd(), @@ -38,6 +40,7 @@ const { compactFleetDraftSlots, createFleetCandidateDraft, createFleetDraft, + decisiveGalleryShipName, fleetDraftFromTeamPlan, fleetDraftToTeamPlan, fleetPresetIdentityKey, @@ -46,6 +49,7 @@ const { insertFleetCandidate, insertFleetPrimary, isFleetSlotEmpty, + loadGalleryViewState, moveFleetPrimary, normalizeGallerySearch, removeFleetPrimary, @@ -54,6 +58,7 @@ const { resolveGalleryFormationDropTarget, resolveFleetPresetRules, resolveFleetPreset, + saveGalleryViewState, toBackendName, } = module.exports; @@ -113,6 +118,10 @@ const galleryTypeLabels = { dd: '驱逐舰', ss: '潜艇', }; +assert.equal( + decisiveGalleryShipName(galleryShips[2]), + galleryShips[2].name, +); const refitGalleryShips = filterAndSortGalleryShips(galleryShips, { searchText: 'U.47', typeFilters: new Set(['ss']), @@ -157,6 +166,33 @@ assert.equal(calculateGalleryBatchSize(0, 0), 12); assert.equal(calculateGalleryBatchSize(664, 612), 25); }); +await runScenario('ship gallery state persists filters and scroll position', () => { +const values = new Map(); +const storage = { + get: key => values.get(key) ?? null, + set: (key, value) => values.set(key, value), + remove: key => values.delete(key), +}; +const state = { + searchText: 'U-47', + groupFilter: null, + typeFilters: ['ss'], + countryFilters: ['de'], + refitOnly: true, + sortField: 'name', + descending: true, + scrollTop: 824, + scrollLeft: 0, + renderedShipCount: 48, +}; + +saveGalleryViewState(storage, 'gallery', state); +assert.deepEqual(loadGalleryViewState(storage, 'gallery'), state); + +values.set('gallery', '{invalid'); +assert.equal(loadGalleryViewState(storage, 'gallery'), null); +}); + const createFollowModeDraft = () => { const draft = createFleetDraft(); draft.slots[0].primary = testShip(1, '主选A'); diff --git a/scripts/tests/test-renderer-architecture.js b/scripts/tests/test-renderer-architecture.js index eaedb23..9d41201 100644 --- a/scripts/tests/test-renderer-architecture.js +++ b/scripts/tests/test-renderer-architecture.js @@ -163,6 +163,7 @@ const previousDocument = global.document; const previousResizeObserver = global.ResizeObserver; let observedElement = null; let disconnectCount = 0; +let savedGalleryState = null; global.document = fakeElement(); global.ResizeObserver = class { observe(element) { @@ -178,27 +179,51 @@ try { const { ShipGalleryView, } = require('../../dist/src/view/plan/ShipGalleryView.js'); - const galleryElement = fakeElement(); + const galleryElement = fakeElement({ + scrollTop: 321, + scrollLeft: 0, + }); + const searchInput = fakeElement({ value: '' }); + const refitFilter = fakeElement(); + const sortDescending = fakeElement(); const gallery = new ShipGalleryView({ gallery: galleryElement, countLabel: fakeElement(), - searchInput: fakeElement(), + searchInput, filterButtons: [fakeElement()], filterCount: fakeElement(), filterPopover: fakeElement(), typeOptions: fakeElement(), countryOptions: fakeElement(), - refitFilter: fakeElement(), - sortDescending: fakeElement(), + refitFilter, + sortDescending, resetButton: fakeElement(), confirmButton: fakeElement(), }, { activeSlotDescription: () => 'test slot', isExcluded: () => false, assignShip: () => {}, + getGalleryState: () => ({ + searchText: 'U-47', + groupFilter: null, + typeFilters: ['ss'], + countryFilters: ['de'], + refitOnly: true, + sortField: 'name', + descending: true, + scrollTop: 321, + scrollLeft: 0, + renderedShipCount: 36, + }), + setGalleryState: state => { + savedGalleryState = state; + }, }); assert.equal(observedElement, galleryElement); + assert.equal(searchInput.value, 'U-47'); + assert.equal(refitFilter.checked, true); + assert.equal(sortDescending.checked, true); assert.ok(listenerSignals.length > 0, 'gallery listeners must use AbortSignal'); const signal = listenerSignals[0]; assert.equal( @@ -211,6 +236,8 @@ try { gallery.dispose(); assert.equal(signal.aborted, true); assert.equal(disconnectCount, 1); + assert.equal(savedGalleryState.searchText, 'U-47'); + assert.equal(savedGalleryState.scrollTop, 321); gallery.dispose(); assert.equal(disconnectCount, 1, 'gallery disposal must be idempotent'); } finally { diff --git a/scripts/tests/test-scheduler-domain.mjs b/scripts/tests/test-scheduler-domain.mjs index 85141f7..3086b25 100644 --- a/scripts/tests/test-scheduler-domain.mjs +++ b/scripts/tests/test-scheduler-domain.mjs @@ -15,6 +15,8 @@ const entries = [ 'src/model/scheduler/NormalFightDailyQuota.ts', 'src/model/scheduler/CampaignDailyQuota.ts', 'src/controller/app/rendering.ts', + 'src/controller/taskGroup/queueLoader.ts', + 'src/controller/taskGroup/addItems.ts', ]; const modules = await Promise.all(entries.map(async entry => { const result = await esbuild.build({ @@ -40,6 +42,8 @@ const taskQueueModule = modules[9]; const normalFightQuotaModule = modules[10]; const campaignQuotaModule = modules[11]; const renderingModule = modules[12]; +const queueLoaderModule = modules[13]; +const addItemsModule = modules[14]; assert.equal(normalFightQuotaModule.normalFightDailyLimit(undefined), 1); assert.equal(normalFightQuotaModule.normalFightDailyLimit(0), 1); @@ -806,6 +810,15 @@ assert.equal( ); assert.equal(strictPlanResult.tasks[0].config.source, 'system'); +const decisiveYaml = level2 => [ + 'task_type: decisive', + 'chapter: 5', + 'level1: [当前主力·改]', + `level2: [${level2}]`, + 'flagship_priority: [当前主力·改]', + 'use_quick_repair: true', + '', +].join('\n'); globalThis.window = { electronBridge: { getDecisivePlanSettings: async () => { @@ -813,10 +826,16 @@ globalThis.window = { return { chapter: 5, useQuickRepair: false, - level1: ['当前主力'], + level1: ['当前主力·改'], level2: ['当前替补'], }; }, + getShipLibraryManifest: async () => ({ + ships: [ + { name: '当前主力·改', search_name: '当前主力' }, + { name: '当前替补', search_name: '当前替补' }, + ], + }), readManagedCombatPlan: async (source, file) => { managedPlanReads.push([source, file]); return { @@ -826,11 +845,146 @@ globalThis.window = { content: managedLootYaml, }; }, + readDailyPlan: async (source, file) => ({ + success: true, + path: `daily://${source}/${file}`, + content: decisiveYaml('未收录舰名'), + }), + readCombatPlanFile: async path => ({ + success: true, + path, + content: decisiveYaml('当前替补'), + }), saveFile: async () => { managedPlanWrites += 1; }, }, }; +const directDecisiveQueueCalls = []; +const directDecisiveHost = { + scheduler: { + addTask: (...args) => { + directDecisiveQueueCalls.push(args); + }, + }, + getShipNameAliases: () => ({}), + renderMain: () => {}, +}; +const dailyDecisiveSelection = source => ({ + plan: { + source, + file: `decisive-${source}.yaml`, + name: `${source} 决战`, + taskType: 'decisive', + times: 1, + chapter: 5, + useQuickRepair: true, + }, + times: 1, + useQuickRepair: true, +}); +const assertLatestDecisiveNames = (expected, message) => { + const request = directDecisiveQueueCalls.at(-1)[2]; + assert.deepEqual({ + level1: request.level1, + level2: request.level2, + flagship_priority: request.flagship_priority, + }, expected, message); +}; +const decisiveNameCases = [ + { + source: 'user', + expected: { + level1: ['当前主力'], + level2: ['未收录舰名'], + flagship_priority: ['当前主力'], + }, + }, + { + source: 'system', + expected: { + level1: ['当前主力·改'], + level2: ['未收录舰名'], + flagship_priority: ['当前主力·改'], + }, + }, +]; +for (const { source, expected } of decisiveNameCases) { + await queueLoaderModule.loadDailyPlanToQueue( + dailyDecisiveSelection(source), + directDecisiveHost, + ); + assertLatestDecisiveNames(expected, `${source} 日常决战舰名转换错误`); +} +const decisiveTemplateFixture = { + type: 'decisive', + chapter: 5, + level1: ['当前主力·改'], + level2: ['未收录舰名'], + flagship_priority: ['当前主力·改'], +}; +const decisiveTemplateModel = builtin => ({ + get: () => decisiveTemplateFixture, + isBuiltin: () => builtin, +}); +for (const { source, expected } of decisiveNameCases) { + const builtin = source === 'system'; + const label = builtin ? '内置决战模板' : '用户决战模板'; + const item = { + kind: 'template', + templateId: `decisive-${builtin ? 'builtin' : 'user'}`, + times: 1, + label, + }; + await queueLoaderModule.loadSingleItemToQueue( + 0, + { getActiveGroup: () => ({ items: [item] }) }, + decisiveTemplateModel(builtin), + directDecisiveHost, + ); + assertLatestDecisiveNames(expected, `${label}舰名转换错误`); +} +const systemPresetItems = []; +const systemPresetGroup = { + name: '系统预设来源', + items: systemPresetItems, +}; +const systemPresetGroupModel = { + getActiveGroup: () => systemPresetGroup, + addItem: (_groupName, item) => systemPresetItems.push(item), + save: () => {}, +}; +addItemsModule.addPresetToGroup( + systemPresetGroupModel, + () => ({ + preset: { + task_type: 'decisive', + chapter: 5, + level1: ['当前主力·改'], + }, + filePath: 'system-direct.yaml', + source: 'system', + }), + 1, + () => {}, +); +assert.equal( + systemPresetItems[0].managedSource, + 'system', + '从系统预设详情加入任务组时必须保存来源', +); +const systemPresetQueueIndex = directDecisiveQueueCalls.length; +await queueLoaderModule.loadSingleItemToQueue( + 0, + systemPresetGroupModel, + decisiveTemplateModel(false), + directDecisiveHost, +); +assert.deepEqual( + directDecisiveQueueCalls[systemPresetQueueIndex][2].level1, + ['当前主力·改'], + '系统预设加入任务组后立即执行仍必须保持原始舰名', +); const binder = new schedulerBinderModule.SchedulerBinder({ scheduler: { setCallbacks: callbacks => { diff --git a/scripts/tests/test-settings-persistence.js b/scripts/tests/test-settings-persistence.js index 7dff2ed..0ab06da 100644 --- a/scripts/tests/test-settings-persistence.js +++ b/scripts/tests/test-settings-persistence.js @@ -79,6 +79,12 @@ async function runRendererTest(root, tempDirectory) { root, 'dist/src/controller/app/SettingsController.js', )); + const { + NavigationController, + } = require(rendererPath.join( + root, + 'dist/src/controller/app/NavigationController.js', + )); const { BattlePlanLoaderController, } = require(rendererPath.join( @@ -1589,6 +1595,32 @@ async function runRendererTest(root, tempDirectory) { return notices; }; + const runWithDialogChoice = async (action, buttonId) => { + let dialog = null; + const timer = window.setInterval(() => { + const overlay = document.getElementById('generic-prompt'); + if (!overlay || overlay.style.display === 'none') return; + const title = document.getElementById( + 'generic-prompt-title', + )?.textContent ?? ''; + if (!title) return; + dialog = { + title, + message: document.getElementById( + 'generic-prompt-message', + )?.textContent ?? '', + }; + document.getElementById(buttonId)?.click(); + }, 5); + try { + await action(); + } finally { + window.clearInterval(timer); + } + rendererAssert.ok(dialog, '页面切换必须显示未保存设置提示框'); + return dialog; + }; + const controller = new ConfigController(host); const yamlBeforeFailedCommit = rendererFs.readFileSync( rendererPath.join(tempDirectory, 'usersettings.yaml'), @@ -1643,6 +1675,74 @@ async function runRendererTest(root, tempDirectory) { }, localStorageBeforeFailedCommit); await controller.saveConfig(); + rendererAssert.equal( + controller.hasUnsavedChanges(), + false, + '成功保存后设置页不得被标记为未保存', + ); + view.setNormalFightRemaining(sample.normalFightTasks, 1); + rendererAssert.equal( + controller.hasUnsavedChanges(), + false, + '今日剩余次数刷新不得被误判为用户修改设置', + ); + + const navigationController = new NavigationController({ + loadFleetPlanner: async () => {}, + ensureDefaultPlan: async () => {}, + loadPlanManagement: async () => {}, + refreshAdbStatus: async () => {}, + refreshShipLibraryStatus: async () => {}, + hasUnsavedConfigChanges: () => controller.hasUnsavedChanges(), + }); + const activePage = () => ( + document.querySelector('.nav-tab.active')?.dataset.page ?? '' + ); + await navigationController.switchPage('config'); + await navigationController.switchPage('main'); + rendererAssert.equal( + activePage(), + 'main', + '配置未修改时必须直接离开设置页', + ); + await navigationController.switchPage('config'); + + const logRootInput = document.getElementById('cfg-log-root'); + const savedLogRoot = logRootInput.value; + logRootInput.value = `${savedLogRoot}\\unsaved`; + rendererAssert.equal( + controller.hasUnsavedChanges(), + true, + '修改设置后必须被标记为未保存', + ); + + const canceledDialog = await runWithDialogChoice( + () => navigationController.switchPage('plan'), + 'generic-prompt-cancel', + ); + rendererAssert.equal(canceledDialog.title, '设置尚未保存'); + rendererAssert.match(canceledDialog.message, /当前配置尚未保存/); + rendererAssert.equal( + activePage(), + 'config', + '取消切换后必须留在设置页', + ); + + await runWithDialogChoice( + () => navigationController.switchPage('plan'), + 'generic-prompt-ok', + ); + rendererAssert.equal( + activePage(), + 'plan', + '确认切换后必须进入目标页面', + ); + logRootInput.value = savedLogRoot; + rendererAssert.equal( + controller.hasUnsavedChanges(), + false, + '恢复保存值后不得继续提示未保存', + ); const savedYaml = yaml.load(rendererFs.readFileSync( rendererPath.join(tempDirectory, 'usersettings.yaml'), diff --git a/src/adapter/IpcAdapter.ts b/src/adapter/IpcAdapter.ts index bb1cc69..437d99d 100644 --- a/src/adapter/IpcAdapter.ts +++ b/src/adapter/IpcAdapter.ts @@ -17,6 +17,7 @@ export function getRendererIpc(): RendererIpc { export type ManagedCombatPlanRepository = Partial { return ( @@ -16,11 +21,11 @@ export interface YamlCodec { export const yamlCodec: YamlCodec = { parse(content: string): T { - return yaml.load(content) as T; + return parseYaml(content); }, stringify(value: unknown, options?: yaml.DumpOptions): string { - return yaml.dump(value, options); + return serializeYaml(value, options); }, }; diff --git a/src/controller/app/AppController.ts b/src/controller/app/AppController.ts index 6c3992a..6a7794f 100644 --- a/src/controller/app/AppController.ts +++ b/src/controller/app/AppController.ts @@ -126,6 +126,9 @@ export class AppController { refreshShipLibraryStatus: () => ( this.settingsCtrl.refreshShipLibraryStatus() ), + hasUnsavedConfigChanges: () => ( + this.configCtrl?.hasUnsavedChanges() ?? false + ), }); this.operationsCtrl = new OperationsController(this.api, this.mainView, Logger); diff --git a/src/controller/app/AutomaticDecisiveTask.ts b/src/controller/app/AutomaticDecisiveTask.ts index 9430f65..ddf10c7 100644 --- a/src/controller/app/AutomaticDecisiveTask.ts +++ b/src/controller/app/AutomaticDecisiveTask.ts @@ -1,29 +1,31 @@ /** 分别从用户计划和系统预设构造自动决战请求。 */ import type { DecisiveReq } from '../../types/api.js'; -import type { DecisivePlanSettings } from '../../types/ipc.js'; +import type { + DecisivePlanSettings, + ShipLibraryShip, +} from '../../types/ipc.js'; import type { TaskTemplate } from '../../types/model.js'; - -function nonEmptyNames(value: string[] | undefined): string[] { - return Array.isArray(value) - ? value.map(name => name.trim()).filter(Boolean) - : []; -} +import { toBackendDecisiveShipNames } from '../../shared/shipNameNormalizer.js'; function buildRequest( chapterValue: unknown, useQuickRepair: boolean, - level1Value: string[] | undefined, - level2Value: string[] | undefined, - flagshipValue?: string[], + names: Pick< + DecisiveReq, + 'level1' | 'level2' | 'flagship_priority' + >, + ships: readonly Pick[] = [], ): DecisiveReq { const chapter = Math.trunc(Number(chapterValue)); if (!Number.isFinite(chapter) || chapter < 1 || chapter > 6) { throw new Error(`决战章节无效: ${String(chapterValue)}`); } - const level1 = nonEmptyNames(level1Value); - const level2 = nonEmptyNames(level2Value); - const flagshipPriority = nonEmptyNames(flagshipValue); + const { + level1, + level2, + flagship_priority: flagshipPriority, + } = toBackendDecisiveShipNames(names, ships); const request: DecisiveReq = { type: 'decisive', chapter, @@ -41,12 +43,13 @@ function buildRequest( /** 使用计划页面当前保存的用户方案。 */ export function buildAutomaticDecisivePlanRequest( plan: DecisivePlanSettings, + ships: readonly Pick[] = [], ): DecisiveReq { return buildRequest( plan.chapter, plan.useQuickRepair, - plan.level1, - plan.level2, + plan, + ships, ); } @@ -60,8 +63,6 @@ export function buildAutomaticDecisivePresetRequest( return buildRequest( template.chapter, template.use_quick_repair ?? true, - template.level1, - template.level2, - template.flagship_priority, + template, ); } diff --git a/src/controller/app/ConfigController.ts b/src/controller/app/ConfigController.ts index 3a16a49..5bcd3a7 100644 --- a/src/controller/app/ConfigController.ts +++ b/src/controller/app/ConfigController.ts @@ -77,6 +77,8 @@ export interface ConfigControllerHost { } export class ConfigController { + private savedConfigSnapshot: string | null = null; + constructor( private readonly host: ConfigControllerHost, private readonly gateway: ConfigurationGateway | undefined = @@ -91,6 +93,16 @@ export class ConfigController { this.host.startupCtrl = startupCtrl; } + /** 当前表单是否与最近一次加载或成功保存的配置不同。 */ + hasUnsavedChanges(): boolean { + if (this.savedConfigSnapshot === null) return false; + try { + return this.configSnapshot() !== this.savedConfigSnapshot; + } catch { + return true; + } + } + /** 从磁盘加载 usersettings.yaml */ async loadConfig(): Promise { const bridge = this.gateway; @@ -376,6 +388,7 @@ export class ConfigController { planRoot: cfg.plan_root ?? '', }; this.host.configView.render(vo); + this.savedConfigSnapshot = this.configSnapshot(); } /** 保存配置并同步各组件 */ @@ -484,6 +497,7 @@ export class ConfigController { this.host.scheduler.setAutoExpedition(da.auto_expedition); this.host.scheduler.setExpeditionInterval(gui.expeditionInterval); this.refreshNormalFightRemaining(); + this.savedConfigSnapshot = this.configSnapshot(collected); Logger.info('设置已保存,后端启动项将在重启后生效'); } catch (error) { @@ -518,6 +532,16 @@ export class ConfigController { ); } + /** 使用统一的表单收集结果生成可比较快照。 */ + private configSnapshot( + collected = this.host.configView.collect(), + ): string { + return JSON.stringify({ + ...collected, + normalFightRemaining: null, + }); + } + /** 把设置页输入应用到指定模型,供候选配置和正式提交复用。 */ private applyCollectedConfig( model: ConfigModel, diff --git a/src/controller/app/CurrentFleetController.ts b/src/controller/app/CurrentFleetController.ts index 65fbc87..83897fc 100644 --- a/src/controller/app/CurrentFleetController.ts +++ b/src/controller/app/CurrentFleetController.ts @@ -5,8 +5,8 @@ import { } from '../../adapter/IpcAdapter.js'; import type { ShipLibraryManifest, - ShipLibraryShip, } from '../../types/ipc.js'; +import { findShipLibraryShip } from '../../shared/shipLibrary.js'; import type { SchedulerTaskRequest, } from '../../types/scheduler.js'; @@ -68,8 +68,8 @@ function requestedFleet( for (let index = 0; index < slotCount; index += 1) { const fleetName = normalizedShipName(fleet[index]); const candidates = [ - ...(fleetName ? [{ name: fleetName }] : []), fleetRuleShip(rules[index]), + ...(fleetName ? [{ name: fleetName }] : []), ...candidateShips(rules[index]), ].filter((ship): ship is RequestedFleetShip => ship !== null); const ship = candidates.find(candidate => ( @@ -82,31 +82,6 @@ function requestedFleet( return ships; } -function findShip( - manifest: ShipLibraryManifest | null, - preview: RequestedFleetShip, -): ShipLibraryShip | undefined { - const ships = manifest?.ships ?? []; - const exactNames = [preview.name, preview.searchName].filter( - (name): name is string => Boolean(name), - ); - for (const name of exactNames) { - const match = ships.find(ship => ship.name === name) - ?? ships.find(ship => ship.search_name === name); - if (match) return match; - } - - const baseNames = exactNames - .map(name => name.split('·')[0].trim()) - .filter((name, index, names) => name && names.indexOf(name) === index); - for (const name of baseNames) { - const match = ships.find(ship => ship.name === name) - ?? ships.find(ship => ship.search_name === name); - if (match) return match; - } - return undefined; -} - /** 主页当前舰队的唯一资料库读取者和 ViewObject 生成器。 */ export class CurrentFleetController { private manifest: ShipLibraryManifest | null = null; @@ -139,7 +114,11 @@ export class CurrentFleetController { resolve(request: SchedulerTaskRequest): CurrentFleetShipVO[] { return requestedFleet(request).map(preview => { - const ship = findShip(this.manifest, preview); + const ship = findShipLibraryShip(this.manifest?.ships ?? [], { + name: preview.name, + searchName: preview.searchName, + allowBaseNameFallback: true, + }); return { name: preview.name, ship, diff --git a/src/controller/app/NavigationController.ts b/src/controller/app/NavigationController.ts index ea23dd8..8dbe704 100644 --- a/src/controller/app/NavigationController.ts +++ b/src/controller/app/NavigationController.ts @@ -1,5 +1,6 @@ /** 处理主页面导航、标签切换和当前页面状态。 */ import { NavigationView } from '../../view/main/NavigationView'; +import { showConfirm } from '../../view/shared/DialogHelper'; export interface NavigationControllerHost { loadFleetPlanner(): Promise; @@ -7,16 +8,19 @@ export interface NavigationControllerHost { loadPlanManagement(): Promise; refreshAdbStatus(): Promise; refreshShipLibraryStatus(): Promise; + hasUnsavedConfigChanges(): boolean; } export class NavigationController { + private configLeavePromptOpen = false; + constructor( private readonly host: NavigationControllerHost, private readonly view = new NavigationView(), ) {} bindNavigation(): void { - this.view.onPageSelected = pageId => this.switchPage(pageId); + this.view.onPageSelected = pageId => void this.switchPage(pageId); } bindPlanNavigation(): void { @@ -34,7 +38,25 @@ export class NavigationController { } } - switchPage(pageId: string, planTab?: string): void { + async switchPage(pageId: string, planTab?: string): Promise { + if ( + this.view.getActivePage() === 'config' + && pageId !== 'config' + && this.host.hasUnsavedConfigChanges() + ) { + if (this.configLeavePromptOpen) return; + this.configLeavePromptOpen = true; + try { + const confirmed = await showConfirm( + '设置尚未保存', + '当前配置尚未保存,请及时保存设置。是否仍然切换页面?', + ); + if (!confirmed) return; + } finally { + this.configLeavePromptOpen = false; + } + } + this.view.showPage(pageId); if (pageId === 'plan') { if (planTab) this.showPlanTab(planTab); diff --git a/src/controller/app/ScheduledTaskLoader.ts b/src/controller/app/ScheduledTaskLoader.ts index 63c5abd..d9ff995 100644 --- a/src/controller/app/ScheduledTaskLoader.ts +++ b/src/controller/app/ScheduledTaskLoader.ts @@ -79,8 +79,13 @@ export class ScheduledTaskLoader { if (!this.repository?.getDecisivePlanSettings) { throw new Error('决战计划读取接口不可用'); } + const [plan, shipLibrary] = await Promise.all([ + this.repository.getDecisivePlanSettings(), + this.repository.getShipLibraryManifest(), + ]); request = buildAutomaticDecisivePlanRequest( - await this.repository.getDecisivePlanSettings(), + plan, + shipLibrary.ships, ); label = '用户计划'; } else { diff --git a/src/controller/contracts.ts b/src/controller/contracts.ts index 71e984d..a62d9db 100644 --- a/src/controller/contracts.ts +++ b/src/controller/contracts.ts @@ -25,7 +25,11 @@ export interface TaskGroupHost { renderPlanPreview(): void; closePresetDetail(): void; executePreset(): void; - getCurrentPresetInfo(): { preset: TaskPreset; filePath: string } | null; + getCurrentPresetInfo(): { + preset: TaskPreset; + filePath: string; + source: PlanPresetSource; + } | null; pickManagedBattlePlan(): Promise; openManagedPlan( file: string, diff --git a/src/controller/plan/DecisivePlanController.ts b/src/controller/plan/DecisivePlanController.ts index 8d18394..b5c6d8d 100644 --- a/src/controller/plan/DecisivePlanController.ts +++ b/src/controller/plan/DecisivePlanController.ts @@ -5,6 +5,12 @@ import { import type { DecisivePlanRepository, } from '../../adapter/IpcAdapter'; +import { + browserStorageStore, +} from '../../adapter/StorageAdapter.js'; +import type { + StorageStore, +} from '../../adapter/StorageAdapter.js'; import { DecisiveFleetDraft, DEFAULT_DECISIVE_PLAN_SETTINGS, @@ -16,6 +22,12 @@ import type { DecisivePlanSaveResult, DecisivePlanViewState, } from '../../view/plan/DecisivePlanView'; +import { + loadGalleryViewState, + saveGalleryViewState, +} from './galleryStateStorage.js'; + +const GALLERY_STATE_STORAGE_KEY = 'decisivePlanGalleryState'; export class DecisivePlanController { private readonly draft = new DecisiveFleetDraft(); @@ -24,9 +36,19 @@ export class DecisivePlanController { constructor( private readonly repository: DecisivePlanRepository = decisivePlanRepository, + private readonly storage: StorageStore = browserStorageStore, ) { this.view = new DecisivePlanView({ getState: () => this.getViewState(), + getGalleryState: () => loadGalleryViewState( + this.storage, + GALLERY_STATE_STORAGE_KEY, + ), + setGalleryState: state => saveGalleryViewState( + this.storage, + GALLERY_STATE_STORAGE_KEY, + state, + ), setChapter: chapter => this.draft.setChapter(chapter), changeChapter: chapter => this.changeChapter(chapter), setUseQuickRepair: useQuickRepair => ( diff --git a/src/controller/plan/FleetPlannerController.ts b/src/controller/plan/FleetPlannerController.ts index 85bb2b3..8906f46 100644 --- a/src/controller/plan/FleetPlannerController.ts +++ b/src/controller/plan/FleetPlannerController.ts @@ -54,9 +54,14 @@ import { import { PlanManagementController, } from './PlanManagementController.js'; +import { + loadGalleryViewState, + saveGalleryViewState, +} from './galleryStateStorage.js'; const REFIT_FILTER_STORAGE_KEY = 'fleetPlannerRefitFilter'; const BACKUP_FOLLOW_MODE_STORAGE_KEY = 'fleetPlannerBackupFollowMode'; +const GALLERY_STATE_STORAGE_KEY = 'fleetPlannerGalleryState'; export class FleetPlannerController { private draft = createFleetDraft(); @@ -89,6 +94,15 @@ export class FleetPlannerController { setRefitFilter: enabled => ( this.storage.set(REFIT_FILTER_STORAGE_KEY, String(enabled)) ), + getGalleryState: () => loadGalleryViewState( + this.storage, + GALLERY_STATE_STORAGE_KEY, + ), + setGalleryState: state => saveGalleryViewState( + this.storage, + GALLERY_STATE_STORAGE_KEY, + state, + ), getBackupFollowMode: (): BackupFollowMode => ( this.storage.get(BACKUP_FOLLOW_MODE_STORAGE_KEY) === 'position' ? 'position' diff --git a/src/controller/plan/PlanController.ts b/src/controller/plan/PlanController.ts index 0db0532..015cb2a 100644 --- a/src/controller/plan/PlanController.ts +++ b/src/controller/plan/PlanController.ts @@ -23,6 +23,7 @@ import type { MapData } from '../../model/MapDataLoader'; import type { ManagedBattlePlanSelection, PlanPresetSource, + ShipLibraryShip, UserTeamPlan, } from '../../types/ipc.js'; import { taskPresetCodec } from '../../shared/taskPreset'; @@ -57,6 +58,7 @@ export class PlanController { private editingNodeId: string | null = null; private currentPreset: TaskPreset | null = null; private currentPresetFilePath = ''; + private currentPresetSource: PlanPresetSource = 'user'; private mapLoadVersion = 0; private planPresetName = ''; private currentManagedPlanFile: string | null = null; @@ -172,9 +174,17 @@ export class PlanController { this.eventMapCatalog = await loadEventMapCatalog(); } - getCurrentPresetInfo(): { preset: TaskPreset; filePath: string } | null { + getCurrentPresetInfo(): { + preset: TaskPreset; + filePath: string; + source: PlanPresetSource; + } | null { return this.currentPreset && this.currentPresetFilePath - ? { preset: this.currentPreset, filePath: this.currentPresetFilePath } + ? { + preset: this.currentPreset, + filePath: this.currentPresetFilePath, + source: this.currentPresetSource, + } : null; } @@ -188,6 +198,8 @@ export class PlanController { set currentPreset(v) { self.currentPreset = v; }, get currentPresetFilePath() { return self.currentPresetFilePath; }, set currentPresetFilePath(v) { self.currentPresetFilePath = v; }, + get currentPresetSource() { return self.currentPresetSource; }, + set currentPresetSource(v) { self.currentPresetSource = v; }, }; } @@ -363,6 +375,7 @@ export class PlanController { this.importTaskPreset( taskPresetCodec.normalize(parsed), result.path, + source, ); return true; } @@ -525,17 +538,52 @@ export class PlanController { } } - importTaskPreset(preset: TaskPreset, filePath: string): void { + importTaskPreset( + preset: TaskPreset, + filePath: string, + source: PlanPresetSource = 'user', + ): void { this.mapLoadVersion++; - importTaskPresetFlow(preset, filePath, this.planView, this.host, this.presetState); + importTaskPresetFlow( + preset, + filePath, + this.planView, + this.host, + this.presetState, + source, + ); } closePresetDetail(): void { closePresetDetailFlow(this.planView, this.presetState); } - executePreset(): void { - executePresetFlow(this.planView, this.host, this.presetState); + async executePreset(): Promise { + let ships: Pick[] = []; + if ( + this.currentPreset?.task_type === 'decisive' + && this.currentPresetSource !== 'system' + ) { + if (!this.managedPlans?.getShipLibraryManifest) { + await showAlert('加入队列失败', '舰船资料库读取接口不可用'); + return; + } + try { + ships = (await this.managedPlans.getShipLibraryManifest()).ships; + } catch (error) { + await showAlert( + '加入队列失败', + error instanceof Error ? error.message : String(error), + ); + return; + } + } + executePresetFlow( + this.planView, + this.host, + this.presetState, + ships, + ); } renderPlanPreview(): void { diff --git a/src/controller/plan/galleryStateStorage.ts b/src/controller/plan/galleryStateStorage.ts new file mode 100644 index 0000000..e8fa66d --- /dev/null +++ b/src/controller/plan/galleryStateStorage.ts @@ -0,0 +1,73 @@ +/** Reads and writes the persisted filter and scroll state of a ship gallery. */ +import type { + StorageStore, +} from '../../adapter/StorageAdapter.js'; +import type { + ShipGalleryViewState, +} from '../../types/view.js'; + +const SORT_FIELDS = new Set(['type', 'name', 'id']); + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function nonNegativeNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 + ? value + : 0; +} + +/** Returns null when no usable state has been saved. */ +export function loadGalleryViewState( + storage: StorageStore, + key: string, +): ShipGalleryViewState | null { + try { + const raw = storage.get(key); + if (!raw) return null; + const parsed = JSON.parse(raw) as Record; + if (!parsed || typeof parsed !== 'object') return null; + const sortField = typeof parsed['sortField'] === 'string' + && SORT_FIELDS.has(parsed['sortField']) + ? parsed['sortField'] as ShipGalleryViewState['sortField'] + : 'id'; + return { + searchText: typeof parsed['searchText'] === 'string' + ? parsed['searchText'] + : '', + groupFilter: parsed['groupFilter'] === null + ? null + : typeof parsed['groupFilter'] === 'string' + ? parsed['groupFilter'] + : 'all', + typeFilters: stringArray(parsed['typeFilters']), + countryFilters: stringArray(parsed['countryFilters']), + refitOnly: parsed['refitOnly'] === true, + sortField, + descending: parsed['descending'] === true, + scrollTop: nonNegativeNumber(parsed['scrollTop']), + scrollLeft: nonNegativeNumber(parsed['scrollLeft']), + renderedShipCount: Math.floor( + nonNegativeNumber(parsed['renderedShipCount']), + ), + }; + } catch { + return null; + } +} + +/** Storage failures must not interrupt gallery interactions. */ +export function saveGalleryViewState( + storage: StorageStore, + key: string, + state: ShipGalleryViewState, +): void { + try { + storage.set(key, JSON.stringify(state)); + } catch { + // Keep the current in-memory state when browser storage is unavailable. + } +} diff --git a/src/controller/plan/presetFlow.ts b/src/controller/plan/presetFlow.ts index baa7c12..5c9385d 100644 --- a/src/controller/plan/presetFlow.ts +++ b/src/controller/plan/presetFlow.ts @@ -8,10 +8,16 @@ import type { TaskPreset } from '../../types/model'; import { TemplateController } from '../template/TemplateController'; import { Logger } from '../../utils/Logger'; import type { PlanHost } from '../contracts.js'; +import type { + PlanPresetSource, + ShipLibraryShip, +} from '../../types/ipc.js'; +import { toBackendDecisiveShipNames } from '../../shared/shipNameNormalizer.js'; export interface PresetState { currentPreset: TaskPreset | null; currentPresetFilePath: string; + currentPresetSource: PlanPresetSource; } /** 导入任务预设,解析相对路径并显示详情面板 */ @@ -21,12 +27,13 @@ export function importTaskPresetFlow( planView: PlanPreviewView, host: PlanHost, state: PresetState, + source: PlanPresetSource = 'user', ): void { if (preset.plan_id && !/^[A-Za-z]:[/\\]/.test(preset.plan_id) && !preset.plan_id.startsWith('/')) { const dir = filePath.replace(/[\\/][^\\/]+$/, ''); preset.plan_id = dir + '\\' + preset.plan_id.replace(/\//g, '\\'); } - showPresetDetailFlow(preset, filePath, planView, state); + showPresetDetailFlow(preset, filePath, planView, state, source); host.switchPage('plan'); } @@ -36,9 +43,11 @@ export function showPresetDetailFlow( filePath: string, planView: PlanPreviewView, state: PresetState, + source: PlanPresetSource = 'user', ): void { state.currentPreset = preset; state.currentPresetFilePath = filePath; + state.currentPresetSource = source; const name = filePath.split(/[\\/]/).pop()?.replace(/\.ya?ml$/i, '') ?? preset.task_type; const typeLabel = TemplateController.TEMPLATE_TYPE_LABELS[preset.task_type] ?? preset.task_type; @@ -68,6 +77,7 @@ export function closePresetDetailFlow( ): void { state.currentPreset = null; state.currentPresetFilePath = ''; + state.currentPresetSource = 'user'; planView.hidePresetDetail(); } @@ -76,6 +86,7 @@ export function executePresetFlow( planView: PlanPreviewView, host: PlanHost, state: PresetState, + ships: readonly Pick[] = [], ): void { const preset = state.currentPreset; if (!preset) return; @@ -92,16 +103,24 @@ export function executePresetFlow( case 'campaign': req = { type: 'campaign', campaign_name: formVals.campaignName!, times: 1 }; break; - case 'decisive': + case 'decisive': { + const isSystemPreset = state.currentPresetSource === 'system'; + const shipNames = toBackendDecisiveShipNames( + { + level1: formVals.level1, + level2: formVals.level2, + flagship_priority: formVals.flagshipPriority, + }, + isSystemPreset ? undefined : ships, + ); req = { type: 'decisive', chapter: formVals.chapter, - level1: formVals.level1, - level2: formVals.level2, - flagship_priority: formVals.flagshipPriority, + ...shipNames, use_quick_repair: formVals.useQuickRepair, }; break; + } case 'event_fight': req = { type: 'event_fight', diff --git a/src/controller/taskGroup/addItems.ts b/src/controller/taskGroup/addItems.ts index 481eee0..2f907ce 100644 --- a/src/controller/taskGroup/addItems.ts +++ b/src/controller/taskGroup/addItems.ts @@ -8,6 +8,7 @@ import type { TaskPreset } from '../../types/model.js'; import type { DailyPlanSelection, ManagedBattlePlan, + PlanPresetSource, } from '../../types/ipc.js'; import { Logger } from '../../utils/Logger'; import { @@ -115,7 +116,11 @@ export async function addCurrentPlanToGroup( /** 将当前任务预设添加到任务组 */ export function addPresetToGroup( taskGroupModel: TaskGroupModel, - getCurrentPresetInfo: () => { preset: TaskPreset; filePath: string } | null, + getCurrentPresetInfo: () => { + preset: TaskPreset; + filePath: string; + source: PlanPresetSource; + } | null, times: number, render: () => void, ): void { @@ -127,6 +132,7 @@ export function addPresetToGroup( taskGroupModel.addItem(group.name, { path: info.filePath, + managedSource: info.source, kind: 'preset', times: normalizedTimes, label, diff --git a/src/controller/taskGroup/queueLoader.ts b/src/controller/taskGroup/queueLoader.ts index d9b75e0..32d4229 100644 --- a/src/controller/taskGroup/queueLoader.ts +++ b/src/controller/taskGroup/queueLoader.ts @@ -10,11 +10,15 @@ import type { EventFightReq, NormalFightReq, TaskRequest } from '../../types/api import type { DailyPlanSelection, ManagedBattlePlanSelection, + ShipLibraryShip, } from '../../types/ipc.js'; import type { TaskPreset } from '../../types/model.js'; import { resolveFleetPreset } from '../../model/fleet/ShipMatcher'; import { resolveFleetPresetRules } from '../../model/fleet/FleetRuleMapper'; -import { toBackendName } from '../../shared/shipNameNormalizer'; +import { + toBackendDecisiveShipNames, + toBackendName, +} from '../../shared/shipNameNormalizer'; import { taskPresetCodec } from '../../shared/taskPreset'; import { Logger } from '../../utils/Logger'; import { @@ -24,7 +28,10 @@ import { import type { TaskGroupHost } from '../contracts.js'; import { readTaskGroupItemFile } from './managedPlanReader'; import { parseYamlRecord } from '../../adapter'; -import { getTaskGroupRepository } from '../../adapter/IpcAdapter'; +import { + getTaskGroupRepository, + type TaskGroupRepository, +} from '../../adapter/IpcAdapter'; export function applyPlanNodeOverrides( req: NormalFightReq | EventFightReq, @@ -95,6 +102,44 @@ interface PlanQueueHost { renderMain(): void; } +async function decisiveShipsForItem( + item: TaskGroupItem, + preset: TaskPreset, + repository: TaskGroupRepository, +): Promise< + readonly Pick[] | undefined +> { + const source = item.dailySource ?? item.managedSource; + if (preset.task_type !== 'decisive' || source === 'system') { + return undefined; + } + if (!repository.getShipLibraryManifest) { + throw new Error('舰船资料库读取接口不可用'); + } + return (await repository.getShipLibraryManifest()).ships; +} + +async function decisiveShipsForTemplate( + item: TaskGroupItem, + templateModel: TemplateModel, + repository: TaskGroupRepository | undefined, +): Promise< + readonly Pick[] | undefined +> { + const templateId = item.templateId ?? ''; + const template = templateModel.get(templateId); + if ( + template?.type !== 'decisive' + || templateModel.isBuiltin(templateId) + ) { + return undefined; + } + if (!repository?.getShipLibraryManifest) { + throw new Error('舰船资料库读取接口不可用'); + } + return (await repository.getShipLibraryManifest()).ships; +} + function addPlanTaskToQueue( item: TaskGroupItem, plan: PlanModel, @@ -133,6 +178,7 @@ function addPresetTaskToQueue( item: TaskGroupItem, preset: TaskPreset, scheduler: Scheduler, + ships?: readonly Pick[], ): void { let req: TaskRequest; if (preset.task_type === 'campaign') { @@ -150,9 +196,7 @@ function addPresetTaskToQueue( req = { type: 'decisive', chapter: preset.chapter, - level1: preset.level1 ?? [], - level2: preset.level2 ?? [], - flagship_priority: preset.flagship_priority ?? [], + ...toBackendDecisiveShipNames(preset, ships), use_quick_repair: item.useQuickRepair ?? preset.use_quick_repair ?? true, @@ -194,16 +238,20 @@ export async function loadManagedPlanToQueue( label: selection.plan.name, fleetPresetIndex: selection.fleetPresetIndex, }; - const { content, path } = await readTaskGroupItemFile(item); + const repository = getTaskGroupRepository(); + if (!repository) throw new Error('Electron bridge is unavailable'); + const { content, path } = await readTaskGroupItemFile(item, repository); const parsed = parseYamlRecord(content, '任务文件'); if ( item.kind === 'preset' || taskPresetCodec.isStandalone(parsed) ) { + const preset = taskPresetCodec.normalize(parsed); addPresetTaskToQueue( item, - taskPresetCodec.normalize(parsed), + preset, host.scheduler, + await decisiveShipsForItem(item, preset, repository), ); } else { const plan = PlanModel.fromYaml(content, path); @@ -232,11 +280,18 @@ export async function loadDailyPlanToQueue( ? selection.useQuickRepair !== false : undefined, }; - const { content } = await readTaskGroupItemFile(item); + const repository = getTaskGroupRepository(); + if (!repository) throw new Error('Electron bridge is unavailable'); + const { content } = await readTaskGroupItemFile(item, repository); const preset = taskPresetCodec.normalize( parseYamlRecord(content, '日常任务'), ); - addPresetTaskToQueue(item, preset, host.scheduler); + addPresetTaskToQueue( + item, + preset, + host.scheduler, + await decisiveShipsForItem(item, preset, repository), + ); Logger.info(`已将日常任务「${selection.plan.name}」加入任务队列`); host.renderMain(); } @@ -256,7 +311,17 @@ export async function loadGroupToQueue( for (const item of group.items) { try { if (item.kind === 'template') { - loadedCount += loadTemplateToQueue(item, templateModel, host) ? 1 : 0; + const ships = await decisiveShipsForTemplate( + item, + templateModel, + repository, + ); + loadedCount += loadTemplateToQueue( + item, + templateModel, + host, + ships, + ) ? 1 : 0; continue; } @@ -270,10 +335,12 @@ export async function loadGroupToQueue( item.kind === 'preset' || taskPresetCodec.isStandalone(parsed) ) { + const preset = taskPresetCodec.normalize(parsed); addPresetTaskToQueue( item, - taskPresetCodec.normalize(parsed), + preset, host.scheduler, + await decisiveShipsForItem(item, preset, repository), ); } else { const plan = PlanModel.fromYaml(content, path); @@ -298,6 +365,7 @@ export function loadTemplateToQueue( item: TaskGroupItem, templateModel: TemplateModel, host: TaskGroupHost, + ships?: readonly Pick[], ): boolean { const tpl = templateModel.get(item.templateId ?? ''); if (!tpl) { Logger.error(`模板「${item.label}」不存在,可能已被删除`); return false; } @@ -317,17 +385,16 @@ export function loadTemplateToQueue( host.scheduler.addTask(item.label || tpl.name, 'campaign', req, TaskPriority.USER_TASK, times, undefined, undefined, undefined, undefined, undefined, undefined, allowPolling); break; } - case 'decisive': + case 'decisive': { req = { type: 'decisive', chapter: item.chapter ?? tpl.chapter ?? 6, - level1: tpl.level1 ?? [], - level2: tpl.level2 ?? [], - flagship_priority: tpl.flagship_priority ?? [], + ...toBackendDecisiveShipNames(tpl, ships), use_quick_repair: tpl.use_quick_repair, }; host.scheduler.addTask(item.label || tpl.name, 'decisive', req, TaskPriority.USER_TASK, times, undefined, undefined, undefined, undefined, undefined, undefined, allowPolling); break; + } default: return false; } @@ -345,15 +412,25 @@ export async function loadSingleItemToQueue( if (!group) return; const item = group.items[index]; if (!item) return; + const repository = getTaskGroupRepository(); if (item.kind === 'template') { - loadTemplateToQueue(item, templateModel, host); - Logger.info(`已将「${item.label}」加入队列`); - host.renderMain(); + try { + const ships = await decisiveShipsForTemplate( + item, + templateModel, + repository, + ); + loadTemplateToQueue(item, templateModel, host, ships); + Logger.info(`已将「${item.label}」加入队列`); + host.renderMain(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + Logger.error(`加载「${item.label}」失败: ${msg}`); + } return; } - const repository = getTaskGroupRepository(); if (!repository) return; try { @@ -367,10 +444,12 @@ export async function loadSingleItemToQueue( item.kind === 'preset' || taskPresetCodec.isStandalone(parsed) ) { + const preset = taskPresetCodec.normalize(parsed); addPresetTaskToQueue( item, - taskPresetCodec.normalize(parsed), + preset, host.scheduler, + await decisiveShipsForItem(item, preset, repository), ); } else { const plan = PlanModel.fromYaml(content, path); diff --git a/src/model/PlanModel.ts b/src/model/PlanModel.ts index 7507d75..42f2f3b 100644 --- a/src/model/PlanModel.ts +++ b/src/model/PlanModel.ts @@ -5,6 +5,7 @@ */ import { yamlCodec } from '../adapter/index.js'; import { normalizeLegacyNodeDecisionFields } from '../shared/nodeDecision.js'; +import { serializePlanYaml } from '../shared/yamlSerializer.js'; import type { PlanData, NodeArgs, @@ -16,124 +17,8 @@ import type { BattleResultGrade, } from '../types/model.js'; -type YamlPath = string[]; const BATTLE_RESULT_GRADES = new Set(['D', 'C', 'B', 'A', 'S', 'SS']); -function isYamlMapping(value: unknown): value is Record { - return value !== null && typeof value === 'object' && !Array.isArray(value); -} - -function isYamlScalar(value: unknown): boolean { - return value === null || ['string', 'number', 'boolean'].includes(typeof value); -} - -/** 使用 YAML 流式语法输出单个值,不改变值本身。 */ -function dumpFlowYaml(value: unknown): string { - return yamlCodec.stringify(value, { - flowLevel: 0, - lineWidth: -1, - noCompatMode: true, - noRefs: true, - sortKeys: false, - }).trimEnd(); -} - -function shouldInlineMapping(path: YamlPath): boolean { - return ( - (path.length === 1 && path[0] === 'node_defaults') - || (path.length === 2 && path[0] === 'node_args') - ); -} - -function canInlineValue(value: unknown, path: YamlPath): boolean { - if (isYamlScalar(value)) return true; - if (Array.isArray(value)) { - return value.length === 0 || value.every(isYamlScalar); - } - if (isYamlMapping(value)) { - return Object.keys(value).length === 0 || shouldInlineMapping(path); - } - return false; -} - -function serializeYamlSequence( - values: unknown[], - indent: number, - path: YamlPath, -): string[] { - const padding = ' '.repeat(indent); - const lines: string[] = []; - const inlineItems = path[path.length - 1] === 'candidates'; - - for (const value of values) { - if ( - isYamlScalar(value) - || (Array.isArray(value) && value.every(isYamlScalar)) - || (inlineItems && isYamlMapping(value)) - ) { - lines.push(`${padding}- ${dumpFlowYaml(value)}`); - continue; - } - - if (Array.isArray(value)) { - lines.push(`${padding}-`); - lines.push(...serializeYamlSequence(value, indent + 2, [...path, '[]'])); - continue; - } - - if (isYamlMapping(value)) { - const itemLines = serializeYamlMapping(value, indent + 2, [...path, '[]']); - if (itemLines.length === 0) { - lines.push(`${padding}- {}`); - continue; - } - const itemPadding = ' '.repeat(indent + 2); - lines.push(`${padding}- ${itemLines[0].slice(itemPadding.length)}`); - lines.push(...itemLines.slice(1)); - continue; - } - - lines.push(`${padding}- ${dumpFlowYaml(value)}`); - } - - return lines; -} - -function serializeYamlMapping( - value: Record, - indent: number, - path: YamlPath, -): string[] { - const padding = ' '.repeat(indent); - const lines: string[] = []; - - for (const [key, item] of Object.entries(value)) { - if (item === undefined) continue; - const itemPath = [...path, key]; - const yamlKey = dumpFlowYaml(key); - - if (canInlineValue(item, itemPath)) { - lines.push(`${padding}${yamlKey}: ${dumpFlowYaml(item)}`); - continue; - } - - lines.push(`${padding}${yamlKey}:`); - if (Array.isArray(item)) { - lines.push(...serializeYamlSequence(item, indent + 2, itemPath)); - } else if (isYamlMapping(item)) { - lines.push(...serializeYamlMapping(item, indent + 2, itemPath)); - } else { - lines.push(`${' '.repeat(indent + 2)}${dumpFlowYaml(item)}`); - } - } - - return lines; -} - -function serializePlanYaml(value: Record): string { - return `${serializeYamlMapping(value, 0, []).join('\n')}\n`; -} - export class PlanModel { data: PlanData; fileName: string; diff --git a/src/shared/shipLibrary.ts b/src/shared/shipLibrary.ts new file mode 100644 index 0000000..d696589 --- /dev/null +++ b/src/shared/shipLibrary.ts @@ -0,0 +1,70 @@ +/** 舰船资料库中的统一名称匹配规则。 */ +import type { ShipLibraryShip } from '../types/ipc.js'; + +export interface ShipLibraryReference { + /** 精确形态名称,例如“U-47·改”。 */ + readonly name?: string | null; + /** 用于兼容旧配置和别名的检索名称。 */ + readonly searchName?: string | null; + /** 名称和检索名称均失败时,是否允许移除“·形态”后缀重试。 */ + readonly allowBaseNameFallback?: boolean; +} + +function normalizedName(value: string | null | undefined): string { + return value?.trim() ?? ''; +} + +function distinctNames( + values: readonly (string | null | undefined)[], +): string[] { + return values + .map(normalizedName) + .filter((value, index, names) => ( + Boolean(value) && names.indexOf(value) === index + )); +} + +function findBySearchName( + ships: readonly ShipLibraryShip[], + searchName: string, +): ShipLibraryShip | undefined { + return ships.find(ship => ( + ship.search_name === searchName && ship.variant === 'normal' + )) ?? ships.find(ship => ship.search_name === searchName); +} + +/** + * 精确形态名称优先,search_name 只负责兼容检索,禁止覆盖已命中的形态。 + */ +export function findShipLibraryShip( + ships: readonly ShipLibraryShip[], + reference: ShipLibraryReference, +): ShipLibraryShip | undefined { + const name = normalizedName(reference.name); + const searchName = normalizedName(reference.searchName); + + if (name) { + const exact = ships.find(ship => ship.name === name); + if (exact) return exact; + } + if (searchName && searchName !== name) { + const exactSearchName = ships.find(ship => ship.name === searchName); + if (exactSearchName) return exactSearchName; + } + + for (const candidate of distinctNames([searchName, name])) { + const match = findBySearchName(ships, candidate); + if (match) return match; + } + + if (!reference.allowBaseNameFallback) return undefined; + const baseNames = distinctNames([name, searchName].map(value => ( + value.split('·')[0] + ))); + for (const baseName of baseNames) { + const match = ships.find(ship => ship.name === baseName) + ?? findBySearchName(ships, baseName); + if (match) return match; + } + return undefined; +} diff --git a/src/shared/shipNameNormalizer.ts b/src/shared/shipNameNormalizer.ts index f243990..3b9dd26 100644 --- a/src/shared/shipNameNormalizer.ts +++ b/src/shared/shipNameNormalizer.ts @@ -1,4 +1,7 @@ /** 统一舰名别名、后端标准名和搜索名转换。 */ +import type { DecisiveReq } from '../types/api.js'; +import type { ShipLibraryShip } from '../types/ipc.js'; + export function toBackendName(displayName: string): string { const noRefit = displayName.endsWith('·改') ? displayName.slice(0, -2) @@ -6,6 +9,36 @@ export function toBackendName(displayName: string): string { return noRefit.replace(/\s*[((][^()()]*[))]\s*$/, '').trim(); } +type DecisiveShipNames = Pick< + DecisiveReq, + 'level1' | 'level2' | 'flagship_priority' +>; + +export function toBackendDecisiveShipNames( + names: DecisiveShipNames, + ships?: readonly Pick[], +): Required { + const backendNames = ships === undefined + ? null + : new Map( + ships.map(ship => [ship.name.trim(), ship.search_name.trim()]), + ); + const convert = (value?: string[]): string[] => { + const values = Array.isArray(value) ? value : []; + return backendNames + ? values + .map(name => name.trim()) + .filter(Boolean) + .map(name => backendNames.get(name) ?? name) + : values; + }; + return { + level1: convert(names.level1), + level2: convert(names.level2), + flagship_priority: convert(names.flagship_priority), + }; +} + export function resolveConfiguredShipSearchName( name: string, aliases: Readonly>, diff --git a/src/shared/yamlSerializer.ts b/src/shared/yamlSerializer.ts new file mode 100644 index 0000000..f3bb910 --- /dev/null +++ b/src/shared/yamlSerializer.ts @@ -0,0 +1,201 @@ +/** + * GUI 所有 YAML 文档的统一编解码入口。 + * + * 解析统一交给 js-yaml,避免各业务模块直接依赖底层库。 + * 序列化分为通用、出征计划和舰队预设三种公开方法。 + * 通用方法交给 js-yaml,统一关闭引用并保留字段顺序。 + * 出征计划和舰队预设共用同一套可读性排版算法。 + * 简单的一维列表使用 `[A, B, C]`,减少无意义换行。 + * 二维规则列表保留外层分行,单条规则使用 `[条件, 结果]`。 + * `node_defaults` 和简单节点参数允许使用行内对象。 + * 舰队主选仍按字段分行,便于查看每个位置的完整要求。 + * `candidates` 中的单艘候选舰使用行内对象,避免文件过长。 + * 包含嵌套对象的数据继续使用块状语法,保证层级清楚。 + * 所有标量和值的引号均由 js-yaml 处理,避免手工转义错误。 + * 未知业务字段按原有顺序保留,不由格式化层擅自删除。 + * 所有专用文档都以换行符结尾,便于编辑器和版本管理。 + * 业务 Codec 只负责校验和字段取舍,不再自行拼接 YAML。 + */ +import * as yaml from 'js-yaml'; + +type YamlPath = string[]; + +const DEFAULT_DUMP_OPTIONS: yaml.DumpOptions = { + lineWidth: -1, + noCompatMode: true, + noRefs: true, + sortKeys: false, +}; + +/** 通过统一入口解析 YAML 文档。 */ +export function parseYaml(content: string): T { + return yaml.load(content) as T; +} + +/** 使用全局默认选项序列化普通 YAML 数据。 */ +export function serializeYaml( + value: unknown, + options: yaml.DumpOptions = {}, +): string { + return yaml.dump(value, { + ...DEFAULT_DUMP_OPTIONS, + ...options, + }); +} + +function isYamlMapping(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isYamlScalar(value: unknown): boolean { + return value === null + || ['string', 'number', 'boolean'].includes(typeof value); +} + +/** 使用 YAML 流式语法输出单个值,不改变值本身。 */ +function dumpFlowYaml(value: unknown): string { + return serializeYaml(value, { flowLevel: 0 }).trimEnd(); +} + +function isShallowYamlMapping( + value: Record, +): boolean { + return Object.values(value).every(item => ( + isYamlScalar(item) + || ( + Array.isArray(item) + && item.every(isYamlScalar) + ) + )); +} + +function shouldInlineMapping( + value: Record, + path: YamlPath, +): boolean { + if (!isShallowYamlMapping(value)) return false; + return ( + (path.length === 1 && path[0] === 'node_defaults') + || (path.length === 2 && path[0] === 'node_args') + ); +} + +function canInlineValue( + value: unknown, + path: YamlPath, +): boolean { + if (isYamlScalar(value)) return true; + if (Array.isArray(value)) { + return value.length === 0 || value.every(isYamlScalar); + } + return isYamlMapping(value) && ( + Object.keys(value).length === 0 + || shouldInlineMapping(value, path) + ); +} + +function serializeYamlSequence( + values: unknown[], + indent: number, + path: YamlPath, +): string[] { + const padding = ' '.repeat(indent); + const lines: string[] = []; + const inlineMappings = path[path.length - 1] === 'candidates'; + + for (const value of values) { + if ( + isYamlScalar(value) + || (Array.isArray(value) && value.every(isYamlScalar)) + || ( + inlineMappings + && isYamlMapping(value) + && isShallowYamlMapping(value) + ) + ) { + lines.push(`${padding}- ${dumpFlowYaml(value)}`); + continue; + } + + if (Array.isArray(value)) { + lines.push(`${padding}-`); + lines.push(...serializeYamlSequence( + value, + indent + 2, + [...path, '[]'], + )); + continue; + } + + if (isYamlMapping(value)) { + const itemLines = serializeYamlMapping( + value, + indent + 2, + [...path, '[]'], + ); + if (itemLines.length === 0) { + lines.push(`${padding}- {}`); + continue; + } + const itemPadding = ' '.repeat(indent + 2); + lines.push(`${padding}- ${itemLines[0].slice(itemPadding.length)}`); + lines.push(...itemLines.slice(1)); + continue; + } + + lines.push(`${padding}- ${dumpFlowYaml(value)}`); + } + + return lines; +} + +function serializeYamlMapping( + value: Record, + indent: number, + path: YamlPath, +): string[] { + const padding = ' '.repeat(indent); + const lines: string[] = []; + + for (const [key, item] of Object.entries(value)) { + if (item === undefined) continue; + const itemPath = [...path, key]; + const yamlKey = dumpFlowYaml(key); + + if (canInlineValue(item, itemPath)) { + lines.push(`${padding}${yamlKey}: ${dumpFlowYaml(item)}`); + continue; + } + + lines.push(`${padding}${yamlKey}:`); + if (Array.isArray(item)) { + lines.push(...serializeYamlSequence(item, indent + 2, itemPath)); + } else if (isYamlMapping(item)) { + lines.push(...serializeYamlMapping(item, indent + 2, itemPath)); + } else { + lines.push(`${' '.repeat(indent + 2)}${dumpFlowYaml(item)}`); + } + } + + return lines; +} + +function serializeReadableYaml( + value: Record, +): string { + return `${serializeYamlMapping(value, 0, []).join('\n')}\n`; +} + +/** 序列化出征计划,并保证文件以换行符结束。 */ +export function serializePlanYaml( + value: Record, +): string { + return serializeReadableYaml(value); +} + +/** 序列化舰队预设,并保证候选舰使用紧凑的行内对象。 */ +export function serializeTeamYaml( + value: Record, +): string { + return serializeReadableYaml(value); +} diff --git a/src/types/view.ts b/src/types/view.ts index ea41e83..556f128 100644 --- a/src/types/view.ts +++ b/src/types/view.ts @@ -163,6 +163,19 @@ export interface FleetShipLibraryViewObject { colorfulBackgroundUrl: string; } +export interface ShipGalleryViewState { + searchText: string; + groupFilter: string | null; + typeFilters: string[]; + countryFilters: string[]; + refitOnly: boolean; + sortField: 'type' | 'name' | 'id'; + descending: boolean; + scrollTop: number; + scrollLeft: number; + renderedShipCount: number; +} + export interface TeamPlanShipRuleViewObject { name: string; searchName?: string; diff --git a/src/view/main/FleetPreviewView.ts b/src/view/main/FleetPreviewView.ts index 7cf356f..e322ceb 100644 --- a/src/view/main/FleetPreviewView.ts +++ b/src/view/main/FleetPreviewView.ts @@ -184,10 +184,11 @@ export class FleetPreviewView { const ship = preview.ship; if (ship) { - card.append(createShipArtwork( - ship, - preview.shipTypeLabel ?? ship.ship_type, - )); + card.append(createShipArtwork(ship, { + shipTypeLabel: preview.shipTypeLabel ?? ship.ship_type, + showNumber: false, + displayName: preview.name, + })); } else { const unknown = document.createElement('span'); unknown.className = 'current-fleet-card-unknown'; diff --git a/src/view/main/NavigationView.ts b/src/view/main/NavigationView.ts index a50092c..7a75384 100644 --- a/src/view/main/NavigationView.ts +++ b/src/view/main/NavigationView.ts @@ -75,6 +75,11 @@ export class NavigationView { ?.dataset['planTab'] ?? 'fleet'; } + getActivePage(): string { + return document.querySelector('.nav-tab.active') + ?.dataset['page'] ?? 'main'; + } + private updateNavigationIndicator(): void { const activeTab = this.navTabs?.querySelector( '.nav-tab.active', diff --git a/src/view/plan/DecisivePlanView.ts b/src/view/plan/DecisivePlanView.ts index d215904..fee2a11 100644 --- a/src/view/plan/DecisivePlanView.ts +++ b/src/view/plan/DecisivePlanView.ts @@ -19,6 +19,10 @@ import type { ShipLibraryManifest, ShipLibraryShip, } from '../../types/ipc.js'; +import { findShipLibraryShip } from '../../shared/shipLibrary.js'; +import type { + ShipGalleryViewState, +} from '../../types/view.js'; import { showAlert, showConfirm, @@ -50,6 +54,8 @@ export interface DecisivePlanSaveResult { export interface DecisivePlanViewHost { getState(): DecisivePlanViewState; + getGalleryState(): ShipGalleryViewState | null; + setGalleryState(state: ShipGalleryViewState): void; setChapter(chapter: number): void; changeChapter(chapter: number): Promise; setUseQuickRepair(useQuickRepair: boolean): void; @@ -87,6 +93,13 @@ const MAIN_SLOT_COUNT = 6; const DEFAULT_BACKUP_SLOT_COUNT = 6; const DECISIVE_DRAG_MIME = 'application/x-autowsgr-decisive-ship'; +/** Keeps the exact normal, refit, or special variant selected in the gallery. */ +export function decisiveGalleryShipName( + ship: Pick, +): string { + return ship.name; +} + function requiredElement(id: string): T { const element = document.getElementById(id); if (!element) throw new Error(`缺少决战页面元素: #${id}`); @@ -120,6 +133,7 @@ export class DecisivePlanView { ); private readonly backupScroll = this.backupList.parentElement!; private readonly galleryView: ShipGalleryView; + private shipSearchNameByName = new Map(); private galleryLevel: DecisiveLevel = 'level1'; private activeMainIndex = 0; private activeBackupIndex = 0; @@ -152,13 +166,11 @@ export class DecisivePlanView { ), }, { activeSlotDescription: () => this.activeSlotDescription(), - isExcluded: ship => { - const state = this.host.getState(); - return state.level1.includes(ship.search_name) - || state.level2.includes(ship.search_name); - }, + isExcluded: ship => this.findConfiguredShip(ship) !== null, assignShip: ship => this.assignGalleryShip(ship), - shipDisplayName: ship => ship.search_name, + getGalleryState: () => this.host.getGalleryState(), + setGalleryState: state => this.host.setGalleryState(state), + shipDisplayName: ship => decisiveGalleryShipName(ship), isInteractionEnabled: () => this.editEnabled.checked, drag: { mime: DECISIVE_DRAG_MIME, @@ -267,19 +279,24 @@ export class DecisivePlanView { private loadShipLibrary(manifest: ShipLibraryManifest | null): void { if (!manifest?.ships) { + this.shipSearchNameByName.clear(); this.galleryView.clearLibrary(); this.galleryView.showLoadError('舰船资料库不可用'); return; } + const ships = manifest.ships.filter(ship => ( + Number.isFinite(ship.id) + && Boolean(ship.name) + && Boolean(ship.search_name) + && Boolean(ship.portraitUrl) + )); + this.shipSearchNameByName = new Map( + ships.map(ship => [ship.name, ship.search_name]), + ); this.galleryView.showLibrary({ labels: manifest.labels, - ships: manifest.ships.filter(ship => ( - Number.isFinite(ship.id) - && Boolean(ship.name) - && Boolean(ship.search_name) - && Boolean(ship.portraitUrl) - )), + ships, }); } @@ -290,7 +307,7 @@ export class DecisivePlanView { this.editEnabled.checked = false; this.renderEditState(); this.renderQueues(); - this.galleryView.render(); + this.galleryView.render(false); this.renderGalleryTarget(); } @@ -361,10 +378,10 @@ export class DecisivePlanView { if (name) { const ship = this.findDisplayShip(name); if (ship) { - slot.append(createShipArtwork( - ship, - this.galleryView.shipTypeDisplay(ship), - )); + slot.append(createShipArtwork(ship, { + shipTypeLabel: this.galleryView.shipTypeDisplay(ship), + displayName: name, + })); } else { const fallback = document.createElement('span'); fallback.className = 'fleet-slot-empty'; @@ -453,7 +470,7 @@ export class DecisivePlanView { private assignGalleryShip(ship: ShipLibraryShip): void { if (!this.editEnabled.checked) return; - const existing = this.findConfiguredShip(ship.search_name); + const existing = this.findConfiguredShip(ship); if (existing) { this.selectSlot(existing.level, existing.index); return; @@ -492,7 +509,7 @@ export class DecisivePlanView { advanceToNextEmpty: boolean, ): void { if (!this.editEnabled.checked) return; - const existing = this.findConfiguredShip(ship.search_name); + const existing = this.findConfiguredShip(ship); if (existing) { this.selectSlot(existing.level, existing.index); return; @@ -502,7 +519,7 @@ export class DecisivePlanView { ? MAIN_SLOT_COUNT - 1 : this.backupSlotCount - 1; const target = this.host.placeShip( - ship.search_name, + decisiveGalleryShipName(ship), level, requestedIndex, maxIndex, @@ -636,15 +653,22 @@ export class DecisivePlanView { } private findDisplayShip(name: string): ShipLibraryShip | undefined { - return this.galleryView.ships().find(ship => ( - ship.search_name === name && ship.variant === 'normal' - )) ?? this.galleryView.ships().find(ship => ship.search_name === name); + return findShipLibraryShip(this.galleryView.ships(), { name }); } private findConfiguredShip( - name: string, + ship: ShipLibraryShip, ): { level: DecisiveLevel; index: number } | null { - return this.host.findShip(name); + const exact = this.host.findShip(ship.name); + if (exact) return exact; + const state = this.host.getState(); + for (const level of LEVELS) { + const index = state[level].findIndex(name => ( + (this.shipSearchNameByName.get(name) ?? name) === ship.search_name + )); + if (index >= 0) return { level, index }; + } + return null; } private renderGalleryTarget(): void { diff --git a/src/view/plan/FleetEditorView.ts b/src/view/plan/FleetEditorView.ts index 41cceb7..b020771 100644 --- a/src/view/plan/FleetEditorView.ts +++ b/src/view/plan/FleetEditorView.ts @@ -660,10 +660,9 @@ export class FleetEditorView { } if (ship) { - const artwork = createShipArtwork( - ship, - this.host.shipTypeDisplay(ship), - ); + const artwork = createShipArtwork(ship, { + shipTypeLabel: this.host.shipTypeDisplay(ship), + }); const validationPreview = document.createElement('span'); validationPreview.className = 'fleet-validation-preview'; const validationBadge = document.createElement('span'); diff --git a/src/view/plan/FleetGalleryView.ts b/src/view/plan/FleetGalleryView.ts index bbab778..67bf8b5 100644 --- a/src/view/plan/FleetGalleryView.ts +++ b/src/view/plan/FleetGalleryView.ts @@ -1,5 +1,6 @@ /** 将舰队规划的编辑契约接入共享舰船图库。 */ import type { ShipLibraryShip } from '../../types/ipc.js'; +import type { ShipGalleryViewState } from '../../types/view.js'; import { ShipGalleryView, } from './ShipGalleryView'; @@ -9,6 +10,8 @@ export const FLEET_DRAG_MIME = 'application/x-autowsgr-fleet'; export interface FleetGalleryViewHost { getRefitFilter(): boolean; setRefitFilter(enabled: boolean): void; + getGalleryState(): ShipGalleryViewState | null; + setGalleryState(state: ShipGalleryViewState): void; activeSlotDescription(): string; selectedShips(): readonly ShipLibraryShip[]; assignShip(ship: ShipLibraryShip): void; @@ -44,6 +47,8 @@ export class FleetGalleryView extends ShipGalleryView { }, { getRefitFilter: () => host.getRefitFilter(), setRefitFilter: enabled => host.setRefitFilter(enabled), + getGalleryState: () => host.getGalleryState(), + setGalleryState: state => host.setGalleryState(state), activeSlotDescription: () => host.activeSlotDescription(), isExcluded: ship => ( host.selectedShips().some(selected => selected.id === ship.id) diff --git a/src/view/plan/FleetPlannerView.ts b/src/view/plan/FleetPlannerView.ts index 16ad3c5..112a2d9 100644 --- a/src/view/plan/FleetPlannerView.ts +++ b/src/view/plan/FleetPlannerView.ts @@ -7,6 +7,7 @@ import type { FleetDraftViewObject, FleetShipLibraryViewObject, + ShipGalleryViewState, TeamPlanListViewObject, } from '../../types/view.js'; import type { @@ -37,6 +38,8 @@ export interface FleetPlannerViewHost { }>; getRefitFilter(): boolean; setRefitFilter(enabled: boolean): void; + getGalleryState(): ShipGalleryViewState | null; + setGalleryState(state: ShipGalleryViewState): void; getBackupFollowMode(): BackupFollowMode; setBackupFollowMode(mode: BackupFollowMode): void; currentDraft(): FleetDraftViewObject; @@ -74,6 +77,8 @@ export class FleetPlannerView { this.galleryView = new FleetGalleryView({ getRefitFilter: () => this.host.getRefitFilter(), setRefitFilter: enabled => this.host.setRefitFilter(enabled), + getGalleryState: () => this.host.getGalleryState(), + setGalleryState: state => this.host.setGalleryState(state), activeSlotDescription: () => ( this.editorView.activeSlotDescription() ), diff --git a/src/view/plan/FleetPresetView.ts b/src/view/plan/FleetPresetView.ts index 9f4b392..71cb41d 100644 --- a/src/view/plan/FleetPresetView.ts +++ b/src/view/plan/FleetPresetView.ts @@ -7,6 +7,7 @@ import type { TeamPlanViewObject, } from '../../types/view.js'; import type { BathRepairConfig } from '../../types/model.js'; +import { findShipLibraryShip } from '../../shared/shipLibrary.js'; import { appendTeamPlanCardContent, filterAndSortTeamPlans, @@ -25,6 +26,7 @@ import { createShipArtwork } from './ShipArtwork'; interface ShipPreviewRule { name: string; + searchName?: string; minLevel?: number; maxLevel?: number; } @@ -475,12 +477,14 @@ export class FleetPresetView { primary: slot?.primary ? { name: slot.primary.name, + searchName: slot.primary.searchName, minLevel: slot.primary.minLevel, maxLevel: slot.primary.maxLevel, } : null, backups: (slot?.candidates ?? []).map(candidate => ({ name: candidate.name, + searchName: candidate.searchName, minLevel: candidate.minLevel, maxLevel: candidate.maxLevel, })), @@ -521,14 +525,19 @@ export class FleetPresetView { return card; } - card.title = name; - card.dataset['name'] = name; - const ship = this.findShip(name); + const ship = findShipLibraryShip(this.shipLibrary?.ships ?? [], { + name, + searchName: rule?.searchName, + }); if (ship) { - card.append(createShipArtwork( - ship, - this.shipLibrary?.labels.ship_types[ship.ship_type] ?? ship.ship_type, - )); + card.append(createShipArtwork(ship, { + shipTypeLabel: + this.shipLibrary?.labels.ship_types[ship.ship_type] ?? ship.ship_type, + showNumber: false, + showName: true, + displayName: name, + nameStyle: size === 'backup' ? 'plain' : 'rarity', + })); } else { const unknown = document.createElement('span'); unknown.className = 'fleet-team-card-empty fleet-team-card-unknown'; @@ -562,11 +571,4 @@ export class FleetPresetView { return summary; } - private findShip( - name: string, - ): FleetShipLibraryViewObject['ships'][number] | undefined { - return this.shipLibrary?.ships.find(ship => ship.name === name) - ?? this.shipLibrary?.ships.find(ship => ship.search_name === name); - } - } diff --git a/src/view/plan/ShipArtwork.ts b/src/view/plan/ShipArtwork.ts index 955a71b..d313a2f 100644 --- a/src/view/plan/ShipArtwork.ts +++ b/src/view/plan/ShipArtwork.ts @@ -1,18 +1,37 @@ -/** 创建舰船立绘元素并处理资源路径和加载失败回退。 */ /** * 舰娘图鉴展示框。 * - * 舰队规划、编队预览和决战图鉴共用同一套图片层级。 - * 背景、立绘、边框、编号、舰型图标和名称的样式由舰队规划样式统一维护。 + * 背景、立绘、边框和舰型图标是所有场景共用的基础图层。 + * 编号和舰名按页面需要开启,业务标记与交互由页面外层负责。 */ import type { ShipLibraryShip } from '../../types/ipc.js'; +export type ShipArtworkNameStyle = 'rarity' | 'plain'; + +export interface ShipArtworkOptions { + readonly shipTypeLabel?: string; + readonly showNumber?: boolean; + readonly showName?: boolean; + readonly displayName?: string; + readonly nameStyle?: ShipArtworkNameStyle; +} + +/** 创建共用舰船图片层,可按场景关闭编号和舰名。 */ export function createShipArtwork( ship: ShipLibraryShip, - shipTypeLabel = ship.ship_type, + options: ShipArtworkOptions = {}, ): HTMLSpanElement { + const { + shipTypeLabel = ship.ship_type, + showNumber = true, + showName = true, + displayName = ship.name, + nameStyle = 'rarity', + } = options; const artwork = document.createElement('span'); artwork.className = 'fleet-ship-artwork'; + artwork.dataset['shipName'] = ship.name; + artwork.dataset['searchName'] = ship.search_name; if (ship.backgroundUrl) { const background = document.createElement('img'); @@ -45,10 +64,12 @@ export function createShipArtwork( artwork.append(frame); } - const number = document.createElement('span'); - number.className = 'fleet-ship-number'; - number.textContent = `No.${String(ship.id).padStart(3, '0')}`; - artwork.append(number); + if (showNumber) { + const number = document.createElement('span'); + number.className = 'fleet-ship-number'; + number.textContent = `No.${String(ship.id).padStart(3, '0')}`; + artwork.append(number); + } if (ship.typeIconUrl) { const typeIcon = document.createElement('img'); @@ -60,14 +81,21 @@ export function createShipArtwork( artwork.append(typeIcon); } - const name = document.createElement('span'); - name.className = 'fleet-ship-name'; - const nameText = document.createElement('strong'); - nameText.className = `fleet-ship-name-text rarity-${ - Math.max(1, Math.min(6, ship.rarity)) - }`; - nameText.textContent = ship.name; - name.append(nameText); - artwork.append(name); + if (showName) { + const name = document.createElement('span'); + name.className = [ + 'fleet-ship-name', + nameStyle === 'plain' ? 'is-plain' : '', + ].filter(Boolean).join(' '); + const nameText = document.createElement('strong'); + nameText.className = nameStyle === 'plain' + ? 'fleet-ship-name-text' + : `fleet-ship-name-text rarity-${ + Math.max(1, Math.min(6, ship.rarity)) + }`; + nameText.textContent = displayName; + name.append(nameText); + artwork.append(name); + } return artwork; } diff --git a/src/view/plan/ShipGalleryView.ts b/src/view/plan/ShipGalleryView.ts index 6f943c6..afab3ad 100644 --- a/src/view/plan/ShipGalleryView.ts +++ b/src/view/plan/ShipGalleryView.ts @@ -3,6 +3,9 @@ import type { ShipLibraryLabels, ShipLibraryShip, } from '../../types/ipc.js'; +import type { + ShipGalleryViewState, +} from '../../types/view.js'; import { SHIP_TYPE_FILTER_ORDER, TYPE_LABELS, @@ -60,6 +63,8 @@ export interface ShipGalleryViewHost { activeSlotDescription(): string; isExcluded(ship: ShipLibraryShip): boolean; assignShip(ship: ShipLibraryShip): void; + getGalleryState?(): ShipGalleryViewState | null; + setGalleryState?(state: ShipGalleryViewState): void; shipDisplayName?(ship: ShipLibraryShip): string; getRefitFilter?(): boolean; setRefitFilter?(enabled: boolean): void; @@ -85,13 +90,32 @@ export class ShipGalleryView { private sortField: GallerySortField = 'id'; private descending = false; private dragScrollTop: number | null = null; + private restoredScrollPosition: { top: number; left: number } | null = null; + private restoredRenderedShipCount = 0; constructor( private readonly elements: ShipGalleryElements, private readonly host: ShipGalleryViewHost, ) { - this.refitOnly = this.host.getRefitFilter?.() ?? false; + const restoredState = this.host.getGalleryState?.() ?? null; + if (restoredState) { + this.elements.searchInput.value = restoredState.searchText; + this.groupFilter = restoredState.groupFilter; + this.typeFilters = new Set(restoredState.typeFilters); + this.countryFilters = new Set(restoredState.countryFilters); + this.sortField = restoredState.sortField; + this.descending = restoredState.descending; + this.restoredScrollPosition = { + top: restoredState.scrollTop, + left: restoredState.scrollLeft, + }; + this.restoredRenderedShipCount = restoredState.renderedShipCount; + } + this.refitOnly = restoredState?.refitOnly + ?? this.host.getRefitFilter?.() + ?? false; this.elements.refitFilter.checked = this.refitOnly; + this.elements.sortDescending.checked = this.descending; this.bindActions(); this.resizeObserver = new ResizeObserver( () => this.ensureGalleryFilled(), @@ -119,6 +143,7 @@ export class ShipGalleryView { } showLibrary(library: ShipGalleryLibrary): void { + const preserveCurrentPosition = this.shipItems.length > 0; this.labels = { ...EMPTY_LABELS, ...library.labels, @@ -126,7 +151,7 @@ export class ShipGalleryView { this.shipItems = [...library.ships]; this.backgroundUrl = library.colorfulBackgroundUrl ?? ''; this.renderFilterOptions(); - this.render(); + this.render(!preserveCurrentPosition); } clearLibrary(): void { @@ -178,6 +203,7 @@ export class ShipGalleryView { dispose(): void { if (this.disposed) return; this.disposed = true; + this.persistState(); this.eventController.abort(); this.resizeObserver.disconnect(); } @@ -234,6 +260,7 @@ export class ShipGalleryView { - gallery.scrollTop - gallery.clientHeight; if (remaining < 480) this.appendGalleryBatch(); + this.persistState(); }, listenerOptions); searchInput.addEventListener( @@ -442,12 +469,21 @@ export class ShipGalleryView { private applyFilters(resetScroll: boolean): void { const gallery = this.elements.gallery; const preservedScroll = captureScrollPosition(gallery); + const restoringSavedPosition = resetScroll + && this.restoredScrollPosition !== null; if (!resetScroll && this.dragScrollTop !== null) { preservedScroll.top = this.dragScrollTop; } - const previousRenderedCount = resetScroll - ? 0 - : this.renderedShipCount; + const targetScrollPosition = restoringSavedPosition + ? this.restoredScrollPosition! + : resetScroll + ? { top: 0, left: 0 } + : preservedScroll; + const previousRenderedCount = restoringSavedPosition + ? this.restoredRenderedShipCount + : resetScroll + ? 0 + : this.renderedShipCount; this.visibleShips = filterAndSortGalleryShips(this.shipItems, { searchText: this.elements.searchInput.value, typeFilters: this.typeFilters, @@ -468,16 +504,28 @@ export class ShipGalleryView { ? this.host.unavailableMessage ?? '没有符合当前条件的舰娘' : '没有符合当前条件的舰娘', ); + restoreScrollPosition(gallery, { top: 0, left: 0 }); + this.restoredScrollPosition = null; + this.restoredRenderedShipCount = 0; + this.persistState(); return; } this.appendGalleryBatch(Math.max( this.galleryBatchSize(), previousRenderedCount, )); - restoreScrollPosition( - gallery, - resetScroll ? { top: 0, left: 0 } : preservedScroll, - ); + while ( + restoringSavedPosition + && this.renderedShipCount < this.visibleShips.length + && gallery.scrollHeight + < targetScrollPosition.top + gallery.clientHeight + ) { + this.appendGalleryBatch(); + } + restoreScrollPosition(gallery, targetScrollPosition); + this.restoredScrollPosition = null; + this.restoredRenderedShipCount = 0; + this.persistState(); } private galleryBatchSize(): number { @@ -515,7 +563,9 @@ export class ShipGalleryView { card.type = 'button'; card.className = 'fleet-ship-card'; card.dataset['shipId'] = String(ship.id); - card.append(createShipArtwork(ship, this.shipTypeDisplay(ship))); + card.append(createShipArtwork(ship, { + shipTypeLabel: this.shipTypeDisplay(ship), + })); this.updateCard(card, ship); return card; } @@ -538,6 +588,21 @@ export class ShipGalleryView { : `显示 ${this.visibleShips.length} / ${this.shipItems.length} 艘`; } + private persistState(): void { + this.host.setGalleryState?.({ + searchText: this.elements.searchInput.value, + groupFilter: this.groupFilter, + typeFilters: [...this.typeFilters], + countryFilters: [...this.countryFilters], + refitOnly: this.refitOnly, + sortField: this.sortField, + descending: this.descending, + scrollTop: this.elements.gallery.scrollTop, + scrollLeft: this.elements.gallery.scrollLeft, + renderedShipCount: this.renderedShipCount, + }); + } + private showMessage(message: string): void { const empty = document.createElement('div'); empty.className = 'fleet-library-empty'; diff --git a/src/view/plan/TeamPlanLoaderView.ts b/src/view/plan/TeamPlanLoaderView.ts index b44e93c..6e8487e 100644 --- a/src/view/plan/TeamPlanLoaderView.ts +++ b/src/view/plan/TeamPlanLoaderView.ts @@ -2,6 +2,7 @@ import type { ShipLibraryShip, } from '../../types/ipc.js'; +import { findShipLibraryShip } from '../../shared/shipLibrary.js'; import type { TeamPlanListViewObject, TeamPlanShipRuleViewObject, @@ -355,23 +356,19 @@ export class TeamPlanLoaderView { return card; } - card.title = name; - card.dataset['name'] = name; const ships = this.host.ships(); - const ship = ships.find(item => item.name === name) - ?? ships.find(item => item.search_name === name) - ?? ( - rule?.searchName - ? ships.find( - item => item.search_name === rule.searchName, - ) - : undefined - ); + const ship = findShipLibraryShip(ships, { + name: rule?.name ?? name, + searchName: rule?.searchName, + }); if (ship) { - card.append(createShipArtwork( - ship, - this.host.shipTypeDisplay(ship), - )); + card.append(createShipArtwork(ship, { + shipTypeLabel: this.host.shipTypeDisplay(ship), + showNumber: false, + showName: true, + displayName: name, + nameStyle: size === 'backup' ? 'plain' : 'rarity', + })); } else { const unknown = document.createElement('span'); unknown.className = 'fleet-team-card-empty fleet-team-card-unknown'; diff --git a/src/view/styles/pages/main-page/_layout.scss b/src/view/styles/pages/main-page/_layout.scss index f82bfb7..3ec41d4 100644 --- a/src/view/styles/pages/main-page/_layout.scss +++ b/src/view/styles/pages/main-page/_layout.scss @@ -132,10 +132,6 @@ height: 100%; } -.current-fleet-card .fleet-ship-number { - display: none; -} - .current-fleet-card .fleet-ship-type-icon { top: 2%; right: 2%; diff --git a/src/view/styles/pages/plan/_fleet-preset.scss b/src/view/styles/pages/plan/_fleet-preset.scss index 2d0763f..f57131e 100644 --- a/src/view/styles/pages/plan/_fleet-preset.scss +++ b/src/view/styles/pages/plan/_fleet-preset.scss @@ -409,12 +409,6 @@ height: 100%; } -.fleet-team-main-card .fleet-ship-number { - top: 3%; - left: 4%; - font-size: clamp(7px, 1vw, 10px); -} - .fleet-team-main-card .fleet-ship-type-icon { top: 2%; right: 2%; @@ -492,35 +486,16 @@ border-radius: 4px; > .fleet-ship-artwork { - transform: scale(0.5); - transform-origin: left top; - } - - .fleet-ship-name { - display: none; - } - - &[data-name]::after { - content: attr(data-name); - position: absolute; - z-index: 20; - right: 4px; - bottom: 4px; - left: 4px; - display: none; - padding: 3px 6px; - color: #fff; - background: rgba(0, 0, 0, 0.9); - border: 1px solid rgba(255, 255, 255, 0.24); - border-radius: 4px; - font-size: 10px; - font-weight: 600; - text-align: center; - overflow-wrap: anywhere; + width: 100%; + height: 100%; } - &[data-name]:hover::after { - display: block; + .fleet-ship-type-icon { + top: 2%; + right: 2%; + width: 30%; + height: auto; + aspect-ratio: 1; } } diff --git a/src/view/styles/pages/plan/_ship-gallery.scss b/src/view/styles/pages/plan/_ship-gallery.scss index d835a24..eb2b2cb 100644 --- a/src/view/styles/pages/plan/_ship-gallery.scss +++ b/src/view/styles/pages/plan/_ship-gallery.scss @@ -452,6 +452,29 @@ white-space: nowrap; } +.fleet-ship-name.is-plain { + right: 4px; + bottom: 4px; + left: 4px; + min-height: 0; + padding: 3px 6px; + background: rgba(0, 0, 0, 0.9); + border-color: rgba(255, 255, 255, 0.24); + border-radius: 4px; + backdrop-filter: none; + + .fleet-ship-name-text { + color: #fff; + font-size: 10px; + font-weight: 600; + line-height: 1.2; + overflow-wrap: anywhere; + text-shadow: none; + -webkit-text-stroke: 0; + white-space: normal; + } +} + .rarity-1 { color: #fff; } diff --git a/src/view/styles/styles.css b/src/view/styles/styles.css index 305b641..f153795 100644 --- a/src/view/styles/styles.css +++ b/src/view/styles/styles.css @@ -1071,10 +1071,6 @@ input[type=checkbox]:focus-visible { height: 100%; } -.current-fleet-card .fleet-ship-number { - display: none; -} - .current-fleet-card .fleet-ship-type-icon { top: 2%; right: 2%; @@ -2699,12 +2695,6 @@ input[type=checkbox]:focus-visible { height: 100%; } -.fleet-team-main-card .fleet-ship-number { - top: 3%; - left: 4%; - font-size: clamp(7px, 1vw, 10px); -} - .fleet-team-main-card .fleet-ship-type-icon { top: 2%; right: 2%; @@ -2782,32 +2772,15 @@ input[type=checkbox]:focus-visible { border-radius: 4px; } .fleet-team-backup-card > .fleet-ship-artwork { - transform: scale(0.5); - transform-origin: left top; -} -.fleet-team-backup-card .fleet-ship-name { - display: none; -} -.fleet-team-backup-card[data-name]::after { - content: attr(data-name); - position: absolute; - z-index: 20; - right: 4px; - bottom: 4px; - left: 4px; - display: none; - padding: 3px 6px; - color: #fff; - background: rgba(0, 0, 0, 0.9); - border: 1px solid rgba(255, 255, 255, 0.24); - border-radius: 4px; - font-size: 10px; - font-weight: 600; - text-align: center; - overflow-wrap: anywhere; + width: 100%; + height: 100%; } -.fleet-team-backup-card[data-name]:hover::after { - display: block; +.fleet-team-backup-card .fleet-ship-type-icon { + top: 2%; + right: 2%; + width: 30%; + height: auto; + aspect-ratio: 1; } .fleet-team-card-empty { @@ -5861,6 +5834,28 @@ input[type=checkbox]:focus-visible { white-space: nowrap; } +.fleet-ship-name.is-plain { + right: 4px; + bottom: 4px; + left: 4px; + min-height: 0; + padding: 3px 6px; + background: rgba(0, 0, 0, 0.9); + border-color: rgba(255, 255, 255, 0.24); + border-radius: 4px; + backdrop-filter: none; +} +.fleet-ship-name.is-plain .fleet-ship-name-text { + color: #fff; + font-size: 10px; + font-weight: 600; + line-height: 1.2; + overflow-wrap: anywhere; + text-shadow: none; + -webkit-text-stroke: 0; + white-space: normal; +} + .rarity-1 { color: #fff; } From bc06613df70ebb5a611e3e532f679299cfe9597a Mon Sep 17 00:00:00 2001 From: ShiinaKuroko <208154746+ShiinaKuroko@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:47:46 +0800 Subject: [PATCH 65/82] chore(release): bump version to 2.0.16-alpha --- build/backend-distribution.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/build/backend-distribution.json b/build/backend-distribution.json index 917a748..d6f9d09 100644 --- a/build/backend-distribution.json +++ b/build/backend-distribution.json @@ -2,6 +2,6 @@ "id": "alpha", "repository": "ShiinaKuroko/AutoWSGR", "ref": "ShiinaKuroko", - "commit": "2f5631491453cd45bdbd6ea77c904a3a0e05b74d", + "commit": "77f34b7b30d18f7b86cf736bdd5cf17ae35d5f78", "forceUpdateOnInstall": true } diff --git a/package-lock.json b/package-lock.json index d3e58ff..6a23389 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wsgrgui", - "version": "2.0.15-alpha", + "version": "2.0.16-alpha", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wsgrgui", - "version": "2.0.15-alpha", + "version": "2.0.16-alpha", "dependencies": { "electron-updater": "^6.8.3", "js-yaml": "^4.1.0", diff --git a/package.json b/package.json index fea5329..4a073ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wsgrgui", - "version": "2.0.15-alpha", + "version": "2.0.16-alpha", "description": "AutoWSGR YAML 管理与自动化桌面界面", "main": "dist/electron/main.js", "scripts": { From e6090c41bb8ffe806a582d1527481f6d3dfd12b8 Mon Sep 17 00:00:00 2001 From: chenxuan Date: Wed, 12 Aug 2026 20:39:20 +0800 Subject: [PATCH 66/82] fix(window): focus main window after loading --- electron/services/WindowService.ts | 7 +++++++ .../main-services/test-configuration-and-window.js | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/electron/services/WindowService.ts b/electron/services/WindowService.ts index 879a01f..56fd843 100644 --- a/electron/services/WindowService.ts +++ b/electron/services/WindowService.ts @@ -182,6 +182,7 @@ export class WindowService { center: initialBounds === null, minWidth: MIN_WINDOW_WIDTH, minHeight: MIN_WINDOW_HEIGHT, + show: false, webPreferences: { preload: path.join( this.dependencies.moduleDirectory, @@ -236,6 +237,12 @@ export class WindowService { }, ); + win.once('ready-to-show', () => { + if (win.isDestroyed()) return; + win.show(); + win.focus(); + }); + win.loadFile(htmlPath).catch((error: Error) => { console.error('[Main] loadFile failed:', error); if (this.dependencies.isPackaged()) { diff --git a/scripts/tests/main-services/test-configuration-and-window.js b/scripts/tests/main-services/test-configuration-and-window.js index fecb1a3..4bf2259 100644 --- a/scripts/tests/main-services/test-configuration-and-window.js +++ b/scripts/tests/main-services/test-configuration-and-window.js @@ -67,11 +67,14 @@ function testWindowService() { let webContentsDestroyed = false; let sendFailsAsDestroyed = false; const sentMessages = []; + const windowActivationCalls = []; const windowHandlers = new Map(); const webContentsHandlers = new Map(); const fakeWindow = { isDestroyed: () => windowDestroyed, getNormalBounds: () => normalBounds, + show: () => windowActivationCalls.push('show'), + focus: () => windowActivationCalls.push('focus'), webContents: { isDestroyed: () => webContentsDestroyed, send: (...args) => { @@ -102,6 +105,9 @@ function testWindowService() { on: (event, handler) => { windowHandlers.set(event, handler); }, + once: (event, handler) => { + windowHandlers.set(event, handler); + }, }; const service = new WindowService(settings, { backendPort: 18438, @@ -130,6 +136,7 @@ function testWindowService() { assert.equal(createdOptions.x, 20); assert.equal(createdOptions.y, 30); assert.equal(createdOptions.center, false); + assert.equal(createdOptions.show, false); assert.equal( createdOptions.webPreferences.preload, path.join(temporaryDirectory, 'dist', 'electron', 'preload.js'), @@ -139,6 +146,10 @@ function testWindowService() { path.join(temporaryDirectory, 'app', 'src', 'view', 'index.html'), ); assert.equal(service.getMainWindow(), fakeWindow); + assert.equal(windowHandlers.has('ready-to-show'), true); + assert.deepEqual(windowActivationCalls, []); + windowHandlers.get('ready-to-show')(); + assert.deepEqual(windowActivationCalls, ['show', 'focus']); assert.equal( service.sendToRenderer('backend-log', 'backend running'), true, From 3391cc2b54fc824aa00a22d9ccebc58e17ef46d6 Mon Sep 17 00:00:00 2001 From: chenxuan Date: Thu, 13 Aug 2026 16:53:32 +0800 Subject: [PATCH 67/82] docs(agent): require upstream version check before packaging --- AGENTS.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 61c29ee..7f3b3ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,6 +183,35 @@ - Release commit 只含版本和发布元数据;Tag、`package.json`、`package-lock.json` 和产物版本必须一致,不得覆盖已有远程 Tag。 - 发布前必须完成构建、专项测试、安装包验证和实际启动;无法完成时不得宣称发布可用。 +### 8.4 主库版本协同与打包门禁 + +#### 仓库权限 + +- `origin` 是个人仓库,Agent 可以按用户明确指令提交、打包和发布;主库只允许读取、同步和提交 PR,禁止直接 push、创建 Tag 或发布 Release。 +- 主库发布版本是个人仓库编排版本号的外部基线,不代表 Agent 获得主库写入或发布权限。 + +#### 打包前版本核对 + +- 修改 `package.json` 或 `package-lock.json` 版本、执行 `npm run dist`、创建或推送 Tag、创建 GitHub Release 或上传安装包前,必须先完成主库版本核对。 +- 核对时必须读取个人仓库与主库的最新分支、Tags 和 Releases,并检查主库目标分支 `package.json` 的当前版本;未配置主库 remote 时,通过主库 GitHub 仓库执行只读核对,不得因此跳过。 +- Agent 必须先向用户列出主库最新稳定版、主库最新测试版、个人仓库当前版本、建议目标版本及理由;只有用户明确确认完整版本号和 `alpha`/稳定通道后,才能修改版本或正式打包。 +- 无法访问主库、Release 与 Tag 不一致、协作桥接版本尚未发布或版本归属不明确时,禁止猜测版本号,必须停止并请求用户确认。 + +#### 版本冲突保护 + +- Electron/npm 版本必须使用合法 SemVer `X.Y.Z-alpha.N`;`X.Y.Z.alpha.N` 仅可作为口头描述,不得写入版本文件或 Tag。 +- 不得发布与主库或个人仓库已有 Tag 完全相同的版本。主库已发布 `X.Y.Z` 稳定版后,不得再发布 `X.Y.Z-alpha.N`。 +- 从稳定版进入下一轮测试必须提升 patch,例如 `2.0.17 -> 2.0.18-alpha.0`。 +- 已进入维护者提供的协作桥接版本时,以其 Commit、Tag、`package.json` 和 `package-lock.json` 为唯一基线;同一功能内部迭代只递增最后一位,例如 `2.0.17-alpha.0 -> 2.0.17-alpha.1`。 +- 不得擅自补建、重命名或改变维护者确定的桥接版本、核心版本号和更新通道。 + +#### 打包与发布边界 + +- `npm run build`、专项测试和开发启动不属于正式打包,不要求修改版本号。 +- 用户只说“打包”时,只生成个人仓库的本地安装包,不得自动创建 Tag、Release、上传产物或向主库提交版本变更。 +- 用户只说“push”时,不得自动修改版本、打包或触发发布。 +- 版本提交必须独立于功能提交;Tag、`package.json`、`package-lock.json` 和安装包版本必须完全一致。 + ## 9. Windows 打包 - 正式 Windows 产物使用 `npm run dist` 生成 NSIS 安装程序;`npm run pack` 的 unpacked 目录不是“单 EXE”交付物。 From 77e2085f228d2b252e80d6dd568dba6a32bb50c8 Mon Sep 17 00:00:00 2001 From: chenxuan Date: Thu, 13 Aug 2026 21:02:44 +0800 Subject: [PATCH 68/82] docs(agent): define fork branch responsibilities --- AGENTS.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7f3b3ba..2ea2d14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,18 +163,19 @@ - 未经明确要求,不得执行 `git reset --hard`、`git checkout -- `、强制清理、历史重写、`--no-verify` 或无条件 `git push --force`。 - 不得 `git add .`;按逻辑变更显式暂存。分支使用语义前缀,Commit 使用 Conventional Commits,一个 commit 对应一个可独立审查的逻辑变更。 -### 8.2 ShiinaKuroko Fork +### 8.2 个人 Fork 分支职责 - 路径以仓库为基准:GUI 为当前仓库根目录,后端默认是同级 `../AutoWSGR`;不得固化某台机器的绝对路径。 - 当前仓库是个人 Fork。`main` 只用于同步 `yltx/AutoWSGR-GUI:alpha`,不直接开发功能;同步上游 `alpha` 后再更新个人 Fork 的 `origin/main`。 -- `ShiinaKuroko` 是主力开发分支,代表个人 Fork 的最新有效代码。功能实现、正式自动化测试和必要生成入口必须先合入该分支并完成验证,再由该分支执行普通 push。 +- `ShiinaSakuya` 是日常主力开发分支,代表个人 Fork 的最新开发代码。所有新功能和 Bug Fix 必须基于该分支开发、验证并 push;正式自动化测试和必要生成入口必须随对应代码一并进入该分支。 +- `ShiinaKuroko` 是主库 Stable 升级协作专用分支,只用于维护者明确要求的主库稳定版基线同步、升级适配、兼容处理和稳定版发布准备。不得在该分支直接进行日常新功能或 Bug Fix 开发,也不得把无关开发提交 push 到该分支。 - 未经维护者事先明确允许,不得创建任何本地或远程分支、备份分支、worktree 或发布克隆;不得以隔离脏工作树、备份、测试、打包或发布为理由自行创建。 - 只有维护者明确要求并批准 PR 时,才允许创建对应 PR 分支;格式为 `feat/<功能>-PR` 或 `fix/<功能>-PR`,Git 分支名不得使用反斜杠。创建下一条 PR 前必须检查上一条临时 PR 的状态,确认代码已经合入目标分支后,删除对应的本地和远端 PR 分支;未合入或状态不明确时不得删除。 - GitHub 网络操作前读取系统当前代理设置;只允许命令级临时代理,不得硬编码端口或修改全局 Git 配置。 -- 每次 push `ShiinaKuroko` 前必须先 `git fetch origin`。本地落后或出现分叉时,先审查并整合远端提交,不得用强推覆盖远端代码。 -- 只有维护者明确要求并批准时,才以当前 `origin/ShiinaKuroko` 为指针创建远端不可移动备份 `backup/YYYYMMDD-`;不得擅自创建、移动、复用或删除 backup 分支。 +- 每次 push `ShiinaSakuya` 或 `ShiinaKuroko` 前必须先 `git fetch origin`。本地落后或出现分叉时,先审查并整合远端提交,不得用强推覆盖远端代码。 +- 只有维护者明确要求并批准时,才以对应目标分支的当前远端指针创建不可移动备份 `backup/YYYYMMDD-`;不得擅自创建、移动、复用或删除 backup 分支。 - 普通 push 禁止 `--force` 和 `--force-with-lease`。只有用户明确授权历史改写、已说明将被替换的远端提交且备份完成时,才允许使用 `--force-with-lease`。 -- push 完成后检查其他本地工作分支:只有确认有效提交已进入 `ShiinaKuroko`、没有独有未推送提交、没有未提交修改且未被 worktree 使用时才能删除。不得批量强删;保留 `main`、`ShiinaKuroko` 和仍未合入的活动 PR 分支。 +- push 完成后检查其他本地工作分支:只有确认有效提交已进入对应目标分支、没有独有未推送提交、没有未提交修改且未被 worktree 使用时才能删除。不得批量强删;保留 `main`、`ShiinaSakuya`、`ShiinaKuroko` 和仍未合入的活动 PR 分支。 ### 8.3 Alpha 与正式发布 From 5c538433f598ea6f4d352ffcfc188e68283d8e5c Mon Sep 17 00:00:00 2001 From: chenxuan Date: Thu, 13 Aug 2026 21:08:33 +0800 Subject: [PATCH 69/82] docs(review): record PR 1 update bridge findings --- .../2026-08-13-pr1-update-bridge-review.md | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/reviews/2026-08-13-pr1-update-bridge-review.md diff --git a/docs/reviews/2026-08-13-pr1-update-bridge-review.md b/docs/reviews/2026-08-13-pr1-update-bridge-review.md new file mode 100644 index 0000000..0c6af95 --- /dev/null +++ b/docs/reviews/2026-08-13-pr1-update-bridge-review.md @@ -0,0 +1,195 @@ +# PR #1 更新桥接审查交接 + +## 结论 + +PR #1 `feat: add 2.1 Alpha-to-Stable update bridge` 当前不能直接合并。 + +主要原因不是功能方向错误,而是 PR 分支基线与当前仓库错位,并且混入了两个应独立处理的功能范围: + +1. 1.4.3 计划降级备份,属于日常功能开发,应基于 `ShiinaSakuya` 单独处理。 +2. Alpha 到 Stable 更新桥接,属于主库 Stable 升级协作,应基于 `ShiinaKuroko` 单独处理。 + +本次审查没有合并、cherry-pick 或修改 PR 代码。 + +## PR 元数据 + +- PR:`ShiinaKuroko/AutoWSGR-GUI#1` +- 标题:`feat: add 2.1 Alpha-to-Stable update bridge` +- 目标分支:`ShiinaKuroko` +- 来源分支:`feat/2.1-update-bridge-PR` +- Head:`f7bf2f27b62ad4818ee8b7b1b01e70be870800c8` +- GitHub 统计:43 个提交、1739 个文件、增加 149111 行、删除 24597 行 +- 初次检查状态:`mergeable=false / dirty` +- 后续 API 检查状态:`mergeable=null / unknown` + +1739 个文件不是本功能的真实改动范围,而是分支历史与目标分支没有正确对齐造成的差异。 + +## 真实功能范围 + +PR 顶部 3 笔提交是本次需要交接的实际功能改动: + +| 提交 | 内容 | 文件 | 增加 | 删除 | +| --- | --- | ---: | ---: | ---: | +| `6f839ca` | 导出 1.4.3 降级计划备份 | 11 | 308 | 2 | +| `5fa6cb4` | 2.0 Alpha 到 2.1 Stable 更新桥接 | 21 | 525 | 79 | +| `f7bf2f2` | 分离发布验证与实际发布 | 2 | 24 | 5 | + +合并统计为 32 个唯一文件、增加 852 行、删除 81 行。 + +### 发布与构建 + +- `.github/workflows/release.yml` +- `build/electron-builder.release.cjs`(新增) +- `build/electron-builder.stable.cjs`(删除) +- `package.json` +- `package-lock.json` + +### Electron 主进程 + +- `electron/main.ts` +- `electron/preload.ts` +- `electron/ipc/CombatPlanIpc.ts` +- `electron/ipc/ConfigurationIpc.ts` +- `electron/ipc/UpdaterIpc.ts` +- `electron/services/GuiConfigurationService.ts` +- `electron/services/GuiUpdatePolicy.ts` +- `electron/services/PlanExportService.ts` + +### Renderer + +- `src/adapter/IpcAdapter.ts` +- `src/controller/app/ConfigController.ts` +- `src/controller/plan/PlanManagementController.ts` +- `src/types/ipc.ts` +- `src/types/view.ts` +- `src/view/config/ConfigView.ts` +- `src/view/plan/PlanManagementView.ts` +- `src/view/html/pages/config/system.html` +- `src/view/html/pages/plan/management.html` +- `src/view/index.html`(构建生成入口) + +### 测试 + +- `scripts/tests/main-services/test-configuration-and-window.js` +- `scripts/tests/main-services/test-plan-export.js` +- `scripts/tests/main-services/test-updater-and-shutdown.js` +- `scripts/tests/test-backend-distribution.js` +- `scripts/tests/test-release-package.js` + +### 文档 + +- `docs/architecture/07-environment-management.md` +- `docs/architecture/08-dev-setup.md` +- `docs/architecture/13-release-version-governance.md`(新增) +- `docs/architecture/README.md` + +## 功能评估 + +### 1.4.3 计划降级备份 + +新增了从计划管理页面选择用户出征计划并导出 ZIP 的完整链路: + +`PlanManagementView -> PlanManagementController -> IpcAdapter -> preload -> CombatPlanIpc -> PlanExportService` + +导出包包含: + +- `plans/`:转换后的 1.4.3 兼容出征计划。 +- `original_2.0/user_battle_plans/`:未经转换的原始计划。 +- `恢复说明.txt`:恢复步骤和备份范围。 + +当前转换只处理出征计划,不导出舰队计划。它将舰船槽位的 `candidates` 转为 `priority`,并移除 `search_name` 和 `relaxed`。功能边界相对独立,但需要单独确认 1.4.3 的实际读取兼容性。 + +### 自动更新设置 + +“允许测试版更新”开关的调用链完整: + +`system.html -> ConfigView -> ConfigController -> ConfigurationGateway -> preload -> ConfigurationIpc -> GuiConfigurationService` + +配置写入 `allow_test_updates`。Stable 默认只接收 `latest`;开启后选择 `alpha`,候选校验允许 `latest` 和 `alpha`。Beta 和 Dev 保持自己的频道。 + +### Release workflow + +workflow 扩展为同时支持: + +- `X.Y.Z` Stable,对应 `latest`。 +- `X.Y.Z-alpha.N` Alpha,对应 `alpha`。 +- 发布前目标仓库和版本检查。 +- Stable 发布时附带 Alpha 兼容资产。 +- 向个人仓库和 `yltx/AutoWSGR-GUI` 创建、发布 Release。 + +## 阻断问题 + +### 1. 分支基线错位 + +PR 必须先基于正确目标分支重建。不能在当前 43 个提交、1739 个文件的差异上解决冲突后直接合并,否则无法可靠区分功能代码和旧分支历史。 + +### 2. 版本方案与当前规则不一致 + +PR 把 `package.json` 和 `package-lock.json` 改为 `2.1.0-alpha.1`,并在 workflow 中硬编码 `2.0.16-alpha -> 2.1.0-alpha.1 -> 2.1.0`。 + +当前项目确认的协作桥接规则是以维护者提供的版本为基线,只递增最后一位;本轮使用 `2.0.17-alpha.* -> 2.0.17`。不得直接采用 PR 中的 `2.1.0` 版本变更。重新处理前仍须执行 `AGENTS.md` 规定的双仓库版本与 Release 检查,并取得用户明确确认。 + +### 3. 越过主库发布权限边界 + +workflow 使用 `LEGACY_RELEASE_TOKEN` 直接在 `yltx/AutoWSGR-GUI`: + +- 检查 Release。 +- 创建 Draft Release。 +- 上传安装包和更新清单。 +- 将 Release 从 Draft 改为正式发布。 + +这不符合当前仓库规则:主库只允许读取、同步和提交 PR,禁止直接 push、创建 Tag 或发布 Release。相关步骤必须移除,或改为只生成并校验交付物,由主库维护者在主库侧发布。 + +### 4. Alpha 到 Stable 的自动桥接尚未成立 + +Stable workflow 下载旧 Alpha Release 的 `alpha.yml` 和旧 Alpha 安装包,再原样附加到 Stable Release。该 `alpha.yml` 仍描述旧 Alpha 版本,不会自然变成目标 Stable 版本的更新清单。 + +同时,预发布客户端默认 `allow_test_updates=true`,更新器选择 `alpha` 频道。`acceptedChannels=['latest', 'alpha']` 只参与下载结果校验,不会让 electron-updater 同时请求 `latest.yml`。因此 Alpha 客户端可能继续读取旧 `alpha.yml`,无法发现 Stable。 + +必须重新设计目标 Stable 的 Alpha 兼容清单,并用真实 Release feed 或可控模拟器验证完整升级链路。 + +### 5. 设置频道会重新允许降级 + +`UpdaterIpc.ts` 先执行: + +```ts +autoUpdater.allowDowngrade = false; +``` + +随后 `applyUpdatePolicy()` 设置 `autoUpdater.channel`。当前 `electron-updater` 的 `AppUpdater.channel` setter 会自动执行: + +```ts +this.allowDowngrade = true; +``` + +而且每次检查更新前都会再次设置频道。因此 PR 实际上重新开启了降级,与“不允许降级”的目标相反。应在每次设置频道后显式恢复 `autoUpdater.allowDowngrade = false`,并增加行为测试。 + +## 非阻断观察 + +- `恢复说明.txt` 写死“2.0.1 -> 1.4.3”,但 PR 包版本是 `2.1.0-alpha.1`,文案来源版本不一致。 +- 计划降级转换已有单元测试,但仍需要使用真实 1.4.3 程序读取导出文件,确认字段兼容性。 +- `src/view/index.html` 随 HTML partial 一起修改,符合生成入口需要提交的规则;重建分支后仍应通过 `npm run build` 重新生成,不能手工搬运。 +- 更新开关的 preload、IPC、Adapter、Controller 和配置持久化链路已经补齐,静态检查未发现缺失边界。 + +## 本次已完成工作 + +1. 建立隔离审查 worktree,固定 PR head `f7bf2f2`,未污染开发工作区。 +2. 核对 PR 元数据、提交历史、真实文件范围和逐提交统计。 +3. 静态审查计划导出、更新策略、配置 IPC、打包配置和 Release workflow。 +4. 对顶部 3 笔提交执行 `git diff --check`,结果通过。 +5. 确认 `electron-updater` 当前依赖实现中,设置 `channel` 会自动开启 `allowDowngrade`。 +6. 创建远端长期分支 `ShiinaSakuya`,基线来自当时的 `ShiinaKuroko@3391cc2`。 +7. 更新 `AGENTS.md` 的分支职责,并以提交 `77e2085` 同时推送到 `ShiinaSakuya` 和 `ShiinaKuroko`。 +8. 未合并、cherry-pick、修改或关闭 PR #1。 + +本交接不声明 PR 自动化测试已经通过。由于原 PR 基线无效,应在拆分并重建提交后重新运行对应测试。 + +## 建议处理顺序 + +1. 从最新 `ShiinaSakuya` 单独重建“1.4.3 计划降级备份”功能提交。 +2. 从最新 `ShiinaKuroko` 单独重建“Stable 更新桥接”提交。 +3. 删除或重写直接发布 `yltx/AutoWSGR-GUI` 的 workflow 步骤。 +4. 按用户确认的版本基线重做版本与频道配置,不沿用硬编码的 `2.1.0-alpha.1`。 +5. 修复 `allowDowngrade` 被频道 setter 重新开启的问题。 +6. 重新设计 Alpha 兼容清单,并完成真实升级链路验证。 +7. 分别运行直接相关的 main service、renderer contract、build 和 release package 测试。 From 73c355ab356c52d366649c29fc4868073b47cd16 Mon Sep 17 00:00:00 2001 From: chenxuan Date: Thu, 13 Aug 2026 21:27:25 +0800 Subject: [PATCH 70/82] docs(review): keep PR 1 changes off Sakuya --- docs/reviews/2026-08-13-pr1-update-bridge-review.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/reviews/2026-08-13-pr1-update-bridge-review.md b/docs/reviews/2026-08-13-pr1-update-bridge-review.md index 0c6af95..9aeb7ef 100644 --- a/docs/reviews/2026-08-13-pr1-update-bridge-review.md +++ b/docs/reviews/2026-08-13-pr1-update-bridge-review.md @@ -4,10 +4,12 @@ PR #1 `feat: add 2.1 Alpha-to-Stable update bridge` 当前不能直接合并。 -主要原因不是功能方向错误,而是 PR 分支基线与当前仓库错位,并且混入了两个应独立处理的功能范围: +主要原因不是功能方向错误,而是 PR 分支基线与当前仓库错位,并且混入了两个应独立审查的功能范围: -1. 1.4.3 计划降级备份,属于日常功能开发,应基于 `ShiinaSakuya` 单独处理。 -2. Alpha 到 Stable 更新桥接,属于主库 Stable 升级协作,应基于 `ShiinaKuroko` 单独处理。 +1. 1.4.3 计划降级备份。 +2. Alpha 到 Stable 更新桥接。 + +本 PR 的任何功能提交都不得合入 `ShiinaSakuya`;如需处理,必须统一在 `ShiinaKuroko` 上逐笔审查、解决冲突和验证。Sakuya 仅保留本审查交接文档。 本次审查没有合并、cherry-pick 或修改 PR 代码。 @@ -186,8 +188,8 @@ this.allowDowngrade = true; ## 建议处理顺序 -1. 从最新 `ShiinaSakuya` 单独重建“1.4.3 计划降级备份”功能提交。 -2. 从最新 `ShiinaKuroko` 单独重建“Stable 更新桥接”提交。 +1. 以最新 `ShiinaKuroko` 为唯一目标,逐笔处理 3 个真实功能提交;不得将其中任何代码合入 `ShiinaSakuya`。 +2. 分别审查“1.4.3 计划降级备份”和“Stable 更新桥接”,避免把两个功能范围作为一个整体验收。 3. 删除或重写直接发布 `yltx/AutoWSGR-GUI` 的 workflow 步骤。 4. 按用户确认的版本基线重做版本与频道配置,不沿用硬编码的 `2.1.0-alpha.1`。 5. 修复 `allowDowngrade` 被频道 setter 重新开启的问题。 From e126e606b673b9960f82b08c9e44d4a8fd97a565 Mon Sep 17 00:00:00 2001 From: yltx <2326439151@qq.com> Date: Thu, 13 Aug 2026 16:51:46 +0800 Subject: [PATCH 71/82] feat(plans): export 1.4.3 downgrade backups --- electron/ipc/CombatPlanIpc.ts | 27 ++++++ electron/main.ts | 1 + electron/preload.ts | 4 + electron/services/PlanExportService.ts | 97 +++++++++++++++++++ .../tests/main-services/test-plan-export.js | 62 +++++++++++- src/adapter/IpcAdapter.ts | 5 + .../plan/PlanManagementController.ts | 30 ++++++ src/types/ipc.ts | 3 + src/view/html/pages/plan/management.html | 5 + src/view/index.html | 5 + src/view/plan/PlanManagementView.ts | 36 +++++++ 11 files changed, 273 insertions(+), 2 deletions(-) diff --git a/electron/ipc/CombatPlanIpc.ts b/electron/ipc/CombatPlanIpc.ts index 1679217..98b7a40 100644 --- a/electron/ipc/CombatPlanIpc.ts +++ b/electron/ipc/CombatPlanIpc.ts @@ -72,6 +72,33 @@ export function registerCombatPlanIpc( } }); + ipc.handle('export-legacy-143-plans', async (_event, selections: unknown) => { + try { + const archive = await dependencies.planExports.createLegacy143Archive( + selections, + ); + const selected = await dependencies.dialog.showSaveDialog({ + title: '导出 1.4.3 降级计划备份', + defaultPath: 'AutoWSGR-GUI-1.4.3-plans-backup.zip', + filters: [{ name: 'ZIP 压缩包', extensions: ['zip'] }], + }); + if (selected.canceled || !selected.filePath) { + return { success: false, canceled: true }; + } + dependencies.planExports.writeArchive(selected.filePath, archive); + return { + success: true, + path: selected.filePath, + count: archive.count, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + }); + ipc.handle('import-local-combat-plan', async () => { try { const selected = await dependencies.dialog.showOpenDialog({ diff --git a/electron/main.ts b/electron/main.ts index e640820..a1e3c56 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -202,6 +202,7 @@ const planExportService = new PlanExportService( combatPlanRepository, teamPlanRepository, atomicFileStore, + combatPlanCodec, ); const shipLibraryService = new ShipLibraryService(appPaths, { processId: process.pid, diff --git a/electron/preload.ts b/electron/preload.ts index e4945be..a802de2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -229,6 +229,10 @@ const electronBridge = { return ipcRenderer.invoke('export-user-plans', selections); }, + exportLegacy143Plans: ( + selections: Array<{ kind: 'battle' | 'team'; file: string }>, + ) => ipcRenderer.invoke('export-legacy-143-plans', selections), + importLocalCombatPlan: () => { return ipcRenderer.invoke('import-local-combat-plan'); }, diff --git a/electron/services/PlanExportService.ts b/electron/services/PlanExportService.ts index 5dd451a..01c9e81 100644 --- a/electron/services/PlanExportService.ts +++ b/electron/services/PlanExportService.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import JSZip from 'jszip'; import type { CombatPlanRepository } from './CombatPlanRepository'; import type { TeamPlanRepository } from './TeamPlanRepository'; +import type { CombatPlanCodec } from './CombatPlanCodec'; import { AtomicFileStore } from './AtomicFileStore'; export interface UserPlanExportSelection { @@ -31,6 +32,7 @@ export class PlanExportService { private readonly combatRepository: CombatPlanRepository, private readonly teamRepository: TeamPlanRepository, private readonly atomicFiles: AtomicFileStore, + private readonly combatCodec?: CombatPlanCodec, ) {} /** 使用本地日期生成导出文件名。 */ @@ -41,6 +43,66 @@ export class PlanExportService { return `${year}-${month}-${day}-plans.zip`; } + /** 生成供 1.4.3 恢复的计划包,同时保留未经转换的 2.0 原文件。 */ + async createLegacy143Archive( + rawSelections: unknown, + ): Promise { + if (!this.combatCodec) { + throw new Error('当前环境未启用 1.4.3 兼容导出'); + } + const selections = this.normalizeSelections(rawSelections).filter( + selection => selection.kind === 'battle', + ); + if (selections.length === 0) { + throw new Error('请至少选择一个用户出征计划'); + } + const zip = new JSZip(); + zip.folder('plans'); + zip.folder('original_2.0/user_battle_plans'); + selections.forEach(selection => { + const filePath = this.resolveUserPlan(selection); + const originalContent = fs.readFileSync(filePath, 'utf-8'); + const root = this.combatCodec!.parseRoot( + originalContent, + `无效的出征计划:${selection.file}`, + ); + const expanded = this.combatCodec!.expandRoot(root, 'user'); + const legacy = this.toLegacy143Plan(expanded); + const stats = fs.statSync(filePath); + zip.file( + `plans/${selection.file}`, + this.combatCodec!.serialize(legacy, originalContent), + { date: stats.mtime }, + ); + zip.file( + `original_2.0/user_battle_plans/${selection.file}`, + originalContent, + { date: stats.mtime }, + ); + }); + zip.file( + '恢复说明.txt', + [ + 'AutoWSGR-GUI 2.0 → 1.4.3 用户计划备份', + '', + '1. 安装 1.4.3 后退出程序。', + '2. 将 plans 目录内的 YAML 复制到 1.4.3 安装目录的 plans。', + '3. original_2.0 保存未经转换的 2.0 原文件,仅用于完整备份。', + '4. 其他设置、模板、任务组和运行环境不在本备份范围内。', + '', + ].join('\r\n'), + ); + return { + content: await zip.generateAsync({ + type: 'nodebuffer', + compression: 'DEFLATE', + compressionOptions: { level: 6 }, + platform: 'DOS', + }), + count: selections.length, + }; + } + /** 校验用户选择并生成包含固定分类目录的 ZIP。 */ async createArchive(rawSelections: unknown): Promise { const selections = this.normalizeSelections(rawSelections); @@ -134,4 +196,39 @@ export class PlanExportService { } return canonicalCandidate; } + + /** 把 2.0 独立舰队和候选规则降为 1.4.3 可读取的内嵌结构。 */ + private toLegacy143Plan( + root: Record, + ): Record { + const output = structuredClone(root); + if (!Array.isArray(output.fleet_presets)) return output; + output.fleet_presets = output.fleet_presets.map(rawPreset => { + if (!this.combatCodec!.isPlainObject(rawPreset)) return rawPreset; + const ships = Array.isArray(rawPreset.ships) + ? rawPreset.ships.map(slot => this.toLegacy143Slot(slot)) + : rawPreset.ships; + return { ...rawPreset, ships }; + }); + return output; + } + + private toLegacy143Slot(slot: unknown): unknown { + if (!this.combatCodec!.isPlainObject(slot)) return slot; + const candidates = Array.isArray(slot.candidates) + ? slot.candidates + : []; + const priority = candidates + .filter(candidate => this.combatCodec!.isPlainObject(candidate)) + .map(candidate => candidate.name) + .filter((name): name is string => ( + typeof name === 'string' && name.trim().length > 0 + )); + const output = { ...slot }; + delete output.candidates; + delete output.search_name; + delete output.relaxed; + if (priority.length > 0) output.priority = priority; + return output; + } } diff --git a/scripts/tests/main-services/test-plan-export.js b/scripts/tests/main-services/test-plan-export.js index b6049e4..2417711 100644 --- a/scripts/tests/main-services/test-plan-export.js +++ b/scripts/tests/main-services/test-plan-export.js @@ -6,6 +6,7 @@ const assert = require('node:assert/strict'); const path = require('node:path'); const JSZip = require('jszip'); +const yaml = require('js-yaml'); const { AtomicFileStore, fs, @@ -23,7 +24,14 @@ async function testPlanExportService() { fs.mkdirSync(userTeamDirectory, { recursive: true }); fs.writeFileSync( path.join(userBattleDirectory, 'bettle-test.yaml'), - 'name: 出征测试\n', + [ + '# 出征测试', + 'chapter: 1', + 'map: 1', + 'fleet_presets:', + ' - name: 舰队测试', + '', + ].join('\n'), 'utf8', ); fs.writeFileSync( @@ -46,6 +54,27 @@ async function testPlanExportService() { }, }, new AtomicFileStore(), + { + parseRoot: content => yaml.load(content), + expandRoot: rootPlan => ({ + ...rootPlan, + fleet_presets: [{ + name: '舰队测试', + ships: [{ + name: '重庆', + search_name: '重庆改', + relaxed: true, + candidates: [{ name: '长春', min_level: 80 }], + }], + }], + }), + serialize: rootPlan => yaml.dump(rootPlan), + isPlainObject: value => ( + typeof value === 'object' + && value !== null + && !Array.isArray(value) + ), + }, ); assert.equal( @@ -67,13 +96,42 @@ async function testPlanExportService() { await zip.file( 'user_battle_plans/bettle-test.yaml', ).async('string'), - 'name: 出征测试\n', + [ + '# 出征测试', + 'chapter: 1', + 'map: 1', + 'fleet_presets:', + ' - name: 舰队测试', + '', + ].join('\n'), ); assert.equal( await zip.file('user_team_plans/team-test.yaml').async('string'), 'name: 舰队测试\n', ); + const legacyArchive = await service.createLegacy143Archive([ + { kind: 'battle', file: 'bettle-test.yaml' }, + { kind: 'team', file: 'team-test.yaml' }, + ]); + assert.equal(legacyArchive.count, 1); + const legacyZip = await JSZip.loadAsync(legacyArchive.content); + const legacyPlan = yaml.load( + await legacyZip.file('plans/bettle-test.yaml').async('string'), + ); + assert.deepEqual(legacyPlan.fleet_presets, [{ + name: '舰队测试', + ships: [{ + name: '重庆', + priority: ['长春'], + }], + }]); + assert.ok(legacyZip.file('original_2.0/user_battle_plans/bettle-test.yaml')); + assert.match( + await legacyZip.file('恢复说明.txt').async('string'), + /仅用于完整备份/, + ); + const outputPath = path.join(root, '2026-08-04-plans.zip'); service.writeArchive(outputPath, archive); assert.deepEqual(fs.readFileSync(outputPath), archive.content); diff --git a/src/adapter/IpcAdapter.ts b/src/adapter/IpcAdapter.ts index 437d99d..710b3de 100644 --- a/src/adapter/IpcAdapter.ts +++ b/src/adapter/IpcAdapter.ts @@ -244,6 +244,7 @@ export type FleetPlannerRepository = Pick< | 'listTeamPlans' | 'getPlanManagement' | 'exportUserPlans' + | 'exportLegacy143Plans' | 'setPlanUnlinkedIgnored' | 'renameUserCombatPlan' | 'deleteUserCombatPlan' @@ -276,6 +277,10 @@ export const fleetPlannerRepository: FleetPlannerRepository = { return getRendererIpc().bridge.exportUserPlans(selections); }, + exportLegacy143Plans(selections) { + return getRendererIpc().bridge.exportLegacy143Plans(selections); + }, + setPlanUnlinkedIgnored(kind, source, file, ignored) { return getRendererIpc().bridge.setPlanUnlinkedIgnored( kind, diff --git a/src/controller/plan/PlanManagementController.ts b/src/controller/plan/PlanManagementController.ts index 691c02f..00a319d 100644 --- a/src/controller/plan/PlanManagementController.ts +++ b/src/controller/plan/PlanManagementController.ts @@ -24,6 +24,7 @@ export type PlanManagementRepository = Pick< FleetPlannerRepository, | 'getPlanManagement' | 'exportUserPlans' + | 'exportLegacy143Plans' | 'setPlanUnlinkedIgnored' | 'renameUserCombatPlan' | 'deleteUserCombatPlan' @@ -68,6 +69,9 @@ export class PlanManagementController { this.view = view; this.view.onRefresh = () => this.load(); this.view.onExportPlans = selections => this.exportPlans(selections); + this.view.onExportLegacy143Plans = selections => ( + this.exportLegacy143Plans(selections) + ); this.view.onDeletePlans = selections => this.deletePlans(selections); this.view.onToggleUnlinked = ( kind, @@ -156,6 +160,32 @@ export class PlanManagementController { } } + private async exportLegacy143Plans( + selections: readonly UserPlanExportSelection[], + ): Promise { + try { + const result = await this.repository.exportLegacy143Plans( + selections.filter(selection => selection.kind === 'battle'), + ); + if (result.canceled) return; + if (!result.success) { + await this.dialogs.alert( + '降级备份失败', + result.error || '未知错误', + ); + return; + } + this.dialogs.success( + `已保存 ${result.count ?? 0} 个 1.4.3 兼容出征计划`, + ); + } catch (error) { + await this.dialogs.alert( + '降级备份失败', + error instanceof Error ? error.message : String(error), + ); + } + } + private async deletePlans( selections: readonly UserPlanExportSelection[], ): Promise { diff --git a/src/types/ipc.ts b/src/types/ipc.ts index aa96b7a..5cee4fb 100644 --- a/src/types/ipc.ts +++ b/src/types/ipc.ts @@ -461,6 +461,9 @@ export interface ElectronBridge { exportUserPlans: ( selections: UserPlanExportSelection[], ) => Promise; + exportLegacy143Plans: ( + selections: UserPlanExportSelection[], + ) => Promise; importLocalCombatPlan: () => Promise; setPlanUnlinkedIgnored: ( kind: 'battle' | 'team', diff --git a/src/view/html/pages/plan/management.html b/src/view/html/pages/plan/management.html index 96a730d..c7e5251 100644 --- a/src/view/html/pages/plan/management.html +++ b/src/view/html/pages/plan/management.html @@ -51,6 +51,11 @@

YAML 计划管理

type="button" disabled >批量导出 + +