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
+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
}