评论阶段一(前端):评论区 + 登录引导卡 + 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
+25
View File
@@ -85,6 +85,31 @@ export const adminApi = {
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body })
}
// 读者(评论区)身份。会话是服务端 httpOnly cookie(one_reader),
// 前端只拿 /api/auth/me 的结果做展示,不存 token。
// 注意 me 在匿名时返回 200 {user:null} 而不是 401 —— request() 对每个 401
// 都会派发 one:unauthorized,匿名访客每次开页都会刷一遍事件。
export const readerApi = {
me: () => request('/api/auth/me'),
providers: () => request('/api/auth/providers'),
logout: () => request('/api/auth/logout', { method: 'POST' }),
// Telegram 是 Login Widget:官方脚本回调里直接带着签名字段,没有 code 可换
telegram: (payload) => request('/api/auth/telegram', { method: 'POST', body: payload }),
comments: (postId, { sort = 'default', page = 1, size = 10 } = {}) =>
request('/api/comments?' + new URLSearchParams({ post_id: postId, sort, page, size })),
thread: (rootId, cursor = '', size = 10) =>
request(`/api/comments/${rootId}/thread?` + new URLSearchParams({ cursor, size })),
create: (postId, bodyMd, parentId = 0) =>
request('/api/comments', {
method: 'POST',
body: { post_id: postId, parent_id: parentId, body_md: bodyMd }
}),
edit: (id, bodyMd) =>
request(`/api/comments/${id}`, { method: 'PUT', body: { body_md: bodyMd } }),
remove: (id) => request(`/api/comments/${id}`, { method: 'DELETE' })
}
// 后台登录态:cookie 由服务端下发(httpOnly),这里只存一份展示用的用户名
const USER_KEY = 'one.admin.user'
+3 -1
View File
@@ -71,7 +71,9 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
gap: 12px;
padding: 14px 16px;
/* 左右 30 = 16(出血到列边)+ 14(内容离边框的呼吸位)。
之前是 16/16,负 margin 正好把内边距抵光,头像贴死在边框上。 */
padding: 14px 30px;
margin: 0 -16px;
border-bottom: 1px solid var(--line-soft);
transition: background 0.15s ease;
@@ -0,0 +1,178 @@
<script setup>
import { computed, ref } from 'vue'
import { reader } from '../../reader'
import CommentMarkdown from './CommentMarkdown.vue'
import { BRANDS, PROVIDER_LABEL } from './brands'
import { relativeDate } from '../../utils'
// 单条评论。回复在同一个组件里自引用渲染(depth 0 → 1),
// 这样「回复框 / 编辑框」的模板只写一遍,两层不会各写一份再漂移。
// 只渲染一层:depth >= 1 时不再显示它自己的 replies 与「加载更多」。
const props = defineProps({
c: { type: Object, required: true },
api: { type: Object, required: true },
depth: { type: Number, default: 0 },
maxLen: { type: Number, default: 500 }
})
const s = props.api.s
const EDIT_WINDOW_MS = 10 * 60 * 1000
const name = computed(() => (props.c.author && props.c.author.name) || '已注销用户')
const relativeTime = computed(() => relativeDate(props.c.created_at))
const isOwner = computed(() => !!(props.c.author && props.c.author.is_owner))
const isMine = computed(() => !!(reader.user && props.c.author && reader.user.id === props.c.author.id))
// 外链:优先作者自填的 url,其次 GitHub 主页
const authorHref = computed(() => {
const a = props.c.author
if (!a) return ''
if (a.url) return a.url
if (a.provider === 'github' && a.handle) return `https://github.com/${a.handle}`
return ''
})
const canEdit = computed(() => {
if (props.c.is_deleted) return false
if (isOwner.value) return true
if (!isMine.value) return false
const t = new Date(props.c.created_at).getTime()
return Number.isFinite(t) && Date.now() - t <= EDIT_WINDOW_MS
})
const canDelete = computed(() => !props.c.is_deleted && (isMine.value || isOwner.value))
const replying = computed(() => s.replyTo && s.replyTo.id === props.c.id)
const editing = computed(() => s.editing && s.editing.id === props.c.id)
const replyDraft = ref('')
const editDraft = ref('')
function startReply() {
replyDraft.value = ''
props.api.startReply(props.c)
}
function startEdit() {
editDraft.value = props.c.body_md || ''
props.api.startEdit(props.c)
}
async function onReply() {
const text = replyDraft.value.trim()
if (!text) return
const ok = await props.api.submit(text, props.c)
if (ok) {
replyDraft.value = ''
props.api.cancel()
}
}
async function onSave() {
const text = editDraft.value.trim()
if (!text) return
await props.api.saveEdit(props.c, text)
}
async function onRemove() {
if (!window.confirm('删掉这条评论?')) return
await props.api.remove(props.c)
}
const moreReplies = computed(() =>
props.depth === 0 ? Math.max(0, (props.c.reply_count || 0) - ((props.c.replies || []).length)) : 0
)
</script>
<template>
<li class="cm-item" :class="{ 'is-deleted': c.is_deleted, 'is-reply': depth > 0 }">
<div class="cm-av" :class="{ 'is-owner': isOwner }">
<img
v-if="c.author && c.author.avatar_url"
:src="c.author.avatar_url"
:alt="`${name} 的头像`"
loading="lazy"
>
<span v-else class="cm-av-fb">{{ name.slice(0, 1) }}</span>
<i v-if="c.author && c.author.provider" class="cm-pv" :title="PROVIDER_LABEL[c.author.provider] || c.author.provider">
<svg v-if="BRANDS[c.author.provider]" viewBox="0 0 24 24" aria-hidden="true">
<path :d="BRANDS[c.author.provider]" />
</svg>
</i>
</div>
<div class="cm-main">
<div class="cm-name">
<a
v-if="authorHref"
class="cm-name-a"
:href="authorHref"
target="_blank"
rel="noopener noreferrer"
>{{ name }}</a>
<span v-else class="cm-name-a">{{ name }}</span>
<span v-if="isOwner" class="cm-owner">站长</span>
<time class="cm-time" :datetime="c.created_at">{{ relativeTime }}</time>
<span v-if="c.state === 'pending'" class="cm-pending">审核中</span>
<span v-if="c.edited_at" class="cm-edited-tip">已编辑</span>
</div>
<!-- 编辑态:把气泡换成输入框 -->
<div v-if="editing" class="cm-field cm-field-inline">
<p class="cm-field-tip">正在改定此评:</p>
<textarea v-model="editDraft" class="cm-input" rows="3" :maxlength="maxLen" aria-label="编辑评论" />
<div class="cm-bar">
<span class="cm-bar-l" />
<span class="cm-bar-r">
<button type="button" class="cm-link" @click="api.cancel">取消</button>
<button type="button" class="cm-send" :disabled="!editDraft.trim() || s.submitting" @click="onSave">
{{ s.submitting ? '保存中…' : '保存' }}
</button>
</span>
</div>
</div>
<div v-else class="cm-bubble">
<span v-if="c.is_deleted" class="cm-gone">该评论已删除</span>
<CommentMarkdown v-else :html="c.body_html" />
<div v-if="!c.is_deleted" class="cm-acts">
<button v-if="canEdit" type="button" class="cm-act" aria-label="编辑" title="编辑" @click="startEdit">✎</button>
<button v-if="canDelete" type="button" class="cm-act" aria-label="删除" title="删除" @click="onRemove">✕</button>
<button v-if="reader.user" type="button" class="cm-act" aria-label="回复" title="回复" @click="startReply">↩</button>
</div>
</div>
<!-- 回复框 -->
<div v-if="replying" class="cm-field cm-field-inline">
<textarea
v-model="replyDraft"
class="cm-input"
rows="3"
:maxlength="maxLen"
:placeholder="`回复 ${name}…`"
:aria-label="`回复 ${name}`"
/>
<div class="cm-bar">
<span class="cm-bar-l">回应 <b>{{ name }}</b></span>
<span class="cm-bar-r">
<button type="button" class="cm-link" @click="api.cancel">取消</button>
<button type="button" class="cm-send" :disabled="!replyDraft.trim() || s.submitting" @click="onReply">
{{ s.submitting ? '发送中…' : '发送' }}
</button>
</span>
</div>
</div>
<!-- 回复:只有一层,depth 1 不再往下展开 -->
<ul v-if="depth === 0 && c.replies && c.replies.length" class="cm-replies">
<CommentItem
v-for="r in c.replies"
:key="r.id"
:c="r"
:api="api"
:depth="depth + 1"
:max-len="maxLen"
/>
</ul>
<button v-if="moreReplies > 0" type="button" class="cm-more-r" @click="api.loadMoreReplies(c)">
加载更多回复(剩 {{ moreReplies }} 条)
</button>
</div>
</li>
</template>
@@ -0,0 +1,21 @@
<script setup>
import { computed } from 'vue'
import { sanitizeHtml } from '../../utils'
// 评论正文的唯一 v-html 出口。
// 后端已经用 goldmark 渲染并转义过一遍(不开启 Unsafe),这里再过一次
// DOMPurify —— 和文章正文同一条规矩:任何 v-html 都要走 sanitizeHtml。
//
// 刻意不给这个组件加 <style>:它被 classic 和 vivid 两边共用,
// 而 v-html 出来的内部元素选不到作用域样式(只给子组件根元素加 data-v)。
// 正文排版由两边各自的全局规则负责(vivid: comments.css,classic: styles.css)。
const props = defineProps({
html: { type: String, default: '' }
})
const safe = computed(() => sanitizeHtml(props.html || ''))
</script>
<template>
<div class="cm-md" v-html="safe" />
</template>
@@ -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>
@@ -0,0 +1,12 @@
// 登录方式的品牌图标(24×24,单色,靠 currentColor 上色)。
// 只放了站点真正会启用的三种;换成文字首字母兜底,见 CommentSection 的 v-else。
export const BRANDS = {
github:
'M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.56 9.56 0 0 1 2.504.337c1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.02 10.02 0 0 0 22 12.017C22 6.484 17.522 2 12 2z',
google:
'M12.24 10.285V14.4h6.806c-.275 1.765-2.056 5.174-6.806 5.174-4.095 0-7.439-3.389-7.439-7.574s3.344-7.574 7.439-7.574c2.33 0 3.891.989 4.785 1.849l3.254-3.138C18.189 1.186 15.479 0 12.24 0c-6.635 0-12 5.365-12 12s5.365 12 12 12c6.926 0 11.52-4.869 11.52-11.726 0-.788-.085-1.39-.189-1.989H12.24z',
telegram:
'M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z'
}
export const PROVIDER_LABEL = { github: 'GitHub', google: 'Google', telegram: 'Telegram' }
+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
}
}
+11 -1
View File
@@ -12,11 +12,21 @@ import './ui/yohaku/chrome.css'
import './ui/yohaku/home.css'
import './ui/yohaku/article.css'
import './ui/yohaku/pages.css'
// 评论区的 vivid 差异。插在 pages 之后、mobile 之前 —— 它要盖住 styles.css 里
// 同特指度的 .cm-* 基础版,又必须让 mobile.css 保持最后。它自己的窄屏规则写在文件内。
import './ui/yohaku/comments.css'
// mobile.css 必须排最后:它修正的是上面几个文件里同特指度的基准尺寸,
// 媒体查询不加特指度,「后导入者赢」是它唯一的凭仗。
import './ui/yohaku/mobile.css'
loadSite().then(() => {
loadSite().then(async () => {
installCustomCss(router)
// 评论区的 dev mock:只有 dev 且带 ?mock 时才会被下载。
// import.meta.env.DEV 在 build 时被替换成 false,整个分支连同这个动态 chunk
// 一起被 rollup 丢掉(产物里 grep __COMMENT_MOCK__ 应为空)。
// 必须等它装完再挂载 —— 否则组件首屏那几次请求会先打到真接口上。
if (import.meta.env.DEV && new URLSearchParams(location.search).has('mock')) {
await import('./mocks/comments').then((m) => m.install())
}
createApp(App).use(router).mount('#app')
})
+228
View File
@@ -0,0 +1,228 @@
import { readerApi } from '../api'
import { site } from '../site'
import { reader } from '../reader'
import { ME, OWNER, GUEST, PROVIDERS, baseItems, longItems, mk } from './fixtures'
// ============================================================================
// 评论区的 dev mock(仅开发用)
// ----------------------------------------------------------------------------
// 由 main.ts 在 `import.meta.env.DEV && ?mock` 时动态 import。
// build 时 DEV 被替换成 false,整个分支连同这个 chunk 一起被丢掉 ——
// 产物里 grep __COMMENT_MOCK__ 必须为空,这是阶段一验证的一条硬指标。
//
// 用「覆写 readerApi 的方法」而不是另起一套假 API:组件里的代码路径在
// mock 与真实模式下逐字节相同,不会出现「mock 能跑、真接口挂」。
//
// 用法:/post/<slug>?mock → 默认一屏(含各种状态)
// /post/<slug>?mock&state=empty → 指定状态
// 右下角浮层可逐个切换(切换会 reload,保持 URL 可分享)
// ============================================================================
const SENTINEL = '__COMMENT_MOCK__'
const STATES = [
['default', '默认一屏'],
['loggedout', '未登录'],
['empty', '空'],
['loading', '加载中'],
['pending', '审核中'],
['deleted', '已删除'],
['replies', '有回复'],
['long', '长列表'],
['error', '报错']
]
const delay = (ms) => new Promise((r) => setTimeout(r, ms))
let items = []
function applyState(name) {
reader.user = null
switch (name) {
case 'loggedout':
items = baseItems()
break
case 'empty':
reader.user = ME
items = []
break
case 'loading':
reader.user = ME
items = baseItems()
break
case 'pending':
reader.user = ME
items = [mk({ author: ME, ago: 60e3, state: 'pending', html: '<p>刚发出去,还在等审核。</p>' })]
break
case 'deleted':
reader.user = ME
items = [mk({ author: GUEST, ago: 30 * 60e3, is_deleted: true, html: '' })]
break
case 'replies': {
reader.user = ME
const c = mk({ author: GUEST, ago: 3 * 3600e3, html: '<p>回复都在这一条下面。</p>' })
c.replies = [
mk({ author: ME, ago: 2 * 3600e3, parent_id: c.id, root_id: c.id, html: '<p>第一条回复。</p>' }),
mk({ author: OWNER, ago: 3600e3, parent_id: c.id, root_id: c.id, html: '<p>第二条,站长回的。</p>' })
]
c.reply_count = 6
items = [c]
break
}
case 'long':
reader.user = ME
items = longItems()
break
case 'error':
reader.user = ME
items = []
break
default:
reader.user = ME
items = baseItems()
}
}
function pageOf(opt = {}) {
const size = opt.size || 10
const page = opt.page || 1
const sorted = items.slice()
if (opt.sort === 'oldest') sorted.reverse()
return {
items: sorted.slice(0, page * size),
total: sorted.length,
page,
pages: Math.max(1, Math.ceil(sorted.length / size)),
has_more: sorted.length > page * size
}
}
export function install() {
const q = new URLSearchParams(location.search)
const state = q.get('state') || 'default'
applyState(state)
// 评审用:?ui=classic 强制走 classic 那套壳。站点平时是 vivid,classic 分支
// 平时根本渲染不出来,不加这个就没法给它截图 / 做探针。
// 改 ui_id 会触发 site.js 里的 watcher,applyTheme 由它调。
const ui = q.get('ui')
if (ui === 'classic' || ui === 'vivid') site.ui_id = ui
Object.assign(readerApi, {
me: async () => ({ user: reader.user }),
providers: async () => ({ providers: PROVIDERS }),
logout: async () => {
reader.user = null
return { ok: true }
},
comments: async (_postId, opt) => {
if (state === 'error') throw new Error('评论加载失败(mock 的错误态)')
if (state === 'loading') {
// 把骨架屏留在屏幕上足够看清
await delay(30000)
} else {
await delay(180)
}
return pageOf(opt)
},
create: async (_postId, bodyMd, parentId) => {
await delay(300)
const text = `<p>${bodyMd.replace(/[<>&]/g, '')}</p>`
const c = mk({ author: reader.user || ME, ago: 0, html: text, state: 'pending' })
if (parentId) {
c.parent_id = parentId
c.root_id = parentId
const root = items.find((x) => x.id === parentId) || items.find((x) => (x.replies || []).some((r) => r.id === parentId))
if (root) {
root.replies = (root.replies || []).concat(c)
root.reply_count = (root.reply_count || 0) + 1
}
} else {
items.unshift(c)
}
return c
},
edit: async (id, bodyMd) => {
await delay(200)
const found = findComment(id)
if (found) {
found.body_html = `<p>${bodyMd.replace(/[<>&]/g, '')}</p>`
found.edited_at = new Date().toISOString()
}
return found || {}
},
remove: async (id) => {
await delay(200)
const found = findComment(id)
if (found) {
found.is_deleted = true
found.body_html = ''
}
return { ok: true }
},
thread: async (rootId) => {
await delay(200)
const root = items.find((x) => x.id === rootId)
if (!root) return { items: [], cursor: '', reply_count: 0 }
const extra = [
mk({ author: GUEST, ago: 45 * 60e3, parent_id: rootId, root_id: rootId, html: '<p>后面补上的回复。</p>' })
]
root.replies = (root.replies || []).concat(extra)
return { items: extra, cursor: '', reply_count: root.reply_count }
}
})
function findComment(id) {
for (const c of items) {
if (c.id === id) return c
const r = (c.replies || []).find((x) => x.id === id)
if (r) return r
}
return null
}
mountSwitcher(state)
// 哨兵:验证产物里没有 mock 时 grep 这个串
console.info(`[${SENTINEL}] comments mock installed, state =`, state)
}
// 右下角状态浮层。用原生 DOM + 内联样式 —— 它永远不进产物,不值得占 CSS 文件。
function mountSwitcher(current) {
if (document.getElementById('cm-mock-switch')) return
const box = document.createElement('div')
box.id = 'cm-mock-switch'
box.setAttribute(
'style',
'position:fixed;z-index:9999;right:12px;bottom:12px;max-width:190px;padding:10px;' +
'font:12px/1.6 ui-sans-serif,system-ui;color:#111;background:rgba(255,255,255,.94);' +
'border:1px solid #ddd;border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.16)'
)
const title = document.createElement('div')
title.textContent = '评论 mock 状态'
title.setAttribute('style', 'font-weight:700;margin-bottom:6px')
box.appendChild(title)
const wrap = document.createElement('div')
wrap.setAttribute('style', 'display:flex;flex-wrap:wrap;gap:4px')
for (const [value, label] of STATES) {
const b = document.createElement('button')
b.type = 'button'
b.textContent = label
b.setAttribute(
'style',
'font:inherit;padding:3px 7px;border-radius:6px;cursor:pointer;' +
(value === current
? 'background:#111;color:#fff;border:1px solid #111'
: 'background:#fff;color:#333;border:1px solid #ddd')
)
b.onclick = () => {
const q = new URLSearchParams(location.search)
q.set('mock', '1')
q.set('state', value)
location.search = q.toString()
}
wrap.appendChild(b)
}
box.appendChild(wrap)
document.body.appendChild(box)
}
+181
View File
@@ -0,0 +1,181 @@
// 评论 mock 的假数据(仅 dev,见 mocks/comments.js)。
// 字段要和后端 model.Comment 对齐 —— 真接口落地后,这里的差别就是前端要改的地方。
// 内容按真实博客评论的口吻写的:提问 / 挑错 / 补充 / 串门,别改成「好文,学习了」那种。
// 头像:内联 SVG(离线可用),每个作者一个色相,顺便把 <img> 那条分支走通
const AV =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' fill='%23__COLOR__'/%3E%3Ccircle cx='32' cy='25' r='11' fill='%23fff' opacity='.92'/%3E%3Cpath d='M12 64c0-12 9-20 20-20s20 8 20 20z' fill='%23fff' opacity='.92'/%3E%3C/svg%3E"
const avatar = (hex) => AV.replace('__COLOR__', hex)
export const ME = {
id: 3,
name: 'hegu',
handle: 'cnhegu',
avatar_url: avatar('69a6cc'),
url: '',
provider: 'github',
role: 'reader',
is_owner: false
}
export const OWNER = {
id: 1,
name: 'ONE',
handle: 'one-blog',
avatar_url: '',
url: 'https://example.com',
provider: 'github',
role: 'admin',
is_owner: true
}
export const USERS = {
me: ME,
owner: OWNER,
lin: { id: 9, name: '林间', handle: 'linjian-dev', avatar_url: avatar('8a7f6b'), url: '', provider: 'google', role: 'reader', is_owner: false },
yuki: { id: 12, name: 'Yuki', handle: 'yuki0x0', avatar_url: avatar('6b8f9e'), url: 'https://yuki.example.dev', provider: 'github', role: 'member', is_owner: false },
awei: { id: 21, name: '阿伟', handle: '', avatar_url: '', url: '', provider: 'telegram', role: 'reader', is_owner: false },
mason: { id: 30, name: 'Mason', handle: 'masoncodes', avatar_url: avatar('7d6f9c'), url: '', provider: 'github', role: 'member', is_owner: false },
noli: { id: 44, name: 'noli', handle: '', avatar_url: '', url: '', provider: 'google', role: 'reader', is_owner: false },
tg: { id: 11, name: 'TG 用户', handle: '', avatar_url: '', url: '', provider: 'telegram', role: 'reader', is_owner: false }
}
// 旧名字留个别名,mock 主文件里还在用
export const GUEST = USERS.lin
export const TGUSER = USERS.tg
export const PROVIDERS = [
{ id: 'github', label: 'GitHub', kind: 'redirect' },
{ id: 'google', label: 'Google', kind: 'redirect' },
{ id: 'telegram', label: 'Telegram', kind: 'widget', login: 'example_bot' }
]
const MIN = 60e3
const HOUR = 60 * MIN
const DAY = 24 * HOUR
let seq = 100
export function mk({ author = AUTHORS.lin, html, ago = HOUR, state = 'published', ...rest }) {
const text = html || '<p>写点什么。</p>'
return {
id: ++seq,
parent_id: 0,
root_id: 0,
body_html: text,
body_md: text.replace(/<[^>]+>/g, ''),
state,
created_at: new Date(Date.now() - ago).toISOString(),
edited_at: '',
is_deleted: false,
reply_count: 0,
replies: [],
author,
...rest
}
}
export function reply(parent, opts = {}) {
const r = mk(opts)
r.parent_id = parent.id
r.root_id = parent.id
return r
}
// ---- 一屏的量:覆盖各种状态和排版(徽章 / 缺角气泡 / 代码 / 引用 / 列表 / 删除)----
export function baseItems() {
// 主楼:最长的讨论串(5 条回复、reply_count 同值,测「加载更多回复」的按钮态)
const c1 = mk({
author: USERS.lin,
ago: 6 * HOUR,
html:
'<p>三栏那个侧边目录会跟着滚动高亮,这个细节做得好。<code>IntersectionObserver</code> 还是算 scroll 位置?</p>' +
'<p>另外时间线里「更早 / 上一篇」这组标签,是文章自己的字段还是按位置算的?</p>'
})
c1.replies = [
reply(c1, { author: ME, ago: 5 * HOUR, html: '<p>算 scroll 位置,没用 observer —— 目录只有几条,没必要。</p>' }),
reply(c1, { author: OWNER, ago: 4 * HOUR + 20 * MIN, html: '<p>后面会换成 observer,等条目多了再说。</p>' }),
reply(c1, { author: USERS.yuki, ago: 3 * HOUR, html: '<p>标签是按位置算的:找到当前篇,前后各两篇贴上「上一篇 / 下一篇 / 更早 / 更新」。</p>' }),
reply(c1, { author: USERS.mason, ago: 2 * HOUR, html: '<p>学到了。</p>' }),
reply(c1, { author: USERS.noli, ago: 80 * MIN, html: '<p>同问 observer,楼上已经答了,收了。</p>' })
]
c1.reply_count = 8 // 只展开 5 条,剩 3 条走「加载更多回复」(thread 接口)
const c2 = mk({
author: USERS.yuki,
ago: 5 * HOUR,
html:
'<p>挑个刺:<code>.proj-grid</code> 的轨道下限要是裸 <code>300px</code>,320px 的屏会溢出吧?</p>' +
'<pre><code>grid-template-columns: repeat(auto-fill, minmax(min(300px,100%), 1fr));</code></pre>' +
'<p>这样写就稳了。</p>'
})
const c3 = mk({
author: OWNER,
ago: 2 * DAY,
html:
'<p>补一句:留白的尺度是 <strong>28 / 48 / 60</strong>,不要随手改成整十数,视觉节奏会散。</p>' +
'<blockquote><p>间距是设计的一部分,不是留白的一部分。</p></blockquote>'
})
const c4 = mk({
author: USERS.awei,
ago: 40 * HOUR,
html:
'<p>手机上那个 ☰ 菜单挺好用的。要是能把 <strong>主题切换</strong> 也放进去就完美了 —— 现在得退回顶栏才切得动。</p>' +
'<ul><li>时间线 / 归档 / 标签 / 作品 / 关于</li><li>底部:主题</li></ul>'
})
const c5 = mk({ author: USERS.noli, ago: 26 * HOUR, is_deleted: true, html: '' })
const c6 = mk({
author: ME,
ago: 20 * HOUR,
state: 'pending',
html: '<p>这条在等审核(<code>comment_review=1</code> 时会走这个状态),只有我自己看得见。</p>'
})
const c7 = mk({
author: USERS.tg,
ago: 14 * HOUR,
html: '<p>手机上那个 ☰ 菜单挺好用的。Telegram 登录什么时候开?</p>'
})
const c8 = mk({
author: USERS.mason,
ago: 8 * HOUR,
edited_at: new Date(Date.now() - 6 * HOUR).toISOString(),
html:
'<p>已编辑过的评论会带一个小标记。顺手贴个我抄走的 <code>color-mix</code> 用法:</p>' +
'<blockquote><p>color-mix(in srgb, var(--ink) 6%, transparent)</p></blockquote>'
})
return [c8, c7, c6, c2, c4, c5, c3, c1]
}
// ---- 长列表:把 baseItems 铺开再加一批,专门测「显示更多」分页 ----
export function longItems() {
const out = baseItems()
const lines = [
['收藏了,回头照着做一遍。', USERS.lin],
['暗色下那套 <code>--v-*</code> 是怎么对齐的?手调的还是工具生成的?', USERS.mason],
['正文行宽 728 是算出来的还是试出来的?', USERS.awei],
['首页那个「做过的东西」排版舒服。', USERS.yuki],
['<p>提个需求:评论能不能支持 <em>表情回应</em>?不用点赞那么复杂,几个 emoji 就够。</p>', USERS.awei],
['RSS 里能带上评论数吗?', USERS.noli],
['<p>照着把 <code>mobile.css</code> 的思路搬回我们项目了,一处收口确实比到处写媒体查询干净。</p>', USERS.mason],
['字体是思源宋体?正文里中英混排的字距很舒服。', USERS.lin],
['试了下 320px 宽,没横向滚动,赞。', USERS.yuki],
['<p>后台那个命令面板(<code>⌘K</code>)是现成的还是自己实现的?</p>', USERS.awei],
['归档页按年份分组比无限流舒服。', USERS.noli],
['希望以后能匿名评论 —— 不想为了一条评论去连 GitHub。', USERS.tg],
['<p>时间线那条渐变竖线:</p><pre><code>background: linear-gradient(180deg, var(--accent), transparent);</code></pre><p>stolen, thanks.</p>', USERS.yuki],
['文章里的首字下沉在暗色下会不会太跳?', USERS.mason],
['关注了。', USERS.lin],
['<p>标签页那组 <strong>写 / 设 / 篇 / 辑</strong> 的措辞是故意的吧,挺妙。</p>', USERS.awei],
['页脚那句引言换过几次了?', USERS.tg],
['<p>RSS 阅读器里看到的排版比网页还干净,罕见。</p>', USERS.yuki]
]
lines.forEach(([html, author], i) => {
out.push(mk({ author, ago: 2 * DAY + (i + 1) * 7 * HOUR, html }))
})
return out
}
+53
View File
@@ -0,0 +1,53 @@
import { reactive } from 'vue'
import { readerApi } from './api'
// 前台的读者身份(评论区用)。后台那套是另一个会话(cookie one_session),
// 两边服务端不通用 —— 见 backend/internal/session 的 audience。
//
// 和 site.js 一样做成 reactive 单例:登录态是全局的,不该每个组件各拉一次。
export const reader = reactive({
user: null, // { id, name, handle, avatar_url, url, provider, role, is_owner }
providers: [], // [{ id, label, kind, login }]
loaded: false,
loading: false
})
export async function loadReader() {
if (reader.loading) return
reader.loading = true
try {
// 两个都匿名可读;失败也不该打断页面,静默退化成「未登录」
const [me, prov] = await Promise.all([
readerApi.me().catch(() => null),
readerApi.providers().catch(() => null)
])
reader.user = (me && me.user) || null
reader.providers = (prov && prov.providers) || []
} finally {
reader.loaded = true
reader.loading = false
}
}
// 登录一律走整页跳转:比弹窗简单,手机上也不会被拦。
// widget 类型(Telegram Login Widget)没有跳转,由组件内联渲染官方按钮。
export function signIn(provider) {
if (!provider || provider.kind === 'widget') return
location.href = `/api/auth/${encodeURIComponent(provider.id)}/login`
}
export async function signOut() {
try {
await readerApi.logout()
} catch (_) {
/* 服务端已失效也无所谓,本地清掉即可 */
}
reader.user = null
}
// 会话过期:任何 401 都当作「当前用户没了」
if (typeof window !== 'undefined') {
window.addEventListener('one:unauthorized', () => {
reader.user = null
})
}
+151
View File
@@ -1092,3 +1092,154 @@ a,
font-size: 16px;
}
}
/* ==========================================================================
评论区
--------------------------------------------------------------------------
classic 和 vivid 共用同一份 DOM(components/comments/)。
这里只写「两边都要」的基础,用经典那套 token —— vivid 把那批名字
(--paper/--card/--ink/--accent/--line/--paper-sunken…)都别名到了 --v-*,
所以同一份规则在两套 UI 下都成立。
vivid 的差异(缺角气泡、渐变底、头像 ring、触屏尺寸)在
ui/yohaku/comments.css,那边特指度更高,会盖住这里。
========================================================================== */
/* 头部只放计数 + 排序。刻意不写「评论」二字标题:参考站也是这样,
靠计数和排序控件就能认出这是评论区,多一行标题只是噪音。 */
.cm-sec {margin-top: 28px;padding: 22px 26px 0;border-top: 1px solid var(--line);}
.cm-head {display: flex;align-items: center;justify-content: space-between;
gap: 12px;flex-wrap: wrap;margin-bottom: 20px;}
.cm-count {font-size: 13px;font-weight: 600;color: var(--muted);
font-variant-numeric: tabular-nums;}
/* classic 下是一排文字钮;vivid 里这个元素还带 .seg,会被 pill 化 */
.cm-sort {display: inline-flex;align-items: center;gap: 2px;}
.cm-sort button {font-size: 13px;font-weight: 600;color: var(--muted);
padding: 5px 10px;border: 0;border-radius: 999px;background: none;cursor: pointer;}
.cm-sort button.on {color: var(--accent);background: var(--accent-soft);}
/* 未登录:登录引导卡 */
.cm-gate {padding: 22px;border: 1px solid var(--line);border-radius: 12px;
background: var(--card);text-align: center;}
.cm-gate-skel {height: 104px;background: var(--paper-sunken);border-style: dashed;}
.cm-gate-t {margin: 0 0 14px;font-size: 13px;color: var(--muted);}
.cm-gate-hint {margin: 0;font-size: 13px;color: var(--muted);}
.cm-providers {display: flex;justify-content: center;gap: 12px;
margin: 0;padding: 0;list-style: none;}
.cm-provider {display: grid;place-items: center;width: 40px;height: 40px;
border: 1px solid var(--line);border-radius: 50%;background: var(--card);color: var(--ink);
cursor: pointer;transition: border-color .2s,color .2s;}
.cm-provider:hover {border-color: var(--accent);color: var(--accent);}
.cm-brand {width: 18px;height: 18px;fill: currentColor;}
.cm-brand-t {font-size: 15px;font-weight: 700;}
.cm-tg {margin-top: 12px;}
/* 输入区 */
.cm-compose {display: flex;gap: 12px;align-items: flex-start;}
.cm-av-me {width: 36px;height: 36px;}
.cm-field {flex: 1;min-width: 0;overflow: hidden;
border: 1px solid var(--line);border-radius: 12px;background: var(--card);}
.cm-field:focus-within {border-color: var(--accent);}
.cm-field-inline {margin-top: 10px;}
.cm-field-tip {margin: 0;padding: 10px 12px 0;font-size: 13px;color: var(--muted);}
.cm-input {display: block;width: 100%;min-height: 72px;padding: 10px 12px;
border: 0;background: none;resize: vertical;
font: inherit;font-size: 14px;line-height: 1.7;color: var(--ink);}
.cm-input:focus {outline: none;}
.cm-bar {display: flex;align-items: center;justify-content: space-between;gap: 10px;
padding: 6px 10px;border-top: 1px solid var(--line-soft);
font-size: 13px;color: var(--muted);}
.cm-bar-l {min-width: 0;overflow: hidden;text-overflow: ellipsis;white-space: nowrap;}
.cm-bar-r {display: flex;flex: none;align-items: center;gap: 10px;}
.cm-len {font-variant-numeric: tabular-nums;}
.cm-len.over {color: #a64953;}
.cm-link {padding: 0;border: 0;background: none;color: var(--muted);
font-size: 13px;cursor: pointer;}
.cm-link:hover {color: var(--accent);}
.cm-send {padding: 5px 14px;border: 0;border-radius: 999px;cursor: pointer;
background: var(--accent);color: #fff;font-size: 13px;font-weight: 600;}
.cm-send:disabled {opacity: .45;cursor: default;}
.cm-err {margin: 14px 0 0;font-size: 13px;color: #a64953;}
/* 列表 */
.cm-list {display: flex;flex-direction: column;gap: 18px;
margin: 22px 0 0;padding: 0;list-style: none;}
.cm-item {display: flex;gap: 12px;align-items: flex-start;}
.cm-av {position: relative;display: grid;place-items: center;flex: none;
width: 36px;height: 36px;border-radius: 50%;
background: var(--paper-sunken);color: var(--muted);
font-size: 14px;font-weight: 700;
box-shadow: 0 0 0 2px var(--paper);}
.cm-av img {display: block;width: 100%;height: 100%;border-radius: 50%;object-fit: cover;}
.cm-av.is-owner {box-shadow: 0 0 0 2px var(--accent-soft);}
/* 头像角上的登录方式小标 */
.cm-pv {position: absolute;right: -2px;bottom: -2px;
display: grid;place-items: center;width: 14px;height: 14px;border-radius: 50%;
background: var(--card);color: var(--muted);font-style: normal;
box-shadow: 0 0 0 1.5px var(--line);}
.cm-pv svg {width: 8px;height: 8px;fill: currentColor;}
.cm-main {flex: 1;min-width: 0;}
.cm-name {display: flex;align-items: center;gap: 8px;flex-wrap: wrap;margin-bottom: 6px;}
.cm-name-a {font-size: 13.5px;font-weight: 600;color: var(--ink);}
a.cm-name-a:hover {color: var(--accent);}
.cm-owner {padding: 1px 6px;border-radius: 999px;background: var(--accent-soft);
color: var(--accent);font-size: 11px;font-weight: 600;}
.cm-time {font-size: 11.5px;color: var(--muted);font-variant-numeric: tabular-nums;}
.cm-pending {padding: 1px 6px;border-radius: 999px;background: var(--paper-sunken);
color: var(--muted);font-size: 11px;font-weight: 600;}
.cm-edited-tip {font-size: 11px;color: var(--muted);opacity: .75;}
/* 气泡:左上角不收圆(缺口朝头像),是这套评论的形状特征 */
.cm-bubble {position: relative;display: inline-block;
max-width: calc(100% - 3rem);padding: 8px 12px;
border: 1px solid var(--line);border-radius: 12px;border-top-left-radius: 3px;
background: var(--card);color: var(--ink);}
.cm-item.is-reply .cm-bubble {border-top-left-radius: 12px;}
.cm-item.is-deleted .cm-bubble {background: none;border-style: dashed;}
.cm-gone {font-size: 13px;color: var(--muted);font-style: italic;}
/* 正文(走 CommentMarkdown,后端已渲染 + 前端 DOMPurify 再洗一遍) */
.cm-md {font-size: 14px;line-height: 1.65;}
.cm-md > :first-child {margin-top: 0;}
.cm-md > :last-child {margin-bottom: 0;}
.cm-md p {margin: 0 0 .6em;}
.cm-md a {color: var(--accent);text-underline-offset: 2px;}
.cm-md code {padding: 1px 5px;border-radius: 5px;font-family: var(--mono);font-size: .92em;
background: color-mix(in srgb, var(--ink) 6%, transparent);}
.cm-md pre {padding: 10px 12px;border-radius: 8px;overflow-x: auto;font-size: 12.5px;
background: var(--paper-sunken);}
.cm-md pre code {padding: 0;background: none;}
.cm-md ul, .cm-md ol {margin: .5em 0;padding-left: 1.4em;}
.cm-md blockquote {margin: .6em 0;padding-left: 10px;border-left: 2px solid var(--line);
color: var(--muted);}
.cm-md img {max-width: 100%;border-radius: 6px;}
/* 编辑 / 删除 / 回复:hover 或键盘聚焦才出现,触屏直接常显 */
.cm-acts {position: absolute;right: 0;bottom: -10px;display: flex;gap: 4px;
opacity: 0;transition: opacity .2s;}
.cm-item:hover .cm-acts, .cm-item:focus-within .cm-acts {opacity: 1;}
@media (hover: none) {
.cm-acts {opacity: 1;}
}
.cm-act {display: grid;place-items: center;width: 24px;height: 24px;
border: 1px solid var(--line);border-radius: 50%;background: var(--card);
color: var(--muted);font-size: 12px;cursor: pointer;}
.cm-act:hover {border-color: var(--accent);color: var(--accent);}
/* 回复只有一层,缩进 36px */
.cm-replies {display: flex;flex-direction: column;gap: 14px;
margin: 14px 0 0;padding: 0 0 0 36px;list-style: none;}
.cm-replies .cm-av {width: 28px;height: 28px;font-size: 12px;}
.cm-more-r {margin: 10px 0 0 36px;padding: 4px 12px;border: 1px solid var(--line);
border-radius: 999px;background: none;color: var(--muted);font-size: 12px;cursor: pointer;}
.cm-more-r:hover {border-color: var(--accent);color: var(--accent);}
/* 分页 / 空态 */
.cm-more {display: flex;justify-content: center;margin-top: 22px;}
.cm-empty {margin-top: 22px;}
/* 骨架 */
.cm-skel-row {display: flex;gap: 12px;align-items: flex-start;}
.cm-skel-av {flex: none;width: 36px;height: 36px;border-radius: 50%;background: var(--paper-sunken);}
.cm-skel-lines {display: flex;flex: 1;flex-direction: column;gap: 8px;}
.cm-skel-lines i {display: block;height: 12px;border-radius: 6px;background: var(--paper-sunken);}
.cm-skel-lines i:last-child {width: 55%;}
+4
View File
@@ -1,6 +1,7 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { sanitizeHtml } from '../../utils'
import CommentSection from '../../components/comments/CommentSection.vue'
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
// 左 side-l : 时间线(当前篇前后各两篇,标签来自位置)
@@ -171,6 +172,9 @@ function onTocClick(e) {
<RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink>
</div>
</article>
<!-- 评论在 .paper 外面:评论接口挂了也不该把文章一起带下去 -->
<CommentSection :post-id="props.post.id" />
</main>
<aside class="side-r">
+118
View File
@@ -0,0 +1,118 @@
/* ==========================================================================
评论区:vivid(余白)的差异部分
--------------------------------------------------------------------------
基础样式在 styles.css 的 .cm-*(classic 和 vivid 共用同一份 DOM)。
这里只写 vivid 和 classic 长得不一样的地方 —— 全部带 html[data-ui='vivid']
前缀,特指度比基础版高,天然盖住它。
自己的移动端覆盖也写在下面(不要挪进 mobile.css:那是「靠最后导入赢」,
语义上不该承担某个具体组件的窄屏规则)。
========================================================================== */
/* 和 .paper 的正文列左缘对齐(.paper 的横向内边距是 24px)。
桌面不给上分隔线:文章末尾已经有一条 .end 的虚线,再画一条就重了。 */
html[data-ui='vivid'] .cm-sec {margin-top: 8px;padding: 24px 24px 0;border-top: 0;}
html[data-ui='vivid'] .cm-count {font-size: 13px;color: var(--muted);letter-spacing: .01em;}
/* .cm-sort 在 vivid 里同时带 .seg 类,pill 外观由 home.css 的 .seg 规则给 */
/* 未登录:一张安静的纸片,用和输入框同一套「场地」观感 */
html[data-ui='vivid'] .cm-gate {padding: 26px 22px;border-radius: var(--radius-lg);
border-color: var(--line);background: var(--card);box-shadow: var(--shadow);}
html[data-ui='vivid'] .cm-gate-skel {box-shadow: none;border-radius: var(--radius);}
html[data-ui='vivid'] .cm-provider {width: 40px;height: 40px;border-color: var(--line);
background: var(--card);color: var(--soft);
transition: border-color .3s var(--spring),color .3s var(--spring),transform .5s var(--spring);}
@media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .cm-provider:hover {border-color: var(--accent);color: var(--accent);
transform: translateY(-2px);}
}
/* 输入框:vivid 的 .search / .seg 是同一套「无边框 + 聚焦才亮」的思路 */
html[data-ui='vivid'] .cm-field {border-radius: var(--radius);background: var(--card);
box-shadow: var(--shadow);
transition: border-color .3s var(--ease),box-shadow .3s var(--ease);}
html[data-ui='vivid'] .cm-field:focus-within {border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);}
html[data-ui='vivid'] .cm-input {font-size: 14.5px;line-height: 1.8;}
html[data-ui='vivid'] .cm-bar {border-top-color: var(--line);}
html[data-ui='vivid'] .cm-send {background: var(--accent);color: #fff;
transition: transform .5s var(--spring),box-shadow .3s;}
@media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .cm-send:not(:disabled):hover {transform: scale(1.04);
box-shadow: 0 4px 12px var(--accent-glow);}
}
html[data-ui='vivid'] .cm-send:active {transform: scale(.97);}
/* 列表 */
html[data-ui='vivid'] .cm-list {gap: 20px;}
html[data-ui='vivid'] .cm-av {width: 36px;height: 36px;background: var(--accent-soft);color: var(--accent);
box-shadow: 0 0 0 2px var(--paper);}
/* 站长的头像 ring 用强调色,一眼能认出来 */
html[data-ui='vivid'] .cm-av.is-owner {box-shadow: 0 0 0 2px var(--paper),0 0 0 3.5px var(--accent-soft);}
html[data-ui='vivid'] .cm-pv {box-shadow: 0 0 0 1.5px var(--paper);color: var(--muted);}
html[data-ui='vivid'] .cm-name-a {font-size: 13.5px;color: var(--text);letter-spacing: .01em;}
/* tokens.css 的 a 重置(html[data-ui=vivid] a,特指度 0,1,2)会把链接名拉回
inherit —— 继承到的是 body 的颜色,暗色下正好是深底深字。得在这里压回去。 */
html[data-ui='vivid'] a.cm-name-a {color: var(--text);}
@media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] a.cm-name-a:hover {color: var(--accent);}
}
html[data-ui='vivid'] .cm-owner {background: var(--accent-soft);color: var(--accent);}
html[data-ui='vivid'] .cm-time {letter-spacing: .02em;}
html[data-ui='vivid'] .cm-pending {background: color-mix(in srgb,var(--ink) 6%,transparent);}
/* 气泡:vivid 里用「场地」那套(细边 + 顶上一点渐变),缺角保留 */
html[data-ui='vivid'] .cm-bubble {padding: 9px 14px;border-radius: var(--radius-lg);
border-top-left-radius: 3px;border-color: var(--line);background: var(--card);
color: var(--text);
background-image: linear-gradient(180deg,var(--accent-soft) 0,transparent 26%);}
html[data-ui='vivid'] .cm-item.is-reply .cm-bubble {border-top-left-radius: var(--radius-lg);}
html[data-ui='vivid'] .cm-md {font-size: 14.5px;line-height: 1.75;}
html[data-ui='vivid'] .cm-md a {text-decoration: underline;
text-decoration-color: color-mix(in srgb,var(--accent) 40%,transparent);
text-underline-offset: 2px;}
@media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .cm-md a:hover {text-decoration-color: var(--accent);}
}
html[data-ui='vivid'] .cm-md code {background: var(--accent-soft);color: var(--accent);}
html[data-ui='vivid'] .cm-md pre {background: color-mix(in srgb,var(--ink) 92%,var(--accent));
color: #e8e8f0;box-shadow: var(--shadow-lg);}
html[data-ui='vivid'] .cm-md pre code {background: none;color: inherit;}
html[data-ui='vivid'] .cm-md blockquote {border-left-color: var(--accent);
font-family: var(--serif);font-style: italic;}
/* 操作钮 */
html[data-ui='vivid'] .cm-act {border-color: var(--line);background: var(--card);color: var(--muted);
transition: border-color .3s var(--spring),color .3s var(--spring),transform .5s var(--spring);}
@media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .cm-act:hover {border-color: var(--accent);color: var(--accent);
transform: translateY(-1px);}
}
/* 回复 */
html[data-ui='vivid'] .cm-more-r {border-color: var(--line);color: var(--muted);}
html[data-ui='vivid'] .cm-empty .chip {font-size: 13px;padding: 9px 22px;}
/* --------------------------------------------------------------------------
手机:头像 24px(桌面 36px),触屏的可点目标 44px、输入框 16px(防聚焦缩放)
-------------------------------------------------------------------------- */
@media (max-width:640px) {
html[data-ui='vivid'] .cm-sec {margin-top: 4px;padding: 20px 20px 0;}
html[data-ui='vivid'] .cm-av,
html[data-ui='vivid'] .cm-av-me {width: 24px;height: 24px;font-size: 11px;}
html[data-ui='vivid'] .cm-replies {padding-left: 20px;}
html[data-ui='vivid'] .cm-more-r {margin-left: 20px;}
html[data-ui='vivid'] .cm-bubble {max-width: calc(100% - 1rem);}
html[data-ui='vivid'] .cm-provider {width: 44px;height: 44px;}
html[data-ui='vivid'] .cm-act {width: 32px;height: 32px;font-size: 13px;}
html[data-ui='vivid'] .cm-sort button {min-height: 44px;}
html[data-ui='vivid'] .cm-input {font-size: 16px;}
}
/* 触屏(可能是平板/桌面触屏,不限于窄屏):可点目标给足 */
@media (pointer: coarse) {
html[data-ui='vivid'] .cm-provider {width: 44px;height: 44px;}
html[data-ui='vivid'] .cm-act {width: 32px;height: 32px;}
html[data-ui='vivid'] .cm-more-r {padding: 10px 16px;}
html[data-ui='vivid'] .cm-link {padding: 8px 2px;}
html[data-ui='vivid'] .cm-input {font-size: 16px;}
}
+8 -1
View File
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
import LeftNav from '../components/LeftNav.vue'
import RightRail from '../components/RightRail.vue'
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
import CommentSection from '../components/comments/CommentSection.vue'
import { publicApi } from '../api'
import { site, applyDocTitle } from '../site'
import { formatDate, minutesLabel, sanitizeHtml } from '../utils'
@@ -69,6 +70,9 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<div v-else class="shell">
<div class="layout">
<!-- LeftNav / RightRail 之前 import 了却没渲染,导致整页掉进 236px 的左列。
补上,和 HomeView 的三栏一致。 -->
<LeftNav />
<main class="main">
<div v-if="loading" class="loading">载入中…</div>
@@ -116,8 +120,11 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<RouterLink to="/" class="back">← 回到时间线</RouterLink>
</footer>
</article>
</main>
<!-- 评论区(和 vivid 共用同一个组件,见 components/comments/) -->
<CommentSection v-if="post" :post-id="post.id" />
</main>
<RightRail />
</div>
</div>
</template>