Compare commits

..

4 Commits

Author SHA1 Message Date
马丁
19e4322abd 新增:充值弹窗展示并校验最小到账金额,读取后台系统参数配置。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:45:13 +08:00
马丁
1c64d3a834 新增:提现弹窗展示手续费与最低提现金额,读取后台系统参数配置。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 22:44:40 +08:00
马丁
4f695953d3 修复:钱包登录 SIWE statement 使用固定 ASCII,避免非英文语言解析失败。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 20:59:32 +08:00
马丁
bd698067f8 修改:盈亏曲线展示时间轴,并按区间起止固定可视范围。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 23:12:51 +08:00
10 changed files with 252 additions and 31 deletions

View File

@ -174,3 +174,49 @@ export function amountToUsdcDisplay(raw: number | undefined): string {
if (raw == null || !Number.isFinite(raw)) return '0.00'
return (raw / USDC_SCALE).toFixed(2)
}
/** 提现配置(GET /pmset/getWithdrawConfig) */
export interface WithdrawConfig {
/** 手续费,最小精度整数(1U=1e6;系统参数配置口径为 1=1U) */
fee: number
/** 最低提现金额,最小精度整数(1U=1e6;系统参数配置口径为 1=1U) */
minWithdrawAmount: number
}
export interface WithdrawConfigResponse extends ApiResponse<WithdrawConfig> {
code: number
data?: WithdrawConfig
msg: string
}
/**
* GET /pmset/getWithdrawConfig
* 获取提现手续费与最低提现金额(系统参数 withdrawFee / minWithdrawAmount,配置 1=1U;缺失自动插入)
*/
export async function getWithdrawConfig(
config?: { headers?: Record<string, string> },
): Promise<WithdrawConfigResponse> {
return get<WithdrawConfigResponse>('/pmset/getWithdrawConfig', undefined, config)
}
/** 充值配置(GET /pmset/getDepositConfig) */
export interface DepositConfig {
/** 最小到账金额,最小精度整数(1U=1e6;系统参数配置口径 1=1U,默认 10) */
minDepositAmount: number
}
export interface DepositConfigResponse extends ApiResponse<DepositConfig> {
code: number
data?: DepositConfig
msg: string
}
/**
* GET /pmset/getDepositConfig
* 获取最小到账金额(系统参数 minDepositAmount,配置 1=1U,默认 10;缺失自动插入)
*/
export async function getDepositConfig(
config?: { headers?: Record<string, string> },
): Promise<DepositConfigResponse> {
return get<DepositConfigResponse>('/pmset/getDepositConfig', undefined, config)
}

View File

