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 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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})",
|
||||
|
||||
@@ -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
@@ -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
@@ -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']
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user