Pārlūkot izejas kodu

完成咨询页面功能开发

yuanmingze 2 mēneši atpakaļ
vecāks
revīzija
81307fb1ae

+ 2 - 1
.env.development

@@ -1,5 +1,6 @@
 VITE_MODE=development
 VITE_APP_TYPE=dev
 VITE_PROJECT_TITLE=要易小助手
-VITE_BASE_API=https://preapi.yaoyi.net
+VITE_BASE_API=https://pre1.yaoyi.net
+# VITE_BASE_API=https://preapi.yaoyi.net
 VITE_WX_APPID=wxd03398e1bff2b241

+ 1 - 0
components.d.ts

@@ -27,6 +27,7 @@ declare module 'vue' {
     WdPopup: typeof import('@wot-ui/ui/components/wd-popup/wd-popup.vue')['default']
     WdProgress: typeof import('@wot-ui/ui/components/wd-progress/wd-progress.vue')['default']
     WdSearch: typeof import('@wot-ui/ui/components/wd-search/wd-search.vue')['default']
+    WdSwiper: typeof import('@wot-ui/ui/components/wd-swiper/wd-swiper.vue')['default']
     WdTag: typeof import('@wot-ui/ui/components/wd-tag/wd-tag.vue')['default']
     WdToast: typeof import('@wot-ui/ui/components/wd-toast/wd-toast.vue')['default']
   }

+ 2 - 1
src/components/auth/AuthInputItem.vue

