koishi-plugin-ets2-tools-tmp 3.3.65 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,43 +21,19 @@ async function requestWithFallback(http, path) {
21
21
  module.exports = {
22
22
  /**
23
23
  * 查询服务器列表
24
+ * game: 'ETS2' 欧卡 / 'ATS' 美卡(接口 game 参数 1=欧卡 / 2=美卡,不传默认欧卡)
24
25
  */
25
26
  async serverList(http, game) {
27
+ const gameCode = game === 'ATS' ? 2 : 1
26
28
  let result = null
27
29
  try {
28
- result = game === 'ATS'
29
- ? await apiLog.get(http, 'evm.proxy.truckersmp.servers', `${BASE_APIS[0]}/proxy/truckersmp/servers`)
30
- : await requestWithFallback(http, `/server/list`)
30
+ result = await requestWithFallback(http, `/server/list?game=${gameCode}`)
31
31
  } catch {
32
32
  return {
33
33
  error: true
34
34
  }
35
35
  }
36
36
 
37
- if (game === 'ATS') {
38
- const servers = result?.response
39
- const isError = result?.error === true || !Array.isArray(servers)
40
- return {
41
- error: isError,
42
- ...(isError ? {} : {
43
- data: servers
44
- .filter(server => server.game === 'ATS')
45
- .map(server => ({
46
- serverName: server.name,
47
- isOnline: server.online ? 1 : 0,
48
- playerCount: server.players,
49
- maxPlayer: server.maxplayers,
50
- queueCount: server.queue || 0,
51
- afkEnable: server.afkenabled ? 1 : 0,
52
- collisionsEnable: server.collisions ? 1 : 0,
53
- policeCarEnable: server.policecarsforplayers ? 1 : 0,
54
- speedLimiterEnable: server.speedlimiter > 0 ? 1 : 0,
55
- playerHistory: []
56
- }))
57
- })
58
- }
59
- }
60
-
61
37
  // 拼接返回数据
62
38
  let data = {
63
39
  error: result.code !== 200
@@ -181,11 +157,13 @@ module.exports = {
181
157
  },
182
158
  /**
183
159
  * 查询玩家历史数据
160
+ * @param game 可选,2=美卡(接口不传默认欧卡)
184
161
  */
185
- async mapPlayerHistory(http, tmpId, serverId, startTime, endTime) {
162
+ async mapPlayerHistory(http, tmpId, serverId, startTime, endTime, game) {
186
163
  let result = null
187
164
  try {
188
- result = await requestWithFallback(http, `/map/playerHistory?tmpId=${tmpId || ''}&serverId=${serverId || ''}&startTime=${startTime || ''}&endTime=${endTime || ''}`)
165
+ const gameParam = game ? `&game=${game}` : ''
166
+ result = await requestWithFallback(http, `/map/playerHistory?tmpId=${tmpId || ''}&serverId=${serverId || ''}&startTime=${startTime || ''}&endTime=${endTime || ''}${gameParam}`)
189
167
  } catch {
190
168
  return {
191
169
  error: true
@@ -27,11 +27,13 @@ module.exports = {
27
27
  },
28
28
  /**
29
29
  * 查询热门交通数据
30
+ * @param game 'ets2' 欧卡 / 'ats' 美卡
30
31
  */
31
- async trafficTop (http, serverName) {
32
+ async trafficTop (http, serverName, game) {
33
+ const gameCode = game || 'ets2'
32
34
  let result = null
33
35
  try {
34
- result = await apiLog.get(http, 'trucky.trafficTop', `${BASE_API}/v2/traffic/top?game=ets2&server=${serverName}`)
36
+ result = await apiLog.get(http, 'trucky.trafficTop', `${BASE_API}/v2/traffic/top?game=${gameCode}&server=${serverName}`)
35
37
  } catch {
36
38
  return {
37
39
  error: true
@@ -2,7 +2,7 @@ const guildBind = require('../database/guildBind');
2
2
  const truckersMpApi = require("../api/truckersMpApi");
3
3
  const evmOpenApi = require('../api/evmOpenApi');
4
4
  /**
5
- * 绑定 TMP ID
5
+ * 绑定 TMP ID(支持绑定多个:追加入库,同一编号重复绑定会提示)
6
6
  */
7
7
  module.exports = async (ctx, cfg, session, tmpId) => {
8
8
  if (!tmpId || isNaN(tmpId)) {
@@ -13,7 +13,11 @@ module.exports = async (ctx, cfg, session, tmpId) => {
13
13
  if (playerInfo.error) {
14
14
  return '绑定失败 (查询玩家信息失败)';
15
15
  }
16
- // 更新数据库
17
- guildBind.saveOrUpdate(ctx.database, session.platform, session.userId, playerInfo.data.tmpId);
18
- return `绑定成功 ( ${playerInfo.data.name} )`;
16
+ // 追加绑定(同一 tmpId 已绑定过则提示,不重复入库)
17
+ const result = await guildBind.add(ctx.database, session.platform, session.userId, playerInfo.data.tmpId, playerInfo.data.name);
18
+ if (result.exists) {
19
+ return `该编号已在绑定列表中 ( ${playerInfo.data.name} ),无需重复绑定`;
20
+ }
21
+ const count = (await guildBind.list(ctx.database, session.platform, session.userId)).length;
22
+ return `绑定成功 ( ${playerInfo.data.name} ),当前已绑定 ${count} 个编号`;
19
23
  };
@@ -2,9 +2,10 @@ const { segment } = require('koishi')
2
2
  const dayjs = require('dayjs')
3
3
  const { resolve } = require('path')
4
4
  const common = require('../util/common')
5
- const { PromodsIds, ServerType } = require('../util/constant')
5
+ const { PromodsIds, AtsIds, AtsFootprintStartDate, ServerType } = require('../util/constant')
6
6
  const evmOpenApi = require('../api/evmOpenApi')
7
7
  const guildBind = require('../database/guildBind')
8
+ const tmpIdPicker = require('../util/tmpIdPicker')
8
9
 
9
10
  // 足迹统计数据的开始收集日期(仅用于近一年足迹图片中的提示展示)
10
11
  const FOOTPRINT_DATA_START_DATE = '2026-08-19'
@@ -81,13 +82,25 @@ module.exports = async (ctx, session, serverType, tmpId, date) => {
81
82
  return `请输入正确的玩家编号,或绑定玩家编号`
82
83
  }
83
84
 
84
- // 如果没有传入tmpId,尝试从数据库查询绑定信息
85
+ // 如果没有传入tmpId,尝试从绑定记录获取(多绑定时会列出序号供选择,支持多选=发多张足迹图)
85
86
  if (!tmpId) {
86
- let guildBindData = await guildBind.get(ctx.database, session.platform, session.userId)
87
- if (!guildBindData) {
87
+ const bindings = await guildBind.list(ctx.database, session.platform, session.userId)
88
+ if (bindings.length === 0) {
88
89
  return `请输入正确的玩家编号,或绑定玩家编号`
89
90
  }
90
- tmpId = guildBindData.tmp_id
91
+ const picked = await tmpIdPicker.pick(ctx, session, bindings)
92
+ if (picked === null) {
93
+ return // 已列出序号等待用户回复
94
+ }
95
+ if (picked.length > 1) {
96
+ const results = []
97
+ for (const id of picked) {
98
+ const r = await module.exports(ctx, session, serverType, id, date)
99
+ if (r != null) results.push(r)
100
+ }
101
+ return results.length === 1 ? results[0] : results
102
+ }
103
+ tmpId = picked[0]
91
104
  }
92
105
 
93
106
  // 查询玩家信息
@@ -131,16 +144,23 @@ module.exports = async (ctx, session, serverType, tmpId, date) => {
131
144
  return '日期传递错误';
132
145
  }
133
146
 
134
- let mapPlayerHistory = await evmOpenApi.mapPlayerHistory(ctx.http, tmpId, null, startTime, endTime)
147
+ // 是否为美卡足迹(决定历史接口的 game 参数与过滤规则)
148
+ const isAts = ServerType.ats === serverType
149
+ // 查询美卡历史需要显式传 game=2(不传默认欧卡,欧卡/Promods 维持原样)
150
+ let mapPlayerHistory = await evmOpenApi.mapPlayerHistory(ctx.http, tmpId, null, startTime, endTime, isAts ? 2 : undefined)
135
151
  if (mapPlayerHistory.error) {
136
152
  return '查询玩家历史位置数据失败,请稍后重试'
137
153
  }
138
154
 
139
155
  // 过滤非对应服务器数据(单次遍历统计轨迹范围与有效点数,避免百万级数据产生额外数组拷贝)
140
156
  const promodsIdSet = new Set(PromodsIds)
157
+ const atsIdSet = new Set(AtsIds)
141
158
  const isTargetServer = item => {
142
- if (ServerType.ets === serverType) {
143
- return !promodsIdSet.has(item.serverId)
159
+ if (isAts) {
160
+ return atsIdSet.has(item.serverId)
161
+ } else if (ServerType.ets === serverType) {
162
+ // 欧卡:排除 Promods 与美卡的服务器
163
+ return !promodsIdSet.has(item.serverId) && !atsIdSet.has(item.serverId)
144
164
  } else if (ServerType.promods === serverType) {
145
165
  return promodsIdSet.has(item.serverId)
146
166
  }
@@ -191,16 +211,19 @@ module.exports = async (ctx, session, serverType, tmpId, date) => {
191
211
 
192
212
  // 拼接数据(今日足迹展示今日里程;其余展示该区间里程(distance 累加)与玩家总里程)
193
213
  const isToday = date === 'today'
214
+ const rangeTextMap = { today: '今日', yesterday: '昨日', sevenday: '近七日', month: '近一个月', year: '近一年' }[date] || ''
194
215
  let data = {
195
- mapType: ServerType.promods === serverType ? 'promods' : 'ets',
216
+ mapType: isAts ? 'ats' : (ServerType.promods === serverType ? 'promods' : 'ets'),
196
217
  name: playerInfo.data.name,
197
218
  smallAvatarUrl: playerInfo.data.smallAvatarUrl,
198
- rangeText: { today: '今日', yesterday: '昨日', sevenday: '近七日', month: '近一个月', year: '近一年' }[date] || '',
219
+ rangeText: isAts ? `${rangeTextMap}(美卡)` : rangeTextMap,
199
220
  isToday,
200
221
  mileageLabel: isToday ? '今日里程' : '该区间里程',
201
222
  mileage: isToday ? playerInfo.data.todayMileage : intervalDistance,
202
223
  totalMileage: isToday ? null : playerInfo.data.mileage,
203
- dataStartDate: date === 'year' ? FOOTPRINT_DATA_START_DATE : null,
224
+ etsMileage: isToday ? null : playerInfo.data.mileageEts2,
225
+ atsMileage: isToday ? null : playerInfo.data.mileageAts,
226
+ dataStartDate: date === 'year' ? (isAts ? AtsFootprintStartDate : FOOTPRINT_DATA_START_DATE) : null,
204
227
  segments
205
228
  }
206
229
 
@@ -3,6 +3,7 @@ const { resolve } = require('path');
3
3
  const common = require('../util/common');
4
4
  const evmOpenApi = require('../api/evmOpenApi');
5
5
  const guildBind = require('../database/guildBind');
6
+ const tmpIdPicker = require('../util/tmpIdPicker');
6
7
  module.exports = async (ctx, session, rankingType) => {
7
8
  if (!ctx.puppeteer) {
8
9
  return '未启用 Puppeteer 功能';
@@ -15,11 +16,21 @@ module.exports = async (ctx, session, rankingType) => {
15
16
  else if (mileageRankingList.data.length === 0) {
16
17
  return '暂无数据';
17
18
  }
18
- // 查询当前玩家的排行信息
19
- let guildBindData = await guildBind.get(ctx.database, session.platform, session.userId);
19
+ // 查询当前玩家的排行信息(多绑定时先单选一个,排行榜只高亮一个人)
20
+ const bindings = await guildBind.list(ctx.database, session.platform, session.userId);
21
+ let bindTmpId = null;
22
+ if (bindings.length === 1) {
23
+ bindTmpId = String(bindings[0].tmp_id);
24
+ } else if (bindings.length > 1) {
25
+ const picked = await tmpIdPicker.pick(ctx, session, bindings, { allowMulti: false });
26
+ if (picked === null) {
27
+ return; // 已列出序号等待用户回复
28
+ }
29
+ bindTmpId = picked[0];
30
+ }
20
31
  let playerMileageRanking = null;
21
- if (guildBindData) {
22
- let playerMileageRankingResult = await evmOpenApi.mileageRankingList(ctx.http, rankingType, guildBindData.tmp_id);
32
+ if (bindTmpId) {
33
+ let playerMileageRankingResult = await evmOpenApi.mileageRankingList(ctx.http, rankingType, bindTmpId);
23
34
  if (!playerMileageRankingResult.error && playerMileageRankingResult.data.length > 0) {
24
35
  playerMileageRanking = playerMileageRankingResult.data[0];
25
36
  }
@@ -1,6 +1,7 @@
1
1
  const { segment } = require('koishi')
2
2
  const { resolve } = require('path')
3
3
  const guildBind = require('../database/guildBind')
4
+ const tmpIdPicker = require('../util/tmpIdPicker')
4
5
  const truckyAppApi = require('../api/truckyAppApi')
5
6
  const truckersMpApi = require('../api/truckersMpApi')
6
7
  const evmOpenApi = require('../api/evmOpenApi')
@@ -32,13 +33,25 @@ module.exports = async (ctx, cfg, session, tmpId) => {
32
33
  }
33
34
  tmpId = guildBindData.tmp_id;
34
35
  }
35
- // 如果没有传入tmpId,尝试从数据库查询绑定信息
36
+ // 如果没有传入tmpId,尝试从绑定记录获取(多绑定时会列出序号供选择,支持多选=发多张定位图)
36
37
  if (!tmpId) {
37
- let guildBindData = await guildBind.get(ctx.database, session.platform, session.userId)
38
- if (!guildBindData) {
38
+ const bindings = await guildBind.list(ctx.database, session.platform, session.userId)
39
+ if (bindings.length === 0) {
39
40
  return `请输入正确的玩家编号`
40
41
  }
41
- tmpId = guildBindData.tmp_id
42
+ const picked = await tmpIdPicker.pick(ctx, session, bindings)
43
+ if (picked === null) {
44
+ return // 已列出序号等待用户回复
45
+ }
46
+ if (picked.length > 1) {
47
+ const results = []
48
+ for (const id of picked) {
49
+ const r = await module.exports(ctx, cfg, session, id)
50
+ if (r != null) results.push(r)
51
+ }
52
+ return results.length === 1 ? results[0] : results
53
+ }
54
+ tmpId = picked[0]
42
55
  }
43
56
 
44
57
  // 查询玩家信息
@@ -2,6 +2,7 @@ const dayjs = require('dayjs');
2
2
  const dayjsRelativeTime = require('dayjs/plugin/relativeTime');
3
3
  const dayjsLocaleZhCn = require('dayjs/locale/zh-cn');
4
4
  const guildBind = require('../../database/guildBind');
5
+ const tmpIdPicker = require('../../util/tmpIdPicker');
5
6
  const truckyAppApi = require('../../api/truckyAppApi');
6
7
  const evmOpenApi = require('../../api/evmOpenApi');
7
8
  const baiduTranslate = require('../../util/baiduTranslate');
@@ -80,13 +81,25 @@ module.exports = async (ctx, cfg, session, tmpId) => {
80
81
  }
81
82
  tmpId = guildBindData.tmp_id;
82
83
  }
83
- // 如果没有传入tmpId,尝试从数据库查询绑定信息
84
+ // 如果没有传入tmpId,尝试从绑定记录获取(多绑定时会列出序号供选择,支持多选)
84
85
  if (!tmpId) {
85
- let guildBindData = await guildBind.get(ctx.database, session.platform, session.userId);
86
- if (!guildBindData) {
86
+ const bindings = await guildBind.list(ctx.database, session.platform, session.userId);
87
+ if (bindings.length === 0) {
87
88
  return `请输入正确的玩家编号`;
88
89
  }
89
- tmpId = guildBindData.tmp_id;
90
+ const picked = await tmpIdPicker.pick(ctx, session, bindings);
91
+ if (picked === null) {
92
+ return; // 已列出序号等待用户回复
93
+ }
94
+ if (picked.length > 1) {
95
+ const results = [];
96
+ for (const id of picked) {
97
+ const r = await module.exports(ctx, cfg, session, id);
98
+ if (r != null) results.push(r);
99
+ }
100
+ return results.length === 1 ? results[0] : results;
101
+ }
102
+ tmpId = picked[0];
90
103
  }
91
104
  // 并行请求玩家信息与线上状态(互不依赖,避免串行等待)
92
105
  const [playerInfo, playerMapInfo] = await Promise.all([
@@ -176,8 +189,13 @@ module.exports = async (ctx, cfg, session, tmpId) => {
176
189
  data.sponsorAmount = playerInfo.data.sponsorAmount;
177
190
  data.sponsorCumulativeAmount = playerInfo.data.sponsorCumulativeAmount;
178
191
  data.sponsorHide = playerInfo.data.sponsorHide;
192
+ // 里程:mileage/todayMileage 为欧卡+美卡合计,另附两款游戏的单独里程
179
193
  data.mileage = playerInfo.data.mileage;
180
194
  data.todayMileage = playerInfo.data.todayMileage;
195
+ data.mileageEts2 = playerInfo.data.mileageEts2;
196
+ data.mileageAts = playerInfo.data.mileageAts;
197
+ data.todayMileageEts2 = playerInfo.data.todayMileageEts2;
198
+ data.todayMileageAts = playerInfo.data.todayMileageAts;
181
199
  data.isOnline = false;
182
200
  data.onlineStatus = '离线';
183
201
  if (playerMapInfo && !playerMapInfo.error) {
@@ -2,11 +2,38 @@ const dayjs = require('dayjs');
2
2
  const dayjsRelativeTime = require('dayjs/plugin/relativeTime');
3
3
  const dayjsLocaleZhCn = require('dayjs/locale/zh-cn');
4
4
  const guildBind = require('../../database/guildBind');
5
+ const tmpIdPicker = require('../../util/tmpIdPicker');
5
6
  const truckyAppApi = require('../../api/truckyAppApi');
6
7
  const evmOpenApi = require('../../api/evmOpenApi');
7
8
  const baiduTranslate = require('../../util/baiduTranslate');
8
9
  dayjs.extend(dayjsRelativeTime);
9
10
  dayjs.locale(dayjsLocaleZhCn);
11
+
12
+ /**
13
+ * 里程文本:>=1000 米换算成公里;0 显示成「0公里」(表示这款游戏没跑过)
14
+ */
15
+ function formatMileageText(value) {
16
+ const n = Number(value) || 0;
17
+ if (n <= 0) {
18
+ return '0公里';
19
+ }
20
+ if (n >= 1000) {
21
+ return Number((n / 1000).toFixed(1)).toLocaleString('en-US') + '公里';
22
+ }
23
+ return n + '米';
24
+ }
25
+
26
+ /**
27
+ * 欧卡/美卡里程明细文本(两者都为 0 时返回空串)
28
+ */
29
+ function formatMileageDetail(ets2, ats) {
30
+ const ets2Val = Number(ets2) || 0;
31
+ const atsVal = Number(ats) || 0;
32
+ if (ets2Val <= 0 && atsVal <= 0) {
33
+ return '';
34
+ }
35
+ return ` (欧卡 ${formatMileageText(ets2Val)} / 美卡 ${formatMileageText(atsVal)})`;
36
+ }
10
37
  /**
11
38
  * 用户组
12
39
  */
@@ -43,13 +70,25 @@ module.exports = async (ctx, cfg, session, tmpId) => {
43
70
  }
44
71
  tmpId = guildBindData.tmp_id;
45
72
  }
46
- // 如果没有传入tmpId,尝试从数据库查询绑定信息
73
+ // 如果没有传入tmpId,尝试从绑定记录获取(多绑定时会列出序号供选择,支持多选)
47
74
  if (!tmpId) {
48
- let guildBindData = await guildBind.get(ctx.database, session.platform, session.userId);
49
- if (!guildBindData) {
75
+ const bindings = await guildBind.list(ctx.database, session.platform, session.userId);
76
+ if (bindings.length === 0) {
50
77
  return `请输入正确的玩家编号`;
51
78
  }
52
- tmpId = guildBindData.tmp_id;
79
+ const picked = await tmpIdPicker.pick(ctx, session, bindings);
80
+ if (picked === null) {
81
+ return; // 已列出序号等待用户回复
82
+ }
83
+ if (picked.length > 1) {
84
+ const results = [];
85
+ for (const id of picked) {
86
+ const r = await module.exports(ctx, cfg, session, id);
87
+ if (r != null) results.push(r);
88
+ }
89
+ return results.length === 1 ? results[0] : results;
90
+ }
91
+ tmpId = picked[0];
53
92
  }
54
93
  // 并行请求玩家信息与线上状态(互不依赖,避免串行等待)
55
94
  const [playerInfo, playerMapInfo] = await Promise.all([
@@ -139,23 +178,14 @@ module.exports = async (ctx, cfg, session, tmpId) => {
139
178
  }
140
179
  }
141
180
  message += '\n🚫封禁次数: ' + (playerInfo.data.banCount || 0);
181
+ // 历史/今日里程为欧卡+美卡合计,后面附上两款游戏的单独里程
142
182
  if (playerInfo.data.mileage) {
143
- let mileage = playerInfo.data.mileage;
144
- let mileageUnit = '米';
145
- if (mileage > 1000) {
146
- mileage = (mileage / 1000).toFixed(1);
147
- mileageUnit = '公里';
148
- }
149
- message += '\n\n🚩历史里程: ' + mileage + mileageUnit;
183
+ message += '\n\n🚩历史里程: ' + formatMileageText(playerInfo.data.mileage)
184
+ + formatMileageDetail(playerInfo.data.mileageEts2, playerInfo.data.mileageAts);
150
185
  }
151
186
  if (playerInfo.data.todayMileage) {
152
- let todayMileage = playerInfo.data.todayMileage;
153
- let mileageUnit = '米';
154
- if (todayMileage > 1000) {
155
- todayMileage = (todayMileage / 1000).toFixed(1);
156
- mileageUnit = '公里';
157
- }
158
- message += '\n🚩今日里程: ' + todayMileage + mileageUnit;
187
+ message += '\n🚩今日里程: ' + formatMileageText(playerInfo.data.todayMileage)
188
+ + formatMileageDetail(playerInfo.data.todayMileageEts2, playerInfo.data.todayMileageAts);
159
189
  }
160
190
  if (playerMapInfo && !playerMapInfo.error) {
161
191
  message += '\n📶在线状态: ' + (playerMapInfo.data.online ? `在线🟢 (${playerMapInfo.data.serverDetails.name})` : '离线⚫');
@@ -1,5 +1,6 @@
1
1
  const truckyAppApi = require('../../api/truckyAppApi');
2
2
  const evmOpenApi = require('../../api/evmOpenApi');
3
+ const truckersMpMapApi = require('../../api/truckersMpMapApi');
3
4
  const baiduTranslate = require('../../util/baiduTranslate');
4
5
  const { resolve } = require("path");
5
6
  const common = require("../../util/common");
@@ -31,6 +32,28 @@ const serverAlias = {
31
32
  mapType: 'ets',
32
33
  serverId: 7,
33
34
  bounds: [[-94189, 93775], [79264, -78999]]
35
+ },
36
+ // 美卡:evm 的 /map/playerList 没有美卡数据,玩家位置改走 ets2map tracker(坐标同为游戏坐标)
37
+ '美s': {
38
+ name: 'eusim',
39
+ game: 'ats',
40
+ mapType: 'ats',
41
+ serverId: 8,
42
+ bounds: [[-122000, 100000], [39000, -88000]]
43
+ },
44
+ '美uss': {
45
+ name: 'ussim',
46
+ game: 'ats',
47
+ mapType: 'ats',
48
+ serverId: 10,
49
+ bounds: [[-122000, 100000], [39000, -88000]]
50
+ },
51
+ '美usa': {
52
+ name: 'usarc',
53
+ game: 'ats',
54
+ mapType: 'ats',
55
+ serverId: 45,
56
+ bounds: [[-122000, 100000], [39000, -88000]]
34
57
  }
35
58
  };
36
59
  /**
@@ -72,24 +95,42 @@ module.exports = async (ctx, cfg, serverName) => {
72
95
  // 根据别名获取服务器信息
73
96
  let serverInfo = serverAlias[serverName];
74
97
  if (!serverInfo) {
75
- return '请输入正确的服务器名称 (s1, s2, p, a)';
98
+ return '请输入正确的服务器名称 (s1, s2, p, a, 美s, 美uss, 美usa)';
76
99
  }
77
100
  // 查询路况信息
78
- let trafficData = await truckyAppApi.trafficTop(ctx.http, serverInfo.name);
101
+ let trafficData = await truckyAppApi.trafficTop(ctx.http, serverInfo.name, serverInfo.game);
79
102
  if (trafficData.error) {
80
103
  return '查询路况信息失败';
81
104
  }
82
- // 查询地图玩家数据
83
- let mapData = await evmOpenApi.mapPlayerList(ctx.http, serverInfo.serverId, serverInfo.bounds[0][0], serverInfo.bounds[0][1], serverInfo.bounds[1][0], serverInfo.bounds[1][1]);
105
+ // 查询地图玩家数据(美卡走 ets2map tracker,欧卡/Promods 走 evm)
106
+ let mapData;
107
+ if (serverInfo.game === 'ats') {
108
+ mapData = await truckersMpMapApi.area(ctx.http, serverInfo.serverId,
109
+ serverInfo.bounds[0][0], serverInfo.bounds[0][1], serverInfo.bounds[1][0], serverInfo.bounds[1][1]);
110
+ } else {
111
+ mapData = await evmOpenApi.mapPlayerList(ctx.http, serverInfo.serverId,
112
+ serverInfo.bounds[0][0], serverInfo.bounds[0][1], serverInfo.bounds[1][0], serverInfo.bounds[1][1]);
113
+ }
114
+ // 两个接口的坐标字段名不同:tracker 为 X/Y,evm 为 axisX/axisY(数值都是游戏坐标)
115
+ const coordOf = serverInfo.game === 'ats'
116
+ ? (item => [item.X, item.Y])
117
+ : (item => [item.axisX, item.axisY]);
118
+ // 接口失败或返回异常时用空数组兜底,避免对 undefined 调 map 导致指令崩溃
119
+ const playerCoordinateList = (!mapData.error && Array.isArray(mapData.data))
120
+ ? mapData.data.map(coordOf)
121
+ : [];
84
122
 
85
123
  switch (serverName) {
86
124
  case 's1':
87
125
  case 's2':
88
- case 'a': {
126
+ case 'a':
127
+ case '美s':
128
+ case '美uss':
129
+ case '美usa': {
89
130
  let data = {
90
131
  mapType: serverInfo.mapType,
91
132
  trafficList: [],
92
- playerCoordinateList: mapData.error && mapData.data ? [] : mapData.data.map(item => [item.axisX, item.axisY])
133
+ playerCoordinateList
93
134
  };
94
135
  for (const traffic of trafficData.data) {
95
136
  data.trafficList.push({
@@ -126,7 +167,7 @@ module.exports = async (ctx, cfg, serverName) => {
126
167
  let data = {
127
168
  mapType: serverInfo.mapType,
128
169
  trafficList: [],
129
- playerCoordinateList: mapData.error && mapData.data ? [] : mapData.data.map(item => [item.axisX, item.axisY])
170
+ playerCoordinateList
130
171
  };
131
172
  for (const traffic of trafficData.data) {
132
173
  data.trafficList.push({
@@ -11,7 +11,19 @@ const serverNameAlias = {
11
11
  'S1': 'sim1',
12
12
  'S2': 'sim2',
13
13
  'P': 'eupromods1',
14
- 'A': 'arc1'
14
+ 'A': 'arc1',
15
+ // 美卡(Trucky 的美卡服务器代号:eusim/ussim/usarc)
16
+ '美s': 'eusim',
17
+ '美uss': 'ussim',
18
+ '美usa': 'usarc'
19
+ };
20
+ /**
21
+ * 别名对应的车队游戏(路况接口的 game 参数)
22
+ */
23
+ const aliasGame = {
24
+ '美s': 'ats',
25
+ '美uss': 'ats',
26
+ '美usa': 'ats'
15
27
  };
16
28
  /**
17
29
  * 路况程度转中文
@@ -36,9 +48,9 @@ const typeToZh = {
36
48
  module.exports = async (ctx, cfg, serverName) => {
37
49
  let serverQueryName = serverNameAlias[serverName];
38
50
  if (!serverQueryName) {
39
- return '请输入正确的服务器名称 (s1, s2, p, a)';
51
+ return '请输入正确的服务器名称 (s1, s2, p, a, 美s, 美uss, 美usa)';
40
52
  }
41
- let trafficData = await truckyAppApi.trafficTop(ctx.http, serverQueryName);
53
+ let trafficData = await truckyAppApi.trafficTop(ctx.http, serverQueryName, aliasGame[serverName]);
42
54
  if (trafficData.error) {
43
55
  return '查询路况信息失败';
44
56
  }
@@ -3,6 +3,7 @@ const { resolve } = require('path');
3
3
  const common = require('../util/common');
4
4
  const evmOpenApi = require('../api/evmOpenApi');
5
5
  const guildBind = require('../database/guildBind');
6
+ const tmpIdPicker = require('../util/tmpIdPicker');
6
7
  module.exports = async (ctx, cfg, session, rankingType) => {
7
8
  if (!ctx.puppeteer) {
8
9
  return '未启用 Puppeteer 功能';
@@ -19,11 +20,21 @@ module.exports = async (ctx, cfg, session, rankingType) => {
19
20
  else if (mileageRankingList.data.length === 0) {
20
21
  return '暂无数据';
21
22
  }
22
- // 从排行榜中查找当前玩家的排行信息
23
- let guildBindData = await guildBind.get(ctx.database, session.platform, session.userId);
23
+ // 从排行榜中查找当前玩家的排行信息(多绑定时先单选一个,排行榜只高亮一个人)
24
+ const bindings = await guildBind.list(ctx.database, session.platform, session.userId);
25
+ let bindTmpId = null;
26
+ if (bindings.length === 1) {
27
+ bindTmpId = String(bindings[0].tmp_id);
28
+ } else if (bindings.length > 1) {
29
+ const picked = await tmpIdPicker.pick(ctx, session, bindings, { allowMulti: false });
30
+ if (picked === null) {
31
+ return; // 已列出序号等待用户回复
32
+ }
33
+ bindTmpId = picked[0];
34
+ }
24
35
  let playerMileageRanking = null;
25
- if (guildBindData) {
26
- playerMileageRanking = mileageRankingList.data.find(p => p.tmpId == guildBindData.tmp_id) || null;
36
+ if (bindTmpId) {
37
+ playerMileageRanking = mileageRankingList.data.find(p => p.tmpId == bindTmpId) || null;
27
38
  }
28
39
  // 拼接页面数据
29
40
  let data = {
@@ -1,6 +1,11 @@
1
+ /**
2
+ * TMP ID 绑定(支持一人绑定多个)
3
+ * 表 tmp_guild_bind:一个绑定一行,同一 (platform, user_id) 可有多行;
4
+ * get() 返回第一行(默认绑定),保持与旧版行为兼容。
5
+ */
1
6
  module.exports = {
2
7
  /**
3
- * 获取绑定信息
8
+ * 获取默认绑定(第一行,兼容旧逻辑)
4
9
  * @param db 数据源
5
10
  * @param platform 平台
6
11
  * @param userId 用户编号
@@ -16,26 +21,52 @@ module.exports = {
16
21
  return null;
17
22
  },
18
23
  /**
19
- * 新增或更新绑定信息
20
- * @param db 数据源
21
- * @param platform 平台
22
- * @param userId 用户编号
24
+ * 获取全部绑定(按绑定先后排序)
25
+ * @returns 绑定行数组,可能为空数组
26
+ */
27
+ async list(db, platform, userId) {
28
+ const guildBindList = await db.get('tmp_guild_bind', {
29
+ platform,
30
+ user_id: userId
31
+ });
32
+ if (!guildBindList || guildBindList.length === 0) {
33
+ return [];
34
+ }
35
+ return guildBindList.sort((a, b) => a.id - b.id);
36
+ },
37
+ /**
38
+ * 追加绑定(同一 tmpId 重复绑定时不会重复入库)
23
39
  * @param tmpId TMP ID
40
+ * @param tmpName TMP 玩家名称(用于多绑定列表展示,可为 null)
41
+ * @returns { exists, row } exists=true 表示该 tmpId 已绑定过
24
42
  */
25
- saveOrUpdate(db, platform, userId, tmpId) {
26
- this.get(db, platform, userId).then((data) => {
27
- if (data) {
28
- db.set('tmp_guild_bind', data.id, {
29
- tmp_id: tmpId
30
- });
31
- }
32
- else {
33
- db.create('tmp_guild_bind', {
34
- platform: platform,
35
- user_id: userId,
36
- tmp_id: tmpId
37
- });
38
- }
43
+ async add(db, platform, userId, tmpId, tmpName) {
44
+ const guildBindList = await this.list(db, platform, userId);
45
+ const existed = guildBindList.find(row => String(row.tmp_id) === String(tmpId));
46
+ if (existed) {
47
+ return { exists: true, row: existed };
48
+ }
49
+ const row = await db.create('tmp_guild_bind', {
50
+ platform: platform,
51
+ user_id: userId,
52
+ tmp_id: tmpId,
53
+ tmp_name: tmpName || null
54
+ });
55
+ return { exists: false, row };
56
+ },
57
+ /**
58
+ * 删除指定主键的绑定
59
+ */
60
+ async remove(db, id) {
61
+ await db.remove('tmp_guild_bind', id);
62
+ },
63
+ /**
64
+ * 删除指定用户的全部绑定
65
+ */
66
+ async removeAll(db, platform, userId) {
67
+ await db.remove('tmp_guild_bind', {
68
+ platform,
69
+ user_id: userId
39
70
  });
40
71
  }
41
72
  };
@@ -26,6 +26,12 @@ const modelArray = {
26
26
  length: 50,
27
27
  nullable: false,
28
28
  comment: 'TMP ID'
29
+ },
30
+ tmp_name: {
31
+ type: 'string',
32
+ length: 100,
33
+ nullable: true,
34
+ comment: 'TMP玩家名称(绑定时记录,用于多绑定列表展示)'
29
35
  }
30
36
  },
31
37
  tmp_translate_cache: {
package/lib/index.js CHANGED
@@ -8,6 +8,7 @@ const model = require('./database/model');
8
8
  const { ServerType } = require('./util/constant');
9
9
  const { MileageRankingType } = require('./util/constant');
10
10
  const { LeadRoleCallMessage, LeadRoleCallTime } = require('./util/constant');
11
+ const tmpIdPicker = require('./util/tmpIdPicker');
11
12
 
12
13
  const commands = {
13
14
  tmpQuery: require('./command/tmpQuery/tmpQuery'),
@@ -258,8 +259,44 @@ function registerBaseCommands(ctx, cfg) {
258
259
 
259
260
  if (cfg.commands?.tmpQuery) {
260
261
  ctx.command('绑定 <tmpId>')
261
- .usage("绑定TmpId")
262
+ .usage("绑定TmpId(可绑定多个,查询时会列出序号供选择)")
262
263
  .action(async ({ session }, tmpId) => await commands.tmpBind(ctx, cfg, session, tmpId));
264
+
265
+ ctx.command('我的绑定')
266
+ .usage("查看已绑定的全部TMP编号")
267
+ .action(async ({ session }) => {
268
+ const bindings = await guildBind.list(ctx.database, session.platform, session.userId);
269
+ if (bindings.length === 0) {
270
+ return '还没有绑定编号,使用「绑定 tmpId」添加';
271
+ }
272
+ const lines = bindings.map((b, i) => `${i + 1}. ${b.tmp_name || '未知'} (${b.tmp_id})`);
273
+ return `已绑定 ${bindings.length} 个编号:\n${lines.join('\n')}${bindings.length > 1 ? '\n查询时会列出序号供选择;使用「解绑 序号」可删除' : ''}`;
274
+ });
275
+
276
+ ctx.command('解绑 <index:string>')
277
+ .usage("删除已绑定的编号(序号见「我的绑定」,all 为全部删除)")
278
+ .example("解绑 2")
279
+ .example("解绑 all")
280
+ .action(async ({ session }, index) => {
281
+ const bindings = await guildBind.list(ctx.database, session.platform, session.userId);
282
+ if (bindings.length === 0) {
283
+ return '还没有绑定编号';
284
+ }
285
+ if (!index) {
286
+ return '请输入要解绑的序号或 all(序号见「我的绑定」)';
287
+ }
288
+ if (/^all$/i.test(index.trim())) {
289
+ await guildBind.removeAll(ctx.database, session.platform, session.userId);
290
+ return `已删除全部 ${bindings.length} 个绑定`;
291
+ }
292
+ const idx = parseInt(index, 10);
293
+ if (isNaN(idx) || idx < 1 || idx > bindings.length) {
294
+ return `序号无效,可用范围 1-${bindings.length}(序号见「我的绑定」)`;
295
+ }
296
+ const row = bindings[idx - 1];
297
+ await guildBind.remove(ctx.database, row.id);
298
+ return `已解绑 ${idx}. ${row.tmp_name || '未知'} (${row.tmp_id})`;
299
+ });
263
300
  }
264
301
 
265
302
  if (cfg.commands?.tmpTraffic) {
@@ -338,6 +375,36 @@ function registerBaseCommands(ctx, cfg) {
338
375
  .example("近一年足迹p 12345")
339
376
  .action(async ({ session }, tmpId) => await commands.tmpFootprint(ctx, session, ServerType.promods, tmpId, 'year'));
340
377
 
378
+ ctx.command('近一年足迹美 [tmpId:string]')
379
+ .usage("查询美卡(ATS)服务器足迹")
380
+ .example("近一年足迹美")
381
+ .example("近一年足迹美 12345")
382
+ .action(async ({ session }, tmpId) => await commands.tmpFootprint(ctx, session, ServerType.ats, tmpId, 'year'));
383
+
384
+ ctx.command('近一个月足迹美 [tmpId:string]')
385
+ .usage("查询美卡(ATS)服务器足迹")
386
+ .example("近一个月足迹美")
387
+ .example("近一个月足迹美 12345")
388
+ .action(async ({ session }, tmpId) => await commands.tmpFootprint(ctx, session, ServerType.ats, tmpId, 'month'));
389
+
390
+ ctx.command('近七日足迹美 [tmpId:string]')
391
+ .usage("查询美卡(ATS)服务器足迹")
392
+ .example("近七日足迹美")
393
+ .example("近七日足迹美 12345")
394
+ .action(async ({ session }, tmpId) => await commands.tmpFootprint(ctx, session, ServerType.ats, tmpId, 'sevenday'));
395
+
396
+ ctx.command('昨日足迹美 [tmpId:string]')
397
+ .usage("查询美卡(ATS)服务器足迹")
398
+ .example("昨日足迹美")
399
+ .example("昨日足迹美 12345")
400
+ .action(async ({ session }, tmpId) => await commands.tmpFootprint(ctx, session, ServerType.ats, tmpId, 'yesterday'));
401
+
402
+ ctx.command('今日足迹美 [tmpId:string]')
403
+ .usage("查询美卡(ATS)服务器今日足迹")
404
+ .example("今日足迹美")
405
+ .example("今日足迹美 12345")
406
+ .action(async ({ session }, tmpId) => await commands.tmpFootprint(ctx, session, ServerType.ats, tmpId, 'today'));
407
+
341
408
  ctx.command('近一个月足迹 [tmpId:string]')
342
409
  .usage("查询ETS服务器今日足迹")
343
410
  .example("近一个月足迹")
@@ -419,6 +486,9 @@ function apply(ctx, cfg) {
419
486
  // 初始化 API 请求日志(调试模式开启后,truckersmp/trucky 等接口会输出请求与错误日志)
420
487
  apiLog.init(ctx, cfg.debugMode, cfg.apiRetryCount);
421
488
 
489
+ // 多绑定选择器中间件(捕获用户回复的序号,完成"列出绑定→序号选择"交互)
490
+ tmpIdPicker.register(ctx);
491
+
422
492
  try {
423
493
  model(ctx);
424
494
  if (cfg.debugMode) {
@@ -98,8 +98,8 @@
98
98
  z-index: 1001;
99
99
  display: flex;
100
100
  align-items: center;
101
- gap: 18px;
102
- padding: 11px 20px;
101
+ gap: 12px;
102
+ padding: 10px 16px;
103
103
  border-radius: 14px;
104
104
  box-sizing: border-box;
105
105
  }
@@ -107,16 +107,17 @@
107
107
  display: flex;
108
108
  flex-direction: column;
109
109
  gap: 2px;
110
- min-width: 110px;
111
110
  }
112
111
  .mileage-block .label {
113
- font-size: 11px;
114
- letter-spacing: 2px;
112
+ font-size: 10px;
113
+ letter-spacing: 1.5px;
115
114
  color: #8fa3c8;
115
+ white-space: nowrap;
116
116
  }
117
117
  .mileage-block .value {
118
- font-size: 21px;
118
+ font-size: 18px;
119
119
  font-weight: 800;
120
+ white-space: nowrap;
120
121
  background: linear-gradient(90deg, #4ade80, #a3e635);
121
122
  -webkit-background-clip: text;
122
123
  background-clip: text;
@@ -136,14 +137,15 @@
136
137
  .legend {
137
138
  display: flex;
138
139
  align-items: center;
139
- gap: 14px;
140
+ gap: 10px;
140
141
  }
141
142
  .legend .item {
142
143
  display: flex;
143
144
  align-items: center;
144
- gap: 6px;
145
- font-size: 11.5px;
145
+ gap: 5px;
146
+ font-size: 10.5px;
146
147
  color: #9fb3d9;
148
+ white-space: nowrap;
147
149
  }
148
150
  .legend .line-sample {
149
151
  width: 20px;
@@ -165,13 +167,15 @@
165
167
  /* 数据说明(近一年足迹时显示统计开始日期) */
166
168
  .note {
167
169
  margin-left: auto;
168
- font-size: 11px;
170
+ font-size: 10.5px;
169
171
  color: #fcd34d;
170
172
  background: rgba(251, 191, 36, .1);
171
173
  border: 1px solid rgba(251, 191, 36, .32);
172
- padding: 5px 12px;
174
+ padding: 4px 10px;
173
175
  border-radius: 8px;
174
176
  letter-spacing: .5px;
177
+ white-space: nowrap;
178
+ flex-shrink: 0;
175
179
  }
176
180
  .hidden { display: none; }
177
181
  </style>
@@ -201,6 +205,11 @@
201
205
  <span class="label">总里程</span>
202
206
  <span class="value" id="total-distance">0.0 km</span>
203
207
  </div>
208
+ <div class="v-divider" id="game-mileage-divider"></div>
209
+ <div class="mileage-block" id="game-mileage-block">
210
+ <span class="label" id="game-mileage-label">欧卡总里程</span>
211
+ <span class="value" id="game-mileage-distance">0.0 km</span>
212
+ </div>
204
213
  <div class="v-divider"></div>
205
214
  <div class="legend">
206
215
  <div class="item"><span class="line-sample"></span>行驶轨迹</div>
@@ -233,10 +242,24 @@
233
242
  document.getElementById('distance').classList.add('cyan');
234
243
  const totalKm = Number(((data.totalMileage || 0) / 1000).toFixed(1)).toLocaleString('zh-CN');
235
244
  document.getElementById('total-distance').innerText = `${totalKm} km`;
245
+ // 本游戏总里程:美卡足迹显示美卡总里程,欧卡/Promods 足迹显示欧卡总里程
246
+ const gameBlock = document.getElementById('game-mileage-block');
247
+ const gameDivider = document.getElementById('game-mileage-divider');
248
+ const gameValue = data.mapType === 'ats' ? data.atsMileage : data.etsMileage;
249
+ if (gameValue != null) {
250
+ document.getElementById('game-mileage-label').innerText = data.mapType === 'ats' ? '美卡总里程' : '欧卡总里程';
251
+ const gameKm = Number((gameValue / 1000).toFixed(1)).toLocaleString('zh-CN');
252
+ document.getElementById('game-mileage-distance').innerText = `${gameKm} km`;
253
+ } else {
254
+ gameBlock.classList.add('hidden');
255
+ gameDivider.classList.add('hidden');
256
+ }
236
257
  } else {
237
258
  // 今日足迹不显示总里程块
238
259
  document.getElementById('total-mileage-block').classList.add('hidden');
239
260
  document.getElementById('total-divider').classList.add('hidden');
261
+ document.getElementById('game-mileage-block').classList.add('hidden');
262
+ document.getElementById('game-mileage-divider').classList.add('hidden');
240
263
  }
241
264
 
242
265
  // 近一年足迹显示统计开始日期
@@ -50,6 +50,29 @@ let mapConfig = {
50
50
  y / 2.598541 + mapConfig.promods.multipliers.y
51
51
  ]
52
52
  }
53
+ },
54
+ // 美卡(ATS):换算参数来自瓦片导出配置 TileMapInfo.json
55
+ // (游戏坐标 x∈[-120098.891, 41144.094] y∈[-79127.37, 82115.62] 线性铺满 131072 网格)
56
+ ats: {
57
+ tileUrl: 'https://ets_tiles.cnly.top/20260903/tmp-ats-yellow/Tiles/{z}/{x}/{y}.png',
58
+ multipliers: {
59
+ x: 97626.58,
60
+ y: 64321.45
61
+ },
62
+ breakpoints: {},
63
+ bounds: {
64
+ y: 131072,
65
+ x: 131072
66
+ },
67
+ maxZoom: 8,
68
+ minZoom: 0,
69
+ // 游戏地转地图坐标
70
+ calculateMapCoordinate (x, y) {
71
+ return [
72
+ x / 1.230185 + mapConfig.ats.multipliers.x,
73
+ y / 1.230185 + mapConfig.ats.multipliers.y
74
+ ];
75
+ }
53
76
  }
54
77
  }
55
78
 
@@ -505,6 +505,47 @@
505
505
  --acc-tint: rgba(52, 211, 153, 0.06);
506
506
  }
507
507
 
508
+ /* 里程明细(欧卡/美卡):一行两个小格,与遥测网格同一套视觉 */
509
+ .mini-grid {
510
+ display: grid;
511
+ gap: 6px 8px;
512
+ margin-top: 6px;
513
+ position: relative;
514
+ z-index: 1;
515
+ }
516
+
517
+ .mini-grid.cols-2 {
518
+ grid-template-columns: repeat(2, minmax(0, 1fr));
519
+ }
520
+
521
+ .mini-cell {
522
+ min-width: 0;
523
+ display: flex;
524
+ align-items: center;
525
+ justify-content: space-between;
526
+ gap: 8px;
527
+ min-height: 26px;
528
+ padding: 4px 9px;
529
+ border-radius: var(--r-sm);
530
+ background: rgba(255, 255, 255, 0.04);
531
+ border: 1px solid rgba(255, 255, 255, 0.06);
532
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05);
533
+ }
534
+
535
+ .mini-k {
536
+ font-size: 11px;
537
+ color: var(--t2);
538
+ white-space: nowrap;
539
+ }
540
+
541
+ .mini-v {
542
+ font-size: 12.5px;
543
+ font-weight: 600;
544
+ color: var(--t1);
545
+ text-align: right;
546
+ font-variant-numeric: tabular-nums;
547
+ }
548
+
508
549
  /* ===== GLASS BAR ===== */
509
550
  .glass-bar {
510
551
  margin: 11px 14px;
@@ -922,13 +963,33 @@
922
963
  <div class="section mileage-section" id="mileage-section" style="display:none;">
923
964
  <div class="section-title">🚩 里程信息</div>
924
965
  <div class="info-row" id="mileage-row" style="display:none;">
925
- <div class="info-label">🚩 历史里程</div>
966
+ <div class="info-label">🚩 总里程</div>
926
967
  <div class="info-value" id="tmp-mileage"></div>
927
968
  </div>
969
+ <div class="mini-grid cols-2" id="mileage-detail" style="display:none;">
970
+ <div class="mini-cell">
971
+ <span class="mini-k">欧卡</span>
972
+ <span class="mini-v" id="tmp-mileage-ets2"></span>
973
+ </div>
974
+ <div class="mini-cell">
975
+ <span class="mini-k">美卡</span>
976
+ <span class="mini-v" id="tmp-mileage-ats"></span>
977
+ </div>
978
+ </div>
928
979
  <div class="info-row" id="today-mileage-row" style="display:none;">
929
980
  <div class="info-label">🚩 今日里程</div>
930
981
  <div class="info-value" id="tmp-today-mileage"></div>
931
982
  </div>
983
+ <div class="mini-grid cols-2" id="today-mileage-detail" style="display:none;">
984
+ <div class="mini-cell">
985
+ <span class="mini-k">欧卡</span>
986
+ <span class="mini-v" id="tmp-today-mileage-ets2"></span>
987
+ </div>
988
+ <div class="mini-cell">
989
+ <span class="mini-k">美卡</span>
990
+ <span class="mini-v" id="tmp-today-mileage-ats"></span>
991
+ </div>
992
+ </div>
932
993
  </div>
933
994
 
934
995
  <!-- Game Time Section -->
@@ -990,10 +1051,24 @@
990
1051
 
991
1052
  function formatMileage(val) {
992
1053
  if (!val) return null;
993
- if (val > 1000) return (val / 1000).toFixed(1) + ' 公里';
1054
+ if (val > 1000) return Number((val / 1000).toFixed(1)).toLocaleString('en-US') + ' 公里';
994
1055
  return val + ' 米';
995
1056
  }
996
1057
 
1058
+ // 单款游戏的里程:0 也照常显示成「0 公里」(表示这款没跑过),只有 null/空 才不显示
1059
+ function formatMileageZero(val) {
1060
+ if (val === null || val === undefined || val === '') return null;
1061
+ var n = Number(val);
1062
+ if (!isFinite(n)) return null;
1063
+ if (n <= 0) return '0 公里';
1064
+ return n >= 1000 ? Number((n / 1000).toFixed(1)).toLocaleString('en-US') + ' 公里' : n + ' 米';
1065
+ }
1066
+
1067
+ // 两款游戏里程是否值得单独列出(都为 0 就不占地方)
1068
+ function hasMileageDetail(ets2, ats) {
1069
+ return Number(ets2) > 0 || Number(ats) > 0;
1070
+ }
1071
+
997
1072
  /* ==================================================================
998
1073
  * V1 车队平台 · 实时遥测
999
1074
  * 仅渲染「有值」的字段(null / undefined / 空串一律不显示)
@@ -1388,19 +1463,31 @@
1388
1463
  banCountEl.innerText = banCount + '次';
1389
1464
  banCountEl.className = banCount > 0 ? 'info-value has-bans' : 'info-value no-bans';
1390
1465
 
1391
- // Mileage
1466
+ // Mileage:总里程 / 今日里程是欧卡+美卡合计,另附两款游戏的单独里程
1392
1467
  const mileage = formatMileage(data.mileage);
1393
1468
  const todayMileage = formatMileage(data.todayMileage);
1394
- if (mileage || todayMileage) {
1469
+ const hasMileageBreakdown = hasMileageDetail(data.mileageEts2, data.mileageAts);
1470
+ const hasTodayBreakdown = hasMileageDetail(data.todayMileageEts2, data.todayMileageAts);
1471
+ if (mileage || todayMileage || hasMileageBreakdown || hasTodayBreakdown) {
1395
1472
  document.getElementById('mileage-section').style.display = '';
1396
1473
  if (mileage) {
1397
1474
  document.getElementById('mileage-row').style.display = '';
1398
1475
  document.getElementById('tmp-mileage').innerText = mileage;
1399
1476
  }
1477
+ if (hasMileageBreakdown) {
1478
+ document.getElementById('mileage-detail').style.display = '';
1479
+ document.getElementById('tmp-mileage-ets2').innerText = formatMileageZero(data.mileageEts2) || '';
1480
+ document.getElementById('tmp-mileage-ats').innerText = formatMileageZero(data.mileageAts) || '';
1481
+ }
1400
1482
  if (todayMileage) {
1401
1483
  document.getElementById('today-mileage-row').style.display = '';
1402
1484
  document.getElementById('tmp-today-mileage').innerText = todayMileage;
1403
1485
  }
1486
+ if (hasTodayBreakdown) {
1487
+ document.getElementById('today-mileage-detail').style.display = '';
1488
+ document.getElementById('tmp-today-mileage-ets2').innerText = formatMileageZero(data.todayMileageEts2) || '';
1489
+ document.getElementById('tmp-today-mileage-ats').innerText = formatMileageZero(data.todayMileageAts) || '';
1490
+ }
1404
1491
  }
1405
1492
 
1406
1493
  // Game Time
@@ -264,6 +264,17 @@
264
264
  calculateMapCoordinate(x, y) {
265
265
  return [x / 2.598541 + mapConfig.promods.multipliers.x, y / 2.598541 + mapConfig.promods.multipliers.y];
266
266
  }
267
+ },
268
+ // 美卡(ATS):换算参数来自瓦片导出配置 TileMapInfo.json
269
+ ats: {
270
+ tileUrl: 'https://ets_tiles.cnly.top/20260903/tmp-ats-yellow/Tiles/{z}/{x}/{y}.png',
271
+ multipliers: { x: 97626.58, y: 64321.45 },
272
+ breakpoints: {},
273
+ bounds: { y: 131072, x: 131072 },
274
+ maxZoom: 8, minZoom: 0,
275
+ calculateMapCoordinate(x, y) {
276
+ return [x / 1.230185 + mapConfig.ats.multipliers.x, y / 1.230185 + mapConfig.ats.multipliers.y];
277
+ }
267
278
  }
268
279
  };
269
280
 
@@ -26,12 +26,21 @@ module.exports = {
26
26
  * P服务器ID集合
27
27
  */
28
28
  PromodsIds: [50, 51],
29
+ /**
30
+ * 美卡(ATS)服务器ID集合
31
+ */
32
+ AtsIds: [8, 10, 45],
33
+ /**
34
+ * 美卡足迹记录开始日期(接口自 2026-09-30 19:00 起记录美卡轨迹)
35
+ */
36
+ AtsFootprintStartDate: '2026-09-30',
29
37
  /**
30
38
  * 服务器类型
31
39
  */
32
40
  ServerType: {
33
41
  ets: 1,
34
- promods: 2
42
+ promods: 2,
43
+ ats: 3
35
44
  },
36
45
  /**
37
46
  * 联运部接龙:每天发布的时间
@@ -0,0 +1,94 @@
1
+ /**
2
+ * 多绑定选择器:用户绑定了多个 tmpId 时,列出序号让用户回复选择(支持多选)。
3
+ * 原理:需要选择的指令先回复编号列表并挂起(pending),由前置中间件捕获用户下一条
4
+ * "纯序号"消息后解析并继续执行;超时/取消/其他内容均视为放弃。
5
+ */
6
+ const MAX_PICK = 5; // 单次多选上限
7
+ const PICK_TIMEOUT = 60_000; // 等待回复时长(毫秒)
8
+
9
+ /**
10
+ * key: `${platform}:${channelId}:${userId}` → { bindings, allowMulti, max, finish }
11
+ * 按平台+频道+用户隔离,避免群里其他人误触发。
12
+ */
13
+ const pending = new Map();
14
+
15
+ function keyOf(session) {
16
+ return `${session.platform}:${session.channelId || 'private'}:${session.userId}`;
17
+ }
18
+
19
+ /**
20
+ * 注册选择器中间件(在插件 apply 中调用一次,前置执行)
21
+ */
22
+ function register(ctx) {
23
+ ctx.middleware((session, next) => {
24
+ const key = keyOf(session);
25
+ const record = pending.get(key);
26
+ if (!record) return next();
27
+ const content = (session.content || '').trim();
28
+ // 取消
29
+ if (/^取消$/.test(content)) {
30
+ record.finish(null);
31
+ return;
32
+ }
33
+ // 纯序号消息(支持 1,3 / 1 3 / 1、3 等分隔写法)
34
+ if (/^\d{1,6}(?:\s*[,,、 ]\s*\d{1,6})*$/.test(content)) {
35
+ const indexes = [...content.matchAll(/\d{1,6}/g)].map(m => parseInt(m[0], 10));
36
+ const picked = [...new Set(indexes)]
37
+ .filter(i => i >= 1 && i <= record.bindings.length)
38
+ .slice(0, record.allowMulti ? record.max : 1)
39
+ .map(i => String(record.bindings[i - 1].tmp_id));
40
+ if (picked.length === 0) {
41
+ record.finish(null);
42
+ session.send('没有有效的序号,已取消').catch(() => { });
43
+ return;
44
+ }
45
+ record.finish(picked);
46
+ return;
47
+ }
48
+ // 其他内容:视为放弃挂起,消息继续按正常流程处理
49
+ record.finish(null);
50
+ return next();
51
+ }, true);
52
+ }
53
+
54
+ /**
55
+ * 需要绑定 tmpId 的指令调用:绑定数 ≤1 时直接返回不打扰,≥2 时列出序号并等待选择。
56
+ * @param bindings guildBind.list() 的结果
57
+ * @param opts { allowMulti?: boolean } 是否允许多选(默认允许)
58
+ * @returns 选中的 tmpId 字符串数组;返回 null 表示已在等待用户回复,调用方应直接 return
59
+ */
60
+ async function pick(ctx, session, bindings, opts = {}) {
61
+ if (!bindings || bindings.length === 0) return [];
62
+ if (bindings.length === 1) return [String(bindings[0].tmp_id)];
63
+ const allowMulti = opts.allowMulti !== false;
64
+ const max = allowMulti ? MAX_PICK : 1;
65
+ const lines = bindings.map((b, i) => `${i + 1}. ${b.tmp_name || '未知'} (${b.tmp_id})`);
66
+ const tip = allowMulti
67
+ ? `请回复序号选择(最多 ${max} 个,多选用逗号分隔,如 1,3)`
68
+ : '请回复一个序号';
69
+ await session.send(`您绑定了多个编号:\n${lines.join('\n')}\n${tip}\n回复"取消"放弃,60 秒内有效`);
70
+ return await new Promise(resolve => {
71
+ const key = keyOf(session);
72
+ const record = {
73
+ bindings,
74
+ allowMulti,
75
+ max,
76
+ timer: null,
77
+ finish(result) {
78
+ clearTimeout(record.timer);
79
+ pending.delete(key);
80
+ resolve(result);
81
+ }
82
+ };
83
+ record.timer = setTimeout(() => {
84
+ if (pending.get(key) === record) {
85
+ pending.delete(key);
86
+ resolve(null);
87
+ session.send('选择超时,已取消').catch(() => { });
88
+ }
89
+ }, PICK_TIMEOUT);
90
+ pending.set(key, record);
91
+ });
92
+ }
93
+
94
+ module.exports = { register, pick, MAX_PICK, PICK_TIMEOUT };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-ets2-tools-tmp",
3
3
  "description": "欧卡2 TruckersMP信息查询、车队平台查询及活动提醒",
4
- "version": "3.3.65",
4
+ "version": "3.4.0",
5
5
  "contributors": [
6
6
  "opwop <slhp1013@qq.com>"
7
7
  ],