170 lines
6.4 KiB
JavaScript
170 lines
6.4 KiB
JavaScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { drawStatusOptions } from "@/features/lucky-gift/constants.js";
|
||
import { AdminFilterResetButton, AdminFilterSelect, AdminListToolbar, AdminSearchBox } from "@/shared/ui/AdminListLayout.jsx";
|
||
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
||
import { TimeText } from "@/shared/ui/TimeText.jsx";
|
||
import { fetchLuckyGiftDraws } from "../api.js";
|
||
import { formatMultiplierFromPPM, formatNumber } from "../format.js";
|
||
import { OpsStatusBadge } from "./OpsStatusBadge.jsx";
|
||
|
||
const PAGE_SIZE = 20;
|
||
|
||
const initialFilters = { poolId: "", status: "", userId: "" };
|
||
|
||
export function DrawsView({ apps, defaultAppCode, poolIDsByApp }) {
|
||
// 抽奖流水量大且服务端分页,必须选定应用后按需拉取,不能像配置那样一次拉全。
|
||
const [appCode, setAppCode] = useState(defaultAppCode);
|
||
const [filters, setFilters] = useState(initialFilters);
|
||
const [page, setPage] = useState(1);
|
||
const [state, setState] = useState({ error: "", items: [], loading: false, total: 0 });
|
||
|
||
useEffect(() => {
|
||
// 首屏 dashboard 加载完成前 defaultAppCode 为空,就绪后自动选中第一个应用。
|
||
setAppCode((current) => current || defaultAppCode);
|
||
}, [defaultAppCode]);
|
||
|
||
useEffect(() => {
|
||
if (!appCode) {
|
||
return undefined;
|
||
}
|
||
let cancelled = false;
|
||
setState((current) => ({ ...current, error: "", loading: true }));
|
||
fetchLuckyGiftDraws({ appCode, page, pageSize: PAGE_SIZE, poolId: filters.poolId, status: filters.status, userId: filters.userId })
|
||
.then((result) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
setState((current) => ({
|
||
error: "",
|
||
// 滚动加载:翻页时向已加载列表追加;筛选变化会把 page 重置为 1,从头替换。
|
||
items: page > 1 ? [...current.items, ...result.items] : result.items,
|
||
loading: false,
|
||
total: result.total
|
||
}));
|
||
})
|
||
.catch((error) => {
|
||
if (cancelled) {
|
||
return;
|
||
}
|
||
setState((current) => ({ ...current, error: error.message || "获取抽奖记录失败", loading: false }));
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [appCode, filters, page]);
|
||
|
||
const changeAppCode = useCallback((nextAppCode) => {
|
||
setAppCode(nextAppCode);
|
||
// 奖池归属于应用,切换应用后旧奖池筛选一定失效,一并重置。
|
||
setFilters(initialFilters);
|
||
setPage(1);
|
||
}, []);
|
||
|
||
const changeFilter = useCallback((key, value) => {
|
||
setFilters((current) => ({ ...current, [key]: value }));
|
||
setPage(1);
|
||
}, []);
|
||
|
||
const resetFilters = useCallback(() => {
|
||
setFilters(initialFilters);
|
||
setPage(1);
|
||
}, []);
|
||
|
||
const appOptions = useMemo(() => apps.map((item) => [item.app_code ?? item.appCode, item.app_code ?? item.appCode]), [apps]);
|
||
const poolOptions = useMemo(() => (poolIDsByApp.get(appCode) || []).map((poolID) => [poolID, poolID]), [appCode, poolIDsByApp]);
|
||
const hasActiveFilters = Boolean(filters.poolId || filters.status || filters.userId);
|
||
|
||
return (
|
||
<div className="ops-view">
|
||
<AdminListToolbar
|
||
filters={
|
||
<>
|
||
<AdminFilterSelect label="应用" options={appOptions} value={appCode || ""} onChange={changeAppCode} />
|
||
<AdminFilterSelect
|
||
label="奖池"
|
||
options={[["", "全部奖池"], ...poolOptions]}
|
||
value={filters.poolId}
|
||
onChange={(value) => changeFilter("poolId", value)}
|
||
/>
|
||
<AdminFilterSelect
|
||
label="发放状态"
|
||
options={[["", "全部状态"], ...drawStatusOptions]}
|
||
value={filters.status}
|
||
onChange={(value) => changeFilter("status", value)}
|
||
/>
|
||
<AdminSearchBox
|
||
label="用户"
|
||
placeholder="用户 ID / 外部用户 ID"
|
||
value={filters.userId}
|
||
onChange={(value) => changeFilter("userId", value)}
|
||
/>
|
||
<AdminFilterResetButton disabled={!hasActiveFilters} onClick={resetFilters} />
|
||
</>
|
||
}
|
||
/>
|
||
{state.error ? <div className="ops-alert" role="alert">{state.error}</div> : null}
|
||
<DataTable
|
||
columns={drawColumns}
|
||
emptyLabel={state.loading ? "加载中..." : "暂无抽奖记录"}
|
||
items={state.items}
|
||
minWidth="1080px"
|
||
pagination={{ onPageChange: setPage, page, total: state.total }}
|
||
rowKey={(item) => item.draw_id ?? item.drawId}
|
||
title="抽奖记录"
|
||
total={state.total}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const drawColumns = [
|
||
{
|
||
key: "created_at_ms",
|
||
label: "抽奖时间",
|
||
render: (item) => <TimeText value={item.created_at_ms ?? item.createdAtMs} />,
|
||
width: "minmax(150px, 1fr)"
|
||
},
|
||
{ key: "app_code", label: "应用", render: (item) => item.app_code ?? item.appCode, width: "minmax(80px, 0.6fr)" },
|
||
{
|
||
key: "user",
|
||
label: "用户",
|
||
// 外部接入应用只有 external_user_id,内部应用只有数字 user_id,两者互斥,取有值的一侧。
|
||
render: (item) => item.external_user_id || item.externalUserId || item.user_id || item.userId || "-",
|
||
width: "minmax(120px, 1fr)"
|
||
},
|
||
{ key: "pool_id", label: "奖池", render: (item) => item.pool_id ?? item.poolId, width: "minmax(100px, 0.8fr)" },
|
||
{ key: "gift_id", label: "礼物", render: (item) => item.gift_id ?? item.giftId, width: "minmax(100px, 0.8fr)" },
|
||
{
|
||
key: "multiplier_ppm",
|
||
label: "命中倍率",
|
||
render: (item) => formatMultiplierFromPPM(item.multiplier_ppm ?? item.multiplierPpm),
|
||
width: "minmax(88px, 0.6fr)"
|
||
},
|
||
{ key: "effective_reward_coins", label: "中奖金币", render: (item) => formatNumber(item.effective_reward_coins ?? item.effectiveRewardCoins) },
|
||
{
|
||
key: "reward_status",
|
||
label: "发放状态",
|
||
render: (item) => renderRewardStatus(item.reward_status ?? item.rewardStatus),
|
||
width: "minmax(124px, 0.8fr)"
|
||
},
|
||
{
|
||
key: "rule_version",
|
||
label: "规则版本",
|
||
render: (item) => `v${item.rule_version ?? item.ruleVersion ?? "-"}`,
|
||
width: "minmax(80px, 0.5fr)"
|
||
}
|
||
];
|
||
|
||
function renderRewardStatus(status) {
|
||
if (status === "granted") {
|
||
return <OpsStatusBadge label="已发放" tone="succeeded" />;
|
||
}
|
||
if (status === "failed") {
|
||
return <OpsStatusBadge label="发放失败" tone="danger" />;
|
||
}
|
||
if (status === "pending") {
|
||
return <OpsStatusBadge label="发放中" tone="warning" />;
|
||
}
|
||
return <OpsStatusBadge label={status || "-"} />;
|
||
}
|