Compare commits

...

2 Commits

Author SHA1 Message Date
zhx
efaaef7e86 feat: update voice room red packet admin UI 2026-05-29 17:36:33 +08:00
zhx
c22b23cb8c feat: add report list page 2026-05-29 16:48:00 +08:00
16 changed files with 600 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",
@ -1471,6 +1483,18 @@
"x-permissions": ["coin-ledger:view"]
}
},
"/admin/operations/reports": {
"get": {
"operationId": "listReports",
"responses": {
"200": {
"$ref": "#/components/responses/EmptyResponse"
}
},
"x-permission": "report:view",
"x-permissions": ["report:view"]
}
},
"/admin/payment/recharge-bills": {
"get": {
"operationId": "listRechargeBills",

View File

@ -6,6 +6,7 @@ import CardGiftcardOutlined from "@mui/icons-material/CardGiftcardOutlined";
import CategoryOutlined from "@mui/icons-material/CategoryOutlined";
import DashboardOutlined from "@mui/icons-material/DashboardOutlined";
import EventAvailableOutlined from "@mui/icons-material/EventAvailableOutlined";
import FlagOutlined from "@mui/icons-material/FlagOutlined";
import GroupsOutlined from "@mui/icons-material/GroupsOutlined";
import HistoryOutlined from "@mui/icons-material/HistoryOutlined";
import HubOutlined from "@mui/icons-material/HubOutlined";
@ -44,6 +45,7 @@ const iconMap = {
dashboard: DashboardOutlined,
event_available: EventAvailableOutlined,
explore: MapOutlined,
flag: FlagOutlined,
gift: CardGiftcardOutlined,
inventory: Inventory2Outlined,
leaderboard: LeaderboardOutlined,
@ -144,6 +146,7 @@ export const fallbackNavigation = [
children: [
routeNavItem("operation-coin-ledger", { icon: ReceiptLongOutlined }),
routeNavItem("operation-coin-adjustment", { icon: WalletOutlined }),
routeNavItem("operation-reports", { icon: FlagOutlined }),
routeNavItem("lucky-gift", { icon: RedeemOutlined }),
],
},

View File

@ -28,6 +28,7 @@ export const PERMISSIONS = {
coinLedgerView: "coin-ledger:view",
coinAdjustmentView: "coin-adjustment:view",
coinAdjustmentCreate: "coin-adjustment:create",
reportView: "report:view",
paymentBillView: "payment-bill:view",
paymentProductView: "payment-product:view",
paymentProductCreate: "payment-product:create",
@ -160,6 +161,7 @@ export const MENU_CODES = {
operations: "operations",
operationCoinLedger: "operation-coin-ledger",
operationCoinAdjustment: "operation-coin-adjustment",
operationReports: "operation-reports",
luckyGift: "lucky-gift",
activities: "activities",
dailyTaskList: "daily-task-list",

View File

@ -8,6 +8,7 @@ import type {
CoinLedgerEntryDto,
CoinLedgerUserDto,
PageQuery,
ReportDto,
} from "@/shared/api/types";
export function listCoinLedger(query: PageQuery = {}): Promise<ApiPage<CoinLedgerEntryDto>> {
@ -26,6 +27,14 @@ export function listCoinAdjustments(query: PageQuery = {}): Promise<ApiPage<Coin
});
}
export function listReports(query: PageQuery = {}): Promise<ApiPage<ReportDto>> {
const endpoint = API_ENDPOINTS.listReports;
return apiRequest<ApiPage<ReportDto>>(apiEndpointPath(API_OPERATIONS.listReports), {
method: endpoint.method,
query,
});
}
export function lookupCoinAdjustmentTarget(userId: string): Promise<CoinLedgerUserDto> {
const endpoint = API_ENDPOINTS.lookupCoinAdjustmentTarget;
return apiRequest<CoinLedgerUserDto>(apiEndpointPath(API_OPERATIONS.lookupCoinAdjustmentTarget), {

View File

@ -185,6 +185,56 @@
background: var(--bg-card-strong);
}
.reportTargetType {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text-primary);
font-weight: 700;
white-space: nowrap;
}
.reportReason {
display: -webkit-box;
overflow: hidden;
color: var(--text-primary);
line-height: 1.55;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.reportImages {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-2);
}
.reportImage,
.reportImageCount {
display: inline-flex;
flex: 0 0 auto;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-control);
background: var(--bg-card-strong);
color: var(--text-secondary);
font-size: 12px;
font-weight: 700;
text-decoration: none;
}
.reportImage img {
width: 100%;
height: 100%;
object-fit: cover;
}
@media (max-width: 640px) {
.detailGrid {
grid-template-columns: 1fr;

View File

@ -0,0 +1,260 @@
import MeetingRoomOutlined from "@mui/icons-material/MeetingRoomOutlined";
import PersonOutlineOutlined from "@mui/icons-material/PersonOutlineOutlined";
import { useMemo, useState } from "react";
import { listReports } from "@/features/operations/api";
import styles from "@/features/operations/operations.module.css";
import { usePaginatedQuery } from "@/shared/hooks/usePaginatedQuery.js";
import {
AdminFilterResetButton,
AdminListBody,
AdminListPage,
AdminListToolbar,
} from "@/shared/ui/AdminListLayout.jsx";
import { DataState } from "@/shared/ui/DataState.jsx";
import { DataTable } from "@/shared/ui/DataTable.jsx";
import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx";
import { createOptionsColumnFilter, createTextColumnFilter } from "@/shared/ui/tableFilters.js";
import { formatMillis } from "@/shared/utils/time.js";
const pageSize = 50;
const targetTypeOptions = [
{ label: "全部目标", value: "" },
{ label: "用户", value: "user" },
{ label: "房间", value: "room" },
];
const reportTypeOptions = [
{ label: "全部类型", value: "" },
{ label: "政治", value: "politics" },
{ label: "色情", value: "pornography" },
{ label: "血腥暴力", value: "graphic_violence" },
{ label: "言语辱骂", value: "verbal_abuse" },
{ label: "欺诈", value: "fraud" },
{ label: "违法内容", value: "illegal_content" },
{ label: "未成年人", value: "minors" },
{ label: "线下交易", value: "in_person_transactions" },
{ label: "其他", value: "other" },
];
const columns = [
{
key: "targetType",
label: "举报目标",
render: (report) => <TargetTypeLabel report={report} />,
width: "minmax(120px, 0.55fr)",
},
{
key: "target",
label: "举报信息",
render: (report) => <TargetCell report={report} />,
width: "minmax(300px, 1.3fr)",
},
{
key: "reportType",
label: "举报类型",
render: (report) => reportTypeLabel(report.reportType),
width: "minmax(150px, 0.7fr)",
},
{
key: "reason",
label: "举报理由",
render: (report) => <span className={styles.reportReason}>{report.reason || "-"}</span>,
width: "minmax(220px, 1fr)",
},
{
key: "imageUrls",
label: "举报图片",
render: (report) => <EvidenceImages imageUrls={report.imageUrls} />,
width: "minmax(180px, 0.8fr)",
},
{
key: "createdAtMs",
label: "提交时间",
render: (report) => formatMillis(report.createdAtMs),
width: "minmax(170px, 0.75fr)",
},
];
export function ReportListPage() {
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState("");
const [targetType, setTargetType] = useState("");
const [reportType, setReportType] = useState("");
const [timeRange, setTimeRange] = useState({ endMs: "", startMs: "" });
const filters = useMemo(
() => ({
end_at_ms: timeRange.endMs || "",
keyword,
report_type: reportType,
start_at_ms: timeRange.startMs || "",
target_type: targetType,
}),
[keyword, reportType, targetType, timeRange.endMs, timeRange.startMs],
);
const query = usePaginatedQuery({
errorMessage: "获取举报列表失败",
fetcher: listReports,
filters,
page,
pageSize,
queryKey: ["reports", filters, page],
});
const data = query.data || { items: [], total: 0, page, pageSize };
const items = data.items || [];
const total = data.total || 0;
const changeFilter = (setter) => (value) => {
setter(value);
setPage(1);
};
const changeTimeRange = (nextRange) => {
setTimeRange(nextRange);
setPage(1);
};
const resetFilters = () => {
setKeyword("");
setTargetType("");
setReportType("");
setTimeRange({ endMs: "", startMs: "" });
setPage(1);
};
const tableColumns = columns.map((column) => {
if (column.key === "target") {
return {
...column,
filter: createTextColumnFilter({
placeholder: "长 ID / 短 ID / 昵称",
value: keyword,
onChange: changeFilter(setKeyword),
}),
};
}
if (column.key === "targetType") {
return {
...column,
filter: createOptionsColumnFilter({
options: targetTypeOptions,
value: targetType,
onChange: changeFilter(setTargetType),
}),
};
}
if (column.key === "reportType") {
return {
...column,
filter: createOptionsColumnFilter({
options: reportTypeOptions,
value: reportType,
onChange: changeFilter(setReportType),
}),
};
}
return column;
});
return (
<AdminListPage>
<AdminListToolbar
filters={
<>
<TimeRangeFilter value={timeRange} onChange={changeTimeRange} />
<AdminFilterResetButton
disabled={!keyword && !targetType && !reportType && !timeRange.startMs && !timeRange.endMs}
onClick={resetFilters}
/>
</>
}
/>
<DataState error={query.error} loading={query.loading} onRetry={query.reload}>
<AdminListBody>
<DataTable
columns={tableColumns}
items={items}
minWidth="1140px"
pagination={
total > 0
? {
page,
pageSize: data.pageSize || pageSize,
total,
onPageChange: setPage,
}
: undefined
}
rowKey={(report) => report.reportId}
/>
</AdminListBody>
</DataState>
</AdminListPage>
);
}
function TargetTypeLabel({ report }) {
const isRoom = report.targetType === "room";
return (
<span className={styles.reportTargetType}>
{isRoom ? <MeetingRoomOutlined fontSize="small" /> : <PersonOutlineOutlined fontSize="small" />}
{isRoom ? "房间" : "用户"}
</span>
);
}
function TargetCell({ report }) {
const target = report.target || {};
if (report.targetType === "room") {
const room = target.room || {};
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{room.coverUrl ? <img alt="" src={room.coverUrl} /> : <MeetingRoomOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{room.title || "-"}</span>
<span className={styles.meta}>{targetMeta(room.roomShortId, room.roomId || report.roomId)}</span>
</span>
</div>
);
}
const user = target.user || {};
return (
<div className={styles.identity}>
<span className={styles.avatar}>
{user.avatar ? <img alt="" src={user.avatar} /> : <PersonOutlineOutlined fontSize="small" />}
</span>
<span className={styles.identityText}>
<span className={styles.primaryText}>{user.username || "-"}</span>
<span className={styles.meta}>
{targetMeta(user.displayUserId || user.defaultDisplayUserId, user.userId || report.userId)}
</span>
</span>
</div>
);
}
function EvidenceImages({ imageUrls = [] }) {
const urls = Array.isArray(imageUrls) ? imageUrls.filter(Boolean) : [];
if (!urls.length) {
return "-";
}
return (
<div className={styles.reportImages}>
{urls.slice(0, 4).map((url) => (
<a className={styles.reportImage} href={url} key={url} rel="noreferrer" target="_blank">
<img alt="" src={url} />
</a>
))}
{urls.length > 4 ? <span className={styles.reportImageCount}>+{urls.length - 4}</span> : null}
</div>
);
}
function targetMeta(shortId, longId) {
return `短ID ${shortId || "-"} · 长ID ${longId || "-"}`;
}
function reportTypeLabel(value) {
return reportTypeOptions.find((item) => item.value === value)?.label || value || "-";
}

View File

@ -17,4 +17,12 @@ export const operationsRoutes = [
path: "/operations/coin-adjustments",
permission: PERMISSIONS.coinAdjustmentView,
},
{
label: "举报列表",
loader: () => import("./pages/ReportListPage.jsx").then((module) => module.ReportListPage),
menuCode: MENU_CODES.operationReports,
pageKey: "operation-reports",
path: "/operations/reports",
permission: PERMISSIONS.reportView,
},
];

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

@ -131,6 +131,7 @@ export const API_OPERATIONS = {
listRegionBlocks: "listRegionBlocks",
listRegions: "listRegions",
listRegistrationRewardClaims: "listRegistrationRewardClaims",
listReports: "listReports",
listResourceGrants: "listResourceGrants",
listResourceGroups: "listResourceGroups",
listResources: "listResources",
@ -154,6 +155,7 @@ export const API_OPERATIONS = {
replaceRoleDataScopes: "replaceRoleDataScopes",
replaceRolePermissions: "replaceRolePermissions",
resetUserPassword: "resetUserPassword",
retryRedPacketRefund: "retryRedPacketRefund",
search: "search",
setAgencyJoinEnabled: "setAgencyJoinEnabled",
setBDLeaderStatus: "setBDLeaderStatus",
@ -711,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"]
},
@ -1003,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"]
},
@ -1028,6 +1030,13 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
permission: "registration-reward:view",
permissions: ["registration-reward:view"]
},
listReports: {
method: "GET",
operationId: API_OPERATIONS.listReports,
path: "/v1/admin/operations/reports",
permission: "report:view",
permissions: ["report:view"]
},
listResourceGrants: {
method: "GET",
operationId: API_OPERATIONS.listResourceGrants,
@ -1179,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,
@ -1360,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;
@ -1012,6 +1028,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/admin/operations/reports": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listReports"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/admin/payment/recharge-bills": {
parameters: {
query?: never;
@ -2718,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;
@ -3785,6 +3829,18 @@ export interface operations {
200: components["responses"]["EmptyResponse"];
};
};
listReports: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: components["responses"]["EmptyResponse"];
};
};
listRechargeBills: {
parameters: {
query?: never;

View File

@ -245,6 +245,43 @@ export interface CoinAdjustmentCreateDto {
user: CoinLedgerUserDto;
}
export interface ReportUserDto {
avatar?: string;
defaultDisplayUserId?: string;
displayUserId?: string;
userId?: string;
username?: string;
}
export interface ReportRoomDto {
coverUrl?: string;
roomId?: string;
roomShortId?: string;
title?: string;
}
export interface ReportTargetDto {
room?: ReportRoomDto;
type: "user" | "room" | string;
user?: ReportUserDto;
}
export interface ReportDto {
createdAtMs?: number;
imageUrls?: string[];
reason?: string;
reportId: string;
reporterUserId?: string;
reportType?: string;
requestId?: string;
roomId?: string;
status?: string;
target: ReportTargetDto;
targetType: "user" | "room" | string;
updatedAtMs?: number;
userId?: string;
}
export interface RechargeBillDto {
appCode?: string;
coinAmount: number;