This commit is contained in:
2026-08-03 18:15:34 +08:00
parent 0260b9bb41
commit 3e347dd7a5
6 changed files with 89 additions and 24 deletions
+1
View File
@@ -44,6 +44,7 @@ declare module 'vue' {
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTooltip: typeof import('element-plus/es')['ElTooltip']
ElUpload: typeof import('element-plus/es')['ElUpload']
FirstLegOrderEditorShell: typeof import('./components/first-leg/FirstLegOrderEditorShell.vue')['default']
LoadErrorState: typeof import('./components/common/LoadErrorState.vue')['default']
+1
View File
@@ -30,6 +30,7 @@ test('implements profile view, edit, save and cancel states', () => {
test('implements password validation, strength feedback and double-submit protection', () => {
assert.match(viewSource, /async function changePassword\(\)/)
assert.match(viewSource, /validatePasswordChange/)
assert.match(viewSource, /callback\(new Error\('请输入新密码'\)\)/)
assert.match(viewSource, /getPasswordStrength/)
assert.match(viewSource, /passwordStrengthState/)
assert.match(viewSource, /if \(passwordSaving\.value\) return/)
+11 -6
View File
@@ -73,6 +73,10 @@ const passwordRules: FormRules<PasswordChangePayload> = {
newPassword: [{
required: true,
validator: (_rule, value: string, callback) => {
if (!value) {
callback(new Error('请输入新密码'))
return
}
const errors = validatePasswordChange({
currentPassword: passwordForm.currentPassword,
newPassword: value,
@@ -277,28 +281,29 @@ async function changePassword() {
.account-form-grid :deep(.el-form-item) { align-content: start; margin-bottom: 21px; }
.account-form-actions { display: flex; grid-column: 1 / -1; justify-content: flex-end; gap: 8px; padding-top: 2px; }
.field-help { margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.5; }
.password-layout { display: grid; grid-template-columns: minmax(320px, 520px) minmax(240px, 1fr); align-items: start; gap: clamp(36px, 7vw, 90px); }
.password-layout { display: grid; grid-template-columns: minmax(0, 440px) minmax(260px, 1fr); align-items: start; max-width: 980px; gap: clamp(32px, 6vw, 72px); padding-top: 22px; }
.password-form { max-width: 440px; }
.password-form :deep(.el-form-item) { margin-bottom: 20px; }
.password-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 2px; }
.password-form :deep(.el-form-item) { margin-bottom: 18px; }
.password-form :deep(.el-form-item__error) { padding-top: 5px; line-height: 1.45; white-space: normal; }
.password-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 28px; padding-top: 18px; border-top: 1px solid var(--border); }
.password-result { display: flex; align-items: flex-start; gap: 9px; margin-top: 18px; padding: 14px; background: #edf6f4; color: var(--primary); }
.password-result strong { color: var(--navy); font-size: 12px; }
.password-result p { margin: 3px 0 0; color: #526b67; font-size: 11px; line-height: 1.6; }
.password-strength { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 12px; margin-top: 8px; color: var(--muted); }
.password-strength { display: grid; width: 100%; min-height: 18px; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 10px; margin-top: 9px; color: var(--muted); }
.password-strength > span { display: grid; flex: 1; grid-template-columns: repeat(3, 1fr); gap: 4px; }
.password-strength i { height: 3px; background: #dce3e6; }
.password-strength[data-strength="weak"] i:first-child { background: #c2413b; }
.password-strength[data-strength="medium"] i:nth-child(-n+2) { background: #c47913; }
.password-strength[data-strength="strong"] i { background: var(--primary); }
.password-strength small { white-space: nowrap; font-size: 10px; }
.security-note { display: grid; grid-template-columns: auto 1fr; gap: 10px 12px; margin-top: 24px; padding: 22px; background: #f2f7f6; color: var(--primary); }
.security-note { display: grid; grid-template-columns: auto 1fr; gap: 10px 12px; margin-top: 0; padding: 22px; background: #f2f7f6; color: var(--primary); }
.security-note strong { color: var(--navy); font-size: 13px; }
.security-note p { margin: 5px 0 0; color: #526b67; font-size: 11px; line-height: 1.7; }
.security-note > p { grid-column: 1 / -1; margin-top: 8px; padding-top: 14px; border-top: 1px solid #d7e5e2; }
.security-note code { color: var(--navy); font-weight: 700; }
@media (max-width: 820px) {
.password-layout { grid-template-columns: 1fr; gap: 14px; }
.password-layout { grid-template-columns: 1fr; gap: 24px; padding-top: 18px; }
.security-note { margin-top: 0; }
}
+50 -4
View File
@@ -8,6 +8,7 @@ import { useTenantAdministrationStore, type TenantCompanyProfile, type TenantQua
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()
@@ -25,6 +26,7 @@ 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>()
@@ -41,6 +43,16 @@ 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' }],
@@ -109,6 +121,10 @@ async function saveProfile() {
}
function openQualificationDialog() {
if (!canUploadQualification.value) {
ElMessage.warning(qualificationUploadDisabledReason.value)
return
}
resetQualificationDraft()
qualificationOpen.value = true
}
@@ -159,6 +175,12 @@ function resetQualificationDraft() {
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 = '请选择营业执照图片'
@@ -175,6 +197,12 @@ async function submitQualification() {
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
@@ -182,15 +210,24 @@ async function submitQualification() {
}
async function previewQualification(record: TenantQualification) {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewRecord.value = record
previewOpen.value = true
if (!record.documentUrl) return
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) {
ElMessage.error(error instanceof Error ? error.message : '资质文件加载失败')
previewError.value = error instanceof Error && error.message === '资质文件地址无效'
? '当前资质文件尚未迁移到安全预览地址,请联系平台管理员处理。'
: '资质文件加载失败,请稍后重试。'
ElMessage.error(previewError.value)
} finally {
previewLoading.value = false
}
@@ -199,6 +236,7 @@ async function previewQualification(record: TenantQualification) {
function closeQualificationPreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = ''
previewError.value = ''
previewRecord.value = null
}
@@ -247,7 +285,12 @@ function qualificationStatus(status: string) {
<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>文件更新后需等待平台重新审核,历史业务不受影响。</p></div><el-button v-if="auth.hasPermission('company:qualification:upload')" type="primary" @click="openQualificationDialog"><Upload :size="16" />更新资质</el-button></header>
<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"
@@ -319,7 +362,8 @@ function qualificationStatus(status: string) {
<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>
<span>{{ previewUrl ? '文件通过当前商户会话临时加载,关闭弹窗后立即释放。' : '当前文件暂不可预览。' }}</span>
<p v-if="previewError" class="document-preview-error" role="alert">{{ previewError }}</p>
<span v-else>{{ previewUrl ? '文件通过当前商户会话临时加载,关闭弹窗后立即释放。' : '当前文件暂不可预览。' }}</span>
</div>
</el-dialog>
</template>
@@ -355,6 +399,7 @@ function qualificationStatus(status: string) {
.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; }
@@ -374,6 +419,7 @@ function qualificationStatus(status: string) {
.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; }
+15 -14
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Check, LockKeyhole, RotateCcw, Save, ShieldCheck } from '@lucide/vue'
import { LockKeyhole, RotateCcw, Save, ShieldCheck } from '@lucide/vue'
import { TENANT_PERMISSION_GROUPS, useTenantAdministrationStore } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth'
@@ -110,16 +110,15 @@ async function savePermissions() {
<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">
<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">
<label v-for="item in group.items" :key="item.code" class="permission-item" :class="{ selected: selectedCodes.includes(item.code) }">
<el-checkbox :value="item.code"><span class="permission-check"><Check :size="14" /></span></el-checkbox>
<span><strong>{{ item.label }}</strong><small>{{ item.description }}</small><code>{{ item.code }}</code></span>
</label>
<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>
@@ -151,18 +150,20 @@ async function savePermissions() {
.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 { padding: 14px 18px; border-bottom: 1px solid var(--border); background: #fafcfc; }
.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-item { display: flex; min-height: 92px; align-items: flex-start; gap: 9px; padding: 16px 18px; border-bottom: 1px solid var(--border); cursor: pointer; }
.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__label) { display: none; }
.permission-check { display: none; }
.permission-item > span { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.permission-item strong { color: var(--navy); font-size: 12px; }
.permission-item small { color: var(--muted); font-size: 10px; line-height: 1.5; }
.permission-item code { color: #86939a; font-size: 9px; }
.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; } }
@@ -39,7 +39,12 @@ test('implements company details, contact information and qualification workflow
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, /企业资料加载失败/)
@@ -123,6 +128,12 @@ test('implements grouped menu and action permission editing', () => {
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, /当前只读展示权限资源/)