交互优化

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> <strong>{item.value}</strong>
<div className={["metric-delta", item.deltaTone === "down" ? "metric-delta--down" : ""].join(" ")}> <div className={["metric-delta", item.deltaTone === "down" ? "metric-delta--down" : ""].join(" ")}>
<span>{item.caption}</span> <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>
</div> </div>
</article> </article>

View File

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

View File

@ -672,9 +672,9 @@
.business-metric-grid .metric-content { .business-metric-grid .metric-content {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) max-content; grid-template-columns: minmax(max-content, 1fr) max-content;
grid-template-rows: auto 1fr; grid-template-rows: auto 1fr;
column-gap: 12px; column-gap: 8px;
align-items: center; align-items: center;
} }
@ -707,6 +707,15 @@
.business-metric-grid .metric-label-row { .business-metric-grid .metric-label-row {
grid-column: 1; grid-column: 1;
grid-row: 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 { .business-metric-grid .metric-content strong {

View File

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

View File

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

View File

@ -8,7 +8,7 @@ export interface GamePlatformDto {
platformName: string; platformName: string;
status: string; status: string;
apiBaseUrl: 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; adapterType: string;
// callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。 // callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。
callbackSecret?: string; callbackSecret?: string;
@ -173,7 +173,7 @@ function normalizePlatform(platform: Partial<GamePlatformDto>): GamePlatformDto
platformName: stringValue(platform.platformName), platformName: stringValue(platform.platformName),
status: stringValue(platform.status || "active"), status: stringValue(platform.status || "active"),
apiBaseUrl: stringValue(platform.apiBaseUrl), apiBaseUrl: stringValue(platform.apiBaseUrl),
adapterType: stringValue(platform.adapterType || "demo"), adapterType: stringValue(platform.adapterType || "yomi_v4"),
callbackSecret: stringValue(platform.callbackSecret), callbackSecret: stringValue(platform.callbackSecret),
callbackSecretSet: Boolean(platform.callbackSecretSet), callbackSecretSet: Boolean(platform.callbackSecretSet),
callbackIpWhitelist: Array.isArray(platform.callbackIpWhitelist) callbackIpWhitelist: Array.isArray(platform.callbackIpWhitelist)

View File

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

View File

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

View File

@ -33,10 +33,10 @@ const helpText = {
poolId: "奖池 ID 是幸运礼物规则隔离键。不同奖池拥有独立 RTP、窗口和阶段奖档。", poolId: "奖池 ID 是幸运礼物规则隔离键。不同奖池拥有独立 RTP、窗口和阶段奖档。",
poolRatePercent: "每笔幸运礼物扣费进入基础奖池的比例,必须大于等于 RTP避免奖池长期亏空。", poolRatePercent: "每笔幸运礼物扣费进入基础奖池的比例,必须大于等于 RTP避免奖池长期亏空。",
userHourlyPayoutCap: "同一用户每小时基础返奖上限。达到上限后,该用户会优先进入 0x 或低倍率。", userHourlyPayoutCap: "同一用户每小时基础返奖上限。达到上限后,该用户会优先进入 0x 或低倍率。",
userDailyPayoutCap: "同一用户每日基础返奖上限。按 UTC 自然日统计。", userDailyPayoutCap: "同一用户每日基础返奖上限。按服务端自然日统计。",
deviceDailyPayoutCap: "同一设备每日基础返奖上限。用于限制多账号集中领取。", deviceDailyPayoutCap: "同一设备每日基础返奖上限。用于限制多账号集中领取。",
roomHourlyPayoutCap: "同一房间每小时基础返奖上限。房间触顶后,新用户也会被该房间上限影响。", roomHourlyPayoutCap: "同一房间每小时基础返奖上限。房间触顶后,新用户也会被该房间上限影响。",
anchorDailyPayoutCap: "同一收礼主播每日基础返奖上限。按 UTC 自然日统计。", anchorDailyPayoutCap: "同一收礼主播每日基础返奖上限。按服务端自然日统计。",
settlementWindowWager: "RTP 结算窗口按金币流水计算,不按抽数计算,低价和高价礼物会自然等比参与控制。", settlementWindowWager: "RTP 结算窗口按金币流水计算,不按抽数计算,低价和高价礼物会自然等比参与控制。",
stages: "新手、正常、高阶三个阶段必须各自配置概率,抽奖引擎按用户阶段读取对应奖档。", stages: "新手、正常、高阶三个阶段必须各自配置概率,抽奖引擎按用户阶段读取对应奖档。",
targetRtpPercent: "基础返奖目标。95 表示长期每消耗 100 金币,基础 RTP 目标返还 95 金币。", 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 { 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 { IconButton } from "@/shared/ui/IconButton.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js"; import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js"; import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
@ -30,6 +31,7 @@ const statusOptions = [
const rechargeTypeOptions = [ const rechargeTypeOptions = [
["", "全部途径"], ["", "全部途径"],
["coin_seller_transfer", "币商充值"], ["coin_seller_transfer", "币商充值"],
["google_play_recharge", "谷歌充值"],
]; ];
const columns = [ const columns = [
@ -41,46 +43,36 @@ const columns = [
}, },
{ {
key: "userId", key: "userId",
label: "用户 ID", label: "用户信息",
width: "minmax(190px, 1fr)", width: "minmax(190px, 1fr)",
render: (bill) => <BillUser bill={bill} type="user" />, render: (bill) => <BillUser bill={bill} type="user" />,
}, },
{ {
key: "sellerUserId", key: "sellerUserId",
label: "币商 ID", label: "币商信息",
width: "minmax(190px, 1fr)", width: "minmax(190px, 1fr)",
render: (bill) => <BillUser bill={bill} type="seller" />, render: (bill) => <BillUser bill={bill} type="seller" />,
}, },
{ {
key: "amount", key: "coinAmount",
label: "金币 / 金额", label: "金币",
width: "minmax(150px, 0.75fr)", width: "minmax(120px, 0.6fr)",
render: (bill) => ( render: (bill) => formatNumber(bill.coinAmount),
<Stack
primary={formatNumber(bill.coinAmount)}
secondary={formatMoney(bill.usdMinorAmount, bill.currencyCode)}
/>
),
}, },
{ {
key: "policy", key: "money",
label: "兑换口径", label: "金额",
width: "minmax(170px, 0.9fr)", width: "minmax(120px, 0.6fr)",
render: (bill) => ( render: (bill) => billMoneyText(bill),
<Stack
primary={bill.policyVersion || "-"}
secondary={`${formatNumber(bill.exchangeCoinAmount)} / ${formatMoney(bill.exchangeUsdMinorAmount, bill.currencyCode)}`}
/>
),
}, },
{ {
key: "region", key: "region",
label: "区域", label: "国家/区域",
width: "minmax(110px, 0.55fr)", width: "minmax(150px, 0.75fr)",
render: (bill, _index, context) => ( render: (bill, _index, context) => (
<Stack <Stack
primary={regionName(bill.targetRegionId, context?.regionNames)} primary={billCountryName(bill)}
secondary={bill.sellerRegionId ? `来源 ${regionName(bill.sellerRegionId, context?.regionNames)}` : "-"} secondary={regionName(bill.targetRegionId, context?.regionNames)}
/> />
), ),
}, },
@ -109,11 +101,12 @@ export function PaymentBillListPage() {
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [status, setStatus] = useState(""); const [status, setStatus] = useState("");
const [rechargeType, setRechargeType] = useState(""); const [rechargeType, setRechargeType] = useState("");
const [regionId, setRegionId] = useState("");
const [userId, setUserId] = useState(""); const [userId, setUserId] = useState("");
const [sellerUserId, setSellerUserId] = useState(""); const [sellerUserId, setSellerUserId] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" }); const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const { regionOptions } = useRegionOptions(); const { loadingRegions, regionOptions } = useRegionOptions();
const { showToast } = useToast(); const { showToast } = useToast();
const regionNames = useMemo(() => regionNameMap(regionOptions), [regionOptions]); const regionNames = useMemo(() => regionNameMap(regionOptions), [regionOptions]);
@ -122,12 +115,13 @@ export function PaymentBillListPage() {
end_at_ms: timeRange.endMs, end_at_ms: timeRange.endMs,
keyword, keyword,
recharge_type: rechargeType, recharge_type: rechargeType,
region_id: regionId,
seller_user_id: sellerUserId, seller_user_id: sellerUserId,
start_at_ms: timeRange.startMs, start_at_ms: timeRange.startMs,
status, status,
user_id: userId, 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({ const query = usePaginatedQuery({
errorMessage: "获取账单列表失败", 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") { if (column.key === "status") {
return { return {
...column, ...column,
@ -318,8 +323,8 @@ function BillUser({ bill, type }) {
{profile.avatar ? <img alt="" src={profile.avatar} /> : <PersonOutlineOutlined fontSize="small" />} {profile.avatar ? <img alt="" src={profile.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span> </span>
<span className={styles.identityText}> <span className={styles.identityText}>
<span className={styles.identityId}>{shortId}</span> <span className={styles.identityId}>{name}</span>
<span className={styles.identityName}>{name}</span> <span className={styles.identityName}>{shortId}</span>
</span> </span>
</div> </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) { function normalizeBillUser(profile, fallbackId) {
const source = profile || {}; const source = profile || {};
return { return {
avatar: source.avatar || "", 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 || "", displayUserId: source.displayUserId || source.display_user_id || "",
userId: String(source.userId || source.user_id || fallbackId || ""), userId: String(source.userId || source.user_id || fallbackId || ""),
username: source.username || source.name || "", 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 = []) { function regionNameMap(regionOptions = []) {
return Object.fromEntries( return Object.fromEntries(
regionOptions.map((region) => [String(region.regionId || region.value), region.name || region.label]), 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.displayUserId,
seller.username, seller.username,
formatNumber(bill.coinAmount), formatNumber(bill.coinAmount),
formatMoney(bill.usdMinorAmount, bill.currencyCode), billMoneyText(bill),
bill.policyVersion, billCountryName(bill),
formatNumber(bill.exchangeCoinAmount),
formatMoney(bill.exchangeUsdMinorAmount, bill.currencyCode),
regionName(bill.targetRegionId, regionNames), regionName(bill.targetRegionId, regionNames),
bill.sellerRegionId ? regionName(bill.sellerRegionId, regionNames) : "",
rechargeTypeLabel(bill.rechargeType), rechargeTypeLabel(bill.rechargeType),
statusLabel(bill.status), statusLabel(bill.status),
formatMillis(bill.createdAtMs), 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 { UploadField } from "@/shared/ui/UploadField.jsx";
import styles from "@/features/room-rocket/room-rocket.module.css"; import styles from "@/features/room-rocket/room-rocket.module.css";
const fuelSourceOptions = [ const fuelSourceOptions = [["heat_value", "礼物贡献"]];
["heat_value", "礼物贡献"],
];
const broadcastScopeOptions = [ const broadcastScopeOptions = [
["region", "区域广播"], ["region", "区域广播"],
@ -51,7 +49,11 @@ export function RoomRocketConfigEditor({
const [rewardTab, setRewardTab] = useState("inRoomRewards"); const [rewardTab, setRewardTab] = useState("inRoomRewards");
const [editorTab, setEditorTab] = useState("levels"); const [editorTab, setEditorTab] = useState("levels");
const activeLevelIndex = useMemo( 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], [activeLevel, form.levels],
); );
const level = form.levels[activeLevelIndex] || form.levels[0]; const level = form.levels[activeLevelIndex] || form.levels[0];
@ -72,7 +74,12 @@ export function RoomRocketConfigEditor({
<Button disabled={configSaving} type="button" onClick={onCancel}> <Button disabled={configSaving} type="button" onClick={onCancel}>
取消 取消
</Button> </Button>
<Button disabled={disabled} startIcon={<SaveOutlined fontSize="small" />} type="submit" variant="primary"> <Button
disabled={disabled}
startIcon={<SaveOutlined fontSize="small" />}
type="submit"
variant="primary"
>
保存 保存
</Button> </Button>
</div> </div>
@ -161,7 +168,9 @@ export function RoomRocketConfigEditor({
<div className={styles.levelDetailHeader}> <div className={styles.levelDetailHeader}>
<div className={styles.stack}> <div className={styles.stack}>
<span className={styles.title}>L{level.level}</span> <span className={styles.title}>L{level.level}</span>
<span className={styles.meta}>当前等级溢出燃料作废点火后下一等级立即开始</span> <span className={styles.meta}>
当前等级溢出燃料作废点火后下一等级立即开始
</span>
</div> </div>
<TextField <TextField
required required
@ -171,27 +180,36 @@ export function RoomRocketConfigEditor({
type="number" type="number"
value={level.fuelThreshold} value={level.fuelThreshold}
onChange={(event) => onChange={(event) =>
updateLevel(setForm, activeLevelIndex, { fuelThreshold: event.target.value }) updateLevel(setForm, activeLevelIndex, {
fuelThreshold: event.target.value,
})
} }
/> />
</div> </div>
<div className={styles.materialGrid}> <div className={styles.materialGrid}>
<UploadField <UploadField
density="compact"
disabled={disabled} disabled={disabled}
kind="image" kind="image"
label="封面图" label="封面图"
value={level.coverUrl} value={level.coverUrl}
onChange={(value) => updateLevel(setForm, activeLevelIndex, { coverUrl: value })} onChange={(value) =>
updateLevel(setForm, activeLevelIndex, { coverUrl: value })
}
/> />
<UploadField <UploadField
density="compact"
disabled={disabled} disabled={disabled}
kind="file" kind="file"
label="火箭动效" label="火箭动效"
uploadKind="file" uploadKind="file"
value={level.animationUrl} value={level.animationUrl}
onChange={(value) => updateLevel(setForm, activeLevelIndex, { animationUrl: value })} onChange={(value) =>
updateLevel(setForm, activeLevelIndex, { animationUrl: value })
}
/> />
<UploadField <UploadField
density="compact"
disabled={disabled} disabled={disabled}
kind="file" kind="file"
label="发射动效" label="发射动效"
@ -202,11 +220,14 @@ export function RoomRocketConfigEditor({
} }
/> />
<UploadField <UploadField
density="compact"
disabled={disabled} disabled={disabled}
kind="image" kind="image"
label="发射后图" label="发射后图"
value={level.launchedImageUrl} value={level.launchedImageUrl}
onChange={(value) => updateLevel(setForm, activeLevelIndex, { launchedImageUrl: value })} onChange={(value) =>
updateLevel(setForm, activeLevelIndex, { launchedImageUrl: value })
}
/> />
</div> </div>
<div className={styles.rewardPanel}> <div className={styles.rewardPanel}>
@ -251,19 +272,22 @@ export function RoomRocketConfigEditor({
} }
function LevelNavItem({ active, level, onClick }) { function LevelNavItem({ active, level, onClick }) {
const materialCount = [ const materialCount = [level.coverUrl, level.animationUrl, level.launchAnimationUrl, level.launchedImageUrl].filter(
level.coverUrl, Boolean,
level.animationUrl, ).length;
level.launchAnimationUrl,
level.launchedImageUrl,
].filter(Boolean).length;
const rewardCount = rewardTabs.filter(([key]) => (level[key] || []).length > 0).length; const rewardCount = rewardTabs.filter(([key]) => (level[key] || []).length > 0).length;
return ( 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.levelNavMain}>L{level.level}</span>
<span className={styles.levelNavMeta}>阈值 {formatNumber(level.fuelThreshold)}</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> </button>
); );
} }
@ -289,7 +313,9 @@ function RewardTable({ disabled, levelIndex, poolKey, resourceGroups, rewards, s
groups={resourceGroups} groups={resourceGroups}
label="资源组" label="资源组"
value={reward.resourceGroupId} value={reward.resourceGroupId}
onChange={(value) => updateReward(setForm, levelIndex, poolKey, rewardIndex, { resourceGroupId: value })} onChange={(value) =>
updateReward(setForm, levelIndex, poolKey, rewardIndex, { resourceGroupId: value })
}
/> />
<TextField <TextField
required required
@ -298,7 +324,9 @@ function RewardTable({ disabled, levelIndex, poolKey, resourceGroups, rewards, s
label="权重" label="权重"
type="number" type="number"
value={reward.weight} 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 <TextField
disabled={disabled} disabled={disabled}
@ -309,11 +337,14 @@ function RewardTable({ disabled, levelIndex, poolKey, resourceGroups, rewards, s
} }
/> />
<UploadField <UploadField
density="compact"
disabled={disabled} disabled={disabled}
kind="image" kind="image"
label="奖励图标" label="奖励图标"
value={reward.iconUrl} value={reward.iconUrl}
onChange={(value) => updateReward(setForm, levelIndex, poolKey, rewardIndex, { iconUrl: value })} onChange={(value) =>
updateReward(setForm, levelIndex, poolKey, rewardIndex, { iconUrl: value })
}
/> />
<Tooltip arrow title="删除奖项"> <Tooltip arrow title="删除奖项">
<span> <span>
@ -339,7 +370,12 @@ function FuelRuleEditor({ disabled, form, setForm }) {
<div className={styles.fuelRuleEditor}> <div className={styles.fuelRuleEditor}>
<div className={styles.fuelRuleToolbar}> <div className={styles.fuelRuleToolbar}>
<span className={styles.meta}>未命中特殊规则的礼物按基础燃料来源计算</span> <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> </Button>
</div> </div>
@ -365,7 +401,9 @@ function FuelRuleEditor({ disabled, form, setForm }) {
disabled={disabled} disabled={disabled}
label="礼物类型" label="礼物类型"
value={rule.giftTypeCode} value={rule.giftTypeCode}
onChange={(event) => updateFuelRule(setForm, index, { giftTypeCode: event.target.value })} onChange={(event) =>
updateFuelRule(setForm, index, { giftTypeCode: event.target.value })
}
/> />
<TextField <TextField
disabled={disabled} disabled={disabled}
@ -373,7 +411,9 @@ function FuelRuleEditor({ disabled, form, setForm }) {
label="倍率 ppm" label="倍率 ppm"
type="number" type="number"
value={rule.multiplierPpm} value={rule.multiplierPpm}
onChange={(event) => updateFuelRule(setForm, index, { multiplierPpm: event.target.value })} onChange={(event) =>
updateFuelRule(setForm, index, { multiplierPpm: event.target.value })
}
/> />
<TextField <TextField
disabled={disabled} disabled={disabled}
@ -414,7 +454,13 @@ function FuelRuleEditor({ disabled, form, setForm }) {
function SelectField({ disabled, label, onChange, options, value }) { function SelectField({ disabled, label, onChange, options, value }) {
return ( 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]) => ( {options.map(([optionValue, optionLabel]) => (
<MenuItem key={optionValue} value={optionValue}> <MenuItem key={optionValue} value={optionValue}>
{optionLabel} {optionLabel}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -332,6 +332,9 @@ export interface RechargeBillDto {
export interface RechargeBillUserDto { export interface RechargeBillUserDto {
avatar?: string; avatar?: string;
countryCode?: string;
countryDisplayName?: string;
countryName?: string;
displayUserId?: string; displayUserId?: string;
userId?: string; userId?: string;
username?: 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, ...column,
className, className,
resizable: actionColumn ? false : column.resizable, 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 }) { export function TimeText({ className, component: Component = "span", value }) {
const { formatMillis, timeZoneLabel } = useTimeZone(); const { formatDateTime, timeZoneLabel } = useAdminTime();
const text = formatMillis(value); 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 ( return (
<Component className={className} title={text === "-" ? undefined : timeZoneLabel}> <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({ export function UploadField({
accept, accept,
disabled = false, disabled = false,
density = "regular",
kind = "image", kind = "image",
label, label,
onChange, onChange,
@ -88,7 +89,11 @@ export function UploadField({
}; };
return ( 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}> <div className={styles.header}>
<span className={styles.label}>{label}</span> <span className={styles.label}>{label}</span>
<span className={styles.type}>{assetKindLabel(assetKind, isImage)}</span> <span className={styles.type}>{assetKindLabel(assetKind, isImage)}</span>

View File

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