6 Commits

Author SHA1 Message Date
panFD
311b8ebb54 chore: 配置开发服务器监听所有网卡并新增技能数据文件 2026-10-03 15:46:03 +08:00
panFD
5d398d0a29 chore(server): 添加 HOST 配置支持容器部署
在示例环境配置、README 和服务代码中添加 HOST 配置项,默认监听 0.0.0.0 以适配容器和局域网部署场景,同时更新服务启动日志显示正确的监听地址。
2026-10-03 15:37:24 +08:00
panFD
9c9cc99e64 feat: 重构服务支持双模式并接入 MongoDB 数据库
- 新增 MongoDB 存储层实现英雄、预设和版本数据的持久化
- 新增 push-meta 和 migrate-to-mongo 脚本支持数据同步迁移
- 支持开发版和线上设计版双模式运行
- 新增版本闸门机制,线上设计版落后时进入只读模式
- 优化前端 API 配置适配不同部署环境
- 新增线上设计版静态资源托管和路由回退
2026-10-03 12:02:47 +08:00
pan
1bb275607a refactor: 修改英雄图鉴和详情页的排序规则为按职业升序
调整英雄列表排序逻辑,从原有的按品质降序改为按职业升序,保持与养成进度解耦的稳定展示。
2026-09-24 15:45:46 +08:00
pan
95bf2a4e17 feat: 添加回合进度指示 UI 组件及刷新逻辑 2026-09-24 15:44:06 +08:00
pan
3f2989ab55 refactor: 移除性能监控面板相关代码
本次提交移除了 MissionComp 组件中所有性能监控相关的代码,包括:
- 性能监控面板的属性、初始化和更新逻辑
- 相关的导入依赖
- 未使用的变量和方法

