2026-06-06 13:42:26 +08:00

757 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { InlineEditableCell } from "@/shared/ui/InlineEditableCell.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 { ResourceSelectField } from "@/features/resources/components/ResourceSelectDrawer.jsx";
import { useGiftListPage } from "@/features/resources/hooks/useResourcePages.js";
import styles from "@/features/resources/resources.module.css";
const effectOptions = [
["animation", "动画"],
["music", "音乐"],
["global_broadcast", "全局广播"],
];
const effectLabels = Object.fromEntries(effectOptions);
const weekLabels = ["一", "二", "三", "四", "五", "六", "日"];
const baseColumns = (giftTypeOptions) => [
{
key: "id",
label: "ID",
width: "minmax(88px, 0.4fr)",
render: (gift) => gift.giftId || "-",
},
{
key: "gift",
label: "礼物",
width: "minmax(260px, 1.4fr)",
render: (gift) => <GiftIdentity gift={gift} />,
},
{
key: "type",
label: "类型",
width: "minmax(130px, 0.6fr)",
render: (gift) => giftTypeLabel(gift.giftTypeCode, giftTypeOptions),
},
{
key: "price",
label: "价格",
width: "minmax(130px, 0.6fr)",
render: (gift) => giftPriceLabel(gift),
},
{
key: "regions",
label: "区域",
width: "minmax(160px, 0.8fr)",
},
{
key: "sortOrder",
label: "排序",
width: "minmax(96px, 0.45fr)",
},
{
key: "effects",
label: "有效期 / 特效",
width: "minmax(210px, 1.1fr)",
render: (gift) => (
<div className={styles.stack}>
{giftEffectDetails(gift).map((item, index) => (
<span className={index > 0 ? styles.meta : undefined} key={item}>
{item}
</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 === "type") {
return {
...column,
filter: createOptionsColumnFilter({
emptyLabel: "全部类型",
loading: page.giftTypesLoading,
options: giftTypeOptions.map((item) => ({
label: item.displayName || item.tabName || item.tabKey,
value: item.tabKey,
})),
placeholder: "搜索类型",
value: page.giftTypeCode,
onChange: page.changeGiftTypeCode,
}),
};
}
if (column.key === "sortOrder") {
return {
...column,
render: (gift) => <GiftSortOrderCell 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="1440px"
pagination={
total > 0
? {
page: page.page,
pageSize: page.data.pageSize || 50,
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 GiftSortOrderCell({ gift, page }) {
const originalValue = String(gift.sortOrder ?? 0);
const saving = Boolean(page.giftSortSavingIds?.[gift.giftId]);
return (
<InlineEditableCell
buttonTitle="点击编辑排序"
editable={page.abilities.canUpdateGift}
inputProps={{ min: 0, step: 1 }}
maxWidth="76px"
normalizeValue={(value) => String(value || "0").trim() || "0"}
saving={saving}
type="number"
value={originalValue}
width="76px"
onSave={(nextValue) => page.saveGiftSortOrder(gift, nextValue)}
/>
);
}
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))">
<ResourceSelectField
disabled={disabled || page.resourceOptionsLoading}
drawerTitle="选择礼物资源"
label="礼物资源"
loading={page.resourceOptionsLoading}
resources={page.resourceOptions}
value={form.resourceId}
onChange={(resourceId) => page.selectGiftResource(resourceId)}
/>
{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 label="价格" required type="number" value={form.coinPrice} />
<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);
const resourceCode = String(gift.resource?.resourceCode || "").trim();
const title = `${gift.name || "-"}${resourceCode ? `${resourceCode}` : ""}`;
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}>{title}</span>
</div>
</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 giftPriceLabel(gift) {
return `金币 · ${formatNumber(gift.coinPrice)}`;
}
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 giftEffectDetails(gift) {
const details = [];
const effective = effectiveRangeLabel(gift);
if (effective) {
details.push(effective);
}
const effects = effectTypesLabel(gift.effectTypes);
if (effects !== "无特效") {
details.push(effects);
}
return details.length ? details : ["-"];
}
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 || "");
}