feat: 完善移动端会员业务流程

This commit is contained in:
richjohnrambo
2026-10-13 14:19:55 +08:00
parent 32c6238751
commit b2ebea4c80
59 changed files with 1516 additions and 470 deletions
+14
View File
@@ -2,6 +2,7 @@ import axios, { AxiosError, AxiosRequestConfig } from 'axios';
const TOKEN_KEY = 'olons_web_session';
const DEVICE_KEY = 'olons_web_device';
const LOGIN_NOTICE_PENDING_KEY = 'olons_login_notice_pending';
const reasonMessages: Record<string, string> = {
AUTH_REQUIRED: '登录已失效,请重新登录',
@@ -19,6 +20,9 @@ const reasonMessages: Record<string, string> = {
ORDER_STATE_INVALID: '当前任务状态不允许此操作',
TRADE_PASSWORD_NOT_SET: '请先设置支付密码',
TRADE_PASSWORD_INVALID: '支付密码不正确',
PAYMENT_ACCOUNT_FIELDS_INVALID: '绑卡信息填写不完整或格式不正确',
QR_ATTACHMENT_INVALID: '二维码图片无效,请重新上传',
IMAGE_INVALID: '图片格式或大小不符合要求',
PAYMENT_ACCOUNT_INVALID: '收款账户未核验或不可用',
WITHDRAWAL_DISABLED: '取出功能暂未开放',
WITHDRAWAL_AMOUNT_INVALID: '取出金额不符合当前规则',
@@ -132,16 +136,26 @@ function getDeviceID() {
export function saveSession(token: string) {
sessionStorage.setItem(TOKEN_KEY, token);
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '1');
}
export function clearSession() {
sessionStorage.removeItem(TOKEN_KEY);
sessionStorage.removeItem(LOGIN_NOTICE_PENDING_KEY);
}
export function hasSession() {
return Boolean(sessionStorage.getItem(TOKEN_KEY));
}
export function shouldShowLoginNotice() {
return sessionStorage.getItem(LOGIN_NOTICE_PENDING_KEY) !== '0';
}
export function completeLoginNotice() {
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '0');
}
export function idempotencyKey() {
return crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 645 KiB

@@ -0,0 +1,148 @@
<template>
<Teleport to="body">
<Transition name="announcement-fade">
<div v-if="show" class="m-announcement-mask" role="presentation">
<section
class="m-announcement-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="announcement-title"
aria-describedby="announcement-content"
>
<h2 id="announcement-title" class="m-announcement-sr-title">
{{ notice?.title || '公告通知' }}
</h2>
<img class="m-announcement-board" :src="announcementBoard" alt="" />
<div class="m-announcement-body">
<div id="announcement-content" class="m-announcement-content">
{{ notice?.content }}
</div>
<button type="button" @click="$emit('read')">我已阅读</button>
</div>
</section>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue';
import announcementBoard from '../assets/announcement-popup@2x.png';
export interface AnnouncementNotice {
id: number;
title: string;
content: string;
}
const props = defineProps<{
show: boolean;
notice?: AnnouncementNotice | null;
}>();
defineEmits<{ read: [] }>();
watch(
() => props.show,
(show) => {
document.body.classList.toggle('m-announcement-open', show);
},
{ immediate: true }
);
onBeforeUnmount(() => document.body.classList.remove('m-announcement-open'));
</script>
<style scoped>
.m-announcement-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: grid;
place-items: center;
padding: 20px 16px;
background: rgba(0, 0, 0, 0.62);
}
.m-announcement-dialog {
position: relative;
width: min(312px, calc(100vw - 32px), calc((100vh - 32px) * 2 / 3));
aspect-ratio: 2 / 3;
}
.m-announcement-board {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
user-select: none;
}
.m-announcement-body {
position: absolute;
inset: 31.2% 16.7% 13.6%;
display: flex;
min-height: 0;
flex-direction: column;
}
.m-announcement-content {
flex: 1;
min-height: 0;
overflow-y: auto;
color: #282828;
font-size: clamp(14px, 4.25vw, 16px);
line-height: 1.42;
text-align: left;
white-space: pre-line;
scrollbar-width: none;
}
.m-announcement-content::-webkit-scrollbar {
display: none;
}
.m-announcement-body button {
flex: none;
width: 100%;
height: 41px;
margin-top: 10px;
border: 0;
border-radius: 24px;
background: linear-gradient(90deg, #ff972c 0%, #f45119 100%);
box-shadow: 0 5px 12px rgba(244, 81, 25, 0.2);
color: #fff;
font-size: 16px;
cursor: pointer;
}
.m-announcement-sr-title {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.announcement-fade-enter-active,
.announcement-fade-leave-active {
transition: opacity 0.2s ease;
}
.announcement-fade-enter-from,
.announcement-fade-leave-to {
opacity: 0;
}
</style>
<style>
body.m-announcement-open {
overflow: hidden;
}
</style>
+146
View File
@@ -1008,6 +1008,91 @@ body {
text-align: right;
overflow-wrap: anywhere;
}
.m-order-submit-panel {
padding: 17px;
border-radius: 8px;
background: #fff8ea;
box-shadow: 0 4px 18px rgba(73, 54, 31, 0.07);
}
.m-order-submit-panel strong {
color: #6f3f12;
font-size: 17px;
}
.m-order-submit-panel p {
margin: 7px 0 15px;
color: #8b765e;
font-size: 14px;
line-height: 1.6;
}
.m-order-submit-panel > button,
.m-order-submit-actions button:last-child {
border: 0;
color: #fff;
background: linear-gradient(90deg, #ffa32b, #f15b20);
}
.m-order-submit-panel > button {
width: 100%;
min-height: 46px;
border-radius: 24px;
font-size: 16px;
}
.m-order-submit-panel button:disabled,
.m-order-submit-actions button:disabled {
opacity: 0.6;
}
.m-order-submit-confirm {
width: min(90vw, 430px);
overflow: hidden;
border-radius: 18px;
background: #fff;
box-shadow: 0 16px 50px rgba(70, 40, 10, 0.2);
}
.m-order-submit-confirm h2 {
margin: 0;
padding: 24px 22px 8px;
text-align: center;
font-size: 21px;
}
.m-order-submit-confirm > p {
margin: 0;
padding: 0 25px 18px;
color: #8a8a8a;
text-align: center;
line-height: 1.55;
}
.m-order-submit-confirm dl {
margin: 0 20px 22px;
padding: 13px 16px;
border-radius: 10px;
background: #faf8f5;
}
.m-order-submit-confirm dl > div {
min-height: 38px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.m-order-submit-confirm dt {
flex: 0 0 auto;
color: #777;
}
.m-order-submit-confirm dd {
margin: 0;
overflow-wrap: anywhere;
text-align: right;
}
.m-order-submit-actions {
display: grid;
grid-template-columns: 1fr 1fr;
border-top: 1px solid #f0ebe4;
}
.m-order-submit-actions button {
min-height: 54px;
border: 0;
background: #fff;
font-size: 16px;
}
.m-btn-link {
display: grid;
place-items: center;
@@ -1449,9 +1534,70 @@ body {
color: var(--m-muted);
line-height: 1.6;
}
.m-form-hint a {
color: var(--m-primary);
font-weight: 600;
}
.m-plain-form > .m-form-error {
margin: 15px 30px;
}
.m-bank-form {
margin-top: 18px;
padding-bottom: 38px;
}
.m-bank-form > label {
min-height: 66px;
grid-template-columns: 125px 1fr;
border-bottom: 0;
}
.m-bank-form__divider {
height: 16px;
margin: 0;
background: #f6f6f6;
}
.m-qr-field {
padding: 16px 15px 0;
}
.m-qr-field h3 {
margin: 0 0 10px;
font-size: 16px;
font-weight: 400;
}
.m-qr-upload {
width: 100%;
aspect-ratio: 2.12 / 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px dashed #d1d1d1;
border-radius: 5px;
background: #f8f8f8;
color: #b8b8b8;
cursor: pointer;
}
.m-qr-upload input {
display: none;
}
.m-qr-upload > span {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.m-qr-upload .n-icon {
font-size: 50px;
color: #ddd;
}
.m-qr-upload b {
font-size: 15px;
font-weight: 400;
}
.m-qr-upload img {
width: 100%;
height: 100%;
object-fit: contain;
}
.m-verify label:nth-child(2) {
grid-template-columns: 125px 1fr auto;
}
+40 -14
View File
@@ -5,7 +5,7 @@
<img :src="mobileAssets.claimMachine" alt="每日参与任务,赚取更多佣金" />
<button
type="button"
:disabled="claiming || !halls.length || eligibility?.eligible === false"
:disabled="claiming || !eligibleHalls.length"
aria-label="自动领取任务"
@click="claim"
>
@@ -22,7 +22,7 @@
</div>
</section>
<div v-if="eligibility?.eligible === false" class="m-claim2-warning">
<div v-if="halls.length && !eligibleHalls.length && eligibility?.eligible === false" class="m-claim2-warning">
<n-icon :component="AlertCircleOutline" />
<span>{{ eligibility.message || '暂不满足领取条件' }}</span>
<button v-if="eligibility.pending_order_id" type="button" @click="openPendingOrder">查看任务</button>
@@ -100,7 +100,7 @@
import { useRouter } from 'vue-router';
import { AlertCircleOutline, ImageOutline } from '@vicons/ionicons5';
import AsyncState from '../components/AsyncState.vue';
import { dateTime, errorText, money, request, safeImage } from '../api';
import { MobileApiError, dateTime, errorText, money, request, safeImage } from '../api';
import { mobileAssets } from '../assets';
type ResultState = 'matching' | 'success' | 'failed' | 'cancelled';
@@ -112,6 +112,7 @@
const halls = ref<any[]>([]);
const stats = ref<any>();
const eligibility = ref<any>();
const eligibilityByHall = ref<Record<number, any>>({});
const order = ref<any>();
const showResult = ref(false);
const resultState = ref<ResultState>('matching');
@@ -128,6 +129,9 @@
{ label: '今日佣金', value: money(stats.value?.today_commission_amount), icon: mobileAssets.claimToday },
{ label: '今日领取数', value: stats.value?.today_claim_count ?? 0, icon: mobileAssets.claimCount },
]);
const eligibleHalls = computed(() =>
halls.value.filter((hall) => eligibilityByHall.value[hall.id]?.eligible)
);
const businessHours = computed(() => {
const start = String(eligibility.value?.business_start_at || '09:00').slice(0, 5);
const end = String(eligibility.value?.business_end_at || '21:00').slice(0, 5);
@@ -154,7 +158,7 @@
]);
halls.value = hallData || [];
stats.value = statData;
if (halls.value.length) await loadEligibility(halls.value[0].id);
if (halls.value.length) await loadEligibilities();
} catch (err) {
error.value = errorText(err);
} finally {
@@ -162,8 +166,15 @@
}
}
async function loadEligibility(hallID: number) {
eligibility.value = await request({ url: '/v1/tasks/eligibility', params: { hall_id: hallID } });
async function loadEligibilities() {
const entries = await Promise.all(
halls.value.map(async (hall) => [
hall.id,
await request<any>({ url: '/v1/tasks/eligibility', params: { hall_id: hall.id } }),
] as const)
);
eligibilityByHall.value = Object.fromEntries(entries);
eligibility.value = entries.find(([, value]) => value?.eligible)?.[1] || entries[0]?.[1];
}
async function claim() {
@@ -171,18 +182,33 @@
claiming.value = true;
pollFinished.value = false;
try {
await loadEligibility(halls.value[0].id);
if (!eligibility.value?.eligible) {
await loadEligibilities();
const candidates = eligibleHalls.value;
if (!candidates.length) {
window.$message.warning(eligibility.value?.message || '暂不满足领取条件');
return;
}
openMatching();
order.value = await request<any>({
url: '/v1/task-orders/claim',
method: 'POST',
data: { hall_id: halls.value[0].id },
});
handleOrder(order.value);
let stockError: unknown;
for (const hall of candidates) {
eligibility.value = eligibilityByHall.value[hall.id];
try {
order.value = await request<any>({
url: '/v1/task-orders/claim',
method: 'POST',
data: { hall_id: hall.id },
});
handleOrder(order.value);
return;
} catch (err) {
if (err instanceof MobileApiError && err.reason === 'NO_TASK_AVAILABLE') {
stockError = err;
continue;
}
throw err;
}
}
throw stockError || new Error('当前暂无可领取任务');
} catch (err) {
failureMessage.value = errorText(err);
resultState.value = 'failed';
+66 -32
View File
@@ -43,38 +43,38 @@
</div>
</AsyncState>
<form v-else-if="formType === 'bank'" class="m-plain-form" @submit.prevent="saveBank">
<form v-else-if="formType === 'bank'" class="m-plain-form m-bank-form" @submit.prevent="saveBank">
<label
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
/></label>
<label
><span>身份证号</span
><input v-model.trim="bank.identity_no" maxlength="18" placeholder="请输入你的身份证号"
/></label>
<div class="m-bank-form__divider"></div>
<label
><span>银行卡号</span
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入银行卡号"
><input v-model.trim="bank.account_no" inputmode="numeric" placeholder="请输入你的银行卡号"
/></label>
<label
><span>开户行</span><input v-model.trim="bank.bank_name" placeholder="请输入开户行"
/></label>
<label
><span>银行编码</span><input v-model.trim="bank.bank_code" placeholder="例如 ICBC"
/></label>
<label
><span>预留手机号</span
><input
v-model.trim="bank.reserved_phone"
inputmode="numeric"
maxlength="11"
placeholder="请输入预留手机号(选填)"
/></label>
<label
><span>交易密码</span
><input
v-model="bank.trade_password"
type="password"
inputmode="numeric"
maxlength="6"
placeholder="请输入 6 位交易密码"
/></label>
<p class="m-form-hint">微信、支付宝二维码上传能力将在上传接口开放后提供。</p>
<section class="m-qr-field">
<h3>微信二维码</h3>
<label class="m-qr-upload">
<input type="file" accept="image/jpeg,image/png,image/webp" @change="uploadQRCode('wechat', $event)" />
<img v-if="qr.wechat.url" :src="safeImage(qr.wechat.url)" alt="微信二维码" />
<span v-else><n-icon :component="CameraOutline" /><b>{{ qr.wechat.uploading ? '上传中...' : '点击上传' }}</b></span>
</label>
</section>
<section class="m-qr-field">
<h3>支付宝二维码</h3>
<label class="m-qr-upload">
<input type="file" accept="image/jpeg,image/png,image/webp" @change="uploadQRCode('alipay', $event)" />
<img v-if="qr.alipay.url" :src="safeImage(qr.alipay.url)" alt="支付宝二维码" />
<span v-else><n-icon :component="CameraOutline" /><b>{{ qr.alipay.uploading ? '上传中...' : '点击上传' }}</b></span>
</label>
</section>
<p v-if="error" class="m-form-error">{{ error }}</p
><button class="m-primary-btn" :disabled="submitting">{{
submitting ? '提交中...' : '确认绑定'
@@ -208,6 +208,7 @@
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
CameraOutline,
ChevronForwardOutline,
KeyOutline,
LockClosedOutline,
@@ -239,14 +240,16 @@
const content = ref<any>();
const resetPassword = ref('');
const bank = reactive({
account_type: 'bank_card',
bank_code: '',
bank_name: '',
holder_name: '',
identity_no: '',
account_no: '',
reserved_phone: '',
trade_password: '',
qr_attachment_id: 0,
wechat_qr_attachment_id: 0,
alipay_qr_attachment_id: 0,
});
const qr = reactive({
wechat: { uploading: false, url: '' },
alipay: { uploading: false, url: '' },
});
const trade = reactive({ login_password: '', old_trade_password: '', new_trade_password: '' });
const loginPassword = reactive({ old_password: '', new_password: '', confirm_password: '' });
@@ -266,24 +269,55 @@
error.value = '';
if (
!bank.holder_name ||
!/^[1-9]\d{14}(?:\d{2}[\dXx])?$/.test(bank.identity_no.replace(/[ -]/g, '')) ||
!/^[0-9 -]{8,36}$/.test(bank.account_no) ||
!bank.bank_name ||
!/^[0-9]{6}$/.test(bank.trade_password)
!bank.wechat_qr_attachment_id ||
!bank.alipay_qr_attachment_id
) {
error.value = '请完整填写姓名、银行卡、开户行和 6 位交易密码';
error.value = '请完整填写绑卡信息并上传微信、支付宝二维码';
return;
}
submitting.value = true;
try {
await request({ url: '/v1/payment-accounts', method: 'POST', data: bank });
window.$message?.success('提交成功,等待后台核验');
router.back();
window.$message?.success('银行卡绑定成功');
await router.replace('/app/profile/withdrawal');
} catch (err) {
error.value = errorText(err);
} finally {
submitting.value = false;
}
}
async function uploadQRCode(kind: 'wechat' | 'alipay', event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 5 * 1024 * 1024) {
error.value = '二维码仅支持 5MB 以内的 JPG、PNG 或 WebP 图片';
return;
}
const target = qr[kind];
target.uploading = true;
error.value = '';
try {
const form = new FormData();
form.append('file', file);
const uploaded = await request<{ id: number; url: string }>({
url: '/v1/attachments/images',
method: 'POST',
data: form,
});
target.url = uploaded.url;
if (kind === 'wechat') bank.wechat_qr_attachment_id = uploaded.id;
else bank.alipay_qr_attachment_id = uploaded.id;
} catch (err) {
error.value = errorText(err);
} finally {
target.uploading = false;
}
}
async function saveTradePassword() {
error.value = '';
if (!/^\d{6}$/.test(trade.new_trade_password) || trade.login_password.length < 6) {
+31 -2
View File
@@ -1,5 +1,10 @@
<template>
<section class="m-home">
<AnnouncementPopup
:show="Boolean(popupNotice)"
:notice="popupNotice"
@read="markPopupRead"
/>
<div class="m-home-hero" :style="bannerStyle">
<label class="m-search">
<n-icon :component="SearchOutline" />
@@ -81,7 +86,17 @@
import { useRouter } from 'vue-router';
import { SearchOutline, MegaphoneOutline } from '@vicons/ionicons5';
import AsyncState from '../components/AsyncState.vue';
import { errorText, money, request, safeImage } from '../api';
import AnnouncementPopup, {
type AnnouncementNotice,
} from '../components/AnnouncementPopup.vue';
import {
completeLoginNotice,
errorText,
money,
request,
safeImage,
shouldShowLoginNotice,
} from '../api';
import { mobileAssets } from '../assets';
interface HomeData {
@@ -108,6 +123,7 @@
const loading = ref(true);
const error = ref('');
const leaderboard = ref<LeaderboardEntry[]>([]);
const popupNotice = ref<AnnouncementNotice | null>(null);
const formatCount = (value: number) => new Intl.NumberFormat('zh-CN').format(value || 0);
const bannerStyle = computed(() => {
const url = safeImage(home.value?.banners?.[0]?.image_url);
@@ -166,17 +182,30 @@
loading.value = true;
error.value = '';
try {
const [homeData, rankData] = await Promise.all([
const noticeRequest = shouldShowLoginNotice()
? request<AnnouncementNotice | null>({ url: '/v1/notices/popup' }).catch(() => null)
: Promise.resolve(null);
const [homeData, rankData, noticeData] = await Promise.all([
request<HomeData>({ url: '/v1/home' }),
request<LeaderboardEntry[]>({ url: '/v1/leaderboard' }).catch(() => []),
noticeRequest,
]);
home.value = homeData;
leaderboard.value = rankData || [];
popupNotice.value = noticeData;
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
function markPopupRead() {
if (!popupNotice.value) return;
const noticeId = popupNotice.value.id;
popupNotice.value = null;
completeLoginNotice();
request<void>({ url: `/v1/notices/${noticeId}/read`, method: 'POST' }).catch(() => undefined);
}
onMounted(load);
</script>
+52 -1
View File
@@ -103,8 +103,31 @@
><b>{{ detail.product?.specification || detail.product?.spec || '--' }}</b></div
>
</section>
<section v-if="canSubmit" class="m-order-submit-panel">
<div>
<strong>任务已匹配成功</strong>
<p>请确认任务信息,提交后佣金将自动结算到可用余额。</p>
</div>
<button type="button" :disabled="submitting" @click="showSubmitConfirm = true">立即提交</button>
</section>
</div>
</AsyncState>
<n-modal v-model:show="showSubmitConfirm" :mask-closable="!submitting">
<section class="m-order-submit-confirm">
<h2>确认提交任务</h2>
<p>提交后系统将自动完成订单并发放佣金,请确认订单信息无误。</p>
<dl>
<div><dt>记录编号</dt><dd>{{ detail?.order_no || '--' }}</dd></div>
<div><dt>任务商品</dt><dd>{{ detail?.product?.name || '--' }}</dd></div>
<div><dt>预计佣金</dt><dd>{{ money(detail?.commission_amount, detail?.currency) }}</dd></div>
</dl>
<div class="m-order-submit-actions">
<button type="button" :disabled="submitting" @click="showSubmitConfirm = false">暂不提交</button>
<button type="button" :disabled="submitting" @click="submitOrder">{{ submitting ? '提交中...' : '立即提交' }}</button>
</div>
</section>
</n-modal>
</section>
</template>
@@ -114,7 +137,7 @@
import { ImageOutline } from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { dateTime, errorText, money, request, safeImage } from '../api';
import { MobileApiError, dateTime, errorText, money, request, safeImage } from '../api';
const route = useRoute();
const isDetail = computed(() => Boolean(route.params.id));
@@ -124,6 +147,8 @@
const orders = ref<any[]>([]);
const detail = ref<any>();
const stats = ref<any>();
const showSubmitConfirm = ref(false);
const submitting = ref(false);
let listController: AbortController | undefined;
const tabs = [
{ label: '全部', value: 'all' },
@@ -149,6 +174,9 @@
failed: '失败',
};
const statusText = (value: string) => labels[value] || value;
const canSubmit = computed(
() => detail.value?.status === 'pending' && detail.value?.allowed_actions?.includes('submit')
);
async function loadList() {
listController?.abort();
const controller = new AbortController();
@@ -185,11 +213,34 @@
loading.value = false;
}
}
async function submitOrder() {
if (!detail.value?.id || submitting.value) return;
submitting.value = true;
try {
await request({
url: `/v1/task-orders/${detail.value.id}/submissions`,
method: 'POST',
data: {},
});
showSubmitConfirm.value = false;
window.$message.success('提交成功,佣金已自动入账');
await loadDetail();
} catch (err) {
window.$message.error(errorText(err));
if (err instanceof MobileApiError && err.reason === 'ORDER_EXPIRED') {
showSubmitConfirm.value = false;
await loadDetail();
}
} finally {
submitting.value = false;
}
}
function changeTab(value: string) {
status.value = value;
loadList();
}
function load() {
showSubmitConfirm.value = false;
if (isDetail.value) loadDetail();
else loadList();
}
+1 -1
View File
@@ -104,7 +104,7 @@
remark: `姓名:${form.name}`,
},
});
window.$message?.success(`储入订单 ${order.recharge_no} 已创建`);
window.$message?.success(`储入申请 ${order.recharge_no} 已提交审核`);
form.amount = '';
form.name = '';
} catch (err) {
+16 -2
View File
@@ -41,7 +41,12 @@
<span>加载中</span>
</div>
<n-modal v-model:show="showConfirm">
<n-modal
v-model:show="showConfirm"
:auto-focus="false"
:trap-focus="false"
@after-enter="focusTradePassword"
>
<form class="m-withdraw-confirm m-card" @submit.prevent="submitWithdrawal">
<h2>确认取出</h2>
<div><span>申请金额</span><b>{{ money(quote?.request_amount) }}</b></div>
@@ -50,8 +55,11 @@
<label>
<span>支付密码</span>
<input
ref="tradePasswordInput"
v-model="form.tradePassword"
type="password"
name="trade-password"
autocomplete="off"
inputmode="numeric"
maxlength="6"
placeholder="请输入 6 位支付密码"
@@ -65,7 +73,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, nextTick, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ChevronForwardOutline } from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue';
@@ -83,6 +91,7 @@
const quote = ref<any>();
const showConfirm = ref(false);
const withdrawalKey = ref('');
const tradePasswordInput = ref<HTMLInputElement>();
const form = reactive({ paymentAccountId: 0, amountYuan: '', tradePassword: '' });
const selectedAccount = computed(() =>
accounts.value.find((item) => item.id === form.paymentAccountId)
@@ -101,6 +110,10 @@
function openAccountPicker() {
if (!selectedAccount.value) router.push('/app/profile/bank-card');
}
async function focusTradePassword() {
await nextTick();
tradePasswordInput.value?.focus();
}
async function load() {
loading.value = true;
loadError.value = '';
@@ -138,6 +151,7 @@
data: { payment_account_id: form.paymentAccountId, amount, currency: 'CNY' },
});
withdrawalKey.value = idempotencyKey();
form.tradePassword = '';
showConfirm.value = true;
} catch (err) {
actionError.value = errorText(err);
+4
View File
@@ -15,6 +15,10 @@ export default defineConfig(({ mode }) => {
target: env.VITE_API_TARGET || 'http://localhost:6061',
changeOrigin: true,
},
'/uploads': {
target: env.VITE_API_TARGET || 'http://localhost:6061',
changeOrigin: true,
},
},
},
build: {