diff --git a/internal/handler/reader_comment_test.go b/internal/handler/reader_comment_test.go
new file mode 100644
index 0000000..f74fc70
--- /dev/null
+++ b/internal/handler/reader_comment_test.go
@@ -0,0 +1,40 @@
+package handler
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// 段评打开接口的 HTTP 层契约:路由存在、参数校验生效、内网地址被拒。
+//
+// 正常路径(真的去抓评论页)依赖外部站点,放在服务层解析单测里覆盖;
+// 这里只钉住契约与安全边界,避免把测试绑到网络。
+func TestReaderOpenCommentRejectsBadURL(t *testing.T) {
+ container := newReaderHandlerContainer(t)
+ router := registerReaderRoutesForTest(container)
+
+ post := func(body string) int {
+ w := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/api/reader/comments/open", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ router.ServeHTTP(w, req)
+ return w.Code
+ }
+
+ cases := []struct {
+ name string
+ body string
+ }{
+ {"缺少 url", `{"book_id":"x"}`},
+ {"非 http 协议", `{"book_id":"x","url":"javascript:alert(1)"}`},
+ {"回环地址", `{"book_id":"x","url":"http://127.0.0.1/c"}`},
+ {"内网地址", `{"book_id":"x","url":"http://192.168.1.1/c"}`},
+ }
+ for _, c := range cases {
+ if code := post(c.body); code != http.StatusBadRequest {
+ t.Fatalf("%s:应 400,得到 %d", c.name, code)
+ }
+ }
+}
diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go
index 2cc38f3..6985a81 100644
--- a/internal/handler/reader_routes.go
+++ b/internal/handler/reader_routes.go
@@ -40,6 +40,9 @@ func registerReaderRoutes(authed *gin.RouterGroup, svc *service.Container) {
// 页面内的 fetch/XHR 经此转发(iframe 是不透明源,请求带不上书源 Cookie)
g.POST("/browser/xhr", readerBrowserXHRHandler(svc))
+ // 段评:用宿主浏览器打开评论地址(带书源 Cookie/登录态,对应书源的 showCmt)
+ g.POST("/comments/open", readerOpenCommentHandler(svc))
+
// 搜索(多源聚合)
g.POST("/search", readerSearchHandler(svc))
@@ -249,6 +252,30 @@ func readerBrowserXHRHandler(svc *service.Container) gin.HandlerFunc {
}
}
+// readerOpenCommentHandler 打开一条段评:复用书源登录态在宿主浏览器里承载评论页。
+// 书源的 showCmt 内部就是「java.ajax 取评论页 → java.showBrowser 展示」,
+// 这里用承载登录页的同一套机制等价实现(见 reader.OpenContentComment)。
+func readerOpenCommentHandler(svc *service.Container) gin.HandlerFunc {
+ var body struct {
+ BookID string `json:"book_id" binding:"required"`
+ URL string `json:"url" binding:"required"`
+ Title string `json:"title"`
+ }
+ return func(c *gin.Context) {
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ userID := c.GetString(middleware.CtxUserID)
+ page, err := svc.Reader.OpenContentComment(c.Request.Context(), userID, body.BookID, body.URL, body.Title)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, page)
+ }
+}
+
// readerBrowserPageHandler 承载待办页面本体。//
// 鉴权走 HMAC 签名而非 JWT:这个地址要填进
\n第二段
" {
+ t.Fatalf("正文被破坏:%q", out.Content)
+ }
+ if len(out.Comments) != 1 {
+ t.Fatalf("段评数 = %d,期望 1", len(out.Comments))
+ }
+ c := out.Comments[0]
+ if c.Line != 0 || c.Count != 5 || c.URL != "https://v1.example.com/get_review?book_id=1" {
+ t.Fatalf("段评内容不符:%+v", c)
+ }
+}
diff --git a/internal/service/reader/reader.go b/internal/service/reader/reader.go
index 94595ec..ac91009 100644
--- a/internal/service/reader/reader.go
+++ b/internal/service/reader/reader.go
@@ -45,8 +45,10 @@ type ReaderService struct {
// browserMu / browserPending 保护「待用户完成的页面」表
// (java.startBrowser / startBrowserAwait,见 browser_panel.go)。
+ // browserSeq 是页面登记的单调递增序号,供前端判断哪个页面最新。
browserMu sync.Mutex
browserPending map[string]*pendingBrowser
+ browserSeq int64
// limiter 单源限速(书源 concurrentRate)。
limiter *sourceRateLimiter
@@ -1401,6 +1403,9 @@ type ChapterContent struct {
Tracks []string `json:"tracks,omitempty"` // 音频播放地址(已改写为服务端签名代理)
Images []string `json:"images,omitempty"` // 漫画图片列表(已改写为服务端签名代理)
ImageStyle string `json:"image_style,omitempty"` // 对应 legado ruleContent.imageStyle
+ // Comments 正文里的段评锚点( 标记与带 click 配置的内嵌评论图,见 comment.go)。
+ // 只在文本类型下有意义;前端按 Line 把气泡挂到对应段落旁。
+ Comments []ContentComment `json:"comments,omitempty"`
// Transcoding 为真表示该音轨走了服务端转码(源格式浏览器解不了),
// 首次播放需要等转码完成,之后命中缓存秒开。
Transcoding bool `json:"transcoding,omitempty"`
@@ -1702,6 +1707,9 @@ func (s *ReaderService) getContentFrom(ctx context.Context, src *model.ReaderBoo
out.ImageStyle = SPtr(cr.ImageStyle)
default:
out.Type = "text"
+ // 正文里的段评标记( 或「图片地址 + click 配置」的内嵌评论图)
+ // 归一成结构化锚点,正文文字原样保留(见 comment.go 的说明)。
+ out.Content, out.Comments = extractContentComments(content)
}
return out, nil
}
diff --git a/web/src/api/reader.ts b/web/src/api/reader.ts
index b686361..25de5a6 100644
--- a/web/src/api/reader.ts
+++ b/web/src/api/reader.ts
@@ -118,10 +118,26 @@ export interface ReaderChapterContent {
tracks?: string[]
images?: string[]
image_style?: string
+ /** 正文里的段评锚点(按 line 挂到对应段落旁的气泡)。 */
+ comments?: ReaderContentComment[]
/** 该音轨走了服务端转码(源格式浏览器解不了),首次播放需要等转码完成。 */
transcoding?: boolean
}
+/** 正文里的一条段评(服务端从 / 内嵌评论图里解析出来)。 */
+export interface ReaderContentComment {
+ /** 段落行号:content 按 \n 拆分后的 0-based 下标。 */
+ line: number
+ /** 评论数(0 表示未知,只画一个不带数字的气泡)。 */
+ count: number
+ /** 评论地址(书源 showCmt 的第一个参数)。 */
+ url: string
+ /** 段评 / 本章说。 */
+ label?: string
+ /** 独占一行的整块评论(章末「本章说」)。 */
+ block?: boolean
+}
+
export interface ReaderReplaceRule {
id: string
name: string
@@ -174,6 +190,8 @@ export interface ReaderBrowserPage {
title: string
/** wait = 需要回传 DOM(点 √);open = 仅展示。 */
mode: 'wait' | 'open'
+ /** 登记序号(单调递增)。同时有多个待办时,取它最大的那个展示。 */
+ seq: number
/** iframe 承载地址(同源,带签名)。 */
page_url: string
refetch: boolean
@@ -223,6 +241,9 @@ export const readerAPI = {
// 回传用户操作后的 DOM(或取消),解除服务端阻塞
submitBrowserResult: (body: { id: string; body?: string; url?: string; cancelled?: boolean }) =>
api.post('/reader/browser/result', body).then((r) => r.data),
+ // 打开一条段评:复用书源登录态在宿主浏览器里承载评论页(对应书源的 showCmt)
+ openContentComment: (body: { book_id: string; url: string; title?: string }) =>
+ api.post('/reader/comments/open', body).then((r) => r.data),
// 承载页面内的接口请求转交服务端代发(iframe 是不透明源,带不上书源 Cookie)
browserXHR: (body: {
id: string
diff --git a/web/src/pages/reader/ReaderViewPage.tsx b/web/src/pages/reader/ReaderViewPage.tsx
index a644ee6..d9206e4 100644
--- a/web/src/pages/reader/ReaderViewPage.tsx
+++ b/web/src/pages/reader/ReaderViewPage.tsx
@@ -9,6 +9,7 @@ import {
LayoutList,
ListEnd,
Loader2,
+ MessageSquare,
Minus,
Moon,
Plus,
@@ -17,12 +18,21 @@ import {
} from 'lucide-react'
import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
-import { readerAPI, type ReaderBook, type ReaderChapter, type ReaderChapterContent, type ReaderSearchOrigin } from '../../api/reader'
+import {
+ readerAPI,
+ type ReaderBook,
+ type ReaderBrowserPage,
+ type ReaderChapter,
+ type ReaderChapterContent,
+ type ReaderContentComment,
+ type ReaderSearchOrigin,
+} from '../../api/reader'
import { useComicSpreads } from '../../hooks/useComicSpreads'
import { useSmoothWheelScroll } from '../../hooks/useSmoothWheelScroll'
import { useReaderAudioStore } from '../../stores/readerAudio'
import { COMIC_IMAGE_FITS, READER_THEMES, getReaderTheme, useReaderSettingsStore } from '../../stores/readerSettings'
import { buildChapterGroups, chapterGroupIndexOf } from '../../utils/chapterGroups'
+import BrowserPanel from './BrowserPanel'
import { ReaderAudioPanel } from './ReaderAudioPanel'
import { ReaderComic } from './ReaderComic'
import { SourcePickerDialog } from './SourcePickerDialog'
@@ -63,6 +73,12 @@ const COMIC_SPREAD_MIN_WIDTH = 900
/** 空图片列表的常量引用:避免每次渲染产生新数组、把分组结果的 memo 依赖打散。 */
const NO_IMAGES: string[] = []
+/** 一个渲染单元:一段正文文字 + 挂在这一行上的段评气泡。 */
+interface ReaderParagraphBlock {
+ text: string
+ comments: ReaderContentComment[]
+}
+
function firstReadableIndex(chapters: ReaderChapter[]): number {
const i = chapters.findIndex((c) => !c.is_volume && c.url)
if (i !== -1) return i
@@ -93,6 +109,8 @@ export default function ReaderViewPage() {
const [switchLoading, setSwitchLoading] = useState(false)
const [switchCandidates, setSwitchCandidates] = useState([])
const [reloadKey, setReloadKey] = useState(0)
+ /** 段评承载页:点击段评气泡后由宿主浏览器打开评论页(带书源登录态)。 */
+ const [browserPage, setBrowserPage] = useState(null)
// 顶栏高度:菜单打开时正文整体下移这么多,顶栏就不会压住开头几行
const topBarRef = useRef(null)
const [menuInset, setMenuInset] = useState(0)
@@ -715,15 +733,59 @@ export default function ReaderViewPage() {
},
}
})()
- const paragraphs = (content ?? '').split('\n').map((p) => p.trim()).filter(Boolean)
+ // 正文按行组织成「段落块」:保留原始行号(段评锚点按行号挂靠),
+ // 只丢掉既无文字又无段评的空行。
+ const blocks = useMemo(() => {
+ const lines = (content ?? '').split('\n')
+ const byLine = new Map()
+ for (const c of media?.type === 'text' ? (media.comments ?? []) : []) {
+ const list = byLine.get(c.line)
+ if (list) list.push(c)
+ else byLine.set(c.line, [c])
+ }
+ const out: ReaderParagraphBlock[] = []
+ for (let i = 0; i < lines.length; i++) {
+ const text = lines[i].trim()
+ const comments = byLine.get(i) ?? []
+ if (text === '' && comments.length === 0) continue
+ out.push({ text, comments })
+ }
+ return out
+ }, [content, media])
- // 正文段落:普通段落按缩进排版,[img] 行渲染成居中图片
- const renderParagraph = (line: string, key: number) => {
- if (line.startsWith(IMG_MARK)) {
+ // 打开段评:服务端带书源 Cookie 抓取评论页,这里用 iframe 承载。
+ // 前端直接 window.open 会以未登录身份访问,评论接口拿不到数据。
+ const openComment = useCallback(
+ async (comment: ReaderContentComment) => {
+ if (!book) return
+ if (!comment.url) {
+ toast('这条评论没有可用地址')
+ return
+ }
+ try {
+ const page = await readerAPI.openContentComment({
+ book_id: book.id,
+ url: comment.url,
+ title: comment.label || '段评',
+ })
+ setBrowserPage(page)
+ } catch (e) {
+ toast.error(
+ (e as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '打开段评失败',
+ )
+ }
+ },
+ [book],
+ )
+
+ // 正文段落块:普通段落按缩进排版,[img] 行渲染成居中图片,段评在段末挂气泡
+ const renderParagraph = (block: ReaderParagraphBlock, key: number) => {
+ const { text, comments } = block
+ if (text.startsWith(IMG_MARK)) {
return (
)
}
+ const standalone = text === '' && comments.length > 0
return (
-
- {line}
+
c.block) ? 'center' : undefined,
+ }}
+ >
+ {text}
+ {comments.map((c, ci) => (
+
+ ))}
)
}
@@ -785,8 +882,16 @@ export default function ReaderViewPage() {
) : settings.pageMode === 'page' ? (
/* 左右/上下留边(legado 默认左右16/上下6),避免正文贴屏幕边;
菜单打开时整体下移一个顶栏高度,顶栏不再压住正文(用 transform,
- 不改高度也就不触发重新分页) */
-
+ 不改高度也就不触发重新分页)。
+ relative + z-index + pointer-events-none:正文容器带 transform,
+ 本身就形成一个层叠上下文,段评气泡的 z-index 只在容器内部生效,
+ 会被后面的点击覆盖层压住。把整层抬到覆盖层之上并关闭指针事件,
+ 只让气泡自己 pointer-events:auto,气泡可点、其余位置照样透给
+ 覆盖层翻页/呼出菜单。 */
+
- {paragraphs.map((p, i) => renderParagraph(p, i))}
+ {blocks.map((b, i) => renderParagraph(b, i))}
@@ -825,13 +930,13 @@ export default function ReaderViewPage() {
style={{ fontSize: settings.fontSize, lineHeight: settings.lineHeight, ...menuShiftStyle }}
>
- {paragraphs.map((p, i) => renderParagraph(p, i))}
+ {blocks.map((b, i) => renderParagraph(b, i))}
)}
{/* 加载 / 错误 / 空内容态 */}
- {(loadingStage !== null || (content !== null && paragraphs.length === 0)) && contentType === 'text' && (
+ {(loadingStage !== null || (content !== null && blocks.length === 0)) && contentType === 'text' && (
{loadingStage !== null ? (
@@ -865,7 +970,7 @@ export default function ReaderViewPage() {
原生纵向滚动(手指落在覆盖层上时找不到可滚动的祖先节点),
所以滚动模式由正文容器的 onClick 分区(见 handleZoneTap)。 */}
{settings.pageMode === 'page' && (contentType === 'text' || contentType === 'image') && (
-
+
)
}
diff --git a/web/src/pages/reader/SourceLoginDialog.tsx b/web/src/pages/reader/SourceLoginDialog.tsx
index a69190a..fc4ea39 100644
--- a/web/src/pages/reader/SourceLoginDialog.tsx
+++ b/web/src/pages/reader/SourceLoginDialog.tsx
@@ -70,8 +70,16 @@ export default function SourceLoginDialog({ sourceId, sourceName, onClose, onLog
const pollBrowserPages = useCallback(async () => {
try {
const pages = await readerAPI.browserPending(sourceId)
- const next = pages.find((p) => !handledPages.current.has(p.id))
- if (next) setBrowserPage(next)
+ // 只取本次还没处理过的页面,并按 seq 取最新登记的那个。
+ // 不能直接拿数组第一个:服务端待办表可能同时存着多个页面,且返回顺序
+ // 是随机的(Go map 遍历),拿第一个会把旧页面顶到正在点的按钮上
+ // (光遇聚合「更新书源」残留后串页就是这么来的)。
+ const next = pages
+ .filter((p) => !handledPages.current.has(p.id))
+ .reduce
((acc, p) => (!acc || p.seq > acc.seq ? p : acc), null)
+ if (!next) return
+ // 已经在展示的页面不要被更旧的页面替换掉
+ setBrowserPage((cur) => (cur && cur.seq >= next.seq ? cur : next))
} catch {
// 轮询失败不打断正在执行的动作
}