重构前端:临床仪表盘主题 + AI日报编辑版式

- 全面采用临床数据仪表盘风格:IBM Plex Mono/Sans、teal 主色、无阴影设计
- theme.css 完整重写设计 token,支持亮色/暗色/跟随系统三挡切换
- ThemeControls 简化为图标三键切换组件
- NewsCard 临床风格重排:ALL CAPS 分类标签、INSIGHT 观点块
- NewsReader 精选栏:时间轴布局,蓝点 + 垂直轨道线
- AI日报全新排版:左侧 ARCHIVE 侧边栏 + 主区编辑版式
  - 报头:中文数字日期、星期、PHARMA INTEL tagline
  - 分节:52px teal 编号 + 22px 中文分类 + 英文副标题
  - 内容卡片:圆角12px,teal 标题,chips 行(来源/时间/评分)
  - 页脚统计:STORIES / PROCESSED / SOURCES
- 全响应式:≤900px 平板、≤640px 移动端自适应

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:18:36 +08:00
parent 264f00c138
commit 1b7210de4f
9 changed files with 1191 additions and 319 deletions

19
.claude/launch.json Normal file
View File

@@ -0,0 +1,19 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "Frontend (Vite)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "frontend",
"port": 5173
},
{
"name": "Backend (FastAPI/uvicorn)",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"],
"cwd": "backend",
"port": 8000
}
]
}

View File

@@ -4,9 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>医药情报</title> <title>医药情报</title>
<!-- 国内可将 Google Fonts 替换为 fonts.loli.net 镜像 -->
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet"> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -1,3 +1,49 @@
<template> <template>
<router-view /> <router-view v-slot="{ Component }">
<component
:is="Component"
:theme-mode="themeMode"
:resolved-theme="resolvedTheme"
@set-theme-mode="setThemeMode"
/>
</router-view>
</template> </template>
<script setup>
import { onMounted, onUnmounted, ref, watch } from 'vue'
const STORAGE_KEY = 'theme-mode'
const themeMode = ref(localStorage.getItem(STORAGE_KEY) || 'system')
const resolvedTheme = ref('light')
let mediaQuery
function getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function applyTheme() {
const nextTheme = themeMode.value === 'system' ? getSystemTheme() : themeMode.value
resolvedTheme.value = nextTheme
document.documentElement.dataset.theme = nextTheme
document.documentElement.style.colorScheme = nextTheme
}
function setThemeMode(mode) {
themeMode.value = mode
}
watch(themeMode, (mode) => {
localStorage.setItem(STORAGE_KEY, mode)
applyTheme()
})
onMounted(() => {
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
mediaQuery.addEventListener('change', applyTheme)
applyTheme()
})
onUnmounted(() => {
mediaQuery?.removeEventListener('change', applyTheme)
})
</script>

View File

