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.
This commit is contained in:
1 parent
4f43ca8b57
commit
91bc65df75
7 files changed
+101
-3
No files matched your search
@@ -113,8 +113,11 @@ async def list_news(
|
|||||||
end: datetime | None = None,
|
end: datetime | None = None,
|
||||||
page: Annotated[int, Query(ge=1)] = 1,
|
page: Annotated[int, Query(ge=1)] = 1,
|
||||||
limit: Annotated[int, Query(ge=1, le=100)] = 30,
|
limit: Annotated[int, Query(ge=1, le=100)] = 30,
|
||||||
|
ids: Annotated[list[int] | None, Query(min_length=1, max_length=100)] = None,
|
||||||
) -> ApiResponse[NewsPage]:
|
) -> ApiResponse[NewsPage]:
|
||||||
conditions = news_filters(source, q, start, end)
|
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))
|
total = await session.scalar(select(func.count()).select_from(NewsItem).where(*conditions))
|
||||||
rows = (await session.execute(
|
rows = (await session.execute(
|
||||||
select(NewsItem, func.left(NewsItem.content_text, EXCERPT_LENGTH).label("excerpt"), RiskAnalysis)
|
select(NewsItem, func.left(NewsItem.content_text, EXCERPT_LENGTH).label("excerpt"), RiskAnalysis)
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ async def main():
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
assert response.json()['code'] == 0, path
|
assert response.json()['code'] == 0, path
|
||||||
assert 'CI synthetic news' in (await client.get('/api/news?q=CI')).text
|
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
|
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']:
|
for path in ['/', '/home', '/news', '/calendar', '/crawler', '/market', '/trading', '/chat', '/skills', '/settings']:
|
||||||
response = await client.get(path)
|
response = await client.get(path)
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { request } from "@/http/axios"
|
|||||||
import { consumeStream } from "@/http/stream"
|
import { consumeStream } from "@/http/stream"
|
||||||
|
|
||||||
/** 分页查询新闻 */
|
/** 分页查询新闻 */
|
||||||
export function listNewsApi(params: News.NewsQuery) {
|
export function listNewsApi(params: News.NewsQuery, notify = true) {
|
||||||
return request<News.ListNewsResponseData>({
|
return request<News.ListNewsResponseData>({
|
||||||
url: "/news",
|
url: "/news",
|
||||||
method: "get",
|
method: "get",
|
||||||
params,
|
params,
|
||||||
|
notify,
|
||||||
paramsSerializer: { indexes: null }
|
paramsSerializer: { indexes: null }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export interface NewsBrief {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NewsQuery {
|
export interface NewsQuery {
|
||||||
|
ids?: number[]
|
||||||
source?: string[]
|
source?: string[]
|
||||||
q?: string
|
q?: string
|
||||||
start?: string
|
start?: string
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { NewsBrief, NewsEvent } from "@@/apis/news/type"
|
import type { NewsBrief, NewsEvent } from "@@/apis/news/type"
|
||||||
import { listNewsApi, subscribeNewsApi } from "@@/apis/news"
|
import { listNewsApi, subscribeNewsApi } from "@@/apis/news"
|
||||||
|
import { useSessionScope } from "@@/composables/useSessionScope"
|
||||||
import { useSourceNames } from "@@/composables/useSourceNames"
|
import { useSourceNames } from "@@/composables/useSourceNames"
|
||||||
import { usePersisted } from "@@/utils/storage"
|
import { usePersisted } from "@@/utils/storage"
|
||||||
import { useEventSource } from "@/http/useEventSource"
|
import { useEventSource } from "@/http/useEventSource"
|
||||||
|
|
||||||
export function useNewsFeed() {
|
export function useNewsFeed() {
|
||||||
|
const scope = useSessionScope()
|
||||||
const sourceNames = useSourceNames()
|
const sourceNames = useSourceNames()
|
||||||
const filters = reactive({
|
const filters = reactive({
|
||||||
source: [] as string[],
|
source: [] as string[],
|
||||||
@@ -17,6 +19,9 @@ export function useNewsFeed() {
|
|||||||
const pageSize = usePersisted("news-page-size", 30)
|
const pageSize = usePersisted("news-page-size", 30)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const failed = ref(false)
|
const failed = ref(false)
|
||||||
|
const riskFailed = ref(false)
|
||||||
|
let riskLoading = false
|
||||||
|
let timer: ReturnType<typeof setInterval> | undefined
|
||||||
const pendingCount = ref(0)
|
const pendingCount = ref(0)
|
||||||
const openedId = ref<number | null>(null)
|
const openedId = ref<number | null>(null)
|
||||||
const listBox = ref<HTMLElement>()
|
const listBox = ref<HTMLElement>()
|
||||||
@@ -39,6 +44,7 @@ export function useNewsFeed() {
|
|||||||
if (seq !== requestSeq)
|
if (seq !== requestSeq)
|
||||||
return
|
return
|
||||||
failed.value = false
|
failed.value = false
|
||||||
|
riskFailed.value = false
|
||||||
items.value = result.items
|
items.value = result.items
|
||||||
total.value = result.total
|
total.value = result.total
|
||||||
if (page.value === 1)
|
if (page.value === 1)
|
||||||
@@ -58,6 +64,26 @@ export function useNewsFeed() {
|
|||||||
else return load()
|
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]) => {
|
watch([page, pageSize], ([, size], [, oldSize]) => {
|
||||||
if (size !== oldSize && page.value !== 1) {
|
if (size !== oldSize && page.value !== 1) {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
@@ -73,6 +99,15 @@ export function useNewsFeed() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(load)
|
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 }
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Refresh, Search } from "@element-plus/icons-vue"
|
|||||||
import NewsDetail from "./components/NewsDetail.vue"
|
import NewsDetail from "./components/NewsDetail.vue"
|
||||||
import { useNewsFeed } from "./composables/useNewsFeed"
|
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)
|
const mobile = ref(false)
|
||||||
let media: MediaQueryList
|
let media: MediaQueryList
|
||||||
const updateMobile = () => mobile.value = media.matches
|
const updateMobile = () => mobile.value = media.matches
|
||||||
@@ -23,6 +23,7 @@ onBeforeUnmount(() => media?.removeEventListener("change", updateMobile))
|
|||||||
● {{ connected ? "实时推送已连接" : "实时推送断开" }}
|
● {{ connected ? "实时推送已连接" : "实时推送断开" }}
|
||||||
</el-tag>
|
</el-tag>
|
||||||
<div class="flex-1" />
|
<div class="flex-1" />
|
||||||
|
<span v-if="riskFailed" class="text-xs text-amber-600">AI 状态刷新失败,将自动重试</span>
|
||||||
<div
|
<div
|
||||||
v-if="pendingCount > 0"
|
v-if="pendingCount > 0"
|
||||||
class="flex cursor-pointer items-center gap-1 border border-sky-200 rounded-full border-solid bg-sky-50 px-4 py-1 text-sm text-[var(--el-color-primary)] hover:bg-sky-100"
|
class="flex cursor-pointer items-center gap-1 border border-sky-200 rounded-full border-solid bg-sky-50 px-4 py-1 text-sm text-[var(--el-color-primary)] hover:bg-sky-100"
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ test("新闻搜索/页容量重置只有一次请求;失败区别于空数据
|
|||||||
} },
|
} },
|
||||||
"@@/composables/useSourceNames": { useSourceNames: () => vue.ref({}) },
|
"@@/composables/useSourceNames": { useSourceNames: () => vue.ref({}) },
|
||||||
"@@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) },
|
"@@/utils/storage": { usePersisted: (_: string, value: unknown) => vue.ref(value) },
|
||||||
|
"@@/composables/useSessionScope": { useSessionScope: () => ({ isCurrent: () => true }) },
|
||||||
"@/http/useEventSource": { useEventSource: () => ({ connected: vue.ref(false) }) }
|
"@/http/useEventSource": { useEventSource: () => ({ connected: vue.ref(false) }) }
|
||||||
}, globals)
|
}, globals)
|
||||||
const page = load("@/pages/news/composables/useNewsFeed").useNewsFeed()
|
const page = load("@/pages/news/composables/useNewsFeed").useNewsFeed()
|
||||||
@@ -467,3 +468,55 @@ test("新闻 AI 详情等待期间自动刷新,完成后停止;隐藏、旧
|
|||||||
assert.equal(requests, 3)
|
assert.equal(requests, 3)
|
||||||
assert.equal(cleared, true)
|
assert.equal(cleared, true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("新闻列表只更新可见条目的匹配版本评级,保留滚动与新消息提示并丢弃旧页结果", async () => {
|
||||||
|
const queries: Record<string, unknown>[] = []
|
||||||
|
const pending: ReturnType<typeof Promise.withResolvers>[] = []
|
||||||
|
let active = true
|
||||||
|
let scrolls = 0
|
||||||
|
const load = moduleLoader({
|
||||||
|
"@@/apis/news": { listNewsApi: (query: Record<string, unknown>) => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user