评论阶段一(前端):评论区 + 登录引导卡 + dev mock,经典头像留白修复

评论区(按已批准的设计文档,阶段一=前端;后端与多用户为阶段二/三)
- classic / vivid 共用同一份 DOM:CommentSection + CommentItem(回复一层、
  自引用渲染,回复/编辑框模板只写一份)+ CommentMarkdown(唯一 v-html 出口,
  必过 sanitizeHtml)。比计划里的两套组件少一半文件,也避免两边结构漂移
- 数据层:api.js 追加 readerApi(me/providers/logout/comments/thread/create/
  edit/remove);src/reader.js 读者身份 reactive 单例(仿 site.js,
  挂 one:unauthorized 清 user);composables/useComments 按文章实例化
  (刻意不做单例,换文章必须重置),乐观插入按当前排序落位
- 交互:排序(默认/最新/最早)、发表、回复(一层 + 加载更多回复)、
  10 分钟内编辑、软删、分页(显示更多 N 条)、骨架屏、空态、错误态、审核中徽章
- 登录引导卡:三种 provider(GitHub/Google/Telegram);redirect 类整页跳转,
  widget 类(Telegram)点击时内联官方脚本(无跳转、无回调,官方按钮落位)
- 样式:styles.css 追加 .cm-* 基础段(经典 token,vivid 靠别名继承同一份规则);
  ui/yohaku/comments.css 只写 vivid 差异(全前缀、特指度盖过基础版),
  自己的窄屏/触屏规则写在文件内,不塞 mobile.css
- main.ts:comments.css 插在 pages 之后 mobile 之前(层叠契约不变);
  dev mock 用 ?mock 门控动态 import,await 装完再挂载,否则首屏请求会打到真接口

修三处真问题
- vivid 的 a{color:inherit} 重置(特指度 0,1,2)把作者链接名拉回 body 色,
  暗色下暗底暗字 —— 用 a.cm-name-a 压回
- classic 文章页整页掉进 236px 左列:PostView import 了 LeftNav/RightRail
  却没渲染,补上(与 HomeView 三栏一致),文章页回到 640px
- classic 信息流头像贴死列边框:.row-feed 的负 margin 正好抵光左右内边距,
  改 padding 14/16 -> 14/30(30 = 16 出血 + 14 留白,hover 底色与分隔线仍铺满整列)

mock 假数据
- 8 位作者(三种登录方式、不同色相头像、两个走首字母兜底)、
  默认一屏 8 条各测一个排版点(代码块/引用/列表/已删除/审核中/已编辑/站长徽章/
  加载更多回复),长列表态 26 条测分页;右下角浮层切 9 个状态(?mock&state=),
  ?ui=classic 可强制 classic 壳(平时渲染不出来,没法做探针)

验证(headless Chromium + CDP 真实时间截图;virtual-time 对含动态 import 的页面
截图时机不稳,会截出全白,已换 CDP 方案)
- 头部计数 13px/tabular-nums、.seg 3 选 1;头像 50% 圆、36/24px;
  气泡圆角 3,20,20,20(缺角);回复缩进 36px 且无二层嵌套;360 宽无横向溢出
- 暗色:气泡/输入框/名字全部适配(量的时候被 a 的 150ms 颜色 transition 骗过,
  t0 与 t400 两次测量值不同才看清,稳态正确)
- 产物:CSS 顺序 styles→pages→comments→mobile;49 条评论规则全带
  html[data-ui=vivid] 前缀;grep __COMMENT_MOCK__ 为空、无 mock chunk —— 生产零 mock
