fix SCUM player online freshness display
This commit is contained in:
@@ -174,7 +174,8 @@ describe("ServerDetailPage config write approval", () => {
|
||||
expect(serverDetailPageSource).toContain("serverDetailSectionEntries(readyPlugin)");
|
||||
expect(serverDetailPageSource).toContain("plugin:${page.key}");
|
||||
expect(serverDetailPageSource).toContain("ServerDetailSectionNav");
|
||||
expect(serverDetailPageSource).toContain('aria-label="游戏运营功能"');
|
||||
expect(serverDetailPageSource).toContain('aria-label="插件功能"');
|
||||
expect(serverDetailPageSource).not.toContain(">游戏运营</span>");
|
||||
expect(serverDetailPageSource).toContain("server-plugin-section-tabs");
|
||||
expect(serverDetailPageSource).not.toContain("server-plugin-section-picker");
|
||||
expect(serverDetailPageSource).toContain('section === "files"');
|
||||
|
||||
@@ -271,8 +271,7 @@ function ServerDetailSectionNav({ entries, section, onChange }: { entries: Array
|
||||
return (
|
||||
<nav className="section-tabs" aria-label="server sections">
|
||||
{pluginEntries.length > 0 && (
|
||||
<div className="server-plugin-section-tabs" aria-label="游戏运营功能">
|
||||
<span className="server-plugin-section-label">游戏运营</span>
|
||||
<div className="server-plugin-section-tabs" aria-label="插件功能">
|
||||
{pluginEntries.map((entry) => (
|
||||
<button key={entry.id} type="button" className={cx("section-tab", section === entry.id && "section-tab-active")} aria-current={section === entry.id ? "page" : undefined} onClick={() => onChange(entry.id)}>
|
||||
{entry.label}
|
||||
|
||||
@@ -496,7 +496,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
||||
.server-detail-title-row>div:first-child{min-width:0}
|
||||
.server-detail-title-row h1{margin:0;font-size:24px;color:var(--ink);overflow-wrap:anywhere}
|
||||
.section-tabs{display:flex;gap:6px;flex-wrap:wrap;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),color-mix(in srgb,var(--surface) 72%,transparent);box-shadow:inset 0 1px 0 var(--crystal-rim)}
|
||||
.server-plugin-section-tabs{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.server-plugin-section-label{display:inline-flex;align-items:center;min-height:38px;padding:0 8px;color:var(--ink-faint);font-size:12px;font-weight:850;white-space:nowrap}
|
||||
.server-plugin-section-tabs{display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||||
.section-tab{display:inline-flex;align-items:center;gap:6px;min-height:38px;padding:0 14px;border:1px solid var(--line-strong);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;white-space:nowrap}
|
||||
.section-tab:focus-visible,.section-tab:hover{border-color:var(--accent);outline:0}
|
||||
.section-tab-active{background:var(--primary-command-surface);border-color:var(--accent-deep);color:#fff;font-weight:700;box-shadow:0 10px 24px var(--candy-glow),inset 0 1px 0 var(--crystal-rim),0 0 18px var(--moonbeam)}
|
||||
@@ -841,3 +841,5 @@ button.operations-inline-warning{cursor:pointer}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){.console-row-button,.operations-job-row,.operations-tray-trigger{transition:none}
|
||||
}
|
||||
.scum-online-summary{display:inline-flex;align-items:center;min-height:30px;margin-left:auto;padding:0 10px;border:1px solid color-mix(in srgb,var(--accent) 42%,var(--line));border-radius:6px;background:color-mix(in srgb,var(--accent-soft) 46%,transparent);color:var(--ink-soft);font-size:12px;font-weight:760;white-space:nowrap}.scum-online-summary strong{color:var(--accent-deep);font-family:var(--font-mono);font-size:13px}
|
||||
@media (max-width:760px){.scum-online-summary{margin-left:0}}
|
||||
|
||||
@@ -181,16 +181,17 @@ function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data:
|
||||
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
|
||||
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 onlineCount = data.players.filter(playerOnline).length;
|
||||
const selectedPlayer = data.players.find((player) => playerKey(player) === view.playerPanel.playerId);
|
||||
return e("div", { className: "console-record-list scum-surface" },
|
||||
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("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: "page-status" }, `${players.length} / ${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
|
||||
),
|
||||
e("div", { className: "provider-table-wrap scum-table-wrap" },
|
||||
e("table", { className: "resource-table scum-user-table" },
|
||||
e("caption", { className: "provider-id" }, "SCUM 用户真实记录"),
|
||||
e("thead", null, e("tr", null, ["用户名", "队伍", "状态", "最后活动", "操作"].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" }, "没有符合筛选条件的真实用户记录。"))))
|
||||
)
|
||||
@@ -578,7 +579,9 @@ function field(row: RecordMap | undefined, ...keys: string[]): unknown { if (!ro
|
||||
function normalizeKey(value: string): string { return value.replace(/[_-]/g, "").toLowerCase(); }
|
||||
function textField(row: RecordMap | undefined, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null ? "" : String(value); }
|
||||
function boolField(row: RecordMap, ...keys: string[]): boolean { const value = field(row, ...keys); return value === true || value === 1 || value === "1" || value === "true"; }
|
||||
function playerOnline(player: RecordMap): boolean { const status = textField(player, "status").toLowerCase(); return boolField(player, "online") || ["online", "active", "connected"].includes(status); }
|
||||
const playerActivityKeys = ["lastSeenAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt"];
|
||||
const playerOnlineWindowMs = 5 * 60 * 1000;
|
||||
function playerOnline(player: RecordMap): boolean { const status = textField(player, "status").toLowerCase(); const active = boolField(player, "online") || ["online", "active", "connected"].includes(status); if (!active) return false; const lastActivity = field(player, ...playerActivityKeys); if (lastActivity === undefined || lastActivity === null || lastActivity === "") return true; const timestamp = Date.parse(String(lastActivity)); return Number.isNaN(timestamp) || Date.now() - timestamp <= playerOnlineWindowMs; }
|
||||
function numField(row: RecordMap, ...keys: string[]): string { const value = field(row, ...keys); return value === undefined || value === null || value === "" ? "--" : String(value); }
|
||||
function idOf(row: RecordMap | undefined, fallback: string): string { return textField(row, "_recordKey", "id", "gamePlayerId", "squadId", "vehicleId", "flagId", "eventId", "code") || fallback; }
|
||||
function freshness(row: RecordMap): string { const fresh = field(row, "freshness"); return isRecord(fresh) ? textField(fresh, "status") || "unknown" : textField(row, "freshnessStatus", "updatedAt") || "unknown"; }
|
||||
|
||||
@@ -175,7 +175,8 @@ describe("SCUM plugin feature module", () => {
|
||||
it("renders searchable user management from real collection values", () => {
|
||||
const view = renderAndCollect();
|
||||
expect(view.nodes).toContain("section:用户管理");
|
||||
expect(view.texts).toContain("SCUM 用户真实记录");
|
||||
expect(view.texts).not.toContain("SCUM 用户真实记录");
|
||||
expect(view.texts).toEqual(expect.arrayContaining(["在线用户 ", "1 / 1"]));
|
||||
expect(view.texts).toEqual(expect.arrayContaining(["用户名", "队伍", "状态", "最后活动", "操作"]));
|
||||
expect(view.texts).not.toEqual(expect.arrayContaining(["Steam", "上次登录", "登录 IP", "概况"]));
|
||||
expect(view.texts).not.toContain("筛选结果");
|
||||
@@ -191,6 +192,13 @@ describe("SCUM plugin feature module", () => {
|
||||
expect(view.texts.join("\n")).not.toContain("Fame 42");
|
||||
});
|
||||
|
||||
it("expires online users after five minutes without activity", () => {
|
||||
const staleAt = new Date(Date.now() - 5 * 60 * 1000 - 1).toISOString();
|
||||
const view = renderAndCollect({ data: { ...surfaceData, players: [{ ...surfaceData.players[0], lastSeenAt: staleAt }] } });
|
||||
expect(view.texts).toEqual(expect.arrayContaining(["在线用户 ", "0 / 1"]));
|
||||
expect(view.texts).toContain("离线/未知");
|
||||
});
|
||||
|
||||
it("prepares player attribute SQL execution through platform-to-Run remote access", async () => {
|
||||
const player = { steamId: "76561198000000001", displayName: "Mira", stateVersion: "state-1", stamina: 12, dexterity: 4, intelligence: 8 };
|
||||
const drafts = playerAttributeDrafts(player).map((draft) => draft.fieldKey === "stamina" ? { ...draft, after: "855" } : draft);
|
||||
|
||||
Reference in New Issue
Block a user