Show SCUM player identifiers and login IP

This commit is contained in:
npc0-hue
2026-08-28 11:54:16 +08:00
parent dcbeb33dfc
commit 3ca67907a1
4 changed files with 47 additions and 16 deletions
@@ -198,20 +198,20 @@ function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data:
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const search = view.playerSearch.trim().toLowerCase(); const search = view.playerSearch.trim().toLowerCase();
const players = data.players.filter((player) => matchesText(player, search, "displayName", "playerName", "gamePlayerId", "playerId", "steamId", "squadName") && (view.playerStatus === "all" || (view.playerStatus === "online") === playerOnline(player))); const players = data.players.filter((player) => matchesText(player, search, "displayName", "playerName", "gamePlayerId", "playerId", "userProfileId", "profileId", "steamId", "squadName", "lastLoginIp", "loginIp", "ipAddress", "ip") && (view.playerStatus === "all" || (view.playerStatus === "online") === playerOnline(player)));
const onlineCount = data.players.filter(playerOnline).length; const onlineCount = data.players.filter(playerOnline).length;
const selectedPlayer = data.players.find((player) => playerKey(player) === view.playerPanel.playerId); const selectedPlayer = data.players.find((player) => playerKey(player) === view.playerPanel.playerId);
return e("div", { className: "console-record-list scum-surface" }, return e("div", { className: "console-record-list scum-surface" },
e("div", { className: "resource-filter-bar scum-filter-bar" }, e("div", { className: "resource-filter-bar scum-filter-bar" },
labeledField(e, "搜索用户", e("input", { type: "search", value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / Steam ID / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) })), labeledField(e, "搜索用户", e("input", { type: "search", value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / UUID / Steam ID / 登录 IP / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) })),
labeledField(e, "在线状态", e("select", { value: view.playerStatus, "aria-label": "在线状态", onChange: (event: InputEvent) => view.setPlayerStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), e("option", { value: "online" }, "在线"), e("option", { value: "offline" }, "离线/未知"))), labeledField(e, "在线状态", e("select", { value: view.playerStatus, "aria-label": "在线状态", onChange: (event: InputEvent) => view.setPlayerStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), e("option", { value: "online" }, "在线"), e("option", { value: "offline" }, "离线/未知"))),
e("span", { className: "scum-online-summary" }, "在线用户 ", e("strong", null, `${onlineCount} / ${data.players.length}`)), e("span", { className: "scum-online-summary" }, "在线用户 ", e("strong", null, `${onlineCount} / ${data.players.length}`)),
players.length !== data.players.length ? e("span", { className: "page-status" }, `当前显示 ${players.length}`) : null players.length !== data.players.length ? e("span", { className: "page-status" }, `当前显示 ${players.length}`) : null
), ),
e("div", { className: "provider-table-wrap scum-table-wrap" }, e("div", { className: "provider-table-wrap scum-table-wrap" },
e("table", { className: "resource-table scum-user-table" }, e("table", { className: "resource-table scum-user-table" },
e("thead", null, e("tr", null, ["用户名", "队伍", "状态", "最后活动", "操作"].map((label) => e("th", { key: label, scope: "col" }, label)))), e("thead", null, e("tr", null, ["用户名", "UUID / Steam", "渣币", "登录 IP", "队伍", "状态", "最后活动", "操作"].map((label) => e("th", { key: label, scope: "col" }, label)))),
e("tbody", null, players.length ? players.map((player, index) => playerTableRow(e, player, index, data, input, view)) : e("tr", null, e("td", { colSpan: 5 }, e("p", { className: "page-status" }, "没有符合筛选条件的真实用户记录。")))) e("tbody", null, players.length ? players.map((player, index) => playerTableRow(e, player, index, data, input, view)) : e("tr", null, e("td", { colSpan: 8 }, e("p", { className: "page-status" }, "没有符合筛选条件的真实用户记录。"))))
) )
), ),
selectedPlayer && view.playerPanel.kind !== "closed" ? playerDrawer(e, selectedPlayer, data, input, view) : null selectedPlayer && view.playerPanel.kind !== "closed" ? playerDrawer(e, selectedPlayer, data, input, view) : null
@@ -220,12 +220,17 @@ function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, in
function playerTableRow(e: ReactLike["createElement"], player: RecordMap, index: number, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { function playerTableRow(e: ReactLike["createElement"], player: RecordMap, index: number, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const name = textField(player, "displayName", "playerName", "name") || textField(player, "gamePlayerId", "playerId", "steamId") || "未知用户"; const name = textField(player, "displayName", "playerName", "name") || textField(player, "gamePlayerId", "playerId", "steamId") || "未知用户";
const uuid = textField(player, "gamePlayerId", "playerId", "userProfileId", "profileId") || "未同步";
const steamId = textField(player, "steamId", "providerId");
const open = (kind: PlayerPanelKind) => { view.setPlayerPanel({ kind, playerId: playerKey(player) }); if (kind === "attributes") view.setAttributeDrafts(playerAttributeDrafts(player)); }; const open = (kind: PlayerPanelKind) => { view.setPlayerPanel({ kind, playerId: playerKey(player) }); if (kind === "attributes") view.setAttributeDrafts(playerAttributeDrafts(player)); };
return e("tr", { key: idOf(player, `player-${index}`) }, return e("tr", { key: idOf(player, `player-${index}`) },
e("td", null, e("strong", null, name)), e("td", null, e("strong", null, name)),
e("td", null, e("span", { className: "provider-id" }, uuid), steamId ? e("span", { className: "provider-id" }, `Steam ${steamId}`) : null),
e("td", null, e("strong", null, numField(player, "normalBalance", "moneyBalance")), e("span", { className: "provider-id" }, `Gold ${numField(player, "goldBalance")}`)),
e("td", null, e("span", { className: "provider-id" }, textField(player, "lastLoginIp", "loginIp", "ipAddress", "ip") || "未同步")),
e("td", null, e("span", null, textField(player, "squadName", "squadId") || "未加入"), e("span", { className: "provider-id" }, textField(player, "squadId") ? `ID ${textField(player, "squadId")}` : "")), e("td", null, e("span", null, textField(player, "squadName", "squadId") || "未加入"), e("span", { className: "provider-id" }, textField(player, "squadId") ? `ID ${textField(player, "squadId")}` : "")),
e("td", null, e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知"), e("span", { className: "provider-id" }, numField(player, "pingMs") === "--" ? "" : `${numField(player, "pingMs")} ms`)), e("td", null, e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知"), e("span", { className: "provider-id" }, numField(player, "pingMs") === "--" ? "" : `${numField(player, "pingMs")} ms`)),
e("td", null, userDateField(player, "lastSeenAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt")), e("td", null, userDateField(player, "lastSeenAt", "lastLoginTime", "lastLoginAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt")),
e("td", { className: "provider-actions-cell" }, playerActionMenu(e, player, view, open)) e("td", { className: "provider-actions-cell" }, playerActionMenu(e, player, view, open))
); );
} }
@@ -254,9 +259,11 @@ function playerDrawer(e: ReactLike["createElement"], player: RecordMap, data: SC
e("div", { className: "panel-header" }, e("div", null, e("h2", null, title), e("span", { className: "provider-id" }, `${name} · Steam ${textField(player, "steamId", "providerId") || "未同步"}`)), e("button", { type: "button", className: "drawer-close", onClick: close }, "关闭")), e("div", { className: "panel-header" }, e("div", null, e("h2", null, title), e("span", { className: "provider-id" }, `${name} · Steam ${textField(player, "steamId", "providerId") || "未同步"}`)), e("button", { type: "button", className: "drawer-close", onClick: close }, "关闭")),
e("div", { className: "console-record-meta scum-player-overview" }, e("div", { className: "console-record-meta scum-player-overview" },
e("span", null, `Profile ${textField(player, "userProfileId", "profileId") || "未同步"}`), e("span", null, `Profile ${textField(player, "userProfileId", "profileId") || "未同步"}`),
e("span", null, `UUID ${textField(player, "gamePlayerId", "playerId") || "未同步"}`),
e("span", null, `登录 IP ${textField(player, "lastLoginIp", "loginIp", "ipAddress", "ip") || "未同步"}`),
e("span", null, `网络相关 ${shortHash(textField(player, "networkCorrelation")) || "未同步"}`), e("span", null, `网络相关 ${shortHash(textField(player, "networkCorrelation")) || "未同步"}`),
e("span", null, `Fame ${numField(player, "famePoints")}`), e("span", null, `Fame ${numField(player, "famePoints")}`),
e("span", null, `Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")}`), e("span", null, `渣币 ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")}`),
e("span", null, `上次登录 ${userDateField(player, "lastLoginTime", "lastLoginAt", "lastLoginObservedAt")}`)), e("span", null, `上次登录 ${userDateField(player, "lastLoginTime", "lastLoginAt", "lastLoginObservedAt")}`)),
content content
) )
@@ -304,7 +311,7 @@ function playerItemsPanel(e: ReactLike["createElement"], player: RecordMap) {
function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) { function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) {
const rows = playerRecords(data.activityEvents, player).filter((row) => ["login", "logout", "scum.login", "scum.logout"].includes(textField(row, "eventType", "type").toLowerCase())); const rows = playerRecords(data.activityEvents, player).filter((row) => ["login", "logout", "scum.login", "scum.logout"].includes(textField(row, "eventType", "type").toLowerCase()));
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件声明的 SCUM 登录日志投影;网络字段只显示不可逆相关性哈希。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, shortHash(textField(row, "networkCorrelation")) || textField(row, "reason") || "无网络字段"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。"))); return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件声明的 SCUM 登录日志投影。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, textField(row, "lastLoginIp", "loginIp", "ipAddress", "ip") || shortHash(textField(row, "networkCorrelation")) || textField(row, "reason") || "无网络字段"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。")));
} }
function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) { function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) {
@@ -268,7 +268,21 @@
"sqlRef": "sql/scum-db-v57/users.sql", "sqlRef": "sql/scum-db-v57/users.sql",
"pollIntervalSeconds": 3, "pollIntervalSeconds": 3,
"maxRows": 500, "maxRows": 500,
"timeoutSeconds": 15 "timeoutSeconds": 15,
"projections": [
{
"collection": "scum_users",
"rowPath": "rows",
"upsertKeys": [
"steamId"
],
"fixedValues": {
"source": "run.sqlite.scum.player.profile"
},
"observedAtField": "profileSampledAt",
"mergeExisting": true
}
]
}, },
{ {
"key": "scum.squads", "key": "scum.squads",
@@ -649,7 +663,8 @@
], ],
"captureMappings": { "captureMappings": {
"steamId": "steamId", "steamId": "steamId",
"displayName": "displayName" "displayName": "displayName",
"lastLoginIp": "ip"
}, },
"hashMappings": { "hashMappings": {
"networkCorrelation": "ip" "networkCorrelation": "ip"
@@ -672,7 +687,8 @@
], ],
"captureMappings": { "captureMappings": {
"steamId": "steamId", "steamId": "steamId",
"displayName": "displayName" "displayName": "displayName",
"lastLoginIp": "ip"
}, },
"hashMappings": { "hashMappings": {
"networkCorrelation": "ip" "networkCorrelation": "ip"
@@ -205,6 +205,9 @@ describe("plugin manifest validation", () => {
const compile = (pattern: string) => new RegExp(pattern.replaceAll("(?P<", "(?<")); const compile = (pattern: string) => new RegExp(pattern.replaceAll("(?P<", "(?<"));
expect(compile(projection.steps[0].pattern).exec('LogBattlEye: Display: Player "love_fitting" reported as player 0')?.groups).toMatchObject({ displayName: "love_fitting", slot: "0" }); expect(compile(projection.steps[0].pattern).exec('LogBattlEye: Display: Player "love_fitting" reported as player 0')?.groups).toMatchObject({ displayName: "love_fitting", slot: "0" });
expect(compile(projection.steps[1].pattern).exec("LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111")?.groups).toMatchObject({ slot: "0", steamId: "76561199510658111" }); expect(compile(projection.steps[1].pattern).exec("LogBattlEye: Display: Player 0 SteamID (assumed): 76561199510658111")?.groups).toMatchObject({ slot: "0", steamId: "76561199510658111" });
const loginLogProjection = manifest.gameClientBridge.logProjections.find((candidate: { key: string }) => candidate.key === "scum.login-log.login");
expect(loginLogProjection.target.captureMappings).toMatchObject({ steamId: "steamId", displayName: "displayName", lastLoginIp: "ip" });
expect(loginLogProjection.presence.activityTarget.captureMappings).toMatchObject({ steamId: "steamId", displayName: "displayName", lastLoginIp: "ip" });
}); });
it("declares SCUM install/update and start lifecycle through plugin assets", () => { it("declares SCUM install/update and start lifecycle through plugin assets", () => {
@@ -648,6 +651,7 @@ describe("plugin manifest validation", () => {
pollIntervalSeconds: number; pollIntervalSeconds: number;
maxRows: number; maxRows: number;
timeoutSeconds: number; timeoutSeconds: number;
projections?: Array<{ collection: string; rowPath: string; upsertKeys: string[]; observedAtField?: string; mergeExisting?: boolean }>;
}>; }>;
pages: Array<{ pageKey: string; commandTypes?: string[]; queryTemplateKeys?: string[] }>; pages: Array<{ pageKey: string; commandTypes?: string[]; queryTemplateKeys?: string[] }>;
}; };
@@ -669,6 +673,7 @@ describe("plugin manifest validation", () => {
const fastTemplates = new Set(["scum.player.profile", "scum.vehicles", "scum.positions"]); const fastTemplates = new Set(["scum.player.profile", "scum.vehicles", "scum.positions"]);
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template])); const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys)); expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
expect(templatesByKey.get("scum.player.profile")?.projections).toEqual([expect.objectContaining({ collection: "scum_users", rowPath: "rows", upsertKeys: ["steamId"], observedAtField: "profileSampledAt", mergeExisting: true })]);
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query"); expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
expect(manifest.capabilities).toContain("remote.run.db.sqlite.execute"); expect(manifest.capabilities).toContain("remote.run.db.sqlite.execute");
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query"); expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query");
+9 -6
View File
@@ -15,7 +15,7 @@ const pageSource = readFileSync(resolve(featureRoot, "page.ts"), "utf8");
const dataClientSource = readFileSync(resolve(featureRoot, "page-data.ts"), "utf8"); const dataClientSource = readFileSync(resolve(featureRoot, "page-data.ts"), "utf8");
const surfaceData: SCUMSurfaceData = { const surfaceData: SCUMSurfaceData = {
players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", squadId: "squad-1", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, position: { x: 10, y: 20, z: 3 }, freshness: { status: "fresh" } }], players: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", userProfileId: "profile-1", displayName: "Mira", squadName: "Wolves", squadId: "squad-1", online: true, famePoints: 42, normalBalance: 1000, goldBalance: 3, lastLoginIp: "203.0.113.7", position: { x: 10, y: 20, z: 3 }, freshness: { status: "fresh" } }],
squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 1, memberLimit: 12, leaderProfileId: "profile-1", score: 88, message: "Hold the north", freshness: { status: "fresh" } }], squads: [{ squadId: "squad-1", name: "Wolves", memberCount: 1, memberLimit: 12, leaderProfileId: "profile-1", score: 88, message: "Hold the north", freshness: { status: "fresh" } }],
members: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", displayName: "Mira", squadId: "squad-1", rank: "Leader", score: 42, lastLoginAt: "2026-08-10T00:00:00Z", freshness: { status: "fresh" } }], members: [{ gamePlayerId: "steam-1", steamId: "76561198000000001", displayName: "Mira", squadId: "squad-1", rank: "Leader", score: 42, lastLoginAt: "2026-08-10T00:00:00Z", freshness: { status: "fresh" } }],
events: [{ id: "event-1", name: "Friday Range", eventType: "range", class: 1, corn: "0 20 * * 5", placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2, status: "enabled" }], events: [{ id: "event-1", name: "Friday Range", eventType: "range", class: 1, corn: "0 20 * * 5", placard: "Event starting", percent: 75, npc: 1, item: 3, zombie: 12, animal: 2, status: "enabled" }],
@@ -186,8 +186,7 @@ describe("SCUM plugin feature module", () => {
expect(view.nodes).toContain("section:用户管理"); expect(view.nodes).toContain("section:用户管理");
expect(view.texts).not.toContain("SCUM 用户真实记录"); expect(view.texts).not.toContain("SCUM 用户真实记录");
expect(view.texts).toEqual(expect.arrayContaining(["在线用户 ", "1 / 1"])); expect(view.texts).toEqual(expect.arrayContaining(["在线用户 ", "1 / 1"]));
expect(view.texts).toEqual(expect.arrayContaining(["用户名", "队伍", "状态", "最后活动", "操作"])); expect(view.texts).toEqual(expect.arrayContaining(["用户名", "UUID / Steam", "渣币", "登录 IP", "队伍", "状态", "最后活动", "操作"]));
expect(view.texts).not.toEqual(expect.arrayContaining(["Steam", "上次登录", "登录 IP", "概况"]));
expect(view.texts).not.toContain("筛选结果"); expect(view.texts).not.toContain("筛选结果");
expect(view.texts).not.toContain("通用数据/机器动作可用"); expect(view.texts).not.toContain("通用数据/机器动作可用");
expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["同步 SCUM.db", "重新读取"])); expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["同步 SCUM.db", "重新读取"]));
@@ -197,8 +196,11 @@ describe("SCUM plugin feature module", () => {
expect(pageSource).toContain("resource-filter-bar scum-filter-bar"); expect(pageSource).toContain("resource-filter-bar scum-filter-bar");
expect(pageSource).toContain("provider-table-wrap scum-table-wrap"); expect(pageSource).toContain("provider-table-wrap scum-table-wrap");
expect(view.texts).toContain("Mira"); expect(view.texts).toContain("Mira");
expect(view.texts).not.toContain("76561198000000001"); expect(view.texts).toEqual(expect.arrayContaining(["steam-1", "Steam 76561198000000001", "1000", "Gold 3", "203.0.113.7"]));
expect(view.texts.join("\n")).not.toContain("Fame 42"); const ipSearch = renderAndCollect({ playerSearch: "203.0.113.7" });
expect(ipSearch.texts).toContain("Mira");
const uuidSearch = renderAndCollect({ playerSearch: "profile-1" });
expect(uuidSearch.texts).toContain("Mira");
}); });
it("expires online users after five minutes without activity", () => { it("expires online users after five minutes without activity", () => {
@@ -325,7 +327,7 @@ function gameClientActions() {
}; };
} }
function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed" } = {}) { function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: string[]; pageKey?: string; pageTitle?: string; giftTab?: "definitions" | "claims" | "deliveries" | "timed"; playerSearch?: string } = {}) {
const nodes: string[] = []; const nodes: string[] = [];
const texts: string[] = []; const texts: string[] = [];
const buttons: Array<{ label: string; disabled: boolean; onClick?: () => void }> = []; const buttons: Array<{ label: string; disabled: boolean; onClick?: () => void }> = [];
@@ -346,6 +348,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin
useState: <T,>(initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => { useState: <T,>(initial: T | (() => T)): [T, (next: T | ((previous: T) => T)) => void] => {
stateCall += 1; stateCall += 1;
if (stateCall === 1) return [{ status: "ready", data: options.data ?? surfaceData } as T, () => undefined]; if (stateCall === 1) return [{ status: "ready", data: options.data ?? surfaceData } as T, () => undefined];
if (stateCall === 3 && options.playerSearch !== undefined) return [options.playerSearch as T, () => undefined];
const value = typeof initial === "function" ? (initial as () => T)() : initial; const value = typeof initial === "function" ? (initial as () => T)() : initial;
if (options.giftTab && value === "definitions") return [options.giftTab as T, () => undefined]; if (options.giftTab && value === "definitions") return [options.giftTab as T, () => undefined];
return [value, () => undefined]; return [value, () => undefined];