feat(scum): add versioned gift grants
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import source from "./GameGiftCatalogPanel.tsx?raw";
|
||||
|
||||
describe("GameGiftCatalogPanel", () => {
|
||||
it("keeps lifecycle state readable and avoids raw game command surfaces", () => {
|
||||
expect(source).toContain("版本化礼包目录与定向发放");
|
||||
expect(source).toContain("通知失败");
|
||||
expect(source).toContain("绝不自动重试");
|
||||
expect(source).not.toContain("rawCommand");
|
||||
expect(source).not.toContain("sendSourceRCONCommand");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { CheckCircle2, Gift, RefreshCw, Send } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GameGiftCatalogResponse, GameGiftGrantResponse, GameGiftRevisionResponse, GamePlayerResponse } from "../api/types";
|
||||
import { ErrorState, LoadingState } from "./StateViews";
|
||||
|
||||
type Ready = { catalogs: GameGiftCatalogResponse[]; revisions: GameGiftRevisionResponse[]; grants: GameGiftGrantResponse[]; players: GamePlayerResponse[]; selectedCatalog?: string; selectedRevision?: string; selectedPlayer?: string; name: string; notice: string; item: "bandage" | "water-bottle" | "improvised-spear"; quantity: number; note?: string };
|
||||
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; value: Ready };
|
||||
const itemLabels = { bandage: "绷带", "water-bottle": "饮用水", "improvised-spear": "简易长矛" } as const;
|
||||
|
||||
export function GameGiftCatalogPanel({ serverInstanceId }: { serverInstanceId: string }) {
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const load = useCallback(async () => { setState({ status: "loading" }); try { const [catalogs, grants, players] = await Promise.all([platformApiClient.listGameGiftCatalogs(serverInstanceId), platformApiClient.listGameGiftGrants(serverInstanceId), platformApiClient.listGamePlayers(serverInstanceId)]); const selectedCatalog = catalogs.items[0]?.id; const revisions = selectedCatalog ? await platformApiClient.listGameGiftRevisions(serverInstanceId, selectedCatalog) : { items: [] }; setState({ status: "ready", value: { catalogs: catalogs.items, revisions: revisions.items, grants: grants.items, players: players.items, selectedCatalog, selectedRevision: revisions.items[0]?.id, selectedPlayer: players.items[0]?.id, name: "新手补给", notice: "已向你发放运营礼包,请查收。", item: "bandage", quantity: 2 } }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "礼包目录读取失败" }); } }, [serverInstanceId]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
const edit = (patch: Partial<Ready>) => setState((current) => current.status === "ready" ? { ...current, value: { ...current.value, ...patch } } : current);
|
||||
async function createDraft() { if (state.status !== "ready") return; const v = state.value; try { const saved = await platformApiClient.saveGameGiftCatalog(serverInstanceId, { id: v.selectedCatalog, name: v.name, gameVersion: "0.9.700.90357", items: [{ catalogItemKey: v.item, quantity: v.quantity }] }); edit({ catalogs: [saved, ...v.catalogs.filter((catalog) => catalog.id !== saved.id)], selectedCatalog: saved.id, revisions: [], selectedRevision: undefined, note: "草稿已保存;发布后才可定向发放。" }); } catch (error) { edit({ note: error instanceof Error ? error.message : "草稿保存失败" }); } }
|
||||
async function publish() { if (state.status !== "ready" || !state.value.selectedCatalog) return; try { const revision = await platformApiClient.publishGameGiftCatalog(serverInstanceId, state.value.selectedCatalog); edit({ revisions: [revision, ...state.value.revisions], selectedRevision: revision.id, note: "已发布不可变版本,可预览并选择目标玩家。" }); } catch (error) { edit({ note: error instanceof Error ? error.message : "发布失败" }); } }
|
||||
async function requestGrant() { if (state.status !== "ready") return; const v=state.value; if (!v.selectedRevision || !v.selectedPlayer) return; try { const grant=await platformApiClient.requestGameGiftGrant(serverInstanceId,{revisionId:v.selectedRevision,gamePlayerRecordId:v.selectedPlayer,notice:v.notice,idempotencyKey:`gift-${v.selectedRevision}-${v.selectedPlayer}`}); edit({grants:[grant,...v.grants],note:"发放申请已冻结礼包版本和目标身份,等待平台管理员审批。"}); } catch(error){edit({note:error instanceof Error?error.message:"发放申请失败"});} }
|
||||
async function approve(grant: GameGiftGrantResponse) { if(state.status!=="ready")return;try{const next=await platformApiClient.approveGameGiftGrant(serverInstanceId,grant.id);edit({grants:state.value.grants.map((item)=>item.id===next.id?next:item),note:"已通过受控游戏通道排队发放。"});}catch(error){edit({note:error instanceof Error?error.message:"审批失败"});} }
|
||||
if(state.status==="loading")return <LoadingState label="正在加载版本化 SCUM 礼包目录…"/>;
|
||||
if(state.status==="error")return <ErrorState title="礼包目录不可用" reason={state.reason} onRetry={()=>void load()}/>;
|
||||
const v=state.value; const selectedRevision=v.revisions.find((item)=>item.id===v.selectedRevision);
|
||||
return <section className="console-panel" aria-label="SCUM 版本化礼包目录与定向发放"><div className="panel-header"><div><h2><Gift size={16}/> 版本化礼包目录与定向发放</h2><p className="provider-id">仅可选择此 SCUM 版本已验证的目录项;网页不提交游戏生成指令、物品代码或 RCON 文本。</p></div><button className="icon-command" type="button" onClick={()=>void load()}><RefreshCw size={14}/><span>刷新</span></button></div><div className="console-module"><h3>礼包草稿与版本</h3><div className="console-row-list"><label className="console-row"><strong>礼包名称</strong><input value={v.name} maxLength={80} onChange={(event)=>edit({name:event.target.value})}/></label><label className="console-row"><strong>已验证物品</strong><select value={v.item} onChange={(event)=>edit({item:event.target.value as Ready["item"]})}>{Object.entries(itemLabels).map(([key,label])=><option key={key} value={key}>{label}</option>)}</select></label><label className="console-row"><strong>数量</strong><input type="number" min={1} max={20} value={v.quantity} onChange={(event)=>edit({quantity:Number(event.target.value)})}/></label></div><div className="action-strip"><button className="command-button" type="button" onClick={()=>void createDraft()}>保存草稿</button><select aria-label="选择礼包草稿" value={v.selectedCatalog||""} onChange={async(event)=>{const catalogId=event.target.value;const response=await platformApiClient.listGameGiftRevisions(serverInstanceId,catalogId);edit({selectedCatalog:catalogId,revisions:response.items,selectedRevision:response.items[0]?.id});}}><option value="">选择草稿</option>{v.catalogs.map((catalog)=><option key={catalog.id} value={catalog.id}>{catalog.name} · {catalog.gameVersion}</option>)}</select><button className="primary-command" type="button" disabled={!v.selectedCatalog} onClick={()=>void publish()}>发布不可变版本</button></div></div><div className="console-module"><h3>预览、玩家选择与发放确认</h3><p className="page-status">{selectedRevision ? `版本 #${selectedRevision.revision}:${selectedRevision.items.map((item)=>`${item.label} ×${item.quantity}`).join("、")}` : "请先发布一个礼包版本。"}</p><div className="console-row-list"><label className="console-row"><strong>目标本地玩家</strong><select value={v.selectedPlayer||""} onChange={(event)=>edit({selectedPlayer:event.target.value})}>{v.players.map((player)=><option key={player.id} value={player.id}>{player.displayName}({player.gamePlayerId})</option>)}</select></label><label className="console-row"><strong>定向通知</strong><input maxLength={200} value={v.notice} onChange={(event)=>edit({notice:event.target.value})}/></label></div><button className="primary-command" type="button" disabled={!selectedRevision||!v.selectedPlayer} onClick={()=>void requestGrant()}><Send size={14}/>确认并提交发放申请</button></div>{v.note&&<p className="page-status">{v.note}</p>}<div className="console-record-list">{v.grants.map((grant)=><div className="console-record" key={grant.id}><strong>{grantStatus(grant.status)}</strong><span>{grant.playerDisplayName} · {grant.items.map((item)=>`${item.label} ×${item.quantity}`).join("、")}</span><small>版本 #{grant.revisionNumber} · {grant.deliverySummary||"等待投递结果"}{grant.notificationSummary?` · 通知:${grant.notificationSummary}`:""}</small>{grant.status==="pending-approval"&&<button className="icon-command" type="button" onClick={()=>void approve(grant)}><CheckCircle2 size={14}/><span>平台管理员审批</span></button>}</div>)}</div></section>;
|
||||
}
|
||||
function grantStatus(status: GameGiftGrantResponse["status"]) { return ({"pending-approval":"等待平台管理员审批",queued:"已排队投递",delivered:"物品已投递,通知处理中",notification_failed:"物品已投递,定向通知失败",failed:"投递失败",unknown:"投递结果未知,绝不自动重试"})[status]; }
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react";
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GamePlayerProfileResponse, GamePlayerResponse, GamePlayerStatePatchResponse, GamePlayerStateResponse } from "../api/types";
|
||||
import { ErrorState, LoadingState } from "./StateViews";
|
||||
import { GameGiftCatalogPanel } from "./GameGiftCatalogPanel";
|
||||
|
||||
type Selected = { profile: GamePlayerProfileResponse; state?: GamePlayerStateResponse; patches: GamePlayerStatePatchResponse[]; reason: string; after: Record<string, string>; note?: string };
|
||||
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; players: GamePlayerResponse[]; selected?: Selected };
|
||||
@@ -18,7 +19,7 @@ export function GamePlayerIntelligencePanel({ serverInstanceId }: { serverInstan
|
||||
if (state.status === "loading") return <LoadingState label="正在加载本地游戏玩家档案…" />;
|
||||
if (state.status === "error") return <ErrorState title="玩家档案不可用" reason={state.reason} onRetry={() => void load()} />;
|
||||
const selected = state.selected;
|
||||
return <section className="console-panel" aria-label="SCUM 本地玩家档案、风险信号与受控属性修改"><div className="panel-header"><div><h2><UsersRound size={16} /> 本地玩家档案与登录轨迹</h2><p className="provider-id">游戏身份独立于平台账号;网络数据仅用于服务器内不可逆关联,绝不展示或持久化原始值。</p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span>刷新</span></button></div><div className="console-record-list">{state.players.length ? state.players.map((player) => <button type="button" className="console-record" key={player.id} onClick={() => void select(player)}><strong>{player.displayName}</strong><span>游戏 ID:{player.gamePlayerId}</span><small>最近登录:{formatTime(player.lastSeenAt)}</small></button>) : <p className="page-status">暂无已投影的成功登录记录。</p>}</div>{selected && <PlayerDetail selected={selected} onReason={(reason) => setSelected(setState, reason)} onAfter={(key, value) => setAfter(setState, key, value)} onSubmit={() => void submitPatch()} onApprove={(patch) => void approvePatch(patch)} />}</section>;
|
||||
return <><section className="console-panel" aria-label="SCUM 本地玩家档案、风险信号与受控属性修改"><div className="panel-header"><div><h2><UsersRound size={16} /> 本地玩家档案与登录轨迹</h2><p className="provider-id">游戏身份独立于平台账号;网络数据仅用于服务器内不可逆关联,绝不展示或持久化原始值。</p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span>刷新</span></button></div><div className="console-record-list">{state.players.length ? state.players.map((player) => <button type="button" className="console-record" key={player.id} onClick={() => void select(player)}><strong>{player.displayName}</strong><span>游戏 ID:{player.gamePlayerId}</span><small>最近登录:{formatTime(player.lastSeenAt)}</small></button>) : <p className="page-status">暂无已投影的成功登录记录。</p>}</div>{selected && <PlayerDetail selected={selected} onReason={(reason) => setSelected(setState, reason)} onAfter={(key, value) => setAfter(setState, key, value)} onSubmit={() => void submitPatch()} onApprove={(patch) => void approvePatch(patch)} />}</section><GameGiftCatalogPanel serverInstanceId={serverInstanceId}/></>;
|
||||
}
|
||||
|
||||
function PlayerDetail({ selected, onReason, onAfter, onSubmit, onApprove }: { selected: Selected; onReason: (value: string) => void; onAfter: (key: string, value: string) => void; onSubmit: () => void; onApprove: (patch: GamePlayerStatePatchResponse) => void }) {
|
||||
|
||||
Reference in New Issue
Block a user