浏览代码

feat: mobile

chenlei 10 月之前
父节点
当前提交
f929dfdf09
共有 100 个文件被更改,包括 2585 次插入8 次删除
  1. 26 8
      packages/base/src/stores/epub.js
  2. 4 0
      packages/base/src/theme.scss
  3. 30 0
      packages/mobile/.gitignore
  4. 3 0
      packages/mobile/.vscode/extensions.json
  5. 15 0
      packages/mobile/index.html
  6. 8 0
      packages/mobile/jsconfig.json
  7. 33 0
      packages/mobile/package.json
  8. 22 0
      packages/mobile/postcss.config.js
  9. 1 0
      packages/mobile/public/epub.min.js
  10. 二进制
      packages/mobile/public/favicon.ico
  11. 二进制
      packages/mobile/public/images/bg.png
  12. 15 0
      packages/mobile/public/jszip.min.js
  13. 二进制
      packages/mobile/public/test.epub
  14. 二进制
      packages/mobile/public/test2.epub
  15. 24 0
      packages/mobile/src/App.vue
  16. 二进制
      packages/mobile/src/assets/fonts/SOURCEHANSERIFCN-BOLD.OTF
  17. 二进制
      packages/mobile/src/assets/fonts/SOURCEHANSERIFCN-REGULAR.OTF
  18. 二进制
      packages/mobile/src/assets/fonts/SourceHanSansCN-Regular.otf
  19. 二进制
      packages/mobile/src/assets/images/bg@2x-min.png
  20. 二进制
      packages/mobile/src/assets/images/icon_like_active@2x-min.png
  21. 二进制
      packages/mobile/src/assets/images/icon_like_normal@2x-min.png
  22. 二进制
      packages/mobile/src/assets/images/icon_portrait@2x-min.png
  23. 二进制
      packages/mobile/src/assets/images/logo2@2x-min.png
  24. 二进制
      packages/mobile/src/assets/images/logo@2x-min.png
  25. 162 0
      packages/mobile/src/assets/main.css
  26. 3 0
      packages/mobile/src/assets/svgs/icon_back.svg
  27. 8 0
      packages/mobile/src/assets/svgs/icon_comment_yellow.svg
  28. 5 0
      packages/mobile/src/assets/svgs/icon_copy.svg
  29. 5 0
      packages/mobile/src/assets/svgs/icon_delete.svg
  30. 11 0
      packages/mobile/src/assets/svgs/icon_edit.svg
  31. 5 0
      packages/mobile/src/assets/svgs/icon_eyes.svg
  32. 16 0
      packages/mobile/src/assets/svgs/icon_fullscreen_yellow.svg
  33. 5 0
      packages/mobile/src/assets/svgs/icon_like_yellow.svg
  34. 7 0
      packages/mobile/src/assets/svgs/icon_mark_yellow.svg
  35. 7 0
      packages/mobile/src/assets/svgs/icon_menu_yellow.svg
  36. 5 0
      packages/mobile/src/assets/svgs/icon_search.svg
  37. 7 0
      packages/mobile/src/assets/svgs/icon_search_yellow.svg
  38. 4 0
      packages/mobile/src/assets/svgs/icon_select.svg
  39. 7 0
      packages/mobile/src/assets/svgs/icon_setting_yellow.svg
  40. 5 0
      packages/mobile/src/assets/svgs/icon_time.svg
  41. 5 0
      packages/mobile/src/assets/svgs/icon_upload.svg
  42. 81 0
      packages/mobile/src/components/BookCard.vue
  43. 61 0
      packages/mobile/src/components/BookCard2.vue
  44. 95 0
      packages/mobile/src/components/NavBar.vue
  45. 42 0
      packages/mobile/src/components/SearchDivider.vue
  46. 71 0
      packages/mobile/src/components/SearchInput.vue
  47. 39 0
      packages/mobile/src/components/SvgIcon.jsx
  48. 二进制
      packages/mobile/src/components/Tabbar/images/icon_book_active@2x-min.png
  49. 二进制
      packages/mobile/src/components/Tabbar/images/icon_book_normal@2x-min.png
  50. 二进制
      packages/mobile/src/components/Tabbar/images/icon_home_active@2x-min.png
  51. 二进制
      packages/mobile/src/components/Tabbar/images/icon_home_normal@2x-min.png
  52. 二进制
      packages/mobile/src/components/Tabbar/images/icon_user_active@2x-min.png
  53. 二进制
      packages/mobile/src/components/Tabbar/images/icon_user_normal@2x-min.png
  54. 34 0
      packages/mobile/src/components/Tabbar/index.scss
  55. 72 0
      packages/mobile/src/components/Tabbar/index.vue
  56. 26 0
      packages/mobile/src/main.js
  57. 65 0
      packages/mobile/src/router/index.js
  58. 14 0
      packages/mobile/src/stores/detail.js
  59. 2 0
      packages/mobile/src/stores/index.js
  60. 82 0
      packages/mobile/src/views/Detail/components/ImgPane.vue
  61. 43 0
      packages/mobile/src/views/Detail/components/TextPane.vue
  62. 49 0
      packages/mobile/src/views/Detail/components/VideoPane.vue
  63. 二进制
      packages/mobile/src/views/Detail/images/bg1-min.png
  64. 二进制
      packages/mobile/src/views/Detail/images/bg2-min.png
  65. 二进制
      packages/mobile/src/views/Detail/images/btn@2x-min.png
  66. 二进制
      packages/mobile/src/views/Detail/images/icon_img_mb@2x-min.png
  67. 二进制
      packages/mobile/src/views/Detail/images/icon_img_mb_n@2x-min.png
  68. 二进制
      packages/mobile/src/views/Detail/images/icon_left@2x-min.png
  69. 二进制
      packages/mobile/src/views/Detail/images/icon_right@2x-min.png
  70. 二进制
      packages/mobile/src/views/Detail/images/icon_text_mb@2x-min.png
  71. 二进制
      packages/mobile/src/views/Detail/images/icon_text_mb_n@2x-min.png
  72. 二进制
      packages/mobile/src/views/Detail/images/icon_video_mb@2x-min.png
  73. 二进制
      packages/mobile/src/views/Detail/images/icon_video_mb_n@2x-min.png
  74. 66 0
      packages/mobile/src/views/Detail/index.scss
  75. 90 0
      packages/mobile/src/views/Detail/index.vue
  76. 92 0
      packages/mobile/src/views/Home/components/NewsDrawer.vue
  77. 61 0
      packages/mobile/src/views/Home/components/RankPane.vue
  78. 68 0
      packages/mobile/src/views/Home/components/SearchPane.vue
  79. 62 0
      packages/mobile/src/views/Home/components/SecondPane/index.scss
  80. 65 0
      packages/mobile/src/views/Home/components/SecondPane/index.vue
  81. 二进制
      packages/mobile/src/views/Home/images/icon_tip@3x-min.png
  82. 二进制
      packages/mobile/src/views/Home/images/img_tip@3x.png
  83. 二进制
      packages/mobile/src/views/Home/images/text_news@2x-min.png
  84. 二进制
      packages/mobile/src/views/Home/images/text_readomg@2x-min.png
  85. 二进制
      packages/mobile/src/views/Home/images/text_recommend@2x-min.png
  86. 4 0
      packages/mobile/src/views/Home/index.scss
  87. 13 0
      packages/mobile/src/views/Home/index.vue
  88. 28 0
      packages/mobile/src/views/ImgViewer/index.scss
  89. 20 0
      packages/mobile/src/views/ImgViewer/index.vue
  90. 76 0
      packages/mobile/src/views/Mine/components/EditNamePopup.vue
  91. 106 0
      packages/mobile/src/views/Mine/index.scss
  92. 74 0
      packages/mobile/src/views/Mine/index.vue
  93. 110 0
      packages/mobile/src/views/Reader/components/BookmarkPopup.vue
  94. 73 0
      packages/mobile/src/views/Reader/components/CommentPopup.vue
  95. 36 0
      packages/mobile/src/views/Reader/components/DirectoryPopup.vue
  96. 66 0
      packages/mobile/src/views/Reader/components/MsgItem.vue
  97. 87 0
      packages/mobile/src/views/Reader/components/NotePopup.vue
  98. 63 0
      packages/mobile/src/views/Reader/components/Popup.vue
  99. 101 0
      packages/mobile/src/views/Reader/components/SearchPopup.vue
  100. 0 0
      packages/mobile/src/views/Reader/components/SettingPopup.vue

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

