优化构架

This commit is contained in:
2026-08-03 18:52:21 +08:00
parent 3e347dd7a5
commit 35c713a7ca
51 changed files with 0 additions and 0 deletions
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const source = readFileSync(new URL('./merchantIam.ts', import.meta.url), 'utf8')
const storeSource = readFileSync(new URL('../stores/tenantAdministration.ts', import.meta.url), 'utf8')
const authSource = readFileSync(new URL('../stores/auth.ts', import.meta.url), 'utf8')
test('declares the documented merchant role and member endpoints', () => {
assert.match(source, /url: '\/merchant\/v1\/iam\/roles'/)
assert.match(source, /method: 'PUT', url: `\/merchant\/v1\/iam\/roles\/\$\{roleId\}`/)
assert.match(source, /method: 'PUT', url: `\/merchant\/v1\/iam\/roles\/\$\{roleId\}\/status`/)
assert.match(source, /method: 'DELETE', url: `\/merchant\/v1\/iam\/roles\/\$\{roleId\}`/)
assert.match(source, /url: '\/merchant\/v1\/iam\/roles\/permissions'/)
assert.match(source, /url: '\/merchant\/v1\/iam\/members'/)
assert.match(source, /url: '\/merchant\/v1\/iam\/members\/roles'/)
assert.match(source, /url: `\/merchant\/v1\/iam\/members\/\$\{membershipId\}`/)
assert.match(source, /url: `\/merchant\/v1\/iam\/members\/\$\{membershipId\}\/status`/)
assert.match(source, /url: `\/merchant\/v1\/iam\/members\/\$\{membershipId\}\/password`/)
assert.match(source, /url: '\/merchant\/v1\/iam\/profile'/)
})
test('supports tenant profile updates and authenticated qualification downloads', () => {
assert.match(source, /method: 'PUT', url: '\/merchant\/v1\/iam\/tenant\/profile'/)
assert.match(source, /apiBlobRequest\(\{ method: 'GET', url \}\)/)
assert.match(source, /url\.startsWith\('\/merchant\/v1\/files\/qualifications\/'\)/)
assert.match(storeSource, /await updateTenantProfile/)
})
test('uses membershipId and full replacement arrays in authorization writes', () => {
assert.match(source, /data: \{ roleId, permissionIds \}/)
assert.match(source, /data: \{ membershipId, roleIds \}/)
assert.doesNotMatch(source, /data: \{ userId, roleIds \}/)
})
test('starts empty and only loads server role and member records', () => {
assert.match(storeSource, /const members = ref<TenantMember\[]>\(\[\]\)/)
assert.match(storeSource, /const roles = ref<TenantRole\[]>\(\[\]\)/)
assert.match(storeSource, /async function loadRoles\(\) \{\s+roles\.value = \[\]/)
assert.match(storeSource, /async function loadMembers\(\) \{\s+members\.value = \[\]/)
assert.doesNotMatch(storeSource, /initialRoles|initialMembers|示例贸易|演示大道/)
assert.match(storeSource, /await createMerchantRole/)
assert.match(storeSource, /await updateMerchantRole/)
assert.match(storeSource, /await changeMerchantRoleStatus/)
assert.match(storeSource, /await deleteMerchantRole/)
assert.match(storeSource, /await createMerchantMember/)
assert.match(storeSource, /await updateMerchantMember/)
assert.match(storeSource, /await changeMerchantMemberStatus/)
assert.match(storeSource, /await resetMerchantMemberPassword/)
})
test('shares the merchant permission map and preserves resources not rendered by the role editor', () => {
assert.match(storeSource, /export const FRONTEND_TO_MERCHANT_PERMISSION_CODES/)
assert.match(storeSource, /'order:first-leg:cancel': \['merchant:order:cancel'\]/)
assert.match(authSource, /FRONTEND_TO_MERCHANT_PERMISSION_CODES/)
assert.match(authSource, /Object\.entries\(FRONTEND_TO_MERCHANT_PERMISSION_CODES\)/)
assert.match(storeSource, /const preservedPermissionIds = role\.permissionIds\.filter/)
assert.match(storeSource, /const nextPermissionIds = \[\.\.\.new Set\(\[\.\.\.preservedPermissionIds, \.\.\.selectedPermissionIds\]\)\]/)
assert.match(storeSource, /await replaceMerchantRolePermissions\(id, nextPermissionIds\)/)
})
@@ -0,0 +1,205 @@
import { apiBlobRequest, apiRequest } from '@/api/http'
export type MerchantTenantStatus = 'PROFILE_REQUIRED' | 'PENDING' | 'ACTIVE' | 'REJECTED' | 'DISABLED'
export type QualificationStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
export interface MerchantTenantProfile {
id: number | string
tenantCode: string
tenantName: string
status: MerchantTenantStatus
legalName: string
unifiedSocialCreditCode: string | null
contactName: string
contactPhone: string
contactEmail: string | null
}
export interface UpdateMerchantTenantProfileRequest {
tenantName: string
legalName: string
contactName: string
contactPhone: string
contactEmail: string
}
export interface MerchantQualification {
id: number | string
tenantId: number | string
qualificationType: 'BUSINESS_LICENSE'
certificateNo: string | null
documentUrl: string
status: QualificationStatus
reviewComment: string | null
}
export interface MerchantPermissionResource {
id: number | string
tenantId: number | string
appCode: 'MERCHANT'
permissionType: 'MENU' | 'BUTTON'
parentId: number | string
permissionCode: string
permissionName: string
routePath: string | null
httpMethod: string | null
sortNo: number
status: 'ACTIVE' | string
}
export interface QualificationUploadResult {
qualificationId: number | string
reviewId: number | string
tenantStatus: 'PENDING'
documentUrl: string
}
export interface MerchantRole {
id: number | string
tenantId: number | string
appCode: 'MERCHANT'
roleCode: string
roleName: string
roleType: 'SYSTEM' | 'CUSTOM' | string
status: 'ACTIVE' | 'INACTIVE' | string
description: string | null
permissionIds: Array<number | string>
}
export interface CreateMerchantRoleRequest {
roleCode: string
roleName: string
description: string
}
export interface UpdateMerchantRoleRequest {
roleName: string
description: string
}
export interface MerchantMember {
membershipId: number | string
userId: number | string
authUserId: number | string
displayName: string
phone: string | null
email: string | null
memberType: 'OWNER' | string
memberNo: string
status: 'ACTIVE' | 'INACTIVE' | string
roleIds: Array<number | string>
}
export interface MerchantPersonalProfile {
userId: number | string
authUserId: number | string
username: string
displayName: string
phone: string | null
email: string | null
}
export interface CreateMerchantMemberRequest {
username: string
password: string
displayName: string
phone: string
email: string
memberType: 'EMPLOYEE'
memberNo: string
roleIds: Array<number | string>
}
export interface UpdateMerchantMemberRequest {
displayName: string
phone: string
email: string
memberType: string
memberNo: string
roleIds: Array<number | string>
}
export function getTenantProfile() {
return apiRequest<MerchantTenantProfile>({ method: 'GET', url: '/merchant/v1/iam/tenant/profile' })
}
export function updateTenantProfile(payload: UpdateMerchantTenantProfileRequest) {
return apiRequest<MerchantTenantProfile>({ method: 'PUT', url: '/merchant/v1/iam/tenant/profile', data: payload })
}
export function getTenantQualifications() {
return apiRequest<MerchantQualification[]>({ method: 'GET', url: '/merchant/v1/iam/tenant/qualifications' })
}
export function getMerchantPermissionResources() {
return apiRequest<MerchantPermissionResource[]>({ method: 'GET', url: '/merchant/v1/iam/permissions' })
}
export function getMerchantPersonalProfile() {
return apiRequest<MerchantPersonalProfile>({ method: 'GET', url: '/merchant/v1/iam/profile' })
}
export function updateMerchantPersonalProfile(payload: Pick<MerchantPersonalProfile, 'displayName' | 'phone' | 'email'>) {
return apiRequest<MerchantPersonalProfile>({ method: 'PUT', url: '/merchant/v1/iam/profile', data: payload })
}
export function uploadBusinessLicense(file: File, certificateNo?: string) {
const data = new FormData()
data.append('file', file)
if (certificateNo) data.append('certificateNo', certificateNo)
return apiRequest<QualificationUploadResult>({ method: 'POST', url: '/merchant/v1/files/qualifications', data })
}
export function downloadQualificationImage(documentUrl: string) {
const url = documentUrl.startsWith('/api/') ? documentUrl.slice(4) : documentUrl
if (!url.startsWith('/merchant/v1/files/qualifications/')) throw new Error('资质文件地址无效')
return apiBlobRequest({ method: 'GET', url })
}
export function getMerchantRoles() {
return apiRequest<MerchantRole[]>({ method: 'GET', url: '/merchant/v1/iam/roles' })
}
export function createMerchantRole(payload: CreateMerchantRoleRequest) {
return apiRequest<MerchantRole>({ method: 'POST', url: '/merchant/v1/iam/roles', data: payload })
}
export function updateMerchantRole(roleId: number | string, payload: UpdateMerchantRoleRequest) {
return apiRequest<MerchantRole>({ method: 'PUT', url: `/merchant/v1/iam/roles/${roleId}`, data: payload })
}
export function changeMerchantRoleStatus(roleId: number | string, status: 'ACTIVE' | 'DISABLED') {
return apiRequest<MerchantRole>({ method: 'PUT', url: `/merchant/v1/iam/roles/${roleId}/status`, data: { status } })
}
export function deleteMerchantRole(roleId: number | string) {
return apiRequest<void>({ method: 'DELETE', url: `/merchant/v1/iam/roles/${roleId}` })
}
export function replaceMerchantRolePermissions(roleId: number | string, permissionIds: Array<number | string>) {
return apiRequest<void>({ method: 'PUT', url: '/merchant/v1/iam/roles/permissions', data: { roleId, permissionIds } })
}
export function getMerchantMembers() {
return apiRequest<MerchantMember[]>({ method: 'GET', url: '/merchant/v1/iam/members' })
}
export function createMerchantMember(payload: CreateMerchantMemberRequest) {
return apiRequest<MerchantMember>({ method: 'POST', url: '/merchant/v1/iam/members', data: payload })
}
export function updateMerchantMember(membershipId: number | string, payload: UpdateMerchantMemberRequest) {
return apiRequest<MerchantMember>({ method: 'PUT', url: `/merchant/v1/iam/members/${membershipId}`, data: payload })
}
export function changeMerchantMemberStatus(membershipId: number | string, status: 'ACTIVE' | 'DISABLED') {
return apiRequest<MerchantMember>({ method: 'PUT', url: `/merchant/v1/iam/members/${membershipId}/status`, data: { status } })
}
export function resetMerchantMemberPassword(membershipId: number | string, newPassword: string) {
return apiRequest<void>({ method: 'PUT', url: `/merchant/v1/iam/members/${membershipId}/password`, data: { newPassword } })
}
export function replaceMerchantMemberRoles(membershipId: number | string, roleIds: Array<number | string>) {
return apiRequest<void>({ method: 'PUT', url: '/merchant/v1/iam/members/roles', data: { membershipId, roleIds } })
}
@@ -0,0 +1,387 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import {
changeMerchantMemberStatus,
changeMerchantRoleStatus,
createMerchantMember,
createMerchantRole,
deleteMerchantRole,
getMerchantMembers,
getMerchantPermissionResources,
getMerchantRoles,
getTenantProfile,
getTenantQualifications,
resetMerchantMemberPassword,
replaceMerchantRolePermissions,
updateMerchantMember,
updateMerchantRole,
updateTenantProfile,
type MerchantRole,
type MerchantMember,
type MerchantPermissionResource,
type MerchantTenantStatus,
} from '@/api/merchantIam'
export type TenantRecordStatus = 'ACTIVE' | 'INACTIVE'
export interface TenantCompanyProfile {
companyName: string
creditCode: string
legalRepresentative: string
contactName: string
contactPhone: string
contactEmail: string
auditStatus: MerchantTenantStatus
}
export interface TenantQualification {
id: string
type: string
fileName: string
status: 'VERIFIED' | 'PENDING' | 'REJECTED' | 'EXPIRED'
expiresAt: string
updatedAt: string
documentUrl: string
}
export interface TenantMember {
id: string
name: string
account: string
phone: string
email: string
roleIds: string[]
status: TenantRecordStatus
lastLoginAt: string
isAdministrator: boolean
}
export interface TenantRole {
id: string
name: string
code: string
description: string
status: TenantRecordStatus
builtin: boolean
permissionIds: string[]
permissionCodes: string[]
}
export interface TenantPermissionItem {
code: string
label: string
description: string
}
export interface TenantPermissionGroup {
key: string
label: string
items: TenantPermissionItem[]
}
export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
{
key: 'account',
label: '个人账号',
items: [{ code: 'account:profile:view', label: '维护我的信息', description: '查看并维护当前成员自己的资料和登录密码' }],
},
{
key: 'dashboard',
label: '业务总览',
items: [{ code: 'dashboard:view', label: '查看总览', description: '查看企业统计和最近订单' }],
},
{
key: 'orders',
label: '头程订单',
items: [
{ code: 'order:first-leg:list', label: '查看订单', description: '查看本企业头程订单列表和详情' },
{ code: 'order:first-leg:create', label: '创建订单', description: '新建并保存订单草稿' },
{ code: 'order:first-leg:edit', label: '编辑订单', description: '编辑本企业尚未提交的订单草稿' },
{ code: 'order:first-leg:submit', label: '提交订单', description: '提交草稿进入平台审核' },
{ code: 'order:first-leg:cancel', label: '取消订单', description: '在允许的状态下取消订单' },
],
},
{
key: 'company',
label: '企业资料',
items: [
{ code: 'company:profile:view', label: '查看资料', description: '查看企业、联系人和资质资料' },
{ code: 'company:profile:edit', label: '编辑资料', description: '维护企业联系信息和业务地址' },
{ code: 'company:qualification:upload', label: '更新资质', description: '上传或更新企业资质文件' },
],
},
{
key: 'members',
label: '企业成员',
items: [
{ code: 'company:member:list', label: '查看成员', description: '查看本企业成员列表' },
{ code: 'company:member:write', label: '维护成员', description: '新增、编辑、启停企业成员' },
{ code: 'company:member:reset-password', label: '重置密码', description: '重置企业成员登录密码' },
],
},
{
key: 'roles',
label: '角色与权限',
items: [
{ code: 'company:role:list', label: '查看角色', description: '查看本企业角色列表' },
{ code: 'company:role:write', label: '维护角色', description: '新增、编辑、启停企业角色' },
{ code: 'company:permission:list', label: '查看权限', description: '查看角色拥有的菜单和操作权限' },
{ code: 'company:permission:assign', label: '分配权限', description: '为本企业角色分配菜单及操作权限' },
],
},
]
const allPermissionCodes = TENANT_PERMISSION_GROUPS.flatMap((group) => group.items.map((item) => item.code))
export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, readonly string[]>> = {
'order:first-leg:list': ['merchant:order:list', 'merchant:order:view'],
'order:first-leg:create': ['merchant:order:create'],
'order:first-leg:edit': ['merchant:order:edit'],
'order:first-leg:submit': ['merchant:order:submit'],
'order:first-leg:cancel': ['merchant:order:cancel'],
'company:profile:view': ['merchant:profile:view'],
'company:profile:edit': ['merchant:profile:edit'],
'company:qualification:upload': ['merchant:qualification:upload'],
'company:member:list': ['merchant:member:list'],
'company:member:write': ['merchant:member:create', 'merchant:member:edit', 'merchant:member:grant-role', 'merchant:member:status'],
'company:member:reset-password': ['merchant:member:reset-password'],
'company:role:list': ['merchant:role:list'],
'company:role:write': ['merchant:role:create', 'merchant:role:edit', 'merchant:role:status', 'merchant:role:delete'],
'company:permission:list': ['merchant:role:list'],
'company:permission:assign': ['merchant:role:grant'],
}
const mappedMerchantPermissionCodes = new Set(Object.values(FRONTEND_TO_MERCHANT_PERMISSION_CODES).flat())
const emptyCompany: TenantCompanyProfile = {
companyName: '',
creditCode: '',
legalRepresentative: '',
contactName: '',
contactPhone: '',
contactEmail: '',
auditStatus: 'PROFILE_REQUIRED',
}
export const useTenantAdministrationStore = defineStore('tenant-administration', () => {
const company = ref<TenantCompanyProfile>({ ...emptyCompany })
const qualifications = ref<TenantQualification[]>([])
const members = ref<TenantMember[]>([])
const roles = ref<TenantRole[]>([])
const permissionResources = ref<MerchantPermissionResource[]>([])
const activeMemberCount = computed(() => members.value.filter((member) => member.status === 'ACTIVE').length)
async function loadCompanyContext() {
company.value = { ...emptyCompany }
qualifications.value = []
const [profile, qualificationRecords] = await Promise.all([
getTenantProfile(),
getTenantQualifications(),
])
company.value = {
companyName: profile.tenantName,
creditCode: profile.unifiedSocialCreditCode ?? '',
legalRepresentative: profile.legalName,
contactName: profile.contactName,
contactPhone: profile.contactPhone,
contactEmail: profile.contactEmail ?? '',
auditStatus: profile.status,
}
qualifications.value = qualificationRecords.map((item) => ({
id: String(item.id),
type: item.qualificationType === 'BUSINESS_LICENSE' ? '营业执照' : item.qualificationType,
fileName: item.documentUrl.split('/').pop() || '营业执照',
status: item.status === 'APPROVED' ? 'VERIFIED' : item.status,
expiresAt: '-',
updatedAt: '-',
documentUrl: item.documentUrl,
}))
}
function mapMerchantRole(role: MerchantRole): TenantRole {
const grantedIds = (role.permissionIds ?? []).map(String)
const grantedBackendCodes = new Set(permissionResources.value
.filter((resource) => grantedIds.includes(String(resource.id)))
.map((resource) => resource.permissionCode))
const permissionCodes = Object.entries(FRONTEND_TO_MERCHANT_PERMISSION_CODES)
.filter(([, backendCodes]) => backendCodes.some((code) => grantedBackendCodes.has(code)))
.map(([frontendCode]) => frontendCode)
return {
id: String(role.id),
name: role.roleName,
code: role.roleCode,
description: role.description ?? '暂无角色说明',
status: role.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE',
builtin: role.roleType === 'SYSTEM',
permissionIds: grantedIds,
permissionCodes: role.roleType === 'SYSTEM' && role.roleCode === 'MERCHANT_OWNER' ? [...allPermissionCodes] : permissionCodes,
}
}
function mapMerchantMember(member: MerchantMember): TenantMember {
return {
id: String(member.membershipId),
name: member.displayName || `企业成员 ${member.membershipId}`,
account: member.memberNo || `用户 ${member.authUserId}`,
phone: member.phone ?? '',
email: member.email ?? '',
roleIds: member.roleIds.map(String),
status: member.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE',
lastLoginAt: '服务端未提供',
isAdministrator: member.memberType === 'OWNER',
}
}
async function loadRoles() {
roles.value = []
permissionResources.value = []
const [roleRecords, resources] = await Promise.all([getMerchantRoles(), getMerchantPermissionResources()])
permissionResources.value = resources.filter((item) => item.status === 'ACTIVE')
roles.value = roleRecords.map(mapMerchantRole)
}
async function loadMembers() {
members.value = []
members.value = (await getMerchantMembers()).map(mapMerchantMember)
}
async function updateCompany(payload: TenantCompanyProfile) {
const updated = await updateTenantProfile({
tenantName: payload.companyName.trim(),
legalName: payload.legalRepresentative.trim(),
contactName: payload.contactName.trim(),
contactPhone: payload.contactPhone.trim(),
contactEmail: payload.contactEmail.trim(),
})
company.value = {
companyName: updated.tenantName,
creditCode: updated.unifiedSocialCreditCode ?? '',
legalRepresentative: updated.legalName,
contactName: updated.contactName,
contactPhone: updated.contactPhone,
contactEmail: updated.contactEmail ?? '',
auditStatus: updated.status,
}
}
async function createMember(payload: { name: string; account: string; password: string; phone: string; email: string; roleIds: string[]; memberNo: string }) {
const created = await createMerchantMember({
username: payload.account.trim(),
password: payload.password,
displayName: payload.name.trim(),
phone: payload.phone.trim(),
email: payload.email.trim(),
memberType: 'EMPLOYEE',
memberNo: payload.memberNo.trim(),
roleIds: payload.roleIds,
})
members.value.unshift(mapMerchantMember(created))
}
async function updateMember(id: string, payload: Pick<TenantMember, 'name' | 'phone' | 'email' | 'roleIds'> & { memberNo: string }) {
const member = members.value.find((item) => item.id === id)
if (!member) throw new Error('成员不存在')
const updated = await updateMerchantMember(id, {
displayName: payload.name.trim(),
phone: payload.phone.trim(),
email: payload.email.trim(),
memberType: member.isAdministrator ? 'OWNER' : 'EMPLOYEE',
memberNo: payload.memberNo.trim(),
roleIds: payload.roleIds,
})
Object.assign(member, mapMerchantMember(updated))
}
async function toggleMemberStatus(id: string) {
const member = members.value.find((item) => item.id === id)
if (!member) throw new Error('成员不存在')
if (member.isAdministrator) throw new Error('企业管理员不能停用')
const updated = await changeMerchantMemberStatus(id, member.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE')
Object.assign(member, mapMerchantMember(updated))
}
async function resetMemberPassword(id: string, newPassword: string) {
await resetMerchantMemberPassword(id, newPassword)
}
async function createRole(payload: Pick<TenantRole, 'name' | 'code' | 'description'>) {
if (roles.value.some((role) => role.code === payload.code)) throw new Error('角色编码已存在')
const created = await createMerchantRole({ roleCode: payload.code, roleName: payload.name, description: payload.description })
roles.value.push(mapMerchantRole(created))
}
async function updateRole(id: string, payload: Pick<TenantRole, 'name' | 'description'>) {
const role = roles.value.find((item) => item.id === id)
if (!role) throw new Error('角色不存在')
if (role.builtin) throw new Error('内置角色不能编辑')
const updated = await updateMerchantRole(id, { roleName: payload.name.trim(), description: payload.description.trim() })
Object.assign(role, mapMerchantRole(updated))
}
async function toggleRoleStatus(id: string) {
const role = roles.value.find((item) => item.id === id)
if (!role) throw new Error('角色不存在')
if (role.builtin) throw new Error('内置角色不能停用')
const updated = await changeMerchantRoleStatus(id, role.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE')
Object.assign(role, mapMerchantRole(updated))
}
async function deleteRole(id: string) {
const role = roles.value.find((item) => item.id === id)
if (!role) throw new Error('角色不存在')
if (role.builtin) throw new Error('内置角色不能删除')
if (members.value.some((member) => member.roleIds.includes(id))) throw new Error('该角色仍有成员使用,不能删除')
await deleteMerchantRole(id)
roles.value = roles.value.filter((item) => item.id !== id)
}
async function setRolePermissions(id: string, permissionCodes: string[]) {
const role = roles.value.find((item) => item.id === id)
if (!role) throw new Error('角色不存在')
if (role.builtin) throw new Error('内置管理员权限不能修改')
const backendCodes = new Set(permissionCodes.flatMap((code) => FRONTEND_TO_MERCHANT_PERMISSION_CODES[code] ?? []))
const selectedPermissionIds = permissionResources.value
.filter((resource) => backendCodes.has(resource.permissionCode))
.map((resource) => resource.id)
const mappedResourceIds = new Set(permissionResources.value
.filter((resource) => mappedMerchantPermissionCodes.has(resource.permissionCode))
.map((resource) => String(resource.id)))
// The endpoint replaces every ID, including MENU nodes this screen does not render.
const preservedPermissionIds = role.permissionIds.filter((permissionId) => !mappedResourceIds.has(permissionId))
const nextPermissionIds = [...new Set([...preservedPermissionIds, ...selectedPermissionIds])]
await replaceMerchantRolePermissions(id, nextPermissionIds)
role.permissionIds = nextPermissionIds.map(String)
role.permissionCodes = [...new Set(permissionCodes)]
}
function roleMemberCount(roleId: string) {
return members.value.filter((member) => member.roleIds.includes(roleId)).length
}
function roleNames(roleIds: readonly string[]) {
return roleIds.map((id) => roles.value.find((role) => role.id === id)?.name).filter((name): name is string => Boolean(name))
}
return {
company,
qualifications,
members,
roles,
activeMemberCount,
loadCompanyContext,
loadRoles,
loadMembers,
updateCompany,
createMember,
updateMember,
toggleMemberStatus,
resetMemberPassword,
createRole,
updateRole,
toggleRoleStatus,
deleteRole,
setRolePermissions,
roleMemberCount,
roleNames,
}
})
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { formatQualificationFileSize, QUALIFICATION_MAX_FILE_SIZE, validateQualificationFile } from './qualificationFile.ts'
test('accepts the backend-confirmed JPG and PNG formats up to 5MB', () => {
assert.equal(validateQualificationFile({ name: '营业执照.jpeg', size: QUALIFICATION_MAX_FILE_SIZE, type: 'image/jpeg' }), '')
assert.equal(validateQualificationFile({ name: '营业执照.PNG', size: 2048, type: 'image/png' }), '')
})
test('rejects PDF, empty, mismatched, and oversized files', () => {
assert.match(validateQualificationFile({ name: '营业执照.pdf', size: 1024, type: 'application/pdf' }), /格式不支持/)
assert.match(validateQualificationFile({ name: '伪装图片.png', size: 1024, type: 'text/plain' }), /内容类型/)
assert.match(validateQualificationFile({ name: '空文件.png', size: 0, type: 'image/png' }), /内容为空/)
assert.match(validateQualificationFile({ name: '超大文件.jpg', size: QUALIFICATION_MAX_FILE_SIZE + 1, type: 'image/jpeg' }), /超过 5MB/)
})
test('formats selected file sizes for review', () => {
assert.equal(formatQualificationFileSize(512), '512 B')
assert.equal(formatQualificationFileSize(1536), '1.5 KB')
assert.equal(formatQualificationFileSize(2 * 1024 * 1024), '2.0 MB')
})
@@ -0,0 +1,25 @@
export const QUALIFICATION_MAX_FILE_SIZE = 5 * 1024 * 1024
const allowedExtensions = new Set(['jpg', 'jpeg', 'png'])
const allowedMimeTypes = new Set(['image/jpeg', 'image/png'])
export interface QualificationFileLike {
name: string
size: number
type?: string
}
export function validateQualificationFile(file: QualificationFileLike) {
const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() ?? '' : ''
if (!allowedExtensions.has(extension)) return '文件格式不支持,请选择 JPG 或 PNG 图片'
if (file.type && !allowedMimeTypes.has(file.type)) return '文件内容类型与支持的资质格式不一致'
if (file.size <= 0) return '文件内容为空,请重新选择'
if (file.size > QUALIFICATION_MAX_FILE_SIZE) return '文件超过 5MB,请压缩后重新选择'
return ''
}
export function formatQualificationFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
@@ -0,0 +1,443 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules, UploadFile, UploadInstance } from 'element-plus'
import { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2, Upload, UserRound } from '@lucide/vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { useTenantAdministrationStore, type TenantCompanyProfile, type TenantQualification } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth'
import { formatQualificationFileSize, validateQualificationFile } from '@/utils/qualificationFile'
import { downloadQualificationImage, uploadBusinessLicense } from '@/api/merchantIam'
import { ApiError } from '@/api/http'
const store = useTenantAdministrationStore()
const auth = useAuthStore()
const companyApproved = computed(() => store.company.auditStatus === 'ACTIVE')
const auditStatusCopy = computed(() => ({
PROFILE_REQUIRED: { title: '请上传营业执照', detail: '营业执照提交审核前不能提交头程订单。' },
PENDING: { title: '企业资料正在等待平台审核', detail: '审核期间仍可维护资料和保存订单草稿;审核通过前不能提交订单。' },
ACTIVE: { title: '企业资料已审核,下单资格已开通', detail: '您可以创建、保存并提交头程订单。' },
REJECTED: { title: '企业资料审核未通过', detail: '请根据平台反馈修正资料;当前后端暂不支持同一企业重新上传。' },
DISABLED: { title: '企业账号已被平台停用', detail: '当前不能提交订单,请联系平台客服核实。' },
})[store.company.auditStatus])
const activeTab = ref('basic')
const editing = ref(false)
const qualificationOpen = ref(false)
const previewOpen = ref(false)
const previewRecord = ref<TenantQualification | null>(null)
const previewUrl = ref('')
const previewError = ref('')
const previewLoading = ref(false)
const profileFormRef = ref<FormInstance>()
const qualificationFormRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>()
const qualificationPage = ref(1)
const qualificationPageSize = ref(10)
const selectedFileSize = ref(0)
const selectedFileType = ref('')
const qualificationFileError = ref('')
const qualificationSubmitting = ref(false)
const profileSubmitting = ref(false)
const loading = ref(false)
const loadError = ref('')
const selectedFile = ref<File | null>(null)
const draft = reactive<TenantCompanyProfile>({ ...store.company })
const qualificationDraft = reactive({ type: '营业执照', fileName: '', certificateNo: '' })
const canUploadQualification = computed(() => ['PROFILE_REQUIRED', 'REJECTED'].includes(store.company.auditStatus))
const qualificationUploadDisabledReason = computed(() => {
if (store.company.auditStatus === 'PENDING') return '企业资料正在审核,暂不能再次提交资质文件。'
if (store.company.auditStatus === 'ACTIVE') return '当前企业已审核通过,资质替换请联系平台管理员处理。'
if (store.company.auditStatus === 'DISABLED') return '企业账号已停用,暂不能提交资质文件。'
return ''
})
const qualificationPanelHint = computed(() => canUploadQualification.value
? '一期仅支持提交营业执照;提交后将进入平台审核。'
: qualificationUploadDisabledReason.value)
const profileFormRules: FormRules<TenantCompanyProfile> = {
companyName: [{ required: true, whitespace: true, message: '请输入公司名称', trigger: 'blur' }],
legalRepresentative: [{ required: true, whitespace: true, message: '请输入法人姓名', trigger: 'blur' }],
contactName: [{ required: true, whitespace: true, message: '请输入联系人姓名', trigger: 'blur' }],
contactPhone: [{ required: true, whitespace: true, message: '请输入联系电话', trigger: 'blur' }],
contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }],
}
const qualificationFormRules: FormRules = {
fileName: [{ required: true, message: '请选择营业执照图片', trigger: 'change' }],
}
const qualificationColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'type', prop: 'type', label: '文件类型', minWidth: 150 },
{ key: 'fileName', prop: 'fileName', label: '文件名称', minWidth: 220, showOverflowTooltip: true },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'expiresAt', prop: 'expiresAt', label: '有效期', width: 130 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 160 },
]
const pagedQualifications = computed(() => {
const start = (qualificationPage.value - 1) * qualificationPageSize.value
return store.qualifications.slice(start, start + qualificationPageSize.value)
})
async function loadCompanyContext() {
loading.value = true
loadError.value = ''
try {
await store.loadCompanyContext()
Object.assign(draft, store.company)
} catch (error) {
loadError.value = error instanceof Error ? error.message : '企业资料加载失败'
} finally {
loading.value = false
}
}
onMounted(() => void loadCompanyContext())
function startEdit() {
Object.assign(draft, store.company)
profileFormRef.value?.clearValidate()
editing.value = true
}
function cancelEdit() {
Object.assign(draft, store.company)
editing.value = false
profileFormRef.value?.clearValidate()
}
async function saveProfile() {
if (profileSubmitting.value) return
const valid = await profileFormRef.value?.validate().then(() => true).catch(() => false)
if (!valid) return
profileSubmitting.value = true
try {
await store.updateCompany({ ...draft })
Object.assign(draft, store.company)
editing.value = false
ElMessage.success('企业资料已保存')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '企业资料保存失败')
} finally {
profileSubmitting.value = false
}
}
function openQualificationDialog() {
if (!canUploadQualification.value) {
ElMessage.warning(qualificationUploadDisabledReason.value)
return
}
resetQualificationDraft()
qualificationOpen.value = true
}
function handleFileChange(file: UploadFile) {
const raw = file.raw
if (!raw) {
qualificationFileError.value = '未能读取所选文件,请重新选择'
return
}
const error = validateQualificationFile({ name: raw.name, size: raw.size, type: raw.type })
if (error) {
qualificationFileError.value = error
qualificationDraft.fileName = ''
selectedFileSize.value = 0
selectedFileType.value = ''
selectedFile.value = null
uploadRef.value?.clearFiles()
return
}
qualificationFileError.value = ''
selectedFile.value = raw
qualificationDraft.fileName = raw.name
selectedFileSize.value = raw.size
selectedFileType.value = raw.type || raw.name.split('.').pop()?.toUpperCase() || '文件'
}
function removeSelectedFile() {
uploadRef.value?.clearFiles()
qualificationDraft.fileName = ''
selectedFileSize.value = 0
selectedFileType.value = ''
qualificationFileError.value = ''
selectedFile.value = null
}
function resetQualificationDraft() {
uploadRef.value?.clearFiles()
qualificationDraft.type = '营业执照'
qualificationDraft.fileName = ''
qualificationDraft.certificateNo = store.company.creditCode
selectedFileSize.value = 0
selectedFileType.value = ''
qualificationFileError.value = ''
selectedFile.value = null
qualificationFormRef.value?.clearValidate()
}
async function submitQualification() {
if (qualificationSubmitting.value) return
if (!canUploadQualification.value) {
qualificationOpen.value = false
await loadCompanyContext()
ElMessage.warning(qualificationUploadDisabledReason.value)
return
}
const valid = await qualificationFormRef.value?.validate().then(() => true).catch(() => false)
if (!valid || !selectedFile.value) {
qualificationFileError.value = '请选择营业执照图片'
return
}
if (qualificationFileError.value) {
ElMessage.warning(qualificationFileError.value)
return
}
qualificationSubmitting.value = true
try {
await uploadBusinessLicense(selectedFile.value, qualificationDraft.certificateNo.trim() || undefined)
await store.loadCompanyContext()
qualificationOpen.value = false
ElMessage.success('营业执照已提交审核')
} catch (error) {
if (error instanceof ApiError && error.code === 'A0409') {
await loadCompanyContext()
qualificationOpen.value = false
ElMessage.warning('企业资料正在审核,不能再次提交资质文件')
return
}
ElMessage.error(error instanceof Error ? error.message : '营业执照上传失败')
} finally {
qualificationSubmitting.value = false
}
}
async function previewQualification(record: TenantQualification) {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewRecord.value = record
previewOpen.value = true
previewUrl.value = ''
previewError.value = ''
if (!record.documentUrl) {
previewError.value = '当前资质记录没有可查看的文件地址,请联系平台管理员处理。'
return
}
previewLoading.value = true
try {
const blob = await downloadQualificationImage(record.documentUrl)
previewUrl.value = URL.createObjectURL(blob)
} catch (error) {
previewError.value = error instanceof Error && error.message === '资质文件地址无效'
? '当前资质文件尚未迁移到安全预览地址,请联系平台管理员处理。'
: '资质文件加载失败,请稍后重试。'
ElMessage.error(previewError.value)
} finally {
previewLoading.value = false
}
}
function closeQualificationPreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
previewError.value = ''
previewRecord.value = null
}
function qualificationStatus(status: string) {
return status === 'VERIFIED' ? '已验证' : status === 'PENDING' ? '待审核' : status === 'REJECTED' ? '已驳回' : '已过期'
}
</script>
<template>
<section v-loading="loading" class="profile-page">
<div v-if="loadError" class="client-card profile-load-error" role="alert">
<div><strong>企业资料加载失败</strong><span>{{ loadError }}</span></div>
<el-button :loading="loading" @click="loadCompanyContext">重新加载</el-button>
</div>
<template v-else>
<div class="company-audit-notice" :class="companyApproved ? 'is-approved' : 'is-pending'" role="status">
<component :is="companyApproved ? CheckCircle2 : Clock3" :size="21" />
<div><strong>{{ auditStatusCopy.title }}</strong><span>{{ auditStatusCopy.detail }}</span></div>
</div>
<el-tabs v-model="activeTab" class="profile-tabs">
<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>
<dl class="profile-grid">
<div><dt>公司名称</dt><dd>{{ store.company.companyName || '-' }}</dd></div>
<div><dt>统一社会信用代码</dt><dd>{{ store.company.creditCode || '-' }}</dd></div>
<div><dt>法人姓名</dt><dd>{{ store.company.legalRepresentative || '-' }}</dd></div>
</dl>
</div>
</el-tab-pane>
<el-tab-pane name="contact">
<template #label><span class="tab-label"><UserRound :size="16" />联系信息</span></template>
<div class="client-card info-panel">
<header class="info-panel-header"><h3>联系信息</h3></header>
<dl class="profile-grid">
<div><dt>联系人</dt><dd>{{ store.company.contactName || '-' }}</dd></div>
<div><dt>联系电话</dt><dd>{{ store.company.contactPhone || '-' }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ store.company.contactEmail || '-' }}</dd></div>
</dl>
</div>
</el-tab-pane>
<el-tab-pane name="qualification">
<template #label><span class="tab-label"><FileCheck2 :size="16" />资质文件</span></template>
<div class="client-card qualification-panel">
<header>
<div><h3>企业资质</h3><p>{{ qualificationPanelHint }}</p></div>
<el-tooltip v-if="auth.hasPermission('company:qualification:upload')" :disabled="canUploadQualification" :content="qualificationUploadDisabledReason" placement="top">
<span class="qualification-update-control"><el-button type="primary" :disabled="!canUploadQualification" @click="openQualificationDialog"><Upload :size="16" />更新资质</el-button></span>
</el-tooltip>
</header>
<DataTable
v-model:page="qualificationPage"
v-model:page-size="qualificationPageSize"
class="desktop-qualification-table"
:columns="qualificationColumns"
:data="pagedQualifications"
:empty-text="'暂无企业资质记录'"
:page-sizes="[10, 20, 50]"
:total="store.qualifications.length"
row-key="id"
>
<template #actions="{ row }"><el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button></template>
<template #status="{ row }"><el-tag :type="row.status === 'VERIFIED' ? 'success' : row.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(row.status) }}</el-tag></template>
</DataTable>
<div class="mobile-qualification-list">
<article v-for="record in store.qualifications" :key="record.id">
<header><strong>{{ record.type }}</strong><el-tag :type="record.status === 'VERIFIED' ? 'success' : record.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(record.status) }}</el-tag></header>
<div class="qualification-file-name"><FileText :size="16" /><span>{{ record.fileName }}</span></div>
<dl><div><dt>有效期</dt><dd>{{ record.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ record.updatedAt }}</dd></div></dl>
<el-button link type="primary" @click="previewQualification(record)">查看文件信息</el-button>
</article>
<div v-if="store.qualifications.length === 0" class="client-empty">暂无企业资质记录。</div>
</div>
</div>
</el-tab-pane>
</el-tabs>
<el-drawer v-model="editing" title="编辑企业资料" size="min(620px, 94vw)" @closed="cancelEdit">
<el-form ref="profileFormRef" :model="draft" :rules="profileFormRules" label-position="top" class="profile-form">
<h3>企业基本信息</h3>
<div class="form-grid-two">
<el-form-item label="公司名称" prop="companyName" required><el-input v-model="draft.companyName" placeholder="请输入公司名称" /></el-form-item>
<el-form-item label="统一社会信用代码"><el-input v-model="draft.creditCode" disabled /></el-form-item>
<el-form-item label="法人姓名" prop="legalRepresentative" required><el-input v-model="draft.legalRepresentative" placeholder="请输入法定代表人姓名" /></el-form-item>
</div>
<h3>联系信息</h3>
<div class="form-grid-two">
<el-form-item label="联系人" prop="contactName" required><el-input v-model="draft.contactName" placeholder="请输入联系人姓名" /></el-form-item>
<el-form-item label="联系电话" prop="contactPhone" required><el-input v-model="draft.contactPhone" placeholder="请输入联系电话" /></el-form-item>
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="draft.contactEmail" placeholder="请输入联系邮箱(选填)" /></el-form-item>
</div>
</el-form>
<template #footer><div class="drawer-actions"><el-button :disabled="profileSubmitting" @click="cancelEdit">取消</el-button><el-button type="primary" :loading="profileSubmitting" @click="saveProfile">保存修改</el-button></div></template>
</el-drawer>
<el-dialog v-model="qualificationOpen" title="更新资质文件" width="min(520px, 92vw)" @closed="resetQualificationDraft">
<el-form ref="qualificationFormRef" :model="qualificationDraft" :rules="qualificationFormRules" label-position="top">
<el-form-item label="文件类型"><el-input v-model="qualificationDraft.type" disabled /></el-form-item>
<el-form-item label="选择文件" prop="fileName" required :error="qualificationFileError">
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" :show-file-list="false" accept=".jpg,.jpeg,.png" @change="handleFileChange">
<el-button><Upload :size="16" />选择文件</el-button>
<template #tip><div class="upload-tip">仅支持 JPG、PNG,单张图片不超过 5MB。</div></template>
</el-upload>
<div v-if="qualificationDraft.fileName" class="selected-qualification-file" aria-live="polite">
<FileText :size="20" />
<div><strong>{{ qualificationDraft.fileName }}</strong><span>{{ selectedFileType }} · {{ formatQualificationFileSize(selectedFileSize) }}</span></div>
<el-button link type="danger" aria-label="移除所选资质文件" @click="removeSelectedFile"><Trash2 :size="16" />移除</el-button>
</div>
</el-form-item>
<el-form-item label="统一社会信用代码"><el-input v-model="qualificationDraft.certificateNo" maxlength="18" placeholder="建议填写 18 位统一社会信用代码" /></el-form-item>
</el-form>
<template #footer><el-button :disabled="qualificationSubmitting" @click="qualificationOpen = false">取消</el-button><el-button type="primary" :loading="qualificationSubmitting" @click="submitQualification">提交审核</el-button></template>
</el-dialog>
<el-dialog v-model="previewOpen" :title="`${previewRecord?.type || '资质文件'}详情`" width="min(560px, 92vw)" @closed="closeQualificationPreview">
<div v-if="previewRecord" v-loading="previewLoading" class="document-preview">
<img v-if="previewUrl" :src="previewUrl" :alt="`${previewRecord.type}预览`" class="qualification-preview-image" />
<FileCheck2 :size="42" />
<strong>{{ previewRecord.fileName }}</strong>
<el-tag :type="previewRecord.status === 'VERIFIED' ? 'success' : previewRecord.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(previewRecord.status) }}</el-tag>
<dl><div><dt>文件类型</dt><dd>{{ previewRecord.type }}</dd></div><div><dt>有效期</dt><dd>{{ previewRecord.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ previewRecord.updatedAt }}</dd></div></dl>
<p v-if="previewError" class="document-preview-error" role="alert">{{ previewError }}</p>
<span v-else>{{ previewUrl ? '文件通过当前商户会话临时加载,关闭弹窗后立即释放。' : '当前文件暂不可预览。' }}</span>
</div>
</el-dialog>
</template>
</section>
</template>
<style scoped>
.profile-page { display: flex; flex-direction: column; gap: 16px; }
.profile-load-error { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 22px; text-align: left; }
.profile-load-error > div { display: flex; max-width: 560px; flex-direction: column; gap: 6px; }
.profile-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.company-audit-notice { display: flex; align-items: flex-start; gap: 12px; padding: 15px 17px; border: 1px solid; }
.company-audit-notice svg { flex: 0 0 21px; margin-top: 1px; }
.company-audit-notice div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.company-audit-notice strong { color: var(--navy); font-size: 13px; }
.company-audit-notice span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.company-audit-notice.is-approved { border-color: #bcd9d2; background: #eff8f5; color: #20745f; }
.qualification-preview-image { display: block; max-width: 100%; max-height: 360px; margin: 0 auto 14px; object-fit: contain; }
.company-audit-notice.is-pending { border-color: #e5d2a5; background: #fff9eb; color: #9a6a12; }
.qualification-panel header p { margin: 6px 0 0; color: var(--muted); font-size: 12px; }
.profile-tabs :deep(.el-tabs__header) { margin-bottom: 14px; padding: 0 16px; border: 1px solid var(--border); background: #fff; }
.profile-tabs :deep(.el-tabs__nav-wrap::after) { display: none; }
.tab-label { display: inline-flex; align-items: center; gap: 7px; }
.info-panel { padding: 0; }
.info-panel-header { min-height: 66px; padding: 14px 22px; border-bottom: 1px solid var(--border); }
.info-panel-header h3 { margin: 0; font-size: 15px; }
.profile-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); margin: 0; }
.profile-grid > div { min-height: 86px; padding: 20px 22px; border-bottom: 1px solid var(--border); }
.profile-grid > div:nth-child(odd) { border-right: 1px solid var(--border); }
.profile-grid dt { color: var(--muted); font-size: 11px; }
.profile-grid dd { margin: 9px 0 0; color: var(--navy); font-size: 14px; font-weight: 650; }
.profile-grid .full-row { grid-column: 1 / -1; border-right: 0; }
.qualification-panel { padding: 0; overflow: hidden; }
.qualification-panel > header { padding: 20px 22px; border-bottom: 1px solid var(--border); }
.qualification-panel h3 { margin: 0; }
.qualification-update-control { display: inline-flex; }
.mobile-qualification-list { display: none; }
.profile-form h3 { margin: 4px 0 18px; padding-bottom: 10px; border-bottom: 1px solid var(--border); font-size: 15px; }
.profile-form h3:not(:first-child) { margin-top: 22px; }
.form-grid-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
.form-grid-two .full-row { grid-column: 1 / -1; }
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
.upload-tip { margin-top: 8px; color: var(--muted); font-size: 11px; line-height: 1.55; }
.selected-qualification-file { display: flex; width: 100%; min-width: 0; align-items: center; gap: 10px; margin-top: 12px; padding: 12px; border: 1px solid #cfe1dd; background: #f3f8f7; color: var(--primary); }
.selected-qualification-file > svg { flex: 0 0 20px; }
.selected-qualification-file > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
.selected-qualification-file strong { overflow: hidden; color: var(--navy); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.selected-qualification-file span { color: var(--muted); font-size: 10px; }
.document-preview { display: grid; min-height: 260px; place-items: center; align-content: center; gap: 12px; border: 1px dashed #b8c8c5; background: #f6faf9; color: var(--primary); }
.document-preview > strong { max-width: min(420px, calc(100% - 32px)); overflow-wrap: anywhere; color: var(--navy); text-align: center; }
.document-preview dl { display: grid; width: min(420px, calc(100% - 32px)); grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 6px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.document-preview dl div { min-width: 0; padding: 12px; text-align: center; }
.document-preview dt { color: var(--muted); font-size: 10px; }
.document-preview dd { margin: 5px 0 0; overflow-wrap: anywhere; color: var(--navy); font-size: 12px; font-weight: 650; }
.document-preview > span { max-width: 440px; padding: 0 16px; color: var(--muted); font-size: 11px; line-height: 1.6; text-align: center; }
.document-preview-error { max-width: 440px; margin: 0; padding: 0 16px; color: #9a5a09; font-size: 11px; line-height: 1.6; text-align: center; }
@media (max-width: 720px) {
.profile-grid, .form-grid-two { grid-template-columns: 1fr; }
.profile-grid > div:nth-child(odd) { border-right: 0; }
.qualification-panel > header { align-items: flex-start; gap: 12px; }
.qualification-panel > header .el-button { flex: 0 0 auto; }
.desktop-qualification-table { display: none; }
.mobile-qualification-list { display: grid; gap: 10px; padding: 10px; }
.mobile-qualification-list article { min-width: 0; padding: 14px; border: 1px solid var(--border); background: #fff; }
.mobile-qualification-list article > header { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.qualification-file-name { display: flex; min-width: 0; align-items: center; gap: 8px; margin: 14px 0; color: var(--primary); }
.qualification-file-name svg { flex: 0 0 16px; }
.qualification-file-name span { min-width: 0; overflow: hidden; color: var(--navy); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.mobile-qualification-list dl { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 0 0 10px; padding-top: 12px; border-top: 1px solid var(--border); }
.mobile-qualification-list dt { color: var(--muted); font-size: 10px; }
.mobile-qualification-list dd { margin: 4px 0 0; overflow-wrap: anywhere; font-size: 11px; font-weight: 600; }
.document-preview dl { grid-template-columns: 1fr; }
.document-preview dl div + div { border-top: 1px solid var(--border); }
.selected-qualification-file { align-items: flex-start; flex-wrap: wrap; }
.selected-qualification-file > .el-button { margin-left: 30px; }
}
</style>
@@ -0,0 +1,238 @@
<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>
@@ -0,0 +1,170 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { LockKeyhole, RotateCcw, Save, ShieldCheck } from '@lucide/vue'
import { TENANT_PERMISSION_GROUPS, useTenantAdministrationStore } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth'
const store = useTenantAdministrationStore()
const auth = useAuthStore()
const route = useRoute()
const initialRoleId = typeof route.query.role === 'string' && store.roles.some((role) => role.id === route.query.role) ? route.query.role : store.roles[0]?.id ?? ''
const selectedRoleId = ref(initialRoleId)
const selectedCodes = ref<string[]>([])
const savedCodes = ref<string[]>([])
const loading = ref(false)
const loadError = ref('')
const saving = ref(false)
const selectedRole = computed(() => store.roles.find((role) => role.id === selectedRoleId.value))
const editable = computed(() => Boolean(auth.hasPermission('company:permission:assign') && selectedRole.value && !selectedRole.value.builtin && selectedRole.value.status === 'ACTIVE'))
const hasChanges = computed(() => [...selectedCodes.value].sort().join('|') !== [...savedCodes.value].sort().join('|'))
function loadRolePermissions() {
const codes = selectedRole.value?.permissionCodes ?? []
selectedCodes.value = [...codes]
savedCodes.value = [...codes]
}
watch(selectedRoleId, loadRolePermissions, { immediate: true })
async function loadRoles() {
loading.value = true
loadError.value = ''
try {
await store.loadRoles()
if (!store.roles.some((role) => role.id === selectedRoleId.value)) selectedRoleId.value = store.roles[0]?.id ?? ''
loadRolePermissions()
} catch (error) {
loadError.value = error instanceof Error ? error.message : '角色列表加载失败'
} finally {
loading.value = false
}
}
onMounted(() => void loadRoles())
function groupCodes(groupKey: string) {
return TENANT_PERMISSION_GROUPS.find((group) => group.key === groupKey)?.items.map((item) => item.code) ?? []
}
function groupChecked(groupKey: string) {
const codes = groupCodes(groupKey)
return codes.length > 0 && codes.every((code) => selectedCodes.value.includes(code))
}
function groupIndeterminate(groupKey: string) {
const codes = groupCodes(groupKey)
const count = codes.filter((code) => selectedCodes.value.includes(code)).length
return count > 0 && count < codes.length
}
function toggleGroup(groupKey: string, checked: boolean) {
const codes = groupCodes(groupKey)
if (checked) selectedCodes.value = [...new Set([...selectedCodes.value, ...codes])]
else selectedCodes.value = selectedCodes.value.filter((code) => !codes.includes(code))
}
function resetPermissions() {
selectedCodes.value = [...savedCodes.value]
}
async function savePermissions() {
if (!selectedRole.value || !editable.value || saving.value) return
if (!selectedCodes.value.includes('account:profile:view')) selectedCodes.value.unshift('account:profile:view')
if (!selectedCodes.value.includes('dashboard:view')) selectedCodes.value.unshift('dashboard:view')
saving.value = true
try {
await store.setRolePermissions(selectedRole.value.id, selectedCodes.value)
savedCodes.value = [...selectedCodes.value]
ElMessage.success('角色菜单及操作权限已保存')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
saving.value = false
}
}
</script>
<template>
<section v-loading="loading" class="permissions-page">
<div v-if="loadError" class="client-card permission-load-error" role="alert">
<div><strong>角色权限加载失败</strong><span>{{ loadError }}</span></div>
<el-button :loading="loading" @click="loadRoles">重新加载</el-button>
</div>
<template v-else-if="store.roles.length">
<aside class="client-card role-selector" aria-label="选择角色">
<button v-for="role in store.roles" :key="role.id" type="button" :class="{ active: role.id === selectedRoleId }" @click="selectedRoleId = role.id">
<span class="role-select-icon"><LockKeyhole v-if="role.builtin" :size="17" /><ShieldCheck v-else :size="17" /></span>
<span><strong>{{ role.name }}</strong><small>{{ role.code }}</small></span>
<el-tag :type="role.status === 'ACTIVE' ? 'success' : 'info'" effect="plain" size="small">{{ role.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag>
</button>
</aside>
<div class="permission-content">
<div class="permission-actions" aria-label="权限操作"><el-button :disabled="!editable || !hasChanges || saving" @click="resetPermissions"><RotateCcw :size="16" />撤销修改</el-button><el-button type="primary" :loading="saving" :disabled="!editable || !hasChanges || saving" @click="savePermissions"><Save :size="16" />保存权限</el-button></div>
<div v-if="selectedRole?.builtin" class="permission-notice"><LockKeyhole :size="18" /><span>当前角色为系统内置角色,权限由平台统一维护,不允许修改。</span></div>
<div v-else-if="selectedRole?.status === 'INACTIVE'" class="permission-notice warning"><span>该角色已停用,请先启用角色后再调整权限。</span></div>
<div v-else class="permission-notice"><ShieldCheck :size="18" /><span>保存时会全量替换该角色的权限,请确认所有需要保留的菜单和操作均已勾选。</span></div>
<div class="permission-groups">
<article v-for="group in TENANT_PERMISSION_GROUPS" :key="group.key" class="client-card permission-group" :class="{ 'permission-group--single': group.items.length === 1 }">
<header>
<el-checkbox :model-value="groupChecked(group.key)" :indeterminate="groupIndeterminate(group.key)" :disabled="!editable" @change="toggleGroup(group.key, Boolean($event))"><strong>{{ group.label }}</strong></el-checkbox>
<span>{{ group.items.filter((item) => selectedCodes.includes(item.code)).length }} / {{ group.items.length }}</span>
</header>
<el-checkbox-group v-model="selectedCodes" :disabled="!editable">
<el-checkbox v-for="item in group.items" :key="item.code" :value="item.code" class="permission-item" :class="{ selected: selectedCodes.includes(item.code) }">
<span class="permission-copy"><strong>{{ item.label }}</strong><small>{{ item.description }}</small><code>{{ item.code }}</code></span>
</el-checkbox>
</el-checkbox-group>
</article>
</div>
</div>
</template>
<div v-else class="client-card permission-empty">当前企业暂无可用角色,创建角色后才能查看和分配权限。</div>
</section>
</template>
<style scoped>
.permissions-page { display: grid; grid-template-columns: 250px minmax(0, 1fr); align-items: start; gap: 14px; }
.permission-load-error, .permission-empty { grid-column: 1 / -1; min-height: 170px; }
.permission-load-error { display: flex; align-items: center; justify-content: center; gap: 20px; }
.permission-load-error > div { display: flex; max-width: 520px; flex-direction: column; gap: 6px; }
.permission-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.permission-empty { display: grid; place-items: center; color: var(--muted); font-size: 12px; text-align: center; }
.role-selector { position: sticky; top: 84px; padding: 12px; }
.role-selector > button { display: flex; width: 100%; min-height: 58px; align-items: center; gap: 9px; margin: 3px 0; padding: 9px; border: 0; border-radius: 8px; background: transparent; color: var(--navy); cursor: pointer; text-align: left; }
.role-selector > button:hover, .role-selector > button.active { background: #edf6f4; }
.role-selector > button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.role-selector > button.active { color: var(--primary); }
.role-select-icon { display: grid; width: 32px; height: 32px; flex: 0 0 32px; place-items: center; background: #f0f5f4; }
.role-selector > button > span:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
.role-selector strong { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.role-selector small { color: var(--muted); font-size: 9px; }
.permission-content { display: flex; min-width: 0; flex-direction: column; gap: 12px; }
.permission-actions { display: flex; justify-content: flex-end; gap: 8px; }
.permission-notice { display: flex; align-items: center; gap: 9px; padding: 12px 15px; border: 1px solid #cfe1dc; background: #edf6f4; color: #45615d; font-size: 11px; }
.permission-notice.warning { border-color: #f1d4a8; background: #fff7ed; color: #9a5a09; }
.permission-groups { display: grid; gap: 12px; }
.permission-group { padding: 0; overflow: hidden; }
.permission-group > header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); background: #fafcfc; }
.permission-group > header > span { color: var(--muted); font-size: 11px; }
.permission-group :deep(.el-checkbox-group) { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.permission-group--single :deep(.el-checkbox-group) { grid-template-columns: minmax(0, 1fr); }
.permission-item { display: flex; width: 100%; min-height: 104px; align-items: flex-start; margin: 0; padding: 16px 18px; border-bottom: 1px solid var(--border); cursor: pointer; }
.permission-item:nth-child(odd) { border-right: 1px solid var(--border); }
.permission-item:last-child:nth-child(odd) { grid-column: 1 / -1; border-right: 0; }
.permission-item.selected { background: #f5faf9; }
.permission-item :deep(.el-checkbox__input) { flex: 0 0 auto; margin-top: 3px; }
.permission-item :deep(.el-checkbox__label) { display: flex; min-width: 0; flex: 1; padding-left: 10px; white-space: normal; }
.permission-copy { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
.permission-copy strong { color: var(--navy); font-size: 13px; line-height: 1.35; }
.permission-copy small { color: var(--muted); font-size: 11px; line-height: 1.5; }
.permission-copy code { overflow-wrap: anywhere; color: #86939a; font-size: 10px; line-height: 1.35; }
@media (max-width: 900px) { .permissions-page { grid-template-columns: 1fr; } .role-selector { position: static; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 720px) { .permission-actions { width: 100%; } .permission-actions .el-button { flex: 1; } .permission-group :deep(.el-checkbox-group) { grid-template-columns: 1fr; } .permission-item:nth-child(odd) { border-right: 0; } }
@media (max-width: 480px) { .role-selector { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,190 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { LockKeyhole, Pencil, Plus, ShieldCheck, Trash2 } 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 TenantRole } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth'
const store = useTenantAdministrationStore()
const auth = useAuthStore()
const router = useRouter()
const roleFilters = ref({ keyword: '' })
const appliedRoleFilters = ref({ ...roleFilters.value })
const rolePage = ref(1)
const rolePageSize = ref(10)
const editorOpen = ref(false)
const editingId = ref('')
const loading = ref(false)
const loadError = ref('')
const saving = ref(false)
const actionRoleId = ref('')
const formRef = ref<FormInstance>()
const form = reactive({ name: '', code: '', description: '' })
const roleRules: FormRules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
description: [{ required: true, message: '请填写角色说明', trigger: 'blur' }],
}
const roleQueryFields: QueryField[] = [{ key: 'keyword', type: 'input', placeholder: '搜索角色名称、编码或说明' }]
const roleTableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', fixed: 'left', width: 250, slotName: 'actions' },
{ key: 'role', label: '角色', minWidth: 180, slotName: 'role' },
{ key: 'description', prop: 'description', label: '角色说明', minWidth: 220 },
{ key: 'memberCount', label: '成员数', width: 100, slotName: 'memberCount' },
{ key: 'permissionCount', label: '权限数', width: 100, slotName: 'permissionCount' },
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
]
const filteredRoles = computed(() => {
const key = appliedRoleFilters.value.keyword.trim().toLowerCase()
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))
function resetForm() {
editingId.value = ''
Object.assign(form, { name: '', code: '', description: '' })
void nextTick(() => formRef.value?.clearValidate())
}
function openCreate() {
resetForm()
editorOpen.value = true
}
function openEdit(role: TenantRole) {
editingId.value = role.id
Object.assign(form, { name: role.name, code: role.code, description: role.description })
editorOpen.value = true
}
async function loadRoles() {
loading.value = true
loadError.value = ''
try {
const requests = [store.loadRoles()]
if (auth.hasPermission('company:member:list')) requests.push(store.loadMembers())
await Promise.all(requests)
} catch (error) {
loadError.value = error instanceof Error ? error.message : '角色与成员统计加载失败'
} finally {
loading.value = false
}
}
onMounted(() => void loadRoles())
function searchRoles(filters: QueryModel) {
appliedRoleFilters.value = { keyword: typeof filters.keyword === 'string' ? filters.keyword : '' }
rolePage.value = 1
void loadRoles()
}
async function saveRole() {
if (saving.value) return
form.name = form.name.trim()
form.code = form.code.trim().toUpperCase()
form.description = form.description.trim()
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
if (editingId.value) await store.updateRole(editingId.value, { name: form.name, description: form.description })
else await store.createRole({ ...form, code: form.code.trim().toUpperCase() })
editorOpen.value = false
ElMessage.success(editingId.value ? '角色已更新' : '角色已新增')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
saving.value = false
}
}
async function toggleStatus(role: TenantRole) {
const action = role.status === 'ACTIVE' ? '停用' : '启用'
try {
await ElMessageBox.confirm(`${action}后将影响拥有该角色的成员权限。`, `${action}角色 ${role.name}`, { type: 'warning', confirmButtonText: action, cancelButtonText: '取消' })
actionRoleId.value = role.id
await store.toggleRoleStatus(role.id)
ElMessage.success(`角色已${action}`)
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '操作失败')
} finally {
actionRoleId.value = ''
}
}
async function removeRole(role: TenantRole) {
try {
await ElMessageBox.confirm('删除后不能恢复,仅未被成员使用的自定义角色可以删除。', `删除角色 ${role.name}`, { type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' })
actionRoleId.value = role.id
await store.deleteRole(role.id)
ElMessage.success('角色已删除')
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(error instanceof Error ? error.message : '删除失败')
} finally {
actionRoleId.value = ''
}
}
function assignPermissions(role: TenantRole) {
router.push({ name: 'permissions', query: { role: role.id } })
}
</script>
<template>
<section v-loading="loading" class="roles-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="loadRoles">重新加载</el-button>
</div>
<template v-else>
<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>
<DataTable v-model:page="rolePage" v-model:page-size="rolePageSize" :data="pagedRoles" :columns="roleTableColumns" :loading="loading" :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 #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>
<template #status="{ row }"><el-tag v-if="(row as TenantRole).builtin" effect="plain">内置</el-tag><el-tag :type="(row as TenantRole).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as TenantRole).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #empty><div class="client-empty">没有匹配的企业角色。</div></template>
</DataTable>
</div>
<el-drawer v-model="editorOpen" :title="editingId ? '编辑角色' : '新增角色'" size="min(500px, 94vw)" @closed="resetForm">
<el-form ref="formRef" :model="form" :rules="roleRules" label-position="top">
<el-form-item label="角色名称" prop="name" required><el-input v-model="form.name" maxlength="30" placeholder="例如 订单操作员" /></el-form-item>
<el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(editingId)" placeholder="例如 ORDER_OPERATOR" maxlength="40" /></el-form-item>
<el-form-item label="角色说明" prop="description" required><el-input v-model="form.description" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="简要说明该角色的职责和适用范围" /></el-form-item>
<div class="role-form-tip">保存后请前往“菜单权限”为角色分配可见菜单和操作权限。</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="saveRole">保存</el-button></div></template>
</el-drawer>
</template>
</section>
</template>
<style scoped>
.roles-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; }
.role-table-card { padding: 0; overflow: hidden; }
.table-actions { display: flex; justify-content: flex-end; padding: 12px 16px; border-bottom: 1px solid var(--border); }
.role-identity { display: flex; min-width: 0; align-items: center; gap: 10px; }
.role-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; background: #e7f3f1; color: var(--primary); }
.role-identity > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.role-identity strong { overflow-wrap: anywhere; }
.role-identity code { color: var(--muted); font-size: 10px; }
.role-form-tip { padding: 12px; background: #f2f7f6; color: var(--muted); font-size: 11px; }
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
.roles-page :deep(.el-form-item__error) { line-height: 1.45; overflow-wrap: anywhere; }
@media (max-width: 600px) { .drawer-actions { flex-wrap: wrap; } }
</style>
@@ -0,0 +1,148 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import test from 'node:test'
const routerSource = fs.readFileSync(new URL('../router/index.ts', import.meta.url), 'utf8')
const profileSource = fs.readFileSync(new URL('./CompanyProfileView.vue', import.meta.url), 'utf8')
const membersSource = fs.readFileSync(new URL('./MembersView.vue', import.meta.url), 'utf8')
const rolesSource = fs.readFileSync(new URL('./RolesView.vue', import.meta.url), 'utf8')
const permissionsSource = fs.readFileSync(new URL('./PermissionsView.vue', import.meta.url), 'utf8')
const storeSource = fs.readFileSync(new URL('../stores/tenantAdministration.ts', import.meta.url), 'utf8')
test('routes tenant administration modules to implemented views instead of the placeholder', () => {
assert.match(routerSource, /company\/profile'.*CompanyProfileView\.vue/)
assert.match(routerSource, /organization\/members'.*MembersView\.vue/)
assert.match(routerSource, /organization\/roles'.*RolesView\.vue/)
assert.match(routerSource, /organization\/permissions'.*PermissionsView\.vue/)
assert.doesNotMatch(routerSource, /ModuleView\.vue/)
})
test('implements company details, contact information and qualification workflow', () => {
assert.match(profileSource, /企业基本信息/)
assert.match(profileSource, /联系信息/)
assert.match(profileSource, /资质文件/)
assert.match(profileSource, /async function saveProfile\(\)/)
assert.match(profileSource, /function submitQualification\(\)/)
assert.match(profileSource, /validateQualificationFile/)
assert.match(profileSource, /function removeSelectedFile\(\)/)
assert.match(profileSource, /qualificationSubmitting/)
assert.match(profileSource, /class="mobile-qualification-list"/)
assert.match(profileSource, /import DataTable, \{ type DataTableColumn \} from '@\/components\/common\/DataTable\.vue'/)
assert.match(profileSource, /const qualificationColumns: DataTableColumn\[\]/)
assert.match(profileSource, /slotName: 'actions'/)
assert.match(profileSource, /<DataTable[\s\S]*v-model:page="qualificationPage"[\s\S]*v-model:page-size="qualificationPageSize"[\s\S]*:columns="qualificationColumns"/)
assert.doesNotMatch(profileSource, /<el-table class="desktop-qualification-table"/)
assert.doesNotMatch(profileSource, /class="profile-summary-card"/)
assert.match(profileSource, /class="info-panel-header"[\s\S]*auth\.hasPermission\('company:profile:edit'\)/)
assert.match(profileSource, /ref="profileFormRef" :model="draft" :rules="profileFormRules"/)
assert.match(profileSource, /prop="companyName" required><el-input v-model="draft\.companyName" placeholder="请输入公司名称"/)
assert.match(profileSource, /prop="fileName" required :error="qualificationFileError"/)
assert.match(profileSource, /const valid = await profileFormRef\.value\?\.validate\(\)/)
assert.match(profileSource, /qualificationFileError\.value = '请选择营业执照图片'/)
assert.match(profileSource, /const canUploadQualification = computed/)
assert.match(profileSource, /:disabled="!canUploadQualification"/)
assert.match(profileSource, /error\.code === 'A0409'/)
assert.match(profileSource, /downloadQualificationImage/)
assert.match(profileSource, /当前资质文件尚未迁移到安全预览地址/)
assert.match(profileSource, /previewError/)
assert.match(profileSource, /URL\.createObjectURL/)
assert.match(profileSource, /URL\.revokeObjectURL/)
assert.match(profileSource, /企业资料加载失败/)
assert.match(profileSource, /@click="loadCompanyContext"/)
assert.doesNotMatch(profileSource, /企业类型|注册资本|contactWechat|businessAddress/)
})
test('implements member list, role assignment, password reset and status control', () => {
assert.match(membersSource, /async function saveMember\(\)/)
assert.match(membersSource, /function resetPassword\(member: TenantMember\)/)
assert.match(membersSource, /function toggleStatus\(member: TenantMember\)/)
assert.match(membersSource, /v-model="form\.roleIds" multiple/)
assert.match(membersSource, /class="mobile-member-list"/)
assert.match(membersSource, /store\.loadMembers\(\)/)
assert.match(membersSource, /await store\.resetMemberPassword/)
assert.match(membersSource, /await store\.toggleMemberStatus/)
assert.match(membersSource, /v-model="form\.password"/)
assert.match(membersSource, /企业成员加载失败/)
assert.match(membersSource, /@click="loadMembers"/)
assert.match(membersSource, /const memberRules: FormRules/)
assert.match(membersSource, /ref="formRef" :model="form" :rules="memberRules"/)
assert.match(membersSource, /prop="roleIds" required/)
assert.match(membersSource, /await formRef\.value\?\.validate\(\)/)
assert.match(membersSource, /import QueryToolbar.*QueryToolbar\.vue/)
assert.match(membersSource, /import DataTable.*DataTable\.vue/)
assert.match(membersSource, /<QueryToolbar v-model="memberFilters"/)
assert.match(membersSource, /<DataTable v-model:page="memberPage" v-model:page-size="memberPageSize"/)
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.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, /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\)\)\)/)
assert.match(rolesSource, /store\.loadRoles\(\)/)
assert.match(rolesSource, /auth\.hasPermission\('company:member:list'\).*store\.loadMembers\(\)/)
assert.match(rolesSource, /await Promise\.all\(requests\)/)
assert.match(rolesSource, /await store\.createRole/)
assert.match(rolesSource, /await store\.updateRole/)
assert.match(rolesSource, /await store\.toggleRoleStatus/)
assert.match(rolesSource, /await store\.deleteRole/)
assert.match(rolesSource, /企业角色加载失败/)
assert.match(rolesSource, /@click="loadRoles"/)
assert.match(rolesSource, /const roleRules: FormRules/)
assert.match(rolesSource, /ref="formRef" :model="form" :rules="roleRules"/)
assert.match(rolesSource, /placeholder="例如 订单操作员"/)
assert.match(rolesSource, /import QueryToolbar.*QueryToolbar\.vue/)
assert.match(rolesSource, /import DataTable.*DataTable\.vue/)
assert.match(rolesSource, /<QueryToolbar v-model="roleFilters"/)
assert.match(rolesSource, /<DataTable v-model:page="rolePage" v-model:page-size="rolePageSize"/)
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.doesNotMatch(rolesSource, /role-guidance/)
})
test('shows company audit status and applies button permissions', () => {
assert.match(profileSource, /companyApproved/)
assert.match(profileSource, /企业资料正在等待平台审核/)
assert.match(profileSource, /auth\.hasPermission\('company:profile:edit'\)/)
assert.match(membersSource, /auth\.hasPermission\('company:member:write'\)/)
assert.match(permissionsSource, /auth\.hasPermission\('company:permission:assign'\)/)
assert.match(storeSource, /auditStatus: 'PROFILE_REQUIRED'/)
assert.match(storeSource, /loadCompanyContext/)
})
test('implements grouped menu and action permission editing', () => {
assert.match(permissionsSource, /TENANT_PERMISSION_GROUPS/)
assert.match(permissionsSource, /function toggleGroup\(groupKey: string, checked: boolean\)/)
assert.match(permissionsSource, /function savePermissions\(\)/)
assert.match(permissionsSource, /系统内置角色,权限由平台统一维护,不允许修改/)
assert.match(permissionsSource, /全量替换/)
assert.match(permissionsSource, /await store\.setRolePermissions/)
assert.match(permissionsSource, /角色权限加载失败/)
assert.match(permissionsSource, /当前企业暂无可用角色/)
assert.match(permissionsSource, /const saving = ref\(false\)/)
assert.match(permissionsSource, /:loading="saving"/)
assert.match(permissionsSource, /button:focus-visible/)
assert.match(permissionsSource, /class="permission-actions" aria-label="权限操作"/)
assert.match(permissionsSource, /<el-checkbox v-for="item in group\.items"[^>]*class="permission-item"/)
assert.doesNotMatch(permissionsSource, /<label v-for="item in group\.items"/)
assert.match(permissionsSource, /permission-group--single/)
assert.match(permissionsSource, /\.permission-item:last-child:nth-child\(odd\) \{ grid-column: 1 \/ -1; border-right: 0; \}/)
assert.match(permissionsSource, /\.permission-item :deep\(\.el-checkbox__label\) \{[^}]*white-space: normal/)
assert.match(permissionsSource, /\.permission-copy \{[^}]*flex-direction: column[^}]*gap: 5px/)
assert.doesNotMatch(permissionsSource, /permission-heading/)
assert.doesNotMatch(permissionsSource, /section-label/)
assert.doesNotMatch(permissionsSource, /当前只读展示权限资源/)
})
test('keeps tenant administration state scoped to the client store', () => {
assert.match(storeSource, /defineStore\('tenant-administration'/)
assert.match(storeSource, /function createMember/)
assert.match(storeSource, /function createRole/)
assert.match(storeSource, /function setRolePermissions/)
assert.doesNotMatch(storeSource, /tenantId\s*:/)
})