2026-05-19 18:04:08 +08:00

758 lines
29 KiB
JavaScript

import Add from "@mui/icons-material/Add";
import CalendarMonthOutlined from "@mui/icons-material/CalendarMonthOutlined";
import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined";
import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined";
import EditOutlined from "@mui/icons-material/EditOutlined";
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment";
import MenuItem from "@mui/material/MenuItem";
import Popover from "@mui/material/Popover";
import { AdminSwitch } from "@/shared/ui/AdminSwitch.jsx";
import TextField from "@mui/material/TextField";
import { useMemo, useState } from "react";
import {
AdminFormDialog,
AdminFormFieldGrid,
AdminFormSection,
AdminFormSwitchField,
} from "@/shared/ui/AdminFormDialog.jsx";
import { Button } from "@/shared/ui/Button.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { MultiValueAutocomplete } from "@/shared/ui/MultiValueAutocomplete.jsx";
import {
AdminActionIconButton,
AdminListBody,
AdminListPage,
AdminRowActions,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { createRegionColumnFilter } from "@/shared/ui/RegionFilterControl.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
import { defaultGiftTypeOptions, resourceStatusFilters } from "@/features/resources/constants.js";
import { GiftTypeConfigDialog } from "@/features/resources/components/GiftTypeConfigDialog.jsx";
import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
import styles from "@/features/resources/resources.module.css";
const chargeAssetOptions = [
["COIN", "金币"],
["DIAMOND", "钻石"],
];
const effectOptions = [
["animation", "动画"],
["music", "音乐"],
["global_broadcast", "全局广播"],
];
const effectLabels = Object.fromEntries(effectOptions);
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
const baseColumns = (giftTypeOptions) => [
{
key: "gift",
label: "礼物",
width: "minmax(260px, 1.4fr)",
render: (gift) => <GiftIdentity gift={gift} />,
},
{
key: "resource",
label: "资源",
width: "minmax(220px, 1.1fr)",
render: (gift) => <ResourceRef gift={gift} />,
},
{
key: "price",
label: "类型 / 价格",
width: "minmax(150px, 0.75fr)",
render: (gift) => (
<div className={styles.stack}>
<span>{giftTypeLabel(gift.giftTypeCode, giftTypeOptions)}</span>
<span className={styles.meta}>
{chargeAssetLabel(gift.chargeAssetType)} · {formatNumber(gift.coinPrice)}
</span>
</div>
),
},
{
key: "income",
label: "积分 / 热度",
width: "minmax(140px, 0.8fr)",
render: (gift) => (
<div className={styles.stack}>
<span>{formatNumber(gift.giftPointAmount)}</span>
<span className={styles.meta}>{formatNumber(gift.heatValue)}</span>
</div>
),
},
{
key: "regions",
label: "区域",
width: "minmax(160px, 0.8fr)",
},
{
key: "effects",
label: "有效期 / 特效",
width: "minmax(210px, 1.1fr)",
render: (gift) => (
<div className={styles.stack}>
<span>{effectiveRangeLabel(gift)}</span>
<span className={styles.meta}>{effectTypesLabel(gift.effectTypes)}</span>
</div>
),
},
{
key: "status",
label: "状态",
width: "minmax(92px, 0.55fr)",
},
{
key: "time",
label: "更新时间",
width: "minmax(170px, 0.9fr)",
render: (gift) => formatMillis(gift.updatedAtMs || gift.createdAtMs),
},
{
key: "actions",
label: "操作",
width: "minmax(76px, 0.4fr)",
},
];
export function GiftListPage() {
const page = useGiftListPage();
const items = page.data.items || [];
const total = page.data.total || 0;
const isEditing = page.activeAction === "edit";
const giftRegionOptions = [{ label: "全局 · 0", value: "0" }, ...page.regionOptions];
const regionLabelById = buildRegionLabelMap(giftRegionOptions);
const giftTypeOptions = page.giftTypeOptions?.length ? page.giftTypeOptions : defaultGiftTypeOptions;
const tableColumns = baseColumns(giftTypeOptions).map((column) => {
if (column.key === "regions") {
return {
...column,
filter: createRegionColumnFilter({
loading: page.loadingRegions,
options: giftRegionOptions,
value: page.regionId,
onChange: page.changeRegionId,
}),
render: (gift) => <RegionTags regionIds={gift.regionIds} regionLabelById={regionLabelById} />,
};
}
if (column.key === "status") {
return {
...column,
filter: createOptionsColumnFilter({
options: resourceStatusFilters,
placeholder: "搜索状态",
value: page.status,
onChange: page.changeStatus,
}),
render: (gift) => <GiftStatusSwitch gift={gift} page={page} />,
};
}
if (column.key === "gift") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "搜索礼物名称、礼物 ID",
value: page.query,
onChange: page.changeQuery,
}),
};
}
if (column.key === "actions") {
return {
...column,
render: (gift) => <GiftRowActions gift={gift} page={page} />,
};
}
return column;
});
return (
<AdminListPage>
<AdminListToolbar
actions={
<>
{page.abilities.canUpdateGift ? (
<AdminActionIconButton label="礼物类型" onClick={page.openGiftTypeDialog}>
<SettingsOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
{page.abilities.canCreateGift ? (
<AdminActionIconButton label="添加礼物" primary onClick={page.openCreateGift}>
<Add fontSize="small" />
</AdminActionIconButton>
) : null}
</>
}
/>
<DataState error={page.error} loading={page.loading} onRetry={page.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
items={items}
minWidth="1480px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 10,
total,
onPageChange: page.setPage,
}
: undefined
}
rowKey={(gift) => gift.giftId}
/>
</AdminListBody>
</DataState>
<GiftFormDialog
disabled={isEditing ? !page.abilities.canUpdateGift : !page.abilities.canCreateGift}
form={page.form}
loading={isEditing ? page.loadingAction === "gift-edit" : page.loadingAction === "gift-create"}
mode={isEditing ? "edit" : "create"}
open={page.activeAction === "create" || page.activeAction === "edit"}
page={page}
regionOptions={giftRegionOptions}
onClose={page.closeAction}
onSubmit={page.submitGift}
/>
<GiftTypeConfigDialog
disabled={!page.abilities.canUpdateGift}
form={page.giftTypeForm}
loading={page.loadingAction === "gift-types"}
open={page.giftTypeDialogOpen}
optionsLoading={page.giftTypesLoading}
onChangeItem={page.updateGiftTypeFormItem}
onClose={page.closeGiftTypeDialog}
onSubmit={page.submitGiftTypes}
/>
</AdminListPage>
);
}
function GiftRowActions({ gift, page }) {
return (
<AdminRowActions>
<AdminActionIconButton
disabled={!page.abilities.canUpdateGift}
label="编辑礼物"
onClick={() => page.openEditGift(gift)}
>
<EditOutlined fontSize="small" />
</AdminActionIconButton>
</AdminRowActions>
);
}
function GiftStatusSwitch({ gift, page }) {
const checked = gift.status === "active";
return (
<AdminSwitch
checked={checked}
disabled={!page.abilities.canStatusGift || page.loadingAction === `gift-status-${gift.giftId}`}
inputProps={{ "aria-label": checked ? "禁用礼物" : "启用礼物" }}
onChange={(event) => page.toggleGift(gift, event.target.checked)}
/>
);
}
function GiftFormDialog({ disabled, form, loading, mode, onClose, onSubmit, open, page, regionOptions }) {
const submitDisabled =
disabled || loading || page.resourceOptionsLoading || page.loadingRegions || page.giftTypesLoading;
const showGiftIdentityFields = mode === "edit" || Boolean(form.resourceId);
return (
<AdminFormDialog
loading={loading}
open={open}
size="large"
submitDisabled={submitDisabled}
title={mode === "edit" ? "编辑礼物" : "添加礼物"}
onClose={onClose}
onSubmit={onSubmit}
>
<AdminFormSection title="资源信息">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={disabled || page.resourceOptionsLoading}
label="礼物资源"
required
select
value={form.resourceId}
onChange={(event) => page.selectGiftResource(event.target.value)}
>
{page.resourceOptions.map((resource) => (
<MenuItem key={resource.resourceId} value={String(resource.resourceId)}>
{resource.name || resource.resourceCode || resource.resourceId}
</MenuItem>
))}
</TextField>
{showGiftIdentityFields ? (
<>
<TextField
disabled={disabled}
label="礼物名称"
required
value={form.name}
onChange={(event) => page.setForm({ ...form, name: event.target.value })}
/>
<TextField
disabled={disabled || mode === "edit"}
label="礼物 ID"
required
value={form.giftId}
onChange={(event) => page.setForm({ ...form, giftId: event.target.value })}
/>
</>
) : null}
<TextField
disabled={disabled || page.giftTypesLoading}
label="礼物类型"
required
select
value={form.giftTypeCode}
onChange={(event) => page.setForm({ ...form, giftTypeCode: event.target.value })}
>
{page.giftTypeOptions.map((item) => (
<MenuItem key={item.tabKey} value={item.tabKey}>
{item.displayName || item.tabKey}
</MenuItem>
))}
</TextField>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="价格与排序">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<TextField
disabled={disabled}
label="收费类型"
required
select
value={form.chargeAssetType}
onChange={(event) => page.setForm({ ...form, chargeAssetType: event.target.value })}
>
{chargeAssetOptions.map(([value, label]) => (
<MenuItem key={value} value={value}>
{label}
</MenuItem>
))}
</TextField>
<TextField
disabled={disabled}
label="价格"
required
type="number"
value={form.coinPrice}
onChange={(event) => page.changeGiftPrice(event.target.value)}
/>
<TextField
disabled={disabled}
label="积分"
type="number"
value={form.giftPointAmount}
onChange={(event) => page.setForm({ ...form, giftPointAmount: event.target.value })}
/>
<TextField
disabled={disabled}
label="热度"
type="number"
value={form.heatValue}
onChange={(event) => page.setForm({ ...form, heatValue: event.target.value })}
/>
<TextField
disabled={disabled}
label="排序"
type="number"
value={form.sortOrder}
onChange={(event) => page.setForm({ ...form, sortOrder: event.target.value })}
/>
<AdminFormSwitchField
checked={form.enabled}
checkedLabel="启用"
className={styles.giftSwitch}
disabled={disabled}
label="礼物状态"
uncheckedLabel="禁用"
onChange={(checked) => page.setForm({ ...form, enabled: checked })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
<AdminFormSection title="投放与时间">
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
<GiftRegionSelect
disabled={disabled || page.loadingRegions}
labels={buildRegionLabelMap(regionOptions)}
options={regionOptions}
value={form.regionIds}
onChange={(regionIds) => page.setForm({ ...form, regionIds })}
/>
<DateTimeField
disabled={disabled}
label="有效开始时间"
value={form.effectiveFrom}
onChange={(effectiveFrom) => page.setForm({ ...form, effectiveFrom })}
/>
<DateTimeField
disabled={disabled}
label="有效结束时间"
value={form.effectiveTo}
onChange={(effectiveTo) => page.setForm({ ...form, effectiveTo })}
/>
<GiftEffectSelect
disabled={disabled}
value={form.effectTypes}
onChange={(effectTypes) => page.setForm({ ...form, effectTypes })}
/>
</AdminFormFieldGrid>
</AdminFormSection>
</AdminFormDialog>
);
}
function DateTimeField({ disabled, label, onChange, value }) {
const parsedValue = parseLocalDateTime(value);
const [anchorEl, setAnchorEl] = useState(null);
const [draftDate, setDraftDate] = useState(parsedValue || new Date());
const [viewDate, setViewDate] = useState(parsedValue || new Date());
const days = useMemo(() => calendarDays(viewDate), [viewDate]);
const open = Boolean(anchorEl);
const openPicker = (event) => {
if (disabled) {
return;
}
const baseDate = parseLocalDateTime(value) || new Date();
setDraftDate(baseDate);
setViewDate(new Date(baseDate.getFullYear(), baseDate.getMonth(), 1));
setAnchorEl(event.currentTarget);
};
const closePicker = () => setAnchorEl(null);
const changeMonth = (offset) => {
setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1));
};
const changeDay = (day) => {
setDraftDate(
(current) =>
new Date(day.getFullYear(), day.getMonth(), day.getDate(), current.getHours(), current.getMinutes()),
);
setViewDate(new Date(day.getFullYear(), day.getMonth(), 1));
};
const changeTime = (unit, nextValue) => {
setDraftDate((current) => {
const nextDate = new Date(current);
if (unit === "hour") {
nextDate.setHours(Number(nextValue));
} else {
nextDate.setMinutes(Number(nextValue));
}
return nextDate;
});
};
return (
<>
<TextField
className={styles.dateTimeField}
disabled={disabled}
inputProps={{ readOnly: true }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<CalendarMonthOutlined fontSize="small" />
</InputAdornment>
),
}}
label={label}
placeholder="长期有效"
value={formatDateTimeInputLabel(value)}
onClick={openPicker}
/>
<Popover
anchorEl={anchorEl}
anchorOrigin={{ horizontal: "left", vertical: "bottom" }}
open={open}
transformOrigin={{ horizontal: "left", vertical: "top" }}
onClose={closePicker}
>
<div className={styles.datePopover}>
<div className={styles.calendarHeader}>
<IconButton aria-label="上个月" size="small" onClick={() => changeMonth(-1)}>
<ChevronLeftOutlined fontSize="small" />
</IconButton>
<span className={styles.calendarTitle}>{monthTitle(viewDate)}</span>
<IconButton aria-label="下个月" size="small" onClick={() => changeMonth(1)}>
<ChevronRightOutlined fontSize="small" />
</IconButton>
</div>
<div className={styles.calendarGrid}>
{weekLabels.map((weekday) => (
<span className={styles.weekday} key={weekday}>
{weekday}
</span>
))}
{days.map((day) => {
const isOutside = day.getMonth() !== viewDate.getMonth();
const isSelected = sameDay(day, draftDate);
return (
<button
className={[
styles.dayButton,
isOutside ? styles.dayOutside : "",
isSelected ? styles.daySelected : "",
]
.filter(Boolean)
.join(" ")}
key={day.toISOString()}
type="button"
onClick={() => changeDay(day)}
>
{day.getDate()}
</button>
);
})}
</div>
<div className={styles.timeGrid}>
<TextField
label="时"
select
size="small"
value={String(draftDate.getHours())}
onChange={(event) => changeTime("hour", event.target.value)}
>
{Array.from({ length: 24 }, (_, hour) => (
<MenuItem key={hour} value={String(hour)}>
{twoDigits(hour)}
</MenuItem>
))}
</TextField>
<TextField
label="分"
select
size="small"
value={String(draftDate.getMinutes())}
onChange={(event) => changeTime("minute", event.target.value)}
>
{Array.from({ length: 60 }, (_, minute) => (
<MenuItem key={minute} value={String(minute)}>
{twoDigits(minute)}
</MenuItem>
))}
</TextField>
</div>
<div className={styles.popoverActions}>
<Button
onClick={() => {
onChange("");
closePicker();
}}
>
清空
</Button>
<Button
variant="primary"
onClick={() => {
onChange(toDatetimeLocalValue(draftDate));
closePicker();
}}
>
确定
</Button>
</div>
</div>
</Popover>
</>
);
}
function GiftRegionSelect({ disabled, labels, onChange, options, value }) {
const selected = value || [];
const mergedOptions = [...new Set([...options.map((option) => option.value), ...selected])].sort(
(left, right) => Number(left) - Number(right),
);
return (
<MultiValueAutocomplete
disabled={disabled}
label="区域"
labels={labels}
options={mergedOptions}
required
value={selected}
onChange={onChange}
/>
);
}
function GiftEffectSelect({ disabled, onChange, value }) {
const selected = value || [];
return (
<MultiValueAutocomplete
disabled={disabled}
label="特效"
labels={effectLabels}
options={effectOptions.map(([optionValue]) => optionValue)}
value={selected}
onChange={onChange}
/>
);
}
function RegionTags({ regionIds = [], regionLabelById }) {
const items = [...new Set(regionIds)].sort((left, right) => Number(left) - Number(right));
if (!items.length) {
return "-";
}
return (
<div className="admin-tag-list">
{items.map((regionId) => (
<span className="admin-tag" key={regionId}>
{regionLabelById[String(regionId)] || regionId}
</span>
))}
</div>
);
}
function GiftIdentity({ gift }) {
const preview = imageURL(gift.resource?.previewUrl || gift.resource?.assetUrl);
return (
<div className={styles.identity}>
<span className={styles.thumb}>
{preview ? <img src={preview} alt="" /> : <CardGiftcardOutlined fontSize="small" />}
</span>
<div className={styles.identityText}>
<span className={styles.name}>{gift.name || "-"}</span>
<span className={styles.meta}>{gift.giftId}</span>
</div>
</div>
);
}
function ResourceRef({ gift }) {
const resource = gift.resource || {};
return (
<div className={styles.stack}>
<span>{resource.name || "-"}</span>
<span className={styles.meta}>{resource.resourceCode || gift.resourceId || "-"}</span>
</div>
);
}
function imageURL(value) {
const url = String(value || "").trim();
if (!url) {
return "";
}
return /\.(avif|gif|jpe?g|png|webp)(\?|#|$)/i.test(url) ? url : "";
}
function formatNumber(value) {
return Number(value || 0).toLocaleString("zh-CN");
}
function giftTypeLabel(value, options = defaultGiftTypeOptions) {
return options.find((option) => option.tabKey === value)?.displayName || value || "普通礼物";
}
function chargeAssetLabel(value) {
return chargeAssetOptions.find(([optionValue]) => optionValue === value)?.[1] || value || "金币";
}
function effectTypeLabel(value) {
return effectOptions.find(([optionValue]) => optionValue === value)?.[1] || value;
}
function effectTypesLabel(values) {
const list = values || [];
return list.length ? list.map(effectTypeLabel).join("、") : "无特效";
}
function parseLocalDateTime(value) {
const normalizedValue = String(value || "").trim();
if (!normalizedValue) {
return null;
}
const [datePart, timePart = "00:00"] = normalizedValue.split("T");
const [year, month, day] = datePart.split("-").map(Number);
const [hour = 0, minute = 0] = timePart.split(":").map(Number);
if (![year, month, day, hour, minute].every(Number.isFinite)) {
return null;
}
const date = new Date(year, month - 1, day, hour, minute);
return Number.isNaN(date.getTime()) ? null : date;
}
function toDatetimeLocalValue(date) {
return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())}T${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`;
}
function formatDateTimeInputLabel(value) {
const date = parseLocalDateTime(value);
if (!date) {
return "";
}
return `${date.getFullYear()}-${twoDigits(date.getMonth() + 1)}-${twoDigits(date.getDate())} ${twoDigits(date.getHours())}:${twoDigits(date.getMinutes())}`;
}
function calendarDays(viewDate) {
const firstDay = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1);
const offset = (firstDay.getDay() + 6) % 7;
const startDate = new Date(firstDay);
startDate.setDate(firstDay.getDate() - offset);
return Array.from({ length: 42 }, (_, index) => {
const day = new Date(startDate);
day.setDate(startDate.getDate() + index);
return day;
});
}
function sameDay(left, right) {
return (
left.getFullYear() === right.getFullYear() &&
left.getMonth() === right.getMonth() &&
left.getDate() === right.getDate()
);
}
function monthTitle(date) {
return `${date.getFullYear()}${date.getMonth() + 1}`;
}
function twoDigits(value) {
return String(value).padStart(2, "0");
}
function effectiveRangeLabel(gift) {
if (!gift.effectiveFromMs && !gift.effectiveToMs) {
return "长期有效";
}
return `${shortDateTime(gift.effectiveFromMs) || "不限"} - ${shortDateTime(gift.effectiveToMs) || "不限"}`;
}
function shortDateTime(value) {
if (!value) {
return "";
}
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(Number(value)));
}
function buildRegionLabelMap(options = []) {
return options.reduce((acc, option) => {
acc[option.value] = shortRegionLabel(option.label);
return acc;
}, {});
}
function shortRegionLabel(label) {
return String(label || "").split(" · ")[0] || String(label || "");
}