index.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518
  1. <template>
  2. <view class="search-center-page">
  3. <!-- 顶部搜索栏(带“取消”) -->
  4. <view class="search-header">
  5. <view class="header-row">
  6. <view class="search-wrap">
  7. <image class="search-icon" src="/static/icons/search.svg" mode="aspectFit" />
  8. <input
  9. class="search-input"
  10. v-model="keyword"
  11. placeholder="搜索"
  12. confirm-type="search"
  13. @confirm="onConfirm"
  14. />
  15. </view>
  16. <view class="cancel" @click="onCancel">取消</view>
  17. </view>
  18. </view>
  19. <!-- 分类 chips(点击后滚动到对应区块) -->
  20. <scroll-view class="chips-scroll" scroll-x :show-scrollbar="false">
  21. <view class="chips-row">
  22. <view
  23. v-for="c in chips"
  24. :key="c.key"
  25. class="chip"
  26. :class="{ active: c.key === activeKey }"
  27. @click="onChipClick(c.key)"
  28. >
  29. {{ c.label }}
  30. </view>
  31. </view>
  32. </scroll-view>
  33. <scroll-view
  34. class="results-scroll"
  35. scroll-y
  36. :scroll-into-view="scrollIntoView"
  37. scroll-with-animation
  38. >
  39. <view class="empty-state" v-if="!keyword.trim()">
  40. <text class="empty-text">请输入关键词开始搜索</text>
  41. </view>
  42. <!-- 联络人 -->
  43. <view id="sec-contacts" class="section" v-if="keyword.trim()">
  44. <view class="section-header">
  45. <text class="section-title">联络人</text>
  46. <view class="section-arrow">></view>
  47. </view>
  48. <view class="section-body">
  49. <view v-if="contacts.length" class="list">
  50. <view
  51. v-for="u in contacts"
  52. :key="String(u.id)"
  53. class="row-item"
  54. @click="openContact(u)"
  55. >
  56. <UserAvatar
  57. :name="u.name"
  58. :id="String(u.id)"
  59. :src="''"
  60. :size="64"
  61. unit="rpx"
  62. />
  63. <view class="row-main">
  64. <text class="row-title">{{ u.name }}</text>
  65. <text class="row-sub">{{ u.english_name || '' }}</text>
  66. </view>
  67. </view>
  68. </view>
  69. <view v-else class="empty-sub">暂无匹配联系人</view>
  70. </view>
  71. <view class="more-row" @click="onMore('contacts')">
  72. <text class="more-text">在联系人中搜索更多</text>
  73. <text class="more-arrow">></text>
  74. </view>
  75. </view>
  76. <!-- 应用 -->
  77. <view id="sec-apps" class="section" v-if="keyword.trim()">
  78. <view class="section-header">
  79. <text class="section-title">应用</text>
  80. <view class="section-arrow">></view>
  81. </view>
  82. <view class="section-body">
  83. <view v-if="apps.length" class="list">
  84. <view
  85. v-for="a in apps"
  86. :key="String(a.id)"
  87. class="row-item"
  88. @click="openApp(a)"
  89. >
  90. <SystemAvatar :name="a.name" :size="64" unit="rpx" />
  91. <view class="row-main">
  92. <text class="row-title">{{ a.name }}</text>
  93. <text class="row-sub">{{ a.categoryName || '' }}</text>
  94. </view>
  95. </view>
  96. </view>
  97. <view v-else class="empty-sub">暂无匹配应用</view>
  98. </view>
  99. <view class="more-row" @click="onMore('apps')">
  100. <text class="more-text">在应用中搜索更多</text>
  101. <text class="more-arrow">></text>
  102. </view>
  103. </view>
  104. <!-- 消息(当前仅占位,若后续有接口可替换) -->
  105. <view id="sec-messages" class="section" v-if="keyword.trim()">
  106. <view class="section-header">
  107. <text class="section-title">消息</text>
  108. <view class="section-arrow">></view>
  109. </view>
  110. <view class="section-body">
  111. <view class="empty-sub">暂无可展示的消息搜索内容</view>
  112. </view>
  113. <view class="more-row" @click="onMore('messages')">
  114. <text class="more-text">在消息中搜索更多</text>
  115. <text class="more-arrow">></text>
  116. </view>
  117. </view>
  118. <view class="bottom-spacer" />
  119. </scroll-view>
  120. <!-- 加载提示(只在搜索中显示) -->
  121. <view class="loading-mask" v-if="loading">
  122. <text class="loading-text">搜索中...</text>
  123. </view>
  124. </view>
  125. </template>
  126. <script>
  127. import UserAvatar from '../../components/UserAvatar.vue'
  128. import SystemAvatar from '../../components/SystemAvatar.vue'
  129. import { getToken, getLaunchpadApps, searchUsers, ssoLogin } from '../../utils/api'
  130. export default {
  131. components: { UserAvatar, SystemAvatar },
  132. data() {
  133. return {
  134. keyword: '',
  135. activeKey: 'contacts',
  136. scrollIntoView: '',
  137. loading: false,
  138. contacts: [],
  139. apps: [],
  140. chips: [
  141. { key: 'messages', label: '消息' },
  142. { key: 'contacts', label: '联系人' },
  143. { key: 'apps', label: '应用' }
  144. ]
  145. }
  146. },
  147. onLoad(options) {
  148. try {
  149. const kw = options && options.keyword ? options.keyword : ''
  150. if (/%[0-9A-Fa-f]{2}/.test(String(kw))) this.keyword = decodeURIComponent(String(kw))
  151. else this.keyword = String(kw || '')
  152. } catch (e) {}
  153. // 如果从外部带了关键字,直接触发一次搜索
  154. if (this.keyword.trim()) {
  155. this.doSearch()
  156. }
  157. },
  158. methods: {
  159. onCancel() {
  160. uni.navigateBack()
  161. },
  162. onConfirm() {
  163. this.doSearch()
  164. },
  165. onChipClick(key) {
  166. this.activeKey = key
  167. // scroll-into-view 需要存在于 scroll-view 内的子节点 id
  168. this.scrollIntoView = 'sec-' + key
  169. },
  170. async doSearch() {
  171. const q = String(this.keyword || '').trim()
  172. if (!q) {
  173. this.contacts = []
  174. this.apps = []
  175. return
  176. }
  177. this.loading = true
  178. try {
  179. const token = getToken()
  180. if (!token) {
  181. this.contacts = []
  182. this.apps = []
  183. uni.showToast({ title: '请先登录', icon: 'none' })
  184. return
  185. }
  186. // 1) 联络人
  187. let contacts = []
  188. try {
  189. const res = await searchUsers(token, q, 20)
  190. if (Array.isArray(res)) contacts = res
  191. else if (res && Array.isArray(res.items)) contacts = res.items
  192. } catch (e) {
  193. contacts = []
  194. }
  195. this.contacts = contacts
  196. // 2) 应用:复用应用中心的列表接口,然后本地模糊过滤
  197. let apps = []
  198. try {
  199. const res = await getLaunchpadApps()
  200. const items = res && Array.isArray(res.items) ? res.items : []
  201. const filtered = items.filter((it) => {
  202. const name = String(it.app_name || '')
  203. return name.includes(q)
  204. })
  205. // 和 app-center 页结构尽量保持一致(只用到 name/categoryName/id)
  206. apps = filtered.slice(0, 8).map((it) => ({
  207. id: it.app_id ?? it.id,
  208. name: it.app_name || '应用',
  209. categoryName: it.category_name || '分类'
  210. }))
  211. } catch (e) {
  212. apps = []
  213. }
  214. this.apps = apps
  215. } finally {
  216. this.loading = false
  217. }
  218. },
  219. openContact(u) {
  220. const id = String(u?.id ?? '').trim()
  221. if (!id) return
  222. uni.navigateTo({
  223. url:
  224. '/pages/contact-detail/index?contactId=' +
  225. encodeURIComponent(id) +
  226. '&contactName=' +
  227. encodeURIComponent(u?.name || '') +
  228. '&contactEnglishName=' +
  229. encodeURIComponent(u?.english_name || '')
  230. })
  231. },
  232. async openApp(app) {
  233. if (!app || !app.id) return
  234. uni.showLoading({ title: '打开中...' })
  235. try {
  236. const res = await ssoLogin(app.id)
  237. const redirectUrl = res && (res.redirect_url || res.redirectUrl)
  238. if (!redirectUrl) {
  239. uni.showToast({ title: '打开失败', icon: 'none' })
  240. return
  241. }
  242. const pageUrl =
  243. '/pages/webview/index?url=' +
  244. encodeURIComponent(redirectUrl) +
  245. '&title=' +
  246. encodeURIComponent(app.name || '应用')
  247. uni.navigateTo({ url: pageUrl })
  248. } catch (e) {
  249. uni.showToast({ title: '打开失败', icon: 'none' })
  250. } finally {
  251. uni.hideLoading()
  252. }
  253. },
  254. onMore(key) {
  255. // 当前项目尚未实现分模块“更多搜索”的详情页,这里先保持交互反馈
  256. const map = {
  257. contacts: '联系人',
  258. apps: '应用',
  259. messages: '消息'
  260. }
  261. uni.showToast({ title: `打开${map[key] || '更多'}搜索`, icon: 'none' })
  262. }
  263. }
  264. }
  265. </script>
  266. <style scoped>
  267. .search-center-page {
  268. height: 100vh;
  269. background: #f5f5f7;
  270. display: flex;
  271. flex-direction: column;
  272. position: relative;
  273. }
  274. .search-header {
  275. background: #fff;
  276. border-bottom: 1rpx solid #eee;
  277. padding: 0 24rpx 20rpx 24rpx;
  278. /* 顶部安全区:与消息/通讯录页面 custom-header 对齐,避免刘海屏点击不到 */
  279. padding-top: 88rpx;
  280. padding-top: max(88rpx, calc(24rpx + constant(safe-area-inset-top)));
  281. padding-top: max(88rpx, calc(24rpx + env(safe-area-inset-top)));
  282. }
  283. .header-row {
  284. display: flex;
  285. align-items: center;
  286. gap: 16rpx;
  287. }
  288. .search-wrap {
  289. flex: 1;
  290. min-width: 0;
  291. display: flex;
  292. align-items: center;
  293. gap: 12rpx;
  294. padding: 14rpx 18rpx;
  295. border-radius: 999rpx;
  296. background: #f0f0f0;
  297. }
  298. .search-icon {
  299. width: 28rpx;
  300. height: 28rpx;
  301. opacity: 0.75;
  302. }
  303. .search-input {
  304. flex: 1;
  305. min-width: 0;
  306. font-size: 28rpx;
  307. height: 44rpx;
  308. line-height: 44rpx;
  309. padding: 0;
  310. }
  311. .cancel {
  312. color: #259653;
  313. font-size: 28rpx;
  314. font-weight: 600;
  315. padding: 10rpx 8rpx;
  316. white-space: nowrap;
  317. }
  318. .chips-scroll {
  319. background: #fff;
  320. border-bottom: 1rpx solid #eee;
  321. }
  322. .chips-row {
  323. display: flex;
  324. align-items: center;
  325. gap: 16rpx;
  326. padding: 18rpx 24rpx;
  327. }
  328. .chip {
  329. padding: 10rpx 20rpx;
  330. border-radius: 999rpx;
  331. background: #f3f4f6;
  332. color: #111827;
  333. font-size: 26rpx;
  334. white-space: nowrap;
  335. }
  336. .chip.active {
  337. background: rgba(37, 150, 83, 0.12);
  338. color: #259653;
  339. font-weight: 700;
  340. }
  341. .results-scroll {
  342. flex: 1;
  343. min-height: 0;
  344. height: 0;
  345. padding: 20rpx 24rpx 0;
  346. box-sizing: border-box;
  347. }
  348. .empty-state {
  349. padding: 120rpx 10rpx;
  350. display: flex;
  351. align-items: center;
  352. justify-content: center;
  353. }
  354. .empty-text {
  355. color: #9ca3af;
  356. font-size: 28rpx;
  357. }
  358. .section {
  359. margin-bottom: 22rpx;
  360. background: #fff;
  361. border-radius: 24rpx;
  362. padding: 18rpx 18rpx 14rpx;
  363. box-shadow: 0 2rpx 10rpx rgba(17, 24, 39, 0.04);
  364. }
  365. .section-header {
  366. display: flex;
  367. align-items: center;
  368. justify-content: space-between;
  369. padding: 0 6rpx 10rpx;
  370. }
  371. .section-title {
  372. font-size: 30rpx;
  373. font-weight: 800;
  374. color: #111827;
  375. }
  376. .section-arrow {
  377. color: #9ca3af;
  378. font-size: 32rpx;
  379. font-weight: 600;
  380. }
  381. .section-body {
  382. padding: 6rpx 6rpx 0;
  383. }
  384. .list {
  385. display: flex;
  386. flex-direction: column;
  387. gap: 14rpx;
  388. }
  389. .row-item {
  390. display: flex;
  391. align-items: center;
  392. gap: 18rpx;
  393. padding: 14rpx 12rpx;
  394. border-radius: 18rpx;
  395. }
  396. .row-main {
  397. flex: 1;
  398. min-width: 0;
  399. }
  400. .row-title {
  401. display: block;
  402. font-size: 30rpx;
  403. font-weight: 700;
  404. color: #111827;
  405. overflow: hidden;
  406. text-overflow: ellipsis;
  407. white-space: nowrap;
  408. }
  409. .row-sub {
  410. display: block;
  411. margin-top: 6rpx;
  412. font-size: 24rpx;
  413. color: #6b7280;
  414. overflow: hidden;
  415. text-overflow: ellipsis;
  416. white-space: nowrap;
  417. }
  418. .empty-sub {
  419. padding: 22rpx 6rpx 28rpx;
  420. color: #9ca3af;
  421. font-size: 26rpx;
  422. }
  423. .more-row {
  424. margin-top: 12rpx;
  425. padding: 16rpx 10rpx;
  426. display: flex;
  427. align-items: center;
  428. justify-content: space-between;
  429. border-top: 1rpx solid #f3f4f6;
  430. }
  431. .more-text {
  432. font-size: 26rpx;
  433. color: #6b7280;
  434. }
  435. .more-arrow {
  436. font-size: 30rpx;
  437. color: #9ca3af;
  438. font-weight: 600;
  439. }
  440. .badge {
  441. min-width: 42rpx;
  442. height: 36rpx;
  443. padding: 0 12rpx;
  444. border-radius: 18rpx;
  445. background: #f5222d;
  446. color: #fff;
  447. font-size: 22rpx;
  448. display: flex;
  449. align-items: center;
  450. justify-content: center;
  451. font-weight: 700;
  452. }
  453. .loading-mask {
  454. position: absolute;
  455. left: 0;
  456. right: 0;
  457. top: 0;
  458. bottom: 0;
  459. background: rgba(255, 255, 255, 0.65);
  460. display: flex;
  461. align-items: flex-start;
  462. justify-content: center;
  463. padding-top: 240rpx;
  464. z-index: 10;
  465. }
  466. .loading-text {
  467. background: rgba(255, 255, 255, 0.95);
  468. padding: 18rpx 28rpx;
  469. border-radius: 20rpx;
  470. font-size: 28rpx;
  471. color: #111827;
  472. font-weight: 700;
  473. }
  474. .bottom-spacer {
  475. height: 60rpx;
  476. }
  477. </style>