@@ -38,17 +38,35 @@ export const useEpubStore = defineStore("epub", () => {
   const navigation = ref([]);
   const metadata = ref(null);
 
-  const init = (url) => {
-    initEpub(url);
-    initTheme();
-    goToChapter(localStorage.getItem(`${EPUB_LOCATION}-1`));
+  /**
+   * 初始化
+   * @param {Object} options 配置
+   * @example
+   * init({
+   *   // 文件路径
+   *   url: 'https://xxxx.epub',
+   *   // 主题列表
+   *   themes: THEMES,
+   *   // http://epubjs.org/documentation/0.3/#rendition
+   *   renderOptions: {
+   *     flow: "scrolled",
+         manager: "continuous",
+         snap: false,
+   *   }
+   * })
+   */
+  const init = (options = {}) => {
+    initEpub(options);
+    initTheme(options.themes);
+    goToChapter(localStorage.getItem(`${EPUB_LOCATION}-1`) || undefined);
   };
 
-  const initEpub = (url) => {
-    const _book = ePub(url);
+  const initEpub = (options) => {
+    const _book = ePub(options.url);
     const _rendition = _book.renderTo("reader", {
       width: "100%",
       height: "100%",
+      ...(options.renderOptions || {}),
     });
     console.log(_book, _rendition);
 
@@ -73,8 +91,8 @@ export const useEpubStore = defineStore("epub", () => {
     rendition.value = _rendition;
   };
 
-  const initTheme = () => {
-    THEMES.forEach((theme) => {
+  const initTheme = (themes) => {
+    (themes || THEMES).forEach((theme) => {
       rendition.value.themes.register(theme.key, {
         body: {
           color: theme.textColor,

+ 4 - 0
packages/base/src/theme.scss

@@ -8,6 +8,8 @@ html {
   --text-color-placeholder: rgba(70, 70, 70, 0.5);
   --color-primary-opacity-5: rgba(209, 187, 158, 0.5);
   --icon-color: #d1bb9e;
+  --icon-mb-color: #464646;
+  --icon-close-color: #585757;
 }
 
 /**
@@ -25,6 +27,8 @@ html.dark {
   --text-color-placeholder: rgba(236, 236, 236, 0.5);
   --color-primary-opacity-5: rgba(209, 187, 158, 0.5);
   --icon-color: #2c2c2c;
+  --icon-mb-color: white;
+  --icon-close-color: white;
 
   body,
   input {

+ 30 - 0
packages/mobile/.gitignore

@@ -0,0 +1,30 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+.DS_Store
+dist
+dist-ssr
+coverage
+*.local
+
+/cypress/videos/
+/cypress/screenshots/
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+*.tsbuildinfo

+ 3 - 0
packages/mobile/.vscode/extensions.json

@@ -0,0 +1,3 @@
+{
+  "recommendations": ["Vue.volar"]
+}

+ 15 - 0
packages/mobile/index.html

@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" href="/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>刘少奇同志纪念馆</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.js"></script>
+    <script type="text/javascript" src="./jszip.min.js"></script>
+    <script type="text/javascript" src="./epub.min.js"></script>
+  </body>
+</html>

+ 8 - 0
packages/mobile/jsconfig.json

@@ -0,0 +1,8 @@
+{
+  "compilerOptions": {
+    "paths": {
+      "@/*": ["./src/*"]
+    }
+  },
+  "exclude": ["node_modules", "dist"]
+}

+ 33 - 0
packages/mobile/package.json

@@ -0,0 +1,33 @@
+{
+  "name": "mobile",
+  "version": "0.0.0",
+  "private": true,
+  "type": "commonjs",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview"
+  },
+  "dependencies": {
+    "@lsq/base": "workspace:^",
+    "lodash": "^4.17.21",
+    "pinia": "^2.2.4",
+    "swiper": "^11.1.14",
+    "vant": "^4.9.7",
+    "vconsole": "^3.15.1",
+    "vue": "^3.5.11",
+    "vue-clipboard3": "^2.0.0",
+    "vue-router": "^4.4.5",
+    "vue-virtual-scroller": "2.0.0-beta.8"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^5.1.4",
+    "@vitejs/plugin-vue-jsx": "^4.0.1",
+    "autoprefixer": "^10.4.20",
+    "postcss-px-to-viewport": "^1.1.1",
+    "sass": "1.53.0",
+    "unplugin-vue-components": "^0.27.4",
+    "vite": "^5.4.8",
+    "vite-plugin-svg-icons": "^2.0.1"
+  }
+}

+ 22 - 0
packages/mobile/postcss.config.js

@@ -0,0 +1,22 @@
+module.exports = {
+  plugins: {
+    autoprefixer: {},
+    "postcss-px-to-viewport": {
+      unitToConvert: "px", // 需要转换的单位,默认为"px"
+      viewportWidth: 750, // 设计稿的视口宽度
+      unitPrecision: 5, // 单位转换后保留的精度
+      propList: ["*"], // 能转化为vw的属性列表
+      viewportUnit: "vw", // 希望使用的视口单位
+      fontViewportUnit: "vw", // 字体使用的视口单位
+      selectorBlackList: [], // 需要忽略的CSS选择器,不会转为视口单位,使用原有的px等单位。
+      minPixelValue: 1, // 设置最小的转换数值,如果为1的话,只有大于1的值会被转换
+      mediaQuery: true, // 媒体查询里的单位是否需要转换单位
+      replace: true, //  是否直接更换属性值,而不添加备用属性
+      exclude: [/node_modules/], // 忽略某些文件夹下的文件或特定文件,例如 'node_modules' 下的文件
+      include: undefined, // 如果设置了include,那将只有匹配到的文件才会被转换
+      landscape: false, // 是否添加根据 landscapeWidth 生成的媒体查询条件 @media (orientation: landscape)
+      landscapeUnit: "vw", // 横屏时使用的单位
+      landscapeWidth: 750, // 横屏时使用的视口宽度
+    },
+  },
+};

文件差异内容过多而无法显示
+ 1 - 0
packages/mobile/public/epub.min.js


二进制
packages/mobile/public/favicon.ico


二进制
packages/mobile/public/images/bg.png


文件差异内容过多而无法显示
+ 15 - 0
packages/mobile/public/jszip.min.js


二进制
packages/mobile/public/test.epub


二进制
packages/mobile/public/test2.epub


+ 24 - 0
packages/mobile/src/App.vue

@@ -0,0 +1,24 @@
+<script setup>
+import { computed } from "vue";
+import { useRoute, RouterView } from "vue-router";
+import Tabbar from "@/components/Tabbar/index.vue";
+
+const route = useRoute();
+
+const tabbarVisible = computed(() => !route.meta.hideTabbar);
+</script>
+
+<template>
+  <VanConfigProvider>
+    <RouterView />
+
+    <Tabbar :visible="tabbarVisible" />
+  </VanConfigProvider>
+</template>
+
+<style lang="scss">
+:root {
+  --tabbar-height: 112px;
+  --van-primary-color: #d3bfa2;
+}
+</style>

二进制
packages/mobile/src/assets/fonts/SOURCEHANSERIFCN-BOLD.OTF


二进制
packages/mobile/src/assets/fonts/SOURCEHANSERIFCN-REGULAR.OTF


二进制
packages/mobile/src/assets/fonts/SourceHanSansCN-Regular.otf


二进制
packages/mobile/src/assets/images/bg@2x-min.png


二进制
packages/mobile/src/assets/images/icon_like_active@2x-min.png


二进制
packages/mobile/src/assets/images/icon_like_normal@2x-min.png


二进制
packages/mobile/src/assets/images/icon_portrait@2x-min.png


二进制
packages/mobile/src/assets/images/logo2@2x-min.png


二进制
packages/mobile/src/assets/images/logo@2x-min.png


+ 162 - 0
packages/mobile/src/assets/main.css

@@ -0,0 +1,162 @@
+html,
+body,
+div,
+span,
+applet,
+object,
+iframe,
+h1,
+h2,
+h3,
+h4,
+h5,
+h6,
+p,
+blockquote,
+pre,
+a,
+abbr,
+acronym,
+address,
+big,
+cite,
+code,
+del,
+dfn,
+em,
+img,
+ins,
+kbd,
+q,
+s,
+samp,
+small,
+strike,
+strong,
+sub,
+sup,
+tt,
+var,
+b,
+u,
+i,
+center,
+dl,
+dt,
+dd,
+ol,
+ul,
+li,
+fieldset,
+form,
+label,
+legend,
+table,
+caption,
+tbody,
+tfoot,
+thead,
+tr,
+th,
+td,
+article,
+aside,
+canvas,
+details,
+embed,
+figure,
+figcaption,
+footer,
+header,
+hgroup,
+menu,
+nav,
+output,
+ruby,
+section,
+summary,
+time,
+mark,
+audio,
+video {
+  margin: 0;
+  padding: 0;
+  border: 0;
+  font-size: 100%;
+  font: inherit;
+  vertical-align: baseline;
+}
+/* HTML5 display-role reset for older browsers */
+article,
+aside,
+details,
+figcaption,
+figure,
+footer,
+header,
+hgroup,
+menu,
+nav,
+section {
+  display: block;
+}
+body,
+input {
+  font-size: 31px;
+  color: #464646;
+  font-family: "Source Han Sans CN-Regular";
+}
+ol,
+ul {
+  list-style: none;
+}
+blockquote,
+q {
+  quotes: none;
+}
+blockquote:before,
+blockquote:after,
+q:before,
+q:after {
+  content: "";
+  content: none;
+}
+table {
+  border-collapse: collapse;
+  border-spacing: 0;
+}
+a {
+  color: inherit;
+  text-decoration: none;
+}
+
+.limit-line {
+  display: -webkit-box;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  -webkit-line-clamp: 1;
+  -webkit-box-orient: vertical;
+  word-break: break-all;
+  word-wrap: break-word;
+}
+
+.line-2 {
+  -webkit-line-clamp: 2;
+}
+
+.line-3 {
+  -webkit-line-clamp: 3;
+}
+
+@font-face {
+  font-family: "Source Han Sans CN-Regular";
+  src: url("@/assets/fonts/SourceHanSansCN-Regular.otf");
+}
+@font-face {
+  font-family: "Source Han Serif CN-Bold";
+  src: url("@/assets/fonts/SOURCEHANSERIFCN-BOLD.otf");
+}
+@font-face {
+  font-family: "Source Han Serif CN-Regular";
+  src: url("@/assets/fonts/SOURCEHANSERIFCN-REGULAR.otf");
+}

+ 3 - 0
packages/mobile/src/assets/svgs/icon_back.svg

@@ -0,0 +1,3 @@
+<svg width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M19 7L11 15L19 23" stroke="currentColor" stroke-width="3"/>
+</svg>

文件差异内容过多而无法显示
+ 8 - 0
packages/mobile/src/assets/svgs/icon_comment_yellow.svg


文件差异内容过多而无法显示
+ 5 - 0
packages/mobile/src/assets/svgs/icon_copy.svg


文件差异内容过多而无法显示
+ 5 - 0
packages/mobile/src/assets/svgs/icon_delete.svg


文件差异内容过多而无法显示
+ 11 - 0
packages/mobile/src/assets/svgs/icon_edit.svg


文件差异内容过多而无法显示
+ 5 - 0
packages/mobile/src/assets/svgs/icon_eyes.svg


文件差异内容过多而无法显示
+ 16 - 0
packages/mobile/src/assets/svgs/icon_fullscreen_yellow.svg


文件差异内容过多而无法显示
+ 5 - 0
packages/mobile/src/assets/svgs/icon_like_yellow.svg


文件差异内容过多而无法显示
+ 7 - 0
packages/mobile/src/assets/svgs/icon_mark_yellow.svg


文件差异内容过多而无法显示
+ 7 - 0
packages/mobile/src/assets/svgs/icon_menu_yellow.svg


文件差异内容过多而无法显示
+ 5 - 0
packages/mobile/src/assets/svgs/icon_search.svg


文件差异内容过多而无法显示
+ 7 - 0
packages/mobile/src/assets/svgs/icon_search_yellow.svg


文件差异内容过多而无法显示
+ 4 - 0
packages/mobile/src/assets/svgs/icon_select.svg


文件差异内容过多而无法显示
+ 7 - 0
packages/mobile/src/assets/svgs/icon_setting_yellow.svg


文件差异内容过多而无法显示
+ 5 - 0
packages/mobile/src/assets/svgs/icon_time.svg


文件差异内容过多而无法显示
+ 5 - 0
packages/mobile/src/assets/svgs/icon_upload.svg


+ 81 - 0
packages/mobile/src/components/BookCard.vue

@@ -0,0 +1,81 @@
+<template>
+  <div
+    class="book-card"
+    @click="$router.push({ name: 'detail', params: { id: 1 } })"
+  >
+    <div class="book-card__cover">
+      <van-image src="" />
+
+      <img
+        v-if="isLike"
+        class="book-card__like"
+        src="@/assets/images/icon_like_active@2x-min.png"
+      />
+      <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>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  isLike: {
+    type: Boolean,
+    default: false,
+  },
+  isRead: {
+    type: Boolean,
+    default: false,
+  },
+});
+</script>
+
+<style scoped lang="scss">
+.book-card {
+  width: 223px;
+
+  &__cover {
+    position: relative;
+    width: inherit;
+    height: 313px;
+    border-radius: 4px;
+    overflow: hidden;
+
+    .van-image {
+      width: 100%;
+      height: 100%;
+    }
+  }
+  &__title {
+    margin-top: 15px;
+    font-family: "Source Han Serif CN-Bold";
+  }
+  &__sub {
+    font-size: 27px;
+    color: var(--text-color-secondary);
+  }
+  &__like {
+    position: absolute;
+    top: 5px;
+    right: 5px;
+    width: 60px;
+    height: 60px;
+  }
+  &__read {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    position: absolute;
+    top: 15px;
+    right: 13px;
+    width: 83px;
+    height: 46px;
+    color: white;
+    font-size: 23px;
+    background: rgba(0, 0, 0, 0.3);
+    border-radius: 4px;
+  }
+}
+</style>

+ 61 - 0
packages/mobile/src/components/BookCard2.vue

@@ -0,0 +1,61 @@
+<template>
+  <div class="book-card2">
+    <van-image class="book-card2__cover" src="" :width="122" :height="157" />
+
+    <div class="book-card2-inner">
+      <h3 class="limit-line">刘少奇传-上</h3>
+      <div class="book-card2-inner__info">
+        <p class="limit-line">金冲及</p>
+        <p class="limit-line">中共中央文献研究室 编</p>
+      </div>
+      <div class="book-card2-inner__read">
+        <span>开始阅读</span>
+        <van-icon name="arrow" />
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+/* prettier-ignore */
+.book-card2 {
+  display: flex;
+  align-items: center;
+  gap: 48px;
+  position: relative;
+  margin: 30PX 40px 0;
+  padding: 13PX 35px;
+  height: 137PX;
+  border-radius: 20px;
+  box-shadow: 0px 0 20PX 0px rgba(153, 135, 110, 0.25);
+  background: var(--van-white);
+  box-sizing: border-box;
+
+  &__cover {
+    flex-shrink: 0;
+    position: relative;
+    bottom: 23PX;
+  }
+  &-inner {
+    h3 {
+      font-family: 'Source Han Serif CN-Bold';
+    }
+    p {
+      font-size: 27px;
+      color: var(--text-color-secondary);
+    }
+    &__info {
+      margin: 20px 0 30px;
+    }
+    &__read {
+      font-size: 27px;
+      color:var(--van-primary-color);
+      line-height: 20px;
+
+      span {
+        padding-right: 15px;
+      }
+    }
+  }
+}
+</style>

+ 95 - 0
packages/mobile/src/components/NavBar.vue

@@ -0,0 +1,95 @@
+<template>
+  <div
+    class="nav-bar"
+    :style="{
+      background: needBgColor
+        ? `rgba(180, 157, 126, ${opacity})`
+        : 'transparent',
+    }"
+  >
+    <svg-icon
+      class="nav-bar__back"
+      name="icon_back"
+      width="30px"
+      height="30px"
+      :color="backColor"
+      @click="$router.back()"
+    />
+
+    <div class="nav-bar__tag">
+      <img src="@/assets/images/icon_like_normal@2x-min.png" />
+      <span>加入书架</span>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { debounce } from "lodash";
+import { onMounted, onUnmounted, ref } from "vue";
+
+const props = defineProps({
+  needBgColor: {
+    type: Boolean,
+    default: true,
+  },
+  backColor: {
+    type: String,
+    default: "white",
+  },
+});
+
+const opacity = ref(0);
+
+onMounted(() => {
+  props.needBgColor && window.addEventListener("scroll", handleScroll);
+});
+onUnmounted(() => {
+  props.needBgColor && window.removeEventListener("scroll", handleScroll);
+});
+
+const handleScroll = debounce(() => {
+  const scrollY = window.scrollY;
+
+  if (scrollY <= 225) {
+    opacity.value = Math.round((scrollY / 225) * 100) / 100;
+  } else {
+    opacity.value = 1;
+  }
+}, 10);
+</script>
+
+<style lang="scss" scoped>
+.nav-bar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding-top: 10px;
+  height: 100px;
+  z-index: 99;
+
+  &__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>

+ 42 - 0
packages/mobile/src/components/SearchDivider.vue

@@ -0,0 +1,42 @@
+<template>
+  <div class="search-divider">
+    <div class="search-divider-inner">
+      <van-loading v-if="type === 'loading'" />
+      <p v-else-if="type === 'default'">搜索结果</p>
+      <p v-else-if="type === 'noData'">暂无结果</p>
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  /**
+   * 状态
+   * @enum 'default' | 'loading' | 'noData'
+   */
+  type: {
+    type: String,
+    default: "default",
+  },
+});
+</script>
+
+<style lang="scss" scoped>
+.search-divider {
+  display: flex;
+  align-items: center;
+
+  &-inner {
+    flex-shrink: 0;
+    padding: 0 30px;
+  }
+  &::after,
+  &::before {
+    content: "";
+    flex: 1;
+    height: 1px;
+    background: #464646;
+    opacity: 0.2;
+  }
+}
+</style>

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

@@ -0,0 +1,71 @@
+<template>
+  <div class="search-input" :class="{ simple }">
+    <input
+      :value="modelValue"
+      placeholder="请输入关键词..."
+      @input="emits('update:modelValue', $event.target.value)"
+    />
+    <div class="search-input__icon" @click="emits('search')">
+      <svg-icon
+        name="icon_search"
+        width="24px"
+        height="24px"
+        :color="simple ? 'var(--van-primary-color)' : 'white'"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+const props = defineProps({
+  modelValue: {
+    type: String,
+    required: true,
+  },
+  simple: {
+    type: Boolean,
+    default: false,
+  },
+});
+const emits = defineEmits(["update:modelValue", "search"]);
+</script>
+
+<style lang="scss" scoped>
+.search-input {
+  position: relative;
+  padding: 0 105px 0 40px;
+  width: 100%;
+  height: 96px;
+  overflow: hidden;
+  border-radius: 100px;
+  background: rgba(255, 255, 255, 0.5);
+  border: 2px solid var(--van-primary-color);
+  box-sizing: border-box;
+
+  &.simple {
+    .search-input__icon {
+      background: none;
+    }
+  }
+  input {
+    border: none;
+    width: 100%;
+    height: 100%;
+    background: transparent;
+    box-sizing: border-box;
+  }
+  &__icon {
+    position: absolute;
+    top: 50%;
+    right: 8px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 77px;
+    height: 77px;
+    border-radius: 50%;
+    background: var(--van-primary-color);
+    transform: translateY(-50%);
+  }
+}
+</style>

+ 39 - 0
packages/mobile/src/components/SvgIcon.jsx

@@ -0,0 +1,39 @@
+import { defineComponent } from "vue";
+
+// TOFIX: .vue后缀会被unplugin-vue-components自动注册导致冲突
+export default defineComponent({
+  props: {
+    // xlink:href的属性值前缀
+    prefix: {
+      type: String,
+      default: "#icon-",
+    },
+    // 需要使用的svg的图标的名字
+    name: {
+      type: String,
+      required: true,
+    },
+    // 需要使用的svg的图标的颜色
+    color: {
+      type: String,
+      default: "var(--van-text-color-2)",
+    },
+    // 需要使用的svg的图标的宽度
+    width: {
+      type: String,
+      default: "16px",
+    },
+    // 需要使用的svg的图标的高度
+    height: {
+      type: String,
+      default: "16px",
+    },
+  },
+  render() {
+    return (
+      <svg style={{ width: this.width, height: this.height }}>
+        <use xlink:href={this.prefix + this.name} color={this.color}></use>
+      </svg>
+    );
+  },
+});

二进制
packages/mobile/src/components/Tabbar/images/icon_book_active@2x-min.png


二进制
packages/mobile/src/components/Tabbar/images/icon_book_normal@2x-min.png


二进制
packages/mobile/src/components/Tabbar/images/icon_home_active@2x-min.png


二进制
packages/mobile/src/components/Tabbar/images/icon_home_normal@2x-min.png


二进制
packages/mobile/src/components/Tabbar/images/icon_user_active@2x-min.png


二进制
packages/mobile/src/components/Tabbar/images/icon_user_normal@2x-min.png


+ 34 - 0
packages/mobile/src/components/Tabbar/index.scss

@@ -0,0 +1,34 @@
+.tabbar {
+  position: fixed;
+  left: 60px;
+  right: 60px;
+  bottom: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 110px;
+  border-radius: 100px;
+  background: rgba(255, 255, 255, 0.8);
+  box-shadow: 0px 0 72px 0px rgba(102, 86, 65, 0.2);
+  transition: bottom ease-in-out 0.2s;
+  z-index: 999;
+
+  &.hide {
+    bottom: -50%;
+  }
+  &-item {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    font-size: 20px;
+    color: #c8c4be;
+
+    &.active {
+      color: #a99271;
+    }
+    img {
+      width: 60px;
+      height: 60px;
+    }
+  }
+}

+ 72 - 0
packages/mobile/src/components/Tabbar/index.vue

@@ -0,0 +1,72 @@
+<template>
+  <div class="tabbar" :class="{ hide: !visible }">
+    <div
+      v-for="(item, index) in LIST"
+      :key="item.label"
+      class="tabbar-item"
+      :class="{ active: index === activeIndex }"
+      @click="
+        $router.push({
+          name: Array.isArray(item.routeName)
+            ? item.routeName[0]
+            : item.routeName,
+        })
+      "
+    >
+      <img :src="index === activeIndex ? item.activeIcon : item.icon" />
+      <p>{{ item.label }}</p>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { useRoute } from "vue-router";
+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";
+import BookActiveIcon from "./images/icon_book_active@2x-min.png";
+import UserIcon from "./images/icon_user_normal@2x-min.png";
+import UserActiveIcon from "./images/icon_user_active@2x-min.png";
+
+defineProps({
+  visible: {
+    type: Boolean,
+    default: false,
+  },
+});
+
+const LIST = [
+  {
+    label: "首页",
+    icon: HomeIcon,
+    activeIcon: HomeActiveIcon,
+    routeName: ["home", "home2"],
+  },
+  {
+    label: "书库",
+    icon: BookIcon,
+    activeIcon: BookActiveIcon,
+    routeName: "stack",
+  },
+  {
+    label: "我的",
+    icon: UserIcon,
+    activeIcon: UserActiveIcon,
+    routeName: "mine",
+  },
+];
+
+const route = useRoute();
+const activeIndex = computed(() => {
+  return LIST.findIndex((item) =>
+    Array.isArray(item.routeName)
+      ? item.routeName.includes(route.name)
+      : item.routeName === route.name
+  );
+});
+</script>
+
+<style lang="scss" scoped>
+@import "./index.scss";
+</style>

+ 26 - 0
packages/mobile/src/main.js

@@ -0,0 +1,26 @@
+import "@lsq/base/src/theme.scss";
+import "swiper/css";
+import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
+import "./assets/main.css";
+import "virtual:svg-icons-register";
+
+import { createApp } from "vue";
+import { createPinia } from "pinia";
+
+import App from "./App.vue";
+import router from "./router";
+
+import SvgIcon from "./components/SvgIcon";
+import { Lazyload } from "vant";
+
+import VConsole from "vconsole";
+const vConsole = new VConsole();
+
+const app = createApp(App);
+
+app.use(createPinia());
+app.use(router);
+app.use(Lazyload);
+app.component("svg-icon", SvgIcon);
+
+app.mount("#app");

+ 65 - 0
packages/mobile/src/router/index.js

@@ -0,0 +1,65 @@
+import { createRouter, createWebHashHistory } from "vue-router";
+
+const router = createRouter({
+  history: createWebHashHistory(import.meta.env.BASE_URL),
+  routes: [
+    {
+      path: "/",
+      name: "home",
+      component: () => import("@/views/Home/index.vue"),
+      meta: {},
+    },
+    {
+      path: "/home",
+      name: "home2",
+      component: () => import("@/views/Home/components/SecondPane/index.vue"),
+      meta: {},
+    },
+    {
+      path: "/stack",
+      name: "stack",
+      component: () => import("@/views/Stack/index.vue"),
+      meta: {},
+    },
+    {
+      path: "/mine",
+      name: "mine",
+      component: () => import("@/views/Mine/index.vue"),
+      meta: {},
+    },
+    {
+      path: "/search",
+      name: "search",
+      component: () => import("@/views/Search/index.vue"),
+      meta: {
+        hideTabbar: true,
+      },
+    },
+    {
+      path: "/detail/:id",
+      name: "detail",
+      component: () => import("@/views/Detail/index.vue"),
+      meta: {
+        hideTabbar: true,
+      },
+    },
+    {
+      path: "/img-viewer",
+      name: "imgViewer",
+      component: () => import("@/views/ImgViewer/index.vue"),
+      meta: {
+        hideTabbar: true,
+      },
+    },
+    {
+      path: "/reader",
+      name: "reader",
+      component: () => import("@/views/Reader/index.vue"),
+      meta: {
+        hideTabbar: true,
+      },
+    },
+  ],
+});
+
+export default router;

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

@@ -0,0 +1,14 @@
+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 };
+});

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

@@ -0,0 +1,2 @@
+export * from "@lsq/base/src/stores";
+export * from "./detail";

+ 82 - 0
packages/mobile/src/views/Detail/components/ImgPane.vue

@@ -0,0 +1,82 @@
+<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>
+
+    <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>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { Swiper, SwiperSlide } from "swiper/vue";
+
+const swiperRef = ref(null);
+</script>
+
+<style lang="scss" scoped>
+.img-pane {
+  position: relative;
+  margin: 10px -70px;
+  padding-bottom: calc(120px + 65px);
+
+  &__img {
+    width: 100%;
+    height: 727px;
+  }
+  &-footer {
+    position: fixed;
+    left: 0;
+    right: 0;
+    bottom: 65px;
+    padding: 0 80px;
+    z-index: 99;
+
+    .van-button {
+      width: 100%;
+      height: 120px;
+      font-size: 31px;
+      background: url("../images/btn@2x-min.png") no-repeat center / cover;
+      border: none;
+    }
+  }
+  &__prev-icon,
+  &__next-icon {
+    position: absolute;
+    top: 310px;
+    width: 80px;
+    height: 80px;
+    z-index: 98;
+  }
+  &__prev-icon {
+    left: 33px;
+  }
+  &__next-icon {
+    right: 42px;
+  }
+}
+</style>

+ 43 - 0
packages/mobile/src/views/Detail/components/TextPane.vue

@@ -0,0 +1,43 @@
+<template>
+  <div class="text-pane">
+    <p>
+      蜿蜒曲折的湘江,像一条绿色的玉带,从南到北缓缓穿越湖南全省,注入中国第二大淡水湖洞庭湖。在湘江西侧的宁乡县境内,有一个普普通通的小山村,叫炭子冲。相传在很久以前,这一带有不少人以伐木烧炭为生,是烧炭人居住和落脚的地方,因此得名炭子冲。
+      “冲”​,是湖南老百姓对山间小块平原的称呼。炭子冲,就是一块夹在两座山岭之间的平地。它的北面背靠着连绵不绝的丘陵,东西两边是长满了密密层层各色杂树的山坡,南面是平坦的农田和宁静的池塘。湘江的支流靳江,在它的西南角不远处淙淙流过。顺着冲口的大路往东北方向行进,约莫四十来公里,便到了湘江。湘江对岸,就是湖南省省会长沙。
+      炭子冲在行政建制上属于湖南省宁乡县花明楼乡。这一带有山有水,盛产稻米、林木、烟叶,是湖南中部较为富庶的地区。由于这里离省会和县城都不远,交通便利,外面的信息容易传播进来,文化也比较发达。
+    </p>
+
+    <div class="text-pane-footer">
+      <van-button type="primary" @click="$router.push({ name: 'reader' })"
+        >开始阅读</van-button
+      >
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.text-pane {
+  position: relative;
+  padding-bottom: calc(120px + 65px);
+
+  > p {
+    font-size: 35px;
+    line-height: 60px;
+  }
+  &-footer {
+    position: fixed;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    padding: 100px 80px 65px;
+    background: linear-gradient(to bottom, transparent, white);
+
+    .van-button {
+      width: 100%;
+      height: 120px;
+      font-size: 31px;
+      background: url("../images/btn@2x-min.png") no-repeat center / cover;
+      border: none;
+    }
+  }
+}
+</style>

+ 49 - 0
packages/mobile/src/views/Detail/components/VideoPane.vue

@@ -0,0 +1,49 @@
+<template>
+  <div class="video-pane">
+    <div v-for="item in 8" :key="item" class="video-item">
+      <van-image class="video-item__cover" src="" />
+
+      <div class="video-item-inner">
+        <h3>学习求索</h3>
+        <p class="limit-line line-3">
+          刘少奇(1898年11月24日-1969年11月12日),生于湖南省宁乡县,伟大的马克思主义者,伟大的无产阶级革命家、政治家、理论家,党和国家主要领导人之一,中华人民共和国开国元勋,是以毛泽东同志为核心的党的第一代中央领导集体的重要成员
+        </p>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.video-pane {
+  display: flex;
+  flex-direction: column;
+  gap: 48px;
+}
+
+.video-item {
+  display: flex;
+  align-items: center;
+  gap: 30px;
+  padding: 20px 40px 20px 20px;
+  background: white;
+  border-radius: 20px;
+
+  &__cover {
+    flex-shrink: 0;
+    width: 270px;
+    height: 190px;
+  }
+  &-inner {
+    flex: 1;
+
+    h3 {
+      margin-bottom: 30px;
+      font-family: "Source Han Serif CN-Bold";
+    }
+    p {
+      font-size: 23px;
+      color: var(--text-color-secondary);
+    }
+  }
+}
+</style>

二进制
packages/mobile/src/views/Detail/images/bg1-min.png


二进制
packages/mobile/src/views/Detail/images/bg2-min.png


二进制
packages/mobile/src/views/Detail/images/btn@2x-min.png


二进制
packages/mobile/src/views/Detail/images/icon_img_mb@2x-min.png


二进制
packages/mobile/src/views/Detail/images/icon_img_mb_n@2x-min.png


二进制
packages/mobile/src/views/Detail/images/icon_left@2x-min.png


二进制
packages/mobile/src/views/Detail/images/icon_right@2x-min.png


二进制
packages/mobile/src/views/Detail/images/icon_text_mb@2x-min.png


二进制
packages/mobile/src/views/Detail/images/icon_text_mb_n@2x-min.png


二进制
packages/mobile/src/views/Detail/images/icon_video_mb@2x-min.png


二进制
packages/mobile/src/views/Detail/images/icon_video_mb_n@2x-min.png


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

@@ -0,0 +1,66 @@
+.detail {
+  &-top {
+    position: relative;
+    height: 450px;
+    background: url("./images/bg1-min.png") no-repeat center / cover;
+  }
+  &-info {
+    display: flex;
+    align-items: center;
+    gap: 48px;
+    position: relative;
+    top: 142px;
+    padding: 0 40px;
+
+    &__cover {
+      flex-shrink: 0;
+      width: 329px;
+      height: 429px;
+      border-radius: 4px;
+      overflow: hidden;
+    }
+    &-inner {
+      color: white;
+
+      h3 {
+        margin-bottom: 5px;
+        font-size: 46px;
+        font-family: "Source Han Serif CN-Bold";
+      }
+      > p {
+        font-size: 27px;
+      }
+    }
+    &-tabs {
+      display: flex;
+      justify-content: space-between;
+      margin: 60px -20px 0;
+
+      &__item {
+        display: flex;
+        flex-direction: column;
+        align-items: center;
+        color: var(--text-color-placeholder);
+
+        img {
+          width: 110px;
+          height: 110px;
+        }
+        &.active {
+          color: var(--van-primary-color);
+        }
+      }
+    }
+  }
+  &-main {
+    padding: 160px 70px 40px;
+    min-height: calc(100vh - 450px);
+    background: url("./images/bg2-min.png") no-repeat top center / cover;
+
+    h3 {
+      margin-bottom: 20px;
+      font-size: 46px;
+      font-family: "Source Han Serif CN-Bold";
+    }
+  }
+}

+ 90 - 0
packages/mobile/src/views/Detail/index.vue

@@ -0,0 +1,90 @@
+<template>
+  <div class="detail">
+    <nav-bar />
+
+    <div class="detail-top">
+      <div class="detail-info">
+        <van-image class="detail-info__cover" src="" />
+
+        <div class="detail-info-inner">
+          <h3>刘少奇传-上</h3>
+          <p>金冲及</p>
+          <p>中共中央文献研究室 编</p>
+
+          <div class="detail-info-tabs">
+            <div
+              v-for="(item, index) in TABS"
+              :key="item.label"
+              class="detail-info-tabs__item"
+              :class="{ active: activeTab === index }"
+              @click="activeTab = index"
+            >
+              <img :src="activeTab === index ? item.activeIcon : item.icon" />
+              <span>{{ item.label }}</span>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <div class="detail-main">
+      <h3>{{ TABS[activeTab].mainTitle }}</h3>
+
+      <component :is="comp" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed } from "vue";
+import NavBar from "@/components/NavBar.vue";
+import TextPane from "./components/TextPane.vue";
+import VideoPane from "./components/VideoPane.vue";
+import ImgPane from "./components/ImgPane.vue";
+import TextIcon from "./images/icon_text_mb_n@2x-min.png";
+import TextAcIcon from "./images/icon_text_mb@2x-min.png";
+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";
+
+const TABS = [
+  {
+    key: "text",
+    label: "文本",
+    icon: TextIcon,
+    activeIcon: TextAcIcon,
+    mainTitle: "作品简介",
+  },
+  {
+    key: "video",
+    label: "视频",
+    icon: videoIcon,
+    activeIcon: videoAcIcon,
+    mainTitle: "相关视频",
+  },
+  {
+    key: "img",
+    label: "影印",
+    icon: ImgIcon,
+    activeIcon: ImgAcIcon,
+    mainTitle: "影印文件",
+  },
+];
+
+const activeTab = ref(0);
+const comp = computed(() => {
+  switch (TABS[activeTab.value].key) {
+    case "video":
+      return VideoPane;
+    case "img":
+      return ImgPane;
+    default:
+      return TextPane;
+  }
+});
+</script>
+
+<style lang="scss" scoped>
+@import "./index.scss";
+</style>

文件差异内容过多而无法显示
+ 92 - 0
packages/mobile/src/views/Home/components/NewsDrawer.vue


+ 61 - 0
packages/mobile/src/views/Home/components/RankPane.vue

@@ -0,0 +1,61 @@
+<template>
+  <div class="rank-pane">
+    <div class="rank-pane-header">
+      <div class="rank-pane-header__title">
+        <slot name="title-prepend" />
+        <span>排行榜</span>
+      </div>
+
+      <p class="rank-pane-header__more">More <span>+</span></p>
+    </div>
+
+    <swiper
+      :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>
+    </swiper>
+  </div>
+</template>
+
+<script setup>
+import { Swiper, SwiperSlide } from "swiper/vue";
+import BookCard from "@/components/BookCard.vue";
+</script>
+
+<style lang="scss" scoped>
+.rank-pane {
+  position: relative;
+  margin: 0 -55px;
+
+  &-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    margin-bottom: 25px;
+    padding: 0 55px;
+
+    &__title {
+      display: flex;
+      align-items: flex-end;
+      gap: 4px;
+
+      span {
+        position: relative;
+        bottom: 6px;
+        font-family: "Source Han Serif CN-Bold";
+      }
+    }
+    &__more {
+      font-size: 23px;
+
+      span {
+        color: var(--van-primary-color);
+      }
+    }
+  }
+}
+</style>

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

@@ -0,0 +1,68 @@
+<template>
+  <div class="search-pane">
+    <div class="search-pane-main">
+      <img class="logo" src="@/assets/images/logo2@2x-min.png" />
+
+      <search-input />
+
+      <div class="search-pane-more">
+        <span class="limit-line">共收录132件藏品,查看书库</span>
+      </div>
+    </div>
+
+    <div class="search-pane-tips" @click="$router.push({ name: 'home2' })">
+      <span>点击查看更多</span>
+      <img src="../images/icon_tip@3x-min.png" />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import SearchInput from "../../../components/SearchInput.vue";
+</script>
+
+<style lang="scss" scoped>
+.search-pane {
+  padding: 0 55px;
+
+  &-main {
+    position: relative;
+    top: 300px;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+
+    .logo {
+      width: calc(100% - 80px);
+    }
+  }
+  &-more {
+    padding: 0 70px 0 23px;
+    width: 385px;
+    height: 45px;
+    line-height: 45px;
+    font-size: 23px;
+    color: var(--van-primary-color);
+    background: url("../images/img_tip@3x.png") no-repeat center / contain;
+    box-sizing: border-box;
+  }
+  &-tips {
+    position: absolute;
+    left: 50%;
+    bottom: calc(var(--tabbar-height) + 80px);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 17px;
+    color: #a99271;
+    transform: translateX(-50%);
+
+    img {
+      width: 60px;
+    }
+  }
+  .search-input {
+    margin: 30px 0 20px;
+  }
+}
+</style>

+ 62 - 0
packages/mobile/src/views/Home/components/SecondPane/index.scss

@@ -0,0 +1,62 @@
+.second-pane {
+  padding: 0 55px calc(var(--tabbar-height) + 80px);
+  min-height: 100vh;
+  overflow-y: auto;
+  background: url("@/assets/images/bg@2x-min.png") no-repeat top center / cover;
+
+  .logo {
+    display: block;
+    margin: 35px auto 27px;
+    width: calc(100% - 100px);
+  }
+  &__title-prepend {
+    display: block;
+    width: 123px;
+    height: 88px;
+  }
+  &-recommend {
+    margin-top: 70px;
+  }
+  &-read,
+  &-news {
+    margin-top: 60px;
+  }
+  &-news {
+    &-header {
+      display: flex;
+      align-items: flex-end;
+      gap: 4px;
+      margin-bottom: 25px;
+
+      span {
+        position: relative;
+        bottom: 6px;
+        font-family: "Source Han Serif CN-Bold";
+      }
+    }
+  }
+  &-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;
+      }
+    }
+  }
+}

+ 65 - 0
packages/mobile/src/views/Home/components/SecondPane/index.vue

@@ -0,0 +1,65 @@
+<template>
+  <div class="second-pane">
+    <img class="logo" src="@/assets/images/logo@2x-min.png" />
+
+    <search-input />
+
+    <rank-pane class="second-pane-recommend">
+      <template #title-prepend>
+        <img
+          class="second-pane__title-prepend"
+          src="../../images/text_recommend@2x-min.png"
+        />
+      </template>
+    </rank-pane>
+
+    <rank-pane class="second-pane-read">
+      <template #title-prepend>
+        <img
+          class="second-pane__title-prepend"
+          src="../../images/text_readomg@2x-min.png"
+        />
+      </template>
+    </rank-pane>
+
+    <div class="second-pane-news">
+      <div class="second-pane-news-header">
+        <img
+          class="second-pane__title-prepend"
+          src="../../images/text_news@2x-min.png"
+        />
+        <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>
+    </div>
+  </div>
+
+  <news-drawer v-model:show="newsDetailVisible" />
+</template>
+
+<script setup>
+import { ref } from "vue";
+import SearchInput from "../../../../components/SearchInput.vue";
+import RankPane from "../RankPane.vue";
+import NewsDrawer from "../NewsDrawer.vue";
+
+const newsDetailVisible = ref(false);
+</script>
+
+<style lang="scss" scoped>
+@import "./index.scss";
+</style>

二进制
packages/mobile/src/views/Home/images/icon_tip@3x-min.png


二进制
packages/mobile/src/views/Home/images/img_tip@3x.png


二进制
packages/mobile/src/views/Home/images/text_news@2x-min.png


二进制
packages/mobile/src/views/Home/images/text_readomg@2x-min.png


二进制
packages/mobile/src/views/Home/images/text_recommend@2x-min.png


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

@@ -0,0 +1,4 @@
+.home {
+  height: 100vh;
+  background: url("@/assets/images/bg@2x-min.png") no-repeat top center / cover;
+}

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

@@ -0,0 +1,13 @@
+<template>
+  <div class="home">
+    <search-pane />
+  </div>
+</template>
+
+<script setup>
+import SearchPane from "./components/SearchPane.vue";
+</script>
+
+<style lang="scss" scoped>
+@import "./index.scss";
+</style>

+ 28 - 0
packages/mobile/src/views/ImgViewer/index.scss

@@ -0,0 +1,28 @@
+.img-viewer {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  min-height: 100vh;
+  background: #434343;
+
+  &__back {
+    position: fixed;
+    top: 40px;
+    left: 40px;
+    z-index: 99;
+  }
+  .van-image {
+    min-height: 200px;
+  }
+  &__pagination {
+    position: fixed;
+    left: 50%;
+    bottom: 65px;
+    padding: 10px 40px;
+    border-radius: 100px;
+    font-family: "Source Han Serif CN-Bold";
+    background: rgba(169, 146, 113, 0.3);
+    transform: translateX(-50%);
+    z-index: 99;
+  }
+}

+ 20 - 0
packages/mobile/src/views/ImgViewer/index.vue

@@ -0,0 +1,20 @@
+<template>
+  <div class="img-viewer">
+    <svg-icon
+      class="img-viewer__back"
+      name="icon_back"
+      width="30px"
+      height="30px"
+      color="#464646"
+      @click="$router.back()"
+    />
+
+    <div class="img-viewer__pagination">12/24</div>
+
+    <van-image width="100%" src="" lazy-load fit="contain" />
+  </div>
+</template>
+
+<style lang="scss" scoped>
+@import "./index.scss";
+</style>

+ 76 - 0
packages/mobile/src/views/Mine/components/EditNamePopup.vue

@@ -0,0 +1,76 @@
+<template>
+  <van-popup v-model:show="visible" class="edit-name" position="center">
+    <p>修改昵称</p>
+    <van-field
+      v-model="nickname"
+      class="edit-name__input"
+      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>
+    </div>
+  </van-popup>
+</template>
+
+<script setup>
+import { computed, ref } from "vue";
+
+const props = defineProps({
+  show: {
+    type: Boolean,
+    required: false,
+  },
+});
+const emits = defineEmits(["update:show"]);
+const nickname = ref("");
+
+const visible = computed({
+  get() {
+    return props.show;
+  },
+  set(v) {
+    emits("update:show", v);
+  },
+});
+</script>
+
+<style lang="scss" scoped>
+.edit-name {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  gap: 30px;
+  padding: 48px 75px;
+  border-radius: 40px;
+
+  &__input {
+    padding: 0;
+
+    &::after {
+      display: none;
+    }
+    :deep(.van-field__body) {
+      padding: var(--van-cell-vertical-padding)
+        var(--van-cell-horizontal-padding);
+      width: 494px;
+      border: 1px solid #d9d9d9;
+      box-sizing: border-box;
+    }
+  }
+  &-footer {
+    display: flex;
+    gap: 30px;
+    width: 100%;
+
+    .van-button {
+      flex: 1;
+      border-radius: 0;
+    }
+  }
+}
+</style>

+ 106 - 0
packages/mobile/src/views/Mine/index.scss

@@ -0,0 +1,106 @@
+.mine-page {
+  $infoHeight: 402px;
+
+  min-height: 100vh;
+  overflow: hidden;
+  background: #f3f3f3;
+
+  &-info {
+    position: fixed;
+    top: 0;
+    left: 0;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    width: 100%;
+    height: $infoHeight;
+    font-family: "Source Han Serif CN-Bold";
+
+    &__avatar {
+      --van-uploader-size: 154px;
+      --van-padding-xs: 0;
+      --van-uploader-upload-background: rgba(44, 44, 44, 0.7);
+      --van-uploader-icon-size: 92px;
+      overflow: hidden;
+      border-radius: 50%;
+    }
+    &__name {
+      display: flex;
+      align-items: center;
+      gap: 20px;
+      margin-top: 10px;
+      font-size: 46px;
+    }
+    &__id {
+      color: var(--text-color-placeholder);
+    }
+  }
+  &-main {
+    position: relative;
+    margin-top: $infoHeight;
+    padding: 40px 50px calc(var(--tabbar-height) + 80px);
+    min-height: calc(100vh - $infoHeight);
+    border-top-left-radius: 60px;
+    border-top-right-radius: 60px;
+    box-shadow: 0px 0 58px 0px rgba(0, 0, 0, 0.25);
+    background: var(--van-white);
+    box-sizing: border-box;
+    z-index: 1;
+
+    &-header {
+      display: flex;
+      align-items: flex-start;
+      justify-content: space-between;
+      font-family: "Source Han Serif CN-Bold";
+
+      h3 {
+        position: relative;
+        font-size: 46px;
+
+        &::after {
+          content: "";
+          position: absolute;
+          right: 0;
+          bottom: 4px;
+          width: 85px;
+          height: 12px;
+          background: var(--van-primary-color);
+        }
+        span {
+          position: relative;
+          z-index: 1;
+        }
+      }
+      .van-button {
+        --van-button-normal-padding: 0;
+        width: 231px;
+        height: 77px;
+        font-size: 31px;
+      }
+    }
+  }
+  &-list {
+    display: flex;
+    flex-wrap: wrap;
+    margin: 32px -16px 0;
+
+    li {
+      margin: 16px;
+      width: calc(33.3333% - 32px);
+
+      .book-card {
+        width: 100%;
+        text-align: center;
+      }
+      :deep(.book-card__cover) {
+        height: 250px;
+        border-radius: 4px;
+        overflow: hidden;
+      }
+      :deep(.book-card__title) {
+        margin-top: 4px;
+      }
+    }
+  }
+}

+ 74 - 0
packages/mobile/src/views/Mine/index.vue

@@ -0,0 +1,74 @@
+<template>
+  <div class="mine-page">
+    <div class="mine-page-info">
+      <van-uploader
+        v-model="fileList"
+        class="mine-page-info__avatar"
+        :before-read="beforeRead"
+        :max-count="1"
+        :deletable="false"
+        reupload
+        :upload-icon="PhotoIcon"
+      />
+      <p class="mine-page-info__name">
+        SAMSARA<svg-icon
+          name="icon_edit"
+          width="18px"
+          height="18px"
+          color="#A99271"
+          @click="editNameVisible = true"
+        />
+      </p>
+      <p class="mine-page-info__id">ID:6855576858</p>
+    </div>
+
+    <div class="mine-page-main">
+      <div class="mine-page-main-header">
+        <h3><span>我的书架</span></h3>
+
+        <van-button type="primary">
+          <template #icon>
+            <svg-icon
+              name="icon_upload"
+              width="20px"
+              height="20px"
+              color="white"
+            />
+          </template>
+          上传图书</van-button
+        >
+      </div>
+
+      <ul class="mine-page-list">
+        <li v-for="item in 8" :key="item">
+          <book-card is-read />
+        </li>
+      </ul>
+    </div>
+  </div>
+
+  <edit-name-popup v-model:show="editNameVisible" />
+</template>
+
+<script setup>
+import { showToast } from "vant";
+import { ref } from "vue";
+import BookCard from "@/components/BookCard.vue";
+import PhotoIcon from "@/assets/images/icon_portrait@2x-min.png";
+import EditNamePopup from "./components/EditNamePopup.vue";
+
+const fileList = ref([]);
+const editNameVisible = ref(false);
+
+const beforeRead = (file) => {
+  if (file.type !== "image/jpeg") {
+    showToast("请上传 jpg 格式图片");
+    return false;
+  }
+  return true;
+};
+</script>
+
+<style lang="scss" scoped>
+@import "./index.scss";
+</style>

+ 110 - 0
packages/mobile/src/views/Reader/components/BookmarkPopup.vue

@@ -0,0 +1,110 @@
+<template>
+  <popup v-bind="$attrs" title="书签">
+    <div class="bookmark-popup-header">
+      <p>总数:2</p>
+
+      <van-button><van-icon name="plus" @click="addBookmark" /></van-button>
+    </div>
+
+    <div class="bookmark-popup-list">
+      <div v-for="(item, idx) in list" :key="idx" class="bookmark-popup-item">
+        <div class="bookmark-popup-item__inner" @click="goToDetail(item)">
+          <p>书签一</p>
+          <p>{{ item.time }}</p>
+        </div>
+
+        <svg-icon
+          class="bookmark-item__close"
+          name="icon_delete"
+          width="24px"
+          height="24px"
+          color="var(--el-color-primary)"
+        />
+      </div>
+    </div>
+  </popup>
+</template>
+
+<script setup>
+import { ref, useAttrs } from "vue";
+import { formatDate } from "@dage/utils";
+import { useEpubStore } from "@/stores";
+import Popup from "./Popup.vue";
+
+const attrs = useAttrs();
+const epubStore = useEpubStore();
+
+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 goToDetail = (item) => {
+  epubStore.goToChapter(item.location.start.cfi);
+  attrs["onUpdate:show"](false);
+};
+</script>
+
+<style lang="scss" scoped>
+.bookmark-popup {
+  &-header {
+    display: flex;
+    align-items: flex-end;
+    justify-content: space-between;
+    padding-bottom: 6px;
+    font-size: 27px;
+    border-bottom: 1px solid var(--van-border-color);
+
+    .van-button {
+      --van-button-default-height: 60px;
+    }
+  }
+  &-list {
+    padding: 10px 0;
+  }
+  &-item {
+    position: relative;
+    margin: 10px 0;
+    padding-left: 23px;
+    display: flex;
+    align-items: center;
+
+    &__inner {
+      flex: 1;
+      cursor: pointer;
+
+      p:last-child {
+        color: var(--text-color-placeholder);
+      }
+    }
+    &__close {
+      cursor: pointer;
+    }
+    &::before {
+      content: "";
+      position: absolute;
+      top: 50%;
+      left: 0;
+      width: 10px;
+      height: 60px;
+      background: var(--van-primary-color);
+      transform: translateY(-50%);
+    }
+  }
+}
+</style>

+ 73 - 0
packages/mobile/src/views/Reader/components/CommentPopup.vue

@@ -0,0 +1,73 @@
+<template>
+  <popup v-bind="$attrs" title="评论">
+    <div class="comment-popup">
+      <DynamicScroller
+        class="comment-list"
+        :items="list"
+        key-field="id"
+        :min-item-size="86"
+      >
+        <template #default="{ item, index, active }">
+          <DynamicScrollerItem
+            :item="item"
+            :active="active"
+            :size-dependencies="[item.status, item.type]"
+            :data-index="index"
+          >
+            <msg-item :item="item" />
+          </DynamicScrollerItem>
+        </template>
+      </DynamicScroller>
+
+      <textarea class="comment-popup__textarea" />
+
+      <van-button type="primary">提交</van-button>
+    </div>
+  </popup>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
+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: "所以家族中平时亲切地叫他“九满”​",
+  },
+]);
+</script>
+
+<style lang="scss" scoped>
+.comment-popup {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+
+  &__textarea {
+    margin: 30px 0;
+    height: 352px;
+    border-color: var(--van-primary-color);
+  }
+  .van-button {
+    margin-bottom: 30px;
+    border-radius: 0;
+    height: 115px;
+    font-size: 31px;
+  }
+}
+.comment-list {
+  flex: 1;
+}
+</style>

