diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index bcb997c..28aafe6 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -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", diff --git a/src/app/navigation/menu.js b/src/app/navigation/menu.js index 22c1563..a55d3e0 100644 --- a/src/app/navigation/menu.js +++ b/src/app/navigation/menu.js @@ -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 }), ], }, { diff --git a/src/app/permissions.ts b/src/app/permissions.ts index da4b660..555036a 100644 --- a/src/app/permissions.ts +++ b/src/app/permissions.ts @@ -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]; diff --git a/src/app/providers.jsx b/src/app/providers.jsx index d411928..8fbccd4 100644 --- a/src/app/providers.jsx +++ b/src/app/providers.jsx @@ -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 }) { - {children} + + {children} + diff --git a/src/app/router/DeferredPage.jsx b/src/app/router/DeferredPage.jsx index f082a77..4c496aa 100644 --- a/src/app/router/DeferredPage.jsx +++ b/src/app/router/DeferredPage.jsx @@ -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 ; } - setPage(null); - route.loader().then((component) => { - pageCache.set(route.pageKey, component); - if (mounted) { - setPage(() => component); - } - }); - - return () => { - mounted = false; - }; - }, [route]); - - if (!Page) { - return ; - } - - return ( - - - - ); + return ( + + + + ); } diff --git a/src/features/app-users/api.test.ts b/src/features/app-users/api.test.ts index 3ffe3c7..be07f08 100644 --- a/src/features/app-users/api.test.ts +++ b/src/features/app-users/api.test.ts @@ -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"); +}); diff --git a/src/features/app-users/api.ts b/src/features/app-users/api.ts index c37b928..c3a5871 100644 --- a/src/features/app-users/api.ts +++ b/src/features/app-users/api.ts @@ -19,50 +19,76 @@ import type { SetPrettyDisplayIDStatusPayload, } from "@/shared/api/types"; -export function listAppUsers(query: PageQuery = {}): Promise> { +export async function listAppUsers(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.appListUsers; - return apiRequest>(apiEndpointPath(API_OPERATIONS.appListUsers), { - method: endpoint.method, - query, - }); + const data = await apiRequest & { page_size?: number }>( + apiEndpointPath(API_OPERATIONS.appListUsers), + { + method: endpoint.method, + query, + }, + ); + return normalizePage(data, normalizeAppUser); } -export function listAppUserLoginLogs(query: PageQuery = {}): Promise> { +export async function getAppUser(userId: EntityId): Promise { + const endpoint = API_ENDPOINTS.appGetUser; + const data = await apiRequest(apiEndpointPath(API_OPERATIONS.appGetUser, { id: userId }), { + method: endpoint.method, + }); + return normalizeAppUser(data); +} + +export async function listAppUserLoginLogs(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.appListLoginLogs; - return apiRequest>(apiEndpointPath(API_OPERATIONS.appListLoginLogs), { - method: endpoint.method, - query, - }); + const data = await apiRequest & { page_size?: number }>( + apiEndpointPath(API_OPERATIONS.appListLoginLogs), + { + method: endpoint.method, + query, + }, + ); + return normalizePage(data, normalizeAppUserLoginLog); } -export function listAppUserBans(query: PageQuery = {}): Promise> { +export async function listAppUserBans(query: PageQuery = {}): Promise> { const endpoint = API_ENDPOINTS.appListBannedUsers; - return apiRequest>(apiEndpointPath(API_OPERATIONS.appListBannedUsers), { - method: endpoint.method, - query, - }); + const data = await apiRequest & { page_size?: number }>( + apiEndpointPath(API_OPERATIONS.appListBannedUsers), + { + method: endpoint.method, + query, + }, + ); + return normalizePage(data, normalizeAppUserBanRecord); } -export function updateAppUser(userId: EntityId, payload: AppUserUpdatePayload): Promise { +export async function updateAppUser(userId: EntityId, payload: AppUserUpdatePayload): Promise { const endpoint = API_ENDPOINTS.appUpdateUser; - return apiRequest(apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), { - body: payload, - method: endpoint.method, - }); + const data = await apiRequest( + apiEndpointPath(API_OPERATIONS.appUpdateUser, { id: userId }), + { + body: payload, + method: endpoint.method, + }, + ); + return normalizeAppUser(data); } -export function banAppUser(userId: EntityId): Promise { +export async function banAppUser(userId: EntityId): Promise { const endpoint = API_ENDPOINTS.appBanUser; - return apiRequest(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), { + const data = await apiRequest(apiEndpointPath(API_OPERATIONS.appBanUser, { id: userId }), { method: endpoint.method, }); + return normalizeAppUser(data); } -export function unbanAppUser(userId: EntityId): Promise { +export async function unbanAppUser(userId: EntityId): Promise { const endpoint = API_ENDPOINTS.appUnbanUser; - return apiRequest(apiEndpointPath(API_OPERATIONS.appUnbanUser, { id: userId }), { + const data = await apiRequest(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( }; } +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 { diff --git a/src/features/app-users/app-users.module.css b/src/features/app-users/app-users.module.css index 3acdfc2..b5371ff 100644 --- a/src/features/app-users/app-users.module.css +++ b/src/features/app-users/app-users.module.css @@ -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; diff --git a/src/features/app-users/components/AppUserDetailContext.jsx b/src/features/app-users/components/AppUserDetailContext.jsx new file mode 100644 index 0000000..bd79e69 --- /dev/null +++ b/src/features/app-users/components/AppUserDetailContext.jsx @@ -0,0 +1,9 @@ +import { createContext, useContext } from "react"; + +export const AppUserDetailContext = createContext({ + openAppUserDetail: null, +}); + +export function useAppUserDetail() { + return useContext(AppUserDetailContext); +} diff --git a/src/features/app-users/components/AppUserDetailProvider.jsx b/src/features/app-users/components/AppUserDetailProvider.jsx new file mode 100644 index 0000000..a60215b --- /dev/null +++ b/src/features/app-users/components/AppUserDetailProvider.jsx @@ -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 ( + + {children} + + + ); +} + +function AppUserDetailDrawer({ loading, onClose, open, user }) { + if (!open || !user) { + return null; + } + + return ( + +
+ + +
+ {loading ?
正在刷新用户详情...
: null} +
+

身份信息

+
+ + + } /> + + + +
+
+
+

账号状态

+
+ + + + + + + + +
+
+
+ ); +} + +function DetailItem({ label, value }) { + const isElement = value && typeof value === "object"; + return ( +
+ {label} + + {isElement ? value : value === undefined || value === null || value === "" ? "-" : value} + +
+ ); +} + +function PrettyValue({ user }) { + if (!user.prettyId || !user.prettyDisplayUserId) { + return "-"; + } + return ; +} + +function AppUserStatus({ status }) { + const tone = status === "active" ? "running" : "danger"; + return ( + + + {appUserStatusLabels[status] || status || "-"} + + ); +} + +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 ""; +} diff --git a/src/features/app-users/hooks/useAppUsersPage.js b/src/features/app-users/hooks/useAppUsersPage.js index 5be00e3..2990afd 100644 --- a/src/features/app-users/hooks/useAppUsersPage.js +++ b/src/features/app-users/hooks/useAppUsersPage.js @@ -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({}); diff --git a/src/features/app-users/pages/AppUserBanListPage.jsx b/src/features/app-users/pages/AppUserBanListPage.jsx index 7751067..1830cc7 100644 --- a/src/features/app-users/pages/AppUserBanListPage.jsx +++ b/src/features/app-users/pages/AppUserBanListPage.jsx @@ -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 ( - - ); + return ; } function OperatorIdentity({ operator }) { @@ -138,52 +131,26 @@ function OperatorIdentity({ operator }) { } if (operator.type === "admin") { return ( - ); } if (operator.type === "app_user") { return ( - ); } return system; } -function UserIdentity({ avatar, name, primaryFallback, rows }) { - const title = name || primaryFallback || "-"; - const visibleRows = rows.filter(Boolean); - return ( -
- - {avatar ? : } - -
-
- {title} -
- {visibleRows.length ? ( -
- {visibleRows.map((value, index) => ( - - {value} - - ))} -
- ) : null} -
-
- ); -} - function UserStatus({ status }) { return ( diff --git a/src/features/app-users/pages/AppUserListPage.jsx b/src/features/app-users/pages/AppUserListPage.jsx index f89b0e4..eb74f96 100644 --- a/src/features/app-users/pages/AppUserListPage.jsx +++ b/src/features/app-users/pages/AppUserListPage.jsx @@ -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) => , - }, -]; - 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) => , }, { 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 ( -
- - {user.avatar ? : } - -
-
- {name} - {gender.symbol} -
-
{shortId}
-
-
- ); -} - 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"); } diff --git a/src/features/app-users/pages/AppUserLoginLogsPage.jsx b/src/features/app-users/pages/AppUserLoginLogsPage.jsx index 9aa96a7..3d07922 100644 --- a/src/features/app-users/pages/AppUserLoginLogsPage.jsx +++ b/src/features/app-users/pages/AppUserLoginLogsPage.jsx @@ -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) => , + render: (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 ( -
- - {log.avatar ? : } - -
-
- {name} -
-
{shortId}
-
-
- ); -} - function ResultBadge({ log }) { const result = String(log.result || "").toLowerCase(); const blocked = log.blocked || result === "blocked"; diff --git a/src/features/app-users/pages/AppUserPrettyIdsPage.jsx b/src/features/app-users/pages/AppUserPrettyIdsPage.jsx index abcc667..b906911 100644 --- a/src/features/app-users/pages/AppUserPrettyIdsPage.jsx +++ b/src/features/app-users/pages/AppUserPrettyIdsPage.jsx @@ -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) => (
- {formatEntityID(item.assignedUserId)} + {item.assignedAtMs ? formatMillis(item.assignedAtMs) : formatMillis(item.createdAtMs)} @@ -366,6 +367,32 @@ function idColumns(page) { ]; } +function AssignedUserCell({ item }) { + const { openAppUserDetail } = useAppUserDetail(); + const assignedUserId = formatEntityID(item.assignedUserId); + if (!item.assignedUserId || !openAppUserDetail) { + return {assignedUserId}; + } + + return ( + + ); +} + function PoolDialog({ page }) { const editing = Boolean(page.activePool); return ( diff --git a/src/features/cumulative-recharge-reward/api.ts b/src/features/cumulative-recharge-reward/api.ts index 668c603..2f3c89f 100644 --- a/src/features/cumulative-recharge-reward/api.ts +++ b/src/features/cumulative-recharge-reward/api.ts @@ -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), }, diff --git a/src/features/cumulative-recharge-reward/pages/CumulativeRechargeRewardPage.jsx b/src/features/cumulative-recharge-reward/pages/CumulativeRechargeRewardPage.jsx index 53fd867..67820d6 100644 --- a/src/features/cumulative-recharge-reward/pages/CumulativeRechargeRewardPage.jsx +++ b/src/features/cumulative-recharge-reward/pages/CumulativeRechargeRewardPage.jsx @@ -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 ( -
- -
- {user.username || `用户 ${grant.userId}`} - - {user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${grant.userId}`} - -
-
- ); + return ; } function grantStatusLabel(status) { diff --git a/src/features/daily-tasks/api.ts b/src/features/daily-tasks/api.ts index 806fceb..405bbf6 100644 --- a/src/features/daily-tasks/api.ts +++ b/src/features/daily-tasks/api.ts @@ -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), diff --git a/src/features/daily-tasks/constants.js b/src/features/daily-tasks/constants.js index 04fbf1e..8e8ddf8 100644 --- a/src/features/daily-tasks/constants.js +++ b/src/features/daily-tasks/constants.js @@ -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", "发布心情"], +]; diff --git a/src/features/daily-tasks/daily-tasks.module.css b/src/features/daily-tasks/daily-tasks.module.css index a8b0972..eef791b 100644 --- a/src/features/daily-tasks/daily-tasks.module.css +++ b/src/features/daily-tasks/daily-tasks.module.css @@ -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; diff --git a/src/features/daily-tasks/hooks/useDailyTasksPage.js b/src/features/daily-tasks/hooks/useDailyTasksPage.js index f1e4332..0f7f939 100644 --- a/src/features/daily-tasks/hooks/useDailyTasksPage.js +++ b/src/features/daily-tasks/hooks/useDailyTasksPage.js @@ -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); diff --git a/src/features/daily-tasks/pages/DailyTaskListPage.jsx b/src/features/daily-tasks/pages/DailyTaskListPage.jsx index 6ca870e..12eacb8 100644 --- a/src/features/daily-tasks/pages/DailyTaskListPage.jsx +++ b/src/features/daily-tasks/pages/DailyTaskListPage.jsx @@ -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 = [
), }, + { + key: "display", + label: "属性 / 跳转", + width: "minmax(220px, 1fr)", + render: (task) => ( +
+ {taskAudienceLabel(task.audienceType)} + + {taskActionLabel(task.actionType)} + {task.actionParam ? `:${task.actionParam}` : ""} + +
+ ), + }, { key: "target", label: "目标 / 奖励", @@ -182,7 +201,7 @@ export function DailyTaskListPage() { 0 ? { @@ -214,6 +233,7 @@ export function DailyTaskListPage() { function TaskIdentity({ task }) { return (
+ {taskTypeLabel(task.taskType)}
{task.title || task.taskId} @@ -223,6 +243,15 @@ function TaskIdentity({ task }) { ); } +function TaskIconPreview({ task }) { + const label = String(task.iconKey || "任务").slice(0, 2); + return ( + + {task.iconUrl ? : label} + + ); +} + 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]) => ( + + {label} + + ))} + + + + page.setForm({ ...form, audienceType: event.target.value })} + > + {taskAudienceOptions.map(([value, label]) => ( + + {label} + + ))} + + page.setForm({ ...form, actionType: event.target.value })} + > + {taskActionOptions.map(([value, label]) => ( + + {label} + + ))} + + page.setForm({ ...form, actionParam: event.target.value })} + /> + page.setForm({ ...form, iconKey: event.target.value })} + /> + page.setForm({ ...form, iconUrl: event.target.value })} + /> + page.setForm({ ...form, dimensionFilterJson: event.target.value })} + /> + page.setForm({ ...form, actionPayloadJson: event.target.value })} + /> + + 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; @@ -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; + } +} diff --git a/src/features/first-recharge-reward/api.ts b/src/features/first-recharge-reward/api.ts index 84eda8a..2504bbc 100644 --- a/src/features/first-recharge-reward/api.ts +++ b/src/features/first-recharge-reward/api.ts @@ -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), }, diff --git a/src/features/first-recharge-reward/pages/FirstRechargeRewardPage.jsx b/src/features/first-recharge-reward/pages/FirstRechargeRewardPage.jsx index e2d436a..51c2e51 100644 --- a/src/features/first-recharge-reward/pages/FirstRechargeRewardPage.jsx +++ b/src/features/first-recharge-reward/pages/FirstRechargeRewardPage.jsx @@ -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 ( -
- -
- {user.username || `用户 ${claim.userId}`} - - {user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`} - -
-
- ); + return ; } function claimStatusLabel(status) { diff --git a/src/features/games/api.ts b/src/features/games/api.ts index b4647da..8e9972d 100644 --- a/src/features/games/api.ts +++ b/src/features/games/api.ts @@ -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; +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 { const endpoint = API_ENDPOINTS.listPlatforms; return apiRequest(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 { + const endpoint = API_ENDPOINTS.listRoomRpsChallenges; + return apiRequest(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 { return { appCode: stringValue(platform.appCode), @@ -372,6 +516,67 @@ function normalizeDiceRobot(robot: Partial): DiceRobotDto { }; } +function normalizeRoomRpsConfig(config: Partial): 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 { + 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 { + 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 { + 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); } diff --git a/src/features/games/games.module.css b/src/features/games/games.module.css index 9b4534f..6d50c33 100644 --- a/src/features/games/games.module.css +++ b/src/features/games/games.module.css @@ -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; diff --git a/src/features/games/pages/GameRobotPage.jsx b/src/features/games/pages/GameRobotPage.jsx index 807bd09..329082e 100644 --- a/src/features/games/pages/GameRobotPage.jsx +++ b/src/features/games/pages/GameRobotPage.jsx @@ -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) => ( 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() { load("", false)}> - setDialogOpen(true)}> + setDialogOpen(true)} + > } filters={ <> - setStatus(event.target.value)}> + setStatus(event.target.value)} + > 全部状态 启用 停用 @@ -244,7 +255,14 @@ export function GameRobotPage() { function GenerateDialog({ form, loading, open, setForm, onClose, onSubmit }) { return ( - + { + 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) => ( +
+ {item.challengeId} + {item.roomId || "-"} +
+ ), + }, + { + 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) => ( + + openDetail(item.challengeId)}> + + + retrySettlement(item.challengeId)} + > + + + expireChallenge(item.challengeId)} + > + + + + ), + }, + ]; + + 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 ; + } + + return ( + + + load("")}> + + + + + } + filters={ + <> + updateFilter({ roomId: event.target.value })} + /> + updateFilter({ status: value })} + /> + updateFilter({ initiatorUserId: event.target.value })} + /> + updateFilter({ challengerUserId: event.target.value })} + /> + + + } + /> + + item.challengeId} /> + + + load("")}> + + + load(nextCursor)} + > + + + + ) : null + } + /> + setDetailOpen(false)} /> + + ); +} + +function RoomRpsChallengeDetail({ challenge, open, onClose }) { + const closeBySubmit = (event) => { + event.preventDefault(); + onClose(); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +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"); +} diff --git a/src/features/games/pages/RoomRpsConfigPage.jsx b/src/features/games/pages/RoomRpsConfigPage.jsx new file mode 100644 index 0000000..7d8bb4b --- /dev/null +++ b/src/features/games/pages/RoomRpsConfigPage.jsx @@ -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) => ( +
+ 房内猜拳 + {item.gameId || "room_rps"} +
+ ), + }, + { + 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) => ( + + openEditor(item)}> + + + + ), + }, + ], + [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 ; + } + + return ( + + + +
+ } + /> + + item.gameId || "room_rps"} + /> + + setEditing(false)} + onSubmit={submit} + /> + + ); +} + +function RoomRpsConfigDialog({ form, loading, open, setForm, onClose, onSubmit }) { + return ( + + + + setForm({ ...form, status: event.target.value })} + > + 启用 + 停用 + + setForm({ ...form, challengeTimeoutMs: event.target.value })} + /> + setForm({ ...form, revealCountdownMs: event.target.value })} + /> + + + +
+ {form.stakeGifts.map((gift, index) => ( +
+ setGiftTier(setForm, form, index, { giftId: event.target.value })} + /> + + setGiftTier(setForm, form, index, { sortOrder: event.target.value }) + } + /> + setGiftTier(setForm, form, index, { enabled: checked })} + /> +
+ ))} +
+
+
+ ); +} + +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`; +} diff --git a/src/features/games/routes.js b/src/features/games/routes.js index d54a726..1193892 100644 --- a/src/features/games/routes.js +++ b/src/features/games/routes.js @@ -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, + }, ]; diff --git a/src/features/host-agency-policy/api.ts b/src/features/host-agency-policy/api.ts index 0349b85..371c728 100644 --- a/src/features/host-agency-policy/api.ts +++ b/src/features/host-agency-policy/api.ts @@ -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): 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), }; diff --git a/src/features/host-agency-policy/pages/HostSalarySettlementPage.jsx b/src/features/host-agency-policy/pages/HostSalarySettlementPage.jsx index b4d3efe..26aeb99 100644 --- a/src/features/host-agency-policy/pages/HostSalarySettlementPage.jsx +++ b/src/features/host-agency-policy/pages/HostSalarySettlementPage.jsx @@ -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={
- - - - - + + + + +
} @@ -502,10 +516,31 @@ function TeamRecordsPanel() { - { setTimeRange(value); setPage(1); }} /> - - - + { + setTimeRange(value); + setPage(1); + }} + /> + + +
} @@ -591,7 +626,9 @@ const teamPendingColumns = [ width: "minmax(180px, 0.8fr)", render: (item) => (
- {roleLabel(item.policyType)} · {item.cycleKey || "-"} + + {roleLabel(item.policyType)} · {item.cycleKey || "-"} + {item.regionName || `区域 ${item.regionId || "-"}`}
), @@ -652,7 +689,9 @@ const teamRecordColumns = [ render: (item) => (
{item.cycleKey || "-"} - {roleLabel(item.policyType)} · {triggerLabel(item.triggerMode)} + + {roleLabel(item.policyType)} · {triggerLabel(item.triggerMode)} +
), }, @@ -704,17 +743,7 @@ function UserCell({ fallbackId, user }) { if (!idText) { return "-"; } - return ( -
- - {user?.avatar ? : } - -
- {user?.username || "-"} - {idText} -
-
- ); + return ; } // 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 || + "-" ); } diff --git a/src/features/host-org/components/HostOrgIdentity.jsx b/src/features/host-org/components/HostOrgIdentity.jsx index dd432d6..2725039 100644 --- a/src/features/host-org/components/HostOrgIdentity.jsx +++ b/src/features/host-org/components/HostOrgIdentity.jsx @@ -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 ( -
- {avatar ? ( - - ) : ( - - {String(name || meta) - .slice(0, 1) - .toUpperCase()} - - )} - - {name} - {meta} - -
+ ); } diff --git a/src/features/host-org/pages/HostBdLeadersPage.jsx b/src/features/host-org/pages/HostBdLeadersPage.jsx index 2a6cb1d..b279e97 100644 --- a/src/features/host-org/pages/HostBdLeadersPage.jsx +++ b/src/features/host-org/pages/HostBdLeadersPage.jsx @@ -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 ( -
- {item.avatar ? ( - - ) : ( - - {String(name || meta) - .slice(0, 1) - .toUpperCase()} - - )} - - {name} - {meta} - -
- ); + return ; } function CreatorIdentity({ item }) { diff --git a/src/features/host-org/pages/HostCoinSellersPage.jsx b/src/features/host-org/pages/HostCoinSellersPage.jsx index 7d0b20f..af24c32 100644 --- a/src/features/host-org/pages/HostCoinSellersPage.jsx +++ b/src/features/host-org/pages/HostCoinSellersPage.jsx @@ -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 (
- +
@@ -314,7 +312,10 @@ export function HostCoinSellersPage() { onClose={page.closeAction} > {page.selectedSeller ? ( - + ) : null} @@ -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 })} />
page.updateRateTier(index, { enabled: event.target.checked })} + onChange={(event) => + page.updateRateTier(index, { enabled: event.target.checked }) + } />
- {item.avatar ? ( - - ) : ( - {name.slice(0, 1).toUpperCase()} - )} - - {name} - {meta} - -
- ); + return ; } function SellerStatusSwitch({ item, page }) { @@ -460,7 +446,11 @@ function SellerActions({ item, page }) { return ( {page.abilities.canLedger ? ( - page.openSellerLedger(item)}> + page.openSellerLedger(item)} + > ) : null} diff --git a/src/features/operations/components/CoinSellerLedgerTable.jsx b/src/features/operations/components/CoinSellerLedgerTable.jsx index ea610e7..29a101d 100644 --- a/src/features/operations/components/CoinSellerLedgerTable.jsx +++ b/src/features/operations/components/CoinSellerLedgerTable.jsx @@ -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={ <> - + } actions={ - } @@ -235,18 +232,7 @@ function OperatorCell({ entry }) { } function UserCell({ user = {} }) { - const name = user.username || "-"; - return ( -
- - {user.avatar ? : } - - - {name} - {userIdText(user)} - -
- ); + return ; } 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) { diff --git a/src/features/operations/pages/CoinAdjustmentPage.jsx b/src/features/operations/pages/CoinAdjustmentPage.jsx index b944b07..78f5f87 100644 --- a/src/features/operations/pages/CoinAdjustmentPage.jsx +++ b/src/features/operations/pages/CoinAdjustmentPage.jsx @@ -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 ( -
- - {user.avatar ? : } - - - {name} - {userIdText(user, item.userId)} - -
- ); + return ; } function TargetUserPreview({ user }) { - return ( -
- - {user.avatar ? : } - - - {user.username || "-"} - {userIdText(user, user.userId)} - -
- ); + return ; } 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 || "-"; diff --git a/src/features/operations/pages/CoinLedgerPage.jsx b/src/features/operations/pages/CoinLedgerPage.jsx index 11475ee..a36a961 100644 --- a/src/features/operations/pages/CoinLedgerPage.jsx +++ b/src/features/operations/pages/CoinLedgerPage.jsx @@ -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 ? ( <>
- - {user.avatar ? : } - -
-
-

{user.username || "-"}

- -
- {userIdText(entry)} -
+ +
- - {user.avatar ? : } - -
- {user.username || "-"} - {userIdText(entry)} -
-
- ); + return ; } 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); } diff --git a/src/features/operations/pages/ReportListPage.jsx b/src/features/operations/pages/ReportListPage.jsx index ef241d1..42f3efd 100644 --- a/src/features/operations/pages/ReportListPage.jsx +++ b/src/features/operations/pages/ReportListPage.jsx @@ -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 ( -
- - {user.avatar ? : } - - - {user.username || "-"} - - {targetMeta(user.displayUserId || user.defaultDisplayUserId, user.userId || report.userId)} - - -
- ); + return ; } function EvidenceImages({ imageUrls = [] }) { diff --git a/src/features/payment/pages/PaymentBillListPage.jsx b/src/features/payment/pages/PaymentBillListPage.jsx index bd28f4d..44ae657 100644 --- a/src/features/payment/pages/PaymentBillListPage.jsx +++ b/src/features/payment/pages/PaymentBillListPage.jsx @@ -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) => ( - + ), }, { @@ -315,19 +308,7 @@ function BillUser({ bill, type }) { return "-"; } - const shortId = profile.displayUserId || profile.userId || "-"; - const name = profile.username || "-"; - return ( -
- - {profile.avatar ? : } - - - {name} - {shortId} - -
- ); + return ; } 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 || "", }; diff --git a/src/features/registration-reward/api.ts b/src/features/registration-reward/api.ts index 3c3be2c..829246b 100644 --- a/src/features/registration-reward/api.ts +++ b/src/features/registration-reward/api.ts @@ -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), }, diff --git a/src/features/registration-reward/pages/RegistrationRewardPage.jsx b/src/features/registration-reward/pages/RegistrationRewardPage.jsx index a17d5d4..ec9f2ba 100644 --- a/src/features/registration-reward/pages/RegistrationRewardPage.jsx +++ b/src/features/registration-reward/pages/RegistrationRewardPage.jsx @@ -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 ( -
- -
- {displayUserId ? `${name} (${displayUserId})` : name} - {displayUserId ? null : ID {claim.userId}} -
-
- ); + return ; } function RewardSummary({ claim }) { diff --git a/src/features/registration-reward/pages/RegistrationRewardPage.test.jsx b/src/features/registration-reward/pages/RegistrationRewardPage.test.jsx index 5cd46f1..ccc2437 100644 --- a/src/features/registration-reward/pages/RegistrationRewardPage.test.jsx +++ b/src/features/registration-reward/pages/RegistrationRewardPage.test.jsx @@ -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(); diff --git a/src/features/resources/api.ts b/src/features/resources/api.ts index df290ab..6f86628 100644 --- a/src/features/resources/api.ts +++ b/src/features/resources/api.ts @@ -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; } diff --git a/src/features/resources/pages/ResourceGrantListPage.jsx b/src/features/resources/pages/ResourceGrantListPage.jsx index e8acbdd..fa8040e 100644 --- a/src/features/resources/pages/ResourceGrantListPage.jsx +++ b/src/features/resources/pages/ResourceGrantListPage.jsx @@ -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 }) + } /> - - {user.avatar ? : } - - - {name} - 长ID {userId || "-"} - {displayUserId ? 短ID {displayUserId} : null} - -
+ ); } @@ -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 ( -
- - {operator.avatar ? ( - - ) : ( - - )} - - - {name} - {shortId} - -
+ ); } if (source === "admin") { diff --git a/src/features/rooms/components/RoomDetailDrawer.jsx b/src/features/rooms/components/RoomDetailDrawer.jsx index 1f23275..45b32de 100644 --- a/src/features/rooms/components/RoomDetailDrawer.jsx +++ b/src/features/rooms/components/RoomDetailDrawer.jsx @@ -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 ( - - {room ? ( - <> -
- - {room.coverUrl ? : } - -
-
-

{room.title || "-"}

- -
- -
-
+ return ( + + {room ? ( + <> +
+ + {room.coverUrl ? ( + + ) : ( + + )} + +
+
+

{room.title || "-"}

+ +
+ +
+
- ], - ["房间状态", roomStatusLabels[normalizedStatus]], - ["房间模式", room.mode], - ["区域", room.regionName], - ["创建时间", ], - ["更新时间", ] - ]} - title="基础信息" - /> + ], + ["房间状态", roomStatusLabels[normalizedStatus]], + ["房间模式", room.mode], + ["区域", room.regionName], + ["创建时间", ], + ["更新时间", ], + ]} + title="基础信息" + /> - {normalizedStatus === "closed" ? ( - ], - ["关闭原因", room.closeReason] - ]} - title="关闭信息" - /> - ) : null} + {normalizedStatus === "closed" ? ( + ], + ["关闭原因", room.closeReason], + ]} + title="关闭信息" + /> + ) : null} -
-

房主信息

-
- - {owner.avatar ? : } - -
- {owner.username || "-"} - {ownerDisplayId} -
-
-
- -
-
+
+

房主信息

+
+ +
+
+ + + + ) : ( + "" + ) + } + /> +
+
- - - ) : null} -
- ); + + + ) : null} +
+ ); } function DetailSection({ items, title }) { - return ( -
-

{title}

-
- {items.map(([label, value]) => ( - - ))} -
-
- ); + return ( +
+

{title}

+
+ {items.map(([label, value]) => ( + + ))} +
+
+ ); } function DetailItem({ label, value }) { - return ( -
- {label} -
{displayValue(value)}
-
- ); + return ( +
+ {label} +
{displayValue(value)}
+
+ ); } 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 ( - - ); + return ( + + ); } function StatusBadge({ status }) { - const tone = status === "active" ? "running" : "stopped"; + const tone = status === "active" ? "running" : "stopped"; - return ( - - - {roomStatusLabels[status]} - - ); + return ( + + + {roomStatusLabels[status]} + + ); } 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(); } diff --git a/src/features/rooms/pages/RoomListPage.jsx b/src/features/rooms/pages/RoomListPage.jsx index 1597fe5..29e39c8 100644 --- a/src/features/rooms/pages/RoomListPage.jsx +++ b/src/features/rooms/pages/RoomListPage.jsx @@ -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) => 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 })} /> - ); } @@ -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 ( -
- - {owner.avatar ? : } - -
-
{ownerName}
-
{ownerDisplayId}
-
-
- ); + return ; } function RoomActions({ page, room }) { @@ -344,12 +330,3 @@ function ContributionSortHeader({ direction, onClick }) { ); } - -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 || "-"; -} diff --git a/src/features/rooms/pages/RoomPinsPage.jsx b/src/features/rooms/pages/RoomPinsPage.jsx index 9b9fd87..64cfd0a 100644 --- a/src/features/rooms/pages/RoomPinsPage.jsx +++ b/src/features/rooms/pages/RoomPinsPage.jsx @@ -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() { {page.abilities.canPinCreate ? (
-
@@ -268,18 +272,7 @@ function PinRoomIdentity({ room = {} }) { } function PinUserIdentity({ user = {} }) { - return ( -
- - {user.avatar ? : } - -
-
{user.username || "-"}
-
长ID {user.userId || "-"}
-
短ID {user.displayUserId || "-"}
-
-
- ); + return ; } function RoomPinActions({ page, pin }) { diff --git a/src/features/seven-day-checkin/api.ts b/src/features/seven-day-checkin/api.ts index 841e923..e3a369b 100644 --- a/src/features/seven-day-checkin/api.ts +++ b/src/features/seven-day-checkin/api.ts @@ -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), }, diff --git a/src/features/seven-day-checkin/pages/SevenDayCheckInPage.jsx b/src/features/seven-day-checkin/pages/SevenDayCheckInPage.jsx index 36deeab..d5584a2 100644 --- a/src/features/seven-day-checkin/pages/SevenDayCheckInPage.jsx +++ b/src/features/seven-day-checkin/pages/SevenDayCheckInPage.jsx @@ -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 ( -
- -
- {user.username || `用户 ${claim.userId}`} - - {user.displayUserId ? `短号 ${user.displayUserId}` : `ID ${claim.userId}`} - -
-
- ); + return ; } function ResourceGroupLabel({ groups, id }) { diff --git a/src/features/user-leaderboard/api.ts b/src/features/user-leaderboard/api.ts index 304fedc..34d35b1 100644 --- a/src/features/user-leaderboard/api.ts +++ b/src/features/user-leaderboard/api.ts @@ -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), }; diff --git a/src/features/user-leaderboard/pages/UserLeaderboardPage.jsx b/src/features/user-leaderboard/pages/UserLeaderboardPage.jsx index ea430f8..7136ba8 100644 --- a/src/features/user-leaderboard/pages/UserLeaderboardPage.jsx +++ b/src/features/user-leaderboard/pages/UserLeaderboardPage.jsx @@ -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 ( -
- - {user.avatar ? : } - - - {displayName} - 短ID {shortId || "-"} - -
- ); + return ; } function formatNumber(value) { diff --git a/src/shared/api/generated/endpoints.ts b/src/shared/api/generated/endpoints.ts index 1dcb329..b813915 100644 --- a/src/shared/api/generated/endpoints.ts +++ b/src/shared/api/generated/endpoints.ts @@ -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 = { 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 = { 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 = { 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 = { 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 = { 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, diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 3c30c69..bccf508 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -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; diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index dace89d..53de7e6 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -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; } diff --git a/src/shared/ui/AdminUserIdentity.jsx b/src/shared/ui/AdminUserIdentity.jsx new file mode 100644 index 0000000..c67217b --- /dev/null +++ b/src/shared/ui/AdminUserIdentity.jsx @@ -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 ( + + + {normalized.avatar ? ( + + ) : ( + + )} + +
+
+ {normalized.displayName} +
+ {visibleRows.length ? ( +
+ {visibleRows[0]} + {normalized.hasPretty ? ( + + ) : null} +
+ ) : normalized.hasPretty ? ( +
+ +
+ ) : null} + {visibleRows.slice(1).map((row, index) => ( + + {row} + + ))} +
+
+ ); +} + +export function AdminPrettyDisplayID({ prettyDisplayUserId, prettyId }) { + const prettyValue = String(prettyDisplayUserId || "").trim(); + const recordId = String(prettyId || "").trim(); + if (!prettyValue || !recordId) { + return null; + } + + return ( + + {prettyValue} + + ); +} + +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 ""; +} diff --git a/src/shared/ui/AdminUserIdentity.module.css b/src/shared/ui/AdminUserIdentity.module.css new file mode 100644 index 0000000..3a0c4ca --- /dev/null +++ b/src/shared/ui/AdminUserIdentity.module.css @@ -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); + } +} diff --git a/src/shared/ui/AdminUserIdentity.test.jsx b/src/shared/ui/AdminUserIdentity.test.jsx new file mode 100644 index 0000000..3be1e1f --- /dev/null +++ b/src/shared/ui/AdminUserIdentity.test.jsx @@ -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( + , + ); + + 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(); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + + + , + ); + + 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"); +});