Files
photo-live/apps/admin/src/views/system/users/index.vue
T
2026-09-22 18:36:16 +08:00

357 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 账号管理页(P4a Task 8;G4 后改 page 分页 + 同步档案列):过滤(前缀+
治理状态)+ 页码分页(page/pageSize/total)+ 同步档案(头像/工号/邮箱/
公司)展示 + 行内治理操作(编辑/重置密码/激活/停用/解锁)。自我保护双
保险:UI 对自身行禁用角色/锁定编辑,服务端 400 仍会经 http 层本地化弹出。 -->
<template>
<div class="p-5">
<ElCard data-test="users-card">
<template #header>
<div class="flex items-center justify-between">
<span>{{ $t('usersManage.list.title') }}</span>
<!-- 过滤栏:前缀查询(account/displayName)+ 治理状态单选 -->
<div class="flex flex-wrap items-center gap-2">
<ElInput
v-model="filters.query"
clearable
:placeholder="$t('usersManage.filter.queryPlaceholder')"
style="width: 220px"
data-test="users-query-input"
@keyup.enter="search"
/>
<ElSelect v-model="filters.status" style="width: 160px" data-test="users-status-select">
<ElOption
v-for="opt in statusOptions"
:key="opt.value"
:value="opt.value"
:label="opt.label"
:data-test="`users-status-option-${opt.value || 'all'}`"
/>
</ElSelect>
<ElButton type="primary" data-test="users-search-btn" @click="search">
{{ $t('usersManage.filter.search') }}
</ElButton>
<ElButton data-test="users-reset-btn" @click="resetFilters">
{{ $t('usersManage.filter.reset') }}
</ElButton>
</div>
</div>
</template>
<ElAlert
v-if="error && !items.length"
type="error"
:closable="false"
data-test="users-list-error"
>
<div class="flex items-center gap-2">
<span>{{ $t('usersManage.list.loadFailed') }}:{{ error.message }}</span>
<ElButton size="small" data-test="users-list-retry" @click="loadPage()">
{{ $t('usersManage.list.retry') }}
</ElButton>
</div>
</ElAlert>
<ElTable v-else v-loading="loading" :data="items" data-test="users-table">
<ElTableColumn :label="$t('usersManage.list.colAccount')" min-width="140">
<template #default="{ row }">
<div class="flex items-center gap-2">
<span class="font-medium" data-test="user-account">{{ row.account }}</span>
<ElTag v-if="isSelf(row)" size="small" type="primary">
{{ $t('usersManage.list.selfTag') }}
</ElTag>
</div>
</template>
</ElTableColumn>
<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">
{{ $t(`usersManage.role.${row.role}`) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('usersManage.list.colSourceStatus')" width="110">
<template #default="{ row }">
<ElTag
:type="sourceTagOf(row.sourceStatus)"
:effect="row.sourceStatus === 'local' ? 'plain' : 'light'"
>
{{ $t(`usersManage.source.${row.sourceStatus}`) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('usersManage.list.colCredentialStatus')" width="110">
<template #default="{ row }">
<ElTag :type="row.credentialStatus === 'pending' ? 'warning' : 'success'">
{{ $t(`usersManage.credential.${row.credentialStatus}`) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('usersManage.list.colLocalLocked')" width="100">
<template #default="{ row }">
<ElTag
:type="row.localLocked ? 'danger' : 'info'"
:effect="row.localLocked ? 'light' : 'plain'"
>
{{
row.localLocked ? $t('usersManage.list.locked') : $t('usersManage.list.unlocked')
}}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('usersManage.list.colCreatedAt')" width="160">
<template #default="{ row }">
<span>{{ formatDateTime(row.createdAt) }}</span>
</template>
</ElTableColumn>
<ElTableColumn :label="$t('usersManage.list.colActions')" width="280" fixed="right">
<template #default="{ row, $index }">
<ElButton link type="primary" data-test="user-edit-btn" @click="openEdit(row)">
{{ $t('usersManage.list.actionEdit') }}
</ElButton>
<ElButton link type="primary" data-test="user-reset-btn" @click="openReset(row)">
{{ $t('usersManage.list.actionResetPassword') }}
</ElButton>
<ElButton
v-if="row.credentialStatus === 'pending'"
link
type="success"
data-test="user-activate-btn"
@click="confirmActivate(row, $index)"
>
{{ $t('usersManage.list.actionActivate') }}
</ElButton>
<ElButton
v-if="row.localLocked"
link
type="success"
data-test="user-unlock-btn"
@click="confirmUnlock(row, $index)"
>
{{ $t('usersManage.list.actionUnlock') }}
</ElButton>
<ElButton
v-else
link
type="danger"
:disabled="isSelf(row)"
data-test="user-deactivate-btn"
@click="confirmDeactivate(row, $index)"
>
{{ $t('usersManage.list.actionDeactivate') }}
</ElButton>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty v-if="!loading" :description="$t('usersManage.list.empty')" />
</template>
</ElTable>
<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="[15, 20, 50, 100]"
:disabled="loading"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="loadPage"
@size-change="onSizeChange"
/>
</div>
</ElCard>
<UserEditDialog v-model="editVisible" :user="editingUser" @saved="onSaved" />
<ResetPasswordDialog v-model="resetVisible" :user="resettingUser" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage, ElMessageBox } from 'element-plus'
import UserEditDialog from './UserEditDialog.vue'
import ResetPasswordDialog from './ResetPasswordDialog.vue'
import {
fetchActivateAdminUser,
fetchDeactivateAdminUser,
fetchUpdateAdminUser,
fetchAdminUsers
} from '@/api/adminUsers'
import { useAdminUserPages } from './composables/useAdminUserPages'
import { formatDateTime } from '@/utils/albums/format'
import { useUserStore } from '@/store/modules/user'
defineOptions({ name: 'SystemUsers' })
const { t } = useI18n()
const userStore = useUserStore()
// ── 过滤(查询即整页重载:游标键集依赖过滤条件稳定) ──
const filters = reactive({ query: '', status: '' })
/** 过滤选项(computed:语言切换时标签随 i18n 响应式刷新) */
const statusOptions = computed(() => [
{ value: '', label: t('usersManage.filter.statusAll') },
{ value: 'active', label: t('usersManage.bucket.active') },
{ value: 'pending', label: t('usersManage.bucket.pending') },
{ value: 'disabled', label: t('usersManage.bucket.disabled') },
{ value: 'locked', label: t('usersManage.bucket.locked') }
])
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 {
page.value = 1
void loadPage()
}
function resetFilters(): void {
filters.query = ''
filters.status = ''
page.value = 1
void loadPage()
}
/** 自身行判定:本地会话只存账号(无 /users/me 端点),按 account 对齐 */
function isSelf(row: Api.AdminUser.Item): boolean {
return row.account === (userStore.info?.userName ?? '')
}
const SOURCE_TAG_TYPE: Record<Api.AdminUser.SourceStatus, 'success' | 'danger' | 'info'> = {
local: 'info',
active: 'success',
disabled: 'danger'
}
/** 模板取值桥(ElTable 行槽位为 any,模板表达式不可写 as 断言) */
function sourceTagOf(sourceStatus: Api.AdminUser.SourceStatus): 'success' | 'danger' | 'info' {
return SOURCE_TAG_TYPE[sourceStatus]
}
// ── 编辑 ──
const editVisible = ref(false)
const editingUser = ref<Api.AdminUser.Item | null>(null)
function openEdit(row: Api.AdminUser.Item): void {
editingUser.value = row
editVisible.value = true
}
function onSaved(updated: Api.AdminUser.Item): void {
const index = items.value.findIndex((item) => item.id === updated.id)
if (index >= 0) replaceItem(index, updated)
}
// ── 重置密码 ──
const resetVisible = ref(false)
const resettingUser = ref<Api.AdminUser.Item | null>(null)
function openReset(row: Api.AdminUser.Item): void {
resettingUser.value = row
resetVisible.value = true
}
// ── 激活 / 停用 / 解锁(确认框 → 调用 → 行内回显) ──
async function confirmActivate(row: Api.AdminUser.Item, index: number): Promise<void> {
try {
await ElMessageBox.confirm(
t('usersManage.confirm.activateBody', { account: row.account }),
t('usersManage.list.actionActivate'),
{
type: 'info',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
}
)
} catch {
return
}
const updated = await fetchActivateAdminUser(row.id)
replaceItem(index, updated)
// §10 语义:激活不出凭据,初始密码经重置密码出具(闭环提示)
ElMessage.success(t('usersManage.toast.activated'))
}
async function confirmDeactivate(row: Api.AdminUser.Item, index: number): Promise<void> {
try {
await ElMessageBox.confirm(
t('usersManage.confirm.deactivateBody', { account: row.account }),
t('usersManage.list.actionDeactivate'),
{
type: 'warning',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
}
)
} catch {
return
}
const updated = await fetchDeactivateAdminUser(row.id)
replaceItem(index, updated)
ElMessage.success(t('usersManage.toast.deactivated'))
}
async function confirmUnlock(row: Api.AdminUser.Item, index: number): Promise<void> {
try {
await ElMessageBox.confirm(
t('usersManage.confirm.unlockBody', { account: row.account }),
t('usersManage.list.actionUnlock'),
{
type: 'info',
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel')
}
)
} catch {
return
}
const updated = await fetchUpdateAdminUser(row.id, { localLocked: false })
replaceItem(index, updated)
ElMessage.success(t('usersManage.toast.unlocked'))
}
onMounted(() => {
void loadPage()
})
</script>