shanbinzhang 2 weeks ago
parent
commit
4fe07bebe0
34 changed files with 2674 additions and 9 deletions
  1. 1 0
      package.json
  2. BIN
      src/assets/imgs/audio_next.png
  3. BIN
      src/assets/imgs/audio_pause.png
  4. BIN
      src/assets/imgs/audio_pause_big.png
  5. BIN
      src/assets/imgs/audio_play.png
  6. BIN
      src/assets/imgs/audio_play_big.png
  7. BIN
      src/assets/imgs/audio_prev.png
  8. 201 0
      src/components/Audio.vue
  9. 75 0
      src/components/LoadList.vue
  10. 20 3
      src/layout/Index.vue
  11. 54 0
      src/layout/components/Aslide.vue
  12. 2 0
      src/main.js
  13. 2 2
      src/router/modules/etaChart.js
  14. 39 0
      src/router/modules/report.js
  15. 4 0
      src/store/index.js
  16. 109 0
      src/store/modules/report/index.js
  17. 589 0
      src/views/report/Classify.vue
  18. 23 0
      src/views/report/Detail.vue
  19. 450 0
      src/views/report/Index.vue
  20. 10 0
      src/views/report/Search.vue
  21. 84 0
      src/views/report/components/AudioBox.vue
  22. 46 0
      src/views/report/components/ChapterWrap.vue
  23. 31 0
      src/views/report/components/Disclaimer.vue
  24. 69 0
      src/views/report/components/ReportContent.vue
  25. 54 0
      src/views/report/css/index.scss
  26. 19 0
      src/views/user/favorite/Index.vue
  27. 10 0
      src/views/user/favorite/components/Aslide.vue
  28. 1 1
      src/views/user/favorite/components/ClassifyWrap.vue
  29. 127 0
      src/views/user/favorite/components/CollectReport.vue
  30. 1 1
      src/views/user/favorite/components/MoveChart.vue
  31. 269 0
      src/views/user/favorite/components/MoveReport.vue
  32. 190 0
      src/views/user/favorite/components/ReportClassifyWrap.vue
  33. 2 2
      src/views/user/favorite/etaChart.vue
  34. 192 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",

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


+ 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 #F3A52F;
+            color: #F3A52F;
+            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>

+ 20 - 3
src/layout/Index.vue

@@ -1,12 +1,22 @@
 <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'
 
 const {getUserInfo,userInfo}=useUserInfo()
 const router=useRouter()
+const route= useRoute()
 
 getUserInfo()
 
+const dynamicStyle = computed(() => {
+	const isStylePage = route.path.includes('/etaReport')
+
+	return isStylePage ? 'background:#fff' : ''
+})
+
 
 async function handleLoginOut(){
 	await $confirmDialog({
@@ -23,7 +33,10 @@ async function handleLoginOut(){
 <template>
   <div class="layout-wrap">
     <div class="flex header">
-      <img class="logo" src="@/assets/imgs/logo.png" alt="" />
+			<div class="flex">
+      	<img class="logo" src="@/assets/imgs/logo.png" alt="" />
+				<Aslide/>
+			</div>
 			<div class="fav-btn" @click="$router.push('/etaChart/favorite')">我的收藏</div>
       <t-popup placement="bottom-left" overlayInnerClassName="header-userInfo-pop-wrap">
 				<template #content>
@@ -59,10 +72,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 +140,7 @@ async function handleLoginOut(){
     padding: 0 20px;
     .logo {
       width: 168px;
+			margin-right: 20px;
     }
 		.fav-btn{
 			cursor: pointer;

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

@@ -0,0 +1,54 @@
+<script setup>
+import { ref } from 'vue'
+import { useRouter } from 'vue-router'
+import { useRoute } from 'vue-router'
+
+const route = useRoute()
+const router = useRouter()
+
+const menu = [
+  {  
+    title: '研报',
+    path: '/etaReport/index'
+  },
+  {  
+    title: '图库',
+    path: '/etaChart/index'
+  },
+]
+const activeMenu = ref(route.path)
+
+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 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 - 2
src/router/modules/etaChart.js

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

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

@@ -0,0 +1,39 @@
+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:'研报详情',
+        },
+      },
+    ]
+  }
+]

+ 4 - 0
src/store/index.js

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

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

@@ -0,0 +1,109 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { MessagePlugin } from 'tdesign-vue-next';
+
+
+export const useReportStore = defineStore('reportState',() => {
+
+  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
+  }
+
+})

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

