feat: initial commit - oncology literature search platform
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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">⚙️ 系统配置</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">
|
||||
• 运行参数通过 <code>config/specialties/oncology.yaml</code> 和环境变量配置<br>
|
||||
• JWT 过期时间可在运行时修改,重启后恢复为配置值<br>
|
||||
• 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user