任务,用户头像显示相关

This commit is contained in:
zhx 2026-06-11 13:33:30 +08:00
parent 7b094f2b6f
commit 54fdc867fe
59 changed files with 3059 additions and 728 deletions

View File

@ -90,7 +90,7 @@
"name": "task_id",
"required": true,
"schema": {
"type": "integer"
"type": "string"
}
}
],
@ -112,7 +112,7 @@
"name": "task_id",
"required": true,
"schema": {
"type": "integer"
"type": "string"
}
}
],
@ -1731,6 +1731,106 @@
"x-permissions": ["game:update"]
}
},
"/admin/game/room-rps/config": {
"get": {
"operationId": "getRoomRpsConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:view",
"x-permissions": ["game:view"]
},
"patch": {
"operationId": "updateRoomRpsConfig",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:update",
"x-permissions": ["game:update"]
}
},
"/admin/game/room-rps/challenges": {
"get": {
"operationId": "listRoomRpsChallenges",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "game:view",
"x-permissions": ["game:view"]
}
},
"/admin/game/room-rps/challenges/{challenge_id}": {
"get": {
"operationId": "getRoomRpsChallenge",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "challenge_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "game:view",
"x-permissions": ["game:view"]
}
},
"/admin/game/room-rps/challenges/{challenge_id}/retry-settlement": {
"post": {
"operationId": "retryRoomRpsSettlement",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "challenge_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "game:update",
"x-permissions": ["game:update"]
}
},
"/admin/game/room-rps/challenges/{challenge_id}/expire": {
"post": {
"operationId": "expireRoomRpsChallenge",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"parameters": [
{
"in": "path",
"name": "challenge_id",
"required": true,
"schema": {
"type": "string"
}
}
],
"x-permission": "game:update",
"x-permissions": ["game:update"]
}
},
"/admin/gift-types": {
"get": {
"operationId": "listGiftTypes",

View File

@ -222,6 +222,8 @@ export const fallbackNavigation = [
routeNavItem("game-list", { icon: SportsEsportsOutlined }),
routeNavItem("self-games", { icon: SettingsApplicationsOutlined }),
routeNavItem("game-robots", { icon: ManageAccountsOutlined }),
routeNavItem("room-rps-config", { icon: SettingsApplicationsOutlined }),
routeNavItem("room-rps-challenges", { icon: HistoryOutlined }),
],
},
{

View File

@ -234,6 +234,8 @@ export const MENU_CODES = {
gameList: "game-list",
selfGames: "self-games",
gameRobots: "game-robots",
roomRpsConfig: "room-rps-config",
roomRpsChallenges: "room-rps-challenges",
} as const;
export type MenuCode = (typeof MENU_CODES)[keyof typeof MENU_CODES];

View File

@ -6,6 +6,7 @@ import { ConfirmProvider } from "@/shared/ui/ConfirmProvider.jsx";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { QueryProvider } from "@/shared/query/QueryProvider.jsx";
import { RefreshProvider } from "@/shared/hooks/useRefreshSignal.js";
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
import { theme } from "@/theme.js";
export function AppProviders({ children }) {
@ -17,7 +18,9 @@ export function AppProviders({ children }) {
<RefreshProvider>
<AuthProvider>
<AppScopeProvider>
<TimeZoneProvider>{children}</TimeZoneProvider>
<TimeZoneProvider>
<AppUserDetailProvider>{children}</AppUserDetailProvider>
</TimeZoneProvider>
</AppScopeProvider>
</AuthProvider>
</RefreshProvider>

View File

@ -5,36 +5,48 @@ import { PageLoadBoundary } from "@/shared/ui/PageLoadBoundary.jsx";
const pageCache = new Map();
export function DeferredPage({ route }) {
const [Page, setPage] = useState(() => pageCache.get(route.pageKey) || null);
const [Page, setPage] = useState(() => pageCache.get(route.pageKey) || null);
useEffect(() => {
let mounted = true;
useEffect(() => {
let mounted = true;
if (pageCache.has(route.pageKey)) {
setPage(() => pageCache.get(route.pageKey));
return undefined;
if (pageCache.has(route.pageKey)) {
setPage(() => pageCache.get(route.pageKey));
return undefined;
}
setPage(null);
route
.loader()
.then((component) => {
pageCache.set(route.pageKey, component);
if (mounted) {
setPage(() => component);
}
})
.catch((error) => {
if (mounted) {
setPage(
() =>
function DeferredPageLoaderError() {
throw error;
},
);
}
});
return () => {
mounted = false;
};
}, [route]);
if (!Page) {
return <PageSkeleton />;
}
setPage(null);
route.loader().then((component) => {
pageCache.set(route.pageKey, component);
if (mounted) {
setPage(() => component);
}
});
return () => {
mounted = false;
};
}, [route]);
if (!Page) {
return <PageSkeleton />;
}
return (
<PageLoadBoundary pageKey={route.pageKey}>
<Page {...(route.props || {})} />
</PageLoadBoundary>
);
return (
<PageLoadBoundary pageKey={route.pageKey}>
<Page {...(route.props || {})} />
</PageLoadBoundary>
);
}

View File

@ -1,6 +1,6 @@
import { afterEach, expect, test, vi } from "vitest";
import { setAccessToken } from "@/shared/api/request";
import { listAppUsers, listPrettyDisplayIDs } from "./api";
import { getAppUser, listAppUsers, listPrettyDisplayIDs } from "./api";
afterEach(() => {
setAccessToken("");
@ -86,3 +86,76 @@ test("listAppUsers sends country region and time filters", async () => {
expect(String(url)).toContain("end_ms=2000");
expect(init?.method).toBe("GET");
});
test("listAppUsers normalizes pretty display id fields from snake case", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
items: [
{
avatar: "https://cdn.example/avatar.png",
coin: 99,
default_display_user_id: "123456",
display_user_id: "123456",
pretty_display_user_id: "VIP2026",
pretty_id: "pretty-2026",
user_id: "10001",
username: "tester",
},
],
page: 1,
page_size: 50,
total: 1,
},
}),
),
),
);
const result = await listAppUsers({ page: 1, page_size: 50 });
expect(result.pageSize).toBe(50);
expect(result.items[0]).toMatchObject({
defaultDisplayUserId: "123456",
displayUserId: "123456",
prettyDisplayUserId: "VIP2026",
prettyId: "pretty-2026",
userId: "10001",
username: "tester",
});
});
test("getAppUser uses detail endpoint and normalizes pretty display id", async () => {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
display_user_id: "123456",
default_display_user_id: "123456",
pretty_display_user_id: "VIP888",
pretty_id: "pretty-888",
user_id: "10001",
},
}),
),
),
);
const result = await getAppUser("10001");
const [url, init] = vi.mocked(fetch).mock.calls[0];
expect(String(url)).toContain("/api/v1/app/users/10001");
expect(init?.method).toBe("GET");
expect(result.defaultDisplayUserId).toBe("123456");
expect(result.prettyDisplayUserId).toBe("VIP888");
expect(result.prettyId).toBe("pretty-888");
});

View File

