2026-06-07 21:51:01 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-06-15 18:26:35 +08:00
|
|
|
|
import { computed, h, onMounted, ref, watch } from 'vue'
|
2026-06-07 21:51:01 +08:00
|
|
|
|
import { useRouter } from 'vue-router'
|
|
|
|
|
|
import {
|
|
|
|
|
|
NCard, NSpace, NTag, NText, NSelect, NInput, NButton, NEmpty, NSkeleton, NSpin,
|
2026-06-15 20:36:06 +08:00
|
|
|
|
NPagination, NAutoComplete, NAlert, useMessage,
|
2026-06-07 21:51:01 +08:00
|
|
|
|
} from 'naive-ui'
|
2026-06-13 21:18:06 +08:00
|
|
|
|
import { articlesApi, readsApi, sourcesApi, type ArticleListItem, type Source } from '@/api/articles'
|
2026-06-15 19:37:40 +08:00
|
|
|
|
import { searchApi, type SearchKeyword } from '@/api/search'
|
2026-06-15 18:26:35 +08:00
|
|
|
|
import { useDebounce } from '@/composables/useDebounce'
|
2026-06-07 21:51:01 +08:00
|
|
|
|
import { useAuthStore } from '@/stores/auth'
|
|
|
|
|
|
import dayjs from 'dayjs'
|
|
|
|
|
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
|
|
|
|
|
import 'dayjs/locale/zh-cn'
|
|
|
|
|
|
dayjs.extend(relativeTime)
|
|
|
|
|
|
dayjs.locale('zh-cn')
|
|
|
|
|
|
|
|
|
|
|
|
const router = useRouter()
|
|
|
|
|
|
const auth = useAuthStore()
|
2026-06-13 21:18:06 +08:00
|
|
|
|
const message = useMessage()
|
2026-06-07 21:51:01 +08:00
|
|
|
|
const items = ref<ArticleListItem[]>([])
|
|
|
|
|
|
const sources = ref<Source[]>([])
|
|
|
|
|
|
const loading = ref(false)
|
2026-06-14 09:35:57 +08:00
|
|
|
|
// 等待滑出动画跑完的 article id 集合(避免动画进行中 v-for 直接移除元素)
|
|
|
|
|
|
const pendingRemoval = ref<Set<number>>(new Set())
|
2026-06-10 12:07:04 +08:00
|
|
|
|
|
2026-06-15 21:24:06 +08:00
|
|
|
|
// === 分类批量已读提示条(per-article) ===
|
2026-06-15 20:36:06 +08:00
|
|
|
|
// 触发:用户标某条为已读时,查询该文章前 2 个 category 的"24h 未读数";
|
2026-06-15 21:24:06 +08:00
|
|
|
|
// 大于 0 的 category 各显示一行提示,**紧贴该文章卡片下方**
|
2026-06-15 20:36:06 +08:00
|
|
|
|
// 行为:点击"全部已读"调 markCategory,前端拿到 article_ids 走乐观滑出
|
2026-06-15 21:24:06 +08:00
|
|
|
|
// 自动消失:8 秒(任一 category 被 dismiss / 确认 → 移除该 article 的提示条)
|
|
|
|
|
|
// hide_read 模式下,卡片 leave 时该 article 对应的提示条也跟着 leave(共享 setTimeout)
|
2026-06-15 20:36:06 +08:00
|
|
|
|
type CategoryPromptItem = {
|
|
|
|
|
|
category: string
|
|
|
|
|
|
unreadCount: number
|
|
|
|
|
|
}
|
2026-06-15 21:24:06 +08:00
|
|
|
|
// Map<articleId, CategoryPromptItem[]> — 每篇文章独立挂自己的提示条
|
|
|
|
|
|
const categoryPromptsByArticle = ref<Map<number, CategoryPromptItem[]>>(new Map())
|
|
|
|
|
|
const pendingCategory = ref<string | null>(null) // 正在请求"全部已读"的分类(category 名)
|
2026-06-15 20:36:06 +08:00
|
|
|
|
let categoryPromptTimer: number | null = null
|
|
|
|
|
|
|
2026-06-15 21:35:35 +08:00
|
|
|
|
// 只渲染"还在 items 里"且"有提示条"的 article 列表(给 TransitionGroup 用作 v-for)
|
|
|
|
|
|
// computed 保证响应式
|
|
|
|
|
|
// - items 没这条 → 不渲染(孤儿)
|
|
|
|
|
|
// - items 有 + Map 也有 → 渲染
|
|
|
|
|
|
// - items 有 + Map 没 → 不渲染(用户已 dismiss/确认/超时)
|
2026-06-16 10:15:36 +08:00
|
|
|
|
const articlesWithPrompts = computed(() =>
|
|
|
|
|
|
items.value.filter((a) => (categoryPromptsByArticle.value.get(a.id)?.length ?? 0) > 0)
|
|
|
|
|
|
)
|
2026-06-15 21:35:35 +08:00
|
|
|
|
|
2026-06-15 21:24:06 +08:00
|
|
|
|
// 移除某篇文章的所有提示条(8 秒超时 / 确认后 / 过滤变化)
|
|
|
|
|
|
function clearPromptsForArticle(articleId: number) {
|
|
|
|
|
|
if (categoryPromptsByArticle.value.has(articleId)) {
|
|
|
|
|
|
const next = new Map(categoryPromptsByArticle.value)
|
|
|
|
|
|
next.delete(articleId)
|
|
|
|
|
|
categoryPromptsByArticle.value = next
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 全清
|
|
|
|
|
|
function clearAllPrompts() {
|
|
|
|
|
|
if (categoryPromptsByArticle.value.size > 0) {
|
|
|
|
|
|
categoryPromptsByArticle.value = new Map()
|
|
|
|
|
|
}
|
|
|
|
|
|
if (categoryPromptTimer !== null) {
|
|
|
|
|
|
clearTimeout(categoryPromptTimer)
|
|
|
|
|
|
categoryPromptTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 12:07:04 +08:00
|
|
|
|
// === 页码分页(替代原来的 cursor 无限滚动)===
|
|
|
|
|
|
const page = ref(1)
|
|
|
|
|
|
const pageSize = ref(50)
|
|
|
|
|
|
const total = ref(0)
|
|
|
|
|
|
const totalPages = ref(1)
|
|
|
|
|
|
|
2026-06-07 21:51:01 +08:00
|
|
|
|
const sourceFilter = ref<string[]>([])
|
|
|
|
|
|
const q = ref('')
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
// 已读过滤:hide_read = true → 默认隐藏已读;切换显示
|
|
|
|
|
|
const hideRead = ref(true)
|
2026-06-07 21:51:01 +08:00
|
|
|
|
|
|
|
|
|
|
const sourceOptions = ref<{ label: string; value: string }[]>([])
|
|
|
|
|
|
|
2026-06-15 19:37:40 +08:00
|
|
|
|
// === 搜索建议(autocomplete) — 纯 keyword 续接词 ===
|
2026-06-15 18:26:35 +08:00
|
|
|
|
// 触发:q 变化(用户输入)→ 250ms debounce → 调 /api/v1/search/suggestions
|
|
|
|
|
|
// 取消:每次新输入前 abort 上一次未完成的请求,避免旧响应覆盖新结果
|
|
|
|
|
|
// 选词:@select → 填入 q + 触发搜索(不再等回车)
|
|
|
|
|
|
const suggestKeywords = ref<SearchKeyword[]>([])
|
|
|
|
|
|
let suggestAbort: AbortController | null = null
|
|
|
|
|
|
|
|
|
|
|
|
async function fetchSuggestions(prefix: string) {
|
|
|
|
|
|
const p = prefix.trim()
|
|
|
|
|
|
if (!p) {
|
|
|
|
|
|
suggestKeywords.value = []
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
// 取消上一次未完成的
|
|
|
|
|
|
suggestAbort?.abort()
|
|
|
|
|
|
const ctrl = new AbortController()
|
|
|
|
|
|
suggestAbort = ctrl
|
|
|
|
|
|
try {
|
|
|
|
|
|
const resp = await searchApi.suggestions(p, 10, { signal: ctrl.signal })
|
2026-06-15 19:37:40 +08:00
|
|
|
|
// race condition 防护 — 只采纳最新请求的响应
|
2026-06-15 18:26:35 +08:00
|
|
|
|
if (suggestAbort === ctrl) {
|
|
|
|
|
|
suggestKeywords.value = resp.keywords
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
// 被 abort 的请求静默忽略(用户继续输入中)
|
|
|
|
|
|
if (e?.code !== 'ERR_CANCELED' && e?.name !== 'CanceledError') {
|
|
|
|
|
|
// 静默失败:不弹错误(autocomplete 失败不应干扰用户)
|
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
|
console.debug('search suggestions failed:', e?.message)
|
|
|
|
|
|
if (suggestAbort === ctrl) {
|
|
|
|
|
|
suggestKeywords.value = []
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const debouncedFetchSuggestions = useDebounce(fetchSuggestions, 250)
|
|
|
|
|
|
|
|
|
|
|
|
watch(q, (v) => {
|
|
|
|
|
|
debouncedFetchSuggestions(v)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// === NAutoComplete options ===
|
2026-06-15 19:37:40 +08:00
|
|
|
|
// 只用 keyword 续接词,扁平结构。
|
|
|
|
|
|
type SuggestOption = {
|
2026-06-15 18:26:35 +08:00
|
|
|
|
label: string
|
|
|
|
|
|
value: string
|
|
|
|
|
|
meta: SearchKeyword
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const suggestOptions = computed<SuggestOption[]>(() => {
|
2026-06-15 19:37:40 +08:00
|
|
|
|
return suggestKeywords.value.map((k) => ({
|
|
|
|
|
|
label: k.word,
|
|
|
|
|
|
value: k.word,
|
|
|
|
|
|
meta: k,
|
|
|
|
|
|
}))
|
2026-06-15 18:26:35 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-06-15 19:42:40 +08:00
|
|
|
|
// 自定义 render:只显示词文本(干净,不挂标签/数字)
|
2026-06-15 18:26:35 +08:00
|
|
|
|
function renderSuggestion(opt: SuggestOption) {
|
2026-06-15 19:42:40 +08:00
|
|
|
|
return h('span', { class: 'feed-suggest-text' }, opt.label)
|
2026-06-15 18:26:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 19:37:40 +08:00
|
|
|
|
// 选完候选词:naive-ui 把 value 写回 v-model,我们从 suggestOptions 找 meta
|
2026-06-15 18:26:35 +08:00
|
|
|
|
function onSelectSuggestion(value: string) {
|
|
|
|
|
|
const matched = suggestOptions.value.find((o) => o.value === value)
|
2026-06-15 19:37:40 +08:00
|
|
|
|
if (matched) {
|
2026-06-15 18:26:35 +08:00
|
|
|
|
q.value = matched.meta.word
|
|
|
|
|
|
resetToFirstPage()
|
|
|
|
|
|
} else {
|
2026-06-15 19:37:40 +08:00
|
|
|
|
// 兜底:value 就是用户要的关键词
|
2026-06-15 18:26:35 +08:00
|
|
|
|
q.value = value
|
|
|
|
|
|
resetToFirstPage()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 21:51:01 +08:00
|
|
|
|
async function load() {
|
|
|
|
|
|
if (loading.value) return
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
try {
|
|
|
|
|
|
const resp = await articlesApi.list({
|
|
|
|
|
|
source: sourceFilter.value.join(',') || undefined,
|
|
|
|
|
|
q: q.value || undefined,
|
2026-06-10 12:07:04 +08:00
|
|
|
|
page: page.value,
|
|
|
|
|
|
page_size: pageSize.value,
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
hide_read: hideRead.value ? 'true' : 'false',
|
2026-06-07 21:51:01 +08:00
|
|
|
|
})
|
2026-06-10 12:07:04 +08:00
|
|
|
|
items.value = resp.items
|
|
|
|
|
|
total.value = resp.total
|
|
|
|
|
|
totalPages.value = resp.total_pages
|
2026-06-07 21:51:01 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 09:35:57 +08:00
|
|
|
|
// === 已读操作(乐观更新,失败回滚;hide_read 模式下用滑出动画)===
|
|
|
|
|
|
// leave 动画起点(测出卡片真实高度,写到 style 让 max-height 能 transition 到 0)
|
|
|
|
|
|
function beforeCardLeave(el: Element) {
|
|
|
|
|
|
const h = (el as HTMLElement).offsetHeight
|
|
|
|
|
|
;(el as HTMLElement).style.maxHeight = h + 'px'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
async function toggleRead(a: ArticleListItem) {
|
|
|
|
|
|
const wasRead = a.is_read
|
|
|
|
|
|
a.is_read = !wasRead // 乐观更新
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (wasRead) {
|
|
|
|
|
|
await readsApi.unmark(a.id)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
await readsApi.mark(a.id)
|
|
|
|
|
|
}
|
2026-06-16 07:48:16 +08:00
|
|
|
|
|
2026-06-16 07:55:09 +08:00
|
|
|
|
// === 关键:先 await 提示条 fetch,再决定是否 splice ===
|
|
|
|
|
|
// 原因:hide_read 模式下,如果 360ms 内就把 a 从 items 移除,
|
|
|
|
|
|
// articlesWithPrompts 跟着过滤,wrapper 就被切走 → 用户看不到提示条。
|
|
|
|
|
|
// 解决:如果 fetch 完发现有提示条 → 把 splice 推迟到 8s 提示条到期再 splice;
|
|
|
|
|
|
// 如果没有提示条 → 照旧 360ms splice。
|
2026-06-16 07:48:16 +08:00
|
|
|
|
if (!wasRead && a.category) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const fetchPromise = maybePromptCategoryRead(a)
|
|
|
|
|
|
const timeout = new Promise<void>((resolve) => setTimeout(resolve, 1500))
|
|
|
|
|
|
await Promise.race([fetchPromise, timeout])
|
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
|
console.debug('[category-prompt] mounted for article', a.id, 'cats=', categoryPromptsByArticle.value.get(a.id))
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// 静默失败
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-16 10:22:54 +08:00
|
|
|
|
// hide_read 模式:卡片从列表里滑出(用户预期行为)
|
|
|
|
|
|
// wrapper 现在嵌在 NCard 内部,NCard leave 动画会带 wrapper 一起走
|
|
|
|
|
|
// 有提示条:让用户能看完 8s;无提示条:照旧 360ms 滑出
|
|
|
|
|
|
if (!wasRead && hideRead.value) {
|
|
|
|
|
|
const hasPrompt = (categoryPromptsByArticle.value.get(a.id)?.length ?? 0) > 0
|
|
|
|
|
|
const delay = hasPrompt ? 8200 : 360 // 有提示:等提示条走完;无提示:照旧
|
|
|
|
|
|
const idx = items.value.findIndex((x) => x.id === a.id)
|
|
|
|
|
|
if (idx >= 0) {
|
|
|
|
|
|
pendingRemoval.value.add(a.id)
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const i = items.value.findIndex((x) => x.id === a.id)
|
|
|
|
|
|
if (i >= 0) items.value.splice(i, 1)
|
|
|
|
|
|
pendingRemoval.value.delete(a.id)
|
|
|
|
|
|
if (total.value > 0) total.value -= 1
|
|
|
|
|
|
}, delay)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
// 失败回滚
|
|
|
|
|
|
a.is_read = wasRead
|
|
|
|
|
|
message.error(e?.response?.data?.title || '操作失败')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 20:36:06 +08:00
|
|
|
|
// 查 a 的前 2 个 category 的 24h 未读数,有未读就追加 / 替换提示条
|
|
|
|
|
|
async function maybePromptCategoryRead(a: ArticleListItem) {
|
|
|
|
|
|
const cats = splitCategory(a.category).slice(0, 2)
|
|
|
|
|
|
if (cats.length === 0) return
|
|
|
|
|
|
try {
|
|
|
|
|
|
const counts = await readsApi.countByCategories(cats, {
|
|
|
|
|
|
window_hours: 24,
|
|
|
|
|
|
sources: sourceFilter.value.length ? sourceFilter.value : undefined,
|
|
|
|
|
|
q: q.value || undefined,
|
|
|
|
|
|
})
|
|
|
|
|
|
// 只保留 unread_count > 0 的
|
|
|
|
|
|
const newPrompts: CategoryPromptItem[] = counts
|
|
|
|
|
|
.filter((c) => c.unread_count > 0)
|
|
|
|
|
|
.map((c) => ({
|
|
|
|
|
|
category: c.category,
|
|
|
|
|
|
unreadCount: c.unread_count,
|
|
|
|
|
|
}))
|
|
|
|
|
|
if (newPrompts.length === 0) return
|
|
|
|
|
|
|
2026-06-15 21:24:06 +08:00
|
|
|
|
// 把提示条挂到被标记的那篇文章下面(Map 替换触发响应式)
|
|
|
|
|
|
const next = new Map(categoryPromptsByArticle.value)
|
|
|
|
|
|
next.set(a.id, newPrompts)
|
|
|
|
|
|
categoryPromptsByArticle.value = next
|
2026-06-15 20:36:06 +08:00
|
|
|
|
|
2026-06-15 21:24:06 +08:00
|
|
|
|
// 8 秒后自动移除该文章的提示条(无操作就消失)
|
2026-06-15 20:36:06 +08:00
|
|
|
|
if (categoryPromptTimer !== null) clearTimeout(categoryPromptTimer)
|
|
|
|
|
|
categoryPromptTimer = window.setTimeout(() => {
|
2026-06-15 21:24:06 +08:00
|
|
|
|
clearPromptsForArticle(a.id)
|
2026-06-15 20:36:06 +08:00
|
|
|
|
categoryPromptTimer = null
|
|
|
|
|
|
}, 8000)
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
// 计数失败不影响主流程,静默
|
|
|
|
|
|
// eslint-disable-next-line no-console
|
|
|
|
|
|
console.debug('category count failed:', e?.message)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 21:24:06 +08:00
|
|
|
|
// 用户点提示条"全部已读":调 markCategory + 乐观滑出 + 关闭该条提示
|
|
|
|
|
|
async function confirmMarkCategory(articleId: number, category: string) {
|
2026-06-15 20:36:06 +08:00
|
|
|
|
if (pendingCategory.value) return // 防重入
|
|
|
|
|
|
pendingCategory.value = category
|
|
|
|
|
|
try {
|
|
|
|
|
|
const resp = await readsApi.markCategory({
|
|
|
|
|
|
category,
|
|
|
|
|
|
scope: 'filtered_unread',
|
|
|
|
|
|
window_hours: 24,
|
|
|
|
|
|
sources: sourceFilter.value.length ? sourceFilter.value : undefined,
|
|
|
|
|
|
q: q.value || undefined,
|
|
|
|
|
|
})
|
|
|
|
|
|
// 把命中的 article 在 items 里全部 is_read=true
|
|
|
|
|
|
const ids = new Set(resp.article_ids)
|
|
|
|
|
|
for (const item of items.value) {
|
|
|
|
|
|
if (ids.has(item.id)) item.is_read = true
|
|
|
|
|
|
}
|
2026-06-16 10:22:54 +08:00
|
|
|
|
// 走 hide_read 模式下的滑出(累计 delay,逐个错开,避免 30 个 setTimeout 同时触发视觉抖)
|
|
|
|
|
|
// wrapper 嵌在 NCard 内部,NCard leave 动画会带 wrapper 一起走
|
|
|
|
|
|
let i = 0
|
|
|
|
|
|
for (const id of resp.article_ids) {
|
|
|
|
|
|
const idx = items.value.findIndex((x) => x.id === id)
|
|
|
|
|
|
if (idx < 0) continue
|
|
|
|
|
|
pendingRemoval.value.add(id)
|
|
|
|
|
|
const delay = 360 + i * 20
|
|
|
|
|
|
i++
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
const k = items.value.findIndex((x) => x.id === id)
|
|
|
|
|
|
if (k >= 0) items.value.splice(k, 1)
|
|
|
|
|
|
pendingRemoval.value.delete(id)
|
|
|
|
|
|
if (total.value > 0) total.value -= 1
|
|
|
|
|
|
}, delay)
|
|
|
|
|
|
}
|
2026-06-15 21:24:06 +08:00
|
|
|
|
// 关闭该 article 的整组提示(不只关 category — 既然"全部已读"了,这一片都不需要了)
|
|
|
|
|
|
clearPromptsForArticle(articleId)
|
2026-06-15 20:36:06 +08:00
|
|
|
|
message.success(`已将 ${resp.marked} 条「${category}」标记为已读`)
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
message.error(e?.response?.data?.title || '操作失败')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
pendingCategory.value = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 21:24:06 +08:00
|
|
|
|
// 关闭某条提示(稍后再说 / × 按钮)— 输入是 articleId + category
|
|
|
|
|
|
function dismissPrompt(articleId: number, category: string) {
|
|
|
|
|
|
const cur = categoryPromptsByArticle.value.get(articleId)
|
|
|
|
|
|
if (!cur) return
|
|
|
|
|
|
const next = cur.filter((p) => p.category !== category)
|
|
|
|
|
|
const map = new Map(categoryPromptsByArticle.value)
|
|
|
|
|
|
if (next.length === 0) {
|
|
|
|
|
|
map.delete(articleId)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
map.set(articleId, next)
|
|
|
|
|
|
}
|
|
|
|
|
|
categoryPromptsByArticle.value = map
|
|
|
|
|
|
// 如果整组提示都关了,清掉 timer
|
|
|
|
|
|
if (categoryPromptsByArticle.value.size === 0 && categoryPromptTimer !== null) {
|
2026-06-15 20:36:06 +08:00
|
|
|
|
clearTimeout(categoryPromptTimer)
|
|
|
|
|
|
categoryPromptTimer = null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-07 21:51:01 +08:00
|
|
|
|
async function loadSources() {
|
|
|
|
|
|
sources.value = await sourcesApi.list()
|
|
|
|
|
|
sourceOptions.value = sources.value.map((s) => ({ label: s.name, value: s.slug }))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-10 12:07:04 +08:00
|
|
|
|
// 切页 → 重新加载 + 滚到顶部
|
|
|
|
|
|
function onPageChange(p: number) {
|
|
|
|
|
|
page.value = p
|
|
|
|
|
|
load()
|
|
|
|
|
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 筛选/搜索变化 → 回到第 1 页
|
|
|
|
|
|
function resetToFirstPage() {
|
|
|
|
|
|
page.value = 1
|
2026-06-07 21:51:01 +08:00
|
|
|
|
load()
|
2026-06-15 20:36:06 +08:00
|
|
|
|
// 过滤上下文变了,旧分类提示的"24h 未读"数已不准,清掉
|
2026-06-15 21:24:06 +08:00
|
|
|
|
if (categoryPromptsByArticle.value.size > 0) {
|
|
|
|
|
|
clearAllPrompts()
|
2026-06-15 20:36:06 +08:00
|
|
|
|
}
|
2026-06-07 21:51:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function open(a: ArticleListItem) {
|
|
|
|
|
|
router.push(`/article/${a.id}`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function star(a: ArticleListItem) {
|
|
|
|
|
|
a.is_starred = !a.is_starred
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fmtTime(s?: string | null) {
|
|
|
|
|
|
if (!s) return '—'
|
|
|
|
|
|
return dayjs(s).fromNow()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-09 15:59:48 +08:00
|
|
|
|
// category 是逗号分隔字符串(LLM 输出),拆成多个 tag
|
|
|
|
|
|
function splitCategory(c?: string | null): string[] {
|
|
|
|
|
|
if (!c) return []
|
|
|
|
|
|
return c.split(',').map((s) => s.trim()).filter(Boolean)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。
实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
(.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
(..., q)):
- 中文标题 + 原标题
- 正文摘要
- Angel 评论预览
- 美团评论预览
样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题
安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS
不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)
无后端改动。
2026-06-15 07:32:39 +08:00
|
|
|
|
// === 搜索关键字高亮(用于标题/评论预览的渲染)===
|
|
|
|
|
|
// 1) HTML escape — 防止 XSS(content 来自外部 RSS/ingest,不可信)
|
|
|
|
|
|
// 2) 不区分大小写匹配 q
|
|
|
|
|
|
// 3) 用 <mark> 包裹匹配项(全局 mark 标签由 .commentary-text / 标题区样式接管)
|
|
|
|
|
|
// 返回 HTML 字符串,供 v-html 使用。
|
|
|
|
|
|
function escapeHtml(s: string): string {
|
|
|
|
|
|
return s
|
|
|
|
|
|
.replace(/&/g, '&')
|
|
|
|
|
|
.replace(/</g, '<')
|
|
|
|
|
|
.replace(/>/g, '>')
|
|
|
|
|
|
.replace(/"/g, '"')
|
|
|
|
|
|
.replace(/'/g, ''')
|
|
|
|
|
|
}
|
|
|
|
|
|
function highlightHtml(text: string | null | undefined, q: string): string {
|
|
|
|
|
|
const safe = escapeHtml(text || '')
|
|
|
|
|
|
if (!q || !q.trim()) return safe
|
|
|
|
|
|
// 转义 q 里可能的正则元字符(用户搜 "*.x" 等不应该当 regex)
|
|
|
|
|
|
const escapedQ = q.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
|
|
|
|
const re = new RegExp(escapedQ, 'gi')
|
|
|
|
|
|
return safe.replace(re, (m) => `<mark>${m}</mark>`)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 评论预览:长文截断 + 搜索时高亮
|
|
|
|
|
|
// 返回 HTML 字符串(供 v-html 渲染)。q 为空时等价于纯文本(无高亮标签)。
|
|
|
|
|
|
function previewCommentary(c?: string | null, max = 120, q = ''): string {
|
2026-06-09 15:59:48 +08:00
|
|
|
|
if (!c) return ''
|
|
|
|
|
|
const trimmed = c.replace(/\s+/g, ' ').trim()
|
feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。
实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
(.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
(..., q)):
- 中文标题 + 原标题
- 正文摘要
- Angel 评论预览
- 美团评论预览
样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题
安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS
不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)
无后端改动。
2026-06-15 07:32:39 +08:00
|
|
|
|
const text = trimmed.length > max ? trimmed.slice(0, max) + '…' : trimmed
|
|
|
|
|
|
return highlightHtml(text, q)
|
2026-06-09 15:59:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function commentaryStatusType(s?: string | null): 'success' | 'warning' | 'error' | 'default' {
|
|
|
|
|
|
if (s === 'ok') return 'success'
|
|
|
|
|
|
if (s === 'failed') return 'error'
|
|
|
|
|
|
if (s === 'pending') return 'warning'
|
|
|
|
|
|
return 'default'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 23:24:30 +08:00
|
|
|
|
// === 评论三态语义 ===
|
|
|
|
|
|
// status 'ok' + 有内容 → 显示评论
|
|
|
|
|
|
// status 'ok' + 无内容 → 视为等待(防御性,正常不会触发)
|
|
|
|
|
|
// status 'pending' / 'n/a' / null → 等待中
|
|
|
|
|
|
// status 'failed' → 显示失败提示
|
|
|
|
|
|
type CommentaryState = 'ok' | 'waiting' | 'failed'
|
|
|
|
|
|
function commentaryState(status?: string | null, content?: string | null): CommentaryState {
|
|
|
|
|
|
if (status === 'failed') return 'failed'
|
|
|
|
|
|
if (status === 'ok' && content) return 'ok'
|
|
|
|
|
|
return 'waiting'
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 16:15:21 +08:00
|
|
|
|
// 正文摘要:长新闻截前 200 字(把多空白合并),短新闻保留原始换行不截取
|
|
|
|
|
|
function bodyExcerpt(text?: string | null, max = 200, keepNewlines = false): string {
|
2026-06-10 12:07:04 +08:00
|
|
|
|
if (!text) return ''
|
2026-06-14 16:15:21 +08:00
|
|
|
|
if (keepNewlines) {
|
|
|
|
|
|
// 短新闻:不去空白,保留 \n 让前端 white-space: pre-wrap 换行
|
|
|
|
|
|
return text.length > max ? text.slice(0, max) + '…' : text
|
|
|
|
|
|
}
|
2026-06-10 12:07:04 +08:00
|
|
|
|
const trimmed = text.replace(/\s+/g, ' ').trim()
|
|
|
|
|
|
return trimmed.length > max ? trimmed.slice(0, max) + '…' : trimmed
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const itemsLabel = computed(() => `共 ${total.value} 条`)
|
|
|
|
|
|
|
2026-06-07 21:51:01 +08:00
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
await loadSources()
|
|
|
|
|
|
await load()
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
<NSpace vertical :size="16">
|
2026-06-11 09:28:14 +08:00
|
|
|
|
<NSpace align="center" justify="space-between" :wrap="true" :size="[10, 10]" class="feed-toolbar">
|
|
|
|
|
|
<NSpace :size="10" :wrap="true" class="feed-toolbar-left">
|
2026-06-07 21:51:01 +08:00
|
|
|
|
<NSelect
|
|
|
|
|
|
v-model:value="sourceFilter"
|
|
|
|
|
|
multiple
|
|
|
|
|
|
clearable
|
|
|
|
|
|
placeholder="按源筛选"
|
|
|
|
|
|
:options="sourceOptions"
|
2026-06-11 09:28:14 +08:00
|
|
|
|
class="feed-source-select"
|
2026-06-10 12:07:04 +08:00
|
|
|
|
@update:value="resetToFirstPage"
|
2026-06-07 21:51:01 +08:00
|
|
|
|
/>
|
2026-06-15 18:26:35 +08:00
|
|
|
|
<NAutoComplete
|
|
|
|
|
|
v-model:value="q"
|
|
|
|
|
|
placeholder="关键词搜索"
|
|
|
|
|
|
clearable
|
|
|
|
|
|
class="feed-search-input"
|
|
|
|
|
|
:options="suggestOptions"
|
|
|
|
|
|
:render-label="renderSuggestion"
|
|
|
|
|
|
@select="onSelectSuggestion"
|
|
|
|
|
|
@keyup.enter="resetToFirstPage"
|
|
|
|
|
|
@clear="resetToFirstPage"
|
|
|
|
|
|
/>
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
<NSpace align="center" :size="6" class="feed-hideread-toggle">
|
|
|
|
|
|
<NText style="font-size: 13px">隐藏已读</NText>
|
|
|
|
|
|
<NSwitch v-model:value="hideRead" @update:value="resetToFirstPage" />
|
|
|
|
|
|
</NSpace>
|
2026-06-11 09:28:14 +08:00
|
|
|
|
<NButton type="primary" @click="resetToFirstPage" round>刷新</NButton>
|
2026-06-07 21:51:01 +08:00
|
|
|
|
</NSpace>
|
2026-06-11 09:28:14 +08:00
|
|
|
|
<NText :depth="3" style="font-size: 13px" class="feed-count-label">{{ itemsLabel }}</NText>
|
2026-06-07 21:51:01 +08:00
|
|
|
|
</NSpace>
|
|
|
|
|
|
|
2026-06-15 20:36:06 +08:00
|
|
|
|
<!--
|
2026-06-15 21:24:06 +08:00
|
|
|
|
分类批量已读提示条(per-article):
|
|
|
|
|
|
- 标某条已读后,提示条出现在该卡片下方(紧贴)
|
|
|
|
|
|
- 1-2 行(取 category 前 2 个,unread_count > 0)
|
2026-06-15 20:36:06 +08:00
|
|
|
|
- 每行独立"全部已读 / 稍后再说"
|
2026-06-15 21:24:06 +08:00
|
|
|
|
- 8 秒自动消失 / 过滤变化时清掉 / 卡片 leave 时跟随 leave
|
2026-06-15 20:36:06 +08:00
|
|
|
|
-->
|
|
|
|
|
|
|
2026-06-07 21:51:01 +08:00
|
|
|
|
<NSpin :show="loading && items.length === 0">
|
|
|
|
|
|
<NSkeleton v-if="loading && items.length === 0" :repeat="4" />
|
|
|
|
|
|
<NEmpty v-else-if="items.length === 0 && !loading" description="暂无新闻" />
|
2026-06-14 09:35:57 +08:00
|
|
|
|
<TransitionGroup
|
|
|
|
|
|
v-else
|
|
|
|
|
|
name="card"
|
|
|
|
|
|
tag="div"
|
|
|
|
|
|
class="feed-list"
|
|
|
|
|
|
@before-leave="beforeCardLeave"
|
|
|
|
|
|
>
|
2026-06-07 21:51:01 +08:00
|
|
|
|
<NCard
|
|
|
|
|
|
v-for="a in items"
|
|
|
|
|
|
:key="a.id"
|
|
|
|
|
|
class="article-card"
|
2026-06-14 16:15:21 +08:00
|
|
|
|
:class="{
|
|
|
|
|
|
'article-card-read': a.is_read,
|
|
|
|
|
|
'short-card': a.is_short_news,
|
|
|
|
|
|
}"
|
2026-06-07 21:51:01 +08:00
|
|
|
|
hoverable
|
|
|
|
|
|
@click="open(a)"
|
|
|
|
|
|
>
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
<NSpace vertical :size="10">
|
|
|
|
|
|
<!-- 顶行:源 / 语言 / 分类 tag / 时间 -->
|
2026-06-11 09:28:14 +08:00
|
|
|
|
<NSpace align="center" :size="6" :wrap="true" style="row-gap: 6px">
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
<NTag size="small" type="primary" :bordered="false" round>
|
|
|
|
|
|
{{ a.source.name }}
|
|
|
|
|
|
</NTag>
|
|
|
|
|
|
<NTag v-if="a.lang_src" size="small" round :bordered="false">
|
|
|
|
|
|
{{ a.lang_src.toUpperCase() }}
|
|
|
|
|
|
</NTag>
|
|
|
|
|
|
<NTag v-if="a.translation_status !== 'ok'" size="small" type="warning" :bordered="false" round>
|
2026-06-07 21:51:01 +08:00
|
|
|
|
{{ a.translation_status }}
|
|
|
|
|
|
</NTag>
|
2026-06-09 15:59:48 +08:00
|
|
|
|
<NTag
|
|
|
|
|
|
v-for="c in splitCategory(a.category)"
|
|
|
|
|
|
:key="c"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
type="success"
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
:bordered="false"
|
|
|
|
|
|
round
|
2026-06-09 15:59:48 +08:00
|
|
|
|
>
|
|
|
|
|
|
{{ c }}
|
|
|
|
|
|
</NTag>
|
2026-06-14 16:15:21 +08:00
|
|
|
|
<!-- 短新闻(API Push)角标:固定显示 -->
|
|
|
|
|
|
<NTag
|
|
|
|
|
|
v-if="a.is_short_news"
|
|
|
|
|
|
size="tiny"
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
:bordered="false"
|
|
|
|
|
|
round
|
|
|
|
|
|
class="feed-short-tag"
|
|
|
|
|
|
>
|
|
|
|
|
|
📰 短讯
|
|
|
|
|
|
</NTag>
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
<!-- 已读/未读小标签 -->
|
|
|
|
|
|
<NTag
|
|
|
|
|
|
v-if="a.is_read"
|
|
|
|
|
|
size="tiny"
|
|
|
|
|
|
:bordered="false"
|
|
|
|
|
|
round
|
|
|
|
|
|
type="default"
|
|
|
|
|
|
class="feed-read-tag"
|
|
|
|
|
|
>
|
|
|
|
|
|
✓ 已读
|
|
|
|
|
|
</NTag>
|
2026-06-11 09:28:14 +08:00
|
|
|
|
<NText :depth="3" style="font-size: 12px; margin-left: auto" class="feed-time-label">
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
{{ fmtTime(a.published_at || a.fetched_at) }}
|
|
|
|
|
|
</NText>
|
2026-06-07 21:51:01 +08:00
|
|
|
|
</NSpace>
|
2026-06-10 12:07:04 +08:00
|
|
|
|
|
feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。
实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
(.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
(..., q)):
- 中文标题 + 原标题
- 正文摘要
- Angel 评论预览
- 美团评论预览
样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题
安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS
不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)
无后端改动。
2026-06-15 07:32:39 +08:00
|
|
|
|
<!-- 中文标题(主)— 搜索时高亮 q -->
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="a.title_zh"
|
|
|
|
|
|
style="
|
|
|
|
|
|
font-family: var(--font-serif);
|
|
|
|
|
|
font-size: 20px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: var(--color-letter);
|
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
|
"
|
feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。
实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
(.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
(..., q)):
- 中文标题 + 原标题
- 正文摘要
- Angel 评论预览
- 美团评论预览
样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题
安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS
不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)
无后端改动。
2026-06-15 07:32:39 +08:00
|
|
|
|
v-html="highlightHtml(a.title_zh, q)"
|
|
|
|
|
|
/>
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
|
feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。
实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
(.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
(..., q)):
- 中文标题 + 原标题
- 正文摘要
- Angel 评论预览
- 美团评论预览
样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题
安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS
不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)
无后端改动。
2026-06-15 07:32:39 +08:00
|
|
|
|
<!-- 原标题(灰色,辅助)— 搜索时高亮 q -->
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="a.title"
|
|
|
|
|
|
style="font-size: 13px; color: var(--color-text-faint); line-height: 1.4;"
|
feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。
实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
(.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
(..., q)):
- 中文标题 + 原标题
- 正文摘要
- Angel 评论预览
- 美团评论预览
样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题
安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS
不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)
无后端改动。
2026-06-15 07:32:39 +08:00
|
|
|
|
v-html="highlightHtml(a.title, q)"
|
|
|
|
|
|
/>
|
2026-06-10 12:07:04 +08:00
|
|
|
|
|
2026-06-14 16:15:21 +08:00
|
|
|
|
<!-- AI 插图(若有;短新闻不显示) -->
|
2026-06-10 12:07:04 +08:00
|
|
|
|
<img
|
2026-06-14 16:15:21 +08:00
|
|
|
|
v-if="!a.is_short_news && (a.image_ai_url || a.image_url)"
|
2026-06-10 12:07:04 +08:00
|
|
|
|
:src="a.image_ai_url || a.image_url || ''"
|
|
|
|
|
|
style="
|
|
|
|
|
|
display: block;
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
width: 100%;
|
2026-06-10 12:07:04 +08:00
|
|
|
|
max-height: 280px;
|
|
|
|
|
|
object-fit: cover;
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
border-radius: 8px;
|
2026-06-10 12:07:04 +08:00
|
|
|
|
margin: 4px 0;
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
background: var(--color-surface-variant);
|
2026-06-10 12:07:04 +08:00
|
|
|
|
"
|
|
|
|
|
|
referrerpolicy="no-referrer"
|
|
|
|
|
|
loading="lazy"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2026-06-14 16:15:21 +08:00
|
|
|
|
<!--
|
|
|
|
|
|
正文摘要:
|
|
|
|
|
|
- 长新闻:body_zh_text 截前 200 字(去多余空白)
|
|
|
|
|
|
- 短新闻:body_zh_text(=body_text)完整展示,保留换行
|
feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。
实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
(.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
(..., q)):
- 中文标题 + 原标题
- 正文摘要
- Angel 评论预览
- 美团评论预览
样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题
安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS
不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)
无后端改动。
2026-06-15 07:32:39 +08:00
|
|
|
|
- 搜索时高亮 q(escape + <mark> 包裹,无 XSS)
|
2026-06-14 16:15:21 +08:00
|
|
|
|
-->
|
2026-06-10 12:07:04 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="a.body_zh_text || a.summary_zh"
|
2026-06-14 16:15:21 +08:00
|
|
|
|
:class="{ 'short-body': a.is_short_news }"
|
2026-06-10 12:07:04 +08:00
|
|
|
|
style="
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
margin-top: 4px;
|
|
|
|
|
|
color: var(--color-letter);
|
2026-06-10 12:07:04 +08:00
|
|
|
|
font-size: 14px;
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
line-height: 1.75;
|
2026-06-10 12:07:04 +08:00
|
|
|
|
"
|
feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。
实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
(.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
(..., q)):
- 中文标题 + 原标题
- 正文摘要
- Angel 评论预览
- 美团评论预览
样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题
安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS
不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)
无后端改动。
2026-06-15 07:32:39 +08:00
|
|
|
|
v-html="highlightHtml(
|
2026-06-14 16:15:21 +08:00
|
|
|
|
a.is_short_news
|
|
|
|
|
|
? bodyExcerpt(a.body_zh_text || a.summary_zh || '', 5000, true)
|
feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。
实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
(.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
(..., q)):
- 中文标题 + 原标题
- 正文摘要
- Angel 评论预览
- 美团评论预览
样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题
安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS
不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)
无后端改动。
2026-06-15 07:32:39 +08:00
|
|
|
|
: bodyExcerpt(a.body_zh_text || a.summary_zh, 200),
|
|
|
|
|
|
q
|
|
|
|
|
|
)"
|
|
|
|
|
|
/>
|
2026-06-10 12:07:04 +08:00
|
|
|
|
|
2026-06-12 23:24:30 +08:00
|
|
|
|
<!-- 评论钩子(双 provider:Angel + 美团,三态显式显示:有内容 / 等待中 / 失败) -->
|
2026-06-12 23:37:48 +08:00
|
|
|
|
<div class="commentary-stack">
|
2026-06-12 19:00:00 +08:00
|
|
|
|
<!-- Angel 评论 -->
|
2026-06-12 23:37:48 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="commentary-item"
|
|
|
|
|
|
:class="{
|
|
|
|
|
|
'commentary-item-waiting': commentaryState(a.commentary_status, a.commentary) === 'waiting',
|
|
|
|
|
|
'commentary-item-failed': commentaryState(a.commentary_status, a.commentary) === 'failed',
|
|
|
|
|
|
'commentary-item-ok': commentaryState(a.commentary_status, a.commentary) === 'ok',
|
|
|
|
|
|
}"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="commentary-head">
|
2026-06-12 19:00:00 +08:00
|
|
|
|
<span class="commentary-label">💬 Angel 评论</span>
|
2026-06-12 23:37:48 +08:00
|
|
|
|
<span
|
2026-06-12 23:48:52 +08:00
|
|
|
|
v-if="commentaryState(a.commentary_status, a.commentary) !== 'ok'"
|
2026-06-12 23:37:48 +08:00
|
|
|
|
class="commentary-badge"
|
|
|
|
|
|
:class="`commentary-badge-${commentaryState(a.commentary_status, a.commentary)}`"
|
2026-06-12 23:24:30 +08:00
|
|
|
|
>
|
2026-06-12 23:37:48 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="commentaryState(a.commentary_status, a.commentary) === 'waiting'"
|
|
|
|
|
|
class="commentary-spinner"
|
|
|
|
|
|
/>
|
2026-06-12 23:24:30 +08:00
|
|
|
|
{{
|
2026-06-12 23:48:52 +08:00
|
|
|
|
commentaryState(a.commentary_status, a.commentary) === 'failed' ? '失败' : '生成中'
|
2026-06-12 23:24:30 +08:00
|
|
|
|
}}
|
2026-06-12 23:37:48 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-06-12 23:24:30 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="commentaryState(a.commentary_status, a.commentary) === 'ok'"
|
|
|
|
|
|
class="commentary-text"
|
feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。
实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
(.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
(..., q)):
- 中文标题 + 原标题
- 正文摘要
- Angel 评论预览
- 美团评论预览
样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题
安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS
不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)
无后端改动。
2026-06-15 07:32:39 +08:00
|
|
|
|
v-html="previewCommentary(a.commentary, 140, q)"
|
|
|
|
|
|
/>
|
2026-06-12 23:24:30 +08:00
|
|
|
|
<div v-else-if="commentaryState(a.commentary_status, a.commentary) === 'failed'" class="commentary-text commentary-text-failed">
|
2026-06-12 23:37:48 +08:00
|
|
|
|
评论生成失败,后台 enrichment_loop 会重试
|
2026-06-12 23:24:30 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div v-else class="commentary-text commentary-text-waiting">
|
2026-06-12 23:37:48 +08:00
|
|
|
|
正在生成评论…
|
2026-06-12 23:24:30 +08:00
|
|
|
|
</div>
|
2026-06-12 23:37:48 +08:00
|
|
|
|
</div>
|
2026-06-12 19:00:00 +08:00
|
|
|
|
<!-- 美团评论 -->
|
2026-06-12 23:37:48 +08:00
|
|
|
|
<div
|
|
|
|
|
|
class="commentary-item"
|
|
|
|
|
|
:class="{
|
|
|
|
|
|
'commentary-item-waiting': commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'waiting',
|
|
|
|
|
|
'commentary-item-failed': commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'failed',
|
|
|
|
|
|
'commentary-item-ok': commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'ok',
|
|
|
|
|
|
}"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="commentary-head">
|
2026-06-12 19:00:00 +08:00
|
|
|
|
<span class="commentary-label commentary-label-meituan">🐱 美团评论</span>
|
2026-06-12 23:37:48 +08:00
|
|
|
|
<span
|
2026-06-12 23:48:52 +08:00
|
|
|
|
v-if="commentaryState(a.commentary_meituan_status, a.commentary_meituan) !== 'ok'"
|
2026-06-12 23:37:48 +08:00
|
|
|
|
class="commentary-badge"
|
|
|
|
|
|
:class="`commentary-badge-${commentaryState(a.commentary_meituan_status, a.commentary_meituan)}`"
|
2026-06-12 23:24:30 +08:00
|
|
|
|
>
|
2026-06-12 23:37:48 +08:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'waiting'"
|
|
|
|
|
|
class="commentary-spinner"
|
|
|
|
|
|
/>
|
2026-06-12 23:24:30 +08:00
|
|
|
|
{{
|
2026-06-12 23:48:52 +08:00
|
|
|
|
commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'failed' ? '失败' : '生成中'
|
2026-06-12 23:24:30 +08:00
|
|
|
|
}}
|
2026-06-12 23:37:48 +08:00
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-06-12 23:24:30 +08:00
|
|
|
|
<div
|
|
|
|
|
|
v-if="commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'ok'"
|
|
|
|
|
|
class="commentary-text"
|
feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。
实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
(.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
(..., q)):
- 中文标题 + 原标题
- 正文摘要
- Angel 评论预览
- 美团评论预览
样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题
安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS
不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)
无后端改动。
2026-06-15 07:32:39 +08:00
|
|
|
|
v-html="previewCommentary(a.commentary_meituan, 140, q)"
|
|
|
|
|
|
/>
|
2026-06-12 23:24:30 +08:00
|
|
|
|
<div v-else-if="commentaryState(a.commentary_meituan_status, a.commentary_meituan) === 'failed'" class="commentary-text commentary-text-failed">
|
2026-06-12 23:37:48 +08:00
|
|
|
|
评论生成失败,后台 enrichment_loop 会重试
|
2026-06-12 23:24:30 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div v-else class="commentary-text commentary-text-waiting">
|
2026-06-12 23:37:48 +08:00
|
|
|
|
正在生成评论…
|
2026-06-12 23:24:30 +08:00
|
|
|
|
</div>
|
2026-06-12 23:37:48 +08:00
|
|
|
|
</div>
|
2026-06-09 15:59:48 +08:00
|
|
|
|
</div>
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
|
2026-06-14 09:35:57 +08:00
|
|
|
|
<!-- 底部操作栏:已读/未读切换(浮在卡片右下角) -->
|
|
|
|
|
|
<div class="feed-actions" @click.stop>
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
<NButton
|
2026-06-14 09:35:57 +08:00
|
|
|
|
size="small"
|
|
|
|
|
|
:type="a.is_read ? 'tertiary' : 'primary'"
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
:ghost="!a.is_read"
|
|
|
|
|
|
round
|
2026-06-14 09:35:57 +08:00
|
|
|
|
class="feed-read-btn"
|
|
|
|
|
|
:class="{ 'feed-read-btn-read': a.is_read }"
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
@click.stop="toggleRead(a)"
|
|
|
|
|
|
>
|
2026-06-14 09:35:57 +08:00
|
|
|
|
<template #icon>
|
|
|
|
|
|
<span class="feed-read-icon" :class="{ 'feed-read-icon-checked': a.is_read }">
|
|
|
|
|
|
{{ a.is_read ? '✓' : '○' }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
{{ a.is_read ? '已读' : '标为已读' }}
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
</NButton>
|
2026-06-14 09:35:57 +08:00
|
|
|
|
</div>
|
2026-06-16 10:15:36 +08:00
|
|
|
|
<!--
|
|
|
|
|
|
分类提示条(per-article,紧贴卡片底部):
|
|
|
|
|
|
- 嵌在 NCard 内部,确保 DOM 位置紧贴对应卡片
|
|
|
|
|
|
- 只有当该 article 在 categoryPromptsByArticle Map 里有值时才渲染
|
|
|
|
|
|
- 8s 后 Map 删掉 → wrapper 走 leave 动画
|
|
|
|
|
|
- 内部 NAlert v-for 用 ${a.id}-${category} 作为 key,确保多个 category 时各自独立
|
|
|
|
|
|
-->
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="(categoryPromptsByArticle.get(a.id)?.length ?? 0) > 0"
|
|
|
|
|
|
:key="`prompt-wrapper-${a.id}`"
|
|
|
|
|
|
class="feed-category-prompt-wrapper"
|
2026-06-16 10:28:10 +08:00
|
|
|
|
@click.stop
|
2026-06-16 10:15:36 +08:00
|
|
|
|
>
|
|
|
|
|
|
<!-- 标题行:标记已读的确认 + 1~2 条分类提示的容器 -->
|
2026-06-16 10:28:10 +08:00
|
|
|
|
<div class="feed-category-prompt-header" @click.stop>
|
2026-06-16 10:15:36 +08:00
|
|
|
|
<NSpace align="center" :size="8" :wrap="true">
|
|
|
|
|
|
<NTag type="success" size="small" round :bordered="false">✓ 已将《{{ a.title_zh || a.title }}》标记为已读</NTag>
|
|
|
|
|
|
</NSpace>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<NAlert
|
|
|
|
|
|
v-for="p in (categoryPromptsByArticle.get(a.id) || [])"
|
|
|
|
|
|
:key="`${a.id}-${p.category}`"
|
|
|
|
|
|
type="success"
|
|
|
|
|
|
:show-icon="false"
|
|
|
|
|
|
closable
|
|
|
|
|
|
@close="dismissPrompt(a.id, p.category)"
|
|
|
|
|
|
class="feed-category-prompt"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #header>
|
|
|
|
|
|
<NSpace align="center" :size="8" :wrap="true">
|
|
|
|
|
|
<NText>「{{ p.category }}」分类下还有 {{ p.unreadCount }} 条 24 小时未读</NText>
|
|
|
|
|
|
</NSpace>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<NSpace :size="8" style="margin-top: 8px">
|
|
|
|
|
|
<NButton
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
round
|
|
|
|
|
|
:loading="pendingCategory === p.category"
|
|
|
|
|
|
:disabled="pendingCategory !== null"
|
|
|
|
|
|
@click="confirmMarkCategory(a.id, p.category)"
|
|
|
|
|
|
>
|
|
|
|
|
|
全部已读
|
|
|
|
|
|
</NButton>
|
|
|
|
|
|
<NButton size="small" round @click="dismissPrompt(a.id, p.category)">
|
|
|
|
|
|
稍后再说
|
|
|
|
|
|
</NButton>
|
|
|
|
|
|
</NSpace>
|
|
|
|
|
|
</NAlert>
|
|
|
|
|
|
</div>
|
2026-06-07 21:51:01 +08:00
|
|
|
|
</NSpace>
|
|
|
|
|
|
</NCard>
|
2026-06-14 09:35:57 +08:00
|
|
|
|
</TransitionGroup>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- 页码分页 -->
|
|
|
|
|
|
<NSpace v-if="total > 0" justify="center" style="margin: 32px 0 24px">
|
|
|
|
|
|
<NPagination
|
|
|
|
|
|
v-model:page="page"
|
|
|
|
|
|
:page-count="totalPages"
|
|
|
|
|
|
:page-size="pageSize"
|
|
|
|
|
|
show-quick-jumper
|
|
|
|
|
|
@update:page="onPageChange"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</NSpace>
|
|
|
|
|
|
<NText v-else :depth="3" style="display:block; text-align:center; padding: 16px">— 暂无数据 —</NText>
|
2026-06-07 21:51:01 +08:00
|
|
|
|
</NSpin>
|
|
|
|
|
|
</NSpace>
|
|
|
|
|
|
</template>
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-06-12 23:37:48 +08:00
|
|
|
|
/* === 评论钩子(双 provider,三态视觉化) === */
|
|
|
|
|
|
.commentary-stack {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 6px;
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
margin-top: 10px;
|
2026-06-12 23:37:48 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.commentary-item {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.65;
|
|
|
|
|
|
min-height: 56px; /* 避免占位卡片太矮导致视觉跳 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* === OK 状态(有内容)== */
|
|
|
|
|
|
.commentary-item-ok {
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
background: var(--color-primary-soft);
|
|
|
|
|
|
border-left: 3px solid var(--color-primary);
|
2026-06-12 23:37:48 +08:00
|
|
|
|
color: var(--color-letter);
|
|
|
|
|
|
}
|
|
|
|
|
|
.commentary-item-ok .commentary-label {
|
|
|
|
|
|
color: var(--color-primary);
|
|
|
|
|
|
}
|
|
|
|
|
|
.commentary-item-ok .commentary-text {
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
color: var(--color-letter);
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
line-height: 1.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 23:37:48 +08:00
|
|
|
|
/* === 等待中(生成中)== */
|
|
|
|
|
|
.commentary-item-waiting {
|
|
|
|
|
|
background: #f0f4fa; /* 浅蓝底 */
|
|
|
|
|
|
border: 1.5px dashed #94a3b8; /* 虚线边框,提示"未完成" */
|
|
|
|
|
|
border-left: 4px solid #64748b; /* 左侧稍粗的灰条,跟 ok 状态一致位置 */
|
|
|
|
|
|
color: var(--color-text-faint);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.commentary-item-waiting .commentary-label {
|
|
|
|
|
|
color: #475569;
|
|
|
|
|
|
}
|
|
|
|
|
|
.commentary-item-waiting .commentary-text {
|
|
|
|
|
|
color: #64748b;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-style: italic;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* === 失败 === */
|
|
|
|
|
|
.commentary-item-failed {
|
|
|
|
|
|
background: #fef2f2; /* 浅红底 */
|
|
|
|
|
|
border: 1.5px solid #fca5a5;
|
|
|
|
|
|
border-left: 4px solid #ef4444;
|
|
|
|
|
|
color: #991b1b;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
.commentary-item-failed .commentary-label {
|
|
|
|
|
|
color: #dc2626;
|
|
|
|
|
|
}
|
|
|
|
|
|
.commentary-item-failed .commentary-text {
|
|
|
|
|
|
color: #b91c1c;
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
font-style: italic;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
.commentary-label {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 19:00:00 +08:00
|
|
|
|
.commentary-label-meituan {
|
|
|
|
|
|
color: #c2410c; /* 橙色,与 Angel 区分 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 23:37:48 +08:00
|
|
|
|
.commentary-text-failed,
|
2026-06-12 23:24:30 +08:00
|
|
|
|
.commentary-text-waiting {
|
|
|
|
|
|
font-size: 12px;
|
2026-06-12 23:37:48 +08:00
|
|
|
|
font-style: italic;
|
2026-06-12 23:24:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 23:37:48 +08:00
|
|
|
|
.commentary-head {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
margin-bottom: 4px;
|
2026-06-12 23:24:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-12 23:37:48 +08:00
|
|
|
|
.commentary-badge {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
padding: 1px 8px;
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
line-height: 1.4;
|
|
|
|
|
|
}
|
|
|
|
|
|
.commentary-badge-waiting {
|
|
|
|
|
|
background: #e2e8f0;
|
|
|
|
|
|
color: #334155;
|
|
|
|
|
|
}
|
|
|
|
|
|
.commentary-badge-failed {
|
|
|
|
|
|
background: #fee2e2;
|
|
|
|
|
|
color: #991b1b;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 旋转的 spinner(等待中) */
|
|
|
|
|
|
.commentary-spinner {
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
width: 10px;
|
|
|
|
|
|
height: 10px;
|
|
|
|
|
|
border: 1.5px solid currentColor;
|
|
|
|
|
|
border-top-color: transparent;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
animation: commentary-spin 0.8s linear infinite;
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes commentary-spin {
|
|
|
|
|
|
to { transform: rotate(360deg); }
|
2026-06-12 19:00:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 09:28:14 +08:00
|
|
|
|
/* ===== 桌面端默认宽度 ===== */
|
|
|
|
|
|
.feed-source-select {
|
|
|
|
|
|
min-width: 240px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-search-input {
|
|
|
|
|
|
width: 220px;
|
|
|
|
|
|
}
|
2026-06-15 18:26:35 +08:00
|
|
|
|
/* NAutoComplete 外层 div 不默认撑满,需要让内部 n-input 占满 */
|
|
|
|
|
|
.feed-search-input.n-auto-complete {
|
|
|
|
|
|
display: inline-block;
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-search-input.n-auto-complete > .n-input {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-15 19:42:40 +08:00
|
|
|
|
/* === 搜索建议下拉项(干净版,只显示词本身) === */
|
2026-06-15 18:26:35 +08:00
|
|
|
|
.feed-suggest-text {
|
2026-06-15 19:42:40 +08:00
|
|
|
|
display: block;
|
2026-06-15 18:26:35 +08:00
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
2026-06-15 19:37:40 +08:00
|
|
|
|
}
|
2026-06-11 09:28:14 +08:00
|
|
|
|
|
|
|
|
|
|
/* ===== 移动端(<= 768px):过滤条全宽,允许换行 ===== */
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.feed-source-select {
|
|
|
|
|
|
min-width: 0;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-search-input {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-toolbar-left > * {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-count-label {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
margin-top: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-toolbar {
|
|
|
|
|
|
align-items: stretch !important;
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-time-label {
|
|
|
|
|
|
margin-left: 0 !important;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
text-align: right;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
|
|
|
|
|
|
/* === 已读卡片视觉降级 === */
|
|
|
|
|
|
.article-card-read {
|
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
|
background: #fafafa;
|
|
|
|
|
|
}
|
|
|
|
|
|
.article-card-read :deep(.n-card-header) {
|
|
|
|
|
|
color: var(--color-text-faint);
|
|
|
|
|
|
}
|
|
|
|
|
|
.article-card-read .commentary-text {
|
|
|
|
|
|
color: var(--color-text-faint);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 16:15:21 +08:00
|
|
|
|
/* === 短新闻(API Push)卡片差异化 ===
|
|
|
|
|
|
长新闻:卡片色调不变
|
|
|
|
|
|
短新闻:淡蓝底 + 左侧 3px 蓝色竖线条,便于一眼区分
|
|
|
|
|
|
*/
|
|
|
|
|
|
.article-card.short-card {
|
|
|
|
|
|
background: #f6f9fc;
|
|
|
|
|
|
border-left: 3px solid #4f9eff;
|
|
|
|
|
|
}
|
|
|
|
|
|
.article-card.short-card:hover {
|
|
|
|
|
|
background: #eef4fb;
|
|
|
|
|
|
}
|
|
|
|
|
|
/* 已读 + 短新闻:已读底色优先,左边色条仍保留作为"短讯"标识 */
|
|
|
|
|
|
.article-card.short-card.article-card-read {
|
|
|
|
|
|
background: #fafafa;
|
|
|
|
|
|
border-left-color: #4f9eff;
|
|
|
|
|
|
border-left-width: 3px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 短新闻正文:保留换行 */
|
|
|
|
|
|
.short-body {
|
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
|
font-size: 13.5px;
|
|
|
|
|
|
line-height: 1.65;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* 短讯角标 */
|
|
|
|
|
|
.feed-short-tag {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(search): 搜索结果关键字高亮(标题/正文/评论)
Feed.vue 搜索 q 时,命中的关键字在卡片标题/正文/双 provider
评论预览里 <mark> 包裹高亮(暖黄底 + 加粗)。
实现:
- 新增 escapeHtml(text) — 防止 XSS(content 来自外部 RSS/ingest,
不可信;先 escape 再 replace,确保 <mark> 之外不会有任何原始
HTML 进入 DOM)
- 新增 highlightHtml(text, q) — 不区分大小写匹配,正则元字符
(.*+?^${}()|[]\\) 自动转义(避免用户搜 "*.x" 时被当 regex)
q 为空时返回纯 escape 文本(行为与原来 {{ }} 插值一致)
- 改造 previewCommentary(text, max, q) — 第三个参数 q 透传
highlightHtml
- 4 处渲染改 {{ }} -> v-html,传 highlightHtml(previewCommentary
(..., q)):
- 中文标题 + 原标题
- 正文摘要
- Angel 评论预览
- 美团评论预览
样式:
- .feed-list :deep(mark) 暖黄底 (#fff3a0) + inherit 父级文字色 +
padding 2px + 加粗
- :deep() 避免 Naive UI 组件 scoped 样式隔离问题
安全:
- 所有用户内容先 escapeHtml,再 replace
- <mark> 标签是 escape 之后才插入,不会引入新的 XSS 通道
- q 特殊字符转义,不构成 regex DoS
不影响:
- q 为空时(highlightHtml(text, '') = escapeHtml(text) 等价于
Vue 原生 {{ }} 自动 escape) - 非搜索场景行为完全不变
- waiting/failed 状态的评论不显示评论内容,不需高亮
- 短新闻正文也支持高亮(q 不空时,完整 5000 字都高亮匹配项)
无后端改动。
2026-06-15 07:32:39 +08:00
|
|
|
|
/* === 搜索关键字高亮(<mark> 标签)=== */
|
|
|
|
|
|
.feed-list :deep(mark) {
|
|
|
|
|
|
background: #fff3a0; /* 暖黄底,跟 Naive UI 主题协调 */
|
|
|
|
|
|
color: inherit; /* 保持父级文字色,不被 <mark> 默认色影响 */
|
|
|
|
|
|
padding: 0 2px;
|
|
|
|
|
|
border-radius: 2px;
|
|
|
|
|
|
font-weight: 600; /* 命中后略加粗,视觉强调 */
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 09:35:57 +08:00
|
|
|
|
/* === 底部操作栏(浮在卡片右下角)=== */
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
.feed-actions {
|
2026-06-14 09:35:57 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: flex-end;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
margin-top: 10px;
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
padding-top: 8px;
|
|
|
|
|
|
border-top: 1px dashed var(--color-primary-soft);
|
|
|
|
|
|
}
|
2026-06-14 09:35:57 +08:00
|
|
|
|
.feed-read-btn {
|
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
|
transition: all 0.2s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-read-btn:not(.feed-read-btn-read) {
|
|
|
|
|
|
background: linear-gradient(135deg, var(--color-primary) 0%, #4f7fd1 100%);
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
box-shadow: 0 2px 6px rgba(91, 134, 229, 0.25);
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-read-btn:not(.feed-read-btn-read):hover {
|
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
|
box-shadow: 0 4px 10px rgba(91, 134, 229, 0.35);
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-read-btn-read {
|
|
|
|
|
|
opacity: 0.7;
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-read-icon {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
margin-right: 2px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-read-icon-checked {
|
|
|
|
|
|
color: #16a34a;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(read): 已读功能 — 每账号标已读,列表默认隐藏
需求: 每个账号可标已读,已读过的文章刷新/重载后不在 24h feed 中显示。
设计:
- 新表 article_reads (user_id, article_id, read_at) 复合主键,on-delete CASCADE
- 迁移 0007_article_reads
- /me/reads/{id} POST/DELETE 标记 / 取消(幂等,PG upsert on_conflict_do_nothing)
- /me/reads GET 列出已读 IDs(默认 7 天,limit 500)
- articles.py 列表查询加 hide_read=true 参数(默认 true),用 NOT EXISTS 排除已读
- ArticleListItem / ArticleDetail schema 加 is_read 字段
前端:
- types 加 is_read + readsApi(mark/unmark/list)
- Feed 列表:
顶部加 '隐藏已读' 开关,默认 ON
每张卡片加 '标为已读 / 标为未读' 按钮(乐观更新,失败回滚)
已读卡片 opacity 0.7 + 灰背景,标识弱化
- ArticleDetail 详情页操作栏加 '标为已读' 按钮(同样乐观)
2026-06-13 21:04:47 +08:00
|
|
|
|
.feed-read-tag {
|
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-hideread-toggle {
|
|
|
|
|
|
margin-left: 4px;
|
|
|
|
|
|
}
|
2026-06-14 09:35:57 +08:00
|
|
|
|
|
|
|
|
|
|
/* === Feed 列表容器(给 TransitionGroup 用)== */
|
|
|
|
|
|
.feed-list {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* === 卡片进入/离开动画(标为已读后 hide_read 模式下滑出)== */
|
|
|
|
|
|
.card-enter-active,
|
|
|
|
|
|
.card-leave-active {
|
|
|
|
|
|
transition: opacity 0.3s ease, transform 0.35s cubic-bezier(0.55, 0, 0.55, 0.2),
|
|
|
|
|
|
max-height 0.35s cubic-bezier(0.55, 0, 0.55, 0.2),
|
|
|
|
|
|
margin 0.35s cubic-bezier(0.55, 0, 0.55, 0.2),
|
|
|
|
|
|
padding 0.35s cubic-bezier(0.55, 0, 0.55, 0.2);
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-enter-from {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: translateY(-12px);
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-leave-from {
|
|
|
|
|
|
/* 由 JS 在 before-leave 钩子注入 maxHeight 起点 */
|
|
|
|
|
|
opacity: 1;
|
|
|
|
|
|
transform: translateX(0) scale(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-leave-to {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: translateX(40px) scale(0.96);
|
|
|
|
|
|
max-height: 0 !important;
|
|
|
|
|
|
margin-top: 0 !important;
|
|
|
|
|
|
margin-bottom: 0 !important;
|
|
|
|
|
|
padding-top: 0 !important;
|
|
|
|
|
|
padding-bottom: 0 !important;
|
|
|
|
|
|
border-width: 0 !important;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-move {
|
|
|
|
|
|
transition: transform 0.35s ease;
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-enter-from {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: translateY(-12px);
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-leave-to {
|
|
|
|
|
|
opacity: 0;
|
|
|
|
|
|
transform: translateX(40px) scale(0.96);
|
|
|
|
|
|
max-height: 0;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
border-width: 0;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-leave-active {
|
|
|
|
|
|
/* max-height transition, 配合 transform 一起 */
|
|
|
|
|
|
max-height: 800px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-move {
|
|
|
|
|
|
transition: transform 0.35s ease;
|
|
|
|
|
|
}
|
2026-06-15 20:36:06 +08:00
|
|
|
|
|
2026-06-15 21:24:06 +08:00
|
|
|
|
/* === 分类批量已读提示条(per-article,紧贴卡片下方)===
|
2026-06-15 20:36:06 +08:00
|
|
|
|
- 浅绿渐变 + 左侧色条,跟已读视觉呼应但不抢戏
|
2026-06-15 21:24:06 +08:00
|
|
|
|
- wrapper 自身不带动画,内部 NAlert 走 card 动画(因为是 TransitionGroup 子节点)
|
|
|
|
|
|
- wrapper 与 NCard 之间有视觉分隔(margin-top),但因为它在 TransitionGroup 内
|
|
|
|
|
|
共享 name="card" 的 leave 动画,卡片滑出时 wrapper 同步滑出,看起来"提示条跟着卡片走"
|
2026-06-15 20:36:06 +08:00
|
|
|
|
*/
|
2026-06-15 21:24:06 +08:00
|
|
|
|
.feed-category-prompt-wrapper {
|
2026-06-15 20:36:06 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 8px;
|
2026-06-16 10:17:42 +08:00
|
|
|
|
margin-top: 12px;
|
2026-06-15 20:36:06 +08:00
|
|
|
|
}
|
2026-06-16 07:48:16 +08:00
|
|
|
|
/* 标题行(标已读的确认)— 跟下面的 NAlert 视觉分层 */
|
|
|
|
|
|
.feed-category-prompt-header {
|
|
|
|
|
|
padding: 6px 4px 0;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
color: var(--color-text-faint);
|
|
|
|
|
|
}
|
2026-06-15 20:36:06 +08:00
|
|
|
|
.feed-category-prompt {
|
|
|
|
|
|
background: linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 100%);
|
|
|
|
|
|
border-left: 3px solid var(--color-primary, #5b86e5);
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.feed-category-prompt :deep(.n-alert__header) {
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
}
|
style(web): 改暖色木色调,跟 Android 端对齐
Web 端原本是冷色蓝调(#2080f0 + #fafbfc),
Android 端已经是暖色木色(#8B6B45 + #F5E9D0),两端不一致。
统一到暖色木色调(跟 logo "D" 木质方块的视觉延伸):
style.css 重写:
- CSS 变量: --color-primary #8B6B45, --color-bg #F5E9D0 等
- 字体栈: serif 用于标题(Georgia / Songti), sans-serif 用于正文
- 卡片: 圆角 12px, 边框 + 浅阴影, hover 浮起 1px
- 滚动条: 木色风格
- 按钮 / 分页 / TopBar 主题色统一
Feed.vue:
- 中文标题字体 18px -> 20px, 字重 600 -> 700
- 原标题字号 13, 颜色淡木色
- 插图圆角 4px -> 8px, 高度自适应 max 280px
- 评论钩子: 淡木色背景 + 木色左边框 3px(与 Android 一致)
- 标签全部用 round
ArticleDetail.vue:
- 中文主标题大字号, 衬线粗体
- 原标题灰色辅助
- 操作按钮全部 round
- 卡片标题统一用 serif
Login.vue:
- 登录卡 圆角 16px, 木色渐变背景
- 标题用 serif, 按钮 round
未提交 Android 端改动 — 在 D:/selftools/diary-news-android/ 独立目录,
会重新 build APK 后单独交付。
2026-06-10 18:12:05 +08:00
|
|
|
|
</style>
|