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:
hujun
2026-09-21 15:04:59 +08:00
parent 046a03e5e9
commit 4a69357606
28 changed files with 846 additions and 116 deletions
+19 -4
View File
@@ -1,6 +1,6 @@
# AI 融资移动端 # AI 融资移动端
基于 uni-app x、Vue 3、UTS 的移动端原型,包含 19 个页面。使用 HBuilderX(VDOM 模式)开发,已验证 5.24 和 5.26 的本地编译;本项目没有独立的 npm 构建入口。 基于 uni-app x、Vue 3、UTS 的移动端原型,包含 25 个页面。使用 HBuilderX(VDOM 模式)开发,已验证 5.24 和 5.26 的本地编译;本项目没有独立的 npm 构建入口。
## 本地运行 ## 本地运行
@@ -33,7 +33,7 @@ $projectPath = (Get-Location).Path
- 页面尺寸和安全区计算集中在 `services/layout.uts`。新增页面应复用这里的逻辑,并检查小屏、横屏、键盘弹出及底部安全区。 - 页面尺寸和安全区计算集中在 `services/layout.uts`。新增页面应复用这里的逻辑,并检查小屏、横屏、键盘弹出及底部安全区。
- 微信小程序首页在右侧预留胶囊区域与 8px 间距;公共页面标题采用左右对称的安全留白,始终按屏幕中心定位。获取胶囊失败时按 104px 避让。产品分享入口在小程序端放在内容区,App 和 Web 仍保留在标题栏右侧。导航不新增顶部空行,顶部按状态栏和安全区计算。 - 微信小程序首页在右侧预留胶囊区域与 8px 间距;公共页面标题采用左右对称的安全留白,始终按屏幕中心定位。获取胶囊失败时按 104px 避让。产品分享入口在小程序端放在内容区,App 和 Web 仍保留在标题栏右侧。导航不新增顶部空行,顶部按状态栏和安全区计算。
- 首页右上角只保留消息通知;微信端通知按钮与胶囊等高、顶部对齐,左侧 Logo 对齐胶囊的垂直中心;其他端仍在导航栏内居中。AI 助手保留首页横幅及快捷入口。 - 首页右上角依次为搜索、消息通知;微信端两个按钮与胶囊等高、顶部对齐,按钮组向左展开,左侧 Logo 对齐胶囊的垂直中心;其他端仍在导航栏内居中。AI 助手保留首页横幅及快捷入口。
- 原生样式遵循 uni-app x 支持范围。仅 Web/小程序支持的样式用条件编译隔离;修改 UTS 类型后应重新验证 Android 编译。 - 原生样式遵循 uni-app x 支持范围。仅 Web/小程序支持的样式用条件编译隔离;修改 UTS 类型后应重新验证 Android 编译。
- 首页及跨包共用的轻量位图位于 `static/mobile/`,专属大图位于各分包的 `static/`,原始图片保留在 `static/images/` 与 `static/icons-generated/`。箭头继续使用 `static/icons/` 下的 SVG。 - 首页及跨包共用的轻量位图位于 `static/mobile/`,专属大图位于各分包的 `static/`,原始图片保留在 `static/images/` 与 `static/icons-generated/`。箭头继续使用 `static/icons/` 下的 SVG。
- 底栏使用 `static/mobile/navigation/` 的专用位图,由现有高清图标按可见边界裁切、等高居中得到。更换素材时保持一致的图案高度和画布留白,避免仅统一图片元素尺寸却出现视觉错位。 - 底栏使用 `static/mobile/navigation/` 的专用位图,由现有高清图标按可见边界裁切、等高居中得到。更换素材时保持一致的图案高度和画布留白,避免仅统一图片元素尺寸却出现视觉错位。
@@ -44,18 +44,33 @@ $projectPath = (Get-Location).Path
个人与企业评估分别保存本机草稿;选图仅供本机预览,资料上传和数据查询尚未接入。课程章节进度、消息已读与体验签到均保存在当前设备,不代表服务器账户数据。 个人与企业评估分别保存本机草稿;选图仅供本机预览,资料上传和数据查询尚未接入。课程章节进度、消息已读与体验签到均保存在当前设备,不代表服务器账户数据。
个人中心顶部的头像、昵称、手机号区域统一进入修改资料;账户安全独立提供修改密码、验证码重设密码及换绑手机号。昵称和头像按当前手机号隔离保存在本机,头像使用 500KB 以内的 JPG / PNG / WebP 数据,不保存会失效的临时路径,不修改融资档案的实名信息。真实账户认证尚未接入,修改密码和换绑手机号不会在本机模拟成功,也不保存密码、验证码或其散列。安全接口成功后清除当前会话并要求重新登录;验证码计时仅在服务端发送成功后启动。
账户接口集中在 `packages/account/services/account.uts`,请求携带当前会话的 `Authorization: Bearer <token>`;后端需根据 token 确定账户,校验验证码用途与有效期、限制发送频率并完成旧会话撤销。预留:
- `PUT /account/profile`:`nickname`、`avatar`(图片 data URL 或空字符串);昵称 2–20 字符。当前本地缓存用于个人中心回显,后续应接入服务端资料读取和持久头像存储。
- `POST /account/security/sms-code`:`purpose`(`password` / `phone-current` / `phone-new`)与 `newPhone`(仅新号码验证传入);当前号码从认证账户读取。
- `PUT /account/password`:`verification`(`password` / `sms`)、`oldPassword` 或 `code`、`newPassword`。新密码为 8–32 位可打印 ASCII,需含字母和数字,不允许空格。
- `PUT /account/phone`:`newPhone`、`currentCode`、`newCode`;后端验证两组验证码及新号码占用情况。
资产与负债包含 `creditLoanCount`(信用贷款笔数)、`totalDebtBalance`(欠款总余额)、`monthlyLoanPayment`(贷款月还款金额)、`creditCardCount`(银行信用卡张数)、`creditCardTotalLimit`(总额度)、`creditCardUsedLimit`(已使用总额度)、`house` / `car`(有 / 无)。金额以元计,最多两位小数,数量为非负整数;保持原来的可跳过规则,填满八项后计为档案已完善。旧草稿的房车选项自动映射,新数字字段留空,不从旧存款区间或信用卡状态猜测金额;旧字段保留在草稿中以免丢失历史输入。
新闻与课程的数据定义位于 `packages/content/services/content.uts`,产品定义位于 `packages/finance/services/finance.uts`。产品列表与详情共用 ID;预留 `GET /matches`(`items` 数组)及 `GET /products/:id`(单个产品),字段见 `FinanceProduct`,其中 `amount` 为万元数值、`rate` 为年化百分比数值。申请入口目前仅引导准备资料,不向金融机构提交申请。 新闻与课程的数据定义位于 `packages/content/services/content.uts`,产品定义位于 `packages/finance/services/finance.uts`。产品列表与详情共用 ID;预留 `GET /matches`(`items` 数组)及 `GET /products/:id`(单个产品),字段见 `FinanceProduct`,其中 `amount` 为万元数值、`rate` 为年化百分比数值。申请入口目前仅引导准备资料,不向金融机构提交申请。
首页搜索进入内容分包的 `search/search`,支持全部、新闻、融资课堂筛选及对应详情跳转。搜索服务位于 `packages/content/services/search.uts`,当前匹配本地标题、简介、分类和正文;空格分隔的多个关键词需同时命中。预留 `GET /content/search`,参数为 `keyword` 与 `type`(`all` / `news` / `lesson`),返回 `{ items: ContentSearchResult[] }`,每项包含 `id`、`type`、`title`、`summary`、`category`、`meta`、`image`。后端接入时需同步接入新闻和课程详情的数据源,确保返回的 ID 可查询详情。
## 页面与分包 ## 页面与分包
“帮助与反馈”从个人中心进入内容分包的 `help/help`,按账户使用、融资评估、资料与隐私分组展示可展开的问答,不跳转 AI 助手。数据与客服接入点位于 `packages/content/services/help.uts`;预留 `GET /help/faqs` 返回 `{ items: HelpQuestion[] }`。联系客服当前只展示暂未开放提示,不发起客服会话或提交反馈。
`pages/` 只保留 `home/home.uvue`,也是唯一主包页面。页面注册统一维护在 `pages.json` 的 `pages` 和 `subPackages`,分包内的 `path` 相对于 `root`。 `pages/` 只保留 `home/home.uvue`,也是唯一主包页面。页面注册统一维护在 `pages.json` 的 `pages` 和 `subPackages`,分包内的 `path` 相对于 `root`。
| 目录 | 内容 | 页面数 | | 目录 | 内容 | 页面数 |
| --- | --- | --- | | --- | --- | --- |
| `pages/home` | 首页 | 1 | | `pages/home` | 首页 | 1 |
| `packages/finance` | 融资入口、评估、确认、分析、报告、匹配、产品、助手、档案 | 9 | | `packages/finance` | 融资入口、评估、确认、分析、报告、匹配、产品、助手、档案 | 9 |
| `packages/account` | 启动引导、登录、个人中心、积分、消息 | 5 | | `packages/account` | 启动引导、登录、个人中心、修改资料、账户安全、修改密码、更换手机号、积分、消息 | 9 |
| `packages/content` | 新闻、文章、融资课堂、课程详情 | 4 | | `packages/content` | 搜索、帮助与反馈、新闻、文章、融资课堂、课程详情 | 6 |
当前各模块体积较小,优先保持完整业务链;普通内容统一一个包。新增功能先放入所属分包,再根据微信编译产物大小决定是否细分。单包以 2 MiB 为保守预算,需预留增长空间,最终体积以微信开发者工具为准。 当前各模块体积较小,优先保持完整业务链;普通内容统一一个包。新增功能先放入所属分包,再根据微信编译产物大小决定是否细分。单包以 2 MiB 为保守预算,需预留增长空间,最终体积以微信开发者工具为准。
+14 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "ai融资", "name": "ai融资",
"appid" : "__UNI__F40DD82", "appid": "__UNI__204876E",
"description": "", "description": "",
"versionName": "1.0.0", "versionName": "1.0.0",
"versionCode": "100", "versionCode": "100",
@@ -11,12 +11,20 @@
"quickapp": {}, "quickapp": {},
/* 小程序特有相关 */ /* 小程序特有相关 */
"mp-weixin": { "mp-weixin": {
"appid" : "", "appid": "wx8e92cbd69e97d2b8",
"packOptions": { "packOptions": {
"ignore" : [ "ignore": [{
{ "type": "folder", "value": "static/images" }, "type": "folder",
{ "type": "folder", "value": "static/icons-generated" }, "value": "static/images"
{ "type": "file", "value": "static/logo.png" } },
{
"type": "folder",
"value": "static/icons-generated"
},
{
"type": "file",
"value": "static/logo.png"
}
] ]
}, },
"setting": { "setting": {
@@ -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>
+10 -6
View File
@@ -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="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-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" 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 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>
</view> </view>
@@ -22,7 +22,6 @@
import { usePageLayout } from '@/services/layout.uts' import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue' import AppHeader from '@/components/AppHeader.uvue'
import BottomNav from '@/components/BottomNav.uvue' import BottomNav from '@/components/BottomNav.uvue'
import AppIcon from '@/components/AppIcon.uvue'
const { contentStyle, pageStyle } = usePageLayout() const { contentStyle, pageStyle } = usePageLayout()
const tabs: string[] = ['全部', '系统通知', '服务通知', '活动消息'] const tabs: string[] = ['全部', '系统通知', '服务通知', '活动消息']
const tab = ref('全部') const tab = ref('全部')
@@ -67,15 +66,20 @@
.read-all { padding: 14rpx 0 14rpx 24rpx; } .read-all { padding: 14rpx 0 14rpx 24rpx; }
.read-all-text { color: #0868f7; font-size: 23rpx; } .read-all-text { color: #0868f7; font-size: 23rpx; }
.notice-list { background-color: #ffffff; border-radius: 18rpx; padding: 0 20rpx; } .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; } .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-icon { width: 76rpx; height: 76rpx; margin-right: 20rpx; position: relative; flex-shrink: 0; }
.notice-copy { flex: 1; } .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-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-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-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-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; } .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; } .message-footer { color: #66758a; font-size: 22rpx; text-align: center; margin-top: 28rpx; }
</style> </style>
+53
View File
@@ -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>
+12 -10
View File
@@ -3,7 +3,7 @@
<AppHeader title="个人中心" :back="false" /> <AppHeader title="个人中心" :back="false" />
<scroll-view scroll-y :style="contentStyle(226, false)"> <scroll-view scroll-y :style="contentStyle(226, false)">
<view class="profile-content"> <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="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> <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> <button class="logout" @click="logout">{{ loggedIn ? '退出登录' : '登录 / 注册' }}</button>
@@ -20,23 +20,24 @@
import BottomNav from '@/components/BottomNav.uvue' import BottomNav from '@/components/BottomNav.uvue'
import AppIcon from '@/components/AppIcon.uvue' import AppIcon from '@/components/AppIcon.uvue'
import { clearSession, getSession } from '@/services/api.uts' 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() const { contentStyle, pageStyle } = usePageLayout()
type ProfileMenu = { icon: string, name: string, note: string, key: string } 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 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 loggedIn = ref(false)
const displayName = ref('登录 / 注册') const displayName = ref('登录 / 注册')
const maskedPhone = ref('') const maskedPhone = ref('')
const avatar = ref('')
const completion = ref(0) const completion = ref(0)
const hasReport = ref(false) const hasReport = ref(false)
onShow(() => { onShow(() => {
const session = getSession() const session = getSession()
loggedIn.value = session != null loggedIn.value = session != null
const phone = session != null ? draftText(session, 'phone') : '' const profile = getAccountProfile()
maskedPhone.value = phone.length >= 7 ? phone.substring(0, 3) + '****' + phone.substring(phone.length - 4) : phone maskedPhone.value = maskPhone(profile.phone); avatar.value = loggedIn.value ? profile.avatar : ''
const type = activeAssessmentType(); const saved = loadAssessmentDraft(type) const type = activeAssessmentType()
displayName.value = loggedIn.value ? '融资用户' : '登录 / 注册' displayName.value = loggedIn.value ? profile.nickname : '登录 / 注册'
if (loggedIn.value && saved != null && draftText(saved, 'phone') == phone && draftText(saved, 'name').length > 0) displayName.value = draftText(saved, 'name')
completion.value = assessmentProgress(type).percent completion.value = assessmentProgress(type).percent
const latest = uni.getStorageSync('latestAssessment') const latest = uni.getStorageSync('latestAssessment')
hasReport.value = latest != null && typeof latest == 'object' 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 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) => { const open = (key: string) => {
if (key == 'archive') go('/packages/finance/archive/archive') if (key == 'archive') go('/packages/finance/archive/archive')
else if (key == 'help') go('/packages/finance/assistant/assistant') else if (key == 'help') go('/packages/content/help/help')
else if (key == 'security') uni.showModal({ title: '账户安全', content: loggedIn.value ? '当前账户:' + maskedPhone.value + '\n请妥善保管账户信息,不要向他人透露密码或验证码。' : '当前尚未登录,请先使用手机号登录。', showCancel: false, confirmText: '我知道了' }) 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 == '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: '我知道了' }) 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 = () => { const logout = () => {
if (!loggedIn.value) { go('/packages/account/login/login'); return } 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' }) } } }) 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-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; } .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; } .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-main { flex: 1; min-width: 0; }
.profile-name { color: #23344e; font-size: 32rpx; font-weight: 700; } .profile-name { color: #23344e; font-size: 32rpx; font-weight: 700; }
.profile-phone { color: #8190a4; font-size: 24rpx; line-height: 36rpx; margin-top: 8rpx; } .profile-phone { color: #8190a4; font-size: 24rpx; line-height: 36rpx; margin-top: 8rpx; }
+43
View File
@@ -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>
+103
View File
@@ -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

+10
View File
@@ -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; }
+80
View File
@@ -0,0 +1,80 @@
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="帮助与反馈" />
<view class="help-body" :style="contentStyle(96, false)">
<scroll-view scroll-y class="help-scroll">
<view class="help-content">
<text class="help-title">常见问题</text><text class="help-description">关于账户和融资使用,您可能想了解这些</text>
<view v-if="loading" class="help-state"><text class="state-text">正在加载…</text></view>
<view v-else-if="errorMessage.length > 0" class="help-state"><text class="state-text">{{ errorMessage }}</text><button class="retry-button" @click="load">重新加载</button></view>
<view v-else-if="questions.length == 0" class="help-state"><text class="state-text">常见问题正在整理中</text></view>
<template v-else>
<view class="faq-group" v-for="group in groups" :key="group.category">
<text class="group-title">{{ group.category }}</text>
<view class="faq-list"><view v-for="(item, index) in group.items" :key="item.id" :class="index == group.items.length - 1 ? 'faq-item last-item' : 'faq-item'">
<view class="question-row" @click="toggle(item.id)"><text class="question-text">{{ item.question }}</text><AppIcon name="chevron-right" :size="28" :class="expanded == item.id ? 'question-arrow expanded-arrow' : 'question-arrow'" /></view>
<view v-if="expanded == item.id" class="answer-panel"><text class="answer-text">{{ item.answer }}</text></view>
</view></view>
</view>
</template>
</view>
</scroll-view>
<view class="contact-bar" :style="bottomStyle(20)"><button class="primary-button contact-button" @click="contact">联系客服</button></view>
</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 { helpCategories, HelpQuestion, fetchHelpQuestions, contactCustomerService } from '@/packages/content/services/help.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
type HelpGroup = { category: string, items: HelpQuestion[] }
const questions = ref<HelpQuestion[]>([])
const expanded = ref('')
const loading = ref(false)
const errorMessage = ref('')
let alive = true
const groups = computed((): HelpGroup[] => {
const results: HelpGroup[] = []
helpCategories.forEach((category: string) => {
const items = questions.value.filter((item: HelpQuestion): boolean => item.category == category)
if (items.length > 0) { const group: HelpGroup = { category, items }; results.push(group) }
})
return results
})
const toggle = (id: string) => { expanded.value = expanded.value == id ? '' : id }
const contact = () => { contactCustomerService() }
const load = async () => {
if (loading.value) return
loading.value = true; errorMessage.value = ''
try { const items = await fetchHelpQuestions(); if (alive) questions.value = items }
catch (_error) { if (alive) errorMessage.value = '暂时无法加载帮助内容,请稍后重试' }
finally { if (alive) loading.value = false }
}
onLoad(() => { load() })
onUnload(() => { alive = false })
</script>
<style>
.help-body { overflow: hidden; }
.help-scroll { flex: 1; min-height: 0; }
.help-content { padding: 32rpx 30rpx 40rpx; }
.help-title { color: #23344e; font-size: 38rpx; line-height: 54rpx; font-weight: 700; }
.help-description { color: #738198; font-size: 25rpx; line-height: 40rpx; margin-top: 12rpx; }
.faq-group { margin-top: 30rpx; }
.group-title { color: #66758a; font-size: 25rpx; line-height: 38rpx; font-weight: 600; margin-bottom: 16rpx; }
.faq-list { padding: 0 24rpx; border-radius: 18rpx; background-color: #ffffff; }
.faq-item { border-bottom: 1rpx solid #edf1f6; }
.last-item { border-bottom-width: 0; }
.question-row { min-height: 102rpx; padding: 26rpx 0; flex-direction: row; align-items: center; }
.question-text { flex: 1; min-width: 0; color: #29394f; font-size: 28rpx; line-height: 42rpx; font-weight: 600; }
.question-arrow { margin-left: 18rpx; flex-shrink: 0; }
.expanded-arrow { transform: rotate(90deg); }
.answer-panel { padding: 0 0 26rpx; }
.answer-text { color: #66758a; font-size: 25rpx; line-height: 44rpx; }
.help-state { padding: 90rpx 20rpx; align-items: center; }
.state-text { font-size: 26rpx; line-height: 42rpx; color: #738198; text-align: center; }
.retry-button { color: #0868f7; background-color: #e8f2ff; font-size: 26rpx; margin-top: 26rpx; padding: 0 30rpx; height: 76rpx; line-height: 76rpx; border-radius: 12rpx; }
.contact-bar { flex-shrink: 0; padding: 20rpx 30rpx; border-top: 1rpx solid #e8eef6; background-color: #ffffff; }
.contact-button { margin: 0; }
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<view class="page" :style="pageStyle()">
<AppHeader title="搜索" />
<view class="search-bar">
<view class="search-box">
<image class="search-icon" src="/static/icons/search.svg" mode="aspectFit" />
<input v-model="keyword" class="search-input" placeholder="搜索新闻、融资课堂" maxlength="80" confirm-type="search" @input="resetResults" @confirm="search" />
<view v-if="keyword.length > 0" class="clear-search" @click="clearSearch"><text class="clear-mark">×</text></view>
</view>
<button class="search-button" @click="search">搜索</button>
</view>
<view class="search-tabs">
<view v-for="item in tabs" :key="item.id" :class="activeType == item.id ? 'search-tab selected-tab' : 'search-tab'" @click="selectType(item.id)"><text :class="activeType == item.id ? 'tab-label selected-label' : 'tab-label'">{{ item.label }}</text></view>
</view>
<scroll-view scroll-y :key="listVersion" :style="contentStyle(296, false)">
<view class="search-content" :style="bottomStyle(40)">
<view v-if="!searched" class="search-guide">
<text class="guide-title">想了解什么?</text><text class="guide-copy">查找融资资讯,学习实用知识</text>
<view class="suggestion-list"><view class="suggestion" v-for="word in suggestions" :key="word" @click="searchWord(word)"><text class="suggestion-label">{{ word }}</text></view></view>
</view>
<view v-else-if="loading" class="state-panel"><text class="state-title">正在搜索…</text></view>
<view v-else-if="errorMessage.length > 0" class="state-panel"><text class="state-title">搜索暂时不可用</text><text class="state-copy">{{ errorMessage }}</text><button class="retry-button" @click="search">重新搜索</button></view>
<view v-else-if="results.length == 0" class="state-panel"><view class="empty-symbol"><image class="empty-icon" src="/static/icons/search.svg" mode="aspectFit" /></view><text class="state-title">没有找到相关内容</text><text class="state-copy">试试更简短的关键词,或切换搜索分类</text></view>
<view v-else>
<text class="result-count">找到 {{ results.length }} 条相关内容</text>
<view class="result-card" v-for="item in results" :key="item.type + ':' + item.id" @click="openResult(item)">
<view class="result-copy"><view class="result-heading"><text :class="item.type == 'news' ? 'result-kind' : 'result-kind lesson-kind'">{{ item.type == 'news' ? '新闻' : '融资课堂' }}</text><text class="result-category">{{ item.category }}</text></view><text class="result-title">{{ item.title }}</text><text class="result-summary">{{ item.summary }}</text><text class="result-meta">{{ item.meta }}</text></view>
<image v-if="item.image.length > 0" class="result-image" :src="item.image" mode="aspectFill" />
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="uts">
import AppHeader from '@/components/AppHeader.uvue'
import { usePageLayout } from '@/services/layout.uts'
import { searchContent, ContentSearchResult } from '@/packages/content/services/search.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
type SearchTab = { id: string, label: string }
const tabs: SearchTab[] = [{ id: 'all', label: '全部' }, { id: 'news', label: '新闻' }, { id: 'lesson', label: '融资课堂' }]
const suggestions: string[] = ['个人信用', '企业融资', '利率', '还款', '材料', '政策']
const keyword = ref('')
const activeType = ref('all')
const searched = ref(false)
const loading = ref(false)
const errorMessage = ref('')
const results = ref<ContentSearchResult[]>([])
const listVersion = ref(0)
let requestVersion = 0
const resetResults = () => { requestVersion++; results.value = []; searched.value = false; loading.value = false; errorMessage.value = ''; listVersion.value++ }
const clearSearch = () => { keyword.value = ''; resetResults() }
const search = async () => {
keyword.value = keyword.value.trim()
if (keyword.value.length == 0) { clearSearch(); return }
uni.hideKeyboard()
const currentRequest = ++requestVersion
searched.value = true; loading.value = true; errorMessage.value = ''; results.value = []; listVersion.value++
try {
const items = await searchContent(keyword.value, activeType.value)
// 输入、切换分类或退出页面后,忽略旧请求,避免结果被覆盖。
if (currentRequest == requestVersion) results.value = items
} catch (_error) {
if (currentRequest == requestVersion) errorMessage.value = '请检查网络连接后重试'
} finally { if (currentRequest == requestVersion) loading.value = false }
}
const selectType = (type: string) => { if (activeType.value == type) return; activeType.value = type; if (searched.value) search() }
const searchWord = (word: string) => { keyword.value = word; search() }
const openResult = (item: ContentSearchResult) => {
const route = item.type == 'news' ? '/packages/content/article/article' : '/packages/content/lesson/lesson'
uni.navigateTo({ url: route + '?id=' + encodeURIComponent(item.id) })
}
onUnload(() => { requestVersion++ })
</script>
<style>
.search-bar { height: 112rpx; padding: 20rpx 28rpx; flex-direction: row; align-items: center; background-color: #ffffff; }
.search-box { flex: 1; min-width: 0; height: 72rpx; border-radius: 16rpx; background-color: #f2f6fc; flex-direction: row; align-items: center; padding-left: 20rpx; }
.search-icon { width: 34rpx; height: 34rpx; margin-right: 14rpx; flex-shrink: 0; }
.search-input { flex: 1; min-width: 0; height: 72rpx; font-size: 27rpx; color: #22324a; }
.clear-search { width: 64rpx; height: 72rpx; align-items: center; justify-content: center; flex-shrink: 0; }
.clear-mark { font-size: 36rpx; line-height: 44rpx; color: #738198; }
.search-button { width: 92rpx; height: 72rpx; line-height: 72rpx; padding: 0; margin: 0 0 0 16rpx; font-size: 28rpx; font-weight: 600; color: #0868f7; background-color: transparent; border-radius: 12rpx; }
.search-tabs { height: 88rpx; padding: 0 28rpx; flex-direction: row; background-color: #ffffff; border-bottom: 1rpx solid #e8eef6; }
.search-tab { flex: 1; height: 88rpx; align-items: center; justify-content: center; border-bottom: 4rpx solid transparent; }
.selected-tab { border-bottom-color: #0868f7; }
.tab-label { font-size: 28rpx; color: #66758a; }
.selected-label { color: #0868f7; font-weight: 700; }
.search-content { padding: 28rpx 28rpx 40rpx; }
.search-guide { padding: 22rpx 4rpx; }
.guide-title { color: #172640; font-size: 34rpx; line-height: 48rpx; font-weight: 700; }
.guide-copy { color: #738198; font-size: 26rpx; line-height: 40rpx; margin-top: 10rpx; }
.suggestion-list { flex-direction: row; flex-wrap: wrap; margin-top: 28rpx; }
.suggestion { padding: 18rpx 28rpx; margin: 0 16rpx 18rpx 0; border: 1rpx solid #e3eaf4; border-radius: 14rpx; background-color: #ffffff; }
.suggestion-label { font-size: 26rpx; color: #405675; }
.result-count { font-size: 24rpx; color: #738198; margin-bottom: 20rpx; }
.result-card { flex-direction: row; align-items: center; background-color: #ffffff; border: 1rpx solid #e6edf7; border-radius: 18rpx; padding: 24rpx; margin-bottom: 18rpx; }
.result-copy { flex: 1; min-width: 0; }
.result-heading { flex-direction: row; align-items: center; flex-wrap: wrap; }
.result-kind { padding: 5rpx 10rpx; border-radius: 6rpx; font-size: 21rpx; color: #0868f7; background-color: #edf5ff; margin-right: 12rpx; }
.lesson-kind { color: #197f70; background-color: #eaf7f3; }
.result-category { color: #738198; font-size: 22rpx; line-height: 34rpx; }
.result-title { color: #172640; font-size: 29rpx; line-height: 42rpx; font-weight: 600; margin-top: 12rpx; }
.result-summary { color: #66758a; font-size: 24rpx; line-height: 36rpx; margin-top: 10rpx; }
.result-meta { color: #738198; font-size: 22rpx; line-height: 32rpx; margin-top: 14rpx; }
.result-image { width: 132rpx; height: 132rpx; border-radius: 12rpx; margin-left: 20rpx; flex-shrink: 0; }
.state-panel { padding: 100rpx 16rpx; align-items: center; }
.empty-symbol { width: 112rpx; height: 112rpx; border-radius: 56rpx; align-items: center; justify-content: center; background-color: #e8f2ff; margin-bottom: 26rpx; }
.empty-icon { width: 50rpx; height: 50rpx; }
.state-title { color: #26374f; font-size: 30rpx; line-height: 44rpx; font-weight: 600; text-align: center; }
.state-copy { color: #738198; font-size: 25rpx; line-height: 40rpx; margin-top: 14rpx; text-align: center; }
.retry-button { color: #0868f7; background-color: #e8f2ff; height: 76rpx; line-height: 76rpx; padding: 0 36rpx; margin-top: 28rpx; border-radius: 12rpx; font-size: 26rpx; }
</style>
+35
View File
@@ -0,0 +1,35 @@
import { API_BASE_URL, requestJson } from '@/services/api.uts'
export type HelpQuestion = { id: string, category: string, question: string, answer: string }
export const helpCategories: string[] = ['账户使用', '融资评估', '资料与隐私']
const questions: HelpQuestion[] = [
{ id: 'profile', category: '账户使用', question: '如何修改头像和昵称?', answer: '在“我的”页面,点击顶部头像、昵称和手机号所在的区域进入修改资料。更换头像或编辑昵称后,点击“保存资料”即可。昵称不会替代融资档案中的实名信息。' },
{ id: 'password', category: '账户使用', question: '忘记登录密码怎么办?', answer: '可先使用手机号验证码登录,再进入“我的 → 账户安全 → 验证码重设密码”。如果记得当前密码,也可以选择“修改登录密码”。修改成功后需要重新登录。' },
{ id: 'phone', category: '账户使用', question: '如何更换绑定手机号?', answer: '进入“我的 → 账户安全 → 绑定手机号”,填写新手机号,并分别验证当前号码与新号码。换绑成功后使用新号码登录。如果原号码无法接收短信,需要通过官方客服核验身份。' },
{ id: 'assessment', category: '融资评估', question: '如何开始融资评估?', answer: '从首页选择个人或企业融资入口,依次填写基本信息、收入或经营信息、资产与负债情况,再核对并确认。已有资料可在“我的融资档案”中继续完善。' },
{ id: 'result', category: '融资评估', question: '评估结果是否代表贷款已经获批?', answer: '不代表。评估结果用于帮助了解融资需求和方案,不是金融机构的授信或放款承诺。实际额度、利率、审批与放款结果,以相关机构的审核和合同为准。' },
{ id: 'amount', category: '融资评估', question: '资产与负债信息应该怎么填写?', answer: '信用贷款笔数、信用卡张数填写非负整数,没有则填 0。欠款余额、月还款金额、信用卡额度与已使用额度以元填写,最多保留两位小数;房产、车辆按实际情况选择“有”或“无”。' },
{ id: 'files', category: '资料与隐私', question: '选择资料或勾选授权会立即上传、查询吗?', answer: '当前选择的资料仅用于本机预览,勾选授权仅保存您的选择,不会自动上传资料或查询外部数据。使用相关服务前,请确认授权对象、用途和范围。' },
{ id: 'draft', category: '资料与隐私', question: '退出登录后,填写的资料还在吗?', answer: '退出登录会结束当前会话,本机保存的评估草稿仍会保留。个人与企业评估分别保存。清理应用数据、更换设备或卸载应用可能使本地资料丢失,请勿将本机草稿视为云端备份。' }
]
export async function fetchHelpQuestions(): Promise<HelpQuestion[]> {
if (API_BASE_URL.length == 0) return questions.slice()
const response = await requestJson('/help/faqs', 'GET', {})
const items = response['items'] as UTSJSONObject[] | null
if (items == null) throw new Error('帮助内容加载失败')
const results: HelpQuestion[] = []
items.forEach((item) => {
const id = item['id'] as string ?? ''; const category = item['category'] as string ?? ''
const question = item['question'] as string ?? ''; const answer = item['answer'] as string ?? ''
if (id.length > 0 && helpCategories.includes(category) && question.length > 0 && answer.length > 0 && !results.some((entry: HelpQuestion): boolean => entry.id == id)) {
const result: HelpQuestion = { id, category, question, answer }; results.push(result)
}
})
return results
}
export function contactCustomerService(): void {
// 后续在此接入各端客服渠道;当前不发起会话、拨号或发送反馈。
uni.showModal({ title: '联系客服', content: '客服服务暂未开放,请稍后再试。', showCancel: false, confirmText: '我知道了' })
}
+48
View File
@@ -0,0 +1,48 @@
import { API_BASE_URL, requestJson } from '@/services/api.uts'
import { newsArticles, financeLessons, ContentSection } from './content.uts'
export type ContentSearchResult = {
id: string, type: string, title: string, summary: string, category: string, meta: string, image: string
}
const sectionText = (sections: ContentSection[]): string => sections.map((item: ContentSection): string => item.title + ' ' + item.body).join(' ')
const matches = (text: string, words: string[]): boolean => words.every((word: string): boolean => text.toLowerCase().includes(word))
const readText = (item: UTSJSONObject, key: string): string => typeof item[key] == 'string' ? item[key] as string : ''
// type: all / news / lesson。接入后端时返回相同类型的 items,详情沿用内容 ID。
export async function searchContent(keyword: string, type: string): Promise<ContentSearchResult[]> {
const query = keyword.trim().replace(/\s+/g, ' ')
const results: ContentSearchResult[] = []
if (query.length == 0) return results
if (API_BASE_URL.length > 0) {
const response = await requestJson('/content/search', 'GET', { keyword: query, type })
const items = response['items'] as UTSJSONObject[] | null
if (items == null) throw new Error('搜索结果加载失败,请重试')
items.forEach((item: UTSJSONObject) => {
const kind = readText(item, 'type'); const id = readText(item, 'id'); const title = readText(item, 'title')
if ((kind == 'news' || kind == 'lesson') && (type == 'all' || type == kind) && id.length > 0 && title.length > 0) {
const result: ContentSearchResult = { id, type: kind, title, summary: readText(item, 'summary'), category: readText(item, 'category'), meta: readText(item, 'meta'), image: readText(item, 'image') }
results.push(result)
}
})
return results
}
const words = query.toLowerCase().split(' ')
if (type == 'all' || type == 'news') {
newsArticles.forEach((item) => {
if (matches(item.title + ' ' + item.summary + ' ' + item.category + ' ' + sectionText(item.sections), words)) {
const result: ContentSearchResult = { id: item.id, type: 'news', title: item.title, summary: item.summary, category: item.category, meta: item.date, image: item.image }
results.push(result)
}
})
}
if (type == 'all' || type == 'lesson') {
financeLessons.forEach((item) => {
if (matches(item.title + ' ' + item.desc + ' ' + item.tag + ' ' + item.intro + ' ' + sectionText(item.chapters), words)) {
const result: ContentSearchResult = { id: item.id, type: 'lesson', title: item.title, summary: item.desc, category: item.tag, meta: item.duration + ' · ' + item.chapters.length + ' 节', image: item.image }
results.push(result)
}
})
}
return results
}
+1 -1
View File
@@ -23,7 +23,7 @@
const archiveItems = computed((): ArchiveItem[] => [ const archiveItems = computed((): ArchiveItem[] => [
{ icon: 'file-text', name: '基本信息', note: type.value == 'enterprise' ? '企业与联系人信息' : '身份与联系方式', done: progress.value.basicDone, step: 0 }, { icon: 'file-text', name: '基本信息', note: type.value == 'enterprise' ? '企业与联系人信息' : '身份与联系方式', done: progress.value.basicDone, step: 0 },
{ icon: 'income', name: type.value == 'enterprise' ? '经营信息' : '职业与收入', note: type.value == 'enterprise' ? '经营行业、年限与营收' : '职业、收入与缴纳情况', done: progress.value.incomeDone, step: 1 }, { icon: 'income', name: type.value == 'enterprise' ? '经营信息' : '职业与收入', note: type.value == 'enterprise' ? '经营行业、年限与营收' : '职业、收入与缴纳情况', done: progress.value.incomeDone, step: 1 },
{ icon: 'asset', name: '资产与负债', note: '资产情况与信用卡使用', done: progress.value.assetsDone, step: 2 }, { icon: 'asset', name: '资产与负债', note: '贷款、信用卡额度与房车情况', done: progress.value.assetsDone, step: 2 },
{ icon: 'authorization', name: '资料与授权', note: '本地资料与数据授权意向', done: progress.value.authorizationSelected, step: 3 } { icon: 'authorization', name: '资料与授权', note: '本地资料与数据授权意向', done: progress.value.authorizationSelected, step: 3 }
]) ])
const refresh = () => { progress.value = assessmentProgress(type.value) } const refresh = () => { progress.value = assessmentProgress(type.value) }
+16 -7
View File
@@ -31,11 +31,17 @@
</template> </template>
</view> </view>
<view v-else-if="step == 2" class="form-list"> <view v-else-if="step == 2" class="form-list">
<AssessmentSelect label="房产情况" v-model="form.house" :options="['无房产', '全款房产', '按揭房产']" /> <text class="asset-section-title">贷款信息</text>
<AssessmentSelect label="车辆情况" v-model="form.car" :options="['无车辆', '全款车辆', '按揭车辆']" /> <view class="form-row"><text class="field-label">信用贷款笔数</text><input v-model="form.creditLoanCount" class="field-input" type="number" maxlength="4" placeholder="无则填 0" /><text class="field-unit">笔</text></view>
<AssessmentSelect label="存款情况" v-model="form.savings" :options="['5 万元以下', '5–20 万元', '20–50 万元', '50 万元以上']" /> <view class="form-row"><text class="field-label">欠款总余额</text><input v-model="form.totalDebtBalance" class="field-input" type="digit" maxlength="15" placeholder="请输入金额" /><text class="field-unit">元</text></view>
<AssessmentSelect label="信用卡使用" v-model="form.creditCard" :options="['无信用卡', '正常使用,无逾期', '当前存在逾期', '历史有逾期,已结清']" /> <view class="form-row"><text class="field-label">贷款月还款金额</text><input v-model="form.monthlyLoanPayment" class="field-input" type="digit" maxlength="15" placeholder="请输入金额" /><text class="field-unit">元</text></view>
<view class="form-row"><text class="field-label">其他资产</text><input v-model="form.otherAssets" class="field-input" placeholder="选填" /></view> <text class="asset-section-title">信用卡信息</text>
<view class="form-row"><text class="field-label">银行信用卡张数</text><input v-model="form.creditCardCount" class="field-input" type="number" maxlength="4" placeholder="无则填 0" /><text class="field-unit">张</text></view>
<view class="form-row"><text class="field-label">总额度</text><input v-model="form.creditCardTotalLimit" class="field-input" type="digit" maxlength="15" placeholder="请输入金额" /><text class="field-unit">元</text></view>
<view class="form-row"><text class="field-label">已使用总额度</text><input v-model="form.creditCardUsedLimit" class="field-input" type="digit" maxlength="15" placeholder="请输入金额" /><text class="field-unit">元</text></view>
<text class="asset-section-title">房车信息</text>
<AssessmentSelect label="是否有房" v-model="form.house" :options="['有', '无']" />
<AssessmentSelect label="是否有车" v-model="form.car" :options="['有', '无']" />
</view> </view>
<view v-else class="upload-area"> <view v-else class="upload-area">
<text class="upload-title">资料选择 <text class="optional">(选填)</text></text> <text class="upload-title">资料选择 <text class="optional">(选填)</text></text>
@@ -56,7 +62,7 @@
import AppHeader from '@/components/AppHeader.uvue' import AppHeader from '@/components/AppHeader.uvue'
import AppIcon from '@/components/AppIcon.uvue' import AppIcon from '@/components/AppIcon.uvue'
import AssessmentSelect from '@/packages/finance/components/AssessmentSelect.uvue' import AssessmentSelect from '@/packages/finance/components/AssessmentSelect.uvue'
import { AssessmentForm, SelectedDocument, DataAuthorization, emptyAssessmentForm, activeAssessmentType, loadAssessmentDraft, formFromDraft, documentsFromDraft, saveAssessmentDraft, showAgreementNotice } from '@/services/assessment.uts' import { AssessmentForm, SelectedDocument, DataAuthorization, emptyAssessmentForm, activeAssessmentType, loadAssessmentDraft, formFromDraft, documentsFromDraft, saveAssessmentDraft, showAgreementNotice, assetValidationMessage } from '@/services/assessment.uts'
const { bottomStyle, contentStyle, pageStyle } = usePageLayout() const { bottomStyle, contentStyle, pageStyle } = usePageLayout()
const showAgreement = (title: string) => { showAgreementNotice(title) } const showAgreement = (title: string) => { showAgreementNotice(title) }
const step = ref(0) const step = ref(0)
@@ -74,7 +80,7 @@
const saveDraft = () => { const saveDraft = () => {
if (!initialized) return if (!initialized) return
const source = form.value const source = form.value
const draftForm: AssessmentForm = { name: source.name, idCard: source.idCard, phone: source.phone, city: source.city, maritalStatus: source.maritalStatus, occupation: source.occupation, company: source.company, monthlyIncome: source.monthlyIncome, socialSecurity: source.socialSecurity, housingFund: source.housingFund, house: source.house, car: source.car, savings: source.savings, creditCard: source.creditCard, otherAssets: source.otherAssets, enterpriseName: source.enterpriseName, businessCode: source.businessCode, annualRevenue: source.annualRevenue, operatingYears: source.operatingYears } const draftForm: AssessmentForm = { name: source.name, idCard: source.idCard, phone: source.phone, city: source.city, maritalStatus: source.maritalStatus, occupation: source.occupation, company: source.company, monthlyIncome: source.monthlyIncome, socialSecurity: source.socialSecurity, housingFund: source.housingFund, house: source.house, car: source.car, savings: source.savings, creditCard: source.creditCard, otherAssets: source.otherAssets, creditLoanCount: source.creditLoanCount, totalDebtBalance: source.totalDebtBalance, monthlyLoanPayment: source.monthlyLoanPayment, creditCardCount: source.creditCardCount, creditCardTotalLimit: source.creditCardTotalLimit, creditCardUsedLimit: source.creditCardUsedLimit, enterpriseName: source.enterpriseName, businessCode: source.businessCode, annualRevenue: source.annualRevenue, operatingYears: source.operatingYears }
if (draftForm.city == '其他城市' && customCity.value.trim().length > 0) draftForm.city = customCity.value.trim() if (draftForm.city == '其他城市' && customCity.value.trim().length > 0) draftForm.city = customCity.value.trim()
saveAssessmentDraft(assessmentType.value, step.value, draftForm, documents.value, authorizations.value, documentConsent.value, dataConsent.value) saveAssessmentDraft(assessmentType.value, step.value, draftForm, documents.value, authorizations.value, documentConsent.value, dataConsent.value)
} }
@@ -145,6 +151,7 @@
const amount = assessmentType.value == 'enterprise' ? form.value.annualRevenue : form.value.monthlyIncome const amount = assessmentType.value == 'enterprise' ? form.value.annualRevenue : form.value.monthlyIncome
if (!/^[0-9]+([.][0-9]{1,2})?$/.test(amount) || parseFloat(amount) <= 0) message = assessmentType.value == 'enterprise' ? '请输入有效的年营业收入' : '请输入有效的月收入' if (!/^[0-9]+([.][0-9]{1,2})?$/.test(amount) || parseFloat(amount) <= 0) message = assessmentType.value == 'enterprise' ? '请输入有效的年营业收入' : '请输入有效的月收入'
} }
if (step.value == 2) message = assetValidationMessage(form.value)
if (step.value == 3 && documents.value.length > 0 && !documentConsent.value) message = '请阅读并同意资料使用协议,或移除资料' if (step.value == 3 && documents.value.length > 0 && !documentConsent.value) message = '请阅读并同意资料使用协议,或移除资料'
if (step.value == 3 && authorizations.value.some((item) => item.selected) && !dataConsent.value) message = '请阅读并同意数据授权协议,或取消授权选择' if (step.value == 3 && authorizations.value.some((item) => item.selected) && !dataConsent.value) message = '请阅读并同意数据授权协议,或取消授权选择'
if (message.length == 0) return true if (message.length == 0) return true
@@ -167,6 +174,8 @@
.type-active { color: #0868f7; font-weight: 600; background-color: #fff; } .type-active { color: #0868f7; font-weight: 600; background-color: #fff; }
.section-hint { color: #738198; font-size: 24rpx; line-height: 36rpx; margin-top: 30rpx; } .section-hint { color: #738198; font-size: 24rpx; line-height: 36rpx; margin-top: 30rpx; }
.form-list { margin-top: 14rpx; } .form-list { margin-top: 14rpx; }
.asset-section-title { margin-top: 28rpx; font-size: 26rpx; line-height: 38rpx; color: #738198; font-weight: 600; }
.field-unit { margin-left: 12rpx; font-size: 25rpx; color: #738198; flex-shrink: 0; }
.form-row { min-height: 106rpx; flex-direction: row; align-items: center; border-bottom: 1rpx solid #edf1f6; } .form-row { min-height: 106rpx; flex-direction: row; align-items: center; border-bottom: 1rpx solid #edf1f6; }
.field-label { font-size: 28rpx; color: #29374e; margin-right: 18rpx; flex-shrink: 0; } .field-label { font-size: 28rpx; color: #29374e; margin-right: 18rpx; flex-shrink: 0; }
.field-input { flex: 1; min-width: 0; text-align: right; font-size: 27rpx; color: #22324a; } .field-input { flex: 1; min-width: 0; text-align: right; font-size: 27rpx; color: #22324a; }
+14 -2
View File
@@ -19,7 +19,7 @@
import { usePageLayout } from '@/services/layout.uts' import { usePageLayout } from '@/services/layout.uts'
import AppHeader from '@/components/AppHeader.uvue' import AppHeader from '@/components/AppHeader.uvue'
import { submitAssessment } from '@/packages/finance/services/api.uts' import { submitAssessment } from '@/packages/finance/services/api.uts'
import { activeAssessmentType, loadAssessmentDraft, draftText, documentsFromDraft } from '@/services/assessment.uts' import { activeAssessmentType, loadAssessmentDraft, draftText, documentsFromDraft, ownershipFromDraft, formFromDraft, assetValidationMessage } from '@/services/assessment.uts'
const { pageStyle, contentStyle, bottomStyle } = usePageLayout() const { pageStyle, contentStyle, bottomStyle } = usePageLayout()
type InfoRow = { label: string, value: string } type InfoRow = { label: string, value: string }
type InfoSection = { title: string, step: number, rows: InfoRow[], expanded: boolean } type InfoSection = { title: string, step: number, rows: InfoRow[], expanded: boolean }
@@ -31,6 +31,7 @@
const mask = (value: string, left: number, right: number): string => { if (value.length <= left + right) return value; return value.substring(0, left) + '****' + value.substring(value.length - right) } const mask = (value: string, left: number, right: number): string => { if (value.length <= left + right) return value; return value.substring(0, left) + '****' + value.substring(value.length - right) }
const displayValue = (saved: UTSJSONObject, key: string): string => { const value = draftText(saved, key); return value.length > 0 ? value : '未填写' } const displayValue = (saved: UTSJSONObject, key: string): string => { const value = draftText(saved, key); return value.length > 0 ? value : '未填写' }
const appendRow = (rows: InfoRow[], label: string, value: string) => { const row: InfoRow = { label, value }; rows.push(row) } const appendRow = (rows: InfoRow[], label: string, value: string) => { const row: InfoRow = { label, value }; rows.push(row) }
const valueWithUnit = (saved: UTSJSONObject, key: string, unit: string): string => { const value = draftText(saved, key); return value.length > 0 ? value + ' ' + unit : '未填写' }
const loadDraft = () => { const loadDraft = () => {
assessmentType.value = activeAssessmentType() assessmentType.value = activeAssessmentType()
const saved = loadAssessmentDraft(assessmentType.value) const saved = loadAssessmentDraft(assessmentType.value)
@@ -62,7 +63,16 @@
sections.value = [ sections.value = [
{ title: '基本信息', step: 0, rows: basicRows, expanded: false }, { title: '基本信息', step: 0, rows: basicRows, expanded: false },
{ title: enterprise ? '经营信息' : '职业与收入', step: 1, rows: incomeRows, expanded: false }, { title: enterprise ? '经营信息' : '职业与收入', step: 1, rows: incomeRows, expanded: false },
{ title: '资产与负债', step: 2, rows: [{ label: '房产情况', value: displayValue(saved, 'house') }, { label: '车辆情况', value: displayValue(saved, 'car') }, { label: '存款情况', value: displayValue(saved, 'savings') }, { label: '信用卡使用', value: displayValue(saved, 'creditCard') }, { label: '其他资产', value: displayValue(saved, 'otherAssets') }], expanded: false }, { title: '资产与负债', step: 2, rows: [
{ label: '信用贷款笔数', value: valueWithUnit(saved, 'creditLoanCount', '笔') },
{ label: '欠款总余额', value: valueWithUnit(saved, 'totalDebtBalance', '元') },
{ label: '贷款月还款金额', value: valueWithUnit(saved, 'monthlyLoanPayment', '元') },
{ label: '银行信用卡张数', value: valueWithUnit(saved, 'creditCardCount', '张') },
{ label: '总额度', value: valueWithUnit(saved, 'creditCardTotalLimit', '元') },
{ label: '已使用总额度', value: valueWithUnit(saved, 'creditCardUsedLimit', '元') },
{ label: '是否有房', value: ownershipFromDraft(saved, 'house').length > 0 ? ownershipFromDraft(saved, 'house') : '未填写' },
{ label: '是否有车', value: ownershipFromDraft(saved, 'car').length > 0 ? ownershipFromDraft(saved, 'car') : '未填写' }
], expanded: false },
{ title: '资料与授权', step: 3, rows: [{ label: '本地资料', value: fileNames.length > 0 ? fileNames : '未选择' }, { label: '授权意向', value: grantNames.length > 0 ? grantNames.join('、') : '未选择' }, { label: '当前状态', value: '资料未上传 · 数据未查询' }], expanded: false } { title: '资料与授权', step: 3, rows: [{ label: '本地资料', value: fileNames.length > 0 ? fileNames : '未选择' }, { label: '授权意向', value: grantNames.length > 0 ? grantNames.join('、') : '未选择' }, { label: '当前状态', value: '资料未上传 · 数据未查询' }], expanded: false }
] ]
payload.value = { ...saved, source: 'mobile', confirmed: true } payload.value = { ...saved, source: 'mobile', confirmed: true }
@@ -85,6 +95,8 @@
if (amount.length == 0 || parseFloat(amount) <= 0 || isNaN(parseFloat(amount))) { uni.showToast({ title: '请先完善收入信息', icon: 'none' }); edit(1); return } if (amount.length == 0 || parseFloat(amount) <= 0 || isNaN(parseFloat(amount))) { uni.showToast({ title: '请先完善收入信息', icon: 'none' }); edit(1); return }
const grants = saved['authorizations'] as string[] | null const grants = saved['authorizations'] as string[] | null
if ((documentsFromDraft(saved).length > 0 && saved['documentConsent'] != true) || (grants != null && grants.length > 0 && saved['dataConsent'] != true)) { uni.showToast({ title: '请确认资料与授权选择', icon: 'none' }); edit(3); return } if ((documentsFromDraft(saved).length > 0 && saved['documentConsent'] != true) || (grants != null && grants.length > 0 && saved['dataConsent'] != true)) { uni.showToast({ title: '请确认资料与授权选择', icon: 'none' }); edit(3); return }
const assetError = assetValidationMessage(formFromDraft(saved))
if (assetError.length > 0) { uni.showToast({ title: assetError, icon: 'none' }); edit(2); return }
submitting.value = true submitting.value = true
try { await submitAssessment(payload.value); uni.redirectTo({ url: '/packages/finance/analyzing/analyzing' }) } try { await submitAssessment(payload.value); uni.redirectTo({ url: '/packages/finance/analyzing/analyzing' }) }
catch (error) { uni.showToast({ title: error instanceof Error ? error.message : '提交失败,请重试', icon: 'none' }) } catch (error) { uni.showToast({ title: error instanceof Error ? error.message : '提交失败,请重试', icon: 'none' }) }
+6
View File
@@ -23,6 +23,10 @@
{ "path": "index/index", "style": { "navigationStyle": "custom" } }, { "path": "index/index", "style": { "navigationStyle": "custom" } },
{ "path": "login/login", "style": { "navigationStyle": "custom" } }, { "path": "login/login", "style": { "navigationStyle": "custom" } },
{ "path": "profile/profile", "style": { "navigationStyle": "custom" } }, { "path": "profile/profile", "style": { "navigationStyle": "custom" } },
{ "path": "edit-profile/edit-profile", "style": { "navigationStyle": "custom" } },
{ "path": "security/security", "style": { "navigationStyle": "custom" } },
{ "path": "password/password", "style": { "navigationStyle": "custom" } },
{ "path": "change-phone/change-phone", "style": { "navigationStyle": "custom" } },
{ "path": "points/points", "style": { "navigationStyle": "custom" } }, { "path": "points/points", "style": { "navigationStyle": "custom" } },
{ "path": "messages/messages", "style": { "navigationStyle": "custom" } } { "path": "messages/messages", "style": { "navigationStyle": "custom" } }
] ]
@@ -30,6 +34,8 @@
{ {
"root": "packages/content", "root": "packages/content",
"pages": [ "pages": [
{ "path": "search/search", "style": { "navigationStyle": "custom" } },
{ "path": "help/help", "style": { "navigationStyle": "custom" } },
{ "path": "news/news", "style": { "navigationStyle": "custom" } }, { "path": "news/news", "style": { "navigationStyle": "custom" } },
{ "path": "article/article", "style": { "navigationStyle": "custom" } }, { "path": "article/article", "style": { "navigationStyle": "custom" } },
{ "path": "classroom/classroom", "style": { "navigationStyle": "custom" } }, { "path": "classroom/classroom", "style": { "navigationStyle": "custom" } },
+10 -6
View File
@@ -4,8 +4,11 @@
<view class="safe" :style="statusStyle(0)"></view> <view class="safe" :style="statusStyle(0)"></view>
<view class="home-bar" :style="navigationStyle()"> <view class="home-bar" :style="navigationStyle()">
<view class="home-brand" :style="navigationBrandStyle(104)"><BrandLogo /></view> <view class="home-brand" :style="navigationBrandStyle(104)"><BrandLogo /></view>
<view class="header-actions" :style="navigationActionStyle(104)"> <view class="header-actions" :style="navigationActionStyle(104, 2)">
<view class="round-action notify" @click="go('/packages/account/messages/messages')"> <view class="round-action search-action" @click="go('/packages/content/search/search')">
<view class="action-surface"><image class="header-icon" src="/static/icons/search.svg" mode="aspectFit" /></view>
</view>
<view class="round-action notify action-following" @click="go('/packages/account/messages/messages')">
<view class="action-surface"><image class="header-icon" src="/static/icons/bell.svg" mode="aspectFit" /></view> <view class="action-surface"><image class="header-icon" src="/static/icons/bell.svg" mode="aspectFit" /></view>
<view v-if="hasUnread" class="red-dot"></view> <view v-if="hasUnread" class="red-dot"></view>
</view> </view>
@@ -60,11 +63,12 @@
.safe { } .safe { }
.home-bar { height: 104rpx; padding: 0 28rpx; flex-direction: row; align-items: center; justify-content: space-between; position: relative; } .home-bar { height: 104rpx; padding: 0 28rpx; flex-direction: row; align-items: center; justify-content: space-between; position: relative; }
.home-brand { flex-shrink: 0; justify-content: center; } .home-brand { flex-shrink: 0; justify-content: center; }
.header-actions { width: 62rpx; height: 62rpx; margin-left: 16rpx; flex-shrink: 0; } .header-actions { width: 140rpx; height: 62rpx; margin-left: 16rpx; flex-shrink: 0; flex-direction: row; }
.round-action { width: 100%; height: 100%; position: relative; flex-shrink: 0; } .round-action { flex: 1; height: 100%; position: relative; }
.action-surface { width: 100%; height: 100%; border-radius: 50%; background-color: #eef5ff; align-items: center; justify-content: center; } .action-following { margin-left: 16rpx; }
.action-surface { width: 100%; height: 100%; align-items: center; justify-content: center; }
.header-icon { width: 36rpx; height: 36rpx; flex-shrink: 0; } .header-icon { width: 36rpx; height: 36rpx; flex-shrink: 0; }
/* 红点与圆形背景同级,避免被跨端 view 的圆角裁切。 */ /* 红点独立定位,不影响图标居中及点击区域。 */
.red-dot { position: absolute; width: 16rpx; height: 16rpx; right: 2rpx; top: 2rpx; z-index: 1; background-color: #ff4d4f; border-radius: 50%; border: 2rpx solid #fff; } .red-dot { position: absolute; width: 16rpx; height: 16rpx; right: 2rpx; top: 2rpx; z-index: 1; background-color: #ff4d4f; border-radius: 50%; border: 2rpx solid #fff; }
.home-scroll { } .home-scroll { }
.home-content { padding: 18rpx 28rpx 180rpx; } .home-content { padding: 18rpx 28rpx 180rpx; }
+30 -4
View File
@@ -2,13 +2,15 @@ export type AssessmentForm = {
name: string, idCard: string, phone: string, city: string, maritalStatus: string, name: string, idCard: string, phone: string, city: string, maritalStatus: string,
occupation: string, company: string, monthlyIncome: string, socialSecurity: string, occupation: string, company: string, monthlyIncome: string, socialSecurity: string,
housingFund: string, house: string, car: string, savings: string, creditCard: string, housingFund: string, house: string, car: string, savings: string, creditCard: string,
creditLoanCount: string, totalDebtBalance: string, monthlyLoanPayment: string,
creditCardCount: string, creditCardTotalLimit: string, creditCardUsedLimit: string,
otherAssets: string, enterpriseName: string, businessCode: string, annualRevenue: string, operatingYears: string otherAssets: string, enterpriseName: string, businessCode: string, annualRevenue: string, operatingYears: string
} }
export type SelectedDocument = { name: string, path: string } export type SelectedDocument = { name: string, path: string }
export type DataAuthorization = { key: string, name: string, selected: boolean } export type DataAuthorization = { key: string, name: string, selected: boolean }
export function emptyAssessmentForm(): AssessmentForm { export function emptyAssessmentForm(): AssessmentForm {
return { name: '', idCard: '', phone: '', city: '', maritalStatus: '', occupation: '', company: '', monthlyIncome: '', socialSecurity: '', housingFund: '', house: '', car: '', savings: '', creditCard: '', otherAssets: '', enterpriseName: '', businessCode: '', annualRevenue: '', operatingYears: '' } return { name: '', idCard: '', phone: '', city: '', maritalStatus: '', occupation: '', company: '', monthlyIncome: '', socialSecurity: '', housingFund: '', house: '', car: '', savings: '', creditCard: '', otherAssets: '', creditLoanCount: '', totalDebtBalance: '', monthlyLoanPayment: '', creditCardCount: '', creditCardTotalLimit: '', creditCardUsedLimit: '', enterpriseName: '', businessCode: '', annualRevenue: '', operatingYears: '' }
} }
export function draftText(saved: UTSJSONObject, key: string): string { export function draftText(saved: UTSJSONObject, key: string): string {
const value = saved[key] const value = saved[key]
@@ -35,10 +37,32 @@ export function formFromDraft(saved: UTSJSONObject): AssessmentForm {
return { return {
name: draftText(saved, 'name'), idCard: draftText(saved, 'idCard'), phone: draftText(saved, 'phone'), city: draftText(saved, 'city'), maritalStatus: draftText(saved, 'maritalStatus'), name: draftText(saved, 'name'), idCard: draftText(saved, 'idCard'), phone: draftText(saved, 'phone'), city: draftText(saved, 'city'), maritalStatus: draftText(saved, 'maritalStatus'),
occupation: draftText(saved, 'occupation'), company: draftText(saved, 'company'), monthlyIncome: draftText(saved, 'monthlyIncome'), socialSecurity: draftText(saved, 'socialSecurity'), housingFund: draftText(saved, 'housingFund'), occupation: draftText(saved, 'occupation'), company: draftText(saved, 'company'), monthlyIncome: draftText(saved, 'monthlyIncome'), socialSecurity: draftText(saved, 'socialSecurity'), housingFund: draftText(saved, 'housingFund'),
house: draftText(saved, 'house'), car: draftText(saved, 'car'), savings: draftText(saved, 'savings'), creditCard: draftText(saved, 'creditCard'), otherAssets: draftText(saved, 'otherAssets'), house: ownershipFromDraft(saved, 'house'), car: ownershipFromDraft(saved, 'car'), savings: draftText(saved, 'savings'), creditCard: draftText(saved, 'creditCard'), otherAssets: draftText(saved, 'otherAssets'),
creditLoanCount: draftText(saved, 'creditLoanCount'), totalDebtBalance: draftText(saved, 'totalDebtBalance'), monthlyLoanPayment: draftText(saved, 'monthlyLoanPayment'),
creditCardCount: draftText(saved, 'creditCardCount'), creditCardTotalLimit: draftText(saved, 'creditCardTotalLimit'), creditCardUsedLimit: draftText(saved, 'creditCardUsedLimit'),
enterpriseName: draftText(saved, 'enterpriseName'), businessCode: draftText(saved, 'businessCode'), annualRevenue: draftText(saved, 'annualRevenue'), operatingYears: draftText(saved, 'operatingYears') enterpriseName: draftText(saved, 'enterpriseName'), businessCode: draftText(saved, 'businessCode'), annualRevenue: draftText(saved, 'annualRevenue'), operatingYears: draftText(saved, 'operatingYears')
} }
} }
export function ownershipFromDraft(saved: UTSJSONObject, key: string): string {
const value = draftText(saved, key)
if (value == '有' || value == '无') return value
if (value == '无房产' || value == '无车辆') return '无'
if (['全款房产', '按揭房产', '全款车辆', '按揭车辆'].includes(value)) return '有'
return ''
}
export function assetValidationMessage(form: AssessmentForm): string {
const counts = [form.creditLoanCount, form.creditCardCount]
const countLabels = ['信用贷款笔数', '银行信用卡张数']
for (let i = 0; i < counts.length; i++) {
if (counts[i].length > 0 && !/^[0-9]{1,4}$/.test(counts[i])) return countLabels[i] + '请输入非负整数'
}
const amounts = [form.totalDebtBalance, form.monthlyLoanPayment, form.creditCardTotalLimit, form.creditCardUsedLimit]
const amountLabels = ['欠款总余额', '贷款月还款金额', '总额度', '已使用总额度']
for (let i = 0; i < amounts.length; i++) {
if (amounts[i].length > 0 && !/^[0-9]{1,12}([.][0-9]{1,2})?$/.test(amounts[i])) return amountLabels[i] + '请输入有效金额,最多两位小数'
}
return ''
}
export function documentsFromDraft(saved: UTSJSONObject): SelectedDocument[] { export function documentsFromDraft(saved: UTSJSONObject): SelectedDocument[] {
const results: SelectedDocument[] = [] const results: SelectedDocument[] = []
const items = saved['documents'] as UTSJSONObject[] | null const items = saved['documents'] as UTSJSONObject[] | null
@@ -56,6 +80,8 @@ export function saveAssessmentDraft(type: string, step: number, form: Assessment
type, step, name: form.name, idCard: form.idCard, phone: form.phone, city: form.city, maritalStatus: form.maritalStatus, type, step, name: form.name, idCard: form.idCard, phone: form.phone, city: form.city, maritalStatus: form.maritalStatus,
occupation: form.occupation, company: form.company, monthlyIncome: form.monthlyIncome, socialSecurity: form.socialSecurity, housingFund: form.housingFund, occupation: form.occupation, company: form.company, monthlyIncome: form.monthlyIncome, socialSecurity: form.socialSecurity, housingFund: form.housingFund,
house: form.house, car: form.car, savings: form.savings, creditCard: form.creditCard, otherAssets: form.otherAssets, house: form.house, car: form.car, savings: form.savings, creditCard: form.creditCard, otherAssets: form.otherAssets,
creditLoanCount: form.creditLoanCount, totalDebtBalance: form.totalDebtBalance, monthlyLoanPayment: form.monthlyLoanPayment,
creditCardCount: form.creditCardCount, creditCardTotalLimit: form.creditCardTotalLimit, creditCardUsedLimit: form.creditCardUsedLimit,
enterpriseName: form.enterpriseName, businessCode: form.businessCode, annualRevenue: form.annualRevenue, operatingYears: form.operatingYears, enterpriseName: form.enterpriseName, businessCode: form.businessCode, annualRevenue: form.annualRevenue, operatingYears: form.operatingYears,
documents: files, authorizations: grants, documentConsent, dataConsent documents: files, authorizations: grants, documentConsent, dataConsent
} }
@@ -74,9 +100,9 @@ export function assessmentProgress(type: string): AssessmentProgress {
if (saved == null) return { percent: 0, basicDone: false, incomeDone: false, assetsDone: false, authorizationSelected: false } if (saved == null) return { percent: 0, basicDone: false, incomeDone: false, assetsDone: false, authorizationSelected: false }
const basic = type == 'enterprise' ? ['enterpriseName', 'businessCode', 'name', 'phone', 'city'] : ['name', 'idCard', 'phone', 'city', 'maritalStatus'] const basic = type == 'enterprise' ? ['enterpriseName', 'businessCode', 'name', 'phone', 'city'] : ['name', 'idCard', 'phone', 'city', 'maritalStatus']
const income = type == 'enterprise' ? ['occupation', 'operatingYears', 'annualRevenue'] : ['occupation', 'company', 'monthlyIncome', 'socialSecurity', 'housingFund'] const income = type == 'enterprise' ? ['occupation', 'operatingYears', 'annualRevenue'] : ['occupation', 'company', 'monthlyIncome', 'socialSecurity', 'housingFund']
const assets = ['house', 'car', 'savings', 'creditCard'] const assets = ['house', 'car', 'creditLoanCount', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardCount', 'creditCardTotalLimit', 'creditCardUsedLimit']
const keys = basic.concat(income).concat(assets) const keys = basic.concat(income).concat(assets)
const filled = keys.filter((key) => draftText(saved, key).trim().length > 0).length const filled = keys.filter((key) => draftText(saved, key).trim().length > 0).length
const grants = saved['authorizations'] as string[] | null const grants = saved['authorizations'] as string[] | null
return { percent: Math.round(filled / keys.length * 100), basicDone: basic.every((key) => draftText(saved, key).length > 0), incomeDone: income.every((key) => draftText(saved, key).length > 0), assetsDone: assets.every((key) => draftText(saved, key).length > 0), authorizationSelected: documentsFromDraft(saved).length > 0 || (grants != null && grants.length > 0) } return { percent: Math.round(filled / keys.length * 100), basicDone: basic.every((key) => draftText(saved, key).length > 0), incomeDone: income.every((key) => draftText(saved, key).length > 0), assetsDone: assets.every((key) => draftText(saved, key).trim().length > 0) && assetValidationMessage(formFromDraft(saved)).length == 0, authorizationSelected: documentsFromDraft(saved).length > 0 || (grants != null && grants.length > 0) }
} }
+5 -4
View File
@@ -7,7 +7,7 @@ export type PageLayout = {
statusStyle: (offset: number) => string, statusStyle: (offset: number) => string,
navigationStyle: () => string, navigationStyle: () => string,
navigationTitleStyle: () => string, navigationTitleStyle: () => string,
navigationActionStyle: (headerHeight: number) => string, navigationActionStyle: (headerHeight: number, actionCount: number) => string,
navigationBrandStyle: (headerHeight: number) => string, navigationBrandStyle: (headerHeight: number) => string,
bottomStyle: (padding: number) => string bottomStyle: (padding: number) => string
} }
@@ -63,13 +63,14 @@ export function usePageLayout(): PageLayout {
return '' return ''
// #endif // #endif
} }
const navigationActionStyle = (headerHeight: number): string => { const navigationActionStyle = (headerHeight: number, actionCount: number): string => {
// 微信通知按钮与系统胶囊等高、顶部对齐;App 保持导航行内居中。 // 微信操作按钮与胶囊等高、顶部对齐,整组向左展开;App 保持导航行内居中。
// #ifdef MP-WEIXIN // #ifdef MP-WEIXIN
const size = capsuleHeight.value > 0 ? capsuleHeight.value : 32 const size = capsuleHeight.value > 0 ? capsuleHeight.value : 32
const top = capsuleHeight.value > 0 ? Math.max(0, capsuleTop.value - status()) : Math.max(0, (rpx(headerHeight) - size) / 2) const top = capsuleHeight.value > 0 ? Math.max(0, capsuleTop.value - status()) : Math.max(0, (rpx(headerHeight) - size) / 2)
const right = capsuleLeft.value > 0 ? windowInfo.value.windowWidth - capsuleLeft.value + 8 : 104 const right = capsuleLeft.value > 0 ? windowInfo.value.windowWidth - capsuleLeft.value + 8 : 104
return 'position:absolute;top:' + top + 'px;right:' + right + 'px;width:' + size + 'px;height:' + size + 'px;' const width = size * actionCount + rpx(16) * Math.max(0, actionCount - 1)
return 'position:absolute;top:' + top + 'px;right:' + right + 'px;width:' + width + 'px;height:' + size + 'px;'
// #endif // #endif
// #ifndef MP-WEIXIN // #ifndef MP-WEIXIN
return '' return ''
+1
View File
@@ -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"><circle cx="10.5" cy="10.5" r="6.5"/><path d="m15.5 15.5 5 5"/></svg>

After

Width:  |  Height:  |  Size: 225 B