任务,用户头像显示相关
This commit is contained in:
parent
7b094f2b6f
commit
54fdc867fe
@ -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",
|
||||
|
||||
@ -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 }),
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -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];
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
@ -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 返回,生成类型和页面代码使用 camelCase;Raw 类型同时接收两种形态,便于后续切换生成客户端。
|
||||
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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
export const AppUserDetailContext = createContext({
|
||||
openAppUserDetail: null,
|
||||
});
|
||||
|
||||
export function useAppUserDetail() {
|
||||
return useContext(AppUserDetailContext);
|
||||
}
|
||||
201
src/features/app-users/components/AppUserDetailProvider.jsx
Normal file
201
src/features/app-users/components/AppUserDetailProvider.jsx
Normal 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 "";
|
||||
}
|
||||
@ -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({});
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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", "发布心情"],
|
||||
];
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 "归档";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 个"
|
||||
|
||||
357
src/features/games/pages/RoomRpsChallengePage.jsx
Normal file
357
src/features/games/pages/RoomRpsChallengePage.jsx
Normal 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");
|
||||
}
|
||||
298
src/features/games/pages/RoomRpsConfigPage.jsx
Normal file
298
src/features/games/pages/RoomRpsConfigPage.jsx
Normal 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`;
|
||||
}
|
||||
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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,
|
||||
];
|
||||
// 默认值也纳入 hasFilters,这样运营能一键回到“BD + 自动政策 + 上月周期”的标准入口。
|
||||
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 ||
|
||||
"-"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 || "-";
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 = [] }) {
|
||||
|
||||
@ -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 || "",
|
||||
};
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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 || "-";
|
||||
}
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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),
|
||||
},
|
||||
|
||||
@ -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 }) {
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
162
src/shared/api/generated/schema.d.ts
vendored
162
src/shared/api/generated/schema.d.ts
vendored
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
219
src/shared/ui/AdminUserIdentity.jsx
Normal file
219
src/shared/ui/AdminUserIdentity.jsx
Normal 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 "";
|
||||
}
|
||||
143
src/shared/ui/AdminUserIdentity.module.css
Normal file
143
src/shared/ui/AdminUserIdentity.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
121
src/shared/ui/AdminUserIdentity.test.jsx
Normal file
121
src/shared/ui/AdminUserIdentity.test.jsx
Normal 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");
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user