@@ -0,0 +1,589 @@
+<script setup>
+import { nextTick, reactive, ref } from 'vue'
+import { SearchIcon,InfoCircleFilledIcon } from 'tdesign-icons-vue-next';
+import { useRouter } from 'vue-router'
+import moment from 'moment'
+import 'moment/locale/zh-cn';
+moment.locale('zh-cn')
+import LoadList from '@/components/LoadList.vue'
+
+const router = useRouter()
+
+const searchText = ref('')
+
+const listState = reactive({
+  list: [
+        {
+          "report_id": 9716,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T16:21:51+08:00",
+          "title": "智能布局pdf生成",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">给他人工我跟热范围废物费恶无非王菲菲</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        },
+        {
+          "report_id": 9714,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T14:29:44+08:00",
+          "title": "测试报告常规布局",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">带我去多无群大青蛙单位群单位</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        },
+        {
+          "report_id": 9716,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T16:21:51+08:00",
+          "title": "智能布局pdf生成",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">给他人工我跟热范围废物费恶无非王菲菲</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        },
+        {
+          "report_id": 9714,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T14:29:44+08:00",
+          "title": "测试报告常规布局",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">带我去多无群大青蛙单位群单位</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        },
+        {
+          "report_id": 9716,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T16:21:51+08:00",
+          "title": "智能布局pdf生成",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">给他人工我跟热范围废物费恶无非王菲菲</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        },
+        {
+          "report_id": 9714,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T14:29:44+08:00",
+          "title": "测试报告常规布局",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">带我去多无群大青蛙单位群单位</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        },
+        {
+          "report_id": 9716,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T16:21:51+08:00",
+          "title": "智能布局pdf生成",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">给他人工我跟热范围废物费恶无非王菲菲</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        },
+        {
+          "report_id": 9714,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T14:29:44+08:00",
+          "title": "测试报告常规布局",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">带我去多无群大青蛙单位群单位</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        }
+  ],
+  showAll: false,
+  page: 1,
+  pageSize: 20,
+  finished: true,
+  loading: false,
+  dateArr: [],//日期数据
+})
+
+// 格式化列表日期
+const formatDate=(e)=>{
+  const isSameYear=moment(e).isSame(new Date(), 'year');
+  if(isSameYear){//今年
+    return moment(e).format('MM.DD')+' '+ moment(e).format('ddd')
+  }else{
+    return moment(e).format('YY.MM.DD')+' '+moment(e).format('ddd')
+  }
+}
+
+const latestNewsList = ref([
+  { img: '',title:'带我去带我去多带我去带我去多' },
+  { img: '',title:'带我去带我去多带我去带我去多' },
+  { img: '',title:'带我去带我去多带我去带我去多' },
+  { img: '',title:'带我去带我去多带我去带我去多',abstract: '带我去多无群单位娶老婆德莱文【的利物浦【 单 ' },
+  { img: '',title:'带我去带我去多带我去带我去多' },
+  { img: '',title:'带我去带我去多带我去带我去多' },
+  { img: '',title:'带我去带我去多带我去带我去多' },
+])
+
+
+
+const selectFirstType = ref('')
+const selectSubType = ref('')
+const firstClassifyList = ref([
+  { classify_name: '宏观经济',icon_url:'',id: 0 },
+  { classify_name: '宏观经济2',icon_url:'',id: 1 },
+  { classify_name: '宏观经济3',icon_url:'',id: 2 },
+  { classify_name: '宏观经济4',icon_url:'',id: 3 },
+  { classify_name: '宏观经济6',icon_url:'',id: 4 },
+])
+const subClassifyList = ref([
+  {
+    "chart_permission_name": "宏观经济",
+    "chart_permission_id": 1,
+    "sort": -999,
+    "auth_ok": true,
+    "pirce_driven_state": 0
+  },
+  {
+    "chart_permission_name": "利率债",
+    "chart_permission_id": 16,
+    "sort": -997,
+    "auth_ok": true,
+    "pirce_driven_state": 0
+  },
+  {
+    "chart_permission_name": "资产配置",
+    "chart_permission_id": 45,
+    "sort": -997,
+    "auth_ok": true,
+    "pirce_driven_state": 1
+  },
+  {
+    "chart_permission_name": "汇率",
+    "chart_permission_id": 46,
+    "sort": -994,
+    "auth_ok": true,
+    "pirce_driven_state": 1
+  },
+  {
+    "chart_permission_name": "贵金属",
+    "chart_permission_id": 47,
+    "sort": -994,
+    "auth_ok": true,
+    "pirce_driven_state": 1
+  },
+  {
+    "chart_permission_name": "名字超长名字超长名字超长名字超长名字超长名字超长名字超长名字超长名字超长",
+    "chart_permission_id": 108,
+    "sort": 2,
+    "auth_ok": false,
+    "pirce_driven_state": 1
+  },
+  {
+    "chart_permission_name": "股票交易权限",
+    "chart_permission_id": 76,
+    "sort": 23,
+    "auth_ok": false,
+    "pirce_driven_state": 1
+  },
+  {
+    "chart_permission_name": "jinyon",
+    "chart_permission_id": 107,
+    "sort": 63,
+    "auth_ok": false,
+    "pirce_driven_state": 1
+  }
+])
+function handleClickFirstType(item) {
+  selectFirstType.value = item.classify_name
+  // subTypeList.value = item.list;
+
+  // handleClickSubType(item.list[0])
+  // nextTick(() => {
+  //   resetHeaderWidthHandle();
+  // });
+}
+function handleClickSubType(item) {
+  selectSubType.value = item.chart_permission_id
+  listState.list = []
+  listState.page = 1
+  listState.finished = false
+  getReportList()
+  handleShowAuthData(item)
+  getLatestNews()
+}
+
+
+
+function handleToIndex() {
+  router.replace('/etaReport/index')
+}
+
+function handleToDetail() {
+  router.push(`/etaReport/detail?id=`)
+}
+
+// 实现头部的适配
+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">显示全部 </t-radio>
+            <t-tooltip 
+              content="未勾选时展示所有客户有阅读权限的报告,勾选时展示弘则ETA所有已发布的报告"
+            >
+              <InfoCircleFilledIcon style="margin-left:5px;"/>
+            </t-tooltip>  
+          </div>
+
+          <div class="first-tab flex">
+            <div 
+              :class="['item', item.classify_name == selectFirstType && 'item-active']" 
+              v-for="item in firstClassifyList" 
+              :key="item.classify_name" 
+              @click="handleClickFirstType(item)"
+            >{{ item.classify_name }}</div>
+          </div>
+        </div>
+
+        <t-input 
+          v-model="searchText" 
+          placeholder="请输入搜索内容" 
+          clearable
+          style="width:240px"
+        >
+          <template #prefixIcon>
+            <SearchIcon />
+          </template>
+        </t-input>
+        
+      </div>
+
+      <div class="sub-tab">
+        <span 
+          :class="['sub-item', item.chart_permission_id === selectSubType && 'sub-active']" 
+          v-for="item in subClassifyList" 
+          :key="item.chart_permission_id" 
+          @click="handleClickSubType(item)"
+        >{{ item.chart_permission_name }}</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" v-for="item in listState.list" :key="item.date" @click="handleGoReportDetail">
+              <div class="report-item-info">
+                <img src="" class="report-img">
+                <div class="right-info">
+                  <div class="c-stage flex">
+                    <div>
+                      <t-tag theme="primary">能源化工</t-tag>
+                      {{ '第五期' }}
+                      | {{ item.classify_name_first }}
+
+                    </div>
+                    
+                    <div class="c-time">{{ moment(item.publish_time).format('YYYY-MM-DD')  }}</div>
+                  </div>
+                  <div class="c-title">{{ item.title }}</div>
+                  <div class="desc" v-html="item.content_sub"></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.report_id"
+              @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;
+      }
+    }
+
+    .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 {
+
+    .report-list-wrap {
+      margin-top: 130px;
+
+      .content-item {
+        padding: 20px 0;
+        border-bottom: 1px solid #DCDFE6;
+        position: relative;
+
+        .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;
+          }
+        }
+      }
+    }
+
+  }
+  .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>

File diff suppressed because it is too large
+ 23 - 0
src/views/report/Detail.vue


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

@@ -0,0 +1,450 @@
+<script setup>
+import { reactive, ref } from 'vue'
+import { SearchIcon,InfoCircleFilledIcon } from 'tdesign-icons-vue-next';
+import { useRouter } from 'vue-router';
+import moment from 'moment'
+import 'moment/locale/zh-cn';
+moment.locale('zh-cn')
+import LoadList from '@/components/LoadList.vue'
+
+const router = useRouter()
+
+const searchText = ref('')
+
+const listState = reactive({
+  list: [
+    {
+      "date": "2025-05-19",
+      "sub_list": [
+        {
+          "report_id": 9716,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T16:21:51+08:00",
+          "title": "智能布局pdf生成",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">给他人工我跟热范围废物费恶无非王菲菲</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        },
+        {
+          "report_id": 9714,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T14:29:44+08:00",
+          "title": "测试报告常规布局",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">带我去多无群大青蛙单位群单位</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        }
+      ]
+    },
+    {
+      "date": "2025-05-19",
+      "sub_list": [
+        {
+          "report_id": 9716,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T16:21:51+08:00",
+          "title": "智能布局pdf生成",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">给他人工我跟热范围废物费恶无非王菲菲</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        },
+        {
+          "report_id": 9714,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T14:29:44+08:00",
+          "title": "测试报告常规布局",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">带我去多无群大青蛙单位群单位</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        }
+      ]
+    },
+    {
+      "date": "2025-05-19",
+      "sub_list": [
+        {
+          "report_id": 9716,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T16:21:51+08:00",
+          "title": "智能布局pdf生成",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">给他人工我跟热范围废物费恶无非王菲菲</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        },
+        {
+          "report_id": 9714,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T14:29:44+08:00",
+          "title": "测试报告常规布局",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">带我去多无群大青蛙单位群单位</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        }
+      ]
+    },
+    {
+      "date": "2025-05-19",
+      "sub_list": [
+        {
+          "report_id": 9716,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T16:21:51+08:00",
+          "title": "智能布局pdf生成",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">给他人工我跟热范围废物费恶无非王菲菲</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        },
+        {
+          "report_id": 9714,
+          "report_chapter_id": 0,
+          "classify_id_first": 749,
+          "classify_name_first": "自由报告",
+          "classify_id_second": 0,
+          "classify_name_second": "",
+          "report_chapter_type_id": 0,
+          "publish_time": "2025-05-19T14:29:44+08:00",
+          "title": "测试报告常规布局",
+          "content_sub": "<div style=\"-webkit-line-clamp: 3;-webkit-box-orient: vertical;display: -webkit-box;overflow: hidden;text-overflow: ellipsis;\">带我去多无群大青蛙单位群单位</div>",
+          "classify_id": 0,
+          "classify_name": "",
+          "report_detail_show_type": 0,
+          "rai_report_id": 0
+        }
+      ]
+    },
+  ],
+  page: 1,
+  pageSize: 20,
+  finished: true,
+  loading: false,
+  dateArr: [],//日期数据
+})
+
+function getList() {
+  
+}
+
+// 格式化列表日期
+const formatDate=(e)=>{
+  const isSameYear=moment(e).isSame(new Date(), 'year');
+  if(isSameYear){//今年
+    return moment(e).format('MM.DD')+' '+ moment(e).format('ddd')
+  }else{
+    return moment(e).format('YY.MM.DD')+' '+moment(e).format('ddd')
+  }
+}
+
+const latestNewsList = ref([
+  { img: '' },
+  { img: '' },
+  { img: '' },
+])
+
+
+function handleClickClassify() {
+  router.push('/etaReport/classify')
+}
+
+function handleToDetail() {
+  router.push(`/etaReport/detail?id=`)
+}
+
+
+// 实现头部的适配
+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">显示全部 </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"
+        >
+          <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" v-for="item in listState.list" :key="item.date">
+            <div class="item-time">{{ formatDate(item.date) }}</div>
+            <div class="content-list">
+              <div class="content-item" v-for="citem in item.sub_list" :key="citem.report_id" @click="handleToDetail">
+                <div class="c-time">{{ moment(citem.publish_time).format('HH:mm:ss')  }}</div>
+                <div class="report-item-info">
+                  <img src="" class="report-img">
+                  <div>
+                    <div class="c-stage">
+                      <t-tag theme="primary">能源化工</t-tag>
+                      {{ '第五期' }}
+                      | {{ citem.classify_name_first }}
+                    </div>
+                    <div class="c-title">{{ citem.title }}</div>
+                    <div class="desc" v-html="citem.content_sub"></div>
+                  </div>
+                </div>
+              </div>
+            </div>
+          </div>
+        </div>
+      </LoadList>
+
+    </div>
+    <div class="right-aside-box">
+      <div class="fix-top" style="z-index: 100;">
+        <div class="recommand-banner" :style="`background-image: url(${''})`"></div>
+        <div class="recmd-box">
+          <div class="label">行业板块</div>
+          <div class="flex recmd-wrap">
+
+            <div 
+              class="recmd-item" 
+              v-for="item in latestNewsList" 
+              :key="item.report_id"
+              @click="handleClickClassify(item)"
+              :style="`background-imgage: url('')`"
+            >
+              <!-- <div class="title">{{item.classify_name_second}}</div> -->
+            </div>
+          </div>
+        </div>
+
+      </div>
+    </div>
+
+
+  </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;
+      }
+    }
+
+    .report-list-wrap {
+      margin-top: 100px;
+      .item{
+        margin-left: 4px;
+        border-left: 1px solid #DAE0FE;
+        position: relative;
+        padding: 0 20px 30px;
+
+        &::before {
+          content: '';
+          display: block;
+          box-sizing: border-box;
+          width: 10px;
+          height: 10px;
+          border-radius: 50%;
+          position: absolute;
+          left: 0;
+          top: 5px;
+          background: #4B64F7;
+          transform: translate(-50%, -50%);
+          z-index: 2;
+        }
+      }
+      .item-time {
+        padding: 10px;
+        background: #F5F7F9;
+        color: #3D5EFF;
+        font-size: 16px;
+        margin-bottom: 20px;
+        font-weight: bold;
+      }
+
+      .content-list {
+
+        .content-item {
+          padding: 20px 0;
+          border-bottom: 1px solid #DCDFE6;
+          position: relative;
+
+          .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;
+            }
+          }
+        }
+      }
+    }
+
+  }
+  .right-aside-box{
+    .recommand-banner {
+      width: 420px;
+      height: 240px;
+      margin-bottom: 15px;
+    }
+
+    .recmd-box {
+      .label {
+        font-size: 24px;
+      }
+
+      .recmd-wrap {
+        flex-wrap: wrap;
+        gap: 10px;
+        justify-content: space-around;
+        .recmd-item {
+          width: 120px;
+          height: 120px;
+          background: #3D5EFF;
+        }
+      }
+    }
+  }
+}
+</style>

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

@@ -0,0 +1,10 @@
+<script setup>
+import { ref } from 'vue'
+
+</script>
+<template>
+  <div></div>
+</template>
+<style scoped lang="scss">
+
+</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>

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

@@ -0,0 +1,46 @@
+<script setup>
+import { ref } from 'vue'
+const props = defineProps({
+  list: Array,
+  chapterId: Number
+})
+
+
+</script>
+<template>
+  <div class="chapter-list-wrap">
+    <div :class="['item',{act: item.chapterId===chapterId}]" v-for="item in list" :key="item">
+      <div class="chapter-title text-ellipsis--l1" v-text="item.title"></div>
+      <p class="text-ellipsis--l1" v-text="item.report_chapter_type_name"></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;
+    }
+  }
+}
+</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>

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

@@ -0,0 +1,69 @@
+<script setup>
+import {reactive,watch,onMounted,onUnmounted, nextTick} from 'vue'
+import { useThrottleFn } from '@vueuse/core'
+
+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=useThrottleFn(()=>{
+    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);
+    const bufferHeight = 600;
+
+    if((scrollHeight - scrollTop - clientHeight) < bufferHeight) {
+        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>

+ 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;
+      }
+    }
+  }
+}

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

