index.vue 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. <template>
  2. <view class="achievement-container">
  3. <!-- 顶部固定 Tab 区域 -->
  4. <view class="header">
  5. <view class="search-section">
  6. <view class="search-bar">
  7. <uv-input
  8. v-model="searchText"
  9. placeholder="搜索名称"
  10. prefixIcon="search"
  11. prefixIconStyle="color: #94a3b8; font-size: 32rpx;"
  12. shape="circle"
  13. border="none"
  14. customStyle="background-color: #f1f5f9; padding: 12rpx 24rpx;"
  15. clearable
  16. @input="onInput"
  17. @confirm="onSearch"
  18. @clear="onSearch"
  19. ></uv-input>
  20. </view>
  21. </view>
  22. <view class="tab-section">
  23. <uv-tabs
  24. :list="tabList"
  25. :current="current"
  26. @change="onTabChange"
  27. activeStyle="{ color: '#3b82f6', fontWeight: 'bold', fontSize: '30rpx' }"
  28. inactiveStyle="{ color: '#64748b', fontSize: '28rpx' }"
  29. lineColor="#3b82f6"
  30. lineWidth="40rpx"
  31. lineHeight="6rpx"
  32. :scrollable="true"
  33. itemStyle="padding-left: 30rpx; padding-right: 30rpx; height: 88rpx;"
  34. ></uv-tabs>
  35. </view>
  36. </view>
  37. <!-- 列表区域 -->
  38. <view class="list-wrapper">
  39. <scroll-view
  40. scroll-y
  41. class="scroll-view"
  42. :scroll-top="scrollTopValue"
  43. scroll-with-animation
  44. refresher-enabled
  45. refresher-background="#f8fafc"
  46. :refresher-triggered="isTriggered"
  47. @refresherrefresh="onRefresh"
  48. @scrolltolower="onLoadMore"
  49. @scroll="onScroll"
  50. >
  51. <view class="list-inner">
  52. <!-- 初始加载骨架屏或 Loading -->
  53. <view v-if="currentLoading && !currentList.length" class="loading-state">
  54. <uv-loading-icon text="正在加载科研成果..." color="#3b82f6"></uv-loading-icon>
  55. </view>
  56. <uv-empty v-else-if="!currentList.length" mode="list" text="暂无相关科研成果" icon="https://cdn.uviewui.com/uview/empty/list.png"></uv-empty>
  57. <view v-else class="card-list">
  58. <AchievementCard
  59. v-for="(item, index) in currentList"
  60. :key="item.id || index"
  61. :item="item"
  62. :type="currentType"
  63. class="animate-fade-in"
  64. :style="{ animationDelay: `${(index % 10) * 0.05}s` }"
  65. @click="goDetail(item)"
  66. />
  67. </view>
  68. <view class="load-more-box" v-if="currentList.length > 0">
  69. <uv-load-more :status="loadStatus" color="#94a3b8" />
  70. </view>
  71. </view>
  72. </scroll-view>
  73. <!-- 返回顶部 -->
  74. <uv-back-top :scrollTop="currentScrollTop" :bottom="100" :right="30" @click="backToTop"></uv-back-top>
  75. </view>
  76. </view>
  77. </template>
  78. <script setup lang="ts">
  79. import { ref, computed, onMounted, nextTick } from 'vue';
  80. import { useAchievementStore } from '@/store/modules/achievement';
  81. import { storeToRefs } from 'pinia';
  82. import { debounce } from 'lodash-es';
  83. import AchievementCard from './components/AchievementCard.vue';
  84. const achievementStore = useAchievementStore();
  85. const {
  86. patentList, softwareList, otherList, standardList, decisionList, workList, awardsList, paperList, conferenceList, specialActivityList,
  87. patentPagination, softwarePagination, otherPagination, standardPagination, decisionPagination, workPagination, awardsPagination, paperPagination, conferencePagination, specialActivityPagination,
  88. patentLoading, softwareLoading, otherLoading, standardLoading, decisionLoading, workLoading, awardsLoading, paperLoading, conferenceLoading, specialActivityLoading
  89. } = storeToRefs(achievementStore);
  90. const tabList = [
  91. { name: '专利', type: 'patent' },
  92. { name: '软著', type: 'software' },
  93. { name: '其他成果', type: 'other' },
  94. { name: '标准', type: 'standard' },
  95. { name: '决策咨询', type: 'decision' },
  96. { name: '学术著作', type: 'work' },
  97. { name: '奖项荣誉', type: 'awards' },
  98. { name: '学术论文', type: 'paper' },
  99. { name: '学术会议', type: 'conference' },
  100. { name: '科技专项活动', type: 'special_activity' }
  101. ];
  102. const current = ref(0);
  103. const searchText = ref('');
  104. const isTriggered = ref(false);
  105. const scrollTopValue = ref(0);
  106. const currentScrollTop = ref(0);
  107. const currentType = computed(() => tabList[current.value].type);
  108. const currentList = computed<any[]>(() => {
  109. const typeMap: Record<string, any[]> = {
  110. patent: patentList.value,
  111. software: softwareList.value,
  112. other: otherList.value,
  113. standard: standardList.value,
  114. decision: decisionList.value,
  115. work: workList.value,
  116. awards: awardsList.value,
  117. paper: paperList.value,
  118. conference: conferenceList.value,
  119. special_activity: specialActivityList.value,
  120. };
  121. return typeMap[currentType.value] || [];
  122. });
  123. const currentPagination = computed(() => {
  124. const typeMap: any = {
  125. patent: patentPagination.value,
  126. software: softwarePagination.value,
  127. other: otherPagination.value,
  128. standard: standardPagination.value,
  129. decision: decisionPagination.value,
  130. work: workPagination.value,
  131. awards: awardsPagination.value,
  132. paper: paperPagination.value,
  133. conference: conferencePagination.value,
  134. special_activity: specialActivityPagination.value,
  135. };
  136. return typeMap[currentType.value];
  137. });
  138. const currentLoading = computed(() => {
  139. const typeMap: any = {
  140. patent: patentLoading.value,
  141. software: softwareLoading.value,
  142. other: otherLoading.value,
  143. standard: standardLoading.value,
  144. decision: decisionLoading.value,
  145. work: workLoading.value,
  146. awards: awardsLoading.value,
  147. paper: paperLoading.value,
  148. conference: conferenceLoading.value,
  149. special_activity: specialActivityLoading.value,
  150. };
  151. return typeMap[currentType.value];
  152. });
  153. const loadStatus = computed(() => {
  154. if (currentLoading.value) return 'loading';
  155. if (currentList.value.length >= (currentPagination.value?.total || 0)) return 'nomore';
  156. return 'loadmore';
  157. });
  158. const getQuery = () => {
  159. const query: any = {};
  160. const type = currentType.value;
  161. if (searchText.value) {
  162. if (type === 'patent') query.patentName = searchText.value;
  163. else if (type === 'software') query.softwareName = searchText.value;
  164. else if (type === 'other') query.achievementName = searchText.value;
  165. else if (type === 'standard') query.standardName = searchText.value;
  166. else if (type === 'decision') query.reportTitle = searchText.value;
  167. else if (type === 'work') query.workName = searchText.value;
  168. else if (type === 'awards') query.awardName = searchText.value;
  169. else if (type === 'paper') query.paperName = searchText.value;
  170. else if (type === 'conference') query.codTitle = searchText.value;
  171. else if (type === 'special_activity') query.activityName = searchText.value;
  172. }
  173. return query;
  174. };
  175. const onTabChange = (e: any) => {
  176. current.value = e.index;
  177. // 切换 tab 时清空搜索或保留?通常清空比较清晰
  178. searchText.value = '';
  179. initData();
  180. };
  181. const onInput = () => {
  182. debouncedSearch();
  183. };
  184. const onSearch = () => {
  185. initData();
  186. };
  187. const debouncedSearch = debounce(() => {
  188. initData();
  189. }, 500);
  190. const initData = async () => {
  191. await achievementStore.fetchList(currentType.value, getQuery(), true);
  192. };
  193. const onRefresh = async () => {
  194. isTriggered.value = true;
  195. await initData();
  196. isTriggered.value = false;
  197. };
  198. const onLoadMore = () => {
  199. if (loadStatus.value === 'loadmore') {
  200. achievementStore.loadMore(currentType.value, getQuery());
  201. }
  202. };
  203. const goDetail = (item: any) => {
  204. uni.navigateTo({
  205. url: `/pages/achievement/detail?type=${currentType.value}&id=${item.id}&code=${item.patentCode || item.workCode || item.awardCode || item.paperCode || item.standardCode || ''}`
  206. });
  207. };
  208. const onScroll = (e: any) => {
  209. currentScrollTop.value = e.detail.scrollTop;
  210. };
  211. const backToTop = () => {
  212. // 先同步 scrollTopValue 为当前位置,再置为 0 触发滚动
  213. scrollTopValue.value = currentScrollTop.value;
  214. nextTick(() => {
  215. scrollTopValue.value = 0;
  216. });
  217. };
  218. onMounted(() => {
  219. initData();
  220. });
  221. </script>
  222. <style lang="scss" scoped>
  223. .achievement-container {
  224. height: 100vh;
  225. display: flex;
  226. flex-direction: column;
  227. background-color: #f8fafc;
  228. }
  229. .header {
  230. background-color: #ffffff;
  231. z-index: 100;
  232. box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.03);
  233. .search-section {
  234. padding: 24rpx 30rpx 12rpx;
  235. .search-bar {
  236. border-radius: 100rpx;
  237. overflow: hidden;
  238. transition: all 0.3s ease;
  239. &:focus-within {
  240. transform: scale(1.02);
  241. }
  242. }
  243. }
  244. .tab-section {
  245. border-bottom: 1rpx solid #f1f5f9;
  246. }
  247. }
  248. .list-wrapper {
  249. flex: 1;
  250. overflow: hidden;
  251. }
  252. .scroll-view {
  253. height: 100%;
  254. }
  255. .list-inner {
  256. padding: 30rpx;
  257. padding-bottom: env(safe-area-inset-bottom);
  258. }
  259. .loading-state {
  260. display: flex;
  261. justify-content: center;
  262. align-items: center;
  263. padding-top: 100rpx;
  264. }
  265. .card-list {
  266. display: flex;
  267. flex-direction: column;
  268. }
  269. .load-more-box {
  270. padding: 20rpx 0 40rpx;
  271. }
  272. .animate-fade-in {
  273. animation: fadeIn 0.5s ease both;
  274. }
  275. @keyframes fadeIn {
  276. from {
  277. opacity: 0;
  278. transform: translateY(20rpx);
  279. }
  280. to {
  281. opacity: 1;
  282. transform: translateY(0);
  283. }
  284. }
  285. </style>