inital
This commit is contained in:
3
frontend/src/App.vue
Normal file
3
frontend/src/App.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
64
frontend/src/api/index.js
Normal file
64
frontend/src/api/index.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const BASE = '/api'
|
||||
|
||||
function adminHeaders() {
|
||||
const token = localStorage.getItem('admin_token') || ''
|
||||
return { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }
|
||||
}
|
||||
|
||||
async function get(url) {
|
||||
const resp = await fetch(BASE + url)
|
||||
if (!resp.ok) throw new Error(`GET ${url} failed: ${resp.status}`)
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
async function adminGet(url) {
|
||||
const resp = await fetch(BASE + url, { headers: adminHeaders() })
|
||||
if (resp.status === 401) throw new Error('UNAUTHORIZED')
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
async function adminPost(url, body) {
|
||||
const resp = await fetch(BASE + url, {
|
||||
method: 'POST', headers: adminHeaders(), body: JSON.stringify(body)
|
||||
})
|
||||
if (resp.status === 401) throw new Error('UNAUTHORIZED')
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
async function adminPut(url, body) {
|
||||
const resp = await fetch(BASE + url, {
|
||||
method: 'PUT', headers: adminHeaders(), body: JSON.stringify(body)
|
||||
})
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
async function adminDelete(url) {
|
||||
const resp = await fetch(BASE + url, { method: 'DELETE', headers: adminHeaders() })
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
// ── Reader API ────────────────────────────────────────────────────────────────
|
||||
export const fetchFeatured = (date) => get(`/news/featured${date ? `?date=${date}` : ''}`)
|
||||
export const fetchNews = ({ date, category, page = 1 } = {}) => {
|
||||
const p = new URLSearchParams()
|
||||
if (date) p.set('date', date)
|
||||
if (category) p.set('category', category)
|
||||
p.set('page', page)
|
||||
return get(`/news?${p}`)
|
||||
}
|
||||
export const fetchDates = () => get('/news/dates')
|
||||
export const fetchNewsDetail = (id) => get(`/news/${id}`)
|
||||
|
||||
// ── Admin API ─────────────────────────────────────────────────────────────────
|
||||
export const getLLMConfig = () => adminGet('/admin/llm-config')
|
||||
export const saveLLMConfig = (cfg) => adminPost('/admin/llm-config', cfg)
|
||||
export const testLLMConfig = (cfg) => adminPost('/admin/llm-config/test', cfg)
|
||||
|
||||
export const getSources = () => adminGet('/admin/sources')
|
||||
export const addSource = (src) => adminPost('/admin/sources', src)
|
||||
export const toggleSource = (id, is_active) => adminPut(`/admin/sources/${id}`, { is_active })
|
||||
export const deleteSource = (id) => adminDelete(`/admin/sources/${id}`)
|
||||
|
||||
export const triggerCrawl = () => adminPost('/admin/crawl/trigger', {})
|
||||
export const getStats = () => adminGet('/admin/stats')
|
||||
export const getLogs = (limit = 100) => adminGet(`/admin/logs?limit=${limit}`)
|
||||
141
frontend/src/components/NewsCard.vue
Normal file
141
frontend/src/components/NewsCard.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="news-card" :class="{ featured: news.is_featured }" @click="$emit('open', news)">
|
||||
<div class="card-top">
|
||||
<span class="score-badge" :class="badgeClass">{{ news.importance_score?.toFixed(1) }}</span>
|
||||
<span class="cat-label">{{ news.category }}</span>
|
||||
<span class="spacer"></span>
|
||||
<span class="source-meta">{{ news.source_name }} · {{ timeAgo }}</span>
|
||||
</div>
|
||||
|
||||
<h3 class="card-title">{{ news.title_zh }}</h3>
|
||||
<p class="card-summary">{{ news.summary }}</p>
|
||||
|
||||
<div v-if="news.opinion" class="opinion-block">
|
||||
<b>核心观点</b>{{ news.opinion }}
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<div class="kw-row">
|
||||
<span v-for="kw in (news.keywords || []).slice(0, 5)" :key="kw" class="kw-tag">{{ kw }}</span>
|
||||
</div>
|
||||
<a :href="news.source_url" target="_blank" @click.stop class="orig-link">查看原文 →</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({ news: { type: Object, required: true } })
|
||||
defineEmits(['open'])
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
const s = props.news.importance_score
|
||||
if (s >= 9) return 'badge-red'
|
||||
if (s >= 7) return 'badge-amber'
|
||||
if (s >= 5) return 'badge-blue'
|
||||
return 'badge-gray'
|
||||
})
|
||||
|
||||
const timeAgo = computed(() => {
|
||||
if (!props.news.published_at) return ''
|
||||
const diff = Date.now() - new Date(props.news.published_at).getTime()
|
||||
const h = Math.floor(diff / 3600000)
|
||||
if (h < 1) return '刚刚'
|
||||
if (h < 24) return `${h}小时前`
|
||||
return `${Math.floor(h / 24)}天前`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.news-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: var(--r);
|
||||
padding: 16px;
|
||||
margin-bottom: 10px;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, transform .15s;
|
||||
}
|
||||
.news-card:hover { border-color: var(--rule2); transform: translateY(-1px); }
|
||||
.news-card.featured { border-left: 2px solid var(--blue-2); }
|
||||
|
||||
/* Top row */
|
||||
.card-top { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
.score-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 7px;
|
||||
border-radius: 6px;
|
||||
font-family: var(--mono);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge-red { background: rgba(255,77,106,.2); color: var(--red); }
|
||||
.badge-amber { background: rgba(255,163,54,.2); color: var(--amber); }
|
||||
.badge-blue { background: rgba(46,85,245,.2); color: var(--blue-2); }
|
||||
.badge-gray { background: rgba(122,128,160,.15); color: var(--t3); }
|
||||
|
||||
.cat-label { font-size: 11px; color: var(--t3); }
|
||||
.spacer { flex: 1; }
|
||||
.source-meta { font-size: 11px; color: var(--t4); font-family: var(--mono); }
|
||||
|
||||
/* Body */
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--t1);
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-summary {
|
||||
font-size: 13px;
|
||||
color: var(--t3);
|
||||
line-height: 1.65;
|
||||
margin-bottom: 10px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Opinion */
|
||||
.opinion-block {
|
||||
background: var(--blue-gl);
|
||||
border-left: 2px solid var(--blue-bd);
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12.5px;
|
||||
color: #8AAAFF;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.opinion-block b {
|
||||
display: block;
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 3px;
|
||||
opacity: .7;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.card-footer { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.kw-row { display: flex; gap: 5px; flex-wrap: wrap; flex: 1; }
|
||||
.kw-tag {
|
||||
font-size: 10px;
|
||||
font-family: var(--mono);
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: rgba(46,85,245,.15);
|
||||
color: #7B9BFF;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.orig-link {
|
||||
font-size: 12px;
|
||||
color: var(--blue-2);
|
||||
white-space: nowrap;
|
||||
transition: color .15s;
|
||||
}
|
||||
.orig-link:hover { color: var(--t1); }
|
||||
</style>
|
||||
149
frontend/src/components/TopTenPanel.vue
Normal file
149
frontend/src/components/TopTenPanel.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<aside class="top10-panel">
|
||||
<div class="panel-head">
|
||||
<span class="dot d-blue"></span>
|
||||
今日精选 TOP 10
|
||||
</div>
|
||||
|
||||
<div v-if="items.length === 0" class="empty-msg">暂无精选,等待今日抓取...</div>
|
||||
|
||||
<div
|
||||
v-for="(n, i) in items"
|
||||
:key="n.id"
|
||||
class="top10-item"
|
||||
:class="{ active: activeId === n.id }"
|
||||
@click="$emit('select', n)"
|
||||
>
|
||||
<span class="rank">{{ String(i + 1).padStart(2, '0') }}</span>
|
||||
<div class="item-body">
|
||||
<span class="item-score" :class="badgeClass(n.importance_score)">{{ n.importance_score?.toFixed(1) }}</span>
|
||||
<span class="item-title">{{ n.title_zh }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="archive-section" v-if="dates.length > 0">
|
||||
<div class="archive-head">历史归档</div>
|
||||
<div
|
||||
v-for="d in dates.slice(1, 8)"
|
||||
:key="d.date"
|
||||
class="archive-item"
|
||||
:class="{ active: selectedDate === d.date }"
|
||||
@click="$emit('date-change', d.date)"
|
||||
>
|
||||
<span class="arc-date">{{ d.date.slice(5) }}</span>
|
||||
<span class="arc-count">{{ d.count }}条</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
items: { type: Array, default: () => [] },
|
||||
dates: { type: Array, default: () => [] },
|
||||
activeId: { type: Number, default: null },
|
||||
selectedDate: { type: String, default: '' },
|
||||
})
|
||||
defineEmits(['select', 'date-change'])
|
||||
|
||||
function badgeClass(score) {
|
||||
if (score >= 9) return 'badge-red'
|
||||
if (score >= 7) return 'badge-amber'
|
||||
return 'badge-blue'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.top10-panel {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 120px;
|
||||
height: calc(100vh - 140px);
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.top10-panel::-webkit-scrollbar { display: none; }
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--mono);
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--t4);
|
||||
padding: 0 0 12px;
|
||||
}
|
||||
.dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.d-blue { background: var(--blue-2); }
|
||||
|
||||
.empty-msg { font-size: 12px; color: var(--t4); padding: 8px 0; }
|
||||
|
||||
.top10-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.top10-item:hover { background: var(--bg-hi); }
|
||||
.top10-item.active { background: var(--blue-gl); }
|
||||
|
||||
.rank {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
color: var(--t4);
|
||||
width: 18px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 3px;
|
||||
}
|
||||
.item-body { flex: 1; display: flex; flex-direction: column; gap: 4px; }
|
||||
.item-score {
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.badge-red { background: rgba(255,77,106,.2); color: var(--red); }
|
||||
.badge-amber { background: rgba(255,163,54,.2); color: var(--amber); }
|
||||
.badge-blue { background: rgba(46,85,245,.2); color: var(--blue-2); }
|
||||
|
||||
.item-title {
|
||||
font-size: 12px;
|
||||
color: var(--t2);
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Archive */
|
||||
.archive-section { margin-top: 20px; border-top: 1px solid var(--rule); padding-top: 12px; }
|
||||
.archive-head {
|
||||
font-size: 10px;
|
||||
font-family: var(--mono);
|
||||
color: var(--t4);
|
||||
letter-spacing: .1em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.archive-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
transition: background .15s;
|
||||
}
|
||||
.archive-item:hover, .archive-item.active { background: var(--bg-hi); }
|
||||
.arc-date { font-size: 12px; color: var(--t2); font-family: var(--mono); }
|
||||
.arc-count { font-size: 11px; color: var(--t4); font-family: var(--mono); }
|
||||
</style>
|
||||
20
frontend/src/main.js
Normal file
20
frontend/src/main.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router/index.js'
|
||||
import './styles/theme.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(ElementPlus, { locale: zhCn })
|
||||
app.use(router)
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
13
frontend/src/router/index.js
Normal file
13
frontend/src/router/index.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import NewsReader from '../views/NewsReader.vue'
|
||||
import Admin from '../views/Admin.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', component: NewsReader },
|
||||
{ path: '/admin', component: Admin },
|
||||
]
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
59
frontend/src/styles/theme.css
Normal file
59
frontend/src/styles/theme.css
Normal file
@@ -0,0 +1,59 @@
|
||||
/* 设计令牌——与 HTML 参考文件保持一致 */
|
||||
:root {
|
||||
--bg: #07091A;
|
||||
--bg-1: #0B0E24;
|
||||
--bg-2: #10142E;
|
||||
--bg-card: #0D1028;
|
||||
--bg-hi: #141838;
|
||||
|
||||
--blue: #2E55F5;
|
||||
--blue-2: #5578FF;
|
||||
--blue-gl: rgba(46, 85, 245, 0.15);
|
||||
--blue-bd: rgba(46, 85, 245, 0.3);
|
||||
|
||||
--violet: #7B3FE4;
|
||||
--violet-2: #9A68EE;
|
||||
--cyan: #19C3E6;
|
||||
--mint: #25D6A3;
|
||||
--amber: #FFA336;
|
||||
--red: #FF4D6A;
|
||||
|
||||
--t1: #F0F2FF;
|
||||
--t2: #B8BEDD;
|
||||
--t3: #7A80A0;
|
||||
--t4: #4A506A;
|
||||
|
||||
--rule: rgba(255, 255, 255, 0.07);
|
||||
--rule2: rgba(255, 255, 255, 0.13);
|
||||
|
||||
--sans: 'Noto Sans SC', system-ui, sans-serif;
|
||||
--mono: 'JetBrains Mono', 'Courier New', monospace;
|
||||
|
||||
--r: 12px;
|
||||
--r-sm: 8px;
|
||||
--r-pill: 20px;
|
||||
}
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--t1);
|
||||
font-family: var(--sans);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: inherit; text-decoration: none; }
|
||||
button { cursor: pointer; font-family: inherit; }
|
||||
input, select, textarea { font-family: inherit; }
|
||||
|
||||
/* Element Plus 暗色覆盖 */
|
||||
.el-table { --el-table-bg-color: var(--bg-card); --el-table-header-bg-color: var(--bg-2); }
|
||||
.el-input__wrapper { background: var(--bg-2) !important; box-shadow: 0 0 0 1px var(--rule2) !important; }
|
||||
.el-input__inner { color: var(--t1) !important; }
|
||||
.el-select-dropdown { background: var(--bg-2); border-color: var(--rule2); }
|
||||
.el-select-dropdown__item { color: var(--t2); }
|
||||
.el-select-dropdown__item.hover, .el-select-dropdown__item:hover { background: var(--bg-hi); }
|
||||
434
frontend/src/views/Admin.vue
Normal file
434
frontend/src/views/Admin.vue
Normal file
@@ -0,0 +1,434 @@
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<!-- ── 登录 ──────────────────────────────────────────────── -->
|
||||
<div v-if="!authed" class="login-screen">
|
||||
<div class="login-card">
|
||||
<div class="login-logo">PI</div>
|
||||
<div class="login-title">管理后台</div>
|
||||
<input
|
||||
v-model="tokenInput"
|
||||
type="password"
|
||||
placeholder="输入管理员令牌"
|
||||
class="token-input"
|
||||
@keyup.enter="login"
|
||||
/>
|
||||
<div v-if="loginError" class="login-error">{{ loginError }}</div>
|
||||
<button class="login-btn" @click="login">进入</button>
|
||||
<router-link to="/" class="back-link">← 返回首页</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 管理内容 ────────────────────────────────────────────── -->
|
||||
<template v-else>
|
||||
<header class="admin-topbar">
|
||||
<div class="admin-logo">
|
||||
<div class="logo-chip">PI</div>
|
||||
<span>医药情报 · 管理后台</span>
|
||||
</div>
|
||||
<div class="admin-nav">
|
||||
<button v-for="t in TABS" :key="t.key" :class="['tab-btn', { active: activeTab === t.key }]" @click="activeTab = t.key">
|
||||
{{ t.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="admin-right">
|
||||
<router-link to="/" class="exit-link">← 返回首页</router-link>
|
||||
<button class="logout-btn" @click="logout">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-body">
|
||||
|
||||
<!-- Tab: LLM 配置 -->
|
||||
<section v-if="activeTab === 'llm'" class="admin-section">
|
||||
<div class="sec-title">LLM 配置</div>
|
||||
<div class="form-grid">
|
||||
<label class="form-label">提供商</label>
|
||||
<el-select v-model="llmForm.provider" class="dark-input">
|
||||
<el-option v-for="p in PROVIDERS" :key="p.value" :label="p.label" :value="p.value" />
|
||||
</el-select>
|
||||
|
||||
<label class="form-label">配置名称</label>
|
||||
<el-input v-model="llmForm.name" placeholder="如「DeepSeek 默认」" class="dark-input" />
|
||||
|
||||
<label class="form-label">API Key</label>
|
||||
<el-input v-model="llmForm.api_key" type="password" placeholder="sk-..." show-password class="dark-input" />
|
||||
|
||||
<label class="form-label">Base URL</label>
|
||||
<el-input v-model="llmForm.base_url" placeholder="https://api.deepseek.com" class="dark-input" />
|
||||
|
||||
<label class="form-label">模型名称</label>
|
||||
<el-input v-model="llmForm.model_name" placeholder="deepseek-chat" class="dark-input" />
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn-primary" @click="saveLLM" :disabled="llmSaving">
|
||||
{{ llmSaving ? '保存中...' : '保存配置' }}
|
||||
</button>
|
||||
<button class="btn-secondary" @click="testLLM" :disabled="llmTesting">
|
||||
{{ llmTesting ? '测试中...' : '连接测试' }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="llmTestResult" class="test-result" :class="llmTestResult.ok ? 'ok' : 'err'">
|
||||
{{ llmTestResult.ok ? '✓ ' + llmTestResult.reply : '✗ ' + llmTestResult.error }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tab: 新闻源 -->
|
||||
<section v-if="activeTab === 'sources'" class="admin-section">
|
||||
<div class="sec-title">新闻源管理</div>
|
||||
<el-table :data="sources" class="dark-table">
|
||||
<el-table-column prop="name" label="名称" width="140" />
|
||||
<el-table-column prop="url" label="URL" show-overflow-tooltip />
|
||||
<el-table-column prop="source_type" label="类型" width="80" />
|
||||
<el-table-column prop="language" label="语言" width="70" />
|
||||
<el-table-column prop="category" label="分类" width="100" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-switch :model-value="row.is_active" @change="toggleSrc(row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80">
|
||||
<template #default="{ row }">
|
||||
<button class="del-btn" @click="deleteSrc(row.id)">删除</button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="add-source-form">
|
||||
<div class="add-title">添加新闻源</div>
|
||||
<div class="form-row">
|
||||
<el-input v-model="newSrc.name" placeholder="名称" style="width:130px" />
|
||||
<el-input v-model="newSrc.url" placeholder="RSS URL" style="flex:1" />
|
||||
<el-select v-model="newSrc.source_type" style="width:90px">
|
||||
<el-option label="RSS" value="rss" />
|
||||
<el-option label="Scrape" value="scrape" />
|
||||
</el-select>
|
||||
<el-select v-model="newSrc.language" style="width:80px">
|
||||
<el-option label="中文" value="zh" />
|
||||
<el-option label="English" value="en" />
|
||||
</el-select>
|
||||
<el-select v-model="newSrc.category" placeholder="分类" style="width:110px">
|
||||
<el-option v-for="c in CAT_OPTIONS" :key="c" :label="c" :value="c" />
|
||||
</el-select>
|
||||
<button class="btn-primary" style="height:32px;padding:0 14px" @click="addSrc">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tab: 系统操作 -->
|
||||
<section v-if="activeTab === 'ops'" class="admin-section">
|
||||
<div class="sec-title">系统操作</div>
|
||||
<div class="stats-row" v-if="stats">
|
||||
<div class="stat-box">
|
||||
<div class="stat-val">{{ stats.raw_today }}</div>
|
||||
<div class="stat-lbl">今日抓取原始</div>
|
||||
</div>
|
||||
<div class="stat-box">
|
||||
<div class="stat-val">{{ stats.processed_today }}</div>
|
||||
<div class="stat-lbl">今日 AI 处理</div>
|
||||
</div>
|
||||
<div class="stat-box sc-m">
|
||||
<div class="stat-val">{{ stats.featured_today }}</div>
|
||||
<div class="stat-lbl">精选 TOP</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ops-card">
|
||||
<div class="ops-desc">立即触发今日抓取 + AI 处理 + 精选 TOP 10(约需 3-10 分钟)</div>
|
||||
<button
|
||||
class="btn-primary trigger-btn"
|
||||
@click="triggerCrawl"
|
||||
:disabled="stats?.pipeline_running || triggering"
|
||||
>
|
||||
{{ stats?.pipeline_running || triggering ? '⏳ 运行中...' : '▶ 立即触发' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tab: 日志 -->
|
||||
<section v-if="activeTab === 'logs'" class="admin-section">
|
||||
<div class="sec-title-row">
|
||||
<div class="sec-title">运行日志</div>
|
||||
<button class="btn-secondary" style="height:30px;padding:0 12px;font-size:12px" @click="loadLogs">刷新</button>
|
||||
</div>
|
||||
<div class="log-list">
|
||||
<div v-for="l in logs" :key="l.id" class="log-row" :class="'log-' + l.level.toLowerCase()">
|
||||
<span class="log-time">{{ l.created_at.slice(11, 19) }}</span>
|
||||
<span class="log-level">{{ l.level }}</span>
|
||||
<span class="log-msg">{{ l.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
getLLMConfig, saveLLMConfig, testLLMConfig,
|
||||
getSources, addSource, toggleSource, deleteSource,
|
||||
triggerCrawl as apiTrigger, getStats, getLogs,
|
||||
} from '../api/index.js'
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
const authed = ref(!!localStorage.getItem('admin_token'))
|
||||
const tokenInput = ref('')
|
||||
const loginError = ref('')
|
||||
|
||||
function login() {
|
||||
if (!tokenInput.value) return
|
||||
localStorage.setItem('admin_token', tokenInput.value)
|
||||
authed.value = true
|
||||
init()
|
||||
}
|
||||
function logout() {
|
||||
localStorage.removeItem('admin_token')
|
||||
authed.value = false
|
||||
}
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
const TABS = [
|
||||
{ key: 'llm', label: 'LLM 配置' },
|
||||
{ key: 'sources', label: '新闻源' },
|
||||
{ key: 'ops', label: '系统操作' },
|
||||
{ key: 'logs', label: '日志' },
|
||||
]
|
||||
const activeTab = ref('llm')
|
||||
|
||||
// ── LLM ──────────────────────────────────────────────────────────────────────
|
||||
const PROVIDERS = [
|
||||
{ label: 'DeepSeek(推荐·国内可直连)', value: 'deepseek' },
|
||||
{ label: '通义千问 Qwen', value: 'qwen' },
|
||||
{ label: 'OpenAI 兼容', value: 'openai' },
|
||||
{ label: 'Anthropic Claude', value: 'anthropic' },
|
||||
{ label: '自定义 Endpoint', value: 'custom' },
|
||||
]
|
||||
const llmForm = ref({ name: '默认配置', provider: 'deepseek', api_key: '', base_url: 'https://api.deepseek.com', model_name: 'deepseek-chat' })
|
||||
const llmSaving = ref(false)
|
||||
const llmTesting = ref(false)
|
||||
const llmTestResult = ref(null)
|
||||
|
||||
async function loadLLM() {
|
||||
try {
|
||||
const cfg = await getLLMConfig()
|
||||
if (cfg) Object.assign(llmForm.value, { ...cfg, api_key: '' })
|
||||
} catch (e) { if (e.message === 'UNAUTHORIZED') logout() }
|
||||
}
|
||||
async function saveLLM() {
|
||||
llmSaving.value = true
|
||||
try { await saveLLMConfig(llmForm.value) }
|
||||
finally { llmSaving.value = false }
|
||||
}
|
||||
async function testLLM() {
|
||||
llmTesting.value = true
|
||||
llmTestResult.value = null
|
||||
try { llmTestResult.value = await testLLMConfig(llmForm.value) }
|
||||
finally { llmTesting.value = false }
|
||||
}
|
||||
|
||||
// ── Sources ───────────────────────────────────────────────────────────────────
|
||||
const CAT_OPTIONS = ['药品监管', '临床研究', '行业动态', '政策法规']
|
||||
const sources = ref([])
|
||||
const newSrc = ref({ name: '', url: '', source_type: 'rss', language: 'zh', category: '行业动态' })
|
||||
|
||||
async function loadSources() {
|
||||
try { sources.value = await getSources() }
|
||||
catch (e) { if (e.message === 'UNAUTHORIZED') logout() }
|
||||
}
|
||||
async function addSrc() {
|
||||
await addSource(newSrc.value)
|
||||
newSrc.value = { name: '', url: '', source_type: 'rss', language: 'zh', category: '行业动态' }
|
||||
await loadSources()
|
||||
}
|
||||
async function toggleSrc(row) {
|
||||
await toggleSource(row.id, !row.is_active)
|
||||
await loadSources()
|
||||
}
|
||||
async function deleteSrc(id) {
|
||||
await deleteSource(id)
|
||||
await loadSources()
|
||||
}
|
||||
|
||||
// ── Ops ───────────────────────────────────────────────────────────────────────
|
||||
const stats = ref(null)
|
||||
const triggering = ref(false)
|
||||
|
||||
async function loadStats() {
|
||||
try { stats.value = await getStats() }
|
||||
catch (e) { if (e.message === 'UNAUTHORIZED') logout() }
|
||||
}
|
||||
async function triggerCrawl() {
|
||||
triggering.value = true
|
||||
try {
|
||||
await apiTrigger()
|
||||
setTimeout(loadStats, 2000)
|
||||
} finally {
|
||||
triggering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logs ──────────────────────────────────────────────────────────────────────
|
||||
const logs = ref([])
|
||||
async function loadLogs() {
|
||||
try { logs.value = await getLogs(100) }
|
||||
catch (e) { if (e.message === 'UNAUTHORIZED') logout() }
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await Promise.all([loadLLM(), loadSources(), loadStats(), loadLogs()])
|
||||
}
|
||||
|
||||
onMounted(() => { if (authed.value) init() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-layout { min-height: 100vh; background: var(--bg); }
|
||||
|
||||
/* Login */
|
||||
.login-screen {
|
||||
min-height: 100vh;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.login-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--rule2);
|
||||
border-radius: 16px;
|
||||
padding: 40px;
|
||||
width: 360px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 16px;
|
||||
}
|
||||
.login-logo {
|
||||
width: 48px; height: 48px;
|
||||
background: linear-gradient(135deg, var(--blue), var(--violet));
|
||||
border-radius: 12px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: var(--mono); font-size: 16px; font-weight: 700; color: #fff;
|
||||
}
|
||||
.login-title { font-size: 20px; font-weight: 700; color: var(--t1); }
|
||||
.token-input {
|
||||
width: 100%; height: 40px; border-radius: 8px;
|
||||
background: var(--bg-2); border: 1px solid var(--rule2);
|
||||
color: var(--t1); padding: 0 14px; font-size: 14px;
|
||||
}
|
||||
.token-input:focus { outline: none; border-color: var(--blue-bd); }
|
||||
.login-error { font-size: 12px; color: var(--red); }
|
||||
.login-btn {
|
||||
width: 100%; height: 40px; border-radius: 8px;
|
||||
background: var(--blue); color: #fff; font-size: 14px; font-weight: 600; border: none;
|
||||
}
|
||||
.back-link { font-size: 12px; color: var(--t4); }
|
||||
|
||||
/* Topbar */
|
||||
.admin-topbar {
|
||||
height: 56px;
|
||||
background: var(--bg-1);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex; align-items: center; padding: 0 24px; gap: 20px;
|
||||
}
|
||||
.admin-logo {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
font-size: 14px; font-weight: 600; color: var(--t1);
|
||||
}
|
||||
.logo-chip {
|
||||
width: 28px; height: 28px;
|
||||
background: linear-gradient(135deg, var(--blue), var(--violet));
|
||||
border-radius: 7px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: var(--mono); font-size: 10px; font-weight: 700; color: #fff;
|
||||
}
|
||||
.admin-nav { flex: 1; display: flex; gap: 4px; }
|
||||
.tab-btn {
|
||||
padding: 5px 14px; border-radius: 7px; font-size: 13px;
|
||||
background: transparent; border: none; color: var(--t3); transition: all .15s;
|
||||
}
|
||||
.tab-btn:hover { color: var(--t1); background: var(--bg-hi); }
|
||||
.tab-btn.active { background: var(--blue-gl); color: var(--blue-2); }
|
||||
.admin-right { display: flex; align-items: center; gap: 10px; }
|
||||
.exit-link { font-size: 12px; color: var(--t4); }
|
||||
.logout-btn {
|
||||
padding: 4px 12px; border-radius: 6px; font-size: 12px;
|
||||
background: transparent; border: 1px solid var(--rule2); color: var(--t3);
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.admin-body { padding: 24px; max-width: 960px; }
|
||||
.admin-section { }
|
||||
.sec-title { font-size: 13px; font-family: var(--mono); letter-spacing: .08em; color: var(--t4); text-transform: uppercase; margin-bottom: 20px; }
|
||||
.sec-title-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
||||
.sec-title-row .sec-title { margin-bottom: 0; }
|
||||
|
||||
/* LLM form */
|
||||
.form-grid {
|
||||
display: grid; grid-template-columns: 120px 1fr; gap: 12px 16px;
|
||||
align-items: center; max-width: 560px; margin-bottom: 20px;
|
||||
}
|
||||
.form-label { font-size: 13px; color: var(--t3); }
|
||||
.form-actions { display: flex; gap: 10px; margin-bottom: 12px; }
|
||||
|
||||
.test-result {
|
||||
padding: 10px 14px; border-radius: var(--r-sm);
|
||||
font-size: 13px; font-family: var(--mono);
|
||||
}
|
||||
.test-result.ok { background: rgba(37,214,163,.1); color: var(--mint); }
|
||||
.test-result.err { background: rgba(255,77,106,.1); color: var(--red); }
|
||||
|
||||
/* Buttons */
|
||||
.btn-primary {
|
||||
padding: 0 18px; height: 36px; border-radius: 8px;
|
||||
background: var(--blue); color: #fff; font-size: 13px; font-weight: 600;
|
||||
border: none; transition: background .15s;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: var(--blue-2); }
|
||||
.btn-primary:disabled { opacity: .5; cursor: default; }
|
||||
.btn-secondary {
|
||||
padding: 0 18px; height: 36px; border-radius: 8px;
|
||||
background: var(--bg-2); border: 1px solid var(--rule2); color: var(--t2);
|
||||
font-size: 13px; font-weight: 600; transition: background .15s;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg-hi); }
|
||||
|
||||
/* Sources table */
|
||||
.dark-table { background: var(--bg-card); --el-table-border-color: var(--rule); color: var(--t2); margin-bottom: 20px; }
|
||||
.del-btn {
|
||||
padding: 2px 8px; border-radius: 4px; font-size: 11px;
|
||||
background: rgba(255,77,106,.15); border: 1px solid rgba(255,77,106,.3); color: var(--red);
|
||||
}
|
||||
.add-source-form { background: var(--bg-card); border: 1px solid var(--rule); border-radius: var(--r); padding: 16px; }
|
||||
.add-title { font-size: 12px; color: var(--t4); font-family: var(--mono); margin-bottom: 10px; }
|
||||
.form-row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* Stats */
|
||||
.stats-row { display: flex; gap: 12px; margin-bottom: 20px; }
|
||||
.stat-box {
|
||||
flex: 1; background: var(--bg-card); border: 1px solid var(--rule);
|
||||
border-radius: var(--r); padding: 16px; text-align: center;
|
||||
}
|
||||
.stat-val { font-family: var(--mono); font-size: 28px; font-weight: 700; color: var(--t1); }
|
||||
.stat-lbl { font-size: 11px; color: var(--t4); margin-top: 4px; font-family: var(--mono); }
|
||||
|
||||
.ops-card {
|
||||
background: var(--bg-card); border: 1px solid var(--rule);
|
||||
border-radius: var(--r); padding: 20px;
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 20px;
|
||||
}
|
||||
.ops-desc { font-size: 13px; color: var(--t3); line-height: 1.6; }
|
||||
.trigger-btn { flex-shrink: 0; padding: 0 24px; height: 42px; font-size: 14px; }
|
||||
|
||||
/* Logs */
|
||||
.log-list { background: var(--bg-card); border: 1px solid var(--rule); border-radius: var(--r); overflow: hidden; }
|
||||
.log-row {
|
||||
display: flex; gap: 12px; align-items: baseline;
|
||||
padding: 8px 14px; border-bottom: 1px solid var(--rule);
|
||||
font-family: var(--mono); font-size: 12px;
|
||||
}
|
||||
.log-row:last-child { border-bottom: none; }
|
||||
.log-time { color: var(--t4); flex-shrink: 0; }
|
||||
.log-level { width: 50px; flex-shrink: 0; font-weight: 700; }
|
||||
.log-msg { color: var(--t2); }
|
||||
.log-info .log-level { color: var(--blue-2); }
|
||||
.log-error .log-level { color: var(--red); }
|
||||
.log-warn .log-level { color: var(--amber); }
|
||||
</style>
|
||||
398
frontend/src/views/NewsReader.vue
Normal file
398
frontend/src/views/NewsReader.vue
Normal file
@@ -0,0 +1,398 @@
|
||||
<template>
|
||||
<div class="reader-layout">
|
||||
<!-- ── 顶部导航 ─────────────────────────────────────────────────── -->
|
||||
<header class="topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="logo-chip">PI</div>
|
||||
<span class="app-title">医药情报</span>
|
||||
</div>
|
||||
<div class="live-bar">
|
||||
<span class="live-dot"></span>
|
||||
<span>{{ selectedDate }} · 今日 {{ totalCount }} 条</span>
|
||||
</div>
|
||||
<div class="topbar-right">
|
||||
<router-link to="/admin" class="admin-link">管理后台 →</router-link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ── 分类筛选 ─────────────────────────────────────────────────── -->
|
||||
<nav class="cat-nav">
|
||||
<button
|
||||
v-for="c in CATEGORIES"
|
||||
:key="c.value"
|
||||
:class="['cat-pill', { active: activeCategory === c.value }]"
|
||||
@click="setCategory(c.value)"
|
||||
>{{ c.label }}</button>
|
||||
</nav>
|
||||
|
||||
<!-- ── 主内容区 ─────────────────────────────────────────────────── -->
|
||||
<div class="main-content">
|
||||
<TopTenPanel
|
||||
:items="featuredNews"
|
||||
:dates="archiveDates"
|
||||
:active-id="selectedNews?.id"
|
||||
:selected-date="selectedDate"
|
||||
@select="openDetail"
|
||||
@date-change="loadDate"
|
||||
/>
|
||||
|
||||
<div class="news-feed">
|
||||
<div v-if="loading" class="feed-state">
|
||||
<div class="spinner"></div>
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
<template v-else-if="newsList.length">
|
||||
<NewsCard
|
||||
v-for="n in newsList"
|
||||
:key="n.id"
|
||||
:news="n"
|
||||
@open="openDetail"
|
||||
/>
|
||||
<div v-if="hasMore" class="load-more" @click="loadMore">加载更多</div>
|
||||
</template>
|
||||
<div v-else class="feed-state">今日暂无数据,请先在管理后台触发抓取</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 详情面板 ─────────────────────────────────────────────────── -->
|
||||
<Transition name="sheet">
|
||||
<div v-if="selectedNews" class="sheet-bg" @click.self="selectedNews = null">
|
||||
<div class="sheet">
|
||||
<div class="sheet-handle" @click="selectedNews = null"></div>
|
||||
<div class="sheet-inner">
|
||||
<div class="sheet-head">
|
||||
<div class="sh-tags">
|
||||
<span class="kw-tag cat-tag">{{ selectedNews.category }}</span>
|
||||
<span class="score-badge" :class="badgeClass(selectedNews.importance_score)">
|
||||
{{ selectedNews.importance_score?.toFixed(1) }}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="sh-title">{{ selectedNews.title_zh }}</h2>
|
||||
<div class="sh-meta">
|
||||
{{ selectedNews.source_name }} · {{ selectedNews.published_at?.slice(0, 10) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheet-body">
|
||||
<p class="sh-summary">{{ selectedNews.summary }}</p>
|
||||
|
||||
<div v-if="selectedNews.opinion" class="sh-block sh-blue">
|
||||
<b>核心观点</b>{{ selectedNews.opinion }}
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNews.importance_reason" class="sh-block sh-mint">
|
||||
<b>评分依据 · {{ selectedNews.importance_score?.toFixed(1) }} 分</b>
|
||||
{{ selectedNews.importance_reason }}
|
||||
</div>
|
||||
|
||||
<div v-if="selectedNews.keywords?.length" class="sh-kw">
|
||||
<span v-for="kw in selectedNews.keywords" :key="kw" class="kw-tag">{{ kw }}</span>
|
||||
</div>
|
||||
|
||||
<div class="sh-actions">
|
||||
<a :href="selectedNews.source_url" target="_blank" class="btn-primary">查看原文 →</a>
|
||||
<button class="btn-secondary" @click="selectedNews = null">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import NewsCard from '../components/NewsCard.vue'
|
||||
import TopTenPanel from '../components/TopTenPanel.vue'
|
||||
import { fetchFeatured, fetchNews, fetchDates } from '../api/index.js'
|
||||
|
||||
const CATEGORIES = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '药品监管', value: '药品监管' },
|
||||
{ label: '临床研究', value: '临床研究' },
|
||||
{ label: '行业动态', value: '行业动态' },
|
||||
{ label: '政策法规', value: '政策法规' },
|
||||
]
|
||||
|
||||
const selectedDate = ref(new Date().toISOString().slice(0, 10))
|
||||
const activeCategory = ref('')
|
||||
const featuredNews = ref([])
|
||||
const newsList = ref([])
|
||||
const archiveDates = ref([])
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const totalCount = ref(0)
|
||||
const hasMore = ref(false)
|
||||
const selectedNews = ref(null)
|
||||
|
||||
function badgeClass(score) {
|
||||
if (score >= 9) return 'badge-red'
|
||||
if (score >= 7) return 'badge-amber'
|
||||
if (score >= 5) return 'badge-blue'
|
||||
return 'badge-gray'
|
||||
}
|
||||
|
||||
async function loadAll(date) {
|
||||
loading.value = true
|
||||
try {
|
||||
const [feat, list, dates] = await Promise.all([
|
||||
fetchFeatured(date),
|
||||
fetchNews({ date, category: activeCategory.value, page: 1 }),
|
||||
fetchDates(),
|
||||
])
|
||||
featuredNews.value = feat.items || []
|
||||
newsList.value = list.items || []
|
||||
totalCount.value = list.total || 0
|
||||
hasMore.value = list.items?.length < list.total
|
||||
archiveDates.value = dates || []
|
||||
page.value = 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDate(date) {
|
||||
selectedDate.value = date
|
||||
await loadAll(date)
|
||||
}
|
||||
|
||||
async function setCategory(cat) {
|
||||
activeCategory.value = cat
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await fetchNews({ date: selectedDate.value, category: cat, page: 1 })
|
||||
newsList.value = list.items || []
|
||||
totalCount.value = list.total || 0
|
||||
hasMore.value = list.items?.length < list.total
|
||||
page.value = 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
page.value++
|
||||
const list = await fetchNews({ date: selectedDate.value, category: activeCategory.value, page: page.value })
|
||||
newsList.value.push(...(list.items || []))
|
||||
hasMore.value = newsList.value.length < list.total
|
||||
}
|
||||
|
||||
function openDetail(news) {
|
||||
selectedNews.value = news
|
||||
}
|
||||
|
||||
onMounted(() => loadAll(selectedDate.value))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reader-layout {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Topbar ─────────────────────────────────────────────────────────────────── */
|
||||
.topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: 56px;
|
||||
background: rgba(7,9,26,.95);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
gap: 16px;
|
||||
}
|
||||
.topbar-left { display: flex; align-items: center; gap: 10px; }
|
||||
.logo-chip {
|
||||
width: 30px; height: 30px;
|
||||
background: linear-gradient(135deg, var(--blue), var(--violet));
|
||||
border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-family: var(--mono); font-size: 11px; font-weight: 700; color: #fff;
|
||||
}
|
||||
.app-title { font-size: 16px; font-weight: 700; letter-spacing: -.02em; }
|
||||
.live-bar {
|
||||
flex: 1;
|
||||
display: flex; align-items: center; gap: 6px; justify-content: center;
|
||||
font-size: 12px; color: var(--t4); font-family: var(--mono);
|
||||
}
|
||||
.live-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; background: var(--mint);
|
||||
box-shadow: 0 0 0 0 rgba(37,214,163,.4);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(37,214,163,.4); }
|
||||
70% { box-shadow: 0 0 0 6px rgba(37,214,163,0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(37,214,163,0); }
|
||||
}
|
||||
.topbar-right { display: flex; align-items: center; }
|
||||
.admin-link {
|
||||
font-size: 12px; color: var(--blue-2); font-family: var(--mono);
|
||||
padding: 5px 12px; border: 1px solid var(--blue-bd); border-radius: var(--r-pill);
|
||||
transition: background .15s;
|
||||
}
|
||||
.admin-link:hover { background: var(--blue-gl); }
|
||||
|
||||
/* ── Category nav ────────────────────────────────────────────────────────────── */
|
||||
.cat-nav {
|
||||
position: sticky;
|
||||
top: 56px;
|
||||
z-index: 99;
|
||||
background: rgba(7,9,26,.92);
|
||||
backdrop-filter: blur(8px);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 10px 24px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.cat-nav::-webkit-scrollbar { display: none; }
|
||||
.cat-pill {
|
||||
flex-shrink: 0;
|
||||
padding: 5px 14px;
|
||||
border-radius: var(--r-pill);
|
||||
border: 1px solid var(--rule2);
|
||||
background: var(--bg-2);
|
||||
color: var(--t3);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
transition: all .15s;
|
||||
}
|
||||
.cat-pill:hover { color: var(--t2); border-color: var(--rule2); }
|
||||
.cat-pill.active { background: var(--blue-gl); border-color: var(--blue-bd); color: var(--blue-2); }
|
||||
|
||||
/* ── Main layout ─────────────────────────────────────────────────────────────── */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px 24px;
|
||||
max-width: 1400px;
|
||||
width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.news-feed { flex: 1; min-width: 0; }
|
||||
|
||||
.feed-state {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 40px 0; color: var(--t4); font-size: 13px;
|
||||
}
|
||||
.spinner {
|
||||
width: 16px; height: 16px; border-radius: 50%;
|
||||
border: 2px solid var(--rule2); border-top-color: var(--blue-2);
|
||||
animation: spin .8s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.load-more {
|
||||
text-align: center; padding: 16px;
|
||||
font-size: 13px; color: var(--blue-2);
|
||||
cursor: pointer; border: 1px dashed var(--blue-bd);
|
||||
border-radius: var(--r); transition: background .15s;
|
||||
}
|
||||
.load-more:hover { background: var(--blue-gl); }
|
||||
|
||||
/* ── Detail sheet ────────────────────────────────────────────────────────────── */
|
||||
.sheet-bg {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
display: flex; align-items: flex-end; justify-content: center;
|
||||
}
|
||||
.sheet {
|
||||
width: 100%; max-width: 680px;
|
||||
background: var(--bg-1);
|
||||
border-radius: 20px 20px 0 0;
|
||||
border-top: 1px solid var(--rule2);
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.sheet::-webkit-scrollbar { display: none; }
|
||||
.sheet-handle {
|
||||
width: 36px; height: 4px; background: var(--rule2); border-radius: 2px;
|
||||
margin: 10px auto 0; cursor: pointer;
|
||||
}
|
||||
.sheet-inner { padding: 8px 0 40px; }
|
||||
|
||||
.sheet-head { padding: 12px 24px 16px; }
|
||||
.sh-tags { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
|
||||
.cat-tag { background: rgba(122,128,160,.15); color: var(--t3); }
|
||||
.score-badge {
|
||||
padding: 2px 8px; border-radius: 5px;
|
||||
font-family: var(--mono); font-size: 11px; font-weight: 700;
|
||||
}
|
||||
.badge-red { background: rgba(255,77,106,.2); color: var(--red); }
|
||||
.badge-amber { background: rgba(255,163,54,.2); color: var(--amber); }
|
||||
.badge-blue { background: rgba(46,85,245,.2); color: var(--blue-2); }
|
||||
.badge-gray { background: rgba(122,128,160,.15); color: var(--t3); }
|
||||
|
||||
.sh-title { font-size: 18px; font-weight: 700; color: var(--t1); line-height: 1.4; margin-bottom: 6px; }
|
||||
.sh-meta { font-size: 11px; color: var(--t4); font-family: var(--mono); }
|
||||
|
||||
.sheet-body { padding: 0 24px; }
|
||||
.sh-summary { font-size: 14px; color: var(--t2); line-height: 1.7; margin-bottom: 14px; }
|
||||
|
||||
.sh-block {
|
||||
border-radius: 0 var(--r-sm) var(--r-sm) 0;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.sh-blue { background: var(--blue-gl); border-left: 2px solid var(--blue-bd); color: #8AAAFF; }
|
||||
.sh-mint { background: rgba(37,214,163,.06); border-left: 2px solid rgba(37,214,163,.4); color: rgba(37,214,163,.85); }
|
||||
.sh-block b {
|
||||
display: block; font-family: var(--mono); font-size: 10px;
|
||||
font-weight: 700; margin-bottom: 4px; opacity: .7; letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.sh-kw { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
|
||||
|
||||
.kw-tag {
|
||||
font-size: 10px; font-family: var(--mono);
|
||||
padding: 2px 7px; border-radius: 4px;
|
||||
background: rgba(46,85,245,.15); color: #7B9BFF;
|
||||
}
|
||||
|
||||
.sh-actions { display: flex; gap: 10px; }
|
||||
.btn-primary {
|
||||
flex: 1; height: 42px; border-radius: 10px;
|
||||
background: var(--blue); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; font-weight: 600;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-primary:hover { background: var(--blue-2); }
|
||||
.btn-secondary {
|
||||
flex: 1; height: 42px; border-radius: 10px;
|
||||
background: var(--bg-2); border: 1px solid var(--rule2); color: var(--t2);
|
||||
font-size: 13px; font-weight: 600;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-secondary:hover { background: var(--bg-hi); }
|
||||
|
||||
/* ── Transitions ─────────────────────────────────────────────────────────────── */
|
||||
.sheet-enter-active, .sheet-leave-active { transition: opacity .25s; }
|
||||
.sheet-enter-active .sheet, .sheet-leave-active .sheet { transition: transform .3s cubic-bezier(.32,.72,0,1); }
|
||||
.sheet-enter-from, .sheet-leave-to { opacity: 0; }
|
||||
.sheet-enter-from .sheet, .sheet-leave-to .sheet { transform: translateY(100%); }
|
||||
|
||||
/* ── Mobile ──────────────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 768px) {
|
||||
.main-content { padding: 12px 16px; gap: 0; }
|
||||
/* TopTenPanel becomes horizontally scrollable on mobile — handled in component */
|
||||
.topbar { padding: 0 16px; }
|
||||
.cat-nav { padding: 8px 16px; }
|
||||
.live-bar { display: none; }
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user