search.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485
  1. <script setup lang="ts">
  2. import { useI18n } from 'vue-i18n'
  3. import SearchSvg from '~/assets/icons/search/search-detail.svg'
  4. import DelSvg from '~/assets/icons/search/del.svg'
  5. import type { PlaymateSearchVo } from '~/types/api'
  6. import { playmateApi } from '~/api/playmate'
  7. import { useApi } from '~/composables/useApi'
  8. import { SEARCH_HISTORY_KEY } from '~/constants'
  9. defineOptions({
  10. name: 'SearchPage',
  11. })
  12. definePageMeta({
  13. auth: false,
  14. })
  15. const { t } = useI18n()
  16. const router = useRouter()
  17. const { request } = useApi()
  18. const keyword = ref('')
  19. const list = ref<PlaymateSearchVo[]>([])
  20. const pageSize = 10
  21. const pageCursor = ref<unknown>('')
  22. const listLoading = ref(false)
  23. const listFinished = ref(false)
  24. const searched = ref(false)
  25. const listRef = ref<{ check: () => void } | null>(null)
  26. const MAX_HISTORY = 10
  27. const searchHistory = ref<string[]>([])
  28. function loadSearchHistory() {
  29. if (import.meta.client) {
  30. try {
  31. const raw = localStorage.getItem(SEARCH_HISTORY_KEY)
  32. const parsed = raw ? JSON.parse(raw) : []
  33. searchHistory.value = Array.isArray(parsed) ? parsed.slice(0, MAX_HISTORY) : []
  34. }
  35. catch {
  36. searchHistory.value = []
  37. }
  38. }
  39. }
  40. function saveSearchHistory(keyword: string) {
  41. const trimmed = keyword.trim()
  42. if (!trimmed)
  43. return
  44. const next = [trimmed, ...searchHistory.value.filter(k => k !== trimmed)].slice(0, MAX_HISTORY)
  45. searchHistory.value = next
  46. if (import.meta.client) {
  47. try {
  48. localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(next))
  49. }
  50. catch {
  51. // ignore
  52. }
  53. }
  54. }
  55. onMounted(() => {
  56. loadSearchHistory()
  57. })
  58. const handleBack = () => {
  59. router.back()
  60. }
  61. const handleClear = () => {
  62. keyword.value = ''
  63. resetList()
  64. searched.value = false
  65. }
  66. const resetList = () => {
  67. list.value = []
  68. pageCursor.value = ''
  69. listFinished.value = false
  70. }
  71. const loadList = async (isLoadMore = false) => {
  72. const q = keyword.value.trim()
  73. if (!q && !isLoadMore) {
  74. list.value = []
  75. searched.value = true
  76. listFinished.value = true
  77. return
  78. }
  79. if (isLoadMore) {
  80. if (!q || listFinished.value) return
  81. }
  82. listLoading.value = true
  83. if (!isLoadMore)
  84. searched.value = true
  85. try {
  86. const res = await request(() =>
  87. playmateApi.search({
  88. keyword: q || undefined,
  89. page: {
  90. size: pageSize,
  91. next: isLoadMore ? pageCursor.value ?? '' : '',
  92. },
  93. }),
  94. )
  95. if (!res) {
  96. if (!isLoadMore)
  97. list.value = []
  98. listFinished.value = true
  99. return
  100. }
  101. if (!isLoadMore)
  102. list.value = res.list ?? []
  103. else
  104. list.value.push(...(res.list ?? []))
  105. const hasNext = res.next != null && res.next !== ''
  106. listFinished.value = !hasNext
  107. pageCursor.value = hasNext ? res.next : ''
  108. }
  109. finally {
  110. listLoading.value = false
  111. // When content doesn't fill the viewport (e.g. tall phone), infinite scroll won't trigger.
  112. // Manually trigger List's check so it loads more until the screen is filled or no more data.
  113. if (list.value.length > 0 && !listFinished.value) {
  114. nextTick(() => listRef.value?.check())
  115. }
  116. }
  117. }
  118. const handleSearch = () => {
  119. const q = keyword.value.trim()
  120. if (q)
  121. saveSearchHistory(q)
  122. resetList()
  123. loadList(false)
  124. }
  125. const handleHistoryClick = (item: string) => {
  126. keyword.value = item
  127. handleSearch()
  128. }
  129. const handleUserClick = (userNo: string) => {
  130. router.push(`/user/profile?id=${userNo}`)
  131. }
  132. </script>
  133. <template>
  134. <div class="search-page min-h-screen bg-white text-[#1d2129]">
  135. <header class="search-page__bar">
  136. <button
  137. type="button"
  138. class="search-page__back"
  139. aria-label="back"
  140. @click="handleBack"
  141. >
  142. <van-icon
  143. name="arrow-left"
  144. :size="24"
  145. />
  146. </button>
  147. <div class="search-page__input-wrap">
  148. <SearchSvg class="search-page__input-icon" />
  149. <input
  150. v-model="keyword"
  151. type="text"
  152. class="search-page__input"
  153. :placeholder="t('search.placeholder')"
  154. @keydown.enter.prevent="handleSearch"
  155. >
  156. <button
  157. v-if="keyword"
  158. type="button"
  159. class="search-page__clear"
  160. aria-label="clear"
  161. @click="handleClear"
  162. >
  163. <DelSvg />
  164. </button>
  165. </div>
  166. <button
  167. type="button"
  168. class="search-page__btn-search"
  169. @click="handleSearch"
  170. >
  171. {{ t('search.button') }}
  172. </button>
  173. </header>
  174. <section class="search-page__content">
  175. <!-- History (Figma: 搜索/记录标签) -->
  176. <div
  177. v-if="searchHistory.length > 0"
  178. class="search-page__history"
  179. >
  180. <h2 class="search-page__section-title">
  181. {{ t('search.history') }}
  182. </h2>
  183. <div class="search-page__history-tags">
  184. <button
  185. v-for="item in searchHistory"
  186. :key="item"
  187. type="button"
  188. class="search-page__history-tag"
  189. @click="handleHistoryClick(item)"
  190. >
  191. {{ item }}
  192. </button>
  193. </div>
  194. </div>
  195. <!-- Related Contacts -->
  196. <div class="search-page__related">
  197. <h2
  198. v-if="searched"
  199. class="search-page__section-title"
  200. >
  201. {{ t('search.relatedContacts') }}
  202. </h2>
  203. <div
  204. v-if="listLoading && !list.length"
  205. class="search-page__loading"
  206. >
  207. {{ t('common.loading') }}
  208. </div>
  209. <div
  210. v-else-if="searched && !list.length"
  211. class="search-page__empty"
  212. >
  213. {{ t('search.noResult') }}
  214. </div>
  215. <van-list
  216. v-else-if="searched"
  217. ref="listRef"
  218. v-model:loading="listLoading"
  219. :finished="listFinished"
  220. :finished-text="t('common.noMoreData')"
  221. :loading-text="t('common.loading')"
  222. :immediate-check="false"
  223. @load="loadList(true)"
  224. >
  225. <ul class="search-page__list">
  226. <li
  227. v-for="item in list"
  228. :key="item.userNo"
  229. class="search-page__row"
  230. @click="handleUserClick(item.userNo)"
  231. >
  232. <div class="search-page__row-left">
  233. <div class="search-page__avatar-wrap">
  234. <NuxtImg
  235. :src="item.avatar"
  236. :alt="item.nickname"
  237. class="search-page__avatar"
  238. loading="lazy"
  239. />
  240. </div>
  241. <div class="search-page__info">
  242. <div class="search-page__name-row">
  243. <span class="search-page__name">{{ item.nickname }}</span>
  244. <CommonGender
  245. :gender="item.gender"
  246. :age="item.age"
  247. />
  248. </div>
  249. <div class="search-page__meta">
  250. <span>ID {{ item.userNo }}</span>
  251. <!-- <span>{{ t('search.fansCount', { count: item.fansCount ?? 0 }) }}</span> -->
  252. </div>
  253. </div>
  254. </div>
  255. </li>
  256. </ul>
  257. </van-list>
  258. </div>
  259. </section>
  260. </div>
  261. </template>
  262. <style lang="scss" scoped>
  263. .search-page {
  264. padding-top: env(safe-area-inset-top);
  265. padding-bottom: env(safe-area-inset-bottom);
  266. }
  267. .search-page__bar {
  268. display: flex;
  269. align-items: center;
  270. gap: 12px;
  271. height: 54px;
  272. padding: 9px 16px;
  273. background: #fff;
  274. }
  275. .search-page__back {
  276. flex-shrink: 0;
  277. padding: 0;
  278. border: none;
  279. background: transparent;
  280. display: flex;
  281. align-items: center;
  282. justify-content: center;
  283. -webkit-tap-highlight-color: transparent;
  284. }
  285. .search-page__input-wrap {
  286. flex: 1;
  287. min-width: 0;
  288. height: 36px;
  289. display: flex;
  290. align-items: center;
  291. gap: 8px;
  292. padding: 0 10px;
  293. background: #f2f3f5;
  294. border-radius: 20px;
  295. }
  296. .search-page__input-icon {
  297. flex-shrink: 0;
  298. width: 18px;
  299. height: 18px;
  300. }
  301. .search-page__input {
  302. flex: 1;
  303. min-width: 0;
  304. border: none;
  305. background: transparent;
  306. font-size: 12px;
  307. line-height: 16px;
  308. color: #1d2129;
  309. outline: none;
  310. &::placeholder {
  311. color: #c9cdd4;
  312. }
  313. }
  314. .search-page__clear {
  315. flex-shrink: 0;
  316. padding: 0;
  317. border: none;
  318. background: transparent;
  319. display: flex;
  320. align-items: center;
  321. justify-content: center;
  322. color: #86909c;
  323. -webkit-tap-highlight-color: transparent;
  324. }
  325. .search-page__btn-search {
  326. flex-shrink: 0;
  327. padding: 0;
  328. border: none;
  329. background: transparent;
  330. font-size: 14px;
  331. font-weight: 600;
  332. line-height: normal;
  333. color: #1d2129;
  334. -webkit-tap-highlight-color: transparent;
  335. }
  336. .search-page__content {
  337. padding: 0 16px;
  338. padding-top: 10px;
  339. display: flex;
  340. flex-direction: column;
  341. gap: 20px;
  342. }
  343. .search-page__history-tags {
  344. display: flex;
  345. flex-wrap: wrap;
  346. gap: 10px;
  347. }
  348. .search-page__history-tag {
  349. flex-shrink: 0;
  350. height: 22px;
  351. padding: 3px 8px;
  352. border: 1px solid #c9cdd4;
  353. border-radius: 200px;
  354. background: transparent;
  355. font-size: 11px;
  356. line-height: 14px;
  357. color: #454a50;
  358. -webkit-tap-highlight-color: transparent;
  359. max-width: 120px;
  360. overflow: hidden;
  361. text-overflow: ellipsis;
  362. white-space: nowrap;
  363. }
  364. .search-page__section-title {
  365. font-size: 14px;
  366. font-weight: 600;
  367. color: #1d2129;
  368. margin: 0 0 10px;
  369. }
  370. .search-page__loading,
  371. .search-page__empty {
  372. padding: 24px 0;
  373. font-size: 12px;
  374. color: #86909c;
  375. text-align: center;
  376. }
  377. .search-page__list {
  378. list-style: none;
  379. margin: 0;
  380. padding: 0;
  381. display: flex;
  382. flex-direction: column;
  383. gap: 20px;
  384. }
  385. .search-page__row {
  386. display: flex;
  387. align-items: center;
  388. justify-content: space-between;
  389. gap: 10px;
  390. min-height: 44px;
  391. }
  392. .search-page__row-left {
  393. display: flex;
  394. align-items: center;
  395. gap: 10px;
  396. min-width: 0;
  397. flex: 1;
  398. }
  399. .search-page__avatar-wrap {
  400. position: relative;
  401. flex-shrink: 0;
  402. width: 44px;
  403. height: 44px;
  404. border-radius: 50%;
  405. overflow: hidden;
  406. border: 0.5px solid #fff;
  407. }
  408. .search-page__avatar {
  409. width: 100%;
  410. height: 100%;
  411. object-fit: cover;
  412. }
  413. .search-page__info {
  414. min-width: 0;
  415. display: flex;
  416. flex-direction: column;
  417. gap: 4px;
  418. }
  419. .search-page__name-row {
  420. display: flex;
  421. align-items: center;
  422. gap: 4px;
  423. }
  424. .search-page__name {
  425. font-size: 14px;
  426. font-weight: 600;
  427. color: #1d2129;
  428. overflow: hidden;
  429. text-overflow: ellipsis;
  430. white-space: nowrap;
  431. }
  432. .search-page__meta {
  433. font-size: 11px;
  434. line-height: 14px;
  435. color: #86909c;
  436. display: flex;
  437. align-items: center;
  438. gap: 7px;
  439. }
  440. </style>