Compare commits

..

3 Commits

6 changed files with 489 additions and 49 deletions

View 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()))

View File

@@ -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:

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>