@@ -0,0 +1,19 @@
+<script setup>
+import { ref } from 'vue'
+import Aslide from './components/Aslide.vue'
+import etaReport from './etaReport.vue'
+import etaChart from './etaChart.vue'
+
+</script>
+<template>
+  <div>
+    <Aslide/>
+
+    <etaChart/>
+
+    <!-- <etaReport/> -->
+  </div>
+</template>
+<style scoped lang="scss">
+
+</style>

+ 10 - 0
src/views/user/favorite/components/Aslide.vue

@@ -0,0 +1,10 @@
+<script setup>
+import { ref } from 'vue'
+
+</script>
+<template>
+  <div></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"

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

@@ -0,0 +1,127 @@
+<script setup>
+import { apiETAChartUser } from '@/api/etaChart'
+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 apiETAChartUser.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 apiETAChartUser.chartCollect({
+    ChartInfoId:props.data.ChartInfoId,
+    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" 
+    @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>

+ 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"

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

@@ -0,0 +1,269 @@
+<script setup>
+import { apiETAChartUser } from '@/api/etaChart'
+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: 'ChartName',
+    title: '图表名称',
+    align: 'center'
+  },
+  {
+    colKey: 'SysUserRealName',
+    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 apiETAChartUser.chartCollectList({
+    CollectClassifyIds: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
+  if(checkAll.value){
+    tableData.value.forEach(item=>{
+      if(!selectedRowKeys.value.includes(item.ChartInfoId)&&!unSelectedCharts.includes(item.ChartInfoId)){
+        selectedRowKeys.value.push(item.ChartInfoId)
+      }
+    })
+  }
+}
+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.ChartInfoId));
+  
+  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.ChartInfoId)){
+        selectedRowKeys.value.push(item.ChartInfoId)
+      }
+    })
+  }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
+  }
+  const params={
+    SelectAll:checkAll.value,
+    CollectClassifyIds:selectClassify.value?selectClassify.value.join(','):'',
+    Keyword:keyword.value,
+    ChartInfoIds:checkAll.value?unSelectedCharts.join(',') : selectedRowKeys.value.join(','),
+    CollectClassifyId:newClassify.value,
+  }
+  const res=await apiETAChartUser.chartCollectBatchMove(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="ChartInfoId"
+      :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>

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

@@ -0,0 +1,190 @@
+<script setup>
+import {apiETAChartUser} from '@/api/etaChart'
+import { useTemplateRef, watch } from "vue";
+import EditClassify from './EditClassify.vue'
+import MoveChart from './MoveChart.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 apiETAChartUser.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 apiETAChartUser.moveClassify(params)
+  if(res.Ret!==200){
+    getClassifyList()
+    return
+  }
+  MessagePlugin.success("移动成功")
+}
+
+
+// 删除分类
+async function handleDeleteClassify(node){
+  await $confirmDialog({
+    header:'提示',
+    body: '若删除该分类,则分类下关联的所有图表将被全部删除,是否继续?',
+  });
+  const res=await apiETAChartUser.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 showMoveChart=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="showMoveChart=true">
+        <t-icon name="swap" size="20px"></t-icon>
+        <span>转移分类</span>
+      </div>
+    </div>
+  </div>
+  <!-- 新增\编辑分类 -->
+  <EditClassify 
+    v-model:show="showEditClassify" 
+    :data="activeClassifyData" 
+    @change="getClassifyList"
+  />
+  <!-- 转移分类 -->
+  <MoveChart v-model:show="showMoveChart" :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>

+ 2 - 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">

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

@@ -0,0 +1,192 @@
+<script setup>
+import {SearchIcon} from 'tdesign-icons-vue-next'
+import ClassifyWrap from './components/ReportClassifyWrap.vue'
+import {apiETAChartUser} from '@/api/etaChart'
+import { MessagePlugin } from 'tdesign-vue-next'
+
+const classifyId=ref(0)
+// const SysUserIds=ref([])
+const keyword=ref('')
+const chartList=ref([])
+const page=ref(1)
+const pageSize=ref(30)
+const total=ref(0)
+const finished=ref(false)
+async function getReportList(){
+  const res=await apiETAChartUser.chartCollectList({
+    CollectClassifyIds:classifyId.value||'',
+    // SysUserIds:SysUserIds.value.join(','),
+    Keyword:keyword.value,
+    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
+  chartList.value=arr
+}
+
+function onPageChange(e){
+  page.value=e
+  getChartList()
+}
+function refreshList(){
+  page.value=1
+  chartList.value=[]
+  getChartList()
+}
+function handleClassifyChange(e){
+  refreshList()
+}
+
+// 搜索
+function handleSearch(){
+  classifyId.value=0
+  refreshList()
+}
+
+
+const showChartDetailPop=ref(false)
+const activeChartInfo=ref(null)
+function handleShowChartDetail(e){
+  activeChartInfo.value=e
+  showChartDetailPop.value=true
+}
+
+// 取消收藏
+async function handleCancelCollect(item,index){
+  const res=await apiETAChartUser.chartCollectCancel({
+    ChartInfoId:item.ChartInfoId
+  })
+  if(res.Ret!==200) return
+  MessagePlugin.success('取消成功')
+  chartList.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">
+        <!-- <select-chart-creator v-model="SysUserIds" @change="refreshList" style="width:300px" size="large" /> -->
+        <t-input 
+          v-model="keyword" 
+          placeholder="请输入图表名称" 
+          size="large" 
+          style="max-width:600px"
+          clearable
+          @change="handleSearch"
+        >
+          <template #prefixIcon>
+            <SearchIcon />
+          </template>
+        </t-input>
+      </div>
+      <empty-wrap v-if="finished&&chartList.length===0" />
+      <div class="chart-list-wrap" v-else>
+        <ul class="flex chart-list">
+          <li class="bg-white chart-item" v-for="item,index in chartList" :key="item.ChartCollectId">
+            <div class="flex chart-name">
+              <div class="text-ellipsis--l1 name">{{item.ChartName}}</div>
+              <svg-icon name="star_fill" style="font-size:20px;cursor: pointer;" @click="handleCancelCollect(item,index)"></svg-icon>
+              <svg-icon name="full_screen" style="font-size:20px;margin-left:20px;cursor: pointer;" @click="handleShowChartDetail(item)"></svg-icon>
+            </div>
+            <img class="chart-img" :src="item.ChartImage" alt="">
+            <div class="time">
+              <span>收藏时间:</span>
+              <span>{{formatTime(item.CollectTime,'YYYY-MM-DD HH:mm:ss')}}</span>
+            </div>
+          </li>
+        </ul>
+        <t-pagination
+          v-model="page"
+          :pageSize="pageSize"
+          :total="total"
+          :totalContent="true"
+          :showPageSize="false"
+          @current-change="onPageChange"
+          style="margin-top:20px"
+        />
+      </div>
+    </div>
+  </div>
+
+  <!-- 图表详情弹窗 -->
+  <ChartDetailPop v-model:show="showChartDetailPop" :chartInfoId="activeChartInfo?.ChartInfoId" @collectChange="refreshList()"/>
+</template>
+
+<style lang="scss" scoped>
+.my-favorite-chart-page{
+  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;
+    }
+    .chart-list-wrap{
+      flex: 1;
+      overflow-y: auto;
+    }
+  }
+  .chart-list{
+    flex-wrap: wrap;
+    gap: 20px;
+    margin-block-start:0;
+    margin-block-end:0;
+    padding-inline-start:0;
+    .chart-item{
+      list-style-type: none;
+      width: calc(calc(100% - 60px)/4);
+      border: 1px solid var(--border-color);
+      box-shadow: 0px 4px 12px 0px #2300351F;
+      border-radius: 4px;
+      overflow: hidden;
+      .chart-name{
+        border-bottom: 1px solid var(--border-color);
+        padding: 14px;
+        .name{
+          font-size: 16px;
+          font-weight: 600;
+          flex: 1;
+        }
+      }
+      .chart-img{
+        display: block;
+        width: 100%;
+        height: 230px;
+      }
+      .time{
+        border-top: 1px solid var(--border-color);
+        padding: 10px 14px;
+        color: #999999;
+      }
+    }
+  }
+}
+</style>

Some files were not shown because too many files changed in this diff