tangning 1 месяц назад
Родитель
Сommit
70d950e3c8
77 измененных файлов с 4905 добавлено и 1835 удалено
  1. 6 0
      .env
  2. 6 0
      .env.cn-prod
  3. 6 0
      .env.cn-test
  4. 3 0
      .env.intl-prod
  5. 3 0
      .env.intl-test
  6. 2 1
      README.md
  7. 14 4
      src/App.vue
  8. 163 1
      src/api/equity/index.js
  9. 98 2
      src/api/login/index.js
  10. 30 0
      src/api/mall/index.js
  11. 69 5
      src/api/scene/index.js
  12. 75 29
      src/assets/font/demo_index.html
  13. 18 10
      src/assets/font/iconfont.css
  14. BIN
      src/assets/font/iconfont.eot
  15. 1 1
      src/assets/font/iconfont.js
  16. 21 7
      src/assets/font/iconfont.json
  17. 6 2
      src/assets/font/iconfont.svg
  18. BIN
      src/assets/font/iconfont.ttf
  19. BIN
      src/assets/font/iconfont.woff
  20. BIN
      src/assets/font/iconfont.woff2
  21. 1 0
      src/assets/style/index.less
  22. 25 2
      src/components/BaseTable/index.vue
  23. 4 0
      src/hooks/index.js
  24. 125 0
      src/hooks/useCartPay.js
  25. 165 0
      src/hooks/useDownloadPay.js
  26. 176 0
      src/hooks/useMembershipPay.js
  27. 355 0
      src/hooks/useOssUpload.js
  28. 2 1
      src/layouts/header.vue
  29. 129 132
      src/layouts/menu.vue
  30. 268 1
      src/locales/zh.json
  31. 13 1
      src/router/index.js
  32. 7 1
      src/stores/cart.js
  33. 1 0
      src/stores/index.js
  34. 38 0
      src/stores/order.js
  35. 0 8
      src/stores/scene.js
  36. 47 3
      src/stores/user.js
  37. 2 1
      src/utils/http.js
  38. 6 6
      src/utils/index.js
  39. 2 2
      src/views/account/components/aside.vue
  40. 156 101
      src/views/account/login.vue
  41. 162 136
      src/views/account/register.vue
  42. 17 4
      src/views/device/components/bindCameraModal.vue
  43. 50 25
      src/views/device/components/cooperationModal.vue
  44. 8 7
      src/views/device/data.ts
  45. 51 128
      src/views/device/index.vue
  46. 17 14
      src/views/equity/components/bindCameraModal.vue
  47. 18 14
      src/views/equity/data.ts
  48. 65 63
      src/views/equity/index.vue
  49. 0 1
      src/views/home/components/map.vue
  50. 38 12
      src/views/home/index.vue
  51. 151 112
      src/views/information/components/brandInfo.vue
  52. 248 172
      src/views/information/components/info.vue
  53. 2 0
      src/views/information/index.vue
  54. 4 1
      src/views/mall/cart.vue
  55. 1 1
      src/views/mall/components/browse.vue
  56. 516 47
      src/views/mall/components/cart/confirmOrder.vue
  57. 23 10
      src/views/mall/components/cart/list.vue
  58. 1 0
      src/views/mall/components/header.vue
  59. 8 3
      src/views/mall/components/shopInfo.vue
  60. 210 212
      src/views/mall/components/shopMemberCard.vue
  61. 18 32
      src/views/mall/components/valueAdded.vue
  62. 51 19
      src/views/mall/data.ts
  63. 1 1
      src/views/mall/details.vue
  64. 3 0
      src/views/mall/home.vue
  65. 66 66
      src/views/mall/index.vue
  66. 385 0
      src/views/mall/membership.vue
  67. 37 0
      src/views/mall/membershipConfig.ts
  68. 199 0
      src/views/mall/sercice.vue
  69. 14 13
      src/views/order/components/InvoicePreview.vue
  70. 42 37
      src/views/order/components/invoiceModal.vue
  71. 50 15
      src/views/order/data.ts
  72. 93 43
      src/views/order/details.vue
  73. 59 115
      src/views/order/list.vue
  74. 1 1
      src/views/scene/components/cooperation.vue
  75. 61 147
      src/views/scene/components/uploadModal.vue
  76. 176 54
      src/views/scene/more.vue
  77. 16 9
      src/views/scene/obj.vue

+ 6 - 0
.env

@@ -1,8 +1,14 @@
 # cdn
 VITE_CDN = https://4dscene.4dage.com/new4dkk/
+
+# oss 对外访问域名
+VITE_OSS_PUBLIC_URL = https://4dkk.4dage.com
 VITE_PATH = https://test.4dkankan.com/
 # spa-title
 VITE_GLOB_APP_TITLE = 四维时代
 
 # spa shortname
 VITE_GLOB_APP_SHORT_NAME = 4DAGE
+
+# 区分国内/国际
+VITE_REGION = CN

+ 6 - 0
.env.cn-prod

@@ -1,8 +1,14 @@
 # cdn
 VITE_CDN = https://4dscene.4dage.com/new4dkk/
 
+# oss 对外访问域名
+VITE_OSS_PUBLIC_URL = https://4dkk.4dage.com
+
 # spa-title
 VITE_GLOB_APP_TITLE = 四维时代管理后台
 
 # spa shortname
 VITE_GLOB_APP_SHORT_NAME = 4DAGE
+
+# 区分国内/国际
+VITE_REGION = CN

+ 6 - 0
.env.cn-test

@@ -1,8 +1,14 @@
 # cdn
 VITE_CDN = https://4dscene.4dage.com/new4dkk/
 
+# oss 对外访问域名
+VITE_OSS_PUBLIC_URL = https://4dkk.4dage.com
+
 # spa-title
 VITE_GLOB_APP_TITLE = 四维时代管理后台
 
 # spa shortname
 VITE_GLOB_APP_SHORT_NAME = 4DAGE
+
+# 区分国内/国际
+VITE_REGION = CN

+ 3 - 0
.env.intl-prod

@@ -6,3 +6,6 @@ VITE_GLOB_APP_TITLE = 四维时代管理后台
 
 # spa shortname
 VITE_GLOB_APP_SHORT_NAME = 4DAGE
+
+# 区分国内/国际
+VITE_REGION = EN

+ 3 - 0
.env.intl-test

@@ -6,3 +6,6 @@ VITE_GLOB_APP_TITLE = 四维时代管理后台
 
 # spa shortname
 VITE_GLOB_APP_SHORT_NAME = 4DAGE
+
+# 区分国内/国际
+VITE_REGION = EN

+ 2 - 1
README.md

@@ -1,4 +1,5 @@
 # Vue 3 + Vite
 buildObjStatus  生成OBJ状态,0,未生成 1完成,2计算中
 buildE57Status  生成E57状态-1失败,0,未生成 1完成,2计算中
-sceneStatus     status -2 1 为正常状态、0 计算中、-1计算失败; payStatus -2 为容量过期
+sceneStatus     status -2 1 为正常状态、0 计算中、-1计算失败; payStatus -2 为容量过期
+downloadStatus 0 没下载过, 1 下载中, 2 下载过,已更新, 3,下载过,未更新, -1 下载次数不足

+ 14 - 4
src/App.vue

@@ -12,10 +12,20 @@ const route = useRoute();
 const { locale } = useI18n();
 const userStore = useUserStore();
 const cartStore = useCartStore();
-// 推导是否显示左侧菜单(优先取路由meta,默认显示)
+// 商城模块内子路由切换时保持外层布局;其余路由按 meta 控制
 const keepAlive = computed(() => {
+  if (route.path.startsWith("/mall")) {
+    return true;
+  }
   return route.meta.keepAlive ?? true;
 });
