chenlei 9 months ago
parent
commit
2709e07c1d
62 changed files with 2820 additions and 212 deletions
  1. 89 8
      packages/base/src/api/index.js
  2. 24 8
      packages/base/src/stores/epub.js
  3. 10 2
      packages/base/src/utils/index.js
  4. 6 2
      packages/base/src/utils/usePagination.js
  5. 1 0
      packages/mobile/.env.development
  6. 1 0
      packages/mobile/.env.production
  7. 326 0
      packages/mobile/public/three/PanoramaControls.js
  8. BIN
      packages/mobile/public/three/assets/0.jpg
  9. BIN
      packages/mobile/public/three/assets/1.jpg
  10. BIN
      packages/mobile/public/three/assets/10.jpg
  11. BIN
      packages/mobile/public/three/assets/11.jpg
  12. BIN
      packages/mobile/public/three/assets/12.jpg
  13. BIN
      packages/mobile/public/three/assets/13.jpg
  14. BIN
      packages/mobile/public/three/assets/14.jpg
  15. BIN
      packages/mobile/public/three/assets/2.jpg
  16. BIN
      packages/mobile/public/three/assets/3.jpg
  17. BIN
      packages/mobile/public/three/assets/4.jpg
  18. BIN
      packages/mobile/public/three/assets/5.jpg
  19. BIN
      packages/mobile/public/three/assets/6.jpg
  20. BIN
      packages/mobile/public/three/assets/7.jpg
  21. BIN
      packages/mobile/public/three/assets/8.jpg
  22. BIN
      packages/mobile/public/three/assets/9.jpg
  23. BIN
      packages/mobile/public/three/background.jpg
  24. 4 0
      packages/mobile/public/three/click.js
  25. 101 0
      packages/mobile/public/three/index.html
  26. 499 0
      packages/mobile/public/three/index.js
  27. 4 0
      packages/mobile/public/three/jquery-2.1.1.min.js
  28. 2 0
      packages/mobile/public/three/three.min.js
  29. 636 0
      packages/mobile/public/three/utils.js
  30. 1 0
      packages/mobile/src/components/SearchInput.vue
  31. 49 0
      packages/mobile/src/configure.js
  32. 3 2
      packages/mobile/src/main.js
  33. 8 0
      packages/mobile/src/stores/base.js
  34. 1 0
      packages/mobile/src/stores/index.js
  35. 22 2
      packages/mobile/src/views/Home/components/SearchPane.vue
  36. 9 0
      packages/mobile/src/views/Home/index.scss
  37. 81 0
      packages/mobile/src/views/Home/index.vue
  38. 62 5
      packages/mobile/src/views/Stack/components/ClassifyScroll.vue
  39. 29 32
      packages/mobile/src/views/Stack/index.vue
  40. 1 1
      packages/pc/.env.development
  41. 10 0
      packages/pc/src/App.vue
  42. 20 1
      packages/pc/src/components/BookCard/index.vue
  43. 5 4
      packages/pc/src/components/TopNav/components/LoginDialog.vue
  44. 6 3
      packages/pc/src/components/TopNav/index.scss
  45. 37 18
      packages/pc/src/components/TopNav/index.vue
  46. 5 0
      packages/pc/src/configure.js
  47. 14 1
      packages/pc/src/router/index.js
  48. 44 2
      packages/pc/src/stores/base.js
  49. 18 0
      packages/pc/src/stores/detail.js
  50. 10 0
      packages/pc/src/utils/index.js
  51. 112 24
      packages/pc/src/views/Bookshelf/components/UploadDialog.vue
  52. 126 0
      packages/pc/src/views/Bookshelf/components/UploadList.vue
  53. 125 26
      packages/pc/src/views/Bookshelf/index.vue
  54. 48 20
      packages/pc/src/views/Detail/components/Bookmark.vue
  55. 64 18
      packages/pc/src/views/Detail/components/Comment.vue
  56. 50 10
      packages/pc/src/views/Detail/components/Note.vue
  57. 74 9
      packages/pc/src/views/Detail/components/Reader/index.vue
  58. 4 1
      packages/pc/src/views/Detail/components/Toolbar/index.vue
  59. 18 8
      packages/pc/src/views/Detail/index.vue
  60. 5 0
      packages/pc/src/views/Home2/index.scss
  61. 55 4
      packages/pc/src/views/Home2/index.vue
  62. 1 1
      packages/pc/vite.config.js

+ 89 - 8
packages/base/src/api/index.js

@@ -15,6 +15,10 @@ export const getBookCountApi = () => {
   return requestByGet("/api/show/book/count");
 };
 
