评论阶段一(前端):评论区 + 登录引导卡 + 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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user