chenlei 9 bulan lalu
induk
melakukan
c1e13db7d5
51 mengubah file dengan 1714 tambahan dan 393 penghapusan
  1. 11 0
      packages/base/src/utils/index.js
  2. 3 0
      packages/mobile/package.json
  3. 48 4
      packages/mobile/src/App.vue
  4. 180 0
      packages/mobile/src/api/index.js
  5. 12 4
      packages/mobile/src/components/BookCard.vue
  6. 23 5
      packages/mobile/src/components/BookCard2.vue
  7. 1 21
      packages/mobile/src/components/NavBar.vue
  8. 2 1
      packages/mobile/src/components/SearchInput.vue
  9. 17 8
      packages/mobile/src/components/Tabbar/index.vue
  10. 15 4
      packages/mobile/src/configure.js
  11. 2 2
      packages/mobile/src/main.js
  12. 25 5
      packages/mobile/src/router/index.js
  13. 74 2
      packages/mobile/src/stores/base.js
  14. 0 14
      packages/mobile/src/stores/detail.js
  15. 1 1
      packages/mobile/src/stores/index.js
  16. 41 0
      packages/mobile/src/stores/reader.js
  17. 18 0
      packages/mobile/src/utils/index.js
  18. 78 27
      packages/mobile/src/views/Detail/components/ImgPane.vue
  19. 13 6
      packages/mobile/src/views/Detail/components/TextPane.vue
  20. 77 6
      packages/mobile/src/views/Detail/components/VideoPane.vue
  21. 17 0
      packages/mobile/src/views/Detail/index.scss
  22. 67 8
      packages/mobile/src/views/Detail/index.vue
  23. 27 8
      packages/mobile/src/views/Home/components/NewsDrawer.vue
  24. 85 0
      packages/mobile/src/views/Home/components/NewsList.vue
  25. 24 3
      packages/mobile/src/views/Home/components/RankPane.vue
  26. 8 8
      packages/mobile/src/views/Home/components/SearchPane.vue
  27. 1 25
      packages/mobile/src/views/Home/components/SecondPane/index.scss
  28. 49 22
      packages/mobile/src/views/Home/components/SecondPane/index.vue
  29. 1 1
      packages/mobile/src/views/Home/index.vue
  30. 49 2
      packages/mobile/src/views/ImgViewer/index.vue
  31. 45 4
      packages/mobile/src/views/Mine/components/EditNamePopup.vue
  32. 101 11
      packages/mobile/src/views/Mine/index.vue
  33. 61 23
      packages/mobile/src/views/Reader/components/BookmarkPopup.vue
  34. 71 17
      packages/mobile/src/views/Reader/components/CommentPopup.vue
  35. 8 4
      packages/mobile/src/views/Reader/components/MsgItem.vue
  36. 41 16
      packages/mobile/src/views/Reader/components/NotePopup.vue
  37. 10 10
      packages/mobile/src/views/Reader/components/SearchPopup.vue
  38. 4 4
      packages/mobile/src/views/Reader/components/ToolbarMenu.vue
  39. 32 5
      packages/mobile/src/views/Reader/components/ToolbarPopover.vue
  40. 65 9
      packages/mobile/src/views/Reader/index.vue
  41. 83 19
      packages/mobile/src/views/Search/components/FilterPopup.vue
  42. 64 22
      packages/mobile/src/views/Search/index.vue
  43. 24 8
      packages/mobile/src/views/Stack/components/ClassifyScroll.vue
  44. 1 1
      packages/mobile/src/views/Stack/index.scss
  45. 93 17
      packages/mobile/src/views/Stack/index.vue
  46. 1 1
      packages/pc/src/configure.js
  47. 1 1
      packages/pc/src/router/index.js
  48. 1 1
      packages/pc/src/stores/base.js
  49. 0 11
      packages/pc/src/utils/index.js
  50. 8 11
      packages/pc/src/views/Bookshelf/index.vue
  51. 31 11
      pnpm-lock.yaml

+ 11 - 0
packages/base/src/utils/index.js

@@ -1 +1,12 @@
 export * from "./usePagination";
+
+export const USERINFO_KEY = "userinfo";
+
+export const getUserInfo = () => {
+  let userInfoStorage = localStorage.getItem(USERINFO_KEY);
+  if (userInfoStorage) {
+    userInfoStorage = JSON.parse(userInfoStorage);
+  }
+
+  return userInfoStorage;
+};

+ 3 - 0
packages/mobile/package.json

@@ -9,7 +9,10 @@
     "preview": "vite preview"
   },
   "dependencies": {
+    "@dage/service": "^1.0.3",
+    "@dage/utils": "^1.0.2",
     "@lsq/base": "workspace:^",
+    "@vueuse/router": "^11.1.0",
     "lodash": "^4.17.21",
     "pinia": "^2.2.4",
     "swiper": "^11.1.14",

+ 48 - 4
packages/mobile/src/App.vue

@@ -1,20 +1,59 @@
 <script setup>
-import { computed } from "vue";
-import { useRoute, RouterView } from "vue-router";
+import { computed, onMounted } from "vue";
+import { useRoute, RouterView, useRouter } from "vue-router";
 import { storeToRefs } from "pinia";
 import Tabbar from "@/components/Tabbar/index.vue";
-import { useSettingStore } from "@/stores";
+import { wxLoginApi } from "@/api";
+import { useSettingStore, useBaseStore } from "@/stores";
 
 const route = useRoute();
+const router = useRouter();
 const settingStore = useSettingStore();
+const baseStore = useBaseStore();
 const { theme } = storeToRefs(settingStore);
 
 const tabbarVisible = computed(() => !route.meta.hideTabbar);
+
+onMounted(async () => {
+  const url = new URL(window.location.href);
+  const code = url.searchParams.get("code");
+  const state = url.searchParams.get("state");
+
+  if (code) {
+    try {
+      const data = await wxLoginApi(code);
+      baseStore.setLoginInfo(data);
+      state && router.replace(JSON.parse(state));
+    } catch (err) {
+      const url = new URL(window.location.href);
+
+      url.searchParams.delete("code");
+      url.searchParams.delete("state");
+
+      window.history.replaceState({}, document.title, url.toString());
+    }
+  } else if (baseStore.isLogin) {
+    baseStore.getUserInfo();
+  }
+});
 </script>
 
 <template>
   <VanConfigProvider :theme="theme">
-    <RouterView />
+    <router-view v-slot="{ Component }">
+      <keep-alive>
+        <component
+          :key="$route.name"
+          :is="Component"
+          v-if="$route.meta.keepAlive"
+        />
+      </keep-alive>
+      <component
+        :key="$route.name"
+        :is="Component"
+        v-if="!$route.meta.keepAlive"
+      />
+    </router-view>
 
     <Tabbar :visible="tabbarVisible" />
   </VanConfigProvider>
@@ -25,4 +64,9 @@ const tabbarVisible = computed(() => !route.meta.hideTabbar);
   --tabbar-height: 112px;
   --van-primary-color: #d3bfa2;
 }
+
+// FIX: 权重不够
+.van-notify--danger {
+  background: var(--van-notify-danger-background) !important;
+}
 </style>

+ 180 - 0
packages/mobile/src/api/index.js

@@ -0,0 +1,180 @@
+import { requestByGet, requestByPost } from "@dage/service";
+
+// 推荐列表
+export const getRecommendListApi = (params) => {
+  return requestByPost("/api/show/rank/getList", params);
+};
+
+// 图书-阅读排行
+export const getReadListApi = () => {
+  return requestByGet("/api/show/book/getVisit");
+};
+
+// 图书-总数
+export const getBookCountApi = () => {
+  return requestByGet("/api/show/book/count");
+};
+
+// 图书-详情 需要登录
+export const getBookDetail2Api = (bookId) => {
+  return requestByGet(`/api/wx/book/detail/${bookId}`, undefined, {
+    meta: {
+      showMobileLoading: true,
+    },
+  });
+};
+// 图书-详情
+export const getBookDetailApi = (id) => {
+  return requestByGet(`/api/show/book/detail/${id}`, undefined, {
+    meta: {
+      showMobileLoading: true,
+    },
+  });
+};
+
+// 图书列表-分页
+export const getBookListApi = (params) => {
+  return requestByPost("/api/show/book/pageList", params);
+};
+
+// 字典列表
+export const getDictListApi = () => {
+  return requestByGet("/api/show/dict/getList");
+};
+
+// 公告列表-分页
+export const getNoticeListApi = (params) => {
+  return requestByPost("/api/show/notice/pageList", params);
+};
+
+// 公告-保存访问量
+export const addNoticeVisitApi = (noticeId) => {
+  return requestByGet(`/api/show/notice/addVisit/${noticeId}`);
+};
+
+// 书籍分类
+export const getStorageTreeApi = () => {
+  return requestByGet("/api/show/storage/getTree", undefined, {
+    meta: {
+      showMobileLoading: true,
+    },
+  });
+};
+
+// 藏品分类
+export const getExhibitTypeListApi = (params) => {
+  return requestByPost("/api/show/exhibitType/getList", params, {
+    meta: {
+      showMobileLoading: true,
+    },
+  });
+};
+
+// 微信-登录
+export const wxLoginApi = (code) => {
+  return requestByGet(`/api/show/wx/mpLogin`, {
+    code,
+  });
+};
+export const fakeLoginApi = (id) => {
+  return requestByGet(`/api/show/test/login/${id}`, undefined, {
+    meta: {
+      showMobileLoading: true,
+      loadingText: "登录中",
+    },
+  });
+};
+
+// 图书-文件-上传
+export const uploadApi = (params) => {
+  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", undefined, {
+    meta: {
+      showMobileLoading: true,
+      loadingText: "登录中",
+    },
+  });
+};
+
+// 图书-我的收藏总数
+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);
+};
+
+// 留言-删除
+export const deleteMessageApi = (id) => {
+  return requestByGet(`/api/wx/message/remove/${id}`);
+};
+
+// 影印&视频-列表-分页
+export const getMediaListApi = (params) => {
+  return requestByPost("/api/show/video/pageList", params, {
+    meta: {
+      showMobileLoading: true,
+    },
+  });
+};

+ 12 - 4
packages/mobile/src/components/BookCard.vue

@@ -1,10 +1,10 @@
 <template>
   <div
     class="book-card"
-    @click="$router.push({ name: 'detail', params: { id: 1 } })"
+    @click="$router.push({ name: 'detail', params: { id: item.id } })"
   >
     <div class="book-card__cover">
-      <van-image src="" />
+      <van-image fit="cover" :src="baseUrl + item.thumb" />
 
       <img
         v-if="isLike"
@@ -14,12 +14,14 @@
       <span v-else-if="isRead" class="book-card__read">读过</span>
     </div>
 
-    <p class="book-card__title limit-line">刘少奇传</p>
-    <p class="book-card__sub limit-line">中共中央文献研究室 编</p>
+    <p class="book-card__title limit-line">{{ item.name }}</p>
+    <p class="book-card__sub limit-line">{{ item.press }} 编</p>
   </div>
 </template>
 
 <script setup>