@@ -9,13 +9,13 @@
<h3 class="card-title">{{ news.title_zh }}</h3> <h3 class="card-title">{{ news.title_zh }}</h3>
<p class="card-summary">{{ news.summary }}</p> <p class="card-summary">{{ news.summary }}</p>
<div v-if="news.opinion" class="opinion-block"> <div v-if="news.opinion" class="opinion-block">
<b>核心观点</b>{{ news.opinion }} <b>INSIGHT</b>{{ news.opinion }}
</div> </div>
<div class="card-footer"> <div class="card-footer">
<div class="kw-row"> <div class="kw-row">
<span v-for="kw in (news.keywords || []).slice(0, 5)" :key="kw" class="kw-chip">{{ kw }}</span> <span v-for="kw in (news.keywords || []).slice(0, 4)" :key="kw" class="kw-chip">{{ kw }}</span>
</div> </div>
<a :href="news.source_url" target="_blank" @click.stop class="orig-link">查看原文 </a> <a :href="news.source_url" target="_blank" @click.stop class="orig-link">原文 </a>
</div> </div>
</div> </div>
</template> </template>
@@ -39,56 +39,76 @@ const timeAgo = computed(() => {
const diff = Date.now() - new Date(props.news.published_at).getTime() const diff = Date.now() - new Date(props.news.published_at).getTime()
const h = Math.floor(diff / 3600000) const h = Math.floor(diff / 3600000)
if (h < 1) return '刚刚' if (h < 1) return '刚刚'
if (h < 24) return `${h}小时` if (h < 24) return `${h}h`
return `${Math.floor(h / 24)}` return `${Math.floor(h / 24)}d`
}) })
</script> </script>
<style scoped> <style scoped>
.news-card { .news-card {
background: #fff; background: var(--bg-card);
border: 1px solid var(--rule2); border: 0.5px solid var(--rule2);
border-radius: var(--r); border-radius: var(--r);
padding: 18px; padding: 16px 18px;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: border-color 0.15s, background 0.15s;
box-shadow: var(--shadow-sm);
} }
.news-card:hover { .news-card:hover {
transform: translateY(-1px); background: var(--bg-hover);
box-shadow: var(--shadow);
border-color: var(--blue-bd); border-color: var(--blue-bd);
} }
.news-card.featured { border-left: 3px solid var(--blue); } .news-card.featured {
border-left: 2px solid var(--blue);
}
.card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } /* top row */
.card-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 9px;
}
.spacer { flex: 1; }
.score-badge { .score-badge {
flex-shrink: 0; flex-shrink: 0;
padding: 2px 7px; padding: 1px 6px;
border-radius: 5px; border-radius: 3px;
font-family: var(--mono); font-family: var(--mono);
font-size: 11px; font-size: 10px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.04em;
} }
.badge-red { background: #fef2f2; color: #dc2626; } .badge-red { background: var(--badge-red-bg); color: var(--badge-red-text); }
.badge-amber { background: #fffbeb; color: #d97706; } .badge-amber { background: var(--badge-amber-bg); color: var(--badge-amber-text); }
.badge-blue { background: #eff6ff; color: #2563eb; } .badge-blue { background: var(--badge-blue-bg); color: var(--badge-blue-text); }
.badge-gray { background: #f9fafb; color: #6b7280; } .badge-gray { background: var(--badge-gray-bg); color: var(--badge-gray-text); }
.cat-label { font-size: 12px; color: var(--t3); } .cat-label {
.spacer { flex: 1; } font-size: 10px;
.source-meta { font-size: 11px; color: var(--t4); font-family: var(--mono); } font-family: var(--mono);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--t4);
}
.source-meta {
font-size: 10px;
font-family: var(--mono);
color: var(--t4);
letter-spacing: 0.03em;
}
/* content */
.card-title { .card-title {
font-size: 15px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--t1); color: var(--t1);
line-height: 1.45; line-height: 1.5;
margin-bottom: 8px; margin-bottom: 7px;
} }
.card-summary { .card-summary {
font-size: 13px; font-size: 12.5px;
color: var(--t3); color: var(--t3);
line-height: 1.65; line-height: 1.65;
margin-bottom: 10px; margin-bottom: 10px;
@@ -99,41 +119,45 @@ const timeAgo = computed(() => {
} }
.opinion-block { .opinion-block {
background: #eff6ff; background: var(--soft-blue);
border-left: 2px solid rgba(37, 99, 235, 0.35); border-left: 2px solid var(--blue-bd);
border-radius: 0 6px 6px 0; border-radius: 0 4px 4px 0;
padding: 8px 12px; padding: 7px 11px;
margin-bottom: 10px; margin-bottom: 10px;
font-size: 12.5px; font-size: 12px;
color: var(--t2); color: var(--t2);
line-height: 1.55; line-height: 1.55;
} }
.opinion-block b { .opinion-block b {
display: block; display: block;
font-family: var(--mono); font-family: var(--mono);
font-size: 10px; font-size: 9px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.1em;
margin-bottom: 3px; margin-bottom: 3px;
color: var(--blue); color: var(--blue);
letter-spacing: 0.04em;
} }
/* footer */
.card-footer { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } .card-footer { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.kw-row { display: flex; gap: 5px; flex-wrap: wrap; flex: 1; } .kw-row { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; }
.kw-chip { .kw-chip {
font-size: 11px; font-size: 10px;
font-family: var(--mono); font-family: var(--mono);
padding: 2px 7px; padding: 1px 6px;
border-radius: 4px; border-radius: 3px;
background: #eff6ff; background: var(--soft-blue);
color: #3b82f6; color: var(--blue-2);
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.02em;
} }
.orig-link { .orig-link {
font-size: 12px; font-size: 11px;
font-family: var(--mono);
color: var(--blue); color: var(--blue);
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.04em;
transition: color 0.15s; transition: color 0.15s;
} }
.orig-link:hover { color: #1d4ed8; } .orig-link:hover { color: var(--link-hover); }
</style> </style>

View File

@@ -0,0 +1,59 @@
<template>
<div class="theme-toggle" role="group" aria-label="主题">
<button
v-for="item in modes"
:key="item.value"
:class="['t-btn', { active: themeMode === item.value }]"
:title="item.title"
@click="$emit('set-theme-mode', item.value)"
>
<el-icon><component :is="item.icon" /></el-icon>
</button>
</div>
</template>
<script setup>
defineProps({
themeMode: { type: String, required: true },
resolvedTheme: { type: String, required: true },
})
defineEmits(['set-theme-mode'])
const modes = [
{ value: 'light', title: '浅色', icon: 'Sunny' },
{ value: 'dark', title: '深色', icon: 'Moon' },
{ value: 'system', title: '系统', icon: 'Monitor' },
]
</script>
<style scoped>
.theme-toggle {
display: inline-flex;
align-items: center;
height: 30px;
padding: 2px;
gap: 1px;
border: 1px solid var(--rule2);
border-radius: var(--r-sm);
background: var(--bg-2);
flex-shrink: 0;
}
.t-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 4px;
color: var(--t4);
font-size: 13px;
transition: background 0.15s, color 0.15s;
}
.t-btn:hover { color: var(--t1); }
.t-btn.active { background: var(--blue-gl); color: var(--blue); border: 1px solid var(--blue-bd); }
@media (max-width: 640px) {
.theme-toggle { order: 10; }
}
</style>

View File

@@ -1,64 +1,147 @@
/* Light theme design tokens */ /* ─────────────────────────────────────────────────────────────────
Clinical design-system — IBM Plex Mono + IBM Plex Sans
Light = clinical white | Dark = deep navy #050D12
───────────────────────────────────────────────────────────────── */
/* ── Light ────────────────────────────────────────────────────── */
:root { :root {
--bg: #f0f2f5; --bg: #F0F6FA;
--bg-card: #ffffff; --bg-card: #FFFFFF;
--bg-hover: #f0f4ff; --bg-hover: rgba(13,155,142,0.05);
--bg-1: #ffffff; --bg-1: #FFFFFF;
--bg-2: #f8f9fc; --bg-2: #F5F9FC;
--bg-hi: #f3f4f6; --bg-hi: rgba(13,155,142,0.06);
--blue: #2563eb; /* primary accent — clinical teal */
--blue-2: #3b82f6; --blue: #0D9B8E;
--blue-gl: rgba(37, 99, 235, 0.06); --blue-2: #13B5A7;
--blue-bd: rgba(37, 99, 235, 0.2); --blue-gl: rgba(13,155,142,0.06);
--blue-bd: rgba(13,155,142,0.22);
--violet: #7c3aed; --violet: #7C3AED;
--cyan: #0ea5e9; --cyan: #06B6D4;
--mint: #10b981; --mint: #0D9B8E;
--amber: #f59e0b; --amber: #B07A0A;
--red: #ef4444; --red: #CC3333;
--link-hover:#0A7D72;
--t1: #111827; --ok-bg: rgba(13,155,142,0.08);
--t2: #374151; --ok-bd: rgba(13,155,142,0.22);
--t3: #6b7280; --ok-text: #0D9B8E;
--t4: #9ca3af; --soft-blue: rgba(13,155,142,0.07);
--soft-green: rgba(22,163,74,0.07);
--rule: #f3f4f6; --badge-red-bg: rgba(204,51,51,0.10);
--rule2: #e5e7eb; --badge-red-text: #CC3333;
--badge-amber-bg: rgba(176,122,10,0.10);
--badge-amber-text:#B07A0A;
--badge-blue-bg: rgba(13,155,142,0.10);
--badge-blue-text: #0D9B8E;
--badge-gray-bg: rgba(5,13,18,0.06);
--badge-gray-text: rgba(5,13,18,0.46);
--sans: 'Noto Sans SC', system-ui, sans-serif; --t1: #050D12;
--mono: 'JetBrains Mono', 'Courier New', monospace; --t2: rgba(5,13,18,0.76);
--t3: rgba(5,13,18,0.52);
--t4: rgba(5,13,18,0.36);
--r: 12px; --rule: rgba(13,155,142,0.10);
--r-sm: 8px; --rule2: rgba(13,155,142,0.20);
--sans: 'IBM Plex Sans', system-ui, sans-serif;
--mono: 'IBM Plex Mono', 'Courier New', monospace;
--r: 8px;
--r-sm: 5px;
--r-pill: 9999px; --r-pill: 9999px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04); /* clinical: no decorative shadows */
--shadow: 0 4px 12px rgba(0,0,0,0.08); --shadow-sm: none;
--shadow-lg: 0 8px 24px rgba(37,99,235,0.12); --shadow: none;
--shadow-lg: none;
} }
/* ── Dark ─────────────────────────────────────────────────────── */
:root[data-theme="dark"] {
--bg: #050D12;
--bg-card: #0B1820;
--bg-hover: rgba(61,217,198,0.06);
--bg-1: #0B1820;
--bg-2: #071018;
--bg-hi: rgba(61,217,198,0.06);
--blue: #3DD9C6;
--blue-2: #3DD9C6;
--blue-gl: rgba(61,217,198,0.06);
--blue-bd: rgba(61,217,198,0.20);
--violet: #A78BFA;
--cyan: #22D3EE;
--mint: #3DD9C6;
--amber: #F5C97A;
--red: #FF6B6B;
--link-hover:#5DE8D5;
--ok-bg: rgba(61,217,198,0.10);
--ok-bd: rgba(61,217,198,0.20);
--ok-text: #3DD9C6;
--soft-blue: rgba(61,217,198,0.06);
--soft-green: rgba(61,217,198,0.06);
--badge-red-bg: rgba(255,107,107,0.15);
--badge-red-text: #FF6B6B;
--badge-amber-bg: rgba(245,201,122,0.15);
--badge-amber-text:#F5C97A;
--badge-blue-bg: rgba(61,217,198,0.12);
--badge-blue-text: #3DD9C6;
--badge-gray-bg: rgba(255,255,255,0.08);
--badge-gray-text: rgba(255,255,255,0.40);
--t1: #FFFFFF;
--t2: rgba(255,255,255,0.76);
--t3: rgba(255,255,255,0.50);
--t4: rgba(255,255,255,0.30);
--rule: rgba(61,217,198,0.08);
--rule2: rgba(61,217,198,0.15);
}
/* ── Global base ──────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { html { font-size: 16px; }
height: 100%;
body {
font-family: var(--sans);
background: var(--bg); background: var(--bg);
color: var(--t1); color: var(--t1);
font-family: var(--sans);
font-size: 14px;
line-height: 1.5; line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
transition: background 0.25s, color 0.25s;
} }
a { color: inherit; text-decoration: none; } a { color: inherit; text-decoration: none; }
button { cursor: pointer; font-family: inherit; border: none; background: none; } button { background: none; border: none; cursor: pointer; font: inherit; }
input, select, textarea { font-family: inherit; } input, select, textarea { font: inherit; }
/* Element Plus light theme */ /* Element Plus overrides */
.el-input__wrapper { background: #fff !important; box-shadow: 0 0 0 1px var(--rule2) !important; } .el-input__wrapper { background: var(--bg-card) !important; box-shadow: 0 0 0 1px var(--rule2) !important; }
.el-input__inner { color: var(--t1) !important; } .el-input__inner { color: var(--t1) !important; }
.el-select-dropdown { background: #fff; border-color: var(--rule2); } .el-select-dropdown { background: var(--bg-card); border-color: var(--rule2); }
.el-select-dropdown__item { color: var(--t2); } .el-select-dropdown__item { color: var(--t2); }
.el-select-dropdown__item.hover, .el-select-dropdown__item.hover,
.el-select-dropdown__item:hover { background: var(--bg-hover); } .el-select-dropdown__item:hover { background: var(--bg-hover); }
.el-table { --el-table-bg-color: #fff; --el-table-header-bg-color: var(--bg-2); } .el-table {
--el-table-bg-color: var(--bg-card);
--el-table-tr-bg-color: var(--bg-card);
--el-table-header-bg-color: var(--bg-2);
--el-table-text-color: var(--t2);
--el-table-header-text-color: var(--t1);
--el-table-border-color: var(--rule);
--el-table-row-hover-bg-color: var(--bg-hover);
}
:root[data-theme="dark"] .el-popper,
:root[data-theme="dark"] .el-select__popper.el-popper {
background: var(--bg-card); border-color: var(--rule2); color: var(--t2);
}

View File

@@ -2,6 +2,13 @@
<div class="admin-layout"> <div class="admin-layout">
<!-- 登录 --> <!-- 登录 -->
<div v-if="!authed" class="login-screen"> <div v-if="!authed" class="login-screen">
<div class="login-theme">
<ThemeControls
:theme-mode="themeMode"
:resolved-theme="resolvedTheme"
@set-theme-mode="$emit('set-theme-mode', $event)"
/>
</div>
<div class="login-card"> <div class="login-card">
<div class="login-logo">PI</div> <div class="login-logo">PI</div>
<div class="login-title">管理后台</div> <div class="login-title">管理后台</div>
@@ -33,6 +40,11 @@
</button> </button>
</div> </div>
<div class="admin-right"> <div class="admin-right">
<ThemeControls
:theme-mode="themeMode"
:resolved-theme="resolvedTheme"
@set-theme-mode="$emit('set-theme-mode', $event)"
/>
<router-link to="/" class="exit-link"> 返回首页</router-link> <router-link to="/" class="exit-link"> 返回首页</router-link>
<button class="logout-btn" @click="logout">退出</button> <button class="logout-btn" @click="logout">退出</button>
</div> </div>
@@ -169,12 +181,19 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import ThemeControls from '../components/ThemeControls.vue'
import { import {
getLLMConfig, saveLLMConfig, testLLMConfig, getLLMConfig, saveLLMConfig, testLLMConfig,
getSources, addSource, toggleSource, deleteSource, getSources, addSource, toggleSource, deleteSource,
triggerCrawl as apiTrigger, getStats, getLogs, triggerCrawl as apiTrigger, getStats, getLogs,
} from '../api/index.js' } from '../api/index.js'
defineProps({
themeMode: { type: String, required: true },
resolvedTheme: { type: String, required: true },
})
defineEmits(['set-theme-mode'])
// ── Auth ────────────────────────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────────────────────────
const authed = ref(!!localStorage.getItem('admin_token')) const authed = ref(!!localStorage.getItem('admin_token'))
const tokenInput = ref('') const tokenInput = ref('')
@@ -321,7 +340,9 @@ onUnmounted(() => stopPolling())
.login-screen { .login-screen {
min-height: 100vh; min-height: 100vh;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
position: relative;
} }
.login-theme { position: absolute; top: 20px; right: 24px; }
.login-card { .login-card {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--rule2); border: 1px solid var(--rule2);
@@ -463,4 +484,15 @@ onUnmounted(() => stopPolling())
.log-info .log-level { color: var(--blue-2); } .log-info .log-level { color: var(--blue-2); }
.log-error .log-level { color: var(--red); } .log-error .log-level { color: var(--red); }
.log-warn .log-level { color: var(--amber); } .log-warn .log-level { color: var(--amber); }
@media (max-width: 768px) {
.login-theme { top: 16px; right: 16px; left: 16px; }
.login-card { width: calc(100% - 32px); padding: 32px 24px; }
.admin-topbar { height: auto; min-height: 56px; padding: 10px 16px; flex-wrap: wrap; gap: 10px; }
.admin-logo { flex: 1 1 auto; }
.admin-nav { order: 3; width: 100%; overflow-x: auto; }
.admin-right { width: 100%; flex-wrap: wrap; }
.admin-body { padding: 16px; }
.form-grid { grid-template-columns: 1fr; }
}
</style> </style>

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,8 @@ export default defineConfig({
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
}, },
server: { server: {
allowedHosts: ['ddc.chenwuzhu.cn'], host: '0.0.0.0',
allowedHosts: ['localhost', '127.0.0.1', 'ddc.chenwuzhu.cn'],
proxy: { proxy: {
'/api': { target: 'http://127.0.0.1:8000', changeOrigin: true }, '/api': { target: 'http://127.0.0.1:8000', changeOrigin: true },
}, },