Files
ONE/frontend/src/views/PostView.vue
T
Sakurasan f1e639e0ba 账户中心:站主资料可编辑 + 身份绑定 + passkey 登录
后台新增 /admin/account 一页,四块:公开资料、密码、第三方账号、Passkey。

-  schema:users 加 role(默认 reader),新表 user_identities、passkeys。
  user_identities 上双 UNIQUE —— (provider, extern_uid) 防一个外部账号顶两个身份,
  (user_id, provider) 防一站主绑两个同平台号,绑错也劫持不了。
  extern_uid 存平台稳定 ID,不存用户名(用户名可改)。
- 头像存 files 里的 key 而非 URL,换存储/CDN 不失效;单 key SetSetting 写入,
  避开 UpdateSettings 的整表替换会把它抹掉。站主名/简介从设置页挪到账户页,
  一个字段只留一个编辑入口。
- OAuth 绑定要求先有后台会话(绑定动作本身是提权路径);已绑的站主身份登录后
  直接发 one_session,读者身份仍发 one_reader。
- passkey 走 go-webauthn v0.15.0(最后一条吃 go 1.24 的版本线),可发现凭据登录。
  必须显式设 ONE_WEBAUTHN_ORIGINS 才启用,不配就安静关掉。
  签名计数只记克隆警告、不硬拦 —— 云同步 passkey 的计数本就不单调。
- 密码故意留在 ONE_ADMIN_PASSWORD,不做哈希入库:这是「解绑一切、删光 passkey
  也还能进门」的保底,比 env 明文更值得守。memos 那个 SSO 建号随机密码无重置
  入口的坑,从设计上绕开。

已知限制:会话仍是有状态无关的 HMAC cookie,删 passkey / 解绑不会让已发出的
7 天后台会话失效 —— 要修得加一张吊销表。
2026-09-30 01:08:55 +08:00

