diff --git a/config/index.js b/config/index.js
index 9e7dfe6..cea3e2e 100644
--- a/config/index.js
+++ b/config/index.js
@@ -60,8 +60,8 @@ const config = {
chain.module
.rule("markdown")
.test(/\.md$/)
- .use("raw-loader")
- .loader("raw-loader")
+ .use("md-chunk-loader")
+ .loader(path.resolve(__dirname, "..", "src", "loaders", "md-chunk-loader.js"))
.end();
chain.module
diff --git a/src/loaders/md-chunk-loader.js b/src/loaders/md-chunk-loader.js
new file mode 100644
index 0000000..576d305
--- /dev/null
+++ b/src/loaders/md-chunk-loader.js
@@ -0,0 +1,33 @@
+const { marked } = require("marked");
+
+module.exports = function mdChunkLoader(source) {
+ this.cacheable && this.cacheable();
+
+ const html = marked.parse(source, { breaks: true, gfm: true });
+
+ const lazyHtml = html.replace(/
])/i).filter(c => c.trim());
+ if (rawChunks.length <= 1) {
+ rawChunks = lazyHtml.split(/(?=
])/i).filter(c => c.trim());
+ }
+
+ const headers = [];
+ const headerRegex = /]*>(.*?)<\/h1>/gi;
+ let m;
+ while ((m = headerRegex.exec(lazyHtml)) !== null) {
+ headers.push(m[1].replace(/<[^>]+>/g, ""));
+ }
+
+ const chunks = rawChunks.map(chunkHtml => {
+ const imgCount = (chunkHtml.match(/
]+>/g, "").length;
+ const estimatedHeight = Math.max(300, Math.round(textLen * 0.6 + imgCount * 350));
+ return { html: chunkHtml, estimatedHeight };
+ });
+
+ return [
+ "export var chunks = " + JSON.stringify(chunks) + ";",
+ "export var headers = " + JSON.stringify(headers) + ";",
+ ].join("\n");
+};
diff --git a/src/pages/book/index.scss b/src/pages/book/index.scss
index 4c69056..f5d307c 100644
--- a/src/pages/book/index.scss
+++ b/src/pages/book/index.scss
@@ -1,5 +1,17 @@
@import "github-markdown-css/github-markdown.css";
+// ===== 阅读进度条 =====
+.reading-progress {
+ position: fixed;
+ top: 0;
+ left: 0;
+ height: 4px;
+ background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
+ z-index: 201;
+ transition: width 0.15s linear;
+ border-radius: 0 4px 4px 0;
+}
+
// ===== 顶部导航栏 =====
.book_header_div {
width: 100%;
@@ -13,9 +25,10 @@
right: 0;
z-index: 100;
height: 88px;
- background: rgba(255, 255, 255, 0.95);
- backdrop-filter: blur(10px);
- border-bottom: 1px solid #f0f0f0;
+ background: rgba(255, 255, 255, 0.92);
+ backdrop-filter: blur(12px);
+ -webkit-backdrop-filter: blur(12px);
+ border-bottom: 1px solid rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: row;
align-items: center;
@@ -71,11 +84,102 @@
}
}
+// ===== 段落渐入动画 =====
+@keyframes chunkAppear {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.chunk-enter {
+ animation: chunkAppear 0.5s ease-out both;
+}
+
+// ===== 骨架屏占位 =====
+@keyframes shimmer {
+ 0% { background-position: 200% 0; }
+ 100% { background-position: -200% 0; }
+}
+
+.chunk-placeholder {
+ background: linear-gradient(90deg, #f6f7f8 25%, #edeef1 37%, #f6f7f8 63%);
+ background-size: 400% 100%;
+ animation: shimmer 1.8s ease infinite;
+ border-radius: 12px;
+ margin: 24px 0;
+}
+
+// ===== 加载状态 =====
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+@keyframes fadeInUp {
+ from { opacity: 0; transform: translateY(16px); }
+ to { opacity: 1; transform: translateY(0); }
+}
+
+// ===== 回到顶部 =====
+.back-to-top {
+ position: fixed;
+ bottom: 120px;
+ right: 30px;
+ width: 80px;
+ height: 80px;
+ background: rgba(255, 255, 255, 0.92);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
+ cursor: pointer;
+ z-index: 50;
+ animation: fadeInUp 0.3s ease;
+ color: #4facfe;
+ transition: transform 0.2s ease, box-shadow 0.2s ease;
+
+ &:active {
+ transform: scale(0.9);
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+ }
+}
+
// ===== 内容区域 =====
.Book {
padding: 30px;
max-width: 100%;
+ .markdown-loading {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ min-height: 500px;
+ gap: 28px;
+
+ .loading-spinner {
+ width: 56px;
+ height: 56px;
+ border: 5px solid #f0f0f0;
+ border-top-color: #4facfe;
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+ }
+
+ .loading-text {
+ color: #8c8c8c;
+ font-size: 28px;
+ letter-spacing: 2px;
+ }
+ }
+
.markdown-body {
font-size: 32px !important;
line-height: 1.85 !important;
@@ -87,17 +191,27 @@
font-size: 48px !important;
font-weight: 800;
color: #1a1a2e;
- margin: 48px 0 24px;
- padding-bottom: 16px;
- border-bottom: 2px solid #e8e8e8;
+ margin: 56px 0 20px;
+ padding-bottom: 0;
+ border-bottom: none;
line-height: 1.3;
+
+ &::after {
+ content: '';
+ display: block;
+ width: 80px;
+ height: 6px;
+ background: linear-gradient(90deg, #4facfe, #00f2fe);
+ border-radius: 3px;
+ margin-top: 16px;
+ }
}
h2 {
font-size: 42px !important;
font-weight: 700;
color: #2c3e50;
- margin: 40px 0 20px;
+ margin: 44px 0 18px;
padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
line-height: 1.3;
@@ -107,14 +221,14 @@
font-size: 36px !important;
font-weight: 600;
color: #34495e;
- margin: 32px 0 16px;
+ margin: 36px 0 14px;
}
h4, h5, h6 {
font-size: 32px !important;
font-weight: 600;
color: #4a5568;
- margin: 24px 0 12px;
+ margin: 28px 0 12px;
}
p {
@@ -124,13 +238,25 @@
}
blockquote {
- margin: 24px 0;
- padding: 20px 24px;
- border-left: 6px solid #4facfe;
- background: #f8fbff;
+ position: relative;
+ margin: 28px 0;
+ padding: 20px 24px 20px 30px;
+ border-left: none;
+ background: linear-gradient(135deg, #f8fbff 0%, #f0f7ff 100%);
border-radius: 0 12px 12px 0;
color: #4a5568;
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 6px;
+ background: linear-gradient(180deg, #4facfe, #00f2fe);
+ border-radius: 3px;
+ }
+
p {
margin-bottom: 8px;
&:last-child { margin-bottom: 0; }
@@ -138,13 +264,25 @@
}
pre {
- margin: 24px 0;
- padding: 24px;
+ position: relative;
+ margin: 28px 0;
+ padding: 28px 24px 24px;
background: #1e1e2e;
border-radius: 12px;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 4px;
+ background: linear-gradient(90deg, #4facfe, #00f2fe);
+ border-radius: 12px 12px 0 0;
+ }
+
code {
font-family: "SF Mono", "Fira Code", Menlo, Monaco, Consolas, monospace;
font-size: 26px !important;
@@ -192,15 +330,16 @@
table {
width: 100%;
- margin: 24px 0;
+ margin: 28px 0;
border-collapse: collapse;
border-spacing: 0;
display: block;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
+ border-radius: 8px;
th {
- background: #f8fafc;
+ background: linear-gradient(135deg, #f8fafc, #edf2f7);
font-weight: 600;
padding: 16px 20px;
text-align: left;
@@ -216,20 +355,30 @@
}
tr:nth-child(even) { background: #f8fafc; }
+ tr { transition: background 0.15s ease; }
}
hr {
- margin: 40px 0;
+ margin: 48px 0;
border: none;
- border-top: 2px solid #f0f0f0;
+ height: 2px;
+ background: linear-gradient(90deg, transparent, #e2e8f0, transparent);
}
img {
display: block;
- margin: 24px auto;
+ margin: 28px auto;
max-width: 100%;
height: auto;
border-radius: 8px;
+ opacity: 0;
+ transform: translateY(10px);
+ transition: opacity 0.6s ease, transform 0.6s ease, box-shadow 0.3s ease;
+
+ &.loaded {
+ opacity: 1;
+ transform: translateY(0);
+ }
}
a {
@@ -237,8 +386,12 @@
text-decoration: none;
font-size: inherit !important;
border-bottom: 1px solid transparent;
- transition: border-color 0.2s;
- &:hover { border-bottom-color: #4facfe; }
+ transition: border-color 0.2s, color 0.2s;
+
+ &:hover {
+ color: #2b8de3;
+ border-bottom-color: #4facfe;
+ }
}
strong {
@@ -258,6 +411,12 @@
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
+ transition: background 0.2s;
+
+ &[open] {
+ background: #fff;
+ }
+
summary {
cursor: pointer;
font-weight: 600;
@@ -266,15 +425,6 @@
}
}
- .markdown-loading {
- display: flex;
- justify-content: center;
- align-items: center;
- min-height: 400px;
- color: #8c8c8c;
- font-size: 28px;
- }
-
// 二维码区域
.qrcode-section {
display: flex;
@@ -332,8 +482,24 @@
/* stylelint-disable */
// ===== Tablet/Desktop: 768px+ =====
-// 使用 PX (大写) 防止 Taro postcss-pxtransform 转换
@media (min-width: 768PX) {
+ .reading-progress {
+ height: 3PX;
+ }
+
+ .back-to-top {
+ width: 44PX;
+ height: 44PX;
+ bottom: 40PX;
+ right: 40PX;
+ box-shadow: 0 2PX 12PX rgba(0, 0, 0, 0.1);
+
+ &:hover {
+ transform: translateY(-2PX);
+ box-shadow: 0 4PX 16PX rgba(0, 0, 0, 0.15);
+ }
+ }
+
.book_header {
height: 56PX;
@@ -369,6 +535,17 @@
.Book {
padding: 30PX 60PX;
+ .markdown-loading {
+ .loading-spinner {
+ width: 36PX;
+ height: 36PX;
+ border-width: 3PX;
+ }
+ .loading-text {
+ font-size: 15PX;
+ }
+ }
+
.book_AtDrawer .at-drawer__content {
padding-top: 56PX;
width: 300PX;
@@ -399,14 +576,20 @@
h1 {
font-size: 28PX !important;
- margin: 32PX 0 16PX;
- padding-bottom: 10PX;
- border-bottom-width: 2PX;
+ margin: 36PX 0 14PX;
+
+ &::after {
+ width: 50PX;
+ height: 3PX;
+ margin-top: 10PX;
+ border-radius: 2PX;
+ }
}
h2 {
font-size: 23PX !important;
- margin: 26PX 0 12PX;
+ margin: 28PX 0 12PX;
+ padding-bottom: 8PX;
}
h3 {
@@ -428,10 +611,15 @@
}
pre {
- padding: 18PX;
+ padding: 22PX 18PX 18PX;
border-radius: 8PX;
margin: 20PX 0;
+ &::before {
+ height: 3PX;
+ border-radius: 8PX 8PX 0 0;
+ }
+
code {
font-size: 14PX !important;
}
@@ -449,17 +637,21 @@
}
blockquote {
- padding: 14PX 18PX;
- border-left-width: 4PX;
+ padding: 14PX 18PX 14PX 22PX;
margin: 18PX 0;
border-radius: 0 8PX 8PX 0;
+
+ &::before {
+ width: 4PX;
+ border-radius: 2PX;
+ }
}
ul, ol { padding-left: 24PX; }
img { border-radius: 8PX; margin: 20PX auto; }
- hr { margin: 24PX 0; border-top-width: 2PX; }
+ hr { margin: 28PX 0; }
- a { font-size: inherit !important; border-bottom-width: 1PX; }
+ a { font-size: inherit !important; }
strong { font-size: inherit !important; }
}
@@ -494,13 +686,18 @@
li { font-size: 17PX !important; }
pre {
- padding: 18PX 22PX;
+ padding: 22PX 22PX 18PX;
code { font-size: 14PX !important; }
}
img {
border-radius: 10PX;
max-width: 90%;
+
+ &.loaded:hover {
+ box-shadow: 0 8PX 30PX rgba(0, 0, 0, 0.12);
+ transform: translateY(-2PX);
+ }
}
}
diff --git a/src/pages/book/index.tsx b/src/pages/book/index.tsx
index 46732d9..5c0cef0 100644
--- a/src/pages/book/index.tsx
+++ b/src/pages/book/index.tsx
@@ -3,37 +3,14 @@ import { View, Image } from "@tarojs/components";
import Taro, { useRouter } from "@tarojs/taro";
import "./index.scss";
import { AtButton, AtDrawer, AtIcon } from "taro-ui";
-import React, { useEffect, useState, useRef, useMemo, useCallback } from "react";
-import { marked } from "marked";
-import infoText from "./info.md";
-import bookText from "./book.md";
+import React, { useEffect, useState, useRef, useCallback } from "react";
+import { chunks as infoChunks, headers as infoHeaders } from "./info.md";
import hackrobot from "../../images/hackrobot.jpg";
const PB_URL = "https://pocketbase.hackrobot.cn";
const pb = new PocketBase(PB_URL);
-marked.setOptions({ breaks: true, gfm: true });
-
-function addLazyLoadToImages(html) {
- return html.replace(/
])/i);
- return parts.filter(p => p.trim().length > 0);
-}
-
-function extractHeaders(html) {
- const regex = /]*>(.*?)<\/h1>/gi;
- const results = [];
- let match;
- while ((match = regex.exec(html)) !== null) {
- results.push(match[1].replace(/<[^>]+>/g, ""));
- }
- return results;
-}
-
-const LazyChunk = React.memo(({ html, forceVisible }) => {
+const LazyChunk = React.memo(({ html, estimatedHeight, forceVisible }) => {
const [visible, setVisible] = useState(forceVisible);
const ref = useRef(null);
@@ -49,28 +26,29 @@ const LazyChunk = React.memo(({ html, forceVisible }) => {
observer.disconnect();
}
},
- { rootMargin: "800px 0px" }
+ { rootMargin: "1200px 0px" }
);
observer.observe(el);
return () => observer.disconnect();
}, [forceVisible, visible]);
if (visible) {
- return
;
+ return ;
}
- return ;
+ return ;
});
const Book = () => {
const [isWeChat, setisWeChat] = useState(false);
const [show, setshow] = useState(false);
- const [headers, setHeaders] = useState([]);
const contentRef = useRef(null);
const [paidType, setPaidType] = useState(null);
const [userId, setUserId] = useState(Taro.getStorageSync("userId") || null);
const [loading, setLoading] = useState(true);
- const [contentReady, setContentReady] = useState(false);
const [forceShowAll, setForceShowAll] = useState(false);
+ const [readProgress, setReadProgress] = useState(0);
+ const [showBackTop, setShowBackTop] = useState(false);
+ const [bookData, setBookData] = useState(null);
const router = useRouter();
const [vipbook, setVipbook] = useState(false);
@@ -126,9 +104,7 @@ const Book = () => {
const isWeChatFun = () => {
const ua = navigator.userAgent.toLowerCase();
- const result = ua.includes("micromessenger");
- setisWeChat(result);
- return result;
+ setisWeChat(ua.includes("micromessenger"));
};
const getOrCreateUserId = () => {
@@ -162,18 +138,70 @@ const Book = () => {
init();
}, []);
+ const isPaid = paidType === "book" || vipbook;
+
+ useEffect(() => {
+ if (isPaid && !bookData) {
+ import("./book.md").then(mod => {
+ setBookData({ chunks: mod.chunks, headers: mod.headers });
+ });
+ }
+ }, [isPaid, bookData]);
+
+ const activeChunks = isPaid && bookData ? bookData.chunks : infoChunks;
+ const activeHeaders = isPaid && bookData ? bookData.headers : infoHeaders;
+ const contentLoading = loading || (isPaid && !bookData);
+
+ useEffect(() => {
+ let ticking = false;
+ const onScroll = () => {
+ if (ticking) return;
+ ticking = true;
+ requestAnimationFrame(() => {
+ const scrollTop = window.scrollY || document.documentElement.scrollTop;
+ const docHeight = document.documentElement.scrollHeight - window.innerHeight;
+ setReadProgress(docHeight > 0 ? Math.min(100, (scrollTop / docHeight) * 100) : 0);
+ setShowBackTop(scrollTop > 600);
+ ticking = false;
+ });
+ };
+ window.addEventListener("scroll", onScroll, { passive: true });
+ return () => window.removeEventListener("scroll", onScroll);
+ }, []);
+
+ useEffect(() => {
+ const el = contentRef.current;
+ if (!el || contentLoading) return;
+ const onImgLoad = (e) => {
+ if (e.target.tagName === "IMG") e.target.classList.add("loaded");
+ };
+ el.addEventListener("load", onImgLoad, true);
+ const checkCached = () => {
+ el.querySelectorAll("img").forEach(img => {
+ if (img.complete) img.classList.add("loaded");
+ });
+ };
+ const mo = new MutationObserver(checkCached);
+ mo.observe(el, { childList: true, subtree: true });
+ checkCached();
+ return () => {
+ el.removeEventListener("load", onImgLoad, true);
+ mo.disconnect();
+ };
+ }, [contentLoading]);
+
const handleJump = useCallback((text) => {
setForceShowAll(true);
setTimeout(() => {
if (!contentRef.current) return;
- const h1Elements = contentRef.current.querySelectorAll("h1");
- for (let h1 of h1Elements) {
+ const h1s = contentRef.current.querySelectorAll("h1");
+ for (let h1 of h1s) {
if (h1.textContent.trim() === text.trim()) {
h1.scrollIntoView({ behavior: "smooth" });
break;
}
}
- }, 150);
+ }, 200);
}, []);
const submitPayForm = (xorpayUrl, xorpayParams) => {
@@ -211,45 +239,35 @@ const Book = () => {
});
};
- const isPaid = paidType === "book" || vipbook;
-
- const markdownSource = isPaid ? bookText : infoText;
- const parsedHtml = useMemo(() => addLazyLoadToImages(marked(markdownSource)), [markdownSource]);
- const chunks = useMemo(() => splitIntoChunks(parsedHtml), [parsedHtml]);
- const headerList = useMemo(() => extractHeaders(parsedHtml), [parsedHtml]);
-
- useEffect(() => {
- if (loading) return;
- setHeaders(headerList);
- const raf = requestAnimationFrame(() => {
- setContentReady(true);
- });
- return () => cancelAnimationFrame(raf);
- }, [loading, headerList]);
-
return (
<>
+
+
Taro.redirectTo({ url: "/" })}>
数字游民
- setshow(!show)}>
+ setshow(!show)}>
目录
- {!contentReady ? (
- 加载中...
+ {contentLoading ? (
+
+
+ 正在加载...
+
) : (
- {chunks.map((chunk, i) => (
+ {activeChunks.map((chunk, i) => (
))}
@@ -260,8 +278,8 @@ const Book = () => {
className="book_AtDrawer"
show={show}
mask
- items={headers}
- onItemClick={(index) => handleJump(headers[index])}
+ items={activeHeaders}
+ onItemClick={(index) => handleJump(activeHeaders[index])}
onClose={() => setshow(false)}
/>
@@ -293,6 +311,15 @@ const Book = () => {
)}
+
+ {showBackTop && (
+ window.scrollTo({ top: 0, behavior: "smooth" })}
+ >
+
+
+ )}
>
);
};
diff --git a/src/pages/sphone/index.tsx b/src/pages/sphone/index.tsx
index 5876aa3..9bdfc25 100644
--- a/src/pages/sphone/index.tsx
+++ b/src/pages/sphone/index.tsx
@@ -1,47 +1,16 @@
// @ts-nocheck
-import { Component } from "react";
-import { View, Text, Image } from "@tarojs/components";
+import { View } from "@tarojs/components";
import "./index.scss";
-import { AtButton, AtInput, AtTabBar } from "taro-ui";
-import React, { useCallback, useEffect, useState } from "react";
-import { AtGrid } from "taro-ui";
-import { Swiper, SwiperItem } from "@tarojs/components";
-import { AtCard } from "taro-ui";
-import { AtImagePicker } from "taro-ui";
+import { AtButton } from "taro-ui";
+import React, { useEffect, useState } from "react";
import Taro from "@tarojs/taro";
-import { AtAvatar } from "taro-ui";
-import { AtTag } from "taro-ui";
-import { AtList, AtListItem } from "taro-ui";
-import { AtForm, AtMessage } from "taro-ui";
-import { marked } from "marked";
-import infoText from "./info.md";
-import payText from "./pay.md";
+import { chunks as infoChunks } from "./info.md";
+import { chunks as payChunks } from "./pay.md";
-marked.setOptions({
- breaks: true, // 是否回车换行
- gfm: true, //使用经批准的 GitHub Flavored Markdown (GFM) 规范
- // pedantic: true, //严格模式
- // highlight(code:any, lang:any) {
- // // 语法高亮
- // let val = code;
- // if (lang) {
- // val = hljs.highlight(lang, code).value;
- // } else {
- // val = hljs.highlightAuto(code).value;
- // }
- // return val;
- // },
-});
+const joinChunks = (chunks) => chunks.map(c => c.html).join("");
-// const renderer = new marked.Renderer();
-// // 使用拓展
-// marked.use({ renderer });
-
-// export default class Index extends Component {
const Sphone = () => {
- const [markText, setmarkText] = useState();
const [isWeChat, setisWeChat] = useState(false);
-
const [sphone, setsphone] = useState(window.localStorage.getItem("sphone"));
useEffect(() => {
@@ -55,17 +24,9 @@ const Sphone = () => {
// });
// };
- // 是否在微信客户端
- const isWeChatFun = () => {
- var ua = navigator.userAgent.toLowerCase();
- if (ua.indexOf("micromessenger") != -1) {
- setisWeChat(true);
- return true;
- } else {
- setisWeChat(false);
- return false;
- }
- };
+ const isWeChatFun = () => {
+ setisWeChat(navigator.userAgent.toLowerCase().includes("micromessenger"));
+ };
const payh5 = (type, total_fee = 1000) => {
// console.log("111");
@@ -114,11 +75,11 @@ const Sphone = () => {
return (
+ />
{/* 判断有没有wxid,是否女性 */}