交互优化

This commit is contained in:
zhx 2026-06-07 14:45:51 +08:00
parent fd80e18aff
commit 2118a2ee4d
29 changed files with 1635 additions and 723 deletions

View File

@ -37,7 +37,7 @@ export function MetricCard({ item, loading = false, onClick }) {
<strong>{item.value}</strong>
<div className={["metric-delta", item.deltaTone === "down" ? "metric-delta--down" : ""].join(" ")}>
<span>{item.caption}</span>
<b>{item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : ""}</b>
<b className={item.delta ? "" : "metric-delta-empty"}>{item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : "--"}</b>
</div>
</div>
</article>

View File

@ -139,6 +139,10 @@
color: #f5a623;
}
.metric-delta .metric-delta-empty {
color: #7f9bb7;
}
.skeleton-block {
display: block;
position: relative;

View File

@ -672,9 +672,9 @@
.business-metric-grid .metric-content {
display: grid;
grid-template-columns: minmax(0, 1fr) max-content;
grid-template-columns: minmax(max-content, 1fr) max-content;
grid-template-rows: auto 1fr;
column-gap: 12px;
column-gap: 8px;
align-items: center;
}
@ -707,6 +707,15 @@
.business-metric-grid .metric-label-row {
grid-column: 1;
grid-row: 1;
gap: 4px;
font-size: 11px;
white-space: nowrap;
}
.business-metric-grid .metric-label-row > span {
flex: 0 0 auto;
overflow: visible;
text-overflow: clip;
}
.business-metric-grid .metric-content strong {

View File

@ -1,20 +1,13 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { RewardTierEditorTable } from "@/shared/ui/RewardTierEditorTable.jsx";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
import styles from "@/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css";
const statusOptions = [
["active", "启用"],
["inactive", "停用"],
];
export function CumulativeRechargeRewardConfigDrawer({
abilities,
configLoading,
@ -35,7 +28,7 @@ export function CumulativeRechargeRewardConfigDrawer({
...(current.tiers || []),
{
tierId: 0,
tierCode: "",
tierCode: `cumulative_recharge_${Date.now()}_${(current.tiers || []).length + 1}`,
tierName: "",
thresholdUsd: "",
resourceGroupId: "",
@ -62,101 +55,94 @@ export function CumulativeRechargeRewardConfigDrawer({
return (
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
<form className="form-drawer" onSubmit={onSubmit}>
<form className="form-drawer form-drawer--activity-config" onSubmit={onSubmit}>
<h2>累充奖励配置</h2>
<section className="form-drawer__section">
<div className="form-drawer__section-title">发放状态</div>
<AdminSwitch
checked={form.enabled}
checkedLabel="开启"
disabled={disabled}
label="累充奖励状态"
uncheckedLabel="关闭"
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
</section>
<section className="form-drawer__section">
<div className={styles.drawerSectionTitle}>
<span>奖励档位</span>
<Button
<div className="form-drawer__content">
<section className="form-drawer__section">
<div className="form-drawer__section-title">发放状态</div>
<AdminSwitch
checked={form.enabled}
checkedLabel="开启"
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={addTier}
>
添加档位
</Button>
</div>
<div className={styles.tierEditorList}>
{(form.tiers || []).map((tier, index) => (
<div className={styles.tierEditor} key={`${tier.tierId || "new"}-${index}`}>
<div className={styles.tierEditorHeader}>
<span>档位 {index + 1}</span>
<IconButton
aria-label="删除档位"
disabled={disabled}
size="small"
onClick={() => removeTier(index)}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</div>
<div className="form-drawer__grid">
<TextField
disabled={disabled}
label="档位编码"
value={tier.tierCode}
onChange={(event) => updateTier(index, { tierCode: event.target.value })}
/>
<TextField
disabled={disabled}
label="档位名称"
value={tier.tierName}
onChange={(event) => updateTier(index, { tierName: event.target.value })}
/>
<TextField
disabled={disabled}
inputProps={{ min: 0.01, step: 0.01 }}
label="充值金额 USD"
type="number"
value={tier.thresholdUsd}
onChange={(event) => updateTier(index, { thresholdUsd: event.target.value })}
/>
<ResourceGroupSelectField
disabled={disabled}
drawerTitle="选择累充奖励资源组"
groups={resourceGroups}
label="奖励资源组"
value={tier.resourceGroupId}
onChange={(value) => updateTier(index, { resourceGroupId: value })}
/>
<TextField
select
disabled={disabled}
label="状态"
value={tier.status}
onChange={(event) => updateTier(index, { status: event.target.value })}
>
{statusOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
inputProps={{ min: 0 }}
label="排序"
type="number"
value={tier.sortOrder}
onChange={(event) => updateTier(index, { sortOrder: event.target.value })}
/>
</div>
</div>
))}
{!form.tiers?.length ? <div className={styles.emptyState}>暂无档位</div> : null}
</div>
</section>
label="累充奖励状态"
uncheckedLabel="关闭"
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
</section>
<section className="form-drawer__section">
<div className={styles.drawerSectionTitle}>
<span>奖励档位</span>
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={addTier}
>
添加档位
</Button>
</div>
<RewardTierEditorTable
columns={[
{
key: "tier",
label: "档位",
render: (_tier, index) => (
<span className="reward-tier-editor-table__index">档位 {index + 1}</span>
),
},
{
key: "thresholdUsd",
label: "累充 USD",
render: (tier, index) => (
<TextField
disabled={disabled}
inputProps={{ "aria-label": "累充 USD", min: 0.01, step: 0.01 }}
placeholder="100.00"
type="number"
value={tier.thresholdUsd}
onChange={(event) => updateTier(index, { thresholdUsd: event.target.value })}
/>
),
},
{
key: "resourceGroupId",
label: "奖励资源组",
render: (tier, index) => (
<ResourceGroupSelectField
disabled={disabled}
drawerTitle="选择累充奖励资源组"
groups={resourceGroups}
label="奖励资源组"
value={tier.resourceGroupId}
onChange={(value) => updateTier(index, { resourceGroupId: value })}
/>
),
},
{
key: "status",
label: "状态",
render: (tier, index) => (
<AdminSwitch
checked={tier.status !== "inactive"}
checkedLabel="启用"
disabled={disabled}
label={`档位 ${index + 1} 状态`}
uncheckedLabel="停用"
onChange={(event) =>
updateTier(index, { status: event.target.checked ? "active" : "inactive" })
}
/>
),
},
]}
disabled={disabled}
emptyText="暂无档位"
gridTemplateColumns="72px minmax(140px, 0.85fr) minmax(260px, 1.5fr) 120px 52px"
rows={form.tiers || []}
onRemoveTier={removeTier}
/>
</section>
</div>
<div className="form-drawer__actions">
<Button disabled={configSaving} type="button" onClick={onClose}>
取消

View File

@ -158,26 +158,26 @@ function payloadFromForm(form) {
const tiers = (form.tiers || []).map((tier, index) => {
const thresholdUsdMinor = Math.round(Number(tier.thresholdUsd || 0) * 100);
const resourceGroupId = Number(tier.resourceGroupId || 0);
if (!tier.tierCode.trim()) {
throw new Error("档位编码不能为空");
}
if (!tier.tierName.trim()) {
throw new Error("档位名称不能为空");
}
if (thresholdUsdMinor <= 0) {
throw new Error("充值 USD 金额必须大于 0");
}
if (resourceGroupId <= 0) {
throw new Error("请选择资源组");
}
const existingTier = Number(tier.tierId || 0) > 0;
const tierCode = String(tier.tierCode || "").trim() || `cumulative_recharge_${Date.now()}_${index + 1}`;
const tierName =
existingTier && String(tier.tierName || "").trim()
? String(tier.tierName).trim()
: `累充 ${(thresholdUsdMinor / 100).toFixed(2)} USD`;
return {
tier_id: Number(tier.tierId || 0),
tier_code: tier.tierCode.trim(),
tier_name: tier.tierName.trim(),
tier_code: tierCode,
tier_name: tierName,
threshold_usd_minor: thresholdUsdMinor,
resource_group_id: resourceGroupId,
status: tier.status === "inactive" ? "inactive" : "active",
sort_order: Number(tier.sortOrder || index),
sort_order: index,
};
});
if (form.enabled && tiers.filter((tier) => tier.status === "active").length === 0) {

View File

@ -8,7 +8,7 @@ export interface GamePlatformDto {
platformName: string;
status: string;
apiBaseUrl: string;
// 服务端按 adapterType 选择厂商协议:demo/yomi_v4/leadercc_v1/zeeone_v1/baishun_v1/vivagames_v1。
// 服务端按 adapterType 选择真实厂商协议yomi_v4/leadercc_v1/zeeone_v1/baishun_v1/vivagames_v1。
adapterType: string;
// callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。
callbackSecret?: string;
@ -173,7 +173,7 @@ function normalizePlatform(platform: Partial<GamePlatformDto>): GamePlatformDto
platformName: stringValue(platform.platformName),
status: stringValue(platform.status || "active"),
apiBaseUrl: stringValue(platform.apiBaseUrl),
adapterType: stringValue(platform.adapterType || "demo"),
adapterType: stringValue(platform.adapterType || "yomi_v4"),
callbackSecret: stringValue(platform.callbackSecret),
callbackSecretSet: Boolean(platform.callbackSecretSet),
callbackIpWhitelist: Array.isArray(platform.callbackIpWhitelist)

View File

@ -37,8 +37,8 @@ const defaultPlatformForm = {
platformName: "",
status: "active",
apiBaseUrl: "",
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1/zeeone_v1/baishun_v1/vivagames_v1
adapterType: "demo",
// 新建平台必须选择真实厂商协议,避免把线上配置误落成 demo
adapterType: "yomi_v4",
// 后台现在会回显厂商 key编辑弹窗直接显示当前值提交空值仍代表“不覆盖旧值”。
callbackSecret: "",
callbackSecretSet: false,
@ -542,7 +542,7 @@ function platformToForm(platform) {
platformName: platform.platformName || "",
status: platform.status || "active",
apiBaseUrl: platform.apiBaseUrl || "",
adapterType: platform.adapterType || "demo",
adapterType: platform.adapterType || "yomi_v4",
// 使用后端返回的当前密钥,方便运营直接核对;用户清空提交时后端会保留旧值。
callbackSecret: platform.callbackSecret || "",
callbackSecretSet: Boolean(platform.callbackSecretSet),
@ -589,7 +589,7 @@ function platformPayload(form) {
platformName: form.platformName.trim(),
status: form.status.trim() || "active",
apiBaseUrl: isLeaderCC ? "" : form.apiBaseUrl.trim(),
adapterType: form.adapterType.trim() || "demo",
adapterType: form.adapterType.trim() || "yomi_v4",
// 空密钥提交给后端后会保留旧值;非空才覆盖,用于安全轮换 key/AppSecret。
callbackSecret: form.callbackSecret.trim(),
// 支持换行或逗号分隔,方便直接粘贴厂商给的多 IP 白名单。

View File

@ -44,7 +44,6 @@ const launchModeOptions = [
];
// game-service adapter_type
const adapterTypeOptions = [
["demo", "Demo"],
["yomi_v4", "小游 Yomi V4"],
["leadercc_v1", "灵仙 LeaderCC V1"],
["zeeone_v1", "ZeeOne V1"],
@ -543,7 +542,7 @@ function PlatformManageDialog({ open, page, onClose }) {
<div className={styles.stack}>
<span className={styles.name}>{platform.platformName || platform.platformCode}</span>
<span className={styles.meta}>
{platform.platformCode} / {platform.adapterType || "demo"}
{platform.platformCode} / {platform.adapterType || "未配置"}
</span>
{platform.apiBaseUrl ? <span className={styles.meta}>{platform.apiBaseUrl}</span> : null}
<span className={`${styles.meta} ${styles.secretMeta}`}>

View File

@ -33,10 +33,10 @@ const helpText = {
poolId: "奖池 ID 是幸运礼物规则隔离键。不同奖池拥有独立 RTP、窗口和阶段奖档。",
poolRatePercent: "每笔幸运礼物扣费进入基础奖池的比例,必须大于等于 RTP避免奖池长期亏空。",
userHourlyPayoutCap: "同一用户每小时基础返奖上限。达到上限后,该用户会优先进入 0x 或低倍率。",
userDailyPayoutCap: "同一用户每日基础返奖上限。按 UTC 自然日统计。",
userDailyPayoutCap: "同一用户每日基础返奖上限。按服务端自然日统计。",
deviceDailyPayoutCap: "同一设备每日基础返奖上限。用于限制多账号集中领取。",
roomHourlyPayoutCap: "同一房间每小时基础返奖上限。房间触顶后,新用户也会被该房间上限影响。",
anchorDailyPayoutCap: "同一收礼主播每日基础返奖上限。按 UTC 自然日统计。",
anchorDailyPayoutCap: "同一收礼主播每日基础返奖上限。按服务端自然日统计。",
settlementWindowWager: "RTP 结算窗口按金币流水计算,不按抽数计算,低价和高价礼物会自然等比参与控制。",
stages: "新手、正常、高阶三个阶段必须各自配置概率,抽奖引擎按用户阶段读取对应奖档。",
targetRtpPercent: "基础返奖目标。95 表示长期每消耗 100 金币,基础 RTP 目标返还 95 金币。",

View File

@ -13,6 +13,7 @@ import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { IconButton } from "@/shared/ui/IconButton.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
@ -30,6 +31,7 @@ const statusOptions = [
const rechargeTypeOptions = [
["", "全部途径"],
["coin_seller_transfer", "币商充值"],
["google_play_recharge", "谷歌充值"],
];
const columns = [
@ -41,46 +43,36 @@ const columns = [
},
{
key: "userId",
label: "用户 ID",
label: "用户信息",
width: "minmax(190px, 1fr)",
render: (bill) => <BillUser bill={bill} type="user" />,
},
{
key: "sellerUserId",
label: "币商 ID",
label: "币商信息",
width: "minmax(190px, 1fr)",
render: (bill) => <BillUser bill={bill} type="seller" />,
},
{
key: "amount",
label: "金币 / 金额",
width: "minmax(150px, 0.75fr)",
render: (bill) => (
<Stack
primary={formatNumber(bill.coinAmount)}
secondary={formatMoney(bill.usdMinorAmount, bill.currencyCode)}
/>
),
key: "coinAmount",
label: "金币",
width: "minmax(120px, 0.6fr)",
render: (bill) => formatNumber(bill.coinAmount),
},
{
key: "policy",
label: "兑换口径",
width: "minmax(170px, 0.9fr)",
render: (bill) => (
<Stack
primary={bill.policyVersion || "-"}
secondary={`${formatNumber(bill.exchangeCoinAmount)} / ${formatMoney(bill.exchangeUsdMinorAmount, bill.currencyCode)}`}
/>
),
key: "money",
label: "金额",
width: "minmax(120px, 0.6fr)",
render: (bill) => billMoneyText(bill),
},
{
key: "region",
label: "区域",
width: "minmax(110px, 0.55fr)",
label: "国家/区域",
width: "minmax(150px, 0.75fr)",
render: (bill, _index, context) => (
<Stack
primary={regionName(bill.targetRegionId, context?.regionNames)}
secondary={bill.sellerRegionId ? `来源 ${regionName(bill.sellerRegionId, context?.regionNames)}` : "-"}
primary={billCountryName(bill)}
secondary={regionName(bill.targetRegionId, context?.regionNames)}
/>
),
},
@ -109,11 +101,12 @@ export function PaymentBillListPage() {
const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState("");
const [rechargeType, setRechargeType] = useState("");
const [regionId, setRegionId] = useState("");
const [userId, setUserId] = useState("");
const [sellerUserId, setSellerUserId] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const [exporting, setExporting] = useState(false);
const { regionOptions } = useRegionOptions();
const { loadingRegions, regionOptions } = useRegionOptions();
const { showToast } = useToast();
const regionNames = useMemo(() => regionNameMap(regionOptions), [regionOptions]);
@ -122,12 +115,13 @@ export function PaymentBillListPage() {
end_at_ms: timeRange.endMs,
keyword,
recharge_type: rechargeType,
region_id: regionId,
seller_user_id: sellerUserId,
start_at_ms: timeRange.startMs,
status,
user_id: userId,
}),
[keyword, rechargeType, sellerUserId, status, timeRange.endMs, timeRange.startMs, userId],
[keyword, rechargeType, regionId, sellerUserId, status, timeRange.endMs, timeRange.startMs, userId],
);
const query = usePaginatedQuery({
errorMessage: "获取账单列表失败",
@ -204,6 +198,17 @@ export function PaymentBillListPage() {
}),
};
}
if (column.key === "region") {
return {
...column,
filter: createRegionColumnFilter({
loading: loadingRegions,
options: regionOptions,
value: regionId,
onChange: resetPage(setRegionId),
}),
};
}
if (column.key === "status") {
return {
...column,
@ -318,8 +323,8 @@ function BillUser({ bill, type }) {
{profile.avatar ? <img alt="" src={profile.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.identityId}>{shortId}</span>
<span className={styles.identityName}>{name}</span>
<span className={styles.identityId}>{name}</span>
<span className={styles.identityName}>{shortId}</span>
</span>
</div>
);
@ -366,16 +371,31 @@ function formatMoney(value, currency = "USD") {
})}`;
}
function billMoneyText(bill) {
if (bill?.rechargeType === "coin_seller_transfer") {
return "";
}
return formatMoney(bill?.usdMinorAmount, bill?.currencyCode);
}
function normalizeBillUser(profile, fallbackId) {
const source = profile || {};
return {
avatar: source.avatar || "",
countryCode: source.countryCode || source.country_code || "",
countryDisplayName: source.countryDisplayName || source.country_display_name || "",
countryName: source.countryName || source.country_name || "",
displayUserId: source.displayUserId || source.display_user_id || "",
userId: String(source.userId || source.user_id || fallbackId || ""),
username: source.username || source.name || "",
};
}
function billCountryName(bill) {
const user = normalizeBillUser(bill?.user, bill?.userId);
return user.countryDisplayName || user.countryName || user.countryCode || "-";
}
function regionNameMap(regionOptions = []) {
return Object.fromEntries(
regionOptions.map((region) => [String(region.regionId || region.value), region.name || region.label]),
@ -418,11 +438,8 @@ function createBillsCsv(bills, regionNames) {
"币商名称",
"金币",
"金额",
"兑换政策",
"兑换金币",
"兑换金额",
"国家",
"区域",
"来源区域",
"充值途径",
"状态",
"创建时间",
@ -441,12 +458,9 @@ function createBillsCsv(bills, regionNames) {
seller.displayUserId,
seller.username,
formatNumber(bill.coinAmount),
formatMoney(bill.usdMinorAmount, bill.currencyCode),
bill.policyVersion,
formatNumber(bill.exchangeCoinAmount),
formatMoney(bill.exchangeUsdMinorAmount, bill.currencyCode),
billMoneyText(bill),
billCountryName(bill),
regionName(bill.targetRegionId, regionNames),
bill.sellerRegionId ? regionName(bill.sellerRegionId, regionNames) : "",
rechargeTypeLabel(bill.rechargeType),
statusLabel(bill.status),
formatMillis(bill.createdAtMs),

View File

@ -0,0 +1,123 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { PaymentBillListPage } from "@/features/payment/pages/PaymentBillListPage.jsx";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
vi.mock("@/shared/hooks/usePaginatedQuery.js", () => ({
usePaginatedQuery: vi.fn(),
}));
vi.mock("@/shared/hooks/useRegionOptions.js", () => ({
useRegionOptions: vi.fn(),
}));
vi.mock("@/shared/ui/ToastProvider.jsx", () => ({
useToast: () => ({ showToast: vi.fn() }),
}));
afterEach(() => {
vi.clearAllMocks();
});
test("payment bills show user seller country region and split amount columns", () => {
mockRegions();
vi.mocked(usePaginatedQuery).mockReturnValue(queryFixture([coinSellerBill()]));
render(<PaymentBillListPage />);
expect(screen.getByText("用户信息")).toBeInTheDocument();
expect(screen.getByText("币商信息")).toBeInTheDocument();
expect(screen.getByText("金币")).toBeInTheDocument();
expect(screen.getByText("金额")).toBeInTheDocument();
expect(screen.getByText("国家/区域")).toBeInTheDocument();
expect(screen.queryByText("兑换口径")).not.toBeInTheDocument();
expect(screen.queryByText("金币 / 金额")).not.toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("165075")).toBeInTheDocument();
expect(screen.getByText("Seller")).toBeInTheDocument();
expect(screen.getByText("164917")).toBeInTheDocument();
expect(screen.getByText("菲律宾")).toBeInTheDocument();
expect(screen.getByText("东南亚")).toBeInTheDocument();
expect(screen.queryByText("USD 0.00")).not.toBeInTheDocument();
});
test("payment bills include google recharge option and region header filter", () => {
mockRegions();
vi.mocked(usePaginatedQuery).mockReturnValue(queryFixture([googleBill()]));
render(<PaymentBillListPage />);
expect(screen.getByText("谷歌充值")).toBeInTheDocument();
expect(screen.getByText("USDT 0.99")).toBeInTheDocument();
fireEvent.click(screen.getAllByRole("button", { name: /国家\/区域/ })[0]);
const filter = screen.getByRole("presentation");
expect(within(filter).getByText("全部区域")).toBeInTheDocument();
expect(within(filter).getByText("中东 · ME · 2")).toBeInTheDocument();
});
function mockRegions() {
vi.mocked(useRegionOptions).mockReturnValue({
loadingRegions: false,
regionOptions: [
{ label: "东南亚 · SEA · 1", name: "东南亚", regionId: 1, value: "1" },
{ label: "中东 · ME · 2", name: "中东", regionId: 2, value: "2" },
],
});
}
function queryFixture(items) {
return {
data: { items, page: 1, pageSize: 50, total: items.length },
error: null,
loading: false,
reload: vi.fn(),
};
}
function coinSellerBill() {
return {
coinAmount: 130000,
createdAtMs: 1760000000000,
currencyCode: "USD",
rechargeType: "coin_seller_transfer",
seller: { displayUserId: "164917", userId: "2002", username: "Seller" },
sellerUserId: 2002,
status: "succeeded",
targetRegionId: 1,
transactionId: "tx_coin",
usdMinorAmount: 0,
user: {
countryCode: "PH",
countryDisplayName: "菲律宾",
displayUserId: "165075",
userId: "1001",
username: "Alice",
},
userId: 1001,
};
}
function googleBill() {
return {
coinAmount: 80000,
createdAtMs: 1760000000000,
currencyCode: "USDT",
rechargeType: "google_play_recharge",
status: "succeeded",
targetRegionId: 2,
transactionId: "tx_google",
usdMinorAmount: 99,
user: {
countryCode: "AE",
countryDisplayName: "阿联酋",
displayUserId: "165108",
userId: "1002",
username: "Google User",
},
userId: 1002,
};
}

View File

@ -0,0 +1,153 @@
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import SearchOutlined from "@mui/icons-material/SearchOutlined";
import InputAdornment from "@mui/material/InputAdornment";
import TextField from "@mui/material/TextField";
import { useMemo, useState } from "react";
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
import styles from "@/features/resources/components/ResourceSelectDrawer.module.css";
const drawerZIndex = 1600;
export function GiftSelectField({
disabled = false,
drawerTitle = "选择礼物",
gifts = [],
label = "礼物",
loading = false,
onChange = () => {},
placeholder = "请选择礼物",
required = true,
value,
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const normalizedValue = String(value || "");
const selectedGift = gifts.find((gift) => String(gift.giftId) === normalizedValue);
const displayValue = selectedGift ? giftName(selectedGift) : normalizedValue ? `礼物 #${normalizedValue}` : "";
const filteredGifts = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) {
return gifts;
}
return gifts.filter((gift) =>
[
gift.name,
gift.giftId,
gift.giftTypeCode,
gift.resource?.name,
gift.resource?.resourceCode,
gift.resource?.resourceId,
]
.filter(Boolean)
.join(" ")
.toLowerCase()
.includes(keyword),
);
}, [gifts, query]);
const openDrawer = () => {
if (!disabled && !loading) {
setOpen(true);
}
};
const selectGift = (gift) => {
onChange(String(gift.giftId), gift);
setOpen(false);
};
return (
<>
<TextField
fullWidth
className={styles.field}
disabled={disabled || loading}
label={label}
placeholder={loading ? "礼物加载中" : placeholder}
required={required}
slotProps={{ htmlInput: { "aria-haspopup": "dialog", readOnly: true, role: "button" } }}
value={displayValue}
onClick={openDrawer}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openDrawer();
}
}}
/>
<SideDrawer
className={styles.drawer}
contentClassName={styles.drawerBody}
drawerProps={{ sx: { zIndex: drawerZIndex } }}
open={open}
title={drawerTitle}
width="wide"
onClose={() => setOpen(false)}
>
<div className={styles.filters}>
<TextField
className={styles.search}
placeholder="搜索礼物名称、礼物 ID、资源编码"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchOutlined fontSize="small" />
</InputAdornment>
),
},
}}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
<div className={styles.grid}>
{filteredGifts.map((gift) => {
const selected = String(gift.giftId) === normalizedValue;
return (
<button
className={[styles.card, selected ? styles.cardSelected : ""].filter(Boolean).join(" ")}
key={gift.giftId}
type="button"
onClick={() => selectGift(gift)}
>
<GiftThumb gift={gift} />
<span className={styles.name}>{giftName(gift)}</span>
<span className={styles.meta}>
{gift.resource?.resourceCode || gift.giftId || `资源 ${gift.resourceId || "-"}`}
</span>
<span className={styles.tags}>
<span className={styles.tag}>{gift.giftTypeCode || "普通礼物"}</span>
<span className={styles.tag}>金币 {formatNumber(gift.coinPrice)}</span>
</span>
</button>
);
})}
{!filteredGifts.length ? <div className={styles.empty}>当前无可选礼物</div> : null}
</div>
</SideDrawer>
</>
);
}
function GiftThumb({ gift }) {
const imageUrl = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl || gift.resource?.animationUrl);
return (
<span className={styles.thumb}>
{imageUrl ? <img alt="" src={imageUrl} /> : <CardGiftcardOutlined fontSize="small" />}
</span>
);
}
function giftName(gift) {
return gift?.name || gift?.resource?.name || gift?.giftId || "礼物";
}
function imageURL(value) {
const url = String(value || "").trim();
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}

View File

@ -15,9 +15,7 @@ import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.
import { UploadField } from "@/shared/ui/UploadField.jsx";
import styles from "@/features/room-rocket/room-rocket.module.css";
const fuelSourceOptions = [
["heat_value", "礼物贡献"],
];
const fuelSourceOptions = [["heat_value", "礼物贡献"]];
const broadcastScopeOptions = [
["region", "区域广播"],
@ -51,7 +49,11 @@ export function RoomRocketConfigEditor({
const [rewardTab, setRewardTab] = useState("inRoomRewards");
const [editorTab, setEditorTab] = useState("levels");
const activeLevelIndex = useMemo(
() => Math.max(0, form.levels.findIndex((level) => Number(level.level) === Number(activeLevel))),
() =>
Math.max(
0,
form.levels.findIndex((level) => Number(level.level) === Number(activeLevel)),
),
[activeLevel, form.levels],
);
const level = form.levels[activeLevelIndex] || form.levels[0];
@ -72,7 +74,12 @@ export function RoomRocketConfigEditor({
<Button disabled={configSaving} type="button" onClick={onCancel}>
取消
</Button>
<Button disabled={disabled} startIcon={<SaveOutlined fontSize="small" />} type="submit" variant="primary">
<Button
disabled={disabled}
startIcon={<SaveOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存
</Button>
</div>
@ -161,7 +168,9 @@ export function RoomRocketConfigEditor({
<div className={styles.levelDetailHeader}>
<div className={styles.stack}>
<span className={styles.title}>L{level.level}</span>
<span className={styles.meta}>当前等级溢出燃料作废点火后下一等级立即开始</span>
<span className={styles.meta}>
当前等级溢出燃料作废点火后下一等级立即开始
</span>
</div>
<TextField
required
@ -171,27 +180,36 @@ export function RoomRocketConfigEditor({
type="number"
value={level.fuelThreshold}
onChange={(event) =>
updateLevel(setForm, activeLevelIndex, { fuelThreshold: event.target.value })
updateLevel(setForm, activeLevelIndex, {
fuelThreshold: event.target.value,
})
}
/>
</div>
<div className={styles.materialGrid}>
<UploadField
density="compact"
disabled={disabled}
kind="image"
label="封面图"
value={level.coverUrl}
onChange={(value) => updateLevel(setForm, activeLevelIndex, { coverUrl: value })}
onChange={(value) =>
updateLevel(setForm, activeLevelIndex, { coverUrl: value })
}
/>
<UploadField
density="compact"
disabled={disabled}
kind="file"
label="火箭动效"
uploadKind="file"
value={level.animationUrl}
onChange={(value) => updateLevel(setForm, activeLevelIndex, { animationUrl: value })}
onChange={(value) =>
updateLevel(setForm, activeLevelIndex, { animationUrl: value })
}
/>
<UploadField
density="compact"
disabled={disabled}
kind="file"
label="发射动效"
@ -202,11 +220,14 @@ export function RoomRocketConfigEditor({
}
/>
<UploadField
density="compact"
disabled={disabled}
kind="image"
label="发射后图"
value={level.launchedImageUrl}
onChange={(value) => updateLevel(setForm, activeLevelIndex, { launchedImageUrl: value })}
onChange={(value) =>
updateLevel(setForm, activeLevelIndex, { launchedImageUrl: value })
}
/>
</div>
<div className={styles.rewardPanel}>
@ -251,19 +272,22 @@ export function RoomRocketConfigEditor({
}
function LevelNavItem({ active, level, onClick }) {
const materialCount = [
level.coverUrl,
level.animationUrl,
level.launchAnimationUrl,
level.launchedImageUrl,
].filter(Boolean).length;
const materialCount = [level.coverUrl, level.animationUrl, level.launchAnimationUrl, level.launchedImageUrl].filter(
Boolean,
).length;
const rewardCount = rewardTabs.filter(([key]) => (level[key] || []).length > 0).length;
return (
<button className={[styles.levelNavItem, active ? styles.levelNavItemActive : ""].join(" ")} type="button" onClick={onClick}>
<button
className={[styles.levelNavItem, active ? styles.levelNavItemActive : ""].join(" ")}
type="button"
onClick={onClick}
>
<span className={styles.levelNavMain}>L{level.level}</span>
<span className={styles.levelNavMeta}>阈值 {formatNumber(level.fuelThreshold)}</span>
<span className={styles.levelNavMeta}>物料 {materialCount}/4 · 奖励 {rewardCount}/3</span>
<span className={styles.levelNavMeta}>
物料 {materialCount}/4 · 奖励 {rewardCount}/3
</span>
</button>
);
}
@ -289,7 +313,9 @@ function RewardTable({ disabled, levelIndex, poolKey, resourceGroups, rewards, s
groups={resourceGroups}
label="资源组"
value={reward.resourceGroupId}
onChange={(value) => updateReward(setForm, levelIndex, poolKey, rewardIndex, { resourceGroupId: value })}
onChange={(value) =>
updateReward(setForm, levelIndex, poolKey, rewardIndex, { resourceGroupId: value })
}
/>
<TextField
required
@ -298,7 +324,9 @@ function RewardTable({ disabled, levelIndex, poolKey, resourceGroups, rewards, s
label="权重"
type="number"
value={reward.weight}
onChange={(event) => updateReward(setForm, levelIndex, poolKey, rewardIndex, { weight: event.target.value })}
onChange={(event) =>
updateReward(setForm, levelIndex, poolKey, rewardIndex, { weight: event.target.value })
}
/>
<TextField
disabled={disabled}
@ -309,11 +337,14 @@ function RewardTable({ disabled, levelIndex, poolKey, resourceGroups, rewards, s
}
/>
<UploadField
density="compact"
disabled={disabled}
kind="image"
label="奖励图标"
value={reward.iconUrl}
onChange={(value) => updateReward(setForm, levelIndex, poolKey, rewardIndex, { iconUrl: value })}
onChange={(value) =>
updateReward(setForm, levelIndex, poolKey, rewardIndex, { iconUrl: value })
}
/>
<Tooltip arrow title="删除奖项">
<span>
@ -339,7 +370,12 @@ function FuelRuleEditor({ disabled, form, setForm }) {
<div className={styles.fuelRuleEditor}>
<div className={styles.fuelRuleToolbar}>
<span className={styles.meta}>未命中特殊规则的礼物按基础燃料来源计算</span>
<Button disabled={disabled} startIcon={<AddOutlined fontSize="small" />} type="button" onClick={() => addFuelRule(setForm)}>
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={() => addFuelRule(setForm)}
>
添加规则
</Button>
</div>
@ -365,7 +401,9 @@ function FuelRuleEditor({ disabled, form, setForm }) {
disabled={disabled}
label="礼物类型"
value={rule.giftTypeCode}
onChange={(event) => updateFuelRule(setForm, index, { giftTypeCode: event.target.value })}
onChange={(event) =>
updateFuelRule(setForm, index, { giftTypeCode: event.target.value })
}
/>
<TextField
disabled={disabled}
@ -373,7 +411,9 @@ function FuelRuleEditor({ disabled, form, setForm }) {
label="倍率 ppm"
type="number"
value={rule.multiplierPpm}
onChange={(event) => updateFuelRule(setForm, index, { multiplierPpm: event.target.value })}
onChange={(event) =>
updateFuelRule(setForm, index, { multiplierPpm: event.target.value })
}
/>
<TextField
disabled={disabled}
@ -414,7 +454,13 @@ function FuelRuleEditor({ disabled, form, setForm }) {
function SelectField({ disabled, label, onChange, options, value }) {
return (
<TextField select disabled={disabled} label={label} value={value} onChange={(event) => onChange(event.target.value)}>
<TextField
select
disabled={disabled}
label={label}
value={value}
onChange={(event) => onChange(event.target.value)}
>
{options.map(([optionValue, optionLabel]) => (
<MenuItem key={optionValue} value={optionValue}>
{optionLabel}

View File

@ -111,7 +111,7 @@
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 28px;
padding: 10px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-card);
}
@ -141,11 +141,27 @@
.editorSection {
display: grid;
min-width: 0;
gap: 18px;
padding: 22px 28px;
gap: 12px;
padding: 16px 24px;
border-bottom: 1px solid var(--border);
}
.editorPage :global(.MuiTextField-root),
.editorPage :global(.MuiFormControl-root) {
--admin-control-height: 30px;
--admin-control-font-size: 13px;
}
.editorPage :global(.MuiTabs-root) {
min-height: 36px;
}
.editorPage :global(.MuiTab-root) {
min-height: 36px;
padding: 0 14px;
font-size: 13px;
}
.sectionTitle {
color: var(--text-primary);
font-size: 15px;
@ -155,44 +171,45 @@
.basicGrid {
display: grid;
grid-template-columns: repeat(4, minmax(180px, 1fr));
gap: 14px;
gap: 10px 12px;
}
.switchField {
display: flex;
min-height: var(--control-height);
min-height: 30px;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 12px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card);
color: var(--text-primary);
font-size: 13px;
font-weight: 700;
}
.levelWorkspace {
display: grid;
min-width: 0;
grid-template-columns: 220px minmax(0, 1fr);
gap: 22px;
grid-template-columns: 188px minmax(0, 1fr);
gap: 16px;
align-items: start;
}
.levelNav {
position: sticky;
top: 16px;
top: 12px;
display: grid;
gap: 8px;
gap: 6px;
}
.levelNavItem {
display: grid;
min-width: 0;
gap: 4px;
padding: 12px;
gap: 2px;
padding: 9px 10px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-card);
@ -208,7 +225,7 @@
.levelNavMain {
color: var(--text-primary);
font-size: 15px;
font-size: 14px;
font-weight: 800;
}
@ -223,26 +240,26 @@
.levelDetail {
display: grid;
min-width: 0;
gap: 20px;
gap: 12px;
}
.levelDetailHeader {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
gap: 18px;
grid-template-columns: minmax(0, 1fr) minmax(200px, 240px);
gap: 12px;
align-items: center;
}
.materialGrid {
display: grid;
grid-template-columns: repeat(4, minmax(170px, 1fr));
gap: 14px;
gap: 10px;
}
.rewardPanel {
display: grid;
min-width: 0;
gap: 14px;
gap: 10px;
}
.rewardPanelHead,
@ -251,7 +268,7 @@
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 14px;
gap: 10px;
}
.rewardTable,
@ -259,15 +276,15 @@
.fuelRuleTable {
display: grid;
min-width: 0;
gap: 10px;
gap: 8px;
}
.rewardTableHead,
.rewardTableRow {
display: grid;
min-width: 0;
grid-template-columns: minmax(220px, 1.2fr) 120px minmax(180px, 1fr) minmax(220px, 1fr) var(--control-height);
gap: 12px;
grid-template-columns: minmax(200px, 1.2fr) 96px minmax(150px, 1fr) minmax(170px, 1fr) var(--control-height);
gap: 8px;
align-items: start;
}
@ -281,7 +298,7 @@
.rewardTableRow,
.fuelRuleTableRow {
padding: 12px 0;
padding: 8px 0;
border-top: 1px solid var(--border);
}
@ -290,7 +307,7 @@
display: grid;
min-width: 0;
grid-template-columns: minmax(140px, 1fr) minmax(140px, 1fr) 130px 130px 120px var(--control-height);
gap: 12px;
gap: 8px;
align-items: start;
}

View File

@ -1,19 +1,12 @@
import AddOutlined from "@mui/icons-material/AddOutlined";
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import SaveOutlined from "@mui/icons-material/SaveOutlined";
import Drawer from "@mui/material/Drawer";
import IconButton from "@mui/material/IconButton";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { RewardTierEditorTable } from "@/shared/ui/RewardTierEditorTable.jsx";
import styles from "@/features/room-turnover-reward/room-turnover-reward.module.css";
const statusOptions = [
["active", "启用"],
["inactive", "停用"],
];
export function RoomTurnoverRewardConfigDrawer({
abilities,
configLoading,
@ -33,7 +26,7 @@ export function RoomTurnoverRewardConfigDrawer({
...(current.tiers || []),
{
tierId: 0,
tierCode: "",
tierCode: `room_turnover_${Date.now()}_${(current.tiers || []).length + 1}`,
tierName: "",
thresholdCoinSpent: "",
rewardCoinAmount: "",
@ -60,105 +53,98 @@ export function RoomTurnoverRewardConfigDrawer({
return (
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
<form className="form-drawer" onSubmit={onSubmit}>
<form className="form-drawer form-drawer--activity-config" onSubmit={onSubmit}>
<h2>房间流水奖励配置</h2>
<section className="form-drawer__section">
<div className="form-drawer__section-title">结算状态</div>
<AdminSwitch
checked={form.enabled}
checkedLabel="开启"
disabled={disabled}
label="房间流水奖励状态"
uncheckedLabel="关闭"
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
</section>
<section className="form-drawer__section">
<div className={styles.drawerSectionTitle}>
<span>奖励档位</span>
<Button
<div className="form-drawer__content">
<section className="form-drawer__section">
<div className="form-drawer__section-title">结算状态</div>
<AdminSwitch
checked={form.enabled}
checkedLabel="开启"
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={addTier}
>
添加档位
</Button>
</div>
<div className={styles.tierEditorList}>
{(form.tiers || []).map((tier, index) => (
<div className={styles.tierEditor} key={`${tier.tierId || "new"}-${index}`}>
<div className={styles.tierEditorHeader}>
<span>档位 {index + 1}</span>
<IconButton
aria-label="删除档位"
disabled={disabled}
size="small"
onClick={() => removeTier(index)}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</div>
<div className="form-drawer__grid">
<TextField
disabled={disabled}
label="档位编码"
value={tier.tierCode}
onChange={(event) => updateTier(index, { tierCode: event.target.value })}
/>
<TextField
disabled={disabled}
label="档位名称"
value={tier.tierName}
onChange={(event) => updateTier(index, { tierName: event.target.value })}
/>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label="流水阈值"
type="number"
value={tier.thresholdCoinSpent}
onChange={(event) =>
updateTier(index, { thresholdCoinSpent: event.target.value })
}
/>
<TextField
disabled={disabled}
inputProps={{ min: 0 }}
label="奖励金币"
type="number"
value={tier.rewardCoinAmount}
onChange={(event) =>
updateTier(index, { rewardCoinAmount: event.target.value })
}
/>
<TextField
select
disabled={disabled}
label="状态"
value={tier.status}
onChange={(event) => updateTier(index, { status: event.target.value })}
>
{statusOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
inputProps={{ min: 0 }}
label="排序"
type="number"
value={tier.sortOrder}
onChange={(event) => updateTier(index, { sortOrder: event.target.value })}
/>
</div>
</div>
))}
{!form.tiers?.length ? <div className={styles.emptyState}>暂无档位</div> : null}
</div>
</section>
label="房间流水奖励状态"
uncheckedLabel="关闭"
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
</section>
<section className="form-drawer__section">
<div className={styles.drawerSectionTitle}>
<span>奖励档位</span>
<Button
disabled={disabled}
startIcon={<AddOutlined fontSize="small" />}
type="button"
onClick={addTier}
>
添加档位
</Button>
</div>
<RewardTierEditorTable
columns={[
{
key: "tier",
label: "档位",
render: (_tier, index) => (
<span className="reward-tier-editor-table__index">档位 {index + 1}</span>
),
},
{
key: "thresholdCoinSpent",
label: "流水阈值",
render: (tier, index) => (
<TextField
disabled={disabled}
inputProps={{ "aria-label": "流水阈值", min: 1 }}
placeholder="100000"
type="number"
value={tier.thresholdCoinSpent}
onChange={(event) =>
updateTier(index, { thresholdCoinSpent: event.target.value })
}
/>
),
},
{
key: "rewardCoinAmount",
label: "奖励金币",
render: (tier, index) => (
<TextField
disabled={disabled}
inputProps={{ "aria-label": "奖励金币", min: 0 }}
placeholder="1000"
type="number"
value={tier.rewardCoinAmount}
onChange={(event) =>
updateTier(index, { rewardCoinAmount: event.target.value })
}
/>
),
},
{
key: "status",
label: "状态",
render: (tier, index) => (
<AdminSwitch
checked={tier.status !== "inactive"}
checkedLabel="启用"
disabled={disabled}
label={`档位 ${index + 1} 状态`}
uncheckedLabel="停用"
onChange={(event) =>
updateTier(index, { status: event.target.checked ? "active" : "inactive" })
}
/>
),
},
]}
disabled={disabled}
emptyText="暂无档位"
gridTemplateColumns="72px minmax(160px, 1fr) minmax(160px, 1fr) 120px 52px"
rows={form.tiers || []}
onRemoveTier={removeTier}
/>
</section>
</div>
<div className="form-drawer__actions">
<Button disabled={configSaving} type="button" onClick={onClose}>
取消

View File

@ -176,26 +176,26 @@ function payloadFromForm(form) {
const tiers = (form.tiers || []).map((tier, index) => {
const thresholdCoinSpent = Number(tier.thresholdCoinSpent || 0);
const rewardCoinAmount = Number(tier.rewardCoinAmount || 0);
if (!tier.tierCode.trim()) {
throw new Error("档位编码不能为空");
}
if (!tier.tierName.trim()) {
throw new Error("档位名称不能为空");
}
if (thresholdCoinSpent <= 0) {
throw new Error("流水阈值必须大于 0");
}
if (rewardCoinAmount < 0) {
throw new Error("奖励金币不能小于 0");
}
const existingTier = Number(tier.tierId || 0) > 0;
const tierCode = String(tier.tierCode || "").trim() || `room_turnover_${Date.now()}_${index + 1}`;
const tierName =
existingTier && String(tier.tierName || "").trim()
? String(tier.tierName).trim()
: `房间流水 ${thresholdCoinSpent}`;
return {
tier_id: Number(tier.tierId || 0),
tier_code: tier.tierCode.trim(),
tier_name: tier.tierName.trim(),
tier_code: tierCode,
tier_name: tierName,
threshold_coin_spent: thresholdCoinSpent,
reward_coin_amount: rewardCoinAmount,
status: tier.status === "inactive" ? "inactive" : "active",
sort_order: Number(tier.sortOrder || index),
sort_order: index,
};
});
const activeTiers = tiers

View File

@ -29,7 +29,7 @@ const columnsBase = [
},
{
key: "period",
label: "UTC 周期",
label: "周期",
width: "minmax(260px, 1.1fr)",
render: (item) => (
<div className={styles.stack}>
@ -90,13 +90,13 @@ const columnsBase = [
width: "112px",
fixed: "right",
resizable: false,
render: (item, context) =>
item.status === "failed" && context.abilities.canRetry ? (
render: (item, _index, context = {}) =>
item.status === "failed" && context.abilities?.canRetry ? (
<AdminActionIconButton
disabled={context.retryingSettlementId === item.settlementId}
label="重试"
tooltip="重试发放"
onClick={() => context.retrySettlement(item)}
onClick={() => context.retrySettlement?.(item)}
>
<ReplayOutlined fontSize="small" />
</AdminActionIconButton>

View File

@ -1,3 +1,4 @@
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import {
@ -7,7 +8,7 @@ import {
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { formatMillis } from "@/shared/utils/time.js";
import { TimeRangeText, TimeText } from "@/shared/ui/TimeText.jsx";
import { useUserLeaderboardPage } from "@/features/user-leaderboard/hooks/useUserLeaderboardPage.js";
import styles from "@/features/user-leaderboard/user-leaderboard.module.css";
@ -32,7 +33,7 @@ const columns = [
},
{
key: "subject",
label: "对象",
label: "用户信息",
width: "minmax(280px, 1.25fr)",
render: (item, _index, context) => <LeaderboardSubject item={item} type={context.boardType} />,
},
@ -58,7 +59,7 @@ const columns = [
key: "lastGiftAtMs",
label: "最后送礼时间",
width: "minmax(180px, 0.85fr)",
render: (item) => `${formatMillis(item.lastGiftAtMs, "UTC")} UTC`,
render: (item) => <TimeText value={item.lastGiftAtMs} />,
},
];
@ -93,9 +94,9 @@ export function UserLeaderboardPage() {
/>
<div className={styles.summary}>
<div>
<span className={styles.summaryLabel}>UTC 时间范围</span>
<span className={styles.summaryLabel}>时间范围</span>
<span className={styles.summaryValue}>
{formatMillis(page.data.startAtMs, "UTC")} - {formatMillis(page.data.endAtMs, "UTC")}
<TimeRangeText end={page.data.endAtMs} start={page.data.startAtMs} />
</span>
</div>
{page.data.myRank ? (
@ -136,7 +137,7 @@ function LeaderboardSubject({ item, type }) {
if (type === "room") {
const room = item.room || {};
return (
<div className={styles.identity}>
<div className={styles.roomIdentity}>
<span className={styles.name}>{room.title || item.roomId || "-"}</span>
<span className={styles.meta}>
{room.roomShortId ? `短号 ${room.roomShortId}` : item.roomId || "-"}
@ -146,12 +147,16 @@ function LeaderboardSubject({ item, type }) {
);
}
const user = item.user || {};
const shortId = user.displayUserId || "";
const displayName = user.username || (shortId ? `用户 ${shortId}` : "用户");
return (
<div className={styles.identity}>
<span className={styles.name}>{user.username || user.displayUserId || item.userId || "-"}</span>
<span className={styles.meta}>
{user.displayUserId ? `短号 ${user.displayUserId}` : ""}
{item.userId ? `${user.displayUserId ? " · " : ""}ID ${item.userId}` : ""}
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.name}>{displayName}</span>
<span className={styles.meta}>短ID {shortId || "-"}</span>
</span>
</div>
);

View File

@ -24,11 +24,44 @@
}
.identity {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
}
.identityText {
display: grid;
min-width: 0;
gap: 2px;
}
.roomIdentity {
display: grid;
min-width: 0;
gap: 4px;
}
.avatar {
display: inline-flex;
width: 34px;
height: 34px;
flex: 0 0 34px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 50%;
background: var(--bg-card-strong);
color: var(--text-tertiary);
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.name {
overflow: hidden;
color: var(--text-primary);

View File

@ -5,17 +5,14 @@ import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined";
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
import WorkspacePremiumOutlined from "@mui/icons-material/WorkspacePremiumOutlined";
import Alert from "@mui/material/Alert";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Chip from "@mui/material/Chip";
import Divider from "@mui/material/Divider";
import Drawer from "@mui/material/Drawer";
import FormControl from "@mui/material/FormControl";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import Select from "@mui/material/Select";
import Snackbar from "@mui/material/Snackbar";
import Stack from "@mui/material/Stack";
import Table from "@mui/material/Table";
import TableBody from "@mui/material/TableBody";
import TableCell from "@mui/material/TableCell";
@ -34,9 +31,22 @@ import {
updateWeeklyStarCycle,
} from "@/features/weekly-star/api";
import { listGifts, listResourceGroups } from "@/features/resources/api";
import { GiftSelectField } from "@/features/resources/components/GiftSelectDrawer.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
AdminRowActions,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { TimeText } from "@/shared/ui/TimeText.jsx";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
const statusOptions = [
["draft", "草稿"],
@ -51,8 +61,8 @@ const emptyForm = {
title: "Weekly Star",
regionId: "0",
status: "draft",
startAt: "",
endAt: "",
startMs: "",
endMs: "",
giftIds: ["", "", ""],
resourceGroupIds: ["", "", ""],
};
@ -69,6 +79,7 @@ export function WeeklyStarPage() {
const [toast, setToast] = useState("");
const [leaderboard, setLeaderboard] = useState({ cycle: null, loading: false, items: [], error: "" });
const [settlements, setSettlements] = useState({ cycle: null, loading: false, items: [], error: "" });
const { loadingRegions, regionOptions } = useRegionOptions();
const giftOptions = useMemo(
() =>
@ -86,6 +97,10 @@ export function WeeklyStarPage() {
})),
[resourceGroups],
);
const weeklyStarRegionOptions = useMemo(
() => [{ label: "默认区域 · 0", name: "默认区域", regionId: 0, value: "0" }, ...regionOptions],
[regionOptions],
);
useEffect(() => {
reloadCycles();
@ -122,10 +137,11 @@ export function WeeklyStarPage() {
}
function openCreate() {
const now = Date.now();
setForm({
...emptyForm,
startAt: formatUTCInput(Date.now()),
endAt: formatUTCInput(Date.now() + 7 * 24 * 60 * 60 * 1000),
startMs: now,
endMs: now + 7 * 24 * 60 * 60 * 1000,
});
setDrawerOpen(true);
}
@ -139,8 +155,8 @@ export function WeeklyStarPage() {
title: cycle.title || "Weekly Star",
regionId: String(cycle.regionId || 0),
status: cycle.status || "draft",
startAt: formatUTCInput(cycle.startMs),
endAt: formatUTCInput(cycle.endMs),
startMs: cycle.startMs || "",
endMs: cycle.endMs || "",
giftIds: [0, 1, 2].map((index) => String(cycle.gifts[index]?.giftId || "")),
resourceGroupIds: rewards,
});
@ -167,6 +183,14 @@ export function WeeklyStarPage() {
}));
}
function updateTimeRange(value) {
setForm((current) => ({
...current,
endMs: value?.endMs || "",
startMs: value?.startMs || "",
}));
}
async function submitForm(event) {
event.preventDefault();
const payload = buildPayload(form);
@ -194,8 +218,8 @@ export function WeeklyStarPage() {
function buildPayload(values) {
const giftIds = values.giftIds.map((item) => String(item || "").trim()).filter(Boolean);
const resourceGroupIds = values.resourceGroupIds.map((item) => Number(item || 0));
const startMs = parseUTCInput(values.startAt);
const endMs = parseUTCInput(values.endAt);
const startMs = Number(values.startMs || 0);
const endMs = Number(values.endMs || 0);
if (!values.title.trim()) {
setToast("请填写活动标题");
return null;
@ -209,7 +233,7 @@ export function WeeklyStarPage() {
return null;
}
if (!startMs || !endMs || endMs <= startMs) {
setToast("请填写有效 UTC 时间范围");
setToast("请填写有效时间范围");
return null;
}
return {
@ -272,220 +296,218 @@ export function WeeklyStarPage() {
/>
<DataState error={error} loading={loading} onRetry={reloadCycles}>
<AdminListBody>
<TableContainer sx={{ minWidth: 1200 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>周期</TableCell>
<TableCell>区域</TableCell>
<TableCell>UTC 时间</TableCell>
<TableCell>礼物</TableCell>
<TableCell>奖励资源组</TableCell>
<TableCell>状态</TableCell>
<TableCell align="right">操作</TableCell>
</TableRow>
</TableHead>
<TableBody>
{cycles.map((cycle) => (
<TableRow hover key={cycle.cycleId}>
<TableCell>
<Stack spacing={0.5}>
<Typography fontWeight={700}>{cycle.title || "-"}</Typography>
<Typography color="text.secondary" variant="caption">
{cycle.cycleId}
</Typography>
</Stack>
</TableCell>
<TableCell>{cycle.regionId === 0 ? "默认区域 · 0" : cycle.regionId}</TableCell>
<TableCell>
<Stack spacing={0.5}>
<span>{formatUTC(cycle.startMs)}</span>
<Typography color="text.secondary" variant="caption">
{formatUTC(cycle.endMs)}
</Typography>
</Stack>
</TableCell>
<TableCell>
<Stack direction="row" flexWrap="wrap" gap={0.75}>
{cycle.gifts.map((gift) => (
<Chip
key={gift.giftId}
label={giftLabel(gift.giftId, giftOptions)}
size="small"
/>
))}
</Stack>
</TableCell>
<TableCell>
<Stack direction="row" flexWrap="wrap" gap={0.75}>
{cycle.rewards.map((reward) => (
<Chip
key={reward.rankNo}
label={`Top${reward.rankNo}: ${groupLabel(
reward.resourceGroupId,
resourceGroupOptions,
)}`}
size="small"
variant="outlined"
/>
))}
</Stack>
</TableCell>
<TableCell>
<Chip
color={statusColor(cycle.status)}
label={statusLabel(cycle.status)}
size="small"
/>
</TableCell>
<TableCell align="right">
<Stack direction="row" justifyContent="flex-end" spacing={1}>
<Button
startIcon={<EditOutlined fontSize="small" />}
onClick={() => openEdit(cycle)}
>
编辑
</Button>
<Button
disabled={cycle.status === "settled" || cycle.status === "settling"}
variant={cycle.status === "active" ? "danger" : "success"}
onClick={() => toggleStatus(cycle)}
>
{cycle.status === "active" ? "停用" : "启用"}
</Button>
<Button
startIcon={<BarChartOutlined fontSize="small" />}
onClick={() => openLeaderboard(cycle)}
>
排行榜
</Button>
<Button
startIcon={<WorkspacePremiumOutlined fontSize="small" />}
onClick={() => openSettlements(cycle)}
>
结算
</Button>
</Stack>
</TableCell>
</TableRow>
))}
{!cycles.length ? (
<TableRow>
<TableCell colSpan={7}>
<Box sx={{ py: 6, textAlign: "center", color: "text.secondary" }}>
暂无周星周期配置
</Box>
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</TableContainer>
<DataTable
columns={[
{
key: "cycle",
label: "周期",
width: "minmax(280px, 1.1fr)",
render: (cycle) => (
<div className="weekly-star-list-stack">
<span className="weekly-star-list-title">{cycle.title || "-"}</span>
<span className="weekly-star-list-meta">{cycle.cycleId || "-"}</span>
</div>
),
},
{
key: "region",
label: "区域",
width: "120px",
render: (cycle) => regionLabel(cycle.regionId, weeklyStarRegionOptions),
},
{
key: "time",
label: "时间",
width: "minmax(240px, 0.9fr)",
render: (cycle) => (
<div className="weekly-star-list-stack">
<TimeText value={cycle.startMs} />
<span className="weekly-star-list-meta">
<TimeText value={cycle.endMs} />
</span>
</div>
),
},
{
key: "gifts",
label: "礼物",
width: "minmax(260px, 1fr)",
render: (cycle) => (
<WeeklyStarChipList
items={(cycle.gifts || []).map((gift) => ({
key: gift.giftId,
label: giftLabel(gift.giftId, giftOptions),
}))}
/>
),
},
{
key: "rewards",
label: "奖励资源组",
width: "minmax(240px, 0.8fr)",
render: (cycle) => (
<WeeklyStarChipList
outlined
items={(cycle.rewards || []).map((reward) => ({
key: reward.rankNo,
label: `Top${reward.rankNo}: ${groupLabel(
reward.resourceGroupId,
resourceGroupOptions,
)}`,
}))}
/>
),
},
{
key: "status",
label: "状态",
width: "112px",
render: (cycle) => (
<Chip color={statusColor(cycle.status)} label={statusLabel(cycle.status)} size="small" />
),
},
{
key: "actions",
label: "操作",
width: "112px",
fixed: "right",
resizable: false,
render: (cycle) => (
<WeeklyStarRowActions
cycle={cycle}
onEdit={openEdit}
onLeaderboard={openLeaderboard}
onSettlements={openSettlements}
onToggleStatus={toggleStatus}
/>
),
},
]}
emptyLabel="暂无周星周期配置"
items={cycles}
minWidth="1460px"
rowKey={(cycle) => cycle.cycleId}
/>
</AdminListBody>
</DataState>
<Drawer anchor="right" open={drawerOpen} PaperProps={{ sx: { width: 520 } }} onClose={closeDrawer}>
<Box component="form" sx={{ p: 3 }} onSubmit={submitForm}>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
<Typography variant="h6">{form.cycleId ? "编辑周星周期" : "新增周星周期"}</Typography>
<Chip label="UTC epoch ms" size="small" />
</Stack>
<Stack spacing={2.25}>
<TextField
required
label="活动标题"
value={form.title}
onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))}
/>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
label="区域 ID"
type="number"
value={form.regionId}
helperText="0 表示默认区域"
onChange={(event) =>
setForm((current) => ({ ...current, regionId: event.target.value }))
}
/>
<FormControl fullWidth>
<InputLabel>状态</InputLabel>
<Select
label="状态"
value={form.status}
<Drawer anchor="right" open={drawerOpen} onClose={closeDrawer}>
<Box className="weekly-star-config-drawer" component="form" onSubmit={submitForm}>
<div className="weekly-star-config-drawer__header">
<h2 className="weekly-star-config-drawer__title">
{form.cycleId ? "编辑周星周期" : "新增周星周期"}
</h2>
</div>
<div className="weekly-star-config-drawer__body">
<section className="weekly-star-config-section">
<div className="form-drawer__section-title">基础信息</div>
<div className="weekly-star-config-grid weekly-star-config-grid--basic">
<TextField
required
label="活动标题"
value={form.title}
onChange={(event) =>
setForm((current) => ({ ...current, status: event.target.value }))
setForm((current) => ({ ...current, title: event.target.value }))
}
>
{statusOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Stack direction="row" spacing={2}>
<TextField
fullWidth
required
label="开始时间 UTC"
placeholder="2026-06-01T00:00"
value={form.startAt}
onChange={(event) =>
setForm((current) => ({ ...current, startAt: event.target.value }))
}
/>
<TextField
fullWidth
required
label="结束时间 UTC"
placeholder="2026-06-08T00:00"
value={form.endAt}
onChange={(event) => setForm((current) => ({ ...current, endAt: event.target.value }))}
/>
</Stack>
<Divider />
<Typography fontWeight={700}>活动礼物</Typography>
{[0, 1, 2].map((index) => (
<OptionField
key={index}
label={`礼物 ${index + 1}`}
options={giftOptions}
value={form.giftIds[index]}
onChange={(value) => updateGift(index, value)}
/>
))}
<Divider />
<Typography fontWeight={700}>Top1 / Top2 / Top3 资源组</Typography>
{[0, 1, 2].map((index) => (
<OptionField
key={index}
label={`Top${index + 1} 资源组 ID`}
options={resourceGroupOptions}
value={form.resourceGroupIds[index]}
onChange={(value) => updateReward(index, value)}
/>
))}
<Stack direction="row" justifyContent="flex-end" spacing={1.5} sx={{ pt: 1 }}>
<Button disabled={saving} onClick={closeDrawer}>
取消
</Button>
<Button
disabled={saving}
startIcon={<EventAvailableOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存
</Button>
</Stack>
</Stack>
/>
<RegionSelect
required
label="区域"
loading={loadingRegions}
options={weeklyStarRegionOptions}
size="medium"
value={form.regionId}
onChange={(value) =>
setForm((current) => ({ ...current, regionId: value || "0" }))
}
/>
<FormControl>
<InputLabel>状态</InputLabel>
<Select
label="状态"
value={form.status}
onChange={(event) =>
setForm((current) => ({ ...current, status: event.target.value }))
}
>
{statusOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
</div>
<div className="weekly-star-time-range">
<TimeRangeFilter
className="weekly-star-time-range__control"
label="活动时间"
value={{ endMs: form.endMs, startMs: form.startMs }}
onChange={updateTimeRange}
/>
</div>
</section>
<section className="weekly-star-config-section">
<div className="form-drawer__section-title">活动礼物</div>
<div className="weekly-star-config-card-grid">
{[0, 1, 2].map((index) => (
<div className="weekly-star-config-card" key={index}>
<div className="weekly-star-config-card__header">
<span className="weekly-star-config-card__label">礼物 {index + 1}</span>
</div>
<GiftSelectField
drawerTitle={`选择礼物 ${index + 1}`}
gifts={gifts}
label="选择礼物"
placeholder="搜索礼物名称或 ID"
value={form.giftIds[index]}
onChange={(value) => updateGift(index, value)}
/>
</div>
))}
</div>
</section>
<section className="weekly-star-config-section">
<div className="form-drawer__section-title">排名奖励</div>
<div className="weekly-star-config-card-grid">
{[0, 1, 2].map((index) => (
<div className="weekly-star-config-card" key={index}>
<div className="weekly-star-config-card__header">
<span className={`weekly-star-rank-badge weekly-star-rank-badge--${index + 1}`}>
Top{index + 1}
</span>
</div>
<ResourceGroupSelectField
drawerTitle={`选择 Top${index + 1} 奖励资源组`}
groups={resourceGroups}
label="奖励资源组"
placeholder="搜索资源组名称或 ID"
value={form.resourceGroupIds[index]}
onChange={(value) => updateReward(index, value)}
/>
</div>
))}
</div>
</section>
</div>
<div className="weekly-star-config-drawer__actions">
<Button disabled={saving} type="button" onClick={closeDrawer}>
取消
</Button>
<Button
disabled={saving}
startIcon={<EventAvailableOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存
</Button>
</div>
</Box>
</Drawer>
<ResultDialog
columns={["排名", "用户 ID", "积分", "首次计分 UTC", "最后计分 UTC"]}
columns={["排名", "用户 ID", "积分", "首次计分时间", "最后计分时间"]}
emptyText="暂无排行榜数据"
error={leaderboard.error}
loading={leaderboard.loading}
@ -497,8 +519,8 @@ export function WeeklyStarPage() {
item.rankNo,
item.userId,
formatNumber(item.score),
formatUTC(item.firstScoredAtMs),
formatUTC(item.lastScoredAtMs),
<TimeText value={item.firstScoredAtMs} />,
<TimeText value={item.lastScoredAtMs} />,
]}
/>
<ResultDialog
@ -528,22 +550,46 @@ export function WeeklyStarPage() {
);
}
function OptionField({ label, onChange, options, value }) {
function WeeklyStarChipList({ items, outlined = false }) {
return (
<Autocomplete
freeSolo
options={options}
value={optionForValue(value, options)}
getOptionLabel={(option) => (typeof option === "string" ? option : option.label || "")}
isOptionEqualToValue={(option, selected) => option.value === selected.value}
onChange={(_, next) => onChange(typeof next === "string" ? next : next?.value || "")}
onInputChange={(_, next, reason) => {
if (reason === "input") {
onChange(next);
}
}}
renderInput={(params) => <TextField {...params} required label={label} />}
/>
<div className="weekly-star-list-chip-list">
{items.length ? (
items.map((item) => (
<Chip
className="weekly-star-list-chip"
key={item.key}
label={item.label || "-"}
size="small"
variant={outlined ? "outlined" : "filled"}
/>
))
) : (
<span className="weekly-star-list-meta">-</span>
)}
</div>
);
}
function WeeklyStarRowActions({ cycle, onEdit, onLeaderboard, onSettlements, onToggleStatus }) {
const toggleDisabled = cycle.status === "settled" || cycle.status === "settling";
const toggleLabel = cycle.status === "active" ? "停用周期" : "启用周期";
return (
<div className="weekly-star-list-actions">
<AdminRowActions>
<AdminActionIconButton label="编辑周期" onClick={() => onEdit(cycle)}>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton disabled={toggleDisabled} label={toggleLabel} onClick={() => onToggleStatus(cycle)}>
<EventAvailableOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton label="排行榜" onClick={() => onLeaderboard(cycle)}>
<BarChartOutlined fontSize="small" />
</AdminActionIconButton>
<AdminActionIconButton label="结算结果" onClick={() => onSettlements(cycle)}>
<WorkspacePremiumOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
</div>
);
}
@ -594,11 +640,6 @@ function ResultDialog({ columns, emptyText, error, loading, onClose, open, rende
);
}
function optionForValue(value, options) {
const normalized = String(value || "");
return options.find((option) => option.value === normalized) || normalized;
}
function giftLabel(giftId, options) {
return options.find((option) => option.value === String(giftId))?.label || giftId || "-";
}
@ -607,6 +648,12 @@ function groupLabel(groupId, options) {
return options.find((option) => option.value === String(groupId))?.label || groupId || "-";
}
function regionLabel(regionId, options) {
const value = String(regionId ?? "0");
const option = options.find((item) => item.value === value);
return option?.name || option?.label || (value === "0" ? "默认区域" : `区域 ${value}`);
}
function statusLabel(status) {
return statusOptions.find(([value]) => value === status)?.[1] || status || "-";
}
@ -619,38 +666,6 @@ function statusColor(status) {
return "info";
}
function parseUTCInput(value) {
const match = String(value || "").match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/);
if (!match) {
return 0;
}
return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]));
}
function formatUTCInput(value) {
const ms = Number(value || 0);
if (!ms) {
return "";
}
const date = new Date(ms);
return (
[date.getUTCFullYear(), pad(date.getUTCMonth() + 1), pad(date.getUTCDate())].join("-") +
`T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`
);
}
function formatUTC(value) {
const ms = Number(value || 0);
if (!ms) {
return "-";
}
return new Date(ms).toISOString().replace("T", " ").slice(0, 19) + " UTC";
}
function pad(value) {
return String(value).padStart(2, "0");
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}

View File

@ -332,6 +332,9 @@ export interface RechargeBillDto {
export interface RechargeBillUserDto {
avatar?: string;
countryCode?: string;
countryDisplayName?: string;
countryName?: string;
displayUserId?: string;
userId?: string;
username?: string;

View File

@ -0,0 +1,23 @@
import { useCallback } from "react";
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
export function useAdminTime() {
const { formatMillis, timeZone, timeZoneLabel } = useTimeZone();
const formatDateTime = useCallback((value) => formatMillis(value), [formatMillis]);
const formatRange = useCallback(
(startValue, endValue, separator = " - ") => {
const startText = formatMillis(startValue);
const endText = formatMillis(endValue);
return startText === "-" && endText === "-" ? "-" : `${startText}${separator}${endText}`;
},
[formatMillis]
);
return {
formatDateTime,
formatRange,
timeZone,
timeZoneLabel
};
}

View File

@ -497,7 +497,7 @@ function normalizeColumn(column) {
...column,
className,
resizable: actionColumn ? false : column.resizable,
width: actionColumn ? ACTION_COLUMN_WIDTH : normalizeColumnWidth(column)
width: actionColumn && !column.width ? ACTION_COLUMN_WIDTH : normalizeColumnWidth(column)
};
}

View File

@ -0,0 +1,62 @@
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
import IconButton from "@mui/material/IconButton";
export function RewardTierEditorTable({
columns,
disabled = false,
emptyText = "暂无档位",
getRowLabel = (_row, index) => `档位 ${index + 1}`,
gridTemplateColumns,
onRemoveTier,
rowKey,
rows = [],
}) {
const tableStyle = gridTemplateColumns ? { "--reward-tier-columns": gridTemplateColumns } : undefined;
return (
<div className="reward-tier-editor-table" role="table" style={tableStyle}>
<div className="reward-tier-editor-table__head" role="row">
{columns.map((column) => (
<span key={column.key} role="columnheader">
{column.label}
</span>
))}
<span role="columnheader">操作</span>
</div>
<div className="reward-tier-editor-table__body">
{rows.map((row, index) => {
const label = getRowLabel(row, index);
return (
<div
className="reward-tier-editor-table__row"
key={rowKey ? rowKey(row, index) : `${row.tierId || "new"}-${index}`}
role="row"
>
{columns.map((column) => (
<div
className="reward-tier-editor-table__cell"
data-label={column.label}
key={column.key}
role="cell"
>
{column.render(row, index)}
</div>
))}
<div className="reward-tier-editor-table__cell reward-tier-editor-table__action" data-label="操作" role="cell">
<IconButton
aria-label={`删除${label}`}
disabled={disabled}
size="small"
onClick={() => onRemoveTier(index)}
>
<DeleteOutlineOutlined fontSize="small" />
</IconButton>
</div>
</div>
);
})}
{!rows.length ? <div className="reward-tier-editor-table__empty">{emptyText}</div> : null}
</div>
</div>
);
}

View File

@ -1,8 +1,19 @@
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
import { useAdminTime } from "@/shared/hooks/useAdminTime.js";
export function TimeText({ className, component: Component = "span", value }) {
const { formatMillis, timeZoneLabel } = useTimeZone();
const text = formatMillis(value);
const { formatDateTime, timeZoneLabel } = useAdminTime();
const text = formatDateTime(value);
return (
<Component className={className} title={text === "-" ? undefined : timeZoneLabel}>
{text}
</Component>
);
}
export function TimeRangeText({ className, component: Component = "span", end, separator = " - ", start }) {
const { formatRange, timeZoneLabel } = useAdminTime();
const text = formatRange(start, end, separator);
return (
<Component className={className} title={text === "-" ? undefined : timeZoneLabel}>

View File

@ -0,0 +1,50 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, expect, test } from "vitest";
import { TimeZoneProvider, useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
import { TimeRangeText, TimeText } from "@/shared/ui/TimeText.jsx";
const startMs = Date.UTC(2026, 4, 9, 19, 21, 0);
const endMs = Date.UTC(2026, 4, 9, 19, 22, 0);
beforeEach(() => {
window.localStorage.clear();
});
test("renders single times and ranges using the selected admin timezone", async () => {
const user = userEvent.setup();
render(
<TimeZoneProvider>
<TimeTextFixture />
</TimeZoneProvider>
);
expect(screen.getByTestId("single-time")).toHaveTextContent("2026-05-10 03:21:00");
expect(screen.getByTestId("range-time")).toHaveTextContent("2026-05-10 03:21:00 - 2026-05-10 03:22:00");
await user.click(screen.getByRole("button", { name: "UTC" }));
expect(screen.getByTestId("single-time")).toHaveTextContent("2026-05-09 19:21:00");
expect(screen.getByTestId("single-time")).not.toHaveTextContent("UTC");
expect(screen.getByTestId("range-time")).toHaveTextContent("2026-05-09 19:21:00 - 2026-05-09 19:22:00");
expect(screen.getByTestId("range-time")).not.toHaveTextContent("UTC");
});
function TimeTextFixture() {
const { setTimeZone } = useTimeZone();
return (
<>
<button type="button" onClick={() => setTimeZone("UTC")}>
UTC
</button>
<span data-testid="single-time">
<TimeText value={startMs} />
</span>
<span data-testid="range-time">
<TimeRangeText end={endMs} start={startMs} />
</span>
</>
);
}

View File

@ -16,6 +16,7 @@ let pagModulePromise;
export function UploadField({
accept,
disabled = false,
density = "regular",
kind = "image",
label,
onChange,
@ -88,7 +89,11 @@ export function UploadField({
};
return (
<div className={[styles.root, disabled ? styles.disabled : ""].filter(Boolean).join(" ")}>
<div
className={[styles.root, density === "compact" ? styles.compact : "", disabled ? styles.disabled : ""]
.filter(Boolean)
.join(" ")}
>
<div className={styles.header}>
<span className={styles.label}>{label}</span>
<span className={styles.type}>{assetKindLabel(assetKind, isImage)}</span>

View File

@ -1,176 +1,210 @@
.root {
display: grid;
width: 100%;
gap: var(--space-2);
display: grid;
width: 100%;
gap: var(--space-2);
}
.disabled {
opacity: 0.72;
opacity: 0.72;
}
.input {
display: none;
display: none;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.label {
color: var(--text-secondary);
font-weight: 650;
color: var(--text-secondary);
font-weight: 650;
}
.type {
color: var(--text-tertiary);
font-size: var(--admin-font-size);
color: var(--text-tertiary);
font-size: var(--admin-font-size);
}
.panel {
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-input);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-input);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
}
.preview {
position: relative;
display: flex;
width: 100%;
height: 168px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 0;
border-bottom: 1px solid var(--border);
background:
linear-gradient(45deg, rgba(100, 116, 139, 0.08) 25%, transparent 25%) 0 0 / 16px 16px,
linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.08) 75%) 0 0 / 16px 16px,
linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.08) 75%) 8px 8px / 16px 16px,
linear-gradient(45deg, rgba(100, 116, 139, 0.08) 25%, transparent 25%) 8px 8px / 16px 16px,
var(--bg-card-strong);
color: var(--text-tertiary);
cursor: pointer;
position: relative;
display: flex;
width: 100%;
height: 168px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 0;
border-bottom: 1px solid var(--border);
background:
linear-gradient(45deg, rgba(100, 116, 139, 0.08) 25%, transparent 25%) 0 0 / 16px 16px,
linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.08) 75%) 0 0 / 16px 16px,
linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.08) 75%) 8px 8px / 16px 16px,
linear-gradient(45deg, rgba(100, 116, 139, 0.08) 25%, transparent 25%) 8px 8px / 16px 16px,
var(--bg-card-strong);
color: var(--text-tertiary);
cursor: pointer;
}
.preview:disabled {
cursor: not-allowed;
cursor: not-allowed;
}
.image {
width: 100%;
height: 100%;
object-fit: contain;
width: 100%;
height: 100%;
object-fit: contain;
}
.empty {
display: inline-flex;
min-width: 0;
align-items: center;
justify-content: center;
gap: var(--space-2);
color: var(--text-tertiary);
font-weight: 650;
display: inline-flex;
min-width: 0;
align-items: center;
justify-content: center;
gap: var(--space-2);
color: var(--text-tertiary);
font-weight: 650;
}
.empty svg {
width: 28px;
height: 28px;
width: 28px;
height: 28px;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.68);
color: var(--primary);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.68);
color: var(--primary);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
.player {
position: relative;
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
position: relative;
display: flex;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
}
.playerSurface {
width: 100%;
height: 100%;
width: 100%;
height: 100%;
}
.playerSurface canvas,
.pagCanvas {
width: 100% !important;
height: 100% !important;
width: 100% !important;
height: 100% !important;
}
.pagCanvas {
display: block;
display: block;
}
.footer {
display: flex;
min-height: 52px;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
display: flex;
min-height: 52px;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
}
.name {
min-width: 0;
overflow: hidden;
color: var(--text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
overflow: hidden;
color: var(--text-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
}
.action {
display: inline-flex;
height: var(--control-height);
min-width: var(--control-height);
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: 0 var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-input-strong);
color: var(--text-secondary);
cursor: pointer;
font-size: var(--admin-font-size);
line-height: var(--admin-line-height);
transition:
border-color var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard),
background var(--motion-fast) var(--ease-standard);
display: inline-flex;
height: var(--control-height);
min-width: var(--control-height);
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: 0 var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-input-strong);
color: var(--text-secondary);
cursor: pointer;
font-size: var(--admin-font-size);
line-height: var(--admin-line-height);
transition:
border-color var(--motion-fast) var(--ease-standard),
color var(--motion-fast) var(--ease-standard),
background var(--motion-fast) var(--ease-standard);
}
.action:hover:not(:disabled) {
border-color: var(--primary-border);
background: var(--primary-hover);
color: var(--primary);
border-color: var(--primary-border);
background: var(--primary-hover);
color: var(--primary);
}
.action:disabled {
cursor: not-allowed;
opacity: 0.48;
cursor: not-allowed;
opacity: 0.48;
}
.compact {
gap: var(--space-1);
}
.compact .label {
font-size: 13px;
}
.compact .type,
.compact .name,
.compact .action {
font-size: 12px;
}
.compact .preview {
height: 112px;
}
.compact .empty svg {
width: 22px;
height: 22px;
}
.compact .footer {
min-height: 40px;
padding: var(--space-1) var(--space-2);
}
.compact .action {
height: 28px;
min-width: 28px;
padding: 0 var(--space-2);
}

View File

@ -805,6 +805,31 @@
width: min(560px, 100vw);
}
.form-drawer--activity-config {
box-sizing: border-box;
width: min(880px, calc(100vw - 48px));
height: 100vh;
max-height: 100vh;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
padding: var(--space-5);
}
.form-drawer--activity-config .form-drawer__content {
display: grid;
align-content: start;
min-height: 0;
gap: var(--space-4);
overflow: auto;
padding-right: 2px;
}
.form-drawer--activity-config .form-drawer__actions {
padding-top: var(--space-3);
border-top: 1px solid var(--border);
background: var(--bg-card);
}
.role-permission-drawer {
box-sizing: border-box;
width: min(1040px, calc(100vw - 48px));
@ -880,6 +905,271 @@
padding-top: var(--space-2);
}
.reward-tier-editor-table {
display: grid;
min-width: 0;
gap: var(--space-2);
overflow-x: auto;
}
.reward-tier-editor-table__head,
.reward-tier-editor-table__row {
display: grid;
min-width: 720px;
align-items: center;
gap: var(--space-2);
grid-template-columns: var(--reward-tier-columns, 72px minmax(160px, 1fr) minmax(160px, 1fr) 120px 96px 52px);
}
.reward-tier-editor-table__head {
padding: 0 var(--space-2);
color: var(--text-tertiary);
font-size: 12px;
font-weight: 700;
line-height: 18px;
}
.reward-tier-editor-table__body {
display: grid;
gap: var(--space-2);
}
.reward-tier-editor-table__row {
padding: var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-2);
background: var(--fill-secondary);
}
.reward-tier-editor-table__cell {
min-width: 0;
}
.reward-tier-editor-table__cell > .MuiTextField-root,
.reward-tier-editor-table__cell > .MuiFormControl-root,
.reward-tier-editor-table__cell > .MuiAutocomplete-root {
width: 100%;
}
.reward-tier-editor-table__index {
color: var(--text-primary);
font-weight: 750;
white-space: nowrap;
}
.reward-tier-editor-table__action {
display: flex;
justify-content: flex-end;
}
.reward-tier-editor-table__empty {
display: flex;
min-height: 92px;
align-items: center;
justify-content: center;
border: 1px dashed var(--border-strong);
border-radius: var(--radius-2);
color: var(--text-tertiary);
}
.weekly-star-config-drawer {
box-sizing: border-box;
display: grid;
width: min(880px, calc(100vw - 48px));
height: 100vh;
max-height: 100vh;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: var(--space-4);
overflow: hidden;
padding: var(--space-5);
}
.weekly-star-config-drawer__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
.weekly-star-config-drawer__title {
margin: 0;
color: var(--text-primary);
font-size: var(--admin-font-size);
font-weight: 800;
}
.weekly-star-config-drawer__body {
display: grid;
align-content: start;
min-height: 0;
gap: var(--space-4);
overflow: auto;
padding-right: 2px;
}
.weekly-star-config-section {
display: grid;
gap: var(--space-3);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border-soft);
}
.weekly-star-config-section:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.weekly-star-config-grid {
display: grid;
gap: var(--space-3);
}
.weekly-star-config-grid--basic {
grid-template-columns: minmax(240px, 1.2fr) minmax(160px, 0.8fr) minmax(180px, 0.8fr);
}
.weekly-star-config-grid > .MuiTextField-root,
.weekly-star-config-grid > .MuiFormControl-root,
.weekly-star-config-grid > .MuiAutocomplete-root {
width: 100%;
}
.weekly-star-time-range {
display: flex;
align-items: center;
}
.weekly-star-time-range__control {
width: min(520px, 100%);
}
.weekly-star-config-card-grid {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.weekly-star-config-card {
display: grid;
min-width: 0;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-card);
background: var(--fill-secondary);
}
.weekly-star-config-card__header {
display: flex;
min-height: 22px;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.weekly-star-config-card__label {
color: var(--text-primary);
font-weight: 760;
line-height: 1;
}
.weekly-star-rank-badge {
display: inline-flex;
height: 22px;
align-items: center;
padding: 0 var(--space-2);
border: 1px solid var(--border);
border-radius: var(--radius-pill);
background: var(--bg-card);
color: var(--text-primary);
font-size: 12px;
font-weight: 760;
line-height: 1;
}
.weekly-star-rank-badge--1 {
border-color: color-mix(in srgb, #f2a800 36%, var(--border));
background: color-mix(in srgb, #f2a800 13%, var(--bg-card));
}
.weekly-star-rank-badge--2 {
border-color: color-mix(in srgb, #7d8796 32%, var(--border));
background: color-mix(in srgb, #7d8796 12%, var(--bg-card));
}
.weekly-star-rank-badge--3 {
border-color: color-mix(in srgb, #c26f30 34%, var(--border));
background: color-mix(in srgb, #c26f30 12%, var(--bg-card));
}
.weekly-star-list-stack {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.weekly-star-list-title,
.weekly-star-list-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.weekly-star-list-title {
color: var(--text-primary);
font-weight: 720;
}
.weekly-star-list-meta {
color: var(--text-tertiary);
font-size: 12px;
line-height: 18px;
}
.weekly-star-list-chip-list {
display: grid;
width: 100%;
min-width: 0;
max-height: 82px;
gap: var(--space-1);
overflow: hidden;
}
.weekly-star-list-chip.MuiChip-root {
max-width: 100%;
justify-content: flex-start;
}
.weekly-star-list-chip .MuiChip-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.weekly-star-list-actions {
display: flex;
width: 100%;
justify-content: flex-end;
}
.weekly-star-list-actions > div {
display: grid;
grid-template-columns: repeat(2, var(--control-height));
gap: var(--space-2);
justify-content: end;
}
.weekly-star-config-drawer__actions {
display: flex;
justify-content: flex-end;
gap: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border);
background: var(--bg-card);
}
.side-drawer {
box-sizing: border-box;
display: flex;
@ -1067,6 +1357,50 @@
}
}
@media (max-width: 900px) {
.form-drawer--activity-config,
.weekly-star-config-drawer {
width: 100vw;
padding: var(--space-4);
}
.reward-tier-editor-table {
overflow-x: visible;
}
.reward-tier-editor-table__head {
display: none;
}
.reward-tier-editor-table__row {
min-width: 0;
grid-template-columns: 1fr;
}
.reward-tier-editor-table__cell {
display: grid;
align-items: center;
gap: var(--space-2);
grid-template-columns: 96px minmax(0, 1fr);
}
.reward-tier-editor-table__cell::before {
color: var(--text-tertiary);
content: attr(data-label);
font-size: 12px;
font-weight: 700;
}
.reward-tier-editor-table__action {
justify-content: space-between;
}
.weekly-star-config-grid--basic,
.weekly-star-config-card-grid {
grid-template-columns: 1fr;
}
}
.batch-actions {
display: flex;
gap: var(--space-2);