评论阶段一(前端):评论区 + 登录引导卡 + 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:
@@ -85,6 +85,31 @@ export const adminApi = {
|
|||||||
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body })
|
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),这里只存一份展示用的用户名
|
// 后台登录态:cookie 由服务端下发(httpOnly),这里只存一份展示用的用户名
|
||||||
const USER_KEY = 'one.admin.user'
|
const USER_KEY = 'one.admin.user'
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,9 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 40px minmax(0, 1fr);
|
grid-template-columns: 40px minmax(0, 1fr);
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 14px 16px;
|
/* 左右 30 = 16(出血到列边)+ 14(内容离边框的呼吸位)。
|
||||||
|
之前是 16/16,负 margin 正好把内边距抵光,头像贴死在边框上。 */
|
||||||
|
padding: 14px 30px;
|
||||||
margin: 0 -16px;
|
margin: 0 -16px;
|
||||||
border-bottom: 1px solid var(--line-soft);
|
border-bottom: 1px solid var(--line-soft);
|
||||||
transition: background 0.15s ease;
|
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' }
|
||||||
@@ -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
@@ -12,11 +12,21 @@ import './ui/yohaku/chrome.css'
|
|||||||
import './ui/yohaku/home.css'
|
import './ui/yohaku/home.css'
|
||||||
import './ui/yohaku/article.css'
|
import './ui/yohaku/article.css'
|
||||||
import './ui/yohaku/pages.css'
|
import './ui/yohaku/pages.css'
|
||||||
|
// 评论区的 vivid 差异。插在 pages 之后、mobile 之前 —— 它要盖住 styles.css 里
|
||||||
|
// 同特指度的 .cm-* 基础版,又必须让 mobile.css 保持最后。它自己的窄屏规则写在文件内。
|
||||||
|
import './ui/yohaku/comments.css'
|
||||||
// mobile.css 必须排最后:它修正的是上面几个文件里同特指度的基准尺寸,
|
// mobile.css 必须排最后:它修正的是上面几个文件里同特指度的基准尺寸,
|
||||||
// 媒体查询不加特指度,「后导入者赢」是它唯一的凭仗。
|
// 媒体查询不加特指度,「后导入者赢」是它唯一的凭仗。
|
||||||
import './ui/yohaku/mobile.css'
|
import './ui/yohaku/mobile.css'
|
||||||
|
|
||||||
loadSite().then(() => {
|
loadSite().then(async () => {
|
||||||
installCustomCss(router)
|
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')
|
createApp(App).use(router).mount('#app')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1092,3 +1092,154 @@ a,
|
|||||||
font-size: 16px;
|
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%;}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { sanitizeHtml } from '../../utils'
|
import { sanitizeHtml } from '../../utils'
|
||||||
|
import CommentSection from '../../components/comments/CommentSection.vue'
|
||||||
|
|
||||||
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
|
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
|
||||||
// 左 side-l : 时间线(当前篇前后各两篇,标签来自位置)
|
// 左 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>
|
<RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<!-- 评论在 .paper 外面:评论接口挂了也不该把文章一起带下去 -->
|
||||||
|
<CommentSection :post-id="props.post.id" />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<aside class="side-r">
|
<aside class="side-r">
|
||||||
|
|||||||
@@ -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;}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'
|
|||||||
import LeftNav from '../components/LeftNav.vue'
|
import LeftNav from '../components/LeftNav.vue'
|
||||||
import RightRail from '../components/RightRail.vue'
|
import RightRail from '../components/RightRail.vue'
|
||||||
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
|
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
|
||||||
|
import CommentSection from '../components/comments/CommentSection.vue'
|
||||||
import { publicApi } from '../api'
|
import { publicApi } from '../api'
|
||||||
import { site, applyDocTitle } from '../site'
|
import { site, applyDocTitle } from '../site'
|
||||||
import { formatDate, minutesLabel, sanitizeHtml } from '../utils'
|
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 v-else class="shell">
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
|
<!-- LeftNav / RightRail 之前 import 了却没渲染,导致整页掉进 236px 的左列。
|
||||||
|
补上,和 HomeView 的三栏一致。 -->
|
||||||
|
<LeftNav />
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<div v-if="loading" class="loading">载入中…</div>
|
<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>
|
<RouterLink to="/" class="back">← 回到时间线</RouterLink>
|
||||||
</footer>
|
</footer>
|
||||||
</article>
|
</article>
|
||||||
</main>
|
|
||||||
|
|
||||||
|
<!-- 评论区(和 vivid 共用同一个组件,见 components/comments/) -->
|
||||||
|
<CommentSection v-if="post" :post-id="post.id" />
|
||||||
|
</main>
|
||||||
|
<RightRail />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user