feat(admin): standard pagination + synced profile columns on users list
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -12,8 +12,10 @@ test.use({ testIdAttribute: 'data-test' })
|
|||||||
|
|
||||||
const SUFFIX = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
const SUFFIX = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
||||||
const SLOT_NAME = `E2E展示位 ${SUFFIX}`
|
const SLOT_NAME = `E2E展示位 ${SUFFIX}`
|
||||||
|
const V2_SLOT_NAME = `E2E展示位v2 ${SUFFIX}`
|
||||||
const ALBUM_A_TITLE = `E2E嵌入相册A ${SUFFIX}`
|
const ALBUM_A_TITLE = `E2E嵌入相册A ${SUFFIX}`
|
||||||
const ALBUM_B_TITLE = `E2E嵌入相册B ${SUFFIX}`
|
const ALBUM_B_TITLE = `E2E嵌入相册B ${SUFFIX}`
|
||||||
|
const ALBUM_C_TITLE = `E2E嵌入相册C ${SUFFIX}`
|
||||||
|
|
||||||
interface SeededAlbum {
|
interface SeededAlbum {
|
||||||
id: string
|
id: string
|
||||||
@@ -55,10 +57,11 @@ function slotRow(page: Page, name: string) {
|
|||||||
return page.getByTestId('embed-slots-table').locator('tr', { hasText: name })
|
return page.getByTestId('embed-slots-table').locator('tr', { hasText: name })
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ov 公开嵌入 feed 形状(断言关心的子集) */
|
/** ov 公开嵌入 feed 形状(断言关心的子集;settings 为 UI-04 v2 投影断言) */
|
||||||
interface FeedBody {
|
interface FeedBody {
|
||||||
album: { publicToken: string; title: string }
|
album: { publicToken: string; title: string }
|
||||||
photos: Array<{ photoId: string }>
|
photos: Array<{ photoId: string }>
|
||||||
|
settings: { displayMode: string; scrollSpeed: number; reactionsEnabled: boolean }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ov 公开嵌入 feed(观众侧权威断言;断言侧直接读 ov 副本端点) */
|
/** ov 公开嵌入 feed(观众侧权威断言;断言侧直接读 ov 副本端点) */
|
||||||
@@ -82,6 +85,8 @@ test.describe.serial('embed slot management (P4b Task 5)', () => {
|
|||||||
await page.getByTestId('slot-create-btn').click()
|
await page.getByTestId('slot-create-btn').click()
|
||||||
await expect(page.getByTestId('slot-form-dialog')).toBeVisible()
|
await expect(page.getByTestId('slot-form-dialog')).toBeVisible()
|
||||||
await page.getByTestId('slot-form-name').fill(SLOT_NAME)
|
await page.getByTestId('slot-form-name').fill(SLOT_NAME)
|
||||||
|
// UI-04:新建默认「照片上滚」(v2)——本用例回归旧版轮播路径,先显式切换
|
||||||
|
await page.getByTestId('slot-form-mode-carousel').click()
|
||||||
// 设置表单:位置/间隔(其余取缺省值;编辑载荷映射由 vitest 覆盖)
|
// 设置表单:位置/间隔(其余取缺省值;编辑载荷映射由 vitest 覆盖)
|
||||||
await page.getByTestId('slot-form-position').click()
|
await page.getByTestId('slot-form-position').click()
|
||||||
await page.getByTestId('slot-form-position-left').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')
|
seedFixture('sync-flush')
|
||||||
expect((await ovFeed(page, slotToken)).status).toBe(404)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -566,6 +566,9 @@
|
|||||||
"selfTag": "Me",
|
"selfTag": "Me",
|
||||||
"colAccount": "Account",
|
"colAccount": "Account",
|
||||||
"colDisplayName": "Display Name",
|
"colDisplayName": "Display Name",
|
||||||
|
"colSn": "Staff No.",
|
||||||
|
"colEmail": "Email",
|
||||||
|
"colCompany": "Company",
|
||||||
"colRole": "Role",
|
"colRole": "Role",
|
||||||
"colSourceStatus": "Source",
|
"colSourceStatus": "Source",
|
||||||
"colCredentialStatus": "Credential",
|
"colCredentialStatus": "Credential",
|
||||||
@@ -748,7 +751,16 @@
|
|||||||
"zIndexLabel": "Layer (z-index)",
|
"zIndexLabel": "Layer (z-index)",
|
||||||
"pxUnitOffsetX": "px (0-120)",
|
"pxUnitOffsetX": "px (0-120)",
|
||||||
"pxUnitOffsetBottom": "px (0-240)",
|
"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": {
|
"create": {
|
||||||
"title": "New Embed Slot",
|
"title": "New Embed Slot",
|
||||||
@@ -800,7 +812,9 @@
|
|||||||
"collapseLabel": "Collapse preview",
|
"collapseLabel": "Collapse preview",
|
||||||
"viewAlbum": "View album",
|
"viewAlbum": "View album",
|
||||||
"photoLabel": "Sample photo {n}",
|
"photoLabel": "Sample photo {n}",
|
||||||
"mockAlbumTitle": "Event Album"
|
"mockAlbumTitle": "Event Album",
|
||||||
|
"simLike": "Simulate like event",
|
||||||
|
"sampleDataHint": "Sample data · simulated events"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"enabledOn": "Enabled (configVersion increased to {version})",
|
"enabledOn": "Enabled (configVersion increased to {version})",
|
||||||
|
|||||||
@@ -566,6 +566,9 @@
|
|||||||
"selfTag": "我",
|
"selfTag": "我",
|
||||||
"colAccount": "账号",
|
"colAccount": "账号",
|
||||||
"colDisplayName": "展示姓名",
|
"colDisplayName": "展示姓名",
|
||||||
|
"colSn": "工号",
|
||||||
|
"colEmail": "邮箱",
|
||||||
|
"colCompany": "公司",
|
||||||
"colRole": "角色",
|
"colRole": "角色",
|
||||||
"colSourceStatus": "来源状态",
|
"colSourceStatus": "来源状态",
|
||||||
"colCredentialStatus": "凭据状态",
|
"colCredentialStatus": "凭据状态",
|
||||||
@@ -748,7 +751,16 @@
|
|||||||
"zIndexLabel": "层级",
|
"zIndexLabel": "层级",
|
||||||
"pxUnitOffsetX": "像素(0-120)",
|
"pxUnitOffsetX": "像素(0-120)",
|
||||||
"pxUnitOffsetBottom": "像素(0-240)",
|
"pxUnitOffsetBottom": "像素(0-240)",
|
||||||
"dirtyTag": "未保存"
|
"dirtyTag": "未保存",
|
||||||
|
"modeLabel": "展示模式",
|
||||||
|
"modeStream": "照片上滚(新版)",
|
||||||
|
"modeCarousel": "旧版轮播",
|
||||||
|
"fixedSizeLabel": "窗口尺寸",
|
||||||
|
"fixedSizeValue": "195 × 280(固定)",
|
||||||
|
"speedLabel": "上滚速度",
|
||||||
|
"speedUnit": "像素/秒(8-32)",
|
||||||
|
"reactionsLabel": "点赞气泡",
|
||||||
|
"reactionsHint": "展示真实点赞心形动画(关闭仅隐藏气泡)"
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
"title": "新建展示位",
|
"title": "新建展示位",
|
||||||
@@ -800,7 +812,9 @@
|
|||||||
"collapseLabel": "收起预览",
|
"collapseLabel": "收起预览",
|
||||||
"viewAlbum": "查看相册",
|
"viewAlbum": "查看相册",
|
||||||
"photoLabel": "示例照片 {n}",
|
"photoLabel": "示例照片 {n}",
|
||||||
"mockAlbumTitle": "活动相册"
|
"mockAlbumTitle": "活动相册",
|
||||||
|
"simLike": "模拟点赞事件",
|
||||||
|
"sampleDataHint": "示例数据 · 事件为测试模拟"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"enabledOn": "已启用(configVersion 已递增至 {version})",
|
"enabledOn": "已启用(configVersion 已递增至 {version})",
|
||||||
|
|||||||
+16
-1
@@ -33,6 +33,15 @@ declare namespace Api {
|
|||||||
*/
|
*/
|
||||||
type StatusFilter = 'active' | 'pending' | 'disabled' | 'locked'
|
type StatusFilter = 'active' | 'pending' | 'disabled' | 'locked'
|
||||||
|
|
||||||
|
/** 同步源档案快照(G4:工号/邮箱/公司/头像——纯展示,本地用户为 null) */
|
||||||
|
interface SyncedProfile {
|
||||||
|
sn?: string
|
||||||
|
email?: string
|
||||||
|
companyName?: string
|
||||||
|
companyAbbr?: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
|
||||||
/** 用户治理条目(不含邮箱/手机/密码等凭据字段) */
|
/** 用户治理条目(不含邮箱/手机/密码等凭据字段) */
|
||||||
interface Item {
|
interface Item {
|
||||||
id: string
|
id: string
|
||||||
@@ -44,13 +53,15 @@ declare namespace Api {
|
|||||||
credentialStatus: CredentialStatus
|
credentialStatus: CredentialStatus
|
||||||
locale: string | null
|
locale: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
synced?: SyncedProfile | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 管理列表响应(游标分页) */
|
/** 管理列表响应(page 模式带 total;游标模式 total=null) */
|
||||||
interface ListResponse {
|
interface ListResponse {
|
||||||
items: Item[]
|
items: Item[]
|
||||||
nextCursor: string | null
|
nextCursor: string | null
|
||||||
hasMore: boolean
|
hasMore: boolean
|
||||||
|
total?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 管理列表查询参数(双模式分流:不带 accountPrefix 即管理列表模式) */
|
/** 管理列表查询参数(双模式分流:不带 accountPrefix 即管理列表模式) */
|
||||||
@@ -61,6 +72,10 @@ declare namespace Api {
|
|||||||
status?: StatusFilter
|
status?: StatusFilter
|
||||||
cursor?: string
|
cursor?: string
|
||||||
limit?: number
|
limit?: number
|
||||||
|
/** 页码(提供即 page 模式:响应带 total,翻页由 total 驱动) */
|
||||||
|
page?: number
|
||||||
|
/** page 模式每页条数(1..100,缺省 20) */
|
||||||
|
pageSize?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 治理属性部分更新(仅提交字段被修改;sourceStatus 不在可更新集) */
|
/** 治理属性部分更新(仅提交字段被修改;sourceStatus 不在可更新集) */
|
||||||
|
|||||||
+1
@@ -79,6 +79,7 @@ declare module 'vue' {
|
|||||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
|
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||||
ElPopover: typeof import('element-plus/es')['ElPopover']
|
ElPopover: typeof import('element-plus/es')['ElPopover']
|
||||||
ElProgress: typeof import('element-plus/es')['ElProgress']
|
ElProgress: typeof import('element-plus/es')['ElProgress']
|
||||||
ElRadio: typeof import('element-plus/es')['ElRadio']
|
ElRadio: typeof import('element-plus/es')['ElRadio']
|
||||||
|
|||||||
@@ -264,8 +264,11 @@ describe('嵌入代码 v2 入口(UI-04:displayMode=vertical-stream → /embe
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('buildPreviewPlacement 本地预览停靠几何(草稿即时反映,纯本地)', () => {
|
describe('buildPreviewPlacement 本地预览停靠几何(草稿即时反映,纯本地)', () => {
|
||||||
it('desktop:按配置展开(collapsed=collapsedDefault),几何随草稿字段变化', () => {
|
it('desktop:按配置展开(collapsed=collapsedDefault),几何随草稿字段变化(carousel)', () => {
|
||||||
const placement = buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'desktop')
|
const placement = buildPreviewPlacement(
|
||||||
|
{ ...DEFAULT_EMBED_SETTINGS, displayMode: 'carousel' },
|
||||||
|
'desktop'
|
||||||
|
)
|
||||||
expect(placement).toEqual({
|
expect(placement).toEqual({
|
||||||
side: 'right',
|
side: 'right',
|
||||||
size: 'md',
|
size: 'md',
|
||||||
@@ -278,7 +281,14 @@ describe('buildPreviewPlacement 本地预览停靠几何(草稿即时反映,
|
|||||||
})
|
})
|
||||||
// 表单字段变化 → 预览几何即时跟随(未保存修改即时反映的纯逻辑基础)
|
// 表单字段变化 → 预览几何即时跟随(未保存修改即时反映的纯逻辑基础)
|
||||||
const changed = 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'
|
'desktop'
|
||||||
)
|
)
|
||||||
expect(changed.side).toBe('left')
|
expect(changed.side).toBe('left')
|
||||||
@@ -294,24 +304,30 @@ describe('buildPreviewPlacement 本地预览停靠几何(草稿即时反映,
|
|||||||
})
|
})
|
||||||
// collapsedDefault 仅作用于桌面预览初始形态
|
// collapsedDefault 仅作用于桌面预览初始形态
|
||||||
expect(
|
expect(
|
||||||
buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS, collapsedDefault: true }, 'desktop')
|
buildPreviewPlacement(
|
||||||
.collapsed
|
{ ...DEFAULT_EMBED_SETTINGS, displayMode: 'carousel', collapsedDefault: true },
|
||||||
|
'desktop'
|
||||||
|
).collapsed
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('mobile:窄屏强制 sm + 默认收起(§5.4 <480px);宽度按剩余宽收缩防溢出', () => {
|
it('v2(vertical-stream):恒 195 宽(无 sm 降档),mobile 仍默认收起(§2.1)', () => {
|
||||||
const placement = buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS }, 'mobile')
|
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.size).toBe('sm')
|
||||||
expect(placement.width).toBe(280)
|
expect(placement.width).toBe(280)
|
||||||
expect(placement.collapsed).toBe(true) // 窄屏强制收起(与 collapsedDefault 无关)
|
expect(placement.collapsed).toBe(true) // 窄屏强制收起(与 collapsedDefault 无关)
|
||||||
expect(
|
expect(buildPreviewPlacement({ ...v1, collapsedDefault: false }, 'mobile').collapsed).toBe(true)
|
||||||
buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS, collapsedDefault: false }, 'mobile')
|
|
||||||
.collapsed
|
|
||||||
).toBe(true)
|
|
||||||
// 偏移挤占剩余宽 → 宽度取小(375 - 120 - 8 = 247)
|
// 偏移挤占剩余宽 → 宽度取小(375 - 120 - 8 = 247)
|
||||||
expect(buildPreviewPlacement({ ...DEFAULT_EMBED_SETTINGS, offsetX: 120 }, 'mobile').width).toBe(
|
expect(buildPreviewPlacement({ ...v1, offsetX: 120 }, 'mobile').width).toBe(247)
|
||||||
247
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ export const EMBED_Z_INDEX_MAX = 2147483000
|
|||||||
export const EMBED_SCROLL_SPEED_MIN = 8
|
export const EMBED_SCROLL_SPEED_MIN = 8
|
||||||
export const EMBED_SCROLL_SPEED_MAX = 32
|
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 对齐) */
|
/** size → 悬浮组件宽度 px(sm 窄屏降级档;与 loader SIZE_WIDTH 对齐) */
|
||||||
export const EMBED_SIZE_WIDTH: Record<Api.EmbedSlot.Size, number> = { sm: 280, md: 320 }
|
export const EMBED_SIZE_WIDTH: Record<Api.EmbedSlot.Size, number> = { sm: 280, md: 320 }
|
||||||
|
|
||||||
@@ -253,22 +257,24 @@ export const EMBED_PREVIEW_FRAME_WIDTH: Record<'desktop' | 'mobile', number> = {
|
|||||||
mobile: 375
|
mobile: 375
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 表单草稿 → 预览停靠几何(desktop=按配置;mobile=窄屏强制 sm+收起)。
|
/** 表单草稿 → 预览停靠几何(desktop=按配置;mobile=窄屏强制收起)。
|
||||||
* 数值全部经契约夹取——表单输入的中间态(空/越界)预览不抖动;宽度按
|
* 数值全部经契约夹取——表单输入的中间态(空/越界)预览不抖动;宽度按
|
||||||
* loader applyWrapperBox 同规则取配置宽与视口剩余宽的较小值。 */
|
* loader applyWrapperBox 同规则取配置宽与视口剩余宽的较小值。
|
||||||
|
* UI-04:vertical-stream 恒 195 宽(§2.1 硬性,无 sm 降档——mobile 仅默认收起)。 */
|
||||||
export function buildPreviewPlacement(
|
export function buildPreviewPlacement(
|
||||||
draft: EmbedSettingsDraft,
|
draft: EmbedSettingsDraft,
|
||||||
mode: 'desktop' | 'mobile',
|
mode: 'desktop' | 'mobile',
|
||||||
frameWidth: number = EMBED_PREVIEW_FRAME_WIDTH[mode]
|
frameWidth: number = EMBED_PREVIEW_FRAME_WIDTH[mode]
|
||||||
): EmbedPreviewPlacement {
|
): EmbedPreviewPlacement {
|
||||||
const narrow = mode === 'mobile'
|
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 offsetX = clampOffsetX(draft.offsetX)
|
||||||
const available = Math.max(frameWidth - offsetX - 8, EMBED_PREVIEW_MIN_WIDTH)
|
const available = Math.max(frameWidth - offsetX - 8, EMBED_PREVIEW_MIN_WIDTH)
|
||||||
return {
|
return {
|
||||||
side: draft.position,
|
side: draft.position,
|
||||||
size,
|
size,
|
||||||
width: Math.min(EMBED_SIZE_WIDTH[size], available),
|
width: Math.min(isV2 ? EMBED_V2_WIDTH : EMBED_SIZE_WIDTH[size], available),
|
||||||
offsetX,
|
offsetX,
|
||||||
offsetBottom: clampOffsetBottom(draft.offsetBottom),
|
offsetBottom: clampOffsetBottom(draft.offsetBottom),
|
||||||
zIndex: clampZIndex(draft.zIndex),
|
zIndex: clampZIndex(draft.zIndex),
|
||||||
|
|||||||
@@ -54,12 +54,41 @@
|
|||||||
<!-- 悬浮组件本体(几何随草稿即时变化) -->
|
<!-- 悬浮组件本体(几何随草稿即时变化) -->
|
||||||
<div
|
<div
|
||||||
class="embed-preview-dock"
|
class="embed-preview-dock"
|
||||||
:class="`is-${placement.side}`"
|
:class="[`is-${placement.side}`, { 'is-v2': isV2 }]"
|
||||||
:style="widgetStyle"
|
:style="widgetStyle"
|
||||||
data-test="embed-preview-widget"
|
data-test="embed-preview-widget"
|
||||||
>
|
>
|
||||||
|
<!-- UI-04 v2:真实组件预览(iframe 载 viewer /embed-preview——真实
|
||||||
|
EmbedStream/引擎/气泡层,样例数据注入;配置经 postMessage 实时
|
||||||
|
更新,§8.2「preview 用真实展示组件,数据源注入」)。 -->
|
||||||
|
<div v-if="isV2 && !isCollapsed" class="embed-preview-v2frame" data-test="embed-preview-v2">
|
||||||
|
<iframe
|
||||||
|
ref="previewFrame"
|
||||||
|
:src="previewSrc"
|
||||||
|
title="PhotoLive v2 preview"
|
||||||
|
data-test="embed-preview-v2-iframe"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="isCollapsed"
|
v-else-if="isCollapsed && isV2"
|
||||||
|
class="embed-handle embed-handle--v2"
|
||||||
|
data-test="embed-preview-handle"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="expand"
|
||||||
|
@keydown.enter.prevent="expand"
|
||||||
|
@keydown.space.prevent="expand"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false">
|
||||||
|
<path
|
||||||
|
fill="#1a73e8"
|
||||||
|
d="M9 2 7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ $t('embedSlotsManage.preview.handleText') }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="isCollapsed"
|
||||||
class="embed-handle"
|
class="embed-handle"
|
||||||
data-test="embed-preview-handle"
|
data-test="embed-preview-handle"
|
||||||
role="button"
|
role="button"
|
||||||
@@ -149,6 +178,19 @@
|
|||||||
<span v-if="mode === 'mobile'" class="embed-preview-caption-hint">
|
<span v-if="mode === 'mobile'" class="embed-preview-caption-hint">
|
||||||
{{ $t('embedSlotsManage.preview.collapsedHint') }}
|
{{ $t('embedSlotsManage.preview.collapsedHint') }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- 工具区(§8.2:仅管理预览,不在实际组件):模拟点赞事件(测试模拟)。 -->
|
||||||
|
<button
|
||||||
|
v-if="isV2 && mode === 'desktop'"
|
||||||
|
type="button"
|
||||||
|
class="embed-preview-sim"
|
||||||
|
data-test="embed-preview-sim-like"
|
||||||
|
@click="simulateLike"
|
||||||
|
>
|
||||||
|
{{ $t('embedSlotsManage.preview.simLike') }}
|
||||||
|
</button>
|
||||||
|
<span v-if="isV2" class="embed-preview-caption-hint">
|
||||||
|
{{ $t('embedSlotsManage.preview.sampleDataHint') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -158,6 +200,8 @@
|
|||||||
import {
|
import {
|
||||||
buildPreviewPlacement,
|
buildPreviewPlacement,
|
||||||
buildPreviewWidgetStyle,
|
buildPreviewWidgetStyle,
|
||||||
|
clampScrollSpeed,
|
||||||
|
embedOriginFromEnv,
|
||||||
type EmbedSettingsDraft
|
type EmbedSettingsDraft
|
||||||
} from '@/utils/system/embedSlots'
|
} from '@/utils/system/embedSlots'
|
||||||
|
|
||||||
@@ -180,6 +224,43 @@
|
|||||||
const placement = computed(() => buildPreviewPlacement(props.draft, props.mode))
|
const placement = computed(() => buildPreviewPlacement(props.draft, props.mode))
|
||||||
const widgetStyle = computed(() => buildPreviewWidgetStyle(placement.value))
|
const widgetStyle = computed(() => buildPreviewWidgetStyle(placement.value))
|
||||||
|
|
||||||
|
// ── UI-04 v2 真实组件预览(§8.2)──
|
||||||
|
const isV2 = computed(() => props.draft.displayMode === 'vertical-stream')
|
||||||
|
const previewFrame = ref<HTMLIFrameElement | null>(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<string, unknown>): 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 expanded = ref(false)
|
||||||
const isCollapsed = computed(() => placement.value.collapsed && !expanded.value)
|
const isCollapsed = computed(() => placement.value.collapsed && !expanded.value)
|
||||||
@@ -353,6 +434,47 @@
|
|||||||
box-shadow: 0 4px 16px rgb(0 0 0 / 0.18);
|
box-shadow: 0 4px 16px rgb(0 0 0 / 0.18);
|
||||||
overflow: hidden;
|
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 {
|
.embed-root {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
font-family:
|
font-family:
|
||||||
|
|||||||
@@ -31,6 +31,45 @@
|
|||||||
data-test="slot-form-name"
|
data-test="slot-form-name"
|
||||||
/>
|
/>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
|
<ElFormItem :label="$t('embedSlotsManage.form.modeLabel')">
|
||||||
|
<ElRadioGroup v-model="draft.displayMode" data-test="slot-form-mode">
|
||||||
|
<ElRadio value="vertical-stream" data-test="slot-form-mode-stream">
|
||||||
|
{{ $t('embedSlotsManage.form.modeStream') }}
|
||||||
|
</ElRadio>
|
||||||
|
<ElRadio value="carousel" data-test="slot-form-mode-carousel">
|
||||||
|
{{ $t('embedSlotsManage.form.modeCarousel') }}
|
||||||
|
</ElRadio>
|
||||||
|
</ElRadioGroup>
|
||||||
|
</ElFormItem>
|
||||||
|
<!-- v2 固定尺寸(§7.2:宽高只读——不可经旧字段改变新布局)。 -->
|
||||||
|
<ElFormItem v-if="isV2" :label="$t('embedSlotsManage.form.fixedSizeLabel')">
|
||||||
|
<span class="slot-form-fixed-size" data-test="slot-form-fixed-size">
|
||||||
|
{{ $t('embedSlotsManage.form.fixedSizeValue') }}
|
||||||
|
</span>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-if="isV2" :label="$t('embedSlotsManage.form.speedLabel')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElSlider
|
||||||
|
v-model="draft.scrollSpeed"
|
||||||
|
:min="EMBED_SCROLL_SPEED_MIN"
|
||||||
|
:max="EMBED_SCROLL_SPEED_MAX"
|
||||||
|
:step="1"
|
||||||
|
style="width: 180px"
|
||||||
|
data-test="slot-form-speed"
|
||||||
|
/>
|
||||||
|
<span class="text-gray-400 text-sm" data-test="slot-form-speed-value">
|
||||||
|
{{ draft.scrollSpeed }} {{ $t('embedSlotsManage.form.speedUnit') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
|
<ElFormItem v-if="isV2" :label="$t('embedSlotsManage.form.reactionsLabel')">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ElSwitch v-model="draft.reactionsEnabled" data-test="slot-form-reactions" />
|
||||||
|
<span class="text-gray-400 text-sm">{{
|
||||||
|
$t('embedSlotsManage.form.reactionsHint')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</ElFormItem>
|
||||||
<ElFormItem :label="$t('embedSlotsManage.form.positionLabel')">
|
<ElFormItem :label="$t('embedSlotsManage.form.positionLabel')">
|
||||||
<ElSelect v-model="draft.position" style="width: 200px" data-test="slot-form-position">
|
<ElSelect v-model="draft.position" style="width: 200px" data-test="slot-form-position">
|
||||||
<ElOption
|
<ElOption
|
||||||
@@ -42,7 +81,9 @@
|
|||||||
/>
|
/>
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem :label="$t('embedSlotsManage.form.sizeLabel')">
|
<!-- v1 专属布局字段(§7.2:v2 忽略——「预览清楚显示其在新模式无效,
|
||||||
|
而不是保存后偷偷生效」,切换回旧版轮播时恢复编辑)。 -->
|
||||||
|
<ElFormItem v-if="!isV2" :label="$t('embedSlotsManage.form.sizeLabel')">
|
||||||
<ElSelect v-model="draft.size" style="width: 200px" data-test="slot-form-size">
|
<ElSelect v-model="draft.size" style="width: 200px" data-test="slot-form-size">
|
||||||
<ElOption
|
<ElOption
|
||||||
v-for="opt in sizeOptions"
|
v-for="opt in sizeOptions"
|
||||||
@@ -53,7 +94,7 @@
|
|||||||
/>
|
/>
|
||||||
</ElSelect>
|
</ElSelect>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem :label="$t('embedSlotsManage.form.intervalLabel')">
|
<ElFormItem v-if="!isV2" :label="$t('embedSlotsManage.form.intervalLabel')">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<ElInputNumber
|
<ElInputNumber
|
||||||
v-model="draft.intervalMs"
|
v-model="draft.intervalMs"
|
||||||
@@ -86,7 +127,7 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</ElFormItem>
|
</ElFormItem>
|
||||||
<ElFormItem :label="$t('embedSlotsManage.form.imageFitLabel')">
|
<ElFormItem v-if="!isV2" :label="$t('embedSlotsManage.form.imageFitLabel')">
|
||||||
<ElRadioGroup v-model="draft.imageFit" data-test="slot-form-image-fit">
|
<ElRadioGroup v-model="draft.imageFit" data-test="slot-form-image-fit">
|
||||||
<ElRadio value="contain" data-test="slot-form-image-fit-contain">
|
<ElRadio value="contain" data-test="slot-form-image-fit-contain">
|
||||||
{{ $t('embedSlotsManage.form.imageFitContain') }}
|
{{ $t('embedSlotsManage.form.imageFitContain') }}
|
||||||
@@ -169,6 +210,8 @@
|
|||||||
EMBED_OFFSET_BOTTOM_MIN,
|
EMBED_OFFSET_BOTTOM_MIN,
|
||||||
EMBED_OFFSET_X_MAX,
|
EMBED_OFFSET_X_MAX,
|
||||||
EMBED_OFFSET_X_MIN,
|
EMBED_OFFSET_X_MIN,
|
||||||
|
EMBED_SCROLL_SPEED_MAX,
|
||||||
|
EMBED_SCROLL_SPEED_MIN,
|
||||||
EMBED_Z_INDEX_MAX,
|
EMBED_Z_INDEX_MAX,
|
||||||
EMBED_Z_INDEX_MIN,
|
EMBED_Z_INDEX_MIN,
|
||||||
buildSlotEditPayload,
|
buildSlotEditPayload,
|
||||||
@@ -200,6 +243,9 @@
|
|||||||
// 脏态:编辑态与已保存条目比对 / 新建态与缺省比对 →「未保存」标记
|
// 脏态:编辑态与已保存条目比对 / 新建态与缺省比对 →「未保存」标记
|
||||||
const dirty = computed(() => isSlotDraftDirty(props.embedSlot, draftName.value, draft))
|
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(
|
const previewAlbumTitle = computed(
|
||||||
() => props.embedSlot?.albumTitle ?? t('embedSlotsManage.preview.mockAlbumTitle')
|
() => props.embedSlot?.albumTitle ?? t('embedSlotsManage.preview.mockAlbumTitle')
|
||||||
@@ -274,6 +320,10 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
.slot-form-fixed-size {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
.slot-form-dialog__preview {
|
.slot-form-dialog__preview {
|
||||||
flex: none;
|
flex: none;
|
||||||
width: 470px;
|
width: 470px;
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<Api.AdminUser.ListResponse>,
|
||||||
|
) {
|
||||||
|
const items = ref<Api.AdminUser.Item[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
|
||||||
|
async function loadPage(target: number = page.value): Promise<void> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<!-- 账号管理页(P4a Task 8):过滤(前缀+治理状态)+ 游标累积列表 + 行内治理
|
<!-- 账号管理页(P4a Task 8;G4 后改 page 分页 + 同步档案列):过滤(前缀+
|
||||||
操作(编辑/重置密码/激活/停用/解锁)。自我保护双保险:UI 对自身行禁用
|
治理状态)+ 页码分页(page/pageSize/total)+ 同步档案(头像/工号/邮箱/
|
||||||
角色/锁定编辑,服务端 400 仍会经 http 层本地化弹出。 -->
|
公司)展示 + 行内治理操作(编辑/重置密码/激活/停用/解锁)。自我保护双
|
||||||
|
保险:UI 对自身行禁用角色/锁定编辑,服务端 400 仍会经 http 层本地化弹出。 -->
|
||||||
<template>
|
<template>
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<ElCard data-test="users-card">
|
<ElCard data-test="users-card">
|
||||||
@@ -44,7 +45,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span>{{ $t('usersManage.list.loadFailed') }}:{{ error.message }}</span>
|
<span>{{ $t('usersManage.list.loadFailed') }}:{{ error.message }}</span>
|
||||||
<ElButton size="small" data-test="users-list-retry" @click="loadFirst()">
|
<ElButton size="small" data-test="users-list-retry" @click="loadPage()">
|
||||||
{{ $t('usersManage.list.retry') }}
|
{{ $t('usersManage.list.retry') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,11 +62,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
<ElTableColumn
|
<ElTableColumn :label="$t('usersManage.list.colDisplayName')" min-width="180">
|
||||||
:label="$t('usersManage.list.colDisplayName')"
|
<template #default="{ row }">
|
||||||
min-width="140"
|
<div class="flex items-center gap-2">
|
||||||
prop="displayName"
|
<ElAvatar
|
||||||
/>
|
:size="28"
|
||||||
|
:src="row.synced?.avatar || undefined"
|
||||||
|
data-test="user-avatar"
|
||||||
|
>
|
||||||
|
{{ row.displayName.slice(0, 1) }}
|
||||||
|
</ElAvatar>
|
||||||
|
<span data-test="user-display-name">{{ row.displayName }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="$t('usersManage.list.colSn')" min-width="90">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span data-test="user-sn">{{ row.synced?.sn || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="$t('usersManage.list.colEmail')" min-width="170">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span data-test="user-email">{{ row.synced?.email || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
|
<ElTableColumn :label="$t('usersManage.list.colCompany')" min-width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span data-test="user-company">
|
||||||
|
{{ row.synced?.companyAbbr || row.synced?.companyName || '—' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</ElTableColumn>
|
||||||
<ElTableColumn :label="$t('usersManage.list.colRole')" width="100">
|
<ElTableColumn :label="$t('usersManage.list.colRole')" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<ElTag :type="row.role === 'admin' ? 'warning' : 'info'" data-test="user-role-tag">
|
<ElTag :type="row.role === 'admin' ? 'warning' : 'info'" data-test="user-role-tag">
|
||||||
@@ -151,11 +178,18 @@
|
|||||||
</template>
|
</template>
|
||||||
</ElTable>
|
</ElTable>
|
||||||
|
|
||||||
<div v-if="items.length" class="mt-4 text-center">
|
<div v-if="total > 0" class="mt-4 flex justify-end" data-test="users-pagination">
|
||||||
<ElButton v-if="hasMore" :loading="loading" data-test="users-load-more" @click="loadMore()">
|
<ElPagination
|
||||||
{{ $t('usersManage.list.loadMore') }}
|
v-model:current-page="page"
|
||||||
</ElButton>
|
v-model:page-size="pageSize"
|
||||||
<span v-else class="text-gray-400 text-sm">{{ $t('usersManage.list.noMore') }}</span>
|
:total="total"
|
||||||
|
:page-sizes="[20, 50, 100]"
|
||||||
|
:disabled="loading"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
@current-change="loadPage"
|
||||||
|
@size-change="onSizeChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ElCard>
|
</ElCard>
|
||||||
|
|
||||||
@@ -176,14 +210,12 @@
|
|||||||
fetchUpdateAdminUser,
|
fetchUpdateAdminUser,
|
||||||
fetchAdminUsers
|
fetchAdminUsers
|
||||||
} from '@/api/adminUsers'
|
} from '@/api/adminUsers'
|
||||||
import { useCursorList } from '@/views/albums/composables/useCursorList'
|
import { useAdminUserPages } from './composables/useAdminUserPages'
|
||||||
import { formatDateTime } from '@/utils/albums/format'
|
import { formatDateTime } from '@/utils/albums/format'
|
||||||
import { useUserStore } from '@/store/modules/user'
|
import { useUserStore } from '@/store/modules/user'
|
||||||
|
|
||||||
defineOptions({ name: 'SystemUsers' })
|
defineOptions({ name: 'SystemUsers' })
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
@@ -198,26 +230,26 @@
|
|||||||
{ value: 'locked', label: t('usersManage.bucket.locked') }
|
{ value: 'locked', label: t('usersManage.bucket.locked') }
|
||||||
])
|
])
|
||||||
|
|
||||||
const { items, hasMore, loading, error, loadFirst, loadMore, replaceItem } =
|
const { items, total, page, pageSize, loading, error, loadPage, onSizeChange, replaceItem } =
|
||||||
useCursorList<Api.AdminUser.Item>(
|
useAdminUserPages((targetPage, size) =>
|
||||||
(cursor?: string) =>
|
fetchAdminUsers({
|
||||||
fetchAdminUsers({
|
query: filters.query.trim() || undefined,
|
||||||
query: filters.query.trim() || undefined,
|
status: (filters.status || undefined) as Api.AdminUser.StatusFilter | undefined,
|
||||||
status: (filters.status || undefined) as Api.AdminUser.StatusFilter | undefined,
|
page: targetPage,
|
||||||
cursor,
|
pageSize: size
|
||||||
limit: PAGE_SIZE
|
})
|
||||||
}),
|
|
||||||
(item) => item.id
|
|
||||||
)
|
)
|
||||||
|
|
||||||
function search(): void {
|
function search(): void {
|
||||||
loadFirst()
|
page.value = 1
|
||||||
|
void loadPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetFilters(): void {
|
function resetFilters(): void {
|
||||||
filters.query = ''
|
filters.query = ''
|
||||||
filters.status = ''
|
filters.status = ''
|
||||||
loadFirst()
|
page.value = 1
|
||||||
|
void loadPage()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 自身行判定:本地会话只存账号(无 /users/me 端点),按 account 对齐 */
|
/** 自身行判定:本地会话只存账号(无 /users/me 端点),按 account 对齐 */
|
||||||
@@ -319,6 +351,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadFirst()
|
void loadPage()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user