feat: update voice room red packet admin UI

This commit is contained in:
zhx 2026-05-29 17:36:33 +08:00
parent c22b23cb8c
commit efaaef7e86
9 changed files with 183 additions and 39 deletions

View File

@ -132,7 +132,7 @@
"x-permissions": ["user-leaderboard:view"]
}
},
"/admin/activity/red-packets": {
"/resident-activity/voice-room-red-packet/records": {
"get": {
"operationId": "listRedPackets",
"responses": {
@ -144,7 +144,7 @@
"x-permissions": ["red-packet:view"]
}
},
"/admin/activity/red-packets/{packet_id}": {
"/resident-activity/voice-room-red-packet/records/{packet_id}": {
"get": {
"operationId": "getRedPacket",
"responses": {
@ -166,7 +166,7 @@
"x-permissions": ["red-packet:view"]
}
},
"/admin/activity/red-packets/config": {
"/resident-activity/voice-room-red-packet/config": {
"get": {
"operationId": "getRedPacketConfig",
"responses": {
@ -177,7 +177,7 @@
"x-permission": "red-packet:view",
"x-permissions": ["red-packet:view"]
},
"put": {
"post": {
"operationId": "updateRedPacketConfig",
"responses": {
"200": {
@ -188,6 +188,18 @@
"x-permissions": ["red-packet:update"]
}
},
"/resident-activity/voice-room-red-packet/refund/retry": {
"post": {
"operationId": "retryRedPacketRefund",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "red-packet:update",
"x-permissions": ["red-packet:update"]
}
},
"/admin/activity/lucky-gifts/v2/config": {
"get": {
"operationId": "getLuckyGiftConfig",

View File

@ -10,6 +10,7 @@ export interface RedPacketConfigDto {
delayedOpenSeconds: number;
expireSeconds: number;
dailySendLimit: number;
ruleUrl?: string;
updatedByAdminId?: number;
createdAtMs?: number;
updatedAtMs?: number;
@ -20,8 +21,9 @@ export interface RedPacketConfigPayload {
count_tiers: number[];
amount_tiers: number[];
delayed_open_seconds: number;
expire_seconds: number;
expire_seconds?: number;
daily_send_limit: number;
rule_url?: string;
}
export interface RedPacketClaimDto {
@ -54,15 +56,20 @@ export interface RedPacketDto {
expiresAtMs: number;
createdAtMs: number;
updatedAtMs?: number;
refundAmount?: number;
refundStatus?: string;
refundedAtMs?: number;
claims?: RedPacketClaimDto[];
}
export interface RedPacketQuery extends PageQuery {
room_id?: string;
sender_user_id?: string;
roomId?: string;
senderUserId?: string;
region_id?: string;
packet_type?: string;
packetMode?: string;
status?: string;
startTime?: string;
endTime?: string;
}
type RawConfig = RedPacketConfigDto & Record<string, unknown>;
@ -102,6 +109,14 @@ export function getRedPacket(packetId: string): Promise<RedPacketDto> {
}).then(normalizePacket);
}
export function retryRedPacketRefund(packetId: string): Promise<unknown> {
const endpoint = API_ENDPOINTS.retryRedPacketRefund;
return apiRequest(apiEndpointPath(API_OPERATIONS.retryRedPacketRefund), {
body: { packetNo: packetId },
method: endpoint.method,
});
}
function normalizeConfig(item: RawConfig): RedPacketConfigDto {
return {
appCode: stringValue(item.appCode ?? item.app_code),
@ -111,6 +126,7 @@ function normalizeConfig(item: RawConfig): RedPacketConfigDto {
delayedOpenSeconds: numberValue(item.delayedOpenSeconds ?? item.delayed_open_seconds),
expireSeconds: numberValue(item.expireSeconds ?? item.expire_seconds),
dailySendLimit: numberValue(item.dailySendLimit ?? item.daily_send_limit),
ruleUrl: stringValue(item.ruleUrl ?? item.rule_url),
updatedByAdminId: numberValue(item.updatedByAdminId ?? item.updated_by_admin_id),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
@ -136,6 +152,9 @@ function normalizePacket(item: RawPacket): RedPacketDto {
expiresAtMs: numberValue(item.expiresAtMs ?? item.expires_at_ms),
createdAtMs: numberValue(item.createdAtMs ?? item.created_at_ms),
updatedAtMs: numberValue(item.updatedAtMs ?? item.updated_at_ms),
refundAmount: numberValue(item.refundAmount ?? item.refund_amount),
refundStatus: stringValue(item.refundStatus ?? item.refund_status),
refundedAtMs: numberValue(item.refundedAtMs ?? item.refunded_at_ms),
claims: rawClaims.map(normalizeClaim),
};
}

View File

@ -61,30 +61,38 @@ export function RedPacketConfigDrawer({
<div className="form-drawer__grid">
<TextField
disabled={disabled}
inputProps={{ min: 0 }}
label="延迟红包秒数"
slotProps={{ htmlInput: { min: 0 } }}
type="number"
value={form.delayedOpenSeconds}
onChange={(event) => updateValue("delayedOpenSeconds", event.target.value)}
/>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label="过期退款秒数"
disabled
label="过期退款秒数(固定)"
slotProps={{ htmlInput: { min: 1 } }}
type="number"
value={form.expireSeconds}
onChange={(event) => updateValue("expireSeconds", event.target.value)}
/>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label="每用户每日最多发送"
slotProps={{ htmlInput: { min: 1 } }}
type="number"
value={form.dailySendLimit}
onChange={(event) => updateValue("dailySendLimit", event.target.value)}
/>
</div>
</section>
<section className="form-drawer__section">
<TextField
fullWidth
disabled={disabled}
label="规则说明 URL"
value={form.ruleUrl}
onChange={(event) => updateValue("ruleUrl", event.target.value)}
/>
</section>
<TierSection
disabled={disabled}
label="红包数量档位"
@ -140,8 +148,8 @@ function TierSection({ disabled, label, onAdd, onRemove, onUpdate, tierKey, valu
<div className={styles.tierGrid} key={`${tierKey}-${index}`}>
<TextField
disabled={disabled}
inputProps={{ min: 1 }}
label={`${label} ${index + 1}`}
slotProps={{ htmlInput: { min: 1 } }}
type="number"
value={value}
onChange={(event) => onUpdate(tierKey, index, event.target.value)}

View File

@ -20,9 +20,10 @@ export function RedPacketConfigSummary({ canUpdate, config, configLoading, onEdi
<SummaryItem label="数量档位">{formatTiers(config?.countTiers)}</SummaryItem>
<SummaryItem label="金额档位">{formatTiers(config?.amountTiers)}</SummaryItem>
<SummaryItem label="延迟/过期">
{Number(config?.delayedOpenSeconds || 0)}s / {Number(config?.expireSeconds || 0)}s
{Number(config?.delayedOpenSeconds || 0)}s / 24h
</SummaryItem>
<SummaryItem label="每日上限">{Number(config?.dailySendLimit || 0)} </SummaryItem>
<SummaryItem label="规则 URL">{config?.ruleUrl || "-"}</SummaryItem>
</div>
<div className={styles.summaryActions}>
<Button disabled={configLoading} startIcon={<RefreshOutlined fontSize="small" />} onClick={onRefresh}>

View File

@ -53,6 +53,20 @@ export function RedPacketDetailDrawer({ loading, onClose, open, packet }) {
<InfoRow label="状态" value={packetStatusLabel(packet.status)} />
<InfoRow label="金额" value={`${formatNumber(packet.totalAmount)} 金币`} />
<InfoRow label="剩余" value={`${formatNumber(packet.remainingAmount)} / ${packet.remainingCount}`} />
<InfoRow
label="已领"
value={`${formatNumber(packet.totalAmount - packet.remainingAmount)} / ${
packet.packetCount - packet.remainingCount
} `}
/>
<InfoRow
label="退款"
value={
packet.refundStatus
? `${packetStatusLabel(packet.refundStatus)} ${formatNumber(packet.refundAmount)} 金币`
: "-"
}
/>
<InfoRow label="开启时间" value={formatMillis(packet.openAtMs)} />
<InfoRow label="过期时间" value={formatMillis(packet.expiresAtMs)} />
<DataTable
@ -81,7 +95,7 @@ function InfoRow({ label, value }) {
}
function packetTypeLabel(type) {
return type === "delayed" ? "延迟红包" : "普通红包";
return String(type || "").toLowerCase() === "delayed" ? "延时红包" : "即时红包";
}
function packetStatusLabel(status) {
@ -91,7 +105,7 @@ function packetStatusLabel(status) {
refunded: "已退款",
waiting_open: "待开启",
};
return labels[status] || status || "-";
return labels[String(status || "").toLowerCase()] || status || "-";
}
function claimStatusLabel(status) {

View File

@ -3,6 +3,7 @@ import {
getRedPacket,
getRedPacketConfig,
listRedPackets,
retryRedPacketRefund,
updateRedPacketConfig,
} from "@/features/red-packets/api";
import { useRedPacketAbilities } from "@/features/red-packets/permissions.js";
@ -17,8 +18,9 @@ const emptyForm = {
countTiers: [""],
amountTiers: [""],
delayedOpenSeconds: "0",
expireSeconds: "300",
expireSeconds: "86400",
dailySendLimit: "5",
ruleUrl: "",
};
export function useRedPacketPage() {
@ -40,9 +42,9 @@ export function useRedPacketPage() {
const filters = useMemo(
() => ({
room_id: roomId.trim() || undefined,
sender_user_id: senderUserId.trim() || undefined,
packet_type: packetType || undefined,
roomId: roomId.trim() || undefined,
senderUserId: senderUserId.trim() || undefined,
packetMode: packetType || undefined,
status: status || undefined,
}),
[packetType, roomId, senderUserId, status],
@ -124,6 +126,22 @@ export function useRedPacketPage() {
}
};
const retryRefund = async (packetId) => {
if (!packetId || !abilities.canUpdate) {
return;
}
try {
await retryRedPacketRefund(packetId);
showToast("退款重试已提交", "success");
await reloadPackets();
if (selectedPacket?.packetId === packetId) {
await openPacketDetail(packetId);
}
} catch (err) {
showToast(err.message || "退款重试失败", "error");
}
};
const closePacketDetail = () => {
setDetailOpen(false);
setSelectedPacket(null);
@ -163,6 +181,7 @@ export function useRedPacketPage() {
reloadConfig,
reloadPackets,
resetFilters,
retryRefund,
roomId,
selectedPacket,
senderUserId,
@ -183,8 +202,9 @@ function formFromConfig(config) {
countTiers: valuesToForm(config.countTiers),
amountTiers: valuesToForm(config.amountTiers),
delayedOpenSeconds: String(config.delayedOpenSeconds || 0),
expireSeconds: String(config.expireSeconds || 300),
expireSeconds: "86400",
dailySendLimit: String(config.dailySendLimit || 0),
ruleUrl: config.ruleUrl || "",
};
}
@ -196,7 +216,7 @@ function payloadFromForm(form) {
const countTiers = normalizePositiveIntegers(form.countTiers);
const amountTiers = normalizePositiveIntegers(form.amountTiers);
const delayedOpenSeconds = Number(form.delayedOpenSeconds || 0);
const expireSeconds = Number(form.expireSeconds || 0);
const expireSeconds = 86400;
const dailySendLimit = Number(form.dailySendLimit || 0);
if (!countTiers.length) {
throw new Error("红包数量档位不能为空");
@ -220,6 +240,7 @@ function payloadFromForm(form) {
delayed_open_seconds: delayedOpenSeconds,
expire_seconds: expireSeconds,
daily_send_limit: dailySendLimit,
rule_url: String(form.ruleUrl || "").trim(),
};
}

View File

@ -1,4 +1,5 @@
import VisibilityOutlined from "@mui/icons-material/VisibilityOutlined";
import ReplayOutlined from "@mui/icons-material/ReplayOutlined";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import { RedPacketConfigDrawer } from "@/features/red-packets/components/RedPacketConfigDrawer.jsx";
@ -20,8 +21,8 @@ import { formatMillis } from "@/shared/utils/time.js";
const packetTypeOptions = [
["", "全部类型"],
["normal", "普通红包"],
["delayed", "延迟红包"],
["IMMEDIATE", "即时红包"],
["DELAYED", "延时红包"],
];
const statusOptions = [
@ -83,6 +84,17 @@ export function RedPacketPage() {
</div>
),
},
{
key: "claimed",
label: "已领",
width: "minmax(145px, 0.6fr)",
render: (packet) => (
<div className={styles.stack}>
<span>{formatNumber(packet.totalAmount - packet.remainingAmount)} 金币</span>
<span className={styles.meta}>{packet.packetCount - packet.remainingCount} </span>
</div>
),
},
{
key: "status",
label: "状态",
@ -101,12 +113,26 @@ export function RedPacketPage() {
width: "minmax(180px, 0.75fr)",
render: (packet) => formatMillis(packet.expiresAtMs),
},
{
key: "refund",
label: "退款",
width: "minmax(120px, 0.55fr)",
render: (packet) =>
packet.refundStatus
? `${packetStatusLabel(packet.refundStatus)} ${formatNumber(packet.refundAmount)}`
: "-",
},
{
key: "actions",
label: "操作",
width: "88px",
width: "128px",
render: (packet) => (
<AdminRowActions>
{page.abilities.canUpdate && canRetryRefund(packet) ? (
<AdminActionIconButton label="重试退款" onClick={() => page.retryRefund(packet.packetId)}>
<ReplayOutlined fontSize="small" />
</AdminActionIconButton>
) : null}
<AdminActionIconButton label="查看详情" onClick={() => page.openPacketDetail(packet.packetId)}>
<VisibilityOutlined fontSize="small" />
</AdminActionIconButton>
@ -210,13 +236,20 @@ export function RedPacketPage() {
}
function packetTypeLabel(type) {
return type === "delayed" ? "延迟红包" : "普通红包";
const normalized = String(type || "").toLowerCase();
return normalized === "delayed" ? "延时红包" : "即时红包";
}
function packetStatusLabel(status) {
return statusOptions.find(([value]) => value === status)?.[1] || status || "-";
const normalized = String(status || "").toLowerCase();
return statusOptions.find(([value]) => value === normalized)?.[1] || status || "-";
}
function formatNumber(value) {
return new Intl.NumberFormat("zh-CN").format(Number(value || 0));
}
function canRetryRefund(packet) {
const status = String(packet.status || "").toLowerCase();
return status === "active" || status === "waiting_open";
}

View File

@ -155,6 +155,7 @@ export const API_OPERATIONS = {
replaceRoleDataScopes: "replaceRoleDataScopes",
replaceRolePermissions: "replaceRolePermissions",
resetUserPassword: "resetUserPassword",
retryRedPacketRefund: "retryRedPacketRefund",
search: "search",
setAgencyJoinEnabled: "setAgencyJoinEnabled",
setBDLeaderStatus: "setBDLeaderStatus",
@ -712,14 +713,14 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
getRedPacket: {
method: "GET",
operationId: API_OPERATIONS.getRedPacket,
path: "/v1/admin/activity/red-packets/{packet_id}",
path: "/v1/resident-activity/voice-room-red-packet/records/{packet_id}",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
getRedPacketConfig: {
method: "GET",
operationId: API_OPERATIONS.getRedPacketConfig,
path: "/v1/admin/activity/red-packets/config",
path: "/v1/resident-activity/voice-room-red-packet/config",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
@ -1004,7 +1005,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
listRedPackets: {
method: "GET",
operationId: API_OPERATIONS.listRedPackets,
path: "/v1/admin/activity/red-packets",
path: "/v1/resident-activity/voice-room-red-packet/records",
permission: "red-packet:view",
permissions: ["red-packet:view"]
},
@ -1187,6 +1188,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "user:reset-password",
permissions: ["user:reset-password"]
},
retryRedPacketRefund: {
method: "POST",
operationId: API_OPERATIONS.retryRedPacketRefund,
path: "/v1/resident-activity/voice-room-red-packet/refund/retry",
permission: "red-packet:update",
permissions: ["red-packet:update"]
},
search: {
method: "GET",
operationId: API_OPERATIONS.search,
@ -1368,9 +1376,9 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permissions: ["payment-product:update"]
},
updateRedPacketConfig: {
method: "PUT",
method: "POST",
operationId: API_OPERATIONS.updateRedPacketConfig,
path: "/v1/admin/activity/red-packets/config",
path: "/v1/resident-activity/voice-room-red-packet/config",
permission: "red-packet:update",
permissions: ["red-packet:update"]
},

View File

@ -100,7 +100,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/red-packets": {
"/resident-activity/voice-room-red-packet/records": {
parameters: {
query?: never;
header?: never;
@ -116,7 +116,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/red-packets/{packet_id}": {
"/resident-activity/voice-room-red-packet/records/{packet_id}": {
parameters: {
query?: never;
header?: never;
@ -132,7 +132,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/activity/red-packets/config": {
"/resident-activity/voice-room-red-packet/config": {
parameters: {
query?: never;
header?: never;
@ -140,8 +140,24 @@ export interface paths {
cookie?: never;
};
get: operations["getRedPacketConfig"];
put: operations["updateRedPacketConfig"];
post?: never;
put?: never;
post: operations["updateRedPacketConfig"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/resident-activity/voice-room-red-packet/refund/retry": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["retryRedPacketRefund"];
delete?: never;
options?: never;
head?: never;
@ -2734,6 +2750,18 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
retryRedPacketRefund: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
getLuckyGiftConfig: {
parameters: {
query?: never;