正在改定此评:
+ + +刚发出去,还在等审核。
' })] + 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: '回复都在这一条下面。
' }) + c.replies = [ + mk({ author: ME, ago: 2 * 3600e3, parent_id: c.id, root_id: c.id, html: '第一条回复。
' }), + mk({ author: OWNER, ago: 3600e3, parent_id: c.id, root_id: c.id, html: '第二条,站长回的。
' }) + ] + 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 = `${bodyMd.replace(/[<>&]/g, '')}
` + 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 = `${bodyMd.replace(/[<>&]/g, '')}
` + 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: '后面补上的回复。
' }) + ] + 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) +} diff --git a/frontend/src/mocks/fixtures.js b/frontend/src/mocks/fixtures.js new file mode 100644 index 0000000..300910f --- /dev/null +++ b/frontend/src/mocks/fixtures.js @@ -0,0 +1,181 @@ +// 评论 mock 的假数据(仅 dev,见 mocks/comments.js)。 +// 字段要和后端 model.Comment 对齐 —— 真接口落地后,这里的差别就是前端要改的地方。 +// 内容按真实博客评论的口吻写的:提问 / 挑错 / 补充 / 串门,别改成「好文,学习了」那种。 + +// 头像:内联 SVG(离线可用),每个作者一个色相,顺便把写点什么。
' + 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: + '三栏那个侧边目录会跟着滚动高亮,这个细节做得好。IntersectionObserver 还是算 scroll 位置?
另外时间线里「更早 / 上一篇」这组标签,是文章自己的字段还是按位置算的?
' + }) + c1.replies = [ + reply(c1, { author: ME, ago: 5 * HOUR, html: '算 scroll 位置,没用 observer —— 目录只有几条,没必要。
' }), + reply(c1, { author: OWNER, ago: 4 * HOUR + 20 * MIN, html: '后面会换成 observer,等条目多了再说。
' }), + reply(c1, { author: USERS.yuki, ago: 3 * HOUR, html: '标签是按位置算的:找到当前篇,前后各两篇贴上「上一篇 / 下一篇 / 更早 / 更新」。
' }), + reply(c1, { author: USERS.mason, ago: 2 * HOUR, html: '学到了。
' }), + reply(c1, { author: USERS.noli, ago: 80 * MIN, html: '同问 observer,楼上已经答了,收了。
' }) + ] + c1.reply_count = 8 // 只展开 5 条,剩 3 条走「加载更多回复」(thread 接口) + + const c2 = mk({ + author: USERS.yuki, + ago: 5 * HOUR, + html: + '挑个刺:.proj-grid 的轨道下限要是裸 300px,320px 的屏会溢出吧?
grid-template-columns: repeat(auto-fill, minmax(min(300px,100%), 1fr));' +
+ '这样写就稳了。
' + }) + + const c3 = mk({ + author: OWNER, + ago: 2 * DAY, + html: + '补一句:留白的尺度是 28 / 48 / 60,不要随手改成整十数,视觉节奏会散。
' + + '' + }) + + const c4 = mk({ + author: USERS.awei, + ago: 40 * HOUR, + html: + '间距是设计的一部分,不是留白的一部分。
手机上那个 ☰ 菜单挺好用的。要是能把 主题切换 也放进去就完美了 —— 现在得退回顶栏才切得动。
' + + '这条在等审核(comment_review=1 时会走这个状态),只有我自己看得见。
手机上那个 ☰ 菜单挺好用的。Telegram 登录什么时候开?
' + }) + + const c8 = mk({ + author: USERS.mason, + ago: 8 * HOUR, + edited_at: new Date(Date.now() - 6 * HOUR).toISOString(), + html: + '已编辑过的评论会带一个小标记。顺手贴个我抄走的 color-mix 用法:
' + }) + + return [c8, c7, c6, c2, c4, c5, c3, c1] +} + +// ---- 长列表:把 baseItems 铺开再加一批,专门测「显示更多」分页 ---- +export function longItems() { + const out = baseItems() + const lines = [ + ['收藏了,回头照着做一遍。', USERS.lin], + ['暗色下那套color-mix(in srgb, var(--ink) 6%, transparent)
--v-* 是怎么对齐的?手调的还是工具生成的?', USERS.mason],
+ ['正文行宽 728 是算出来的还是试出来的?', USERS.awei],
+ ['首页那个「做过的东西」排版舒服。', USERS.yuki],
+ ['提个需求:评论能不能支持 表情回应?不用点赞那么复杂,几个 emoji 就够。
', USERS.awei], + ['RSS 里能带上评论数吗?', USERS.noli], + ['照着把 mobile.css 的思路搬回我们项目了,一处收口确实比到处写媒体查询干净。
后台那个命令面板(⌘K)是现成的还是自己实现的?
时间线那条渐变竖线:
background: linear-gradient(180deg, var(--accent), transparent);stolen, thanks.
', USERS.yuki], + ['文章里的首字下沉在暗色下会不会太跳?', USERS.mason], + ['关注了。', USERS.lin], + ['标签页那组 写 / 设 / 篇 / 辑 的措辞是故意的吧,挺妙。
', USERS.awei], + ['页脚那句引言换过几次了?', USERS.tg], + ['RSS 阅读器里看到的排版比网页还干净,罕见。
', USERS.yuki] + ] + lines.forEach(([html, author], i) => { + out.push(mk({ author, ago: 2 * DAY + (i + 1) * 7 * HOUR, html })) + }) + return out +} diff --git a/frontend/src/reader.js b/frontend/src/reader.js new file mode 100644 index 0000000..300b424 --- /dev/null +++ b/frontend/src/reader.js @@ -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 + }) +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5eceea3..d67b7d6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -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%;} diff --git a/frontend/src/ui/yohaku/YohakuArticle.vue b/frontend/src/ui/yohaku/YohakuArticle.vue index 665cbeb..d5116d7 100644 --- a/frontend/src/ui/yohaku/YohakuArticle.vue +++ b/frontend/src/ui/yohaku/YohakuArticle.vue @@ -1,6 +1,7 @@
使用社交账号登录
++-
+
+
+
+站点还没配置登录方式。
+ + +{{ s.error }}
+ + ++-
+
+
+
+
+这里还静悄悄
+第一条评论留给你。
++
+
+ +