+ 36 - 0
packages/mobile/src/views/Reader/components/DirectoryPopup.vue

@@ -0,0 +1,36 @@
+<template>
+  <popup v-bind="$attrs" title="目录">
+    <ul class="directory-popup">
+      <li
+        v-for="item in epubStore.navigation"
+        :key="item.id"
+        @click="goToChapter(item.href)"
+      >
+        {{ item.label }}
+      </li>
+    </ul>
+  </popup>
+</template>
+
+<script setup>
+import { useAttrs } from "vue";
+import { useEpubStore } from "@/stores";
+import Popup from "./Popup.vue";
+
+const attrs = useAttrs();
+const epubStore = useEpubStore();
+
+const goToChapter = (href) => {
+  epubStore.goToChapter(href);
+  attrs["onUpdate:show"](false);
+};
+</script>
+
+<style lang="scss" scoped>
+.directory-popup {
+  li {
+    padding: 20px 6px;
+    border-bottom: 1px solid var(--van-border-color);
+  }
+}
+</style>

+ 66 - 0
packages/mobile/src/views/Reader/components/MsgItem.vue

@@ -0,0 +1,66 @@
+<template>
+  <div class="msg-item">
+    <div class="msg-item-header">
+      <p v-if="item.name">{{ item.name }}</p>
+      <p>{{ item.date }}</p>
+    </div>
+    <div class="msg-item-inner">
+      <p>{{ item.msg }}</p>
+    </div>
+    <div v-if="isNote" class="msg-item-footer">暂无笔记</div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  item: {
+    type: Object,
+    required: true,
+  },
+  isNote: {
+    type: Boolean,
+    default: false,
+  },
+});
+</script>
+
+<style lang="scss" scoped>
+.msg-item {
+  padding-bottom: 40px;
+
+  &::after {
+    content: "";
+    display: block;
+    height: 40px;
+    border-bottom: 1px solid var(--van-border-color);
+  }
+  &-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    font-size: 27px;
+    color: var(--text-color-placeholder);
+  }
+  &-inner {
+    position: relative;
+    margin-top: 20px;
+    padding-left: 33px;
+    min-height: 60px;
+
+    &::after {
+      content: "";
+      position: absolute;
+      top: 0;
+      left: 0;
+      width: 10px;
+      height: 60px;
+      background: var(--van-primary-color);
+    }
+  }
+  &-footer {
+    margin-top: 20px;
+    font-size: 27px;
+    color: var(--text-color-placeholder);
+  }
+}
+</style>