This commit is contained in:
Sakurasan
2026-09-26 18:36:12 +08:00
parent 8fd8674910
commit 815eff9f9a
15 changed files with 1351 additions and 3 deletions
+179
View File
@@ -0,0 +1,179 @@
import { computed, reactive } from 'vue'
import { readerApi } from '../api'
// 一篇文章的评论区状态。
// 刻意做成「每个文章实例化一份」而不是像 site.js 那样的单例 ——
// 换文章必须重新来过,单例会在路由切换时残留上一条的数据。
//
// 后端契约(见 README / 方案文档):
// GET /api/comments?post_id=&sort=&page=&size=
// -> { items:[Comment], total, page, pages, has_more }
// Comment = { id, parent_id, root_id, body_html, state, created_at, edited_at,
// is_deleted, reply_count, replies:[Comment], author:{...} }
// total / has_more 只统计顶层评论;回复挂在各自的 replies 里。
const PAGE_SIZE = 10
export function useComments(postId) {
const s = reactive({
items: [],
total: 0,
page: 0,
sort: 'default', // default | newest | oldest
loading: false,
loadingMore: false,
error: '',
replyTo: null, // 正在回复的那条(顶层或回复都可能是)
editing: null, // 正在编辑的那条
submitting: false
})
const remaining = computed(() => Math.max(0, s.total - s.items.length))
async function load(reset = true) {
if (s.loading || s.loadingMore) return
if (reset) {
s.loading = true
s.error = ''
} else {
s.loadingMore = true
}
try {
const page = reset ? 1 : s.page + 1
const data = await readerApi.comments(postId, { sort: s.sort, page, size: PAGE_SIZE })
const items = (data && data.items) || []
s.items = reset ? items : s.items.concat(items)
s.page = page
s.total = (data && data.total) || 0
} catch (e) {
s.error = e.message || '评论加载失败'
} finally {
s.loading = false
s.loadingMore = false
}
}
function setSort(sort) {
if (sort === s.sort) return
s.sort = sort
load(true)
}
// 发表 / 回复。成功后直接插进列表,不整页重拉 —— 位置按当前排序决定,
// 用户刚发的东西必须马上看得见。
async function submit(bodyMd, parent = null) {
s.submitting = true
s.error = ''
try {
const created = await readerApi.create(postId, bodyMd, parent ? parent.id : 0)
s.total += 1
if (parent) {
const root = rootOf(parent)
if (root) {
root.replies = (root.replies || []).concat(created)
root.reply_count = (root.reply_count || 0) + 1
}
} else if (s.sort === 'oldest') {
s.items.push(created)
} else {
s.items.unshift(created)
}
return created
} catch (e) {
s.error = e.message || '发表失败'
return null
} finally {
s.submitting = false
}
}
async function saveEdit(comment, bodyMd) {
s.submitting = true
s.error = ''
try {
const updated = await readerApi.edit(comment.id, bodyMd)
// 后端会把新的 body_html / edited_at 一起回来,就地替换
comment.body_html = updated.body_html
comment.edited_at = updated.edited_at
s.editing = null
return true
} catch (e) {
s.error = e.message || '保存失败'
return false
} finally {
s.submitting = false
}
}
async function remove(comment) {
s.error = ''
try {
await readerApi.remove(comment.id)
comment.is_deleted = true
comment.body_html = ''
// 顶层评论软删后总数要减;回复只改自身
if (!comment.parent_id) s.total = Math.max(0, s.total - 1)
else {
const root = rootOf(comment)
if (root) root.reply_count = Math.max(0, (root.reply_count || 0) - 1)
}
return true
} catch (e) {
s.error = e.message || '删除失败'
return false
}
}
// 回复折叠时按需拉取(GET /api/comments/{rootId}/thread)
async function loadMoreReplies(root) {
if (root.__threadLoading) return
root.__threadLoading = true
try {
const data = await readerApi.thread(root.id, root.__cursor || '')
const more = (data && data.items) || []
root.replies = (root.replies || []).concat(more)
root.__cursor = (data && data.cursor) || ''
root.reply_count = (data && data.reply_count) || (root.replies || []).length
} catch (e) {
s.error = e.message || '回复加载失败'
} finally {
root.__threadLoading = false
}
}
// 顶层评论列表里找出某条回复所属的根
function rootOf(comment) {
if (comment.root_id) {
const byRoot = s.items.find((c) => c.id === comment.root_id)
if (byRoot) return byRoot
}
return s.items.find((c) => (c.replies || []).some((r) => r.id === comment.id)) || null
}
function startReply(comment) {
s.editing = null
s.replyTo = comment
}
function startEdit(comment) {
s.replyTo = null
s.editing = comment
}
function cancel() {
s.replyTo = null
s.editing = null
}
return {
s,
remaining,
load,
setSort,
submit,
saveEdit,
remove,
loadMoreReplies,
startReply,
startEdit,
cancel
}
}