207 lines
14 KiB
Vue
207 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { KeyRound, Pencil, Plus, RotateCcw, Search, ShieldCheck, UserCheck, UserRoundX } from '@lucide/vue'
|
||
import { useTenantAdministrationStore, type TenantMember, type TenantRecordStatus } from '@/stores/tenantAdministration'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
|
||
const store = useTenantAdministrationStore()
|
||
const auth = useAuthStore()
|
||
const keyword = ref('')
|
||
const statusFilter = ref<TenantRecordStatus | ''>('')
|
||
const editorOpen = ref(false)
|
||
const editingId = ref('')
|
||
const loading = ref(false)
|
||
const saving = ref(false)
|
||
const form = reactive({ name: '', account: '', password: '', phone: '', email: '', roleIds: [] as string[] })
|
||
|
||
const filteredMembers = computed(() => store.members.filter((member) => {
|
||
const key = keyword.value.trim().toLowerCase()
|
||
const matchesKeyword = !key || [member.name, member.account, member.phone, member.email].some((value) => value.toLowerCase().includes(key))
|
||
return matchesKeyword && (!statusFilter.value || member.status === statusFilter.value)
|
||
}))
|
||
|
||
onMounted(async () => {
|
||
loading.value = true
|
||
try {
|
||
await Promise.all([store.loadRoles(), store.loadMembers()])
|
||
} catch (error) {
|
||
ElMessage.error(error instanceof Error ? error.message : '企业成员加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
})
|
||
|
||
function resetForm() {
|
||
editingId.value = ''
|
||
Object.assign(form, { name: '', account: '', password: '', phone: '', email: '', roleIds: [] })
|
||
}
|
||
|
||
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
|
||
if (!form.name.trim() || !/^[A-Za-z0-9_.@-]{4,64}$/.test(form.account) || form.roleIds.length === 0) {
|
||
ElMessage.warning('请填写姓名、4–64 位合法账号并至少选择一个角色')
|
||
return
|
||
}
|
||
if (form.phone.length > 32 || (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))) {
|
||
ElMessage.warning('请检查联系电话长度和邮箱格式')
|
||
return
|
||
}
|
||
if (!editingId.value && (!/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/.test(form.password))) {
|
||
ElMessage.warning('初始密码需为 8–64 位,并同时包含字母和数字')
|
||
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 : '密码重置失败,请重试')
|
||
}
|
||
}
|
||
|
||
function clearFilters() {
|
||
keyword.value = ''
|
||
statusFilter.value = ''
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section v-loading="loading" class="members-page">
|
||
<div v-if="store.membersLoadedFromApi" class="member-contract-notice client-card">成员资料、角色回显、新增、编辑、启停和密码重置均使用当前企业的真实接口;企业所有者保护和租户隔离由服务端最终校验。</div>
|
||
<div class="member-metrics">
|
||
<article><UserCheck :size="20" /><div><span>成员总数</span><strong>{{ store.members.length }}</strong></div></article>
|
||
<article><ShieldCheck :size="20" /><div><span>正常成员</span><strong>{{ store.activeMemberCount }}</strong></div></article>
|
||
<article><UserRoundX :size="20" /><div><span>已停用</span><strong>{{ store.members.length - store.activeMemberCount }}</strong></div></article>
|
||
</div>
|
||
|
||
<div class="member-toolbar client-card">
|
||
<div class="member-filters">
|
||
<el-input v-model="keyword" clearable placeholder="姓名、账号、手机或邮箱"><template #prefix><Search :size="16" /></template></el-input>
|
||
<el-select v-model="statusFilter" clearable placeholder="全部状态"><el-option label="正常" value="ACTIVE" /><el-option label="已停用" value="INACTIVE" /></el-select>
|
||
<el-button @click="clearFilters"><RotateCcw :size="16" />重置</el-button>
|
||
</div>
|
||
<el-button v-if="auth.hasPermission('company:member:write')" type="primary" @click="openCreate"><Plus :size="17" />新增成员</el-button>
|
||
</div>
|
||
|
||
<div class="client-card member-table-card">
|
||
<el-table v-if="filteredMembers.length" class="desktop-member-table" :data="filteredMembers" row-key="id">
|
||
<el-table-column label="成员" min-width="180"><template #default="{ row }"><div class="member-identity"><span>{{ row.name.slice(0, 1) }}</span><div><strong>{{ row.name }}</strong><small>{{ row.account }}</small></div></div></template></el-table-column>
|
||
<el-table-column label="联系方式" min-width="190"><template #default="{ row }"><div class="member-contact"><span>{{ row.phone }}</span><small>{{ row.email || '-' }}</small></div></template></el-table-column>
|
||
<el-table-column label="角色" min-width="180"><template #default="{ row }"><div class="role-tags"><el-tag v-for="name in store.roleNames(row.roleIds)" :key="name" effect="plain">{{ name }}</el-tag><span v-if="store.roleNames(row.roleIds).length === 0" class="readonly-action">未分配</span></div></template></el-table-column>
|
||
<el-table-column prop="lastLoginAt" label="登录记录" min-width="150" />
|
||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ row.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></template></el-table-column>
|
||
<el-table-column label="操作" width="250" fixed="right"><template #default="{ 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.isAdministrator" link :type="row.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></template></el-table-column>
|
||
</el-table>
|
||
|
||
<div v-if="filteredMembers.length" class="mobile-member-list">
|
||
<article v-for="member in filteredMembers" :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-else class="client-empty">没有匹配的企业成员,请调整筛选条件。</div>
|
||
</div>
|
||
|
||
<el-drawer v-model="editorOpen" :title="editingId ? '编辑成员' : '新增成员'" size="min(520px, 94vw)" @closed="resetForm">
|
||
<el-form label-position="top">
|
||
<el-form-item label="成员姓名" required><el-input v-model="form.name" maxlength="64" /></el-form-item>
|
||
<el-form-item label="登录账号" 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="初始密码" 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="联系电话"><el-input v-model="form.phone" maxlength="32" placeholder="选填" /></el-form-item><el-form-item label="邮箱"><el-input v-model="form.email" maxlength="128" placeholder="选填" /></el-form-item></div>
|
||
<el-form-item label="成员角色" 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>
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.members-page { display: flex; flex-direction: column; gap: 14px; }
|
||
.member-contract-notice { padding: 13px 16px; border-left: 3px solid #d97706; background: #fff8eb; color: #8a5b10; font-size: 11px; line-height: 1.6; }
|
||
.member-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||
.member-metrics article { display: flex; align-items: center; gap: 14px; padding: 18px 20px; border: 1px solid var(--border); background: #fff; color: var(--primary); }
|
||
.member-metrics article div { display: flex; flex-direction: column; gap: 2px; }
|
||
.member-metrics span { color: var(--muted); font-size: 11px; }
|
||
.member-metrics strong { color: var(--navy); font-size: 23px; }
|
||
.member-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px; }
|
||
.member-filters { display: grid; flex: 1; grid-template-columns: minmax(210px, 1fr) 150px auto; gap: 10px; }
|
||
.member-table-card { padding: 0; overflow: hidden; }
|
||
.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 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-left: 3px solid var(--primary); 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; }
|
||
@media (max-width: 900px) { .member-toolbar { align-items: stretch; flex-direction: column; } .member-toolbar > .el-button { align-self: flex-end; } }
|
||
@media (max-width: 720px) {
|
||
.member-metrics { grid-template-columns: 1fr; }
|
||
.member-filters, .member-form-grid { grid-template-columns: 1fr; }
|
||
.member-toolbar > .el-button { width: 100%; }
|
||
.desktop-member-table { display: none; }
|
||
.mobile-member-list { display: grid; gap: 10px; padding: 10px; }
|
||
.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); }
|
||
}
|
||
</style>
|