530 lines
19 KiB
Vue
530 lines
19 KiB
Vue
<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>
|