diff --git a/README.md b/README.md index f33dd34..91aeeb6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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`。新增页面应复用这里的逻辑,并检查小屏、横屏、键盘弹出及底部安全区。 - 微信小程序首页在右侧预留胶囊区域与 8px 间距;公共页面标题采用左右对称的安全留白,始终按屏幕中心定位。获取胶囊失败时按 104px 避让。产品分享入口在小程序端放在内容区,App 和 Web 仍保留在标题栏右侧。导航不新增顶部空行,顶部按状态栏和安全区计算。 -- 首页右上角只保留消息通知;微信端通知按钮与胶囊等高、顶部对齐,左侧 Logo 对齐胶囊的垂直中心;其他端仍在导航栏内居中。AI 助手保留首页横幅及快捷入口。 +- 首页右上角依次为搜索、消息通知;微信端两个按钮与胶囊等高、顶部对齐,按钮组向左展开,左侧 Logo 对齐胶囊的垂直中心;其他端仍在导航栏内居中。AI 助手保留首页横幅及快捷入口。 - 原生样式遵循 uni-app x 支持范围。仅 Web/小程序支持的样式用条件编译隔离;修改 UTS 类型后应重新验证 Android 编译。 - 首页及跨包共用的轻量位图位于 `static/mobile/`,专属大图位于各分包的 `static/`,原始图片保留在 `static/images/` 与 `static/icons-generated/`。箭头继续使用 `static/icons/` 下的 SVG。 - 底栏使用 `static/mobile/navigation/` 的专用位图,由现有高清图标按可见边界裁切、等高居中得到。更换素材时保持一致的图案高度和画布留白,避免仅统一图片元素尺寸却出现视觉错位。 @@ -44,18 +44,33 @@ $projectPath = (Get-Location).Path 个人与企业评估分别保存本机草稿;选图仅供本机预览,资料上传和数据查询尚未接入。课程章节进度、消息已读与体验签到均保存在当前设备,不代表服务器账户数据。 +个人中心顶部的头像、昵称、手机号区域统一进入修改资料;账户安全独立提供修改密码、验证码重设密码及换绑手机号。昵称和头像按当前手机号隔离保存在本机,头像使用 500KB 以内的 JPG / PNG / WebP 数据,不保存会失效的临时路径,不修改融资档案的实名信息。真实账户认证尚未接入,修改密码和换绑手机号不会在本机模拟成功,也不保存密码、验证码或其散列。安全接口成功后清除当前会话并要求重新登录;验证码计时仅在服务端发送成功后启动。 + +账户接口集中在 `packages/account/services/account.uts`,请求携带当前会话的 `Authorization: Bearer `;后端需根据 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` 为年化百分比数值。申请入口目前仅引导准备资料,不向金融机构提交申请。 +首页搜索进入内容分包的 `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` | 首页 | 1 | | `packages/finance` | 融资入口、评估、确认、分析、报告、匹配、产品、助手、档案 | 9 | -| `packages/account` | 启动引导、登录、个人中心、积分、消息 | 5 | -| `packages/content` | 新闻、文章、融资课堂、课程详情 | 4 | +| `packages/account` | 启动引导、登录、个人中心、修改资料、账户安全、修改密码、更换手机号、积分、消息 | 9 | +| `packages/content` | 搜索、帮助与反馈、新闻、文章、融资课堂、课程详情 | 6 | 当前各模块体积较小,优先保持完整业务链;普通内容统一一个包。新增功能先放入所属分包,再根据微信编译产物大小决定是否细分。单包以 2 MiB 为保守预算,需预留增长空间,最终体积以微信开发者工具为准。 diff --git a/manifest.json b/manifest.json index 6c79cd1..dc59dff 100644 --- a/manifest.json +++ b/manifest.json @@ -1,73 +1,81 @@ { - "name" : "ai融资", - "appid" : "__UNI__F40DD82", - "description" : "", - "versionName" : "1.0.0", - "versionCode" : "100", - "uni-app-x" : { - "styleIsolationVersion" : "2" - }, - /* 快应用特有相关 */ - "quickapp" : {}, - /* 小程序特有相关 */ - "mp-weixin" : { - "appid" : "", - "packOptions" : { - "ignore" : [ - { "type": "folder", "value": "static/images" }, - { "type": "folder", "value": "static/icons-generated" }, - { "type": "file", "value": "static/logo.png" } - ] - }, - "setting" : { - "urlCheck" : false - }, - "usingComponents" : true - }, - "mp-alipay" : { - "usingComponents" : true - }, - "mp-baidu" : { - "usingComponents" : true - }, - "mp-toutiao" : { - "usingComponents" : true - }, - "uniStatistics" : { - "enable" : false - }, - "vueVersion" : "3", - "app" : { - "distribute" : { - "icons" : { - "android" : { - "hdpi" : "", - "xhdpi" : "", - "xxhdpi" : "", - "xxxhdpi" : "" - } - } - } - }, - "app-android" : { - "distribute" : { - "modules" : {}, - "icons" : { - "hdpi" : "", - "xhdpi" : "", - "xxhdpi" : "", - "xxxhdpi" : "" - }, - "splashScreens" : { - "default" : {} - } - } - }, - "app-ios" : { - "distribute" : { - "modules" : {}, - "icons" : {}, - "splashScreens" : {} - } - } -} + "name": "ai融资", + "appid": "__UNI__204876E", + "description": "", + "versionName": "1.0.0", + "versionCode": "100", + "uni-app-x": { + "styleIsolationVersion": "2" + }, + /* 快应用特有相关 */ + "quickapp": {}, + /* 小程序特有相关 */ + "mp-weixin": { + "appid": "wx8e92cbd69e97d2b8", + "packOptions": { + "ignore": [{ + "type": "folder", + "value": "static/images" + }, + { + "type": "folder", + "value": "static/icons-generated" + }, + { + "type": "file", + "value": "static/logo.png" + } + ] + }, + "setting": { + "urlCheck": false + }, + "usingComponents": true + }, + "mp-alipay": { + "usingComponents": true + }, + "mp-baidu": { + "usingComponents": true + }, + "mp-toutiao": { + "usingComponents": true + }, + "uniStatistics": { + "enable": false + }, + "vueVersion": "3", + "app": { + "distribute": { + "icons": { + "android": { + "hdpi": "", + "xhdpi": "", + "xxhdpi": "", + "xxxhdpi": "" + } + } + } + }, + "app-android": { + "distribute": { + "modules": {}, + "icons": { + "hdpi": "", + "xhdpi": "", + "xxhdpi": "", + "xxxhdpi": "" + }, + "splashScreens": { + "default": {} + } + } + }, + "app-ios": { + "distribute": { + "modules": {}, + "icons": {}, + "splashScreens": {} + } + } +} \ No newline at end of file diff --git a/packages/account/change-phone/change-phone.uvue b/packages/account/change-phone/change-phone.uvue new file mode 100644 index 0000000..7926bc2 --- /dev/null +++ b/packages/account/change-phone/change-phone.uvue @@ -0,0 +1,38 @@ + + + diff --git a/packages/account/components/SecurityCode.uvue b/packages/account/components/SecurityCode.uvue new file mode 100644 index 0000000..1572c28 --- /dev/null +++ b/packages/account/components/SecurityCode.uvue @@ -0,0 +1,31 @@ + + + diff --git a/packages/account/edit-profile/edit-profile.uvue b/packages/account/edit-profile/edit-profile.uvue new file mode 100644 index 0000000..e5500ba --- /dev/null +++ b/packages/account/edit-profile/edit-profile.uvue @@ -0,0 +1,83 @@ + + + diff --git a/packages/account/messages/messages.uvue b/packages/account/messages/messages.uvue index 5b5a229..420256b 100644 --- a/packages/account/messages/messages.uvue +++ b/packages/account/messages/messages.uvue @@ -7,7 +7,7 @@ {{ unreadCount > 0 ? unreadCount + ' 条未读消息' : '消息已全部阅读' }}全部已读 - + {{ item.title }}{{ item.time }}{{ item.desc }}{{ item.category }} @@ -22,7 +22,6 @@ import { usePageLayout } from '@/services/layout.uts' import AppHeader from '@/components/AppHeader.uvue' import BottomNav from '@/components/BottomNav.uvue' - import AppIcon from '@/components/AppIcon.uvue' const { contentStyle, pageStyle } = usePageLayout() const tabs: string[] = ['全部', '系统通知', '服务通知', '活动消息'] const tab = ref('全部') @@ -67,15 +66,20 @@ .read-all { padding: 14rpx 0 14rpx 24rpx; } .read-all-text { color: #0868f7; font-size: 23rpx; } .notice-list { background-color: #ffffff; border-radius: 18rpx; padding: 0 20rpx; } - .notice { padding: 26rpx 0; border-bottom: 1rpx solid #e8eef6; flex-direction: row; align-items: flex-start; } + .notice { padding: 26rpx 0; border-bottom: 1rpx solid #e8eef6; flex-direction: row; align-items: center; } .last-notice { border-bottom-width: 0; } - .notice-icon { width: 70rpx; height: 70rpx; align-items: center; justify-content: center; margin-right: 18rpx; position: relative; flex-shrink: 0; } - .notice-copy { flex: 1; } + .notice-icon { width: 76rpx; height: 76rpx; margin-right: 20rpx; position: relative; flex-shrink: 0; } + .notice-icon-surface { width: 100%; height: 100%; border-radius: 20rpx; background-color: #edf5ff; align-items: center; justify-content: center; } + .notice-icon-payout .notice-icon-surface { background-color: #eaf7f3; } + .notice-icon-gift .notice-icon-surface { background-color: #fff3e8; } + .notice-symbol { width: 46rpx; height: 46rpx; flex-shrink: 0; } + .notice-copy { flex: 1; min-width: 0; } .notice-head { flex-direction: row; justify-content: space-between; align-items: flex-start; } .notice-title { color: #172640; font-size: 28rpx; line-height: 40rpx; font-weight: 600; flex: 1; } .notice-time { color: #66758a; font-size: 22rpx; line-height: 40rpx; margin-left: 12rpx; flex-shrink: 0; } .notice-desc { color: #66758a; font-size: 25rpx; line-height: 38rpx; margin-top: 10rpx; } .notice-category { color: #66758a; font-size: 22rpx; line-height: 32rpx; margin-top: 10rpx; } - .unread { position: absolute; width: 15rpx; height: 15rpx; border-radius: 8rpx; background-color: #f04452; border: 2rpx solid #ffffff; right: 0; top: 0; } + /* 红点独立于圆角背景,避免跨端圆角裁切。 */ + .unread { position: absolute; width: 15rpx; height: 15rpx; border-radius: 8rpx; background-color: #f04452; border: 2rpx solid #ffffff; right: -3rpx; top: -3rpx; z-index: 1; } .message-footer { color: #66758a; font-size: 22rpx; text-align: center; margin-top: 28rpx; } diff --git a/packages/account/password/password.uvue b/packages/account/password/password.uvue new file mode 100644 index 0000000..988825d --- /dev/null +++ b/packages/account/password/password.uvue @@ -0,0 +1,53 @@ + + + diff --git a/packages/account/profile/profile.uvue b/packages/account/profile/profile.uvue index 233d982..650d9a5 100644 --- a/packages/account/profile/profile.uvue +++ b/packages/account/profile/profile.uvue @@ -3,7 +3,7 @@ - {{ displayName }}{{ loggedIn ? maskedPhone : '登录后管理您的融资信息' }} + {{ displayName }}{{ loggedIn ? maskedPhone : '登录后管理您的融资信息' }} {{ completion }}%档案完整度{{ hasReport ? '已生成' : '暂无' }}评估报告积分中心 {{ item.name }}{{ item.note }} @@ -20,23 +20,24 @@ import BottomNav from '@/components/BottomNav.uvue' import AppIcon from '@/components/AppIcon.uvue' import { clearSession, getSession } from '@/services/api.uts' - import { activeAssessmentType, loadAssessmentDraft, draftText, assessmentProgress, showAgreementNotice } from '@/services/assessment.uts' + import { activeAssessmentType, assessmentProgress, showAgreementNotice } from '@/services/assessment.uts' + import { DEFAULT_AVATAR, getAccountProfile, maskPhone } from '@/packages/account/services/account.uts' const { contentStyle, pageStyle } = usePageLayout() type ProfileMenu = { icon: string, name: string, note: string, key: string } const menus: ProfileMenu[] = [{ icon: 'archive', name: '我的融资档案', note: '', key: 'archive' }, { icon: 'shield', name: '账户安全', note: '', key: 'security' }, { icon: 'privacy', name: '隐私与授权', note: '', key: 'privacy' }, { icon: 'help', name: '帮助与反馈', note: '', key: 'help' }, { icon: 'info', name: '关于我们', note: 'V1.0.0', key: 'about' }] const loggedIn = ref(false) const displayName = ref('登录 / 注册') const maskedPhone = ref('') + const avatar = ref('') const completion = ref(0) const hasReport = ref(false) onShow(() => { const session = getSession() loggedIn.value = session != null - const phone = session != null ? draftText(session, 'phone') : '' - maskedPhone.value = phone.length >= 7 ? phone.substring(0, 3) + '****' + phone.substring(phone.length - 4) : phone - const type = activeAssessmentType(); const saved = loadAssessmentDraft(type) - displayName.value = loggedIn.value ? '融资用户' : '登录 / 注册' - if (loggedIn.value && saved != null && draftText(saved, 'phone') == phone && draftText(saved, 'name').length > 0) displayName.value = draftText(saved, 'name') + const profile = getAccountProfile() + maskedPhone.value = maskPhone(profile.phone); avatar.value = loggedIn.value ? profile.avatar : '' + const type = activeAssessmentType() + displayName.value = loggedIn.value ? profile.nickname : '登录 / 注册' completion.value = assessmentProgress(type).percent const latest = uni.getStorageSync('latestAssessment') hasReport.value = latest != null && typeof latest == 'object' @@ -45,12 +46,12 @@ const openReport = () => { if (hasReport.value) go('/packages/finance/report/report'); else uni.showModal({ title: '暂无评估报告', content: '完善融资信息后,即可体验融资评估与方案匹配。', confirmText: '去完善', success: (result) => { if (result.confirm) go('/packages/finance/archive/archive') } }) } const open = (key: string) => { if (key == 'archive') go('/packages/finance/archive/archive') - else if (key == 'help') go('/packages/finance/assistant/assistant') - else if (key == 'security') uni.showModal({ title: '账户安全', content: loggedIn.value ? '当前账户:' + maskedPhone.value + '\n请妥善保管账户信息,不要向他人透露密码或验证码。' : '当前尚未登录,请先使用手机号登录。', showCancel: false, confirmText: '我知道了' }) + else if (key == 'help') go('/packages/content/help/help') + else if (key == 'security') go(loggedIn.value ? '/packages/account/security/security' : '/packages/account/login/login') else if (key == 'privacy') uni.showActionSheet({ itemList: ['查看隐私说明', '管理资料与授权'], success: (result) => { if (result.tapIndex == 0) showAgreementNotice('隐私说明'); else if (result.tapIndex == 1) { uni.setStorageSync('assessmentEditStep', 3); go('/packages/finance/assessment/assessment?type=' + activeAssessmentType()) } } }) else if (key == 'about') uni.showModal({ title: '关于 AI融资', content: '版本 V1.0.0\n帮助您整理融资信息、了解融资知识与融资方案。评估结果仅供参考,不构成授信承诺。', showCancel: false, confirmText: '我知道了' }) } - const openAccount = () => { if (!loggedIn.value) go('/packages/account/login/login'); else open('security') } + const openAccount = () => { go(loggedIn.value ? '/packages/account/edit-profile/edit-profile' : '/packages/account/login/login') } const logout = () => { if (!loggedIn.value) { go('/packages/account/login/login'); return } uni.showModal({ title: '退出登录', content: '退出后,本机保存的评估草稿仍会保留。', confirmText: '退出登录', success: (result) => { if (result.confirm) { clearSession(); uni.reLaunch({ url: '/packages/account/login/login' }) } } }) @@ -61,6 +62,7 @@ .profile-content { padding: 26rpx 30rpx 90rpx; } .profile-card { min-height: 166rpx; padding: 28rpx 24rpx; border-radius: 18rpx 18rpx 0 0; background-color: #fff; flex-direction: row; align-items: center; } .avatar { width: 84rpx; height: 84rpx; align-items: center; justify-content: center; margin-right: 20rpx; flex-shrink: 0; } + .profile-avatar { width: 84rpx; height: 84rpx; border-radius: 42rpx; background-color: #eaf2ff; } .profile-main { flex: 1; min-width: 0; } .profile-name { color: #23344e; font-size: 32rpx; font-weight: 700; } .profile-phone { color: #8190a4; font-size: 24rpx; line-height: 36rpx; margin-top: 8rpx; } diff --git a/packages/account/security/security.uvue b/packages/account/security/security.uvue new file mode 100644 index 0000000..029a341 --- /dev/null +++ b/packages/account/security/security.uvue @@ -0,0 +1,43 @@ + + + diff --git a/packages/account/services/account.uts b/packages/account/services/account.uts new file mode 100644 index 0000000..40f8801 --- /dev/null +++ b/packages/account/services/account.uts @@ -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 { + return new Promise((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 { + 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 { + 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 { + 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 { + 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 { + return new Promise((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 + }) +} diff --git a/packages/account/static/default-avatar.png b/packages/account/static/default-avatar.png new file mode 100644 index 0000000..09b9aed Binary files /dev/null and b/packages/account/static/default-avatar.png differ diff --git a/packages/account/static/notifications/approval.svg b/packages/account/static/notifications/approval.svg new file mode 100644 index 0000000..cf18065 --- /dev/null +++ b/packages/account/static/notifications/approval.svg @@ -0,0 +1 @@ + diff --git a/packages/account/static/notifications/gift.svg b/packages/account/static/notifications/gift.svg new file mode 100644 index 0000000..79f3934 --- /dev/null +++ b/packages/account/static/notifications/gift.svg @@ -0,0 +1 @@ + diff --git a/packages/account/static/notifications/payout.svg b/packages/account/static/notifications/payout.svg new file mode 100644 index 0000000..fac1b71 --- /dev/null +++ b/packages/account/static/notifications/payout.svg @@ -0,0 +1 @@ + diff --git a/packages/account/static/notifications/system-bell.svg b/packages/account/static/notifications/system-bell.svg new file mode 100644 index 0000000..6e2cfe0 --- /dev/null +++ b/packages/account/static/notifications/system-bell.svg @@ -0,0 +1 @@ + diff --git a/packages/account/styles/forms.css b/packages/account/styles/forms.css new file mode 100644 index 0000000..9ae8b2b --- /dev/null +++ b/packages/account/styles/forms.css @@ -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; } diff --git a/packages/content/help/help.uvue b/packages/content/help/help.uvue new file mode 100644 index 0000000..d8710b5 --- /dev/null +++ b/packages/content/help/help.uvue @@ -0,0 +1,80 @@ + + + diff --git a/packages/content/search/search.uvue b/packages/content/search/search.uvue new file mode 100644 index 0000000..c3f262a --- /dev/null +++ b/packages/content/search/search.uvue @@ -0,0 +1,114 @@ + + + + + diff --git a/packages/content/services/help.uts b/packages/content/services/help.uts new file mode 100644 index 0000000..fb25615 --- /dev/null +++ b/packages/content/services/help.uts @@ -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 { + 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: '我知道了' }) +} diff --git a/packages/content/services/search.uts b/packages/content/services/search.uts new file mode 100644 index 0000000..17d3c3c --- /dev/null +++ b/packages/content/services/search.uts @@ -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 { + 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 +} diff --git a/packages/finance/archive/archive.uvue b/packages/finance/archive/archive.uvue index 041bd2b..ae2a042 100644 --- a/packages/finance/archive/archive.uvue +++ b/packages/finance/archive/archive.uvue @@ -23,7 +23,7 @@ const archiveItems = computed((): ArchiveItem[] => [ { 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: '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 } ]) const refresh = () => { progress.value = assessmentProgress(type.value) } diff --git a/packages/finance/assessment/assessment.uvue b/packages/finance/assessment/assessment.uvue index eb512bd..19508ca 100644 --- a/packages/finance/assessment/assessment.uvue +++ b/packages/finance/assessment/assessment.uvue @@ -31,11 +31,17 @@ - - - - - 其他资产 + 贷款信息 + 信用贷款笔数笔 + 欠款总余额元 + 贷款月还款金额元 + 信用卡信息 + 银行信用卡张数张 + 总额度元 + 已使用总额度元 + 房车信息 + + 资料选择 (选填) @@ -56,7 +62,7 @@ import AppHeader from '@/components/AppHeader.uvue' import AppIcon from '@/components/AppIcon.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 showAgreement = (title: string) => { showAgreementNotice(title) } const step = ref(0) @@ -74,7 +80,7 @@ const saveDraft = () => { if (!initialized) return 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() 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 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 && authorizations.value.some((item) => item.selected) && !dataConsent.value) message = '请阅读并同意数据授权协议,或取消授权选择' if (message.length == 0) return true @@ -167,6 +174,8 @@ .type-active { color: #0868f7; font-weight: 600; background-color: #fff; } .section-hint { color: #738198; font-size: 24rpx; line-height: 36rpx; margin-top: 30rpx; } .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; } .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; } diff --git a/packages/finance/confirm/confirm.uvue b/packages/finance/confirm/confirm.uvue index d8bfd5e..b64bc38 100644 --- a/packages/finance/confirm/confirm.uvue +++ b/packages/finance/confirm/confirm.uvue @@ -19,7 +19,7 @@ import { usePageLayout } from '@/services/layout.uts' import AppHeader from '@/components/AppHeader.uvue' 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() type InfoRow = { label: string, value: string } 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 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 valueWithUnit = (saved: UTSJSONObject, key: string, unit: string): string => { const value = draftText(saved, key); return value.length > 0 ? value + ' ' + unit : '未填写' } const loadDraft = () => { assessmentType.value = activeAssessmentType() const saved = loadAssessmentDraft(assessmentType.value) @@ -62,7 +63,16 @@ sections.value = [ { title: '基本信息', step: 0, rows: basicRows, 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 } ] 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 } 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 } + const assetError = assetValidationMessage(formFromDraft(saved)) + if (assetError.length > 0) { uni.showToast({ title: assetError, icon: 'none' }); edit(2); return } submitting.value = true try { await submitAssessment(payload.value); uni.redirectTo({ url: '/packages/finance/analyzing/analyzing' }) } catch (error) { uni.showToast({ title: error instanceof Error ? error.message : '提交失败,请重试', icon: 'none' }) } diff --git a/pages.json b/pages.json index 227febe..655cc4c 100644 --- a/pages.json +++ b/pages.json @@ -23,6 +23,10 @@ { "path": "index/index", "style": { "navigationStyle": "custom" } }, { "path": "login/login", "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": "messages/messages", "style": { "navigationStyle": "custom" } } ] @@ -30,6 +34,8 @@ { "root": "packages/content", "pages": [ + { "path": "search/search", "style": { "navigationStyle": "custom" } }, + { "path": "help/help", "style": { "navigationStyle": "custom" } }, { "path": "news/news", "style": { "navigationStyle": "custom" } }, { "path": "article/article", "style": { "navigationStyle": "custom" } }, { "path": "classroom/classroom", "style": { "navigationStyle": "custom" } }, diff --git a/pages/home/home.uvue b/pages/home/home.uvue index b436e5a..a4f8756 100644 --- a/pages/home/home.uvue +++ b/pages/home/home.uvue @@ -4,8 +4,11 @@ - - + + + + + @@ -60,11 +63,12 @@ .safe { } .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; } - .header-actions { width: 62rpx; height: 62rpx; margin-left: 16rpx; flex-shrink: 0; } - .round-action { width: 100%; height: 100%; position: relative; flex-shrink: 0; } - .action-surface { width: 100%; height: 100%; border-radius: 50%; background-color: #eef5ff; align-items: center; justify-content: center; } + .header-actions { width: 140rpx; height: 62rpx; margin-left: 16rpx; flex-shrink: 0; flex-direction: row; } + .round-action { flex: 1; height: 100%; position: relative; } + .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; } - /* 红点与圆形背景同级,避免被跨端 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; } .home-scroll { } .home-content { padding: 18rpx 28rpx 180rpx; } diff --git a/services/assessment.uts b/services/assessment.uts index 0191ad2..2d71e14 100644 --- a/services/assessment.uts +++ b/services/assessment.uts @@ -2,13 +2,15 @@ export type AssessmentForm = { name: string, idCard: string, phone: string, city: string, maritalStatus: string, occupation: string, company: string, monthlyIncome: string, socialSecurity: 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 } export type SelectedDocument = { name: string, path: string } export type DataAuthorization = { key: string, name: string, selected: boolean } 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 { const value = saved[key] @@ -35,10 +37,32 @@ export function formFromDraft(saved: UTSJSONObject): AssessmentForm { return { 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'), - 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') } } +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[] { const results: SelectedDocument[] = [] 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, 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, + 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, 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 } 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 assets = ['house', 'car', 'savings', 'creditCard'] + const assets = ['house', 'car', 'creditLoanCount', 'totalDebtBalance', 'monthlyLoanPayment', 'creditCardCount', 'creditCardTotalLimit', 'creditCardUsedLimit'] const keys = basic.concat(income).concat(assets) const filled = keys.filter((key) => draftText(saved, key).trim().length > 0).length 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) } } diff --git a/services/layout.uts b/services/layout.uts index 7777a4d..56f3627 100644 --- a/services/layout.uts +++ b/services/layout.uts @@ -7,7 +7,7 @@ export type PageLayout = { statusStyle: (offset: number) => string, navigationStyle: () => string, navigationTitleStyle: () => string, - navigationActionStyle: (headerHeight: number) => string, + navigationActionStyle: (headerHeight: number, actionCount: number) => string, navigationBrandStyle: (headerHeight: number) => string, bottomStyle: (padding: number) => string } @@ -63,13 +63,14 @@ export function usePageLayout(): PageLayout { return '' // #endif } - const navigationActionStyle = (headerHeight: number): string => { - // 微信通知按钮与系统胶囊等高、顶部对齐;App 保持导航行内居中。 + const navigationActionStyle = (headerHeight: number, actionCount: number): string => { + // 微信操作按钮与胶囊等高、顶部对齐,整组向左展开;App 保持导航行内居中。 // #ifdef MP-WEIXIN 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 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 // #ifndef MP-WEIXIN return '' diff --git a/static/icons/search.svg b/static/icons/search.svg new file mode 100644 index 0000000..ba88515 --- /dev/null +++ b/static/icons/search.svg @@ -0,0 +1 @@ +