+// 图书-详情 需要登录
+export const getBookDetail2Api = (bookId) => {
+  return requestByGet(`/api/wx/book/detail/${bookId}`);
+};
 // 图书-详情
 export const getBookDetailApi = (id) => {
   return requestByGet(`/api/show/book/detail/${id}`);
@@ -42,24 +46,101 @@ export const addNoticeVisitApi = (noticeId) => {
 
 // 书籍分类
 export const getStorageTreeApi = () => {
-  return requestByGet("/api/show/storage/getTree");
+  return requestByGet("/api/show/storage/getTree", undefined, {
+    meta: {
+      showMobileLoading: true,
+    },
+  });
 };
 
 // 藏品分类
 export const getExhibitTypeListApi = (params) => {
-  return requestByPost("/api/show/exhibitType/getList", params);
+  return requestByPost("/api/show/exhibitType/getList", params, {
+    meta: {
+      showMobileLoading: true,
+    },
+  });
 };
 
 // 微信-登录
 export const wxLoginApi = (code) => {
-  return requestByGet(`/api/show/wx/login/${code}`);
+  return requestByGet(`/api/show/wx/pcLogin/${code}`);
+};
+export const fakeLoginApi = (id) => {
+  return requestByGet(`/api/show/test/login/${id}`);
 };
 
 // 图书-文件-上传
 export const uploadApi = (params) => {
-  return requestByPost("/api/wx/book/upload", params, {
-    headers: {
-      "content-type": "application/x-www-form-urlencoded",
-    },
-  });
+  return requestByPost("/api/wx/book/upload", params);
+};
+
+// 图书-我的书架
+export const getMyBookListApi = () => {
+  return requestByGet("/api/wx/book/getCollect");
+};
+
+// 编辑用户信息
+export const updateUserInfoApi = (params) => {
+  return requestByPost("/api/wx/updateWxUser", params);
+};
+
+// 用户信息
+export const getUserInfoApi = () => {
+  return requestByGet("/api/wx/userInfo");
+};
+
+// 图书-我的收藏总数
+export const getUserCollectedBookCountApi = () => {
+  return requestByGet("/api/wx/book/count");
+};
+
+// 保存收藏图书&保存浏览节点
+export const updateBookCollectApi = (bootId, query) => {
+  return requestByGet(`/api/wx/book/collect/${bootId}`, query);
+};
+
+// 退出登录
+export const logoutApi = () => {
+  return requestByGet("/api/wx/logout");
+};
+
+// 图书-上传记录
+export const getUploadBookListApi = () => {
+  return requestByGet("/api/wx/book/auditList");
+};
+
+// 编辑用户信息
+export const saveBookApi = (params) => {
+  return requestByPost("/api/wx/book/save", params);
+};
+
+// 图书-删除
+export const deleteBookApi = (id) => {
+  return requestByGet(`/api/wx/book/remove/${id}`);
+};
+
+// 书签|笔记-保存
+export const saveLabelApi = (params) => {
+  return requestByPost("/api/wx/label/save", params);
+};
+
+// 书签|笔记-列表
+export const getLabelListApi = (bookId, type) => {
+  return requestByGet(`/api/wx/label/getList/${bookId}/${type}`);
+};
+
+// 书签|笔记-列表
+export const deleteLabelApi = (id) => {
+  return requestByGet(`/api/wx/label/del/${id}`);
+};
+
+// 留言-我的列表
+export const getMessageListApi = (params) => {
+  return requestByPost("/api/show/message/pageList", params);
+};
+
+// 留言-保存
+export const saveMessageApi = (params) => {
+  return requestByPost("/api/wx/message/save", params);
 };

+ 24 - 8
packages/base/src/stores/epub.js

@@ -9,8 +9,17 @@ export const EPUB_FONTSIZE_KEY = "epub-fontsize";
 export const EPUB_SIMPLIFIED = "epub-simplified";
 export const EPUB_LOCATION = "epub-location";
 
+const getFileName = (path) => {
+  const regex = /([^/]+)\.[^.]+$/;
+  const match = path.match(regex);
+
+  if (match && match[1]) return match[1];
+  return "";
+};
+
 export const useEpubStore = defineStore("epub", () => {
   let bodyNode = null;
+  const fileName = ref("");
   const book = ref(null);
   /**
    * @see http://epubjs.org/documentation/0.3/#rendition
@@ -56,9 +65,13 @@ export const useEpubStore = defineStore("epub", () => {
    * })
    */
   const init = (options = {}) => {
+    fileName.value = getFileName(options.url);
+
     initEpub(options);
     initTheme(options.themes);
-    const locationStorage = localStorage.getItem(`${EPUB_LOCATION}-1`);
+    const locationStorage = localStorage.getItem(
+      `${EPUB_LOCATION}-${fileName.value}`
+    );
     if (locationStorage) {
       const arr = locationStorage.split("-");
 
@@ -83,7 +96,6 @@ export const useEpubStore = defineStore("epub", () => {
     });
 
     _book.loaded.metadata.then((res) => {
-      console.log("metadata: ", res);
       metadata.value = res;
     });
 
@@ -135,12 +147,16 @@ export const useEpubStore = defineStore("epub", () => {
     const startCfi = curLocation.start.cfi;
     const scrollLeft =
       document.getElementsByClassName("epub-container")[0]?.scrollLeft;
+    const page = scrollLeft / rendition.value._layout.pageWidth;
 
-    localStorage.setItem(`${EPUB_LOCATION}-1`, `${startCfi}-${scrollLeft}`);
+    localStorage.setItem(
+      `${EPUB_LOCATION}-${fileName.value}`,
+      `${startCfi}-${page}`
+    );
 
     return {
       startCfi,
-      scrollLeft,
+      page,
     };
   };
 
@@ -184,15 +200,15 @@ export const useEpubStore = defineStore("epub", () => {
   /**
    * 跳转目录
    * @param {String} cfi
-   * @param {String | Number} scrollLeft
+   * @param {String | Number} page
    */
-  const goToChapter = async (cfi, scrollLeft) => {
+  const goToChapter = async (cfi, page) => {
     await rendition.value.display(cfi);
 
-    if (scrollLeft) {
+    if (page) {
       nextTick(() => {
         document.getElementsByClassName("epub-container")[0]?.scrollTo({
-          left: Number(scrollLeft),
+          left: Number(page) * rendition.value._layout.pageWidth,
         });
         refreshLocation();
       });

+ 10 - 2
packages/base/src/utils/index.js

@@ -5,8 +5,16 @@ const baseUrl = getBaseURL();
 export const isDevelopment = import.meta.env.MODE === "development";
 
 export const getBaseUrl = () => {
-  return baseUrl;
-  // return `${baseUrl}${isDevelopment ? "/api" : ""}`;
+  return `${baseUrl}${isDevelopment ? "/api" : ""}`;
+};
+
+export const getFixUrl = (path) => {
+  const isHttp = path.startsWith("http");
+  if (isHttp) {
+    return path;
+  }
+  const base = getBaseUrl();
+  return base + path;
 };
 
 export * from "./usePagination";

+ 6 - 2
packages/base/src/utils/usePagination.js

@@ -15,9 +15,12 @@ export const usePagination = (
   const pageNum = ref(1);
   const total = ref(0);
   const loading = ref(false);
+  const inited = ref(false);
   const list = ref([]);
-  const noData = computed(() => !total.value && !loading.value);
-  const noMore = computed(() => size * pageNum.value > total.value);
+  const noData = computed(() => !total.value && !loading.value && inited.value);
+  const noMore = computed(
+    () => size * pageNum.value > total.value && inited.value
+  );
 
   const getList = async () => {
     try {
@@ -37,6 +40,7 @@ export const usePagination = (
       }
     } finally {
       loading.value = false;
+      inited.value = true;
     }
   };
 

+ 1 - 0
packages/mobile/.env.development

@@ -0,0 +1 @@
+VITE_BASE_URL=https://sit-liushaoqibwg.4dage.com

+ 1 - 0
packages/mobile/.env.production

@@ -0,0 +1 @@
+VITE_BASE_URL=https://sit-liushaoqibwg.4dage.com

+ 326 - 0
packages/mobile/public/three/PanoramaControls.js

@@ -0,0 +1,326 @@
+
+function PanoramaControls(camera, domElement) {
+
+  
+    // fyz 相机放大缩小
+    this.activationThreshold = 1.1;
+    this.scrollZoomSpeed = 0.001;
+    this.scrollZoomSta = true;
+    this.zoomMin = 0.7;
+    this.zoomMax = 1.5;
+    this.baseFov = 70;
+ 
+    this.camera = camera;
+    this.camera.fov = this.baseFov;
+     
+    this.domElement = domElement; 
+    this.camera.controls = this; 
+    this.enabled = true;
+
+    this.target = new THREE.Vector3(0, 0, 0);
+    this.lookVector = new THREE.Vector3;
+    this.aimFrom = this.camera.position;
+
+
+    this.lat = 0;
+    this.latMin = -60//-40;
+    this.latMax = 60//40;
+    this.lon = 0;
+    this.phi = 0;
+    this.theta = 0;
+    this.lookSpeed = 0.05;
+    this.rotationAcc = new THREE.Vector2;
+    this.rotationSpeed = new THREE.Vector2;
+    this.rotationHistory = [];
+    this.rotationDifference = new THREE.Vector2;
+
+
+    this.pointerDragOn = !1;
+    this.pointer = new THREE.Vector3(0, 0, -1);
+    this.pointerDragStart = new THREE.Vector3(0, 0, -1); 
+    this._wheel = 0;
+    this.zoomLevel = 1; 
+    this.translationWorldDelta = new THREE.Vector3
+    this.bindEvents()
+}
+
+PanoramaControls.prototype.bindEvents = function() {
+
+    window.addEventListener("mousemove", this.onMouseMove.bind(this));
+    this.domElement.addEventListener("mousedown", this.onMouseDown.bind(this));
+    window.addEventListener("mouseup", this.onMouseUp.bind(this));
+    this.domElement.addEventListener("mouseover", (event) => this.pointerDragOn && 0 === event.which && this.onMouseUp(event));
+
+    this.domElement.addEventListener("touchstart", this.onTouchStart.bind(this));
+    this.domElement.addEventListener("touchmove", this.onTouchMove.bind(this));
+    this.domElement.addEventListener("touchend", this.onTouchEnd.bind(this));
+
+    this.domElement.addEventListener("wheel", this.onMouseWheel.bind(this));   // fyz wheel事件代替mousewheel事件
+    this.domElement.addEventListener("DOMMouseScroll", this.onMouseWheel.bind(this));
+    this.domElement.addEventListener("contextmenu", (event) => event.preventDefault());
+
+    //document.addEventListener("keydown", this.onKeyDown.bind(this));
+    //document.addEventListener("keyup", this.onKeyUp.bind(this));
+
+}
+PanoramaControls.prototype.lookAt = function(point) {
+ 
+
+    var directionNegative = this.camera.position.clone().sub(point),
+        theta = Math.atan(directionNegative.z / directionNegative.x);
+
+    theta += directionNegative.x < 0 ? Math.PI : 0;
+    theta += directionNegative.x > 0 && directionNegative.z < 0 ? 2 * Math.PI : 0;
+    this.lon = THREE.Math.radToDeg(theta) + 180;
+
+    let projectorR = Math.sqrt(directionNegative.x * directionNegative.x + directionNegative.z * directionNegative.z),
+        phi = Math.atan(directionNegative.y / projectorR);
+
+    this.lat = -THREE.Math.radToDeg(phi)
+}
+PanoramaControls.prototype.startRotationFrom = function(screenX, screenY) {
+
+    this.updatePointer(screenX, screenY);
+    this.pointerDragOn = true;
+    this.pointerDragStart.copy(this.pointer);
+    //TODO
+    //this.pointerDragStartIntersect = this.player.getMouseIntersect(this.pointer.clone(), [this.scene.skybox]).point;
+    this.rotationHistory = [];
+    this.rotationSpeed.set(0, 0);
+}
+PanoramaControls.prototype.onTouchStart = function(event) {
+
+    if (this.enabled) {
+
+        event.preventDefault();
+        event.stopPropagation();
+        this.startRotationFrom(event.changedTouches[0].clientX, event.changedTouches[0].clientY);
+    }
+
+}
+PanoramaControls.prototype.onMouseDown = function(event) {
+
+    if (this.enabled) {
+
+        event.preventDefault();
+        event.stopPropagation()
+
+        switch (event.button) {
+
+            case 0:
+                this.startRotationFrom(event.clientX, event.clientY);
+                break;
+            case 2:
+
+        }
+    }
+}
+
+PanoramaControls.prototype.updateRotation = function( ) {
+
+
+    if (this.enabled && this.pointerDragOn) {
+
+        var pointerDragStart3D = new THREE.Vector3(this.pointerDragStart.x, this.pointerDragStart.y, 1).unproject(this.camera);
+        var pointer3D = new THREE.Vector3(this.pointer.x, this.pointer.y, 1).unproject(this.camera);
+
+            //两交互点分别到原点的长度
+        var pointerDragStart3DLength = Math.sqrt(pointerDragStart3D.x * pointerDragStart3D.x + pointerDragStart3D.z * pointerDragStart3D.z);
+        var pointer3DLength = Math.sqrt(pointer3D.x * pointer3D.x + pointer3D.z * pointer3D.z);
+
+            //通过Math.atan2计算在XY面上与X轴的夹角弧度。
+            //注:因为 z = -1,所以两者到原点的长度近似为x分量(数值的大小也不需要绝对对应)
+        var anglePointerDragStart3DToX = Math.atan2(pointerDragStart3D.y, pointerDragStart3DLength);       //近似为 anglePointerDragStart3DToX = Math.atan2( pointerDragStart3D.y, pointerDragStart3D.x ) 
+        var anglePointer3DToX = Math.atan2(pointer3D.y, pointer3DLength);                                 //近似为 anglePointer3DToX = Math.atan2( pointer3D.y, pointer3D.x )
+
+        //算出两者角度差,作为竖直方向角度差值(rotationDifference.y)
+        this.rotationDifference.y = THREE.Math.radToDeg(anglePointerDragStart3DToX - anglePointer3DToX);
+
+
+        //y分量清零,原向量等价于在XZ轴上的投影向量
+        pointerDragStart3D.y = 0;
+        pointer3D.y = 0;
+
+        //归一化(/length),求两者夹角作为
+        //判断方向,最后记为水平方向角度差值(rotationDifference.x)
+        var anglePointerDragStart3DToPointer3D = Math.acos(pointerDragStart3D.dot(pointer3D) / pointerDragStart3D.length() / pointer3D.length());
+
+        if (!isNaN(anglePointerDragStart3DToPointer3D)) {
+            this.rotationDifference.x = THREE.Math.radToDeg(anglePointerDragStart3DToPointer3D);
+            if (this.pointerDragStart.x < this.pointer.x) {
+                this.rotationDifference.x *= -1;
+            }
+        }
+
+        //更新pointerDragStart记录当前帧坐标,用于下一帧求帧差值
+        this.pointerDragStart.copy(this.pointer);
+    }
+}
+PanoramaControls.prototype.onMouseMove = function(event) {
+     
+    this.updatePointer(event.clientX, event.clientY);
+
+}
+PanoramaControls.prototype.onTouchMove = function(event) {
+
+    this.updatePointer(event.changedTouches[0].clientX, event.changedTouches[0].clientY)
+
+}
+
+PanoramaControls.prototype.updatePointer = function(screenX, screenY) {
+    this.pointer.x = screenX / this.domElement.clientWidth * 2 - 1;   // 屏幕坐标换算相对于canvas的父级
+    this.pointer.y = 2 * -(screenY / this.domElement.clientHeight) + 1;
+
+}
+PanoramaControls.prototype.endRotation = function() {
+
+
+    this.pointerDragOn = false;
+    try{
+        var rotationHistoryAverage = averageVectors(this.rotationHistory);
+    }catch(e){
+        console.error(e)
+    } 
+    
+    this.rotationSpeed.set(rotationHistoryAverage.x * 30, rotationHistoryAverage.y * 30);
+
+}
+PanoramaControls.prototype.onTouchEnd = function(event) {
+
+    if (this.enabled) {
+
+        event.preventDefault();
+        event.stopPropagation();
+        this.endRotation()
+    }
+}
+PanoramaControls.prototype.onMouseUp = function(event) {
+
+
+    if (this.enabled) {
+
+        event.preventDefault();
+        event.stopPropagation();
+        this.endRotation()
+    }
+}
+PanoramaControls.prototype.update = function(deltaTime) {
+
+    if (this.enabled) {
+
+        this.updateRotation();
+
+        for (this.rotationHistory.push(this.rotationDifference.clone()); this.rotationHistory.length > 5;) {
+
+            this.rotationHistory.shift();
+        }
+
+        this.lon += this.rotationDifference.x;
+        this.lat += this.rotationDifference.y;
+        this.rotationDifference.set(0, 0);
+        this.rotationSpeed.x = this.rotationSpeed.x * (1 - 0.05) + this.rotationAcc.x * 4.5;
+        this.rotationSpeed.y = this.rotationSpeed.y * (1 - 0.05) + this.rotationAcc.y * 4.5;
+
+        this.lon += this.rotationSpeed.x * deltaTime;
+        this.lat += this.rotationSpeed.y * deltaTime;
+
+      
+
+        this.lat = Math.max(this.latMin, Math.min(this.latMax, this.lat));
+        this.phi = THREE.Math.degToRad(90 - this.lat);
+        this.theta = THREE.Math.degToRad(this.lon);
+
+        this.lookVector.x = Math.sin(this.phi) * Math.cos(this.theta);
+        this.lookVector.y = Math.cos(this.phi);
+        this.lookVector.z = Math.sin(this.phi) * Math.sin(this.theta);
+
+
+        this.camera.position.add(this.translationWorldDelta) 
+        this.translationWorldDelta.multiplyScalar(0.9); 
+    
+        this.target.copy(this.lookVector).add(this.aimFrom);
+        this.camera.lookAt(this.target)
+        
+        
+        
+    }
+}
+PanoramaControls.prototype.onMouseWheel = function(event) {
+
+    /* if (this.enabled) {
+
+        // let z = void 0 !== event['wheelDelta'] ? event['wheelDelta'] : 0 !== event.detail && -event.detail;
+        // this.flyDirection(new THREE.Vector3(0, 0, -z).normalize());
+        this._wheel = Math.floor(event['wheelDeltaY'] / 120);
+        this._wheel = Math.abs(this._wheel) > 0.1 ? Math.sign(this._wheel) : 0;
+        if (this._wheel !== 0 && this.scrollZoomSta) {
+            this._wheel > 0 ? this._wheel = 1 + this.scrollZoomSpeed : this._wheel = 1 - this.scrollZoomSpeed;
+            let curZoomLevel = this._wheel * this.zoomLevel;
+            this.zoomTo(curZoomLevel);
+        }
+
+    } */
+    
+    let delta
+    if (event.wheelDelta !== undefined) { // WebKit / Opera / Explorer 9
+        delta = event.wheelDelta;
+    } else if (event.detail !== undefined) { // Firefox
+        delta = -event.detail;
+    }
+        
+    
+    
+    if(delta != void 0){//滚轮缩放 
+        if(delta == 0)return //mac
+        let direction = new THREE.Vector3(0,0,-1).applyQuaternion(this.camera.quaternion)
+        let moveSpeed = 0.03
+        if(delta < 0) moveSpeed *=-1
+        this.translationWorldDelta.add(direction.multiplyScalar(moveSpeed))
+    }
+    
+}
+PanoramaControls.prototype.zoomTo = function(curZoomLevel) {
+    curZoomLevel < this.zoomMin && (curZoomLevel = this.zoomMin);
+    curZoomLevel > this.zoomMax && (curZoomLevel = this.zoomMax);
+    this.zoomLevel = curZoomLevel;
+    this.camera.fov = this.baseFov*(1/this.zoomLevel);
+    this.camera.updateProjectionMatrix();
+} 
+
+/*   
+reset() {
+
+    this.stop()
+}
+
+stop() {
+
+    this.rotationAcc.set(0, 0);
+    this.rotationSpeed.set(0, 0);
+}
+*/
+
+
+
+
+//-------------copyFromPlayer
+PanoramaControls.prototype.handleControlScroll = function(e) {
+     e > 0 ? e = 1 + this.scrollZoomSpeed : e < 0 && (e = 1 - this.scrollZoomSpeed);
+    0 !== e && this.zoomBy(e)
+}
+PanoramaControls.prototype.zoomBy = function(e) {
+     this.zoomTo(this.zoomLevel * e);
+}
+
+
+
+function averageVectors(e, t) {
+    var i = new THREE.Vector3();
+    if (0 === e.length) return i;
+    for (var r = 0, o = 0; o < e.length; o++) {
+        var a = t ? e[o][t] : e[o];
+        i.add(a), r++;
+    }
+    return i.divideScalar(r);
+} 
+ 

BIN
packages/mobile/public/three/assets/0.jpg


BIN
packages/mobile/public/three/assets/1.jpg


BIN
packages/mobile/public/three/assets/10.jpg


BIN
packages/mobile/public/three/assets/11.jpg


BIN
packages/mobile/public/three/assets/12.jpg


BIN
packages/mobile/public/three/assets/13.jpg


BIN
packages/mobile/public/three/assets/14.jpg


BIN
packages/mobile/public/three/assets/2.jpg


BIN
packages/mobile/public/three/assets/3.jpg


BIN
packages/mobile/public/three/assets/4.jpg


BIN
packages/mobile/public/three/assets/5.jpg


BIN
packages/mobile/public/three/assets/6.jpg


BIN
packages/mobile/public/three/assets/7.jpg


BIN
packages/mobile/public/three/assets/8.jpg


BIN
packages/mobile/public/three/assets/9.jpg


BIN
packages/mobile/public/three/background.jpg


+ 4 - 0
packages/mobile/public/three/click.js

@@ -0,0 +1,4 @@
+// 继续动画 - 给 父页面调用
+window.stareMove = (val) => {
+  viewer.setAutoMove(val)
+}

+ 101 - 0
packages/mobile/public/three/index.html

@@ -0,0 +1,101 @@
+<!DOCTYPE html>
+<html lang="zh">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width,initial-scale=1.0" />
+    <title>刘少奇同志纪念馆</title>
+    <style>
+      #player,
+      body,
+      canvas {
+        width: 100%;
+        height: 100%;
+        position: absolute;
+        overflow: hidden;
+        margin: 0;
+        padding: 0;
+      }
+
+      /*canvas{
+          background-image: url(background.jpg); 
+          background-repeat: no-repeat;
+          background-position: center;
+          background-size: cover;
+      }*/
+      #consoleLog {
+        width: 120px;
+        height: 153px;
+        position: absolute;
+        left: 0px;
+        bottom: 160px;
+        z-index: 999999;
+        color: black;
+        opacity: 0.9;
+        font-size: 12px;
+      }
+    </style>
+  </head>
+
+  <body>
+    <div id="player">
+      <canvas></canvas>
+    </div>
+
+    <script type="text/javascript" src="./jquery-2.1.1.min.js"></script>
+    <script type="text/javascript" src="./three.min.js"></script>
+    <script>
+      let vfov = 60; //垂直视角范围度数
+      window.setting = {
+        vfov,
+        cards: {
+          far: 15,
+          beginFadeNear: 7,
+          near: 1,
+          fadeInDur: 3000,
+          highest: Math.tan(THREE.Math.degToRad(vfov / 2)), //当card在1米处时最高可以多少才能在视线内
+        },
+      };
+
+      function initViewer(cardNames) {
+        window.cardNames = cardNames;
+        var startTime = new Date().getTime();
+        window.viewer = new Viewer(0, $("#player")[0])
+
+        // 点击
+        viewer.addEventListener('clickObject', e => {
+          window.top.clickObject(e.imgName);
+          // 暂停动画
+          viewer.setAutoMove(false)
+        })
+
+        let flag = false
+
+        // 鼠标移入
+        viewer.addEventListener('hoverObject', e => {
+          // console.log('鼠标移入',e);
+          flag = true
+          window.top.hoverObject(e);
+        })
+
+        // 鼠标移出
+        viewer.addEventListener('mouseoutObject', e => {
+          // console.log('鼠标移出',e);
+          flag = false
+          window.top.mouseoutObject(e);
+        })
+
+        document.querySelector('#player').onmousemove = (event) => {
+          if (!flag) return
+          let e = event || window.event;
+          window.top.mouseLoc(e.clientX, e.clientY);
+        }
+      }
+    </script>
+
+    <script type="text/javascript" src="./utils.js"></script>
+    <script type="text/javascript" src="./PanoramaControls.js"></script>
+    <script type="text/javascript" src="./index.js"></script>
+    <script src="./click.js"></script>
+  </body>
+</html>

File diff suppressed because it is too large
+ 499 - 0
packages/mobile/public/three/index.js


File diff suppressed because it is too large
+ 4 - 0
packages/mobile/public/three/jquery-2.1.1.min.js


File diff suppressed because it is too large
+ 2 - 0
packages/mobile/public/three/three.min.js


+ 636 - 0
packages/mobile/public/three/utils.js

@@ -0,0 +1,636 @@
+var lerp = {
+	vector: function(e, t, f) {//xzw change, add f
+		var i = e.clone();
+		return t = t.clone(),
+		function(n) {
+			e.set(i.x * (1 - n) + t.x * n, i.y * (1 - n) + t.y * n, i.z * (1 - n) + t.z * n)
+			f && f(e,n);
+		}
+	},
+    quaternion: function(e, t, f) {//xzw change, add f
+        var i = e.clone();
+        return function(n) {
+            e.copy(i).slerp(t, n);
+			f && f(e,n);
+        }
+    },
+    property: function(e, t, i, n) {
+        var r = e[t];
+        return function(o) {
+            e[t] = r * (1 - o) + i * o,
+            n && n(e[t])
+        }
+    },
+    uniform: function(e, t, i) {
+        var n = e.material.uniforms[t].value;
+        return function(r) {
+            try{
+                e.material.uniforms[t] && (e.material.uniforms[t].value = n * (1 - r) + i * r)
+            }catch(e){
+                console.log(1)
+            }
+            
+        }
+    },
+    matrix4: function(e, t) {
+        var i = e.clone();
+        return function(n) {
+            for (var r = e.elements, o = i.elements, a = t.elements, s = 0; s < 16; s++)
+                r[s] = o[s] * (1 - n) + a[s] * n
+        }
+    },
+    allUniforms: function(e, t, i) {
+        var n = e.map(function(e) {
+            return this.uniform(e, t, i)
+        }
+        .bind(this));
+        return function(e) {
+            n.forEach(function(t) {
+                t(e)
+            })
+        }
+    }
+};
+
+
+//////
+ 
+var easing = {};
+//渐变曲线函数,反应加速度的变化
+easing.linearTween = function(e, t, i, n) {
+    return i * e / n + t
+}
+,
+easing.easeInQuad = function(e, t, i, n) {
+    return e /= n,
+    i * e * e + t
+}
+,
+easing.easeOutQuad = function(e, t, i, n) {
+    return e /= n,
+    -i * e * (e - 2) + t
+}
+,
+easing.easeInOutQuad = function(e, t, i, n) {
+    return e /= n / 2,
+    e < 1 ? i / 2 * e * e + t : (e--,
+    -i / 2 * (e * (e - 2) - 1) + t)
+}
+,
+easing.easeInCubic = function(e, t, i, n) {
+    return e /= n,
+    i * e * e * e + t
+}
+,
+easing.easeOutCubic = function(e, t, i, n) {
+    return e /= n,
+    e--,
+    i * (e * e * e + 1) + t
+}
+,
+easing.easeInOutCubic = function(e, t, i, n) {
+    return e /= n / 2,
+    e < 1 ? i / 2 * e * e * e + t : (e -= 2,
+    i / 2 * (e * e * e + 2) + t)
+}
+,
+easing.easeInQuart = function(e, t, i, n) {
+    return e /= n,
+    i * e * e * e * e + t
+}
+,
+easing.easeOutQuart = function(e, t, i, n) {
+    return e /= n,
+    e--,
+    -i * (e * e * e * e - 1) + t
+}
+,
+easing.easeInOutQuart = function(e, t, i, n) {
+    return e /= n / 2,
+    e < 1 ? i / 2 * e * e * e * e + t : (e -= 2,
+    -i / 2 * (e * e * e * e - 2) + t)
+}
+,
+easing.easeInQuint = function(e, t, i, n) {
+    return e /= n,
+    i * e * e * e * e * e + t
+}
+,
+easing.easeOutQuint = function(e, t, i, n) {
+    return e /= n,
+    e--,
+    i * (e * e * e * e * e + 1) + t
+}
+,
+easing.easeInOutQuint = function(e, t, i, n) {
+    return e /= n / 2,
+    e < 1 ? i / 2 * e * e * e * e * e + t : (e -= 2,
+    i / 2 * (e * e * e * e * e + 2) + t)
+}
+,
+easing.easeInSine = function(e, t, i, n) {
+    return -i * Math.cos(e / n * (Math.PI / 2)) + i + t
+}
+,
+easing.easeOutSine = function(e, t, i, n) {
+    return i * Math.sin(e / n * (Math.PI / 2)) + t
+}
+,
+easing.easeInOutSine = function(e, t, i, n) {
+    return -i / 2 * (Math.cos(Math.PI * e / n) - 1) + t
+}
+,
+easing.easeInExpo = function(e, t, i, n) {
+    return i * Math.pow(2, 10 * (e / n - 1)) + t
+}
+,
+easing.easeOutExpo = function(e, t, i, n) {
+    return i * (-Math.pow(2, -10 * e / n) + 1) + t
+}
+,
+easing.easeInOutExpo = function(e, t, i, n) {
+    return e /= n / 2,
+    e < 1 ? i / 2 * Math.pow(2, 10 * (e - 1)) + t : (e--,
+    i / 2 * (-Math.pow(2, -10 * e) + 2) + t)
+}
+,
+easing.easeInCirc = function(e, t, i, n) {
+    return e /= n,
+    -i * (Math.sqrt(1 - e * e) - 1) + t
+}
+,
+easing.easeOutCirc = function(e, t, i, n) {
+    return e /= n,
+    e--,
+    i * Math.sqrt(1 - e * e) + t
+}
+,
+easing.easeInOutCirc = function(e, t, i, n) {
+    return e /= n / 2,
+    e < 1 ? -i / 2 * (Math.sqrt(1 - e * e) - 1) + t : (e -= 2,
+    i / 2 * (Math.sqrt(1 - e * e) + 1) + t)
+}
+,
+easing.easeInElastic = function(e, t, i, n) {
+    var r = 1.70158
+      , o = 0
+      , a = i;
+    return 0 === e ? t : 1 === (e /= n) ? t + i : (o || (o = .3 * n),
+    a < Math.abs(i) ? (a = i,
+    r = o / 4) : r = o / (2 * Math.PI) * Math.asin(i / a),
+    -(a * Math.pow(2, 10 * (e -= 1)) * Math.sin((e * n - r) * (2 * Math.PI) / o)) + t)
+}
+,
+easing.easeOutElastic = function(e, t, i, n) {
+    var r = 1.70158
+      , o = 0
+      , a = i;
+    return 0 === e ? t : 1 === (e /= n) ? t + i : (o || (o = .3 * n),
+    a < Math.abs(i) ? (a = i,
+    r = o / 4) : r = o / (2 * Math.PI) * Math.asin(i / a),
+    a * Math.pow(2, -10 * e) * Math.sin((e * n - r) * (2 * Math.PI) / o) + i + t)
+}
+,
+easing.easeInOutElastic = function(e, t, i, n) {
+    var r = 1.70158
+      , o = 0
+      , a = i;
+    return 0 === e ? t : 2 === (e /= n / 2) ? t + i : (o || (o = n * (.3 * 1.5)),
+    a < Math.abs(i) ? (a = i,
+    r = o / 4) : r = o / (2 * Math.PI) * Math.asin(i / a),
+    e < 1 ? -.5 * (a * Math.pow(2, 10 * (e -= 1)) * Math.sin((e * n - r) * (2 * Math.PI) / o)) + t : a * Math.pow(2, -10 * (e -= 1)) * Math.sin((e * n - r) * (2 * Math.PI) / o) * .5 + i + t)
+}
+,
+easing.easeInBack = function(e, t, i, n, r) {
+    return void 0 === r && (r = 1.70158),
+    i * (e /= n) * e * ((r + 1) * e - r) + t
+}
+,
+easing.easeOutBack = function(e, t, i, n, r) {
+    return void 0 === r && (r = 1.70158),
+    i * ((e = e / n - 1) * e * ((r + 1) * e + r) + 1) + t
+}
+,
+easing.easeInOutBack = function(e, t, i, n, r) {
+    return void 0 === r && (r = 1.70158),
+    (e /= n / 2) < 1 ? i / 2 * (e * e * (((r *= 1.525) + 1) * e - r)) + t : i / 2 * ((e -= 2) * e * (((r *= 1.525) + 1) * e + r) + 2) + t
+}
+,
+easing.easeOutBounce = function(e, t, i, n) {
+    return (e /= n) < 1 / 2.75 ? i * (7.5625 * e * e) + t : e < 2 / 2.75 ? i * (7.5625 * (e -= 1.5 / 2.75) * e + .75) + t : e < 2.5 / 2.75 ? i * (7.5625 * (e -= 2.25 / 2.75) * e + .9375) + t : i * (7.5625 * (e -= 2.625 / 2.75) * e + .984375) + t
+}
+,
+easing.easeInBounce = function(e, t, i, r) {
+    return i - easing.easeOutBounce(r - e, 0, i, r) + t
+}
+,
+easing.easeInOutBounce = function(e, t, i, r) {
+    return e < r / 2 ? .5 * easing.easeInBounce(2 * e, 0, i, r) + t : .5 * easing.easeOutBounce(x, 2 * e - r, 0, i, r) + .5 * i + t
+}
+
+ 
+ 
+ 
+ 
+/* 
+    渐变
+    
+
+ */
+
+var transitions = {
+    globalDone: null,
+    funcs: [],
+    counter: 0,
+    uniqueID: 0,
+    start: function(e, t, i, r, o, a, s, cancelFun) {
+        r = r || 0 
+        let info = {
+            func: e,
+            current: -r * Math.abs(t),                      //当前时间
+            duration: (1 - Math.max(r, 0)) * Math.abs(t),   //总时长
+            done: i,
+            easing: o || easing.linearTween,                //渐变曲线
+            cycling: t < 0,
+            running: !0,
+            debug: r < 0,
+            name: a || "T" + this.counter,
+            id: void 0 === s ? this.counter : s,
+            paused: !1,
+			cancelFun : cancelFun,   //取消时执行的函数
+        }  
+        this.funcs.push(info),
+        e(0, 16),
+        this.counter += 1 
+        return info
+    },
+    trigger: function(e) {
+        var t = void 0 === e.delayRatio ? 0 : e.delayRatio
+            , i = e.func || function() {}
+            , r = void 0 === e.duration ? 0 : e.duration;
+        void 0 !== e.cycling && e.cycling && (r = -Math.abs(r));
+        var o = e.done || null
+            , a = e.easing || easing.linearTween
+            , s = e.name || "R" + this.counter
+            , l = void 0 === e.id ? this.counter : e.id;
+        return this.start(i, r, o, t, a, s, l)
+    },
+    setTimeout: function(e, t, i) {
+        var n = void 0 === i ? this.counter : i;
+        return this.trigger({
+            done: e,
+            duration: void 0 === t ? 0 : t,
+            name: "O" + this.counter,
+            id: n
+        })
+    },
+    pause: function() {
+        this.paused = !0
+    },
+    resume: function() {
+        this.paused = !1
+    },
+    update: function(e) {
+        this.funcs.forEach(function(t) {
+            if (!(t.paused || (t.current += 1e3 * e, t.current < 0))){ 
+                if (t.current >= t.duration && !t.cycling) {
+                    var i = t.easing(1, 0, 1, 1);
+                    t.func(i, 1e3 * e),
+                    t.done && t.done(),
+                    t.running = !1 
+                } else {
+                    var n = t.easing(t.current % t.duration / t.duration, 0, 1, 1)
+                        , r = t.func(n, 1e3 * e) || !1;
+                    r && (t.done && t.done(),
+                    t.running = !1)
+                }
+            }
+        });
+        var t = this.funcs.length;
+        this.funcs = this.funcs.filter(function(e) {
+            return e.running
+        }); 
+        var i = this.funcs.length;
+        if (t > 0 && 0 === i && this.globalDone) {
+            var n = this.globalDone;
+            this.globalDone = null,
+            n()
+        }
+    },
+    adjustSpeed: function(e, t) {
+        for (var i = this.getById(e), n = 0; n < i.length; n++) {
+            var r = i[n];
+            r.duration /= t,
+            r.current /= t
+        }
+    },
+    getById: function(e) {
+        return this.funcs.filter(function(t) {
+            return e === t.id
+        })
+    },
+    get: function(e) {
+        for (var t = 0; t < this.funcs.length; t += 1)
+            if (this.funcs[t].func === e)
+                return this.funcs[t];
+        return null
+    },
+    isRunning: function(e) {
+        var t = this.get(e);
+        return null !== t && t.running
+    },
+    countActive: function() {
+        for (var e = 0, t = 0; t < this.funcs.length; t += 1)
+            e += this.funcs[t].running;
+        return e
+    },
+    listActive: function() {
+        for (var e = [], t = 0; t < this.funcs.length; t += 1)
+            this.funcs[t].running && e.push(this.funcs[t].name);
+        return e
+    },
+    done: function(e) {
+        this.globalDone = e
+    },
+    cancelById: function(e, dealCancelFun) { //xzw add dealDone
+        var t = void 0 === e ? 0 : e;
+		 
+        this.funcs = this.funcs.filter(function(e) {
+			var is = e.id == t;
+			
+			if(is && dealCancelFun){
+				e.cancelFun && e.cancelFun()
+			} 
+            return !is
+        })
+    },
+    cancel: function(e) {
+        this.funcs = this.funcs.filter(function(t) {
+            return t.func !== e
+        }) 
+        
+    },
+    getUniqueId: function() {
+        return this.uniqueID -= 1,
+        this.uniqueID
+    }
+};
+
+ 
+let convertTool = {
+    
+	getPos2d : function(point, camera, dom){//获取一个三维坐标对应屏幕中的二维坐标
+	  
+        
+        
+        if(!camera)return
+		var pos = point.clone().project(camera)	//比之前hotspot的计算方式写得简单  project用于3转2(求法同shader); unproject用于2转3 :new r.Vector3(e.x, e.y, -1).unproject(this.camera);
+		
+		var x,y;
+		x = (pos.x + 1) / 2 * dom.clientWidth;
+		y = (1 - (pos.y + 1) / 2) * dom.clientHeight; 
+  
+		var inSight = x <= dom.clientWidth &&  x >= 0    //是否在屏幕中   
+					&& y <= dom.clientHeight &&  y >= 0 
+	 
+	
+		return {
+			pos: new THREE.Vector2(x,y),  // 屏幕像素坐标
+			vector:  pos,   //(范围 -1 ~ 1)
+			trueSide : pos.z<1, //trueSide为false时,即使在屏幕范围内可见,也是反方向的另一个不可以被渲染的点   参见Tag.update
+			inSight : inSight	//在屏幕范围内可见
+		};
+	},
+
+	ifShelter: function (pos3d,  pos2d,  camera, colliders, margin=0  ) {
+        //检测某点在视线中是否被mesh遮挡
+        if (!pos2d) pos2d = convertTool.getPos2d(pos3d )
+        camera = camera || player.camera
+        var ori = new THREE.Vector3(pos2d.x, pos2d.y, -1).unproject(camera) //找到视线原点
+        var dir = pos3d.clone().sub(ori).normalize()
+        var ray = new THREE.Raycaster(ori, dir); //由外向里 因为模型从内侧是可见的所以从外侧
+        var o = ray.intersectObjects(colliders); 
+	 
+		var len = pos3d.distanceTo(ori);
+		if (o && o.length) {
+			for(var i=0;i<o.length;i++){
+				if(o[i].distance < len-margin){  return true;  }//有遮挡
+			} 
+		} 
+	},
+    
+    updateVisible : function(object, reason, ifShow, level=0, type){//当所有加入的条件都不为false时才显示. reason='force'一般是强制、临时的
+        if(!object.unvisibleReasons) object.unvisibleReasons = []; //如果length>0代表不可见
+        if(!object.visibleReasons) object.visibleReasons = []; //在同级时,优先可见
+        
+        
+        var update = function(){
+            
+            //先按从高到低的level排列
+            object.unvisibleReasons = object.unvisibleReasons.sort((a,b)=>b.level-a.level)
+            object.visibleReasons = object.visibleReasons.sort((a,b)=>b.level-a.level)
+            var maxVisiLevel = object.visibleReasons[0] ? object.visibleReasons[0].level : -1
+            var maxunVisiLevel = object.unvisibleReasons[0] ? object.unvisibleReasons[0].level : -1
+            
+            var shouldVisi = maxVisiLevel >= maxunVisiLevel
+            var visiBefore = object.visible
+            
+            
+            if(visiBefore != shouldVisi){
+                object.visible = shouldVisi
+                object.dispatchEvent({
+                    type: 'isVisible',
+                    visible: shouldVisi,
+                    reason,
+                }) 
+            }
+            
+            
+        }    
+        
+        
+        
+        if(ifShow){ 
+
+            var index = object.unvisibleReasons.findIndex(e=>e.reason == reason) 
+            if(index > -1){
+                type = 'cancel'
+                object.unvisibleReasons.splice(index, 1); 
+            }
+            
+            if(type == 'add' ){
+                if(!object.visibleReasons.some(e=>e.reason == reason)){
+                    object.visibleReasons.push({reason,level})
+                }
+            } 
+        }else{ 
+            var index = object.visibleReasons.findIndex(e=>e.reason == reason) 
+            if(index > -1){
+                type = 'cancel'
+                object.visibleReasons.splice(index, 1); 
+            }
+            
+            if(type != 'cancel' ){
+                if(!object.unvisibleReasons.some(e=>e.reason == reason)){
+                    object.unvisibleReasons.push({reason,level})
+                }
+            }
+        }
+          
+        update() 
+        
+    }, 
+     
+
+    toPrecision: function (e, t) {//xzw change 保留小数
+		var f = function (e, t) {
+			var i = Math.pow(10, t);
+			return Math.round(e * i) / i
+		}
+		if (e instanceof Array) {
+			for (var s = 0; s < e.length; s++) {
+				e[s] = f(e[s], t);
+			}
+			return e;
+		} else if (e instanceof Object) {
+			for (var s in e) {
+				e[s] = f(e[s], t);
+			}
+			return e;
+		} else return f(e, t)
+	},
+
+    intervalTool:{  //延时update,防止卡顿
+        list:[],
+        
+        isWaiting:function(name, func, delayTime){
+            if(!this.list.includes(name)){  //如果没有该项, 则开始判断
+                var needWait = func(); //触发了改变,则等待一段时间后再自动判断
+                if(needWait){
+                    this.list.push(name);
+                    setTimeout(()=>{
+                        var a = this.list.indexOf(name);
+                        this.list.splice(a,1);
+                        this.isWaiting(name, func, delayTime) //循环
+                    },delayTime)
+                } 
+            }
+        }, 
+    }
+    ,
+
+}
+ 
+
+
+let math = {
+    closeTo : function(a,b, precision=1e-6){ 
+        let f = (a,b)=>{
+            return Math.abs(a-b) < precision;
+        }; 
+          
+        if(typeof (a) == 'number'){
+            return f(a, b);
+        }else {
+            let judge = (name)=>{
+                if(a[name] == void 0)return true //有值就判断,没值就不判断
+                else return f(a[name],b[name])
+            };
+            return judge('x') && judge('y') && judge('z') && judge('w')  
+        } 
+        
+    }, 
+    linearClamp(value, xArr , yArr){ //xArr需要按顺序从小到大,yArr对应xArr中的值
+	        
+	        let len = xArr.length; 
+	        if(value <= xArr[0]) return yArr[0]
+	        if(value >= xArr[len - 1]) return yArr[len - 1]
+	        let i = 0; 
+	        
+	        while(++i < len ){
+	            if(value < xArr[i]){
+	                let x1 = xArr[i-1], x2 = xArr[i], y1 = yArr[i-1], y2 = yArr[i]; 
+	                value = y1 + ( y2 - y1) * (value - x1)  / (x2 - x1);  
+	                break
+	            }
+	        }
+	        return value
+	        
+	         
+	    } 
+
+
+}
+var cameraLight = {
+    clampVFOV: function(currentFov, maxHFov, width, height) {//限制currentFov, 使之造成的横向fov不大于指定值maxHFov
+        var r = cameraLight.getHFOVFromVFOV(currentFov, width, height);
+        return r > maxHFov ? cameraLight.getVFOVFromHFOV(maxHFov, width, height) : currentFov
+    },
+    getHFOVForCamera: function(camera,  getRad) {
+        return cameraLight.getHFOVByScreenPrecent(camera.fov, camera.aspect, getRad)
+    }, 
+    //add
+    getHFOVByScreenPrecent: function(fov, percent, getRad) { //当fov为占比百分百时,percent代表在屏幕上从中心到边缘的占比
+        let rad = 2 * Math.atan(percent * Math.tan(THREE.Math.degToRad(fov * 2)));
+        if(getRad)return rad 
+        else return rad * MathLight.DEGREES_PER_RADIAN;
+    }
+};
+
+
+let texLoader = new THREE.TextureLoader; 
+let texs = new Map
+let common = {
+    urlHasValue(key, isGetValue) { 
+        let querys = window.location.search.substr(1).split('?')
+        if (isGetValue) {
+            for (let i = 0; i < querys.length; i++) {
+                let keypair = querys[i].split('=')
+                if (keypair.length === 2 && keypair[0] === key) {
+                    return keypair[1]
+                }
+            }
+            return ''
+        } else {
+            //return window.location.search.match("&" + key + "|\\?" + key) != null  有bug
+            for (let i = 0; i < querys.length; i++) {
+                let keypair = querys[i].split('=')
+                if(keypair[0] == key){
+                    return true
+                }
+            }
+            return false
+        }
+    }, 
+
+
+    dataURLtoBlob(dataurl) {//将base64转换blob
+        var arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
+            bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
+        while (n--) {
+            u8arr[n] = bstr.charCodeAt(n);
+        }
+        return new Blob([u8arr], { type: mime });
+    },
+     
+    loadTexture(src,done){
+        let o = texs.get(src)
+        if(o){
+            if(o.tex.image) done && done(o.tex)//加载完毕
+            else{
+                o.callbacks.push(done)//等待加载
+            }
+            return 
+        }
+        
+        
+        
+        let callbacks = []
+        let tex = texLoader.load(src,(tex)=>{
+            callbacks.forEach(done=>done(tex)) 
+        }) 
+        done && callbacks.push(done)
+        texs.set(src,{tex,callbacks})
+        return tex
+    }
+}

+ 1 - 0
packages/mobile/src/components/SearchInput.vue

@@ -2,6 +2,7 @@
   <div class="search-input" :class="{ simple }">
     <input
       :value="modelValue"
+      type="search"
       placeholder="请输入关键词..."
       @input="emits('update:modelValue', $event.target.value)"
     />

+ 49 - 0
packages/mobile/src/configure.js

@@ -0,0 +1,49 @@
+import { compose, initial } from "@dage/service";
+import { showNotify, showLoadingToast } from "vant";
+import "vant/lib/notify/style/index";
+import "vant/lib/toast/style/index";
+import router from "./router";
+
+const showMessage = (msg, type = "danger") => {
+  showNotify({
+    type,
+    message: msg,
+    duration: 4000,
+  });
+};
+
+initial({
+  fetch: window.fetch.bind(window),
+  baseURL: import.meta.env.VITE_BASE_URL,
+  interceptor: compose(async (request, next) => {
+    let toast = null;
+    try {
+      const { showError = true, showMobileLoading } = request.meta;
+      if (showMobileLoading) {
+        toast = showLoadingToast({
+          duration: 0,
+          message: "加载中...",
+          forbidClick: true,
+        });
+      }
+      const response = await next();
+
+      if (response.code !== 0 && showError) {
+        if ([2404, 2001].includes(response.code)) {
+          router.replace({ name: "home" });
+
+          return response;
+        }
+
+        const message = response.__raw__.data.msg ?? "请稍后重试";
+        showMessage(message);
+      }
+
+      return response;
+    } catch (err) {
+      showMessage("请稍后重试");
+    } finally {
+      toast?.close();
+    }
+  }),
+});

+ 3 - 2
packages/mobile/src/main.js

@@ -3,6 +3,7 @@ import "swiper/css";
 import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
 import "./assets/main.css";
 import "virtual:svg-icons-register";
+import "./configure";
 
 import { createApp } from "vue";
 import { createPinia } from "pinia";
@@ -13,8 +14,8 @@ import router from "./router";
 import SvgIcon from "./components/SvgIcon";
 import { Lazyload } from "vant";
 
-import VConsole from "vconsole";
-const vConsole = new VConsole();
+// import VConsole from "vconsole";
+// const vConsole = new VConsole();
 
 const app = createApp(App);
 

+ 8 - 0
packages/mobile/src/stores/base.js

@@ -0,0 +1,8 @@
+import { ref } from "vue";
+import { defineStore } from "pinia";
+
+export const useBaseStore = defineStore("base", () => {
+  const isLogin = ref(false);
+
+  return { isLogin };
+});

+ 1 - 0
packages/mobile/src/stores/index.js

@@ -1,3 +1,4 @@
 export * from "@lsq/base/src/stores";
 export * from "./detail";
 export * from "./setting";
+export * from "./base";

+ 22 - 2
packages/mobile/src/views/Home/components/SearchPane.vue

@@ -3,10 +3,13 @@
     <div class="search-pane-main">
       <img class="logo" src="@/assets/images/logo2@2x-min.png" />
 
-      <search-input />
+      <search-input
+        v-model="keyword"
+        @search="$router.push({ name: 'search', query: { keyword } })"
+      />
 
       <div class="search-pane-more">
-        <span class="limit-line">共收录132件藏品,查看书库</span>
+        <span class="limit-line">共收录{{ total }}件藏品,查看书库</span>
       </div>
     </div>
 
@@ -18,12 +21,29 @@
 </template>
 
 <script setup>
+import { ref, onMounted } from "vue";
+import { getBookCountApi } from "@lsq/base";
 import SearchInput from "../../../components/SearchInput.vue";
+
+const total = ref("--");
+const keyword = ref("");
+
+const getBookCount = async () => {
+  const data = await getBookCountApi();
+  total.value = data;
+};
+
+onMounted(() => {
+  getBookCount();
+});
 </script>
 
 <style lang="scss" scoped>
 .search-pane {
+  position: relative;
   padding: 0 55px;
+  height: 100%;
+  z-index: 99;
 
   &-main {
     position: relative;

+ 9 - 0
packages/mobile/src/views/Home/index.scss

@@ -1,4 +1,13 @@
 .home {
   height: 100vh;
   background: url("@/assets/images/bg@2x-min.png") no-repeat top center / cover;
+
+  &-iframe {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    z-index: 1;
+  }
 }

+ 81 - 0
packages/mobile/src/views/Home/index.vue

@@ -1,11 +1,92 @@
 <template>
   <div class="home">
     <search-pane />
+
+    <iframe
+      id="iframe"
+      class="home-iframe"
+      src="./three/index.html"
+      frameborder="0"
+    ></iframe>
   </div>
 </template>
 
 <script setup>
+import { onMounted, ref } from "vue";
+import { getRecommendListApi, getBaseUrl } from "@lsq/base";
+import { useRouter } from "vue-router";
 import SearchPane from "./components/SearchPane.vue";
+
+const router = useRouter();
+const txt = ref({ show: false });
+const txtDom = ref(null);
+const baseUrl = getBaseUrl();
+const list = ref([]);
+
+onMounted(() => {
+  // 点击图片
+  window.clickObject = (val) => {
+    const item = list.value.find((i) => i.thumb.indexOf(val) > -1);
+    router.push({
+      name: "detail",
+      params: {
+        id: item.id,
+      },
+    });
+  };
+  // 鼠标移入
+  window.hoverObject = (val) => {
+    const item = list.value[val.target.index];
+
+    txt.value = {
+      title: item.name,
+      con: item.author,
+      show: true,
+    };
+  };
+  // 鼠标移出
+  window.mouseoutObject = (val) => {
+    txt.value.show = false;
+  };
+
+  // 获取鼠标坐标
+  window.mouseLoc = (x, y) => {
+    // console.log("ppp", x, y);
+    // 最大X值
+    const maxX = window.innerWidth - 200;
+    let xRes = x >= maxX ? maxX : x;
+    // xRes = xRes - 100 <= 0 ? 0 : xRes - 100;
+    // 最大y值
+    const domHeight = txtDom.value.clientHeight;
+    const maxY = window.innerHeight - domHeight;
+    let yRes = y >= maxY ? maxY : y;
+    // yRes = yRes - domHeight / 2 <= 0 ? 0 : yRes - domHeight / 2;
+    txtDom.value.style.top = yRes + "px";
+    txtDom.value.style.left = xRes + "px";
+  };
+
+  getRecommendList();
+});
+
+const getRecommendList = async () => {
+  const data = await getRecommendListApi({
+    pageNum: 1,
+    pageSize: 20,
+    type: "index",
+  });
+
+  const iframe = document.getElementById("iframe");
+  const iframeDoc = iframe?.contentDocument || iframe?.contentWindow.document;
+  if (iframeDoc?.readyState === "complete") {
+    iframe.contentWindow.initViewer(data.map((i) => `${baseUrl}${i.thumb}`));
+  } else {
+    iframe.onload = () => {
+      iframe.contentWindow.initViewer(data.map((i) => `${baseUrl}${i.thumb}`));
+    };
+  }
+
+  list.value = data;
+};
 </script>
 
 <style lang="scss" scoped>

+ 62 - 5
packages/mobile/src/views/Stack/components/ClassifyScroll.vue

@@ -6,20 +6,77 @@
     slides-per-view="auto"
     :space-between="10"
   >
-    <SwiperSlide v-for="(item, index) in 8" :key="item">
-      <div class="classify-scroll-item" :class="{ active: active === index }">
-        分类一
+    <SwiperSlide
+      v-for="(item, index) in activeTab === 0 ? storageList : exhibitList"
+      :key="item.id"
+    >
+      <div
+        class="classify-scroll-item"
+        :class="{
+          active: (isStorage ? storageActive : exhibitActive) === index,
+        }"
+        @click="handleClick(item, index)"
+      >
+        {{ item.name }}
       </div>
     </SwiperSlide>
   </Swiper>
 </template>
 
 <script setup>
-import { ref } from "vue";
+import { ref, computed, watch } from "vue";
 import { FreeMode } from "swiper/modules";
 import { Swiper, SwiperSlide } from "swiper/vue";
+import { getStorageTreeApi, getExhibitTypeListApi } from "@lsq/base";
 
-const active = ref(0);
+const props = defineProps({
+  activeTab: {
+    type: Number,
+    required: true,
+  },
+});
+const isStorage = computed(() => props.activeTab === 0);
+const emits = defineEmits(["storageId", "exhibitId"]);
+const storageActive = ref(0);
+const exhibitActive = ref(0);
+const storageList = ref([]);
+const exhibitList = ref([]);
+
+const getStorageTree = async () => {
+  const data = await getStorageTreeApi();
+  storageList.value = data;
+};
+
+const getExhibitTypeList = async () => {
+  const data = await getExhibitTypeListApi();
+  exhibitList.value = data;
+};
+
+const handleClick = (item, index) => {
+  if (isStorage.value) {
+    storageActive.value = index;
+    emits("storageId", item.id);
+  } else {
+    exhibitActive.value = index;
+    emits("exhibitId", item.id);
+  }
+};
+
+watch(
+  () => props.activeTab,
+  async () => {
+    if (isStorage.value) {
+      !storageList.value.length && (await getStorageTree());
+      emits("storageId", storageList.value[0].id);
+    } else {
+      !exhibitList.value.length && (await getExhibitTypeList());
+      emits("exhibitId", exhibitList.value[0].id);
+    }
+  },
+  {
+    immediate: true,
+  }
+);
 </script>
 
 <style scoped lang="scss">

+ 29 - 32
packages/mobile/src/views/Stack/index.vue

@@ -8,9 +8,9 @@
     @click="$router.push({ name: 'search' })"
   />
 
-  <van-tabs swipeable shrink class="stack">
+  <van-tabs v-model:active="activeTab" swipeable shrink class="stack">
     <template #nav-bottom>
-      <classify-scroll />
+      <classify-scroll :active-tab="activeTab" @storageId="handleStorage" />
     </template>
 
     <van-tab title="按书籍分类">
@@ -22,6 +22,10 @@
         @scroll-end="handleEnd"
       >
         <BookCard2 />
+
+        <template v-slot="after">
+          <van-loading />
+        </template>
       </RecycleScroller>
     </van-tab>
 
@@ -40,42 +44,35 @@
 
 <script setup>
 import { ref } from "vue";
+import { getBookListApi, usePagination } from "@lsq/base";
 import { RecycleScroller } from "vue-virtual-scroller";
 import BookCard2 from "@/components/BookCard2.vue";
 import ClassifyScroll from "./components/ClassifyScroll.vue";
 
-const bookList = ref([
-  {
-    id: 1,
-  },
-  {
-    id: 2,
-  },
-  {
-    id: 3,
-  },
-  {
-    id: 5,
-  },
-  {
-    id: 6,
-  },
-]);
-const bookLoading = ref(false);
+const activeTab = ref(0);
+const storageId = ref(0);
+const {
+  loading: bookLoading,
+  list: bookList,
+  noData: bookNoData,
+  noMore: bookNoMore,
+  getList: getBookList,
+} = usePagination(async (params) => {
+  return getBookListApi({
+    ...params,
+    storageId: storageId.value,
+  });
+});
+
+const handleStorage = (id) => {
+  console.log(id);
+  storageId.value = id;
+  getBookList();
+};
 
-let id = 7;
 const handleEnd = () => {
-  try {
-    bookLoading.value = true;
-    const stack = [];
-    for (let i = 0; i < 20; i++) {
-      stack.push({
-        id: id++,
-      });
-    }
-    bookList.value = bookList.value.concat(stack);
-  } finally {
-    bookLoading.value = false;
+  if (activeTab.value === 0 && !noMore.value) {
+    getBookList();
   }
 };
 </script>

+ 1 - 1
packages/pc/.env.development

@@ -1 +1 @@
-VITE_BASE_URL=https://sit-liushaoqibwg.4dage.com
+VITE_BASE_URL=http://192.168.20.61:8072

+ 10 - 0
packages/pc/src/App.vue

@@ -1,6 +1,16 @@
 <script setup>
+import { onMounted } from "vue";
 import { RouterView } from "vue-router";
 import TopNav from "@/components/TopNav/index.vue";
+import { useBaseStore } from "./stores";
+
+const baseStore = useBaseStore();
+
+onMounted(() => {
+  if (baseStore.isLogin) {
+    baseStore.getUserInfo();
+  }
+});
 </script>
 
 <template>

+ 20 - 1
packages/pc/src/components/BookCard/index.vue

@@ -13,6 +13,8 @@
         src="@/assets/images/icon_like.png"
         draggable="false"
       />
+
+      <div v-if="showRead" class="book-card-img__tag">读过</div>
     </div>
 
     <div class="book-card-inner">
@@ -42,7 +44,10 @@ const props = defineProps({
   },
   showLike: {
     type: Boolean,
-    required: false,
+    default: false,
+  },
+  showRead: {
+    type: Boolean,
     default: false,
   },
   item: {
@@ -122,6 +127,20 @@ const isRow = computed(() => props.type === "row");
       width: 30px;
       height: 30px;
     }
+    &__tag {
+      position: absolute;
+      top: 10px;
+      right: 10px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 43px;
+      height: 24px;
+      color: white;
+      font-size: 12px;
+      border-radius: 2px;
+      background: rgba(0, 0, 0, 0.3);
+    }
   }
   &-inner {
     text-align: center;

+ 5 - 4
packages/pc/src/components/TopNav/components/LoginDialog.vue

@@ -11,9 +11,9 @@
 
 <script setup>
 import { computed, ref, watch, nextTick } from "vue";
-import { useRoute } from "vue-router";
+import { useRoute, useRouter } from "vue-router";
 import { wxLoginApi } from "@lsq/base";
-import { useRouter } from "vue-router";
+import { useBaseStore } from "@/stores";
 
 const props = defineProps({
   visible: {
@@ -22,6 +22,7 @@ const props = defineProps({
   },
 });
 const emits = defineEmits(["update:visible"]);
+const baseStore = useBaseStore();
 const route = useRoute();
 const router = useRouter();
 const loading = ref(false);
@@ -39,7 +40,7 @@ const login = () => {
   new WxLogin({
     self_redirect: false, //true:手机点击确认登录后可以在 iframe 内跳转到 redirect_uri
     id: "login-dialog__scan", //显示二维码容器设置
-    appid: "wxc09c15943347c4a4", //应用位置标识appid
+    appid: "wx3d4f2e0cfc3b8e54", //应用位置标识appid
     scope: "snsapi_login", //当前微信扫码登录页面已经授权了
     redirect_uri: encodeURIComponent(location.href), //填写授权回调域路径,就是用户授权成功以后,微信服务器向公司后台推送code地址
     state: "", //state就是后台服务器重定向的地址携带用户信息
@@ -53,7 +54,7 @@ watch(route, async (v) => {
     try {
       loading.value = true;
       const data = await wxLoginApi(v.query.code);
-      console.log(data);
+      baseStore.login(data);
     } finally {
       const query = { ...route.query };
       delete query.code;

+ 6 - 3
packages/pc/src/components/TopNav/index.scss

@@ -91,12 +91,10 @@
     align-items: center;
     gap: 30px;
 
-    h1 {
+    &__title {
       display: flex;
       align-items: center;
       gap: 20px;
-      font-size: 24px;
-      font-family: "Source Han Serif CN-Bold";
 
       span {
         display: inline-flex;
@@ -113,6 +111,11 @@
         cursor: pointer;
       }
     }
+    h1 {
+      max-width: 200px;
+      font-size: 24px;
+      font-family: "Source Han Serif CN-Bold";
+    }
     p {
       color: var(--text-color-secondary);
     }

+ 37 - 18
packages/pc/src/components/TopNav/index.vue

@@ -24,12 +24,13 @@
               />
             </router-link>
             <div class="top-nav-detail__inner">
-              <h1>
-                刘少奇传<span @click="introductionVisible = true"
-                  >查看简介</span
-                >
-              </h1>
-              <p>金冲及 中共中央文献研究室 编</p>
+              <div class="top-nav-detail__title">
+                <h1 class="limit-line">
+                  {{ detail?.name }}
+                </h1>
+                <span @click="introductionVisible = true">查看简介</span>
+              </div>
+              <p>{{ detail?.author }} {{ detail?.press }} 编</p>
             </div>
           </div>
         </div>
@@ -46,7 +47,7 @@
               element-loading-custom-class="top-nav__loading"
               @click="handleLike"
             >
-              加入书架
+              {{ detail?.isCollect === 1 ? "我的书架" : "加入书架" }}
             </li>
           </ul>
 
@@ -105,20 +106,18 @@
           >
             <template #reference>
               <div class="top-nav-user">
-                <el-avatar src="" :size="30" />
-                <p>用户名</p>
+                <el-avatar :src="getFixUrl(userInfo.avatarUrl)" :size="30" />
+                <p>{{ userInfo.nickName }}</p>
               </div>
             </template>
 
             <ul class="top-nav-user-toolbar">
-              <li>我的书架</li>
-              <li>退出登录</li>
+              <li @click="$router.push({ name: 'bookshelf' })">我的书架</li>
+              <li @click="baseStore.logout">退出登录</li>
             </ul>
           </el-popover>
 
-          <div class="top-nav__login" @click="loginVisible = true">
-            微信登录
-          </div>
+          <div v-else class="top-nav__login" @click="handleLogin">微信登录</div>
         </div>
       </div>
     </div>
@@ -129,9 +128,10 @@
 
 <script setup>
 import { watch, ref } from "vue";
-import { useRoute } from "vue-router";
+import { useRoute, useRouter } from "vue-router";
 import { useDark } from "@vueuse/core";
 import { storeToRefs } from "pinia";
+import { getFixUrl, updateBookCollectApi, fakeLoginApi } from "@lsq/base";
 import { useBaseStore, useEpubStore, useDetailStore } from "@/stores";
 import LoginDialog from "./components/LoginDialog.vue";
 
@@ -170,12 +170,13 @@ const DETAIL_NAV = [
 ];
 
 const route = useRoute();
+const router = useRouter();
 const isDark = useDark();
 
 const detailStore = useDetailStore();
 const { introductionVisible, detail } = storeToRefs(detailStore);
 const baseStore = useBaseStore();
-const { isLogin, loginVisible } = storeToRefs(baseStore);
+const { isLogin, loginVisible, userInfo } = storeToRefs(baseStore);
 const epubStore = useEpubStore();
 const { isSimplified } = storeToRefs(epubStore);
 
@@ -186,11 +187,29 @@ const bgColor = ref("");
 const joinLoading = ref(false);
 
 // 加入书架
-const handleLike = () => {
-  if (!baseStore.loginValidator()) {
+const handleLike = async () => {
+  if (!baseStore.loginValidator()) return;
+
+  if (detail.value.isCollect !== 1) {
+    try {
+      joinLoading.value = true;
+      await updateBookCollectApi(detail.value.id, {
+        status: 1,
+      });
+    } finally {
+      joinLoading.value = false;
+    }
+  } else {
+    router.push({ name: "bookshelf" });
   }
 };
 
+const handleLogin = async () => {
+  // loginVisible.value = true
+  const data = await fakeLoginApi(1);
+  baseStore.login(data);
+};
+
 watch(route, (v) => {
   isDetail.value = v.name === "detail";
   showLogo.value = v.meta.showLogo ?? false;

+ 5 - 0
packages/pc/src/configure.js

@@ -1,5 +1,6 @@
 import { compose, initial } from "@dage/service";
 import router from "./router";
+import { getUserInfo } from "@/utils";
 
 const showMessage = (msg, type = "error") => {
   ElMessage({
@@ -14,6 +15,10 @@ initial({
   baseURL: import.meta.env.VITE_BASE_URL,
   interceptor: compose(async (request, next) => {
     try {
+      const userInfo = getUserInfo();
+      if (userInfo) {
+        request.headers["token"] = userInfo.token;
+      }
       const response = await next();
       const { showError = true } = request.meta;
 

+ 14 - 1
packages/pc/src/router/index.js

@@ -1,3 +1,4 @@
+import { getUserInfo } from "@/utils";
 import { createRouter, createWebHashHistory } from "vue-router";
 
 const router = createRouter({
@@ -26,6 +27,7 @@ const router = createRouter({
       component: () => import("@/views/Bookshelf/index.vue"),
       meta: {
         showLogo: true,
+        needLogin: true,
       },
     },
     {
@@ -45,12 +47,23 @@ const router = createRouter({
   ],
 });
 
-router.beforeEach((to) => {
+router.beforeEach((to, from, next) => {
+  const userInfo = getUserInfo();
+
   if (to.name !== "detail") {
     // 除了 detail 页面,其他页面不需要暗黑模式
     localStorage.removeItem("vueuse-color-scheme");
     document.documentElement.className = "";
   }
+
+  if (to.meta.needLogin && !userInfo) {
+    next({
+      name: "home",
+    });
+    return;
+  }
+
+  next();
 });
 
 export default router;

+ 44 - 2
packages/pc/src/stores/base.js

@@ -1,9 +1,15 @@
 import { ref } from "vue";
 import { defineStore } from "pinia";
+import { getUserInfoApi, logoutApi } from "@lsq/base";
+import { getUserInfo, USERINFO_KEY } from "@/utils";
+
+const userInfoStorage = getUserInfo();
 
 export const useBaseStore = defineStore("base", () => {
-  const isLogin = ref(false);
+  const isLogin = ref(Boolean(userInfoStorage));
   const loginVisible = ref(false);
+  const userInfo = ref(userInfoStorage ? userInfoStorage.user : null);
+  const token = ref(userInfoStorage ? userInfoStorage.token : null);
 
   const loginValidator = () => {
     if (isLogin.value) return true;
@@ -12,5 +18,41 @@ export const useBaseStore = defineStore("base", () => {
     return false;
   };
 
-  return { isLogin, loginVisible, loginValidator };
+  const login = (data) => {
+    isLogin.value = true;
+    userInfo.value = data.user;
+    token.value = data.token;
+    localStorage.setItem(USERINFO_KEY, JSON.stringify(data));
+  };
+
+  const logout = () => {
+    logoutApi();
+    isLogin.value = false;
+    userInfo.value = null;
+    token.value = "";
+    localStorage.removeItem(USERINFO_KEY);
+  };
+
+  const getUserInfo = async () => {
+    const data = await getUserInfoApi();
+    userInfo.value = data;
+    localStorage.setItem(
+      USERINFO_KEY,
+      JSON.stringify({
+        token: token.value,
+        user: data,
+      })
+    );
+  };
+
+  return {
+    isLogin,
+    loginVisible,
+    userInfo,
+    token,
+    logout,
+    login,
+    getUserInfo,
+    loginValidator,
+  };
 });

+ 18 - 0
packages/pc/src/stores/detail.js

@@ -1,10 +1,12 @@
 import { ref } from "vue";
 import { defineStore } from "pinia";
+import { getLabelListApi } from "@lsq/base";
 
 export const useDetailStore = defineStore("detail", () => {
   const detail = ref(null);
   const searchVisible = ref(false);
   const searchKey = ref("");
+  const noteList = ref([]);
 
   /**
    * 是否显示简介
@@ -16,11 +18,27 @@ export const useDetailStore = defineStore("detail", () => {
     searchVisible.value = true;
   };
 
+  const getLabelList = async () => {
+    if (!detail.value) return;
+
+    const data = await getLabelListApi(detail.value.id, "note");
+    noteList.value = data.map((item) =>
+      item.content
+        ? {
+            ...item,
+            content: JSON.parse(item.content),
+          }
+        : item
+    );
+  };
+
   return {
     detail,
     searchVisible,
     searchKey,
+    noteList,
     introductionVisible,
     openSearchDrawer,
+    getLabelList,
   };
 });

+ 10 - 0
packages/pc/src/utils/index.js

@@ -0,0 +1,10 @@
+export const USERINFO_KEY = "userinfo";
+
+export const getUserInfo = () => {
+  let userInfoStorage = localStorage.getItem(USERINFO_KEY);
+  if (userInfoStorage) {
+    userInfoStorage = JSON.parse(userInfoStorage);
+  }
+
+  return userInfoStorage;
+};

+ 112 - 24
packages/pc/src/views/Bookshelf/components/UploadDialog.vue

@@ -50,6 +50,7 @@
           clearable
           class="w350"
           type="year"
+          value-format="YYYY"
           placeholder="请选择年份"
         />
       </el-form-item>
@@ -62,7 +63,7 @@
             accept="image/jpeg,image/png,image/jpg"
             :before-upload="onBeforeUploadImage"
           >
-            <img v-if="imageUrl" :src="imageUrl" />
+            <img v-if="form.thumb" :src="devBaseUrl + form.thumb" />
             <div v-else>
               <el-icon
                 :size="30"
@@ -111,12 +112,34 @@
           :maxlength="20"
         />
       </el-form-item>
+      <el-form-item label="附件" prop="fileName">
+        <div>
+          <el-upload
+            accept=".epub"
+            :limit="1"
+            :http-request="handleUploadFile"
+            :before-upload="onBeforeUploadEpub"
+          >
+            <div class="upload-dialog__upload-file">
+              <svg-icon
+                name="icon_upload_yellow"
+                width="24px"
+                height="24px"
+                color="var(--el-color-primary)"
+              />
+              上传
+            </div>
+          </el-upload>
+          <p style="font-size: 12px; color: var(--text-color-placeholder)">
+            请上传epub格式附件,最多1个
+          </p>
+        </div>
+      </el-form-item>
       <el-form-item label="验证码" prop="randCode">
         <div class="w350" style="display: flex; gap: 10px">
           <el-input
             v-model="form.randCode"
             class="code"
-            type="number"
             placeholder="请输入验证码"
           />
           <el-image
@@ -130,6 +153,7 @@
         <el-button
           class="upload-dialog__btn confirm"
           type="primary"
+          :loading="btnLoading"
           @click="handleSubmit"
           >提交</el-button
         >
@@ -151,9 +175,11 @@ import { Plus } from "@element-plus/icons-vue";
 import {
   uploadApi,
   getStorageTreeApi,
+  saveBookApi,
   getExhibitTypeListApi,
   getBaseUrl,
 } from "@lsq/base";
+import { getBaseURL } from "@dage/service";
 
 const DEFAULT_FORM = {
   name: "",
@@ -166,6 +192,8 @@ const DEFAULT_FORM = {
   exhibitTypeId: "",
   num: "",
   randCode: "",
+  fileName: "",
+  filePath: "",
 };
 
 const props = defineProps({
@@ -174,14 +202,16 @@ const props = defineProps({
     required: true,
   },
 });
-const emits = defineEmits(["update:visible"]);
+const emits = defineEmits(["update:visible", "success"]);
 const ruleFormRef = ref();
-const baseUrl = getBaseUrl();
+const baseUrl = getBaseURL();
+const devBaseUrl = getBaseUrl();
 const storageLoading = ref(false);
 const storageList = ref([]);
 const exhibitLoading = ref(false);
 const exhibitList = ref([]);
 const timestamp = ref(new Date().getTime());
+const btnLoading = ref(false);
 const form = reactive({ ...DEFAULT_FORM });
 const rules = reactive({
   randCode: [{ required: true, message: "请输入验证码", trigger: "blur" }],
@@ -191,10 +221,10 @@ const rules = reactive({
   press: [{ required: true, message: "请输入出版社", trigger: "blur" }],
   year: [{ required: true, message: "请选择出版年份", trigger: "blur" }],
   thumb: [{ required: true, message: "请上传封面", trigger: "blur" }],
+  fileName: [{ required: true, message: "请上传文件", trigger: "blur" }],
   storageId: [{ required: true, message: "请选择", trigger: "blur" }],
   exhibitTypeId: [{ required: true, message: "请选择", trigger: "blur" }],
 });
-const imageUrl = ref("");
 
 const show = computed({
   get() {
@@ -217,15 +247,40 @@ const onBeforeUploadImage = (file) => {
   return isIMAGE && isLt1M;
 };
 
+const onBeforeUploadEpub = (file) => {
+  if (file.type !== "application/epub") {
+    ElMessage.error("只能上传 epub 格式文件");
+    return false;
+  }
+
+  return true;
+};
+
 const handleUpload = async (params) => {
   const formData = new FormData();
   formData.append("file", params.file);
   formData.append("type", "img");
   try {
     const data = await uploadApi(formData);
-    console.log(data);
+    form.thumb = data.filePath;
+    params.onSuccess();
+  } catch (err) {
+    ElMessage.error("上传封面失败");
+    params.onError();
+  }
+};
+
+const handleUploadFile = async (params) => {
+  const formData = new FormData();
+  formData.append("file", params.file);
+  formData.append("type", "doc");
+  try {
+    const data = await uploadApi(formData);
+    form.filePath = data.filePath;
+    form.fileName = data.fileName;
     params.onSuccess();
   } catch (err) {
+    ElMessage.error("上传文件失败");
     params.onError();
   }
 };
@@ -251,9 +306,21 @@ const getExhibitTypeList = async () => {
 };
 
 const handleSubmit = async () => {
-  await ruleFormRef.value.validate((valid) => {
+  await ruleFormRef.value.validate(async (valid) => {
     if (valid) {
-      console.log("submit!");
+      try {
+        btnLoading.value = true;
+        const { storageId, ...rest } = form;
+        await saveBookApi({
+          ...rest,
+          storageId: storageId[0],
+        });
+        ElMessage.success("上传成功,等待审核");
+        handleClose();
+        emits("success");
+      } finally {
+        btnLoading.value = false;
+      }
     }
   });
 };
@@ -274,18 +341,8 @@ watch(show, (v) => {
 
 <style lang="scss">
 .upload-dialog {
-  &__btn {
-    width: 132px;
-    height: 55px;
-    border-radius: 0;
-    font-size: 16px;
-    background: white;
-
-    &.confirm {
-      border: 0;
-      background: url("@/assets/images/btn_02-min.png") no-repeat center right /
-        cover;
-    }
+  .el-input {
+    --el-input-height: 50px;
   }
   .el-dialog__header {
     font-size: 24px;
@@ -294,9 +351,6 @@ watch(show, (v) => {
   .el-form {
     --el-form-label-font-size: 18px;
   }
-  .el-input {
-    --el-input-height: 50px;
-  }
   .el-form-item__label {
     height: 50px;
     line-height: 50px;
@@ -304,6 +358,23 @@ watch(show, (v) => {
   .el-select__wrapper {
     height: 50px;
   }
+}
+</style>
+<style lang="scss" scoped>
+.upload-dialog {
+  &__btn {
+    width: 132px;
+    height: 55px;
+    border-radius: 0;
+    font-size: 16px;
+    background: white;
+
+    &.confirm {
+      border: 0;
+      background: url("@/assets/images/btn_02-min.png") no-repeat center right /
+        cover;
+    }
+  }
   .code {
     width: 167px;
   }
@@ -312,14 +383,31 @@ watch(show, (v) => {
     height: 85px;
     border-radius: 5px;
     border: 1px dotted #d1bb9e;
+    overflow: hidden;
 
-    div {
+    :deep(div) {
       display: flex;
       align-items: center;
       justify-content: center;
       width: 100%;
       height: 100%;
     }
+    img {
+      width: 100%;
+      height: 100%;
+    }
+  }
+  &__upload-file {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 5px;
+    width: 120px;
+    height: 50px;
+    color: var(--el-color-primary);
+    border-radius: 5px;
+    border: 1px solid var(--el-color-primary);
+    box-sizing: border-box;
   }
 }
 </style>

+ 126 - 0
packages/pc/src/views/Bookshelf/components/UploadList.vue

@@ -0,0 +1,126 @@
+<template>
+  <div class="bookshelf-list">
+    <el-table v-loading="loading" :data="list">
+      <el-table-column align="center" prop="name" label="书名" width="180" />
+      <el-table-column align="center" label="封面" #default="scope">
+        <el-link
+          type="primary"
+          @click="
+            () => {
+              previewList = [`${baseUrl}${scope.row.thumb}`];
+              showPreview = true;
+            }
+          "
+          >{{ scope.row.thumb }}</el-link
+        >
+      </el-table-column>
+      <el-table-column align="center" prop="author" label="作者" />
+      <el-table-column align="center" prop="press" label="出版社" />
+      <el-table-column align="center" prop="year" label="出版年份" />
+      <el-table-column align="center" prop="storageName" label="中图法分类" />
+      <el-table-column align="center" prop="exhibitTypeName" label="展示分类" />
+      <el-table-column align="center" prop="num" label="ISBN编号" />
+      <el-table-column align="center" label="审核状态" #default="scope">
+        <p :style="{ color: STATUS[scope.row.auditStatus].color }">
+          {{ STATUS[scope.row.auditStatus].label }}
+        </p>
+      </el-table-column>
+      <el-table-column align="center" #default="scope" label="提交时间">
+        {{ formatDate(scope.row.createTime) }}
+      </el-table-column>
+      <el-table-column
+        align="center"
+        fixed="right"
+        #default="scope"
+        label="操作"
+      >
+        <el-link type="error" @click="handleDelete(scope.row.id)">删除</el-link>
+      </el-table-column>
+    </el-table>
+  </div>
+
+  <el-image-viewer
+    v-if="showPreview"
+    :url-list="previewList"
+    @close="showPreview = false"
+  />
+</template>
+
+<script setup>
+import { ref, onMounted } from "vue";
+import { getBaseUrl, getUploadBookListApi, deleteBookApi } from "@lsq/base";
+import { formatDate } from "@dage/utils";
+
+const STATUS = [
+  {
+    label: "待审核",
+    color: "var(--el-color-info)",
+  },
+  {
+    label: "审核通过",
+    color: "var(--el-color-success)",
+  },
+  {
+    label: "审核不通过",
+    color: "var(--el-color-error)",
+  },
+];
+const baseUrl = getBaseUrl();
+const loading = ref(false);
+const list = ref([]);
+const showPreview = ref(false);
+const previewList = ref([]);
+
+const getUploadBookList = async () => {
+  try {
+    loading.value = true;
+    const data = await getUploadBookListApi();
+    list.value = data;
+  } finally {
+    loading.value = false;
+  }
+};
+
+const handleDelete = (id) => {
+  ElMessageBox.confirm("确认是否删除?", "提示", {
+    confirmButtonText: "确定",
+    cancelButtonText: "取消",
+    type: "warning",
+    beforeClose: async (action, instance, done) => {
+      if (action === "confirm") {
+        try {
+          instance.confirmButtonLoading = true;
+          await deleteBookApi(id);
+          ElMessage.success("删除成功");
+          done();
+
+          getUploadBookList();
+        } finally {
+          instance.confirmButtonLoading = false;
+        }
+      } else {
+        done();
+      }
+    },
+  });
+};
+
+onMounted(() => {
+  getUploadBookList();
+});
+
+defineExpose({
+  getUploadBookList,
+});
+</script>
+
+<style lang="scss" scoped>
+.bookshelf-list {
+  margin: 35px -67px 0;
+
+  .el-table {
+    --el-table-header-text-color: #464646;
+    --el-table-header-bg-color: rgba(209, 187, 158, 0.2);
+  }
+}
+</style>

+ 125 - 26
packages/pc/src/views/Bookshelf/index.vue

@@ -4,12 +4,15 @@
       <div class="bookshelf-header">
         <div class="bookshelf-info">
           <el-upload
+            v-loading="avatarLoading"
             class="bookshelf-avatar"
-            action="https://run.mocky.io/v3/9d059bf9-4660-45f2-925d-ce80ad6c4d15"
+            title="修改头像"
+            :http-request="handleUpload"
             :show-file-list="false"
-            :before-upload="beforeAvatarUpload"
+            accept="image/jpeg,image/png,image/jpg"
+            :before-upload="onBeforeUploadImage"
           >
-            <el-avatar v-if="imageUrl" :size="100" :src="imageUrl" />
+            <el-avatar :size="100" :src="getFixUrl(userInfo.avatarUrl)" />
 
             <!-- <div class="bookshelf-avatar__mask">
               <img src="@/assets/images/icon_portrait-min.png" />
@@ -18,14 +21,14 @@
 
           <div class="bookshelf-info-inner">
             <div class="bookshelf-info__name">
-              <span>SAMSARA</span>
+              <span>{{ userInfo.nickName }}</span>
               <img
                 draggable="false"
                 src="@/assets/images/icon_edit-min.png"
                 @click="openEditName"
               />
             </div>
-            <p class="bookshelf-info__id">ID:6855576858</p>
+            <p class="bookshelf-info__id">ID:{{ userInfo.id }}</p>
           </div>
         </div>
 
@@ -43,7 +46,10 @@
             />
             上传图书</el-button
           >
-          <el-button type="primary" class="bookshelf-header__logout"
+          <el-button
+            type="primary"
+            class="bookshelf-header__logout"
+            @click="handleLogout"
             >退出登录</el-button
           >
         </div>
@@ -52,37 +58,97 @@
       <div class="bookshelf-inner">
         <p class="bookshelf-inner__label"><span>我的书架</span></p>
 
-        <ul>
-          <li v-for="key in 10" :key="key">
-            <!-- <book-card type="column" size="large" show-like /> -->
-          </li>
-        </ul>
+        <div v-loading="bookLoading" style="min-height: 300px">
+          <ul>
+            <li v-for="item in bookList" :key="item.id">
+              <book-card
+                type="column"
+                size="large"
+                :item="item"
+                :show-like="item.isCollect === 1"
+                :show-read="item.isCollect === 0"
+              />
+            </li>
+          </ul>
+
+          <el-empty
+            v-if="!bookList.length && !bookLoading"
+            description="暂无数据"
+          ></el-empty>
+        </div>
+
+        <p class="bookshelf-inner__label" style="margin-top: 100px">
+          <span>我的上传</span>
+        </p>
+
+        <upload-list ref="listRef" />
       </div>
     </div>
   </div>
 
-  <upload-dialog v-model:visible="uploadVisible" />
+  <upload-dialog
+    v-model:visible="uploadVisible"
+    @success="listRef?.getUploadBookList"
+  />
 </template>
 
 <script setup>
-import { ref } from "vue";
+import { ref, onMounted } from "vue";
+import { useBaseStore } from "@/stores";
+import { storeToRefs } from "pinia";
+import {
+  updateUserInfoApi,
+  getMyBookListApi,
+  uploadApi,
+  getFixUrl,
+} from "@lsq/base";
+import { useRouter } from "vue-router";
 import BookCard from "@/components/BookCard/index.vue";
 import UploadDialog from "./components/UploadDialog.vue";
+import UploadList from "./components/UploadList.vue";
 
-const imageUrl = ref(
-  "https://fuss10.elemecdn.com/3/63/4e7f3a15429bfda99bce42a18cdd1jpeg.jpeg?imageMogr2/thumbnail/360x360/format/webp/quality/100"
-);
+const router = useRouter();
+const listRef = ref(null);
 const uploadVisible = ref(false);
+const avatarLoading = ref(false);
+const baseUrl = useBaseStore();
+const { userInfo } = storeToRefs(baseUrl);
+
+const bookLoading = ref(false);
+const bookList = ref([]);
+
+const onBeforeUploadImage = (file) => {
+  const isIMAGE = ["image/jpeg", "image/jpg", "image/png"].includes(file.type);
+  const isLt1M = file.size / 1024 / 1024 < 2;
+  if (!isIMAGE) {
+    ElMessage.error("上传文件只能是图片格式!");
+  }
+  if (!isLt1M) {
+    ElMessage.error("上传文件大小不能超过 2MB!");
+  }
+  return isIMAGE && isLt1M;
+};
 
-const beforeAvatarUpload = (rawFile) => {
-  if (rawFile.type !== "image/jpeg") {
-    ElMessage.error("Avatar picture must be JPG format!");
-    return false;
-  } else if (rawFile.size / 1024 / 1024 > 2) {
-    ElMessage.error("Avatar picture size can not exceed 2MB!");
-    return false;
+const handleUpload = async (params) => {
+  const formData = new FormData();
+  formData.append("file", params.file);
+  formData.append("type", "img");
+  try {
+    try {
+      avatarLoading.value = true;
+      const data = await uploadApi(formData);
+      await updateUserInfoApi({
+        avatarUrl: data.filePath,
+      });
+      userInfo.value.avatarUrl = data.filePath;
+      params.onSuccess();
+    } finally {
+      avatarLoading.value = false;
+    }
+  } catch (err) {
+    ElMessage.error("头像上传失败");
+    params.onError();
   }
-  return true;
 };
 
 const openEditName = () => {
@@ -91,10 +157,43 @@ const openEditName = () => {
     cancelButtonText: "取消",
     inputPattern: /^[\w\W]{1,20}$/,
     inputErrorMessage: "昵称长度为1-20个字符",
-  }).then(({ value }) => {
-    console.log(value);
+    beforeClose: async (action, instance, done) => {
+      if (action === "confirm") {
+        try {
+          instance.confirmButtonLoading = true;
+          await updateUserInfoApi({
+            nickName: instance.inputValue,
+          });
+          userInfo.value.nickName = instance.inputValue;
+        } finally {
+          instance.confirmButtonLoading = false;
+          done();
+        }
+      } else {
+        done();
+      }
+    },
   });
 };
+
+const getMyBookList = async () => {
+  try {
+    bookLoading.value = true;
+    const data = await getMyBookListApi();
+    bookList.value = data;
+  } finally {
+    bookLoading.value = false;
+  }
+};
+
+const handleLogout = () => {
+  baseUrl.logout();
+  router.replace({ name: "home" });
+};
+
+onMounted(() => {
+  getMyBookList();
+});
 </script>
 
 <style lang="scss" scoped>

+ 48 - 20
packages/pc/src/views/Detail/components/Bookmark.vue

@@ -5,11 +5,11 @@
 
       <p class="bookmark__title">总数:{{ list.length }}</p>
 
-      <div class="bookmark-list">
-        <div v-for="(item, idx) in list" :key="idx" class="bookmark-item">
+      <div v-loading="loading" class="bookmark-list">
+        <div v-for="(item, idx) in list" :key="item.id" class="bookmark-item">
           <div class="bookmark-item__inner" @click="goToDetail(item)">
-            <p>书签</p>
-            <p>{{ item.time }}</p>
+            <p>书签</p>
+            <p>{{ item.createTime }}</p>
           </div>
 
           <svg-icon
@@ -18,30 +18,29 @@
             width="24px"
             height="24px"
             color="var(--el-color-primary)"
+            @click="handleDelete(item, idx)"
           />
         </div>
+
+        <el-empty v-if="!list.length && !loading" description="暂无数据" />
       </div>
     </div>
   </Drawer>
 </template>
 
 <script setup>
-import { computed, ref } from "vue";
-import { formatDate } from "@dage/utils";
+import { computed, ref, watch } from "vue";
 import { Plus } from "@element-plus/icons-vue";
-import { useEpubStore } from "@/stores";
+import { saveLabelApi, getLabelListApi, deleteLabelApi } from "@lsq/base";
+import { useEpubStore, useDetailStore } from "@/stores";
 import Drawer from "./Drawer.vue";
 
 const props = defineProps(["visible"]);
 const emits = defineEmits(["update:visible", "close"]);
 const epubStore = useEpubStore();
-const list = ref([
-  {
-    scrollLeft: 5416,
-    location: "epubcfi(/6/14[x05.xhtml]!/4/38/1:0)",
-    time: "2022-01-01",
-  },
-]);
+const detailStore = useDetailStore();
+const list = ref([]);
+const loading = ref(false);
 
 const show = computed({
   get() {
@@ -53,18 +52,46 @@ const show = computed({
 });
 
 const addBookmark = async () => {
-  const { startCfi, scrollLeft } = await epubStore.refreshLocation();
+  const { startCfi, page } = await epubStore.refreshLocation();
 
-  list.value.push({
-    location: startCfi,
-    scrollLeft,
-    time: formatDate(new Date(), "YYYY-MM-DD HH:mm:ss"),
+  const data = await saveLabelApi({
+    type: "label",
+    bookId: detailStore.detail.id,
+    content: JSON.stringify({
+      location: startCfi,
+      page,
+    }),
   });
+  list.value.push({ ...data, content: JSON.parse(data.content) });
+};
+
+const getLabelList = async () => {
+  try {
+    loading.value = true;
+    const data = await getLabelListApi(detailStore.detail.id, "label");
+    list.value = data.map((i) => ({
+      ...i,
+      content: JSON.parse(i.content),
+    }));
+  } finally {
+    loading.value = false;
+  }
 };
 
 const goToDetail = (item) => {
-  epubStore.goToChapter(item.location, item.scrollLeft);
+  epubStore.goToChapter(item.content.location, item.content.page);
 };
+
+const handleDelete = (item, idx) => {
+  list.value.splice(idx, 1);
+  deleteLabelApi(item.id);
+};
+
+watch(show, (v) => {
+  if (v && !list.value.length) {
+    getLabelList();
+  }
+});
 </script>
 
 <style lang="scss" scoped>
@@ -83,6 +110,7 @@ const goToDetail = (item) => {
   }
   &-list {
     padding: 7.5px 0;
+    min-height: 300px;
   }
   &-item {
     position: relative;

+ 64 - 18
packages/pc/src/views/Detail/components/Comment.vue

@@ -1,26 +1,32 @@
 <template>
   <Drawer v-model:visible="show" title="评论" @close="emits('close')">
     <div class="comment">
-      <el-input
-        type="textarea"
-        :autosize="{ minRows: 8 }"
-        placeholder="请留下您的评论,最多200字"
-        :maxlength="200"
-        show-word-limit
-      />
+      <template v-if="isLogin">
+        <el-input
+          v-model="message"
+          type="textarea"
+          :autosize="{ minRows: 8 }"
+          placeholder="请留下您的评论,最多200字"
+          :maxlength="200"
+          show-word-limit
+        />
 
-      <el-button
-        type="primary"
-        style="margin: 10px 0 70px; width: 100%; height: 50px"
-        >发表</el-button
-      >
+        <el-button
+          :loading="btnLoading"
+          type="primary"
+          style="margin: 10px 0 70px; width: 100%; height: 50px"
+          @click="handleSubmit"
+          >发表</el-button
+        >
+      </template>
 
-      <div class="comment-list">
-        <div class="comment-item">
+      <div class="comment-list" v-infinite-scroll="load">
+        <div v-for="item in list" :key="item.id" class="comment-item">
           <div class="comment-item-header">
-            <p>怕黑的cat</p>
-            <p class="comment-item-header__date">2024-08-18</p>
+            <p>{{ item.createBy }}</p>
+            <p class="comment-item-header__date">{{ item.createTime }}</p>
             <svg-icon
+              v-if="item.creatorId === userInfo.id"
               class="comment-item-header__del"
               name="icon_delete"
               width="16px"
@@ -29,7 +35,7 @@
             />
           </div>
           <p class="comment-item__inner">
-            像一条绿色的玉带,从南到北缓缓穿越湖南全省,注入中国第二大淡水湖洞庭湖。
+            {{ item.content }}
           </p>
         </div>
       </div>
@@ -38,11 +44,28 @@
 </template>
 
 <script setup>
-import { computed } from "vue";
+import { computed, ref } from "vue";
+import { usePagination, getMessageListApi, saveMessageApi } from "@lsq/base";
 import Drawer from "./Drawer.vue";
+import { useBaseStore, useDetailStore } from "@/stores";
+import { storeToRefs } from "pinia";
 
 const props = defineProps(["visible"]);
 const emits = defineEmits(["update:visible", "close"]);
+const baseStore = useBaseStore();
+const detailStore = useDetailStore();
+const btnLoading = ref(false);
+const message = ref("");
+const { isLogin, userInfo } = storeToRefs(baseStore);
+
+const { pageNum, loading, list, noData, noMore, getList } = usePagination(
+  async (params) => {
+    return getMessageListApi({
+      ...params,
+      auditStatus: 1,
+    });
+  }
+);
 
 const show = computed({
   get() {
@@ -52,6 +75,28 @@ const show = computed({
     emits("update:visible", v);
   },
 });
+
+const load = () => {
+  if (!noMore.value && !loading.value) {
+    getList();
+    pageNum.value++;
+  }
+};
+
+const handleSubmit = async () => {
+  try {
+    btnLoading.value = true;
+    await saveMessageApi({
+      bookId: detailStore.detail.id,
+      createBy: userInfo.value.nickName,
+      content: message.value,
+    });
+    ElMessage.success("提交成功,等待审核");
+    message.value = "";
+  } finally {
+    btnLoading.value = false;
+  }
+};
 </script>
 
 <style lang="scss" scoped>
@@ -60,6 +105,7 @@ const show = computed({
 
   &-item {
     &:not(:last-child) {
+      margin-bottom: 20px;
       border-bottom: 1px solid #d9d9d9;
     }
     &-header {

+ 50 - 10
packages/pc/src/views/Detail/components/Note.vue

@@ -1,14 +1,24 @@
 <template>
   <Drawer v-model:visible="show" title="笔记" @close="close">
     <div class="note">
-      <p style="margin-bottom: 3px">总数:2</p>
+      <p style="margin-bottom: 3px">总数:{{ noteList.length }}</p>
 
-      <div class="note-item" @click="openEdit">
-        <p class="note-item__date">2024-08-18</p>
-        <p class="note-item__inner limit-line line-2">
-          像一条绿色的玉带,从南到北缓缓穿越湖南全省,注入中国第二大淡水湖洞庭湖。
+      <div
+        v-for="item in noteList"
+        :key="item.id"
+        class="note-item"
+        @click="openEdit(item)"
+      >
+        <p class="note-item__date">
+          {{ formatDate(item.createTime, "YYYY-MM-DD") }}
         </p>
-        <p class="note-item__note">暂无笔记</p>
+        <p
+          class="note-item__inner limit-line line-2"
+          :style="{ borderColor: item.content?.color }"
+        >
+          {{ item.content?.text }}
+        </p>
+        <p class="note-item__note">{{ item.content?.note || "暂无笔记" }}</p>
       </div>
     </div>
   </Drawer>
@@ -20,15 +30,22 @@
     class="note-edit-drawer"
   >
     <div class="note-edit">
-      <p class="note-item__date">2024-08-18</p>
-      <p class="note-item__inner">
-        像一条绿色的玉带,从南到北缓缓穿越湖南全省,注入中国第二大淡水湖洞庭湖。
+      <p class="note-item__date">
+        {{ formatDate(checkedItem.createTime, "YYYY-MM-DD") }}
+      </p>
+      <p
+        class="note-item__inner"
+        :style="{ borderColor: checkedItem.content?.color }"
+      >
+        {{ checkedItem.content.text }}
       </p>
 
       <el-input
+        v-model="input"
         type="textarea"
         style="margin-top: 10px"
         :autosize="{ minRows: 8 }"
+        @blur="handleBlur"
       />
     </div>
   </Drawer>
@@ -36,11 +53,19 @@
 
 <script setup>
 import { computed, ref } from "vue";
+import { storeToRefs } from "pinia";
+import { formatDate } from "@dage/utils";
+import { saveLabelApi } from "@lsq/base";
+import { useDetailStore } from "@/stores";
 import Drawer from "./Drawer.vue";
 
 const props = defineProps(["visible"]);
 const emits = defineEmits(["update:visible", "close"]);
+const detailStore = useDetailStore();
+const { noteList } = storeToRefs(detailStore);
 const editVisible = ref(false);
+const checkedItem = ref(null);
+const input = ref("");
 
 const show = computed({
   get() {
@@ -51,7 +76,9 @@ const show = computed({
   },
 });
 
-const openEdit = () => {
+const openEdit = (item) => {
+  checkedItem.value = item;
+  input.value = item.content.note || "";
   editVisible.value = true;
 };
 
@@ -59,6 +86,19 @@ const close = () => {
   editVisible.value = false;
   emits("close");
 };
+
+const handleBlur = () => {
+  saveLabelApi({
+    id: checkedItem.value.id,
+    type: "note",
+    bookId: checkedItem.value.bookId,
+    content: JSON.stringify({
+      ...checkedItem.value.content,
+      note: input.value,
+    }),
+  });
+  checkedItem.value.content.note = input.value;
+};
 </script>
 
 <style lang="scss" scoped>

+ 74 - 9
packages/pc/src/views/Detail/components/Reader/index.vue

@@ -1,5 +1,5 @@
 <template>
-  <div class="detail-text">
+  <div class="detail-text" v-loading="bookLoading">
     <div id="reader" />
 
     <Teleport v-if="selectMenuStyle.visibility === 'visible'" to=".epub-view">
@@ -62,6 +62,10 @@
           () => {
             hideSelectMenu();
             epubStore.nextPage();
+            if (isLogin && detail && typeof detail.isCollect !== 'number') {
+              // 标记为有阅读过
+              updateBookCollectApi(detail.id);
+            }
           }
         "
       >
@@ -73,23 +77,31 @@
 </template>
 
 <script setup>
-import { onMounted, ref } from "vue";
+import { onMounted, ref, watch } from "vue";
 import { ElNotification } from "element-plus";
 import useClipboard from "vue-clipboard3";
 import { ArrowLeft, ArrowRight } from "@element-plus/icons-vue";
-import { useEpubStore, useDetailStore } from "@/stores";
+import { useEpubStore, useDetailStore, useBaseStore } from "@/stores";
 import { COLOR_MAP } from "./constants";
-import { getBaseUrl } from "@lsq/base";
+import {
+  getBaseUrl,
+  updateBookCollectApi,
+  saveLabelApi,
+  deleteLabelApi,
+} from "@lsq/base";
+import { storeToRefs } from "pinia";
 
-const props = defineProps(["detail"]);
+const props = defineProps(["detail", "isLogin"]);
 
+const baseStore = useBaseStore();
 const epubStore = useEpubStore();
 const detailStore = useDetailStore();
+const { isLogin } = storeToRefs(baseStore);
 const { toClipboard } = useClipboard();
 const selectMenuStyle = ref({});
 const selectedCfi = ref("");
-const selectedCfiStack = ref([]);
 const baseUrl = getBaseUrl();
+const bookLoading = ref(true);
 
 onMounted(() => {
   epubStore.init({
@@ -110,8 +122,30 @@ onMounted(() => {
   epubStore.rendition.on("selected", (v) => {
     selectedCfi.value = v;
   });
+
+  epubStore.rendition.on("rendered", () => {
+    bookLoading.value = false;
+
+    isLogin.value && getLabelList();
+  });
 });
 
+const getLabelList = () => {
+  detailStore.getLabelList().then(() => {
+    detailStore.noteList.forEach((item) => {
+      epubStore.rendition?.annotations.highlight(
+        item.content.cfi,
+        {},
+        () => {},
+        "",
+        {
+          fill: item.content.color,
+        }
+      );
+    });
+  });
+};
+
 const hideSelectMenu = () => {
   selectMenuStyle.value = { visibility: "hidden" };
 };
@@ -143,7 +177,9 @@ const selected = (e, v) => {
   selectMenuStyle.value = sty;
 };
 
-const setHighlight = (color) => {
+const setHighlight = async (color) => {
+  if (!baseStore.loginValidator()) return;
+
   epubStore.rendition.annotations.highlight(
     selectedCfi.value,
     {},
@@ -155,11 +191,30 @@ const setHighlight = (color) => {
   );
 
   hideSelectMenu();
-  selectedCfiStack.value.push(selectedCfi.value);
+  const data = await saveLabelApi({
+    type: "note",
+    bookId: props.detail.id,
+    content: JSON.stringify({
+      cfi: selectedCfi.value,
+      text: epubStore.rendition.getRange(selectedCfi.value).toString(),
+      color,
+    }),
+  });
+  detailStore.noteList.push({ ...data, content: JSON.parse(data.content) });
 };
 
 const removeHighlight = () => {
-  epubStore.rendition.annotations.remove(selectedCfi.value, "highlight");
+  if (!baseStore.loginValidator()) return;
+
+  const index = detailStore.noteList.findIndex(
+    (i) => i.content.cfi === selectedCfi.value
+  );
+  if (index > -1) {
+    const item = detailStore.noteList[index];
+    deleteLabelApi(item.id);
+    epubStore.rendition.annotations.remove(selectedCfi.value, "highlight");
+    detailStore.noteList.splice(index, 1);
+  }
   hideSelectMenu();
 };
 
@@ -191,6 +246,16 @@ const handleSearch = () => {
   detailStore.openSearchDrawer(selectedText);
   hideSelectMenu();
 };
+
+watch(isLogin, (v) => {
+  if (v) {
+    getLabelList();
+  } else {
+    detailStore.noteList.forEach((item) => {
+      epubStore.rendition.annotations.remove(item.content.cfi, "highlight");
+    });
+  }
+});
 </script>
 
 <style lang="scss" scoped>

+ 4 - 1
packages/pc/src/views/Detail/components/Toolbar/index.vue

@@ -29,7 +29,7 @@
 <script setup>
 import { ref, computed, watch } from "vue";
 import { useFullscreen } from "@vueuse/core";
-import { useEpubStore, useDetailStore } from "@/stores";
+import { useEpubStore, useDetailStore, useBaseStore } from "@/stores";
 import Directory from "../Directory.vue";
 import Setting from "../Setting.vue";
 import Search from "../Search.vue";
@@ -40,6 +40,7 @@ import Comment from "../Comment.vue";
 const { isFullscreen, isSupported, toggle } = useFullscreen();
 const epubStore = useEpubStore();
 const detailStore = useDetailStore();
+const baseStore = useBaseStore();
 const active = ref(-1);
 const directoryVisible = ref(false);
 const settingVisible = ref(false);
@@ -106,6 +107,8 @@ const list = computed(() => {
 });
 
 const handleToolbar = (key, idx) => {
+  if (["note", "bookmark"].includes(key) && !baseStore.loginValidator()) return;
+
   switch (key) {
     case "directory":
       directoryVisible.value = true;

+ 18 - 8
packages/pc/src/views/Detail/index.vue

@@ -4,7 +4,7 @@
     :container-color="paneBgColor"
     :simple="isPhotocopy"
   >
-    <component v-if="detail" :is="comp" :detail="detail" />
+    <component v-if="detail" :is="comp" :is-login="isLogin" :detail="detail" />
   </page-pane>
 
   <toolbar v-if="route.params.type === 'reader'" />
@@ -12,11 +12,11 @@
 </template>
 
 <script setup>
-import { computed, ref, onMounted } from "vue";
+import { computed, ref, watch } from "vue";
 import { useRoute } from "vue-router";
-import { THEMES, getBookDetailApi } from "@lsq/base";
+import { THEMES, getBookDetailApi, getBookDetail2Api } from "@lsq/base";
 import { storeToRefs } from "pinia";
-import { useEpubStore, useDetailStore } from "@/stores";
+import { useEpubStore, useDetailStore, useBaseStore } from "@/stores";
 import Toolbar from "./components/Toolbar/index.vue";
 import Reader from "./components/Reader/index.vue";
 import Photocopy from "./components/Photocopy/index.vue";
@@ -24,8 +24,10 @@ import DetailVideo from "./components/Video/index.vue";
 import IntroductionDialog from "./components/IntroductionDialog.vue";
 
 const route = useRoute();
+const baseStore = useBaseStore();
 const epubStore = useEpubStore();
 const detailStore = useDetailStore();
+const { isLogin } = storeToRefs(baseStore);
 const { introductionVisible, detail } = storeToRefs(detailStore);
 
 const loading = ref(false);
@@ -48,16 +50,24 @@ const comp = computed(() => {
 const getBookDetail = async () => {
   try {
     loading.value = true;
-    const data = await getBookDetailApi(route.params.id);
+    const data = await (isLogin.value ? getBookDetail2Api : getBookDetailApi)(
+      route.params.id
+    );
     detail.value = data;
   } finally {
     loading.value = false;
   }
 };
 
-onMounted(() => {
-  getBookDetail();
-});
+watch(
+  isLogin,
+  () => {
+    getBookDetail();
+  },
+  {
+    immediate: true,
+  }
+);
 </script>
 
 <style lang="scss" scoped>

+ 5 - 0
packages/pc/src/views/Home2/index.scss

@@ -42,6 +42,11 @@
         }
       }
     }
+    &-main {
+      display: flex;
+      align-items: center;
+      gap: 85px;
+    }
 
     &__pick {
       width: 131px;

+ 55 - 4
packages/pc/src/views/Home2/index.vue

@@ -6,7 +6,8 @@
           <div>
             <p>我的书架</p>
             <p>
-              <span>5</span>本<img
+              <span>{{ collectedCount }}</span
+              >本<img
                 draggable="false"
                 src="./images/icon_more-min.png"
                 @click="$router.push({ name: 'bookshelf' })"
@@ -18,8 +19,22 @@
         </div>
 
         <div class="home2-top-main">
-          <book-card type="column" />
+          <book-card
+            v-for="item in bookList.slice(0, 4)"
+            :key="item.id"
+            :item="item"
+            type="column"
+            :show-like="item.isCollect === 1"
+            :show-read="item.isCollect === 0"
+          />
         </div>
+
+        <el-empty
+          v-if="!bookList.length && !bookLoading"
+          description="暂无收藏"
+        >
+          <template #image><div /></template>
+        </el-empty>
       </div>
     </div>
 
@@ -50,10 +65,15 @@
 </template>
 
 <script setup>
-import { ref, onMounted } from "vue";
+import { ref, onMounted, watch } from "vue";
 import { storeToRefs } from "pinia";
 import { useBaseStore } from "@/stores";
-import { getRecommendListApi, getReadListApi } from "@lsq/base";
+import {
+  getRecommendListApi,
+  getReadListApi,
+  getMyBookListApi,
+  getUserCollectedBookCountApi,
+} from "@lsq/base";
 import BookCard from "@/components/BookCard/index.vue";
 import RankPanel from "@/components/RankPanel/index.vue";
 import News from "./components/News.vue";
@@ -64,12 +84,31 @@ const recommendLoading = ref(false);
 const recommendList = ref([]);
 const readLoading = ref(false);
 const readList = ref([]);
+const bookLoading = ref(false);
+const bookList = ref([]);
+const collectedCount = ref("-");
 
 onMounted(() => {
   getRecommendList();
   getReadList();
+  getUserCollectedBookCount();
 });
 
+const getUserCollectedBookCount = async () => {
+  const data = await getUserCollectedBookCountApi();
+  collectedCount.value = data;
+};
+
+const getMyBookList = async () => {
+  try {
+    bookLoading.value = true;
+    const data = await getMyBookListApi();
+    bookList.value = data;
+  } finally {
+    bookLoading.value = false;
+  }
+};
+
 const getReadList = async () => {
   try {
     readLoading.value = true;
@@ -93,6 +132,18 @@ const getRecommendList = async () => {
     recommendLoading.value = false;
   }
 };
+
+watch(
+  isLogin,
+  () => {
+    if (isLogin.value) {
+      getMyBookList();
+    }
+  },
+  {
+    immediate: true,
+  }
+);
 </script>
 
 <style lang="scss" scoped>

+ 1 - 1
packages/pc/vite.config.js

@@ -19,7 +19,7 @@ export default defineConfig({
     port: 80,
     proxy: {
       "/api": {
-        target: "https://sit-liushaoqibwg.4dage.com",
+        target: "http://192.168.20.61:8072",
         changeOrigin: true,
       },
     },