+ 87 - 0
packages/mobile/src/views/Reader/components/NotePopup.vue

@@ -0,0 +1,87 @@
+<template>
+  <popup v-bind="$attrs" title="笔记">
+    <div class="comment-popup">
+      <DynamicScroller
+        class="comment-list"
+        :items="list"
+        key-field="id"
+        :min-item-size="86"
+      >
+        <template #default="{ item, index, active }">
+          <DynamicScrollerItem
+            :item="item"
+            :active="active"
+            :size-dependencies="[item.status, item.type]"
+            :data-index="index"
+            @click="editVisible = true"
+          >
+            <msg-item :item="item" is-note />
+          </DynamicScrollerItem>
+        </template>
+      </DynamicScroller>
+    </div>
+  </popup>
+
+  <van-popup
+    v-model:show="editVisible"
+    :close-on-click-overlay="false"
+    closeable
+  >
+    <div class="edit-comment">
+      <textarea class="comment-popup__textarea" />
+
+      <van-button type="primary">提交</van-button>
+    </div>
+  </van-popup>
+</template>
+
+<script setup>
+import { ref } from "vue";
+import { DynamicScroller, DynamicScrollerItem } from "vue-virtual-scroller";
+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 editVisible = ref(false);
+</script>
+
+<style lang="scss" scoped>
+.comment-popup {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+
+  &__textarea {
+    height: 352px;
+    border-color: var(--van-primary-color);
+  }
+  .van-button {
+    border-radius: 0;
+    height: 115px;
+    font-size: 31px;
+  }
+}
+.comment-list {
+  flex: 1;
+}
+.edit-comment {
+  display: flex;
+  flex-direction: column;
+  gap: 30px;
+  padding: 100px 30px 30px;
+  width: 90vw;
+  box-sizing: border-box;
+}
+</style>

