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 (
+
+ );
+}
+
+function FormSection({ children, icon, index, title }) {
+ return (
+
+
+ {index}
+
+ {icon}
+
+ {title}
+
+ {children}
+
+ );
+}
+
+function CoinQuoteResult({ form, quote, quoteError, quoteLoading }) {
+ const coinAmount = form.isMakeup ? 0 : quote?.coinAmount;
+ return (
+
onFiltersChange({ keyword: event.target.value })}
/>
- } variant="outlined" onClick={onReload}>
- 刷新
-
- {canConfigureExchangeRate ? (
- } variant="outlined" onClick={() => setRateConfigOpen(true)}>
- 金币汇率配置
+
+ } variant="outlined" onClick={onReload}>
+ 刷新
- ) : null}
- }
- variant="contained"
- onClick={openCreate}
- >
- 创建币商充值
-
+ {canConfigureExchangeRate ? (
+ } variant="outlined" onClick={() => setRateConfigOpen(true)}>
+ 金币汇率配置
+
+ ) : null}
+ }
+ variant="contained"
+ onClick={openCreate}
+ >
+ 创建币商充值
+
+
{error ? {error}
: null}
@@ -379,8 +375,11 @@ export function FinanceCoinSellerRechargeOrderList({
) : 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 (
+ <>
+
+
+
+
+ >
+ );
+}
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");
}
|