Files
client-web/src/views/MembersView.vue
T
2026-07-30 17:59:42 +08:00

207 lines
14 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, 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>