278 lines
7.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
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 LinkCard from '../components/LinkCard.vue'
import AuthorAvatar from '../components/AuthorAvatar.vue'
import { openLightbox } from '../lightbox'
import { publicApi } from '../api'
import { site, applyDocTitle } from '../site'
import { applyImageRatio, formatDate, minutesLabel, sanitizeHtml } from '../utils'
const route = useRoute()
const post = ref(null)
const loading = ref(true)
const error = ref('')
const isVivid = computed(() => site.ui_id === 'vivid')
// 正文 h2 加 id,供 vivid 右栏目录;classic 不用但无害
function withHeadingIds(html) {
const doc = new DOMParser().parseFromString(html || '', 'text/html')
const heads = [...doc.querySelectorAll('h2')]
heads.forEach((h, i) => {
if (!h.id) h.id = 'h-' + i
})
return {
html: doc.body.innerHTML,
headings: heads.map((h) => ({ id: h.id, text: h.textContent || '' }))
}
}
const contentHtml = ref('')
const headings = ref([])
// 顶部阅读进度条(classic):滚动过正文才算数,短文太短不显示
const progress = ref(0)
function onScroll() {
const doc = document.documentElement
const total = doc.scrollHeight - window.innerHeight
progress.value = total > 300 ? Math.min(100, (window.scrollY / total) * 100) : 0
}
async function load() {
loading.value = true
error.value = ''
post.value = null
contentHtml.value = ''
headings.value = []
try {
post.value = await publicApi.post(route.params.slug)
const prepared = withHeadingIds(post.value.content_html)
contentHtml.value = applyImageRatio(prepared.html)
headings.value = prepared.headings
applyDocTitle(post.value.kind === 'short' ? '短文' : post.value.title)
} catch (e) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
}
onMounted(() => {
load()
window.addEventListener('scroll', onScroll, { passive: true })
})
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
watch(() => route.params.slug, load)
const isShort = computed(() => post.value && post.value.kind === 'short')
</script>
<template>
<YohakuArticle
v-if="isVivid"
:post="post"
:headings="headings"
:content-html="contentHtml"
:neighbors="post?.neighbors || []"
:loading="loading"
:error="error"
/>
<div v-else class="shell">
<!-- 阅读进度:贴在视口顶部的细线 -->
<div class="reading-bar" :style="{ width: progress + '%' }" aria-hidden="true"></div>
<div class="layout">
<!-- LeftNav / RightRail 之前 import 了却没渲染,导致整页掉进 236px 的左列。
补上,和 HomeView 的三栏一致。 -->
<LeftNav />
<main class="main">
<div v-if="loading" class="loading">载入中…</div>
<div v-else-if="error" class="empty">
{{ error }}
<p><RouterLink to="/">← 回到时间线</RouterLink></p>
</div>
<article v-else class="wrap" :class="{ short: isShort }">
<header class="head">
<div class="byline">
<div class="avatar"><AuthorAvatar /></div>
<div class="who">
<span class="author">{{ site.author_name || 'ONE' }}</span>
<span class="sub">
<time :datetime="post.published_at">{{ formatDate(post.published_at) }}</time>
<span class="dot">·</span>
<span>{{ minutesLabel(post) }}</span>
</span>
</div>
</div>
<!-- 短文不渲染大标题 -->
<h1 v-if="!isShort" class="title">{{ post.title }}</h1>
<p v-if="!isShort && post.summary" class="lede">{{ post.summary }}</p>
</header>
<div
class="prose"
:class="{ 'prose-short': isShort }"
v-html="sanitizeHtml(contentHtml)"
></div>
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
<div
v-if="isShort && post.images && post.images.length"
class="post-imgs"
:class="'n' + Math.min(post.images.length, 4)"
>
<img
v-for="(u, i) in post.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click="openLightbox(post.images, i)"
/>
</div>
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
<LinkCard v-if="isShort && post.link_card" :card="post.link_card" />
<footer class="foot">
<div v-if="post.tags && post.tags.length" class="tags">
<RouterLink
v-for="t in post.tags"
:key="t"
:to="`/tag/${encodeURIComponent(t)}`"
class="tag-chip"
>
{{ t }}
</RouterLink>
</div>
<RouterLink to="/" class="back">← 回到时间线</RouterLink>
</footer>
</article>
<!-- 评论区(和 vivid 共用同一个组件,见 components/comments/) -->
<CommentSection v-if="post" :post-id="post.id" />
</main>
<RightRail :toc="isShort ? [] : headings" />
</div>
</div>
</template>
<style scoped>
.main {
min-width: 0;
border-left: 1px solid var(--line);
border-right: 1px solid var(--line);
padding-bottom: 60px;
}
.wrap {
padding: 26px 26px 40px;
}
.head {
padding-bottom: 18px;
border-bottom: 1px solid var(--line);
}
.byline {
display: flex;
align-items: center;
gap: 12px;
}
/* 站标自身是圆,原来的 accent-soft 圆底与衬线首字母一并去掉,只留尺寸与颜色 */
.avatar {
width: 44px;
height: 44px;
color: var(--ink);
}
.who {
display: flex;
flex-direction: column;
line-height: 1.5;
}
.author {
font-weight: 600;
}
.sub {
font-size: 13px;
color: var(--muted);
}
.dot {
margin: 0 5px;
color: var(--faint);
}
.title {
margin: 20px 0 0;
font-size: 32px;
line-height: 1.2;
/* 大号衬线标题需要负字距,越紧越像"排"出来的而不是"摆"出来的 */
letter-spacing: -0.015em;
}
.lede {
margin: 10px 0 0;
font-family: var(--serif);
font-size: 16.5px;
color: var(--ink-soft);
font-style: italic;
}
.foot {
margin-top: 32px;
padding-top: 18px;
border-top: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.back {
font-size: 13px;
color: var(--accent);
}
@media (max-width: 780px) {
.main {
border: 0;
}
.wrap {
padding: 18px 4px 40px;
}
.title {
font-size: 27px;
}
}
@media (max-width: 640px) {
/* 整站手机页边距是 12px(styles.css 的 .shell),这里把侧边距归零,
正文才和评论区落在同一条 12px 左缘上 */
.wrap {
padding: 18px 0 40px;
}
}
</style>