评论阶段一(前端):评论区 + 登录引导卡 + 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
@@ -0,0 +1,179 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { reader, loadReader, signIn, signOut } from '../../reader'
import { readerApi } from '../../api'
import { useComments } from '../../composables/useComments'
import CommentItem from './CommentItem.vue'
import { BRANDS } from './brands'
// 文章评论区。classic 和 vivid 共用这一份 DOM —— 两边只是 CSS 不同
// (基础样式在 styles.css 的 .cm-*,vivid 的细节在 ui/yohaku/comments.css)。
// 这和左栏/工具栏那种「两套组件」不同:评论的结构完全相同,共用一份才不会漂移。
//
// 刻意不写 <style>:vivid 组件的既有约定,且这里要同时服务两套 UI。
const props = defineProps({
postId: { type: Number, default: 0 }
})
const api = useComments(props.postId)
const { s, remaining, load, setSort, submit, saveEdit, remove, loadMoreReplies, startReply, startEdit, cancel } = api
const sorts = [
{ value: 'default', label: '默认' },
{ value: 'newest', label: '最新' },
{ value: 'oldest', label: '最早' }
]
const MAX_LEN = 500
const draft = ref('')
const canSend = computed(() => draft.value.trim().length > 0 && !s.submitting && draft.value.length <= MAX_LEN)
async function onSend() {
if (!canSend.value) return
const ok = await submit(draft.value.trim(), s.replyTo)
if (ok) {
draft.value = ''
cancel()
}
}
function onSignIn(p) {
if (p.kind === 'widget') {
mountTelegram(p)
return
}
signIn(p)
}
// Telegram 的 Login Widget 没有跳转:只能把官方脚本插进来,它会把自己换成
// 官方按钮(iframe,样式改不动)。所以它和另外两个自绘圆钮长得不一样 ——
// 这是 Telegram 的限制,不是没做。
const tgBox = ref(null)
function mountTelegram(p) {
const box = tgBox.value
if (!box || box.dataset.mounted || !p.login) return
box.dataset.mounted = '1'
const sc = document.createElement('script')
sc.async = true
sc.src = 'https://telegram.org/js/telegram-widget.js?22'
sc.setAttribute('data-telegram-login', p.login)
sc.setAttribute('data-size', 'medium')
sc.setAttribute('data-radius', '10')
sc.setAttribute('data-request-access', 'write')
sc.setAttribute('data-onauth', 'oneTelegramAuth(user)')
box.appendChild(sc)
}
// 官方脚本要求回调是全局函数名
if (typeof window !== 'undefined') {
window.oneTelegramAuth = async (user) => {
try {
await readerApi.telegram(user)
await loadReader()
} catch (e) {
s.error = e.message || 'Telegram 登录失败'
}
}
}
onMounted(() => {
if (!reader.loaded) loadReader()
if (props.postId) load(true)
})
</script>
<template>
<section id="comments" class="cm-sec">
<div class="cm-head">
<span class="cm-count">共 {{ s.total }} 条评论</span>
<div class="seg cm-sort" role="tablist" aria-label="排序方式">
<button
v-for="o in sorts"
:key="o.value"
type="button"
role="tab"
:aria-selected="s.sort === o.value"
:class="{ on: s.sort === o.value }"
@click="setSort(o.value)"
>{{ o.label }}</button>
</div>
</div>
<!-- 登录卡 / 输入框 -->
<div v-if="!reader.loaded" class="cm-gate cm-gate-skel" aria-hidden="true" />
<div v-else-if="!reader.user" class="cm-gate">
<p class="cm-gate-t">使用社交账号登录</p>
<ul v-if="reader.providers.length" class="cm-providers">
<li v-for="p in reader.providers" :key="p.id">
<button
type="button"
class="cm-provider"
:title="`使用 ${p.label} 登录`"
:aria-label="`使用 ${p.label} 登录`"
@click="onSignIn(p)"
>
<svg v-if="BRANDS[p.id]" class="cm-brand" viewBox="0 0 24 24" aria-hidden="true">
<path :d="BRANDS[p.id]" />
</svg>
<span v-else class="cm-brand-t">{{ p.label.slice(0, 1) }}</span>
</button>
</li>
</ul>
<p v-else class="cm-gate-hint">站点还没配置登录方式。</p>
<!-- Telegram 官方按钮落到这里 -->
<div ref="tgBox" class="cm-tg" />
</div>
<div v-else class="cm-compose">
<div class="cm-av cm-av-me">
<img v-if="reader.user.avatar_url" :src="reader.user.avatar_url" alt="" loading="lazy">
<span v-else class="cm-av-fb">{{ (reader.user.name || '?').slice(0, 1) }}</span>
</div>
<div class="cm-field">
<textarea
v-model="draft"
class="cm-input"
rows="3"
:maxlength="MAX_LEN"
placeholder="说点什么…(支持 Markdown)"
aria-label="写评论"
/>
<div class="cm-bar">
<span class="cm-bar-l">
<b>{{ reader.user.name }}</b>
<button type="button" class="cm-link" @click="signOut">退出</button>
</span>
<span class="cm-bar-r">
<span class="cm-len" :class="{ over: draft.length > MAX_LEN }">{{ draft.length }}/{{ MAX_LEN }}</span>
<button type="button" class="cm-send" :disabled="!canSend" @click="onSend">
{{ s.submitting ? '发送中…' : '发送' }}
</button>
</span>
</div>
</div>
</div>
<p v-if="s.error" class="cm-err">{{ s.error }}</p>
<!-- 骨架 / 空态 -->
<ul v-if="s.loading" class="cm-list cm-skel" aria-hidden="true">
<li v-for="i in 3" :key="i" class="cm-skel-row">
<span class="cm-skel-av" />
<span class="cm-skel-lines"><i /><i /></span>
</li>
</ul>
<div v-else-if="!s.items.length" class="empty cm-empty">
<h3>这里还静悄悄</h3>
<p>第一条评论留给你。</p>
</div>
<ul v-else class="cm-list">
<CommentItem v-for="c in s.items" :key="c.id" :c="c" :api="api" :depth="0" :max-len="MAX_LEN" />
</ul>
<div v-if="remaining > 0 && !s.loading" class="cm-more">
<button type="button" class="chip" :disabled="s.loadingMore" @click="load(false)">
{{ s.loadingMore ? '载入中…' : `显示更多(剩 ${remaining} 条)` }}
</button>
</div>
</section>
</template>