91 lines
8.7 KiB
TypeScript
91 lines
8.7 KiB
TypeScript
import { Activity, Gift, MapPinned, RefreshCw, ShieldAlert, UsersRound } from "lucide-react";
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
|
|
import { platformApiClient } from "../api/client";
|
|
import type { SCUMActivityEventRecord, SCUMGiftRecord, SCUMMapPointRecord, SCUMSquadRecord, SCUMUserRecord } from "../api/types";
|
|
import { EmptyState, ErrorState, LoadingState } from "./StateViews";
|
|
|
|
export type SCUMPersistedDataset = "users" | "squads" | "activity" | "gifts" | "map";
|
|
|
|
interface SCUMPersistedDataViewProps {
|
|
serverInstanceId: string;
|
|
dataset: SCUMPersistedDataset;
|
|
}
|
|
|
|
type DatasetRecord = (SCUMUserRecord | SCUMSquadRecord | SCUMActivityEventRecord | SCUMGiftRecord | SCUMMapPointRecord) & { fields?: Record<string, unknown>; updatedAt?: string; freshness?: { observedAt?: string } };
|
|
type LoadState = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; items: DatasetRecord[]; count: number };
|
|
|
|
const datasetCopy: Record<SCUMPersistedDataset, { title: string; summary: string; empty: string; icon: typeof UsersRound }> = {
|
|
users: { title: "用户", summary: "来自 SCUM 用户、角色和账户记录的已同步数据。", empty: "还没有完成的用户同步记录。", icon: UsersRound },
|
|
squads: { title: "队伍", summary: "来自 SCUM 队伍和成员记录的已同步数据。", empty: "还没有完成的队伍同步记录。", icon: UsersRound },
|
|
activity: { title: "活动", summary: "来自已解析日志和活动记录的持久化事件。", empty: "还没有完成的活动同步记录。", icon: Activity },
|
|
gifts: { title: "礼包", summary: "来自 SCUM 礼包目录和发放记录的已同步数据。", empty: "还没有完成的礼包同步记录。", icon: Gift },
|
|
map: { title: "地图", summary: "来自基地、旗帜、载具和实体坐标的已同步地图点。", empty: "还没有完成的地图同步记录。", icon: MapPinned }
|
|
};
|
|
|
|
export function scumDatasetForPluginRoute(routeKey: string): SCUMPersistedDataset | null {
|
|
if (routeKey === "players") return "users";
|
|
if (routeKey === "squads") return "squads";
|
|
if (routeKey === "workflows") return "activity";
|
|
if (routeKey === "gifts") return "gifts";
|
|
if (routeKey === "live-map") return "map";
|
|
return null;
|
|
}
|
|
|
|
export function SCUMPersistedDataView({ serverInstanceId, dataset }: SCUMPersistedDataViewProps) {
|
|
const copy = datasetCopy[dataset];
|
|
const load = useCallback(async () => {
|
|
if (!serverInstanceId) return { status: "error" as const, reason: "插件页面没有绑定服务器。" };
|
|
try {
|
|
const response = dataset === "users" ? await platformApiClient.listSCUMUsers(serverInstanceId)
|
|
: dataset === "squads" ? await platformApiClient.listSCUMSquads(serverInstanceId)
|
|
: dataset === "activity" ? await platformApiClient.listSCUMActivity(serverInstanceId)
|
|
: dataset === "gifts" ? await platformApiClient.listSCUMGifts(serverInstanceId)
|
|
: await platformApiClient.listSCUMMapPoints(serverInstanceId);
|
|
return { status: "ready" as const, items: response.items.map(normalizePersistedRow) as DatasetRecord[], count: response.count };
|
|
} catch (error) {
|
|
return { status: "error" as const, reason: error instanceof Error ? error.message : `${copy.title}数据加载失败。` };
|
|
}
|
|
}, [copy.title, dataset, serverInstanceId]);
|
|
const [state, setState] = useState<LoadState>({ status: "loading" });
|
|
const refresh = useCallback(() => { setState({ status: "loading" }); void load().then(setState); }, [load]);
|
|
useEffect(() => { refresh(); }, [refresh]);
|
|
|
|
return <section className="console-panel" aria-label={`SCUM ${copy.title}`}>
|
|
<div className="panel-header">
|
|
<div><h2><copy.icon size={16} aria-hidden="true" /> {copy.title}</h2><p className="provider-id">{copy.summary}</p></div>
|
|
<button type="button" className="icon-command" onClick={refresh} disabled={state.status === "loading"}><RefreshCw size={14} aria-hidden="true" /><span>刷新</span></button>
|
|
</div>
|
|
{state.status === "loading" && <LoadingState compact label={`正在读取${copy.title}同步数据…`} />}
|
|
{state.status === "error" && <ErrorState compact title={`${copy.title}数据不可用`} reason={state.reason} onRetry={refresh} />}
|
|
{state.status === "ready" && (state.items.length ? <SCUMDatasetTable dataset={dataset} items={state.items} count={state.count} /> : <EmptyState title={`暂无${copy.title}数据`} description={copy.empty} icon={<ShieldAlert size={26} />} />)}
|
|
</section>;
|
|
}
|
|
|
|
function SCUMDatasetTable({ dataset, items, count }: { dataset: SCUMPersistedDataset; items: DatasetRecord[]; count: number }) {
|
|
const columns = useMemo(() => tableColumns(dataset), [dataset]);
|
|
return <div className="console-record-list">
|
|
<dl className="console-stat-strip"><div><dt>已同步</dt><dd>{count}</dd></div><div><dt>当前展示</dt><dd>{Math.min(items.length, 100)}</dd></div><div><dt>数据源</dt><dd>平台数据库</dd></div></dl>
|
|
<div className="resource-table-wrap"><table className="resource-table"><thead><tr>{columns.map((column) => <th key={column.label}>{column.label}</th>)}</tr></thead><tbody>{items.slice(0, 100).map((item) => <tr key={item.id}>{columns.map((column) => <td key={column.label}>{column.value(item)}</td>)}</tr>)}</tbody></table></div>
|
|
</div>;
|
|
}
|
|
|
|
type TableColumn = { label: string; value: (record: DatasetRecord) => string };
|
|
function tableColumns(dataset: SCUMPersistedDataset): TableColumn[] {
|
|
if (dataset === "users") return [column("名称", (value) => (value as SCUMUserRecord).displayName || "未命名"), column("Steam", (value) => (value as SCUMUserRecord).steamId), column("声望", (value) => numberText((value as SCUMUserRecord).famePoints)), column("余额", (value) => numberText((value as SCUMUserRecord).normalBalance)), column("最后登录", (value) => dateText((value as SCUMUserRecord).lastLoginTime || value.observedAt))];
|
|
if (dataset === "squads") return [column("队伍", (value) => (value as SCUMSquadRecord).name || "未命名"), column("成员", (value) => numberText((value as SCUMSquadRecord).memberCount)), column("上限", (value) => numberText((value as SCUMSquadRecord).memberLimit)), column("分数", (value) => numberText((value as SCUMSquadRecord).score)), column("同步时间", (value) => dateText(value.syncedAt || value.observedAt))];
|
|
if (dataset === "activity") return [column("事件", (value) => (value as SCUMActivityEventRecord).kind || "活动"), column("对象", (value) => (value as SCUMActivityEventRecord).subjectName || (value as SCUMActivityEventRecord).subjectId), column("摘要", (value) => (value as SCUMActivityEventRecord).summary), column("发生时间", (value) => dateText((value as SCUMActivityEventRecord).occurredAt || value.observedAt)), column("坐标", (value) => coordinates(value as SCUMActivityEventRecord))];
|
|
if (dataset === "gifts") return [column("礼包", (value) => (value as SCUMGiftRecord).name || "未命名礼包"), column("状态", (value) => (value as SCUMGiftRecord).status || "未知"), column("接收者", (value) => (value as SCUMGiftRecord).recipientName || (value as SCUMGiftRecord).recipientUserProfileId), column("可用时间", (value) => dateText((value as SCUMGiftRecord).availableAt)), column("过期时间", (value) => dateText((value as SCUMGiftRecord).expiresAt))];
|
|
return [column("标记", (value) => (value as SCUMMapPointRecord).label || (value as SCUMMapPointRecord).kind || "地图点"), column("类型", (value) => (value as SCUMMapPointRecord).kind), column("地图", (value) => (value as SCUMMapPointRecord).mapId), column("坐标", (value) => coordinates(value as SCUMMapPointRecord)), column("同步时间", (value) => dateText(value.syncedAt || value.observedAt))];
|
|
}
|
|
|
|
function column(label: string, getValue: (record: DatasetRecord) => unknown): TableColumn { return { label, value: (record) => text(getValue(record)) }; }
|
|
function normalizePersistedRow(row: DatasetRecord): DatasetRecord {
|
|
const fields = row.fields ?? {};
|
|
return { ...row, ...fields, syncedAt: row.updatedAt ?? row.syncedAt, observedAt: row.freshness?.observedAt ?? row.observedAt };
|
|
}
|
|
function text(value: unknown): string { return value === undefined || value === null || value === "" ? "--" : String(value); }
|
|
function numberText(value: number | undefined): string { return value === undefined || value === null ? "--" : new Intl.NumberFormat("zh-CN").format(value); }
|
|
function dateText(value: string | undefined): string { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }
|
|
function coordinates(value: Pick<SCUMActivityEventRecord, "locationX" | "locationY" | "locationZ"> | SCUMMapPointRecord): string { return value.locationX === undefined || value.locationY === undefined ? "--" : `X ${value.locationX} / Y ${value.locationY}${value.locationZ === undefined ? "" : ` / Z ${value.locationZ}`}`; }
|