Files
olons_server/web/src/mobile/views/FormView.vue
T

530 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section
class="m-page m-page--white m-subpage"
:class="{ 'm-subpage--profile': formType === 'profile' }"
>
<PageHeader :title="title" />
<AsyncState
v-if="formType === 'profile'"
:loading="loading"
:error="error"
@retry="loadProfile"
>
<div class="m-profile-info">
<div class="m-profile-info-card m-card">
<label class="m-profile-info-row m-profile-avatar-row">
<n-icon :component="ImageOutline" />
<span>头像</span>
<span class="m-profile-avatar-preview">
<img v-if="safeImage(me?.avatar_url)" :src="safeImage(me.avatar_url)" alt="用户头像" />
<n-icon v-else :component="PersonCircleOutline" />
<small v-if="avatarUploading">上传中...</small>
</span>
<n-icon :component="ChevronForwardOutline" />
<input type="file" accept="image/jpeg,image/png,image/webp" :disabled="avatarUploading" @change="uploadAvatar" />
</label>
</div>
<p v-if="avatarError" class="m-form-error">{{ avatarError }}</p>
<div class="m-profile-info-card m-card">
<div class="m-profile-info-row">
<n-icon :component="PhonePortraitOutline" />
<span>我的手机号</span>
<b>{{ me?.phone }}</b>
</div>
<router-link class="m-profile-info-row" to="/app/profile/bank-card?from=profile">
<n-icon :component="CardOutline" />
<span>取出信息</span>
<n-icon :component="ChevronForwardOutline" />
</router-link>
<router-link class="m-profile-info-row" to="/app/profile/password">
<n-icon :component="LockClosedOutline" />
<span>密码管理</span>
<n-icon :component="ChevronForwardOutline" />
</router-link>
<router-link class="m-profile-info-row" to="/app/profile/trade-password">
<n-icon :component="LockClosedOutline" />
<span>支付密码管理</span>
<n-icon :component="ChevronForwardOutline" />
</router-link>
</div>
</div>
</AsyncState>
<AsyncState v-else-if="formType === 'bank'" :loading="bankLoading" :error="bankLoadError" @retry="loadPaymentAccounts">
<form class="m-plain-form m-bank-form" @submit.prevent="saveBank">
<p v-if="existingAccount?.masked" class="m-bound-account-hint">历史资料无法完整读取,请重新填写姓名和银行卡号后保存</p>
<label
><span>姓名</span><input v-model.trim="bank.holder_name" placeholder="请输入姓名"
/></label>
<label
><span>银行卡号</span
><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.digital_rmb_code"
inputmode="numeric"
maxlength="32"
placeholder="请输入数字编码(选填)"
/></label>
<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 ? '上传中...' : bank.wechat_qr_attachment_id ? '已上传,点击更换' : '点击上传' }}</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 ? '上传中...' : bank.alipay_qr_attachment_id ? '已上传,点击更换' : '点击上传' }}</b></span>
</label>
</section>
<p v-if="error" class="m-form-error">{{ error }}</p
><button class="m-primary-btn" :disabled="submitting || qr.wechat.uploading || qr.alipay.uploading">{{
submitting ? '提交中...' : existingAccount ? '保存修改' : '确认绑定'
}}</button>
</form>
</AsyncState>
<form
v-else-if="formType === 'tradePassword'"
class="m-password-form"
@submit.prevent="saveTradePassword"
>
<h2>设置支付密码</h2><p>支付密码用于收款账户绑定和取出确认</p>
<div class="m-code-input"
><input
v-model="trade.new_trade_password"
type="password"
inputmode="numeric"
maxlength="6"
placeholder="请输入 6 位数字"
/></div>
<label class="m-input-row"
><n-icon :component="LockClosedOutline" /><input
v-model="trade.login_password"
type="password"
placeholder="请输入登录密码确认"
/></label>
<label class="m-input-row"
><n-icon :component="KeyOutline" /><input
v-model="trade.old_trade_password"
type="password"
inputmode="numeric"
maxlength="6"
placeholder="已设置过时请输入原支付密码"
/></label>
<p v-if="error" class="m-form-error">{{ error }}</p
><button class="m-primary-btn" :disabled="submitting">{{
submitting ? '提交中...' : '确定'
}}</button>
</form>
<form
v-else-if="formType === 'loginPassword'"
class="m-password-form"
@submit.prevent="saveLoginPassword"
>
<h2>修改登录密码</h2><p>修改成功后需要重新登录</p>
<label class="m-input-row"
><n-icon :component="LockClosedOutline" /><input
v-model="loginPassword.old_password"
type="password"
autocomplete="current-password"
placeholder="请输入当前密码"
/></label>
<label class="m-input-row"
><n-icon :component="KeyOutline" /><input
v-model="loginPassword.new_password"
type="password"
autocomplete="new-password"
placeholder="请输入新密码"
/></label>
<label class="m-input-row"
><n-icon :component="KeyOutline" /><input
v-model="loginPassword.confirm_password"
type="password"
autocomplete="new-password"
placeholder="请再次输入新密码"
/></label>
<p v-if="error" class="m-form-error">{{ error }}</p>
<button class="m-primary-btn" :disabled="submitting">{{
submitting ? '提交中...' : '确定修改'
}}</button>
</form>
<form v-else-if="formType === 'verify'" class="m-plain-form m-verify" @submit.prevent="goReset">
<label
><span>手机号</span
><input
v-model.trim="verify.phone"
inputmode="numeric"
maxlength="11"
placeholder="请输入手机号"
/></label>
<label
><span>验证码</span
><input
v-model.trim="verify.code"
inputmode="numeric"
maxlength="6"
placeholder="请输入验证码"
/><button type="button" @click="notifyUnavailable">获取验证码</button></label
>
<p v-if="error" class="m-form-error">{{ error }}</p
><button class="m-primary-btn">确定</button>
</form>
<form
v-else-if="formType === 'reset'"
class="m-password-form"
@submit.prevent="notifyUnavailable"
>
<h2>重设密码</h2><p>请输入新的登录密码</p
><label class="m-input-row"
><n-icon :component="LockClosedOutline" /><input
v-model="resetPassword"
type="password"
placeholder="请输入新密码" /></label
><button class="m-primary-btn">确定</button>
</form>
<AsyncState
v-else
:loading="loading"
:error="error"
:empty="!content"
empty-text="暂无内容"
@retry="loadContent"
>
<article v-if="content" class="m-content"
><h1>{{ content.title }}</h1
><p class="m-content-summary">{{ content.summary }}</p
><div v-if="content.body_html" class="m-content-body m-managed-rich-text" v-html="content.body_html"></div
><div v-else class="m-content-body">{{ content.body_text || '暂无正文' }}</div
><time v-if="content.published_at"
>发布时间:{{ dateTime(content.published_at) }}</time
></article
>
</AsyncState>
</section>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
CardOutline,
CameraOutline,
ChevronForwardOutline,
ImageOutline,
KeyOutline,
LockClosedOutline,
PersonCircleOutline,
PhonePortraitOutline,
} from '@vicons/ionicons5';
import PageHeader from '../components/PageHeader.vue';
import AsyncState from '../components/AsyncState.vue';
import { clearSession, dateTime, errorText, request, safeImage } from '../api';
const route = useRoute();
const router = useRouter();
const formType = computed(() => String(route.meta.form || ''));
const contentTitles: Record<string, string> = {
recharge_records: '储入记录',
withdrawal_records: '取出记录',
wallet_ledgers: '账户明细',
notices: '弹窗明细',
};
const title = computed(
() =>
content.value?.title ||
contentTitles[String(route.params.key || '')] ||
String(route.meta.title || '')
);
const loading = ref(false);
const bankLoading = ref(false);
const bankLoadError = ref('');
const submitting = ref(false);
const error = ref('');
const avatarError = ref('');
const avatarUploading = ref(false);
const me = ref<any>();
interface PaymentAccountDetail {
id: number;
bank_name: string;
holder_name: string;
account_no: string;
digital_rmb_code: string;
wechat_qr_url: string;
alipay_qr_url: string;
wechat_qr_attachment_id: number;
alipay_qr_attachment_id: number;
masked: boolean;
}
const existingAccount = ref<PaymentAccountDetail | null>(null);
let paymentAccountsLoadVersion = 0;
const content = ref<any>();
const resetPassword = ref('');
const bank = reactive({
bank_name: '',
holder_name: '',
account_no: '',
digital_rmb_code: '',
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: '' });
const verify = reactive({ phone: '', code: '' });
async function loadProfile() {
loading.value = true;
error.value = '';
try {
me.value = await request({ url: '/v1/me' });
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
async function uploadAvatar(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file || avatarUploading.value) return;
avatarError.value = '';
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) || file.size > 5 * 1024 * 1024) {
avatarError.value = '头像仅支持 5MB 以内的 JPG、PNG 或 WebP 图片';
return;
}
avatarUploading.value = true;
try {
const data = new FormData();
data.append('file', file);
const uploaded = await request<{ id: number; url: string }>({
url: '/v1/attachments/images',
method: 'POST',
data,
});
const avatar = await request<{ attachment_id: number; avatar_url: string }>({
url: '/v1/me/avatar',
method: 'PATCH',
data: { attachment_id: uploaded.id },
});
me.value = { ...me.value, avatar_url: avatar.avatar_url || uploaded.url };
window.$message?.success('头像更新成功');
} catch (err) {
avatarError.value = errorText(err);
} finally {
avatarUploading.value = false;
}
}
async function loadPaymentAccounts() {
const version = ++paymentAccountsLoadVersion;
bankLoading.value = true;
bankLoadError.value = '';
error.value = '';
existingAccount.value = null;
Object.assign(bank, { bank_name: '', holder_name: '', account_no: '', digital_rmb_code: '',
wechat_qr_attachment_id: 0, alipay_qr_attachment_id: 0 });
qr.wechat.url = '';
qr.alipay.url = '';
try {
const accounts = await request<PaymentAccountDetail[]>({ url: '/v1/payment-accounts/details' });
if (version !== paymentAccountsLoadVersion || formType.value !== 'bank') return;
const account = accounts[0];
if (!account) return;
existingAccount.value = account;
bank.bank_name = account.bank_name;
bank.wechat_qr_attachment_id = account.wechat_qr_attachment_id;
bank.alipay_qr_attachment_id = account.alipay_qr_attachment_id;
if (!account.masked) {
bank.holder_name = account.holder_name;
bank.account_no = account.account_no;
bank.digital_rmb_code = account.digital_rmb_code;
}
} catch (err) {
if (version === paymentAccountsLoadVersion && formType.value === 'bank') bankLoadError.value = errorText(err);
} finally {
if (version === paymentAccountsLoadVersion) bankLoading.value = false;
}
}
async function saveBank() {
error.value = '';
const accountNo = bank.account_no.replace(/[ -]/g, '');
const digitalRMBCode = bank.digital_rmb_code.replace(/[ -]/g, '');
const validationError =
bank.holder_name.length < 2
? '请输入至少 2 个字符的姓名'
: !/^\d{8,32}$/.test(accountNo)
? '请输入 8 至 32 位银行卡号'
: bank.bank_name.length < 2
? '请输入开户行'
: digitalRMBCode && !/^\d{6,32}$/.test(digitalRMBCode)
? '数字人民币编码须为 6 至 32 位数字,也可留空'
: !bank.wechat_qr_attachment_id || !bank.alipay_qr_attachment_id
? '请上传微信和支付宝二维码'
: '';
if (validationError) {
error.value = validationError;
window.$message?.error(validationError);
return;
}
submitting.value = true;
try {
await request({ url: '/v1/payment-accounts', method: existingAccount.value ? 'PUT' : 'POST',
data: { ...bank, id: existingAccount.value?.id } });
window.$message?.success(existingAccount.value ? '绑定信息已更新' : '银行卡绑定成功');
await router.replace(route.query.from === 'profile' ? '/app/profile/info' : '/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) {
error.value = '请输入 6 位支付密码并填写登录密码';
return;
}
submitting.value = true;
try {
await request({
url: '/v1/me/trade-password',
method: 'PUT',
data: { ...trade, old_trade_password: trade.old_trade_password || undefined },
});
window.$message?.success('支付密码设置成功');
router.back();
} catch (err) {
error.value = errorText(err);
} finally {
submitting.value = false;
}
}
async function saveLoginPassword() {
error.value = '';
if (loginPassword.old_password.length < 6 || loginPassword.new_password.length < 6) {
error.value = '登录密码至少为 6 位';
return;
}
if (loginPassword.new_password !== loginPassword.confirm_password) {
error.value = '两次输入的新密码不一致';
return;
}
submitting.value = true;
try {
await request({
url: '/v1/me/password',
method: 'PUT',
data: {
old_password: loginPassword.old_password,
new_password: loginPassword.new_password,
},
});
clearSession();
window.$message?.success('密码修改成功,请重新登录');
await router.replace('/app/auth');
} catch (err) {
error.value = errorText(err);
} finally {
submitting.value = false;
}
}
function notifyUnavailable() {
window.$message?.warning('短信验证接口尚未开放');
}
function goReset() {
if (!/^1\d{10}$/.test(verify.phone) || !/^\d{4,6}$/.test(verify.code)) {
error.value = '请输入正确的手机号和验证码';
return;
}
notifyUnavailable();
}
async function loadContent() {
loading.value = true;
error.value = '';
const key = String(route.params.key || '');
if (['recharge_records', 'wallet_ledgers'].includes(key)) {
loading.value = false;
return;
}
try {
if (key === 'withdrawal_records') {
const data = await request<any>({
url: '/v1/withdrawal-orders',
params: { page: 1, page_size: 20, status: 'all' },
});
content.value = {
title: '取出记录',
body_text: data.items?.length
? data.items
.map((x: any) => `${dateTime(x.created_at)} ${x.status_text || x.status}`)
.join('\n')
: '暂无取出记录',
};
} else {
content.value = await request({ url: `/v1/contents/${key}` });
}
} catch (err) {
error.value = errorText(err);
} finally {
loading.value = false;
}
}
watch(
() => [formType.value, String(route.params.key || '')],
([type]) => {
paymentAccountsLoadVersion += 1;
error.value = '';
if (type !== 'content') content.value = undefined;
if (type === 'profile') void loadProfile();
else if (type === 'bank') void loadPaymentAccounts();
else if (type === 'content') void loadContent();
},
{ immediate: true }
);
</script>