Compare commits
12 Commits
05412e477a
...
2125f81baf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2125f81baf | ||
|
|
9405e2b7d6 | ||
|
|
88ef541b95 | ||
|
|
e51a4401ef | ||
|
|
bc9484c60d | ||
|
|
b6d8af0fa3 | ||
|
|
dae91e892e | ||
|
|
4166fe0786 | ||
|
|
2215b01743 | ||
|
|
6ab21a8baa | ||
|
|
81fa3a25bb | ||
|
|
889e75f6a1 |
@ -178,6 +178,40 @@
|
|||||||
"x-permissions": ["cp-config:view"]
|
"x-permissions": ["cp-config:view"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/activity/cp-weekly-rank/config": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getCPWeeklyRankConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "cp-weekly-rank:view",
|
||||||
|
"x-permissions": ["cp-weekly-rank:view"]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "updateCPWeeklyRankConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "cp-weekly-rank:update",
|
||||||
|
"x-permissions": ["cp-weekly-rank:update"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/activity/cp-weekly-rank/settlements": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listCPWeeklyRankSettlements",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "cp-weekly-rank:view",
|
||||||
|
"x-permissions": ["cp-weekly-rank:view"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/activity/red-packets": {
|
"/admin/activity/red-packets": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listRedPackets",
|
"operationId": "listRedPackets",
|
||||||
@ -1488,6 +1522,28 @@
|
|||||||
"x-permissions": ["coin-seller:stock-credit"]
|
"x-permissions": ["coin-seller:stock-credit"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/coin-sellers/{user_id}/stock-debits": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "debitCoinSellerStock",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "user_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-permission": "coin-seller:stock-credit",
|
||||||
|
"x-permissions": ["coin-seller:stock-credit"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/coin-seller-salary-rates/{region_id}": {
|
"/admin/coin-seller-salary-rates/{region_id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getCoinSellerSalaryRates",
|
"operationId": "getCoinSellerSalaryRates",
|
||||||
@ -2615,6 +2671,40 @@
|
|||||||
"x-permissions": ["payment-product:delete"]
|
"x-permissions": ["payment-product:delete"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/payment/temporary-links": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "listTemporaryPaymentLinks",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "payment-temporary-link:view",
|
||||||
|
"x-permissions": ["payment-temporary-link:view"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/admin/payment/temporary-links/{order_id}": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getTemporaryPaymentLink",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "order_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"x-permission": "payment-temporary-link:view",
|
||||||
|
"x-permissions": ["payment-temporary-link:view"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/payment/third-party-channels": {
|
"/admin/payment/third-party-channels": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listThirdPartyPaymentChannels",
|
"operationId": "listThirdPartyPaymentChannels",
|
||||||
@ -2674,6 +2764,24 @@
|
|||||||
"/admin/payment/third-party-rates/sync": {
|
"/admin/payment/third-party-rates/sync": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "syncThirdPartyPaymentRates",
|
"operationId": "syncThirdPartyPaymentRates",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["markupPercent"],
|
||||||
|
"properties": {
|
||||||
|
"markupPercent": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"$ref": "#/components/responses/EmptyResponse"
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
@ -2835,6 +2943,28 @@
|
|||||||
"x-permissions": ["resource-grant:create"]
|
"x-permissions": ["resource-grant:create"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/resource-grants/{grant_id}/revoke": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "revokeResourceGrant",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "grant_id",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "resource-grant:revoke",
|
||||||
|
"x-permissions": ["resource-grant:revoke"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/resource-grants/resource": {
|
"/admin/resource-grants/resource": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "grantResource",
|
"operationId": "grantResource",
|
||||||
@ -2937,6 +3067,28 @@
|
|||||||
"x-permissions": ["resource-shop:update"]
|
"x-permissions": ["resource-shop:update"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/resource-groups/identity-auto-grant-config": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getResourceIdentityAutoGrantConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "resource-group:view",
|
||||||
|
"x-permissions": ["resource-group:view"]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "updateResourceIdentityAutoGrantConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "resource-group:update",
|
||||||
|
"x-permissions": ["resource-group:update"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/admin/resource-groups": {
|
"/admin/resource-groups": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "listResourceGroups",
|
"operationId": "listResourceGroups",
|
||||||
@ -3546,6 +3698,44 @@
|
|||||||
"x-permissions": ["region-block:update"]
|
"x-permissions": ["region-block:update"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/admin/users/risk-config": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getRiskConfig",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "risk-config:view",
|
||||||
|
"x-permissions": ["risk-config:view"]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "updateRiskConfig",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["max_accounts_per_device"],
|
||||||
|
"properties": {
|
||||||
|
"max_accounts_per_device": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"$ref": "#/components/responses/EmptyResponse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-permission": "risk-config:update",
|
||||||
|
"x-permissions": ["risk-config:update"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/app/users": {
|
"/app/users": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "appListUsers",
|
"operationId": "appListUsers",
|
||||||
|
|||||||
@ -110,7 +110,8 @@ export function DatabiApp() {
|
|||||||
regionId,
|
regionId,
|
||||||
seriesEndMs: rangeEndMs(seriesRange, timeZone),
|
seriesEndMs: rangeEndMs(seriesRange, timeZone),
|
||||||
seriesStartMs: rangeStartMs(seriesRange, timeZone),
|
seriesStartMs: rangeStartMs(seriesRange, timeZone),
|
||||||
startMs
|
startMs,
|
||||||
|
statTz: timeZone
|
||||||
});
|
});
|
||||||
const nextOverview = enrichCountryBreakdown(data || {}, scopedCountries);
|
const nextOverview = enrichCountryBreakdown(data || {}, scopedCountries);
|
||||||
if (requestId === overviewRequestIdRef.current) {
|
if (requestId === overviewRequestIdRef.current) {
|
||||||
@ -146,7 +147,8 @@ export function DatabiApp() {
|
|||||||
endMs: rangeEndMs(range, timeZone),
|
endMs: rangeEndMs(range, timeZone),
|
||||||
gameId,
|
gameId,
|
||||||
regionId,
|
regionId,
|
||||||
startMs: rangeStartMs(range, timeZone)
|
startMs: rangeStartMs(range, timeZone),
|
||||||
|
statTz: timeZone
|
||||||
});
|
});
|
||||||
if (requestId === selfGameRequestIdRef.current) {
|
if (requestId === selfGameRequestIdRef.current) {
|
||||||
setSelfGameOverview(data || {});
|
setSelfGameOverview(data || {});
|
||||||
|
|||||||
@ -53,6 +53,22 @@ test("keeps current data visible during scheduled refresh", async () => {
|
|||||||
expect(document.querySelector(".metric-card.is-loading")).toBeNull();
|
expect(document.querySelector(".metric-card.is-loading")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("queries China natural day statistics when display timezone is Beijing", async () => {
|
||||||
|
fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 });
|
||||||
|
|
||||||
|
render(<DatabiApp />);
|
||||||
|
|
||||||
|
await flushEffects();
|
||||||
|
|
||||||
|
expect(fetchStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
endMs: Date.UTC(2026, 5, 6, 15, 59, 59),
|
||||||
|
seriesEndMs: Date.UTC(2026, 5, 6, 15, 59, 59),
|
||||||
|
seriesStartMs: Date.UTC(2026, 4, 30, 16, 0, 0),
|
||||||
|
startMs: Date.UTC(2026, 5, 5, 16, 0, 0),
|
||||||
|
statTz: "Asia/Shanghai"
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
test("loads self game statistics from the big screen switch", async () => {
|
test("loads self game statistics from the big screen switch", async () => {
|
||||||
fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 });
|
fetchStatisticsOverview.mockResolvedValue({ active_users: 10, paid_users: 2, recharge_usd_minor: 100, updated_at_ms: 1 });
|
||||||
fetchSelfGameStatisticsOverview.mockResolvedValue({
|
fetchSelfGameStatisticsOverview.mockResolvedValue({
|
||||||
@ -69,7 +85,7 @@ test("loads self game statistics from the big screen switch", async () => {
|
|||||||
});
|
});
|
||||||
await flushEffects();
|
await flushEffects();
|
||||||
|
|
||||||
expect(fetchSelfGameStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({ appCode: "lalu", gameId: "all" }));
|
expect(fetchSelfGameStatisticsOverview).toHaveBeenCalledWith(expect.objectContaining({ appCode: "lalu", gameId: "all", statTz: "Asia/Shanghai" }));
|
||||||
expect(screen.getByText("Dice / Rock H5 聚合统计")).toBeTruthy();
|
expect(screen.getByText("Dice / Rock H5 聚合统计")).toBeTruthy();
|
||||||
expect(screen.getByText("创建局")).toBeTruthy();
|
expect(screen.getByText("创建局")).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -16,8 +16,11 @@ export function setCurrentAppCode(value) {
|
|||||||
return appCode;
|
return appCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, seriesEndMs, seriesStartMs, startMs }) {
|
export async function fetchStatisticsOverview({ appCode, countryId, endMs, regionId, seriesEndMs, seriesStartMs, startMs, statTz }) {
|
||||||
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
|
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
|
||||||
|
if (statTz) {
|
||||||
|
query.stat_tz = statTz;
|
||||||
|
}
|
||||||
if (startMs) {
|
if (startMs) {
|
||||||
query.start_ms = String(startMs);
|
query.start_ms = String(startMs);
|
||||||
}
|
}
|
||||||
@ -40,8 +43,11 @@ export async function fetchStatisticsOverview({ appCode, countryId, endMs, regio
|
|||||||
return fetchDatabiData("/v1/statistics/overview", { appCode, query });
|
return fetchDatabiData("/v1/statistics/overview", { appCode, query });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSelfGameStatisticsOverview({ appCode, countryId, endMs, gameId, regionId, startMs }) {
|
export async function fetchSelfGameStatisticsOverview({ appCode, countryId, endMs, gameId, regionId, startMs, statTz }) {
|
||||||
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
|
const query = { app_code: normalizeAppCode(appCode) || "lalu" };
|
||||||
|
if (statTz) {
|
||||||
|
query.stat_tz = statTz;
|
||||||
|
}
|
||||||
if (startMs) {
|
if (startMs) {
|
||||||
query.start_ms = String(startMs);
|
query.start_ms = String(startMs);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,7 @@ const defaultSortByTab = {
|
|||||||
|
|
||||||
const countryColumns = [
|
const countryColumns = [
|
||||||
{ key: "country", label: "国家", type: "text", align: "left" },
|
{ key: "country", label: "国家", type: "text", align: "left" },
|
||||||
{ key: "visitors", label: "访客" },
|
{ key: "registrations", label: "注册" },
|
||||||
{ key: "active_users", label: "活跃用户" },
|
{ key: "active_users", label: "活跃用户" },
|
||||||
{ key: "paid_users", label: "付费用户" },
|
{ key: "paid_users", label: "付费用户" },
|
||||||
{ key: "recharge_users", label: "充值用户" },
|
{ key: "recharge_users", label: "充值用户" },
|
||||||
@ -44,8 +44,8 @@ const countrySortAccessors = {
|
|||||||
recharge_conversion_rate: (item) => item.recharge_conversion_rate,
|
recharge_conversion_rate: (item) => item.recharge_conversion_rate,
|
||||||
recharge_users: (item) => item.recharge_users,
|
recharge_users: (item) => item.recharge_users,
|
||||||
recharge_usd_minor: (item) => item.recharge_usd_minor,
|
recharge_usd_minor: (item) => item.recharge_usd_minor,
|
||||||
trend_rate: (item) => item.trend_rate,
|
registrations: (item) => item.registrations,
|
||||||
visitors: (item) => item.visitors
|
trend_rate: (item) => item.trend_rate
|
||||||
};
|
};
|
||||||
|
|
||||||
const giftColumns = [
|
const giftColumns = [
|
||||||
@ -146,7 +146,7 @@ function CountryTable({ items, loading, onSort, sortState }) {
|
|||||||
<tr key={item.country}>
|
<tr key={item.country}>
|
||||||
<td>{index + 1}</td>
|
<td>{index + 1}</td>
|
||||||
<td>{item.flag ? <i className="table-flag">{item.flag}</i> : null}{item.country}</td>
|
<td>{item.flag ? <i className="table-flag">{item.flag}</i> : null}{item.country}</td>
|
||||||
<td>{formatNumber(item.visitors)}</td>
|
<td>{formatNumber(item.registrations)}</td>
|
||||||
<td>{formatNumber(item.active_users)}</td>
|
<td>{formatNumber(item.active_users)}</td>
|
||||||
<td>{formatNumber(item.paid_users)}</td>
|
<td>{formatNumber(item.paid_users)}</td>
|
||||||
<td>{formatNumber(item.recharge_users)}</td>
|
<td>{formatNumber(item.recharge_users)}</td>
|
||||||
|
|||||||
@ -8,19 +8,19 @@ const countryItems = [
|
|||||||
country: "阿富汗",
|
country: "阿富汗",
|
||||||
flag: "🇦🇫",
|
flag: "🇦🇫",
|
||||||
recharge_usd_minor: 50000,
|
recharge_usd_minor: 50000,
|
||||||
visitors: 10
|
registrations: 10
|
||||||
}),
|
}),
|
||||||
createCountryRow({
|
createCountryRow({
|
||||||
country: "巴西",
|
country: "巴西",
|
||||||
flag: "🇧🇷",
|
flag: "🇧🇷",
|
||||||
recharge_usd_minor: 10000,
|
recharge_usd_minor: 10000,
|
||||||
visitors: 30
|
registrations: 30
|
||||||
}),
|
}),
|
||||||
createCountryRow({
|
createCountryRow({
|
||||||
country: "中国",
|
country: "中国",
|
||||||
flag: "🇨🇳",
|
flag: "🇨🇳",
|
||||||
recharge_usd_minor: 30000,
|
recharge_usd_minor: 30000,
|
||||||
visitors: 20
|
registrations: 20
|
||||||
})
|
})
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -31,14 +31,14 @@ test("sorts country table headers ascending and descending", async () => {
|
|||||||
|
|
||||||
expect(firstCountryCell()).toHaveTextContent("阿富汗");
|
expect(firstCountryCell()).toHaveTextContent("阿富汗");
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /访客/ }));
|
await user.click(screen.getByRole("button", { name: /注册/ }));
|
||||||
|
|
||||||
expect(screen.getByRole("columnheader", { name: /访客/ })).toHaveAttribute("aria-sort", "descending");
|
expect(screen.getByRole("columnheader", { name: /注册/ })).toHaveAttribute("aria-sort", "descending");
|
||||||
expect(firstCountryCell()).toHaveTextContent("巴西");
|
expect(firstCountryCell()).toHaveTextContent("巴西");
|
||||||
|
|
||||||
await user.click(screen.getByRole("button", { name: /访客/ }));
|
await user.click(screen.getByRole("button", { name: /注册/ }));
|
||||||
|
|
||||||
expect(screen.getByRole("columnheader", { name: /访客/ })).toHaveAttribute("aria-sort", "ascending");
|
expect(screen.getByRole("columnheader", { name: /注册/ })).toHaveAttribute("aria-sort", "ascending");
|
||||||
expect(firstCountryCell()).toHaveTextContent("阿富汗");
|
expect(firstCountryCell()).toHaveTextContent("阿富汗");
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -56,8 +56,8 @@ function createCountryRow(overrides) {
|
|||||||
recharge_conversion_rate: 0,
|
recharge_conversion_rate: 0,
|
||||||
recharge_users: 0,
|
recharge_users: 0,
|
||||||
recharge_usd_minor: 0,
|
recharge_usd_minor: 0,
|
||||||
|
registrations: 0,
|
||||||
trend_rate: 0,
|
trend_rate: 0,
|
||||||
visitors: 0,
|
|
||||||
...overrides
|
...overrides
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ export function createDashboardModel(overview, { appCode, countryId, range, time
|
|||||||
const activeUsers = readNumber(source, "active_users", "activeUsers");
|
const activeUsers = readNumber(source, "active_users", "activeUsers");
|
||||||
const paidUsers = readNumber(source, "paid_users", "paidUsers");
|
const paidUsers = readNumber(source, "paid_users", "paidUsers");
|
||||||
const rechargeUsers = readNumber(source, "recharge_users", "rechargeUsers", "paid_users", "paidUsers");
|
const rechargeUsers = readNumber(source, "recharge_users", "rechargeUsers", "paid_users", "paidUsers");
|
||||||
const newUsers = readNumber(source, "new_users", "newUsers", "visitors");
|
const newUsers = readNumber(source, "new_users", "newUsers", "registrations", "registered_users", "registeredUsers");
|
||||||
const arpu = readNumber(source, "arpu_usd_minor", "arpuUsdMinor");
|
const arpu = readNumber(source, "arpu_usd_minor", "arpuUsdMinor");
|
||||||
const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor");
|
const arppu = readNumber(source, "arppu_usd_minor", "arppuUsdMinor");
|
||||||
const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent");
|
const giftCoinSpent = readNumber(source, "gift_coin_spent", "giftCoinSpent");
|
||||||
@ -257,8 +257,8 @@ function normalizeCountryRow(item, index, totalRecharge) {
|
|||||||
: ratio(rechargeUsers, activeUsers),
|
: ratio(rechargeUsers, activeUsers),
|
||||||
recharge_users: rechargeUsers,
|
recharge_users: rechargeUsers,
|
||||||
share: recharge / totalRecharge,
|
share: recharge / totalRecharge,
|
||||||
trend_rate: numberValue(item.trend_rate ?? item.trendRate),
|
registrations: numberValue(item.new_users ?? item.newUsers ?? item.registrations ?? item.registered_users ?? item.registeredUsers),
|
||||||
visitors: numberValue(item.visitors ?? item.new_users ?? item.newUsers)
|
trend_rate: numberValue(item.trend_rate ?? item.trendRate)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -107,6 +107,23 @@ test("uses active users as payer and recharge conversion denominator", () => {
|
|||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("uses registration count instead of legacy visitors in country breakdown", () => {
|
||||||
|
const model = createDashboardModel({
|
||||||
|
country_breakdown: [
|
||||||
|
{
|
||||||
|
country: "菲律宾",
|
||||||
|
new_users: 12,
|
||||||
|
visitors: 99
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}, { appCode: "lalu", countryId: 0, range: { end: "2026-06-04", start: "2026-06-04" } });
|
||||||
|
|
||||||
|
expect(model.countryBreakdown[0]).toEqual(expect.objectContaining({
|
||||||
|
registrations: 12
|
||||||
|
}));
|
||||||
|
expect(model.countryBreakdown[0]).not.toHaveProperty("visitors");
|
||||||
|
});
|
||||||
|
|
||||||
function metricValue(model, label) {
|
function metricValue(model, label) {
|
||||||
return [...model.kpis, ...model.businessKpis, ...model.robotGiftKpis].find((item) => item.label === label)?.value;
|
return [...model.kpis, ...model.businessKpis, ...model.robotGiftKpis].find((item) => item.label === label)?.value;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -122,6 +122,65 @@ test("renders cp config route with an authenticated session", async () => {
|
|||||||
expect(screen.queryByText("共 1 条")).not.toBeInTheDocument();
|
expect(screen.queryByText("共 1 条")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders cp weekly rank route with an authenticated session", async () => {
|
||||||
|
setAccessToken("test-token");
|
||||||
|
vi.mocked(fetch).mockImplementation(async (input) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes("/v1/auth/refresh") || url.includes("/v1/auth/me")) {
|
||||||
|
return jsonResponse({
|
||||||
|
accessToken: "test-token",
|
||||||
|
permissions: ["cp-weekly-rank:view"],
|
||||||
|
user: { userId: 1, username: "admin" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/v1/admin/apps")) {
|
||||||
|
return jsonResponse({ items: [{ appCode: "lalu", name: "Lalu" }], total: 1 });
|
||||||
|
}
|
||||||
|
if (url.includes("/v1/admin/navigation/menus")) {
|
||||||
|
return jsonResponse([]);
|
||||||
|
}
|
||||||
|
if (url.includes("/v1/admin/resource-groups")) {
|
||||||
|
return jsonResponse({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||||
|
}
|
||||||
|
if (url.includes("/v1/admin/activity/cp-weekly-rank/config")) {
|
||||||
|
return jsonResponse({
|
||||||
|
activityCode: "cp_weekly_rank",
|
||||||
|
enabled: true,
|
||||||
|
relationType: "cp",
|
||||||
|
rewards: [
|
||||||
|
{ rankNo: 1, resourceGroupId: 1001 },
|
||||||
|
{ rankNo: 2, resourceGroupId: 1002 },
|
||||||
|
{ rankNo: 3, resourceGroupId: 1003 },
|
||||||
|
],
|
||||||
|
topCount: 3,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (url.includes("/v1/admin/activity/cp-weekly-rank/settlements")) {
|
||||||
|
return jsonResponse({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
periodStartMs: 1760918400000,
|
||||||
|
periodEndMs: 1761523200000,
|
||||||
|
rankNo: 1,
|
||||||
|
relationshipId: "cp_1001_1002",
|
||||||
|
score: 12000,
|
||||||
|
settlementId: "settlement_1",
|
||||||
|
status: "granted",
|
||||||
|
userId: "1001",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jsonResponse(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithRoute("/activities/cp-weekly-rank");
|
||||||
|
|
||||||
|
expect((await screen.findAllByText("CP排行活动")).length).toBeGreaterThan(0);
|
||||||
|
expect(await screen.findByText("已发放")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test("admin routes declare menu code and permission", () => {
|
test("admin routes declare menu code and permission", () => {
|
||||||
expect(adminRoutes.length).toBeGreaterThan(0);
|
expect(adminRoutes.length).toBeGreaterThan(0);
|
||||||
expect(adminRoutes.every((route) => route.menuCode && route.permission)).toBe(true);
|
expect(adminRoutes.every((route) => route.menuCode && route.permission)).toBe(true);
|
||||||
|
|||||||
@ -115,6 +115,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("app-user-bans", { icon: BlockOutlined }),
|
routeNavItem("app-user-bans", { icon: BlockOutlined }),
|
||||||
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
|
routeNavItem("app-user-login-logs", { icon: LoginOutlined }),
|
||||||
routeNavItem("app-user-pretty-ids", { icon: LocalOfferOutlined }),
|
routeNavItem("app-user-pretty-ids", { icon: LocalOfferOutlined }),
|
||||||
|
routeNavItem("app-user-risk-config", { icon: ShieldOutlined }),
|
||||||
routeNavItem("app-user-region-blocks", { icon: PublicOutlined }),
|
routeNavItem("app-user-region-blocks", { icon: PublicOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -198,6 +199,7 @@ export const fallbackNavigation = [
|
|||||||
children: [
|
children: [
|
||||||
routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined }),
|
routeNavItem("payment-bill-list", { icon: ReceiptLongOutlined }),
|
||||||
routeNavItem("payment-third-party", { icon: WalletOutlined }),
|
routeNavItem("payment-third-party", { icon: WalletOutlined }),
|
||||||
|
routeNavItem("payment-temporary-links", { icon: ReceiptLongOutlined }),
|
||||||
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
routeNavItem("payment-recharge-products", { icon: WalletOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -221,6 +223,7 @@ export const fallbackNavigation = [
|
|||||||
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
routeNavItem("user-leaderboard", { icon: LeaderboardOutlined }),
|
||||||
routeNavItem("red-packet", { icon: RedeemOutlined }),
|
routeNavItem("red-packet", { icon: RedeemOutlined }),
|
||||||
routeNavItem("cp-config", { icon: FavoriteBorderOutlined }),
|
routeNavItem("cp-config", { icon: FavoriteBorderOutlined }),
|
||||||
|
routeNavItem("cp-weekly-rank", { icon: LeaderboardOutlined }),
|
||||||
routeNavItem("vip-config", { icon: WorkspacePremiumOutlined }),
|
routeNavItem("vip-config", { icon: WorkspacePremiumOutlined }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -52,6 +52,7 @@ export const PERMISSIONS = {
|
|||||||
paymentProductCreate: "payment-product:create",
|
paymentProductCreate: "payment-product:create",
|
||||||
paymentProductUpdate: "payment-product:update",
|
paymentProductUpdate: "payment-product:update",
|
||||||
paymentProductDelete: "payment-product:delete",
|
paymentProductDelete: "payment-product:delete",
|
||||||
|
paymentTemporaryLinkView: "payment-temporary-link:view",
|
||||||
paymentThirdPartyView: "payment-third-party:view",
|
paymentThirdPartyView: "payment-third-party:view",
|
||||||
paymentThirdPartyUpdate: "payment-third-party:update",
|
paymentThirdPartyUpdate: "payment-third-party:update",
|
||||||
gameView: "game:view",
|
gameView: "game:view",
|
||||||
@ -89,6 +90,8 @@ export const PERMISSIONS = {
|
|||||||
prettyIdGrant: "pretty-id:grant",
|
prettyIdGrant: "pretty-id:grant",
|
||||||
levelConfigView: "level-config:view",
|
levelConfigView: "level-config:view",
|
||||||
levelConfigUpdate: "level-config:update",
|
levelConfigUpdate: "level-config:update",
|
||||||
|
riskConfigView: "risk-config:view",
|
||||||
|
riskConfigUpdate: "risk-config:update",
|
||||||
regionBlockView: "region-block:view",
|
regionBlockView: "region-block:view",
|
||||||
regionBlockUpdate: "region-block:update",
|
regionBlockUpdate: "region-block:update",
|
||||||
roomView: "room:view",
|
roomView: "room:view",
|
||||||
@ -120,6 +123,7 @@ export const PERMISSIONS = {
|
|||||||
resourceGroupUpdate: "resource-group:update",
|
resourceGroupUpdate: "resource-group:update",
|
||||||
resourceGrantView: "resource-grant:view",
|
resourceGrantView: "resource-grant:view",
|
||||||
resourceGrantCreate: "resource-grant:create",
|
resourceGrantCreate: "resource-grant:create",
|
||||||
|
resourceGrantRevoke: "resource-grant:revoke",
|
||||||
giftView: "gift:view",
|
giftView: "gift:view",
|
||||||
giftCreate: "gift:create",
|
giftCreate: "gift:create",
|
||||||
giftUpdate: "gift:update",
|
giftUpdate: "gift:update",
|
||||||
@ -158,6 +162,8 @@ export const PERMISSIONS = {
|
|||||||
redPacketUpdate: "red-packet:update",
|
redPacketUpdate: "red-packet:update",
|
||||||
cpConfigView: "cp-config:view",
|
cpConfigView: "cp-config:view",
|
||||||
cpConfigUpdate: "cp-config:update",
|
cpConfigUpdate: "cp-config:update",
|
||||||
|
cpWeeklyRankView: "cp-weekly-rank:view",
|
||||||
|
cpWeeklyRankUpdate: "cp-weekly-rank:update",
|
||||||
vipConfigView: "vip-config:view",
|
vipConfigView: "vip-config:view",
|
||||||
vipConfigUpdate: "vip-config:update",
|
vipConfigUpdate: "vip-config:update",
|
||||||
vipConfigGrant: "vip-config:grant",
|
vipConfigGrant: "vip-config:grant",
|
||||||
@ -189,6 +195,7 @@ export const MENU_CODES = {
|
|||||||
appUserBans: "app-user-bans",
|
appUserBans: "app-user-bans",
|
||||||
appUserLoginLogs: "app-user-login-logs",
|
appUserLoginLogs: "app-user-login-logs",
|
||||||
appUserLevelConfig: "app-user-level-config",
|
appUserLevelConfig: "app-user-level-config",
|
||||||
|
appUserRiskConfig: "app-user-risk-config",
|
||||||
appUserRegionBlocks: "app-user-region-blocks",
|
appUserRegionBlocks: "app-user-region-blocks",
|
||||||
appUserPrettyIds: "app-user-pretty-ids",
|
appUserPrettyIds: "app-user-pretty-ids",
|
||||||
rooms: "rooms",
|
rooms: "rooms",
|
||||||
@ -233,6 +240,7 @@ export const MENU_CODES = {
|
|||||||
userLeaderboard: "user-leaderboard",
|
userLeaderboard: "user-leaderboard",
|
||||||
redPacket: "red-packet",
|
redPacket: "red-packet",
|
||||||
cpConfig: "cp-config",
|
cpConfig: "cp-config",
|
||||||
|
cpWeeklyRank: "cp-weekly-rank",
|
||||||
vipConfig: "vip-config",
|
vipConfig: "vip-config",
|
||||||
weeklyStar: "weekly-star",
|
weeklyStar: "weekly-star",
|
||||||
agencyOpening: "agency-opening",
|
agencyOpening: "agency-opening",
|
||||||
@ -251,6 +259,7 @@ export const MENU_CODES = {
|
|||||||
payment: "payment",
|
payment: "payment",
|
||||||
paymentBillList: "payment-bill-list",
|
paymentBillList: "payment-bill-list",
|
||||||
paymentRechargeProducts: "payment-recharge-products",
|
paymentRechargeProducts: "payment-recharge-products",
|
||||||
|
paymentTemporaryLinks: "payment-temporary-links",
|
||||||
paymentThirdParty: "payment-third-party",
|
paymentThirdParty: "payment-third-party",
|
||||||
games: "games",
|
games: "games",
|
||||||
gameList: "game-list",
|
gameList: "game-list",
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { appConfigRoutes } from "@/features/app-config/routes.js";
|
|||||||
import { appUserRoutes } from "@/features/app-users/routes.js";
|
import { appUserRoutes } from "@/features/app-users/routes.js";
|
||||||
import { cumulativeRechargeRewardRoutes } from "@/features/cumulative-recharge-reward/routes.js";
|
import { cumulativeRechargeRewardRoutes } from "@/features/cumulative-recharge-reward/routes.js";
|
||||||
import { cpConfigRoutes } from "@/features/cp-config/routes.js";
|
import { cpConfigRoutes } from "@/features/cp-config/routes.js";
|
||||||
|
import { cpWeeklyRankRoutes } from "@/features/cp-weekly-rank/routes.js";
|
||||||
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
import { dashboardRoutes } from "@/features/dashboard/routes.js";
|
||||||
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
import { dailyTaskRoutes } from "@/features/daily-tasks/routes.js";
|
||||||
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
|
import { firstRechargeRewardRoutes } from "@/features/first-recharge-reward/routes.js";
|
||||||
@ -22,6 +23,7 @@ import { registrationRewardRoutes } from "@/features/registration-reward/routes.
|
|||||||
import { redPacketRoutes } from "@/features/red-packets/routes.js";
|
import { redPacketRoutes } from "@/features/red-packets/routes.js";
|
||||||
import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
|
import { regionBlockRoutes } from "@/features/region-blocks/routes.js";
|
||||||
import { resourceRoutes } from "@/features/resources/routes.js";
|
import { resourceRoutes } from "@/features/resources/routes.js";
|
||||||
|
import { riskConfigRoutes } from "@/features/risk-config/routes.js";
|
||||||
import { rolesRoutes } from "@/features/roles/routes.js";
|
import { rolesRoutes } from "@/features/roles/routes.js";
|
||||||
import { roomRoutes } from "@/features/rooms/routes.js";
|
import { roomRoutes } from "@/features/rooms/routes.js";
|
||||||
import { roomRocketRoutes } from "@/features/room-rocket/routes.js";
|
import { roomRocketRoutes } from "@/features/room-rocket/routes.js";
|
||||||
@ -40,6 +42,7 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...dashboardRoutes,
|
...dashboardRoutes,
|
||||||
...appUserRoutes,
|
...appUserRoutes,
|
||||||
...levelConfigRoutes,
|
...levelConfigRoutes,
|
||||||
|
...riskConfigRoutes,
|
||||||
...regionBlockRoutes,
|
...regionBlockRoutes,
|
||||||
...roomRoutes,
|
...roomRoutes,
|
||||||
...appConfigRoutes,
|
...appConfigRoutes,
|
||||||
@ -57,6 +60,7 @@ export const adminRoutes: AdminRoute[] = [
|
|||||||
...userLeaderboardRoutes,
|
...userLeaderboardRoutes,
|
||||||
...redPacketRoutes,
|
...redPacketRoutes,
|
||||||
...cpConfigRoutes,
|
...cpConfigRoutes,
|
||||||
|
...cpWeeklyRankRoutes,
|
||||||
...vipConfigRoutes,
|
...vipConfigRoutes,
|
||||||
...resourceRoutes,
|
...resourceRoutes,
|
||||||
...operationsRoutes,
|
...operationsRoutes,
|
||||||
|
|||||||
@ -106,6 +106,12 @@ test("listAppUsers normalizes pretty display id fields from snake case", async (
|
|||||||
pretty_id: "pretty-2026",
|
pretty_id: "pretty-2026",
|
||||||
user_id: "10001",
|
user_id: "10001",
|
||||||
username: "tester",
|
username: "tester",
|
||||||
|
vip: {
|
||||||
|
active: true,
|
||||||
|
expires_at_ms: 1790000000000,
|
||||||
|
level: 3,
|
||||||
|
name: "黄金会员",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
page: 1,
|
page: 1,
|
||||||
@ -127,10 +133,15 @@ test("listAppUsers normalizes pretty display id fields from snake case", async (
|
|||||||
prettyId: "pretty-2026",
|
prettyId: "pretty-2026",
|
||||||
userId: "10001",
|
userId: "10001",
|
||||||
username: "tester",
|
username: "tester",
|
||||||
|
vip: expect.objectContaining({
|
||||||
|
active: true,
|
||||||
|
level: 3,
|
||||||
|
name: "黄金会员",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("getAppUser uses detail endpoint and normalizes pretty display id", async () => {
|
test("getAppUser uses detail endpoint and normalizes assets", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(
|
vi.fn(
|
||||||
@ -144,6 +155,39 @@ test("getAppUser uses detail endpoint and normalizes pretty display id", async (
|
|||||||
pretty_display_user_id: "VIP888",
|
pretty_display_user_id: "VIP888",
|
||||||
pretty_id: "pretty-888",
|
pretty_id: "pretty-888",
|
||||||
user_id: "10001",
|
user_id: "10001",
|
||||||
|
balances: [
|
||||||
|
{
|
||||||
|
asset_type: "COIN",
|
||||||
|
available_amount: 1200,
|
||||||
|
frozen_amount: 50,
|
||||||
|
total_amount: 1250,
|
||||||
|
version: 7,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
equipped_resources: [
|
||||||
|
{
|
||||||
|
entitlement_id: "ent-1",
|
||||||
|
equipped: true,
|
||||||
|
name: "金色头像框",
|
||||||
|
quantity: 1,
|
||||||
|
remaining_quantity: 1,
|
||||||
|
resource_code: "frame_gold",
|
||||||
|
resource_id: 9,
|
||||||
|
resource_type: "avatar_frame",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
resources: [
|
||||||
|
{
|
||||||
|
entitlement_id: "ent-1",
|
||||||
|
equipped: true,
|
||||||
|
name: "金色头像框",
|
||||||
|
quantity: 1,
|
||||||
|
remaining_quantity: 1,
|
||||||
|
resource_code: "frame_gold",
|
||||||
|
resource_id: 9,
|
||||||
|
resource_type: "avatar_frame",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@ -158,4 +202,20 @@ test("getAppUser uses detail endpoint and normalizes pretty display id", async (
|
|||||||
expect(result.defaultDisplayUserId).toBe("123456");
|
expect(result.defaultDisplayUserId).toBe("123456");
|
||||||
expect(result.prettyDisplayUserId).toBe("VIP888");
|
expect(result.prettyDisplayUserId).toBe("VIP888");
|
||||||
expect(result.prettyId).toBe("pretty-888");
|
expect(result.prettyId).toBe("pretty-888");
|
||||||
|
expect(result.balances?.[0]).toMatchObject({
|
||||||
|
assetType: "COIN",
|
||||||
|
availableAmount: 1200,
|
||||||
|
frozenAmount: 50,
|
||||||
|
totalAmount: 1250,
|
||||||
|
version: 7,
|
||||||
|
});
|
||||||
|
expect(result.equippedResources?.[0]).toMatchObject({
|
||||||
|
equipped: true,
|
||||||
|
name: "金色头像框",
|
||||||
|
resourceType: "avatar_frame",
|
||||||
|
});
|
||||||
|
expect(result.resources?.[0]).toMatchObject({
|
||||||
|
entitlementId: "ent-1",
|
||||||
|
resourceCode: "frame_gold",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -198,6 +198,7 @@ export async function setPrettyDisplayIDStatus(
|
|||||||
// 用户相关接口历史上既返回过 snake_case,也返回过 camelCase;在 API 边界归一化后,共享用户组件只消费稳定字段。
|
// 用户相关接口历史上既返回过 snake_case,也返回过 camelCase;在 API 边界归一化后,共享用户组件只消费稳定字段。
|
||||||
interface RawAppUser {
|
interface RawAppUser {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
balances?: RawAppUserAssetBalance[];
|
||||||
coin?: number;
|
coin?: number;
|
||||||
country?: string;
|
country?: string;
|
||||||
country_display_name?: string;
|
country_display_name?: string;
|
||||||
@ -211,6 +212,8 @@ interface RawAppUser {
|
|||||||
diamond?: number;
|
diamond?: number;
|
||||||
display_user_id?: string;
|
display_user_id?: string;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
|
equipped_resources?: RawAppUserResource[];
|
||||||
|
equippedResources?: RawAppUserResource[];
|
||||||
gender?: string;
|
gender?: string;
|
||||||
last_active_at_ms?: number;
|
last_active_at_ms?: number;
|
||||||
lastActiveAtMs?: number;
|
lastActiveAtMs?: number;
|
||||||
@ -222,16 +225,77 @@ interface RawAppUser {
|
|||||||
regionId?: number;
|
regionId?: number;
|
||||||
region_name?: string;
|
region_name?: string;
|
||||||
regionName?: string;
|
regionName?: string;
|
||||||
|
resources?: RawAppUserResource[];
|
||||||
status?: string;
|
status?: string;
|
||||||
updated_at_ms?: number;
|
updated_at_ms?: number;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
user_id?: EntityId;
|
user_id?: EntityId;
|
||||||
userId?: EntityId;
|
userId?: EntityId;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
vip?: RawAppUserVIP;
|
||||||
}
|
}
|
||||||
|
|
||||||
type RawAppUserBrief = RawAppUser;
|
type RawAppUserBrief = RawAppUser;
|
||||||
|
|
||||||
|
interface RawAppUserAssetBalance {
|
||||||
|
asset_type?: string;
|
||||||
|
assetType?: string;
|
||||||
|
available_amount?: number;
|
||||||
|
availableAmount?: number;
|
||||||
|
frozen_amount?: number;
|
||||||
|
frozenAmount?: number;
|
||||||
|
total_amount?: number;
|
||||||
|
totalAmount?: number;
|
||||||
|
updated_at_ms?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
version?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAppUserVIP {
|
||||||
|
active?: boolean;
|
||||||
|
expires_at_ms?: number;
|
||||||
|
expiresAtMs?: number;
|
||||||
|
level?: number;
|
||||||
|
name?: string;
|
||||||
|
started_at_ms?: number;
|
||||||
|
startedAtMs?: number;
|
||||||
|
updated_at_ms?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawAppUserResource {
|
||||||
|
animation_url?: string;
|
||||||
|
animationUrl?: string;
|
||||||
|
asset_url?: string;
|
||||||
|
assetUrl?: string;
|
||||||
|
created_at_ms?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
effective_at_ms?: number;
|
||||||
|
effectiveAtMs?: number;
|
||||||
|
entitlement_id?: string;
|
||||||
|
entitlementId?: string;
|
||||||
|
equipped?: boolean;
|
||||||
|
expires_at_ms?: number;
|
||||||
|
expiresAtMs?: number;
|
||||||
|
name?: string;
|
||||||
|
preview_url?: string;
|
||||||
|
previewUrl?: string;
|
||||||
|
quantity?: number;
|
||||||
|
remaining_quantity?: number;
|
||||||
|
remainingQuantity?: number;
|
||||||
|
resource_code?: string;
|
||||||
|
resourceCode?: string;
|
||||||
|
resource_id?: number;
|
||||||
|
resourceId?: number;
|
||||||
|
resource_type?: string;
|
||||||
|
resourceType?: string;
|
||||||
|
source_grant_id?: string;
|
||||||
|
sourceGrantId?: string;
|
||||||
|
status?: string;
|
||||||
|
updated_at_ms?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface RawAppUserBanOperator extends RawAppUserBrief {
|
interface RawAppUserBanOperator extends RawAppUserBrief {
|
||||||
account?: string;
|
account?: string;
|
||||||
admin_id?: EntityId;
|
admin_id?: EntityId;
|
||||||
@ -410,6 +474,7 @@ function normalizePage<TRaw, TItem>(
|
|||||||
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
|
function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
|
||||||
return {
|
return {
|
||||||
avatar: stringValue(item.avatar),
|
avatar: stringValue(item.avatar),
|
||||||
|
balances: (item.balances || []).map(normalizeAppUserAssetBalance),
|
||||||
coin: numberValue(item.coin),
|
coin: numberValue(item.coin),
|
||||||
country: stringValue(item.country),
|
country: stringValue(item.country),
|
||||||
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name),
|
countryDisplayName: stringValue(item.countryDisplayName ?? item.country_display_name),
|
||||||
@ -418,16 +483,65 @@ function normalizeAppUser(item: RawAppUser = {}): AppUserDto {
|
|||||||
defaultDisplayUserId: stringValue(item.defaultDisplayUserId ?? item.default_display_user_id),
|
defaultDisplayUserId: stringValue(item.defaultDisplayUserId ?? item.default_display_user_id),
|
||||||
diamond: numberValue(item.diamond),
|
diamond: numberValue(item.diamond),
|
||||||
displayUserId: stringValue(item.displayUserId ?? item.display_user_id),
|
displayUserId: stringValue(item.displayUserId ?? item.display_user_id),
|
||||||
|
equippedResources: (item.equippedResources ?? item.equipped_resources ?? []).map(normalizeAppUserResource),
|
||||||
gender: stringValue(item.gender),
|
gender: stringValue(item.gender),
|
||||||
lastActiveAtMs: numberValue(item.lastActiveAtMs ?? item.last_active_at_ms),
|
lastActiveAtMs: numberValue(item.lastActiveAtMs ?? item.last_active_at_ms),
|
||||||
prettyDisplayUserId: stringValue(item.prettyDisplayUserId ?? item.pretty_display_user_id),
|
prettyDisplayUserId: stringValue(item.prettyDisplayUserId ?? item.pretty_display_user_id),
|
||||||
prettyId: stringValue(item.prettyId ?? item.pretty_id),
|
prettyId: stringValue(item.prettyId ?? item.pretty_id),
|
||||||
regionId: numberValue(item.regionId ?? item.region_id),
|
regionId: numberValue(item.regionId ?? item.region_id),
|
||||||
regionName: stringValue(item.regionName ?? item.region_name),
|
regionName: stringValue(item.regionName ?? item.region_name),
|
||||||
|
resources: (item.resources || []).map(normalizeAppUserResource),
|
||||||
status: stringValue(item.status),
|
status: stringValue(item.status),
|
||||||
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
userId: stringValue(item.userId ?? item.user_id),
|
userId: stringValue(item.userId ?? item.user_id),
|
||||||
username: stringValue(item.username),
|
username: stringValue(item.username),
|
||||||
|
vip: normalizeAppUserVIP(item.vip),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAppUserAssetBalance(item: RawAppUserAssetBalance = {}) {
|
||||||
|
const availableAmount = numberValue(item.availableAmount ?? item.available_amount);
|
||||||
|
const frozenAmount = numberValue(item.frozenAmount ?? item.frozen_amount);
|
||||||
|
return {
|
||||||
|
assetType: stringValue(item.assetType ?? item.asset_type).toUpperCase(),
|
||||||
|
availableAmount,
|
||||||
|
frozenAmount,
|
||||||
|
totalAmount: numberValue(item.totalAmount ?? item.total_amount ?? availableAmount + frozenAmount),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
version: numberValue(item.version),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAppUserVIP(item: RawAppUserVIP = {}) {
|
||||||
|
return {
|
||||||
|
active: Boolean(item.active),
|
||||||
|
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
|
||||||
|
level: numberValue(item.level),
|
||||||
|
name: stringValue(item.name),
|
||||||
|
startedAtMs: numberValue(item.startedAtMs ?? item.started_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAppUserResource(item: RawAppUserResource = {}) {
|
||||||
|
return {
|
||||||
|
animationUrl: stringValue(item.animationUrl ?? item.animation_url),
|
||||||
|
assetUrl: stringValue(item.assetUrl ?? item.asset_url),
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
effectiveAtMs: numberValue(item.effectiveAtMs ?? item.effective_at_ms),
|
||||||
|
entitlementId: stringValue(item.entitlementId ?? item.entitlement_id),
|
||||||
|
equipped: Boolean(item.equipped),
|
||||||
|
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
|
||||||
|
name: stringValue(item.name),
|
||||||
|
previewUrl: stringValue(item.previewUrl ?? item.preview_url),
|
||||||
|
quantity: numberValue(item.quantity),
|
||||||
|
remainingQuantity: numberValue(item.remainingQuantity ?? item.remaining_quantity),
|
||||||
|
resourceCode: stringValue(item.resourceCode ?? item.resource_code),
|
||||||
|
resourceId: numberValue(item.resourceId ?? item.resource_id),
|
||||||
|
resourceType: stringValue(item.resourceType ?? item.resource_type),
|
||||||
|
sourceGrantId: stringValue(item.sourceGrantId ?? item.source_grant_id),
|
||||||
|
status: stringValue(item.status),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -441,6 +441,155 @@
|
|||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vipBadge,
|
||||||
|
.vipBadgeMuted {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vipBadge {
|
||||||
|
border: 1px solid color-mix(in srgb, #f2b84b 55%, var(--border));
|
||||||
|
background: #fff7df;
|
||||||
|
color: #8a5a00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vipBadgeMuted {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--neutral-surface);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyInline {
|
||||||
|
display: flex;
|
||||||
|
min-height: 40px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assetList {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assetRow {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assetName {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assetMeta,
|
||||||
|
.resourceMeta {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assetAmount {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 800;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceList {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceItem {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceThumb,
|
||||||
|
.resourceThumbFallback {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--neutral-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceThumb {
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceThumbFallback {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceBody {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceTitle {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceTitle span:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resourceTag {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid var(--success-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--success-surface);
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
.stack {
|
.stack {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
|
|||||||
@ -70,6 +70,9 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
|
|||||||
if (!open || !user) {
|
if (!open || !user) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const balances = user.balances || [];
|
||||||
|
const equippedResources = user.equippedResources || [];
|
||||||
|
const resources = user.resources || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SideDrawer open={open} title="用户详情" width="wide" onClose={onClose}>
|
<SideDrawer open={open} title="用户详情" width="wide" onClose={onClose}>
|
||||||
@ -93,6 +96,7 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
|
|||||||
<h3 className={styles.detailSectionTitle}>账号状态</h3>
|
<h3 className={styles.detailSectionTitle}>账号状态</h3>
|
||||||
<div className={styles.detailGrid}>
|
<div className={styles.detailGrid}>
|
||||||
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
|
<DetailItem label="状态" value={appUserStatusLabels[user.status] || user.status || "-"} />
|
||||||
|
<DetailItem label="VIP 等级" value={<VipDetailValue vip={user.vip} />} />
|
||||||
<DetailItem label="金币" value={formatNumber(user.coin)} />
|
<DetailItem label="金币" value={formatNumber(user.coin)} />
|
||||||
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
|
<DetailItem label="钻石" value={formatNumber(user.diamond)} />
|
||||||
<DetailItem label="国家" value={formatCountry(user)} />
|
<DetailItem label="国家" value={formatCountry(user)} />
|
||||||
@ -105,6 +109,18 @@ function AppUserDetailDrawer({ loading, onClose, open, user }) {
|
|||||||
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
|
<DetailItem label="更新时间" value={formatMillis(user.updatedAtMs)} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<section className={styles.detailSection}>
|
||||||
|
<h3 className={styles.detailSectionTitle}>资产余额</h3>
|
||||||
|
<AssetBalanceList balances={balances} />
|
||||||
|
</section>
|
||||||
|
<section className={styles.detailSection}>
|
||||||
|
<h3 className={styles.detailSectionTitle}>当前佩戴</h3>
|
||||||
|
<ResourceList resources={equippedResources} />
|
||||||
|
</section>
|
||||||
|
<section className={styles.detailSection}>
|
||||||
|
<h3 className={styles.detailSectionTitle}>资源资产</h3>
|
||||||
|
<ResourceList resources={resources} />
|
||||||
|
</section>
|
||||||
</SideDrawer>
|
</SideDrawer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -128,6 +144,67 @@ function PrettyValue({ user }) {
|
|||||||
return <AdminPrettyDisplayID prettyDisplayUserId={user.prettyDisplayUserId} prettyId={user.prettyId} />;
|
return <AdminPrettyDisplayID prettyDisplayUserId={user.prettyDisplayUserId} prettyId={user.prettyId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VipDetailValue({ vip }) {
|
||||||
|
const level = Number(vip?.level || 0);
|
||||||
|
if (level <= 0 || !vip?.active) {
|
||||||
|
return "VIP 0";
|
||||||
|
}
|
||||||
|
return vip.name ? `VIP ${level} · ${vip.name}` : `VIP ${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssetBalanceList({ balances }) {
|
||||||
|
if (!balances.length) {
|
||||||
|
return <div className={styles.emptyInline}>当前无数据</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={styles.assetList}>
|
||||||
|
{balances.map((balance) => (
|
||||||
|
<div className={styles.assetRow} key={balance.assetType}>
|
||||||
|
<div>
|
||||||
|
<div className={styles.assetName}>{formatAssetType(balance.assetType)}</div>
|
||||||
|
<div className={styles.assetMeta}>冻结 {formatNumber(balance.frozenAmount)}</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.assetAmount}>{formatNumber(balance.availableAmount)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceList({ resources }) {
|
||||||
|
if (!resources.length) {
|
||||||
|
return <div className={styles.emptyInline}>当前无数据</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ul className={styles.resourceList}>
|
||||||
|
{resources.map((resource) => (
|
||||||
|
<li className={styles.resourceItem} key={resource.entitlementId || resource.resourceId}>
|
||||||
|
<ResourceThumb resource={resource} />
|
||||||
|
<div className={styles.resourceBody}>
|
||||||
|
<div className={styles.resourceTitle}>
|
||||||
|
<span>{resource.name || resource.resourceCode || `资源 ${resource.resourceId}`}</span>
|
||||||
|
{resource.equipped ? <span className={styles.resourceTag}>佩戴中</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className={styles.resourceMeta}>
|
||||||
|
{formatResourceType(resource.resourceType)} · 剩余{" "}
|
||||||
|
{formatNumber(resource.remainingQuantity)} / {formatNumber(resource.quantity)}
|
||||||
|
</div>
|
||||||
|
<div className={styles.resourceMeta}>到期 {formatExpireTime(resource.expiresAtMs)}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourceThumb({ resource }) {
|
||||||
|
const imageUrl = resource.previewUrl || resource.assetUrl || resource.animationUrl;
|
||||||
|
if (!imageUrl) {
|
||||||
|
return <span className={styles.resourceThumbFallback}>{resource.name?.slice(0, 1) || "资"}</span>;
|
||||||
|
}
|
||||||
|
return <img alt="" className={styles.resourceThumb} src={imageUrl} />;
|
||||||
|
}
|
||||||
|
|
||||||
function AppUserStatus({ status }) {
|
function AppUserStatus({ status }) {
|
||||||
const tone = status === "active" ? "running" : "danger";
|
const tone = status === "active" ? "running" : "danger";
|
||||||
return (
|
return (
|
||||||
@ -190,6 +267,39 @@ function formatNumber(value) {
|
|||||||
return Number.isFinite(number) ? number.toLocaleString() : "-";
|
return Number.isFinite(number) ? number.toLocaleString() : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAssetType(value) {
|
||||||
|
const normalized = String(value || "").toUpperCase();
|
||||||
|
const labels = {
|
||||||
|
COIN: "金币",
|
||||||
|
DIAMOND: "钻石",
|
||||||
|
};
|
||||||
|
return labels[normalized] || normalized || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResourceType(value) {
|
||||||
|
const labels = {
|
||||||
|
avatar_frame: "头像框",
|
||||||
|
badge: "徽章",
|
||||||
|
chat_bubble: "聊天气泡",
|
||||||
|
emoji_pack: "表情包",
|
||||||
|
floating_screen: "飘屏",
|
||||||
|
gift: "礼物",
|
||||||
|
mic_seat_animation: "麦位动画",
|
||||||
|
mic_seat_icon: "麦位图标",
|
||||||
|
profile_card: "资料卡",
|
||||||
|
vehicle: "座驾",
|
||||||
|
};
|
||||||
|
return labels[value] || value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExpireTime(value) {
|
||||||
|
const expiresAtMs = Number(value || 0);
|
||||||
|
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= 0) {
|
||||||
|
return "永久";
|
||||||
|
}
|
||||||
|
return formatMillis(expiresAtMs);
|
||||||
|
}
|
||||||
|
|
||||||
function firstNonEmpty(...values) {
|
function firstNonEmpty(...values) {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const normalized = String(value ?? "").trim();
|
const normalized = String(value ?? "").trim();
|
||||||
|
|||||||
@ -90,6 +90,12 @@ export function AppUserListPage() {
|
|||||||
}),
|
}),
|
||||||
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
|
render: (user) => <AdminUserIdentity openInAppUserDetail user={user} />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "vip",
|
||||||
|
label: "VIP",
|
||||||
|
width: "minmax(80px, 0.45fr)",
|
||||||
|
render: (user) => <VipValue vip={user.vip} />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "location",
|
key: "location",
|
||||||
label: "国家 / 区域",
|
label: "国家 / 区域",
|
||||||
@ -316,6 +322,14 @@ function CoinValue({ page, user }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VipValue({ vip }) {
|
||||||
|
const level = Number(vip?.level || 0);
|
||||||
|
if (level <= 0 || !vip?.active) {
|
||||||
|
return <span className={styles.vipBadgeMuted}>VIP 0</span>;
|
||||||
|
}
|
||||||
|
return <span className={styles.vipBadge}>VIP {level}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
function UserLocation({ page, user }) {
|
function UserLocation({ page, user }) {
|
||||||
const countryLabel = formatCountry(user);
|
const countryLabel = formatCountry(user);
|
||||||
const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-");
|
const regionLabel = user.regionName || (user.regionId ? `区域 ${user.regionId}` : "-");
|
||||||
|
|||||||
@ -28,6 +28,7 @@ test("clicking an app user's coin opens the coin ledger drawer", () => {
|
|||||||
|
|
||||||
render(<AppUserListPage />);
|
render(<AppUserListPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("VIP 2")).toBeInTheDocument();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" }));
|
fireEvent.click(screen.getByRole("button", { name: "查看 d 的金币流水" }));
|
||||||
|
|
||||||
expect(mocks.openCoinLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
|
expect(mocks.openCoinLedger).toHaveBeenCalledWith(expect.objectContaining({ userId: "10001" }));
|
||||||
@ -121,5 +122,6 @@ function userFixture() {
|
|||||||
status: "active",
|
status: "active",
|
||||||
userId: "10001",
|
userId: "10001",
|
||||||
username: "d",
|
username: "d",
|
||||||
|
vip: { active: true, level: 2, name: "白银会员" },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,16 +1,25 @@
|
|||||||
|
import AddModeratorOutlined from "@mui/icons-material/AddModeratorOutlined";
|
||||||
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { toPageQuery } from "@/shared/api/query";
|
import { toPageQuery } from "@/shared/api/query";
|
||||||
|
import { FormValidationError, parseForm } from "@/shared/forms/validation";
|
||||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
import { mergePaginatedItems } from "@/shared/hooks/usePaginatedQuery.js";
|
import { mergePaginatedItems } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { listAppUserLoginLogs } from "@/features/app-users/api";
|
import { listAppUserLoginLogs } from "@/features/app-users/api";
|
||||||
|
import { addRegionBlockWhitelistIP } from "@/features/region-blocks/api";
|
||||||
|
import { useRegionBlockAbilities } from "@/features/region-blocks/permissions.js";
|
||||||
|
import { regionBlockIPSchema } from "@/features/region-blocks/schema";
|
||||||
import styles from "@/features/app-users/app-users.module.css";
|
import styles from "@/features/app-users/app-users.module.css";
|
||||||
|
|
||||||
const pageSize = 50;
|
const pageSize = 50;
|
||||||
@ -23,10 +32,14 @@ const resultOptions = [
|
|||||||
|
|
||||||
export function AppUserLoginLogsPage() {
|
export function AppUserLoginLogsPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const regionBlockAbilities = useRegionBlockAbilities();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [result, setResult] = useState("");
|
const [result, setResult] = useState("");
|
||||||
const [regionId, setRegionId] = useState("");
|
const [regionId, setRegionId] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [addingWhitelistIP, setAddingWhitelistIP] = useState("");
|
||||||
const [mergedData, setMergedData] = useState(emptyData);
|
const [mergedData, setMergedData] = useState(emptyData);
|
||||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||||
const userFilter = searchParams.get("user_id") || "";
|
const userFilter = searchParams.get("user_id") || "";
|
||||||
@ -132,6 +145,27 @@ export function AppUserLoginLogsPage() {
|
|||||||
[changeQuery, setSearchParams, userFilter],
|
[changeQuery, setSearchParams, userFilter],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const addLoginIPToWhitelist = useCallback(
|
||||||
|
async (log) => {
|
||||||
|
if (!regionBlockAbilities.canUpdate || addingWhitelistIP) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ipAddress = parseForm(regionBlockIPSchema, log.loginIp);
|
||||||
|
setAddingWhitelistIP(ipAddress);
|
||||||
|
const result = await addRegionBlockWhitelistIP(ipAddress);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["region-blocks"] });
|
||||||
|
showToast(result.added ? "IP 已添加到白名单" : "白名单 IP 已存在", result.added ? "success" : "warning");
|
||||||
|
} catch (err) {
|
||||||
|
showValidationError(err, showToast, "添加白名单 IP 失败");
|
||||||
|
} finally {
|
||||||
|
setAddingWhitelistIP("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addingWhitelistIP, queryClient, regionBlockAbilities.canUpdate, showToast],
|
||||||
|
);
|
||||||
|
|
||||||
const selectedUserLabel = useMemo(() => {
|
const selectedUserLabel = useMemo(() => {
|
||||||
if (!userFilter) {
|
if (!userFilter) {
|
||||||
return "";
|
return "";
|
||||||
@ -233,8 +267,23 @@ export function AppUserLoginLogsPage() {
|
|||||||
width: "180px",
|
width: "180px",
|
||||||
render: (log) => <span className="muted">{formatMillis(log.createdAtMs)}</span>,
|
render: (log) => <span className="muted">{formatMillis(log.createdAtMs)}</span>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "120px",
|
||||||
|
render: (log) => (
|
||||||
|
<LoginLogActions
|
||||||
|
canUpdateWhitelist={regionBlockAbilities.canUpdate}
|
||||||
|
addingWhitelistIP={addingWhitelistIP}
|
||||||
|
log={log}
|
||||||
|
onAddToWhitelist={addLoginIPToWhitelist}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
addLoginIPToWhitelist,
|
||||||
|
addingWhitelistIP,
|
||||||
changeIdentityFilter,
|
changeIdentityFilter,
|
||||||
changeRegionId,
|
changeRegionId,
|
||||||
changeResult,
|
changeResult,
|
||||||
@ -244,6 +293,7 @@ export function AppUserLoginLogsPage() {
|
|||||||
regionId,
|
regionId,
|
||||||
resetIdentityFilter,
|
resetIdentityFilter,
|
||||||
result,
|
result,
|
||||||
|
regionBlockAbilities.canUpdate,
|
||||||
selectedUserLabel,
|
selectedUserLabel,
|
||||||
userFilter,
|
userFilter,
|
||||||
],
|
],
|
||||||
@ -258,7 +308,7 @@ export function AppUserLoginLogsPage() {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
emptyLabel="暂无登录日志"
|
emptyLabel="暂无登录日志"
|
||||||
items={mergedData.items || []}
|
items={mergedData.items || []}
|
||||||
minWidth="1380px"
|
minWidth="1500px"
|
||||||
getRowProps={(log) => loginLogRowProps(log, showUserHistory)}
|
getRowProps={(log) => loginLogRowProps(log, showUserHistory)}
|
||||||
rowKey={(log) => log.id}
|
rowKey={(log) => log.id}
|
||||||
pagination={
|
pagination={
|
||||||
@ -279,6 +329,33 @@ export function AppUserLoginLogsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LoginLogActions({ addingWhitelistIP, canUpdateWhitelist, log, onAddToWhitelist }) {
|
||||||
|
const ipAddress = String(log.loginIp || "").trim();
|
||||||
|
const adding = Boolean(addingWhitelistIP) && addingWhitelistIP === ipAddress;
|
||||||
|
const disabled = !canUpdateWhitelist || !ipAddress || Boolean(addingWhitelistIP);
|
||||||
|
const title = !canUpdateWhitelist ? "无白名单更新权限" : ipAddress ? "添加IP到白名单" : "该日志没有IP";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.rowActions}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Tooltip arrow title={title}>
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled}
|
||||||
|
label="添加IP到白名单"
|
||||||
|
onClick={() => onAddToWhitelist(log)}
|
||||||
|
>
|
||||||
|
<AddModeratorOutlined color={adding ? "primary" : "inherit"} fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function loginLogRowProps(log, onOpenHistory) {
|
function loginLogRowProps(log, onOpenHistory) {
|
||||||
if (!log.userId) {
|
if (!log.userId) {
|
||||||
return {};
|
return {};
|
||||||
@ -297,6 +374,14 @@ function loginLogRowProps(log, onOpenHistory) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showValidationError(err, showToast, fallback) {
|
||||||
|
if (err instanceof FormValidationError) {
|
||||||
|
showToast(err.message, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showToast(err?.message || fallback, "error");
|
||||||
|
}
|
||||||
|
|
||||||
function ResultBadge({ log }) {
|
function ResultBadge({ log }) {
|
||||||
const result = String(log.result || "").toLowerCase();
|
const result = String(log.result || "").toLowerCase();
|
||||||
const blocked = log.blocked || result === "blocked";
|
const blocked = log.blocked || result === "blocked";
|
||||||
|
|||||||
@ -118,11 +118,6 @@ export interface CPConfigPayload {
|
|||||||
status: string;
|
status: string;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
weeklyRank: {
|
|
||||||
enabled: boolean;
|
|
||||||
topCount: number;
|
|
||||||
rewards: CPWeeklyRankRewardDto[];
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCPConfig(): Promise<CPConfigDto> {
|
export function getCPConfig(): Promise<CPConfigDto> {
|
||||||
@ -259,14 +254,18 @@ function normalizeRelationship(raw: unknown): CPRelationshipDto {
|
|||||||
const item = asRecord(raw) as RawRelationship;
|
const item = asRecord(raw) as RawRelationship;
|
||||||
const userA = normalizeUser(item.userA ?? item.user_a ?? item.me ?? item.requester);
|
const userA = normalizeUser(item.userA ?? item.user_a ?? item.me ?? item.requester);
|
||||||
const userB = normalizeUser(item.userB ?? item.user_b ?? item.partner ?? item.target);
|
const userB = normalizeUser(item.userB ?? item.user_b ?? item.partner ?? item.target);
|
||||||
const gift = normalizeGift(item.gift ?? item.sourceGift ?? item.source_gift ?? item.applicationGift ?? item.application_gift);
|
const gift = normalizeGift(
|
||||||
|
item.gift ?? item.sourceGift ?? item.source_gift ?? item.applicationGift ?? item.application_gift,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
relationshipId: stringValue(item.relationshipId ?? item.relationship_id),
|
relationshipId: stringValue(item.relationshipId ?? item.relationship_id),
|
||||||
relationType: normalizeRelationType(item.relationType ?? item.relation_type),
|
relationType: normalizeRelationType(item.relationType ?? item.relation_type),
|
||||||
status: stringValue(item.status) || "active",
|
status: stringValue(item.status) || "active",
|
||||||
userA,
|
userA,
|
||||||
userB,
|
userB,
|
||||||
intimacyValue: numberValue(item.intimacyValue ?? item.intimacy_value ?? item.experienceValue ?? item.experience_value),
|
intimacyValue: numberValue(
|
||||||
|
item.intimacyValue ?? item.intimacy_value ?? item.experienceValue ?? item.experience_value,
|
||||||
|
),
|
||||||
level: numberValue(item.level ?? item.levelNo ?? item.level_no),
|
level: numberValue(item.level ?? item.levelNo ?? item.level_no),
|
||||||
durationDays: numberValue(item.durationDays ?? item.duration_days ?? item.days),
|
durationDays: numberValue(item.durationDays ?? item.duration_days ?? item.days),
|
||||||
gift: gift.giftId || gift.giftName || gift.giftValue ? gift : undefined,
|
gift: gift.giftId || gift.giftName || gift.giftValue ? gift : undefined,
|
||||||
@ -282,7 +281,9 @@ function normalizeApplication(raw: unknown): CPApplicationDto {
|
|||||||
applicationId: stringValue(item.applicationId ?? item.application_id),
|
applicationId: stringValue(item.applicationId ?? item.application_id),
|
||||||
relationType: normalizeRelationType(item.relationType ?? item.relation_type),
|
relationType: normalizeRelationType(item.relationType ?? item.relation_type),
|
||||||
status: stringValue(item.status) || "pending",
|
status: stringValue(item.status) || "pending",
|
||||||
requester: normalizeUser(item.requester ?? item.requesterUser ?? item.requester_user ?? item.userA ?? item.user_a),
|
requester: normalizeUser(
|
||||||
|
item.requester ?? item.requesterUser ?? item.requester_user ?? item.userA ?? item.user_a,
|
||||||
|
),
|
||||||
target: normalizeUser(item.target ?? item.targetUser ?? item.target_user ?? item.userB ?? item.user_b),
|
target: normalizeUser(item.target ?? item.targetUser ?? item.target_user ?? item.userB ?? item.user_b),
|
||||||
gift: normalizeGift(item.gift ?? item.sourceGift ?? item.source_gift),
|
gift: normalizeGift(item.gift ?? item.sourceGift ?? item.source_gift),
|
||||||
roomId: stringValue(item.roomId ?? item.room_id),
|
roomId: stringValue(item.roomId ?? item.room_id),
|
||||||
|
|||||||
@ -10,7 +10,6 @@ export const cpRelationLabels = {
|
|||||||
brother: "兄弟",
|
brother: "兄弟",
|
||||||
sister: "姐妹",
|
sister: "姐妹",
|
||||||
};
|
};
|
||||||
export const cpWeeklyRankRanks = [1, 2, 3];
|
|
||||||
|
|
||||||
const defaultExpireHours = 24;
|
const defaultExpireHours = 24;
|
||||||
const defaultThresholds = [0, 10000, 30000, 60000, 100000];
|
const defaultThresholds = [0, 10000, 30000, 60000, 100000];
|
||||||
@ -28,10 +27,9 @@ export function useCPConfigPage() {
|
|||||||
const [relationshipsError, setRelationshipsError] = useState("");
|
const [relationshipsError, setRelationshipsError] = useState("");
|
||||||
const [applicationsError, setApplicationsError] = useState("");
|
const [applicationsError, setApplicationsError] = useState("");
|
||||||
const [applicationsLoaded, setApplicationsLoaded] = useState(false);
|
const [applicationsLoaded, setApplicationsLoaded] = useState(false);
|
||||||
const [config, setConfig] = useState({ relations: defaultRelations(), weeklyRank: defaultWeeklyRank() });
|
const [config, setConfig] = useState({ relations: defaultRelations() });
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
relations: defaultRelations().map(relationToForm),
|
relations: defaultRelations().map(relationToForm),
|
||||||
weeklyRank: weeklyRankToForm(defaultWeeklyRank()),
|
|
||||||
});
|
});
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -53,9 +51,8 @@ export function useCPConfigPage() {
|
|||||||
if (configResult.status === "fulfilled") {
|
if (configResult.status === "fulfilled") {
|
||||||
const remoteConfig = configResult.value;
|
const remoteConfig = configResult.value;
|
||||||
const relations = completeRelations(remoteConfig.relations || []);
|
const relations = completeRelations(remoteConfig.relations || []);
|
||||||
const weeklyRank = completeWeeklyRank(remoteConfig.weeklyRank);
|
setConfig({ ...remoteConfig, relations });
|
||||||
setConfig({ ...remoteConfig, relations, weeklyRank });
|
setForm({ relations: relations.map(relationToForm) });
|
||||||
setForm({ relations: relations.map(relationToForm), weeklyRank: weeklyRankToForm(weeklyRank) });
|
|
||||||
} else {
|
} else {
|
||||||
showToast(configResult.reason?.message || "加载 CP 配置失败", "error");
|
showToast(configResult.reason?.message || "加载 CP 配置失败", "error");
|
||||||
}
|
}
|
||||||
@ -168,7 +165,6 @@ export function useCPConfigPage() {
|
|||||||
const openDrawer = () => {
|
const openDrawer = () => {
|
||||||
setForm({
|
setForm({
|
||||||
relations: completeRelations(config.relations || []).map(relationToForm),
|
relations: completeRelations(config.relations || []).map(relationToForm),
|
||||||
weeklyRank: weeklyRankToForm(completeWeeklyRank(config.weeklyRank)),
|
|
||||||
});
|
});
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
};
|
};
|
||||||
@ -179,7 +175,6 @@ export function useCPConfigPage() {
|
|||||||
}
|
}
|
||||||
setForm({
|
setForm({
|
||||||
relations: completeRelations(config.relations || []).map(relationToForm),
|
relations: completeRelations(config.relations || []).map(relationToForm),
|
||||||
weeklyRank: weeklyRankToForm(completeWeeklyRank(config.weeklyRank)),
|
|
||||||
});
|
});
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
};
|
};
|
||||||
@ -209,25 +204,6 @@ export function useCPConfigPage() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateWeeklyRank = (patch) => {
|
|
||||||
setForm((current) => ({
|
|
||||||
...current,
|
|
||||||
weeklyRank: { ...(current.weeklyRank || weeklyRankToForm(defaultWeeklyRank())), ...patch },
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateWeeklyRankReward = (rankNo, patch) => {
|
|
||||||
setForm((current) => ({
|
|
||||||
...current,
|
|
||||||
weeklyRank: {
|
|
||||||
...(current.weeklyRank || weeklyRankToForm(defaultWeeklyRank())),
|
|
||||||
rewards: completeWeeklyRankRewardForms(current.weeklyRank?.rewards || []).map((reward) =>
|
|
||||||
Number(reward.rankNo) === Number(rankNo) ? { ...reward, ...patch } : reward,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const submit = async (event) => {
|
const submit = async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!abilities.canUpdate) {
|
if (!abilities.canUpdate) {
|
||||||
@ -244,9 +220,8 @@ export function useCPConfigPage() {
|
|||||||
try {
|
try {
|
||||||
const saved = await updateCPConfig(payload);
|
const saved = await updateCPConfig(payload);
|
||||||
const relations = completeRelations(saved.relations || []);
|
const relations = completeRelations(saved.relations || []);
|
||||||
const weeklyRank = completeWeeklyRank(saved.weeklyRank);
|
setConfig({ ...saved, relations });
|
||||||
setConfig({ ...saved, relations, weeklyRank });
|
setForm({ relations: relations.map(relationToForm) });
|
||||||
setForm({ relations: relations.map(relationToForm), weeklyRank: weeklyRankToForm(weeklyRank) });
|
|
||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
showToast("CP 配置已保存", "success");
|
showToast("CP 配置已保存", "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -282,8 +257,6 @@ export function useCPConfigPage() {
|
|||||||
submit,
|
submit,
|
||||||
updateLevel,
|
updateLevel,
|
||||||
updateRelation,
|
updateRelation,
|
||||||
updateWeeklyRank,
|
|
||||||
updateWeeklyRankReward,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -311,16 +284,6 @@ function defaultLevels() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultWeeklyRank() {
|
|
||||||
return {
|
|
||||||
enabled: true,
|
|
||||||
relationType: "cp",
|
|
||||||
topCount: 3,
|
|
||||||
rewards: cpWeeklyRankRanks.map((rankNo) => ({ rankNo, resourceGroupId: 0 })),
|
|
||||||
updatedAtMs: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function completeRelations(relations) {
|
function completeRelations(relations) {
|
||||||
const byType = new Map((relations || []).map((relation) => [relation.relationType, relation]));
|
const byType = new Map((relations || []).map((relation) => [relation.relationType, relation]));
|
||||||
return defaultRelations().map((fallback) => {
|
return defaultRelations().map((fallback) => {
|
||||||
@ -365,44 +328,6 @@ function levelToForm(level) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeWeeklyRank(weeklyRank) {
|
|
||||||
const fallback = defaultWeeklyRank();
|
|
||||||
const normalized = { ...fallback, ...(weeklyRank || {}) };
|
|
||||||
normalized.topCount = 3;
|
|
||||||
normalized.relationType = "cp";
|
|
||||||
normalized.rewards = completeWeeklyRankRewards(normalized.rewards || []);
|
|
||||||
return normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
function completeWeeklyRankRewards(rewards) {
|
|
||||||
const byRank = new Map((rewards || []).map((reward) => [Number(reward.rankNo), reward]));
|
|
||||||
return cpWeeklyRankRanks.map((rankNo) => ({
|
|
||||||
rankNo,
|
|
||||||
resourceGroupId: Number(byRank.get(rankNo)?.resourceGroupId || 0),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function weeklyRankToForm(weeklyRank) {
|
|
||||||
const completed = completeWeeklyRank(weeklyRank);
|
|
||||||
return {
|
|
||||||
enabled: completed.enabled !== false,
|
|
||||||
relationType: "cp",
|
|
||||||
topCount: "3",
|
|
||||||
rewards: completeWeeklyRankRewards(completed.rewards).map((reward) => ({
|
|
||||||
rankNo: reward.rankNo,
|
|
||||||
resourceGroupId: reward.resourceGroupId ? String(reward.resourceGroupId) : "",
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function completeWeeklyRankRewardForms(rewards) {
|
|
||||||
const byRank = new Map((rewards || []).map((reward) => [Number(reward.rankNo), reward]));
|
|
||||||
return cpWeeklyRankRanks.map((rankNo) => ({
|
|
||||||
rankNo,
|
|
||||||
resourceGroupId: byRank.get(rankNo)?.resourceGroupId || "",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function payloadFromForm(form) {
|
function payloadFromForm(form) {
|
||||||
const relations = completeFormRelations(form.relations || []).map((relation) => {
|
const relations = completeFormRelations(form.relations || []).map((relation) => {
|
||||||
const relationType = relation.relationType;
|
const relationType = relation.relationType;
|
||||||
@ -428,29 +353,7 @@ function payloadFromForm(form) {
|
|||||||
levels: completeFormLevels(relation.levels || [], relationType),
|
levels: completeFormLevels(relation.levels || [], relationType),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const weeklyRank = weeklyRankPayloadFromForm(form.weeklyRank || weeklyRankToForm(defaultWeeklyRank()));
|
return { relations };
|
||||||
return { relations, weeklyRank };
|
|
||||||
}
|
|
||||||
|
|
||||||
function weeklyRankPayloadFromForm(weeklyRank) {
|
|
||||||
const enabled = weeklyRank.enabled !== false;
|
|
||||||
const rewards = completeWeeklyRankRewardForms(weeklyRank.rewards || []).map((reward) => ({
|
|
||||||
rankNo: Number(reward.rankNo),
|
|
||||||
resourceGroupId: Number(reward.resourceGroupId || 0),
|
|
||||||
}));
|
|
||||||
if (enabled) {
|
|
||||||
const missing = rewards.find(
|
|
||||||
(reward) => !Number.isInteger(reward.resourceGroupId) || reward.resourceGroupId <= 0,
|
|
||||||
);
|
|
||||||
if (missing) {
|
|
||||||
throw new Error(`CP周榜 Top${missing.rankNo} 奖励资源组必须配置`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
enabled,
|
|
||||||
topCount: 3,
|
|
||||||
rewards,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function completeFormRelations(relations) {
|
function completeFormRelations(relations) {
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import Tab from "@mui/material/Tab";
|
|||||||
import Tabs from "@mui/material/Tabs";
|
import Tabs from "@mui/material/Tabs";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { cpRelationLabels, cpWeeklyRankRanks, useCPConfigPage } from "@/features/cp-config/hooks/useCPConfigPage.js";
|
import { cpRelationLabels, useCPConfigPage } from "@/features/cp-config/hooks/useCPConfigPage.js";
|
||||||
import styles from "@/features/cp-config/cp-config.module.css";
|
import styles from "@/features/cp-config/cp-config.module.css";
|
||||||
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
import { AdminListBody, AdminListPage } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
@ -19,8 +19,8 @@ import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
|||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
|
||||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||||
|
|
||||||
const tabLabels = {
|
const tabLabels = {
|
||||||
relationships: "用户关系列表",
|
relationships: "用户关系列表",
|
||||||
@ -49,9 +49,6 @@ export function CPConfigPage() {
|
|||||||
<SummaryItem label="兄弟上限">{relationMax(relations, "brother")}</SummaryItem>
|
<SummaryItem label="兄弟上限">{relationMax(relations, "brother")}</SummaryItem>
|
||||||
<SummaryItem label="姐妹上限">{relationMax(relations, "sister")}</SummaryItem>
|
<SummaryItem label="姐妹上限">{relationMax(relations, "sister")}</SummaryItem>
|
||||||
<SummaryItem label="CP解除费">{relationBreakupCost(relations, "cp")}</SummaryItem>
|
<SummaryItem label="CP解除费">{relationBreakupCost(relations, "cp")}</SummaryItem>
|
||||||
<SummaryItem label="CP周榜">
|
|
||||||
{page.config.weeklyRank?.enabled === false ? "停用" : "启用"}
|
|
||||||
</SummaryItem>
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.summaryActions}>
|
<div className={styles.summaryActions}>
|
||||||
<Button
|
<Button
|
||||||
@ -63,7 +60,13 @@ export function CPConfigPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={page.loading}
|
disabled={page.loading}
|
||||||
startIcon={page.abilities.canUpdate ? <EditOutlined fontSize="small" /> : <SettingsOutlined fontSize="small" />}
|
startIcon={
|
||||||
|
page.abilities.canUpdate ? (
|
||||||
|
<EditOutlined fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<SettingsOutlined fontSize="small" />
|
||||||
|
)
|
||||||
|
}
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={page.openDrawer}
|
onClick={page.openDrawer}
|
||||||
>
|
>
|
||||||
@ -91,7 +94,9 @@ export function CPConfigPage() {
|
|||||||
onPageChange: isApplicationsTab ? page.loadApplications : page.loadRelationships,
|
onPageChange: isApplicationsTab ? page.loadApplications : page.loadRelationships,
|
||||||
}}
|
}}
|
||||||
rowKey={(item) =>
|
rowKey={(item) =>
|
||||||
isApplicationsTab ? item.applicationId : item.relationshipId || `${item.userA?.userId}-${item.userB?.userId}`
|
isApplicationsTab
|
||||||
|
? item.applicationId
|
||||||
|
: item.relationshipId || `${item.userA?.userId}-${item.userB?.userId}`
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
@ -116,7 +121,7 @@ function CPConfigDialog({ page }) {
|
|||||||
<DialogTitle className={styles.configDialogTitle}>
|
<DialogTitle className={styles.configDialogTitle}>
|
||||||
<div>
|
<div>
|
||||||
<h2>CP配置</h2>
|
<h2>CP配置</h2>
|
||||||
<span>关系等级、解除费用和 CP 周榜奖励</span>
|
<span>关系等级、关系数量和解除费用</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.configDrawerStats}>
|
<div className={styles.configDrawerStats}>
|
||||||
<span>{(page.form.relations || []).length} 个关系</span>
|
<span>{(page.form.relations || []).length} 个关系</span>
|
||||||
@ -139,16 +144,6 @@ function CPConfigDialog({ page }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="form-drawer__section">
|
|
||||||
<div className="form-drawer__section-title">CP周榜奖励</div>
|
|
||||||
<WeeklyRankEditor
|
|
||||||
disabled={disabled}
|
|
||||||
resourceGroups={page.resourceGroups}
|
|
||||||
weeklyRank={page.form.weeklyRank}
|
|
||||||
updateWeeklyRank={page.updateWeeklyRank}
|
|
||||||
updateWeeklyRankReward={page.updateWeeklyRankReward}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions className={styles.configDialogActions}>
|
<DialogActions className={styles.configDialogActions}>
|
||||||
<Button disabled={page.saving} type="button" onClick={page.closeDrawer}>
|
<Button disabled={page.saving} type="button" onClick={page.closeDrawer}>
|
||||||
@ -157,7 +152,13 @@ function CPConfigDialog({ page }) {
|
|||||||
{canUpdate ? (
|
{canUpdate ? (
|
||||||
<Button
|
<Button
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
startIcon={page.saving ? <CircularProgress color="inherit" size={16} /> : <SaveOutlined fontSize="small" />}
|
startIcon={
|
||||||
|
page.saving ? (
|
||||||
|
<CircularProgress color="inherit" size={16} />
|
||||||
|
) : (
|
||||||
|
<SaveOutlined fontSize="small" />
|
||||||
|
)
|
||||||
|
}
|
||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
>
|
>
|
||||||
@ -170,64 +171,6 @@ function CPConfigDialog({ page }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WeeklyRankEditor({ disabled, resourceGroups, weeklyRank, updateWeeklyRank, updateWeeklyRankReward }) {
|
|
||||||
const enabled = weeklyRank?.enabled !== false;
|
|
||||||
const rewards = weeklyRank?.rewards || [];
|
|
||||||
return (
|
|
||||||
<div className={styles.relationEditor}>
|
|
||||||
<div className={styles.relationEditorHeader}>
|
|
||||||
<div className={styles.relationTitleGroup}>
|
|
||||||
<span className={styles.relationTitle}>Last Weekly CP</span>
|
|
||||||
<span className={styles.relationSubtitle}>Top 1-3 资源组会发给获奖 CP 双方</span>
|
|
||||||
</div>
|
|
||||||
<AdminSwitch
|
|
||||||
checked={enabled}
|
|
||||||
checkedLabel="启用"
|
|
||||||
disabled={disabled}
|
|
||||||
label="CP周榜状态"
|
|
||||||
uncheckedLabel="停用"
|
|
||||||
onChange={(event) => updateWeeklyRank({ enabled: event.target.checked })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.levelEditorList}>
|
|
||||||
{cpWeeklyRankRanks.map((rankNo) => {
|
|
||||||
const reward = rewards.find((item) => Number(item.rankNo) === Number(rankNo)) || {
|
|
||||||
rankNo,
|
|
||||||
resourceGroupId: "",
|
|
||||||
};
|
|
||||||
const selectedGroup = resourceGroups.find(
|
|
||||||
(group) => String(group.groupId) === String(reward.resourceGroupId),
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className={styles.levelEditor} key={rankNo}>
|
|
||||||
<div className={styles.levelEditorHeader}>
|
|
||||||
<div className={styles.levelTitleGroup}>
|
|
||||||
<span className={styles.levelTitle}>Top {rankNo}</span>
|
|
||||||
<span className={styles.levelMeta}>
|
|
||||||
{resourceGroupLabel(selectedGroup, reward.resourceGroupId)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.levelFields}>
|
|
||||||
<ResourceGroupSelectField
|
|
||||||
disabled={disabled || !enabled}
|
|
||||||
drawerTitle={`CP周榜 Top ${rankNo} 奖励资源组`}
|
|
||||||
emptyLabel="未配置奖励"
|
|
||||||
groups={resourceGroups}
|
|
||||||
label="奖励资源组"
|
|
||||||
placeholder="点击选择奖励资源组"
|
|
||||||
value={reward.resourceGroupId}
|
|
||||||
onChange={(value) => updateWeeklyRankReward(rankNo, { resourceGroupId: value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function RelationEditor({ disabled, relation, resourceGroups, updateLevel, updateRelation }) {
|
function RelationEditor({ disabled, relation, resourceGroups, updateLevel, updateRelation }) {
|
||||||
const relationType = relation.relationType;
|
const relationType = relation.relationType;
|
||||||
const maxCountDisabled = disabled || relationType === "cp";
|
const maxCountDisabled = disabled || relationType === "cp";
|
||||||
|
|||||||
128
src/features/cp-weekly-rank/api.ts
Normal file
128
src/features/cp-weekly-rank/api.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { apiRequest, type QueryParams } from "@/shared/api/request";
|
||||||
|
import { API_ENDPOINTS } from "@/shared/api/generated/endpoints";
|
||||||
|
|
||||||
|
export interface CPWeeklyRankRewardDto {
|
||||||
|
rankNo: number;
|
||||||
|
resourceGroupId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CPWeeklyRankConfigDto {
|
||||||
|
appCode: string;
|
||||||
|
activityCode: string;
|
||||||
|
enabled: boolean;
|
||||||
|
relationType: string;
|
||||||
|
topCount: number;
|
||||||
|
rewards: CPWeeklyRankRewardDto[];
|
||||||
|
updatedByAdminId?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CPWeeklyRankConfigPayload {
|
||||||
|
enabled: boolean;
|
||||||
|
top_count: number;
|
||||||
|
rewards: Array<{
|
||||||
|
rank_no: number;
|
||||||
|
resource_group_id: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CPWeeklyRankSettlementDto {
|
||||||
|
settlementId: string;
|
||||||
|
periodStartMs: number;
|
||||||
|
periodEndMs: number;
|
||||||
|
rankNo: number;
|
||||||
|
relationshipId: string;
|
||||||
|
userId: string;
|
||||||
|
score: number;
|
||||||
|
resourceGroupId: number;
|
||||||
|
walletCommandId: string;
|
||||||
|
walletGrantId: string;
|
||||||
|
status: string;
|
||||||
|
failureReason: string;
|
||||||
|
attemptCount: number;
|
||||||
|
createdAtMs: number;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Raw = Record<string, unknown>;
|
||||||
|
|
||||||
|
export function getCPWeeklyRankConfig(): Promise<CPWeeklyRankConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.getCPWeeklyRankConfig;
|
||||||
|
return apiRequest<Raw>(endpoint.path, { method: endpoint.method }).then(normalizeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCPWeeklyRankConfig(payload: CPWeeklyRankConfigPayload): Promise<CPWeeklyRankConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateCPWeeklyRankConfig;
|
||||||
|
return apiRequest<Raw, CPWeeklyRankConfigPayload>(endpoint.path, {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCPWeeklyRankSettlements(query: QueryParams = {}) {
|
||||||
|
const endpoint = API_ENDPOINTS.listCPWeeklyRankSettlements;
|
||||||
|
return apiRequest<{ items?: Raw[]; total?: number }>(endpoint.path, { method: endpoint.method, query }).then(
|
||||||
|
(page) => ({
|
||||||
|
items: (page.items || []).map(normalizeSettlement),
|
||||||
|
total: numberValue(page.total),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConfig(raw: Raw): CPWeeklyRankConfigDto {
|
||||||
|
return {
|
||||||
|
appCode: stringValue(raw.appCode ?? raw.app_code),
|
||||||
|
activityCode: stringValue(raw.activityCode ?? raw.activity_code) || "cp_weekly_rank",
|
||||||
|
enabled: raw.enabled !== false,
|
||||||
|
relationType: stringValue(raw.relationType ?? raw.relation_type) || "cp",
|
||||||
|
topCount: numberValue(raw.topCount ?? raw.top_count) || 3,
|
||||||
|
rewards: arrayValue(raw.rewards).map((reward) => {
|
||||||
|
const item = asRecord(reward);
|
||||||
|
return {
|
||||||
|
rankNo: numberValue(item.rankNo ?? item.rank_no),
|
||||||
|
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
updatedByAdminId: numberValue(raw.updatedByAdminId ?? raw.updated_by_admin_id),
|
||||||
|
createdAtMs: numberValue(raw.createdAtMs ?? raw.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(raw.updatedAtMs ?? raw.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSettlement(raw: Raw): CPWeeklyRankSettlementDto {
|
||||||
|
return {
|
||||||
|
settlementId: stringValue(raw.settlementId ?? raw.settlement_id),
|
||||||
|
periodStartMs: numberValue(raw.periodStartMs ?? raw.period_start_ms),
|
||||||
|
periodEndMs: numberValue(raw.periodEndMs ?? raw.period_end_ms),
|
||||||
|
rankNo: numberValue(raw.rankNo ?? raw.rank_no),
|
||||||
|
relationshipId: stringValue(raw.relationshipId ?? raw.relationship_id),
|
||||||
|
userId: stringValue(raw.userId ?? raw.user_id),
|
||||||
|
score: numberValue(raw.score),
|
||||||
|
resourceGroupId: numberValue(raw.resourceGroupId ?? raw.resource_group_id),
|
||||||
|
walletCommandId: stringValue(raw.walletCommandId ?? raw.wallet_command_id),
|
||||||
|
walletGrantId: stringValue(raw.walletGrantId ?? raw.wallet_grant_id),
|
||||||
|
status: stringValue(raw.status),
|
||||||
|
failureReason: stringValue(raw.failureReason ?? raw.failure_reason),
|
||||||
|
attemptCount: numberValue(raw.attemptCount ?? raw.attempt_count),
|
||||||
|
createdAtMs: numberValue(raw.createdAtMs ?? raw.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(raw.updatedAtMs ?? raw.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Raw {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Raw) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayValue(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
const number = Number(value || 0);
|
||||||
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
}
|
||||||
213
src/features/cp-weekly-rank/cp-weekly-rank.module.css
Normal file
213
src/features/cp-weekly-rank/cp-weekly-rank.module.css
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
.summaryPanel {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItems {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItem {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryLabel {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryValue {
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryActions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardStrip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardItem {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardRank {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardGroup {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configDialog {
|
||||||
|
z-index: 1300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configDialogPaper {
|
||||||
|
width: min(760px, calc(100vw - 32px));
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configDialogForm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configDialogTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 22px 24px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configDialogTitle h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configDialogTitle span {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configDialogContent {
|
||||||
|
padding: 0 24px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configDialogActions {
|
||||||
|
padding: 14px 24px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardEditorList {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardEditor {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardEditorHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rewardEditorHeader span:last-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: right;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.periodCell {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusBadge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 64px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusActive {
|
||||||
|
background: color-mix(in srgb, var(--success) 12%, transparent);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusPending {
|
||||||
|
background: color-mix(in srgb, var(--warning) 14%, transparent);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDanger {
|
||||||
|
background: color-mix(in srgb, var(--danger) 12%, transparent);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusDisabled {
|
||||||
|
background: var(--surface-muted);
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.summaryPanel {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryItems,
|
||||||
|
.rewardStrip,
|
||||||
|
.rewardEditorList {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summaryActions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/features/cp-weekly-rank/hooks/useCPWeeklyRankPage.js
Normal file
228
src/features/cp-weekly-rank/hooks/useCPWeeklyRankPage.js
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
getCPWeeklyRankConfig,
|
||||||
|
listCPWeeklyRankSettlements,
|
||||||
|
updateCPWeeklyRankConfig,
|
||||||
|
} from "@/features/cp-weekly-rank/api";
|
||||||
|
import { useCPWeeklyRankAbilities } from "@/features/cp-weekly-rank/permissions.js";
|
||||||
|
import { listResourceGroups } from "@/features/resources/api";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
export const cpWeeklyRankRanks = [1, 2, 3];
|
||||||
|
|
||||||
|
export function useCPWeeklyRankPage() {
|
||||||
|
const abilities = useCPWeeklyRankAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [config, setConfig] = useState(defaultConfig());
|
||||||
|
const [form, setForm] = useState(configToForm(defaultConfig()));
|
||||||
|
const [resourceGroups, setResourceGroups] = useState([]);
|
||||||
|
const [settlementRange, setSettlementRange] = useState(currentUTCWeekRange());
|
||||||
|
const [settlements, setSettlements] = useState({ items: [], total: 0 });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [settlementsLoading, setSettlementsLoading] = useState(false);
|
||||||
|
const [settlementsError, setSettlementsError] = useState("");
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const configuredRewardCount = useMemo(
|
||||||
|
() => completeRewards(config.rewards || []).filter((reward) => Number(reward.resourceGroupId) > 0).length,
|
||||||
|
[config.rewards],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reloadConfig = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [remoteConfig, groups] = await Promise.all([
|
||||||
|
getCPWeeklyRankConfig(),
|
||||||
|
listResourceGroups({ page: 1, page_size: 200, status: "active" }),
|
||||||
|
]);
|
||||||
|
const completed = completeConfig(remoteConfig);
|
||||||
|
setConfig(completed);
|
||||||
|
setForm(configToForm(completed));
|
||||||
|
setResourceGroups(groups.items || []);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "加载 CP 排行活动配置失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [showToast]);
|
||||||
|
|
||||||
|
const reloadSettlements = useCallback(
|
||||||
|
async (range = settlementRange) => {
|
||||||
|
setSettlementsLoading(true);
|
||||||
|
setSettlementsError("");
|
||||||
|
try {
|
||||||
|
const page = await listCPWeeklyRankSettlements({
|
||||||
|
period_start_ms: Number(range.startMs || 0),
|
||||||
|
period_end_ms: Number(range.endMs || 0),
|
||||||
|
});
|
||||||
|
setSettlements(page);
|
||||||
|
} catch (err) {
|
||||||
|
setSettlementsError(err.message || "加载 CP 排行活动发奖记录失败");
|
||||||
|
} finally {
|
||||||
|
setSettlementsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[settlementRange],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reloadConfig();
|
||||||
|
}, [reloadConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reloadSettlements(settlementRange);
|
||||||
|
}, [reloadSettlements, settlementRange]);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
await Promise.allSettled([reloadConfig(), reloadSettlements(settlementRange)]);
|
||||||
|
}, [reloadConfig, reloadSettlements, settlementRange]);
|
||||||
|
|
||||||
|
const openDrawer = () => {
|
||||||
|
setForm(configToForm(config));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDrawer = () => {
|
||||||
|
if (saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm(configToForm(config));
|
||||||
|
setDrawerOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateForm = (patch) => {
|
||||||
|
setForm((current) => ({ ...current, ...patch }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateReward = (rankNo, patch) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
rewards: completeRewardForms(current.rewards || []).map((reward) =>
|
||||||
|
Number(reward.rankNo) === Number(rankNo) ? { ...reward, ...patch } : reward,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!abilities.canUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = payloadFromForm(form);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "CP 排行活动配置参数不正确", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const saved = await updateCPWeeklyRankConfig(payload);
|
||||||
|
const completed = completeConfig(saved);
|
||||||
|
setConfig(completed);
|
||||||
|
setForm(configToForm(completed));
|
||||||
|
setDrawerOpen(false);
|
||||||
|
showToast("CP 排行活动配置已保存", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "保存 CP 排行活动配置失败", "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
closeDrawer,
|
||||||
|
config,
|
||||||
|
configuredRewardCount,
|
||||||
|
drawerOpen,
|
||||||
|
form,
|
||||||
|
loading,
|
||||||
|
openDrawer,
|
||||||
|
reload,
|
||||||
|
reloadSettlements,
|
||||||
|
resourceGroups,
|
||||||
|
saving,
|
||||||
|
settlementRange,
|
||||||
|
settlements,
|
||||||
|
settlementsError,
|
||||||
|
settlementsLoading,
|
||||||
|
setSettlementRange,
|
||||||
|
submit,
|
||||||
|
updateForm,
|
||||||
|
updateReward,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultConfig() {
|
||||||
|
return {
|
||||||
|
activityCode: "cp_weekly_rank",
|
||||||
|
enabled: true,
|
||||||
|
relationType: "cp",
|
||||||
|
topCount: 3,
|
||||||
|
rewards: cpWeeklyRankRanks.map((rankNo) => ({ rankNo, resourceGroupId: 0 })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeConfig(config) {
|
||||||
|
const completed = { ...defaultConfig(), ...(config || {}) };
|
||||||
|
completed.topCount = 3;
|
||||||
|
completed.relationType = "cp";
|
||||||
|
completed.rewards = completeRewards(completed.rewards || []);
|
||||||
|
return completed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeRewards(rewards) {
|
||||||
|
const byRank = new Map((rewards || []).map((reward) => [Number(reward.rankNo), reward]));
|
||||||
|
return cpWeeklyRankRanks.map((rankNo) => ({
|
||||||
|
rankNo,
|
||||||
|
resourceGroupId: Number(byRank.get(rankNo)?.resourceGroupId || 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function configToForm(config) {
|
||||||
|
const completed = completeConfig(config);
|
||||||
|
return {
|
||||||
|
enabled: completed.enabled !== false,
|
||||||
|
rewards: completeRewards(completed.rewards).map((reward) => ({
|
||||||
|
rankNo: reward.rankNo,
|
||||||
|
resourceGroupId: reward.resourceGroupId ? String(reward.resourceGroupId) : "",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeRewardForms(rewards) {
|
||||||
|
const byRank = new Map((rewards || []).map((reward) => [Number(reward.rankNo), reward]));
|
||||||
|
return cpWeeklyRankRanks.map((rankNo) => ({
|
||||||
|
rankNo,
|
||||||
|
resourceGroupId: byRank.get(rankNo)?.resourceGroupId || "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function payloadFromForm(form) {
|
||||||
|
const rewards = completeRewardForms(form.rewards || []).map((reward) => ({
|
||||||
|
rank_no: Number(reward.rankNo),
|
||||||
|
resource_group_id: Number(reward.resourceGroupId || 0),
|
||||||
|
}));
|
||||||
|
if (form.enabled !== false) {
|
||||||
|
const missing = rewards.find(
|
||||||
|
(reward) => !Number.isInteger(reward.resource_group_id) || reward.resource_group_id <= 0,
|
||||||
|
);
|
||||||
|
if (missing) {
|
||||||
|
throw new Error(`Top${missing.rank_no} 奖励资源组必须配置`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enabled: form.enabled !== false,
|
||||||
|
top_count: 3,
|
||||||
|
rewards,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentUTCWeekRange(now = new Date()) {
|
||||||
|
const utcDayStart = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
||||||
|
const weekday = now.getUTCDay() || 7;
|
||||||
|
const startMs = utcDayStart - (weekday - 1) * 86400000;
|
||||||
|
return { endMs: startMs + 7 * 86400000, startMs };
|
||||||
|
}
|
||||||
339
src/features/cp-weekly-rank/pages/CPWeeklyRankPage.jsx
Normal file
339
src/features/cp-weekly-rank/pages/CPWeeklyRankPage.jsx
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
|
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||||
|
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { cpWeeklyRankRanks, useCPWeeklyRankPage } from "@/features/cp-weekly-rank/hooks/useCPWeeklyRankPage.js";
|
||||||
|
import styles from "@/features/cp-weekly-rank/cp-weekly-rank.module.css";
|
||||||
|
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
|
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||||
|
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
|
||||||
|
export function CPWeeklyRankPage() {
|
||||||
|
const page = useCPWeeklyRankPage();
|
||||||
|
const resourceLabels = useMemo(() => groupLabelMap(page.resourceGroups), [page.resourceGroups]);
|
||||||
|
const columns = useMemo(() => settlementColumns(resourceLabels), [resourceLabels]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<div className={styles.summaryPanel}>
|
||||||
|
<div className={styles.summaryItems}>
|
||||||
|
<SummaryItem label="活动编码">{page.config.activityCode || "cp_weekly_rank"}</SummaryItem>
|
||||||
|
<SummaryItem label="活动状态">{page.config.enabled === false ? "停用" : "启用"}</SummaryItem>
|
||||||
|
<SummaryItem label="奖励名次">Top {page.config.topCount || 3}</SummaryItem>
|
||||||
|
<SummaryItem label="已配置奖励">{page.configuredRewardCount} / 3</SummaryItem>
|
||||||
|
<SummaryItem label="最近更新">
|
||||||
|
{page.config.updatedAtMs ? <TimeText value={page.config.updatedAtMs} /> : "-"}
|
||||||
|
</SummaryItem>
|
||||||
|
</div>
|
||||||
|
<div className={styles.summaryActions}>
|
||||||
|
<Button
|
||||||
|
disabled={page.loading}
|
||||||
|
startIcon={<RefreshOutlined fontSize="small" />}
|
||||||
|
onClick={page.reload}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={page.loading}
|
||||||
|
startIcon={
|
||||||
|
page.abilities.canUpdate ? (
|
||||||
|
<EditOutlined fontSize="small" />
|
||||||
|
) : (
|
||||||
|
<SettingsOutlined fontSize="small" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
variant="primary"
|
||||||
|
onClick={page.openDrawer}
|
||||||
|
>
|
||||||
|
配置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RewardPreview config={page.config} resourceLabels={resourceLabels} />
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
disabled={page.settlementsLoading}
|
||||||
|
startIcon={<RefreshOutlined fontSize="small" />}
|
||||||
|
onClick={() => page.reloadSettlements(page.settlementRange)}
|
||||||
|
>
|
||||||
|
刷新记录
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
filters={
|
||||||
|
<TimeRangeFilter label="结算周期" value={page.settlementRange} onChange={page.setSettlementRange} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState
|
||||||
|
error={page.settlementsError}
|
||||||
|
loading={page.settlementsLoading}
|
||||||
|
onRetry={() => page.reloadSettlements(page.settlementRange)}
|
||||||
|
>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
emptyLabel="当前无数据"
|
||||||
|
items={page.settlements.items}
|
||||||
|
minWidth="1320px"
|
||||||
|
rowKey={(item) => item.settlementId || `${item.relationshipId}-${item.userId}`}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
<CPWeeklyRankDialog page={page} resourceLabels={resourceLabels} />
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RewardPreview({ config, resourceLabels }) {
|
||||||
|
const rewards = completeRewards(config.rewards || []);
|
||||||
|
return (
|
||||||
|
<div className={styles.rewardStrip}>
|
||||||
|
{rewards.map((reward) => (
|
||||||
|
<div className={styles.rewardItem} key={reward.rankNo}>
|
||||||
|
<span className={styles.rewardRank}>Top {reward.rankNo}</span>
|
||||||
|
<span className={styles.rewardGroup}>{resourceLabel(resourceLabels, reward.resourceGroupId)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CPWeeklyRankDialog({ page, resourceLabels }) {
|
||||||
|
const canUpdate = page.abilities.canUpdate;
|
||||||
|
const disabled = !canUpdate || page.saving || page.loading;
|
||||||
|
const enabled = page.form.enabled !== false;
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
className={styles.configDialog}
|
||||||
|
maxWidth={false}
|
||||||
|
open={page.drawerOpen}
|
||||||
|
slotProps={{ paper: { className: styles.configDialogPaper } }}
|
||||||
|
onClose={page.saving ? undefined : page.closeDrawer}
|
||||||
|
>
|
||||||
|
<form className={styles.configDialogForm} onSubmit={page.submit}>
|
||||||
|
<DialogTitle className={styles.configDialogTitle}>
|
||||||
|
<div>
|
||||||
|
<h2>CP排行活动</h2>
|
||||||
|
<span>Top 1-3 资源组会发给获奖 CP 双方</span>
|
||||||
|
</div>
|
||||||
|
<AdminSwitch
|
||||||
|
checked={enabled}
|
||||||
|
checkedLabel="启用"
|
||||||
|
disabled={disabled}
|
||||||
|
label="CP排行活动状态"
|
||||||
|
uncheckedLabel="停用"
|
||||||
|
onChange={(event) => page.updateForm({ enabled: event.target.checked })}
|
||||||
|
/>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent className={styles.configDialogContent}>
|
||||||
|
<div className={styles.rewardEditorList}>
|
||||||
|
{cpWeeklyRankRanks.map((rankNo) => {
|
||||||
|
const reward = (page.form.rewards || []).find(
|
||||||
|
(item) => Number(item.rankNo) === Number(rankNo),
|
||||||
|
) || {
|
||||||
|
rankNo,
|
||||||
|
resourceGroupId: "",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={styles.rewardEditor} key={rankNo}>
|
||||||
|
<div className={styles.rewardEditorHeader}>
|
||||||
|
<span>Top {rankNo}</span>
|
||||||
|
<span>{resourceLabel(resourceLabels, reward.resourceGroupId)}</span>
|
||||||
|
</div>
|
||||||
|
<ResourceGroupSelectField
|
||||||
|
disabled={disabled || !enabled}
|
||||||
|
drawerTitle={`CP排行活动 Top ${rankNo} 奖励资源组`}
|
||||||
|
emptyLabel="未配置奖励"
|
||||||
|
groups={page.resourceGroups}
|
||||||
|
label="奖励资源组"
|
||||||
|
placeholder="点击选择奖励资源组"
|
||||||
|
value={reward.resourceGroupId}
|
||||||
|
onChange={(value) =>
|
||||||
|
page.updateReward(rankNo, {
|
||||||
|
resourceGroupId: value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions className={styles.configDialogActions}>
|
||||||
|
<Button disabled={page.saving} type="button" onClick={page.closeDrawer}>
|
||||||
|
{canUpdate ? "取消" : "关闭"}
|
||||||
|
</Button>
|
||||||
|
{canUpdate ? (
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
startIcon={
|
||||||
|
page.saving ? (
|
||||||
|
<CircularProgress color="inherit" size={16} />
|
||||||
|
) : (
|
||||||
|
<SaveOutlined fontSize="small" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</DialogActions>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function settlementColumns(resourceLabels) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "period",
|
||||||
|
label: "结算周期",
|
||||||
|
width: "minmax(240px, 0.9fr)",
|
||||||
|
render: (item) => (
|
||||||
|
<span className={styles.periodCell}>
|
||||||
|
<TimeText value={item.periodStartMs} />
|
||||||
|
<span>至</span>
|
||||||
|
<TimeText value={item.periodEndMs} />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "rankNo",
|
||||||
|
label: "名次",
|
||||||
|
width: "minmax(90px, 0.3fr)",
|
||||||
|
render: (item) => `Top ${item.rankNo || "-"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "userId",
|
||||||
|
label: "用户ID",
|
||||||
|
width: "minmax(140px, 0.45fr)",
|
||||||
|
render: (item) => item.userId || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "relationshipId",
|
||||||
|
label: "关系ID",
|
||||||
|
width: "minmax(220px, 0.75fr)",
|
||||||
|
render: (item) => item.relationshipId || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "score",
|
||||||
|
label: "周积分",
|
||||||
|
width: "minmax(110px, 0.35fr)",
|
||||||
|
render: (item) => formatNumber(item.score),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "resourceGroupId",
|
||||||
|
label: "奖励资源组",
|
||||||
|
width: "minmax(180px, 0.55fr)",
|
||||||
|
render: (item) => resourceLabel(resourceLabels, item.resourceGroupId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "状态",
|
||||||
|
width: "minmax(110px, 0.35fr)",
|
||||||
|
render: (item) => <StatusBadge status={item.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "walletGrantId",
|
||||||
|
label: "发放流水",
|
||||||
|
width: "minmax(180px, 0.55fr)",
|
||||||
|
render: (item) => item.walletGrantId || item.walletCommandId || "-",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "attemptCount",
|
||||||
|
label: "重试",
|
||||||
|
width: "minmax(90px, 0.3fr)",
|
||||||
|
render: (item) => formatNumber(item.attemptCount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAt",
|
||||||
|
label: "更新时间",
|
||||||
|
width: "minmax(170px, 0.55fr)",
|
||||||
|
render: (item) => (item.updatedAtMs ? <TimeText value={item.updatedAtMs} /> : "-"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const normalized = String(status || "").toLowerCase();
|
||||||
|
return (
|
||||||
|
<span className={[styles.statusBadge, statusClassName(normalized)].filter(Boolean).join(" ")}>
|
||||||
|
{statusLabel(normalized)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryItem({ children, label }) {
|
||||||
|
return (
|
||||||
|
<div className={styles.summaryItem}>
|
||||||
|
<span className={styles.summaryLabel}>{label}</span>
|
||||||
|
<span className={styles.summaryValue}>{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeRewards(rewards) {
|
||||||
|
const byRank = new Map((rewards || []).map((reward) => [Number(reward.rankNo), reward]));
|
||||||
|
return cpWeeklyRankRanks.map((rankNo) => ({
|
||||||
|
rankNo,
|
||||||
|
resourceGroupId: Number(byRank.get(rankNo)?.resourceGroupId || 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLabelMap(groups) {
|
||||||
|
return new Map(
|
||||||
|
(groups || []).map((group) => [
|
||||||
|
String(group.groupId),
|
||||||
|
group.name || group.groupCode || `资源组 #${group.groupId}`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resourceLabel(resourceLabels, value) {
|
||||||
|
const normalized = String(value || "");
|
||||||
|
return normalized && resourceLabels.has(normalized)
|
||||||
|
? resourceLabels.get(normalized)
|
||||||
|
: normalized
|
||||||
|
? `资源组 #${normalized}`
|
||||||
|
: "未配置奖励";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status) {
|
||||||
|
const labels = {
|
||||||
|
failed: "失败",
|
||||||
|
granted: "已发放",
|
||||||
|
pending: "待发放",
|
||||||
|
running: "发放中",
|
||||||
|
};
|
||||||
|
return labels[status] || status || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusClassName(status) {
|
||||||
|
if (status === "granted") {
|
||||||
|
return styles.statusActive;
|
||||||
|
}
|
||||||
|
if (status === "failed") {
|
||||||
|
return styles.statusDanger;
|
||||||
|
}
|
||||||
|
if (status === "running") {
|
||||||
|
return styles.statusPending;
|
||||||
|
}
|
||||||
|
return styles.statusDisabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return Number(value || 0).toLocaleString("zh-CN");
|
||||||
|
}
|
||||||
11
src/features/cp-weekly-rank/permissions.js
Normal file
11
src/features/cp-weekly-rank/permissions.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export function useCPWeeklyRankAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canUpdate: can(PERMISSIONS.cpWeeklyRankUpdate),
|
||||||
|
canView: can(PERMISSIONS.cpWeeklyRankView),
|
||||||
|
};
|
||||||
|
}
|
||||||
12
src/features/cp-weekly-rank/routes.js
Normal file
12
src/features/cp-weekly-rank/routes.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const cpWeeklyRankRoutes = [
|
||||||
|
{
|
||||||
|
label: "CP排行活动",
|
||||||
|
loader: () => import("./pages/CPWeeklyRankPage.jsx").then((module) => module.CPWeeklyRankPage),
|
||||||
|
menuCode: MENU_CODES.cpWeeklyRank,
|
||||||
|
pageKey: "cp-weekly-rank",
|
||||||
|
path: "/activities/cp-weekly-rank",
|
||||||
|
permission: PERMISSIONS.cpWeeklyRankView,
|
||||||
|
},
|
||||||
|
];
|
||||||
@ -61,8 +61,14 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
region_id: 7,
|
region_id: 7,
|
||||||
status: "active",
|
status: "active",
|
||||||
});
|
});
|
||||||
await createManager({ canBlockUser: false, contact: "+63", targetUserId: "1003" });
|
await createManager({ canBlockUser: false, canGrantBadge: false, contact: "+63", targetUserId: "1003" });
|
||||||
await updateManager("1003", { canBlockUser: false, canGrantVehicle: true, contact: "+63" });
|
await updateManager("1003", {
|
||||||
|
canBlockUser: false,
|
||||||
|
canGrantVehicle: true,
|
||||||
|
canTransferUserCountry: false,
|
||||||
|
contact: "+63",
|
||||||
|
status: "disabled",
|
||||||
|
});
|
||||||
await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" });
|
await listHosts({ agency_id: 41, page: 1, page_size: 10, sort_by: "diamond", sort_direction: "desc" });
|
||||||
await listCoinSellers({
|
await listCoinSellers({
|
||||||
page: 1,
|
page: 1,
|
||||||
@ -92,6 +98,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
await debitCoinSellerStock("1001", {
|
await debitCoinSellerStock("1001", {
|
||||||
coinAmount: 1000,
|
coinAmount: 1000,
|
||||||
commandId: "coin-seller-stock-debit-test",
|
commandId: "coin-seller-stock-debit-test",
|
||||||
|
rechargeAmount: "1.25",
|
||||||
reason: "deduct bad stock",
|
reason: "deduct bad stock",
|
||||||
});
|
});
|
||||||
await deleteAgency(7001, { commandId: "agency-delete-test", reason: "cleanup" });
|
await deleteAgency(7001, { commandId: "agency-delete-test", reason: "cleanup" });
|
||||||
@ -132,6 +139,7 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
expect(managerCreateInit?.method).toBe("POST");
|
expect(managerCreateInit?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({
|
expect(JSON.parse(String(managerCreateInit?.body))).toEqual({
|
||||||
canBlockUser: false,
|
canBlockUser: false,
|
||||||
|
canGrantBadge: false,
|
||||||
contact: "+63",
|
contact: "+63",
|
||||||
targetUserId: "1003",
|
targetUserId: "1003",
|
||||||
});
|
});
|
||||||
@ -140,7 +148,9 @@ test("host org list APIs use generated admin paths and filters", async () => {
|
|||||||
expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({
|
expect(JSON.parse(String(managerUpdateInit?.body))).toEqual({
|
||||||
canBlockUser: false,
|
canBlockUser: false,
|
||||||
canGrantVehicle: true,
|
canGrantVehicle: true,
|
||||||
|
canTransferUserCountry: false,
|
||||||
contact: "+63",
|
contact: "+63",
|
||||||
|
status: "disabled",
|
||||||
});
|
});
|
||||||
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
|
expect(String(hostUrl)).toContain("/api/v1/admin/hosts?");
|
||||||
expect(String(hostUrl)).toContain("agency_id=41");
|
expect(String(hostUrl)).toContain("agency_id=41");
|
||||||
|
|||||||
@ -318,11 +318,12 @@ export function debitCoinSellerStock(
|
|||||||
userId: EntityId,
|
userId: EntityId,
|
||||||
payload: CoinSellerStockDebitPayload,
|
payload: CoinSellerStockDebitPayload,
|
||||||
): Promise<CoinSellerStockDebitDto> {
|
): Promise<CoinSellerStockDebitDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.debitCoinSellerStock;
|
||||||
return apiRequest<CoinSellerStockDebitDto, CoinSellerStockDebitPayload>(
|
return apiRequest<CoinSellerStockDebitDto, CoinSellerStockDebitPayload>(
|
||||||
`/v1/admin/coin-sellers/${encodeURIComponent(String(userId))}/stock-debits`,
|
apiEndpointPath(API_OPERATIONS.debitCoinSellerStock, { user_id: userId }),
|
||||||
{
|
{
|
||||||
body: payload,
|
body: payload,
|
||||||
method: "POST",
|
method: endpoint.method,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,7 @@ const emptyStockDebitForm = () => ({
|
|||||||
coinAmount: "",
|
coinAmount: "",
|
||||||
commandId: makeCommandId("coin-seller-stock-debit"),
|
commandId: makeCommandId("coin-seller-stock-debit"),
|
||||||
reason: "",
|
reason: "",
|
||||||
|
rechargeAmount: "",
|
||||||
});
|
});
|
||||||
const emptyRateTier = (index = 0) => ({
|
const emptyRateTier = (index = 0) => ({
|
||||||
coinPerUsd: "",
|
coinPerUsd: "",
|
||||||
@ -158,7 +159,7 @@ export function useHostCoinSellersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openStockCredit = (seller) => {
|
const openStockCredit = (seller) => {
|
||||||
if (!seller?.userId || seller.status !== "active" || !abilities.canStockCredit) {
|
if (!seller?.userId || !abilities.canStockCredit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedSeller(seller);
|
setSelectedSeller(seller);
|
||||||
@ -167,7 +168,7 @@ export function useHostCoinSellersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openStockDebit = (seller) => {
|
const openStockDebit = (seller) => {
|
||||||
if (!seller?.userId || seller.status !== "active" || !abilities.canStockCredit) {
|
if (!seller?.userId || !abilities.canStockCredit) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelectedSeller(seller);
|
setSelectedSeller(seller);
|
||||||
|
|||||||
@ -12,11 +12,13 @@ const emptyData = { items: [], page: 1, pageSize, total: 0 };
|
|||||||
export const managerPermissionFields = [
|
export const managerPermissionFields = [
|
||||||
"canGrantAvatarFrame",
|
"canGrantAvatarFrame",
|
||||||
"canGrantVehicle",
|
"canGrantVehicle",
|
||||||
|
"canGrantBadge",
|
||||||
"canUpdateUserLevel",
|
"canUpdateUserLevel",
|
||||||
"canAddBdLeader",
|
"canAddBdLeader",
|
||||||
"canAddAdmin",
|
"canAddAdmin",
|
||||||
"canAddSuperadmin",
|
"canAddSuperadmin",
|
||||||
"canBlockUser",
|
"canBlockUser",
|
||||||
|
"canTransferUserCountry",
|
||||||
];
|
];
|
||||||
|
|
||||||
const emptyPermissions = () => Object.fromEntries(managerPermissionFields.map((field) => [field, true]));
|
const emptyPermissions = () => Object.fromEntries(managerPermissionFields.map((field) => [field, true]));
|
||||||
@ -26,6 +28,12 @@ const emptyManagerForm = () => ({ contact: "", targetUserId: "", ...emptyPermiss
|
|||||||
const managerPermissionPayload = (form) =>
|
const managerPermissionPayload = (form) =>
|
||||||
Object.fromEntries(managerPermissionFields.map((field) => [field, form[field] !== false]));
|
Object.fromEntries(managerPermissionFields.map((field) => [field, form[field] !== false]));
|
||||||
|
|
||||||
|
const managerStatusPayload = (item, status) => ({
|
||||||
|
contact: item?.contact || "",
|
||||||
|
status,
|
||||||
|
...Object.fromEntries(managerPermissionFields.map((field) => [field, item?.[field] !== false])),
|
||||||
|
});
|
||||||
|
|
||||||
const managerFormFromItem = (item) => ({
|
const managerFormFromItem = (item) => ({
|
||||||
contact: item?.contact || "",
|
contact: item?.contact || "",
|
||||||
targetUserId: String(item?.userId || ""),
|
targetUserId: String(item?.userId || ""),
|
||||||
@ -137,6 +145,21 @@ export function useHostManagersPage() {
|
|||||||
setActiveAction("manager-edit");
|
setActiveAction("manager-edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleManager = async (item, enabled) => {
|
||||||
|
const nextStatus = enabled ? "active" : "disabled";
|
||||||
|
setLoadingAction(`manager-status-${item.userId}`);
|
||||||
|
try {
|
||||||
|
// 状态接口复用经理更新入口;提交当前行的权限快照,避免只改状态时把已有权限开关覆盖成后端默认值。
|
||||||
|
await updateManager(item.userId, managerStatusPayload(item, nextStatus));
|
||||||
|
showToast(enabled ? "经理已启用" : "经理已停用", "success");
|
||||||
|
await reload();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || (enabled ? "启用经理失败" : "停用经理失败"), "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
abilities,
|
abilities,
|
||||||
activeAction,
|
activeAction,
|
||||||
@ -163,5 +186,6 @@ export function useHostManagersPage() {
|
|||||||
setPage,
|
setPage,
|
||||||
status,
|
status,
|
||||||
submitManager,
|
submitManager,
|
||||||
|
toggleManager,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -230,7 +230,7 @@ export function HostCoinSellersPage() {
|
|||||||
</HostOrgActionModal>
|
</HostOrgActionModal>
|
||||||
|
|
||||||
<HostOrgActionModal
|
<HostOrgActionModal
|
||||||
disabled={!page.abilities.canStockCredit || page.selectedSeller?.status !== "active"}
|
disabled={!page.abilities.canStockCredit}
|
||||||
loading={String(page.loadingAction).startsWith("coin-seller-stock")}
|
loading={String(page.loadingAction).startsWith("coin-seller-stock")}
|
||||||
open={page.activeAction === "stock"}
|
open={page.activeAction === "stock"}
|
||||||
sectioned={false}
|
sectioned={false}
|
||||||
@ -306,7 +306,7 @@ export function HostCoinSellersPage() {
|
|||||||
</HostOrgActionModal>
|
</HostOrgActionModal>
|
||||||
|
|
||||||
<HostOrgActionModal
|
<HostOrgActionModal
|
||||||
disabled={!page.abilities.canStockCredit || page.selectedSeller?.status !== "active"}
|
disabled={!page.abilities.canStockCredit}
|
||||||
loading={String(page.loadingAction).startsWith("coin-seller-stock-debit")}
|
loading={String(page.loadingAction).startsWith("coin-seller-stock-debit")}
|
||||||
open={page.activeAction === "stock-debit"}
|
open={page.activeAction === "stock-debit"}
|
||||||
sectioned={false}
|
sectioned={false}
|
||||||
@ -326,17 +326,30 @@ export function HostCoinSellersPage() {
|
|||||||
</AdminFormFieldGrid>
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="扣除信息">
|
<AdminFormSection title="扣除信息">
|
||||||
<AdminFormAmountField
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
disabled={!page.abilities.canStockCredit}
|
<AdminFormAmountField
|
||||||
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
disabled={!page.abilities.canStockCredit}
|
||||||
label="扣除金币"
|
inputProps={{ inputMode: "numeric", pattern: "[0-9]*" }}
|
||||||
required
|
label="扣除金币"
|
||||||
unit="金币"
|
required
|
||||||
value={stockDebitForm.coinAmount}
|
unit="金币"
|
||||||
onChange={(event) =>
|
value={stockDebitForm.coinAmount}
|
||||||
page.setStockDebitForm?.({ ...stockDebitForm, coinAmount: event.target.value })
|
onChange={(event) =>
|
||||||
}
|
page.setStockDebitForm?.({ ...stockDebitForm, coinAmount: event.target.value })
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
<AdminFormAmountField
|
||||||
|
disabled={!page.abilities.canStockCredit}
|
||||||
|
inputProps={{ inputMode: "decimal" }}
|
||||||
|
label="扣除USDT数量"
|
||||||
|
required
|
||||||
|
unit="USDT"
|
||||||
|
value={stockDebitForm.rechargeAmount}
|
||||||
|
onChange={(event) =>
|
||||||
|
page.setStockDebitForm?.({ ...stockDebitForm, rechargeAmount: event.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AdminFormFieldGrid>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
<AdminFormSection title="备注信息">
|
<AdminFormSection title="备注信息">
|
||||||
<TextField
|
<TextField
|
||||||
@ -504,7 +517,7 @@ function SellerActions({ item, page }) {
|
|||||||
) : null}
|
) : null}
|
||||||
{page.abilities.canStockCredit ? (
|
{page.abilities.canStockCredit ? (
|
||||||
<AdminActionIconButton
|
<AdminActionIconButton
|
||||||
disabled={item.status !== "active" || page.loadingAction === `coin-seller-stock-${item.userId}`}
|
disabled={page.loadingAction === `coin-seller-stock-${item.userId}`}
|
||||||
label="加金币"
|
label="加金币"
|
||||||
sx={coinCreditActionSx}
|
sx={coinCreditActionSx}
|
||||||
onClick={() => page.openStockCredit(item)}
|
onClick={() => page.openStockCredit(item)}
|
||||||
@ -514,7 +527,7 @@ function SellerActions({ item, page }) {
|
|||||||
) : null}
|
) : null}
|
||||||
{page.abilities.canStockCredit ? (
|
{page.abilities.canStockCredit ? (
|
||||||
<AdminActionIconButton
|
<AdminActionIconButton
|
||||||
disabled={item.status !== "active" || page.loadingAction === `coin-seller-stock-debit-${item.userId}`}
|
disabled={page.loadingAction === `coin-seller-stock-debit-${item.userId}`}
|
||||||
label="扣金币"
|
label="扣金币"
|
||||||
sx={coinDebitActionSx}
|
sx={coinDebitActionSx}
|
||||||
onClick={() => page.openStockDebit(item)}
|
onClick={() => page.openStockDebit(item)}
|
||||||
|
|||||||
@ -134,6 +134,7 @@ function pageFixture(patch = {}) {
|
|||||||
setPage: vi.fn(),
|
setPage: vi.fn(),
|
||||||
setStockForm: vi.fn(),
|
setStockForm: vi.fn(),
|
||||||
status: "",
|
status: "",
|
||||||
|
stockDebitForm: { coinAmount: "", reason: "", rechargeAmount: "" },
|
||||||
stockForm: { coinAmount: "", reason: "", rechargeAmount: "", type: "usdt_purchase" },
|
stockForm: { coinAmount: "", reason: "", rechargeAmount: "", type: "usdt_purchase" },
|
||||||
sortBy: "",
|
sortBy: "",
|
||||||
sortDirection: "",
|
sortDirection: "",
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { formatMillis } from "@/shared/utils/time.js";
|
|||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||||
import { managerStatusFilters, statusLabels } from "@/features/host-org/constants.js";
|
import { managerStatusFilters } from "@/features/host-org/constants.js";
|
||||||
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
import { HostOrgActionModal } from "@/features/host-org/components/HostOrgActionModal.jsx";
|
||||||
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
import { HostOrgPerson, hostOrgRegionName } from "@/features/host-org/components/HostOrgIdentity.jsx";
|
||||||
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
import { HostOrgTable } from "@/features/host-org/components/HostOrgTable.jsx";
|
||||||
@ -18,11 +18,13 @@ import styles from "@/features/host-org/host-org.module.css";
|
|||||||
const managerPermissionOptions = [
|
const managerPermissionOptions = [
|
||||||
{ key: "canGrantAvatarFrame", label: "头像框" },
|
{ key: "canGrantAvatarFrame", label: "头像框" },
|
||||||
{ key: "canGrantVehicle", label: "Car" },
|
{ key: "canGrantVehicle", label: "Car" },
|
||||||
|
{ key: "canGrantBadge", label: "徽章" },
|
||||||
{ key: "canUpdateUserLevel", label: "升级等级" },
|
{ key: "canUpdateUserLevel", label: "升级等级" },
|
||||||
{ key: "canAddBdLeader", label: "BD Leader" },
|
{ key: "canAddBdLeader", label: "BD Leader" },
|
||||||
{ key: "canAddAdmin", label: "Admin" },
|
{ key: "canAddAdmin", label: "Admin" },
|
||||||
{ key: "canAddSuperadmin", label: "Superadmin" },
|
{ key: "canAddSuperadmin", label: "Superadmin" },
|
||||||
{ key: "canBlockUser", label: "Block User" },
|
{ key: "canBlockUser", label: "Block User" },
|
||||||
|
{ key: "canTransferUserCountry", label: "转移国家" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const managerColumns = [
|
const managerColumns = [
|
||||||
@ -53,7 +55,7 @@ const managerColumns = [
|
|||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
label: "状态",
|
label: "状态",
|
||||||
render: (item) => <ManagerStatus item={item} />,
|
render: (item, _index, context) => <ManagerStatusSwitch item={item} page={context?.page} />,
|
||||||
width: "minmax(90px, 0.7fr)",
|
width: "minmax(90px, 0.7fr)",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -185,12 +187,14 @@ function ManagerUser({ item }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ManagerStatus({ item }) {
|
function ManagerStatusSwitch({ item, page }) {
|
||||||
const active = item.status === "active";
|
|
||||||
return (
|
return (
|
||||||
<span className={active ? styles.positive : styles.negative}>
|
<AdminSwitch
|
||||||
{statusLabels[item.status] || item.status || "-"}
|
checked={item.status === "active"}
|
||||||
</span>
|
disabled={!page?.abilities.canUpdate || page.loadingAction === `manager-status-${item.userId}`}
|
||||||
|
inputProps={{ "aria-label": item.status === "active" ? "停用经理" : "启用经理" }}
|
||||||
|
onChange={(event) => page.toggleManager(item, event.target.checked)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
103
src/features/host-org/pages/HostManagersPage.test.jsx
Normal file
103
src/features/host-org/pages/HostManagersPage.test.jsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { HostManagersPage } from "./HostManagersPage.jsx";
|
||||||
|
import { useHostManagersPage } from "@/features/host-org/hooks/useHostManagersPage.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
toggleManager: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/host-org/hooks/useHostManagersPage.js", () => ({
|
||||||
|
useHostManagersPage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("manager status column uses a switch and toggles through the page hook", () => {
|
||||||
|
const manager = managerFixture();
|
||||||
|
vi.mocked(useHostManagersPage).mockReturnValue(pageFixture({ data: { items: [manager], page: 1, pageSize: 50, total: 1 } }));
|
||||||
|
|
||||||
|
render(<HostManagersPage />);
|
||||||
|
|
||||||
|
const statusSwitch = screen.getByRole("switch", { name: "停用经理" });
|
||||||
|
expect(statusSwitch).toBeChecked();
|
||||||
|
|
||||||
|
fireEvent.click(statusSwitch);
|
||||||
|
|
||||||
|
expect(mocks.toggleManager).toHaveBeenCalledWith(manager, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
function pageFixture(patch = {}) {
|
||||||
|
return {
|
||||||
|
abilities: {
|
||||||
|
canCreate: true,
|
||||||
|
canUpdate: true,
|
||||||
|
canView: true,
|
||||||
|
},
|
||||||
|
activeAction: "",
|
||||||
|
changeQuery: vi.fn(),
|
||||||
|
changeRegionId: vi.fn(),
|
||||||
|
changeStatus: vi.fn(),
|
||||||
|
closeAction: vi.fn(),
|
||||||
|
data: { items: [], page: 1, pageSize: 50, total: 0 },
|
||||||
|
editingManager: null,
|
||||||
|
error: null,
|
||||||
|
loading: false,
|
||||||
|
loadingAction: "",
|
||||||
|
loadingRegions: false,
|
||||||
|
managerForm: {
|
||||||
|
canAddAdmin: true,
|
||||||
|
canAddBdLeader: true,
|
||||||
|
canAddSuperadmin: true,
|
||||||
|
canBlockUser: true,
|
||||||
|
canGrantAvatarFrame: true,
|
||||||
|
canGrantBadge: true,
|
||||||
|
canGrantVehicle: true,
|
||||||
|
canTransferUserCountry: true,
|
||||||
|
canUpdateUserLevel: true,
|
||||||
|
contact: "",
|
||||||
|
targetUserId: "",
|
||||||
|
},
|
||||||
|
openManagerEdit: vi.fn(),
|
||||||
|
openManagerForm: vi.fn(),
|
||||||
|
page: 1,
|
||||||
|
query: "",
|
||||||
|
regionId: "",
|
||||||
|
regionOptions: [],
|
||||||
|
reload: vi.fn(),
|
||||||
|
resetFilters: vi.fn(),
|
||||||
|
setManagerForm: vi.fn(),
|
||||||
|
setPage: vi.fn(),
|
||||||
|
status: "",
|
||||||
|
submitManager: vi.fn(),
|
||||||
|
toggleManager: mocks.toggleManager,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function managerFixture() {
|
||||||
|
return {
|
||||||
|
avatar: "",
|
||||||
|
bdLeaderCount: 3,
|
||||||
|
canAddAdmin: true,
|
||||||
|
canAddBdLeader: false,
|
||||||
|
canAddSuperadmin: true,
|
||||||
|
canBlockUser: true,
|
||||||
|
canGrantAvatarFrame: true,
|
||||||
|
canGrantBadge: true,
|
||||||
|
canGrantVehicle: true,
|
||||||
|
canTransferUserCountry: true,
|
||||||
|
canUpdateUserLevel: true,
|
||||||
|
contact: "+63 910 323 3670",
|
||||||
|
displayUserId: "167579",
|
||||||
|
lastInvitedAtMs: 1782198308000,
|
||||||
|
regionId: 1,
|
||||||
|
regionName: "欧亚美区",
|
||||||
|
status: "active",
|
||||||
|
updatedAtMs: 1782284840000,
|
||||||
|
userId: "10001",
|
||||||
|
username: "PH-MANAGER",
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -114,7 +114,9 @@ export const createManagerSchema = z.object({
|
|||||||
canAddSuperadmin: z.boolean().optional().default(true),
|
canAddSuperadmin: z.boolean().optional().default(true),
|
||||||
canBlockUser: z.boolean().optional().default(true),
|
canBlockUser: z.boolean().optional().default(true),
|
||||||
canGrantAvatarFrame: z.boolean().optional().default(true),
|
canGrantAvatarFrame: z.boolean().optional().default(true),
|
||||||
|
canGrantBadge: z.boolean().optional().default(true),
|
||||||
canGrantVehicle: z.boolean().optional().default(true),
|
canGrantVehicle: z.boolean().optional().default(true),
|
||||||
|
canTransferUserCountry: z.boolean().optional().default(true),
|
||||||
canUpdateUserLevel: z.boolean().optional().default(true),
|
canUpdateUserLevel: z.boolean().optional().default(true),
|
||||||
contact: z.string().trim().max(128, "联系方式不能超过 128 个字符").optional().default(""),
|
contact: z.string().trim().max(128, "联系方式不能超过 128 个字符").optional().default(""),
|
||||||
targetUserId: userIdSchema,
|
targetUserId: userIdSchema,
|
||||||
@ -148,6 +150,11 @@ export const coinSellerStockCreditSchema = z
|
|||||||
export const coinSellerStockDebitSchema = z.object({
|
export const coinSellerStockDebitSchema = z.object({
|
||||||
coinAmount: z.coerce.number().int().positive("请输入扣除金币"),
|
coinAmount: z.coerce.number().int().positive("请输入扣除金币"),
|
||||||
commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"),
|
commandId: z.string().trim().min(1, "请输入 Command ID").max(120, "Command ID 不能超过 120 个字符"),
|
||||||
|
rechargeAmount: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^(0|[1-9]\d*)(\.\d{1,6})?$/, "请输入最多 6 位小数的 USDT 数量")
|
||||||
|
.refine((value) => Number(value) > 0, "请输入扣除USDT数量"),
|
||||||
reason: z.string().trim().min(1, "请输入扣除原因").max(512, "原因不能超过 512 个字符"),
|
reason: z.string().trim().min(1, "请输入扣除原因").max(512, "原因不能超过 512 个字符"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ const ledgerTypeLabels = {
|
|||||||
|
|
||||||
const bizTypeLabels = {
|
const bizTypeLabels = {
|
||||||
coin_seller_coin_compensation: "金币补偿",
|
coin_seller_coin_compensation: "金币补偿",
|
||||||
|
coin_seller_stock_deduction: "USDT扣除",
|
||||||
coin_seller_stock_purchase: "USDT进货",
|
coin_seller_stock_purchase: "USDT进货",
|
||||||
coin_seller_transfer: "币商转用户",
|
coin_seller_transfer: "币商转用户",
|
||||||
manual_credit: "金币增加",
|
manual_credit: "金币增加",
|
||||||
@ -280,11 +281,16 @@ function SalaryTransferAmount({ entry }) {
|
|||||||
|
|
||||||
function PaidUSDTAmount({ entry }) {
|
function PaidUSDTAmount({ entry }) {
|
||||||
const stockType = entry.stockType || entry.metadata?.stock_type;
|
const stockType = entry.stockType || entry.metadata?.stock_type;
|
||||||
if (entry.bizType !== "coin_seller_stock_purchase" && stockType !== "usdt_purchase") {
|
const isUSDTStock =
|
||||||
|
entry.bizType === "coin_seller_stock_purchase" ||
|
||||||
|
entry.bizType === "coin_seller_stock_deduction" ||
|
||||||
|
stockType === "usdt_purchase" ||
|
||||||
|
stockType === "usdt_deduction";
|
||||||
|
if (!isUSDTStock) {
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
const amountMicro = Number(entry.paidAmountMicro || entry.metadata?.paid_amount_micro || 0);
|
const amountMicro = Number(entry.paidAmountMicro || entry.metadata?.paid_amount_micro || 0);
|
||||||
return amountMicro > 0 ? formatUSDTMicro(amountMicro) : "-";
|
return amountMicro !== 0 ? formatSignedUSDTMicro(amountMicro) : "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatUSDMinor(value) {
|
function formatUSDMinor(value) {
|
||||||
@ -301,3 +307,12 @@ function formatUSDTMicro(value) {
|
|||||||
const trimmedFraction = String(fraction).padStart(6, "0").replace(/0+$/, "");
|
const trimmedFraction = String(fraction).padStart(6, "0").replace(/0+$/, "");
|
||||||
return trimmedFraction ? `${whole}.${trimmedFraction}` : String(whole);
|
return trimmedFraction ? `${whole}.${trimmedFraction}` : String(whole);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSignedUSDTMicro(value) {
|
||||||
|
const amount = Number(value || 0);
|
||||||
|
if (!Number.isFinite(amount) || amount === 0) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
const sign = amount < 0 ? "-" : "";
|
||||||
|
return `${sign}${formatUSDTMicro(Math.abs(amount))}`;
|
||||||
|
}
|
||||||
|
|||||||
@ -8,8 +8,10 @@ import type {
|
|||||||
RechargeBillDto,
|
RechargeBillDto,
|
||||||
RechargeProductDto,
|
RechargeProductDto,
|
||||||
RechargeProductPayload,
|
RechargeProductPayload,
|
||||||
|
TemporaryPaymentLinkDto,
|
||||||
ThirdPartyPaymentChannelDto,
|
ThirdPartyPaymentChannelDto,
|
||||||
ThirdPartyPaymentMethodDto,
|
ThirdPartyPaymentMethodDto,
|
||||||
|
ThirdPartyPaymentRateSyncPayload,
|
||||||
ThirdPartyPaymentRateSyncDto,
|
ThirdPartyPaymentRateSyncDto,
|
||||||
} from "@/shared/api/types";
|
} from "@/shared/api/types";
|
||||||
|
|
||||||
@ -71,6 +73,27 @@ export function listThirdPartyPaymentChannels(): Promise<ApiList<ThirdPartyPayme
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listTemporaryPaymentLinks(query: PageQuery = {}): Promise<ApiPage<TemporaryPaymentLinkDto>> {
|
||||||
|
const endpoint = API_ENDPOINTS.listTemporaryPaymentLinks;
|
||||||
|
return apiRequest<ApiPage<TemporaryPaymentLinkDto>>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.listTemporaryPaymentLinks),
|
||||||
|
{
|
||||||
|
method: endpoint.method,
|
||||||
|
query,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTemporaryPaymentLink(orderId: EntityId): Promise<TemporaryPaymentLinkDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.getTemporaryPaymentLink;
|
||||||
|
return apiRequest<TemporaryPaymentLinkDto>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.getTemporaryPaymentLink, { order_id: orderId }),
|
||||||
|
{
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function setThirdPartyPaymentMethodStatus(
|
export function setThirdPartyPaymentMethodStatus(
|
||||||
methodId: EntityId,
|
methodId: EntityId,
|
||||||
payload: { enabled: boolean },
|
payload: { enabled: boolean },
|
||||||
@ -99,9 +122,15 @@ export function updateThirdPartyPaymentRate(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncThirdPartyPaymentRates(): Promise<ThirdPartyPaymentRateSyncDto> {
|
export function syncThirdPartyPaymentRates(
|
||||||
|
payload: ThirdPartyPaymentRateSyncPayload = { markupPercent: 0 },
|
||||||
|
): Promise<ThirdPartyPaymentRateSyncDto> {
|
||||||
const endpoint = API_ENDPOINTS.syncThirdPartyPaymentRates;
|
const endpoint = API_ENDPOINTS.syncThirdPartyPaymentRates;
|
||||||
return apiRequest<ThirdPartyPaymentRateSyncDto>(apiEndpointPath(API_OPERATIONS.syncThirdPartyPaymentRates), {
|
return apiRequest<ThirdPartyPaymentRateSyncDto, ThirdPartyPaymentRateSyncPayload>(
|
||||||
method: endpoint.method,
|
apiEndpointPath(API_OPERATIONS.syncThirdPartyPaymentRates),
|
||||||
});
|
{
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
106
src/features/payment/hooks/useTemporaryPaymentLinksPage.js
Normal file
106
src/features/payment/hooks/useTemporaryPaymentLinksPage.js
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { getTemporaryPaymentLink, listTemporaryPaymentLinks } from "@/features/payment/api";
|
||||||
|
import { usePaymentAbilities } from "@/features/payment/permissions.js";
|
||||||
|
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
|
const pageSize = 50;
|
||||||
|
|
||||||
|
export function useTemporaryPaymentLinksPage() {
|
||||||
|
const abilities = usePaymentAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [keyword, setKeyword] = useState("");
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
|
const [providerCode, setProviderCode] = useState("");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
|
||||||
|
const filters = useMemo(
|
||||||
|
() => ({
|
||||||
|
keyword,
|
||||||
|
provider_code: providerCode,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
[keyword, providerCode, status],
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = usePaginatedQuery({
|
||||||
|
errorMessage: "加载三方临时支付链接失败",
|
||||||
|
fetcher: listTemporaryPaymentLinks,
|
||||||
|
filters,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
queryKey: ["payment", "temporary-links", filters, page],
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetPage = (setter) => (value) => {
|
||||||
|
setter(value);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetFilters = () => {
|
||||||
|
setKeyword("");
|
||||||
|
setStatus("");
|
||||||
|
setProviderCode("");
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyOrder = async (orderId) => {
|
||||||
|
// 单条核验必须走 admin-server,由 wallet-service 按 provider 重新查询状态;前端只传订单号,避免三方签名参数外露。
|
||||||
|
const id = String(orderId || "").trim();
|
||||||
|
if (!id || !abilities.canViewTemporaryLink) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setLoadingAction(`verify:${id}`);
|
||||||
|
try {
|
||||||
|
const order = await getTemporaryPaymentLink(id);
|
||||||
|
await result.reload();
|
||||||
|
showToast(order?.status === "paid" ? "支付已成功" : `当前状态:${temporaryStatusLabel(order?.status)}`, "success");
|
||||||
|
return order;
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "验证支付状态失败", "error");
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
data: result.data,
|
||||||
|
error: result.error,
|
||||||
|
keyword,
|
||||||
|
loading: result.loading,
|
||||||
|
loadingAction,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
providerCode,
|
||||||
|
reload: result.reload,
|
||||||
|
resetFilters,
|
||||||
|
setKeyword: resetPage(setKeyword),
|
||||||
|
setPage,
|
||||||
|
setProviderCode: resetPage(setProviderCode),
|
||||||
|
setStatus: resetPage(setStatus),
|
||||||
|
status,
|
||||||
|
verifyOrder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function temporaryStatusLabel(value) {
|
||||||
|
if (value === "paid") {
|
||||||
|
return "已支付";
|
||||||
|
}
|
||||||
|
if (value === "redirected") {
|
||||||
|
return "已生成链接";
|
||||||
|
}
|
||||||
|
if (value === "pending") {
|
||||||
|
return "待支付";
|
||||||
|
}
|
||||||
|
if (value === "failed") {
|
||||||
|
return "失败";
|
||||||
|
}
|
||||||
|
if (value === "credited") {
|
||||||
|
return "已入账";
|
||||||
|
}
|
||||||
|
return value || "-";
|
||||||
|
}
|
||||||
@ -6,10 +6,12 @@ import {
|
|||||||
updateThirdPartyPaymentRate,
|
updateThirdPartyPaymentRate,
|
||||||
} from "@/features/payment/api";
|
} from "@/features/payment/api";
|
||||||
import { usePaymentAbilities } from "@/features/payment/permissions.js";
|
import { usePaymentAbilities } from "@/features/payment/permissions.js";
|
||||||
|
import { thirdPartyPaymentRateSyncSchema } from "@/features/payment/schema";
|
||||||
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
|
||||||
const emptyChannels = { items: [], total: 0 };
|
const emptyChannels = { items: [], total: 0 };
|
||||||
|
const defaultSyncRateForm = { markupPercent: "0" };
|
||||||
|
|
||||||
export function useThirdPartyPaymentPage() {
|
export function useThirdPartyPaymentPage() {
|
||||||
const abilities = usePaymentAbilities();
|
const abilities = usePaymentAbilities();
|
||||||
@ -17,6 +19,8 @@ export function useThirdPartyPaymentPage() {
|
|||||||
const [expandedChannel, setExpandedChannel] = useState("");
|
const [expandedChannel, setExpandedChannel] = useState("");
|
||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const [rateDrafts, setRateDrafts] = useState({});
|
const [rateDrafts, setRateDrafts] = useState({});
|
||||||
|
const [syncRateDialogOpen, setSyncRateDialogOpen] = useState(false);
|
||||||
|
const [syncRateForm, setSyncRateFormState] = useState(defaultSyncRateForm);
|
||||||
|
|
||||||
// 三方支付页是配置中心入口,列表必须包含已关闭方式,H5 只展示后端判定可用的方式。
|
// 三方支付页是配置中心入口,列表必须包含已关闭方式,H5 只展示后端判定可用的方式。
|
||||||
// 这里不在前端过滤关闭项,保证运营可以重新打开某个国家或某个支付方式。
|
// 这里不在前端过滤关闭项,保证运营可以重新打开某个国家或某个支付方式。
|
||||||
@ -66,6 +70,26 @@ export function useThirdPartyPaymentPage() {
|
|||||||
setRateDrafts((current) => ({ ...current, [String(methodId)]: value }));
|
setRateDrafts((current) => ({ ...current, [String(methodId)]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const setSyncRateMarkupPercent = (value) => {
|
||||||
|
setSyncRateFormState({ markupPercent: value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openSyncRateDialog = () => {
|
||||||
|
if (!abilities.canUpdateThirdParty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSyncRateFormState(defaultSyncRateForm);
|
||||||
|
setSyncRateDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeSyncRateDialog = () => {
|
||||||
|
// 同步请求已经开始后不允许关闭弹窗,避免运营误判请求被取消;接口返回后统一由成功/失败提示收敛状态。
|
||||||
|
if (loadingAction === "sync-rates") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSyncRateDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const toggleMethod = async (method, active) => {
|
const toggleMethod = async (method, active) => {
|
||||||
// 权限判断放在动作入口,避免只依赖按钮禁用;接口失败时保留原状态并提示。
|
// 权限判断放在动作入口,避免只依赖按钮禁用;接口失败时保留原状态并提示。
|
||||||
if (!abilities.canUpdateThirdParty) {
|
if (!abilities.canUpdateThirdParty) {
|
||||||
@ -107,16 +131,23 @@ export function useThirdPartyPaymentPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const syncRates = async () => {
|
const submitSyncRates = async (event) => {
|
||||||
// 汇率同步只触发服务端动作;汇率源、失败切换和写库都在 admin-server 完成,前端不接收可篡改的汇率输入。
|
event?.preventDefault();
|
||||||
|
// 上浮比例只表达运营策略,不在前端计算最终汇率;服务端会拉实时源、套用比例并按 1 位小数向上取整,避免旧页面数据参与写库。
|
||||||
if (!abilities.canUpdateThirdParty) {
|
if (!abilities.canUpdateThirdParty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const parsed = thirdPartyPaymentRateSyncSchema.safeParse(syncRateForm);
|
||||||
|
if (!parsed.success) {
|
||||||
|
showToast(parsed.error.issues[0]?.message || "汇率同步参数不正确", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
setLoadingAction("sync-rates");
|
setLoadingAction("sync-rates");
|
||||||
try {
|
try {
|
||||||
const summary = await syncThirdPartyPaymentRates();
|
const summary = await syncThirdPartyPaymentRates({ markupPercent: parsed.data.markupPercent });
|
||||||
const fresh = await result.reload();
|
const fresh = await result.reload();
|
||||||
setRateDrafts(rateDraftsFromChannels(fresh?.items || []));
|
setRateDrafts(rateDraftsFromChannels(fresh?.items || []));
|
||||||
|
setSyncRateDialogOpen(false);
|
||||||
const suffix = summary?.sourceNames?.length ? `(${summary.sourceNames.join("、")})` : "";
|
const suffix = summary?.sourceNames?.length ? `(${summary.sourceNames.join("、")})` : "";
|
||||||
showToast(`汇率已同步 ${summary?.updatedCount || 0} 个支付方式${suffix}`, "success");
|
showToast(`汇率已同步 ${summary?.updatedCount || 0} 个支付方式${suffix}`, "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -130,15 +161,20 @@ export function useThirdPartyPaymentPage() {
|
|||||||
abilities,
|
abilities,
|
||||||
activeChannel,
|
activeChannel,
|
||||||
channels,
|
channels,
|
||||||
|
closeSyncRateDialog,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
expandedChannel,
|
expandedChannel,
|
||||||
loading: result.loading,
|
loading: result.loading,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
|
openSyncRateDialog,
|
||||||
rateDrafts,
|
rateDrafts,
|
||||||
reload: result.reload,
|
reload: result.reload,
|
||||||
saveRate,
|
saveRate,
|
||||||
setRateDraft,
|
setRateDraft,
|
||||||
syncRates,
|
setSyncRateMarkupPercent,
|
||||||
|
submitSyncRates,
|
||||||
|
syncRateDialogOpen,
|
||||||
|
syncRateForm,
|
||||||
toggleChannel,
|
toggleChannel,
|
||||||
toggleMethod,
|
toggleMethod,
|
||||||
};
|
};
|
||||||
|
|||||||
296
src/features/payment/pages/TemporaryPaymentLinksPage.jsx
Normal file
296
src/features/payment/pages/TemporaryPaymentLinksPage.jsx
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
import ContentCopyOutlined from "@mui/icons-material/ContentCopyOutlined";
|
||||||
|
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined";
|
||||||
|
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||||
|
import VerifiedOutlined from "@mui/icons-material/VerifiedOutlined";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useTemporaryPaymentLinksPage, temporaryStatusLabel } from "@/features/payment/hooks/useTemporaryPaymentLinksPage.js";
|
||||||
|
import styles from "@/features/payment/payment.module.css";
|
||||||
|
import {
|
||||||
|
AdminActionIconButton,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
|
AdminRowActions,
|
||||||
|
} 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 { StatusSelect } from "@/shared/ui/StatusSelect.jsx";
|
||||||
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
["", "全部状态"],
|
||||||
|
["pending", "待支付"],
|
||||||
|
["redirected", "已生成链接"],
|
||||||
|
["paid", "已支付"],
|
||||||
|
["failed", "失败"],
|
||||||
|
];
|
||||||
|
|
||||||
|
const providerOptions = [
|
||||||
|
["", "全部渠道"],
|
||||||
|
["mifapay", "MiFaPay"],
|
||||||
|
["v5pay", "V5Pay"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TemporaryPaymentLinksPage() {
|
||||||
|
const page = useTemporaryPaymentLinksPage();
|
||||||
|
const items = page.data?.items || [];
|
||||||
|
const total = page.data?.total || 0;
|
||||||
|
const columns = useMemo(() => temporaryPaymentLinkColumns(page), [page]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
<Button startIcon={<RestartAltOutlined fontSize="small" />} onClick={page.resetFilters}>
|
||||||
|
重置筛选
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
filters={
|
||||||
|
<>
|
||||||
|
<StatusSelect
|
||||||
|
label="支付渠道"
|
||||||
|
options={providerOptions}
|
||||||
|
value={page.providerCode}
|
||||||
|
onChange={page.setProviderCode}
|
||||||
|
/>
|
||||||
|
<StatusSelect
|
||||||
|
label="支付状态"
|
||||||
|
options={statusOptions}
|
||||||
|
value={page.status}
|
||||||
|
onChange={page.setStatus}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
emptyLabel="当前无数据"
|
||||||
|
items={items}
|
||||||
|
minWidth="1420px"
|
||||||
|
pagination={
|
||||||
|
total > 0
|
||||||
|
? {
|
||||||
|
page: page.page,
|
||||||
|
pageSize: page.data?.pageSize || page.pageSize,
|
||||||
|
total,
|
||||||
|
onPageChange: page.setPage,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
rowKey={(item) => item.orderId}
|
||||||
|
/>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function temporaryPaymentLinkColumns(page) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "orderId",
|
||||||
|
label: "订单号",
|
||||||
|
width: "minmax(160px, 0.8fr)",
|
||||||
|
filter: createTextColumnFilter({
|
||||||
|
placeholder: "搜索订单号、三方单号",
|
||||||
|
value: page.keyword,
|
||||||
|
onChange: page.setKeyword,
|
||||||
|
}),
|
||||||
|
render: (item) => (
|
||||||
|
<Stack primary={<CopyableText label="复制订单号" value={item.orderId} />} secondary={item.appCode ? `App: ${item.appCode}` : "-"} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "provider",
|
||||||
|
label: "支付方式",
|
||||||
|
width: "minmax(170px, 0.85fr)",
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
options: providerOptions,
|
||||||
|
placeholder: "搜索支付渠道",
|
||||||
|
value: page.providerCode,
|
||||||
|
onChange: page.setProviderCode,
|
||||||
|
}),
|
||||||
|
render: (item) => (
|
||||||
|
<Stack
|
||||||
|
primary={providerLabel(item.providerCode)}
|
||||||
|
secondary={[item.payWay, item.payType].filter(Boolean).join(" / ") || "-"}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "amount",
|
||||||
|
label: "收款金额",
|
||||||
|
width: "minmax(140px, 0.65fr)",
|
||||||
|
render: (item) => <Stack primary={formatMoney(item.usdMinorAmount, "USD")} secondary="仅收款,不发金币" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "providerAmount",
|
||||||
|
label: "三方金额",
|
||||||
|
width: "minmax(130px, 0.6fr)",
|
||||||
|
render: (item) => formatMoney(item.providerAmountMinor, item.currencyCode),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "payUrl",
|
||||||
|
label: "支付链接",
|
||||||
|
width: "minmax(260px, 1.2fr)",
|
||||||
|
render: (item) => <PaymentLink value={item.payUrl} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "providerOrderId",
|
||||||
|
label: "三方订单",
|
||||||
|
width: "minmax(160px, 0.75fr)",
|
||||||
|
render: (item) => <CopyableText label="复制三方订单" value={item.providerOrderId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
label: "支付状态",
|
||||||
|
width: "minmax(110px, 0.55fr)",
|
||||||
|
filter: createOptionsColumnFilter({
|
||||||
|
options: statusOptions,
|
||||||
|
placeholder: "搜索状态",
|
||||||
|
value: page.status,
|
||||||
|
onChange: page.setStatus,
|
||||||
|
}),
|
||||||
|
render: (item) => <StatusBadge status={item.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "time",
|
||||||
|
label: "创建时间",
|
||||||
|
width: "minmax(160px, 0.75fr)",
|
||||||
|
render: (item) => <Stack primary={formatMillis(item.createdAtMs)} secondary={formatMillis(item.updatedAtMs)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "128px",
|
||||||
|
fixed: "right",
|
||||||
|
resizable: false,
|
||||||
|
render: (item) => (
|
||||||
|
<AdminRowActions>
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!page.abilities.canViewTemporaryLink || page.loadingAction === `verify:${item.orderId}`}
|
||||||
|
label="验证支付"
|
||||||
|
tooltip="验证支付"
|
||||||
|
onClick={() => page.verifyOrder(item.orderId)}
|
||||||
|
>
|
||||||
|
<VerifiedOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!item.payUrl}
|
||||||
|
label="打开链接"
|
||||||
|
tooltip="打开链接"
|
||||||
|
onClick={() => openLink(item.payUrl)}
|
||||||
|
>
|
||||||
|
<OpenInNewOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
</AdminRowActions>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentLink({ value }) {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={styles.temporaryLinkCell}>
|
||||||
|
<span title={value}>{value}</span>
|
||||||
|
<CopyButton label="复制支付链接" value={value} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyableText({ label, value }) {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={styles.copyableText}>
|
||||||
|
<span title={String(value)}>{value}</span>
|
||||||
|
<CopyButton label={label} value={value} />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CopyButton({ label, value }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const copyValue = async () => {
|
||||||
|
await copyText(String(value || ""));
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1200);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<span className={styles.copyWrap}>
|
||||||
|
<AdminActionIconButton disabled={!value} label={label} tooltip={copied ? "已复制" : label} onClick={copyValue}>
|
||||||
|
<ContentCopyOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }) {
|
||||||
|
const tone =
|
||||||
|
status === "paid" || status === "credited" ? "succeeded" : status === "failed" ? "danger" : "stopped";
|
||||||
|
return (
|
||||||
|
<span className={`status-badge status-badge--${tone}`}>
|
||||||
|
<span className="status-point" />
|
||||||
|
{temporaryStatusLabel(status)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stack({ primary, secondary }) {
|
||||||
|
return (
|
||||||
|
<div className="cell-stack">
|
||||||
|
<span>{primary || "-"}</span>
|
||||||
|
<span className="muted">{secondary || "-"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function providerLabel(value) {
|
||||||
|
return providerOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMoney(value, currency = "USD") {
|
||||||
|
if (!(value === 0 || value)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return `${currency || "USD"} ${(Number(value) / 100).toLocaleString("zh-CN", {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLink(value) {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.open(value, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(value) {
|
||||||
|
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();
|
||||||
|
}
|
||||||
@ -2,10 +2,12 @@ import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutl
|
|||||||
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
|
import KeyboardArrowRightOutlined from "@mui/icons-material/KeyboardArrowRightOutlined";
|
||||||
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||||
import SyncOutlined from "@mui/icons-material/SyncOutlined";
|
import SyncOutlined from "@mui/icons-material/SyncOutlined";
|
||||||
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useThirdPartyPaymentPage } from "@/features/payment/hooks/useThirdPartyPaymentPage.js";
|
import { useThirdPartyPaymentPage } from "@/features/payment/hooks/useThirdPartyPaymentPage.js";
|
||||||
|
import { AdminFormDialog } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
@ -30,7 +32,7 @@ export function ThirdPartyPaymentContent({ embedded = false, showProductButton =
|
|||||||
<Button
|
<Button
|
||||||
disabled={!page.abilities.canUpdateThirdParty || page.loadingAction === "sync-rates"}
|
disabled={!page.abilities.canUpdateThirdParty || page.loadingAction === "sync-rates"}
|
||||||
startIcon={<SyncOutlined fontSize="small" />}
|
startIcon={<SyncOutlined fontSize="small" />}
|
||||||
onClick={page.syncRates}
|
onClick={page.openSyncRateDialog}
|
||||||
>
|
>
|
||||||
支付汇率全球同步
|
支付汇率全球同步
|
||||||
</Button>
|
</Button>
|
||||||
@ -159,6 +161,31 @@ export function ThirdPartyPaymentContent({ embedded = false, showProductButton =
|
|||||||
</div>
|
</div>
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
|
<AdminFormDialog
|
||||||
|
loading={page.loadingAction === "sync-rates"}
|
||||||
|
open={page.syncRateDialogOpen}
|
||||||
|
size="narrow"
|
||||||
|
submitLabel="确定"
|
||||||
|
title="同步支付汇率"
|
||||||
|
onClose={page.closeSyncRateDialog}
|
||||||
|
onSubmit={page.submitSyncRates}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
autoFocus
|
||||||
|
disabled={page.loadingAction === "sync-rates" || !page.abilities.canUpdateThirdParty}
|
||||||
|
fullWidth
|
||||||
|
label="汇率上浮比例"
|
||||||
|
required
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { inputMode: "decimal", max: 100, min: 0, step: "0.1" },
|
||||||
|
input: {
|
||||||
|
endAdornment: <InputAdornment position="end">%</InputAdornment>,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
value={page.syncRateForm.markupPercent}
|
||||||
|
onChange={(event) => page.setSyncRateMarkupPercent(event.target.value)}
|
||||||
|
/>
|
||||||
|
</AdminFormDialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
76
src/features/payment/pages/ThirdPartyPaymentPage.test.jsx
Normal file
76
src/features/payment/pages/ThirdPartyPaymentPage.test.jsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { ThirdPartyPaymentPage } from "@/features/payment/pages/ThirdPartyPaymentPage.jsx";
|
||||||
|
import { useThirdPartyPaymentPage } from "@/features/payment/hooks/useThirdPartyPaymentPage.js";
|
||||||
|
|
||||||
|
vi.mock("@/features/payment/hooks/useThirdPartyPaymentPage.js", () => ({
|
||||||
|
useThirdPartyPaymentPage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("third-party payment sync button opens rate markup dialog", () => {
|
||||||
|
const page = pageFixture({ openSyncRateDialog: vi.fn() });
|
||||||
|
vi.mocked(useThirdPartyPaymentPage).mockReturnValue(page);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "支付汇率全球同步" }));
|
||||||
|
|
||||||
|
expect(page.openSyncRateDialog).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("third-party payment rate dialog submits markup percent", () => {
|
||||||
|
const page = pageFixture({
|
||||||
|
setSyncRateMarkupPercent: vi.fn(),
|
||||||
|
submitSyncRates: vi.fn((event) => event.preventDefault()),
|
||||||
|
syncRateDialogOpen: true,
|
||||||
|
});
|
||||||
|
vi.mocked(useThirdPartyPaymentPage).mockReturnValue(page);
|
||||||
|
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: /汇率上浮比例/ }), { target: { value: "3" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "确定" }));
|
||||||
|
|
||||||
|
expect(page.setSyncRateMarkupPercent).toHaveBeenCalledWith("3");
|
||||||
|
expect(page.submitSyncRates).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ThirdPartyPaymentPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageFixture(overrides = {}) {
|
||||||
|
return {
|
||||||
|
abilities: {
|
||||||
|
canUpdateThirdParty: true,
|
||||||
|
},
|
||||||
|
activeChannel: null,
|
||||||
|
channels: [],
|
||||||
|
closeSyncRateDialog: vi.fn(),
|
||||||
|
error: null,
|
||||||
|
expandedChannel: "",
|
||||||
|
loading: false,
|
||||||
|
loadingAction: "",
|
||||||
|
openSyncRateDialog: vi.fn(),
|
||||||
|
rateDrafts: {},
|
||||||
|
reload: vi.fn(),
|
||||||
|
saveRate: vi.fn(),
|
||||||
|
setRateDraft: vi.fn(),
|
||||||
|
setSyncRateMarkupPercent: vi.fn(),
|
||||||
|
submitSyncRates: vi.fn((event) => event.preventDefault()),
|
||||||
|
syncRateDialogOpen: false,
|
||||||
|
syncRateForm: { markupPercent: "0" },
|
||||||
|
toggleChannel: vi.fn(),
|
||||||
|
toggleMethod: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -3,6 +3,23 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.copyableText,
|
||||||
|
.temporaryLinkCell {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
grid-template-columns: minmax(0, 1fr) var(--control-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copyableText > span:first-child,
|
||||||
|
.temporaryLinkCell > span:first-child {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.identity {
|
.identity {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export function usePaymentAbilities() {
|
|||||||
canUpdateProduct: can(PERMISSIONS.paymentProductUpdate),
|
canUpdateProduct: can(PERMISSIONS.paymentProductUpdate),
|
||||||
canViewBill: can(PERMISSIONS.paymentBillView),
|
canViewBill: can(PERMISSIONS.paymentBillView),
|
||||||
canViewProduct: can(PERMISSIONS.paymentProductView),
|
canViewProduct: can(PERMISSIONS.paymentProductView),
|
||||||
|
canViewTemporaryLink: can(PERMISSIONS.paymentTemporaryLinkView),
|
||||||
canUpdateThirdParty: can(PERMISSIONS.paymentThirdPartyUpdate),
|
canUpdateThirdParty: can(PERMISSIONS.paymentThirdPartyUpdate),
|
||||||
canViewThirdParty: can(PERMISSIONS.paymentThirdPartyView),
|
canViewThirdParty: can(PERMISSIONS.paymentThirdPartyView),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -17,6 +17,15 @@ export const paymentRoutes = [
|
|||||||
path: "/payment/third-party",
|
path: "/payment/third-party",
|
||||||
permission: PERMISSIONS.paymentThirdPartyView,
|
permission: PERMISSIONS.paymentThirdPartyView,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "三方临时支付链接",
|
||||||
|
loader: () =>
|
||||||
|
import("./pages/TemporaryPaymentLinksPage.jsx").then((module) => module.TemporaryPaymentLinksPage),
|
||||||
|
menuCode: MENU_CODES.paymentTemporaryLinks,
|
||||||
|
pageKey: "payment-temporary-links",
|
||||||
|
path: "/payment/temporary-links",
|
||||||
|
permission: PERMISSIONS.paymentTemporaryLinkView,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "支付内购商品",
|
label: "支付内购商品",
|
||||||
loader: () =>
|
loader: () =>
|
||||||
|
|||||||
@ -18,3 +18,15 @@ export const rechargeProductSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type RechargeProductForm = z.infer<typeof rechargeProductSchema>;
|
export type RechargeProductForm = z.infer<typeof rechargeProductSchema>;
|
||||||
|
|
||||||
|
export const thirdPartyPaymentRateSyncSchema = z.object({
|
||||||
|
markupPercent: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1, "请填写汇率上浮比例")
|
||||||
|
.regex(/^(?:\d+|\d+\.\d{1,4})$/, "汇率上浮比例最多支持 4 位小数")
|
||||||
|
.transform(Number)
|
||||||
|
.pipe(z.number().min(0, "汇率上浮比例不能小于 0").max(100, "汇率上浮比例不能大于 100")),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ThirdPartyPaymentRateSyncForm = z.infer<typeof thirdPartyPaymentRateSyncSchema>;
|
||||||
|
|||||||
@ -24,6 +24,11 @@ export interface RegionBlockPayload {
|
|||||||
whitelist_ips?: string[];
|
whitelist_ips?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AddRegionBlockWhitelistIPResult {
|
||||||
|
added: boolean;
|
||||||
|
config: RegionBlockConfigDto;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegionBlockConfigDto extends ApiList<RegionBlockDto> {
|
export interface RegionBlockConfigDto extends ApiList<RegionBlockDto> {
|
||||||
whitelistItems: IPWhitelistDto[];
|
whitelistItems: IPWhitelistDto[];
|
||||||
whitelistTotal: number;
|
whitelistTotal: number;
|
||||||
@ -51,6 +56,24 @@ export function replaceRegionBlocks(payload: RegionBlockPayload): Promise<Region
|
|||||||
).then(normalizeRegionBlockConfig);
|
).then(normalizeRegionBlockConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function addRegionBlockWhitelistIP(ipAddress: string): Promise<AddRegionBlockWhitelistIPResult> {
|
||||||
|
const current = await listRegionBlocks();
|
||||||
|
const keywords = (current.items || []).map((item) => item.keyword).filter(Boolean);
|
||||||
|
const whitelistIPs = (current.whitelistItems || []).map((item) => item.ipAddress).filter(Boolean);
|
||||||
|
const normalizedIP = stringValue(ipAddress).trim();
|
||||||
|
|
||||||
|
// 后端当前只提供整份配置替换接口;快捷追加前先读取最新配置并合并,避免把已保存的屏蔽词或其他 IP 覆盖掉。
|
||||||
|
if (whitelistIPs.some((item) => normalizeKey(item) === normalizeKey(normalizedIP))) {
|
||||||
|
return { added: false, config: current };
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await replaceRegionBlocks({
|
||||||
|
keywords,
|
||||||
|
whitelist_ips: [...whitelistIPs, normalizedIP],
|
||||||
|
});
|
||||||
|
return { added: true, config };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRegionBlockConfig(data: RawRegionBlockConfig): RegionBlockConfigDto {
|
function normalizeRegionBlockConfig(data: RawRegionBlockConfig): RegionBlockConfigDto {
|
||||||
const rawWhitelist = (data.whitelistItems ?? data.whitelist_items ?? []) as RawIPWhitelist[];
|
const rawWhitelist = (data.whitelistItems ?? data.whitelist_items ?? []) as RawIPWhitelist[];
|
||||||
return {
|
return {
|
||||||
@ -86,6 +109,10 @@ function stringValue(value: unknown) {
|
|||||||
return value === undefined || value === null ? "" : String(value);
|
return value === undefined || value === null ? "" : String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeKey(value: unknown) {
|
||||||
|
return String(value || "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function numberValue(value: unknown) {
|
function numberValue(value: unknown) {
|
||||||
const number = Number(value || 0);
|
const number = Number(value || 0);
|
||||||
return Number.isFinite(number) ? number : 0;
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export function useRegionBlockPage() {
|
|||||||
const [draftKeywords, setDraftKeywords] = useState([]);
|
const [draftKeywords, setDraftKeywords] = useState([]);
|
||||||
const [draftWhitelistIPs, setDraftWhitelistIPs] = useState([]);
|
const [draftWhitelistIPs, setDraftWhitelistIPs] = useState([]);
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [whitelistDialogOpen, setWhitelistDialogOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const {
|
const {
|
||||||
data = emptyData,
|
data = emptyData,
|
||||||
@ -188,6 +189,7 @@ export function useRegionBlockPage() {
|
|||||||
ipInput,
|
ipInput,
|
||||||
keywordInput,
|
keywordInput,
|
||||||
loading,
|
loading,
|
||||||
|
openWhitelistDialog: () => setWhitelistDialogOpen(true),
|
||||||
reload,
|
reload,
|
||||||
removeIP,
|
removeIP,
|
||||||
removeKeyword,
|
removeKeyword,
|
||||||
@ -198,6 +200,8 @@ export function useRegionBlockPage() {
|
|||||||
setKeywordInput,
|
setKeywordInput,
|
||||||
submitIPInput,
|
submitIPInput,
|
||||||
submitInput,
|
submitInput,
|
||||||
|
whitelistDialogOpen,
|
||||||
|
closeWhitelistDialog: () => setWhitelistDialogOpen(false),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import FormatListBulletedOutlined from "@mui/icons-material/FormatListBulletedOutlined";
|
||||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||||
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
import RestartAltOutlined from "@mui/icons-material/RestartAltOutlined";
|
||||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import Tooltip from "@mui/material/Tooltip";
|
import Tooltip from "@mui/material/Tooltip";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
@ -88,6 +93,13 @@ export function RegionBlockPage() {
|
|||||||
添加IP
|
添加IP
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
<Button
|
||||||
|
disabled={page.loading}
|
||||||
|
startIcon={<FormatListBulletedOutlined fontSize="small" />}
|
||||||
|
onClick={page.openWhitelistDialog}
|
||||||
|
>
|
||||||
|
白名单IP列表
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -113,6 +125,7 @@ export function RegionBlockPage() {
|
|||||||
</div>
|
</div>
|
||||||
</AdminListBody>
|
</AdminListBody>
|
||||||
</DataState>
|
</DataState>
|
||||||
|
<WhitelistDialog page={page} />
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -195,6 +208,54 @@ function buildWhitelistColumns(page) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildWhitelistDialogColumns() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "ipAddress",
|
||||||
|
label: "IP",
|
||||||
|
width: "minmax(220px, 1fr)",
|
||||||
|
render: (item) => <KeywordCell item={{ keyword: item.ipAddress, enabled: item.enabled }} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "updatedAtMs",
|
||||||
|
label: "更新时间",
|
||||||
|
width: "minmax(180px, 0.8fr)",
|
||||||
|
render: (item) => (item.updatedAtMs ? formatMillis(item.updatedAtMs) : "-"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function WhitelistDialog({ page }) {
|
||||||
|
const columns = buildWhitelistDialogColumns();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
maxWidth="md"
|
||||||
|
open={page.whitelistDialogOpen}
|
||||||
|
slotProps={{ paper: { className: styles.whitelistDialogPaper } }}
|
||||||
|
fullWidth
|
||||||
|
onClose={page.closeWhitelistDialog}
|
||||||
|
>
|
||||||
|
<DialogTitle className={styles.whitelistDialogTitle}>
|
||||||
|
<span>白名单IP列表</span>
|
||||||
|
<small>共 {page.draftWhitelistItems.length} 个</small>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogContent className={styles.whitelistDialogContent}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
emptyLabel="当前无数据"
|
||||||
|
items={page.draftWhitelistItems}
|
||||||
|
minWidth="520px"
|
||||||
|
rowKey={(item) => item.whitelistId || item.ipAddress}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions className={styles.whitelistDialogActions}>
|
||||||
|
<Button onClick={page.closeWhitelistDialog}>关闭</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function KeywordCell({ item }) {
|
function KeywordCell({ item }) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.keywordCell}>
|
<div className={styles.keywordCell}>
|
||||||
|
|||||||
@ -56,6 +56,50 @@
|
|||||||
font-size: var(--admin-font-size);
|
font-size: var(--admin-font-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.whitelistDialogPaper {
|
||||||
|
max-height: min(720px, calc(100dvh - 48px));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelistDialogTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelistDialogTitle small {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelistDialogContent {
|
||||||
|
display: flex;
|
||||||
|
min-height: 320px;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelistDialogContent :global(.table-frame) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelistDialogContent :global(.table-scroll) {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitelistDialogActions {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.forms {
|
.forms {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
enableGift,
|
enableGift,
|
||||||
enableResource,
|
enableResource,
|
||||||
enableResourceGroup,
|
enableResourceGroup,
|
||||||
|
getResourceIdentityAutoGrantConfig,
|
||||||
grantResource,
|
grantResource,
|
||||||
grantResourceGroup,
|
grantResourceGroup,
|
||||||
listEmojiPackCategories,
|
listEmojiPackCategories,
|
||||||
@ -25,9 +26,11 @@ import {
|
|||||||
listResourceShopPurchaseOrders,
|
listResourceShopPurchaseOrders,
|
||||||
listResources,
|
listResources,
|
||||||
lookupResourceGrantTarget,
|
lookupResourceGrantTarget,
|
||||||
|
revokeResourceGrant,
|
||||||
updateGift,
|
updateGift,
|
||||||
updateGiftTypes,
|
updateGiftTypes,
|
||||||
updateResource,
|
updateResource,
|
||||||
|
updateResourceIdentityAutoGrantConfig,
|
||||||
updateResourceMp4Layouts,
|
updateResourceMp4Layouts,
|
||||||
updateResourceGroup,
|
updateResourceGroup,
|
||||||
upsertResourceShopItems,
|
upsertResourceShopItems,
|
||||||
@ -169,6 +172,7 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
reason: "manual",
|
reason: "manual",
|
||||||
targetUserId: "1001",
|
targetUserId: "1001",
|
||||||
});
|
});
|
||||||
|
await revokeResourceGrant("rgr_test");
|
||||||
|
|
||||||
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
const [listUrl, listInit] = vi.mocked(fetch).mock.calls[0];
|
||||||
const [createUrl, createInit] = vi.mocked(fetch).mock.calls[1];
|
const [createUrl, createInit] = vi.mocked(fetch).mock.calls[1];
|
||||||
@ -189,6 +193,7 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[16];
|
const [grantTargetUrl, grantTargetInit] = vi.mocked(fetch).mock.calls[16];
|
||||||
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[17];
|
const [grantResourceUrl, grantResourceInit] = vi.mocked(fetch).mock.calls[17];
|
||||||
const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[18];
|
const [grantGroupUrl, grantGroupInit] = vi.mocked(fetch).mock.calls[18];
|
||||||
|
const [revokeGrantUrl, revokeGrantInit] = vi.mocked(fetch).mock.calls[19];
|
||||||
|
|
||||||
expect(String(listUrl)).toContain("/api/v1/admin/resources?");
|
expect(String(listUrl)).toContain("/api/v1/admin/resources?");
|
||||||
expect(String(listUrl)).toContain("resource_type=gift");
|
expect(String(listUrl)).toContain("resource_type=gift");
|
||||||
@ -260,6 +265,60 @@ test("resource APIs use generated admin paths", async () => {
|
|||||||
expect(String(grantGroupUrl)).toContain("/api/v1/admin/resource-grants/group");
|
expect(String(grantGroupUrl)).toContain("/api/v1/admin/resource-grants/group");
|
||||||
expect(grantGroupInit?.method).toBe("POST");
|
expect(grantGroupInit?.method).toBe("POST");
|
||||||
expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: "1001" });
|
expect(JSON.parse(String(grantGroupInit?.body))).toMatchObject({ groupId: 22, targetUserId: "1001" });
|
||||||
|
expect(String(revokeGrantUrl)).toContain("/api/v1/admin/resource-grants/rgr_test/revoke");
|
||||||
|
expect(revokeGrantInit?.method).toBe("POST");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource identity auto grant config APIs use generated admin paths", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{ identity_type: "host", resource_group_id: 11 },
|
||||||
|
{ identityType: "bd", resourceGroupId: 12 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = await getResourceIdentityAutoGrantConfig();
|
||||||
|
await updateResourceIdentityAutoGrantConfig({
|
||||||
|
items: [
|
||||||
|
{ identity_type: "host", resource_group_id: 11 },
|
||||||
|
{ identity_type: "bd", resource_group_id: 12 },
|
||||||
|
{ identity_type: "bd_leader", resource_group_id: 13 },
|
||||||
|
{ identity_type: "agency", resource_group_id: 14 },
|
||||||
|
{ identity_type: "manager", resource_group_id: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const [getUrl, getInit] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
const [putUrl, putInit] = vi.mocked(fetch).mock.calls[1];
|
||||||
|
|
||||||
|
expect(config.items).toMatchObject([
|
||||||
|
{ identityType: "host", resourceGroupId: 11 },
|
||||||
|
{ identityType: "bd", resourceGroupId: 12 },
|
||||||
|
]);
|
||||||
|
expect(String(getUrl)).toContain("/api/v1/admin/resource-groups/identity-auto-grant-config");
|
||||||
|
expect(getInit?.method).toBe("GET");
|
||||||
|
expect(String(putUrl)).toContain("/api/v1/admin/resource-groups/identity-auto-grant-config");
|
||||||
|
expect(putInit?.method).toBe("PUT");
|
||||||
|
expect(JSON.parse(String(putInit?.body))).toEqual({
|
||||||
|
items: [
|
||||||
|
{ identity_type: "host", resource_group_id: 11 },
|
||||||
|
{ identity_type: "bd", resource_group_id: 12 },
|
||||||
|
{ identity_type: "bd_leader", resource_group_id: 13 },
|
||||||
|
{ identity_type: "agency", resource_group_id: 14 },
|
||||||
|
{ identity_type: "manager", resource_group_id: 0 },
|
||||||
|
],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resource mp4 layout batch API uses narrow update endpoint", async () => {
|
test("resource mp4 layout batch API uses narrow update endpoint", async () => {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { apiRequest } from "@/shared/api/request";
|
import { apiRequest } from "@/shared/api/request";
|
||||||
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
import type { ApiPage, CoinLedgerUserDto, EntityId, PageQuery } from "@/shared/api/types";
|
import type { ApiPage, CoinLedgerUserDto, EntityId, PageQuery } from "@/shared/api/types";
|
||||||
|
import { resourceIdentityAutoGrantTypes } from "@/features/resources/constants.js";
|
||||||
|
|
||||||
export interface ResourceDto {
|
export interface ResourceDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
@ -108,6 +109,30 @@ export interface ResourceGroupDto {
|
|||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ResourceIdentityAutoGrantType = "host" | "bd" | "bd_leader" | "agency" | "manager";
|
||||||
|
|
||||||
|
export interface ResourceIdentityAutoGrantItemDto {
|
||||||
|
identityType: ResourceIdentityAutoGrantType;
|
||||||
|
resourceGroupId: number;
|
||||||
|
resourceGroup?: ResourceGroupDto;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceIdentityAutoGrantConfigDto {
|
||||||
|
items: ResourceIdentityAutoGrantItemDto[];
|
||||||
|
updatedByAdminId?: number;
|
||||||
|
createdAtMs?: number;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceIdentityAutoGrantConfigPayload {
|
||||||
|
items: Array<{
|
||||||
|
identity_type: ResourceIdentityAutoGrantType;
|
||||||
|
resource_group_id: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResourceShopItemDto {
|
export interface ResourceShopItemDto {
|
||||||
appCode?: string;
|
appCode?: string;
|
||||||
shopItemId: number;
|
shopItemId: number;
|
||||||
@ -253,6 +278,9 @@ export interface ResourceGrantDto {
|
|||||||
items?: ResourceGrantItemDto[];
|
items?: ResourceGrantItemDto[];
|
||||||
operatorUserId?: number;
|
operatorUserId?: number;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
revokedAtMs?: number;
|
||||||
|
revokedByUserId?: string;
|
||||||
|
revokeReason?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
targetUser?: ResourceGrantUserDto;
|
targetUser?: ResourceGrantUserDto;
|
||||||
targetUserId?: string;
|
targetUserId?: string;
|
||||||
@ -446,6 +474,29 @@ export function disableResourceGroup(groupId: EntityId): Promise<ResourceGroupDt
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getResourceIdentityAutoGrantConfig(): Promise<ResourceIdentityAutoGrantConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.getResourceIdentityAutoGrantConfig;
|
||||||
|
return apiRequest<RawResourceIdentityAutoGrantConfig | RawResourceIdentityAutoGrantItem[]>(
|
||||||
|
apiEndpointPath(API_OPERATIONS.getResourceIdentityAutoGrantConfig),
|
||||||
|
{
|
||||||
|
method: endpoint.method,
|
||||||
|
},
|
||||||
|
).then(normalizeResourceIdentityAutoGrantConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateResourceIdentityAutoGrantConfig(
|
||||||
|
payload: ResourceIdentityAutoGrantConfigPayload,
|
||||||
|
): Promise<ResourceIdentityAutoGrantConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateResourceIdentityAutoGrantConfig;
|
||||||
|
return apiRequest<
|
||||||
|
RawResourceIdentityAutoGrantConfig | RawResourceIdentityAutoGrantItem[],
|
||||||
|
ResourceIdentityAutoGrantConfigPayload
|
||||||
|
>(apiEndpointPath(API_OPERATIONS.updateResourceIdentityAutoGrantConfig), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizeResourceIdentityAutoGrantConfig);
|
||||||
|
}
|
||||||
|
|
||||||
export function listGifts(query: PageQuery = {}): Promise<ApiPage<GiftDto>> {
|
export function listGifts(query: PageQuery = {}): Promise<ApiPage<GiftDto>> {
|
||||||
const endpoint = API_ENDPOINTS.listGifts;
|
const endpoint = API_ENDPOINTS.listGifts;
|
||||||
return apiRequest<ApiPage<GiftDto>>(apiEndpointPath(API_OPERATIONS.listGifts), {
|
return apiRequest<ApiPage<GiftDto>>(apiEndpointPath(API_OPERATIONS.listGifts), {
|
||||||
@ -571,3 +622,65 @@ export function grantResourceGroup(payload: GrantResourceGroupPayload): Promise<
|
|||||||
method: endpoint.method,
|
method: endpoint.method,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function revokeResourceGrant(grantId: string): Promise<ResourceGrantDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.revokeResourceGrant;
|
||||||
|
return apiRequest<ResourceGrantDto>(apiEndpointPath(API_OPERATIONS.revokeResourceGrant, { grant_id: grantId }), {
|
||||||
|
method: endpoint.method,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawResourceIdentityAutoGrantItem = ResourceIdentityAutoGrantItemDto & {
|
||||||
|
created_at_ms?: unknown;
|
||||||
|
identity_type?: unknown;
|
||||||
|
resource_group?: unknown;
|
||||||
|
resource_group_id?: unknown;
|
||||||
|
updated_at_ms?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RawResourceIdentityAutoGrantConfig = ResourceIdentityAutoGrantConfigDto & {
|
||||||
|
configs?: RawResourceIdentityAutoGrantItem[];
|
||||||
|
created_at_ms?: unknown;
|
||||||
|
items?: RawResourceIdentityAutoGrantItem[];
|
||||||
|
updated_at_ms?: unknown;
|
||||||
|
updated_by_admin_id?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeResourceIdentityAutoGrantConfig(
|
||||||
|
source: RawResourceIdentityAutoGrantConfig | RawResourceIdentityAutoGrantItem[],
|
||||||
|
): ResourceIdentityAutoGrantConfigDto {
|
||||||
|
const rawItems = Array.isArray(source) ? source : source.items || source.configs || [];
|
||||||
|
return {
|
||||||
|
items: rawItems.map(normalizeResourceIdentityAutoGrantItem),
|
||||||
|
updatedByAdminId: Array.isArray(source) ? 0 : numberValue(source.updatedByAdminId ?? source.updated_by_admin_id),
|
||||||
|
createdAtMs: Array.isArray(source) ? 0 : numberValue(source.createdAtMs ?? source.created_at_ms),
|
||||||
|
updatedAtMs: Array.isArray(source) ? 0 : numberValue(source.updatedAtMs ?? source.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResourceIdentityAutoGrantItem(
|
||||||
|
item: RawResourceIdentityAutoGrantItem,
|
||||||
|
): ResourceIdentityAutoGrantItemDto {
|
||||||
|
const resourceGroup = item.resourceGroup ?? item.resource_group;
|
||||||
|
return {
|
||||||
|
identityType: normalizeIdentityType(item.identityType ?? item.identity_type),
|
||||||
|
resourceGroupId: numberValue(item.resourceGroupId ?? item.resource_group_id),
|
||||||
|
resourceGroup: resourceGroup && typeof resourceGroup === "object" ? (resourceGroup as ResourceGroupDto) : undefined,
|
||||||
|
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
|
||||||
|
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIdentityType(value: unknown): ResourceIdentityAutoGrantType {
|
||||||
|
const identityType = stringValue(value) as ResourceIdentityAutoGrantType;
|
||||||
|
return resourceIdentityAutoGrantTypes.includes(identityType) ? identityType : "host";
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown): number {
|
||||||
|
const parsed = Number(value || 0);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown): string {
|
||||||
|
return value === undefined || value === null ? "" : String(value);
|
||||||
|
}
|
||||||
|
|||||||
@ -33,10 +33,12 @@ export const cpRelationTypeLabels = Object.fromEntries(cpRelationTypeOptions);
|
|||||||
export const resourceGrantStatusFilters = [
|
export const resourceGrantStatusFilters = [
|
||||||
["", "全部状态"],
|
["", "全部状态"],
|
||||||
["succeeded", "成功"],
|
["succeeded", "成功"],
|
||||||
|
["revoked", "已撤销"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const resourceGrantStatusLabels = {
|
export const resourceGrantStatusLabels = {
|
||||||
succeeded: "成功",
|
succeeded: "成功",
|
||||||
|
revoked: "已撤销",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resourceGrantSubjectLabels = {
|
export const resourceGrantSubjectLabels = {
|
||||||
@ -127,6 +129,18 @@ export const resourceGroupAssetOptions = [
|
|||||||
|
|
||||||
export const resourceGroupAssetLabels = Object.fromEntries(resourceGroupAssetOptions);
|
export const resourceGroupAssetLabels = Object.fromEntries(resourceGroupAssetOptions);
|
||||||
|
|
||||||
|
export const resourceIdentityAutoGrantOptions = [
|
||||||
|
["host", "主播"],
|
||||||
|
["bd", "BD"],
|
||||||
|
["bd_leader", "BD Leader"],
|
||||||
|
["agency", "Agency"],
|
||||||
|
["manager", "Manager"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const resourceIdentityAutoGrantTypes = resourceIdentityAutoGrantOptions.map(([value]) => value);
|
||||||
|
|
||||||
|
export const resourceIdentityAutoGrantLabels = Object.fromEntries(resourceIdentityAutoGrantOptions);
|
||||||
|
|
||||||
export const grantStrategyLabels = {
|
export const grantStrategyLabels = {
|
||||||
extend_expiry: "延长有效期",
|
extend_expiry: "延长有效期",
|
||||||
increase_quantity: "增加数量",
|
increase_quantity: "增加数量",
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
enableGift,
|
enableGift,
|
||||||
enableResource,
|
enableResource,
|
||||||
enableResourceGroup,
|
enableResourceGroup,
|
||||||
|
getResourceIdentityAutoGrantConfig,
|
||||||
grantResource,
|
grantResource,
|
||||||
grantResourceGroup,
|
grantResourceGroup,
|
||||||
listEmojiPackCategories,
|
listEmojiPackCategories,
|
||||||
@ -30,9 +31,11 @@ import {
|
|||||||
listResourceShopPurchaseOrders,
|
listResourceShopPurchaseOrders,
|
||||||
listResources,
|
listResources,
|
||||||
lookupResourceGrantTarget,
|
lookupResourceGrantTarget,
|
||||||
|
revokeResourceGrant,
|
||||||
updateGift,
|
updateGift,
|
||||||
updateGiftTypes,
|
updateGiftTypes,
|
||||||
updateResource,
|
updateResource,
|
||||||
|
updateResourceIdentityAutoGrantConfig,
|
||||||
updateResourceGroup,
|
updateResourceGroup,
|
||||||
upsertResourceShopItems,
|
upsertResourceShopItems,
|
||||||
} from "@/features/resources/api";
|
} from "@/features/resources/api";
|
||||||
@ -40,6 +43,7 @@ import {
|
|||||||
cpRelationTypeLabels,
|
cpRelationTypeLabels,
|
||||||
cpRelationTypeOptions,
|
cpRelationTypeOptions,
|
||||||
defaultGiftTypeOptions,
|
defaultGiftTypeOptions,
|
||||||
|
resourceIdentityAutoGrantOptions,
|
||||||
resourceShopSellableTypes,
|
resourceShopSellableTypes,
|
||||||
} from "@/features/resources/constants.js";
|
} from "@/features/resources/constants.js";
|
||||||
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
import { useResourceAbilities } from "@/features/resources/permissions.js";
|
||||||
@ -51,6 +55,7 @@ import {
|
|||||||
resourceFormSchema,
|
resourceFormSchema,
|
||||||
resourceGrantFormSchema,
|
resourceGrantFormSchema,
|
||||||
resourceGroupCreateFormSchema,
|
resourceGroupCreateFormSchema,
|
||||||
|
resourceIdentityAutoGrantFormSchema,
|
||||||
resourceShopItemsFormSchema,
|
resourceShopItemsFormSchema,
|
||||||
} from "@/features/resources/schema.js";
|
} from "@/features/resources/schema.js";
|
||||||
|
|
||||||
@ -109,6 +114,15 @@ const emptyResourceGroupForm = (group = {}) => ({
|
|||||||
name: group.name || "",
|
name: group.name || "",
|
||||||
sortOrder: group.sortOrder === 0 || group.sortOrder ? String(group.sortOrder) : "0",
|
sortOrder: group.sortOrder === 0 || group.sortOrder ? String(group.sortOrder) : "0",
|
||||||
});
|
});
|
||||||
|
const emptyResourceIdentityAutoGrantForm = (items = []) => {
|
||||||
|
const itemByIdentityType = new Map(items.map((item) => [item.identityType, item]));
|
||||||
|
return resourceIdentityAutoGrantOptions.map(([identityType]) => ({
|
||||||
|
identityType,
|
||||||
|
resourceGroupId: itemByIdentityType.get(identityType)?.resourceGroupId
|
||||||
|
? String(itemByIdentityType.get(identityType).resourceGroupId)
|
||||||
|
: "",
|
||||||
|
}));
|
||||||
|
};
|
||||||
const emptyGroupResourceItem = () => ({
|
const emptyGroupResourceItem = () => ({
|
||||||
durationDays: "1",
|
durationDays: "1",
|
||||||
itemType: "resource",
|
itemType: "resource",
|
||||||
@ -778,6 +792,9 @@ export function useResourceGroupListPage() {
|
|||||||
const [loadingAction, setLoadingAction] = useState("");
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
const [resourceOptions, setResourceOptions] = useState([]);
|
const [resourceOptions, setResourceOptions] = useState([]);
|
||||||
const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false);
|
const [resourceOptionsLoading, setResourceOptionsLoading] = useState(false);
|
||||||
|
const [identityGrantForm, setIdentityGrantForm] = useState(() => emptyResourceIdentityAutoGrantForm());
|
||||||
|
const [identityGrantGroupOptions, setIdentityGrantGroupOptions] = useState([]);
|
||||||
|
const [identityGrantLoading, setIdentityGrantLoading] = useState(false);
|
||||||
const filters = useMemo(
|
const filters = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
keyword: query,
|
keyword: query,
|
||||||
@ -821,6 +838,40 @@ export function useResourceGroupListPage() {
|
|||||||
};
|
};
|
||||||
}, [activeAction, selectedGroup, showToast]);
|
}, [activeAction, selectedGroup, showToast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeAction !== "identity-config") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let ignore = false;
|
||||||
|
setIdentityGrantLoading(true);
|
||||||
|
// 这里并行拉取配置和可发放资源组,避免弹窗先显示旧配置再异步换值造成误保存。
|
||||||
|
Promise.all([
|
||||||
|
getResourceIdentityAutoGrantConfig(),
|
||||||
|
fetchAllOptionPages(listResourceGroups, { status: "active" }),
|
||||||
|
])
|
||||||
|
.then(([config, groups]) => {
|
||||||
|
if (ignore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIdentityGrantForm(emptyResourceIdentityAutoGrantForm(config.items || []));
|
||||||
|
setIdentityGrantGroupOptions(mergeResourceGroupOptions(groups.items || [], config.items || []));
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!ignore) {
|
||||||
|
showToast(err.message || "加载身份资源组配置失败", "error");
|
||||||
|
setActiveAction("");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!ignore) {
|
||||||
|
setIdentityGrantLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
ignore = true;
|
||||||
|
};
|
||||||
|
}, [activeAction, showToast]);
|
||||||
|
|
||||||
const openCreateGroup = () => {
|
const openCreateGroup = () => {
|
||||||
setForm(emptyResourceGroupForm());
|
setForm(emptyResourceGroupForm());
|
||||||
setSelectedGroup(null);
|
setSelectedGroup(null);
|
||||||
@ -836,6 +887,11 @@ export function useResourceGroupListPage() {
|
|||||||
setActiveAction("edit");
|
setActiveAction("edit");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openIdentityGrantConfig = () => {
|
||||||
|
setIdentityGrantLoading(true);
|
||||||
|
setActiveAction("identity-config");
|
||||||
|
};
|
||||||
|
|
||||||
const closeAction = () => {
|
const closeAction = () => {
|
||||||
setActiveAction("");
|
setActiveAction("");
|
||||||
setSelectedGroup(null);
|
setSelectedGroup(null);
|
||||||
@ -887,6 +943,27 @@ export function useResourceGroupListPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitIdentityGrantConfig = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!abilities.canUpdateGroup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingAction("identity-grant-config");
|
||||||
|
try {
|
||||||
|
const payload = buildResourceIdentityAutoGrantPayload(
|
||||||
|
parseForm(resourceIdentityAutoGrantFormSchema, { items: identityGrantForm }),
|
||||||
|
);
|
||||||
|
const nextConfig = await updateResourceIdentityAutoGrantConfig(payload);
|
||||||
|
setIdentityGrantForm(emptyResourceIdentityAutoGrantForm(nextConfig.items || []));
|
||||||
|
showToast("身份资源组配置已保存", "success");
|
||||||
|
closeAction();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "保存身份资源组配置失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleGroup = async (group, nextEnabled = group.status !== "active") => {
|
const toggleGroup = async (group, nextEnabled = group.status !== "active") => {
|
||||||
if (!abilities.canUpdateGroup || !group?.groupId) {
|
if (!abilities.canUpdateGroup || !group?.groupId) {
|
||||||
return;
|
return;
|
||||||
@ -925,16 +1002,22 @@ export function useResourceGroupListPage() {
|
|||||||
changeStatus: resetSetter(setStatus, setPage),
|
changeStatus: resetSetter(setStatus, setPage),
|
||||||
closeAction,
|
closeAction,
|
||||||
form,
|
form,
|
||||||
|
identityGrantForm,
|
||||||
|
identityGrantGroupOptions,
|
||||||
|
identityGrantLoading,
|
||||||
loadingAction,
|
loadingAction,
|
||||||
openCreateGroup,
|
openCreateGroup,
|
||||||
openEditGroup,
|
openEditGroup,
|
||||||
|
openIdentityGrantConfig,
|
||||||
removeGroupItem,
|
removeGroupItem,
|
||||||
resourceOptions,
|
resourceOptions,
|
||||||
resourceOptionsLoading,
|
resourceOptionsLoading,
|
||||||
selectedGroup,
|
selectedGroup,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
setForm,
|
setForm,
|
||||||
|
setIdentityGrantForm,
|
||||||
status,
|
status,
|
||||||
|
submitIdentityGrantConfig,
|
||||||
submitGroup,
|
submitGroup,
|
||||||
toggleGroup,
|
toggleGroup,
|
||||||
updateGroupItem,
|
updateGroupItem,
|
||||||
@ -1457,6 +1540,7 @@ export function useEmojiPackListPage() {
|
|||||||
|
|
||||||
export function useResourceGrantListPage() {
|
export function useResourceGrantListPage() {
|
||||||
const abilities = useResourceAbilities();
|
const abilities = useResourceAbilities();
|
||||||
|
const confirm = useConfirm();
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = useState("");
|
const [status, setStatus] = useState("");
|
||||||
@ -1562,6 +1646,36 @@ export function useResourceGrantListPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const revokeGrant = async (grant) => {
|
||||||
|
if (
|
||||||
|
!abilities.canRevokeGrant ||
|
||||||
|
!grant?.grantId ||
|
||||||
|
grant.status !== "succeeded" ||
|
||||||
|
grant.grantSubjectType !== "resource_group"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const accepted = await confirm({
|
||||||
|
title: "撤销资源组赠送",
|
||||||
|
message: "撤销后会扣回金币并移除资源组内装扮。该操作不可自动恢复。",
|
||||||
|
confirmText: "撤销",
|
||||||
|
tone: "danger",
|
||||||
|
});
|
||||||
|
if (!accepted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoadingAction(`grant-revoke-${grant.grantId}`);
|
||||||
|
try {
|
||||||
|
await revokeResourceGrant(grant.grantId);
|
||||||
|
showToast("资源组赠送已撤销", "success");
|
||||||
|
await result.reload();
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "撤销失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setStatus("");
|
setStatus("");
|
||||||
@ -1583,6 +1697,7 @@ export function useResourceGrantListPage() {
|
|||||||
optionsLoading,
|
optionsLoading,
|
||||||
resourceOptions,
|
resourceOptions,
|
||||||
resetFilters,
|
resetFilters,
|
||||||
|
revokeGrant,
|
||||||
setForm,
|
setForm,
|
||||||
status,
|
status,
|
||||||
submitGrant,
|
submitGrant,
|
||||||
@ -1744,6 +1859,35 @@ function buildResourceGroupPayload(form) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildResourceIdentityAutoGrantPayload(form) {
|
||||||
|
const itemByIdentityType = new Map(form.items.map((item) => [item.identityType, item]));
|
||||||
|
return {
|
||||||
|
items: resourceIdentityAutoGrantOptions.map(([identityType]) => {
|
||||||
|
const resourceGroupId = Number(itemByIdentityType.get(identityType)?.resourceGroupId || 0);
|
||||||
|
return {
|
||||||
|
identity_type: identityType,
|
||||||
|
resource_group_id: Number.isInteger(resourceGroupId) && resourceGroupId >= 0 ? resourceGroupId : 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeResourceGroupOptions(activeGroups = [], configItems = []) {
|
||||||
|
const groupById = new Map();
|
||||||
|
activeGroups.forEach((group) => {
|
||||||
|
if (group?.groupId) {
|
||||||
|
groupById.set(String(group.groupId), group);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
configItems.forEach((item) => {
|
||||||
|
const group = item?.resourceGroup;
|
||||||
|
if (group?.groupId && !groupById.has(String(group.groupId))) {
|
||||||
|
groupById.set(String(group.groupId), group);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(groupById.values());
|
||||||
|
}
|
||||||
|
|
||||||
function buildGiftPayload(form) {
|
function buildGiftPayload(form) {
|
||||||
const cpRelationType = form.giftTypeCode === cpGiftTypeCode ? normalizedCPRelationType(form.cpRelationType) : "";
|
const cpRelationType = form.giftTypeCode === cpGiftTypeCode ? normalizedCPRelationType(form.cpRelationType) : "";
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -1,11 +1,18 @@
|
|||||||
import Add from "@mui/icons-material/Add";
|
import Add from "@mui/icons-material/Add";
|
||||||
|
import UndoOutlined from "@mui/icons-material/UndoOutlined";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
import { AdminFormDialog, AdminFormFieldGrid, AdminFormSection } from "@/shared/ui/AdminFormDialog.jsx";
|
||||||
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
import { AdminUserIdentity } from "@/shared/ui/AdminUserIdentity.jsx";
|
||||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||||
import { AdminActionIconButton, AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
import {
|
||||||
|
AdminActionIconButton,
|
||||||
|
AdminListBody,
|
||||||
|
AdminListPage,
|
||||||
|
AdminListToolbar,
|
||||||
|
AdminRowActions,
|
||||||
|
} from "@/shared/ui/AdminListLayout.jsx";
|
||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { GiftResourceSelectField } from "@/features/resources/components/GiftResourceSelectDrawer.jsx";
|
import { GiftResourceSelectField } from "@/features/resources/components/GiftResourceSelectDrawer.jsx";
|
||||||
@ -63,7 +70,7 @@ const grantColumns = [
|
|||||||
label: "状态",
|
label: "状态",
|
||||||
width: "minmax(100px, 0.55fr)",
|
width: "minmax(100px, 0.55fr)",
|
||||||
render: (grant) => (
|
render: (grant) => (
|
||||||
<span className={`status-badge status-badge--${grant.status || "stopped"}`}>
|
<span className={`status-badge status-badge--${grantStatusTone(grant.status)}`}>
|
||||||
<span className="status-point" />
|
<span className="status-point" />
|
||||||
{resourceGrantStatusLabels[grant.status] || grant.status || "-"}
|
{resourceGrantStatusLabels[grant.status] || grant.status || "-"}
|
||||||
</span>
|
</span>
|
||||||
@ -87,30 +94,38 @@ export function ResourceGrantListPage() {
|
|||||||
const page = useResourceGrantListPage();
|
const page = useResourceGrantListPage();
|
||||||
const items = page.data.items || [];
|
const items = page.data.items || [];
|
||||||
const total = page.data.total || 0;
|
const total = page.data.total || 0;
|
||||||
const columns = grantColumns.map((column) => {
|
const columns = [
|
||||||
if (column.key === "target") {
|
...grantColumns.map((column) => {
|
||||||
return {
|
if (column.key === "target") {
|
||||||
...column,
|
return {
|
||||||
filter: createTextColumnFilter({
|
...column,
|
||||||
placeholder: "搜索用户 ID",
|
filter: createTextColumnFilter({
|
||||||
value: page.query,
|
placeholder: "搜索用户 ID",
|
||||||
onChange: page.changeQuery,
|
value: page.query,
|
||||||
}),
|
onChange: page.changeQuery,
|
||||||
};
|
}),
|
||||||
}
|
};
|
||||||
if (column.key === "status") {
|
}
|
||||||
return {
|
if (column.key === "status") {
|
||||||
...column,
|
return {
|
||||||
filter: createOptionsColumnFilter({
|
...column,
|
||||||
options: resourceGrantStatusFilters,
|
filter: createOptionsColumnFilter({
|
||||||
placeholder: "搜索状态",
|
options: resourceGrantStatusFilters,
|
||||||
value: page.status,
|
placeholder: "搜索状态",
|
||||||
onChange: page.changeStatus,
|
value: page.status,
|
||||||
}),
|
onChange: page.changeStatus,
|
||||||
};
|
}),
|
||||||
}
|
};
|
||||||
return column;
|
}
|
||||||
});
|
return column;
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
label: "操作",
|
||||||
|
width: "minmax(88px, 0.45fr)",
|
||||||
|
render: (grant) => <ResourceGrantActions grant={grant} page={page} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminListPage>
|
<AdminListPage>
|
||||||
@ -128,7 +143,7 @@ export function ResourceGrantListPage() {
|
|||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
items={items}
|
items={items}
|
||||||
minWidth="1370px"
|
minWidth="1460px"
|
||||||
pagination={
|
pagination={
|
||||||
total > 0
|
total > 0
|
||||||
? {
|
? {
|
||||||
@ -161,6 +176,28 @@ export function ResourceGrantListPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ResourceGrantActions({ grant, page }) {
|
||||||
|
const canRevoke =
|
||||||
|
page.abilities.canRevokeGrant && grant?.status === "succeeded" && grant?.grantSubjectType === "resource_group";
|
||||||
|
if (!canRevoke) {
|
||||||
|
return <span className={styles.meta}>-</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<AdminRowActions>
|
||||||
|
<AdminActionIconButton
|
||||||
|
aria-label="撤销"
|
||||||
|
disabled={page.loadingAction === `grant-revoke-${grant.grantId}`}
|
||||||
|
label="撤销"
|
||||||
|
sx={dangerActionSx}
|
||||||
|
tooltip="撤销资源组赠送"
|
||||||
|
onClick={() => page.revokeGrant(grant)}
|
||||||
|
>
|
||||||
|
<UndoOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
</AdminRowActions>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ResourceGrantDialog({
|
function ResourceGrantDialog({
|
||||||
disabled,
|
disabled,
|
||||||
form,
|
form,
|
||||||
@ -281,6 +318,29 @@ function ResourceGrantDialog({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function grantStatusTone(status) {
|
||||||
|
if (status === "revoked") {
|
||||||
|
return "stopped";
|
||||||
|
}
|
||||||
|
if (status === "failed") {
|
||||||
|
return "danger";
|
||||||
|
}
|
||||||
|
if (status === "pending") {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
return status || "stopped";
|
||||||
|
}
|
||||||
|
|
||||||
|
const dangerActionSx = {
|
||||||
|
borderColor: "var(--danger-border)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
"&:hover": {
|
||||||
|
borderColor: "var(--danger-border-strong)",
|
||||||
|
backgroundColor: "var(--danger-surface)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function GrantItems({ grant }) {
|
function GrantItems({ grant }) {
|
||||||
const items = grant.items || [];
|
const items = grant.items || [];
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
|
|||||||
124
src/features/resources/pages/ResourceGrantListPage.test.jsx
Normal file
124
src/features/resources/pages/ResourceGrantListPage.test.jsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { ResourceGrantListPage } from "./ResourceGrantListPage.jsx";
|
||||||
|
import { useResourceGrantListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
revokeGrant: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/resources/hooks/useResourcePages.js", () => ({
|
||||||
|
useResourceGrantListPage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource grant list shows revoke only for succeeded resource group grants", () => {
|
||||||
|
const eligible = grantFixture({
|
||||||
|
grantId: "rgr_group_ok",
|
||||||
|
grantSubjectType: "resource_group",
|
||||||
|
status: "succeeded",
|
||||||
|
});
|
||||||
|
vi.mocked(useResourceGrantListPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
eligible,
|
||||||
|
grantFixture({ grantId: "rgr_resource_ok", grantSubjectType: "resource", status: "succeeded" }),
|
||||||
|
grantFixture({ grantId: "rgr_group_revoked", grantSubjectType: "resource_group", status: "revoked" }),
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 3,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ResourceGrantListPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText("已撤销")).toBeInTheDocument();
|
||||||
|
const revokeButtons = screen.getAllByLabelText("撤销");
|
||||||
|
expect(revokeButtons).toHaveLength(1);
|
||||||
|
|
||||||
|
fireEvent.click(revokeButtons[0]);
|
||||||
|
|
||||||
|
expect(mocks.revokeGrant).toHaveBeenCalledWith(eligible);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resource grant list hides revoke without permission", () => {
|
||||||
|
vi.mocked(useResourceGrantListPage).mockReturnValue(
|
||||||
|
pageFixture({
|
||||||
|
abilities: { canCreateGrant: false, canRevokeGrant: false },
|
||||||
|
data: {
|
||||||
|
items: [grantFixture({ grantSubjectType: "resource_group", status: "succeeded" })],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
render(<ResourceGrantListPage />);
|
||||||
|
|
||||||
|
expect(screen.queryByLabelText("撤销")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
function pageFixture(patch = {}) {
|
||||||
|
return {
|
||||||
|
abilities: {
|
||||||
|
canCreateGrant: true,
|
||||||
|
canRevokeGrant: true,
|
||||||
|
},
|
||||||
|
activeAction: "",
|
||||||
|
changeQuery: vi.fn(),
|
||||||
|
changeStatus: vi.fn(),
|
||||||
|
closeAction: vi.fn(),
|
||||||
|
data: { items: [], page: 1, pageSize: 50, total: 0 },
|
||||||
|
error: null,
|
||||||
|
form: {
|
||||||
|
durationDays: "0",
|
||||||
|
giftIds: [],
|
||||||
|
groupId: "",
|
||||||
|
reason: "",
|
||||||
|
resourceIds: [],
|
||||||
|
subjectType: "resource",
|
||||||
|
targetUserId: "",
|
||||||
|
},
|
||||||
|
giftOptions: [],
|
||||||
|
giftTypeOptions: [],
|
||||||
|
groupOptions: [],
|
||||||
|
loading: false,
|
||||||
|
loadingAction: "",
|
||||||
|
openCreateGrant: vi.fn(),
|
||||||
|
optionsLoading: false,
|
||||||
|
page: 1,
|
||||||
|
query: "",
|
||||||
|
reload: vi.fn(),
|
||||||
|
resetFilters: vi.fn(),
|
||||||
|
resourceOptions: [],
|
||||||
|
revokeGrant: mocks.revokeGrant,
|
||||||
|
setForm: vi.fn(),
|
||||||
|
setPage: vi.fn(),
|
||||||
|
status: "",
|
||||||
|
submitGrant: vi.fn(),
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function grantFixture(patch = {}) {
|
||||||
|
return {
|
||||||
|
commandId: "resource-group-grant-test",
|
||||||
|
createdAtMs: 1782287714973,
|
||||||
|
grantId: "rgr_test",
|
||||||
|
grantSubjectId: "22",
|
||||||
|
grantSubjectType: "resource_group",
|
||||||
|
items: [{ grantItemId: 1, quantity: 100, resourceId: 0 }],
|
||||||
|
operatorUserId: 7,
|
||||||
|
reason: "manual",
|
||||||
|
status: "succeeded",
|
||||||
|
targetUserId: "318705991371722752",
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
|||||||
import EditOutlined from "@mui/icons-material/EditOutlined";
|
import EditOutlined from "@mui/icons-material/EditOutlined";
|
||||||
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
import Inventory2Outlined from "@mui/icons-material/Inventory2Outlined";
|
||||||
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
import MonetizationOnOutlined from "@mui/icons-material/MonetizationOnOutlined";
|
||||||
|
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
|
||||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import {
|
import {
|
||||||
@ -28,8 +29,14 @@ import {
|
|||||||
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
|
||||||
import { formatMillis } from "@/shared/utils/time.js";
|
import { formatMillis } from "@/shared/utils/time.js";
|
||||||
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
|
import { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
|
||||||
import { resourceGroupAssetLabels, resourceStatusFilters, resourceTypeLabels } from "@/features/resources/constants.js";
|
import {
|
||||||
|
resourceGroupAssetLabels,
|
||||||
|
resourceIdentityAutoGrantLabels,
|
||||||
|
resourceStatusFilters,
|
||||||
|
resourceTypeLabels,
|
||||||
|
} from "@/features/resources/constants.js";
|
||||||
import { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js";
|
import { useResourceGroupListPage } from "@/features/resources/hooks/useResourcePages.js";
|
||||||
|
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||||
import styles from "@/features/resources/resources.module.css";
|
import styles from "@/features/resources/resources.module.css";
|
||||||
|
|
||||||
const dayMillis = 24 * 60 * 60 * 1000;
|
const dayMillis = 24 * 60 * 60 * 1000;
|
||||||
@ -114,6 +121,13 @@ export function ResourceGroupListPage() {
|
|||||||
</AdminActionIconButton>
|
</AdminActionIconButton>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
|
filters={
|
||||||
|
page.abilities.canViewGroup ? (
|
||||||
|
<AdminActionIconButton label="配置身份资源组" onClick={page.openIdentityGrantConfig}>
|
||||||
|
<SettingsOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
<AdminListBody>
|
<AdminListBody>
|
||||||
@ -151,6 +165,16 @@ export function ResourceGroupListPage() {
|
|||||||
onClose={page.closeAction}
|
onClose={page.closeAction}
|
||||||
onSubmit={page.submitGroup}
|
onSubmit={page.submitGroup}
|
||||||
/>
|
/>
|
||||||
|
<ResourceIdentityAutoGrantDialog
|
||||||
|
disabled={!page.abilities.canUpdateGroup}
|
||||||
|
form={page.identityGrantForm}
|
||||||
|
groupOptions={page.identityGrantGroupOptions}
|
||||||
|
loading={page.identityGrantLoading || page.loadingAction === "identity-grant-config"}
|
||||||
|
open={page.activeAction === "identity-config"}
|
||||||
|
page={page}
|
||||||
|
onClose={page.closeAction}
|
||||||
|
onSubmit={page.submitIdentityGrantConfig}
|
||||||
|
/>
|
||||||
</AdminListPage>
|
</AdminListPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -271,6 +295,53 @@ function ResourceGroupFormDialog({ disabled, form, loading, mode, onClose, onSub
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ResourceIdentityAutoGrantDialog({ disabled, form, groupOptions, loading, onClose, onSubmit, open, page }) {
|
||||||
|
const submitDisabled = disabled || loading;
|
||||||
|
return (
|
||||||
|
<AdminFormDialog
|
||||||
|
loading={loading}
|
||||||
|
open={open}
|
||||||
|
size="wide"
|
||||||
|
submitDisabled={submitDisabled}
|
||||||
|
submitLabel="保存配置"
|
||||||
|
title="身份资源组配置"
|
||||||
|
onClose={onClose}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
>
|
||||||
|
<AdminFormSection title="自动发放资源组">
|
||||||
|
<AdminFormList>
|
||||||
|
{form.map((item, index) => (
|
||||||
|
<AdminFormListRow columns="140px minmax(260px, 1fr)" key={item.identityType}>
|
||||||
|
<span className={styles.identityGrantLabel}>
|
||||||
|
{resourceIdentityAutoGrantLabels[item.identityType] || item.identityType}
|
||||||
|
</span>
|
||||||
|
<ResourceGroupSelectField
|
||||||
|
allowEmpty
|
||||||
|
disabled={disabled || loading}
|
||||||
|
drawerTitle={`${resourceIdentityAutoGrantLabels[item.identityType] || item.identityType}资源组`}
|
||||||
|
emptyLabel="不自动发放"
|
||||||
|
groups={groupOptions}
|
||||||
|
label="资源组"
|
||||||
|
placeholder="点击选择资源组"
|
||||||
|
value={item.resourceGroupId}
|
||||||
|
onChange={(value) =>
|
||||||
|
page.setIdentityGrantForm((current) =>
|
||||||
|
current.map((currentItem, itemIndex) =>
|
||||||
|
itemIndex === index
|
||||||
|
? { ...currentItem, resourceGroupId: value }
|
||||||
|
: currentItem,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AdminFormListRow>
|
||||||
|
))}
|
||||||
|
</AdminFormList>
|
||||||
|
</AdminFormSection>
|
||||||
|
</AdminFormDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ResourceGroupItemEditor({ disabled, index, item, page }) {
|
function ResourceGroupItemEditor({ disabled, index, item, page }) {
|
||||||
if (item.itemType === "wallet_asset") {
|
if (item.itemType === "wallet_asset") {
|
||||||
const label = resourceGroupAssetLabels[item.walletAssetType] || "钱包资产";
|
const label = resourceGroupAssetLabels[item.walletAssetType] || "钱包资产";
|
||||||
|
|||||||
@ -9,6 +9,7 @@ export function useResourceAbilities() {
|
|||||||
canCreateGift: can(PERMISSIONS.giftCreate),
|
canCreateGift: can(PERMISSIONS.giftCreate),
|
||||||
canCreateGrant: can(PERMISSIONS.resourceGrantCreate),
|
canCreateGrant: can(PERMISSIONS.resourceGrantCreate),
|
||||||
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
canCreateGroup: can(PERMISSIONS.resourceGroupCreate),
|
||||||
|
canRevokeGrant: can(PERMISSIONS.resourceGrantRevoke),
|
||||||
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
canCreateEmojiPack: can(PERMISSIONS.emojiPackCreate),
|
||||||
canDeleteGift: can(PERMISSIONS.giftDelete),
|
canDeleteGift: can(PERMISSIONS.giftDelete),
|
||||||
canStatusGift: can(PERMISSIONS.giftStatus),
|
canStatusGift: can(PERMISSIONS.giftStatus),
|
||||||
@ -20,6 +21,7 @@ export function useResourceAbilities() {
|
|||||||
canView: can(PERMISSIONS.resourceView),
|
canView: can(PERMISSIONS.resourceView),
|
||||||
canViewEmojiPack: can(PERMISSIONS.emojiPackView),
|
canViewEmojiPack: can(PERMISSIONS.emojiPackView),
|
||||||
canViewGrant: can(PERMISSIONS.resourceGrantView),
|
canViewGrant: can(PERMISSIONS.resourceGrantView),
|
||||||
|
canViewGroup: can(PERMISSIONS.resourceGroupView),
|
||||||
canViewShop: can(PERMISSIONS.resourceShopView),
|
canViewShop: can(PERMISSIONS.resourceShopView),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -100,6 +100,14 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.identityGrantLabel {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: var(--control-height);
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.selectColumn {
|
.selectColumn {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { cpRelationTypeOptions } from "@/features/resources/constants.js";
|
import { cpRelationTypeOptions, resourceIdentityAutoGrantTypes } from "@/features/resources/constants.js";
|
||||||
import { hasUnconfirmedMp4Layout } from "@/features/resources/mp4AlphaLayout.js";
|
import { hasUnconfirmedMp4Layout } from "@/features/resources/mp4AlphaLayout.js";
|
||||||
|
|
||||||
const resourceTypes = [
|
const resourceTypes = [
|
||||||
@ -177,6 +177,58 @@ export const resourceGroupCreateFormSchema = z
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const identityAutoGrantItemSchema = z.object({
|
||||||
|
identityType: z.enum(resourceIdentityAutoGrantTypes, "请选择身份"),
|
||||||
|
resourceGroupId: z.union([z.string(), z.number()]).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resourceIdentityAutoGrantFormSchema = z
|
||||||
|
.object({
|
||||||
|
items: z.array(identityAutoGrantItemSchema),
|
||||||
|
})
|
||||||
|
.superRefine((value, context) => {
|
||||||
|
const requiredIdentityTypes = new Set(resourceIdentityAutoGrantTypes);
|
||||||
|
const seenIdentityTypes = new Set();
|
||||||
|
|
||||||
|
if (value.items.length !== resourceIdentityAutoGrantTypes.length) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "必须配置所有身份",
|
||||||
|
path: ["items"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
value.items.forEach((item, index) => {
|
||||||
|
if (seenIdentityTypes.has(item.identityType)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "身份不能重复配置",
|
||||||
|
path: ["items", index, "identityType"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
seenIdentityTypes.add(item.identityType);
|
||||||
|
requiredIdentityTypes.delete(item.identityType);
|
||||||
|
|
||||||
|
const resourceGroupId =
|
||||||
|
item.resourceGroupId === undefined || item.resourceGroupId === "" ? 0 : Number(item.resourceGroupId);
|
||||||
|
if (!Number.isInteger(resourceGroupId) || resourceGroupId < 0) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "资源组必须是有效资源组",
|
||||||
|
path: ["items", index, "resourceGroupId"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
requiredIdentityTypes.forEach((identityType) => {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: "必须配置所有身份",
|
||||||
|
path: ["items", identityType],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
export const giftFormSchema = z
|
export const giftFormSchema = z
|
||||||
.object({
|
.object({
|
||||||
chargeAssetType: z.enum(["COIN"]),
|
chargeAssetType: z.enum(["COIN"]),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
resourceFormSchema,
|
resourceFormSchema,
|
||||||
resourceGrantFormSchema,
|
resourceGrantFormSchema,
|
||||||
resourceGroupCreateFormSchema,
|
resourceGroupCreateFormSchema,
|
||||||
|
resourceIdentityAutoGrantFormSchema,
|
||||||
resourceShopItemsFormSchema,
|
resourceShopItemsFormSchema,
|
||||||
} from "./schema.js";
|
} from "./schema.js";
|
||||||
|
|
||||||
@ -162,6 +163,62 @@ describe("resource form schema", () => {
|
|||||||
).toThrow(FormValidationError);
|
).toThrow(FormValidationError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("validates identity auto grant resource group config", () => {
|
||||||
|
const payload = parseForm(resourceIdentityAutoGrantFormSchema, {
|
||||||
|
items: [
|
||||||
|
{ identityType: "host", resourceGroupId: "11" },
|
||||||
|
{ identityType: "bd", resourceGroupId: "12" },
|
||||||
|
{ identityType: "bd_leader", resourceGroupId: "13" },
|
||||||
|
{ identityType: "agency", resourceGroupId: "14" },
|
||||||
|
{ identityType: "manager", resourceGroupId: "" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.items).toHaveLength(5);
|
||||||
|
expect(payload.items[4].resourceGroupId).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects incomplete identity auto grant config", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseForm(resourceIdentityAutoGrantFormSchema, {
|
||||||
|
items: [
|
||||||
|
{ identityType: "host", resourceGroupId: "11" },
|
||||||
|
{ identityType: "bd", resourceGroupId: "12" },
|
||||||
|
{ identityType: "bd_leader", resourceGroupId: "13" },
|
||||||
|
{ identityType: "agency", resourceGroupId: "14" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toThrow(FormValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects duplicate identity auto grant config", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseForm(resourceIdentityAutoGrantFormSchema, {
|
||||||
|
items: [
|
||||||
|
{ identityType: "host", resourceGroupId: "11" },
|
||||||
|
{ identityType: "bd", resourceGroupId: "12" },
|
||||||
|
{ identityType: "bd_leader", resourceGroupId: "13" },
|
||||||
|
{ identityType: "agency", resourceGroupId: "14" },
|
||||||
|
{ identityType: "agency", resourceGroupId: "15" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toThrow(FormValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects invalid identity auto grant resource group id", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseForm(resourceIdentityAutoGrantFormSchema, {
|
||||||
|
items: [
|
||||||
|
{ identityType: "host", resourceGroupId: "-1" },
|
||||||
|
{ identityType: "bd", resourceGroupId: "12" },
|
||||||
|
{ identityType: "bd_leader", resourceGroupId: "13" },
|
||||||
|
{ identityType: "agency", resourceGroupId: "14" },
|
||||||
|
{ identityType: "manager", resourceGroupId: "0" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toThrow(FormValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
test("validates editable gift form fields", () => {
|
test("validates editable gift form fields", () => {
|
||||||
const payload = parseForm(giftFormSchema, {
|
const payload = parseForm(giftFormSchema, {
|
||||||
chargeAssetType: "COIN",
|
chargeAssetType: "COIN",
|
||||||
|
|||||||
48
src/features/risk-config/api.ts
Normal file
48
src/features/risk-config/api.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { API_ENDPOINTS, API_OPERATIONS, apiEndpointPath } from "@/shared/api/generated/endpoints";
|
||||||
|
import { apiRequest } from "@/shared/api/request";
|
||||||
|
|
||||||
|
export interface RiskConfigDto {
|
||||||
|
appCode: string;
|
||||||
|
maxAccountsPerDevice: number;
|
||||||
|
updatedAtMs: number;
|
||||||
|
updatedByAdminId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskConfigPayload {
|
||||||
|
max_accounts_per_device: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RawRiskConfig = RiskConfigDto & Record<string, unknown>;
|
||||||
|
|
||||||
|
export function getRiskConfig(): Promise<RiskConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.getRiskConfig;
|
||||||
|
return apiRequest<RawRiskConfig>(apiEndpointPath(API_OPERATIONS.getRiskConfig), {
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizeRiskConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateRiskConfig(payload: RiskConfigPayload): Promise<RiskConfigDto> {
|
||||||
|
const endpoint = API_ENDPOINTS.updateRiskConfig;
|
||||||
|
return apiRequest<RawRiskConfig, RiskConfigPayload>(apiEndpointPath(API_OPERATIONS.updateRiskConfig), {
|
||||||
|
body: payload,
|
||||||
|
method: endpoint.method,
|
||||||
|
}).then(normalizeRiskConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRiskConfig(data: RawRiskConfig): RiskConfigDto {
|
||||||
|
return {
|
||||||
|
appCode: stringValue(data.appCode ?? data.app_code),
|
||||||
|
maxAccountsPerDevice: numberValue(data.maxAccountsPerDevice ?? data.max_accounts_per_device),
|
||||||
|
updatedAtMs: numberValue(data.updatedAtMs ?? data.updated_at_ms),
|
||||||
|
updatedByAdminId: numberValue(data.updatedByAdminId ?? data.updated_by_admin_id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown) {
|
||||||
|
const number = Number(value ?? 0);
|
||||||
|
return Number.isFinite(number) ? number : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" ? value : value == null ? "" : String(value);
|
||||||
|
}
|
||||||
80
src/features/risk-config/hooks/useRiskConfigPage.js
Normal file
80
src/features/risk-config/hooks/useRiskConfigPage.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
|
import { useAdminQuery } from "@/shared/hooks/useAdminQuery.js";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
|
import { getRiskConfig, updateRiskConfig } from "@/features/risk-config/api";
|
||||||
|
import { useRiskConfigAbilities } from "@/features/risk-config/permissions.js";
|
||||||
|
import { riskConfigSchema } from "@/features/risk-config/schema";
|
||||||
|
|
||||||
|
const emptyData = {
|
||||||
|
appCode: "",
|
||||||
|
maxAccountsPerDevice: 1,
|
||||||
|
updatedAtMs: 0,
|
||||||
|
updatedByAdminId: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useRiskConfigPage() {
|
||||||
|
const abilities = useRiskConfigAbilities();
|
||||||
|
const { showToast } = useToast();
|
||||||
|
const [form, setForm] = useState(emptyData);
|
||||||
|
const [loadingAction, setLoadingAction] = useState("");
|
||||||
|
const queryFn = useCallback(() => getRiskConfig(), []);
|
||||||
|
const {
|
||||||
|
data = emptyData,
|
||||||
|
error,
|
||||||
|
loading,
|
||||||
|
reload,
|
||||||
|
} = useAdminQuery(queryFn, {
|
||||||
|
errorMessage: "加载风控配置失败",
|
||||||
|
initialData: emptyData,
|
||||||
|
queryKey: ["risk-config", "register"],
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm({
|
||||||
|
appCode: data.appCode || "",
|
||||||
|
maxAccountsPerDevice: Number(data.maxAccountsPerDevice || 1),
|
||||||
|
updatedAtMs: Number(data.updatedAtMs || 0),
|
||||||
|
updatedByAdminId: Number(data.updatedByAdminId || 0),
|
||||||
|
});
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const changeMaxAccountsPerDevice = (value) => {
|
||||||
|
setForm((current) => ({ ...current, maxAccountsPerDevice: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitConfig = async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
setLoadingAction("save");
|
||||||
|
try {
|
||||||
|
const payload = parseForm(riskConfigSchema, {
|
||||||
|
maxAccountsPerDevice: form.maxAccountsPerDevice,
|
||||||
|
});
|
||||||
|
const saved = await updateRiskConfig({
|
||||||
|
max_accounts_per_device: payload.maxAccountsPerDevice,
|
||||||
|
});
|
||||||
|
setForm(saved);
|
||||||
|
await reload();
|
||||||
|
showToast("风控配置已保存", "success");
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err.message || "保存失败", "error");
|
||||||
|
} finally {
|
||||||
|
setLoadingAction("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
abilities,
|
||||||
|
changeMaxAccountsPerDevice,
|
||||||
|
data,
|
||||||
|
error,
|
||||||
|
form,
|
||||||
|
loading,
|
||||||
|
loadingAction,
|
||||||
|
reload,
|
||||||
|
submitConfig,
|
||||||
|
};
|
||||||
|
}
|
||||||
65
src/features/risk-config/pages/RiskConfigPage.jsx
Normal file
65
src/features/risk-config/pages/RiskConfigPage.jsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||||
|
import ShieldOutlined from "@mui/icons-material/ShieldOutlined";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { AdminActionIconButton, AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||||
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||||
|
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||||
|
import { useRiskConfigPage } from "@/features/risk-config/hooks/useRiskConfigPage.js";
|
||||||
|
import styles from "@/features/risk-config/risk-config.module.css";
|
||||||
|
|
||||||
|
export function RiskConfigPage() {
|
||||||
|
const page = useRiskConfigPage();
|
||||||
|
const canSave = page.abilities.canUpdate && page.loadingAction !== "save";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminListPage>
|
||||||
|
<AdminListToolbar
|
||||||
|
actions={
|
||||||
|
page.abilities.canUpdate ? (
|
||||||
|
<AdminActionIconButton
|
||||||
|
disabled={!canSave}
|
||||||
|
form="risk-config-form"
|
||||||
|
label="保存"
|
||||||
|
primary
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
<SaveOutlined fontSize="small" />
|
||||||
|
</AdminActionIconButton>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
|
||||||
|
<AdminListBody>
|
||||||
|
<form id="risk-config-form" className={styles.configForm} onSubmit={page.submitConfig}>
|
||||||
|
<section className={styles.configSection}>
|
||||||
|
<div className={styles.configHeader}>
|
||||||
|
<span className={styles.configIcon}>
|
||||||
|
<ShieldOutlined fontSize="small" />
|
||||||
|
</span>
|
||||||
|
<div className={styles.configTitle}>
|
||||||
|
<h2>注册限制</h2>
|
||||||
|
<span>
|
||||||
|
{page.form.updatedAtMs ? (
|
||||||
|
<TimeText value={page.form.updatedAtMs} />
|
||||||
|
) : (
|
||||||
|
"默认配置"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
disabled={!page.abilities.canUpdate}
|
||||||
|
inputProps={{ min: 1, step: 1 }}
|
||||||
|
label="单设备最大账号数"
|
||||||
|
type="number"
|
||||||
|
value={page.form.maxAccountsPerDevice}
|
||||||
|
onChange={(event) => page.changeMaxAccountsPerDevice(event.target.value)}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
</AdminListBody>
|
||||||
|
</DataState>
|
||||||
|
</AdminListPage>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/features/risk-config/permissions.js
Normal file
11
src/features/risk-config/permissions.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { useAuth } from "@/app/auth/AuthProvider.jsx";
|
||||||
|
import { PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export function useRiskConfigAbilities() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
return {
|
||||||
|
canUpdate: can(PERMISSIONS.riskConfigUpdate),
|
||||||
|
canView: can(PERMISSIONS.riskConfigView),
|
||||||
|
};
|
||||||
|
}
|
||||||
53
src/features/risk-config/risk-config.module.css
Normal file
53
src/features/risk-config/risk-config.module.css
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
.configForm {
|
||||||
|
max-width: 520px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configSection {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
background: var(--bg-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.configHeader {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.configIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--primary-soft);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.configTitle {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configTitle h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.configTitle span {
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
font-size: var(--admin-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.configForm {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/features/risk-config/routes.js
Normal file
12
src/features/risk-config/routes.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { MENU_CODES, PERMISSIONS } from "@/app/permissions";
|
||||||
|
|
||||||
|
export const riskConfigRoutes = [
|
||||||
|
{
|
||||||
|
label: "风控管理",
|
||||||
|
loader: () => import("./pages/RiskConfigPage.jsx").then((module) => module.RiskConfigPage),
|
||||||
|
menuCode: MENU_CODES.appUserRiskConfig,
|
||||||
|
pageKey: "app-user-risk-config",
|
||||||
|
path: "/app/users/risk-config",
|
||||||
|
permission: PERMISSIONS.riskConfigView,
|
||||||
|
},
|
||||||
|
];
|
||||||
7
src/features/risk-config/schema.ts
Normal file
7
src/features/risk-config/schema.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const riskConfigSchema = z.object({
|
||||||
|
maxAccountsPerDevice: z.coerce.number().int("单设备最大账号数必须是整数").min(1, "单设备最大账号数必须大于 0"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RiskConfigForm = z.infer<typeof riskConfigSchema>;
|
||||||
@ -27,6 +27,7 @@ const pinStatusOptions = [
|
|||||||
|
|
||||||
const pinTypeOptions = [
|
const pinTypeOptions = [
|
||||||
{ label: "区域置顶", value: "region" },
|
{ label: "区域置顶", value: "region" },
|
||||||
|
{ label: "国家置顶", value: "country" },
|
||||||
{ label: "全区置顶", value: "global" },
|
{ label: "全区置顶", value: "global" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -323,5 +324,11 @@ function formatRemaining(pin) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pinTypeLabel(pinType) {
|
function pinTypeLabel(pinType) {
|
||||||
return pinType === "global" ? "全区置顶" : "区域置顶";
|
if (pinType === "global") {
|
||||||
|
return "全区置顶";
|
||||||
|
}
|
||||||
|
if (pinType === "country") {
|
||||||
|
return "国家置顶";
|
||||||
|
}
|
||||||
|
return "区域置顶";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { parseForm } from "@/shared/forms/validation";
|
import { parseForm } from "@/shared/forms/validation";
|
||||||
import { humanRoomRobotConfigSchema, robotRoomCreateSchema, roomWhitelistSchema } from "./schema";
|
import { humanRoomRobotConfigSchema, robotRoomCreateSchema, roomPinCreateSchema, roomWhitelistSchema } from "./schema";
|
||||||
|
|
||||||
describe("robot room form schema", () => {
|
describe("robot room form schema", () => {
|
||||||
test("preserves int64 robot user ids as strings", () => {
|
test("preserves int64 robot user ids as strings", () => {
|
||||||
@ -136,3 +136,19 @@ describe("room whitelist schema", () => {
|
|||||||
expect(payload.userIds).toEqual(["1001", "325379237278126080"]);
|
expect(payload.userIds).toEqual(["1001", "325379237278126080"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("room pin create schema", () => {
|
||||||
|
test("accepts country pin type", () => {
|
||||||
|
const payload = parseForm(roomPinCreateSchema, {
|
||||||
|
expiresAtMs: "2026-07-23T19:17",
|
||||||
|
pinType: "country",
|
||||||
|
pinnedAtMs: "2026-06-23T19:17",
|
||||||
|
roomId: "lalu_20d8aba4-23da-44d2-aa6e-876f88",
|
||||||
|
weight: "0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.pinType).toBe("country");
|
||||||
|
expect(payload.roomId).toBe("lalu_20d8aba4-23da-44d2-aa6e-876f88");
|
||||||
|
expect(payload.weight).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -30,7 +30,7 @@ const commaSeparatedTextListSchema = z
|
|||||||
const pinTypeSchema = z
|
const pinTypeSchema = z
|
||||||
.string()
|
.string()
|
||||||
.trim()
|
.trim()
|
||||||
.refine((value) => value === "region" || value === "global", "请选择置顶类型")
|
.refine((value) => value === "region" || value === "country" || value === "global", "请选择置顶类型")
|
||||||
.default("region");
|
.default("region");
|
||||||
const dateTimeMsSchema = z.union([z.string(), z.number()]).transform((value, context) => {
|
const dateTimeMsSchema = z.union([z.string(), z.number()]).transform((value, context) => {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
if (typeof value === "number" && Number.isFinite(value)) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
280
src/shared/api/generated/schema.d.ts
vendored
280
src/shared/api/generated/schema.d.ts
vendored
@ -148,6 +148,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/activity/cp-weekly-rank/config": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getCPWeeklyRankConfig"];
|
||||||
|
put: operations["updateCPWeeklyRankConfig"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/activity/cp-weekly-rank/settlements": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listCPWeeklyRankSettlements"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/activity/red-packets": {
|
"/admin/activity/red-packets": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1044,6 +1076,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/coin-sellers/{user_id}/stock-debits": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["debitCoinSellerStock"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/coin-seller-salary-rates/{region_id}": {
|
"/admin/coin-seller-salary-rates/{region_id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1812,6 +1860,38 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/payment/temporary-links": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listTemporaryPaymentLinks"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/admin/payment/temporary-links/{order_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getTemporaryPaymentLink"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/payment/third-party-channels": {
|
"/admin/payment/third-party-channels": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -1972,6 +2052,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/resource-grants/{grant_id}/revoke": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post: operations["revokeResourceGrant"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/resource-grants/resource": {
|
"/admin/resource-grants/resource": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2068,6 +2164,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/resource-groups/identity-auto-grant-config": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getResourceIdentityAutoGrantConfig"];
|
||||||
|
put: operations["updateResourceIdentityAutoGrantConfig"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/admin/resource-groups": {
|
"/admin/resource-groups": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -2484,6 +2596,22 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/admin/users/risk-config": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getRiskConfig"];
|
||||||
|
put: operations["updateRiskConfig"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/app/users": {
|
"/app/users": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -3848,6 +3976,42 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getCPWeeklyRankConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateCPWeeklyRankConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
listCPWeeklyRankSettlements: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listRedPackets: {
|
listRedPackets: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -4959,6 +5123,20 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
debitCoinSellerStock: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
user_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
getCoinSellerSalaryRates: {
|
getCoinSellerSalaryRates: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -5863,6 +6041,32 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listTemporaryPaymentLinks: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getTemporaryPaymentLink: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
order_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listThirdPartyPaymentChannels: {
|
listThirdPartyPaymentChannels: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -5910,7 +6114,13 @@ export interface operations {
|
|||||||
path?: never;
|
path?: never;
|
||||||
cookie?: never;
|
cookie?: never;
|
||||||
};
|
};
|
||||||
requestBody?: never;
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
markupPercent: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
responses: {
|
responses: {
|
||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
@ -6033,6 +6243,20 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
revokeResourceGrant: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
grant_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
grantResource: {
|
grantResource: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -6121,6 +6345,30 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getResourceIdentityAutoGrantConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateResourceIdentityAutoGrantConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
listResourceGroups: {
|
listResourceGroups: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -6604,6 +6852,36 @@ export interface operations {
|
|||||||
200: components["responses"]["EmptyResponse"];
|
200: components["responses"]["EmptyResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getRiskConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updateRiskConfig: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
max_accounts_per_device: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
200: components["responses"]["EmptyResponse"];
|
||||||
|
};
|
||||||
|
};
|
||||||
appListUsers: {
|
appListUsers: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@ -473,6 +473,34 @@ export interface ThirdPartyPaymentChannelDto {
|
|||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ThirdPartyPaymentRateSyncPayload {
|
||||||
|
markupPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TemporaryPaymentLinkDto {
|
||||||
|
appCode?: string;
|
||||||
|
audienceType?: string;
|
||||||
|
coinAmount: number;
|
||||||
|
countryCode?: string;
|
||||||
|
createdAtMs?: number;
|
||||||
|
currencyCode?: string;
|
||||||
|
failureReason?: string;
|
||||||
|
orderId: string;
|
||||||
|
payType?: string;
|
||||||
|
payUrl?: string;
|
||||||
|
payWay?: string;
|
||||||
|
paymentMethodId?: EntityId;
|
||||||
|
productName?: string;
|
||||||
|
providerAmountMinor?: number;
|
||||||
|
providerCode?: string;
|
||||||
|
providerOrderId?: string;
|
||||||
|
status?: string;
|
||||||
|
transactionId?: string;
|
||||||
|
txHash?: string;
|
||||||
|
updatedAtMs?: number;
|
||||||
|
usdMinorAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ThirdPartyPaymentRateSyncDto {
|
export interface ThirdPartyPaymentRateSyncDto {
|
||||||
missingCurrencies?: string[];
|
missingCurrencies?: string[];
|
||||||
rates?: Record<string, string>;
|
rates?: Record<string, string>;
|
||||||
@ -566,7 +594,9 @@ export interface ManagerDto {
|
|||||||
canAddSuperadmin?: boolean;
|
canAddSuperadmin?: boolean;
|
||||||
canBlockUser?: boolean;
|
canBlockUser?: boolean;
|
||||||
canGrantAvatarFrame?: boolean;
|
canGrantAvatarFrame?: boolean;
|
||||||
|
canGrantBadge?: boolean;
|
||||||
canGrantVehicle?: boolean;
|
canGrantVehicle?: boolean;
|
||||||
|
canTransferUserCountry?: boolean;
|
||||||
canUpdateUserLevel?: boolean;
|
canUpdateUserLevel?: boolean;
|
||||||
contact?: string;
|
contact?: string;
|
||||||
createdAtMs?: number;
|
createdAtMs?: number;
|
||||||
@ -586,7 +616,9 @@ export interface CreateManagerPayload {
|
|||||||
canAddSuperadmin?: boolean;
|
canAddSuperadmin?: boolean;
|
||||||
canBlockUser?: boolean;
|
canBlockUser?: boolean;
|
||||||
canGrantAvatarFrame?: boolean;
|
canGrantAvatarFrame?: boolean;
|
||||||
|
canGrantBadge?: boolean;
|
||||||
canGrantVehicle?: boolean;
|
canGrantVehicle?: boolean;
|
||||||
|
canTransferUserCountry?: boolean;
|
||||||
canUpdateUserLevel?: boolean;
|
canUpdateUserLevel?: boolean;
|
||||||
contact?: string;
|
contact?: string;
|
||||||
targetUserId: string;
|
targetUserId: string;
|
||||||
@ -598,9 +630,12 @@ export interface UpdateManagerPayload {
|
|||||||
canAddSuperadmin?: boolean;
|
canAddSuperadmin?: boolean;
|
||||||
canBlockUser?: boolean;
|
canBlockUser?: boolean;
|
||||||
canGrantAvatarFrame?: boolean;
|
canGrantAvatarFrame?: boolean;
|
||||||
|
canGrantBadge?: boolean;
|
||||||
canGrantVehicle?: boolean;
|
canGrantVehicle?: boolean;
|
||||||
|
canTransferUserCountry?: boolean;
|
||||||
canUpdateUserLevel?: boolean;
|
canUpdateUserLevel?: boolean;
|
||||||
contact?: string;
|
contact?: string;
|
||||||
|
status?: "active" | "disabled";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HostProfileDto {
|
export interface HostProfileDto {
|
||||||
@ -671,6 +706,7 @@ export interface CoinSellerStockDebitPayload {
|
|||||||
coinAmount: number;
|
coinAmount: number;
|
||||||
commandId: string;
|
commandId: string;
|
||||||
evidenceRef?: string;
|
evidenceRef?: string;
|
||||||
|
rechargeAmount: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -678,7 +714,12 @@ export interface CoinSellerStockDebitDto {
|
|||||||
availableDelta: number;
|
availableDelta: number;
|
||||||
balanceAfter: number;
|
balanceAfter: number;
|
||||||
coinAmount: number;
|
coinAmount: number;
|
||||||
|
countsAsSellerRecharge?: boolean;
|
||||||
|
createdAtMs?: number;
|
||||||
|
paidAmountMicro?: number;
|
||||||
|
paidCurrencyCode?: string;
|
||||||
sellerUserId: string;
|
sellerUserId: string;
|
||||||
|
stockType?: string;
|
||||||
transactionId: string;
|
transactionId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -717,6 +758,7 @@ export interface CoinSellerSalaryRatesPayload {
|
|||||||
|
|
||||||
export interface AppUserDto {
|
export interface AppUserDto {
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
|
balances?: AppUserAssetBalanceDto[];
|
||||||
coin?: number;
|
coin?: number;
|
||||||
country?: string;
|
country?: string;
|
||||||
countryDisplayName?: string;
|
countryDisplayName?: string;
|
||||||
@ -725,16 +767,57 @@ export interface AppUserDto {
|
|||||||
defaultDisplayUserId?: string;
|
defaultDisplayUserId?: string;
|
||||||
diamond?: number;
|
diamond?: number;
|
||||||
displayUserId?: string;
|
displayUserId?: string;
|
||||||
|
equippedResources?: AppUserResourceDto[];
|
||||||
gender?: string;
|
gender?: string;
|
||||||
lastActiveAtMs?: number;
|
lastActiveAtMs?: number;
|
||||||
prettyDisplayUserId?: string;
|
prettyDisplayUserId?: string;
|
||||||
prettyId?: string;
|
prettyId?: string;
|
||||||
regionId?: number;
|
regionId?: number;
|
||||||
regionName?: string;
|
regionName?: string;
|
||||||
|
resources?: AppUserResourceDto[];
|
||||||
status?: string;
|
status?: string;
|
||||||
updatedAtMs?: number;
|
updatedAtMs?: number;
|
||||||
userId: string;
|
userId: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
vip?: AppUserVIPDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppUserAssetBalanceDto {
|
||||||
|
assetType: string;
|
||||||
|
availableAmount: number;
|
||||||
|
frozenAmount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
version: number;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppUserVIPDto {
|
||||||
|
active: boolean;
|
||||||
|
expiresAtMs: number;
|
||||||
|
level: number;
|
||||||
|
name?: string;
|
||||||
|
startedAtMs: number;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppUserResourceDto {
|
||||||
|
animationUrl?: string;
|
||||||
|
assetUrl?: string;
|
||||||
|
createdAtMs: number;
|
||||||
|
effectiveAtMs: number;
|
||||||
|
entitlementId: string;
|
||||||
|
equipped: boolean;
|
||||||
|
expiresAtMs: number;
|
||||||
|
name: string;
|
||||||
|
previewUrl?: string;
|
||||||
|
quantity: number;
|
||||||
|
remainingQuantity: number;
|
||||||
|
resourceCode: string;
|
||||||
|
resourceId: number;
|
||||||
|
resourceType: string;
|
||||||
|
sourceGrantId?: string;
|
||||||
|
status: string;
|
||||||
|
updatedAtMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AppUserBriefDto {
|
export interface AppUserBriefDto {
|
||||||
|
|||||||
@ -99,6 +99,10 @@ export function TimeRangeFilter({ className = "", disabled = false, label = "时
|
|||||||
const clearRange = () => applyRange(emptyRange);
|
const clearRange = () => applyRange(emptyRange);
|
||||||
const submitDraft = (event) => {
|
const submitDraft = (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
// Popover content is rendered through a portal, but React still bubbles
|
||||||
|
// submit events through the component tree. Stop here so a picker confirm
|
||||||
|
// never submits an outer edit drawer before the new range reaches state.
|
||||||
|
event.stopPropagation();
|
||||||
const nextRange = normalizeRange(draft);
|
const nextRange = normalizeRange(draft);
|
||||||
if (nextRange.startMs && nextRange.endMs && nextRange.endMs <= nextRange.startMs) {
|
if (nextRange.startMs && nextRange.endMs && nextRange.endMs <= nextRange.startMs) {
|
||||||
setError("结束时间必须晚于开始时间");
|
setError("结束时间必须晚于开始时间");
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { datetimeTextToMs, normalizeRange, timeRangeLabel } from "@/shared/ui/TimeRangeFilter.jsx";
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
import { TimeRangeFilter, datetimeTextToMs, normalizeRange, timeRangeLabel } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||||
|
|
||||||
describe("TimeRangeFilter helpers", () => {
|
describe("TimeRangeFilter helpers", () => {
|
||||||
test("parses strict local date time text", () => {
|
test("parses strict local date time text", () => {
|
||||||
@ -24,4 +26,23 @@ describe("TimeRangeFilter helpers", () => {
|
|||||||
expect(timeRangeLabel("时间", {})).toBe("时间");
|
expect(timeRangeLabel("时间", {})).toBe("时间");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("confirms picker changes without submitting an outer form", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const onSubmit = vi.fn((event) => event.preventDefault());
|
||||||
|
const startMs = datetimeTextToMs("2026-05-13 10:00");
|
||||||
|
const endMs = datetimeTextToMs("2026-05-13 12:00");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<form onSubmit={onSubmit}>
|
||||||
|
<TimeRangeFilter label="活动时间" value={{ endMs, startMs }} onChange={onChange} />
|
||||||
|
</form>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /活动时间/ }));
|
||||||
|
await user.click(screen.getByRole("button", { name: "确定" }));
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith({ endMs, startMs });
|
||||||
|
expect(onSubmit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user