This commit is contained in:
2026-08-04 10:31:53 +08:00
parent 198101c42c
commit 5c25806081
10 changed files with 100 additions and 31 deletions
@@ -34,6 +34,12 @@ test('sends backend-required channel, customs, destination and cargo fields', ()
}
assert.match(storeSource, /containerMode: draft\.transportMode === 'FCL' \? 'FULL_CONTAINER'/)
assert.match(storeSource, /customerId: draft\.shipperPartyId/)
assert.match(storeSource, /customerName: customerName\.trim\(\) \|\| draft\.shipperPartySnapshot\.name/)
})
test('only blocks submission when a known company audit status is not active', () => {
assert.match(storeSource, /companyAuditStatus\?: MerchantTenantStatus/)
assert.match(storeSource, /if \(companyAuditStatus && companyAuditStatus !== 'ACTIVE'\)/)
})
test('does not expose the out-of-scope cancellation write', () => {
@@ -66,7 +66,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: PlatformReceivingWare
// 后端把 customerId 作为订单主往来单位校验并以服务端名称覆盖 customerName;
// 当前商户下单以所选发货单位作为主往来单位,收发货详情仍分别保存订单快照。
customerId: draft.shipperPartyId,
customerName,
customerName: customerName.trim() || draft.shipperPartySnapshot.name,
transportMode: apiTransport(draft),
tradeTerm: draft.tradeTerm || undefined,
destinationCountryCode: draft.destinationCountryCode.trim().toUpperCase(),
@@ -246,8 +246,8 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
return loadDetail(String(createdId))
}
async function submit(draft: FirstLegOrderDraft, customerName: string, id?: string, companyAuditStatus: MerchantTenantStatus = 'ACTIVE') {
if (companyAuditStatus !== 'ACTIVE') throw new Error('企业资料尚未审核通过,请先完善企业资料并等待平台审核')
async function submit(draft: FirstLegOrderDraft, customerName: string, id?: string, companyAuditStatus?: MerchantTenantStatus) {
if (companyAuditStatus && companyAuditStatus !== 'ACTIVE') throw new Error('企业资料尚未审核通过,请先完善企业资料并等待平台审核')
const order = await saveDraft(draft, customerName, id)
await submitMerchantFirstLegOrder(order.id)
return loadDetail(order.id)
@@ -33,13 +33,21 @@ test('exposes real list, draft, submit, and detail workflow entries without canc
})
test('blocks order submission before company approval while preserving draft actions', () => {
assert.match(source, /companyApproved/)
assert.match(source, /companySubmissionBlocked/)
assert.match(source, /companyAuditLoaded\.value = true/)
assert.match(source, /企业资料待审核,当前仅可保存订单草稿/)
assert.match(source, /暂时无法提交订单/)
assert.match(source, /router\.push\('\/company\/profile'\)/)
assert.match(source, /auth\.hasPermission\('order:first-leg:submit'\)/)
})
test('defers approval checks to the server when the member cannot read the company profile', () => {
assert.match(source, /const canViewCompanyProfile = computed/)
assert.match(source, /const companySubmissionBlocked = computed\(\(\) => companyAuditLoaded\.value/)
assert.match(source, /const knownCompanyAuditStatus = companyAuditLoaded\.value \? tenantStore\.company\.auditStatus : undefined/)
assert.match(source, /store\.submit\([^\n]*knownCompanyAuditStatus\)/)
})
test('renders dynamic cargo lines and responsive drawers', () => {
assert.match(source, /v-for="\(item, index\) in form\.items"/)
assert.match(source, /function addCargoItem\(\)/)
@@ -77,7 +85,7 @@ test('keeps desktop query actions on the first row and only shows the qualificat
assert.match(source, /\.first-leg-query-toolbar \{ flex-wrap: nowrap; \}/)
assert.match(source, /query-toolbar__filters\) \{ display: grid; min-width: 0; flex: 1 1 auto;/)
assert.match(source, /query-toolbar__actions\) \{ align-self: flex-start; flex-wrap: nowrap; margin-left: auto;/)
assert.match(source, /v-if="!companyApproved && !supportingDataErrors\.some/)
assert.match(source, /v-if="companySubmissionBlocked && !supportingDataErrors\.some/)
assert.doesNotMatch(source, /企业资料已审核,可正常提交订单/)
assert.doesNotMatch(source, /order-eligibility\.is-approved/)
})
@@ -29,7 +29,9 @@ const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const isCreatePage = computed(() => route.name === 'first-leg-order-create')
const companyApproved = computed(() => tenantStore.company.auditStatus === 'ACTIVE')
const canViewCompanyProfile = computed(() => auth.hasPermission('company:profile:view'))
const companyAuditLoaded = ref(false)
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create'))
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
const availableWarehouses = computed(() => store.warehouses)
@@ -195,10 +197,11 @@ const emptyDescription = computed(() => hasActiveFilters.value ? '没有符合
async function loadPageData() {
supportingDataLoading.value = true
supportingDataErrors.value = []
companyAuditLoaded.value = false
availableShippers.value = []
availableConsignees.value = []
const companyRequest = auth.hasPermission('company:profile:view')
const companyRequest = canViewCompanyProfile.value
? tenantStore.loadCompanyContext()
: Promise.resolve()
const [ordersResult, partiesResult, companyResult] = await Promise.allSettled([
@@ -213,7 +216,9 @@ async function loadPageData() {
} else {
supportingDataErrors.value.push(`往来单位加载失败:${errorMessage(partiesResult.reason)}`)
}
if (companyResult.status === 'rejected') {
if (canViewCompanyProfile.value && companyResult.status === 'fulfilled') {
companyAuditLoaded.value = true
} else if (companyResult.status === 'rejected') {
supportingDataErrors.value.push(`企业资料加载失败:${errorMessage(companyResult.reason)}`)
}
if (ordersResult.status === 'rejected' && !store.loadError) {
@@ -510,7 +515,7 @@ async function saveDraft() {
async function submitOrder() {
if (saving.value) return
if (!companyApproved.value) {
if (companySubmissionBlocked.value) {
try {
await ElMessageBox.confirm('企业资料审核通过后才能提交订单。您可以继续保存草稿,或前往企业资料核对并补充信息。', '暂时无法提交订单', {
type: 'warning',
@@ -530,7 +535,8 @@ async function submitOrder() {
try {
saving.value = true
const order = await store.submit(cloneDraft(form), tenantStore.company.companyName, editingId.value, tenantStore.company.auditStatus)
const knownCompanyAuditStatus = companyAuditLoaded.value ? tenantStore.company.auditStatus : undefined
const order = await store.submit(cloneDraft(form), tenantStore.company.companyName, editingId.value, knownCompanyAuditStatus)
markEditorClean()
editorOpen.value = false
if (isCreatePage.value) await router.push({ name: 'first-leg-orders' })
@@ -569,10 +575,10 @@ async function searchOrders(model: QueryModel) {
<div><strong>订单列表</strong><span>共 {{ filteredOrders.length }} 条</span></div>
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
</div>
<div v-if="!companyApproved && !supportingDataErrors.some((message) => message.startsWith('企业资料'))" class="order-eligibility is-pending" role="status">
<div v-if="companySubmissionBlocked && !supportingDataErrors.some((message) => message.startsWith('企业资料'))" class="order-eligibility is-pending" role="status">
<AlertTriangle :size="20" />
<div><strong>{{ canCreateOrder ? '企业资料待审核,当前仅可保存订单草稿' : '企业资料待审核,当前订单为只读' }}</strong><span>{{ canCreateOrder ? '平台审核通过后即可提交;您仍可创建、编辑和保存草稿。' : '您当前拥有只读权限,可以继续查看订单进展。' }}</span></div>
<el-button v-if="!companyApproved && auth.hasPermission('company:profile:view')" link type="primary" @click="router.push('/company/profile')">查看企业资料</el-button>
<el-button v-if="canViewCompanyProfile" link type="primary" @click="router.push('/company/profile')">查看企业资料</el-button>
</div>
<div v-if="supportingDataErrors.length" class="supporting-data-error" role="alert">
<AlertTriangle :size="20" />
@@ -711,7 +717,7 @@ async function searchOrders(model: QueryModel) {
<p class="cargo-tip">FBA 货物必须填写 SKU、UPC、货件编号和跟踪编号;提交前请核对重量与体积。</p>
</section>
</el-form>
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="closeEditor">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companyApproved ? '提交订单' : '提交前需完成资料审核' }}</el-button></div></template>
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="closeEditor">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companySubmissionBlocked ? '提交前需完成资料审核' : '提交订单' }}</el-button></div></template>
</FirstLegOrderEditorShell>
<el-drawer v-model="detailOpen" class="first-leg-detail" title="订单详情" size="680px">
@@ -6,6 +6,7 @@ const source = readFileSync(new URL('./merchantIam.ts', import.meta.url), 'utf8'
const storeSource = readFileSync(new URL('../store/tenantAdministration.ts', import.meta.url), 'utf8')
const contractsSource = readFileSync(new URL('../contracts.ts', import.meta.url), 'utf8')
const authSource = readFileSync(new URL('../../auth/store/auth.ts', import.meta.url), 'utf8')
const permissionsViewSource = readFileSync(new URL('../views/PermissionsView.vue', import.meta.url), 'utf8')
test('declares the documented merchant role and member endpoints', () => {
assert.match(source, /url: '\/merchant\/v1\/iam\/roles'/)
@@ -66,3 +67,12 @@ test('shares the merchant permission map and preserves resources not rendered by
assert.match(storeSource, /const nextPermissionIds = \[\.\.\.new Set\(\[\.\.\.preservedPermissionIds, \.\.\.selectedPermissionIds\]\)\]/)
assert.match(storeSource, /await replaceMerchantRolePermissions\(id, nextPermissionIds\)/)
})
test('keeps client baseline permissions selected when server resources do not define them', () => {
assert.match(contractsSource, /REQUIRED_TENANT_PERMISSION_CODES = \['account:profile:view', 'dashboard:view'\]/)
assert.match(storeSource, /\.\.\.REQUIRED_TENANT_PERMISSION_CODES, \.\.\.permissionCodes/)
assert.match(storeSource, /role\.permissionCodes = effectivePermissionCodes/)
assert.match(permissionsViewSource, /group\.items\.every\(\(item\) => isRequiredPermission\(item\.code\)\)/)
assert.match(permissionsViewSource, /:disabled="isRequiredPermission\(item\.code\)"/)
assert.doesNotMatch(permissionsViewSource, /selectedCodes\.value\.unshift\('account:profile:view'\)/)
})
+4 -2
View File
@@ -12,16 +12,18 @@ export interface TenantPermissionGroup {
items: TenantPermissionItem[]
}
export const REQUIRED_TENANT_PERMISSION_CODES = ['account:profile:view', 'dashboard:view'] as const
export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
{
key: 'account',
label: '个人账号',
items: [{ code: 'account:profile:view', label: '维护我的信息', description: '查看并维护当前成员自己的资料和登录密码' }],
items: [{ code: 'account:profile:view', label: '维护我的信息', description: '所有企业成员默认可用,可查看并维护自己的资料和登录密码' }],
},
{
key: 'dashboard',
label: '业务总览',
items: [{ code: 'dashboard:view', label: '查看总览', description: '查看企业统计和最近订单' }],
items: [{ code: 'dashboard:view', label: '查看总览', description: '所有企业成员默认可用,可查看企业统计和最近订单' }],
},
{
key: 'orders',
@@ -22,6 +22,7 @@ import {
} from '@/modules/tenant-management/api/merchantIam'
import {
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
REQUIRED_TENANT_PERMISSION_CODES,
TENANT_PERMISSION_GROUPS,
type MerchantTenantStatus,
} from '@/modules/tenant-management/contracts'
@@ -131,6 +132,7 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
const permissionCodes = Object.entries(FRONTEND_TO_MERCHANT_PERMISSION_CODES)
.filter(([, backendCodes]) => backendCodes.some((code) => grantedBackendCodes.has(code)))
.map(([frontendCode]) => frontendCode)
const effectivePermissionCodes = [...new Set([...REQUIRED_TENANT_PERMISSION_CODES, ...permissionCodes])]
return {
id: String(role.id),
name: role.roleName,
@@ -139,7 +141,7 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
status: role.status === 'ACTIVE' ? 'ACTIVE' : 'INACTIVE',
builtin: role.roleType === 'SYSTEM',
permissionIds: grantedIds,
permissionCodes: role.roleType === 'SYSTEM' && role.roleCode === 'MERCHANT_OWNER' ? [...allPermissionCodes] : permissionCodes,
permissionCodes: role.roleType === 'SYSTEM' && role.roleCode === 'MERCHANT_OWNER' ? [...allPermissionCodes] : effectivePermissionCodes,
}
}
@@ -265,7 +267,8 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
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 effectivePermissionCodes = [...new Set([...REQUIRED_TENANT_PERMISSION_CODES, ...permissionCodes])]
const backendCodes = new Set(effectivePermissionCodes.flatMap((code) => FRONTEND_TO_MERCHANT_PERMISSION_CODES[code] ?? []))
const selectedPermissionIds = permissionResources.value
.filter((resource) => backendCodes.has(resource.permissionCode))
.map((resource) => resource.id)
@@ -277,7 +280,7 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
const nextPermissionIds = [...new Set([...preservedPermissionIds, ...selectedPermissionIds])]
await replaceMerchantRolePermissions(id, nextPermissionIds)
role.permissionIds = nextPermissionIds.map(String)
role.permissionCodes = [...new Set(permissionCodes)]
role.permissionCodes = effectivePermissionCodes
}
function roleMemberCount(roleId: string) {
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import type { FormInstance, FormRules, UploadFile } from 'element-plus'
import { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2, Upload, UserRound } from '@lucide/vue'
@@ -50,6 +50,7 @@ type QualificationDraftItem = QualificationTypeDefinition & {
fileName: string
fileSize: number
fileType: string
previewUrl: string
inputVersion: number
error: string
uploadState: QualificationUploadState
@@ -67,6 +68,7 @@ const qualificationDrafts = reactive<QualificationDraftItem[]>(qualificationType
fileName: '',
fileSize: 0,
fileType: '',
previewUrl: '',
inputVersion: 0,
error: '',
uploadState: 'idle',
@@ -116,6 +118,9 @@ const qualificationSaveDisabledReason = computed(() => {
if (!selectedQualificationCount.value) return '请选择需要新增或替换的资质文件'
return ''
})
const qualificationDialogHint = computed(() => qualificationSaveDisabledReason.value === qualificationCreditCodeError.value
? ''
: qualificationSaveDisabledReason.value)
const canSubmitQualificationReview = computed(() => canUploadQualification.value
&& companyCreditCodeValid.value
&& qualificationTypes.every((type) => uploadedQualificationTypes.value.has(type.value)))
@@ -167,6 +172,7 @@ async function loadCompanyContext() {
}
onMounted(() => void loadCompanyContext())
onBeforeUnmount(() => qualificationDrafts.forEach(releaseQualificationPreview))
function startEdit() {
Object.assign(draft, store.company)
@@ -223,6 +229,12 @@ function qualificationDraftItem(type: MerchantQualificationType) {
return qualificationDrafts.find((item) => item.value === type)
}
function releaseQualificationPreview(item: QualificationDraftItem) {
if (!item.previewUrl) return
URL.revokeObjectURL(item.previewUrl)
item.previewUrl = ''
}
function qualificationSlotLabel(item: QualificationDraftItem) {
if (item.uploadState === 'uploading') return '上传中'
if (item.uploadState === 'uploaded') return '已保存'
@@ -244,6 +256,7 @@ function qualificationSlotTagType(item: QualificationDraftItem) {
function handleQualificationFileChange(type: MerchantQualificationType, file: UploadFile) {
const item = qualificationDraftItem(type)
if (!item) return
releaseQualificationPreview(item)
const raw = file.raw
if (!raw) {
item.error = '未能读取所选文件,请重新选择'
@@ -272,11 +285,13 @@ function handleQualificationFileChange(type: MerchantQualificationType, file: Up
item.fileName = raw.name
item.fileSize = raw.size
item.fileType = raw.type || raw.name.split('.').pop()?.toUpperCase() || '文件'
if (raw.type.startsWith('image/') || /\.(jpe?g|png)$/i.test(raw.name)) item.previewUrl = URL.createObjectURL(raw)
}
function removeSelectedFile(type: MerchantQualificationType) {
const item = qualificationDraftItem(type)
if (!item) return
releaseQualificationPreview(item)
item.fileName = ''
item.fileSize = 0
item.fileType = ''
@@ -288,6 +303,7 @@ function removeSelectedFile(type: MerchantQualificationType) {
function resetQualificationDraft() {
qualificationDrafts.forEach((item) => {
releaseQualificationPreview(item)
item.fileName = ''
item.fileSize = 0
item.fileType = ''
@@ -341,6 +357,7 @@ async function submitQualification() {
item.value === 'BUSINESS_LICENSE' ? normalizedQualificationCertificateNo.value : undefined,
Boolean(replaceableQualification(item.value)),
)
releaseQualificationPreview(item)
item.file = null
item.fileName = ''
item.fileSize = 0
@@ -552,8 +569,11 @@ function closeQualificationPreview() {
<template #tip><div class="upload-tip">支持 PDF、JPG、PNG,单个文件不超过 5MB。</div></template>
</el-upload>
<div v-if="item.fileName" class="selected-qualification-file" aria-live="polite">
<FileText :size="18" />
<div><strong>{{ item.fileName }}</strong><span>{{ item.fileType }} · {{ formatQualificationFileSize(item.fileSize) }}</span></div>
<a v-if="item.previewUrl" class="selected-file-preview" :href="item.previewUrl" target="_blank" rel="noopener" :aria-label="`查看${item.label}图片预览`">
<img :src="item.previewUrl" :alt="`${item.label}待上传图片预览`" />
</a>
<div v-else class="selected-file-type" aria-hidden="true"><FileText :size="18" /><span>PDF</span></div>
<div class="selected-file-meta"><strong>{{ item.fileName }}</strong><span>{{ item.fileType }} · {{ formatQualificationFileSize(item.fileSize) }}</span></div>
<el-button link type="danger" :disabled="qualificationSubmitting" :aria-label="`移除${item.label}文件`" @click="removeSelectedFile(item.value)"><Trash2 :size="16" />移除</el-button>
</div>
<p v-if="item.error" class="qualification-upload-error" role="alert">{{ item.error }}</p>
@@ -562,7 +582,7 @@ function closeQualificationPreview() {
<el-form-item label="统一社会信用代码" class="qualification-credit-code" :required="!companyCreditCodeValid" :error="qualificationCreditCodeReady ? '' : qualificationCreditCodeError">
<el-input v-model="qualificationCertificateNo" maxlength="18" placeholder="请输入18位统一社会信用代码" @input="qualificationCertificateNo = String($event).toUpperCase()" />
</el-form-item>
<p v-if="qualificationSaveDisabledReason" class="qualification-dialog-hint">{{ qualificationSaveDisabledReason }}</p>
<p v-if="qualificationDialogHint" class="qualification-dialog-hint">{{ qualificationDialogHint }}</p>
<template #footer><el-button :disabled="qualificationSubmitting" @click="qualificationOpen = false">取消</el-button><el-button type="primary" :loading="qualificationSubmitting" :disabled="!canSaveQualificationBatch" @click="submitQualification">保存全部资质</el-button></template>
</el-dialog>
@@ -635,8 +655,12 @@ function closeQualificationPreview() {
.existing-qualification-file span { min-width: 0; overflow: hidden; color: var(--muted); text-overflow: ellipsis; white-space: nowrap; }
.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-file-preview { display: block; width: 54px; height: 54px; flex: 0 0 54px; overflow: hidden; border: 1px solid #bfd4cf; background: #fff; }
.selected-file-preview img { display: block; width: 100%; height: 100%; object-fit: cover; }
.selected-file-preview:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.selected-file-type { display: grid; width: 54px; height: 54px; flex: 0 0 54px; place-items: center; align-content: center; gap: 2px; border: 1px solid #cfe1dd; background: #fff; }
.selected-file-type span { color: var(--muted); font-size: 9px; font-weight: 700; }
.selected-file-meta { 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; }
.qualification-upload-error { margin: 9px 0 0; color: #b42318; font-size: 11px; line-height: 1.5; }
@@ -673,6 +697,6 @@ function closeQualificationPreview() {
.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: 28px; }
.selected-qualification-file > .el-button { margin-left: 64px; }
}
</style>
@@ -4,7 +4,7 @@ import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { LockKeyhole, RotateCcw, Save, ShieldCheck } from '@lucide/vue'
import { useTenantAdministrationStore } from '@/modules/tenant-management/store/tenantAdministration'
import { TENANT_PERMISSION_GROUPS } from '@/modules/tenant-management/contracts'
import { REQUIRED_TENANT_PERMISSION_CODES, TENANT_PERMISSION_GROUPS } from '@/modules/tenant-management/contracts'
import { useAuthStore } from '@/modules/auth/public'
const store = useTenantAdministrationStore()
@@ -21,6 +21,11 @@ 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('|'))
const requiredPermissionCodes = new Set<string>(REQUIRED_TENANT_PERMISSION_CODES)
function isRequiredPermission(code: string) {
return requiredPermissionCodes.has(code)
}
function loadRolePermissions() {
const codes = selectedRole.value?.permissionCodes ?? []
@@ -64,7 +69,7 @@ function groupIndeterminate(groupKey: string) {
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))
else selectedCodes.value = selectedCodes.value.filter((code) => !codes.includes(code) || isRequiredPermission(code))
}
function resetPermissions() {
@@ -73,8 +78,6 @@ function resetPermissions() {
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)
@@ -113,11 +116,11 @@ async function savePermissions() {
<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>
<el-checkbox :model-value="groupChecked(group.key)" :indeterminate="groupIndeterminate(group.key)" :disabled="!editable || group.items.every((item) => isRequiredPermission(item.code))" @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) }">
<el-checkbox v-for="item in group.items" :key="item.code" :value="item.code" :disabled="isRequiredPermission(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>
@@ -37,6 +37,13 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /item\.fileName = ''[\s\S]*item\.uploadState = 'uploaded'/)
assert.match(profileSource, /const canSaveQualificationBatch = computed/)
assert.match(profileSource, /const qualificationCreditCodeReady = computed/)
assert.match(profileSource, /const qualificationDialogHint = computed/)
assert.match(profileSource, /qualificationSaveDisabledReason\.value === qualificationCreditCodeError\.value/)
assert.match(profileSource, /previewUrl: string/)
assert.match(profileSource, /URL\.createObjectURL\(raw\)/)
assert.match(profileSource, /URL\.revokeObjectURL\(item\.previewUrl\)/)
assert.match(profileSource, /class="selected-file-preview"/)
assert.match(profileSource, /待上传图片预览/)
assert.match(profileSource, /:close-on-click-modal="!qualificationSubmitting"/)
assert.match(profileSource, /:close-on-press-escape="!qualificationSubmitting"/)
assert.match(profileSource, /:show-close="!qualificationSubmitting"/)