757 lines
29 KiB
JavaScript
757 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 { 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 || "");
|
||
}
|