@@ -64,9 +64,10 @@ interface Props {
 const props = withDefaults(defineProps<Props>(), {
   type: 'text',
   placeholder: '',
-  maxlength: undefined,
+  maxlength: 50,
   error: '',
   passwordToggle: false,
+  disabled: false,
 })
 
 /* ---------- emits ---------- */

+ 36 - 0
src/constants/regionOptions.ts

@@ -0,0 +1,36 @@
+export const provinces = [
+  { label: '北京市', value: '11' },
+  { label: '天津市', value: '12' },
+  { label: '河北省', value: '13' },
+  { label: '山西省', value: '14' },
+  { label: '内蒙古自治区', value: '15' },
+  { label: '辽宁省', value: '21' },
+  { label: '吉林省', value: '22' },
+  { label: '黑龙江省', value: '23' },
+  { label: '上海市', value: '31' },
+  { label: '江苏省', value: '32' },
+  { label: '浙江省', value: '33' },
+  { label: '安徽省', value: '34' },
+  { label: '福建省', value: '35' },
+  { label: '江西省', value: '36' },
+  { label: '山东省', value: '37' },
+  { label: '河南省', value: '41' },
+  { label: '湖北省', value: '42' },
+  { label: '湖南省', value: '43' },
+  { label: '广东省', value: '44' },
+  { label: '广西壮族自治区', value: '45' },
+  { label: '海南省', value: '46' },
+  { label: '重庆市', value: '50' },
+  { label: '四川省', value: '51' },
+  { label: '贵州省', value: '52' },
+  { label: '云南省', value: '53' },
+  { label: '西藏自治区', value: '54' },
+  { label: '陕西省', value: '61' },
+  { label: '甘肃省', value: '62' },
+  { label: '青海省', value: '63' },
+  { label: '宁夏回族自治区', value: '64' },
+  { label: '新疆维吾尔自治区', value: '65' },
+  { label: '台湾', value: '66' },
+  { label: '香港', value: '67' },
+  { label: '澳门', value: '68' },
+]

+ 239 - 0
src/pages-common/banner-detail/index.vue

@@ -0,0 +1,239 @@
+<template>
+  <page-meta :page-style="`overflow:${showContactPopup ? 'hidden' : 'visible'};`" />
+
+  <view class="detail-page">
+    <template v-if="currentDetail">
+      <image
+        class="detail-image"
+        :class="{ 'has-fixed-action': currentDetail.buttonText }"
+        :src="currentDetail.detailImage"
+        :show-menu-by-longpress="true"
+        mode="widthFix"
+      />
+
+      <view v-if="currentDetail.buttonText" class="fixed-action">
+        <view class="action-button" @tap="showInfo">
+          {{ currentDetail.buttonText }}
+        </view>
+      </view>
+    </template>
+
+    <view v-else class="empty-state">
+      <text class="empty-title">暂无详情内容</text>
+      <text class="empty-desc">当前轮播图详情不存在或参数异常</text>
+    </view>
+
+    <wd-popup
+      v-model="showContactPopup"
+      position="bottom"
+      closable
+      lock-scroll
+      safe-area-inset-bottom
+      custom-style="border-radius: 32rpx 32rpx 0 0;"
+    >
+      <view class="info-content">
+        <view class="popup-handle" />
+
+        <view class="info-title">联系方式</view>
+        <view class="info-tips">可添加企业微信详细沟通</view>
+
+        <view class="qrcode-card">
+          <image
+            src="https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/img/banner-ercode.jpg"
+            class="er-code"
+            :show-menu-by-longpress="true"
+            mode="aspectFit"
+          />
+        </view>
+
+        <view class="qrcode-tip">长按二维码保存或识别</view>
+      </view>
+    </wd-popup>
+  </view>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+
+import { onLoad } from '@dcloudio/uni-app'
+
+const detailId = ref('')
+const showContactPopup = ref(false)
+
+const detailList = [
+  {
+    id: 'banner-consult',
+    title: '点击咨询',
+    detailImage: 'https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/img/banner01Detail.png',
+    buttonText: '点击咨询',
+  },
+  {
+    id: 'signup-consult',
+    title: '报名咨询',
+    detailImage: 'https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/img/banner03Detail.jpg',
+    buttonText: '报名咨询',
+  },
+  {
+    id: 'ad-20230504',
+    title: '广告详情',
+    detailImage: 'https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/img/ad20230504001.jpeg',
+  },
+  {
+    id: 'academic-agent',
+    title: '学术推广代理服务商',
+    detailImage: 'https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/img/ad20250317.png',
+  },
+  {
+    id: 'tianshan-snow-lotus-cover-1',
+    title: '天山雪莲单方制剂',
+    detailImage:
+      'https://yy-cloud-oss-public.oss-cn-beijing.aliyuncs.com/template/tianshanian-ad-conent1.jpg',
+  },
+  {
+    id: 'tianshan-snow-lotus-cover-2',
+    title: '天山雪莲推广图',
+    detailImage:
+      'https://yy-cloud-oss-public.oss-cn-beijing.aliyuncs.com/template/tianshanian-ad-conent2.jpg',
+  },
+  {
+    id: 'jianping-jinxing',
+    title: '健脾金杏',
+    detailImage:
+      'https://yy-cloud-oss-public.oss-cn-beijing.aliyuncs.com/image/jianpingjinxing.png',
+  },
+]
+
+const currentDetail = computed(() => {
+  return detailList.find((item) => item.id === detailId.value)
+})
+
+const showInfo = () => {
+  showContactPopup.value = true
+}
+
+onLoad((options) => {
+  detailId.value = decodeURIComponent(options?.id || '')
+})
+</script>
+
+<style scoped lang="scss">
+.detail-page {
+  min-height: 100vh;
+  background: #f7f8fa;
+}
+
+.detail-image {
+  width: 750rpx;
+  display: block;
+  background: #fff;
+}
+
+.has-fixed-action {
+  padding-bottom: 170rpx;
+}
+
+.fixed-action {
+  position: fixed;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  z-index: 20;
+  box-sizing: border-box;
+  padding: 22rpx 30rpx calc(22rpx + env(safe-area-inset-bottom));
+  background: rgba(255, 255, 255, 0.96);
+  box-shadow: 0 -8rpx 28rpx rgba(0, 0, 0, 0.08);
+  backdrop-filter: blur(12rpx);
+}
+
+.action-button {
+  height: 88rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 999rpx;
+  background: linear-gradient(135deg, #4db7ff 0%, #248cff 100%);
+  box-shadow: 0 12rpx 26rpx rgba(36, 140, 255, 0.28);
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #fff;
+}
+
+.info-content {
+  box-sizing: border-box;
+  min-height: 560rpx;
+  padding: 24rpx 40rpx 52rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  background: #fff;
+  border-radius: 32rpx 32rpx 0 0;
+}
+
+.popup-handle {
+  width: 72rpx;
+  height: 8rpx;
+  border-radius: 999rpx;
+  background: #e5e5e5;
+}
+
+.info-title {
+  margin-top: 38rpx;
+  font-size: 38rpx;
+  font-weight: 700;
+  color: #222;
+}
+
+.info-tips {
+  margin-top: 20rpx;
+  font-size: 26rpx;
+  color: #777;
+}
+
+.qrcode-card {
+  margin-top: 34rpx;
+  width: 392rpx;
+  height: 392rpx;
+  box-sizing: border-box;
+  padding: 16rpx;
+  border-radius: 28rpx;
+  background: #fff;
+  box-shadow:
+    0 16rpx 42rpx rgba(0, 0, 0, 0.08),
+    inset 0 0 0 1rpx #f0f0f0;
+}
+
+.er-code {
+  width: 360rpx;
+  height: 360rpx;
+  display: block;
+  border-radius: 18rpx;
+}
+
+.qrcode-tip {
+  margin-top: 22rpx;
+  font-size: 24rpx;
+  color: #aaa;
+}
+
+.empty-state {
+  min-height: 100vh;
+  box-sizing: border-box;
+  padding-top: 260rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  background: #fff;
+}
+
+.empty-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.empty-desc {
+  margin-top: 16rpx;
+  font-size: 26rpx;
+  color: #999;
+}
+</style>

+ 14 - 3
src/pages.json

@@ -16,11 +16,15 @@
     },
 
     {
-      "path": "pages/news/index",
+      "path": "pages/information/index",
       "style": {
-        "navigationStyle": "custom"
+        "navigationBarTitleText": "资讯",
+        "enablePullDownRefresh": true,
+        "onReachBottomDistance": 80,
+        "navigationStyle": "default"
       }
     },
+
     {
       "path": "pages/mine/index",
       "style": {
@@ -57,6 +61,13 @@
             "navigationBarTitleText": "web",
             "navigationStyle": "default"
           }
+        },
+        {
+          "path": "banner-detail/index",
+          "style": {
+            "navigationBarTitleText": "详情",
+            "navigationStyle": "default"
+          }
         }
       ]
     },
