This commit is contained in:
2026-08-14 16:06:33 +08:00
parent 09e44e6520
commit 610cc1abf9
17 changed files with 135 additions and 41 deletions
@@ -25,6 +25,7 @@ const auditStatusCopy = computed(() => ({
ACTIVE: { title: '企业资料已审核,下单资格已开通', detail: '您可以创建、保存并提交头程订单。' },
REJECTED: { title: '企业资料审核未通过', detail: '请根据平台反馈替换对应资质,必填文件齐全后可重新提交审核。' },
DISABLED: { title: '企业账号已被平台停用', detail: '当前不能提交订单,请联系平台客服核实。' },
ARCHIVED: { title: '企业已归档', detail: '该企业已结束使用,资料和历史业务仅供查看,不能再提交或修改。' },
})[store.company.auditStatus])
const activeTab = ref('basic')
const editing = ref(false)
@@ -114,6 +115,7 @@ const qualificationUploadDisabledReason = computed(() => {
if (store.company.auditStatus === 'PENDING') return '企业资料正在审核,暂不能再次提交资质文件。'
if (store.company.auditStatus === 'ACTIVE') return '当前企业已审核通过,资质替换请联系平台管理员处理。'
if (store.company.auditStatus === 'DISABLED') return '企业账号已停用,暂不能提交资质文件。'
if (store.company.auditStatus === 'ARCHIVED') return '企业已归档,不能再提交资质文件。'
return ''
})
const selectedQualificationCount = computed(() => qualificationDrafts.filter((item) => Boolean(item.file)).length)
@@ -183,6 +185,7 @@ onMounted(() => void loadCompanyContext())
onBeforeUnmount(() => qualificationDrafts.forEach(releaseQualificationPreview))
function startEdit() {
if (store.company.auditStatus === 'ARCHIVED') return
Object.assign(draft, store.company)
profileFormRef.value?.clearValidate()
editing.value = true
@@ -487,7 +490,7 @@ function closeQualificationPreview() {
<el-tab-pane name="basic">
<template #label><span class="tab-label"><Building2 :size="16" />企业基本信息</span></template>
<div class="client-card info-panel">
<header class="info-panel-header"><h3>企业基本信息</h3><el-button v-if="auth.hasPermission('company:profile:edit')" type="primary" @click="startEdit"><Pencil :size="16" />编辑企业资料</el-button></header>
<header class="info-panel-header"><h3>企业基本信息</h3><el-button v-if="auth.hasPermission('company:profile:edit') && store.company.auditStatus !== 'ARCHIVED'" type="primary" @click="startEdit"><Pencil :size="16" />编辑企业资料</el-button></header>
<dl class="profile-grid">
<div><dt>公司名称</dt><dd>{{ store.company.companyName || '-' }}</dd></div>
<div><dt>统一社会信用代码</dt><dd>{{ store.company.creditCode || '-' }}</dd></div>
@@ -54,6 +54,11 @@ const filteredMembers = computed(() => store.members.filter((member) => {
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))
const canCreateMember = computed(() => auth.hasPermission('company:member:create'))
const canEditMember = computed(() => auth.hasPermission('company:member:edit'))
const canGrantMemberRole = computed(() => auth.hasPermission('company:member:grant-role'))
const canChangeMemberStatus = computed(() => auth.hasPermission('company:member:status'))
const canResetMemberPassword = computed(() => auth.hasPermission('company:member:reset-password'))
async function loadMembers() {
loading.value = true
@@ -166,10 +171,10 @@ async function resetPassword(member: TenantMember) {
<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>
<div v-if="canCreateMember" class="table-actions"><el-button type="primary" @click="openCreate"><Plus :size="17" />新增成员</el-button></div>
<div class="desktop-member-table">
<DataTable v-model:page="memberPage" v-model:page-size="memberPageSize" :data="pagedMembers" :columns="memberTableColumns" :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 #actions="{ row }"><el-button v-if="canEditMember" link type="primary" @click="openEdit(row as TenantMember)"><Pencil :size="15" />编辑</el-button><el-button v-if="canResetMemberPassword" link type="primary" @click="resetPassword(row as TenantMember)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="canChangeMemberStatus && !(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="!canEditMember && !canResetMemberPassword && !canChangeMemberStatus" 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>
@@ -182,7 +187,7 @@ async function resetPassword(member: TenantMember) {
<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>
<footer><el-button v-if="canEditMember" link type="primary" @click="openEdit(member)">编辑</el-button><el-button v-if="canResetMemberPassword" link type="primary" @click="resetPassword(member)">重置密码</el-button><el-button v-if="canChangeMemberStatus && !member.isAdministrator" link :type="member.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(member)">{{ member.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!canEditMember && !canResetMemberPassword && !canChangeMemberStatus" class="readonly-action">仅查看</span></footer>
</article>
</div>
@@ -195,7 +200,7 @@ async function resetPassword(member: TenantMember) {
<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>
<el-form-item label="成员角色" prop="roleIds" required><el-select v-model="form.roleIds" multiple :disabled="Boolean(editingId) && !canGrantMemberRole" 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>
@@ -43,6 +43,10 @@ const filteredRoles = computed(() => {
return store.roles.filter((role) => !key || [role.name, role.code, role.description].some((value) => value.toLowerCase().includes(key)))
})
const pagedRoles = computed(() => filteredRoles.value.slice((rolePage.value - 1) * rolePageSize.value, rolePage.value * rolePageSize.value))
const canCreateRole = computed(() => auth.hasPermission('company:role:create'))
const canEditRole = computed(() => auth.hasPermission('company:role:edit'))
const canChangeRoleStatus = computed(() => auth.hasPermission('company:role:status'))
const canDeleteRole = computed(() => auth.hasPermission('company:role:delete'))
function resetForm() {
editingId.value = ''
@@ -147,9 +151,9 @@ function assignPermissions(role: TenantRole) {
<QueryToolbar v-model="roleFilters" ariaLabel="企业角色筛选" :fields="roleQueryFields" search-text="查询" @search="searchRoles" />
<div class="client-card role-table-card">
<div v-if="auth.hasPermission('company:role:write')" class="table-actions"><el-button type="primary" @click="openCreate"><Plus :size="17" />新增角色</el-button></div>
<div v-if="canCreateRole" class="table-actions"><el-button type="primary" @click="openCreate"><Plus :size="17" />新增角色</el-button></div>
<DataTable v-model:page="rolePage" v-model:page-size="rolePageSize" :data="pagedRoles" :columns="roleTableColumns" :total="filteredRoles.length" row-key="id">
<template #actions="{ row }"><el-button v-if="auth.hasPermission('company:permission:list')" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="assignPermissions(row as TenantRole)">{{ auth.hasPermission('company:permission:assign') && !(row as TenantRole).builtin ? '分配权限' : '查看权限' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="openEdit(row as TenantRole)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link :type="(row as TenantRole).status === 'ACTIVE' ? 'warning' : 'success'" :loading="actionRoleId === (row as TenantRole).id" @click="toggleStatus(row as TenantRole)">{{ (row as TenantRole).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link type="danger" :disabled="actionRoleId === (row as TenantRole).id" @click="removeRole(row as TenantRole)"><Trash2 :size="15" />删除</el-button></template>
<template #actions="{ row }"><el-button v-if="auth.hasPermission('company:permission:list')" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="assignPermissions(row as TenantRole)">{{ auth.hasPermission('company:permission:assign') && !(row as TenantRole).builtin ? '分配权限' : '查看权限' }}</el-button><el-button v-if="canEditRole && !(row as TenantRole).builtin" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="openEdit(row as TenantRole)"><Pencil :size="15" />编辑</el-button><el-button v-if="canChangeRoleStatus && !(row as TenantRole).builtin" link :type="(row as TenantRole).status === 'ACTIVE' ? 'warning' : 'success'" :loading="actionRoleId === (row as TenantRole).id" @click="toggleStatus(row as TenantRole)">{{ (row as TenantRole).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="canDeleteRole && !(row as TenantRole).builtin" link type="danger" :disabled="actionRoleId === (row as TenantRole).id" @click="removeRole(row as TenantRole)"><Trash2 :size="15" />删除</el-button></template>
<template #role="{ row }"><div class="role-identity"><span class="role-icon"><LockKeyhole v-if="(row as TenantRole).builtin" :size="20" /><ShieldCheck v-else :size="20" /></span><div><strong>{{ (row as TenantRole).name }}</strong><code>{{ (row as TenantRole).code }}</code></div></div></template>
<template #memberCount="{ row }"><span>{{ store.roleMemberCount((row as TenantRole).id) }}</span></template>
<template #permissionCount="{ row }"><span>{{ (row as TenantRole).permissionCodes.length }}</span></template>
@@ -123,12 +123,20 @@ test('implements member list, role assignment, password reset and status control
assert.doesNotMatch(membersSource, /<DataTable[^>]*:loading="loading"/)
assert.match(membersSource, /fixed: 'left'/)
assert.match(membersSource, /class="table-actions"/)
assert.doesNotMatch(membersSource, /template #actions><el-button v-if="auth\.hasPermission\('company:member:write'\)/)
assert.match(membersSource, /canCreateMember.*company:member:create/)
assert.match(membersSource, /canEditMember.*company:member:edit/)
assert.match(membersSource, /canGrantMemberRole.*company:member:grant-role/)
assert.match(membersSource, /canChangeMemberStatus.*company:member:status/)
assert.doesNotMatch(membersSource, /company:member:write/)
assert.doesNotMatch(membersSource, /member-metrics/)
})
test('protects built-in roles and exposes role permission assignment', () => {
assert.match(rolesSource, /auth\.hasPermission\('company:role:write'\).*\.builtin/)
assert.match(rolesSource, /canCreateRole.*company:role:create/)
assert.match(rolesSource, /canEditRole.*company:role:edit/)
assert.match(rolesSource, /canChangeRoleStatus.*company:role:status/)
assert.match(rolesSource, /canDeleteRole.*company:role:delete/)
assert.doesNotMatch(rolesSource, /company:role:write/)
assert.match(rolesSource, /function assignPermissions\(role: TenantRole\)/)
assert.match(storeSource, /if \(role\.builtin\) throw new Error\('内置角色不能删除'\)/)
assert.match(storeSource, /if \(members\.value\.some\(\(member\) => member\.roleIds\.includes\(id\)\)\)/)
@@ -152,15 +160,19 @@ test('protects built-in roles and exposes role permission assignment', () => {
assert.doesNotMatch(rolesSource, /<DataTable[^>]*:loading="loading"/)
assert.match(rolesSource, /fixed: 'left'/)
assert.match(rolesSource, /class="table-actions"/)
assert.doesNotMatch(rolesSource, /template #actions><el-button v-if="auth\.hasPermission\('company:role:write'\)/)
assert.match(rolesSource, /canEditRole && !\(row as TenantRole\)\.builtin/)
assert.doesNotMatch(rolesSource, /role-guidance/)
})
test('shows company audit status and applies button permissions', () => {
assert.match(profileSource, /companyApproved/)
assert.match(profileSource, /ARCHIVED: \{ title: '企业已归档'/)
assert.match(profileSource, /auditStatus !== 'ARCHIVED'/)
assert.match(profileSource, /企业资料正在等待平台审核/)
assert.match(profileSource, /auth\.hasPermission\('company:profile:edit'\)/)
assert.match(membersSource, /auth\.hasPermission\('company:member:write'\)/)
assert.match(membersSource, /company:member:create/)
assert.match(membersSource, /company:member:edit/)
assert.match(membersSource, /company:member:status/)
assert.match(permissionsSource, /auth\.hasPermission\('company:permission:assign'\)/)
assert.match(storeSource, /auditStatus: 'PROFILE_REQUIRED'/)
assert.match(storeSource, /loadCompanyContext/)