@ -126,6 +126,7 @@
@update:model-value="onDepositNetworkChange"
/>
<p class="crypto-support-tip">{{ t('deposit.supportedTip') }}</p>
<p class="crypto-min-amount-tip">{{ t('deposit.minArrivalAmount', { amount: minDepositDisplay }) }}</p>
<div class="crypto-addr-head">
<span class="crypto-addr-title">{{ t('deposit.depositAddress') }}</span>
<v-btn color="primary" variant="text" class="crypto-copy-btn" :disabled="!depositAddress" @click="copyAddress">
@ -214,6 +215,7 @@
@update:model-value="onDepositNetworkChange"
/>
<p class="crypto-support-tip exchange-mm-hint">{{ t('deposit.metamaskSendHint') }}</p>
<p class="crypto-min-amount-tip">{{ t('deposit.minArrivalAmount', { amount: minDepositDisplay }) }}</p>
<v-text-field
v-model="depositAmountStr"
:label="t('deposit.amountUsdc')"
@ -317,7 +319,7 @@ import { getDepositAddress, collectDeposit } from '@/api/wallet'
import { useUserStore } from '@/stores/user'
import { useToastStore } from '@/stores/toast'
import { ethers } from 'ethers'
import { USDC_ADDRESS_BY_CHAIN } from '@/api/pmset'
import { USDC_ADDRESS_BY_CHAIN, getDepositConfig, amountToUsdcDisplay } from '@/api/pmset'
import {
WALLET_PRIMARY_NETWORK_ID,
useWalletNetworkGuard,
@ -353,6 +355,23 @@ const connecting = ref(false)
const depositAmountStr = ref('')
const metamaskSendLoading = ref(false)
const metamaskSendError = ref('')
/** 最小到账金额(最小精度,默认 10U = 10_000_000) */
const minDepositRaw = ref(10_000_000)
const minDepositDisplay = computed(() => amountToUsdcDisplay(minDepositRaw.value))
async function loadDepositConfig() {
try {
const res = await getDepositConfig()
if (res.code === 0 || res.code === 200) {
const raw = Number(res.data?.minDepositAmount ?? 0)
minDepositRaw.value = Number.isFinite(raw) && raw > 0 ? raw : 10_000_000
return
}
} catch (e) {
console.error('[loadDepositConfig]', e)
}
minDepositRaw.value = 10_000_000
}
const depositFooterWalletBtnRef = ref<ComponentPublicInstance | null>(null)
const depositFooterCollectBtnRef = ref<ComponentPublicInstance | null>(null)
@ -926,6 +945,10 @@ async function sendUsdcViaMetaMask() {
metamaskSendError.value = t('deposit.invalidAmount')
return
}
if (amountWei < BigInt(minDepositRaw.value)) {
metamaskSendError.value = t('deposit.belowMinArrivalAmount', { amount: minDepositDisplay.value })
return
}
if (!USDC_ADDRESS_BY_CHAIN[selectedNetwork.value]) {
metamaskSendError.value = t('deposit.networkConfigError')
return
@ -1005,6 +1028,7 @@ watch(
metamaskSendError.value = ''
stopDepositCheck()
} else {
void loadDepositConfig()
if (step.value === 'crypto') fetchAddress()
}
},
@ -1127,6 +1151,13 @@ onUnmounted(() => {
padding-bottom: 0;
align-items: center;
}
.crypto-min-amount-tip {
margin: 0 0 12px;
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
line-height: 1.4;
}
.crypto-support-tip {
font-size: 0.75rem;
font-weight: 500;

View File

@ -50,6 +50,7 @@ import {
LineType,
type IChartApi,
type ISeriesApi,
type UTCTimestamp,
} from 'lightweight-charts'
import {
PNL_CURVE_RANGES,
@ -77,6 +78,20 @@ let chartSeries: ISeriesApi<'Area'> | null = null
const LINE_UP = '#2E5CFF'
const LINE_DOWN = '#dc2626'
/** 与 toLwcData 一致:把毫秒时间戳转为图表本地显示用的 UTCTimestamp */
function toChartTime(tsMs: number): UTCTimestamp {
const d = new Date(tsMs)
return (Date.UTC(
d.getFullYear(),
d.getMonth(),
d.getDate(),
d.getHours(),
d.getMinutes(),
d.getSeconds(),
d.getMilliseconds(),
) / 1000) as UTCTimestamp
}
function seriesColors(pnl: number) {
const color = pnl >= 0 ? LINE_UP : LINE_DOWN
return {
@ -105,7 +120,21 @@ async function loadCurve() {
if (chartSeries) {
chartSeries.applyOptions(seriesColors(currentPnl.value))
chartSeries.setData(toLwcData(points))
chartInstance?.timeScale().fitContent()
const startTs = data.startTs
const endTs = data.endTs
if (
chartInstance &&
Number.isFinite(startTs) &&
Number.isFinite(endTs) &&
endTs > startTs
) {
chartInstance.timeScale().setVisibleRange({
from: toChartTime(startTs),
to: toChartTime(endTs),
})
} else {
chartInstance?.timeScale().fitContent()
}
}
} catch (e) {
const msg = e instanceof Error ? e.message : t('wallet.pnlCurve.loadFailed')
@ -125,7 +154,7 @@ function initChart() {
const el = chartRef.value
chartInstance = createChart(el, {
width: el.clientWidth,
height: 140,
height: 160,
layout: {
background: { color: '#ffffff' },
textColor: '#9ca3af',
@ -136,7 +165,12 @@ function initChart() {
grid: { vertLines: { visible: false }, horzLines: { visible: false } },
leftPriceScale: { visible: false },
rightPriceScale: { visible: false, borderVisible: false },
timeScale: { visible: false, borderVisible: false },
timeScale: {
visible: true,
borderVisible: false,
timeVisible: true,
secondsVisible: false,
},
handleScroll: false,
handleScale: false,
crosshair: {
@ -157,7 +191,7 @@ function initChart() {
function handleResize() {
if (chartInstance && chartRef.value) {
chartInstance.resize(chartRef.value.clientWidth, 140)
chartInstance.resize(chartRef.value.clientWidth, 160)
}
}
@ -283,7 +317,7 @@ onUnmounted(() => {
.pnl-chart {
position: relative;
width: 100%;
height: 140px;
height: 160px;
margin-top: auto;
}

View File

@ -34,6 +34,21 @@
</template>
</v-text-field>
<div class="withdraw-meta">
<div class="withdraw-meta-row">
<span>{{ t('withdraw.minAmount') }}</span>
<span>${{ minAmountDisplay }}</span>
</div>
<div class="withdraw-meta-row">
<span>{{ t('withdraw.fee') }}</span>
<span>${{ feeDisplay }}</span>
</div>
<div v-if="amountNum > 0" class="withdraw-meta-row withdraw-meta-row--receive">
<span>{{ t('withdraw.receiveAmount') }}</span>
<span>${{ receiveAmountDisplay }}</span>
</div>
</div>
<v-select
v-model="selectedNetwork"
:items="networks"
@ -86,7 +101,12 @@ import { BrowserProvider } from 'ethers'
import { SiweMessage } from 'siwe'
import { useUserStore } from '@/stores/user'
import { useToastStore } from '@/stores/toast'
import { withdrawByWallet, usdcToAmount } from '@/api/pmset'
import {
withdrawByWallet,
usdcToAmount,
getWithdrawConfig,
amountToUsdcDisplay,
} from '@/api/pmset'
import { WALLET_PRIMARY_NETWORK_ID, useWalletNetworkGuard } from '@/composables/useWalletNetworkPolicy'
const { t } = useI18n()
@ -107,6 +127,9 @@ const connectedAddress = ref('')
const connecting = ref(false)
const submitting = ref(false)
const errorMessage = ref('')
const feeRaw = ref(0)
const minWithdrawRaw = ref(0)
const configLoading = ref(false)
const networks = [
{ id: 'ethereum', label: 'Ethereum' },
@ -128,11 +151,25 @@ const { onWalletNetworkChange } = useWalletNetworkGuard(
const balanceNum = computed(() => parseFloat(props.balance) || 0)
const amountNum = computed(() => parseFloat(amount.value) || 0)
const feeDisplayNum = computed(() => feeRaw.value / 1_000_000)
const minAmountDisplayNum = computed(() => minWithdrawRaw.value / 1_000_000)
const feeDisplay = computed(() => amountToUsdcDisplay(feeRaw.value))
const minAmountDisplay = computed(() => amountToUsdcDisplay(minWithdrawRaw.value))
const receiveAmountDisplay = computed(() => {
const receive = Math.max(0, amountNum.value - feeDisplayNum.value)
return receive.toFixed(2)
})
const amountError = computed(() => {
if (!amount.value) return ''
if (amountNum.value <= 0) return t('withdraw.amountMustBePositive')
if (amountNum.value > balanceNum.value) return t('withdraw.insufficientBalance')
if (minAmountDisplayNum.value > 0 && amountNum.value < minAmountDisplayNum.value) {
return t('withdraw.belowMinAmount', { min: minAmountDisplay.value })
}
if (feeDisplayNum.value > 0 && amountNum.value <= feeDisplayNum.value) {
return t('withdraw.amountMustExceedFee', { fee: feeDisplay.value })
}
return ''
})
@ -145,7 +182,8 @@ const canSubmit = computed(
amountNum.value <= balanceNum.value &&
hasValidDestination.value &&
!amountError.value &&
!errorMessage.value,
!errorMessage.value &&
!configLoading.value,
)
function shortAddress(addr: string) {
@ -175,6 +213,23 @@ function resolveUserLoginWalletAddress(): string | undefined {
return undefined
}
async function loadWithdrawConfig() {
configLoading.value = true
try {
const res = await getWithdrawConfig()
if (res.code === 0 || res.code === 200) {
feeRaw.value = Number(res.data?.fee ?? 0) || 0
minWithdrawRaw.value = Number(res.data?.minWithdrawAmount ?? 0) || 0
}
} catch (e) {
console.error('[loadWithdrawConfig]', e)
feeRaw.value = 0
minWithdrawRaw.value = 0
} finally {
configLoading.value = false
}
}
/** 打开弹窗时自动连接浏览器钱包,且必须与登录账户地址一致 */
async function autoConnectWallet() {
connectedAddress.value = ''
@ -299,7 +354,7 @@ watch(
errorMessage.value = ''
return
}
await autoConnectWallet()
await Promise.all([loadWithdrawConfig(), autoConnectWallet()])
},
)
</script>
@ -358,13 +413,35 @@ watch(
}
.amount-field {
margin-bottom: 16px;
margin-bottom: 10px;
}
.max-btn {
text-transform: none;
font-size: 0.8rem;
}
.withdraw-meta {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 16px;
padding: 10px 12px;
background: #f9fafb;
border-radius: 8px;
font-size: 0.8125rem;
color: #6b7280;
}
.withdraw-meta-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.withdraw-meta-row--receive {
color: #111827;
font-weight: 600;
}
.network-select {
margin-bottom: 20px;
}

View File

@ -160,8 +160,7 @@
"loginFailed": "Sign in failed. Please check your credentials and try again.",
"credentialsRequired": "Please enter username and password.",
"captchaRequired": "Please enter the captcha.",
"captchaLoadFailed": "Failed to load captcha. Please refresh and try again.",
"siweStatement": "Sign in to FuturePulse"
"captchaLoadFailed": "Failed to load captcha. Please refresh and try again."
},
"register": {
"title": "Sign Up",
@ -495,7 +494,9 @@
"txConfirmTimeout": "Transaction was submitted but not confirmed in time. Check it in your wallet or a block explorer.",
"walletRpcIncomplete": "Your wallet returned incomplete transaction data. If issues persist, update MetaMask or change the RPC.",
"depositHistory": "Deposit History",
"depositStatusAll": "All"
"depositStatusAll": "All",
"minArrivalAmount": "Minimum credit amount: {amount} USDC",
"belowMinArrivalAmount": "Deposit amount must be at least {amount} USDC"
},
"withdraw": {
"title": "Withdraw",
@ -520,7 +521,12 @@
"customAddressNotSupported": "Custom address is not supported for this withdrawal.",
"loginWalletLabel": "Your login wallet",
"connectingWallet": "Connecting wallet…",
"walletMismatch": "Switch your browser wallet to the same account you used to sign in."
"walletMismatch": "Switch your browser wallet to the same account you used to sign in.",
"fee": "Fee",
"minAmount": "Minimum withdrawal",
"receiveAmount": "You will receive",
"belowMinAmount": "Amount must be at least {min} USDC",
"amountMustExceedFee": "Amount must be greater than the fee of {fee} USDC"
},
"earnActivity": {
"title": "Participate to Earn",

View File

@ -160,8 +160,7 @@
"loginFailed": "サインインに失敗しました。認証情報を確認してください。",
"credentialsRequired": "ユーザー名とパスワードを入力してください。",
"captchaRequired": "認証コードを入力してください。",
"captchaLoadFailed": "認証コードの読み込みに失敗しました。更新して再試行してください。",
"siweStatement": "FuturePulse にサインイン"
"captchaLoadFailed": "認証コードの読み込みに失敗しました。更新して再試行してください。"
},
"register": {
"title": "新規登録",
@ -495,7 +494,9 @@
"txConfirmTimeout": "トランザクションは送信されましたが、一定時間内に確認されませんでした。ウォレットまたはブロックエクスプローラーでご確認ください。",
"walletRpcIncomplete": "ウォレットから返ったトランザクション情報が不完全です。MetaMask の更新や RPC の変更をお試しください。",
"depositHistory": "入金履歴",
"depositStatusAll": "すべて"
"depositStatusAll": "すべて",
"minArrivalAmount": "最小着金金額:{amount} USDC",
"belowMinArrivalAmount": "入金額は {amount} USDC 以上である必要があります"
},
"withdraw": {
"title": "出金",
@ -520,7 +521,12 @@
"customAddressNotSupported": "この出金ではカスタムアドレスはサポートされていません。",
"loginWalletLabel": "ログイン中のウォレット",
"connectingWallet": "ウォレットに接続しています…",
"walletMismatch": "ログイン時に使用したアカウントにブラウザのウォレットを切り替えてください。"
"walletMismatch": "ログイン時に使用したアカウントにブラウザのウォレットを切り替えてください。",
"fee": "手数料",
"minAmount": "最低出金額",
"receiveAmount": "受取金額",
"belowMinAmount": "出金額は {min} USDC 以上である必要があります",
"amountMustExceedFee": "出金額は手数料 {fee} USDC より大きい必要があります"
},
"earnActivity": {
"title": "キャンペーンに参加して稼ぐ",

View File

@ -160,8 +160,7 @@
"loginFailed": "로그인에 실패했습니다. 계정 정보를 확인해 주세요.",
"credentialsRequired": "사용자 이름과 비밀번호를 입력해 주세요.",
"captchaRequired": "인증 코드를 입력해 주세요.",
"captchaLoadFailed": "인증 코드를 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요.",
"siweStatement": "FuturePulse 로그인"
"captchaLoadFailed": "인증 코드를 불러오지 못했습니다. 새로고침 후 다시 시도해 주세요."
},
"register": {
"title": "회원가입",
@ -495,7 +494,9 @@
"txConfirmTimeout": "거래는 제출되었지만 제한 시간 안에 확인되지 않았습니다. 지갑이나 블록 탐색기에서 확인해 주세요.",
"walletRpcIncomplete": "지갑에서 반환된 거래 데이터가 불완전합니다. MetaMask를 업데이트하거나 RPC를 변경해 보세요.",
"depositHistory": "입금 내역",
"depositStatusAll": "전체"
"depositStatusAll": "전체",
"minArrivalAmount": "최소 입금 금액: {amount} USDC",
"belowMinArrivalAmount": "입금 금액은 최소 {amount} USDC 이상이어야 합니다"
},
"withdraw": {
"title": "출금",
@ -520,7 +521,12 @@
"customAddressNotSupported": "이 출금에서는 사용자 지정 주소가 지원되지 않습니다.",
"loginWalletLabel": "로그인 지갑",
"connectingWallet": "지갑 연결 중…",
"walletMismatch": "로그인에 사용한 계정과 동일한 계정으로 브라우저 지갑을 전환해 주세요."
"walletMismatch": "로그인에 사용한 계정과 동일한 계정으로 브라우저 지갑을 전환해 주세요.",
"fee": "수수료",
"minAmount": "최소 출금 금액",
"receiveAmount": "실수령액",
"belowMinAmount": "출금 금액은 최소 {min} USDC 이상이어야 합니다",
"amountMustExceedFee": "출금 금액은 수수료 {fee} USDC 보다 커야 합니다"
},
"earnActivity": {
"title": "이벤트 참여하여 돈 벌기",

View File

@ -160,8 +160,7 @@
"loginFailed": "登录失败,请检查账号密码后重试。",
"credentialsRequired": "请输入用户名和密码。",
"captchaRequired": "请输入验证码。",
"captchaLoadFailed": "验证码加载失败,请刷新后重试。",
"siweStatement": "登录 FuturePulse"
"captchaLoadFailed": "验证码加载失败,请刷新后重试。"
},
"register": {
"title": "注册",
@ -495,7 +494,9 @@
"txConfirmTimeout": "交易已提交,但长时间未在链上确认,请在浏览器钱包或区块浏览器中查看该笔交易。",
"walletRpcIncomplete": "钱包返回的交易数据不完整。已改为直连接口发交易;若仍失败请更新 MetaMask 或更换 RPC。",
"depositHistory": "充值历史",
"depositStatusAll": "全部"
"depositStatusAll": "全部",
"minArrivalAmount": "最小到账金额:{amount} USDC",
"belowMinArrivalAmount": "充值金额不能低于 {amount} USDC"
},
"withdraw": {
"title": "提现",
@ -533,7 +534,12 @@
"customAddressNotSupported": "暂不支持自定义地址提现。",
"loginWalletLabel": "登录钱包(提现地址)",
"connectingWallet": "正在连接钱包…",
"walletMismatch": "请在浏览器钱包中切换到您登录时使用的账户。"
"walletMismatch": "请在浏览器钱包中切换到您登录时使用的账户。",
"fee": "手续费",
"minAmount": "最低提现金额",
"receiveAmount": "实际到账",
"belowMinAmount": "提现金额不能低于 {min} USDC",
"amountMustExceedFee": "提现金额必须大于手续费 {fee} USDC"
},
"earnActivity": {
"title": "参与活动赚钱",

View File

@ -160,8 +160,7 @@
"loginFailed": "登入失敗,請檢查帳號密碼後重試。",
"credentialsRequired": "請輸入使用者名稱和密碼。",
"captchaRequired": "請輸入驗證碼。",
"captchaLoadFailed": "驗證碼載入失敗,請刷新後重試。",
"siweStatement": "登入 FuturePulse"
"captchaLoadFailed": "驗證碼載入失敗,請刷新後重試。"
},
"register": {
"title": "註冊",
@ -495,7 +494,9 @@
"txConfirmTimeout": "交易已提交,但長時間未在鏈上確認,請在瀏覽器錢包或區塊瀏覽器中查看該筆交易。",
"walletRpcIncomplete": "錢包回傳的交易資料不完整。已改為直接介面發送;若仍失敗請更新 MetaMask 或更換 RPC。",
"depositHistory": "充值歷史",
"depositStatusAll": "全部"
"depositStatusAll": "全部",
"minArrivalAmount": "最小到帳金額:{amount} USDC",
"belowMinArrivalAmount": "充值金額不能低於 {amount} USDC"
},
"withdraw": {
"title": "提現",
@ -520,7 +521,12 @@
"customAddressNotSupported": "暫不支援自訂地址提現。",
"loginWalletLabel": "登入錢包(提現地址)",
"connectingWallet": "正在連接錢包…",
"walletMismatch": "請在瀏覽器錢包中切換至您登入時使用的帳戶。"
"walletMismatch": "請在瀏覽器錢包中切換至您登入時使用的帳戶。",
"fee": "手續費",
"minAmount": "最低提現金額",
"receiveAmount": "實際到帳",
"belowMinAmount": "提現金額不能低於 {min} USDC",
"amountMustExceedFee": "提現金額必須大於手續費 {fee} USDC"
},
"earnActivity": {
"title": "參與活動賺錢",

View File

@ -106,6 +106,9 @@ import { useUserStore } from '@/stores/user'
import { post } from '@/api/request'
import { getCaptcha, loginByPassword, parseLoginResponse, type LoginResponseData } from '@/api/auth'
// EIP-4361 只允许可打印 US-ASCII 的 statement,不能本地化
const SIWE_STATEMENT = 'Sign in to FuturePulse'
const router = useRouter()
const { t } = useI18n()
const userStore = useUserStore()
@ -213,7 +216,7 @@ const connectWithWallet = async () => {
scheme,
domain,
address: signer.address,
statement: t('login.siweStatement'),
statement: SIWE_STATEMENT,
uri: origin,
version: '1',
chainId: chainId,