@@ -284,7 +295,7 @@
         "text": "工作台"
       },
       {
-        "pagePath": "pages/news/index",
+        "pagePath": "pages/information/index",
         "iconPath": "static/images/tabbar/news.png",
         "selectedIconPath": "static/images/tabbar/newsAct.png",
         "text": "资讯"

+ 337 - 0
src/pages/information/components/academic/AcademicInfoPanel.vue

@@ -0,0 +1,337 @@
+<template>
+  <view class="academic-panel">
+    <InformationBannerSwiper />
+
+    <InformationSearchBar @search="handleSearch" @region-change="handleRegionChange" />
+
+    <view class="article-list">
+      <view
+        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>
+
+      <view v-if="!loading && articleList.length === 0" class="empty-state">
+        <view class="empty-title">暂无内容</view>
+        <view class="empty-desc">换个区域或关键词试试</view>
+      </view>
+
+      <view v-if="articleList.length > 0" class="load-more">
+        <text v-if="loading">加载中...</text>
+        <text v-else-if="finished">没有更多了</text>
+        <text v-else>上拉加载更多</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+
+import { getArticlePageApi } from '@/services/modules/information'
+import type {
+  ArticleItem,
+  ArticlePageRequest,
+  ArticlePageResponse,
+} from '@/services/modules/information/type'
+
+import InformationBannerSwiper from './InformationBannerSwiper.vue'
+import InformationSearchBar from './InformationSearchBar.vue'
+
+interface ArticleSearchParams {
+  region: string
+  keyword: string
+}
+
+interface RegionOption {
+  label: string
+  value: string
+}
+
+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 articleList = ref<ArticleItem[]>([])
+const loading = ref(false)
+const finished = ref(false)
+
+let requestSeq = 0
+
+const buildRequestParams = (current: number): ArticlePageRequest => {
+  const requestParams: ArticlePageRequest = {
+    current,
+    size: pageParams.size,
+  }
+
+  const title = pageParams.title?.trim()
+  const area = pageParams.area?.trim()
+
+  if (title) {
+    requestParams.title = title
+  }
+
+  if (area) {
+    requestParams.area = area
+  }
+
+  return requestParams
+}
+
+const getArticleList = async (current = 1, append = false): Promise<void> => {
+  if (loading.value) return
+  if (append && finished.value) return
+
+  const seq = ++requestSeq
+
+  if (!append) {
+    finished.value = false
+  }
+
+  loading.value = true
+
+  try {
+    const res = await getArticlePageApi(buildRequestParams(current))
+    const pageResponse: ArticlePageResponse = res.data
+
+    if (seq !== requestSeq) return
+
+    const records = Array.isArray(pageResponse.records) ? pageResponse.records : []
+
+    articleList.value = append ? articleList.value.concat(records) : records
+
+    pageParams.current = pageResponse.current
+
+    finished.value =
+      records.length < pageParams.size ||
+      pageResponse.current >= pageResponse.pages ||
+      articleList.value.length >= pageResponse.total
+  } catch (error) {
+    console.error('获取文章分页失败:', error)
+
+    uni.showToast({
+      title: '内容加载失败',
+      icon: 'none',
+    })
+  } finally {
+    if (seq === requestSeq) {
+      loading.value = false
+    }
+  }
+}
+
+const refresh = async (): Promise<void> => {
+  pageParams.current = 1
+  await getArticleList(1, false)
+}
+
+const loadMore = async (): Promise<void> => {
+  await getArticleList(pageParams.current + 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 handleArticleClick = (item: ArticleItem): void => {
+  uni.navigateTo({
+    url: `/pages-sub-packages/information/detail/index?type=academic&id=${item.id}`,
+  })
+}
+
+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()
+})
+
+defineExpose({
+  refresh,
+  loadMore,
+})
+</script>
+
+<style scoped lang="scss">
+.academic-panel {
+  background: #fff;
+}
+
+.article-list {
+  padding: 8rpx 32rpx 32rpx;
+  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;
+}
+
+.empty-state {
+  padding: 140rpx 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;
+  font-size: 24rpx;
+  color: #999;
+}
+</style>

+ 91 - 0
src/pages/information/components/academic/InformationBannerSwiper.vue

@@ -0,0 +1,91 @@
+<template>
+  <view class="swiper-card">
+    <wd-swiper
+      v-model:current="current"
+      :list="swiperList"
+      value-key="image"
+      autoplay
+      :interval="3000"
+      height="250rpx"
+      image-mode="aspectFill"
+      :indicator="{ type: 'dots' }"
+      custom-class="information-swiper"
+      @click="handleSwiperClick"
+    />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+interface SwiperItem {
+  id: string
+  image: string
+}
+
+interface SwiperClickEvent {
+  index: number
+  item: SwiperItem
+}
+
+const current = ref(0)
+
+const swiperList = ref<SwiperItem[]>([
+  {
+    id: 'academic-agent',
+    image: 'https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/img/banner_20250317.png',
+  },
+  {
+    id: 'tianshan-snow-lotus-cover-1',
+    image:
+      'https://yy-cloud-oss-public.oss-cn-beijing.aliyuncs.com/template/tianshanian-ad-cover1.png',
+  },
+  {
+    id: 'tianshan-snow-lotus-cover-2',
+    image:
+      'https://yy-cloud-oss-public.oss-cn-beijing.aliyuncs.com/template/tianshanian-ad-cover2.png',
+  },
+  {
+    id: 'jianping-jinxing',
+    image: 'https://yy-cloud-oss-public.oss-cn-beijing.aliyuncs.com/image/banner690250.png',
+  },
+])
+
+const handleSwiperClick = ({ item }: SwiperClickEvent): void => {
+  if (!item.id) return
+
+  uni.navigateTo({
+    url: `/pages-common/banner-detail/index?id=${encodeURIComponent(item.id)}`,
+  })
+}
+</script>
+
+<style scoped lang="scss">
+.swiper-card {
+  position: relative;
+  overflow: hidden;
+  border-radius: 26rpx;
+  margin: 10rpx 30rpx;
+  background: linear-gradient(135deg, #eef9ff 0%, #ffffff 100%);
+  box-shadow:
+    0 18rpx 48rpx rgba(38, 137, 255, 0.16),
+    0 6rpx 18rpx rgba(0, 0, 0, 0.06);
+}
+
+.swiper-card::after {
+  content: '';
+  position: absolute;
+  inset: 0;
+  z-index: 2;
+  pointer-events: none;
+  border-radius: 26rpx;
+  box-shadow: inset 0 0 0 1rpx rgba(255, 255, 255, 0.75);
+}
+
+:deep(.information-swiper) {
+  width: 100%;
+  height: 250rpx !important;
+  overflow: hidden;
+  border-radius: 26rpx;
+}
+</style>

+ 147 - 0
src/pages/information/components/academic/InformationSearchBar.vue

@@ -0,0 +1,147 @@
+<template>
+  <view class="wrap">
+    <view class="card">
+      <!-- 左侧 region -->
+      <view class="region" @tap="openPicker">
+        <text class="region-text" :class="{ active: !!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>
+
+      <!-- 分割 -->
+      <view class="split" />
+
+      <!-- 输入 -->
+      <view class="input-box">
+        <wd-search v-model="keyword" placeholder="请输入关键词" hide-cancel :show-action="false" />
+      </view>
+    </view>
+
+    <!-- picker -->
+    <wd-picker v-model:visible="visible" :columns="provinces" @confirm="onConfirm" />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+
+import { debounce } from '@/plugins/debounce'
+
+import { provinces } from '@/constants/regionOptions'
+
+const emit = defineEmits<{
+  search: [{ region: string; keyword: string }]
+}>()
+
+const region = ref('')
+const keyword = ref('')
+const visible = ref(false)
+
+const regionLabel = computed(() => {
+  return region.value || '区域'
+})
+
+const openPicker = () => (visible.value = true)
+
+const clearRegion = () => {
+  region.value = ''
+}
+
+interface PickerConfirmEvent {
+  selectedItems: [
+    {
+      label: string
+      value: string
+    },
+  ]
+  value: string[]
+}
+const onConfirm = (value: PickerConfirmEvent) => {
+  region.value = value.selectedItems[0].label
+}
+
+const doSearch = () => {
+  emit('search', {
+    region: region.value,
+    keyword: keyword.value.trim(),
+  })
+}
+
+const trigger = debounce(doSearch, 350)
+
+watch([region, keyword], trigger)
+</script>
+
+<style scoped lang="scss">
+.wrap {
+  padding: 24rpx;
+}
+
+.card {
+  display: flex;
+  align-items: center;
+  height: 92rpx;
+
+  background: #ffffff;
+  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;
+}
+
+/* region */
+.region {
+  display: flex;
+  align-items: center;
+  padding: 0 22rpx;
+  height: 100%;
+  flex-shrink: 0;
+}
+
+.region-text {
+  font-size: 28rpx;
+  margin-right: 6rpx;
+  color: #8a94a6;
+}
+
+.region-text.active {
+  color: #1f2937;
+  font-weight: 600;
+}
+
+.clear {
+  margin-left: 10rpx;
+  font-size: 22rpx;
+  color: #9aa4b2;
+}
+
+/* split */
+.split {
+  width: 1rpx;
+  height: 42rpx;
+  background: linear-gradient(#e5e7eb, #f3f4f6, #e5e7eb);
+}
+
+/* input */
+.input-box {
+  flex: 1;
+  padding-right: 16rpx;
+}
+
+/* 去掉 wd-search 默认背景 */
+:deep(.wd-search__block) {
+  background: transparent !important;
+  box-shadow: none !important;
+  border: none !important;
+}
+
+:deep(.wd-search__input) {
+  font-size: 30rpx;
+}
+</style>

+ 1 - 1
src/pages/news/index.vue → src/pages/information/components/album/ProductAlbumPanel.vue

@@ -1,5 +1,5 @@
 <template>
-  <view>新闻仔细</view>
+  <view>// 产品相册内容面板</view>
 </template>
 
 <script setup lang="ts"></script>

+ 82 - 0
src/pages/information/components/page/InformationTabs.vue

@@ -0,0 +1,82 @@
+<template>
+  <view class="tabs">
+    <view
+      v-for="item in tabs"
+      :key="item.value"
+      class="tab-item"
+      :class="{ active: modelValue === item.value }"
+      @tap="handleChange(item.value)"
+    >
+      <text class="tab-text">{{ item.label }}</text>
+      <view v-if="modelValue === item.value" class="tab-line" />
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+interface TabItem {
+  label: string
+  value: string
+}
+
+const props = defineProps<{
+  modelValue: string
+  tabs: TabItem[]
+}>()
+
+const emit = defineEmits<{
+  'update:modelValue': [value: string]
+  change: [value: string]
+}>()
+
+const handleChange = (value: string): void => {
+  if (value === props.modelValue) return
+
+  emit('update:modelValue', value)
+  emit('change', value)
+}
+</script>
+
+<style scoped lang="scss">
+.tabs {
+  position: sticky;
+  top: 0;
+  z-index: 99;
+  height: 112rpx;
+  display: flex;
+  align-items: center;
+  background: #fff;
+  border-bottom: 1rpx solid #f2f2f2;
+}
+
+.tab-item {
+  position: relative;
+  flex: 1;
+  height: 112rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.tab-text {
+  font-size: 32rpx;
+  font-weight: 500;
+  color: #999;
+}
+
+.tab-item.active .tab-text {
+  color: #333;
+  font-weight: 700;
+}
+
+.tab-line {
+  position: absolute;
+  bottom: 20rpx;
+  left: 50%;
+  width: 56rpx;
+  height: 6rpx;
+  border-radius: 999rpx;
+  background: #40a9ff;
+  transform: translateX(-50%);
+}
+</style>

+ 115 - 0
src/pages/information/components/page/LoginRequiredState.vue

@@ -0,0 +1,115 @@
+<template>
+  <view class="empty-state">
+    <view class="empty-icon">
+      <text class="empty-icon-text">!</text>
+    </view>
+
+    <text class="empty-title">{{ title }}</text>
+    <text class="empty-desc">{{ description }}</text>
+
+    <button class="login-btn" hover-class="login-btn-hover" @tap="handleGoLogin">
+      {{ buttonText }}
+    </button>
+  </view>
+</template>
+
+<script setup lang="ts">
+interface Props {
+  title?: string
+  description?: string
+  buttonText?: string
+  loginUrl?: string
+  enableRedirect?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  title: '暂无内容',
+  description: '登录后可查看学术信息和产品相册',
+  buttonText: '去登录',
+  loginUrl: '/pages/login/index',
+  enableRedirect: true,
+})
+
+const getCurrentPageUrl = (): string => {
+  const pages = getCurrentPages()
+  const currentPage = pages[pages.length - 1]
+
+  if (!currentPage?.route) {
+    return ''
+  }
+
+  return `/${currentPage.route}`
+}
+
+const handleGoLogin = (): void => {
+  uni.navigateTo({
+    url: props.loginUrl,
+  })
+}
+</script>
+
+<style scoped lang="scss">
+.empty-state {
+  min-height: calc(100vh - 88rpx);
+  box-sizing: border-box;
+  padding: 180rpx 48rpx 0;
+  display: flex;
+  align-items: center;
+  flex-direction: column;
+  background: #fff;
+}
+
+.empty-icon {
+  width: 128rpx;
+  height: 128rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 50%;
+  background: #f2f8ff;
+}
+
+.empty-icon-text {
+  font-size: 72rpx;
+  font-weight: 700;
+  color: #40a9ff;
+}
+
+.empty-title {
+  margin-top: 36rpx;
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #333;
+}
+
+.empty-desc {
+  margin-top: 16rpx;
+  font-size: 26rpx;
+  line-height: 38rpx;
+  color: #999;
+  text-align: center;
+}
+
+.login-btn {
+  width: 320rpx;
+  height: 80rpx;
+  margin-top: 48rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-radius: 999rpx;
+  border: none;
+  background: #40a9ff;
+  color: #fff;
+  font-size: 30rpx;
+  line-height: 80rpx;
+}
+
+.login-btn::after {
+  border: none;
+}
+
+.login-btn-hover {
+  opacity: 0.85;
+}
+</style>

+ 109 - 0
src/pages/information/index.vue

@@ -0,0 +1,109 @@
+<template>
+  <view class="page">
+    <LoginRequiredState v-if="!isLoggedIn" />
+
+    <template v-else>
+      <InformationTabs v-model="activeTab" :tabs="tabs" />
+
+      <AcademicInfoPanel v-if="activeTab === 'academic'" ref="academicPanelRef" />
+
+      <ProductAlbumPanel v-else ref="albumPanelRef" />
+    </template>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue'
+
+import { onPullDownRefresh, onReachBottom, onShareAppMessage } from '@dcloudio/uni-app'
+
+import { useUserStore } from '@/stores/modules/user'
+
+import AcademicInfoPanel from './components/academic/AcademicInfoPanel.vue'
+import ProductAlbumPanel from './components/album/ProductAlbumPanel.vue'
+import InformationTabs from './components/page/InformationTabs.vue'
+import LoginRequiredState from './components/page/LoginRequiredState.vue'
+
+type TabKey = 'academic' | 'album'
+
+interface TabItem {
+  label: string
+  value: TabKey
+}
+
+interface PanelExpose {
+  refresh: () => Promise<void>
+  loadMore: () => Promise<void>
+}
+
+interface ShareDataset {
+  id?: string | number
+  title?: string
+  activeTab?: TabKey
+}
+
+const userStore = useUserStore()
+
+const isLoggedIn = computed<boolean>(() => {
+  return Boolean(userStore.isLoggedIn)
+})
+
+const tabs: TabItem[] = [
+  {
+    label: '学术信息',
+    value: 'academic',
+  },
+  {
+    label: '产品相册',
+    value: 'album',
+  },
+]
+
+const activeTab = ref<TabKey>('academic')
+
+const academicPanelRef = ref<PanelExpose | null>(null)
+const albumPanelRef = ref<PanelExpose | null>(null)
+
+const currentPanel = computed<PanelExpose | null>(() => {
+  return activeTab.value === 'academic' ? academicPanelRef.value : albumPanelRef.value
+})
+
+onReachBottom(() => {
+  if (!isLoggedIn.value) return
+
+  void currentPanel.value?.loadMore()
+})
+
+onPullDownRefresh(async () => {
+  try {
+    if (!isLoggedIn.value) return
+
+    await currentPanel.value?.refresh()
+  } finally {
+    uni.stopPullDownRefresh()
+  }
+})
+
+onShareAppMessage((res) => {
+  const dataset =
+    res.from === 'button' ? (res.target?.dataset as ShareDataset | undefined) : undefined
+
+  const id = dataset?.id
+  const title = dataset?.title || '要易小助手'
+  const type = dataset?.activeTab || activeTab.value
+
+  return {
+    title,
+    path: id
+      ? `/pages-sub-packages/information/detail/index?type=${type}&id=${id}`
+      : '/pages/information/index',
+  }
+})
+</script>
+
+<style scoped lang="scss">
+.page {
+  min-height: 100vh;
+  background: #fff;
+}
+</style>

+ 9 - 0
src/services/modules/information/index.ts

@@ -0,0 +1,9 @@
+import http from '../../index'
+import type { ArticlePageRequest, ArticlePageResponse } from './type'
+
+export const getArticlePageApi = (data: ArticlePageRequest) => {
+  return http.get<ArticlePageResponse>(`/admin/api/getArticlePage`, data, {
+    loading: true,
+    loadingText: '加载中...',
+  })
+}

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

@@ -0,0 +1,43 @@
+export interface ArticlePageRequest {
+  current: number
+  size: number
+  title?: string
+  area?: string
+}
+
+export interface ArticlePageResponse {
+  records: ArticleItem[]
+  total: number
+  size: number
+  current: number
+  pages: number
+  orders: string[]
+  hitCount: boolean
+  searchCount: boolean
+  optimizeCountSql: boolean
+}
+
+export interface ArticleItem {
+  id: number
+  title: string
+  description: string
+  author: string
+  fm: string | null
+  area: string
+  content: string
+  createTime: string
+  createUser: string | null
+  delFlag: number
+  enableFlag: number
+  hotFlag: number
+  newFlag: number
+  deptId: number | null
+  forwardNum: number
+  readNum: number
+  score: number | null
+  source: string | null
+  sourceEnt: string[]
+  tenantId: number
+  updateTime: string | null
+  updateUser: string | null
+}