Compare commits
3 Commits
3f183d14db
...
4b8d776aac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b8d776aac | ||
|
|
d90c5955f5 | ||
|
|
2e0e5ea80c |
255
backend/app/scripts/retranslate_history.py
Normal file
255
backend/app/scripts/retranslate_history.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""批量重译历史文章。
|
||||
|
||||
用途:
|
||||
- 翻译链切换后(智谱/星火/腾讯 TMT 顺序调整),让所有历史文章走新链重译
|
||||
- 发现"翻译失败 / 译文明显退化 / 译文缺失"的文章,统一重跑
|
||||
|
||||
模式:
|
||||
- soft(默认):只把 translation_status 改回 pending,worker 会按新链重译;
|
||||
保留 LLM 排版 / 分类 / 评论 / 插图(避免无谓重跑 LLM)
|
||||
- hard:显式清空所有译文相关字段 + 标 pending,排版/分类/插图/评论也清掉,
|
||||
等于把这篇当新文章处理(慎用,会重跑 enrich)
|
||||
|
||||
判定"翻译是否失败"(is_bad_translation):
|
||||
- status 字段层面: pending / failed / partial
|
||||
- 内容层面启发式:
|
||||
- title_zh 为空
|
||||
- body_zh_text 为空
|
||||
- 译文里有 [本条未翻译 / [翻译失败 等标记
|
||||
- body_zh_text 跟 body_text 完全一样(疑似未翻)
|
||||
- 长文译文里中文比例 < 30%(几乎没翻译)
|
||||
|
||||
用法(在 worker 容器里):
|
||||
# 仅预览,不动
|
||||
docker compose exec worker python -m app.scripts.retranslate_history --dry-run
|
||||
|
||||
# 软重译,全量
|
||||
docker compose exec worker python -m app.scripts.retranslate_history --mode soft
|
||||
|
||||
# 软重译,先试 50 篇
|
||||
docker compose exec worker python -m app.scripts.retranslate_history --limit 50
|
||||
|
||||
# 硬重译,限定源
|
||||
docker compose exec worker python -m app.scripts.retranslate_history \\
|
||||
--mode hard --source-slug bbc-world --limit 100
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.article import Article
|
||||
from app.models.source import Source
|
||||
|
||||
logger = logging.getLogger("news.scripts.retranslate_history")
|
||||
|
||||
|
||||
# 译文"翻车"标记(从 service.py / tencent.py 残留 marker 推断)
|
||||
_BAD_MARKERS = [
|
||||
"[本条未翻译",
|
||||
"[翻译失败",
|
||||
"未翻译:",
|
||||
"Translation failed",
|
||||
"translation failed",
|
||||
]
|
||||
|
||||
|
||||
def _is_chinese_char(ch: str) -> bool:
|
||||
"""粗略判断一个字符是否落在 CJK 范围。"""
|
||||
cp = ord(ch)
|
||||
return (
|
||||
0x4E00 <= cp <= 0x9FFF # CJK Unified
|
||||
or 0x3400 <= cp <= 0x4DBF # CJK Extension A
|
||||
or 0x20000 <= cp <= 0x2A6DF # CJK Extension B
|
||||
or 0x3040 <= cp <= 0x309F # Hiragana
|
||||
or 0x30A0 <= cp <= 0x30FF # Katakana
|
||||
or 0xFF00 <= cp <= 0xFFEF # Fullwidth
|
||||
)
|
||||
|
||||
|
||||
def _chinese_ratio(text: str) -> float:
|
||||
if not text:
|
||||
return 0.0
|
||||
cnt = sum(1 for ch in text if _is_chinese_char(ch))
|
||||
return cnt / max(1, len(text))
|
||||
|
||||
|
||||
def is_bad_translation(art: Article) -> tuple[bool, str]:
|
||||
"""判断一条文章是否需要重译。返回 (need, reason)。"""
|
||||
# 1) 状态层面
|
||||
if art.translation_status in ("pending", "failed", "partial"):
|
||||
return True, f"status={art.translation_status}"
|
||||
|
||||
# 2) status=ok 但内容缺失(可能是写入失败,或者老数据没填)
|
||||
if not art.title_zh or not art.title_zh.strip():
|
||||
return True, "title_zh empty"
|
||||
if not art.body_zh_text or not art.body_zh_text.strip():
|
||||
return True, "body_zh_text empty"
|
||||
|
||||
# 3) 译文里有"翻车"标记
|
||||
haystack = (art.title_zh or "") + "\n" + (art.body_zh_text or "")
|
||||
for marker in _BAD_MARKERS:
|
||||
if marker in haystack:
|
||||
return True, f"contains marker '{marker}'"
|
||||
|
||||
# 4) 译文跟原文完全一样(疑似没翻)
|
||||
# 仅在原文是英文/日文时判定;原文中文时不需重译
|
||||
if art.body_text and art.body_zh_text:
|
||||
same = art.body_zh_text.strip() == art.body_text.strip()
|
||||
if same:
|
||||
src_ratio = _chinese_ratio(art.body_text)
|
||||
# 原文几乎无中文 = 几乎肯定是外文
|
||||
if src_ratio < 0.05:
|
||||
return True, "translation identical to source (likely untranslated)"
|
||||
|
||||
# 5) 译文"几乎全是英文/日文"(短文翻译失败回退到原文)
|
||||
zh = art.body_zh_text
|
||||
if len(zh) > 200 and _chinese_ratio(zh) < 0.30:
|
||||
return True, f"translation low Chinese ratio ({_chinese_ratio(zh):.0%})"
|
||||
|
||||
return False, "ok"
|
||||
|
||||
|
||||
async def scan_bad_articles(
|
||||
*,
|
||||
source_slug: str | None,
|
||||
limit: int | None,
|
||||
) -> list[tuple[Article, str]]:
|
||||
"""扫描需要重译的文章,返回 (article, reason) 列表。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
stmt = select(Article).order_by(Article.id.asc())
|
||||
if source_slug:
|
||||
src = (
|
||||
await session.execute(select(Source).where(Source.slug == source_slug))
|
||||
).scalar_one_or_none()
|
||||
if not src:
|
||||
print(f"!! source_slug '{source_slug}' 不存在", file=sys.stderr)
|
||||
return []
|
||||
stmt = stmt.where(Article.source_id == src.id)
|
||||
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
|
||||
bad: list[tuple[Article, str]] = []
|
||||
for art in rows:
|
||||
need, reason = is_bad_translation(art)
|
||||
if need:
|
||||
bad.append((art, reason))
|
||||
if limit is not None and len(bad) >= limit:
|
||||
break
|
||||
return bad
|
||||
|
||||
|
||||
async def retranslate_articles(
|
||||
articles: Iterable[Article],
|
||||
*,
|
||||
mode: str,
|
||||
dry_run: bool,
|
||||
) -> int:
|
||||
"""把需要重译的文章状态改回 pending(soft/hard),等 worker 接手。
|
||||
|
||||
返回实际改动行数。
|
||||
"""
|
||||
if mode not in ("soft", "hard"):
|
||||
print(f"!! 未知 mode '{mode}'(可选: soft / hard)", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
changed = 0
|
||||
async with AsyncSessionLocal() as session:
|
||||
for art in articles:
|
||||
if dry_run:
|
||||
print(
|
||||
f"[DRY] id={art.id:>6} status={art.translation_status:<8} "
|
||||
f"engine={art.translation_engine or '-':<12} source_id={art.source_id}"
|
||||
)
|
||||
changed += 1
|
||||
continue
|
||||
|
||||
art.translation_status = "pending"
|
||||
if mode == "hard":
|
||||
# 硬重译:清空所有译文相关字段,等 worker 重新跑
|
||||
art.title_zh = None
|
||||
art.body_zh_text = None
|
||||
art.body_zh_html = None
|
||||
art.summary_zh = None
|
||||
art.translation_engine = None
|
||||
art.translation_chars = 0
|
||||
art.translated_at = None
|
||||
# 注:enrichment 字段(format/commentary/image_ai)故意保留 —
|
||||
# 它们跟翻译链无关,清掉会浪费 LLM 调用
|
||||
# 如果用户想要完全重跑,需要手动调 /admin/translation/rerun
|
||||
# 提交(批量 commit,避免循环里 round-trip)
|
||||
await session.flush()
|
||||
changed += 1
|
||||
if not dry_run:
|
||||
await session.commit()
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
async def main() -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="扫描并标记需要重译的历史文章",
|
||||
)
|
||||
p.add_argument(
|
||||
"--mode",
|
||||
choices=["soft", "hard"],
|
||||
default="soft",
|
||||
help="soft=只改 status 回 pending(默认);hard=清空所有译文相关字段",
|
||||
)
|
||||
p.add_argument(
|
||||
"--source-slug",
|
||||
default=None,
|
||||
help="限定某个采集源(按 slug)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=None,
|
||||
help="最多处理多少条(用于分批,避免一次性塞爆队列)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="只打印待重译列表,不动数据库",
|
||||
)
|
||||
p.add_argument(
|
||||
"--show-stats",
|
||||
action="store_true",
|
||||
help="额外按 reason 分组统计(配合 --dry-run 使用)",
|
||||
)
|
||||
args = p.parse_args()
|
||||
|
||||
# 1) 扫描
|
||||
bad = await scan_bad_articles(source_slug=args.source_slug, limit=args.limit)
|
||||
if not bad:
|
||||
print("✅ 没有发现需要重译的文章")
|
||||
return 0
|
||||
|
||||
print(f"发现 {len(bad)} 条需要重译的文章(模式={args.mode}, source={args.source_slug or 'ALL'})")
|
||||
if args.show_stats:
|
||||
from collections import Counter
|
||||
stats = Counter(reason for _, reason in bad)
|
||||
for reason, cnt in stats.most_common():
|
||||
print(f" - {reason:<48} {cnt:>4}")
|
||||
|
||||
# 2) 处理
|
||||
articles = [art for art, _ in bad]
|
||||
changed = await retranslate_articles(articles, mode=args.mode, dry_run=args.dry_run)
|
||||
|
||||
if args.dry_run:
|
||||
print(f"\n[DRY-RUN] 共 {changed} 条,实际未改动。去掉 --dry-run 真正执行。")
|
||||
else:
|
||||
print(f"\n✅ 已将 {changed} 条标为 pending,等 worker 拉起重译。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
||||
sys.exit(asyncio.run(main()))
|
||||
@@ -1,8 +1,8 @@
|
||||
"""翻译服务门面:配额检查 + 缓存 + 引擎选择 + 月度计数。
|
||||
|
||||
引擎链路(优先级降序):
|
||||
1. spark(主,Lite 免费;spark_api_password 配了才用)
|
||||
2. zhipu(第二序位,GLM-4-Flash 免费;zhipu_api_key 配了才用)
|
||||
1. zhipu(主,GLM-4-Flash 免费;zhipu_api_key 配了才用)
|
||||
2. spark(第二序位,Lite 免费;spark_api_password 配了才用)
|
||||
3. tencent TMT(第三级,按月配额;快满时主动切走)
|
||||
4. tencent_maas(备用,OpenAI 兼容,无配额;主失败/TMT 配额耗尽时启用)
|
||||
5. agnes(第四级,通用 LLM 做翻译;MaaS 不可用时启用 — 质量次之但够用)
|
||||
@@ -12,8 +12,9 @@
|
||||
- TMT 是按月计费的(腾讯云后台可能计费口径是请求字节,我们 redis 累加的是字符数,
|
||||
差异约 2-3x);用户从腾讯云后台看"已用 2M"时,我们 redis 显示约 80 万字符
|
||||
- 用户决策:以腾讯云后台数字为准,快满时降级
|
||||
- spark / zhipu 都是免费模型,默认优先;不可用时降级到 tencent(继续吃配额)。
|
||||
- zhipu / spark 都是免费模型,默认优先;不可用时降级到 tencent(继续吃配额)。
|
||||
想要完全绕开 tencent,把 TENCENTCLOUD_SECRET_ID 留空即可。
|
||||
- 智谱放第一是因为它家 GLM-4-Flash 翻译质量比星火 Lite 更稳,星火降为二级
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -58,18 +59,8 @@ class TranslationService:
|
||||
# 串行:1 个并发;避免触发腾讯 TMT 限速
|
||||
self._sem = asyncio.Semaphore(1)
|
||||
|
||||
def _spark_translator(self) -> BaseTranslator | None:
|
||||
"""主引擎:星火 Spark(Lite 免费)。配了 spark_api_password 才启用。"""
|
||||
if self._spark is None and settings.spark_api_password:
|
||||
try:
|
||||
self._spark = SparkTranslator()
|
||||
except Exception as e:
|
||||
logger.warning("spark init failed: %s", e)
|
||||
self._spark = None
|
||||
return self._spark
|
||||
|
||||
def _zhipu_translator(self) -> BaseTranslator | None:
|
||||
"""第二序位引擎:智谱 GLM(免费)。配了 zhipu_api_key 才启用。"""
|
||||
"""主引擎:智谱 GLM(免费)。配了 zhipu_api_key 才启用。"""
|
||||
if self._zhipu is None and settings.zhipu_api_key:
|
||||
try:
|
||||
self._zhipu = ZhipuTranslator()
|
||||
@@ -78,6 +69,16 @@ class TranslationService:
|
||||
self._zhipu = None
|
||||
return self._zhipu
|
||||
|
||||
def _spark_translator(self) -> BaseTranslator | None:
|
||||
"""第二序位引擎:星火 Spark(Lite 免费)。配了 spark_api_password 才启用。"""
|
||||
if self._spark is None and settings.spark_api_password:
|
||||
try:
|
||||
self._spark = SparkTranslator()
|
||||
except Exception as e:
|
||||
logger.warning("spark init failed: %s", e)
|
||||
self._spark = None
|
||||
return self._spark
|
||||
|
||||
def _primary(self) -> BaseTranslator | None:
|
||||
"""第三级:腾讯 TMT(初始化失败返回 None 表示不可用)。"""
|
||||
if self._tencent is None:
|
||||
@@ -168,12 +169,12 @@ class TranslationService:
|
||||
return TranslationResult(text=cached, engine="cache", chars=chars, cached=True)
|
||||
|
||||
# 2) 选引擎
|
||||
# 优先级:spark → zhipu → tencent(配额)→ maas → agnes → local
|
||||
# 优先级:zhipu → spark → tencent(配额)→ maas → agnes → local
|
||||
engine: BaseTranslator | None = None
|
||||
if self._spark_translator() is not None:
|
||||
engine = self._spark_translator()
|
||||
elif self._zhipu_translator() is not None:
|
||||
if self._zhipu_translator() is not None:
|
||||
engine = self._zhipu_translator()
|
||||
elif self._spark_translator() is not None:
|
||||
engine = self._spark_translator()
|
||||
elif await self.can_use_tencent(chars):
|
||||
engine = self._primary()
|
||||
if engine is None:
|
||||
@@ -204,17 +205,17 @@ class TranslationService:
|
||||
res = await engine.translate(text, source=source, target=target)
|
||||
except Exception as e:
|
||||
logger.exception("translate failed with %s: %s", engine.name, e)
|
||||
# 失败时按 zhipu → tencent → maas → local 顺序找一个不同的 fallback
|
||||
# spark / zhipu 失败时也要走 tencent(继续吃配额,因优先级只是降低不是禁用)
|
||||
# 失败时按 spark → tencent → maas → local 顺序找一个不同的 fallback
|
||||
# zhipu / spark 失败时也要走 tencent(继续吃配额,因优先级只是降低不是禁用)
|
||||
fb: BaseTranslator | None = None
|
||||
if engine.name == "spark":
|
||||
if self._zhipu_translator() is not None:
|
||||
fb = self._zhipu_translator()
|
||||
if engine.name == "zhipu":
|
||||
if self._spark_translator() is not None:
|
||||
fb = self._spark_translator()
|
||||
if fb is None and await self.can_use_tencent(chars):
|
||||
fb = self._primary()
|
||||
if fb is None:
|
||||
fb = self._maas() if engine.name != "tencent_maas" else None
|
||||
elif engine.name == "zhipu":
|
||||
elif engine.name == "spark":
|
||||
if await self.can_use_tencent(chars):
|
||||
fb = self._primary()
|
||||
if fb is None:
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { computed, h, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter, RouterView, RouterLink } from 'vue-router'
|
||||
import {
|
||||
NLayout, NLayoutHeader, NLayoutContent, NLayoutSider,
|
||||
NMenu, NIcon, NSpace, NButton, NText, NTag, NAvatar, NDropdown, useMessage,
|
||||
NMenu, NIcon, NSpace, NButton, NText, NTag, NAvatar, NDropdown, NDrawer, NDrawerContent, useMessage,
|
||||
} from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { meApi } from '@/api/articles'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
@@ -15,6 +14,22 @@ const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const usage = ref<any>(null)
|
||||
const isMobile = ref(false)
|
||||
const drawerOpen = ref(false)
|
||||
|
||||
// 监听屏幕宽度,断点 768px
|
||||
function syncBreakpoint() {
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
syncBreakpoint()
|
||||
window.addEventListener('resize', syncBreakpoint)
|
||||
try { usage.value = await meApi.usage() } catch { /* noop */ }
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', syncBreakpoint)
|
||||
})
|
||||
|
||||
const menu = computed(() => [
|
||||
{ key: '/', label: '24h 列表', icon: () => '📰' },
|
||||
@@ -30,11 +45,6 @@ const userMenu = computed(() => [
|
||||
{ key: 'logout', label: '退出登录' },
|
||||
])
|
||||
|
||||
async function fetchUsage() {
|
||||
try { usage.value = await meApi.usage() } catch { /* noop */ }
|
||||
}
|
||||
onMounted(fetchUsage)
|
||||
|
||||
function onUserMenu(key: string) {
|
||||
if (key === 'logout') {
|
||||
auth.logout()
|
||||
@@ -45,30 +55,52 @@ function onUserMenu(key: string) {
|
||||
|
||||
function onMenu(key: string) {
|
||||
router.push(key)
|
||||
// 手机端:点完菜单自动关闭抽屉
|
||||
if (isMobile.value) drawerOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NLayout style="min-height: 100vh">
|
||||
<NLayoutHeader bordered style="padding: 12px 24px">
|
||||
<NSpace align="center" justify="space-between">
|
||||
<NSpace align="center">
|
||||
<NSpace align="center" justify="space-between" :wrap="true" :size="[8, 8]">
|
||||
<NSpace align="center" :size="8" :wrap="false">
|
||||
<NButton
|
||||
v-if="isMobile"
|
||||
quaternary
|
||||
circle
|
||||
class="mobile-menu-btn"
|
||||
aria-label="菜单"
|
||||
@click="drawerOpen = true"
|
||||
>
|
||||
☰
|
||||
</NButton>
|
||||
<span style="font-size: 20px; font-weight: 700">📚 Diary News</span>
|
||||
<NTag v-if="usage" size="small" :type="usage.pct_used > 80 ? 'warning' : 'default'">
|
||||
<NTag v-if="usage && !isMobile" size="small" :type="usage.pct_used > 80 ? 'warning' : 'default'">
|
||||
翻译: {{ usage.used_chars.toLocaleString() }} / {{ usage.quota_chars.toLocaleString() }}
|
||||
({{ usage.pct_used.toFixed(1) }}%)
|
||||
</NTag>
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NText v-if="auth.user">{{ auth.user.username }} ({{ auth.user.role }})</NText>
|
||||
<NSpace :size="8" :wrap="false">
|
||||
<NText v-if="auth.user" style="font-size: 13px" class="mobile-hide">
|
||||
{{ auth.user.username }} ({{ auth.user.role }})
|
||||
</NText>
|
||||
<NDropdown :options="userMenu" trigger="click" @select="onUserMenu">
|
||||
<NButton quaternary>账号 ▾</NButton>
|
||||
</NDropdown>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
<!-- 手机端:配额 tag 放到第二行,避免挤掉菜单按钮 -->
|
||||
<div v-if="usage && isMobile" style="margin-top: 6px">
|
||||
<NTag size="small" :type="usage.pct_used > 80 ? 'warning' : 'default'">
|
||||
翻译: {{ usage.used_chars.toLocaleString() }} / {{ usage.quota_chars.toLocaleString() }}
|
||||
({{ usage.pct_used.toFixed(1) }}%)
|
||||
</NTag>
|
||||
</div>
|
||||
</NLayoutHeader>
|
||||
|
||||
<NLayout has-sider style="min-height: calc(100vh - 60px)">
|
||||
<!-- 桌面:固定侧栏 -->
|
||||
<NLayout v-if="!isMobile" has-sider style="min-height: calc(100vh - 60px)">
|
||||
<NLayoutSider bordered :width="220" :native-scrollbar="false">
|
||||
<NMenu
|
||||
:value="route.path"
|
||||
@@ -81,5 +113,28 @@ function onMenu(key: string) {
|
||||
<RouterView />
|
||||
</NLayoutContent>
|
||||
</NLayout>
|
||||
|
||||
<!-- 手机:无侧栏,菜单用抽屉;内容全宽 -->
|
||||
<NLayoutContent v-else style="padding: 0; width: 100%;">
|
||||
<RouterView />
|
||||
</NLayoutContent>
|
||||
|
||||
<!-- 手机端抽屉式侧栏 -->
|
||||
<NDrawer v-if="isMobile" v-model:show="drawerOpen" :width="260" placement="left">
|
||||
<NDrawerContent title="📚 Diary News" closable>
|
||||
<NMenu
|
||||
:value="route.path"
|
||||
:options="menu"
|
||||
@update:value="onMenu"
|
||||
/>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</NLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-menu-btn {
|
||||
font-size: 20px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -68,6 +68,88 @@ h1 { font-size: 28px; font-weight: 700; line-height: 1.3; }
|
||||
h2 { font-size: 22px; font-weight: 700; line-height: 1.35; }
|
||||
h3 { font-size: 18px; font-weight: 700; line-height: 1.4; }
|
||||
|
||||
/* ============================================================
|
||||
* 手机端适配
|
||||
*
|
||||
* 触发断点:
|
||||
* - 768px:pad / 小屏(侧栏改成抽屉)
|
||||
* - 480px:手机竖屏(进一步压缩 padding / 字号)
|
||||
*
|
||||
* 改动:
|
||||
* 1) 容器 padding 缩小,正文最大宽度限制放宽
|
||||
* 2) 卡片内边距 / 标题字号 / 评论钩子字号按档位缩
|
||||
* 3) 文章正文段间距、行高按手机阅读体验调
|
||||
* 4) 标签 / Tag 不再强制 nowrap(允许换行,避免溢出)
|
||||
* 5) 顶栏 NSpace 在小屏允许换行,过滤区也允许 wrap
|
||||
* ============================================================ */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--max-width: 100%;
|
||||
}
|
||||
|
||||
/* 顶栏/正文容器 padding 收紧 */
|
||||
.n-layout-header,
|
||||
.n-layout-header.n-layout-header--absolute-positioned {
|
||||
padding: 10px 14px !important;
|
||||
}
|
||||
.n-layout-content {
|
||||
padding: 14px !important;
|
||||
}
|
||||
|
||||
/* 卡片 padding 缩 25% */
|
||||
.n-card.article-card .n-card__content {
|
||||
padding: 12px 14px !important;
|
||||
}
|
||||
|
||||
/* 标题字号档位降 1 档 */
|
||||
h1 { font-size: 22px; line-height: 1.35; }
|
||||
h2 { font-size: 20px; line-height: 1.4; }
|
||||
h3 { font-size: 17px; line-height: 1.45; }
|
||||
|
||||
/* 文章正文:行高微调(屏幕窄,行高再松一点) */
|
||||
.article-body { font-size: 16px; line-height: 1.8; }
|
||||
.diary-para { font-size: 16px; line-height: 1.8; }
|
||||
|
||||
/* 评论钩子字号微调 */
|
||||
.commentary-box { font-size: 13px; padding: 10px 12px; }
|
||||
.commentary-text { font-size: 13px; }
|
||||
|
||||
/* 详情页字段 */
|
||||
.commentary-text-detail { font-size: 14.5px; line-height: 1.8; }
|
||||
.article-body-fallback { font-size: 15px; line-height: 1.8; }
|
||||
|
||||
/* 顶栏字号 / 配额 tag */
|
||||
.n-layout-header span[style*="font-size: 20px"] {
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
/* 滚动条:手机端太窄,隐藏 */
|
||||
::-webkit-scrollbar { width: 0; height: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.n-card.article-card .n-card__content {
|
||||
padding: 10px 12px !important;
|
||||
}
|
||||
h1 { font-size: 20px; }
|
||||
.article-body { font-size: 15.5px; }
|
||||
.diary-para { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
/* 工具类:手机端隐藏侧栏相关装饰 */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-hide {
|
||||
display: none !important;
|
||||
}
|
||||
.mobile-stack {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
.mobile-full-width {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 文章卡片 ===== */
|
||||
.n-card.article-card {
|
||||
margin-bottom: 16px;
|
||||
|
||||
@@ -114,7 +114,7 @@ onMounted(load)
|
||||
<NCard class="article-detail-card">
|
||||
<NSpace vertical :size="14">
|
||||
<!-- tag 行 -->
|
||||
<NSpace align="center" :size="6" :wrap="false" style="overflow: hidden">
|
||||
<NSpace align="center" :size="6" :wrap="true" style="row-gap: 6px">
|
||||
<NTag type="primary" :bordered="false" round>{{ article.source.name }}</NTag>
|
||||
<NTag v-if="article.lang_src" :bordered="false" round>{{ article.lang_src.toUpperCase() }}</NTag>
|
||||
<NTag v-if="article.translation_status !== 'ok'" size="small" type="warning" :bordered="false" round>
|
||||
@@ -126,7 +126,7 @@ onMounted(load)
|
||||
<NTag v-for="c in categories" :key="c" type="success" size="small" :bordered="false" round>
|
||||
{{ c }}
|
||||
</NTag>
|
||||
<NText :depth="3" style="font-size: 12px; margin-left: auto">
|
||||
<NText :depth="3" style="font-size: 12px; margin-left: auto" class="detail-time-label">
|
||||
{{ fmtTime(publishedAt) }}
|
||||
</NText>
|
||||
</NSpace>
|
||||
@@ -148,7 +148,7 @@ onMounted(load)
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮行 -->
|
||||
<NSpace :size="8" :wrap="false">
|
||||
<NSpace :size="8" :wrap="true" style="row-gap: 8px">
|
||||
<NButton
|
||||
:type="starred ? 'warning' : 'primary'"
|
||||
:ghost="!starred"
|
||||
@@ -304,4 +304,16 @@ onMounted(load)
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-variant);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.detail-time-label {
|
||||
margin-left: 0 !important;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
/* 详情页头部操作按钮在手机上等宽更整齐 */
|
||||
:deep(.n-card .n-space) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -116,22 +116,22 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<NSpace align="center" justify="space-between">
|
||||
<NSpace :size="10">
|
||||
<NSpace align="center" justify="space-between" :wrap="true" :size="[10, 10]" class="feed-toolbar">
|
||||
<NSpace :size="10" :wrap="true" class="feed-toolbar-left">
|
||||
<NSelect
|
||||
v-model:value="sourceFilter"
|
||||
multiple
|
||||
clearable
|
||||
placeholder="按源筛选"
|
||||
:options="sourceOptions"
|
||||
style="min-width: 240px"
|
||||
class="feed-source-select"
|
||||
@update:value="resetToFirstPage"
|
||||
/>
|
||||
<NInput v-model:value="q" placeholder="关键词搜索" clearable style="width: 220px"
|
||||
<NInput v-model:value="q" placeholder="关键词搜索" clearable class="feed-search-input"
|
||||
@keyup.enter="resetToFirstPage" @clear="resetToFirstPage" />
|
||||
<NButton type="primary" @click="resetToFirstPage">刷新</NButton>
|
||||
<NButton type="primary" @click="resetToFirstPage" round>刷新</NButton>
|
||||
</NSpace>
|
||||
<NText :depth="3" style="font-size: 13px">{{ itemsLabel }}</NText>
|
||||
<NText :depth="3" style="font-size: 13px" class="feed-count-label">{{ itemsLabel }}</NText>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="loading && items.length === 0">
|
||||
@@ -147,7 +147,7 @@ onMounted(async () => {
|
||||
>
|
||||
<NSpace vertical :size="10">
|
||||
<!-- 顶行:源 / 语言 / 分类 tag / 时间 -->
|
||||
<NSpace align="center" :size="6" :wrap="false" style="overflow: hidden">
|
||||
<NSpace align="center" :size="6" :wrap="true" style="row-gap: 6px">
|
||||
<NTag size="small" type="primary" :bordered="false" round>
|
||||
{{ a.source.name }}
|
||||
</NTag>
|
||||
@@ -167,7 +167,7 @@ onMounted(async () => {
|
||||
>
|
||||
{{ c }}
|
||||
</NTag>
|
||||
<NText :depth="3" style="font-size: 12px; margin-left: auto">
|
||||
<NText :depth="3" style="font-size: 12px; margin-left: auto" class="feed-time-label">
|
||||
{{ fmtTime(a.published_at || a.fetched_at) }}
|
||||
</NText>
|
||||
</NSpace>
|
||||
@@ -282,4 +282,39 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ===== 桌面端默认宽度 ===== */
|
||||
.feed-source-select {
|
||||
min-width: 240px;
|
||||
}
|
||||
.feed-search-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
/* ===== 移动端(<= 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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user