feat: initial commit - oncology literature search platform
CI / backend (push) Canceled after 0s
CI / frontend (push) Canceled after 0s

OncoLit: a multi-tenant oncology literature search, feed, and
collaboration platform. Built with FastAPI + Vue 3 + PostgreSQL.
Includes PubMed pipeline, drug approvals, AI summaries, and
systematic review tools.
This commit is contained in:
34047007@qq.com
2026-07-27 07:59:18 +08:00
commit a6cd99a4ca
473 changed files with 151472 additions and 0 deletions
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { NAvatar, NBreadcrumb, NBreadcrumbItem, NButton, NDrawer, NDrawerContent, NIcon, NLayout, NLayoutHeader, NLayoutSider, NMenu, NSpace } from 'naive-ui'
import { useAuthStore } from '../../stores/auth'
import { useUiStore } from '../../stores/ui'
import { AddOutline } from '@vicons/ionicons5'
const logoLight = new URL('../../assets/logo-light.png', import.meta.url).href
const logoDark = new URL('../../assets/logo-dark.png', import.meta.url).href
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const ui = useUiStore()
const baseMenu = [
{ label: '📊 看板', key: 'dashboard' },
{ label: '🏢 租户', key: 'tenants' },
{ label: '👤 用户', key: 'users' },
{ label: '📡 管道', key: 'pipeline' },
{ label: '🏷️ 标签', key: 'tags' },
{ label: '📰 期刊', key: 'journals' },
{ label: '📋 指南', key: 'guidelines' },
{ label: '💊 审批', key: 'drug-approvals' },
{ label: '🔔 公告', key: 'notifications' },
{ label: '🌐 访问记录', key: 'page-views' },
{ label: '💬 反馈', key: 'feedback' },
{ label: '📈 运营', key: 'analytics' },
{ label: '⚙️ 配置', key: 'config' },
]
const menuOptions = computed(() => {
const isOwner = auth.user?.platform_role === 'platform_owner'
const rolesItem = { label: '🛡️ 平台角色', key: 'roles' }
return isOwner ? [...baseMenu.slice(0, 3), rolesItem, ...baseMenu.slice(3)] : baseMenu
})
const activeKey = computed(() => {
const p = route.path.split('/admin/')[1] || 'dashboard'
return p
})
function handleMenu(k: string) { router.push(`/admin/${k}`) }
onMounted(() => {
// 阻止搜索引擎索引管理后台
let el = document.querySelector('meta[name="robots"]') as HTMLMetaElement | null
if (!el) {
el = document.createElement('meta')
el.setAttribute('name', 'robots')
document.head.appendChild(el)
}
el.setAttribute('content', 'noindex, nofollow')
})
</script>
<template>
<NLayout has-sider style="min-height:100vh">
<NLayoutSider v-if="!ui.isMobile" bordered width="200" collapse-mode="transform" :collapsed="ui.sidebarCollapsed">
<div style="height:64px;display:flex;align-items:center;justify-content:center;border-bottom:1px solid var(--border-color);cursor:pointer;padding:0 12px" @click="router.push('/')" title="OncoLit 肿瘤科研文献,每日更新">
<img :src="ui.isDark ? logoDark : logoLight" style="height:40px;max-width:100%;object-fit:contain" alt="OncoLit" loading="lazy">
</div>
<NMenu :value="activeKey" :options="menuOptions" @update:value="handleMenu" />
</NLayoutSider>
<NLayout>
<NLayoutHeader bordered style="height:56px;display:flex;align-items:center;justify-content:space-between;padding:0 12px 0 24px;background:var(--bg-card)">
<div style="display:flex;align-items:center;gap:8px">
<NButton v-if="ui.isMobile" size="small" @click="ui.toggleSidebar()" class="sky-btn"></NButton>
<NBreadcrumb>
<NBreadcrumbItem @click="router.push('/admin')">管理后台</NBreadcrumbItem>
<NBreadcrumbItem>{{ menuOptions.find(m=>m.key===activeKey)?.label||'' }}</NBreadcrumbItem>
</NBreadcrumb>
</div>
<NSpace>
<NButton size="small" class="sky-btn" @click="router.push('/')" title="首页"><template #icon><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg></template></NButton>
<NButton size="small" class="sky-btn" @click="router.push('/app/feed')">📱 用户端</NButton>
<NButton size="small" class="sky-btn" @click="ui.toggleDark()"><template #icon><NIcon size="14"><AddOutline /></NIcon></template>{{ ui.isDark ? '☀️' : '🌙' }}</NButton>
<NAvatar size="small">{{ auth.user?.display_name?.[0]||'A' }}</NAvatar>
</NSpace>
</NLayoutHeader>
<!-- Mobile 抽屉菜单 -->
<NDrawer v-if="ui.isMobile" :show="!ui.sidebarCollapsed" placement="left" :width="200" @update:show="v => !v && ui.toggleSidebar()">
<NDrawerContent title="管理后台" closable>
<NMenu :value="activeKey" :options="menuOptions" @update:value="k => { handleMenu(k); ui.toggleSidebar() }" />
</NDrawerContent>
</NDrawer>
<div style="padding:16px;max-width:1400px">
<RouterView />
</div>
</NLayout>
</NLayout>
</template>
+432
View File
@@ -0,0 +1,432 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { NCard, NGrid, NGridItem, NStatistic, NSkeleton, NTag, useMessage } from 'naive-ui'
import { Bar, Pie, Line } from 'vue-chartjs'
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler } from 'chart.js'
import { api } from '../../api/client'
import { toBeijingDateTime } from '../../utils/date'
const message = useMessage()
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement, Filler)
interface TenantInfo { plan_type: string; [key: string]: unknown }
interface DashboardStats { tenants: number; users: number; literature: number; wau_7d: number; saved: number; notes: number; [key: string]: unknown }
interface PipelineRun { type: string; status: string; new: number; feeds: number; completed: string | null }
const loading = ref(true)
const stats = ref<DashboardStats>({} as DashboardStats)
const tenantList = ref<TenantInfo[]>([])
const feedbackCount = ref(0)
const userGrowth = ref<{year: number; month: number; count: number}[]>([])
const pipelines = ref<PipelineRun[]>([])
const tagCategories = ref<{category: string; count: number; articles: number}[]>([])
const dauData = ref<{date: string; count: number}[]>([])
const pipelineTrend = ref<PipelineRun[]>([])
const pageViews = ref<{date: string; pv: number; uv: number}[]>([])
const anonPageViews = ref<{date: string; pv: number; uv: number}[]>([])
const behaviors = ref<{type: string; count: number}[]>([])
const popularPages = ref<{type: string; id: string; count: number}[]>([])
// ── Phase 3 新增 ──
const sessionTrend = ref<{date: string; sessions: number; unique_users: number; avg_duration_seconds: number}[]>([])
const funnel = ref<{step: string; label: string; users: number; conversion_rate: number}[]>([])
const retention = ref<{cohort: string; total: number; weeks: {week: number; active_users: number; retention_rate: number}[]}[]>([])
onMounted(async () => {
try {
const [dashR, tenantsR, feedbackR, growthR, tagsR, dauR, pipelineR, pvR, anonPvR, behR, popR, sessR, funR, retR] = await Promise.all([
api.get('/admin/dashboard'),
api.get('/admin/tenants', { params: { page_size: 100 } }),
api.get('/settings/feedback'),
api.get('/admin/users/growth'),
api.get('/admin/tags/distribution'),
api.get('/admin/stats/dau', { params: { days: 30 } }),
api.get('/admin/pipeline/runs', { params: { page_size: 30 } }),
api.get('/admin/stats/page-views', { params: { days: 30 } }),
api.get('/admin/stats/anonymous-page-views', { params: { days: 30 } }),
api.get('/admin/stats/anonymous-page-views', { params: { days: 30 } }),
api.get('/admin/stats/behavior', { params: { days: 7 } }),
api.get('/admin/stats/popular-pages', { params: { days: 7, limit: 20 } }),
api.get('/analytics/sessions/trend', { params: { days: 30 } }).catch(() => ({ data: { items: [] } })),
api.get('/analytics/funnel', { params: { days: 30 } }).catch(() => ({ data: { funnel: [] } })),
api.get('/analytics/retention', { params: { weeks: 12 } }).catch(() => ({ data: { cohorts: [] } })),
])
stats.value = dashR.data.stats || {}
pipelines.value = dashR.data.recent_pipelines || []
tenantList.value = tenantsR.data.items || []
feedbackCount.value = (feedbackR.data.items || []).length
userGrowth.value = growthR.data.growth || []
tagCategories.value = tagsR.data.categories || []
dauData.value = dauR.data.dau || []
pipelineTrend.value = (pipelineR.data.items || []).reverse()
pageViews.value = pvR.data.page_views || []
anonPageViews.value = anonPvR.data.page_views || []
behaviors.value = behR.data.behaviors || []
popularPages.value = popR.data.pages || []
sessionTrend.value = (sessR as any)?.data?.items || []
funnel.value = (funR as any)?.data?.funnel || []
retention.value = (retR as any)?.data?.cohorts || []
} catch (e: any) {
message.error(e?.response?.data?.detail || '加载分析数据失败')
} finally { loading.value = false }
})
const revenueData = computed(() => ({
labels: ['Free', 'Pro', 'Team', 'Enterprise'],
datasets: [{
label: '租户数',
data: [
tenantList.value.filter((t: TenantInfo) => t.plan_type === 'free').length,
tenantList.value.filter((t: TenantInfo) => t.plan_type === 'pro').length,
tenantList.value.filter((t: TenantInfo) => t.plan_type === 'team').length,
],
backgroundColor: ['#999', '#2080f0', '#18a058'],
}],
}))
const growthData = computed(() => ({
labels: userGrowth.value.map((g: {year: number; month: number}) => `${g.year}-${String(g.month).padStart(2, '0')}`),
datasets: [{
label: '新增用户',
data: userGrowth.value.map((g: {count: number}) => g.count),
borderColor: '#2080f0',
backgroundColor: 'rgba(32,128,240,0.1)',
fill: true,
tension: 0.3,
}],
}))
const tagPieData = computed(() => ({
labels: tagCategories.value.map((c: {category: string}) => c.category),
datasets: [{
data: tagCategories.value.map((c: {count: number}) => c.count),
backgroundColor: ['#2080f0', '#18a058', '#d03050', '#f0a020', '#7c4dff', '#eb2f96', '#13c2c2', '#fa8c16'],
}],
}))
const estimatedMRR = computed(() => {
return tenantList.value.filter((t: TenantInfo) => t.plan_type === 'pro').length * 35 +
tenantList.value.filter((t: TenantInfo) => t.plan_type === 'team').length * 70 * 5
})
const dauChartData = computed(() => ({
labels: dauData.value.map((d: {date: string}) => {
const parts = d.date.split('-')
return `${parts[1]}/${parts[2]}`
}),
datasets: [{
label: '日活跃用户',
data: dauData.value.map((d: {count: number}) => d.count),
borderColor: '#18a058',
backgroundColor: 'rgba(24,160,88,0.1)',
fill: true,
tension: 0.3,
}],
}))
const pageViewChartData = computed(() => ({
labels: pageViews.value.map((d) => {
const parts = d.date.split('-')
return `${parts[1]}/${parts[2]}`
}),
datasets: [
{
label: 'PV',
data: pageViews.value.map((d) => d.pv),
borderColor: '#2080f0',
backgroundColor: 'rgba(32,128,240,0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y',
},
{
label: 'UV',
data: pageViews.value.map((d) => d.uv),
borderColor: '#18a058',
backgroundColor: 'rgba(24,160,88,0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y1',
},
],
}))
const anonPageViewChartData = computed(() => ({
labels: anonPageViews.value.map((d) => {
const parts = d.date.split('-')
return `${parts[1]}/${parts[2]}`
}),
datasets: [
{
label: '匿名PV',
data: anonPageViews.value.map((d) => d.pv),
borderColor: '#f0a020',
backgroundColor: 'rgba(240,160,32,0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y',
},
{
label: '匿名UV',
data: anonPageViews.value.map((d) => d.uv),
borderColor: '#d03050',
backgroundColor: 'rgba(208,48,80,0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y1',
},
],
}))
const behaviorPieData = computed(() => ({
labels: behaviors.value.map((b) => b.type),
datasets: [{
data: behaviors.value.map((b) => b.count),
backgroundColor: ['#2080f0', '#18a058', '#d03050', '#f0a020', '#7c4dff', '#eb2f96', '#13c2c2', '#fa8c16'],
}],
}))
const pipelineVolumeData = computed(() => ({
labels: pipelineTrend.value.map((p: PipelineRun) =>
p.completed ? p.completed.slice(5, 10) : '--'
),
datasets: [
{
label: '新增文献',
data: pipelineTrend.value.map((p: PipelineRun) => p.new || 0),
backgroundColor: '#2080f0',
borderRadius: 3,
},
{
label: '生成推送',
data: pipelineTrend.value.map((p: PipelineRun) => p.feeds || 0),
backgroundColor: '#18a058',
borderRadius: 3,
},
],
}))
const sessionChartData = computed(() => ({
labels: sessionTrend.value.map((d: any) => {
const parts = d.date.split('-')
return `${parts[1]}/${parts[2]}`
}),
datasets: [{
label: '会话数',
data: sessionTrend.value.map((d: any) => d.sessions),
borderColor: '#7c4dff',
backgroundColor: 'rgba(124,77,255,0.1)',
fill: true,
tension: 0.3,
}],
}))
const funnelChartData = computed(() => ({
labels: funnel.value.map((f: any) => f.label),
datasets: [{
label: '用户数',
data: funnel.value.map((f: any) => f.users),
backgroundColor: ['#2080f0', '#18a058', '#f0a020', '#d03050', '#7c4dff'],
borderRadius: 3,
}],
}))
const todaySessionStats = computed(() => {
const today = sessionTrend.value[sessionTrend.value.length - 1]
if (!today) return { sessions: 0, avgDuration: 0, uniqueUsers: 0 }
return {
sessions: today.sessions || 0,
avgDuration: today.avg_duration_seconds || 0,
uniqueUsers: today.unique_users || 0,
}
})
</script>
<template>
<div>
<h2 style="margin-bottom:16px">运营分析</h2>
<NSkeleton v-if="loading" text :repeat="6" />
<template v-else>
<!-- 概览指标 -->
<n-grid :cols="4" :x-gap="12" responsive="screen" style="margin-bottom:24px">
<n-grid-item><n-card size="small"><n-statistic label="总租户" :value="stats.tenants||0"/></n-card></n-grid-item>
<n-grid-item><n-card size="small"><n-statistic label="总用户" :value="stats.users||0"/></n-card></n-grid-item>
<n-grid-item><n-card size="small"><n-statistic label="文献总量" :value="stats.literature||0"/></n-card></n-grid-item>
<n-grid-item><n-card size="small"><n-statistic label="7日活跃" :value="stats.wau_7d||0"/></n-card></n-grid-item>
</n-grid>
<n-grid :cols="4" :x-gap="12" responsive="screen" style="margin-bottom:24px">
<n-grid-item><n-card size="small"><n-statistic label="用户反馈" :value="feedbackCount||0"/></n-card></n-grid-item>
<n-grid-item><n-card size="small"><n-statistic label="估算MRR" :value="`¥${estimatedMRR||0}`"/></n-card></n-grid-item>
<n-grid-item><n-card size="small"><n-statistic label="人均收藏" :value="stats.users ? Math.round((stats.saved||0)/(stats.users||1)) : 0"/></n-card></n-grid-item>
<n-grid-item><n-card size="small"><n-statistic label="文献/人" :value="stats.users ? Math.round((stats.literature||0)/(stats.users||1)) : 0"/></n-card></n-grid-item>
</n-grid>
<!-- 图表行2×2 -->
<n-grid :cols="2" :x-gap="16" :y-gap="16" responsive="screen" style="margin-bottom:24px">
<n-grid-item>
<n-card title="方案分布" size="small">
<Bar :data="revenueData" :options="{ responsive: true, plugins: { legend: { display: false } } }" style="max-height:200px" />
<div style="text-align:center;margin-top:8px;font-size:12px;color:var(--text-secondary)">MRR¥{{ estimatedMRR }}/</div>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="用户增长趋势" size="small">
<Line v-if="userGrowth.length" :data="growthData" :options="{ responsive: true, plugins: { legend: { display: false } } }" style="max-height:200px" />
<div v-else style="height:200px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px">暂无增长数据</div>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="标签分类分布" size="small">
<Pie v-if="tagCategories.length" :data="tagPieData" :options="{ responsive: true, plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, font: { size: 10 } } } } }" style="max-height:200px" />
<div v-else style="height:200px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px">暂无标签数据</div>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="日活跃用户 (近30天)" size="small">
<Line v-if="dauData.length" :data="dauChartData" :options="{ responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } } }" style="max-height:200px" />
<div v-else style="height:200px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px">暂无登录数据</div>
</n-card>
</n-grid-item>
</n-grid>
<!-- 管道运行趋势 -->
<n-card title="管道运行量趋势" size="small" style="margin-bottom:24px">
<Bar v-if="pipelineTrend.length" :data="pipelineVolumeData" :options="{ responsive: true, plugins: { legend: { position: 'top', labels: { boxWidth: 10, font: { size: 11 } } } }, scales: { x: { ticks: { maxRotation: 45, font: { size: 10 } } }, y: { beginAtZero: true } } }" style="max-height:250px" />
<div v-else style="height:100px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px">暂无运行记录</div>
</n-card>
<!-- 流水线 + 指标 -->
<n-grid :cols="2" :x-gap="16" responsive="screen">
<n-grid-item>
<n-card title="最近管道运行" size="small">
<div v-if="pipelines.length">
<div v-for="(p,i) in pipelines" :key="i" style="display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border-color);font-size:13px">
<div>
<NTag size="tiny" :type="p.status==='completed'?'success':'warning'" :bordered="false">{{ p.status }}</NTag>
<span style="margin-left:6px">{{ p.type }}</span>
</div>
<div style="color:var(--text-muted)">
<span v-if="p.new>0">+{{ p.new }} </span>
<span v-if="p.completed">{{ toBeijingDateTime(p.completed) || '' }}</span>
</div>
</div>
</div>
<div v-else style="color:var(--text-muted);text-align:center;padding:16px;font-size:13px">暂无运行记录</div>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="数据指标" size="small">
<div style="padding:8px">
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border-color);font-size:13px">
<span>平均每租户用户</span><strong>{{ stats.tenants ? (stats.users/stats.tenants).toFixed(1) : 0 }}</strong>
</div>
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border-color);font-size:13px">
<span>笔记总量</span><strong>{{ stats.notes||0 }}</strong>
</div>
<div style="display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid var(--border-color);font-size:13px">
<span>收藏总量</span><strong>{{ stats.saved||0 }}</strong>
</div>
<div style="display:flex;justify-content:space-between;padding:8px 0;font-size:13px">
<span>付费转化率</span>
<strong>{{ stats.tenants ? Math.round(tenantList.filter((t: TenantInfo)=>t.plan_type!=='free').length/stats.tenants*100) : 0 }}%</strong>
</div>
</div>
</n-card>
</n-grid-item>
</n-grid>
<!-- 页面访问趋势 -->
<n-card title="页面访问趋势 (近30天)" size="small" style="margin-bottom:24px">
<div style="font-size:13px;color:var(--text-muted);margin-bottom:8px">注册用户</div>
<Line v-if="pageViews.length" :data="pageViewChartData" :options="{ responsive: true, plugins: { legend: { position: 'top', labels: { boxWidth: 10, font: { size: 11 } } } }, scales: { y: { beginAtZero: true, type: 'linear', position: 'left' }, y1: { beginAtZero: true, type: 'linear', position: 'right', grid: { display: false } } } }" style="max-height:220px" />
<div v-else style="height:80px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px">暂无注册用户访问数据</div>
<div style="border-top:1px solid var(--border-color);margin:16px 0" />
<div style="font-size:13px;color:var(--text-muted);margin-bottom:8px">匿名用户</div>
<Line v-if="anonPageViews.length" :data="anonPageViewChartData" :options="{ responsive: true, plugins: { legend: { position: 'top', labels: { boxWidth: 10, font: { size: 11 } } } }, scales: { y: { beginAtZero: true, type: 'linear', position: 'left' }, y1: { beginAtZero: true, type: 'linear', position: 'right', grid: { display: false } } } }" style="max-height:220px" />
<div v-else style="height:80px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px">暂无匿名用户访问数据</div>
</n-card>
<!-- 用户行为 + 热门页面 -->
<n-grid :cols="2" :x-gap="16" responsive="screen" style="margin-bottom:24px">
<n-grid-item>
<n-card title="用户行为分布 (近7天)" size="small">
<Pie v-if="behaviors.length" :data="behaviorPieData" :options="{ responsive: true, plugins: { legend: { position: 'bottom', labels: { boxWidth: 10, font: { size: 10 } } } } }" style="max-height:200px" />
<div v-else style="height:200px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px">暂无行为数据</div>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="热门页面 (近7天)" size="small">
<div v-if="popularPages.length">
<div v-for="(p,i) in popularPages.slice(0,10)" :key="i" style="display:flex;justify-content:space-between;align-items:center;padding:6px 0;border-bottom:1px solid var(--border-color);font-size:13px">
<div>
<span style="color:var(--text-muted);margin-right:6px">{{ i + 1 }}.</span>
<span>{{ p.id || p.type }}</span>
</div>
<NTag size="tiny" :bordered="false">{{ p.count }}</NTag>
</div>
</div>
<div v-else style="height:100px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px">暂无热门页面数据</div>
</n-card>
</n-grid-item>
</n-grid>
<!-- Phase 3: 会话分析 + 漏斗 + 留存 -->
<n-grid :cols="2" :x-gap="16" :y-gap="16" responsive="screen" style="margin-bottom:24px">
<n-grid-item>
<n-card title="会话分析" size="small">
<n-grid :cols="3" :x-gap="8" style="margin-bottom:12px">
<n-grid-item><n-statistic label="今日会话" :value="todaySessionStats.sessions" /></n-grid-item>
<n-grid-item><n-statistic label="平均时长" :value="`${Math.round(todaySessionStats.avgDuration)}s`" /></n-grid-item>
<n-grid-item><n-statistic label="独立用户" :value="todaySessionStats.uniqueUsers" /></n-grid-item>
</n-grid>
<Line v-if="sessionTrend.length" :data="sessionChartData" :options="{ responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true } } }" style="max-height:200px" />
<div v-else style="height:100px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px">暂无会话数据</div>
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="行为漏斗" size="small">
<Bar v-if="funnel.length" :data="funnelChartData" :options="{ responsive: true, indexAxis: 'y', plugins: { legend: { display: false } }, scales: { x: { beginAtZero: true } } }" style="max-height:200px" />
<div v-else style="height:100px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px">暂无漏斗数据</div>
<div v-if="funnel.length" style="margin-top:8px">
<div v-for="f in funnel" :key="f.step" style="display:flex;justify-content:space-between;padding:3px 0;font-size:12px">
<span>{{ f.label }}</span>
<span><strong>{{ f.users }}</strong> · {{ f.conversion_rate }}%</span>
</div>
</div>
</n-card>
</n-grid-item>
</n-grid>
<!-- 留存分析 -->
<n-card title="留存分析 (周级 Cohort)" size="small" style="margin-bottom:24px">
<div v-if="retention.length" style="overflow-x:auto">
<table style="border-collapse:collapse;font-size:12px;width:100%">
<thead>
<tr>
<th style="padding:6px 8px;text-align:left;border:1px solid var(--border-color);background:var(--bg-hover)">Cohort</th>
<th style="padding:6px 8px;text-align:right;border:1px solid var(--border-color);background:var(--bg-hover)">用户数</th>
<th v-for="i in 8" :key="i" style="padding:6px 4px;text-align:center;border:1px solid var(--border-color);background:var(--bg-hover)">W{{ i-1 }}</th>
</tr>
</thead>
<tbody>
<tr v-for="c in retention.slice(0,10)" :key="c.cohort">
<td style="padding:4px 8px;border:1px solid var(--border-color);white-space:nowrap">{{ c.cohort.slice(5) }}</td>
<td style="padding:4px 8px;border:1px solid var(--border-color);text-align:right">{{ c.total }}</td>
<td v-for="w in c.weeks.slice(0,8)" :key="w.week"
style="padding:4px;border:1px solid var(--border-color);text-align:center"
:style="{ background: `rgba(32,128,240,${Math.max(0.05, w.retention_rate / 100)})` }">
{{ w.retention_rate > 0 ? w.retention_rate + '%' : '-' }}
</td>
</tr>
</tbody>
</table>
</div>
<div v-else style="height:60px;display:flex;align-items:center;justify-content:center;color:var(--text-muted);font-size:13px">暂无留存数据需要更多用户活动积累</div>
</n-card>
</template>
</div>
</template>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NCard, NButton, NForm, NFormItem, NInput, NInputNumber, NSelect, NSkeleton, NAlert, NTag, NSpace, NModal, NIcon } from 'naive-ui'
import { SettingsOutline, CloseOutline, SaveOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
import { useToast } from '../../composables/useToast'
const toast = useToast()
const loading = ref(true)
const saving = ref(false)
const config = ref<any>({})
const editJwt = ref(15)
const showEdit = ref(false)
// AI config
const aiConfig = ref<any>({})
const aiLoading = ref(true)
const aiSaving = ref(false)
const showAiEdit = ref(false)
const aiForm = ref({
provider: 'deepseek',
api_key: '',
base_url: '',
model_name: '',
temperature: 3,
})
const providerOptions = [
{ label: 'DeepSeek', value: 'deepseek' },
{ label: 'OpenAI', value: 'openai' },
{ label: 'Claude (Anthropic)', value: 'claude' },
{ label: 'SiliconFlow', value: 'siliconflow' },
{ label: '自定义', value: 'custom' },
]
onMounted(() => {
loadConfig()
loadAiConfig()
})
async function loadConfig() {
try { const { data } = await api.get('/admin/config'); config.value = data; editJwt.value = data.jwt_expire_min || 15 }
catch (e) { toast.apiError(e, '加载配置失败') }
finally { loading.value = false }
}
async function saveConfig() {
saving.value = true
try { await api.put('/admin/config', { jwt_expire_min: editJwt.value }); showEdit.value = false; toast.success('配置已更新'); await loadConfig() }
catch (e) { toast.apiError(e, '保存失败') }
finally { saving.value = false }
}
async function loadAiConfig() {
aiLoading.value = true
try {
const { data } = await api.get('/admin/ai-config')
aiConfig.value = data
if (data.configured) {
aiForm.value = {
provider: data.provider || 'deepseek',
api_key: '',
base_url: data.base_url || '',
model_name: data.model_name || '',
temperature: data.temperature ?? 3,
}
}
} catch (e) { toast.apiError(e, '加载 AI 配置失败') }
finally { aiLoading.value = false }
}
function openAiEdit() {
aiForm.value.api_key = ''
showAiEdit.value = true
}
function formatTemp(t: number | null | undefined): string {
if (t === null || t === undefined) return '-'
return (t / 10).toFixed(1)
}
async function saveAiConfig() {
aiSaving.value = true
try {
await api.put('/admin/ai-config', {
provider: aiForm.value.provider,
api_key: aiForm.value.api_key || undefined,
base_url: aiForm.value.base_url || undefined,
model_name: aiForm.value.model_name || undefined,
temperature: aiForm.value.temperature,
})
showAiEdit.value = false
toast.success('AI 配置已更新')
await loadAiConfig()
} catch (e) { toast.apiError(e, '保存失败') }
finally { aiSaving.value = false }
}
</script>
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">&#x2699;&#xFE0F; 系统配置</h2>
<NButton size="small" @click="showEdit = true" class="sky-btn"><template #icon><NIcon size="14"><SettingsOutline /></NIcon></template>编辑配置</NButton>
</div>
<NSkeleton v-if="loading" text :repeat="4" />
<template v-else>
<NCard title="基本参数" size="small" style="margin-bottom:16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div><NTag size="tiny" :bordered="false">专科</NTag><span style="margin-left:8px;font-weight:600">{{ config.specialty || 'oncology' }}</span></div>
<div><NTag size="tiny" :bordered="false">调试模式</NTag><span style="margin-left:8px">{{ config.debug ? '开启' : '关闭' }}</span></div>
<div><NTag size="tiny" :bordered="false">JWT 过期</NTag><span style="margin-left:8px">{{ config.jwt_expire_min || 15 }} 分钟</span></div>
</div>
</NCard>
<NCard title="AI 摘要配置" size="small" style="margin-bottom:16px">
<template #header-extra><NButton size="tiny" class="sky-btn" @click="openAiEdit" :disabled="aiLoading">编辑</NButton>
</template>
<NSkeleton v-if="aiLoading" text :repeat="3" />
<template v-else-if="!aiConfig.configured">
<NAlert type="info" :bordered="false" style="margin-bottom:0">
<template #header>未配置</template>
AI 摘要当前使用环境变量中的 API Key点击编辑以通过数据库配置
</NAlert>
</template>
<div v-else style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div><NTag size="tiny" :bordered="false">Provider</NTag><span style="margin-left:8px;font-weight:600">{{ aiConfig.provider }}</span></div>
<div><NTag size="tiny" :bordered="false">API Key</NTag><span style="margin-left:8px;font-family:monospace">{{ aiConfig.api_key }}</span></div>
<div><NTag size="tiny" :bordered="false">Base URL</NTag><span style="margin-left:8px;font-size:12px">{{ aiConfig.base_url }}</span></div>
<div><NTag size="tiny" :bordered="false">Model</NTag><span style="margin-left:8px">{{ aiConfig.model_name }}</span></div>
<div><NTag size="tiny" :bordered="false">Temperature</NTag><span style="margin-left:8px">{{ formatTemp(aiConfig.temperature) }}</span></div>
<div><NTag size="tiny" :bordered="false">状态</NTag><span style="margin-left:8px">{{ aiConfig.is_active ? '启用' : '停用' }}</span></div>
</div>
</NCard>
<NAlert type="info" title="配置管理说明" style="margin-bottom:16px">
<div style="font-size:13px;line-height:1.8">
&bull; 运行参数通过 <code>config/specialties/oncology.yaml</code> 和环境变量配置<br>
&bull; JWT 过期时间可在运行时修改重启后恢复为配置值<br>
&bull; AI 摘要配置优先使用数据库配置无数据库配置时回退到环境变量
</div>
</NAlert>
</template>
<NModal v-model:show="showEdit" title="编辑配置" preset="card" style="width:450px">
<NForm label-placement="top">
<NFormItem label="JWT 过期时间(分钟)">
<NInputNumber v-model:value="editJwt" :min="1" :max="1440" style="width:100%" />
</NFormItem>
<NSpace justify="end" style="margin-top:12px">
<NButton class="sky-btn" @click="showEdit = false"><template #icon><NIcon size="14"><CloseOutline /></NIcon></template>取消</NButton>
<NButton class="sky-btn" type="primary" :loading="saving" @click="saveConfig"><template #icon><NIcon size="14"><SaveOutline /></NIcon></template>保存</NButton>
</NSpace>
</NForm>
</NModal>
<NModal v-model:show="showAiEdit" title="编辑 AI 摘要配置" preset="card" style="width:500px">
<NForm label-placement="top">
<NFormItem label="Provider">
<NSelect v-model:value="aiForm.provider" :options="providerOptions" />
</NFormItem>
<NFormItem label="API Key">
<NInput v-model:value="aiForm.api_key" type="password" :placeholder="aiConfig.configured ? '留空不修改' : '必填'" show-password-on="click" />
</NFormItem>
<NFormItem label="Base URL">
<NInput v-model:value="aiForm.base_url" placeholder="https://api.deepseek.com" />
</NFormItem>
<NFormItem label="Model">
<NInput v-model:value="aiForm.model_name" placeholder="deepseek-chat" />
</NFormItem>
<NFormItem label="Temperature ({{ formatTemp(aiForm.temperature) }})">
<NInputNumber v-model:value="aiForm.temperature" :min="0" :max="10" :step="1" style="width:100%" />
</NFormItem>
<NSpace justify="end" style="margin-top:12px">
<NButton class="sky-btn" @click="showAiEdit = false"><template #icon><NIcon size="14"><CloseOutline /></NIcon></template>取消</NButton>
<NButton class="sky-btn" type="primary" :loading="aiSaving" @click="saveAiConfig"><template #icon><NIcon size="14"><SaveOutline /></NIcon></template>保存</NButton>
</NSpace>
</NForm>
</NModal>
</div>
</template>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { ref, onMounted, h, computed } from 'vue'
import { NCard, NGrid, NGridItem, NStatistic, NDataTable, NTag, NSkeleton, NProgress, useMessage } from 'naive-ui'
import { Bar, Doughnut } from 'vue-chartjs'
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement } from 'chart.js'
import { api } from '../../api/client'
const message = useMessage()
function fmtPipelineTime(ts: string | null | undefined): string {
if (!ts) return '—'
return new Date(ts + 'Z').toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
}).replace(/\//g, '-')
}
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, PointElement, LineElement)
interface DashboardStats {
tenants?: number
users?: number
literature?: number
wau_7d?: number
[key: string]: unknown
}
interface DataQuality {
total: number
with_abstract: number
with_fulltext: number
tagged: number
with_pico: number
with_study_design: number
with_cited_by: number
with_doi: number
}
interface Pipeline {
type?: string
status?: string
new?: number
feeds?: number
completed?: string
[key: string]: unknown
}
const stats = ref<DashboardStats>({})
const quality = ref<DataQuality>({ total: 0, with_abstract: 0, with_fulltext: 0, tagged: 0, with_pico: 0, with_study_design: 0, with_cited_by: 0, with_doi: 0 })
const pipelines = ref<Pipeline[]>([])
const dailyStats = ref<any[]>([])
const loading = ref(true)
const tagDistribution = ref<Record<string, number>>({})
const qualityItems = computed(() => [
{ label: '有摘要', value: quality.value.with_abstract, total: quality.value.total, color: '#2080f0' },
{ label: '已打标', value: quality.value.tagged, total: quality.value.total, color: '#18a058' },
{ label: '有引用次数', value: quality.value.with_cited_by, total: quality.value.total, color: '#f0a020' },
{ label: '有研究设计', value: quality.value.with_study_design, total: quality.value.total, color: '#7c4dff' },
{ label: '有DOI', value: quality.value.with_doi, total: quality.value.total, color: '#008080' },
{ label: '有OA全文', value: quality.value.with_fulltext, total: quality.value.total, color: '#d03050' },
{ label: '有PICO', value: quality.value.with_pico, total: quality.value.total, color: '#808080' },
])
onMounted(async () => {
try {
const [dashR, tagsR] = await Promise.all([api.get('/admin/dashboard'), api.get('/admin/tags')])
stats.value = (dashR.data?.stats || {}) as DashboardStats
quality.value = (dashR.data?.data_quality || { total:0, with_abstract:0, with_fulltext:0, tagged:0, with_pico:0, with_study_design:0, with_cited_by:0, with_doi:0 }) as DataQuality
pipelines.value = (dashR.data?.recent_pipelines || []) as Pipeline[]
dailyStats.value = (dashR.data?.daily_pipeline_stats || []) as any[]
const tc: Record<string, number> = {}
for (const [cat, tags] of Object.entries(tagsR.data?.tags || {})) tc[cat] = (tags as unknown[]).length
tagDistribution.value = tc
} catch (e: any) {
message.error(e?.response?.data?.detail || '加载仪表盘失败')
} finally { loading.value = false }
})
const barData = computed(() => ({
labels: ['租户', '用户', '文献(K)', '7日活跃'],
datasets: [{ label:'数量', data:[stats.value.tenants||0, stats.value.users||0, Math.round((stats.value.literature||0)/1000), stats.value.wau_7d||0], backgroundColor: ['#2080f0','#18a058','#f0a020','#7c4dff'] }]
}))
const doughnutData = computed(() => ({
labels: Object.keys(tagDistribution.value),
datasets: [{ data: Object.values(tagDistribution.value), backgroundColor: ['#2080f0','#18a058','#f0a020','#d03050','#7c4dff','#008080'] }]
}))
const pipeCols = [
{ title: '类型', key: 'type', width: 80 },
{ title: '状态', key: 'status', width: 60, render: (r: Pipeline) => h(NTag, { type: r.status === 'success' ? 'success' : 'error', size: 'tiny' as const }, () => r.status) },
{ title: '新增', key: 'new', width: 50 },
{ title: 'Feed', key: 'feeds', width: 50 },
{ title: '完成', key: 'completed', width: 140, render: (r: Pipeline) => fmtPipelineTime(r.completed) },
]
const dailyCols = [
{ title: '类型', key: 'type', width: 80 },
{ title: '执行次数', key: 'count', width: 70 },
{ title: '新增文献', key: 'new_articles', width: 80 },
{ title: 'Feed推送', key: 'feeds', width: 80 },
]
const catLabels: Record<string,string> = { cancer:'癌种', gene:'靶点', treatment:'治疗', study_type:'研究类型', endpoint:'终点', scenario:'场景' }
</script>
<template>
<div>
<NSkeleton v-if="loading" text :repeat="8" />
<template v-else>
<!-- Top stats cards -->
<n-grid :cols="4" :x-gap="12" responsive="screen" class="stat-grid" style="margin-bottom:20px">
<n-grid-item><n-card size="small"><n-statistic label="租户" :value="stats.tenants||0"/></n-card></n-grid-item>
<n-grid-item><n-card size="small"><n-statistic label="用户" :value="stats.users||0"/></n-card></n-grid-item>
<n-grid-item><n-card size="small"><n-statistic label="文献" :value="stats.literature||0"/></n-card></n-grid-item>
<n-grid-item><n-card size="small"><n-statistic label="7日活跃" :value="stats.wau_7d||0"/></n-card></n-grid-item>
</n-grid>
<!-- Charts row -->
<n-grid :cols="2" :x-gap="16" responsive="screen" class="chart-grid" style="margin-bottom:20px">
<n-grid-item>
<n-card title="📊 系统概览" size="small">
<Bar :data="barData" :options="{responsive:true,plugins:{legend:{display:false}}}" style="max-height:220px" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="🏷️ 标签分布" size="small">
<Doughnut :data="doughnutData" :options="{responsive:true,plugins:{legend:{position:'bottom',labels:{font:{size:10},generateLabels:(chart:any)=>chart.data.labels.map((l:string,i:number)=>({text:catLabels[l]||l,fillStyle:chart.data.datasets[0].backgroundColor[i],hidden:false,index:i}))}}}}" style="max-height:220px" />
</n-card>
</n-grid-item>
</n-grid>
<!-- Data quality section -->
<n-card title="📋 数据质量" size="small" style="margin-bottom:20px">
<n-grid :cols="qualityItems.length > 5 ? 4 : qualityItems.length" :x-gap="16" :y-gap="16" responsive="screen">
<n-grid-item v-for="item in qualityItems" :key="item.label">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:4px">{{ item.label }}</div>
<NProgress
type="line"
:percentage="item.total > 0 ? Math.round(item.value / item.total * 100) : 0"
:color="item.color"
:height="12"
:border-radius="6"
:fill-border-radius="6"
/>
<div style="font-size:11px;color:var(--text-tertiary);margin-top:2px;text-align:right">
{{ item.value }} / {{ item.total }}
</div>
</n-grid-item>
</n-grid>
</n-card>
<!-- Daily pipeline stats -->
<n-grid :cols="2" :x-gap="16" responsive="screen" style="margin-bottom:20px">
<n-grid-item>
<n-card title="📡 最近管道运行" size="small">
<n-data-table :columns="pipeCols" :data="pipelines" size="small" :bordered="false" :paginate="pipelines.length > 10 ? {pageSize:10} : false" />
</n-card>
</n-grid-item>
<n-grid-item>
<n-card title="📈 近7天管道统计" size="small">
<n-data-table v-if="dailyStats.length" :columns="dailyCols" :data="dailyStats" size="small" :bordered="false" />
<div v-else style="font-size:13px;color:var(--text-tertiary);padding:12px">近7天暂无管道运行</div>
</n-card>
</n-grid-item>
</n-grid>
</template>
</div>
</template>
<style scoped>
@media (max-width: 768px) {
.stat-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.chart-grid {
grid-template-columns: 1fr !important;
}
}
</style>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { h } from 'vue'
import { NButton, NCard, NDataTable, NEmpty, NInput, NModal, NSelect, NSpace, NTag, useMessage, useDialog } from 'naive-ui'
import { api } from '../../api/client'
const message = useMessage()
const dialog = useDialog()
const loading = ref(false)
const items = ref<any[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(50)
const filterTargetNull = ref(false)
const filterAgency = ref<string | null>(null)
// Add modal
const showAdd = ref(false)
const addForm = ref({
drug_name: '', generic_name: '', target: '', indication: '',
approval_agency: 'NMPA', approval_type: 'new_drug',
approval_date: '', source_url: '',
})
const agencyOptions = [
{ label: 'FDA', value: 'FDA' },
{ label: 'NMPA', value: 'NMPA' },
{ label: 'EMA', value: 'EMA' },
]
const typeOptions = [
{ label: '新药批准 (new_drug)', value: 'new_drug' },
{ label: '适应症扩展 (expanded_indication)', value: 'expanded_indication' },
]
async function load() {
loading.value = true
try {
const params: any = { page: page.value, page_size: pageSize.value }
if (filterTargetNull.value) params.target_null = true
if (filterAgency.value) params.agency = filterAgency.value
const r = await api.get('/admin/drug-approvals', { params })
items.value = r.data.items
total.value = r.data.total
} catch (e: any) {
message.error('加载失败: ' + (e.response?.data?.detail || e.message))
} finally {
loading.value = false
}
}
async function updateTarget(row: any) {
const newTarget = (row as any)._editTarget
if (!newTarget || newTarget === row.target) return
try {
await api.put(`/admin/drug-approvals/${row.id}`, { target: newTarget })
row.target = newTarget
message.success('已更新')
} catch (e: any) {
message.error('更新失败: ' + (e.response?.data?.detail || e.message))
}
}
function confirmDelete(row: any) {
dialog.warning({
title: '删除确认',
content: `确定删除 ${row.drug_name} (${row.approval_date})`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.delete(`/admin/drug-approvals/${row.id}`)
message.success('已删除')
load()
} catch (e: any) {
message.error('删除失败: ' + (e.response?.data?.detail || e.message))
}
},
})
}
async function addRecord() {
if (!addForm.value.drug_name || !addForm.value.approval_date) {
message.warning('请填写药名和批准日期')
return
}
try {
await api.post('/admin/drug-approvals', addForm.value)
message.success('已添加')
showAdd.value = false
addForm.value = { drug_name: '', generic_name: '', target: '', indication: '',
approval_agency: 'NMPA', approval_type: 'new_drug', approval_date: '', source_url: '' }
load()
} catch (e: any) {
message.error('添加失败: ' + (e.response?.data?.detail || e.message))
}
}
const columns = computed(() => [
{ title: '药品名', key: 'drug_name', width: 130, ellipsis: { tooltip: true } },
{ title: '通用名', key: 'generic_name', width: 130, ellipsis: { tooltip: true } },
{
title: '靶点', key: 'target', width: 100,
render: (row: any) => {
if (row.target) {
return h(NTag, { size: 'small', type: 'success' }, row.target)
}
return h('div', { style: 'display:flex;gap:4px;align-items:center' }, [
h(NInput, {
size: 'small', style: 'width:90px', placeholder: '填写靶点',
value: row._editTarget,
'onUpdate:value': (v: string) => row._editTarget = v,
}),
h(NButton, {
size: 'tiny', type: 'primary', ghost: true,
onClick: () => updateTarget(row),
}, '✓'),
])
},
},
{
title: '类型', key: 'approval_type', width: 110,
render: (row: any) => h(NTag, { size: 'small', type: row.approval_type === 'new_drug' ? 'info' : 'warning' },
row.approval_type === 'new_drug' ? '新药' : '扩展'),
},
{ title: '日期', key: 'approval_date', width: 100 },
{ title: '机构', key: 'approval_agency', width: 60 },
{
title: '操作', key: 'actions', width: 70,
render: (row: any) => h(NButton, { size: 'tiny', type: 'error', ghost: true, onClick: () => confirmDelete(row) }, '删除'),
},
])
const scrollX = computed(() => columns.value.reduce((s: number, c: any) => s + (c.width || c.minWidth || 200), 0))
onMounted(load)
</script>
<template>
<NCard title="💊 药品审批管理">
<template #header-extra>
<NSpace>
<NSelect v-model:value="filterAgency" :options="agencyOptions" placeholder="全部机构" clearable style="width:120px" @update:value="load" />
<NButton :type="filterTargetNull ? 'warning' : 'default'" size="small" @click="filterTargetNull = !filterTargetNull; load()">
{{ filterTargetNull ? '📌 显示全部' : '🎯 仅缺靶点' }}
</NButton>
<NButton type="primary" size="small" @click="showAdd = true">+ 手动添加</NButton>
<NButton size="small" @click="load">刷新</NButton>
</NSpace>
</template>
<NDataTable
:columns="columns"
:data="items"
:loading="loading"
:bordered="false"
:single-line="false"
:max-height="600"
:scroll-x="scrollX"
size="small"
style="margin-top:8px"
/>
<div v-if="!loading && items.length === 0" style="padding:40px">
<NEmpty description="暂无记录" />
</div>
<div v-if="total > pageSize" style="display:flex;justify-content:center;padding:16px">
<NButton size="small" :disabled="page <= 1" @click="page--; load()">上一页</NButton>
<span style="padding:0 12px;line-height:28px">{{ page }} / {{ Math.ceil(total / pageSize) }}</span>
<NButton size="small" :disabled="page * pageSize >= total" @click="page++; load()">下一页</NButton>
</div>
<div style="color:var(--text-color-3);font-size:12px;padding:8px 0 0">
{{ total }} | 点击 🎯 筛选靶点缺失的记录 填写靶点 保存 | 手动添加用于 NMPA 等非 FDA 数据
</div>
</NCard>
<!-- Add Modal -->
<NModal v-model:show="showAdd" title="手动添加审批记录" preset="card" style="width:500px">
<div style="display:flex;flex-direction:column;gap:10px">
<NInput v-model:value="addForm.drug_name" placeholder="药品名 *(必填)" />
<NInput v-model:value="addForm.generic_name" placeholder="通用名" />
<NInput v-model:value="addForm.target" placeholder="靶点(如 EGFR, PD-1" />
<NInput v-model:value="addForm.indication" placeholder="适应症" />
<NSelect v-model:value="addForm.approval_agency" :options="agencyOptions" />
<NSelect v-model:value="addForm.approval_type" :options="typeOptions" />
<NInput v-model:value="addForm.approval_date" placeholder="批准日期 * YYYY-MM-DD" />
<NInput v-model:value="addForm.source_url" placeholder="来源链接" />
<NSpace justify="end" style="margin-top:8px">
<NButton @click="showAdd = false">取消</NButton>
<NButton type="primary" @click="addRecord">添加</NButton>
</NSpace>
</div>
</NModal>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref, onMounted, h } from 'vue'
import { NButton, NDataTable, NTag, NSkeleton, NPagination, NPopconfirm, NSpace, NIcon } from 'naive-ui'
import { RefreshOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
import { toBeijingDate } from '../../utils/date'
import { useToast } from '../../composables/useToast'
const toast = useToast()
const items = ref<any[]>([])
const loading = ref(true)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
onMounted(loadData)
async function loadData() {
loading.value = true
try { const { data } = await api.get('/settings/feedback', { params: { page: page.value, page_size: pageSize.value } }); items.value = data.items || []; total.value = data.total || 0 }
catch (e) { toast.apiError(e, '加载反馈失败') }
finally { loading.value = false }
}
async function resolveItem(fid: string) {
try { await api.post('/settings/feedback/' + fid + '/resolve'); toast.success('已标记为已处理'); await loadData() }
catch (e) { toast.apiError(e, '操作失败') }
}
async function deleteItem(fid: string) {
try { await api.delete('/settings/feedback/' + fid); toast.success('已删除'); await loadData() }
catch (e) { toast.apiError(e, '删除失败') }
}
const typeMap: Record<string,string> = { bug:'错误', help:'求助', suggestion:'建议' }
const cols = [
{ title: '用户', key: 'user_id', width: 80, render: (r: any) => r.user_id?.slice(0,8)+'...' },
{ title: '类型', key: 'type', width: 70, render: (r: any) => h(NTag, { size:'tiny', type: r.type==='bug'?'error':r.type==='help'?'warning':'info' }, typeMap[r.type]||r.type) },
{ title: '内容', key: 'detail', ellipsis: true, minWidth: 200 },
{ title: '状态', key: 'resolved', width: 60, render: (r: any) => r.resolved ? h(NTag, {size:'tiny',type:'success'}, '已处理') : h(NTag, {size:'tiny',type:'error'}, '待处理') },
{ title: '时间', key: 'created_at', width: 90, render: (r: any) => toBeijingDate(r.created_at) || '—' },
{ title: '操作', key: 'actions', width: 120, render: (r: any) => h(NSpace, null, [
!r.resolved ? h(NButton, {size:'tiny', onClick: ()=>resolveItem(r.id) }, '处理') : null,
h(NPopconfirm, { onPositiveClick: ()=>deleteItem(r.id) }, { trigger: () => h(NButton, {size:'tiny', type:'error'}, '删除') }),
])},
]
const scrollX = 620
</script>
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">💬 用户反馈 ({{ total }})</h2>
<NSpace>
<NButton size="small" @click="loadData" class="sky-btn"><template #icon><NIcon size="14"><RefreshOutline /></NIcon></template>刷新</NButton>
</NSpace>
</div>
<NSkeleton v-if="loading" text :repeat="5" />
<NDataTable v-else :columns="cols" :data="items" size="small" :bordered="false" :scroll-x="scrollX" />
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:16px">
<NPagination :page="page" :page-size="pageSize" :item-count="total" @update:page="p => { page=p; loadData() }" />
</div>
</div>
</template>
@@ -0,0 +1,355 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { h } from 'vue'
import { NCard, NTag, NButton, NSkeleton, NEmpty, NModal, NInput, NSelect, NAlert, NDataTable, useMessage, useDialog, NIcon } from 'naive-ui'
import { AlertCircleOutline, AddOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
const message = useMessage()
const dialog = useDialog()
const loading = ref(true)
const guidelines = ref<any[]>([])
const updateCheck = ref<any>(null)
const updateCheckLoading = ref(false)
// Create guideline modal
const showCreateModal = ref(false)
const createForm = ref({
source: '',
cancer_type: '',
version: '',
publish_date: '',
change_summary_zh: '',
source_url: '',
})
const creating = ref(false)
// Evidence management
const selectedGid = ref<string | null>(null)
const evidence = ref<any[]>([])
const evLoading = ref(false)
const showEv = ref(false)
const showAddEv = ref(false)
const editingEv = ref<any>(null)
const evForm = ref({ recommendation: '', level: '', treatment_line: null, literature_id: '' })
// ==================== 来源提示信息 ====================
const SOURCE_GUIDE: Record<string, { name_cn: string; color: string; hint: string }> = {
CSCO: { name_cn: 'CSCO', color: '#d03050', hint: 'CSCO 每年 3-5 月发布新版本。关注 csco.org.cn → 学术专栏 → 指南,或 "CSCO 诊疗指南" 微信公众号。每年 4 月左右会进行年度更新。' },
NHC: { name_cn: '卫健委', color: '#f0a020', hint: '卫健委每年 1 月左右发布新版《XX 诊疗指南(XXXX 年版)》。关注 nhc.gov.cn → 通知公告,或 "国家卫生健康委员会" 官网 → 医政医管。已发布的 PDF 可在官网搜索下载。' },
NCCN: { name_cn: 'NCCN', color: '#2080f0', hint: 'PubMed 自动捕获,无需手动维护。' },
ESMO: { name_cn: 'ESMO', color: '#18a058', hint: 'PubMed 自动捕获,无需手动维护。' },
ASTRO: { name_cn: 'ASTRO', color: '#7c3aed', hint: 'PubMed 自动捕获,无需手动维护。' },
}
const staleSources = computed(() => {
if (!updateCheck.value?.sources) return []
return Object.entries(updateCheck.value.sources)
.filter(([_, s]: any) => s.status === 'stale')
.map(([src, s]: any) => ({ source: src, ...s, ...SOURCE_GUIDE[src] }))
})
const treatmentLineOptions = [
{ label: '一线 (1L)', value: '1L' },
{ label: '二线 (2L)', value: '2L' },
{ label: '三线 (3L)', value: '3L' },
{ label: '辅助 (adjuvant)', value: 'adjuvant' },
{ label: '新辅助 (neoadjuvant)', value: 'neoadjuvant' },
{ label: '维持 (maintenance)', value: 'maintenance' },
]
onMounted(async () => {
await Promise.all([loadGuidelines(), checkUpdates()])
})
async function loadGuidelines() {
loading.value = true
try {
const { data } = await api.get('/admin/guidelines')
guidelines.value = data.items || []
} catch (e: any) { message.error(e?.response?.data?.detail || '加载失败') }
finally { loading.value = false }
}
async function checkUpdates() {
updateCheckLoading.value = true
try {
const { data } = await api.get('/admin/guidelines/check-updates')
updateCheck.value = data
} catch (e: any) { /* silent */ }
finally { updateCheckLoading.value = false }
}
// ==================== 新建指南版本 ====================
function openCreate(src?: string) {
createForm.value = { source: src || '', cancer_type: '', version: '', publish_date: '', change_summary_zh: '', source_url: '' }
showCreateModal.value = true
}
async function createGuideline() {
if (!createForm.value.source || !createForm.value.cancer_type || !createForm.value.version || !createForm.value.publish_date) {
message.warning('请填写来源、癌种、版本号和发布日期')
return
}
creating.value = true
try {
await api.post('/admin/guidelines', createForm.value)
message.success('指南版本已创建')
showCreateModal.value = false
await loadGuidelines()
await checkUpdates()
} catch (e: any) { message.error(e?.response?.data?.detail || '创建失败') }
finally { creating.value = false }
}
// ==================== 证据管理 ====================
async function loadEvidence(gid: string) {
selectedGid.value = gid
evLoading.value = true
showEv.value = true
try {
const { data } = await api.get(`/admin/guidelines/${gid}/evidence`)
evidence.value = data.items || []
} catch (e: any) { message.error(e?.response?.data?.detail || '加载证据失败') }
finally { evLoading.value = false }
}
function openAddEv() {
editingEv.value = null
evForm.value = { recommendation: '', level: '', treatment_line: null, literature_id: '' }
showAddEv.value = true
}
function openEditEv(ev: any) {
editingEv.value = ev
evForm.value = {
recommendation: ev.recommendation || '',
level: ev.level || '',
treatment_line: ev.treatment_line || null,
literature_id: ev.literature_id || '',
}
showAddEv.value = true
}
async function saveEv() {
if (!selectedGid.value) return
const body: Record<string, any> = {
recommendation: evForm.value.recommendation,
recommendation_level: evForm.value.level,
treatment_line: evForm.value.treatment_line,
}
try {
if (editingEv.value) {
await api.put(`/admin/evidence/${editingEv.value.id}`, body)
message.success('已更新')
} else {
body.literature_id = evForm.value.literature_id
await api.post(`/admin/guidelines/${selectedGid.value}/evidence`, body)
message.success('已添加')
}
showAddEv.value = false
await loadEvidence(selectedGid.value)
} catch (e: any) { message.error(e?.response?.data?.detail || '保存失败') }
}
function confirmDelEv(ev: any) {
dialog.warning({
title: '确认删除',
content: `删除该证据?${ev.recommendation ? '' + ev.recommendation.slice(0, 40) + '...' : ''}`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
try {
await api.delete(`/admin/evidence/${ev.id}`)
message.success('已删除')
await loadEvidence(selectedGid.value!)
} catch (e: any) { message.error(e?.response?.data?.detail || '删除失败') }
},
})
}
const sourceFilter = ref('')
const filteredGuidelines = computed(() => {
if (!sourceFilter.value) return guidelines.value
return guidelines.value.filter(g => g.source === sourceFilter.value)
})
const columns = [
{ title: '来源', key: 'source', width: 70 },
{ title: '版本', key: 'version', width: 90 },
{ title: '癌种', key: 'cancer_type', width: 100 },
{ title: '日期', key: 'publish_date', width: 100 },
{ title: '证据数', key: 'evidence_count', width: 70 },
{
title: '操作', key: 'actions', width: 130,
render: (row: any) => h(NButton, { size: 'tiny', type: 'primary', onClick: () => loadEvidence(row.id) }, { default: () => '📚 管理证据' }),
},
]
const scrollX = 560
</script>
<template>
<div>
<h2 style="margin-bottom:16px">📋 指南管理</h2>
<!-- 更新状态提示 -->
<div v-if="updateCheckLoading" style="margin-bottom:12px">
<NSkeleton text :repeat="1" style="max-width:400px" />
</div>
<NAlert v-else-if="staleSources.length" type="warning" :bordered="false" closable style="margin-bottom:16px">
<template #header>
<NIcon size="18" style="vertical-align:-3px;margin-right:4px"><AlertCircleOutline /></NIcon>
以下指南可能已有新版本
</template>
<div v-for="s in staleSources" :key="s.source" style="margin:4px 0;font-size:13px">
<strong :style="{color:s.color}">{{ s.name_cn }}</strong>
最近版本 {{ s.latest_date }} {{ s.total }} {{ s.hint }}
</div>
</NAlert>
<!-- 工具栏 -->
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;align-items:center">
<NButton size="small" :type="!sourceFilter?'primary':'default'" @click="sourceFilter=''">全部</NButton>
<NButton size="small" :type="sourceFilter==='NCCN'?'primary':'default'" @click="sourceFilter='NCCN'">NCCN</NButton>
<NButton size="small" :type="sourceFilter==='CSCO'?'primary':'default'" @click="sourceFilter='CSCO'" :color="sourceFilter==='CSCO'?'#d03050':undefined">CSCO</NButton>
<NButton size="small" :type="sourceFilter==='NHC'?'primary':'default'" @click="sourceFilter='NHC'" :color="sourceFilter==='NHC'?'#f0a020':undefined">卫健委</NButton>
<NButton size="small" :type="sourceFilter==='ESMO'?'primary':'default'" @click="sourceFilter='ESMO'">ESMO</NButton>
<NButton size="small" :type="sourceFilter==='ASTRO'?'primary':'default'" @click="sourceFilter='ASTRO'">ASTRO</NButton>
<div style="flex:1" />
<NButton size="small" type="primary" @click="openCreate()">
<template #icon><NIcon size="14"><AddOutline /></NIcon></template>
新建指南版本
</NButton>
<NButton size="small" @click="checkUpdates()">刷新状态</NButton>
</div>
<!-- 数据表格 -->
<NSkeleton v-if="loading" text :repeat="6" />
<NDataTable v-else :columns="columns" :data="filteredGuidelines" :bordered="false" size="small"
:scroll-x="scrollX"
:row-props="() => ({ style: 'cursor:pointer' })"
/>
<!-- ==================== 新建指南版本 Modal ==================== -->
<NModal v-model:show="showCreateModal" title="新建指南版本" :mask-closable="false">
<NCard style="width:520px" size="small">
<div style="display:flex;flex-direction:column;gap:12px">
<!-- 来源 -->
<div>
<label style="font-size:12px;color:var(--text-muted)">来源 *</label>
<NSelect v-model:value="createForm.source" :options="[
{ label:'CSCO', value:'CSCO' },
{ label:'卫健委 (NHC)', value:'NHC' },
]" placeholder="选择来源" />
</div>
<!-- 癌种 -->
<div>
<label style="font-size:12px;color:var(--text-muted)">癌种(英文)*</label>
<NInput v-model:value="createForm.cancer_type" placeholder="Lung Cancer / Breast Cancer / Gastric Cancer " />
</div>
<!-- 版本 + 日期 -->
<div style="display:flex;gap:12px">
<div style="flex:1">
<label style="font-size:12px;color:var(--text-muted)">版本号 *</label>
<NInput v-model:value="createForm.version" placeholder="2025" />
</div>
<div style="flex:1">
<label style="font-size:12px;color:var(--text-muted)">发布日期 *</label>
<NInput v-model:value="createForm.publish_date" placeholder="2025-04-01" />
</div>
</div>
<!-- 变更摘要 -->
<div>
<label style="font-size:12px;color:var(--text-muted)">变更摘要(选填)</label>
<NInput v-model:value="createForm.change_summary_zh" type="textarea" rows="2" placeholder="驱动基因阴性NSCLC一线免疫治疗推荐更新" />
</div>
<!-- 来源链接 -->
<div>
<label style="font-size:12px;color:var(--text-muted)">官方链接(选填)</label>
<NInput v-model:value="createForm.source_url" placeholder="https://…" />
</div>
<!-- 维护提示 -->
<NAlert v-if="createForm.source && SOURCE_GUIDE[createForm.source]" type="info" :bordered="false" style="font-size:12px">
<template #header>📌 {{ SOURCE_GUIDE[createForm.source]?.name_cn }} 维护提示</template>
{{ SOURCE_GUIDE[createForm.source]?.hint }}
</NAlert>
<!-- 按钮 -->
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:4px">
<NButton size="small" @click="showCreateModal=false">取消</NButton>
<NButton size="small" type="primary" :loading="creating" @click="createGuideline">创建</NButton>
</div>
</div>
</NCard>
</NModal>
<!-- ==================== 证据管理 Modal ==================== -->
<NModal v-model:show="showEv" title="证据管理">
<NCard style="width:700px;max-height:80vh;overflow-y:auto">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<span>证据列表</span>
<NButton size="tiny" type="primary" @click="openAddEv"> 添加</NButton>
</div>
</template>
<NSkeleton v-if="evLoading" text :repeat="4" />
<NEmpty v-else-if="!evidence.length" description="暂无证据" />
<div v-for="ev in evidence" :key="ev.id"
style="padding:8px 0;border-bottom:1px solid var(--border-color);font-size:13px">
<div style="display:flex;gap:6px;align-items:flex-start;margin-bottom:4px">
<NTag size="tiny" type="success">{{ ev.level || '—' }}</NTag>
<NTag v-if="ev.treatment_line" size="tiny" :color="{
color: ({ '1L':'#d03050','2L':'#f0a020','3L':'#18a058',adjuvant:'#2080f0',neoadjuvant:'#8a2be2',maintenance:'#999' } as Record<string,string>)[ev.treatment_line] || '#999'
}">{{ ev.treatment_line }}</NTag>
</div>
<p style="margin:4px 0;line-height:1.5">{{ ev.recommendation }}</p>
<p style="margin:4px 0;font-size:12px;color:var(--text-muted)">
PMID {{ ev.pmid }} · {{ ev.journal }}
</p>
<div style="display:flex;gap:6px;margin-top:4px">
<NButton size="tiny" @click="openEditEv(ev)">编辑</NButton>
<NButton size="tiny" type="error" @click="confirmDelEv(ev)">删除</NButton>
</div>
</div>
</NCard>
</NModal>
<!-- 添加/编辑证据 Modal -->
<NModal v-model:show="showAddEv" title="编辑证据">
<NCard style="width:500px">
<div style="display:flex;flex-direction:column;gap:12px">
<div>
<label style="font-size:12px;color:var(--text-muted)">推荐方案</label>
<NInput v-model:value="evForm.recommendation" type="textarea" rows="2" placeholder="Osimertinib+化疗..." />
</div>
<div style="display:flex;gap:12px">
<div style="flex:1">
<label style="font-size:12px;color:var(--text-muted)">推荐级别</label>
<NInput v-model:value="evForm.level" placeholder="1A" />
</div>
<div style="flex:1">
<label style="font-size:12px;color:var(--text-muted)">治疗线数</label>
<NSelect v-model:value="evForm.treatment_line" :options="treatmentLineOptions" placeholder="选择" clearable />
</div>
</div>
<div v-if="!editingEv">
<label style="font-size:12px;color:var(--text-muted)">文献 ID (UUID)</label>
<NInput v-model:value="evForm.literature_id" placeholder="literature UUID" />
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:8px">
<NButton size="small" @click="showAddEv=false">取消</NButton>
<NButton size="small" type="primary" @click="saveEv">保存</NButton>
</div>
</div>
</NCard>
</NModal>
</div>
</template>
+57
View File
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import { NCard, NSkeleton, NPagination, NInput, useMessage } from 'naive-ui'
import { api } from '../../api/client'
const message = useMessage()
const journals = ref<any[]>([])
const loading = ref(true)
const page = ref(1)
const pageSize = ref(50)
const total = ref(0)
const q = ref('')
async function load() {
loading.value = true
try {
const { data } = await api.get('/admin/journals', { params: { page: page.value, page_size: pageSize.value, q: q.value || undefined } })
journals.value = data.items || []
total.value = data.total || 0
} catch (e: any) {
message.error(e?.response?.data?.detail || '加载期刊失败')
} finally {
loading.value = false
}
}
onMounted(load)
watch([page, pageSize], load)
async function onSearch() {
page.value = 1
await nextTick()
load()
}
const tierLabels: Record<string,string> = { '1':'🔴', '2':'🟠', '3':'🟡', '4':'⚪' }
</script>
<template>
<div>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px">
<h2 style="margin:0">📰 期刊列表 ({{ total }})</h2>
<NInput v-model:value="q" placeholder="搜索期刊名称..." size="small" style="width:240px" clearable @keyup.enter="onSearch" @clear="onSearch" />
</div>
<NSkeleton v-if="loading" text :repeat="8" />
<NCard v-else size="small">
<div v-for="j in journals" :key="j.id" style="display:flex;align-items:center;padding:8px;border-bottom:1px solid var(--border-color);font-size:13px">
<span style="width:30px">{{ tierLabels[j.tier]||'' }}</span>
<span style="flex:1;font-weight:500">{{ j.name }}</span>
<span style="width:120px;font-size:11px;color:var(--text-muted)">{{ j.issn || '—' }}</span>
<span style="width:60px;text-align:right;font-weight:600" v-if="j.impact_factor">IF {{ j.impact_factor }}</span>
</div>
</NCard>
<div v-if="total > 0" style="display:flex;justify-content:center;margin-top:16px">
<NPagination v-model:page="page" :page-count="Math.ceil(total / pageSize)" :page-size="pageSize" @update:page-size="pageSize = $event; page = 1" :page-sizes="[20, 50, 100, 200]" show-size-picker />
</div>
</div>
</template>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { NCard, NButton, NInput, NModal, NForm, NFormItem, NSelect, NSkeleton, NPagination, NIcon, type FormRules } from 'naive-ui'
import { SendOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
import { toBeijingDate } from '../../utils/date'
import { useToast } from '../../composables/useToast'
const toast = useToast()
const notifications = ref<any[]>([])
const loading = ref(true)
const showModal = ref(false)
const form = ref({ title: '', content: '', target_type: 'all' })
const saving = ref(false)
const formRef = ref()
const rules: FormRules = {
title: { required: true, message: '请输入公告标题', trigger: 'blur' },
}
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
onMounted(loadData)
async function loadData() {
loading.value = true
try { const { data } = await api.get('/admin/notifications', { params: { page: page.value, page_size: pageSize.value } }); notifications.value = data.items || []; total.value = data.total || 0 }
finally { loading.value = false }
}
async function createNotif() {
try { await formRef.value?.validate() } catch { return }
saving.value = true
try { await api.post('/admin/notifications', form.value); showModal.value = false; form.value = { title: '', content: '', target_type: 'all' }; loadData() }
catch (e) { toast.apiError(e, '发布公告失败') }
finally { saving.value = false }
}
</script>
<template>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:16px">
<h2>🔔 系统公告</h2>
<NButton type="primary" @click="showModal=true" class="sky-btn"><template #icon><NIcon size="14"><SendOutline /></NIcon></template>发布公告</NButton>
</div>
<NSkeleton v-if="loading" text :repeat="3" />
<NCard v-for="n in notifications" :key="n.id" size="small" style="margin-bottom:8px" :title="n.title">
<p>{{ n.content }}</p>
<div style="font-size:11px;color:var(--text-muted);margin-top:8px">对象: {{ n.target_type }} · {{ toBeijingDate(n.created_at) }}</div>
</NCard>
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:16px">
<NPagination :page="page" :page-size="pageSize" :item-count="total" @update:page="p => { page=p; loadData() }" />
</div>
<NModal v-model:show="showModal" title="发布公告">
<NCard style="width:500px">
<NForm ref="formRef" :model="form" :rules="rules" label-placement="left" label-width="80">
<NFormItem path="title" label="标题" required><NInput v-model:value="form.title" /></NFormItem>
<NFormItem path="content" label="内容"><NInput v-model:value="form.content" type="textarea" :autosize="{minRows:3}" /></NFormItem>
<NFormItem label="对象"><NSelect v-model:value="form.target_type" :options="[{label:'全部用户',value:'all'}]" /></NFormItem>
<NButton type="primary" :loading="saving" @click="createNotif" class="sky-btn"><template #icon><NIcon size="14"><SendOutline /></NIcon></template>发布</NButton>
</NForm>
</NCard>
</NModal>
</div>
</template>
+113
View File
@@ -0,0 +1,113 @@
<script setup lang="ts">
import { ref, onMounted, h, watch } from 'vue'
import { NButton, NDataTable, NInput, NSpace, NPagination, NDatePicker, NIcon, NTabs, NTabPane } from 'naive-ui'
import { SearchOutline, RefreshOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
import { useToast } from '../../composables/useToast'
import { toBeijingDateTime } from '../../utils/date'
const toast = useToast()
const tab = ref('registered')
const items = ref<any[]>([])
const loading = ref(false)
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const ipFilter = ref('')
// const locationFilter = ref('') // 所在地 已注释
const dateRange = ref<[number, number] | null>(null)
onMounted(loadData)
watch(tab, () => { page.value = 1; loadData() })
async function loadData() {
loading.value = true
try {
const params: Record<string, any> = { page: page.value, page_size: pageSize.value }
if (ipFilter.value) params.ip = ipFilter.value
// if (locationFilter.value) params.location = locationFilter.value // 所在地 已注释
if (dateRange.value) {
params.start_date = new Date(dateRange.value[0]).toISOString().slice(0, 10)
params.end_date = new Date(dateRange.value[1]).toISOString().slice(0, 10)
}
const endpoint = tab.value === 'registered' ? '/admin/page-views' : '/admin/anonymous-page-views'
const { data } = await api.get(endpoint, { params })
items.value = data.items || []
total.value = data.total || 0
} catch (e) {
toast.apiError(e, '加载失败')
} finally {
loading.value = false
}
}
function formatDuration(s: number | null | undefined): string {
if (!s || s <= 0) return ''
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`
return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`
}
// function formatLocation(loc: any): string { // 已注释
// if (!loc) return '—'
// const parts = [loc.city, loc.regionName, loc.country].filter(Boolean)
// return parts.join(', ') || '—'
// }
function userAgentShort(ua: string | null | undefined): string {
if (!ua) return '—'
if (ua.includes('Mobile')) return '📱 Mobile'
if (ua.includes('Windows')) return '🖥 Windows'
if (ua.includes('Mac')) return '🍎 Mac'
if (ua.includes('Linux')) return '🐧 Linux'
return ua.split('/')[0]?.split(' ')[0] || ua.slice(0, 12)
}
const regCols = [
{ title: '用户', key: 'display_name', width: 160, ellipsis: { tooltip: true }, render: (r: any) => r.display_name || '—' },
{ title: 'IP', key: 'ip_address', width: 130, render: (r: any) => r.ip_address ? h('span', { style: 'font-family:monospace;font-size:12px' }, r.ip_address) : '—' },
// { title: '所在地', key: 'location', width: 150, ellipsis: { tooltip: true }, render: (r: any) => formatLocation(r.location) }, // 已注释
{ title: '页面名称', key: 'page_title', minWidth: 180, ellipsis: { tooltip: true }, render: (r: any) => r.page_title || '—' },
{ title: '页面 URL', key: 'path', width: 180, ellipsis: { tooltip: true }, render: (r: any) => r.path ? h('span', { style: 'font-family:monospace;font-size:12px' }, r.path) : '—' },
{ title: '访问时间', key: 'created_at', width: 150, render: (r: any) => toBeijingDateTime(r.created_at) || '—' },
{ title: '停留', key: 'duration_seconds', width: 70, render: (r: any) => formatDuration(r.duration_seconds) },
{ title: 'UA', key: 'user_agent', width: 90, ellipsis: { tooltip: true }, render: (r: any) => userAgentShort(r.user_agent) },
]
const anonCols = [
{ title: '匿名 ID', key: 'anonymous_id', width: 160, ellipsis: { tooltip: true }, render: (r: any) => h('span', { style: 'font-family:monospace;font-size:12px' }, r.anonymous_id?.slice(0, 8) + '…') },
{ title: 'IP', key: 'ip_address', width: 130, render: (r: any) => r.ip_address ? h('span', { style: 'font-family:monospace;font-size:12px' }, r.ip_address) : '—' },
// { title: '所在地', key: 'location', width: 150, ellipsis: { tooltip: true }, render: (r: any) => formatLocation(r.location) }, // 已注释
{ title: '页面名称', key: 'page_title', minWidth: 180, ellipsis: { tooltip: true }, render: (r: any) => r.page_title || '—' },
{ title: '页面 URL', key: 'path', width: 180, ellipsis: { tooltip: true }, render: (r: any) => r.path ? h('span', { style: 'font-family:monospace;font-size:12px' }, r.path) : '—' },
{ title: '访问时间', key: 'created_at', width: 150, render: (r: any) => toBeijingDateTime(r.created_at) || '—' },
{ title: '停留', key: 'duration_seconds', width: 70, render: (r: any) => formatDuration(r.duration_seconds) },
{ title: 'UA', key: 'user_agent', width: 90, ellipsis: { tooltip: true }, render: (r: any) => userAgentShort(r.user_agent) },
]
const scrollX = 1110
</script>
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<h2 style="margin:0">🌐 网站访问记录 ({{ total }})</h2>
<NSpace wrap>
<NInput v-model:value="ipFilter" placeholder="IP 过滤" size="small" style="width:140px" clearable @keyup.enter="loadData" />
<!-- <NInput v-model:value="locationFilter" placeholder="所在地过滤" size="small" style="width:140px" clearable @keyup.enter="loadData" /> 已注释 -->
<NDatePicker v-model:value="dateRange" type="datetimerange" size="small" style="width:280px" clearable placeholder="选择日期范围" />
<NButton size="small" type="primary" @click="loadData" class="sky-btn"><template #icon><NIcon size="14"><SearchOutline /></NIcon></template>查询</NButton>
<NButton size="small" @click="ipFilter='';/*locationFilter='';*/dateRange=null;loadData()" class="sky-btn"><template #icon><NIcon size="14"><RefreshOutline /></NIcon></template>重置</NButton>
</NSpace>
</div>
<NTabs v-model:value="tab" type="line">
<NTabPane name="registered" tab="注册用户" />
<NTabPane name="anonymous" tab="匿名用户" />
</NTabs>
<NDataTable :columns="tab === 'registered' ? regCols : anonCols" :data="items" :loading="loading" size="small" :bordered="false" :row-key="(r:any)=>r.id" :scroll-x="scrollX" />
<div v-if="total > pageSize" style="display:flex;justify-content:center;margin-top:16px">
<NPagination :page="page" :page-size="pageSize" :item-count="total" @update:page="p => { page=p; loadData() }" />
</div>
</div>
</template>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
import { ref, onMounted, h } from 'vue'
import { NDataTable, NButton, NTag, NAlert, NCard, NGrid, NGridItem } from 'naive-ui'
import { api } from '../../api/client'
import { useToast } from '../../composables/useToast'
const toast = useToast()
const runs = ref<any[]>([])
const loading = ref(true)
// Action loading states
const running = ref(false)
const refreshingCitations = ref(false)
const extractingPico = ref(false)
const backfillingDesign = ref(false)
const generatingSummary = ref(false)
const backfillingOA = ref(false)
const retagging = ref(false)
function fmtPipelineTime(ts: string | null | undefined): string {
if (!ts) return '—'
const d = new Date(ts)
if (isNaN(d.getTime())) return ts
return d.toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false,
}).replace(/\//g, '-')
}
onMounted(loadData)
async function loadData() {
loading.value = true
try { const { data } = await api.get('/admin/pipeline/runs'); runs.value = data.items || [] }
catch (e) { toast.apiError(e, '加载管道记录失败') }
finally { loading.value = false }
}
async function triggerPipeline() {
running.value = true
try { await api.post('/admin/pipeline/run'); toast.success('管道已触发'); await loadData() }
catch (e) { toast.apiError(e, '触发失败') }
finally { running.value = false }
}
async function refreshCitations() {
refreshingCitations.value = true
try { const { data } = await api.post('/admin/pipeline/refresh-citations'); toast.success('引用更新完成: ' + data.updated + ' 篇'); await loadData() }
catch (e) { toast.apiError(e, '刷新失败') }
finally { refreshingCitations.value = false }
}
async function extractPico() {
extractingPico.value = true
try { const { data } = await api.post('/admin/pipeline/extract-pico'); toast.success('PICO 提取完成: ' + data.processed + ' 篇'); await loadData() }
catch (e) { toast.apiError(e, '提取失败') }
finally { extractingPico.value = false }
}
async function backfillDesign() {
backfillingDesign.value = true
try { const { data } = await api.post('/admin/pipeline/backfill-study-designs'); toast.success('回填完成: ' + data.updated + ' 篇'); await loadData() }
catch (e) { toast.apiError(e, '回填失败') }
finally { backfillingDesign.value = false }
}
async function generateSummary() {
generatingSummary.value = true
try { const { data } = await api.post('/admin/ai/summarize'); toast.success('摘要生成完成: ' + data.processed + ' 篇'); await loadData() }
catch (e) { toast.apiError(e, '生成失败') }
finally { generatingSummary.value = false }
}
async function backfillOA() {
backfillingOA.value = true
try { const { data } = await api.post('/admin/pipeline/backfill-oa-text?limit=5000'); toast.success('OA全文回填完成: ' + data.fetched + ' 篇'); await loadData() }
catch (e) { toast.apiError(e, '回填失败') }
finally { backfillingOA.value = false }
}
async function retagTags() {
retagging.value = true
try { const { data } = await api.post('/admin/pipeline/retag-tags'); toast.success('MeSH 回填完成: ' + data.tagged_articles + ' 篇 (' + data.tags_added + ' 个标签)'); await loadData() }
catch (e) { toast.apiError(e, '回填失败') }
finally { retagging.value = false }
}
const cols = [
{ title: '类型', key: 'type', width: 120 },
{ title: '状态', key: 'status', width: 70, render: (r: any) => h(NTag, { size:'tiny', type: r.status==='success'?'success':'error' }, r.status) },
{ title: '新增', key: 'new', width: 50 },
{ title: '打标', key: 'filtered', width: 50 },
{ title: 'Feed', key: 'feeds', width: 50 },
{ title: '错误', key: 'error', width: 120, ellipsis: true, render: (r: any) => r.error || '—' },
{ title: '开始', key: 'started', width: 140, render: (r: any) => fmtPipelineTime(r.started) },
{ title: '完成', key: 'completed', width: 140, render: (r: any) => fmtPipelineTime(r.completed) },
]
const scrollX = 740
</script>
<template>
<div class="pipeline-view">
<div class="header-row" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">📡 数据管道</h2>
<NButton type="primary" :loading="running" @click="triggerPipeline" class="sky-btn">🔄 手动触发管道</NButton>
</div>
<NAlert type="info" style="margin-bottom:16px" title="PubMed E-utilities API · 自动 MeSH 打标 · 自动生成 Feed">
<div style="font-size:13px;line-height:1.8">
每天 03:07 UTC 自动精搜MAJR· 周日 03:37 UTC 自动宽搜<br>
引用刷新 05:13 UTC 每日自动 · 11月中12月中 MeSH 年更期 in-process 文献仅宽搜覆盖
</div>
</NAlert>
<!-- Action cards -->
<n-grid :cols="4" :x-gap="12" responsive="screen" style="margin-bottom:20px">
<n-grid-item>
<NCard size="small" style="text-align:center">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">🔄 刷新被引次数</div>
<NButton size="tiny" :loading="refreshingCitations" @click="refreshCitations" class="sky-btn">执行</NButton>
</NCard>
</n-grid-item>
<n-grid-item>
<NCard size="small" style="text-align:center">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">📝 PICO 提取</div>
<NButton size="tiny" :loading="extractingPico" @click="extractPico" class="sky-btn">执行</NButton>
</NCard>
</n-grid-item>
<n-grid-item>
<NCard size="small" style="text-align:center">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">🏗 回填研究设计</div>
<NButton size="tiny" :loading="backfillingDesign" @click="backfillDesign" class="sky-btn">执行</NButton>
</NCard>
</n-grid-item>
<n-grid-item>
<NCard size="small" style="text-align:center">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">🤖 AI 摘要生成</div>
<NButton size="tiny" :loading="generatingSummary" @click="generateSummary" class="sky-btn">执行</NButton>
</NCard>
</n-grid-item>
<n-grid-item>
<NCard size="small" style="text-align:center">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">📄 OA 全文回填</div>
<NButton size="tiny" :loading="backfillingOA" @click="backfillOA" class="sky-btn">执行</NButton>
</NCard>
</n-grid-item>
<n-grid-item>
<NCard size="small" style="text-align:center">
<div style="font-size:12px;color:var(--text-secondary);margin-bottom:8px">🏷 MeSH 标签回填</div>
<NButton size="tiny" :loading="retagging" @click="retagTags" class="sky-btn">执行</NButton>
</NCard>
</n-grid-item>
</n-grid>
<!-- Run history table -->
<NCard title="运行历史" size="small">
<NDataTable :columns="cols" :data="runs" :loading="loading" size="small" :bordered="false" :paginate="{ pageSize: 20 }" :scroll-x="scrollX" />
</NCard>
</div>
</template>
<style scoped>
@media (max-width: 768px) {
.pipeline-view {
padding: 12px;
}
.header-row {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.header-row h2 {
font-size: 20px;
}
.n-grid {
grid-template-columns: 1fr 1fr;
}
.n-card:has(.n-data-table) {
overflow-x: auto;
}
}
</style>
@@ -0,0 +1,258 @@
<script setup lang="ts">
import { ref, h, onMounted } from 'vue'
import { useMessage, NCard, NDataTable, NButton, NTag, NSpace, NModal, NForm, NFormItem, NSelect, NIcon, NInput } from 'naive-ui'
import { SaveOutline, SearchOutline, AddOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
interface PlatformMember {
id: string
email: string
display_name: string
platform_role: string
last_login_at?: string
created_at?: string
}
interface SearchUser {
id: string
email: string
display_name: string
platform_role?: string
}
const members = ref<PlatformMember[]>([])
const total = ref(0)
const loading = ref(false)
// Edit existing member
const showEditModal = ref(false)
const editing = ref<PlatformMember | null>(null)
// Add new member
const showAddModal = ref(false)
const searchQ = ref('')
const searchResults = ref<SearchUser[]>([])
const searching = ref(false)
const selectedUser = ref<SearchUser | null>(null)
const newRole = ref('platform_admin')
const roleOptions = [
{ label: '平台管理 (ADMIN)', value: 'platform_admin' },
{ label: '平台运营 (OPERATOR)', value: 'platform_operator' },
{ label: '平台只读 (VIEWER)', value: 'platform_viewer' },
]
const saving = ref(false)
const message = useMessage()
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const { data } = await api.get('/admin/platform-roles', { params: { page_size: 200 } })
members.value = data.items
total.value = data.total
} catch (e) {
message.error('加载平台用户列表失败:' + ((e as any)?.response?.data?.detail || '请重试'))
} finally {
loading.value = false
}
}
function editRole(row: PlatformMember) {
editing.value = { ...row }
showEditModal.value = true
}
async function saveEdit() {
if (!editing.value) return
saving.value = true
try {
await api.put(`/admin/platform-roles/${editing.value.id}`, { role: editing.value.platform_role })
showEditModal.value = false
await loadData()
message.success('角色已更新')
} catch (e: any) {
message.error('保存失败:' + (e.response?.data?.detail || '请重试'))
} finally {
saving.value = false
}
}
async function removeRole(row: PlatformMember) {
try {
await api.delete(`/admin/platform-roles/${row.id}`)
await loadData()
message.success('已移除平台角色')
} catch (e: any) {
message.error('操作失败:' + (e.response?.data?.detail || '请重试'))
}
}
function openAddModal() {
searchQ.value = ''
searchResults.value = []
selectedUser.value = null
newRole.value = 'platform_admin'
showAddModal.value = true
}
async function searchUsers() {
if (!searchQ.value.trim()) return
selectedUser.value = null
searching.value = true
try {
const { data } = await api.get('/admin/users', { params: { q: searchQ.value, page_size: 20 } })
// 不过滤——让 owner 能看到所有用户,包含已有角色的
searchResults.value = data.items || []
} catch (e: any) {
message.error('搜索失败:' + (e.response?.data?.detail || '请重试'))
} finally {
searching.value = false
}
}
function pickUser(u: SearchUser) {
selectedUser.value = u
}
async function saveAdd() {
if (!selectedUser.value) {
message.warning('请先在搜索结果中点击选择一个用户')
return
}
if (!newRole.value) return
const roleLabel = roleOptions.find(r => r.value === newRole.value)?.label || newRole.value
const ok = window.confirm(`确认将 ${selectedUser.value.display_name}${selectedUser.value.email})设为「${roleLabel}」?`)
if (!ok) return
saving.value = true
try {
await api.put(`/admin/platform-roles/${selectedUser.value.id}`, { role: newRole.value })
showAddModal.value = false
await loadData()
message.success(`已将 ${selectedUser.value.email} 设为 ${roleLabel}`)
} catch (e: any) {
message.error('分配失败:' + (e.response?.data?.detail || '请重试'))
} finally {
saving.value = false
}
}
const roleBadge = (role: string) => {
const map: Record<string, { type: 'error' | 'warning' | 'info' | 'default', label: string }> = {
platform_owner: { type: 'error', label: 'OWNER' },
platform_admin: { type: 'warning', label: 'ADMIN' },
platform_operator: { type: 'info', label: 'OP' },
platform_viewer: { type: 'default', label: 'VIEWER' },
}
return map[role] || { type: 'default', label: role }
}
const cols = [
{ title: '邮箱', key: 'email', width: 220, ellipsis: true },
{ title: '姓名', key: 'display_name', width: 100 },
{
title: '平台角色', key: 'platform_role', width: 100,
render: (r: PlatformMember) => {
const b = roleBadge(r.platform_role)
return h(NTag, { size: 'tiny', type: b.type }, () => b.label)
},
},
{ title: '最后登录', key: 'last_login_at', width: 100, render: (r: PlatformMember) => r.last_login_at?.slice(0, 10) || '—' },
{
title: '操作', key: 'actions', width: 140,
render: (r: PlatformMember) => {
if (r.platform_role === 'platform_owner') return '—'
return h(NSpace, null, () => [
h(NButton, { size: 'tiny', onClick: () => editRole(r) }, () => '编辑'),
h(NButton, { size: 'tiny', type: 'error', ghost: true, onClick: () => removeRole(r) }, () => '移除'),
])
},
},
]
const scrollX = 660
</script>
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h2 style="margin:0">👥 平台用户管理 ({{ total }})</h2>
<NButton size="small" type="primary" @click="openAddModal" class="sky-btn">
<template #icon><NIcon size="14"><AddOutline /></NIcon></template>添加
</NButton>
</div>
<NCard :bordered="false">
<p style="color:var(--text-muted);font-size:13px;margin-bottom:12px">
管理平台成员的角色修改后 JWT 不立即过期下次登录生效OWNER 角色不可在此编辑
</p>
<NDataTable :columns="cols" :data="members" :loading="loading" size="small" :paginate="{ pageSize: 20 }" :scroll-x="scrollX" />
</NCard>
<!-- Edit Modal -->
<NModal v-model:show="showEditModal" title="编辑平台角色" v-if="editing">
<NCard style="width:400px">
<NForm label-placement="left" label-width="80">
<NFormItem label="邮箱">{{ editing.email }}</NFormItem>
<NFormItem label="姓名">{{ editing.display_name }}</NFormItem>
<NFormItem label="角色">
<NSelect v-model:value="editing.platform_role" :options="roleOptions" />
</NFormItem>
<NButton type="primary" :loading="saving" attr-type="button" @click="saveEdit" class="sky-btn">
<template #icon><NIcon size="14"><SaveOutline /></NIcon></template>保存
</NButton>
</NForm>
</NCard>
</NModal>
<!-- Add Modal -->
<NModal v-model:show="showAddModal" title="添加平台用户" preset="card" style="width:520px" closable>
<div style="margin-bottom:14px">
<div style="font-size:13px;font-weight:500;margin-bottom:6px">搜索用户</div>
<div style="display:flex;gap:8px;width:100%">
<NInput v-model:value="searchQ" placeholder="输入邮箱或姓名搜索" size="small" class="add-user-search" @keyup.enter="searchUsers" />
<NButton size="small" :loading="searching" @click="searchUsers" class="sky-btn">
<template #icon><NIcon size="14"><SearchOutline /></NIcon></template>搜索
</NButton>
</div>
</div>
<div v-if="searchResults.length" style="margin-bottom:14px">
<div style="font-size:12px;color:var(--text-muted);margin-bottom:6px">搜索结果</div>
<div v-for="u in searchResults" :key="u.id" style="margin-bottom:4px">
<div
@click.stop="pickUser(u)"
:style="{
padding: '8px 12px',
cursor: 'pointer',
borderRadius: '4px',
border: selectedUser?.id === u.id ? '2px solid var(--primary-color)' : '2px solid transparent',
background: selectedUser?.id === u.id ? 'var(--primary-color)' : 'var(--bg-hover)',
color: selectedUser?.id === u.id ? '#fff' : undefined,
fontSize: '13px',
}">
<span style="font-weight:500">{{ u.display_name }}</span>
<span style="margin-left:8px;opacity:0.7">{{ u.email }}</span>
<span v-if="u.platform_role" style="margin-left:6px;font-size:11px;opacity:0.5">已有角色{{ u.platform_role }}</span>
</div>
</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:4px"> {{ searchResults.length }} 条结果</div>
</div>
<div v-else-if="searchQ && !searching" style="font-size:12px;color:var(--text-muted);margin-bottom:14px">
未找到用户
</div>
<div v-if="selectedUser" style="margin-bottom:14px;background:var(--primary-color);color:#fff;padding:8px 12px;border-radius:4px;font-size:13px">
已选{{ selectedUser.display_name }}{{ selectedUser.email }}
</div>
<div style="margin-bottom:14px">
<div style="font-size:13px;font-weight:500;margin-bottom:6px">分配角色</div>
<NSelect v-model:value="newRole" :options="roleOptions" />
</div>
<NButton type="primary" :loading="saving" @click="saveAdd" class="sky-btn">
<template #icon><NIcon size="14"><SaveOutline /></NIcon></template>确认分配
</NButton>
</NModal>
</div>
</template>
+269
View File
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { ref, watch, onMounted, h } from 'vue'
import { useMessage, NButton, NTag, NSpace, NModal, NInput, NSelect, NIcon, NDataTable, NForm, NFormItem, NInputNumber, NPagination } from 'naive-ui'
import { AddOutline, SearchOutline, RefreshOutline, SaveOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
interface Tag {
id: string; name_zh: string | null; name_en: string | null;
path: string; level: number; tag_category: string;
is_selectable: boolean; article_count: number;
source: string; is_active: boolean; mesh_ui: string | null;
}
const message = useMessage()
const items = ref<Tag[]>([])
const total = ref(0)
const loading = ref(true)
const page = ref(1)
const pageSize = ref(20)
const sourceFilter = ref('')
const activeFilter = ref('')
const categoryFilter = ref('')
const searchQuery = ref('')
function doSearch() {
page.value = 1
loadData()
}
onMounted(loadData)
watch([page, pageSize], loadData)
watch([sourceFilter, activeFilter, categoryFilter], () => {
page.value = 1
loadData()
})
async function loadData() {
loading.value = true
try {
const params: Record<string, any> = { page: page.value, page_size: pageSize.value }
if (sourceFilter.value) params.source = sourceFilter.value
if (activeFilter.value) params.active = activeFilter.value
if (categoryFilter.value) params.category = categoryFilter.value
if (searchQuery.value) params.q = searchQuery.value.trim()
const { data } = await api.get('/admin/tags', { params })
items.value = data.items || []
total.value = data.total || 0
} catch (e: any) {
message.error(e?.response?.data?.detail || '加载标签失败')
} finally {
loading.value = false
}
}
function editTag(t: Tag) {
editing.value = { ...t }
showEditModal.value = true
}
async function saveTag() {
if (!editing.value) return
saving.value = true
try {
await api.put(`/admin/tags/${editing.value.id}`, {
name_zh: editing.value.name_zh,
name_en: editing.value.name_en,
tag_category: editing.value.tag_category,
is_selectable: editing.value.is_selectable,
is_active: editing.value.is_active,
})
showEditModal.value = false
message.success('已更新')
await loadData()
} catch (e: any) {
message.error('保存失败:' + (e?.response?.data?.detail || '请重试'))
} finally {
saving.value = false
}
}
async function deleteTag(t: Tag) {
if (!confirm(`确认删除标签「${t.name_zh || t.name_en}」?将同时删除所有文献关联和用户订阅。`)) return
try {
await api.delete(`/admin/tags/${t.id}`)
message.success('已删除')
await loadData()
} catch (e: any) {
message.error('删除失败:' + (e?.response?.data?.detail || '请重试'))
}
}
async function refreshCounts() {
try {
await api.post('/admin/tags/refresh-counts')
message.success('文章计数已刷新')
await loadData()
} catch (e: any) {
message.error('刷新失败:' + (e?.response?.data?.detail || '请重试'))
}
}
async function quickToggleActive(t: Tag) {
try {
await api.put(`/admin/tags/${t.id}`, { is_active: !t.is_active })
if (!t.is_active && t.source === 'auto') {
await api.put(`/admin/tags/${t.id}`, { source: 'manual', is_active: true })
}
await loadData()
} catch (e: any) {
message.error('操作失败:' + (e?.response?.data?.detail || '请重试'))
}
}
async function createTag() {
saving.value = true
try {
await api.post('/admin/tags', creating.value)
showCreateModal.value = false
message.success('标签已创建')
creating.value = { name_zh: '', name_en: '', path: '', tag_category: 'mesh_other', level: 1, parent_id: null }
await loadData()
} catch (e: any) {
message.error('创建失败:' + (e?.response?.data?.detail || '请重试'))
} finally {
saving.value = false
}
}
// ─── Modal State ───
const showEditModal = ref(false)
const editing = ref<any>(null)
const saving = ref(false)
const showCreateModal = ref(false)
const creating = ref({ name_zh: '', name_en: '', path: '', tag_category: 'mesh_other', level: 1, parent_id: null as string | null })
const CAT_OPTIONS = [
{ label: '癌种部位', value: 'cancer' },
{ label: '驱动基因/靶点', value: 'gene' },
{ label: '治疗方式', value: 'treatment' },
{ label: '研究类型', value: 'study_type' },
{ label: '临床终点', value: 'endpoint' },
{ label: '临床场景', value: 'scenario' },
{ label: '其他', value: 'mesh_other' },
]
const CATEGORY_OPTIONS = [
{ label: '全部', value: '' },
...CAT_OPTIONS,
]
const SOURCE_OPTIONS = [
{ label: '全部', value: '' },
{ label: '手工', value: 'manual' },
{ label: 'MeSH', value: 'mesh' },
{ label: '自动', value: 'auto' },
]
const ACTIVE_OPTIONS = [
{ label: '全部', value: '' },
{ label: '已激活', value: 'true' },
{ label: '未激活', value: 'false' },
]
const cols = [
{ title: '中文名', key: 'name_zh', width: 120, ellipsis: true, render: (r: Tag) => r.name_zh || h('span', { style: 'color:var(--text-muted)' }, '(空)') },
{ title: '英文名', key: 'name_en', width: 180, ellipsis: true, render: (r: Tag) => r.name_en || '—' },
{ title: '路径', key: 'path', width: 200, ellipsis: { tooltip: true } },
{ title: '级别', key: 'level', width: 50 },
{ title: '类别', key: 'tag_category', width: 80, render: (r: Tag) => {
const label = CAT_OPTIONS.find(o => o.value === r.tag_category)?.label || r.tag_category
return h(NTag, { size: 'tiny' }, label)
}},
{ title: '来源', key: 'source', width: 80, render: (r: Tag) => {
const m: Record<string, string> = { manual: '手工', mesh: 'MeSH', auto: '自动' }
const t: Record<string, 'success' | 'info' | 'warning' | 'default'> = { manual: 'success', mesh: 'info', auto: 'warning' }
return h(NTag, { size: 'tiny', type: t[r.source] || 'default' }, m[r.source] || r.source)
}},
{ title: '状态', key: 'is_active', width: 60, render: (r: Tag) => {
return h(NButton, {
size: 'tiny',
type: r.is_active ? 'success' : 'warning',
quaternary: true,
onClick: () => quickToggleActive(r),
}, r.is_active ? '✓ 激活' : '未激活')
}},
{ title: '可选', key: 'is_selectable', width: 50, render: (r: Tag) => h(NTag, { size: 'tiny', type: r.is_selectable ? 'success' : 'default' }, r.is_selectable ? '是' : '否') },
{ title: '文献数', key: 'article_count', width: 70 },
{ title: 'MeSH UI', key: 'mesh_ui', width: 90, ellipsis: true, render: (r: Tag) => r.mesh_ui || '—' },
{ title: '操作', key: 'actions', width: 90, render: (r: Tag) => h(NSpace, {}, [
h(NButton, { size: 'tiny', quaternary: true, onClick: () => editTag(r) }, '编辑'),
h(NButton, { size: 'tiny', quaternary: true, type: 'error', onClick: () => deleteTag(r) }, '删除'),
])},
]
const scrollX = 1070
</script>
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;flex-wrap:wrap;gap:8px">
<h2 style="margin:0">标签管理 ({{ total }})</h2>
<NSpace wrap>
<NInput v-model:value="searchQuery" placeholder="搜索中文名/英文名/类别" size="small" style="width:200px" clearable @keyup.enter="doSearch" @clear="doSearch" />
<NButton size="small" @click="doSearch" class="sky-btn"><template #icon><NIcon size="14"><SearchOutline /></NIcon></template>查询</NButton>
<span style="font-size:12px;color:var(--text-muted);line-height:28px">来源</span>
<NSelect v-model:value="sourceFilter" :options="SOURCE_OPTIONS" placeholder="来源" size="small" style="width:100px" />
<span style="font-size:12px;color:var(--text-muted);line-height:28px">类别</span>
<NSelect v-model:value="categoryFilter" :options="CATEGORY_OPTIONS" placeholder="类别" size="small" style="width:120px" />
<span style="font-size:12px;color:var(--text-muted);line-height:28px">状态</span>
<NSelect v-model:value="activeFilter" :options="ACTIVE_OPTIONS" placeholder="状态" size="small" style="width:100px" />
<NButton size="small" @click="showCreateModal = true" class="sky-btn"><template #icon><NIcon size="14"><AddOutline /></NIcon></template>新建标签</NButton>
<NButton size="small" @click="refreshCounts" class="sky-btn"><template #icon><NIcon size="14"><RefreshOutline /></NIcon></template>刷新计数</NButton>
</NSpace>
</div>
<NDataTable :columns="cols" :data="items" :loading="loading" size="small" :bordered="false" :row-key="(r:Tag)=>r.id" :scroll-x="scrollX" />
<div v-if="total > 0" style="display:flex;justify-content:center;margin-top:16px">
<NPagination v-model:page="page" :page-count="Math.ceil(total / pageSize)" :page-size="pageSize"
@update:page-size="pageSize = $event; page = 1" :page-sizes="[20, 50, 100, 200]" show-size-picker />
</div>
<!-- Edit Modal -->
<NModal v-model:show="showEditModal" title="编辑标签" v-if="editing" preset="card" style="width:500px" closable>
<NForm label-placement="left" label-width="80" :show-feedback="false">
<NFormItem label="中文名"><NInput v-model:value="editing.name_zh" placeholder="留空则显示英文" /></NFormItem>
<NFormItem label="英文名"><NInput v-model:value="editing.name_en" /></NFormItem>
<NFormItem label="路径">{{ editing.path }}</NFormItem>
<NFormItem label="类别">
<NSelect v-model:value="editing.tag_category" :options="CAT_OPTIONS" />
</NFormItem>
<NFormItem label="来源" v-if="editing">
<NTag :type="(editing.source === 'manual' ? 'success' : editing.source === 'mesh' ? 'info' : 'warning') as any" size="small">
{{ SOURCE_OPTIONS.find(o => o.value === editing.source)?.label || editing.source }}
</NTag>
</NFormItem>
<NFormItem label="激活">
<NSelect :value="editing.is_active ? 'yes' : 'no'" @update:value="v => editing.is_active = v === 'yes'"
:options="[{label:'已激活',value:'yes'},{label:'未激活',value:'no'}]" />
</NFormItem>
<NFormItem label="可选">
<NSelect :value="editing.is_selectable ? 'yes' : 'no'" @update:value="v => editing.is_selectable = v === 'yes'"
:options="[{label:'是',value:'yes'},{label:'否',value:'no'}]" />
</NFormItem>
<NFormItem label="文献数">{{ editing.article_count }}</NFormItem>
<NButton type="primary" :loading="saving" @click="saveTag" class="sky-btn" style="margin-top:8px">
<template #icon><NIcon size="14"><SaveOutline /></NIcon></template>保存
</NButton>
</NForm>
</NModal>
<!-- Create Modal -->
<NModal v-model:show="showCreateModal" title="新建标签" preset="card" style="width:500px" closable>
<NForm label-placement="left" label-width="80">
<NFormItem label="中文名"><NInput v-model:value="creating.name_zh" placeholder="必填" /></NFormItem>
<NFormItem label="英文名"><NInput v-model:value="creating.name_en" placeholder="可选" /></NFormItem>
<NFormItem label="路径"><NInput v-model:value="creating.path" placeholder="如 癌种::肺癌" /></NFormItem>
<NFormItem label="级别"><NInputNumber v-model:value="creating.level" :min="1" :max="5" /></NFormItem>
<NFormItem label="类别"><NSelect v-model:value="creating.tag_category" :options="CAT_OPTIONS" /></NFormItem>
<NButton type="primary" :loading="saving" @click="createTag" class="sky-btn">
<template #icon><NIcon size="14"><AddOutline /></NIcon></template>创建
</NButton>
</NForm>
</NModal>
</div>
</template>
+100
View File
@@ -0,0 +1,100 @@
<script setup lang="ts">
import { ref, onMounted, h } from 'vue'
import { useMessage, NCard, NDataTable, NButton, NInput, NTag, NSpace, NModal, NForm, NFormItem, NSelect, NPopconfirm, NIcon, type FormRules } from 'naive-ui'
import { SearchOutline, SaveOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
import { toBeijingDate } from '../../utils/date'
interface Tenant {
id: string
name: string
slug: string
hospital_name: string
plan_type: string
status: string
created_at: string
member_count?: number
[key: string]: unknown
}
interface TenantListResponse {
items: Tenant[]
total: number
}
const tenants = ref<Tenant[]>([])
const total = ref(0)
const loading = ref(true)
const searchQ = ref('')
const showModal = ref(false)
const editing = ref<Tenant | null>(null)
const saving = ref(false)
const formRef = ref()
const rules: FormRules = {
name: { required: true, message: '请输入租户名称', trigger: 'blur' },
}
const message = useMessage()
const planOptions = [{label:'Free',value:'free'},{label:'Pro',value:'pro'},{label:'Team',value:'team'}]
onMounted(loadData)
async function loadData() {
loading.value = true
try { const { data } = await api.get<TenantListResponse>('/admin/tenants', { params: { q: searchQ.value, page_size:50 } }); tenants.value = data.items; total.value = data.total }
catch (e) { message.error('加载租户列表失败:' + ((e as any)?.response?.data?.detail || '请重试')) }
finally { loading.value = false }
}
async function toggleStatus(row: Tenant) {
try { await api.post(`/admin/tenants/${row.id}/toggle-status`); loadData() }
catch (e) { message.error('操作失败:' + ((e as any)?.response?.data?.detail || '请重试')) }
}
async function editTenant(row: Tenant) {
const { data } = await api.get<Tenant>(`/admin/tenants/${row.id}`)
editing.value = data; showModal.value = true
}
async function saveTenant() {
if (!editing.value) return
try { await formRef.value?.validate() } catch { return }
saving.value = true
try { await api.put(`/admin/tenants/${editing.value.id}`, { plan_type: editing.value.plan_type, status: editing.value.status, name: editing.value.name }); showModal.value = false; loadData() }
catch (e) { message.error('保存失败:' + ((e as any)?.response?.data?.detail || '请重试')) }
finally { saving.value = false }
}
const cols = [
{ title: '名称', key: 'name', width: 200, ellipsis: true },
{ title: 'Slug', key: 'slug', width: 150, ellipsis: true },
{ title: '医院', key: 'hospital_name', width: 120 },
{ title: '方案', key: 'plan_type', width: 80, render: (r: Tenant) => h(NTag, { size:'tiny', type: r.plan_type==='team'?'success':'default' }, r.plan_type||'free') },
{ title: '状态', key: 'status', width: 80, render: (r: Tenant) => h(NTag, { size:'tiny', type: r.status==='active'?'success':'error' }, r.status) },
{ title: '创建时间', key: 'created_at', width: 120, render: (r: Tenant) => toBeijingDate(r.created_at) },
{ title: '操作', key: 'actions', width: 160, render: (r: Tenant) => h(NSpace, { size:'small' }, [
h(NButton, { size:'tiny', onClick: () => editTenant(r) }, '编辑'),
h(NPopconfirm, { onPositiveClick: () => toggleStatus(r) }, {
trigger: () => h(NButton, { size:'tiny', secondary: true }, r.status==='active'?'停用':'启用'),
}),
]) },
]
const scrollX = 910
</script>
<template>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:16px">
<h2>🏢 租户管理 ({{ total }})</h2>
<NSpace><NInput v-model:value="searchQ" placeholder="搜索..." size="small" @keyup.enter="loadData"/><NButton size="small" @click="loadData" class="sky-btn"><template #icon><NIcon size="14"><SearchOutline /></NIcon></template>搜索</NButton></NSpace>
</div>
<NDataTable :columns="cols" :data="tenants" :loading="loading" size="small" :paginate="{ pageSize: 20 }" :scroll-x="scrollX" />
<NModal v-model:show="showModal" title="编辑租户" v-if="editing">
<NCard style="width:500px">
<NForm ref="formRef" :model="editing" :rules="rules" label-placement="left" label-width="80">
<NFormItem path="name" label="名称"><NInput v-model:value="editing.name" /></NFormItem>
<NFormItem label="方案"><NSelect v-model:value="editing.plan_type" :options="planOptions" /></NFormItem>
<NFormItem label="状态"><NSelect v-model:value="editing.status" :options="[{label:'启用',value:'active'},{label:'停用',value:'suspended'}]" /></NFormItem>
<NFormItem label="成员">{{ editing.member_count || 0 }}</NFormItem>
<NButton type="primary" :loading="saving" @click="saveTenant" class="sky-btn"><template #icon><NIcon size="14"><SaveOutline /></NIcon></template>保存</NButton>
</NForm>
</NCard>
</NModal>
</div>
</template>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
import { ref, onMounted, h } from 'vue'
import { useMessage, NDataTable, NButton, NInput, NTag, NSpace, NModal, NForm, NFormItem, NSelect, NIcon } from 'naive-ui'
import { SearchOutline, SaveOutline } from '@vicons/ionicons5'
import { api } from '../../api/client'
interface TenantRole {
tenant_name: string
role: string
}
interface User {
id: string
email: string
display_name: string
is_active: boolean
platform_role?: string
admin_note?: string
customer_name?: string
created_at?: string
last_login_at?: string
tenant_count?: number
tenants?: TenantRole[]
[key: string]: unknown
}
const users = ref<User[]>([])
const total = ref(0)
const loading = ref(true)
const searchQ = ref('')
const showModal = ref(false)
const showModalMax = ref(false)
const editing = ref<User | null>(null)
const saving = ref(false)
const message = useMessage()
// Maximized note viewer
const showNoteMax = ref(false)
const noteToView = ref('')
const noteTitle = ref('')
onMounted(loadData)
async function loadData() {
loading.value = true
try { const { data } = await api.get('/admin/users', { params: { q: searchQ.value, page_size:50 } }); users.value = data.items; total.value = data.total }
catch (e) { message.error('加载用户列表失败:' + ((e as any)?.response?.data?.detail || '请重试')) }
finally { loading.value = false }
}
async function editUser(row: User) { editing.value = {...row}; showModal.value = true }
async function saveUser() {
if (!editing.value) return
saving.value = true
try { await api.put(`/admin/users/${editing.value.id}`, { is_active: editing.value.is_active, admin_note: editing.value.admin_note || null, customer_name: editing.value.customer_name || null }); showModal.value = false; loadData() }
catch (e) { message.error('保存失败:' + ((e as any)?.response?.data?.detail || '请重试')) }
finally { saving.value = false }
}
function openMaxNote(note: string, title: string) { noteToView.value = note; noteTitle.value = title; showNoteMax.value = true }
const editModalStyle = ref('width:520px')
function toggleModalMax() {
showModalMax.value = !showModalMax.value
editModalStyle.value = showModalMax.value ? 'width:90vw;max-width:1200px' : 'width:520px'
}
const cols = [
{ title: '邮箱', key: 'email', width: 280, ellipsis: { tooltip: true } },
{ title: '姓名', key: 'display_name', width: 160, ellipsis: { tooltip: true } },
{ title: '客户名称', key: 'customer_name', width: 200, ellipsis: { tooltip: true }, render: (r: User) => r.customer_name || '—' },
{ title: '所属租户(角色)', key: 'tenants', width: 300, ellipsis: { tooltip: true }, render: (r: User) => {
const list = r.tenants || []
return h('div', { style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' },
list.length ? list.map((t: TenantRole) => `${t.tenant_name}(${t.role})`).join(', ') : '—'
)
}
},
{ title: '状态', key: 'is_active', width: 80, render: (r: User) => h(NTag, { size:'tiny', type: r.is_active?'success':'error' }, r.is_active?'正常':'禁用') },
{ title: '平台角色', key: 'platform_role', width: 120, render: (r: User) => r.platform_role ? h(NTag, { size:'tiny', type:'info' }, r.platform_role) : '—' },
{ title: '注册时间', key: 'created_at', width: 200, render: (r: User) => r.created_at ? r.created_at.slice(0,16).replace('T', ' ') : '—' },
{ title: '最后登录', key: 'last_login_at', width: 200, render: (r: User) => r.last_login_at ? r.last_login_at.slice(0,16).replace('T', ' ') : '—' },
{ title: '备注', key: 'admin_note', width: 200, ellipsis: { tooltip: true }, render: (r: User) => {
if (!r.admin_note) return '—'
return h('div', { style:'display:flex;align-items:center;gap:4px' }, [
h('span', { style:'flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, r.admin_note),
h(NButton as any, { size:'tiny', quaternary:true, onClick:(e:Event) => { e.stopPropagation(); openMaxNote(r.admin_note!, r.display_name + ' 备注') } }, '🔍'),
])
}
},
{ title: '操作', key: 'actions', width: 100, render: (r: User) => h(NButton, { size:'tiny', onClick:()=>editUser(r) }, '编辑') },
]
const scrollX = 1840
</script>
<template>
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:16px">
<h2>👤 用户管理 ({{ total }})</h2>
<NSpace>
<NInput v-model:value="searchQ" placeholder="搜索邮箱/姓名/客户/备注..." size="small" @keyup.enter="loadData" style="min-width:200px" />
<NButton size="small" @click="loadData" class="sky-btn"><template #icon><NIcon size="14"><SearchOutline /></NIcon></template>搜索</NButton>
<NButton size="small" @click="searchQ='';loadData()" class="sky-btn">重置</NButton>
</NSpace>
</div>
<NDataTable :columns="cols" :data="users" :loading="loading" size="small" single-line :paginate="{ pageSize: 20 }" class="tight-rows" :scroll-x="scrollX" />
<!-- Edit Modal -->
<NModal v-model:show="showModal" title="编辑用户" v-if="editing" :style="editModalStyle" preset="card" closable>
<div style="position:absolute;top:16px;right:48px;z-index:10">
<NButton size="tiny" quaternary @click="toggleModalMax">{{ showModalMax ? '🗗 还原' : '🗖 最大化' }}</NButton>
</div>
<NForm label-placement="left" label-width="80" :show-feedback="false" class="tight-form">
<NFormItem label="邮箱">{{ editing.email }}</NFormItem>
<NFormItem label="状态"><NSelect :value="editing?.is_active ? 'yes' : 'no'" @update:value="v => { if (editing) editing.is_active = v === 'yes' }" :options="[{label:'正常',value:'yes'},{label:'禁用',value:'no'}]" /></NFormItem>
<NFormItem label="平台角色"><NTag :type="editing?.platform_role ? 'info' : 'default'" size="small">{{ editing?.platform_role || '无' }}</NTag></NFormItem>
<NFormItem label="客户名称"><NInput v-model:value="editing.customer_name" placeholder="客户名称(用户不可见)" maxlength="200" show-count /></NFormItem>
<NFormItem label="备注">
<div style="width:100%">
<NInput v-model:value="editing.admin_note" placeholder="平台备注(用户不可见)" type="textarea" :rows="showModalMax ? 8 : 3" maxlength="1000" show-count />
<NButton v-if="editing.admin_note" size="tiny" quaternary style="margin-top:4px" @click="openMaxNote(editing.admin_note!, editing.display_name + ' 备注')">
最大化查看
</NButton>
</div>
</NFormItem>
<NButton type="primary" :loading="saving" @click="saveUser" class="sky-btn" style="margin-top:4px"><template #icon><NIcon size="14"><SaveOutline /></NIcon></template>保存</NButton>
</NForm>
</NModal>
<!-- Maximized Note Viewer -->
<NModal v-model:show="showNoteMax" :title="noteTitle" preset="card" style="width:800px" closable>
<div style="white-space:pre-wrap;font-size:14px;line-height:1.7;max-height:70vh;overflow-y:auto;padding:12px;background:var(--bg-hover);border-radius:6px">
{{ noteToView }}
</div>
</NModal>
</div>
</template>
<style scoped>
.tight-rows :deep(td) { padding-top: 4px !important; padding-bottom: 4px !important; }
.tight-form :deep(.n-form-item) { margin-bottom: 0 !important; padding-bottom: 4px !important; }
</style>