Răsfoiți Sursa

Merge branch 'master' into xqc_431

yujinwen 2 zile în urmă
părinte
comite
ee38931a26
47 a modificat fișierele cu 3920 adăugiri și 12 ștergeri
  1. 1 0
      package.json
  2. 48 0
      src/api/etaReport/collect.js
  3. 7 0
      src/api/etaReport/index.js
  4. 59 0
      src/api/etaReport/report.js
  5. 4 0
      src/api/system/common.js
  6. BIN
      src/assets/imgs/audio_next.png
  7. BIN
      src/assets/imgs/audio_pause.png
  8. BIN
      src/assets/imgs/audio_pause_big.png
  9. BIN
      src/assets/imgs/audio_play.png
  10. BIN
      src/assets/imgs/audio_play_big.png
  11. BIN
      src/assets/imgs/audio_prev.png
  12. 11 0
      src/assets/svg/chart.svg
  13. 10 0
      src/assets/svg/report.svg
  14. 201 0
      src/components/Audio.vue
  15. 75 0
      src/components/LoadList.vue
  16. 52 0
      src/hooks/usePermissionMenu.js
  17. 44 4
      src/layout/Index.vue
  18. 63 0
      src/layout/components/Aslide.vue
  19. 2 0
      src/main.js
  20. 2 1
      src/router/index.js
  21. 2 2
      src/router/modules/etaChart.js
  22. 63 0
      src/router/modules/report.js
  23. 4 0
      src/store/index.js
  24. 123 0
      src/store/modules/report/index.js
  25. 21 1
      src/views/Login.vue
  26. 473 0
      src/views/report/Classify.vue
  27. 509 0
      src/views/report/Detail.vue
  28. 320 0
      src/views/report/Index.vue
  29. 108 0
      src/views/report/PosterDetail.vue
  30. 279 0
      src/views/report/Search.vue
  31. 84 0
      src/views/report/components/AudioBox.vue
  32. 55 0
      src/views/report/components/ChapterWrap.vue
  33. 31 0
      src/views/report/components/Disclaimer.vue
  34. 68 0
      src/views/report/components/ReportContent.vue
  35. 100 0
      src/views/report/components/RightSlideWrap.vue
  36. 54 0
      src/views/report/css/index.scss
  37. 15 0
      src/views/report/hooks/useReport.js
  38. 23 0
      src/views/user/favorite/Index.vue
  39. 1 1
      src/views/user/favorite/components/ClassifyWrap.vue
  40. 75 0
      src/views/user/favorite/components/CollectAslide.vue
  41. 128 0
      src/views/user/favorite/components/CollectReport.vue
  42. 27 0
      src/views/user/favorite/components/EditClassify.vue
  43. 1 1
      src/views/user/favorite/components/MoveChart.vue
  44. 289 0
      src/views/user/favorite/components/MoveReport.vue
  45. 192 0
      src/views/user/favorite/components/ReportClassifyWrap.vue
  46. 3 2
      src/views/user/favorite/etaChart.vue
  47. 293 0
      src/views/user/favorite/etaReport.vue

+ 1 - 0
package.json

@@ -19,6 +19,7 @@
     "lodash": "^4.17.21",
     "moment": "^2.30.1",
     "normalize.css": "^8.0.1",
+    "pinia": "^3.0.2",
     "tdesign-icons-vue-next": "^0.2.2",
     "tdesign-vue-next": "^1.10.7",
     "vue": "^3.5.13",

+ 48 - 0
src/api/etaReport/collect.js

@@ -0,0 +1,48 @@
+import {get,post} from '@/api/index'
+
+// 研报收藏模块
+export default{
+  // 新增分类
+  addClassify:(params)=>{
+    return post('/report_collect/classify/add',params)
+  },
+  // 编辑分类
+  editClassify:(params)=>{
+    return post('/report_collect/classify/edit',params)
+  },
+  // 移动分类
+  moveClassify:(params)=>{
+    return post('/report_collect/classify/move',params)
+  },
+  // 删除分类
+  deleteClassify:(params)=>{
+    return post('/report_collect/classify/delete',params)
+  },
+  // 分类列表
+  classifyList:(params)=>{
+    return get('/report_collect/classify/list',params)
+  },
+
+  // 收藏报告
+  reportCollect:(params)=>{
+    return post('/report_collect/add',params)
+  },
+  // 取消收藏报告
+  reportCollectCancel:(params)=>{
+    return post('/report_collect/delete',params)
+  },
+  // 收藏报告列表数据
+  reportCollectList:(params)=>{
+    return get('/report_collect/list',params)
+  },
+  // 批量转移报告
+  reportCollectBatchMove:(params)=>{
+    return post('/report_collect/modify_collect_classify',params)
+  },
+
+  //报告的所属分类树
+  etaReportClassify: params => {
+    return get('/report/classify_tree',params)
+  }
+  
+}

+ 7 - 0
src/api/etaReport/index.js

@@ -0,0 +1,7 @@
+import apiEtaReport from './report'
+import apiEtaReportCollect from './collect'
+
+export {
+  apiEtaReport,
+  apiEtaReportCollect
+}

+ 59 - 0
src/api/etaReport/report.js

@@ -0,0 +1,59 @@
+import {get,post} from '@/api/index'
+
+// 研报模块
+export default {
+  /**
+   * 报告列表
+   * @param {*} params PageSize=&CurrentIndex=&ShowAll=&IsToday=&PermissionId=1
+   * @returns 
+   */
+  reportList: params => {
+    return get('/report/list',params)
+  },
+
+  /**
+   * 品种分类
+   * @param {*} params 
+   * @returns 
+   */
+  permissionList: params => {
+    return get('/report/permission_list',params)
+  },
+
+  /**
+   * 
+   * @param {*} params PageSize=5&CurrentIndex=1&ShowAll=&Keyword=
+   * @returns 
+   */
+  reportSearch: params => {
+    return get('/report/search_by_es',params)
+  },
+
+  /**
+   * 
+   * @param {*} params ReportId
+   * @returns 
+   */
+  reportDetail: params => {
+    return get('/report/detail',params)
+  },
+
+  /**
+   * 
+   * @param {*} params ReportId ChapterId
+   * @returns 
+   */
+  chapterDetail: params => {
+    return get('/report/chapter_detail',params)
+  },
+
+  //热门推荐
+  recommandList: params => {
+    return get('/report/hot_recommend',params)
+  },
+
+  //海报为
+  reportPoster: params => {
+    return get('/report/poster',params)
+  }
+}

+ 4 - 0
src/api/system/common.js

@@ -24,5 +24,9 @@ export default{
     //获取用户信息
     userInfo:()=>{
         return get('/user/base_info',{})
+    },
+
+    menuAuth: () => {
+        return get('/user/auth_list')
     }
 }

BIN
src/assets/imgs/audio_next.png


BIN
src/assets/imgs/audio_pause.png


BIN
src/assets/imgs/audio_pause_big.png


BIN
src/assets/imgs/audio_play.png


BIN
src/assets/imgs/audio_play_big.png


BIN
src/assets/imgs/audio_prev.png


+ 11 - 0
src/assets/svg/chart.svg

@@ -0,0 +1,11 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<mask id="mask0_796_8413" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="20" height="20">
+<rect width="20" height="20" fill="#D9D9D9"/>
+</mask>
+<g mask="url(#mask0_796_8413)">
+<path fill-rule="evenodd" clip-rule="evenodd" d="M3.75 3.75V16.25H16.25V3.75H3.75ZM3.5 2.5C2.94772 2.5 2.5 2.94772 2.5 3.5V16.5C2.5 17.0523 2.94772 17.5 3.5 17.5H16.5C17.0523 17.5 17.5 17.0523 17.5 16.5V3.5C17.5 2.94772 17.0523 2.5 16.5 2.5H3.5Z" fill="#333333"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M7.50002 10.5L11.9881 16.1101C12.2037 16.3796 12.16 16.7729 11.8905 16.9885C11.6209 17.2042 11.2276 17.1605 11.012 16.8909L7.50002 12.501L3.98807 16.8909C3.77244 17.1605 3.37913 17.2042 3.10959 16.9885C2.84005 16.7729 2.79635 16.3796 3.01198 16.1101L7.50002 10.5Z" fill="#333333"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M14.5 8.5L16.9881 11.6101C17.2037 11.8796 17.16 12.2729 16.8905 12.4885C16.6209 12.7042 16.2276 12.6605 16.012 12.3909L14.5 10.501L10.9881 14.8909C10.7724 15.1605 10.3791 15.2042 10.1096 14.9885C9.84005 14.7729 9.79635 14.3796 10.012 14.1101L14.5 8.5Z" fill="#333333"/>
+<path d="M8 7.5C8 8.32843 7.32843 9 6.5 9C5.67157 9 5 8.32843 5 7.5C5 6.67157 5.67157 6 6.5 6C7.32843 6 8 6.67157 8 7.5Z" fill="#333333"/>
+</g>
+</svg>

+ 10 - 0
src/assets/svg/report.svg

@@ -0,0 +1,10 @@
+<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<mask id="mask0_796_7932" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="20" height="20">
+<rect width="20" height="20" fill="#D9D9D9"/>
+</mask>
+<g mask="url(#mask0_796_7932)">
+</g>
+<path d="M6.33341 2.66651C6.50641 2.66574 6.67293 2.73224 6.7978 2.85197C6.92267 2.9717 6.9961 3.13528 7.0026 3.30816C7.00909 3.48103 6.94812 3.64966 6.83258 3.77842C6.71705 3.90717 6.55598 3.98598 6.38341 3.99818L6.33341 3.99984H4.00008V17.3332H16.0001V3.99984H13.8334C13.6604 4.00062 13.4939 3.93412 13.369 3.81438C13.2442 3.69465 13.1707 3.53107 13.1642 3.3582C13.1577 3.18532 13.2187 3.0167 13.3342 2.88794C13.4498 2.75918 13.6108 2.68038 13.7834 2.66818L13.8334 2.66651H16.0001C16.3422 2.66651 16.6712 2.79799 16.919 3.03375C17.1669 3.26951 17.3146 3.59152 17.3317 3.93318L17.3334 3.99984V17.3332C17.3334 17.6753 17.2019 18.0043 16.9662 18.2521C16.7304 18.5 16.4084 18.6477 16.0667 18.6648L16.0001 18.6665H4.00008C3.658 18.6665 3.32901 18.535 3.08114 18.2993C2.83328 18.0635 2.68552 17.7415 2.66841 17.3998L2.66675 17.3332V3.99984C2.66675 3.65776 2.79823 3.32877 3.03399 3.08091C3.26975 2.83305 3.59176 2.68528 3.93341 2.66818L4.00008 2.66651H6.33341Z" fill="#333333"/>
+<path d="M12.3334 1.3335H7.66675C6.56508 1.3335 5.66675 2.23183 5.66675 3.3335C5.66675 4.43516 6.56508 5.3335 7.66675 5.3335H12.3334C13.4351 5.3335 14.3334 4.43516 14.3334 3.3335C14.3334 2.23183 13.4351 1.3335 12.3334 1.3335ZM7.66675 2.66683H12.3334C12.6984 2.66683 13.0001 2.9685 13.0001 3.3335C13.0001 3.6985 12.6984 4.00016 12.3334 4.00016H7.66675C7.30175 4.00016 7.00008 3.6985 7.00008 3.3335C7.00008 2.9685 7.30175 2.66683 7.66675 2.66683Z" fill="#333333"/>
+<path d="M13.1953 8.19516L14.138 9.13782L9.80465 13.4712C9.67963 13.5961 9.51009 13.6663 9.33331 13.6663C9.15654 13.6663 8.987 13.5961 8.86198 13.4712L6.19531 10.8045L7.13798 9.86182L9.33331 12.0565L13.1953 8.19482V8.19516Z" fill="#333333"/>
+</svg>

+ 201 - 0
src/components/Audio.vue

@@ -0,0 +1,201 @@
+<script setup>
+import { useDraggable } from '@vueuse/core'
+import { CloseIcon } from 'tdesign-icons-vue-next'
+import { ref,computed } from 'vue'
+import { useReportStore } from '@/store/modules/report'
+import { throttle } from 'lodash'
+
+const store=useReportStore()
+
+const globalAudioIns=ref(null)
+store.audioData.INS=globalAudioIns
+
+// 格式化音频时长
+const formatAudioTime=(e)=>{
+    const minus = parseInt(e / 60);
+    const sec = parseInt(e % 60);
+    return `${minus > 9 ? minus : "0" + minus}:${sec > 9 ? sec : "0" + sec}`;
+}
+
+// 拖动头
+const drag = ref(null)
+const {style} = useDraggable(drag,{
+  initialValue: {},
+})
+
+// 当前音频地址
+const curUrl=computed(()=>{
+    return store.audioData.list[store.audioData.index]&&store.audioData.list[store.audioData.index].url||''
+})
+
+const curTime = ref(0)//当前音频播放时长
+const audioTimeupdate= throttle((e)=>{// 音频播放时间更新
+    curTime.value=e.target.currentTime
+    store.updateAudioTime(e.target.currentTime)
+},300)
+
+const audioOnError=(e)=>{
+    console.log('音频播放错误');
+}
+
+const audioOnEnd=()=>{// 音频播放结束事件
+    store.changeAudio('auto')
+}
+
+const audioOnPause=()=>{// 音频播放暂停事件
+    store.audioStatusChange('paused')
+}
+
+const audioOnPlay=()=>{// 音频开始播放事件
+    store.audioStatusChange('paly')
+}
+
+
+const handleClickAudioBtn=(type)=>{ //点击音频按钮
+    if(type==='center'){
+        if(store.audioData.paused){
+            store.audioData.INS.play()
+        }else{
+            store.audioData.INS.pause()
+        }
+    }else{
+        store.changeAudio(type)
+    }
+}
+
+//拖动进度条
+const handleDragTime=(e)=>{
+    store.audioData.INS.currentTime=e
+}
+
+// 关闭弹窗
+const closeAudioPop=()=>{
+    store.closeAudio()
+    store.closeAudioPop()
+}
+
+</script>
+
+<template>
+    <audio 
+        @ended="audioOnEnd" 
+        @pause="audioOnPause"
+        @play="audioOnPlay"
+        @timeupdate="audioTimeupdate"
+        @error="audioOnError"
+        :src="curUrl"
+        ref="globalAudioIns"
+        style="display: none;"
+        autoplay
+    ></audio>
+    <div :style="style" class="global-audio-pop-wrap" v-if="store.audioData.show">
+        <CloseIcon class="close-btn" @click="closeAudioPop"/>
+        <div class="drag-head" ref="drag"></div>
+        <div class="flex content">
+            <div class="flex btns" style="flex-shrink: 0;">
+                <!-- <div class="before-btn" @click="handleClickAudioBtn('before')"></div> -->
+                <div :class="['center-btn',!store.audioData.paused&&'center-btn-play']" @click="handleClickAudioBtn('center')"></div>
+                <!-- <div class="next-btn" @click="handleClickAudioBtn('next')"></div> -->
+            </div>
+            <div class="right-box">
+                <div class="top flex">
+                    <div style="text-align:center;font-size: 14px;color: #666;">{{store.audioData.list[store.audioData.index].name}}</div>
+                    <div class="time">{{formatAudioTime(curTime)}}</div>
+                </div>
+                <t-slider 
+                    v-model="curTime"
+                    :label="false"
+                    @change="handleDragTime"
+                    :max="Number(store.audioData.list[store.audioData.index].time)"
+                />
+            </div>
+        </div>
+    </div>
+</template>
+
+<style lang="scss" scoped>
+.global-audio-pop-wrap{
+    overflow: hidden;
+    position: fixed;
+    z-index: 5000;
+    left: 50%;
+    bottom: 20px;
+    left: 15vw;
+    width: 70vw;
+    height: 80px;
+    background-color: rgba(255, 255, 255, 0.9);
+    box-shadow: 0px 4px 40px 1px rgba(0, 0, 0, 0.04);
+    border-radius: 4px;
+    border: 1px solid #EFEFEF;
+    .close-btn{
+        position: absolute;
+        top: 10px;
+        right: 20px;
+        width: 14px;
+        height: 14px;
+    }
+    .drag-head{
+        cursor: move;
+        height: 20px;
+    }
+    .content{
+        align-items: center;
+        padding-left: 40px;
+        .before-btn,.next-btn{
+            width: 24px;
+            height: 24px;
+            background-image: url('@/assets/imgs/audio_prev.png');
+            background-size: cover;
+            display: block;
+            cursor: pointer;
+        }
+        .next-btn{
+            background-image: url('@/assets/imgs/audio_next.png');
+        }
+        .center-btn{
+            display: block;
+            width: 34px;
+            height: 34px;
+            // margin: 0 12px;
+            background-image: url('@/assets/imgs/audio_pause.png');
+            background-size: cover;
+            cursor: pointer;
+        }
+        .center-btn-play{
+            background-image: url('@/assets/imgs/audio_play.png');
+        }
+
+        .btns {
+            align-items: center;
+        }
+
+        .right-box{
+            flex: 1;
+            margin-left: 10px;
+            padding-right: 80px;
+            align-items: center;
+
+            .top {
+                justify-content: space-between;
+                align-items: center;
+                margin-bottom: 10px;
+            }
+            .time{
+                color: #666;
+                font-size: 14px;
+                margin-left: 20px;
+            }
+            :deep(.t-slider__button){
+                border-color: #7C54FF;
+            }
+            :deep(.slider__rail){
+                background-color: #DAE0FE;
+            }
+            :deep(.t-slider__track){
+                background-color: #4B64F7;
+            }
+        }
+    }
+    
+}
+</style>