+
+const componentKey = computed(() => {
+  if (route.path.startsWith("/mall")) {
+    return "/mall";
+  }
+  return route.meta.keepAlive ? route.name ?? route.fullPath : route.fullPath;
+});
 userStore.getInfo();
 cartStore.updataCart();
 const theme = ref({
@@ -34,11 +44,11 @@ const localeobj = ref({
   <div id="app">
     <a-config-provider :theme="theme" :locale="localeobj[locale]">
       <!-- 不缓存的页面 -->
-      <router-view v-slot="{ Component, route }">
+      <router-view v-slot="{ Component }">
         <keep-alive v-if="keepAlive">
-          <component  :is="Component" :key="route.fullPath" />
+          <component :is="Component" :key="componentKey" />
         </keep-alive>
-        <component v-else :is="Component" :key="route.fullPath" />
+        <component v-else :is="Component" :key="componentKey" />
       </router-view>
     </a-config-provider>
   </div>

+ 163 - 1
src/api/equity/index.js

@@ -2,6 +2,26 @@ import request from '@/utils/http.js';
 
 const Api = {
   incrementList: '/ucenter/user/increment/findIncrementList',
+  downloadOrderList: '/ucenter/user/virtualOrder/downloadOrderList',
+  incrementDetailDown: '/ucenter/user/increment/findIncrementDetailDown',
+  incrementDetailDownDetail: '/ucenter/user/increment/findIncrementDetailDownDetail',
+  getIncrementType: '/ucenter/user/increment/getIncrementType',
+  getIncrementOrDownloadPrice: '/ucenter/user/order/getIncrementOrDownloadPrice',
+  incrementOrder: '/ucenter/user/order/incrementOrder',
+  placeOrder: '/ucenter/user/order/placeOrder',
+  downloadOrder: '/ucenter/user/order/downloadOrder',
+  queryOrderStatus: '/ucenter/user/order/queryOrderStatus',
+  orderList: '/ucenter/user/order/list',
+  incrementOrderList: '/ucenter/user/virtualOrder/incrementOrderList',
+  orderDetail: '/ucenter/user/order/detail',
+  userDownLog: '/ucenter/szy/userDownLog/list',
+  invoiceOpen: '/ucenter/user/invoice/open',
+  cameraUnbind: '/ucenter/user/camera/unbind',
+  cameraAdd: '/ucenter/user/camera/add',
+  userBrandInfo: '/ucenter/szy/userBrand/info',
+  setuserBrand: '/ucenter/szy/userBrand/saveOrUpdate',
+  deleteCooperationUser: '/ucenter/user/camera/deleteCooperationUser',
+  saveCooperationUser: '/ucenter/user/camera/saveCooperationUser',
 }
 
 /**
@@ -14,4 +34,146 @@ export const incrementList = (params) =>
       // @ts-ignore
       ignoreCancelToken: true,
     },
-  });
+  });
+export const orderList = (params) =>
+  request.post(Api.orderList, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+export const downloadOrderList = (params) =>
+  request.post(Api.downloadOrderList, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+export const incrementOrderList = (params) =>
+  request.post(Api.incrementOrderList, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+
+export const getIncrementType = (params) =>
+  request.get(Api.getIncrementType, { params });
+
+export const getIncrementOrDownloadPrice = (params) =>
+  request.post(Api.getIncrementOrDownloadPrice, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+
+export const incrementOrder = (params) =>
+  request.post(Api.incrementOrder, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+
+export const placeOrder = (params) =>
+  request.post(Api.placeOrder, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+
+export const downloadOrder = (params) =>
+  request.post(Api.downloadOrder, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+
+export const queryOrderStatus = (params) =>
+  request.post(Api.queryOrderStatus, params, {
+    allowLoading: false,
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+
+
+export const allOrderList = async (params) => {
+  let res = {}
+  if(params.orderType == 0){
+    res = await incrementOrderList(params)
+  }else if(params.orderType == 1){
+    res = await orderList(params)
+  }else{
+    res = await downloadOrderList(params)
+  }
+  let list = res && res.list.map(item => {
+    return {
+      ...item,
+      orderType: params.orderType
+    }
+  })
+  
+  return {
+    ...res,
+    list: list,
+  }
+}
+
+export const invoiceOpen = (params) =>
+  request.post(Api.invoiceOpen, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+export const cameraUnbind = (params) =>
+  request.post(Api.cameraUnbind, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+
+export const cameraAdd = (params) =>
+  request.post(Api.cameraAdd, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+export const saveCooperationUser = (params) =>
+  request.post(Api.saveCooperationUser, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+export const deleteCooperationUser = (params) =>
+  request.post(Api.deleteCooperationUser, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+  
+export const userDownLog = (params) =>
+  request.post(Api.userDownLog, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });export const setuserBrand = (params) =>
+  request.post(Api.setuserBrand, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+  
+export const userBrandInfo = (params) =>
+  request.get(Api.userBrandInfo, { params });

+ 98 - 2
src/api/login/index.js

@@ -3,18 +3,24 @@ import request from '@/utils/http.js';
 const Api = {
   login: '/ucenter/sso/user/login',
   quickLogin: '/ucenter/sso/user/quickLogin',
+  register: '/ucenter/sso/user/register',
   logout: '/ucenter/sso/user/logout',
   getUserInfo: '/ucenter/user/getUserInfo',
   checkUser: '/ucenter/sso/user/checkUser',
-  getMsgAuthCode: '/ucenter/sso/user/getMsgAuthCode',
+  getAuthCode: '/ucenter/sso/user/getMsgAuthCode',
   sendUserInfo: '/ucenter/sso/user/sendUserInfo',
   createLoginQrCode: '/ucenter/sso/user/createLoginQrCode',
   cartlist: '/ucenter/user/cart/list',
   receiverInfo: '/ucenter/user/getReceiverInfo',
   updateAddress: '/ucenter/user/updateAddress',
   cartAdd: '/ucenter/user/cart/add',
+  cartRemove: '/ucenter/user/cart/remove',
   mapSceneList: '/ucenter/szy/indexSceneList',
   cameraDetail: '/ucenter/user/camera/detail',
+  updateUserDetail: 'ucenter/user/updateUserDetail',
+  checkSms: '/ucenter/sso/user/checkSms',
+  changePassword: '/ucenter/sso/user/changePassword',
+  userDetail: '/ucenter/user/detail',
 }
 
 /**
@@ -27,13 +33,20 @@ export const userlogin = (params) =>
       // @ts-ignore
       ignoreCancelToken: true,
     },
-  }); export const quickLogin = (params) =>
+    }); export const quickLogin = (params) =>
     request.post(Api.quickLogin, params, {
       headers: {
         // @ts-ignore
         ignoreCancelToken: true,
       },
     });
+export const userRegister = (params) =>
+  request.post(Api.register, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
 export const userlogout = (params) =>
   request.post(Api.logout, params, {
     headers: {
@@ -98,6 +111,13 @@ export const cartAdd = (params) =>
       ignoreCancelToken: true,
     },
   });
+export const cartRemove = (params) =>
+  request.post(Api.cartRemove, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
   export const mapSceneList = (params) =>
   request.post(Api.mapSceneList, params, {
     allowLoading: false,
@@ -115,6 +135,82 @@ export const cartAdd = (params) =>
       ignoreCancelToken: true,
     },
   });
+  export const updateUserDetail = (params) =>
+  request.post(Api.updateUserDetail, params, {
+    allowLoading: false,
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+  export const changePassword = (params) =>
+  request.post(Api.changePassword, params, {
+    allowLoading: false,
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+  
+  export const checkSms = (msgAuthCode) =>
+    request.post(Api.checkSms, {msgAuthCode}, {
+      allowLoading: false,
+      isReturnNativeResponse: true,
+      headers: {
+        // @ts-ignore
+        ignoreCancelToken: true,
+      },
+    });
+    export const checkUser = (phoneNum) =>
+    request.post(Api.checkUser, { phoneNum }, {
+      allowLoading: false,
+      isReturnNativeResponse: true,
+      headers: {
+        // @ts-ignore
+        ignoreCancelToken: true,
+      },
+    });
+
+  const requestMsgAuthCode = (params) =>
+    request.post(Api.getAuthCode, params, {
+      allowLoading: false,
+      isReturnNativeResponse: true,
+      headers: {
+        // @ts-ignore
+        ignoreCancelToken: true,
+      },
+    });
+
+  /**
+   * 获取短信验证码:先校验用户是否存在,再发送验证码
+   * @param {Object} params
+   * @param {string} params.phoneNum|params.phone 手机号
+   * @param {string|number} params.areaNum 区号
+   * @param {'login'|'register'} [params.scene='login'] login-code为0发送;register-code为3015发送
+   */
+  export const getAuthCode = async (params = {}) => {
+    const phoneNum = params.phoneNum || params.phone;
+    const areaNum = params.areaNum;
+    const scene = params.scene || 'login';
+
+    const checkRes = await checkUser(phoneNum);
+    if (!checkRes) return checkRes;
+
+    const checkCode = Number(checkRes.code);
+
+    if (scene === 'register') {
+      if (checkCode !== 3015) {
+        return checkRes;
+      }
+    } else if (checkCode !== 0) {
+      return checkRes;
+    }
+
+    return requestMsgAuthCode({
+      phoneNum: Number(phoneNum),
+      areaNum: Number(areaNum),
+    });
+  };
 
 //权益详情
 export const createLoginQrCode = (params) =>

+ 30 - 0
src/api/mall/index.js

@@ -0,0 +1,30 @@
+import request from '@/utils/http.js';
+
+const Api = {
+  serviceConfigList: '/ucenter/szyIndex/serviceConfig/list',
+  userSerciceList: '/ucenter/szy/userSercice/userList',
+  userSerciceBuy: '/ucenter/szy/userSercice/buy',
+}
+
+/**
+ * @description: Get sample list value
+ */
+
+export const incrementList = (params) =>
+  request.post(Api.incrementList, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });
+export const serviceConfigList = (params) =>
+  request.get(Api.serviceConfigList, { params });
+export const userSerciceList = (params) =>
+  request.get(Api.userSerciceList, { params });
+export const userSerciceBuy = (params) =>
+  request.post(Api.userSerciceBuy, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  });

+ 69 - 5
src/api/scene/index.js

@@ -1,4 +1,5 @@
 import request from '@/utils/http.js';
+import { useUserStore } from "@/stores/user";
 
 const Api = {
   sceneList: '/ucenter/szy/sceneList',
@@ -9,11 +10,18 @@ const Api = {
   findListTree: 'ucenter/szy/folder/findListTree',
   findListByName: 'ucenter/szy/folder/findListByName',
   updateFolder: 'ucenter/szy/folder/update',
+  //下载e57
   checkDownLoadE57: 'ucenter/user/scene/checkDownLoadE57',
-  downSceneE57: '/user/scene/downSceneE57',
-  downloadProcessE57: '/user/scene/downloadProcessE57',
+  downSceneE57: 'ucenter/user/scene/downSceneE57',
+  downloadProcessE57: 'ucenter/user/scene/downloadProcessE57',
+  //下载离线包
+  checkDownload: 'ucenter/user/scene/checkDownload',
+  downloadScene: 'ucenter/user/scene/downloadScene',
+  downloadProcess: 'ucenter/user/scene/downloadProcess',
+
   getUploadUrl: 'ucenter/user/scene/getUploadUrl',
   getStsSignature: 'ucenter/user/scene/getStsSignature',
+  relevanceE57: 'ucenter/user/scene/relevanceE57List',
   assumeRole: 'cloud-acl/sts/assumeRole',
   copyScene: 'ucenter/user/scene/copyScene',
   sceneDetail: 'ucenter/szy/sceneDetail',
@@ -24,6 +32,12 @@ const Api = {
   cooperation: 'ucenter/product/getByKey/cooperation',
   saveCamera: 'ucenter/product/getFreeCount/',
 
+  bindCamera: 'ucenter/user/increment/bindCamera',
+  unbindCamera: 'ucenter/user/increment/unbindCamera',
+  unbindCamera: 'ucenter/user/increment/unbindCamera',
+  unbindCamera: 'ucenter/user/increment/unbindCamera',
+  cancelSubscription: 'ucenter/user/increment/cancelSubscription/',
+  cameraList: '/ucenter/user/camera/listNew' 
 }
 
 /**
@@ -134,10 +148,25 @@ export const buildSceneObj = (params) =>
       ignoreCancelToken: true,
     },
   });
+  
+export const relevanceE57 = (params) =>
+  request.post(Api.relevanceE57, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+      'Content-Type': 'application/json'
+    },
+  });
+  
 export const getStsSignature = (params) =>
   request.get(Api.getStsSignature, { params, isReturnNativeResponse: false, allowLoading: false });
-export const assumeRole = (params) =>
-  request.get(Api.assumeRole, { params, isReturnNativeResponse: false, allowLoading: false });
+export const assumeRole = (params, signature) =>
+  request.get(Api.assumeRole, { params, 
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+      signature: signature,
+    }, });
 export const cooperation = (params) =>
   request.get(Api.cooperation, { params });
 export const saveCamera = (params) =>
@@ -147,7 +176,16 @@ export const checkDownLoadE57 = (params) =>
 export const downSceneE57 = (params) =>
   request.get(Api.downSceneE57, { params });
 export const downloadProcessE57 = (params) =>
-  request.get(Api.downloadProcessE57, { params });
+  request.get(Api.downloadProcessE57, { params, allowLoading: false });
+
+
+export const checkDownload = (params) =>
+  request.get(Api.checkDownload, { params });
+export const downloadScene = (params) =>
+  request.post(Api.downloadScene, params);
+export const downloadProcess = (params) =>
+  // 下载处理结果
+  request.post(Api.downloadProcess, params, { allowLoading: false });
 export const saveBatch = (params) =>
   request.post(Api.saveBatch, params, {
     headers: {
@@ -155,3 +193,29 @@ export const saveBatch = (params) =>
       ignoreCancelToken: true,
     },
   });
+
+  
+export const bindCamera = (params) => {
+  const userStore = useUserStore();
+  return request.post(Api.bindCamera, params).then(res => {
+    userStore.getInfo();
+    return res;
+  });
+}
+export const unbindCamera = (params) => {
+  const userStore = useUserStore();
+  return request.post(Api.unbindCamera, params).then(res => {
+    userStore.getInfo();
+    return res;
+  });
+}
+
+export const cancelSubscription = (incrementId) =>
+  request.get(Api.cancelSubscription + incrementId, { incrementId });
+export const cameraList = (params) =>
+  request.post(Api.cameraList, params, {
+    headers: {
+      // @ts-ignore
+      ignoreCancelToken: true,
+    },
+  })

Разница между файлами не показана из-за своего большого размера
+ 75 - 29
src/assets/font/demo_index.html


Разница между файлами не показана из-за своего большого размера
+ 18 - 10
src/assets/font/iconfont.css


BIN
src/assets/font/iconfont.eot


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
src/assets/font/iconfont.js


+ 21 - 7
src/assets/font/iconfont.json

@@ -6,6 +6,27 @@
   "description": "",
   "glyphs": [
     {
+      "icon_id": "47986281",
+      "name": "common_toolbar_subscribe",
+      "font_class": "common_toolbar_subscribe",
+      "unicode": "e7a1",
+      "unicode_decimal": 59297
+    },
+    {
+      "icon_id": "47986280",
+      "name": "common_toolbar_unsubscribe",
+      "font_class": "common_toolbar_unsubscribe",
+      "unicode": "e7a6",
+      "unicode_decimal": 59302
+    },
+    {
+      "icon_id": "47878955",
+      "name": "common_toolbar_e57",
+      "font_class": "common_toolbar_e57",
+      "unicode": "e7a5",
+      "unicode_decimal": 59301
+    },
+    {
       "icon_id": "47876058",
       "name": "common_toolbar_calculating",
       "font_class": "common_toolbar_calculating",
@@ -314,13 +335,6 @@
       "unicode_decimal": 59296
     },
     {
-      "icon_id": "47875933",
-      "name": "common_toolbar_renew",
-      "font_class": "common_toolbar_renew",
-      "unicode": "e7a1",
-      "unicode_decimal": 59297
-    },
-    {
       "icon_id": "47875934",
       "name": "settings_add",
       "font_class": "settings_add",

Разница между файлами не показана из-за своего большого размера
+ 6 - 2
src/assets/font/iconfont.svg


BIN
src/assets/font/iconfont.ttf


BIN
src/assets/font/iconfont.woff


BIN
src/assets/font/iconfont.woff2


+ 1 - 0
src/assets/style/index.less

@@ -93,6 +93,7 @@
     margin-bottom: 26px;
   }
   .ant-modal-footer {
+    margin-top: 40px;
     .ant-btn {
       min-width: 88px;
     }

+ 25 - 2
src/components/BaseTable/index.vue

@@ -12,6 +12,7 @@
       :bordered="bordered"
       :pagination="{...innerPagination, total}"
       v-bind="$attrs"
+      :row-selection="props.rowSelectionOptions && { selectedRowKeys: state.selectedRowKeys, onChange: onSelectChange, getCheckboxProps: onGetCheckboxProps }"
       @change="onTableChange"
     >
       <!-- 自定义单元格插槽 -->
@@ -29,7 +30,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref, defineProps, defineEmits } from 'vue'
+import { ref, reactive, defineProps, defineEmits } from 'vue'
 import EmptyData from '../EmptyData/index.vue'
 const props = defineProps({
   columns: {
@@ -56,6 +57,10 @@ const props = defineProps({
     type: Number,
     default: 0
   },
+  rowSelectionOptions: {
+    type: Object,
+    default: ()=>{}
+  },
   // 默认每页条数
   defaultPageSize: {
     type: Number,
@@ -64,10 +69,26 @@ const props = defineProps({
 })
 
 const emit = defineEmits(['search'])
-
 // ========== 分页完全内部变量,父组件不用管理 ==========
 const pageNum = ref(1)
 const pageSize = ref(props.defaultPageSize)
+const state = reactive({
+  selectedRowKeys: [],
+  // Check here to configure the default column
+});
+const onSelectChange = (selectedRowKeys, selectedRows) => {
+  console.log('selectedRowKeys changed: ', selectedRowKeys);
+  state.selectedRowKeys = selectedRowKeys;
+  if(props.rowSelectionOptions?.onChange){
+    return props.rowSelectionOptions.onChange(selectedRowKeys, selectedRows)
+  }
+};
+const onGetCheckboxProps = (record) => {
+  if(props.rowSelectionOptions?.getCheckboxProps){
+    return props.rowSelectionOptions.getCheckboxProps(record)
+  }
+  return false
+};
 
 // 分页配置
 const innerPagination = ref({
@@ -78,6 +99,7 @@ const innerPagination = ref({
   // showTotal: (t: number) => `共${t}条数据`,
   pageSizeOptions: ['10','20','50','100']
 })
+// 新增:清空表格选中状态
 
 // 页码、每页条数改变,通知父请求接口
 const onTableChange = (pag: any) => {
@@ -91,6 +113,7 @@ const onTableChange = (pag: any) => {
 // 【对外重置页码方法:父调用重置到第一页】
 const resetPage = () => {
   innerPagination.value.current = 1
+  state.selectedRowKeys = []
   emit('search', innerPagination.value)
 }
 const refresh = () => {

+ 4 - 0
src/hooks/index.js

@@ -0,0 +1,4 @@
+export { useOssUpload, buildOssFileUrl, toRelativeOssPath, default as useOssUploadDefault } from './useOssUpload';
+export { useMembershipPay, DEFAULT_SUCCESS_REDIRECT } from './useMembershipPay';
+export { useCartPay } from './useCartPay';
+export { useDownloadPay } from './useDownloadPay';

+ 125 - 0
src/hooks/useCartPay.js

@@ -0,0 +1,125 @@
+import { ref, onUnmounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+import { message } from 'ant-design-vue'
+import { useCartStore, useUserStore } from '@/stores'
+import { placeOrder, queryOrderStatus } from '@/api/equity/index.js'
+
+const POLL_INTERVAL = 5000
+const DEFAULT_SUCCESS_REDIRECT = { name: 'mallIndex' }
+
+const unwrap = (val, fallback = '') =>
+  val && typeof val === 'object' && 'value' in val ? val.value : val ?? fallback
+
+const getPaymentBaseUrl = () => {
+  const envPath = import.meta.env.VITE_PATH
+  if (envPath) return envPath.replace(/\/$/, '')
+  return window.location.hostname.includes('test')
+    ? 'https://test.4dkankan.com'
+    : 'https://www.4dkankan.com'
+}
+
+/** 购物车确认订单:下单、跳转支付、轮询支付结果 */
+export function useCartPay(options = {}) {
+  const router = useRouter()
+  const { t } = useI18n()
+  const cartStore = useCartStore()
+  const userStore = useUserStore()
+
+  const orderSn = ref('')
+  const submitting = ref(false)
+
+  let pollTimer = null
+
+  const stopPolling = () => {
+    if (pollTimer) {
+      clearInterval(pollTimer)
+      pollTimer = null
+    }
+  }
+
+  const jumpToPayment = (sn) => {
+    if (!sn) return
+    window.open(`${getPaymentBaseUrl()}/payment/?id=${sn}`)
+  }
+
+  const checkOrderStatus = async () => {
+    if (!orderSn.value) return false
+    try {
+      const paid = await queryOrderStatus({ orderSn: orderSn.value })
+      if (paid) {
+        stopPolling()
+        await cartStore.updataCart()
+        await userStore.getInfo()
+        message.success(t('mall.paySuccess'))
+        await options.onPaySuccess?.()
+        const target = options.successRedirect ?? DEFAULT_SUCCESS_REDIRECT
+        if (target) {
+          router.push(target)
+        }
+        return true
+      }
+    } catch {
+      // 轮询中忽略单次失败
+    }
+    return false
+  }
+
+  const startPolling = () => {
+    stopPolling()
+    pollTimer = setInterval(checkOrderStatus, POLL_INTERVAL)
+  }
+
+  const submitOrder = async () => {
+    if (submitting.value) return false
+
+    const goods =
+      typeof options.getGoods === 'function'
+        ? options.getGoods()
+        : unwrap(options.getGoods, [])
+    const receiver =
+      typeof options.getReceiver === 'function'
+        ? options.getReceiver()
+        : unwrap(options.getReceiver, {})
+
+    if (!goods.length) {
+      message.warn(t('mall.noGoodsConfirm'))
+      return false
+    }
+    if (!receiver?.id || !receiver?.shipMobile) {
+      message.warn(t('mall.addAddressTip'))
+      return false
+    }
+
+    submitting.value = true
+    stopPolling()
+    try {
+      const data = await placeOrder({
+        goods,
+        receiver,
+        invoice: null,
+        payType: 0,
+        abroad: import.meta.env.VITE_REGION === 'EN' ? 1 : 0,
+      })
+      orderSn.value = data?.orderSn ?? ''
+      // await cartStore.updataCart()
+      startPolling()
+      jumpToPayment(orderSn.value)
+      return true
+    } finally {
+      submitting.value = false
+    }
+  }
+
+  onUnmounted(stopPolling)
+
+  return {
+    orderSn,
+    submitting,
+    submitOrder,
+    jumpToPayment,
+    startPolling,
+    stopPolling,
+    checkOrderStatus,
+  }
+}

+ 165 - 0
src/hooks/useDownloadPay.js

@@ -0,0 +1,165 @@
+import { ref, onUnmounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+import { message } from 'ant-design-vue'
+import { useUserStore } from '@/stores'
+import {
+  getIncrementOrDownloadPrice,
+  downloadOrder,
+  queryOrderStatus,
+} from '@/api/equity/index.js'
+
+const POLL_INTERVAL = 5000
+const DOWNLOAD_ORDER_TYPE = 5
+
+const unwrap = (val, fallback = '') =>
+  val && typeof val === 'object' && 'value' in val ? val.value : val ?? fallback
+
+const getPaymentBaseUrl = () => {
+  const envPath = import.meta.env.VITE_PATH
+  if (envPath) return envPath.replace(/\/$/, '')
+  return window.location.hostname.includes('test')
+    ? 'https://test.4dkankan.com'
+    : 'https://www.4dkankan.com'
+}
+
+/** 离线包/场景下载:查价、下单、跳转支付、轮询结果(参考 downloadModal) */
+export function useDownloadPay(options = {}) {
+  const router = useRouter()
+  const { t } = useI18n()
+  const userStore = useUserStore()
+
+  const skuSn = ref('')
+  const unitPrice = ref(0)
+  const orderSn = ref('')
+  const loading = ref(false)
+  const submitting = ref(false)
+  const paymentPending = ref(false)
+
+  let pollTimer = null
+
+  const getDateType = () => unwrap(options.dateType, '6')
+  const getSceneNum = () => unwrap(options.sceneNum, '')
+  const getSceneName = () => unwrap(options.sceneName, '')
+  const getCount = () => Number(unwrap(options.count, 1)) || 1
+
+  const stopPolling = () => {
+    if (pollTimer) {
+      clearInterval(pollTimer)
+      pollTimer = null
+    }
+  }
+
+  const jumpToPayment = (sn) => {
+    if (!sn) return
+    window.open(`${getPaymentBaseUrl()}/payment/?id=${sn}`)
+  }
+
+  const fetchPrice = async () => {
+    loading.value = true
+    try {
+      const data = await getIncrementOrDownloadPrice({
+        dateType: getDateType(),
+        userIncrementId: '',
+      })
+      skuSn.value = data?.skuSn ?? ''
+      unitPrice.value = Number(data?.price ?? 0)
+      return data
+    } finally {
+      loading.value = false
+    }
+  }
+
+  const checkOrderStatus = async (showFailTip = false) => {
+    if (!orderSn.value) return false
+    try {
+      const paid = await queryOrderStatus({
+        orderSn: orderSn.value,
+        orderType: DOWNLOAD_ORDER_TYPE,
+        payType: 0,
+      })
+      if (paid) {
+        stopPolling()
+        paymentPending.value = false
+        await userStore.getInfo()
+        message.success(t('mall.paySuccess'))
+        await options.onPaySuccess?.()
+        const target = options.successRedirect
+        if (target) {
+          router.push(target)
+        }
+        return true
+      }
+      if (showFailTip) {
+        message.error(t('toast.8002'))
+        paymentPending.value = false
+        stopPolling()
+      }
+    } catch {
+      if (showFailTip) {
+        message.error(t('toast.8002'))
+        paymentPending.value = false
+        stopPolling()
+      }
+    }
+    return false
+  }
+
+  const startPolling = () => {
+    stopPolling()
+    pollTimer = setInterval(() => checkOrderStatus(false), POLL_INTERVAL)
+  }
+
+  const submitOrder = async () => {
+    if (submitting.value || !skuSn.value) return false
+
+    submitting.value = true
+    stopPolling()
+    try {
+      const data = await downloadOrder({
+        payType: 0,
+        sceneNum: getSceneNum(),
+        sceneName: getSceneName(),
+        skuSn: skuSn.value,
+        count: getCount(),
+        abroad: import.meta.env.VITE_REGION === 'EN' ? 1 : 0,
+      })
+      orderSn.value = data?.orderSn ?? ''
+      paymentPending.value = true
+      startPolling()
+      jumpToPayment(orderSn.value)
+      return true
+    } finally {
+      submitting.value = false
+    }
+  }
+
+  const confirmPaid = () => checkOrderStatus(true)
+
+  const openPayment = (sn) => {
+    if (!sn) return
+    orderSn.value = sn
+    paymentPending.value = true
+    startPolling()
+    jumpToPayment(sn)
+  }
+
+  onUnmounted(stopPolling)
+
+  return {
+    skuSn,
+    unitPrice,
+    orderSn,
+    loading,
+    submitting,
+    paymentPending,
+    fetchPrice,
+    submitOrder,
+    confirmPaid,
+    openPayment,
+    jumpToPayment,
+    startPolling,
+    stopPolling,
+    checkOrderStatus,
+  }
+}

+ 176 - 0
src/hooks/useMembershipPay.js

@@ -0,0 +1,176 @@
+import { ref, computed, onUnmounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+import { message } from 'ant-design-vue'
+import { useUserStore } from '@/stores'
+import {
+  getIncrementOrDownloadPrice,
+  incrementOrder,
+  queryOrderStatus,
+} from '@/api/equity/index.js'
+
+const POLL_INTERVAL = 5000
+const DEFAULT_SUCCESS_REDIRECT = '/information'
+
+const unwrap = (val, fallback = '') =>
+  val && typeof val === 'object' && 'value' in val ? val.value : val ?? fallback
+
+/** 解析支付成功跳转目标:string | route 对象 | 函数 | false 不跳转 */
+const resolveSuccessRedirect = (successRedirect) => {
+  const redirect = unwrap(successRedirect, DEFAULT_SUCCESS_REDIRECT)
+  if (redirect === false) return null
+  if (typeof redirect === 'function') return redirect() ?? null
+  return redirect || DEFAULT_SUCCESS_REDIRECT
+}
+
+const formatDate = (value) => {
+  if (!value) return ''
+  const date = new Date(value)
+  if (Number.isNaN(date.getTime())) return String(value)
+  const y = date.getFullYear()
+  const m = String(date.getMonth() + 1).padStart(2, '0')
+  const d = String(date.getDate()).padStart(2, '0')
+  return `${y}-${m}-${d}`
+}
+
+const getPaymentBaseUrl = () => {
+  const envPath = import.meta.env.VITE_PATH
+  if (envPath) return envPath.replace(/\/$/, '')
+  return window.location.hostname.includes('test')
+    ? 'https://test.4dkankan.com'
+    : 'https://www.4dkankan.com'
+}
+
+/** 会员购买/续费:查价、下单、轮询、跳转支付 */
+export function useMembershipPay(options = {}) {
+  const router = useRouter()
+  const { t } = useI18n()
+  const userStore = useUserStore()
+
+  const unwrapLocal = (val, fallback = '') =>
+    val && typeof val === 'object' && 'value' in val ? val.value : val ?? fallback
+
+  const getIncrementTypeId = () => unwrapLocal(options.incrementTypeId)
+  const getIncrementId = () => unwrapLocal(options.incrementId)
+  const getDateType = () => unwrapLocal(options.dateType, '5')
+  const getMonthQy = () => Number(unwrapLocal(options.monthQy, 1)) || 1
+
+  const count = options.count ?? ref(1)
+  const getAutoRenew = () => unwrapLocal(options.autoRenew, false)
+  const abroad = ref(
+    options.abroad ?? (import.meta.env.VITE_REGION === 'EN' ? 1 : 0)
+  )
+
+  const skuSn = ref('')
+  const deadLine = ref('')
+  const orderSn = ref('')
+  const orderInfo = ref({})
+  const loading = ref(false)
+  const submitting = ref(false)
+
+  let pollTimer = null
+
+  const maxCount = computed(() => (getIncrementId() ? 1 : 100))
+
+  const stopPolling = () => {
+    if (pollTimer) {
+      clearInterval(pollTimer)
+      pollTimer = null
+    }
+  }
+
+  const jumpToPayment = (sn) => {
+    if (!sn) return
+    const baseUrl = getPaymentBaseUrl()
+    window.open(`${baseUrl}/payment/?id=${sn}`)
+  }
+
+  const fetchPrice = async () => {
+    const typeId = getIncrementTypeId()
+    if (!typeId) return null
+    loading.value = true
+    try {
+      const data = await getIncrementOrDownloadPrice({
+        dateType: getDateType(),
+        incrementTypeId: typeId,
+        userIncrementId: getIncrementId() || '',
+        monthQy: getMonthQy(),
+      })
+      skuSn.value = data?.skuSn ?? ''
+      deadLine.value = data?.deadLine ? formatDate(data.deadLine) : ''
+      return data
+    } finally {
+      loading.value = false
+    }
+  }
+
+  const checkOrderStatus = async () => {
+    if (!orderSn.value) return false
+    try {
+      const paid = await queryOrderStatus({ orderSn: orderSn.value })
+      if (paid) {
+        stopPolling()
+        await userStore.getInfo()
+        message.success(t('mall.member.buySuccessTip'))
+        await options.onPaySuccess?.()
+        const target = resolveSuccessRedirect(options.successRedirect)
+        if (target) {
+          router.push(target)
+        }
+        return true
+      }
+    } catch {
+      // 轮询中忽略单次失败
+    }
+    return false
+  }
+
+  const startPolling = () => {
+    stopPolling()
+    pollTimer = setInterval(checkOrderStatus, POLL_INTERVAL)
+  }
+
+  const submitOrder = async () => {
+    if (submitting.value || !skuSn.value) return
+    submitting.value = true
+    stopPolling()
+    try {
+      const data = await incrementOrder({
+        incrementId: getIncrementId() || '',
+        skuSn: skuSn.value,
+        count: count.value,
+        incrementTypeId: getIncrementTypeId(),
+        abroad: abroad.value,
+        continuous: getAutoRenew() ? 1 : 0,
+        monthQy: getMonthQy(),
+      })
+      orderSn.value = data?.orderSn ?? ''
+      orderInfo.value = data ?? {}
+      startPolling()
+      jumpToPayment(orderSn.value)
+    } finally {
+      submitting.value = false
+    }
+  }
+
+  onUnmounted(stopPolling)
+
+  return {
+    count,
+    skuSn,
+    deadLine,
+    orderSn,
+    orderInfo,
+    loading,
+    submitting,
+    maxCount,
+    fetchPrice,
+    submitOrder,
+    jumpToPayment,
+    startPolling,
+    stopPolling,
+    checkOrderStatus,
+  }
+}
+
+export { DEFAULT_SUCCESS_REDIRECT }

+ 355 - 0
src/hooks/useOssUpload.js

@@ -0,0 +1,355 @@
+import { ref, computed } from 'vue';
+import { message } from 'ant-design-vue';
+import OSS from 'ali-oss';
+import { getStsSignature, assumeRole } from '@/api/scene';
+import { autoFormatKbSplit, buildShortUUID } from '@/utils/index';
+
+const DEFAULT_OPTIONS = {
+  storageKey: 'oss_upload_checkpoints',
+  pathPrefix: '/ucenter/e57/',
+  allowedExt: ['.e57', '.las', '.ply', '.zip'],
+
+  allowedTypes: ['application/zip', 'application/x-zip-compressed'],
+  partSize: 5 * 1024 * 1024,
+  parallel: 3,
+  ossConfig: {
+    region: 'oss-cn-shenzhen',
+    bucket: '4dkankan',
+    appCode: 'd8cef8919d5744669786802b67f2bf31',
+    timeout: 30000,
+    refreshSTSTokenInterval: 30000000,
+  },
+  showMessage: true,
+  maxSize: 0,
+  publicDomain: import.meta.env.VITE_OSS_PUBLIC_URL || 'https://4dkk.4dage.com',
+};
+
+export const buildOssFileUrl = (ossPath, publicBaseUrl) => {
+  if (!ossPath) return '';
+  if (/^https?:\/\//i.test(ossPath)) return ossPath;
+  const base = (publicBaseUrl || import.meta.env.VITE_OSS_PUBLIC_URL || 'https://4dkk.4dage.com').replace(/\/$/, '');
+  const path = ossPath.startsWith('/') ? ossPath : `/${ossPath}`;
+  return `${base}${path}`;
+};
+
+export const toRelativeOssPath = (urlOrPath, publicBaseUrl) => {
+  if (!urlOrPath) return '';
+  if (urlOrPath.startsWith('/') && !urlOrPath.startsWith('//')) return urlOrPath;
+
+  const base = (publicBaseUrl || import.meta.env.VITE_OSS_PUBLIC_URL || 'https://4dkk.4dage.com').replace(/\/$/, '');
+  if (urlOrPath.startsWith(base)) {
+    const path = urlOrPath.slice(base.length);
+    return path.startsWith('/') ? path : `/${path}`;
+  }
+
+  try {
+    const { pathname } = new URL(urlOrPath);
+    return pathname || urlOrPath;
+  } catch {
+    return urlOrPath;
+  }
+};
+
+/**
+ * OSS 分片上传 Hook,支持断点续传,可在任意组件中复用
+ * @param {Object} options 上传配置
+ */
+export function useOssUpload(options = {}) {
+  const config = { ...DEFAULT_OPTIONS, ...options, ossConfig: { ...DEFAULT_OPTIONS.ossConfig, ...options.ossConfig } };
+
+  const uploadList = ref([]);
+  const activeUploads = new Map();
+  const cancelledUids = new Set();
+
+  const isUploading = computed(() => uploadList.value.some((item) => item.status === 'uploading'));
+  const hasDoneFiles = computed(() => uploadList.value.some((item) => item.status === 'done'));
+
+  const getCheckpointKey = (fileName, fileSize) => `${config.storageKey}::${fileName}_${fileSize}`;
+
+  const getCheckpointStore = () => {
+    try {
+      return JSON.parse(localStorage.getItem(config.storageKey) || '{}');
+    } catch {
+      return {};
+    }
+  };
+
+  const loadCheckpointMeta = (key) => getCheckpointStore()[key];
+
+  const persistCheckpoint = (key, checkpoint, extra = {}) => {
+    if (!checkpoint?.uploadId) return;
+    const store = getCheckpointStore();
+    store[key] = {
+      name: checkpoint.name,
+      fileSize: checkpoint.fileSize,
+      partSize: checkpoint.partSize,
+      uploadId: checkpoint.uploadId,
+      doneParts: checkpoint.doneParts,
+      originalFileName: extra.originalFileName,
+      newFileName: extra.newFileName,
+      savedAt: Date.now(),
+    };
+    localStorage.setItem(config.storageKey, JSON.stringify(store));
+  };
+
+  const removeCheckpoint = (key) => {
+    const store = getCheckpointStore();
+    delete store[key];
+    localStorage.setItem(config.storageKey, JSON.stringify(store));
+  };
+
+  const getPendingCheckpointCount = () => Object.keys(getCheckpointStore()).length;
+
+  const notify = (type, content) => {
+    if (!config.showMessage) return;
+    message[type](content);
+  };
+
+  const updateFileProgress = (percents, file) => {
+    const percent = Math.floor(percents * 100);
+    uploadList.value = uploadList.value.map((item) => {
+      if (item.uid === file.uid) {
+        return {
+          ...item,
+          name: file.name,
+          percent,
+          status: percents < 1 ? 'uploading' : 'done',
+          progress: autoFormatKbSplit(file.size * percents),
+        };
+      }
+      return item;
+    });
+  };
+
+  const markUploadError = (file, err) => {
+    uploadList.value = uploadList.value.map((item) =>
+      item.uid === file.uid ? { ...item, status: 'error' } : item
+    );
+    activeUploads.delete(file.uid);
+    console.error('upload error', err);
+  };
+
+  async function getSTSToken() {
+    const param = {
+      appCode: config.ossConfig.appCode,
+      timestamp: Math.floor(Date.now() / 1000),
+    };
+    const signature = await getStsSignature(param);
+    return assumeRole(param, signature);
+  }
+
+  async function initOSS() {
+    const sts = await getSTSToken();
+    return new OSS({
+      region: config.ossConfig.region,
+      bucket: config.ossConfig.bucket,
+      accessKeyId: sts.accessKeyId,
+      accessKeySecret: sts.accessKeySecret,
+      stsToken: sts.securityToken,
+      expiration: sts.expiration,
+      refreshSTSToken: getSTSToken,
+      refreshSTSTokenInterval: config.ossConfig.refreshSTSTokenInterval,
+      timeout: config.ossConfig.timeout,
+    });
+  }
+
+  const buildOssPath = (newFileName) => {
+    const prefix = config.pathPrefix.endsWith('/') ? config.pathPrefix : `${config.pathPrefix}/`;
+    return `${prefix}${newFileName}`;
+  };
+
+  const startMultipartUpload = async (file, callbacks = {}) => {
+    const { onSuccess, onError } = callbacks;
+    const rawFile = file.originFileObj || file;
+    const { size, name, uid } = file;
+    const checkpointKey = getCheckpointKey(name, size);
+    const savedMeta = loadCheckpointMeta(checkpointKey);
+    const ext = name.includes('.') ? name.substring(name.lastIndexOf('.')) : '';
+    const newFileName = savedMeta?.newFileName || `${buildShortUUID()}${ext}`;
+    const filePath = savedMeta?.name || buildOssPath(newFileName);
+    const resumeCheckpoint = savedMeta?.uploadId ? { ...savedMeta, file: rawFile } : undefined;
+
+    if (resumeCheckpoint) {
+      notify('info', `${name} 检测到未完成上传,正在续传...`);
+    }
+
+    file.status = 'uploading';
+    updateFileProgress(
+      resumeCheckpoint?.doneParts?.length
+        ? resumeCheckpoint.doneParts.length /
+            (Math.ceil(size / (resumeCheckpoint.partSize || config.partSize)) + 1)
+        : 0,
+      file
+    );
+
+    try {
+      const client = await initOSS();
+      activeUploads.set(uid, { client, checkpointKey });
+
+      const uploadOnce = async (checkpoint) =>
+        client.multipartUpload(filePath, rawFile, {
+          checkpoint,
+          progress: (percent, cpt) => {
+            updateFileProgress(percent, file);
+            persistCheckpoint(checkpointKey, cpt, {
+              originalFileName: name,
+              newFileName,
+            });
+          },
+          partSize: config.partSize,
+          parallel: config.parallel,
+        });
+
+      let result;
+      try {
+        result = await uploadOnce(resumeCheckpoint);
+      } catch (err) {
+        if (err?.name === 'abort') {
+          removeCheckpoint(checkpointKey);
+          result = await uploadOnce();
+        } else {
+          throw err;
+        }
+      }
+
+      file.newFileName = newFileName;
+      file.ossPath = filePath;
+      file.url = buildOssFileUrl(filePath, config.publicDomain);
+      file.originFileObj = rawFile;
+      removeCheckpoint(checkpointKey);
+      activeUploads.delete(uid);
+      updateFileProgress(1, file);
+      config.onUploadSuccess?.(file, result);
+      onSuccess?.({ code: 0, ...result });
+      return result;
+    } catch (err) {
+      if (cancelledUids.has(uid)) {
+        cancelledUids.delete(uid);
+        activeUploads.delete(uid);
+        return;
+      }
+      markUploadError(file, err);
+      notify('error', err?.name === 'cancel' ? '上传已取消' : `${name} 上传失败,可点击续传重试`);
+      config.onUploadError?.(file, err);
+      onError?.(err);
+      throw err;
+    }
+  };
+
+  const validateFile = (file) => {
+    const isAllowed =
+      config.allowedTypes.includes(file.type) ||
+      config.allowedExt.some((ext) => file.name.toLowerCase().endsWith(ext.toLowerCase()));
+    if (!isAllowed) {
+      notify('error', `仅支持 ${config.allowedExt.join('、')} 格式文件上传`);
+      return false;
+    }
+    if (config.maxSize > 0 && file.size > config.maxSize) {
+      notify('warning', `文件大小不能超过 ${autoFormatKbSplit(config.maxSize)}`);
+      return false;
+    }
+    return true;
+  };
+
+  const buildFileUrl = (fileOrPath) => {
+    if (typeof fileOrPath === 'object') {
+      if (fileOrPath?.url) return fileOrPath.url;
+      return buildOssFileUrl(fileOrPath?.ossPath, config.publicDomain);
+    }
+    return buildOssFileUrl(fileOrPath, config.publicDomain);
+  };
+
+  const toRelativePath = (urlOrPath) => toRelativeOssPath(urlOrPath, config.publicDomain);
+
+  const beforeUpload = (file) => validateFile(file);
+
+  const customUpload = async (uploadOptions) => {
+    const { file, onSuccess, onError } = uploadOptions;
+    file.originFileObj = file.originFileObj || file;
+    await startMultipartUpload(file, { onSuccess, onError });
+  };
+
+  const uploadFile = async (rawFile) => {
+    if (!validateFile(rawFile)) return null;
+    const file = {
+      uid: buildShortUUID('upload'),
+      name: rawFile.name,
+      size: rawFile.size,
+      originFileObj: rawFile,
+      status: 'uploading',
+      percent: 0,
+    };
+    uploadList.value.push(file);
+    await startMultipartUpload(file);
+    return file;
+  };
+
+  const retryUpload = async (fileItem) => {
+    if (fileItem.status === 'uploading') return;
+    if (!fileItem.originFileObj) {
+      notify('warning', '请重新选择相同文件后继续上传');
+      return;
+    }
+    await startMultipartUpload(fileItem);
+  };
+
+  const removeFile = (index) => {
+    const item = uploadList.value[index];
+    if (!item) return;
+    const uploadTask = activeUploads.get(item.uid);
+    if (uploadTask?.client && item.status === 'uploading') {
+      cancelledUids.add(item.uid);
+      uploadTask.client.cancel();
+      activeUploads.delete(item.uid);
+    }
+    removeCheckpoint(getCheckpointKey(item.name, item.size));
+    uploadList.value.splice(index, 1);
+    notify('info', `已移除 ${item.name}`);
+  };
+
+  const removeFileByUid = (uid) => {
+    const index = uploadList.value.findIndex((item) => item.uid === uid);
+    if (index > -1) removeFile(index);
+  };
+
+  const handleUploadChange = ({ file }) => {
+    if (file.status === 'done' && file.response?.code === 0) {
+      notify('success', `${file.name} 上传成功`);
+    } else if (file.status === 'error') {
+      notify('error', `${file.name} 上传失败`);
+    }
+  };
+
+  const notifyPendingCheckpoints = () => {
+    const pendingCount = getPendingCheckpointCount();
+    if (pendingCount > 0) {
+      notify('info', `检测到 ${pendingCount} 个未完成上传,重新选择相同文件可自动续传`);
+    }
+    return pendingCount;
+  };
+
+  const clearUploadList = () => {
+    uploadList.value = [];
+  };
+
+  return {
+    uploadList,
+    isUploading,
+    hasDoneFiles,
+    beforeUpload,
+    customUpload,
+    uploadFile,
+    retryUpload,
+    removeFile,
+    removeFileByUid,
+    handleUploadChange,
+    notifyPendingCheckpoints,
+    getPendingCheckpointCount,
+    clearUploadList,
+    validateFile,
+    buildFileUrl,
+    toRelativePath,
+  };
+}
+
+export default useOssUpload;

+ 2 - 1
src/layouts/header.vue

@@ -29,6 +29,7 @@ const handleMenuClick = (e) => {
         title: t('common.tip'),
         icon: createVNode(ExclamationCircleFilled),
         content: t('login.logoutTip'),
+        centered: true,
         // okText: '确认',
         // cancelText: '取消',
         onOk: async () => {
@@ -42,7 +43,7 @@ const handleMenuClick = (e) => {
   <div class="h-21 bg-white flex items-center justify-between px-10">
     <div class="haeder-left">
       <span v-if="route.name == 'sceneomore'" class="haeder-left-name">
-        <span class="font-route">{{type == 'obj'?$t('scene.obj'):$t('scene.mesh')}}</span>
+        <span class="font-route cursor-pointer" @click="$router.push(type == 'laser'?'/sceneobj': '/scenemesh')">{{type == 'laser'?$t('scene.obj'):$t('scene.mesh')}}</span>
         <span class="font-fh">/</span>
         <span class="font-name">{{type}}</span>
       </span>

+ 129 - 132
src/layouts/menu.vue

@@ -1,15 +1,12 @@
 <template>
-  <div class="menu h-screen bg-sky-50">
-    <div class="w-64 z-10 flex flex-col h-full">
-      <!-- Logo区域 -->
+  <div class="h-screen menu bg-sky-50">
+    <div class="z-10 flex flex-col w-64 h-full">
       <div class="flex items-center justify-center mt-10 mb-2">
         <h2 class="text-xl font-bold text-blue-600">
-          <img src="@/assets/image/menu/logo.png" style="height: 38px" alt="logo" />
+          <img src="@/assets/image/menu/logo.png" style="height: 38px" />
         </h2>
         <div class="logoTips">个人版</div>
       </div>
-
-      <!-- 菜单主体 -->
       <div class="flex-1 overflow-y-auto">
         <a-menu
           id="leftmenu"
@@ -20,77 +17,73 @@
           :items="menuItems"
           :style="{ backgroundColor: 'transparent', border: 'none' }"
           @click="handleClick"
-        />
+        ></a-menu>
       </div>
     </div>
   </div>
 </template>
 
 <script setup>
-import { reactive, ref, watch, h, onUnmounted } from 'vue'
+import { ref, watch, h, onMounted } from 'vue';
 import { useRouter, useRoute } from 'vue-router'
+import { userSerciceList } from '@/api/mall'
+// import { hasPanoramaUserService } from '@/views/mall/data.ts'
 
 const router = useRouter()
 const route = useRoute()
 
-// 选中菜单key
+// 路由选中key
 const selectedKeys = ref([route.path])
-// 展开父菜单key(去重,修复原数组重复scene)
-const openKeys = ref(['scene', 'app', 'equityDevice'])
 
-// 图标资源统一管理(集中维护,方便后续替换)
-const iconAssets = {
-  home: {
-    normal: import('@/assets/image/menu/home.png'),
-    active: import('@/assets/image/menu/home-active.png')
-  },
-  sceneObj: {
-    normal: import('@/assets/image/menu/obj.png'),
-    active: import('@/assets/image/menu/obj-active.png')
-  },
-  mesh: {
-    normal: import('@/assets/image/menu/mesh.png'),
-    active: import('@/assets/image/menu/mesh-active.png')
-  },
-  app: {
-    normal: import('@/assets/image/menu/qjkk.png'),
-    active: import('@/assets/image/menu/qjkk-active.png')
-  },
-  vip: {
-    normal: import('@/assets/image/menu/equity.png'),
-    active: import('@/assets/image/menu/equity-active.png')
-  },
-  order: {
-    normal: import('@/assets/image/menu/order.png'),
-    active: import('@/assets/image/menu/order-active.png')
-  },
-  device: {
-    normal: import('@/assets/image/menu/device.png'),
-    active: import('@/assets/image/menu/device-active.png')
-  }
-}
-
-/**
- * 生成菜单图标渲染函数
- * @param {Object} iconObj 图标资源 {normal, active}
- * @param {string} targetKey 当前菜单key
- * @returns Function h渲染函数
- */
-const renderIcon = (iconObj, targetKey) => {
+const openKeys = ref(['scene', 'equityDevice']);
+
+// ========== 导入所有菜单图片(替换成你自己的图片路径)==========
+// 首页
+import homeNormal from '@/assets/image/menu/home.png'
+import homeActive from '@/assets/image/menu/home-active.png'
+// 点云场景
+import sceneObjNormal from '@/assets/image/menu/obj.png'
+import sceneObjActive from '@/assets/image/menu/obj-active.png'
+// Mesh场景
+import meshNormal from '@/assets/image/menu/mesh.png'
+import meshActive from '@/assets/image/menu/mesh-active.png'
+// 全景看看
+import appNormal from '@/assets/image/menu/qjkk.png'
+import appActive from '@/assets/image/menu/qjkk-active.png'
+// 会员权益
+import vipNormal from '@/assets/image/menu/equity.png'
+import vipActive from '@/assets/image/menu/equity-active.png'
+// 订单管理
+import orderNormal from '@/assets/image/menu/order.png'
+import orderActive from '@/assets/image/menu/order-active.png'
+// 设备管理
+import deviceNormal from '@/assets/image/menu/device.png'
+import deviceActive from '@/assets/image/menu/device-active.png'
+// 父菜单折叠标题图标(场景/应用/设备分组)
+// import parentSceneNormal from '@/assets/image/menu/parent-scene.png'
+// import parentSceneActive from '@/assets/image/menu/parent-scene-active.png'
+// import parentAppNormal from '@/assets/image/menu/parent-app.png'
+// import parentAppActive from '@/assets/image/menu/parent-app-active.png'
+// import parentDeviceNormal from '@/assets/image/menu/parent-device.png'
+// import parentDeviceActive from '@/assets/image/menu/parent-device-active.png'
+
+// 生成图标渲染函数:根据当前选中key切换 选中/未选中图片
+const renderIcon = (normalImg, activeImg, targetKey) => {
   return () => h('img', {
-    src: selectedKeys.value.includes(targetKey) ? iconObj.active : iconObj.normal,
+    src: selectedKeys.value.includes(targetKey) ? activeImg : normalImg,
     class: 'menu-img-icon',
-    alt: 'menu-icon'
   })
 }
 
-// 菜单配置(扁平化管理,结构清晰)
-const menuItems = reactive([
+// 菜单配置:每一项新增 iconNormal / iconActive,icon使用渲染函数
+const buildMenuItems = () => [
   {
     key: '/home',
     label: '首页',
-    icon: renderIcon(iconAssets.home, '/home'),
-    popupClassName: 'homePage'
+    iconNormal: homeNormal,
+    iconActive: homeActive,
+    icon: renderIcon(homeNormal, homeActive, '/home'),
+    popupClassName: 'homePage',
   },
   {
     key: 'scene',
@@ -99,14 +92,18 @@ const menuItems = reactive([
       {
         key: '/sceneobj',
         label: '点云场景',
-        icon: renderIcon(iconAssets.sceneObj, '/sceneobj')
+        iconNormal: sceneObjNormal,
+        iconActive: sceneObjActive,
+        icon: renderIcon(sceneObjNormal, sceneObjActive, '/sceneobj'),
       },
       {
         key: '/scenemesh',
         label: 'Mesh场景',
-        icon: renderIcon(iconAssets.mesh, '/scenemesh')
-      }
-    ]
+        iconNormal: meshNormal,
+        iconActive: meshActive,
+        icon: renderIcon(meshNormal, meshActive, '/scenemesh'),
+      },
+    ],
   },
   {
     key: 'app',
@@ -115,9 +112,11 @@ const menuItems = reactive([
       {
         key: '/appProduct',
         label: '全景看看',
-        icon: renderIcon(iconAssets.app, '/appProduct')
-      }
-    ]
+        iconNormal: appNormal,
+        iconActive: appActive,
+        icon: renderIcon(appNormal, appActive, '/appProduct'),
+      },
+    ],
   },
   {
     key: 'equityDevice',
@@ -126,109 +125,107 @@ const menuItems = reactive([
       {
         key: '/equity',
         label: '会员权益',
-        icon: renderIcon(iconAssets.vip, '/equity')
+        iconNormal: vipNormal,
+        iconActive: vipActive,
+        icon: renderIcon(vipNormal, vipActive, '/equity'),
       },
       {
         key: '/order/list',
         label: '订单管理',
-        icon: renderIcon(iconAssets.order, '/order/list')
+        iconNormal: orderNormal,
+        iconActive: orderActive,
+        icon: renderIcon(orderNormal, orderActive, '/order/list'),
       },
       {
         key: '/device',
         label: '设备管理',
-        icon: renderIcon(iconAssets.device, '/device')
-      }
-    ]
+        iconNormal: deviceNormal,
+        iconActive: deviceActive,
+        icon: renderIcon(deviceNormal, deviceActive, '/device'),
+      },
+    ],
   }
-])
+];
+
+const menuItems = ref(buildMenuItems().filter((item) => item.key !== 'app'));
+
+const loadMenuItems = async () => {
+  try {
+    const userList = await userSerciceList();
+    console.log('userSerciceList', userList)
+    const isApp = userList.find(item => item.id == 1)
+    menuItems.value = isApp
+      ? buildMenuItems()
+      : buildMenuItems().filter((item) => item.key !== 'app');
+  } catch {
+    menuItems.value = buildMenuItems().filter((item) => item.key !== 'app');
+  }
+};
 
-/**
- * 菜单点击事件
- * @param {Object} info 菜单点击信息 { key }
- */
-const handleClick = ({ key }) => {
-  // 仅路由路径跳转,父分组key禁止跳转
-  if (!key.startsWith('/')) return
-  // 避免重复跳转当前路由
-  if (route.path === key) return
-  router.push(key)
-}
+onMounted(loadMenuItems);
 
-/**
- * 递归查找当前子菜单对应的父菜单key
- * @param {Array} list 菜单数组
- * @param {string} targetPath 当前路由path
- * @returns string|null 父菜单key
- */
-const findParentMenuKey = (list, targetPath) => {
-  for (const item of list) {
-    if (!item.children) continue
-    // 直接匹配子项
-    const targetChild = item.children.find(child => child.key === targetPath)
-    if (targetChild) return item.key
-    // 递归深层子菜单
-    const deepParent = findParentMenuKey(item.children, targetPath)
-    if (deepParent) return deepParent
+// 菜单点击跳转
+const handleClick = ({ key }) => {
+  // 只跳转路由key(带/),父分组不跳转
+  if (key.startsWith('/')) {
+    router.push(key)
   }
-  return null
-}
-
-// 监听路由变化,自动高亮菜单、展开父菜单
-const unwatchRoute = watch(
-  () => route.path,
-  (newPath) => {
-    selectedKeys.value = [newPath]
-    const parentKey = findParentMenuKey(menuItems, newPath)
-    if (!parentKey) return
-    // 只保留当前父菜单,不叠加历史展开项
+};
+
+// 监听路由变化,自动更新选中菜单、自动展开父菜单
+watch(() => route.path, (newPath) => {
+  selectedKeys.value = [newPath]
+  // 自动展开当前路由对应的父菜单
+  const findParent = (list, targetPath) => {
+    for (const item of list) {
+      if (item.children) {
+        const childMatch = item.children.find(c => c.key === targetPath)
+        if (childMatch) return item.key
+        const res = findParent(item.children, targetPath)
+        if (res) return res
+      }
+    }
+    return null
+  }
+  const parentKey = findParent(menuItems.value, newPath)
+  if (parentKey && !openKeys.value.includes(parentKey)) {
     openKeys.value = [parentKey]
-  },
-  { immediate: true }
-)
-
-// 监听展开菜单(调试用,上线可删除)
-const unwatchOpenKeys = watch(openKeys, (val) => {
-  console.log('当前展开菜单', val)
-})
+  }
+}, { immediate: true })
 
-// 组件销毁清除监听,防止内存泄漏
-onUnmounted(() => {
-  unwatchRoute()
-  unwatchOpenKeys()
-})
+watch(openKeys, val => {
+  console.log('openKeys', val);
+});
 </script>
 
 <style scoped lang="less">
-.logoTips {
+/* 菜单图片图标统一样式 */
+:deep(.menu-img-icon) {
+  width: 18px;
+  height: 18px;
+  object-fit: contain;
+}
+.logoTips{
   font-weight: normal;
   font-size: 14px;
-  color: #fff;
+  color: #FFFFFF;
   padding: 0 6px;
   margin-left: 6px;
   line-height: 22px;
   background: #1D2129;
-  border-radius: 4px;
-}
-
-/* 深度修改antd菜单样式 */
-:deep(.menu-img-icon) {
-  width: 18px;
-  height: 18px;
-  object-fit: contain;
+  border-radius: 4px 4px 4px 4px;
 }
-
+/* 菜单基础美化 */
 :deep(.ant-menu-item) {
   border-radius: 6px;
   margin: 4px 6px;
   height: 42px;
 }
-
 :deep(.ant-menu-submenu-title) {
   border-radius: 6px;
   margin: 4px 6px;
   height: 42px;
 }
-
 :deep(.ant-menu-item-selected) {
   background: #e6f4ff !important;
 }

+ 268 - 1
src/locales/zh.json

@@ -103,6 +103,7 @@
       "downinfo1": "下载内容:场景包(含贴图,模型,热点信息等所有场景数据),可用于场景私有部署。",
       "downinfo2": "注意事项:下载完成后,若在编辑后台修改并保存场景信息,您需要重新付费下载。",
       "qxxzTip": "取消协助成功",
+      "qxxz": "取消协助",
       "myScene": "我的场景",
       "sdkk": "四维看看",
       "sdkj": "四维看见",
@@ -357,9 +358,23 @@
     "buyCount": "购买数量",
     "payType": "支付方式",
     "memberDeadLine": "会员期限",
+    "Premium": "专业会员",
+    "Senior": "高级会员",
+    "Months": "个月",
+    "year": "年",
+    "Month": "个月",
+    "lify": "会员期限",
+    "type": "类型",
+    "countdesc": "可授权相机数量:",
     "shouldPay": "应付金额",
     "unbindCamera": "解绑相机",
     "unbindCameraTip": "确定解除该相机吗?",
+    "Selectfirst": "选择会员类型",
+    "cancelContinuous": "取消订阅",
+    "autoRenew": "自动续费",
+    "autoRenewSuccess": "当前会员权益订阅服务已取消成功",
+    "autoRenewTips": "非强制消费。在每个续期日期前至少一天您可以随时在“账号信息” > “会员权益”  > “取消订阅”   中取消。方案将自动续期,直到您取消为止。",
+    "continuousTips": "如果您取消订阅,我们将无法再为您提供会员服务。您还想要取消订阅吗?",
     "downloadPrice": "{number}元/次",
     "privilegeDownload": "免费下载{number}次",
     "spacesMax": "无限空间",
@@ -481,8 +496,26 @@
     "zhijiaColor": "标准色",
     "payOrder": "付款",
     "addAddressTip": "请添加地址",
+    "agree": "同意",
+    "4dkkxsxy": "四维看看销售协议",
+    "paidOff": "我已支付",
+    "payPendingTip": "请在新窗口完成支付,完成后可点击下方按钮确认",
+    "reopenPay": "重新打开支付页",
     "payCenter": "支付中心",
     "nowAccount": "当前帐号",
+    "pay": "支付",
+    "downdesc": "场景下载",
+    "payTypeStr": {
+      "0": "微信",
+      "1": "支付宝",
+      "2": "paypal",
+      "6": "stripe"
+    },
+    "costType": {
+      "0": "限时免费",
+      "1": "咨询客服",
+      "price": "¥{price}"
+    },
     "payType": "支付方式",
     "aliPay": "支付宝",
     "wechatPay": "微信支付",
@@ -560,14 +593,248 @@
       "0": "相机配件",
       "1": "增值服务",
       "2": "相机"
+    },
+    "myOrders": {
+      "unaddress": "请填写收票地址",
+      "recname": "(四维看看)",
+      "typeStip": {
+        "0": "专票将于每个月 15 日开具并邮寄到您的默认收货地址,请注意查收!",
+        "1": "电子发票将于每个月 15 日开具内发送至您的邮箱!"
+      },
+      "invoiceTip": {
+        "0": "点击提交后,系统会为您提交开票申请。",
+        "1": "您只能提交一次开票信息,请谨慎填写!",
+        "2": "发票每个月15日统一开具并寄出"
+      },
+      "invoiceInfo": "发票信息",
+      "total1": "总价",
+      "tabs": {
+        "0": "相机订单",
+        "1": "权益订单",
+        "2": "消费订单",
+        "3": "下载订单"
+      },
+      "memerText": "四维看看会员权益",
+      "numbers": "订单号:",
+      "product": "商品",
+      "quantity": "数量",
+      "subtotal": "价格",
+      "total": "总价:¥",
+      "cancal": "取消订单",
+      "pay": "立即付款",
+      "norecord": "暂无任何订单,赶紧去购买吧。",
+      "invoice": "开具发票",
+      "wlNum": "物流单号:",
+      "unshipped": "等待商家发货",
+      "finish": "已完成",
+      "partShipped": "部分发货",
+      "received": "已收货",
+      "hasCancal": "已取消",
+      "expire": "已失效",
+      "edit": "修改(发货后7天内可修改)",
+      "dontneed": "发票类型:不需要发票",
+      "type1": "不需要发票",
+      "type2": "增值税普票(电子发票)",
+      "type3": "增值税专用发票",
+      "title": "发票抬头",
+      "code": "18位税务登记号",
+      "email": "电子邮箱",
+      "organizedAddress": "注册地址",
+      "registerPhone": "注册电话",
+      "bankName": "开户银行",
+      "bankAccount": "银行账户",
+      "save": "提交",
+      "cancal1": "取消",
+      "unMsg": "暂无相关记录",
+      "isExpired": "已过期",
+      "isUsing": "使用中",
+      "bindCameraModule": {
+        "newDevice": "绑定相机",
+        "reNewDevice": "再添加一台相机",
+        "bindCamera": "绑定",
+        "inputSnCode": "请输入相机底部的S/N码"
+      }
+    },
+    "account": {
+      "info": "账号信息",
+      "address": "我的收货地址",
+      "invoice": "我的发票抬头",
+      "normal": "增值税普票(电子发票)",
+      "zengzhi": "增值税专用发票",
+      "edit": "编辑",
+      "show": "收起",
+      "avatar": "头像",
+      "nickname": "昵称",
+      "account": "账号",
+      "email": "邮箱",
+      "invoiceTitle": "发票抬头",
+      "invoiceEmail": "电子邮箱",
+      "invoiceModule": {
+        "saddress": "收票地址",
+        "title": "发票抬头",
+        "invoiceType": "发票类型",
+        "invoiceShui": "税务登记号",
+        "openInvoice": "开具发票",
+        "openMoneyPlaceholder": "请输入开票金额",
+        "canOpenMoney": "可开票额度:{money}元",
+        "code": "税务登记号",
+        "address": "注册地址",
+        "phone": "注册电话",
+        "bank": "开户银行",
+        "bankAccount": "银行账号",
+        "emptyReceiveAddressTip": "请完善收货信息",
+        "successTip": "开票成功",
+        "failTip": "信息填写不能为空"
+      },
+      "placeholder": {
+        "nickname": "输入昵称",
+        "shipMobile": "联系电话",
+        "phone": "电话",
+        "name": "姓名",
+        "email": "邮箱",
+        "address": "详细地址",
+        "title": "请输入发票抬头",
+        "code": "请输入18位税务登记号",
+        "raddress": "注册地址",
+        "rphone": "注册电话",
+        "bank": "开户银行",
+        "accout": "银行账户",
+        "userName": "姓名",
+        "userPhone": "电话",
+        "province": "州/省/地区"
+      },
+      "save": "保存",
+      "select": "选择图片",
+      "sinfo": {
+        "0": "支持图片格式为:JPG/PNG",
+        "1": "推荐分辨率为:512*512px,大小不大于1MB"
+      },
+      "isReceive": "获取四维看看优惠信息和最新动态"
     }
   },
+  "information": {
+    "uploadTip": "图片大小不能大于",
+    "editInformation": "修改信息",
+    "editAddressLinkText": "修改地址",
+    "editInfo": "修改账号信息",
+    "gotoPay": "购买权益",
+    "memberDevice": "权益相机",
+    "expiredDownload": "剩余下载",
+    "takePhotos": "拍摄场景",
+    "defaultAddress": "默认收货地址",
+    "informationOverview": "账户概览",
+    "uploadAvatar": "上传头像",
+    "uploadAvatarTips": "推荐分辨率为:512*512px,<br/>允许JPG、PNG格式,小于1MB",
+    "userName": "账号",
+    "nickName": "昵称",
+    "email": "邮箱",
+    "cameraCount": "相机数量",
+    "memberCount": "权益数量",
+    "tai": "台",
+    "ci": "次",
+    "ge": "个",
+    "tai1": "(台)",
+    "ci1": "(次)",
+    "ge1": "(个)",
+    "editAddress": {
+      "editAddress": "修改收货地址",
+      "country": "国家/地区",
+      "province": "省",
+      "city": "市",
+      "qu": "区",
+      "addressDetail": "详细地址",
+      "receiver": "收件人",
+      "receiverPhone": "手机号码",
+      "internationalCity": "城市",
+      "internationalProvince": "州/省/地区"
+    },
+    "changePassword": {
+      "changePassword": "修改密码",
+      "verCode": "验证码",
+      "verCodeTips": "验证码错误或已过期",
+      "newPassword": "新密码",
+      "confirmNewPassword": "确认密码"
+    },
+    "memberTable": {
+      "deviceMember": "会员权益",
+      "bindDeviceNum": "授权相机{num}台",
+      "snInputPlaceHolder": "搜索相机S/N码",
+      "searchNoData": "抱歉,没有找到相关信息。",
+      "nodataPre": "请先购买会员权益",
+      "nodataGoBuy": "购买权益",
+      "isExpired": "已过期"
+    },
+    "nameArr": {
+      "information": "账号信息",
+      "scene": "场景管理",
+      "order": "我的订单",
+      "device": "我的相机",
+      "consumption": "消费记录",
+      "change": "修改密码",
+      "logout": "退出登录",
+      "products": "应用中心"
+    },
+    "deviceSettings": [
+      {
+        "name": "security",
+        "items": [
+          {
+            "name": "场景管理",
+            "to": {
+              "name": "scene"
+            }
+          },
+          {
+            "name": "退出登录",
+            "to": "logout"
+          }
+        ]
+      }
+    ]
+  },
+  "deviceAdmin": {
+    "addDevice": "绑定相机",
+    "collaborative": "分配协作",
+    "unbind": "解除",
+    "snCode": "S/N码",
+    "useage": "云容量",
+    "type": "套餐类型",
+    "expiredTime": "到期时间",
+    "cooperationUserName": "协作者",
+    "cooperationUserplaceholder": "请输入用户账号",
+    "sceneCount": "拍摄场景数量",
+    "lastTime": "最后拍摄时间",
+    "cameraType": "相机类型",
+    "notFoundAdmin": "没有找到对应的相机",
+    "viewCount": "拍摄场景数量",
+    "bindinfo1": "1. 相机需要跟用户账号绑定,绑定相机后,用户可以在个人中心管理相机及相机拍摄的场景。",
+    "bindinfo2": "2. 同一台相机只能被一个账号绑定,已经被绑定的相机,需要先登录绑定的账号进行相机解绑后才能被其他账号绑定。",
+    "bindSnCodeIsNull": "S/N码不能为空",
+    "maxCapacity": "无限容量",
+    "spaces": " 空间",
+    "memberWillExpired": "权益还有 {expired} 天到期",
+    "memberExpired": "权益已过期,请及时续费。",
+    "buy": "购买",
+    "balance": "剩余点数",
+    "bindTenErrorTip": "一次最多支持添加10台相机",
+    "errorRetryTip": "请勿重复输入",
+    "bindinfo3": "3、A device can only be bond to one account. ",
+    "bindinfo4": "4、You can always log into the camera account of the device by scan the QR code on the website via the APP.",
+    "bindinfo5": "5、Login via camera account can edit, and delete the scenes, but not top-up or expand the storage."
+  },
+
   "common": {
     "tip": "提示",
     "search": "搜索",
     "input": "输入",
     "all": "全部",
-    "myUpload": "我的上传"
+    "myUpload": "我的上传",
+    "success": "操作成功",
+    "operate": "操作",
+    "content": "内容",
+    "cancel": "取消",
+    "comfirm": "确定",
+    "fail": "失败"
   },
   "toast": {
     "1": "获取订单信息失败",

+ 13 - 1
src/router/index.js

@@ -92,7 +92,13 @@ const routes = [
         path: 'details/:id',
         name: 'mallDetails',
         component: () => import('@/views/mall/details.vue'),
-        meta: { title: '商城首页', keepAlive: false }
+        meta: { title: '商城详情', keepAlive: false }
+      },
+      {
+        path: 'sercice/:id',
+        name: 'mallSercice',
+        component: () => import('@/views/mall/sercice.vue'),
+        meta: { title: '商城详情', keepAlive: false }
       },
       {
         path: 'goods/:id',
@@ -112,6 +118,12 @@ const routes = [
         component: () => import('@/views/mall/cart.vue'),
         meta: { title: '购物车', keepAlive: true }
       },
+      {
+        path: 'membership/:id',
+        name: 'mallMembership',
+        component: () => import('@/views/mall/membership.vue'),
+        meta: { title: '购买会员权益', keepAlive: false }
+      },
     ]
   },
   {

+ 7 - 1
src/stores/cart.js

@@ -4,7 +4,7 @@ import router from "@/router";
 import i18n from "@/locales/index.js";
 import { defineStore } from "pinia";
 // 定义 store
-import { cartAdd, cartlist, getReceiverInfo } from "@/api/login/index.js";
+import { cartAdd, cartlist, cartRemove, getReceiverInfo } from "@/api/login/index.js";
 export const useCartStore = defineStore("cart", {
   // 状态
   state: () => ({
@@ -45,6 +45,12 @@ export const useCartStore = defineStore("cart", {
       message.success(t("toast.39"))
       return res;
     },
+    async removeCart(params) {
+      const { t } = i18n.global;
+      await cartRemove(params);
+      await this.updataCart();
+      message.success(t('toast.37'));
+    },
     async getReceiver() {
       const res = await getReceiverInfo();
       this.greceiverInfo = res;

+ 1 - 0
src/stores/index.js

@@ -1,4 +1,5 @@
 // 更规范写法,只导出需要的store
 export { useUserStore } from './user'
 export { useCartStore } from './cart'
+export { useOrderStore } from './order'
 export { useSceneStore } from './scene'

+ 38 - 0
src/stores/order.js

@@ -0,0 +1,38 @@
+import { message } from "ant-design-vue";
+import { encodeStr } from "@/utils/encodeUtil.js";
+import router from "@/router";
+import i18n from "@/locales/index.js";
+import { defineStore } from "pinia";
+// 定义 store
+import { cameraDetail } from "@/api/login/index.js";
+export const useOrderStore = defineStore("order", {
+  // 状态
+  state: () => ({
+    item: {},
+  }),
+
+  // 计算属性
+  getters: {
+    // 名字大写
+    getOrderItem(state) {
+      return state.item || {};
+    },
+  },
+
+  // 方法 / 操作状态
+  actions: {
+    // 修改名字
+    async updataOrder(item) {
+      this.item = item;
+    },
+    async checkCamera(childName, path) {
+      const res = await cameraDetail(childName);
+      console.log('cameraDetail', res)
+      this.greceiverInfo = res;
+      return res;
+    },
+  },
+
+  // 可选:持久化(需安装 pinia-plugin-persistedstate)
+  persist: true,
+});

+ 0 - 8
src/stores/scene.js

@@ -4,7 +4,6 @@ import router from "@/router";
 import i18n from "@/locales/index.js";
 import { defineStore } from "pinia";
 // 定义 store
-import { cartAdd, cartlist, getReceiverInfo } from "@/api/login/index.js";
 import { cameraDetail } from "@/api/login/index.js";
 export const useSceneStore = defineStore("scene", {
   // 状态
@@ -26,13 +25,6 @@ export const useSceneStore = defineStore("scene", {
     async updataCart(item) {
       this.item = item;
     },
-    async AddCart(params) {
-      const { t , locale} = i18n.global;
-      const res = await cartAdd(params);
-      this.updataCart();
-      message.success(t("toast.39"))
-      return res;
-    },
     async checkCamera(childName, path) {
       const res = await cameraDetail(childName);
       console.log('cameraDetail', res)

+ 47 - 3
src/stores/user.js

@@ -3,8 +3,9 @@ import { message } from 'ant-design-vue'
 import { encodeStr } from '@/utils/encodeUtil.js'
 import router from '@/router'
 // 定义 store
-import { getUserInfo, userlogin, userlogout } from "@/api/login/index.js";
+import { getUserInfo, userlogin, userlogout, updateUserDetail, changePassword, userRegister } from "@/api/login/index.js";
 import i18n from "@/locales/index.js";
+import { update } from 'lodash-es';
 export const useUserStore = defineStore('user', {
   // 状态
   state: () => ({
@@ -35,8 +36,8 @@ export const useUserStore = defineStore('user', {
   // 计算属性
   getters: {
     // 名字大写
-    upperName(state) {
-      return state.userName.toUpperCase()
+    getNickName() {
+      return this.nickName
     },
     getToken(state) {
       return state.token || localStorage.getItem('token')
@@ -76,6 +77,10 @@ export const useUserStore = defineStore('user', {
         })
       }
     },
+    async updateInfo (nickName) {
+      await updateUserDetail({email: this.email, nickName: nickName, isNotice: 1})
+      this.getInfo()
+    },
     async login(params){
       const { t , locale} = i18n.global;
       const data = await userlogin({
@@ -96,6 +101,45 @@ export const useUserStore = defineStore('user', {
         message.error('登录失败,请重试!')
       }
     },
+    async register(params) {
+      const password = encodeStr(window.btoa(params.password))
+      const data = await userRegister({
+        phoneNum: params.username,
+        msgAuthCode: params.msgAuthCode,
+        nickName: params.username,
+        country: Number(params.country),
+        password,
+        confirmPwd: password,
+      })
+      if (data) {
+        this.setToken(data.token)
+        this.getInfo()
+        message.success('注册成功')
+        router.push({ name: 'Home' })
+        return data
+      } else {
+        message.error('注册失败,请重试!')
+      }
+    },
+    async changePaswd(params){
+      let  password =  encodeStr(window.btoa(params.password))
+      const data = await changePassword({
+        msgAuthCode: params.msgAuthCode,
+        phoneNum:params.phoneNum,
+        password,
+        confirmPwd: password,
+      })
+      console.log('changePassword', data)
+      if (data) {
+        this.setToken(data.token)
+        this.getInfo()
+        message.success('登录成功')
+        router.push({name:'Home'})
+        return data
+      } else {
+        message.error('登录失败,请重试!')
+      }
+    },
 
     // 示例:退出登录的方法
     async logout() {

+ 2 - 1
src/utils/http.js

@@ -106,7 +106,7 @@ service.interceptors.request.use(
     config.cancelToken = new CancelToken(cancel => {
       pendingRequest.set(reqKey, cancel)
     })
-
+    console.log('请求拦截器', config)
     // 控制 Loading
     if (config.allowLoading !== false) {
       showLoading()
@@ -140,6 +140,7 @@ service.interceptors.response.use(
     const res = response.data
     const config = response.config;
     const isReturnNative = config.isReturnNativeResponse ?? false;
+    console.log('响应拦截器', config);
     if (isReturnNative) {
       return res;
     }

+ 6 - 6
src/utils/index.js

@@ -33,10 +33,10 @@ export function dayjsFormatHMS(seconds) {
 
   return parts.join(' ')
 }
-export function downloadScene(url) {
-  // url = url.replace('-internal', '')
-  // let iframe = document.createElement('iframe')
-  // iframe.style.display = 'none'
-  // iframe.src = "javascript: '<script>location.href=\"" + url + "\"<\/script>'"
-  // document.getElementsByTagName('body')[0].appendChild(iframe)
+export function downloadSceneURL(url) {
+  url = url.replace('-internal', '')
+  let iframe = document.createElement('iframe')
+  iframe.style.display = 'none'
+  iframe.src = "javascript: '<script>location.href=\"" + url + "\"<\/script>'"
+  document.getElementsByTagName('body')[0].appendChild(iframe)
 }

+ 2 - 2
src/views/account/components/aside.vue

@@ -29,9 +29,9 @@
           </div>
           <p>{{ $t("loginAside.xieyi") }}</p>
           <div class="cls-btn">
-            <span @click="cancel">{{ $t("loginAside.cancel") }}</span>
+            <span @click="cancel">{{ $t("common.cancel") }}</span>
             <span :class="{ primary: allreadyRead }" @click="agree">{{
-              $t("loginAside.comfirm")
+              $t("common.comfirm")
             }}</span>
           </div>
         </div>

+ 156 - 101
src/views/account/login.vue

@@ -1,130 +1,185 @@
 <script setup>
-import { ref, reactive, h, toRaw } from "vue";
-import {
-  GlobalOutlined,
-  CaretDownFilled,
-  CaretUpFilled,
-} from "@ant-design/icons-vue";
+import { ref, reactive, onBeforeUnmount } from "vue";
+import { useRouter } from "vue-router";
+import { useI18n } from "vue-i18n";
+import { CaretDownFilled, CaretUpFilled } from "@ant-design/icons-vue";
 import phoneAddressSelect from "./components/phoneAddressSelect.vue";
 import { message } from "ant-design-vue";
 import { useUserStore } from "@/stores";
-import { Form } from 'ant-design-vue';
-const modelRef = ref();
-const useForm = Form.useForm;
+import { getAuthCode, checkSms, quickLogin } from "@/api/login";
+
+const router = useRouter();
+const { t } = useI18n();
 const userStore = useUserStore();
-const formState = reactive({
-  pass: "",
-  checkPass: "",
-  age: undefined,
-});
+
+const formRef = ref();
 const sendCodeExTime = ref(0);
 const type = ref("user");
 const showPhoneArea = ref(false);
+let timer = null;
+
+const formState = reactive({
+  username: "",
+  password: "",
+  msgAuthCode: "",
+  remember: false,
+  selectArea: ["中国", "+86"],
+});
+
+const getAreaNum = () => {
+  const code = formState.selectArea?.[1] || "+86";
+  return String(code).replace("+", "") || "86";
+};
+
+const formRules = {
+  username: [{ required: true, message: "请输入手机号码", trigger: "blur" }],
+  password: [{ required: true, message: "请输入密码", trigger: "blur" }],
+  msgAuthCode: [
+    { required: true, message: "请输入验证码", trigger: "blur" },
+    { pattern: /^\d+$/, message: "验证码只能输入数字", trigger: "blur" },
+    {
+      validator: async (_rule, value) => {
+        if (!value) return Promise.resolve();
+        const res = await checkSms(value);
+        if (res.code !== 0) {
+          return Promise.reject(t("toast.3006"));
+        }
+        return Promise.resolve();
+      },
+      trigger: "blur",
+    },
+  ],
+};
+
+const validateForm = async () => {
+  const fields = type.value === "code" ? ["username", "msgAuthCode"] : ["username", "password"];
+  try {
+    await formRef.value?.validateFields(fields);
+  } catch (err) {
+    if (err?.errorFields?.length) {
+      throw err;
+    }
+  }
+};
+
+const clearCodeTimer = () => {
+  if (timer) {
+    clearInterval(timer);
+    timer = null;
+  }
+};
+
+onBeforeUnmount(clearCodeTimer);
+
 const changeArea = (item) => {
   showPhoneArea.value = false;
-  if (!item) {
-    return;
-  }
+  if (!item) return;
   formState.selectArea = item;
 };
-const { resetFields, validate, validateInfos } = useForm(
-  modelRef,
-  reactive({
-    username: [
-      {
-        required: true,
-        message: '请输入手机号码',
-      },
-    ],
-    password: [
-      {
-        required: true,
-        message: '请输入密码',
-      },
-    ],
-  }),
-);
-const onSubmit = () => {
-  validate().then(res => {
-      let login = userStore.login(formState)
-      console.log(res, login);
-
-    })
-    .catch(err => {
-      console.log('error', err);
-    });
-};
+
 const sendCode = async () => {
-  if (sendCodeExTime.value) return;
+  if (sendCodeExTime.value > 0) return;
+  if (!formState.username) {
+    message.warning("请输入手机号码");
+    return;
+  }
 
-  let res = await userStore.getAuthCode({
-    phone: formState.phone,
-    code: Number(formState.selectArea[1].substr(1)),
+  const res = await getAuthCode({
+    phone: formState.username,
+    areaNum: getAreaNum(),
+    scene: 'login',
   });
+  if (!res) return;
 
-  if (res) {
-    sendCodeExTime.value = 60;
+  sendCodeExTime.value = 60;
+  if (res.code === 3033) {
+    sendCodeExTime.value = Number(res.msg);
+  }
 
-    if (res.data.code === 3033) {
-      sendCodeExTime.value = Number(res.data.msg);
-    }
+  if (res.code === 0 || res.code === 3033) {
+    clearCodeTimer();
+    timer = setInterval(() => {
+      if (sendCodeExTime.value <= 1) {
+        clearCodeTimer();
+        return;
+      }
+      sendCodeExTime.value--;
+    }, 1000);
+  } else {
+    const tipText = t(`toast.${res.code}`) ? t(`toast.${res.code}`) : res.msg;
+    message.warning(tipText);
+  }
+};
 
-    if (res.data.code === 0 || res.data.code === 3033) {
-      timer = setInterval(() => {
-        if (sendCodeExTime.value <= 1) {
-          clearInterval(timer);
-          timer = null;
-        }
-        sendCodeExTime.value--;
-      }, 1000);
-    } else {
-      if (this.$t(`toast.${res.data.code}`)) {
-        message.warn(this.$t(`toast.${res.data.code}`));
+const onSubmit = async () => {
+  try {
+    await validateForm();
+    if (type.value === "code") {
+      const data = await quickLogin({
+        phoneNum: formState.username,
+        msgAuthCode: formState.msgAuthCode,
+        areaNum: getAreaNum(),
+      });
+      if (data) {
+        userStore.setToken(data.token);
+        await userStore.getInfo();
+        message.success("登录成功");
+        router.push({ name: "Home" });
       } else {
-        message.warn(res.data.msg);
+        message.error("登录失败,请重试!");
       }
+    } else {
+      await userStore.login(formState);
     }
+  } catch (err) {
+    console.log("error", err);
   }
 };
+
+const switchLoginType = (loginType) => {
+  type.value = loginType;
+  formState.msgAuthCode = "";
+  formState.password = "";
+  formRef.value?.clearValidate();
+};
 </script>
+
 <template>
   <div class="login-from">
     <div class="login-type flex">
       <div
         class="login-type-item"
         :class="{ 'is-active': type === 'user' }"
-        @click="type = 'user'"
+        @click="switchLoginType('user')"
       >
         账号密码
       </div>
       <div
         class="login-type-item"
         :class="{ 'is-active': type === 'code' }"
-        @click="type = 'code'"
+        @click="switchLoginType('code')"
       >
         验证码
       </div>
     </div>
     <a-form
       :model="formState"
+      :rules="formRules"
       name="basic"
-      ref="modelRef"
+      ref="formRef"
       :label-col="{ span: 0 }"
       :wrapper-col="{ span: 24 }"
+      :validate-on-rule-change="false"
       autocomplete="off"
-      @finish="onFinish"
       @submit.prevent="onSubmit"
-      @finishFailed="onFinishFailed"
     >
-      <a-form-item
-        name="username"
-        v-bind="validateInfos.username"
-      >
+      <a-form-item name="username">
         <a-input
           v-model:value="formState.username"
           class="w-full"
           size="large"
           placeholder="请输入手机号码"
+          :autocomplete="type === 'code' ? 'tel' : 'username'"
         >
           <template #prefix v-if="type == 'code'">
             <div
@@ -137,7 +192,7 @@ const sendCode = async () => {
                 padding-right: 10px;
               "
             >
-              <span style="margin-right: 10px">+86</span>
+              <span style="margin-right: 10px">{{ formState.selectArea?.[1] || "+86" }}</span>
               <CaretDownFilled v-if="!showPhoneArea" />
               <CaretUpFilled v-else />
               <phoneAddressSelect v-show="showPhoneArea" @select="changeArea" />
@@ -146,42 +201,43 @@ const sendCode = async () => {
         </a-input>
       </a-form-item>
 
-      <a-form-item
-        v-if="type == 'user'"
-        name="password"
-        v-bind="validateInfos.password"
-      >
+      <a-form-item v-if="type == 'user'" name="password">
         <a-input-password
           v-model:value="formState.password"
           size="large"
           placeholder="请输入密码"
+          autocomplete="current-password"
         />
       </a-form-item>
       <div class="login-remember flex justify-between items-center" v-show="type == 'user'">
-      <a-checkbox
-        style="height: 16px; line-height: 14px; font-size: 12px"
-        v-model:checked="formState.remember"
-        >{{$t('login.rememberPassword')}}</a-checkbox
-      >
-      <span class="text-xs cursor-pointer" @click="$router.push('/login/forget')">{{$t("login.forgetPassword")}}</span>
+        <a-checkbox
+          style="height: 16px; line-height: 14px; font-size: 12px"
+          v-model:checked="formState.remember"
+          >{{ $t("login.rememberPassword") }}</a-checkbox
+        >
+        <span class="text-xs cursor-pointer" @click="$router.push('/login/forget')">{{
+          $t("login.forgetPassword")
+        }}</span>
       </div>
 
-      <a-form-item
-        name="msgAuthCode"
-        v-if="type == 'code'"
-        :rules="[{ required: true, message: 'Please input your password!' }]"
-      >
+      <a-form-item v-if="type == 'code'" name="msgAuthCode">
         <a-input
           v-model:value="formState.msgAuthCode"
           size="large"
           style="margin-bottom: 17px"
           :placeholder="$t('login.codePlaceholder')"
+          autocomplete="one-time-code"
+          name="msgAuthCode"
         >
           <template #suffix>
             <div
-              class="code-btn text-sm cursor-pointer pl-4"
-              style="border-left: 1px solid #bfbfbf;font-size: 14px"
-              :class="{ 'is-disabled': sendCodeExTime }"
+              class="code-btn text-sm pl-4"
+              style="border-left: 1px solid #bfbfbf; font-size: 14px"
+              :class="
+                sendCodeExTime
+                  ? 'is-disabled cursor-not-allowed'
+                  : 'cursor-pointer'
+              "
               @click="sendCode"
             >
               {{
@@ -194,20 +250,19 @@ const sendCode = async () => {
         </a-input>
       </a-form-item>
       <a-form-item>
-        <a-button class="login-btn w-full" type="primary" html-type="submit"
-          >{{$t('login.login')}}</a-button
-        >
+        <a-button class="login-btn w-full" type="primary" html-type="submit">{{
+          $t("login.login")
+        }}</a-button>
       </a-form-item>
       <div class="registerTip text-center text-xs">
-        还没有账号?<span
-          class="cursor-pointer"
-          @click="$router.push('/login/register')"
+        还没有账号?<span class="cursor-pointer" @click="$router.push('/login/register')"
           >立即注册</span
         >
       </div>
     </a-form>
   </div>
 </template>
+
 <style lang="less" scoped>
 .login-from {
   .login-btn {
@@ -246,4 +301,4 @@ const sendCode = async () => {
     border-bottom: 2px solid @primary-color;
   }
 }
-</style>
+</style>

+ 162 - 136
src/views/account/register.vue

@@ -1,142 +1,174 @@
 <script setup>
-import { ref, reactive, h, toRaw } from "vue";
-import {
-  GlobalOutlined,
-  CaretDownFilled,
-  CaretUpFilled,
-} from "@ant-design/icons-vue";
+import { ref, reactive, onBeforeUnmount } from "vue";
+import { useI18n } from "vue-i18n";
+import { CaretDownFilled, CaretUpFilled } from "@ant-design/icons-vue";
 import phoneAddressSelect from "./components/phoneAddressSelect.vue";
 import asidediv from "./components/aside.vue";
-import myModal from "@/components/myModal.vue";
 import { message } from "ant-design-vue";
 import { useUserStore } from "@/stores";
-import { Form } from "ant-design-vue";
-const useForm = Form.useForm;
-const modelRef = ref();
-const formState = reactive({
-  pass: "",
-  checkPass: "",
-  age: undefined,
-});
+import { getAuthCode } from "@/api/login";
+
+const { t } = useI18n();
+const userStore = useUserStore();
+
+const formRef = ref();
 const sendCodeExTime = ref(0);
-const type = ref("user");
 const show = ref(false);
 const showPhoneArea = ref(false);
+let timer = null;
+
+const formState = reactive({
+  username: "",
+  password: "",
+  confirmPass: "",
+  msgAuthCode: "",
+  remember: false,
+  selectArea: ["中国", "+86"],
+});
+
+const getAreaNum = () => {
+  const code = formState.selectArea?.[1] || "+86";
+  return String(code).replace("+", "") || "86";
+};
+
+const formRules = {
+  username: [{ required: true, message: "请输入手机号码", trigger: "blur" }],
+  msgAuthCode: [
+    { required: true, message: "请输入验证码", trigger: "blur" },
+    { pattern: /^\d+$/, message: "验证码只能输入数字", trigger: "blur" },
+  ],
+  password: [{ required: true, message: "请输入密码", trigger: "blur" }],
+  confirmPass: [
+    { required: true, message: "请输入确认密码", trigger: "blur" },
+    {
+      validator: (_rule, value) => {
+        if (value && value !== formState.password) {
+          return Promise.reject("两次输入密码不一致");
+        }
+        return Promise.resolve();
+      },
+      trigger: "blur",
+    },
+  ],
+};
+
+const validateForm = async () => {
+  try {
+    await formRef.value?.validate();
+  } catch (err) {
+    if (err?.errorFields?.length) {
+      throw err;
+    }
+    // 倒计时等重渲染导致 outOfDate,但无字段错误时视为通过
+  }
+};
+
+const clearCodeTimer = () => {
+  if (timer) {
+    clearInterval(timer);
+    timer = null;
+  }
+};
+
+onBeforeUnmount(clearCodeTimer);
+
 const changeArea = (item) => {
   showPhoneArea.value = false;
-  if (!item) {
-    return;
-  }
+  if (!item) return;
   formState.selectArea = item;
 };
-const { resetFields, validate, validateInfos } = useForm(
-  modelRef,
-  reactive({
-    username: [
-      {
-        required: true,
-        message: "请输入手机号码",
-      },
-    ],
-    msgAuthCode: [
-      {
-        required: true,
-        message: "请输入验证码",
-      },
-    ],
-    password: [
-      {
-        required: true,
-        message: "请输入密码",
-      },
-    ],
-    confirmPass: [
-      {
-        required: true,
-        message: "请输入确认密码",
-      },
-    ],
-  })
-);
-const onSubmit = () => {
-  validate()
-    .then((res) => {
-      let login = userStore.login(formState);
-      console.log(res, login);
-    })
-    .catch((err) => {
-      console.log("error", err);
-    });
-};
-const cancel = () => {
-  show.value = false;
-  formState.remember = false;
-  console.log('cancel', show.value, formState.remember)
-}
+
 const sendCode = async () => {
-  if (sendCodeExTime.value) return;
+  if (sendCodeExTime.value > 0) return;
+  if (!formState.username) {
+    message.warning("请输入手机号码");
+    return;
+  }
 
-  let res = await userStore.getAuthCode({
-    phone: formState.phone,
-    code: Number(formState.selectArea[1].substr(1)),
+  const res = await getAuthCode({
+    phone: formState.username,
+    areaNum: getAreaNum(),
+    scene: "register",
   });
+  if (!res) return;
 
-  if (res) {
-    sendCodeExTime.value = 60;
-
-    if (res.data.code === 3033) {
-      sendCodeExTime.value = Number(res.data.msg);
-    }
+  sendCodeExTime.value = 60;
+  if (res.code === 3033) {
+    sendCodeExTime.value = Number(res.msg);
+  }
 
-    if (res.data.code === 0 || res.data.code === 3033) {
-      timer = setInterval(() => {
-        if (sendCodeExTime.value <= 1) {
-          clearInterval(timer);
-          timer = null;
-        }
-        sendCodeExTime.value--;
-      }, 1000);
-    } else {
-      if (this.$t(`toast.${res.data.code}`)) {
-        message.warn(this.$t(`toast.${res.data.code}`));
-      } else {
-        message.warn(res.data.msg);
+  if (res.code === 0 || res.code === 3033) {
+    clearCodeTimer();
+    timer = setInterval(() => {
+      if (sendCodeExTime.value <= 1) {
+        clearCodeTimer();
+        return;
       }
+      sendCodeExTime.value--;
+    }, 1000);
+  } else {
+    const tipText = t(`toast.${res.code}`) ? t(`toast.${res.code}`) : res.msg;
+    message.warning(tipText);
+  }
+};
+
+const onSubmit = async () => {
+  try {
+    await validateForm();
+    if (!formState.remember) {
+      message.warning("请先阅读并同意用户协议");
+      return;
     }
+    await userStore.register({
+      username: formState.username,
+      password: formState.password,
+      msgAuthCode: formState.msgAuthCode,
+      country: getAreaNum(),
+    });
+  } catch (err) {
+    console.log("error", err);
   }
 };
+
+const cancel = () => {
+  show.value = false;
+  formState.remember = false;
+};
+
 const handleProtocol = () => {
   show.value = true;
   formState.remember = true;
 };
 </script>
+
 <template>
   <div class="login-from">
-    <div class="login-type flex">
+    <div class="flex login-type">
       <div class="login-type-item is-active">注册账号</div>
     </div>
     <a-form
       :model="formState"
+      :rules="formRules"
       name="basic"
-      ref="modelRef"
+      ref="formRef"
       :label-col="{ span: 0 }"
       :wrapper-col="{ span: 24 }"
+      :validate-on-rule-change="false"
       autocomplete="off"
-      @finish="onFinish"
       @submit.prevent="onSubmit"
-      @finishFailed="onFinishFailed"
     >
-      <a-form-item name="username" v-bind="validateInfos.username">
+      <a-form-item name="username">
         <a-input
           v-model:value="formState.username"
           class="w-full"
           size="large"
           placeholder="请输入手机号码"
+          autocomplete="tel"
         >
           <template #prefix>
             <div
               @click.stop="showPhoneArea = !showPhoneArea"
-              class="areaCode flex items-center cursor-pointer"
+              class="flex items-center cursor-pointer areaCode"
               style="
                 height: 16px;
                 border-right: 1px solid #bfbfbf;
@@ -144,7 +176,7 @@ const handleProtocol = () => {
                 padding-right: 10px;
               "
             >
-              <span style="margin-right: 10px">+86</span>
+              <span style="margin-right: 10px">{{ formState.selectArea?.[1] || "+86" }}</span>
               <CaretDownFilled v-if="!showPhoneArea" />
               <CaretUpFilled v-else />
               <phoneAddressSelect v-show="showPhoneArea" @select="changeArea" />
@@ -153,17 +185,23 @@ const handleProtocol = () => {
         </a-input>
       </a-form-item>
 
-      <a-form-item name="msgAuthCode" v-bind="validateInfos.msgAuthCode">
+      <a-form-item name="msgAuthCode">
         <a-input
           v-model:value="formState.msgAuthCode"
           size="large"
           :placeholder="$t('login.codePlaceholder')"
+          autocomplete="one-time-code"
+          name="msgAuthCode"
         >
           <template #suffix>
             <div
-              class="code-btn text-sm cursor-pointer pl-4"
-              style="border-left: 1px solid #bfbfbf;"
-              :class="{ 'is-disabled': sendCodeExTime }"
+              class="pl-4 text-sm code-btn"
+              style="border-left: 1px solid #bfbfbf"
+              :class="
+                sendCodeExTime
+                  ? 'is-disabled cursor-not-allowed'
+                  : 'cursor-pointer'
+              "
               @click="sendCode"
             >
               {{
@@ -175,52 +213,55 @@ const handleProtocol = () => {
           </template>
         </a-input>
       </a-form-item>
-      <a-form-item name="password" v-bind="validateInfos.password">
+      <a-form-item name="password">
         <a-input-password
           v-model:value="formState.password"
           size="large"
           :placeholder="$t('login.setPassword')"
+          autocomplete="new-password"
         />
       </a-form-item>
 
-      <a-form-item name="password" v-bind="validateInfos.confirmPass">
+      <a-form-item name="confirmPass">
         <a-input-password
           v-model:value="formState.confirmPass"
           size="large"
           :placeholder="$t('login.rePassword')"
+          autocomplete="new-password"
         />
       </a-form-item>
-      <div class="login-remember flex items-center">
+      <div class="flex items-center login-remember">
         <a-checkbox
-          style="height: 16px; line-height: 14px; font-size: 12px;margin-right: 10px;"
+          style="height: 16px; line-height: 14px; font-size: 12px; margin-right: 10px"
           v-model:checked="formState.remember"
-        >
-          </a-checkbox
-        ><label for="xieyi" @click="handleProtocol"
-            >{{ $t("login.cluse1") }}
-            <span class="code-btn">{{ $t("login.cluse2") }}</span>
-          </label>
-        <!-- <span
-          class="text-xs cursor-pointer"
-          @click="$router.push('/login/forget')"
-          >{{ $t("login.forgetPassword") }}</span
-        > -->
+        />
+        <label for="xieyi" @click="handleProtocol">
+          {{ $t("login.cluse1") }}
+          <span class="code-btn">{{ $t("login.cluse2") }}</span>
+        </label>
       </div>
       <a-form-item>
-        <a-button class="login-btn w-full" type="primary" html-type="submit"
-          >{{$t('login.register')}}</a-button
-        >
+        <a-button class="w-full login-btn" type="primary" html-type="submit">{{
+          $t("login.register")
+        }}</a-button>
       </a-form-item>
-      <div class="registerTip text-center text-xs">
-        {{$t('login.hasAccount')}}<span
+      <div class="text-xs text-center registerTip">
+        {{ $t("login.hasAccount") }}<span
           class="cursor-pointer"
           @click="$router.push('/login/login')"
-          >{{$t('login.zhijieLogin')}}</span
+          >{{ $t("login.zhijieLogin") }}</span
         >
       </div>
     </a-form>
-    <a-modal v-model:open="show" :closable="false" style="width: 800px" @cancel="cancel" :footer="null" :title="null" @ok="handleOk">
-        <asidediv :show="show"  @cancel="cancel"/>
+    <a-modal
+      v-model:open="show"
+      :closable="false"
+      style="width: 800px"
+      @cancel="cancel"
+      :footer="null"
+      :title="null"
+    >
+      <asidediv :show="show" @cancel="cancel" />
     </a-modal>
   </div>
 </template>
@@ -231,11 +272,6 @@ const handleProtocol = () => {
     margin: 20px 0 0;
     height: 40px;
   }
-  .qrdiv {
-    width: 240px;
-    height: 240px;
-    margin: 0 auto;
-  }
   .registerTip {
     span {
       color: @primary-color;
@@ -247,16 +283,6 @@ const handleProtocol = () => {
   .areaCode {
     position: relative;
   }
-  .qrcodeTips {
-    padding: 19px 0 0;
-    line-height: 20px;
-    font-family: PingFang SC;
-    font-weight: 400;
-    font-size: 14px;
-    color: #333;
-    text-align: center;
-    font-style: normal;
-  }
 }
 
 .login-type {
@@ -277,4 +303,4 @@ const handleProtocol = () => {
     border-bottom: 2px solid @primary-color;
   }
 }
-</style>
+</style>

+ 17 - 4
src/views/device/components/bindCameraModal.vue

@@ -13,6 +13,7 @@
     <a-form ref="formRef" :model="formData" layout="vertical">
       
         <a-form-item
+          name="sn"
           :rules="[
             {
               required: true,
@@ -23,7 +24,7 @@
         >
           <a-input
             size="large"
-            v-model:value="sn"
+            v-model:value="formData.sn"
             placeholder="请输入设备机身的S/N码"
           />
         </a-form-item>
@@ -62,7 +63,7 @@
       </a-button>
       <div class="btn-group">
         <a-button style="margin-right: 16px" @click="handleCancel">取消</a-button>
-        <a-button type="primary" @click="handleConfirm">确定</a-button>
+        <a-button type="primary" @click="handleSubmit">确定</a-button>
       </div>
     </div>
   </a-modal>
@@ -73,6 +74,8 @@ import { ref, reactive, watch, defineProps, defineEmits } from "vue";
 import {
   MinusCircleFilled,
 } from "@ant-design/icons-vue";
+import { message } from "ant-design-vue";
+import { cameraAdd } from "@/api/equity";
 // 父组件控制弹窗显隐
 const props = defineProps({
   open: {
@@ -80,6 +83,10 @@ const props = defineProps({
     default: false,
     description: "弹窗是否可见",
   },
+  cameraType: {
+    type: Number,
+    default: null,
+  }
 });
 const emit = defineEmits(["update:open", "confirm"]);
 
@@ -90,6 +97,7 @@ const sn = ref("");
 // 初始化表单:默认1条输入框
 const formData = reactive({
   snList: [],
+  sn: "",
 });
 
 // 新增一条SN输入
@@ -99,7 +107,7 @@ const addItem = () => {
 
 // 删除指定行,最少保留1条
 const removeItem = (index) => {
-  if (formData.snList.length <= 1) return;
+  if (formData.snList.length <= 0) return;
   formData.snList.splice(index, 1);
   // 清除对应行校验提示
   formRef.value?.clearValidate(["snList", index, "sn"]);
@@ -124,9 +132,14 @@ watch(
 const handleSubmit = async () => {
   try {
     loading.value = true;
-    await formRef.value?.validate();
+    const sndata = await formRef.value?.validate();
+    console.log("formRef", sndata);
+    let list = formData.snList?.map((item) => item.sn) || [];
+    list.push(sndata.sn)
     // 抛出所有SN数组给父组件
     const snArr = formData.snList.map((item) => item.sn.trim());
+    await cameraAdd({snCodes: list.join(',') })
+    message.success(t('common.success'));
     emit("confirm", snArr);
     emit("update:open", false);
   } catch (err) {

+ 50 - 25
src/views/device/components/cooperationModal.vue

@@ -1,13 +1,14 @@
 <template>
   <a-modal 
     title="协作设置" 
-    :open="visible" 
-    @cancel="handleCancel" 
+    v-model:open="open"
     :width="500"
+    @cancel="handleCancels" 
+    :footer="null"
     @ok="handleConfirm"
   >
     <!-- 选择用户下拉框 -->
-    <a-select
+    <!-- <a-select
       v-model:value="state.value"
       mode="multiple"
       size="large"
@@ -18,14 +19,30 @@
       not-found-content="暂无匹配用户"
       :options="state.data"
       @search="handleSearch"
-    />
+    /> -->
+      <a-input
+        size="large"
+        v-model:value="userName"
+        :placeholder="$t('common.input')"
+      />
+      
+    <!-- 底部:复选框 + 按钮 -->
+    <div class="ant-modal-footer modal-footer flex justify-end">
+      <div class="btn-group">
+        <a-button style="margin-right: 16px" @click="handleCancel">{{props.userName?$t('scene.sceneAdmin.qxxz'):$t('common.cancel')}}</a-button>
+        <a-button type="primary" @click="handleConfirm">{{$t('common.comfirm')}}</a-button>
+      </div>
+    </div>
   </a-modal>
 </template>
 
 <script setup>
 import { ref, reactive, defineEmits, defineProps, watch, onUnmounted } from "vue";
+import { useI18n } from "vue-i18n";
 import { debounce } from "lodash-es";
-
+import { message } from "ant-design-vue";
+import { saveCooperationUser, deleteCooperationUser } from "@/api/equity";
+const { t } = useI18n();
 // 1. 定义Props(增加类型校验和注释)
 const props = defineProps({
   visible: {
@@ -33,13 +50,16 @@ const props = defineProps({
     default: false,
     description: "弹窗是否可见"
   },
-  initialName: {
+  ids: {
+    type: Array,
+    default: [],
+  },
+  userName: {
     type: String,
-    default: "",
-    description: "初始名称(重命名用)"
+    default: '',
   }
 });
-
+const open = ref(props.visible);
 // 2. 定义Emits(明确事件类型)
 const emit = defineEmits(["update:visible", "confirm"], {
   confirm: (value) => {
@@ -47,7 +67,7 @@ const emit = defineEmits(["update:visible", "confirm"], {
     return typeof value === "string" || Array.isArray(value);
   }
 });
-
+const userName = ref(props.userName)
 // 3. 响应式数据(规范化初始化)
 const state = reactive({
   data: [], // 下拉框选项
@@ -106,8 +126,11 @@ const handleSearch = (value) => {
 watch(
   () => props.visible,
   (val) => {
+    console.log(val);
     if (val) {
       localName.value = props.initialName;
+      userName.value = props.userName;
+      open.value = true;
       state.value = []; // 打开弹窗清空选中值
     } else {
       fetchUser.cancel(); // 关闭弹窗取消防抖请求
@@ -123,29 +146,31 @@ onUnmounted(() => {
 });
 
 // 8. 取消弹窗逻辑
-const handleCancel = () => {
+const handleCancel = async () => {
+  if(props.userName){
+    await deleteCooperationUser({ids:props.ids.join(',')})
+    message.success(t('scene.sceneAdmin.qxxzTip'));
+  }
+  emit("update:visible", false);
+};
+const handleCancels = async () => {
   emit("update:visible", false);
 };
 
+
 // 9. 确认逻辑(区分重命名/选择用户场景)
-const handleConfirm = () => {
+const handleConfirm = async () => {
   // 场景1:重命名(使用输入框值)
-  const renameValue = localName.value.trim();
-  if (renameValue) {
-    emit("confirm", renameValue);
-    emit("update:visible", false);
-    return;
-  }
-
-  // 场景2:选择用户(使用下拉选中值)
-  if (state.value.length > 0) {
-    emit("confirm", state.value);
+  const ids = props.ids || [] ;
+  if (userName.value.trim()) {
+    await saveCooperationUser({ids:ids.join(','),userName: userName.value.trim()})
+    message.success(t('common.success'));
+    emit("confirm");
     emit("update:visible", false);
     return;
+  }else{
+    message.warn(t('deviceAdmin.bindSnCodeIsNull'));
   }
-
-  // 提示用户(可替换为UI组件库的message)
-  alert("请输入重命名内容或选择用户!");
 };
 </script>
 

+ 8 - 7
src/views/device/data.ts

@@ -5,12 +5,14 @@ import i18n from "@/locales/index.js";
 const { t , locale} = i18n.global;
 
 export const equityColumns = [
-  { title: "会员权益ID", dataIndex: "id" },
-  { title: "授权相机S/N码", dataIndex: "snCode" },
-  { title: "容量使用情况", dataIndex: "capacity" },
-  { title: "下载次数剩余", dataIndex: "remain" },
-  { title: "生效日期", dataIndex: "incrementStartStr" },
-  { title: "到期日期", dataIndex: "incrementEndTime" },
+  { title: "SN码", dataIndex: "snCode" },
+  { title: "会员类型", dataIndex: "memberLevels", customRender: ({text}) => text=='PR'?'svip':text=='SE'?'vip':t('mall.member.normal') },
+  { title: "设备类型", dataIndex: "cameraType", customRender: ({text}) => text && t("camera.device."+text) },
+  { title: "云容量", dataIndex: "usedSpaceStr" },
+  { title: "权益到期时间", dataIndex: "spaceEndStr" },
+  { title: "最后拍摄时间", dataIndex: "lastTime" },
+  { title: "拍摄数量", dataIndex: "sceneNum" },
+  { title: "协作者", dataIndex: "cooperationUserName" },
   { title: "操作", slotName: "operate", width: 180 },
 ]
 export const downColumns = [
@@ -80,5 +82,4 @@ export const getCameraType = [
   { value: CameraType.SWKJ, label: t("camera.device."+CameraType.SWKJ) },
   { value: CameraType.SWSS, label: t("camera.device."+CameraType.SWSS)  },
   { value: CameraType.SWSX, label: t("camera.device."+CameraType.SWSX)  },
-  { value: 0, label: t("common.myUpload")  },
 ]

+ 51 - 128
src/views/device/index.vue

@@ -1,8 +1,8 @@
 <script setup>
-import { ref, h, onMounted, createVNode } from "vue";
+import { ref, h, onMounted, createVNode, watch, reactive } from "vue";
 import { useRouter, useRoute } from "vue-router";
-import { useI18n } from "vue-i18n";
-import { Modal } from "ant-design-vue";
+import { useI18n,  } from "vue-i18n";
+import { Modal, message } from "ant-design-vue";
 import {
   QuestionCircleOutlined,
   ExclamationCircleFilled,
@@ -12,8 +12,8 @@ import {
   EditOutlined,
   CameraOutlined,
 } from "@ant-design/icons-vue";
-import { sceneList } from "@/api/scene";
-import { incrementList } from "@/api/equity";
+import { cameraList } from "@/api/scene";
+import { incrementList, cameraUnbind } from "@/api/equity";
 import BaseTable from "@/components/BaseTable/index.vue";
 import myModal from "@/components/myModal.vue";
 import bindCameraModal from "./components/bindCameraModal.vue";
@@ -35,12 +35,15 @@ const activeKey = ref("0");
 const baseTableRef = ref();
 const snKey = ref("");
 const cameraType = ref(null);
-const numKey = ref([]);
+const childName = ref(null);
+const selectids = ref(null);
+const ids = ref([]);
 const selectTable = ref([]);
 
 const cooperation = ref({
   show: false,
-  name: "",
+  userName: '',
+  ids: [],
 });
 
 const searchType = ref("name");
@@ -55,21 +58,18 @@ const total = ref(0);
 const options = [...Array(25)].map((_, i) => ({
   value: (i + 10).toString(36) + (i + 1),
 }));
-const rowSelection = {
+
+const rowSelection = ref({
   onChange: (selectedRowKeys, selectedRows) => {
-    console.log(
-      `selectedRowKeys: ${selectedRowKeys}`,
-      "selectedRows:",
-      selectedRows
-    );
-    numKey.value = selectedRows.map((item) => item.num);
+    console.log(`selectedRowKeys: ${selectedRowKeys}`, "selectedRows: ", selectedRows);
+    ids.value =  selectedRows.map((item) => item.id);
     selectTable.value = selectedRows;
   },
   getCheckboxProps: (record) => ({
     disabled: record.name === "Disabled User", // Column configuration not to be checked
     name: record.name,
   }),
-};
+});
 const handleCustomRow = (record, index) => {
   return {
     // 行单击事件(完全替代 row-click)
@@ -84,7 +84,7 @@ const handleCustomRow = (record, index) => {
 // 接口查询(只接收组件抛出来的分页参数)
 const getList = async ({ pageNum, pageSize }) => {
   loading.value = true;
-  const res = await incrementList({ pageNum, pageSize, sn: snKey.value });
+  const res = await cameraList({ pageNum, pageSize, cameraType: cameraType.value, childName: childName.value, selecttype: 2 });
   console.log(res, "sceneList");
   tableList.value = res.list;
   total.value = res.total;
@@ -93,81 +93,40 @@ const getList = async ({ pageNum, pageSize }) => {
 
 // 点击搜索:重置页码到第1页再查询
 const searchClick = () => {
+  // rowSelection.value.selectedRowKeys = [] // 先清空受控key
+  ids.value =  [];
+  selectTable.value = [];
   baseTableRef.value.resetPage();
 };
-const handleItem = (record, item) => {
-  switch (record.key) {
-    case "more":
-      router.push({ path: "/sceneomore/obj/" + item.num });
-      break;
-    case "collaborativeScene":
-      handleEditItem(record);
-      break;
-  }
-  console.log(record, "handleItem", record, item);
-};
 const handleSee = (record) => {
   let path = SceneTypePaths[record.sceneSource];
   window.open(path[0], "_blank");
 };
-const handleEditItem = (record) => {
-  if (record.tbType == 'f') {
-    // 修改文件夹
-    return handleRename(record);
-  }
-  let path = SceneTypePaths[record.sceneSource];
-  window.open(path[1], "_blank");
-};
-const hendleCooperation = () => {
-  const isNorn = selectTable.value.some(
-    (item) => item.status == 2 || item.status == 0
-  );
-  console.log(isNorn, "isNorn");
-  if (isNorn) {
-    Modal.confirm({
-      title: t("common.tip"),
-      icon: createVNode(ExclamationCircleFilled),
-      content: "计算中/已封存的场景无法协作",
-      // okText: '确认',
-      // cancelText: '取消',
-      onOk: async () => {
-        await userStore.logout();
-      },
-    });
-  } else {
-    cooperationRef.value.openModal(numKey);
-  }
-};
-const handleFolder = () => {
-  moveModalRef.value.openModal();
-};
-const handleRowClick = (row) => {
-  console.log(row, "handleRowClick");
+const handleCooperation = (record) => {
+  cooperation.value = {
+    show: true,
+    userName: record.cooperationUserName || '',
+    ids: record.id?[record.id]:ids.value
+  };
 };
 const handleUploadSuccess = () => {
   console.log("上传成功的文件列表:", fileList);
 };
-const handleEquity = (record) => {
-  if (record.snCode) {
+const handleUnbind = (record) => {
+  let list = record.id?[record.id]:ids.value
     Modal.confirm({
       title: t("common.tip"),
       icon: createVNode(ExclamationCircleFilled),
-      content: "确定要解除该相机的权益吗?",
+      content: t('toast.26'),
+      centered: true,
       // okText: '确认',
       // cancelText: '取消',
       onOk: async () => {
-        // await userStore.logout()
+        await cameraUnbind({ids: list.join(',') })
+        message.success(t('common.success'));
+        searchClick()
       },
     });
-  } else {
-    handleRename(record);
-  }
-};
-const handleRename = (record) => {
-  cooperation.value = {
-    show: true,
-    name: record.name,
-  };
 };
 const onRenameConfirm = (name) => {
   console.log("改名成功");
@@ -183,6 +142,9 @@ onMounted(() => {
 
   getList({ pageNum: 1, pageSize: 20 });
 });
+watch([() => cameraType.value, ()=>childName.value], () => {
+  searchClick();
+});
 </script>
 <template>
   <div class="scene-layout">
@@ -194,10 +156,10 @@ onMounted(() => {
         :loading="loading"
         :total="total"
         :bordered="false"
-        rowKey="num"
+        rowKey="id"
         default-page-size="20"
         @search="getList"
-        :row-selection="rowSelection"
+        :rowSelectionOptions="rowSelection"
         :customRow="handleCustomRow"
         ref="baseTableRef"
       >
@@ -205,7 +167,7 @@ onMounted(() => {
         <template #header>
           <div
             class="tableHeader flex justify-between items-center mb-5 w-full"
-            v-if="numKey.length == 0"
+            v-if="ids.length == 0"
           >
             <div
               class="headerLeft flex justify-start items-center gap-x-4 position-relative"
@@ -224,10 +186,11 @@ onMounted(() => {
                 style="width: auto; margin-right: 10px"
               >
                 <a-input
-                  v-model:value="value4"
+                  v-model:value="childName"
                   style="width: 400px"
                   size="large"
-                  placeholder="请输入商品名称或订单编号"
+                  allowClear
+                  placeholder="请输入S/N码搜索"
                 >
                   <template #prefix><SearchOutlined /></template>
                 </a-input>
@@ -241,76 +204,36 @@ onMounted(() => {
             v-else
           >
             <a-space size="middle">
-              <a-button class="operation" size="small">
+              <a-button class="operation" size="small" @click="handleCooperation">
                 <i class="iconfont icon-menu_cooperation1"></i>
-                <span class="operationText">协作</span>
+                <!-- <span class="operationText">协作</span> -->
               </a-button>
-              <a-button class="operation" size="small" @click="handleFolder">
+              <a-button class="operation" size="small" @click="handleUnbind">
                 <i class="iconfont icon-unbind"></i>
-                <span class="operationText">解绑</span>
+                <!-- <span class="operationText">解绑</span> -->
               </a-button>
             </a-space>
           </div>
         </template>
         <!-- 操作列插槽 -->
-        <template #sceneName="{ record }">
-          <div
-            class="sceneName flex items-center cursor-pointer"
-            @click="handleSee(record)"
-          >
-            <div class="sceneleft w-32 h-22 relative rounded-lg">
-              <img
-                v-if="record.tbType == 'f'"
-                src="/src/assets/image/scene/folder.png"
-                alt=""
-                class="Folder w-32 h-22 mr-2"
-              />
-              <img
-                v-else
-                :src="getStatusImg(record)"
-                alt=""
-                class="w-32 h-22 rounded-lg mr-2"
-              />
-
-              <div
-                class="loading-hover absolute rounded-lg"
-                v-if="record.status != -2"
-              >
-                <span>{{ getStatusText(record) }}</span>
-              </div>
-            </div>
-            <div class="sceneNameTight ml-4">
-              <div class="sceneNameTitle text-base" style="color: #1d2129">
-                {{ record.name || record.sceneName }}
-              </div>
-              <div
-                class="sceneTime text-sm"
-                style="margin-top: 6px; color: #86909c"
-              >
-                更新于{{ record.createTime }}
-              </div>
-            </div>
-          </div>
-        </template>
-        <!-- 操作列插槽 -->
         <template #operate="{ record }">
           <a-space size="middle">
-            <a-button class="operation" @click.stop="handleEquity(record)">
+            <a-button class="operation"  @click.stop="handleCooperation(record)">
 
                 <i class="iconfont icon-menu_cooperation1"></i>
-                <span class="operationText">协作</span>
+                <!-- <span class="operationText">协作</span> -->
             </a-button>
-            <a-button class="operation" @click.stop="open = true">
+            <a-button class="operation" @click.stop="handleUnbind(record)">
                 <i class="iconfont icon-unbind"></i>
-                <span class="operationText">解绑</span>
+                <!-- <span class="operationText">解绑</span> -->
             </a-button>
           </a-space>
         </template>
       </BaseTable>
     </div>
     <div class="moadl">
-      <bindCameraModal v-model:open="bindModalOpen" @confirm="handleBindCamera" />
-      <cooperationModal v-model:visible="cooperation.show" @confirm="onRenameConfirm" />
+      <bindCameraModal v-model:open="bindModalOpen" :cameraType="cameraType" @confirm="searchClick" />
+      <cooperationModal v-model:visible="cooperation.show" :userName="cooperation.userName" :ids="cooperation.ids" @confirm="searchClick" />
     </div>
   </div>
 </template>

+ 17 - 14
src/views/equity/components/bindCameraModal.vue

@@ -7,7 +7,7 @@
     @ok="handleConfirm"
   >
     <!-- 选择用户下拉框 -->
-    <a-select
+    <!-- <a-select
       v-model:value="state.value"
       mode="multiple"
       size="large"
@@ -18,6 +18,11 @@
       not-found-content="暂无匹配用户"
       :options="state.data"
       @search="handleSearch"
+    /> -->
+    <a-input
+      size="large"
+      v-model:value="snCode"
+      :placeholder="$t('common.input')"
     />
   </a-modal>
 </template>
@@ -33,13 +38,14 @@ const props = defineProps({
     default: false,
     description: "弹窗是否可见"
   },
-  initialName: {
-    type: String,
-    default: "",
-    description: "初始名称(重命名用)"
+  item: {
+    type: Object,
+    default: {},
   }
 });
 
+const snCode = ref("");
+
 // 2. 定义Emits(明确事件类型)
 const emit = defineEmits(["update:visible", "confirm"], {
   confirm: (value) => {
@@ -54,8 +60,6 @@ const state = reactive({
   value: [], // 选中值
   fetching: false, // 是否加载中
 });
-// 重命名输入值(原逻辑遗漏绑定)
-const localName = ref(props.initialName);
 
 // 4. 声明缺失变量 + 防抖函数(增加取消逻辑)
 let lastFetchId = 0;
@@ -107,11 +111,11 @@ watch(
   () => props.visible,
   (val) => {
     if (val) {
-      localName.value = props.initialName;
-      state.value = []; // 打开弹窗清空选中值
+      snCode.value = '';
+      // state.value = []; // 打开弹窗清空选中值
     } else {
-      fetchUser.cancel(); // 关闭弹窗取消防抖请求
-      state.fetching = false;
+      // fetchUser.cancel(); // 关闭弹窗取消防抖请求
+      // state.fetching = false;
     }
   },
   { immediate: true }
@@ -130,9 +134,8 @@ const handleCancel = () => {
 // 9. 确认逻辑(区分重命名/选择用户场景)
 const handleConfirm = () => {
   // 场景1:重命名(使用输入框值)
-  const renameValue = localName.value.trim();
-  if (renameValue) {
-    emit("confirm", renameValue);
+  if (snCode.value.trim()) {
+    emit("confirm", snCode.value.trim());
     emit("update:visible", false);
     return;
   }

+ 18 - 14
src/views/equity/data.ts

@@ -3,22 +3,29 @@ import scene_error_en from "@/assets/image/scene/scene_error-en.png";
 import { useI18n } from "vue-i18n";
 import i18n from "@/locales/index.js";
 const { t , locale} = i18n.global;
-
+import { autoFormatKbSplit } from "@/utils/index.js";
 export const equityColumns = [
   { title: "会员权益ID", dataIndex: "id" },
   { title: "授权相机S/N码", dataIndex: "snCode" },
-  { title: "容量使用情况", dataIndex: "capacity" },
-  { title: "下载次数剩余", dataIndex: "remain" },
+  { title: "容量使用情况", dataIndex: "capacity", customRender: ({record}) => {
+    if(record.space == null) return
+    if(record.unit == 'GB'){
+      return `${autoFormatKbSplit(record.space)}/${record.totalSpace==-1?t('mall.member.privilegeCapacity'):autoFormatKbSplit(record.totalSpace)}`
+    }else{
+      return `${record.space}/${record.totalSpace==-1?t('mall.member.spacesMax'):record.totalSpace}`
+    }
+  } },
+  // { title: "下载次数剩余", dataIndex: "remain" },
   { title: "生效日期", dataIndex: "incrementStartStr" },
   { title: "到期日期", dataIndex: "incrementEndTime" },
   { title: "操作", slotName: "operate", width: 180 },
 ]
 export const downColumns = [
-  { title: "操作类型", dataIndex: "id" },
-  { title: "内容", dataIndex: "sn" },
-  { title: "场景码", dataIndex: "capacity" },
-  { title: "次数", dataIndex: "remain" },
-  { title: "日期", dataIndex: "startDate" },
+  { title: "操作类型", dataIndex: "id", slotName: "useType" },
+  { title: "内容", dataIndex: "content", width: 300, ellipsis: true, },
+  { title: "场景码", dataIndex: "num" },
+  { title: "次数", dataIndex: "count", width: 80 },
+  { title: "日期", dataIndex: "createTime" },
 ]
 export const getStatusImg = (record: any) => {
   switch (record.status) {
@@ -74,11 +81,8 @@ export const SceneTypePaths = {
   [SceneType.SWYDSS]: ["/swss/index.html", "/swss/index.html"],
   [SceneType.SWYDMX]: ["/swkk/spg.html", "/swkk/epg.html"],
 };
-export const getCameraType = [
+export const getdownType = [
   { value: null, label: t("common.all") },
-  { value: CameraType.SWKK, label: t("camera.device."+CameraType.SWKK) },
-  { value: CameraType.SWKJ, label: t("camera.device."+CameraType.SWKJ) },
-  { value: CameraType.SWSS, label: t("camera.device."+CameraType.SWSS)  },
-  { value: CameraType.SWSX, label: t("camera.device."+CameraType.SWSX)  },
-  { value: 0, label: t("common.myUpload")  },
+  { value: 'sub', label: '使用'  },
+  { value: 'add', label: '购买'  },
 ]

+ 65 - 63
src/views/equity/index.vue

@@ -1,8 +1,9 @@
 <script setup>
-import { ref, h, onMounted, createVNode } from "vue";
+import { ref, h, onMounted, createVNode, watch } from "vue";
 import { useRouter, useRoute } from "vue-router";
 import { useI18n } from "vue-i18n";
-import { Modal } from 'ant-design-vue';
+import { Modal, message } from 'ant-design-vue';
+const regino = import.meta.env.VITE_REGION
 import {
   QuestionCircleOutlined,
   ExclamationCircleFilled,
@@ -12,20 +13,25 @@ import {
   EditOutlined,
 } from "@ant-design/icons-vue";
 import { sceneList } from "@/api/scene";
-import { incrementList } from "@/api/equity";
+import { incrementList, userDownLog } from "@/api/equity";
 import BaseTable from "@/components/BaseTable/index.vue";
 import bindCameraModal from './components/bindCameraModal.vue';
+import { unbindCamera, bindCamera } from "@/api/scene/index.js";
 import {
   equityColumns,
   downColumns,
   getStatusImg,
   getStatusText,
   SceneTypePaths,
-  getCameraType,
+  getdownType,
 } from "./data";
 const { t } = useI18n();
 const router = useRouter();
-const activeKey = ref("0");
+const { query } = useRoute();
+console.log(query, "route");
+const activeKey = ref(query.type || "0");
+const useType = ref(null);
+
 const tablist = ref([
   {
     label: '会员权益',
@@ -38,15 +44,15 @@ const tablist = ref([
     key: "1",
   },
 ]);
-
+const snCode = ref(""); // 搜索框的值
 // 仅需维护这4个变量
 const baseTableRef = ref();
 const snKey = ref("");
 const numKey = ref([]);
 const selectTable = ref([]);
-const bindCamera = ref({
+const bindCameraRef = ref({
   show: false,
-  name: '',
+  item: '',
 });
 const searchType = ref("name");
 
@@ -74,7 +80,8 @@ const rowSelection = {
 // 接口查询(只接收组件抛出来的分页参数)
 const getList = async ({ pageNum, pageSize }) => {
   loading.value = true;
-  const res = await incrementList({ pageNum, pageSize, sn: snKey.value });
+  const apiSrc = activeKey.value === "0" ? incrementList : userDownLog;
+  const res = await apiSrc({ pageNum, pageSize, snCode: snCode.value, useType: useType.value });
   console.log(res, "sceneList");
   tableList.value = res.list;
   total.value = res.total;
@@ -114,6 +121,7 @@ const hendleCooperation = () => {
     Modal.confirm({
         title: t('common.tip'),
         icon: createVNode(ExclamationCircleFilled),
+        centered: true,
         content: '计算中/已封存的场景无法协作',
         // okText: '确认',
         // cancelText: '取消',
@@ -142,11 +150,13 @@ const handleEquity = (record)=> {
     Modal.confirm({
         title: t('common.tip'),
         icon: createVNode(ExclamationCircleFilled),
+        centered: true,
         content: '确定要解除该相机的权益吗?',
         // okText: '确认',
         // cancelText: '取消',
         onOk: async () => {
-          // await userStore.logout()
+          await unbindCamera({id: record.id})
+          baseTableRef.value.resetPage();
         }
       });
   }else{
@@ -154,18 +164,38 @@ const handleEquity = (record)=> {
   }
 }
 const handleRename = (record) => {
-  bindCamera.value = {
+  bindCameraRef.value = {
     show: true,
-    name: record.name,
+    item: record,
   };
 };
-const onRenameConfirm = (name) => {
-  console.log('改名成功');
-  bindCamera.value.name = name;
+const handleBindConfirm = async (snCode) => {
+  await bindCamera({id: bindCameraRef.value.item.id, snCode})
+  message.success(t('member.authSuccess'))
+  searchClick();
+}
+const handleCancel = async (record) => {
+    Modal.confirm({
+        title: t('common.tip'),
+        icon: createVNode(ExclamationCircleFilled),
+        content: t('member.continuousTips'),
+        centered: true,
+        // okText: '确认',
+        // cancelText: '取消',
+        onOk: async () => {
+          await cancelSubscription({id: record.id})
+          baseTableRef.value.resetPage();
+        }
+      });
 }
+function handleRenew(item) {
+    router.push({path: `/mall/membership/${item.memberLevels == 'SE'?'7':'5'}`, query: {incrementId: item.id}})
+  }
+watch([() => activeKey.value, ()=>useType.value], () => {
+  searchClick();
+});
 onMounted(() => {
   // 初始化默认查询,不传分页,组件内部自动1页
-
   getList({ pageNum: 1, pageSize: 20 });
 });
 
@@ -200,10 +230,11 @@ onMounted(() => {
             >
               <a-input-group v-if="activeKey == '0'" compact style="width: auto; margin-right: 10px">
                 <a-input
-                  v-model:value="value4"
+                  v-model:value="snCode"
                   style="width: 400px"
                   size="large"
-                  :placeholder="$t('common.search')"
+                  @change="searchClick"
+                  :placeholder="$t('information.memberTable.snInputPlaceHolder')"
                 >
                   <template #prefix><SearchOutlined /></template>
                 </a-input>
@@ -211,8 +242,8 @@ onMounted(() => {
               <a-space v-else>
                 <a-select
                   size="large"
-                  v-model:value="value17"
-                  :options="getCameraType"
+                  v-model:value="useType"
+                  :options="getdownType"
                   style="width: 200px; height: 44px"
                 />
               </a-space>
@@ -248,60 +279,31 @@ onMounted(() => {
           </div>
         </template>
         <!-- 操作列插槽 -->
-        <template #sceneName="{ record }">
-          <div
-            class="sceneName flex items-center cursor-pointer"
-            @click="handleSee(record)"
-          >
-            <div class="sceneleft w-32 h-22 relative rounded-lg">
-              <img
-                v-if="record.tbType == 'f'"
-                src="/src/assets/image/scene/folder.png"
-                alt=""
-                class="Folder w-32 h-22 mr-2"
-              />
-              <img
-                v-else
-                :src="getStatusImg(record)"
-                alt=""
-                class="w-32 h-22 rounded-lg mr-2"
-              />
-
-              <div
-                class="loading-hover absolute rounded-lg"
-                v-if="record.status != -2"
-              >
-                <span>{{ getStatusText(record) }}</span>
-              </div>
-            </div>
-            <div class="sceneNameTight ml-4">
-              <div class="sceneNameTitle text-base" style="color: #1d2129">
-                {{ record.name || record.sceneName }}
-              </div>
-              <div
-                class="sceneTime text-sm"
-                style="margin-top: 6px; color: #86909c"
-              >
-                更新于{{ record.createTime }}
-              </div>
-            </div>
-          </div>
+        <template #useType="{ record }">
+          <div>{{ getdownType.find(item => item.value == record.useType).label }}</div>
         </template>
         <!-- 操作列插槽 -->
         <template #operate="{ record }">
           <a-space size="middle">
-              <a-button class="operation" @click="handleEquity(record)">
-                <i class="iconfont icon-ar"></i>
+              <a-button class="operation" :title="$t(record.snCode?'member.unbtn':'member.bindbtn')"  @click="handleEquity(record)">
+                <i class="iconfont icon-common_toolbar_release" v-if="record.snCode"></i>
+                <i class="iconfont icon-common_toolbar_authorize" v-else></i>
+              </a-button>
+              <a-button class="operation" :title="$t('member.renew')" @click="handleRenew(record)" v-if="regino == 'CN'">
+                <i class="iconfont icon-common_toolbar_subscribe"></i>
+              </a-button>
+              <a-button class="operation" :title="$t('member.cancelContinuous')" @click="handleCancel(record)" v-else-if="record.subscriptionOrder">
+                <i class="iconfont icon-common_toolbar_unsubscribe"></i>
               </a-button>
-              <a-button class="operation">
-                <i class="iconfont icon-download"></i>
+              <a-button class="operation" :title="$t('member.renew')" @click="handleRenew(record)" v-else>
+                <i class="iconfont icon-common_toolbar_subscribe"></i>
               </a-button>
           </a-space>
         </template>
       </BaseTable>
     </div>
     <div class="moadl">
-        <bindCameraModal v-model:visible="bindCamera.show" @confirm="onRenameConfirm"/>
+        <bindCameraModal v-model:visible="bindCameraRef.show" @confirm="handleBindConfirm"/>
     </div>
   </div>
 </template>

+ 0 - 1
src/views/home/components/map.vue

@@ -1,6 +1,5 @@
 <template>
   <!-- 地图容器必须设置宽高,统一ID逻辑 -->
-  <div @click="destroyMarkerCluster">clear</div>
   <div :id="id || 'map-container'" style="width: 100%; height: 600px; border-radius: 8px;"></div>
 </template>
 

+ 38 - 12
src/views/home/index.vue

@@ -1,5 +1,5 @@
 <script setup>
-import { ref, h } from 'vue'
+import { ref, h, onMounted } from 'vue'
 import homeNotice from './components/notice.vue'
 import LeafletMap from './components/map.vue'
 import myModal from '@/components/myModal.vue'
@@ -18,37 +18,63 @@ import ai from '@/assets/image/home/ai.png'
 import vr from '@/assets/image/home/vr.png'
 import qjkk from '@/assets/image/home/qjkk.png'
 import { useUserStore } from '@/stores'
+import { userSerciceList } from '@/api/mall'
+import { hasPanoramaUserService, PANORAMA_SERVICE_ID } from '@/views/mall/data.ts'
+
 const open = ref(false)
 const router = useRouter()
 const user = useUserStore()
 const mapRef = ref(null)
 const [modal, contextHolder] = Modal.useModal();
 const { openModal } = useModal()
-const equityList = ref([
+
+const allEquityList = [
   {
+    id: 2,
     title: '离线包下载',
     img: down,
-    path: '/mall/details/100'
+    path: '/mall/sercice/2'
   },{
+    id: PANORAMA_SERVICE_ID,
     title: '四维全景',
     img: qjkk,
-    path: '/download'
+    path: '/mall/sercice/1'
   },{
     title: 'VR小程序对接',
     img: vr,
-    path: '/mall/details/101'
+    url: 'https://affim.baidu.com/unique_54577246/chat?siteId=22911125&userId=54577246&siteToken=29903ef13e9612af77266f2b97d1c715 '
   },{
+    id: 3,
     title: 'AI训练',
     img: ai,
-    path: '/mall/details/102'
+    path: '/mall/sercice/3'
   },
-])
+]
+
+const equityList = ref([...allEquityList])
+
+const loadEquityList = async () => {
+  try {
+    const userList = await userSerciceList()
+    const showPanorama = hasPanoramaUserService(userList)
+    equityList.value = allEquityList.filter(
+      (item) => item.id !== PANORAMA_SERVICE_ID || showPanorama
+    )
+  } catch {
+    equityList.value = allEquityList.filter((item) => item.id !== PANORAMA_SERVICE_ID)
+  }
+}
+
+onMounted(loadEquityList)
 const handleMap = async () => {
   open.value = true
 }
 const handleServes = async (item) => {
-  router.push(item.path)
-  console.log(item)
+  if(item.path){
+    router.push(item.path)
+  }else{
+    window.open(item.url)
+  }
 }
 
 </script>
@@ -59,20 +85,20 @@ const handleServes = async (item) => {
       <div class="equity flex-1">
         <div class="title">我的权益</div>
         <div class="list flex gap-4">
-          <div class="member-item flex-1">
+          <div class="member-item flex-1 cursor-pointer" @click="$router.push('/equity')">
             <!-- 左侧图标 -->
             <div class="icon-box">
               <img src="@/assets/image/home/equity.png" alt="会员">
             </div>
             <!-- 中间文字,左对齐 -->
-            <div class="text-group">
+            <div class="text-group" >
               <div class="name">会员权益</div>
               <div class="num">{{user.incrementNum}}/{{user.incrementBindNum}}</div>
             </div>
             <!-- 右侧箭头 -->
             <RightOutlined style="font-size: 12px" />
           </div>
-          <div class="member-item flex-1">
+          <div class="member-item flex-1 cursor-pointer" @click="$router.push('/equity?type=1')">
             <!-- 左侧图标 -->
             <div class="icon-box">
               <img src="@/assets/image/home/downloads.png" alt="会员">

+ 151 - 112
src/views/information/components/brandInfo.vue

@@ -1,94 +1,77 @@
 <template>
   <div class="scene-logo-setting" style="max-width: 720px;">
-    <!-- 场景简介输入框 -->
     <div class="form-item">
       <div class="label">场景简介</div>
       <a-input
-      size="large" style="width: 400px"
-        v-model:value="formData.intro"
+        size="large"
+        style="width: 400px"
+        v-model:value="formData.description"
         placeholder="请输入场景简介"
+        @blur="handleDescriptionSave"
+        @pressEnter="handleDescriptionSave"
       />
     </div>
 
-    <!-- 二维码Logo上传区域 -->
     <div class="form-item">
       <div class="label">二维码Logo</div>
       <div class="upload-list">
-        <!-- 上传占位卡片 -->
-        <div class="upload-card" @click="triggerInput('qrcode')">
-          <span class="plus-icon">+</span>
-        </div>
-        <!-- 已上传图片 -->
         <div
-          v-for="(item, idx) in formData.qrcodeList"
-          :key="idx"
-          class="img-card"
+          class="upload-card"
+          :class="{ uploading: uploadingField === 'shareLogo' }"
+          @click="triggerUpload('shareLogo')"
         >
-                  <a-image
-    :width="100"
-    :src="item.url"
-  />
-          <!-- <img :src="item.url" alt="二维码logo" />
-          <div class="close-btn" @click="removeImg('qrcode', idx)">×</div> -->
+          <loading-outlined v-if="uploadingField === 'shareLogo'" spin />
+          <span v-else class="plus-icon">+</span>
+        </div>
+        <div v-if="formData.shareLogo" class="img-card">
+          <a-image class="brand-logo-image" :width="100" :height="100" :src="getLogoUrl(formData.shareLogo)" />
+          <span class="close-btn" @click="removeLogo('shareLogo')">×</span>
         </div>
       </div>
     </div>
 
-    <!-- 地面Logo组1 -->
     <div class="form-item">
       <div class="label">地面logo</div>
       <div class="upload-list">
-        <div class="upload-card" @click="triggerInput('floor1')">
-          <span class="plus-icon">+</span>
-        </div>
         <div
-          v-for="(item, idx) in formData.floor1List"
-          :key="idx"
-          class="img-card round-card"
+          class="upload-card"
+          :class="{ uploading: uploadingField === 'floorLogo' }"
+          @click="triggerUpload('floorLogo')"
         >
-          <!-- <img :src="item.url" alt="地面logo" /> -->
-          <a-image
-    :width="100"
-    :height="100"
-    :src="item.url"
-  />
-          <!-- <div class="close-btn" @click="removeImg('floor1', idx)">×</div> -->
+          <loading-outlined v-if="uploadingField === 'floorLogo'" spin />
+          <span v-else class="plus-icon">+</span>
+        </div>
+        <div v-if="formData.floorLogo" class="img-card">
+          <a-image class="brand-logo-image" :width="100" :height="100" :src="getLogoUrl(formData.floorLogo)" />
+          <span class="close-btn" @click="removeLogo('floorLogo')">×</span>
         </div>
       </div>
     </div>
 
-    <!-- 地面Logo组2 -->
     <div class="form-item">
       <div class="label">地面logo</div>
       <div class="upload-list">
-        <div class="upload-card" @click="triggerInput('floor2')">
-          <span class="plus-icon">+</span>
-        </div>
         <div
-          v-for="(item, idx) in formData.floor2List"
-          :key="idx"
-          
-          style="width: 180px"
-          class="img-card"
+          class="upload-card"
+          :class="{ uploading: uploadingField === 'loadingLogo' }"
+          @click="triggerUpload('loadingLogo')"
         >
-          <!-- <img :src="item.url" alt="地面logo" /> -->
-                    <a-image
-    :width="180"
-    :height="100"
-    :src="item.url"
-  />
-          <div class="close-btn" @click="removeImg('floor2', idx)">×</div>
+          <loading-outlined v-if="uploadingField === 'loadingLogo'" spin />
+          <span v-else class="plus-icon">+</span>
+        </div>
+        <div v-if="formData.loadingLogo" class="img-card">
+          <a-image class="brand-logo-image" :width="100" :height="100" :src="getLogoUrl(formData.loadingLogo)" />
+          <span class="close-btn" @click="removeLogo('loadingLogo')">×</span>
         </div>
       </div>
     </div>
 
-    <!-- 隐藏原生文件input,替代a-upload -->
     <input
       ref="fileInputRef"
       type="file"
-      accept="image/png,image/jpg,image/jpeg"
+      accept="image/png,image/jpeg,image/jpg,.png,.jpg,.jpeg"
       style="display: none"
-      @change="onFileChange"
+      @change="handleFileSelect"
     />
   </div>
 </template>
@@ -96,74 +79,123 @@
 <script setup lang="ts">
 import { ref, reactive } from 'vue'
 import { message } from 'ant-design-vue'
+import { LoadingOutlined } from '@ant-design/icons-vue'
+import { userBrandInfo, setuserBrand } from '@/api/equity'
+import { useOssUpload } from '@/hooks/useOssUpload'
+
+type LogoField = 'shareLogo' | 'floorLogo' | 'loadingLogo'
 
-type ImageItem = { url: string }
-// 上传分组key
-type UploadType = 'qrcode' | 'floor1' | 'floor2'
+interface BrandInfo {
+  description?: string
+  shareLogo?: string
+  floorLogo?: string
+  loadingLogo?: string
+}
 
-// 替换:原生文件input ref,不再使用a-upload
 const fileInputRef = ref<HTMLInputElement | null>(null)
-// 当前要上传的分组
-const currentUploadType = ref<UploadType>('qrcode')
+const currentUploadField = ref<LogoField>('shareLogo')
+const uploadingField = ref<LogoField | ''>('')
+const lastSavedDescription = ref('')
+const isInitialized = ref(false)
 
-// 表单数据
 const formData = reactive({
-  intro: '四维看看,让空间讲故事',
-  qrcodeList: [
-    { url: '@/assets/image/empty.png' }
-  ] as ImageItem[],
-  floor1List: [
-    { url: '@/assets/demo/floor-logo-round.png' }
-  ] as ImageItem[],
-  floor2List: [
-    { url: '@/assets/demo/floor-logo-square.png' }
-  ] as ImageItem[],
+  description: '四维看看,让空间讲故事',
+  shareLogo: '',
+  floorLogo: '',
+  loadingLogo: '',
 })
 
-// 点击加号卡片 唤起文件选择
-const triggerInput = (type: UploadType) => {
-  currentUploadType.value = type
-  // 清空上次选择,防止选同一张不触发change
-  if (fileInputRef.value) {
-    fileInputRef.value.value = ''
-    fileInputRef.value.click()
-  }
+const { uploadFile, buildFileUrl, toRelativePath, clearUploadList } = useOssUpload({
+  storageKey: 'brand_logo_upload_checkpoints',
+  pathPrefix: '/ucenter/brand/',
+  allowedExt: ['.jpg', '.jpeg', '.png'],
+  allowedTypes: ['image/jpeg', 'image/png', 'image/jpg'],
+  maxSize: 2 * 1024 * 1024,
+})
+
+const getLogoUrl = (path: string) => buildFileUrl(path)
+
+const triggerUpload = (field: LogoField) => {
+  if (uploadingField.value) return
+  currentUploadField.value = field
+  fileInputRef.value?.click()
 }
 
-// 文件选择回调,替代 beforeUpload
-const onFileChange = (e: Event) => {
-  const target = e.target as HTMLInputElement
-  const file = target.files?.[0]
-  if (!file) return
+const saveBrandInfo = async () => {
+  await setuserBrand({
+    description: formData.description,
+    shareLogo: formData.shareLogo,
+    floorLogo: formData.floorLogo,
+    loadingLogo: formData.loadingLogo,
+  })
+  lastSavedDescription.value = formData.description
+}
 
-  // 文件校验
-  const isImg = ['image/jpeg', 'image/png', 'image/jpg'].includes(file.type)
-  if (!isImg) {
-    message.warning('仅支持jpg、png图片格式')
-    return
+const handleDescriptionSave = async () => {
+  if (!isInitialized.value) return
+  if (formData.description === lastSavedDescription.value) return
+  await saveBrandInfo()
+  message.success('保存成功')
+}
+
+const handleFileSelect = async (event: Event) => {
+  const input = event.target as HTMLInputElement
+  const rawFile = input.files?.[0]
+  input.value = ''
+  if (!rawFile) return
+
+  const field = currentUploadField.value
+  uploadingField.value = field
+
+  try {
+    const result = await uploadFile(rawFile)
+    if (result?.ossPath) {
+      formData[field] = result.ossPath
+      await saveBrandInfo()
+      message.success('上传并保存成功')
+    }
+  } catch {
+    // 错误提示由 hook 统一处理
+  } finally {
+    uploadingField.value = ''
+    clearUploadList()
   }
-  if (file.size / 1024 / 1024 > 2) {
-    message.warning('图片大小不能超过2MB')
+}
+
+const removeLogo = async (field: LogoField) => {
+  formData[field] = ''
+  await saveBrandInfo()
+  message.success('图片已移除')
+}
+
+const getInfo = async () => {
+  const res = (await userBrandInfo()) as BrandInfo | null | undefined
+  if (!res) {
+    isInitialized.value = true
+    lastSavedDescription.value = formData.description
     return
   }
-
-  // 模拟上传,生产环境替换为OSS/后端上传接口
-  const tempUrl = URL.createObjectURL(file)
-  const targetList = formData[`${currentUploadType.value}List`] as ImageItem[]
-  targetList.push({ url: tempUrl })
+  formData.description = res.description ?? formData.description
+  formData.shareLogo = toRelativePath(res.shareLogo ?? '')
+  formData.floorLogo = toRelativePath(res.floorLogo ?? '')
+  formData.loadingLogo = toRelativePath(res.loadingLogo ?? '')
+  lastSavedDescription.value = formData.description
+  isInitialized.value = true
 }
 
-// 删除图片
-const removeImg = (type: UploadType, index: number) => {
-  const targetList = formData[`${type}List`] as ImageItem[]
-  targetList.splice(index, 1)
-  message.success('图片已移除')
+const setInfo = async () => {
+  await saveBrandInfo()
+  message.success('保存成功')
 }
+
+getInfo()
+
+defineExpose({ getInfo, setInfo, formData })
 </script>
 
 <style scoped lang="less">
 .scene-logo-setting {
-  padding: 20px 0 0 0 ;
+  padding: 20px 0 0 0;
 }
 .form-item {
   margin-bottom: 40px;
@@ -171,17 +203,15 @@ const removeImg = (type: UploadType, index: number) => {
 .label {
   font-weight: normal;
   font-size: 14px;
-  color: #1D2129;
+  color: #1d2129;
   margin: 20px 0;
   display: block;
 }
-// 上传+图片横向排列容器
 .upload-list {
   display: flex;
   gap: 16px;
   align-items: center;
 }
-// 加号上传卡片
 .upload-card {
   width: 100px;
   height: 100px;
@@ -196,12 +226,15 @@ const removeImg = (type: UploadType, index: number) => {
     font-size: 32px;
     color: #999;
   }
-  &:hover {
+  &.uploading {
+    cursor: not-allowed;
+    background: #ebf4ff;
+  }
+  &:hover:not(.uploading) {
     background: #ebf4ff;
     border-color: #91caff;
   }
 }
-// 已上传图片卡片
 .img-card {
   width: 100px;
   height: 100px;
@@ -209,17 +242,22 @@ const removeImg = (type: UploadType, index: number) => {
   background: #d0d2d6;
   border-radius: 4px;
   overflow: hidden;
-  img {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+
+  :deep(.brand-logo-image) {
     width: 100%;
     height: 100%;
-    object-fit: contain;
-  }
-  // 圆形地面logo专用圆角
-  &.round-card {
-    border-radius: 50%;
+
+    .ant-image-img {
+      width: 100%;
+      height: 100%;
+      object-fit: contain;
+      object-position: center;
+    }
   }
 }
-// 右上角删除红叉
 .close-btn {
   position: absolute;
   top: 0;
@@ -232,5 +270,6 @@ const removeImg = (type: UploadType, index: number) => {
   line-height: 22px;
   font-size: 14px;
   cursor: pointer;
+  z-index: 1;
 }
-</style>
+</style>

+ 248 - 172
src/views/information/components/info.vue

@@ -31,7 +31,11 @@
     <div class="profile-item">
       <div class="item-left">
         <div class="label">收货地址</div>
-        <div class="value">{{ receiverInfo.shipAreaPath }}{{receiverInfo.shipAddress}}-{{ receiverInfo.shipName }}{{receiverInfo.shipMobile}}</div>
+        <div class="value">
+          {{ receiverInfo.shipAreaPath }}{{ receiverInfo.shipAddress }}-{{
+            receiverInfo.shipName
+          }}{{ receiverInfo.shipMobile }}
+        </div>
       </div>
       <div class="item-right">
         <a-button @click="openAddrModal">修改地址</a-button>
@@ -47,10 +51,18 @@
       cancel-text="取消"
       @ok="submitNick"
     >
-      <a-form ref="nickRef" :model="nickForm" layout="vertical">
-        <a-form-item name="nick" :rules="[{ required: true, message: '请输入昵称' }, { max: 50, message: '最多50个字符' }]">
-          <a-input size="large" allowClear
-            v-model:value="user.nickName"
+      <a-form ref="nickRef" :model="userInfo" layout="vertical">
+        <a-form-item
+          name="nickName"
+          :rules="[
+            { required: true, message: '请输入昵称' },
+            { max: 50, message: '最多50个字符' },
+          ]"
+        >
+          <a-input
+            size="large"
+            allowClear
+            v-model:value="userInfo.nickName"
             placeholder="请输入昵称"
             show-count
             maxlength="50"
@@ -68,237 +80,301 @@
       cancel-text="取消"
       @ok="submitPwd"
     >
-      <a-form ref="pwdRef" :model="pwdForm" layout="vertical">
+      <a-form
+        ref="pwdRef"
+        :model="pwdForm"
+        :rules="formRules"
+        layout="vertical"
+        autocomplete="off"
+      >
         <!-- 双区号+手机号行 -->
         <div class="phone-row">
-          <a-select size="large" :dropdownMatchSelectWidth="150" v-model:value="pwdForm.countryCode" :options="selectCall" style="width:130px">
+          <a-select
+            disabled
+            size="large"
+            :dropdownMatchSelectWidth="150"
+            v-model:value="pwdForm.countryCode"
+            :options="selectCall"
+            style="width: 130px"
+          >
           </a-select>
           <a-input
+            disabled
             size="large"
             v-model:value="user.userName"
             placeholder="请输入手机号"
             maxlength="11"
+            autocomplete="off"
+            readonly
           />
         </div>
-        <a-form-item
-          name="code"
-          :rules="[{ required: true, message: '请输入验证码' }]"
-        >
-        <a-input
-          v-model:value="pwdForm.msgAuthCode"
-          size="large"
-          style="margin-bottom: 16px"
-          placeholder="请输入密码"
-        >
-          <template #suffix>
-            <div
-              class="code-btn text-sm cursor-pointer pl-4"
-              style="border-left: 1px solid #bfbfbf; height: 16px"
-              :class="{ 'is-disabled': sendCodeExTime }"
-              @click="sendCode"
-            >
-              {{
-                sendCodeExTime
-                  ? $t("login.resendTime", { time: sendCodeExTime })
-                  : $t("login.sendCodeBtnText")
-              }}
-            </div>
-          </template>
-        </a-input>
+        <a-form-item name="msgAuthCode">
+          <a-input
+            v-model:value="pwdForm.msgAuthCode"
+            size="large"
+            :placeholder="$t('login.verCode')"
+            autocomplete="one-time-code"
+            name="msgAuthCode"
+          >
+            <template #suffix>
+              <div
+                class="code-btn text-sm pl-4"
+                style="border-left: 1px solid #bfbfbf"
+                :class="
+                  sendCodeExTime
+                    ? 'is-disabled cursor-not-allowed'
+                    : 'cursor-pointer'
+                "
+                @click="sendCode"
+              >
+                {{
+                  sendCodeExTime
+                    ? $t("login.resendTime", { time: sendCodeExTime })
+                    : $t("login.sendCodeBtnText")
+                }}
+              </div>
+            </template>
+          </a-input>
         </a-form-item>
         <a-form-item
-          name="newPwd"
-          :rules="[{ required: true, message: '请设置新密码' }]"
+          name="password"
         >
-          <a-input-password size="large" v-model:value="pwdForm.newPwd" placeholder="设置密码" />
+          <a-input-password
+            size="large"
+            v-model:value="pwdForm.password"
+            placeholder="设置密码"
+            autocomplete="new-password"
+            name="new-password"
+          />
         </a-form-item>
         <a-form-item
           name="confirmPwd"
-          :rules="[
-            { required: true, message: '请确认密码' },
-            { validator: validateSamePwd, trigger: 'blur' }
-          ]"
         >
-          <a-input-password size="large" v-model:value="pwdForm.confirmPwd" placeholder="确认密码" />
+          <a-input-password
+            size="large"
+            v-model:value="pwdForm.confirmPwd"
+            placeholder="确认密码"
+            autocomplete="new-password"
+            name="confirm-new-password"
+          />
         </a-form-item>
       </a-form>
     </a-modal>
 
     <!-- 3. 修改地址弹窗 优化版(省市区三级联动) -->
-    <AddressModal
-      v-model:open="addrVisible"
-      @confirm="addrVisible = false"
-    />
-    <!-- <a-modal
-      v-model:open="addrVisible"
-      title="修改地址"
-      :width="600"
-      ok-text="确定"
-      cancel-text="取消"
-      @ok="submitAddr"
-    >
-      <a-form ref="addrRef" :model="addrForm" 
-      :label-col="{ style: { width: '120px' } }"
-      :wrapper-col="{ span: 18 }">
-        <a-form-item label="国家/地区" name="country">
-          <a-select size="large" v-model:value="addrForm.country">
-            <a-select-option value="CN">中国</a-select-option>
-          </a-select>
-        </a-form-item>
-        <a-form-item label="所在地区" name="area" :rules="[{ required: true, message: '请选择省市区' }]">
-          <a-cascader size="large"
-            v-model:value="addrForm.area"
-            :options="cityOptions"
-            placeholder="省、市、区"
-            style="width:100%"
-          />
-        </a-form-item>
-        <a-form-item label="详细地址" name="detail" :rules="[{ required: true, message: '请输入详细地址' }, { max:50 }]">
-          <a-input size="large" v-model:value="addrForm.detail" show-count maxlength="50" placeholder="请输入详细地址" />
-        </a-form-item>
-        <a-form-item label="收件人" name="name" :rules="[{ required: true, message: '请输入收件人' }, { max:10 }]">
-          <a-input size="large" v-model:value="addrForm.name" show-count maxlength="10" placeholder="请输入名称" />
-        </a-form-item>
-        <a-form-item label="手机号码" name="mobile" :rules="[{ required: true, message: '请输入手机号' }, { len:11, message:'手机号必须11位' }]">
-          <a-input size="large" v-model:value="addrForm.mobile" show-count maxlength="11" placeholder="请输入手机号码" />
-        </a-form-item>
-      </a-form>
-    </a-modal> -->
+    <AddressModal v-model:open="addrVisible" @confirm="addrVisible = false" />
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, nextTick, computed } from 'vue'
-import type { FormInstance } from 'ant-design-vue'
-import { message } from 'ant-design-vue'
+import { ref, reactive, nextTick, computed } from "vue";
+import type { FormInstance } from "ant-design-vue";
+import { message } from "ant-design-vue";
 import { useCartStore, useUserStore } from "@/stores";
-import AddressModal from '@/components/AddressModal.vue'
-import selectcall from '@/utils/country.js'
+import { useI18n } from "vue-i18n";
+import { checkSms, getAuthCode } from "@/api/login";
+import AddressModal from "@/components/AddressModal.vue";
+import selectcall from "@/utils/country.js";
+const { t } = useI18n();
 const user = useUserStore();
-
 // 用户基础信息
 const cart = useCartStore();
 const receiverInfo = computed(() => cart.greceiverInfo);
 const userInfo = reactive({
-  account: '13631262785',
-  nickname: '这是一个昵称',
-  // 地址展示文本
-  addressText: '广东省珠海市香洲区港湾一号11栋 张三 10688888888',
-  // 地址弹窗回填字段
-  country: 'CN',
-  area: ['广东省', '珠海市', '香洲区'],
-  detail: '港湾一号11栋',
-  name: '张三',
-  mobile: '10688888888'
-})
+  nickName: user.nickName,
+});
+const sendCodeExTime = ref(0);
+
 const selectCall = computed(() => {
-  return selectcall.map(item => {
+  return selectcall.map((item) => {
     return {
-    value: item[1],
-    // label: item[1],
-    label: `${item[1]}(${item[0]})`
-  }
-  })
-})
+      value: item[1],
+      // label: item[1],
+      label: `${item[1]}(${item[0]})`,
+    };
+  });
+});
 // ========== 弹窗显示状态 ==========
-const nickVisible = ref(false)
-const pwdVisible = ref(false)
-const addrVisible = ref(false)
+const nickVisible = ref(false);
+const pwdVisible = ref(false);
+const addrVisible = ref(false);
 
 // ========== 昵称弹窗表单 ==========
-const nickRef = ref<FormInstance>()
-const nickForm = reactive({ nick: '' })
+const nickRef = ref<FormInstance>();
+const nickForm = reactive({ nick: "" });
 const openNickModal = () => {
-  nickForm.nick = userInfo.nickname
-  nickVisible.value = true
-  nextTick(() => nickRef.value?.clearValidate())
-}
+  userInfo.nickName = user.nickName;
+  nickVisible.value = true;
+  nextTick(() => nickRef.value?.clearValidate());
+};
 const submitNick = async () => {
-  await nickRef.value?.validate()
-  userInfo.nickname = nickForm.nick
-  nickVisible.value = false
-  message.success('昵称修改成功')
+  await nickRef.value?.validate();
+  await user.updateInfo(userInfo.nickName);
+  nickVisible.value = false;
+  message.success("昵称修改成功");
   // 调用后端更新昵称接口
-}
-
+};
+// 表单校验规则
+const formRules: Record<string, Rule[]> = {
+  phone: [
+    { required: true, message: "请输入手机号", trigger: "blur" },
+    { pattern: /^1[3-9]\d{9}$/, message: "手机号格式错误", trigger: "blur" },
+  ],
+  // 验证码:本地格式校验 + 异步远程校验
+  msgAuthCode: [
+    { required: true, message: "请输入验证码", trigger: "blur" },
+    { pattern: /^\d+$/, message: "验证码只能输入数字", trigger: "blur" },
+    {
+      validator: async (_rule, value) => {
+        if (!value) return Promise.resolve();
+        const res = await checkSms(value);
+        console.log('checkSms', res);
+        if (res.code !== 0) {
+          return Promise.reject(t('toast.3006'));
+        }
+        return Promise.resolve();
+      },
+      trigger: "blur",
+    },
+  ],
+  password: [
+    { required: true, message: "请设置密码", trigger: "blur" },
+    {
+      pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[A-Za-z0-9]{8,16}$/,
+      message: "密码需8-16位,同时包含大写字母、小写字母、数字",
+      trigger: "blur",
+    },
+  ],
+  // 确认密码 联动密码校验
+  confirmPwd: [
+    { required: true, message: "请再次输入密码", trigger: "blur" },
+    {
+      validator: (_rule, value) => {
+        if (value !== formData.value.password) {
+          return Promise.reject("两次输入密码不一致");
+        }
+        return Promise.resolve();
+      },
+      trigger: "blur",
+    },
+  ],
+};
 // ========== 修改密码弹窗表单 + 验证码倒计时 ==========
-const pwdRef = ref<FormInstance>()
-const codeCount = ref(0)
-let timer: number | null = null
+const pwdRef = ref<FormInstance>();
+const codeCount = ref(0);
+let timer: number | null = null;
 const pwdForm = reactive({
-  countryCode: '+' + user.country,
-  phone: userInfo.account,
-  code: '',
-  newPwd: '',
-  confirmPwd: ''
-})
+  countryCode: "+" + user.country,
+  phone: user.userName,
+  phoneNum: user.userName,
+  msgAuthCode: "",
+  password: "",
+  confirmPwd: "",
+});
 // 打开密码弹窗重置表单
 const openPwdModal = () => {
-  pwdForm.code = ''
-  pwdForm.newPwd = ''
-  pwdForm.confirmPwd = ''
-  pwdVisible.value = true
-  nextTick(() => pwdRef.value?.clearValidate())
-}
+  pwdForm.msgAuthCode = "";
+  pwdForm.password = "";
+  pwdForm.confirmPwd = "";
+  pwdVisible.value = true;
+  nextTick(() => pwdRef.value?.clearValidate());
+};
 // 获取验证码倒计时
 const getCode = () => {
-  codeCount.value = 60
-  message.success('验证码已发送')
+  codeCount.value = 60;
+  message.success("验证码已发送");
   timer = window.setInterval(() => {
-    codeCount.value--
-    if (codeCount.value <= 0) clearInterval(timer!)
-  }, 1000)
-}
+    codeCount.value--;
+    if (codeCount.value <= 0) clearInterval(timer!);
+  }, 1000);
+};
 // 密码一致性校验
 const validateSamePwd = (_rule: any, val: string) => {
-  if (val && val !== pwdForm.newPwd) return Promise.reject('两次密码输入不一致')
-  return Promise.resolve()
-}
+  if (val && val !== pwdForm.password)
+    return Promise.reject("两次密码输入不一致");
+  return Promise.resolve();
+};
 // 提交密码
 const submitPwd = async () => {
-  await pwdRef.value?.validate()
-  pwdVisible.value = false
-  message.success('密码修改成功,请重新登录')
+  await pwdRef.value?.validate();
+  await user.changePaswd(pwdForm);
+  pwdVisible.value = false;
+  message.success("密码修改成功,请重新登录");
   // 调用后端修改密码接口
-}
+};
 
 // ========== 修改地址弹窗表单 + 省市区级联数据 ==========
-const addrRef = ref<FormInstance>()
+const addrRef = ref<FormInstance>();
 // 简易省市区数据源,可替换真实后台接口
 
 const addrForm = reactive({
-  country: 'CN',
+  country: "CN",
   area: [] as string[],
-  detail: '',
-  name: '',
-  mobile: ''
-})
+  detail: "",
+  name: "",
+  mobile: "",
+});
 // 打开地址弹窗回填数据
 const openAddrModal = () => {
-  addrForm.country = userInfo.country
-  addrForm.area = [...userInfo.area]
-  addrForm.detail = userInfo.detail
-  addrForm.name = userInfo.name
-  addrForm.mobile = userInfo.mobile
-  addrVisible.value = true
-  nextTick(() => addrRef.value?.clearValidate())
-}
+  addrForm.country = userInfo.country;
+  addrForm.area = [...userInfo.area];
+  addrForm.detail = userInfo.detail;
+  addrForm.name = userInfo.name;
+  addrForm.mobile = userInfo.mobile;
+  addrVisible.value = true;
+  nextTick(() => addrRef.value?.clearValidate());
+};
 // 提交地址
 const submitAddr = async () => {
-  await addrRef.value?.validate()
+  await addrRef.value?.validate();
   // 拼接展示文本
-  const [prov, city, dist] = addrForm.area
-  userInfo.addressText = `${prov}${city}${dist}${addrForm.detail} ${addrForm.name} ${addrForm.mobile}`
+  const [prov, city, dist] = addrForm.area;
+  userInfo.addressText = `${prov}${city}${dist}${addrForm.detail} ${addrForm.name} ${addrForm.mobile}`;
   // 同步存储地址字段
-  userInfo.country = addrForm.country
-  userInfo.area = [...addrForm.area]
-  userInfo.detail = addrForm.detail
-  userInfo.name = addrForm.name
-  userInfo.mobile = addrForm.mobile
-  addrVisible.value = false
-  message.success('地址修改成功')
+  userInfo.country = addrForm.country;
+  userInfo.area = [...addrForm.area];
+  userInfo.detail = addrForm.detail;
+  userInfo.name = addrForm.name;
+  userInfo.mobile = addrForm.mobile;
+  addrVisible.value = false;
+  message.success("地址修改成功");
   // 调用后端更新地址接口
-}
+};
+// 发送验证码
+const sendCode = async () => {
+  // 倒计时中直接返回
+  if (sendCodeExTime.value > 0) return;
+
+  const res = await getAuthCode({
+    phone: user.userName,
+    areaNum: user.country,
+  });
+  console.log("sendCode", res);
+  if (!res) return;
+
+  // 默认倒计时60秒
+  sendCodeExTime.value = 60;
+
+  // 后端返回自定义倒计时
+  if (res.code === 3033) {
+    sendCodeExTime.value = Number(res.msg);
+  }
+
+  // 成功/3033 开启倒计时
+  if (res.code === 0 || res.code === 3033) {
+    timer = window.setInterval(() => {
+      if (sendCodeExTime.value <= 1) {
+        clearInterval(timer!);
+        timer = null;
+      }
+      sendCodeExTime.value--;
+    }, 1000);
+  } else {
+    // 错误提示
+    const tipText = t(`toast.${res.code}`) ? t(`toast.${res.code}`) : res.msg;
+    message.warning(tipText);
+  }
+};
 </script>
 
 <style scoped lang="less">
@@ -324,7 +400,7 @@ const submitAddr = async () => {
 .value {
   font-weight: normal;
   font-size: 12px;
-  color: #4E5969;
+  color: #4e5969;
   line-height: 1.6;
 }
 .item-right {

+ 2 - 0
src/views/information/index.vue

@@ -118,6 +118,7 @@ const hendleCooperation = () => {
         title: t('common.tip'),
         icon: createVNode(ExclamationCircleFilled),
         content: '计算中/已封存的场景无法协作',
+        centered: true,
         // okText: '确认',
         // cancelText: '取消',
         onOk: async () => {
@@ -146,6 +147,7 @@ const handleEquity = (record)=> {
         title: t('common.tip'),
         icon: createVNode(ExclamationCircleFilled),
         content: '确定要解除该相机的权益吗?',
+        centered: true,
         // okText: '确认',
         // cancelText: '取消',
         onOk: async () => {

+ 4 - 1
src/views/mall/cart.vue

@@ -32,7 +32,7 @@
     <!-- 有商品购物车列表 -->
     <div v-else class="cart-content">
       <cartlist v-if="step == 1" :cartList="cartList" @changSetp="step = 2"></cartlist>
-      <confirmorder v-else :cartList="cartList" @changSetp="step = 2"></confirmorder>
+      <confirmorder v-else :cartList="cartList" @changSetp="step = $event"></confirmorder>
     </div>
   </div>
 </template>
@@ -45,6 +45,9 @@ import number from "./components/number.vue";
 import cartlist from "./components/cart/list.vue";
 import confirmorder from "./components/cart/confirmOrder.vue";
 import { stepList } from "./data";
+
+defineOptions({ name: "MallCart" });
+
 const router = useRouter();
 const cart = useCartStore();
 const step = ref(1);

+ 1 - 1
src/views/mall/components/browse.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="brow-layout">
     <div class="product-img">
-      <video v-if="active.video" :src="floder ? `${$cdn}images/${floder}/${active.big}.mp4`: active.big" autoplay muted loop></video>
+      <video v-if="active && active.video" :src="floder ? `${$cdn}images/${floder}/${active.big}.mp4`: active.big" autoplay muted loop></video>
       <img v-else :src="floder ? `${$cdn}images/${floder}/${active.big}.png` : active.big">
     </div>
     <ul v-if="data.length && data.length > 1">

+ 516 - 47
src/views/mall/components/cart/confirmOrder.vue

@@ -1,327 +1,796 @@
 <template>
+
   <div class="order-list">
+
      <!-- 收货地址区域 -->
+
     <div class="address-block">
-      <h3 class="block-title">收货地址</h3>
-      <div class="address-card" v-if="receiverInfo && receiverInfo.id">
-        <p class="addr-name-phone">{{receiverInfo.shipName}}  {{receiverInfo.shipMobile}}</p>
-        <p class="addr-detail">{{receiverInfo.shipAreaPath}}</p>
-        <div class="addr-desc">{{receiverInfo.shipAddress}}</div>
-        <span class="edit-text cursor-pointer" @click="handleEditAddr">编辑</span>
+
+      <h3 class="block-title">{{ t('mall.receiveAddress') }}</h3>
+
+      <div class="address-card" v-if="receiverInfo?.id && receiverInfo?.shipMobile">
+
+        <p class="addr-name-phone">{{ receiverInfo.shipName }}  {{ receiverInfo.shipMobile }}</p>
+
+        <p class="addr-detail">{{ receiverInfo.shipAreaPath }}</p>
+
+        <div class="addr-desc">{{ receiverInfo.shipAddress }}</div>
+
+        <span class="cursor-pointer edit-text" @click="handleEditAddr">{{ t('manage.account.edit') }}</span>
+
       </div>
-      <div class="address-card EmptyData" v-else>
+
+      <div class="address-card EmptyData" v-else @click="handleEditAddr">
+
         <PlusOutlined />
-        <span>{{$t('mall.xzsh')}}</span>
+
+        <span>{{ t('mall.xzsh') }}</span>
+
       </div>
+
     </div>
 
+
+
     <!-- 商品信息表格 -->
+
     <div class="goods-block">
-      <h3 class="block-title">商品信息</h3>
+
+      <h3 class="block-title">{{ t('mall.goodsInfo') }}</h3>
+
       <div class="goods-table">
+
         <!-- 表头 -->
+
         <div class="table-head">
-          <div class="col col-goods">商品</div>
-          <div class="col col-price">价格</div>
-          <div class="col col-num">数量</div>
-          <div class="col col-total" style="border:none">总价</div>
+
+          <div class="col col-goods">{{ t('mall.goods') }}</div>
+
+          <div class="col col-price">{{ t('mall.price') }}</div>
+
+          <div class="col col-num">{{ t('mall.count') }}</div>
+
+          <div class="col col-total">{{ t('mall.mini') }}</div>
+
+          <div class="col col-del"></div>
+
         </div>
+
         <div class="divider-sm"></div>
 
+
+
         <!-- 商品列表 -->
+
         <div class="goods-list">
-          <div class="goods-row" v-for="item in cartList" :key="item.id" v-show="item.checked">
-            <div class="col col-goods">
+
+          <div class="goods-row" v-for="item in cartList" :key="item.id" v-show="orderGoodsIds.includes(item.goodsId)">
+
+            <div class="col col-goods" v-if="partsData[item.goodsId]">
+
             <img :src="$cdn + partsData[item.goodsId].img" alt="" class="goods-img" />
+
               <div class="goods-text">
+
                 <p class="goods-name">{{ partsData[item.goodsId].name }}</p>
+
                 <p class="goods-desc" v-for="(p, index) in partsData[item.goodsId].detail" :key="index">{{ `${p} x ${item.goodsCount}` }}</p>
+
             </div>
+
             </div>
+
             <div class="col col-price">¥{{ item.price }}</div>
+
             <div class="col col-num">{{ item.goodsCount }}</div>
-            <div class="col col-total" style="height: 103px;">¥{{ item.goodsCount * item.price }}</div>
-            <!-- 总价列只在第一行展示合并单元格 -->
-            
+
+            <div class="col col-total">¥{{ item.goodsCount * item.price }}</div>
+
+            <div class="col col-del">
+
+              <span class="del-icon" @click="handleRemove(item)">×</span>
+
+            </div>
+
           </div>
+
         </div>
 
+
+
         <!-- 费用汇总行 -->
+
         <div style="padding: 9px 20px">
+
         <div class="fee-row">
-          <div class="fee-label">税费及其它费用:</div>
+
+          <div class="fee-label">{{ t('mall.otherPrice') }}:</div>
+
           <div class="fee-value">¥0</div>
+
         </div>
+
         <div class="fee-row">
-          <div class="fee-label">运费:</div>
+
+          <div class="fee-label">{{ t('mall.yunfei') }}:</div>
+
           <div class="fee-value">¥0</div>
+
         </div>
+
         <div class="fee-row total-row">
-          <div class="fee-label">合计:</div>
+
+          <div class="fee-label">{{ t('mall.total') }}:</div>
+
           <div class="fee-value total-money">¥{{ totalGoodsMoney }}</div>
+
         </div>
+
         </div>
+
       </div>
+
     </div>
 
+
+
     <!-- 底部协议 + 付款按钮 -->
+
     <div class="bottom-bar">
-      <a-checkbox v-model="agreeProtocol">
-        同意《四维看看销售协议》
+
+      <a-checkbox v-if="isZh" v-model:checked="agreeProtocol">
+
+        {{ t('mall.agree') }}<span class="protocol-link">《{{ t('mall.4dkkxsxy') }}》</span>
+
       </a-checkbox>
-      <a-button type="primary" size="large" class="pay-btn" :disabled="!agreeProtocol" @click="handlePay">
-        付款
+
+      <span v-else></span>
+
+      <a-button
+
+        type="primary"
+
+        size="large"
+
+        class="pay-btn"
+
+        :loading="submitting"
+
+        :disabled="isZh && !agreeProtocol"
+
+        @click="handlePay"
+
+      >
+
+        {{ t('mall.payOrder') }}
+
       </a-button>
+
     </div>
+
     <AddressModal
+
       v-model:open="addrVisible"
-      @confirm="addrVisible = false"
+
+      @confirm="handleAddrConfirm"
+
     />
+
   </div>
+
 </template>
 
+
+
 <script setup>
-import { ref, computed, watch, onMounted } from "vue";
+
+import { ref, computed, onMounted, createVNode } from "vue";
+
 import { useCartStore } from "@/stores";
+
 import AddressModal from '@/components/AddressModal.vue'
-import { useRouter } from "vue-router";
+
+import { useI18n } from "vue-i18n";
+
 import { partsData } from "../../data.ts";
-import { message } from "ant-design-vue";
-import { PlusOutlined} from "@ant-design/icons-vue";
-import number from '../number.vue'
+
+import { Modal, message } from "ant-design-vue";
+
+import { ExclamationCircleFilled, PlusOutlined } from "@ant-design/icons-vue";
+
+import { useCartPay } from "@/hooks";
+
+
+
+const { t, locale } = useI18n();
+
 const cart = useCartStore();
+
 const cartList = computed(() => cart.getList);
-const receiverInfo = ref(cart.receiverInfo);
-const goodsCount = ref(1)
+
+const receiverInfo = computed(() => cart.greceiverInfo);
+
 const emit = defineEmits(['changSetp'])
+
 const addrVisible = ref(false)
 
-// 全选状态
-// 协议勾选状态
 const agreeProtocol = ref(true)
 
-// 商品总金额
+const isZh = computed(() => locale.value === 'zh' || locale.value === 'zh-CN')
+
+/** 当前确认订单中的商品 goodsId */
+
+const orderGoodsIds = ref([])
+
+
+
+const syncOrderChecked = () => {
+
+  cartList.value.forEach((i) => {
+
+    i.checked = orderGoodsIds.value.includes(i.goodsId)
+
+  })
+
+}
+
+
+
+const orderGoods = computed(() =>
+
+  cartList.value.filter((item) => orderGoodsIds.value.includes(item.goodsId))
+
+)
+
+
+
 const totalGoodsMoney = computed(() => {
-  return cartList.value.reduce((sum, item) => sum + item.price * item.goodsCount, 0)
+
+  return orderGoods.value.reduce((sum, item) => sum + item.price * item.goodsCount, 0)
+
+})
+
+
+
+const { submitting, submitOrder } = useCartPay({
+
+  getGoods: () => orderGoods.value,
+
+  getReceiver: () => receiverInfo.value,
+
+  onPaySuccess: () => {
+
+    emit('changSetp', 1)
+
+  },
+
 })
 
-// 编辑地址
+
+
 const handleEditAddr = () => {
-  console.log('打开地址编辑弹窗')
+
   addrVisible.value = true
+
+}
+
+
+
+const handleAddrConfirm = async () => {
+
+  addrVisible.value = false
+
+  await cart.getReceiver()
+
+}
+
+
+
+const handleRemove = (item) => {
+
+  Modal.confirm({
+
+    title: t('common.tip'),
+
+    icon: createVNode(ExclamationCircleFilled),
+
+    centered: true,
+
+    content: t('toast.15'),
+
+    onOk: async () => {
+
+      await cart.removeCart({
+
+        goodsId: item.goodsId,
+
+        goodsCount: 0,
+
+      })
+
+      orderGoodsIds.value = orderGoodsIds.value.filter((id) => id !== item.goodsId)
+
+      syncOrderChecked()
+
+      if (orderGoodsIds.value.length === 0) {
+
+        emit('changSetp', 1)
+
+      }
+
+    },
+
+  })
+
 }
 
-// 付款,跳转到第三步「下单」页面
-const handlePay = () => {
-  if (!agreeProtocol.value) return
-  // 跳转第三步下单页面,完整流程闭环
-  router.push('/order-submit')
+
+
+const handlePay = async () => {
+
+  if (isZh.value && !agreeProtocol.value) {
+
+    message.warn(t('toast.agreeXieyi'))
+
+    return
+
+  }
+
+  await submitOrder()
+
 }
+
+
+
 onMounted(async () => {
-  receiverInfo.value = await cart.getReceiver()
-  console.log(receiverInfo.value, 'receiverInfo')
+
+  await cart.getReceiver()
+
+  orderGoodsIds.value = cartList.value.filter((i) => i.checked).map((i) => i.goodsId)
+
+  syncOrderChecked()
+
 })
+
 </script>
 
+
+
 <style lang="less" scoped>
+
 .order-confirm-wrap {
+
   width: 100%;
+
   padding: 32px;
+
   box-sizing: border-box;
+
   background: #fff;
+
 }
 
+
+
 // 顶部标题+步骤条
+
 .page-header {
+
   display: flex;
+
   justify-content: space-between;
+
   align-items: center;
+
 }
+
 .page-title {
+
   font-size: 24px;
+
   font-weight: 600;
+
   margin: 0;
+
 }
+
 .step-bar {
+
   display: flex;
+
   align-items: center;
+
   gap: 16px;
+
 }
+
 .step-item {
+
   display: flex;
+
   align-items: center;
+
   gap: 8px;
+
   color: #aaa;
+
 }
+
 .step-item.active {
+
   color: #0fc4c4;
+
 }
+
 .step-circle {
+
   width: 28px;
+
   height: 28px;
+
   border-radius: 50%;
+
   background: #eee;
+
   display: flex;
+
   align-items: center;
+
   justify-content: center;
+
 }
+
 .step-item.active .step-circle {
+
   background: #0fc4c4;
+
   color: #fff;
+
 }
+
 .line {
+
   width: 80px;
+
   height: 1px;
+
   background: #ddd;
+
 }
+
 .divider {
+
   width: 100%;
+
   height: 1px;
+
   background: #e8e8e8;
+
   margin: 16px 0 30px;
+
 }
+
 .divider-sm {
+
   width: 100%;
+
   height: 1px;
+
   background: #eee;
+
 }
 
+
+
 // 通用区块标题
+
 .block-title {
+
   font-size: 18px;
+
   font-weight: 500;
+
   margin: 0 0 16px;
+
 }
 
+
+
 // 收货地址
+
 .address-block {
+
   margin-bottom: 40px;
+
 }
+
 .address-card {
+
   border: 1px solid #0fc4c4;
+
   padding: 16px 20px;
+
   width: 600px;
+
   border-radius: 4px;
+
   font-size: 12px;
 
+
+
   .addr-name-phone {
+
     font-size: 14px;
+
     margin-bottom: 4px;
+
     font-weight: 600;
+
     margin: 0 0 8px;
+
   }
+
   .addr-detail, .addr-desc {
+
     margin: 4px 0;
+
     color: #666;
+
     font-size: 14px;
+
   }
+
   .edit-text {
+
     margin-top: 8px;
+
     font-size: 14px;
+
     color: #0fc4c4;
+
     cursor: pointer;
+
   }
+
 }
+
 .EmptyData{
+
     height: 90px;
+
     line-height: 50px;
+
     cursor: pointer;
+
     text-align: center;
+
     background: #f7f7f7;
+
     color: #323233;
+
+    display: flex;
+
+    align-items: center;
+
+    justify-content: center;
+
+    gap: 8px;
+
 }
+
 // 商品表格
+
 .goods-table {
+
   border: 1px solid #eee;
+
 }
+
 .table-head {
+
   display: flex;
+
   padding: 12px 16px;
+
   background: #f8f8f8;
+
   font-size: 15px;
+
 }
+
 .goods-row {
+
   display: flex;
+
   align-items: center;
+
   border-bottom: 1px solid #eee;
+
   padding: 0 16px;
+
   position: relative;
+
   .col {
+
     padding: 16px 0;
+
     
 
+
+
   }
+
 }
+
 .col {
+
   display: flex;
+
   align-items: center;
+
 }
+
 .col-goods { flex: 1; }
+
 .col-price { width: 140px; justify-content: center; }
+
 .col-num { width: 140px; justify-content: center; }
+
 .col-total { width: 160px; justify-content: center; border-left:1px solid #eee; }
+
+.col-del { width: 60px; justify-content: center; }
+
+.del-icon {
+
+  font-size: 20px;
+
+  color: #999;
+
+  cursor: pointer;
+
+  line-height: 1;
+
+  &:hover {
+
+    color: #666;
+
+  }
+
+}
+
 .row-span-all {
+
   position: absolute;
+
   right: 0;
+
   top: 0;
+
   border-left:1px solid #eee;
+
   display: flex;
+
   align-items: center;
+
   justify-content: center;
+
 }
+
 .goods-img {
+
   width: 70px;
+
   height: 70px;
+
   object-fit: contain;
+
   margin-right: 14px;
+
 }
+
 .goods-name {
+
   margin: 0 0 4px;
+
   font-size: 15px;
+
 }
+
 .goods-spec {
+
   margin: 0;
+
   color: #888;
+
   font-size: 13px;
+
 }
 
+
+
 // 费用汇总
+
 .fee-row {
+
   display: flex;
+
   justify-content: flex-end;
+
   padding: 8px 20px;
+
   gap: 10px;
+
   font-size: 15px;
+
   .fee-label {
+
     color: #666;
+
   }
+
   .fee-value{
+
     width: 150px;
+
     text-align: right;
 
+
+
   }
+
 }
+
 .total-row {
+
   // margin: 8px 0 16px;
+
   .total-money {
+
     font-size: 20px;
+
     font-weight: 600;
+
     color: #0fc4c4;
+
   }
+
 }
 
+
+
 // 底部栏
+
 .bottom-bar {
+
   display: flex;
+
   justify-content: space-between;
+
   align-items: center;
+
   margin-top: 30px;
+
 }
+
+.protocol-link {
+
+  color: #0fc4c4;
+
+}
+
 .pay-btn {
+
   background: #0fc4c4;
+
   border: none;
+
   width: 180px;
+
   height: 50px;
+
   font-size: 16px;
+
 }
-</style>
+
+</style>
+

+ 23 - 10
src/views/mall/components/cart/list.vue

@@ -19,7 +19,7 @@
           <div class="col col-check">
             <a-checkbox v-model:checked="item.checked" @change="calcTotal" />
           </div>
-          <div class="col col-goods">
+          <div class="col col-goods" v-if="partsData[item.goodsId]">
             <img :src="$cdn + partsData[item.goodsId].img" alt="" class="goods-img" />
             <div class="goods-info">
               <p class="goods-name">{{ partsData[item.goodsId].name }}</p>
@@ -36,7 +36,7 @@
           </div>
           <div class="col col-sub">¥{{ item.price * item.goodsCount }}</div>
           <div class="col col-del">
-            <span class="del-icon" @click="delItem(item.id)">×</span>
+            <span class="del-icon" @click="handleRemove(item)">×</span>
           </div>
         </div>
       </div>
@@ -81,13 +81,16 @@
 </template>
 
 <script setup>
-import { ref, computed, watch } from "vue";
+import { ref, computed, watch, createVNode } from "vue";
 import { useCartStore } from "@/stores";
 import { useRouter } from "vue-router";
+import { useI18n } from "vue-i18n";
 import { partsData } from "../../data.ts";
-import { message } from "ant-design-vue";
+import { Modal, message } from "ant-design-vue";
+import { ExclamationCircleFilled } from "@ant-design/icons-vue";
 import number from '../number.vue'
 
+const { t } = useI18n();
 const cart = useCartStore();
 const router = useRouter();
 const cartList = computed(() => cart.getList);
@@ -122,10 +125,21 @@ const calcTotal = () => {
 };
 
 // 删除商品
-const delItem = (id) => {
-  cartList.value = cartList.value.filter((item) => item.id !== id);
-  calcTotal();
-};
+const handleRemove = (item) => {
+  Modal.confirm({
+    title: t('common.tip'),
+    icon: createVNode(ExclamationCircleFilled),
+    centered: true,
+    content: t('toast.15'),
+    onOk: async () => {
+      await cart.removeCart({
+        goodsId: item.goodsId,
+        goodsCount: 0,
+      })
+      calcTotal()
+    },
+  })
+}
 
 // 去选购
 const goShop = () => {
@@ -134,8 +148,7 @@ const goShop = () => {
 
 // 去确认订单
 const goConfirm = () => {
-  if (totalMoney.value <= 0) return  message.warn('请选择需要购买的商品');
-  // router.push("/order-confirm");
+  if (totalMoney.value <= 0) return message.warn(t('mall.noGoodsConfirm'));
   emit('changSetp', 2)
 };
 

+ 1 - 0
src/views/mall/components/header.vue

@@ -40,6 +40,7 @@ const handleUserClick = (e) => {
         title: t('common.tip'),
         icon: createVNode(ExclamationCircleFilled),
         content: t('login.logoutTip'),
+        centered: true,
         // okText: '确认',
         // cancelText: '取消',
         onOk: async () => {

+ 8 - 3
src/views/mall/components/shopInfo.vue

@@ -30,7 +30,8 @@
       <a-button 
       type="primary"
         block 
-        class="add-to-cart-btn" 
+        class="add-to-cart-btn"
+        :loading="loading"
         @click="handleAddToCart"
       >
         {{shopInfo.okText||'加入购物车'}}
@@ -54,9 +55,13 @@ import number from './number.vue'
 // 1. 定义 props
 const props = defineProps({
   shopInfo: {
-    type: Array,  // 假设传入的是数组,根据实际情况调整
+    type: Object,
     required: true
   },
+  loading: {
+    type: Boolean,
+    default: false,
+  },
 })
 // 定义向父组件传递的事件
 const emit = defineEmits(['add-to-cart'])
@@ -77,7 +82,7 @@ const handleAddToCart = () => {
 <style lang="less" scoped>
 .product-purchase-container {
   min-width: 500px;
-  padding: 20px;
+  padding: 20px 0px 20px 60px;
   font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
   color: #333;
   background-color: #fff;

+ 210 - 212
src/views/mall/components/shopMemberCard.vue

@@ -1,141 +1,60 @@
-<!-- components/ShopMemberCard.vue -->
 <template>
   <div class="shop-member-page">
     <div class="container">
+      <h2 class="section-title">{{ t('mall.member.memberTitle') }}</h2>
 
-
-      <!-- 会员权益标题 -->
-      <h2 class="section-title">会员权益</h2>
-
-      <!-- 三种会员卡片容器 -->
       <div class="member-card-wrap">
-        <!-- 1. 普通权益(免费) -->
-        <div class="member-card card-normal">
-          <div class="card-top">
-            <h3 class="card-title">普通权益</h3>
-            <div class="price-text">免费</div>
-            <p class="card-desc">
-              具备基础的场景编辑输出功能及有限云容量,适用于个人爱好者,或无需定制服务需求的团队。
-            </p>
-          </div>
-          <div class="card-content">
-            <div class="row-item">
-              <span class="label">相机类型</span>
-              <span class="value">激光+全景</span>
-            </div>
-            <div class="row-item">
-              <span class="label">云容量</span>
-              <span class="value">10GB</span>
-            </div>
-            <div class="row-item">
-              <span class="label">品牌信息</span>
-              <span class="value">不支持</span>
-            </div>
-            <div class="row-item">
-              <span class="label">优先计算</span>
-              <span class="value">根据实际情况排队</span>
-            </div>
-            <div class="row-item">
-              <span class="label">离线包下载</span>
-              <span class="value">99元/次</span>
-            </div>
-          </div>
-          <div class="card-btn">
-            <a-button block size="large">立即体验</a-button>
-          </div>
-        </div>
-
-        <!-- 2. 高级会员 -->
-        <div class="member-card card-vip">
+        <div
+          v-for="item in equityList"
+          :key="item.id"
+          class="member-card"
+          :class="getMemberCardClass(item.type)"
+        >
           <div class="card-top">
             <h3 class="card-title">
-              高级会员
-              <span class="icon">
-                <img src="@/assets/image/mall/vip.png" alt="" />
+              {{ getMemberTitle(item.type) }}
+              <span v-if="item.type !== 'freeMember'" class="icon">
+                <img src="@/assets/image/mall/vip.png" alt="vip" />
               </span>
             </h3>
-            <div class="price-text">5980 <span class="unit">元/年</span></div>
-            <p class="card-desc">
-              适用于品牌宣传等需求,具备深度品牌信息,对于大空间场景量灵活控制的专业用户或企业。
-            </p>
-          </div>
-          <div class="card-content">
-            <div class="row-item">
-              <span class="label">相机类型</span>
-              <span class="value">激光+全景</span>
-            </div>
-            <div class="row-item">
-              <span class="label">云容量</span>
-              <span class="value">无限制容量</span>
-            </div>
-            <div class="row-item">
-              <span class="label">品牌信息</span>
-              <span class="value">支持</span>
-            </div>
-            <div class="row-item">
-              <span class="label">优先计算</span>
-              <span class="value">免排队</span>
-            </div>
-            <div class="row-item">
-              <span class="label">离线包下载</span>
-              <span class="value">免费10次</span>
+            <div class="price-text">
+              <template v-if="item.type === 'freeMember'">
+                {{ t('mall.member.normalPrice') }}
+              </template>
+              <template v-else>
+                {{ item.price }}<span class="unit">元/年</span>
+              </template>
             </div>
+            <p class="card-desc">{{ getMemberDesc(item.type) }}</p>
           </div>
-          <div class="card-btn">
-            <a-button type="primary" block size="large">立即购买</a-button>
-          </div>
-        </div>
 
-        <!-- 3. 专业会员 -->
-        <div class="member-card card-pro">
-          <div class="card-top">
-            <h3 class="card-title">
-              专业会员
-              <span class="icon">
-                <img src="@/assets/image/mall/vip.png" alt=""
-              /></span>
-            </h3>
-            <div class="price-text">9999 <span class="unit">元/年</span></div>
-            <p class="card-desc">
-              满足于专业建设需求标准,主要用于别墅别墅、工程应用等企业级用户。
-            </p>
-          </div>
           <div class="card-content">
-            <div class="row-item">
-              <span class="label">相机类型</span>
-              <span class="value">激光+全景</span>
-            </div>
-            <div class="row-item">
-              <span class="label">云容量</span>
-              <span class="value">无限制容量</span>
-            </div>
-            <div class="row-item">
-              <span class="label">品牌信息</span>
-              <span class="value">支持</span>
-            </div>
-            <div class="row-item">
-              <span class="label">优先计算</span>
-              <span class="value">免排队</span>
-            </div>
-            <div class="row-item">
-              <span class="label">离线包下载</span>
-              <span class="value">免费10次</span>
+            <div
+              v-for="row in getMemberFeatures(item)"
+              :key="row.label"
+              class="row-item"
+            >
+              <span class="label">{{ row.label }}</span>
+              <span class="value">{{ row.value }}</span>
             </div>
           </div>
+
           <div class="card-btn">
-            <a-button type="primary" block size="large">立即购买</a-button>
+            <a-button
+              block
+              size="large"
+              :type="item.type === 'freeMember' ? 'default' : 'primary'"
+              @click="handleBuy(item)"
+            >
+              {{ item.type === 'freeMember' ? '立即体验' : '立即购买' }}
+            </a-button>
           </div>
         </div>
       </div>
 
-      <!-- 常见问题区域 -->
       <h2 class="section-title">常见问题</h2>
       <div v-for="item in faqList" :key="item.key" class="faq-panel">
-        <!-- 折叠标题 -->
-        <div class="faq-header">
-          {{ item.header }}
-        </div>
-        <!-- 折叠内容,仅激活时显示 -->
+        <div class="faq-header">{{ item.header }}</div>
         <div class="faq-content">
           <p>{{ item.desc }}</p>
         </div>
@@ -145,54 +64,147 @@
 </template>
 
 <script setup lang="ts">
-import { ref } from "vue";
-
-// Tab 激活key
-const activeTab = ref("member");
-// 常见问题折叠面板激活key
-const activeFaqKey = ref<string | string[]>();
-// FAQ 数据源
-const faqList = ref([
-  {
-    key: "1",
-    header: "会员权益如何生效?",
-    desc: "答:用户点击购买会员权益,并付款成功即开通生效,生效日期即购买日期,用户可在会员权益栏目进行续费,即使不续费权益也为生效。",
-  },
-  {
-    key: "2",
-    header: "如何授权会员权益?",
-    desc: "答:成功购买会员权益后,请到商家管理平台 - 会员权益页面,选择相应的会员权益点击【授权】按钮,输入相机ID完成相机授权,会员权益不可多台设备共享。您可在查看管理授权相机后取消对应授权相机权益。",
-  },
-  {
-    key: "3",
-    header: "会员权益是否可以更换授权相机?",
-    desc: "答:授权相机不影响权益的生效与结束,会员权益生效期,可更换授权给任意类型相机。",
-  },
-  {
-    key: "4",
-    header: "权益相机的场景量可以协作给他人吗?",
-    desc: "答:可以,无论相机授权的场景量,协作作者,协作作者也可同样享受该权益对应的会员权益。",
+import { ref, computed, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+import { getIncrementType } from '@/api/equity/index.js'
+
+const router = useRouter()
+
+type MemberType = 'freeMember' | 'member' | 'seMember'
+
+interface EquityItem {
+  id: number | string
+  type: MemberType
+  price: number
+  downloadPrice: number
+}
+
+interface FeatureRow {
+  label: string
+  value: string
+}
+
+const { t } = useI18n()
+const equityList = ref<EquityItem[]>([])
+
+/** 设计稿顺序:普通权益 → 高级会员 → 专业会员 */
+const MEMBER_ORDER: MemberType[] = ['freeMember', 'seMember', 'member']
+
+/** API type 与卡片样式 class 映射(高级会员用 vip 深色,专业会员用 pro 蓝色) */
+const MEMBER_CARD_CLASS: Record<MemberType, string> = {
+  freeMember: 'freeMember',
+  seMember: 'member',
+  member: 'seMember',
+}
+
+interface MemberConfig {
+  title: string
+  desc: string
+  cameraType: string
+  capacity: string
+  brandSupport: string
+  priorityCal: string
+  downloadText: (item: EquityItem) => string
+}
+
+const MEMBER_CONFIG: Record<MemberType, MemberConfig> = {
+  freeMember: {
+    title: '普通权益',
+    desc: '具备基础的场景编辑功能及有限云容量,适用于个人爱好者,或无强定制信息需求的团队。',
+    cameraType: '激光+全景',
+    capacity: '10GB',
+    brandSupport: '不支持',
+    priorityCal: '根据实际情况排队',
+    downloadText: (item) => `${item.downloadPrice}元/次`,
   },
-  {
-    key: "5",
-    header: "场景更新LOGO后,权益到期会恢复默认吗?",
-    desc: "答:不会,用户会自动生成场景自定义的logo,在权益到期后,不会恢复为默认的四维看logo。",
+  seMember: {
+    title: '高级会员',
+    desc: '适用于品牌宣传要求较高,灵活定制品牌信息,对于云空间容量灵活控制的专业用户或企业。',
+    cameraType: '全景',
+    capacity: '无限容量',
+    brandSupport: '支持',
+    priorityCal: '免排队',
+    downloadText: () => '免费10次',
   },
-  {
-    key: "6",
-    header: "什么是离线包?",
-    desc: "答:VR场景数据包,下载完成后,可以下载离线包,进行离线浏览。",
+  member: {
+    title: '专业会员',
+    desc: '适用于专业要求较高,主要用于测量测绘,工程应用等企业级用户。',
+    cameraType: '激光',
+    capacity: '无限容量',
+    brandSupport: '支持',
+    priorityCal: '免排队',
+    downloadText: () => '免费10次',
   },
-]);
+}
+
+const FAQ_KEYS = ['1', '2', '3', '4', '5', '6'] as const
+
+const faqList = computed(() =>
+  FAQ_KEYS.map((key) => ({
+    key,
+    header: t(`mall.member.question${key}`),
+    desc: t(`mall.member.answer${key}`),
+  }))
+)
+
+const getMemberCardClass = (type: MemberType) => MEMBER_CARD_CLASS[type]
+
+const getMemberTitle = (type: MemberType) => MEMBER_CONFIG[type].title
+
+const getMemberDesc = (type: MemberType) => MEMBER_CONFIG[type].desc
+
+const getMemberFeatures = (item: EquityItem): FeatureRow[] => {
+  const config = MEMBER_CONFIG[item.type]
+  return [
+    {
+      label: t('scene.sceneAdmin.filterFormModule.cameraType'),
+      value: config.cameraType,
+    },
+    {
+      label: '云容量',
+      value: config.capacity,
+    },
+    {
+      label: '品牌信息',
+      value: config.brandSupport,
+    },
+    {
+      label: '优先计算',
+      value: config.priorityCal,
+    },
+    {
+      label: '离线包下载',
+      value: config.downloadText(item),
+    },
+  ]
+}
+
+const loadEquityList = async () => {
+  const res = await getIncrementType({})
+  const list = Array.isArray(res) ? res : []
+  equityList.value = list.sort(
+    (a, b) => MEMBER_ORDER.indexOf(a.type) - MEMBER_ORDER.indexOf(b.type)
+  )
+}
+
+const handleBuy = (item: EquityItem) => {
+  if (item.type === 'freeMember') return
+  const routeId = item.type === 'member' ? 5 : 7
+  router.push({
+    path: `/mall/membership/${routeId}`,
+    query: { incrementTypeId: String(item.id) },
+  })
+}
+
+onMounted(loadEquityList)
 </script>
 
 <style scoped lang="less">
 .shop-member-page {
-  // padding: 40px 24px;
   margin-bottom: 40px;
 }
 
-// 区块标题
 .section-title {
   text-align: center;
   font-weight: normal;
@@ -200,25 +212,29 @@ const faqList = ref([
   color: #1d2129;
   margin: 60px 0 40px;
 }
-.faq-panel{
-  .faq-header{
+
+.faq-panel {
+  .faq-header {
     font-weight: normal;
     font-size: 18px;
-    color: #1D2129;
+    color: #1d2129;
     padding-bottom: 10px;
   }
-  .faq-content{
+  .faq-content {
     font-weight: normal;
     font-size: 14px;
-    color: #4E5969;
+    color: #4e5969;
     line-height: 22px;
     padding: 14px 16px;
-    background: #F9FAFC;
-    border-radius: 10px 10px 10px 10px;
+    background: #f9fafc;
+    border-radius: 10px;
     margin-bottom: 20px;
+    p {
+      margin: 0;
+    }
   }
 }
-// 会员卡片容器 三栏布局
+
 .member-card-wrap {
   display: grid;
   grid-template-columns: repeat(3, 1fr);
@@ -226,16 +242,14 @@ const faqList = ref([
   padding-bottom: 20px;
 }
 
-// 通用卡片样式
 .member-card {
-  border-radius: 12px;
-  padding: 50px 30px 40px 30px;
+  border-radius: 20px;
+  padding: 50px 30px 40px;
   display: flex;
   flex-direction: column;
   background-size: 100% 670px;
-  border-radius: 20px;
-  box-shadow: 0px 0px 10px 0px rgba(6,97,201,0.2);
   background-repeat: no-repeat;
+  box-shadow: 0 0 10px 0 rgba(6, 97, 201, 0.2);
 
   .card-top {
     margin-bottom: 10px;
@@ -245,6 +259,7 @@ const faqList = ref([
       display: flex;
       align-items: center;
       gap: 6px;
+      margin: 0;
       img {
         width: 26px;
         height: 26px;
@@ -266,17 +281,15 @@ const faqList = ref([
       margin: 0;
     }
   }
+
   .card-content {
     flex: 1;
-    margin: 20px 0 30px 0;
+    margin: 20px 0 30px;
     font-size: 16px;
     .row-item {
       display: flex;
       justify-content: space-between;
       padding: 10px 0;
-      &:last-child {
-        border-bottom: none;
-      }
       .label {
         color: #666;
       }
@@ -285,10 +298,11 @@ const faqList = ref([
       }
     }
   }
+
   .card-btn {
     margin-top: auto;
     height: 50px;
-    button {
+    :deep(.ant-btn) {
       height: 100%;
       font-weight: normal;
       font-size: 18px;
@@ -296,48 +310,42 @@ const faqList = ref([
   }
 }
 
-// 三种卡片区分背景色
-.card-normal {
-  background-image: url("@/assets/image/mall/card-normal.png");
+.freeMember {
+  background-image: url('@/assets/image/mall/card-normal.png');
   color: #384545;
-  button {
+  :deep(.ant-btn) {
     background: #a5b4b7;
     color: #f0f6ff;
+    border: none;
   }
-  .price-text{
+  .price-text {
     font-size: 48px;
   }
 }
-.card-vip {
-  background-image: url("@/assets/image/mall/card-vip.png");
+
+.member {
+  background-image: url('@/assets/image/mall/card-vip.png');
   color: #663d14;
-  .row-item {
-    border-bottom-color: #444;
-    .label {
-      color: #ccc;
-    }
-  }
   .card-top {
     color: #fee0ab;
   }
-  button {
+  .row-item .label {
+    color: #ccc;
+  }
+  :deep(.ant-btn) {
     background: linear-gradient(45deg, #515459 0%, #1b1c1f 100%);
     color: #fee0ab;
+    border: none;
   }
 }
-.card-pro {
-  background-image: url("@/assets/image/mall/card-pro.png");
+
+.seMember {
+  background-image: url('@/assets/image/mall/card-pro.png');
   color: #0e2372;
-  .row-item {
-    border-bottom-color: #404e78;
-    .label {
-      color: #ccc;
-    }
-  }
   .card-top {
-    * {
+    :is(h3, p, div) {
       background-image: linear-gradient(
-        356.4388238315751deg,
+        356deg,
         #ffeacc 0%,
         #fdccbb 35%,
         #f4cdf5 57%,
@@ -347,25 +355,15 @@ const faqList = ref([
       -webkit-background-clip: text;
       background-clip: text;
       color: transparent;
-      -webkit-text-fill-color: transparent;
     }
   }
-  button {
+  .row-item .label {
+    color: #ccc;
+  }
+  :deep(.ant-btn) {
     background: linear-gradient(56deg, #36457f 0%, #202637 100%);
     color: #e6c0b1;
+    border: none;
   }
 }
-
-// 常见问题折叠面板
-.faq-collapse {
-  .ant-collapse-item {
-    margin-bottom: 8px;
-  }
-  p {
-    font-size: 13px;
-    line-height: 1.7;
-    color: #555;
-    margin: 0;
-  }
-}
-</style>
+</style>

+ 18 - 32
src/views/mall/components/valueAdded.vue

@@ -1,23 +1,26 @@
 <template>
-  <div class="service-wrap container">
+  <div class="container service-wrap">
     <h2 class="service-title">增值服务</h2>
     <div class="service-list">
       <div
         v-for="item in serviceList"
         :key="item.id"
-        @click="$router.push(`/mall/details/${item.id}`)"
+        @click="$router.push(`/mall/sercice/${item.id}`)"
         class="service-card"
       >
         <!-- 图标+标题 -->
         <div class="card-head">
-          <i class="iconfont" :class="item.icon"></i>
-          <h3 class="card-name">{{ item.name }}</h3>
+          <i class="iconfont" :class="serciceData[item.id].icon"></i>
+          <h3 class="card-name">{{ serciceData[item.id].name }}</h3>
         </div>
         <!-- 描述文本 -->
-        <div class="card-desc">{{ item.desc }}</div>
+        <div class="card-desc">{{ serciceData[item.id].shopInfo.introduction }}</div>
         <!-- 底部按钮 -->
-        <div class="card-btn cursor-pointer">
-          {{ item.btnText }}
+        <div class="cursor-pointer card-btn" v-if="item.isPublic">
+          {{ item.amount?$t('mall.costType.price', { price: item.amount }): $t('mall.costType.0') }}
+        </div>
+        <div class="cursor-pointer card-btn" v-else>
+          {{ $t('mall.costType.1') }}
         </div>
       </div>
     </div>
@@ -25,32 +28,15 @@
 </template>
 
 <script setup>
-import { ref } from 'vue'
-
+import { ref, onMounted } from 'vue'
+import { serviceConfigList, userSerciceList } from '@/api/mall'
+import { serciceData, filterServiceMenuList } from '../data'
 // 增值服务数据源
-const serviceList = ref([
-  {
-    id: 100,
-    icon: 'icon-store_benefit_panorama',
-    name: '四维全景',
-    desc: '小程序需完成与四维服务的对接,才能解锁VR全景展示能力,享受完整流畅的VR体验。',
-    btnText: '限时免费'
-  },
-  {
-    id: 101,
-    icon: 'icon-store_benefit_download',
-    name: '离线包下载服务',
-    desc: 'VR场景离线包支持本地离线打开,无需安装应用,浏览器直接访问。',
-    btnText: '¥99'
-  },
-  {
-    id: 102,
-    icon: 'icon-store_benefit_ai',
-    name: 'AI训练',
-    desc: '基于专业大模型技术,为客户提供专属AI模型训练服务。',
-    btnText: '联系客服'
-  }
-])
+const serviceList = ref([])
+const getList = async () => {
+  serviceList.value = await serviceConfigList()
+}
+onMounted(getList)
 </script>
 
 <style scoped>

+ 51 - 19
src/views/mall/data.ts

@@ -1,7 +1,9 @@
-import i18n from "@/locales/index.js";
 import { ref } from "vue";
-
-const { t, locale } = i18n.global;
+import i18n from "@/locales/index.js";
+function $t(key: string) {
+  console.log(i18n, 'global');
+  return i18n.global.t(key);
+}
 export enum goodsType {
   parts = 0,
   server = 1,
@@ -242,9 +244,9 @@ export const partsData = {
   },
   11: {
     type: goodsType.parts,
-    name: t('mall.usb'),
+    name: $t('mall.usb'),
     img: `images/zhijiabrowse/usb-0.png`,
-    detail: [`${t('mall.usb')}(${t('mall.silvery')})`],
+    detail: [`${$t('mall.usb')}(${$t('mall.silvery')})`],
     price: usb,
     skuSn: 'U15604132404280603',
 
@@ -351,9 +353,9 @@ export const partsData = {
   },
   12: {
     type: goodsType.parts,
-    name: t('mall.battery'),
+    name: $t('mall.battery'),
     img: `images/zhijiabrowse/battery-0.png`,
-    detail: [`${t('mall.battery')}(${t('mall.black')})`],
+    detail: [`${$t('mall.battery')}(${$t('mall.black')})`],
     price: battery,
     skuSn: 'U15604132404280703',
 
@@ -465,9 +467,32 @@ export const partsData = {
       }
     },
   },
-  100: {
+}
+
+
+export const PANORAMA_SERVICE_ID = 1;
+
+export const hasPanoramaUserService = (userSerciceList) => {
+  const list = Array.isArray(userSerciceList)
+    ? userSerciceList
+    : userSerciceList?.list || userSerciceList?.data || [];
+  if (!list.length) return false;
+  return list.some(
+    (item) => Number(item?.id ?? item?.serviceId) === PANORAMA_SERVICE_ID
+  );
+};
+
+export const filterServiceMenuList = (menuList, userSerciceList) => {
+  const list = Array.isArray(menuList) ? menuList : menuList?.list || [];
+  if (hasPanoramaUserService(userSerciceList)) return list;
+  return list.filter((item) => Number(item.id) !== PANORAMA_SERVICE_ID);
+};
+
+export const serciceData = {
+  2: {
     type: goodsType.server,
     name: '离线包下载服务',
+    icon: 'icon-store_benefit_download',
     price: 99,
     skuSn: 'U15609161635760015',
     browdata: [
@@ -476,17 +501,21 @@ export const partsData = {
         small: "download",
         big: "download",
       }],
-      
+
     shopInfo: {
       name: "离线包下载服务",
-      price: 199,
+      price: 99,
       okText: '立即购买',
       introduction: 'VR 场景离线包支持本地离线打开,无需安装应用,浏览器直接访问。',
+      payType: 'download',
+      dateType: 6,
     },
   },
-  101: {
+  1: {
     type: goodsType.server,
-    name: '离线包下载服务',
+    icon: 'icon-store_benefit_panorama',
+    btnText: '限时免费',
+    name: '四维全景',
     price: 99,
     skuSn: 'U15609161635760015',
     browdata: [
@@ -495,17 +524,19 @@ export const partsData = {
         small: "swqj",
         big: "swqj",
       }],
-      
+
     shopInfo: {
-      name: "离线包下载服务",
-      price: 199,
+      name: "四维全景",
+      price: 99,
       okText: '立即购买',
       introduction: '小程序需完成与四维服务的对接,才能解锁 VR 全景展示能力,享受完整流畅的 VR 体验。',
+      payType: 'service',
     },
   },
-  102: {
+  3: {
     type: goodsType.server,
     name: 'AI训练',
+    icon: 'icon-store_benefit_ai',
     price: 0,
     skuSn: 'U15609161635760015',
     browdata: [
@@ -514,12 +545,13 @@ export const partsData = {
         small: "aiTraining",
         big: "aiTraining",
       }],
-      
+
     shopInfo: {
       name: "AI训练",
       price: 199,
       okText: '联系客服',
       introduction: '基于专业大模型技术,为客户提供专属 AI 模型训练服务。',
+      payType: 'contact',
     },
   }
 }
@@ -527,10 +559,10 @@ export const stepList = ref([
   {
     id: 1,
     name: "加入购物车",
-  },{
+  }, {
     id: 2,
     name: "确认订单",
-  },{
+  }, {
     id: 3,
     name: "下单",
   },

+ 1 - 1
src/views/mall/details.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="shop-page">
     <div class="routerInfo container" style="padding: 20px; font-size: 16px;">
-      <span class="cursor-pointer" style="color: #86909C" @click="$router.go(-1)">
+      <span class="cursor-pointer" style="color: #86909C" @click="$router.replace('/mall/index')">
         {{$t(`mall.typeList.${goodInfoData.type}`)}}  /</span> <span style="colot: #202020">{{goodInfoData.name}}</span>
 
     </div>

+ 3 - 0
src/views/mall/home.vue

@@ -29,6 +29,9 @@ import ShopMemberCard from "./components/shopMemberCard.vue";
 import parts from "./components/Parts.vue";
 import valueAdded from "./components/valueAdded.vue";
 import { ref } from "vue";
+
+defineOptions({ name: "MallHome" });
+
 const activeTab = ref("member");
 </script>
 

+ 66 - 66
src/views/mall/index.vue

@@ -1,66 +1,66 @@
-<template>
-  <div class="mall-layout">
-    <iheader />
-      <router-view v-slot="{ Component, route }">
-        <!-- 路由meta标记keepAlive为true时开启缓存 -->
-        <keep-alive>
-          <component v-if="keepAlive" :is="Component" :key="route.fullPath" />
-        </keep-alive>
-        <component v-if="!keepAlive" :is="Component" :key="route.fullPath" />
-      </router-view>
-    <ifooter />
-  </div>
-</template>
-
-<script setup>
-import { computed, ref } from "vue";
-import { useRoute } from "vue-router";
-import ifooter from "@/layouts/footer.vue";
-import iheader from "./components/header.vue";
-const route = useRoute();
-const keepAlive = computed(() => {
-  return route.meta.keepAlive ?? true;
-});
-</script>
-
-<style lang="less" scoped>
-.mall-layout{
-  background: #f7f7f7;
-}
-.mall {
-  .mall-page {
-    // 页面头部
-    .page-header {
-      text-align: center;
-      padding: 130px 0 93px 0;
-      background-image: url("@/assets/image/mall/mallbg.png");
-      background-size: auto 400px;
-      background-repeat: no-repeat;
-
-      .main-title {
-        font-weight: normal;
-        font-size: 48px;
-        color: #1d2129;
-        height: 66px;
-        margin: 0 0 16px;
-      }
-      .sub-desc {
-        font-weight: normal;
-        font-size: 24px;
-        color: #4e5969;
-        margin: 0;
-      }
-    }
-
-    // Tab栏
-    .page-tabs {
-      margin-bottom: 48px;
-      :deep(.ant-tabs-tab) {
-        .ant-tabs-tab-btn {
-          font-size: 16px;
-        }
-      }
-    }
-  }
-}
-</style>
+<template>

+  <div class="mall-layout">

+    <iheader />

+    <router-view v-slot="{ Component, route: childRoute }">

+      <keep-alive :include="cachedViews">

+        <component

+          :is="Component"

+          :key="childRoute.meta.keepAlive ? childRoute.name : childRoute.fullPath"

+        />

+      </keep-alive>

+    </router-view>

+    <ifooter />

+  </div>

+</template>

+

+<script setup>

+import ifooter from "@/layouts/footer.vue";

+import iheader from "./components/header.vue";

+

+defineOptions({ name: "MallLayout" });

+

+/** 与路由 meta.keepAlive 对应,需与子页面 defineOptions name 一致 */

+const cachedViews = ["MallHome", "MallCart"];

+</script>

+

+<style lang="less" scoped>

+.mall-layout{

+  background: #f7f7f7;

+}

+.mall {

+  .mall-page {

+    // 页面头部

+    .page-header {

+      text-align: center;

+      padding: 130px 0 93px 0;

+      background-image: url("@/assets/image/mall/mallbg.png");

+      background-size: auto 400px;

+      background-repeat: no-repeat;

+

+      .main-title {

+        font-weight: normal;

+        font-size: 48px;

+        color: #1d2129;

+        height: 66px;

+        margin: 0 0 16px;

+      }

+      .sub-desc {

+        font-weight: normal;

+        font-size: 24px;

+        color: #4e5969;

+        margin: 0;

+      }

+    }

+

+    // Tab栏

+    .page-tabs {

+      margin-bottom: 48px;

+      :deep(.ant-tabs-tab) {

+        .ant-tabs-tab-btn {

+          font-size: 16px;

+        }

+      }

+    }

+  }

+}

+</style>


+ 385 - 0
src/views/mall/membership.vue

@@ -0,0 +1,385 @@
+<template>
+  <div class="container membership-wrap">
+    <h2 class="page-title">{{ t('member.buyMember') }}</h2>
+    <div class="divider"></div>
+
+    <a-spin :spinning="loading">
+      <div class="membership-table">
+        <div class="table-head">
+          <div class="col col-type">{{ t('member.type') }}</div>
+          <div class="col col-term">{{ t('member.memberDeadLine') }}</div>
+          <div class="col col-expire">{{ t('member.expiredDate') }}</div>
+          <div class="col col-count">{{ t('member.buyCount') }}</div>
+          <div v-if="incrementId" class="col col-package">{{ t('member.memberPackage') }}</div>
+          <div class="col col-camera">{{ t('member.bindDeviceky') }}</div>
+        </div>
+        <div class="divider-sm"></div>
+        <div class="table-row">
+          <div class="col col-type">{{ memberTitle }}</div>
+          <div class="col col-term">
+            <a-select
+              v-if="canSelectTerm"
+              v-model:value="termMonths"
+              class="term-select"
+              :bordered="false"
+              :dropdown-match-select-width="false"
+            >
+              <a-select-option v-for="n in termMonthOptions" :key="n" :value="n">
+                {{ n }}{{ t('member.Month') }}
+              </a-select-option>
+            </a-select>
+            <span v-else-if="routeConfig.selectableTerm && effectiveAutoRenew">1{{ t('member.Month') }}</span>
+            <span v-else>1 {{ t('member.year') }}</span>
+          </div>
+          <div class="col col-expire highlight">{{ deadLine || '-' }}</div>
+          <div class="col col-count">
+            <number v-model="count" :max="maxCount" />
+          </div>
+          <div v-if="incrementId" class="col col-package">{{ incrementId }}</div>
+          <div class="col col-camera highlight">{{ count }}</div>
+        </div>
+      </div>
+    </a-spin>
+
+    <div class="divider"></div>
+
+    <div v-if="showAutoRenew" class="auto-renew">
+      <a-checkbox v-model:checked="autoRenew">{{ t('member.autoRenew') }}</a-checkbox>
+      <p class="auto-renew-tips">{{ t('member.autoRenewTips') }}</p>
+    </div>
+
+    <div class="membership-footer">
+      <div class="pay-icons">
+        <template v-if="isIntl">
+          <span class="pay-logo paypal" aria-label="PayPal">PayPal</span>
+          <span class="pay-logo stripe" aria-label="Stripe">stripe</span>
+        </template>
+        <template v-else>
+          <img src="@/assets/image/mall/ali-pay.png" alt="支付宝" class="pay-img" />
+          <img src="@/assets/image/mall/wechat-pay.png" alt="微信支付" class="pay-img" />
+        </template>
+      </div>
+      <div class="footer-right">
+        <div class="summary">
+          <span class="summary-count">{{ t('mall.countNum', { num: count }) }}</span>
+          <span class="summary-price">{{ currencySymbol }} {{ totalPrice.toFixed(2) }}</span>
+        </div>
+        <a-button
+          type="primary"
+          size="large"
+          class="settle-btn"
+          :loading="submitting"
+          @click="submitOrder"
+        >
+          {{ t('mall.nowJieSuan') }}
+        </a-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, watch } from 'vue'
+import { useRoute } from 'vue-router'
+import { useI18n } from 'vue-i18n'
+import { message } from 'ant-design-vue'
+import { getIncrementType } from '@/api/equity/index.js'
+import { useMembershipPay, DEFAULT_SUCCESS_REDIRECT } from '@/hooks/useMembershipPay.js'
+import { getMembershipRouteConfig } from './membershipConfig'
+import number from './components/number.vue'
+
+const { t } = useI18n()
+const route = useRoute()
+const isIntl = import.meta.env.VITE_REGION === 'EN'
+
+const routeId = computed(() => String(route.params.id || '5'))
+const routeConfig = computed(() => getMembershipRouteConfig(routeId.value))
+const incrementId = computed(() => String(route.query.incrementId || ''))
+const incrementTypeId = computed(() => String(route.query.incrementTypeId || ''))
+const count = ref(1)
+const termMonths = ref(1)
+/** 国内不支持自动续费,默认关闭 */
+const autoRenew = ref(isIntl)
+const unitPrice = ref(0)
+
+/** 仅国际版高级会员展示自动续费 */
+const showAutoRenew = computed(() => isIntl && routeConfig.value.selectableTerm)
+
+const effectiveAutoRenew = computed(() => isIntl && autoRenew.value)
+
+const canSelectTerm = computed(() => {
+  if (!routeConfig.value.selectableTerm) return false
+  return isIntl ? !autoRenew.value : true
+})
+
+const termMonthOptions = computed(() =>
+  Array.from({ length: routeConfig.value.maxTermMonths }, (_, i) => i + 1)
+)
+
+const apiIncrementTypeId = ref('')
+
+
+
+const memberTitle = computed(() => routeConfig.value.title)
+
+const currencySymbol = computed(() => (isIntl ? '$' : '¥'))
+
+/** dateType = 路由 id;monthQy = 购买月数 */
+const monthQy = computed(() => {
+  if (!routeConfig.value.selectableTerm) return 1
+  if (effectiveAutoRenew.value) return 1
+  return termMonths.value
+})
+
+/** 高级会员:接口为单月价,页面自行计算展示总价 */
+const totalPrice = computed(() => {
+  let amount = unitPrice.value * count.value
+  if (routeConfig.value.selfCalculateTerm) {
+    amount *= monthQy.value
+  }
+  return Number(amount.toFixed(2))
+})
+
+const loadPriceInfo = async () => {
+  const data = await fetchPrice()
+  if (data) {
+    unitPrice.value = Number(data.price ?? 0)
+  }
+}
+
+const resolveIncrementTypeId = async () => {
+  const fromQuery = route.query.incrementTypeId
+  if (fromQuery) {
+    apiIncrementTypeId.value = String(fromQuery)
+    return
+  }
+  const res = await getIncrementType({})
+  const list = Array.isArray(res) ? res : []
+  const target = list.find((item) => item.id === routeConfig.value.memberType)
+  if (!target) {
+    message.error(t('toast.25'))
+    return
+  }
+  apiIncrementTypeId.value = String(target.id)
+}
+
+const {
+  deadLine,
+  maxCount,
+  loading,
+  submitting,
+  fetchPrice,
+  submitOrder,
+} = useMembershipPay({
+  incrementTypeId,
+  incrementId,
+  count,
+  dateType: routeId,
+  monthQy,
+  autoRenew: effectiveAutoRenew,
+  successRedirect: '/equity',
+})
+
+watch(termMonths, () => {
+  if (canSelectTerm.value && incrementTypeId.value) {
+    loadPriceInfo()
+  }
+})
+
+watch(autoRenew, (checked) => {
+  if (!showAutoRenew.value) return
+  if (checked) termMonths.value = 1
+  if (incrementTypeId.value) loadPriceInfo()
+})
+
+onMounted(async () => {
+  // await resolveIncrementTypeId()
+  if (incrementTypeId.value) {
+    await loadPriceInfo()
+  }
+})
+</script>
+
+<style scoped lang="less">
+.membership-wrap {
+  margin: 60px auto;
+  padding: 42px 90px 40px;
+  background: #fff;
+  box-shadow: 0 2px 2px rgba(0, 0, 0, 0.11);
+  box-sizing: border-box;
+}
+
+.page-title {
+  font-size: 24px;
+  font-weight: 600;
+  color: #1d2129;
+  margin: 0;
+}
+
+.divider {
+  width: 100%;
+  height: 1px;
+  background: #e8e8e8;
+  margin: 28px 0 32px;
+}
+
+.divider-sm {
+  width: 100%;
+  height: 1px;
+  background: #eee;
+}
+
+.membership-table {
+  .table-head,
+  .table-row {
+    display: flex;
+    align-items: center;
+    font-size: 16px;
+    color: #1d2129;
+  }
+
+  .table-head {
+    padding: 0 0 16px;
+    color: #666;
+  }
+
+  .table-row {
+    padding: 20px 0 8px;
+  }
+
+  .col {
+    flex: 1;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    text-align: center;
+  }
+
+  .col-type {
+    justify-content: flex-start;
+    text-align: left;
+  }
+
+  .highlight {
+    color: #f53f3f;
+  }
+
+  .term-select {
+    min-width: 100px;
+    font-size: 16px;
+
+    :deep(.ant-select-selector) {
+      padding: 0 !important;
+      border: none !important;
+      box-shadow: none !important;
+      background: transparent !important;
+    }
+
+    :deep(.ant-select-selection-item) {
+      padding-right: 20px !important;
+      color: #1d2129;
+    }
+
+    :deep(.ant-select-arrow) {
+      color: #1d2129;
+    }
+  }
+}
+
+.auto-renew {
+  margin-bottom: 48px;
+
+  :deep(.ant-checkbox-wrapper) {
+    font-size: 16px;
+    color: #1d2129;
+  }
+
+  .auto-renew-tips {
+    margin: 12px 0 0;
+    font-size: 14px;
+    line-height: 22px;
+    color: #86909c;
+    max-width: 920px;
+  }
+}
+
+.membership-footer {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-end;
+  margin-top: 0;
+}
+
+.pay-icons {
+  display: flex;
+  align-items: center;
+  gap: 28px;
+  margin-left: 8px;
+  pointer-events: none;
+  user-select: none;
+}
+
+.pay-img {
+  width: 79px;
+  height: 28px;
+  object-fit: contain;
+}
+
+.pay-logo {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  height: 28px;
+  padding: 0 12px;
+  border-radius: 4px;
+  font-size: 18px;
+  font-weight: 700;
+  letter-spacing: 0.2px;
+}
+
+.paypal {
+  color: #003087;
+  background: #fff;
+  border: 1px solid #c4c4c4;
+  font-style: italic;
+  font-family: Arial, sans-serif;
+}
+
+.stripe {
+  color: #635bff;
+  background: #fff;
+  font-family: Arial, sans-serif;
+  font-weight: 600;
+  letter-spacing: -0.5px;
+}
+
+.footer-right {
+  display: flex;
+  flex-direction: column;
+  align-items: flex-end;
+  gap: 20px;
+}
+
+.summary {
+  display: flex;
+  align-items: baseline;
+  gap: 24px;
+  font-size: 16px;
+  color: #666;
+}
+
+.summary-price {
+  font-size: 28px;
+  font-weight: 600;
+  color: #f53f3f;
+}
+
+.settle-btn {
+  width: 180px;
+  height: 50px;
+  font-size: 16px;
+  font-weight: 600;
+  background: #3275ff;
+  border-color: #3275ff;
+}
+</style>

+ 37 - 0
src/views/mall/membershipConfig.ts

@@ -0,0 +1,37 @@
+export type MemberType = 'freeMember' | 'member' | 'seMember'
+
+export interface MembershipRouteConfig {
+  memberType: MemberType
+  title: string
+  termUnit: 'year' | 'month'
+  /** 是否可选会员期限(未勾选自动续费时) */
+  selectableTerm: boolean
+  /** 可选月数上限 */
+  maxTermMonths: number
+  /** 接口返回单月价,总价需前端乘以 monthQy */
+  selfCalculateTerm: boolean
+}
+
+/** 路由 id 与会员购买模式映射 */
+export const MEMBERSHIP_ROUTE_CONFIG: Record<string, MembershipRouteConfig> = {
+  /** 专业会员 - 按年购买 */
+  '5': {
+    memberType: 'member',
+    title: '专业会员',
+    termUnit: 'year',
+    selectableTerm: false,
+    maxTermMonths: 1,
+    selfCalculateTerm: false,
+  },
+  '7': {
+    memberType: 'seMember',
+    title: '高级会员',
+    termUnit: 'month',
+    selectableTerm: true,
+    maxTermMonths: 10,
+    selfCalculateTerm: true,
+  },
+}
+
+export const getMembershipRouteConfig = (routeId: string | number) =>
+  MEMBERSHIP_ROUTE_CONFIG[String(routeId)] ?? MEMBERSHIP_ROUTE_CONFIG['5']

+ 199 - 0
src/views/mall/sercice.vue

@@ -0,0 +1,199 @@
+<template>
+  <div class="shop-page">
+    <div class="container routerInfo" style="padding: 20px; font-size: 16px;">
+      <span class="cursor-pointer" style="color: #86909C" @click="$router.replace('/mall/index')">
+        {{ $t(`mall.typeList.${goodInfoData.type}`) }}  /</span>
+      <span style="color: #202020">{{ goodInfoData.name }}</span>
+    </div>
+    <div class="shop-info">
+      <div class="container flex justify-between" style="margin-bottom: 40px">
+        <browse
+        v-if="browdata && browdata.length"
+          :idata="browdata"
+          :iactive="browdata[0]"
+          :floder="'zhijiabrowse'"
+          class="product-img"
+        />
+        <a-spin :spinning="priceLoading">
+          <shopInfo
+            :shopInfo="displayShopInfo"
+            :loading="submitting"
+            @add-to-cart="handleBuy"
+          />
+        </a-spin>
+      </div>
+
+      <div class="container plate03" v-if="specifications">
+        <div class="b-title">{{ specifications.name }}</div>
+        <a-row class="params-con" :gutter="95">
+          <a-col
+            class="params-item"
+            :span="12"
+            v-for="(item, index) in specifications.techSpecs.list"
+            :key="index"
+          >
+            <div class="flex">
+              <div class="p-l">{{ item.name }}</div>
+              <div class="p-r">
+                <div v-html="sub" v-for="(sub, i) in item.val" :key="i"></div>
+              </div>
+            </div>
+          </a-col>
+        </a-row>
+        <div class="sub">
+          <p>{{ specifications.techSpecs.dec }}</p>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, computed, onMounted, onUnmounted, watch, createVNode } from "vue";
+import browse from "./components/browse.vue";
+import shopInfo from "./components/shopInfo.vue";
+import { serciceData } from "./data.ts";
+import { userSerciceBuy } from "@/api/mall";
+import { useRoute } from "vue-router";
+import { useI18n } from "vue-i18n";
+import { Modal, message } from "ant-design-vue";
+import { ExclamationCircleFilled } from "@ant-design/icons-vue";
+import { useDownloadPay } from "@/hooks";
+
+const route = useRoute();
+const { t, locale } = useI18n();
+const language = computed(() => locale.value);
+
+const serviceId = computed(() => String(route.params.id || ""));
+const goodInfoData = computed(() => serciceData[serviceId.value] || {});
+const browdata = ref([]);
+const shopInfoData = reactive({});
+const goodInfo = ref(null);
+const specifications = ref(null);
+
+const displayShopInfo = computed(() => ({
+  ...shopInfoData,
+  price: isDownloadPay.value && unitPrice.value > 0 ? unitPrice.value : shopInfoData.price,
+}));
+
+const payType = computed(() => shopInfoData.payType || "download");
+const isDownloadPay = computed(() => payType.value === "download");
+
+const sceneNum = computed(() => String(route.query.sceneNum || ""));
+const sceneName = computed(() => String(route.query.sceneName || ""));
+
+const {
+  skuSn,
+  unitPrice,
+  orderSn,
+  loading: priceLoading,
+  submitting,
+  paymentPending,
+  fetchPrice,
+  submitOrder,
+  confirmPaid,
+  openPayment,
+  jumpToPayment,
+} = useDownloadPay({
+  dateType: computed(() => shopInfoData.dateType || 6),
+  sceneNum,
+  sceneName,
+  successRedirect: { name: "mallIndex" },
+});
+
+let paymentModal = null;
+
+const closePaymentModal = () => {
+  paymentModal?.destroy();
+  paymentModal = null;
+};
+
+const showPaymentConfirm = () => {
+  closePaymentModal();
+  paymentModal = Modal.confirm({
+    title: t("common.tip"),
+    icon: createVNode(ExclamationCircleFilled),
+    centered: true,
+    maskClosable: false,
+    content: t("mall.payPendingTip"),
+    okText: t("mall.paidOff"),
+    cancelText: t("mall.reopenPay"),
+    onOk: () => confirmPaid(),
+    onCancel: () => {
+      jumpToPayment(orderSn.value);
+      return Promise.reject();
+    },
+  });
+};
+
+watch(paymentPending, (pending) => {
+  if (pending) {
+    showPaymentConfirm();
+    return;
+  }
+  closePaymentModal();
+});
+
+const initPageData = () => {
+  const data = goodInfoData.value;
+  if (!data || !Object.keys(data).length) return;
+  browdata.value = data.browdata || [];
+  Object.assign(shopInfoData, data.shopInfo || {});
+  goodInfo.value = data.goodInfo1 || null;
+  specifications.value = data.goodInfo || null;
+};
+
+const handleServiceBuy = async () => {
+  const res = await userSerciceBuy({ serviceId: serviceId.value });
+  if (res?.orderSn) {
+    openPayment(res.orderSn);
+    return;
+  }
+  message.success(t("common.success"));
+};
+
+const handleBuy = async () => {
+  if (payType.value === "contact") {
+    message.info(t("mall.purchaseTpis"));
+    return;
+  }
+  if (submitting.value) return;
+
+  if (payType.value === "service") {
+    await handleServiceBuy();
+    return;
+  }
+
+  if (!skuSn.value) {
+    await fetchPrice();
+  }
+  if (!skuSn.value) return;
+  await submitOrder();
+};
+
+watch(serviceId, () => {
+  initPageData();
+  if (isDownloadPay.value) {
+    fetchPrice();
+  }
+});
+
+onMounted(async () => {
+  initPageData();
+  if (isDownloadPay.value) {
+    await fetchPrice();
+  }
+});
+
+onUnmounted(() => {
+  closePaymentModal();
+});
+</script>
+
+<style scoped>
+@import "./components/parts/style.less";
+
+.shop-page {
+  background: #fff;
+}
+</style>

+ 14 - 13
src/views/order/components/InvoicePreview.vue

@@ -13,28 +13,28 @@
         <!-- 公共通用字段 -->
         <div class="invoice-item">
           <span class="label">发票类型:</span>
-          <span class="value">{{ info.invoiceTypeName }}</span>
+          <span class="value">{{ titleText }}</span>
         </div>
         <div class="invoice-item">
           <span class="label">发票抬头:</span>
-          <span class="value">{{ info.invoiceTitle }}</span>
+          <span class="value">{{ info.title }}</span>
         </div>
         <div class="invoice-item">
           <span class="label">税务登记号:</span>
-          <span class="value">{{ info.taxNo }}</span>
+          <span class="value">{{ info.code }}</span>
         </div>
 
         <!-- 电子普票独有:电子邮箱 -->
-        <div v-if="invoiceType === 'electronic'" class="invoice-item">
+        <div v-if="invoiceType === 2" class="invoice-item">
           <span class="label">电子邮箱:</span>
-          <span class="value">{{ info.email }}</span>
+          <span class="value">{{ info.emailAddress }}</span>
         </div>
 
         <!-- 专票独有:银行注册信息 -->
-        <template v-if="invoiceType === 'special'">
+        <template v-if="invoiceType == 3">
           <div class="invoice-item">
             <span class="label">注册地址:</span>
-            <span class="value">{{ info.registerAddr }}</span>
+            <span class="value">{{ info.organizedAddress }}</span>
           </div>
           <div class="invoice-item">
             <span class="label">注册电话:</span>
@@ -72,17 +72,18 @@
 import { computed } from 'vue';
 
 // 发票类型枚举
-type InvoiceType = 'electronic' | 'special';
+type InvoiceType = 2 | 'special';
 
 interface InvoiceInfo {
   invoiceType: InvoiceType;
+  type;
   invoiceTypeName: string;
   invoiceTitle: string;
   taxNo: string;
   // 电子普票字段
-  email?: string;
+  emailAddress?: string;
   // 专票专属字段
-  registerAddr?: string;
+  organizedAddress?: string;
   registerPhone?: string;
   bankName?: string;
   bankAccount?: string;
@@ -95,18 +96,18 @@ const props = defineProps<{
 }>();
 
 // 当前发票类型
-const invoiceType = computed(() => props.info.invoiceType);
+const invoiceType = computed(() => props.info.type);
 
 // 动态标题文字
 const titleText = computed(() => {
-  return invoiceType.value === 'electronic'
+  return invoiceType.value === 2
     ? '增值税普票电子发票'
     : '增值税专用发票';
 });
 
 // 底部动态提示文案
 const tipText = computed(() => {
-  return invoiceType.value === 'electronic'
+  return invoiceType.value === 2
     ? '注:电子发票将于每个月 15 日开具内发送至您的邮箱!'
     : '注:专票将于每个月 15 日开具并邮寄到您的默认收货地址,请注意查收!';
 });

+ 42 - 37
src/views/order/components/invoiceModal.vue

@@ -1,6 +1,6 @@
 <template>
   <a-modal
-    title="授权相机"
+    title="开具发票"
     :open="visible"
     @cancel="handleCancel"
     :width="600"
@@ -20,25 +20,25 @@
           v-model:value="formData.invoiceType"
           @change="handleTypeChange"
         >
-          <a-select-option value="electronic"
+          <a-select-option :value="2"
             >增值税普票(电子发票)</a-select-option
           >
-          <a-select-option value="special">增值税专用发票</a-select-option>
+          <a-select-option :value="3">增值税专用发票</a-select-option>
         </a-select>
       </a-form-item>
 
       <!-- 公共必填项:发票抬头 -->
-      <a-form-item label="发票抬头" name="invoiceTitle">
+      <a-form-item label="发票抬头" name="title">
         <a-input size="large"
-          v-model:value="formData.invoiceTitle"
+          v-model:value="formData.title"
           placeholder="请输入发票抬头"
         />
       </a-form-item>
 
       <!-- 公共必填项:税务登记号 -->
-      <a-form-item label="税务登记号" name="taxNo">
+      <a-form-item label="税务登记号" name="code">
         <a-input size="large"
-          v-model:value="formData.taxNo"
+          v-model:value="formData.code"
           placeholder="请输入18位税务登记号"
           maxlength="18"
         />
@@ -46,18 +46,18 @@
 
       <!-- 电子发票专属:电子邮箱 -->
       <a-form-item 
-        v-if="formData.invoiceType === 'electronic'"
+        v-if="formData.invoiceType == 2"
         label="电子邮箱"
-        name="email"
+        name="emailAddress"
       >
-        <a-input size="large" v-model:value="formData.email" placeholder="请输入电子邮箱" />
+        <a-input size="large" v-model:value="formData.emailAddress" placeholder="请输入电子邮箱" />
       </a-form-item>
 
       <!-- 专票专属字段 start -->
-      <template v-if="formData.invoiceType === 'special'">
-        <a-form-item label="注册地址" name="registerAddr">
+      <template v-if="formData.invoiceType == 3">
+        <a-form-item label="注册地址" name="organizedAddress">
           <a-input size="large"
-            v-model:value="formData.registerAddr"
+            v-model:value="formData.organizedAddress"
             placeholder="请输入注册地址"
           />
         </a-form-item>
@@ -86,9 +86,9 @@
       <!-- 专票专属字段 end -->
 
       <!-- 公共项:联系电话 -->
-      <a-form-item label="联系电话" name="contactPhone">
+      <a-form-item label="联系电话" name="shipMobile">
         <a-input size="large"
-          v-model:value="formData.contactPhone"
+          v-model:value="formData.shipMobile"
           placeholder="请输入联系电话"
         />
       </a-form-item>
@@ -106,13 +106,17 @@ import {
   onUnmounted,
 } from "vue";
 import { debounce } from "lodash-es";
-
+import { invoiceOpen } from "@/api/equity";
 // 1. 定义Props(增加类型校验和注释)
 const props = defineProps({
   visible: {
     type: Boolean,
     default: false,
     description: "弹窗是否可见",
+  },
+  item: {
+    type: Object,
+    default: {},
   }
 });
 
@@ -129,37 +133,37 @@ const submitLoading = ref(false);
 
 // 初始化表单
 const formData = reactive({
-  invoiceType: 'electronic',
-  invoiceTitle: '',
-  taxNo: '',
-  email: '',
-  registerAddr: '',
+  invoiceType: 2,
+  title: '',
+  code: '',
+  emailAddress: '',
+  organizedAddress: '',
   registerPhone: '',
   bankName: '',
   bankAccount: '',
-  contactPhone: '',
+  shipMobile: '',
 });
 
 // 动态校验规则
 const formRules = reactive({
   // 通用校验
   invoiceType: [{ required: true, message: '请选择发票类型', trigger: 'change' }],
-  invoiceTitle: [{ required: true, message: '请输入发票抬头', trigger: 'blur' }],
-  taxNo: [
+  title: [{ required: true, message: '请输入发票抬头', trigger: 'blur' }],
+  code: [
     { required: true, message: '请输入税务登记号', trigger: 'blur' },
     { pattern: /^[0-9A-Z]{18}$/, message: '税务登记号必须18位', trigger: 'blur' },
   ],
-  contactPhone: [
+  shipMobile: [
     { required: true, message: '请输入联系电话', trigger: 'blur' },
     { pattern: /^1[3-9]\d{9}$/, message: '手机号格式错误', trigger: 'blur' },
   ],
   // 电子票邮箱校验
-  email: [
+  emailAddress: [
     { required: true, message: '请输入电子邮箱', trigger: 'blur' },
     { pattern: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/, message: '邮箱格式错误', trigger: 'blur' },
   ],
   // 专票专属校验
-  registerAddr: [{ required: true, message: '请输入注册地址', trigger: 'blur' }],
+  organizedAddress: [{ required: true, message: '请输入注册地址', trigger: 'blur' }],
   registerPhone: [
     { required: true, message: '请输入注册电话', trigger: 'blur' },
     { pattern: /^1[3-9]\d{9}$/, message: '手机号格式错误', trigger: 'blur' },
@@ -175,15 +179,15 @@ const formRules = reactive({
 const handleTypeChange = () => {
   // 切换时清空所有表单校验报错
   formRef.value?.clearValidate();
-  if (formData.invoiceType === 'electronic') {
+  if (formData.invoiceType == 2) {
     // 切换电子票,清空专票字段
-    formData.registerAddr = '';
+    formData.organizedAddress = '';
     formData.registerPhone = '';
     formData.bankName = '';
     formData.bankAccount = '';
   } else {
     // 切换专票,清空电子票邮箱
-    formData.email = '';
+    formData.emailAddress = '';
   }
 };
 
@@ -192,15 +196,15 @@ watch(props.visible, (newVal) => {
   if (!newVal) {
     formRef.value?.resetFields();
     Object.assign(formData, {
-      invoiceType: 'electronic',
-      invoiceTitle: '',
-      taxNo: '',
-      email: '',
-      registerAddr: '',
+      invoiceType: '1',
+      title: '',
+      code: '',
+      emailAddress: '',
+      organizedAddress: '',
       registerPhone: '',
       bankName: '',
       bankAccount: '',
-      contactPhone: '',
+      shipMobile: '',
     });
   }
 });
@@ -213,7 +217,8 @@ const handleSubmit = async () => {
   try {
     submitLoading.value = true;
     // 表单校验
-    await formRef.value?.validate();
+    const params = await formRef.value?.validate();
+    await invoiceOpen({...params, orderId: props.item.id, type: params.invoiceType, consumeType: props.item.orderType === 1 ? 0 : props.item.orderType == 0 ? 2 : 3 })
     // 抛出表单数据给父组件
     emit('confirm', { ...formData });
     // 关闭弹窗

+ 50 - 15
src/views/order/data.ts

@@ -2,16 +2,18 @@ import scene_error from "@/assets/image/scene/scene_error.png";
 import scene_error_en from "@/assets/image/scene/scene_error-en.png";
 import { useI18n } from "vue-i18n";
 import i18n from "@/locales/index.js";
+import  { partsData } from '../mall/data'
+
 const { t , locale} = i18n.global;
 
 export const equityColumns = [
-  { title: "会员权益ID", dataIndex: "id" },
-  { title: "授权相机S/N码", dataIndex: "snCode" },
-  { title: "容量使用情况", dataIndex: "capacity" },
-  { title: "下载次数剩余", dataIndex: "remain" },
-  { title: "生效日期", dataIndex: "incrementStartStr" },
-  { title: "到期日期", dataIndex: "incrementEndTime" },
-  { title: "操作", slotName: "operate", width: 180 },
+  { title: "内容", dataIndex: "content", slotName: "content", width: 220,},
+  { title: "型号", dataIndex: "snCode", slotName: "orderType", width: 120,  },
+  { title: "订单编号", dataIndex: "orderSn" },
+  { title: "下单时间", dataIndex: "createTime" },
+  { title: "总价", dataIndex: "amount", customRender: ({record}) => record.amount || record.price || record.totalAmount },
+  { title: t('member.payType'), dataIndex: "payType", customRender: ({record}) => record.paymentTypeName != null && t(`mall.payTypeStr.${record.paymentTypeName}`) || record.payType!= null && t(`mall.payTypeStr.${record.payType}`)  },
+  { title: "操作", slotName: "operate", width: 120 },
 ]
 export const downColumns = [
   { title: "操作类型", dataIndex: "id" },
@@ -74,11 +76,44 @@ export const SceneTypePaths = {
   [SceneType.SWYDSS]: ["/swss/index.html", "/swss/index.html"],
   [SceneType.SWYDMX]: ["/swkk/spg.html", "/swkk/epg.html"],
 };
-export const getCameraType = [
-  { value: null, label: t("common.all") },
-  { value: CameraType.SWKK, label: t("camera.device."+CameraType.SWKK) },
-  { value: CameraType.SWKJ, label: t("camera.device."+CameraType.SWKJ) },
-  { value: CameraType.SWSS, label: t("camera.device."+CameraType.SWSS)  },
-  { value: CameraType.SWSX, label: t("camera.device."+CameraType.SWSX)  },
-  { value: 0, label: t("common.myUpload")  },
-]
+export const getOrderType = [
+  // { value: null, label: t("common.all") },
+  { value: 0, label: t("mall.member.memberTitle") },
+  { value: 1, label: t("mall.typeList.0") },
+  { value: 2, label: t("mall.typeList.1") },
+]
+
+export function getStatus(item:any) {
+  const { orderStatus, paymentStatus, shippingStatus } = item || {};
+
+  // 发货状态映射
+  const shippingMap = {
+    unshipped: 'unshipped',
+    partShipped: 'partShipped',
+    shipped: 'shipped',
+    received: 'received'
+  };
+
+  // 核心分支逻辑复刻原代码流程
+  if (orderStatus === 'completed') return 'finish';
+  if (orderStatus === 'expire') return 'expire';
+
+  if (orderStatus === 'unprocessed') {
+    if (paymentStatus === 'paid') {
+      return shippingMap[shippingStatus] ?? '';
+    }
+    if (paymentStatus === 'unpaid') {
+      return 'unpaid';
+    }
+  }
+
+  if (orderStatus === 'processed') {
+    return shippingMap[shippingStatus] ?? '';
+  }
+
+  return '';
+}
+export const orderGoodsInfo = (goodsId) =>{
+  const info = partsData[goodsId] || {}
+  return info.detail || []
+}

+ 93 - 43
src/views/order/details.vue

@@ -1,45 +1,75 @@
 <template>
   <div class="project-detail-container">
     <!-- 标题 -->
-    <h2 class="project-title">项目名称文件夹</h2>
+    <h2 class="project-title">订单编号:{{ item.orderSn }}</h2>
     <div class="divider"></div>
 
     <div class="content-wrapper">
-
       <div class="right-section">
-        <h3 class="section-title">场景信息</h3>
+        <h3 class="section-title">订单信息</h3>
         <div class="info-list">
           <div class="info-item">
-            <span class="label">拍摄设备:</span>
-            <span class="value">四维深光 | SN:XXXXX</span>
+            <span class="label">类型:</span>
+            <span class="value">{{
+              getOrderType.find((ele) => ele.value == item.orderType)?.label
+            }}</span>
+          </div>
+          <div class="info-item">
+            <span class="label">订单编号:</span>
+            <span class="value">{{ item.orderSn }}</span>
           </div>
           <div class="info-item">
-            <span class="label">拍摄方式:</span>
-            <span class="value">架站式拍摄</span>
+            <span class="label">下单时间:</span>
+            <span class="value">{{ item.createTime }}</span>
           </div>
           <div class="info-item">
-            <span class="label">拍摄时间:</span>
-            <span class="value">2024-08-25 18:01-2024-08-27 18:01</span>
+            <span class="label">付款时间:</span>
+            <span class="value">{{ item.tradeTime }}</span>
           </div>
           <div class="info-item">
-            <span class="label">拍摄点位:</span>
-            <span class="value">21</span>
+            <span class="label">内容:</span>
+            <span class="value">
+              <span v-if="item.orderType == 0">{{$t(item.memberLevels === 'SE' ? 'member.Senior' : 'member.Premium')}}(4DKanKan)</span>
+              <span v-else-if="item.orderType == 1">
+                <span v-for="(ele, index) in orderGoodsInfo(item.goodsId)">{{ ele }}
+                  <template v-if="index != 0">、</template>
+                </span>
+              </span>
+              <span v-else>
+                {{ item.sceneName }}{{item.sceneNum && `(${item.sceneNum})`}}{{$t('mall.downdesc')}}
+              </span>
+            </span>
           </div>
           <div class="info-item">
-            <span class="label">拍摄地点:</span>
+            <span class="label">开通时间:</span>
             <span class="value">-</span>
           </div>
           <div class="info-item">
-            <span class="label">计算完成:</span>
+            <span class="label">到期时间:</span>
             <span class="value">2024-08-27 21:12</span>
           </div>
           <div class="info-item">
-            <span class="label">计算时长:</span>
-            <span class="value">8h 30m 30s</span>
+            <span class="label">总价:</span>
+            <span class="value">{{
+              item.amount || item.price || item.totalAmount
+            }}</span>
           </div>
           <div class="info-item">
-            <span class="label">场景大小:</span>
-            <span class="value">8891MB</span>
+            <span class="label">支付方式:</span>
+            <span class="value">{{
+              $t(`mall.payTypeStr.${item.payType}`)
+            }}</span>
+          </div>
+          <div class="info-item">
+            <span class="label">订单状态:</span>
+            <span class="value">
+              <span v-if="getOrderStatusInfo(item).textKey" class="expreeNum">
+                {{ $t(getOrderStatusInfo(item).textKey) }}
+                <span v-if="getOrderStatusInfo(item).showExpress"
+                  >:{{ item.orderItems[0].expressNum }}</span
+                >
+              </span>
+            </span>
           </div>
         </div>
       </div>
@@ -53,7 +83,28 @@ import {
   DownloadOutlined,
   QuestionCircleOutlined,
 } from "@ant-design/icons-vue";
-import { ref } from "vue";
+import { ref, computed } from "vue";
+import { getOrderType, getStatus, orderGoodsInfo } from "./data";
+import  { partsData } from '../mall/data'
+import { useOrderStore } from "@/stores";
+const orderStore = useOrderStore();
+const item = computed(() => orderStore.getOrderItem);
+
+const getOrderStatusInfo = (item) => {
+  const status = getStatus(item);
+  const map = {
+    unpaid: { textKey: "", showExpress: false },
+    unshipped: { textKey: "mall.myOrders.unshipped", showExpress: false },
+    shipped: { textKey: "mall.myOrders.wlNum", showExpress: true },
+    finish: { textKey: "mall.myOrders.finish", showExpress: false },
+    partShipped: { textKey: "mall.myOrders.partShipped", showExpress: false },
+    received: { textKey: "mall.myOrders.received", showExpress: true },
+    expire: { textKey: "mall.myOrders.expire", showExpress: false },
+  };
+  return (
+    map[status] || { textKey: "mall.myOrders.hasCancal", showExpress: false }
+  );
+};
 
 // 进度条百分比(可以和后端状态联动)
 const progressPercent = ref(10);
@@ -98,31 +149,31 @@ const progressPercent = ref(10);
       margin: 0 0 20px;
       font-weight: 500;
     }
-    
-        .progress-wrap {
-          width: 100%; // 确保进度条占满父容器
-          display: flex;
-          align-items: center;
-          gap: 6px;
-          // margin-top: 10px; // 和产品名称保持统一间距
-          margin-bottom: 40px; // 和产品名称保持统一间距
-          font-weight: normal;
-          font-size: 12px;
-          color: #4E5969;
-          .progress-text {
-            font-size: 12px;
-            color: #666;
-            margin-right: 23px;
-          }
 
-          // 调整进度条样式,避免溢出
-          :deep(.ant-progress) {
-            width: 100%;
-          }
-          :deep(.ant-progress-outer) {
-            width: 100%;
-          }
-        }
+    .progress-wrap {
+      width: 100%; // 确保进度条占满父容器
+      display: flex;
+      align-items: center;
+      gap: 6px;
+      // margin-top: 10px; // 和产品名称保持统一间距
+      margin-bottom: 40px; // 和产品名称保持统一间距
+      font-weight: normal;
+      font-size: 12px;
+      color: #4e5969;
+      .progress-text {
+        font-size: 12px;
+        color: #666;
+        margin-right: 23px;
+      }
+
+      // 调整进度条样式,避免溢出
+      :deep(.ant-progress) {
+        width: 100%;
+      }
+      :deep(.ant-progress-outer) {
+        width: 100%;
+      }
+    }
     // 单个附加产物项 - 核心调整:Flex 布局垂直对齐 + 宽度分配
     .product-item-top {
       display: flex;
@@ -157,7 +208,6 @@ const progressPercent = ref(10);
             cursor: pointer;
           }
         }
-
       }
 
       .action-buttons {

+ 59 - 115
src/views/order/list.vue

@@ -1,8 +1,9 @@
 <script setup>
-import { ref, h, onMounted, createVNode } from "vue";
+import { ref, h, onMounted, createVNode, watch } from "vue";
 import { useRouter, useRoute } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { Modal } from "ant-design-vue";
+import { useOrderStore } from "@/stores";
 import {
   QuestionCircleOutlined,
   ExclamationCircleFilled,
@@ -12,7 +13,7 @@ import {
   EditOutlined,
 } from "@ant-design/icons-vue";
 import { sceneList } from "@/api/scene";
-import { incrementList } from "@/api/equity";
+import { allOrderList } from "@/api/equity";
 import BaseTable from "@/components/BaseTable/index.vue";
 import myModal from "@/components/myModal.vue";
 import invoiceModal from "./components/invoiceModal.vue";
@@ -24,21 +25,25 @@ import {
   getStatusImg,
   getStatusText,
   SceneTypePaths,
-  getCameraType,
+  getOrderType,
+  getStatus,
+  orderGoodsInfo,
 } from "./data";
 const { t } = useI18n();
 const router = useRouter();
+const useOrder = useOrderStore();
 const activeKey = ref("0");
 
 // 仅需维护这4个变量
 const baseTableRef = ref();
 const snKey = ref("");
-const cameraType = ref(null);
+const orderType = ref(0);
 const numKey = ref([]);
 const selectTable = ref([]);
 const invoice = ref({
   show: false,
-  name: "",
+  item: {},
+  invoice: {},
   data: {
     invoiceType: 'special',
     invoiceTypeName: '增值税专用发票',
@@ -52,6 +57,8 @@ const invoice = ref({
   }
 });
 const searchType = ref("name");
+const orderSn = ref(null);
+
 const open = ref(false);
 
 const loading = ref(false);
@@ -94,7 +101,7 @@ const handleCustomRow = (record, index) => {
 // 接口查询(只接收组件抛出来的分页参数)
 const getList = async ({ pageNum, pageSize }) => {
   loading.value = true;
-  const res = await incrementList({ pageNum, pageSize, sn: snKey.value });
+  const res = await allOrderList({ pageNum, pageSize, orderType: orderType.value, keyword: orderSn.value });
   console.log(res, "sceneList");
   tableList.value = res.list;
   total.value = res.total;
@@ -105,29 +112,10 @@ const getList = async ({ pageNum, pageSize }) => {
 const searchClick = () => {
   baseTableRef.value.resetPage();
 };
-const handleItem = (record, item) => {
-  switch (record.key) {
-    case "more":
-      router.push({ path: "/sceneomore/obj/" + item.num });
-      break;
-    case "collaborativeScene":
-      handleEditItem(record);
-      break;
-  }
-  console.log(record, "handleItem", record, item);
-};
 const handleSee = (record) => {
   let path = SceneTypePaths[record.sceneSource];
   window.open(path[0], "_blank");
 };
-const handleEditItem = (record) => {
-  if (record.tbType == 'f') {
-    // 修改文件夹
-    return handleRename(record);
-  }
-  let path = SceneTypePaths[record.sceneSource];
-  window.open(path[1], "_blank");
-};
 const hendleCooperation = () => {
   const isNorn = selectTable.value.some(
     (item) => item.status == 2 || item.status == 0
@@ -138,6 +126,7 @@ const hendleCooperation = () => {
       title: t("common.tip"),
       icon: createVNode(ExclamationCircleFilled),
       content: "计算中/已封存的场景无法协作",
+      centered: true,
       // okText: '确认',
       // cancelText: '取消',
       onOk: async () => {
@@ -158,41 +147,40 @@ const handleUploadSuccess = () => {
   console.log("上传成功的文件列表:", fileList);
 };
 const handleEquity = (record) => {
-  if (record.snCode) {
-    Modal.confirm({
-      title: t("common.tip"),
-      icon: createVNode(ExclamationCircleFilled),
-      content: "确定要解除该相机的权益吗?",
-      // okText: '确认',
-      // cancelText: '取消',
-      onOk: async () => {
-        // await userStore.logout()
-      },
-    });
-  } else {
-    handleRename(record);
-  }
+  useOrder.updataOrder(record)
+  router.push({ path: "/order/details/" + record.orderSn });
 };
-const handleRename = (record) => {
+const onRenameConfirm = (name) => {
+  invoice.value.name = name;
+};
+const hanleInvoice = (item) => {
+  if(item.invoice && item.invoice.type){//已开票
+    open.value = true;
+    invoice.value = {
+      item: item.invoice,
+    }
+  }else{
   invoice.value = {
     show: true,
-    name: record.name,
+    item: item,
   };
+  }
 };
-const onRenameConfirm = (name) => {
-  console.log("改名成功");
-  invoice.value.name = name;
-};
+
 onMounted(() => {
   // 初始化默认查询,不传分页,组件内部自动1页
-
   getList({ pageNum: 1, pageSize: 20 });
 });
+// 监听openClause变化
+watch([()=>orderType.value, ()=>orderSn.value], () => {
+  baseTableRef.value.resetPage();
+});
 </script>
 <template>
   <div class="scene-layout">
     <div class="routeName">订单管理</div>
     <div class="table w-full">
+      <!-- :customRow="handleCustomRow" -->
       <BaseTable
         :columns="activeKey == '0' ? equityColumns : downColumns"
         :table-data="tableList"
@@ -202,14 +190,12 @@ onMounted(() => {
         rowKey="num"
         default-page-size="20"
         @search="getList"
-        :customRow="handleCustomRow"
         ref="baseTableRef"
       >
         <!-- 顶部搜索插槽 -->
         <template #header>
           <div
             class="tableHeader flex justify-between items-center mb-5 w-full"
-            v-if="numKey.length == 0"
           >
             <div
               class="headerLeft flex justify-start items-center gap-x-4 position-relative"
@@ -217,8 +203,8 @@ onMounted(() => {
               <a-space >
                 <a-select
                   size="large"
-                  v-model:value="cameraType"
-                  :options="getCameraType"
+                  v-model:value="orderType"
+                  :options="getOrderType"
                   style="width: 200px"
                 />
               </a-space>
@@ -228,9 +214,10 @@ onMounted(() => {
                 style="width: auto; margin-right: 10px"
               >
                 <a-input
-                  v-model:value="value4"
+                  v-model:value="orderSn"
                   style="width: 400px; padding: 0 11px"
                   size="large"
+                  allowClear
                   placeholder="请输入商品名称或订单编号"
                 >
                   <template #prefix><SearchOutlined /></template>
@@ -238,84 +225,41 @@ onMounted(() => {
               </a-input-group>
             </div>
           </div>
-          <div
-            class="tableHeader flex justify-end items-center mb-5 w-full"
-            v-else
-          >
-            <a-space size="middle">
-              <a-button class="operation" size="small">
-                <i class="iconfont icon-menu_cooperation1"></i>
-              </a-button>
-              <a-button class="operation" size="small" @click="handleFolder">
-                <i class="iconfont icon-menu_move"></i>
-              </a-button>
-              <a-button
-                class="operation"
-                size="small"
-                danger
-                @click="handleEditItem(record)"
-              >
-                <i class="iconfont icon-list_delete"></i>
-              </a-button>
-            </a-space>
-          </div>
         </template>
         <!-- 操作列插槽 -->
-        <template #sceneName="{ record }">
-          <div
-            class="sceneName flex items-center cursor-pointer"
-            @click="handleSee(record)"
-          >
-            <div class="sceneleft w-32 h-22 relative rounded-lg">
-              <img
-                v-if="record.tbType == 'f'"
-                src="/src/assets/image/scene/folder.png"
-                alt=""
-                class="Folder w-32 h-22 mr-2"
-              />
-              <img
-                v-else
-                :src="getStatusImg(record)"
-                alt=""
-                class="w-32 h-22 rounded-lg mr-2"
-              />
-
-              <div
-                class="loading-hover absolute rounded-lg"
-                v-if="record.status != -2"
-              >
-                <span>{{ getStatusText(record) }}</span>
-              </div>
-            </div>
-            <div class="sceneNameTight ml-4">
-              <div class="sceneNameTitle text-base" style="color: #1d2129">
-                {{ record.name || record.sceneName }}
-              </div>
-              <div
-                class="sceneTime text-sm"
-                style="margin-top: 6px; color: #86909c"
-              >
-                更新于{{ record.createTime }}
-              </div>
-            </div>
+        <template #orderType>
+          <div>{{ getOrderType.find(item => item.value == orderType).label }}</div>
+        </template>
+        <template #content="{record}">
+          <div>
+              <span v-if="record.orderType == 0">{{$t(record.memberLevels === 'SE' ? 'member.Senior' : 'member.Premium')}}(4DKanKan)</span>
+              <span v-else-if="record.orderType == 1">
+                <p v-for="ele in record.orderItems" :key="ele.id">
+                  <span v-for="(element,index) in orderGoodsInfo(ele.goodsId)" :key="element.goodsId">{{ element }}
+                    <template v-if="index != 0">、</template>
+                  </span>
+                </p>
+              </span>
+              <span v-else>
+                {{ record.sceneName }}{{record.sceneNum && `(${record.sceneNum})`}}{{$t('mall.downdesc')}}
+              </span>
           </div>
         </template>
         <!-- 操作列插槽 -->
         <template #operate="{ record }">
           <a-space size="middle">
             <a-button class="operation" @click.stop="handleEquity(record)">
-              <i class="iconfont icon-ar"></i>
+              <i class="iconfont icon-common_toolbar_details"></i>
             </a-button>
-            <a-button class="operation" @click.stop="open = true">
-              <i class="iconfont icon-download"></i>
+            <a-button class="operation" :title="record.invoice && record.invoice.type ? $t('mall.myOrders.invoiceInfo') : $t('mall.myOrders.invoice')" @click.stop="hanleInvoice(record)">
+              <i class="iconfont icon-common_toolbar_invoice"></i>
             </a-button>
           </a-space>
         </template>
       </BaseTable>
     </div>
     <div class="moadl">
-      <invoiceModal v-model:visible="invoice.show" @confirm="onRenameConfirm" />
-
+      <invoiceModal v-model:visible="invoice.show" :item="invoice.item" @confirm="searchClick" />
       <myModal
         :open="open"
         style="height: 83%"
@@ -324,7 +268,7 @@ onMounted(() => {
         @cancel="open = false"
       >
         <InvoicePreview
-          :info="invoice.data || {
+          :info="invoice.item || {
             invoiceType: 'electronic',
             invoiceTypeName: '增值税普票(电子发票)',
             invoiceTitle: '珠海市四维时代网络科技有限公司',

+ 1 - 1
src/views/scene/components/cooperation.vue

@@ -1,7 +1,7 @@
 <template>
   <a-modal
     v-model:visible="visible"
-    title="协作场景"
+    :title="$t('scene.cooperaScene')"
     :footer="null"
     :width="600"
   >

Разница между файлами не показана из-за своего большого размера
+ 61 - 147
src/views/scene/components/uploadModal.vue


+ 176 - 54
src/views/scene/more.vue

@@ -1,7 +1,7 @@
 <template>
   <div class="project-detail-container">
     <!-- 标题 -->
-    <h2 class="project-title">项目名称文件夹</h2>
+    <h2 class="project-title">{{sceneInfo.title}}</h2>
     <div class="divider"></div>
 
     <div class="content-wrapper">
@@ -18,6 +18,7 @@
             <div class="action-buttons">
               <a-button
                 class="operation"
+                :disabled="downe57Info.buildE57Status == 2"
                 v-if="sceneInfo.location == 4 && !sceneInfo.isCopy"
                 @click="handleCreateE57"
               >
@@ -25,18 +26,28 @@
               </a-button>
               <a-button
                 class="operation"
-                :disabled="!e57Info.downloadUrl"
-                @click="downloadScene(e57Info.downloadUrl)"
+                :disabled="!downe57Info.downloadUrl"
+                @click="downloadSceneURL(downe57Info.downloadUrl)"
               >
                 <i class="iconfont icon-download"></i>
               </a-button>
             </div>
           </div>
           <div class="progress-wrap">
-            计算完成时间:{{ sceneInfo.algorithmTime }} 过期时间:2026.04.15
-            <a-tooltip title="提示说明">
-              <question-circle-outlined class="help-icon" />
-            </a-tooltip>
+            <template v-if="downe57Info.downloadUrl">
+              计算完成时间:{{ sceneInfo.algorithmTime }} 过期时间:2026.04.15
+            </template>
+
+            <template v-else-if="downe57Info.buildE57Status !== 0">
+              <span class="progress-text">{{downe57Info.downloadUrl?'已完成':'打包中'}}{{ downe57Info.progress }}%</span>
+              <a-progress
+                style="flex: 1; margin-bottom: 0; margin-inline-end: 0"
+                :percent="downe57Info.progress"
+                :show-info="false"
+                size="small"
+                status="active"
+              />
+            </template>
           </div>
         </div>
 
@@ -47,23 +58,25 @@
               <p class="product-name">离线包</p>
             </div>
             <div class="action-buttons">
-              <a-button class="operation">
+              <a-button class="operation" @click="handleDownScene" :disabled="downInfo.buildStatus == 2">
                 <i class="iconfont icon-common_toolbar_generate_model"></i>
               </a-button>
-              <a-button class="operation">
+              <a-button class="operation"
+                :disabled="!downInfo.downloadUrl"
+                @click="downloadSceneURL(downInfo.downloadUrl)">
                 <i class="iconfont icon-download"></i>
               </a-button>
             </div>
           </div>
-          <div class="progress-wrap">
-            <span class="progress-text">已完成10%</span>
-            <a-progress
-              style="flex: 1; margin-bottom: 0; margin-inline-end: 0"
-              :percent="progressPercent"
-              :show-info="false"
-              size="small"
-              status="active"
-            />
+          <div class="progress-wrap" v-if="downInfo.buildStatus !== 0">
+              <span class="progress-text">{{downInfo.downloadUrl?'已完成':'打包中'}}{{ downInfo.progress }}%</span>
+              <a-progress
+                style="flex: 1; margin-bottom: 0; margin-inline-end: 0"
+                :percent="downInfo.progress"
+                :show-info="false"
+                size="small"
+                status="active"
+              />
           </div>
         </div>
       </div>
@@ -131,70 +144,179 @@ import {
   QuestionCircleOutlined,
   ExclamationCircleFilled,
 } from "@ant-design/icons-vue";
-import { ref, computed, createVNode } from "vue";
+import { ref, computed, onUnmounted } from "vue";
+import { useI18n } from "vue-i18n";
 import { useRouter, useRoute } from "vue-router";
 import {
   autoFormatKbSplit,
   dayjsFormatHMS,
-  downloadScene,
+  downloadSceneURL,
 } from "@/utils/index.js";
-import { sceneDetail, checkDownLoadE57, downSceneE57 } from "@/api/scene";
+import {
+  sceneDetail,
+  checkDownLoadE57,
+  downSceneE57,
+  downloadProcessE57,
+  checkDownload,
+  downloadScene,
+  downloadProcess,
+} from "@/api/scene";
 import { Modal, message } from "ant-design-vue";
+import { useSceneStore } from "@/stores";
 import dayjs from "dayjs";
 // 初始化路由、store
+// 存储定时器ID
+let pollTimerE57 = null;
+let pollTimer = null;
 const router = useRouter();
+const { t } = useI18n();
 const route = useRoute();
-const { t } = useI18n()
-const { type, num } = route.params;
 const sceneInfo = ref({
   snCode: "",
   cameraType: "",
   shootCount: 0,
   space: 0,
 });
-const e57Info = ref({
+const { type, num } = route.params;
+const downe57Info = ref({
   progress: 0,
   buildE57Status: 0,
   downloadUrl: "",
   downloadStatus: 0,
 });
+const downInfo = ref({
+  progress: 0,
+  buildStatus: 0,
+  downloadUrl: "",
+  downloadStatus: 0,
+});
 // 进度条百分比(可以和后端状态联动)
 const progressPercent = ref(10);
+const useScene = useSceneStore();
 const getSceneInfo = async () => {
-  const info = await sceneDetail({ num, isObj: type === "mesh" ? 1 : 0 });
+  const info = await sceneDetail({ num, isObj: sceneInfo.value.isObj });
   sceneInfo.value = info;
+  handleCreateE57(true)
+  handleDownScene(true)
+};
+const handleCreateE57 = async (down) => {
+  const apiSrc = down ? downSceneE57 : checkDownLoadE57;
+  const res = await apiSrc({ num });
+  console.log("checkDownLoadE57", res);
+  downe57Info.value.downloadUrl = res.downloadUrl;
+  downe57Info.value.downloadStatus = res.downloadStatus;
+  if(down == true) return
+  if (res.downloadUrl) {
+    downloadSceneURL(res.downloadUrl);
+  } else if (res.downloadStatus === -1) {
+    Modal.confirm({
+      title: t("common.tip"),
+      icon: createVNode(ExclamationCircleFilled),
+      content: t("toast.400003"),
+      centered: true,
+      // okText: '确认',
+      // cancelText: '取消',
+    });
+  } else {
+    //生成
+    handleDownloadSceneeE57();
+  }
+};
+
+const handleDownloadSceneeE57 = async () => {
+    // 未完成,继续递归轮询
+    pollTimerE57 = setInterval(() => {
+      getDownloadE57Process();
+    }, 1000);
+  
 };
-const handleCreateE57 = async () => {
-  // const res = await checkDownLoadE57({ num });
-  // console.log("checkDownLoadE57", res);
-  // e57Info.value.downloadUrl = res.downloadUrl;
-  // e57Info.value.downloadStatus = res.downloadStatus;
-  // if (res.downloadUrl) {
-  //   downloadScene(res.downloadUrl);
-  // } else if (res.downloadStatus === -1) {
-  //   Modal.confirm({
-  //     title: t("common.tip"),
-  //     icon: createVNode(ExclamationCircleFilled),
-  //     content: t("toast.400003"),
-  //     // okText: '确认',
-  //     // cancelText: '取消',
-  //   });
-  // } else{//生成
-  //   handleDownloadScene();
-  // }
+const getDownloadE57Process = async () => {
+  let res = await downloadProcessE57({ num });
+  const { status, percent, url } = res;
+  if (status === 1003) {
+    return message.error(t("toast.400003"));
+  }
+  downe57Info.value.buildE57Status = 2;
+  downe57Info.value.progress = percent;
+  if (url) {
+    downe57Info.value.downloadUrl = url;
+    downe57Info.value.progress = 100;
+    downe57Info.value.buildE57Status = 1;
+    stopPollE57();
+    setTimeout(() => {
+      downloadSceneURL(url);
+    }, 1000);
+  }
+};
+const handleDownScene = async (down) => {
+  const apiSrc = down ? downloadScene : checkDownload;
+  const res = await apiSrc({ sceneNum:num, isTiles:true, isObj: sceneInfo.value.isObj});
+  console.log("checkDownLoadE57", res);
+  downInfo.value.downloadUrl = res.downloadUrl;
+  downInfo.value.downloadStatus = res.downloadStatus;
+  if(down == true && res.downloadStatus != 1) return
+  if (res.downloadUrl) {
+    downloadSceneURL(res.downloadUrl);
+  } else if (res.downloadStatus === -1) {
+    Modal.confirm({
+      title: t("common.tip"),
+      icon: createVNode(ExclamationCircleFilled),
+      content: t("toast.400003"),
+      centered: true,
+      // okText: '确认',
+      // cancelText: '取消',
+    });
+  } else if(res.downloadStatus == 1) {
+    //生成
+    handleDownloadScene();
+  } else {
+    //生成
+    handleDownloadScene();
+  }
+};
+const handleDownloadScene = async () => {
+    // 未完成,继续递归轮询
+    pollTimer = setInterval(() => {
+      getDownloadProcess();
+    }, 1000);
 };
 
-/**
- * 处理场景下载操作
- * 调用场景下载接口,根据返回状态码判断下载结果
- * - status 为 1003 时显示错误提示
- */
-//   let res = await downloadProcessE57({ num });
-//   const { status, percent, url } = res;
-//   if (status === 1003) {
-//     return message.error($t('toast.400003'));
-//   }
-// }
+const getDownloadProcess = async () => {
+  let res = await downloadProcess({ sceneNum: num, isObj: sceneInfo.value.isObj });
+  const { status, percent, url } = res;
+  if (status === 1003) {
+    return message.error(t("toast.400003"));
+  }
+  downInfo.value.buildStatus = 2;
+  downInfo.value.progress = percent;
+  if (url) {
+    downInfo.value.downloadUrl = url;
+    downInfo.value.progress = 100;
+    downInfo.value.buildStatus = 1;
+    stopPoll();
+    setTimeout(() => {
+      downloadSceneURL(url);
+    }, 1000);
+  }
+};
+// 停止轮询函数(外部可手动调用终止循环)
+const stopPollE57 = () => {
+  if (pollTimerE57) {
+    clearTimeout(pollTimerE57);
+    pollTimerE57 = null;
+  }
+};
+const stopPoll = () => {
+  if (pollTimer) {
+    clearTimeout(pollTimer);
+    pollTimer = null;
+  }
+};
+// 组件销毁时强制清除定时器,离开页面不再请求
+onUnmounted(() => {
+  stopPollE57();
+  stopPoll();
+});
 getSceneInfo();
 </script>
 

+ 16 - 9
src/views/scene/obj.vue

@@ -116,6 +116,7 @@ const getList = async ({ pageNum, pageSize, current }) => {
 
 // 点击搜索:重置页码到第1页再查询
 const searchClick = () => {
+  selectTable.value = [];
   baseTableRef.value.resetPage();
 };
 const handleItem = async (record, item) => {
@@ -127,10 +128,11 @@ const handleItem = async (record, item) => {
           title: t("common.tip"),
           icon: createVNode(ExclamationCircleFilled),
           content: t("scene.myScenes.copyTips"),
+          centered: true,
           // okText: '确认',
           // cancelText: '取消',
           onOk: async () => {
-            copyaApi(item.num);
+            copyaApi(item);
           },
         });
       } else {
@@ -138,6 +140,7 @@ const handleItem = async (record, item) => {
           title: t("common.tip"),
           icon: createVNode(ExclamationCircleFilled),
           content: t("scene.myScenes.copyErr"),
+          centered: true,
           // okText: '确认',
           // cancelText: '取消',
           onOk: async () => {
@@ -151,7 +154,7 @@ const handleItem = async (record, item) => {
       handleGenerate(item)
       break;
     case "more":
-      useScene.AddCart(item)
+      useScene.updataCart(item)
       router.push({ path: "/sceneomore/laser/" + item.num });
       break;
     case "collaborativeScene":
@@ -169,10 +172,11 @@ const handleGenerate = async (item) => {
           title: t("common.tip"),
           icon: createVNode(ExclamationCircleFilled),
           content: toastText,
+          centered: true,
           // okText: '确认',
           // cancelText: '取消',
           onOk: async () => {
-            generateApi(id)
+            generateApi(num)
           },
         });
       } else {
@@ -180,6 +184,7 @@ const handleGenerate = async (item) => {
           title: t("common.tip"),
           icon: createVNode(ExclamationCircleFilled),
           content: toastText,
+          centered: true,
           okText: t('manage.4DMega.moreText'),
           cancelText: t('manage.4DMega.confirmMoald'),
  
@@ -190,8 +195,9 @@ const handleSee = (record) => {
   let path = SceneTypePaths[record.sceneSource];
   window.open(path[0], "_blank");
 };
-const generateApi = async (id) => {
-  await buildSceneObj({id});
+const generateApi = async (num) => {
+  await buildSceneObj({num});
+  message.success(t("common.success"));
 };
 
 const handleEditItem = (record) => {
@@ -213,6 +219,7 @@ const hendleCooperation = (item) => {
         title: t("common.tip"),
         icon: createVNode(ExclamationCircleFilled),
         content: "计算中/已封存的场景无法协作",
+        centered: true,
         // okText: '确认',
         // cancelText: '取消',
         onOk: async () => {
@@ -241,12 +248,12 @@ const handleShowUpload = () => {
  * @param {*} item 待复制的文件项
  */
 const copyaApi = async (item) => {
-  await copyScene({ num: item.num });
+  await copyScene({ sceneNum: item.num });
   message.success(t("scene.myScenes.copySuccess"));
   searchClick()
 };
 const handleUploadSuccess = () => {
-  console.log("上传成功的文件列表:", fileList);
+  searchClick()
 };
 const handleRename = (record) => {
   reName.value = {
@@ -310,7 +317,7 @@ onMounted(() => {
         :total="total"
         :bordered="false"
         rowKey="id"
-        :row-selection="rowSelection"
+        :rowSelectionOptions="rowSelection"
         default-page-size="20"
         @search="getList"
         ref="baseTableRef"
@@ -560,7 +567,7 @@ onMounted(() => {
     </div>
     <div class="moadl">
       <cooperation ref="cooperationRef" />
-      <uploadModal ref="uploadModalRef" @upload-success="handleUploadSuccess" />
+      <uploadModal ref="uploadModalRef" @refresh="handleUploadSuccess" />
       <moveSceneModal ref="moveModalRef" @refresh="baseTableRef?.refresh" />
       <renameModal
         v-model:visible="reName.show"