Jelajahi Sumber

更新 disk 模块网盘相关的页面

reghao 1 bulan lalu
induk
melakukan
401396d340

+ 127 - 0
src/api/disk.js

@@ -0,0 +1,127 @@
+import { get, post } from '@/utils/request'
+
+const diskApi = {
+  createFolderApi: '/api/disk/folder/create',
+  renameFolderApi: '/api/disk/folder/rename',
+  getFolderTreeApi: '/api/disk/folder/tree',
+  addFileApi: '/api/disk/file/add',
+  deleteFileApi: '/api/disk/file/delete',
+  moveFileApi: '/api/disk/file/move',
+  diskFileApi: '/api/disk/file/list',
+  filePreviewApi: '/api/disk/file/preview',
+  createAlbumApi: '/api/disk/album/create',
+  editAlbumApi: '/api/disk/album/edit',
+  getAlbumKeyValueApi: '/api/disk/album/kv',
+  getAlbumListApi: '/api/disk/album/list',
+  getAlbumExcludeFilesApi: '/api/disk/album/exclude',
+  getAlbumDetailApi: '/api/disk/album/detail',
+  getAlbumApi: '/api/disk/album',
+  getCamApi: '/api/disk/cam',
+  createShareApi: '/api/disk/share/create',
+  deleteShareApi: '/api/disk/share/delete',
+  getShareListApi: '/api/disk/share/list',
+  getShareToListApi: '/api/disk/share/user_list',
+  submitUserActivityApi: '/api/disk/cam/activity',
+  getCamRecordByMonthAPi: '/api/disk/cam/record/month',
+  getRecordUrlAPi: '/api/disk/cam/record/url',
+  getDiskChannelInfoApi: '/api/file/oss/serverinfo/file'
+}
+
+export function createFolder(payload) {
+  return post(diskApi.createFolderApi, payload)
+}
+
+export function renameFolder(payload) {
+  return post(diskApi.renameFolderApi, payload)
+}
+
+export function getFolderTree() {
+  return get(diskApi.getFolderTreeApi)
+}
+
+export function addFile(payload) {
+  return post(diskApi.addFileApi, payload)
+}
+
+export function deleteFile(payload) {
+  return post(diskApi.deleteFileApi, payload)
+}
+
+export function moveFile(payload) {
+  return post(diskApi.moveFileApi, payload)
+}
+
+export function getDiskFile(query) {
+  return get(diskApi.diskFileApi, query)
+}
+
+export function getFileDetail(fileId) {
+  return get(diskApi.filePreviewApi + '?fileId=' + fileId)
+}
+
+export function getDiskChannelInfo() {
+  return post(diskApi.getDiskChannelInfoApi)
+}
+
+export function createAlbum(payload) {
+  return post(diskApi.createAlbumApi, payload)
+}
+
+export function editAlbum(payload) {
+  return post(diskApi.editAlbumApi, payload)
+}
+
+export function getAlbumKeyValues() {
+  return get(diskApi.getAlbumKeyValueApi)
+}
+
+export function getAlbumList(pn) {
+  return get(diskApi.getAlbumListApi + '?pn=' + pn)
+}
+
+export function getAlbumExcludeFiles(fileType, pn) {
+  return get(diskApi.getAlbumExcludeFilesApi + '?fileType=' + fileType + '&pn=' + pn)
+}
+
+export function getAlbumDetail(queryParams) {
+  return get(diskApi.getAlbumDetailApi, queryParams)
+}
+
+export function getPhotoItems(queryParams) {
+  return get(diskApi.getAlbumApi + '/items', queryParams)
+}
+
+export function createShare(payload) {
+  return post(diskApi.createShareApi, payload)
+}
+
+export function deleteShare(shareId) {
+  return post(diskApi.deleteShareApi + '/' + shareId)
+}
+
+export function getShareList(pn) {
+  return get(diskApi.getShareListApi + '?pn=' + pn)
+}
+
+export function getShareToList(shareId) {
+  return get(diskApi.getShareToListApi + '?shareId=' + shareId)
+}
+
+export function getCamList() {
+  return get(diskApi.getCamApi + '/list')
+}
+
+export function getCamDetail(query) {
+  return get(diskApi.getCamApi + '/detail', query)
+}
+
+export function getRecordByMonth(query) {
+  return get(diskApi.getCamRecordByMonthAPi, query)
+}
+export function getRecordUrl(recordId) {
+  return get(diskApi.getRecordUrlAPi + '/' + recordId)
+}
+
+export function submitActivity() {
+  return post(diskApi.submitUserActivityApi)
+}

+ 5 - 0
src/api/user.js

@@ -20,6 +20,7 @@ const userApi = {
   loginRecordApi: '/api/account/record/list',
   signOutApi: '/api/account/deactivate',
   updateAvatarApi: '/api/file/avatar/update',
+  userContactApi: '/api/user/contact/list',
   userInfoApi: '/api/user/info'
 }
 