+ 75 - 0
src/components/LoadList.vue

@@ -0,0 +1,75 @@
+<script setup>
+/**
+ * 公共列表组件
+ */
+const props=defineProps({
+    count:0,//当前加载的列表数据数量
+    loading:false,
+    finished:false,//是否加载完
+    isEmpty:false,//是否为空数据
+    emptyMsg:{
+        type:String,
+        default:'暂无数据'
+    },//为空时描述文字
+})
+const emit=defineEmits(['listOnload'])
+const handleClickLoadMore=()=>{
+    emit('listOnload')
+}
+
+</script>
+
+<template>
+    <div class="self-list-wrap">
+        <slot></slot>
+        <div class="empty-box" v-if="props.isEmpty">
+            <img src="@/assets/imgs/nodata.png" alt="">
+            <p>{{props.emptyMsg}}</p>
+        </div>
+        <div class="bot-load" v-if="!props.finished&&!props.loading&&!props.isEmpty">
+            <div class="btn" @click="handleClickLoadMore">加载更多</div>
+        </div>
+        <div class="bot-load" v-if="!props.finished&&props.loading">
+            <div class="loading-text">加载中...</div>
+        </div>
+        <p class="nomore-text" v-if="!props.isEmpty&&props.finished&&props.count>20">没有更多了~</p>
+    </div>
+</template>
+
+<style lang="scss" scoped>
+.self-list-wrap{
+    .empty-box{
+        padding-top: 100px;
+        text-align: center;
+        color: #C0C4CC;
+        img{
+            width: 200px;
+        }
+    }
+    .bot-load{
+        .btn{
+            margin: 20px auto;
+            width: 112px;
+            height: 30px;
+            background: #FFFFFF;
+            border-radius: 20px;
+            border: 1px solid var(--td-brand-color);
+            color: var(--td-brand-color);
+            font-size: 14px;
+            text-align: center;
+            line-height: 30px;
+            cursor: pointer;
+        }
+        .loading-text{
+            margin: 20px auto;
+            text-align: center;
+            color: #666;
+            height: 30px;
+        }
+    }
+    .nomore-text{
+        text-align: center;
+        color: #999;
+    }
+}
+</style>

+ 52 - 0
src/hooks/usePermissionMenu.js

@@ -0,0 +1,52 @@
+import commonApi from '@/api/system/common'
+import {$confirmDialog} from '@/plugin/dialog'
+
+export function usePermissionMenu(){
+
+    async function getMenu(){
+      return await commonApi.menuAuth()
+    }
+
+
+    const menu = [
+      {  
+        title: '研报',
+        path: '/etaReport/index',
+      },
+      {  
+        title: '图库',
+        path: '/etaChart/index',
+      },
+    ]
+
+    async function getPermissionMenu() {
+      const res = await getMenu()
+      if(res.Ret !== 200) return 
+
+      if(res.Data.ReportPermissions||res.Data.ChartPermissions) {
+        let haveReport = res.Data.ReportPermissions ? true : false,
+            haveChart = res.Data.ChartPermissions ? true : false;
+        
+        let arr = menu.map( _ => ({
+          ..._,
+          show: _.path==='/etaReport/index' ? haveReport : haveChart
+        }))
+
+        return arr.filter(_ =>_.show);
+      }else {
+        $confirmDialog({
+          header:'提示',
+          body: '您无菜单权限,请联系管理员!',
+          confirmBtn:'知道了',
+          cancelBtn:null,
+          closeBtn:false
+        }).then(()=>{
+        });
+      }  
+    }
+
+    return {
+        getMenu,
+        getPermissionMenu
+    }
+}

+ 44 - 4
src/layout/Index.vue

@@ -1,12 +1,41 @@
 <script setup>
 import {useUserInfo} from '@/hooks/useUserInfo'
-import { useRouter } from 'vue-router';
+import { computed } from 'vue';
+import AudioBox from '@/components/Audio.vue'
+import { useRoute, useRouter } from 'vue-router';
+import Aslide from './components/Aslide.vue'
+import { usePermissionMenu } from '@/hooks/usePermissionMenu'
 
 const {getUserInfo,userInfo}=useUserInfo()
 const router=useRouter()
+const route= useRoute()
+
+const { getPermissionMenu } = usePermissionMenu()
+
+const menu = ref([])
+async function init() {
+	const res = await getPermissionMenu()
+	menu.value = res||[];
+	if(menu.value.length) {
+		router.replace(menu.value[0].path)
+	}else {
+		router.replace('/login')
+	}
+}
+init()
 
 getUserInfo()
 