这些代码主要用于显示内存、帧率等性能数据,移除后不会影响游戏核心功能,有助于简化代码结构和减少不必要的性能开销。
2026-09-24 14:04:46 +08:00
20 changed files with 2590 additions and 693 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -247,11 +247,13 @@ export class HeroInfoComp extends CCComp {
* @param delta 1 下一个,-1 上一个
*/
private switchHero(delta: 1 | -1) {
// 与图鉴页面排序同源:仅按品质降序 → uuid 升序
// 与图鉴页面排序同源:按职业(HRole)升序 → uuid 升序
const sorted = [...HeroList].sort((a, b) => {
const ra = HeroInfo[a]?.rarity ?? 1;
const rb = HeroInfo[b]?.rarity ?? 1;
if (ra !== rb) return rb - ra;
const heroA = HeroInfo[a];
const heroB = HeroInfo[b];
const roleA = heroA?.role ?? 0;
const roleB = heroB?.role ?? 0;
if (roleA !== roleB) return roleA - roleB;
return a - b;
});
if (sorted.length === 0) return;

View File

@@ -113,11 +113,13 @@ export class HerosListComp extends CCComp {
this.cards_node.removeAllChildren()
// 图鉴排序:仅按品质降序 → uuid 升序(与养成进度解耦,保持展示稳定)
// 图鉴排序:按职业(HRole)升序 → uuid 升序(与养成进度解耦,保持展示稳定)
const sorted = [...HeroList].sort((a, b) => {
const ra = HeroInfo[a]?.rarity ?? 1;
const rb = HeroInfo[b]?.rarity ?? 1;
if (ra !== rb) return rb - ra;
const heroA = HeroInfo[a];
const heroB = HeroInfo[b];
const roleA = heroA?.role ?? 0;
const roleB = heroB?.role ?? 0;
if (roleA !== roleB) return roleA - roleB;
return a - b;
})

View File

@@ -17,7 +17,7 @@
* - 第 5 节胜利 → open_Victory(final) 弹结算,确认退出并存档进下一关。
* - cleanComponents() / clearBattlePools() 仅在整关开始/结束时执行,节间不调用。
*/
import { _decorator, Vec3, Animation, instantiate, Prefab, Node, NodeEventType, ProgressBar, Label, CCInteger, tween, v3, Tween, Widget, UIOpacity } from "cc";
import { _decorator, Node, NodeEventType, Label, tween, v3, Tween, Widget, Color, Sprite, ProgressBar } from "cc";
import { ecs } from "../../../../extensions/oops-plugin-framework/assets/libs/ecs/ECS";
import { CCComp } from "../../../../extensions/oops-plugin-framework/assets/module/common/CCComp";
import { smc } from "../common/SingletonModuleComp";
@@ -31,7 +31,6 @@ import { SkillTriggerHelper } from "../hero/SkillTriggerHelper";
import { UIID } from "../common/config/GameUIConfig";
import { SkillView } from "../skill/SkillView";
import { FacSet, FightSet } from "../common/config/GameSet";
import { HeroInfo } from "../common/config/heroSet";
import { mLogger } from "../common/Logger";
import { Monster } from "../hero/Mon";
import { Skill } from "../skill/Skill";
@@ -80,8 +79,6 @@ type SettleType = "defeat" | "final";
export class MissionComp extends CCComp {
@property({ tooltip: "是否启用调试日志" })
private debugMode: boolean = true;
@property({ tooltip: "是否显示战斗内存观测面板" })
private showMemoryPanel: boolean = false;
// ======================== 配置参数 ========================
@@ -97,6 +94,21 @@ export class MissionComp extends CCComp {
@property(Node)
tooltip: Node = null!
/** 回合指示节点(5 个,索引 0~4 对应回合 1~5),按回合进度染色 */
@property({ type: [Node], tooltip: "回合指示节点(按回合 1~5 顺序绑定)" })
round_nodes: Node[] = [];
/** 回合进度条:第 1 回合 0%,每推进一回合 +25%(第 5 回合满格) */
@property({ type: ProgressBar, tooltip: "回合进度条(第 1 回合 0%,每回合 +25%)" })
round_progress: ProgressBar = null!;
/** 回合指示:未激活(白色) */
private static readonly ROUND_INACTIVE = new Color(255, 255, 255, 255);
/** 回合指示:已激活(橙黄 #FFA300) */
private static readonly ROUND_ACTIVE = new Color(255, 163, 0, 255);
/** 回合指示:Boss 当前回合(红色) */
private static readonly ROUND_BOSS = new Color(230, 60, 60, 255);
/** 阶段名称映射表(用于 UI 显示) */
private static readonly PhaseNameMap: Record<MissionPhase, string> = {
[MissionPhase.None]: "未开始",
@@ -133,26 +145,6 @@ export class MissionComp extends CCComp {
private lastTimeStr: string = "";
/** 上一次显示的秒数(避免重复计算) */
private lastTimeSecond: number = -1;
/** 性能监控面板 Label 引用 */
private memoryLabel: Label | null = null;
/** 性能监控刷新计时器 */
private memoryRefreshTimer: number = 0;
/** 上一次性能文本(避免重复渲染) */
private lastMemoryText: string = "";
/** 帧间隔累加(用于计算平均 FPS) */
private perfDtAcc: number = 0;
/** 帧数计数 */
private perfFrameCount: number = 0;
/** 初始堆内存基准值(MB) */
private heapBaseMB: number = -1;
/** 堆内存峰值(MB) */
private heapPeakMB: number = 0;
/** 堆内存增长趋势(MB/分钟) */
private heapTrendPerMinMB: number = 0;
/** 趋势计算计时器 */
private heapTrendTimer: number = 0;
/** 趋势计算基准(MB) */
private heapTrendBaseMB: number = -1;
/** 怪物数量同步计时器(降低同步频率) */
private monsterCountSyncTimer: number = 0;
/** 当前回合数(= 当前小节号,驱动 HUD 回合/进度显示) */
@@ -161,8 +153,6 @@ export class MissionComp extends CCComp {
private isBossWave: boolean = false;
/** 超时回合已在 onBattleTimeout 预扣留存分,BattleEnd 需跳过重复累加 */
private skipRemainScoreOnBattleEnd: boolean = false;
/** 上节清场时间快照(含清场加速还原,供评分统计) */
private lastWaveClearTime: number = 0;
/** 当前任务阶段 */
public currentPhase: MissionPhase = MissionPhase.None;
/** 是否处于回合间倒计时状态 */
@@ -200,7 +190,6 @@ export class MissionComp extends CCComp {
this.heroViewMatcher = ecs.allOf(HeroViewComp);
this.skillViewMatcher = ecs.allOf(SkillView);
this.heroAttrsMatcher = ecs.allOf(HeroAttrsComp);
this.showMemoryPanel = false
// 注册生命周期事件
this.on(GameEvent.MissionEnd, this.mission_end, this)
this.on(GameEvent.NewWave, this.onNewWave, this)
@@ -210,10 +199,9 @@ export class MissionComp extends CCComp {
this.start_btn?.on(NodeEventType.TOUCH_END, this.onStartFightBtnClick, this)
// 开局免费三选一选完后自动进入战斗
oops.message.on("OpeningPicksDone", this.onOpeningPicksDone, this);
this.removeMemoryPanel()
}
onAdded(args: any) {
onAdded() {
// 使用 scheduleOnce 将事件推迟到下一帧执行,
// 确保所有关联组件(如 MissionCardComp 等)都已经完成其 onLoad 生命周期。
this.scheduleOnce(() => {
@@ -452,7 +440,6 @@ export class MissionComp extends CCComp {
*/
private changePhase(targetPhase: MissionPhase) {
if (this.currentPhase === targetPhase) return;
const oldPhase = this.currentPhase;
this.currentPhase = targetPhase;
const phaseName = MissionComp.PhaseNameMap[targetPhase] || "未知";
@@ -553,8 +540,6 @@ export class MissionComp extends CCComp {
// 英雄为真实死亡(实体已销毁),本节死亡数取死亡记录长度
const heroDeathCount = smc.mission.dead_heroes.length;
// 快照清场时间供评分统计(onNewWave 会清零 clearTime,必须提前快照)
this.lastWaveClearTime = this.clearTime;
// 【评分系统 - 战绩分】记录全员存活的胜利节数(额外加分)
if (hasHero && heroDeathCount === 0) {
smc.vmdata.scores.wave_all_alive_count++;
@@ -748,11 +733,11 @@ export class MissionComp extends CCComp {
* - 胜利即 final(通关),存档推进到下一关;失败即 defeat(进度不变,可复活/再来一次)
* - 切入 BattleEnd,2s 后由 autoNextPhase 自动弹出 VictoryComp
*
* @param e 事件对象(未使用)
* @param is_hero_dead 是否因英雄全灭触发
* @param _e 事件对象(未使用)
* @param _is_hero_dead 是否因英雄全灭触发(当前未使用,保留签名以统一事件调用口径)
* @param victory 是否胜利(默认 false)
*/
open_Victory(e: any, is_hero_dead: boolean = false, victory: boolean = false) {
open_Victory(_e: any, _is_hero_dead: boolean = false, victory: boolean = false) {
// 战斗结束(通关/失败),标记暂停状态以切断节间流转逻辑
smc.mission.pause = true;
this._victory = victory;
@@ -828,20 +813,11 @@ export class MissionComp extends CCComp {
this.currentWave = 1;
this.isBossWave = false;
this.skipRemainScoreOnBattleEnd = false;
this.lastWaveClearTime = 0;
this.updateRoundNodes();
this.rewards = []
this.revive_times = 1;
this.lastTimeStr = "";
this.lastTimeSecond = -1;
this.memoryRefreshTimer = 0;
this.lastMemoryText = "";
this.perfDtAcc = 0;
this.perfFrameCount = 0;
this.heapBaseMB = -1;
this.heapPeakMB = 0;
this.heapTrendPerMinMB = 0;
this.heapTrendTimer = 0;
this.heapTrendBaseMB = -1;
this.monsterCountSyncTimer = 0;
spawningEngine.reset();
@@ -941,7 +917,7 @@ export class MissionComp extends CCComp {
* @param event 事件名
* @param data { wave: number, bossWave?: boolean }
*/
private onNewWave(event: string, data: any) {
private onNewWave(_event: string, data: any) {
const wave = Number(data?.wave ?? 0);
if (wave <= 0) return;
@@ -950,12 +926,46 @@ export class MissionComp extends CCComp {
this.currentWave = wave;
smc.vmdata.mission_data.level = wave;
this.updateRoundNodes();
// 金币改为怪物死亡掉落(见 HeroAtkSystem),不再每回合固定发放
this.lastTimeSecond = -1;
this.clearTime = 0;
this.update_time();
}
/**
* 刷新回合指示节点颜色:
* - 已激活(已完成 + 当前回合)→ 黄色;当前回合且为 Boss 节 → 红色;未激活 → 白色。
* Why: 回合推进时(onNewWave)与开局重置(data_init)调用,驱动 HUD 回合指示实时反馈。
*/
private updateRoundNodes() {
const len = Math.min(this.round_nodes.length, SECTIONS_PER_STAGE);
for (let i = 0; i < len; i++) {
const node = this.round_nodes[i];
if (!node || !node.isValid) continue;
const round = i + 1;
const color = round > this.currentWave
? MissionComp.ROUND_INACTIVE
: (round === this.currentWave && this.isBossWave)
? MissionComp.ROUND_BOSS
: MissionComp.ROUND_ACTIVE;
// 优先染色 Sprite(图标配色),无 Sprite 时退化到 Label,均无则跳过
const sprite = node.getComponent(Sprite);
if (sprite) {
sprite.color = color;
} else {
const label = node.getComponent(Label);
if (label) label.color = color;
}
}
// 进度条:第 1 回合 0%,每推进一回合 +25%(第 5 回合满格)
if (this.round_progress && this.round_progress.isValid) {
this.round_progress.progress =
Math.max(0, Math.min(SECTIONS_PER_STAGE, this.currentWave) - 1) / (SECTIONS_PER_STAGE - 1);
}
}
// ======================== 怪物数量管理 ========================
/**
@@ -1164,115 +1174,6 @@ export class MissionComp extends CCComp {
}
}
/** 获取战斗层的英雄和技能节点数量(用于性能监控) */
private getBattleLayerNodeCount() {
const scene = smc.map?.MapView?.scene;
const layer = scene?.entityLayer?.node;
if (!layer) return { heroNodes: 0, skillNodes: 0 };
const heroRoot = layer.getChildByName("HERO");
const skillRoot = layer.getChildByName("SKILL");
return {
heroNodes: heroRoot?.children.length || 0,
skillNodes: skillRoot?.children.length || 0
};
}
// ======================== 性能监控面板 ========================
/** 性能监控相关代码 */
/** 初始化性能监控面板:在本组件节点下创建 Label */
private initMemoryPanel() {
if (!this.showMemoryPanel) return;
let panel = this.node.getChildByName("mem_panel");
if (!panel) {
panel = new Node("mem_panel");
panel.parent = this.node;
panel.setPosition(0, -32, 0);
}
let label = panel.getComponent(Label);
if (!label) {
label = panel.addComponent(Label);
}
label.fontSize = 16;
label.lineHeight = 20;
this.memoryLabel = label;
}
/** 移除性能监控面板 */
private removeMemoryPanel() {
const panel = this.node?.getChildByName("mem_panel");
if (panel) {
panel.destroy();
}
this.memoryLabel = null;
this.lastMemoryText = "";
}
/**
* 更新性能监控面板内容(每 0.5 秒一次):
* 显示 堆内存 / 增长趋势 / 帧率 / 实体数量 / 对象池状态 等信息。
*/
private updateMemoryPanel(dt: number) {
if (!this.showMemoryPanel || !this.memoryLabel) return;
this.perfDtAcc += dt;
this.perfFrameCount += 1;
this.memoryRefreshTimer += dt;
if (this.memoryRefreshTimer < 0.5) return;
this.memoryRefreshTimer = 0;
let heroCount = 0;
ecs.query(this.heroViewMatcher).forEach(() => {
heroCount++;
});
let skillCount = 0;
ecs.query(this.skillViewMatcher).forEach(() => {
skillCount++;
});
const monPool = Monster.getPoolStats();
const skillPool = Skill.getPoolStats();
const tooltipPool = Tooltip.getPoolStats();
const layerNodes = this.getBattleLayerNodeCount();
const perf = (globalThis as any).performance;
const heapBytes = perf && perf.memory ? perf.memory.usedJSHeapSize : 0;
let heapMB = heapBytes > 0 ? heapBytes / 1024 / 1024 : -1;
if (heapMB > 0 && this.heapBaseMB < 0) {
this.heapBaseMB = heapMB;
this.heapPeakMB = heapMB;
this.heapTrendBaseMB = heapMB;
this.heapTrendTimer = 0;
}
if (heapMB > this.heapPeakMB) {
this.heapPeakMB = heapMB;
}
this.heapTrendTimer += 0.5;
if (heapMB > 0 && this.heapTrendBaseMB > 0 && this.heapTrendTimer >= 10) {
const deltaMB = heapMB - this.heapTrendBaseMB;
this.heapTrendPerMinMB = (deltaMB / this.heapTrendTimer) * 60;
this.heapTrendBaseMB = heapMB;
this.heapTrendTimer = 0;
}
const heapText = heapMB > 0 ? heapMB.toFixed(1) : "N/A";
const heapDeltaText = this.heapBaseMB > 0 && heapMB > 0 ? (heapMB - this.heapBaseMB).toFixed(1) : "N/A";
const heapPeakText = this.heapPeakMB > 0 ? this.heapPeakMB.toFixed(1) : "N/A";
const avgDt = this.perfFrameCount > 0 ? this.perfDtAcc / this.perfFrameCount : 0;
const fps = avgDt > 0 ? 1 / avgDt : 0;
this.perfDtAcc = 0;
this.perfFrameCount = 0;
const text =
`Heap:${heapText}MB Δ:${heapDeltaText} Peak:${heapPeakText}\n` +
`Trend:${this.heapTrendPerMinMB.toFixed(2)}MB/min\n` +
`Perf dt:${(avgDt * 1000).toFixed(1)}ms fps:${fps.toFixed(1)}\n` +
`Ent H:${heroCount} S:${skillCount} N:${layerNodes.heroNodes}/${layerNodes.skillNodes}\n` +
`Pool M:${monPool.total}(${monPool.paths}) K:${skillPool.total}(${skillPool.paths}) T:${tooltipPool.total}`;
if (text === this.lastMemoryText) return;
this.lastMemoryText = text;
this.memoryLabel.string = text;
}
/** ECS 组件移除时销毁节点 */
reset() {
this.PhaseTime = null as any;

6
put/.gitignore vendored
View File

@@ -1,4 +1,8 @@
node_modules
dist
*.local
.DS_Store
# 环境变量含数据库连接串,禁止提交(参考 server/.env.example)
.env
# 线上设计版部署所需的产物需提交:web/dist、server/data/meta.json(勿忽略)
!web/dist
!web/dist/**

89
put/README.md Normal file
View File

@@ -0,0 +1,89 @@
# hero_set — 英雄配置编辑器
英雄数值/技能/触发/光环的可视化配置工具。数据存储在 **MongoDB**,编辑器产出可写回游戏工程的 `heroSet.ts` 与 `英雄设计表.csv`。
## 双版本架构
| | 项目开发版(local) | 线上设计版(remote) |
|---|---|---|
| 使用人 | 开发者 | 数值设计人员 |
| 数据 | 同一个线上 MongoDB | 同一个线上 MongoDB |
| meta 底座(技能/Buff) | 从游戏工程 TS 实时编译 | `server/data/meta.json` 快照(需随部署发布) |
| 英雄/预设 增删改 | ✅ | ✅(库结构版本更高时只读) |
| 写 heroSet.ts / 导出 CSV | ✅ | ❌(接口返回 403,按钮隐藏) |
**版本闸门**:库中 `meta` 集合存 `schemaVersion`(只升不降)。线上版启动版本低于库版本时,保存接口返回 409、前端禁用保存——防止旧结构覆盖新数据。结构有破坏性变更时:本地 `SCHEMA_VERSION` +1 → `npm run push-meta` → 重新部署线上版。
## 目录
```
put/
├── server/ # Express API(单服务托管 API + 前端静态页)
│ ├── index.js # 接口与模式/版本闸门
│ ├── db.js # MongoDB 存储层
│ ├── data/ # meta.json 快照(remote 模式必需)
│ ├── scripts/
│ │ ├── migrate-to-mongo.js # 一次性:JSON 文件 → Mongo
│ │ └── push-meta.js # 开发版:生成 meta 快照 + 抬升库版本
│ └── .env.example
└── web/ # Vue3 + Element Plus 前端
└── dist/ # 构建产物(remote 模式由 server 托管,需提交)
```
## 部署线上设计版(给 AI 部署智能体)
前置:Node.js ≥ 18;可访问的 MongoDB 连接串。
```bash
# 1. 拉代码
git clone git@git.eoxnet.com:pan/hero_set.git && cd hero_set
# 2. 配置环境变量
cp server/.env.example server/.env
# 编辑 server/.env:
# MODE=remote
# MONGO_URL=mongodb://user:pass@host:27017
# MONGO_DB=heroconfig
# PORT=3001
# 3. 构建前端
cd web && npm ci && npm run build && cd ..
# 注意:web/dist 与 server/data/meta.json 必须存在(均已随仓库提交,若仓库有更新请重新构建)
# 4. 启动(server 自动托管 web/dist,单服务即可)
cd server && npm ci && npm start
# 访问 http://<host>:3001
```
进程守护(可选):`pm2 start index.js --name hero-set --cwd server`
## 开发版工作流(本地)
```bash
# server/.env:MODE=local + 同一个 MONGO_URL
cd server && npm ci && npm run dev # API :3001
cd web && npm ci && npm run dev # 前端 :4004(已代理 /api → :3001)
```
- **保存到数据库** → 全量覆盖 MongoDB(按 uuid upsert + 删除多余)
- **写入 heroSet.ts** → 渲染进 `assets/script/game/common/config/heroSet.ts` 的 `@hero-setup:heroes-begin/end` 标记区域
- **技能/底座结构变更后**:`SCHEMA_VERSION` +1 → `npm run push-meta`(需 dev 服务已启动)→ 提交并重新部署线上版
## 环境变量
| 变量 | 默认 | 说明 |
|---|---|---|
| `MODE` | `local` | `local` 开发版 / `remote` 线上设计版 |
| `MONGO_URL` | — | 必填,MongoDB 连接串 |
| `MONGO_DB` | `heroconfig` | 库名 |
| `PORT` | `3001` | 服务端口 |
| `HOST` | `0.0.0.0` | 监听地址;容器部署必须保持 0.0.0.0 并映射端口(如 `docker run -p 3001:3001`) |
| `SCHEMA_VERSION` | `1` | 数据结构版本,破坏性变更时 +1 |
前端单独部署(不经 server 托管)时,`web/.env` 配 `VITE_API_BASE=http://<host>:3001`。
## 数据库结构(MongoDB · heroconfig)
- `heroes`:每个英雄一条文档,`uuid` 唯一索引
- `templates`:`_id='presets'` 单文档(triggerPresets / fieldPresets)
- `meta`:`_id='version'` 单文档(schemaVersion)

166
put/package-lock.json generated
View File

@@ -527,6 +527,15 @@
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT"
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.5.5.tgz",
"integrity": "sha512-0PV4mvjGd2Q2/viDvXuoSNzTjQngHvtuWUry1vqvcK4Q/CTeO1zEKMf9set01DMocJnogl/pyNAdvyk+Pe0kQA==",
"license": "MIT",
"dependencies": {
"sparse-bitfield": "^3.0.3"
}
},
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -975,6 +984,21 @@
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==",
"license": "MIT"
},
"node_modules/@types/whatwg-url": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
"license": "MIT",
"dependencies": {
"@types/webidl-conversions": "*"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -1253,6 +1277,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/bson": {
"version": "6.10.4",
"resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
"integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.20.1"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1495,6 +1528,21 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
"node_modules/dotenv": {
"version": "18.0.5",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.5.tgz",
"integrity": "sha512-aBrGvt6KhjxbEnatqMWOMidftwsrCiCvqh1yNIGB9QyF0J+VcWkKDMsvYOEm7ZS82oiV+BT1sTOtIlAmUlTpwQ==",
"license": "BSD-2-Clause",
"bin": {
"dotenv": "dist/index.cjs"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2102,6 +2150,12 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/memory-pager": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==",
"license": "MIT"
},
"node_modules/merge-descriptors": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
@@ -2153,6 +2207,62 @@
"node": ">= 0.6"
}
},
"node_modules/mongodb": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.21.0.tgz",
"integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==",
"license": "Apache-2.0",
"dependencies": {
"@mongodb-js/saslprep": "^1.3.0",
"bson": "^6.10.4",
"mongodb-connection-string-url": "^3.0.2"
},
"engines": {
"node": ">=16.20.1"
},
"peerDependencies": {
"@aws-sdk/credential-providers": "^3.188.0",
"@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
"gcp-metadata": "^5.2.0",
"kerberos": "^2.0.1",
"mongodb-client-encryption": ">=6.0.0 <7",
"snappy": "^7.3.2",
"socks": "^2.7.1"
},
"peerDependenciesMeta": {
"@aws-sdk/credential-providers": {
"optional": true
},
"@mongodb-js/zstd": {
"optional": true
},
"gcp-metadata": {
"optional": true
},
"kerberos": {
"optional": true
},
"mongodb-client-encryption": {
"optional": true
},
"snappy": {
"optional": true
},
"socks": {
"optional": true
}
}
},
"node_modules/mongodb-connection-string-url": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
"license": "Apache-2.0",
"dependencies": {
"@types/whatwg-url": "^11.0.2",
"whatwg-url": "^14.1.0 || ^13.0.0"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@@ -2309,6 +2419,15 @@
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
@@ -2590,6 +2709,15 @@
"node": ">=0.10.0"
}
},
"node_modules/sparse-bitfield": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
"license": "MIT",
"dependencies": {
"memory-pager": "^1.0.2"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -2669,6 +2797,18 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tree-kill": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
@@ -3299,6 +3439,28 @@
"resolved": "web",
"link": true
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/whatwg-url": {
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"license": "MIT",
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -3360,8 +3522,10 @@
"version": "1.0.0",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^18.0.5",
"esbuild": "^0.24.0",
"express": "^4.21.2"
"express": "^4.21.2",
"mongodb": "^6.12.0"
}
},
"web": {

24
put/server/.env.example Normal file
View File

@@ -0,0 +1,24 @@
# ---- 运行模式 ----
# local = 项目开发版(本地,全功能:写 heroSet.ts / 导出 CSV / push-meta)
# remote = 线上设计版(VPS 部署,仅英雄/预设编辑,版本落后时只读)
MODE=local
# ---- MongoDB(两边都要配置,连同一个库)----
# 本地 MongoDB 示例:mongodb://127.0.0.1:27017
# 带账号密码示例: mongodb://user:password@your-vps-ip:27017
# Atlas 示例: mongodb+srv://user:password@cluster0.xxxx.mongodb.net
MONGO_URL=mongodb://user:password@your-vps-ip:27017
MONGO_DB=heroconfig
# ---- 服务端口 ----
PORT=3001
# 监听地址:默认 0.0.0.0(容器/局域网部署必须);仅本机访问可改 127.0.0.1
HOST=0.0.0.0
# ---- 数据结构版本 ----
# 英雄/底座结构有破坏性变更时 +1,并执行 npm run push-meta 推送到库中
# 线上设计版低于库中版本时将进入只读,需更新部署
SCHEMA_VERSION=1
# ---- 前端(web/.env,仅静态部署时需要;server 托管 dist 时不用配)----
# VITE_API_BASE=http://your-vps-ip:3001

File diff suppressed because one or more lines are too long

75
put/server/db.js Normal file
View File

@@ -0,0 +1,75 @@
/**
* 英雄/预设存储层:开发版与线上设计版统一读写线上 MongoDB。
* - heroes 集合:每个英雄一条文档,uuid 唯一索引
* - templates 集合:预设库(_id='presets' 单文档)
* - meta 集合:版本闸门(_id='version' 单文档,schemaVersion 由开发版推送 meta 时写入)
* 技能/Buff 底座不进数据库,开发版走 TS 编译,线上版走 meta.json 快照。
*/
import { MongoClient } from 'mongodb'
const MONGO_URL = process.env.MONGO_URL || ''
const DB_NAME = process.env.MONGO_DB || 'heroconfig'
let client = null
let db = null
/** 惰性连接 MongoDB(单例复用) */
async function mongo() {
if (db) return db
if (!MONGO_URL) throw new Error('未配置 MONGO_URL 环境变量')
client = new MongoClient(MONGO_URL)
await client.connect()
db = client.db(DB_NAME)
// uuid 唯一索引:保证英雄按 uuid 幂等覆盖
await db.collection('heroes').createIndex({ uuid: 1 }, { unique: true })
return db
}
/** 读取全部英雄(按 uuid 升序,剥离 Mongo 内部 _id) */
export async function getHeroes() {
const docs = await (await mongo()).collection('heroes').find().sort({ uuid: 1 }).toArray()
return docs.map(({ _id, ...h }) => h)
}
/** 全量覆盖:upsert 传入英雄,并删除库中多余的 uuid */
export async function saveHeroes(heroes) {
const col = (await mongo()).collection('heroes')
for (const h of heroes) {
await col.replaceOne({ uuid: h.uuid }, h, { upsert: true })
}
await col.deleteMany({ uuid: { $nin: heroes.map((h) => h.uuid) } })
}
/** 读取预设库(无文档时返回空池) */
export async function getTemplates() {
const doc = await (await mongo()).collection('templates').findOne({ _id: 'presets' })
return {
triggerPresets: Array.isArray(doc?.triggerPresets) ? doc.triggerPresets : [],
fieldPresets: Array.isArray(doc?.fieldPresets) ? doc.fieldPresets : [],
}
}
/** 全量覆盖预设库 */
export async function saveTemplates(triggerPresets, fieldPresets) {
await (await mongo())
.collection('templates')
.replaceOne({ _id: 'presets' }, { _id: 'presets', triggerPresets, fieldPresets }, { upsert: true })
}
/** 读取库中数据结构版本(首次为 0) */
export async function getSchemaVersion() {
const doc = await (await mongo()).collection('meta').findOne({ _id: 'version' })
return doc?.schemaVersion ?? 0
}
/**
* 推送数据结构版本:只允许升不允许降,避免旧部署误把版本刷低导致线上版解锁写入。
* @returns {Promise<number>} 写入后的实际版本
*/
export async function bumpSchemaVersion(version) {
const col = (await mongo()).collection('meta')
const cur = await getSchemaVersion()
const next = Math.max(cur, version)
await col.replaceOne({ _id: 'version' }, { _id: 'version', schemaVersion: next, updatedAt: new Date() }, { upsert: true })
return next
}

View File

@@ -1,9 +1,17 @@
/**
* 英雄配置工具服务(双模式)
* - 项目开发版(MODE=local):全功能,meta 从游戏工程 TS 编译,可写 heroSet.ts / 导出 CSV / 推送快照
* - 线上设计版(MODE=remote):meta 从 data/meta.json 快照加载,仅英雄/预设的增删改查;
* 库中 schemaVersion 高于本服务内置版本时进入只读(保存接口返回 409),需更新部署后才能修改
*/
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import esbuild from 'esbuild'
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import * as store from './db.js'
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const PROJECT_ROOT = path.resolve(__dirname, '..', '..')
@@ -14,7 +22,11 @@ const CSV_PATH = path.join(CONFIG_DIR, '英雄设计表.csv')
const HEROES_BEGIN = '// @hero-setup:heroes-begin'
const HEROES_END = '// @hero-setup:heroes-end'
const DATA_DIR = path.join(__dirname, 'data')
const HEROES_PATH = path.join(DATA_DIR, 'heroes.json')
const META_SNAPSHOT_PATH = path.join(DATA_DIR, 'meta.json')
const MODE = process.env.MODE || 'local'
/** 数据结构版本:英雄/底座结构有破坏性变更时 +1,并随 push-meta 推送到库中 */
const SCHEMA_VERSION = Number(process.env.SCHEMA_VERSION || 1)
const app = express()
app.use(cors())
@@ -43,6 +55,14 @@ async function loadConfigModule(file) {
let cache = null
async function buildCache() {
// 线上设计版:从快照文件加载(快照由开发版 scripts/push-meta.js 生成并随部署发布)
if (MODE === 'remote') {
if (!fs.existsSync(META_SNAPSHOT_PATH)) {
throw new Error('缺少 data/meta.json 快照,请先在开发版执行 npm run push-meta 并重新部署')
}
cache = JSON.parse(fs.readFileSync(META_SNAPSHOT_PATH, 'utf8'))
return cache
}
const [skill, buff, hero] = await Promise.all([
loadConfigModule('SkillSet.ts'),
loadConfigModule('BuffSet.ts'),
@@ -63,7 +83,12 @@ async function buildCache() {
app.get('/api/meta', async (_req, res) => {
try {
if (!cache) await buildCache()
res.json(cache)
res.json({
...cache,
mode: MODE,
schemaVersion: SCHEMA_VERSION,
dbSchemaVersion: await store.getSchemaVersion(),
})
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
@@ -78,51 +103,56 @@ app.post('/api/refresh', async (_req, res) => {
}
})
/** 英雄数据持久化:GET 读取(无文件时返回空列表) */
app.get('/api/heroes', (_req, res) => {
/** 版本闸门:线上设计版落后于库中结构版本时,写操作返回 409 强制只读 */
async function guardVersion(res) {
if (MODE !== 'remote') return true
const dbVer = await store.getSchemaVersion()
if (dbVer > SCHEMA_VERSION) {
res.status(409).json({
error: `线上设计版结构版本(v${SCHEMA_VERSION})低于数据库(v${dbVer}),请更新部署后再修改`,
dbSchemaVersion: dbVer,
schemaVersion: SCHEMA_VERSION,
})
return false
}
return true
}
/** 英雄数据:GET 从线上库读取 */
app.get('/api/heroes', async (_req, res) => {
try {
if (!fs.existsSync(HEROES_PATH)) return res.json({ heroes: [] })
res.json(JSON.parse(fs.readFileSync(HEROES_PATH, 'utf8')))
res.json({ heroes: await store.getHeroes() })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 英雄数据持久化:POST 全量覆盖保存 */
app.post('/api/heroes', (req, res) => {
/** 英雄数据:POST 全量覆盖写入线上库 */
app.post('/api/heroes', async (req, res) => {
const heroes = req.body?.heroes
if (!Array.isArray(heroes)) {
return res.status(400).json({ error: 'heroes 必须为数组' })
}
try {
fs.mkdirSync(DATA_DIR, { recursive: true })
fs.writeFileSync(HEROES_PATH, JSON.stringify({ heroes }, null, 2), 'utf8')
res.json({ ok: true, path: HEROES_PATH, count: heroes.length })
if (!(await guardVersion(res))) return
await store.saveHeroes(heroes)
res.json({ ok: true, count: heroes.length })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
const TEMPLATES_PATH = path.join(DATA_DIR, 'templates.json')
/** 预设库持久化:GET 读取(无文件时返回空池) */
app.get('/api/templates', (_req, res) => {
/** 预设库:GET 从线上库读取 */
app.get('/api/templates', async (_req, res) => {
try {
if (!fs.existsSync(TEMPLATES_PATH)) return res.json({ triggerPresets: [], fieldPresets: [] })
const data = JSON.parse(fs.readFileSync(TEMPLATES_PATH, 'utf8'))
// 兼容旧格式:若根上是 templates 数组,返回空池让前端走默认
if (Array.isArray(data.templates)) return res.json({ triggerPresets: [], fieldPresets: [] })
res.json({
triggerPresets: Array.isArray(data.triggerPresets) ? data.triggerPresets : [],
fieldPresets: Array.isArray(data.fieldPresets) ? data.fieldPresets : [],
})
res.json(await store.getTemplates())
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** 职业模板持久化:POST 全量覆盖保存(结构:{ triggerPresets: [], fieldPresets: [] }) */
app.post('/api/templates', (req, res) => {
/** 预设库:POST 全量覆盖写入线上库(结构:{ triggerPresets: [], fieldPresets: [] }) */
app.post('/api/templates', async (req, res) => {
const body = req.body || {}
const triggerPresets = body.triggerPresets
const fieldPresets = body.fieldPresets
@@ -136,9 +166,9 @@ app.post('/api/templates', (req, res) => {
return res.status(400).json({ error: `触发预设 ${bad.id || bad.label} 的 group 非法:${bad.group}` })
}
try {
fs.mkdirSync(DATA_DIR, { recursive: true })
fs.writeFileSync(TEMPLATES_PATH, JSON.stringify({ triggerPresets, fieldPresets }, null, 2), 'utf8')
res.json({ ok: true, path: TEMPLATES_PATH, triggerCount: triggerPresets.length, fieldCount: fieldPresets.length })
if (!(await guardVersion(res))) return
await store.saveTemplates(triggerPresets, fieldPresets)
res.json({ ok: true, triggerCount: triggerPresets.length, fieldCount: fieldPresets.length })
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
@@ -425,8 +455,16 @@ function renderHeroCsv(heroes) {
return rows.map((r) => r.map(csvCell).join(',')).join('\n') + '\n'
}
/** 仅开发版可用的本地文件写操作(heroSet.ts / CSV),线上设计版调用返回 403 */
function localOnly(res) {
if (MODE === 'local') return true
res.status(403).json({ error: '线上设计版不支持此操作(写 heroSet.ts / 导出 CSV 请在项目开发版执行)' })
return false
}
app.post('/api/export-csv', async (req, res) => {
try {
if (!localOnly(res)) return
const heroes = req.body?.heroes
if (!Array.isArray(heroes) || !heroes.length) {
return res.status(400).json({ error: 'heroes 不能为空' })
@@ -440,12 +478,25 @@ app.post('/api/export-csv', async (req, res) => {
}
})
app.get('/api/export-csv', (_req, res) => {
app.get('/api/export-csv', (req, res) => {
if (!localOnly(res)) return
if (!fs.existsSync(CSV_PATH)) return res.status(404).json({ error: '尚未生成' })
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
res.send(fs.readFileSync(CSV_PATH, 'utf8'))
})
/** 导出 meta 快照(开发版专用):供 push-meta 脚本发布到线上设计版 */
app.get('/api/meta-snapshot', async (_req, res) => {
try {
if (!localOnly(res)) return
if (!cache) await buildCache()
const { mode: _m, schemaVersion: _v, dbSchemaVersion: _d, ...snapshot } = cache
res.json(snapshot)
} catch (e) {
res.status(500).json({ error: String(e?.message || e) })
}
})
/** HeroList 定义匹配(用于导出时同步图鉴/卡池数据源) */
const HERO_LIST_RE = /export const HeroList: number\[\] = \[[\s\S]*?\n\];/
@@ -470,6 +521,7 @@ function syncHeroList(src, heroes) {
app.post('/api/apply', (req, res) => {
try {
if (!localOnly(res)) return
const heroes = req.body?.heroes
if (!Array.isArray(heroes) || !heroes.length) {
return res.status(400).json({ error: 'heroes 不能为空' })
@@ -491,7 +543,19 @@ app.post('/api/apply', (req, res) => {
}
})
const PORT = 3001
app.listen(PORT, () => {
console.log(`[hero-config-tool] server listening http://localhost:${PORT}`)
const PORT = Number(process.env.PORT || 3001)
// 默认监听所有网卡,保证容器/局域网端口映射可用;可用 HOST=127.0.0.1 限制为本机
const HOST = process.env.HOST || '0.0.0.0'
// 线上设计版:托管前端构建产物(web/dist),单个服务即可对外提供完整站点
if (MODE === 'remote') {
const DIST_DIR = path.resolve(__dirname, '..', 'web', 'dist')
if (fs.existsSync(DIST_DIR)) {
app.use(express.static(DIST_DIR))
app.get(/^\/(?!api\/).*/, (_req, res) => res.sendFile(path.join(DIST_DIR, 'index.html')))
}
}
app.listen(PORT, HOST, () => {
console.log(`[hero-config-tool] mode=${MODE} schemaVersion=${SCHEMA_VERSION} listening http://${HOST}:${PORT}`)
})

View File

@@ -4,11 +4,15 @@
"private": true,
"type": "module",
"scripts": {
"dev": "node --watch index.js"
"dev": "node --watch index.js",
"start": "node index.js",
"push-meta": "node scripts/push-meta.js"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^18.0.5",
"esbuild": "^0.24.0",
"express": "^4.21.2"
"express": "^4.21.2",
"mongodb": "^6.12.0"
}
}

View File

@@ -0,0 +1,35 @@
/**
* 一次性迁移:把本地 data/heroes.json、data/templates.json 导入线上 MongoDB
* 用法:配置 MONGO_URL 后执行 node scripts/migrate-to-mongo.js
* 已在库中的数据会按 uuid 幂等覆盖,可重复执行。
*/
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import { saveHeroes, saveTemplates, getHeroes } from '../db.js'
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const DATA_DIR = path.join(__dirname, '..', 'data')
const readJson = (file, fallback) => {
const p = path.join(DATA_DIR, file)
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : fallback
}
const heroes = readJson('heroes.json', { heroes: [] }).heroes || []
const tpl = readJson('templates.json', { triggerPresets: [], fieldPresets: [] })
if (heroes.length) {
await saveHeroes(heroes)
console.log(`[migrate] heroes 导入 ${heroes.length} 条`)
} else {
console.log('[migrate] 无本地 heroes.json 或为空,跳过英雄导入')
}
if ((tpl.triggerPresets?.length || 0) + (tpl.fieldPresets?.length || 0) > 0) {
await saveTemplates(tpl.triggerPresets || [], tpl.fieldPresets || [])
console.log(`[migrate] templates 导入 trigger=${tpl.triggerPresets?.length || 0} field=${tpl.fieldPresets?.length || 0}`)
} else {
console.log('[migrate] 无本地 templates.json 或为空,跳过预设导入')
}
console.log(`[migrate] 库中现有英雄 ${(await getHeroes()).length} 条`)
process.exit(0)

View File

@@ -0,0 +1,29 @@
/**
* 推送 meta 快照 + 结构版本到线上(项目开发版使用)
* 用法:先启动本地 dev 服务,再执行 npm run push-meta
* 产物:server/data/meta.json —— 需随线上设计版一起部署(git 提交或拷贝到 VPS)
*/
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import { bumpSchemaVersion } from '../db.js'
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
const SNAPSHOT_PATH = path.join(__dirname, '..', 'data', 'meta.json')
const LOCAL_API = process.env.LOCAL_API || 'http://localhost:3001'
const SCHEMA_VERSION = Number(process.env.SCHEMA_VERSION || 1)
const res = await fetch(`${LOCAL_API}/api/meta-snapshot`)
if (!res.ok) {
const body = await res.text().catch(() => '')
throw new Error(`拉取 meta 快照失败(请确认本地 dev 服务已启动): ${res.status} ${body}`)
}
const snapshot = await res.json()
fs.mkdirSync(path.dirname(SNAPSHOT_PATH), { recursive: true })
fs.writeFileSync(SNAPSHOT_PATH, JSON.stringify(snapshot), 'utf8')
const dbVersion = await bumpSchemaVersion(SCHEMA_VERSION)
console.log(`[push-meta] meta.json 已生成(${Buffer.byteLength(JSON.stringify(snapshot))} bytes)`)
console.log(`[push-meta] 数据库结构版本: v${dbVersion}(本服务 v${SCHEMA_VERSION})`)
console.log('[push-meta] 请将 meta.json 与最新代码一起部署到线上设计版')
process.exit(0)

88
put/web/dist/assets/index-CZTxENXL.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

13
put/web/dist/index.html vendored Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>英雄配置编辑器</title>
<script type="module" crossorigin src="/assets/index-CZTxENXL.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dzv_EDk3.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@@ -1,18 +1,33 @@
<template>
<div class="hero-app">
<header class="hero-header">
<h1>英雄配置编辑器</h1>
<h1>
英雄配置编辑器
<el-tag size="small" :type="isDev ? 'success' : 'warning'" effect="plain">
{{ isDev ? '项目开发版' : '线上设计版' }} v{{ meta?.schemaVersion ?? '-' }}
</el-tag>
</h1>
<div class="actions">
<el-radio-group v-model="view" size="small">
<el-radio-button value="heroes">英雄列表</el-radio-button>
<el-radio-button value="templates">预设库</el-radio-button>
</el-radio-group>
<el-button size="small" @click="refreshMeta" :loading="loadingMeta">刷新配置数据</el-button>
<el-button size="small" type="success" @click="doSave" :disabled="!heroes.length">保存到服务器</el-button>
<el-button size="small" type="success" @click="doSave" :disabled="!heroes.length || outdated">保存到数据库</el-button>
<template v-if="isDev">
<el-button size="small" type="primary" @click="doApply" :disabled="!heroes.length">写入 heroSet.ts</el-button>
<el-button size="small" type="danger" @click="doExportCsv" :disabled="!heroes.length">导出英雄设计表.csv</el-button>
</template>
</div>
</header>
<el-alert
v-if="outdated"
type="error"
:closable="false"
show-icon
:title="`数据库结构已升级到 v${meta.dbSchemaVersion},当前线上设计版(v${meta.schemaVersion})已切换为只读,请更新部署后再修改`"
style="margin-bottom: 8px"
/>
<div class="hero-main">
<!-- 预设库视图 -->
<TemplateManager
@@ -252,6 +267,10 @@ const editVisible = ref(false)
const current = computed(() => heroes.value[activeIdx.value])
// 版本/模式:isDev 区分开发版全功能;outdated 时线上设计版只读
const isDev = computed(() => meta.value?.mode === 'local')
const outdated = computed(() => (meta.value?.dbSchemaVersion ?? 0) > (meta.value?.schemaVersion ?? 0))
// 首页列表排序:none 原始顺序 / role 按职业枚举序(坦克→辅助)/ rarity 按星级
const sortKey = ref('none')
const sortOrder = ref('desc')
@@ -510,7 +529,7 @@ async function loadTemplates() {
async function doSave() {
try {
const r = await api.saveHeroes(heroes.value)
ElMessage.success(`已保存 ${r.count} 个英雄 → ${r.path}`)
ElMessage.success(`已保存 ${r.count} 个英雄到数据库`)
} catch (e) {
ElMessage.error('保存失败: ' + (e?.response?.data?.error || e.message))
}

View File

@@ -1,12 +1,16 @@
import axios from 'axios'
// 开发环境走 vite 代理(留空即可);部署静态站点时用 VITE_API_BASE 指向后端服务
const baseURL = import.meta.env.VITE_API_BASE || ''
const http = axios.create({ baseURL })
export const api = {
meta: () => axios.get('/api/meta').then((r) => r.data),
refresh: () => axios.post('/api/refresh').then((r) => r.data),
fetchHeroes: () => axios.get('/api/heroes').then((r) => r.data),
saveHeroes: (heroes) => axios.post('/api/heroes', { heroes }).then((r) => r.data),
applyHeroes: (heroes) => axios.post('/api/apply', { heroes }).then((r) => r.data),
exportCsv: (heroes) => axios.post('/api/export-csv', { heroes }).then((r) => r.data),
fetchTemplates: () => axios.get('/api/templates').then((r) => r.data),
saveTemplates: (templates) => axios.post('/api/templates', { templates }).then((r) => r.data),
meta: () => http.get('/api/meta').then((r) => r.data),
refresh: () => http.post('/api/refresh').then((r) => r.data),
fetchHeroes: () => http.get('/api/heroes').then((r) => r.data),
saveHeroes: (heroes) => http.post('/api/heroes', { heroes }).then((r) => r.data),
applyHeroes: (heroes) => http.post('/api/apply', { heroes }).then((r) => r.data),
exportCsv: (heroes) => http.post('/api/export-csv', { heroes }).then((r) => r.data),
fetchTemplates: () => http.get('/api/templates').then((r) => r.data),
saveTemplates: (templates) => http.post('/api/templates', { templates }).then((r) => r.data),
}

View File

@@ -4,6 +4,8 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
// 监听所有网卡,容器/局域网内可直接访问开发服务器
host: '0.0.0.0',
port: 4004,
strictPort: true,
proxy: {