运营审核
This commit is contained in:
parent
ad4d4151f5
commit
1c4e6d7f38
@ -4411,7 +4411,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"x-permission": "finance:view",
|
"x-permission": "finance:view",
|
||||||
"x-permissions": ["finance:view"]
|
"x-permissions": [
|
||||||
|
"finance:view",
|
||||||
|
"finance-withdrawal:view",
|
||||||
|
"finance-withdrawal:audit",
|
||||||
|
"operations-withdrawal:view",
|
||||||
|
"operations-withdrawal:audit"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/finance/withdrawal-applications": {
|
"/admin/finance/withdrawal-applications": {
|
||||||
@ -4467,6 +4473,9 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/AppCodeHeader"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@ -4492,6 +4501,9 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/AppCodeHeader"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@ -4559,6 +4571,9 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/AppCodeHeader"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@ -4584,6 +4599,9 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/parameters/AppCodeHeader"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@ -14162,7 +14180,8 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"operationsStatus": {
|
"operationsStatus": {
|
||||||
"enum": ["pending", "approved", "rejected"],
|
"description": "运营审核状态;rejecting 表示拒绝已持久认领,钱包释放或通知仍待重试。",
|
||||||
|
"enum": ["pending", "rejecting", "approved", "rejected", "skipped"],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"operationsReviewerUserId": {
|
"operationsReviewerUserId": {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
fetchFinanceRechargeOverview,
|
fetchFinanceRechargeOverview,
|
||||||
fetchFinanceRechargeSummaries,
|
fetchFinanceRechargeSummaries,
|
||||||
fetchFinanceSession,
|
fetchFinanceSession,
|
||||||
|
fetchFinanceWithdrawalOptions,
|
||||||
grantFinanceCoinSellerRechargeOrder,
|
grantFinanceCoinSellerRechargeOrder,
|
||||||
getFinanceCoinSellerRechargeExchangeRate,
|
getFinanceCoinSellerRechargeExchangeRate,
|
||||||
getFinanceUSDTAddresses,
|
getFinanceUSDTAddresses,
|
||||||
@ -142,6 +143,11 @@ export function FinanceApp() {
|
|||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
|
const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null });
|
||||||
const [optionsState, setOptionsState] = useState({ data: emptyOptions, error: "", loading: false });
|
const [optionsState, setOptionsState] = useState({ data: emptyOptions, error: "", loading: false });
|
||||||
|
const [withdrawalOptionsState, setWithdrawalOptionsState] = useState({
|
||||||
|
data: emptyOptions,
|
||||||
|
error: "",
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
const [rechargeDetailsState, setRechargeDetailsState] = useState({
|
const [rechargeDetailsState, setRechargeDetailsState] = useState({
|
||||||
data: { items: [], page: 1, pageSize, total: 0 },
|
data: { items: [], page: 1, pageSize, total: 0 },
|
||||||
error: "",
|
error: "",
|
||||||
@ -171,6 +177,11 @@ export function FinanceApp() {
|
|||||||
error: "",
|
error: "",
|
||||||
loading: false,
|
loading: false,
|
||||||
});
|
});
|
||||||
|
const [operationsWithdrawalsState, setOperationsWithdrawalsState] = useState({
|
||||||
|
data: { items: [], page: 1, pageSize, total: 0 },
|
||||||
|
error: "",
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
const [coinSellerRechargeOrdersState, setCoinSellerRechargeOrdersState] = useState({
|
const [coinSellerRechargeOrdersState, setCoinSellerRechargeOrdersState] = useState({
|
||||||
data: { items: [], page: 1, pageSize, total: 0 },
|
data: { items: [], page: 1, pageSize, total: 0 },
|
||||||
error: "",
|
error: "",
|
||||||
@ -190,6 +201,11 @@ export function FinanceApp() {
|
|||||||
}));
|
}));
|
||||||
const [rechargeExporting, setRechargeExporting] = useState(false);
|
const [rechargeExporting, setRechargeExporting] = useState(false);
|
||||||
const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 });
|
const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 });
|
||||||
|
const [operationsWithdrawalFilters, setOperationsWithdrawalFilters] = useState({
|
||||||
|
appCode: "",
|
||||||
|
keyword: "",
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
const [coinSellerRechargeOrderFilters, setCoinSellerRechargeOrderFilters] = useState({
|
const [coinSellerRechargeOrderFilters, setCoinSellerRechargeOrderFilters] = useState({
|
||||||
appCode: "",
|
appCode: "",
|
||||||
grantStatus: "",
|
grantStatus: "",
|
||||||
@ -210,13 +226,9 @@ export function FinanceApp() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSessionState({ error: "", loading: false, session });
|
setSessionState({ error: "", loading: false, session });
|
||||||
setActiveView(
|
const nextView = resolvedFinanceView(session);
|
||||||
session.canViewAppRechargeDetails
|
setActiveView(nextView);
|
||||||
? "overview"
|
syncFinanceView(nextView);
|
||||||
: session.canViewCoinSellerRechargeOrders
|
|
||||||
? "coinSellerRechargeOrders"
|
|
||||||
: "withdrawals",
|
|
||||||
);
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@ -230,6 +242,9 @@ export function FinanceApp() {
|
|||||||
|
|
||||||
const session = sessionState.session;
|
const session = sessionState.session;
|
||||||
const canLoadOptions = Boolean(session?.canAccessFinance);
|
const canLoadOptions = Boolean(session?.canAccessFinance);
|
||||||
|
const canLoadWithdrawalOptions = Boolean(
|
||||||
|
session?.canViewOperationsWithdrawalApplications || session?.canViewWithdrawalApplications,
|
||||||
|
);
|
||||||
const canViewFinance = Boolean(session?.canViewAppRechargeDetails);
|
const canViewFinance = Boolean(session?.canViewAppRechargeDetails);
|
||||||
|
|
||||||
// 财务三页(概览/流水/对账)共享同一套 App × 时间 × 计价 全局筛选。
|
// 财务三页(概览/流水/对账)共享同一套 App × 时间 × 计价 全局筛选。
|
||||||
@ -264,6 +279,15 @@ export function FinanceApp() {
|
|||||||
}),
|
}),
|
||||||
[withdrawalFilters],
|
[withdrawalFilters],
|
||||||
);
|
);
|
||||||
|
const operationsWithdrawalQuery = useMemo(
|
||||||
|
() => ({
|
||||||
|
app_code: operationsWithdrawalFilters.appCode,
|
||||||
|
keyword: operationsWithdrawalFilters.keyword,
|
||||||
|
page: operationsWithdrawalFilters.page,
|
||||||
|
page_size: pageSize,
|
||||||
|
}),
|
||||||
|
[operationsWithdrawalFilters],
|
||||||
|
);
|
||||||
const coinSellerRechargeOrderQuery = useMemo(
|
const coinSellerRechargeOrderQuery = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
app_code: coinSellerRechargeOrderFilters.appCode,
|
app_code: coinSellerRechargeOrderFilters.appCode,
|
||||||
@ -291,6 +315,24 @@ export function FinanceApp() {
|
|||||||
}
|
}
|
||||||
}, [canLoadOptions]);
|
}, [canLoadOptions]);
|
||||||
|
|
||||||
|
const loadWithdrawalOptions = useCallback(async () => {
|
||||||
|
if (!canLoadWithdrawalOptions) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWithdrawalOptionsState((current) => ({ ...current, error: "", loading: true }));
|
||||||
|
try {
|
||||||
|
const data = await fetchFinanceWithdrawalOptions();
|
||||||
|
setWithdrawalOptionsState({ data, error: "", loading: false });
|
||||||
|
} catch (err) {
|
||||||
|
// 财务范围加载失败时必须保持空目录,不能回退全量 options 造成越权筛选入口。
|
||||||
|
setWithdrawalOptionsState({
|
||||||
|
data: emptyOptions,
|
||||||
|
error: err.message || "加载提现 App 范围失败",
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [canLoadWithdrawalOptions]);
|
||||||
|
|
||||||
// 充值详情的 App 目录来自 recharge-apps(含 Yumi/Aslan 等 legacy 外部账单源);接口未就绪时回退到财务通用 App 列表。
|
// 充值详情的 App 目录来自 recharge-apps(含 Yumi/Aslan 等 legacy 外部账单源);接口未就绪时回退到财务通用 App 列表。
|
||||||
const rechargeApps = useMemo(
|
const rechargeApps = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@ -474,6 +516,23 @@ export function FinanceApp() {
|
|||||||
}
|
}
|
||||||
}, [session?.canViewWithdrawalApplications, withdrawalQuery]);
|
}, [session?.canViewWithdrawalApplications, withdrawalQuery]);
|
||||||
|
|
||||||
|
const loadOperationsWithdrawals = useCallback(async () => {
|
||||||
|
if (!session?.canViewOperationsWithdrawalApplications) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOperationsWithdrawalsState((current) => ({ ...current, error: "", loading: true }));
|
||||||
|
try {
|
||||||
|
const data = await listOperationsWithdrawalApplications(operationsWithdrawalQuery);
|
||||||
|
setOperationsWithdrawalsState({ data, error: "", loading: false });
|
||||||
|
} catch (err) {
|
||||||
|
setOperationsWithdrawalsState((current) => ({
|
||||||
|
...current,
|
||||||
|
error: err.message || "加载用户提现运营审核列表失败",
|
||||||
|
loading: false,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [operationsWithdrawalQuery, session?.canViewOperationsWithdrawalApplications]);
|
||||||
|
|
||||||
const loadCoinSellerRechargeOrders = useCallback(async () => {
|
const loadCoinSellerRechargeOrders = useCallback(async () => {
|
||||||
if (!session?.canViewCoinSellerRechargeOrders) {
|
if (!session?.canViewCoinSellerRechargeOrders) {
|
||||||
return;
|
return;
|
||||||
@ -495,6 +554,10 @@ export function FinanceApp() {
|
|||||||
loadOptions();
|
loadOptions();
|
||||||
}, [loadOptions]);
|
}, [loadOptions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWithdrawalOptions();
|
||||||
|
}, [loadWithdrawalOptions]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRechargeApps();
|
loadRechargeApps();
|
||||||
}, [loadRechargeApps]);
|
}, [loadRechargeApps]);
|
||||||
@ -523,24 +586,48 @@ export function FinanceApp() {
|
|||||||
loadWithdrawals();
|
loadWithdrawals();
|
||||||
}, [loadWithdrawals]);
|
}, [loadWithdrawals]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOperationsWithdrawals();
|
||||||
|
}, [loadOperationsWithdrawals]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadCoinSellerRechargeOrders();
|
loadCoinSellerRechargeOrders();
|
||||||
}, [loadCoinSellerRechargeOrders]);
|
}, [loadCoinSellerRechargeOrders]);
|
||||||
|
|
||||||
const submitWithdrawalAudit = async ({ auditImageUrl, decision, remark }) => {
|
const submitWithdrawalAudit = async ({ auditImageUrl, decision, remark }) => {
|
||||||
if (!withdrawalAuditTarget?.id || !session?.canAuditWithdrawalApplications) {
|
const reviewStage = withdrawalAuditTarget?.reviewStage === "operations" ? "operations" : "finance";
|
||||||
|
const canAudit =
|
||||||
|
reviewStage === "operations"
|
||||||
|
? session?.canAuditOperationsWithdrawalApplications
|
||||||
|
: session?.canAuditWithdrawalApplications;
|
||||||
|
const isOperationsRejectRetry =
|
||||||
|
reviewStage === "operations" && withdrawalAuditTarget?.operationsStatus === "rejecting";
|
||||||
|
// rejecting 已锁定为拒绝方向,前端门禁不得再把该记录提交到通过接口。
|
||||||
|
if (!withdrawalAuditTarget?.id || !canAudit || (isOperationsRejectRetry && decision !== "rejected")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setWithdrawalAuditLoading(true);
|
setWithdrawalAuditLoading(true);
|
||||||
try {
|
try {
|
||||||
const submitter =
|
const submitter =
|
||||||
decision === "approved" ? approveFinanceWithdrawalApplication : rejectFinanceWithdrawalApplication;
|
reviewStage === "operations"
|
||||||
await submitter(withdrawalAuditTarget.id, cleanPayload({ auditImageUrl, auditRemark: remark }));
|
? decision === "approved"
|
||||||
showToast(decision === "approved" ? "提现申请已通过" : "提现申请已拒绝", "success");
|
? approveOperationsWithdrawalApplication
|
||||||
|
: rejectOperationsWithdrawalApplication
|
||||||
|
: decision === "approved"
|
||||||
|
? approveFinanceWithdrawalApplication
|
||||||
|
: rejectFinanceWithdrawalApplication;
|
||||||
|
const payload =
|
||||||
|
reviewStage === "operations"
|
||||||
|
? cleanPayload({ auditRemark: remark })
|
||||||
|
: cleanPayload({ auditImageUrl, auditRemark: remark });
|
||||||
|
await submitter(withdrawalAuditTarget.id, withdrawalAuditTarget.appCode, payload);
|
||||||
|
const stageLabel = reviewStage === "operations" ? "运营审核" : "财务审核";
|
||||||
|
showToast(`${stageLabel}${decision === "approved" ? "已通过" : "已拒绝"}`, "success");
|
||||||
setWithdrawalAuditTarget(null);
|
setWithdrawalAuditTarget(null);
|
||||||
await loadWithdrawals();
|
// 平台管理员可能同时打开两个队列;任一阶段落库后刷新两边,运营通过即可立即出现在财务队列。
|
||||||
|
await Promise.all([loadOperationsWithdrawals(), loadWithdrawals()]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message || "审核提现申请失败", "error");
|
showToast(err.message || "审核用户提现失败", "error");
|
||||||
} finally {
|
} finally {
|
||||||
setWithdrawalAuditLoading(false);
|
setWithdrawalAuditLoading(false);
|
||||||
}
|
}
|
||||||
@ -653,6 +740,19 @@ export function FinanceApp() {
|
|||||||
setWithdrawalFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
|
setWithdrawalFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateOperationsWithdrawalFilters = (nextFilters) => {
|
||||||
|
setOperationsWithdrawalFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeActiveView = (nextView) => {
|
||||||
|
// URL 只能进入当前会话有权访问的视图;无权深链按初始化回退处理,菜单点击则直接忽略越权目标。
|
||||||
|
if (!canAccessFinanceView(session, nextView)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setActiveView(nextView);
|
||||||
|
syncFinanceView(nextView);
|
||||||
|
};
|
||||||
|
|
||||||
const updateCoinSellerRechargeOrderFilters = (nextFilters) => {
|
const updateCoinSellerRechargeOrderFilters = (nextFilters) => {
|
||||||
setCoinSellerRechargeOrderFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
|
setCoinSellerRechargeOrderFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 }));
|
||||||
};
|
};
|
||||||
@ -681,7 +781,7 @@ export function FinanceApp() {
|
|||||||
String(preset.range.startMs || "") === String(rechargeDetailFilters.timeRange.startMs || "") &&
|
String(preset.range.startMs || "") === String(rechargeDetailFilters.timeRange.startMs || "") &&
|
||||||
String(preset.range.endMs || "") === String(rechargeDetailFilters.timeRange.endMs || ""),
|
String(preset.range.endMs || "") === String(rechargeDetailFilters.timeRange.endMs || ""),
|
||||||
);
|
);
|
||||||
const isFinanceView = FINANCE_VIEWS.has(activeView);
|
const isFinanceView = RECHARGE_VIEWS.has(activeView);
|
||||||
const financeToolbar =
|
const financeToolbar =
|
||||||
isFinanceView && session.canViewAppRechargeDetails ? (
|
isFinanceView && session.canViewAppRechargeDetails ? (
|
||||||
<>
|
<>
|
||||||
@ -754,17 +854,19 @@ export function FinanceApp() {
|
|||||||
activeView={activeView}
|
activeView={activeView}
|
||||||
appBar={appBar}
|
appBar={appBar}
|
||||||
canViewCoinSellerRechargeOrders={session.canViewCoinSellerRechargeOrders}
|
canViewCoinSellerRechargeOrders={session.canViewCoinSellerRechargeOrders}
|
||||||
|
canViewFinanceWithdrawals={session.canViewWithdrawalApplications}
|
||||||
|
canViewOperationsWithdrawals={session.canViewOperationsWithdrawalApplications}
|
||||||
canViewRechargeDetails={session.canViewAppRechargeDetails}
|
canViewRechargeDetails={session.canViewAppRechargeDetails}
|
||||||
canViewWithdrawals={session.canViewWithdrawalApplications}
|
|
||||||
loading={
|
loading={
|
||||||
optionsState.loading ||
|
optionsState.loading ||
|
||||||
rechargeDetailsState.loading ||
|
rechargeDetailsState.loading ||
|
||||||
withdrawalsState.loading ||
|
withdrawalsState.loading ||
|
||||||
|
operationsWithdrawalsState.loading ||
|
||||||
coinSellerRechargeOrdersState.loading
|
coinSellerRechargeOrdersState.loading
|
||||||
}
|
}
|
||||||
session={session}
|
session={session}
|
||||||
toolbar={financeToolbar}
|
toolbar={financeToolbar}
|
||||||
onViewChange={setActiveView}
|
onViewChange={changeActiveView}
|
||||||
>
|
>
|
||||||
{activeView === "overview" && session.canViewAppRechargeDetails ? (
|
{activeView === "overview" && session.canViewAppRechargeDetails ? (
|
||||||
<FinanceOverview
|
<FinanceOverview
|
||||||
@ -780,13 +882,13 @@ export function FinanceApp() {
|
|||||||
onFiltersChange={updateRechargeDetailFilters}
|
onFiltersChange={updateRechargeDetailFilters}
|
||||||
onGoUnsynced={() => {
|
onGoUnsynced={() => {
|
||||||
updateRechargeDetailFilters({ paidState: "unsynced", rechargeType: "" });
|
updateRechargeDetailFilters({ paidState: "unsynced", rechargeType: "" });
|
||||||
setActiveView("rechargeDetails");
|
changeActiveView("rechargeDetails");
|
||||||
}}
|
}}
|
||||||
onOpenApp={(appCode) => {
|
onOpenApp={(appCode) => {
|
||||||
updateRechargeDetailFilters({ appCode, regionId: "" });
|
updateRechargeDetailFilters({ appCode, regionId: "" });
|
||||||
setActiveView("rechargeDetails");
|
changeActiveView("rechargeDetails");
|
||||||
}}
|
}}
|
||||||
onOpenWithdrawals={() => setActiveView("withdrawals")}
|
onOpenWithdrawals={() => changeActiveView("withdrawalFinance")}
|
||||||
/>
|
/>
|
||||||
) : activeView === "coinSellerRechargeOrders" && session.canViewCoinSellerRechargeOrders ? (
|
) : activeView === "coinSellerRechargeOrders" && session.canViewCoinSellerRechargeOrders ? (
|
||||||
<FinanceCoinSellerRechargeOrderList
|
<FinanceCoinSellerRechargeOrderList
|
||||||
@ -843,14 +945,28 @@ export function FinanceApp() {
|
|||||||
loadRechargeOverview();
|
loadRechargeOverview();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : activeView === "withdrawals" && session.canViewWithdrawalApplications ? (
|
) : activeView === "withdrawalOperations" && session.canViewOperationsWithdrawalApplications ? (
|
||||||
|
<FinanceWithdrawalApplicationList
|
||||||
|
applications={operationsWithdrawalsState.data}
|
||||||
|
canAudit={session.canAuditOperationsWithdrawalApplications}
|
||||||
|
error={operationsWithdrawalsState.error || withdrawalOptionsState.error}
|
||||||
|
filters={operationsWithdrawalFilters}
|
||||||
|
loading={operationsWithdrawalsState.loading}
|
||||||
|
options={withdrawalOptionsState.data}
|
||||||
|
reviewStage="operations"
|
||||||
|
onAudit={setWithdrawalAuditTarget}
|
||||||
|
onFiltersChange={updateOperationsWithdrawalFilters}
|
||||||
|
onReload={loadOperationsWithdrawals}
|
||||||
|
/>
|
||||||
|
) : activeView === "withdrawalFinance" && session.canViewWithdrawalApplications ? (
|
||||||
<FinanceWithdrawalApplicationList
|
<FinanceWithdrawalApplicationList
|
||||||
applications={withdrawalsState.data}
|
applications={withdrawalsState.data}
|
||||||
canAudit={session.canAuditWithdrawalApplications}
|
canAudit={session.canAuditWithdrawalApplications}
|
||||||
error={withdrawalsState.error}
|
error={withdrawalsState.error || withdrawalOptionsState.error}
|
||||||
filters={withdrawalFilters}
|
filters={withdrawalFilters}
|
||||||
loading={withdrawalsState.loading}
|
loading={withdrawalsState.loading}
|
||||||
options={optionsState.data}
|
options={withdrawalOptionsState.data}
|
||||||
|
reviewStage="finance"
|
||||||
onAudit={setWithdrawalAuditTarget}
|
onAudit={setWithdrawalAuditTarget}
|
||||||
onFiltersChange={updateWithdrawalFilters}
|
onFiltersChange={updateWithdrawalFilters}
|
||||||
onReload={loadWithdrawals}
|
onReload={loadWithdrawals}
|
||||||
@ -858,11 +974,12 @@ export function FinanceApp() {
|
|||||||
) : null}
|
) : null}
|
||||||
<FinanceAuditDialog
|
<FinanceAuditDialog
|
||||||
application={withdrawalAuditTarget}
|
application={withdrawalAuditTarget}
|
||||||
apps={optionsState.data.apps}
|
apps={withdrawalOptionsState.data.apps}
|
||||||
loading={withdrawalAuditLoading}
|
loading={withdrawalAuditLoading}
|
||||||
open={Boolean(withdrawalAuditTarget)}
|
open={Boolean(withdrawalAuditTarget)}
|
||||||
requireApprovalImage
|
requireApprovalImage={withdrawalAuditTarget?.reviewStage !== "operations"}
|
||||||
requireRejectRemark
|
requireRejectRemark
|
||||||
|
reviewLabel={withdrawalAuditTarget?.reviewStage === "operations" ? "运营" : "财务"}
|
||||||
onClose={() => setWithdrawalAuditTarget(null)}
|
onClose={() => setWithdrawalAuditTarget(null)}
|
||||||
onSubmit={submitWithdrawalAudit}
|
onSubmit={submitWithdrawalAudit}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,22 +1,27 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { FinanceApp } from "./FinanceApp.jsx";
|
import { FinanceApp } from "./FinanceApp.jsx";
|
||||||
import {
|
import {
|
||||||
|
approveOperationsWithdrawalApplication,
|
||||||
fetchFinanceOptions,
|
fetchFinanceOptions,
|
||||||
fetchFinanceRechargeApps,
|
fetchFinanceRechargeApps,
|
||||||
fetchFinanceRechargeOverview,
|
fetchFinanceRechargeOverview,
|
||||||
fetchFinanceRechargeSummaries,
|
fetchFinanceRechargeSummaries,
|
||||||
fetchFinanceSession,
|
fetchFinanceSession,
|
||||||
|
fetchFinanceWithdrawalOptions,
|
||||||
listFinanceCoinSellerRechargeOrders,
|
listFinanceCoinSellerRechargeOrders,
|
||||||
listFinanceRechargeDetails,
|
listFinanceRechargeDetails,
|
||||||
listFinanceRechargeRegions,
|
listFinanceRechargeRegions,
|
||||||
listFinanceWithdrawalApplications,
|
listFinanceWithdrawalApplications,
|
||||||
|
listOperationsWithdrawalApplications,
|
||||||
|
rejectOperationsWithdrawalApplication,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY } from "./format.js";
|
import { EMPTY_RECHARGE_OVERVIEW, EMPTY_RECHARGE_SUMMARY } from "./format.js";
|
||||||
|
|
||||||
vi.mock("./api.js", () => ({
|
vi.mock("./api.js", () => ({
|
||||||
approveFinanceWithdrawalApplication: vi.fn(),
|
approveFinanceWithdrawalApplication: vi.fn(),
|
||||||
|
approveOperationsWithdrawalApplication: vi.fn(),
|
||||||
createFinanceCoinSellerRechargeOrder: vi.fn(),
|
createFinanceCoinSellerRechargeOrder: vi.fn(),
|
||||||
exportFinanceRechargeBills: vi.fn(),
|
exportFinanceRechargeBills: vi.fn(),
|
||||||
fetchFinanceOptions: vi.fn(),
|
fetchFinanceOptions: vi.fn(),
|
||||||
@ -24,6 +29,7 @@ vi.mock("./api.js", () => ({
|
|||||||
fetchFinanceRechargeOverview: vi.fn(),
|
fetchFinanceRechargeOverview: vi.fn(),
|
||||||
fetchFinanceRechargeSummaries: vi.fn(),
|
fetchFinanceRechargeSummaries: vi.fn(),
|
||||||
fetchFinanceSession: vi.fn(),
|
fetchFinanceSession: vi.fn(),
|
||||||
|
fetchFinanceWithdrawalOptions: vi.fn(),
|
||||||
grantFinanceCoinSellerRechargeOrder: vi.fn(),
|
grantFinanceCoinSellerRechargeOrder: vi.fn(),
|
||||||
getFinanceCoinSellerRechargeExchangeRate: vi.fn(),
|
getFinanceCoinSellerRechargeExchangeRate: vi.fn(),
|
||||||
getFinanceUSDTAddresses: vi.fn(),
|
getFinanceUSDTAddresses: vi.fn(),
|
||||||
@ -31,7 +37,9 @@ vi.mock("./api.js", () => ({
|
|||||||
listFinanceRechargeDetails: vi.fn(),
|
listFinanceRechargeDetails: vi.fn(),
|
||||||
listFinanceRechargeRegions: vi.fn(),
|
listFinanceRechargeRegions: vi.fn(),
|
||||||
listFinanceWithdrawalApplications: vi.fn(),
|
listFinanceWithdrawalApplications: vi.fn(),
|
||||||
|
listOperationsWithdrawalApplications: vi.fn(),
|
||||||
rejectFinanceWithdrawalApplication: vi.fn(),
|
rejectFinanceWithdrawalApplication: vi.fn(),
|
||||||
|
rejectOperationsWithdrawalApplication: vi.fn(),
|
||||||
refreshFinanceGoogleRechargePaid: vi.fn(),
|
refreshFinanceGoogleRechargePaid: vi.fn(),
|
||||||
replaceFinanceCoinSellerRechargeExchangeRate: vi.fn(),
|
replaceFinanceCoinSellerRechargeExchangeRate: vi.fn(),
|
||||||
upsertFinanceUSDTAddress: vi.fn(),
|
upsertFinanceUSDTAddress: vi.fn(),
|
||||||
@ -42,6 +50,7 @@ vi.mock("./api.js", () => ({
|
|||||||
|
|
||||||
const defaultSession = {
|
const defaultSession = {
|
||||||
canAccessFinance: true,
|
canAccessFinance: true,
|
||||||
|
canAuditOperationsWithdrawalApplications: false,
|
||||||
canAuditWithdrawalApplications: false,
|
canAuditWithdrawalApplications: false,
|
||||||
canConfigureCoinSellerRechargeExchangeRate: false,
|
canConfigureCoinSellerRechargeExchangeRate: false,
|
||||||
canCreateCoinSellerRechargeOrder: false,
|
canCreateCoinSellerRechargeOrder: false,
|
||||||
@ -50,6 +59,7 @@ const defaultSession = {
|
|||||||
canVerifyCoinSellerRechargeOrder: false,
|
canVerifyCoinSellerRechargeOrder: false,
|
||||||
canViewAppRechargeDetails: false,
|
canViewAppRechargeDetails: false,
|
||||||
canViewCoinSellerRechargeOrders: false,
|
canViewCoinSellerRechargeOrders: false,
|
||||||
|
canViewOperationsWithdrawalApplications: false,
|
||||||
canViewWithdrawalApplications: true,
|
canViewWithdrawalApplications: true,
|
||||||
permissions: ["finance-withdrawal:view"],
|
permissions: ["finance-withdrawal:view"],
|
||||||
user: { account: "admin", name: "admin" }
|
user: { account: "admin", name: "admin" }
|
||||||
@ -61,10 +71,12 @@ beforeEach(() => {
|
|||||||
vi.mocked(fetchFinanceRechargeOverview).mockResolvedValue(EMPTY_RECHARGE_OVERVIEW);
|
vi.mocked(fetchFinanceRechargeOverview).mockResolvedValue(EMPTY_RECHARGE_OVERVIEW);
|
||||||
vi.mocked(fetchFinanceRechargeSummaries).mockResolvedValue({ merged: EMPTY_RECHARGE_SUMMARY, perApp: [] });
|
vi.mocked(fetchFinanceRechargeSummaries).mockResolvedValue({ merged: EMPTY_RECHARGE_SUMMARY, perApp: [] });
|
||||||
vi.mocked(fetchFinanceSession).mockResolvedValue(defaultSession);
|
vi.mocked(fetchFinanceSession).mockResolvedValue(defaultSession);
|
||||||
|
vi.mocked(fetchFinanceWithdrawalOptions).mockResolvedValue({ apps: [{ appCode: "lalu", appName: "lalu" }] });
|
||||||
vi.mocked(listFinanceCoinSellerRechargeOrders).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
vi.mocked(listFinanceCoinSellerRechargeOrders).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||||
vi.mocked(listFinanceRechargeDetails).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
vi.mocked(listFinanceRechargeDetails).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||||
vi.mocked(listFinanceRechargeRegions).mockResolvedValue([]);
|
vi.mocked(listFinanceRechargeRegions).mockResolvedValue([]);
|
||||||
vi.mocked(listFinanceWithdrawalApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
vi.mocked(listFinanceWithdrawalApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||||
|
vi.mocked(listOperationsWithdrawalApplications).mockResolvedValue({ items: [], page: 1, pageSize: 50, total: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@ -112,3 +124,159 @@ test("defaults recharge finance views to yesterday in China timezone", async ()
|
|||||||
expect(query.start_at_ms).toBe(Date.UTC(2026, 6, 6, 16));
|
expect(query.start_at_ms).toBe(Date.UTC(2026, 6, 6, 16));
|
||||||
expect(query.end_at_ms).toBe(Date.UTC(2026, 6, 7, 16));
|
expect(query.end_at_ms).toBe(Date.UTC(2026, 6, 7, 16));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("opens the permitted operations withdrawal deep link and synchronizes menu changes", async () => {
|
||||||
|
vi.mocked(fetchFinanceSession).mockResolvedValue({
|
||||||
|
...defaultSession,
|
||||||
|
canAuditOperationsWithdrawalApplications: true,
|
||||||
|
canViewOperationsWithdrawalApplications: true,
|
||||||
|
permissions: ["finance-withdrawal:view", "operations-withdrawal:audit"]
|
||||||
|
});
|
||||||
|
window.history.pushState(null, "", "/finance/?view=withdrawalOperations");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<FinanceApp />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: "用户提现运营审核" })).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(listOperationsWithdrawalApplications).toHaveBeenCalled());
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "用户提现财务审核" }));
|
||||||
|
expect(window.location.search).toBe("?view=withdrawalFinance");
|
||||||
|
expect(screen.getByRole("heading", { name: "用户提现财务审核" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back from an unauthorized operations deep link to the finance review queue", async () => {
|
||||||
|
window.history.pushState(null, "", "/finance/?view=withdrawalOperations");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<FinanceApp />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("heading", { name: "用户提现财务审核" })).toBeInTheDocument();
|
||||||
|
expect(window.location.search).toBe("?view=withdrawalFinance");
|
||||||
|
expect(screen.queryByRole("button", { name: "用户提现运营审核" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("withdrawal app filter uses only the MoneyAccess scope catalog", async () => {
|
||||||
|
vi.mocked(fetchFinanceOptions).mockResolvedValue({
|
||||||
|
apps: [
|
||||||
|
{ appCode: "lalu", appName: "Lalu" },
|
||||||
|
{ appCode: "aslan", appName: "Aslan" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
vi.mocked(fetchFinanceWithdrawalOptions).mockResolvedValue({
|
||||||
|
apps: [{ appCode: "lalu", appName: "Lalu" }]
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<FinanceApp />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
const appFilter = await screen.findByRole("combobox", { name: "APP" });
|
||||||
|
fireEvent.mouseDown(appFilter);
|
||||||
|
|
||||||
|
expect(await screen.findByRole("option", { name: "全部 APP" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("option", { name: "Lalu" })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("option", { name: "Aslan" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("submits operations review with the withdrawal record app instead of the global app", async () => {
|
||||||
|
vi.mocked(fetchFinanceSession).mockResolvedValue({
|
||||||
|
...defaultSession,
|
||||||
|
canAuditOperationsWithdrawalApplications: true,
|
||||||
|
canViewOperationsWithdrawalApplications: true,
|
||||||
|
canViewWithdrawalApplications: false,
|
||||||
|
permissions: ["operations-withdrawal:audit"]
|
||||||
|
});
|
||||||
|
vi.mocked(listOperationsWithdrawalApplications).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
appCode: "aslan",
|
||||||
|
createdAtMs: 1760000000000,
|
||||||
|
id: 16,
|
||||||
|
operationsStatus: "pending",
|
||||||
|
status: "pending",
|
||||||
|
userId: "10002",
|
||||||
|
withdrawAddress: "TRX-operations",
|
||||||
|
withdrawAmount: 20,
|
||||||
|
withdrawMethod: "USDT-TRC20"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 1
|
||||||
|
});
|
||||||
|
vi.mocked(approveOperationsWithdrawalApplication).mockResolvedValue({ id: 16 });
|
||||||
|
window.history.pushState(null, "", "/finance/?view=withdrawalOperations");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<FinanceApp />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "通过" }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "确认" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(approveOperationsWithdrawalApplication).toHaveBeenCalledWith(16, "aslan", {});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retries a claimed operations rejection with its saved remark", async () => {
|
||||||
|
vi.mocked(fetchFinanceSession).mockResolvedValue({
|
||||||
|
...defaultSession,
|
||||||
|
canAuditOperationsWithdrawalApplications: true,
|
||||||
|
canViewOperationsWithdrawalApplications: true,
|
||||||
|
canViewWithdrawalApplications: false,
|
||||||
|
permissions: ["operations-withdrawal:audit"]
|
||||||
|
});
|
||||||
|
vi.mocked(listOperationsWithdrawalApplications).mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
appCode: "aslan",
|
||||||
|
createdAtMs: 1760000000000,
|
||||||
|
id: 17,
|
||||||
|
operationsAuditRemark: "命中运营风控",
|
||||||
|
operationsStatus: "rejecting",
|
||||||
|
status: "pending",
|
||||||
|
userId: "10003",
|
||||||
|
withdrawAddress: "TRX-rejecting",
|
||||||
|
withdrawAmount: 30,
|
||||||
|
withdrawMethod: "USDT-TRC20"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
page: 1,
|
||||||
|
pageSize: 50,
|
||||||
|
total: 1
|
||||||
|
});
|
||||||
|
vi.mocked(rejectOperationsWithdrawalApplication).mockResolvedValue({ id: 17 });
|
||||||
|
window.history.pushState(null, "", "/finance/?view=withdrawalOperations");
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ToastProvider>
|
||||||
|
<FinanceApp />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await screen.findByText("拒绝处理中")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "通过" })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "重试拒绝" }));
|
||||||
|
const savedRemark = await screen.findByRole("textbox", { name: "审核备注" });
|
||||||
|
expect(savedRemark).toHaveValue("命中运营风控");
|
||||||
|
expect(savedRemark).toBeDisabled();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "确认" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(rejectOperationsWithdrawalApplication).toHaveBeenCalledWith(17, "aslan", {
|
||||||
|
auditRemark: "命中运营风控"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
expect(approveOperationsWithdrawalApplication).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|||||||
@ -69,6 +69,16 @@ export async function fetchFinanceOptions() {
|
|||||||
return normalizeOptions({ apps: data });
|
return normalizeOptions({ apps: data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchFinanceWithdrawalOptions() {
|
||||||
|
const endpoint = API_ENDPOINTS.getFinanceScope;
|
||||||
|
const data = await apiRequest(apiEndpointPath(API_OPERATIONS.getFinanceScope), {
|
||||||
|
method: endpoint.method
|
||||||
|
});
|
||||||
|
// 提现是跨 App 财务工作台,目录只能使用后端 MoneyAccess 裁剪后的 apps;
|
||||||
|
// 不能回退全量 App 或主站 AppScope,否则“全部 APP”会越过财务范围。
|
||||||
|
return normalizeOptions({ apps: data?.apps });
|
||||||
|
}
|
||||||
|
|
||||||
// 充值详情的 App 目录与其他财务页不同:包含 Yumi 这类 legacy 外部账单源,只用于账单查询。
|
// 充值详情的 App 目录与其他财务页不同:包含 Yumi 这类 legacy 外部账单源,只用于账单查询。
|
||||||
export async function fetchFinanceRechargeApps() {
|
export async function fetchFinanceRechargeApps() {
|
||||||
const endpoint = API_ENDPOINTS.listRechargeApps;
|
const endpoint = API_ENDPOINTS.listRechargeApps;
|
||||||
@ -303,34 +313,39 @@ export async function refreshFinanceGoogleRechargePaid(appCode, transactionIds)
|
|||||||
return Array.isArray(data?.items) ? data.items : [];
|
return Array.isArray(data?.items) ? data.items : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function approveFinanceWithdrawalApplication(applicationId, payload) {
|
export async function approveFinanceWithdrawalApplication(applicationId, appCode, payload) {
|
||||||
const endpoint = API_ENDPOINTS.approveFinanceWithdrawalApplication;
|
const endpoint = API_ENDPOINTS.approveFinanceWithdrawalApplication;
|
||||||
return apiRequest(apiEndpointPath(API_OPERATIONS.approveFinanceWithdrawalApplication, { application_id: applicationId }), {
|
return apiRequest(apiEndpointPath(API_OPERATIONS.approveFinanceWithdrawalApplication, { application_id: applicationId }), {
|
||||||
body: payload,
|
body: payload,
|
||||||
|
// 审核对象可能来自跨 App 列表;必须用记录所属 App 覆盖主站全局选择,避免按错误 App 锁单和执行钱包命令。
|
||||||
|
headers: { [appCodeHeader]: appCode },
|
||||||
method: endpoint.method
|
method: endpoint.method
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rejectFinanceWithdrawalApplication(applicationId, payload) {
|
export async function rejectFinanceWithdrawalApplication(applicationId, appCode, payload) {
|
||||||
const endpoint = API_ENDPOINTS.rejectFinanceWithdrawalApplication;
|
const endpoint = API_ENDPOINTS.rejectFinanceWithdrawalApplication;
|
||||||
return apiRequest(apiEndpointPath(API_OPERATIONS.rejectFinanceWithdrawalApplication, { application_id: applicationId }), {
|
return apiRequest(apiEndpointPath(API_OPERATIONS.rejectFinanceWithdrawalApplication, { application_id: applicationId }), {
|
||||||
body: payload,
|
body: payload,
|
||||||
|
headers: { [appCodeHeader]: appCode },
|
||||||
method: endpoint.method
|
method: endpoint.method
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function approveOperationsWithdrawalApplication(applicationId, payload) {
|
export async function approveOperationsWithdrawalApplication(applicationId, appCode, payload) {
|
||||||
const endpoint = API_ENDPOINTS.approveOperationsWithdrawalApplication;
|
const endpoint = API_ENDPOINTS.approveOperationsWithdrawalApplication;
|
||||||
return apiRequest(apiEndpointPath(API_OPERATIONS.approveOperationsWithdrawalApplication, { application_id: applicationId }), {
|
return apiRequest(apiEndpointPath(API_OPERATIONS.approveOperationsWithdrawalApplication, { application_id: applicationId }), {
|
||||||
body: payload,
|
body: payload,
|
||||||
|
headers: { [appCodeHeader]: appCode },
|
||||||
method: endpoint.method
|
method: endpoint.method
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rejectOperationsWithdrawalApplication(applicationId, payload) {
|
export async function rejectOperationsWithdrawalApplication(applicationId, appCode, payload) {
|
||||||
const endpoint = API_ENDPOINTS.rejectOperationsWithdrawalApplication;
|
const endpoint = API_ENDPOINTS.rejectOperationsWithdrawalApplication;
|
||||||
return apiRequest(apiEndpointPath(API_OPERATIONS.rejectOperationsWithdrawalApplication, { application_id: applicationId }), {
|
return apiRequest(apiEndpointPath(API_OPERATIONS.rejectOperationsWithdrawalApplication, { application_id: applicationId }), {
|
||||||
body: payload,
|
body: payload,
|
||||||
|
headers: { [appCodeHeader]: appCode },
|
||||||
method: endpoint.method
|
method: endpoint.method
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,22 @@
|
|||||||
import { afterEach, expect, test, vi } from "vitest";
|
import { afterEach, expect, test, vi } from "vitest";
|
||||||
|
import { API_ENDPOINTS } from "@/shared/api/generated/endpoints";
|
||||||
import { setAccessToken } from "@/shared/api/request";
|
import { setAccessToken } from "@/shared/api/request";
|
||||||
import {
|
import {
|
||||||
approveFinanceWithdrawalApplication,
|
approveFinanceWithdrawalApplication,
|
||||||
|
approveOperationsWithdrawalApplication,
|
||||||
createFinanceCoinSellerRechargeOrder,
|
createFinanceCoinSellerRechargeOrder,
|
||||||
fetchFinanceOptions,
|
fetchFinanceOptions,
|
||||||
fetchFinanceSession,
|
fetchFinanceSession,
|
||||||
|
fetchFinanceWithdrawalOptions,
|
||||||
getFinanceCoinSellerRechargeExchangeRate,
|
getFinanceCoinSellerRechargeExchangeRate,
|
||||||
getFinanceUSDTAddresses,
|
getFinanceUSDTAddresses,
|
||||||
grantFinanceCoinSellerRechargeOrder,
|
grantFinanceCoinSellerRechargeOrder,
|
||||||
listFinanceCoinSellerRechargeOrders,
|
listFinanceCoinSellerRechargeOrders,
|
||||||
listFinanceRechargeDetails,
|
listFinanceRechargeDetails,
|
||||||
listFinanceWithdrawalApplications,
|
listFinanceWithdrawalApplications,
|
||||||
|
listOperationsWithdrawalApplications,
|
||||||
rejectFinanceWithdrawalApplication,
|
rejectFinanceWithdrawalApplication,
|
||||||
|
rejectOperationsWithdrawalApplication,
|
||||||
quoteFinanceCoinSellerRechargeOrder,
|
quoteFinanceCoinSellerRechargeOrder,
|
||||||
replaceFinanceCoinSellerRechargeExchangeRate,
|
replaceFinanceCoinSellerRechargeExchangeRate,
|
||||||
upsertFinanceUSDTAddress,
|
upsertFinanceUSDTAddress,
|
||||||
@ -43,13 +48,15 @@ test("finance session no longer treats removed application permissions as withdr
|
|||||||
const session = await fetchFinanceSession();
|
const session = await fetchFinanceSession();
|
||||||
|
|
||||||
expect(session.canAccessFinance).toBe(false);
|
expect(session.canAccessFinance).toBe(false);
|
||||||
|
expect(session.canAuditOperationsWithdrawalApplications).toBe(false);
|
||||||
expect(session.canAuditWithdrawalApplications).toBe(false);
|
expect(session.canAuditWithdrawalApplications).toBe(false);
|
||||||
|
expect(session.canViewOperationsWithdrawalApplications).toBe(false);
|
||||||
expect(session.canViewWithdrawalApplications).toBe(false);
|
expect(session.canViewWithdrawalApplications).toBe(false);
|
||||||
expect(session).not.toHaveProperty("canAuditApplication");
|
expect(session).not.toHaveProperty("canAuditApplication");
|
||||||
expect(session).not.toHaveProperty("canCreateApplication");
|
expect(session).not.toHaveProperty("canCreateApplication");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("finance options and withdrawal APIs use generated endpoint paths", async () => {
|
test("finance options and both withdrawal review stages use generated endpoint paths", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn(async (url) => {
|
vi.fn(async (url) => {
|
||||||
@ -71,8 +78,11 @@ test("finance options and withdrawal APIs use generated endpoint paths", async (
|
|||||||
|
|
||||||
const options = await fetchFinanceOptions();
|
const options = await fetchFinanceOptions();
|
||||||
await listFinanceWithdrawalApplications({ page: 1, page_size: 50 });
|
await listFinanceWithdrawalApplications({ page: 1, page_size: 50 });
|
||||||
await approveFinanceWithdrawalApplication(14, { auditImageUrl: "https://cdn.example.com/pay.png", auditRemark: "ok" });
|
await approveFinanceWithdrawalApplication(14, "lalu", { auditImageUrl: "https://cdn.example.com/pay.png", auditRemark: "ok" });
|
||||||
await rejectFinanceWithdrawalApplication(15, { auditRemark: "bad" });
|
await rejectFinanceWithdrawalApplication(15, "aslan", { auditRemark: "bad" });
|
||||||
|
await listOperationsWithdrawalApplications({ page: 1, page_size: 50 });
|
||||||
|
await approveOperationsWithdrawalApplication(16, "huwaa", { auditRemark: "checked" });
|
||||||
|
await rejectOperationsWithdrawalApplication(17, "yumi", { auditRemark: "risk" });
|
||||||
|
|
||||||
const calls = vi.mocked(fetch).mock.calls;
|
const calls = vi.mocked(fetch).mock.calls;
|
||||||
expect(options.apps).toEqual([
|
expect(options.apps).toEqual([
|
||||||
@ -84,10 +94,83 @@ test("finance options and withdrawal APIs use generated endpoint paths", async (
|
|||||||
expect(calls[1][1]?.method).toBe("GET");
|
expect(calls[1][1]?.method).toBe("GET");
|
||||||
expect(String(calls[2][0])).toContain("/api/v1/admin/finance/withdrawal-applications/14/approve");
|
expect(String(calls[2][0])).toContain("/api/v1/admin/finance/withdrawal-applications/14/approve");
|
||||||
expect(calls[2][1]?.method).toBe("POST");
|
expect(calls[2][1]?.method).toBe("POST");
|
||||||
|
expect(calls[2][1]?.headers?.["X-App-Code"]).toBe("lalu");
|
||||||
expect(JSON.parse(String(calls[2][1]?.body))).toEqual({ auditImageUrl: "https://cdn.example.com/pay.png", auditRemark: "ok" });
|
expect(JSON.parse(String(calls[2][1]?.body))).toEqual({ auditImageUrl: "https://cdn.example.com/pay.png", auditRemark: "ok" });
|
||||||
expect(String(calls[3][0])).toContain("/api/v1/admin/finance/withdrawal-applications/15/reject");
|
expect(String(calls[3][0])).toContain("/api/v1/admin/finance/withdrawal-applications/15/reject");
|
||||||
expect(calls[3][1]?.method).toBe("POST");
|
expect(calls[3][1]?.method).toBe("POST");
|
||||||
|
expect(calls[3][1]?.headers?.["X-App-Code"]).toBe("aslan");
|
||||||
expect(JSON.parse(String(calls[3][1]?.body))).toEqual({ auditRemark: "bad" });
|
expect(JSON.parse(String(calls[3][1]?.body))).toEqual({ auditRemark: "bad" });
|
||||||
|
expect(String(calls[4][0])).toContain("/api/v1/admin/operations/withdrawal-applications?");
|
||||||
|
expect(calls[4][1]?.method).toBe("GET");
|
||||||
|
expect(String(calls[5][0])).toContain("/api/v1/admin/operations/withdrawal-applications/16/approve");
|
||||||
|
expect(calls[5][1]?.method).toBe("POST");
|
||||||
|
expect(calls[5][1]?.headers?.["X-App-Code"]).toBe("huwaa");
|
||||||
|
expect(JSON.parse(String(calls[5][1]?.body))).toEqual({ auditRemark: "checked" });
|
||||||
|
expect(String(calls[6][0])).toContain("/api/v1/admin/operations/withdrawal-applications/17/reject");
|
||||||
|
expect(calls[6][1]?.method).toBe("POST");
|
||||||
|
expect(calls[6][1]?.headers?.["X-App-Code"]).toBe("yumi");
|
||||||
|
expect(JSON.parse(String(calls[6][1]?.body))).toEqual({ auditRemark: "risk" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("withdrawal options use the MoneyAccess-filtered finance scope catalog", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
all: false,
|
||||||
|
apps: [{ appCode: "aslan", appName: "Aslan", logoUrl: "https://media.example.com/aslan.png" }],
|
||||||
|
countries: [],
|
||||||
|
regions: [],
|
||||||
|
scopes: [{ appCode: "aslan", regionId: 7 }]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const options = await fetchFinanceWithdrawalOptions();
|
||||||
|
const [url, init] = vi.mocked(fetch).mock.calls[0];
|
||||||
|
|
||||||
|
expect(String(url)).toContain("/api/v1/admin/finance/scope");
|
||||||
|
expect(init?.method).toBe("GET");
|
||||||
|
expect(API_ENDPOINTS.getFinanceScope.permissions).toEqual([
|
||||||
|
"finance:view",
|
||||||
|
"finance-withdrawal:view",
|
||||||
|
"finance-withdrawal:audit",
|
||||||
|
"operations-withdrawal:view",
|
||||||
|
"operations-withdrawal:audit"
|
||||||
|
]);
|
||||||
|
expect(options.apps).toEqual([
|
||||||
|
{ appCode: "aslan", appName: "Aslan", logoUrl: "https://media.example.com/aslan.png" }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("operations withdrawal audit permission grants only the operations review queue", async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () =>
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
permissions: ["operations-withdrawal:audit"],
|
||||||
|
user: { account: "operations-reviewer" }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const session = await fetchFinanceSession();
|
||||||
|
|
||||||
|
expect(session.canAccessFinance).toBe(true);
|
||||||
|
expect(session.canAuditOperationsWithdrawalApplications).toBe(true);
|
||||||
|
expect(session.canViewOperationsWithdrawalApplications).toBe(true);
|
||||||
|
expect(session.canAuditWithdrawalApplications).toBe(false);
|
||||||
|
expect(session.canViewWithdrawalApplications).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("finance coin seller recharge order APIs use generated endpoint paths", async () => {
|
test("finance coin seller recharge order APIs use generated endpoint paths", async () => {
|
||||||
|
|||||||
@ -24,14 +24,17 @@ export function FinanceAuditDialog({
|
|||||||
const decision = application?.decision || "approved";
|
const decision = application?.decision || "approved";
|
||||||
const needsImage = decision === "approved" && requireApprovalImage;
|
const needsImage = decision === "approved" && requireApprovalImage;
|
||||||
const needsRejectRemark = decision === "rejected" && requireRejectRemark;
|
const needsRejectRemark = decision === "rejected" && requireRejectRemark;
|
||||||
|
const isOperationsRejectRetry =
|
||||||
|
decision === "rejected" && application?.operationsStatus === "rejecting" && application?.reviewStage === "operations";
|
||||||
const submitDisabled = loading || (needsImage && !auditImageUrl) || (needsRejectRemark && !remark.trim());
|
const submitDisabled = loading || (needsImage && !auditImageUrl) || (needsRejectRemark && !remark.trim());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setRemark("");
|
// rejecting 已持久化了原拒绝原因;重试必须默认复用该原因,避免补偿动作改写审核语义。
|
||||||
|
setRemark(isOperationsRejectRetry ? application?.operationsAuditRemark || "" : "");
|
||||||
setAuditImageUrl("");
|
setAuditImageUrl("");
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [application?.operationsAuditRemark, isOperationsRejectRetry, open]);
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
onSubmit({ auditImageUrl, decision, remark });
|
onSubmit({ auditImageUrl, decision, remark });
|
||||||
@ -52,7 +55,8 @@ export function FinanceAuditDialog({
|
|||||||
<span>{application?.userId || "-"}</span>
|
<span>{application?.userId || "-"}</span>
|
||||||
</div>
|
</div>
|
||||||
<TextField
|
<TextField
|
||||||
disabled={loading}
|
// 后端会固定沿用首次 claim 的原因;只读展示可避免用户误以为重试能修改审核事实。
|
||||||
|
disabled={loading || isOperationsRejectRetry}
|
||||||
label="审核备注"
|
label="审核备注"
|
||||||
minRows={4}
|
minRows={4}
|
||||||
multiline
|
multiline
|
||||||
|
|||||||
@ -204,7 +204,7 @@ export function FinanceOverview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WithdrawalKpi 是两张独立口径的资金流出卡:
|
// WithdrawalKpi 是两张独立口径的资金流出卡:
|
||||||
// 「用户提现」只统计提现申请审核通过的 USDT(点击进入提现审核页);
|
// 「用户提现」只统计提现申请财务审核通过的 USDT(点击进入用户提现财务审核页);
|
||||||
// 「工资兑换」只统计用户工资转币商的 USDT(likei 平台为银行卡余额兑换金币)。
|
// 「工资兑换」只统计用户工资转币商的 USDT(likei 平台为银行卡余额兑换金币)。
|
||||||
// 两者都不是充值渠道,不参与来源筛选联动,占比按“金额 / 充值总和”表达资金回流强度。
|
// 两者都不是充值渠道,不参与来源筛选联动,占比按“金额 / 充值总和”表达资金回流强度。
|
||||||
function WithdrawalKpi({ loading, onOpenWithdrawals, totalUsd, withdrawal }) {
|
function WithdrawalKpi({ loading, onOpenWithdrawals, totalUsd, withdrawal }) {
|
||||||
@ -219,7 +219,7 @@ function WithdrawalKpi({ loading, onOpenWithdrawals, totalUsd, withdrawal }) {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="finance-kpi finance-kpi--withdrawal"
|
className="finance-kpi finance-kpi--withdrawal"
|
||||||
title="点击进入用户提现申请"
|
title="点击进入用户提现财务审核"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onOpenWithdrawals}
|
onClick={onOpenWithdrawals}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -185,6 +185,27 @@ export function FinanceWithdrawalApplicationList({
|
|||||||
拒绝
|
拒绝
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
|
) : isOperationsReview && item.operationsStatus === "rejecting" && canAudit ? (
|
||||||
|
<span className="finance-row-actions">
|
||||||
|
<span>{statusLabel(item.operationsStatus)}</span>
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
onClick={() =>
|
||||||
|
// rejecting 是已持久认领的拒绝方向,只允许继续调用拒绝接口完成外部补偿。
|
||||||
|
onAudit({
|
||||||
|
...item,
|
||||||
|
decision: "rejected",
|
||||||
|
operation: "用户提现运营审核",
|
||||||
|
reviewStage,
|
||||||
|
targetUserId: item.userId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
重试拒绝
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
) : (
|
) : (
|
||||||
statusLabel(isOperationsReview ? item.operationsStatus : item.status)
|
statusLabel(isOperationsReview ? item.operationsStatus : item.status)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { expect, test, vi } from "vitest";
|
import { expect, test, vi } from "vitest";
|
||||||
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
import { ToastProvider } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import { FinanceWithdrawalApplicationList } from "./FinanceWithdrawalApplicationList.jsx";
|
import { FinanceWithdrawalApplicationList } from "./FinanceWithdrawalApplicationList.jsx";
|
||||||
@ -17,6 +17,7 @@ const baseProps = {
|
|||||||
page: 1
|
page: 1
|
||||||
},
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
|
onAudit: vi.fn(),
|
||||||
onFiltersChange: vi.fn(),
|
onFiltersChange: vi.fn(),
|
||||||
onReload: vi.fn(),
|
onReload: vi.fn(),
|
||||||
options: { apps: [] }
|
options: { apps: [] }
|
||||||
@ -44,6 +45,12 @@ test("renders withdrawal application table with requested finance columns", () =
|
|||||||
auditRemark: "地址不符合要求",
|
auditRemark: "地址不符合要求",
|
||||||
createdAtMs: 1760000000000,
|
createdAtMs: 1760000000000,
|
||||||
id: 7,
|
id: 7,
|
||||||
|
operationsAuditRemark: "资料无误",
|
||||||
|
operationsReviewedAtMs: 1760003600000,
|
||||||
|
operationsReviewerName: "运营一",
|
||||||
|
operationsReviewerUserId: 11,
|
||||||
|
operationsStatus: "approved",
|
||||||
|
status: "approved",
|
||||||
userId: "10001",
|
userId: "10001",
|
||||||
withdrawAddress: "TRX-address",
|
withdrawAddress: "TRX-address",
|
||||||
withdrawAmount: 12.5,
|
withdrawAmount: 12.5,
|
||||||
@ -60,9 +67,10 @@ test("renders withdrawal application table with requested finance columns", () =
|
|||||||
expect(screen.getByRole("columnheader", { name: "提现方式" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "提现方式" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "提现地址" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "提现地址" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "申请时间" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "申请时间" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "审批人" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "运营审核" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "审批时间" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "财务审核人" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "审核原因" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "财务审核时间" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "财务审核原因" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "操作" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "操作" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("lalu")).toBeInTheDocument();
|
expect(screen.getByText("lalu")).toBeInTheDocument();
|
||||||
expect(screen.getByText("10001")).toBeInTheDocument();
|
expect(screen.getByText("10001")).toBeInTheDocument();
|
||||||
@ -71,12 +79,119 @@ test("renders withdrawal application table with requested finance columns", () =
|
|||||||
expect(screen.getByText("TRX-address")).toBeInTheDocument();
|
expect(screen.getByText("TRX-address")).toBeInTheDocument();
|
||||||
expect(screen.getByText("财务一")).toBeInTheDocument();
|
expect(screen.getByText("财务一")).toBeInTheDocument();
|
||||||
expect(screen.getByText("21")).toBeInTheDocument();
|
expect(screen.getByText("21")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("运营一")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("11")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("资料无误")).toBeInTheDocument();
|
||||||
expect(screen.getByText("地址不符合要求")).toBeInTheDocument();
|
expect(screen.getByText("地址不符合要求")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps empty withdrawal table colspan aligned with visible columns", () => {
|
test("keeps empty withdrawal table colspan aligned with visible columns", () => {
|
||||||
const { container } = renderList(baseProps);
|
const { container } = renderList(baseProps);
|
||||||
|
|
||||||
expect(container.querySelector("tbody td")?.colSpan).toBe(10);
|
expect(container.querySelector("tbody td")?.colSpan).toBe(11);
|
||||||
expect(screen.getByText("当前无数据")).toBeInTheDocument();
|
expect(screen.getByText("当前无数据")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("operations review only exposes actions for operations pending applications", () => {
|
||||||
|
const onAudit = vi.fn();
|
||||||
|
renderList({
|
||||||
|
...baseProps,
|
||||||
|
canAudit: true,
|
||||||
|
onAudit,
|
||||||
|
reviewStage: "operations",
|
||||||
|
applications: {
|
||||||
|
...baseProps.applications,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
appCode: "lalu",
|
||||||
|
createdAtMs: 1760000000000,
|
||||||
|
id: 8,
|
||||||
|
operationsStatus: "pending",
|
||||||
|
status: "pending",
|
||||||
|
userId: "10002",
|
||||||
|
withdrawAddress: "TRX-operations",
|
||||||
|
withdrawAmount: 20,
|
||||||
|
withdrawMethod: "USDT-TRC20"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("columnheader", { name: "运营审核人" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "运营审核时间" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("columnheader", { name: "运营审核原因" })).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "通过" }));
|
||||||
|
expect(onAudit).toHaveBeenCalledWith(expect.objectContaining({ id: 8, decision: "approved", reviewStage: "operations" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("operations rejecting applications expose only rejection retry", () => {
|
||||||
|
const onAudit = vi.fn();
|
||||||
|
renderList({
|
||||||
|
...baseProps,
|
||||||
|
canAudit: true,
|
||||||
|
onAudit,
|
||||||
|
reviewStage: "operations",
|
||||||
|
applications: {
|
||||||
|
...baseProps.applications,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
appCode: "lalu",
|
||||||
|
createdAtMs: 1760000000000,
|
||||||
|
id: 10,
|
||||||
|
operationsAuditRemark: "命中运营风控",
|
||||||
|
operationsStatus: "rejecting",
|
||||||
|
status: "pending",
|
||||||
|
userId: "10004",
|
||||||
|
withdrawAddress: "TRX-rejecting",
|
||||||
|
withdrawAmount: 40,
|
||||||
|
withdrawMethod: "USDT-TRC20"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("拒绝处理中")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "通过" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "拒绝" })).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "重试拒绝" }));
|
||||||
|
expect(onAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
decision: "rejected",
|
||||||
|
id: 10,
|
||||||
|
operationsAuditRemark: "命中运营风控",
|
||||||
|
reviewStage: "operations"
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("finance review actions follow the overall pending status", () => {
|
||||||
|
const onAudit = vi.fn();
|
||||||
|
renderList({
|
||||||
|
...baseProps,
|
||||||
|
canAudit: true,
|
||||||
|
onAudit,
|
||||||
|
reviewStage: "finance",
|
||||||
|
applications: {
|
||||||
|
...baseProps.applications,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
appCode: "lalu",
|
||||||
|
createdAtMs: 1760000000000,
|
||||||
|
id: 9,
|
||||||
|
operationsStatus: "approved",
|
||||||
|
status: "pending",
|
||||||
|
userId: "10003",
|
||||||
|
withdrawAddress: "TRX-finance",
|
||||||
|
withdrawAmount: 30,
|
||||||
|
withdrawMethod: "USDT-TRC20"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
total: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "拒绝" }));
|
||||||
|
expect(onAudit).toHaveBeenCalledWith(expect.objectContaining({ id: 9, decision: "rejected", reviewStage: "finance" }));
|
||||||
|
});
|
||||||
|
|||||||
@ -14,8 +14,10 @@ export const FINANCE_PERMISSIONS = {
|
|||||||
|
|
||||||
export const WITHDRAWAL_STATUS = [
|
export const WITHDRAWAL_STATUS = [
|
||||||
["pending", "待审核"],
|
["pending", "待审核"],
|
||||||
|
["rejecting", "拒绝处理中"],
|
||||||
["approved", "已通过"],
|
["approved", "已通过"],
|
||||||
["rejected", "已拒绝"],
|
["rejected", "已拒绝"],
|
||||||
|
["skipped", "存量跳过运营审核"],
|
||||||
];
|
];
|
||||||
|
|
||||||
export const COIN_SELLER_RECHARGE_PROVIDERS = [
|
export const COIN_SELLER_RECHARGE_PROVIDERS = [
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
normalizeRechargeBillPage,
|
normalizeRechargeBillPage,
|
||||||
normalizeWithdrawalApplicationPage,
|
normalizeWithdrawalApplicationPage,
|
||||||
rechargeTypeLabel,
|
rechargeTypeLabel,
|
||||||
|
statusLabel,
|
||||||
validateCoinSellerRechargeOrderPayload,
|
validateCoinSellerRechargeOrderPayload,
|
||||||
validateCoinSellerRechargeExchangeRatePayload,
|
validateCoinSellerRechargeExchangeRatePayload,
|
||||||
} from "./format.js";
|
} from "./format.js";
|
||||||
@ -27,6 +28,12 @@ test("normalizes finance withdrawal application list rows", () => {
|
|||||||
created_at_ms: 1760000000000,
|
created_at_ms: 1760000000000,
|
||||||
freeze_transaction_id: "tx-freeze",
|
freeze_transaction_id: "tx-freeze",
|
||||||
id: 7,
|
id: 7,
|
||||||
|
operations_audit_remark: "资料无误",
|
||||||
|
operations_audit_transaction_id: "tx-operations-audit",
|
||||||
|
operations_reviewed_at_ms: 1760003600000,
|
||||||
|
operations_reviewer_name: "运营一",
|
||||||
|
operations_reviewer_user_id: 11,
|
||||||
|
operations_status: "approved",
|
||||||
salary_asset_type: "HOST_SALARY_USD",
|
salary_asset_type: "HOST_SALARY_USD",
|
||||||
status: "approved",
|
status: "approved",
|
||||||
user_id: 10001,
|
user_id: 10001,
|
||||||
@ -57,6 +64,12 @@ test("normalizes finance withdrawal application list rows", () => {
|
|||||||
createdAtMs: 1760000000000,
|
createdAtMs: 1760000000000,
|
||||||
freezeTransactionId: "tx-freeze",
|
freezeTransactionId: "tx-freeze",
|
||||||
id: 7,
|
id: 7,
|
||||||
|
operationsAuditRemark: "资料无误",
|
||||||
|
operationsAuditTransactionId: "tx-operations-audit",
|
||||||
|
operationsReviewedAtMs: 1760003600000,
|
||||||
|
operationsReviewerName: "运营一",
|
||||||
|
operationsReviewerUserId: 11,
|
||||||
|
operationsStatus: "approved",
|
||||||
salaryAssetType: "HOST_SALARY_USD",
|
salaryAssetType: "HOST_SALARY_USD",
|
||||||
userId: "10001",
|
userId: "10001",
|
||||||
withdrawAddress: "TRX-address",
|
withdrawAddress: "TRX-address",
|
||||||
@ -64,6 +77,8 @@ test("normalizes finance withdrawal application list rows", () => {
|
|||||||
withdrawAmountMinor: 1250,
|
withdrawAmountMinor: 1250,
|
||||||
withdrawMethod: "USDT-TRC20",
|
withdrawMethod: "USDT-TRC20",
|
||||||
});
|
});
|
||||||
|
expect(statusLabel("rejecting")).toBe("拒绝处理中");
|
||||||
|
expect(statusLabel("skipped")).toBe("存量跳过运营审核");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("builds validates and normalizes coin seller recharge orders", () => {
|
test("builds validates and normalizes coin seller recharge orders", () => {
|
||||||
|
|||||||
@ -25,6 +25,11 @@ test("detail drawer keeps the identity hero and shows the expanded admin project
|
|||||||
avatar: "",
|
avatar: "",
|
||||||
ban: { active: true, permanent: true, reason: "risk" },
|
ban: { active: true, permanent: true, reason: "risk" },
|
||||||
birth: "2000-02-29",
|
birth: "2000-02-29",
|
||||||
|
balances: [
|
||||||
|
{ assetType: "AGENCY_SALARY_USD", availableAmount: 100, frozenAmount: 0 },
|
||||||
|
{ assetType: "COIN", availableAmount: 15, frozenAmount: 0 },
|
||||||
|
{ assetType: "HOST_SALARY_USD", availableAmount: 400, frozenAmount: 0 },
|
||||||
|
],
|
||||||
buildNumber: "345",
|
buildNumber: "345",
|
||||||
coin: 1200,
|
coin: 1200,
|
||||||
countryDisplayName: "阿联酋",
|
countryDisplayName: "阿联酋",
|
||||||
@ -109,6 +114,9 @@ test("detail drawer keeps the identity hero and shows the expanded admin project
|
|||||||
fireEvent.click(screen.getByRole("tab", { name: "资产" }));
|
fireEvent.click(screen.getByRole("tab", { name: "资产" }));
|
||||||
expect(screen.getAllByText("累计充值").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("累计充值").length).toBeGreaterThan(0);
|
||||||
expect(screen.getAllByText(/123\.45/).length).toBeGreaterThan(0);
|
expect(screen.getAllByText(/123\.45/).length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText("$1.00")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("$4.00")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("15", { selector: "div" })).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "状态记录" }));
|
fireEvent.click(screen.getByRole("tab", { name: "状态记录" }));
|
||||||
expect(screen.getByText("永久")).toBeInTheDocument();
|
expect(screen.getByText("永久")).toBeInTheDocument();
|
||||||
|
|||||||
@ -488,9 +488,13 @@ function AssetBalanceList({ balances }) {
|
|||||||
<div className={styles.assetRow} key={balance.assetType}>
|
<div className={styles.assetRow} key={balance.assetType}>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.assetName}>{formatAssetType(balance.assetType)}</div>
|
<div className={styles.assetName}>{formatAssetType(balance.assetType)}</div>
|
||||||
<div className={styles.assetMeta}>冻结 {formatNumber(balance.frozenAmount)}</div>
|
<div className={styles.assetMeta}>
|
||||||
|
冻结 {formatAssetAmount(balance.assetType, balance.frozenAmount)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.assetAmount}>
|
||||||
|
{formatAssetAmount(balance.assetType, balance.availableAmount)}
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.assetAmount}>{formatNumber(balance.availableAmount)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -616,6 +620,23 @@ function formatAssetType(value) {
|
|||||||
return labels[normalized] || normalized || "-";
|
return labels[normalized] || normalized || "-";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAssetAmount(assetType, value) {
|
||||||
|
const normalizedAssetType = String(assetType || "").toUpperCase();
|
||||||
|
if (normalizedAssetType.endsWith("_USD")) {
|
||||||
|
const amountMinor = Number(value);
|
||||||
|
if (!Number.isFinite(amountMinor)) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
// 工资类 USD 资产由钱包按美分存储;只在展示层换算为美元,避免把 100 美分误显示为 100 美元。
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
currency: "USD",
|
||||||
|
currencyDisplay: "narrowSymbol",
|
||||||
|
style: "currency",
|
||||||
|
}).format(amountMinor / 100);
|
||||||
|
}
|
||||||
|
return formatNumber(value);
|
||||||
|
}
|
||||||
|
|
||||||
function formatResourceType(value) {
|
function formatResourceType(value) {
|
||||||
const labels = {
|
const labels = {
|
||||||
avatar_frame: "头像框",
|
avatar_frame: "头像框",
|
||||||
|
|||||||
@ -9,7 +9,13 @@ export const WORKSPACE_ENTRIES = [
|
|||||||
href: "/finance/",
|
href: "/finance/",
|
||||||
icon: AccountBalanceOutlined,
|
icon: AccountBalanceOutlined,
|
||||||
label: "财务工作台",
|
label: "财务工作台",
|
||||||
permission: PERMISSIONS.financeView,
|
permissions: [
|
||||||
|
PERMISSIONS.financeView,
|
||||||
|
PERMISSIONS.financeWithdrawalView,
|
||||||
|
PERMISSIONS.financeWithdrawalAudit,
|
||||||
|
PERMISSIONS.operationsWithdrawalView,
|
||||||
|
PERMISSIONS.operationsWithdrawalAudit,
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: "运营管理",
|
description: "运营管理",
|
||||||
@ -21,8 +27,8 @@ export const WORKSPACE_ENTRIES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function getPermittedWorkspaceEntries(can) {
|
export function getPermittedWorkspaceEntries(can) {
|
||||||
// 两个工作台是独立 HTML 入口,展示权限必须与各入口后端接口的既有权限保持一致,避免仅靠前端隐藏造成权限口径漂移。
|
// 独立工作台按任一真实入口权限展示;提现财务/运营审核者没有 finance:view 时也必须能发现自己的审批队列。
|
||||||
return WORKSPACE_ENTRIES.filter((entry) => can(entry.permission));
|
return WORKSPACE_ENTRIES.filter((entry) => (entry.permissions || [entry.permission]).some((permission) => can(permission)));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DashboardWorkspaces() {
|
export function DashboardWorkspaces() {
|
||||||
|
|||||||
@ -31,4 +31,23 @@ describe("DashboardWorkspaces", () => {
|
|||||||
expect(screen.getByRole("link", { name: /运营工作台/ })).toHaveAttribute("href", "/databi/social/");
|
expect(screen.getByRole("link", { name: /运营工作台/ })).toHaveAttribute("href", "/databi/social/");
|
||||||
expect(screen.queryByRole("link", { name: /财务工作台/ })).not.toBeInTheDocument();
|
expect(screen.queryByRole("link", { name: /财务工作台/ })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("shows the finance document entry to withdrawal operations reviewers", () => {
|
||||||
|
can.mockImplementation((permission) => permission === PERMISSIONS.operationsWithdrawalAudit);
|
||||||
|
|
||||||
|
render(<DashboardWorkspaces />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("link", { name: /财务工作台/ })).toHaveAttribute("href", "/finance/");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([PERMISSIONS.financeWithdrawalView, PERMISSIONS.financeWithdrawalAudit])(
|
||||||
|
"shows the finance document entry to finance withdrawal roles with %s",
|
||||||
|
(grantedPermission) => {
|
||||||
|
can.mockImplementation((permission) => permission === grantedPermission);
|
||||||
|
|
||||||
|
render(<DashboardWorkspaces />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("link", { name: /财务工作台/ })).toHaveAttribute("href", "/finance/");
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import AddOutlined from "@mui/icons-material/AddOutlined";
|
import AddOutlined from "@mui/icons-material/AddOutlined";
|
||||||
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
import DeleteOutlineOutlined from "@mui/icons-material/DeleteOutlineOutlined";
|
||||||
|
import FileUploadOutlined from "@mui/icons-material/FileUploadOutlined";
|
||||||
|
import CircularProgress from "@mui/material/CircularProgress";
|
||||||
import InputAdornment from "@mui/material/InputAdornment";
|
import InputAdornment from "@mui/material/InputAdornment";
|
||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
@ -14,11 +16,22 @@ import {
|
|||||||
updateAdapterCatalog,
|
updateAdapterCatalog,
|
||||||
updateAdapterConfigField,
|
updateAdapterConfigField,
|
||||||
} from "@/features/games/adapterConfigModel.js";
|
} from "@/features/games/adapterConfigModel.js";
|
||||||
|
import { uploadImage } from "@/shared/api/upload";
|
||||||
import { Button } from "@/shared/ui/Button.jsx";
|
import { Button } from "@/shared/ui/Button.jsx";
|
||||||
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
|
import { JsonEditorField } from "@/shared/ui/JsonEditorField.jsx";
|
||||||
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
||||||
import styles from "@/features/games/components/AdapterConfigFields.module.css";
|
import styles from "@/features/games/components/AdapterConfigFields.module.css";
|
||||||
|
|
||||||
export function AdapterConfigFields({ adapterType, disabled = false, value, onChange }) {
|
const catalogImageAccept = "image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp";
|
||||||
|
|
||||||
|
export function AdapterConfigFields({
|
||||||
|
adapterType,
|
||||||
|
disabled = false,
|
||||||
|
onChange,
|
||||||
|
onUploadingChange,
|
||||||
|
uploadDisabled = false,
|
||||||
|
value,
|
||||||
|
}) {
|
||||||
const definition = adapterDefinition(adapterType);
|
const definition = adapterDefinition(adapterType);
|
||||||
const configValid = Boolean(readAdapterConfig(value));
|
const configValid = Boolean(readAdapterConfig(value));
|
||||||
const visualFieldsDisabled = disabled || !configValid;
|
const visualFieldsDisabled = disabled || !configValid;
|
||||||
@ -46,8 +59,10 @@ export function AdapterConfigFields({ adapterType, disabled = false, value, onCh
|
|||||||
<GameCatalogEditor
|
<GameCatalogEditor
|
||||||
adapterType={adapterType}
|
adapterType={adapterType}
|
||||||
disabled={visualFieldsDisabled}
|
disabled={visualFieldsDisabled}
|
||||||
|
uploadDisabled={uploadDisabled}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
onUploadingChange={onUploadingChange}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{(definition.advancedFields || []).length > 0 ? (
|
{(definition.advancedFields || []).length > 0 ? (
|
||||||
@ -144,7 +159,7 @@ function AdapterField({ disabled, field, value, onChange }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
|
function GameCatalogEditor({ adapterType, disabled, uploadDisabled, value, onChange, onUploadingChange }) {
|
||||||
const definition = adapterDefinition(adapterType);
|
const definition = adapterDefinition(adapterType);
|
||||||
const externalRows = useMemo(() => adapterCatalogRows(value), [value]);
|
const externalRows = useMemo(() => adapterCatalogRows(value), [value]);
|
||||||
const externalSignature = useMemo(() => catalogSignature(externalRows), [externalRows]);
|
const externalSignature = useMemo(() => catalogSignature(externalRows), [externalRows]);
|
||||||
@ -152,28 +167,53 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
|
|||||||
const adapterTypeRef = useRef(adapterType);
|
const adapterTypeRef = useRef(adapterType);
|
||||||
const rowSequence = useRef(externalRows.length);
|
const rowSequence = useRef(externalRows.length);
|
||||||
const [rows, setRows] = useState(() => withRowKeys(externalRows, "initial"));
|
const [rows, setRows] = useState(() => withRowKeys(externalRows, "initial"));
|
||||||
|
const rowsRef = useRef(rows);
|
||||||
|
const valueRef = useRef(value);
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
const [uploadingAssets, setUploadingAssets] = useState(() => new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
valueRef.current = value;
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
}, [onChange, value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onUploadingChange?.(uploadingAssets.size > 0);
|
||||||
|
}, [onUploadingChange, uploadingAssets]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => onUploadingChange?.(false);
|
||||||
|
}, [onUploadingChange]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const adapterChanged = adapterTypeRef.current !== adapterType;
|
const adapterChanged = adapterTypeRef.current !== adapterType;
|
||||||
if (adapterChanged || externalSignature !== lastEmittedSignature.current) {
|
if (adapterChanged || externalSignature !== lastEmittedSignature.current) {
|
||||||
rowSequence.current += externalRows.length;
|
rowSequence.current += externalRows.length;
|
||||||
setRows(withRowKeys(externalRows, `sync-${rowSequence.current}`));
|
const nextRows = withRowKeys(externalRows, `sync-${rowSequence.current}`);
|
||||||
|
rowsRef.current = nextRows;
|
||||||
|
setRows(nextRows);
|
||||||
|
if (adapterChanged) {
|
||||||
|
// 切换厂商会卸载原素材输入;清空聚合状态,迟到的上传结果由字段自身的代次保护丢弃。
|
||||||
|
setUploadingAssets(new Set());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
adapterTypeRef.current = adapterType;
|
adapterTypeRef.current = adapterType;
|
||||||
lastEmittedSignature.current = externalSignature;
|
lastEmittedSignature.current = externalSignature;
|
||||||
}, [adapterType, externalRows, externalSignature]);
|
}, [adapterType, externalRows, externalSignature]);
|
||||||
|
|
||||||
const emitRows = (nextRows) => {
|
const emitRows = (nextRows) => {
|
||||||
|
// 上传完成属于异步回调,必须基于最新 rows/config patch,避免并发上传或期间编辑被旧闭包覆盖。
|
||||||
|
rowsRef.current = nextRows;
|
||||||
setRows(nextRows);
|
setRows(nextRows);
|
||||||
const nextValue = updateAdapterCatalog(value, nextRows);
|
const nextValue = updateAdapterCatalog(valueRef.current, nextRows);
|
||||||
lastEmittedSignature.current = catalogSignature(adapterCatalogRows(nextValue));
|
lastEmittedSignature.current = catalogSignature(adapterCatalogRows(nextValue));
|
||||||
onChange(nextValue);
|
onChangeRef.current(nextValue);
|
||||||
};
|
};
|
||||||
|
|
||||||
const addRow = () => {
|
const addRow = () => {
|
||||||
rowSequence.current += 1;
|
rowSequence.current += 1;
|
||||||
setRows((current) => [
|
const nextRows = [
|
||||||
...current,
|
...rowsRef.current,
|
||||||
{
|
{
|
||||||
rowKey: `draft-${rowSequence.current}`,
|
rowKey: `draft-${rowSequence.current}`,
|
||||||
providerGameId: "",
|
providerGameId: "",
|
||||||
@ -183,22 +223,38 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
|
|||||||
coverUrl: "",
|
coverUrl: "",
|
||||||
gameLevelUid: "",
|
gameLevelUid: "",
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
rowsRef.current = nextRows;
|
||||||
|
setRows(nextRows);
|
||||||
};
|
};
|
||||||
|
|
||||||
const changeRow = (rowKey, patch) => {
|
const changeRow = (rowKey, patch) => {
|
||||||
emitRows(rows.map((row) => (row.rowKey === rowKey ? { ...row, ...patch } : row)));
|
emitRows(rowsRef.current.map((row) => (row.rowKey === rowKey ? { ...row, ...patch } : row)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeRow = (rowKey) => {
|
const removeRow = (rowKey) => {
|
||||||
emitRows(rows.filter((row) => row.rowKey !== rowKey));
|
emitRows(rowsRef.current.filter((row) => row.rowKey !== rowKey));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeUploadingState = (assetKey, uploading) => {
|
||||||
|
setUploadingAssets((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (uploading) {
|
||||||
|
next.add(assetKey);
|
||||||
|
} else {
|
||||||
|
next.delete(assetKey);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const assetsUploading = uploadingAssets.size > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={styles.catalog}>
|
<section className={styles.catalog}>
|
||||||
<div className={styles.catalogHeader}>
|
<div className={styles.catalogHeader}>
|
||||||
<div className={styles.groupTitle}>游戏目录 · {rows.length}</div>
|
<div className={styles.groupTitle}>游戏目录 · {rows.length}</div>
|
||||||
<Button disabled={disabled} onClick={addRow}>
|
<Button disabled={disabled || assetsUploading} onClick={addRow}>
|
||||||
<AddOutlined fontSize="small" />
|
<AddOutlined fontSize="small" />
|
||||||
添加游戏
|
添加游戏
|
||||||
</Button>
|
</Button>
|
||||||
@ -254,7 +310,7 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
aria-label={`删除第 ${index + 1} 个游戏`}
|
aria-label={`删除第 ${index + 1} 个游戏`}
|
||||||
disabled={disabled}
|
disabled={disabled || assetsUploading}
|
||||||
onClick={() => removeRow(row.rowKey)}
|
onClick={() => removeRow(row.rowKey)}
|
||||||
>
|
>
|
||||||
<DeleteOutlineOutlined fontSize="small" />
|
<DeleteOutlineOutlined fontSize="small" />
|
||||||
@ -263,18 +319,24 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
|
|||||||
<details className={styles.rowDetails}>
|
<details className={styles.rowDetails}>
|
||||||
<summary>素材与游戏参数</summary>
|
<summary>素材与游戏参数</summary>
|
||||||
<div className={styles.rowExtra}>
|
<div className={styles.rowExtra}>
|
||||||
<TextField
|
<CatalogImageURLField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="图标 URL"
|
label="图标 URL"
|
||||||
|
uploadDisabled={uploadDisabled}
|
||||||
value={row.iconUrl}
|
value={row.iconUrl}
|
||||||
onChange={(event) => changeRow(row.rowKey, { iconUrl: event.target.value })}
|
onChange={(iconUrl) => changeRow(row.rowKey, { iconUrl })}
|
||||||
|
onUploadingChange={(uploading) =>
|
||||||
|
changeUploadingState(`${row.rowKey}:icon`, uploading)
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<CatalogImageURLField
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
label="封面 URL"
|
label="封面 URL"
|
||||||
|
uploadDisabled={uploadDisabled}
|
||||||
value={row.coverUrl}
|
value={row.coverUrl}
|
||||||
onChange={(event) =>
|
onChange={(coverUrl) => changeRow(row.rowKey, { coverUrl })}
|
||||||
changeRow(row.rowKey, { coverUrl: event.target.value })
|
onUploadingChange={(uploading) =>
|
||||||
|
changeUploadingState(`${row.rowKey}:cover`, uploading)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{adapterType === "yomi_v4" ? (
|
{adapterType === "yomi_v4" ? (
|
||||||
@ -302,6 +364,106 @@ function GameCatalogEditor({ adapterType, disabled, value, onChange }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CatalogImageURLField({ disabled, label, onChange, onUploadingChange, uploadDisabled, value }) {
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
const uploadGenerationRef = useRef(0);
|
||||||
|
const onUploadingChangeRef = useRef(onUploadingChange);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onUploadingChangeRef.current = onUploadingChange;
|
||||||
|
}, [onUploadingChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// 网络请求不能中途取消;字段卸载后作废结果,防止回写已经切换的厂商或已删除行。
|
||||||
|
uploadGenerationRef.current += 1;
|
||||||
|
onUploadingChangeRef.current?.(false);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const openPicker = () => {
|
||||||
|
if (!disabled && !uploadDisabled && !uploading) {
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (event) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uploadGeneration = ++uploadGenerationRef.current;
|
||||||
|
setUploading(true);
|
||||||
|
onUploadingChange?.(true);
|
||||||
|
try {
|
||||||
|
const result = await uploadImage(file);
|
||||||
|
if (uploadGeneration !== uploadGenerationRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(result.url);
|
||||||
|
showToast(`${label.replace(/ URL$/, "")}上传成功`, "success");
|
||||||
|
} catch (err) {
|
||||||
|
if (uploadGeneration !== uploadGenerationRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 上传失败只提示,不清空用户原先手填或已保存的 URL。
|
||||||
|
showToast(err.message || "图片上传失败", "error");
|
||||||
|
} finally {
|
||||||
|
if (uploadGeneration === uploadGenerationRef.current) {
|
||||||
|
setUploading(false);
|
||||||
|
onUploadingChange?.(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.imageURLField}>
|
||||||
|
<TextField
|
||||||
|
disabled={disabled || uploading}
|
||||||
|
label={label}
|
||||||
|
placeholder="粘贴图片 URL,或点击上传"
|
||||||
|
slotProps={{
|
||||||
|
// 历史厂商配置允许相对路径和协议相对地址;保留文本校验,只用 inputMode 提供 URL 输入体验。
|
||||||
|
htmlInput: { inputMode: "url" },
|
||||||
|
input: {
|
||||||
|
endAdornment: (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<button
|
||||||
|
aria-label={`上传${label.replace(/ URL$/, "")}`}
|
||||||
|
className={styles.uploadButton}
|
||||||
|
disabled={disabled || uploadDisabled || uploading}
|
||||||
|
type="button"
|
||||||
|
onClick={openPicker}
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<CircularProgress color="inherit" size={14} />
|
||||||
|
) : (
|
||||||
|
<FileUploadOutlined fontSize="small" />
|
||||||
|
)}
|
||||||
|
上传
|
||||||
|
</button>
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
accept={catalogImageAccept}
|
||||||
|
className={styles.fileInput}
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function withRowKeys(rows, prefix) {
|
function withRowKeys(rows, prefix) {
|
||||||
return rows.map((row, index) => ({ ...row, rowKey: `${prefix}-${index}` }));
|
return rows.map((row, index) => ({ ...row, rowKey: `${prefix}-${index}` }));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -119,6 +119,52 @@
|
|||||||
padding-top: var(--space-2);
|
padding-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.imageURLField {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.imageURLField > :global(.MuiTextField-root) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadButton {
|
||||||
|
display: inline-flex;
|
||||||
|
height: 28px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 0 7px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-control);
|
||||||
|
background: var(--bg-input-strong);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadButton:hover:not(:disabled) {
|
||||||
|
border-color: var(--primary-border);
|
||||||
|
background: var(--primary-hover);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadButton:focus-visible {
|
||||||
|
outline: 3px solid var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadButton:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.48;
|
||||||
|
}
|
||||||
|
|
||||||
.advancedEditor,
|
.advancedEditor,
|
||||||
.details > .fieldGrid {
|
.details > .fieldGrid {
|
||||||
padding: var(--space-3);
|
padding: var(--space-3);
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import MenuItem from "@mui/material/MenuItem";
|
import MenuItem from "@mui/material/MenuItem";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
|
import { useState } from "react";
|
||||||
import { AdapterConfigFields } from "@/features/games/components/AdapterConfigFields.jsx";
|
import { AdapterConfigFields } from "@/features/games/components/AdapterConfigFields.jsx";
|
||||||
import {
|
import {
|
||||||
adapterDefinition,
|
adapterDefinition,
|
||||||
@ -17,6 +18,7 @@ import {
|
|||||||
import styles from "@/features/games/components/PlatformFormDialog.module.css";
|
import styles from "@/features/games/components/PlatformFormDialog.module.css";
|
||||||
|
|
||||||
export function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
|
export function PlatformFormDialog({ form, loading, mode, open, page, onClose, onSubmit }) {
|
||||||
|
const [assetsUploading, setAssetsUploading] = useState(false);
|
||||||
const definition = adapterDefinition(form.adapterType);
|
const definition = adapterDefinition(form.adapterType);
|
||||||
const configValid = Boolean(readAdapterConfig(form.adapterConfigJson));
|
const configValid = Boolean(readAdapterConfig(form.adapterConfigJson));
|
||||||
const disabled = !page.abilities.canUpdate;
|
const disabled = !page.abilities.canUpdate;
|
||||||
@ -25,6 +27,15 @@ export function PlatformFormDialog({ form, loading, mode, open, page, onClose, o
|
|||||||
)?.[0];
|
)?.[0];
|
||||||
const { configuredSecretUsable, required: secretRequired } = adapterSecretState(form, mode === "edit");
|
const { configuredSecretUsable, required: secretRequired } = adapterSecretState(form, mode === "edit");
|
||||||
|
|
||||||
|
const submitForm = (event) => {
|
||||||
|
if (assetsUploading) {
|
||||||
|
// 禁用按钮之外再拦截 Enter 隐式提交,避免把上传前的旧 URL 写入平台配置。
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSubmit(event);
|
||||||
|
};
|
||||||
|
|
||||||
const changeAdapter = (adapterType) => {
|
const changeAdapter = (adapterType) => {
|
||||||
const previousDefinition = adapterDefinition(form.adapterType);
|
const previousDefinition = adapterDefinition(form.adapterType);
|
||||||
const nextDefinition = adapterDefinition(adapterType);
|
const nextDefinition = adapterDefinition(adapterType);
|
||||||
@ -77,11 +88,11 @@ export function PlatformFormDialog({ form, loading, mode, open, page, onClose, o
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
open={open}
|
open={open}
|
||||||
size="large"
|
size="large"
|
||||||
submitDisabled={disabled || loading || !configValid}
|
submitDisabled={disabled || loading || assetsUploading || !configValid}
|
||||||
submitLabel={mode === "edit" ? "保存平台" : "创建平台"}
|
submitLabel={mode === "edit" ? "保存平台" : "创建平台"}
|
||||||
title={mode === "edit" ? "编辑平台" : "添加平台"}
|
title={mode === "edit" ? "编辑平台" : "添加平台"}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
onSubmit={onSubmit}
|
onSubmit={submitForm}
|
||||||
>
|
>
|
||||||
<AdminFormSection title="平台信息">
|
<AdminFormSection title="平台信息">
|
||||||
<AdminFormFieldGrid columns="repeat(4, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(4, minmax(0, 1fr))">
|
||||||
@ -125,7 +136,8 @@ export function PlatformFormDialog({ form, loading, mode, open, page, onClose, o
|
|||||||
<AdminFormSection title="接入与回调">
|
<AdminFormSection title="接入与回调">
|
||||||
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
||||||
<TextField
|
<TextField
|
||||||
disabled={disabled || (mode === "edit" && Boolean(fixedAdapterType))}
|
// 上传结果归属当前厂商目录;上传结束前不允许切换适配器,避免结果落到另一份协议草稿。
|
||||||
|
disabled={disabled || assetsUploading || (mode === "edit" && Boolean(fixedAdapterType))}
|
||||||
helperText={mode === "edit" && fixedAdapterType ? "协议固定适配器" : undefined}
|
helperText={mode === "edit" && fixedAdapterType ? "协议固定适配器" : undefined}
|
||||||
label="适配器"
|
label="适配器"
|
||||||
select
|
select
|
||||||
@ -183,8 +195,14 @@ export function PlatformFormDialog({ form, loading, mode, open, page, onClose, o
|
|||||||
<AdapterConfigFields
|
<AdapterConfigFields
|
||||||
adapterType={form.adapterType}
|
adapterType={form.adapterType}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
// open 变化时立即重挂载,关闭过渡期间也会作废旧上传结果,不能回写下一次打开的表单。
|
||||||
|
key={open ? "active-platform-form" : "inactive-platform-form"}
|
||||||
|
uploadDisabled={!page.abilities.canUpload}
|
||||||
value={form.adapterConfigJson}
|
value={form.adapterConfigJson}
|
||||||
onChange={(adapterConfigJson) => page.setPlatformForm({ ...form, adapterConfigJson })}
|
onChange={(adapterConfigJson) =>
|
||||||
|
page.setPlatformForm((current) => ({ ...current, adapterConfigJson }))
|
||||||
|
}
|
||||||
|
onUploadingChange={setAssetsUploading}
|
||||||
/>
|
/>
|
||||||
</AdminFormSection>
|
</AdminFormSection>
|
||||||
</AdminFormDialog>
|
</AdminFormDialog>
|
||||||
|
|||||||
@ -1317,7 +1317,7 @@ export const API_ENDPOINTS: Record<ApiOperationId, ApiEndpoint> = {
|
|||||||
operationId: API_OPERATIONS.getFinanceScope,
|
operationId: API_OPERATIONS.getFinanceScope,
|
||||||
path: "/v1/admin/finance/scope",
|
path: "/v1/admin/finance/scope",
|
||||||
permission: "finance:view",
|
permission: "finance:view",
|
||||||
permissions: ["finance:view"]
|
permissions: ["finance:view","finance-withdrawal:view","finance-withdrawal:audit","operations-withdrawal:view","operations-withdrawal:audit"]
|
||||||
},
|
},
|
||||||
getFinanceUSDTAddresses: {
|
getFinanceUSDTAddresses: {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
|
|||||||
23
src/shared/api/generated/schema.d.ts
vendored
23
src/shared/api/generated/schema.d.ts
vendored
@ -6533,8 +6533,11 @@ export interface components {
|
|||||||
freezeTransactionId?: string;
|
freezeTransactionId?: string;
|
||||||
auditTransactionId?: string;
|
auditTransactionId?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
/** @enum {string} */
|
/**
|
||||||
operationsStatus?: "pending" | "approved" | "rejected";
|
* @description 运营审核状态;rejecting 表示拒绝已持久认领,钱包释放或通知仍待重试。
|
||||||
|
* @enum {string}
|
||||||
|
*/
|
||||||
|
operationsStatus?: "pending" | "rejecting" | "approved" | "rejected" | "skipped";
|
||||||
operationsReviewerUserId?: number | null;
|
operationsReviewerUserId?: number | null;
|
||||||
operationsReviewerName?: string;
|
operationsReviewerName?: string;
|
||||||
operationsAuditRemark?: string;
|
operationsAuditRemark?: string;
|
||||||
@ -11257,7 +11260,9 @@ export interface operations {
|
|||||||
approveFinanceWithdrawalApplication: {
|
approveFinanceWithdrawalApplication: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header: {
|
||||||
|
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||||
|
};
|
||||||
path: {
|
path: {
|
||||||
application_id: number;
|
application_id: number;
|
||||||
};
|
};
|
||||||
@ -11271,7 +11276,9 @@ export interface operations {
|
|||||||
rejectFinanceWithdrawalApplication: {
|
rejectFinanceWithdrawalApplication: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header: {
|
||||||
|
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||||
|
};
|
||||||
path: {
|
path: {
|
||||||
application_id: number;
|
application_id: number;
|
||||||
};
|
};
|
||||||
@ -11302,7 +11309,9 @@ export interface operations {
|
|||||||
approveOperationsWithdrawalApplication: {
|
approveOperationsWithdrawalApplication: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header: {
|
||||||
|
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||||
|
};
|
||||||
path: {
|
path: {
|
||||||
application_id: number;
|
application_id: number;
|
||||||
};
|
};
|
||||||
@ -11316,7 +11325,9 @@ export interface operations {
|
|||||||
rejectOperationsWithdrawalApplication: {
|
rejectOperationsWithdrawalApplication: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header: {
|
||||||
|
"X-App-Code": components["parameters"]["AppCodeHeader"];
|
||||||
|
};
|
||||||
path: {
|
path: {
|
||||||
application_id: number;
|
application_id: number;
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user