feat: add account profile, search, help and security features
1. add default avatar and multiple notification icons 2. update finance archive note and add form styles 3. add new pages: edit-profile, security, password, change-phone, search, help 4. refactor navigation action layout to support multiple actions 5. add search button to home page 6. add security code component and account service APIs 7. add help and search content services 8. update message notice to use svg icons 9. update asset section in finance assessment 10. update manifest appid and pack options
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<view class="page" :style="pageStyle()"><AppHeader title="更换手机号" />
|
||||
<scroll-view scroll-y :style="contentStyle(96, false)"><view class="account-form-content" :style="bottomStyle(40)">
|
||||
<text class="form-title">验证并绑定新手机号</text><text class="form-description">请分别验证当前号码和新号码,确保账户由您本人操作。</text>
|
||||
<view class="form-card"><text class="form-label">当前手机号 {{ maskPhone(phone) }}</text><SecurityCode v-model="currentCode" :disabled="submitting" purpose="phone-current" @error="codeError" @sent="errorMessage = ''" /></view>
|
||||
<view class="form-card"><text class="form-label">新手机号</text><input v-model="newPhone" class="account-input new-phone" :disabled="submitting" type="number" maxlength="11" placeholder="请输入新的 11 位手机号" /><SecurityCode v-model="newCode" :disabled="submitting || !validPhone(newPhone) || newPhone == phone" purpose="phone-new" :phone="newPhone" @error="codeError" @sent="errorMessage = ''" /></view>
|
||||
<text class="form-hint">换绑成功后需要使用新手机号登录。若原号码已无法接收短信,请通过官方客服核验身份后处理。</text>
|
||||
<text v-if="errorMessage.length > 0" class="form-error">{{ errorMessage }}</text>
|
||||
<button class="primary-button form-submit" :disabled="submitting" @click="submit">{{ submitting ? '提交中…' : '确认更换' }}</button>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="uts">
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import SecurityCode from '@/packages/account/components/SecurityCode.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession, clearSession } from '@/services/api.uts'
|
||||
import { getAccountProfile, maskPhone, validPhone, changeAccountPhone } from '@/packages/account/services/account.uts'
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
const phone = ref(''); const newPhone = ref(''); const currentCode = ref(''); const newCode = ref(''); const submitting = ref(false); const errorMessage = ref('')
|
||||
onLoad(() => { if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }; phone.value = getAccountProfile().phone })
|
||||
const codeError = (message: string) => { errorMessage.value = message }
|
||||
const submit = async () => {
|
||||
if (submitting.value) return
|
||||
submitting.value = true; errorMessage.value = ''
|
||||
try {
|
||||
if (getAccountProfile().phone != phone.value) throw new Error('登录状态已变化,请重新进入页面')
|
||||
await changeAccountPhone(newPhone.value, currentCode.value, newCode.value)
|
||||
currentCode.value = ''; newCode.value = ''; clearSession(); uni.reLaunch({ url: '/packages/account/login/login' }); uni.showToast({ title: '手机号已更换,请重新登录', icon: 'none' })
|
||||
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '更换失败,请稍后重试' }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
onHide(() => { currentCode.value = ''; newCode.value = '' })
|
||||
onUnload(() => { currentCode.value = ''; newCode.value = '' })
|
||||
</script>
|
||||
<style>
|
||||
@import '../styles/forms.css';
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<view class="code-field"><input class="code-input" :value="modelValue" :disabled="disabled" type="number" maxlength="6" placeholder="请输入 6 位验证码" @input="emit('update:modelValue', $event.detail.value)" /><button class="code-button" :disabled="disabled || sending || countdown > 0" @click="send">{{ sending ? '发送中…' : (countdown > 0 ? countdown + 's 后重试' : '获取验证码') }}</button></view>
|
||||
</template>
|
||||
<script setup lang="uts">
|
||||
import { requestSecurityCode } from '@/packages/account/services/account.uts'
|
||||
const props = defineProps({ modelValue: { type: String, default: '' }, purpose: { type: String, default: 'password' }, phone: { type: String, default: '' }, disabled: { type: Boolean, default: false } })
|
||||
const emit = defineEmits(['update:modelValue', 'error', 'sent'])
|
||||
const sending = ref(false); const countdown = ref(0)
|
||||
let timer = 0; let version = 0; let expiresAt = 0
|
||||
const reset = () => { version++; clearInterval(timer); countdown.value = 0; sending.value = false }
|
||||
const targetPhone = computed((): string => props.phone)
|
||||
watch(targetPhone, () => { reset(); emit('update:modelValue', '') })
|
||||
const send = async () => {
|
||||
if (props.disabled || sending.value || countdown.value > 0) return
|
||||
const current = ++version; sending.value = true
|
||||
try {
|
||||
await requestSecurityCode(props.purpose, props.phone)
|
||||
if (current != version) return
|
||||
expiresAt = Date.now() + 60000; countdown.value = 60
|
||||
timer = setInterval(() => { countdown.value = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000)); if (countdown.value == 0) clearInterval(timer) }, 1000)
|
||||
emit('sent'); uni.showToast({ title: '验证码已发送', icon: 'none' })
|
||||
} catch (error) { if (current == version) emit('error', error instanceof Error ? error.message : '发送失败,请稍后重试') }
|
||||
finally { if (current == version) sending.value = false }
|
||||
}
|
||||
onUnmounted(() => reset())
|
||||
</script>
|
||||
<style>
|
||||
.code-field { flex-direction: row; align-items: center; height: 96rpx; border-bottom: 1rpx solid #edf1f6; }
|
||||
.code-input { flex: 1; min-width: 0; font-size: 27rpx; color: #23344e; }
|
||||
.code-button { flex-shrink: 0; padding: 0 12rpx; margin: 0 0 0 12rpx; height: 72rpx; line-height: 72rpx; font-size: 24rpx; color: #0868f7; background-color: transparent; }
|
||||
</style>
|
||||
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<view class="page" :style="pageStyle()">
|
||||
<AppHeader title="修改资料" customBack @back="back" />
|
||||
<scroll-view scroll-y :style="contentStyle(96, false)"><view class="edit-content" :style="bottomStyle(40)">
|
||||
<view class="avatar-panel" @click="chooseAvatar"><image class="edit-avatar" :src="avatar.length > 0 ? avatar : DEFAULT_AVATAR" mode="aspectFill" @error="avatar = ''" /><text class="avatar-action">{{ choosing ? '读取中…' : '更换头像' }}</text></view>
|
||||
<view class="edit-card"><text class="field-title">昵称</text><input v-model="nickname" class="nickname-input" :disabled="saving" maxlength="20" placeholder="请输入昵称" /><text class="field-help">2–20 个字符,用于个人中心展示</text></view>
|
||||
<view class="phone-row" @click="openPhone"><view class="phone-copy"><text class="field-title">绑定手机号</text><text class="phone-value">{{ maskPhone(phone) }}</text></view><text class="change-label">更换</text><AppIcon name="chevron-right" :size="28" /></view>
|
||||
<text class="profile-note">昵称与融资档案中的实名信息独立,修改后不影响已填写的融资资料。</text>
|
||||
<text v-if="errorMessage.length > 0" class="error-text">{{ errorMessage }}</text>
|
||||
<button class="primary-button save-button" :disabled="saving || choosing" @click="save">{{ saving ? '保存中…' : '保存资料' }}</button>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="uts">
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import AppIcon from '@/components/AppIcon.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { DEFAULT_AVATAR, maskPhone, getAccountProfile, saveAccountProfile, readAvatar } from '@/packages/account/services/account.uts'
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
const nickname = ref(''); const avatar = ref(''); const phone = ref(''); const saving = ref(false); const choosing = ref(false); const errorMessage = ref('')
|
||||
let originalName = ''; let originalAvatar = ''; let alive = true
|
||||
onLoad(() => {
|
||||
if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }
|
||||
const profile = getAccountProfile(); nickname.value = profile.nickname; avatar.value = profile.avatar; phone.value = profile.phone
|
||||
originalName = profile.nickname; originalAvatar = profile.avatar
|
||||
})
|
||||
const dirty = (): boolean => nickname.value.trim() != originalName || avatar.value != originalAvatar
|
||||
const leave = () => { if (getCurrentPages().length > 1) uni.navigateBack(); else uni.redirectTo({ url: '/packages/account/profile/profile' }) }
|
||||
const back = () => {
|
||||
if (saving.value) return
|
||||
if (!dirty()) { leave(); return }
|
||||
uni.showModal({ title: '放弃修改?', content: '当前修改尚未保存。', confirmText: '放弃修改', success: (result) => { if (result.confirm) { originalName = nickname.value.trim(); originalAvatar = avatar.value; leave() } } })
|
||||
}
|
||||
onBackPress((event): boolean => { if (event.from == 'navigateBack') return false; if (saving.value || dirty()) { back(); return true }; return false })
|
||||
const loadAvatar = async (path: string) => {
|
||||
choosing.value = true; errorMessage.value = ''
|
||||
try { const image = await readAvatar(path); if (alive) avatar.value = image }
|
||||
catch (error) { if (alive) errorMessage.value = error instanceof Error ? error.message : '图片读取失败' }
|
||||
finally { if (alive) choosing.value = false }
|
||||
}
|
||||
const chooseAvatar = () => {
|
||||
if (saving.value || choosing.value) return
|
||||
uni.showActionSheet({ itemList: ['从相册选择', '恢复默认头像'], success: (action) => {
|
||||
if (action.tapIndex == 1) { avatar.value = ''; return }
|
||||
uni.chooseImage({ count: 1, sizeType: ['compressed'], sourceType: ['album'], success: (result) => {
|
||||
if (result.tempFilePaths.length == 0) return
|
||||
if (result.tempFiles[0].size > 512000) { errorMessage.value = '请选择 500KB 以内的图片'; return }
|
||||
loadAvatar(result.tempFilePaths[0])
|
||||
}, fail: (error) => { if (!error.errMsg.includes('cancel')) errorMessage.value = '无法打开相册,请检查权限后重试' } })
|
||||
} })
|
||||
}
|
||||
const openPhone = () => { if (!saving.value && !choosing.value) uni.navigateTo({ url: '/packages/account/change-phone/change-phone' }) }
|
||||
const save = async () => {
|
||||
if (saving.value || choosing.value) return
|
||||
errorMessage.value = ''; saving.value = true
|
||||
try {
|
||||
if (getAccountProfile().phone != phone.value) throw new Error('登录状态已变化,请重新进入页面')
|
||||
await saveAccountProfile(nickname.value, avatar.value)
|
||||
originalName = nickname.value.trim(); originalAvatar = avatar.value
|
||||
uni.showToast({ title: '资料已保存', icon: 'success' }); leave()
|
||||
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '保存失败,请稍后重试' }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
onUnload(() => { alive = false })
|
||||
</script>
|
||||
<style>
|
||||
.edit-content { padding: 28rpx 30rpx 40rpx; }
|
||||
.avatar-panel { align-items: center; padding: 20rpx 0 36rpx; }
|
||||
.edit-avatar { width: 140rpx; height: 140rpx; border-radius: 70rpx; background-color: #eaf2ff; }
|
||||
.avatar-action { color: #0868f7; font-size: 27rpx; margin-top: 20rpx; }
|
||||
.edit-card { padding: 26rpx; border-radius: 18rpx; background-color: #ffffff; }
|
||||
.field-title { color: #29394f; font-size: 28rpx; font-weight: 600; }
|
||||
.nickname-input { height: 86rpx; color: #23344e; font-size: 30rpx; border-bottom: 1rpx solid #edf1f6; }
|
||||
.field-help { color: #8793a6; font-size: 23rpx; margin-top: 16rpx; }
|
||||
.phone-row { flex-direction: row; align-items: center; padding: 26rpx; margin-top: 20rpx; border-radius: 18rpx; background-color: #ffffff; }
|
||||
.phone-copy { flex: 1; }
|
||||
.phone-value { color: #66758a; font-size: 26rpx; margin-top: 12rpx; }
|
||||
.change-label { color: #0868f7; font-size: 25rpx; margin-right: 8rpx; }
|
||||
.profile-note { color: #738198; font-size: 24rpx; line-height: 38rpx; margin: 24rpx 4rpx; }
|
||||
.error-text { color: #d64040; font-size: 25rpx; line-height: 38rpx; margin-bottom: 20rpx; }
|
||||
.save-button { margin: 12rpx 0 0; }
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
<view class="message-summary"><text class="summary-text">{{ unreadCount > 0 ? unreadCount + ' 条未读消息' : '消息已全部阅读' }}</text><view class="read-all" @click="markAllRead"><text class="read-all-text">全部已读</text></view></view>
|
||||
<view class="notice-list">
|
||||
<view class="notice" v-for="(item, index) in filteredNotices" :key="item.id" :class="index == filteredNotices.length - 1 ? 'last-notice' : ''" @click="openNotice(item)">
|
||||
<view class="notice-icon"><AppIcon :name="item.icon" :size="58" /><view v-if="item.unread" class="unread"></view></view>
|
||||
<view class="notice-icon" :class="'notice-icon-' + item.icon"><view class="notice-icon-surface"><image class="notice-symbol" :src="'/packages/account/static/notifications/' + item.icon + '.svg'" mode="aspectFit" /></view><view v-if="item.unread" class="unread"></view></view>
|
||||
<view class="notice-copy"><view class="notice-head"><text class="notice-title">{{ item.title }}</text><text class="notice-time">{{ item.time }}</text></view><text class="notice-desc">{{ item.desc }}</text><text class="notice-category">{{ item.category }}</text></view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -22,7 +22,6 @@
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import BottomNav from '@/components/BottomNav.uvue'
|
||||
import AppIcon from '@/components/AppIcon.uvue'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
const tabs: string[] = ['全部', '系统通知', '服务通知', '活动消息']
|
||||
const tab = ref('全部')
|
||||
@@ -67,15 +66,20 @@
|
||||
.read-all { padding: 14rpx 0 14rpx 24rpx; }
|
||||
.read-all-text { color: #0868f7; font-size: 23rpx; }
|
||||
.notice-list { background-color: #ffffff; border-radius: 18rpx; padding: 0 20rpx; }
|
||||
.notice { padding: 26rpx 0; border-bottom: 1rpx solid #e8eef6; flex-direction: row; align-items: flex-start; }
|
||||
.notice { padding: 26rpx 0; border-bottom: 1rpx solid #e8eef6; flex-direction: row; align-items: center; }
|
||||
.last-notice { border-bottom-width: 0; }
|
||||
.notice-icon { width: 70rpx; height: 70rpx; align-items: center; justify-content: center; margin-right: 18rpx; position: relative; flex-shrink: 0; }
|
||||
.notice-copy { flex: 1; }
|
||||
.notice-icon { width: 76rpx; height: 76rpx; margin-right: 20rpx; position: relative; flex-shrink: 0; }
|
||||
.notice-icon-surface { width: 100%; height: 100%; border-radius: 20rpx; background-color: #edf5ff; align-items: center; justify-content: center; }
|
||||
.notice-icon-payout .notice-icon-surface { background-color: #eaf7f3; }
|
||||
.notice-icon-gift .notice-icon-surface { background-color: #fff3e8; }
|
||||
.notice-symbol { width: 46rpx; height: 46rpx; flex-shrink: 0; }
|
||||
.notice-copy { flex: 1; min-width: 0; }
|
||||
.notice-head { flex-direction: row; justify-content: space-between; align-items: flex-start; }
|
||||
.notice-title { color: #172640; font-size: 28rpx; line-height: 40rpx; font-weight: 600; flex: 1; }
|
||||
.notice-time { color: #66758a; font-size: 22rpx; line-height: 40rpx; margin-left: 12rpx; flex-shrink: 0; }
|
||||
.notice-desc { color: #66758a; font-size: 25rpx; line-height: 38rpx; margin-top: 10rpx; }
|
||||
.notice-category { color: #66758a; font-size: 22rpx; line-height: 32rpx; margin-top: 10rpx; }
|
||||
.unread { position: absolute; width: 15rpx; height: 15rpx; border-radius: 8rpx; background-color: #f04452; border: 2rpx solid #ffffff; right: 0; top: 0; }
|
||||
/* 红点独立于圆角背景,避免跨端圆角裁切。 */
|
||||
.unread { position: absolute; width: 15rpx; height: 15rpx; border-radius: 8rpx; background-color: #f04452; border: 2rpx solid #ffffff; right: -3rpx; top: -3rpx; z-index: 1; }
|
||||
.message-footer { color: #66758a; font-size: 22rpx; text-align: center; margin-top: 28rpx; }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<view class="page" :style="pageStyle()"><AppHeader :title="mode == 'password' ? '修改密码' : '重设密码'" />
|
||||
<scroll-view scroll-y :style="contentStyle(96, false)"><view class="account-form-content" :style="bottomStyle(40)">
|
||||
<text class="form-title">{{ mode == 'password' ? '设置新的登录密码' : '验证手机号,重设密码' }}</text>
|
||||
<text class="form-description">{{ mode == 'password' ? '为保障账户安全,请先验证当前密码。' : '验证码将发送至绑定手机号 ' + maskPhone(phone) }}</text>
|
||||
<view class="form-card">
|
||||
<template v-if="mode == 'password'"><text class="form-label">当前密码</text><input v-model="oldPassword" class="account-input old-password" :disabled="submitting" :password="!visible" maxlength="64" placeholder="请输入当前密码" /></template>
|
||||
<template v-else><text class="form-label">短信验证码</text><SecurityCode v-model="code" :disabled="submitting" purpose="password" @error="codeError" @sent="errorMessage = ''" /></template>
|
||||
<text class="form-label">新密码</text><input v-model="newPassword" class="account-input new-password" :disabled="submitting" :password="!visible" maxlength="32" placeholder="8–32 位,包含字母和数字" />
|
||||
<text class="form-label">确认新密码</text><input v-model="confirmation" class="account-input confirm-password" :disabled="submitting" :password="!visible" maxlength="32" placeholder="请再次输入新密码" confirm-type="done" @confirm="submit" />
|
||||
<text class="visibility-control" @click="visible = !visible">{{ visible ? '隐藏密码' : '显示密码' }}</text>
|
||||
</view>
|
||||
<text class="form-hint">密码可包含英文符号,不支持空格。修改成功后请使用新密码重新登录。</text>
|
||||
<text v-if="errorMessage.length > 0" class="form-error">{{ errorMessage }}</text>
|
||||
<button class="primary-button form-submit" :disabled="submitting" @click="submit">{{ submitting ? '提交中…' : '确认修改' }}</button>
|
||||
<text class="form-link" @click="switchMode">{{ mode == 'password' ? '忘记当前密码?使用短信验证' : '使用当前密码验证' }}</text>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="uts">
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import SecurityCode from '@/packages/account/components/SecurityCode.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession, clearSession } from '@/services/api.uts'
|
||||
import { getAccountProfile, maskPhone, validNewPassword, validCode, changeAccountPassword } from '@/packages/account/services/account.uts'
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
const mode = ref('password'); const phone = ref(''); const oldPassword = ref(''); const newPassword = ref(''); const confirmation = ref(''); const code = ref(''); const visible = ref(false); const submitting = ref(false); const errorMessage = ref('')
|
||||
onLoad((options) => { if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }; phone.value = getAccountProfile().phone; if (options != null && options['mode'] == 'sms') mode.value = 'sms' })
|
||||
const clearSecrets = () => { oldPassword.value = ''; newPassword.value = ''; confirmation.value = ''; code.value = ''; visible.value = false }
|
||||
const switchMode = () => { if (submitting.value) return; clearSecrets(); errorMessage.value = ''; mode.value = mode.value == 'password' ? 'sms' : 'password' }
|
||||
const codeError = (message: string) => { errorMessage.value = message }
|
||||
const submit = async () => {
|
||||
if (submitting.value) return
|
||||
errorMessage.value = ''
|
||||
if (mode.value == 'password' && oldPassword.value.length == 0) { errorMessage.value = '请输入当前密码'; return }
|
||||
if (mode.value == 'sms' && !validCode(code.value)) { errorMessage.value = '请输入 6 位数字验证码'; return }
|
||||
if (!validNewPassword(newPassword.value)) { errorMessage.value = '新密码需为 8–32 位,包含字母和数字,可使用符号'; return }
|
||||
if (newPassword.value != confirmation.value) { errorMessage.value = '两次输入的新密码不一致'; return }
|
||||
submitting.value = true
|
||||
try {
|
||||
if (getAccountProfile().phone != phone.value) throw new Error('登录状态已变化,请重新进入页面')
|
||||
await changeAccountPassword(mode.value, oldPassword.value, code.value, newPassword.value)
|
||||
clearSecrets(); clearSession(); uni.reLaunch({ url: '/packages/account/login/login' }); uni.showToast({ title: '密码已修改,请重新登录', icon: 'none' })
|
||||
} catch (error) { errorMessage.value = error instanceof Error ? error.message : '修改失败,请稍后重试' }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
onHide(() => clearSecrets())
|
||||
onUnload(() => clearSecrets())
|
||||
</script>
|
||||
<style>
|
||||
@import '../styles/forms.css';
|
||||
.visibility-control { color: #0868f7; font-size: 24rpx; padding: 20rpx 0 4rpx; text-align: right; }
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
<AppHeader title="个人中心" :back="false" />
|
||||
<scroll-view scroll-y :style="contentStyle(226, false)">
|
||||
<view class="profile-content">
|
||||
<view class="profile-card" @click="openAccount"><view class="avatar"><AppIcon name="user" :size="80" /></view><view class="profile-main"><text class="profile-name">{{ displayName }}</text><text class="profile-phone">{{ loggedIn ? maskedPhone : '登录后管理您的融资信息' }}</text></view><text class="account-state">{{ loggedIn ? '已登录' : '未登录' }}</text></view>
|
||||
<view class="profile-card" @click="openAccount"><view class="avatar"><image class="profile-avatar" :src="avatar.length > 0 ? avatar : DEFAULT_AVATAR" mode="aspectFill" @error="avatar = ''" /></view><view class="profile-main"><text class="profile-name">{{ displayName }}</text><text class="profile-phone">{{ loggedIn ? maskedPhone : '登录后管理您的融资信息' }}</text></view><text v-if="!loggedIn" class="account-state">未登录</text><AppIcon v-if="loggedIn" name="chevron-right" :size="28" /></view>
|
||||
<view class="stat-row"><view class="stat-item" @click="go('/packages/finance/archive/archive')"><text class="stat-value">{{ completion }}%</text><text class="stat-label">档案完整度</text></view><view class="stat-item" @click="openReport"><text class="stat-value">{{ hasReport ? '已生成' : '暂无' }}</text><text class="stat-label">评估报告</text></view><view class="stat-item" @click="go('/packages/account/points/points')"><AppIcon name="points" :size="42" /><text class="stat-label">积分中心</text></view></view>
|
||||
<view class="menu-list"><view class="menu-row" v-for="item in menus" :key="item.name" @click="open(item.key)"><AppIcon :name="item.icon" :size="46" /><text class="menu-name">{{ item.name }}</text><text v-if="item.note.length > 0" class="menu-note">{{ item.note }}</text><AppIcon name="chevron-right" :size="28" /></view></view>
|
||||
<button class="logout" @click="logout">{{ loggedIn ? '退出登录' : '登录 / 注册' }}</button>
|
||||
@@ -20,23 +20,24 @@
|
||||
import BottomNav from '@/components/BottomNav.uvue'
|
||||
import AppIcon from '@/components/AppIcon.uvue'
|
||||
import { clearSession, getSession } from '@/services/api.uts'
|
||||
import { activeAssessmentType, loadAssessmentDraft, draftText, assessmentProgress, showAgreementNotice } from '@/services/assessment.uts'
|
||||
import { activeAssessmentType, assessmentProgress, showAgreementNotice } from '@/services/assessment.uts'
|
||||
import { DEFAULT_AVATAR, getAccountProfile, maskPhone } from '@/packages/account/services/account.uts'
|
||||
const { contentStyle, pageStyle } = usePageLayout()
|
||||
type ProfileMenu = { icon: string, name: string, note: string, key: string }
|
||||
const menus: ProfileMenu[] = [{ icon: 'archive', name: '我的融资档案', note: '', key: 'archive' }, { icon: 'shield', name: '账户安全', note: '', key: 'security' }, { icon: 'privacy', name: '隐私与授权', note: '', key: 'privacy' }, { icon: 'help', name: '帮助与反馈', note: '', key: 'help' }, { icon: 'info', name: '关于我们', note: 'V1.0.0', key: 'about' }]
|
||||
const loggedIn = ref(false)
|
||||
const displayName = ref('登录 / 注册')
|
||||
const maskedPhone = ref('')
|
||||
const avatar = ref('')
|
||||
const completion = ref(0)
|
||||
const hasReport = ref(false)
|
||||
onShow(() => {
|
||||
const session = getSession()
|
||||
loggedIn.value = session != null
|
||||
const phone = session != null ? draftText(session, 'phone') : ''
|
||||
maskedPhone.value = phone.length >= 7 ? phone.substring(0, 3) + '****' + phone.substring(phone.length - 4) : phone
|
||||
const type = activeAssessmentType(); const saved = loadAssessmentDraft(type)
|
||||
displayName.value = loggedIn.value ? '融资用户' : '登录 / 注册'
|
||||
if (loggedIn.value && saved != null && draftText(saved, 'phone') == phone && draftText(saved, 'name').length > 0) displayName.value = draftText(saved, 'name')
|
||||
const profile = getAccountProfile()
|
||||
maskedPhone.value = maskPhone(profile.phone); avatar.value = loggedIn.value ? profile.avatar : ''
|
||||
const type = activeAssessmentType()
|
||||
displayName.value = loggedIn.value ? profile.nickname : '登录 / 注册'
|
||||
completion.value = assessmentProgress(type).percent
|
||||
const latest = uni.getStorageSync('latestAssessment')
|
||||
hasReport.value = latest != null && typeof latest == 'object'
|
||||
@@ -45,12 +46,12 @@
|
||||
const openReport = () => { if (hasReport.value) go('/packages/finance/report/report'); else uni.showModal({ title: '暂无评估报告', content: '完善融资信息后,即可体验融资评估与方案匹配。', confirmText: '去完善', success: (result) => { if (result.confirm) go('/packages/finance/archive/archive') } }) }
|
||||
const open = (key: string) => {
|
||||
if (key == 'archive') go('/packages/finance/archive/archive')
|
||||
else if (key == 'help') go('/packages/finance/assistant/assistant')
|
||||
else if (key == 'security') uni.showModal({ title: '账户安全', content: loggedIn.value ? '当前账户:' + maskedPhone.value + '\n请妥善保管账户信息,不要向他人透露密码或验证码。' : '当前尚未登录,请先使用手机号登录。', showCancel: false, confirmText: '我知道了' })
|
||||
else if (key == 'help') go('/packages/content/help/help')
|
||||
else if (key == 'security') go(loggedIn.value ? '/packages/account/security/security' : '/packages/account/login/login')
|
||||
else if (key == 'privacy') uni.showActionSheet({ itemList: ['查看隐私说明', '管理资料与授权'], success: (result) => { if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { uni.setStorageSync('assessmentEditStep', 3); go('/packages/finance/assessment/assessment?type=' + activeAssessmentType()) } } })
|
||||
else if (key == 'about') uni.showModal({ title: '关于 AI融资', content: '版本 V1.0.0\n帮助您整理融资信息、了解融资知识与融资方案。评估结果仅供参考,不构成授信承诺。', showCancel: false, confirmText: '我知道了' })
|
||||
}
|
||||
const openAccount = () => { if (!loggedIn.value) go('/packages/account/login/login'); else open('security') }
|
||||
const openAccount = () => { go(loggedIn.value ? '/packages/account/edit-profile/edit-profile' : '/packages/account/login/login') }
|
||||
const logout = () => {
|
||||
if (!loggedIn.value) { go('/packages/account/login/login'); return }
|
||||
uni.showModal({ title: '退出登录', content: '退出后,本机保存的评估草稿仍会保留。', confirmText: '退出登录', success: (result) => { if (result.confirm) { clearSession(); uni.reLaunch({ url: '/packages/account/login/login' }) } } })
|
||||
@@ -61,6 +62,7 @@
|
||||
.profile-content { padding: 26rpx 30rpx 90rpx; }
|
||||
.profile-card { min-height: 166rpx; padding: 28rpx 24rpx; border-radius: 18rpx 18rpx 0 0; background-color: #fff; flex-direction: row; align-items: center; }
|
||||
.avatar { width: 84rpx; height: 84rpx; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
|
||||
.profile-avatar { width: 84rpx; height: 84rpx; border-radius: 42rpx; background-color: #eaf2ff; }
|
||||
.profile-main { flex: 1; min-width: 0; }
|
||||
.profile-name { color: #23344e; font-size: 32rpx; font-weight: 700; }
|
||||
.profile-phone { color: #8190a4; font-size: 24rpx; line-height: 36rpx; margin-top: 8rpx; }
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<view class="page" :style="pageStyle()"><AppHeader title="账户安全" />
|
||||
<scroll-view scroll-y :style="contentStyle(96, false)"><view class="security-content" :style="bottomStyle(40)">
|
||||
<view class="security-intro"><view class="shield-surface"><image class="shield-icon" src="/static/icons/shield.svg" mode="aspectFit" /></view><view class="intro-copy"><text class="intro-title">守护您的账户</text><text class="intro-desc">妥善保管密码与短信验证码</text></view></view>
|
||||
<view class="security-list">
|
||||
<view class="security-row" @click="go('password/password')"><view class="row-copy"><text class="row-title">修改登录密码</text><text class="row-desc">通过当前密码验证身份</text></view><AppIcon name="chevron-right" :size="28" /></view>
|
||||
<view class="security-row" @click="go('password/password?mode=sms')"><view class="row-copy"><text class="row-title">验证码重设密码</text><text class="row-desc">忘记密码或尚未设置密码时使用</text></view><AppIcon name="chevron-right" :size="28" /></view>
|
||||
<view class="security-row last-row" @click="go('change-phone/change-phone')"><view class="row-copy"><text class="row-title">绑定手机号</text><text class="row-desc">{{ maskPhone(phone) }}</text></view><text class="row-action">更换</text><AppIcon name="chevron-right" :size="28" /></view>
|
||||
</view>
|
||||
<view class="security-tips"><text class="tips-title">安全提醒</text><text class="tips-copy">请勿向他人提供密码或验证码。修改密码或换绑手机号成功后,需要重新登录。</text></view>
|
||||
</view></scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
<script setup lang="uts">
|
||||
import AppHeader from '@/components/AppHeader.uvue'
|
||||
import AppIcon from '@/components/AppIcon.uvue'
|
||||
import { usePageLayout } from '@/services/layout.uts'
|
||||
import { getSession } from '@/services/api.uts'
|
||||
import { getAccountProfile, maskPhone } from '@/packages/account/services/account.uts'
|
||||
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
|
||||
const phone = ref('')
|
||||
onShow(() => { if (getSession() == null) { uni.redirectTo({ url: '/packages/account/login/login' }); return }; phone.value = getAccountProfile().phone })
|
||||
const go = (path: string) => { uni.navigateTo({ url: '/packages/account/' + path }) }
|
||||
</script>
|
||||
<style>
|
||||
.security-content { padding: 30rpx; }
|
||||
.security-intro { padding: 26rpx; border-radius: 18rpx; background-color: #edf5ff; flex-direction: row; align-items: center; }
|
||||
.shield-surface { width: 80rpx; height: 80rpx; border-radius: 22rpx; background-color: #ffffff; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; }
|
||||
.shield-icon { width: 48rpx; height: 48rpx; }
|
||||
.intro-copy { flex: 1; min-width: 0; }
|
||||
.intro-title { font-size: 32rpx; font-weight: 700; color: #23344e; }
|
||||
.intro-desc { font-size: 24rpx; line-height: 36rpx; color: #738198; margin-top: 10rpx; }
|
||||
.security-list { padding: 0 26rpx; background-color: #ffffff; border-radius: 18rpx; margin-top: 24rpx; }
|
||||
.security-row { flex-direction: row; align-items: center; min-height: 134rpx; padding: 24rpx 0; border-bottom: 1rpx solid #edf1f6; }
|
||||
.last-row { border-bottom-width: 0; }
|
||||
.row-copy { flex: 1; min-width: 0; }
|
||||
.row-title { font-size: 29rpx; color: #29394f; font-weight: 600; }
|
||||
.row-desc { font-size: 24rpx; line-height: 36rpx; color: #738198; margin-top: 12rpx; }
|
||||
.row-action { font-size: 25rpx; color: #0868f7; margin-right: 8rpx; }
|
||||
.security-tips { padding: 28rpx 8rpx; }
|
||||
.tips-title { font-size: 25rpx; font-weight: 600; color: #66758a; }
|
||||
.tips-copy { font-size: 24rpx; line-height: 40rpx; color: #8793a6; margin-top: 12rpx; }
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
import { API_BASE_URL, getSession } from '@/services/api.uts'
|
||||
import { activeAssessmentType, loadAssessmentDraft, draftText } from '@/services/assessment.uts'
|
||||
|
||||
export type AccountProfile = { nickname: string, avatar: string, phone: string }
|
||||
export const DEFAULT_AVATAR = '/packages/account/static/default-avatar.png'
|
||||
const profileKey = (phone: string): string => 'finance.account.profile:' + phone
|
||||
export const maskPhone = (phone: string): string => phone.length == 11 ? phone.substring(0, 3) + '****' + phone.substring(7) : phone
|
||||
export const validPhone = (phone: string): boolean => /^1[3-9][0-9]{9}$/.test(phone)
|
||||
export const validCode = (code: string): boolean => /^[0-9]{6}$/.test(code)
|
||||
export const validNewPassword = (password: string): boolean => /^[!-~]{8,32}$/.test(password) && /[A-Za-z]/.test(password) && /[0-9]/.test(password)
|
||||
|
||||
export function requireAccount(): UTSJSONObject {
|
||||
const session = getSession()
|
||||
if (session == null || draftText(session, 'token').length == 0 || !validPhone(draftText(session, 'phone'))) throw new Error('请先登录后再操作')
|
||||
return session
|
||||
}
|
||||
export function sameAccount(session: UTSJSONObject): boolean {
|
||||
const current = getSession()
|
||||
return current != null && draftText(current, 'token') == draftText(session, 'token') && draftText(current, 'phone') == draftText(session, 'phone')
|
||||
}
|
||||
export function getAccountProfile(): AccountProfile {
|
||||
const session = getSession()
|
||||
const phone = session == null ? '' : draftText(session, 'phone')
|
||||
let nickname = '融资用户'; let avatar = ''
|
||||
if (phone.length > 0) {
|
||||
const saved = uni.getStorageSync(profileKey(phone))
|
||||
if (saved != null && typeof saved == 'object') {
|
||||
nickname = draftText(saved as UTSJSONObject, 'nickname'); avatar = draftText(saved as UTSJSONObject, 'avatar')
|
||||
} else {
|
||||
const draft = loadAssessmentDraft(activeAssessmentType())
|
||||
if (draft != null && draftText(draft, 'phone') == phone && draftText(draft, 'name').length > 0) nickname = draftText(draft, 'name')
|
||||
}
|
||||
}
|
||||
return { phone, nickname: nickname.length > 0 ? nickname : '融资用户', avatar }
|
||||
}
|
||||
|
||||
// 安全接口必须经过服务端认证。未配置服务时不模拟验证或修改成功。
|
||||
function accountRequest(path: string, method: RequestMethod, payload: UTSJSONObject): Promise<UTSJSONObject> {
|
||||
return new Promise<UTSJSONObject>((resolve, reject) => {
|
||||
const session = requireAccount()
|
||||
if (API_BASE_URL.length == 0) { reject(new Error('账户服务暂未开放,请稍后再试')); return }
|
||||
uni.request({ url: API_BASE_URL + path, method, data: payload, header: { Authorization: 'Bearer ' + draftText(session, 'token') }, timeout: 10000,
|
||||
success: (response) => {
|
||||
if (!sameAccount(session)) { reject(new Error('登录状态已变化,请重新进入页面')); return }
|
||||
if (response.statusCode == 401) { reject(new Error('登录已失效,请重新登录')); return }
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) { reject(new Error('操作失败,请核对信息后重试')); return }
|
||||
resolve(response.data == null ? new UTSJSONObject() : response.data as UTSJSONObject)
|
||||
}, fail: () => reject(new Error('网络连接失败,请稍后重试'))
|
||||
})
|
||||
})
|
||||
}
|
||||
export async function saveAccountProfile(nickname: string, avatar: string): Promise<void> {
|
||||
const session = requireAccount(); const phone = draftText(session, 'phone')
|
||||
const name = nickname.trim()
|
||||
if (name.length < 2 || name.length > 20) throw new Error('昵称请输入 2–20 个字符')
|
||||
if (API_BASE_URL.length > 0) await accountRequest('/account/profile', 'PUT', { nickname: name, avatar })
|
||||
if (!sameAccount(session)) throw new Error('登录状态已变化,请重新进入页面')
|
||||
uni.setStorageSync(profileKey(phone), { nickname: name, avatar })
|
||||
}
|
||||
export async function requestSecurityCode(purpose: string, newPhone: string): Promise<void> {
|
||||
if (!['password', 'phone-current', 'phone-new'].includes(purpose)) throw new Error('验证码用途无效')
|
||||
if (purpose == 'phone-new' && !validPhone(newPhone)) throw new Error('请输入正确的新手机号')
|
||||
await accountRequest('/account/security/sms-code', 'POST', { purpose, newPhone: purpose == 'phone-new' ? newPhone : '' })
|
||||
}
|
||||
export async function changeAccountPassword(mode: string, oldPassword: string, code: string, newPassword: string): Promise<void> {
|
||||
if (!validNewPassword(newPassword)) throw new Error('新密码需为 8–32 位,包含字母和数字,可使用符号')
|
||||
if (mode == 'password') {
|
||||
if (oldPassword.length == 0) throw new Error('请输入当前密码')
|
||||
if (oldPassword == newPassword) throw new Error('新密码不能与当前密码相同')
|
||||
} else if (mode != 'sms' || !validCode(code)) throw new Error('请输入 6 位数字验证码')
|
||||
await accountRequest('/account/password', 'PUT', { verification: mode, oldPassword: mode == 'password' ? oldPassword : '', code: mode == 'sms' ? code : '', newPassword })
|
||||
}
|
||||
export async function changeAccountPhone(newPhone: string, currentCode: string, newCode: string): Promise<void> {
|
||||
const session = requireAccount()
|
||||
if (!validPhone(newPhone)) throw new Error('请输入正确的新手机号')
|
||||
if (newPhone == draftText(session, 'phone')) throw new Error('新手机号不能与当前手机号相同')
|
||||
if (!validCode(currentCode) || !validCode(newCode)) throw new Error('请输入两组 6 位数字验证码')
|
||||
await accountRequest('/account/phone', 'PUT', { newPhone, currentCode, newCode })
|
||||
}
|
||||
|
||||
// 限制头像体积,避免把临时路径或过大的图片写入账户缓存。
|
||||
export function readAvatar(path: string): Promise<string> {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
// #ifdef WEB
|
||||
fetch(path).then((response) => response.blob()).then((blob) => {
|
||||
if (blob.size > 512000 || !['image/jpeg', 'image/png', 'image/webp'].includes(blob.type)) { reject(new Error('请选择 500KB 以内的 JPG、PNG 或 WebP 图片')); return }
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(new Error('图片读取失败,请重新选择'))
|
||||
reader.readAsDataURL(blob)
|
||||
}).catch(() => reject(new Error('图片读取失败,请重新选择')))
|
||||
// #endif
|
||||
// #ifndef WEB
|
||||
uni.getFileSystemManager().readFile({ filePath: path, encoding: 'base64', success: (result) => {
|
||||
const data = result.data as string
|
||||
if (data.length > 683000) { reject(new Error('请选择 500KB 以内的图片')); return }
|
||||
const mime = data.startsWith('/9j/') ? 'image/jpeg' : (data.startsWith('iVBOR') ? 'image/png' : (data.startsWith('UklGR') ? 'image/webp' : ''))
|
||||
if (mime.length == 0) { reject(new Error('请选择 JPG、PNG 或 WebP 图片')); return }
|
||||
resolve('data:' + mime + ';base64,' + data)
|
||||
}, fail: () => reject(new Error('图片读取失败,请重新选择')) })
|
||||
// #endif
|
||||
})
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#0868f7" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="3" width="14" height="18" rx="2"/><path d="M9 7h6M9 11h6m-6 5 2 2 4-4"/></svg>
|
||||
|
After Width: | Height: | Size: 249 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ed861c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="8" width="18" height="4" rx="1"/><path d="M5 12v9h14v-9M12 8v13m0-13H8a2.5 2.5 0 1 1 2.5-2.5L12 8Zm0 0h4a2.5 2.5 0 1 0-2.5-2.5L12 8Z"/></svg>
|
||||
|
After Width: | Height: | Size: 312 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#13957c" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="16" rx="2"/><path d="M7 3v4m10-4v4M3 10h18m-13 5 3 3 5-5"/></svg>
|
||||
|
After Width: | Height: | Size: 258 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#0868f7" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3 8 3v5c0 5-3.3 8.4-8 10-4.7-1.6-8-5-8-10V6Z"/><path d="m8.5 12 2.5 2.5 4.5-5"/></svg>
|
||||
|
After Width: | Height: | Size: 255 B |
@@ -0,0 +1,10 @@
|
||||
.account-form-content { padding: 30rpx; }
|
||||
.form-title { font-size: 36rpx; line-height: 52rpx; font-weight: 700; color: #23344e; }
|
||||
.form-description { font-size: 25rpx; line-height: 40rpx; color: #738198; margin-top: 14rpx; }
|
||||
.form-card { padding: 8rpx 26rpx 22rpx; margin-top: 28rpx; border-radius: 18rpx; background-color: #ffffff; }
|
||||
.form-label { font-size: 26rpx; font-weight: 600; color: #405675; margin-top: 22rpx; }
|
||||
.account-input { height: 96rpx; border-bottom: 1rpx solid #edf1f6; font-size: 28rpx; color: #23344e; }
|
||||
.form-hint { font-size: 24rpx; line-height: 38rpx; color: #8793a6; margin-top: 22rpx; }
|
||||
.form-error { font-size: 25rpx; line-height: 38rpx; color: #d64040; margin-top: 22rpx; }
|
||||
.form-submit { margin: 30rpx 0 0; }
|
||||
.form-link { font-size: 26rpx; line-height: 40rpx; padding: 20rpx 0; color: #0868f7; text-align: center; }
|
||||
Reference in New Issue
Block a user