+import { getBaseUrl } from "@/utils";
+
 defineProps({
   isLike: {
     type: Boolean,
@@ -29,7 +31,13 @@ defineProps({
     type: Boolean,
     default: false,
   },
+  item: {
+    type: Object,
+    required: true,
+  },
 });
+
+const baseUrl = getBaseUrl();
 </script>
 
 <style scoped lang="scss">

+ 23 - 5
packages/mobile/src/components/BookCard2.vue

@@ -1,12 +1,22 @@
 <template>
-  <div class="book-card2">
-    <van-image class="book-card2__cover" src="" :width="122" :height="157" />
+  <div
+    v-if="item"
+    class="book-card2"
+    @click="$router.push({ name: 'detail', params: { id: item.id } })"
+  >
+    <van-image
+      class="book-card2__cover"
+      fit="cover"
+      :src="baseUrl + item.thumb"
+      :width="122"
+      :height="157"
+    />
 
     <div class="book-card2-inner">
-      <h3 class="limit-line">刘少奇传-上</h3>
+      <h3 class="limit-line">{{ item.name }}</h3>
       <div class="book-card2-inner__info">
-        <p class="limit-line">金冲及</p>
-        <p class="limit-line">中共中央文献研究室 编</p>
+        <p class="limit-line">{{ item.author }}</p>
+        <p class="limit-line">{{ item.press }} 编</p>
       </div>
       <div class="book-card2-inner__read">
         <span>开始阅读</span>
@@ -16,6 +26,14 @@
   </div>
 </template>
 
+<script setup>
+import { getBaseUrl } from "@/utils";
+
+defineProps(["item"]);
+
+const baseUrl = getBaseUrl();
+</script>
+
 <style lang="scss" scoped>
 /* prettier-ignore */
 .book-card2 {

+ 1 - 21
packages/mobile/src/components/NavBar.vue

@@ -16,10 +16,7 @@
       @click="$router.back()"
     />
 
-    <div class="nav-bar__tag">
-      <img src="@/assets/images/icon_like_normal@2x-min.png" />
-      <span>加入书架</span>
-    </div>
+    <slot />
   </div>
 </template>
 
@@ -74,22 +71,5 @@ const handleScroll = debounce(() => {
   &__back {
     margin-left: 30px;
   }
-  &__tag {
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    gap: 5px;
-    width: 250px;
-    height: 77px;
-    color: white;
-    border-top-left-radius: 50px;
-    border-bottom-left-radius: 50px;
-    background: rgba(0, 0, 0, 0.3);
-
-    img {
-      width: 60px;
-      height: 60px;
-    }
-  }
 }
 </style>

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

@@ -1,10 +1,11 @@
 <template>
   <div class="search-input" :class="{ simple }">
     <input
-      :value="modelValue"
       type="search"
       placeholder="请输入关键词..."
+      :value="modelValue"
       @input="emits('update:modelValue', $event.target.value)"
+      @keyup.enter="emits('update:modelValue', $event.target.value)"
     />
     <div class="search-input__icon" @click="emits('search')">
       <svg-icon

+ 17 - 8
packages/mobile/src/components/Tabbar/index.vue

@@ -5,13 +5,7 @@
       :key="item.label"
       class="tabbar-item"
       :class="{ active: index === activeIndex }"
-      @click="
-        $router.push({
-          name: Array.isArray(item.routeName)
-            ? item.routeName[0]
-            : item.routeName,
-        })
-      "
+      @click="handleClick(item)"
     >
       <img :src="index === activeIndex ? item.activeIcon : item.icon" />
       <p>{{ item.label }}</p>
@@ -21,7 +15,8 @@
 
 <script setup>
 import { computed } from "vue";
-import { useRoute } from "vue-router";
+import { useRoute, useRouter } from "vue-router";
+import { useBaseStore } from "@/stores";
 import HomeIcon from "./images/icon_home_normal@2x-min.png";
 import HomeActiveIcon from "./images/icon_home_active@2x-min.png";
 import BookIcon from "./images/icon_book_normal@2x-min.png";
@@ -57,7 +52,10 @@ const LIST = [
   },
 ];
 
+const baseStore = useBaseStore();
+
 const route = useRoute();
+const router = useRouter();
 const activeIndex = computed(() => {
   return LIST.findIndex((item) =>
     Array.isArray(item.routeName)
@@ -65,6 +63,17 @@ const activeIndex = computed(() => {
       : item.routeName === route.name
   );
 });
+
+const handleClick = (item) => {
+  if (item.routeName === "mine" && !baseStore.isLogin) {
+    baseStore.login({ name: item.routeName });
+    return;
+  }
+
+  router.push({
+    name: Array.isArray(item.routeName) ? item.routeName[0] : item.routeName,
+  });
+};
 </script>
 
 <style lang="scss" scoped>

+ 15 - 4
packages/mobile/src/configure.js

@@ -1,8 +1,9 @@
 import { compose, initial } from "@dage/service";
 import { showNotify, showLoadingToast } from "vant";
-import "vant/lib/notify/style/index";
-import "vant/lib/toast/style/index";
+import { getUserInfo } from "@lsq/base";
 import router from "./router";
+import "vant/es/notify/style";
+import "vant/es/toast/style";
 
 const showMessage = (msg, type = "danger") => {
   showNotify({
@@ -18,14 +19,24 @@ initial({
   interceptor: compose(async (request, next) => {
     let toast = null;
     try {
-      const { showError = true, showMobileLoading } = request.meta;
+      const {
+        showError = true,
+        showMobileLoading,
+        loadingText = "加载中...",
+      } = request.meta;
       if (showMobileLoading) {
         toast = showLoadingToast({
           duration: 0,
-          message: "加载中...",
+          message: loadingText,
           forbidClick: true,
         });
       }
+
+      const userInfo = getUserInfo();
+      if (userInfo) {
+        request.headers["token"] = userInfo.token;
+      }
+
       const response = await next();
 
       if (response.code !== 0 && showError) {

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

@@ -15,8 +15,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);
 

+ 25 - 5
packages/mobile/src/router/index.js

@@ -1,4 +1,5 @@
 import { createRouter, createWebHashHistory } from "vue-router";
+import { getUserInfo } from "@lsq/base";
 
 const router = createRouter({
   history: createWebHashHistory(import.meta.env.BASE_URL),
@@ -7,7 +8,9 @@ const router = createRouter({
       path: "/",
       name: "home",
       component: () => import("@/views/Home/index.vue"),
-      meta: {},
+      meta: {
+        keepAlive: true,
+      },
     },
     {
       path: "/home",
@@ -19,13 +22,17 @@ const router = createRouter({
       path: "/stack",
       name: "stack",
       component: () => import("@/views/Stack/index.vue"),
-      meta: {},
+      meta: {
+        keepAlive: true,
+      },
     },
     {
       path: "/mine",
       name: "mine",
       component: () => import("@/views/Mine/index.vue"),
-      meta: {},
+      meta: {
+        keepAlive: true,
+      },
     },
     {
       path: "/search",
@@ -44,7 +51,7 @@ const router = createRouter({
       },
     },
     {
-      path: "/img-viewer",
+      path: "/img-viewer/:id",
       name: "imgViewer",
       component: () => import("@/views/ImgViewer/index.vue"),
       meta: {
@@ -52,7 +59,7 @@ const router = createRouter({
       },
     },
     {
-      path: "/reader",
+      path: "/reader/:id",
       name: "reader",
       component: () => import("@/views/Reader/index.vue"),
       meta: {
@@ -62,4 +69,17 @@ const router = createRouter({
   ],
 });
 
+router.beforeEach((to, from, next) => {
+  const userInfo = getUserInfo();
+
+  if (to.meta.needLogin && !userInfo) {
+    next({
+      name: "home",
+    });
+    return;
+  }
+
+  next();
+});
+
 export default router;

+ 74 - 2
packages/mobile/src/stores/base.js

@@ -1,8 +1,80 @@
 import { ref } from "vue";
 import { defineStore } from "pinia";
+import { getUserInfoApi, logoutApi, fakeLoginApi } from "@/api";
+import { getUserInfo, USERINFO_KEY } from "@lsq/base";
+import { useRouter } from "vue-router";
+import { isDevelopment } from "@/utils";
+
+const userInfoStorage = getUserInfo();
 
 export const useBaseStore = defineStore("base", () => {
-  const isLogin = ref(false);
+  const isLogin = ref(Boolean(userInfoStorage));
+  const userInfo = ref(userInfoStorage ? userInfoStorage.user : null);
+  const token = ref(userInfoStorage ? userInfoStorage.token : null);
+  const router = useRouter();
+
+  const login = async (routeParams) => {
+    if (isDevelopment) {
+      const data = await fakeLoginApi(1);
+      isLogin.value = true;
+      userInfo.value = data.user;
+      token.value = data.token;
+      localStorage.setItem(USERINFO_KEY, JSON.stringify(data));
+
+      router.push(routeParams);
+    } else {
+      location.href = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=wxc09c15943347c4a4&redirect_uri=${
+        location.origin + location.pathname
+      }&state=${
+        routeParams
+          ? JSON.stringify(routeParams)
+          : JSON.stringify({ path: location.hash })
+      }&response_type=code&scope=snsapi_userinfo#wechat_redirect`;
+    }
+  };
+
+  const setLoginInfo = (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 loginValidator = () => {
+    if (isLogin.value) return true;
+
+    login();
+    return false;
+  };
+
+  const getUserInfo = async () => {
+    const data = await getUserInfoApi();
+    userInfo.value = data;
+    localStorage.setItem(
+      USERINFO_KEY,
+      JSON.stringify({
+        token: token.value,
+        user: data,
+      })
+    );
+  };
 
-  return { isLogin };
+  return {
+    isLogin,
+    userInfo,
+    token,
+    loginValidator,
+    setLoginInfo,
+    login,
+    logout,
+    getUserInfo,
+  };
 });

+ 0 - 14
packages/mobile/src/stores/detail.js

@@ -1,14 +0,0 @@
-import { ref } from "vue";
-import { defineStore } from "pinia";
-
-export const useDetailStore = defineStore("detail", () => {
-  const searchVisible = ref(false);
-  const searchKey = ref("");
-
-  const openSearchDrawer = (key = "") => {
-    searchKey.value = key;
-    searchVisible.value = true;
-  };
-
-  return { searchVisible, searchKey, openSearchDrawer };
-});

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

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

+ 41 - 0
packages/mobile/src/stores/reader.js

@@ -0,0 +1,41 @@
+import { ref } from "vue";
+import { defineStore } from "pinia";
+import { getLabelListApi } from "@/api";
+
+export const useReaderStore = defineStore("reader", () => {
+  const searchVisible = ref(false);
+  const searchKey = ref("");
+  const noteList = ref([]);
+
+  const openSearchDrawer = (key = "") => {
+    searchKey.value = key;
+    searchVisible.value = true;
+  };
+
+  const clear = () => {
+    noteList.value = [];
+    searchKey.value = "";
+    searchVisible.value = false;
+  };
+
+  const getLabelList = async (id) => {
+    const data = await getLabelListApi(id, "note");
+    noteList.value = data.map((item) =>
+      item.content
+        ? {
+            ...item,
+            content: JSON.parse(item.content),
+          }
+        : item
+    );
+  };
+
+  return {
+    noteList,
+    searchVisible,
+    searchKey,
+    clear,
+    getLabelList,
+    openSearchDrawer,
+  };
+});

+ 18 - 0
packages/mobile/src/utils/index.js

@@ -0,0 +1,18 @@
+import { getBaseURL } from "@dage/service";
+
+export const isDevelopment = import.meta.env.MODE === "development";
+
+export const getBaseUrl = () => {
+  const baseUrl = getBaseURL();
+  return baseUrl;
+  // return `${baseUrl}${isDevelopment ? "/api" : ""}`;
+};
+
+export const getFixUrl = (path) => {
+  const isHttp = path.startsWith("http");
+  if (isHttp) {
+    return path;
+  }
+  const base = getBaseUrl();
+  return base + path;
+};

+ 78 - 27
packages/mobile/src/views/Detail/components/ImgPane.vue

@@ -1,41 +1,92 @@
 <template>
   <div class="img-pane">
-    <swiper
-      :slides-per-view="1.25"
-      :slides-offset-before="35"
-      :slides-offset-after="35"
-      :space-between="15"
-      @swiper="(e) => (swiperRef = e)"
-    >
-      <swiper-slide v-for="item in 8" :key="item">
-        <van-image class="img-pane__img" src="" />
-      </swiper-slide>
-    </swiper>
+    <template v-if="!noData">
+      <swiper
+        :slides-per-view="1.25"
+        :slides-offset-before="35"
+        :slides-offset-after="35"
+        :space-between="15"
+        @swiper="(e) => (swiperRef = e)"
+      >
+        <swiper-slide v-for="(item, idx) in list" :key="item.id">
+          <van-image
+            class="img-pane__img"
+            :src="item.thumb"
+            fit="cover"
+            @click="
+              showImagePreview({
+                startPosition: idx,
+                images: list.map((i) => i.thumb),
+              })
+            "
+          />
+        </swiper-slide>
+      </swiper>
 
-    <img
-      class="img-pane__prev-icon"
-      src="../images/icon_left@2x-min.png"
-      @click="swiperRef.slidePrev()"
-    />
-    <img
-      class="img-pane__next-icon"
-      src="../images/icon_right@2x-min.png"
-      @click="swiperRef.slideNext()"
-    />
+      <img
+        class="img-pane__prev-icon"
+        src="../images/icon_left@2x-min.png"
+        @click="swiperRef.slidePrev()"
+      />
+      <img
+        class="img-pane__next-icon"
+        src="../images/icon_right@2x-min.png"
+        @click="swiperRef.slideNext()"
+      />
 
-    <div class="img-pane-footer">
-      <van-button type="primary" @click="$router.push({ name: 'imgViewer' })"
-        >查看大图</van-button
-      >
-    </div>
+      <div v-if="detail" class="img-pane-footer">
+        <van-button
+          type="primary"
+          @click="
+            $router.push({ name: 'imgViewer', params: { id: detail.id } })
+          "
+          >查看大图</van-button
+        >
+      </div>
+    </template>
+
+    <van-empty v-else description="暂无数据" image-size="0" />
   </div>
 </template>
 
 <script setup>
-import { ref } from "vue";
+import { ref, watch } from "vue";
+import { showImagePreview } from "vant";
+import "vant/es/image-preview/style";
 import { Swiper, SwiperSlide } from "swiper/vue";
+import { usePagination } from "@lsq/base";
+import { getMediaListApi } from "@/api";
+import { getBaseUrl } from "@/utils";
 
+const props = defineProps(["detail"]);
+const baseUrl = getBaseUrl();
 const swiperRef = ref(null);
+
+const { list, noData, getList } = usePagination(async (params) => {
+  const data = await getMediaListApi({
+    ...params,
+    pageSize: 999,
+    bookId: props.detail.id,
+    type: "img",
+  });
+
+  data.records = data.records.map((i) => ({
+    ...i,
+    thumb: baseUrl + i.thumb,
+  }));
+
+  return data;
+});
+
+watch(
+  () => props.detail,
+  () => {
+    props.detail && getList();
+  },
+  {
+    immediate: true,
+  }
+);
 </script>
 
 <style lang="scss" scoped>

+ 13 - 6
packages/mobile/src/views/Detail/components/TextPane.vue

@@ -1,19 +1,26 @@
 <template>
   <div class="text-pane">
-    <p>
-      蜿蜒曲折的湘江,像一条绿色的玉带,从南到北缓缓穿越湖南全省,注入中国第二大淡水湖洞庭湖。在湘江西侧的宁乡县境内,有一个普普通通的小山村,叫炭子冲。相传在很久以前,这一带有不少人以伐木烧炭为生,是烧炭人居住和落脚的地方,因此得名炭子冲。
-      “冲”​,是湖南老百姓对山间小块平原的称呼。炭子冲,就是一块夹在两座山岭之间的平地。它的北面背靠着连绵不绝的丘陵,东西两边是长满了密密层层各色杂树的山坡,南面是平坦的农田和宁静的池塘。湘江的支流靳江,在它的西南角不远处淙淙流过。顺着冲口的大路往东北方向行进,约莫四十来公里,便到了湘江。湘江对岸,就是湖南省省会长沙。
-      炭子冲在行政建制上属于湖南省宁乡县花明楼乡。这一带有山有水,盛产稻米、林木、烟叶,是湖南中部较为富庶的地区。由于这里离省会和县城都不远,交通便利,外面的信息容易传播进来,文化也比较发达。
-    </p>
+    <van-empty
+      v-if="!detail || !detail?.description"
+      description="暂无内容"
+      image-size="0"
+    />
+    <div v-else v-html="detail?.description" />
 
     <div class="text-pane-footer">
-      <van-button type="primary" @click="$router.push({ name: 'reader' })"
+      <van-button
+        type="primary"
+        @click="$router.push({ name: 'reader', params: { id: detail?.id } })"
         >开始阅读</van-button
       >
     </div>
   </div>
 </template>
 
+<script setup>
+defineProps(["detail"]);
+</script>
+
 <style lang="scss" scoped>
 .text-pane {
   position: relative;

+ 77 - 6
packages/mobile/src/views/Detail/components/VideoPane.vue

@@ -1,18 +1,89 @@
 <template>
-  <div class="video-pane">
-    <div v-for="item in 8" :key="item" class="video-item">
-      <van-image class="video-item__cover" src="" />
+  <van-list
+    v-model:loading="loading"
+    :finished="noMore"
+    :immediate-check="false"
+    finished-text="没有更多了"
+    class="video-pane"
+    @load="onLoad"
+  >
+    <div
+      v-for="item in list"
+      :key="item.id"
+      class="video-item"
+      @click="
+        () => {
+          checkedItem = item;
+          videoVisible = true;
+        }
+      "
+    >
+      <van-image class="video-item__cover" :src="baseUrl + item.thumb" />
 
       <div class="video-item-inner">
-        <h3>学习求索</h3>
+        <h3>{{ item.name }}</h3>
         <p class="limit-line line-3">
-          刘少奇(1898年11月24日-1969年11月12日),生于湖南省宁乡县,伟大的马克思主义者,伟大的无产阶级革命家、政治家、理论家,党和国家主要领导人之一,中华人民共和国开国元勋,是以毛泽东同志为核心的党的第一代中央领导集体的重要成员
+          {{ item.description }}
         </p>
       </div>
     </div>
-  </div>
+  </van-list>
+
+  <van-overlay
+    :show="videoVisible"
+    :custom-style="{
+      display: 'flex',
+      alignItems: 'center',
+      justifyContent: 'center',
+    }"
+    @click="videoVisible = false"
+  >
+    <video
+      v-if="videoVisible"
+      controls
+      autoplay
+      :src="baseUrl + checkedItem?.filePath"
+      style="width: 100%"
+      @click.stop=""
+    />
+  </van-overlay>
 </template>
 
+<script setup>
+import { watch, ref } from "vue";
+import { usePagination } from "@lsq/base";
+import { getMediaListApi } from "@/api";
+import { getBaseUrl } from "@/utils";
+
+const baseUrl = getBaseUrl();
+const props = defineProps(["detail"]);
+const videoVisible = ref(false);
+const checkedItem = ref(null);
+
+const { pageNum, loading, list, noMore, getList } = usePagination((params) =>
+  getMediaListApi({
+    ...params,
+    bookId: props.detail.id,
+    type: "video",
+  })
+);
+
+const onLoad = () => {
+  pageNum.value++;
+  getList();
+};
+
+watch(
+  () => props.detail,
+  () => {
+    props.detail && getList();
+  },
+  {
+    immediate: true,
+  }
+);
+</script>
+
 <style lang="scss" scoped>
 .video-pane {
   display: flex;

+ 17 - 0
packages/mobile/src/views/Detail/index.scss

@@ -1,4 +1,21 @@
 .detail {
+  &__tag {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 5px;
+    width: 250px;
+    height: 77px;
+    color: white;
+    border-top-left-radius: 50px;
+    border-bottom-left-radius: 50px;
+    background: rgba(0, 0, 0, 0.3);
+
+    img {
+      width: 60px;
+      height: 60px;
+    }
+  }
   &-top {
     position: relative;
     height: 450px;

+ 67 - 8
packages/mobile/src/views/Detail/index.vue

@@ -1,15 +1,24 @@
 <template>
   <div class="detail">
-    <nav-bar />
+    <nav-bar>
+      <div class="detail__tag" @click="handleCollect">
+        <img :src="isCollect ? CollectIcon : UnCollectIcon" />
+        <span>{{ isCollect ? "移除书架" : "加入书架" }}</span>
+      </div>
+    </nav-bar>
 
     <div class="detail-top">
       <div class="detail-info">
-        <van-image class="detail-info__cover" src="" />
+        <van-image
+          class="detail-info__cover"
+          :src="baseUrl + detail?.thumb"
+          fit="cover"
+        />
 
         <div class="detail-info-inner">
-          <h3>刘少奇传-上</h3>
-          <p>金冲及</p>
-          <p>中共中央文献研究室 编</p>
+          <h3>{{ detail?.name }}</h3>
+          <p>{{ detail?.author }}</p>
+          <p>{{ detail?.press }} 编</p>
 
           <div class="detail-info-tabs">
             <div
@@ -30,14 +39,25 @@
     <div class="detail-main">
       <h3>{{ TABS[activeTab].mainTitle }}</h3>
 
-      <component :is="comp" />
+      <component :is="comp" :detail="detail" />
     </div>
   </div>
 </template>
 
 <script setup>
-import { ref, computed } from "vue";
+import { useRoute } from "vue-router";
+import { ref, computed, watch } from "vue";
+import { storeToRefs } from "pinia";
+import { useRouteQuery } from "@vueuse/router";
+import { useBaseStore } from "@/stores";
+import {
+  getBookDetailApi,
+  getBookDetail2Api,
+  updateBookCollectApi,
+} from "@/api";
 import NavBar from "@/components/NavBar.vue";
+import { getBaseUrl } from "@/utils";
+
 import TextPane from "./components/TextPane.vue";
 import VideoPane from "./components/VideoPane.vue";
 import ImgPane from "./components/ImgPane.vue";
@@ -47,6 +67,8 @@ import videoIcon from "./images/icon_video_mb_n@2x-min.png";
 import videoAcIcon from "./images/icon_video_mb@2x-min.png";
 import ImgIcon from "./images/icon_img_mb_n@2x-min.png";
 import ImgAcIcon from "./images/icon_img_mb@2x-min.png";
+import UnCollectIcon from "@/assets/images/icon_like_normal@2x-min.png";
+import CollectIcon from "@/assets/images/icon_like_active@2x-min.png";
 
 const TABS = [
   {
@@ -72,7 +94,11 @@ const TABS = [
   },
 ];
 
-const activeTab = ref(0);
+const route = useRoute();
+const baseUrl = getBaseUrl();
+const baseStore = useBaseStore();
+const { isLogin } = storeToRefs(baseStore);
+const activeTab = useRouteQuery("tab", 0, { transform: Number });
 const comp = computed(() => {
   switch (TABS[activeTab.value].key) {
     case "video":
@@ -83,6 +109,39 @@ const comp = computed(() => {
       return TextPane;
   }
 });
+const isCollect = computed(() => detail.value?.isCollect === 1);
+const detail = ref(null);
+
+const getBookDetail = async () => {
+  const data = await (isLogin.value ? getBookDetail2Api : getBookDetailApi)(
+    route.params.id
+  );
+  detail.value = data;
+};
+
+const handleCollect = async () => {
+  if (!baseStore.loginValidator()) return;
+
+  const temp = isCollect.value;
+  try {
+    detail.value.isCollect = temp ? 0 : 1;
+    await updateBookCollectApi(detail.value.id, {
+      status: temp ? 0 : 1,
+    });
+  } catch (err) {
+    detail.value.isCollect = temp;
+  }
+};
+
+watch(
+  isLogin,
+  () => {
+    getBookDetail();
+  },
+  {
+    immediate: true,
+  }
+);
 </script>
 
 <style lang="scss" scoped>

File diff ditekan karena terlalu besar
+ 27 - 8
packages/mobile/src/views/Home/components/NewsDrawer.vue


+ 85 - 0
packages/mobile/src/views/Home/components/NewsList.vue

@@ -0,0 +1,85 @@
+<template>
+  <van-list
+    class="news-list"
+    v-model:loading="loading"
+    :finished="noMore"
+    @load="onLoad"
+  >
+    <div
+      v-for="item in list"
+      :key="item.id"
+      class="news-item"
+      @click="
+        () => {
+          checkedItem = item;
+          newsDetailVisible = true;
+        }
+      "
+    >
+      <span class="news-item__tag">【{{ item.dictName }}】</span>
+      <p class="limit-line">
+        {{ item.name }}
+      </p>
+      <span class="news-item__date">{{
+        formatDate(item.createTime, "YYYY-MM-DD")
+      }}</span>
+    </div>
+  </van-list>
+
+  <van-empty v-if="noData" image-size="0" description="暂无数据" />
+
+  <news-drawer v-model:show="newsDetailVisible" :item="checkedItem" />
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { PaginationType, usePagination } from "@lsq/base";
+import { formatDate } from "@dage/utils";
+import { getNoticeListApi } from "@/api";
+import NewsDrawer from "./NewsDrawer.vue";
+
+const newsDetailVisible = ref(false);
+const checkedItem = ref(null);
+
+const { pageNum, loading, list, noData, noMore, getList } = usePagination(
+  async (params) => {
+    return getNoticeListApi({
+      ...params,
+    });
+  },
+  PaginationType.CONCAT
+);
+
+const onLoad = () => {
+  pageNum.value++;
+  getList();
+};
+</script>
+
+<style lang="scss" scoped>
+.news {
+  &-list {
+    display: flex;
+    flex-direction: column;
+    gap: 38px;
+  }
+  &-item {
+    display: flex;
+    align-items: center;
+    font-size: 27px;
+
+    &__tag {
+      white-space: nowrap;
+      color: var(--van-primary-color);
+    }
+    &__date {
+      white-space: nowrap;
+      color: var(--text-color-placeholder);
+    }
+    p {
+      padding-right: 20px;
+      flex: 1;
+    }
+  }
+}
+</style>

+ 24 - 3
packages/mobile/src/views/Home/components/RankPane.vue

@@ -6,16 +6,27 @@
         <span>排行榜</span>
       </div>
 
-      <p class="rank-pane-header__more">More <span>+</span></p>
+      <p class="rank-pane-header__more" @click="emits('more')">
+        More <span>+</span>
+      </p>
     </div>
 
+    <van-loading v-if="loading" class="rank-pane__loading" />
+
+    <van-empty
+      v-if="!loading && !list.length"
+      image-size="0"
+      description="暂无数据"
+    />
+
     <swiper
+      v-else
       :slides-per-view="2.7"
       :slides-offset-before="25"
       :slides-offset-after="25"
     >
-      <swiper-slide v-for="item in 8" :key="item">
-        <book-card />
+      <swiper-slide v-for="item in list" :key="item.id">
+        <book-card :item="item" />
       </swiper-slide>
     </swiper>
   </div>
@@ -24,6 +35,10 @@
 <script setup>
 import { Swiper, SwiperSlide } from "swiper/vue";
 import BookCard from "@/components/BookCard.vue";
+
+defineProps(["loading", "list"]);
+
+const emits = defineEmits(["more"]);
 </script>
 
 <style lang="scss" scoped>
@@ -31,6 +46,12 @@ import BookCard from "@/components/BookCard.vue";
   position: relative;
   margin: 0 -55px;
 
+  &__loading {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 408px;
+  }
   &-header {
     display: flex;
     align-items: center;

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

@@ -8,7 +8,7 @@
         @search="$router.push({ name: 'search', query: { keyword } })"
       />
 
-      <div class="search-pane-more">
+      <div class="search-pane-more" @click="$router.push({ name: 'stack' })">
         <span class="limit-line">共收录{{ total }}件藏品,查看书库</span>
       </div>
     </div>
@@ -22,7 +22,7 @@
 
 <script setup>
 import { ref, onMounted } from "vue";
-import { getBookCountApi } from "@lsq/base";
+import { getBookCountApi } from "@/api";
 import SearchInput from "../../../components/SearchInput.vue";
 
 const total = ref("--");
@@ -40,17 +40,16 @@ onMounted(() => {
 
 <style lang="scss" scoped>
 .search-pane {
-  position: relative;
-  padding: 0 55px;
-  height: 100%;
-  z-index: 99;
-
   &-main {
-    position: relative;
+    position: absolute;
     top: 300px;
+    left: 0;
+    right: 0;
+    padding: 0 55px;
     display: flex;
     flex-direction: column;
     align-items: center;
+    z-index: 99;
 
     .logo {
       width: calc(100% - 80px);
@@ -76,6 +75,7 @@ onMounted(() => {
     gap: 17px;
     color: #a99271;
     transform: translateX(-50%);
+    z-index: 99;
 
     img {
       width: 60px;

+ 1 - 25
packages/mobile/src/views/Home/components/SecondPane/index.scss

@@ -1,7 +1,7 @@
 .second-pane {
   padding: 0 55px calc(var(--tabbar-height) + 80px);
   min-height: 100vh;
-  overflow-y: auto;
+  overflow: hidden;
   background: url("@/assets/images/bg@2x-min.png") no-repeat top center / cover;
 
   .logo {
@@ -35,28 +35,4 @@
       }
     }
   }
-  &-news {
-    &-list {
-      display: flex;
-      flex-direction: column;
-      gap: 38px;
-    }
-    &-item {
-      display: flex;
-      align-items: center;
-      font-size: 27px;
-
-      &__tag {
-        white-space: nowrap;
-        color: var(--van-primary-color);
-      }
-      &__date {
-        white-space: nowrap;
-        color: var(--text-color-placeholder);
-      }
-      p {
-        padding-right: 20px;
-      }
-    }
-  }
 }

+ 49 - 22
packages/mobile/src/views/Home/components/SecondPane/index.vue

@@ -2,9 +2,16 @@
   <div class="second-pane">
     <img class="logo" src="@/assets/images/logo@2x-min.png" />
 
-    <search-input />
+    <search-input
+      v-model="keyword"
+      @search="$router.push({ name: 'search', query: { keyword } })"
+    />
 
-    <rank-pane class="second-pane-recommend">
+    <rank-pane
+      class="second-pane-recommend"
+      :list="recommendList"
+      :loading="recommendLoading"
+    >
       <template #title-prepend>
         <img
           class="second-pane__title-prepend"
@@ -13,7 +20,7 @@
       </template>
     </rank-pane>
 
-    <rank-pane class="second-pane-read">
+    <rank-pane class="second-pane-read" :list="readList" :loading="readLoading">
       <template #title-prepend>
         <img
           class="second-pane__title-prepend"
@@ -31,33 +38,53 @@
         <span>排行榜</span>
       </div>
 
-      <ul class="second-pane-news-list">
-        <li
-          v-for="item in 8"
-          :key="item"
-          class="second-pane-news-item"
-          @click="newsDetailVisible = true"
-        >
-          <span class="second-pane-news-item__tag">【公告公示】</span>
-          <p class="limit-line">
-            2023年度 刘少奇同志纪念馆(刘少奇故里管理局)部门决算
-          </p>
-          <span class="second-pane-news-item__date">2024-09-07</span>
-        </li>
-      </ul>
+      <news-list />
     </div>
   </div>
-
-  <news-drawer v-model:show="newsDetailVisible" />
 </template>
 
 <script setup>
-import { ref } from "vue";
+import { ref, onMounted } from "vue";
+import { getReadListApi, getRecommendListApi } from "@/api";
 import SearchInput from "../../../../components/SearchInput.vue";
 import RankPane from "../RankPane.vue";
-import NewsDrawer from "../NewsDrawer.vue";
+import NewsList from "../NewsList.vue";
+
+const keyword = ref("");
+
+const recommendLoading = ref(false);
+const recommendList = ref([]);
+const readLoading = ref(false);
+const readList = ref([]);
+
+onMounted(() => {
+  getRecommendList();
+  getReadList();
+});
+
+const getReadList = async () => {
+  try {
+    readLoading.value = true;
+    const data = await getReadListApi();
+    readList.value = data;
+  } finally {
+    readLoading.value = false;
+  }
+};
 
-const newsDetailVisible = ref(false);
+const getRecommendList = async () => {
+  try {
+    recommendLoading.value = true;
+    const data = await getRecommendListApi({
+      pageNum: 1,
+      pageSize: 10,
+      type: "rank",
+    });
+    recommendList.value = data;
+  } finally {
+    recommendLoading.value = false;
+  }
+};
 </script>
 
 <style lang="scss" scoped>

+ 1 - 1
packages/mobile/src/views/Home/index.vue

@@ -13,7 +13,7 @@
 
 <script setup>
 import { onMounted, ref } from "vue";
-import { getRecommendListApi } from "@lsq/base";
+import { getRecommendListApi } from "@/api";
 import { useRouter } from "vue-router";
 import { getBaseUrl } from "@/utils";
 import SearchPane from "./components/SearchPane.vue";

+ 49 - 2
packages/mobile/src/views/ImgViewer/index.vue

@@ -9,12 +9,59 @@
       @click="$router.back()"
     />
 
-    <div class="img-viewer__pagination">12/24</div>
+    <div class="img-viewer__pagination">
+      {{ curIndex + 1 }}/{{ list.length }}
+    </div>
 
-    <van-image width="100%" src="" lazy-load fit="contain" />
+    <swiper
+      direction="vertical"
+      style="width: 100vw; height: 100vh"
+      @slide-change="handleChange"
+    >
+      <swiper-slide v-for="img in list" :key="img">
+        <van-image
+          width="100%"
+          height="100%"
+          :src="img"
+          lazy-load
+          fit="contain"
+        />
+      </swiper-slide>
+    </swiper>
   </div>
 </template>
 
+<script setup>
+import { onMounted, ref } from "vue";
+import { useRoute } from "vue-router";
+import { Swiper, SwiperSlide } from "swiper/vue";
+import { getMediaListApi } from "@/api";
+import { getBaseUrl } from "@/utils";
+
+const route = useRoute();
+const list = ref([]);
+const baseUrl = getBaseUrl();
+const curIndex = ref(0);
+
+const getBookDetail = async () => {
+  const data = await getMediaListApi({
+    pageNum: 1,
+    pageSize: 999,
+    bookId: route.params.id,
+    type: "img",
+  });
+  list.value = data.records.map((i) => baseUrl + i.thumb);
+};
+
+const handleChange = (e) => {
+  curIndex.value = e.activeIndex;
+};
+
+onMounted(() => {
+  getBookDetail();
+});
+</script>
+
 <style lang="scss" scoped>
 @import "./index.scss";
 </style>

+ 45 - 4
packages/mobile/src/views/Mine/components/EditNamePopup.vue

@@ -7,18 +7,26 @@
       label-width="0"
       placeholder="请输入内容,最多20字"
       :maxlength="20"
-      error-message="手机号格式错误"
     />
 
     <div class="edit-name-footer">
-      <van-button @click="visible = false">取消</van-button>
-      <van-button type="primary">提交</van-button>
+      <van-button @click="handleCancel">取消</van-button>
+      <van-button
+        :disabled="nickname.length < 2"
+        :loading="loading"
+        type="primary"
+        @click="handleSubmit"
+        >提交</van-button
+      >
     </div>
   </van-popup>
 </template>
 
 <script setup>
-import { computed, ref } from "vue";
+import { useBaseStore } from "@/stores";
+import { storeToRefs } from "pinia";
+import { computed, ref, watch } from "vue";
+import { updateUserInfoApi } from "@/api";
 
 const props = defineProps({
   show: {
@@ -27,7 +35,10 @@ const props = defineProps({
   },
 });
 const emits = defineEmits(["update:show"]);
+const baseStore = useBaseStore();
+const { userInfo } = storeToRefs(baseStore);
 const nickname = ref("");
+const loading = ref(false);
 
 const visible = computed({
   get() {
@@ -37,6 +48,36 @@ const visible = computed({
     emits("update:show", v);
   },
 });
+
+const handleCancel = () => {
+  nickname.value = userInfo.value.nickName;
+  visible.value = false;
+};
+
+const handleSubmit = async () => {
+  try {
+    loading.value = true;
+    await updateUserInfoApi({
+      nickName: nickname.value,
+    });
+    userInfo.value.nickName = nickname.value;
+    visible.value = false;
+  } finally {
+    loading.value = false;
+  }
+};
+
+watch(
+  userInfo,
+  (v) => {
+    if (v) {
+      nickname.value = v.nickName;
+    }
+  },
+  {
+    immediate: true,
+  }
+);
 </script>
 
 <style lang="scss" scoped>

+ 101 - 11
packages/mobile/src/views/Mine/index.vue

@@ -5,13 +5,16 @@
         v-model="fileList"
         class="mine-page-info__avatar"
         :before-read="beforeRead"
+        :after-read="afterRead"
         :max-count="1"
         :deletable="false"
         reupload
+        accept="image/jpeg,image/png,image/jpg"
         :upload-icon="PhotoIcon"
       />
       <p class="mine-page-info__name">
-        SAMSARA<svg-icon
+        {{ userInfo.nickName
+        }}<svg-icon
           name="icon_edit"
           width="18px"
           height="18px"
@@ -19,14 +22,14 @@
           @click="editNameVisible = true"
         />
       </p>
-      <p class="mine-page-info__id">ID:6855576858</p>
+      <p class="mine-page-info__id">ID:{{ userInfo.id }}</p>
     </div>
 
     <div class="mine-page-main">
       <div class="mine-page-main-header">
         <h3><span>我的书架</span></h3>
 
-        <van-button type="primary">
+        <van-button type="primary" @click="handleUpload">
           <template #icon>
             <svg-icon
               name="icon_upload"
@@ -40,8 +43,12 @@
       </div>
 
       <ul class="mine-page-list">
-        <li v-for="item in 8" :key="item">
-          <book-card is-read />
+        <li v-for="item in bookList" :key="item.id">
+          <book-card
+            :is-read="item.isCollect === 0"
+            :is-like="item.isCollect === 1"
+            :item="item"
+          />
         </li>
       </ul>
     </div>
@@ -51,22 +58,105 @@
 </template>
 
 <script setup>
-import { showToast } from "vant";
-import { ref } from "vue";
+import { showToast, showLoadingToast } from "vant";
+import { ref, watch, onMounted } from "vue";
+import { storeToRefs } from "pinia";
+import useClipboard from "vue-clipboard3";
+import { useBaseStore } from "@/stores";
+import { uploadApi, updateUserInfoApi, getMyBookListApi } from "@/api";
 import BookCard from "@/components/BookCard.vue";
 import PhotoIcon from "@/assets/images/icon_portrait@2x-min.png";
 import EditNamePopup from "./components/EditNamePopup.vue";
+import { getBaseUrl } from "@/utils";
 
+const { toClipboard } = useClipboard();
+const baseStore = useBaseStore();
+const baseUrl = getBaseUrl();
+const { userInfo } = storeToRefs(baseStore);
 const fileList = ref([]);
 const editNameVisible = ref(false);
 
 const beforeRead = (file) => {
-  if (file.type !== "image/jpeg") {
-    showToast("请上传 jpg 格式图片");
-    return false;
+  const isIMAGE = ["image/jpeg", "image/jpg", "image/png"].includes(file.type);
+  const isLt1M = file.size / 1024 / 1024 < 2;
+  if (!isIMAGE) {
+    showToast({
+      type: "fail",
+      message: "上传文件只能是图片格式",
+      duration: 3000,
+    });
   }
-  return true;
+  if (!isLt1M) {
+    showToast({
+      type: "fail",
+      message: "上传文件大小不能超过 2MB",
+      duration: 3000,
+    });
+  }
+  return isIMAGE && isLt1M;
+};
+
+const afterRead = async (file) => {
+  let toast = null;
+  const formData = new FormData();
+  formData.append("file", file.file);
+  formData.append("type", "img");
+
+  try {
+    toast = showLoadingToast({
+      duration: 0,
+      message: "上传中",
+      forbidClick: true,
+    });
+    const data = await uploadApi(formData);
+    await updateUserInfoApi({
+      avatarUrl: data.filePath,
+    });
+    userInfo.value.avatarUrl = data.filePath;
+  } finally {
+    toast?.close();
+  }
+};
+
+const bookLoading = ref(false);
+const bookList = ref([]);
+
+const getMyBookList = async () => {
+  try {
+    bookLoading.value = true;
+    const data = await getMyBookListApi();
+    bookList.value = data;
+  } finally {
+    bookLoading.value = false;
+  }
+};
+
+const handleUpload = () => {
+  toClipboard(location.href);
+  showToast({
+    message: "由于设备限制,请在pc端完成图书上传.链接已复制",
+    duration: 4000,
+  });
 };
+
+onMounted(() => {
+  getMyBookList();
+});
+
+watch(
+  userInfo,
+  (v) => {
+    fileList.value = [
+      {
+        url: baseUrl + v.avatarUrl,
+        isImage: true,
+      },
+    ];
+  },
+  {
+    immediate: true,
+  }
+);
 </script>
 
 <style lang="scss" scoped>

+ 61 - 23
packages/mobile/src/views/Reader/components/BookmarkPopup.vue

@@ -1,16 +1,27 @@
 <template>
   <popup v-bind="$attrs" title="书签">
     <div class="bookmark-popup-header">
-      <p>总数:2</p>
+      <p>总数:{{ list.length }}</p>
 
       <van-button><van-icon name="plus" @click="addBookmark" /></van-button>
     </div>
 
+    <van-loading
+      v-if="loading"
+      style="text-align: center; line-height: 300px"
+    />
+
+    <van-empty v-if="!list.length" image-size="0" description="暂无数据" />
+
     <div class="bookmark-popup-list">
-      <div v-for="(item, idx) in list" :key="idx" class="bookmark-popup-item">
+      <div
+        v-for="(item, idx) in list"
+        :key="item.id"
+        class="bookmark-popup-item"
+      >
         <div class="bookmark-popup-item__inner" @click="goToDetail(item)">
-          <p>书签一</p>
-          <p>{{ item.time }}</p>
+          <p>书签</p>
+          <p>{{ item.createTime }}</p>
         </div>
 
         <svg-icon
@@ -19,6 +30,7 @@
           width="24px"
           height="24px"
           color="var(--el-color-primary)"
+          @click="handleDelete(item, idx)"
         />
       </div>
     </div>
@@ -26,38 +38,64 @@
 </template>
 
 <script setup>
-import { ref, useAttrs } from "vue";
+import { ref, useAttrs, watch } from "vue";
 import { formatDate } from "@dage/utils";
+import { useRoute } from "vue-router";
 import { useEpubStore } from "@/stores";
+import { saveLabelApi, deleteLabelApi, getLabelListApi } from "@/api";
 import Popup from "./Popup.vue";
 
+const route = useRoute();
 const attrs = useAttrs();
 const epubStore = useEpubStore();
+const list = ref([]);
+const loading = ref(false);
+
+const addBookmark = async () => {
+  const { startCfi, page } = await epubStore.refreshLocation();
 
-const list = ref([
-  {
-    location: {
-      start: {
-        cfi: "epubcfi(/6/8[x02.xhtml]!/4/22/1:15)",
-      },
-    },
-    time: "2022-01-01",
-  },
-]);
-
-const addBookmark = () => {
-  const curLocation = epubStore.book?.rendition.currentLocation();
-
-  list.value.push({
-    location: curLocation,
-    time: formatDate(new Date(), "YYYY-MM-DD HH:mm:ss"),
+  const data = await saveLabelApi({
+    type: "label",
+    bookId: route.params.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(route.params.id, "label");
+    list.value = data.map((i) => ({
+      ...i,
+      content: JSON.parse(i.content),
+    }));
+  } finally {
+    loading.value = false;
+  }
 };
 
 const goToDetail = (item) => {
-  epubStore.goToChapter(item.location.start.cfi);
+  epubStore.goToChapter(item.content.location, item.content.page);
   attrs["onUpdate:show"](false);
 };
+
+const handleDelete = (item, idx) => {
+  list.value.splice(idx, 1);
+  deleteLabelApi(item.id);
+};
+
+watch(
+  () => attrs.show,
+  (v) => {
+    if (v && !list.value.length) {
+      getLabelList();
+    }
+  }
+);
 </script>
 
 <style lang="scss" scoped>

+ 71 - 17
packages/mobile/src/views/Reader/components/CommentPopup.vue

@@ -6,6 +6,7 @@
         :items="list"
         key-field="id"
         :min-item-size="86"
+        @update="onUpdate"
       >
         <template #default="{ item, index, active }">
           <DynamicScrollerItem
@@ -17,36 +18,87 @@
             <msg-item :item="item" />
           </DynamicScrollerItem>
         </template>
+
+        <template v-if="loading" #before>
+          <van-loading style="text-align: center" />
+        </template>
+
+        <template v-if="noData" #after>
+          <van-empty description="暂无数据" image-size="0" />
+        </template>
       </DynamicScroller>
 
-      <textarea class="comment-popup__textarea" />
+      <template v-if="isLogin">
+        <textarea v-model="textarea" class="comment-popup__textarea" />
 
-      <van-button type="primary">提交</van-button>
+        <van-button
+          type="primary"
+          :loading="btnLoading"
+          :disabled="!textarea"
+          @click="handleSubmit"
+          >提交</van-button
+        >
+      </template>
     </div>
   </popup>
 </template>
 
 <script setup>
-import { ref } from "vue";
+import { ref, onMounted } from "vue";
+import { storeToRefs } from "pinia";
+import { showToast } from "vant";
+import { useRoute } from "vue-router";
 import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
+import { useBaseStore } from "@/stores";
+import { PaginationType, usePagination } from "@lsq/base";
+import { getMessageListApi, saveMessageApi } from "@/api";
 import Popup from "./Popup.vue";
 import MsgItem from "./MsgItem.vue";
 import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
 
-const list = ref([
-  {
-    id: 1,
-    name: "blueeee",
-    date: "2024-08-18",
-    msg: "像一条绿色的玉带,从南到北缓缓穿越湖南全省,注入中国第二大淡水湖洞庭湖。",
-  },
-  {
-    id: 2,
-    name: "blueeee",
-    date: "2024-08-18",
-    msg: "所以家族中平时亲切地叫他“九满”​",
-  },
-]);
+const route = useRoute();
+const baseStore = useBaseStore();
+const { isLogin, userInfo } = storeToRefs(baseStore);
+const btnLoading = ref(false);
+const textarea = ref("");
+
+const { pageNum, loading, list, noData, noMore, getList } = usePagination(
+  async (params) =>
+    getMessageListApi({
+      ...params,
+      auditStatus: 1,
+    }),
+  PaginationType.CONCAT
+);
+
+const onUpdate = (start, end) => {
+  if (list.value.length === end && !noMore.value) {
+    pageNum.value++;
+    getList();
+  }
+};
+
+const handleSubmit = async () => {
+  try {
+    btnLoading.value = true;
+    await saveMessageApi({
+      bookId: route.query.id,
+      createBy: userInfo.value.nickName,
+      content: textarea.value,
+    });
+    showToast({
+      type: "success",
+      message: "提交成功,等待审核",
+    });
+    textarea.value = "";
+  } finally {
+    btnLoading.value = false;
+  }
+};
+
+onMounted(() => {
+  getList();
+});
 </script>
 
 <style lang="scss" scoped>
@@ -57,9 +109,11 @@ const list = ref([
 
   &__textarea {
     margin: 30px 0;
+    padding: 20px;
     height: 352px;
     border-color: var(--van-primary-color);
     background: transparent;
+    box-sizing: border-box;
   }
   .van-button {
     margin-bottom: 30px;

+ 8 - 4
packages/mobile/src/views/Reader/components/MsgItem.vue

@@ -1,17 +1,21 @@
 <template>
   <div class="msg-item">
     <div class="msg-item-header">
-      <p v-if="item.name">{{ item.name }}</p>
-      <p>{{ item.date }}</p>
+      <p v-if="item.createBy">{{ item.createBy }}</p>
+      <p>{{ formatDate(item.createTime, "YYYY-MM-DD") }}</p>
     </div>
     <div class="msg-item-inner">
-      <p>{{ item.msg }}</p>
+      <p>{{ item.content.text || item.content }}</p>
+    </div>
+    <div v-if="isNote" class="msg-item-footer">
+      {{ item.content?.note || "暂无笔记" }}
     </div>
-    <div v-if="isNote" class="msg-item-footer">暂无笔记</div>
   </div>
 </template>
 
 <script setup>
+import { formatDate } from "@dage/utils";
+
 defineProps({
   item: {
     type: Object,

+ 41 - 16
packages/mobile/src/views/Reader/components/NotePopup.vue

@@ -3,7 +3,7 @@
     <div class="comment-popup">
       <DynamicScroller
         class="comment-list"
-        :items="list"
+        :items="noteList"
         key-field="id"
         :min-item-size="86"
       >
@@ -13,7 +13,7 @@
             :active="active"
             :size-dependencies="[item.status, item.type]"
             :data-index="index"
-            @click="editVisible = true"
+            @click="handleClick(item)"
           >
             <msg-item :item="item" is-note />
           </DynamicScrollerItem>
@@ -28,9 +28,11 @@
     closeable
   >
     <div class="edit-comment">
-      <textarea class="comment-popup__textarea" />
+      <textarea v-model="textarea" class="comment-popup__textarea" />
 
-      <van-button type="primary">提交</van-button>
+      <van-button type="primary" :loading="btnLoading" @click="handleSubmit"
+        >提交</van-button
+      >
     </div>
   </van-popup>
 </template>
@@ -38,23 +40,46 @@
 <script setup>
 import { ref } from "vue";
 import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
+import { storeToRefs } from "pinia";
+import { useReaderStore } from "@/stores";
+import { saveLabelApi } from "@/api";
 import Popup from "./Popup.vue";
 import MsgItem from "./MsgItem.vue";
 import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
 
-const list = ref([
-  {
-    id: 1,
-    date: "2024-08-18",
-    msg: "像一条绿色的玉带,从南到北缓缓穿越湖南全省,注入中国第二大淡水湖洞庭湖。",
-  },
-  {
-    id: 2,
-    date: "2024-08-18",
-    msg: "所以家族中平时亲切地叫他“九满”​",
-  },
-]);
+const readerStore = useReaderStore();
+const { noteList } = storeToRefs(readerStore);
 const editVisible = ref(false);
+const checkedItem = ref(null);
+const textarea = ref("");
+const btnLoading = ref(false);
+
+const handleSubmit = async () => {
+  try {
+    btnLoading.value = true;
+
+    await saveLabelApi({
+      id: checkedItem.value.id,
+      type: "note",
+      bookId: checkedItem.value.bookId,
+      content: JSON.stringify({
+        ...checkedItem.value.content,
+        note: textarea.value,
+      }),
+    });
+
+    checkedItem.value.content.note = textarea.value;
+    editVisible.value = false;
+  } finally {
+    btnLoading.value = false;
+  }
+};
+
+const handleClick = (item) => {
+  checkedItem.value = item;
+  textarea.value = item.content?.note || "";
+  editVisible.value = true;
+};
 </script>
 
 <style lang="scss" scoped>

+ 10 - 10
packages/mobile/src/views/Reader/components/SearchPopup.vue

@@ -1,7 +1,7 @@
 <template>
   <popup v-bind="$attrs" title="全文搜索">
     <search-input
-      v-model="detailStore.searchKey"
+      v-model="readerStore.searchKey"
       class="search-popup-input"
       simple
       @search="debounceSearch"
@@ -31,7 +31,7 @@
     </DynamicScroller>
 
     <van-empty
-      v-if="!list.length && detailStore.searchKey && !loading"
+      v-if="!list.length && readerStore.searchKey && !loading"
       description="搜索不到结果"
     />
   </popup>
@@ -42,30 +42,30 @@ import { ref, watch } from "vue";
 import { debounce } from "lodash";
 import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
 import SearchInput from "@/components/SearchInput.vue";
-import { useDetailStore, useEpubStore } from "@/stores";
+import { useReaderStore, useEpubStore } from "@/stores";
 import Popup from "./Popup.vue";
 import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
 
-const detailStore = useDetailStore();
+const readerStore = useReaderStore();
 const epubStore = useEpubStore();
 const list = ref([]);
 const loading = ref(false);
 
 const debounceSearch = debounce(async () => {
-  if (!detailStore.searchKey) {
+  if (!readerStore.searchKey) {
     list.value = [];
     return;
   }
 
   try {
-    const res = await epubStore.searchKeyword(detailStore.searchKey);
-    const reg = new RegExp(detailStore.searchKey, "gi");
+    const res = await epubStore.searchKeyword(readerStore.searchKey);
+    const reg = new RegExp(readerStore.searchKey, "gi");
     list.value = res.map((item) => {
       return {
         ...item,
         excerpt: item.excerpt.replace(
           reg,
-          `<span style="color:var(--van-primary-color);padding:4px">${detailStore.searchKey}</span>`
+          `<span style="color:var(--van-primary-color);padding:4px">${readerStore.searchKey}</span>`
         ),
       };
     });
@@ -76,11 +76,11 @@ const debounceSearch = debounce(async () => {
 
 const goToDetail = (cfi) => {
   epubStore.goToChapter(cfi);
-  detailStore.searchVisible = false;
+  readerStore.searchVisible = false;
 };
 
 watch(
-  () => detailStore.searchKey,
+  () => readerStore.searchKey,
   () => {
     loading.value = true;
     debounceSearch();

+ 4 - 4
packages/mobile/src/views/Reader/components/ToolbarMenu.vue

@@ -18,7 +18,7 @@
     </div>
   </div>
 
-  <search-popup v-model:show="detailStore.searchVisible" />
+  <search-popup v-model:show="readerStore.searchVisible" />
   <bookmark-popup v-model:show="bookmarkVisible" />
   <directory-popup v-model:show="directoryVisible" />
   <setting-popup v-model:show="settingVisible" />
@@ -28,7 +28,7 @@
 
 <script setup>
 import { ref } from "vue";
-import { useDetailStore } from "@/stores";
+import { useReaderStore } from "@/stores";
 import SearchPopup from "./SearchPopup.vue";
 import BookmarkPopup from "./BookmarkPopup.vue";
 import DirectoryPopup from "./DirectoryPopup.vue";
@@ -69,7 +69,7 @@ const LIST = [
   },
 ];
 
-const detailStore = useDetailStore();
+const readerStore = useReaderStore();
 const directoryVisible = ref(false);
 const settingVisible = ref(false);
 const noteVisible = ref(false);
@@ -85,7 +85,7 @@ const handleToolbar = (key) => {
       settingVisible.value = true;
       break;
     case "search":
-      detailStore.searchVisible = true;
+      readerStore.searchVisible = true;
       break;
     case "note":
       noteVisible.value = true;

+ 32 - 5
packages/mobile/src/views/Reader/components/ToolbarPopover.vue

@@ -24,7 +24,9 @@ import { ref } from "vue";
 import useClipboard from "vue-clipboard3";
 import { showNotify } from "vant";
 import "vant/es/notify/style/index";
-import { useEpubStore, useDetailStore } from "@/stores";
+import { useRoute } from "vue-router";
+import { saveLabelApi, deleteLabelApi } from "@/api";
+import { useEpubStore, useReaderStore, useBaseStore } from "@/stores";
 import { COLOR_MAP } from "../constants";
 
 const props = defineProps({
@@ -39,12 +41,16 @@ const props = defineProps({
 });
 const emits = defineEmits(["close"]);
 
+const route = useRoute();
 const epubStore = useEpubStore();
-const detailStore = useDetailStore();
+const baseStore = useBaseStore();
+const readerStore = useReaderStore();
 const { toClipboard } = useClipboard();
 const selectedCfiStack = ref([]);
 
-const setHighlight = (color) => {
+const setHighlight = async (color) => {
+  if (!baseStore.loginValidator()) return;
+
   epubStore.rendition.annotations.highlight(
     props.selectedCfi,
     {},
@@ -57,10 +63,31 @@ const setHighlight = (color) => {
 
   emits("close");
   selectedCfiStack.value.push(props.selectedCfi);
+
+  const data = await saveLabelApi({
+    type: "note",
+    bookId: route.params.id,
+    content: JSON.stringify({
+      cfi: props.selectedCfi,
+      text: epubStore.rendition.getRange(props.selectedCfi).toString(),
+      color,
+    }),
+  });
+  readerStore.noteList.push({ ...data, content: JSON.parse(data.content) });
 };
 
 const removeHighlight = () => {
-  epubStore.rendition.annotations.remove(props.selectedCfi, "highlight");
+  if (!baseStore.loginValidator()) return;
+
+  const index = readerStore.noteList.findIndex(
+    (i) => i.content.cfi === props.selectedCfi
+  );
+  if (index > -1) {
+    const item = readerStore.noteList[index];
+    deleteLabelApi(item.id);
+    epubStore.rendition.annotations.remove(props.selectedCfi, "highlight");
+    readerStore.noteList.splice(index, 1);
+  }
   emits("close");
 };
 
@@ -79,7 +106,7 @@ const copyText = async () => {
 const handleSearch = () => {
   const range = epubStore.rendition.getRange(props.selectedCfi);
   const selectedText = range.toString();
-  detailStore.openSearchDrawer(selectedText);
+  readerStore.openSearchDrawer(selectedText);
   emits("close");
 };
 </script>

+ 65 - 9
packages/mobile/src/views/Reader/index.vue

@@ -23,35 +23,64 @@
 
 <script setup>
 import { computed, onMounted, ref } from "vue";
+import { useRoute } from "vue-router";
+import { storeToRefs } from "pinia";
+import { showLoadingToast } from "vant";
 import NavBar from "@/components/NavBar.vue";
-import { useEpubStore } from "@/stores";
+import { useEpubStore, useBaseStore, useReaderStore } from "@/stores";
+import { getBookDetail2Api, getBookDetailApi, getLabelListApi } from "@/api";
+import { getBaseUrl } from "@/utils";
 import { THEMES } from "./constants";
 import ToolbarPopover from "./components/ToolbarPopover.vue";
 import ToolbarMenu from "./components/ToolbarMenu.vue";
 
+const route = useRoute();
+const baseUrl = getBaseUrl();
 const epubStore = useEpubStore();
+const baseStore = useBaseStore();
+const readerStore = useReaderStore();
+const { noteList } = storeToRefs(readerStore);
+const { isLogin } = storeToRefs(baseStore);
 const selectedCfi = ref("");
 const selectMenuStyle = ref({});
+const detail = ref(null);
 const paneBgColor = computed(
   () => THEMES.find((theme) => theme.key === epubStore.curTheme).background
 );
 
-onMounted(() => {
+const getBookDetail = async () => {
+  const data = await (isLogin.value ? getBookDetail2Api : getBookDetailApi)(
+    route.params.id
+  );
+  detail.value = data;
+
+  initEpub();
+};
+
+const initEpub = () => {
+  const toast = showLoadingToast({
+    duration: 0,
+    message: "书本加载中...",
+    forbidClick: true,
+  });
+
   epubStore.init({
-    url: "./test2.epub",
+    url: baseUrl + detail.value.filePath,
     themes: THEMES,
   });
 
+  const parentWidth = window.innerWidth;
   epubStore.rendition.hooks.render.register((v) => {
-    v.document.addEventListener("click", (e) => {
+    v.document.addEventListener("click", async (e) => {
       // 如果显示冒泡,则不翻页
       if (selectMenuStyle.value.visibility === "visible") return;
 
-      const offsetX = e.offsetX;
-      const width = window.innerWidth;
+      const { page } = await epubStore.refreshLocation();
+      const clientX = e.clientX;
+      const screenX = clientX - page * parentWidth;
 
-      if (offsetX > 0 && offsetX < width * 0.3) epubStore.prePage();
-      else if (offsetX > 0 && offsetX > width * 0.7) epubStore.nextPage();
+      if (screenX < parentWidth * 0.5) epubStore.prePage();
+      else if (screenX > parentWidth * 0.5) epubStore.nextPage();
     });
 
     v.document.body.addEventListener("touchend", (e) => {
@@ -67,7 +96,13 @@ onMounted(() => {
   epubStore.rendition.on("selected", (v) => {
     selectedCfi.value = v;
   });
-});
+
+  epubStore.rendition.on("rendered", () => {
+    toast.close();
+
+    isLogin.value && getLabelList();
+  });
+};
 
 const selected = (e, v) => {
   const sty = {
@@ -100,6 +135,27 @@ const selected = (e, v) => {
 const hideSelectMenu = () => {
   selectMenuStyle.value = { visibility: "hidden" };
 };
+
+// 获取笔记列表
+const getLabelList = async () => {
+  await readerStore.getLabelList(detail.value.id);
+
+  noteList.value.forEach((item) => {
+    epubStore.rendition?.annotations.highlight(
+      item.content.cfi,
+      {},
+      () => {},
+      "",
+      {
+        fill: item.content.color,
+      }
+    );
+  });
+};
+
+onMounted(() => {
+  getBookDetail();
+});
 </script>
 
 <style lang="scss" scoped>

+ 83 - 19
packages/mobile/src/views/Search/components/FilterPopup.vue

@@ -3,30 +3,56 @@
     <div class="filter-popup-main">
       <h3>按书籍分类</h3>
 
+      <van-loading v-if="treeLoading" class="filter-popup__loading" />
+
       <van-cascader
-        v-model="cascaderValue"
+        v-else
+        v-model="storageId"
         :options="options"
+        :field-names="{
+          text: 'name',
+          value: 'id',
+        }"
         :show-header="false"
       />
+
+      <h3 style="margin-top: 20px">按书籍分类</h3>
+
+      <van-loading v-if="listLoading" class="filter-popup__loading" />
+
+      <van-radio-group v-else v-model="exhibitTypeId">
+        <van-radio v-for="item in list" :key="item.id" :name="item.id">{{
+          item.name
+        }}</van-radio>
+      </van-radio-group>
     </div>
 
     <div class="filter-popup-footer">
-      <van-button @click="reset">重置</van-button>
-      <van-button type="primary">确定</van-button>
+      <van-button @click="handleReset">重置</van-button>
+      <van-button type="primary" @click="handleSubmit">确定</van-button>
     </div>
   </van-popup>
 </template>
 
 <script setup>
-import { computed, ref } from "vue";
+import { computed, ref, watch } from "vue";
+import { getStorageTreeApi, getExhibitTypeListApi } from "@/api";
 
 const props = defineProps({
   show: {
     type: Boolean,
     required: false,
   },
+  initStorageVal: {
+    type: Number,
+    default: 0,
+  },
+  initExhibitVal: {
+    type: Number,
+    default: 0,
+  },
 });
-const emits = defineEmits(["update:show"]);
+const emits = defineEmits(["update:show", "confirm", "reset"]);
 
 const visible = computed({
   get() {
@@ -37,23 +63,52 @@ const visible = computed({
   },
 });
 
-const cascaderValue = ref("");
-const options = [
-  {
-    text: "浙江省",
-    value: "330000",
-    children: [{ text: "杭州市", value: "330100" }],
-  },
-  {
-    text: "江苏省",
-    value: "320000",
-    children: [{ text: "南京市", value: "320100" }],
-  },
-];
+const storageId = ref(props.initStorageVal ? props.initStorageVal : "");
+const exhibitTypeId = ref(props.initExhibitVal ? props.initExhibitVal : "");
+const options = ref([]);
+const treeLoading = ref(false);
+const list = ref([]);
+const listLoading = ref(false);
+
+const getStorageTree = async () => {
+  try {
+    treeLoading.value = true;
+    const data = await getStorageTreeApi();
+    options.value = data;
+  } finally {
+    treeLoading.value = false;
+  }
+};
 
-const reset = () => {
+const getExhibitTypeList = async () => {
+  try {
+    listLoading.value = true;
+    const data = await getExhibitTypeListApi();
+    list.value = data;
+  } finally {
+    listLoading.value = false;
+  }
+};
+
+const handleReset = () => {
+  emits("reset");
+  visible.value = false;
+};
+
+const handleSubmit = () => {
+  emits("confirm", {
+    storageId: storageId.value,
+    exhibitTypeId: exhibitTypeId.value,
+  });
   visible.value = false;
 };
+
+watch(visible, (v) => {
+  if (!v) return;
+
+  !options.value.length && getStorageTree();
+  !list.value.length && getExhibitTypeList();
+});
 </script>
 
 <style lang="scss" scoped>
@@ -66,16 +121,25 @@ const reset = () => {
   overflow: hidden;
   box-sizing: border-box;
 
+  &__loading {
+    height: 300px;
+    line-height: 300px;
+    text-align: center;
+  }
   &-main {
     flex: 1;
     padding: 0 40px;
     overflow-y: auto;
+    --van-cascader-options-height: 400px;
 
     h3 {
       margin-bottom: 20px;
       font-size: 46px;
       font-family: "Source Han Serif CN-Bold";
     }
+    .van-radio:not(:first-child) {
+      margin-top: 32px;
+    }
   }
   &-footer {
     --van-button-radius: 0;

+ 64 - 22
packages/mobile/src/views/Search/index.vue

@@ -10,7 +10,7 @@
         @click="$router.back()"
       />
 
-      <search-input />
+      <search-input v-model="keyword" @search="handleSearch" />
 
       <div class="search-page__filter" @click="filterVisible = true">
         <svg-icon
@@ -25,48 +25,90 @@
 
     <RecycleScroller
       class="search-page-scroller"
-      :items="bookList"
+      :items="list"
       :item-size="167 + 24"
+      @scroll-end="handleEnd"
     >
       <template #before>
         <search-divider />
       </template>
 
       <template v-slot="{ item }">
-        <BookCard2 />
+        <BookCard2 :item="item" />
+      </template>
+
+      <template v-if="noData" #empty>
+        <van-empty description="暂无数据" />
+      </template>
+
+      <template v-if="loading" #after>
+        <van-loading style="text-align: center" />
       </template>
     </RecycleScroller>
   </div>
 
-  <filter-popup v-model:show="filterVisible" />
+  <filter-popup
+    v-model:show="filterVisible"
+    :init-storage-val="storageId"
+    :init-exhibit-val="exhibitTypeId"
+    @confirm="handleFilter"
+    @reset="handleReset"
+  />
 </template>
 
 <script setup>
-import { ref } from "vue";
+import { ref, onMounted } from "vue";
 import { RecycleScroller } from "vue-virtual-scroller";
+import { useRouteQuery } from "@vueuse/router";
+import { usePagination, PaginationType } from "@lsq/base";
+import { getBookListApi } from "@/api";
 import BookCard2 from "@/components/BookCard2.vue";
 import SearchInput from "@/components/SearchInput.vue";
 import SearchDivider from "@/components/SearchDivider.vue";
 import FilterPopup from "./components/FilterPopup.vue";
 
-const bookList = ref([
-  {
-    id: 1,
-  },
-  {
-    id: 2,
-  },
-  {
-    id: 3,
-  },
-  {
-    id: 5,
-  },
-  {
-    id: 6,
-  },
-]);
 const filterVisible = ref(false);
+const keyword = useRouteQuery("keyword", "");
+const storageId = useRouteQuery("storageId", 0, { transform: Number });
+const exhibitTypeId = useRouteQuery("exhibitTypeId", 0, { transform: Number });
+
+const { loading, list, noData, noMore, pageNum, getList, resetParams } =
+  usePagination((params) => {
+    return getBookListApi({
+      ...params,
+      searchKey: keyword.value,
+      exhibitTypeId: exhibitTypeId.value ? exhibitTypeId.value : "",
+      storageId: storageId.value ? storageId.value : "",
+    });
+  }, PaginationType.CONCAT);
+
+const handleEnd = () => {
+  if (!noMore.value && !loading.value) {
+    pageNum.value++;
+    getList();
+  }
+};
+
+const handleSearch = () => {
+  resetParams();
+  getList();
+};
+
+const handleFilter = (params) => {
+  storageId.value = params.storageId;
+  exhibitTypeId.value = params.exhibitTypeId;
+  handleSearch();
+};
+
+const handleReset = () => {
+  storageId.value = 0;
+  exhibitTypeId.value = 0;
+  handleSearch();
+};
+
+onMounted(() => {
+  getList();
+});
 </script>
 
 <style lang="scss" scoped>

+ 24 - 8
packages/mobile/src/views/Stack/components/ClassifyScroll.vue

@@ -27,16 +27,24 @@
 import { ref, computed, watch } from "vue";
 import { FreeMode } from "swiper/modules";
 import { Swiper, SwiperSlide } from "swiper/vue";
-import { getStorageTreeApi, getExhibitTypeListApi } from "@lsq/base";
+import { getStorageTreeApi, getExhibitTypeListApi } from "@/api";
 
 const props = defineProps({
   activeTab: {
     type: Number,
     required: true,
   },
+  storageInitVal: {
+    type: Number,
+    default: 0,
+  },
+  exhibitInitVal: {
+    type: Number,
+    default: 0,
+  },
 });
 const isStorage = computed(() => props.activeTab === 0);
-const emits = defineEmits(["storageId", "exhibitId"]);
+const emits = defineEmits(["storageId", "exhibitId", "init"]);
 const storageActive = ref(0);
 const exhibitActive = ref(0);
 const storageList = ref([]);
@@ -45,11 +53,19 @@ const exhibitList = ref([]);
 const getStorageTree = async () => {
   const data = await getStorageTreeApi();
   storageList.value = data;
+
+  if (props.storageInitVal) {
+    storageActive.value = data.findIndex((i) => i.id === props.storageInitVal);
+  }
 };
 
 const getExhibitTypeList = async () => {
   const data = await getExhibitTypeListApi();
   exhibitList.value = data;
+
+  if (props.exhibitInitVal) {
+    exhibitActive.value = data.findIndex((i) => i.id === props.exhibitInitVal);
+  }
 };
 
 const handleClick = (item, index) => {
@@ -65,12 +81,12 @@ const handleClick = (item, index) => {
 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);
+    if (isStorage.value && !storageList.value.length) {
+      await getStorageTree();
+      emits("init", storageList.value[0].id);
+    } else if (!isStorage.value && !exhibitList.value.length) {
+      await getExhibitTypeList();
+      emits("init", exhibitList.value[0].id);
     }
   },
   {

+ 1 - 1
packages/mobile/src/views/Stack/index.scss

@@ -35,7 +35,7 @@
   }
 
   &-scroller {
-    padding: 70px 0 calc(var(--tabbar-height) + 80px);
+    padding: 40px 0 calc(var(--tabbar-height) + 80px);
     /* prettier-ignore */
     height: calc(100vh - 104PX);
     box-sizing: border-box;

+ 93 - 17
packages/mobile/src/views/Stack/index.vue

@@ -10,7 +10,14 @@
 
   <van-tabs v-model:active="activeTab" swipeable shrink class="stack">
     <template #nav-bottom>
-      <classify-scroll :active-tab="activeTab" @storageId="handleStorage" />
+      <classify-scroll
+        :active-tab="activeTab"
+        :storage-init-val="storageId"
+        :exhibit-init-val="exhibitId"
+        @storageId="handleStorage"
+        @exhibitId="handleExhibit"
+        @init="handleInitClassify"
+      />
     </template>
 
     <van-tab title="按书籍分类">
@@ -18,13 +25,18 @@
         class="stack-scroller"
         :items="bookList"
         :item-size="167 + 24"
-        v-slot="{ item }"
-        @scroll-end="handleEnd"
+        @scroll-end="handleBookEnd"
       >
-        <BookCard2 />
+        <template v-slot="{ item }">
+          <BookCard2 :item="item" />
+        </template>
+
+        <template v-if="bookNoData" #empty>
+          <van-empty description="暂无数据" />
+        </template>
 
-        <template v-slot="after">
-          <van-loading />
+        <template v-if="bookLoading" #after>
+          <van-loading style="text-align: center" />
         </template>
       </RecycleScroller>
     </van-tab>
@@ -32,49 +44,113 @@
     <van-tab title="按藏品分类">
       <RecycleScroller
         class="stack-scroller"
-        :items="bookList"
+        :items="exhibitList"
         :item-size="167 + 24"
-        v-slot="{ item }"
+        @scroll-end="handleExhibitEnd"
       >
-        <BookCard2 />
+        <template v-slot="{ item }">
+          <BookCard2 :item="item" />
+        </template>
+
+        <template v-if="exhibitNoData" #empty>
+          <van-empty description="暂无数据" />
+        </template>
+
+        <template v-if="exhibitLoading" #after>
+          <van-loading style="text-align: center" />
+        </template>
       </RecycleScroller>
     </van-tab>
   </van-tabs>
 </template>
 
 <script setup>
-import { ref } from "vue";
-import { getBookListApi, usePagination } from "@lsq/base";
+import { usePagination, PaginationType } from "@lsq/base";
+import { useRouteQuery } from "@vueuse/router";
+import { getBookListApi } from "@/api";
 import { RecycleScroller } from "vue-virtual-scroller";
 import BookCard2 from "@/components/BookCard2.vue";
 import ClassifyScroll from "./components/ClassifyScroll.vue";
 
-const activeTab = ref(0);
-const storageId = ref(0);
+const activeTab = useRouteQuery("active", 0, { transform: Number });
+const storageId = useRouteQuery("storage", 0, { transform: Number });
+const exhibitId = useRouteQuery("exhibit", 0, { transform: Number });
+
 const {
   loading: bookLoading,
   list: bookList,
   noData: bookNoData,
   noMore: bookNoMore,
+  pageNum: bookPage,
   getList: getBookList,
+  resetParams: resetBookParams,
 } = usePagination(async (params) => {
   return getBookListApi({
     ...params,
     storageId: storageId.value,
   });
-});
+}, PaginationType.CONCAT);
+
+const {
+  loading: exhibitLoading,
+  list: exhibitList,
+  noData: exhibitNoData,
+  noMore: exhibitNoMore,
+  pageNum: exhibitPage,
+  getList: getExhibitList,
+  resetParams: resetExhibitParams,
+} = usePagination(async (params) => {
+  return getBookListApi({
+    ...params,
+    exhibitTypeId: exhibitId.value,
+  });
+}, PaginationType.CONCAT);
 
 const handleStorage = (id) => {
-  console.log(id);
+  if (id === storageId.value) return;
+
   storageId.value = id;
+  resetBookParams();
   getBookList();
 };
 
-const handleEnd = () => {
-  if (activeTab.value === 0 && !noMore.value) {
+const handleBookEnd = () => {
+  if (!bookNoMore.value && !bookLoading.value) {
+    bookPage.value++;
     getBookList();
   }
 };
+
+const handleExhibit = (id) => {
+  if (id === exhibitId.value) return;
+
+  exhibitId.value = id;
+  resetExhibitParams();
+  getExhibitList();
+};
+
+const handleExhibitEnd = () => {
+  if (!exhibitNoMore.value && !exhibitLoading.value) {
+    exhibitPage.value++;
+    getExhibitList();
+  }
+};
+
+const handleInitClassify = (id) => {
+  if (activeTab.value === 0) {
+    if (storageId.value === 0) {
+      handleStorage(id);
+    } else {
+      getBookList();
+    }
+  } else {
+    if (exhibitId.value === 0) {
+      handleExhibit(id);
+    } else {
+      getExhibitList();
+    }
+  }
+};
 </script>
 
 <style lang="scss">

+ 1 - 1
packages/pc/src/configure.js

@@ -1,6 +1,6 @@
 import { compose, initial } from "@dage/service";
 import router from "./router";
-import { getUserInfo } from "@/utils";
+import { getUserInfo } from "@lsq/base";
 
 const showMessage = (msg, type = "error") => {
   ElMessage({

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

@@ -1,4 +1,4 @@
-import { getUserInfo } from "@/utils";
+import { getUserInfo } from "@lsq/base";
 import { createRouter, createWebHashHistory } from "vue-router";
 
 const router = createRouter({

+ 1 - 1
packages/pc/src/stores/base.js

@@ -1,7 +1,7 @@
 import { ref } from "vue";
 import { defineStore } from "pinia";
 import { getUserInfoApi, logoutApi } from "@/api";
-import { getUserInfo, USERINFO_KEY } from "@/utils";
+import { getUserInfo, USERINFO_KEY } from "@lsq/base";
 
 const userInfoStorage = getUserInfo();
 

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

@@ -1,16 +1,5 @@
 import { getBaseURL } from "@dage/service";
 
-export const USERINFO_KEY = "userinfo";
-
-export const getUserInfo = () => {
-  let userInfoStorage = localStorage.getItem(USERINFO_KEY);
-  if (userInfoStorage) {
-    userInfoStorage = JSON.parse(userInfoStorage);
-  }
-
-  return userInfoStorage;
-};
-
 export const isDevelopment = import.meta.env.MODE === "development";
 
 export const getBaseUrl = () => {

+ 8 - 11
packages/pc/src/views/Bookshelf/index.vue

@@ -130,18 +130,15 @@ const handleUpload = async (params) => {
   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;
-    }
+    avatarLoading.value = true;
+    const data = await uploadApi(formData);
+    await updateUserInfoApi({
+      avatarUrl: data.filePath,
+    });
+    userInfo.value.avatarUrl = data.filePath;
+    params.onSuccess();
   } catch (err) {
+    avatarLoading.value = false;
     ElMessage.error("头像上传失败");
     params.onError();
   }

+ 31 - 11
pnpm-lock.yaml

@@ -14,9 +14,6 @@ importers:
 
   packages/base:
     dependencies:
-      '@dage/service':
-        specifier: '*'
-        version: 1.0.1(lodash@4.17.21)
       chinese-conv:
         specifier: ^2.1.1
         version: 2.1.1
@@ -29,9 +26,18 @@ importers:
 
   packages/mobile:
     dependencies:
+      '@dage/service':
+        specifier: ^1.0.3
+        version: 1.0.3(lodash@4.17.21)
+      '@dage/utils':
+        specifier: ^1.0.2
+        version: 1.0.2(lodash@4.17.21)
       '@lsq/base':
         specifier: workspace:^
         version: link:../base
+      '@vueuse/router':
+        specifier: ^11.1.0
+        version: 11.1.0(vue-router@4.4.5)(vue@3.5.12)
       lodash:
         specifier: ^4.17.21
         version: 4.17.21
@@ -448,14 +454,6 @@ packages:
     resolution: {integrity: sha512-VHNVJbY5gAMvqur7pOmxZ8W9l4LRnwK/OqMIuAt4VLXLkldUTyyfJmWRkPpCdHDWNbn7bATgAA/+ziV01rozDA==}
     dev: false
 
-  /@dage/service@1.0.1(lodash@4.17.21):
-    resolution: {integrity: sha512-G2KJ3LGrTGFkbuZHopC6ZKynOwo0ilOYXHt6ihivqRIdCLp9NLLEMEMu7vpEpfjla6e5slCBfSKEfNB8Ol/isw==}
-    dependencies:
-      '@dage/utils': 1.0.2(lodash@4.17.21)
-    transitivePeerDependencies:
-      - lodash
-    dev: false
-
   /@dage/service@1.0.3(lodash@4.17.21):
     resolution: {integrity: sha512-rZKn9NUQWpZFtzreZyXIl1zK/4+3SsvosuNoq2LWFZl2DakniDdGuLOkkggb1hnRkmscTNQrZQOMpepYrglVWw==}
     dependencies:
@@ -1236,6 +1234,19 @@ packages:
       - vue
     dev: false
 
+  /@vueuse/router@11.1.0(vue-router@4.4.5)(vue@3.5.12):
+    resolution: {integrity: sha512-OjTNIOzX5jD1HDzmDApMke2QsCZ+gWKaydfndKJ3j9ttn41Pr11cQbSY6ZBp+bNjctAR+jhQBV/DGtL3iKpuHg==}
+    peerDependencies:
+      vue-router: '>=4.0.0-rc.1'
+    dependencies:
+      '@vueuse/shared': 11.1.0(vue@3.5.12)
+      vue-demi: 0.14.10(vue@3.5.12)
+      vue-router: 4.4.5(vue@3.5.12)
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+    dev: false
+
   /@vueuse/shared@11.1.0(vue@3.5.11):
     resolution: {integrity: sha512-YUtIpY122q7osj+zsNMFAfMTubGz0sn5QzE5gPzAIiCmtt2ha3uQUY1+JPyL4gRCTsLPX82Y9brNbo/aqlA91w==}
     dependencies:
@@ -1244,6 +1255,15 @@ packages:
       - '@vue/composition-api'
       - vue
 
+  /@vueuse/shared@11.1.0(vue@3.5.12):
+    resolution: {integrity: sha512-YUtIpY122q7osj+zsNMFAfMTubGz0sn5QzE5gPzAIiCmtt2ha3uQUY1+JPyL4gRCTsLPX82Y9brNbo/aqlA91w==}
+    dependencies:
+      vue-demi: 0.14.10(vue@3.5.12)
+    transitivePeerDependencies:
+      - '@vue/composition-api'
+      - vue
+    dev: false
+
   /@vueuse/shared@9.13.0(vue@3.5.11):
     resolution: {integrity: sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==}
     dependencies: