2026-07-30 17:59:42 +08:00
< script setup lang = "ts" >
2026-08-01 12:17:33 +08:00
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'
2026-07-30 17:59:42 +08:00
import { useTenantAdministrationStore , type TenantMember , type TenantRecordStatus } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth'
const store = useTenantAdministrationStore ()
const auth = useAuthStore ()
2026-08-01 12:17:33 +08:00
const memberFilters = ref ({ keyword : '' , status : '' as TenantRecordStatus | '' })
const appliedMemberFilters = ref ({ ... memberFilters . value })
const memberPage = ref ( 1 )
const memberPageSize = ref ( 10 )
2026-07-30 17:59:42 +08:00
const editorOpen = ref ( false )
const editingId = ref ( '' )
const loading = ref ( false )
2026-07-31 15:34:22 +08:00
const loadError = ref ( '' )
2026-07-30 17:59:42 +08:00
const saving = ref ( false )
2026-08-01 12:17:33 +08:00
const formRef = ref < FormInstance >()
2026-07-30 17:59:42 +08:00
const form = reactive ({ name : '' , account : '' , password : '' , phone : '' , email : '' , roleIds : [] as string [] })
2026-08-01 12:17:33 +08:00
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' },
]
2026-07-30 17:59:42 +08:00
const filteredMembers = computed (() => store . members . filter (( member ) => {
2026-08-01 12:17:33 +08:00
const key = appliedMemberFilters . value . keyword . trim (). toLowerCase ()
2026-07-30 17:59:42 +08:00
const matchesKeyword = ! key || [ member . name , member . account , member . phone , member . email ]. some (( value ) => value . toLowerCase (). includes ( key ))
2026-08-01 12:17:33 +08:00
return matchesKeyword && ( ! appliedMemberFilters . value . status || member . status === appliedMemberFilters . value . status )
2026-07-30 17:59:42 +08:00
}))
2026-08-01 12:17:33 +08:00
const pagedMembers = computed (() => filteredMembers . value . slice (( memberPage . value - 1 ) * memberPageSize . value , memberPage . value * memberPageSize . value ))
2026-07-30 17:59:42 +08:00
2026-07-31 15:34:22 +08:00
async function loadMembers () {
2026-07-30 17:59:42 +08:00
loading . value = true
2026-07-31 15:34:22 +08:00
loadError . value = ''
2026-07-30 17:59:42 +08:00
try {
await Promise . all ([ store . loadRoles (), store . loadMembers ()])
} catch ( error ) {
2026-07-31 15:34:22 +08:00
loadError . value = error instanceof Error ? error . message : '企业成员加载失败'
2026-07-30 17:59:42 +08:00
} finally {
loading . value = false
}
2026-07-31 15:34:22 +08:00
}
onMounted (() => void loadMembers ())
2026-07-30 17:59:42 +08:00
2026-08-01 12:17:33 +08:00
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 ()
}
2026-07-30 17:59:42 +08:00
function resetForm () {
editingId . value = ''
Object . assign ( form , { name : '' , account : '' , password : '' , phone : '' , email : '' , roleIds : [] })
2026-08-01 12:17:33 +08:00
void nextTick (() => formRef . value ? . clearValidate ())
2026-07-30 17:59:42 +08:00
}
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
2026-08-01 12:17:33 +08:00
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
2026-07-30 17:59:42 +08:00
}
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" >
2026-07-31 15:34:22 +08:00
< 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 >
2026-08-01 12:17:33 +08:00
< QueryToolbar v-model = "memberFilters" ariaLabel="企业成员筛选" :fields="memberQueryFields" search-text="查询成员" @search="searchMembers" >
< template # actions >< el-button v-if = "auth.hasPermission('company:member:write')" type="primary" @click="openCreate"><Plus :size="17" /> 新增成员 </ el-button ></ template >
</ QueryToolbar >
2026-07-30 17:59:42 +08:00
< div class = "client-card member-table-card" >
2026-08-01 12:17:33 +08:00
< 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 >
2026-07-30 17:59:42 +08:00
2026-08-01 12:17:33 +08:00
< div v-if = "pagedMembers.length" class="mobile-member-list" >
< article v-for = "member in pagedMembers" :key="member.id" >
2026-07-30 17:59:42 +08:00
< 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 >
2026-08-01 12:17:33 +08:00
< div v-if = "!pagedMembers.length" class="mobile-member-empty client-empty" > 没有匹配的企业成员 , 请调整筛选条件 。 </ div >
2026-07-30 17:59:42 +08:00
</ div >
< el-drawer v-model = "editorOpen" :title="editingId ? '编辑成员' : '新增成员'" size="min(520px, 94vw)" @closed="resetForm" >
2026-08-01 12:17:33 +08:00
< 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 >
2026-07-30 17:59:42 +08:00
< 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 >
2026-07-31 15:34:22 +08:00
</ template >
2026-07-30 17:59:42 +08:00
</ section >
</ template >
< style scoped >
. members - page { display : flex ; flex - direction : column ; gap : 14 px ; }
2026-07-31 15:34:22 +08:00
. collection - load - error { display : flex ; min - height : 150 px ; align - items : center ; justify - content : center ; gap : 20 px ; }
. collection - load - error > div { display : flex ; max - width : 520 px ; flex - direction : column ; gap : 6 px ; }
. collection - load - error span { color : var ( -- muted ); font - size : 12 px ; line - height : 1.6 ; }
2026-07-30 17:59:42 +08:00
. member - table - card { padding : 0 ; overflow : hidden ; }
. member - identity { display : flex ; align - items : center ; gap : 10 px ; }
. member - identity > span { display : grid ; width : 34 px ; height : 34 px ; flex : 0 0 34 px ; 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 : 3 px ; }
2026-07-30 18:08:40 +08:00
. member - identity strong , . member - identity small , . member - contact small { overflow - wrap : anywhere ; }
2026-07-30 17:59:42 +08:00
. member - identity small , . member - contact small { color : var ( -- muted ); font - size : 10 px ; }
. role - tags { display : flex ; gap : 5 px ; flex - wrap : wrap ; }
. mobile - member - list { display : none ; }
. member - form - grid { display : grid ; grid - template - columns : 1 fr 1 fr ; gap : 14 px ; }
2026-08-01 12:17:33 +08:00
. member - form - tip { display : flex ; gap : 10 px ; padding : 13 px ; border : 1 px solid # cfe1dc ; background : # f2f7f6 ; color : var ( -- muted ); font - size : 11 px ; line - height : 1.6 ; }
2026-07-30 17:59:42 +08:00
. member - form - tip svg { flex : 0 0 17 px ; color : var ( -- primary ); }
. drawer - actions { display : flex ; justify - content : flex - end ; gap : 8 px ; }
2026-08-01 12:17:33 +08:00
. members - page : deep (. el - form - item__error ) { line - height : 1.45 ; overflow - wrap : anywhere ; }
2026-07-30 17:59:42 +08:00
@ media ( max - width : 720 px ) {
2026-08-01 12:17:33 +08:00
. member - form - grid { grid - template - columns : 1 fr ; }
. desktop - member - table : deep (. el - table ) { display : none ; }
2026-07-30 17:59:42 +08:00
. mobile - member - list { display : grid ; gap : 10 px ; padding : 10 px ; }
2026-08-01 12:17:33 +08:00
. mobile - member - empty { display : grid ; min - height : 150 px ; place - items : center ; }
2026-07-30 17:59:42 +08:00
. mobile - member - list article { padding : 14 px ; border : 1 px 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 : 1 fr 1 fr ; gap : 12 px ; margin : 16 px 0 ; }
. mobile - member - list dl . full { grid - column : 1 / - 1 ; }
. mobile - member - list dt { color : var ( -- muted ); font - size : 10 px ; }
. mobile - member - list dd { margin : 4 px 0 0 ; font - size : 12 px ; }
. mobile - member - list footer { display : flex ; justify - content : flex - end ; padding - top : 10 px ; border - top : 1 px solid var ( -- border ); }
2026-08-01 12:17:33 +08:00
. drawer - actions { flex - wrap : wrap ; }
2026-07-30 17:59:42 +08:00
}
</ style >