Explorar o código

完成学术信息页面

yuanmingze hai 2 meses
pai
achega
e1ec8f6efb

+ 193 - 0
src/pages/information/components/academic/AcademicArticleItem.vue

@@ -0,0 +1,193 @@
+<template>
+  <view class="article-card" hover-class="article-card--hover" @click="emit('select', item)">
+    <view class="article-content">
+      <view class="article-title">
+        {{ item.title }}
+      </view>
+
+      <view v-if="summary" class="article-desc">
+        {{ summary }}
+      </view>
+    </view>
+
+    <view class="article-footer">
+      <view class="article-meta">
+        <text v-if="item.area" class="meta-text">{{ item.area }}</text>
+        <text v-if="item.area && createDate" class="meta-dot">·</text>
+        <text v-if="createDate" class="meta-text">{{ createDate }}</text>
+      </view>
+
+      <button
+        class="share-button"
+        hover-class="share-button--hover"
+        open-type="share"
+        :data-title="item.title"
+        :data-id="item.id"
+        data-active-tab="academic"
+        @click.stop
+      >
+        <image class="share-icon" :src="shareIconUrl" mode="aspectFit" />
+        <text class="share-text">转发</text>
+      </button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { ArticleItem } from '@/services/modules/information/type'
+
+const props = defineProps<{
+  item: ArticleItem
+  shareIconUrl: string
+}>()
+
+const emit = defineEmits<{
+  select: [item: ArticleItem]
+}>()
+
+const createDate = computed(() => {
+  return props.item.createTime ? props.item.createTime.slice(0, 10) : ''
+})
+
+const summary = computed(() => {
+  return stripHtml(props.item.description || props.item.content)
+})
+
+const stripHtml = (value?: string): string => {
+  if (!value) return ''
+
+  return value
+    .replace(/<[^>]+>/g, '')
+    .replace(/&nbsp;/g, ' ')
+    .replace(/&amp;/g, '&')
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
+    .replace(/\s+/g, ' ')
+    .trim()
+}
+</script>
+
+<style scoped lang="scss">
+.article-card {
+  padding: 30rpx 28rpx 26rpx;
+  margin-bottom: 22rpx;
+  background: #fff;
+  border: 2rpx solid rgba(38, 81, 125, 0.06);
+  border-radius: 24rpx;
+  box-shadow: 0 10rpx 30rpx rgba(31, 45, 61, 0.05);
+  transition:
+    opacity 0.18s ease,
+    transform 0.18s ease;
+}
+
+.article-card--hover {
+  opacity: 0.86;
+  transform: scale(0.99);
+}
+
+.article-content {
+  min-width: 0;
+}
+
+.article-title {
+  display: -webkit-box;
+  overflow: hidden;
+
+  font-size: 32rpx;
+  font-weight: 600;
+  line-height: 46rpx;
+  color: #202733;
+  letter-spacing: 0.2rpx;
+  word-break: break-all;
+
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+
+.article-desc {
+  display: -webkit-box;
+  overflow: hidden;
+
+  margin-top: 16rpx;
+  font-size: 26rpx;
+  font-weight: 400;
+  line-height: 40rpx;
+  color: #667085;
+  word-break: break-all;
+
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+
+.article-footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 22rpx;
+  margin-top: 26rpx;
+}
+
+.article-meta {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  min-width: 0;
+  overflow: hidden;
+}
+
+.meta-text {
+  overflow: hidden;
+  font-size: 24rpx;
+  line-height: 34rpx;
+  color: #98a2b3;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+}
+
+.meta-dot {
+  flex-shrink: 0;
+  padding: 0 10rpx;
+  font-size: 24rpx;
+  line-height: 34rpx;
+  color: #c4cad4;
+}
+
+.share-button {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  gap: 8rpx;
+
+  min-width: 142rpx;
+  height: 56rpx;
+  padding: 0 22rpx;
+  margin: 0;
+
+  line-height: 56rpx;
+  background: linear-gradient(135deg, #eef8ff 0%, #e8f4ff 100%);
+  border: none;
+  border-radius: 999rpx;
+}
+
+.share-button::after {
+  border: none;
+}
+
+.share-button--hover {
+  opacity: 0.78;
+}
+
+.share-icon {
+  width: 28rpx;
+  height: 28rpx;
+}
+
+.share-text {
+  font-size: 26rpx;
+  font-weight: 500;
+  color: #318fe8;
+}
+</style>

+ 62 - 230
src/pages/information/components/academic/AcademicInfoPanel.vue

@@ -2,50 +2,21 @@
   <view class="academic-panel">
     <InformationBannerSwiper />
 
-    <InformationSearchBar @search="handleSearch" @region-change="handleRegionChange" />
+    <view class="search-section">
+      <InformationSearchBar @search="handleSearch" />
+    </view>
 
     <view class="article-list">
-      <view
+      <AcademicArticleItem
         v-for="item in articleList"
         :key="item.id"
-        class="article-item"
-        @click="handleArticleClick(item)"
-      >
-        <view class="article-title">
-          {{ item.title }}
-        </view>
-
-        <view v-if="getArticleSummary(item)" class="article-desc">
-          {{ getArticleSummary(item) }}
-        </view>
-
-        <view class="article-footer">
-          <view class="article-info">
-            <text v-if="item.area">{{ item.area }}</text>
-            <text v-if="item.area && formatDate(item.createTime)"> · </text>
-            <text v-if="formatDate(item.createTime)">
-              {{ formatDate(item.createTime) }}
-            </text>
-          </view>
-
-          <button
-            class="forward-button"
-            hover-class="forward-button--hover"
-            open-type="share"
-            :data-title="item.title"
-            :data-id="item.id"
-            data-active-tab="academic"
-            @click.stop
-          >
-            <image class="forward-icon" :src="shareIconUrl" mode="aspectFit" />
-            <text>转发</text>
-          </button>
-        </view>
-      </view>
+        :item="item"
+        :share-icon-url="shareIconUrl"
+        @select="handleArticleClick"
+      />
 
       <view v-if="!loading && articleList.length === 0" class="empty-state">
-        <view class="empty-title">暂无内容</view>
-        <view class="empty-desc">换个区域或关键词试试</view>
+        <wd-empty icon="no-content" tip="暂无内容" />
       </view>
 
       <view v-if="articleList.length > 0" class="load-more">
@@ -58,68 +29,49 @@
 </template>
 
 <script setup lang="ts">
-import { onMounted, reactive, ref } from 'vue'
+import { onMounted, ref } from 'vue'
 
 import { getArticlePageApi } from '@/services/modules/information'
-import type {
-  ArticleItem,
-  ArticlePageRequest,
-  ArticlePageResponse,
-} from '@/services/modules/information/type'
+import type { ArticleItem, ArticlePageRequest } from '@/services/modules/information/type'
 
+import { useUserStore } from '@/stores/modules/user'
+
+import AcademicArticleItem from './AcademicArticleItem.vue'
 import InformationBannerSwiper from './InformationBannerSwiper.vue'
 import InformationSearchBar from './InformationSearchBar.vue'
 
-interface ArticleSearchParams {
-  region: string
-  keyword: string
-}
-
-interface RegionOption {
-  label: string
-  value: string
-}
-
+const PAGE_SIZE = 10
 const shareIconUrl = 'https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/img/fenxiang.png'
 
-const pageParams = reactive<ArticlePageRequest>({
-  current: 1,
-  size: 10,
-  title: '',
-  area: '',
-})
+const userStore = useUserStore()
 
 const articleList = ref<ArticleItem[]>([])
 const loading = ref(false)
 const finished = ref(false)
 
-let requestSeq = 0
+const currentPage = ref(1)
+const searchTitle = ref('')
+const searchArea = ref('')
 
-const buildRequestParams = (current: number): ArticlePageRequest => {
-  const requestParams: ArticlePageRequest = {
-    current,
-    size: pageParams.size,
-  }
+let latestRequestId = 0
 
-  const title = pageParams.title?.trim()
-  const area = pageParams.area?.trim()
-
-  if (title) {
-    requestParams.title = title
-  }
+const buildRequestParams = (current: number): ArticlePageRequest => {
+  const title = searchTitle.value.trim()
+  const area = searchArea.value.trim()
 
-  if (area) {
-    requestParams.area = area
+  return {
+    current,
+    size: PAGE_SIZE,
+    shareUserId: userStore.currentUserInfo?.userId || '',
+    ...(title ? { title } : {}),
+    ...(area ? { area } : {}),
   }
-
-  return requestParams
 }
 
 const getArticleList = async (current = 1, append = false): Promise<void> => {
-  if (loading.value) return
-  if (append && finished.value) return
+  if (append && (loading.value || finished.value)) return
 
-  const seq = ++requestSeq
+  const requestId = ++latestRequestId
 
   if (!append) {
     finished.value = false
@@ -128,21 +80,22 @@ const getArticleList = async (current = 1, append = false): Promise<void> => {
   loading.value = true
 
   try {
-    const res = await getArticlePageApi(buildRequestParams(current))
-    const pageResponse: ArticlePageResponse = res.data
+    const { data } = await getArticlePageApi(buildRequestParams(current))
 
-    if (seq !== requestSeq) return
+    if (requestId !== latestRequestId) return
 
-    const records = Array.isArray(pageResponse.records) ? pageResponse.records : []
+    const records = Array.isArray(data?.records) ? data.records : []
+    const page = Number(data?.current) || current
+    const pages = Number(data?.pages) || 0
+    const total = Number(data?.total) || 0
 
-    articleList.value = append ? articleList.value.concat(records) : records
+    articleList.value = append ? [...articleList.value, ...records] : records
+    currentPage.value = page
 
-    pageParams.current = pageResponse.current
+    const isLastPage = pages > 0 ? page >= pages : records.length < PAGE_SIZE
+    const isTotalLoaded = total > 0 ? articleList.value.length >= total : false
 
-    finished.value =
-      records.length < pageParams.size ||
-      pageResponse.current >= pageResponse.pages ||
-      articleList.value.length >= pageResponse.total
+    finished.value = records.length < PAGE_SIZE || isLastPage || isTotalLoaded
   } catch (error) {
     console.error('获取文章分页失败:', error)
 
@@ -151,33 +104,25 @@ const getArticleList = async (current = 1, append = false): Promise<void> => {
       icon: 'none',
     })
   } finally {
-    if (seq === requestSeq) {
+    if (requestId === latestRequestId) {
       loading.value = false
     }
   }
 }
 
 const refresh = async (): Promise<void> => {
-  pageParams.current = 1
-  await getArticleList(1, false)
+  currentPage.value = 1
+  await getArticleList(1)
 }
 
 const loadMore = async (): Promise<void> => {
-  await getArticleList(pageParams.current + 1, true)
+  await getArticleList(currentPage.value + 1, true)
 }
 
-const handleSearch = (params: ArticleSearchParams): void => {
-  pageParams.title = params.keyword
-  pageParams.area = params.region ?? ''
-  void refresh()
-}
-
-const handleRegionChange = (region: RegionOption): void => {
-  console.log('region', region)
-
-  pageParams.area = region.value ? region.label : ''
-
-  void refresh()
+const handleSearch = (params: { region: string; keyword: string }): void => {
+  searchTitle.value = params.keyword
+  searchArea.value = params.region
+  refresh()
 }
 
 const handleArticleClick = (item: ArticleItem): void => {
@@ -186,30 +131,7 @@ const handleArticleClick = (item: ArticleItem): void => {
   })
 }
 
-const formatDate = (value: string): string => {
-  return value ? value.slice(0, 10) : ''
-}
-
-const stripHtml = (value: string): string => {
-  if (!value) return ''
-
-  return value
-    .replace(/<[^>]+>/g, '')
-    .replace(/&nbsp;/g, ' ')
-    .replace(/&amp;/g, '&')
-    .replace(/&lt;/g, '<')
-    .replace(/&gt;/g, '>')
-    .replace(/\s+/g, ' ')
-    .trim()
-}
-
-const getArticleSummary = (item: ArticleItem): string => {
-  return stripHtml(item.description || item.content || '')
-}
-
-onMounted(() => {
-  void refresh()
-})
+onMounted(refresh)
 
 defineExpose({
   refresh,
@@ -219,119 +141,29 @@ defineExpose({
 
 <style scoped lang="scss">
 .academic-panel {
-  background: #fff;
+  min-height: 100%;
 }
 
-.article-list {
-  padding: 8rpx 32rpx 32rpx;
+.search-section {
+  position: relative;
+  z-index: 1;
   background: #fff;
 }
 
-.article-item {
-  padding: 30rpx 0 28rpx;
-  border-bottom: 2rpx solid #f2f2f2;
-}
-
-.article-title {
-  font-size: 32rpx;
-  font-weight: 500;
-  line-height: 46rpx;
-  color: #333;
-  word-break: break-all;
-  overflow: hidden;
-  display: -webkit-box;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
-}
-
-.article-desc {
-  margin-top: 16rpx;
-  font-size: 26rpx;
-  font-weight: 400;
-  line-height: 38rpx;
-  color: #666;
-  word-break: break-all;
-  overflow: hidden;
-  display: -webkit-box;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
-}
-
-.article-footer {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 24rpx;
-  margin-top: 24rpx;
-}
-
-.article-info {
-  flex: 1;
-  min-width: 0;
-  font-size: 26rpx;
-  line-height: 36rpx;
-  color: #999;
-  overflow: hidden;
-  white-space: nowrap;
-  text-overflow: ellipsis;
-}
-
-.forward-button {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  gap: 10rpx;
-  flex-shrink: 0;
-  width: 164rpx;
-  height: 58rpx;
-  padding: 0;
-  margin: 0;
-  border: none;
-  border-radius: 999rpx;
-  background: #ecf6fe;
-  line-height: 58rpx;
-}
-
-.forward-button::after {
-  border: none;
-}
-
-.forward-button--hover {
-  opacity: 0.82;
-}
-
-.forward-icon {
-  width: 30rpx;
-  height: 30rpx;
-}
-
-.forward-button text {
-  font-size: 28rpx;
-  font-weight: 500;
-  color: #3fa3f1;
+.article-list {
+  padding: 20rpx 28rpx 36rpx;
 }
 
 .empty-state {
-  padding: 140rpx 0;
+  padding: 160rpx 0;
   text-align: center;
 }
 
-.empty-title {
-  font-size: 30rpx;
-  font-weight: 500;
-  color: #333;
-}
-
-.empty-desc {
-  margin-top: 12rpx;
-  font-size: 26rpx;
-  color: #999;
-}
-
 .load-more {
-  padding: 28rpx 0 34rpx;
-  text-align: center;
+  padding: 30rpx 0 36rpx;
   font-size: 24rpx;
-  color: #999;
+  line-height: 34rpx;
+  color: #a3acba;
+  text-align: center;
 }
 </style>

+ 119 - 60
src/pages/information/components/academic/InformationSearchBar.vue

@@ -1,26 +1,40 @@
 <template>
   <view class="wrap">
-    <view class="card">
-      <!-- 左侧 region -->
+    <view class="search-bar">
+      <!-- 左侧区域 -->
       <view class="region" @tap="openPicker">
-        <text class="region-text" :class="{ active: !!region }">
+        <text class="region-text" :class="{ active: Boolean(region) }">
           {{ regionLabel }}
         </text>
 
-        <wd-icon v-if="region" name="close" color="#8a94a6" class="clear" @tap.stop="clearRegion" />
-        <wd-icon v-else name="down" color="#8a94a6" size="36rpx" class="clear"></wd-icon>
+        <view v-if="region" class="region-clear-hit" @tap.stop="clearRegion">
+          <wd-icon name="close" size="22rpx" color="#8a94a6" />
+        </view>
+
+        <wd-icon v-else name="down" size="28rpx" color="#8a94a6" class="region-down" />
       </view>
 
-      <!-- 分割 -->
+      <!-- 分割线 -->
       <view class="split" />
 
-      <!-- 输入 -->
-      <view class="input-box">
-        <wd-search v-model="keyword" placeholder="请输入关键词" hide-cancel :show-action="false" />
+      <!-- 输入区域 -->
+      <view class="keyword">
+        <input
+          v-model="keyword"
+          class="keyword-input"
+          type="text"
+          placeholder="请输入关键词"
+          placeholder-class="keyword-placeholder"
+          confirm-type="search"
+          @confirm="doSearch"
+        />
+
+        <view v-if="keyword" class="keyword-clear-hit" @tap.stop="clearKeyword">
+          <wd-icon name="close" size="26rpx" color="#9aa4b2" />
+        </view>
       </view>
     </view>
 
-    <!-- picker -->
     <wd-picker v-model:visible="visible" :columns="provinces" @confirm="onConfirm" />
   </view>
 </template>
@@ -36,31 +50,36 @@ const emit = defineEmits<{
   search: [{ region: string; keyword: string }]
 }>()
 
+interface PickerItem {
+  label: string
+  value: string
+}
+
+interface PickerConfirmEvent {
+  selectedItems: PickerItem[]
+  value: string[]
+}
+
 const region = ref('')
 const keyword = ref('')
 const visible = ref(false)
 
-const regionLabel = computed(() => {
-  return region.value || '区域'
-})
+const regionLabel = computed(() => region.value || '区域')
 
-const openPicker = () => (visible.value = true)
+const openPicker = () => {
+  visible.value = true
+}
 
 const clearRegion = () => {
   region.value = ''
 }
 
-interface PickerConfirmEvent {
-  selectedItems: [
-    {
-      label: string
-      value: string
-    },
-  ]
-  value: string[]
+const clearKeyword = () => {
+  keyword.value = ''
 }
-const onConfirm = (value: PickerConfirmEvent) => {
-  region.value = value.selectedItems[0].label
+
+const onConfirm = (event: PickerConfirmEvent) => {
+  region.value = event.selectedItems[0]?.label ?? ''
 }
 
 const doSearch = () => {
@@ -70,78 +89,118 @@ const doSearch = () => {
   })
 }
 
-const trigger = debounce(doSearch, 350)
+const triggerSearch = debounce(doSearch)
 
-watch([region, keyword], trigger)
+watch([region, keyword], () => {
+  triggerSearch()
+})
 </script>
 
 <style scoped lang="scss">
 .wrap {
-  padding: 24rpx;
+  padding: 24rpx 28rpx 28rpx;
+  background: #ffffff;
 }
 
-.card {
+.search-bar {
   display: flex;
   align-items: center;
-  height: 92rpx;
+  width: 100%;
+  height: 72rpx;
+  padding: 0 12rpx 0 22rpx;
 
   background: #ffffff;
+  border: 2rpx solid #e6ebf2;
   border-radius: 999rpx;
-
-  box-shadow:
-    0 12rpx 30rpx rgba(0, 0, 0, 0.06),
-    inset 0 0 0 1rpx rgba(0, 0, 0, 0.04);
-
-  overflow: hidden;
+  box-sizing: border-box;
 }
 
-/* region */
+/* 左侧区域 */
 .region {
   display: flex;
   align-items: center;
-  padding: 0 22rpx;
-  height: 100%;
   flex-shrink: 0;
+  width: 138rpx;
+  height: 100%;
+  min-width: 0;
 }
 
 .region-text {
+  max-width: 96rpx;
   font-size: 28rpx;
-  margin-right: 6rpx;
-  color: #8a94a6;
+  line-height: 1;
+  color: #7b8794;
+
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
 }
 
 .region-text.active {
-  color: #1f2937;
-  font-weight: 600;
+  color: #2f3a4a;
+  font-weight: 500;
 }
 
-.clear {
-  margin-left: 10rpx;
-  font-size: 22rpx;
-  color: #9aa4b2;
+.region-clear-hit {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+
+  width: 40rpx;
+  height: 56rpx;
+  margin-left: 2rpx;
 }
 
-/* split */
+.region-down {
+  flex-shrink: 0;
+  margin-left: 4rpx;
+}
+
+/* 中间短分割线 */
 .split {
-  width: 1rpx;
-  height: 42rpx;
-  background: linear-gradient(#e5e7eb, #f3f4f6, #e5e7eb);
+  flex-shrink: 0;
+  width: 2rpx;
+  height: 34rpx;
+  margin: 0 22rpx;
+  background: #e4e9f0;
+}
+
+/* 右侧输入区域 */
+.keyword {
+  display: flex;
+  align-items: center;
+  flex: 1;
+  min-width: 0;
+  height: 100%;
 }
 
-/* input */
-.input-box {
+.keyword-input {
   flex: 1;
-  padding-right: 16rpx;
+  min-width: 0;
+  height: 100%;
+  padding: 0;
+
+  font-size: 28rpx;
+  line-height: 72rpx;
+  color: #2f3a4a;
+  background: transparent;
+  border: none;
+  box-sizing: border-box;
 }
 
-/* 去掉 wd-search 默认背景 */
-:deep(.wd-search__block) {
-  background: transparent !important;
-  box-shadow: none !important;
-  border: none !important;
+.keyword-placeholder {
+  color: #9aa4b2;
 }
 
-:deep(.wd-search__input) {
-  font-size: 30rpx;
+.keyword-clear-hit {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  flex-shrink: 0;
+
+  width: 72rpx;
+  height: 72rpx;
+  margin-right: -10rpx;
 }
 </style>

+ 1 - 0
src/services/modules/information/type.d.ts

@@ -1,6 +1,7 @@
 export interface ArticlePageRequest {
   current: number
   size: number
+  shareUserId: string
   title?: string
   area?: string
 }