@@ -59,3 +60,7 @@ export function logout() {
 export function getUserInfo(userId) {
   return get(userApi.userInfoApi + '?userId=' + userId)
 }
+
+export function getUserContact(pn) {
+  return get(userApi.userContactApi + '?pn=' + pn)
+}

+ 4 - 2
src/main.js

@@ -44,7 +44,8 @@ import {
   Empty,
   Switch,
   Swipe,
-  SwipeItem
+  SwipeItem,
+  Calendar
 } from 'vant'
 
 const components = [
@@ -81,7 +82,8 @@ const components = [
   Empty,
   Switch,
   Swipe,
-  SwipeItem
+  SwipeItem,
+  Calendar
 ]
 components.forEach((cmp) => Vue.use(cmp))
 

+ 16 - 3
src/router/index.js

@@ -95,10 +95,11 @@ const routes = [
     path: '/disk',
     name: 'DiskLayout',
     component: () => import('@/views/disk/DiskLayout.vue'),
+    redirect: '/disk/file',
     children: [
       {
-        path: '',
-        component: () => import('@/views/disk/DiskHome.vue'),
+        path: 'file',
+        component: () => import('@/views/disk/DiskFile.vue'),
         meta: {
           title: '文件',
           customHeader: false,
@@ -112,6 +113,18 @@ const routes = [
         component: () => import('@/views/disk/DiskPhoto.vue'),
         meta: { title: '相册', customHeader: true, loginRequired: true, role: 'tnb_disk' }
       },
+      {
+        path: 'cam',
+        name: 'Cam',
+        component: () => import('@/views/disk/CamList.vue'),
+        meta: { title: '摄像头列表', customHeader: true, loginRequired: true, role: 'tnb_disk' }
+      },
+      {
+        path: 'cam/detail',
+        name: 'CamDetail',
+        component: () => import('@/views/disk/CamDetail.vue'),
+        meta: { title: '摄像头详情', customHeader: true, loginRequired: true, role: 'tnb_disk' }
+      },
       {
         path: 'me',
         name: 'Me',
@@ -146,7 +159,7 @@ VueRouter.prototype.push = function push(location) {
   // 调用原始方法并捕获可能的错误
   return originalPush.call(this, location).catch((err) => {
     // 如果错误是 NavigationDuplicated,就忽略它
-    if (err.name !== 'NavigationDuplicated') {
+    if (err.name !== 'NavigationDuplicated' && !err.message.includes('Navigation cancelled')) {
       throw err
     }
   })

+ 364 - 0
src/views/disk/CamDetail.vue

@@ -0,0 +1,364 @@
+<template>
+  <div class="cam-detail-page">
+    <van-nav-bar
+      :title="camDetail ? camDetail.camName : '摄像头监控'"
+      left-arrow
+      fixed
+      placeholder
+      @click-left="$router.back()"
+    >
+      <template #right>
+        <van-icon name="share-o" size="18" @click="onShareCam" />
+      </template>
+    </van-nav-bar>
+
+    <div class="video-section">
+      <div class="video-container">
+        <video
+          id="videoElement"
+          controls
+          autoplay
+          muted
+          playsinline
+          webkit-playsinline
+          class="video-element"
+        />
+        <div class="video-status" v-if="camDetail">
+          <van-tag :type="camDetail.onLive ? 'danger' : 'primary'" shadow>
+            {{ camDetail.onLive ? '• LIVE 直播' : '回放中' }}
+          </van-tag>
+        </div>
+      </div>
+    </div>
+
+    <div class="action-bar">
+      <div class="current-date" @click="onSelectDate">
+        <van-icon name="clock-o" />
+        <span>{{ getYearMonthDay(calendarDate) }} 档案</span>
+        <van-icon name="arrow-down" size="12" />
+      </div>
+      <van-button size="small" type="warning" plain round @click="onButtonSubmit">
+        上报异常
+      </van-button>
+    </div>
+
+    <div class="record-list">
+      <van-cell-group title="历史录像列表">
+        <van-cell
+          v-for="item in dataList"
+          :key="item.recordId"
+          is-link
+          center
+          @click="handlePlay(item.recordId)"
+        >
+          <template #title>
+            <span class="time-text">{{ item.startTime }}</span>
+          </template>
+          <template #label>
+            <van-tag plain type="info">时长: {{ item.duration }}s</van-tag>
+          </template>
+          <template #right-icon>
+            <van-icon name="play-circle-o" size="24" color="#1989fa" />
+          </template>
+        </van-cell>
+      </van-cell-group>
+      <van-empty v-if="dataList.length === 0" description="该日期暂无录像" />
+    </div>
+
+    <van-popup v-model="showCalendar" position="bottom" round>
+      <van-calendar
+        v-model="showCalendar"
+        :show-confirm="false"
+        :first-day-of-week="1"
+        @confirm="handleDateConfirm"
+        :formatter="calendarFormatter"
+        :min-date="new Date(2023, 0, 1)"
+      />
+    </van-popup>
+
+    <van-action-sheet v-model="showShare" title="分享权限">
+      <div class="share-content">
+        <van-checkbox-group v-model="createShareForm.shareToList">
+          <van-cell-group>
+            <van-cell
+              v-for="user in userContactList"
+              :key="user.userIdStr"
+              :title="user.username"
+              clickable
+              @click="toggleUser(user.username)"
+            >
+              <template #right-icon>
+                <van-checkbox :name="user.username" ref="checkboxes" />
+              </template>
+            </van-cell>
+          </van-cell-group>
+        </van-checkbox-group>
+        <div class="share-btn">
+          <van-button type="info" block round @click="createShare">确认分享</van-button>
+        </div>
+      </div>
+    </van-action-sheet>
+  </div>
+</template>
+
+<script>
+import flvjs from 'flv.js'
+import {
+  getCamDetail,
+  getRecordByMonth,
+  getRecordUrl,
+  createShare,
+  submitActivity
+} from '@/api/disk'
+import { getUserContact } from '@/api/user'
+
+export default {
+  data() {
+    return {
+      camId: '',
+      camDetail: null,
+      calendarDate: new Date(),
+      dataList: [],
+      dateMap: new Map(), // 存储有录像的日期
+      showCalendar: false,
+      showShare: false,
+      flvPlayer: null,
+      userContactList: [],
+      createShareForm: { albumType: 1, albumId: null, shareToList: [] }
+    }
+  },
+  created() {
+    this.camId = this.$route.query.camId
+    if (this.camId) {
+      this.initData()
+    } else {
+      this.$toast.fail('参数错误')
+      this.$router.back()
+    }
+  },
+  beforeDestroy() {
+    this.destroyPlayer()
+  },
+  methods: {
+    async initData() {
+      try {
+        const resp = await getCamDetail({
+          camId: this.camId,
+          yearMonthDay: this.getYearMonthDay(this.calendarDate)
+        })
+        if (resp.code === 0) {
+          this.camDetail = resp.data
+          this.dataList = resp.data.dayRecords || []
+          this.initVideoPlayer(
+            this.camDetail.onLive ? this.camDetail.liveUrl : this.camDetail.url,
+            this.camDetail.onLive
+          )
+          this.fetchMonthRecords(this.calendarDate)
+        }
+      } catch (e) {
+        this.$toast.fail('数据获取失败')
+      }
+    },
+
+    // 播放器逻辑
+    initVideoPlayer(videoUrl, isLive) {
+      this.destroyPlayer()
+      const videoElement = document.getElementById('videoElement')
+
+      if (!isLive) {
+        videoElement.src = videoUrl
+        return
+      }
+
+      if (flvjs.isSupported()) {
+        this.flvPlayer = flvjs.createPlayer({
+          type: 'flv',
+          isLive: true,
+          url: videoUrl,
+          enableStashBuffer: false
+        })
+        this.flvPlayer.attachMediaElement(videoElement)
+        this.flvPlayer.on(flvjs.Events.ERROR, (errorType, errorDetail, errorInfo) => {
+          console.error('播放器异常类型:', errorType)
+          console.error('异常详情:', errorDetail)
+          console.error('异常信息:', errorInfo)
+
+          // 处理网络错误
+          if (errorType === flvjs.ErrorTypes.NETWORK_ERROR) {
+            this.$toast.fail('网络连接异常,尝试重连...')
+            this.handleReload() // 自定义重连逻辑
+          }
+          // 处理媒体错误(解码等)
+          else if (errorType === flvjs.ErrorTypes.MEDIA_ERROR) {
+            this.$toast.fail('媒体解析错误')
+            this.flvPlayer.reload() // flv.js 提供的尝试修复方法
+          } else {
+            this.$toast.fail('播放器发生未知错误')
+          }
+        })
+
+        this.flvPlayer.load()
+        this.flvPlayer.play().catch(() => {
+          console.log('需要用户交互才能播放')
+        })
+      }
+    },
+
+    destroyPlayer() {
+      if (this.flvPlayer) {
+        this.flvPlayer.pause()
+        this.flvPlayer.unload()
+        this.flvPlayer.detachMediaElement()
+        this.flvPlayer.destroy()
+        this.flvPlayer = null
+      }
+    },
+
+    // 日期处理
+    getYearMonthDay(date) {
+      return date.toISOString().split('T')[0]
+    },
+
+    onSelectDate() {
+      this.showCalendar = true
+    },
+
+    async handleDateConfirm(date) {
+      this.calendarDate = date
+      this.showCalendar = false
+      this.initData()
+    },
+
+    // 标记有录像的日期 (Vant 日历格式化)
+    calendarFormatter(day) {
+      const dateStr = this.getYearMonthDay(day.date)
+      if (this.dateMap.has(dateStr)) {
+        day.bottomInfo = '有录像'
+        day.dot = true
+      }
+      return day
+    },
+
+    async fetchMonthRecords(date) {
+      const yearMonth = date.toISOString().slice(0, 7)
+      const resp = await getRecordByMonth({ camId: this.camId, yearMonth })
+      if (resp.code === 0) {
+        resp.data.forEach((d) => this.dateMap.set(d, true))
+      }
+    },
+
+    async handlePlay(recordId) {
+      const resp = await getRecordUrl(recordId)
+      if (resp.code === 0) {
+        this.initVideoPlayer(resp.data.url, false)
+        this.$toast('切换至回放')
+      }
+    },
+
+    // 分享与提交
+    async onShareCam() {
+      const resp = await getUserContact(1)
+      if (resp.code === 0) {
+        this.userContactList = resp.data
+        this.showShare = true
+      }
+    },
+
+    toggleUser(username) {
+      const index = this.createShareForm.shareToList.indexOf(username)
+      if (index > -1) {
+        this.createShareForm.shareToList.splice(index, 1)
+      } else {
+        this.createShareForm.shareToList.push(username)
+      }
+    },
+
+    async createShare() {
+      this.createShareForm.albumId = this.camId
+      await createShare(this.createShareForm)
+      this.$toast.success('分享成功')
+      this.showShare = false
+    },
+
+    onButtonSubmit() {
+      this.$dialog
+        .confirm({ title: '上报异常', message: '确认向物业/保安上报当前异常画面?' })
+        .then(async () => {
+          await submitActivity()
+          this.$toast.success('上报成功')
+        })
+    },
+
+    handleReload() {
+      console.log('handleReload')
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.cam-detail-page {
+  background: #f7f8fa;
+  min-height: 100vh;
+
+  .video-section {
+    background: #000;
+    width: 100%;
+
+    .video-container {
+      position: relative;
+      width: 100%;
+      padding-top: 56.25%; /* 16:9 */
+
+      .video-element {
+        position: absolute;
+        top: 0;
+        left: 0;
+        width: 100%;
+        height: 100%;
+      }
+
+      .video-status {
+        position: absolute;
+        top: 10px;
+        left: 10px;
+        pointer-events: none;
+      }
+    }
+  }
+
+  .action-bar {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 12px 16px;
+    background: #fff;
+    margin-bottom: 10px;
+
+    .current-date {
+      display: flex;
+      align-items: center;
+      gap: 5px;
+      font-size: 14px;
+      color: #323233;
+      font-weight: 500;
+    }
+  }
+
+  .record-list {
+    .time-text {
+      font-family: 'Courier New', Courier, monospace;
+      font-weight: bold;
+      color: #323233;
+    }
+  }
+
+  .share-content {
+    padding: 16px;
+    .share-btn {
+      margin-top: 20px;
+      padding-bottom: 20px;
+    }
+  }
+}
+</style>

+ 177 - 0
src/views/disk/CamList.vue

@@ -0,0 +1,177 @@
+<template>
+  <div class="cam-list-page">
+    <van-nav-bar title="摄像头列表" fixed placeholder />
+
+    <van-pull-refresh v-model="refreshing" @refresh="onRefresh">
+      <div class="cam-container">
+        <div v-for="item in dataList" :key="item.id" class="cam-card" @click="handlePlay(item)">
+          <div class="cam-preview">
+            <van-icon name="video" class="cam-icon" />
+            <van-tag :type="item.state ? 'success' : 'danger'" class="state-tag" round>
+              {{ item.state ? '在线' : '离线' }}
+            </van-tag>
+          </div>
+
+          <div class="cam-content">
+            <div class="cam-header">
+              <span class="cam-name">{{ item.camName }}</span>
+              <span class="cam-id">ID: {{ item.camId }}</span>
+            </div>
+
+            <div class="cam-footer">
+              <span class="cam-time">添加时间: {{ item.addAt }}</span>
+              <div class="actions">
+                <van-button size="small" type="info" plain round @click.stop="handlePlay(item)"
+                  >查看</van-button
+                >
+                <van-button
+                  size="small"
+                  type="default"
+                  plain
+                  round
+                  @click.stop="handleSettings(item)"
+                  >设置</van-button
+                >
+              </div>
+            </div>
+          </div>
+        </div>
+
+        <van-empty v-if="dataList.length === 0 && !loading" description="暂无摄像头数据" />
+      </div>
+    </van-pull-refresh>
+  </div>
+</template>
+
+<script>
+import { getCamList } from '@/api/disk'
+
+export default {
+  name: 'CamList',
+  data() {
+    return {
+      dataList: [],
+      loading: false,
+      refreshing: false
+    }
+  },
+  created() {
+    this.getData()
+  },
+  methods: {
+    async getData() {
+      this.loading = true
+      try {
+        // 如果后端还没写好,可以用下面的模拟数据
+        const resp = await getCamList()
+        if (resp.code === 0) {
+          this.dataList = resp.data
+        } else {
+          this.$toast(resp.msg || '获取失败')
+        }
+      } catch (err) {
+      } finally {
+        this.loading = false
+        this.refreshing = false
+      }
+    },
+    onRefresh() {
+      this.getData()
+    },
+    handlePlay(item) {
+      if (!item.state) {
+        this.$toast('该摄像头当前离线')
+        return
+      }
+      this.$router.push({
+        path: '/disk/cam/detail', // 对应你之前的路由结构
+        query: { camId: item.camId }
+      })
+    },
+    handleSettings(item) {
+      this.$toast('跳转至设置: ' + item.camName)
+    }
+  }
+}
+</script>
+
+<style lang="less" scoped>
+.cam-list-page {
+  background-color: #f7f8fa;
+  min-height: 100vh;
+
+  .cam-container {
+    padding: 12px;
+  }
+
+  .cam-card {
+    background: #fff;
+    border-radius: 12px;
+    margin-bottom: 16px;
+    overflow: hidden;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
+
+    &:active {
+      background: #f2f3f5;
+    }
+
+    .cam-preview {
+      height: 160px;
+      background-color: #2c3e50;
+      display: flex;
+      justify-content: center;
+      align-items: center;
+      position: relative;
+
+      .cam-icon {
+        font-size: 50px;
+        color: #5c6b7a;
+      }
+
+      .state-tag {
+        position: absolute;
+        top: 10px;
+        right: 10px;
+      }
+    }
+
+    .cam-content {
+      padding: 12px;
+
+      .cam-header {
+        display: flex;
+        justify-content: space-between;
+        align-items: center;
+        margin-bottom: 8px;
+
+        .cam-name {
+          font-size: 16px;
+          font-weight: bold;
+          color: #323233;
+        }
+        .cam-id {
+          font-size: 12px;
+          color: #969799;
+        }
+      }
+
+      .cam-footer {
+        display: flex;
+        flex-direction: column;
+        gap: 10px;
+
+        .cam-time {
+          font-size: 12px;
+          color: #c8c9cc;
+        }
+
+        .actions {
+          display: flex;
+          justify-content: flex-end;
+          gap: 8px;
+        }
+      }
+    }
+  }
+}
+</style>

+ 216 - 161
src/views/disk/DiskHome.vue → src/views/disk/DiskFile.vue

@@ -1,10 +1,16 @@
 <template>
   <div class="home-container">
     <div class="header-section">
+      <div class="breadcrumb-bar" v-if="$route.query.path && $route.query.path !== '/'">
+        <van-icon name="folder-o" />
+        <span class="path-text">{{ $route.query.path }}</span>
+        <span class="back-btn" @click="goBack">返回上一级</span>
+      </div>
+
       <van-nav-bar fixed placeholder z-index="10">
         <template #left>
           <van-icon
-            v-if="$route.query.folderId"
+            v-if="$route.query.path"
             name="arrow-left"
             size="20"
             @click="goBack"
@@ -18,7 +24,7 @@
         </template>
       </van-nav-bar>
 
-      <div class="breadcrumb-bar" v-if="$route.query.folderId">
+      <div class="breadcrumb-bar" v-if="$route.query.path">
         <span class="path-text">当前目录: {{ currentFolderName }}</span>
       </div>
     </div>
@@ -33,19 +39,19 @@
       >
         <div
           v-for="item in fileList"
-          :key="item.id"
+          :key="item.fileId"
           class="file-item-card"
           @click="handleItemClick(item)"
         >
           <div class="file-icon">
             <van-icon
-              :name="getFileIcon(item.type)"
-              :style="{ color: getIconColor(item.type) }"
+              :name="getFileIcon(item.fileTypeStr)"
+              :style="{ color: getIconColor(item.fileTypeStr) }"
               class="icon-size-custom"
             />
           </div>
           <div class="file-info van-hairline--bottom">
-            <div class="file-name van-ellipsis">{{ item.name }}</div>
+            <div class="file-name van-ellipsis">{{ item.filename }}</div>
             <div class="file-meta">{{ item.updateTime || '2024-05-09' }} · {{ item.size }}</div>
           </div>
           <div class="file-action" @click.stop="showActionSheet(item)">
@@ -87,10 +93,31 @@
       accept="*"
     />
 
-    <van-popup v-model="showPlayerModal" position="bottom" :style="{ height: '100%' }" closeable>
+    <van-popup
+      v-model="showPlayerModal"
+      position="bottom"
+      :style="{ height: '100%' }"
+      closeable
+      @closed="handleVideoClose"
+    >
       <div v-if="currentPlayItem !== null" class="player-container">
-        <div class="player-header">{{ currentPlayItem.name }}</div>
-        <div id="dplayer" style="height: 300px"></div>
+        <div class="player-header van-ellipsis">{{ currentPlayItem.filename }}</div>
+
+        <div class="video-wrapper">
+          <video
+            ref="videoPlayer"
+            :src="currentPlayItem.url"
+            controls
+            autoplay
+            playsinline
+            webkit-playsinline
+            x5-video-player-type="h5-page"
+            class="native-video"
+          >
+            您的浏览器不支持视频播放。
+          </video>
+        </div>
+
         <div class="player-info">
           <van-cell title="文件大小" :value="currentPlayItem.size" />
           <van-cell title="修改时间" :value="currentPlayItem.updateTime" />
@@ -101,12 +128,17 @@
 </template>
 
 <script>
-import DPlayer from 'dplayer'
 import { ImagePreview } from 'vant'
+import { getDiskFile, getFileDetail } from '@/api/disk'
 
 export default {
   data() {
     return {
+      queryForm: {
+        pn: 1,
+        path: '/',
+        fileType: null
+      },
       fileList: [],
       loading: false,
       finished: false,
@@ -134,50 +166,69 @@ export default {
     }
   },
   watch: {
-    // 仅监听 folderId 的变化(从一个目录跳到另一个目录)
-    '$route.query.folderId'(newVal) {
-      this.resetAndReload()
+    // 仅监听 path 的变化(从一个目录跳到另一个目录)
+    '$route.query.path': {
+      handler(newVal) {
+        this.queryForm.path = newVal || '/'
+        this.resetAndReload()
+      }
     }
   },
-  mounted() {
-    // 确保初次进入页面时执行加载
-    this.onLoad()
-  },
   methods: {
     resetAndReload() {
       this.fileList = []
       this.pagination.page = 1
       this.finished = false
-      this.loading = true // 显示转圈
+      this.loading = true
       this.onLoad()
     },
 
     async onLoad() {
-      // 防止重复加载
       if (this.refreshing) return
-
+      const currentPath = this.$route.query.path || '/'
       try {
-        const folderId = this.$route.query.folderId || 'root'
-        const response = await this.fetchFilesFromServer(folderId, this.pagination)
-
-        // 如果是第一页,直接赋值;否则追加
-        if (this.pagination.page === 1) {
-          this.fileList = response.items
-        } else {
-          this.fileList.push(...response.items)
+        // 构造后端需要的参数
+        const params = {
+          path: currentPath,
+          pn: this.pagination.page // 注意:通常后端分页参数名为 pn
+          // fileType: this.queryForm.fileType
         }
 
-        this.loading = false
+        const resp = await getDiskFile(params)
+        if (resp.code === 0) {
+          const { pageList, namePathList } = resp.data
+
+          // 1. 处理文件列表
+          const items = pageList.list
+          if (this.pagination.page === 1) {
+            this.fileList = items
+          } else {
+            this.fileList.push(...items)
+          }
 
-        if (this.fileList.length >= response.total) {
+          // 2. 更新面包屑/标题 (假设 namePathList 是当前路径的拆解)
+          if (namePathList && namePathList.length > 0) {
+            this.currentFolderName = namePathList[namePathList.length - 1].filename
+          } else {
+            this.currentFolderName = '我的文件'
+          }
+
+          // 3. 判断是否加载完成
+          this.loading = false
           this.finished = true
+          if (this.fileList.length >= pageList.totalCount) {
+            this.finished = true
+          } else {
+            this.pagination.page++
+          }
         } else {
-          this.pagination.page++
+          this.$toast(resp.msg)
+          this.finished = true
         }
       } catch (error) {
         this.loading = false
         this.finished = true
-        this.$toast('数据加载失败')
+        this.$toast('加载失败')
       }
     },
     // 获取图标名称 (对应 Vant 的内置图标名)
@@ -218,38 +269,6 @@ export default {
       // 触发加载逻辑
       this.onLoad()
     },
-    async onLoad() {
-      try {
-        // 如果是在下拉刷新,则需要清空旧数据
-        if (this.refreshing) {
-          this.fileList = []
-          this.refreshing = false
-        }
-
-        const folderId = this.$route.query.folderId || 'root'
-
-        // 模拟 API 请求
-        const response = await this.fetchFilesFromServer(folderId, this.pagination)
-
-        // 将新获取的数据追加到当前列表
-        this.fileList.push(...response.items)
-
-        // 加载状态结束
-        this.loading = false
-
-        // 判断是否已经全部加载完
-        if (this.fileList.length >= this.totalCount) {
-          this.finished = true
-        } else {
-          // 如果还没加载完,页码 +1,为下次滚动做准备
-          this.pagination.page++
-        }
-      } catch (error) {
-        this.loading = false
-        this.finished = true
-        this.$toast('数据加载失败')
-      }
-    },
     onUploadSelect(action) {
       this.showUploadSheet = false // 先关闭菜单
 
@@ -282,7 +301,7 @@ export default {
         // 构建 FormData (后端通用格式)
         const formData = new FormData()
         formData.append('file', item.file)
-        formData.append('folderId', this.$route.query.folderId || 'root')
+        formData.append('folderId', this.$route.query.path || 'root')
 
         try {
           // 这里替换为你真实的 Axios 请求
@@ -330,44 +349,7 @@ export default {
           }
         })
     },
-    fetchFilesFromServer(folderId, params) {
-      return new Promise((resolve) => {
-        setTimeout(() => {
-          const mockNames = [
-            { name: '复仇者联盟4.mp4', type: 'video', size: '2.4GB' },
-            { name: '2024工作计划.mp4', type: 'audio', size: '1.2MB' },
-            { name: '项目源码_最终版.zip', type: 'file', size: '45.8MB' },
-            { name: '度假照片合集', type: 'folder', size: '-' },
-            { name: 'Vue3入门教程.jpg', type: 'image', size: '890MB' },
-            { name: '账单明细.text', type: 'text', size: '156KB' },
-            { name: '壁纸图库', type: 'folder', size: '-' },
-            { name: '系统补丁.exe', type: 'file', size: '12MB' }
-          ]
-
-          const items = Array.from({ length: params.limit }).map((_, index) => {
-            // 随机取一个模板
-            const template = mockNames[Math.floor(Math.random() * mockNames.length)]
-            // 加上唯一 ID 防止 Key 冲突
-            const id = `${folderId}-${params.page}-${index}`
-
-            return {
-              id: id,
-              name: params.page === 1 && index < 2 ? `演示-${template.name}` : template.name,
-              type: template.type,
-              size: template.size,
-              updateTime: '2024-05-09 14:20'
-            }
-          })
-
-          resolve({
-            items: items,
-            total: 60 // 模拟总共有 60 条数据
-          })
-        }, 1000) // 模拟 1 秒网络延迟
-      })
-    },
 
-    // 补全缺失的 handleCreateFolder
     handleCreateFolder() {
       this.$dialog
         .confirm({
@@ -383,91 +365,113 @@ export default {
         })
     },
 
-    // 补全 goBack
     goBack() {
-      this.$router.back()
+      const currentPath = this.$route.query.path || '/'
+      if (currentPath === '/') return
+
+      // 找到最后一个斜杠的位置
+      // 例如:/home/movies -> /home
+      // 例如:/home -> /
+      const pathArray = currentPath.split('/').filter((p) => p !== '')
+      pathArray.pop() // 移除最后一项
+      const parentPath = '/' + pathArray.join('/')
+
+      this.$router.push({
+        path: '/disk/file',
+        query: { path: parentPath }
+      })
     },
 
     handleItemClick(item) {
-      if (item.type === 'folder') {
-        this.$router
-          .push({
-            path: '/disk',
-            query: { folderId: item.id }
-          })
-          .catch(() => {})
+      if (item.fileTypeStr === 'folder' || item.folder) {
+        // 兼容后端字段
+        // 获取当前路径并确保结尾有斜杠
+        const currentPath = this.$route.query.path || '/'
+        const separator = currentPath.endsWith('/') ? '' : '/'
+
+        // 拼接成:/old-path/new-folder
+        const newPath = `${currentPath}${separator}${item.filename}`
+
+        this.$router.push({
+          path: '/disk/file',
+          query: { path: newPath }
+        })
       } else {
-        // 根据类型进行预览
-        switch (item.type) {
-          case 'image':
-            this.previewImage(item)
-            break
-          case 'video':
-            this.previewVideo(item)
-            break
-          case 'audio':
-            this.previewAudio(item)
-            break
-          case 'text':
-            this.previewText(item)
-            break
-          default:
-            this.$toast('该文件类型暂不支持预览')
-        }
+        getFileDetail(item.fileId).then((resp) => {
+          if (resp.code === 0) {
+            /* this.fileDetail = resp.data
+            if (this.fileTypeStr === 'video') {
+              this.videoProp = {
+                videoUrl: this.fileDetail.url
+              }
+            }*/
+            var fileDetail = resp.data
+            // 根据类型进行预览
+            switch (item.fileTypeStr) {
+              case 'image':
+                this.previewImage(fileDetail)
+                break
+              case 'video':
+                this.previewVideo(fileDetail)
+                break
+              case 'audio':
+                this.previewAudio(fileDetail)
+                break
+              case 'text':
+                this.previewText(item)
+                break
+              default:
+                this.$toast('该文件类型暂不支持预览')
+            }
+          } else {
+            this.$toast(resp.msg)
+          }
+        })
       }
     },
-    // --- 1. 图片预览 ---
+
     previewImage(item) {
+      console.log(item.fileId)
       ImagePreview({
-        images: [
-          // 实际开发中这里是后端返回的真实 URL
-          'https://img01.yzcdn.cn/vant/apple-1.jpg'
-        ],
+        images: [item.url],
         startPosition: 0,
         closeable: true
       })
     },
 
-    // --- 2. 视频预览 ---
     previewVideo(item) {
-      // 弹出全屏弹窗显示播放器
-      this.showPlayerModal = true
       this.currentPlayItem = item
-
-      // 等 DOM 渲染后初始化播放器
-      this.$nextTick(() => {
-        const dp = new DPlayer({
-          container: document.getElementById('dplayer'),
-          video: {
-            url: 'https://api.dogecloud.com/player/get.mp4?vcode=5ac682e6f823a691&userId=17&ext=.mp4', // 模拟地址
-            thumbnails: '' // 视频缩略图
-          }
-        })
-        dp.play()
-      })
+      this.showPlayerModal = true
+      // 原生 video 标签通过 :src 绑定会自动加载
+    },
+    handleVideoClose() {
+      // 弹窗关闭时,务必暂停视频,否则后台会有声音
+      if (this.$refs.videoPlayer) {
+        this.$refs.videoPlayer.pause()
+      }
+      this.currentPlayItem = null
     },
-
-    // --- 3. 音频预览 ---
     previewAudio(item) {
-      // 音频可以直接用 Vant 的 Dialog 配合原生 audio 标签
+      const audioSrc = item.url || ''
+      // 映射 MIME 类型,如果是 'audio' 字符串通常需要转为 'audio/mpeg' 或 'audio/wav'
+      const mimeType = item.fileTypeStr === 'audio' ? 'audio/mpeg' : `audio/wav`
       this.$dialog.alert({
-        title: item.name,
+        title: item.filename,
         message: `
-        <div style="padding: 20px 0;">
-          <audio controls autoplay style="width: 100%;">
-            <source src="http://music.163.com/song/media/outer/url?id=1407551413.mp3" type="audio/mpeg">
-          </audio>
-        </div>
-      `,
+      <div style="padding: 20px 0;">
+        <audio controls autoplay style="width: 100%;">
+          <source src="${audioSrc}" type="${mimeType}">
+          您的浏览器不支持音频播放。
+        </audio>
+      </div>
+    `,
         allowHtml: true
       })
     },
-
-    // --- 4. 文本预览 ---
     previewText(item) {
       // 简单文本直接弹出展示,复杂文档(PDF/Word)建议通过 window.open 或专门的预览服务
       this.$dialog.alert({
-        title: item.name,
+        title: item.filename,
         message: '这是模拟的文本内容:\n\nPikPak 是一款非常棒的网盘...',
         messageAlign: 'left'
       })
@@ -671,4 +675,55 @@ export default {
     }
   }
 }
+
+.breadcrumb-bar {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 8px 16px;
+  background: #fdfdfd;
+  border-bottom: 1px solid #ebedf0;
+  .path-text {
+    flex: 1;
+    font-size: 12px;
+    color: #969799;
+    margin-left: 5px;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+  .back-btn {
+    font-size: 12px;
+    color: #1989fa;
+    margin-left: 10px;
+  }
+}
+
+.player-container {
+  background: #000;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
+
+.video-wrapper {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #000;
+}
+
+.native-video {
+  width: 100%;
+  max-height: 70vh; // 保证不遮挡底部的详情信息
+  object-fit: contain;
+}
+
+.player-header {
+  padding: 15px 40px; // 避开关闭按钮
+  text-align: center;
+  font-size: 16px;
+  color: #fff;
+}
 </style>

+ 2 - 1
src/views/disk/DiskLayout.vue

@@ -19,8 +19,9 @@
     </div>
 
     <van-tabbar v-model="active" route active-color="#1989fa" placeholder>
-      <van-tabbar-item replace to="/disk" icon="description">文件</van-tabbar-item>
+      <van-tabbar-item replace to="/disk/file" icon="description">文件</van-tabbar-item>
       <van-tabbar-item replace to="/disk/photo" icon="photo-o">相册</van-tabbar-item>
+      <van-tabbar-item replace to="/disk/cam" icon="video">监控</van-tabbar-item>
       <van-tabbar-item replace to="/disk/me" icon="contact">我的</van-tabbar-item>
     </van-tabbar>
   </div>

+ 11 - 9
src/views/disk/DiskMe.vue

@@ -1,19 +1,13 @@
 <template>
   <div class="me-container">
     <div class="user-card">
-      <van-image
-        round
-        width="64"
-        height="64"
-        src="https://img01.yzcdn.cn/vant/cat.jpeg"
-        class="avatar"
-      />
+      <van-image round width="64" height="64" :src="accountInfo.avatarUrl" class="avatar" />
       <div class="user-info">
         <div class="username">
-          PikPak用户_7829
+          {{ accountInfo.screenName }}
           <van-tag color="#ffe166" text-color="#323233" round>黄金VIP</van-tag>
         </div>
-        <div class="user-id">ID: 102938475</div>
+        <div class="user-id">{{ accountInfo.userId }}</div>
       </div>
       <van-icon name="arrow" color="#969799" />
     </div>
@@ -54,7 +48,12 @@
 </template>
 
 <script>
+import { mapState } from 'vuex'
+
 export default {
+  computed: {
+    ...mapState(['accountInfo'])
+  },
   data() {
     return {
       usedSpaceGB: 120,
@@ -64,6 +63,9 @@ export default {
       photoPercent: 15,
       otherPercent: 10
     }
+  },
+  created() {
+    console.log(this.accountInfo)
   }
 }
 </script>

+ 151 - 58
src/views/disk/DiskPhoto.vue

@@ -19,7 +19,11 @@
           </div>
 
           <van-grid :column-num="3" :gutter="2" :border="false" square>
-            <van-grid-item v-for="item in group.children" :key="item.id" @click="onItemClick(item)">
+            <van-grid-item
+              v-for="item in group.children"
+              :key="item.fileId"
+              @click="onItemClick(item)"
+            >
               <van-image width="100%" height="100%" fit="cover" lazy-load :src="item.url" />
 
               <div v-if="item.type === 'video'" class="video-info-tag">
@@ -30,9 +34,9 @@
               <div
                 v-if="isEditMode"
                 class="select-mask"
-                :class="{ active: selectedIds.includes(item.id) }"
+                :class="{ active: selectedIds.includes(item.fileId) }"
               >
-                <van-icon :name="selectedIds.includes(item.id) ? 'checked' : 'circle'" />
+                <van-icon :name="selectedIds.includes(item.fileId) ? 'checked' : 'circle'" />
               </div>
             </van-grid-item>
           </van-grid>
@@ -52,17 +56,55 @@
         </div>
       </div>
     </transition>
+
+    <van-popup
+      v-model="showPlayerModal"
+      position="bottom"
+      :style="{ height: '100%' }"
+      closeable
+      @closed="onVideoPopupClosed"
+    >
+      <div v-if="currentVideoItem" class="player-container">
+        <div class="player-header van-ellipsis">{{ currentVideoItem.filename }}</div>
+
+        <div class="video-wrapper">
+          <video
+            ref="videoPlayer"
+            :src="currentVideoItem.videoUrl"
+            controls
+            autoplay
+            playsinline
+            webkit-playsinline
+            class="native-video"
+          >
+            您的浏览器不支持视频播放。
+          </video>
+        </div>
+
+        <div class="player-info">
+          <van-cell title="文件大小" :value="currentVideoItem.size" />
+          <van-cell title="拍摄时间" :value="currentVideoItem.updateTime" />
+        </div>
+      </div>
+    </van-popup>
   </div>
 </template>
 
 <script>
+import { getPhotoItems } from '@/api/disk'
+
 export default {
   data() {
     return {
+      queryParams: {
+        pn: 1
+      },
+      showPlayerModal: false, // 控制弹窗
+      currentVideoItem: null, // 当前播放的视频对象
       photoList: [],
-      loading: false,
-      finished: false,
-      refreshing: false,
+      loading: false, // 是否正在加载
+      finished: false, // 是否完成
+      refreshing: false, // 是否刷新
       page: 1,
       isEditMode: false, // 是否处于多选模式
       selectedIds: [] // 已选中的照片 ID 列表
@@ -75,60 +117,57 @@ export default {
   },
   methods: {
     async onLoad() {
-      if (this.refreshing) {
-        this.photoList = []
-        this.refreshing = false
-      }
+      // 1. 如果正在下拉刷新中,不执行滚动加载逻辑
+      if (this.refreshing) return
+
+      try {
+        // 2. 调用接口(确保传参是当前的 pn)
+        const resp = await getPhotoItems(this.queryParams)
+
+        if (resp.code === 0) {
+          const respData = resp.data
+          const newData = respData.list || []
+
+          // 3. 如果是第一页,直接赋值;否则追加
+          if (this.queryParams.pn === 1) {
+            this.photoList = newData
+          } else {
+            this.photoList.push(...newData)
+          }
 
-      // 模拟获取相册数据
-      const newData = await this.fetchPhotos(this.page)
-      this.photoList.push(...newData)
+          // 4. 加载状态结束
+          this.loading = false
 
-      this.loading = false
-      if (this.photoList.length >= 60) {
+          // 5. 判断是否还有下一页
+          // 注意:这里建议优先判断 newData 是否为空,或者使用后端返回的 hasNext
+          if (!respData.hasNext || newData.length === 0) {
+            this.finished = true
+          } else {
+            this.queryParams.pn++ // 准备加载下一页
+          }
+        } else {
+          this.$toast(resp.msg)
+          this.finished = true
+        }
+      } catch (error) {
+        this.loading = false
         this.finished = true
-      } else {
-        this.page++
+        this.$toast('加载失败')
       }
     },
+
     onRefresh() {
-      this.page = 1
+      // 下拉刷新的重置逻辑
       this.finished = false
-      this.loading = true
+      this.loading = true // 展示加载转圈
+      this.refreshing = true
+
+      // 重置参数
+      this.queryParams.pn = 1
+
+      // 重新发起请求
       this.onLoad()
     },
-    // 模拟数据接口
-    fetchPhotos(page) {
-      return new Promise((resolve) => {
-        setTimeout(() => {
-          const photos = []
-          const limit = 15
-          const dates = ['2026-05-09', '2026-05-08', '2026-05-07']
-
-          for (let i = 0; i < limit; i++) {
-            const dateIndex = Math.min(Math.floor((i + (page - 1) * limit) / 10), dates.length - 1)
-            const date = dates[dateIndex]
-
-            // 随机逻辑:每 4 张照片里出现 1 个视频
-            const isVideo = i % 4 === 0
-
-            photos.push({
-              id: `${isVideo ? 'video' : 'photo'}-${page}-${i}`,
-              name: isVideo ? `VIDEO_${page}${i}.mp4` : `IMG_${page}${i}.jpg`,
-              type: isVideo ? 'video' : 'image',
-              // 视频时长模拟 (随机 01:20 到 05:40 之间)
-              duration: isVideo
-                ? `0${Math.floor(Math.random() * 5 + 1)}:${Math.floor(Math.random() * 50 + 10)}`
-                : '',
-              url: `https://picsum.photos/400/400?random=${page * 100 + i}`,
-              size: isVideo ? '45.8MB' : '2.4MB',
-              updateTime: `${date} 14:30:00`
-            })
-          }
-          resolve(photos)
-        }, 800)
-      })
-    },
     // 转换扁平数据为分组数据
     groupDataByDate(flatList) {
       const groups = {}
@@ -178,7 +217,7 @@ export default {
     },
     onItemClick(item) {
       if (this.isEditMode) {
-        this.handleSelect(item.id)
+        this.handleSelect(item.fileId)
       } else {
         if (item.type === 'video') {
           // 唤起视频播放弹窗 (复用之前 Home.vue 里的视频预览逻辑)
@@ -203,12 +242,16 @@ export default {
 
     // 别忘了补齐 previewVideo 方法 (如果还没从 Home.vue 挪过来的话)
     previewVideo(item) {
-      this.$dialog.alert({
-        title: '视频预览',
-        message: `正在模拟播放视频: ${item.name}\n时长: ${item.duration}`,
-        confirmButtonText: '关闭'
-      })
-      // 实际项目中这里应调用之前封装的 DPlayer 弹窗
+      this.currentVideoItem = item
+      this.showPlayerModal = true
+    },
+
+    // 弹窗关闭后的清理逻辑
+    onVideoPopupClosed() {
+      if (this.$refs.videoPlayer) {
+        this.$refs.videoPlayer.pause() // 停止播放防止后台有声音
+      }
+      this.currentVideoItem = null
     },
 
     // 组全选逻辑
@@ -346,4 +389,54 @@ export default {
     font-weight: 500;
   }
 }
+
+/* 视频播放器弹窗样式 */
+.player-container {
+  background: #000;
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+
+  .player-header {
+    padding: 15px 45px; // 留出关闭按钮的空间
+    text-align: center;
+    font-size: 16px;
+    color: #fff;
+    background: rgba(0, 0, 0, 0.8);
+  }
+
+  .video-wrapper {
+    flex: 1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: #000;
+  }
+
+  .native-video {
+    width: 100%;
+    max-height: 80vh;
+    object-fit: contain; // 保持视频比例,不拉伸
+  }
+
+  .player-info {
+    padding-bottom: 20px;
+    // 调整内部 Cell 为深色风格
+    /deep/ .van-cell {
+      background-color: #1a1a1a;
+      color: #eee;
+      &::after {
+        border-bottom: 1px solid #333;
+      }
+      .van-cell__value {
+        color: #aaa;
+      }
+    }
+  }
+}
+
+/* 覆盖 Vant Popup 的关闭按钮颜色,使其在黑底上可见 */
+/deep/ .van-popup__close-icon {
+  color: #fff;
+}
 </style>