【搜索页】- 功能流程

embedded/2025/3/22 8:51:51/

【搜索页】- 功能流程

【搜索组件】- 改造搜索组件HdSearch

src/main/ets/common/components/HdSearch.ets

课程目标

  • 直接将搜索关键字写死在keywords数组中:keywords:string[]=['html','css','js','vue','react']
  • 使用setInterval实现每隔3秒完成题目分类数据的切换
  • 使用router传参的方式将题目分类名称传给搜索页SearchPage.ets

步骤:

  1. 改造HdSearch.ets组件
import { router } from '@kit.ArkUI'@Component
export struct HdSearch {@StatereadonlyMode: boolean = true@Stateph: string = 'html'bg: string = ''color: string = ''keywords: string[] = ['html', 'css3', 'javascript', 'vue', 'react'] // 用来给轮播使用的数据index: number = 0 //表示取得数组的索引号aboutToAppear(): void {// 页面加载完毕就开启定时器setInterval(() => {// 索引先+this.index++// 判断等于数组长度就重置索引if (this.index === this.keywords.length) {this.index = 0}// 从数组中获取当前索引的关键字this.ph = this.keywords[this.index]}, 3000)}build() {Row() {Row({ space: 4 }) {Image($r("app.media.ic_common_search")).width($r('app.float.hd_search_icon_size')).aspectRatio(1).fillColor(this.color || $r('app.color.common_gray_02'))Text(this.ph || $r('app.string.hd_search_placeholder')).fontColor(this.color || $r('app.color.common_gray_02')).fontSize($r('app.float.common_font14'))}.layoutWeight(1).height($r('app.float.hd_search_height')).backgroundColor(this.bg || $r('app.color.common_gray_border')).borderRadius($r('app.float.hd_search_radius')).justifyContent(FlexAlign.Center)}.onClick(() => {router.pushUrl({ url: 'pages/SearchPage',params:{keywordText:this.ph} })})}
}
  1. 新建SearchPage.ets 接受路由传参
import { router } from '@kit.ArkUI';interface iRouterParams {keywordText: string
}@Entry
@Component
struct SearchPage {@State message: string = 'Hello World';aboutToAppear(): void {let params = router.getParams() as iRouterParamsthis.message = params.keywordText}build() {Row() {Column() {Text(this.message).fontSize(50).fontWeight(FontWeight.Bold)}.width('100%')}.height('100%')}
}

【搜索页】- 静态结构(SearchPage.ets)

课程目标

  • 首选项综合案例上代码作为静态模版
