交互优化
This commit is contained in:
parent
fd80e18aff
commit
2118a2ee4d
@ -37,7 +37,7 @@ export function MetricCard({ item, loading = false, onClick }) {
|
||||
<strong>{item.value}</strong>
|
||||
<div className={["metric-delta", item.deltaTone === "down" ? "metric-delta--down" : ""].join(" ")}>
|
||||
<span>{item.caption}</span>
|
||||
<b>{item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : ""}</b>
|
||||
<b className={item.delta ? "" : "metric-delta-empty"}>{item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : "--"}</b>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@ -139,6 +139,10 @@
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
.metric-delta .metric-delta-empty {
|
||||
color: #7f9bb7;
|
||||
}
|
||||
|
||||
.skeleton-block {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
@ -672,9 +672,9 @@
|
||||
|
||||
.business-metric-grid .metric-content {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
grid-template-columns: minmax(max-content, 1fr) max-content;
|
||||
grid-template-rows: auto 1fr;
|
||||
column-gap: 12px;
|
||||
column-gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@ -707,6 +707,15 @@
|
||||
.business-metric-grid .metric-label-row {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.business-metric-grid .metric-label-row > span {
|
||||
flex: 0 0 auto;
|
||||
overflow: visible;
|
||||
text-overflow: clip;
|
||||
}
|
||||
|
||||
.business-metric-grid .metric-content strong {
|
||||
|
||||
@ -1,20 +1,13 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { RewardTierEditorTable } from "@/shared/ui/RewardTierEditorTable.jsx";
|
||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||
import styles from "@/features/cumulative-recharge-reward/cumulative-recharge-reward.module.css";
|
||||
|
||||
const statusOptions = [
|
||||
["active", "启用"],
|
||||
["inactive", "停用"],
|
||||
];
|
||||
|
||||
export function CumulativeRechargeRewardConfigDrawer({
|
||||
abilities,
|
||||
configLoading,
|
||||
@ -35,7 +28,7 @@ export function CumulativeRechargeRewardConfigDrawer({
|
||||
...(current.tiers || []),
|
||||
{
|
||||
tierId: 0,
|
||||
tierCode: "",
|
||||
tierCode: `cumulative_recharge_${Date.now()}_${(current.tiers || []).length + 1}`,
|
||||
tierName: "",
|
||||
thresholdUsd: "",
|
||||
resourceGroupId: "",
|
||||
@ -62,101 +55,94 @@ export function CumulativeRechargeRewardConfigDrawer({
|
||||
|
||||
return (
|
||||
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
|
||||
<form className="form-drawer" onSubmit={onSubmit}>
|
||||
<form className="form-drawer form-drawer--activity-config" onSubmit={onSubmit}>
|
||||
<h2>累充奖励配置</h2>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">发放状态</div>
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
checkedLabel="开启"
|
||||
disabled={disabled}
|
||||
label="累充奖励状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||
/>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className={styles.drawerSectionTitle}>
|
||||
<span>奖励档位</span>
|
||||
<Button
|
||||
<div className="form-drawer__content">
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">发放状态</div>
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
checkedLabel="开启"
|
||||
disabled={disabled}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
type="button"
|
||||
onClick={addTier}
|
||||
>
|
||||
添加档位
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.tierEditorList}>
|
||||
{(form.tiers || []).map((tier, index) => (
|
||||
<div className={styles.tierEditor} key={`${tier.tierId || "new"}-${index}`}>
|
||||
<div className={styles.tierEditorHeader}>
|
||||
<span>档位 {index + 1}</span>
|
||||
<IconButton
|
||||
aria-label="删除档位"
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
onClick={() => removeTier(index)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="form-drawer__grid">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="档位编码"
|
||||
value={tier.tierCode}
|
||||
onChange={(event) => updateTier(index, { tierCode: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="档位名称"
|
||||
value={tier.tierName}
|
||||
onChange={(event) => updateTier(index, { tierName: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 0.01, step: 0.01 }}
|
||||
label="充值金额 USD"
|
||||
type="number"
|
||||
value={tier.thresholdUsd}
|
||||
onChange={(event) => updateTier(index, { thresholdUsd: event.target.value })}
|
||||
/>
|
||||
<ResourceGroupSelectField
|
||||
disabled={disabled}
|
||||
drawerTitle="选择累充奖励资源组"
|
||||
groups={resourceGroups}
|
||||
label="奖励资源组"
|
||||
value={tier.resourceGroupId}
|
||||
onChange={(value) => updateTier(index, { resourceGroupId: value })}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
disabled={disabled}
|
||||
label="状态"
|
||||
value={tier.status}
|
||||
onChange={(event) => updateTier(index, { status: event.target.value })}
|
||||
>
|
||||
{statusOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 0 }}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={tier.sortOrder}
|
||||
onChange={(event) => updateTier(index, { sortOrder: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!form.tiers?.length ? <div className={styles.emptyState}>暂无档位</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
label="累充奖励状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||
/>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className={styles.drawerSectionTitle}>
|
||||
<span>奖励档位</span>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
type="button"
|
||||
onClick={addTier}
|
||||
>
|
||||
添加档位
|
||||
</Button>
|
||||
</div>
|
||||
<RewardTierEditorTable
|
||||
columns={[
|
||||
{
|
||||
key: "tier",
|
||||
label: "档位",
|
||||
render: (_tier, index) => (
|
||||
<span className="reward-tier-editor-table__index">档位 {index + 1}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "thresholdUsd",
|
||||
label: "累充 USD",
|
||||
render: (tier, index) => (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ "aria-label": "累充 USD", min: 0.01, step: 0.01 }}
|
||||
placeholder="100.00"
|
||||
type="number"
|
||||
value={tier.thresholdUsd}
|
||||
onChange={(event) => updateTier(index, { thresholdUsd: event.target.value })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "resourceGroupId",
|
||||
label: "奖励资源组",
|
||||
render: (tier, index) => (
|
||||
<ResourceGroupSelectField
|
||||
disabled={disabled}
|
||||
drawerTitle="选择累充奖励资源组"
|
||||
groups={resourceGroups}
|
||||
label="奖励资源组"
|
||||
value={tier.resourceGroupId}
|
||||
onChange={(value) => updateTier(index, { resourceGroupId: value })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (tier, index) => (
|
||||
<AdminSwitch
|
||||
checked={tier.status !== "inactive"}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label={`档位 ${index + 1} 状态`}
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) =>
|
||||
updateTier(index, { status: event.target.checked ? "active" : "inactive" })
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
disabled={disabled}
|
||||
emptyText="暂无档位"
|
||||
gridTemplateColumns="72px minmax(140px, 0.85fr) minmax(260px, 1.5fr) 120px 52px"
|
||||
rows={form.tiers || []}
|
||||
onRemoveTier={removeTier}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<div className="form-drawer__actions">
|
||||
<Button disabled={configSaving} type="button" onClick={onClose}>
|
||||
取消
|
||||
|
||||
@ -158,26 +158,26 @@ function payloadFromForm(form) {
|
||||
const tiers = (form.tiers || []).map((tier, index) => {
|
||||
const thresholdUsdMinor = Math.round(Number(tier.thresholdUsd || 0) * 100);
|
||||
const resourceGroupId = Number(tier.resourceGroupId || 0);
|
||||
if (!tier.tierCode.trim()) {
|
||||
throw new Error("档位编码不能为空");
|
||||
}
|
||||
if (!tier.tierName.trim()) {
|
||||
throw new Error("档位名称不能为空");
|
||||
}
|
||||
if (thresholdUsdMinor <= 0) {
|
||||
throw new Error("充值 USD 金额必须大于 0");
|
||||
}
|
||||
if (resourceGroupId <= 0) {
|
||||
throw new Error("请选择资源组");
|
||||
}
|
||||
const existingTier = Number(tier.tierId || 0) > 0;
|
||||
const tierCode = String(tier.tierCode || "").trim() || `cumulative_recharge_${Date.now()}_${index + 1}`;
|
||||
const tierName =
|
||||
existingTier && String(tier.tierName || "").trim()
|
||||
? String(tier.tierName).trim()
|
||||
: `累充 ${(thresholdUsdMinor / 100).toFixed(2)} USD`;
|
||||
return {
|
||||
tier_id: Number(tier.tierId || 0),
|
||||
tier_code: tier.tierCode.trim(),
|
||||
tier_name: tier.tierName.trim(),
|
||||
tier_code: tierCode,
|
||||
tier_name: tierName,
|
||||
threshold_usd_minor: thresholdUsdMinor,
|
||||
resource_group_id: resourceGroupId,
|
||||
status: tier.status === "inactive" ? "inactive" : "active",
|
||||
sort_order: Number(tier.sortOrder || index),
|
||||
sort_order: index,
|
||||
};
|
||||
});
|
||||
if (form.enabled && tiers.filter((tier) => tier.status === "active").length === 0) {
|
||||
|
||||
@ -8,7 +8,7 @@ export interface GamePlatformDto {
|
||||
platformName: string;
|
||||
status: string;
|
||||
apiBaseUrl: string;
|
||||
// 服务端按 adapterType 选择厂商协议:demo/yomi_v4/leadercc_v1/zeeone_v1/baishun_v1/vivagames_v1。
|
||||
// 服务端按 adapterType 选择真实厂商协议:yomi_v4/leadercc_v1/zeeone_v1/baishun_v1/vivagames_v1。
|
||||
adapterType: string;
|
||||
// callbackSecret 由后台列表返回,用于配置页直接核对和轮换厂商 key。
|
||||
callbackSecret?: string;
|
||||
@ -173,7 +173,7 @@ function normalizePlatform(platform: Partial<GamePlatformDto>): GamePlatformDto
|
||||
platformName: stringValue(platform.platformName),
|
||||
status: stringValue(platform.status || "active"),
|
||||
apiBaseUrl: stringValue(platform.apiBaseUrl),
|
||||
adapterType: stringValue(platform.adapterType || "demo"),
|
||||
adapterType: stringValue(platform.adapterType || "yomi_v4"),
|
||||
callbackSecret: stringValue(platform.callbackSecret),
|
||||
callbackSecretSet: Boolean(platform.callbackSecretSet),
|
||||
callbackIpWhitelist: Array.isArray(platform.callbackIpWhitelist)
|
||||
|
||||
@ -37,8 +37,8 @@ const defaultPlatformForm = {
|
||||
platformName: "",
|
||||
status: "active",
|
||||
apiBaseUrl: "",
|
||||
// 默认 demo 兼容本地调试;真实厂商在后台切到 yomi_v4/leadercc_v1/zeeone_v1/baishun_v1/vivagames_v1。
|
||||
adapterType: "demo",
|
||||
// 新建平台必须选择真实厂商协议,避免把线上配置误落成 demo。
|
||||
adapterType: "yomi_v4",
|
||||
// 后台现在会回显厂商 key,编辑弹窗直接显示当前值;提交空值仍代表“不覆盖旧值”。
|
||||
callbackSecret: "",
|
||||
callbackSecretSet: false,
|
||||
@ -542,7 +542,7 @@ function platformToForm(platform) {
|
||||
platformName: platform.platformName || "",
|
||||
status: platform.status || "active",
|
||||
apiBaseUrl: platform.apiBaseUrl || "",
|
||||
adapterType: platform.adapterType || "demo",
|
||||
adapterType: platform.adapterType || "yomi_v4",
|
||||
// 使用后端返回的当前密钥,方便运营直接核对;用户清空提交时后端会保留旧值。
|
||||
callbackSecret: platform.callbackSecret || "",
|
||||
callbackSecretSet: Boolean(platform.callbackSecretSet),
|
||||
@ -589,7 +589,7 @@ function platformPayload(form) {
|
||||
platformName: form.platformName.trim(),
|
||||
status: form.status.trim() || "active",
|
||||
apiBaseUrl: isLeaderCC ? "" : form.apiBaseUrl.trim(),
|
||||
adapterType: form.adapterType.trim() || "demo",
|
||||
adapterType: form.adapterType.trim() || "yomi_v4",
|
||||
// 空密钥提交给后端后会保留旧值;非空才覆盖,用于安全轮换 key/AppSecret。
|
||||
callbackSecret: form.callbackSecret.trim(),
|
||||
// 支持换行或逗号分隔,方便直接粘贴厂商给的多 IP 白名单。
|
||||
|
||||
@ -44,7 +44,6 @@ const launchModeOptions = [
|
||||
];
|
||||
// 这里的值必须和 game-service 的 adapter_type 白名单一致;新增厂商先后端登记,再放到后台可选项。
|
||||
const adapterTypeOptions = [
|
||||
["demo", "Demo"],
|
||||
["yomi_v4", "小游 Yomi V4"],
|
||||
["leadercc_v1", "灵仙 LeaderCC V1"],
|
||||
["zeeone_v1", "ZeeOne V1"],
|
||||
@ -543,7 +542,7 @@ function PlatformManageDialog({ open, page, onClose }) {
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.name}>{platform.platformName || platform.platformCode}</span>
|
||||
<span className={styles.meta}>
|
||||
{platform.platformCode} / {platform.adapterType || "demo"}
|
||||
{platform.platformCode} / {platform.adapterType || "未配置"}
|
||||
</span>
|
||||
{platform.apiBaseUrl ? <span className={styles.meta}>{platform.apiBaseUrl}</span> : null}
|
||||
<span className={`${styles.meta} ${styles.secretMeta}`}>
|
||||
|
||||
@ -33,10 +33,10 @@ const helpText = {
|
||||
poolId: "奖池 ID 是幸运礼物规则隔离键。不同奖池拥有独立 RTP、窗口和阶段奖档。",
|
||||
poolRatePercent: "每笔幸运礼物扣费进入基础奖池的比例,必须大于等于 RTP,避免奖池长期亏空。",
|
||||
userHourlyPayoutCap: "同一用户每小时基础返奖上限。达到上限后,该用户会优先进入 0x 或低倍率。",
|
||||
userDailyPayoutCap: "同一用户每日基础返奖上限。按 UTC 自然日统计。",
|
||||
userDailyPayoutCap: "同一用户每日基础返奖上限。按服务端自然日统计。",
|
||||
deviceDailyPayoutCap: "同一设备每日基础返奖上限。用于限制多账号集中领取。",
|
||||
roomHourlyPayoutCap: "同一房间每小时基础返奖上限。房间触顶后,新用户也会被该房间上限影响。",
|
||||
anchorDailyPayoutCap: "同一收礼主播每日基础返奖上限。按 UTC 自然日统计。",
|
||||
anchorDailyPayoutCap: "同一收礼主播每日基础返奖上限。按服务端自然日统计。",
|
||||
settlementWindowWager: "RTP 结算窗口按金币流水计算,不按抽数计算,低价和高价礼物会自然等比参与控制。",
|
||||
stages: "新手、正常、高阶三个阶段必须各自配置概率,抽奖引擎按用户阶段读取对应奖档。",
|
||||
targetRtpPercent: "基础返奖目标。95 表示长期每消耗 100 金币,基础 RTP 目标返还 95 金币。",
|
||||
|
||||
@ -13,6 +13,7 @@ import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { IconButton } from "@/shared/ui/IconButton.jsx";
|
||||
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
|
||||
@ -30,6 +31,7 @@ const statusOptions = [
|
||||
const rechargeTypeOptions = [
|
||||
["", "全部途径"],
|
||||
["coin_seller_transfer", "币商充值"],
|
||||
["google_play_recharge", "谷歌充值"],
|
||||
];
|
||||
|
||||
const columns = [
|
||||
@ -41,46 +43,36 @@ const columns = [
|
||||
},
|
||||
{
|
||||
key: "userId",
|
||||
label: "用户 ID",
|
||||
label: "用户信息",
|
||||
width: "minmax(190px, 1fr)",
|
||||
render: (bill) => <BillUser bill={bill} type="user" />,
|
||||
},
|
||||
{
|
||||
key: "sellerUserId",
|
||||
label: "币商 ID",
|
||||
label: "币商信息",
|
||||
width: "minmax(190px, 1fr)",
|
||||
render: (bill) => <BillUser bill={bill} type="seller" />,
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
label: "金币 / 金额",
|
||||
width: "minmax(150px, 0.75fr)",
|
||||
render: (bill) => (
|
||||
<Stack
|
||||
primary={formatNumber(bill.coinAmount)}
|
||||
secondary={formatMoney(bill.usdMinorAmount, bill.currencyCode)}
|
||||
/>
|
||||
),
|
||||
key: "coinAmount",
|
||||
label: "金币",
|
||||
width: "minmax(120px, 0.6fr)",
|
||||
render: (bill) => formatNumber(bill.coinAmount),
|
||||
},
|
||||
{
|
||||
key: "policy",
|
||||
label: "兑换口径",
|
||||
width: "minmax(170px, 0.9fr)",
|
||||
render: (bill) => (
|
||||
<Stack
|
||||
primary={bill.policyVersion || "-"}
|
||||
secondary={`${formatNumber(bill.exchangeCoinAmount)} / ${formatMoney(bill.exchangeUsdMinorAmount, bill.currencyCode)}`}
|
||||
/>
|
||||
),
|
||||
key: "money",
|
||||
label: "金额",
|
||||
width: "minmax(120px, 0.6fr)",
|
||||
render: (bill) => billMoneyText(bill),
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
label: "区域",
|
||||
width: "minmax(110px, 0.55fr)",
|
||||
label: "国家/区域",
|
||||
width: "minmax(150px, 0.75fr)",
|
||||
render: (bill, _index, context) => (
|
||||
<Stack
|
||||
primary={regionName(bill.targetRegionId, context?.regionNames)}
|
||||
secondary={bill.sellerRegionId ? `来源 ${regionName(bill.sellerRegionId, context?.regionNames)}` : "-"}
|
||||
primary={billCountryName(bill)}
|
||||
secondary={regionName(bill.targetRegionId, context?.regionNames)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
@ -109,11 +101,12 @@ export function PaymentBillListPage() {
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [rechargeType, setRechargeType] = useState("");
|
||||
const [regionId, setRegionId] = useState("");
|
||||
const [userId, setUserId] = useState("");
|
||||
const [sellerUserId, setSellerUserId] = useState("");
|
||||
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const { regionOptions } = useRegionOptions();
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
const { showToast } = useToast();
|
||||
const regionNames = useMemo(() => regionNameMap(regionOptions), [regionOptions]);
|
||||
|
||||
@ -122,12 +115,13 @@ export function PaymentBillListPage() {
|
||||
end_at_ms: timeRange.endMs,
|
||||
keyword,
|
||||
recharge_type: rechargeType,
|
||||
region_id: regionId,
|
||||
seller_user_id: sellerUserId,
|
||||
start_at_ms: timeRange.startMs,
|
||||
status,
|
||||
user_id: userId,
|
||||
}),
|
||||
[keyword, rechargeType, sellerUserId, status, timeRange.endMs, timeRange.startMs, userId],
|
||||
[keyword, rechargeType, regionId, sellerUserId, status, timeRange.endMs, timeRange.startMs, userId],
|
||||
);
|
||||
const query = usePaginatedQuery({
|
||||
errorMessage: "获取账单列表失败",
|
||||
@ -204,6 +198,17 @@ export function PaymentBillListPage() {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "region") {
|
||||
return {
|
||||
...column,
|
||||
filter: createRegionColumnFilter({
|
||||
loading: loadingRegions,
|
||||
options: regionOptions,
|
||||
value: regionId,
|
||||
onChange: resetPage(setRegionId),
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (column.key === "status") {
|
||||
return {
|
||||
...column,
|
||||
@ -318,8 +323,8 @@ function BillUser({ bill, type }) {
|
||||
{profile.avatar ? <img alt="" src={profile.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<span className={styles.identityText}>
|
||||
<span className={styles.identityId}>{shortId}</span>
|
||||
<span className={styles.identityName}>{name}</span>
|
||||
<span className={styles.identityId}>{name}</span>
|
||||
<span className={styles.identityName}>{shortId}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@ -366,16 +371,31 @@ function formatMoney(value, currency = "USD") {
|
||||
})}`;
|
||||
}
|
||||
|
||||
function billMoneyText(bill) {
|
||||
if (bill?.rechargeType === "coin_seller_transfer") {
|
||||
return "";
|
||||
}
|
||||
return formatMoney(bill?.usdMinorAmount, bill?.currencyCode);
|
||||
}
|
||||
|
||||
function normalizeBillUser(profile, fallbackId) {
|
||||
const source = profile || {};
|
||||
return {
|
||||
avatar: source.avatar || "",
|
||||
countryCode: source.countryCode || source.country_code || "",
|
||||
countryDisplayName: source.countryDisplayName || source.country_display_name || "",
|
||||
countryName: source.countryName || source.country_name || "",
|
||||
displayUserId: source.displayUserId || source.display_user_id || "",
|
||||
userId: String(source.userId || source.user_id || fallbackId || ""),
|
||||
username: source.username || source.name || "",
|
||||
};
|
||||
}
|
||||
|
||||
function billCountryName(bill) {
|
||||
const user = normalizeBillUser(bill?.user, bill?.userId);
|
||||
return user.countryDisplayName || user.countryName || user.countryCode || "-";
|
||||
}
|
||||
|
||||
function regionNameMap(regionOptions = []) {
|
||||
return Object.fromEntries(
|
||||
regionOptions.map((region) => [String(region.regionId || region.value), region.name || region.label]),
|
||||
@ -418,11 +438,8 @@ function createBillsCsv(bills, regionNames) {
|
||||
"币商名称",
|
||||
"金币",
|
||||
"金额",
|
||||
"兑换政策",
|
||||
"兑换金币",
|
||||
"兑换金额",
|
||||
"国家",
|
||||
"区域",
|
||||
"来源区域",
|
||||
"充值途径",
|
||||
"状态",
|
||||
"创建时间",
|
||||
@ -441,12 +458,9 @@ function createBillsCsv(bills, regionNames) {
|
||||
seller.displayUserId,
|
||||
seller.username,
|
||||
formatNumber(bill.coinAmount),
|
||||
formatMoney(bill.usdMinorAmount, bill.currencyCode),
|
||||
bill.policyVersion,
|
||||
formatNumber(bill.exchangeCoinAmount),
|
||||
formatMoney(bill.exchangeUsdMinorAmount, bill.currencyCode),
|
||||
billMoneyText(bill),
|
||||
billCountryName(bill),
|
||||
regionName(bill.targetRegionId, regionNames),
|
||||
bill.sellerRegionId ? regionName(bill.sellerRegionId, regionNames) : "",
|
||||
rechargeTypeLabel(bill.rechargeType),
|
||||
statusLabel(bill.status),
|
||||
formatMillis(bill.createdAtMs),
|
||||
|
||||
123
src/features/payment/pages/PaymentBillListPage.test.jsx
Normal file
123
src/features/payment/pages/PaymentBillListPage.test.jsx
Normal 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,
|
||||
};
|
||||
}
|
||||
153
src/features/resources/components/GiftSelectDrawer.jsx
Normal file
153
src/features/resources/components/GiftSelectDrawer.jsx
Normal 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");
|
||||
}
|
||||
@ -15,9 +15,7 @@ import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.
|
||||
import { UploadField } from "@/shared/ui/UploadField.jsx";
|
||||
import styles from "@/features/room-rocket/room-rocket.module.css";
|
||||
|
||||
const fuelSourceOptions = [
|
||||
["heat_value", "礼物贡献"],
|
||||
];
|
||||
const fuelSourceOptions = [["heat_value", "礼物贡献"]];
|
||||
|
||||
const broadcastScopeOptions = [
|
||||
["region", "区域广播"],
|
||||
@ -51,7 +49,11 @@ export function RoomRocketConfigEditor({
|
||||
const [rewardTab, setRewardTab] = useState("inRoomRewards");
|
||||
const [editorTab, setEditorTab] = useState("levels");
|
||||
const activeLevelIndex = useMemo(
|
||||
() => Math.max(0, form.levels.findIndex((level) => Number(level.level) === Number(activeLevel))),
|
||||
() =>
|
||||
Math.max(
|
||||
0,
|
||||
form.levels.findIndex((level) => Number(level.level) === Number(activeLevel)),
|
||||
),
|
||||
[activeLevel, form.levels],
|
||||
);
|
||||
const level = form.levels[activeLevelIndex] || form.levels[0];
|
||||
@ -72,7 +74,12 @@ export function RoomRocketConfigEditor({
|
||||
<Button disabled={configSaving} type="button" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={disabled} startIcon={<SaveOutlined fontSize="small" />} type="submit" variant="primary">
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={<SaveOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
@ -161,7 +168,9 @@ export function RoomRocketConfigEditor({
|
||||
<div className={styles.levelDetailHeader}>
|
||||
<div className={styles.stack}>
|
||||
<span className={styles.title}>L{level.level}</span>
|
||||
<span className={styles.meta}>当前等级溢出燃料作废,点火后下一等级立即开始</span>
|
||||
<span className={styles.meta}>
|
||||
当前等级溢出燃料作废,点火后下一等级立即开始
|
||||
</span>
|
||||
</div>
|
||||
<TextField
|
||||
required
|
||||
@ -171,27 +180,36 @@ export function RoomRocketConfigEditor({
|
||||
type="number"
|
||||
value={level.fuelThreshold}
|
||||
onChange={(event) =>
|
||||
updateLevel(setForm, activeLevelIndex, { fuelThreshold: event.target.value })
|
||||
updateLevel(setForm, activeLevelIndex, {
|
||||
fuelThreshold: event.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.materialGrid}>
|
||||
<UploadField
|
||||
density="compact"
|
||||
disabled={disabled}
|
||||
kind="image"
|
||||
label="封面图"
|
||||
value={level.coverUrl}
|
||||
onChange={(value) => updateLevel(setForm, activeLevelIndex, { coverUrl: value })}
|
||||
onChange={(value) =>
|
||||
updateLevel(setForm, activeLevelIndex, { coverUrl: value })
|
||||
}
|
||||
/>
|
||||
<UploadField
|
||||
density="compact"
|
||||
disabled={disabled}
|
||||
kind="file"
|
||||
label="火箭动效"
|
||||
uploadKind="file"
|
||||
value={level.animationUrl}
|
||||
onChange={(value) => updateLevel(setForm, activeLevelIndex, { animationUrl: value })}
|
||||
onChange={(value) =>
|
||||
updateLevel(setForm, activeLevelIndex, { animationUrl: value })
|
||||
}
|
||||
/>
|
||||
<UploadField
|
||||
density="compact"
|
||||
disabled={disabled}
|
||||
kind="file"
|
||||
label="发射动效"
|
||||
@ -202,11 +220,14 @@ export function RoomRocketConfigEditor({
|
||||
}
|
||||
/>
|
||||
<UploadField
|
||||
density="compact"
|
||||
disabled={disabled}
|
||||
kind="image"
|
||||
label="发射后图"
|
||||
value={level.launchedImageUrl}
|
||||
onChange={(value) => updateLevel(setForm, activeLevelIndex, { launchedImageUrl: value })}
|
||||
onChange={(value) =>
|
||||
updateLevel(setForm, activeLevelIndex, { launchedImageUrl: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.rewardPanel}>
|
||||
@ -251,19 +272,22 @@ export function RoomRocketConfigEditor({
|
||||
}
|
||||
|
||||
function LevelNavItem({ active, level, onClick }) {
|
||||
const materialCount = [
|
||||
level.coverUrl,
|
||||
level.animationUrl,
|
||||
level.launchAnimationUrl,
|
||||
level.launchedImageUrl,
|
||||
].filter(Boolean).length;
|
||||
const materialCount = [level.coverUrl, level.animationUrl, level.launchAnimationUrl, level.launchedImageUrl].filter(
|
||||
Boolean,
|
||||
).length;
|
||||
const rewardCount = rewardTabs.filter(([key]) => (level[key] || []).length > 0).length;
|
||||
|
||||
return (
|
||||
<button className={[styles.levelNavItem, active ? styles.levelNavItemActive : ""].join(" ")} type="button" onClick={onClick}>
|
||||
<button
|
||||
className={[styles.levelNavItem, active ? styles.levelNavItemActive : ""].join(" ")}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className={styles.levelNavMain}>L{level.level}</span>
|
||||
<span className={styles.levelNavMeta}>阈值 {formatNumber(level.fuelThreshold)}</span>
|
||||
<span className={styles.levelNavMeta}>物料 {materialCount}/4 · 奖励 {rewardCount}/3</span>
|
||||
<span className={styles.levelNavMeta}>
|
||||
物料 {materialCount}/4 · 奖励 {rewardCount}/3
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@ -289,7 +313,9 @@ function RewardTable({ disabled, levelIndex, poolKey, resourceGroups, rewards, s
|
||||
groups={resourceGroups}
|
||||
label="资源组"
|
||||
value={reward.resourceGroupId}
|
||||
onChange={(value) => updateReward(setForm, levelIndex, poolKey, rewardIndex, { resourceGroupId: value })}
|
||||
onChange={(value) =>
|
||||
updateReward(setForm, levelIndex, poolKey, rewardIndex, { resourceGroupId: value })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
required
|
||||
@ -298,7 +324,9 @@ function RewardTable({ disabled, levelIndex, poolKey, resourceGroups, rewards, s
|
||||
label="权重"
|
||||
type="number"
|
||||
value={reward.weight}
|
||||
onChange={(event) => updateReward(setForm, levelIndex, poolKey, rewardIndex, { weight: event.target.value })}
|
||||
onChange={(event) =>
|
||||
updateReward(setForm, levelIndex, poolKey, rewardIndex, { weight: event.target.value })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
@ -309,11 +337,14 @@ function RewardTable({ disabled, levelIndex, poolKey, resourceGroups, rewards, s
|
||||
}
|
||||
/>
|
||||
<UploadField
|
||||
density="compact"
|
||||
disabled={disabled}
|
||||
kind="image"
|
||||
label="奖励图标"
|
||||
value={reward.iconUrl}
|
||||
onChange={(value) => updateReward(setForm, levelIndex, poolKey, rewardIndex, { iconUrl: value })}
|
||||
onChange={(value) =>
|
||||
updateReward(setForm, levelIndex, poolKey, rewardIndex, { iconUrl: value })
|
||||
}
|
||||
/>
|
||||
<Tooltip arrow title="删除奖项">
|
||||
<span>
|
||||
@ -339,7 +370,12 @@ function FuelRuleEditor({ disabled, form, setForm }) {
|
||||
<div className={styles.fuelRuleEditor}>
|
||||
<div className={styles.fuelRuleToolbar}>
|
||||
<span className={styles.meta}>未命中特殊规则的礼物按基础燃料来源计算</span>
|
||||
<Button disabled={disabled} startIcon={<AddOutlined fontSize="small" />} type="button" onClick={() => addFuelRule(setForm)}>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
type="button"
|
||||
onClick={() => addFuelRule(setForm)}
|
||||
>
|
||||
添加规则
|
||||
</Button>
|
||||
</div>
|
||||
@ -365,7 +401,9 @@ function FuelRuleEditor({ disabled, form, setForm }) {
|
||||
disabled={disabled}
|
||||
label="礼物类型"
|
||||
value={rule.giftTypeCode}
|
||||
onChange={(event) => updateFuelRule(setForm, index, { giftTypeCode: event.target.value })}
|
||||
onChange={(event) =>
|
||||
updateFuelRule(setForm, index, { giftTypeCode: event.target.value })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
@ -373,7 +411,9 @@ function FuelRuleEditor({ disabled, form, setForm }) {
|
||||
label="倍率 ppm"
|
||||
type="number"
|
||||
value={rule.multiplierPpm}
|
||||
onChange={(event) => updateFuelRule(setForm, index, { multiplierPpm: event.target.value })}
|
||||
onChange={(event) =>
|
||||
updateFuelRule(setForm, index, { multiplierPpm: event.target.value })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
@ -414,7 +454,13 @@ function FuelRuleEditor({ disabled, form, setForm }) {
|
||||
|
||||
function SelectField({ disabled, label, onChange, options, value }) {
|
||||
return (
|
||||
<TextField select disabled={disabled} label={label} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
<TextField
|
||||
select
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{options.map(([optionValue, optionLabel]) => (
|
||||
<MenuItem key={optionValue} value={optionValue}>
|
||||
{optionLabel}
|
||||
|
||||
@ -111,7 +111,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 28px;
|
||||
padding: 10px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
@ -141,11 +141,27 @@
|
||||
.editorSection {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 18px;
|
||||
padding: 22px 28px;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.editorPage :global(.MuiTextField-root),
|
||||
.editorPage :global(.MuiFormControl-root) {
|
||||
--admin-control-height: 30px;
|
||||
--admin-control-font-size: 13px;
|
||||
}
|
||||
|
||||
.editorPage :global(.MuiTabs-root) {
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.editorPage :global(.MuiTab-root) {
|
||||
min-height: 36px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
@ -155,44 +171,45 @@
|
||||
.basicGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(180px, 1fr));
|
||||
gap: 14px;
|
||||
gap: 10px 12px;
|
||||
}
|
||||
|
||||
.switchField {
|
||||
display: flex;
|
||||
min-height: var(--control-height);
|
||||
min-height: 30px;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 12px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.levelWorkspace {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
gap: 22px;
|
||||
grid-template-columns: 188px minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.levelNav {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
top: 12px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.levelNavItem {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
gap: 2px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
@ -208,7 +225,7 @@
|
||||
|
||||
.levelNavMain {
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@ -223,26 +240,26 @@
|
||||
.levelDetail {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.levelDetailHeader {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(220px, 280px);
|
||||
gap: 18px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(200px, 240px);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.materialGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(170px, 1fr));
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rewardPanel {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rewardPanelHead,
|
||||
@ -251,7 +268,7 @@
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.rewardTable,
|
||||
@ -259,15 +276,15 @@
|
||||
.fuelRuleTable {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rewardTableHead,
|
||||
.rewardTableRow {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(220px, 1.2fr) 120px minmax(180px, 1fr) minmax(220px, 1fr) var(--control-height);
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(200px, 1.2fr) 96px minmax(150px, 1fr) minmax(170px, 1fr) var(--control-height);
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@ -281,7 +298,7 @@
|
||||
|
||||
.rewardTableRow,
|
||||
.fuelRuleTableRow {
|
||||
padding: 12px 0;
|
||||
padding: 8px 0;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
@ -290,7 +307,7 @@
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
grid-template-columns: minmax(140px, 1fr) minmax(140px, 1fr) 130px 130px 120px var(--control-height);
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
|
||||
@ -1,19 +1,12 @@
|
||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||
import SaveOutlined from "@mui/icons-material/SaveOutlined";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { RewardTierEditorTable } from "@/shared/ui/RewardTierEditorTable.jsx";
|
||||
import styles from "@/features/room-turnover-reward/room-turnover-reward.module.css";
|
||||
|
||||
const statusOptions = [
|
||||
["active", "启用"],
|
||||
["inactive", "停用"],
|
||||
];
|
||||
|
||||
export function RoomTurnoverRewardConfigDrawer({
|
||||
abilities,
|
||||
configLoading,
|
||||
@ -33,7 +26,7 @@ export function RoomTurnoverRewardConfigDrawer({
|
||||
...(current.tiers || []),
|
||||
{
|
||||
tierId: 0,
|
||||
tierCode: "",
|
||||
tierCode: `room_turnover_${Date.now()}_${(current.tiers || []).length + 1}`,
|
||||
tierName: "",
|
||||
thresholdCoinSpent: "",
|
||||
rewardCoinAmount: "",
|
||||
@ -60,105 +53,98 @@ export function RoomTurnoverRewardConfigDrawer({
|
||||
|
||||
return (
|
||||
<Drawer anchor="right" open={open} onClose={configSaving ? undefined : onClose}>
|
||||
<form className="form-drawer" onSubmit={onSubmit}>
|
||||
<form className="form-drawer form-drawer--activity-config" onSubmit={onSubmit}>
|
||||
<h2>房间流水奖励配置</h2>
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">结算状态</div>
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
checkedLabel="开启"
|
||||
disabled={disabled}
|
||||
label="房间流水奖励状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||
/>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className={styles.drawerSectionTitle}>
|
||||
<span>奖励档位</span>
|
||||
<Button
|
||||
<div className="form-drawer__content">
|
||||
<section className="form-drawer__section">
|
||||
<div className="form-drawer__section-title">结算状态</div>
|
||||
<AdminSwitch
|
||||
checked={form.enabled}
|
||||
checkedLabel="开启"
|
||||
disabled={disabled}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
type="button"
|
||||
onClick={addTier}
|
||||
>
|
||||
添加档位
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.tierEditorList}>
|
||||
{(form.tiers || []).map((tier, index) => (
|
||||
<div className={styles.tierEditor} key={`${tier.tierId || "new"}-${index}`}>
|
||||
<div className={styles.tierEditorHeader}>
|
||||
<span>档位 {index + 1}</span>
|
||||
<IconButton
|
||||
aria-label="删除档位"
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
onClick={() => removeTier(index)}
|
||||
>
|
||||
<DeleteOutlineOutlined fontSize="small" />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="form-drawer__grid">
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="档位编码"
|
||||
value={tier.tierCode}
|
||||
onChange={(event) => updateTier(index, { tierCode: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
label="档位名称"
|
||||
value={tier.tierName}
|
||||
onChange={(event) => updateTier(index, { tierName: event.target.value })}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 1 }}
|
||||
label="流水阈值"
|
||||
type="number"
|
||||
value={tier.thresholdCoinSpent}
|
||||
onChange={(event) =>
|
||||
updateTier(index, { thresholdCoinSpent: event.target.value })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 0 }}
|
||||
label="奖励金币"
|
||||
type="number"
|
||||
value={tier.rewardCoinAmount}
|
||||
onChange={(event) =>
|
||||
updateTier(index, { rewardCoinAmount: event.target.value })
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
select
|
||||
disabled={disabled}
|
||||
label="状态"
|
||||
value={tier.status}
|
||||
onChange={(event) => updateTier(index, { status: event.target.value })}
|
||||
>
|
||||
{statusOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ min: 0 }}
|
||||
label="排序"
|
||||
type="number"
|
||||
value={tier.sortOrder}
|
||||
onChange={(event) => updateTier(index, { sortOrder: event.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!form.tiers?.length ? <div className={styles.emptyState}>暂无档位</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
label="房间流水奖励状态"
|
||||
uncheckedLabel="关闭"
|
||||
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
|
||||
/>
|
||||
</section>
|
||||
<section className="form-drawer__section">
|
||||
<div className={styles.drawerSectionTitle}>
|
||||
<span>奖励档位</span>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
startIcon={<AddOutlined fontSize="small" />}
|
||||
type="button"
|
||||
onClick={addTier}
|
||||
>
|
||||
添加档位
|
||||
</Button>
|
||||
</div>
|
||||
<RewardTierEditorTable
|
||||
columns={[
|
||||
{
|
||||
key: "tier",
|
||||
label: "档位",
|
||||
render: (_tier, index) => (
|
||||
<span className="reward-tier-editor-table__index">档位 {index + 1}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "thresholdCoinSpent",
|
||||
label: "流水阈值",
|
||||
render: (tier, index) => (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ "aria-label": "流水阈值", min: 1 }}
|
||||
placeholder="100000"
|
||||
type="number"
|
||||
value={tier.thresholdCoinSpent}
|
||||
onChange={(event) =>
|
||||
updateTier(index, { thresholdCoinSpent: event.target.value })
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "rewardCoinAmount",
|
||||
label: "奖励金币",
|
||||
render: (tier, index) => (
|
||||
<TextField
|
||||
disabled={disabled}
|
||||
inputProps={{ "aria-label": "奖励金币", min: 0 }}
|
||||
placeholder="1000"
|
||||
type="number"
|
||||
value={tier.rewardCoinAmount}
|
||||
onChange={(event) =>
|
||||
updateTier(index, { rewardCoinAmount: event.target.value })
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
render: (tier, index) => (
|
||||
<AdminSwitch
|
||||
checked={tier.status !== "inactive"}
|
||||
checkedLabel="启用"
|
||||
disabled={disabled}
|
||||
label={`档位 ${index + 1} 状态`}
|
||||
uncheckedLabel="停用"
|
||||
onChange={(event) =>
|
||||
updateTier(index, { status: event.target.checked ? "active" : "inactive" })
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
disabled={disabled}
|
||||
emptyText="暂无档位"
|
||||
gridTemplateColumns="72px minmax(160px, 1fr) minmax(160px, 1fr) 120px 52px"
|
||||
rows={form.tiers || []}
|
||||
onRemoveTier={removeTier}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
<div className="form-drawer__actions">
|
||||
<Button disabled={configSaving} type="button" onClick={onClose}>
|
||||
取消
|
||||
|
||||
@ -176,26 +176,26 @@ function payloadFromForm(form) {
|
||||
const tiers = (form.tiers || []).map((tier, index) => {
|
||||
const thresholdCoinSpent = Number(tier.thresholdCoinSpent || 0);
|
||||
const rewardCoinAmount = Number(tier.rewardCoinAmount || 0);
|
||||
if (!tier.tierCode.trim()) {
|
||||
throw new Error("档位编码不能为空");
|
||||
}
|
||||
if (!tier.tierName.trim()) {
|
||||
throw new Error("档位名称不能为空");
|
||||
}
|
||||
if (thresholdCoinSpent <= 0) {
|
||||
throw new Error("流水阈值必须大于 0");
|
||||
}
|
||||
if (rewardCoinAmount < 0) {
|
||||
throw new Error("奖励金币不能小于 0");
|
||||
}
|
||||
const existingTier = Number(tier.tierId || 0) > 0;
|
||||
const tierCode = String(tier.tierCode || "").trim() || `room_turnover_${Date.now()}_${index + 1}`;
|
||||
const tierName =
|
||||
existingTier && String(tier.tierName || "").trim()
|
||||
? String(tier.tierName).trim()
|
||||
: `房间流水 ${thresholdCoinSpent}`;
|
||||
return {
|
||||
tier_id: Number(tier.tierId || 0),
|
||||
tier_code: tier.tierCode.trim(),
|
||||
tier_name: tier.tierName.trim(),
|
||||
tier_code: tierCode,
|
||||
tier_name: tierName,
|
||||
threshold_coin_spent: thresholdCoinSpent,
|
||||
reward_coin_amount: rewardCoinAmount,
|
||||
status: tier.status === "inactive" ? "inactive" : "active",
|
||||
sort_order: Number(tier.sortOrder || index),
|
||||
sort_order: index,
|
||||
};
|
||||
});
|
||||
const activeTiers = tiers
|
||||
|
||||
@ -29,7 +29,7 @@ const columnsBase = [
|
||||
},
|
||||
{
|
||||
key: "period",
|
||||
label: "UTC 周期",
|
||||
label: "周期",
|
||||
width: "minmax(260px, 1.1fr)",
|
||||
render: (item) => (
|
||||
<div className={styles.stack}>
|
||||
@ -90,13 +90,13 @@ const columnsBase = [
|
||||
width: "112px",
|
||||
fixed: "right",
|
||||
resizable: false,
|
||||
render: (item, context) =>
|
||||
item.status === "failed" && context.abilities.canRetry ? (
|
||||
render: (item, _index, context = {}) =>
|
||||
item.status === "failed" && context.abilities?.canRetry ? (
|
||||
<AdminActionIconButton
|
||||
disabled={context.retryingSettlementId === item.settlementId}
|
||||
label="重试"
|
||||
tooltip="重试发放"
|
||||
onClick={() => context.retrySettlement(item)}
|
||||
onClick={() => context.retrySettlement?.(item)}
|
||||
>
|
||||
<ReplayOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import {
|
||||
@ -7,7 +8,7 @@ import {
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { formatMillis } from "@/shared/utils/time.js";
|
||||
import { TimeRangeText, TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
import { useUserLeaderboardPage } from "@/features/user-leaderboard/hooks/useUserLeaderboardPage.js";
|
||||
import styles from "@/features/user-leaderboard/user-leaderboard.module.css";
|
||||
|
||||
@ -32,7 +33,7 @@ const columns = [
|
||||
},
|
||||
{
|
||||
key: "subject",
|
||||
label: "对象",
|
||||
label: "用户信息",
|
||||
width: "minmax(280px, 1.25fr)",
|
||||
render: (item, _index, context) => <LeaderboardSubject item={item} type={context.boardType} />,
|
||||
},
|
||||
@ -58,7 +59,7 @@ const columns = [
|
||||
key: "lastGiftAtMs",
|
||||
label: "最后送礼时间",
|
||||
width: "minmax(180px, 0.85fr)",
|
||||
render: (item) => `${formatMillis(item.lastGiftAtMs, "UTC")} UTC`,
|
||||
render: (item) => <TimeText value={item.lastGiftAtMs} />,
|
||||
},
|
||||
];
|
||||
|
||||
@ -93,9 +94,9 @@ export function UserLeaderboardPage() {
|
||||
/>
|
||||
<div className={styles.summary}>
|
||||
<div>
|
||||
<span className={styles.summaryLabel}>UTC 时间范围</span>
|
||||
<span className={styles.summaryLabel}>时间范围</span>
|
||||
<span className={styles.summaryValue}>
|
||||
{formatMillis(page.data.startAtMs, "UTC")} - {formatMillis(page.data.endAtMs, "UTC")}
|
||||
<TimeRangeText end={page.data.endAtMs} start={page.data.startAtMs} />
|
||||
</span>
|
||||
</div>
|
||||
{page.data.myRank ? (
|
||||
@ -136,7 +137,7 @@ function LeaderboardSubject({ item, type }) {
|
||||
if (type === "room") {
|
||||
const room = item.room || {};
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<div className={styles.roomIdentity}>
|
||||
<span className={styles.name}>{room.title || item.roomId || "-"}</span>
|
||||
<span className={styles.meta}>
|
||||
{room.roomShortId ? `短号 ${room.roomShortId}` : item.roomId || "-"}
|
||||
@ -146,12 +147,16 @@ function LeaderboardSubject({ item, type }) {
|
||||
);
|
||||
}
|
||||
const user = item.user || {};
|
||||
const shortId = user.displayUserId || "";
|
||||
const displayName = user.username || (shortId ? `用户 ${shortId}` : "用户");
|
||||
return (
|
||||
<div className={styles.identity}>
|
||||
<span className={styles.name}>{user.username || user.displayUserId || item.userId || "-"}</span>
|
||||
<span className={styles.meta}>
|
||||
{user.displayUserId ? `短号 ${user.displayUserId}` : ""}
|
||||
{item.userId ? `${user.displayUserId ? " · " : ""}ID ${item.userId}` : ""}
|
||||
<span className={styles.avatar}>
|
||||
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
|
||||
</span>
|
||||
<span className={styles.identityText}>
|
||||
<span className={styles.name}>{displayName}</span>
|
||||
<span className={styles.meta}>短ID {shortId || "-"}</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -24,11 +24,44 @@
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.identityText {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.roomIdentity {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 34px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card-strong);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
color: var(--text-primary);
|
||||
|
||||
@ -5,17 +5,14 @@ import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined";
|
||||
import RefreshOutlined from "@mui/icons-material/RefreshOutlined";
|
||||
import WorkspacePremiumOutlined from "@mui/icons-material/WorkspacePremiumOutlined";
|
||||
import Alert from "@mui/material/Alert";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Box from "@mui/material/Box";
|
||||
import Chip from "@mui/material/Chip";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import FormControl from "@mui/material/FormControl";
|
||||
import InputLabel from "@mui/material/InputLabel";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import Select from "@mui/material/Select";
|
||||
import Snackbar from "@mui/material/Snackbar";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import Table from "@mui/material/Table";
|
||||
import TableBody from "@mui/material/TableBody";
|
||||
import TableCell from "@mui/material/TableCell";
|
||||
@ -34,9 +31,22 @@ import {
|
||||
updateWeeklyStarCycle,
|
||||
} from "@/features/weekly-star/api";
|
||||
import { listGifts, listResourceGroups } from "@/features/resources/api";
|
||||
import { GiftSelectField } from "@/features/resources/components/GiftSelectDrawer.jsx";
|
||||
import { Button } from "@/shared/ui/Button.jsx";
|
||||
import { AdminListBody, AdminListPage, AdminListToolbar } from "@/shared/ui/AdminListLayout.jsx";
|
||||
import {
|
||||
AdminActionIconButton,
|
||||
AdminListBody,
|
||||
AdminListPage,
|
||||
AdminListToolbar,
|
||||
AdminRowActions,
|
||||
} from "@/shared/ui/AdminListLayout.jsx";
|
||||
import { DataState } from "@/shared/ui/DataState.jsx";
|
||||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||||
import { ResourceGroupSelectField } from "@/shared/ui/ResourceGroupSelectDrawer.jsx";
|
||||
import { RegionSelect } from "@/shared/ui/RegionSelect.jsx";
|
||||
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
|
||||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||||
import { useRegionOptions } from "@/shared/hooks/useRegionOptions.js";
|
||||
|
||||
const statusOptions = [
|
||||
["draft", "草稿"],
|
||||
@ -51,8 +61,8 @@ const emptyForm = {
|
||||
title: "Weekly Star",
|
||||
regionId: "0",
|
||||
status: "draft",
|
||||
startAt: "",
|
||||
endAt: "",
|
||||
startMs: "",
|
||||
endMs: "",
|
||||
giftIds: ["", "", ""],
|
||||
resourceGroupIds: ["", "", ""],
|
||||
};
|
||||
@ -69,6 +79,7 @@ export function WeeklyStarPage() {
|
||||
const [toast, setToast] = useState("");
|
||||
const [leaderboard, setLeaderboard] = useState({ cycle: null, loading: false, items: [], error: "" });
|
||||
const [settlements, setSettlements] = useState({ cycle: null, loading: false, items: [], error: "" });
|
||||
const { loadingRegions, regionOptions } = useRegionOptions();
|
||||
|
||||
const giftOptions = useMemo(
|
||||
() =>
|
||||
@ -86,6 +97,10 @@ export function WeeklyStarPage() {
|
||||
})),
|
||||
[resourceGroups],
|
||||
);
|
||||
const weeklyStarRegionOptions = useMemo(
|
||||
() => [{ label: "默认区域 · 0", name: "默认区域", regionId: 0, value: "0" }, ...regionOptions],
|
||||
[regionOptions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
reloadCycles();
|
||||
@ -122,10 +137,11 @@ export function WeeklyStarPage() {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
const now = Date.now();
|
||||
setForm({
|
||||
...emptyForm,
|
||||
startAt: formatUTCInput(Date.now()),
|
||||
endAt: formatUTCInput(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
startMs: now,
|
||||
endMs: now + 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
@ -139,8 +155,8 @@ export function WeeklyStarPage() {
|
||||
title: cycle.title || "Weekly Star",
|
||||
regionId: String(cycle.regionId || 0),
|
||||
status: cycle.status || "draft",
|
||||
startAt: formatUTCInput(cycle.startMs),
|
||||
endAt: formatUTCInput(cycle.endMs),
|
||||
startMs: cycle.startMs || "",
|
||||
endMs: cycle.endMs || "",
|
||||
giftIds: [0, 1, 2].map((index) => String(cycle.gifts[index]?.giftId || "")),
|
||||
resourceGroupIds: rewards,
|
||||
});
|
||||
@ -167,6 +183,14 @@ export function WeeklyStarPage() {
|
||||
}));
|
||||
}
|
||||
|
||||
function updateTimeRange(value) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
endMs: value?.endMs || "",
|
||||
startMs: value?.startMs || "",
|
||||
}));
|
||||
}
|
||||
|
||||
async function submitForm(event) {
|
||||
event.preventDefault();
|
||||
const payload = buildPayload(form);
|
||||
@ -194,8 +218,8 @@ export function WeeklyStarPage() {
|
||||
function buildPayload(values) {
|
||||
const giftIds = values.giftIds.map((item) => String(item || "").trim()).filter(Boolean);
|
||||
const resourceGroupIds = values.resourceGroupIds.map((item) => Number(item || 0));
|
||||
const startMs = parseUTCInput(values.startAt);
|
||||
const endMs = parseUTCInput(values.endAt);
|
||||
const startMs = Number(values.startMs || 0);
|
||||
const endMs = Number(values.endMs || 0);
|
||||
if (!values.title.trim()) {
|
||||
setToast("请填写活动标题");
|
||||
return null;
|
||||
@ -209,7 +233,7 @@ export function WeeklyStarPage() {
|
||||
return null;
|
||||
}
|
||||
if (!startMs || !endMs || endMs <= startMs) {
|
||||
setToast("请填写有效 UTC 时间范围");
|
||||
setToast("请填写有效时间范围");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
@ -272,220 +296,218 @@ export function WeeklyStarPage() {
|
||||
/>
|
||||
<DataState error={error} loading={loading} onRetry={reloadCycles}>
|
||||
<AdminListBody>
|
||||
<TableContainer sx={{ minWidth: 1200 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>周期</TableCell>
|
||||
<TableCell>区域</TableCell>
|
||||
<TableCell>UTC 时间</TableCell>
|
||||
<TableCell>礼物</TableCell>
|
||||
<TableCell>奖励资源组</TableCell>
|
||||
<TableCell>状态</TableCell>
|
||||
<TableCell align="right">操作</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{cycles.map((cycle) => (
|
||||
<TableRow hover key={cycle.cycleId}>
|
||||
<TableCell>
|
||||
<Stack spacing={0.5}>
|
||||
<Typography fontWeight={700}>{cycle.title || "-"}</Typography>
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
{cycle.cycleId}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>{cycle.regionId === 0 ? "默认区域 · 0" : cycle.regionId}</TableCell>
|
||||
<TableCell>
|
||||
<Stack spacing={0.5}>
|
||||
<span>{formatUTC(cycle.startMs)}</span>
|
||||
<Typography color="text.secondary" variant="caption">
|
||||
至 {formatUTC(cycle.endMs)}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" flexWrap="wrap" gap={0.75}>
|
||||
{cycle.gifts.map((gift) => (
|
||||
<Chip
|
||||
key={gift.giftId}
|
||||
label={giftLabel(gift.giftId, giftOptions)}
|
||||
size="small"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" flexWrap="wrap" gap={0.75}>
|
||||
{cycle.rewards.map((reward) => (
|
||||
<Chip
|
||||
key={reward.rankNo}
|
||||
label={`Top${reward.rankNo}: ${groupLabel(
|
||||
reward.resourceGroupId,
|
||||
resourceGroupOptions,
|
||||
)}`}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
color={statusColor(cycle.status)}
|
||||
label={statusLabel(cycle.status)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={1}>
|
||||
<Button
|
||||
startIcon={<EditOutlined fontSize="small" />}
|
||||
onClick={() => openEdit(cycle)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
disabled={cycle.status === "settled" || cycle.status === "settling"}
|
||||
variant={cycle.status === "active" ? "danger" : "success"}
|
||||
onClick={() => toggleStatus(cycle)}
|
||||
>
|
||||
{cycle.status === "active" ? "停用" : "启用"}
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<BarChartOutlined fontSize="small" />}
|
||||
onClick={() => openLeaderboard(cycle)}
|
||||
>
|
||||
排行榜
|
||||
</Button>
|
||||
<Button
|
||||
startIcon={<WorkspacePremiumOutlined fontSize="small" />}
|
||||
onClick={() => openSettlements(cycle)}
|
||||
>
|
||||
结算
|
||||
</Button>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!cycles.length ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<Box sx={{ py: 6, textAlign: "center", color: "text.secondary" }}>
|
||||
暂无周星周期配置
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<DataTable
|
||||
columns={[
|
||||
{
|
||||
key: "cycle",
|
||||
label: "周期",
|
||||
width: "minmax(280px, 1.1fr)",
|
||||
render: (cycle) => (
|
||||
<div className="weekly-star-list-stack">
|
||||
<span className="weekly-star-list-title">{cycle.title || "-"}</span>
|
||||
<span className="weekly-star-list-meta">{cycle.cycleId || "-"}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "region",
|
||||
label: "区域",
|
||||
width: "120px",
|
||||
render: (cycle) => regionLabel(cycle.regionId, weeklyStarRegionOptions),
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
label: "时间",
|
||||
width: "minmax(240px, 0.9fr)",
|
||||
render: (cycle) => (
|
||||
<div className="weekly-star-list-stack">
|
||||
<TimeText value={cycle.startMs} />
|
||||
<span className="weekly-star-list-meta">
|
||||
至 <TimeText value={cycle.endMs} />
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "gifts",
|
||||
label: "礼物",
|
||||
width: "minmax(260px, 1fr)",
|
||||
render: (cycle) => (
|
||||
<WeeklyStarChipList
|
||||
items={(cycle.gifts || []).map((gift) => ({
|
||||
key: gift.giftId,
|
||||
label: giftLabel(gift.giftId, giftOptions),
|
||||
}))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "rewards",
|
||||
label: "奖励资源组",
|
||||
width: "minmax(240px, 0.8fr)",
|
||||
render: (cycle) => (
|
||||
<WeeklyStarChipList
|
||||
outlined
|
||||
items={(cycle.rewards || []).map((reward) => ({
|
||||
key: reward.rankNo,
|
||||
label: `Top${reward.rankNo}: ${groupLabel(
|
||||
reward.resourceGroupId,
|
||||
resourceGroupOptions,
|
||||
)}`,
|
||||
}))}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: "状态",
|
||||
width: "112px",
|
||||
render: (cycle) => (
|
||||
<Chip color={statusColor(cycle.status)} label={statusLabel(cycle.status)} size="small" />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "操作",
|
||||
width: "112px",
|
||||
fixed: "right",
|
||||
resizable: false,
|
||||
render: (cycle) => (
|
||||
<WeeklyStarRowActions
|
||||
cycle={cycle}
|
||||
onEdit={openEdit}
|
||||
onLeaderboard={openLeaderboard}
|
||||
onSettlements={openSettlements}
|
||||
onToggleStatus={toggleStatus}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
emptyLabel="暂无周星周期配置"
|
||||
items={cycles}
|
||||
minWidth="1460px"
|
||||
rowKey={(cycle) => cycle.cycleId}
|
||||
/>
|
||||
</AdminListBody>
|
||||
</DataState>
|
||||
|
||||
<Drawer anchor="right" open={drawerOpen} PaperProps={{ sx: { width: 520 } }} onClose={closeDrawer}>
|
||||
<Box component="form" sx={{ p: 3 }} onSubmit={submitForm}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 2 }}>
|
||||
<Typography variant="h6">{form.cycleId ? "编辑周星周期" : "新增周星周期"}</Typography>
|
||||
<Chip label="UTC epoch ms" size="small" />
|
||||
</Stack>
|
||||
<Stack spacing={2.25}>
|
||||
<TextField
|
||||
required
|
||||
label="活动标题"
|
||||
value={form.title}
|
||||
onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))}
|
||||
/>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="区域 ID"
|
||||
type="number"
|
||||
value={form.regionId}
|
||||
helperText="0 表示默认区域"
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, regionId: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>状态</InputLabel>
|
||||
<Select
|
||||
label="状态"
|
||||
value={form.status}
|
||||
<Drawer anchor="right" open={drawerOpen} onClose={closeDrawer}>
|
||||
<Box className="weekly-star-config-drawer" component="form" onSubmit={submitForm}>
|
||||
<div className="weekly-star-config-drawer__header">
|
||||
<h2 className="weekly-star-config-drawer__title">
|
||||
{form.cycleId ? "编辑周星周期" : "新增周星周期"}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="weekly-star-config-drawer__body">
|
||||
<section className="weekly-star-config-section">
|
||||
<div className="form-drawer__section-title">基础信息</div>
|
||||
<div className="weekly-star-config-grid weekly-star-config-grid--basic">
|
||||
<TextField
|
||||
required
|
||||
label="活动标题"
|
||||
value={form.title}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, status: event.target.value }))
|
||||
setForm((current) => ({ ...current, title: event.target.value }))
|
||||
}
|
||||
>
|
||||
{statusOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
label="开始时间 UTC"
|
||||
placeholder="2026-06-01T00:00"
|
||||
value={form.startAt}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, startAt: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
label="结束时间 UTC"
|
||||
placeholder="2026-06-08T00:00"
|
||||
value={form.endAt}
|
||||
onChange={(event) => setForm((current) => ({ ...current, endAt: event.target.value }))}
|
||||
/>
|
||||
</Stack>
|
||||
<Divider />
|
||||
<Typography fontWeight={700}>活动礼物</Typography>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<OptionField
|
||||
key={index}
|
||||
label={`礼物 ${index + 1}`}
|
||||
options={giftOptions}
|
||||
value={form.giftIds[index]}
|
||||
onChange={(value) => updateGift(index, value)}
|
||||
/>
|
||||
))}
|
||||
<Divider />
|
||||
<Typography fontWeight={700}>Top1 / Top2 / Top3 资源组</Typography>
|
||||
{[0, 1, 2].map((index) => (
|
||||
<OptionField
|
||||
key={index}
|
||||
label={`Top${index + 1} 资源组 ID`}
|
||||
options={resourceGroupOptions}
|
||||
value={form.resourceGroupIds[index]}
|
||||
onChange={(value) => updateReward(index, value)}
|
||||
/>
|
||||
))}
|
||||
<Stack direction="row" justifyContent="flex-end" spacing={1.5} sx={{ pt: 1 }}>
|
||||
<Button disabled={saving} onClick={closeDrawer}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving}
|
||||
startIcon={<EventAvailableOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
/>
|
||||
<RegionSelect
|
||||
required
|
||||
label="区域"
|
||||
loading={loadingRegions}
|
||||
options={weeklyStarRegionOptions}
|
||||
size="medium"
|
||||
value={form.regionId}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, regionId: value || "0" }))
|
||||
}
|
||||
/>
|
||||
<FormControl>
|
||||
<InputLabel>状态</InputLabel>
|
||||
<Select
|
||||
label="状态"
|
||||
value={form.status}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({ ...current, status: event.target.value }))
|
||||
}
|
||||
>
|
||||
{statusOptions.map(([value, label]) => (
|
||||
<MenuItem key={value} value={value}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className="weekly-star-time-range">
|
||||
<TimeRangeFilter
|
||||
className="weekly-star-time-range__control"
|
||||
label="活动时间"
|
||||
value={{ endMs: form.endMs, startMs: form.startMs }}
|
||||
onChange={updateTimeRange}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="weekly-star-config-section">
|
||||
<div className="form-drawer__section-title">活动礼物</div>
|
||||
<div className="weekly-star-config-card-grid">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div className="weekly-star-config-card" key={index}>
|
||||
<div className="weekly-star-config-card__header">
|
||||
<span className="weekly-star-config-card__label">礼物 {index + 1}</span>
|
||||
</div>
|
||||
<GiftSelectField
|
||||
drawerTitle={`选择礼物 ${index + 1}`}
|
||||
gifts={gifts}
|
||||
label="选择礼物"
|
||||
placeholder="搜索礼物名称或 ID"
|
||||
value={form.giftIds[index]}
|
||||
onChange={(value) => updateGift(index, value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="weekly-star-config-section">
|
||||
<div className="form-drawer__section-title">排名奖励</div>
|
||||
<div className="weekly-star-config-card-grid">
|
||||
{[0, 1, 2].map((index) => (
|
||||
<div className="weekly-star-config-card" key={index}>
|
||||
<div className="weekly-star-config-card__header">
|
||||
<span className={`weekly-star-rank-badge weekly-star-rank-badge--${index + 1}`}>
|
||||
Top{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<ResourceGroupSelectField
|
||||
drawerTitle={`选择 Top${index + 1} 奖励资源组`}
|
||||
groups={resourceGroups}
|
||||
label="奖励资源组"
|
||||
placeholder="搜索资源组名称或 ID"
|
||||
value={form.resourceGroupIds[index]}
|
||||
onChange={(value) => updateReward(index, value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="weekly-star-config-drawer__actions">
|
||||
<Button disabled={saving} type="button" onClick={closeDrawer}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving}
|
||||
startIcon={<EventAvailableOutlined fontSize="small" />}
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Drawer>
|
||||
|
||||
<ResultDialog
|
||||
columns={["排名", "用户 ID", "积分", "首次计分 UTC", "最后计分 UTC"]}
|
||||
columns={["排名", "用户 ID", "积分", "首次计分时间", "最后计分时间"]}
|
||||
emptyText="暂无排行榜数据"
|
||||
error={leaderboard.error}
|
||||
loading={leaderboard.loading}
|
||||
@ -497,8 +519,8 @@ export function WeeklyStarPage() {
|
||||
item.rankNo,
|
||||
item.userId,
|
||||
formatNumber(item.score),
|
||||
formatUTC(item.firstScoredAtMs),
|
||||
formatUTC(item.lastScoredAtMs),
|
||||
<TimeText value={item.firstScoredAtMs} />,
|
||||
<TimeText value={item.lastScoredAtMs} />,
|
||||
]}
|
||||
/>
|
||||
<ResultDialog
|
||||
@ -528,22 +550,46 @@ export function WeeklyStarPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function OptionField({ label, onChange, options, value }) {
|
||||
function WeeklyStarChipList({ items, outlined = false }) {
|
||||
return (
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
options={options}
|
||||
value={optionForValue(value, options)}
|
||||
getOptionLabel={(option) => (typeof option === "string" ? option : option.label || "")}
|
||||
isOptionEqualToValue={(option, selected) => option.value === selected.value}
|
||||
onChange={(_, next) => onChange(typeof next === "string" ? next : next?.value || "")}
|
||||
onInputChange={(_, next, reason) => {
|
||||
if (reason === "input") {
|
||||
onChange(next);
|
||||
}
|
||||
}}
|
||||
renderInput={(params) => <TextField {...params} required label={label} />}
|
||||
/>
|
||||
<div className="weekly-star-list-chip-list">
|
||||
{items.length ? (
|
||||
items.map((item) => (
|
||||
<Chip
|
||||
className="weekly-star-list-chip"
|
||||
key={item.key}
|
||||
label={item.label || "-"}
|
||||
size="small"
|
||||
variant={outlined ? "outlined" : "filled"}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<span className="weekly-star-list-meta">-</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeeklyStarRowActions({ cycle, onEdit, onLeaderboard, onSettlements, onToggleStatus }) {
|
||||
const toggleDisabled = cycle.status === "settled" || cycle.status === "settling";
|
||||
const toggleLabel = cycle.status === "active" ? "停用周期" : "启用周期";
|
||||
return (
|
||||
<div className="weekly-star-list-actions">
|
||||
<AdminRowActions>
|
||||
<AdminActionIconButton label="编辑周期" onClick={() => onEdit(cycle)}>
|
||||
<EditOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton disabled={toggleDisabled} label={toggleLabel} onClick={() => onToggleStatus(cycle)}>
|
||||
<EventAvailableOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton label="排行榜" onClick={() => onLeaderboard(cycle)}>
|
||||
<BarChartOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
<AdminActionIconButton label="结算结果" onClick={() => onSettlements(cycle)}>
|
||||
<WorkspacePremiumOutlined fontSize="small" />
|
||||
</AdminActionIconButton>
|
||||
</AdminRowActions>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -594,11 +640,6 @@ function ResultDialog({ columns, emptyText, error, loading, onClose, open, rende
|
||||
);
|
||||
}
|
||||
|
||||
function optionForValue(value, options) {
|
||||
const normalized = String(value || "");
|
||||
return options.find((option) => option.value === normalized) || normalized;
|
||||
}
|
||||
|
||||
function giftLabel(giftId, options) {
|
||||
return options.find((option) => option.value === String(giftId))?.label || giftId || "-";
|
||||
}
|
||||
@ -607,6 +648,12 @@ function groupLabel(groupId, options) {
|
||||
return options.find((option) => option.value === String(groupId))?.label || groupId || "-";
|
||||
}
|
||||
|
||||
function regionLabel(regionId, options) {
|
||||
const value = String(regionId ?? "0");
|
||||
const option = options.find((item) => item.value === value);
|
||||
return option?.name || option?.label || (value === "0" ? "默认区域" : `区域 ${value}`);
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
return statusOptions.find(([value]) => value === status)?.[1] || status || "-";
|
||||
}
|
||||
@ -619,38 +666,6 @@ function statusColor(status) {
|
||||
return "info";
|
||||
}
|
||||
|
||||
function parseUTCInput(value) {
|
||||
const match = String(value || "").match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/);
|
||||
if (!match) {
|
||||
return 0;
|
||||
}
|
||||
return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]), Number(match[4]), Number(match[5]));
|
||||
}
|
||||
|
||||
function formatUTCInput(value) {
|
||||
const ms = Number(value || 0);
|
||||
if (!ms) {
|
||||
return "";
|
||||
}
|
||||
const date = new Date(ms);
|
||||
return (
|
||||
[date.getUTCFullYear(), pad(date.getUTCMonth() + 1), pad(date.getUTCDate())].join("-") +
|
||||
`T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}`
|
||||
);
|
||||
}
|
||||
|
||||
function formatUTC(value) {
|
||||
const ms = Number(value || 0);
|
||||
if (!ms) {
|
||||
return "-";
|
||||
}
|
||||
return new Date(ms).toISOString().replace("T", " ").slice(0, 19) + " UTC";
|
||||
}
|
||||
|
||||
function pad(value) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
|
||||
}
|
||||
|
||||
@ -332,6 +332,9 @@ export interface RechargeBillDto {
|
||||
|
||||
export interface RechargeBillUserDto {
|
||||
avatar?: string;
|
||||
countryCode?: string;
|
||||
countryDisplayName?: string;
|
||||
countryName?: string;
|
||||
displayUserId?: string;
|
||||
userId?: string;
|
||||
username?: string;
|
||||
|
||||
23
src/shared/hooks/useAdminTime.js
Normal file
23
src/shared/hooks/useAdminTime.js
Normal 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
|
||||
};
|
||||
}
|
||||
@ -497,7 +497,7 @@ function normalizeColumn(column) {
|
||||
...column,
|
||||
className,
|
||||
resizable: actionColumn ? false : column.resizable,
|
||||
width: actionColumn ? ACTION_COLUMN_WIDTH : normalizeColumnWidth(column)
|
||||
width: actionColumn && !column.width ? ACTION_COLUMN_WIDTH : normalizeColumnWidth(column)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
62
src/shared/ui/RewardTierEditorTable.jsx
Normal file
62
src/shared/ui/RewardTierEditorTable.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,19 @@
|
||||
import { useTimeZone } from "@/app/timezone/TimeZoneProvider.jsx";
|
||||
import { useAdminTime } from "@/shared/hooks/useAdminTime.js";
|
||||
|
||||
export function TimeText({ className, component: Component = "span", value }) {
|
||||
const { formatMillis, timeZoneLabel } = useTimeZone();
|
||||
const text = formatMillis(value);
|
||||
const { formatDateTime, timeZoneLabel } = useAdminTime();
|
||||
const text = formatDateTime(value);
|
||||
|
||||
return (
|
||||
<Component className={className} title={text === "-" ? undefined : timeZoneLabel}>
|
||||
{text}
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimeRangeText({ className, component: Component = "span", end, separator = " - ", start }) {
|
||||
const { formatRange, timeZoneLabel } = useAdminTime();
|
||||
const text = formatRange(start, end, separator);
|
||||
|
||||
return (
|
||||
<Component className={className} title={text === "-" ? undefined : timeZoneLabel}>
|
||||
|
||||
50
src/shared/ui/TimeText.test.jsx
Normal file
50
src/shared/ui/TimeText.test.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -16,6 +16,7 @@ let pagModulePromise;
|
||||
export function UploadField({
|
||||
accept,
|
||||
disabled = false,
|
||||
density = "regular",
|
||||
kind = "image",
|
||||
label,
|
||||
onChange,
|
||||
@ -88,7 +89,11 @@ export function UploadField({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={[styles.root, disabled ? styles.disabled : ""].filter(Boolean).join(" ")}>
|
||||
<div
|
||||
className={[styles.root, density === "compact" ? styles.compact : "", disabled ? styles.disabled : ""]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
<div className={styles.header}>
|
||||
<span className={styles.label}>{label}</span>
|
||||
<span className={styles.type}>{assetKindLabel(assetKind, isImage)}</span>
|
||||
|
||||
@ -1,176 +1,210 @@
|
||||
.root {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: var(--space-2);
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.72;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.input {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 650;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.type {
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
color: var(--text-tertiary);
|
||||
font-size: var(--admin-font-size);
|
||||
}
|
||||
|
||||
.panel {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
|
||||
.preview {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 168px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background:
|
||||
linear-gradient(45deg, rgba(100, 116, 139, 0.08) 25%, transparent 25%) 0 0 / 16px 16px,
|
||||
linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.08) 75%) 0 0 / 16px 16px,
|
||||
linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.08) 75%) 8px 8px / 16px 16px,
|
||||
linear-gradient(45deg, rgba(100, 116, 139, 0.08) 25%, transparent 25%) 8px 8px / 16px 16px,
|
||||
var(--bg-card-strong);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 168px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background:
|
||||
linear-gradient(45deg, rgba(100, 116, 139, 0.08) 25%, transparent 25%) 0 0 / 16px 16px,
|
||||
linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.08) 75%) 0 0 / 16px 16px,
|
||||
linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.08) 75%) 8px 8px / 16px 16px,
|
||||
linear-gradient(45deg, rgba(100, 116, 139, 0.08) 25%, transparent 25%) 8px 8px / 16px 16px,
|
||||
var(--bg-card-strong);
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.preview:disabled {
|
||||
cursor: not-allowed;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 650;
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.empty svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
color: var(--primary);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
color: var(--primary);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.player {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.playerSurface {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.playerSurface canvas,
|
||||
.pagCanvas {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.pagCanvas {
|
||||
display: block;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
display: flex;
|
||||
min-height: 52px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
height: var(--control-height);
|
||||
min-width: var(--control-height);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input-strong);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: var(--admin-font-size);
|
||||
line-height: var(--admin-line-height);
|
||||
transition:
|
||||
border-color var(--motion-fast) var(--ease-standard),
|
||||
color var(--motion-fast) var(--ease-standard),
|
||||
background var(--motion-fast) var(--ease-standard);
|
||||
display: inline-flex;
|
||||
height: var(--control-height);
|
||||
min-width: var(--control-height);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--bg-input-strong);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: var(--admin-font-size);
|
||||
line-height: var(--admin-line-height);
|
||||
transition:
|
||||
border-color var(--motion-fast) var(--ease-standard),
|
||||
color var(--motion-fast) var(--ease-standard),
|
||||
background var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.action:hover:not(:disabled) {
|
||||
border-color: var(--primary-border);
|
||||
background: var(--primary-hover);
|
||||
color: var(--primary);
|
||||
border-color: var(--primary-border);
|
||||
background: var(--primary-hover);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.action:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.compact {
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.compact .label {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.compact .type,
|
||||
.compact .name,
|
||||
.compact .action {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.compact .preview {
|
||||
height: 112px;
|
||||
}
|
||||
|
||||
.compact .empty svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.compact .footer {
|
||||
min-height: 40px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
}
|
||||
|
||||
.compact .action {
|
||||
height: 28px;
|
||||
min-width: 28px;
|
||||
padding: 0 var(--space-2);
|
||||
}
|
||||
|
||||
@ -805,6 +805,31 @@
|
||||
width: min(560px, 100vw);
|
||||
}
|
||||
|
||||
.form-drawer--activity-config {
|
||||
box-sizing: border-box;
|
||||
width: min(880px, calc(100vw - 48px));
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
overflow: hidden;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.form-drawer--activity-config .form-drawer__content {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
min-height: 0;
|
||||
gap: var(--space-4);
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.form-drawer--activity-config .form-drawer__actions {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.role-permission-drawer {
|
||||
box-sizing: border-box;
|
||||
width: min(1040px, calc(100vw - 48px));
|
||||
@ -880,6 +905,271 @@
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.reward-tier-editor-table {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__head,
|
||||
.reward-tier-editor-table__row {
|
||||
display: grid;
|
||||
min-width: 720px;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: var(--reward-tier-columns, 72px minmax(160px, 1fr) minmax(160px, 1fr) 120px 96px 52px);
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__head {
|
||||
padding: 0 var(--space-2);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__body {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__row {
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-2);
|
||||
background: var(--fill-secondary);
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__cell {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__cell > .MuiTextField-root,
|
||||
.reward-tier-editor-table__cell > .MuiFormControl-root,
|
||||
.reward-tier-editor-table__cell > .MuiAutocomplete-root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__index {
|
||||
color: var(--text-primary);
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__action {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__empty {
|
||||
display: flex;
|
||||
min-height: 92px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed var(--border-strong);
|
||||
border-radius: var(--radius-2);
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.weekly-star-config-drawer {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
width: min(880px, calc(100vw - 48px));
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
gap: var(--space-4);
|
||||
overflow: hidden;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.weekly-star-config-drawer__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.weekly-star-config-drawer__title {
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
font-size: var(--admin-font-size);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.weekly-star-config-drawer__body {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
min-height: 0;
|
||||
gap: var(--space-4);
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.weekly-star-config-section {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding-bottom: var(--space-4);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.weekly-star-config-section:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.weekly-star-config-grid {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.weekly-star-config-grid--basic {
|
||||
grid-template-columns: minmax(240px, 1.2fr) minmax(160px, 0.8fr) minmax(180px, 0.8fr);
|
||||
}
|
||||
|
||||
.weekly-star-config-grid > .MuiTextField-root,
|
||||
.weekly-star-config-grid > .MuiFormControl-root,
|
||||
.weekly-star-config-grid > .MuiAutocomplete-root {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.weekly-star-time-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.weekly-star-time-range__control {
|
||||
width: min(520px, 100%);
|
||||
}
|
||||
|
||||
.weekly-star-config-card-grid {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.weekly-star-config-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--fill-secondary);
|
||||
}
|
||||
|
||||
.weekly-star-config-card__header {
|
||||
display: flex;
|
||||
min-height: 22px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.weekly-star-config-card__label {
|
||||
color: var(--text-primary);
|
||||
font-weight: 760;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.weekly-star-rank-badge {
|
||||
display: inline-flex;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 760;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.weekly-star-rank-badge--1 {
|
||||
border-color: color-mix(in srgb, #f2a800 36%, var(--border));
|
||||
background: color-mix(in srgb, #f2a800 13%, var(--bg-card));
|
||||
}
|
||||
|
||||
.weekly-star-rank-badge--2 {
|
||||
border-color: color-mix(in srgb, #7d8796 32%, var(--border));
|
||||
background: color-mix(in srgb, #7d8796 12%, var(--bg-card));
|
||||
}
|
||||
|
||||
.weekly-star-rank-badge--3 {
|
||||
border-color: color-mix(in srgb, #c26f30 34%, var(--border));
|
||||
background: color-mix(in srgb, #c26f30 12%, var(--bg-card));
|
||||
}
|
||||
|
||||
.weekly-star-list-stack {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.weekly-star-list-title,
|
||||
.weekly-star-list-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weekly-star-list-title {
|
||||
color: var(--text-primary);
|
||||
font-weight: 720;
|
||||
}
|
||||
|
||||
.weekly-star-list-meta {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.weekly-star-list-chip-list {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-height: 82px;
|
||||
gap: var(--space-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.weekly-star-list-chip.MuiChip-root {
|
||||
max-width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.weekly-star-list-chip .MuiChip-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.weekly-star-list-actions {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.weekly-star-list-actions > div {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, var(--control-height));
|
||||
gap: var(--space-2);
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.weekly-star-config-drawer__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.side-drawer {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
@ -1067,6 +1357,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.form-drawer--activity-config,
|
||||
.weekly-star-config-drawer {
|
||||
width: 100vw;
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.reward-tier-editor-table {
|
||||
overflow-x: visible;
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__head {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__row {
|
||||
min-width: 0;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__cell {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
grid-template-columns: 96px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__cell::before {
|
||||
color: var(--text-tertiary);
|
||||
content: attr(data-label);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reward-tier-editor-table__action {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.weekly-star-config-grid--basic,
|
||||
.weekly-star-config-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.batch-actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user