wangfumin 1 viikko sitten
vanhempi
commit
892fe6abbc

+ 4 - 3
components.d.ts

@@ -9,6 +9,7 @@ export {}
 
 declare module '@vue/runtime-core' {
   export interface GlobalComponents {
+    CameraItem: typeof import('./src/components/tableList/CameraItem.vue')['default']
     Confirm: typeof import('./src/components/Toast/Confirm.vue')['default']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
@@ -18,7 +19,6 @@ declare module '@vue/runtime-core' {
     ElEmpty: typeof import('element-plus/es')['ElEmpty']
     ElForm: typeof import('element-plus/es')['ElForm']
     ElFormItem: typeof import('element-plus/es')['ElFormItem']
-    ElIcon: typeof import('element-plus/es')['ElIcon']
     ElInput: typeof import('element-plus/es')['ElInput']
     ElOption: typeof import('element-plus/es')['ElOption']
     ElPagination: typeof import('element-plus/es')['ElPagination']
@@ -29,8 +29,8 @@ declare module '@vue/runtime-core' {
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTooltip: typeof import('element-plus/es')['ElTooltip']
-    Footer: typeof import('./src/components/pc/footer.vue')['default']
-    Header: typeof import('./src/components/pc/header.vue')['default']
+    Footer: typeof import('./src/components/mobile/footer.vue')['default']
+    Header: typeof import('./src/components/mobile/header.vue')['default']
     HelloWorld: typeof import('./src/components/HelloWorld.vue')['default']
     IconCommunity: typeof import('./src/components/icons/IconCommunity.vue')['default']
     IconDocumentation: typeof import('./src/components/icons/IconDocumentation.vue')['default']
@@ -43,6 +43,7 @@ declare module '@vue/runtime-core' {
     Popup: typeof import('./src/components/popup/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
+    TableList: typeof import('./src/components/tableList/index.vue')['default']
     TheWelcome: typeof import('./src/components/TheWelcome.vue')['default']
     Toast: typeof import('./src/components/Toast/Toast.vue')['default']
     WelcomeItem: typeof import('./src/components/WelcomeItem.vue')['default']

+ 470 - 0
src/components/CameraItem.vue

@@ -0,0 +1,470 @@
+<template>
+  <div class="col-content">
+    <div class="i-left">
+      <img
+        @click="gotoScene(item)"
+        :src="getDeviceImage(item.cameraType)"
+        alt=""
+      />
+    </div>
+    <div class="i-right">
+      <template v-if="item.goodsId !== 1 && item.goodsId !== 8">
+        <p
+          class="d-id"
+          :title="item.snCode || '--'"
+        >
+          <span class="vip-icon" v-if="isMember"></span>
+          <span class="vip-icon vip-expired-icon" v-else-if="isExpiredMember"></span>
+          <span class="sncode">
+            设备编号: {{ item.snCode || "--" }}
+          </span>
+        </p>
+        <p
+          class="p-sub"
+          style="padding-left: 26px"
+          :title="`${item.usedSpaceStr} / ${
+            isMember ? '无限容量' : item.totalSpaceStr
+          }`"
+        >
+          <!-- <img src="/images/icon-cloud.png" alt="" /> -->
+          <span v-if="item.usedSpaceStr && item.totalSpaceStr && item.totalSpaceStr != '0B'">
+            {{ item.usedSpaceStr }}
+            {{ (isMember || item.cameraType == 10 || item.cameraType == 11 || item.cameraType == 12) ? '' : `/${item.totalSpaceStr}` }}
+          </span>
+          <span v-else>--</span>
+        </p>
+        <div class="capacity">
+          <div class="c-line">
+            <div
+              class="active"
+              v-show="!isMember"
+              :style="{
+                width: (item.cameraType == 10 || item.cameraType == 11 || item.cameraType == 12) ? '1%' : getBar(item.usedSpace, item.totalSpace),
+                background: (item.cameraType == 10 || item.cameraType == 11 || item.cameraType == 12) ? '#15BEC8' : getColor(item.usedSpace, item.totalSpace),
+              }"
+            ></div>
+          </div>
+        </div>
+        <p class="p-sub p-expired">
+          到期时间:{{ item.spaceEndStr || "--" }}
+          <span class="expired-icon-w">
+            <span
+              v-if="validExpired(item.spaceEndStr).isExpired"
+              class="expired-icon"
+              @mouseenter="setTimeoutShowCtrls(true)"
+              @mouseleave="setTimeoutShowCtrls(false)"
+            >
+              ⚠️
+            </span>
+            <div 
+              v-show="showCtrls" 
+              class="expired-ctrls-w" 
+              @mouseenter="setTimeoutShowCtrls(true)"
+              @mouseleave="setTimeoutShowCtrls(false)"
+            >
+              <p v-if="!validExpired(item.spaceEndStr).expiredTime">会员即将到期</p>
+              <p v-else-if="validExpired(item.spaceEndStr).trueExpired">会员已过期</p>
+              <p v-else>
+                会员将在 <span class="expired">{{ validExpired(item.spaceEndStr).expiredTime }}</span> 天后过期
+              </p>
+              <div class="ctrls-w">
+                <div class="btn cancel-btn" @click="showCtrls = false">取消</div>
+                <div class="btn submit-btn" @click="handleRepay">续费</div>
+              </div>
+            </div>
+          </span>
+        </p>
+        <p class="p-sub" :title="item.cooperationUserName">
+          协作用户:{{ item.cooperationUserName || '-' }}
+        </p>
+        <div class="oper-con">
+          <div class="oper">
+            <div>
+              <span class="spot"></span>
+            </div>
+            <ul>
+              <li @click="unbind(item)">
+                解绑
+              </li>
+              <li v-if="item.status !== 0" @click="handleCooperation(item)">
+                {{ item.cooperationUserName ? '取消分配' : '分配' }}
+              </li>
+            </ul>
+          </div>
+        </div>
+      </template>
+      <template v-else>
+        <p class="d-id">ID: <span class="sncode">{{ item.childName }}</span></p>
+        <p class="p-sub">余额:{{ item.balance }}</p>
+        <div class="d-edit" :class="{ 'dtow-edit': item.cameraType != 4 }">
+          <div class="primary">
+            <span>充值</span>
+          </div>
+          <div>
+            <span @click="unbind(item)">解绑</span>
+          </div>
+        </div>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+
+interface Props {
+  item: any
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits(['handleCooperation', 'unbind', 'renew'])
+
+const showCtrls = ref(false)
+const timer = ref<NodeJS.Timeout>()
+
+// 计算属性
+const isMember = computed(() => {
+  return props.item.userIncrementId && !expired.value.trueExpired
+})
+
+const isExpiredMember = computed(() => {
+  return props.item.userIncrementId && expired.value.trueExpired
+})
+
+const expired = computed(() => {
+  if (!props.item.spaceEndTime) {
+    return {}
+  }
+  const expiredDays = Math.floor((new Date(props.item.spaceEndTime).getTime() - new Date().getTime()) / 86400000) + 1
+  return {
+    expiredTime: expiredDays,
+    expiredText: `<span class="expired">${expiredDays}</span>`,
+    isExpired: expiredDays <= 30,
+    trueExpired: expiredDays < 0
+  }
+})
+
+// 方法
+const getDeviceImage = (cameraType: number) => {
+  switch (cameraType) {
+    case 4:
+      return '/images/banner_pro.png'
+    case 9:
+      return '/src/assets/images/pic_lite.png'
+    case 10:
+      return '/src/assets/images/pci_Laser.png'
+    case 11:
+      return '/src/assets/images/pic_shenguan.png'
+    case 12:
+      return '/src/assets/images/pic_Mova.png'
+    default:
+      return '/images/t_product.png'
+  }
+}
+
+const gotoScene = (item: any) => {
+  // 这里可以添加路由跳转逻辑
+  console.log('跳转到场景页面', item)
+}
+
+const getBar = (a: number, b: number) => {
+  if (a === 0) {
+    return 0
+  }
+  const temp = (a / b) * 100
+  if (temp < 1) {
+    return "1%"
+  }
+  return (temp > 100 ? 100 : Math.round(temp)) + "%"
+}
+
+const getColor = (a: number, b: number) => {
+  const temp = (a / b) * 100
+  const point = 80
+  return temp < point ? "#15BEC8" : "#ff0000"
+}
+
+const validExpired = (time: string) => {
+  if (!time) {
+    return {}
+  }
+  const expired = Math.floor((new Date(time).getTime() - new Date().getTime()) / 86400000) + 1
+
+  return {
+    expiredText: `<span class="expired">${expired}</span>`,
+    expiredTime: expired,
+    isExpired: expired <= 30,
+    trueExpired: expired < 0
+  }
+}
+
+const handleCooperation = () => {
+  emit("handleCooperation", props.item)
+}
+
+const unbind = () => {
+  emit("unbind", props.item)
+}
+
+const setTimeoutShowCtrls = (bool: boolean) => {
+  if (timer.value) {
+    clearTimeout(timer.value)
+  }
+  timer.value = setTimeout(() => {
+    showCtrls.value = bool
+  }, 200)
+}
+
+const handleRepay = () => {
+  emit('renew', props.item)
+}
+</script>
+
+<style lang="less" scoped>
+.col-content {
+  display: flex;
+  gap: 15px;
+  height: 100%;
+  
+  .i-left {
+    flex-shrink: 0;
+    
+    img {
+      width: 80px;
+      height: 80px;
+      object-fit: cover;
+      border-radius: 4px;
+      cursor: pointer;
+    }
+  }
+  
+  .i-right {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    
+    .d-id {
+      display: flex;
+      align-items: center;
+      font-weight: 600;
+      color: #323233;
+      margin-bottom: 8px;
+      
+      .vip-icon {
+        width: 16px;
+        height: 16px;
+        background: url('@/assets/images/vip_true.svg') no-repeat center center;
+        background-size: cover;
+        margin-right: 5px;
+        
+        &.vip-expired-icon {
+          opacity: 0.5;
+        }
+      }
+      
+      .sncode {
+        flex: 1;
+        word-break: break-all;
+        text-overflow: ellipsis;
+        overflow: hidden;
+        white-space: nowrap;
+      }
+    }
+    
+    .p-sub {
+      display: flex;
+      align-items: center;
+      color: #666;
+      font-size: 14px;
+      margin-bottom: 5px;
+      
+      img {
+        width: 16px;
+        height: 16px;
+        margin-right: 5px;
+      }
+      
+      &.p-expired {
+        overflow: visible !important;
+      }
+    }
+    
+    .capacity {
+      margin: 8px 0;
+      
+      .c-line {
+        width: 100%;
+        height: 6px;
+        background: #e5e5e5;
+        border-radius: 3px;
+        overflow: hidden;
+        
+        .active {
+          height: 100%;
+          border-radius: 3px;
+          transition: all 0.3s;
+        }
+      }
+    }
+    
+    .oper-con {
+      margin-top: 10px;
+      
+      .oper {
+        position: relative;
+        
+        .spot {
+          display: inline-block;
+          width: 4px;
+          height: 4px;
+          background: #666;
+          border-radius: 50%;
+          cursor: pointer;
+          
+          &::before,
+          &::after {
+            content: '';
+            position: absolute;
+            width: 4px;
+            height: 4px;
+            background: #666;
+            border-radius: 50%;
+          }
+          
+          &::before {
+            top: -6px;
+          }
+          
+          &::after {
+            top: 6px;
+          }
+        }
+        
+        ul {
+          display: none;
+          position: absolute;
+          top: 100%;
+          right: 0;
+          background: #fff;
+          border: 1px solid #e5e5e5;
+          border-radius: 4px;
+          box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
+          z-index: 10;
+          min-width: 109px;
+          
+          li {
+            padding: 8px 16px;
+            cursor: pointer;
+            font-size: 14px;
+            color: #323233;
+            
+            &:hover {
+              background: #f5f5f5;
+            }
+            
+            &:not(:last-child) {
+              border-bottom: 1px solid #f0f0f0;
+            }
+          }
+        }
+        
+        &:hover ul {
+          display: block;
+        }
+      }
+    }
+    
+    .d-edit {
+      display: flex;
+      gap: 10px;
+      margin-top: 10px;
+      
+      .primary {
+        background: #15BEC8;
+        color: #fff;
+        padding: 4px 12px;
+        border-radius: 4px;
+        cursor: pointer;
+        font-size: 14px;
+        
+        &:hover {
+          background: #13a8b1;
+        }
+      }
+      
+      div:not(.primary) {
+        color: #666;
+        cursor: pointer;
+        
+        &:hover {
+          color: #15BEC8;
+        }
+      }
+    }
+  }
+}
+
+.expired-icon {
+  cursor: pointer;
+}
+
+.expired-icon-w {
+  position: relative;
+  
+  .expired-ctrls-w {
+    position: absolute;
+    left: 0px;
+    background: #fff;
+    z-index: 1;
+    min-width: 172px;
+    height: 85px;
+    padding: 12px 16px;
+    box-shadow: 0px 2px 12px 0px rgba(50, 50, 51, 0.12);
+    bottom: -10px;
+    transform: translateY(100%);
+    border-radius: 4px;
+    font-size: 14px;
+    color: #323233;
+    line-height: 22px;
+    
+    &::before {
+      content: "";
+      display: block;
+      border: 8px solid transparent;
+      border-bottom-color: #fff;
+      position: absolute;
+      top: -15px;
+      left: 14px;
+      z-index: 1;
+    }
+    
+    .ctrls-w {
+      display: flex;
+      justify-content: flex-end;
+      margin-top: 13px;
+    }
+    
+    .btn {
+      padding: 0 8px;
+      line-height: 24px;
+      border-radius: 2px;
+      background: #ebebeb;
+      display: inline-block;
+      cursor: pointer;
+    }
+    
+    .submit-btn {
+      background: #15BEC8;
+      margin-left: 8px;
+      color: #fff;
+    }
+    
+    .expired {
+      color: #ff4757;
+      font-weight: 600;
+    }
+  }
+}
+</style>

+ 470 - 0
src/components/tableList/CameraItem.vue

@@ -0,0 +1,470 @@
+<template>
+  <div class="col-content">
+    <div class="i-left">
+      <img
+        @click="gotoScene(item)"
+        :src="getDeviceImage(item.cameraType)"
+        alt=""
+      />
+    </div>
+    <div class="i-right">
+      <template v-if="item.goodsId !== 1 && item.goodsId !== 8">
+        <p
+          class="d-id"
+          :title="item.snCode || '--'"
+        >
+          <span class="vip-icon" v-if="isMember"></span>
+          <span class="vip-icon vip-expired-icon" v-else-if="isExpiredMember"></span>
+          <span class="sncode">
+            设备编号: {{ item.snCode || "--" }}
+          </span>
+        </p>
+        <p
+          class="p-sub"
+          style="padding-left: 26px"
+          :title="`${item.usedSpaceStr} / ${
+            isMember ? '无限容量' : item.totalSpaceStr
+          }`"
+        >
+          <!-- <img src="/images/icon-cloud.png" alt="" /> -->
+          <span v-if="item.usedSpaceStr && item.totalSpaceStr && item.totalSpaceStr != '0B'">
+            {{ item.usedSpaceStr }}
+            {{ (isMember || item.cameraType == 10 || item.cameraType == 11 || item.cameraType == 12) ? '' : `/${item.totalSpaceStr}` }}
+          </span>
+          <span v-else>--</span>
+        </p>
+        <div class="capacity">
+          <div class="c-line">
+            <div
+              class="active"
+              v-show="!isMember"
+              :style="{
+                width: (item.cameraType == 10 || item.cameraType == 11 || item.cameraType == 12) ? '1%' : getBar(item.usedSpace, item.totalSpace),
+                background: (item.cameraType == 10 || item.cameraType == 11 || item.cameraType == 12) ? '#15BEC8' : getColor(item.usedSpace, item.totalSpace),
+              }"
+            ></div>
+          </div>
+        </div>
+        <p class="p-sub p-expired">
+          到期时间:{{ item.spaceEndStr || "--" }}
+          <span class="expired-icon-w">
+            <span
+              v-if="validExpired(item.spaceEndStr).isExpired"
+              class="expired-icon"
+              @mouseenter="setTimeoutShowCtrls(true)"
+              @mouseleave="setTimeoutShowCtrls(false)"
+            >
+              ⚠️
+            </span>
+            <div 
+              v-show="showCtrls" 
+              class="expired-ctrls-w" 
+              @mouseenter="setTimeoutShowCtrls(true)"
+              @mouseleave="setTimeoutShowCtrls(false)"
+            >
+              <p v-if="!validExpired(item.spaceEndStr).expiredTime">会员即将到期</p>
+              <p v-else-if="validExpired(item.spaceEndStr).trueExpired">会员已过期</p>
+              <p v-else>
+                会员将在 <span class="expired">{{ validExpired(item.spaceEndStr).expiredTime }}</span> 天后过期
+              </p>
+              <div class="ctrls-w">
+                <div class="btn cancel-btn" @click="showCtrls = false">取消</div>
+                <div class="btn submit-btn" @click="handleRepay">续费</div>
+              </div>
+            </div>
+          </span>
+        </p>
+        <p class="p-sub" :title="item.cooperationUserName">
+          协作用户:{{ item.cooperationUserName || '-' }}
+        </p>
+        <div class="oper-con">
+          <div class="oper">
+            <div>
+              <span class="spot"></span>
+            </div>
+            <ul>
+              <li @click="unbind(item)">
+                解绑
+              </li>
+              <li v-if="item.status !== 0" @click="handleCooperation(item)">
+                {{ item.cooperationUserName ? '取消分配' : '分配' }}
+              </li>
+            </ul>
+          </div>
+        </div>
+      </template>
+      <template v-else>
+        <p class="d-id">ID: <span class="sncode">{{ item.childName }}</span></p>
+        <p class="p-sub">余额:{{ item.balance }}</p>
+        <div class="d-edit" :class="{ 'dtow-edit': item.cameraType != 4 }">
+          <div class="primary">
+            <span>充值</span>
+          </div>
+          <div>
+            <span @click="unbind(item)">解绑</span>
+          </div>
+        </div>
+      </template>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+
+interface Props {
+  item: any
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits(['handleCooperation', 'unbind', 'renew'])
+
+const showCtrls = ref(false)
+const timer = ref<NodeJS.Timeout>()
+
+// 计算属性
+const isMember = computed(() => {
+  return props.item.userIncrementId && !expired.value.trueExpired
+})
+
+const isExpiredMember = computed(() => {
+  return props.item.userIncrementId && expired.value.trueExpired
+})
+
+const expired = computed(() => {
+  if (!props.item.spaceEndTime) {
+    return {}
+  }
+  const expiredDays = Math.floor((new Date(props.item.spaceEndTime).getTime() - new Date().getTime()) / 86400000) + 1
+  return {
+    expiredTime: expiredDays,
+    expiredText: `<span class="expired">${expiredDays}</span>`,
+    isExpired: expiredDays <= 30,
+    trueExpired: expiredDays < 0
+  }
+})
+
+// 方法
+const getDeviceImage = (cameraType: number) => {
+  switch (cameraType) {
+    case 4:
+      return '/images/banner_pro.png'
+    case 9:
+      return '/src/assets/images/pic_lite.png'
+    case 10:
+      return '/src/assets/images/pci_Laser.png'
+    case 11:
+      return '/src/assets/images/pic_shenguan.png'
+    case 12:
+      return '/src/assets/images/pic_Mova.png'
+    default:
+      return '/images/t_product.png'
+  }
+}
+
+const gotoScene = (item: any) => {
+  // 这里可以添加路由跳转逻辑
+  console.log('跳转到场景页面', item)
+}
+
+const getBar = (a: number, b: number) => {
+  if (a === 0) {
+    return 0
+  }
+  const temp = (a / b) * 100
+  if (temp < 1) {
+    return "1%"
+  }
+  return (temp > 100 ? 100 : Math.round(temp)) + "%"
+}
+
+const getColor = (a: number, b: number) => {
+  const temp = (a / b) * 100
+  const point = 80
+  return temp < point ? "#15BEC8" : "#ff0000"
+}
+
+const validExpired = (time: string) => {
+  if (!time) {
+    return {}
+  }
+  const expired = Math.floor((new Date(time).getTime() - new Date().getTime()) / 86400000) + 1
+
+  return {
+    expiredText: `<span class="expired">${expired}</span>`,
+    expiredTime: expired,
+    isExpired: expired <= 30,
+    trueExpired: expired < 0
+  }
+}
+
+const handleCooperation = () => {
+  emit("handleCooperation", props.item)
+}
+
+const unbind = () => {
+  emit("unbind", props.item)
+}
+
+const setTimeoutShowCtrls = (bool: boolean) => {
+  if (timer.value) {
+    clearTimeout(timer.value)
+  }
+  timer.value = setTimeout(() => {
+    showCtrls.value = bool
+  }, 200)
+}
+
+const handleRepay = () => {
+  emit('renew', props.item)
+}
+</script>
+
+<style lang="less" scoped>
+.col-content {
+  display: flex;
+  gap: 15px;
+  height: 100%;
+  
+  .i-left {
+    flex-shrink: 0;
+    
+    img {
+      width: 80px;
+      height: 80px;
+      object-fit: cover;
+      border-radius: 4px;
+      cursor: pointer;
+    }
+  }
+  
+  .i-right {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    
+    .d-id {
+      display: flex;
+      align-items: center;
+      font-weight: 600;
+      color: #323233;
+      margin-bottom: 8px;
+      
+      .vip-icon {
+        width: 16px;
+        height: 16px;
+        background: url('@/assets/images/vip_true.svg') no-repeat center center;
+        background-size: cover;
+        margin-right: 5px;
+        
+        &.vip-expired-icon {
+          opacity: 0.5;
+        }
+      }
+      
+      .sncode {
+        flex: 1;
+        word-break: break-all;
+        text-overflow: ellipsis;
+        overflow: hidden;
+        white-space: nowrap;
+      }
+    }
+    
+    .p-sub {
+      display: flex;
+      align-items: center;
+      color: #666;
+      font-size: 14px;
+      margin-bottom: 5px;
+      
+      img {
+        width: 16px;
+        height: 16px;
+        margin-right: 5px;
+      }
+      
+      &.p-expired {
+        overflow: visible !important;
+      }
+    }
+    
+    .capacity {
+      margin: 8px 0;
+      
+      .c-line {
+        width: 100%;
+        height: 6px;
+        background: #e5e5e5;
+        border-radius: 3px;
+        overflow: hidden;
+        
+        .active {
+          height: 100%;
+          border-radius: 3px;
+          transition: all 0.3s;
+        }
+      }
+    }
+    
+    .oper-con {
+      margin-top: 10px;
+      
+      .oper {
+        position: relative;
+        
+        .spot {
+          display: inline-block;
+          width: 4px;
+          height: 4px;
+          background: #666;
+          border-radius: 50%;
+          cursor: pointer;
+          
+          &::before,
+          &::after {
+            content: '';
+            position: absolute;
+            width: 4px;
+            height: 4px;
+            background: #666;
+            border-radius: 50%;
+          }
+          
+          &::before {
+            top: -6px;
+          }
+          
+          &::after {
+            top: 6px;
+          }
+        }
+        
+        ul {
+          display: none;
+          position: absolute;
+          top: 100%;
+          right: 0;
+          background: #fff;
+          border: 1px solid #e5e5e5;
+          border-radius: 4px;
+          box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
+          z-index: 10;
+          min-width: 109px;
+          
+          li {
+            padding: 8px 16px;
+            cursor: pointer;
+            font-size: 14px;
+            color: #323233;
+            
+            &:hover {
+              background: #f5f5f5;
+            }
+            
+            &:not(:last-child) {
+              border-bottom: 1px solid #f0f0f0;
+            }
+          }
+        }
+        
+        &:hover ul {
+          display: block;
+        }
+      }
+    }
+    
+    .d-edit {
+      display: flex;
+      gap: 10px;
+      margin-top: 10px;
+      
+      .primary {
+        background: #15BEC8;
+        color: #fff;
+        padding: 4px 12px;
+        border-radius: 4px;
+        cursor: pointer;
+        font-size: 14px;
+        
+        &:hover {
+          background: #13a8b1;
+        }
+      }
+      
+      div:not(.primary) {
+        color: #666;
+        cursor: pointer;
+        
+        &:hover {
+          color: #15BEC8;
+        }
+      }
+    }
+  }
+}
+
+.expired-icon {
+  cursor: pointer;
+}
+
+.expired-icon-w {
+  position: relative;
+  
+  .expired-ctrls-w {
+    position: absolute;
+    left: 0px;
+    background: #fff;
+    z-index: 1;
+    min-width: 172px;
+    height: 85px;
+    padding: 12px 16px;
+    box-shadow: 0px 2px 12px 0px rgba(50, 50, 51, 0.12);
+    bottom: -10px;
+    transform: translateY(100%);
+    border-radius: 4px;
+    font-size: 14px;
+    color: #323233;
+    line-height: 22px;
+    
+    &::before {
+      content: "";
+      display: block;
+      border: 8px solid transparent;
+      border-bottom-color: #fff;
+      position: absolute;
+      top: -15px;
+      left: 14px;
+      z-index: 1;
+    }
+    
+    .ctrls-w {
+      display: flex;
+      justify-content: flex-end;
+      margin-top: 13px;
+    }
+    
+    .btn {
+      padding: 0 8px;
+      line-height: 24px;
+      border-radius: 2px;
+      background: #ebebeb;
+      display: inline-block;
+      cursor: pointer;
+    }
+    
+    .submit-btn {
+      background: #15BEC8;
+      margin-left: 8px;
+      color: #fff;
+    }
+    
+    .expired {
+      color: #ff4757;
+      font-weight: 600;
+    }
+  }
+}
+</style>

+ 226 - 0
src/components/tableList/index.vue

@@ -0,0 +1,226 @@
+<template>
+  <div class="table-list-wrapper">
+    <!-- 列表视图 -->
+    <div class="table-layout" v-show="!showCardView">
+      <ul class="t-header" :class="{ line: showLine }">
+        <li v-if="selection" class="check-cls">
+          <el-checkbox 
+            v-model="selectAll" 
+            @change="handleSelectAll"
+            :disabled="!data.length"
+          />
+        </li>
+        <li
+          v-for="(item, i) in header"
+          :key="i"
+          :style="{
+            width: item.width ? item.width + 'px' : 100 / header.length + '%',
+          }"
+        >
+          {{ item.name }}
+        </li>
+      </ul>
+      <div class="t-con">
+        <ul
+          class="t-item"
+          :class="{ line: showLine }"
+          v-for="(item, i) in data"
+          :key="i"
+        >
+          <li v-if="selection" class="check-cls">
+            <el-checkbox 
+              v-model="item.selected" 
+              @change="handleItemSelect(item)"
+            />
+          </li>
+          <li
+            v-for="(sub, j) in header"
+            :key="j"
+            :style="{
+              width: sub.width ? sub.width + 'px' : 100 / header.length + '%',
+            }"
+          >
+            <slot
+              :data="sub.staticName || item[sub.key]"
+              :subKey="sub.key"
+              :type="sub.type"
+              :item="item"
+              :canclick="sub.canclick"
+              name="item"
+            >
+              {{ sub.staticName || item[sub.key] || '-' }}
+            </slot>
+          </li>
+        </ul>
+      </div>
+    </div>
+
+    <!-- 卡片视图 -->
+    <div class="card-view" v-show="showCardView">
+      <el-row :gutter="20" class="camera-cards">
+        <el-col
+          :span="8"
+          v-for="(item, index) in data"
+          :key="index"
+          class="camera-item"
+        >
+          <div class="card-wrapper">
+            <el-checkbox 
+              v-if="selection"
+              v-model="item.selected" 
+              @change="handleItemSelect(item)"
+              class="item-checkbox"
+            />
+            <slot name="card" :item="item" :index="index">
+              <camera-item
+                :item="item"
+                @handleCooperation="$emit('cooperation', $event)"
+                @unbind="$emit('unbind', $event)"
+                @renew="$emit('renew', $event)"
+              />
+            </slot>
+          </div>
+        </el-col>
+      </el-row>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from 'vue'
+import CameraItem from '@/components/tableList/CameraItem.vue'
+
+interface Props {
+  data: any[]
+  header: any[]
+  selection?: boolean
+  showLine?: boolean
+  showCardView?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  selection: false,
+  showLine: true,
+  showCardView: false
+})
+
+const emit = defineEmits(['selection-change', 'cooperation', 'unbind', 'renew'])
+
+const selectAll = ref(false)
+
+const handleSelectAll = (val: boolean) => {
+  props.data.forEach(item => {
+    item.selected = val
+  })
+  handleSelectionChange()
+}
+
+const handleItemSelect = (item: any) => {
+  handleSelectionChange()
+  
+  // 更新全选状态
+  selectAll.value = props.data.every(item => item.selected)
+}
+
+const handleSelectionChange = () => {
+  const selectedItems = props.data.filter(item => item.selected)
+  emit('selection-change', selectedItems)
+}
+
+// 监听数据变化,重置选择状态
+watch(() => props.data, () => {
+  selectAll.value = false
+}, { deep: true })
+</script>
+
+<style lang="less" scoped>
+.table-list-wrapper {
+  width: 100%;
+}
+
+.table-layout {
+  width: 100%;
+  
+  .t-header {
+    display: flex;
+    background: #f7f7f7;
+    border-radius: 4px;
+    padding: 0 20px;
+    height: 50px;
+    align-items: center;
+    font-weight: 600;
+    color: #323233;
+    margin-bottom: 10px;
+    
+    &.line {
+      border-bottom: 1px solid #e5e5e5;
+    }
+    
+    li {
+      display: flex;
+      align-items: center;
+      padding: 0 10px;
+      
+      &.check-cls {
+        width: 60px;
+        justify-content: center;
+      }
+    }
+  }
+  
+  .t-con {
+    .t-item {
+      display: flex;
+      padding: 0 20px;
+      height: 60px;
+      align-items: center;
+      border-bottom: 1px solid #f0f0f0;
+      
+      &:hover {
+        background: #f9f9f9;
+      }
+      
+      &.line {
+        border-bottom: 1px solid #e5e5e5;
+      }
+      
+      li {
+        display: flex;
+        align-items: center;
+        padding: 0 10px;
+        color: #666;
+        
+        &.check-cls {
+          width: 60px;
+          justify-content: center;
+        }
+      }
+    }
+  }
+}
+
+.card-view {
+  .camera-cards {
+    padding: 20px 0;
+    
+    .camera-item {
+      margin-bottom: 20px;
+      
+      .card-wrapper {
+        position: relative;
+        background: #f7f7f7;
+        padding: 20px;
+        border-radius: 8px;
+        min-height: 180px;
+        
+        .item-checkbox {
+          position: absolute;
+          top: 10px;
+          left: 10px;
+          z-index: 1;
+        }
+      }
+    }
+  }
+}
+</style>

+ 152 - 45
src/views/pc/device/index.vue

@@ -103,8 +103,8 @@
 
     <!-- 内容区域 -->
     <template>
-      <!-- 卡片视图 -->
-      <el-row :gutter="20" class="camera-cards" v-show="isImgType || tabActive === 0">
+      <!-- 协作设备特殊显示 -->
+      <el-row :gutter="20" class="camera-cards" v-show="tabActive === 0">
         <template v-if="!loading">
           <el-col
             :span="8"
@@ -120,7 +120,6 @@
               />
               <camera-item
                 :item="item"
-                :tabActive="tabActive"
                 @handleCooperation="handleCooperation"
                 @unbind="unbind"
                 @renew="handleRenew"
@@ -130,9 +129,9 @@
         </template>
       </el-row>
 
-      <!-- 列表视图 -->
+      <!-- TableList组件 - 支持列表和卡片视图 -->
       <table-list
-        v-show="!(isImgType || tabActive === 0)"
+        v-show="tabActive !== 0"
         ref="tableRef"
         @selection-change="selectHandle"
         @unbind="unbind"
@@ -141,7 +140,8 @@
         :header="tabHeader"
         :selection="cameraList.length > 0"
         :data="cameraList"
-        :show-view-toggle="false"
+        :show-card-view="isImgType"
+        :show-line="true"
         class="table-list"
       >
         <template #item="{ data, type, canclick, item }">
@@ -245,29 +245,35 @@
 </template>
 
 <script setup lang="ts">
-import { ref, computed, onMounted, watch, nextTick } from 'vue'
+import { ref, computed, onMounted, watch } from 'vue'
 import { ElMessage, ElMessageBox } from 'element-plus'
 import TableList from '@/components/tableList/index.vue'
+import CameraItem from '@/components/CameraItem.vue'
+
+defineOptions({
+  name: 'DeviceIndex'
+})
 
 // 静态数据
 const tabList = ref([
-  { id: 0, name: '协作设备' },
-  { id: 4, name: '我的设备' },
-  { id: 10, name: '共享设备' },
-  { id: 11, name: '租赁设备' }
+  { id: 0, name: '四维看看' },
+  { id: 4, name: '四维看见' },
+  { id: 10, name: '四维深时' },
+  { id: 11, name: '四维深光' },
+  { id: 12, name: '四维深巡' }
 ])
 
 const searchTypeList = ref([
-  { name: '设备编号', value: 2 },
-  { name: '用户名', value: 1 }
+  { name: 'S/N码', value: 2 },
+  { name: '协作者', value: 1 }
 ])
 
 // 表格头部配置
 const tabHeader = ref([
-  { key: 'snCode', name: '设备编号', type: 'image', width: 200 },
-  { key: 'status', name: '状态', width: 100 },
-  { key: 'qingkuang', name: '使用情况', type: 'qingkuang', width: 150 },
+  { key: 'snCode', name: 'S/N码', type: 'image', width: 200 },
+  { key: 'status', name: '云容量', width: 100 },
   { key: 'spaceEndStr', name: '到期时间', type: 'spaceEndStr', width: 150 },
+  { key: 'qingkuang', name: '协作者', type: 'qingkuang', width: 150 },
   { key: 'operation', name: '操作', canclick: true, width: 200 }
 ])
 
@@ -284,7 +290,12 @@ const mockCameraData = ref([
     userIncrementId: 'inc_001',
     sceneNum: 5,
     lastTime: '2024-01-15 10:30:00',
-    selected: false
+    selected: false,
+    cameraType: 4,
+    cooperationUserName: '张三',
+    goodsId: 0,
+    usedSpace: 2684354560, // 2.5GB in bytes
+    totalSpace: 10737418240 // 10GB in bytes
   },
   {
     id: 2,
@@ -297,7 +308,12 @@ const mockCameraData = ref([
     userIncrementId: 'inc_002',
     sceneNum: 8,
     lastTime: '2024-01-14 15:20:00',
-    selected: false
+    selected: false,
+    cameraType: 9,
+    cooperationUserName: '李四',
+    goodsId: 0,
+    usedSpace: 5583457484, // 5.2GB in bytes
+    totalSpace: 21474836480 // 20GB in bytes
   },
   {
     id: 3,
@@ -310,7 +326,88 @@ const mockCameraData = ref([
     userIncrementId: null,
     sceneNum: 3,
     lastTime: '2024-01-13 09:15:00',
-    selected: false
+    selected: false,
+    cameraType: 10,
+    cooperationUserName: '',
+    goodsId: 0,
+    usedSpace: 1932735283, // 1.8GB in bytes
+    totalSpace: 5368709120 // 5GB in bytes
+  },
+  {
+    id: 4,
+    snCode: 'CAM004',
+    status: 1,
+    usedSpaceStr: '8.5GB',
+    totalSpaceStr: '50GB',
+    spaceEndStr: '2025-03-15',
+    spaceEndTime: '2025-03-15',
+    userIncrementId: 'inc_004',
+    sceneNum: 12,
+    lastTime: '2024-01-16 14:22:00',
+    selected: false,
+    cameraType: 11,
+    cooperationUserName: '王五',
+    goodsId: 0,
+    usedSpace: 9126805504, // 8.5GB in bytes
+    totalSpace: 53687091200 // 50GB in bytes
+  },
+  {
+    id: 5,
+    snCode: 'CAM005',
+    status: 1,
+    usedSpaceStr: '15.2GB',
+    totalSpaceStr: '100GB',
+    spaceEndStr: '2024-09-20',
+    spaceEndTime: '2024-09-20',
+    userIncrementId: 'inc_005',
+    sceneNum: 20,
+    lastTime: '2024-01-16 16:45:00',
+    selected: false,
+    cameraType: 12,
+    cooperationUserName: '赵六',
+    goodsId: 0,
+    usedSpace: 16321863680, // 15.2GB in bytes
+    totalSpace: 107374182400 // 100GB in bytes
+  },
+  {
+    id: 6,
+    snCode: 'CAM006',
+    status: 1,
+    usedSpaceStr: '3.8GB',
+    totalSpaceStr: '15GB',
+    spaceEndStr: '2024-08-30',
+    spaceEndTime: '2024-08-30',
+    userIncrementId: null,
+    sceneNum: 6,
+    lastTime: '2024-01-12 11:30:00',
+    selected: false,
+    cameraType: 4,
+    cooperationUserName: '',
+    goodsId: 0,
+    usedSpace: 4080218931, // 3.8GB in bytes
+    totalSpace: 16106127360 // 15GB in bytes
+  },
+  {
+    id: 7,
+    snCode: 'TOW_001',
+    status: 1,
+    balance: '¥128.50',
+    childName: 'TOW_001',
+    selected: false,
+    cameraType: 4,
+    goodsId: 1,
+    lastTime: '2024-01-16 09:20:00'
+  },
+  {
+    id: 8,
+    snCode: 'TOW_002',
+    status: 1,
+    balance: '¥256.80',
+    childName: 'TOW_002',
+    selected: false,
+    cameraType: 9,
+    goodsId: 8,
+    lastTime: '2024-01-15 17:35:00'
   }
 ])
 
@@ -322,62 +419,72 @@ const total = ref(0)
 const isImgType = ref(localStorage.getItem("isImgTypeForDevice") !== "false")
 const searchKey = ref("")
 const loading = ref(false)
-const selectedArr = ref([])
+const selectedArr = ref<any[]>([])
 const selectAll = ref(false)
 const showBinding = ref(false)
 const showRenew = ref(false)
-const reNewItem = ref({})
-const showCtrls = ref(null)
-const showInfo = ref(null)
+const reNewItem = ref<any>({})
+const showCtrls = ref<number | null>(null)
+const showInfo = ref<number | null>(null)
 const selectedType = ref({ name: '设备编号', value: 2 })
 
 // 计算属性
-const totalObj = ref({
+const totalObj = ref<Record<number, number>>({
   0: 0,
   4: 0, 
   10: 0,
   11: 0
 })
 
-const oldTotalObj = ref({
+const oldTotalObj = ref<Record<number, number>>({
   0: 0,
   4: 0,
   10: 0, 
   11: 0
 })
 
-const cameraList = computed(() => {
-  let filteredData = mockCameraData.value
+const filteredData = computed(() => {
+  let data = mockCameraData.value
   
   // 根据当前标签页过滤
-  if (tabActive.value !== 4) {
-    // 这里可以根据不同的标签页显示不同的数据
-    filteredData = mockCameraData.value.filter(item => {
-      if (tabActive.value === 0) return item.status === 0 // 协作设备
-      if (tabActive.value === 10) return item.id % 2 === 0 // 共享设备
-      if (tabActive.value === 11) return item.id % 3 === 0 // 租赁设备
-      return true
-    })
-  }
+  data = mockCameraData.value.filter(item => {
+    if (tabActive.value === 0) return item.status === 0 // 协作设备
+    if (tabActive.value === 4) return item.goodsId === 0 // 我的设备(排除充值设备)
+    if (tabActive.value === 10) return item.cooperationUserName && item.goodsId === 0 // 共享设备(有协作用户)
+    if (tabActive.value === 11) return item.goodsId === 1 || item.goodsId === 8 // 租赁设备(充值设备)
+    return true
+  })
   
   // 搜索过滤
   if (searchKey.value) {
-    filteredData = filteredData.filter(item => {
+    data = data.filter(item => {
       if (selectedType.value.value === 2) {
-        return item.snCode.toLowerCase().includes(searchKey.value.toLowerCase())
+        return item.snCode && item.snCode.toLowerCase().includes(searchKey.value.toLowerCase())
+      } else if (selectedType.value.value === 1) {
+        return item.cooperationUserName && item.cooperationUserName.toLowerCase().includes(searchKey.value.toLowerCase())
       }
       return true
     })
   }
   
+  return data
+})
+
+const cameraList = computed(() => {
+  const data = filteredData.value
+  console.log(data, 888)
   // 分页
   const start = (currentPage.value - 1) * pageSize.value
   const end = start + pageSize.value
-  total.value = filteredData.length
   
-  return filteredData.slice(start, end)
+  return data.slice(start, end)
 })
 
+// 监听过滤数据变化更新总数
+watch(filteredData, (newData) => {
+  total.value = newData.length
+}, { immediate: true })
+
 // 组件引用
 const tableRef = ref()
 
@@ -390,10 +497,10 @@ const handleTabClick = (tab: any) => {
 
 const updateTotalCount = () => {
   // 更新各个标签的数量
-  totalObj.value[0] = mockCameraData.value.filter(item => item.status === 0).length
-  totalObj.value[4] = mockCameraData.value.length
-  totalObj.value[10] = mockCameraData.value.filter(item => item.id % 2 === 0).length
-  totalObj.value[11] = mockCameraData.value.filter(item => item.id % 3 === 0).length
+  totalObj.value[0] = mockCameraData.value.filter(item => item.status === 0).length // 协作设备
+  totalObj.value[4] = mockCameraData.value.filter(item => item.goodsId === 0).length // 我的设备(排除充值设备)
+  totalObj.value[10] = mockCameraData.value.filter(item => item.cooperationUserName && item.goodsId === 0).length // 共享设备(有协作用户)
+  totalObj.value[11] = mockCameraData.value.filter(item => item.goodsId === 1 || item.goodsId === 8).length // 租赁设备(充值设备)
   
   oldTotalObj.value = { ...totalObj.value }
 }
@@ -414,7 +521,7 @@ const handleSelectAll = (val: boolean) => {
   updateSelectedArr()
 }
 
-const handleItemSelect = (item: any) => {
+const handleItemSelect = (_item: any) => {
   updateSelectedArr()
   
   // 更新全选状态