diff --git a/apps/admin/e2e/users.spec.ts b/apps/admin/e2e/users.spec.ts new file mode 100644 index 0000000..f646978 --- /dev/null +++ b/apps/admin/e2e/users.spec.ts @@ -0,0 +1,151 @@ +// 账号治理 + 用户同步 e2e(真实后端 + compose 基础设施): +// 账号页:种子用户可见 → 前缀过滤 → 自我保护(自身行角色禁改)→ +// 重置密码(生成密码一次性展示/复制)→ 新密码 API 登录 → +// 停用(确认框)→ 该用户既有 token 即刻 401 → 状态桶过滤联动; +// 同步页:手动补拉(mock 源默认健康场景)→ 状态刷新(终态+计数+水位)→ +// 运行历史行点击 → 失败明细抽屉(成功批次空明细——admin e2e 无法 +// 触达后端 mock provider 注入坏行场景,失败明细非空链路由 API 侧 +// e2e 覆盖,此处断言空抽屉 + 真实批次计数)。 +// 治理操作用独立种子账号 gov-e2e(与 albums 套件的 staff-e2e 隔离,互不影响)。 +// 选择器一律 data-test(test.use testIdAttribute),与文案解耦。 +import { expect, test, type Page } from '@playwright/test' +import { adminCredentials, loginAsAdmin, seedFixture } from './helpers' + +test.use({ testIdAttribute: 'data-test', permissions: ['clipboard-read', 'clipboard-write'] }) + +const GOV_ACCOUNT = 'gov-e2e' + +test.beforeAll(() => { + // 幂等种子治理目标账号(占位密码不可登录;本套件经重置密码赋予真实密码) + seedFixture('staff', GOV_ACCOUNT, 'Gov E2E') +}) + +/** 进入账号管理页(已登录态) */ +async function gotoUsers(page: Page): Promise { + await page.goto('/#/system/users') + await expect(page.getByTestId('users-table')).toBeVisible() +} + +/** 账号表内定位目标行 */ +function userRow(page: Page, account: string) { + return page.getByTestId('users-table').locator('tr', { hasText: account }) +} + +test('账号页:种子用户可见 → 前缀过滤 → 自我保护(自身行角色禁改)', async ({ page }) => { + await loginAsAdmin(page) + await gotoUsers(page) + + const { account } = adminCredentials() + await expect(userRow(page, account)).toBeVisible() + await expect(userRow(page, GOV_ACCOUNT)).toBeVisible() + + // 前缀过滤:查 gov-e2e → 目标行保留、admin 行消失 + await page.getByTestId('users-query-input').fill(GOV_ACCOUNT) + await page.getByTestId('users-search-btn').click() + await expect(userRow(page, GOV_ACCOUNT)).toBeVisible() + await expect(userRow(page, account)).toHaveCount(0) + + // 自我保护:admin 自身行的编辑对话框中角色选择禁用(服务端 400 的 UI 前置) + await page.getByTestId('users-query-input').fill('') + await page.getByTestId('users-search-btn').click() + await userRow(page, account).getByTestId('user-edit-btn').click() + await expect(page.getByTestId('user-edit-dialog')).toBeVisible() + // ElSelect 禁用态落在内层 .el-select__wrapper 的 is-disabled class + //(根 div 与 toBeDisabled 均不适配) + await expect(page.getByTestId('user-edit-role').locator('.el-select__wrapper')).toHaveClass( + /is-disabled/ + ) + await page.getByTestId('user-edit-cancel').click() + await expect(page.getByTestId('user-edit-dialog')).toBeHidden() +}) + +test('重置密码(生成)→ 新密码可登录 → 停用 → 既有 token 401 → 状态过滤联动', async ({ page }) => { + await loginAsAdmin(page) + await gotoUsers(page) + + // 定位治理目标行(过滤收敛到单行) + await page.getByTestId('users-query-input').fill(GOV_ACCOUNT) + await page.getByTestId('users-search-btn').click() + const govRow = userRow(page, GOV_ACCOUNT) + await expect(govRow).toBeVisible() + + // 上一轮运行可能已停用(锁定用户无法登录)——先解锁恢复基线,保证幂等重跑 + if (await govRow.getByTestId('user-unlock-btn').isVisible()) { + await govRow.getByTestId('user-unlock-btn').click() + await page.locator('.el-message-box').getByRole('button', { name: '确定' }).click() + await expect(govRow.getByTestId('user-deactivate-btn')).toBeVisible({ timeout: 20_000 }) + } + + // ── 重置密码:生成模式(默认)→ 一次性展示 + 复制 ── + await govRow.getByTestId('user-reset-btn').click() + await expect(page.getByTestId('user-reset-dialog')).toBeVisible() + await page.getByTestId('user-reset-submit').click() + await expect(page.getByTestId('user-reset-result')).toBeVisible() + const newPassword = ((await page.getByTestId('user-reset-password').textContent()) ?? '').trim() + expect(newPassword.length).toBeGreaterThanOrEqual(8) + await page.getByTestId('user-reset-copy').click() + // 127.0.0.1 是安全上下文:剪贴板内容与展示一致 + const clipped = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipped).toBe(newPassword) + await page.getByTestId('user-reset-close').click() + await expect(page.getByTestId('user-reset-dialog')).toBeHidden() + + // ── 新密码 API 登录(重置已撤销占位凭据语义)── + const loginRes = await page.request.post('/v1/auth/password', { + data: { account: GOV_ACCOUNT, password: newPassword } + }) + expect(loginRes.ok()).toBeTruthy() + const { sessionToken } = (await loginRes.json()) as { sessionToken: string } + const authed = { Authorization: `Bearer ${sessionToken}` } + const before = await page.request.get('/v1/albums', { headers: authed }) + expect(before.status()).toBe(200) + + // ── 停用(确认框:会话撤销警告)→ 行内切换为「解锁」── + await govRow.getByTestId('user-deactivate-btn').click() + await page.locator('.el-message-box').getByRole('button', { name: '确定' }).click() + await expect(govRow.getByTestId('user-unlock-btn')).toBeVisible({ timeout: 20_000 }) + + // 停用即撤会话:该用户既有 token 即刻 401 + const after = await page.request.get('/v1/albums', { headers: authed }) + expect(after.status()).toBe(401) + + // ── 状态桶过滤联动:locked + gov 前缀 → 可见;locked + staff-e2e → 空 ── + await page.getByTestId('users-status-select').click() + await page.locator('.el-select-dropdown__item:visible', { hasText: '本地锁定' }).click() + await page.getByTestId('users-search-btn').click() + await expect(userRow(page, GOV_ACCOUNT)).toBeVisible() + await page.getByTestId('users-query-input').fill('staff-e2e') + await page.getByTestId('users-search-btn').click() + await expect(page.getByTestId('users-table').locator('tbody tr')).toHaveCount(0) +}) + +test('同步页:手动补拉 → 终态计数刷新 → 运行历史行点击打开失败明细抽屉', async ({ page }) => { + await loginAsAdmin(page) + await page.goto('/#/system/user-sync') + await expect(page.getByTestId('sync-status-card')).toBeVisible() + await expect(page.getByTestId('sync-runs-table')).toBeVisible() + + // 手动补拉(202 受理 → 页内轮询至批次终态;idle 是受理前的初值,须等到 + // 终态标签而非「非 running」——受理与批次落账之间有秒级窗口) + await page.getByTestId('sync-trigger-btn').click() + await expect + .poll(async () => page.getByTestId('sync-state-tag').getAttribute('data-state'), { + timeout: 30_000 + }) + .toMatch(/^(success|partial)$/) + // mock 源默认健康场景:首轮 added=3 / 已有水位时增量幂等,终态均应为 success + + // 状态刷新:最近批次计数 chips 可见、源水位已提交(非 '-') + await expect(page.getByTestId('sync-counts')).toBeVisible() + const watermark = (await page.getByTestId('sync-source-version').textContent()) ?? '' + expect(watermark.trim()).not.toBe('-') + + // 运行历史:首行为本手动批次 + const firstRun = page.getByTestId('sync-runs-table').locator('tbody tr').first() + await expect(firstRun).toContainText('手动') + + // 行点击 → 失败明细抽屉(成功批次 → 空明细) + await firstRun.click() + await expect(page.getByTestId('sync-errors-drawer')).toBeVisible() + await expect(page.getByTestId('sync-errors-empty').first()).toBeVisible() +}) diff --git a/apps/admin/src/api/adminUsers.ts b/apps/admin/src/api/adminUsers.ts new file mode 100644 index 0000000..5a6d2ca --- /dev/null +++ b/apps/admin/src/api/adminUsers.ts @@ -0,0 +1,44 @@ +/** + * 账号治理接口(对齐 @photo/contracts tags [admin]:listAdminUsers 管理列表 + * 模式 / updateAdminUser / resetAdminUserPassword / activateAdminUser / + * deactivateAdminUser) + * + * - 管理列表不带 accountPrefix(带了即切到邀请前缀查询模式,见 api/albums + * fetchLookupUsers):全量用户 + query/status 过滤 + createdAt+ID 倒序游标 + * - PATCH 部分语义(role/localLocked/displayName;sourceStatus 只读展示) + * - 自我保护在服务层(自改角色/自锁 400 VALIDATION_FAILED,http 层已本地化) + * - activate 仅管 credentialStatus 维度(pending→active,初始凭据经 + * reset-password 出具);deactivate = localLocked=true + 撤全部会话 + */ +import request from '@/utils/http' + +/** 管理列表(全量用户 + query/status 过滤 + 游标分页) */ +export function fetchAdminUsers(params: Api.AdminUser.ListParams) { + return request.get({ url: '/v1/admin/users', params }) +} + +/** 更新用户治理属性(部分更新;服务端幂等 no-op 不落审计) */ +export function fetchUpdateAdminUser(userId: string, data: Api.AdminUser.UpdatePayload) { + return request.patch({ url: `/v1/admin/users/${userId}`, data }) +} + +/** 管理员重置用户密码(指定或服务端生成;撤销目标全部既有会话) */ +export function fetchResetAdminUserPassword( + userId: string, + data: Api.AdminUser.ResetPasswordPayload +) { + return request.post({ + url: `/v1/admin/users/${userId}/reset-password`, + data + }) +} + +/** 激活待激活用户(credentialStatus pending→active,幂等;不出凭据) */ +export function fetchActivateAdminUser(userId: string) { + return request.post({ url: `/v1/admin/users/${userId}/activate` }) +} + +/** 停用用户(localLocked=true + 撤销全部会话,幂等;上游状态不动) */ +export function fetchDeactivateAdminUser(userId: string) { + return request.post({ url: `/v1/admin/users/${userId}/deactivate` }) +} diff --git a/apps/admin/src/api/userSync.ts b/apps/admin/src/api/userSync.ts new file mode 100644 index 0000000..66be265 --- /dev/null +++ b/apps/admin/src/api/userSync.ts @@ -0,0 +1,36 @@ +/** + * 用户同步管理接口(对齐 @photo/contracts tags [admin]:getUserSyncStatus / + * listUserSyncRuns / triggerUserSyncRun / listUserSyncErrors) + * + * - status/runs/errors 为纯读;runs 为 startedAt+ID 倒序键集游标 + * - POST runs → 202 受理(异步执行;运行中批次复用其 runId),结果经 + * status/runs 轮询查询——页面侧的等待/轮询逻辑见 utils/system/syncPolling + * - errors 按 runId 查单批次坏行明细(未知批次=空数组,契约无 404) + */ +import request from '@/utils/http' + +/** 同步引擎状态 + 最近批次计数 */ +export function fetchUserSyncStatus() { + return request.get({ url: '/v1/admin/user-sync/status' }) +} + +/** 运行历史(游标分页) */ +export function fetchUserSyncRuns(params: { cursor?: string; limit?: number }) { + return request.get({ + url: '/v1/admin/user-sync/runs', + params + }) +} + +/** 手动触发同步补拉(202 受理;与定时共用互斥锁) */ +export function fetchTriggerUserSyncRun() { + return request.post({ url: '/v1/admin/user-sync/runs' }) +} + +/** 单批次失败明细(runId 必填) */ +export function fetchUserSyncErrors(runId: string) { + return request.get({ + url: '/v1/admin/user-sync/errors', + params: { runId } + }) +} diff --git a/apps/admin/src/locales/langs/en.json b/apps/admin/src/locales/langs/en.json index a8d46d1..5681709 100644 --- a/apps/admin/src/locales/langs/en.json +++ b/apps/admin/src/locales/langs/en.json @@ -230,6 +230,11 @@ "outside": { "title": "Outside" }, + "system": { + "title": "Accounts & Sync", + "users": "Account Management", + "userSync": "User Sync" + }, "albums": { "title": "Albums", "manage": "Album Management", @@ -436,6 +441,152 @@ "empty": "No photos" } }, + "usersManage": { + "list": { + "title": "Account Management", + "loadMore": "Load more", + "noMore": "No more", + "empty": "No users", + "loadFailed": "Failed to load", + "retry": "Retry", + "selfTag": "Me", + "colAccount": "Account", + "colDisplayName": "Display Name", + "colRole": "Role", + "colSourceStatus": "Source", + "colCredentialStatus": "Credential", + "colLocalLocked": "Local Lock", + "colCreatedAt": "Created At", + "colActions": "Actions", + "locked": "Locked", + "unlocked": "Unlocked", + "actionEdit": "Edit", + "actionResetPassword": "Reset Password", + "actionActivate": "Activate", + "actionDeactivate": "Deactivate", + "actionUnlock": "Unlock" + }, + "filter": { + "queryPlaceholder": "Account/name prefix", + "statusAll": "All statuses", + "search": "Search", + "reset": "Reset" + }, + "role": { + "staff": "Staff", + "admin": "Admin" + }, + "source": { + "local": "Local", + "active": "Active", + "disabled": "Upstream Disabled" + }, + "credential": { + "active": "Active", + "pending": "Pending" + }, + "bucket": { + "active": "Active", + "pending": "Pending", + "disabled": "Upstream Disabled", + "locked": "Locally Locked" + }, + "edit": { + "title": "Edit account: {account}", + "displayNamePlaceholder": "Display name (1-128 chars)", + "lockedHint": "Locking equals deactivation: revokes all sessions of this user; independent of upstream status", + "selfGuardHint": "You cannot change your own role or lock yourself (server rejects likewise)", + "noChanges": "No changes", + "submit": "Save", + "success": "Saved" + }, + "reset": { + "title": "Reset password: {account}", + "revokeHint": "All existing sessions of this user will be revoked immediately", + "modeLabel": "Mode", + "modeGenerate": "Generate random password", + "modeCustom": "Specify password", + "customLabel": "New password", + "customPlaceholder": "At least 8 characters", + "onceWarning": "Shown only once — copy and hand it over to the user", + "copy": "Copy", + "copied": "Copied to clipboard", + "submit": "Reset", + "handedOver": "Handed over", + "errors": { + "required": "Please enter a new password", + "length": "Password must be 8-128 characters" + } + }, + "confirm": { + "activateBody": "Activate user {account}? An initial password must be issued via \"Reset Password\" before login", + "deactivateBody": "Deactivate user {account}? All sessions will be revoked (existing tokens fail immediately)", + "unlockBody": "Unlock {account}? Upstream disabled status is not affected" + }, + "toast": { + "activated": "Activated (reset password to issue initial credentials)", + "deactivated": "Deactivated and all sessions revoked", + "unlocked": "Local lock released" + } + }, + "userSync": { + "status": { + "title": "User Sync Status", + "state": "Engine state", + "lastRunAt": "Last run", + "lastSuccessAt": "Last success", + "sourceVersion": "Watermark", + "noRuns": "Sync has never run", + "trigger": "Sync Now", + "triggerAccepted": "Accepted, syncing…", + "runFinished": "Sync finished ({state})", + "runFailed": "Sync failed; check run history", + "waitFailed": "Timed out or failed waiting for the result; refresh status", + "loadFailed": "Failed to load status", + "retry": "Retry" + }, + "state": { + "idle": "Idle", + "running": "Running", + "success": "Success", + "partial": "Partial", + "failed": "Failed" + }, + "trigger": { + "cron": "Cron", + "manual": "Manual" + }, + "counts": { + "added": "Added", + "updated": "Updated", + "disabled": "Disabled", + "failed": "Failed" + }, + "runs": { + "title": "Run History (click a row for failures)", + "loadMore": "Load more", + "noMore": "No more", + "empty": "No runs yet", + "loadFailed": "Failed to load", + "retry": "Retry", + "colTrigger": "Trigger", + "colState": "State", + "colCounts": "Counts", + "colStartedAt": "Started At", + "colFinishedAt": "Finished At", + "colError": "Error" + }, + "errors": { + "title": "Failures (run {runId})", + "loadFailed": "Failed to load", + "retry": "Retry", + "empty": "No failures in this run", + "colExternalId": "External User", + "colCode": "Code", + "colDetail": "Detail", + "colCreatedAt": "Recorded At" + } + }, "apiMsg": { "AUTH_INVALID_CREDENTIALS": "Invalid account or password", "AUTH_RATE_LIMITED": "Too many failed login attempts; try again later", diff --git a/apps/admin/src/locales/langs/zh.json b/apps/admin/src/locales/langs/zh.json index 7711669..21c4b03 100644 --- a/apps/admin/src/locales/langs/zh.json +++ b/apps/admin/src/locales/langs/zh.json @@ -230,6 +230,11 @@ "outside": { "title": "内嵌页面" }, + "system": { + "title": "账号与同步", + "users": "账号管理", + "userSync": "用户同步" + }, "albums": { "title": "相册管理", "manage": "相册管理", @@ -436,6 +441,152 @@ "empty": "暂无照片" } }, + "usersManage": { + "list": { + "title": "账号管理", + "loadMore": "加载更多", + "noMore": "没有更多了", + "empty": "暂无用户", + "loadFailed": "加载失败", + "retry": "重试", + "selfTag": "我", + "colAccount": "账号", + "colDisplayName": "展示姓名", + "colRole": "角色", + "colSourceStatus": "来源状态", + "colCredentialStatus": "凭据状态", + "colLocalLocked": "本地锁定", + "colCreatedAt": "创建时间", + "colActions": "操作", + "locked": "已锁定", + "unlocked": "未锁定", + "actionEdit": "编辑", + "actionResetPassword": "重置密码", + "actionActivate": "激活", + "actionDeactivate": "停用", + "actionUnlock": "解锁" + }, + "filter": { + "queryPlaceholder": "账号/姓名前缀", + "statusAll": "全部状态", + "search": "查询", + "reset": "重置" + }, + "role": { + "staff": "员工", + "admin": "管理员" + }, + "source": { + "local": "本地", + "active": "正常", + "disabled": "上游停用" + }, + "credential": { + "active": "已激活", + "pending": "待激活" + }, + "bucket": { + "active": "正常", + "pending": "待激活", + "disabled": "上游停用", + "locked": "本地锁定" + }, + "edit": { + "title": "编辑账号:{account}", + "displayNamePlaceholder": "展示姓名(1-128 字符)", + "lockedHint": "锁定即停用:撤销该用户全部会话;与上游状态独立", + "selfGuardHint": "不能修改自己的角色或锁定自己(服务端同口径拒绝)", + "noChanges": "没有变更", + "submit": "保存", + "success": "已保存" + }, + "reset": { + "title": "重置密码:{account}", + "revokeHint": "重置后该用户全部既有会话立即失效", + "modeLabel": "重置方式", + "modeGenerate": "生成随机密码", + "modeCustom": "指定新密码", + "customLabel": "新密码", + "customPlaceholder": "至少 8 位", + "onceWarning": "新密码仅此一次展示,请复制转交给用户", + "copy": "复制", + "copied": "已复制到剪贴板", + "submit": "重置", + "handedOver": "我已转交", + "errors": { + "required": "请输入新密码", + "length": "密码长度须为 8-128 位" + } + }, + "confirm": { + "activateBody": "激活用户 {account}?激活后需经「重置密码」出具初始凭据方可登录", + "deactivateBody": "停用用户 {account}?其全部会话将被撤销(既有令牌即刻失效)", + "unlockBody": "解除 {account} 的本地锁定?上游停用状态不受影响" + }, + "toast": { + "activated": "已激活(待重置密码出初始凭据)", + "deactivated": "已停用并撤销全部会话", + "unlocked": "已解除本地锁定" + } + }, + "userSync": { + "status": { + "title": "用户同步状态", + "state": "引擎状态", + "lastRunAt": "最近批次", + "lastSuccessAt": "最近成功", + "sourceVersion": "源水位", + "noRuns": "从未运行过同步", + "trigger": "手动补拉", + "triggerAccepted": "已受理,正在同步…", + "runFinished": "同步完成({state})", + "runFailed": "同步失败,请查看运行历史", + "waitFailed": "等待同步结果超时或出错,请刷新状态", + "loadFailed": "状态加载失败", + "retry": "重试" + }, + "state": { + "idle": "空闲", + "running": "同步中", + "success": "成功", + "partial": "部分成功", + "failed": "失败" + }, + "trigger": { + "cron": "定时", + "manual": "手动" + }, + "counts": { + "added": "新增", + "updated": "更新", + "disabled": "停用", + "failed": "失败" + }, + "runs": { + "title": "运行历史(点击行查看失败明细)", + "loadMore": "加载更多", + "noMore": "没有更多了", + "empty": "暂无运行记录", + "loadFailed": "加载失败", + "retry": "重试", + "colTrigger": "触发", + "colState": "状态", + "colCounts": "计数", + "colStartedAt": "开始时间", + "colFinishedAt": "结束时间", + "colError": "错误摘要" + }, + "errors": { + "title": "失败明细(批次 {runId})", + "loadFailed": "加载失败", + "retry": "重试", + "empty": "该批次无失败记录", + "colExternalId": "上游用户", + "colCode": "分类码", + "colDetail": "详情", + "colCreatedAt": "记录时间" + } + }, "apiMsg": { "AUTH_INVALID_CREDENTIALS": "账号或密码错误", "AUTH_RATE_LIMITED": "登录失败次数过多,请稍后再试", diff --git a/apps/admin/src/router/modules/index.ts b/apps/admin/src/router/modules/index.ts index ddfb468..444906e 100644 --- a/apps/admin/src/router/modules/index.ts +++ b/apps/admin/src/router/modules/index.ts @@ -1,9 +1,10 @@ import { AppRouteRecord } from '@/types/router' import { albumsRoutes } from './albums' +import { systemRoutes } from './system' /** * 导出所有模块化路由 * @photo/admin 裁剪:模板演示路由(dashboard/template/widgets/examples/system/ * article/result/exception/safeguard/help)已移除,仅保留业务菜单。 */ -export const routeModules: AppRouteRecord[] = [albumsRoutes] +export const routeModules: AppRouteRecord[] = [albumsRoutes, systemRoutes] diff --git a/apps/admin/src/router/modules/system.ts b/apps/admin/src/router/modules/system.ts new file mode 100644 index 0000000..e945c31 --- /dev/null +++ b/apps/admin/src/router/modules/system.ts @@ -0,0 +1,36 @@ +/** + * 账号治理路由(P4a Task 8:账号管理 + 用户同步) + */ +import { AppRouteRecord } from '@/types/router' + +export const systemRoutes: AppRouteRecord = { + name: 'System', + path: '/system', + component: '/index/index', + meta: { + title: 'menus.system.title', + icon: 'ri:shield-user-line' + }, + children: [ + { + path: 'users', + name: 'SystemUsers', + component: '/system/users', + meta: { + title: 'menus.system.users', + icon: 'ri:user-settings-line', + keepAlive: false + } + }, + { + path: 'user-sync', + name: 'SystemUserSync', + component: '/system/user-sync', + meta: { + title: 'menus.system.userSync', + icon: 'ri:refresh-line', + keepAlive: false + } + } + ] +} diff --git a/apps/admin/src/types/api/adminUser.d.ts b/apps/admin/src/types/api/adminUser.d.ts new file mode 100644 index 0000000..85cd4a1 --- /dev/null +++ b/apps/admin/src/types/api/adminUser.d.ts @@ -0,0 +1,157 @@ +/** + * 账号治理与用户同步域接口类型(对齐 @photo/contracts openapi.yaml tags + * [admin]:AdminUser / AdminUserList / AdminPasswordResetResponse 与 + * UserSyncStatus / UserSyncRun / UserSyncErrorList) + * + * 与 album.d.ts 同用全局 Api 命名空间(declare namespace 自动合并)。 + * 契约要点: + * - 管理列表(未带 accountPrefix 的 GET /admin/users):全量用户 + query/status + * 过滤 + createdAt+ID 倒序键集游标(与相册域同一 CursorPageMeta 不变式) + * - AdminUser.sourceStatus 只读(上游状态仅同步任务维护);localLocked 与 + * 上游状态独立(技术方案 3.1A:本地锁定不因上游恢复解除) + * - 重置密码响应 newPassword 仅此一次返回(转交用户后不再可见) + * - 同步状态 counts = 最近批次计数(从未运行为 null) + * + * @module types/api/adminUser + */ + +declare namespace Api { + /** 账号治理域(P4a Task 6 端点) */ + namespace AdminUser { + /** 公司角色(相册角色另表) */ + type Role = 'staff' | 'admin' + + /** 来源状态:local=本地创建;active/disabled=同步源状态 */ + type SourceStatus = 'local' | 'active' | 'disabled' + + /** 凭据状态:active=可登录;pending=同步创建待激活 */ + type CredentialStatus = 'active' | 'pending' + + /** + * 管理列表治理状态过滤(单值归类,后端优先级 pending > disabled > + * locked > active;多态用户只落一个桶) + */ + type StatusFilter = 'active' | 'pending' | 'disabled' | 'locked' + + /** 用户治理条目(不含邮箱/手机/密码等凭据字段) */ + interface Item { + id: string + account: string + displayName: string + role: Role + sourceStatus: SourceStatus + localLocked: boolean + credentialStatus: CredentialStatus + locale: string | null + createdAt: string + } + + /** 管理列表响应(游标分页) */ + interface ListResponse { + items: Item[] + nextCursor: string | null + hasMore: boolean + } + + /** 管理列表查询参数(双模式分流:不带 accountPrefix 即管理列表模式) */ + interface ListParams { + /** account/displayName 二进制前缀(1..64,大小写敏感,非全文搜索) */ + query?: string + /** 治理状态单值过滤 */ + status?: StatusFilter + cursor?: string + limit?: number + } + + /** 治理属性部分更新(仅提交字段被修改;sourceStatus 不在可更新集) */ + interface UpdatePayload { + role?: Role + localLocked?: boolean + displayName?: string + } + + /** 重置密码请求(newPassword 缺省由服务端生成 16 位随机密码) */ + interface ResetPasswordPayload { + newPassword?: string + } + + /** 重置结果(newPassword 仅此一次返回) */ + interface ResetPasswordResponse { + newPassword: string + } + } + + /** 用户同步域(P4a Task 7 端点) */ + namespace UserSync { + /** 引擎状态:idle=空闲/从未运行;其余为最近批次结局 */ + type State = 'idle' | 'running' | 'success' | 'partial' | 'failed' + + /** 批次状态(partial=部分行失败,其余行照常应用) */ + type RunState = 'running' | 'success' | 'partial' | 'failed' + + /** 触发方式 */ + type Trigger = 'cron' | 'manual' + + /** 批次计数 */ + interface Counts { + /** 新增用户数(建为待激活) */ + added: number + /** 幂等更新数(改名等) */ + updated: number + /** 上游停用数(撤会话) */ + disabled: number + /** 失败行数(明细见 /admin/user-sync/errors) */ + failed: number + } + + /** 同步引擎状态 */ + interface Status { + lastRunAt: string | null + lastSuccessAt: string | null + state: State + /** 已提交的源水位(版本游标);从未同步为 null */ + sourceVersion: number | null + /** 最近批次计数;从未运行为 null */ + counts: Counts | null + } + + /** 同步批次条目 */ + interface RunItem { + id: string + trigger: Trigger + state: RunState + /** 本批次已提交的源水位(同步游标,非分页游标);未提交为 null */ + cursor: number | null + counts: Counts + startedAt: string + finishedAt: string | null + error: string | null + } + + /** 运行历史响应(startedAt+ID 倒序键集游标) */ + interface RunListResponse { + items: RunItem[] + nextCursor: string | null + hasMore: boolean + } + + /** 手动触发受理结果(202;运行中批次复用其 runId) */ + interface RunCreatedResponse { + runId: string + } + + /** 同步失败行明细(坏行记录) */ + interface ErrorItem { + runId: string + externalUserId: string + code: string + detail: string + createdAt: string + } + + /** 失败明细列表(无游标即取即用) */ + interface ErrorListResponse { + items: ErrorItem[] + } + } +} diff --git a/apps/admin/src/types/import/components.d.ts b/apps/admin/src/types/import/components.d.ts index 9e5f528..d48eef0 100644 --- a/apps/admin/src/types/import/components.d.ts +++ b/apps/admin/src/types/import/components.d.ts @@ -80,6 +80,7 @@ declare module 'vue' { ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] ElPopover: typeof import('element-plus/es')['ElPopover'] + ElRadio: typeof import('element-plus/es')['ElRadio'] ElRadioButton: typeof import('element-plus/es')['ElRadioButton'] ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElScrollbar: typeof import('element-plus/es')['ElScrollbar'] diff --git a/apps/admin/src/utils/system/syncPolling.spec.ts b/apps/admin/src/utils/system/syncPolling.spec.ts new file mode 100644 index 0000000..9d4b4dd --- /dev/null +++ b/apps/admin/src/utils/system/syncPolling.spec.ts @@ -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((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() + }) +}) diff --git a/apps/admin/src/utils/system/syncPolling.ts b/apps/admin/src/utils/system/syncPolling.ts new file mode 100644 index 0000000..4f961e3 --- /dev/null +++ b/apps/admin/src/utils/system/syncPolling.ts @@ -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 { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** + * 轮询同步状态直到批次离开 running(首次查询即终态则立刻 resolve)。 + * 超时 reject(Error.message = 'timeout');fetchStatus 的网络错误向上抛。 + */ +export function waitSyncTerminal( + fetchStatus: () => Promise, + options: WaitSyncOptions = {} +): Promise { + const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const deadline = Date.now() + timeoutMs + return (async function poll(): Promise { + 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, + intervalMs = 10_000 +): AutoRefreshHandle { + let timer: ReturnType | null = null + let inFlight = false + + async function run(): Promise { + 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 + } + } +} diff --git a/apps/admin/src/utils/system/userAdmin.spec.ts b/apps/admin/src/utils/system/userAdmin.spec.ts new file mode 100644 index 0000000..ac452db --- /dev/null +++ b/apps/admin/src/utils/system/userAdmin.spec.ts @@ -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' } + }) + }) +}) diff --git a/apps/admin/src/utils/system/userAdmin.ts b/apps/admin/src/utils/system/userAdmin.ts new file mode 100644 index 0000000..41eed86 --- /dev/null +++ b/apps/admin/src/utils/system/userAdmin.ts @@ -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 +): 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 = + { + 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 } } +} diff --git a/apps/admin/src/views/system/user-sync/composables/useSyncErrors.spec.ts b/apps/admin/src/views/system/user-sync/composables/useSyncErrors.spec.ts new file mode 100644 index 0000000..e6ca8f2 --- /dev/null +++ b/apps/admin/src/views/system/user-sync/composables/useSyncErrors.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest' +import { useSyncErrors } from './useSyncErrors' + +const errorOf = (runId: string, externalId: string): Api.UserSync.ErrorItem => ({ + runId, + externalUserId: externalId, + code: 'invalid_record', + detail: `missing displayName for ${externalId}`, + createdAt: '2026-09-20T00:00:00.000Z' +}) + +describe('useSyncErrors 失败明细抽屉', () => { + it('open(runId) 拉取该批次明细并打开抽屉', async () => { + const list = vi.fn().mockResolvedValue({ items: [errorOf('r1', 'mock-u-001')] }) + const drawer = useSyncErrors({ list }) + await drawer.open('r1') + expect(list).toHaveBeenCalledWith('r1') + expect(drawer.drawerVisible.value).toBe(true) + expect(drawer.drawerRunId.value).toBe('r1') + expect(drawer.items.value.map((item) => item.externalUserId)).toEqual(['mock-u-001']) + }) + + it('切换批次整表替换;close 清空并收起', async () => { + const list = vi + .fn() + .mockResolvedValueOnce({ items: [errorOf('r1', 'mock-u-001'), errorOf('r1', 'mock-u-002')] }) + .mockResolvedValueOnce({ items: [errorOf('r2', 'mock-u-003')] }) + const drawer = useSyncErrors({ list }) + await drawer.open('r1') + expect(drawer.items.value).toHaveLength(2) + await drawer.open('r2') + expect(drawer.drawerRunId.value).toBe('r2') + expect(drawer.items.value.map((item) => item.externalUserId)).toEqual(['mock-u-003']) + drawer.close() + expect(drawer.drawerVisible.value).toBe(false) + expect(drawer.items.value).toEqual([]) + expect(drawer.drawerRunId.value).toBeNull() + }) + + it('拉取失败置 error(抽屉保持打开),load 重试成功后恢复', async () => { + const list = vi + .fn() + .mockRejectedValueOnce(new Error('network')) + .mockResolvedValueOnce({ items: [errorOf('r1', 'mock-u-009')] }) + const drawer = useSyncErrors({ list }) + await drawer.open('r1') + expect(drawer.error.value).toBeInstanceOf(Error) + expect(drawer.items.value).toEqual([]) + expect(drawer.drawerVisible.value).toBe(true) + await drawer.load() + expect(drawer.error.value).toBeNull() + expect(drawer.items.value).toHaveLength(1) + }) +}) diff --git a/apps/admin/src/views/system/user-sync/composables/useSyncErrors.ts b/apps/admin/src/views/system/user-sync/composables/useSyncErrors.ts new file mode 100644 index 0000000..1316f72 --- /dev/null +++ b/apps/admin/src/views/system/user-sync/composables/useSyncErrors.ts @@ -0,0 +1,62 @@ +/** + * 失败明细抽屉状态(逻辑层抽离,vitest 覆盖按批次打开/关闭/重试) + * + * open(runId) 拉取该批次坏行;切批次整表替换;close 清空(items 归零, + * 下次打开重新拉取——错误明细无游标,量小即取即用)。 + */ +import { computed, ref, shallowRef } from 'vue' +import { fetchUserSyncErrors } from '@/api/userSync' + +export interface SyncErrorsApi { + list: (runId: string) => Promise +} + +export function useSyncErrors(api: SyncErrorsApi = { list: fetchUserSyncErrors }) { + /** 当前抽屉指向的批次(null = 关闭) */ + const drawerRunId = ref(null) + const items = shallowRef([]) + const loading = ref(false) + const error = ref(null) + + const drawerVisible = computed({ + get: () => drawerRunId.value !== null, + set: (visible: boolean) => { + if (!visible) close() + } + }) + + /** 拉取当前批次明细(重试复用同一入口) */ + async function load(): Promise { + const runId = drawerRunId.value + if (runId === null || loading.value) return + loading.value = true + error.value = null + try { + const res = await api.list(runId) + items.value = res.items + } catch (e) { + error.value = e as Error + } finally { + loading.value = false + } + } + + /** 打开抽屉并拉取该批次(正在拉取时切批次:等完成后以下一次 open 为准) */ + async function open(runId: string): Promise { + if (drawerRunId.value === runId) { + await load() + return + } + drawerRunId.value = runId + items.value = [] + await load() + } + + function close(): void { + drawerRunId.value = null + items.value = [] + error.value = null + } + + return { drawerVisible, drawerRunId, items, loading, error, open, load, close } +} diff --git a/apps/admin/src/views/system/user-sync/index.vue b/apps/admin/src/views/system/user-sync/index.vue new file mode 100644 index 0000000..e3a6bd1 --- /dev/null +++ b/apps/admin/src/views/system/user-sync/index.vue @@ -0,0 +1,316 @@ + + + + diff --git a/apps/admin/src/views/system/users/ResetPasswordDialog.vue b/apps/admin/src/views/system/users/ResetPasswordDialog.vue new file mode 100644 index 0000000..7992a19 --- /dev/null +++ b/apps/admin/src/views/system/users/ResetPasswordDialog.vue @@ -0,0 +1,135 @@ + + + + diff --git a/apps/admin/src/views/system/users/UserEditDialog.vue b/apps/admin/src/views/system/users/UserEditDialog.vue new file mode 100644 index 0000000..e040544 --- /dev/null +++ b/apps/admin/src/views/system/users/UserEditDialog.vue @@ -0,0 +1,129 @@ + + + + diff --git a/apps/admin/src/views/system/users/composables/useResetPassword.spec.ts b/apps/admin/src/views/system/users/composables/useResetPassword.spec.ts new file mode 100644 index 0000000..3fb5dbe --- /dev/null +++ b/apps/admin/src/views/system/users/composables/useResetPassword.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' +import { useResetPassword } from './useResetPassword' + +describe('useResetPassword 生成密码一次性展示', () => { + it('generate 提交成功 → step=result,password 为服务端返回值', async () => { + const reset = vi.fn().mockResolvedValue({ newPassword: 'Gen-Random-Pass' }) + const flow = useResetPassword({ reset }) + const ok = await flow.submit('u1', 'generate', '') + expect(ok).toBe(true) + expect(reset).toHaveBeenCalledWith('u1', {}) + expect(flow.step.value).toBe('result') + expect(flow.password.value).toBe('Gen-Random-Pass') + }) + + it('dismiss 即焚:回到 form 且 password 清空(关闭后不再可见)', async () => { + const reset = vi.fn().mockResolvedValue({ newPassword: 'Once-Only-Pass' }) + const flow = useResetPassword({ reset }) + await flow.submit('u1', 'generate', '') + flow.dismiss() + expect(flow.step.value).toBe('form') + expect(flow.password.value).toBe('') + }) + + it('custom 非法(空/长度越界)→ false 且停留 form,不发请求', async () => { + const reset = vi.fn() + const flow = useResetPassword({ reset }) + expect(await flow.submit('u1', 'custom', ' ')).toBe(false) + expect(await flow.submit('u1', 'custom', 'short')).toBe(false) + expect(flow.step.value).toBe('form') + expect(reset).not.toHaveBeenCalled() + }) + + it('custom 合法 → 载荷携带 newPassword(trim 后)', async () => { + const reset = vi.fn().mockResolvedValue({ newPassword: 'hand-made-pass' }) + const flow = useResetPassword({ reset }) + const ok = await flow.submit('u1', 'custom', ' hand-made-pass ') + expect(ok).toBe(true) + expect(reset).toHaveBeenCalledWith('u1', { newPassword: 'hand-made-pass' }) + expect(flow.password.value).toBe('hand-made-pass') + }) +}) diff --git a/apps/admin/src/views/system/users/composables/useResetPassword.ts b/apps/admin/src/views/system/users/composables/useResetPassword.ts new file mode 100644 index 0000000..449924e --- /dev/null +++ b/apps/admin/src/views/system/users/composables/useResetPassword.ts @@ -0,0 +1,51 @@ +/** + * 重置密码流程状态机(逻辑层抽离,vitest 覆盖「生成密码仅展示一次」语义) + * + * step: form(选择 指定/生成)→ result(一次性展示 newPassword) + * dismiss 后 password 立即清空(组件 destroy-on-close 兜底,双保险): + * 契约 newPassword 仅响应返回一次,页面不留存、不复查。 + */ +import { ref } from 'vue' +import { fetchResetAdminUserPassword } from '@/api/adminUsers' +import { buildResetPasswordPayload, type ResetPasswordMode } from '@/utils/system/userAdmin' + +export interface ResetPasswordApi { + reset: ( + userId: string, + payload: Api.AdminUser.ResetPasswordPayload + ) => Promise +} + +/** 校验错误文案键由调用方渲染(返回 false 时表单停留 form 步骤) */ +export function useResetPassword(api: ResetPasswordApi = { reset: fetchResetAdminUserPassword }) { + const step = ref<'form' | 'result'>('form') + /** 一次性展示的新密码(仅 result 步骤非空;dismiss 即焚) */ + const password = ref('') + const submitting = ref(false) + + async function submit( + userId: string, + mode: ResetPasswordMode, + customPassword: string + ): Promise { + const built = buildResetPasswordPayload(mode, customPassword) + if (!built.ok) return false + submitting.value = true + try { + const res = await api.reset(userId, built.payload) + password.value = res.newPassword + step.value = 'result' + return true + } finally { + submitting.value = false + } + } + + /** 关闭结果(或表单):密码即刻清空,回到初始态 */ + function dismiss(): void { + step.value = 'form' + password.value = '' + } + + return { step, password, submitting, submit, dismiss } +} diff --git a/apps/admin/src/views/system/users/index.vue b/apps/admin/src/views/system/users/index.vue new file mode 100644 index 0000000..202bc52 --- /dev/null +++ b/apps/admin/src/views/system/users/index.vue @@ -0,0 +1,324 @@ + + + +