From c381eb8b385760e753573642fcefd3ee73d93d05 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Thu, 27 Aug 2026 15:45:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(media):=20=E4=B8=BA=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E5=BA=93=E6=B7=BB=E5=8A=A0=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 PaginatedResult 泛型类型统一分页返回数据结构 - 重构后端媒体素材列表接口,支持分页参数并返回完整分页信息 - 前端媒体素材页面添加分页控件,适配分页加载逻辑 - 上传素材后自动重置页码至第一页并重新加载列表 - 更新接口文档和用户文档,补充分页API使用规范 - 新增对应测试用例覆盖分页接口与前端调用逻辑 --- .../src/api/client-operations.test.ts | 6 +- WonderQ-Admin-UI-Vue/src/api/client.ts | 9 ++- WonderQ-Admin-UI-Vue/src/pages/MediaPage.vue | 56 +++++++++++++++++-- WonderQ-Admin-UI-Vue/src/styles.css | 5 ++ WonderQ-Admin-UI-Vue/src/types.ts | 7 +++ WonderQ-Admin/app/routers/admin.py | 25 ++++++++- WonderQ-Admin/tests/test_api_contracts.py | 33 +++++++++++ docs/admin-api-requirements.md | 4 +- docs/integration-workflow.md | 1 + 9 files changed, 132 insertions(+), 14 deletions(-) diff --git a/WonderQ-Admin-UI-Vue/src/api/client-operations.test.ts b/WonderQ-Admin-UI-Vue/src/api/client-operations.test.ts index 91bd166..3bd184c 100644 --- a/WonderQ-Admin-UI-Vue/src/api/client-operations.test.ts +++ b/WonderQ-Admin-UI-Vue/src/api/client-operations.test.ts @@ -37,7 +37,7 @@ describe("operations client", () => { ]); }); - it("supports filtered leads, media listing and status contracts", async () => { + it("supports filtered leads, paginated media listing and status contracts", async () => { const fetchMock = vi.fn() .mockResolvedValueOnce(jsonResponse(200, { items: [] })) .mockResolvedValueOnce(jsonResponse(200, { id: "lead-1", status: "contacted" })) @@ -46,12 +46,12 @@ describe("operations client", () => { await getLeads({ leadType: "vehicle", status: "new", keyword: "贵州", take: 50 }); await updateLeadStatus("lead-1", "contacted"); - await getMediaAssets(); + await getMediaAssets({ pageNum: 2, pageSize: 20 }); expect(fetchMock.mock.calls.map(([url, options]) => [url, options?.method])).toEqual([ ["/api/admin/leads?leadType=vehicle&status=new&keyword=%E8%B4%B5%E5%B7%9E&take=50", undefined], ["/api/admin/leads/lead-1/status", "PATCH"], - ["/api/admin/media-assets", undefined], + ["/api/admin/media-assets?pageNum=2&pageSize=20", undefined], ]); }); }); diff --git a/WonderQ-Admin-UI-Vue/src/api/client.ts b/WonderQ-Admin-UI-Vue/src/api/client.ts index 6f8910f..5a7279b 100644 --- a/WonderQ-Admin-UI-Vue/src/api/client.ts +++ b/WonderQ-Admin-UI-Vue/src/api/client.ts @@ -24,6 +24,7 @@ import type { LeadStatus, LeadType, MediaAsset, + PaginatedResult, SiteConfig, SiteConfigItem, SiteItemPatch, @@ -242,7 +243,13 @@ export const updateLeadStatus = (leadId: string, status: LeadStatus) => method: "PATCH", body: JSON.stringify({ status }), }); -export const getMediaAssets = () => request<{ items: MediaAsset[] }>("/api/admin/media-assets"); +export const getMediaAssets = (params: { pageNum?: number; pageSize?: number } = {}) => { + const query = new URLSearchParams({ + pageNum: String(params.pageNum ?? 1), + pageSize: String(params.pageSize ?? 20), + }); + return request>(`/api/admin/media-assets?${query.toString()}`); +}; export const getSystemResource = (resource: string) => request<{ items: unknown[] }>(`/api/admin/system/${resource}`); export const getSystemUsers = () => request<{ items: SystemUserItem[] }>("/api/admin/system/users"); export const getSystemRoles = () => request<{ items: SystemRoleItem[] }>("/api/admin/system/roles"); diff --git a/WonderQ-Admin-UI-Vue/src/pages/MediaPage.vue b/WonderQ-Admin-UI-Vue/src/pages/MediaPage.vue index feda021..edd542f 100644 --- a/WonderQ-Admin-UI-Vue/src/pages/MediaPage.vue +++ b/WonderQ-Admin-UI-Vue/src/pages/MediaPage.vue @@ -12,13 +12,18 @@ const uploading = ref(false); const error = ref(""); const uploadError = ref(""); const group = ref("general"); +const pageNum = ref(1); +const pageSize = ref(10); +const total = ref(0); const input = ref(null); async function load() { loading.value = true; error.value = ""; try { - assets.value = (await getMediaAssets()).items; + const result = await getMediaAssets({ pageNum: pageNum.value, pageSize: pageSize.value }); + assets.value = result.items; + total.value = result.total; } catch (loadError) { error.value = loadError instanceof Error ? loadError.message : "媒体素材加载失败"; } finally { @@ -42,7 +47,8 @@ async function upload(event: Event) { if (validationError) throw new Error(validationError); uploaded.push(await uploadMediaAsset(file, group.value.trim() || "general")); } - assets.value = [...uploaded, ...assets.value]; + pageNum.value = 1; + await load(); ElMessage.success(`已上传 ${uploaded.length} 个素材`); } catch (uploadFailure) { uploadError.value = uploadFailure instanceof Error ? uploadFailure.message : "素材上传失败,请重试"; @@ -51,16 +57,54 @@ async function upload(event: Event) { } } +function changePage(value: number) { + pageNum.value = value; + void load(); +} + +function changePageSize(value: number) { + pageSize.value = value; + pageNum.value = 1; + void load(); +} + onMounted(load); diff --git a/WonderQ-Admin-UI-Vue/src/styles.css b/WonderQ-Admin-UI-Vue/src/styles.css index c77bdcd..f66b987 100644 --- a/WonderQ-Admin-UI-Vue/src/styles.css +++ b/WonderQ-Admin-UI-Vue/src/styles.css @@ -403,6 +403,11 @@ input { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; } +.media-pagination { + display: flex; + justify-content: flex-end; + padding-top: 16px; +} .media-card { overflow: hidden; border: 1px solid var(--line); diff --git a/WonderQ-Admin-UI-Vue/src/types.ts b/WonderQ-Admin-UI-Vue/src/types.ts index b11c79c..fb46046 100644 --- a/WonderQ-Admin-UI-Vue/src/types.ts +++ b/WonderQ-Admin-UI-Vue/src/types.ts @@ -225,6 +225,13 @@ export type MediaAsset = { updatedAt?: string; }; +export type PaginatedResult = { + items: T[]; + total: number; + pageNum: number; + pageSize: number; +}; + export type ConciergeAdvisor = { id: string; avatar?: string; diff --git a/WonderQ-Admin/app/routers/admin.py b/WonderQ-Admin/app/routers/admin.py index b4ac2d4..fb19ab8 100644 --- a/WonderQ-Admin/app/routers/admin.py +++ b/WonderQ-Admin/app/routers/admin.py @@ -1500,9 +1500,28 @@ def update_lead_status(lead_id: str, body: LeadStatusIn, request: Request, _user @router.get("/media-assets") -def list_media_assets(_user: AdminUser = Depends(require_admin_permission("admin:media:read")), db: Session = Depends(get_db)): - assets = db.scalars(apply_data_scope(select(MediaAsset), MediaAsset, _user, db).order_by(MediaAsset.createdAt.desc()).limit(200)).all() - return success_response({"items": [media_model_dict(asset) for asset in assets]}) +def list_media_assets( + pageNum: int = Query(default=1, ge=1), + pageSize: int = Query(default=20, ge=1, le=200), + _user: AdminUser = Depends(require_admin_permission("admin:media:read")), + db: Session = Depends(get_db), +): + offset = (pageNum - 1) * pageSize + assets_stmt = ( + select(MediaAsset) + .order_by(MediaAsset.createdAt.desc()) + .offset(offset) + .limit(pageSize) + ) + assets = db.scalars(apply_data_scope(assets_stmt, MediaAsset, _user, db)).all() + total_stmt = apply_data_scope(select(func.count()).select_from(MediaAsset), MediaAsset, _user, db) + total = db.scalar(total_stmt) or 0 + return success_response({ + "items": [media_model_dict(asset) for asset in assets], + "total": total, + "pageNum": pageNum, + "pageSize": pageSize, + }) @router.post("/media-assets/upload", status_code=status.HTTP_201_CREATED) diff --git a/WonderQ-Admin/tests/test_api_contracts.py b/WonderQ-Admin/tests/test_api_contracts.py index a32b35e..2c9f073 100644 --- a/WonderQ-Admin/tests/test_api_contracts.py +++ b/WonderQ-Admin/tests/test_api_contracts.py @@ -590,6 +590,39 @@ def test_admin_media_upload_rejects_non_image_file(): assert not fake_db.committed +def test_admin_media_assets_endpoint_returns_paginated_results(): + assets = [ + MediaAsset( + id="asset-1", + url="https://cdn.example.test/asset-1.jpg", + name="asset-1.jpg", + mimeType="image/jpeg", + sizeBytes=1024, + group="general", + createdAt=datetime(2026, 1, 2), + updatedAt=datetime(2026, 1, 2), + ) + ] + fake_db = FakeDb(scalar_results=[assets], scalar_values=[3]) + app = authenticated_app(fake_db) + + try: + response = TestClient(app).get( + "/api/admin/media-assets", + params={"pageNum": 2, "pageSize": 1}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + body = response.json()["data"] + assert body["items"][0]["id"] == "asset-1" + assert body["total"] == 3 + assert body["pageNum"] == 2 + assert body["pageSize"] == 1 + assert len(fake_db.scalar_statements) == 2 + + def test_admin_leads_endpoint_accepts_source_keyword_and_created_range_filters(): fake_db = FakeDb(scalar_results=[[]]) app = authenticated_app(fake_db) diff --git a/docs/admin-api-requirements.md b/docs/admin-api-requirements.md index 2e11f68..9586268 100644 --- a/docs/admin-api-requirements.md +++ b/docs/admin-api-requirements.md @@ -75,9 +75,11 @@ | `PATCH` | `/api/admin/home/wild-archives/reorder` | 排序首页极境视界 | | `GET` | `/api/admin/leads` | 线索列表 | | `PATCH` | `/api/admin/leads/{id}/status` | 更新线索状态 | -| `GET` | `/api/admin/media-assets` | 素材列表 | +| `GET` | `/api/admin/media-assets` | 分页查询素材列表 | | `POST` | `/api/admin/media-assets/upload` | 上传图片 | +`GET /api/admin/media-assets?pageNum=1&pageSize=20` 按创建时间倒序分页返回素材,`pageSize` 最大为 `200`,响应为 `data: { items, total, pageNum, pageSize }`。管理端翻页时必须重新请求接口,不在前端截取固定数量的素材。 + 媒体上传成功响应中的 `data.url` 是可直接用于图片回显的 HTTP(S) URL。OSS 私有读场景下,该 URL 会包含短时 GET 签名;管理端应直接使用返回值,后续 Admin/Public API 响应会重新生成签名。该接口只写入素材库,不会自动绑定首页配置;绑定首页轮播或用车卡片后,仍需提交对应的站点配置保存接口。 ## 站点模块 diff --git a/docs/integration-workflow.md b/docs/integration-workflow.md index cb580e4..d91bb9a 100644 --- a/docs/integration-workflow.md +++ b/docs/integration-workflow.md @@ -84,6 +84,7 @@ yarn dev:mp-weixin - 管理端界面图标统一由 `@element-plus/icons-vue` 提供;菜单返回的图标名经 `LayoutIcon` 白名单映射,未知值显示默认图标。顾问服务详情中的 `icon` 字段仍按业务内容数据处理。 - 变更角色、菜单或用户关联后,权限缓存失效时要重新登录或重新加载菜单,确认侧栏、路由和按钮权限同步变化。 - 站点模块、玩法、详情、管家、线索和媒体接口按当前契约返回。 +- “媒体素材”页面通过 `GET /api/admin/media-assets?pageNum=1&pageSize=20` 分页加载,翻页和切换每页条数都应重新请求后端,确认列表总数与服务端一致。 - 新增、更新、删除和排序成功后,页面使用接口返回的数据更新状态,不自行生成 ID 或排序结果。 MiniAPP 重点检查: