--- a/wechat_rpa/archive_store.py +++ b/wechat_rpa/archive_store.py @@ -369,6 +369,17 @@ ON archive_conversation_member(person_id, conversation_id); CREATE INDEX IF NOT EXISTS idx_archive_conv_recent ON archive_conversation(tenant_id, last_message_at DESC, id); +-- New name also upgrades existing databases with the old mixed-direction index. +CREATE INDEX IF NOT EXISTS idx_archive_conv_page + ON archive_conversation(tenant_id, last_message_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_archive_person_recent + ON archive_person(tenant_id, updated_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_archive_msg_recent + ON archive_message(tenant_id, sent_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_archive_media_recent + ON archive_media_object(tenant_id, created_at DESC, id DESC); +CREATE INDEX IF NOT EXISTS idx_archive_export_files + ON archive_export_file(job_id, file_name); CREATE INDEX IF NOT EXISTS idx_archive_msg_timeline ON archive_message(tenant_id, conversation_id, sent_at DESC, id DESC); CREATE INDEX IF NOT EXISTS idx_archive_msg_sender @@ -2659,7 +2670,7 @@ FROM archive_conversation_member cm WHERE cm.person_id=p.id) AS conversation_count, (SELECT COUNT(*) FROM archive_message m - WHERE m.sender_person_id=p.id) AS message_count, + WHERE m.tenant_id=p.tenant_id AND m.sender_person_id=p.id) AS message_count, (SELECT COUNT(*) FROM archive_patient_binding pb WHERE pb.tenant_id=p.tenant_id AND pb.person_id=p.id AND pb.status='active') AS patient_binding_count, @@ -2847,35 +2858,34 @@ params: list[Any] = [tenant] where = "c.tenant_id=?" if cursor: - where += " AND (c.last_message_at < ? OR (c.last_message_at=? AND c.id limit @@ -2919,8 +2929,8 @@ cursor_time, cursor_id = cursor.split("|", 1) except ValueError as exc: raise ValueError("消息游标格式不正确") from exc - where += " AND (m.sent_at > ? OR (m.sent_at=? AND m.id>?))" - params.extend([cursor_time, cursor_time, cursor_id]) + where += " AND (m.sent_at,m.id) > (?,?)" + params.extend([cursor_time, cursor_id]) params.append(limit + 1) with self.database.connect() as db: rows = db.execute( @@ -3076,6 +3086,10 @@ "SELECT * FROM archive_export_file WHERE job_id=? ORDER BY file_name", (job_id,), ).fetchall() + return self._public_export_job(row, files) + + @staticmethod + def _public_export_job(row: Any, files: Iterable[Any]) -> dict[str, Any]: item = {key: row[key] for key in row.keys()} item["formats"] = json.loads(item.pop("formats_json")) item["filters"] = json.loads(item.pop("filters_json")) @@ -3098,15 +3112,22 @@ ) -> list[dict[str, Any]]: tenant = safe_scope(tenant_id or DEFAULT_TENANT, label="租户") with self.database.connect() as db: - ids = [ - row["id"] - for row in db.execute( - """SELECT id FROM archive_export_job WHERE tenant_id=? - ORDER BY created_at DESC LIMIT ?""", - (tenant, max(1, min(int(limit), 500))), + rows = db.execute( + """SELECT * FROM archive_export_job WHERE tenant_id=? + ORDER BY created_at DESC,id DESC LIMIT ?""", + (tenant, max(1, min(int(limit), 500))), + ).fetchall() + files_by_job: dict[str, list[Any]] = {row["id"]: [] for row in rows} + if rows: + placeholders = ",".join("?" for _ in rows) + files = db.execute( + f"""SELECT * FROM archive_export_file + WHERE job_id IN ({placeholders}) ORDER BY file_name""", + tuple(files_by_job), ).fetchall() - ] - return [self.export_job(job_id) for job_id in ids] + for file in files: + files_by_job[file["job_id"]].append(file) + return [self._public_export_job(row, files_by_job[row["id"]]) for row in rows] def _export_where(self, filters: dict[str, Any], cutoff: str) -> tuple[str, list[Any]]: where = ["m.tenant_id=?", "m.created_at<=?"] --- a/wechat_rpa/archive_api.py +++ b/wechat_rpa/archive_api.py @@ -131,6 +131,13 @@ store = ArchiveStore(database) store.initialize() app.state.archive_store = store + export_slot = asyncio.Lock() + + async def run_export(job_id: str) -> None: + # Queued jobs wait asynchronously, never occupying the API worker pool. + async with export_slot: + await asyncio.to_thread(store.run_export_job, job_id) + def _desktop_patient_access(account: Any) -> dict[str, Any]: try: @@ -187,7 +194,7 @@ async def _search_patients_for_tenant( tenant: str, keyword: str, page_no: int, page_size: int ) -> dict[str, Any]: - token = store.zyt_token(tenant) + token = await asyncio.to_thread(store.zyt_token, tenant) if zyt_patient_searcher is not None: result = await asyncio.to_thread( zyt_patient_searcher, @@ -208,7 +215,7 @@ ) if not isinstance(result, dict): raise ZytPatientError("ZYT 患者查询结果格式不正确") - items = store.cache_patients(list(result.get("items") or []), tenant) + items = await asyncio.to_thread(store.cache_patients, list(result.get("items") or []), tenant) return { "items": items, "total": int(result.get("total") or len(items)), @@ -221,7 +228,7 @@ ) -> dict[str, Any] | None: """绑定前重新确认该患者仍在当前 ZYT 账号的数据范围内。""" - token = store.zyt_token(tenant) + token = await asyncio.to_thread(store.zyt_token, tenant) if zyt_patient_searcher is not None: result = await asyncio.to_thread( zyt_patient_searcher, token, str(int(patient_id)), 1, 50 @@ -242,14 +249,14 @@ return await asyncio.to_thread(client.get_patient, int(patient_id)) @app.get("/api/v2/archive/stats") - async def archive_stats( + def archive_stats( account_id: int | None = None, principal: Any = Depends(require("im:read")), ) -> dict[str, Any]: return store.stats(resolve_admin_tenant(principal, account_id)) @app.get("/api/v2/archive/conversations") - async def archive_conversations( + def archive_conversations( limit: int = 50, cursor: str = "", account_id: int | None = None, @@ -263,7 +270,7 @@ raise _http_error(exc) from exc @app.get("/api/v2/archive/conversations/{conversation_id}/messages") - async def archive_messages( + def archive_messages( conversation_id: str, limit: int = 100, cursor: str = "", @@ -281,7 +288,7 @@ raise _http_error(exc) from exc @app.post("/api/v2/archive/imports/messages") - async def archive_import_messages( + def archive_import_messages( body: ImportBody, request: Request, principal: Any = Depends(require("im:import")), @@ -294,7 +301,7 @@ raise _http_error(exc) from exc @app.post("/api/v2/archive/imports/metadata") - async def archive_import_metadata( + def archive_import_metadata( body: MetadataBody, request: Request, principal: Any = Depends(require("im:import")), @@ -307,7 +314,7 @@ raise _http_error(exc) from exc @app.get("/api/v2/archive/desktop/checkpoint") - async def desktop_archive_checkpoint( + def desktop_archive_checkpoint( external_account_id: str, source_table: str = "message_table", account: Any = Depends(current_desktop), @@ -321,7 +328,7 @@ } @app.post("/api/v2/archive/desktop/checkpoint") - async def desktop_archive_advance_checkpoint( + def desktop_archive_advance_checkpoint( body: DesktopCheckpointBody, account: Any = Depends(current_desktop), ) -> dict[str, Any]: @@ -335,7 +342,7 @@ } @app.get("/api/v2/archive/desktop/pending-attachments") - async def desktop_pending_attachments( + def desktop_pending_attachments( external_account_id: str, limit: int = 500, account: Any = Depends(current_desktop), @@ -349,7 +356,7 @@ } @app.post("/api/v2/archive/desktop/imports/messages") - async def desktop_archive_import_messages( + def desktop_archive_import_messages( body: ImportBody, request: Request, account: Any = Depends(current_desktop), @@ -362,7 +369,7 @@ raise _http_error(exc) from exc @app.post("/api/v2/archive/desktop/imports/metadata") - async def desktop_archive_import_metadata( + def desktop_archive_import_metadata( body: MetadataBody, request: Request, account: Any = Depends(current_desktop), @@ -375,7 +382,7 @@ raise _http_error(exc) from exc @app.get("/api/v2/archive/people") - async def archive_people( + def archive_people( limit: int = 100, keyword: str = "", account_id: int | None = None, @@ -388,7 +395,7 @@ } @app.get("/api/v2/archive/people/{person_id}") - async def archive_person( + def archive_person( person_id: str, account_id: int | None = None, principal: Any = Depends(require("im:identity:write")), @@ -403,7 +410,7 @@ raise _http_error(exc) from exc @app.post("/api/v2/archive/people/{person_id}/identities") - async def bind_archive_identity( + def bind_archive_identity( person_id: str, body: IdentityBody, request: Request, @@ -431,7 +438,7 @@ account_id: int | None = None, principal: Any = Depends(require("im:identity:write")), ) -> dict[str, Any]: - tenant = resolve_admin_tenant(principal, account_id) + tenant = await asyncio.to_thread(resolve_admin_tenant, principal, account_id) try: return await _search_patients_for_tenant( tenant, keyword, page_no, page_size @@ -440,7 +447,7 @@ raise _http_error(exc) from exc @app.get("/api/v2/archive/desktop/patient-context") - async def desktop_patient_context( + def desktop_patient_context( external_account_id: str, conversation_external_id: str, person_id: str = "", @@ -496,7 +503,7 @@ status_code=403, detail="当前 ZYT 账号无权访问该患者,不能建立绑定", ) - context = store.conversation_patient_context( + context = await asyncio.to_thread(store.conversation_patient_context, body.external_account_id, body.conversation_external_id, tenant, @@ -507,7 +514,7 @@ if context.get("requires_person_selection"): raise ValueError("群聊中有多个联系人,请先选择需要绑定的联系人") raise ValueError(str(context.get("reason") or "当前会话没有可绑定的联系人")) - binding = store.bind_patient( + binding = await asyncio.to_thread(store.bind_patient, str(person["id"]), body.model_dump(), None, @@ -517,7 +524,7 @@ return { "binding": binding, "context": _context_with_access( - store.conversation_patient_context( + await asyncio.to_thread(store.conversation_patient_context, body.external_account_id, body.conversation_external_id, tenant, @@ -530,7 +537,7 @@ raise _http_error(exc) from exc @app.delete("/api/v2/archive/desktop/patient-binding") - async def desktop_unbind_archive_patient( + def desktop_unbind_archive_patient( request: Request, external_account_id: str, conversation_external_id: str, @@ -568,7 +575,7 @@ raise _http_error(exc) from exc @app.put("/api/v2/archive/people/{person_id}/patient-bindings") - async def bind_archive_patient( + def bind_archive_patient( person_id: str, body: PatientBindingBody, request: Request, @@ -594,7 +601,7 @@ @app.delete( "/api/v2/archive/people/{person_id}/patient-bindings/{binding_id}" ) - async def unbind_archive_patient( + def unbind_archive_patient( person_id: str, binding_id: str, request: Request, @@ -615,13 +622,13 @@ raise _http_error(exc) from exc @app.get("/api/v2/archive/storage") - async def archive_storage( + def archive_storage( _: Any = Depends(require("im:storage:write")), ) -> dict[str, Any]: return {"storage": store.storage_config()} @app.put("/api/v2/archive/storage") - async def save_archive_storage( + def save_archive_storage( body: StorageBody, request: Request, principal: Any = Depends(require("im:storage:write")), @@ -636,7 +643,7 @@ raise _http_error(exc) from exc @app.post("/api/v2/archive/storage/test") - async def test_archive_storage( + def test_archive_storage( request: Request, principal: Any = Depends(require("im:storage:write")), ) -> dict[str, Any]: @@ -659,7 +666,7 @@ raise _http_error(exc) from exc @app.get("/api/v2/archive/media") - async def archive_media( + def archive_media( limit: int = 100, account_id: int | None = None, principal: Any = Depends(require("im:content:read")), @@ -671,7 +678,7 @@ } @app.post("/api/v2/archive/media/access-urls") - async def archive_media_access_urls( + def archive_media_access_urls( body: MediaAccessBody, request: Request, account_id: int | None = None, @@ -694,7 +701,7 @@ raise _http_error(exc) from exc @app.post("/api/v2/archive/media/prepare") - async def prepare_archive_media( + def prepare_archive_media( body: MediaPrepareBody, _: Any = Depends(require("im:import")), ) -> dict[str, Any]: @@ -704,7 +711,7 @@ raise _http_error(exc) from exc @app.post("/api/v2/archive/desktop/media/prepare") - async def desktop_prepare_archive_media( + def desktop_prepare_archive_media( body: MediaPrepareBody, account: Any = Depends(current_desktop), ) -> dict[str, Any]: @@ -716,7 +723,7 @@ raise _http_error(exc) from exc @app.post("/api/v2/archive/media/{media_id}/complete") - async def complete_archive_media( + def complete_archive_media( media_id: str, _: Any = Depends(require("im:import")), ) -> dict[str, Any]: @@ -726,7 +733,7 @@ raise _http_error(exc) from exc @app.post("/api/v2/archive/media/{media_id}/multipart-complete") - async def complete_archive_multipart_media( + def complete_archive_multipart_media( media_id: str, body: MediaMultipartCompleteBody, _: Any = Depends(require("im:import")), @@ -743,7 +750,7 @@ raise _http_error(exc) from exc @app.post("/api/v2/archive/desktop/media/{media_id}/complete") - async def desktop_complete_archive_media( + def desktop_complete_archive_media( media_id: str, account: Any = Depends(current_desktop), ) -> dict[str, Any]: @@ -755,7 +762,7 @@ raise _http_error(exc) from exc @app.post("/api/v2/archive/desktop/media/{media_id}/multipart-complete") - async def desktop_complete_archive_multipart_media( + def desktop_complete_archive_multipart_media( media_id: str, body: MediaMultipartCompleteBody, account: Any = Depends(current_desktop), @@ -773,7 +780,7 @@ raise _http_error(exc) from exc @app.get("/api/v2/archive/media/{media_id}/view") - async def view_archive_media( + def view_archive_media( media_id: str, request: Request, account_id: int | None = None, @@ -791,7 +798,7 @@ raise _http_error(exc) from exc @app.post("/api/v2/archive/exports") - async def create_archive_export( + def create_archive_export( body: ExportBody, request: Request, background: BackgroundTasks, @@ -806,13 +813,13 @@ client_ip(request), resolve_admin_tenant(principal, account_id), ) - background.add_task(store.run_export_job, job["id"]) + background.add_task(run_export, job["id"]) return {"job": job} except Exception as exc: raise _http_error(exc) from exc @app.get("/api/v2/archive/exports") - async def archive_exports( + def archive_exports( limit: int = 100, account_id: int | None = None, principal: Any = Depends(require("im:export")), @@ -824,7 +831,7 @@ } @app.get("/api/v2/archive/exports/{job_id}") - async def archive_export( + def archive_export( job_id: str, account_id: int | None = None, principal: Any = Depends(require("im:export")), @@ -839,7 +846,7 @@ raise _http_error(exc) from exc @app.get("/api/v2/archive/export-files/{file_id}/download") - async def download_archive_export( + def download_archive_export( file_id: str, request: Request, account_id: int | None = None, --- a/wechat_rpa/admin_api.py +++ b/wechat_rpa/admin_api.py @@ -294,7 +294,7 @@ forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip() return forwarded or (request.client.host if request.client else "") - async def current(request: Request) -> Principal: + def current(request: Request) -> Principal: raw = request.headers.get("authorization", "") token = raw[7:].strip() if raw.lower().startswith("bearer ") else "" user = database.session(token) if token else None @@ -306,7 +306,7 @@ ) return Principal(user, database.permissions_for_user(int(user["id"]))) - async def current_desktop(request: Request): + def current_desktop(request: Request): raw = request.headers.get("authorization", "") token = raw[7:].strip() if raw.lower().startswith("bearer ") else "" account = database.desktop_session(token) @@ -591,7 +591,8 @@ # ── 客户端账号与设备 ───────────────────────────────────────────────── @app.get("/api/v2/desktop-accounts") - async def list_desktop_accounts( + def list_desktop_accounts( + include_counts: bool = True, principal: Principal = Depends(current), ) -> dict: allowed = ( @@ -600,7 +601,7 @@ else database.desktop_account_ids_for_admin(principal.id) ) return { - "accounts": database.desktop_accounts(allowed), + "accounts": database.desktop_accounts(allowed, include_counts=include_counts), "can_view_all": "desktop-account:all" in principal.permissions, # 尚未分配任何账号的旧角色仍能查看升级前的未归属数据;一旦分配账号, # 就必须明确选择授权账号,不能再借默认值绕过范围限制。 --- a/wechat_rpa/admin_backend.py +++ b/wechat_rpa/admin_backend.py @@ -1015,7 +1015,7 @@ db.commit() def desktop_accounts( - self, allowed_ids: list[int] | None = None + self, allowed_ids: list[int] | None = None, *, include_counts: bool = True ) -> list[dict[str, Any]]: allowed = None if allowed_ids is None else sorted({int(item) for item in allowed_ids}) where = "" @@ -1025,6 +1025,13 @@ return [] where = f" WHERE a.id IN ({','.join('?' for _ in allowed)})" params = tuple(allowed) + archive_counts = ( + """(SELECT COUNT(*) FROM archive_source_account sa + WHERE sa.tenant_id=a.tenant_id) AS source_account_count, + (SELECT COUNT(*) FROM archive_message m + WHERE m.tenant_id=a.tenant_id) AS message_count""" + if include_counts else "0 AS source_account_count,0 AS message_count" + ) now_epoch = int(time.time()) online_cutoff = ( datetime.now().astimezone() - timedelta(seconds=20) @@ -1046,10 +1053,7 @@ (SELECT d.app_version FROM desktop_devices d WHERE d.account_id=a.id ORDER BY d.last_seen_at DESC LIMIT 1) AS app_version, - (SELECT COUNT(*) FROM archive_source_account sa - WHERE sa.tenant_id=a.tenant_id) AS source_account_count, - (SELECT COUNT(*) FROM archive_message m - WHERE m.tenant_id=a.tenant_id) AS message_count + {archive_counts} FROM desktop_accounts a{where} ORDER BY a.last_online_at DESC,a.id DESC""", (now_epoch, online_cutoff, *params), --- a/admin-web/apps/web-antd/src/api/archive.ts +++ b/admin-web/apps/web-antd/src/api/archive.ts @@ -185,18 +185,19 @@ return text ? `?${text}` : ''; } -export function fetchArchiveStats(accountId = 0) { +export function fetchArchiveStats(accountId = 0, signal?: AbortSignal) { return requestClient.get( `/archive/stats${query({ account_id: accountId })}`, - ); -} - -export function fetchArchiveConversations(limit = 50, cursor = '', accountId = 0) { + { signal }, + ); +} + +export function fetchArchiveConversations(limit = 50, cursor = '', accountId = 0, signal?: AbortSignal) { return requestClient.get<{ has_more: boolean; items: ArchiveConversation[]; next_cursor: string; - }>(`/archive/conversations${query({ account_id: accountId, cursor, limit })}`); + }>(`/archive/conversations${query({ account_id: accountId, cursor, limit })}`, { signal }); } export function fetchArchiveMessages( @@ -204,6 +205,7 @@ limit = 100, cursor = '', accountId = 0, + signal?: AbortSignal, ) { return requestClient.get<{ has_more: boolean; @@ -211,6 +213,7 @@ next_cursor: string; }>( `/archive/conversations/${encodeURIComponent(conversationId)}/messages${query({ account_id: accountId, cursor, limit })}`, + { signal }, ); } @@ -218,15 +221,17 @@ mediaIds: string[], expires = 900, accountId = 0, + signal?: AbortSignal, ) { return requestClient.post<{ items: ArchiveMediaAccess[] }>( `/archive/media/access-urls${query({ account_id: accountId })}`, { expires, media_ids: mediaIds }, - ); -} - -export function fetchArchiveStorage() { - return requestClient.get<{ storage: ArchiveStorage }>('/archive/storage'); + { signal }, + ); +} + +export function fetchArchiveStorage(signal?: AbortSignal) { + return requestClient.get<{ storage: ArchiveStorage }>('/archive/storage', { signal }); } export function saveArchiveStorage(data: Partial & { @@ -245,15 +250,17 @@ }>('/archive/storage/test'); } -export function fetchArchiveMedia(limit = 100, accountId = 0) { +export function fetchArchiveMedia(limit = 100, accountId = 0, signal?: AbortSignal) { return requestClient.get<{ items: ArchiveMedia[] }>( `/archive/media${query({ account_id: accountId, limit })}`, - ); -} - -export function fetchArchivePeople(limit = 100, keyword = '', accountId = 0) { + { signal }, + ); +} + +export function fetchArchivePeople(limit = 100, keyword = '', accountId = 0, signal?: AbortSignal) { return requestClient.get<{ items: ArchivePerson[] }>( `/archive/people${query({ account_id: accountId, keyword, limit })}`, + { signal }, ); } @@ -333,9 +340,10 @@ ); } -export function fetchArchiveExports(limit = 100, accountId = 0) { +export function fetchArchiveExports(limit = 100, accountId = 0, signal?: AbortSignal) { return requestClient.get<{ jobs: ArchiveExportJob[] }>( `/archive/exports${query({ account_id: accountId, limit })}`, + { signal }, ); } --- a/admin-web/apps/web-antd/src/api/console.ts +++ b/admin-web/apps/web-antd/src/api/console.ts @@ -196,13 +196,13 @@ } // ── 客户端账号 ─────────────────────────────────────────────────────────────── -export function fetchDesktopAccounts() { +export function fetchDesktopAccounts(includeCounts = true) { return requestClient.get<{ accounts: DesktopAccountItem[]; can_view_all: boolean; can_view_legacy: boolean; }>( - '/desktop-accounts', + `/desktop-accounts?include_counts=${includeCounts}`, ); } --- a/admin-web/apps/web-antd/src/components/archive-account-selector.vue +++ b/admin-web/apps/web-antd/src/components/archive-account-selector.vue @@ -25,7 +25,7 @@ onMounted(async () => { loading.value = true; try { - const response = await fetchDesktopAccounts(); + const response = await fetchDesktopAccounts(false); accounts.value = response.accounts; canViewLegacy.value = response.can_view_legacy; const saved = Number(localStorage.getItem('wecom-archive-account-id') || 0); --- a/admin-web/apps/web-antd/src/views/archive/conversations.vue +++ b/admin-web/apps/web-antd/src/views/archive/conversations.vue @@ -20,14 +20,20 @@ } from '#/api/archive'; import ArchiveAccountSelector from '#/components/archive-account-selector.vue'; -const loading = ref(false); -const messageLoading = ref(false); +import { useArchiveLoad } from './use-archive-load'; + +const pageRequest = useArchiveLoad(); +const loading = pageRequest.loading; +const messageRequest = useArchiveLoad(); +const messageLoading = messageRequest.loading; const conversations = ref([]); -const nextCursor = ref(''); +const page = ref(0); +let cursors = ['']; const hasMore = ref(false); const selected = ref(null); const messages = ref([]); -const messageCursor = ref(''); +const messagePage = ref(0); +let messageCursors = ['']; const messageHasMore = ref(false); const mediaUrls = ref>({}); const accountId = ref(0); @@ -73,76 +79,74 @@ return labels[status] || status; } -async function loadMediaUrls(items: ArchiveMessage[]) { - const ids = [ - ...new Set( - items.flatMap((item) => - (item.attachments || []) - .filter((attachment) => attachment.status === 'ready') - .map((attachment) => attachment.id), - ), - ), - ].filter((id) => !mediaUrls.value[id]); - for (let index = 0; index < ids.length; index += 200) { +async function loadMediaUrls(items: ArchiveMessage[], account: number, signal: AbortSignal) { + const ids = [...new Set(items.flatMap((item) => (item.attachments || []) + .filter((attachment) => attachment.status === 'ready') + .map((attachment) => attachment.id)))]; + const urls: Record = {}; + for (let index = 0; index < ids.length && !signal.aborted; index += 200) { try { - const data = await fetchArchiveMediaAccessUrls( - ids.slice(index, index + 200), - 900, - accountId.value, - ); - const additions = Object.fromEntries( - data.items.map((item) => [item.id, item.url]), - ); - mediaUrls.value = { ...mediaUrls.value, ...additions }; + const data = await fetchArchiveMediaAccessUrls(ids.slice(index, index + 200), 900, account, signal); + Object.assign(urls, Object.fromEntries(data.items.map((item) => [item.id, item.url]))); } catch { - // 消息正文仍可正常查看;素材签名失败时保留状态提示,刷新后可重试。 + // A signing failure must not hide the message text. } } -} - -async function load(reset = true) { - loading.value = true; - try { - const data = await fetchArchiveConversations( - 50, - reset ? '' : nextCursor.value, - accountId.value, - ); - conversations.value = reset - ? data.items - : [...conversations.value, ...data.items]; - nextCursor.value = data.next_cursor; - hasMore.value = data.has_more; - } finally { - loading.value = false; + return urls; +} + +async function load(reset = true, targetPage = 0) { + if (reset) { + cursors = ['']; + conversations.value = []; + page.value = 0; + hasMore.value = false; } + await pageRequest.run( + (signal) => fetchArchiveConversations(50, cursors[targetPage] || '', accountId.value, signal), + (data) => { + conversations.value = data.items; + page.value = targetPage; + cursors[targetPage + 1] = data.next_cursor; + hasMore.value = data.has_more; + }, + ); +} + +function closeConversation() { + messageRequest.cancel(); + selected.value = null; + messages.value = []; + mediaUrls.value = {}; + messageHasMore.value = false; } async function openConversation(record: Record) { + closeConversation(); selected.value = record as ArchiveConversation; - messages.value = []; - messageCursor.value = ''; - mediaUrls.value = {}; - await loadMessages(true); -} - -async function loadMessages(reset = false) { - if (!selected.value) return; - messageLoading.value = true; - try { - const data = await fetchArchiveMessages( - selected.value.id, - 100, - reset ? '' : messageCursor.value, - accountId.value, - ); - messages.value = reset ? data.items : [...messages.value, ...data.items]; - messageCursor.value = data.next_cursor; - messageHasMore.value = data.has_more; - await loadMediaUrls(data.items); - } finally { - messageLoading.value = false; - } + messageCursors = ['']; + messagePage.value = 0; + await loadMessages(0); +} + +async function loadMessages(targetPage = 0) { + const conversation = selected.value; + if (!conversation) return; + const account = accountId.value; + await messageRequest.run( + async (signal) => { + const data = await fetchArchiveMessages(conversation.id, 100, messageCursors[targetPage] || '', account, signal); + const urls = await loadMediaUrls(data.items, account, signal); + return { data, urls }; + }, + ({ data, urls }) => { + messages.value = data.items; + messagePage.value = targetPage; + messageCursors[targetPage + 1] = data.next_cursor; + messageHasMore.value = data.has_more; + mediaUrls.value = urls; + }, + ); } const TYPE_LABEL: Record = { @@ -163,13 +167,13 @@

- 使用时间 + 唯一 ID 游标翻页,数据增长到百万级时不会因深分页越来越慢。 + 每页显示 50 个会话;打开会话后按时间顺序分页查看消息。

-
- +
+ + 第 {{ page + 1 }} 页 +
@@ -211,7 +217,7 @@ :open="!!selected" :title="selected?.name" width="860" - @close="selected = null" + @close="closeConversation" >
@@ -241,14 +247,14 @@ v-else-if="attachment.media_type === 'video' && mediaUrls[attachment.id]" class="max-h-[420px] max-w-full rounded bg-black" controls - preload="metadata" + preload="none" :src="mediaUrls[attachment.id]" >
@@ -272,8 +278,10 @@
-
- +
+ + 第 {{ messagePage + 1 }} 页 · 每页 100 条 +
--- a/admin-web/apps/web-antd/src/views/archive/exports.vue +++ b/admin-web/apps/web-antd/src/views/archive/exports.vue @@ -1,7 +1,7 @@