+ 63 - 0
packages/mobile/src/views/Reader/components/Popup.vue

@@ -0,0 +1,63 @@
+<template>
+  <van-popup
+    v-model:show="visible"
+    position="bottom"
+    closeable
+    round
+    class="reader-popup"
+  >
+    <h3>{{ title }}</h3>
+
+    <div class="reader-popup-main">
+      <slot />
+    </div>
+  </van-popup>
+</template>
+
+<script setup>
+import { computed } from "vue";
+
+const props = defineProps({
+  show: {
+    type: Boolean,
+    required: false,
+  },
+  title: {
+    type: String,
+    required: true,
+  },
+});
+const emits = defineEmits(["update:show"]);
+
+const visible = computed({
+  get() {
+    return props.show;
+  },
+  set(v) {
+    emits("update:show", v);
+  },
+});
+</script>
+
+<style lang="scss" scoped>
+.reader-popup {
+  --van-popup-close-icon-color: var(--icon-close-color);
+  --van-popup-close-icon-size: 50px;
+
+  h3 {
+    padding: 60px 60px 35px;
+    font-size: 46px;
+    font-family: "Source Han Serif CN-Bold";
+  }
+  :deep(.van-popup__close-icon) {
+    top: 70px;
+    right: 60px;
+  }
+  &-main {
+    padding: 0 60px;
+    height: calc(100vh - 67px - 160px);
+    box-sizing: border-box;
+    overflow-y: auto;
+  }
+}
+</style>

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