import { promptAction } from '@kit.ArkUI'
import { preferences } from '@kit.ArkData'@Entry
@Component
struct PreDemo {@State keyword: string = ''@StorageProp('topHeight') topHeight: number = 0@State keywords: string[] = []aboutToAppear() {this.getData()}@BuilderitemBuilder(text: string) {Row({ space: 20 }) {Text(text)Text('x').height(30).onClick(async () => {//  删除指定关键字await this.delData(text)await this.getData()})}.margin({ right: 10, top: 10 }).padding({ left: 15, right: 15, top: 5, bottom: 5 }).backgroundColor('rgba(0,0,0,0.05)').borderRadius(20)}// 1.0 新增方法async saveData(text: string) {// 非空验证if (!text) {return}// 1.0 获取首选项对象实例const pre = preferences.getPreferencesSync(getContext(), { name: 'store1' })// 2.0 调用putsync方法保存数组// 2.0.1 先从首选项中获取老数据let dataArr = pre.getSync('keyword', []) as string[]// 判断如果首选项中已经有了该关键词,不再保存if (dataArr.includes(text)) {// 该关键字已经存在了,不保存return}dataArr.push(text)// 2.0.2 存数据pre.putSync('keyword', dataArr)// 3.0 调用flush写入到磁盘await pre.flush()}// 2.0 获取首选项数据async getData() {const pre = preferences.getPreferencesSync(getContext(), { name: 'store1' })this.keywords = pre.getSync('keyword', []) as string[]}// 3.0 删除指定关键字和全部关键字async delData(text?: string) {const pre = preferences.getPreferencesSync(getContext(), { name: 'store1' })//   1.0 删除全部关键if (!text) {pre.deleteSync('keyword')} else {// 2.0 删除指定关键字  获取 -> 删除内存数组的关键字 -> 写回let datas = pre.getSync('keyword', []) as string[]let cindex = datas.findIndex(item => item === text)// 当关键字索引为-1的时候,表示没有找到if (cindex < 0) {return}// 如果有删除datas.splice(cindex, 1) // 返回值表示删除的元素//   保存回去pre.putSync('keyword', datas)}// 写回磁盘pre.flush()}build() {Navigation() {Column({ space: 15 }) {// 1. 搜索关键字TextInput({ placeholder: '输入回车保存数据', text: $$this.keyword }).onSubmit(async () => {// AlertDialog.show({ message: this.keyword })//   将关键词数据保存到首选项中await this.saveData(this.keyword)await this.getData()})// 2. 关键字列表Row() {Text('搜索记录').fontSize(20).fontWeight(800)Row() {Text('全部删除').onClick(async () => {// 删除全局数据await this.delData()await this.getData()})Text(' | ')Text('取消删除')Image($r('app.media.ic_common_delete')).height(28)}}.width('100%').justifyContent(FlexAlign.SpaceBetween)//   3. 关键字列表Flex({ wrap: FlexWrap.Wrap }) {ForEach(this.keywords, (item: string) => {this.itemBuilder(item)})}}.padding(15)}.padding({ top: this.topHeight }).titleMode(NavigationTitleMode.Mini).title('搜索页面')}
}

【搜索页】- 向首选项中存储路由传入的关键字

课程目标

  • HdSearch.ets组件通过路由给SearchPage传入关键字
  • SearchPage接收路由参数关键字,将数据保存到首选项

注意:不能将@State修饰的状态变量数组存储到首选项,会报错

import { router } from '@kit.ArkUI'@Component
export struct HdSearch {@StatereadonlyMode: boolean = true@Stateph: string = 'html'bg: string = ''color: string = ''keywords: string[] = ['html', 'css3', 'javascript', 'vue', 'react'] // 用来给轮播使用的数据index: number = 0 //表示取得数组的索引号aboutToAppear(): void {// 页面加载完毕就开启定时器setInterval(() => {// 索引先+this.index++// 判断等于数组长度就重置索引if (this.index === this.keywords.length) {this.index = 0}// 从数组中获取当前索引的关键字this.ph = this.keywords[this.index]}, 3000)}build() {Row() {Row({ space: 4 }) {Image($r("app.media.ic_common_search")).width($r('app.float.hd_search_icon_size')).aspectRatio(1).fillColor(this.color || $r('app.color.common_gray_02'))Text(this.ph || $r('app.string.hd_search_placeholder')).fontColor(this.color || $r('app.color.common_gray_02')).fontSize($r('app.float.common_font14'))}.layoutWeight(1).height($r('app.float.hd_search_height')).backgroundColor(this.bg || $r('app.color.common_gray_border')).borderRadius($r('app.float.hd_search_radius')).justifyContent(FlexAlign.Center)}.onClick(() => {router.pushUrl({ url: 'pages/SearchPage',params:{keywordText:this.ph} })})}
}
import { promptAction } from '@kit.ArkUI'
import { preferences } from '@kit.ArkData'
import { router } from '@kit.ArkUI';interface iRouterParams {keywordText: string
}@Entry
@Component
struct SearchPage {@State keyword: string = ''@StorageProp('topHeight') topHeight: number = 0@State keywords: string[] = []@State isdel: boolean = falseasync aboutToAppear() {let params = router.getParams() as iRouterParamsthis.keyword = params.keywordText // 接收路由参数// 将路由传入的参数关键字保存到首选项await this.saveData(params.keywordText)await this.getData()}@BuilderitemBuilder(text: string) {Row({ space: 20 }) {Text(text)if (this.isdel) {Text('x').height(30).onClick(async () => {//  删除指定关键字await this.delData(text)await this.getData()})}}.margin({ right: 10, top: 10 }).padding({ left: 15, right: 15, top: 5, bottom: 5 }).backgroundColor('rgba(0,0,0,0.05)').borderRadius(20)}// 1.0 新增方法async saveData(text: string) {// 非空验证if (!text) {return}// 1.0 获取首选项对象实例const pre = preferences.getPreferencesSync(getContext(), { name: 'store1' })// 2.0 调用putsync方法保存数组// 2.0.1 先从首选项中获取老数据let dataArr = pre.getSync('keyword', []) as string[]// 判断如果首选项中已经有了该关键词,不再保存if (dataArr.includes(text)) {// 该关键字已经存在了,不保存return}dataArr.push(text)// 2.0.2 存数据pre.putSync('keyword', dataArr)// 3.0 调用flush写入到磁盘await pre.flush()}// 2.0 获取首选项数据async getData() {const pre = preferences.getPreferencesSync(getContext(), { name: 'store1' })this.keywords = pre.getSync('keyword', []) as string[]}// 3.0 删除指定关键字和全部关键字async delData(text?: string) {const pre = preferences.getPreferencesSync(getContext(), { name: 'store1' })//   1.0 删除全部关键if (!text) {pre.deleteSync('keyword')} else {// 2.0 删除指定关键字  获取 -> 删除内存数组的关键字 -> 写回let datas = pre.getSync('keyword', []) as string[]let cindex = datas.findIndex(item => item === text)// 当关键字索引为-1的时候,表示没有找到if (cindex < 0) {return}// 如果有删除datas.splice(cindex, 1) // 返回值表示删除的元素//   保存回去pre.putSync('keyword', datas)}// 写回磁盘pre.flush()}build() {Navigation() {Column({ space: 15 }) {// 1. 搜索关键字TextInput({ placeholder: '输入回车保存数据', text: $$this.keyword }).onSubmit(async () => {// AlertDialog.show({ message: this.keyword })//   将关键词数据保存到首选项中await this.saveData(this.keyword)await this.getData()})// 2. 关键字列表Row() {Text('搜索记录').fontSize(20).fontWeight(800)Row() {if (this.isdel) {Text('全部删除').onClick(async () => {// 删除全局数据await this.delData()await this.getData()})Text(' | ')Text('取消删除').onClick(() => {this.isdel = false})}Image($r('app.media.ic_common_delete')).height(28).onClick(() => {this.isdel = true})}}.width('100%').justifyContent(FlexAlign.SpaceBetween)//   3. 关键字列表Flex({ wrap: FlexWrap.Wrap }) {ForEach(this.keywords, (item: string) => {this.itemBuilder(item)})}}.padding(15)}.padding({ top: this.topHeight }).titleMode(NavigationTitleMode.Mini).title('搜索页面')}
}

【搜索页】- 根据关键字搜索数据

课程目标

  • 复用src/main/ets/views/home/QuestionListComp.ets页面,在QuestionListComp.ets里面的请求url上增加keyword关键字参数

import { preferences } from '@kit.ArkData'
import promptAction from '@ohos.promptAction';
import { router } from '@kit.ArkUI';
import { QuestionListComp } from '../views/home/QuestionListComp';interface iRouterParams {keywrod: string
}@Entry
@Component
struct SearchPage {@State keyword: string = '' // 搜索关键字@State kdList: string[] = []@StorageProp('topHeight') topHeight: number = 0@State isdel: boolean = false@State isSearch: boolean = falseasync aboutToAppear() {// 获取路由传入的关键字let routerObj = router.getParams() as iRouterParamsthis.keyword = routerObj.keywrodthis.isSearch = true //打开搜索页面// 将关键字保存到用户首选项中await this.saveKeyWord(routerObj.keywrod)// 页面进入即获取关键字数组显示this.kdList = await this.getKeyWords()}// 1. 保存用户首选项中的关键async saveKeyWord(keyword: string) {//   1. 去用户首选项中获取key为keyword的数据,字符串数组let pre = preferences.getPreferencesSync(getContext(), { name: 'store' })//   2. 向首选项中获取数据let kdArr = pre.getSync('keyword', []) as string[]// 判断kdArr数组中如果已经存在了keyword变量的值,则不再追加// ['html5','css3'] -> css3 --> 数组的 find,findindex,indexOf, includesif (kdArr.includes(keyword)) {return //不再追加}//   3. 利用push方法将用户输入的关键字追加到kdArr中kdArr.push(keyword)//   4. 利用putSync+flush方法完成写入pre.putSync('keyword', kdArr)await pre.flush()}// 2. 获取用户首选项中的关键字数组async getKeyWords() {let pre = preferences.getPreferencesSync(getContext(), { name: 'store' })return pre.getSync('keyword', []) as string[]}// 3. 删除(① 删除指定关键字  ② 全部删除)async delKeyWord(keywrod?: string) {let pre = preferences.getPreferencesSync(getContext(), { name: 'store' })if (keywrod) {//  删除指定的关键字//   1. 获取首选项的所有数据到内存中(字符串数组)let kdArr = pre.getSync('keyword', []) as string[]//   2. 根据已知的关键字keywrod 查找它在这个数组中的索引,同时调用splice删除之let index = kdArr.findIndex(item => item == keywrod)// ✨注意:如果 index < 0 则不删除if (index < 0) {promptAction.showToast({ message: '当前要删除掉关键字不存在' })return}kdArr.splice(index, 1)//   3. 调用 putSync将最新的数组写回文件pre.putSync('keyword', kdArr)} else {// 删除全部pre.deleteSync('keyword')}await pre.flush()}@BuilderitemBuilder(text: string) {Row({ space: 20 }) {Text(text)if (this.isdel) {Text('x').height(30).onClick(async () => {// 删除指定关键字await this.delKeyWord(text)// 重新筛选页面this.kdList = await this.getKeyWords()})}}.margin({ right: 10, top: 10 }).padding({left: 15,right: 15,top: 5,bottom: 5}).backgroundColor('rgba(0,0,0,0.05)').borderRadius(20)}build() {Navigation() {Column({ space: 15 }) {// 1. 搜索关键字TextInput({ placeholder: '输入回车保存数据', text: $$this.keyword }).onSubmit(async () => {// 关键字保存到首选项中await this.saveKeyWord(this.keyword)this.isSearch = true// 获取关键字数组显示this.kdList = await this.getKeyWords()}).onClick(() => {// 点击文本框关闭搜索组件this.isSearch = false})if (this.isSearch) {// 显示搜索记录组件(List)Row() {QuestionListComp({ keyword: this.keyword })}} else {// 2. 搜索记录Row() {Text('搜索记录').fontSize(20).fontWeight(800)Row() {if (this.isdel) {Text('全部删除').onClick(async () => {//  全部删除所以无需传参数await this.delKeyWord()// 刷新页面this.kdList = await this.getKeyWords()})Text(' | ')Text('取消删除').onClick(() => {this.isdel = false})} else {Image($r('app.media.ic_common_delete')).height(28).onClick(() => {this.isdel = true})}}}.width('100%').justifyContent(FlexAlign.SpaceBetween)//   3. 关键字列表Flex({ wrap: FlexWrap.Wrap }) {ForEach(this.kdList, (item: string) => {this.itemBuilder(item)})}}}.padding(15)}.padding({ top: this.topHeight }).titleMode(NavigationTitleMode.Mini).title('搜索页面')}
}
import { HdLoadingDialog } from '../../components/HdLoadingDialog'
import { HdHttp } from '../../utils/request'
import { QuestionItemComp, QuestionItem, iQuestion } from '../QuestionItemComp'
import { FilterParams } from './QuestionFilterComp'
import { router } from '@kit.ArkUI'@Component
export struct QuestionListComp {// 这个数据是从HomeCategoryComp中传入的(父 传 子)@Prop typeid: number = 0@State list: QuestionItem[] = []// 定义一个状态属性,用来和Refresh组件进行双向数据绑定@State isRefreshing: boolean = falsepage: number = 0 //当前列表数据的页码@State isloadMore: boolean = false // 加载更多@Prop keyword: string = ''// 实例化自定义弹窗对象dialog = new CustomDialogController({builder: HdLoadingDialog({ message: '正在加载中...' }),customStyle: true, //表示关闭CustomDialogController自己的样式,只显示组件定义的样式alignment: DialogAlignment.Center  //弹窗的位置在中间})// 定义一个参数,用@Watch来监听其变化,从而主动执行getList方法@Prop @Watch('getList') params: FilterParams = { index: 0, sort: 0 } as FilterParamsselfIndex: number = 0 // 应该由Cate组件传入,表示当前List组件是属于哪个分类索引下的组件// // source这个参数代表的是哪个参数的改变引起的监听器执行// loadData(source:string) {//   AlertDialog.show({ message: JSON.stringify('watch触发了' + source) })// }aboutToAppear(): void {// 注释原因:因为在List组件上增加了触底加载更多的方法onReachEnd,此时这个方法在页面进入的时候会执行一次,所以注释掉// aboutToAppear中的主动请求数据方法// this.getList()}async getList(p?: string) {// 优化:如果当前params中的index 和 List组件本身的index一致,应该执行请求,否则不允许执行if (this.params.index != this.selfIndex) {return}if (p == 'params') {this.page = 1}try {// 打开自定义弹窗this.dialog.open()let res = await HdHttp.Get<iQuestion>('hm/question/list', new Object({questionBankType: 10, //接口必选参数,默认填写10type: this.typeid, // 由于我们是要查看某个分类下面的数据,所以必须填写type参数page: this.page,sort: this.params.sort, //浏览量:20从低到高21从高到底  ,0:默认keyword: this.keyword  // 这是搜索关键字}))// 判断当前如果是触底加载更多,才追加到数据后面,如果是下拉刷新,应该直接赋值if (this.isloadMore) {this.list.push(...res.data.rows)} else {this.list = res.data.rows}// 增加将this.list中的所有id变成一个数组保存到AppStroage中// 1. 使用map将this.list数组中的id变成一个 ['12','13','25']let ids = this.list.map(item => item.id)// 2. 将ids数组保存到AppStroage中AppStorage.setOrCreate('list', ids)// 关闭下拉动画this.isRefreshing = falsethis.isloadMore = false //数据触底加载完成// 关闭自定义弹窗this.dialog.close()} catch (err) {// 关闭下拉动画this.isRefreshing = falsethis.isloadMore = false //数据触底加载完成// 关闭自定义弹窗this.dialog.close()}}build() {Column() {Text(this.typeid.toString()).fontSize(30)Refresh({ refreshing: $$this.isRefreshing }) {List() {ForEach(this.list, (item: QuestionItem) => {ListItem() {QuestionItemComp({item: item}).padding({ left: 10, right: 10 })}.onClick(() => {router.pushUrl({ url: 'pages/QuestionDetailPage', params: { id: item.id } })})})}.edgeEffect(EdgeEffect.None) // 关闭list组件的回弹效果,从而优化触底会发2次请求的问题.onReachEnd(() => {// 防止用户鼠标或者手指抖动if (this.isloadMore == false) {this.isloadMore = true// 页面进入即执行一次this.page++this.getList()}})}.onRefreshing(() => {// 重新请求服务器数据this.page = 1 //下拉刷新的时候,将当前页码重置为1this.getList()})}}
}

【搜索页】-2个优化

  1. SearchPage页面软键盘优化
import { promptAction, router } from '@kit.ArkUI'
import { preferences } from '@kit.ArkData'
import { QuestionListComp } from '../views/home/QuestionListComp'
import { FilterParams } from '../views/home/QuestionFilterComp'
import { Logger } from '../common/utils/Logger'interface iRouterParams {keywordText: string
}@Entry
@Component
struct SearchPage {@State keyword: string = ''@StorageProp('topHeight') topHeight: number = 0@State isdel: boolean = false@State kdList: string[] = []@State isSearch: boolean = true@StorageLink('filter')  params: FilterParams = { sort: 0, index: 0 }@State fsable:boolean = false@StorageLink('startInterval') startIntervalFlag : boolean = trueasync aboutToAppear() {this.kdList = await this.getData()// 接收搜索关键字const p = router.getParams() as iRouterParamsthis.keyword = p.keywordText}// 准备用户首选项的 保存数据方法+删除方法 + 获取方法async getData() {const pre = preferences.getPreferencesSync(getContext(), { name: 'keywordStore' })return pre.getSync('keyword', []) as string[] //['html5','css3'] -> 如果没有 []}// 保存数据async saveData(kdText: string) {const pre = preferences.getPreferencesSync(getContext(), { name: 'keywordStore' })// 1. 先从用户首选项获取老数据const oldData = pre.getSync('keyword', []) as string[]// 2. 如果kdText在用户首选项中存来了,就不存了if (oldData.includes(kdText)) {return}oldData.push(kdText)// 3. 将新数据保存到首选项中pre.putSync('keyword', oldData)//   4. 写入到文件pre.flushSync()}// 删除数据(指定文本删除,全部删除)async delData(kdText?: string) {const pre = preferences.getPreferencesSync(getContext(), { name: 'keywordStore' })if (kdText) {// 指定文本删除//   1. 获取老数据 2 删除指定的文本(内存中的数据) 3 将数据写回到磁盘const oldData = pre.getSync('keyword', []) as string[]const index = oldData.findIndex(item => item === kdText)// 对未找到的关键字不执行删除动作if (index < 0) {return}oldData.splice(index, 1)pre.putSync('keyword', oldData)pre.flushSync()} else {//   全部删除pre.deleteSync('keyword')pre.flushSync()}}@BuilderitemBuilder(text: string) {Row({ space: 20 }) {Text(text).onClick(()=>{this.isSearch = truethis.keyword = textthis.fsable = false})if (this.isdel) {Text('x').height(30).onClick(async () => {//   1. 调用删除方法await this.delData(text)//   2. 重新获取数据this.kdList = await this.getData()})}}.margin({ right: 10, top: 10 }).padding({left: 15,right: 15,top: 5,bottom: 5}).backgroundColor('rgba(0,0,0,0.05)').borderRadius(20)}onBackPress(): boolean | void {Logger.debug('onBackPress')this.startIntervalFlag = falsethis.startIntervalFlag = truereturn false  // 使用原有的路由方式来回退}build() {Navigation() {Column({ space: 15 }) {// 1. 搜索关键字TextInput({ placeholder: '输入回车保存数据', text: $$this.keyword })// 回车的时候会触发.onSubmit(async () => {// AlertDialog.show({ message: this.keyword })// 1. 将关键字保存到首选项中await this.saveData(this.keyword)// 2. 获取用户首选项数据回显在页面上this.kdList = await this.getData()this.isSearch = true// AlertDialog.show({message:JSON.stringify('保存成功')})}).onClick(()=>{this.isSearch = falsethis.fsable = true}).focusable(this.fsable)if (this.isSearch) {//   使用搜索组件Column(){QuestionListComp({keyword:this.keyword,selfIndex:this.params.index})}} else {// 2. 关键字列表Row() {Text('搜索记录').fontSize(20).fontWeight(800)Row() {if (this.isdel) {Text('全部删除').onClick(async () => {// AlertDialog.show({ message: '补上全部删除逻辑' })//   1. 调用删除方法await this.delData()//   2. 重新获取数据this.kdList = await this.getData()})Text(' | ')Text('取消删除').onClick(() => {this.isdel = false})} else {Image($r('app.media.ic_common_delete')).height(28).onClick(() => {this.isdel = true})}}}.width('100%').justifyContent(FlexAlign.SpaceBetween)//   3. 关键字列表Flex({ wrap: FlexWrap.Wrap }) {ForEach(this.kdList, (item: string) => {this.itemBuilder(item)})}}}.padding(15)}.padding({ top: this.topHeight }).titleMode(NavigationTitleMode.Mini).title('搜索页面')}
}
  1. HdSearch组件定时器优化
import { router } from '@kit.ArkUI'
import { Logger } from '../utils/Logger'@Component
export struct HdSearch {@StatereadonlyMode: boolean = true@State ph: string = 'html'bg: string = ''color: string = ''// 1.准备用户喜好的关键字keywords: string[] = ['html', 'css', 'js', 'vue', 'react']index: number = 0intervalNum: number = -1@StorageLink('startInterval') @Watch('startInterval') startIntervalFlag : boolean = true// 2. 进入这个组件之后,开启定时器(setInterval(()=>{},3000))每隔三秒中轮播里面的关键字aboutToAppear(): void {// Logger.debug('aboutToAppear')this.startInterval()}startInterval(){if(!this.startIntervalFlag) returnthis.intervalNum = setInterval(() => {Logger.debug('setInterval')//   1. 定义一个索引//   2. 索引自增后从keywords中取值赋值给 ph这个状态变量 ->自动刷新UIthis.index++if (this.index >= this.keywords.length) {this.index = 0}this.ph = this.keywords[this.index]}, 3000)}build() {Row() {Row({ space: 4 }) {Image($r("app.media.ic_common_search")).width($r('app.float.hd_search_icon_size')).aspectRatio(1).fillColor(this.color || $r('app.color.common_gray_02'))Text(this.ph || $r('app.string.hd_search_placeholder')).fontColor(this.color || $r('app.color.common_gray_02')).fontSize($r('app.float.common_font14')).onClick(() => {// 跳转之前,将定时器移除clearInterval(this.intervalNum)router.pushUrl({ url: 'pages/SearchPage', params: { keywordText: this.ph } })})}.layoutWeight(1).height($r('app.float.hd_search_height')).backgroundColor(this.bg || $r('app.color.common_gray_border')).borderRadius($r('app.float.hd_search_radius')).justifyContent(FlexAlign.Center)}.onClick(() => {// router.replaceUrl({ url: 'pages/SearchPage' })})}
}
import { promptAction, router } from '@kit.ArkUI'
import { preferences } from '@kit.ArkData'
import { QuestionListComp } from '../views/home/QuestionListComp'
import { FilterParams } from '../views/home/QuestionFilterComp'
import { Logger } from '../common/utils/Logger'interface iRouterParams {keywordText: string
}@Entry
@Component
struct SearchPage {@State keyword: string = ''@StorageProp('topHeight') topHeight: number = 0@State isdel: boolean = false@State kdList: string[] = []@State isSearch: boolean = true@StorageLink('filter')  params: FilterParams = { sort: 0, index: 0 }@State fsable:boolean = false@StorageLink('startInterval') startIntervalFlag : boolean = trueasync aboutToAppear() {this.kdList = await this.getData()// 接收搜索关键字const p = router.getParams() as iRouterParamsthis.keyword = p.keywordText}// 准备用户首选项的 保存数据方法+删除方法 + 获取方法async getData() {const pre = preferences.getPreferencesSync(getContext(), { name: 'keywordStore' })return pre.getSync('keyword', []) as string[] //['html5','css3'] -> 如果没有 []}// 保存数据async saveData(kdText: string) {const pre = preferences.getPreferencesSync(getContext(), { name: 'keywordStore' })// 1. 先从用户首选项获取老数据const oldData = pre.getSync('keyword', []) as string[]// 2. 如果kdText在用户首选项中存来了,就不存了if (oldData.includes(kdText)) {return}oldData.push(kdText)// 3. 将新数据保存到首选项中pre.putSync('keyword', oldData)//   4. 写入到文件pre.flushSync()}// 删除数据(指定文本删除,全部删除)async delData(kdText?: string) {const pre = preferences.getPreferencesSync(getContext(), { name: 'keywordStore' })if (kdText) {// 指定文本删除//   1. 获取老数据 2 删除指定的文本(内存中的数据) 3 将数据写回到磁盘const oldData = pre.getSync('keyword', []) as string[]const index = oldData.findIndex(item => item === kdText)// 对未找到的关键字不执行删除动作if (index < 0) {return}oldData.splice(index, 1)pre.putSync('keyword', oldData)pre.flushSync()} else {//   全部删除pre.deleteSync('keyword')pre.flushSync()}}@BuilderitemBuilder(text: string) {Row({ space: 20 }) {Text(text).onClick(()=>{this.isSearch = truethis.keyword = textthis.fsable = false})if (this.isdel) {Text('x').height(30).onClick(async () => {//   1. 调用删除方法await this.delData(text)//   2. 重新获取数据this.kdList = await this.getData()})}}.margin({ right: 10, top: 10 }).padding({left: 15,right: 15,top: 5,bottom: 5}).backgroundColor('rgba(0,0,0,0.05)').borderRadius(20)}onBackPress(): boolean | void {Logger.debug('onBackPress')this.startIntervalFlag = falsethis.startIntervalFlag = truereturn false  // 使用原有的路由方式来回退}build() {Navigation() {Column({ space: 15 }) {// 1. 搜索关键字TextInput({ placeholder: '输入回车保存数据', text: $$this.keyword })// 回车的时候会触发.onSubmit(async () => {// AlertDialog.show({ message: this.keyword })// 1. 将关键字保存到首选项中await this.saveData(this.keyword)// 2. 获取用户首选项数据回显在页面上this.kdList = await this.getData()this.isSearch = true// AlertDialog.show({message:JSON.stringify('保存成功')})}).onClick(()=>{this.isSearch = falsethis.fsable = true}).focusable(this.fsable)if (this.isSearch) {//   使用搜索组件Column(){QuestionListComp({keyword:this.keyword,selfIndex:this.params.index})}} else {// 2. 关键字列表Row() {Text('搜索记录').fontSize(20).fontWeight(800)Row() {if (this.isdel) {Text('全部删除').onClick(async () => {// AlertDialog.show({ message: '补上全部删除逻辑' })//   1. 调用删除方法await this.delData()//   2. 重新获取数据this.kdList = await this.getData()})Text(' | ')Text('取消删除').onClick(() => {this.isdel = false})} else {Image($r('app.media.ic_common_delete')).height(28).onClick(() => {this.isdel = true})}}}.width('100%').justifyContent(FlexAlign.SpaceBetween)//   3. 关键字列表Flex({ wrap: FlexWrap.Wrap }) {ForEach(this.kdList, (item: string) => {this.itemBuilder(item)})}}}.padding(15)}.padding({ top: this.topHeight }).titleMode(NavigationTitleMode.Mini).title('搜索页面')}
}

【我的】- 功能流程

课程目标

  • 完成编辑个人信息:① 上传头像 ② 修改昵称
  • 完成累计学时统计
  • 完成单词用法高亮、AVPlayer朗读单词功能

【我的-编辑个人信息页】-静态页

课程目标

  • 迁移下面代码到ProfileEditPage.ets页面中
  • 使用@StorageProp('user') 同步用户头像和昵称数据
import { promptAction } from '@kit.ArkUI'
import { iLoginUserModel } from '../models/AccountModel'@Entry
@Component
struct ProfileEditPage {// 获取登录用户数据@StorageProp('user') currentUser: iLoginUserModel = {} as iLoginUserModel// 获取安全区域高度数据@StorageProp("topHeight") topHeight: number = 0build() {Navigation() {Stack() {List() {ListItem() {Row() {Text('头像')// 回显用户头像Image(this.currentUser.avatar || $rawfile('avatar.png')).width((40)).width((40)).borderRadius((40)).border({ width: 0.5, color: '#e4e4e4' }).onClick(() => {// 选择头像并上传this.pickerAvatar()})}.width('100%').height((60)).justifyContent(FlexAlign.SpaceBetween)}ListItem() {Row() {Text('昵称')// 回显用户昵称TextInput({ text: this.currentUser.nickName || '昵称' }).textAlign(TextAlign.End).layoutWeight(1).padding(0).height((60)).backgroundColor(Color.Transparent).borderRadius(0).onSubmit(() => {// 修改昵称 this.updateNickName()})}.width('100%').height(60).justifyContent(FlexAlign.SpaceBetween)}}.width('100%').height('100%').padding({ left: (45), right: (45), top: (15), bottom: (15) }).divider({ strokeWidth: 0.5, color: '#f5f5f5' })}.width('100%').height('100%')}.padding({ top: this.topHeight + 10 }).title('完善个人信息').titleMode(NavigationTitleMode.Mini).mode(NavigationMode.Stack).linearGradient({colors: [['#FFB071', 0], ['#f3f4f5', 0.3], ['#f3f4f5', 1]]})}
}

【我的-编辑个人信息页】- 头像上传

课程目标

  • 完成 利用picker api选择1张图片
  • 完成利用 request.uploadFile 进行图片上传
  • get请求userInfo接口刷新用户数据,更新AppStorage("user")中的用户缓存数据

【头像上传】- 使用photoAccessHelper选择一张图片

步骤:

  1. 实例化选择器参数(使用new PhotoSelectOptions())
  2. 实例化图片选择器 (使用newPhotoViewPicker() )
  3. 调用图片选择器的select方法传入选择器参数完成图片选取获得结果
import { iLoginUserModel } from '../models/datamodel'
// import { picker } from '@kit.CoreFileKit'
import { photoAccessHelper } from '@kit.MediaLibraryKit'@Entry
@Component
struct ProfileEditPage {// 获取登录用户数据@StorageProp('user') currentUser: iLoginUserModel = {} as iLoginUserModel// 获取安全区域高度数据@StorageProp("topHeight") topHeight: number = 0// 1. 选择系统相册的图片async selectImage(maxnum: number) {//   1. 定义打开系统相册的相关参数//   const options = new picker.PhotoSelectOptions()const options = new photoAccessHelper.PhotoSelectOptions()options.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPEoptions.maxSelectNumber = maxnum//   2. 打开和选择let viewer = new photoAccessHelper.PhotoViewPicker()const res = await viewer.select(options)AlertDialog.show({message:JSON.stringify(res.photoUris,null,2)})}build() {Navigation() {Stack() {List() {ListItem() {Row() {Text('头像')// 回显用户头像Image(this.currentUser.avatar || $rawfile('avatar.png')).width((40)).width((40)).borderRadius((40)).border({ width: 0.5, color: '#e4e4e4' }).onClick(async () => {// 选择头像并上传this.pickerAvatar()// 文件的本地存储路径。await this.selectImage(6)})}.width('100%').height((60)).justifyContent(FlexAlign.SpaceBetween)}ListItem() {Row() {Text('昵称')// 回显用户昵称TextInput({ text: this.currentUser.nickName || '昵称' }).textAlign(TextAlign.End).layoutWeight(1).padding(0).height((60)).backgroundColor(Color.Transparent).borderRadius(0).onSubmit(() => {// 修改昵称 this.updateNickName()})}.width('100%').height(60).justifyContent(FlexAlign.SpaceBetween)}}.width('100%').height('100%').padding({left: (45),right: (45),top: (15),bottom: (15)}).divider({ strokeWidth: 0.5, color: '#f5f5f5' })}.width('100%').height('100%')}.padding({ top: this.topHeight + 10 }).title('完善个人信息').titleMode(NavigationTitleMode.Mini).mode(NavigationMode.Stack).linearGradient({colors: [['#FFB071', 0], ['#f3f4f5', 0.3], ['#f3f4f5', 1]]})}
}

【头像上传】- 拷贝选择图片到缓存目录

步骤:

  1. 用上下文获取当前应用的缓存目录-> getContext().cacheDir
  2. 利用Date.now()随机生成图片名字filename,扩展名为jpg
  3. 利用 fs.openSync方法打开图片,准备拷贝到缓存目录 ->const file = fs.openSync(uri, fs.OpenMode.READ_ONLY)
  4. 利用fs.copyFileSync(file.fd, fullPath) 拷贝文件到缓存目录

此时可以获得当前图片的一个缓存地址供reqeust.uploadFile使用:'internal://cache/' + filename

import { iLoginUserModel } from '../models/datamodel'
import { picker } from '@kit.CoreFileKit'
import fs from '@ohos.file.fs';
import { request } from '@kit.BasicServicesKit';
import { Logger } from '../utils/Logger';
import { HdHttp } from '../utils/request';@Entry
@Component
struct ProfileEditPage {// 获取登录用户数据@StorageProp('user') currentUser: iLoginUserModel = {} as iLoginUserModel// 获取安全区域高度数据@StorageProp("topHeight") topHeight: number = 0// 1. 使用picker选择相册中的图片async selectImage(maxnum: number) {// 1.1 实例化选择参数let opts = new picker.PhotoSelectOptions()opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPEopts.maxSelectNumber = maxnum// 1.2 打开相册来选择照片返回(选择相册照片的数组)let viewer = new picker.PhotoViewPicker()let res = await viewer.select(opts)return res.photoUris}// 2. 拷贝到应用程序缓存目录async copyToCacheDir(photoImagePath: string) {//   1. 使用openSync将相册中的图片加载到内存中得到内存的数字指向let file = fs.openSync(photoImagePath, fs.OpenMode.READ_ONLY)//   2. 使用copyFileSync完成图片拷贝到应用程序缓存中let dir = getContext().cacheDirlet type = 'jpg'let filename = Date.now() + '.' + typelet fullpath = dir + '/' + filenamefs.copyFileSync(file.fd, fullpath)//   3. 返回文件名和文件的扩展名// ['123123234.jpg','jpg']return [filename, type]}build() {Navigation() {Stack() {List() {ListItem() {Row() {Text('头像')// 回显用户头像Image(this.currentUser.avatar || $rawfile('avatar.png')).width((40)).width((40)).borderRadius((40)).border({ width: 0.5, color: '#e4e4e4' }).onClick(async () => {// 1. 使用picker选择相册中的图片let urls = await this.selectImage(1)// AlertDialog.show({ message: JSON.stringify(urls[0]) })//  2. 利用fs将相册图片拷贝到缓存目录中let fileInfo = await this.copyToCacheDir(urls[0])AlertDialog.show({ message: JSON.stringify(fileInfo, null, 2) })})}.width('100%').height((60)).justifyContent(FlexAlign.SpaceBetween)}ListItem() {Row() {Text('昵称')// 回显用户昵称TextInput({ text: this.currentUser.nickName || '昵称' }).textAlign(TextAlign.End).layoutWeight(1).padding(0).height((60)).backgroundColor(Color.Transparent).borderRadius(0).onSubmit(() => {// 修改昵称 this.updateNickName()})}.width('100%').height(60).justifyContent(FlexAlign.SpaceBetween)}}.width('100%').height('100%').padding({left: (45),right: (45),top: (15),bottom: (15)}).divider({ strokeWidth: 0.5, color: '#f5f5f5' })}.width('100%').height('100%')}.padding({ top: this.topHeight + 10 }).title('完善个人信息').titleMode(NavigationTitleMode.Mini).mode(NavigationMode.Stack).linearGradient({colors: [['#FFB071', 0], ['#f3f4f5', 0.3], ['#f3f4f5', 1]]})}
}

【头像上传】- 利用request.uploadFile 进行图片上传

上传接口文档

步骤:

  1. 准备好参数调用request.uploadFile()获得上传对象 uploader
 'Content-Type': 'multipart/form-data'
  1. 给uploader对象注册progress事件,监听上传进度 requestRes.on("progress", (uploadedSize: number, totalSize: number)=>{})
  2. 给uploader对象注册fail事件,监听报错信息requestRes.on('fail', (taskStates) => {})
import { iLoginUserModel } from '../models/datamodel'
import { picker } from '@kit.CoreFileKit'
import fs from '@ohos.file.fs';
import { request } from '@kit.BasicServicesKit';
import { Logger } from '../utils/Logger';
import { HdHttp } from '../utils/request';@Entry
@Component
struct ProfileEditPage {// 获取登录用户数据@StorageProp('user') currentUser: iLoginUserModel = {} as iLoginUserModel// 获取安全区域高度数据@StorageProp("topHeight") topHeight: number = 0// 1. 使用picker选择相册中的图片async selectImage(maxnum: number) {// 1.1 实例化选择参数let opts = new picker.PhotoSelectOptions()opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPEopts.maxSelectNumber = maxnum// 1.2 打开相册来选择照片返回(选择相册照片的数组)let viewer = new picker.PhotoViewPicker()let res = await viewer.select(opts)return res.photoUris}// 2. 拷贝到应用程序缓存目录async copyToCacheDir(photoImagePath: string) {//   1. 使用openSync将相册中的图片加载到内存中得到内存的数字指向let file = fs.openSync(photoImagePath, fs.OpenMode.READ_ONLY)//   2. 使用copyFileSync完成图片拷贝到应用程序缓存中let dir = getContext().cacheDirlet type = 'jpg'let filename = Date.now() + '.' + typelet fullpath = dir + '/' + filenamefs.copyFileSync(file.fd, fullpath)//   3. 返回文件名和文件的扩展名// ['123123234.jpg','jpg']return [filename, type]}// 3. 头像上传async upload(filename: string, type: string) {let uploador = await request.uploadFile(getContext(), {method: 'POST',url: 'https://api-harmony-teach.itheima.net/hm/userInfo/avatar',header: {'Content-Type': 'multipart/form-data','Authorization': `Bearer ${this.currentUser.token}`},files: [{filename: filename,type: type,name: 'file',uri: 'internal://cache/' + filename}],data: []})//  1.监控文件上传失败事件// 不能监听所有异常uploador.on('fail', (err) => {// AlertDialog.show({ message: 'fail-->' + JSON.stringify(err, null, 2) })Logger.error('头像上传失败', JSON.stringify(err))})//  2. 监控服务器响应回来的数据uploador.on('headerReceive', (res) => {// AlertDialog.show({ message: '完成-->' + JSON.stringify(res, null, 2) })})}build() {Navigation() {Stack() {List() {ListItem() {Row() {Text('头像')// 回显用户头像Image(this.currentUser.avatar || $rawfile('avatar.png')).width((40)).width((40)).borderRadius((40)).border({ width: 0.5, color: '#e4e4e4' }).onClick(async () => {// 1. 使用picker选择相册中的图片let urls = await this.selectImage(1)// AlertDialog.show({ message: JSON.stringify(urls[0]) })//  2. 利用fs将相册图片拷贝到缓存目录中let fileInfo = await this.copyToCacheDir(urls[0])// AlertDialog.show({ message: JSON.stringify(fileInfo, null, 2) })//  3. 利用reqeust.uploadFile完成图片上传await this.upload(fileInfo[0], fileInfo[1])})}.width('100%').height((60)).justifyContent(FlexAlign.SpaceBetween)}ListItem() {Row() {Text('昵称')// 回显用户昵称TextInput({ text: this.currentUser.nickName || '昵称' }).textAlign(TextAlign.End).layoutWeight(1).padding(0).height((60)).backgroundColor(Color.Transparent).borderRadius(0).onSubmit(() => {// 修改昵称 this.updateNickName()})}.width('100%').height(60).justifyContent(FlexAlign.SpaceBetween)}}.width('100%').height('100%').padding({left: (45),right: (45),top: (15),bottom: (15)}).divider({ strokeWidth: 0.5, color: '#f5f5f5' })}.width('100%').height('100%')}.padding({ top: this.topHeight + 10 }).title('完善个人信息').titleMode(NavigationTitleMode.Mini).mode(NavigationMode.Stack).linearGradient({colors: [['#FFB071', 0], ['#f3f4f5', 0.3], ['#f3f4f5', 1]]})}
}

【头像上传】- 重新调用接口获取最新用户数据

步骤:

    1. get请求userInfo接口 重新获取用户数据
    2. 通过@StorageLink(''user")完成 用户头像字段avatar的修改

import { iLoginUserModel } from '../models/datamodel'
import { picker } from '@kit.CoreFileKit'
import fs from '@ohos.file.fs';
import { request } from '@kit.BasicServicesKit';
import { Logger } from '../utils/Logger';
import { HdHttp } from '../utils/request';@Entry
@Component
struct ProfileEditPage {// 获取登录用户数据@StorageLink('user') currentUser: iLoginUserModel = {} as iLoginUserModel// 获取安全区域高度数据@StorageProp("topHeight") topHeight: number = 0// 1. 使用picker选择相册中的图片async selectImage(maxnum: number) {// 1.1 实例化选择参数let opts = new picker.PhotoSelectOptions()opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPEopts.maxSelectNumber = maxnum// 1.2 打开相册来选择照片返回(选择相册照片的数组)let viewer = new picker.PhotoViewPicker()let res = await viewer.select(opts)return res.photoUris}// 2. 拷贝到应用程序缓存目录async copyToCacheDir(photoImagePath: string) {//   1. 使用openSync将相册中的图片加载到内存中得到内存的数字指向let file = fs.openSync(photoImagePath, fs.OpenMode.READ_ONLY)//   2. 使用copyFileSync完成图片拷贝到应用程序缓存中let dir = getContext().cacheDirlet type = 'jpg'let filename = Date.now() + '.' + typelet fullpath = dir + '/' + filenamefs.copyFileSync(file.fd, fullpath)//   3. 返回文件名和文件的扩展名// ['123123234.jpg','jpg']return [filename, type]}// 3. 头像上传async upload(filename: string, type: string) {let uploador = await request.uploadFile(getContext(), {method: 'POST',url: 'https://api-harmony-teach.itheima.net/hm/userInfo/avatar',header: {'Content-Type': 'multipart/form-data','Authorization': `Bearer ${this.currentUser.token}`},files: [{filename: filename,type: type,name: 'file',uri: 'internal://cache/' + filename}],data: []})//  1.监控文件上传失败事件// 不能监听所有异常uploador.on('fail', (err) => {// AlertDialog.show({ message: 'fail-->' + JSON.stringify(err, null, 2) })Logger.error('头像上传失败', JSON.stringify(err))})//  2. 监控服务器响应回来的数据uploador.on('headerReceive', async (res) => {// AlertDialog.show({ message: '完成-->' + JSON.stringify(res, null, 2) })//  这个方法一旦触发,那么服务器的头像已经上传完毕并且更新了//  这是去重新获取https://api-harmony-teach.itheima.net/hm/userInfo中的头像地址就是我们上传以后的新的头像地址let newUserInfo = await HdHttp.Get<object>('hm/userInfo')this.currentUser.avatar = newUserInfo.data['avatar']// AlertDialog.show({ message: JSON.stringify('老头像地址:'+this.currentUser.avatar +' 新的头像地址:' + newUserInfo.data['avatar']) })})}build() {Navigation() {Stack() {List() {ListItem() {Row() {Text('头像')// 回显用户头像Image(this.currentUser.avatar || $rawfile('avatar.png')).width((40)).width((40)).borderRadius((40)).border({ width: 0.5, color: '#e4e4e4' }).onClick(async () => {// 1. 使用picker选择相册中的图片let urls = await this.selectImage(1)// AlertDialog.show({ message: JSON.stringify(urls[0]) })//  2. 利用fs将相册图片拷贝到缓存目录中let fileInfo = await this.copyToCacheDir(urls[0])// AlertDialog.show({ message: JSON.stringify(fileInfo, null, 2) })//  3. 利用reqeust.uploadFile完成图片上传await this.upload(fileInfo[0], fileInfo[1])})}.width('100%').height((60)).justifyContent(FlexAlign.SpaceBetween)}ListItem() {Row() {Text('昵称')// 回显用户昵称TextInput({ text: this.currentUser.nickName || '昵称' }).textAlign(TextAlign.End).layoutWeight(1).padding(0).height((60)).backgroundColor(Color.Transparent).borderRadius(0).onSubmit(() => {// 修改昵称 this.updateNickName()})}.width('100%').height(60).justifyContent(FlexAlign.SpaceBetween)}}.width('100%').height('100%').padding({left: (45),right: (45),top: (15),bottom: (15)}).divider({ strokeWidth: 0.5, color: '#f5f5f5' })}.width('100%').height('100%')}.padding({ top: this.topHeight + 10 }).title('完善个人信息').titleMode(NavigationTitleMode.Mini).mode(NavigationMode.Stack).linearGradient({colors: [['#FFB071', 0], ['#f3f4f5', 0.3], ['#f3f4f5', 1]]})}
}

【头像上传】- 上传百分比实时更新

业务分析:

  1. ✔️需要在上传的时候获取上传进度百分比(request.uploadFile的progress事件来完成 )

  1. 需要使用emiiter来将上传进度百分数字从ProfileEditPage.ets 传给 HdLoadingDialog.ets页面

问题:使用传统的响应式方式是无法向@CustomDialog组件实时更新数据的

解决:需要使用emitter来实时更新@CustomDialog组件中的变量,从而达到页面数据的实时更新

progress事件监听上传进度
import { iLoginUserModel } from '../models/datamodel'
import { picker } from '@kit.CoreFileKit'
import fs from '@ohos.file.fs';
import { request } from '@kit.BasicServicesKit';
import { Logger } from '../utils/Logger';
import { HdHttp } from '../utils/request';@Entry
@Component
struct ProfileEditPage {// 获取登录用户数据@StorageLink('user') currentUser: iLoginUserModel = {} as iLoginUserModel// 获取安全区域高度数据@StorageProp("topHeight") topHeight: number = 0// 1. 使用picker选择相册中的图片async selectImage(maxnum: number) {// 1.1 实例化选择参数let opts = new picker.PhotoSelectOptions()opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPEopts.maxSelectNumber = maxnum// 1.2 打开相册来选择照片返回(选择相册照片的数组)let viewer = new picker.PhotoViewPicker()let res = await viewer.select(opts)return res.photoUris}// 2. 拷贝到应用程序缓存目录async copyToCacheDir(photoImagePath: string) {//   1. 使用openSync将相册中的图片加载到内存中得到内存的数字指向let file = fs.openSync(photoImagePath, fs.OpenMode.READ_ONLY)//   2. 使用copyFileSync完成图片拷贝到应用程序缓存中let dir = getContext().cacheDirlet type = 'jpg'let filename = Date.now() + '.' + typelet fullpath = dir + '/' + filenamefs.copyFileSync(file.fd, fullpath)//   3. 返回文件名和文件的扩展名// ['123123234.jpg','jpg']return [filename, type]}// 3. 头像上传async upload(filename: string, type: string) {let uploador = await request.uploadFile(getContext(), {method: 'POST',url: 'https://api-harmony-teach.itheima.net/hm/userInfo/avatar',header: {'Content-Type': 'multipart/form-data','Authorization': `Bearer ${this.currentUser.token}`},files: [{filename: filename,type: type,name: 'file',uri: 'internal://cache/' + filename}],data: []})//  1.监控文件上传失败事件// 不能监听所有异常uploador.on('fail', (err) => {// AlertDialog.show({ message: 'fail-->' + JSON.stringify(err, null, 2) })Logger.error('头像上传失败', JSON.stringify(err))})//  2. 监控服务器响应回来的数据uploador.on('headerReceive', async (res) => {// AlertDialog.show({ message: '完成-->' + JSON.stringify(res, null, 2) })//  这个方法一旦触发,那么服务器的头像已经上传完毕并且更新了//  这是去重新获取https://api-harmony-teach.itheima.net/hm/userInfo中的头像地址就是我们上传以后的新的头像地址let newUserInfo = await HdHttp.Get<object>('hm/userInfo')this.currentUser.avatar = newUserInfo.data['avatar']// AlertDialog.show({ message: JSON.stringify('老头像地址:'+this.currentUser.avatar +' 新的头像地址:' + newUserInfo.data['avatar']) })})//   3. 监控当前的上传进度uploador.on('progress', (uploadedSize, totalSize) => {// uploadedSize  -> 当前已经上传的大小// totalSize  -> 要上传的总大小Logger.info(uploadedSize.toString(), totalSize.toString())let pnum = (uploadedSize / totalSize * 100).toFixed(0) // 计算出百分数 %})}build() {Navigation() {Stack() {List() {ListItem() {Row() {Text('头像')// 回显用户头像Image(this.currentUser.avatar || $rawfile('avatar.png')).width((40)).width((40)).borderRadius((40)).border({ width: 0.5, color: '#e4e4e4' }).onClick(async () => {// 1. 使用picker选择相册中的图片let urls = await this.selectImage(1)// AlertDialog.show({ message: JSON.stringify(urls[0]) })//  2. 利用fs将相册图片拷贝到缓存目录中let fileInfo = await this.copyToCacheDir(urls[0])// AlertDialog.show({ message: JSON.stringify(fileInfo, null, 2) })//  3. 利用reqeust.uploadFile完成图片上传await this.upload(fileInfo[0], fileInfo[1])})}.width('100%').height((60)).justifyContent(FlexAlign.SpaceBetween)}ListItem() {Row() {Text('昵称')// 回显用户昵称TextInput({ text: this.currentUser.nickName || '昵称' }).textAlign(TextAlign.End).layoutWeight(1).padding(0).height((60)).backgroundColor(Color.Transparent).borderRadius(0).onSubmit(() => {// 修改昵称 this.updateNickName()})}.width('100%').height(60).justifyContent(FlexAlign.SpaceBetween)}}.width('100%').height('100%').padding({left: (45),right: (45),top: (15),bottom: (15)}).divider({ strokeWidth: 0.5, color: '#f5f5f5' })}.width('100%').height('100%')}.padding({ top: this.topHeight + 10 }).title('完善个人信息').titleMode(NavigationTitleMode.Mini).mode(NavigationMode.Stack).linearGradient({colors: [['#FFB071', 0], ['#f3f4f5', 0.3], ['#f3f4f5', 1]]})}
}

打开自定义弹窗
import { iLoginUserModel } from '../models/datamodel'
import { picker } from '@kit.CoreFileKit'
import fs from '@ohos.file.fs';
import { request } from '@kit.BasicServicesKit';
import { Logger } from '../utils/Logger';
import { HdHttp } from '../utils/request';
import { HdLoadingDialog } from '../components/HdLoadingDialog';@Entry
@Component
struct ProfileEditPage {// 获取登录用户数据@StorageLink('user') currentUser: iLoginUserModel = {} as iLoginUserModel// 获取安全区域高度数据@StorageProp("topHeight") topHeight: number = 0// 实例化自定义弹窗对象dialog = new CustomDialogController({builder: HdLoadingDialog({ message: '上传:' }),customStyle: true})// 1. 使用picker选择相册中的图片async selectImage(maxnum: number) {// 1.1 实例化选择参数let opts = new picker.PhotoSelectOptions()opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPEopts.maxSelectNumber = maxnum// 1.2 打开相册来选择照片返回(选择相册照片的数组)let viewer = new picker.PhotoViewPicker()let res = await viewer.select(opts)return res.photoUris}// 2. 拷贝到应用程序缓存目录async copyToCacheDir(photoImagePath: string) {//   1. 使用openSync将相册中的图片加载到内存中得到内存的数字指向let file = fs.openSync(photoImagePath, fs.OpenMode.READ_ONLY)//   2. 使用copyFileSync完成图片拷贝到应用程序缓存中let dir = getContext().cacheDirlet type = 'jpg'let filename = Date.now() + '.' + typelet fullpath = dir + '/' + filenamefs.copyFileSync(file.fd, fullpath)//   3. 返回文件名和文件的扩展名// ['123123234.jpg','jpg']return [filename, type]}// 3. 头像上传async upload(filename: string, type: string) {// 打开上传提示弹窗this.dialog.open()let uploador = await request.uploadFile(getContext(), {method: 'POST',url: 'https://api-harmony-teach.itheima.net/hm/userInfo/avatar',header: {'Content-Type': 'multipart/form-data','Authorization': `Bearer ${this.currentUser.token}`},files: [{filename: filename,type: type,name: 'file',uri: 'internal://cache/' + filename}],data: []})//  1.监控文件上传失败事件// 不能监听所有异常uploador.on('fail', (err) => {// AlertDialog.show({ message: 'fail-->' + JSON.stringify(err, null, 2) })Logger.error('头像上传失败', JSON.stringify(err))// 关闭上传提示弹窗this.dialog.close()})//  2. 监控服务器响应回来的数据uploador.on('headerReceive', async (res) => {// 关闭上传提示弹窗this.dialog.close()// AlertDialog.show({ message: '完成-->' + JSON.stringify(res, null, 2) })//  这个方法一旦触发,那么服务器的头像已经上传完毕并且更新了//  这是去重新获取https://api-harmony-teach.itheima.net/hm/userInfo中的头像地址就是我们上传以后的新的头像地址let newUserInfo = await HdHttp.Get<object>('hm/userInfo')this.currentUser.avatar = newUserInfo.data['avatar']// AlertDialog.show({ message: JSON.stringify('老头像地址:'+this.currentUser.avatar +' 新的头像地址:' + newUserInfo.data['avatar']) })})//   3. 监控当前的上传进度uploador.on('progress', (uploadedSize, totalSize) => {// uploadedSize  -> 当前已经上传的大小// totalSize  -> 要上传的总大小Logger.info(uploadedSize.toString(), totalSize.toString())let pnum = (uploadedSize / totalSize * 100).toFixed(0) // 计算出百分数 %})}build() {Navigation() {Stack() {List() {ListItem() {Row() {Text('头像')// 回显用户头像Image(this.currentUser.avatar || $rawfile('avatar.png')).width((40)).width((40)).borderRadius((40)).border({ width: 0.5, color: '#e4e4e4' }).onClick(async () => {// 1. 使用picker选择相册中的图片let urls = await this.selectImage(1)// AlertDialog.show({ message: JSON.stringify(urls[0]) })//  2. 利用fs将相册图片拷贝到缓存目录中let fileInfo = await this.copyToCacheDir(urls[0])// AlertDialog.show({ message: JSON.stringify(fileInfo, null, 2) })//  3. 利用reqeust.uploadFile完成图片上传await this.upload(fileInfo[0], fileInfo[1])})}.width('100%').height((60)).justifyContent(FlexAlign.SpaceBetween)}ListItem() {Row() {Text('昵称')// 回显用户昵称TextInput({ text: this.currentUser.nickName || '昵称' }).textAlign(TextAlign.End).layoutWeight(1).padding(0).height((60)).backgroundColor(Color.Transparent).borderRadius(0).onSubmit(() => {// 修改昵称 this.updateNickName()})}.width('100%').height(60).justifyContent(FlexAlign.SpaceBetween)}}.width('100%').height('100%').padding({left: (45),right: (45),top: (15),bottom: (15)}).divider({ strokeWidth: 0.5, color: '#f5f5f5' })}.width('100%').height('100%')}.padding({ top: this.topHeight + 10 }).title('完善个人信息').titleMode(NavigationTitleMode.Mini).mode(NavigationMode.Stack).linearGradient({colors: [['#FFB071', 0], ['#f3f4f5', 0.3], ['#f3f4f5', 1]]})}
}

【新知识】- emitter核心api

课程目标

  • 理解emitter核心api的作用和工作机制

emitter什么是?emitter主要提供发送和处理事件的能力,包括订阅事件(on)、发送事件(emit)、取消订阅(off)事件的功能。我们通过发送事件,来触发所有订阅的事件的执行。

应用场景:可以通过emitter向@CustomDialog组件来实时传递数据

【头像上传】- 使用emitter实现上传进度百分比更新

使用CustomDialogController 弹出正在上传中的提示,并且需要展示上传百分比

步骤:

  1. 在src/main/ets/pages/ProfileEditPage.ets中利用emitter发送事件并传递数据
  2. 在LoadingDialog.ets 中利用emitter 完成事件订阅emitter.on(),来接收上传进度数据,并在页面上更新上传进度数据

import { iLoginUserModel } from '../models/datamodel'
import { picker } from '@kit.CoreFileKit'
import fs from '@ohos.file.fs';
import { emitter, request } from '@kit.BasicServicesKit';
import { Logger } from '../utils/Logger';
import { HdHttp } from '../utils/request';
import { HdLoadingDialog } from '../components/HdLoadingDialog';@Entry
@Component
struct ProfileEditPage {// 获取登录用户数据@StorageLink('user') currentUser: iLoginUserModel = {} as iLoginUserModel// 获取安全区域高度数据@StorageProp("topHeight") topHeight: number = 0// 实例化自定义弹窗对象dialog = new CustomDialogController({builder: HdLoadingDialog({ message: '上传:' }),customStyle: true})// 1. 使用picker选择相册中的图片async selectImage(maxnum: number) {// 1.1 实例化选择参数let opts = new picker.PhotoSelectOptions()opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPEopts.maxSelectNumber = maxnum// 1.2 打开相册来选择照片返回(选择相册照片的数组)let viewer = new picker.PhotoViewPicker()let res = await viewer.select(opts)return res.photoUris}// 2. 拷贝到应用程序缓存目录async copyToCacheDir(photoImagePath: string) {//   1. 使用openSync将相册中的图片加载到内存中得到内存的数字指向let file = fs.openSync(photoImagePath, fs.OpenMode.READ_ONLY)//   2. 使用copyFileSync完成图片拷贝到应用程序缓存中let dir = getContext().cacheDirlet type = 'jpg'let filename = Date.now() + '.' + typelet fullpath = dir + '/' + filenamefs.copyFileSync(file.fd, fullpath)//   3. 返回文件名和文件的扩展名// ['123123234.jpg','jpg']return [filename, type]}// 3. 头像上传async upload(filename: string, type: string) {// 打开上传提示弹窗this.dialog.open()let uploador = await request.uploadFile(getContext(), {method: 'POST',url: 'https://api-harmony-teach.itheima.net/hm/userInfo/avatar',header: {'Content-Type': 'multipart/form-data','Authorization': `Bearer ${this.currentUser.token}`},files: [{filename: filename,type: type,name: 'file',uri: 'internal://cache/' + filename}],data: []})//  1.监控文件上传失败事件// 不能监听所有异常uploador.on('fail', (err) => {// AlertDialog.show({ message: 'fail-->' + JSON.stringify(err, null, 2) })Logger.error('头像上传失败', JSON.stringify(err))// 关闭上传提示弹窗this.dialog.close()})//  2. 监控服务器响应回来的数据uploador.on('headerReceive', async (res) => {// 关闭上传提示弹窗this.dialog.close()// AlertDialog.show({ message: '完成-->' + JSON.stringify(res, null, 2) })//  这个方法一旦触发,那么服务器的头像已经上传完毕并且更新了//  这是去重新获取https://api-harmony-teach.itheima.net/hm/userInfo中的头像地址就是我们上传以后的新的头像地址let newUserInfo = await HdHttp.Get<object>('hm/userInfo')this.currentUser.avatar = newUserInfo.data['avatar']// AlertDialog.show({ message: JSON.stringify('老头像地址:'+this.currentUser.avatar +' 新的头像地址:' + newUserInfo.data['avatar']) })})//   3. 监控当前的上传进度uploador.on('progress', (uploadedSize, totalSize) => {// uploadedSize  -> 当前已经上传的大小// totalSize  -> 要上传的总大小Logger.info(uploadedSize.toString(), totalSize.toString())let pnum = (uploadedSize / totalSize * 100).toFixed(0) // 计算出百分数 %// 使用emitter将pnum这个数据发送出去emitter.emit({ eventId: 0 }, { data: { pstr: pnum + '%' } })})}build() {Navigation() {Stack() {List() {ListItem() {Row() {Text('头像')// 回显用户头像Image(this.currentUser.avatar || $rawfile('avatar.png')).width((40)).width((40)).borderRadius((40)).border({ width: 0.5, color: '#e4e4e4' }).onClick(async () => {// 1. 使用picker选择相册中的图片let urls = await this.selectImage(1)// AlertDialog.show({ message: JSON.stringify(urls[0]) })//  2. 利用fs将相册图片拷贝到缓存目录中let fileInfo = await this.copyToCacheDir(urls[0])// AlertDialog.show({ message: JSON.stringify(fileInfo, null, 2) })//  3. 利用reqeust.uploadFile完成图片上传await this.upload(fileInfo[0], fileInfo[1])})}.width('100%').height((60)).justifyContent(FlexAlign.SpaceBetween)}ListItem() {Row() {Text('昵称')// 回显用户昵称TextInput({ text: this.currentUser.nickName || '昵称' }).textAlign(TextAlign.End).layoutWeight(1).padding(0).height((60)).backgroundColor(Color.Transparent).borderRadius(0).onSubmit(() => {// 修改昵称 this.updateNickName()})}.width('100%').height(60).justifyContent(FlexAlign.SpaceBetween)}}.width('100%').height('100%').padding({left: (45),right: (45),top: (15),bottom: (15)}).divider({ strokeWidth: 0.5, color: '#f5f5f5' })}.width('100%').height('100%')}.padding({ top: this.topHeight + 10 }).title('完善个人信息').titleMode(NavigationTitleMode.Mini).mode(NavigationMode.Stack).linearGradient({colors: [['#FFB071', 0], ['#f3f4f5', 0.3], ['#f3f4f5', 1]]})}
}
/** 自定义弹窗有规则:* 1. 必须有 @CustomDialog* 2.里面使用  controller: CustomDialogController 定义一个固定的控制器对象* */
import { emitter } from '@kit.BasicServicesKit'
import { Logger } from '../utils/Logger'@CustomDialog
export struct HdLoadingDialog {@Prop message: string = ''controller: CustomDialogControlleraboutToAppear(): void {// 注册emitter的on事件来监听emit发送过来的数据emitter.on({ eventId: 0 }, (rec) => {let pstr = rec.data!['pstr'] as stringthis.message = '上传:' + pstr})}build() {Flex({ direction: FlexDirection.Row, alignItems: ItemAlign.Center, justifyContent: FlexAlign.Center }) {LoadingProgress().width(30).height(30).color('#fff')if (this.message) {Text(this.message).fontSize((14)).fontColor('#fff')}}.width(150).height(50).padding(10).backgroundColor('rgba(0,0,0,0.5)').borderRadius(8)}
}

【我的-编辑个人信息页】-昵称更新

课程目标

  • post请求userInfo/profile接口(接口文档)完成用户昵称更新

import { iLoginUserModel } from '../models/datamodel'
import { picker } from '@kit.CoreFileKit'
import fs from '@ohos.file.fs';
import { emitter, request } from '@kit.BasicServicesKit';
import { Logger } from '../utils/Logger';
import { HdHttp } from '../utils/request';
import { HdLoadingDialog } from '../components/HdLoadingDialog';
import { promptAction } from '@kit.ArkUI';@Entry
@Component
struct ProfileEditPage {// 获取登录用户数据@StorageLink('user') currentUser: iLoginUserModel = {} as iLoginUserModel@State nickName: string = this.currentUser.nickName// 获取安全区域高度数据@StorageProp("topHeight") topHeight: number = 0// 实例化自定义弹窗对象dialog = new CustomDialogController({builder: HdLoadingDialog({ message: '上传:' }),customStyle: true})// 1. 使用picker选择相册中的图片async selectImage(maxnum: number) {// 1.1 实例化选择参数let opts = new picker.PhotoSelectOptions()opts.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPEopts.maxSelectNumber = maxnum// 1.2 打开相册来选择照片返回(选择相册照片的数组)let viewer = new picker.PhotoViewPicker()let res = await viewer.select(opts)return res.photoUris}// 2. 拷贝到应用程序缓存目录async copyToCacheDir(photoImagePath: string) {//   1. 使用openSync将相册中的图片加载到内存中得到内存的数字指向let file = fs.openSync(photoImagePath, fs.OpenMode.READ_ONLY)//   2. 使用copyFileSync完成图片拷贝到应用程序缓存中let dir = getContext().cacheDirlet type = 'jpg'let filename = Date.now() + '.' + typelet fullpath = dir + '/' + filenamefs.copyFileSync(file.fd, fullpath)//   3. 返回文件名和文件的扩展名// ['123123234.jpg','jpg']return [filename, type]}// 3. 头像上传async upload(filename: string, type: string) {// 打开上传提示弹窗this.dialog.open()let uploador = await request.uploadFile(getContext(), {method: 'POST',url: 'https://api-harmony-teach.itheima.net/hm/userInfo/avatar',header: {'Content-Type': 'multipart/form-data','Authorization': `Bearer ${this.currentUser.token}`},files: [{filename: filename,type: type,name: 'file',uri: 'internal://cache/' + filename}],data: []})//  1.监控文件上传失败事件// 不能监听所有异常uploador.on('fail', (err) => {// AlertDialog.show({ message: 'fail-->' + JSON.stringify(err, null, 2) })Logger.error('头像上传失败', JSON.stringify(err))// 关闭上传提示弹窗this.dialog.close()})//  2. 监控服务器响应回来的数据uploador.on('headerReceive', async (res) => {// 关闭上传提示弹窗this.dialog.close()// AlertDialog.show({ message: '完成-->' + JSON.stringify(res, null, 2) })//  这个方法一旦触发,那么服务器的头像已经上传完毕并且更新了//  这是去重新获取https://api-harmony-teach.itheima.net/hm/userInfo中的头像地址就是我们上传以后的新的头像地址let newUserInfo = await HdHttp.Get<object>('hm/userInfo')this.currentUser.avatar = newUserInfo.data['avatar']// AlertDialog.show({ message: JSON.stringify('老头像地址:'+this.currentUser.avatar +' 新的头像地址:' + newUserInfo.data['avatar']) })})//   3. 监控当前的上传进度uploador.on('progress', (uploadedSize, totalSize) => {// uploadedSize  -> 当前已经上传的大小// totalSize  -> 要上传的总大小Logger.info(uploadedSize.toString(), totalSize.toString())let pnum = (uploadedSize / totalSize * 100).toFixed(0) // 计算出百分数 %// 使用emitter将pnum这个数据发送出去emitter.emit({ eventId: 0 }, { data: { pstr: pnum + '%' } })})}build() {Navigation() {Stack() {List() {ListItem() {Row() {Text('头像')// 回显用户头像Image(this.currentUser.avatar || $rawfile('avatar.png')).width((40)).width((40)).borderRadius((40)).border({ width: 0.5, color: '#e4e4e4' }).onClick(async () => {// 1. 使用picker选择相册中的图片let urls = await this.selectImage(1)// AlertDialog.show({ message: JSON.stringify(urls[0]) })//  2. 利用fs将相册图片拷贝到缓存目录中let fileInfo = await this.copyToCacheDir(urls[0])// AlertDialog.show({ message: JSON.stringify(fileInfo, null, 2) })//  3. 利用reqeust.uploadFile完成图片上传await this.upload(fileInfo[0], fileInfo[1])})}.width('100%').height((60)).justifyContent(FlexAlign.SpaceBetween)}ListItem() {Row() {Text('昵称')// 回显用户昵称TextInput({ text: $$this.nickName }).textAlign(TextAlign.End).layoutWeight(1).padding(0).height((60)).backgroundColor(Color.Transparent).borderRadius(0).onSubmit(async () => {//1. 调用接口修改昵称//   https://api-harmony-teach.itheima.net/hm/userInfo/profile//   传参:nickNameawait HdHttp.Post<object>('hm/userInfo/profile', new Object({nickName: this.nickName}))//  2. 将最新的昵称同步给 currentUser中的nickNamethis.currentUser.nickName = this.nickNamepromptAction.showToast({ message: '昵称修改成功 ' })})}.width('100%').height(60).justifyContent(FlexAlign.SpaceBetween)}}.width('100%').height('100%').padding({left: (45),right: (45),top: (15),bottom: (15)}).divider({ strokeWidth: 0.5, color: '#f5f5f5' })}.width('100%').height('100%')}.padding({ top: this.topHeight + 10 }).title('完善个人信息').titleMode(NavigationTitleMode.Mini).mode(NavigationMode.Stack).linearGradient({colors: [['#FFB071', 0], ['#f3f4f5', 0.3], ['#f3f4f5', 1]]})}
}


http://www.ppmy.cn/embedded/174644.html

相关文章

JAVA 中的 HashMap 工作原理

‌1. 底层数据结构‌ ‌数组 链表/红黑树‌&#xff1a; HashMap 内部维护一个 ‌桶数组&#xff08;Node[] table&#xff09;‌&#xff0c;每个桶&#xff08;Bucket&#xff09;存储链表或红黑树的头节点。 transient Node<K,V>[] table; // 桶数组 static class N…

C++基础 [十二] - 继承与派生

目录 前言 什么是继承 继承的概念 继承的定义 基类与派生类对象的赋值转换 继承的作用域 派生类中的默认成员函数 默认成员函数的调用 构造函数与析构函数 拷贝构造 赋值运算符重载 显示成员函数的调用 构造函数 拷贝构造 赋值运算符重载 析构函数 继承与…

Spring Boot中接口数据字段为 Long 类型时,前端number精度丢失问题解决方案

Spring Boot中接口数据字段为 Long 类型时&#xff0c;前端number精度丢失问题解决方案 在Spring Boot中&#xff0c;当接口数据字段为 Long 类型时&#xff0c;返回页面的JSON中该字段通常会被序列化为数字类型。 例如&#xff0c;一个Java对象中有一个 Long 类型的属性 id …

蓝桥杯 第十天 :2022 国赛 第 2 题 排列距离/康托定理

实际上就是求字典序&#xff1a; 假设我们有 3 个数字&#xff1a;1, 2, 3。 排列组合总数: 3! 3 * 2 * 1 6 种。 这 6 种排列分别是&#xff1a; 1 2 31 3 22 1 32 3 13 1 23 2 1 康托展开: 对于排列 2 1 3&#xff0c;康托展开计算的结果是 2。这意味着 2 1 3 在所有 6 种…

JavaScript-函数、对象详解

一、函数 1.为什么需要函数&#xff1f; 作用&#xff1a;封装重复代码&#xff0c;实现复用示例&#xff1a;alert ()、prompt () 等内置函数 2.函数声明与调用 语法&#xff1a; function 函数名() {// 函数体 } 函数名(); // 调用 命名规范&#xff1a; 小驼峰命名&am…

windows+ragflow+deepseek实战之一excel表查询

ragflows平台部署参考文章 Win10系统Docker+DeepSeek+ragflow搭建本地知识库 ragflow通过python实现参考这篇文章 ragflow通过python实现 文章目录 背景效果1、准备数据2、创建知识库3、上传数据并解析4、新建聊天助理5、测试会话背景 前面已经基于Win10系统Docker+DeepSeek+…

Python实验:Python语言分支循环结构应用

[实验目的] 掌握分支结构&#xff0c;利用if语句实现单分支、双分支和多分支&#xff1b;掌握循环结构&#xff0c;运用while语句和for语句实现循环结构和循环嵌套&#xff1b;了解Python扩展库的使用方法&#xff1b;掌握程序的异常处理。 [实验和内容] 1.用户从键盘输入一…

家族族谱管理系统基于Spring Boot

目录 引言 一、系统概述 二、系统架构 三、功能模块 四、技术实现 五、系统特色 六、总结 引言 在数字化浪潮席卷全球的今天&#xff0c;家族文化的传承与延续面临着前所未有的挑战与机遇。传统纸质家谱因保存不便、查询困难、更新滞后等问题&#xff0c;已难以满足现代…