diff --git a/databi/src/components/MetricCard.jsx b/databi/src/components/MetricCard.jsx index ed8737d..e98c3d5 100644 --- a/databi/src/components/MetricCard.jsx +++ b/databi/src/components/MetricCard.jsx @@ -37,7 +37,7 @@ export function MetricCard({ item, loading = false, onClick }) { {item.value}
{item.caption} - {item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : ""} + {item.delta ? `${item.deltaTone === "down" ? "↓" : "↑"} ${item.delta.replace(/^[-+]/, "")}` : "--"}
diff --git a/databi/src/styles/cards.css b/databi/src/styles/cards.css index b662376..0ecdb73 100644 --- a/databi/src/styles/cards.css +++ b/databi/src/styles/cards.css @@ -139,6 +139,10 @@ color: #f5a623; } +.metric-delta .metric-delta-empty { + color: #7f9bb7; +} + .skeleton-block { display: block; position: relative; diff --git a/databi/src/styles/layout.css b/databi/src/styles/layout.css index 0430cae..d1f10ca 100644 --- a/databi/src/styles/layout.css +++ b/databi/src/styles/layout.css @@ -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 { diff --git a/src/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigDrawer.jsx b/src/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigDrawer.jsx index a0f85e9..94a512e 100644 --- a/src/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigDrawer.jsx +++ b/src/features/cumulative-recharge-reward/components/CumulativeRechargeRewardConfigDrawer.jsx @@ -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 ( -
+

累充奖励配置

-
-
发放状态
- setForm((current) => ({ ...current, enabled: event.target.checked }))} - /> -
-
-
- 奖励档位 - -
-
- {(form.tiers || []).map((tier, index) => ( -
-
- 档位 {index + 1} - removeTier(index)} - > - - -
-
- updateTier(index, { tierCode: event.target.value })} - /> - updateTier(index, { tierName: event.target.value })} - /> - updateTier(index, { thresholdUsd: event.target.value })} - /> - updateTier(index, { resourceGroupId: value })} - /> - updateTier(index, { status: event.target.value })} - > - {statusOptions.map(([value, label]) => ( - - {label} - - ))} - - updateTier(index, { sortOrder: event.target.value })} - /> -
-
- ))} - {!form.tiers?.length ?
暂无档位
: null} -
-
+ label="累充奖励状态" + uncheckedLabel="关闭" + onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))} + /> + +
+
+ 奖励档位 + +
+ ( + 档位 {index + 1} + ), + }, + { + key: "thresholdUsd", + label: "累充 USD", + render: (tier, index) => ( + updateTier(index, { thresholdUsd: event.target.value })} + /> + ), + }, + { + key: "resourceGroupId", + label: "奖励资源组", + render: (tier, index) => ( + updateTier(index, { resourceGroupId: value })} + /> + ), + }, + { + key: "status", + label: "状态", + render: (tier, index) => ( + + 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} + /> +
+
+ ); + })} + {!filteredGifts.length ?
当前无可选礼物
: null} +
+ + + ); +} + +function GiftThumb({ gift }) { + const imageUrl = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl || gift.resource?.animationUrl); + return ( + + {imageUrl ? : } + + ); +} + +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"); +} diff --git a/src/features/room-rocket/components/RoomRocketConfigEditor.jsx b/src/features/room-rocket/components/RoomRocketConfigEditor.jsx index 0126618..e9cac33 100644 --- a/src/features/room-rocket/components/RoomRocketConfigEditor.jsx +++ b/src/features/room-rocket/components/RoomRocketConfigEditor.jsx @@ -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({ - @@ -161,7 +168,9 @@ export function RoomRocketConfigEditor({
L{level.level} - 当前等级溢出燃料作废,点火后下一等级立即开始 + + 当前等级溢出燃料作废,点火后下一等级立即开始 +
- updateLevel(setForm, activeLevelIndex, { fuelThreshold: event.target.value }) + updateLevel(setForm, activeLevelIndex, { + fuelThreshold: event.target.value, + }) } />
updateLevel(setForm, activeLevelIndex, { coverUrl: value })} + onChange={(value) => + updateLevel(setForm, activeLevelIndex, { coverUrl: value }) + } /> updateLevel(setForm, activeLevelIndex, { animationUrl: value })} + onChange={(value) => + updateLevel(setForm, activeLevelIndex, { animationUrl: value }) + } /> updateLevel(setForm, activeLevelIndex, { launchedImageUrl: value })} + onChange={(value) => + updateLevel(setForm, activeLevelIndex, { launchedImageUrl: value }) + } />
@@ -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 ( - ); } @@ -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 }) + } /> updateReward(setForm, levelIndex, poolKey, rewardIndex, { weight: event.target.value })} + onChange={(event) => + updateReward(setForm, levelIndex, poolKey, rewardIndex, { weight: event.target.value }) + } /> updateReward(setForm, levelIndex, poolKey, rewardIndex, { iconUrl: value })} + onChange={(value) => + updateReward(setForm, levelIndex, poolKey, rewardIndex, { iconUrl: value }) + } /> @@ -339,7 +370,12 @@ function FuelRuleEditor({ disabled, form, setForm }) {
未命中特殊规则的礼物按基础燃料来源计算 -
@@ -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 }) + } /> updateFuelRule(setForm, index, { multiplierPpm: event.target.value })} + onChange={(event) => + updateFuelRule(setForm, index, { multiplierPpm: event.target.value }) + } /> onChange(event.target.value)}> + onChange(event.target.value)} + > {options.map(([optionValue, optionLabel]) => ( {optionLabel} diff --git a/src/features/room-rocket/room-rocket.module.css b/src/features/room-rocket/room-rocket.module.css index 40d4567..1a57b13 100644 --- a/src/features/room-rocket/room-rocket.module.css +++ b/src/features/room-rocket/room-rocket.module.css @@ -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; } diff --git a/src/features/room-turnover-reward/components/RoomTurnoverRewardConfigDrawer.jsx b/src/features/room-turnover-reward/components/RoomTurnoverRewardConfigDrawer.jsx index 620f175..69db5b7 100644 --- a/src/features/room-turnover-reward/components/RoomTurnoverRewardConfigDrawer.jsx +++ b/src/features/room-turnover-reward/components/RoomTurnoverRewardConfigDrawer.jsx @@ -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 ( - +

房间流水奖励配置

-
-
结算状态
- setForm((current) => ({ ...current, enabled: event.target.checked }))} - /> -
-
-
- 奖励档位 - -
-
- {(form.tiers || []).map((tier, index) => ( -
-
- 档位 {index + 1} - removeTier(index)} - > - - -
-
- updateTier(index, { tierCode: event.target.value })} - /> - updateTier(index, { tierName: event.target.value })} - /> - - updateTier(index, { thresholdCoinSpent: event.target.value }) - } - /> - - updateTier(index, { rewardCoinAmount: event.target.value }) - } - /> - updateTier(index, { status: event.target.value })} - > - {statusOptions.map(([value, label]) => ( - - {label} - - ))} - - updateTier(index, { sortOrder: event.target.value })} - /> -
-
- ))} - {!form.tiers?.length ?
暂无档位
: null} -
-
+ label="房间流水奖励状态" + uncheckedLabel="关闭" + onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))} + /> + +
+
+ 奖励档位 + +
+ ( + 档位 {index + 1} + ), + }, + { + key: "thresholdCoinSpent", + label: "流水阈值", + render: (tier, index) => ( + + updateTier(index, { thresholdCoinSpent: event.target.value }) + } + /> + ), + }, + { + key: "rewardCoinAmount", + label: "奖励金币", + render: (tier, index) => ( + + updateTier(index, { rewardCoinAmount: event.target.value }) + } + /> + ), + }, + { + key: "status", + label: "状态", + render: (tier, index) => ( + + 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} + /> +
+
- - - + /> + + setForm((current) => ({ ...current, regionId: value || "0" })) + } + /> + + 状态 + + +
+
+ +
+ +
+
活动礼物
+
+ {[0, 1, 2].map((index) => ( +
+
+ 礼物 {index + 1} +
+ updateGift(index, value)} + /> +
+ ))} +
+
+
+
排名奖励
+
+ {[0, 1, 2].map((index) => ( +
+
+ + Top{index + 1} + +
+ updateReward(index, value)} + /> +
+ ))} +
+
+
+
+ + +
, + , ]} /> (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) => } - /> +
+ {items.length ? ( + items.map((item) => ( + + )) + ) : ( + - + )} +
+ ); +} + +function WeeklyStarRowActions({ cycle, onEdit, onLeaderboard, onSettlements, onToggleStatus }) { + const toggleDisabled = cycle.status === "settled" || cycle.status === "settling"; + const toggleLabel = cycle.status === "active" ? "停用周期" : "启用周期"; + return ( +
+ + onEdit(cycle)}> + + + onToggleStatus(cycle)}> + + + onLeaderboard(cycle)}> + + + onSettlements(cycle)}> + + + +
); } @@ -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)); } diff --git a/src/shared/api/types.ts b/src/shared/api/types.ts index 9b6f7e8..52aa7c2 100644 --- a/src/shared/api/types.ts +++ b/src/shared/api/types.ts @@ -332,6 +332,9 @@ export interface RechargeBillDto { export interface RechargeBillUserDto { avatar?: string; + countryCode?: string; + countryDisplayName?: string; + countryName?: string; displayUserId?: string; userId?: string; username?: string; diff --git a/src/shared/hooks/useAdminTime.js b/src/shared/hooks/useAdminTime.js new file mode 100644 index 0000000..40bf61b --- /dev/null +++ b/src/shared/hooks/useAdminTime.js @@ -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 + }; +} diff --git a/src/shared/ui/DataTable.jsx b/src/shared/ui/DataTable.jsx index 0797614..5f1c85c 100644 --- a/src/shared/ui/DataTable.jsx +++ b/src/shared/ui/DataTable.jsx @@ -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) }; } diff --git a/src/shared/ui/RewardTierEditorTable.jsx b/src/shared/ui/RewardTierEditorTable.jsx new file mode 100644 index 0000000..0c6a52b --- /dev/null +++ b/src/shared/ui/RewardTierEditorTable.jsx @@ -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 ( +
+
+ {columns.map((column) => ( + + {column.label} + + ))} + 操作 +
+
+ {rows.map((row, index) => { + const label = getRowLabel(row, index); + return ( +
+ {columns.map((column) => ( +
+ {column.render(row, index)} +
+ ))} +
+ onRemoveTier(index)} + > + + +
+
+ ); + })} + {!rows.length ?
{emptyText}
: null} +
+
+ ); +} diff --git a/src/shared/ui/TimeText.jsx b/src/shared/ui/TimeText.jsx index 5379df8..591e1f8 100644 --- a/src/shared/ui/TimeText.jsx +++ b/src/shared/ui/TimeText.jsx @@ -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 ( + + {text} + + ); +} + +export function TimeRangeText({ className, component: Component = "span", end, separator = " - ", start }) { + const { formatRange, timeZoneLabel } = useAdminTime(); + const text = formatRange(start, end, separator); return ( diff --git a/src/shared/ui/TimeText.test.jsx b/src/shared/ui/TimeText.test.jsx new file mode 100644 index 0000000..72d890e --- /dev/null +++ b/src/shared/ui/TimeText.test.jsx @@ -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( + + + + ); + + 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 ( + <> + + + + + + + + + ); +} diff --git a/src/shared/ui/UploadField.jsx b/src/shared/ui/UploadField.jsx index 0eb9bcd..0d2fb7f 100644 --- a/src/shared/ui/UploadField.jsx +++ b/src/shared/ui/UploadField.jsx @@ -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 ( -
+
{label} {assetKindLabel(assetKind, isImage)} diff --git a/src/shared/ui/UploadField.module.css b/src/shared/ui/UploadField.module.css index c8a4511..60481d9 100644 --- a/src/shared/ui/UploadField.module.css +++ b/src/shared/ui/UploadField.module.css @@ -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); } diff --git a/src/styles/shared-ui.css b/src/styles/shared-ui.css index a750367..b651727 100644 --- a/src/styles/shared-ui.css +++ b/src/styles/shared-ui.css @@ -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);