@ -19,50 +19,76 @@ import type {
SetPrettyDisplayIDStatusPayload,
} from "@/shared/api/types";
export function listAppUsers(query: PageQuery = {}): Promise<ApiPage<AppUserDto>> {
export async function listAppUsers(query: PageQuery = {}): Promise<ApiPage<AppUserDto>> {
const endpoint = API_ENDPOINTS.appListUsers;
return apiRequest<ApiPage<AppUserDto>>(apiEndpointPath(API_OPERATIONS.appListUsers), {
method: endpoint.method,
query,
});
const data = await apiRequest<ApiPage<RawAppUser> & { page_size?: number }>(
apiEndpointPath(API_OPERATIONS.appListUsers),
{
method: endpoint.method,
query,
},
);
return normalizePage(data, normalizeAppUser);
}
export function listAppUserLoginLogs(query: PageQuery = {}): Promise<ApiPage<AppUserLoginLogDto>> {
export async function getAppUser(userId: EntityId): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appGetUser;
const data = await apiRequest<RawAppUser>(apiEndpointPath(API_OPERATIONS.appGetUser, { id: userId }), {
method: endpoint.method,
});
return normalizeAppUser(data);
}
export async function listAppUserLoginLogs(query: PageQuery = {}): Promise<ApiPage<AppUserLoginLogDto>> {
const endpoint = API_ENDPOINTS.appListLoginLogs;
return apiRequest<ApiPage<AppUserLoginLogDto>>(apiEndpointPath(API_OPERATIONS.appListLoginLogs), {
method: endpoint.method,
query,
});
const data = await apiRequest<ApiPage<RawAppUserLoginLog> & { page_size?: number }>(
apiEndpointPath(API_OPERATIONS.appListLoginLogs),
{
method: endpoint.method,
query,
},
);
return normalizePage(data, normalizeAppUserLoginLog);
}
export function listAppUserBans(query: PageQuery = {}): Promise<ApiPage<AppUserBanRecordDto>> {
export async function listAppUserBans(query: PageQuery = {}): Promise<ApiPage<AppUserBanRecordDto>> {
const endpoint = API_ENDPOINTS.appListBannedUsers;
return apiRequest<ApiPage<AppUserBanRecordDto>>(apiEndpointPath(API_OPERATIONS.appListBannedUsers), {
method: endpoint.method,
query,
});
const data = await apiRequest<ApiPage<RawAppUserBanRecord> & { page_size?: number }>(
apiEndpointPath(API_OPERATIONS.appListBannedUsers),
{
method: endpoint.method,
query,
},
);
return normalizePage(data, normalizeAppUserBanRecord);
}
export function updateAppUser(userId: EntityId, payload: AppUserUpdatePayload): Promise<AppUserDto> {
export async function updateAppUser(userId: EntityId, payload: AppUserUpdatePayload): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appUpdateUser;
return apiRequest<AppUserDto, AppUserUpdatePayload>(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), {
body: payload,
method: endpoint.method,
});
const data = await apiRequest<RawAppUser, AppUserUpdatePayload>(
apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }),
{
body: payload,
method: endpoint.method,
},
);
return normalizeAppUser(data);
}
export function banAppUser(userId: EntityId): Promise<AppUserDto> {
export async function banAppUser(userId: EntityId): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appBanUser;
return apiRequest<AppUserDto>(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), {
const data = await apiRequest<RawAppUser>(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), {
method: endpoint.method,
});
return normalizeAppUser(data);
}
export function unbanAppUser(userId: EntityId): Promise<AppUserDto> {
export async function unbanAppUser(userId: EntityId): Promise<AppUserDto> {
const endpoint = API_ENDPOINTS.appUnbanUser;
return apiRequest<AppUserDto>(apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }), {
const data = await apiRequest<RawAppUser>(apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }), {
method: endpoint.method,
});
return normalizeAppUser(data);
}
export function setAppUserPassword(
@ -169,6 +195,96 @@ export async function setPrettyDisplayIDStatus(
return normalizePrettyDisplayID(data);
}
// 用户相关接口历史上既返回过 snake_case也返回过 camelCase在 API 边界归一化后,共享用户组件只消费稳定字段。
interface RawAppUser {
avatar?: string;
coin?: number;
country?: string;
country_display_name?: string;
countryDisplayName?: string;
country_name?: string;
countryName?: string;
created_at_ms?: number;
createdAtMs?: number;
default_display_user_id?: string;
defaultDisplayUserId?: string;
diamond?: number;
display_user_id?: string;
displayUserId?: string;
gender?: string;
last_active_at_ms?: number;
lastActiveAtMs?: number;
pretty_display_user_id?: string;
prettyDisplayUserId?: string;
pretty_id?: string;
prettyId?: string;
region_id?: number;
regionId?: number;
region_name?: string;
regionName?: string;
status?: string;
updated_at_ms?: number;
updatedAtMs?: number;
user_id?: EntityId;
userId?: EntityId;
username?: string;
}
type RawAppUserBrief = RawAppUser;
interface RawAppUserBanOperator extends RawAppUserBrief {
account?: string;
admin_id?: EntityId;
adminId?: EntityId;
name?: string;
type?: string;
}
interface RawAppUserBanRecord {
created_at_ms?: number;
createdAtMs?: number;
id?: number;
new_status?: string;
newStatus?: string;
old_status?: string;
oldStatus?: string;
operator?: RawAppUserBanOperator | null;
reason?: string;
request_id?: string;
requestId?: string;
target?: RawAppUserBrief;
}
interface RawAppUserLoginLog extends RawAppUserBrief {
block_reason?: string;
blockReason?: string;
blocked?: boolean;
channel?: string;
country?: string;
created_at_ms?: number;
createdAtMs?: number;
failure_code?: string;
failureCode?: string;
id?: number;
ip_country_code?: string;
ipCountryCode?: string;
login_ip?: string;
loginIp?: string;
login_type?: string;
loginType?: string;
platform?: string;
provider?: string;
region_id?: number;
regionId?: number;
region_name?: string;
regionName?: string;
request_id?: string;
requestId?: string;
result?: string;
risk_highlighted?: boolean;
riskHighlighted?: boolean;
}
// Go 后台当前按 snake_case JSON 返回,生成类型和页面代码使用 camelCaseRaw 类型同时接收两种形态,便于后续切换生成客户端。
interface RawPrettyDisplayIDPool {
app_code?: string;
@ -291,6 +407,94 @@ function normalizePage<TRaw, TItem>(
};
}
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
return {
avatar: stringValue(item.avatar),
coin: numberValue(item.coin),
country: stringValue(item.country),
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name),
countryName: stringValue(item.countryName ?? item.country_name),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
defaultDisplayUserId: stringValue(item.defaultDisplayUserId ?? item.default_display_user_id),
diamond: numberValue(item.diamond),
displayUserId: stringValue(item.displayUserId ?? item.display_user_id),
gender: stringValue(item.gender),
lastActiveAtMs: numberValue(item.lastActiveAtMs ?? item.last_active_at_ms),
prettyDisplayUserId: stringValue(item.prettyDisplayUserId ?? item.pretty_display_user_id),
prettyId: stringValue(item.prettyId ?? item.pretty_id),
regionId: numberValue(item.regionId ?? item.region_id),
regionName: stringValue(item.regionName ?? item.region_name),
status: stringValue(item.status),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
userId: stringValue(item.userId ?? item.user_id),
username: stringValue(item.username),
};
}
function normalizeAppUserBrief(item: RawAppUserBrief = {}) {
const user = normalizeAppUser(item);
return {
avatar: user.avatar,
defaultDisplayUserId: user.defaultDisplayUserId,
displayUserId: user.displayUserId,
prettyDisplayUserId: user.prettyDisplayUserId,
prettyId: user.prettyId,
userId: user.userId,
username: user.username,
};
}
function normalizeAppUserBanOperator(item?: RawAppUserBanOperator | null) {
if (!item) {
return undefined;
}
const user = normalizeAppUserBrief(item);
return {
...user,
account: stringValue(item.account),
adminId: stringValue(item.adminId ?? item.admin_id),
name: stringValue(item.name),
type: stringValue(item.type || "system"),
};
}
function normalizeAppUserBanRecord(item: RawAppUserBanRecord = {}): AppUserBanRecordDto {
return {
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
id: numberValue(item.id),
newStatus: stringValue(item.newStatus ?? item.new_status),
oldStatus: stringValue(item.oldStatus ?? item.old_status),
operator: normalizeAppUserBanOperator(item.operator),
reason: stringValue(item.reason),
requestId: stringValue(item.requestId ?? item.request_id),
target: normalizeAppUserBrief(item.target || {}),
};
}
function normalizeAppUserLoginLog(item: RawAppUserLoginLog = {}): AppUserLoginLogDto {
const user = normalizeAppUserBrief(item);
return {
...user,
blockReason: stringValue(item.blockReason ?? item.block_reason),
blocked: Boolean(item.blocked),
channel: stringValue(item.channel),
country: stringValue(item.country),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
failureCode: stringValue(item.failureCode ?? item.failure_code),
id: numberValue(item.id),
ipCountryCode: stringValue(item.ipCountryCode ?? item.ip_country_code),
loginIp: stringValue(item.loginIp ?? item.login_ip),
loginType: stringValue(item.loginType ?? item.login_type),
platform: stringValue(item.platform),
provider: stringValue(item.provider),
regionId: numberValue(item.regionId ?? item.region_id),
regionName: stringValue(item.regionName ?? item.region_name),
requestId: stringValue(item.requestId ?? item.request_id),
result: stringValue(item.result),
riskHighlighted: Boolean(item.riskHighlighted ?? item.risk_highlighted),
};
}
// 池 DTO 在这里完成字段名和数值兜底,页面表格不再关心后端是否省略可选字段。
function normalizePrettyDisplayIDPool(item: RawPrettyDisplayIDPool): PrettyDisplayIDPoolDto {
return {
@ -311,6 +515,15 @@ function normalizePrettyDisplayIDPool(item: RawPrettyDisplayIDPool): PrettyDispl
};
}
function stringValue(value: unknown): string {
return value === undefined || value === null ? "" : String(value).trim();
}
function numberValue(value: unknown): number {
const parsed = Number(value || 0);
return Number.isFinite(parsed) ? parsed : 0;
}
// 靓号明细包含池号和后台发放两类来源pool 可能为空,归一化后页面只判断 source/status 即可。
function normalizePrettyDisplayID(item: RawPrettyDisplayID): PrettyDisplayIDDto {
return {

View File

@ -340,6 +340,67 @@
line-height: 1.3;
}
.detailHero {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border);
}
.detailLoading {
padding: var(--space-2) var(--space-3);
border: 1px solid var(--primary-border);
border-radius: var(--radius-sm);
background: var(--primary-surface);
color: var(--primary);
font-size: 12px;
font-weight: 700;
}
.detailSection {
display: grid;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--bg-card-strong);
}
.detailSectionTitle {
margin: 0;
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 800;
}
.detailGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-3);
}
.detailItem {
display: grid;
min-width: 0;
gap: 4px;
}
.detailLabel {
color: var(--text-tertiary);
font-size: 12px;
}
.detailValue {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 650;
}
.stack {
display: grid;
gap: 1px;

View File

@ -0,0 +1,9 @@
import { createContext, useContext } from "react";
export const AppUserDetailContext = createContext({
openAppUserDetail: null,
});
export function useAppUserDetail() {
return useContext(AppUserDetailContext);
}

View File

@ -0,0 +1,201 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { getAppUser } from "@/features/app-users/api";
import { appUserStatusLabels } from "@/features/app-users/constants.js";
import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx";
import styles from "@/features/app-users/app-users.module.css";
export function AppUserDetailProvider({ children }) {
const { showToast } = useToast();
const requestSeqRef = useRef(0);
const [detailUser, setDetailUser] = useState(null);
const [loadingUserId, setLoadingUserId] = useState("");
const closeAppUserDetail = useCallback(() => {
requestSeqRef.current += 1;
setDetailUser(null);
setLoadingUserId("");
}, []);
const openAppUserDetail = useCallback(
async (source) => {
const seedUser = normalizeDetailSeed(source);
if (!seedUser.userId) {
showToast("缺少用户 ID无法打开用户详情", "error");
return;
}
const requestSeq = requestSeqRef.current + 1;
requestSeqRef.current = requestSeq;
setDetailUser(seedUser);
setLoadingUserId(seedUser.userId);
try {
const latestUser = await getAppUser(seedUser.userId);
if (requestSeqRef.current === requestSeq) {
setDetailUser({ ...seedUser, ...latestUser });
}
} catch (err) {
if (requestSeqRef.current === requestSeq) {
showToast(err.message || "加载用户详情失败", "error");
}
} finally {
if (requestSeqRef.current === requestSeq) {
setLoadingUserId("");
}
}
},
[showToast],
);
const value = useMemo(() => ({ closeAppUserDetail, openAppUserDetail }), [closeAppUserDetail, openAppUserDetail]);
return (
<AppUserDetailContext.Provider value={value}>
{children}
<AppUserDetailDrawer
loading={Boolean(detailUser?.userId) && loadingUserId === detailUser.userId}
open={Boolean(detailUser)}
user={detailUser}
onClose={closeAppUserDetail}
/>
</AppUserDetailContext.Provider>
);
}
function AppUserDetailDrawer({ loading, onClose, open, user }) {
if (!open || !user) {
return null;
}
return (
<SideDrawer open={open} title="用户详情" width="wide" onClose={onClose}>
<div className={styles.detailHero}>
<AdminUserIdentity user={user} size="large" />
<AppUserStatus status={user.status} />
</div>
{loading ? <div className={styles.detailLoading}>正在刷新用户详情...</div> : null}
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>身份信息</h3>
<div className={styles.detailGrid}>
<DetailItem label="用户 ID" value={user.userId} />
<DetailItem label="短 ID" value={user.defaultDisplayUserId || user.displayUserId || user.userId} />
<DetailItem label="靓号" value={<PrettyValue user={user} />} />
<DetailItem label="靓号记录 ID" value={user.prettyId} />
<DetailItem label="昵称" value={user.username} />
<DetailItem label="性别" value={formatGender(user.gender)} />
</div>
</section>
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>账号状态</h3>
<div className={styles.detailGrid}>
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
<DetailItem label="金币" value={formatNumber(user.coin)} />
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
<DetailItem label="国家" value={formatCountry(user)} />
<DetailItem
label="区域"
value={user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-")}
/>
<DetailItem label="最近活跃" value={formatMillis(user.lastActiveAtMs)} />
<DetailItem label="创建时间" value={formatMillis(user.createdAtMs)} />
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
</div>
</section>
</SideDrawer>
);
}
function DetailItem({ label, value }) {
const isElement = value && typeof value === "object";
return (
<div className={styles.detailItem}>
<span className={styles.detailLabel}>{label}</span>
<span className={styles.detailValue}>
{isElement ? value : value === undefined || value === null || value === "" ? "-" : value}
</span>
</div>
);
}
function PrettyValue({ user }) {
if (!user.prettyId || !user.prettyDisplayUserId) {
return "-";
}
return <AdminPrettyDisplayID prettyDisplayUserId={user.prettyDisplayUserId} prettyId={user.prettyId} />;
}
function AppUserStatus({ status }) {
const tone = status === "active" ? "running" : "danger";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{appUserStatusLabels[status] || status || "-"}
</span>
);
}
function normalizeDetailSeed(source) {
if (source === undefined || source === null) {
return {};
}
if (typeof source === "string" || typeof source === "number") {
return { userId: String(source).trim() };
}
const userId = firstNonEmpty(
source.userId,
source.user_id,
source.assignedUserId,
source.assigned_user_id,
source.targetUserId,
source.target_user_id,
);
return {
...source,
avatar: firstNonEmpty(source.avatar),
defaultDisplayUserId: firstNonEmpty(source.defaultDisplayUserId, source.default_display_user_id),
displayUserId: firstNonEmpty(source.displayUserId, source.display_user_id),
prettyDisplayUserId: firstNonEmpty(source.prettyDisplayUserId, source.pretty_display_user_id),
prettyId: firstNonEmpty(source.prettyId, source.pretty_id),
userId,
username: firstNonEmpty(source.username, source.name, source.account),
};
}
function formatCountry(user) {
return user.countryDisplayName || user.countryName || user.country || "-";
}
function formatGender(gender) {
const value = String(gender || "").toLowerCase();
if (value === "male" || value === "m" || value === "男") {
return "男";
}
if (value === "female" || value === "f" || value === "女") {
return "女";
}
if (value === "non_binary") {
return "非二元";
}
return gender || "-";
}
function formatNumber(value) {
const number = Number(value || 0);
return Number.isFinite(number) ? number.toLocaleString() : "-";
}
function firstNonEmpty(...values) {
for (const value of values) {
const normalized = String(value ?? "").trim();
if (normalized) {
return normalized;
}
}
return "";
}

View File

@ -4,7 +4,13 @@ import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { useConfirm } from "@/shared/ui/ConfirmProvider.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { banAppUser, listAppUsers, setAppUserPassword, unbanAppUser, updateAppUser } from "@/features/app-users/api";
import {
banAppUser,
listAppUsers,
setAppUserPassword,
unbanAppUser,
updateAppUser,
} from "@/features/app-users/api";
import { useAppUserAbilities } from "@/features/app-users/permissions.js";
import { appUserCountrySchema, appUserPasswordSchema, appUserUpdateSchema } from "@/features/app-users/schema";
import { useCountryOptions } from "@/features/host-org/hooks/useCountryOptions.js";
@ -70,7 +76,6 @@ export function useAppUsersPage() {
items: items.map((user) => (patchedUsers[user.userId] ? { ...user, ...patchedUsers[user.userId] } : user)),
};
}, [patchedUsers, queryData]);
useEffect(() => {
setSelectedUserIds([]);
setPatchedUsers({});

View File

@ -1,5 +1,5 @@
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useCallback, useMemo, useState } from "react";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
@ -122,14 +122,7 @@ export function AppUserBanListPage() {
}
function TargetIdentity({ target }) {
return (
<UserIdentity
avatar={target?.avatar}
name={target?.username}
primaryFallback={target?.displayUserId || target?.userId}
rows={[target?.displayUserId, target?.userId]}
/>
);
return <AdminUserIdentity openInAppUserDetail rows={[target?.displayUserId, target?.userId]} user={target} />;
}
function OperatorIdentity({ operator }) {
@ -138,52 +131,26 @@ function OperatorIdentity({ operator }) {
}
if (operator.type === "admin") {
return (
<UserIdentity
<AdminUserIdentity
name={operator.account}
primaryFallback={operator.adminId}
rows={[operator.name, operator.adminId ? `Admin ID ${operator.adminId}` : "后台管理员"]}
user={operator}
/>
);
}
if (operator.type === "app_user") {
return (
<UserIdentity
avatar={operator.avatar}
<AdminUserIdentity
openInAppUserDetail
name={operator.name}
primaryFallback={operator.displayUserId || operator.userId}
rows={[operator.displayUserId, operator.userId]}
user={operator}
/>
);
}
return <span className="muted">system</span>;
}
function UserIdentity({ avatar, name, primaryFallback, rows }) {
const title = name || primaryFallback || "-";
const visibleRows = rows.filter(Boolean);
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{avatar ? <img src={avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<div className={styles.nameLine}>
<span className={styles.name}>{title}</span>
</div>
{visibleRows.length ? (
<div className={styles.stack}>
{visibleRows.map((value, index) => (
<span className={styles.meta} key={`${value}-${index}`}>
{value}
</span>
))}
</div>
) : null}
</div>
</div>
);
}
function UserStatus({ status }) {
return (
<span className="status-badge status-badge--danger">

View File

@ -4,7 +4,6 @@ import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutl
import KeyboardArrowUpOutlined from "@mui/icons-material/KeyboardArrowUpOutlined";
import LockOpenOutlined from "@mui/icons-material/LockOpenOutlined";
import PasswordOutlined from "@mui/icons-material/PasswordOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import UnfoldMoreOutlined from "@mui/icons-material/UnfoldMoreOutlined";
import Autocomplete from "@mui/material/Autocomplete";
import Checkbox from "@mui/material/Checkbox";
@ -12,6 +11,7 @@ import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
@ -24,15 +24,6 @@ import { appUserStatusFilters, appUserStatusLabels, genderOptions } from "@/feat
import { useAppUsersPage } from "@/features/app-users/hooks/useAppUsersPage.js";
import styles from "@/features/app-users/app-users.module.css";
const columns = [
{
key: "identity",
label: "用户",
width: "minmax(240px, 1.5fr)",
render: (user) => <UserIdentity user={user} />,
},
];
export function AppUserListPage() {
const page = useAppUsersPage();
const items = page.data.items || [];
@ -49,7 +40,9 @@ export function AppUserListPage() {
);
return;
}
page.setSelectedUserIds((current) => Array.from(new Set([...current, ...selectableItems.map((user) => user.userId)])));
page.setSelectedUserIds((current) =>
Array.from(new Set([...current, ...selectableItems.map((user) => user.userId)])),
);
};
const tableColumns = [
{
@ -85,12 +78,15 @@ export function AppUserListPage() {
},
},
{
...columns[0],
key: "identity",
label: "用户",
width: "minmax(240px, 1.5fr)",
filter: createTextColumnFilter({
placeholder: "搜索名称、短 ID、用户 ID",
value: page.query,
onChange: page.changeQuery,
}),
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
},
{
key: "location",
@ -305,26 +301,6 @@ function UserLocation({ page, user }) {
);
}
function UserIdentity({ user }) {
const name = user.username || "-";
const shortId = user.displayUserId || user.userId;
const gender = genderView(user.gender);
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img src={user.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<div className={styles.nameLine}>
<span className={styles.name}>{name}</span>
<span className={`${styles.gender} ${styles[gender.className]}`}>{gender.symbol}</span>
</div>
<div className={styles.meta}>{shortId}</div>
</div>
</div>
);
}
function UserActions({ page, user }) {
const banned = user.status === "banned" || user.status === "disabled";
return (
@ -412,17 +388,6 @@ function ActionModal({ children, disabled, loading, onClose, onSubmit, open, sec
);
}
function genderView(gender) {
const value = String(gender || "").toLowerCase();
if (value === "male" || value === "m" || value === "男") {
return { className: "genderMale", symbol: "♂" };
}
if (value === "female" || value === "f" || value === "女") {
return { className: "genderFemale", symbol: "♀" };
}
return { className: "genderUnknown", symbol: "-" };
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

@ -1,7 +1,7 @@
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { toPageQuery } from "@/shared/api/query";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
@ -45,7 +45,12 @@ export function AppUserLoginLogsPage() {
[page, query, regionId, result, userFilter],
);
const loadLogs = useCallback(() => listAppUserLoginLogs(requestQuery), [requestQuery]);
const { data, error, loading: queryLoading, reload } = useAdminQuery(loadLogs, {
const {
data,
error,
loading: queryLoading,
reload,
} = useAdminQuery(loadLogs, {
errorMessage: "加载用户登录日志失败",
initialData: emptyData,
keepPreviousData: true,
@ -149,7 +154,7 @@ export function AppUserLoginLogsPage() {
value: query,
onChange: changeIdentityFilter,
}),
render: (log) => <UserIdentity log={log} />,
render: (log) => <AdminUserIdentity openInAppUserDetail user={log} />,
},
{
key: "ip",
@ -292,24 +297,6 @@ function loginLogRowProps(log, onOpenHistory) {
};
}
function UserIdentity({ log }) {
const name = log.username || "-";
const shortId = log.displayUserId || log.userId || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{log.avatar ? <img src={log.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<div className={styles.nameLine}>
<span className={styles.name}>{name}</span>
</div>
<div className={styles.meta}>{shortId}</div>
</div>
</div>
);
}
function ResultBadge({ log }) {
const result = String(log.result || "").toLowerCase();
const blocked = log.blocked || result === "blocked";

View File

@ -12,6 +12,7 @@ import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import {
prettyIdSourceLabels,
@ -338,7 +339,7 @@ function idColumns(page) {
width: "minmax(190px, 0.9fr)",
render: (item) => (
<div className={styles.stack}>
<span>{formatEntityID(item.assignedUserId)}</span>
<AssignedUserCell item={item} />
<span className={styles.meta}>
{item.assignedAtMs ? formatMillis(item.assignedAtMs) : formatMillis(item.createdAtMs)}
</span>
@ -366,6 +367,32 @@ function idColumns(page) {
];
}
function AssignedUserCell({ item }) {
const { openAppUserDetail } = useAppUserDetail();
const assignedUserId = formatEntityID(item.assignedUserId);
if (!item.assignedUserId || !openAppUserDetail) {
return <span>{assignedUserId}</span>;
}
return (
<button
className={styles.countryButton}
type="button"
onClick={(event) => {
event.stopPropagation();
openAppUserDetail({
displayUserId: assignedUserId,
prettyDisplayUserId: item.displayUserId,
prettyId: item.prettyId,
userId: item.assignedUserId,
});
}}
>
{assignedUserId}
</button>
);
}
function PoolDialog({ page }) {
const editing = Boolean(page.activePool);
return (

View File

@ -39,6 +39,8 @@ export interface CumulativeRechargeRewardConfigPayload {
export interface CumulativeRechargeRewardGrantUserDto {
userId?: number;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
username?: string;
avatar?: string;
}
@ -145,6 +147,8 @@ function normalizeGrant(item: RawGrant): CumulativeRechargeRewardGrantDto {
user: {
userId: numberValue(rawUser.userId ?? rawUser.user_id),
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
username: stringValue(rawUser.username),
avatar: stringValue(rawUser.avatar),
},

View File

@ -1,9 +1,9 @@
import Avatar from "@mui/material/Avatar";
import { CumulativeRechargeRewardConfigDrawer } from "@/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigDrawer.jsx";
import { CumulativeRechargeRewardConfigSummary } from "@/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigSummary.jsx";
import { useCumulativeRechargeRewardPage } from "@/features/cumulative-recharge-reward/hooks/useCumulativeRechargeRewardPage.js";
import styles from "@/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
@ -179,21 +179,7 @@ export function CumulativeRechargeRewardPage() {
function GrantUser({ grant }) {
const user = grant.user || {};
return (
<div className={styles.identity}>
<Avatar
alt={user.username || String(grant.userId)}
src={user.avatar || ""}
sx={{ width: 36, height: 36 }}
/>
<div className={styles.stack}>
<span className={styles.name}>{user.username || `用户 ${grant.userId}`}</span>
<span className={styles.meta}>
{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${grant.userId}`}
</span>
</div>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || grant.userId }} />;
}
function grantStatusLabel(status) {

View File

@ -9,6 +9,13 @@ export interface TaskDefinitionDto {
metricType: string;
title: string;
description?: string;
audienceType: string;
iconKey: string;
iconUrl: string;
actionType: string;
actionParam: string;
actionPayloadJson: string;
dimensionFilterJson: string;
targetValue: number;
targetUnit: string;
rewardCoinAmount: number;
@ -29,6 +36,13 @@ export interface TaskDefinitionPayload {
metric_type: string;
title: string;
description: string;
audience_type: string;
icon_key: string;
icon_url: string;
action_type: string;
action_param: string;
action_payload_json: string;
dimension_filter_json: string;
target_value: number;
target_unit: string;
reward_coin_amount: number;
@ -100,6 +114,13 @@ function normalizeTaskDefinition(task: RawTaskDefinitionDto): TaskDefinitionDto
metricType: stringValue(task.metricType ?? task.metric_type),
title: stringValue(task.title),
description: stringValue(task.description),
audienceType: stringValue(task.audienceType ?? task.audience_type),
iconKey: stringValue(task.iconKey ?? task.icon_key),
iconUrl: stringValue(task.iconUrl ?? task.icon_url),
actionType: stringValue(task.actionType ?? task.action_type),
actionParam: stringValue(task.actionParam ?? task.action_param),
actionPayloadJson: stringValue(task.actionPayloadJson ?? task.action_payload_json),
dimensionFilterJson: stringValue(task.dimensionFilterJson ?? task.dimension_filter_json),
targetValue: numberValue(task.targetValue ?? task.target_value),
targetUnit: stringValue(task.targetUnit ?? task.target_unit),
rewardCoinAmount: numberValue(task.rewardCoinAmount ?? task.reward_coin_amount),

View File

@ -29,3 +29,43 @@ export const taskUnitLabels = {
count: "次数",
minute: "分钟",
};
export const taskAudienceOptions = [
["all", "全部用户"],
["newbie", "新人专属"],
];
export const taskAudienceLabels = {
all: "全部用户",
newbie: "新人专属",
};
export const taskActionOptions = [
["none", "无跳转"],
["room_random", "随机房间"],
["room_random_game", "随机房间并打开游戏"],
["wallet", "钱包"],
["gift_panel", "礼物面板"],
["gift_panel_specific", "指定礼物面板"],
];
export const taskActionLabels = {
gift_panel: "礼物面板",
gift_panel_specific: "指定礼物面板",
none: "无跳转",
room_random: "随机房间",
room_random_game: "随机房间并打开游戏",
wallet: "钱包",
};
export const taskMetricOptions = [
["game_spend_coin", "游戏消耗金币"],
["gift_spend_coin", "普通礼物消耗金币"],
["gift_send_count", "赠送礼物次数"],
["lucky_gift_spend_coin", "幸运礼物消耗金币"],
["lucky_gift_send_count", "赠送幸运礼物次数"],
["recharge_coin", "累计充值金币"],
["cp_relationship_created", "组成 CP"],
["mic_online_minute", "麦克风使用时长"],
["mood_publish_count", "发布心情"],
];

View File

@ -43,6 +43,28 @@
line-height: 1;
}
.taskIcon {
display: inline-flex;
width: 32px;
height: 32px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
background: var(--surface-muted);
color: var(--text-secondary);
font-size: var(--admin-font-size-sm);
font-weight: 700;
}
.taskIcon img {
width: 100%;
height: 100%;
object-fit: cover;
}
.statusCell {
display: inline-flex;
min-width: 0;

View File

@ -14,10 +14,17 @@ import { dailyTaskFormSchema } from "@/features/daily-tasks/schema";
const pageSize = 50;
const emptyData = { items: [], page: 1, pageSize, total: 0 };
const emptyForm = (task = {}) => ({
actionParam: task.actionParam || "",
actionPayloadJson: task.actionPayloadJson || "{}",
actionType: task.actionType || "none",
audienceType: task.audienceType || "all",
category: task.category || "",
description: task.description || "",
dimensionFilterJson: task.dimensionFilterJson || "{}",
effectiveFrom: msToDatetimeLocal(task.effectiveFromMs),
effectiveTo: msToDatetimeLocal(task.effectiveToMs),
iconKey: task.iconKey || "",
iconUrl: task.iconUrl || "",
metricType: task.metricType || "",
rewardCoinAmount: task.rewardCoinAmount === 0 || task.rewardCoinAmount ? String(task.rewardCoinAmount) : "",
sortOrder: task.sortOrder === 0 || task.sortOrder ? String(task.sortOrder) : "0",
@ -173,10 +180,17 @@ export function useDailyTasksPage() {
function buildTaskPayload(form) {
return {
action_param: form.actionParam.trim(),
action_payload_json: normalizedJSONObject(form.actionPayloadJson),
action_type: form.actionType,
audience_type: form.audienceType,
category: form.category.trim(),
description: form.description.trim(),
dimension_filter_json: normalizedJSONObject(form.dimensionFilterJson),
effective_from_ms: datetimeLocalToMs(form.effectiveFrom),
effective_to_ms: datetimeLocalToMs(form.effectiveTo),
icon_key: form.iconKey.trim(),
icon_url: form.iconUrl.trim(),
metric_type: form.metricType.trim(),
reward_coin_amount: Number(form.rewardCoinAmount),
sort_order: Number(form.sortOrder || 0),
@ -188,6 +202,22 @@ function buildTaskPayload(form) {
};
}
function normalizedJSONObject(value) {
const trimmed = String(value || "").trim();
if (!trimmed) {
return "{}";
}
try {
const parsed = JSON.parse(trimmed);
if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
return "{}";
}
return JSON.stringify(parsed);
} catch {
return trimmed;
}
}
function resetSetter(setter, setPage) {
return (value) => {
setter(value);

View File

@ -23,6 +23,11 @@ import {
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import {
taskActionLabels,
taskActionOptions,
taskAudienceLabels,
taskAudienceOptions,
taskMetricOptions,
taskStatusLabels,
taskStatusOptions,
taskTypeOptions,
@ -50,6 +55,20 @@ const baseColumns = [
</div>
),
},
{
key: "display",
label: "属性 / 跳转",
width: "minmax(220px, 1fr)",
render: (task) => (
<div className={styles.stack}>
<span>{taskAudienceLabel(task.audienceType)}</span>
<span className={styles.meta}>
{taskActionLabel(task.actionType)}
{task.actionParam ? `${task.actionParam}` : ""}
</span>
</div>
),
},
{
key: "target",
label: "目标 / 奖励",
@ -182,7 +201,7 @@ export function DailyTaskListPage() {
<DataTable
columns={columns}
items={items}
minWidth="1340px"
minWidth="1560px"
pagination={
total > 0
? {
@ -214,6 +233,7 @@ export function DailyTaskListPage() {
function TaskIdentity({ task }) {
return (
<div className={styles.identity}>
<TaskIconPreview task={task} />
<span className={styles.typeBadge}>{taskTypeLabel(task.taskType)}</span>
<div className={styles.identityText}>
<span className={styles.name}>{task.title || task.taskId}</span>
@ -223,6 +243,15 @@ function TaskIdentity({ task }) {
);
}
function TaskIconPreview({ task }) {
const label = String(task.iconKey || "任务").slice(0, 2);
return (
<span className={styles.taskIcon} title={task.iconKey || task.iconUrl || "任务图标"}>
{task.iconUrl ? <img alt="" src={task.iconUrl} /> : label}
</span>
);
}
function TaskStatusSwitch({ page, task }) {
const checked = task.status === "active";
const disabled =
@ -314,9 +343,16 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
disabled={disabled}
label="指标类型"
required
select
value={form.metricType}
onChange={(event) => page.setForm({ ...form, metricType: event.target.value })}
/>
>
{taskMetricOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="目标单位"
@ -356,6 +392,72 @@ function TaskFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="展示与跳转">
<AdminFormFieldGrid>
<TextField
disabled={disabled}
label="适用人群"
required
select
value={form.audienceType}
onChange={(event) => page.setForm({ ...form, audienceType: event.target.value })}
>
{taskAudienceOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="跳转类型"
required
select
value={form.actionType}
onChange={(event) => page.setForm({ ...form, actionType: event.target.value })}
>
{taskActionOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="跳转参数"
value={form.actionParam}
onChange={(event) => page.setForm({ ...form, actionParam: event.target.value })}
/>
<TextField
disabled={disabled}
label="图标键"
value={form.iconKey}
onChange={(event) => page.setForm({ ...form, iconKey: event.target.value })}
/>
<TextField
disabled={disabled}
label="图标 URL"
value={form.iconUrl}
onChange={(event) => page.setForm({ ...form, iconUrl: event.target.value })}
/>
<TextField
disabled={disabled}
label="维度过滤 JSON"
multiline
minRows={2}
value={form.dimensionFilterJson}
onChange={(event) => page.setForm({ ...form, dimensionFilterJson: event.target.value })}
/>
<TextField
disabled={disabled}
label="跳转扩展 JSON"
multiline
minRows={2}
value={form.actionPayloadJson}
onChange={(event) => page.setForm({ ...form, actionPayloadJson: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="有效期">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
@ -398,6 +500,14 @@ function taskStatusLabel(value) {
return taskStatusLabels[value] || value || "-";
}
function taskAudienceLabel(value) {
return taskAudienceLabels[value] || value || "-";
}
function taskActionLabel(value) {
return taskActionLabels[value] || value || "-";
}
function taskSwitchUncheckedLabel(status, mode) {
if (status === "archived") {
return "归档";

View File

@ -3,9 +3,16 @@ import { z } from "zod";
export const dailyTaskFormSchema = z
.object({
category: z.string().trim().min(1, "请输入任务分类").max(32, "任务分类不能超过 32 个字符"),
actionParam: z.string().trim().max(512, "跳转参数不能超过 512 个字符"),
actionPayloadJson: z.string().trim().optional(),
actionType: z.enum(["none", "room_random", "room_random_game", "wallet", "gift_panel", "gift_panel_specific"]),
audienceType: z.enum(["all", "newbie"]),
description: z.string().trim().max(512, "描述不能超过 512 个字符"),
dimensionFilterJson: z.string().trim().optional(),
effectiveFrom: z.string().optional(),
effectiveTo: z.string().optional(),
iconKey: z.string().trim().max(64, "图标键不能超过 64 个字符"),
iconUrl: z.string().trim().max(512, "图标 URL 不能超过 512 个字符"),
metricType: z.string().trim().min(1, "请输入指标类型").max(64, "指标类型不能超过 64 个字符"),
rewardCoinAmount: z.union([z.string(), z.number()]),
sortOrder: z.union([z.string(), z.number()]).optional(),
@ -40,6 +47,12 @@ export const dailyTaskFormSchema = z
if (effectiveFromMs > 0 && effectiveToMs > 0 && effectiveToMs <= effectiveFromMs) {
context.addIssue({ code: "custom", message: "结束时间必须晚于开始时间", path: ["effectiveTo"] });
}
if (!isJSONObject(value.actionPayloadJson)) {
context.addIssue({ code: "custom", message: "跳转扩展必须是 JSON 对象", path: ["actionPayloadJson"] });
}
if (!isJSONObject(value.dimensionFilterJson)) {
context.addIssue({ code: "custom", message: "维度过滤必须是 JSON 对象", path: ["dimensionFilterJson"] });
}
});
export type DailyTaskForm = z.infer<typeof dailyTaskFormSchema>;
@ -52,3 +65,16 @@ function datetimeLocalToMs(value: unknown) {
const parsed = new Date(trimmed).getTime();
return Number.isFinite(parsed) ? parsed : -1;
}
function isJSONObject(value: unknown) {
const trimmed = String(value || "").trim();
if (!trimmed) {
return true;
}
try {
const parsed = JSON.parse(trimmed);
return Boolean(parsed) && !Array.isArray(parsed) && typeof parsed === "object";
} catch {
return false;
}
}

View File

@ -47,6 +47,8 @@ export interface FirstRechargeRewardConfigPayload {
export interface FirstRechargeRewardClaimUserDto {
userId?: number;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
username?: string;
avatar?: string;
}
@ -153,6 +155,8 @@ function normalizeClaim(item: RawClaim): FirstRechargeRewardClaimDto {
user: {
userId: numberValue(rawUser.userId ?? rawUser.user_id),
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
username: stringValue(rawUser.username),
avatar: stringValue(rawUser.avatar),
},

View File

@ -1,9 +1,9 @@
import Avatar from "@mui/material/Avatar";
import { FirstRechargeRewardConfigDrawer } from "@/features/first-recharge-reward/components/FirstRechargeRewardConfigDrawer.jsx";
import { FirstRechargeRewardConfigSummary } from "@/features/first-recharge-reward/components/FirstRechargeRewardConfigSummary.jsx";
import { useFirstRechargeRewardPage } from "@/features/first-recharge-reward/hooks/useFirstRechargeRewardPage.js";
import styles from "@/features/first-recharge-reward/first-recharge-reward.module.css";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
@ -152,21 +152,7 @@ export function FirstRechargeRewardPage() {
function ClaimUser({ claim }) {
const user = claim.user || {};
return (
<div className={styles.identity}>
<Avatar
alt={user.username || String(claim.userId)}
src={user.avatar || ""}
sx={{ width: 36, height: 36 }}
/>
<div className={styles.stack}>
<span className={styles.name}>{user.username || `用户 ${claim.userId}`}</span>
<span className={styles.meta}>
{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`}
</span>
</div>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || claim.userId }} />;
}
function claimStatusLabel(status) {

View File

@ -117,8 +117,79 @@ export interface DiceRobotPage {
serverTimeMs?: number;
}
export interface RoomRpsStakeGiftDto {
giftId: string;
giftIdNumber?: number;
giftName?: string;
giftIconUrl?: string;
giftPriceCoin?: number;
enabled: boolean;
sortOrder: number;
}
export interface RoomRpsConfigDto {
appCode?: string;
gameId: string;
status: string;
challengeTimeoutMs: number;
revealCountdownMs: number;
stakeGifts: RoomRpsStakeGiftDto[];
createdAtMs?: number;
updatedAtMs?: number;
}
export interface RoomRpsPlayerDto {
userId: string;
userIdNumber?: number;
gesture: string;
result: string;
balanceAfter?: number;
joinedAtMs?: number;
}
export interface RoomRpsChallengeDto {
appCode?: string;
challengeId: string;
roomId: string;
regionId?: number;
status: string;
stakeGiftId: string;
stakeGiftIdNumber?: number;
stakeCoin: number;
initiator: RoomRpsPlayerDto;
challenger: RoomRpsPlayerDto;
winnerUserId: string;
winnerUserIdNumber?: number;
settlementStatus: string;
failureReason?: string;
timeoutAtMs?: number;
revealAtMs?: number;
createdAtMs?: number;
matchedAtMs?: number;
settledAtMs?: number;
updatedAtMs?: number;
}
export interface RoomRpsChallengePage {
items: RoomRpsChallengeDto[];
nextCursor?: string;
pageSize?: number;
serverTimeMs?: number;
}
export type DiceConfigPayload = Omit<DiceConfigDto, "appCode" | "createdAtMs" | "updatedAtMs" | "poolBalanceCoin">;
export interface RoomRpsConfigPayload {
status: string;
challengeTimeoutMs: number;
revealCountdownMs: number;
stakeGifts: Array<{
giftId: number;
enabled: boolean;
sortOrder: number;
}>;
}
export interface DicePoolAdjustPayload {
amountCoin: number;
direction: "in" | "out";
@ -146,6 +217,17 @@ export interface GameCatalogQuery {
cursor?: string;
}
export interface RoomRpsChallengeQuery {
roomId?: string;
status?: string;
initiatorUserId?: string;
challengerUserId?: string;
startTimeMs?: number;
endTimeMs?: number;
pageSize?: number;
cursor?: string;
}
export function listGamePlatforms(status = ""): Promise<GamePlatformPage> {
const endpoint = API_ENDPOINTS.listPlatforms;
return apiRequest<GamePlatformPage>(apiEndpointPath(API_OPERATIONS.listPlatforms), {
@ -227,9 +309,12 @@ export function syncGamePlatformCatalog(platformCode: string, payload: GameSyncP
export function listSelfGames(): Promise<{ items: DiceConfigDto[]; serverTimeMs?: number }> {
const endpoint = API_ENDPOINTS.listSelfGames;
return apiRequest<{ items: DiceConfigDto[]; serverTimeMs?: number }>(apiEndpointPath(API_OPERATIONS.listSelfGames), {
method: endpoint.method,
}).then((page) => ({
return apiRequest<{ items: DiceConfigDto[]; serverTimeMs?: number }>(
apiEndpointPath(API_OPERATIONS.listSelfGames),
{
method: endpoint.method,
},
).then((page) => ({
...page,
items: (page.items || []).map(normalizeDiceConfig),
}));
@ -289,6 +374,65 @@ export function deleteDiceRobot(userId: string, gameId = "dice"): Promise<{ dele
});
}
export function getRoomRpsConfig(): Promise<{ config: RoomRpsConfigDto; serverTimeMs?: number }> {
const endpoint = API_ENDPOINTS.getRoomRpsConfig;
return apiRequest<{ config: RoomRpsConfigDto; serverTimeMs?: number }>(
apiEndpointPath(API_OPERATIONS.getRoomRpsConfig),
{ method: endpoint.method },
).then((result) => ({ ...result, config: normalizeRoomRpsConfig(result.config || {}) }));
}
export function updateRoomRpsConfig(
payload: RoomRpsConfigPayload,
): Promise<{ config: RoomRpsConfigDto; serverTimeMs?: number }> {
const endpoint = API_ENDPOINTS.updateRoomRpsConfig;
return apiRequest<{ config: RoomRpsConfigDto; serverTimeMs?: number }, RoomRpsConfigPayload>(
apiEndpointPath(API_OPERATIONS.updateRoomRpsConfig),
{ body: payload, method: endpoint.method },
).then((result) => ({ ...result, config: normalizeRoomRpsConfig(result.config || {}) }));
}
export function listRoomRpsChallenges(query: RoomRpsChallengeQuery = {}): Promise<RoomRpsChallengePage> {
const endpoint = API_ENDPOINTS.listRoomRpsChallenges;
return apiRequest<RoomRpsChallengePage>(apiEndpointPath(API_OPERATIONS.listRoomRpsChallenges), {
method: endpoint.method,
query: query as QueryParams,
}).then((page) => ({
...page,
items: (page.items || []).map(normalizeRoomRpsChallenge),
}));
}
export function getRoomRpsChallenge(
challengeId: string,
): Promise<{ challenge: RoomRpsChallengeDto; serverTimeMs?: number }> {
const endpoint = API_ENDPOINTS.getRoomRpsChallenge;
return apiRequest<{ challenge: RoomRpsChallengeDto; serverTimeMs?: number }>(
apiEndpointPath(API_OPERATIONS.getRoomRpsChallenge, { challenge_id: challengeId }),
{ method: endpoint.method },
).then((result) => ({ ...result, challenge: normalizeRoomRpsChallenge(result.challenge || {}) }));
}
export function retryRoomRpsSettlement(
challengeId: string,
): Promise<{ challenge: RoomRpsChallengeDto; serverTimeMs?: number }> {
const endpoint = API_ENDPOINTS.retryRoomRpsSettlement;
return apiRequest<{ challenge: RoomRpsChallengeDto; serverTimeMs?: number }>(
apiEndpointPath(API_OPERATIONS.retryRoomRpsSettlement, { challenge_id: challengeId }),
{ method: endpoint.method },
).then((result) => ({ ...result, challenge: normalizeRoomRpsChallenge(result.challenge || {}) }));
}
export function expireRoomRpsChallenge(
challengeId: string,
): Promise<{ challenge: RoomRpsChallengeDto; serverTimeMs?: number }> {
const endpoint = API_ENDPOINTS.expireRoomRpsChallenge;
return apiRequest<{ challenge: RoomRpsChallengeDto; serverTimeMs?: number }>(
apiEndpointPath(API_OPERATIONS.expireRoomRpsChallenge, { challenge_id: challengeId }),
{ method: endpoint.method },
).then((result) => ({ ...result, challenge: normalizeRoomRpsChallenge(result.challenge || {}) }));
}
function normalizePlatform(platform: Partial<GamePlatformDto>): GamePlatformDto {
return {
appCode: stringValue(platform.appCode),
@ -372,6 +516,67 @@ function normalizeDiceRobot(robot: Partial<DiceRobotDto>): DiceRobotDto {
};
}
function normalizeRoomRpsConfig(config: Partial<RoomRpsConfigDto>): RoomRpsConfigDto {
return {
appCode: stringValue(config.appCode),
gameId: stringValue(config.gameId || "room_rps"),
status: stringValue(config.status || "active"),
challengeTimeoutMs: numberValue(config.challengeTimeoutMs || 600000),
revealCountdownMs: numberValue(config.revealCountdownMs || 3000),
stakeGifts: Array.isArray(config.stakeGifts) ? config.stakeGifts.map(normalizeRoomRpsStakeGift) : [],
createdAtMs: numberValue(config.createdAtMs),
updatedAtMs: numberValue(config.updatedAtMs),
};
}
function normalizeRoomRpsStakeGift(gift: Partial<RoomRpsStakeGiftDto>): RoomRpsStakeGiftDto {
return {
giftId: stringValue(gift.giftId),
giftIdNumber: numberValue(gift.giftIdNumber),
giftName: stringValue(gift.giftName),
giftIconUrl: stringValue(gift.giftIconUrl),
giftPriceCoin: numberValue(gift.giftPriceCoin),
enabled: gift.enabled !== false,
sortOrder: numberValue(gift.sortOrder),
};
}
function normalizeRoomRpsChallenge(challenge: Partial<RoomRpsChallengeDto>): RoomRpsChallengeDto {
return {
appCode: stringValue(challenge.appCode),
challengeId: stringValue(challenge.challengeId),
roomId: stringValue(challenge.roomId),
regionId: numberValue(challenge.regionId),
status: stringValue(challenge.status || "pending"),
stakeGiftId: stringValue(challenge.stakeGiftId),
stakeGiftIdNumber: numberValue(challenge.stakeGiftIdNumber),
stakeCoin: numberValue(challenge.stakeCoin),
initiator: normalizeRoomRpsPlayer(challenge.initiator || {}),
challenger: normalizeRoomRpsPlayer(challenge.challenger || {}),
winnerUserId: stringValue(challenge.winnerUserId),
winnerUserIdNumber: numberValue(challenge.winnerUserIdNumber),
settlementStatus: stringValue(challenge.settlementStatus),
failureReason: stringValue(challenge.failureReason),
timeoutAtMs: numberValue(challenge.timeoutAtMs),
revealAtMs: numberValue(challenge.revealAtMs),
createdAtMs: numberValue(challenge.createdAtMs),
matchedAtMs: numberValue(challenge.matchedAtMs),
settledAtMs: numberValue(challenge.settledAtMs),
updatedAtMs: numberValue(challenge.updatedAtMs),
};
}
function normalizeRoomRpsPlayer(player: Partial<RoomRpsPlayerDto>): RoomRpsPlayerDto {
return {
userId: stringValue(player.userId),
userIdNumber: numberValue(player.userIdNumber),
gesture: stringValue(player.gesture),
result: stringValue(player.result),
balanceAfter: numberValue(player.balanceAfter),
joinedAtMs: numberValue(player.joinedAtMs),
};
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
}

View File

@ -64,10 +64,29 @@
width: 100%;
}
.giftTierList {
display: flex;
flex-direction: column;
gap: 12px;
}
.giftTierRow {
display: grid;
grid-template-columns: minmax(180px, 1fr) minmax(110px, 0.5fr) minmax(96px, auto);
gap: 12px;
align-items: center;
}
.fullField {
grid-column: 1 / -1;
}
@media (max-width: 720px) {
.giftTierRow {
grid-template-columns: minmax(0, 1fr);
}
}
.platformList {
display: flex;
flex-direction: column;

View File

@ -54,7 +54,7 @@ export function GameRobotPage() {
setItems((current) => (append ? [...current, ...(result.items || [])] : result.items || []));
setNextCursor(result.nextCursor || "");
} catch (err) {
showToast(err.message || "加载游戏机器人失败", "error");
showToast(err.message || "加载全站机器人失败", "error");
} finally {
setLoading(false);
}
@ -86,8 +86,8 @@ export function GameRobotPage() {
async (robot) => {
const ok = await confirm({
confirmText: "删除",
message: `${robot.nickname || robot.userId} 会从游戏机器人列表移除,历史用户资料和对局记录保留。`,
title: "删除游戏机器人",
message: `${robot.nickname || robot.userId} 会从全站机器人列表移除,历史用户资料和对局记录保留。`,
title: "删除全站机器人",
tone: "danger",
});
if (!ok) {
@ -97,9 +97,9 @@ export function GameRobotPage() {
try {
await deleteDiceRobot(robot.userId, robot.gameId || "dice");
await load("", false);
showToast("游戏机器人已删除", "success");
showToast("全站机器人已删除", "success");
} catch (err) {
showToast(err.message || "删除游戏机器人失败", "error");
showToast(err.message || "删除全站机器人失败", "error");
} finally {
setLoadingAction("");
}
@ -157,7 +157,9 @@ export function GameRobotPage() {
render: (robot) => (
<AdminRowActions>
<AdminActionIconButton
disabled={!canDelete || loadingAction === `delete-${robot.userId}` || Boolean(loadingAction)}
disabled={
!canDelete || loadingAction === `delete-${robot.userId}` || Boolean(loadingAction)
}
label="删除"
tone="danger"
onClick={() => removeRobot(robot)}
@ -183,7 +185,7 @@ export function GameRobotPage() {
showToast(`已批量创建 ${result.created || 0} 个机器人`, "success");
setDialogOpen(false);
} catch (err) {
showToast(err.message || "批量创建机器人失败", "error");
showToast(err.message || "批量创建全站机器人失败", "error");
} finally {
setLoadingAction("");
}
@ -201,14 +203,23 @@ export function GameRobotPage() {
<AdminActionIconButton label="刷新" onClick={() => load("", false)}>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton disabled={!canCreate} label="批量创建" primary onClick={() => setDialogOpen(true)}>
<AdminActionIconButton
disabled={!canCreate}
label="批量创建"
primary
onClick={() => setDialogOpen(true)}
>
<AddOutlined fontSize="small" />
</AdminActionIconButton>
</>
}
filters={
<>
<AdminFilterSelect label="状态" value={status} onChange={(event) => setStatus(event.target.value)}>
<AdminFilterSelect
label="状态"
value={status}
onChange={(event) => setStatus(event.target.value)}
>
<MenuItem value="">全部状态</MenuItem>
<MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">停用</MenuItem>
@ -244,7 +255,14 @@ export function GameRobotPage() {
function GenerateDialog({ form, loading, open, setForm, onClose, onSubmit }) {
return (
<AdminFormDialog loading={loading} open={open} submitLabel="创建" title="批量创建游戏机器人" onClose={onClose} onSubmit={onSubmit}>
<AdminFormDialog
loading={loading}
open={open}
submitLabel="创建"
title="批量创建全站机器人"
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormFieldGrid>
<TextField
helperText="最多 200 个"

View File

@ -0,0 +1,357 @@
import BlockOutlined from "@mui/icons-material/BlockOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import ReplayOutlined from "@mui/icons-material/ReplayOutlined";
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
import TextField from "@mui/material/TextField";
import { useCallback, useEffect, useState } from "react";
import { PERMISSIONS } from "@/app/permissions";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import {
expireRoomRpsChallenge,
getRoomRpsChallenge,
listRoomRpsChallenges,
retryRoomRpsSettlement,
} from "@/features/games/api";
import styles from "@/features/games/games.module.css";
import {
AdminActionIconButton,
AdminFilterResetButton,
AdminFilterSelect,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormReadOnlyField,
AdminFormSection,
} from "@/shared/ui/AdminFormDialog.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { formatMillis } from "@/shared/utils/time.js";
const statusOptions = [
["", "全部状态"],
["pending", "待应战"],
["matched", "已应战"],
["revealing", "揭晓中"],
["finished", "已完成"],
["timeout", "已超时"],
["settlement_failed", "结算失败"],
["cancelled", "已取消"],
];
const emptyFilters = {
challengerUserId: "",
initiatorUserId: "",
roomId: "",
status: "",
};
export function RoomRpsChallengePage() {
const { can } = useAuth();
const { showToast } = useToast();
const canUpdate = can(PERMISSIONS.gameUpdate);
const [items, setItems] = useState([]);
const [filters, setFilters] = useState(emptyFilters);
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const [cursor, setCursor] = useState("");
const [nextCursor, setNextCursor] = useState("");
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState("");
const [detail, setDetail] = useState(null);
const [detailOpen, setDetailOpen] = useState(false);
const load = useCallback(
async (nextCursorValue = "") => {
setLoading(true);
try {
const result = await listRoomRpsChallenges({
challengerUserId: filters.challengerUserId || undefined,
cursor: nextCursorValue,
endTimeMs: timeRange.endMs || undefined,
initiatorUserId: filters.initiatorUserId || undefined,
pageSize: 50,
roomId: filters.roomId || undefined,
startTimeMs: timeRange.startMs || undefined,
status: filters.status || undefined,
});
setItems(result.items || []);
setCursor(nextCursorValue);
setNextCursor(result.nextCursor || "");
} catch (err) {
showToast(err.message || "加载房内猜拳订单失败", "error");
} finally {
setLoading(false);
}
},
[filters, showToast, timeRange],
);
useEffect(() => {
load("");
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const columns = [
{
key: "challenge",
label: "挑战单",
width: "minmax(220px, 1.3fr)",
render: (item) => (
<div className={styles.identityText}>
<span className={styles.name}>{item.challengeId}</span>
<span className={styles.meta}>{item.roomId || "-"}</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "120px",
render: (item) => statusText(item.status),
},
{
key: "stake",
label: "下注",
width: "minmax(150px, .9fr)",
render: (item) => `${item.stakeGiftId || "-"} / ${formatNumber(item.stakeCoin)}`,
},
{
key: "users",
label: "双方",
width: "minmax(220px, 1.2fr)",
render: (item) => `${playerText(item.initiator)} / ${playerText(item.challenger)}`,
},
{
key: "settlement",
label: "结算",
width: "minmax(160px, .9fr)",
render: (item) => item.settlementStatus || "-",
},
{
key: "created",
label: "创建时间",
width: "minmax(170px, 1fr)",
render: (item) => (item.createdAtMs ? formatMillis(item.createdAtMs) : "-"),
},
{
key: "actions",
label: "操作",
width: "132px",
render: (item) => (
<AdminRowActions>
<AdminActionIconButton label="详情" onClick={() => openDetail(item.challengeId)}>
<VisibilityOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={
!canUpdate || item.status !== "settlement_failed" || actionLoading === item.challengeId
}
label="重试结算"
onClick={() => retrySettlement(item.challengeId)}
>
<ReplayOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!canUpdate || item.status !== "pending" || actionLoading === item.challengeId}
label="手动过期"
onClick={() => expireChallenge(item.challengeId)}
>
<BlockOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
),
},
];
const updateFilter = (patch) => setFilters((current) => ({ ...current, ...patch }));
const resetFilters = () => {
setFilters(emptyFilters);
setTimeRange({ endMs: "", startMs: "" });
};
const openDetail = async (challengeId) => {
setActionLoading(challengeId);
try {
const result = await getRoomRpsChallenge(challengeId);
setDetail(result.challenge);
setDetailOpen(true);
} catch (err) {
showToast(err.message || "获取房内猜拳订单详情失败", "error");
} finally {
setActionLoading("");
}
};
const retrySettlement = async (challengeId) => {
setActionLoading(challengeId);
try {
const result = await retryRoomRpsSettlement(challengeId);
patchChallenge(result.challenge);
showToast("结算重试已提交", "success");
} catch (err) {
showToast(err.message || "重试结算失败", "error");
} finally {
setActionLoading("");
}
};
const expireChallenge = async (challengeId) => {
setActionLoading(challengeId);
try {
const result = await expireRoomRpsChallenge(challengeId);
patchChallenge(result.challenge);
showToast("挑战单已过期", "success");
} catch (err) {
showToast(err.message || "手动过期失败", "error");
} finally {
setActionLoading("");
}
};
const patchChallenge = (challenge) => {
setItems((current) => current.map((item) => (item.challengeId === challenge.challengeId ? challenge : item)));
if (detail?.challengeId === challenge.challengeId) {
setDetail(challenge);
}
};
if (loading) {
return <PageSkeleton />;
}
return (
<AdminListPage>
<AdminListToolbar
actions={
<>
<AdminActionIconButton label="查询" onClick={() => load("")}>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
<AdminFilterResetButton onClick={resetFilters} />
</>
}
filters={
<>
<TextField
label="房间 ID"
size="small"
value={filters.roomId}
onChange={(event) => updateFilter({ roomId: event.target.value })}
/>
<AdminFilterSelect
options={statusOptions}
value={filters.status}
onChange={(value) => updateFilter({ status: value })}
/>
<TextField
label="发起人"
size="small"
value={filters.initiatorUserId}
onChange={(event) => updateFilter({ initiatorUserId: event.target.value })}
/>
<TextField
label="应战人"
size="small"
value={filters.challengerUserId}
onChange={(event) => updateFilter({ challengerUserId: event.target.value })}
/>
<TimeRangeFilter value={timeRange} onChange={setTimeRange} />
</>
}
/>
<AdminListBody>
<DataTable columns={columns} items={items} minWidth="1120px" rowKey={(item) => item.challengeId} />
</AdminListBody>
<AdminListToolbar
actions={
nextCursor || cursor ? (
<>
<AdminActionIconButton disabled={!cursor} label="上一页" onClick={() => load("")}>
<ReplayOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton
disabled={!nextCursor}
label="下一页"
onClick={() => load(nextCursor)}
>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
</>
) : null
}
/>
<RoomRpsChallengeDetail challenge={detail} open={detailOpen} onClose={() => setDetailOpen(false)} />
</AdminListPage>
);
}
function RoomRpsChallengeDetail({ challenge, open, onClose }) {
const closeBySubmit = (event) => {
event.preventDefault();
onClose();
};
return (
<AdminFormDialog
open={open}
submitLabel="关闭"
title="房内猜拳订单详情"
onClose={onClose}
onSubmit={closeBySubmit}
>
<AdminFormSection title="订单">
<AdminFormFieldGrid>
<AdminFormReadOnlyField label="挑战单 ID" value={challenge?.challengeId} />
<AdminFormReadOnlyField label="房间 ID" value={challenge?.roomId} />
<AdminFormReadOnlyField label="状态" value={statusText(challenge?.status)} />
<AdminFormReadOnlyField label="结算状态" value={challenge?.settlementStatus} />
<AdminFormReadOnlyField label="失败原因" value={challenge?.failureReason} />
<AdminFormReadOnlyField label="胜者" value={challenge?.winnerUserId} />
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="玩家">
<AdminFormFieldGrid>
<AdminFormReadOnlyField label="发起人" value={detailPlayerText(challenge?.initiator)} />
<AdminFormReadOnlyField label="应战人" value={detailPlayerText(challenge?.challenger)} />
<AdminFormReadOnlyField label="礼物 ID" value={challenge?.stakeGiftId} />
<AdminFormReadOnlyField label="金币" value={formatNumber(challenge?.stakeCoin)} />
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="时间">
<AdminFormFieldGrid>
<AdminFormReadOnlyField label="创建" value={timeText(challenge?.createdAtMs)} />
<AdminFormReadOnlyField label="超时" value={timeText(challenge?.timeoutAtMs)} />
<AdminFormReadOnlyField label="揭晓" value={timeText(challenge?.revealAtMs)} />
<AdminFormReadOnlyField label="结算" value={timeText(challenge?.settledAtMs)} />
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function playerText(player) {
if (!player?.userId) return "-";
return `${player.userId}:${player.gesture || "-"}`;
}
function detailPlayerText(player) {
if (!player?.userId) return "-";
return `${player.userId} / ${player.gesture || "-"} / ${player.result || "-"}`;
}
function statusText(status) {
return Object.fromEntries(statusOptions)[status] || status || "-";
}
function timeText(ms) {
return ms ? formatMillis(ms) : "-";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("en-US");
}

View File

@ -0,0 +1,298 @@
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { useCallback, useEffect, useMemo, useState } from "react";
import { PERMISSIONS } from "@/app/permissions";
import { useAuth } from "@/app/auth/AuthProvider.jsx";
import { getRoomRpsConfig, updateRoomRpsConfig } from "@/features/games/api";
import styles from "@/features/games/games.module.css";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { PageSkeleton } from "@/shared/ui/PageSkeleton.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { formatMillis } from "@/shared/utils/time.js";
const defaultStakeGifts = [1, 2, 3, 4].map((sortOrder) => ({
enabled: true,
giftId: "",
sortOrder,
}));
const defaultForm = {
status: "active",
challengeTimeoutMs: 600000,
revealCountdownMs: 3000,
stakeGifts: defaultStakeGifts,
};
export function RoomRpsConfigPage() {
const { can } = useAuth();
const { showToast } = useToast();
const canUpdate = can(PERMISSIONS.gameUpdate);
const [config, setConfig] = useState(null);
const [form, setForm] = useState(defaultForm);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const result = await getRoomRpsConfig();
setConfig(result.config);
} catch (err) {
showToast(err.message || "加载房内猜拳配置失败", "error");
} finally {
setLoading(false);
}
}, [showToast]);
useEffect(() => {
load();
}, [load]);
const rows = useMemo(() => (config ? [config] : []), [config]);
const columns = useMemo(
() => [
{
key: "game",
label: "玩法",
width: "minmax(180px, 1fr)",
render: (item) => (
<div className={styles.identityText}>
<span className={styles.name}>房内猜拳</span>
<span className={styles.meta}>{item.gameId || "room_rps"}</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "120px",
render: (item) => statusText(item.status),
},
{
key: "timeout",
label: "时长",
width: "minmax(180px, 1fr)",
render: (item) =>
`${formatSeconds(item.challengeTimeoutMs)} / ${formatSeconds(item.revealCountdownMs)}`,
},
{
key: "gifts",
label: "礼物档位",
width: "minmax(260px, 1.5fr)",
render: (item) =>
(item.stakeGifts || [])
.map(
(gift) =>
`${gift.sortOrder}:${gift.giftName || gift.giftId}${gift.enabled ? "" : "(停用)"}`,
)
.join(" / "),
},
{
key: "updated",
label: "更新时间",
width: "minmax(170px, 1fr)",
render: (item) => (item.updatedAtMs ? formatMillis(item.updatedAtMs) : "-"),
},
{
key: "actions",
label: "操作",
width: "88px",
render: (item) => (
<AdminRowActions>
<AdminActionIconButton disabled={!canUpdate} label="编辑" onClick={() => openEditor(item)}>
<SettingsOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
),
},
],
[canUpdate],
);
const openEditor = (item) => {
setForm(configToForm(item));
setEditing(true);
};
const submit = async (event) => {
event.preventDefault();
const payload = formToPayload(form);
if (payload.stakeGifts.length !== 4 || payload.stakeGifts.some((gift) => !gift.giftId)) {
showToast("房内猜拳必须配置 4 个礼物档位", "error");
return;
}
setSaving(true);
try {
const result = await updateRoomRpsConfig(payload);
setConfig(result.config);
setEditing(false);
showToast("房内猜拳配置已保存", "success");
} catch (err) {
showToast(err.message || "保存房内猜拳配置失败", "error");
} finally {
setSaving(false);
}
};
if (loading) {
return <PageSkeleton />;
}
return (
<AdminListPage>
<AdminListToolbar
actions={
<AdminActionIconButton label="刷新" onClick={load}>
<RefreshOutlined fontSize="small" />
</AdminActionIconButton>
}
/>
<AdminListBody>
<DataTable
columns={columns}
items={rows}
minWidth="980px"
rowKey={(item) => item.gameId || "room_rps"}
/>
</AdminListBody>
<RoomRpsConfigDialog
form={form}
loading={saving}
open={editing}
setForm={setForm}
onClose={() => setEditing(false)}
onSubmit={submit}
/>
</AdminListPage>
);
}
function RoomRpsConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) {
return (
<AdminFormDialog
loading={loading}
open={open}
submitLabel="保存配置"
title="房内猜拳配置"
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="基础">
<AdminFormFieldGrid>
<TextField
label="状态"
select
value={form.status}
onChange={(event) => setForm({ ...form, status: event.target.value })}
>
<MenuItem value="active">启用</MenuItem>
<MenuItem value="disabled">停用</MenuItem>
</TextField>
<TextField
label="挑战超时 ms"
type="number"
value={form.challengeTimeoutMs}
onChange={(event) => setForm({ ...form, challengeTimeoutMs: event.target.value })}
/>
<TextField
label="揭晓倒计时 ms"
type="number"
value={form.revealCountdownMs}
onChange={(event) => setForm({ ...form, revealCountdownMs: event.target.value })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="礼物档位">
<div className={styles.giftTierList}>
{form.stakeGifts.map((gift, index) => (
<div className={styles.giftTierRow} key={gift.sortOrder || index}>
<TextField
label={`${index + 1} 档礼物 ID`}
type="number"
value={gift.giftId}
onChange={(event) => setGiftTier(setForm, form, index, { giftId: event.target.value })}
/>
<TextField
label="排序"
type="number"
value={gift.sortOrder}
onChange={(event) =>
setGiftTier(setForm, form, index, { sortOrder: event.target.value })
}
/>
<AdminFormSwitchField
checked={gift.enabled}
label="启用"
onChange={(checked) => setGiftTier(setForm, form, index, { enabled: checked })}
/>
</div>
))}
</div>
</AdminFormSection>
</AdminFormDialog>
);
}
function setGiftTier(setForm, form, index, patch) {
const stakeGifts = form.stakeGifts.map((gift, giftIndex) => (giftIndex === index ? { ...gift, ...patch } : gift));
setForm({ ...form, stakeGifts });
}
function configToForm(config) {
const stakeGifts = [...(config.stakeGifts || [])]
.sort((left, right) => Number(left.sortOrder || 0) - Number(right.sortOrder || 0))
.slice(0, 4)
.map((gift, index) => ({
enabled: gift.enabled !== false,
giftId: gift.giftId || "",
sortOrder: gift.sortOrder || index + 1,
}));
while (stakeGifts.length < 4) {
stakeGifts.push({ ...defaultStakeGifts[stakeGifts.length] });
}
return {
status: config.status || "active",
challengeTimeoutMs: config.challengeTimeoutMs || 600000,
revealCountdownMs: config.revealCountdownMs || 3000,
stakeGifts,
};
}
function formToPayload(form) {
return {
status: form.status || "active",
challengeTimeoutMs: Number(form.challengeTimeoutMs || 600000),
revealCountdownMs: Number(form.revealCountdownMs || 3000),
stakeGifts: form.stakeGifts.map((gift, index) => ({
enabled: gift.enabled !== false,
giftId: Number(gift.giftId || 0),
sortOrder: Number(gift.sortOrder || index + 1),
})),
};
}
function statusText(status) {
return status === "disabled" ? "停用" : "启用";
}
function formatSeconds(ms) {
const value = Number(ms || 0);
if (!value) return "-";
return `${Math.round(value / 1000)}s`;
}

View File

@ -18,11 +18,27 @@ export const gameRoutes = [
permission: PERMISSIONS.gameView,
},
{
label: "游戏机器人",
label: "全站机器人",
loader: () => import("./pages/GameRobotPage.jsx").then((module) => module.GameRobotPage),
menuCode: MENU_CODES.gameRobots,
pageKey: "game-robots",
path: "/games/robots",
permission: PERMISSIONS.gameView,
},
{
label: "房内猜拳配置",
loader: () => import("./pages/RoomRpsConfigPage.jsx").then((module) => module.RoomRpsConfigPage),
menuCode: MENU_CODES.roomRpsConfig,
pageKey: "room-rps-config",
path: "/games/room-rps/config",
permission: PERMISSIONS.gameView,
},
{
label: "房内猜拳订单",
loader: () => import("./pages/RoomRpsChallengePage.jsx").then((module) => module.RoomRpsChallengePage),
menuCode: MENU_CODES.roomRpsChallenges,
pageKey: "room-rps-challenges",
path: "/games/room-rps/challenges",
permission: PERMISSIONS.gameView,
},
];

View File

@ -210,6 +210,8 @@ export function deleteTeamSalaryPolicy(policyType: string, policyId: EntityId):
export interface HostSalarySettlementUserDto {
userId: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
username?: string;
avatar?: string;
}
@ -415,6 +417,8 @@ function normalizeSettlementUser(raw: Record<string, unknown>): HostSalarySettle
return {
userId: stringValue(raw.userId ?? raw.user_id),
displayUserId: stringValue(raw.displayUserId ?? raw.display_user_id),
prettyDisplayUserId: stringValue(raw.prettyDisplayUserId ?? raw.pretty_display_user_id),
prettyId: stringValue(raw.prettyId ?? raw.pretty_id),
username: stringValue(raw.username),
avatar: stringValue(raw.avatar),
};

View File

@ -1,5 +1,4 @@
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import Checkbox from "@mui/material/Checkbox";
import Tab from "@mui/material/Tab";
import Tabs from "@mui/material/Tabs";
@ -18,6 +17,7 @@ import {
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
@ -49,20 +49,14 @@ const policyTypeOptions = [
["admin", "Admin"],
];
const policyTypeAllOptions = [
["", "全部角色"],
...policyTypeOptions,
];
const policyTypeAllOptions = [["", "全部角色"], ...policyTypeOptions];
const triggerOptions = [
["automatic", "自动政策"],
["manual", "手动政策"],
];
const triggerAllOptions = [
["", "全部触发"],
...triggerOptions,
];
const triggerAllOptions = [["", "全部触发"], ...triggerOptions];
export function HostSalarySettlementPage() {
const [activeTab, setActiveTab] = useState("team-pending");
@ -184,13 +178,7 @@ function HostRecordsPanel() {
return column;
});
const hasFilters =
agencyOwnerUserId ||
cycleKey ||
settlementType ||
status ||
timeRange.startMs ||
timeRange.endMs ||
userId;
agencyOwnerUserId || cycleKey || settlementType || status || timeRange.startMs || timeRange.endMs || userId;
return (
<>
@ -360,7 +348,8 @@ function TeamPendingPanel() {
...teamPendingColumns,
];
// hasFiltersBD + +
const hasFilters = policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId;
const hasFilters =
policyType !== "bd" || triggerMode !== "automatic" || cycleKey !== defaultCycleKey() || countryCode || regionId;
return (
<>
@ -377,11 +366,36 @@ function TeamPendingPanel() {
}
filters={
<div className={styles.toolbarFilters}>
<AdminFilterSelect label="角色" options={policyTypeOptions} value={policyType} onChange={changeFilter(setPolicyType)} />
<AdminFilterSelect label="政策触发" options={triggerOptions} value={triggerMode} onChange={changeFilter(setTriggerMode)} />
<InlineFilter label="周期" placeholder="YYYY-MM" value={cycleKey} onChange={changeFilter(setCycleKey)} />
<InlineFilter label="国家" placeholder="国家代码" value={countryCode} onChange={changeFilter(setCountryCode)} />
<InlineFilter label="区域" placeholder="区域 ID" value={regionId} onChange={changeFilter(setRegionId)} />
<AdminFilterSelect
label="角色"
options={policyTypeOptions}
value={policyType}
onChange={changeFilter(setPolicyType)}
/>
<AdminFilterSelect
label="政策触发"
options={triggerOptions}
value={triggerMode}
onChange={changeFilter(setTriggerMode)}
/>
<InlineFilter
label="周期"
placeholder="YYYY-MM"
value={cycleKey}
onChange={changeFilter(setCycleKey)}
/>
<InlineFilter
label="国家"
placeholder="国家代码"
value={countryCode}
onChange={changeFilter(setCountryCode)}
/>
<InlineFilter
label="区域"
placeholder="区域 ID"
value={regionId}
onChange={changeFilter(setRegionId)}
/>
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
</div>
}
@ -502,10 +516,31 @@ function TeamRecordsPanel() {
<AdminListToolbar
filters={
<div className={styles.toolbarFilters}>
<TimeRangeFilter value={timeRange} onChange={(value) => { setTimeRange(value); setPage(1); }} />
<AdminFilterSelect label="角色" options={policyTypeAllOptions} value={policyType} onChange={changeFilter(setPolicyType)} />
<AdminFilterSelect label="触发" options={triggerAllOptions} value={triggerMode} onChange={changeFilter(setTriggerMode)} />
<InlineFilter label="国家" placeholder="国家代码" value={countryCode} onChange={changeFilter(setCountryCode)} />
<TimeRangeFilter
value={timeRange}
onChange={(value) => {
setTimeRange(value);
setPage(1);
}}
/>
<AdminFilterSelect
label="角色"
options={policyTypeAllOptions}
value={policyType}
onChange={changeFilter(setPolicyType)}
/>
<AdminFilterSelect
label="触发"
options={triggerAllOptions}
value={triggerMode}
onChange={changeFilter(setTriggerMode)}
/>
<InlineFilter
label="国家"
placeholder="国家代码"
value={countryCode}
onChange={changeFilter(setCountryCode)}
/>
<AdminFilterResetButton disabled={!hasFilters} onClick={resetFilters} />
</div>
}
@ -591,7 +626,9 @@ const teamPendingColumns = [
width: "minmax(180px, 0.8fr)",
render: (item) => (
<div className={styles.stack}>
<span>{roleLabel(item.policyType)} · {item.cycleKey || "-"}</span>
<span>
{roleLabel(item.policyType)} · {item.cycleKey || "-"}
</span>
<span className={styles.meta}>{item.regionName || `区域 ${item.regionId || "-"}`}</span>
</div>
),
@ -652,7 +689,9 @@ const teamRecordColumns = [
render: (item) => (
<div className={styles.stack}>
<span>{item.cycleKey || "-"}</span>
<span className={styles.meta}>{roleLabel(item.policyType)} · {triggerLabel(item.triggerMode)}</span>
<span className={styles.meta}>
{roleLabel(item.policyType)} · {triggerLabel(item.triggerMode)}
</span>
</div>
),
},
@ -704,17 +743,7 @@ function UserCell({ fallbackId, user }) {
if (!idText) {
return "-";
}
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user?.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<span className={styles.primaryText}>{user?.username || "-"}</span>
<span className={styles.meta}>{idText}</span>
</div>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...(user || {}), userId: user?.userId || fallbackId }} />;
}
// CycleCell Host/Agency
@ -820,7 +849,9 @@ function settlementTypeLabel(value) {
daily: "日结",
half_month: "半月结算",
month_end: "月底清算",
}[value] || value || "-"
}[value] ||
value ||
"-"
);
}
@ -831,7 +862,9 @@ function statusLabel(value) {
failed: "失败",
skipped: "跳过",
succeeded: "成功",
}[value] || value || "-"
}[value] ||
value ||
"-"
);
}

View File

@ -1,25 +1,17 @@
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import styles from "@/features/host-org/host-org.module.css";
export function HostOrgPerson({ avatar, displayUserId, fallbackId, username }) {
const name = username || "-";
const meta = displayUserId || fallbackId || "-";
export function HostOrgPerson({ avatar, displayUserId, fallbackId, prettyDisplayUserId, prettyId, username }) {
return (
<div className={styles.sellerIdentity}>
{avatar ? (
<img alt="" className={styles.sellerAvatar} src={avatar} />
) : (
<span className={styles.sellerAvatar}>
{String(name || meta)
.slice(0, 1)
.toUpperCase()}
</span>
)}
<span className={styles.sellerText}>
<span className={styles.sellerName}>{name}</span>
<span className={styles.sellerMeta}>{meta}</span>
</span>
</div>
<AdminUserIdentity
avatar={avatar}
displayUserId={displayUserId}
name={username}
openInAppUserDetail
prettyDisplayUserId={prettyDisplayUserId}
prettyId={prettyId}
userId={fallbackId}
/>
);
}

View File

@ -7,6 +7,7 @@ import TextField from "@mui/material/TextField";
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
import { HostOrgStatus } from "@/features/host-org/components/HostOrgStatus.jsx";
@ -211,25 +212,7 @@ export function HostBdLeadersPage() {
}
function PersonIdentity({ item }) {
const name = item.username || "-";
const meta = item.displayUserId || item.userId || "-";
return (
<div className={styles.sellerIdentity}>
{item.avatar ? (
<img alt="" className={styles.sellerAvatar} src={item.avatar} />
) : (
<span className={styles.sellerAvatar}>
{String(name || meta)
.slice(0, 1)
.toUpperCase()}
</span>
)}
<span className={styles.sellerText}>
<span className={styles.sellerName}>{name}</span>
<span className={styles.sellerMeta}>{meta}</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={item} />;
}
function CreatorIdentity({ item }) {

View File

@ -19,6 +19,7 @@ import {
import { formatMillis } from "@/shared/utils/time.js";
import { DataState } from "@/shared/ui/DataState.jsx";
import { AdminActionIconButton, AdminRowActions } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { InlineEditableCell } from "@/shared/ui/InlineEditableCell.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { coinSellerStatusFilters } from "@/features/host-org/constants.js";
@ -157,10 +158,7 @@ export function HostCoinSellersPage() {
return (
<section className={styles.root}>
<div className={styles.contentPanel}>
<HostOrgToolbar
actions={toolbarActions}
leadingActions={leadingActions}
/>
<HostOrgToolbar actions={toolbarActions} leadingActions={leadingActions} />
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<div className={styles.listBlock}>
@ -314,7 +312,10 @@ export function HostCoinSellersPage() {
onClose={page.closeAction}
>
{page.selectedSeller ? (
<CoinSellerLedgerTable lockedSeller={page.selectedSeller} sellerUserId={page.selectedSeller.userId} />
<CoinSellerLedgerTable
lockedSeller={page.selectedSeller}
sellerUserId={page.selectedSeller.userId}
/>
) : null}
</SideDrawer>
@ -377,9 +378,7 @@ export function HostCoinSellersPage() {
label="coin per USD"
required
value={tier.coinPerUsd}
onChange={(event) =>
page.updateRateTier(index, { coinPerUsd: event.target.value })
}
onChange={(event) => page.updateRateTier(index, { coinPerUsd: event.target.value })}
/>
<div className={styles.rateTierSwitch}>
<AdminSwitch
@ -387,7 +386,9 @@ export function HostCoinSellersPage() {
checkedLabel="启用"
label="状态"
uncheckedLabel="停用"
onChange={(event) => page.updateRateTier(index, { enabled: event.target.checked })}
onChange={(event) =>
page.updateRateTier(index, { enabled: event.target.checked })
}
/>
</div>
<AdminActionIconButton
@ -407,22 +408,7 @@ export function HostCoinSellersPage() {
}
function SellerIdentity({ item }) {
const name = item.username || "-";
const meta = item.displayUserId || item.userId;
return (
<div className={styles.sellerIdentity}>
{item.avatar ? (
<img alt="" className={styles.sellerAvatar} src={item.avatar} />
) : (
<span className={styles.sellerAvatar}>{name.slice(0, 1).toUpperCase()}</span>
)}
<span className={styles.sellerText}>
<span className={styles.sellerName}>{name}</span>
<span className={styles.sellerMeta}>{meta}</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={item} />;
}
function SellerStatusSwitch({ item, page }) {
@ -460,7 +446,11 @@ function SellerActions({ item, page }) {
return (
<AdminRowActions>
{page.abilities.canLedger ? (
<AdminActionIconButton label="币商流水" sx={coinLedgerActionSx} onClick={() => page.openSellerLedger(item)}>
<AdminActionIconButton
label="币商流水"
sx={coinLedgerActionSx}
onClick={() => page.openSellerLedger(item)}
>
<ReceiptLongOutlined fontSize="small" />
</AdminActionIconButton>
) : null}

View File

@ -1,18 +1,14 @@
import FileDownloadOutlined from "@mui/icons-material/FileDownloadOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useMemo, useState } from "react";
import { exportCoinSellerLedger, listCoinSellerLedger } from "@/features/operations/api";
import styles from "@/features/operations/operations.module.css";
import { downloadCsv } from "@/shared/api/download";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import {
AdminFilterResetButton,
AdminListBody,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminFilterResetButton, AdminListBody, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
@ -182,14 +178,15 @@ export function CoinSellerLedgerTable({ lockedSeller = null, sellerUserId = "" }
filters={
<>
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
<AdminFilterResetButton
disabled={!hasActiveFilters}
onClick={resetFilters}
/>
<AdminFilterResetButton disabled={!hasActiveFilters} onClick={resetFilters} />
</>
}
actions={
<Button disabled={exporting} startIcon={<FileDownloadOutlined fontSize="small" />} onClick={downloadLedger}>
<Button
disabled={exporting}
startIcon={<FileDownloadOutlined fontSize="small" />}
onClick={downloadLedger}
>
{exporting ? "导出中" : "导出"}
</Button>
}
@ -235,18 +232,7 @@ function OperatorCell({ entry }) {
}
function UserCell({ user = {} }) {
const name = user.username || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{name}</span>
<span className={styles.meta}>{userIdText(user)}</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={user} />;
}
function AmountValue({ entry }) {
@ -261,16 +247,9 @@ function AmountValue({ entry }) {
}
function ledgerTypeLabel(entry) {
return ledgerTypeLabels[entry.ledgerType] || bizTypeLabels[entry.bizType] || entry.ledgerType || entry.bizType || "-";
}
function userIdText(user = {}) {
const displayUserId = String(user.displayUserId || "").trim();
const userId = String(user.userId || "").trim();
if (displayUserId && userId && displayUserId !== userId) {
return `${displayUserId} / ${userId}`;
}
return displayUserId || userId || "-";
return (
ledgerTypeLabels[entry.ledgerType] || bizTypeLabels[entry.bizType] || entry.ledgerType || entry.bizType || "-"
);
}
function formatNumber(value) {

View File

@ -1,5 +1,4 @@
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import SearchOutlined from "@mui/icons-material/SearchOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
@ -24,6 +23,7 @@ import {
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useToast } from "@/shared/ui/ToastProvider.jsx";
import { createTextColumnFilter } from "@/shared/ui/tableFilters.js";
@ -320,33 +320,11 @@ export function CoinAdjustmentPage() {
function AdjustmentUserCell({ item }) {
const user = item.user || {};
const name = user.username || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{name}</span>
<span className={styles.meta}>{userIdText(user, item.userId)}</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || item.userId }} />;
}
function TargetUserPreview({ user }) {
return (
<div className={styles.targetPreview}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{user.username || "-"}</span>
<span className={styles.meta}>{userIdText(user, user.userId)}</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail className={styles.targetPreview} user={user} />;
}
function AdjustmentAmount({ item }) {
@ -388,11 +366,6 @@ function buildAdjustmentPayload(form, targetUser) {
};
}
function userIdText(user, fallbackUserId) {
const ids = [user.displayUserId, user.userId || fallbackUserId].filter(Boolean);
return ids.length ? ids.join(" / ") : "-";
}
function operatorName(item) {
const operator = item.operator || {};
return operator.name || operator.username || item.operatorUserId || "-";

View File

@ -1,4 +1,3 @@
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useMemo, useState } from "react";
import { listCoinLedger } from "@/features/operations/api";
import {
@ -7,6 +6,7 @@ import {
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
@ -232,16 +232,12 @@ function CoinLedgerDetailDrawer({ entry, onClose, open }) {
{entry ? (
<>
<header className={styles.detailHero}>
<span className={styles.detailAvatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="medium" />}
</span>
<div className={styles.detailHeroText}>
<div className={styles.detailTitleRow}>
<h3>{user.username || "-"}</h3>
<AmountValue entry={entry} />
</div>
<span className={styles.detailMeta}>{userIdText(entry)}</span>
</div>
<AdminUserIdentity
openInAppUserDetail
user={{ ...user, userId: user.userId || entry.userId }}
size="large"
/>
<AmountValue entry={entry} />
</header>
<DetailSection
@ -276,17 +272,7 @@ function CoinLedgerDetailDrawer({ entry, onClose, open }) {
function UserCell({ entry }) {
const user = entry.user || {};
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<span className={styles.primaryText}>{user.username || "-"}</span>
<span className={styles.meta}>{userIdText(entry)}</span>
</div>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || entry.userId }} />;
}
function AmountValue({ entry }) {
@ -366,7 +352,12 @@ function businessContextRows(entry, metadata) {
}
return compactRows([
metadataRow("关联用户", metadata, ["target_user_id", "targetUserId", "seller_user_id", "sellerUserId"], entry.counterpartyUserId),
metadataRow(
"关联用户",
metadata,
["target_user_id", "targetUserId", "seller_user_id", "sellerUserId"],
entry.counterpartyUserId,
),
metadataRow("房间 ID", metadata, ["room_id", "roomId"], entry.roomId),
metadataRow("任务 ID", metadata, ["task_id", "taskId"]),
metadataRow("任务类型", metadata, ["task_type", "taskType"]),
@ -440,16 +431,6 @@ function displayDetailValue(value) {
return String(value);
}
function userIdText(entry) {
const user = entry.user || {};
const longId = user.userId || entry.userId || "";
const shortId = user.displayUserId || "";
if (longId && shortId && longId !== shortId) {
return `${longId}(${shortId})`;
}
return longId || shortId || "-";
}
function hasDetailValue(value) {
return value === 0 || value === false || Boolean(value);
}

View File

@ -10,6 +10,7 @@ import {
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
@ -219,19 +220,7 @@ function TargetCell({ report }) {
);
}
const user = target.user || {};
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{user.username || "-"}</span>
<span className={styles.meta}>
{targetMeta(user.displayUserId || user.defaultDisplayUserId, user.userId || report.userId)}
</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || report.userId }} />;
}
function EvidenceImages({ imageUrls = [] }) {

View File

@ -1,14 +1,10 @@
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
import DownloadOutlined from "@mui/icons-material/DownloadOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import Tooltip from "@mui/material/Tooltip";
import { useEffect, useMemo, useState } from "react";
import { listRechargeBills } from "@/features/payment/api";
import {
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
@ -70,10 +66,7 @@ const columns = [
label: "国家/区域",
width: "minmax(150px, 0.75fr)",
render: (bill, _index, context) => (
<Stack
primary={billCountryName(bill)}
secondary={regionName(bill.targetRegionId, context?.regionNames)}
/>
<Stack primary={billCountryName(bill)} secondary={regionName(bill.targetRegionId, context?.regionNames)} />
),
},
{
@ -315,19 +308,7 @@ function BillUser({ bill, type }) {
return "-";
}
const shortId = profile.displayUserId || profile.userId || "-";
const name = profile.username || "-";
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{profile.avatar ? <img alt="" src={profile.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.identityId}>{name}</span>
<span className={styles.identityName}>{shortId}</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={profile} />;
}
function StatusBadge({ status }) {
@ -386,6 +367,8 @@ function normalizeBillUser(profile, fallbackId) {
countryDisplayName: source.countryDisplayName || source.country_display_name || "",
countryName: source.countryName || source.country_name || "",
displayUserId: source.displayUserId || source.display_user_id || "",
prettyDisplayUserId: source.prettyDisplayUserId || source.pretty_display_user_id || "",
prettyId: source.prettyId || source.pretty_id || "",
userId: String(source.userId || source.user_id || fallbackId || ""),
username: source.username || source.name || "",
};

View File

@ -25,6 +25,8 @@ export interface RegistrationRewardConfigPayload {
export interface RegistrationRewardClaimUserDto {
userId?: number;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
username?: string;
avatar?: string;
}
@ -111,6 +113,8 @@ function normalizeClaim(item: RawClaim): RegistrationRewardClaimDto {
user: {
userId: numberValue(rawUser.userId ?? rawUser.user_id),
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
username: stringValue(rawUser.username),
avatar: stringValue(rawUser.avatar),
},

View File

@ -1,7 +1,7 @@
import Avatar from "@mui/material/Avatar";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { RegistrationRewardConfigDrawer } from "@/features/registration-reward/components/RegistrationRewardConfigDrawer.jsx";
@ -132,17 +132,7 @@ export function RegistrationRewardPage() {
function ClaimUser({ claim }) {
const user = claim.user || {};
const name = user.username || `用户 ${claim.userId}`;
const displayUserId = user.displayUserId || "";
return (
<div className={styles.identity}>
<Avatar alt={user.username || String(claim.userId)} src={user.avatar || ""} sx={{ width: 36, height: 36 }} />
<div className={styles.stack}>
<span className={styles.name}>{displayUserId ? `${name} (${displayUserId})` : name}</span>
{displayUserId ? null : <span className={styles.meta}>ID {claim.userId}</span>}
</div>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || claim.userId }} />;
}
function RewardSummary({ claim }) {

View File

@ -18,11 +18,11 @@ test("registration reward list shows receive filter today count and compact row
expect(screen.getByText("今日已领")).toBeInTheDocument();
expect(screen.getByText("7 份")).toBeInTheDocument();
expect(screen.getAllByRole("button", { name: /领取时间/ }).some((node) => node.textContent.includes("领取时间"))).toBe(
true,
);
expect(screen.getByText("jayCiE (165121)")).toBeInTheDocument();
expect(screen.queryByText("短号 165121")).not.toBeInTheDocument();
expect(
screen.getAllByRole("button", { name: /领取时间/ }).some((node) => node.textContent.includes("领取时间")),
).toBe(true);
expect(screen.getByText("jayCiE")).toBeInTheDocument();
expect(screen.getByText("165121")).toBeInTheDocument();
expect(screen.getAllByText("1,000 金币").length).toBeGreaterThanOrEqual(1);
expect(screen.queryByText(/^注册奖励$/)).not.toBeInTheDocument();
expect(screen.getByText("rclaim_1780684209628_899e9717839e0109")).toBeInTheDocument();

View File

@ -205,6 +205,8 @@ export interface ResourceGrantItemDto {
export interface ResourceGrantOperatorDto {
avatar?: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
name?: string;
source?: string;
userId?: number | string;
@ -214,6 +216,8 @@ export interface ResourceGrantOperatorDto {
export interface ResourceGrantUserDto {
avatar?: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
userId?: number | string;
username?: string;
}

View File

@ -1,16 +1,11 @@
import Add from "@mui/icons-material/Add";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminActionIconButton, AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { GiftResourceSelectField } from "@/features/resources/components/GiftResourceSelectDrawer.jsx";
@ -233,7 +228,9 @@ function ResourceGrantDialog({
required
resources={resourceOptions}
selectedResourceIds={form.resourceIds || []}
onChange={(resourceIds) => setForm({ ...form, resourceId: resourceIds[0] || "", resourceIds })}
onChange={(resourceIds) =>
setForm({ ...form, resourceId: resourceIds[0] || "", resourceIds })
}
/>
<TextField
disabled={disabled}
@ -305,20 +302,12 @@ function GrantItems({ grant }) {
function GrantTargetUser({ grant }) {
const user = grant.targetUser || grant.target_user || grant.user || {};
const userId = user.userId || user.user_id || grant.targetUserId;
const displayUserId = user.displayUserId || user.display_user_id;
const username = user.username || "";
const name = username || (displayUserId ? `用户 ${displayUserId}` : userId ? `用户 ${userId}` : "-");
return (
<div className={styles.identity}>
<span className={styles.operatorAvatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.name}>{name}</span>
<span className={styles.meta}>长ID {userId || "-"}</span>
{displayUserId ? <span className={styles.meta}>短ID {displayUserId}</span> : null}
</span>
</div>
<AdminUserIdentity
openInAppUserDetail
rows={[user.displayUserId || user.display_user_id, userId]}
user={{ ...user, userId }}
/>
);
}
@ -327,22 +316,12 @@ function GrantOperator({ grant }) {
const source = operator.source || grant.grantSource || "";
const operatorUserId = operator.userId || grant.operatorUserId;
if (source === "manager_center") {
const name = operator.username || operator.name || (operatorUserId ? `用户 ${operatorUserId}` : "-");
const shortId = operator.displayUserId || operatorUserId || "-";
return (
<div className={styles.identity}>
<span className={styles.operatorAvatar}>
{operator.avatar ? (
<img alt="" src={operator.avatar} />
) : (
<PersonOutlineOutlined fontSize="small" />
)}
</span>
<span className={styles.identityText}>
<span className={styles.name}>{name}</span>
<span className={styles.meta}>{shortId}</span>
</span>
</div>
<AdminUserIdentity
openInAppUserDetail
name={operator.username || operator.name}
user={{ ...operator, userId: operatorUserId }}
/>
);
}
if (source === "admin") {

View File

@ -1,198 +1,206 @@
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useEffect, useState } from "react";
import { AdminPrettyDisplayID, AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { roomStatusLabels } from "@/features/rooms/constants.js";
import styles from "@/features/rooms/rooms.module.css";
export function RoomDetailDrawer({ onClose, open, room }) {
const owner = room?.owner || {};
const normalizedStatus = room?.status === "active" ? "active" : "closed";
const ownerDisplayId = ownerLongShortId(owner, room);
const closeOperator = room?.closedByAdminName || (room?.closedByAdminId ? String(room.closedByAdminId) : "");
const owner = room?.owner || {};
const normalizedStatus = room?.status === "active" ? "active" : "closed";
const closeOperator = room?.closedByAdminName || (room?.closedByAdminId ? String(room.closedByAdminId) : "");
return (
<SideDrawer open={open} title="房间详情" width="wide" onClose={onClose}>
{room ? (
<>
<header className={styles.detailHero}>
<span className={styles.detailCover}>
{room.coverUrl ? <img src={room.coverUrl} alt="" /> : <MeetingRoomOutlined fontSize="medium" />}
</span>
<div className={styles.detailHeroText}>
<div className={styles.detailTitleRow}>
<h3>{room.title || "-"}</h3>
<StatusBadge status={normalizedStatus} />
</div>
<CopyableRoomId className={styles.detailMeta} prefix="房间 ID " roomId={room.roomId} />
</div>
</header>
return (
<SideDrawer open={open} title="房间详情" width="wide" onClose={onClose}>
{room ? (
<>
<header className={styles.detailHero}>
<span className={styles.detailCover}>
{room.coverUrl ? (
<img src={room.coverUrl} alt="" />
) : (
<MeetingRoomOutlined fontSize="medium" />
)}
</span>
<div className={styles.detailHeroText}>
<div className={styles.detailTitleRow}>
<h3>{room.title || "-"}</h3>
<StatusBadge status={normalizedStatus} />
</div>
<CopyableRoomId className={styles.detailMeta} prefix="房间 ID " roomId={room.roomId} />
</div>
</header>
<DetailSection
items={[
["房间名称", room.title],
["房间 ID", <CopyableRoomId key="room-id" roomId={room.roomId} />],
["房间状态", roomStatusLabels[normalizedStatus]],
["房间模式", room.mode],
["区域", room.regionName],
["创建时间", <TimeText key="created-at" value={room.createdAtMs} />],
["更新时间", <TimeText key="updated-at" value={room.updatedAtMs} />]
]}
title="基础信息"
/>
<DetailSection
items={[
["房间名称", room.title],
["房间 ID", <CopyableRoomId key="room-id" roomId={room.roomId} />],
["房间状态", roomStatusLabels[normalizedStatus]],
["房间模式", room.mode],
["区域", room.regionName],
["创建时间", <TimeText key="created-at" value={room.createdAtMs} />],
["更新时间", <TimeText key="updated-at" value={room.updatedAtMs} />],
]}
title="基础信息"
/>
{normalizedStatus === "closed" ? (
<DetailSection
items={[
["操作人", closeOperator],
["操作时间", <TimeText key="closed-at" value={room.closedAtMs} />],
["关闭原因", room.closeReason]
]}
title="关闭信息"
/>
) : null}
{normalizedStatus === "closed" ? (
<DetailSection
items={[
["操作人", closeOperator],
["操作时间", <TimeText key="closed-at" value={room.closedAtMs} />],
["关闭原因", room.closeReason],
]}
title="关闭信息"
/>
) : null}
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>房主信息</h3>
<div className={styles.detailOwner}>
<span className={styles.detailOwnerAvatar}>
{owner.avatar ? <img src={owner.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.detailOwnerText}>
<strong>{owner.username || "-"}</strong>
<span>{ownerDisplayId}</span>
</div>
</div>
<div className={styles.detailGrid}>
<DetailItem label="房主长 ID(短 ID)" value={ownerDisplayId} />
</div>
</section>
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>房主信息</h3>
<div className={styles.detailOwner}>
<AdminUserIdentity
openInAppUserDetail
user={{ ...owner, userId: owner.userId || room.ownerUserId }}
/>
</div>
<div className={styles.detailGrid}>
<DetailItem label="房主长 ID" value={owner.userId || room.ownerUserId} />
<DetailItem label="房主短 ID" value={owner.defaultDisplayUserId || owner.displayUserId} />
<DetailItem
label="房主靓号"
value={
owner.prettyId && owner.prettyDisplayUserId ? (
<AdminPrettyDisplayID
prettyDisplayUserId={owner.prettyDisplayUserId}
prettyId={owner.prettyId}
/>
) : (
""
)
}
/>
</div>
</section>
<DetailSection
items={[
["在线人数", `${formatNumber(room.onlineCount)}`],
["座位数", `${formatNumber(room.seatCount)}`],
["已占座位", `${formatNumber(room.occupiedSeatCount)}`],
["房间贡献", formatNumber(roomContributionValue(room))],
["热度", formatNumber(room.heat)],
["排序分", formatNumber(room.sortScore)]
]}
title="房间数据"
/>
</>
) : null}
</SideDrawer>
);
<DetailSection
items={[
["在线人数", `${formatNumber(room.onlineCount)}`],
["座位数", `${formatNumber(room.seatCount)}`],
["已占座位", `${formatNumber(room.occupiedSeatCount)}`],
["房间贡献", formatNumber(roomContributionValue(room))],
["热度", formatNumber(room.heat)],
["排序分", formatNumber(room.sortScore)],
]}
title="房间数据"
/>
</>
) : null}
</SideDrawer>
);
}
function DetailSection({ items, title }) {
return (
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>{title}</h3>
<div className={styles.detailGrid}>
{items.map(([label, value]) => (
<DetailItem key={label} label={label} value={value} />
))}
</div>
</section>
);
return (
<section className={styles.detailSection}>
<h3 className={styles.detailSectionTitle}>{title}</h3>
<div className={styles.detailGrid}>
{items.map(([label, value]) => (
<DetailItem key={label} label={label} value={value} />
))}
</div>
</section>
);
}
function DetailItem({ label, value }) {
return (
<div className={styles.detailItem}>
<span className={styles.detailLabel}>{label}</span>
<div className={styles.detailValue}>{displayValue(value)}</div>
</div>
);
return (
<div className={styles.detailItem}>
<span className={styles.detailLabel}>{label}</span>
<div className={styles.detailValue}>{displayValue(value)}</div>
</div>
);
}
function CopyableRoomId({ className = "", prefix = "", roomId }) {
const [copied, setCopied] = useState(false);
const value = displayValue(roomId);
const [copied, setCopied] = useState(false);
const value = displayValue(roomId);
useEffect(() => {
if (!copied) {
return undefined;
}
const timer = window.setTimeout(() => setCopied(false), 1200);
return () => window.clearTimeout(timer);
}, [copied]);
useEffect(() => {
if (!copied) {
return undefined;
}
const timer = window.setTimeout(() => setCopied(false), 1200);
return () => window.clearTimeout(timer);
}, [copied]);
const copyRoomId = async () => {
if (!roomId) {
return;
}
await copyText(String(roomId));
setCopied(true);
};
const copyRoomId = async () => {
if (!roomId) {
return;
}
await copyText(String(roomId));
setCopied(true);
};
return (
<button
aria-label={`复制房间 ID ${value}`}
className={[styles.copyValue, className].filter(Boolean).join(" ")}
disabled={!roomId}
type="button"
onClick={copyRoomId}
>
<span>{prefix}{value}</span>
<ContentCopyOutlined className={styles.copyIcon} fontSize="inherit" />
{copied ? <span className={styles.copyState}>已复制</span> : null}
</button>
);
return (
<button
aria-label={`复制房间 ID ${value}`}
className={[styles.copyValue, className].filter(Boolean).join(" ")}
disabled={!roomId}
type="button"
onClick={copyRoomId}
>
<span>
{prefix}
{value}
</span>
<ContentCopyOutlined className={styles.copyIcon} fontSize="inherit" />
{copied ? <span className={styles.copyState}>已复制</span> : null}
</button>
);
}
function StatusBadge({ status }) {
const tone = status === "active" ? "running" : "stopped";
const tone = status === "active" ? "running" : "stopped";
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{roomStatusLabels[status]}
</span>
);
return (
<span className={`status-badge status-badge--${tone}`}>
<span className="status-point" />
{roomStatusLabels[status]}
</span>
);
}
function displayValue(value) {
return value === 0 || value ? value : "-";
return value === 0 || value ? value : "-";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
return Number(value || 0).toLocaleString("zh-CN");
}
function roomContributionValue(room) {
return room?.roomContribution ?? room?.heat ?? 0;
return room?.roomContribution ?? room?.heat ?? 0;
}
async function copyText(value) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// Clipboard API退
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value);
return;
} catch {
// Clipboard API退
}
}
}
const input = document.createElement("textarea");
input.value = value;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}
function ownerLongShortId(owner, room) {
const longId = owner.userId || room?.ownerUserId || "";
const shortId = owner.displayUserId || "";
if (longId && shortId && longId !== shortId) {
return `${longId}(${shortId})`;
}
return longId || shortId || "-";
const input = document.createElement("textarea");
input.value = value;
input.setAttribute("readonly", "");
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}

View File

@ -1,11 +1,11 @@
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { AdminFormDialog, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
@ -90,7 +90,7 @@ export function RoomListPage() {
}),
render: (room) => <RoomIdentity room={room} onClick={() => page.openDetail(room)} />,
}
: column.key === "status"
: column.key === "status"
? {
...column,
filter: createOptionsColumnFilter({
@ -111,7 +111,7 @@ export function RoomListPage() {
/>
),
}
: column,
: column,
),
{
key: "actions",
@ -208,7 +208,6 @@ export function RoomListPage() {
onChange={(event) => page.setStatusForm({ ...page.statusForm, closeReason: event.target.value })}
/>
</ActionModal>
</section>
);
}
@ -243,20 +242,7 @@ function RoomIdentity({ onClick, room }) {
function OwnerIdentity({ room }) {
const owner = room.owner || {};
const ownerName = owner.username || "-";
const ownerDisplayId = ownerLongShortId(owner, room);
return (
<div className={styles.identity}>
<span className={styles.ownerAvatar}>
{owner.avatar ? <img src={owner.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<div className={styles.name}>{ownerName}</div>
<div className={styles.meta}>{ownerDisplayId}</div>
</div>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...owner, userId: owner.userId || room.ownerUserId }} />;
}
function RoomActions({ page, room }) {
@ -344,12 +330,3 @@ function ContributionSortHeader({ direction, onClick }) {
</button>
);
}
function ownerLongShortId(owner, room) {
const longId = owner.userId || room.ownerUserId || "";
const shortId = owner.displayUserId || "";
if (longId && shortId && longId !== shortId) {
return `${longId}(${shortId})`;
}
return longId || shortId || "-";
}

View File

@ -1,13 +1,13 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import CloseOutlined from "@mui/icons-material/CloseOutlined";
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import Autocomplete from "@mui/material/Autocomplete";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import { Button } from "@/shared/ui/Button.jsx";
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
@ -138,7 +138,11 @@ export function RoomPinsPage() {
</section>
{page.abilities.canPinCreate ? (
<div className={styles.toolbarActions}>
<Button startIcon={<AddOutlined fontSize="small" />} variant="primary" onClick={page.openCreate}>
<Button
startIcon={<AddOutlined fontSize="small" />}
variant="primary"
onClick={page.openCreate}
>
新增置顶
</Button>
</div>
@ -268,18 +272,7 @@ function PinRoomIdentity({ room = {} }) {
}
function PinUserIdentity({ user = {} }) {
return (
<div className={styles.identity}>
<span className={styles.ownerAvatar}>
{user.avatar ? <img src={user.avatar} alt="" /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<div className={styles.name}>{user.username || "-"}</div>
<div className={styles.meta}>长ID {user.userId || "-"}</div>
<div className={styles.meta}>短ID {user.displayUserId || "-"}</div>
</div>
</div>
);
return <AdminUserIdentity openInAppUserDetail rows={[user.displayUserId, user.userId]} user={user} />;
}
function RoomPinActions({ page, pin }) {

View File

@ -28,6 +28,8 @@ export interface SevenDayCheckInConfigPayload {
export interface SevenDayCheckInClaimUserDto {
userId?: number;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
username?: string;
avatar?: string;
}
@ -123,6 +125,8 @@ function normalizeClaim(item: RawClaim): SevenDayCheckInClaimDto {
user: {
userId: numberValue(rawUser.userId ?? rawUser.user_id),
displayUserId: stringValue(rawUser.displayUserId ?? rawUser.display_user_id),
prettyDisplayUserId: stringValue(rawUser.prettyDisplayUserId ?? rawUser.pretty_display_user_id),
prettyId: stringValue(rawUser.prettyId ?? rawUser.pretty_id),
username: stringValue(rawUser.username),
avatar: stringValue(rawUser.avatar),
},

View File

@ -1,7 +1,7 @@
import Avatar from "@mui/material/Avatar";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { SevenDayCheckInConfigDrawer } from "@/features/seven-day-checkin/components/SevenDayCheckInConfigDrawer.jsx";
@ -188,21 +188,7 @@ export function SevenDayCheckInPage() {
function ClaimUser({ claim }) {
const user = claim.user || {};
return (
<div className={styles.identity}>
<Avatar
alt={user.username || String(claim.userId)}
src={user.avatar || ""}
sx={{ width: 36, height: 36 }}
/>
<div className={styles.stack}>
<span className={styles.name}>{user.username || `用户 ${claim.userId}`}</span>
<span className={styles.meta}>
{user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`}
</span>
</div>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || claim.userId }} />;
}
function ResourceGroupLabel({ groups, id }) {

View File

@ -8,6 +8,8 @@ export type UserLeaderboardPeriod = "today" | "week" | "month";
export interface UserLeaderboardUserDto {
userId: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
username?: string;
avatar?: string;
}
@ -94,7 +96,10 @@ function normalizeResponse(response: RawResponse): UserLeaderboardResponseDto {
startAtMs: numberValue(response.startAtMs ?? response.start_at_ms),
endAtMs: numberValue(response.endAtMs ?? response.end_at_ms),
serverTimeMs: numberValue(response.serverTimeMs ?? response.server_time_ms),
myRank: response.myRank || response.my_rank ? normalizeItem((response.myRank || response.my_rank) as RawItem) : null,
myRank:
response.myRank || response.my_rank
? normalizeItem((response.myRank || response.my_rank) as RawItem)
: null,
};
}
@ -116,6 +121,8 @@ function normalizeUser(user: RawUser): UserLeaderboardUserDto {
return {
userId: stringValue(user.userId ?? user.user_id),
displayUserId: optionalString(user.displayUserId ?? user.display_user_id),
prettyDisplayUserId: optionalString(user.prettyDisplayUserId ?? user.pretty_display_user_id),
prettyId: optionalString(user.prettyId ?? user.pretty_id),
username: optionalString(user.username),
avatar: optionalString(user.avatar),
};

View File

@ -1,4 +1,3 @@
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
@ -8,6 +7,7 @@ import {
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
import { TimeRangeText, TimeText } from "@/shared/ui/TimeText.jsx";
import { useUserLeaderboardPage } from "@/features/user-leaderboard/hooks/useUserLeaderboardPage.js";
import styles from "@/features/user-leaderboard/user-leaderboard.module.css";
@ -147,19 +147,7 @@ function LeaderboardSubject({ item, type }) {
);
}
const user = item.user || {};
const shortId = user.displayUserId || "";
const displayName = user.username || (shortId ? `用户 ${shortId}` : "用户");
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.name}>{displayName}</span>
<span className={styles.meta}>短ID {shortId || "-"}</span>
</span>
</div>
);
return <AdminUserIdentity openInAppUserDetail user={{ ...user, userId: user.userId || item.userId }} />;
}
function formatNumber(value) {

View File

@ -89,6 +89,7 @@ export const API_OPERATIONS = {
enableResource: "enableResource",
enableResourceGroup: "enableResourceGroup",
enableResourceShopItem: "enableResourceShopItem",
expireRoomRpsChallenge: "expireRoomRpsChallenge",
exportLoginLogs: "exportLoginLogs",
exportOperationLogs: "exportOperationLogs",
exportUsers: "exportUsers",
@ -109,6 +110,8 @@ export const API_OPERATIONS = {
getRoleDataScopes: "getRoleDataScopes",
getRoomConfig: "getRoomConfig",
getRoomRocketConfig: "getRoomRocketConfig",
getRoomRpsChallenge: "getRoomRpsChallenge",
getRoomRpsConfig: "getRoomRpsConfig",
getRoomTurnoverRewardConfig: "getRoomTurnoverRewardConfig",
getSevenDayCheckInConfig: "getSevenDayCheckInConfig",
getUser: "getUser",
@ -163,6 +166,7 @@ export const API_OPERATIONS = {
listResourceShopItems: "listResourceShopItems",
listRoles: "listRoles",
listRoomPins: "listRoomPins",
listRoomRpsChallenges: "listRoomRpsChallenges",
listRooms: "listRooms",
listRoomTurnoverRewardSettlements: "listRoomTurnoverRewardSettlements",
listSelfGames: "listSelfGames",
@ -192,6 +196,7 @@ export const API_OPERATIONS = {
replaceRolePermissions: "replaceRolePermissions",
resetUserPassword: "resetUserPassword",
retryRedPacketRefund: "retryRedPacketRefund",
retryRoomRpsSettlement: "retryRoomRpsSettlement",
retryRoomTurnoverRewardSettlement: "retryRoomTurnoverRewardSettlement",
search: "search",
setAgencyJoinEnabled: "setAgencyJoinEnabled",
@ -237,6 +242,7 @@ export const API_OPERATIONS = {
updateRoom: "updateRoom",
updateRoomConfig: "updateRoomConfig",
updateRoomRocketConfig: "updateRoomRocketConfig",
updateRoomRpsConfig: "updateRoomRpsConfig",
updateRoomTurnoverRewardConfig: "updateRoomTurnoverRewardConfig",
updateSevenDayCheckInConfig: "updateSevenDayCheckInConfig",
updateSplashScreen: "updateSplashScreen",
@ -788,6 +794,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "resource-shop:update",
permissions: ["resource-shop:update"]
},
expireRoomRpsChallenge: {
method: "POST",
operationId: API_OPERATIONS.expireRoomRpsChallenge,
path: "/v1/admin/game/room-rps/challenges/{challenge_id}/expire",
permission: "game:update",
permissions: ["game:update"]
},
exportLoginLogs: {
method: "GET",
operationId: API_OPERATIONS.exportLoginLogs,
@ -928,6 +941,20 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "room-rocket:view",
permissions: ["room-rocket:view"]
},
getRoomRpsChallenge: {
method: "GET",
operationId: API_OPERATIONS.getRoomRpsChallenge,
path: "/v1/admin/game/room-rps/challenges/{challenge_id}",
permission: "game:view",
permissions: ["game:view"]
},
getRoomRpsConfig: {
method: "GET",
operationId: API_OPERATIONS.getRoomRpsConfig,
path: "/v1/admin/game/room-rps/config",
permission: "game:view",
permissions: ["game:view"]
},
getRoomTurnoverRewardConfig: {
method: "GET",
operationId: API_OPERATIONS.getRoomTurnoverRewardConfig,
@ -1304,6 +1331,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "room-pin:view",
permissions: ["room-pin:view"]
},
listRoomRpsChallenges: {
method: "GET",
operationId: API_OPERATIONS.listRoomRpsChallenges,
path: "/v1/admin/game/room-rps/challenges",
permission: "game:view",
permissions: ["game:view"]
},
listRooms: {
method: "GET",
operationId: API_OPERATIONS.listRooms,
@ -1497,6 +1531,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "red-packet:update",
permissions: ["red-packet:update"]
},
retryRoomRpsSettlement: {
method: "POST",
operationId: API_OPERATIONS.retryRoomRpsSettlement,
path: "/v1/admin/game/room-rps/challenges/{challenge_id}/retry-settlement",
permission: "game:update",
permissions: ["game:update"]
},
retryRoomTurnoverRewardSettlement: {
method: "POST",
operationId: API_OPERATIONS.retryRoomTurnoverRewardSettlement,
@ -1810,6 +1851,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "room-rocket:update",
permissions: ["room-rocket:update"]
},
updateRoomRpsConfig: {
method: "PATCH",
operationId: API_OPERATIONS.updateRoomRpsConfig,
path: "/v1/admin/game/room-rps/config",
permission: "game:update",
permissions: ["game:update"]
},
updateRoomTurnoverRewardConfig: {
method: "PUT",
operationId: API_OPERATIONS.updateRoomTurnoverRewardConfig,

View File

@ -1204,6 +1204,86 @@ export interface paths {
patch: operations["setDiceRobotStatus"];
trace?: never;
};
"/admin/game/room-rps/config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getRoomRpsConfig"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch: operations["updateRoomRpsConfig"];
trace?: never;
};
"/admin/game/room-rps/challenges": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listRoomRpsChallenges"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/game/room-rps/challenges/{challenge_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getRoomRpsChallenge"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/game/room-rps/challenges/{challenge_id}/retry-settlement": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["retryRoomRpsSettlement"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/game/room-rps/challenges/{challenge_id}/expire": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["expireRoomRpsChallenge"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/gift-types": {
parameters: {
query?: never;
@ -3273,7 +3353,7 @@ export interface operations {
query?: never;
header?: never;
path: {
task_id: number;
task_id: string;
};
cookie?: never;
};
@ -3287,7 +3367,7 @@ export interface operations {
query?: never;
header?: never;
path: {
task_id: number;
task_id: string;
};
cookie?: never;
};
@ -4627,6 +4707,84 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
getRoomRpsConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
updateRoomRpsConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listRoomRpsChallenges: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getRoomRpsChallenge: {
parameters: {
query?: never;
header?: never;
path: {
challenge_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
retryRoomRpsSettlement: {
parameters: {
query?: never;
header?: never;
path: {
challenge_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
expireRoomRpsChallenge: {
parameters: {
query?: never;
header?: never;
path: {
challenge_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listGiftTypes: {
parameters: {
query?: never;

View File

@ -191,6 +191,8 @@ export interface ChangePasswordPayload {
export interface CoinLedgerUserDto {
avatar?: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
userId: string;
username?: string;
}
@ -274,6 +276,8 @@ export interface ReportUserDto {
avatar?: string;
defaultDisplayUserId?: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
userId?: string;
username?: string;
}
@ -336,6 +340,8 @@ export interface RechargeBillUserDto {
countryDisplayName?: string;
countryName?: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
userId?: string;
username?: string;
}
@ -433,6 +439,8 @@ export interface BDProfileDto {
createdByUsername?: string;
createdByUserId?: number;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
parentLeaderUserId?: number;
parentLeaderDisplayUserId?: string;
parentLeaderUsername?: string;
@ -481,6 +489,8 @@ export interface HostProfileDto {
currentMembershipId?: number;
diamond?: number;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
firstBecameHostAtMs?: number;
regionId?: number;
regionName?: string;
@ -497,6 +507,8 @@ export interface CoinSellerDto {
createdAtMs?: number;
createdByUserId?: number;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
merchantAssetType?: string;
merchantBalance?: number;
regionId?: number;
@ -568,10 +580,13 @@ export interface AppUserDto {
countryDisplayName?: string;
countryName?: string;
createdAtMs?: number;
defaultDisplayUserId?: string;
diamond?: number;
displayUserId?: string;
gender?: string;
lastActiveAtMs?: number;
prettyDisplayUserId?: string;
prettyId?: string;
regionId?: number;
regionName?: string;
status?: string;
@ -582,7 +597,10 @@ export interface AppUserDto {
export interface AppUserBriefDto {
avatar?: string;
defaultDisplayUserId?: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
userId: string;
username?: string;
}
@ -591,8 +609,11 @@ export interface AppUserBanOperatorDto {
account?: string;
adminId?: string;
avatar?: string;
defaultDisplayUserId?: string;
displayUserId?: string;
name?: string;
prettyDisplayUserId?: string;
prettyId?: string;
type: "admin" | "app_user" | "system" | string;
userId?: string;
}
@ -615,6 +636,7 @@ export interface AppUserLoginLogDto {
channel?: string;
country?: string;
createdAtMs?: number;
defaultDisplayUserId?: string;
displayUserId?: string;
failureCode?: string;
id: number;
@ -622,6 +644,8 @@ export interface AppUserLoginLogDto {
loginIp?: string;
loginType?: string;
platform?: string;
prettyDisplayUserId?: string;
prettyId?: string;
provider?: string;
regionId?: number;
regionName?: string;
@ -800,6 +824,8 @@ export interface RoomPinPayload {
export interface RoomOwnerDto {
avatar?: string;
displayUserId?: string;
prettyDisplayUserId?: string;
prettyId?: string;
userId?: string;
username?: string;
}

View File

@ -0,0 +1,219 @@
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useAppUserDetail } from "@/features/app-users/components/AppUserDetailContext.jsx";
import styles from "@/shared/ui/AdminUserIdentity.module.css";
export function AdminUserIdentity({
avatar,
className = "",
defaultDisplayUserId,
displayUserId,
name,
onClick,
openInAppUserDetail = false,
prettyDisplayUserId,
prettyId,
rows,
size = "table",
user,
userId,
}) {
const { openAppUserDetail } = useAppUserDetail();
const normalized = normalizeUserIdentity({
avatar,
defaultDisplayUserId,
displayUserId,
name,
prettyDisplayUserId,
prettyId,
user,
userId,
});
const shouldOpenAppUserDetail = openInAppUserDetail && normalized.userId && openAppUserDetail;
const Root = onClick || shouldOpenAppUserDetail ? "button" : "div";
const rootProps = resolveRootProps({
displayName: normalized.displayName,
onClick,
openAppUserDetail,
shouldOpenAppUserDetail,
user: normalized.detailUser,
});
const visibleRows = Array.isArray(rows)
? rows.map((row) => String(row || "").trim()).filter(Boolean)
: [normalized.shortId].filter(Boolean);
return (
<Root
className={[
styles.root,
size === "large" ? styles.large : "",
onClick || shouldOpenAppUserDetail ? styles.button : "",
shouldOpenAppUserDetail ? styles.link : "",
onClick || shouldOpenAppUserDetail ? styles.clickable : "",
className,
]
.filter(Boolean)
.join(" ")}
{...rootProps}
>
<span className={styles.avatar}>
{normalized.avatar ? (
<img src={normalized.avatar} alt="" />
) : (
<PersonOutlineOutlined fontSize="small" />
)}
</span>
<div className={styles.text}>
<div className={styles.nameLine}>
<span className={styles.name}>{normalized.displayName}</span>
</div>
{visibleRows.length ? (
<div className={styles.idLine}>
<span className={styles.idText}>{visibleRows[0]}</span>
{normalized.hasPretty ? (
<AdminPrettyDisplayID
prettyDisplayUserId={normalized.prettyDisplayUserId}
prettyId={normalized.prettyId}
/>
) : null}
</div>
) : normalized.hasPretty ? (
<div className={styles.idLine}>
<AdminPrettyDisplayID
prettyDisplayUserId={normalized.prettyDisplayUserId}
prettyId={normalized.prettyId}
/>
</div>
) : null}
{visibleRows.slice(1).map((row, index) => (
<span className={styles.extraRow} key={`${row}-${index}`}>
{row}
</span>
))}
</div>
</Root>
);
}
export function AdminPrettyDisplayID({ prettyDisplayUserId, prettyId }) {
const prettyValue = String(prettyDisplayUserId || "").trim();
const recordId = String(prettyId || "").trim();
if (!prettyValue || !recordId) {
return null;
}
return (
<span className={styles.prettyBadge} title={`靓号 ${prettyValue} · ${recordId}`}>
<span className={styles.prettyValue}>{prettyValue}</span>
</span>
);
}
function normalizeUserIdentity({
avatar,
defaultDisplayUserId,
displayUserId,
name,
prettyDisplayUserId,
prettyId,
user,
userId,
}) {
const source = user || {};
// camelCase snake_case
//
const resolvedUserId = firstNonEmpty(
userId,
source.userId,
source.user_id,
source.assignedUserId,
source.assigned_user_id,
source.targetUserId,
source.target_user_id,
);
const resolvedDefaultDisplayUserId = firstNonEmpty(
defaultDisplayUserId,
source.defaultDisplayUserId,
source.default_display_user_id,
);
const resolvedDisplayUserId = firstNonEmpty(
displayUserId,
source.displayUserId,
source.display_user_id,
resolvedDefaultDisplayUserId,
resolvedUserId,
);
const resolvedPrettyId = firstNonEmpty(prettyId, source.prettyId, source.pretty_id);
const resolvedPrettyDisplayUserId = firstNonEmpty(
prettyDisplayUserId,
source.prettyDisplayUserId,
source.pretty_display_user_id,
);
// record id defaultDisplayUserId
const hasPretty = Boolean(resolvedPrettyId && resolvedPrettyDisplayUserId);
const resolvedShortId = hasPretty
? firstNonEmpty(resolvedDefaultDisplayUserId, resolvedUserId, resolvedDisplayUserId)
: firstNonEmpty(resolvedDisplayUserId, resolvedDefaultDisplayUserId, resolvedUserId);
const displayName = firstNonEmpty(
name,
source.username,
source.name,
source.account,
resolvedDisplayUserId,
resolvedUserId,
"-",
);
return {
avatar: firstNonEmpty(avatar, source.avatar),
detailUser: {
...source,
avatar: firstNonEmpty(avatar, source.avatar),
defaultDisplayUserId: resolvedDefaultDisplayUserId,
displayUserId: resolvedDisplayUserId,
prettyDisplayUserId: resolvedPrettyDisplayUserId,
prettyId: resolvedPrettyId,
userId: resolvedUserId,
username: firstNonEmpty(name, source.username, source.name, source.account),
},
displayName,
hasPretty,
prettyDisplayUserId: resolvedPrettyDisplayUserId,
prettyId: resolvedPrettyId,
shortId: resolvedShortId || "-",
userId: resolvedUserId,
};
}
function resolveRootProps({ displayName, onClick, openAppUserDetail, shouldOpenAppUserDetail, user }) {
if (onClick) {
return {
"aria-label": `查看用户 ${displayName}`,
onClick: (event) => {
event.stopPropagation();
onClick(event);
},
type: "button",
};
}
if (shouldOpenAppUserDetail) {
return {
"aria-label": `查看用户详情 ${displayName}`,
onClick: (event) => {
event.stopPropagation();
openAppUserDetail(user);
},
type: "button",
};
}
return {};
}
function firstNonEmpty(...values) {
for (const value of values) {
const normalized = String(value ?? "").trim();
if (normalized) {
return normalized;
}
}
return "";
}

View File

@ -0,0 +1,143 @@
.root {
display: inline-flex;
max-width: 100%;
min-width: 0;
align-items: center;
gap: var(--space-3);
color: inherit;
text-align: left;
}
.button {
width: 100%;
padding: 0;
border: 0;
background: transparent;
font: inherit;
}
.link {
text-decoration: none;
}
.clickable {
cursor: pointer;
}
.clickable:hover .name,
.clickable:focus-visible .name {
color: var(--primary);
}
.clickable:focus-visible {
border-radius: var(--radius-sm);
outline: 2px solid var(--primary);
outline-offset: 3px;
}
.avatar {
display: inline-flex;
width: 36px;
height: 36px;
flex: 0 0 auto;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-card-strong);
color: var(--text-secondary);
font-weight: 700;
}
.large .avatar {
width: 64px;
height: 64px;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.text {
display: grid;
min-width: 0;
gap: 3px;
}
.nameLine,
.idLine {
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.name {
min-width: 0;
overflow: hidden;
color: var(--text-primary);
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.large .name {
font-size: 18px;
font-weight: 800;
}
.idText,
.extraRow {
min-width: 0;
overflow-wrap: anywhere;
color: var(--text-tertiary);
font-size: var(--admin-font-size);
line-height: 1.3;
}
.prettyBadge {
display: inline-flex;
max-width: 100%;
min-width: 0;
align-items: center;
gap: 5px;
padding: 1px 7px;
border: 1px solid rgba(124, 58, 237, 0.26);
border-radius: 999px;
background: linear-gradient(90deg, rgba(255, 247, 179, 0.8), rgba(255, 225, 244, 0.78), rgba(219, 234, 254, 0.8));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45);
line-height: 1.2;
}
.prettyValue {
min-width: 0;
overflow: hidden;
background: linear-gradient(90deg, #f6da57 0%, #ffa4a8 30%, #ff75c6 55%, #c089ff 78%, #56fffa 100%);
background-clip: text;
color: transparent;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
-webkit-background-clip: text;
}
@media (prefers-reduced-motion: no-preference) {
.name,
.prettyBadge {
transition:
color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
}
.clickable:hover .prettyBadge {
border-color: rgba(37, 99, 235, 0.38);
box-shadow:
0 6px 16px rgba(124, 58, 237, 0.12),
inset 0 0 0 1px rgba(255, 255, 255, 0.55);
}
}

View File

@ -0,0 +1,121 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, test, vi } from "vitest";
import { AppUserDetailProvider } from "@/features/app-users/components/AppUserDetailProvider.jsx";
import { AppUserDetailContext } from "@/features/app-users/components/AppUserDetailContext.jsx";
import { setAccessToken } from "@/shared/api/request";
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
afterEach(() => {
setAccessToken("");
vi.unstubAllGlobals();
});
test("renders avatar name original id and pretty id without label", () => {
render(
<AdminUserIdentity
user={{
avatar: "https://cdn.example/avatar.png",
defaultDisplayUserId: "123456",
displayUserId: "VIP2026",
prettyDisplayUserId: "VIP2026",
prettyId: "pretty-2026",
userId: "10001",
username: "tester",
}}
/>,
);
expect(screen.getByText("tester")).toBeInTheDocument();
expect(screen.getByText("123456")).toBeInTheDocument();
expect(screen.queryByText(["彩色", "靓号"].join(""))).not.toBeInTheDocument();
expect(screen.getByText("VIP2026")).toBeInTheDocument();
expect(document.querySelector("img")).toHaveAttribute("src", "https://cdn.example/avatar.png");
});
test("clickable identity opens detail handler", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<AdminUserIdentity onClick={onClick} user={{ displayUserId: "123456", userId: "10001" }} />);
await user.click(screen.getByRole("button", { name: /查看用户/ }));
expect(onClick).toHaveBeenCalledTimes(1);
});
test("open detail uses provider instead of app user list link", async () => {
const user = userEvent.setup();
const openAppUserDetail = vi.fn();
render(
<AppUserDetailContext.Provider value={{ openAppUserDetail }}>
<AdminUserIdentity openInAppUserDetail user={{ displayUserId: "123456", userId: "10001" }} />
</AppUserDetailContext.Provider>,
);
await user.click(screen.getByRole("button", { name: /查看用户详情/ }));
expect(screen.queryByRole("link", { name: /跳转用户详情/ })).not.toBeInTheDocument();
expect(openAppUserDetail).toHaveBeenCalledWith(
expect.objectContaining({ displayUserId: "123456", userId: "10001" }),
);
});
test("open detail accepts assigned user id from pretty id records", async () => {
const user = userEvent.setup();
const openAppUserDetail = vi.fn();
render(
<AppUserDetailContext.Provider value={{ openAppUserDetail }}>
<AdminUserIdentity
openInAppUserDetail
user={{
assignedUserId: "10001",
displayUserId: "111cc1111",
prettyId: "pretty-2026",
}}
/>
</AppUserDetailContext.Provider>,
);
await user.click(screen.getByRole("button", { name: /查看用户详情/ }));
expect(openAppUserDetail).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
});
test("app user detail provider fetches detail and renders current pretty id", async () => {
const user = userEvent.setup();
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(
JSON.stringify({
code: 0,
data: {
display_user_id: "123456",
default_display_user_id: "123456",
pretty_display_user_id: "VIP2026",
pretty_id: "pretty-2026",
status: "active",
user_id: "10001",
username: "tester",
},
}),
),
),
);
render(
<ToastProvider>
<AppUserDetailProvider>
<AdminUserIdentity openInAppUserDetail user={{ displayUserId: "123456", userId: "10001" }} />
</AppUserDetailProvider>
</ToastProvider>,
);
await user.click(screen.getByRole("button", { name: /查看用户详情/ }));
expect((await screen.findAllByText("VIP2026")).length).toBeGreaterThan(0);
expect(screen.getAllByText("pretty-2026").length).toBeGreaterThan(0);
expect(String(vi.mocked(fetch).mock.calls[0][0])).toContain("/api/v1/app/users/10001");
});