Files
client-web/src/views/MembersView.vue
T
2026-08-03 10:51:53 +08:00

239 lines
16 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.
<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>