时间线操作行补真实计数(评论数 / 浏览数),classic 首页同步加操作行

- posts 表加 view_count 列(迁移自动补列),公开文章详情每被拉取 +1;
  列表接口子查询带出非删除评论数
- classic 首页文章卡补上操作行(回复 / 赞 / 查看),与 vivid 同行为:
  回复跳评论区锚点、赞本机 localStorage、查看进详情;
  卡片底部 meta 行的日期不再重复包「查看」链接
- vivid 操作行的回复 / 查看旁展示对应计数(万以上缩写)
This commit is contained in:
Sakurasan committed 2026-10-08 00:15:34 +08:00
1 parent 7171c95275
commit 916dd74034
6 files changed
+102 -12

No files matched your search

+3
View File
@@ -192,6 +192,9 @@ func (a *API) getPost(w http.ResponseWriter, r *http.Request) {
httpx.NotFound(w)
return
}
// 浏览数:详情页每被拉取一次 +1(草稿不走这里;不区分访客,博客够用)。
// 自增失败静默——计数器丢一次无关紧要,不能让文章因此打不开。
a.Store.BumpViewCount(p.ID)
// 邻居给详情页侧栏的时间线用;取不到不该让详情页挂掉。
nb, err := a.Store.Neighbors(slug)
if err != nil {
+4
View File
@@ -35,6 +35,10 @@ type Post struct {
// LinkCard 是正文里第一个外链的预览卡片(发布时抓一次,存 posts.link_card)。
// nil = 没有链接或抓取失败,前台不渲染卡片。
LinkCard *LinkCard `json:"link_card,omitempty"`
// CommentCount 是非删除评论数(列表接口子查询带出,操作行「回复」旁展示)。
CommentCount int64 `json:"comment_count"`
// ViewCount 是浏览数(公开文章详情每次拉取 +1)。
ViewCount int64 `json:"view_count"`
}
// LinkCard 是链接预览卡片:由 internal/linkmeta 抓取,以 JSON 存在 posts 表里。
+11 -2
View File
@@ -164,6 +164,8 @@ func (s *Store) migrate() error {
`ALTER TABLE posts ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`,
// 正文首个外链的预览卡片(og 标题/描述/封面),JSON 对象或空串
`ALTER TABLE posts ADD COLUMN link_card TEXT NOT NULL DEFAULT ''`,
// 浏览数:公开文章详情每被拉取一次 +1(不区分访客,博客场景够用)
`ALTER TABLE posts ADD COLUMN view_count INTEGER NOT NULL DEFAULT 0`,
// 账号角色:owner(站主,全库唯一)/ reader(评论区访客)
`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'reader'`,
// 后台账号密码(provider=admin 的行才有值):多用户改造后密码上库,
@@ -580,7 +582,9 @@ const postCols = `id, kind, title, slug, summary, cover_url, content_md, content
const listCols = `id, kind, title, slug, summary, cover_url,
'' AS content_md,
CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html,
status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images, link_card`
status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images, link_card,
(SELECT COUNT(*) FROM comments c WHERE c.post_id = posts.id AND c.is_deleted = 0) AS comment_count,
view_count`
// images 列的 JSON 编解码(列存 '[]',Go 侧 []string;坏数据静默为空)。
func decodeImages(s string) []string {
@@ -634,7 +638,7 @@ func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) {
var imgs, card string
err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
&p.ReadingMinutes, &p.ContentLen, &imgs, &card)
&p.ReadingMinutes, &p.ContentLen, &imgs, &card, &p.CommentCount, &p.ViewCount)
p.Tags = []string{}
p.Images = decodeImages(imgs)
p.LinkCard = decodeLinkCard(card)
@@ -1473,6 +1477,11 @@ func (s *Store) Archive() ([]model.ArchiveYear, error) {
return years, nil
}
// BumpViewCount 浏览数自增。静默失败:计数器不该影响文章可读性。
func (s *Store) BumpViewCount(id int64) {
_, _ = s.db.Exec(s.db.Q(`UPDATE posts SET view_count = view_count + 1 WHERE id = ?`), id)
}
func splitDate(rfc3339 string) (string, string) {
if len(rfc3339) < 7 {
return "", ""
+72 -5
View File
@@ -1,14 +1,33 @@
<script setup>
import { computed } from 'vue'
import { computed, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { site } from '../site'
import { openLightbox } from '../lightbox'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml } from '../utils'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml, fmtCount } from '../utils'
import LinkCard from './LinkCard.vue'
import AuthorAvatar from './AuthorAvatar.vue'
import { PhChatCircle, PhHeart, PhEye } from '@phosphor-icons/vue'
const router = useRouter()
// ---------- 操作行:回复 / 赞 / 查看(与 vivid 时间线同一套行为) ----------
// 赞是本机赞(无登录体系,localStorage 按设备计);模块级共享,多张卡片同步
const LIKES_KEY = 'one_feed_likes'
const likes = reactive(readLikes())
function readLikes() {
try { return JSON.parse(localStorage.getItem(LIKES_KEY) || '{}') } catch { return {} }
}
function isLiked(p) { return !!likes[String(p.id || p.slug)] }
function toggleLike(p) {
const k = String(p.id || p.slug)
if (likes[k]) delete likes[k]
else likes[k] = 1
try { localStorage.setItem(LIKES_KEY, JSON.stringify(likes)) } catch { /* 隐私模式存不上就算了 */ }
}
function goComments(p) {
router.push(`/post/${p.slug}#comments`)
}
const props = defineProps({
post: { type: Object, required: true }
})
@@ -86,9 +105,7 @@ const gridW = computed(() => (props.post.images && props.post.images.length ===
<LinkCard v-if="isShort && post.link_card" :card="post.link_card" />
<div class="meta">
<RouterLink :to="`/post/${post.slug}`" class="read" :title="isShort ? '查看' : '阅读全文'">
<time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>
</RouterLink>
<time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>
<span v-if="!isShort" class="mins">{{ minutesLabel(post) }}</span>
<span v-if="post.tags && post.tags.length" class="tags">
<RouterLink
@@ -102,11 +119,61 @@ const gridW = computed(() => (props.post.images && props.post.images.length ===
</RouterLink>
</span>
</div>
<!-- 操作行:回复 / 赞 / 查看。卡片本体可点,按钮 stop 冒泡不误跳 -->
<div class="acts" @click.stop>
<button type="button" @click.stop="goComments(post)">
<svg viewBox="0 0 256 256" aria-hidden="true" fill="currentColor"><path d="M128,24A104,104,0,0,0,36.18,177.53L24.6,218.07a15.9,15.9,0,0,0,20.21,19.85l41.84-11.18A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.8-12.26,8,8,0,0,0-6.53-.85L32.59,214.1,44.15,173.4a8,8,0,0,0-.66-6.09A88,88,0,1,1,128,216Z"/></svg>
{{ fmtCount(post.comment_count) }}
</button>
<button type="button" :class="{ liked: isLiked(post) }" @click.stop="toggleLike(post)">
<svg viewBox="0 0 256 256" aria-hidden="true" fill="currentColor"><path d="M178,32c-20.65,0-38.44,8.76-50,23.89C116.44,40.76,98.65,32,78,32,44.16,32,16,60.84,16,96c0,58.32,104.34,116.55,108.8,119a8,8,0,0,0,7.73,0C137.66,212.55,240,154.32,240,96,240,60.84,211.84,32,178,32Z"/></svg>
赞
</button>
<button type="button" @click.stop="openPost()">
<svg viewBox="0 0 256 256" aria-hidden="true" fill="currentColor"><path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,123.94,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25C38.4,149,30.31,136.13,25.14,128c5.16-8.13,13.25-21,22.92-30.75C70.33,75.2,97.22,64,128,64s57.67,11.19,79.93,33.25c9.67,9.73,17.76,22.62,22.92,30.75C225.7,136.13,217.6,149,207.93,158.75,185.67,180.81,158.78,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/></svg>
{{ fmtCount(post.view_count) }}
</button>
</div>
</div>
</article>
</template>
<style scoped>
/* 操作行:回复 / 赞 / 查看。低调小字,hover 提亮,liked 心形用 accent */
.acts {
display: flex;
gap: 4px;
margin-top: 10px;
}
.acts button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: 0;
border-radius: 999px;
background: transparent;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: var(--faint);
cursor: pointer;
touch-action: manipulation;
transition: color 0.2s ease, background 0.2s ease;
}
.acts button svg {
width: 15px;
height: 15px;
}
.acts button:hover {
color: var(--accent);
background: var(--accent-soft);
}
.acts button.liked {
color: var(--accent);
}
.row-feed {
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
+5 -5
View File
@@ -2,7 +2,7 @@
import { reactive } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { openLightbox } from '../../lightbox'
import { thumbURL, thumbifyHtml } from '../../utils'
import { thumbURL, thumbifyHtml, fmtCount } from '../../utils'
import LinkCard from '../../components/LinkCard.vue'
import { PhChatCircle, PhHeart, PhEye } from '@phosphor-icons/vue'
@@ -81,9 +81,9 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
</div>
</RouterLink>
<div class="acts">
<button type="button" @click="go(p, '#comments')"><PhChatCircle class="ic" aria-hidden="true" />回复</button>
<button type="button" @click="go(p, '#comments')"><PhChatCircle class="ic" aria-hidden="true" />{{ fmtCount(p.comment_count) }}</button>
<button type="button" :class="{ liked: likes[keyOf(p)] }" @click="toggleLike(p)"><PhHeart class="ic" aria-hidden="true" />赞</button>
<button type="button" @click="go(p)"><PhEye class="ic" aria-hidden="true" />查看</button>
<button type="button" @click="go(p)"><PhEye class="ic" aria-hidden="true" />{{ fmtCount(p.view_count) }}</button>
</div>
</div>
@@ -119,9 +119,9 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
</div>
</RouterLink>
<div class="acts note-acts">
<button type="button" @click="go(p, '#comments')"><PhChatCircle class="ic" aria-hidden="true" />回复</button>
<button type="button" @click="go(p, '#comments')"><PhChatCircle class="ic" aria-hidden="true" />{{ fmtCount(p.comment_count) }}</button>
<button type="button" :class="{ liked: likes[keyOf(p)] }" @click="toggleLike(p)"><PhHeart class="ic" aria-hidden="true" />赞</button>
<button type="button" @click="go(p)"><PhEye class="ic" aria-hidden="true" />查看</button>
<button type="button" @click="go(p)"><PhEye class="ic" aria-hidden="true" />{{ fmtCount(p.view_count) }}</button>
</div>
</div>
</template>
+7
View File
@@ -84,6 +84,13 @@ const longDateFmt = new Intl.DateTimeFormat('zh-CN', {
})
const shortDateFmt = new Intl.DateTimeFormat('sv-SE') // YYYY-MM-DD
// 操作行的计数展示:万以上缩写,避免一长串数字压弯布局
export function fmtCount(n) {
const v = Number(n) || 0
if (v >= 10000) return (v / 10000).toFixed(1).replace(/\.0$/, '') + '万'
return String(v)
}
export function formatDate(iso) {
if (!iso) return ''
const d = new Date(iso)