From eebf3b3334d9c762014f203a5ea4d3e39e450070 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 22 Sep 2026 18:10:53 +0800 Subject: [PATCH] feat(admin): standard pagination + synced profile columns on users list Co-Authored-By: Claude Code --- apps/admin/e2e/embed-slots.spec.ts | 68 +++++++++- apps/admin/src/locales/langs/en.json | 18 ++- apps/admin/src/locales/langs/zh.json | 18 ++- apps/admin/src/types/api/adminUser.d.ts | 17 ++- apps/admin/src/types/import/components.d.ts | 1 + .../admin/src/utils/system/embedSlots.spec.ts | 44 ++++-- apps/admin/src/utils/system/embedSlots.ts | 14 +- .../system/embed-slots/EmbedSlotPreview.vue | 126 +++++++++++++++++- .../system/embed-slots/SlotFormDialog.vue | 56 +++++++- .../composables/useAdminUserPages.spec.ts | 53 ++++++++ .../users/composables/useAdminUserPages.ts | 47 +++++++ apps/admin/src/views/system/users/index.vue | 92 ++++++++----- 12 files changed, 495 insertions(+), 59 deletions(-) create mode 100644 apps/admin/src/views/system/users/composables/useAdminUserPages.spec.ts create mode 100644 apps/admin/src/views/system/users/composables/useAdminUserPages.ts diff --git a/apps/admin/e2e/embed-slots.spec.ts b/apps/admin/e2e/embed-slots.spec.ts index 1297091..0d1d399 100644 --- a/apps/admin/e2e/embed-slots.spec.ts +++ b/apps/admin/e2e/embed-slots.spec.ts @@ -12,8 +12,10 @@ test.use({ testIdAttribute: 'data-test' }) const SUFFIX = Date.now().toString(36) + Math.random().toString(36).slice(2, 6) const SLOT_NAME = `E2E展示位 ${SUFFIX}` +const V2_SLOT_NAME = `E2E展示位v2 ${SUFFIX}` const ALBUM_A_TITLE = `E2E嵌入相册A ${SUFFIX}` const ALBUM_B_TITLE = `E2E嵌入相册B ${SUFFIX}` +const ALBUM_C_TITLE = `E2E嵌入相册C ${SUFFIX}` interface SeededAlbum { id: string @@ -55,10 +57,11 @@ function slotRow(page: Page, name: string) { return page.getByTestId('embed-slots-table').locator('tr', { hasText: name }) } -/** ov 公开嵌入 feed 形状(断言关心的子集) */ +/** ov 公开嵌入 feed 形状(断言关心的子集;settings 为 UI-04 v2 投影断言) */ interface FeedBody { album: { publicToken: string; title: string } photos: Array<{ photoId: string }> + settings: { displayMode: string; scrollSpeed: number; reactionsEnabled: boolean } } /** ov 公开嵌入 feed(观众侧权威断言;断言侧直接读 ov 副本端点) */ @@ -82,6 +85,8 @@ test.describe.serial('embed slot management (P4b Task 5)', () => { await page.getByTestId('slot-create-btn').click() await expect(page.getByTestId('slot-form-dialog')).toBeVisible() await page.getByTestId('slot-form-name').fill(SLOT_NAME) + // UI-04:新建默认「照片上滚」(v2)——本用例回归旧版轮播路径,先显式切换 + await page.getByTestId('slot-form-mode-carousel').click() // 设置表单:位置/间隔(其余取缺省值;编辑载荷映射由 vitest 覆盖) await page.getByTestId('slot-form-position').click() await page.getByTestId('slot-form-position-left').click() @@ -198,4 +203,65 @@ test.describe.serial('embed slot management (P4b Task 5)', () => { seedFixture('sync-flush') expect((await ovFeed(page, slotToken)).status).toBe(404) }) + + test('UI-04 v2 新建:照片上滚表单/真实组件预览/模拟按钮 → v2 代码卡 → ov settings 投影', async ({ + page + }) => { + const albumC = await seedOvVisibleAlbum(page, ALBUM_C_TITLE, 2) + await loginAsAdmin(page) + await page.goto('/#/system/embed-slots') + await expect(page.getByTestId('embed-slots-table')).toBeVisible() + + await page.getByTestId('slot-create-btn').click() + await expect(page.getByTestId('slot-form-dialog')).toBeVisible() + await page.getByTestId('slot-form-name').fill(V2_SLOT_NAME) + + // 默认即「照片上滚」:v2 字段组可见、v1 布局字段隐藏(§7.2 不偷偷生效)。 + await expect(page.getByTestId('slot-form-mode-stream')).toBeVisible() + await expect(page.getByTestId('slot-form-fixed-size')).toContainText('195') + await expect(page.getByTestId('slot-form-speed')).toBeVisible() + await expect(page.getByTestId('slot-form-reactions')).toBeVisible() + await expect(page.getByTestId('slot-form-interval')).toHaveCount(0) + await expect(page.getByTestId('slot-form-size')).toHaveCount(0) + + // 真实组件预览(§8.2):iframe 挂载 + 模拟点赞按钮在工具区。 + await expect(page.getByTestId('embed-preview-v2-iframe')).toBeVisible() + await expect(page.getByTestId('embed-preview-sim-like')).toBeVisible() + + // 速度调整(24 px/s):滑轨点击定位((24-8)/(32-8)*180px ≈ 120)后断言值。 + await page.getByTestId('slot-form-speed').click({ position: { x: 120, y: 10 } }) + await expect(page.getByTestId('slot-form-speed-value')).toContainText('24') + await page.getByTestId('slot-form-submit').click() + await expect(page.getByTestId('slot-form-dialog')).toBeHidden() + + const row = slotRow(page, V2_SLOT_NAME) + await expect(row).toBeVisible() + + // 嵌入代码卡:v2 入口(§8.2 生成的接入代码使用 v2)。 + await row.getByTestId('slot-code-btn').click() + await expect(page.getByTestId('embed-code-dialog')).toBeVisible() + const v2Token = ((await page.getByTestId('embed-code-token').textContent()) ?? '').trim() + expect(v2Token).toMatch(/^[0-9a-z]{20,32}$/) + await expect(page.getByTestId('embed-code-snippet')).toContainText( + `http://127.0.0.1:5175/embed/v2/loader.js` + ) + await expect(page.getByTestId('embed-code-snippet')).toContainText(`data-slot="${v2Token}"`) + await page.getByTestId('embed-code-close').click() + + // 绑定相册 + 排空 → ov 公开 feed settings 携带 v2 投影(保存与真实效果一致)。 + await row.getByTestId('slot-bind-btn').click() + await page.getByTestId('slot-bind-select').click() + await page.getByTestId('slot-bind-select').locator('input').fill(SUFFIX) + await page.getByTestId(`slot-bind-option-${albumC.id}`).click() + await page.getByTestId('slot-bind-submit').click() + await page.locator('.el-message-box').getByRole('button', { name: '确定' }).click() + await expect(page.getByTestId('slot-bind-dialog')).toBeHidden() + seedFixture('sync-flush') + + const feed = await ovFeed(page, v2Token) + expect(feed.status).toBe(200) + expect(feed.body.settings.displayMode).toBe('vertical-stream') + expect(feed.body.settings.scrollSpeed).toBe(24) + expect(feed.body.settings.reactionsEnabled).toBe(true) + }) }) diff --git a/apps/admin/src/locales/langs/en.json b/apps/admin/src/locales/langs/en.json index dea937b..93fc847 100644 --- a/apps/admin/src/locales/langs/en.json +++ b/apps/admin/src/locales/langs/en.json @@ -566,6 +566,9 @@ "selfTag": "Me", "colAccount": "Account", "colDisplayName": "Display Name", + "colSn": "Staff No.", + "colEmail": "Email", + "colCompany": "Company", "colRole": "Role", "colSourceStatus": "Source", "colCredentialStatus": "Credential", @@ -748,7 +751,16 @@ "zIndexLabel": "Layer (z-index)", "pxUnitOffsetX": "px (0-120)", "pxUnitOffsetBottom": "px (0-240)", - "dirtyTag": "Unsaved" + "dirtyTag": "Unsaved", + "modeLabel": "Display mode", + "modeStream": "Vertical stream (new)", + "modeCarousel": "Legacy carousel", + "fixedSizeLabel": "Window size", + "fixedSizeValue": "195 × 280 (fixed)", + "speedLabel": "Scroll speed", + "speedUnit": "px/s (8-32)", + "reactionsLabel": "Like bubbles", + "reactionsHint": "Show real like heart animations (off hides bubbles only)" }, "create": { "title": "New Embed Slot", @@ -800,7 +812,9 @@ "collapseLabel": "Collapse preview", "viewAlbum": "View album", "photoLabel": "Sample photo {n}", - "mockAlbumTitle": "Event Album" + "mockAlbumTitle": "Event Album", + "simLike": "Simulate like event", + "sampleDataHint": "Sample data · simulated events" }, "toast": { "enabledOn": "Enabled (configVersion increased to {version})", diff --git a/apps/admin/src/locales/langs/zh.json b/apps/admin/src/locales/langs/zh.json index db4689e..68ca058 100644 --- a/apps/admin/src/locales/langs/zh.json +++ b/apps/admin/src/locales/langs/zh.json @@ -566,6 +566,9 @@ "selfTag": "我", "colAccount": "账号", "colDisplayName": "展示姓名", + "colSn": "工号", + "colEmail": "邮箱", + "colCompany": "公司", "colRole": "角色", "colSourceStatus": "来源状态", "colCredentialStatus": "凭据状态", @@ -748,7 +751,16 @@ "zIndexLabel": "层级", "pxUnitOffsetX": "像素(0-120)", "pxUnitOffsetBottom": "像素(0-240)", - "dirtyTag": "未保存" + "dirtyTag": "未保存", + "modeLabel": "展示模式", + "modeStream": "照片上滚(新版)", + "modeCarousel": "旧版轮播", + "fixedSizeLabel": "窗口尺寸", + "fixedSizeValue": "195 × 280(固定)", + "speedLabel": "上滚速度", + "speedUnit": "像素/秒(8-32)", + "reactionsLabel": "点赞气泡", + "reactionsHint": "展示真实点赞心形动画(关闭仅隐藏气泡)" }, "create": { "title": "新建展示位", @@ -800,7 +812,9 @@ "collapseLabel": "收起预览", "viewAlbum": "查看相册", "photoLabel": "示例照片 {n}", - "mockAlbumTitle": "活动相册" + "mockAlbumTitle": "活动相册", + "simLike": "模拟点赞事件", + "sampleDataHint": "示例数据 · 事件为测试模拟" }, "toast": { "enabledOn": "已启用(configVersion 已递增至 {version})", diff --git a/apps/admin/src/types/api/adminUser.d.ts b/apps/admin/src/types/api/adminUser.d.ts index 85cd4a1..5d9c5e9 100644 --- a/apps/admin/src/types/api/adminUser.d.ts +++ b/apps/admin/src/types/api/adminUser.d.ts @@ -33,6 +33,15 @@ declare namespace Api { */ type StatusFilter = 'active' | 'pending' | 'disabled' | 'locked' + /** 同步源档案快照(G4:工号/邮箱/公司/头像——纯展示,本地用户为 null) */ + interface SyncedProfile { + sn?: string + email?: string + companyName?: string + companyAbbr?: string + avatar?: string + } + /** 用户治理条目(不含邮箱/手机/密码等凭据字段) */ interface Item { id: string @@ -44,13 +53,15 @@ declare namespace Api { credentialStatus: CredentialStatus locale: string | null createdAt: string + synced?: SyncedProfile | null } - /** 管理列表响应(游标分页) */ + /** 管理列表响应(page 模式带 total;游标模式 total=null) */ interface ListResponse { items: Item[] nextCursor: string | null hasMore: boolean + total?: number | null } /** 管理列表查询参数(双模式分流:不带 accountPrefix 即管理列表模式) */ @@ -61,6 +72,10 @@ declare namespace Api { status?: StatusFilter cursor?: string limit?: number + /** 页码(提供即 page 模式:响应带 total,翻页由 total 驱动) */ + page?: number + /** page 模式每页条数(1..100,缺省 20) */ + pageSize?: number } /** 治理属性部分更新(仅提交字段被修改;sourceStatus 不在可更新集) */ diff --git a/apps/admin/src/types/import/components.d.ts b/apps/admin/src/types/import/components.d.ts index 7b7469f..b7efbb1 100644 --- a/apps/admin/src/types/import/components.d.ts +++ b/apps/admin/src/types/import/components.d.ts @@ -79,6 +79,7 @@ declare module 'vue' { ElMenu: typeof import('element-plus/es')['ElMenu'] ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] + ElPagination: typeof import('element-plus/es')['ElPagination'] ElPopover: typeof import('element-plus/es')['ElPopover'] ElProgress: typeof import('element-plus/es')['ElProgress'] ElRadio: typeof import('element-plus/es')['ElRadio'] diff --git a/apps/admin/src/utils/system/embedSlots.spec.ts b/apps/admin/src/utils/system/embedSlots.spec.ts index 0e03bf1..b397042 100644 --- a/apps/admin/src/utils/system/embedSlots.spec.ts +++ b/apps/admin/src/utils/system/embedSlots.spec.ts @@ -264,8 +264,11 @@ describe('嵌入代码 v2 入口(UI-04:displayMode=vertical-stream → /embe }) describe('buildPreviewPlacement 本地预览停靠几何(草稿即时反映,纯本地)', () => { - it('desktop:按配置展开(collapsed=collapsedDefault),几何随草稿字段变化', () => { - const placement = buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'desktop') + it('desktop:按配置展开(collapsed=collapsedDefault),几何随草稿字段变化(carousel)', () => { + const placement = buildPreviewPlacement( + { ...DEFAULT_EMBED_SETTINGS, displayMode: 'carousel' }, + 'desktop' + ) expect(placement).toEqual({ side: 'right', size: 'md', @@ -278,7 +281,14 @@ describe('buildPreviewPlacement 本地预览停靠几何(草稿即时反映, }) // 表单字段变化 → 预览几何即时跟随(未保存修改即时反映的纯逻辑基础) const changed = buildPreviewPlacement( - { ...DEFAULT_EMBED_SETTINGS, position: 'left', size: 'sm', offsetX: 40, zIndex: 2000 }, + { + ...DEFAULT_EMBED_SETTINGS, + displayMode: 'carousel', + position: 'left', + size: 'sm', + offsetX: 40, + zIndex: 2000 + }, 'desktop' ) expect(changed.side).toBe('left') @@ -294,24 +304,30 @@ describe('buildPreviewPlacement 本地预览停靠几何(草稿即时反映, }) // collapsedDefault 仅作用于桌面预览初始形态 expect( - buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS, collapsedDefault: true }, 'desktop') - .collapsed + buildPreviewPlacement( + { ...DEFAULT_EMBED_SETTINGS, displayMode: 'carousel', collapsedDefault: true }, + 'desktop' + ).collapsed ).toBe(true) }) - it('mobile:窄屏强制 sm + 默认收起(§5.4 <480px);宽度按剩余宽收缩防溢出', () => { - const placement = buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'mobile') + it('v2(vertical-stream):恒 195 宽(无 sm 降档),mobile 仍默认收起(§2.1)', () => { + expect(buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'desktop').width).toBe(195) + expect(buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'mobile').width).toBe(195) + expect(buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'mobile').size).toBe('md') + expect(buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'mobile').collapsed).toBe(true) + expect(buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'desktop').collapsed).toBe(false) + }) + + it('mobile:窄屏强制 sm + 默认收起(§5.4 <480px);宽度按剩余宽收缩防溢出(carousel)', () => { + const v1 = { ...DEFAULT_EMBED_SETTINGS, displayMode: 'carousel' } + const placement = buildPreviewPlacement(v1, 'mobile') expect(placement.size).toBe('sm') expect(placement.width).toBe(280) expect(placement.collapsed).toBe(true) // 窄屏强制收起(与 collapsedDefault 无关) - expect( - buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS, collapsedDefault: false }, 'mobile') - .collapsed - ).toBe(true) + expect(buildPreviewPlacement({ ...v1, collapsedDefault: false }, 'mobile').collapsed).toBe(true) // 偏移挤占剩余宽 → 宽度取小(375 - 120 - 8 = 247) - expect(buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS, offsetX: 120 }, 'mobile').width).toBe( - 247 - ) + expect(buildPreviewPlacement({ ...v1, offsetX: 120 }, 'mobile').width).toBe(247) }) }) diff --git a/apps/admin/src/utils/system/embedSlots.ts b/apps/admin/src/utils/system/embedSlots.ts index e2ccd88..a6c2e36 100644 --- a/apps/admin/src/utils/system/embedSlots.ts +++ b/apps/admin/src/utils/system/embedSlots.ts @@ -39,6 +39,10 @@ export const EMBED_Z_INDEX_MAX = 2147483000 export const EMBED_SCROLL_SPEED_MIN = 8 export const EMBED_SCROLL_SPEED_MAX = 32 +/** v2 固定窗尺寸(CSS px,§2.1 硬性:PC/手机同值;对齐 loader V2_WIDTH/HEIGHT)。 */ +export const EMBED_V2_WIDTH = 195 +export const EMBED_V2_HEIGHT = 280 + /** size → 悬浮组件宽度 px(sm 窄屏降级档;与 loader SIZE_WIDTH 对齐) */ export const EMBED_SIZE_WIDTH: Record = { sm: 280, md: 320 } @@ -253,22 +257,24 @@ export const EMBED_PREVIEW_FRAME_WIDTH: Record<'desktop' | 'mobile', number> = { mobile: 375 } -/** 表单草稿 → 预览停靠几何(desktop=按配置;mobile=窄屏强制 sm+收起)。 +/** 表单草稿 → 预览停靠几何(desktop=按配置;mobile=窄屏强制收起)。 * 数值全部经契约夹取——表单输入的中间态(空/越界)预览不抖动;宽度按 - * loader applyWrapperBox 同规则取配置宽与视口剩余宽的较小值。 */ + * loader applyWrapperBox 同规则取配置宽与视口剩余宽的较小值。 + * UI-04:vertical-stream 恒 195 宽(§2.1 硬性,无 sm 降档——mobile 仅默认收起)。 */ export function buildPreviewPlacement( draft: EmbedSettingsDraft, mode: 'desktop' | 'mobile', frameWidth: number = EMBED_PREVIEW_FRAME_WIDTH[mode] ): EmbedPreviewPlacement { const narrow = mode === 'mobile' - const size: Api.EmbedSlot.Size = narrow ? 'sm' : draft.size + const isV2 = draft.displayMode === 'vertical-stream' + const size: Api.EmbedSlot.Size = narrow && !isV2 ? 'sm' : draft.size const offsetX = clampOffsetX(draft.offsetX) const available = Math.max(frameWidth - offsetX - 8, EMBED_PREVIEW_MIN_WIDTH) return { side: draft.position, size, - width: Math.min(EMBED_SIZE_WIDTH[size], available), + width: Math.min(isV2 ? EMBED_V2_WIDTH : EMBED_SIZE_WIDTH[size], available), offsetX, offsetBottom: clampOffsetBottom(draft.offsetBottom), zIndex: clampZIndex(draft.zIndex), diff --git a/apps/admin/src/views/system/embed-slots/EmbedSlotPreview.vue b/apps/admin/src/views/system/embed-slots/EmbedSlotPreview.vue index e382de8..dfaef38 100644 --- a/apps/admin/src/views/system/embed-slots/EmbedSlotPreview.vue +++ b/apps/admin/src/views/system/embed-slots/EmbedSlotPreview.vue @@ -54,12 +54,41 @@
+ +
+ +
+ + {{ $t('embedSlotsManage.preview.handleText') }} +
+
{{ $t('embedSlotsManage.preview.collapsedHint') }} + + + + {{ $t('embedSlotsManage.preview.sampleDataHint') }} +
@@ -158,6 +200,8 @@ import { buildPreviewPlacement, buildPreviewWidgetStyle, + clampScrollSpeed, + embedOriginFromEnv, type EmbedSettingsDraft } from '@/utils/system/embedSlots' @@ -180,6 +224,43 @@ const placement = computed(() => buildPreviewPlacement(props.draft, props.mode)) const widgetStyle = computed(() => buildPreviewWidgetStyle(placement.value)) + // ── UI-04 v2 真实组件预览(§8.2)── + const isV2 = computed(() => props.draft.displayMode === 'vertical-stream') + const previewFrame = ref(null) + const embedOrigin = embedOriginFromEnv(import.meta.env) + + /** 预览 iframe src:初始配置经查询(速度/气泡/语言);后续更新走 + * postMessage(不整页重载,避免滚动位置闪烁)。 */ + const previewSrc = computed(() => { + const params = new URLSearchParams({ + speed: String(clampScrollSpeed(props.draft.scrollSpeed)), + lang: props.draft.lang === 'auto' ? 'zh-CN' : props.draft.lang + }) + if (!props.draft.reactionsEnabled) params.set('reactions', 'off') + return `${embedOrigin}/embed-preview?${params.toString()}` + }) + + function postPreview(payload: Record): void { + previewFrame.value?.contentWindow?.postMessage( + { photolivePreview: true, ...payload }, + embedOrigin + ) + } + + /** 草稿速度/气泡变化 → 实时推送(不发请求不写展示位,纯预览内生效)。 */ + watch( + () => [props.draft.scrollSpeed, props.draft.reactionsEnabled] as const, + ([speed, reactions]) => { + if (!isV2.value) return + postPreview({ type: 'config', speed: clampScrollSpeed(speed), reactions }) + } + ) + + /** 模拟点赞(§8.2 工具区按钮;事件为测试模拟,注入真实校验管线)。 */ + function simulateLike(): void { + postPreview({ type: 'burst', photoIndex: -1 }) + } + /** 把手/关闭仅切换预览内展开态(用户选择优先于默认值;不持久化) */ const expanded = ref(false) const isCollapsed = computed(() => placement.value.collapsed && !expanded.value) @@ -353,6 +434,47 @@ box-shadow: 0 4px 16px rgb(0 0 0 / 0.18); overflow: hidden; } + /* v2 壳(对齐 loader v2 wrapper:1px #fffc 边、#10161e 底、高 280)。 */ + .embed-preview-dock.is-v2 { + border: 1px solid #fffc; + background: #10161e; + box-shadow: 0 4px 16px #0f172a29; + height: 280px; + } + .embed-preview-v2frame { + width: 100%; + height: 100%; + } + .embed-preview-v2frame iframe { + width: 100%; + height: 100%; + border: 0; + display: block; + } + /* v2 收起把手(对齐 loader v2:白底 100×44、蓝图标)。 */ + .embed-handle--v2 { + padding: 0; + width: 100px; + gap: 8px; + border: 1px solid #e3eaf2; + background: #ffffff; + box-shadow: 0 4px 16px #0f172a20; + color: #32618e; + font-size: 12px; + } + .embed-preview-sim { + padding: 2px 10px; + border: 1px solid #d5dfed; + border-radius: 6px; + background: #fff; + color: #285586; + font-size: 11px; + line-height: 20px; + cursor: pointer; + } + .embed-preview-sim:hover { + background: #f0f6ff; + } .embed-root { background: #fff; font-family: diff --git a/apps/admin/src/views/system/embed-slots/SlotFormDialog.vue b/apps/admin/src/views/system/embed-slots/SlotFormDialog.vue index d714075..02167f0 100644 --- a/apps/admin/src/views/system/embed-slots/SlotFormDialog.vue +++ b/apps/admin/src/views/system/embed-slots/SlotFormDialog.vue @@ -31,6 +31,45 @@ data-test="slot-form-name" /> + + + + {{ $t('embedSlotsManage.form.modeStream') }} + + + {{ $t('embedSlotsManage.form.modeCarousel') }} + + + + + + + {{ $t('embedSlotsManage.form.fixedSizeValue') }} + + + +
+ + + {{ draft.scrollSpeed }} {{ $t('embedSlotsManage.form.speedUnit') }} + +
+
+ +
+ + {{ + $t('embedSlotsManage.form.reactionsHint') + }} +
+
- + + - +
- + {{ $t('embedSlotsManage.form.imageFitContain') }} @@ -169,6 +210,8 @@ EMBED_OFFSET_BOTTOM_MIN, EMBED_OFFSET_X_MAX, EMBED_OFFSET_X_MIN, + EMBED_SCROLL_SPEED_MAX, + EMBED_SCROLL_SPEED_MIN, EMBED_Z_INDEX_MAX, EMBED_Z_INDEX_MIN, buildSlotEditPayload, @@ -200,6 +243,9 @@ // 脏态:编辑态与已保存条目比对 / 新建态与缺省比对 →「未保存」标记 const dirty = computed(() => isSlotDraftDirty(props.embedSlot, draftName.value, draft)) + /** UI-04 v2(vertical-stream)模式:字段组切换(§8.2)。 */ + const isV2 = computed(() => draft.displayMode === 'vertical-stream') + // 预览相册标题:编辑已绑定相册用真实标题,否则词条占位(纯展示) const previewAlbumTitle = computed( () => props.embedSlot?.albumTitle ?? t('embedSlotsManage.preview.mockAlbumTitle') @@ -274,6 +320,10 @@ flex: 1; min-width: 0; } + .slot-form-fixed-size { + font-variant-numeric: tabular-nums; + color: var(--el-text-color-regular); + } .slot-form-dialog__preview { flex: none; width: 470px; diff --git a/apps/admin/src/views/system/users/composables/useAdminUserPages.spec.ts b/apps/admin/src/views/system/users/composables/useAdminUserPages.spec.ts new file mode 100644 index 0000000..4aa7a88 --- /dev/null +++ b/apps/admin/src/views/system/users/composables/useAdminUserPages.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest' +import { useAdminUserPages } from './useAdminUserPages' + +const pageOf = (accounts: string[], total: number) => ({ + items: accounts.map((account, i) => ({ id: `u${i}`, account })) as never, + nextCursor: null, + hasMore: false, + total +}) + +describe('useAdminUserPages 分页状态', () => { + it('loadPage 推进页码并落 items/total(透传 page/pageSize 给 fetch)', async () => { + const fetch = vi.fn().mockResolvedValue(pageOf(['a', 'b'], 35)) + const state = useAdminUserPages(fetch) + await state.loadPage(2) + expect(fetch).toHaveBeenCalledWith(2, 20) + expect(state.page.value).toBe(2) + expect(state.items.value).toHaveLength(2) + expect(state.total.value).toBe(35) + }) + + it('total 缺省(旧响应形态)回退 items 长度,不抛错', async () => { + const fetch = vi.fn().mockResolvedValue({ + items: [], + nextCursor: null, + hasMore: false + }) + const state = useAdminUserPages(fetch) + await state.loadPage() + expect(state.total.value).toBe(0) + }) + + it('fetch 抛错 → error 捕获、loading 复位、页码不推进', async () => { + const fetch = vi.fn().mockRejectedValue(new Error('boom')) + const state = useAdminUserPages(fetch) + await state.loadPage(3) + expect(state.error.value?.message).toBe('boom') + expect(state.loading.value).toBe(false) + expect(state.page.value).toBe(1) + }) + + it('onSizeChange 回第 1 页;replaceItem 原地替换当前页条目', async () => { + const fetch = vi.fn().mockResolvedValue(pageOf(['a'], 5)) + const state = useAdminUserPages(fetch) + await state.loadPage(2) + state.onSizeChange() + await Promise.resolve() + await Promise.resolve() + expect(fetch).toHaveBeenLastCalledWith(1, 20) + state.replaceItem(0, { id: 'u0', account: 'a2' } as never) + expect(state.items.value[0]?.account).toBe('a2') + }) +}) diff --git a/apps/admin/src/views/system/users/composables/useAdminUserPages.ts b/apps/admin/src/views/system/users/composables/useAdminUserPages.ts new file mode 100644 index 0000000..4bb326b --- /dev/null +++ b/apps/admin/src/views/system/users/composables/useAdminUserPages.ts @@ -0,0 +1,47 @@ +/** + * 用户管理列表分页状态(G4:page 模式偏移分页——page/pageSize/total 驱动 + * ElPagination;替代旧 useCursorList 累积加载)。fetch 经参数注入便于单测; + * 过滤条件由调用方闭包携带,过滤变化的页码重置在 search/resetFilters 处理。 + */ +import { ref } from 'vue' + +export function useAdminUserPages( + fetchPage: ( + page: number, + pageSize: number, + ) => Promise, +) { + const items = ref([]) + const total = ref(0) + const page = ref(1) + const pageSize = ref(20) + const loading = ref(false) + const error = ref(null) + + async function loadPage(target: number = page.value): Promise { + loading.value = true + error.value = null + try { + const res = await fetchPage(target, pageSize.value) + page.value = target + items.value = res.items + total.value = res.total ?? res.items.length + } catch (err) { + error.value = err instanceof Error ? err : new Error(String(err)) + } finally { + loading.value = false + } + } + + /** 每页条数变化:回第 1 页重载。 */ + function onSizeChange(): void { + void loadPage(1) + } + + /** 行内回显(治理操作/编辑保存后原地替换当前页条目)。 */ + function replaceItem(index: number, updated: Api.AdminUser.Item): void { + items.value[index] = updated + } + + return { items, total, page, pageSize, loading, error, loadPage, onSizeChange, replaceItem } +} diff --git a/apps/admin/src/views/system/users/index.vue b/apps/admin/src/views/system/users/index.vue index 202bc52..9ccdc82 100644 --- a/apps/admin/src/views/system/users/index.vue +++ b/apps/admin/src/views/system/users/index.vue @@ -1,6 +1,7 @@ - + - + + + + + + + + + + + + -
- - {{ $t('usersManage.list.loadMore') }} - - {{ $t('usersManage.list.noMore') }} +
+
@@ -176,14 +210,12 @@ fetchUpdateAdminUser, fetchAdminUsers } from '@/api/adminUsers' - import { useCursorList } from '@/views/albums/composables/useCursorList' + import { useAdminUserPages } from './composables/useAdminUserPages' import { formatDateTime } from '@/utils/albums/format' import { useUserStore } from '@/store/modules/user' defineOptions({ name: 'SystemUsers' }) - const PAGE_SIZE = 20 - const { t } = useI18n() const userStore = useUserStore() @@ -198,26 +230,26 @@ { value: 'locked', label: t('usersManage.bucket.locked') } ]) - const { items, hasMore, loading, error, loadFirst, loadMore, replaceItem } = - useCursorList( - (cursor?: string) => - fetchAdminUsers({ - query: filters.query.trim() || undefined, - status: (filters.status || undefined) as Api.AdminUser.StatusFilter | undefined, - cursor, - limit: PAGE_SIZE - }), - (item) => item.id + const { items, total, page, pageSize, loading, error, loadPage, onSizeChange, replaceItem } = + useAdminUserPages((targetPage, size) => + fetchAdminUsers({ + query: filters.query.trim() || undefined, + status: (filters.status || undefined) as Api.AdminUser.StatusFilter | undefined, + page: targetPage, + pageSize: size + }) ) function search(): void { - loadFirst() + page.value = 1 + void loadPage() } function resetFilters(): void { filters.query = '' filters.status = '' - loadFirst() + page.value = 1 + void loadPage() } /** 自身行判定:本地会话只存账号(无 /users/me 端点),按 account 对齐 */ @@ -319,6 +351,6 @@ } onMounted(() => { - loadFirst() + void loadPage() })