From 219785b1babc26d0edc17404f3db3e48be346203 Mon Sep 17 00:00:00 2001 From: eric Date: Sat, 7 Mar 2026 20:19:07 -0600 Subject: [PATCH] 'cbook' --- config/index.js | 4 +- src/loaders/md-chunk-loader.js | 33 ++++ src/pages/book/index.scss | 281 ++++++++++++++++++++++++++++----- src/pages/book/index.tsx | 149 ++++++++++------- src/pages/sphone/index.tsx | 63 ++------ 5 files changed, 374 insertions(+), 156 deletions(-) create mode 100644 src/loaders/md-chunk-loader.js 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,是否女性 */}