2026-07-30 17:59:43 +08:00
|
|
|
|
<script setup lang="ts">
|
2026-10-20 23:34:11 +08:00
|
|
|
|
import '@/styles/adminWorkbench.css'
|
2026-09-15 09:58:15 +08:00
|
|
|
|
import '@/styles/adminDensity.css'
|
|
|
|
|
|
import '../iamDensity.css'
|
2026-08-04 13:32:08 +08:00
|
|
|
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
2026-08-01 12:17:33 +08:00
|
|
|
|
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
2026-09-15 09:58:15 +08:00
|
|
|
|
import { Plus } from '@lucide/vue'
|
2026-07-31 15:34:35 +08:00
|
|
|
|
import {
|
2026-08-03 14:58:17 +08:00
|
|
|
|
changePlatformManagedUserStatus,
|
|
|
|
|
|
createPlatformManagedUser,
|
|
|
|
|
|
getPlatformUserOptions,
|
2026-08-25 09:52:45 +08:00
|
|
|
|
listSharedReceivingOverseasWarehouseOptions,
|
2026-08-03 14:58:17 +08:00
|
|
|
|
listPlatformManagedUsers,
|
|
|
|
|
|
resetPlatformManagedUserPassword,
|
|
|
|
|
|
updatePlatformManagedUser,
|
|
|
|
|
|
type PlatformManagedUser,
|
|
|
|
|
|
type PlatformUserOptions,
|
2026-08-25 09:52:45 +08:00
|
|
|
|
type SharedReceivingOverseasWarehouseOption,
|
2026-08-03 18:49:18 +08:00
|
|
|
|
} from '@/modules/iam/api/platformIam'
|
|
|
|
|
|
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
|
|
|
|
|
|
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
|
|
|
|
|
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
|
|
|
|
|
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
2026-08-03 19:05:40 +08:00
|
|
|
|
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
2026-08-10 15:36:53 +08:00
|
|
|
|
import AdminPage from '@/shared/components/AdminPage.vue'
|
|
|
|
|
|
import AppDialog from '@/shared/components/AppDialog.vue'
|
2026-10-20 10:11:31 +08:00
|
|
|
|
import InlineHelp from '@/shared/components/InlineHelp.vue'
|
2026-08-10 15:36:53 +08:00
|
|
|
|
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
|
|
|
|
|
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
2026-08-03 18:49:18 +08:00
|
|
|
|
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
|
|
|
|
|
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
|
|
|
|
|
import { useAuthStore } from '@/core/auth/auth'
|
2026-09-22 13:51:58 +08:00
|
|
|
|
import { listAllPlatformWarehouses, type PlatformWarehouseSummaryResource } from '@/modules/warehouses'
|
2026-07-30 17:59:43 +08:00
|
|
|
|
|
|
|
|
|
|
const access = useAccessPreviewStore()
|
2026-08-01 12:17:33 +08:00
|
|
|
|
const auth = useAuthStore()
|
2026-08-03 14:58:17 +08:00
|
|
|
|
const users = ref<PlatformManagedUser[]>([])
|
|
|
|
|
|
const options = ref<PlatformUserOptions>({ tenants: [], roles: [] })
|
2026-08-25 09:52:45 +08:00
|
|
|
|
const overseasWarehouses = ref<SharedReceivingOverseasWarehouseOption[]>([])
|
|
|
|
|
|
const overseasWarehousesLoading = ref(false)
|
|
|
|
|
|
const overseasWarehousesError = ref<ApiErrorDetails | null>(null)
|
2026-09-22 13:51:58 +08:00
|
|
|
|
const domesticWarehouses = ref<PlatformWarehouseSummaryResource[]>([])
|
|
|
|
|
|
const domesticWarehousesLoading = ref(false)
|
|
|
|
|
|
const domesticWarehousesError = ref<ApiErrorDetails | null>(null)
|
2026-07-31 15:34:35 +08:00
|
|
|
|
const loading = ref(false)
|
|
|
|
|
|
const saving = ref(false)
|
|
|
|
|
|
const loadError = ref<ApiErrorDetails | null>(null)
|
|
|
|
|
|
const editorOpen = ref(false)
|
2026-08-01 12:17:33 +08:00
|
|
|
|
const formRef = ref<FormInstance>()
|
2026-08-03 14:58:17 +08:00
|
|
|
|
const editingUser = ref<PlatformManagedUser | null>(null)
|
2026-08-01 12:17:33 +08:00
|
|
|
|
const rowActionId = ref('')
|
2026-08-04 13:32:08 +08:00
|
|
|
|
let loadRequestId = 0
|
2026-08-25 09:52:45 +08:00
|
|
|
|
let warehouseRequestId = 0
|
2026-07-30 17:59:43 +08:00
|
|
|
|
const page = ref(1)
|
2026-08-01 12:17:33 +08:00
|
|
|
|
const pageSize = ref(10)
|
2026-08-04 13:32:08 +08:00
|
|
|
|
const total = ref(0)
|
|
|
|
|
|
type UserFilters = QueryModel & { keyword: string; status: string }
|
|
|
|
|
|
const filters = ref<UserFilters>({ keyword: '', status: '' })
|
2026-08-03 19:05:40 +08:00
|
|
|
|
const appliedFilters = ref<UserFilters>({ ...filters.value })
|
|
|
|
|
|
const queryFields = computed<QueryField[]>(() => [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'keyword',
|
|
|
|
|
|
type: 'input',
|
2026-08-06 19:03:13 +08:00
|
|
|
|
label: '用户关键词',
|
2026-08-04 13:32:08 +08:00
|
|
|
|
placeholder: '姓名、手机号、邮箱或员工编号',
|
2026-08-03 19:05:40 +08:00
|
|
|
|
props: { maxlength: 128, 'aria-label': '用户关键词' },
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'status',
|
|
|
|
|
|
type: 'select',
|
2026-08-06 19:03:13 +08:00
|
|
|
|
label: '状态',
|
2026-08-03 19:05:40 +08:00
|
|
|
|
placeholder: '全部状态',
|
|
|
|
|
|
props: { 'aria-label': '用户状态' },
|
|
|
|
|
|
options: [
|
|
|
|
|
|
{ label: '正常', value: 'ACTIVE' },
|
|
|
|
|
|
{ label: '禁用', value: 'DISABLED' },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
])
|
2026-08-03 14:58:17 +08:00
|
|
|
|
|
|
|
|
|
|
const emptyForm = () => ({
|
|
|
|
|
|
username: '',
|
|
|
|
|
|
displayName: '',
|
|
|
|
|
|
phone: '',
|
|
|
|
|
|
email: '',
|
2026-08-04 13:32:08 +08:00
|
|
|
|
tenantId: '0',
|
2026-08-03 14:58:17 +08:00
|
|
|
|
roleIds: [] as string[],
|
2026-08-25 09:52:45 +08:00
|
|
|
|
overseasWarehouseId: '',
|
2026-09-22 13:51:58 +08:00
|
|
|
|
domesticWarehouseId: '',
|
2026-08-03 14:58:17 +08:00
|
|
|
|
password: '',
|
|
|
|
|
|
confirmPassword: '',
|
|
|
|
|
|
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED',
|
|
|
|
|
|
resetPassword: false,
|
|
|
|
|
|
})
|
2026-07-30 17:59:43 +08:00
|
|
|
|
const form = reactive(emptyForm())
|
2026-08-03 14:58:17 +08:00
|
|
|
|
|
|
|
|
|
|
const canCreate = computed(() => access.can('platform:user:create') && access.can('platform:user:grant-role'))
|
|
|
|
|
|
const canEdit = computed(() => access.can('platform:user:edit'))
|
|
|
|
|
|
const canGrantRoles = computed(() => access.can('platform:user:grant-role'))
|
|
|
|
|
|
const canChangeStatus = computed(() => access.can('platform:user:status'))
|
|
|
|
|
|
const canResetPassword = computed(() => access.can('platform:user:reset-password'))
|
2026-10-20 10:11:31 +08:00
|
|
|
|
const editorLabel = computed(() => editingUser.value ? `编辑用户 · ${editingUser.value.displayName?.trim() || editingUser.value.username?.trim() || '—'}` : '新增用户')
|
2026-08-03 14:58:17 +08:00
|
|
|
|
|
|
|
|
|
|
useUnsavedWorkRegistration({
|
|
|
|
|
|
key: 'platform-user-editor',
|
2026-08-01 12:17:33 +08:00
|
|
|
|
label: editorLabel,
|
2026-07-30 17:59:43 +08:00
|
|
|
|
isOpen: editorOpen,
|
|
|
|
|
|
model: form,
|
2026-08-03 14:58:17 +08:00
|
|
|
|
requiredPermission: computed(() => editingUser.value ? 'platform:user:edit' : 'platform:user:create'),
|
2026-07-30 17:59:43 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-03 14:58:17 +08:00
|
|
|
|
const availableRoles = computed(() => options.value.roles.filter((role) =>
|
2026-08-04 13:32:08 +08:00
|
|
|
|
String(role.tenantId) === '0'
|
2026-08-03 14:58:17 +08:00
|
|
|
|
&& (role.status === 'ACTIVE' || form.roleIds.includes(String(role.id))),
|
|
|
|
|
|
))
|
2026-08-28 13:54:44 +08:00
|
|
|
|
const overseasPdaRoleIds = computed(() => new Set(
|
|
|
|
|
|
availableRoles.value.filter((role) => role.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR').map((role) => String(role.id)),
|
2026-08-25 09:52:45 +08:00
|
|
|
|
))
|
2026-08-28 13:54:44 +08:00
|
|
|
|
const hasOverseasPdaRole = computed(() => form.roleIds.some((roleId) => overseasPdaRoleIds.value.has(roleId)))
|
2026-09-22 13:51:58 +08:00
|
|
|
|
const domesticPdaRoleIds = computed(() => new Set(
|
|
|
|
|
|
availableRoles.value.filter((role) => role.roleCode === 'CN_FIRSTLEG_PDA_OPERATOR').map((role) => String(role.id)),
|
|
|
|
|
|
))
|
|
|
|
|
|
const hasDomesticPdaRole = computed(() => form.roleIds.some((roleId) => domesticPdaRoleIds.value.has(roleId)))
|
2026-10-20 10:11:31 +08:00
|
|
|
|
const selectedRoleNames = computed(() => form.roleIds.map((roleId) => availableRoles.value.find((role) => String(role.id) === roleId)?.name || '—').join('、') || '—')
|
|
|
|
|
|
const selectedOverseasWarehouse = computed(() => overseasWarehouses.value.find((warehouse) => warehouse.id === form.overseasWarehouseId)?.warehouseName || '—')
|
|
|
|
|
|
const selectedDomesticWarehouse = computed(() => domesticWarehouses.value.find((warehouse) => String(warehouse.id) === form.domesticWarehouseId)?.warehouseName || '—')
|
2026-08-03 14:58:17 +08:00
|
|
|
|
|
|
|
|
|
|
const formRules = computed<FormRules>(() => ({
|
|
|
|
|
|
username: editingUser.value ? [] : [
|
|
|
|
|
|
{ required: true, whitespace: true, message: '请输入用户名', trigger: 'blur' },
|
|
|
|
|
|
{ pattern: /^[A-Za-z0-9_.@-]{4,64}$/, message: '用户名为 4-64 位字母、数字或 _ . @ -', trigger: 'blur' },
|
|
|
|
|
|
],
|
|
|
|
|
|
displayName: editingUser.value && !canEdit.value ? [] : [{ required: true, whitespace: true, message: '请输入姓名', trigger: 'blur' }],
|
|
|
|
|
|
phone: editingUser.value && !canEdit.value ? [] : [
|
|
|
|
|
|
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
|
|
|
|
|
{ pattern: /^1\d{10}$/, message: '请输入正确的 11 位手机号', trigger: 'blur' },
|
|
|
|
|
|
],
|
|
|
|
|
|
email: editingUser.value && !canEdit.value ? [] : [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
|
|
|
|
|
|
roleIds: editingUser.value && !canEdit.value ? [] : [{ type: 'array', required: true, min: 1, message: '请选择角色', trigger: 'change' }],
|
2026-08-28 13:54:44 +08:00
|
|
|
|
overseasWarehouseId: !hasOverseasPdaRole.value || (editingUser.value && !canEdit.value) ? [] : [
|
2026-08-25 09:52:45 +08:00
|
|
|
|
{ required: true, message: '请选择实际海外收货仓', trigger: 'change' },
|
|
|
|
|
|
],
|
2026-09-22 13:51:58 +08:00
|
|
|
|
domesticWarehouseId: !hasDomesticPdaRole.value || (editingUser.value && !canEdit.value) ? [] : [
|
|
|
|
|
|
{ required: true, message: '请选择国内作业仓', trigger: 'change' },
|
|
|
|
|
|
],
|
2026-08-03 14:58:17 +08:00
|
|
|
|
password: editingUser.value && !form.resetPassword ? [] : [
|
|
|
|
|
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
|
|
|
|
|
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码为 8-64 位,且必须包含字母和数字', trigger: 'blur' },
|
|
|
|
|
|
],
|
|
|
|
|
|
confirmPassword: editingUser.value && !form.resetPassword ? [] : [
|
|
|
|
|
|
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
|
|
|
|
|
{ validator: (_rule, value, callback) => value === form.password ? callback() : callback(new Error('两次输入的密码不一致')), trigger: 'blur' },
|
|
|
|
|
|
],
|
2026-07-30 17:59:43 +08:00
|
|
|
|
}))
|
2026-08-03 14:58:17 +08:00
|
|
|
|
|
2026-08-01 12:17:33 +08:00
|
|
|
|
const userColumns: DataTableColumn[] = [
|
2026-08-03 14:58:17 +08:00
|
|
|
|
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
2026-08-06 19:03:13 +08:00
|
|
|
|
{ key: 'username', prop: 'username', label: '用户名', minWidth: 145 },
|
|
|
|
|
|
{ key: 'displayName', prop: 'displayName', label: '姓名', minWidth: 130 },
|
2026-08-03 14:58:17 +08:00
|
|
|
|
{ key: 'roles', label: '角色', minWidth: 180, slotName: 'roles' },
|
2026-08-01 12:17:33 +08:00
|
|
|
|
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
|
2026-08-03 14:58:17 +08:00
|
|
|
|
{ key: 'lastLoginAt', label: '最后登录', minWidth: 170, slotName: 'lastLoginAt' },
|
2026-08-01 12:17:33 +08:00
|
|
|
|
]
|
2026-07-30 17:59:43 +08:00
|
|
|
|
|
2026-08-04 13:32:08 +08:00
|
|
|
|
function userQuery() {
|
|
|
|
|
|
return {
|
|
|
|
|
|
page: page.value,
|
|
|
|
|
|
size: pageSize.value,
|
|
|
|
|
|
...(appliedFilters.value.keyword.trim() ? { keyword: appliedFilters.value.keyword.trim() } : {}),
|
|
|
|
|
|
...(appliedFilters.value.status ? { status: appliedFilters.value.status as PlatformManagedUser['status'] } : {}),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 15:34:35 +08:00
|
|
|
|
async function loadUsers() {
|
2026-08-04 13:32:08 +08:00
|
|
|
|
const requestId = ++loadRequestId
|
2026-07-31 15:34:35 +08:00
|
|
|
|
loading.value = true
|
|
|
|
|
|
loadError.value = null
|
|
|
|
|
|
try {
|
2026-08-04 13:32:08 +08:00
|
|
|
|
const result = await listPlatformManagedUsers(userQuery())
|
|
|
|
|
|
if (requestId !== loadRequestId) return
|
|
|
|
|
|
users.value = result.records
|
|
|
|
|
|
total.value = result.total
|
2026-07-31 15:34:35 +08:00
|
|
|
|
} catch (error) {
|
2026-08-04 13:32:08 +08:00
|
|
|
|
if (requestId !== loadRequestId) return
|
2026-08-03 14:58:17 +08:00
|
|
|
|
users.value = []
|
2026-08-04 13:32:08 +08:00
|
|
|
|
total.value = 0
|
2026-08-03 14:58:17 +08:00
|
|
|
|
loadError.value = getApiErrorDetails(error, '用户列表加载失败,请稍后重试')
|
2026-07-31 15:34:35 +08:00
|
|
|
|
} finally {
|
2026-08-04 13:32:08 +08:00
|
|
|
|
if (requestId === loadRequestId) loading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadInitialData() {
|
|
|
|
|
|
const requestId = ++loadRequestId
|
|
|
|
|
|
loading.value = true
|
|
|
|
|
|
loadError.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
const [result, optionRecords] = await Promise.all([
|
|
|
|
|
|
listPlatformManagedUsers(userQuery()),
|
|
|
|
|
|
getPlatformUserOptions(),
|
|
|
|
|
|
])
|
|
|
|
|
|
options.value = {
|
|
|
|
|
|
tenants: optionRecords.tenants.filter((tenant) => String(tenant.id) === '0'),
|
|
|
|
|
|
roles: optionRecords.roles.filter((role) => String(role.tenantId) === '0'),
|
|
|
|
|
|
}
|
|
|
|
|
|
if (requestId !== loadRequestId) return
|
|
|
|
|
|
users.value = result.records
|
|
|
|
|
|
total.value = result.total
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (requestId !== loadRequestId) return
|
|
|
|
|
|
users.value = []
|
|
|
|
|
|
total.value = 0
|
|
|
|
|
|
loadError.value = getApiErrorDetails(error, '内部用户列表加载失败,请稍后重试')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (requestId === loadRequestId) loading.value = false
|
2026-07-31 15:34:35 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-01 12:17:33 +08:00
|
|
|
|
function openCreate() {
|
|
|
|
|
|
Object.assign(form, emptyForm())
|
2026-08-03 14:58:17 +08:00
|
|
|
|
editingUser.value = null
|
2026-08-01 12:17:33 +08:00
|
|
|
|
editorOpen.value = true
|
|
|
|
|
|
nextTick(() => formRef.value?.clearValidate())
|
|
|
|
|
|
}
|
2026-08-03 14:58:17 +08:00
|
|
|
|
|
|
|
|
|
|
function openEdit(row: PlatformManagedUser) {
|
|
|
|
|
|
editingUser.value = row
|
2026-08-01 12:17:33 +08:00
|
|
|
|
Object.assign(form, {
|
2026-08-03 14:58:17 +08:00
|
|
|
|
username: row.username,
|
2026-08-01 12:17:33 +08:00
|
|
|
|
displayName: row.displayName,
|
|
|
|
|
|
phone: row.phone ?? '',
|
|
|
|
|
|
email: row.email ?? '',
|
2026-08-04 13:32:08 +08:00
|
|
|
|
tenantId: '0',
|
2026-08-01 12:17:33 +08:00
|
|
|
|
roleIds: row.roleIds.map(String),
|
2026-08-25 09:52:45 +08:00
|
|
|
|
overseasWarehouseId: row.overseasWarehouseId == null ? '' : String(row.overseasWarehouseId),
|
2026-09-22 13:51:58 +08:00
|
|
|
|
domesticWarehouseId: row.domesticWarehouseId == null ? '' : String(row.domesticWarehouseId),
|
2026-08-03 14:58:17 +08:00
|
|
|
|
password: '',
|
|
|
|
|
|
confirmPassword: '',
|
|
|
|
|
|
status: row.status,
|
|
|
|
|
|
resetPassword: false,
|
2026-08-01 12:17:33 +08:00
|
|
|
|
})
|
|
|
|
|
|
editorOpen.value = true
|
|
|
|
|
|
nextTick(() => formRef.value?.clearValidate())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-25 09:52:45 +08:00
|
|
|
|
async function loadOverseasWarehouses() {
|
|
|
|
|
|
const requestId = ++warehouseRequestId
|
|
|
|
|
|
overseasWarehousesLoading.value = true
|
|
|
|
|
|
overseasWarehousesError.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
const records = await listSharedReceivingOverseasWarehouseOptions()
|
|
|
|
|
|
if (requestId !== warehouseRequestId) return
|
|
|
|
|
|
overseasWarehouses.value = records
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (requestId !== warehouseRequestId) return
|
|
|
|
|
|
overseasWarehouses.value = []
|
|
|
|
|
|
overseasWarehousesError.value = getApiErrorDetails(error, '共享海外收货仓加载失败,请重试')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (requestId === warehouseRequestId) overseasWarehousesLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 13:51:58 +08:00
|
|
|
|
async function loadDomesticWarehouses() {
|
|
|
|
|
|
domesticWarehousesLoading.value = true
|
|
|
|
|
|
domesticWarehousesError.value = null
|
|
|
|
|
|
try {
|
|
|
|
|
|
domesticWarehouses.value = await listAllPlatformWarehouses({ warehouseType: 'DOMESTIC', status: 'ACTIVE' })
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
domesticWarehouses.value = []
|
|
|
|
|
|
domesticWarehousesError.value = getApiErrorDetails(error, '国内作业仓加载失败,请重试')
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
domesticWarehousesLoading.value = false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 14:58:17 +08:00
|
|
|
|
function isCurrentUser(row: PlatformManagedUser | null) {
|
|
|
|
|
|
if (!row) return false
|
2026-08-01 12:17:33 +08:00
|
|
|
|
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
|
|
|
|
|
|
}
|
2026-07-30 17:59:43 +08:00
|
|
|
|
|
2026-08-03 14:58:17 +08:00
|
|
|
|
function cannotDisable(row: PlatformManagedUser | null) {
|
|
|
|
|
|
return Boolean(row?.protectedAccount) || isCurrentUser(row)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-31 15:34:35 +08:00
|
|
|
|
async function saveUser() {
|
2026-08-01 12:17:33 +08:00
|
|
|
|
const valid = await formRef.value?.validate().catch(() => false)
|
|
|
|
|
|
if (!valid) return
|
2026-07-31 15:34:35 +08:00
|
|
|
|
saving.value = true
|
2026-07-30 17:59:43 +08:00
|
|
|
|
try {
|
2026-08-03 14:58:17 +08:00
|
|
|
|
if (!editingUser.value) {
|
|
|
|
|
|
await createPlatformManagedUser({
|
|
|
|
|
|
username: form.username.trim(),
|
2026-08-01 12:17:33 +08:00
|
|
|
|
displayName: form.displayName.trim(),
|
|
|
|
|
|
phone: form.phone.trim(),
|
|
|
|
|
|
email: form.email.trim(),
|
2026-08-04 13:32:08 +08:00
|
|
|
|
tenantId: '0',
|
2026-08-01 12:17:33 +08:00
|
|
|
|
roleIds: form.roleIds,
|
2026-08-28 13:54:44 +08:00
|
|
|
|
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
|
2026-09-22 13:51:58 +08:00
|
|
|
|
...(hasDomesticPdaRole.value ? { domesticWarehouseId: form.domesticWarehouseId } : {}),
|
2026-08-03 14:58:17 +08:00
|
|
|
|
password: form.password,
|
|
|
|
|
|
status: form.status,
|
2026-08-01 12:17:33 +08:00
|
|
|
|
})
|
2026-08-03 14:58:17 +08:00
|
|
|
|
ElMessage.success('用户添加成功')
|
2026-08-01 12:17:33 +08:00
|
|
|
|
} else {
|
2026-08-03 14:58:17 +08:00
|
|
|
|
const row = editingUser.value
|
|
|
|
|
|
if (canEdit.value) {
|
|
|
|
|
|
await updatePlatformManagedUser(row.membershipId, {
|
|
|
|
|
|
displayName: form.displayName.trim(),
|
|
|
|
|
|
phone: form.phone.trim(),
|
|
|
|
|
|
email: form.email.trim(),
|
2026-08-04 13:32:08 +08:00
|
|
|
|
tenantId: '0',
|
2026-08-03 14:58:17 +08:00
|
|
|
|
roleIds: form.roleIds,
|
2026-08-28 13:54:44 +08:00
|
|
|
|
overseasWarehouseId: hasOverseasPdaRole.value ? form.overseasWarehouseId : null,
|
2026-09-22 13:51:58 +08:00
|
|
|
|
...(hasDomesticPdaRole.value ? { domesticWarehouseId: form.domesticWarehouseId } : {}),
|
2026-08-03 14:58:17 +08:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
if (canChangeStatus.value && form.status !== row.status) {
|
|
|
|
|
|
await changePlatformManagedUserStatus(row.membershipId, form.status)
|
|
|
|
|
|
}
|
|
|
|
|
|
if (canResetPassword.value && form.resetPassword) {
|
|
|
|
|
|
await resetPlatformManagedUserPassword(row.membershipId, form.password)
|
|
|
|
|
|
}
|
|
|
|
|
|
ElMessage.success(form.resetPassword ? '用户信息已更新,密码已重置' : '用户信息已更新')
|
2026-08-01 12:17:33 +08:00
|
|
|
|
}
|
2026-07-31 15:34:35 +08:00
|
|
|
|
editorOpen.value = false
|
|
|
|
|
|
await loadUsers()
|
|
|
|
|
|
} catch (error) {
|
2026-08-03 14:58:17 +08:00
|
|
|
|
ElMessage.error(getApiErrorMessage(error, editingUser.value ? '用户更新失败,请稍后重试' : '用户添加失败,请稍后重试'))
|
2026-07-31 15:34:35 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
saving.value = false
|
2026-07-30 17:59:43 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 14:58:17 +08:00
|
|
|
|
async function toggleStatus(row: PlatformManagedUser) {
|
|
|
|
|
|
const targetStatus = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
|
|
|
|
|
|
const targetLabel = targetStatus === 'ACTIVE' ? '启用' : '禁用'
|
2026-08-01 12:17:33 +08:00
|
|
|
|
try {
|
2026-10-20 23:34:11 +08:00
|
|
|
|
await ElMessageBox.confirm(`确认${targetLabel}用户“${row.displayName}”吗?`, `${targetLabel}用户 · ${row.displayName?.trim() || row.username?.trim() || '—'}`, { customClass: 'admin-density-confirm admin-workbench',
|
2026-08-03 14:58:17 +08:00
|
|
|
|
confirmButtonText: `确认${targetLabel}`,
|
2026-08-01 12:17:33 +08:00
|
|
|
|
cancelButtonText: '取消',
|
2026-08-03 14:58:17 +08:00
|
|
|
|
type: targetStatus === 'DISABLED' ? 'warning' : 'info',
|
2026-08-01 12:17:33 +08:00
|
|
|
|
})
|
|
|
|
|
|
rowActionId.value = String(row.membershipId)
|
2026-08-03 14:58:17 +08:00
|
|
|
|
const updated = await changePlatformManagedUserStatus(row.membershipId, targetStatus)
|
|
|
|
|
|
Object.assign(row, updated)
|
|
|
|
|
|
ElMessage.success(`用户已${targetLabel}`)
|
2026-08-01 12:17:33 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
if (error === 'cancel' || error === 'close') return
|
2026-08-03 14:58:17 +08:00
|
|
|
|
ElMessage.error(getApiErrorMessage(error, `用户${targetLabel}失败,请稍后重试`))
|
2026-08-01 12:17:33 +08:00
|
|
|
|
} finally {
|
|
|
|
|
|
rowActionId.value = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 14:58:17 +08:00
|
|
|
|
function formatLogin(value: string | null) {
|
|
|
|
|
|
if (!value) return '—'
|
|
|
|
|
|
return value.replace('T', ' ').slice(0, 16)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 19:05:40 +08:00
|
|
|
|
function applyFilters() {
|
|
|
|
|
|
appliedFilters.value = { ...filters.value }
|
2026-08-04 13:32:08 +08:00
|
|
|
|
if (page.value === 1) void loadUsers()
|
|
|
|
|
|
else page.value = 1
|
2026-08-01 12:17:33 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-04 13:32:08 +08:00
|
|
|
|
watch([page, pageSize], () => void loadUsers())
|
2026-08-28 13:54:44 +08:00
|
|
|
|
watch(hasOverseasPdaRole, (selected) => {
|
2026-08-25 09:52:45 +08:00
|
|
|
|
if (!selected) {
|
|
|
|
|
|
form.overseasWarehouseId = ''
|
|
|
|
|
|
overseasWarehousesError.value = null
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
void loadOverseasWarehouses()
|
|
|
|
|
|
})
|
2026-09-22 13:51:58 +08:00
|
|
|
|
watch(hasDomesticPdaRole, (selected) => {
|
|
|
|
|
|
if (!selected) {
|
|
|
|
|
|
form.domesticWarehouseId = ''
|
|
|
|
|
|
domesticWarehousesError.value = null
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
void loadDomesticWarehouses()
|
|
|
|
|
|
})
|
2026-08-04 13:32:08 +08:00
|
|
|
|
onMounted(loadInitialData)
|
2026-07-30 17:59:43 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
2026-10-20 23:34:11 +08:00
|
|
|
|
<AdminPage class="admin-density iam-density admin-workbench" as="div">
|
2026-08-10 15:36:53 +08:00
|
|
|
|
<ListPageLayout aria-label="内部用户管理">
|
2026-09-30 14:01:58 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-10-21 13:22:42 +08:00
|
|
|
|
<section class="workbench-table is-page-surface" :aria-busy="loading"><DataTable appearance="integrated" column-settings-mode="show" :results-visible="!loadError" v-model:page="page" v-model:page-size="pageSize" :data="users" :columns="userColumns" preference-key="internal-users-table"
|
2026-09-30 14:01:58 +08:00
|
|
|
|
:loading="loading" :total="total" aria-label="内部用户列表" row-key="membershipId" empty-text="没有符合筛选条件的内部用户">
|
|
|
|
|
|
<template #toolbar-start><h1>用户管理</h1></template>
|
|
|
|
|
|
<template #filters>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
<QueryToolbar
|
|
|
|
|
|
v-model="filters"
|
|
|
|
|
|
ariaLabel="用户筛选"
|
|
|
|
|
|
:fields="queryFields"
|
|
|
|
|
|
preference-key="internal-users-query"
|
|
|
|
|
|
@search="applyFilters"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
2026-09-30 14:01:58 +08:00
|
|
|
|
<template #notices><template v-if="loadError">
|
2026-08-10 15:36:53 +08:00
|
|
|
|
<LoadErrorState title="内部用户列表暂时无法加载" :message="loadError.message" :loading="loading" @retry="loadInitialData">
|
|
|
|
|
|
<template #details><ApiErrorMeta :details="loadError" /></template>
|
|
|
|
|
|
</LoadErrorState>
|
2026-09-30 14:01:58 +08:00
|
|
|
|
</template></template>
|
|
|
|
|
|
|
2026-08-03 14:58:17 +08:00
|
|
|
|
|
2026-08-06 20:14:56 +08:00
|
|
|
|
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></template>
|
2026-09-30 14:01:58 +08:00
|
|
|
|
<template #actions="{ row }"><div class="table-actions density-actions row-actions admin-text-actions">
|
2026-09-15 09:58:15 +08:00
|
|
|
|
<el-button class="density-action" v-if="canEdit || canChangeStatus || canResetPassword" size="small" data-tone="neutral" @click="openEdit(row as PlatformManagedUser)">
|
|
|
|
|
|
编辑
|
2026-08-03 14:58:17 +08:00
|
|
|
|
</el-button>
|
2026-09-15 09:58:15 +08:00
|
|
|
|
<el-button class="density-action" v-if="canChangeStatus" size="small" :data-tone="(row as PlatformManagedUser).status === 'ACTIVE' ? 'danger' : 'success'"
|
2026-08-03 14:58:17 +08:00
|
|
|
|
:loading="rowActionId === String((row as PlatformManagedUser).membershipId)"
|
|
|
|
|
|
:disabled="cannotDisable(row as PlatformManagedUser)"
|
|
|
|
|
|
:title="(row as PlatformManagedUser).protectedAccount ? '系统管理员不能被禁用' : isCurrentUser(row as PlatformManagedUser) ? '不能禁用当前登录用户' : undefined"
|
|
|
|
|
|
@click="toggleStatus(row as PlatformManagedUser)">
|
2026-09-15 09:58:15 +08:00
|
|
|
|
{{ (row as PlatformManagedUser).status === 'ACTIVE' ? '禁用' : '启用' }}
|
2026-08-03 14:58:17 +08:00
|
|
|
|
</el-button>
|
2026-09-30 14:01:58 +08:00
|
|
|
|
</div></template>
|
2026-08-03 14:58:17 +08:00
|
|
|
|
<template #roles="{ row }">
|
|
|
|
|
|
<div class="tag-list">
|
|
|
|
|
|
<el-tag v-for="role in (row as PlatformManagedUser).roleNames" :key="role" effect="plain" size="small">{{ role }}</el-tag>
|
|
|
|
|
|
<span v-if="!(row as PlatformManagedUser).roleNames.length" class="secondary-text">未分配</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template #status="{ row }"><StatusBadge :status="(row as PlatformManagedUser).status === 'ACTIVE' ? '正常' : '禁用'" /></template>
|
|
|
|
|
|
<template #lastLoginAt="{ row }">{{ formatLogin((row as PlatformManagedUser).lastLoginAt) }}</template>
|
2026-08-10 16:24:54 +08:00
|
|
|
|
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 条</span></template>
|
2026-09-30 14:01:58 +08:00
|
|
|
|
</DataTable></section>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
</ListPageLayout>
|
2026-08-03 14:58:17 +08:00
|
|
|
|
|
2026-10-20 23:34:11 +08:00
|
|
|
|
<AppDialog v-model="editorOpen" :title="editorLabel" width="min(650px, calc(100vw - 28px))" class="internal-user-editor-dialog admin-density-dialog iam-density admin-workbench is-dialog" fullscreenable destroy-on-close :confirm-loading="saving" :confirm-text="editingUser ? '保存修改' : '确认添加'" @confirm="saveUser">
|
2026-10-20 10:11:31 +08:00
|
|
|
|
<el-form class="density-form density-dialog-sections" ref="formRef" :model="form" :rules="formRules" label-position="left" label-width="100px" :disabled="saving">
|
2026-10-20 23:34:11 +08:00
|
|
|
|
<section class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户基本资料">
|
2026-10-20 10:11:31 +08:00
|
|
|
|
<h3 class="density-dialog-section__title">用户资料</h3>
|
|
|
|
|
|
<table class="density-table density-table--editable user-editor-table"><tbody>
|
|
|
|
|
|
<tr><td><el-form-item label="用户名" prop="username" required><span v-if="editingUser">{{form.username || '—'}}</span><el-input v-else v-model="form.username" placeholder="登录用户名" maxlength="64" /></el-form-item></td><td><el-form-item label="姓名" prop="displayName" required><span v-if="editingUser && !canEdit">{{form.displayName || '—'}}</span><el-input v-else v-model="form.displayName" placeholder="真实姓名" maxlength="64" /></el-form-item></td></tr>
|
|
|
|
|
|
<tr><td><el-form-item label="手机号" prop="phone" required><span v-if="editingUser && !canEdit">{{form.phone || '—'}}</span><el-input v-else v-model="form.phone" placeholder="手机号码" inputmode="tel" maxlength="11" /></el-form-item></td><td><el-form-item label="邮箱" prop="email"><span v-if="editingUser && !canEdit">{{form.email || '—'}}</span><el-input v-else v-model="form.email" placeholder="邮箱地址" inputmode="email" maxlength="128" /></el-form-item></td></tr>
|
|
|
|
|
|
<tr><td colspan="2"><el-form-item label="角色" prop="roleIds" required><span v-if="editingUser && (!canEdit || !canGrantRoles || editingUser.protectedAccount)">{{selectedRoleNames}}</span><el-select v-else v-model="form.roleIds" multiple collapse-tags collapse-tags-tooltip placeholder="选择角色"><el-option v-for="role in availableRoles" :key="role.id" :label="role.name" :value="String(role.id)" :disabled="role.status !== 'ACTIVE'" /></el-select></el-form-item></td></tr>
|
|
|
|
|
|
<tr><td :colspan="editingUser ? 1 : 2"><el-form-item label="状态" prop="status"><span v-if="editingUser && (!canChangeStatus || cannotDisable(editingUser))">{{form.status === 'ACTIVE' ? '正常' : '禁用'}}</span><el-select v-else v-model="form.status"><el-option label="正常" value="ACTIVE" /><el-option label="禁用" value="DISABLED" /></el-select></el-form-item></td><td v-if="editingUser"><el-form-item label="最后登录"><span>{{formatLogin(editingUser.lastLoginAt)}}</span></el-form-item></td></tr>
|
|
|
|
|
|
</tbody></table>
|
|
|
|
|
|
</section>
|
2026-10-20 23:34:11 +08:00
|
|
|
|
<section v-if="hasOverseasPdaRole || hasDomesticPdaRole" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户作业仓库">
|
2026-10-20 10:11:31 +08:00
|
|
|
|
<h3 class="density-dialog-section__title">作业仓库</h3>
|
|
|
|
|
|
<table class="density-table density-table--editable"><tbody>
|
|
|
|
|
|
<tr v-if="hasOverseasPdaRole"><td><el-form-item label="实际海外收货仓" prop="overseasWarehouseId" label-width="145px" required>
|
|
|
|
|
|
<template #label>实际海外收货仓 <InlineHelp title="海外仓作业范围">该人员仅在绑定的海外仓作业,海外 PDA 不提供仓库切换。</InlineHelp></template>
|
|
|
|
|
|
<span v-if="editingUser && !canEdit">{{selectedOverseasWarehouse}}</span><el-select v-else v-model="form.overseasWarehouseId" filterable :loading="overseasWarehousesLoading" placeholder="请选择平台共享海外收货仓"><el-option v-for="warehouse in overseasWarehouses" :key="warehouse.id" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode} · ${warehouse.countryCode})`" :value="warehouse.id" /></el-select>
|
|
|
|
|
|
<LoadErrorState v-if="overseasWarehousesError" compact title="共享海外收货仓加载失败" :message="overseasWarehousesError.message" :loading="overseasWarehousesLoading" @retry="loadOverseasWarehouses"><template #details><ApiErrorMeta :details="overseasWarehousesError" /></template></LoadErrorState>
|
|
|
|
|
|
</el-form-item></td></tr>
|
|
|
|
|
|
<tr v-if="hasDomesticPdaRole"><td><el-form-item label="国内作业仓" prop="domesticWarehouseId" label-width="145px" required>
|
|
|
|
|
|
<template #label>国内作业仓 <InlineHelp title="国内仓作业范围">该人员只会出现在此仓库的国内头程 PDA 任务候选中。</InlineHelp></template>
|
|
|
|
|
|
<span v-if="editingUser && !canEdit">{{selectedDomesticWarehouse}}</span><el-select v-else v-model="form.domesticWarehouseId" filterable :loading="domesticWarehousesLoading" placeholder="请选择国内作业仓"><el-option v-for="warehouse in domesticWarehouses" :key="String(warehouse.id)" :label="`${warehouse.warehouseName}(${warehouse.warehouseCode})`" :value="String(warehouse.id)" /></el-select>
|
|
|
|
|
|
<LoadErrorState v-if="domesticWarehousesError" compact title="国内作业仓加载失败" :message="domesticWarehousesError.message" :loading="domesticWarehousesLoading" @retry="loadDomesticWarehouses"><template #details><ApiErrorMeta :details="domesticWarehousesError" /></template></LoadErrorState>
|
|
|
|
|
|
</el-form-item></td></tr>
|
|
|
|
|
|
</tbody></table>
|
|
|
|
|
|
</section>
|
2026-10-20 23:34:11 +08:00
|
|
|
|
<section v-if="!editingUser || canResetPassword" class="density-dialog-section workbench-evidence" data-tone="work" aria-label="用户登录密码">
|
2026-10-20 10:11:31 +08:00
|
|
|
|
<h3 class="density-dialog-section__title">登录密码 <InlineHelp title="密码要求">密码为 8-64 位,并同时包含字母和数字。</InlineHelp></h3>
|
|
|
|
|
|
<table class="density-table density-table--editable user-editor-table"><tbody>
|
|
|
|
|
|
<tr v-if="editingUser"><td colspan="2"><el-checkbox v-model="form.resetPassword">同时重置密码</el-checkbox></td></tr>
|
|
|
|
|
|
<tr v-if="!editingUser || form.resetPassword"><td><el-form-item :label="editingUser ? '新密码' : '初始密码'" prop="password" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" placeholder="8-64 位,包含字母和数字" /></el-form-item></td><td><el-form-item label="确认密码" prop="confirmPassword" required><el-input v-model="form.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="再次输入密码" /></el-form-item></td></tr>
|
|
|
|
|
|
</tbody></table>
|
|
|
|
|
|
</section>
|
2026-07-30 17:59:43 +08:00
|
|
|
|
</el-form>
|
2026-08-10 15:36:53 +08:00
|
|
|
|
</AppDialog>
|
|
|
|
|
|
</AdminPage>
|
2026-07-30 17:59:43 +08:00
|
|
|
|
</template>
|
2026-08-03 14:58:17 +08:00
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
2026-10-20 10:11:31 +08:00
|
|
|
|
.user-editor-table { table-layout: fixed; }
|
|
|
|
|
|
.user-editor-table td:not([colspan="2"]) { width: 50%; }
|
|
|
|
|
|
:global(.internal-user-editor-dialog.app-dialog.admin-density-dialog.el-dialog:not(.is-fullscreen)) { max-height: min(760px, calc(100vh - 32px)); }
|
|
|
|
|
|
@media (max-width: 720px) { .user-editor-table tr { display: grid; } .user-editor-table td:not([colspan="2"]) { width: auto; } }
|
2026-08-03 14:58:17 +08:00
|
|
|
|
</style>
|