239 lines
16 KiB
Vue
239 lines
16 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||
import { KeyRound, Pencil, Plus } from '@lucide/vue'
|
||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||
import { useTenantAdministrationStore, type TenantMember, type TenantRecordStatus } from '@/stores/tenantAdministration'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
|
||
const store = useTenantAdministrationStore()
|
||
const auth = useAuthStore()
|
||
const memberFilters = ref({ keyword: '', status: '' as TenantRecordStatus | '' })
|
||
const appliedMemberFilters = ref({ ...memberFilters.value })
|
||
const memberPage = ref(1)
|
||
const memberPageSize = ref(10)
|
||
const editorOpen = ref(false)
|
||
const editingId = ref('')
|
||
const loading = ref(false)
|
||
const loadError = ref('')
|
||
const saving = ref(false)
|
||
const formRef = ref<FormInstance>()
|
||
const form = reactive({ name: '', account: '', password: '', phone: '', email: '', roleIds: [] as string[] })
|
||
const memberRules: FormRules = {
|
||
name: [{ required: true, message: '请输入成员姓名', trigger: 'blur' }],
|
||
account: [
|
||
{ required: true, message: '请输入登录账号', trigger: 'blur' },
|
||
{ pattern: /^[A-Za-z0-9_.@-]{4,64}$/, message: '账号需为 4–64 位字母、数字或 _ . @ -', trigger: 'blur' },
|
||
],
|
||
password: [
|
||
{ required: true, message: '请输入初始密码', trigger: 'blur' },
|
||
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码需为 8–64 位,并同时包含字母和数字', trigger: 'blur' },
|
||
],
|
||
phone: [{ max: 32, message: '联系电话不能超过 32 个字符', trigger: 'blur' }],
|
||
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
|
||
roleIds: [{ type: 'array', required: true, min: 1, message: '请至少选择一个成员角色', trigger: 'change' }],
|
||
}
|
||
|
||
const memberQueryFields: QueryField[] = [
|
||
{ key: 'keyword', type: 'input', placeholder: '姓名、账号、手机或邮箱' },
|
||
{ key: 'status', type: 'select', placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '已停用', value: 'INACTIVE' }] },
|
||
]
|
||
const memberTableColumns: DataTableColumn[] = [
|
||
{ key: 'actions', label: '操作', fixed: 'left', width: 230, slotName: 'actions' },
|
||
{ key: 'member', label: '成员', minWidth: 170, slotName: 'member' },
|
||
{ key: 'contact', label: '联系方式', minWidth: 200, slotName: 'contact' },
|
||
{ key: 'roles', label: '角色', minWidth: 130, slotName: 'roles' },
|
||
{ key: 'lastLoginAt', prop: 'lastLoginAt', label: '登录记录', minWidth: 130 },
|
||
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
|
||
]
|
||
|
||
const filteredMembers = computed(() => store.members.filter((member) => {
|
||
const key = appliedMemberFilters.value.keyword.trim().toLowerCase()
|
||
const matchesKeyword = !key || [member.name, member.account, member.phone, member.email].some((value) => value.toLowerCase().includes(key))
|
||
return matchesKeyword && (!appliedMemberFilters.value.status || member.status === appliedMemberFilters.value.status)
|
||
}))
|
||
const pagedMembers = computed(() => filteredMembers.value.slice((memberPage.value - 1) * memberPageSize.value, memberPage.value * memberPageSize.value))
|
||
|
||
async function loadMembers() {
|
||
loading.value = true
|
||
loadError.value = ''
|
||
try {
|
||
await Promise.all([store.loadRoles(), store.loadMembers()])
|
||
} catch (error) {
|
||
loadError.value = error instanceof Error ? error.message : '企业成员加载失败'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => void loadMembers())
|
||
|
||
function searchMembers(filters: QueryModel) {
|
||
appliedMemberFilters.value = {
|
||
keyword: typeof filters.keyword === 'string' ? filters.keyword : '',
|
||
status: filters.status === 'ACTIVE' || filters.status === 'INACTIVE' ? filters.status : '',
|
||
}
|
||
memberPage.value = 1
|
||
void loadMembers()
|
||
}
|
||
|
||
function resetForm() {
|
||
editingId.value = ''
|
||
Object.assign(form, { name: '', account: '', password: '', phone: '', email: '', roleIds: [] })
|
||
void nextTick(() => formRef.value?.clearValidate())
|
||
}
|
||
|
||
function openCreate() {
|
||
resetForm()
|
||
editorOpen.value = true
|
||
}
|
||
|
||
function openEdit(member: TenantMember) {
|
||
editingId.value = member.id
|
||
Object.assign(form, { name: member.name, account: member.account, password: '', phone: member.phone, email: member.email, roleIds: [...member.roleIds] })
|
||
editorOpen.value = true
|
||
}
|
||
|
||
async function saveMember() {
|
||
if (saving.value) return
|
||
form.name = form.name.trim()
|
||
form.account = form.account.trim()
|
||
form.phone = form.phone.trim()
|
||
form.email = form.email.trim()
|
||
if (!editingId.value || form.password) {
|
||
const valid = await formRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
} else {
|
||
const valid = await formRef.value?.validateField(['name', 'account', 'phone', 'email', 'roleIds']).then(() => true).catch(() => false)
|
||
if (!valid) return
|
||
}
|
||
saving.value = true
|
||
try {
|
||
if (editingId.value) {
|
||
await store.updateMember(editingId.value, { name: form.name, memberNo: form.account, phone: form.phone, email: form.email, roleIds: [...form.roleIds] })
|
||
ElMessage.success('成员信息已更新')
|
||
} else {
|
||
await store.createMember({ ...form, memberNo: form.account, roleIds: [...form.roleIds] })
|
||
ElMessage.success('成员已新增')
|
||
}
|
||
editorOpen.value = false
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '成员保存失败,请重试')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function toggleStatus(member: TenantMember) {
|
||
const nextLabel = member.status === 'ACTIVE' ? '停用' : '启用'
|
||
try {
|
||
await ElMessageBox.confirm(`${nextLabel}后将立即影响该成员的客户工作台访问。`, `${nextLabel}成员 ${member.name}`, { type: 'warning', confirmButtonText: nextLabel, cancelButtonText: '取消' })
|
||
await store.toggleMemberStatus(member.id)
|
||
ElMessage.success(`成员已${nextLabel}`)
|
||
} catch (error) {
|
||
if (error === 'cancel' || error === 'close') return
|
||
ElMessage.error(error instanceof Error ? error.message : '操作失败')
|
||
}
|
||
}
|
||
|
||
async function resetPassword(member: TenantMember) {
|
||
try {
|
||
const result = await ElMessageBox.prompt('请输入 8–64 位、同时包含字母和数字的新密码。重置后成员需要重新登录。', `重置 ${member.name} 的密码`, {
|
||
confirmButtonText: '确认重置',
|
||
cancelButtonText: '取消',
|
||
inputType: 'password',
|
||
inputPlaceholder: '请输入新密码',
|
||
inputValidator: (value) => /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/.test(value) || '密码需为 8–64 位,并同时包含字母和数字',
|
||
})
|
||
await store.resetMemberPassword(member.id, result.value)
|
||
ElMessage.success('成员密码已重置')
|
||
} catch (error) {
|
||
if (error === 'cancel' || error === 'close') return
|
||
ElMessage.error(error instanceof Error ? error.message : '密码重置失败,请重试')
|
||
}
|
||
}
|
||
|
||
</script>
|
||
|
||
<template>
|
||
<section v-loading="loading" class="members-page">
|
||
<div v-if="loadError" class="client-card collection-load-error" role="alert">
|
||
<div><strong>企业成员加载失败</strong><span>{{ loadError }}</span></div>
|
||
<el-button :loading="loading" @click="loadMembers">重新加载</el-button>
|
||
</div>
|
||
<template v-else>
|
||
<QueryToolbar v-model="memberFilters" ariaLabel="企业成员筛选" :fields="memberQueryFields" search-text="查询成员" @search="searchMembers" />
|
||
|
||
<div class="client-card member-table-card">
|
||
<div v-if="auth.hasPermission('company:member:write')" class="table-actions"><el-button type="primary" @click="openCreate"><Plus :size="17" />新增成员</el-button></div>
|
||
<DataTable v-model:page="memberPage" v-model:page-size="memberPageSize" class="desktop-member-table" :data="pagedMembers" :columns="memberTableColumns" :loading="loading" :total="filteredMembers.length" row-key="id">
|
||
<template #actions="{ row }"><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(row as TenantMember)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(row as TenantMember)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !(row as TenantMember).isAdministrator" link :type="(row as TenantMember).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ (row as TenantMember).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></template>
|
||
<template #member="{ row }"><div class="member-identity"><span>{{ (row as TenantMember).name.slice(0, 1) }}</span><div><strong>{{ (row as TenantMember).name }}</strong><small>{{ (row as TenantMember).account }}</small></div></div></template>
|
||
<template #contact="{ row }"><div class="member-contact"><span>{{ (row as TenantMember).phone }}</span><small>{{ (row as TenantMember).email || '-' }}</small></div></template>
|
||
<template #roles="{ row }"><div class="role-tags"><el-tag v-for="name in store.roleNames((row as TenantMember).roleIds)" :key="name" effect="plain">{{ name }}</el-tag><span v-if="store.roleNames((row as TenantMember).roleIds).length === 0" class="readonly-action">未分配</span></div></template>
|
||
<template #status="{ row }"><el-tag :type="(row as TenantMember).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as TenantMember).status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></template>
|
||
<template #empty><div class="client-empty">没有匹配的企业成员,请调整筛选条件。</div></template>
|
||
</DataTable>
|
||
|
||
<div v-if="pagedMembers.length" class="mobile-member-list">
|
||
<article v-for="member in pagedMembers" :key="member.id">
|
||
<header><div class="member-identity"><span>{{ member.name.slice(0, 1) }}</span><div><strong>{{ member.name }}</strong><small>{{ member.account }}</small></div></div><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ member.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></header>
|
||
<dl><div><dt>手机</dt><dd>{{ member.phone }}</dd></div><div><dt>最近登录</dt><dd>{{ member.lastLoginAt }}</dd></div><div class="full"><dt>角色</dt><dd>{{ store.roleNames(member.roleIds).join('、') }}</dd></div></dl>
|
||
<footer><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(member)">编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(member)">重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !member.isAdministrator" link :type="member.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(member)">{{ member.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></footer>
|
||
</article>
|
||
</div>
|
||
|
||
<div v-if="!pagedMembers.length" class="mobile-member-empty client-empty">没有匹配的企业成员,请调整筛选条件。</div>
|
||
</div>
|
||
|
||
<el-drawer v-model="editorOpen" :title="editingId ? '编辑成员' : '新增成员'" size="min(520px, 94vw)" @closed="resetForm">
|
||
<el-form ref="formRef" :model="form" :rules="memberRules" label-position="top">
|
||
<el-form-item label="成员姓名" prop="name" required><el-input v-model="form.name" maxlength="64" placeholder="例如 张三" /></el-form-item>
|
||
<el-form-item label="登录账号" prop="account" required><el-input v-model="form.account" :disabled="Boolean(editingId)" maxlength="64" placeholder="4–64 位字母、数字或 _ . @ -" /></el-form-item>
|
||
<el-form-item v-if="!editingId" label="初始密码" prop="password" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="8–64 位,同时包含字母和数字" /></el-form-item>
|
||
<div class="member-form-grid"><el-form-item label="联系电话" prop="phone"><el-input v-model="form.phone" maxlength="32" placeholder="例如 13800138000(选填)" /></el-form-item><el-form-item label="邮箱" prop="email"><el-input v-model="form.email" maxlength="128" placeholder="例如 name@example.com(选填)" /></el-form-item></div>
|
||
<el-form-item label="成员角色" prop="roleIds" required><el-select v-model="form.roleIds" multiple placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE')" :key="role.id" :label="role.name" :value="role.id" /></el-select></el-form-item>
|
||
<div v-if="!editingId" class="member-form-tip"><KeyRound :size="17" /><span>请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。</span></div>
|
||
</el-form>
|
||
<template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMember">保存</el-button></div></template>
|
||
</el-drawer>
|
||
</template>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.members-page { display: flex; flex-direction: column; gap: 14px; }
|
||
.collection-load-error { display: flex; min-height: 150px; align-items: center; justify-content: center; gap: 20px; }
|
||
.collection-load-error > div { display: flex; max-width: 520px; flex-direction: column; gap: 6px; }
|
||
.collection-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; }
|
||
.member-table-card { padding: 0; overflow: hidden; }
|
||
.table-actions { display: flex; justify-content: flex-end; padding: 12px 16px; border-bottom: 1px solid var(--border); }
|
||
.member-identity { display: flex; align-items: center; gap: 10px; }
|
||
.member-identity > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 50%; background: #e7f3f1; color: var(--primary); font-weight: 750; }
|
||
.member-identity > div, .member-contact { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||
.member-identity strong, .member-identity small, .member-contact small { overflow-wrap: anywhere; }
|
||
.member-identity small, .member-contact small { color: var(--muted); font-size: 10px; }
|
||
.role-tags { display: flex; gap: 5px; flex-wrap: wrap; }
|
||
.mobile-member-list { display: none; }
|
||
.member-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||
.member-form-tip { display: flex; gap: 10px; padding: 13px; border: 1px solid #cfe1dc; background: #f2f7f6; color: var(--muted); font-size: 11px; line-height: 1.6; }
|
||
.member-form-tip svg { flex: 0 0 17px; color: var(--primary); }
|
||
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||
.members-page :deep(.el-form-item__error) { line-height: 1.45; overflow-wrap: anywhere; }
|
||
@media (max-width: 720px) {
|
||
.member-form-grid { grid-template-columns: 1fr; }
|
||
.desktop-member-table :deep(.el-table) { display: none; }
|
||
.mobile-member-list { display: grid; gap: 10px; padding: 10px; }
|
||
.mobile-member-empty { display: grid; min-height: 150px; place-items: center; }
|
||
.mobile-member-list article { padding: 14px; border: 1px solid var(--border); background: #fff; }
|
||
.mobile-member-list header { display: flex; align-items: center; justify-content: space-between; }
|
||
.mobile-member-list dl { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0; }
|
||
.mobile-member-list dl .full { grid-column: 1 / -1; }
|
||
.mobile-member-list dt { color: var(--muted); font-size: 10px; }
|
||
.mobile-member-list dd { margin: 4px 0 0; font-size: 12px; }
|
||
.mobile-member-list footer { display: flex; justify-content: flex-end; padding-top: 10px; border-top: 1px solid var(--border); }
|
||
.drawer-actions { flex-wrap: wrap; }
|
||
}
|
||
</style>
|