style(admin): unify dialog layouts and dense readonly presentation

This commit is contained in:
2026-10-20 10:11:31 +08:00
parent 7ff7c9914f
commit 3267679758
214 changed files with 10931 additions and 2654 deletions
@@ -121,6 +121,11 @@ describe('CustomerDetailView', () => {
expect(downloadTenantQualification).toHaveBeenCalledWith(tenant.id, '/files/license.png')
expect(document.querySelector('img[alt="license.png预览"]')).not.toBeNull()
expect(document.querySelector('.customer-qualification-preview .el-dialog__title')?.textContent).toBe('查看资质文件 · TEN8')
expect(document.querySelector('.customer-preview-filename')?.textContent).toBe('license.png')
document.querySelector<HTMLButtonElement>('.customer-qualification-preview button[aria-label="展开全屏"]')!.click()
await flushPromises()
expect(document.querySelector('.customer-qualification-preview.is-fullscreen img')?.getAttribute('src')).toBe('blob:qualification-preview')
})
it('reloads a reused detail view and ignores the previous customer response', async () => {
@@ -21,6 +21,7 @@ import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { customerDialogTitle } from '../customerDialogTitle'
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import PageToolbar from '@/shared/components/PageToolbar.vue'
@@ -163,7 +164,7 @@ async function changeStatus() {
const next = customer.value.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
const verb = next === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox.confirm(`确认${verb}客户“${customer.value.tenantName}”吗?`, `${verb}客户`, {
await ElMessageBox.confirm(`确认${verb}客户“${customer.value.tenantName}”吗?`, customerDialogTitle(`${verb}客户`, customer.value), {
customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消',
})
statusChanging.value = true
@@ -307,8 +308,9 @@ onBeforeUnmount(() => {
</div>
</DetailPageLayout>
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="previewOpen" :title="previewName" width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false">
<div class="qualification-preview"><img v-if="previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></div>
<AppDialog class="admin-density-dialog customer-density-dialog customer-qualification-preview" v-model="previewOpen" :title="customerDialogTitle('查看资质文件', customer)" fullscreenable width="min(920px, calc(100vw - 28px))" append-to-body :show-footer="false">
<p class="density-dialog-note customer-preview-filename">{{ previewName }}</p>
<div class="density-dialog-section qualification-preview" data-tone="source"><img v-if="previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" /><iframe v-else :src="previewUrl" :title="`${previewName}预览`" /></div>
</AppDialog>
</AdminPage>
</template>
@@ -345,7 +347,7 @@ table { width:100%; min-width:720px; border-collapse:collapse; font-size:var(--a
.member-contact span { display:block; }
.member-contact small { display:block; }.member-contact small { margin-top:3px; color:var(--cc-muted); }.module-empty > svg { color:#8797b2; }.module-empty p { max-width:520px; font-size:var(--admin-font-caption, 12px); }
.contract-note { padding:var(--admin-space-6, 16px) 18px; border-radius:10px; background:var(--admin-label-bg, #f8fafc); }.contract-note strong { color:#344054; font-size:var(--admin-font-caption, 12px); }.contract-note p { margin:5px 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.7; }
.qualification-preview { display:grid; min-height:320px; max-height:72vh; overflow:auto; place-items:center; border-radius:10px; background:var(--admin-label-bg, #f8fafc); }.qualification-preview img { display:block; max-width:100%; height:auto; }.qualification-preview iframe { width:100%; min-height:68vh; border:0; background:var(--admin-surface, #fff); }
@media (max-width:960px) { .basic-layout { grid-template-columns:1fr; }.contact-panel,.user-panel { grid-column:auto; }.qualification-panel { order:3; }.user-panel { order:4; }.info-grid--three { grid-template-columns:repeat(2,minmax(0,1fr)); } }
@media (max-width:720px) { .detail-header { align-items:flex-start; flex-wrap:wrap; }.detail-page-toolbar { width:100%; flex-basis:100%; }.company-mark { width:40px; height:40px; }.header-actions { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); width:100%; }.header-actions :deep(.el-tag) { grid-column:1 / -1; justify-self:start; }.header-actions :deep(.el-button) { min-height:44px; }.detail-panel { padding:16px; }.info-grid { grid-template-columns:1fr; }.info-grid > div.wide { grid-column:auto; } }
@media (max-width:420px) { .company-mark { display:none; }.header-actions { grid-template-columns:1fr; }.header-actions :deep(.el-tag) { grid-column:auto; }.qualification-list button { grid-template-columns:36px minmax(0,1fr) auto; }.qualification-list .file-date,.qualification-list button > svg { display:none; } }
@@ -188,7 +188,7 @@ describe('CustomersView', () => {
it('matches the frozen prototype containers for customer operations', () => {
expect(customersSource).toContain("router.push({ name: 'customer-detail'")
expect(customersSource).toContain("const editorDialogWidth = 'min(1120px, calc(100vw - 28px))'")
expect(customersSource.match(/class="form-grid three-cols"/g)).toHaveLength(2)
expect(customersSource.match(/class="density-table density-table--editable customer-editor-table"/g)).toHaveLength(2)
expect(customersSource).toContain('width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog"')
expect(customersSource).toContain('width="min(1040px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog review-decision-dialog"')
expect(customersSource).not.toContain('<AppDrawer v-model="editorOpen"')
@@ -218,6 +218,18 @@ describe('CustomersView', () => {
const email = document.querySelector<HTMLInputElement>('input[placeholder="如:contact@example.com"]')
expect(phone?.value).toBe(fullTenant.contactPhone)
expect(email?.value).toBe(fullTenant.contactEmail)
expect(document.querySelector('.customer-editor-dialog .el-dialog__title')?.textContent).toBe('编辑客户 · TEN8')
expect(document.querySelector('input[placeholder="请输入18位大写字母或数字"]')).toBeNull()
expect(document.querySelector('.qualification-credit-code')?.textContent).toContain(fullTenant.unifiedSocialCreditCode)
phone!.value = '13900139000'
phone!.dispatchEvent(new Event('input', { bubbles: true }))
document.querySelector<HTMLButtonElement>('.customer-editor-dialog button[aria-label="展开全屏"]')!.click()
await flushPromises()
expect(document.querySelector('.customer-editor-dialog.is-fullscreen')).not.toBeNull()
expect(document.querySelector<HTMLInputElement>('input[placeholder="请输入公司联系电话"]')?.value).toBe('13900139000')
document.querySelector<HTMLButtonElement>('.customer-editor-dialog button[aria-label="退出全屏"]')!.click()
await flushPromises()
expect(document.querySelector<HTMLInputElement>('input[placeholder="请输入公司联系电话"]')?.value).toBe('13900139000')
})
it('shows an authenticated image preview for an uploaded qualification', async () => {
@@ -607,7 +619,7 @@ describe('CustomersView', () => {
temporaryPassword: 'Temporary#2026',
})
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
const wrapper = mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
await flushPromises()
tableButtonByText('审核')?.click()
await flushPromises()
@@ -620,6 +632,13 @@ describe('CustomersView', () => {
expect(document.body.textContent).toContain('请立即保存以下一次性凭证')
expect(document.body.textContent).toContain('merchant_8_owner')
expect(document.body.textContent).toContain('Temporary#2026')
const credentials = wrapper.findAllComponents(AppDialog).find(dialog => dialog.props('title').startsWith('审核通过,管理员账号已创建'))!
expect(credentials.props()).toMatchObject({ modelValue: true, showClose: false, closeOnPressEscape: false, closeOnClickModal: false })
expect(document.querySelector('.approval-result-dialog .el-dialog__headerbtn')).toBeNull()
document.querySelector<HTMLButtonElement>('.approval-result-dialog .el-dialog__footer button')!.click()
await flushPromises()
expect(credentials.props('modelValue')).toBe(false)
expect(document.querySelector('.approval-result-dialog code')).toBeNull()
})
it('loads and previews authenticated qualification files in the review dialog', async () => {
+87 -127
View File
@@ -3,7 +3,7 @@ import '@/styles/adminDensity.css'
import '@/modules/customers/customerDensity.css'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Building2, CheckCircle2, CircleCheck, FileCheck2, FileText, ShieldCheck, ShieldX } from '@lucide/vue'
import { CheckCircle2, FileText, ShieldCheck, ShieldX } from '@lucide/vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/shared/api/http'
import RowActions, { type RowActionItem } from '@/shared/components/RowActions.vue'
@@ -42,7 +42,7 @@ import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.v
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import FormSection from '@/shared/components/FormSection.vue'
import { customerDialogTitle } from '../customerDialogTitle'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import { showExportTaskCreatedDialog } from '@/shared/components/exportTaskCreatedDialog'
@@ -227,7 +227,8 @@ const customerColumns: DataTableColumn[] = [
let loadSequence = 0
let reviewLoadSequence = 0
let editorLoadSequence = 0
const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户')
const editorCustomer = computed(() => result.value.records.find(row => String(row.id) === editingId.value) || { tenantName: form.value.tenantName })
const editorTitle = computed(() => editingId.value ? customerDialogTitle('编辑客户', editorCustomer.value) : '新增客户')
const editorDialogWidth = 'min(1120px, calc(100vw - 28px))'
const canUploadQualifications = computed(() => !editorStatus.value
|| ['PROFILE_REQUIRED', 'REJECTED', 'ACTIVE', 'DISABLED'].includes(editorStatus.value))
@@ -641,7 +642,7 @@ async function submitReview() {
const missing = qualifications.value.filter((item) => item.required && !item.uploaded)
for (const item of missing) item.error = `${item.label}为提交审核必填文件`
if (missing.length) { ElMessage.warning(`请先上传${missing.map((item) => item.label).join('、')}`); return }
await ElMessageBox.confirm('提交后客户资料与资质将进入企业审核,审核完成前不能继续替换文件。确认提交吗?', '提交企业审核', { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '继续完善' })
await ElMessageBox.confirm('提交后客户资料与资质将进入企业审核,审核完成前不能继续替换文件。确认提交吗?', customerDialogTitle('提交企业审核', editorCustomer.value), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '继续完善' })
const saved = await submitPlatformTenantReview(editingId.value)
editorStatus.value = saved.status; ElMessage.success('审核单已提交')
editorOpen.value = false; await loadCustomers()
@@ -730,7 +731,7 @@ function handleMoreAction(row: PlatformTenantResource, command: string) {
async function toggleStatus(row: PlatformTenantResource) {
const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; const verb = next === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox.confirm(`确认${verb}客户“${row.tenantName}”吗?`, `${verb}客户`, { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
await ElMessageBox.confirm(`确认${verb}客户“${row.tenantName}”吗?`, customerDialogTitle(`${verb}客户`, row), { customClass: 'admin-density-confirm', type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
rowActionId.value = String(row.id); await changePlatformTenantStatus(row.id, next)
ElMessage.success(`客户已${verb}`); await loadCustomers()
} catch (error) {
@@ -1081,30 +1082,29 @@ onMounted(() => { void loadCustomers() })
</DataTable></section>
</ListPageLayout>
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" destroy-on-close>
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="editorLoading || saving || submitting || uploadingQualifications">
<FormSection title="基础资料" description="维护工商登记和企业主体信息。"><div class="form-grid three-cols">
<el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item>
<el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /><small class="field-hint">创建后仍可在客户资料中修改。</small></el-form-item>
<el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
<el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item>
<el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item>
<el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item>
<el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item>
</div><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></FormSection>
<AppDialog v-model="editorOpen" :title="editorTitle" :width="editorDialogWidth" class="admin-density-dialog customer-density-dialog customer-editor-dialog" :loading="editorLoading" :confirm-loading="saving" fullscreenable destroy-on-close>
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="left" label-width="144px" class="density-form density-dialog-sections" :disabled="editorLoading || saving || submitting || uploadingQualifications">
<section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">基础资料</h3>
<table class="density-table density-table--editable customer-editor-table" aria-label="基础资料"><tbody>
<tr><td><el-form-item label="公司名称" prop="tenantName" required><el-input v-model="form.tenantName" placeholder="请输入工商登记公司名称" maxlength="128" /></el-form-item></td><td><el-form-item label="客户昵称" prop="customerNickname"><el-input v-model="form.customerNickname" placeholder="不填默认生成中文拼音大写首字母,如 SZWL" maxlength="32" /><small class="field-hint">创建后仍可在客户资料中修改。</small></el-form-item></td></tr>
<tr><td><el-form-item label="营业执照企业名称" prop="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item></td><td><el-form-item label="法定代表人" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" placeholder="请输入法定代表人姓名(选填)" maxlength="64" /></el-form-item></td></tr>
<tr><td><el-form-item label="企业类型" prop="enterpriseType"><el-input v-model="form.enterpriseType" placeholder="如:有限责任公司" maxlength="64" /></el-form-item></td><td><el-form-item label="成立日期" prop="establishedDate"><el-date-picker style="width: 100%;" v-model="form.establishedDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择成立日期" :disabled-date="(date: Date) => date.getTime() > Date.now()" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="注册资本" prop="registeredCapital"><div class="capital-input"><el-input-number v-model="form.registeredCapital" :min="0" :precision="2" controls-position="right" placeholder="请输入注册资本(选填)" /><el-select v-model="form.registeredCapitalCurrency" aria-label="注册资本币种"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /><el-option label="港币 HKD" value="HKD" /></el-select></div></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="注册地址" prop="registeredAddress"><el-input v-model="form.registeredAddress" placeholder="请输入省市区、街道及门牌信息" maxlength="500" /></el-form-item></td></tr>
</tbody></table>
</section>
<FormSection title="联系与商务" description="维护客户联系人、账期和内部跟进信息。"><div class="form-grid three-cols">
<el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item>
<el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /><small class="field-hint">仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</small></el-form-item>
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item>
<el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item>
<!-- <el-form-item label="客户套餐" prop="servicePackage" required><el-select v-model="form.servicePackage"><el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" /></el-select></el-form-item> -->
<el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item>
<el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item>
</div><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></FormSection>
<section class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">联系与商务</h3><p class="density-dialog-note">仅用于企业业务联系,不会修改企业管理员登录手机号;登录手机号请在成员/账号管理中单独维护,修改会影响登录。</p>
<table class="density-table density-table--editable customer-editor-table" aria-label="联系与商务"><tbody>
<tr><td><el-form-item label="联系人" prop="contactName" required><el-input v-model="form.contactName" placeholder="请输入主要联系人姓名" maxlength="64" /></el-form-item></td><td><el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="form.contactPhone" placeholder="请输入公司联系电话" maxlength="32" /></el-form-item></td></tr>
<tr><td><el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" placeholder="如:contact@example.com" maxlength="128" /></el-form-item></td><td><el-form-item label="联系微信" prop="contactWechat"><el-input v-model="form.contactWechat" placeholder="请输入联系人微信号" maxlength="64" /></el-form-item></td></tr>
<tr><td><el-form-item label="销售人员" prop="salesOwnerName"><el-input v-model="form.salesOwnerName" placeholder="请输入负责销售人员" maxlength="64" /></el-form-item></td><td><el-form-item label="账期(天)" prop="paymentTermDays" required><el-input-number v-model="form.paymentTermDays" :min="0" :max="365" controls-position="right" placeholder="请输入账期天数" /></el-form-item></td></tr>
<tr><td colspan="2"><el-form-item label="内部备注" prop="internalRemark"><el-input v-model="form.internalRemark" type="textarea" :rows="3" placeholder="请输入仅平台人员可见的客户备注" maxlength="1000" show-word-limit /></el-form-item></td></tr>
</tbody></table>
</section>
<FormSection class="qualification-section" title="企业认证资料" description="营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。"><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><el-input v-model="form.unifiedSocialCreditCode" :disabled="creditCodeEstablished" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item>
<section class="density-dialog-section qualification-section" data-tone="work"><h3 class="density-dialog-section__title">企业认证资料</h3><p class="density-dialog-note">营业执照和18位统一社会信用代码为必填业务项;法人身份证、货代资质证书和银行开户许可证均为选填。</p><div v-if="editorStatus" class="qualification-heading"><el-tag :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
<table class="density-table density-table--editable" aria-label="企业信用代码"><tbody><tr><td><el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required class="qualification-credit-code"><span v-if="creditCodeEstablished">{{ form.unifiedSocialCreditCode }}</span><el-input v-else v-model="form.unifiedSocialCreditCode" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /><small>{{ creditCodeEstablished ? '已登记的统一社会信用代码不可直接修改。' : '必须与营业执照一致,并随营业执照共同保存;法人身份证为选填。' }}</small></el-form-item></td></tr></tbody></table>
<div v-if="qualificationLoadError" class="qualification-load-error" role="alert">{{ qualificationLoadError }}<el-button v-if="editingId" link type="primary" @click="loadEditorQualifications(editingId)">重试</el-button></div>
<div class="qualification-table" role="table" aria-label="客户资质文件">
<div class="qualification-row qualification-header" role="row"><span role="columnheader">文件类型</span><span role="columnheader">上传文件</span><span role="columnheader">有效期</span></div>
@@ -1122,96 +1122,90 @@ onMounted(() => { void loadCustomers() })
</div>
</div>
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 5MB;<span>*</span> 为提交审核必填项。</p>
</FormSection>
</section>
</el-form>
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
<template #footer><div class="customer-editor-footer"><span>保存资料时会一并上传已选资质;进入审核仍需点击“提交审核”并再次确认</span><div class="density-actions density-actions--footer"><el-button :disabled="editorLoading || saving || submitting || uploadingQualifications" @click="editorOpen = false">关闭</el-button><el-button :loading="saving" :disabled="editorLoading || submitting || uploadingQualifications" @click="saveCustomer">保存资料和资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
</AppDialog>
<AppDialog v-model="qualificationPreviewOpen" :title="qualificationPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog qualification-preview-dialog" destroy-on-close :show-confirm="false" cancel-text="关闭预览">
<div class="qualification-preview">
<AppDialog v-model="qualificationPreviewOpen" :title="customerDialogTitle('查看资质文件', editorCustomer)" width="min(860px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog qualification-preview-dialog customer-qualification-preview" fullscreenable destroy-on-close :show-confirm="false" cancel-text="关闭预览">
<p class="density-dialog-note customer-preview-filename">{{ qualificationPreviewName }}</p>
<div class="density-dialog-section qualification-preview" data-tone="source">
<img v-if="qualificationPreviewUrl && qualificationPreviewKind === 'image'" :src="qualificationPreviewUrl" :alt="`${qualificationPreviewName}预览`" />
<iframe v-else-if="qualificationPreviewUrl && qualificationPreviewKind === 'pdf'" :src="qualificationPreviewUrl" :title="`${qualificationPreviewName} PDF 预览`" />
</div>
</AppDialog>
<el-dialog v-model="approvalResultOpen" :title="credentialSource === 'creation' ? '客户已创建,管理员账号已生成' : '审核通过,管理员账号已创建'" width="min(560px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog approval-result-dialog" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<div v-if="approvalResult" class="approval-result-content">
<AppDialog v-model="approvalResultOpen" :title="customerDialogTitle(credentialSource === 'creation' ? '客户已创建,管理员账号已生成' : '审核通过,管理员账号已创建', credentialSource === 'creation' ? editorCustomer : selectedReview)" width="min(560px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog approval-result-dialog" :close-on-click-modal="false" :close-on-press-escape="false" :show-close="false">
<div v-if="approvalResult" class="density-dialog-sections">
<div class="approval-result-warning" role="alert"><ShieldCheck :size="20" /><div><strong>请立即保存以下一次性凭证</strong><span>临时密码只在本次{{ credentialSource === 'creation' ? '创建' : '审核' }}响应中返回,关闭后无法再次查看。</span></div></div>
<dl class="approval-credentials">
<div><dt>登录账号</dt><dd><code>{{ approvalResult.administratorUsername || '未返回' }}</code><CopyButton v-if="approvalResult.administratorUsername" :value="approvalResult.administratorUsername" label="登录账号" size="small" /></dd></div>
<div><dt>临时密码</dt><dd><code>{{ approvalResult.temporaryPassword || '未返回' }}</code><CopyButton v-if="approvalResult.temporaryPassword" :value="approvalResult.temporaryPassword" label="临时密码" size="small" /></dd></div>
</dl>
<section class="density-dialog-section" data-tone="summary"><table class="density-table" aria-label="一次性管理员凭据"><tbody>
<tr><th scope="row">登录账号</th><td><div class="customer-credential-value"><code>{{ approvalResult.administratorUsername || '未返回' }}</code><CopyButton v-if="approvalResult.administratorUsername" :value="approvalResult.administratorUsername" label="登录账号" size="small" /></div></td></tr>
<tr><th scope="row">临时密码</th><td><div class="customer-credential-value"><code>{{ approvalResult.temporaryPassword || '未返回' }}</code><CopyButton v-if="approvalResult.temporaryPassword" :value="approvalResult.temporaryPassword" label="临时密码" size="small" /></div></td></tr>
</tbody></table></section>
</div>
<template #footer><el-button type="primary" @click="closeApprovalResult">我已保存,关闭</el-button></template>
</el-dialog>
</AppDialog>
<el-dialog v-model="decisionOpen" title="客户入驻审核" width="min(1040px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog review-decision-dialog" :close-on-click-modal="false">
<div v-if="selectedReview" class="review-dialog-content">
<header class="review-dialog-header">
<div class="review-dialog-title"><span class="review-dialog-icon"><Building2 :size="18" /></span><div><strong>{{ selectedReview.tenantName || '未命名客户' }}</strong><span>客户编号:{{ selectedReview.tenantCode || '未生成' }}</span></div></div>
<el-tag type="warning" effect="plain">待审核</el-tag>
</header>
<div class="review-dialog-grid">
<section class="review-dialog-section review-dialog-card">
<div class="review-section-heading"><div><h3>企业基本信息</h3><span>核对客户提交的主体资料</span></div><CircleCheck :size="18" /></div>
<dl class="review-info-grid density-fields">
<div><dt>客户名称</dt><dd>{{ selectedReview.tenantName || '未填写' }}</dd></div>
<div><dt>客户编号</dt><dd>{{ selectedReview.tenantCode || '未生成' }}</dd></div>
<div><dt>联系人</dt><dd>{{ selectedReview.contactName || '未填写' }}</dd></div>
<div><dt>公司联系电话</dt><dd>{{ selectedReview.contactPhone || '未填写' }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ selectedReview.contactEmail || '未填写' }}</dd></div>
<div><dt>提交时间</dt><dd>{{ formatDate(selectedReview.submittedAt || selectedReview.createdAt) }}</dd></div>
</dl>
</section>
<section class="review-dialog-section review-dialog-card">
<div class="review-section-heading"><div><h3>资质文件</h3><span>营业执照为必填;法人身份证及其他资质为选填。共 {{ reviewQualifications.length }} 份</span></div><FileCheck2 :size="18" /></div>
<div v-if="reviewQualificationsLoading" class="review-document-state">正在加载资质文件...</div>
<div v-else-if="reviewQualificationsError" class="review-document-error" role="alert"><span>{{ reviewQualificationsError.message }}</span><el-button link type="primary" @click="loadReviewQualifications">重试</el-button></div>
<div v-else class="review-document-table" role="table" aria-label="待审核客户资质">
<div class="review-document-header" role="row"><span role="columnheader">资质类型</span><span role="columnheader">已上传文件</span><span role="columnheader">操作</span></div>
<div v-for="item in reviewQualifications" :key="String(item.id)" class="review-document-row" role="row"><span role="cell" class="review-document-type">{{ qualificationLabel(item.qualificationType) }}</span><span role="cell" class="review-document-name" :title="qualificationName(item)"><FileText :size="15" />{{ qualificationName(item) }}</span><span role="cell" class="review-document-actions"><el-button link type="primary" :loading="reviewDocumentLoading === String(item.id)" @click="loadReviewDocument(item, false)">预览</el-button><el-button link :disabled="Boolean(reviewDocumentLoading)" @click="loadReviewDocument(item, true)">下载</el-button></span></div>
<div v-if="reviewQualifications.length === 0" class="review-document-empty"><FileText :size="18" /><span>暂无资质文件</span></div>
</div>
</section>
</div>
<section class="review-decision-card">
<div class="review-section-heading"><div><h3>审核决定</h3><span>确认资料无误后提交审核结果</span></div><ShieldCheck :size="18" /></div>
<AppDialog v-model="decisionOpen" :title="customerDialogTitle('客户入驻审核', selectedReview)" fullscreenable :confirm-loading="reviewSubmitting" :lock-while-submitting="false" width="min(1040px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog review-decision-dialog" :close-on-click-modal="false">
<div v-if="selectedReview" class="density-dialog-sections">
<section class="density-dialog-section" data-tone="source">
<h3 class="density-dialog-section__title">企业基本信息 <el-tag type="warning" effect="plain">待审核</el-tag></h3>
<table class="density-table customer-review-table" aria-label="待审核企业资料"><tbody>
<tr><th scope="row">客户名称</th><td>{{ selectedReview.tenantName || '未填写' }}</td><th scope="row">客户编号</th><td>{{ selectedReview.tenantCode || '未生成' }}</td></tr>
<tr><th scope="row">联系人</th><td>{{ selectedReview.contactName || '未填写' }}</td><th scope="row">公司联系电话</th><td>{{ selectedReview.contactPhone || '未填写' }}</td></tr>
<tr><th scope="row">联系邮箱</th><td>{{ selectedReview.contactEmail || '未填写' }}</td><th scope="row">提交时间</th><td>{{ formatDate(selectedReview.submittedAt || selectedReview.createdAt) }}</td></tr>
</tbody></table>
</section>
<section class="density-dialog-section" data-tone="source">
<h3 class="density-dialog-section__title">资质文件</h3>
<p class="density-dialog-note">营业执照为必填;法人身份证及其他资质为选填。共 {{ reviewQualifications.length }} 份</p>
<div v-if="reviewQualificationsLoading" class="review-document-state">正在加载资质文件...</div>
<div v-else-if="reviewQualificationsError" class="review-document-error" role="alert"><span>{{ reviewQualificationsError.message }}</span><el-button link type="primary" @click="loadReviewQualifications">重试</el-button></div>
<table v-else class="density-table customer-review-documents" aria-label="待审核客户资质">
<thead><tr><th>资质类型</th><th>已上传文件</th><th>操作</th></tr></thead>
<tbody><tr v-for="item in reviewQualifications" :key="String(item.id)"><td>{{ qualificationLabel(item.qualificationType) }}</td><td :title="qualificationName(item)">{{ qualificationName(item) }}</td><td><div class="review-document-actions density-actions"><el-button class="density-action" data-tone="primary" :loading="reviewDocumentLoading === String(item.id)" @click="loadReviewDocument(item, false)">预览</el-button><el-button class="density-action" :disabled="Boolean(reviewDocumentLoading)" @click="loadReviewDocument(item, true)">下载</el-button></div></td></tr>
<tr v-if="reviewQualifications.length === 0"><td colspan="3">暂无资质文件</td></tr></tbody>
</table>
</section>
<section class="density-dialog-section" data-tone="work">
<h3 class="density-dialog-section__title">审核决定</h3>
<el-radio-group v-if="access.can('platform:customer:approve') && access.can('platform:customer:reject')" v-model="decision.type" class="decision-type" aria-label="审核决定"><el-radio-button label="approve">通过</el-radio-button><el-radio-button label="reject">驳回</el-radio-button></el-radio-group>
<el-form ref="decisionFormRef" :model="decision" :rules="decisionRules" label-position="top">
<el-form ref="decisionFormRef" :model="decision" :rules="decisionRules" label-position="top" class="density-form density-form--flush-end">
<el-form-item prop="comment" :label="decision.type === 'approve' ? '审核意见' : '驳回原因'" required><el-input v-model="decision.comment" type="textarea" :rows="3" maxlength="300" show-word-limit :placeholder="decision.type === 'approve' ? '可填写审核意见' : '请明确说明需要企业修改的内容'" /></el-form-item>
</el-form>
</section>
</div>
<template #footer><div class="drawer-footer"><el-button :disabled="reviewSubmitting" @click="decisionOpen = false">取消</el-button><el-button v-if="access.can('platform:customer:reject') && decision.type === 'reject'" type="danger" :loading="reviewSubmitting" @click="submitDecision">确认驳回</el-button><el-button v-if="access.can('platform:customer:approve') && decision.type === 'approve'" type="primary" :loading="reviewSubmitting" @click="submitDecision">审核通过</el-button></div></template>
</el-dialog>
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="reviewPreviewOpen" :title="reviewPreviewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" destroy-on-close :show-confirm="false" cancel-text="关闭预览" @closed="clearReviewPreview">
<div class="qualification-preview"><img v-if="reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></div>
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="reviewSubmitting" @click="decisionOpen = false">取消</el-button><el-button v-if="access.can('platform:customer:reject') && decision.type === 'reject'" type="danger" :loading="reviewSubmitting" @click="submitDecision">确认驳回</el-button><el-button v-if="access.can('platform:customer:approve') && decision.type === 'approve'" type="primary" :loading="reviewSubmitting" @click="submitDecision">审核通过</el-button></div></template>
</AppDialog>
<el-dialog v-model="passwordDialogOpen" title="重置客户密码" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
<AppDialog class="admin-density-dialog customer-density-dialog customer-qualification-preview" v-model="reviewPreviewOpen" :title="customerDialogTitle('查看审核资质', selectedReview)" fullscreenable width="min(860px, calc(100vw - 28px))" destroy-on-close :show-confirm="false" cancel-text="关闭预览" @closed="clearReviewPreview">
<p class="density-dialog-note customer-preview-filename">{{ reviewPreviewName }}</p>
<div class="density-dialog-section qualification-preview" data-tone="source"><img v-if="reviewPreviewUrl && reviewPreviewKind === 'image'" :src="reviewPreviewUrl" :alt="`${reviewPreviewName}预览`" /><iframe v-else-if="reviewPreviewUrl" :src="reviewPreviewUrl" :title="`${reviewPreviewName} PDF 预览`" /></div>
</AppDialog>
<AppDialog v-model="passwordDialogOpen" :title="customerDialogTitle('重置客户密码', passwordTenant)" :confirm-loading="passwordSubmitting" width="min(520px, calc(100vw - 28px))" class="admin-density-dialog customer-density-dialog customer-password-dialog" :close-on-click-modal="false" :close-on-press-escape="!passwordSubmitting" :show-close="!passwordSubmitting">
<div v-if="passwordTenant" class="decision-summary"><div><strong>正在重置企业管理员登录密码</strong><span>{{ passwordTenant.tenantName }}</span></div><span>客户编号:{{ passwordTenant.tenantCode }}</span></div>
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top">
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-position="top" class="density-form">
<el-form-item label="新密码" prop="newPassword" required><div class="password-input-row"><el-input v-model="passwordForm.newPassword" type="password" show-password autocomplete="new-password" placeholder="请输入 8-20 位新密码" /><el-button @click="randomPassword">随机生成</el-button></div></el-form-item>
<el-form-item label="确认新密码" prop="confirmPassword" required><el-input v-model="passwordForm.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="请再次输入新密码" /></el-form-item>
<el-checkbox v-model="passwordForm.forceChangePassword">客户下次登录时必须修改密码</el-checkbox>
<p class="dialog-hint">重置对象为企业 OWNER 管理员,原密码将立即失效。</p>
<p class="density-dialog-note">重置对象为企业 OWNER 管理员,原密码将立即失效。</p>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="passwordSubmitting" @click="passwordDialogOpen = false">取消</el-button><el-button type="primary" :loading="passwordSubmitting" @click="submitResetPassword">确认重置</el-button></div></template>
</el-dialog>
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="passwordSubmitting" @click="passwordDialogOpen = false">取消</el-button><el-button type="primary" :loading="passwordSubmitting" @click="submitResetPassword">确认重置</el-button></div></template>
</AppDialog>
<el-dialog class="admin-density-dialog customer-density-dialog" v-model="impersonationDialogOpen" title="跳转登录客户工作台" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!impersonationSubmitting" :show-close="!impersonationSubmitting">
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="impersonationDialogOpen" :title="customerDialogTitle('跳转登录客户工作台', impersonationTenant)" :confirm-loading="impersonationSubmitting" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!impersonationSubmitting" :show-close="!impersonationSubmitting">
<div v-if="impersonationTenant" class="decision-summary"><div><strong>{{ impersonationTenant.tenantName }}</strong><span>将以企业管理员身份打开客户工作台</span></div><span>客户编号:{{ impersonationTenant.tenantCode }}</span></div>
<el-alert type="warning" :closable="false" show-icon>本操作会生成一次性、短时有效的客户会话,并记录代登录原因;不会替换当前 Admin 会话。</el-alert>
<el-form ref="impersonationFormRef" :model="impersonationForm" :rules="impersonationRules" label-position="top"><el-form-item label="代登录原因" prop="reason" required><el-input v-model="impersonationForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写 5-500 字的业务原因" /></el-form-item></el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="impersonationSubmitting" @click="impersonationDialogOpen = false">取消</el-button><el-button type="primary" :loading="impersonationSubmitting" @click="submitImpersonation">确认跳转</el-button></div></template>
</el-dialog>
<el-form ref="impersonationFormRef" :model="impersonationForm" :rules="impersonationRules" label-position="top" class="density-form"><el-form-item label="代登录原因" prop="reason" required><el-input v-model="impersonationForm.reason" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请填写 5-500 字的业务原因" /></el-form-item></el-form>
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="impersonationSubmitting" @click="impersonationDialogOpen = false">取消</el-button><el-button type="primary" :loading="impersonationSubmitting" @click="submitImpersonation">确认跳转</el-button></div></template>
</AppDialog>
<el-dialog class="admin-density-dialog customer-density-dialog" v-model="archiveDialogOpen" title="删除客户" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!archiveSubmitting" :show-close="!archiveSubmitting">
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="archiveDialogOpen" :title="customerDialogTitle('删除客户', archiveTenant)" :confirm-loading="archiveSubmitting" width="min(560px, calc(100vw - 28px))" :close-on-click-modal="false" :close-on-press-escape="!archiveSubmitting" :show-close="!archiveSubmitting">
<el-alert type="warning" :closable="false" show-icon>删除是逻辑归档:历史业务数据会保留,客户账号会被停用。是否允许归档由服务端最终判断。</el-alert>
<div v-if="archiveTenant" class="archive-confirm"><strong>{{ archiveTenant.tenantName }}</strong><span>请输入客户编号 {{ archiveTenant.tenantCode }} 以确认。</span><el-input v-model="archiveConfirmCode" :disabled="archiveSubmitting" :placeholder="archiveTenant.tenantCode" autocomplete="off" /></div>
<template #footer><div class="drawer-footer"><el-button :disabled="archiveSubmitting" @click="archiveDialogOpen = false">取消</el-button><el-button type="danger" :loading="archiveSubmitting" :disabled="!archiveTenant || archiveConfirmCode.trim() !== archiveTenant.tenantCode" @click="submitArchive">确认删除</el-button></div></template>
</el-dialog>
<template #footer><div class="density-actions density-actions--footer"><el-button :disabled="archiveSubmitting" @click="archiveDialogOpen = false">取消</el-button><el-button type="danger" :loading="archiveSubmitting" :disabled="!archiveTenant || archiveConfirmCode.trim() !== archiveTenant.tenantCode" @click="submitArchive">确认删除</el-button></div></template>
</AppDialog>
</AdminPage>
</template>
@@ -1227,7 +1221,7 @@ onMounted(() => { void loadCustomers() })
:deep(.customer-list-table .customer-code-column .cell) { white-space:nowrap; }
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:var(--admin-space-3, 8px); } .capital-input :deep(.el-input-number) { width:100%; } .capital-input :deep(.el-select) { width:100%; }
.qualification-heading { display:flex; justify-content:flex-end; margin-bottom:var(--admin-space-5, 12px); } .qualification-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; } .qualification-hint span { color:var(--el-color-danger); } .qualification-label > span { color:var(--el-color-danger); }
.qualification-credit-code { max-width:420px; margin-top:var(--admin-space-6, 16px); } .qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); line-height:1.5; }
.qualification-credit-code small { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); line-height:1.5; } .field-hint { display:block; margin-top:var(--admin-space-2, 6px); color:var(--cc-muted); line-height:1.5; }
.qualification-table { container-type:inline-size; overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:170px minmax(0,1fr) 220px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; align-items:center; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); font-weight:600; }
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
.qualification-optional { padding:2px 5px; border-radius:var(--admin-radius, 4px); background:var(--el-fill-color-light); color:var(--cc-muted); font-size:var(--admin-font-caption, 10px); font-weight:500; line-height:1.4; }
@@ -1246,51 +1240,17 @@ onMounted(() => { void loadCustomers() })
.qualification-preview-placeholder { display:grid; place-items:center; gap:2px; }
.qualification-preview-placeholder small { font-size:var(--admin-font-caption, 10px); font-weight:700; }
.qualification-preview-action { position:absolute; inset:auto 0 0; display:flex; min-height:22px; align-items:center; justify-content:center; gap:3px; background:rgba(23,32,51,.78); color:var(--admin-surface, #fff); }
.qualification-preview { display:grid; max-height:min(70vh,760px); min-height:240px; place-items:center; overflow:auto; border:1px solid var(--cc-border); border-radius:6px; background:var(--admin-label-bg, #f8fafc); }
.qualification-preview img { display:block; max-width:100%; height:auto; }
.qualification-preview iframe { width:100%; min-height:min(68vh,720px); border:0; background:var(--admin-surface, #fff); }
.qualification-load-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-4, 10px); margin-top:var(--admin-space-5, 12px); padding:var(--admin-space-4, 10px) var(--admin-space-5, 12px); border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; font-size:var(--admin-font-caption, 12px); }
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); } .customer-editor-footer > span { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); text-align:left; } .customer-editor-footer > div { display:flex; flex:0 0 auto; gap:var(--admin-space-3, 8px); } .customer-editor-footer :deep(.el-button + .el-button) { margin-left:0; }
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); } .customer-editor-footer > span { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); text-align:left; }
.decision-summary { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); background:var(--admin-label-bg, #f8fafc); } .decision-summary > * { min-width:0; overflow-wrap:anywhere; } .decision-summary strong { color:var(--cc-text); font-size:var(--admin-font-body, 13px); } .decision-summary span { color:var(--cc-muted); font-size:var(--admin-font-caption, 11px); }
.review-dialog-content { display:grid; }
.review-dialog-header { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-6, 16px); border:1px solid #dbe4f0; }
.review-dialog-title { display:flex; min-width:0; align-items:center; gap:11px; }
.review-dialog-title > div { display:grid; min-width:0; gap:3px; }
.review-dialog-title strong { overflow:hidden; color:#18243a; font-size:var(--admin-font-title, 16px); text-overflow:ellipsis; }
.review-dialog-title span:not(.review-dialog-icon) { overflow:hidden; color:#718096; font-size:var(--admin-font-caption, 12px); text-overflow:ellipsis; white-space:nowrap; }
.review-dialog-icon { width:36px; height:var(--admin-row-height, 36px); flex:0 0 36px; place-items:center; border-radius:9px; background:#e9efff; color:#526fe8; }
.review-dialog-grid { display:grid; grid-template-columns:minmax(270px,.85fr) minmax(390px,1.15fr); }
.review-dialog-section { min-width:0; border:1px solid #e5eaf1; background:var(--admin-surface, #fff); }
.review-decision-card { min-width:0; border:1px solid #e5eaf1; background:var(--admin-surface, #fff); }
.review-section-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:var(--admin-space-5, 12px); }
.review-section-heading > div { min-width:0; }
.review-section-heading h3 { margin:0; color:#1a2438; font-size:var(--admin-font-section, 14px); font-weight:700; }
.review-section-heading span { display:block; margin-top:var(--admin-space-1, 4px); color:#8a94a6; line-height:1.4; }
.review-section-heading > svg { flex:0 0 auto; color:#657bea; }
.review-info-grid { margin:0; }
.review-document-table { overflow:hidden; border:1px solid #e4e9f1; }
.review-document-header { display:grid; grid-template-columns:118px minmax(0,1fr) 138px; }
.review-document-row { display:grid; grid-template-columns:118px minmax(0,1fr) 138px; }
.review-document-header { background:#f5f7fb; color:#657188; font-weight:700; }
.review-document-header > span { min-width:0; padding:9px var(--admin-space-4, 10px); overflow-wrap:anywhere; border-right:1px solid #e4e9f1; }
.review-document-row > span { min-width:0; padding:9px var(--admin-space-4, 10px); overflow-wrap:anywhere; border-right:1px solid #e4e9f1; }
.review-document-header > span:last-child { border-right:0; }
.review-document-row > span:last-child { border-right:0; }
.review-document-row { min-height:46px; border-top:1px solid #edf0f4; color:#334155; font-size:var(--admin-font-caption, 12px); }
.review-document-row > span { display:flex; align-items:center; }
.review-document-type { font-weight:650; }
.review-document-name { min-width:0; gap:var(--admin-space-2, 6px); overflow:hidden; color:#4c5b72; text-overflow:ellipsis; }
.review-document-name svg { flex:0 0 auto; color:#8798cf; }
.review-document-actions { justify-content:flex-end; gap:var(--admin-space-2, 6px); }
.review-document-actions :deep(.el-button + .el-button) { margin-left:0; }
.review-document-state { padding:14px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
.review-document-error { padding:14px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
.review-document-empty { padding:14px; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
.review-document-error { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-3, 8px); border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; }
.review-document-empty { display:flex; align-items:center; justify-content:center; gap:7px; text-align:center; }.review-document-empty svg { color:#a5afc1; }
.review-decision-card :deep(.el-form-item) { margin-bottom:0; }.review-decision-card :deep(.el-form-item__label) { color:#4b5870; font-size:var(--admin-font-caption, 12px); font-weight:650; }.decision-summary > div > span { display:block; margin-top:var(--admin-space-1, 4px); }.decision-type { margin:0 0 14px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:var(--admin-space-3, 8px); }.dialog-hint { margin:var(--admin-space-5, 12px) 0 0; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); line-height:1.6; }.archive-confirm { display:grid; gap:var(--admin-space-3, 8px); margin:var(--admin-space-6, 16px) 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
.approval-result-warning { display:flex; align-items:flex-start; gap:var(--admin-space-4, 10px); padding:var(--admin-space-5, 12px) 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong { display:block; } .approval-result-warning span { display:block; } .approval-result-warning strong { font-size:var(--admin-font-section, 14px); } .approval-result-warning span { margin-top:var(--admin-space-1, 4px); font-size:var(--admin-font-caption, 12px); line-height:1.5; } .approval-credentials { margin:18px 0 0; border:1px solid var(--cc-border); } .approval-credentials > div { display:grid; grid-template-columns:90px minmax(0,1fr); gap:var(--admin-space-5, 12px); } .approval-credentials > div + div { border-top:1px solid var(--cc-border); } .approval-credentials dt { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); } .approval-credentials dd { display:flex; min-width:0; align-items:center; gap:var(--admin-space-4, 10px); margin:0; } .approval-credentials code { min-width:0; flex:1; overflow-wrap:anywhere; color:var(--cc-text); font-size:var(--admin-font-section, 14px); font-weight:700; }
@media (max-width:720px) { .customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.review-dialog-grid { grid-template-columns:1fr; gap:12px; }.review-info-grid { grid-template-columns:1fr 1fr; }.review-document-header,.review-document-row { grid-template-columns:104px minmax(0,1fr) 124px; }.review-dialog-header { align-items:flex-start; }.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } }
@media (max-width:480px) { :global(.review-decision-dialog .el-dialog__header),:global(.review-decision-dialog .el-dialog__body) { padding-left:16px; padding-right:16px; }.review-info-grid { grid-template-columns:1fr; }.review-document-header { display:none; }.review-document-row { grid-template-columns:1fr; gap:6px; padding:10px; }.review-document-row > span { padding:0; border-right:0; }.review-document-actions { justify-content:flex-start; }.review-dialog-title strong { max-width:190px; } }
.decision-summary > div > span { display:block; margin-top:var(--admin-space-1, 4px); }.decision-type { margin:0 0 14px; }.password-input-row { display:grid; grid-template-columns:minmax(0,1fr) auto; width:100%; gap:var(--admin-space-3, 8px); }.archive-confirm { display:grid; gap:var(--admin-space-3, 8px); margin:var(--admin-space-6, 16px) 0; }.archive-confirm strong { color:var(--cc-text); }.archive-confirm span { color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
.approval-result-warning { display:flex; align-items:flex-start; gap:var(--admin-space-4, 10px); padding:var(--admin-space-5, 12px) 14px; border:1px solid #fecdca; border-radius:8px; background:#fffafa; color:#b42318; } .approval-result-warning strong { display:block; } .approval-result-warning span { display:block; } .approval-result-warning strong { font-size:var(--admin-font-section, 14px); } .approval-result-warning span { margin-top:var(--admin-space-1, 4px); font-size:var(--admin-font-caption, 12px); line-height:1.5; }
@media (max-width:720px) { .customer-editor-footer,.review-sync-error { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; width:100%; } .customers-table-actions :deep(.el-button) { min-height:40px; margin:0; } .qualification-table { overflow-x:auto; } .qualification-row { min-width:640px; } .customer-editor-footer > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(110px,1fr)); width:100%; } .customer-editor-footer :deep(.el-button) { min-height:40px; margin:0; } .decision-summary { align-items:flex-start; flex-direction:column; }.review-document-header,.password-input-row { grid-template-columns:1fr; }.table-actions :deep(.el-button) { min-height:40px; } }
@media (max-width:480px) { :global(.review-decision-dialog .el-dialog__header),:global(.review-decision-dialog .el-dialog__body) { padding-left:16px; padding-right:16px; } }
@media (max-width:360px) { .customers-table-actions,.capital-input { grid-template-columns:1fr; } }
</style>
@@ -6,6 +6,7 @@ import { ElMessage } from 'element-plus'
import AdminPage from '@/shared/components/AdminPage.vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import { customerDialogTitle } from '../customerDialogTitle'
import QueryToolbar from '@/shared/components/QueryToolbar.vue'
import DataTable from '@/shared/components/DataTable.vue'
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
@@ -70,13 +71,11 @@ onMounted(() => { void load() })
<div class="business-page overseas-fees-page admin-density customer-density">
<ListPageLayout ariaLabel="海外仓阶梯费用列表">
<section><DataTable appearance="integrated" :stripe="false" column-settings-mode="show" :results-visible="!error" v-model:page="page" v-model:page-size="size" :data="rows" :columns="columns" :loading="loading" :total="total" preference-key="overseas-storage-fees-table" row-key="id" border empty-text="暂无海外仓阶梯费用配置" @update:page="load" @update:page-size="load">
<template #toolbar-start><h1>海外仓阶梯费用</h1></template>
<template #filters><QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="海外仓阶梯费用筛选" preference-key="overseas-storage-fees-query" @search="search" /></template>
<template #notices><template v-if="error"><section class="surface-panel error-state" role="alert"><div><strong>{{ error.message }}</strong><ApiErrorMeta :details="error" /></div><el-button type="primary" @click="load">重新加载</el-button></section></template></template>
<template #actions="{ row }"><div class="row-actions density-actions admin-text-actions"><el-button v-if="access.can('platform:overseas-storage-fee:edit')" class="density-action" size="small" plain @click="openEdit(row as OverseasStorageFeeConfig)">编辑</el-button></div></template>
<template #storage="{ row }">
<dl class="tier-list" aria-label="仓租阶梯单价,币种见本行币种列">
@@ -91,8 +90,18 @@ onMounted(() => { void load() })
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 个客户</span></template>
</DataTable></section>
</ListPageLayout>
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="dialogOpen" title="编辑海外仓阶梯费用" width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
<el-form label-position="top"><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item><div class="groups"><div v-for="(group, index) in [storageTiers, returnTiers]" :key="index"><div class="group-heading"><h3>{{ index === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(index === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div><el-table :data="group" size="small" border stripe><el-table-column label="操作" width="88" fixed="left"><template #default="{ row }"><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(row as OverseasStorageFeeTier)">删除</el-button></template></el-table-column><el-table-column label="开始天数"><template #default="{ row }"><el-input-number v-model="row.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></template></el-table-column><el-table-column label="结束天数"><template #default="{ row }"><el-input-number v-if="row.endDay !== null" v-model="row.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></template></el-table-column><el-table-column label="价格"><template #default="{ row }"><el-input-number v-model="row.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></template></el-table-column></el-table></div></div></el-form>
<AppDialog class="admin-density-dialog customer-density-dialog" v-model="dialogOpen" :title="customerDialogTitle('编辑海外仓阶梯费用', { tenantName: editing?.customerName })" fullscreenable width="min(1120px, calc(100vw - 28px))" :confirm-loading="saving" @confirm="save">
<el-form label-position="left" label-width="80px" class="density-form density-dialog-sections">
<section class="density-dialog-section" data-tone="work"><table class="density-table density-table--editable customer-fee-currency" aria-label="费用币种"><tbody><tr><td><el-form-item label="币种"><el-select v-model="form.currency" style="width:180px"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /><el-option label="EUR" value="EUR" /></el-select></el-form-item></td></tr></tbody></table></section>
<div class="customer-fee-tier-groups">
<section v-for="(group, groupIndex) in [storageTiers, returnTiers]" :key="groupIndex" class="density-dialog-section" data-tone="work">
<div class="customer-fee-tier-heading"><h3 class="density-dialog-section__title">{{ groupIndex === 0 ? '仓租(CBM/天)' : '退货仓储费(托/天)' }} · {{ group.length }}档</h3><el-button class="density-action" data-tone="primary" type="primary" plain size="small" :disabled="form.tiers.length >= 100" @click="addTier(groupIndex === 0 ? 'STORAGE' : 'RETURN_STORAGE')">新增阶梯</el-button></div>
<div class="customer-fee-tier-table-wrap"><table class="density-table density-table--editable customer-fee-tier-table" :aria-label="groupIndex === 0 ? '仓租阶梯填写' : '退货仓储费阶梯填写'"><thead><tr><th>操作</th><th>开始天数</th><th>结束天数</th><th>价格</th></tr></thead><tbody>
<tr v-for="tier in group" :key="tier.id"><td><el-button class="density-action" data-tone="danger" size="small" plain type="danger" :disabled="group.length <= 1" @click="removeTier(tier)">删除</el-button></td><td><el-input-number v-model="tier.startDay" :min="1" :max="36500" :controls="false" placeholder="请输入开始天数" /></td><td><el-input-number v-if="tier.endDay !== null" v-model="tier.endDay" :min="1" :max="36500" :controls="false" placeholder="请输入结束天数" /><span v-else>以上</span></td><td><el-input-number v-model="tier.unitPrice" :min="0" :precision="6" :controls="false" placeholder="请输入单价" /></td></tr>
</tbody></table></div>
</section>
</div>
</el-form>
</AppDialog>
</div>
</AdminPage>
@@ -105,14 +114,6 @@ onMounted(() => { void load() })
.tier-item { display:flex; align-items:baseline; min-width:0; max-width:100%; line-height:24px; }
.tier-item dt { flex:0 0 auto; color:var(--cc-muted); font-size:var(--admin-font-caption, 12px); }
.tier-item dd { min-width:0; margin:0; color:var(--cc-text); font-size:var(--admin-font-body, 13px); font-weight:600; font-variant-numeric:tabular-nums; overflow-wrap:anywhere; }
.groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); }
.groups > div { min-width:0; }
.groups :deep(.el-table) { width:100%; }
.groups :deep(.el-input-number) { width:100%; min-width:0; }
.groups h3 { margin:0 0 var(--admin-space-4, 10px); }
.group-heading { display:flex; align-items:center; justify-content:space-between; gap:var(--admin-space-5, 12px); margin-bottom:var(--admin-space-4, 10px); }
.group-heading h3 { min-width:0; margin:0; }
.group-heading :deep(.el-button) { flex:0 0 auto; }
@media (max-width:900px) { .groups { grid-template-columns:1fr; } }
@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .tier-list { grid-template-columns:1fr; } .groups > div { overflow-x:auto; } .groups :deep(.el-table) { min-width:560px; } }
@media (max-width:720px) { .error-state { align-items:stretch; flex-direction:column; gap:12px; } .error-state :deep(.el-button) { width:100%; min-height:44px; } .tier-list { grid-template-columns:1fr; } }
</style>