This commit is contained in:
eric
2026-03-07 20:19:07 -06:00
parent aed78201a9
commit 219785b1ba
5 changed files with 374 additions and 156 deletions

View File

@@ -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

View File

@@ -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(/<img /g, '<img loading="lazy" decoding="async" ');
let rawChunks = lazyHtml.split(/(?=<h1[\s>])/i).filter(c => c.trim());
if (rawChunks.length <= 1) {
rawChunks = lazyHtml.split(/(?=<h2[\s>])/i).filter(c => c.trim());
}
const headers = [];
const headerRegex = /<h1[^>]*>(.*?)<\/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(/<img /g) || []).length;
const textLen = chunkHtml.replace(/<[^>]+>/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");
};

View File

@@ -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);
}
}
}

View File

@@ -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(/<img /g, '<img loading="lazy" decoding="async" ');
}
function splitIntoChunks(html) {
const parts = html.split(/(?=<h1[\s>])/i);
return parts.filter(p => p.trim().length > 0);
}
function extractHeaders(html) {
const regex = /<h1[^>]*>(.*?)<\/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 <div dangerouslySetInnerHTML={{ __html: html }} />;
return <div className="chunk-enter" dangerouslySetInnerHTML={{ __html: html }} />;
}
return <div ref={ref} style={{ minHeight: "200px" }} />;
return <div ref={ref} className="chunk-placeholder" style={{ minHeight: estimatedHeight }} />;
});
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 (
<>
<View className="reading-progress" style={{ width: `${readProgress}%` }} />
<View className="book_header_div" />
<View className="book_header">
<View className="book_header_left" onClick={() => Taro.redirectTo({ url: "/" })}>
<AtIcon value="chevron-left" />
</View>
<View className="book_header_middle"></View>
<View className={`book_header_right ${show ? 'active' : ''}`} onClick={() => setshow(!show)}>
<View className={`book_header_right ${show ? "active" : ""}`} onClick={() => setshow(!show)}>
<AtIcon value="bullet-list" />
<View className="book_header_right_text pc-only"></View>
</View>
</View>
<View className="Book">
{!contentReady ? (
<View className="markdown-loading">...</View>
{contentLoading ? (
<View className="markdown-loading">
<View className="loading-spinner" />
<View className="loading-text">...</View>
</View>
) : (
<View ref={contentRef} className="markdown-body">
{chunks.map((chunk, i) => (
{activeChunks.map((chunk, i) => (
<LazyChunk
key={i}
html={chunk}
key={`${isPaid}-${i}`}
html={chunk.html}
estimatedHeight={chunk.estimatedHeight}
forceVisible={forceShowAll || i === 0}
/>
))}
@@ -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 = () => {
</AtButton>
)}
</View>
{showBackTop && (
<View
className="back-to-top"
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
>
<AtIcon value="chevron-up" size="20" />
</View>
)}
</>
);
};

View File

@@ -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 (
<View className="Sphone">
<View
className="markdown-body" // 添加 GitHub Markdown 主题类
className="markdown-body"
dangerouslySetInnerHTML={{
__html: marked(sphone == 1 ? payText : infoText),
__html: joinChunks(sphone == 1 ? payChunks : infoChunks),
}}
></View>
/>
<View className="bottomDiv"></View>
{/* 判断有没有wxid,是否女性 */}