From 1b9982e52c0f5a1986a60cd8595e221a480bd789 Mon Sep 17 00:00:00 2001 From: zhx Date: Mon, 13 Jul 2026 11:10:08 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=88=9B=E5=BB=BA=E6=97=B6?= =?UTF-8?q?=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- contracts/admin-openapi.json | 18 +- .../FinanceCoinSellerRechargeCreateDialog.jsx | 366 +++++++++++ .../FinanceCoinSellerRechargeOrderList.jsx | 291 +-------- ...inanceCoinSellerRechargeOrderList.test.jsx | 17 +- finance/src/constants.js | 1 + finance/src/format.js | 6 + finance/src/format.test.js | 7 + finance/src/styles/index.css | 586 +++++++++++++++++- .../app-users/hooks/useAppUsersPage.js | 3 +- .../app-users/levelAdjustmentSchema.test.ts | 22 + .../app-users/pages/AppUserListPage.jsx | 2 +- src/features/app-users/schema.ts | 6 +- src/shared/ui/DateTimePicker.jsx | 189 ++++++ src/shared/ui/DateTimePicker.test.jsx | 15 + src/shared/ui/TimeRangeFilter.jsx | 12 +- 15 files changed, 1252 insertions(+), 289 deletions(-) create mode 100644 finance/src/components/FinanceCoinSellerRechargeCreateDialog.jsx create mode 100644 src/features/app-users/levelAdjustmentSchema.test.ts create mode 100644 src/shared/ui/DateTimePicker.jsx create mode 100644 src/shared/ui/DateTimePicker.test.jsx diff --git a/contracts/admin-openapi.json b/contracts/admin-openapi.json index ea51bd0..891a55d 100644 --- a/contracts/admin-openapi.json +++ b/contracts/admin-openapi.json @@ -8581,7 +8581,7 @@ "required": ["track", "level", "duration_days"], "properties": { "track": { "type": "string", "enum": ["wealth", "charm"] }, - "level": { "type": "integer", "minimum": 1, "maximum": 50 }, + "level": { "type": "integer", "minimum": 1, "maximum": 100 }, "duration_days": { "type": "integer", "minimum": 1 } } } @@ -12028,8 +12028,10 @@ "receiveAddress": { "type": "string" }, - "orderDate": { - "type": "string" + "orderDateMs": { + "type": "integer", + "format": "int64", + "description": "订单业务时间;历史订单为 0 时回退到 createdAtMs" }, "status": { "type": "string" @@ -12218,6 +12220,11 @@ "type": "boolean", "description": "补单时为 true;订单正常计入充值,但服务端强制金币数为 0" }, + "orderDateMs": { + "type": "integer", + "format": "int64", + "description": "订单业务时间;未传时服务端默认为当前时间" + }, "providerCode": { "type": "string", "enum": ["mifapay", "v5pay", "usdt"] @@ -12459,6 +12466,11 @@ "type": "integer", "minimum": 0 }, + "orderDateMs": { + "type": "integer", + "format": "int64", + "description": "订单业务时间,用于三方凭证按日期查询" + }, "providerCurrencyCode": { "type": "string" }, diff --git a/finance/src/components/FinanceCoinSellerRechargeCreateDialog.jsx b/finance/src/components/FinanceCoinSellerRechargeCreateDialog.jsx new file mode 100644 index 0000000..c144b79 --- /dev/null +++ b/finance/src/components/FinanceCoinSellerRechargeCreateDialog.jsx @@ -0,0 +1,366 @@ +import AccountCircleOutlined from "@mui/icons-material/AccountCircleOutlined"; +import CheckCircleOutlineOutlined from "@mui/icons-material/CheckCircleOutlineOutlined"; +import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined"; +import PaymentsOutlined from "@mui/icons-material/PaymentsOutlined"; +import ReceiptLongOutlined from "@mui/icons-material/ReceiptLongOutlined"; +import Alert from "@mui/material/Alert"; +import Autocomplete from "@mui/material/Autocomplete"; +import Button from "@mui/material/Button"; +import Dialog from "@mui/material/Dialog"; +import DialogActions from "@mui/material/DialogActions"; +import DialogContent from "@mui/material/DialogContent"; +import DialogTitle from "@mui/material/DialogTitle"; +import InputAdornment from "@mui/material/InputAdornment"; +import MenuItem from "@mui/material/MenuItem"; +import TextField from "@mui/material/TextField"; +import { DateTimePicker } from "@/shared/ui/DateTimePicker.jsx"; +import { + COIN_SELLER_RECHARGE_CHAINS, + COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES, + COIN_SELLER_RECHARGE_PROVIDERS, +} from "../constants.js"; +import { formatAmount, formatTime, formatUsdMinor } from "../format.js"; + +export function FinanceCoinSellerRechargeCreateDialog({ + apps, + form, + formError, + loading, + onChange, + onClose, + onSubmit, + onVerifyReceipt, + open, + quote, + quoteError, + quoteLoading, + receiptLoading, + receiptVerification, +}) { + const disabled = loading || receiptLoading; + // 财务录单的主按钮只在“金额结果已确定 + 三方凭证已校验”时开放,避免界面优化后弱化原有风控门槛。 + const canCreate = receiptVerification?.verified === true && (form.isMakeup || Boolean(quote?.coinAmount)) && !disabled; + const selectedAppName = apps.find((app) => app.appCode === form.appCode)?.appName || form.appCode || "待选择"; + + return ( + + + + + 录入币商充值 + 完成付款凭证校验后,订单将计入充值流水 + + + +
+ {formError ? {formError} : null} +
+
+ } index="1" title="充值对象"> +
+ {apps.length ? ( + onChange("appCode", event.target.value)} + > + {apps.map((app) => ( + + {app.appName || app.appCode} + + ))} + + ) : ( + onChange("appCode", event.target.value)} + /> + )} + onChange("targetUserId", event.target.value)} + /> +
+
+ + } index="2" title="入账金额"> +
+ $ }, + }} + type="number" + value={form.usdAmount} + onChange={(event) => onChange("usdAmount", event.target.value)} + /> + onChange("orderDateMs", value)} + /> +
+
+ + +
+ +
+ + } index="3" title="支付凭证"> +
+ onChange("providerCode", event.target.value)} + > + {COIN_SELLER_RECHARGE_PROVIDERS.map(([value, label]) => ( + + {label} + + ))} + + {form.providerCode === "usdt" ? ( + onChange("chain", event.target.value)} + > + {COIN_SELLER_RECHARGE_CHAINS.map(([value, label]) => ( + + {label} + + ))} + + ) : ( + option || ""} + isOptionEqualToValue={(option, value) => option === value} + noOptionsText="无匹配币种" + options={COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES[form.providerCode] || []} + renderInput={(params) => } + value={form.providerCurrencyCode || null} + onChange={(_, value) => onChange("providerCurrencyCode", value || "")} + /> + )} + {form.providerCode !== "usdt" ? ( + onChange("providerAmount", event.target.value)} + /> + ) : null} +
+
+ onChange("externalOrderNo", event.target.value)} + /> + +
+ {receiptVerification ? : null} + onChange("remark", event.target.value)} + /> +
+
+ + +
+
+
+ + + {receiptVerification?.verified ? : } + {receiptVerification?.verified ? "订单已校验,可以创建" : "校验三方订单后才能创建"} + + + + +
+ ); +} + +function FormSection({ children, icon, index, title }) { + return ( +
+
+ {index} + +

{title}

+
+
{children}
+
+ ); +} + +function CoinQuoteResult({ form, quote, quoteError, quoteLoading }) { + const coinAmount = form.isMakeup ? 0 : quote?.coinAmount; + return ( +
+ + {form.isMakeup ? "本次不发放" : "预计到账"} + {coinAmount === undefined ? "—" : formatAmount(coinAmount)} + 金币 + +

{quoteHelperText(quote, quoteError, quoteLoading, form.isMakeup)}

+
+ ); +} + +function OrderPreview({ appName, form, quote, quoteLoading, receiptVerification }) { + const usdAmount = Number(form.usdAmount || 0); + const coinAmount = form.isMakeup ? 0 : Number(quote?.coinAmount || 0); + const providerLabel = COIN_SELLER_RECHARGE_PROVIDERS.find(([value]) => value === form.providerCode)?.[1] || "待选择"; + return ( + + ); +} + +function quoteHelperText(quote, error, loading, isMakeup) { + if (isMakeup) return "补单不发放金币,充值金额仍正常计入统计"; + if (loading) return "正在计算兑换比例与金币数量"; + if (error) return error; + if (!quote) return "填写 APP、用户 ID 和 USD 金额后自动计算"; + const source = quote.rateSource === "whitelist" + ? `用户白名单 ${quote.matchedUserId || ""}`.trim() + : `${quote.minUsdAmount}-${quote.maxUsdAmount} USD 区间`; + return `${source} · 1 USD = ${formatAmount(quote.coinsPerUsd)} 金币`; +} + +function ReceiptVerificationBanner({ verification }) { + const detail = [ + verification.status && `状态 ${verification.status}`, + verification.providerOrderId && `三方单 ${verification.providerOrderId}`, + verification.currencyCode && verification.providerAmountMinor !== undefined + ? formatUsdMinor(verification.providerAmountMinor, verification.currencyCode) + : "", + verification.failureReason, + ].filter(Boolean).join(" / "); + return ( + + {verification.verified ? "校验通过" : "校验未通过"}{detail ? `:${detail}` : ""} + + ); +} diff --git a/finance/src/components/FinanceCoinSellerRechargeOrderList.jsx b/finance/src/components/FinanceCoinSellerRechargeOrderList.jsx index 845a95e..0a6a201 100644 --- a/finance/src/components/FinanceCoinSellerRechargeOrderList.jsx +++ b/finance/src/components/FinanceCoinSellerRechargeOrderList.jsx @@ -3,20 +3,11 @@ import FactCheckOutlined from "@mui/icons-material/FactCheckOutlined"; import PaidOutlined from "@mui/icons-material/PaidOutlined"; import RefreshOutlined from "@mui/icons-material/RefreshOutlined"; import SettingsOutlined from "@mui/icons-material/SettingsOutlined"; -import Alert from "@mui/material/Alert"; -import Autocomplete from "@mui/material/Autocomplete"; import Button from "@mui/material/Button"; -import Dialog from "@mui/material/Dialog"; -import DialogActions from "@mui/material/DialogActions"; -import DialogContent from "@mui/material/DialogContent"; -import DialogTitle from "@mui/material/DialogTitle"; -import InputAdornment from "@mui/material/InputAdornment"; import MenuItem from "@mui/material/MenuItem"; import TextField from "@mui/material/TextField"; import { useEffect, useState } from "react"; import { - COIN_SELLER_RECHARGE_CHAINS, - COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES, COIN_SELLER_RECHARGE_PROVIDERS, COIN_SELLER_RECHARGE_STATUS, DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM, @@ -38,6 +29,7 @@ import { isCoinSellerRechargeVerified, validateCoinSellerRechargeOrderPayload, } from "../format.js"; +import { FinanceCoinSellerRechargeCreateDialog } from "./FinanceCoinSellerRechargeCreateDialog.jsx"; import { FinanceCoinSellerExchangeRateDialog } from "./FinanceCoinSellerExchangeRateDialog.jsx"; export function FinanceCoinSellerRechargeOrderList({ @@ -131,6 +123,7 @@ export function FinanceCoinSellerRechargeOrderList({ setForm({ ...DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM, appCode: filters.appCode || apps[0]?.appCode || "", + orderDateMs: Date.now(), }); setFormError(""); setQuote(null); @@ -277,28 +270,31 @@ export function FinanceCoinSellerRechargeOrderList({ ))} onFiltersChange({ keyword: event.target.value })} /> - - {canConfigureExchangeRate ? ( - - ) : null} - + {canConfigureExchangeRate ? ( + + ) : null} + + {error ?
{error}
: null} @@ -379,8 +375,11 @@ export function FinanceCoinSellerRechargeOrderList({ ) : null} - - 创建币商充值 - -
- {formError ? {formError} : null} -
- {apps.length ? ( - onChange("appCode", event.target.value)} - > - {apps.map((app) => ( - - {app.appName || app.appCode} - - ))} - - ) : ( - onChange("appCode", event.target.value)} - /> - )} - onChange("targetUserId", event.target.value)} - /> - USD }} - inputProps={{ min: 0, step: "0.01" }} - label="USD 金额" - required - type="number" - value={form.usdAmount} - onChange={(event) => onChange("usdAmount", event.target.value)} - /> - - 金币 }} - helperText={quoteHelperText(quote, quoteError, quoteLoading, form.isMakeup)} - label="充值金币" - required - type="number" - value={form.coinAmount} - /> - onChange("providerCode", event.target.value)} - > - {COIN_SELLER_RECHARGE_PROVIDERS.map(([value, label]) => ( - - {label} - - ))} - - {form.providerCode === "usdt" ? ( - onChange("chain", event.target.value)} - > - {COIN_SELLER_RECHARGE_CHAINS.map(([value, label]) => ( - - {label} - - ))} - - ) : null} - {form.providerCode !== "usdt" ? ( - <> - option === value} - noOptionsText="无匹配币种" - options={providerCurrencyOptions(form.providerCode)} - renderInput={(params) => ( - - )} - value={form.providerCurrencyCode || null} - onChange={(_, value) => onChange("providerCurrencyCode", value || "")} - /> - onChange("providerAmount", event.target.value)} - /> - - ) : null} -
- onChange("externalOrderNo", event.target.value)} - /> - -
- onChange("remark", event.target.value)} - /> -
- {receiptVerification ? : null} - -
- - - - - - ); -} - function isZeroCoinMakeupOrder(order) { return Number(order?.coinAmount || 0) === 0; } -function providerCurrencyOptions(providerCode) { - return COIN_SELLER_RECHARGE_PROVIDER_CURRENCIES[providerCode] || []; -} - -function currencyOptionLabel(option) { - return option || ""; -} - -function quoteHelperText(quote, error, loading, isMakeup) { - if (isMakeup) { - return "补单不发放金币,充值金额仍正常计入统计"; - } - if (loading) { - return "正在计算金币"; - } - if (error) { - return error; - } - if (!quote) { - return "输入 APP、用户 ID 和 USD 金额后自动生成"; - } - const source = quote.rateSource === "whitelist" ? `白名单 ${quote.matchedUserId || ""}`.trim() : `${quote.minUsdAmount}-${quote.maxUsdAmount} USD`; - return `${source} · 1 USD = ${formatAmount(quote.coinsPerUsd)} 金币`; -} - -function ReceiptVerificationBanner({ verification }) { - const detail = [ - verification.status && `状态 ${verification.status}`, - verification.providerOrderId && `三方单 ${verification.providerOrderId}`, - verification.currencyCode && verification.providerAmountMinor !== undefined - ? formatUsdMinor(verification.providerAmountMinor, verification.currencyCode) - : "", - verification.failureReason, - ] - .filter(Boolean) - .join(" / "); - return ( - - {verification.verified ? "校验通过" : "校验未通过"} - {detail ? `:${detail}` : ""} - - ); -} - function StatusBadge({ status }) { return ( diff --git a/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx b/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx index 1b125e2..d394ec7 100644 --- a/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx +++ b/finance/src/components/FinanceCoinSellerRechargeOrderList.test.jsx @@ -3,10 +3,14 @@ import { afterEach, expect, test, vi } from "vitest"; import { FinanceCoinSellerRechargeOrderList } from "./FinanceCoinSellerRechargeOrderList.jsx"; afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllGlobals(); }); test("coin seller recharge order create dialog verifies receipt before create", async () => { + const nowMS = new Date(2026, 6, 13, 12, 30, 0, 0).getTime(); + const historicalOrderMS = new Date(2026, 6, 11, 12, 30, 0, 0).getTime(); + vi.spyOn(Date, "now").mockReturnValue(nowMS); const onCreate = vi.fn(async () => ({ id: "order-2" })); const onQuote = vi.fn(async () => ({ coinAmount: 1000000, @@ -41,8 +45,10 @@ test("coin seller recharge order create dialog verifies receipt before create", fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "10001" } }); fireEvent.change(screen.getByRole("spinbutton", { name: "USD 金额" }), { target: { value: "10" } }); - await waitFor(() => expect(screen.getByRole("spinbutton", { name: "充值金币" })).toHaveValue(1000000)); - expect(screen.getByRole("spinbutton", { name: "充值金币" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: /订单时间/ })); + fireEvent.click(screen.getByRole("button", { name: "2026-07-11" })); + fireEvent.click(screen.getByRole("button", { name: "确定" })); + await waitFor(() => expect(screen.getByRole("status", { name: "充值金币" })).toHaveTextContent("1,000,000")); expect(screen.getByText(/白名单 10001.*1 USD = 100,000 金币/)).toBeInTheDocument(); fireEvent.mouseDown(screen.getByRole("combobox", { name: "币种" })); fireEvent.click(await screen.findByRole("option", { name: "PHP" })); @@ -57,6 +63,7 @@ test("coin seller recharge order create dialog verifies receipt before create", expect(onVerifyReceipt).toHaveBeenCalledWith({ appCode: "lalu", externalOrderNo: "V5-001", + orderDateMs: historicalOrderMS, providerAmountMinor: 4800, providerCode: "v5pay", providerCurrencyCode: "PHP", @@ -68,6 +75,7 @@ test("coin seller recharge order create dialog verifies receipt before create", appCode: "lalu", externalOrderNo: "V5-001", isMakeup: false, + orderDateMs: historicalOrderMS, providerAmountMinor: 4800, providerCode: "v5pay", providerCurrencyCode: "PHP", @@ -79,6 +87,8 @@ test("coin seller recharge order create dialog verifies receipt before create", }); test("makeup order keeps zero coins while creating a normal recharge record", async () => { + const nowMS = new Date(2026, 6, 13, 9, 0, 0, 0).getTime(); + vi.spyOn(Date, "now").mockReturnValue(nowMS); const onCreate = vi.fn(async () => ({ id: "makeup-order" })); const onQuote = vi.fn(); const onVerifyReceipt = vi.fn(async () => ({ status: "paid", verified: true })); @@ -94,7 +104,7 @@ test("makeup order keeps zero coins while creating a normal recharge record", as fireEvent.click(screen.getByRole("button", { name: "创建币商充值" })); fireEvent.click(screen.getByRole("button", { name: "补单" })); expect(screen.getByRole("button", { name: "补单" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByRole("spinbutton", { name: "充值金币" })).toHaveValue(0); + expect(screen.getByRole("status", { name: "充值金币" })).toHaveTextContent("0"); expect(screen.getByText("补单不发放金币,充值金额仍正常计入统计")).toBeInTheDocument(); fireEvent.change(screen.getByRole("textbox", { name: "目标用户 ID" }), { target: { value: "123456" } }); @@ -112,6 +122,7 @@ test("makeup order keeps zero coins while creating a normal recharge record", as appCode: "lalu", externalOrderNo: "MIFA-MAKEUP-001", isMakeup: true, + orderDateMs: nowMS, providerAmountMinor: 720000, providerCode: "mifapay", providerCurrencyCode: "PHP", diff --git a/finance/src/constants.js b/finance/src/constants.js index a51735b..880b042 100644 --- a/finance/src/constants.js +++ b/finance/src/constants.js @@ -113,6 +113,7 @@ export const DEFAULT_COIN_SELLER_RECHARGE_ORDER_FORM = { coinAmount: "", externalOrderNo: "", isMakeup: false, + orderDateMs: "", providerAmount: "", providerCode: "mifapay", providerCurrencyCode: "", diff --git a/finance/src/format.js b/finance/src/format.js index 2938103..81f5123 100644 --- a/finance/src/format.js +++ b/finance/src/format.js @@ -136,6 +136,7 @@ export function buildCoinSellerRechargeOrderPayload(form) { chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "", externalOrderNo: stringValue(form.externalOrderNo), isMakeup: Boolean(form.isMakeup), + orderDateMs: numberValue(form.orderDateMs), providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "", providerCode, providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "", @@ -156,6 +157,7 @@ export function buildCoinSellerRechargeReceiptPayload(form) { appCode: stringValue(form.appCode), chain: providerCode === "usdt" ? stringValue(form.chain).toUpperCase() : "", externalOrderNo: stringValue(form.externalOrderNo), + orderDateMs: numberValue(form.orderDateMs), providerAmountMinor: requiresThirdPartyProviderAmount(providerCode) ? providerAmountMinor : "", providerCode, providerCurrencyCode: requiresThirdPartyProviderAmount(providerCode) ? providerCurrencyCode : "", @@ -174,6 +176,9 @@ export function validateCoinSellerRechargeOrderPayload(payload) { if (!Number.isFinite(payload.usdAmount) || payload.usdAmount <= 0) { return "请填写大于 0 的 USD 金额"; } + if (!Number.isInteger(payload.orderDateMs) || payload.orderDateMs <= 0) { + return "请选择订单时间"; + } if (!payload.providerCode) { return "请选择支付渠道"; } @@ -266,6 +271,7 @@ export function normalizeCoinSellerRechargeOrder(item = {}) { grantedByName: stringValue(item.grantedByName ?? item.granted_by_name), id: stringValue(item.id ?? item.orderId ?? item.order_id), operatorName: stringValue(item.operatorName ?? item.operator_name), + orderDateMs: numberOrNull(item.orderDateMs ?? item.order_date_ms), providerAmountMinor: numberOrNull(item.providerAmountMinor ?? item.provider_amount_minor), providerCode: stringValue(item.providerCode ?? item.provider_code), providerCurrencyCode: stringValue(item.providerCurrencyCode ?? item.provider_currency_code), diff --git a/finance/src/format.test.js b/finance/src/format.test.js index 6c5ca44..63ef80f 100644 --- a/finance/src/format.test.js +++ b/finance/src/format.test.js @@ -173,6 +173,7 @@ test("builds validates and normalizes coin seller recharge orders", () => { appCode: " lalu ", coinAmount: "100000", externalOrderNo: " MIFA-001 ", + orderDateMs: 1760000000000, providerAmount: "1250.5", providerCode: "mifapay", providerCurrencyCode: " php ", @@ -185,6 +186,7 @@ test("builds validates and normalizes coin seller recharge orders", () => { appCode: "lalu", externalOrderNo: "MIFA-001", isMakeup: false, + orderDateMs: 1760000000000, providerAmountMinor: 125050, providerCode: "mifapay", providerCurrencyCode: "PHP", @@ -201,6 +203,7 @@ test("builds validates and normalizes coin seller recharge orders", () => { ).toEqual({ appCode: "lalu", externalOrderNo: "MIFA-001", + orderDateMs: 1760000000000, providerAmountMinor: 125050, providerCode: "mifapay", providerCurrencyCode: "PHP", @@ -263,6 +266,7 @@ test("builds validates and normalizes coin seller recharge orders", () => { chain: "TRON", externalOrderNo: "MIFA-001", isMakeup: false, + orderDateMs: 1760000000000, providerCode: "usdt", remark: "人工补单", targetUserId: "10001", @@ -280,6 +284,7 @@ test("builds validates and normalizes coin seller recharge orders", () => { appCode: "lalu", chain: "BSC", externalOrderNo: "MIFA-001", + orderDateMs: 1760000000000, providerCode: "usdt", usdAmount: 10.5, }); @@ -294,6 +299,7 @@ test("builds validates and normalizes coin seller recharge orders", () => { grant_status: "pending", id: "order-1", provider_code: "mifapay", + order_date_ms: 1759900000000, remark: "人工补单", target_display_user_id: "165075", target_user_id: "10001", @@ -312,6 +318,7 @@ test("builds validates and normalizes coin seller recharge orders", () => { coinAmount: 100000, externalOrderNo: "MIFA-001", id: "order-1", + orderDateMs: 1759900000000, providerCode: "mifapay", remark: "人工补单", targetDisplayUserId: "165075", diff --git a/finance/src/styles/index.css b/finance/src/styles/index.css index 52a994b..bd98578 100644 --- a/finance/src/styles/index.css +++ b/finance/src/styles/index.css @@ -536,8 +536,11 @@ a { } .finance-create-dialog-paper--coin-seller { - width: min(760px, calc(100vw - 64px)); - max-width: min(760px, calc(100vw - 64px)) !important; + width: min(1120px, calc(100vw - 64px)); + height: min(760px, calc(100vh - 32px)); + max-width: min(1120px, calc(100vw - 64px)) !important; + max-height: calc(100vh - 32px) !important; + overflow: hidden; } .finance-create-dialog-paper .MuiDialogTitle-root { @@ -564,6 +567,503 @@ a { grid-template-columns: minmax(0, 1fr); } +.finance-form-grid .finance-order-date-picker { + width: 100%; + max-width: none; +} + +/* 币商充值按虚拟收银台组织:录入区负责分步操作,深色预览区持续强调最终入账结果。 */ +.finance-entry-header.MuiDialogTitle-root { + display: flex; + align-items: center; + gap: var(--space-3); + border-bottom: 1px solid var(--border-soft); + padding: 18px 24px !important; +} + +.finance-entry-header__icon { + display: grid; + width: 42px; + height: 42px; + flex: 0 0 42px; + place-items: center; + border-radius: 12px; + background: var(--primary-surface); + color: var(--primary); +} + +.finance-entry-header__icon svg { + font-size: 23px; +} + +.finance-entry-header > span:last-child { + display: grid; + gap: 2px; +} + +.finance-entry-header strong { + color: var(--text-primary); + font-size: 19px; + line-height: 1.35; +} + +.finance-entry-header small { + color: var(--text-tertiary); + font-size: 12px; + font-weight: 500; +} + +.finance-create-dialog--entry { + padding: 0 !important; +} + +.finance-entry-form { + min-width: 0; +} + +.finance-entry-form > .MuiAlert-root { + margin: var(--space-4) var(--space-5) 0; +} + +.finance-entry-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 304px; + min-width: 0; +} + +.finance-entry-fields { + display: grid; + min-width: 0; + padding: 4px 24px 24px; +} + +.finance-entry-section { + min-width: 0; + border-bottom: 1px solid var(--border-soft); + padding: 20px 0; +} + +.finance-entry-section:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.finance-entry-section__header { + display: flex; + align-items: center; + gap: var(--space-2); + margin-bottom: var(--space-3); +} + +.finance-entry-section__index { + display: grid; + width: 22px; + height: 22px; + place-items: center; + border-radius: var(--radius-pill); + background: var(--primary); + color: var(--active-contrast); + font-size: 11px; + font-weight: 800; +} + +.finance-entry-section__icon { + display: inline-flex; + color: var(--text-tertiary); +} + +.finance-entry-section__icon svg { + font-size: 18px; +} + +.finance-entry-section__header h3 { + margin: 0; + color: var(--text-primary); + font-size: 14px; + font-weight: 750; +} + +.finance-entry-section__body { + display: grid; + min-width: 0; + gap: var(--space-3); + padding-left: 30px; +} + +.finance-entry-field-grid { + display: grid; + min-width: 0; + gap: var(--space-3); +} + +.finance-entry-field-grid--2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.finance-entry-field-grid--3 { + grid-template-columns: 1fr 0.8fr 1fr; +} + +.finance-entry-usd-field .MuiInputBase-input { + color: var(--text-primary); + font-size: 17px; + font-variant-numeric: tabular-nums; + font-weight: 750; +} + +.finance-entry-usd-field .MuiInputAdornment-root { + color: var(--primary); + font-size: 16px; + font-weight: 800; +} + +.finance-entry-section .finance-order-date-picker { + width: 100%; + max-width: none; + height: var(--control-height) !important; + min-height: var(--control-height) !important; + box-sizing: border-box; + align-items: end !important; + border-color: var(--border); + background: var(--bg-input); + padding: 5px 28px 1px var(--control-padding-x) !important; +} + +.finance-entry-mode { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-2); + padding: 4px; + border-radius: 10px; + background: var(--bg-card-strong); +} + +.finance-entry-mode button { + display: grid; + gap: 2px; + min-height: 48px; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + font: inherit; + padding: 7px 12px; + text-align: left; + transition: background-color var(--motion-fast) var(--ease-standard), border-color var(--motion-fast) var(--ease-standard), box-shadow var(--motion-base) var(--ease-emphasized), transform var(--motion-base) var(--ease-emphasized); +} + +.finance-entry-mode button:hover:not(:disabled) { + background: var(--bg-card); + transform: translateY(-1px); +} + +.finance-entry-mode button.is-active { + border-color: var(--primary-border); + background: var(--bg-card); + box-shadow: 0 3px 10px rgb(15 23 42 / 8%); + color: var(--primary); +} + +.finance-entry-mode button.is-active.is-warning { + border-color: var(--warning-border); + color: var(--warning); +} + +.finance-entry-mode button:disabled { + cursor: default; + opacity: 0.58; +} + +.finance-entry-mode strong { + font-size: 13px; +} + +.finance-entry-mode small { + color: var(--text-tertiary); + font-size: 11px; +} + +.finance-quote-result { + display: flex; + min-height: 64px; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + border: 1px solid var(--primary-border); + border-left: 4px solid var(--primary); + border-radius: 10px; + background: linear-gradient(100deg, var(--primary-surface), rgb(255 255 255 / 80%)); + padding: 11px 14px; +} + +.finance-quote-result--makeup { + border-color: var(--warning-border); + border-left-color: var(--warning); + background: linear-gradient(100deg, var(--warning-surface), rgb(255 255 255 / 80%)); +} + +.finance-quote-result > span { + display: flex; + align-items: baseline; + gap: 6px; + white-space: nowrap; +} + +.finance-quote-result small { + color: var(--text-secondary); + font-size: 11px; + font-weight: 650; +} + +.finance-quote-result output { + color: var(--primary); + font-size: 23px; + line-height: 1; + font-variant-numeric: tabular-nums; + font-weight: 850; +} + +.finance-quote-result--makeup output { + color: var(--warning); +} + +.finance-quote-result em { + color: var(--text-secondary); + font-size: 11px; + font-style: normal; +} + +.finance-quote-result p { + margin: 0; + color: var(--text-secondary); + font-size: 11px; + line-height: 1.55; + text-align: right; +} + +.finance-entry-preview { + position: relative; + display: flex; + min-width: 0; + flex-direction: column; + align-self: stretch; + margin: 20px 20px 20px 0; + overflow: hidden; + border-radius: 14px; + background: linear-gradient(160deg, #17233a 0%, #0f172a 65%, #111d33 100%); + box-shadow: 0 18px 34px rgb(15 23 42 / 18%); + color: #fff; + padding: 20px; +} + +.finance-entry-preview::before { + position: absolute; + top: -70px; + right: -70px; + width: 170px; + height: 170px; + border-radius: 50%; + background: rgb(37 99 235 / 25%); + content: ""; + filter: blur(2px); +} + +.finance-entry-preview > * { + position: relative; +} + +.finance-entry-preview > header { + display: flex; + align-items: center; + justify-content: space-between; + color: #dbeafe; + font-size: 12px; + font-weight: 750; +} + +.finance-entry-preview > header b { + border: 1px solid rgb(96 165 250 / 45%); + border-radius: var(--radius-pill); + background: rgb(37 99 235 / 18%); + color: #bfdbfe; + font-size: 10px; + padding: 4px 8px; +} + +.finance-entry-preview > header b.is-warning { + border-color: rgb(251 191 36 / 45%); + background: rgb(217 119 6 / 18%); + color: #fde68a; +} + +.finance-entry-preview__amount { + padding: 38px 0 28px; + text-align: center; +} + +.finance-entry-preview__amount small { + display: block; + margin-bottom: 8px; + color: #94a3b8; + font-size: 11px; +} + +.finance-entry-preview__amount strong { + display: block; + overflow-wrap: anywhere; + font-size: 34px; + line-height: 1.1; + font-variant-numeric: tabular-nums; + letter-spacing: -0.03em; +} + +.finance-entry-preview__amount strong span { + margin-right: 4px; + color: #60a5fa; + font-size: 20px; + vertical-align: 5px; +} + +.finance-entry-preview__amount p { + margin: 7px 0 0; + color: #64748b; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.14em; +} + +.finance-entry-preview__coins { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + border-top: 1px solid rgb(148 163 184 / 16%); + border-bottom: 1px solid rgb(148 163 184 / 16%); + padding: 13px 0; +} + +.finance-entry-preview__coins span { + color: #94a3b8; + font-size: 11px; +} + +.finance-entry-preview__coins strong { + color: #93c5fd; + font-size: 17px; + font-variant-numeric: tabular-nums; +} + +.finance-entry-preview dl { + display: grid; + gap: 10px; + margin: 18px 0; +} + +.finance-entry-preview dl > div { + display: grid; + grid-template-columns: 72px minmax(0, 1fr); + gap: var(--space-2); +} + +.finance-entry-preview dt { + color: #64748b; + font-size: 10px; +} + +.finance-entry-preview dd { + overflow: hidden; + margin: 0; + color: #cbd5e1; + font-size: 11px; + font-variant-numeric: tabular-nums; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; +} + +.finance-entry-preview__verify { + display: flex; + align-items: center; + gap: 9px; + border: 1px solid rgb(148 163 184 / 18%); + border-radius: 9px; + background: rgb(255 255 255 / 4%); + color: #94a3b8; + padding: 10px; +} + +.finance-entry-preview__verify.is-verified { + border-color: rgb(74 222 128 / 28%); + background: rgb(22 163 74 / 10%); + color: #4ade80; +} + +.finance-entry-preview__verify svg { + flex: 0 0 auto; + font-size: 19px; +} + +.finance-entry-preview__verify span { + display: grid; + gap: 2px; +} + +.finance-entry-preview__verify strong { + color: currentColor; + font-size: 11px; +} + +.finance-entry-preview__verify small { + color: #64748b; + font-size: 9px; +} + +.finance-entry-preview__notice { + margin: auto 0 0; + border-radius: 8px; + background: rgb(37 99 235 / 12%); + color: #93c5fd; + font-size: 10px; + line-height: 1.55; + padding: 9px 10px; +} + +.finance-entry-preview__notice.is-warning { + background: rgb(217 119 6 / 14%); + color: #fcd34d; +} + +.finance-entry-actions.MuiDialogActions-root { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: var(--space-2); + border-top: 1px solid var(--border-soft); + padding: 14px 20px; +} + +.finance-entry-actions__hint { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--text-tertiary); + font-size: 11px; +} + +.finance-entry-actions__hint svg { + color: var(--primary); +} + +.finance-entry-actions .MuiButton-root { + min-width: 88px; + height: 38px; +} + +.finance-entry-actions .MuiButton-contained { + min-width: 116px; + box-shadow: var(--brand-shadow); +} + .finance-rate-config { display: grid; gap: 24px; @@ -745,6 +1245,60 @@ a { .finance-form--dialog .finance-evidence-grid { grid-template-columns: 1fr; } + + .finance-entry-layout { + grid-template-columns: 1fr; + } + + .finance-entry-preview { + min-height: 480px; + margin: 0 20px 20px; + } +} + +@media (max-width: 640px) { + .finance-entry-header.MuiDialogTitle-root { + padding: 14px 16px !important; + } + + .finance-entry-fields { + padding: 0 16px 16px; + } + + .finance-entry-section__body { + padding-left: 0; + } + + .finance-entry-field-grid--2, + .finance-entry-field-grid--3, + .finance-entry-mode { + grid-template-columns: 1fr; + } + + .finance-quote-result { + align-items: flex-start; + flex-direction: column; + } + + .finance-quote-result p { + text-align: left; + } + + .finance-receipt-field { + grid-template-columns: 1fr; + } + + .finance-entry-preview { + margin: 0 12px 16px; + } + + .finance-entry-actions.MuiDialogActions-root { + grid-template-columns: 1fr 1fr; + } + + .finance-entry-actions__hint { + grid-column: 1 / -1; + } } @media (prefers-reduced-motion: reduce) { @@ -1209,16 +1763,36 @@ a { padding: var(--space-3) var(--space-4); } -.finance-filterrow__region { - min-width: 140px; +.finance-filterrow > .MuiFormControl-root, +.finance-filterrow > .MuiAutocomplete-root { + width: 132px; + min-width: 132px; + flex: 0 0 132px; } -.finance-filterrow__search { +.finance-filterrow > .finance-filterrow__region { + width: 140px; + min-width: 140px; + flex-basis: 140px; +} + +.finance-filterrow > .finance-filterrow__search { + width: 240px; min-width: 220px; + flex: 0 1 240px; } .finance-filterrow__spacer { - flex: 1; + min-width: var(--space-2); + flex: 1 1 var(--space-4); +} + +.finance-filterrow__actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: var(--space-2); + margin-left: auto; } .finance-fchip { diff --git a/src/features/app-users/hooks/useAppUsersPage.js b/src/features/app-users/hooks/useAppUsersPage.js index 62c07f4..43f4e92 100644 --- a/src/features/app-users/hooks/useAppUsersPage.js +++ b/src/features/app-users/hooks/useAppUsersPage.js @@ -471,7 +471,8 @@ function levelTrackForm(level) { return { durationDays: String(remainingDays), enabled: false, - level: String(Math.min(50, Math.max(1, currentTarget))), + // 旧的临时等级也要保留到产品支持的 100 级,避免打开用户资料时把有效的高等级回填成 50。 + level: String(Math.min(100, Math.max(1, currentTarget))), }; } diff --git a/src/features/app-users/levelAdjustmentSchema.test.ts b/src/features/app-users/levelAdjustmentSchema.test.ts new file mode 100644 index 0000000..11852be --- /dev/null +++ b/src/features/app-users/levelAdjustmentSchema.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "vitest"; +import { appUserLevelAdjustmentSchema } from "./schema"; + +const adjustment = (level: string) => ({ + charm: { durationDays: "7", enabled: false, level: "" }, + reason: "运营修正", + wealth: { durationDays: "7", enabled: true, level }, +}); + +describe("appUserLevelAdjustmentSchema", () => { + test("accepts the product maximum of level 100", () => { + expect(appUserLevelAdjustmentSchema.safeParse(adjustment("100")).success).toBe(true); + }); + + test("rejects a level above 100", () => { + const result = appUserLevelAdjustmentSchema.safeParse(adjustment("101")); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toContainEqual(expect.objectContaining({ message: "目标等级必须是 1 到 100 的整数" })); + } + }); +}); diff --git a/src/features/app-users/pages/AppUserListPage.jsx b/src/features/app-users/pages/AppUserListPage.jsx index 46ce885..9f80e60 100644 --- a/src/features/app-users/pages/AppUserListPage.jsx +++ b/src/features/app-users/pages/AppUserListPage.jsx @@ -474,7 +474,7 @@ function LevelAdjustmentFields({ page }) { updateTrack(track, { level: event.target.value })} diff --git a/src/features/app-users/schema.ts b/src/features/app-users/schema.ts index bf2013d..70da65d 100644 --- a/src/features/app-users/schema.ts +++ b/src/features/app-users/schema.ts @@ -20,7 +20,7 @@ const temporaryLevelTrackSchema = z.object({ level: z.string().trim().optional().default(""), }); -// 财富和魅力必须作为一次命令提交;表单在送出前校验双轨边界,后端继续负责真实等级、规则启用状态和事务原子性。 +// 财富和魅力必须作为一次命令提交;表单在送出前校验双轨边界(产品等级上限为 100),后端继续负责真实等级、规则启用状态和事务原子性。 export const appUserLevelAdjustmentSchema = z .object({ charm: temporaryLevelTrackSchema, @@ -41,8 +41,8 @@ export const appUserLevelAdjustmentSchema = z return; } const level = Number(track.level); - if (!Number.isInteger(level) || level < 1 || level > 50) { - context.addIssue({ code: "custom", message: "目标等级必须是 1 到 50 的整数", path: [name, "level"] }); + if (!Number.isInteger(level) || level < 1 || level > 100) { + context.addIssue({ code: "custom", message: "目标等级必须是 1 到 100 的整数", path: [name, "level"] }); } const durationDays = Number(track.durationDays); if (!Number.isInteger(durationDays) || durationDays < 1 || durationDays > 36500) { diff --git a/src/shared/ui/DateTimePicker.jsx b/src/shared/ui/DateTimePicker.jsx new file mode 100644 index 0000000..061ff8e --- /dev/null +++ b/src/shared/ui/DateTimePicker.jsx @@ -0,0 +1,189 @@ +import AccessTimeOutlined from "@mui/icons-material/AccessTimeOutlined"; +import ChevronLeftOutlined from "@mui/icons-material/ChevronLeftOutlined"; +import ChevronRightOutlined from "@mui/icons-material/ChevronRightOutlined"; +import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined"; +import IconButton from "@mui/material/IconButton"; +import MenuItem from "@mui/material/MenuItem"; +import Popover from "@mui/material/Popover"; +import TextField from "@mui/material/TextField"; +import { useMemo, useState } from "react"; +import { Button } from "@/shared/ui/Button.jsx"; +import { + calendarDays, + formatRangePart, + monthTitle, + normalizeMs, + pad2, + sameDay, +} from "@/shared/ui/TimeRangeFilter.jsx"; +import styles from "@/shared/ui/TimeRangeFilter.module.css"; + +const weekLabels = ["一", "二", "三", "四", "五", "六", "日"]; + +// 单点业务时间与区间筛选复用同一套日历、时分下拉和动效,避免财务表单退回浏览器原生 datetime 输入。 +export function DateTimePicker({ className = "", disabled = false, label = "时间", onChange, value }) { + const selectedMs = normalizeMs(value); + // 空值只用于首次内部 state 占位;真正打开时在点击事件中读取当前时间,保持 render 纯函数。 + const selectedDate = useMemo(() => new Date(selectedMs || 0), [selectedMs]); + const [anchorEl, setAnchorEl] = useState(null); + const [draft, setDraft] = useState(selectedDate); + const [viewDate, setViewDate] = useState(selectedDate); + const open = Boolean(anchorEl); + const days = useMemo(() => calendarDays(viewDate), [viewDate]); + + const openPopover = (event) => { + if (disabled) { + return; + } + const current = new Date(selectedMs || Date.now()); + setDraft(current); + setViewDate(current); + setAnchorEl(event.currentTarget); + }; + const closePopover = () => setAnchorEl(null); + const changeMonth = (offset) => { + setViewDate((current) => new Date(current.getFullYear(), current.getMonth() + offset, 1)); + }; + const changeDay = (day) => { + // 切换日期时保留已选时分,便于补录前天同一时刻的订单,而不是悄悄归零到当天零点。 + setDraft( + (current) => + new Date( + day.getFullYear(), + day.getMonth(), + day.getDate(), + current.getHours(), + current.getMinutes(), + 0, + 0, + ), + ); + setViewDate(new Date(day.getFullYear(), day.getMonth(), 1)); + }; + const changeTime = (unit, valueText) => { + setDraft((current) => { + const next = new Date(current); + if (unit === "hour") { + next.setHours(Number(valueText)); + } else { + next.setMinutes(Number(valueText)); + } + next.setSeconds(0, 0); + return next; + }); + }; + const selectNow = () => { + const now = new Date(); + setDraft(now); + setViewDate(now); + }; + const submit = (event) => { + event.preventDefault(); + // Popover 通过 portal 渲染,必须截断 submit 冒泡,否则确认时间会误提交外层“创建币商充值”表单。 + event.stopPropagation(); + onChange(draft.getTime()); + closePopover(); + }; + + return ( + <> + + +
+
+ +
+
+ changeMonth(-1)}> + + + {monthTitle(viewDate)} + changeMonth(1)}> + + +
+
+ {weekLabels.map((weekday) => ( + + {weekday} + + ))} + {days.map((day) => ( + + ))} +
+
+ changeTime("hour", event.target.value)} + > + {Array.from({ length: 24 }, (_, hour) => ( + + {pad2(hour)} + + ))} + + changeTime("minute", event.target.value)} + > + {Array.from({ length: 60 }, (_, minute) => ( + + {pad2(minute)} + + ))} + +
+
+ + +
+
+
+ + ); +} diff --git a/src/shared/ui/DateTimePicker.test.jsx b/src/shared/ui/DateTimePicker.test.jsx new file mode 100644 index 0000000..f8e4c10 --- /dev/null +++ b/src/shared/ui/DateTimePicker.test.jsx @@ -0,0 +1,15 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; +import { DateTimePicker } from "@/shared/ui/DateTimePicker.jsx"; + +test("selects one business date and time without submitting an outer form", () => { + const onChange = vi.fn(); + const initial = new Date(2026, 6, 13, 14, 30, 0, 0).getTime(); + render(); + + fireEvent.click(screen.getByRole("button", { name: /订单时间/ })); + fireEvent.click(screen.getByRole("button", { name: "2026-07-11" })); + fireEvent.click(screen.getByRole("button", { name: "确定" })); + + expect(onChange).toHaveBeenCalledWith(new Date(2026, 6, 11, 14, 30, 0, 0).getTime()); +}); diff --git a/src/shared/ui/TimeRangeFilter.jsx b/src/shared/ui/TimeRangeFilter.jsx index 76cdfd9..ce19f9a 100644 --- a/src/shared/ui/TimeRangeFilter.jsx +++ b/src/shared/ui/TimeRangeFilter.jsx @@ -319,7 +319,7 @@ function setRangeField(range, field, value) { return field === "start" ? { ...range, startMs: value } : { ...range, endMs: value }; } -function calendarDays(viewDate) { +export function calendarDays(viewDate) { const firstDay = new Date(viewDate.getFullYear(), viewDate.getMonth(), 1); const mondayIndex = (firstDay.getDay() + 6) % 7; const start = new Date(firstDay); @@ -332,7 +332,7 @@ function calendarDays(viewDate) { }); } -function sameDay(left, right) { +export function sameDay(left, right) { return ( left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && @@ -340,11 +340,11 @@ function sameDay(left, right) { ); } -function monthTitle(date) { +export function monthTitle(date) { return `${date.getFullYear()}年${pad2(date.getMonth() + 1)}月`; } -function formatRangePart(value) { +export function formatRangePart(value) { const text = msToDatetimeText(value); return text ? text.replace("-", "/").replace("-", "/") : ""; } @@ -367,7 +367,7 @@ function msToDatetimeText(value) { return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`; } -function normalizeMs(value) { +export function normalizeMs(value) { if (value === "" || value === null || value === undefined) { return ""; } @@ -375,6 +375,6 @@ function normalizeMs(value) { return Number.isFinite(number) && number > 0 ? number : ""; } -function pad2(value) { +export function pad2(value) { return String(value).padStart(2, "0"); }