feat(admin): account governance and user sync pages
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 } }
|
||||
}
|
||||
Reference in New Issue
Block a user