feat(admin): account governance and user sync pages

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-20 20:26:53 +08:00
parent 3681999667
commit 618d1d796c
21 changed files with 2261 additions and 1 deletions
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createAutoRefresh, waitSyncTerminal } from './syncPolling'
const statusOf = (state: Api.UserSync.State): Api.UserSync.Status => ({
lastRunAt: null,
lastSuccessAt: null,
state,
sourceVersion: null,
counts: null
})
describe('waitSyncTerminal 批次终态等待', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('首次查询即终态 → 立刻 resolve,不排定后续轮询', async () => {
const fetchStatus = vi.fn().mockResolvedValue(statusOf('success'))
const result = await waitSyncTerminal(fetchStatus, { intervalMs: 100 })
expect(result.state).toBe('success')
expect(fetchStatus).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(500)
expect(fetchStatus).toHaveBeenCalledTimes(1)
})
it('running → 按间隔轮询直到离开 running', async () => {
const fetchStatus = vi
.fn()
.mockResolvedValueOnce(statusOf('running'))
.mockResolvedValueOnce(statusOf('running'))
.mockResolvedValueOnce(statusOf('partial'))
const promise = waitSyncTerminal(fetchStatus, { intervalMs: 100 })
await vi.advanceTimersByTimeAsync(1000)
const settled = await promise
expect(settled.state).toBe('partial')
expect(fetchStatus).toHaveBeenCalledTimes(3)
// 终态后不再轮询
await vi.advanceTimersByTimeAsync(1000)
expect(fetchStatus).toHaveBeenCalledTimes(3)
})
it('超时上限 → reject(不再无限轮询)', async () => {
const fetchStatus = vi.fn().mockResolvedValue(statusOf('running'))
const promise = waitSyncTerminal(fetchStatus, { intervalMs: 100, timeoutMs: 250 })
// 先挂 rejection 断言再推进时间,避免 reject 先于 handler 的未处理窗口
const expectation = expect(promise).rejects.toThrow('timeout')
await vi.advanceTimersByTimeAsync(1000)
await expectation
const callsAfterTimeout = fetchStatus.mock.calls.length
await vi.advanceTimersByTimeAsync(1000)
expect(fetchStatus.mock.calls.length).toBe(callsAfterTimeout)
})
})
describe('createAutoRefresh 周期刷新', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('start 立即执行一次,之后按间隔触发;stop 即停止', async () => {
const fn = vi.fn().mockResolvedValue(undefined)
const handle = createAutoRefresh(fn, 10_000)
handle.start()
await vi.advanceTimersByTimeAsync(0)
expect(fn).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(10_000)
expect(fn).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(10_000)
expect(fn).toHaveBeenCalledTimes(3)
handle.stop()
expect(handle.isRunning()).toBe(false)
await vi.advanceTimersByTimeAsync(30_000)
expect(fn).toHaveBeenCalledTimes(3)
})
it('重复 start 幂等(不叠加定时器)', async () => {
const fn = vi.fn().mockResolvedValue(undefined)
const handle = createAutoRefresh(fn, 10_000)
handle.start()
handle.start()
await vi.advanceTimersByTimeAsync(0)
expect(fn).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(10_000)
expect(fn).toHaveBeenCalledTimes(2)
handle.stop()
})
it('慢响应不堆叠:上一次未完成时间隔到点跳过', async () => {
let release: (() => void) | undefined
const fn = vi.fn().mockImplementation(() => new Promise<void>((resolve) => (release = resolve)))
const handle = createAutoRefresh(fn, 100)
handle.start()
await vi.advanceTimersByTimeAsync(0)
expect(fn).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(200)
// 首次仍在执行 → 间隔到点不重入
expect(fn).toHaveBeenCalledTimes(1)
release?.()
await vi.advanceTimersByTimeAsync(300)
expect(fn).toHaveBeenCalledTimes(2)
handle.stop()
})
})
@@ -0,0 +1,92 @@
/**
* 用户同步页轮询逻辑(纯函数/可注入依赖,vitest 用 fake timers 覆盖)
*
* - waitSyncTerminal:手动补拉 202 受理后等待批次离开 running(间隔轮询 +
* 超时上限;终态即 resolve,避免页面无限转圈)
* - createAutoRefresh:页面打开期间的定时状态刷新(10s);立即执行一次 +
* 间隔触发 + 重入保护(慢响应不堆叠)+ 幂等 start/stop(组件
* onUnmounted 清理,防路由离开后仍在请求)
*/
export interface WaitSyncOptions {
/** 轮询间隔(默认 1.5s:批次通常秒级完成) */
intervalMs?: number
/** 总超时(默认 45s:超过视为异常,交调用方展示错误) */
timeoutMs?: number
}
const DEFAULT_INTERVAL_MS = 1500
const DEFAULT_TIMEOUT_MS = 45_000
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
/**
* 轮询同步状态直到批次离开 running(首次查询即终态则立刻 resolve)。
* 超时 reject(Error.message = 'timeout');fetchStatus 的网络错误向上抛。
*/
export function waitSyncTerminal(
fetchStatus: () => Promise<Api.UserSync.Status>,
options: WaitSyncOptions = {}
): Promise<Api.UserSync.Status> {
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const deadline = Date.now() + timeoutMs
return (async function poll(): Promise<Api.UserSync.Status> {
const status = await fetchStatus()
if (status.state !== 'running') return status
if (Date.now() >= deadline) {
throw new Error('timeout')
}
await delay(intervalMs)
return poll()
})()
}
export interface AutoRefreshHandle {
/** 开始(立即执行一次 + 间隔触发;重复调用幂等) */
start: () => void
/** 停止并清理定时器(重复调用幂等) */
stop: () => void
/** 是否在刷新周期内 */
isRunning: () => boolean
}
/**
* 周期刷新:start 立即触发一次;执行中的回调未完成时不堆叠下一次。
* 返回的 handle 由组件持有,onUnmounted 时 stop()。
*/
export function createAutoRefresh(
fn: () => void | Promise<unknown>,
intervalMs = 10_000
): AutoRefreshHandle {
let timer: ReturnType<typeof setInterval> | null = null
let inFlight = false
async function run(): Promise<void> {
if (inFlight) return
inFlight = true
try {
await fn()
} finally {
inFlight = false
}
}
return {
start() {
if (timer !== null) return
void run()
timer = setInterval(() => void run(), intervalMs)
},
stop() {
if (timer === null) return
clearInterval(timer)
timer = null
},
isRunning() {
return timer !== null
}
}
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from 'vitest'
import {
ADMIN_STATUS_FILTER_VALUES,
buildAdminUserParams,
buildResetPasswordPayload,
buildUserUpdatePayload,
statusBucketOf
} from './userAdmin'
const baseUser: Api.AdminUser.Item = {
id: 'u1',
account: 'li.demo',
displayName: '李演示',
role: 'staff',
sourceStatus: 'active',
localLocked: false,
credentialStatus: 'active',
locale: null,
createdAt: '2026-09-20T00:00:00.000Z'
}
describe('buildAdminUserParams 过滤参数映射', () => {
it('空查询与「全部」状态均不上送(undefined 语义)', () => {
const params = buildAdminUserParams({ query: ' ', status: '' })
expect(params).not.toHaveProperty('query')
expect(params).not.toHaveProperty('status')
})
it('query 去首尾空白后上送;合法状态透传', () => {
const params = buildAdminUserParams({ query: ' li ', status: 'pending' })
expect(params.query).toBe('li')
expect(params.status).toBe('pending')
})
it('游标与 limit 透传(加载更多)', () => {
const params = buildAdminUserParams({ query: '', status: 'locked' }, 'c1', 20)
expect(params.cursor).toBe('c1')
expect(params.limit).toBe(20)
expect(params.status).toBe('locked')
})
})
describe('statusBucketOf 治理状态归类(与后端过滤同口径)', () => {
it('优先级 pending > disabled > locked > active', () => {
expect(statusBucketOf({ ...baseUser, credentialStatus: 'pending', localLocked: true })).toBe(
'pending'
)
expect(statusBucketOf({ ...baseUser, sourceStatus: 'disabled', localLocked: true })).toBe(
'disabled'
)
expect(statusBucketOf({ ...baseUser, localLocked: true })).toBe('locked')
expect(statusBucketOf(baseUser)).toBe('active')
})
it('取值集与契约 AdminUserStatusFilter 一致', () => {
expect([...ADMIN_STATUS_FILTER_VALUES]).toEqual(['active', 'pending', 'disabled', 'locked'])
})
})
describe('buildUserUpdatePayload 编辑载荷收敛', () => {
it('只提交变更字段(role + localLocked 变更、displayName 不变则省略)', () => {
const payload = buildUserUpdatePayload(baseUser, {
role: 'admin',
displayName: baseUser.displayName,
localLocked: true
})
expect(payload).toEqual({ role: 'admin', localLocked: true })
})
it('displayName 空串视为「不改」(契约 1..128,清空不是合法目标态)', () => {
const payload = buildUserUpdatePayload(baseUser, {
role: 'staff',
displayName: ' ',
localLocked: false
})
expect(payload).toEqual({})
})
it('displayName 变更去首尾空白后上送', () => {
const payload = buildUserUpdatePayload(baseUser, {
role: 'staff',
displayName: ' 王演示 ',
localLocked: false
})
expect(payload).toEqual({ displayName: '王演示' })
})
})
describe('buildResetPasswordPayload 重置密码载荷', () => {
it('generate 模式不上送 newPassword(服务端生成)', () => {
expect(buildResetPasswordPayload('generate', '')).toEqual({ ok: true, payload: {} })
})
it('custom 模式镜像契约边界:空 → required;长度越界 → length;合法 → 透传', () => {
expect(buildResetPasswordPayload('custom', ' ')).toEqual({ ok: false, error: 'required' })
expect(buildResetPasswordPayload('custom', 'short')).toEqual({ ok: false, error: 'length' })
expect(buildResetPasswordPayload('custom', 'a'.repeat(129))).toEqual({
ok: false,
error: 'length'
})
expect(buildResetPasswordPayload('custom', ' new-password-1 ')).toEqual({
ok: true,
payload: { newPassword: 'new-password-1' }
})
})
})
+112
View File
@@ -0,0 +1,112 @@
/**
* 账号治理页纯逻辑(无组件依赖,vitest 直接覆盖)
*
* - 过滤参数映射:空查询/「全部」状态不上送(axios 亦会丢弃 undefined,
* 这里显式省略让请求 URL 干净、单测可断言)
* - 治理状态归类:与后端管理列表 status 过滤同一优先级
* (pending > disabled > locked > active),前端展示与过滤口径一致
* - PATCH 载荷收敛:只提交变更字段(服务端幂等 no-op 不落审计,前端不
* 制造 no-op 噪音);displayName 清空视为「不改」(契约 1..128)
* - 重置密码载荷:generate 模式不上送 newPassword(服务端生成 16 位);
* custom 模式镜像契约 8..128 边界
*/
/** 治理状态过滤取值(「全部」= 空串,不上送) */
export const ADMIN_STATUS_FILTER_VALUES = ['active', 'pending', 'disabled', 'locked'] as const
export type AdminStatusFilter = (typeof ADMIN_STATUS_FILTER_VALUES)[number]
export interface AdminUserFilterInput {
/** account/displayName 前缀(1..64;空 = 不过滤) */
query: string
/** 治理状态('' = 全部) */
status: string
}
function isStatusFilter(value: string): value is AdminStatusFilter {
return (ADMIN_STATUS_FILTER_VALUES as readonly string[]).includes(value)
}
/** 过滤条件 → 列表查询参数(空值省略;query 去首尾空白) */
export function buildAdminUserParams(
filter: AdminUserFilterInput,
cursor?: string,
limit?: number
): Api.AdminUser.ListParams {
const params: Api.AdminUser.ListParams = { cursor, limit }
const query = filter.query.trim()
if (query !== '') params.query = query
if (isStatusFilter(filter.status)) params.status = filter.status
return params
}
/** 用户 → 治理状态桶(与后端过滤同一归类优先级) */
export function statusBucketOf(
user: Pick<Api.AdminUser.Item, 'credentialStatus' | 'sourceStatus' | 'localLocked'>
): AdminStatusFilter {
if (user.credentialStatus === 'pending') return 'pending'
if (user.sourceStatus === 'disabled') return 'disabled'
if (user.localLocked) return 'locked'
return 'active'
}
/** 治理状态桶 → ElTag type(展示层配色) */
export const BUCKET_TAG_TYPE: Record<AdminStatusFilter, 'success' | 'warning' | 'danger' | 'info'> =
{
active: 'success',
pending: 'warning',
disabled: 'danger',
locked: 'danger'
}
/** 同步引擎状态 → ElTag type */
export const SYNC_STATE_TAG_TYPE: Record<
Api.UserSync.State,
'success' | 'warning' | 'danger' | 'info'
> = {
idle: 'info',
running: 'warning',
success: 'success',
partial: 'warning',
failed: 'danger'
}
/** 编辑对话框草稿(三项治理属性) */
export interface AdminUserEditDraft {
role: Api.AdminUser.Role
displayName: string
localLocked: boolean
}
/** 编辑草稿 → PATCH 载荷(只提交变更字段;displayName 空串视为不改) */
export function buildUserUpdatePayload(
target: Api.AdminUser.Item,
draft: AdminUserEditDraft
): Api.AdminUser.UpdatePayload {
const payload: Api.AdminUser.UpdatePayload = {}
if (draft.role !== target.role) payload.role = draft.role
const displayName = draft.displayName.trim()
if (displayName !== '' && displayName !== target.displayName) {
payload.displayName = displayName
}
if (draft.localLocked !== target.localLocked) payload.localLocked = draft.localLocked
return payload
}
export type ResetPasswordMode = 'generate' | 'custom'
export type ResetPayloadResult =
| { ok: true; payload: Api.AdminUser.ResetPasswordPayload }
| { ok: false; error: 'required' | 'length' }
/** 重置密码表单 → 请求载荷(custom 模式镜像契约 8..128 边界) */
export function buildResetPasswordPayload(
mode: ResetPasswordMode,
customPassword: string
): ResetPayloadResult {
if (mode === 'generate') return { ok: true, payload: {} }
const password = customPassword.trim()
if (password === '') return { ok: false, error: 'required' }
if (password.length < 8 || password.length > 128) return { ok: false, error: 'length' }
return { ok: true, payload: { newPassword: password } }
}