From 91bc65df754c8dba66b376fa62f08ef6850c22cb Mon Sep 17 00:00:00 2001 From: eezd Date: Fri, 30 Oct 2026 18:41:03 +0800 Subject: [PATCH] Refresh visible news risk ratings after analysis Poll only visible news IDs and update ratings for matching revisions without replacing the feed, scrolling, or clearing pending-news notifications. Bind refresh results to the page and session, and cover stale responses and bounded ID queries. --- backend/src/alphapulse/api/news.py | 3 ++ scripts/smoke_app.py | 4 ++ web/src/common/apis/news/index.ts | 3 +- web/src/common/apis/news/type.ts | 1 + web/src/pages/news/composables/useNewsFeed.ts | 37 ++++++++++++- web/src/pages/news/index.vue | 3 +- web/tests/workspaces.test.ts | 53 +++++++++++++++++++ 7 files changed, 101 insertions(+), 3 deletions(-) diff --git a/backend/src/alphapulse/api/news.py b/backend/src/alphapulse/api/news.py index ae0b267..2845886 100644 --- a/backend/src/alphapulse/api/news.py +++ b/backend/src/alphapulse/api/news.py @@ -113,8 +113,11 @@ async def list_news( end: datetime | None = None, page: Annotated[int, Query(ge=1)] = 1, limit: Annotated[int, Query(ge=1, le=100)] = 30, + ids: Annotated[list[int] | None, Query(min_length=1, max_length=100)] = None, ) -> ApiResponse[NewsPage]: conditions = news_filters(source, q, start, end) + if ids is not None: + conditions.append(NewsItem.id.in_(ids)) total = await session.scalar(select(func.count()).select_from(NewsItem).where(*conditions)) rows = (await session.execute( select(NewsItem, func.left(NewsItem.content_text, EXCERPT_LENGTH).label("excerpt"), RiskAnalysis) diff --git a/scripts/smoke_app.py b/scripts/smoke_app.py index 7b12509..1f67b6f 100644 --- a/scripts/smoke_app.py +++ b/scripts/smoke_app.py @@ -46,6 +46,10 @@ async def main(): response.raise_for_status() assert response.json()['code'] == 0, path assert 'CI synthetic news' in (await client.get('/api/news?q=CI')).text + item = (await client.get('/api/news?q=CI')).json()['data']['items'][0] + selected = await client.get('/api/news', params=[('ids', item['id']), ('ids', 2147483647)]) + assert selected.status_code == 200 and [n['id'] for n in selected.json()['data']['items']] == [item['id']] + assert (await client.get('/api/news', params=[('ids', n) for n in range(101)])).status_code == 422 assert 'CI CPI' in (await client.get('/api/calendar?start=' + now.strftime('%Y-%m-%d'))).text for path in ['/', '/home', '/news', '/calendar', '/crawler', '/market', '/trading', '/chat', '/skills', '/settings']: response = await client.get(path) diff --git a/web/src/common/apis/news/index.ts b/web/src/common/apis/news/index.ts index ea73c40..cbfd713 100644 --- a/web/src/common/apis/news/index.ts +++ b/web/src/common/apis/news/index.ts @@ -3,11 +3,12 @@ import { request } from "@/http/axios" import { consumeStream } from "@/http/stream" /** 分页查询新闻 */ -export function listNewsApi(params: News.NewsQuery) { +export function listNewsApi(params: News.NewsQuery, notify = true) { return request({ url: "/news", method: "get", params, + notify, paramsSerializer: { indexes: null } }) } diff --git a/web/src/common/apis/news/type.ts b/web/src/common/apis/news/type.ts index f5a1ab9..1272fd2 100644 --- a/web/src/common/apis/news/type.ts +++ b/web/src/common/apis/news/type.ts @@ -15,6 +15,7 @@ export interface NewsBrief { } export interface NewsQuery { + ids?: number[] source?: string[] q?: string start?: string diff --git a/web/src/pages/news/composables/useNewsFeed.ts b/web/src/pages/news/composables/useNewsFeed.ts index 57fbb95..86f04da 100644 --- a/web/src/pages/news/composables/useNewsFeed.ts +++ b/web/src/pages/news/composables/useNewsFeed.ts @@ -1,10 +1,12 @@ import type { NewsBrief, NewsEvent } from "@@/apis/news/type" import { listNewsApi, subscribeNewsApi } from "@@/apis/news" +import { useSessionScope } from "@@/composables/useSessionScope" import { useSourceNames } from "@@/composables/useSourceNames" import { usePersisted } from "@@/utils/storage" import { useEventSource } from "@/http/useEventSource" export function useNewsFeed() { + const scope = useSessionScope() const sourceNames = useSourceNames() const filters = reactive({ source: [] as string[], @@ -17,6 +19,9 @@ export function useNewsFeed() { const pageSize = usePersisted("news-page-size", 30) const loading = ref(false) const failed = ref(false) + const riskFailed = ref(false) + let riskLoading = false + let timer: ReturnType | undefined const pendingCount = ref(0) const openedId = ref(null) const listBox = ref() @@ -39,6 +44,7 @@ export function useNewsFeed() { if (seq !== requestSeq) return failed.value = false + riskFailed.value = false items.value = result.items total.value = result.total if (page.value === 1) @@ -58,6 +64,26 @@ export function useNewsFeed() { else return load() } + async function refreshRisk() { + if (!scope.isCurrent() || loading.value || riskLoading || !items.value.length) return + const seq = requestSeq + riskLoading = true + try { + const result = await listNewsApi({ ids: items.value.map(item => item.id), limit: 100 }, false) + if (seq !== requestSeq || !scope.isCurrent()) return + const latest = new Map(result.items.map(item => [item.id, item])) + for (const item of items.value) { + const current = latest.get(item.id) + if (current?.revision_no === item.revision_no) item.risk = current.risk + } + riskFailed.value = false + } catch { + if (seq === requestSeq && scope.isCurrent()) riskFailed.value = true + } finally { + riskLoading = false + } + } + watch([page, pageSize], ([, size], [, oldSize]) => { if (size !== oldSize && page.value !== 1) { page.value = 1 @@ -73,6 +99,15 @@ export function useNewsFeed() { }) onMounted(load) + onMounted(() => { + timer = setInterval(() => { + if (!document.hidden && items.value.some(item => item.risk?.state !== "done")) void refreshRisk() + }, 15000) + }) + onBeforeUnmount(() => { + requestSeq++ + clearInterval(timer) + }) - return { sourceNames, filters, items, total, page, pageSize, loading, failed, pendingCount, openedId, listBox, connected, load, search } + return { sourceNames, filters, items, total, page, pageSize, loading, failed, riskFailed, pendingCount, openedId, listBox, connected, load, search, refreshRisk } } diff --git a/web/src/pages/news/index.vue b/web/src/pages/news/index.vue index f18ce9e..4db5ba3 100644 --- a/web/src/pages/news/index.vue +++ b/web/src/pages/news/index.vue @@ -4,7 +4,7 @@ import { Refresh, Search } from "@element-plus/icons-vue" import NewsDetail from "./components/NewsDetail.vue" import { useNewsFeed } from "./composables/useNewsFeed" -const { sourceNames, filters, items, total, page, pageSize, loading, failed, pendingCount, openedId, listBox, connected, load, search } = useNewsFeed() +const { sourceNames, filters, items, total, page, pageSize, loading, failed, riskFailed, pendingCount, openedId, listBox, connected, load, search } = useNewsFeed() const mobile = ref(false) let media: MediaQueryList const updateMobile = () => mobile.value = media.matches @@ -23,6 +23,7 @@ onBeforeUnmount(() => media?.removeEventListener("change", updateMobile)) ● {{ connected ? "实时推送已连接" : "实时推送断开" }}
+ AI 状态刷新失败,将自动重试
vue.ref({}) }, "@@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) }, + "@@/composables/useSessionScope": { useSessionScope: () => ({ isCurrent: () => true }) }, "@/http/useEventSource": { useEventSource: () => ({ connected: vue.ref(false) }) } }, globals) const page = load("@/pages/news/composables/useNewsFeed").useNewsFeed() @@ -467,3 +468,55 @@ test("新闻 AI 详情等待期间自动刷新,完成后停止;隐藏、旧 assert.equal(requests, 3) assert.equal(cleared, true) }) + +test("新闻列表只更新可见条目的匹配版本评级,保留滚动与新消息提示并丢弃旧页结果", async () => { + const queries: Record[] = [] + const pending: ReturnType[] = [] + let active = true + let scrolls = 0 + const load = moduleLoader({ + "@@/apis/news": { listNewsApi: (query: Record) => { + queries.push(query) + const response = Promise.withResolvers() + pending.push(response) + return response.promise + } }, + "@@/composables/useSourceNames": { useSourceNames: () => vue.ref({}) }, + "@@/composables/useSessionScope": { useSessionScope: () => ({ isCurrent: () => active }) }, + "@@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) }, + "@/http/useEventSource": { useEventSource: () => ({ connected: vue.ref(false) }) } + }, globals) + const page = load("@/pages/news/composables/useNewsFeed").useNewsFeed() + page.listBox.value = { scrollTo() { + scrolls++ + } } + const initial = page.load() + pending[0].resolve({ items: [{ id: 1, revision_no: 1 }, { id: 2, revision_no: 1, risk: { state: "processing" } }], total: 120 }) + await initial + page.pendingCount.value = 7 + page.openedId.value = 1 + const refreshing = page.refreshRisk() + assert.deepEqual(JSON.parse(JSON.stringify(queries[1])), { ids: [1, 2], limit: 100 }) + pending[1].resolve({ items: [{ id: 1, revision_no: 1, risk: { state: "done", stars: 5 } }, { id: 2, revision_no: 2, risk: { state: "done", stars: 1 } }], total: 2 }) + await refreshing + assert.equal(page.items.value[0].risk.stars, 5) + assert.equal(page.items.value[1].risk.state, "processing") + assert.equal(page.total.value, 120) + assert.equal(page.pendingCount.value, 7) + assert.equal(page.openedId.value, 1) + assert.equal(scrolls, 1) + const old = page.refreshRisk() + const current = page.load() + pending[3].resolve({ items: [{ id: 3, revision_no: 1 }], total: 1 }) + await current + pending[2].resolve({ items: [{ id: 3, revision_no: 1, risk: { state: "done" } }], total: 1 }) + await old + assert.equal(page.items.value[0].risk, undefined) + const previous = page.refreshRisk() + active = false + pending[4].resolve({ items: [{ id: 3, revision_no: 1, risk: { state: "done" } }], total: 1 }) + await previous + assert.equal(page.items.value[0].risk, undefined) + await page.refreshRisk() + assert.equal(queries.length, 5) +})