评论:去气泡改纸面排版 + 回复 @提及 + 双主题手机 12px 贴边

视觉:评论区从「带边框的聊天气泡盒」改成与去卡片后文章页一致的纸面排版
- .cm-bubble 类名保留(DOM 不动)盒子拆掉:正文直接落在纸面上,层级靠
  头像 / 名字行 / 留白撑起,条与条之间不画线不画盒;删除态靠变灰变斜认
- 输入区去卡片盒,只留 textarea 底下一条「书写线」,聚焦变强调色
  (vivid 再加同色光晕,与 .search 聚焦同一语言);名字 / 计数 / 发送
  排在线下方,像在纸上落款
- 操作钮 ✎ ✕ ↩ 圆图标 → 一行文字钮(回复/编辑/删除):桌面 hover 浮现、
  位置预留不跳版;触屏常显并加大可点面积;删除悬停给红;补 focus-visible 环
  (文字钮无边框,键盘可达性自己画)
- 回复串:空白缩进改左侧发丝线(与左栏时间线同一语言);26px 小头像不再
  戴登录方式小标(戴不下);登录引导卡去框,一行引导 + 一排登录圆钮
- 暗色代码块修复(.cm-md pre 与 .prose pre 同一 bug):背景
  color-mix(92% ink, accent) 在暗色下混出浅底浅字,暗端覆盖为
  color-mix(86% paper, accent) + 细边框

@提及
- 回复另一条回复时正文前带强调色 @对方名字,扁平列表里才看得出在跟谁
  说话;回主楼不标。名字由 parent_id 在已加载回复列表里解析,父回复
  未加载就不标 —— 后端阶段二契约不变,纯前端即可工作
- 浮动贴正文首行行首(与首字下沉同一技巧),字号行高与 .cm-md 成对同步
  (基础 / 手机断点 / vivid 三处,改字号要一起改);正文以代码块、列表
  开头时退成独立一行,避免被块级背景盖住
- 编辑态去掉「正在改定此评:」,交给 placeholder;mock 加 reply-to-reply
  用例(Mason 回 ONE)

手机 12px 贴边(两套主题正文与评论共用同一条左缘)
- vivid:.article-layout 手机断点 1fr → minmax(0,1fr)。裸 1fr 的自动最小
  尺寸是 min-content,正文里不可断行的长 token 把整列顶到 592px、整页
  横向溢出(实测 375 视口 bodyScrollWidth 612);页边距 20 → 12;
  .prose 加 overflow-wrap:break-word
- classic:.shell 手机 20 → 12(首页信息流同步收边),PostView .wrap
  ≤640 侧边距归零 —— 那 4px 正是正文比评论多出的一截
- 评论手机断点(styles.css 基础段,两套 UI 共用):头像 36→28、回复
  缩进收紧、排序 / 登录 / 操作钮 44px 触控目标、输入 16px 防 iOS
  聚焦缩放;.cm-md 加 overflow-wrap:break-word(用户内容长链接必须断行)

杂项
- vite dev 加 --host:手机连局域网可直接评审
- 实测(浏览器逐状态截图过目):vivid/classic × 亮/暗 × 桌面/390,
  默认 / 未登录 / 空 / 审核中 / 已删除 / 多级回复 / 回复框 / 编辑框
  共 13 张;375 视口 bodyScrollWidth = 375 无溢出;pnpm build 通过
This commit is contained in:
Sakurasan
2026-09-26 22:34:36 +08:00
parent f29d8e053a
commit 75737034d9
8 changed files with 238 additions and 141 deletions
@@ -8,11 +8,16 @@ import { relativeDate } from '../../utils'
// 单条评论。回复在同一个组件里自引用渲染(depth 0 → 1),
// 这样「回复框 / 编辑框」的模板只写一遍,两层不会各写一份再漂移。
// 只渲染一层:depth >= 1 时不再显示它自己的 replies 与「加载更多」。
//
// 回复之间的相互回复在扁平列表里靠 @提及 认亲:depth 0 渲染每条回复时
// 解析出它回的是谁(parent_id 指向主楼 → 不标;指向同串里另一条回复 →
// 把那条的作者名传下去)。父回复没被加载进来时解析不到,就不标。
const props = defineProps({
c: { type: Object, required: true },
api: { type: Object, required: true },
depth: { type: Number, default: 0 },
maxLen: { type: Number, default: 500 }
maxLen: { type: Number, default: 500 },
replyTo: { type: String, default: '' }
})
const s = props.api.s
@@ -77,6 +82,19 @@ async function onRemove() {
const moreReplies = computed(() =>
props.depth === 0 ? Math.max(0, (props.c.reply_count || 0) - ((props.c.replies || []).length)) : 0
)
// 这条回复要 @ 谁:parent 指向主楼(props.c)不用标;指向串里另一条
// 已加载的回复才标。正文以块级元素开头(代码块/列表)时 @ 没法浮在
// 首行行首,模板里会退成独立一行。
const atInline = computed(
() => !!props.replyTo && /^<p[\s>]/i.test((props.c.body_html || '').trim())
)
function replyNameOf(r) {
if (!r.parent_id || r.parent_id === props.c.id) return ''
const t = (props.c.replies || []).find((x) => x.id === r.parent_id)
return t ? (t.author && t.author.name) || '' : ''
}
</script>
<template>
@@ -112,10 +130,10 @@ const moreReplies = computed(() =>
<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="编辑评论" />
<textarea v-model="editDraft" class="cm-input" rows="3" :maxlength="maxLen"
placeholder="编辑这条评论…" aria-label="编辑评论" />
<div class="cm-bar">
<span class="cm-bar-l" />
<span class="cm-bar-r">
@@ -129,12 +147,17 @@ const moreReplies = computed(() =>
<div v-else class="cm-bubble">
<span v-if="c.is_deleted" class="cm-gone">该评论已删除</span>
<CommentMarkdown v-else :html="c.body_html" />
<template v-else>
<!-- 回复回复时带上 @对方,扁平列表里才看得出在跟谁说话 -->
<span v-if="replyTo && atInline" class="cm-at">@{{ replyTo }}</span>
<p v-else-if="replyTo" class="cm-at cm-at-block">@{{ replyTo }}</p>
<CommentMarkdown :html="c.body_html" />
</template>
<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>
<button v-if="reader.user" type="button" class="cm-act" @click="startReply">回复</button>
<button v-if="canEdit" type="button" class="cm-act" @click="startEdit">编辑</button>
<button v-if="canDelete" type="button" class="cm-act is-danger" @click="onRemove">删除</button>
</div>
</div>
@@ -168,6 +191,7 @@ const moreReplies = computed(() =>
:api="api"
:depth="depth + 1"
:max-len="maxLen"
:reply-to="replyNameOf(r)"
/>
</ul>
<button v-if="moreReplies > 0" type="button" class="cm-more-r" @click="api.loadMoreReplies(c)">