feat(admin): standard pagination + synced profile columns on users list

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-22 18:10:53 +08:00
parent 2f6b6afc92
commit eebf3b3334
12 changed files with 495 additions and 59 deletions
+67 -1
View File
@@ -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)
})
})
+16 -2
View File
@@ -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})",
+16 -2
View File
@@ -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})",
+16 -1
View File
@@ -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 不在可更新集) */
+1
View File
@@ -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']
+30 -14
View File
@@ -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)
})
})
+10 -4
View File
@@ -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<Api.EmbedSlot.Size, number> = { 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),
@@ -54,12 +54,41 @@
<!-- 悬浮组件本体(几何随草稿即时变化) -->
<div
class="embed-preview-dock"
:class="`is-${placement.side}`"
:class="[`is-${placement.side}`, { 'is-v2': isV2 }]"
:style="widgetStyle"
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
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"
data-test="embed-preview-handle"
role="button"
@@ -149,6 +178,19 @@
<span v-if="mode === 'mobile'" class="embed-preview-caption-hint">
{{ $t('embedSlotsManage.preview.collapsedHint') }}
</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>
</template>
@@ -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<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 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:
@@ -31,6 +31,45 @@
data-test="slot-form-name"
/>
</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')">
<ElSelect v-model="draft.position" style="width: 200px" data-test="slot-form-position">
<ElOption
@@ -42,7 +81,9 @@
/>
</ElSelect>
</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">
<ElOption
v-for="opt in sizeOptions"
@@ -53,7 +94,7 @@
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('embedSlotsManage.form.intervalLabel')">
<ElFormItem v-if="!isV2" :label="$t('embedSlotsManage.form.intervalLabel')">
<div class="flex items-center gap-2">
<ElInputNumber
v-model="draft.intervalMs"
@@ -86,7 +127,7 @@
}}</span>
</div>
</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">
<ElRadio value="contain" data-test="slot-form-image-fit-contain">
{{ $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;
@@ -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 }
}
+62 -30
View File
@@ -1,6 +1,7 @@
<!-- 账号管理页(P4a Task 8):过滤(前缀+治理状态)+ 游标累积列表 + 行内治理
操作(编辑/重置密码/激活/停用/解锁)。自我保护双保险:UI 对自身行禁用
角色/锁定编辑,服务端 400 仍会经 http 层本地化弹出。 -->
<!-- 账号管理页(P4a Task 8;G4 后改 page 分页 + 同步档案列):过滤(前缀+
治理状态)+ 页码分页(page/pageSize/total)+ 同步档案(头像/工号/邮箱/
公司)展示 + 行内治理操作(编辑/重置密码/激活/停用/解锁)。自我保护双
保险:UI 对自身行禁用角色/锁定编辑,服务端 400 仍会经 http 层本地化弹出。 -->
<template>
<div class="p-5">
<ElCard data-test="users-card">
@@ -44,7 +45,7 @@
>
<div class="flex items-center gap-2">
<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') }}
</ElButton>
</div>
@@ -61,11 +62,37 @@
</div>
</template>
</ElTableColumn>
<ElTableColumn
:label="$t('usersManage.list.colDisplayName')"
min-width="140"
prop="displayName"
/>
<ElTableColumn :label="$t('usersManage.list.colDisplayName')" min-width="180">
<template #default="{ row }">
<div class="flex items-center gap-2">
<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">
<template #default="{ row }">
<ElTag :type="row.role === 'admin' ? 'warning' : 'info'" data-test="user-role-tag">
@@ -151,11 +178,18 @@
</template>
</ElTable>
<div v-if="items.length" class="mt-4 text-center">
<ElButton v-if="hasMore" :loading="loading" data-test="users-load-more" @click="loadMore()">
{{ $t('usersManage.list.loadMore') }}
</ElButton>
<span v-else class="text-gray-400 text-sm">{{ $t('usersManage.list.noMore') }}</span>
<div v-if="total > 0" class="mt-4 flex justify-end" data-test="users-pagination">
<ElPagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[20, 50, 100]"
:disabled="loading"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="loadPage"
@size-change="onSizeChange"
/>
</div>
</ElCard>
@@ -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<Api.AdminUser.Item>(
(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()
})
</script>