Add three-stage cross-domain news research and reading briefings

Group original news into tagged fact packages, review each package with evidence and read-only research, and build complete 72-hour reading lists with independent model selections.

Add paused RSS/Atom subscriptions, news-scoped Skills, versioned analysis records, and locked deployment support for SearXNG. Preserve original news and keep live migration and legacy AI cleanup separately authorized.

Validate with 604 backend tests, 49 frontend tests, 19 CI control tests, lint, type checks, production build, and disposable PostgreSQL migration and three-stage smoke checks.
This commit is contained in:
eezd committed 2026-10-31 22:08:59 +08:00
1 parent ae35d3d608
commit 80d266dba7
44 files changed
+2682 -574

No files matched your search

+2
View File
@@ -43,3 +43,5 @@ export function subscribeCrawlerApi(signal: AbortSignal, version: number, onOpen
if (name === "log") onMessage(data)
})
}
export const createRssApi = (data: Crawler.RssInput) => request<{ code: string }>({ url: "/crawler/sources/rss", method: "post", data })
+3
View File
@@ -62,6 +62,7 @@ export interface CrawlLog {
}
export interface SourcePatch {
name?: string
enabled?: boolean
interval_seconds?: number
/** ISO 时间表示回补到该时间;null 表示取消回补 */
@@ -73,3 +74,5 @@ export type PatchSourceResponseData = null
export type ListLogsResponseData = CrawlLog[]
export type RestartCrawlerResponseData = null
export interface RssInput { name: string, url: string, interval_seconds: number }
+7 -4
View File
@@ -5,7 +5,10 @@ export const getRiskSettingsApi = () => request<Risk.Settings>({ url: "/risk/set
export const saveRiskSettingsApi = (data: Risk.Settings) => request<null>({ url: "/risk/settings", method: "put", data })
export const enableRiskApi = (data: Risk.Settings) => request<null>({ url: "/risk/enable", method: "post", timeout: 650000, data })
export const getRiskOverviewApi = (hours: number) => request<Risk.Overview>({ url: "/risk/overview", method: "get", params: { hours } })
export const listRiskEventsApi = (params: Risk.EventQuery) => request<{ items: Risk.RiskEvent[], total: number }>({ url: "/risk/events", method: "get", params })
export const getRiskEventApi = (id: number) => request<{ event: Risk.RiskEvent, history: Risk.Analysis[] }>({ url: `/risk/events/${id}`, method: "get" })
export const listRiskAnalysesApi = (params: { news_id?: number, revision_no?: number, failed?: boolean }, notify = true) => request<Risk.Analysis[]>({ url: "/risk/analyses", method: "get", params, notify })
export const retryRiskAnalysisApi = (id: number) => request<null>({ url: `/risk/analyses/${id}/retry`, method: "post" })
export const listRiskEventsApi = (params: Risk.EventQuery) => request<{ items: Risk.RiskEvent[], total: number }>({ url: "/risk/summaries", method: "get", params })
export const getRiskEventApi = (id: number) => request<{ evidence: { id: number, kind: string, data: unknown, fetched_at: string }[], event: Risk.RiskEvent, history: Risk.Analysis[], versions: { id: number, revision: number, state: string, model: string, result: Risk.RiskResult | null, error: string | null, analyzed_at: string | null }[] }>({ url: `/risk/summaries/${id}`, method: "get" })
export const listRiskAnalysesApi = (params: { news_id?: number, revision_no?: number, failed?: boolean, stage?: string }, notify = true) => request<Risk.Analysis[]>({ url: "/risk/tasks", method: "get", params, notify })
export const retryRiskAnalysisApi = (id: number, stage = "grouping") => request<null>({ url: `/risk/tasks/${id}/retry`, method: "post", params: { stage } })
export const listNewsSummariesApi = (newsId: number, revision: number) => request<Risk.RiskEvent[]>({ url: `/risk/news/${newsId}/summaries`, method: "get", params: { revision_no: revision } })
export const createNewsSkillTemplatesApi = () => request<{ grouping_skill_id: number, analysis_skill_id: number, outlook_skill_id: number }>({ url: "/risk/skill-templates", method: "post" })
+50 -7
View File
@@ -2,24 +2,55 @@ export interface RiskResult {
summary: string
reasoning: string
uncertainty: string
evidence: string
evidence?: string
direction: string
verification: string
confidence: string
stars: number
risk_stars: number
importance_stars: number
facts?: string
timeline?: string
asset_impacts?: string
opinions?: string
counter_evidence?: string
watchpoints?: string
claims?: { statement: string, verdict: string, evidence_refs: { article_id: number, quote: string }[] }[]
related_packages?: { id: number, relation: string, reason: string }[]
evidence_refs?: { article_id: number, quote: string }[]
}
export interface RiskEvent {
tags: string[]
content_kind: string
importance_stars: number
article_count: number
id: number
title: string
category: string
entities: string[]
stars: number
risk_stars: number
peak_stars: number
result: RiskResult
result: RiskResult | null
state: string
analyzed_at: string | null
revision: number
published_revision: number
error: string | null
published_at: string
updated_at: string
}
export interface Settings { enabled: boolean, provider_id: number | null, model: string, start_at: string }
export interface Settings {
enabled: boolean
provider_id: number | null
model: string
second_provider_id: number | null
second_model: string
third_provider_id: number | null
third_model: string
grouping_skill_id: number | null
analysis_skill_id: number | null
outlook_skill_id: number | null
start_at: string | null
}
export interface Analysis {
id: number
news_id: number
@@ -31,10 +62,22 @@ export interface Analysis {
analyzed_at: string | null
historical: boolean
}
export interface Reading { id: number, revision: number, priority: number, reason: string }
export interface Trend { summary: string, conditions: string, counter_evidence: string, watchpoints: string }
export interface Briefing {
reading: Reading[]
expansion: Reading[]
as_of: string
synthesis: { summary: string, market: string, short_term: Trend, medium_term: Trend } | null
inputs: { id: number, revision: number, category: string, tags: string[], result: RiskResult & { title: string } }[]
}
export interface Overview {
briefing: Briefing | null
stage2: Record<string, number>
stage3?: { state: string, pending: boolean, heartbeat: string | null }
as_of: string
counts: Record<string, number>
coverage: { total: number, done: number, failed: number, pending: number, processing: number }
coverage: { total: number, done: number, skipped: number, failed: number, pending: number, processing: number }
scenario: { direction: string, summary: string, conditions: string, counter_factors: string, uncertainty: string, event_ids: number[] } | null
snapshot_at: string | null
quotes: { at: string, BTCUSDT?: { lastPrice: string, priceChangePercent: string }, ETHUSDT?: { lastPrice: string, priceChangePercent: string } } | null
@@ -42,4 +85,4 @@ export interface Overview {
error: string | null
heartbeat: string | null
}
export interface EventQuery { hours?: number, stars?: number, q?: string, start?: string, end?: string, page?: number }
export interface EventQuery { tag?: string, importance?: number, hours?: number, stars?: number, q?: string, category?: string, asset?: string, start?: string, end?: string, page?: number }
+2 -1
View File
@@ -3,6 +3,7 @@ export type NodeKind = "folder" | "skill"
export interface SkillNode {
id: number
parent_id: number | null
scope: "shared" | "news"
kind: NodeKind
name: string
path: string
@@ -28,7 +29,7 @@ export type ListSkillTreeResponseData = SkillNode[]
export type CreateSkillNodeResponseData = SkillNode
export interface CreateSkillNodeBodyRequestData { parent_id: number | null, kind: NodeKind, name: string, priority?: number, content?: string }
export interface CreateSkillNodeBodyRequestData { parent_id: number | null, kind: NodeKind, name: string, priority?: number, scope?: "shared" | "news", content?: string }
export type PatchSkillNodeResponseData = SkillNode
@@ -7,6 +7,7 @@ import { useSessionScope } from "@@/composables/useSessionScope"
const props = defineProps<{
nodes: SkillNode[]
scope?: "shared" | "news"
editable?: boolean
selectedId?: number | null
/** 需要高亮的节点(如 Chat 刚生成草稿的 skill) */
@@ -79,7 +80,7 @@ async function create(parentId: number | null, kind: NodeKind) {
inputValidator: NAME_RULE
})
sessionScope.assertCurrent()
const node = await createSkillNodeApi({ parent_id: parentId, kind, name: value.trim() })
const node = await createSkillNodeApi({ parent_id: parentId, kind, name: value.trim(), scope: props.scope ?? "shared" })
emit("changed")
if (kind === "skill")
emit("select", node)
@@ -137,7 +138,7 @@ function onCollapse(item: TreeItem) {
:class="{ 'opacity-50': !data.data.enabled, 'bg-amber-100': highlightIds?.includes(data.id) }"
>
<span>{{ data.data.kind === "folder" ? "📁" : "📄" }}</span>
<span class="truncate">{{ data.data.name }}</span>
<span class="truncate">{{ data.data.name }}{{ data.data.scope === "news" ? " · 新闻" : "" }}</span>
<template v-if="data.data.kind === 'skill'">
<el-tag size="small" effect="plain" class="shrink-0">
P{{ data.data.priority }}
@@ -56,7 +56,7 @@ async function publish(version: SkillVersion) {
const seq = requestSeq
const current = props.skill?.published_version_no
const action = current !== null && current !== undefined && version.version_no < current ? "回退到" : "发布"
await ElMessageBox.confirm(`${action} v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" })
await ElMessageBox.confirm(`${action} v${version.version_no}?发布后对应用途的下一次任务起使用此版本。`, "发布确认", { type: "warning" })
sessionScope.assertCurrent()
if (seq !== requestSeq || !visible.value || skillId !== props.skill?.id)
throw new CanceledError("查看的 Skill 已切换")
@@ -3,6 +3,7 @@ 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"
@@ -20,10 +21,18 @@ const emit = defineEmits<{
}>()
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() {
latest.value = (await listNewsApi({ source: [props.source.code], limit: 5 })).items
const result = await listNewsApi({ source: [props.source.code], limit: 5 })
if (sessionScope.isCurrent()) latest.value = result.items
}
// 本来源有新的“本轮完成”日志时刷新最新入库列表
@@ -97,6 +106,11 @@ 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'"
+33 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { SourcesInfo } from "@@/apis/crawler/type"
import { listSourcesApi, restartCrawlerApi } from "@@/apis/crawler"
import { createRssApi, listSourcesApi, restartCrawlerApi } from "@@/apis/crawler"
import { useSessionScope } from "@@/composables/useSessionScope"
import { formatTime } from "@@/utils/text"
import { Monitor, RefreshRight } from "@element-plus/icons-vue"
@@ -9,6 +9,22 @@ import CalendarSourcePanel from "./components/CalendarSourcePanel.vue"
import SourcePanel from "./components/SourcePanel.vue"
import { useCrawlerLogs } from "./composables/useCrawlerLogs"
const rssVisible = ref(false)
const rssSaving = ref(false)
const rssDraft = reactive({ name: "", url: "", interval_seconds: 300 })
async function addRss() {
if (rssSaving.value) return
rssSaving.value = true
sessionScope.assertCurrent()
try {
await createRssApi({ ...rssDraft })
if (!sessionScope.isCurrent()) return
rssVisible.value = false
await refresh()
} finally {
rssSaving.value = false
}
}
const sessionScope = useSessionScope()
// 连续失败达到该次数时卡片告警
@@ -89,6 +105,22 @@ onMounted(async () => {
</script>
<template>
<el-dialog v-model="rssVisible" title="新增RSS/Atom订阅(保存后默认暂停)">
<el-form label-position="top">
<el-form-item label="名称">
<el-input v-model="rssDraft.name" />
</el-form-item><el-form-item label="公开订阅地址">
<el-input v-model="rssDraft.url" />
</el-form-item><el-form-item label="间隔(秒)">
<el-input-number v-model="rssDraft.interval_seconds" :min="60" :max="86400" />
</el-form-item><el-button :loading="rssSaving" @click="addRss">
保存订阅
</el-button>
</el-form>
</el-dialog>
<el-button @click="rssVisible = true">
新增 RSS/Atom 订阅
</el-button>
<div v-if="!info" v-loading="loading" class="min-h-40">
<el-result v-if="failed" icon="error" title="采集状态读取失败">
<template #extra>
+212 -45
View File
@@ -1,15 +1,36 @@
<script setup lang="ts">
import type { SkillNode } from "@@/apis/skills/type"
import type * as Risk from "@/common/apis/risk/type"
import type { Provider } from "@/common/apis/settings/type"
import { listSkillTreeApi } from "@@/apis/skills"
import { formatTime } from "@@/utils/text"
import * as api from "@/common/apis/risk"
import { listProviderModelsApi, listProvidersApi } from "@/common/apis/settings"
import { useUserStore } from "@/pinia/stores/user"
const user = useUserStore()
const hours = ref(24)
const stars = ref(1)
const route = useRoute()
const hours = ref(72)
const stars = ref(0)
const importance = ref(0)
const category = ref("")
const asset = ref("")
const taskStage = ref("grouping")
const newsSkills = ref<SkillNode[]>([])
const secondModels = ref<string[]>([])
const secondModelsError = ref("")
const secondModelsLoading = ref(false)
let secondModelsSequence = 0
const archive = ref(false)
const tag = ref("")
const readingPage = ref(1)
const thirdModels = ref<string[]>([])
const thirdError = ref("")
let thirdSequence = 0
const readingRows = computed(() => overview.value?.briefing?.reading.slice((readingPage.value - 1) * 20, readingPage.value * 20) ?? [])
function readingTitle(id: number) {
return overview.value?.briefing?.inputs.find(p => p.id === id)?.result.title ?? `包 #${id}`
}
const q = ref("")
const range = ref<[Date, Date] | null>(null)
const page = ref(1)
@@ -36,15 +57,16 @@ let modelsSequence = 0
let timer: ReturnType<typeof setInterval> | undefined
const labels: Record<string, string> = { bullish: "偏多", bearish: "偏空", mixed: "震荡/分歧", neutral: "中性", unknown: "无法判断", official: "官方公告", reported: "媒体报道", unverified: "待核实", refuted: "更正/辟谣", insufficient: "信息不足", low: "低", medium: "中", high: "高" }
const starText = (value: number) => value ? "★".repeat(value) : "无明显风险"
const levels = ["无明显风险", "关注", "警惕", "高风险", "严重", "极端系统性风险"]
const levels = ["未评级", "背景信息", "局部进展", "重要变化", "广泛影响", "重大变化"]
async function load() {
const seq = ++sequence
const version = user.sessionVersion
loading.value = true
try {
const [summary, list] = await Promise.all([api.getRiskOverviewApi(hours.value), api.listRiskEventsApi({ hours: archive.value ? undefined : hours.value, stars: stars.value, q: q.value, page: page.value, start: archive.value ? range.value?.[0].toISOString() : undefined, end: archive.value ? range.value?.[1].toISOString() : undefined })])
const [summary, list] = await Promise.all([api.getRiskOverviewApi(hours.value), api.listRiskEventsApi({ importance: importance.value, tag: tag.value, hours: archive.value ? undefined : hours.value, stars: stars.value, q: q.value, page: page.value, category: category.value, asset: asset.value, start: archive.value ? range.value?.[0].toISOString() : undefined, end: archive.value ? range.value?.[1].toISOString() : undefined })])
if (seq !== sequence || version !== user.sessionVersion) return
overview.value = summary
readingPage.value = Math.min(readingPage.value, Math.max(1, Math.ceil((summary.briefing?.reading.length ?? 0) / 20)))
events.value = list.items
total.value = list.total
failed.value = false
@@ -56,8 +78,9 @@ async function load() {
}
async function openSettings() {
const version = user.sessionVersion
const [config, choices] = await Promise.all([api.getRiskSettingsApi(), listProvidersApi()])
const [config, choices, skills] = await Promise.all([api.getRiskSettingsApi(), listProvidersApi(), listSkillTreeApi()])
if (version !== user.sessionVersion) return
newsSkills.value = skills.filter(s => s.scope === "news" && s.kind === "skill")
settings.value = config
providers.value = choices
showSettings.value = true
@@ -84,6 +107,46 @@ async function loadModels() {
if (seq === modelsSequence && version === user.sessionVersion) modelsLoading.value = false
}
}
async function loadSecondModels() {
const seq = ++secondModelsSequence
const version = user.sessionVersion
const id = settings.value?.second_provider_id
secondModels.value = []
secondModelsError.value = ""
secondModelsLoading.value = false
if (!showSettings.value || !id) return
secondModelsLoading.value = true
try {
const choices = await listProviderModelsApi(id)
if (seq === secondModelsSequence && version === user.sessionVersion) secondModels.value = choices
} catch (error) {
if (seq === secondModelsSequence && version === user.sessionVersion) secondModelsError.value = error instanceof Error ? error.message : "读取模型列表失败"
} finally {
if (seq === secondModelsSequence && version === user.sessionVersion) secondModelsLoading.value = false
}
}
async function loadThirdModels() {
const seq = ++thirdSequence
const version = user.sessionVersion
const id = settings.value?.third_provider_id
thirdModels.value = []
thirdError.value = ""
if (!showSettings.value || !id) return
try {
const rows = await listProviderModelsApi(id)
if (seq === thirdSequence && version === user.sessionVersion) thirdModels.value = rows
} catch (error) {
if (seq === thirdSequence && version === user.sessionVersion) thirdError.value = error instanceof Error ? error.message : "模型列表读取失败"
}
}
async function createTemplates() {
const version = user.sessionVersion
const value = await api.createNewsSkillTemplatesApi()
if (version !== user.sessionVersion || !settings.value) return
Object.assign(settings.value, value)
newsSkills.value = (await listSkillTreeApi()).filter(s => s.scope === "news" && s.kind === "skill")
ElMessage.success("已创建草稿,请前往 Skills 页面编辑并发布")
}
async function save(enable: boolean) {
if (!settings.value) return
const version = user.sessionVersion
@@ -109,24 +172,34 @@ async function openEvent(id: number) {
}
async function openFailures() {
const version = user.sessionVersion
const data = await api.listRiskAnalysesApi({ failed: true })
const stage = taskStage.value
const data = await api.listRiskAnalysesApi({ failed: true, stage })
if (stage !== taskStage.value) return
if (version !== user.sessionVersion) return
failures.value = data
showFailures.value = true
}
async function retry(id: number) {
const version = user.sessionVersion
await api.retryRiskAnalysisApi(id)
await api.retryRiskAnalysisApi(id, taskStage.value)
if (version === user.sessionVersion) await openFailures()
}
function search() {
if (page.value === 1) void load()
else page.value = 1
}
watch([hours, stars, archive, range], search)
watch([hours, stars, importance, archive, range, category, asset, tag], search)
watch(page, () => void load())
watch([showSettings, () => settings.value?.provider_id], () => void loadModels())
watch([showSettings, () => settings.value?.second_provider_id], () => void loadSecondModels())
watch([showSettings, () => settings.value?.third_provider_id], () => void loadThirdModels())
watch(taskStage, () => {
if (showFailures.value) void openFailures()
})
watch(() => user.sessionVersion, () => {
thirdSequence++
secondModelsSequence++
secondModels.value = []
modelsSequence++
showSettings.value = false
settings.value = null
@@ -141,6 +214,8 @@ watch(showDetail, (visible) => {
})
onMounted(() => {
void load()
const summaryId = Number(route.query.summary)
if (Number.isSafeInteger(summaryId) && summaryId > 0) void openEvent(summaryId)
timer = setInterval(() => {
if (!document.hidden && !loading.value) void load()
}, 30000)
@@ -149,6 +224,8 @@ onBeforeUnmount(() => {
sequence++
detailSequence++
modelsSequence++
thirdSequence++
secondModelsSequence++
clearInterval(timer)
})
</script>
@@ -158,9 +235,9 @@ onBeforeUnmount(() => {
<div class="flex flex-wrap items-center gap-3">
<div class="flex-1">
<h1 class="m-0 text-xl font-semibold">
新闻风险观察
72小时新闻阅读与研判
</h1><p class="mb-0 mt-1 text-sm text-slate-500">
加密市场事件、证据与条件情景
跨领域信息、事实核验、市场与社会趋势
</p>
</div>
<el-button @click="openSettings">
@@ -176,11 +253,13 @@ onBeforeUnmount(() => {
24 小时
</el-radio-button><el-radio-button :value="48">
48 小时
</el-radio-button><el-radio-button :value="72">
72 小时
</el-radio-button><el-radio-button :value="168">
7 天
</el-radio-button>
</el-radio-group>
<span class="text-xs text-slate-500">分析起点:2026-10-26 00:00 北京时间</span>
<span class="text-xs text-slate-500">仅处理新流程首次启用后发布或源站修订的新闻</span>
</div>
<el-card v-if="overview" shadow="never">
<div class="flex flex-wrap gap-5">
@@ -191,10 +270,14 @@ onBeforeUnmount(() => {
</div>
</div>
<p class="mb-0 text-sm text-slate-500">
已分析 {{ overview.coverage.done }} / {{ overview.coverage.total }} 条 · 待处理 {{ overview.coverage.pending }} · 处理中 {{ overview.coverage.processing }} · <el-button link type="danger" @click="openFailures">
初筛完成 {{ overview.coverage.done }} / {{ overview.coverage.total }} 条 · 待处理 {{ overview.coverage.pending }} · 处理中 {{ overview.coverage.processing }} · <el-button link type="danger" @click="openFailures">
失败 {{ overview.coverage.failed }}
</el-button>
</p>
<p>事件总结:完成 {{ overview.stage2.done ?? 0 }} · 待更新 {{ overview.stage2.pending ?? 0 }} · 处理中 {{ overview.stage2.processing ?? 0 }} · 失败 {{ overview.stage2.failed ?? 0 }}</p>
<p v-if="overview.stage3">
阅读编排:{{ overview.stage3.state === 'processing' ? '处理中' : overview.stage3.state === 'failed' ? '失败' : overview.stage3.pending ? '等待聚合更新' : '已更新' }}
</p>
<el-alert v-if="overview.coverage.done < overview.coverage.total" class="mt-3" type="info" :closable="false" title="分析覆盖尚未完整;没有风险事件不代表没有风险。" />
<p class="mb-0 text-xs text-slate-500">
Worker 心跳:{{ formatTime(overview.heartbeat) }} · 数据读取:{{ formatTime(overview.as_of) }}
@@ -202,52 +285,66 @@ onBeforeUnmount(() => {
</el-card>
<el-card shadow="never">
<template #header>
<div class="flex justify-between">
<span>AI 综合判断 · 未来数小时至 24 小时</span><el-tag v-if="overview?.stale" type="warning">
尚无最新判断
</el-tag>
</div>
近72小时完整阅读清单 · 按优先级排序
</template>
<template v-if="overview?.scenario">
<el-tag>{{ labels[overview.scenario.direction] }}</el-tag><p class="whitespace-pre-wrap">
{{ overview.scenario.summary }}
</p>
<p><b>触发条件:</b>{{ overview.scenario.conditions }}</p><p><b>反向因素:</b>{{ overview.scenario.counter_factors }}</p><p><b>不确定性:</b>{{ overview.scenario.uncertainty }}</p>
<el-button v-for="id in overview.scenario.event_ids" :key="id" link @click="openEvent(id)">
依据事件 #{{ id }}
</el-button>
<p class="text-xs text-slate-500">
生成于 {{ formatTime(overview.snapshot_at) }},历史补分析不代表当时预测。
</p>
</template><el-empty v-else description="尚无综合判断,请配置模型并启用分析" />
<div v-if="overview?.quotes" class="flex flex-wrap gap-4 text-sm text-slate-600">
<span v-for="symbol in (['BTCUSDT', 'ETHUSDT'] as const)" :key="symbol">{{ symbol }}:{{ overview.quotes[symbol]?.lastPrice ?? '不可用' }} · 24h {{ overview.quotes[symbol]?.priceChangePercent ?? '-' }}%</span><span>快照 {{ formatTime(overview.quotes.at) }}</span>
</div>
<el-alert v-if="overview?.error" class="mt-3" type="warning" :closable="false" :title="overview.error" />
<template v-if="overview?.briefing">
<p>生成于 {{ formatTime(overview.briefing.as_of) }} · 共 {{ overview.briefing.reading.length }} 个重要包</p>
<el-alert v-if="overview.stale" title="阅读稿可能已过期或更新失败,当前保留上次成功结果" type="warning" :closable="false" />
<div v-for="(row, index) in readingRows" :key="row.id" class="my-3">
<el-button link @click="openEvent(row.id)">
{{ (readingPage - 1) * 20 + index + 1 }}. {{ readingTitle(row.id) }}
</el-button>
<p>{{ row.reason }} · 优先级 {{ row.priority }} · 依据版本 {{ row.revision }}</p>
</div>
<el-pagination v-model:current-page="readingPage" :total="overview.briefing.reading.length" :page-size="20" layout="prev, pager, next, total" />
<p><b>拓展阅读</b></p>
<div v-for="row in overview.briefing.expansion" :key="row.id">
<el-button link @click="openEvent(row.id)">
{{ readingTitle(row.id) }}
</el-button><p>{{ row.reason }}</p>
</div>
<template v-if="overview.briefing.synthesis">
<p><b>综合判断:</b>{{ overview.briefing.synthesis.summary }}</p>
<p><b>市场影响:</b>{{ overview.briefing.synthesis.market }}</p>
<div v-for="(trend, index) in [overview.briefing.synthesis.short_term, overview.briefing.synthesis.medium_term]" :key="index">
<p><b>{{ index === 0 ? '未来24~72小时' : '未来1~4周' }}:</b>{{ trend.summary }}</p>
<p>成立条件:{{ trend.conditions }}</p><p>反向证据:{{ trend.counter_evidence }}</p><p>关注点:{{ trend.watchpoints }}</p>
</div>
</template>
</template>
<el-empty v-else description="暂无阅读稿,请配置三个阶段模型并启用" />
<el-alert v-if="overview?.error" :title="overview.error" type="warning" :closable="false" />
</el-card>
<div class="flex flex-wrap gap-3">
<el-switch v-model="archive" active-text="历史归档" />
<el-select v-model="stars" class="w-40">
<el-option :value="0" label="全部评级" /><el-option v-for="n in 5" :key="n" :value="n" :label="`${n} 星及以上`" />
<el-select v-model="importance" class="w-40">
<el-option :value="0" label="全部重要性" /><el-option v-for="n in 5" :key="n" :value="n" :label="`重要性${n}星及以上`" />
</el-select>
<el-select v-model="stars" class="w-40">
<el-option :value="0" label="全部风险评级" /><el-option v-for="n in 5" :key="n" :value="n" :label="`${n} 星及以上`" />
</el-select>
<el-input v-model="category" class="w-40" clearable placeholder="内容分类" />
<el-input v-model="tag" class="w-40" clearable placeholder="TAG标签" />
<el-input v-model="asset" class="w-40" clearable placeholder="受影响资产" />
<el-input v-model="q" class="w-60" clearable placeholder="事件关键词" @keyup.enter="search" @clear="search" />
<el-date-picker v-if="archive" v-model="range" type="datetimerange" start-placeholder="开始" end-placeholder="结束" />
</div>
<div v-loading="loading" class="flex flex-col gap-3">
<el-card v-for="event in events" :key="event.id" shadow="never" class="cursor-pointer" @click="openEvent(event.id)">
<div class="flex flex-wrap items-center gap-2">
<span class="text-amber-600">{{ event.result.verification === 'insufficient' ? '信息不足,无法判断' : starText(event.stars) }}</span><el-tag size="small">
{{ labels[event.result.verification] }}
<span class="text-amber-600">{{ event.result?.verification === 'insufficient' ? '信息不足,无法判断' : starText(event.risk_stars) }}</span><el-tag size="small">
{{ labels[event.result?.verification ?? "unknown"] }}
</el-tag><el-tag size="small" type="info">
{{ labels[event.result.direction] }}
{{ labels[event.result?.direction ?? "unknown"] }}
</el-tag><span class="ml-auto text-xs text-slate-500">最新进展 {{ formatTime(event.updated_at) }}</span>
</div>
<span v-if="event.state !== 'done'" class="text-xs text-amber-600">{{ event.error ? "更新失败,保留上次总结" : "总结更新中" }}</span>
<h3 class="my-2 text-base">
{{ event.title }}
</h3><p class="my-2 text-sm text-slate-600">
{{ event.result.summary }}
{{ event.result?.summary }}
</p><p class="mb-0 text-xs text-slate-500">
{{ event.entities.join(' · ') }} · 历史最高 {{ starText(event.peak_stars) }}
重要性 {{ event.importance_stars }} 星 · {{ event.tags.join(" · ") }} · {{ event.entities.join(' · ') }} · {{ event.article_count }} 篇相关文章 · 历史最高 {{ starText(event.peak_stars) }} · 总结 {{ formatTime(event.analyzed_at) }}
</p>
</el-card>
<el-empty v-if="!events.length && !loading" description="该范围内暂无已归档的风险事件" />
@@ -255,12 +352,12 @@ onBeforeUnmount(() => {
</div>
<el-dialog v-model="showSettings" title="新闻 AI 分析设置" width="min(600px, 95vw)">
<el-form v-if="settings" label-position="top">
<el-form-item label="API 接入配置(复用地址、协议和密钥)">
<el-form-item label="第一阶段 Luna 接入配置">
<el-select v-model="settings.provider_id" class="w-full">
<el-option v-for="provider in providers" :key="provider.id" :value="provider.id" :label="provider.name" />
</el-select>
</el-form-item>
<el-form-item label="新闻模型名称">
<el-form-item label="第一阶段模型名称">
<div class="w-full flex gap-2">
<el-select v-model="settings.model" class="min-w-0 flex-1" filterable allow-create default-first-option :loading="modelsLoading" :disabled="!settings.provider_id" placeholder="选择模型,或输入模型 ID 后回车">
<el-option v-for="model in models" :key="model" :value="model" :label="model" />
@@ -276,7 +373,46 @@ onBeforeUnmount(() => {
接口未返回可选模型,可手动填写模型 ID。
</p>
</el-form-item>
<el-alert type="info" :closable="false" title="测试并启用会调用模型,随后分析起点之后的全部新闻;不会修改 Chat 或交易模型。" />
<el-form-item label="第二阶段接入配置">
<el-select v-model="settings.second_provider_id" class="w-full">
<el-option v-for="provider in providers" :key="provider.id" :value="provider.id" :label="provider.name" />
</el-select>
</el-form-item>
<el-form-item label="第二阶段模型名称">
<el-select v-model="settings.second_model" class="w-full" filterable allow-create default-first-option :loading="secondModelsLoading" :disabled="!settings.second_provider_id">
<el-option v-for="model in secondModels" :key="model" :value="model" :label="model" />
</el-select>
<el-button :disabled="!settings.second_provider_id" @click="loadSecondModels">
刷新列表
</el-button>
<p v-if="secondModelsError">
{{ secondModelsError }}
</p>
</el-form-item>
<el-form-item label="第三阶段高级模型接入配置">
<el-select v-model="settings.third_provider_id" class="w-full">
<el-option v-for="provider in providers" :key="provider.id" :value="provider.id" :label="provider.name" />
</el-select>
</el-form-item>
<el-form-item label="第三阶段模型名称">
<el-select v-model="settings.third_model" class="w-full" filterable allow-create default-first-option>
<el-option v-for="model in thirdModels" :key="model" :value="model" :label="model" />
</el-select><el-button @click="loadThirdModels">
刷新列表
</el-button><p>{{ thirdError }}</p>
</el-form-item>
<el-form-item v-for="field in (['grouping_skill_id', 'analysis_skill_id', 'outlook_skill_id'] as const)" :key="field" :label="({ grouping_skill_id: '初筛归并 Skill', analysis_skill_id: '包核验 Skill', outlook_skill_id: '高级阅读编排 Skill' })[field]">
<el-select v-model="settings[field]" class="w-full">
<el-option v-for="skill in newsSkills" :key="skill.id" :value="skill.id" :label="`${skill.path} · ${skill.published_version_no ? `已发布 v${skill.published_version_no}` : '未发布'}`" />
</el-select>
</el-form-item>
<el-button @click="createTemplates">
创建默认新闻 Skills 草稿
</el-button>
<router-link to="/skills">
编辑与发布 Skills
</router-link>
<el-alert type="info" :closable="false" title="测试并启用会分别调用分包、中级研判和高级阅读编排模型。首次启用后仅处理新发布或源站修订的新闻;暂停后恢复沿用原起点。" />
<p>当前状态:{{ settings.enabled ? '已启用' : '已关闭' }}</p>
<el-button :loading="saving" @click="save(false)">
保存并暂停分析
@@ -289,13 +425,41 @@ onBeforeUnmount(() => {
<template v-if="detail">
<h2 class="text-lg">
{{ detail.event.title }}
</h2><div v-for="item in detail.history" :key="item.id" class="mb-5 border-b border-slate-200 pb-4">
</h2>
<template v-if="detail.event.result">
<p>{{ detail.event.result.summary }}</p><p><b>事实:</b>{{ detail.event.result.facts }}</p><p><b>时间线:</b>{{ detail.event.result.timeline }}</p>
<p><b>资产影响:</b>{{ detail.event.result.asset_impacts }}</p><p><b>相关意见:</b>{{ detail.event.result.opinions }}</p>
<p><b>不确定性:</b>{{ detail.event.result.uncertainty }}</p>
<blockquote v-for="ref in detail.event.result.evidence_refs" :key="`${ref.article_id}:${ref.quote}`">
来源 #{{ ref.article_id }}:{{ ref.quote }}
</blockquote>
</template>
<p>{{ detail.event.result?.counter_evidence }}</p><p>关注点:{{ detail.event.result?.watchpoints }}</p>
<div v-for="claim in detail.event.result?.claims" :key="claim.statement">
<b>{{ claim.verdict }}</b> {{ claim.statement }}<blockquote v-for="ref in claim.evidence_refs" :key="`${ref.article_id}:${ref.quote}`">
来源 {{ ref.article_id }}:{{ ref.quote }}
</blockquote>
</div>
<div v-for="relation in detail.event.result?.related_packages" :key="relation.id">
<el-button link @click="openEvent(relation.id)">
相关包 #{{ relation.id }} · {{ relation.relation }}
</el-button><p>{{ relation.reason }}</p>
</div>
<h3>核验工具与证据</h3><div v-for="record in detail.evidence" :key="record.id">
<details><summary>{{ record.kind }} · {{ formatTime(record.fetched_at) }}</summary><pre class="whitespace-pre-wrap break-all">{{ record.data }}</pre></details>
</div>
<h3>总结版本</h3>
<div v-for="version in detail.versions" :key="version.id" class="mb-3 border-b pb-2">
v{{ version.revision }} · {{ version.model }} · {{ version.state }} · {{ formatTime(version.analyzed_at) }}
<p>{{ version.error || version.result?.summary }}</p>
</div>
<h3>相关文章</h3><div v-for="item in detail.history" :key="item.id" class="mb-5 border-b border-slate-200 pb-4">
<a :href="item.input.url" target="_blank" rel="noopener noreferrer">{{ item.input.title || '原始新闻' }}</a><p class="text-xs text-slate-500">
{{ item.input.source }} · 发布 {{ formatTime(item.input.published_at) }} · 进展 {{ formatTime(item.input.progress_at) }} · 分析 {{ formatTime(item.analyzed_at) }} · {{ item.model }} {{ item.historical ? '· 补分析' : '' }}
</p>
<template v-if="item.result">
<p class="text-amber-600">
{{ starText(item.result.stars) }} · {{ labels[item.result.verification] }}
{{ starText(item.result.risk_stars) }} · {{ labels[item.result.verification] }}
</p><p>{{ item.result.summary }}</p><p>依据:{{ item.result.reasoning }}</p><blockquote class="text-sm text-slate-600">
{{ item.result.evidence }}
</blockquote><p>不确定性:{{ item.result.uncertainty }}</p>
@@ -304,6 +468,9 @@ onBeforeUnmount(() => {
</template>
</el-drawer>
<el-drawer v-model="showFailures" title="失败任务(最近100条)" size="min(700px, 100vw)">
<el-select v-model="taskStage">
<el-option label="文章初筛" value="grouping" /><el-option label="事件总结" value="analysis" /><el-option label="市场综述" value="outlook" />
</el-select>
<div v-for="item in failures" :key="item.id" class="mb-4">
<b>{{ item.input.title }}</b><p>{{ item.error }}</p><el-button @click="retry(item.id)">
重试
+19 -13
View File
@@ -1,11 +1,12 @@
<script setup lang="ts">
import type { Analysis } from "@/common/apis/risk/type"
import { listRiskAnalysesApi } from "@/common/apis/risk"
import type { Analysis, RiskEvent } from "@/common/apis/risk/type"
import { listNewsSummariesApi, listRiskAnalysesApi } from "@/common/apis/risk"
import { useUserStore } from "@/pinia/stores/user"
const props = defineProps<{ newsId: number, revision: number }>()
const user = useUserStore()
const analysis = ref<Analysis | null>(null)
const summaries = ref<RiskEvent[]>([])
const failed = ref(false)
const loading = ref(false)
const ownerVersion = user.sessionVersion
@@ -19,11 +20,13 @@ async function load(reset = true) {
loading.value = true
if (reset) {
analysis.value = null
summaries.value = []
failed.value = false
}
try {
const rows = await listRiskAnalysesApi({ news_id: props.newsId, revision_no: props.revision }, reset)
const [rows, events] = await Promise.all([listRiskAnalysesApi({ news_id: props.newsId, revision_no: props.revision }, reset), listNewsSummariesApi(props.newsId, props.revision)])
if (seq === sequence && version === user.sessionVersion) {
summaries.value = events
analysis.value = rows[0] ?? null
failed.value = false
}
@@ -36,7 +39,7 @@ async function load(reset = true) {
watch(() => [props.newsId, props.revision], () => void load(), { immediate: true })
onMounted(() => {
timer = setInterval(() => {
if (!document.hidden && !loading.value && analysis.value?.state !== "done") void load(false)
if (!document.hidden && !loading.value && (!["done"].includes(analysis.value?.state ?? "") || summaries.value.some(e => ["pending", "processing"].includes(e.state)))) void load(false)
}, 15000)
})
onBeforeUnmount(() => {
@@ -48,18 +51,21 @@ onBeforeUnmount(() => {
<template>
<div class="rounded bg-sky-50 p-3 text-sm">
<b>AI 新闻分析</b>
<p v-if="analysis?.state === 'done' && analysis.result">
{{ analysis.result.verification === 'insufficient' ? '信息不足,无法判断' : analysis.result.stars ? '★'.repeat(analysis.result.stars) : '无明显风险' }} · {{ analysis.result.summary }}
</p>
<p v-else>
{{ failed ? '分析状态读取失败' : analysis?.state === 'failed' ? `分析失败:${analysis.error}` : '等待分析,不能据此判断无风险' }}
<b>AI 关联新闻包</b>
<div v-for="event in summaries" :key="event.id" class="my-2">
<router-link :to="{ path: '/home', query: { summary: event.id } }">
{{ event.title }}
</router-link>
<p>{{ event.result?.summary || '事件归并完成,等待二阶段总结' }}</p>
<p v-if="event.error">
更新失败:{{ event.error }}
</p>
</div>
<p v-if="!summaries.length">
{{ failed ? '关联事件读取失败' : analysis?.state === 'failed' ? `初筛失败:${analysis.error}` : '暂无关联总结;新流程只处理启用后的新闻' }}
<el-button v-if="failed" link :loading="loading" @click="load()">
重试
</el-button>
</p>
<p v-if="analysis?.result" class="mb-0 text-xs">
不确定性:{{ analysis.result.uncertainty }}
</p>
</div>
</template>
+1 -1
View File
@@ -89,7 +89,7 @@ onBeforeUnmount(() => media?.removeEventListener("change", updateMobile))
· {{ item.risk.summary }}
</template>
<template v-else>
{{ item.risk?.state === 'failed' ? 'AI 分析失败' : item.risk?.state === 'processing' ? 'AI 分析中' : 'AI 尚未分析' }}
{{ item.risk?.state === 'skipped' ? '非财经新闻,原文保留' : item.risk?.state === 'failed' ? 'AI 分析失败' : item.risk?.state === 'processing' ? 'AI 分析中' : '未纳入新流程或等待总结' }}
</template>
</div>
</div>
+17 -3
View File
@@ -9,6 +9,13 @@ import { renderMarkdown } from "@@/utils/markdown"
const sessionScope = useSessionScope()
const nodes = ref<SkillNode[]>([])
const scope = ref<"shared" | "news">("shared")
async function changeScope(value: "shared" | "news") {
if (dirty.value) await ElMessageBox.confirm("当前 Skill 有未保存的修改,切换后会丢失。继续?", "未保存", { type: "warning" })
sessionScope.assertCurrent()
scope.value = value
selectedId.value = null
}
const selectedId = ref<number | null>(null)
const selected = computed(() => nodes.value.find(n => n.id === selectedId.value) ?? null)
@@ -90,7 +97,7 @@ async function publishLatest() {
const version = await saveDraft()
if (!version || selectedId.value !== id || contentRequest !== contentVersion)
return
await ElMessageBox.confirm(`发布 v${version.version_no}?发布后 Chat 下一轮起按此版本判断。`, "发布确认", { type: "warning" })
await ElMessageBox.confirm(`发布 v${version.version_no}?发布后对应用途的下一次任务起使用此版本。`, "发布确认", { type: "warning" })
sessionScope.assertCurrent()
if (selectedId.value !== id || contentRequest !== contentVersion)
return
@@ -132,9 +139,16 @@ onMounted(loadTree)
<div class="skills-workspace h-full flex flex-col gap-3 lg:flex-row">
<el-card class="skills-tree max-h-56 w-full flex shrink-0 flex-col lg:max-h-none lg:w-64" body-class="min-h-0 flex-1" shadow="never">
<template #header>
Skills
Skills(新闻专用规则独立生效)
</template>
<SkillTree :nodes="nodes" :selected-id="selectedId" editable @select="select" @changed="loadTree" />
<el-radio-group :model-value="scope" @update:model-value="value => changeScope(value as 'shared' | 'news')" class="mb-2">
<el-radio-button value="shared">
Chat/交易
</el-radio-button><el-radio-button value="news">
新闻
</el-radio-button>
</el-radio-group>
<SkillTree :nodes="nodes.filter(n => n.scope === scope)" :scope="scope" :selected-id="selectedId" editable @select="select" @changed="loadTree" />
</el-card>
<el-card class="min-w-0 flex flex-1 flex-col" body-class="min-h-0 flex-1 flex flex-col gap-3" shadow="never">
+139 -3
View File
@@ -5,7 +5,7 @@ import axios from "axios"
import * as vue from "vue"
import { moduleLoader } from "./module-loader.ts"
const globals = { ...vue, onMounted() {}, onBeforeUnmount() {}, onBeforeRouteLeave() {}, ElMessage: { success() {}, warning() {} } }
const globals = { ...vue, useRoute: () => ({ query: {} }), onMounted() {}, onBeforeUnmount() {}, onBeforeRouteLeave() {}, ElMessage: { success() {}, warning() {} } }
const tradingStatus = (limit = 120) => ({ exchange: "bybit", configured: true, provider_id: null, reasoning_effort: null, snapshot: null, today: { analyses: 10, limit, input_tokens: 0, output_tokens: 0 } })
@@ -354,6 +354,7 @@ test("风险首页丢弃旧窗口与旧会话结果,详情关闭后不写回",
listRiskEventsApi: async () => ({ items: [], total: 0 }),
getRiskEventApi: () => details.promise
},
"@@/apis/skills": { listSkillTreeApi: async () => [] },
"@/common/apis/settings": {},
"@/pinia/stores/user": { useUserStore: () => user }
}, globals)
@@ -384,6 +385,7 @@ test("新闻模型列表随接入配置更新,保留手填值并丢弃旧配
let unmount = () => {}
const user = vue.reactive({ sessionVersion: 1 })
const load = moduleLoader({
"@@/apis/skills": { listSkillTreeApi: async () => [] },
"@/common/apis/risk": { getRiskSettingsApi: async () => ({ provider_id: 1, model: "manual-model", enabled: false }) },
"@/common/apis/settings": {
listProvidersApi: async () => [],
@@ -434,12 +436,68 @@ test("新闻模型列表随接入配置更新,保留手填值并丢弃旧配
assert.equal(page.models.value.length, 0)
})
test("第二阶段模型列表随接入配置更新,保留手填值并丢弃旧配置、关闭及旧会话结果", async () => {
const requests: { id: number, response: ReturnType<typeof Promise.withResolvers> }[] = []
let unmount = () => {}
const user = vue.reactive({ sessionVersion: 1 })
const load = moduleLoader({
"@@/apis/skills": { listSkillTreeApi: async () => [] },
"@/common/apis/risk": { getRiskSettingsApi: async () => ({ second_provider_id: 1, second_model: "manual-model", enabled: false }) },
"@/common/apis/settings": {
listProvidersApi: async () => [],
listProviderModelsApi: (id: number) => {
const response = Promise.withResolvers()
requests.push({ id, response })
return response.promise
}
},
"@/pinia/stores/user": { useUserStore: () => user }
}, { ...globals, clearInterval, onBeforeUnmount: (callback: () => void) => unmount = callback })
const page = load("@/pages/home/index.vue").default.setup({}, { expose() {} })
await page.openSettings()
await vue.nextTick()
assert.equal(requests[0].id, 1)
page.settings.value.second_provider_id = 2
await vue.nextTick()
requests[1].response.resolve(["model-two"])
await setImmediate()
requests[0].response.resolve(["model-one"])
await setImmediate()
assert.deepEqual(Array.from(page.secondModels.value), ["model-two"])
assert.equal(page.settings.value.second_model, "manual-model")
const failed = page.loadSecondModels()
requests[2].response.reject(new Error("目录不可用"))
await failed
assert.equal(page.secondModelsError.value, "目录不可用")
assert.equal(page.settings.value.second_model, "manual-model")
const closed = page.loadSecondModels()
page.showSettings.value = false
await vue.nextTick()
requests[3].response.resolve(["closed"])
await closed
assert.equal(page.secondModels.value.length, 0)
await page.openSettings()
await vue.nextTick()
user.sessionVersion++
await vue.nextTick()
requests[4].response.resolve(["previous-session"])
await setImmediate()
assert.equal(page.showSettings.value, false)
assert.equal(page.secondModels.value.length, 0)
await page.openSettings()
await vue.nextTick()
unmount()
requests[5].response.resolve(["unmounted"])
await setImmediate()
assert.equal(page.secondModels.value.length, 0)
})
test("新闻 AI 详情绑定新闻修订版本,旧分析响应不能覆盖当前版本", async () => {
const requests: { params: unknown, response: ReturnType<typeof Promise.withResolvers> }[] = []
const props = vue.reactive({ newsId: 1, revision: 1 })
const user = { sessionVersion: 1 }
const load = moduleLoader({
"@/common/apis/risk": { listRiskAnalysesApi: (params: unknown) => {
"@/common/apis/risk": { listNewsSummariesApi: async () => [], listRiskAnalysesApi: (params: unknown) => {
const response = Promise.withResolvers()
requests.push({ params, response })
return response.promise
@@ -472,7 +530,7 @@ test("新闻 AI 详情等待期间自动刷新,完成后停止;隐藏、旧
const document = { hidden: false }
const user = { sessionVersion: 1 }
const load = moduleLoader({
"@/common/apis/risk": { listRiskAnalysesApi: async () => {
"@/common/apis/risk": { listNewsSummariesApi: async () => [], listRiskAnalysesApi: async () => {
requests++
return [{ state }]
} },
@@ -575,3 +633,81 @@ test("新闻列表只更新可见条目的匹配版本评级,保留滚动与
await page.refreshRisk()
assert.equal(queries.length, 5)
})
test("第三阶段模型列表随接入配置更新,保留手填值并丢弃旧配置、关闭及旧会话结果", async () => {
const requests: { id: number, response: ReturnType<typeof Promise.withResolvers> }[] = []
let unmount = () => {}
const user = vue.reactive({ sessionVersion: 1 })
const load = moduleLoader({
"@@/apis/skills": { listSkillTreeApi: async () => [] },
"@/common/apis/risk": { getRiskSettingsApi: async () => ({ third_provider_id: 1, third_model: "manual-model", enabled: false }) },
"@/common/apis/settings": {
listProvidersApi: async () => [],
listProviderModelsApi: (id: number) => {
const response = Promise.withResolvers()
requests.push({ id, response })
return response.promise
}
},
"@/pinia/stores/user": { useUserStore: () => user }
}, { ...globals, clearInterval, onBeforeUnmount: (callback: () => void) => unmount = callback })
const page = load("@/pages/home/index.vue").default.setup({}, { expose() {} })
await page.openSettings()
await vue.nextTick()
assert.equal(requests[0].id, 1)
page.settings.value.third_provider_id = 2
await vue.nextTick()
requests[1].response.resolve(["model-two"])
await setImmediate()
requests[0].response.resolve(["model-one"])
await setImmediate()
assert.deepEqual(Array.from(page.thirdModels.value), ["model-two"])
assert.equal(page.settings.value.third_model, "manual-model")
const failed = page.loadThirdModels()
requests[2].response.reject(new Error("目录不可用"))
await failed
assert.equal(page.thirdError.value, "目录不可用")
assert.equal(page.settings.value.third_model, "manual-model")
const closed = page.loadThirdModels()
page.showSettings.value = false
await vue.nextTick()
requests[3].response.resolve(["closed"])
await closed
assert.equal(page.thirdModels.value.length, 0)
await page.openSettings()
await vue.nextTick()
user.sessionVersion++
await vue.nextTick()
requests[4].response.resolve(["previous-session"])
await setImmediate()
assert.equal(page.showSettings.value, false)
assert.equal(page.thirdModels.value.length, 0)
await page.openSettings()
await vue.nextTick()
unmount()
requests[5].response.resolve(["unmounted"])
await setImmediate()
assert.equal(page.thirdModels.value.length, 0)
})
test("72小时阅读列表分页保留全部包,更新缩页后回到有效页", async () => {
let count = 45
const load = moduleLoader({
"@/common/apis/risk": {
getRiskOverviewApi: async () => ({ briefing: { reading: Array.from({ length: count }, (_, id) => ({ id })) } }),
listRiskEventsApi: async () => ({ items: [], total: 0 })
},
"@@/apis/skills": {},
"@/common/apis/settings": {},
"@/pinia/stores/user": { useUserStore: () => ({ sessionVersion: 1 }) }
}, globals)
const page = load("@/pages/home/index.vue").default.setup({}, { expose() {} })
await page.load()
assert.equal(page.readingRows.value.length, 20)
page.readingPage.value = 3
assert.deepEqual(Array.from(page.readingRows.value, (row: { id: number }) => row.id), [40, 41, 42, 43, 44])
count = 1
await page.load()
assert.equal(page.readingPage.value, 1)
assert.equal(page.readingRows.value[0].id, 0)
})