Files
AlphaPulse/web/src/pages/crawler/components/SourcePanel.vue
T
eezd 80665905e5
Development CI / check-and-build (push) Successful in 2m55s
GitHub Git mirror / mirror (push) Successful in 6s
Development CI / deploy-development (push) Successful in 2m31s
Fix official policy selection and persistent attachment gaps
Read the OFAC action body and use focused macro policy phrases, including national budgets, appropriations and reserve policies. Keep unknown image text as an explicit gap instead of treating it as complete or irrelevant.\n\nPreserve meeting attachment retries and derive pending/exhausted document counts from saved source cursors. Cover real selection negatives, positive macro boundaries and attachment failures with isolated regressions.
2026-11-04 23:39:29 +08:00

243 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import type { CrawlLog, SourceInfo } from "@@/apis/crawler/type"
import type { NewsBrief } from "@@/apis/news/type"
import { patchSourceApi } from "@@/apis/crawler"
import { listNewsApi } from "@@/apis/news"
import { useSessionScope } from "@@/composables/useSessionScope"
import { formatTime } from "@@/utils/text"
import { CircleCheck, RefreshRight } from "@element-plus/icons-vue"
import dayjs from "dayjs"
/** logs 只用来发现「本轮完成」以刷新最新入库;alert 为连续失败达到告警阈值 */
const props = defineProps<{
source: SourceInfo
logs: CrawlLog[]
restarting: boolean
alert: boolean
}>()
const emit = defineEmits<{
restart: []
changed: []
}>()
const latest = ref<NewsBrief[]>([])
const sessionScope = useSessionScope()
const sourceName = ref(props.source.name)
async function saveName() {
sessionScope.assertCurrent()
await patchSourceApi(props.source.code, { name: sourceName.value })
if (sessionScope.isCurrent()) emit("changed")
}
const interval = ref(props.source.interval_seconds)
async function loadLatest() {
const result = await listNewsApi({ source: [props.source.code], limit: 5 })
if (sessionScope.isCurrent()) latest.value = result.items
}
// 本来源有新的“本轮完成”日志时刷新最新入库列表
watch(() => props.logs.at(-1), (log) => {
if (log?.message.startsWith("本轮完成") && !log.message.includes("新增 0,修订 0"))
return loadLatest()
})
async function toggleEnabled(enabled: boolean | string | number) {
await patchSourceApi(props.source.code, { enabled: Boolean(enabled) })
emit("changed")
}
async function saveInterval() {
if (interval.value === props.source.interval_seconds)
return
await patchSourceApi(props.source.code, { interval_seconds: interval.value })
ElMessage.success("已保存,下一轮起生效")
emit("changed")
}
// Default to 2025; retain a previously selected target.
const DEFAULT_HISTORY_TARGET = "2025-01-01"
const historyDate = ref(props.source.history_target ? dayjs(props.source.history_target).format("YYYY-MM-DD") : DEFAULT_HISTORY_TARGET)
const history = computed(() => {
const { history_target: target, history_position: position, history_done: done } = props.source
if (!target)
return { text: position ? `未在回补,已回补到 ${formatTime(position, "YYYY-MM-DD HH:mm")}` : "未开始", percent: 0, status: undefined }
const now = Date.now()
const span = now - new Date(target).getTime()
const covered = position ? now - new Date(position).getTime() : 0
const percent = Math.min(100, Math.max(0, Math.round(covered / span * 1000) / 10))
const reached = formatTime(position, "YYYY-MM-DD HH:mm")
if (done && position && new Date(position) <= new Date(target))
return { text: `列表遍历完成,已到 ${reached};正文及附件缺口以条目状态为准`, percent: 100, status: "success" } as const
if (done)
return { text: `来源最早只能翻到 ${reached},更早的数据接口不提供`, percent, status: "warning" } as const
return { text: position ? `进行中,已到 ${reached}` : "等待下一轮开始", percent, status: undefined }
})
async function startHistory() {
// 按北京时间当天 0 点
await patchSourceApi(props.source.code, { history_target: dayjs(`${historyDate.value}T00:00:00+08:00`).toISOString() })
ElMessage.success("已开始,下一轮起分段回补")
emit("changed")
}
async function cancelHistory() {
await patchSourceApi(props.source.code, { history_target: null })
ElMessage.success("已取消,断点保留")
emit("changed")
}
const statusTag = computed(() => {
if (props.source.configuration_error)
return { type: "warning", text: props.source.configuration_error } as const
const process = props.source.process
if (props.restarting || process?.state === "restarting")
return { type: "warning", text: "重启中" } as const
if (!process)
return { type: "danger", text: "管理进程未运行" } as const
if (process.state === "stopped")
return { type: "danger", text: "子进程未运行" } as const
if (!props.source.enabled)
return { type: "info", text: "已停用" } as const
if (props.source.paused_until && new Date(props.source.paused_until) > new Date())
return { type: "warning", text: "失败退避中" } as const
return { type: "success", text: "运行中" } as const
})
onMounted(loadLatest)
</script>
<template>
<div v-if="source.code.startsWith('rss_')" class="flex gap-2">
<el-input v-model="sourceName" placeholder="订阅名称" /><el-button @click="saveName">
保存名称
</el-button>
</div>
<div
class="flex flex-col gap-3 border rounded-lg border-solid p-4 shadow-sm"
:class="alert ? 'border-rose-300 bg-rose-50/50' : 'border-slate-200 bg-white'"
>
<div class="flex items-center gap-2">
<span class="text-base text-slate-900 font-bold">{{ source.name }}</span>
<el-tag v-if="alert" type="danger" effect="dark" size="small">
告警
</el-tag>
<div class="flex-1" />
<el-switch :disabled="Boolean(source.configuration_error)" :model-value="source.enabled" @change="toggleEnabled" />
</div>
<div class="flex items-center gap-2 text-xs">
<span class="h-2 w-2 rounded-full" :style="{ background: `var(--el-color-${statusTag.type})` }" />
<span class="font-semibold" :style="{ color: `var(--el-color-${statusTag.type})` }">{{ statusTag.text }}</span>
<span v-if="source.process?.pid" class="num text-slate-500">PID {{ source.process.pid }}</span>
</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-2 border border-slate-100 rounded-lg border-solid bg-slate-50 p-3 text-xs text-slate-500">
<div>
上次成功
<div class="num text-sm text-slate-900">
{{ formatTime(source.last_success_at) }}
</div>
</div>
<div>
连续失败
<div class="num text-sm" :class="source.consecutive_failures > 0 ? 'text-rose-600 font-bold' : 'text-slate-900'">
{{ source.consecutive_failures }} 次
</div>
</div>
<div>
下次运行
<div class="num text-sm text-[var(--el-color-primary)]">
{{ formatTime(source.paused_until && source.paused_until > (source.process?.next_run_at ?? "") ? source.paused_until : source.process?.next_run_at) }}
</div>
</div>
<div>
累计条目
<div class="num text-sm text-slate-900">
{{ source.total_items.toLocaleString() }}
</div>
</div>
</div>
<div v-if="source.collection_stats?.unresolved_bills" class="text-xs text-amber-600">
待核验法案 {{ source.collection_stats.unresolved_bills }};官方资料尚不充分
</div>
<div v-if="source.collection_stats?.attachment_gaps" class="text-xs text-amber-600">
附件待核验文档 {{ source.collection_stats.attachment_gaps }};待重试 {{ source.collection_stats.attachment_retry_pending }};重试已耗尽 {{ source.collection_stats.attachment_retry_exhausted }},请核对原文
</div>
<div v-if="source.collection_stats?.discovered" class="text-xs text-slate-500">
本轮发现 {{ source.collection_stats.discovered }};筛除 {{ source.collection_stats.excluded ?? 0 }};不完整包 {{ source.collection_stats.partial ?? 0 }}
</div>
<div class="flex justify-between text-xs text-slate-500">
<span>今日新增 <b class="num text-[var(--el-color-primary)]">+{{ source.today_new }}</b></span>
<span>今日修订 <b class="num text-slate-900">{{ source.today_revised }}</b></span>
</div>
<div class="flex items-center justify-between border-t border-t-slate-100 border-t-solid pt-3 text-xs text-slate-500">
采集间隔
<span class="flex items-center gap-1">
<el-input-number v-model="interval" :min="5" :max="86400" size="small" controls-position="right" class="w-28" @change="saveInterval" />
秒
</span>
</div>
<div class="border border-slate-200 rounded-lg border-solid bg-slate-50/60 p-3 text-xs text-slate-500">
<div class="mb-2 flex items-center justify-between">
历史回补
<span v-if="source.supports_history" class="num text-[var(--el-color-primary)]">{{ history.percent }}%</span>
<span v-else>不可用</span>
</div>
<template v-if="!source.supports_history">
此来源不支持控制台连续翻页回补;官方归档由独立任务处理。
</template>
<template v-else>
<div class="flex items-center gap-2">
<el-date-picker v-model="historyDate" type="date" value-format="YYYY-MM-DD" :clearable="false" size="small" class="w-32!" />
<el-button size="small" type="primary" plain class="flex-1" @click="startHistory">
{{ source.history_position ? "继续回补" : "开始回补" }}
</el-button>
<el-button v-if="source.history_target" size="small" class="ml-0!" @click="cancelHistory">
取消
</el-button>
</div>
<el-progress :percentage="history.percent" :status="history.status" :show-text="false" :stroke-width="4" class="mt-2" />
<div class="mt-1">
{{ history.text }}
</div>
</template>
</div>
<div v-if="source.last_error" class="border border-rose-200 rounded-lg border-solid bg-rose-50 p-2 text-xs">
<div class="mb-1 text-rose-700 font-semibold">
最近错误
</div>
<div class="num max-h-24 overflow-auto whitespace-pre-wrap break-all text-rose-800">
{{ source.last_error }}
</div>
</div>
<div v-else class="flex items-center gap-1 text-xs text-slate-500">
<el-icon class="text-emerald-600">
<CircleCheck />
</el-icon>
最近错误:无
</div>
<div class="border border-slate-200 rounded-lg border-solid p-2 text-xs">
<div class="mb-1 flex items-center justify-between text-slate-500">
最新入库
<el-link :href="source.list_url" target="_blank" type="primary" class="text-xs!">
原站链接
</el-link>
</div>
<div v-for="item in latest" :key="item.id" class="truncate leading-6">
<span class="num mr-2 text-slate-400">{{ formatTime(item.published_at, "MM-DD HH:mm") }}</span>
<a :href="item.url" target="_blank" class="text-slate-700 no-underline hover:text-[var(--el-color-primary)]">{{ item.title || item.excerpt.slice(0, 60) }}</a>
</div>
<div v-if="!latest.length" class="text-slate-400">
暂无
</div>
</div>
<el-button :type="alert ? 'danger' : 'default'" :icon="RefreshRight" :loading="restarting" class="mt-auto w-full" @click="emit('restart')">
重启该来源
</el-button>
</div>
</template>