@@ -0,0 +1,101 @@
+<template>
+  <popup v-bind="$attrs" title="全文搜索">
+    <search-input
+      v-model="detailStore.searchKey"
+      simple
+      @search="debounceSearch"
+    />
+
+    <DynamicScroller
+      v-if="list.length"
+      class="search-popup-inner"
+      :items="list"
+      key-field="cfi"
+      :min-item-size="100"
+    >
+      <template #default="{ item, index, active }">
+        <DynamicScrollerItem
+          :item="item"
+          :active="active"
+          :size-dependencies="[item.status, item.type]"
+          :data-index="index"
+        >
+          <div
+            v-html="item.excerpt"
+            class="search-popup-item"
+            @click="goToDetail(item.cfi)"
+          />
+        </DynamicScrollerItem>
+      </template>
+    </DynamicScroller>
+
+    <van-empty
+      v-if="!list.length && detailStore.searchKey && !loading"
+      description="搜索不到结果"
+    />
+  </popup>
+</template>
+
+<script setup>
+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 Popup from "./Popup.vue";
+import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
+
+const detailStore = useDetailStore();
+const epubStore = useEpubStore();
+const list = ref([]);
+const loading = ref(false);
+
+const debounceSearch = debounce(async () => {
+  if (!detailStore.searchKey) {
+    list.value = [];
+    return;
+  }
+
+  try {
+    const res = await epubStore.searchKeyword(detailStore.searchKey);
+    const reg = new RegExp(detailStore.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>`
+        ),
+      };
+    });
+  } finally {
+    loading.value = false;
+  }
+}, 500);
+
+const goToDetail = (cfi) => {
+  epubStore.goToChapter(cfi);
+  detailStore.searchVisible = false;
+};
+
+watch(
+  () => detailStore.searchKey,
+  () => {
+    loading.value = true;
+    debounceSearch();
+  }
+);
+</script>
+
+<style lang="scss" scoped>
+.search-popup {
+  &-inner {
+    margin-top: 20px;
+    height: calc(100% - 96px - 20px);
+  }
+  &-item {
+    padding: 20px;
+    border-bottom: 1px solid var(--van-border-color);
+  }
+}
+</style>

+ 0 - 0
packages/mobile/src/views/Reader/components/SettingPopup.vue


部分文件因为文件数量过多而无法显示