feat: 完善移动端会员业务流程
This commit is contained in:
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user