+const isShowAslide = computed(() => {
+	return !['/etaChart/favorite','/etaReport/favorite'].includes(route.path)
+})
+
+const dynamicStyle = computed(() => {
+	const isStylePage = route.path.includes('/etaReport')
+
+	return isStylePage ? 'background:#fff' : ''
+})
+
 
 async function handleLoginOut(){
 	await $confirmDialog({
@@ -18,13 +47,20 @@ async function handleLoginOut(){
 	router.replace('/login')
 }
 
+function handleJumpCollect() {
+	let path = menu.value[0].path === '/etaReport/index' ? '/etaReport/favorite' : '/etaChart/favorite';
+	router.push(path)
+}
 </script>
 
 <template>
   <div class="layout-wrap">
     <div class="flex header">
-      <img class="logo" src="@/assets/imgs/logo.png" alt="" />
-			<div class="fav-btn" @click="$router.push('/etaChart/favorite')">我的收藏</div>
+			<div class="flex">
+      	<img class="logo" src="@/assets/imgs/logo.png" alt="" />
+				<Aslide v-if="isShowAslide" :menu="menu"/>
+			</div>
+			<div class="fav-btn" @click="handleJumpCollect">{{menu.length ? '我的收藏':''}}</div>
       <t-popup placement="bottom-left" overlayInnerClassName="header-userInfo-pop-wrap">
 				<template #content>
 					<div class="content">
@@ -59,10 +95,13 @@ async function handleLoginOut(){
 				</div>
 			</t-popup>
     </div>
-    <div class="layout-content">
+    <div class="layout-content" :style="dynamicStyle">
       <router-view />
     </div>
   </div>
+
+		<!-- 音频模块 -->
+	<AudioBox></AudioBox>
 </template>
 
 <style lang="scss">
@@ -124,6 +163,7 @@ async function handleLoginOut(){
     padding: 0 20px;
     .logo {
       width: 168px;
+			margin-right: 20px;
     }
 		.fav-btn{
 			cursor: pointer;

+ 63 - 0
src/layout/components/Aslide.vue

@@ -0,0 +1,63 @@
+<script setup>
+import { ref, watch } from 'vue'
+import { useRouter } from 'vue-router'
+import { useRoute } from 'vue-router'
+
+const route = useRoute()
+const router = useRouter()
+
+const props = defineProps({
+  menu: Array
+})
+
+// const menu = [
+//   {  
+//     title: '研报',
+//     path: '/etaReport/index'
+//   },
+//   {  
+//     title: '图库',
+//     path: '/etaChart/index'
+//   },
+// ]
+const activeMenu = ref(route.path)
+
+watch(() => route.path,
+  (newpath) => {
+    activeMenu.value = newpath
+  }
+)
+function handleClickItem(value) {
+  console.log(value)
+  if(value !== route.path) {
+    router.push(value)
+  }
+}
+</script>
+<template>
+  <t-head-menu v-model="activeMenu" theme="light" @change="handleClickItem">
+    <t-menu-item :value="item.path" v-for="item in props.menu" :key="item.path">{{item.title}}</t-menu-item>
+  </t-head-menu>
+</template>
+<style scoped lang="scss">
+.t-menu {
+  background: transparent;
+  .t-menu__item {
+    color: #FFFFFF59;
+    border-radius: initial;
+    border-bottom: 2px solid transparent;
+    &:hover {
+      color: #fff;
+    }
+
+  }
+  .t-menu__item.t-is-active {
+    color: #fff;
+    border-color: #fff;
+    background: transparent;
+  }
+  .t-menu__item:hover:not(.t-is-active):not(.t-is-opened):not(.t-is-disabled) {
+    background-color: transparent;
+  }
+}
+</style>

+ 2 - 0
src/main.js

@@ -1,6 +1,7 @@
 import { createApp } from 'vue'
 import App from './App.vue'
 import router from "./router";
+import {store} from './store'
 import 'normalize.css'
 import './styles/common.scss'
 import './styles/tdesign.scss'
@@ -23,5 +24,6 @@ app.config.globalProperties.formatTime=formatTime
 registerGlobalComponents(app)
 
 app.use(router)
+app.use(store)
 app.directive('drag', dragDirective);
 app.mount('#app')

+ 2 - 1
src/router/index.js

@@ -13,7 +13,8 @@ const routes = [
   ...appAllRoutes,
   {
     path:'/',
-    redirect: '/etaChart/index'
+    // redirect: '/etaReport/index'
+    component:()=>import('@/layout/Index.vue'),
   },
   {
     path:'/login',

+ 2 - 2
src/router/modules/etaChart.js

@@ -28,8 +28,8 @@ export default[
       },
       {
         path:'favorite',
-        name:'ETAChartFavorite',
-        component:()=>import('@/views/user/favorite/etaChart.vue'),
+        name:'ETAFavoriteChart',
+        component:()=>import('@/views/user/favorite/Index.vue'),
         meta:{
           title:'我的收藏'
         },

+ 63 - 0
src/router/modules/report.js

@@ -0,0 +1,63 @@
+import LayoutIndex from "@/layout/Index.vue";
+
+export default[
+  {
+    path:'/etaReport',
+    name:'ETAReport',
+    redirect: '/etaReport/index',
+    component:LayoutIndex,
+    meta:{
+      title:'研报'
+    },
+    children:[
+      {
+        path:'index',
+        name:'ETAReportIndex',
+        component:()=>import('@/views/report/Index.vue'),
+        meta:{
+          title:'研报',
+        },
+      },
+      {
+        path:'classify',
+        name:'ETAReportClassify',
+        component:()=>import('@/views/report/Classify.vue'),
+        meta:{
+          title:'研报分类',
+        },
+      },
+      {
+        path:'detail',
+        name:'ETAReportDetail',
+        component:()=>import('@/views/report/Detail.vue'),
+        meta:{
+          title:'研报详情',
+        },
+      },
+      {
+        path:'search',
+        name:'ETAReportSearch',
+        component:()=>import('@/views/report/Search.vue'),
+        meta:{
+          title:'研报搜索',
+        },
+      },
+      {
+        path:'favorite',
+        name:'ETAFavoriteReport',
+        component:()=>import('@/views/user/favorite/Index.vue'),
+        meta:{
+          title:'我的收藏'
+        },
+      },
+      {
+        path:'posterDetail',
+        name:'ETAPosterDetail',
+        component:()=>import('@/views/report/PosterDetail.vue'),
+        meta:{
+          title:'海报'
+        },
+      },
+    ]
+  }
+]

+ 4 - 0
src/store/index.js

@@ -0,0 +1,4 @@
+import { createPinia } from 'pinia'
+
+
+export const store = createPinia()

+ 123 - 0
src/store/modules/report/index.js

@@ -0,0 +1,123 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { MessagePlugin } from 'tdesign-vue-next';
+
+
+export const useReportStore = defineStore('reportState',() => {
+
+  const showAllReport = ref(false); //显示全部筛选项 系统共享 神奇的设计
+  function changeShowAll(bool) {
+    showAllReport.value = bool;
+  }
+
+  const posterInfo = ref({});//首页海报位信息
+  function setPosterInfo(info) {
+    posterInfo.value = info
+  }
+
+  const audioData = ref({
+    INS:null,//音频实例
+    list:[],//音频数据{time:音频时长,url:音频地址,name:音频名称}
+    index:0,//当前播放第几个
+    activityId:0,//活动的id
+    reportId:0,//报告的id
+    voiceId:0,//语音播报列表id
+    questionId:0,//问答社区的id
+    paused:true,//当前音频播放状态
+    show:false,//是否显示音频弹窗
+    audioCurrentTime:0,//音频当前播放的时间
+  })
+
+    // 设置音频播放
+    function addAudio(e){
+
+      audioData.value.index=e.index
+      audioData.value.list=e.list
+      audioData.value.reportId=e.reportId||0
+      audioData.value.show=true
+      audioData.value.INS.play()
+    }
+
+    //切换音频
+    function changeAudio(e){
+      console.log(e)
+      if(e==='before'){//点击按钮切换上一首
+        if(audioData.value.index===0){
+          MessagePlugin.warning('当前为第一首')
+        }else{
+          audioData.value.index--
+          
+        }
+      }else if(e==='next'){//点击按钮切换下一首
+        if(audioData.value.index==audioData.value.list.length-1){
+          MessagePlugin.warning('当前为最后一首')
+        }else{ 
+          audioData.value.index++
+        }
+      }else if(e==='auto'){//自动播放切换下一首
+        if(audioData.value.index==audioData.value.list.length-1){
+          audioData.value.list=[]
+          audioData.value.index=0
+          audioData.value.activityId=0
+          audioData.value.reportId=0
+          audioData.value.voiceId=0
+          audioData.value.questionId=0
+          audioData.value.INS=null
+          audioData.value.show=false
+          audioData.value.paused=false
+        }else{
+          audioData.value.index++
+        }
+      }
+    }
+    
+    // 关闭音频
+    function closeAudio(e){
+      
+      audioData.value.list=[]
+      audioData.value.index=0
+      audioData.value.activityId=0
+      audioData.value.reportId=0
+      audioData.value.voiceId=0
+      audioData.value.questionId=0
+      audioData.value.INS=null
+      audioData.value.show=false
+      audioData.value.paused=false
+      
+    }
+
+    //音频状态切换
+    function audioStatusChange(e){
+      if(e=='paused'){
+        audioData.value.paused=true
+      }else{
+        audioData.value.paused=false
+        audioData.value.show=true
+      }
+    }
+
+    //关闭音频弹窗
+    function closeAudioPop(){
+      audioData.value.show=false
+    }
+
+    //更新音频当前播放时间
+    function updateAudioTime(e){
+      audioData.value.audioCurrentTime=e
+    }
+
+  return {
+    audioData,
+    addAudio,
+    changeAudio,
+    closeAudio,
+    audioStatusChange,
+    closeAudioPop,
+    updateAudioTime,
+    showAllReport,
+    changeShowAll,
+    posterInfo,
+    setPosterInfo
+  }
+
+})

+ 21 - 1
src/views/Login.vue

@@ -3,10 +3,13 @@ import { useTemplateRef } from "vue"
 import {apiSystemCommon} from '@/api/system'
 import { useRouter } from "vue-router"
 import {useToken} from '@/hooks/useToken'
+import { usePermissionMenu } from '@/hooks/usePermissionMenu'
 
 const {setToken}=useToken()
 const router=useRouter()
 
+const { getMenu } = usePermissionMenu()
+
 //图形验证码
 const imgCodeUrl=ref('')
 let imgCodeId=''
@@ -65,9 +68,26 @@ async function onSubmit(){
     return
   }
   setToken(res.Data.Authorization)
-  router.replace('/etaChart/index')
+  handleMenuJump()
 }
 
+async function handleMenuJump(){
+  const res = await getMenu()
+  if(res.Ret !== 200) return 
+  if(res.Data.ReportPermissions||res.Data.ChartPermissions) {
+    let path = res.Data.ReportPermissions ? '/etaReport/index' : '/etaChart/index'
+    router.replace(path)
+  }else {
+    $confirmDialog({
+      header:'提示',
+      body: '您无菜单权限,请联系管理员!',
+      confirmBtn:'知道了',
+      cancelBtn:null,
+      closeBtn:false
+    }).then(()=>{
+    });
+  }
+}
 
 // 获取手机号验证码
 let countDownTimer=null

+ 473 - 0
src/views/report/Classify.vue

@@ -0,0 +1,473 @@
+<script setup>
+import { nextTick, reactive, ref } from 'vue'
+import { SearchIcon,InfoCircleFilledIcon,ChevronDownIcon  } from 'tdesign-icons-vue-next';
+import { useRoute, useRouter } from 'vue-router'
+import { apiEtaReport } from '@/api/etaReport'
+import { useReportStore } from '@/store/modules/report'
+import { useReport } from './hooks/useReport'
+import moment from 'moment'
+import 'moment/locale/zh-cn';
+moment.locale('zh-cn')
+import LoadList from '@/components/LoadList.vue'
+
+const router = useRouter()
+const route = useRoute()
+
+const  { handleToDetail } = useReport();
+const reportStore = useReportStore();
+
+const listState = reactive({
+  list: [],
+  showAll: reportStore.showAllReport||false,
+  page: 1,
+  pageSize: 20,
+  finished: true,
+  loading: false,
+  searchText: ''
+})
+
+async function getList() {
+  listState.loading=true
+  const res = await apiEtaReport.reportList({
+    PageSize: listState.pageSize,
+    CurrentIndex: listState.page,
+    ShowAll: listState.showAll,
+    IsToday: false,
+    PermissionId: Number(selectSubType.value)
+  })
+
+  listState.loading=false
+  if(listState.page===1){
+    document.body.scrollTop=document.documentElement.scrollTop=0
+  }
+
+  if(res.Ret !== 200) return
+
+  if(res.Data.Paging.IsEnd){
+    listState.finished=true
+  }
+  let arr=res.Data.List||[];
+  listState.list=[...listState.list,...arr];
+}
+function onLoad() {
+  listState.page++
+  getList()
+}
+function initList() {
+  listState.list = [];
+  listState.page = 1;
+  getList()
+}
+
+
+
+
+const selectFirstType = ref('')
+const selectSubType = ref('')
+const firstClassifyList = ref([])
+const subClassifyList = ref([])
+async function getPermissionList() {
+  const res = await apiEtaReport.permissionList()
+  if(res.Ret !== 200) return
+  firstClassifyList.value = res.Data || [];
+  
+  let item = route.query.permissionId 
+    ? firstClassifyList.value.find(_ => _.PermissionId===Number(route.query.permissionId)) 
+    : firstClassifyList.value[0];
+  handleClickFirstType(item)
+}
+getPermissionList()
+
+function handleClickFirstType(item) {
+  selectFirstType.value = item.PermissionName;
+  subClassifyList.value = item.Child;
+
+  handleClickSubType(subClassifyList.value[0])
+  nextTick(() => {
+    resetHeaderWidthHandle();
+  });
+}
+function handleClickSubType(item) {
+  selectSubType.value = item.PermissionId
+  listState.list = []
+  listState.page = 1
+  listState.finished = false
+  getList()
+}
+
+
+const latestNewsList = ref([])
+async function getReCommandList() {
+  const res = await apiEtaReport.recommandList();
+  if(res.Ret !== 200) return
+  latestNewsList.value = res.Data||[];
+}
+getReCommandList()
+
+
+function handleToIndex() {
+  router.replace('/etaReport/index')
+}
+
+function clickHandler(e) {
+  console.log(e)
+  handleClickFirstType(e.value)
+}
+
+// 实现头部的适配
+const contentRef=ref('')
+const headerRef=ref('')
+let contentWidth=ref('')
+let headerHight=ref('')
+/* 重绘固定头宽度 */
+const resetHeaderWidthHandle = () => {
+  contentWidth.value = contentRef.value.offsetWidth
+  headerHight.value = headerRef.value.offsetHeight-10
+}
+onMounted(() => {
+  nextTick(() => resetHeaderWidthHandle())
+  
+  window.addEventListener('resize',resetHeaderWidthHandle)
+});
+
+onUnmounted(() => {
+  window.removeEventListener('resize',resetHeaderWidthHandle)
+})
+
+</script>
+<template>
+  <div class="report-index-page safe-content">
+
+    <div class="top-nav-wrap" ref="headerRef" :style="{'width':`${contentWidth}px`}">
+      <div class="flex top">
+        <div class="flex">
+          <h2 @click="handleToIndex">今日研报</h2>
+          <div class="flex" style="flex-shrink:0">
+            <t-radio allow-uncheck v-model="listState.showAll" @change="reportStore.changeShowAll(listState.showAll);initList()">显示全部 </t-radio>
+            <t-tooltip 
+              content="未勾选时展示所有客户有阅读权限的报告,勾选时展示弘则ETA所有已发布的报告"
+            >
+              <InfoCircleFilledIcon style="margin-left:5px;"/>
+            </t-tooltip>  
+          </div>
+
+          <div class="first-tab flex">
+            <div 
+              :class="['item', item.PermissionName == selectFirstType && 'item-active']" 
+              v-for="item in firstClassifyList.slice(0,6)" 
+              :key="item.PermissionName" 
+              @click="handleClickFirstType(item)"
+            >{{ item.PermissionName }}</div> 
+
+            <t-dropdown @click="clickHandler" trigger="click" v-if="firstClassifyList.length>6">
+              <t-button variant="text" style="font-size:16px">更多
+                <template #suffix><ChevronDownIcon size="16" /></template>
+              </t-button>
+              <t-dropdown-menu>
+                <t-dropdown-item
+                  v-for="item in firstClassifyList.slice(6)" 
+                  :key="item.PermissionName"
+                  :value="item"
+                  :active="item.PermissionName == selectFirstType"
+                >{{item.PermissionName}}</t-dropdown-item>
+              </t-dropdown-menu>
+            </t-dropdown>  
+          </div>
+        </div>
+
+        <t-input 
+          v-model="listState.searchText" 
+          placeholder="请输入搜索内容" 
+          clearable
+          size="large"
+          style="width:240px"
+          readonly
+          @click="router.push({path:'/etaReport/search'})"
+        >
+          <template #prefixIcon>
+            <SearchIcon />
+          </template>
+        </t-input>
+        
+      </div>
+
+      <div class="sub-tab">
+        <span 
+          :class="['sub-item', item.PermissionId === selectSubType && 'sub-active']" 
+          v-for="item in subClassifyList" 
+          :key="item.PermissionId" 
+          @click="handleClickSubType(item)"
+        >{{ item.PermissionName }}</span>
+      </div>
+    </div>
+
+    <div class="report-main hasrightaside-box" ref="contentRef">
+      <div class="content-box">
+        <!-- 报告列表 -->
+        <LoadList 
+          :finished="listState.finished" 
+          :isEmpty="listState.list.length === 0 && listState.finished" 
+          :loading="listState.loading" 
+          :count="listState.list.length"
+          @listOnload="onLoad"
+        >
+          <div class="report-list-wrap"  v-if="listState.list.length" :style="{'margin-top':`${headerHight}px`}">
+            <div 
+              class="content-item" 
+              :class="!item.HasAuth?'noAuth':''"
+              v-for="item in listState.list" 
+              :key="`${item.ReportId}_${item.ReportChapterId}`" 
+              @click="handleToDetail(item)"
+            >
+              <div class="report-item-info">
+                <img :src="item.ClassifyCoverImg" class="report-img">
+                <div class="right-info">
+                  <div class="c-stage flex">
+                    <div>
+                      <!-- <t-tag theme="primary" v-if="item.PermissionNames">{{item.PermissionNames[0]}}</t-tag> -->
+                      {{ `第${item.Stage}期` }}
+                      | {{ item.ClassifyName }}
+
+                    </div>
+                    
+                    <div class="c-time">{{ item.PublishTime }}</div>
+                  </div>
+                  <div class="c-title text-ellipsis--l1">{{ item.Title }}</div>
+                  <div class="desc text-ellipsis--l1" v-html="item.Abstract"></div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </LoadList>
+
+      </div>
+
+      <div class="right-aside-box">
+        <div class="fix-top" style="z-index: 100;" :style="{'top':`${headerHight+100}px`}">
+          <div class="recmd-box">
+            <div class="label">热门推荐</div>
+            <div 
+              class="recmd-item" 
+              v-for="(item,index) in latestNewsList" 
+              :key="item.ReportId"
+              @click="handleToDetail(item)"
+            >
+              <div class="title text-ellipsis--l1">
+                <span class="sort-num" :class="index>2?'grey':'notice'">{{index+1}}</span>
+                {{item.Title}}
+              </div>
+              <div class="abstract text-ellipsis--l1" v-html="item.abstract" v-if="item.Abstract"></div>
+            </div>
+          </div>
+
+        </div>
+      </div>
+    </div>
+
+
+  </div>
+</template>
+<style scoped lang="scss">
+@import './css/index.scss';
+.report-index-page {
+  position: relative;
+
+  .top-nav-wrap {
+    position: fixed;
+    top: 70px;
+    z-index: 99;
+    background-color: #fff;
+    padding-top: 30px;
+    padding-bottom: 20px;
+    width: 1240px;
+    border-bottom: 1px solid #DCDFE6;
+    .top {
+      justify-content: space-between;
+    }
+    h2 {
+      margin: 0;
+      font-size: 24px;
+      font-style: italic;
+      margin-right: 15px;
+      cursor: pointer;
+      flex-shrink:0;
+    }
+
+    .first-tab {
+      width: 100%;
+      overflow-x: auto;
+      overflow-y: hidden;
+      position: relative;
+      gap: 10px 30px;
+      margin-left: 20px;
+      &::-webkit-scrollbar{
+        // height: 5px;
+        display: none;
+      }
+      .item {
+        padding: 5px 0;
+        flex-shrink: 0;
+        text-align: center;
+        font-size: 18px;
+        border-bottom: 2px solid transparent;
+        cursor: pointer;
+        position: relative;
+        &::before {
+          content: '';
+          position: absolute;
+          background: #C0C4CC;
+          width: 2px;
+          height: 18px;
+          top: 50%;
+          transform: translateY(-50%);
+          right: -15px;
+        }
+      }
+      .item-active {
+        color: #3D5EFF;
+        border-color: #3D5EFF;
+        box-shadow: 0px 6px 7px 1px #FFF7EB;
+      }
+    }
+    :deep(.t-dropdown__menu) {
+      .t-dropdown__item {
+
+        &.item-active {
+          color: #3D5EFF;
+          background-color: #3D5EFF;
+        }
+      }
+    }
+
+    .sub-tab {
+      margin-top: 30px;
+      overflow-y: hidden;
+      display: flex;
+      align-items: center;
+      flex-wrap: wrap;
+      .sub-item {
+        flex-shrink: 0;
+        margin-right: 30px;
+        font-size: 16px;
+        margin-bottom: 8px;
+        color: #666666;
+        cursor: pointer;
+      }
+      .sub-active {
+        color: #3D5EFF;
+      }
+
+    }
+  }
+  .report-main {
+    .content-box { overflow: hidden; }
+    .report-list-wrap {
+      margin-top: 130px;
+
+      .content-item {
+        padding: 20px 0;
+        border-bottom: 1px solid #DCDFE6;
+        position: relative;
+        &.noAuth {
+          color: #C0C4CC;
+          .c-time,.desc {
+            color: #C0C4CC;
+          }
+        }
+        .right-info{
+          flex: 1;
+          overflow: hidden;
+        }
+
+        .report-item-info {
+          display: flex;
+        }
+
+        .t-tag--primary {
+          background-color: #49517E;
+          margin-right: 10px;
+        }
+
+        .c-time {
+          color: #666;
+          font-size: 14px;
+        }
+        .report-img {
+          width: 72px;
+          height: 96px;
+          margin-right: 10px;
+        }
+
+        .c-stage {
+          font-size: 16px;
+          justify-content: space-between;
+        }
+        .c-title {
+          font-size: 16px;
+          font-weight: bold;
+          word-wrap: break-word;
+          white-space: normal;
+          word-break: break-all;
+          margin-top: 10px;
+        }
+
+        .desc {
+          line-height: 1.5;
+          margin-top: 10px;
+          color: #666666;
+          font-size: 14px;
+          word-wrap: break-word;
+          word-break: break-all;
+          :deep(div){
+            word-wrap: break-word;
+            word-break: break-all;
+          }
+        }
+      }
+    }
+
+  }
+  .right-aside-box{
+    .fix-top {
+      top: 210px;
+    }
+    .recmd-box {
+      .label {
+        font-size: 24px;
+      }
+      .recmd-item {
+        padding: 15px 0;
+        font-size: 18px;
+        cursor: pointer;
+        &:hover {
+          color: #3D5EFF;
+          .abstract {
+            color:#3D5EFF;
+          }
+        }
+        .sort-num {
+          color: #fff;
+          padding: 2px 5px;
+          margin-right: 5px;
+          border-radius: 2px;
+          font-size: 16px;
+          &.grey{
+            background: #C0C4CC;
+          }
+          &.notice{
+            background: #7C54FF;
+          }
+        }
+
+        .abstract {
+          text-indent: 30px;
+          font-size: 16px;
+          margin-top: 10px;
+          color: #666;
+        }
+      }
+
+    }
+  }
+}
+</style>

+ 509 - 0
src/views/report/Detail.vue

@@ -0,0 +1,509 @@
+<script setup>
+import { ref } from 'vue'
+import moment from 'moment'
+import { useRoute, useRouter } from 'vue-router'
+import { apiEtaReport,apiEtaReportCollect } from '@/api/etaReport'
+import { useUserInfo } from '@/hooks/useUserInfo'
+import {apiSystemCommon} from '@/api/system'
+import { ChevronLeftIcon } from 'tdesign-icons-vue-next'
+import { MessagePlugin } from 'tdesign-vue-next'
+import AudioBox from './components/AudioBox.vue'
+import ReportContent from './components/ReportContent.vue'
+import ChapterWrap from './components/ChapterWrap.vue'
+import Disclaimer from './components/Disclaimer.vue'
+import CollectReport from '../user/favorite/components/CollectReport.vue' 
+import { async } from '@antv/x6/lib/registry/marker/async'
+
+
+const router = useRouter()
+const route = useRoute()
+
+let audioData=ref({})
+const info = ref({})
+const chapterList = ref([])
+const headImgStyle = ref([])//版头style
+const endImgStyle = ref([])//版尾style
+const layoutBaseInfo = ref({
+    研报标题:'',
+    研报作者:'',
+    创建时间:''
+})
+async function getReportDetail (){
+    const res = await apiEtaReport.reportDetail({
+        ReportId:Number(route.query.id)
+    })
+    if(res.Ret!==200) return
+    // if(!res.Data.HasAuth) return MessagePlugin.warning('您暂未开通该报告阅读权限,请联系销售!')
+
+    info.value=res.Data;
+    chapterList.value = res.Data.ChapterList || [];
+    disclaimer.value = res.Data.Disclaimer;
+
+    headImgStyle.value=info.value.HeadStyle?JSON.parse(info.value.HeadStyle):[]
+    endImgStyle.value=info.value.HeadStyle?JSON.parse(info.value.HeadStyle):[]
+    layoutBaseInfo.value['研报标题']=info.value.Title
+    layoutBaseInfo.value['研报作者']=info.value.Author
+    // 已发布已通过的报告才显示发布时间
+    layoutBaseInfo.value['创建时间']=moment(info.value.PublishTime).format('YYYY.MM.DD HH:mm')
+
+    audioData.value={
+        auth_ok:info.value.HasAuth,
+        video_name:info.value.VideoName||`${info.value.Title}(${moment(info.value.PublishTime).format('MMDD')})`,
+        video_size:info.value.VideoSize||'',
+        video_play_seconds:info.value.VideoPlaySeconds,
+        video_url:info.value.VideoUrl,
+        reportId:info.value.Id
+    }
+    document.title = info.value.ClassifyNameFirst
+
+    if(chapterList.value.length) {
+      handleChangeChapter(chapterList.value[0])
+    }
+
+    setWaterMark()
+        
+}
+
+const chapterInfo = ref({})
+async function getChapterDetail(id=null) {
+  const res = await apiEtaReport.chapterDetail({
+    ChapterId: id||Number(route.query.chapterId)
+  })
+  if(res.Ret!==200) return
+
+  chapterInfo.value=res.Data;
+  disclaimer.value = res.Data.Disclaimer;
+  audioData.value={
+    auth_ok:chapterInfo.value.HasAuth,
+    video_name:chapterInfo.value.VideoName||`${chapterInfo.value.Title}(${moment(chapterInfo.value.PublishTime).format('MMDD')})`,
+    video_size:chapterInfo.value.VideoSize||'',
+    video_play_seconds:chapterInfo.value.VideoPlaySeconds,
+    video_url:chapterInfo.value.VideoUrl,
+    reportId:chapterInfo.value.Id
+  }
+
+  setWaterMark()
+
+}
+function initGet() {
+  route.query.chapterId ? getChapterDetail() : getReportDetail()
+}
+initGet()
+
+async function setWaterMark() {
+  const res=await apiSystemCommon.userInfo()
+
+  const options = {
+      text: res.Data?.Mobile||'',
+      fontSize: '14px',
+      color: '#999',
+      rotate: -40,
+      opacity: 0.2,
+      gapX: 180,
+      gapY: 120
+  };
+
+    // 创建 canvas 绘制水印图案
+    const canvas = document.createElement('canvas');
+    canvas.width = options.gapX;
+    canvas.height = options.gapY;
+    const ctx = canvas.getContext('2d');
+
+    ctx.save();
+    ctx.globalAlpha = options.opacity;
+    ctx.font = `${options.fontSize} Arial`;
+    ctx.fillStyle = options.color;
+    ctx.translate(canvas.width / 2, canvas.height / 2);
+    ctx.rotate((options.rotate * Math.PI) / 180);
+    ctx.textAlign = 'center';
+    ctx.textBaseline = 'middle';
+    ctx.fillText(options.text, 0, 0);
+    ctx.restore();
+
+    // 转换为 base64 图片
+    const base64Url = canvas.toDataURL('image/png');
+
+    // 设置背景样式
+    const el = document.getElementsByClassName('report-wrapper')[0];
+    el.style.backgroundImage = `url(${base64Url})`;
+    el.style.backgroundRepeat = 'repeat';
+    el.style.backgroundSize = `${options.gapX}px ${options.gapY}px`;
+}
+
+
+const selectChapterId = ref(0)
+function handleChangeChapter(item) {
+  if(!item.HasAuth) return MessagePlugin.warning('您暂未开通该报告阅读权限,请联系销售!')
+  
+  selectChapterId.value = item.ReportChapterId;
+  getChapterDetail(selectChapterId.value)
+}
+
+
+const disclaimer = ref('带我去带我去多我去大青蛙单位群')
+const showDisclaimers=ref(false)//显示免责声明
+
+
+// 报告标题
+function formatTitle(e) {
+    let t=moment(e.PublishTime).format('MMDD')
+    let title=''
+    if(e.ClassifyNameSecond==e.Title){
+        title=`【第${e.Stage}期】${e.Title}(${t})`
+    }else{
+        title=`【第${e.Stage}期 | ${e.ClassifyNameFirst}】${e.Title}(${t})`
+    }
+    return title
+}
+
+function formatChapterTitle(e) {
+  let t=moment(e.PublishTime).format('MMDD')
+  let title=''
+
+  title=`【第${e.Stage}期 | ${e.ClassifyNameFirst} | ${e.TypeName}】${e.Title}(${t})`
+
+  return title
+}
+
+
+const showCollectReport = ref(false)
+//收藏
+async function handleCancelCollectReport() {
+
+  const res = await apiEtaReportCollect.reportCollectCancel({
+    ReportId: Number(route.query.id),
+    ReportChapterId: Number(route.query.chapterId)
+  })
+
+  if(res.Ret !== 200) return
+  MessagePlugin.success('取消成功')
+  if(route.query.chapterId) {
+    chapterInfo.value.IsCollected = false
+  }else {
+    info.value.IsCollected=false
+  }
+}
+
+
+// 实现头部的适配
+const contentRef=ref('')
+const headerRef=ref('')
+let contentWidth=ref('')
+/* 重绘固定头宽度 */
+const resetHeaderWidthHandle = () => {
+  contentWidth.value = contentRef.value.offsetWidth
+}
+onMounted(() => {
+  nextTick(() => resetHeaderWidthHandle())
+  
+  window.addEventListener('resize',resetHeaderWidthHandle)
+  window.addEventListener('resize',resetHeaderWidthHandle)
+});
+
+onUnmounted(() => {
+  window.removeEventListener('resize',resetHeaderWidthHandle)
+})
+
+</script>
+<template>
+  <div class="report-detail-page safe-content">
+    <div class="top-nav-wrap" ref="headerRef" :style="{'width':`${contentWidth}px`}">
+      <div class="flex top">
+        <div class="flex back" @click="router.go(-1)">
+          <ChevronLeftIcon />
+          返回
+        </div>
+
+        <div class="flex" v-if="!info.HasChapter||route.query.chapterId">
+          <div class="collect-icon">
+            <svg-icon v-if="info?.IsCollected||chapterInfo?.IsCollected" name="star_fill" style="font-size:20px;cursor: pointer;" @click="handleCancelCollectReport"></svg-icon>
+            <svg-icon v-else name="star" style="font-size:20px;cursor: pointer;" @click="showCollectReport=true"></svg-icon>
+          </div>
+          {{(info.IsCollected||chapterInfo.IsCollected) ? '已收藏' : '收藏研报'}}
+        </div>
+      </div>  
+    </div>
+
+    <!-- 报告详情 -->
+    <div class="report-box" ref="contentRef">
+        <!-- 章节区域 -->
+        <ChapterWrap 
+          :list="chapterList"
+          :chapterId="selectChapterId"
+          v-if="info.HasChapter"
+          @change="handleChangeChapter"
+        />
+
+         <!-- 章节详情内容区 -->
+        <div class="report-wrapper " 
+          :class="info.HasChapter?'chapter-detail':''" 
+          v-if="info.HasChapter||route.query.chapterId"
+        >
+          <!-- 无版头板尾显示标题 -->
+
+          <div class="title" v-if="chapterInfo?.Title">{{formatChapterTitle(chapterInfo)}}</div>
+          <div class="time flex">
+              <span>{{chapterInfo.Author}}</span>
+              <span>{{moment(chapterInfo.PublishTime).format('YYYY.MM.DD HH:mm')}}</span>
+          </div>
+
+          <div class="tips">
+              <div class="abstract" v-if="chapterInfo.Abstract">摘要:{{chapterInfo.Abstract}}</div>
+              <div v-if="disclaimer">
+                  <span>*注:请务必阅读</span>
+                  <span style="color:#3D5EFF;margin-left:15px;cursor: pointer;" @click="showDisclaimers=true">免责声明</span>
+              </div>
+          </div>
+          <!-- 音频模块 -->
+          <AudioBox 
+            :data="audioData" 
+            v-if="chapterInfo.VideoUrl&&chapterInfo.VideoPlaySeconds>0">
+          </AudioBox>
+
+          <div id="report-rich-content" class="no-select-text rich-content" ref="waterMarkEl">
+
+            <ReportContent 
+              v-if="chapterInfo.Content"
+              :html="chapterInfo.Content"
+            ></ReportContent>
+          </div>
+        </div>
+
+        <!-- 报告内容区域 -->
+        <div class="report-wrapper" v-else>
+          <!-- 无版头板尾显示标题 -->
+          <template v-if="(!info.HeadImg) && (!info.EndImg)">
+              <div class="title" v-if="info?.Title">{{formatTitle(info)}}</div>
+              <div class="time flex">
+                  <span>{{info.Author}}</span>
+                  <span>{{moment(info.PublishTime).format('YYYY.MM.DD HH:mm')}}</span>
+              </div>
+          </template>
+
+          <!-- 拼接版头 -->
+          <div class="html-head-img-box" v-if="info.HeadImg">
+              <img :src="info.HeadImg" alt="" style="display:block;width:100%">
+              <div 
+                class="head-layout-item" 
+                v-for="item in headImgStyle" 
+                :key="item.value"
+                :style="{
+                  fontFamily:item.family,
+                  fontSize:(item.size*2)+'px',
+                  fontWeight:item.weight,
+                  textAlign:item.align,
+                  color:item.color,
+                  width:item.width,
+                  height:item.height,
+                  left:item.left,
+                  top:item.top
+                }"
+              >
+                  {{ layoutBaseInfo[item.value] }}
+              </div>
+          </div>
+
+          <div class="tips">
+              <div class="abstract" v-if="info.Abstract">摘要:{{info.Abstract}}</div>
+              <div v-if="disclaimer">
+                  <span>*注:请务必阅读</span>
+                  <span style="color:#3D5EFF;margin-left:15px;cursor: pointer;" @click="showDisclaimers=true">免责声明</span>
+              </div>
+          </div>
+          <!-- 音频模块 -->
+          <AudioBox 
+            :data="audioData" 
+            v-if="info.VideoUrl&&info.VideoPlaySeconds>0">
+          </AudioBox>
+
+          <div id="report-rich-content" class="no-select-text rich-content" ref="waterMarkEl">
+
+            <ReportContent 
+              v-if="info.Content"
+              :html="info.Content"
+            ></ReportContent>
+          </div>
+
+          <!-- 拼接版尾 -->
+          <div class="html-end-img-box" v-if="info.EndImg">
+              <img :src="info.EndImg" alt="" style="display:block;width:100%">
+              <div class="head-layout-item" v-for="item in endImgStyle" :key="item.value"
+              :style="{fontFamily:item.family,fontSize:(item.size*2)+'px',fontWeight:item.weight,textAlign:item.align,color:item.color,
+                  width:item.width,height:item.height,left:item.left,top:item.top
+              }">
+                  {{ layoutBaseInfo[item.value] }}
+              </div>
+          </div>
+        </div>
+
+       
+        <!-- <empty-wrap v-else msg="暂无阅读权限"/> -->
+    </div>
+  </div>  
+
+  <!-- 免责声明 -->
+  <Disclaimer v-model:show="showDisclaimers" :content="disclaimer"/>
+
+  <!-- 收藏报告 -->
+  <CollectReport 
+    v-model:show="showCollectReport" 
+    :data="{
+      reportId: Number(route.query.id),
+      chapterId: Number(route.query.chapterId)
+    }" 
+    @success="route.query.chapterId?(info.IsCollected=true):(chapterInfo.IsCollected=true)"
+  />
+
+  <t-back-top
+    :visible-height="200"
+    shape="circle"
+    theme="primary"
+    style="position: fixed; right: 30px; bottom: 40px"
+  ></t-back-top>
+</template>
+<style scoped lang="scss">
+@import './css/index.scss';
+.top-nav-wrap {
+    position: fixed;
+    top: 70px;
+    z-index: 99;
+    background-color: #fff;
+    padding-top: 30px;
+    padding-bottom: 20px;
+    width: 1240px;
+    border-bottom: 1px solid #DCDFE6;
+    .top {
+      justify-content: space-between;
+      font-size: 16px;
+    }
+    h2 {
+      margin: 0;
+      font-size: 24px;
+      font-style: italic;
+      margin-right: 15px;
+      cursor: pointer;
+      flex-shrink:0;
+    }
+    .back {
+      cursor: pointer;
+      &:hover {
+        color: #3D5EFF;
+      }
+    }
+    .collect-icon {
+      padding: 2px;
+      border: 1px solid #C8CDD9;
+      border-radius: 4px;
+      margin-right:5px;
+    }
+}
+
+.report-box {
+  display: flex;
+  margin-top: 90px;
+  .report-wrapper {
+    flex: 1;
+    max-width: 970px;
+    margin: 0 auto;
+    padding-bottom: 20px;
+    overflow: hidden;
+    position: relative;
+    &.chapter-detail {
+      padding-left: 230px;
+      max-width: none;
+    }
+      .title{
+          display: inline;
+          font-size: 24px;
+          font-weight: bold;
+          margin-left: -14px;
+      }
+      .time{
+          color: #666;
+          margin-top: 20px;
+          margin-bottom: 30px;
+          font-size: 16px;
+          justify-content: space-between;
+      }
+      .tips{
+          font-size: 18px;
+          margin-bottom: 30px;
+          position: relative;
+          padding-left: 20px;
+          &::before{
+              content: '';
+              width: 6px;
+              height: 100%;
+              position: absolute;
+              left: 0;
+              display: inline-block;
+              background-color: #3D5EFF;
+              margin-right: 10px;
+          }
+      }
+      .abstract{
+          font-size: 18px;
+          margin-bottom: 20px;
+      }
+      .rich-content{
+          margin-top: 30px;
+          line-height: 1.8;
+          font-size: 18px;
+          position: relative;
+          :deep(img){
+              width: 100% !important;
+          }
+          :deep(span){
+              font-size: 18px !important;
+              line-height: 1.8 !important;
+              background-color: rgba(255, 255, 255, 0) !important;
+          }
+          :deep(ul,ol,li,p){
+              font-size: 18px !important;
+              line-height: 1.8 !important;
+              background-color: rgba(255, 255, 255, 0) !important;
+          }
+          :deep(iframe){
+              width: 100% !important;
+          }
+      }
+
+      .html-head-img-box,.html-end-img-box{
+          margin-bottom: 10px;
+          position: relative;
+          overflow: hidden;
+          .head-layout-item{
+              position: absolute;
+              overflow: hidden;
+              box-sizing: border-box
+          }
+      }
+  }
+}
+
+#report-rich-content{
+  position: relative;
+  .hide-watermark-box{
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    align-items: center;
+    position: absolute;
+    width: 100%;
+    height: 100%;
+    overflow: hidden;
+    font-size: 40px;
+    z-index: 9;
+    transform: rotate(-50deg);
+    background: transparent;
+    left: 0;
+    top: 0;
+    pointer-events: none;
+    color: #DAE0FE; // 水印蒙层字体颜色设置为白色透明
+    opacity: 0.4;
+  }
+}
+.empty-wrap {
+  margin: 0 auto;
+}
+</style>

+ 320 - 0
src/views/report/Index.vue

@@ -0,0 +1,320 @@
+<script setup>
+import { reactive, ref } from 'vue'
+import { SearchIcon,InfoCircleFilledIcon } from 'tdesign-icons-vue-next';
+import { useReportStore } from '@/store/modules/report'
+import { useRouter } from 'vue-router';
+import _ from 'lodash'
+import moment from 'moment'
+import 'moment/locale/zh-cn.js';
+moment.locale('zh-cn')
+import { apiEtaReport } from '@/api/etaReport'
+import LoadList from '@/components/LoadList.vue'
+import RightSideWrap from './components/RightSlideWrap.vue'
+import { useReport } from './hooks/useReport'
+
+const reportStore = useReportStore();
+const  { handleToDetail } = useReport()
+
+const router = useRouter()
+
+const searchText = ref('')
+
+const listState = reactive({
+  list: [],
+  page: 1,
+  pageSize: 20,
+  finished: true,
+  loading: false,
+  showAll: reportStore.showAllReport||false
+})
+
+async function getList() {
+  listState.loading=true
+  const res = await apiEtaReport.reportList({
+    PageSize: listState.pageSize,
+    CurrentIndex: listState.page,
+    ShowAll: listState.showAll,
+    IsToday: true
+  })
+
+  listState.loading=false
+  if(listState.page===1){
+    document.body.scrollTop=document.documentElement.scrollTop=0
+  }
+
+  if(res.Ret !== 200) return
+
+  if(res.Data.Paging.IsEnd){
+    listState.finished=true
+  }
+  let arr=res.Data.List||[];
+  listState.list=[...listState.list,...arr];
+}
+getList()
+
+function onLoad() {
+  listState.page++
+  getList()
+}
+
+function initList() {
+  listState.list = [];
+  listState.page = 1;
+  getList()
+}
+
+// 格式化列表日期
+const formatDate=(e)=>{
+  const isSameYear=moment(e).isSame(new Date(), 'year');
+  
+  const weekMap = {
+    1: '一',
+    2: '二',
+    3: '三',
+    4: '四',
+    5: '五',
+    6: '六',
+    7: '日',
+  }
+  const weekDay = '星期'+weekMap[moment(e).format('E')];
+
+  if(isSameYear){//今年
+    return moment(e).format('MM.DD')+' '+weekDay
+  }else{
+    return moment(e).format('YY.MM.DD')+' '+weekDay
+  }
+}
+
+const colors = [ '#49517E','#DB4E2A','#53B3FF','#FBA730','#00C1CF','#8A4294' ]
+function getColor(index) {
+  return colors[index] || '#49517E'
+}
+
+// 实现头部的适配
+const contentRef=ref('')
+const headerRef=ref('')
+let contentWidth=ref('')
+let headerHight=ref('')
+/* 重绘固定头宽度 */
+const resetHeaderWidthHandle = () => {
+  contentWidth.value = contentRef.value.offsetWidth+'px'
+  headerHight.value = headerRef.value.offsetHeight-10+'px'
+}
+onMounted(() => {
+  resetHeaderWidthHandle()
+  window.addEventListener('resize',resetHeaderWidthHandle)
+});
+
+onUnmounted(() => {
+  window.removeEventListener('resize',resetHeaderWidthHandle)
+})
+
+</script>
+<template>
+  <div class="report-index-page safe-content hasrightaside-box">
+
+    <div class="content-box report-main" ref="contentRef">
+      <div class="top-nav-wrap" ref="headerRef" :style="{'width':contentWidth}">
+        <div class="flex">
+          <h2>今日研报</h2>
+          <div class="flex">
+            <t-radio allow-uncheck v-model="listState.showAll" @change="reportStore.changeShowAll(listState.showAll);initList()">显示全部 </t-radio>
+            <t-tooltip 
+              content="未勾选时展示所有客户有阅读权限的报告,勾选时展示弘则ETA所有已发布的报告"
+            >
+              <InfoCircleFilledIcon style="margin-left:5px;"/>
+            </t-tooltip>
+
+          </div>
+        </div>
+
+        <t-input 
+          v-model="searchText" 
+          placeholder="请输入搜索内容" 
+          clearable
+          style="width:400px"
+          size="large"
+          readonly
+          @click="router.push({path:'/etaReport/search'})"
+        >
+          <template #prefixIcon>
+            <SearchIcon />
+          </template>
+        </t-input>
+      </div>
+
+      <!-- 报告列表 -->
+      <LoadList 
+        :finished="listState.finished" 
+        :isEmpty="listState.list.length === 0 && listState.finished" 
+        :loading="listState.loading" 
+        :count="listState.list.length"
+        @listOnload="onLoad"
+      >
+        <div class="report-list-wrap"  v-if="listState.list.length">
+          <div class="item">
+            <div class="item-time">{{ formatDate(listState.list[0].PublishTime) }}</div>
+            <div class="content-list">
+              <div 
+                class="content-item"
+                :class="!citem.HasAuth?'noAuth':''"
+                v-for="citem in listState.list" 
+                :key="`${citem.ReportId}_${citem.ReportChapterId}`" 
+                @click="handleToDetail(citem)"
+              >
+                <div class="c-time">{{ moment(citem.PublishTime).format('HH:mm:ss')  }}</div>
+                <div class="report-item-info">
+                  <img :src="citem.ClassifyCoverImg" class="report-img">
+                  <div style="flex:1">
+                    <div class="c-stage">
+                      <t-tag 
+                        theme="primary" 
+                        v-for="(tag,index) in citem.PermissionNames" 
+                        :key="tag"
+                        :style="`background: ${getColor(index)}`"
+                      >{{tag}}</t-tag>
+                      {{ `第${citem.Stage}期` }}
+                      | {{ citem.ClassifyName }}
+                    </div>
+                    <div class="c-title text-ellipsis--l1">{{ citem.Title }}</div>
+                    <div class="desc text-ellipsis--l1" v-html="citem.Abstract"></div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </LoadList>
+
+    </div>
+
+    <!-- 右侧 -->
+    <RightSideWrap/>
+
+
+  </div>
+</template>
+<style scoped lang="scss">
+@import './css/index.scss';
+.report-index-page {
+  position: relative;
+
+  .report-main {
+    .top-nav-wrap {
+      position: fixed;
+      top: 70px;
+      z-index: 99;
+      background-color: #fff;
+      padding-top: 30px;
+      padding-bottom: 20px;
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      width: 740px;
+      border-bottom: 1px solid #DCDFE6;
+      h2 {
+        margin: 0;
+        font-size: 24px;
+        font-style: italic;
+        margin-right: 15px;
+        flex-shrink: 0;
+      }
+    }
+
+    .report-list-wrap {
+      margin-top: 100px;
+      .item{
+        margin-left: 4px;
+        border-left: 1px solid #DAE0FE;
+        position: relative;
+
+      }
+
+      .item-time {
+        margin-left: 20px;
+        padding: 10px;
+        background: #F5F7F9;
+        color: #3D5EFF;
+        font-size: 16px;
+        margin-bottom: 20px;
+        font-weight: bold;
+        position: sticky;
+        top: 161px;
+        z-index: 9;
+        &::before {
+          content: '';
+          display: block;
+          box-sizing: border-box;
+          width: 10px;
+          height: 10px;
+          border-radius: 50%;
+          position: absolute;
+          left: -20px;
+          top: 5px;
+          background: #4B64F7;
+          transform: translate(-50%, -50%);
+          z-index: 2;
+        }
+      }
+
+      .content-list {
+        padding: 0 20px 30px;
+        .content-item {
+          padding: 20px 0;
+          border-bottom: 1px solid #DCDFE6;
+          position: relative;
+          &.noAuth {
+            color: #C0C4CC;
+          }
+          .report-item-info {
+            display: flex;
+          }
+
+          .t-tag--primary {
+            background-color: #49517E;
+            margin-right: 10px;
+          }
+
+          .c-time {
+            position: relative;
+            top: -8px;
+            font-size: 14px;
+            color: #3D5EFF;
+          }
+          .report-img {
+            width: 72px;
+            height: 96px;
+            margin-right: 10px;
+          }
+
+          .c-stage {
+            font-size: 16px;
+          }
+          .c-title {
+            font-size: 16px;
+            font-weight: bold;
+            word-wrap: break-word;
+            white-space: normal;
+            word-break: break-all;
+            margin-top: 10px;
+          }
+
+          .desc {
+            line-height: 1.5;
+            margin-top: 10px;
+            color: #666666;
+            font-size: 14px;
+            word-wrap: break-word;
+            word-break: break-all;
+            :deep(div){
+              word-wrap: break-word;
+              word-break: break-all;
+            }
+          }
+        }
+      }
+    }
+
+  }
+}
+</style>

+ 108 - 0
src/views/report/PosterDetail.vue

@@ -0,0 +1,108 @@
+<script setup>
+import { ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { useReportStore } from '@/store/modules/report'
+import { useUserInfo } from '@/hooks/useUserInfo'
+import { ChevronLeftIcon } from 'tdesign-icons-vue-next'
+import { MessagePlugin } from 'tdesign-vue-next'
+
+
+const router = useRouter()
+const route = useRoute()
+
+const reportStore = useReportStore()
+
+
+// 实现头部的适配
+const contentRef=ref('')
+const headerRef=ref('')
+let contentWidth=ref('')
+/* 重绘固定头宽度 */
+const resetHeaderWidthHandle = () => {
+  contentWidth.value = contentRef.value.offsetWidth
+}
+onMounted(() => {
+  nextTick(() => resetHeaderWidthHandle())
+  
+  window.addEventListener('resize',resetHeaderWidthHandle)
+  window.addEventListener('resize',resetHeaderWidthHandle)
+});
+
+onUnmounted(() => {
+  window.removeEventListener('resize',resetHeaderWidthHandle)
+})
+
+</script>
+<template>
+  <div class="report-detail-page safe-content">
+    <div class="top-nav-wrap" ref="headerRef" :style="{'width':`${contentWidth}px`}">
+      <div class="flex top">
+        <div class="flex back" @click="router.go(-1)">
+          <ChevronLeftIcon />
+          返回
+        </div>
+
+        <div class="flex">
+          
+        </div>
+      </div>  
+    </div>
+
+    <div class="report-box" ref="contentRef">
+      <img :src="reportStore.posterInfo?.LongImg" alt="">
+    </div>
+  </div>  
+
+  <t-back-top
+    :visible-height="200"
+    shape="circle"
+    theme="primary"
+    style="position: fixed; right: 30px; bottom: 40px"
+  ></t-back-top>
+</template>
+<style scoped lang="scss">
+@import './css/index.scss';
+.top-nav-wrap {
+    position: fixed;
+    top: 70px;
+    z-index: 99;
+    background-color: #fff;
+    padding-top: 30px;
+    padding-bottom: 20px;
+    width: 1240px;
+    border-bottom: 1px solid #DCDFE6;
+    .top {
+      justify-content: space-between;
+      font-size: 16px;
+    }
+    h2 {
+      margin: 0;
+      font-size: 24px;
+      font-style: italic;
+      margin-right: 15px;
+      cursor: pointer;
+      flex-shrink:0;
+    }
+    .back {
+      cursor: pointer;
+      &:hover {
+        color: #3D5EFF;
+      }
+    }
+    .collect-icon {
+      padding: 2px;
+      border: 1px solid #C8CDD9;
+      border-radius: 4px;
+      margin-right:5px;
+    }
+}
+
+.report-box {
+  display: flex;
+  margin-top: 90px;
+  img {
+    max-width: 100%;
+    margin: 0 auto;
+  }
+}
+</style>

+ 279 - 0
src/views/report/Search.vue

@@ -0,0 +1,279 @@
+<script setup>
+import { reactive, ref } from 'vue'
+import { SearchIcon,InfoCircleFilledIcon,ChevronLeftIcon } from 'tdesign-icons-vue-next';
+import { useReportStore } from '@/store/modules/report'
+import { apiEtaReport } from '@/api/etaReport'
+import { useRouter } from 'vue-router';
+import { useReport } from './hooks/useReport'
+import LoadList from '@/components/LoadList.vue'
+import RightSideWrap from './components/RightSlideWrap.vue'
+
+const reportStore = useReportStore();
+const  { handleToDetail } = useReport()
+
+const router = useRouter()
+
+const searchText = ref('')
+
+const listState = reactive({
+  list: [],
+  page: 1,
+  pageSize: 20,
+  finished: true,
+  loading: false,
+  searchText:'',
+  showAll: reportStore.showAllReport||false
+})
+
+async function getList() {
+  listState.loading=true
+  const res = await apiEtaReport.reportSearch({
+    PageSize: listState.pageSize,
+    CurrentIndex: listState.page,
+    ShowAll: listState.showAll,
+    Keyword: listState.searchText
+  })
+  
+  listState.loading=false
+  if(res.Ret !== 200) return
+  if(listState.page===1){
+    document.body.scrollTop=document.documentElement.scrollTop=0
+  }
+
+  if(res.Ret !== 200) return
+
+  if(res.Data.Paging.IsEnd){
+    listState.finished=true
+  }
+
+  let arr=res.Data.List||[];
+  listState.list=[...listState.list,...arr];
+
+}
+function onLoad() {
+  listState.page++;
+  getList()
+}
+
+function initList() {
+  listState.list = [];
+  listState.page = 1;
+  getList()
+}
+
+
+function handleClickClassify() {
+  router.push('/etaReport/classify')
+}
+
+
+const colors = [ '#49517E','#DB4E2A','#53B3FF','#FBA730','#00C1CF','#8A4294' ]
+function getColor(index) {
+  return colors[index] || '#49517E'
+}
+
+
+// 实现头部的适配
+const contentRef=ref('')
+const headerRef=ref('')
+let contentWidth=ref('')
+let headerHight=ref('')
+/* 重绘固定头宽度 */
+const resetHeaderWidthHandle = () => {
+  contentWidth.value = contentRef.value.offsetWidth+'px'
+  headerHight.value = headerRef.value.offsetHeight-10+'px'
+}
+onMounted(() => {
+  resetHeaderWidthHandle()
+  window.addEventListener('resize',resetHeaderWidthHandle)
+});
+
+onUnmounted(() => {
+  window.removeEventListener('resize',resetHeaderWidthHandle)
+})
+
+</script>
+<template>
+  <div class="report-index-page safe-content hasrightaside-box">
+
+    <div class="content-box report-main" ref="contentRef">
+      <div class="top-nav-wrap" ref="headerRef" :style="{'width':contentWidth}">
+        <div class="flex">
+          <div class="flex back" @click="router.go(-1)">
+            <ChevronLeftIcon />
+            返回
+          </div>
+          <div class="flex">
+            <t-radio allow-uncheck v-model="listState.showAll" @change="reportStore.changeShowAll(listState.showAll);initList()">显示全部 </t-radio>
+            <t-tooltip 
+              content="未勾选时展示所有客户有阅读权限的报告,勾选时展示弘则ETA所有已发布的报告"
+            >
+              <InfoCircleFilledIcon style="margin-left:5px;"/>
+            </t-tooltip>
+
+          </div>
+        </div>
+
+        <t-input 
+          v-model="listState.searchText" 
+          placeholder="请输入搜索内容" 
+          clearable
+          size="large"
+          style="width:400px"
+          autofocus
+          @change="initList"
+        >
+          <template #prefixIcon>
+            <SearchIcon />
+          </template>
+        </t-input>
+      </div>
+
+      <!-- 报告列表 -->
+      <LoadList 
+        emptyMsg="暂无搜索结果"
+        :finished="listState.finished" 
+        :isEmpty="listState.list.length === 0 && listState.finished" 
+        :loading="listState.loading" 
+        :count="listState.list.length"
+        @listOnload="onLoad"
+      >
+        <div class="report-list-wrap"  v-if="listState.list.length">
+          <div class="content-item" 
+            :class="!item.HasAuth?'noAuth':''"
+            v-for="item in listState.list" 
+            :key="`${item.ReportId}_${item.ReportChapterId}`" 
+            @click="handleToDetail(item)"
+          >
+              <div class="report-item-info">
+                <img :src="item.ClassifyCoverImg" class="report-img">
+                <div class="right-info">
+                  <div class="c-stage flex">
+                    <div>
+                      <t-tag 
+                        theme="primary" 
+                        v-for="(tag,index) in item.PermissionNames" 
+                        :key="tag"
+                        :style="`background: ${getColor(index)}`"
+                      >{{tag}}</t-tag>
+                      {{ `第${item.Stage}期` }}
+                      | {{ item.ClassifyName }}
+
+                    </div>
+                    
+                    <div class="c-time">{{ item.PublishTime }}</div>
+                  </div>
+                  <div class="c-title text-ellipsis--l1" v-html="item.Title"></div>
+                  <div class="desc text-ellipsis--l1" v-html="item.ContentSub"></div>
+                </div>
+              </div>
+            </div>
+        </div>
+      </LoadList>
+
+    </div>
+
+    <!-- 右侧 -->
+    <RightSideWrap/>
+
+
+  </div>
+</template>
+<style scoped lang="scss">
+@import './css/index.scss';
+.report-index-page {
+  position: relative;
+
+  .report-main {
+    .top-nav-wrap {
+      position: fixed;
+      top: 70px;
+      z-index: 99;
+      background-color: #fff;
+      padding-top: 30px;
+      padding-bottom: 20px;
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      width: 740px;
+      border-bottom: 1px solid #DCDFE6;
+      h2 {
+        margin: 0;
+        font-size: 24px;
+        font-style: italic;
+        margin-right: 15px;
+      }
+      .back {
+        margin-right: 30px;
+        cursor: pointer;
+        font-size: 16px;
+      }
+    }
+
+    .report-list-wrap {
+      margin-top: 100px;
+      .content-item {
+        padding: 20px 0;
+        border-bottom: 1px solid #DCDFE6;
+        position: relative;
+        &.noAuth {
+          color: #C0C4CC;
+          .c-time,.desc {
+            color: #C0C4CC;
+          }
+        }
+        .right-info{
+          flex: 1;
+        }
+
+        .report-item-info {
+          display: flex;
+        }
+
+        .t-tag--primary {
+          background-color: #49517E;
+          margin-right: 10px;
+        }
+
+        .c-time {
+          color: #666;
+          font-size: 14px;
+        }
+        .report-img {
+          width: 72px;
+          height: 96px;
+          margin-right: 10px;
+        }
+
+        .c-stage {
+          font-size: 16px;
+          justify-content: space-between;
+        }
+        .c-title {
+          font-size: 16px;
+          font-weight: bold;
+          word-wrap: break-word;
+          white-space: normal;
+          word-break: break-all;
+          margin-top: 10px;
+        }
+
+        .desc {
+          line-height: 1.5;
+          margin-top: 10px;
+          color: #666666;
+          font-size: 14px;
+          max-width: 600px;
+          word-wrap: break-word;
+          word-break: break-all;
+          :deep(div){
+            word-wrap: break-word;
+            word-break: break-all;
+          }
+        }
+      }
+    }
+
+  }
+}
+</style>

+ 84 - 0
src/views/report/components/AudioBox.vue

@@ -0,0 +1,84 @@
+<script setup>
+import { useReportStore } from '@/store/modules/report'
+
+const props=defineProps({
+    data:{
+        type:Object
+    }
+})
+
+const reportStore=useReportStore()
+
+const formatVoiceTime = (e) => {
+  let minus = parseInt(e / 60);
+  let sec = parseInt(e % 60);
+  return `${minus > 9 ? minus : "0" + minus}:${sec > 9 ? sec : "0" + sec}`;
+};
+
+const handleClickAudio=()=>{
+    if(reportStore.audioData.reportId===props.data.reportId){
+        if(reportStore.audioData.paused){
+            reportStore.audioData.INS.play()
+        }else{
+            reportStore.audioData.INS.pause()
+        }
+        return
+    }
+    reportStore.addAudio({
+        list:[{time:props.data.video_play_seconds,url:props.data.video_url,name:props.data.video_name}],
+        reportId:props.data.reportId,
+        index:0
+    })
+}
+
+</script>
+
+<template>
+    <div class="flex audio-box">
+        <div :class="['img',(reportStore.audioData.reportId==props.data.reportId&&!reportStore.audioData.paused)&&'img-play']" @click="handleClickAudio"></div>
+        <div class="content">
+            <div class="name">{{props.data.video_name}}</div>
+            <div class="des">
+                <span>主讲人:FICC团队</span>
+                <span>大小:{{props.data.video_size}}M</span>
+                <span>时长:{{formatVoiceTime(props.data.video_play_seconds)}}</span>
+            </div>
+        </div>
+    </div>
+</template>
+
+<style lang="scss" scoped>
+.audio-box{
+    background: #F2F3FF;
+    border-radius: 8px 8px 8px 8px;
+    border: 1px solid #3D5EFF99;
+    padding: 15px 20px;
+    align-items: center;
+    .img{
+        width: 70px;
+        height: 70px;
+        flex-shrink: 0;
+        margin-right: 20px;
+        cursor: pointer;
+        background-image: url('@/assets/imgs/audio_pause_big.png');
+        background-size: cover;
+    }
+    .img-play{
+        background-image: url('@/assets/imgs/audio_play_big.png');
+    }
+    .content{
+        flex: 1;
+        .name{
+            font-weight: bold;
+            margin-bottom: 18px;
+        }
+        .des{
+            span{
+                color: #666;
+                font-size: 14px;
+                margin-right: 20px;
+            }
+        }
+    }
+}
+</style>

+ 55 - 0
src/views/report/components/ChapterWrap.vue

@@ -0,0 +1,55 @@
+<script setup>
+import { ref } from 'vue'
+const props = defineProps({
+  list: Array,
+  chapterId: Number
+})
+
+const emit = defineEmits(['change'])
+
+
+</script>
+<template>
+  <div class="chapter-list-wrap">
+    <div :class="['item',{act: item.ReportChapterId===chapterId},{disabled: !item.HasAuth}]" v-for="item in list" :key="item" @click="emit('change',item)">
+      <div class="chapter-title text-ellipsis--l1" v-text="item.TypeName" v-if="item.TypeName"></div>
+      <p class="text-ellipsis--l1" v-text="item.Title"></p>
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+.chapter-list-wrap {
+  width: 200px;
+  max-height: 60vh;
+  overflow-y: auto;
+  position: fixed;
+  z-index: 9;
+}
+.item {
+  font-size: #666;
+  margin-bottom: 20px;
+  padding: 4px 10px;
+  cursor: pointer;
+  .chapter-title {
+    font-size: 18px;
+    color: #333;
+    margin-bottom: 10px;
+    font-weight: bold;
+  }
+
+  &.act {
+    color: #7C54FF;
+    background: #F8F8F8;
+    .chapter-title {
+      color: #7C54FF;
+    }
+  }
+  &.disabled {
+      color: #C0C4CC;
+      background: #F8F8F8;
+    .chapter-title {
+      color: #C0C4CC;
+    }
+  }
+}
+</style>

+ 31 - 0
src/views/report/components/Disclaimer.vue

@@ -0,0 +1,31 @@
+<script setup>
+import { ref } from 'vue'
+
+const props = defineProps({
+  content: String
+})
+const show = defineModel('show', { type: Boolean, default: false })
+
+</script>
+
+<template>
+  <t-dialog 
+    v-model:visible="show" 
+    header="免责声明" 
+    center 
+    draggable 
+    width="40%"
+    :cancelBtn="null"
+    confirmBtn="知道了"
+    @confirm="show=false"
+  >
+    <div class="disclaimers-box" v-html="content"></div>
+  </t-dialog>
+</template>
+
+<style lang="scss" scoped>
+.disclaimers-box {
+  max-height: 60vh;
+  overflow: auto;
+}
+</style>

+ 68 - 0
src/views/report/components/ReportContent.vue

@@ -0,0 +1,68 @@
+<script setup>
+import {reactive,watch,onMounted,onUnmounted, nextTick} from 'vue'
+// import { useThrottleFn } from '@vueuse/core'
+import _ from 'lodash'
+
+const props=defineProps({
+    html:{
+        type:String
+    }
+})
+
+const listState=reactive({
+    totalList:[],//分割好的html数组
+    totalPage:0,//总页数
+    list:[],
+    page:0,
+    pageSize:20,
+})
+
+function loadContent(){
+    listState.list = listState.list.concat(listState.totalList.slice(listState.page*listState.pageSize, (listState.page + 1)*listState.pageSize))
+}
+
+const load=_.throttle(()=>{
+    if(listState.page>=listState.totalPage) return
+    const clientHeight = document.documentElement.clientHeight || document.body.clientHeight; // 可视高度
+    const scrollHeight = document.body.scrollHeight; // 总高度
+    const scrollTop = document.documentElement.scrollTop || document.body.scrollTop; // 滚动的高度
+    // console.log(clientHeight,scrollHeight);
+    if (scrollTop + clientHeight >= scrollHeight - 80) {
+        console.log('触底')
+        listState.page ++;
+        loadContent();
+    }
+},300)
+
+
+watch(
+    ()=>props.html,
+    (n)=>{
+        
+        const arr = props.html.split('</p>');
+        listState.totalList = arr.map(_ => _+'</p>');
+        listState.list = listState.totalList.slice(0,listState.pageSize)
+        listState.totalPage =  parseInt(listState.totalList.length / listState.pageSize) + 1;
+    },
+    {
+        immediate:true
+    }
+)
+
+onMounted(()=>{
+    window.addEventListener('scroll',load)
+})
+onUnmounted(()=>{
+    window.removeEventListener('scroll',load)
+})
+
+
+</script>
+
+<template>
+    <div v-for="item in listState.list" :key="item" v-html="item"></div>
+</template>
+
+<style lang="scss" scoped>
+
+</style>

+ 100 - 0
src/views/report/components/RightSlideWrap.vue

@@ -0,0 +1,100 @@
+<script setup>
+import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { apiEtaReport } from '@/api/etaReport'
+import { useReportStore } from '@/store/modules/report'
+
+const router = useRouter()
+const reportStore = useReportStore();
+
+
+const firstClassifyList = ref([])
+async function getPermissionList() {
+  const res = await apiEtaReport.permissionList()
+  if(res.Ret !== 200) return
+  firstClassifyList.value = res.Data.map(_ => ({
+    ..._,
+    Child: null
+  }));
+}
+getPermissionList()
+
+
+async function getPosterInfo() {
+  const res = await apiEtaReport.reportPoster();
+  if(res.Ret !== 200) return
+  reportStore.setPosterInfo(res.Data || '')
+}
+getPosterInfo()
+
+
+function handleClickClassify(item) {
+  router.push(`/etaReport/classify?permissionId=${item.PermissionId}`)
+}
+
+function handleToBanner() {
+  router.push('/etaReport/posterDetail')
+}
+
+</script>
+<template>
+  <div class="right-aside-box">
+    <div class="fix-top" style="z-index: 100;">
+      <div class="recommand-banner" :style="`background-image: url(${reportStore.posterInfo?.ShortImg})`" @click="handleToBanner"></div>
+      <div class="recmd-box">
+        <div class="label">行业板块</div>
+        <div class="flex recmd-wrap">
+
+          <div 
+            class="recmd-item" 
+            v-for="item in firstClassifyList" 
+            :key="item.PermissionName"
+            @click="handleClickClassify(item)"
+            :style="`background-image: url('${item.ForumIndustryImgUrl}')`"
+          >
+            <div class="title" :style="`width:${item.PermissionName.length>3?'65px':'auto'}`">{{item.PermissionName}}</div>
+          </div>
+        </div>
+      </div>
+
+    </div>
+  </div>
+</template>
+<style scoped lang="scss">
+@import '../css/index.scss';
+.right-aside-box{
+    .recommand-banner {
+      width: 420px;
+      height: 240px;
+      margin-bottom: 15px;
+      background-size: cover;
+      cursor: pointer;
+    }
+
+    .recmd-box {
+      .label {
+        font-size: 24px;
+      }
+
+      .recmd-wrap {
+        flex-wrap: wrap;
+        gap: 15px;
+        // justify-content: space-around;
+        max-height:30vh;
+        overflow-y: auto; 
+        .recmd-item {
+          width: 120px;
+          height: 120px;
+          display: flex;
+          justify-content: center;
+          align-items: center;
+          .title {
+            color: #fff;
+            font-size: 28px;
+            text-align: center;
+          }
+        }
+      }
+    }
+  }
+</style>

+ 54 - 0
src/views/report/css/index.scss

@@ -0,0 +1,54 @@
+.safe-content {
+  max-width: 1240px;
+  margin: 0 auto;
+}
+
+.flex {
+  display: flex;
+  align-items: center;
+}
+
+// 有右边盒子的模块样式
+.hasrightaside-box {
+  display: flex;
+  .content-box{
+    flex:1;
+  }
+
+  .right-aside-box {
+    flex-shrink: 0;
+    width: 420px;
+    margin-left: 30px;
+    padding: 30px 0;
+    background-color: #fff;
+    position: relative;
+    z-index: 9;
+
+    .fix-top {
+      position: fixed;
+      top: 100px;
+      width: 420px;
+    }
+
+    .label {
+      font-size: 18px;
+      font-weight: bold;
+      margin-bottom: 10px;
+      padding-bottom: 10px;
+      position: relative;
+
+      &::before {
+        content: '';
+        display: inline-block;
+        width: 100%;
+        height: 2px;
+        background: linear-gradient(90deg, #7c54ff 0%, #4b64f7 100%);
+        border-radius: 2px 2px 2px 2px;
+        margin-right: 10px;
+        position: absolute;
+        left: 0;
+        bottom: 0;
+      }
+    }
+  }
+}

+ 15 - 0
src/views/report/hooks/useReport.js

@@ -0,0 +1,15 @@
+import { useRouter } from 'vue-router'
+import { MessagePlugin } from 'tdesign-vue-next'
+export function useReport() {
+
+  const router = useRouter()
+  function handleToDetail(item) {
+    if(!item.HasAuth) return MessagePlugin.warning('您暂未开通该报告阅读权限,请联系销售!')
+
+    router.push(`/etaReport/detail?id=${item.ReportId}&chapterId=${item.ReportChapterId||''}`)
+  }
+
+  return {
+    handleToDetail
+  }
+}

+ 23 - 0
src/views/user/favorite/Index.vue

@@ -0,0 +1,23 @@
+<script setup>
+import { ref } from 'vue'
+import { useRoute } from 'vue-router'
+import Aslide from './components/CollectAslide.vue'
+import etaReport from './etaReport.vue'
+import etaChart from './etaChart.vue'
+
+const route = useRoute()
+
+function getComponents() {
+  return route.path==='/etaChart/favorite' ? etaChart : etaReport;
+}
+</script>
+<template>
+  <div class="flex">
+    <Aslide />
+
+    <component :is="getComponents()"></component>
+  </div>
+</template>
+<style scoped lang="scss">
+
+</style>

+ 1 - 1
src/views/user/favorite/components/ClassifyWrap.vue

@@ -99,7 +99,7 @@ const showMoveChart=ref(false)
 
 <template>
   <div class="bg-white flex_col classify-wrap">
-    <h3 class="label-text">ETA投研资源库</h3>
+    <h3 class="label-text">ETA库</h3>
     <div class="classify-tree"> 
       <t-tree
         ref="treeIns"

+ 75 - 0
src/views/user/favorite/components/CollectAslide.vue

@@ -0,0 +1,75 @@
+<script setup>
+import { ref } from 'vue'
+import { useRouter,useRoute } from 'vue-router'
+import { usePermissionMenu } from '@/hooks/usePermissionMenu'
+
+const route = useRoute()
+const router = useRouter()
+
+const { getPermissionMenu } = usePermissionMenu()
+
+const emit = defineEmits(['change'])
+
+const menu = [
+  {  
+    title: 'ETA图库',
+    path: '/etaChart/favorite',
+    icon:'chart',
+    menu_map: '/etaChart/index'
+  },
+  {  
+    title: 'ETA研报',
+    path: '/etaReport/favorite',
+    icon:'report',
+    menu_map: '/etaReport/index'
+  },
+]
+const activeMenu = ref(route.path)
+watch(() => route.path,
+  (newpath) => {
+    activeMenu.value = newpath
+  }
+)
+
+const permissionMenu = ref([])
+async function init() {
+	const res = await getPermissionMenu()
+  const arr = res || [];
+	if(arr.length) {
+    let pathArr = arr.map(_ => _.path);
+    permissionMenu.value = menu.filter(_=>pathArr.includes(_.menu_map));
+    router.replace(permissionMenu.value[0].path)
+	}
+}
+init()
+
+function handleClickItem(value) {
+  router.push(value)
+}
+</script>
+<template>
+<div class="left-aslide">
+  <t-menu v-model="activeMenu" theme="light" @change="handleClickItem" width="200px">
+    <t-menu-item :value="item.path" v-for="item in permissionMenu" :key="item.path">
+      <svg-icon 
+        :name="item.icon"
+        style="font-size:20px;margin-right:10px;"
+      ></svg-icon>
+      {{item.title}}
+    </t-menu-item>
+  </t-menu>
+</div>
+</template>
+<style scoped lang="scss">
+.left-aslide {
+  margin-right: 20px;
+  background: #fff;
+  border: 1px solid #D8D8D8;
+  box-shadow: 0px 4px 12px 0px #23003514;
+  border-radius: 4px;
+}
+:deep(.t-menu__content) {
+  display: flex;
+  align-items: center;
+}
+</style>

+ 128 - 0
src/views/user/favorite/components/CollectReport.vue

@@ -0,0 +1,128 @@
+<script setup>
+import { apiEtaReportCollect } from '@/api/etaReport'
+import { MessagePlugin } from 'tdesign-vue-next'
+import EditClassify from './EditClassify.vue'
+
+
+
+const show = defineModel('show', { type: Boolean, default: false })
+const emits = defineEmits(['success'])
+
+const props = defineProps({
+  data: {
+    type: [null, Object],
+    default: null
+  }
+})
+
+const selectClassify = ref('')
+const classifyOpts = ref([])
+async function getClassifyOpts() {
+  const res = await apiEtaReportCollect.classifyList()
+  if (res.Ret !== 200) return
+  const arr = res.Data?.List || []
+  classifyOpts.value = arr
+}
+
+const showEditClassify=ref(false)
+
+watch(
+  () => show.value,
+  (n) => {
+    if (n) {
+      selectClassify.value = ''
+      getClassifyOpts()
+    }
+  }
+)
+
+async function handleSave(){
+  if(!selectClassify.value){
+    MessagePlugin.warning('请选择分类')
+    return
+  }
+  const res=await apiEtaReportCollect.reportCollect({
+    ReportId:props.data.reportId,
+    ReportChapterId: props.data.chapterId,
+    CollectClassifyId:selectClassify.value
+  })
+  if(res.Ret!==200) return
+  show.value=false
+  // if(res.Msg==='已收藏,可选择在ETA投研资源库-我的收藏/ETA-我的图库中-ETA投研资源库查看'){
+  //   await $confirmDialog({
+  //     header:'提示',
+  //     body: res.Msg,
+  //     confirmBtn:'知道了',
+  //     cancelBtn:null,
+  //   });
+  // }else{
+  MessagePlugin.success('收藏成功')
+  emits('success')
+}
+
+
+</script>
+
+<template>
+  <t-dialog
+    v-model:visible="show"
+    attach="body"
+    width="500px"
+    :header="'选择我的分类'"
+    closeOnOverlayClick
+    destroyOnClose
+    class="collect-chart-pop"
+  >
+    <div class="classify-wrap">
+      <t-tag
+        :theme="selectClassify===item.CollectClassifyId?'primary':'default'"
+        :variant="selectClassify===item.CollectClassifyId?'':'outline'"
+        v-for="item in classifyOpts"
+        :key="item.CollectClassifyId"
+        @click="selectClassify=item.CollectClassifyId"
+        >{{ item.ClassifyName }}</t-tag
+      >
+    </div>
+    <div class="add_btn" @click="showEditClassify=true">
+      <t-icon name="add-circle"></t-icon>
+      <span>新增</span>
+    </div>
+    <template #footer>
+      <div class="bottom-btn">
+        <t-button theme="default" @click="show = false">取消</t-button>
+        <t-button type="submit" @click="handleSave">确定</t-button>
+      </div>
+    </template>
+  </t-dialog>
+
+  <!-- 新增\编辑分类 -->
+  <EditClassify 
+    v-model:show="showEditClassify" 
+    :data="null" 
+    source="report"
+    @change="getClassifyOpts"
+  />
+</template>
+
+<style lang="scss" scoped>
+.collect-chart-pop{
+  .classify-wrap{
+    padding: 20px;
+    border-radius: 4px;
+    border: 1px dashed var(--border-color);
+    display: flex;
+    flex-wrap: wrap;
+    gap: 5px;
+  }
+  .add_btn{
+    margin-top: 20px;
+    display: flex;
+    align-items: center;
+    color: var(--td-brand-color);
+    font-size: 16px;
+    gap: 0 5px;
+    cursor: pointer;
+  }
+}
+
+</style>

+ 27 - 0
src/views/user/favorite/components/EditClassify.vue

@@ -1,7 +1,9 @@
 <script setup>
 import { useTemplateRef } from "vue"
 import {apiETAChartUser} from '@/api/etaChart'
+import { apiEtaReportCollect } from '@/api/etaReport'
 import {MessagePlugin} from 'tdesign-vue-next'
+import { async } from "@antv/x6/lib/registry/marker/async"
 
 
 
@@ -12,6 +14,10 @@ const props = defineProps({
   data: {
     type: [null, Object],
     default: null
+  },
+  source: { // 研报复用  report 默认chart
+    type: String,
+    default: 'chart'
   }
 })
 
@@ -25,6 +31,27 @@ const formIns=useTemplateRef('formIns')
 async function handleSave(){
   const validRes=await formIns.value.validate()
   if(validRes!==true) return
+
+  props.source === 'report' ? handleReoprtApi() : handleChartApi()
+
+ 
+}
+
+async function handleReoprtApi() {
+  const res=props.data
+    ? await apiEtaReportCollect.editClassify({
+      ClassifyName:formData.classifyName,
+      CollectClassifyId:props.data.CollectClassifyId
+    })
+    : await apiEtaReportCollect.addClassify({
+      ClassifyName:formData.classifyName
+    })
+  if(res.Ret!==200) return
+  MessagePlugin.success(props.data?'编辑成功':'新增成功')
+  show.value=false
+  emits('change')
+}
+async function handleChartApi() {
   const res=props.data?await apiETAChartUser.editClassify({
     ClassifyName:formData.classifyName,
     CollectClassifyId:props.data.CollectClassifyId

+ 1 - 1
src/views/user/favorite/components/MoveChart.vue

@@ -188,7 +188,7 @@ async function handleSave() {
     attach="body"
     width="850px"
     top="80px"
-    header="转移分类(ETA投研资源库分类)" 
+    header="转移分类(ETA库分类)" 
     closeOnOverlayClick
     destroyOnClose
     class="chart-move-pop"

+ 289 - 0
src/views/user/favorite/components/MoveReport.vue

@@ -0,0 +1,289 @@
+<script setup>
+import { apiEtaReportCollect,apiEtaReport } from '@/api/etaReport'
+import { SearchIcon } from 'tdesign-icons-vue-next'
+import { MessagePlugin } from 'tdesign-vue-next'
+
+const show = defineModel('show', { type: Boolean, default: false })
+const emits=defineEmits(['change'])
+
+const props=defineProps({
+  classifyOpts:{
+    type:Array,
+    default:()=>[]
+  }
+})
+
+
+const selectClassify = ref([])
+const keyword = ref('')
+const checkAll = ref(false)
+const indeterminate=ref(false)
+let unSelectedCharts=[]//当全选时 又取消掉部分的放在这里面
+
+const columns=[
+  {
+    colKey: 'row-select',
+    type: 'multiple',
+    width: 50,
+  },
+  {
+    colKey: 'Title',
+    title: '研报名称',
+    align: 'center'
+  },
+  {
+    colKey: 'AdminRealName',
+    title: '创建人',
+    width: '120',
+    align: 'center'
+  },
+  
+]
+const tableData=ref([])
+const pagination = ref({
+  pageSize: 10,
+  total: 0,
+  current: 1,
+  showPageSize:false
+});
+async function getChartList(){
+  const res=await apiEtaReportCollect.reportCollectList({
+    CollectClassifyId:selectClassify.value?selectClassify.value.join(','):'',
+    Keyword:keyword.value,
+    PageSize:pagination.value.pageSize,
+    CurrentIndex:pagination.value.current
+  })
+  if(res.Ret!==200) return
+  const arr=res.Data.List||[]
+  pagination.value.total=res.Data.Paging.Totals
+  tableData.value=arr.map(_ => ({
+    ..._,
+    singleId: `${_.ReportChapterId}_${_.ReportId}`
+  }))
+  if(checkAll.value){
+    tableData.value.forEach(item=>{
+      if(!selectedRowKeys.value.includes(item.singleId)&&!unSelectedCharts.includes(item.singleId)){
+        selectedRowKeys.value.push(item.singleId)
+      }
+    })
+  }
+}
+watch(
+  ()=>show.value,
+  (n)=>{
+    if(n){
+      keyword.value=''
+      selectClassify.value=[]
+      newClassify.value=''
+      unSelectedCharts=[]
+      selectedRowKeys.value=[]
+      handleRefreshList()
+    }
+  }
+)
+
+function onPageChange(pageInfo){
+  pagination.value.current = pageInfo.current
+  getChartList()
+}
+function handleRefreshList() {
+  pagination.value.current = 1
+  checkAll.value=false
+  selectedRowKeys.value=[]
+  indeterminate.value=false
+  unSelectedCharts=[]
+  getChartList()
+}
+
+const selectedRowKeys = ref([]);//当前表格的选择
+function handleTableSelectChange(value, { type, currentRowKey }) {
+  const isFullSelection = currentRowKey === 'CHECK_ALL_BOX';
+  const isCheckAction = type === 'check';
+
+  if (isFullSelection) {
+    handleFullSelection(isCheckAction);
+  } else {
+    handleSingleSelection(currentRowKey, isCheckAction);
+  }
+
+  updateSelectionState();
+}
+function handleFullSelection(isCheck) {
+  const currentPageIds = new Set(tableData.value.map(item => item.singleId));
+  
+  if (isCheck) {
+    // 全选时从排除列表移除当前页所有ID
+    unSelectedCharts = unSelectedCharts.filter(id => !currentPageIds.has(id));
+  } else {
+    // 取消全选时添加当前页所有ID到排除列表
+    const newUnselected = [...new Set([...unSelectedCharts, ...currentPageIds])];
+    unSelectedCharts = newUnselected;
+  }
+}
+function handleSingleSelection(id, isCheck) {
+  if (isCheck) {
+    // 选中时从排除列表移除
+    unSelectedCharts = unSelectedCharts.filter(item => item !== id);
+  } else {
+    // 取消选中时添加到排除列表(去重)
+    if (!unSelectedCharts.includes(id)) {
+      unSelectedCharts = [...unSelectedCharts, id];
+    }
+  }
+}
+function updateSelectionState() {
+  const total = pagination.value.total;
+  const selectedCount = checkAll.value 
+    ? total - unSelectedCharts.length 
+    : selectedRowKeys.value.length;
+
+  checkAll.value = selectedCount === total;
+  indeterminate.value = selectedCount > 0 && selectedCount < total;
+}
+
+function handleClickCheckAll(check) {
+  if(check){
+    // 全选
+    indeterminate.value=false
+    unSelectedCharts=[]
+    tableData.value.forEach(item=>{
+      if(!selectedRowKeys.value.includes(item.singleId)){
+        selectedRowKeys.value.push(item.singleId)
+      }
+    })
+  }else{
+    indeterminate.value=false
+    selectedRowKeys.value=[]
+    unSelectedCharts=[]
+  }
+}
+
+const newClassify=ref('')
+async function handleSave() {
+  if(!newClassify.value){
+    MessagePlugin.warning('请选择要移动至的分类')
+    return
+  }
+  if(!checkAll.value&&selectedRowKeys.value===0){
+    MessagePlugin.warning('请选择要移动的图表')
+    return
+  }
+
+  let Collects = checkAll.value
+    ? unSelectedCharts.map(_ =>{
+        let arr = _.split('_');
+        return {
+          ReportId: Number(arr[1]),
+          ReportChapterId: Number(arr[0])
+        }
+      })
+    : selectedRowKeys.value.map(_ =>{
+        let arr = _.split('_');
+        return {
+          ReportId: Number(arr[1]),
+          ReportChapterId: Number(arr[0])
+        }
+      })
+
+  const params={
+    SelectAll:checkAll.value,
+    CollectClassifyIds:selectClassify.value?selectClassify.value:[],
+    Keyword:keyword.value,
+    Collects,
+    CollectClassifyId:newClassify.value,
+  }
+  const res=await apiEtaReportCollect.reportCollectBatchMove(params)
+  if(res.Ret!==200) return
+  MessagePlugin.success('转移成功')
+  show.value=false
+  emits('change')
+}
+
+
+</script>
+
+<template>
+  <t-dialog
+    v-model:visible="show"
+    attach="body"
+    width="850px"
+    top="80px"
+    header="转移分类(ETA研报分类)" 
+    closeOnOverlayClick
+    destroyOnClose
+    class="chart-move-pop"
+  >
+    <div class="flex top-filter-wrap">
+      <t-select
+        v-model="selectClassify"
+        placeholder="研报分类"
+        multiple
+        :min-collapsed-num="1"
+        clearable
+        style="max-width: 235px"
+        @change="handleRefreshList"
+      >
+        <t-option
+          v-for="item in props.classifyOpts"
+          :key="item.CollectClassifyId"
+          :label="item.ClassifyName"
+          :value="item.CollectClassifyId"
+        ></t-option>
+      </t-select>
+      <t-input placeholder="请输入研报名称" v-model="keyword" @change="handleRefreshList" style="max-width: 600px">
+        <template #prefixIcon>
+          <SearchIcon />
+        </template>
+      </t-input>
+      <t-checkbox 
+        style="flex-shrink: 0" 
+        v-model="checkAll"
+        :indeterminate="indeterminate"
+        @change="handleClickCheckAll"
+      >全选</t-checkbox>
+    </div>
+    <t-table
+      row-key="singleId"
+      :data="tableData"
+      :columns="columns"
+      bordered
+      max-height="600"
+      resizable
+      v-model:selectedRowKeys="selectedRowKeys"
+      :pagination="pagination"
+      @page-change="onPageChange"
+      @select-change="handleTableSelectChange"
+    >
+    </t-table>
+    <div class="flex" style="align-items:center;gap:0 10px">
+      <span>移动至新分类</span>
+      <t-select
+        v-model="newClassify"
+        placeholder="研报分类"
+        style="max-width: 235px"
+      >
+        <t-option
+          v-for="item in props.classifyOpts"
+          :key="item.CollectClassifyId"
+          :label="item.ClassifyName"
+          :value="item.CollectClassifyId"
+        ></t-option>
+      </t-select>
+    </div>
+    <template #footer>
+      <div class="bottom-btn" style="text-align: center">
+        <t-button theme="default" @click="show = false">取消</t-button>
+        <t-button type="submit" @click="handleSave">确认</t-button>
+      </div>
+    </template>
+  </t-dialog>
+</template>
+
+<style lang="scss" scoped>
+.chart-move-pop {
+  .top-filter-wrap {
+    gap: 10px;
+    margin-bottom: 20px;
+  }
+}
+</style>

+ 192 - 0
src/views/user/favorite/components/ReportClassifyWrap.vue

@@ -0,0 +1,192 @@
+<script setup>
+import {apiETAChartUser} from '@/api/etaChart'
+import { apiEtaReportCollect } from '@/api/etaReport'
+import { useTemplateRef, watch } from "vue";
+import EditClassify from './EditClassify.vue'
+import MoveReport from './MoveReport.vue'
+import { MessagePlugin } from 'tdesign-vue-next';
+
+const emits=defineEmits(['change'])
+
+const selecClassify=defineModel('classifyId',{type:[Number,String],default:0})
+function handleClassifyItemClick({node}){
+  selecClassify.value=node.value
+}
+
+watch(
+  ()=>selecClassify.value,
+  ()=>{
+    // console.log("分类改变");
+    emits('change',selecClassify.value)
+  }
+)
+
+const classifyActions=[
+  {
+    content:'重命名',
+    value:'rename',
+    action: (node) => {
+      activeClassifyData.value = node.data;
+      showEditClassify.value = true;
+    },
+  },
+  {
+    content:'删除',
+    value:'delete',
+    action: (node) => handleDeleteClassify(node),
+  }
+]
+const classifyList=ref([])
+// 获取分类
+async function getClassifyList(){
+  const res=await apiEtaReportCollect.classifyList()
+  if(res.Ret!==200)return
+  const arr=res.Data?.List||[]
+  classifyList.value=arr
+  if(arr.length>0){
+    selecClassify.value=arr[0].CollectClassifyId
+  }
+}
+getClassifyList()
+
+// 移动排序
+const treeIns=useTemplateRef('treeIns')
+// dropPosition -1放在dropNode前面 0里面 1后面
+function handleAllowDrop({dropNode, dropPosition}){
+  if(dropPosition===0) return false//不允许向里面拖动
+}
+async function handleSortEnd({node}){
+  const resArr=treeIns.value.getTreeData()//移动后的数组
+  const moveTargetIndex=treeIns.value.getIndex(node.value)//被拖动的元素拖动后的位置
+  const params={
+    PrevCollectClassifyId:moveTargetIndex===0?0:resArr[moveTargetIndex-1].CollectClassifyId,
+    NextCollectClassifyId:moveTargetIndex===resArr.length-1?0:resArr[moveTargetIndex+1].CollectClassifyId,
+    CollectClassifyId:node.value
+  }
+  const res=await apiEtaReportCollect.moveClassify(params)
+  if(res.Ret!==200){
+    getClassifyList()
+    return
+  }
+  MessagePlugin.success("移动成功")
+}
+
+
+// 删除分类
+async function handleDeleteClassify(node){
+  await $confirmDialog({
+    header:'提示',
+    body: '若删除该分类,则分类下关联的所有本报告将被全部删除,是否继续?',
+  });
+  const res=await apiEtaReportCollect.deleteClassify({
+    CollectClassifyId:node.value
+  })
+  if(res.Ret!==200) return
+  MessagePlugin.success('删除成功')
+  getClassifyList()
+}
+
+const showEditClassify=ref(false)
+const activeClassifyData=ref(null)
+// 点击分类的操作
+function clickHandler(option,node){
+  option.action(node);
+}
+
+// 转移分类
+const showMoveReport=ref(false)
+
+</script>
+
+<template>
+  <div class="bg-white flex_col classify-wrap">
+    <h3 class="label-text">ETA研报</h3>
+    <div class="classify-tree"> 
+      <t-tree
+        ref="treeIns"
+        :actived="[selecClassify]"
+        :data="classifyList"
+        :keys="{value:'CollectClassifyId',label:'ClassifyName'}"
+        activable
+        transition
+        draggable
+        :allow-drop="handleAllowDrop"
+        :onDragEnd="handleSortEnd"
+        :onClick="handleClassifyItemClick"
+      >
+        <template #icon>
+          <t-icon name="drag-move" style="font-size:20px"></t-icon>
+        </template>
+        <template #operations="{node}">
+          <t-dropdown :options="classifyActions" trigger="hover" @click="clickHandler($event,node)">
+            <t-icon name="more" style="font-size:20px"></t-icon>
+          </t-dropdown>
+        </template>
+        <template #empty>
+          <div style="text-align:center">
+            <img style="width:50%" src="@/assets/imgs/nodata.png" alt="">
+            <div>暂无数据</div>
+          </div>
+        </template>
+      </t-tree>
+    </div>
+    <div>
+      <div class="opt-item" @click="activeClassifyData=null;showEditClassify=true">
+        <t-icon name="add-rectangle" size="20px"></t-icon>
+        <span>添加分类</span>
+      </div>
+      <div class="opt-item" @click="showMoveReport=true">
+        <t-icon name="swap" size="20px"></t-icon>
+        <span>转移分类</span>
+      </div>
+    </div>
+  </div>
+  <!-- 新增\编辑分类 -->
+  <EditClassify 
+    v-model:show="showEditClassify" 
+    source="report"
+    :data="activeClassifyData" 
+    @change="getClassifyList"
+  />
+  <!-- 转移分类 -->
+  <MoveReport v-model:show="showMoveReport" :classifyOpts="classifyList" @change="emits('change',selecClassify)"/>
+</template>
+
+<style lang="scss" scoped>
+.classify-wrap{
+  flex: 1;
+  border: 1px solid var(--border-color);
+  box-shadow: 0px 4px 12px 0px #2300351F;
+  border-radius: 4px;
+  .label-text{
+    padding: 20px;
+    margin: 0;
+    font-size: 16px;
+  }
+  .classify-tree{
+    flex: 1;
+    overflow-y: auto;
+    padding-bottom: 30px;
+    :deep(.t-is-active){
+      background-color: var(--td-brand-color-light);
+    }
+    :deep(.t-tree__icon){
+      margin-left: 20px;
+    }
+    :deep(.t-tree__operations){
+      margin-right: 20px;
+    }
+  }
+  .opt-item{
+    color: var(--td-brand-color);
+    text-align: center;
+    margin-bottom: 30px;
+    cursor: pointer;
+    span{
+      display: inline-block;
+      margin-left: 5px;
+      font-size: 16px;
+    }
+  }
+}
+</style>

+ 3 - 2
src/views/user/favorite/etaChart.vue

@@ -70,10 +70,10 @@ async function handleCancelCollect(item,index){
 <template>
   <div class="flex my-favorite-chart-page">
     <div class="flex left-wrap">
-      <div class="top-box">
+      <!-- <div class="top-box">
         <span @click="$router.push('/etaChart/index')">研究图库 ></span>
         <span>我的收藏</span>
-      </div>
+      </div> -->
       <ClassifyWrap v-model:classifyId="classifyId" @change="handleClassifyChange"/>
     </div>
     <div class="flex right-wrap">
@@ -127,6 +127,7 @@ async function handleCancelCollect(item,index){
 
 <style lang="scss" scoped>
 .my-favorite-chart-page{
+  flex: 1;
   gap: 0 20px;
   height: calc(100vh - 120px);
   .left-wrap{

+ 293 - 0
src/views/user/favorite/etaReport.vue

@@ -0,0 +1,293 @@
+<script setup>
+import {SearchIcon} from 'tdesign-icons-vue-next'
+import ClassifyWrap from './components/ReportClassifyWrap.vue'
+import { apiEtaReportCollect,apiEtaReport } from '@/api/etaReport'
+import { useReport } from '../../report/hooks/useReport'
+import { MessagePlugin } from 'tdesign-vue-next'
+import moment from 'moment'
+import { ref,reactive } from 'vue'
+
+
+const  { handleToDetail } = useReport()
+
+const filterState = reactive({
+  industry: [],
+  reportClassifyIds: [],
+  keyword: ''
+})
+const industryOpts = ref([])
+async function getPermissionList() {
+  const res = await apiEtaReport.permissionList()
+  if(res.Ret !== 200) return
+  industryOpts.value = res.Data || [];
+}
+getPermissionList()
+
+const reportClassifyList = ref([])
+async function getReportClassify() {
+  const res = await apiEtaReportCollect.etaReportClassify()
+  if(res.Ret !== 200) return
+  reportClassifyList.value = res.Data
+}
+getReportClassify()
+
+
+const colors = [ '#49517E','#DB4E2A','#53B3FF','#FBA730','#00C1CF','#8A4294' ]
+function getColor(index) {
+  return colors[index] || '#49517E'
+}
+
+
+const classifyId=ref(0)
+// const SysUserIds=ref([])
+const list=ref([])
+const page=ref(1)
+const pageSize=ref(30)
+const total=ref(0)
+const finished=ref(false)
+async function getReportList(){
+  const res=await apiEtaReportCollect.reportCollectList({
+    CollectClassifyId:classifyId.value||'',
+    PermissionIds:filterState.industry.join(','),
+    ClassifyIds: filterState.reportClassifyIds.join(','),
+    Keyword:filterState.keyword,
+    PageSize:pageSize.value,
+    CurrentIndex:page.value
+  })
+  if(res.Ret!==200) return
+  const arr=res.Data.List||[]
+  total.value=res.Data.Paging.Totals
+  finished.value=res.Data.Paging.IsEnd
+  list.value=arr
+}
+
+function onLoad(){
+  page.value++
+  getReportList()
+}
+function refreshList(){
+  page.value=1
+  list.value=[]
+  getReportList()
+}
+function handleClassifyChange(e){
+  refreshList()
+}
+
+// 搜索
+function handleSearch(){
+  classifyId.value=0
+  refreshList()
+}
+
+
+// 取消收藏
+async function handleCancelCollect(item,index){
+  const res=await apiEtaReportCollect.reportCollectCancel({
+    ReportId: item.ReportId,
+    ReportChapterId: item.ReportChapterId
+  })
+  if(res.Ret!==200) return
+  MessagePlugin.success('取消成功')
+  list.value.splice(index,1)
+}
+
+</script>
+
+<template>
+  <div class="flex my-favorite-chart-page">
+    <div class="flex left-wrap">
+      <ClassifyWrap v-model:classifyId="classifyId" @change="handleClassifyChange"/>
+    </div>
+    <div class="flex right-wrap">
+      <div class="flex top-filter">
+
+        <t-cascader 
+          v-model="filterState.industry" 
+          :options="industryOpts" 
+          :minCollapsedNum="1"
+          size="large"
+          :keys="{
+              label: 'PermissionName',
+              value: 'PermissionId',
+              children: 'Child'
+          }"
+          filterable
+          multiple 
+          clearable
+          placeholder="行业板块"
+          style="width:250px"
+          @change="refreshList"
+        />
+
+        <t-cascader 
+          v-model="filterState.reportClassifyIds" 
+          :options="reportClassifyList" 
+          :minCollapsedNum="1"
+          size="large"
+          :keys="{
+              label: 'ClassifyName',
+              value: 'Id',
+              children: 'Children',
+          }"
+          filterable
+          multiple 
+          clearable
+          placeholder="报告分类"
+          style="width:250px"
+          @change="refreshList"
+        />
+
+        <t-input 
+          v-model="filterState.keyword" 
+          placeholder="请输入研报名称" 
+          size="large" 
+          style="max-width:600px"
+          clearable
+          @change="handleSearch"
+        >
+          <template #prefixIcon>
+            <SearchIcon />
+          </template>
+        </t-input>
+      </div>
+      <!-- 报告列表 -->
+      <LoadList 
+        emptyMsg="暂无搜索结果"
+        :finished="finished" 
+        :isEmpty="list.length === 0 && finished" 
+        :loading="loading" 
+        :count="list.length"
+        @listOnload="onLoad"
+      >
+        <div class="report-list-wrap"  v-if="list.length">
+          <div 
+            class="content-item" 
+            v-for="item in list"
+            :key="`${item.ReportId}_${item.ReportChapterId}`" 
+            @click="handleToDetail(item)"
+          >
+              <div class="report-item-info">
+                <img :src="item.ClassifyCoverImg" class="report-img">
+                <div class="right-info">
+                  <div class="c-stage flex">
+                    <div>
+                      <t-tag 
+                        theme="primary" 
+                        v-for="(tag,index) in item.PermissionNames" 
+                        :key="tag"
+                        :style="`background: ${getColor(index)}`"
+                      >{{tag}}</t-tag>
+                      {{ `第${item.Stage}期` }}
+                      | {{ item.ClassifyName }}
+
+                    </div>
+                    
+                    <div class="c-time">{{ item.PublishTime }}</div>
+                  </div>
+                  <div class="c-title text-ellipsis--l1" v-html="item.Title"></div>
+                  <div class="desc text-ellipsis--l1" v-html="item.Abstract"></div>
+                </div>
+              </div>
+            </div>
+        </div>
+      </LoadList>
+    </div>
+  </div>
+
+</template>
+
+<style lang="scss" scoped>
+.my-favorite-chart-page{
+  flex: 1;
+  gap: 0 20px;
+  height: calc(100vh - 120px);
+  .left-wrap{
+    width: 310px;
+    height: 100%;
+    flex-direction: column;
+    .top-box{
+      height: 40px;
+      line-height: 40px;
+      margin-bottom: 20px;
+      span:first-child{
+        color: #666;
+        cursor: pointer;
+      }
+      span:last-child{
+        font-weight: 600;
+      }
+    }
+  }
+  .right-wrap{
+    flex: 1;
+    height: 100%;
+    flex-direction: column;
+    .top-filter{
+      margin-bottom: 20px;
+      gap: 0 20px;
+    }
+  }
+  .report-list-wrap {
+    flex: 1;
+    height: calc(100vh - 180px);
+    overflow-y: auto;
+    .content-item {
+      padding: 20px;
+      margin-bottom: 10px;
+      border: 1px solid #DCDFE6;
+      position: relative;
+      background-color: #fff;
+
+      .right-info{
+        flex: 1;
+      }
+
+      .report-item-info {
+        display: flex;
+      }
+
+      .t-tag--primary {
+        background-color: #49517E;
+        margin-right: 10px;
+      }
+
+      .c-time {
+        color: #666;
+        font-size: 14px;
+      }
+      .report-img {
+        width: 72px;
+        height: 96px;
+        margin-right: 10px;
+      }
+
+      .c-stage {
+        font-size: 16px;
+        justify-content: space-between;
+      }
+      .c-title {
+        font-size: 16px;
+        font-weight: bold;
+        word-wrap: break-word;
+        white-space: normal;
+        word-break: break-all;
+        margin-top: 10px;
+      }
+
+      .desc {
+        line-height: 1.5;
+        margin-top: 10px;
+        color: #666666;
+        font-size: 14px;
+        word-wrap: break-word;
+        word-break: break-all;
+        :deep(div){
+          word-wrap: break-word;
+          word-break: break-all;
+        }
+      }
+    }
+  }
+}
+</style>