index.vue 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. <script setup lang="ts">
  2. import { storeToRefs } from 'pinia'
  3. import { useI18n } from 'vue-i18n'
  4. import LanguageSvg from '~/assets/icons/language.svg'
  5. import ProfileSvg from '~/assets/icons/profile.svg'
  6. import AboutSvg from '~/assets/icons/about.svg'
  7. import SearchSvg from '~/assets/icons/search.svg'
  8. import { useApi } from '~/composables/useApi'
  9. import { skillApi } from '~/api/skill'
  10. import type { SkillSearchDTO, SkillSearchVo } from '~/types/api'
  11. import { useCategoryStore } from '~/stores/category'
  12. import { APP_LOCALE_KEY } from '~/constants'
  13. defineOptions({
  14. name: 'HomePage',
  15. })
  16. definePageMeta({
  17. auth: false,
  18. })
  19. const router = useRouter()
  20. const route = useRoute()
  21. const {
  22. open: openFindPartnerPopup,
  23. resetFilters: resetFindPartnerFilters,
  24. getFilters: getFindPartnerFilters,
  25. onApply: onFindPartnerApply,
  26. } = useFindPartnerPopup()
  27. const { open: openSelectListPopup } = useSelectListPopup()
  28. const { open: openAbout } = useAboutPopup()
  29. const { locale, locales, t, setLocale } = useI18n()
  30. const { request } = useApi()
  31. // 全局品类 store(品类树 & 当前选中的一级/二级品类)
  32. const categoryStore = useCategoryStore()
  33. const { categoryTree, activeFirstCode, activeSecondCode } = storeToRefs(categoryStore)
  34. const playmateList = ref<SkillSearchVo[]>([])
  35. const pageSize = 10
  36. const pageCursor = ref<unknown | '' | null>('')
  37. const listLoading = ref(false)
  38. const listFinished = ref(false)
  39. // 是否已完成首次列表加载,用于控制排序区域的展示时机
  40. const hasInitialLoaded = ref(false)
  41. // 排序:rating -> sortByStar, price -> sortByPrice
  42. const sortByStar = ref(-1)
  43. const sortByPrice = ref(-1)
  44. const resetPlaymateList = () => {
  45. playmateList.value = []
  46. pageCursor.value = ''
  47. listFinished.value = false
  48. }
  49. const loadPlaymateList = async () => {
  50. // 还没有选定二级品类时,不请求列表
  51. if (!activeSecondCode.value) {
  52. listLoading.value = false
  53. return
  54. }
  55. listLoading.value = true
  56. try {
  57. // 从「寻找陪玩伙伴」弹窗中读取筛选项映射
  58. const {
  59. gender,
  60. ageRange,
  61. areCode,
  62. priceRange,
  63. sortByStar: sortByStarParam,
  64. } = getFindPartnerFilters(sortByStar.value)
  65. const payload: SkillSearchDTO = {
  66. // 使用二级品类 code 作为筛选条件
  67. code: activeSecondCode.value,
  68. sortByStar: sortByStarParam,
  69. sortByPrice: sortByPrice.value,
  70. ageRange,
  71. gender,
  72. areCode,
  73. priceRange,
  74. page: {
  75. size: pageSize,
  76. next: pageCursor.value ?? '',
  77. },
  78. }
  79. const res = await request(() => skillApi.list(payload))
  80. // 请求失败(例如网络错误或业务错误)时,停止后续自动触发加载,避免 van-list 无限尝试加载
  81. if (!res) {
  82. listFinished.value = true
  83. return
  84. }
  85. playmateList.value.push(...res.list)
  86. const hasNext = res.next && !(typeof res.next === 'string' && res.next === '')
  87. if (!hasNext) {
  88. listFinished.value = true
  89. }
  90. else {
  91. pageCursor.value = res.next
  92. }
  93. // 首次加载完成后再展示排序区域
  94. if (!hasInitialLoaded.value) {
  95. hasInitialLoaded.value = true
  96. }
  97. }
  98. finally {
  99. // 无论成功还是失败,都要把 loading 复位,防止一直处于“加载中”状态
  100. listLoading.value = false
  101. }
  102. }
  103. // 点击「寻找陪玩伙伴」弹窗内确认按钮时,重新加载列表
  104. onFindPartnerApply(() => {
  105. if (!activeSecondCode.value)
  106. return
  107. resetPlaymateList()
  108. loadPlaymateList()
  109. })
  110. // 一级品类切换:通过全局 store 统一控制(内部会自动联动二级品类)
  111. const handleFirstCategoryChange = (firstCode: string) => {
  112. categoryStore.setActiveFirst(firstCode)
  113. }
  114. // 二级品类切换:仅更新二级 code,由监听器统一处理列表刷新
  115. const handleSecondCategoryChange = (secondCode: string) => {
  116. categoryStore.setActiveSecond(secondCode)
  117. }
  118. // 排序变更:更新排序字段并刷新列表
  119. const handleSortChange = (payload: { sortByStar: number, sortByPrice: number }) => {
  120. sortByStar.value = payload.sortByStar
  121. sortByPrice.value = payload.sortByPrice
  122. // 排序改变后,重置并重新加载列表
  123. resetPlaymateList()
  124. loadPlaymateList()
  125. }
  126. const handleAbout = () => {
  127. openAbout({
  128. onSelect: k => router.push(`/about/${k}?header=true`),
  129. })
  130. }
  131. // 当二级品类发生变化时:
  132. // 1. 重置高级筛选(FindPartner 弹窗)
  133. // 2. 重置列表并按默认条件重新加载
  134. watch(activeSecondCode, (code) => {
  135. if (!code)
  136. return
  137. resetFindPartnerFilters()
  138. resetPlaymateList()
  139. loadPlaymateList()
  140. }, { immediate: true })
  141. watch(locale, () => {
  142. categoryStore.loadCategories(true)
  143. })
  144. onActivated(async () => {
  145. // 通过 Pinia 全局 store 加载品类数据(默认只会请求一次)
  146. await categoryStore.loadCategories()
  147. // 如果从品类页带回了指定的一级/二级品类,则优先使用参数
  148. const fcode = route.query.fcode as string | undefined
  149. const scode = route.query.scode as string | undefined
  150. if (fcode) {
  151. categoryStore.setActiveFirst(fcode)
  152. if (scode) {
  153. categoryStore.setActiveSecond(scode)
  154. }
  155. }
  156. else {
  157. // 无路由参数时,使用 store 内部提供的默认选中逻辑
  158. categoryStore.initDefaultSelection()
  159. }
  160. })
  161. const handleProfile = () => {
  162. router.push('/mine')
  163. }
  164. const handleFindPartner = () => {
  165. openFindPartnerPopup()
  166. }
  167. const handleLanguageSelect = () => {
  168. openSelectListPopup({
  169. options: [
  170. ...locales.value.map(locale => ({
  171. label: locale.name ?? '',
  172. value: locale.code,
  173. })),
  174. ],
  175. defaultValue: locale.value,
  176. onSelect: (value) => {
  177. const nextLocale = value as typeof locale.value
  178. setLocale(nextLocale)
  179. if (typeof window !== 'undefined') {
  180. window.localStorage.setItem(APP_LOCALE_KEY, nextLocale)
  181. }
  182. },
  183. })
  184. }
  185. </script>
  186. <template>
  187. <div class="min-h-screen bg-bg-primary text-text-primary">
  188. <div class="relative mx-auto flex flex-col px-4 pt-6 pb-8">
  189. <div class="header-background absolute left-0 right-0 top-0" />
  190. <header class="z-10 flex items-center justify-between">
  191. <div class="logo">
  192. <img
  193. src="~/assets/images/logo.png"
  194. alt="logo"
  195. >
  196. </div>
  197. <div class="flex items-center gap-[16px]">
  198. <SearchSvg
  199. class="cursor-pointer"
  200. @click="router.push('/search')"
  201. />
  202. <AboutSvg @click="handleAbout" />
  203. <LanguageSvg @click="handleLanguageSelect" />
  204. <ProfileSvg @click="handleProfile" />
  205. </div>
  206. </header>
  207. <div class="relative z-10 flex flex-col">
  208. <HomeNavTabs
  209. class="mt-2"
  210. :category-tree="categoryTree"
  211. :active-first-code="activeFirstCode"
  212. :active-second-code="activeSecondCode"
  213. @change-first="handleFirstCategoryChange"
  214. @change-second="handleSecondCategoryChange"
  215. />
  216. <HomeSortSection
  217. v-show="hasInitialLoaded"
  218. class="sticky top-0 py-2"
  219. :on-find-partner="handleFindPartner"
  220. @change-sort="handleSortChange"
  221. />
  222. <CommonEmpty v-if="!hasInitialLoaded && !playmateList.length && categoryTree.length === 0" />
  223. <van-list
  224. v-model:loading="listLoading"
  225. :finished="listFinished"
  226. :finished-text="t('common.noMoreData')"
  227. :loading-text="t('common.loading')"
  228. :immediate-check="false"
  229. @load="loadPlaymateList"
  230. >
  231. <HomePlaymateCards :cards="playmateList" />
  232. </van-list>
  233. </div>
  234. </div>
  235. </div>
  236. </template>
  237. <style lang="scss" scoped>
  238. .logo {
  239. width: 103px;
  240. img {
  241. width: 100%;
  242. height: auto;
  243. }
  244. }
  245. .header-background {
  246. @include size(100%, 173px);
  247. @include bg('~/assets/images/home/header-bg.png', 100% 173px);
  248. }
  249. </style>