296 lines
11 KiB
Go
296 lines
11 KiB
Go
package handler
|
||
|
||
import (
|
||
"fmt"
|
||
"html"
|
||
"net/http"
|
||
"strings"
|
||
|
||
"soul-api/internal/config"
|
||
"soul-api/internal/database"
|
||
"soul-api/internal/model"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// H5ReadPage GET /read/:id 朋友圈/外部链接落地页
|
||
// 渲染文章预览内容(按 unpaid_preview_percent 截取),底部显示「打开小程序继续阅读」按钮
|
||
// 支持 ?ref=xxx 分销参数透传
|
||
func H5ReadPage(c *gin.Context) {
|
||
sectionID := c.Param("id")
|
||
if sectionID == "" {
|
||
c.Data(http.StatusBadRequest, "text/html; charset=utf-8", []byte(h5Error("缺少文章 ID")))
|
||
return
|
||
}
|
||
ref := c.Query("ref")
|
||
|
||
db := database.DB()
|
||
var ch model.Chapter
|
||
if err := db.Where("id = ?", sectionID).First(&ch).Error; err != nil {
|
||
if err == gorm.ErrRecordNotFound {
|
||
c.Data(http.StatusNotFound, "text/html; charset=utf-8", []byte(h5Error("文章不存在")))
|
||
return
|
||
}
|
||
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte(h5Error("加载失败")))
|
||
return
|
||
}
|
||
|
||
percent := chapterPreviewPercent(db, &ch)
|
||
preview := previewContent(ch.Content, percent)
|
||
|
||
title := ch.SectionTitle
|
||
if title == "" {
|
||
title = ch.ChapterTitle
|
||
}
|
||
partTitle := ""
|
||
if ch.PartTitle != "" {
|
||
partTitle = ch.PartTitle
|
||
}
|
||
chapterTitle := ""
|
||
if ch.ChapterTitle != "" && ch.ChapterTitle != title {
|
||
chapterTitle = ch.ChapterTitle
|
||
}
|
||
|
||
cfg := config.Get()
|
||
appID := cfg.WechatAppID
|
||
mpPath := fmt.Sprintf("pages/read/read?id=%s&action=pay", sectionID)
|
||
if ref != "" {
|
||
mpPath += "&ref=" + ref
|
||
}
|
||
|
||
pageHTML := h5BuildPage(h5PageData{
|
||
Title: title,
|
||
PartTitle: partTitle,
|
||
ChapterTitle: chapterTitle,
|
||
Preview: preview,
|
||
Percent: percent,
|
||
SectionID: sectionID,
|
||
Ref: ref,
|
||
AppID: appID,
|
||
MpPath: mpPath,
|
||
})
|
||
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(pageHTML))
|
||
}
|
||
|
||
func h5Error(msg string) string {
|
||
return fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>提示</title><style>body{font-family:-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#0f1923;color:#ccc;}</style>
|
||
</head><body><p>%s</p></body></html>`, html.EscapeString(msg))
|
||
}
|
||
|
||
type h5PageData struct {
|
||
Title, PartTitle, ChapterTitle string
|
||
Preview string
|
||
Percent int
|
||
SectionID, Ref string
|
||
AppID, MpPath string
|
||
}
|
||
|
||
func h5BuildPage(d h5PageData) string {
|
||
escapedTitle := html.EscapeString(d.Title)
|
||
escapedPart := html.EscapeString(d.PartTitle)
|
||
escapedChapter := html.EscapeString(d.ChapterTitle)
|
||
|
||
contentHTML := h5ContentToHTML(d.Preview)
|
||
|
||
subtitle := ""
|
||
if escapedPart != "" || escapedChapter != "" {
|
||
parts := []string{}
|
||
if escapedPart != "" {
|
||
parts = append(parts, escapedPart)
|
||
}
|
||
if escapedChapter != "" {
|
||
parts = append(parts, escapedChapter)
|
||
}
|
||
subtitle = fmt.Sprintf(`<p class="sub">%s</p>`, strings.Join(parts, " · "))
|
||
}
|
||
|
||
mpBtn := fmt.Sprintf(
|
||
`<wx-open-launch-weapp id="launch-btn" appid="%s" path="%s" style="display:block;width:100%%">
|
||
<script type="text/wxtag-template">
|
||
<style>.btn{display:block;width:100%%;padding:14px 0;text-align:center;background:#07c160;color:#fff;border-radius:8px;font-size:16px;font-weight:600;border:none;letter-spacing:1px;}</style>
|
||
<button class="btn">打开小程序 购买全文</button>
|
||
</script>
|
||
</wx-open-launch-weapp>`,
|
||
html.EscapeString(d.AppID), html.EscapeString(d.MpPath))
|
||
|
||
return fmt.Sprintf(`<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
|
||
<title>%s - 一场Soul的创业实验</title>
|
||
<meta name="description" content="%s">
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",sans-serif;background:#0b1220;color:#d1d5db;line-height:1.8;-webkit-font-smoothing:antialiased}
|
||
.wrap{max-width:680px;margin:0 auto;padding:20px 16px 120px}
|
||
.hdr{padding:16px 0;border-bottom:1px solid rgba(255,255,255,.08);margin-bottom:20px}
|
||
.hdr h1{font-size:22px;color:#fff;line-height:1.4;font-weight:700}
|
||
.hdr .sub{font-size:13px;color:#6b7280;margin-top:6px}
|
||
.content{font-size:15px;color:#c9d1d9;line-height:1.9;word-break:break-word}
|
||
.content p{margin-bottom:12px}
|
||
.content h1,.content h2,.content h3{color:#fff;margin:20px 0 10px;font-weight:600}
|
||
.content h1{font-size:20px}
|
||
.content h2{font-size:18px}
|
||
.content h3{font-size:16px}
|
||
.content strong{color:#fff}
|
||
.content blockquote{border-left:3px solid #38bdac;padding-left:12px;margin:12px 0;color:#9ca3af}
|
||
.content code{background:#1f2937;padding:2px 6px;border-radius:3px;font-size:13px;color:#38bdac}
|
||
.content img{max-width:100%%;border-radius:6px;margin:8px 0}
|
||
.fade{position:relative;overflow:hidden;max-height:none}
|
||
.fade::after{content:"";position:absolute;bottom:0;left:0;right:0;height:120px;background:linear-gradient(transparent,#0b1220);pointer-events:none}
|
||
.cta{position:fixed;bottom:0;left:0;right:0;background:linear-gradient(transparent,#0b1220 20%%);padding:16px 16px 24px;z-index:100}
|
||
.cta-inner{max-width:680px;margin:0 auto}
|
||
.cta-hint{text-align:center;font-size:12px;color:#6b7280;margin-bottom:8px}
|
||
.btn-fallback{display:block;width:100%%;padding:14px 0;text-align:center;background:#07c160;color:#fff;border-radius:8px;font-size:16px;font-weight:600;border:none;cursor:pointer;letter-spacing:1px;text-decoration:none}
|
||
.btn-fallback:active{opacity:.8}
|
||
.copy-toast{display:none;position:fixed;top:50%%;left:50%%;transform:translate(-50%%,-50%%);background:rgba(0,0,0,.8);color:#fff;padding:12px 24px;border-radius:8px;font-size:14px;z-index:999}
|
||
.btn-unlock{display:block;width:100%%;padding:14px 0;text-align:center;background:linear-gradient(135deg,#f59e0b,#f97316);color:#fff;border-radius:8px;font-size:16px;font-weight:700;border:none;cursor:pointer;letter-spacing:1px;margin-bottom:10px;box-shadow:0 2px 12px rgba(245,158,11,.35)}
|
||
.btn-unlock:active{opacity:.85}
|
||
.guide-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.72);z-index:200;flex-direction:column;align-items:center;justify-content:flex-end;padding-bottom:80px}
|
||
.guide-overlay.visible{display:flex}
|
||
.guide-text{color:#fff;font-size:17px;font-weight:600;text-align:center;margin-bottom:16px;text-shadow:0 1px 4px rgba(0,0,0,.5)}
|
||
.guide-arrow-wrap{position:absolute;bottom:24px;right:36px;display:flex;flex-direction:column;align-items:center;gap:6px}
|
||
.guide-arrow-label{color:#fff;font-size:13px;white-space:nowrap;opacity:.9}
|
||
.guide-arrow{width:36px;height:36px;animation:bounceArrow 1s ease-in-out infinite}
|
||
@keyframes bounceArrow{0%%,100%%{transform:translateY(0)}50%%{transform:translateY(10px)}}
|
||
.guide-close{position:absolute;top:20px;right:20px;width:36px;height:36px;border-radius:50%%;background:rgba(255,255,255,.15);border:none;color:#fff;font-size:20px;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
||
.guide-close:active{background:rgba(255,255,255,.3)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<div class="hdr">
|
||
<h1>%s</h1>
|
||
%s
|
||
</div>
|
||
<div class="content fade" id="article-content">
|
||
%s
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cta">
|
||
<div class="cta-inner">
|
||
<p class="cta-hint">已预览 %d%% · 打开小程序购买并阅读全文</p>
|
||
<button class="btn-unlock" id="unlock-btn" onclick="showGuide()">¥1 解锁全文</button>
|
||
<div id="mp-btn-area">%s</div>
|
||
<button class="btn-fallback" id="fallback-btn" style="display:none" onclick="copyAndOpen()">
|
||
复制链接并打开微信
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="guide-overlay" id="guide-overlay" onclick="hideGuide()">
|
||
<button class="guide-close" onclick="hideGuide()">×</button>
|
||
<p class="guide-text">在小程序内完成购买,点击下方前往</p>
|
||
<div class="guide-arrow-wrap">
|
||
<span class="guide-arrow-label">点击右下角前往小程序</span>
|
||
<svg class="guide-arrow" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||
<path d="M12 4v12m0 0l-5-5m5 5l5-5" stroke="#fff" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="copy-toast" id="toast">已复制,请打开微信</div>
|
||
|
||
<script src="https://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
|
||
<script>
|
||
(function(){
|
||
var ua = navigator.userAgent.toLowerCase();
|
||
var isWx = ua.indexOf('micromessenger') !== -1;
|
||
var launchBtn = document.getElementById('launch-btn');
|
||
var fallbackBtn = document.getElementById('fallback-btn');
|
||
|
||
if (!isWx) {
|
||
if (launchBtn) launchBtn.style.display = 'none';
|
||
fallbackBtn.style.display = 'block';
|
||
} else {
|
||
if (launchBtn) {
|
||
launchBtn.addEventListener('error', function(e) {
|
||
console.log('launch-btn error', e.detail);
|
||
launchBtn.style.display = 'none';
|
||
fallbackBtn.style.display = 'block';
|
||
});
|
||
}
|
||
}
|
||
})();
|
||
|
||
function showGuide() {
|
||
document.getElementById('guide-overlay').classList.add('visible');
|
||
}
|
||
function hideGuide() {
|
||
document.getElementById('guide-overlay').classList.remove('visible');
|
||
}
|
||
|
||
function copyAndOpen() {
|
||
var link = 'https://soul.quwanzhi.com/read/%s';
|
||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||
navigator.clipboard.writeText(link).then(showToast);
|
||
} else {
|
||
var ta = document.createElement('textarea');
|
||
ta.value = link;
|
||
ta.style.position = 'fixed';
|
||
ta.style.left = '-9999px';
|
||
document.body.appendChild(ta);
|
||
ta.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(ta);
|
||
showToast();
|
||
}
|
||
}
|
||
function showToast() {
|
||
var t = document.getElementById('toast');
|
||
t.style.display = 'block';
|
||
setTimeout(function(){ t.style.display = 'none'; }, 2000);
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>`,
|
||
escapedTitle, escapedTitle,
|
||
escapedTitle, subtitle,
|
||
contentHTML,
|
||
d.Percent, mpBtn,
|
||
html.EscapeString(d.SectionID))
|
||
}
|
||
|
||
func h5ContentToHTML(content string) string {
|
||
if content == "" {
|
||
return ""
|
||
}
|
||
if strings.HasPrefix(content, "<") && strings.Contains(content, "</") {
|
||
return content
|
||
}
|
||
lines := strings.Split(content, "\n")
|
||
var sb strings.Builder
|
||
for _, line := range lines {
|
||
trimmed := strings.TrimSpace(line)
|
||
if trimmed == "" {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(trimmed, "### ") {
|
||
sb.WriteString("<h3>")
|
||
sb.WriteString(html.EscapeString(trimmed[4:]))
|
||
sb.WriteString("</h3>")
|
||
} else if strings.HasPrefix(trimmed, "## ") {
|
||
sb.WriteString("<h2>")
|
||
sb.WriteString(html.EscapeString(trimmed[3:]))
|
||
sb.WriteString("</h2>")
|
||
} else if strings.HasPrefix(trimmed, "# ") {
|
||
sb.WriteString("<h1>")
|
||
sb.WriteString(html.EscapeString(trimmed[2:]))
|
||
sb.WriteString("</h1>")
|
||
} else if strings.HasPrefix(trimmed, "> ") {
|
||
sb.WriteString("<blockquote>")
|
||
sb.WriteString(html.EscapeString(trimmed[2:]))
|
||
sb.WriteString("</blockquote>")
|
||
} else {
|
||
sb.WriteString("<p>")
|
||
sb.WriteString(html.EscapeString(trimmed))
|
||
sb.WriteString("</p>")
|
||
}
|
||
}
|
||
return sb.String()
|
||
}
|