优化
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/shared/api/http'
|
||||
import { changeSupplierStatus, createSupplier, getSupplier, getSupplierStats, listSuppliers, updateSupplier } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import type { SaveSupplierRequest } from '@/modules/suppliers/types'
|
||||
|
||||
vi.mock('@/shared/api/http', async (original) => ({
|
||||
...await original<typeof import('@/shared/api/http')>(),
|
||||
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() },
|
||||
}))
|
||||
|
||||
const payload: SaveSupplierRequest = { name: '测试供应商', category: 'BOOKING', scope: 'DOMESTIC', region: '宁波', legalRepresentative: '张三', contactName: '李四', phone: '13800000000', email: null, address: null, businessLicenseNo: null, licenseFileId: '9007199254740995', status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null }
|
||||
const resource = { id: 9007199254740993, tenantId: 0, ...payload, createdBy: 9007199254740994, createdAt: '2026-08-13T10:00:00', updatedBy: null, updatedAt: '2026-08-13T10:00:00', deleted: false, version: 1 }
|
||||
|
||||
describe('platform supplier API', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('uses real list and stats endpoints and preserves long identifiers as strings', async () => {
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce({ code: '200', data: { records: [resource], page: 1, size: 20, total: 1, pages: 1 } })
|
||||
.mockResolvedValueOnce({ code: '200', data: { booking: 1, customs: 2, trucking: 3, certificate: 4 } })
|
||||
const page = await listSuppliers({ page: 1, size: 20, category: 'BOOKING', scope: 'DOMESTIC', keyword: '测试' })
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/suppliers', { params: expect.objectContaining({ category: 'BOOKING', scope: 'DOMESTIC', keyword: '测试' }) })
|
||||
expect(page.records[0]).toMatchObject({ id: expect.any(String), tenantId: '0', licenseFileId: expect.any(String), createdBy: expect.any(String) })
|
||||
expect(await getSupplierStats()).toEqual({ booking: 1, customs: 2, trucking: 3, certificate: 4 })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/suppliers/stats')
|
||||
})
|
||||
|
||||
it('sends tenantId=0 on detail and every write', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', data: resource })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', data: '77' })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', data: null })
|
||||
vi.mocked(http.patch).mockResolvedValue({ code: '200', data: null })
|
||||
await getSupplier('77'); await createSupplier(payload); await updateSupplier('77', payload); await changeSupplierStatus('77', 'INACTIVE')
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/suppliers/77', { params: { tenantId: 0 } })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/suppliers', payload, { params: { tenantId: 0 } })
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/suppliers/77', payload, { params: { tenantId: 0 } })
|
||||
expect(http.patch).toHaveBeenCalledWith('/platform/v1/suppliers/77/status', { status: 'INACTIVE' }, { params: { tenantId: 0 } })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/shared/api/http'
|
||||
import type { SaveSupplierRequest, Supplier, SupplierPage, SupplierQuery, SupplierStats, SupplierStatus } from '@/modules/suppliers/types'
|
||||
|
||||
const basePath = '/platform/v1/suppliers'
|
||||
const platformScope = 0
|
||||
const id = (value: string | number | null | undefined) => value == null ? null : String(value)
|
||||
|
||||
function normalizeSupplier(raw: Supplier): Supplier {
|
||||
return {
|
||||
...raw,
|
||||
id: String(raw.id), tenantId: String(raw.tenantId),
|
||||
licenseFileId: id(raw.licenseFileId), createdBy: id(raw.createdBy), updatedBy: id(raw.updatedBy),
|
||||
version: Number(raw.version), deleted: Boolean(raw.deleted),
|
||||
}
|
||||
}
|
||||
|
||||
export async function listSuppliers(query: SupplierQuery): Promise<SupplierPage> {
|
||||
const response = await http.get<ApiEnvelope<SupplierPage>, ApiEnvelope<SupplierPage>>(basePath, {
|
||||
params: {
|
||||
page: query.page ?? 1, size: query.size ?? 20, category: query.category,
|
||||
scope: query.scope || undefined, region: query.region?.trim() || undefined,
|
||||
status: query.status || undefined, keyword: query.keyword?.trim() || undefined,
|
||||
},
|
||||
})
|
||||
const data = unwrapApiResponse(response)
|
||||
return { records: data.records.map(normalizeSupplier), page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) }
|
||||
}
|
||||
|
||||
export async function getSupplierStats(): Promise<SupplierStats> {
|
||||
const response = await http.get<ApiEnvelope<SupplierStats>, ApiEnvelope<SupplierStats>>(`${basePath}/stats`)
|
||||
const data = unwrapApiResponse(response)
|
||||
return { booking: Number(data.booking), customs: Number(data.customs), trucking: Number(data.trucking), certificate: Number(data.certificate) }
|
||||
}
|
||||
|
||||
export async function getSupplier(supplierId: string): Promise<Supplier> {
|
||||
const response = await http.get<ApiEnvelope<Supplier>, ApiEnvelope<Supplier>>(`${basePath}/${encodeURIComponent(supplierId)}`, { params: { tenantId: platformScope } })
|
||||
return normalizeSupplier(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function createSupplier(payload: SaveSupplierRequest): Promise<string> {
|
||||
const response = await http.post<ApiEnvelope<string | number>, ApiEnvelope<string | number>>(basePath, payload, { params: { tenantId: platformScope } })
|
||||
return String(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function updateSupplier(supplierId: string, payload: SaveSupplierRequest): Promise<null> {
|
||||
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${encodeURIComponent(supplierId)}`, payload, { params: { tenantId: platformScope } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function changeSupplierStatus(supplierId: string, status: SupplierStatus): Promise<null> {
|
||||
const response = await http.patch<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${encodeURIComponent(supplierId)}/status`, { status }, { params: { tenantId: platformScope } })
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import AppDialog from '@/shared/components/AppDialog.vue'
|
||||
import type { Supplier } from '@/modules/suppliers/types'
|
||||
|
||||
defineProps<{ supplier: Supplier | null }>()
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog v-model="open" :title="`营业执照查看${supplier ? ` - ${supplier.name}` : ''}`" width="min(560px, calc(100vw - 24px))" :show-confirm="false" cancel-text="关闭">
|
||||
<div v-if="supplier" class="license-dialog">
|
||||
<p class="license-dialog__supplier">供应商:<strong>{{ supplier.name }}</strong></p>
|
||||
<section class="license-card" aria-label="营业执照信息">
|
||||
<h2>营业执照</h2><p class="license-card__copy">(副本)</p>
|
||||
<dl>
|
||||
<div><dt>名称:</dt><dd>{{ supplier.name }}</dd></div>
|
||||
<div><dt>法定代表人:</dt><dd>{{ supplier.legalRepresentative }}</dd></div>
|
||||
<div><dt>统一社会信用代码:</dt><dd>{{ supplier.businessLicenseNo || '—' }}</dd></div>
|
||||
<div><dt>资质文件:</dt><dd>{{ supplier.licenseFileId ? '已关联' : '未上传' }}</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<el-alert v-if="supplier.licenseFileId" class="license-dialog__notice" type="info" :closable="false" show-icon title="资质文件已保留;平台公共供应商文件读取接口尚未提供,暂无法展示原始影像。" />
|
||||
</div>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.license-dialog__supplier { margin: 0 0 14px; color: var(--cc-muted); font-size: 13px; }
|
||||
.license-card { max-width: 460px; margin: 0 auto; padding: 24px 28px; border: 2px solid #c0392b; border-radius: 8px; background: #fff; box-shadow: 0 4px 16px rgba(192,57,43,.12); }
|
||||
.license-card h2 { margin: 0; color: #c0392b; font-size: 22px; letter-spacing: 6px; text-align: center; }
|
||||
.license-card__copy { margin: 2px 0 18px; color: #c0392b; font-size: 12px; text-align: center; }
|
||||
.license-card dl { margin: 0; }
|
||||
.license-card dl div { display: grid; grid-template-columns: 126px minmax(0, 1fr); margin-bottom: 10px; font-size: 14px; }
|
||||
.license-card dt { color: #444; font-weight: 700; }.license-card dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
|
||||
.license-dialog__notice { margin-top: 16px; }
|
||||
@media (max-width: 480px) { .license-card { padding: 20px 16px; } .license-card dl div { grid-template-columns: 1fr; gap: 3px; } .license-card h2 { font-size: 19px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, FileText, Pencil } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue'
|
||||
import { getSupplier } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import { categoryLabels, scopeLabels, settlementLabels, statusLabels, type Supplier } from '@/modules/suppliers/types'
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const access = useAccessPreviewStore()
|
||||
const canView = computed(() => access.can('platform:supplier:list'))
|
||||
const supplierId = computed(() => typeof route.params.id === 'string' ? route.params.id : '')
|
||||
const supplier = ref<Supplier | null>(null); const loading = ref(false); const error = ref<ApiErrorDetails | null>(null); const licenseOpen = ref(false)
|
||||
const items = computed(() => supplier.value ? [
|
||||
['供应商名称', supplier.value.name], ['供应商类别', categoryLabels[supplier.value.category]], ['国内/国外', supplier.value.scope ? scopeLabels[supplier.value.scope] : '—'], ['地区', supplier.value.region],
|
||||
['法人代表', supplier.value.legalRepresentative], ['联系人', supplier.value.contactName], ['联系电话', supplier.value.phone], ['电子邮箱', supplier.value.email || '—'],
|
||||
['营业执照号', supplier.value.businessLicenseNo || '—'], ['结算周期', settlementLabels[supplier.value.settlementPeriod]], ['公司地址', supplier.value.address || '—'], ['备注', supplier.value.remark || '—'],
|
||||
] : [])
|
||||
async function load() { if (!canView.value) return; loading.value = true; error.value = null; try { supplier.value = await getSupplier(supplierId.value) } catch (cause) { error.value = getApiErrorDetails(cause, '供应商详情加载失败') } finally { loading.value = false } }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage width="comfortable"><DetailPageLayout aria-label="供应商详情">
|
||||
<template #header><div class="detail-header"><div><h2>{{ supplier?.name || '供应商详情' }}</h2><StatusBadge v-if="supplier" :status="statusLabels[supplier.status]" /></div><div class="detail-actions"><el-button @click="router.push({ name: 'supplier-list' })"><ArrowLeft :size="16" />返回</el-button><el-button v-if="supplier && access.can('platform:supplier:edit')" type="primary" @click="router.push({ name: 'supplier-edit', params: { id: supplier.id } })"><Pencil :size="16" />编辑</el-button></div></div></template>
|
||||
<LoadErrorState v-if="!canView" title="暂无供应商查看权限" message="请联系管理员开通供应商查看权限。" :show-retry="false" />
|
||||
<LoadErrorState v-else-if="error" title="供应商详情加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState>
|
||||
<section v-else v-loading="loading" class="surface-panel detail-panel">
|
||||
<dl v-if="supplier" class="detail-grid"><div v-for="item in items" :key="item[0]"><dt>{{ item[0] }}</dt><dd>{{ item[1] }}</dd></div></dl>
|
||||
<div v-if="supplier" class="license-row"><div><strong>营业执照</strong><span>{{ supplier.licenseFileId ? '已关联资质文件' : '未上传资质文件' }}</span></div><el-button type="primary" plain @click="licenseOpen = true"><FileText :size="16" />查看营业执照</el-button></div>
|
||||
</section>
|
||||
<SupplierLicenseDialog v-model="licenseOpen" :supplier="supplier" />
|
||||
</DetailPageLayout></AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.detail-header > div { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }.detail-header h2 { margin: 0; font-size: 16px; overflow-wrap: anywhere; }.detail-actions :deep(.el-button + .el-button) { margin-left: 0; }
|
||||
.detail-panel { padding: 22px; }.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; }.detail-grid div { min-width: 0; padding: 14px 16px; border-right: 1px solid var(--cc-border); border-bottom: 1px solid var(--cc-border); }.detail-grid div:nth-child(3n) { border-right: 0; }.detail-grid dt { margin-bottom: 6px; color: var(--cc-muted); font-size: 12px; }.detail-grid dd { margin: 0; line-height: 1.55; overflow-wrap: anywhere; }.license-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 20px; padding: 16px; border: 1px dashed var(--cc-border); border-radius: 6px; background: #fafafa; }.license-row strong,.license-row span { display: block; }.license-row span { margin-top: 4px; color: var(--cc-muted); font-size: 12px; }
|
||||
@media (max-width: 720px) { .detail-header,.license-row { align-items: stretch; flex-direction: column; }.detail-actions { display: grid !important; grid-template-columns: 1fr 1fr; }.detail-actions :deep(.el-button) { min-height: 44px; }.detail-grid { grid-template-columns: 1fr; }.detail-grid div,.detail-grid div:nth-child(3n) { border-right: 0; }.detail-panel { padding: 16px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ArrowLeft, FileText, Save } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DetailPageLayout from '@/shared/components/DetailPageLayout.vue'
|
||||
import FormSection from '@/shared/components/FormSection.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import { useUnsavedWorkRegistration } from '@/core/unsaved-work/useUnsavedWorkRegistration'
|
||||
import { createSupplier, getSupplier, updateSupplier } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import { categoryLabels, scopeLabels, settlementLabels, statusLabels, type SaveSupplierRequest, type SupplierCategory } from '@/modules/suppliers/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const supplierId = computed(() => typeof route.params.id === 'string' ? route.params.id : '')
|
||||
const editing = computed(() => Boolean(supplierId.value))
|
||||
const requiredPermission = computed(() => editing.value ? 'platform:supplier:edit' : 'platform:supplier:create')
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const submitError = ref<ApiErrorDetails | null>(null)
|
||||
const initial = (): SaveSupplierRequest => ({ name: '', category: 'BOOKING', scope: 'DOMESTIC', region: '', legalRepresentative: '', contactName: '', phone: '', email: null, address: null, businessLicenseNo: null, licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: null })
|
||||
const form = reactive(initial())
|
||||
const rules: FormRules<SaveSupplierRequest> = {
|
||||
name: [{ required: true, message: '请输入供应商名称', trigger: 'blur' }], category: [{ required: true, message: '请选择供应商类别', trigger: 'change' }],
|
||||
region: [{ required: true, message: '请输入地区', trigger: 'blur' }], legalRepresentative: [{ required: true, message: '请输入法人代表', trigger: 'blur' }],
|
||||
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }], phone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
|
||||
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }], status: [{ required: true, message: '请选择合作状态', trigger: 'change' }],
|
||||
settlementPeriod: [{ required: true, message: '请选择结算周期', trigger: 'change' }],
|
||||
}
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({ key: 'supplier-editor', label: computed(() => editing.value ? '编辑供应商' : '新增供应商'), isOpen: computed(() => true), model: form, requiredPermission })
|
||||
|
||||
function optional(value: string | null) { return value?.trim() || null }
|
||||
function payload(): SaveSupplierRequest {
|
||||
return { ...form, name: form.name.trim(), scope: form.category === 'BOOKING' ? form.scope : null, region: form.region.trim(), legalRepresentative: form.legalRepresentative.trim(), contactName: form.contactName.trim(), phone: form.phone.trim(), email: optional(form.email), address: optional(form.address), businessLicenseNo: optional(form.businessLicenseNo), licenseFileId: form.licenseFileId, remark: optional(form.remark) }
|
||||
}
|
||||
async function load() {
|
||||
if (!editing.value || !access.can(requiredPermission.value)) { captureBaseline(); return }
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const data = await getSupplier(supplierId.value)
|
||||
Object.assign(form, { name: data.name, category: data.category, scope: data.scope, region: data.region, legalRepresentative: data.legalRepresentative, contactName: data.contactName, phone: data.phone, email: data.email, address: data.address, businessLicenseNo: data.businessLicenseNo, licenseFileId: data.licenseFileId, status: data.status, settlementPeriod: data.settlementPeriod, remark: data.remark })
|
||||
captureBaseline()
|
||||
} catch (error) { loadError.value = getApiErrorDetails(error, '供应商详情加载失败') } finally { loading.value = false }
|
||||
}
|
||||
async function save() {
|
||||
if (!access.can(requiredPermission.value)) return
|
||||
if (form.category === 'BOOKING' && !form.scope) { ElMessage.error('请选择订舱国内/国外范围'); return }
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
saving.value = true; submitError.value = null
|
||||
try {
|
||||
if (editing.value) await updateSupplier(supplierId.value, payload())
|
||||
else await createSupplier(payload())
|
||||
captureBaseline(); ElMessage.success(editing.value ? '供应商修改成功' : '供应商新增成功')
|
||||
await router.push({ name: 'supplier-list' })
|
||||
} catch (error) { submitError.value = getApiErrorDetails(error, editing.value ? '供应商修改失败' : '供应商新增失败') } finally { saving.value = false }
|
||||
}
|
||||
|
||||
watch(() => form.category, (category) => { if (category === 'BOOKING' && !form.scope) form.scope = 'DOMESTIC'; if (category !== 'BOOKING') form.scope = null })
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage width="comfortable">
|
||||
<DetailPageLayout :aria-label="editing ? '编辑供应商' : '新增供应商'">
|
||||
<template #header><div class="form-page-header"><h2>{{ editing ? '编辑供应商' : '新增供应商' }}</h2><el-button @click="router.push({ name: 'supplier-list' })"><ArrowLeft :size="16" />返回</el-button></div></template>
|
||||
<LoadErrorState v-if="!access.can(requiredPermission)" title="暂无操作权限" :message="`请联系管理员开通${editing ? '供应商编辑' : '供应商新增'}权限。`" :show-retry="false" />
|
||||
<LoadErrorState v-else-if="loadError" title="供应商加载失败" :message="loadError.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="loadError" /></template></LoadErrorState>
|
||||
<section v-else v-loading="loading" class="surface-panel supplier-form-panel">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" :disabled="saving">
|
||||
<FormSection title="基础信息">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="供应商名称" prop="name"><el-input v-model="form.name" maxlength="160" placeholder="请输入供应商名称" /></el-form-item>
|
||||
<el-form-item label="供应商类别" prop="category"><el-select v-model="form.category"><el-option v-for="(label, value) in categoryLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="地区" prop="region"><el-input v-model="form.region" maxlength="80" placeholder="请输入地区" /></el-form-item>
|
||||
<el-form-item v-if="form.category === 'BOOKING'" label="国内/国外" prop="scope"><el-select v-model="form.scope"><el-option v-for="(label, value) in scopeLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="法人代表" prop="legalRepresentative"><el-input v-model="form.legalRepresentative" maxlength="80" placeholder="请输入法人代表" /></el-form-item>
|
||||
<el-form-item label="联系人" prop="contactName"><el-input v-model="form.contactName" maxlength="80" placeholder="请输入联系人" /></el-form-item>
|
||||
<el-form-item label="联系电话" prop="phone"><el-input v-model="form.phone" maxlength="40" placeholder="请输入联系电话" /></el-form-item>
|
||||
<el-form-item label="电子邮箱" prop="email"><el-input v-model="form.email" maxlength="160" placeholder="请输入邮箱" /></el-form-item>
|
||||
<el-form-item label="合作状态" prop="status"><el-select v-model="form.status"><el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item label="结算周期" prop="settlementPeriod"><el-select v-model="form.settlementPeriod"><el-option v-for="(label, value) in settlementLabels" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form-item class="span-2" label="公司地址" prop="address"><el-input v-model="form.address" maxlength="255" placeholder="请输入公司地址" /></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection title="营业执照">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="营业执照号" prop="businessLicenseNo"><el-input v-model="form.businessLicenseNo" maxlength="64" placeholder="统一社会信用代码" /></el-form-item>
|
||||
<el-form-item label="资质文件"><div class="file-placeholder"><FileText :size="24" /><span>{{ form.licenseFileId ? '已保留现有营业执照文件' : '暂无营业执照文件' }}</span><small>平台公共供应商资质上传接口尚未提供,当前不能新增或替换文件。</small></div></el-form-item>
|
||||
</div>
|
||||
</FormSection>
|
||||
<FormSection title="其他信息"><el-form-item label="备注" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item></FormSection>
|
||||
</el-form>
|
||||
<LoadErrorState v-if="submitError" compact title="保存失败" :message="submitError.message" :show-retry="false"><template #details><ApiErrorMeta :details="submitError" /></template></LoadErrorState>
|
||||
<footer class="form-actions"><el-button type="primary" :loading="saving" @click="save"><Save :size="16" />保存</el-button><el-button :disabled="saving" @click="router.push({ name: 'supplier-list' })">取消</el-button></footer>
|
||||
</section>
|
||||
</DetailPageLayout>
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.form-page-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 20px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }.form-page-header h2 { margin: 0; font-size: 16px; }
|
||||
.supplier-form-panel { padding: 22px; }.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; }.span-2 { grid-column: 1 / -1; }
|
||||
.form-grid :deep(.el-select) { width: 100%; }.file-placeholder { display: flex; width: 100%; min-height: 96px; align-items: center; justify-content: center; flex-direction: column; gap: 6px; padding: 16px; border: 1px dashed #d9d9d9; border-radius: 6px; background: #fafafa; color: var(--cc-muted); text-align: center; }.file-placeholder small { max-width: 420px; line-height: 1.5; }
|
||||
.form-actions { display: flex; justify-content: center; gap: 14px; padding: 18px 0 0; border-top: 1px solid var(--cc-border); }.form-actions :deep(.el-button) { min-width: 112px; margin-left: 0; }
|
||||
@media (max-width: 720px) { .supplier-form-panel { padding: 16px; } .form-grid { grid-template-columns: 1fr; }.span-2 { grid-column: auto; }.form-page-header { align-items: stretch; flex-direction: column; }.form-actions { flex-direction: column; }.form-actions :deep(.el-button) { width: 100%; min-height: 44px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const source = readFileSync(resolve(process.cwd(), 'src/modules/suppliers/pages/SuppliersView.vue'), 'utf8')
|
||||
const formSource = readFileSync(resolve(process.cwd(), 'src/modules/suppliers/pages/SupplierFormView.vue'), 'utf8')
|
||||
const detailSource = readFileSync(resolve(process.cwd(), 'src/modules/suppliers/pages/SupplierDetailView.vue'), 'utf8')
|
||||
const routesSource = readFileSync(resolve(process.cwd(), 'src/modules/suppliers/routes.ts'), 'utf8')
|
||||
|
||||
describe('supplier formal pages', () => {
|
||||
it('keeps actions first and fixed left while guarding all operations with backend permissions', () => {
|
||||
expect(source).toContain("{ key: 'actions', label: '操作', width: 260, fixed: 'left'")
|
||||
expect(source).toContain("access.can('platform:supplier:create')")
|
||||
expect(source).toContain("access.can('platform:supplier:edit')")
|
||||
expect(source).toContain("access.can('platform:supplier:status')")
|
||||
expect(source).toContain("access.can('platform:supplier:list')")
|
||||
})
|
||||
|
||||
it('uses independent routes for list, detail, create and edit', () => {
|
||||
expect(routesSource).toContain("path: 'suppliers/new'")
|
||||
expect(routesSource).toContain("path: 'suppliers/:id'")
|
||||
expect(routesSource).toContain("path: 'suppliers/:id/edit'")
|
||||
expect(routesSource).toContain("permission: 'platform:supplier:create'")
|
||||
expect(routesSource).toContain("permission: 'platform:supplier:edit'")
|
||||
expect(routesSource).toContain("permission: 'platform:supplier:list'")
|
||||
expect(routesSource).not.toContain('platform:supplier:view')
|
||||
expect(detailSource).toContain('供应商详情')
|
||||
})
|
||||
|
||||
it('does not simulate supplier data or license upload and retains API errors', () => {
|
||||
expect(source).not.toContain('supplierData')
|
||||
expect(formSource).not.toContain('type="file"')
|
||||
expect(formSource).toContain('平台公共供应商资质上传接口尚未提供')
|
||||
expect(formSource).toContain('<ApiErrorMeta')
|
||||
expect(detailSource).toContain('<ApiErrorMeta')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Eye, FileText, Pencil, Plus, Power } from '@lucide/vue'
|
||||
import AdminPage from '@/shared/components/AdminPage.vue'
|
||||
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/shared/components/DataTable.vue'
|
||||
import ListPageLayout from '@/shared/components/ListPageLayout.vue'
|
||||
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/shared/components/QueryToolbar.vue'
|
||||
import StatusBadge from '@/shared/components/StatusBadge.vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
|
||||
import { useAccessPreviewStore } from '@/core/access/accessPreview'
|
||||
import SupplierLicenseDialog from '@/modules/suppliers/components/SupplierLicenseDialog.vue'
|
||||
import { changeSupplierStatus, getSupplierStats, listSuppliers } from '@/modules/suppliers/api/platformSuppliers'
|
||||
import { categoryLabels, scopeLabels, settlementLabels, statusLabels, type Supplier, type SupplierCategory, type SupplierScope, type SupplierStats, type SupplierStatus } from '@/modules/suppliers/types'
|
||||
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
const canView = computed(() => access.can('platform:supplier:list'))
|
||||
const loading = ref(false)
|
||||
const rows = ref<Supplier[]>([])
|
||||
const stats = ref<SupplierStats>({ booking: 0, customs: 0, trucking: 0, certificate: 0 })
|
||||
const activeCategory = ref<SupplierCategory>('BOOKING')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const actionId = ref('')
|
||||
const licenseOpen = ref(false)
|
||||
const licenseSupplier = ref<Supplier | null>(null)
|
||||
type Filters = QueryModel & { status: SupplierStatus | ''; scope: SupplierScope | ''; region: string; keyword: string }
|
||||
const filters = ref<Filters>({ status: '', scope: '', region: '', keyword: '' })
|
||||
const applied = ref<Filters>({ ...filters.value })
|
||||
let sequence = 0
|
||||
|
||||
const queryFields = computed<QueryField[]>(() => [
|
||||
{ key: 'status', type: 'select', label: '状态', placeholder: '全部状态', width: 150, options: [{ label: '合作中', value: 'ACTIVE' }, { label: '停用', value: 'INACTIVE' }] },
|
||||
...(activeCategory.value === 'BOOKING' ? [{ key: 'scope', type: 'select' as const, label: '订舱范围', placeholder: '全部国内/国外', width: 170, options: [{ label: '国内', value: 'DOMESTIC' }, { label: '国外', value: 'INTERNATIONAL' }] }] : []),
|
||||
{ key: 'region', type: 'input', label: '地区', placeholder: '请输入地区', width: 170, props: { maxlength: 80 } },
|
||||
{ key: 'keyword', type: 'input', label: '名称或联系人', placeholder: '供应商名称/联系人', width: 220, props: { maxlength: 160 } },
|
||||
])
|
||||
|
||||
const columns = computed<DataTableColumn[]>(() => [
|
||||
{ key: 'actions', label: '操作', width: 260, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'name', label: '供应商名称', minWidth: 230, slotName: 'name' },
|
||||
{ key: 'category', label: '类别', width: 90, slotName: 'category' },
|
||||
...(activeCategory.value === 'BOOKING' ? [{ key: 'scope', label: '国内/国外', width: 110, slotName: 'scope' }] : []),
|
||||
{ key: 'region', label: '地区', minWidth: 110 },
|
||||
{ key: 'legalRepresentative', label: '法人', minWidth: 120 },
|
||||
{ key: 'contactName', label: '联系人', minWidth: 110 },
|
||||
{ key: 'phone', label: '联系电话', minWidth: 150 },
|
||||
{ key: 'businessLicenseNo', label: '营业执照号', minWidth: 190 },
|
||||
{ key: 'license', label: '营业执照', width: 125, slotName: 'license' },
|
||||
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
|
||||
{ key: 'settlementPeriod', label: '结算周期', width: 120, slotName: 'settlement' },
|
||||
])
|
||||
const metricItems = computed(() => [
|
||||
{ category: 'BOOKING' as const, label: '订舱供应商', value: stats.value.booking },
|
||||
{ category: 'CUSTOMS' as const, label: '报关供应商', value: stats.value.customs },
|
||||
{ category: 'TRUCKING' as const, label: '拖车供应商', value: stats.value.trucking },
|
||||
{ category: 'CERTIFICATE' as const, label: '证书供应商', value: stats.value.certificate },
|
||||
])
|
||||
|
||||
async function load() {
|
||||
if (!canView.value) return
|
||||
const current = ++sequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const [result, counts] = await Promise.all([
|
||||
listSuppliers({ page: page.value, size: pageSize.value, category: activeCategory.value, status: applied.value.status, scope: activeCategory.value === 'BOOKING' ? applied.value.scope : '', region: applied.value.region, keyword: applied.value.keyword }),
|
||||
getSupplierStats(),
|
||||
])
|
||||
if (current !== sequence) return
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
stats.value = counts
|
||||
} catch (error) {
|
||||
if (current !== sequence) return
|
||||
rows.value = []; total.value = 0
|
||||
loadError.value = getApiErrorDetails(error, '供应商列表加载失败')
|
||||
} finally { if (current === sequence) loading.value = false }
|
||||
}
|
||||
|
||||
function search() { applied.value = { ...filters.value }; page.value = 1; void load() }
|
||||
function switchCategory(category: SupplierCategory) {
|
||||
activeCategory.value = category
|
||||
filters.value.scope = ''; applied.value.scope = ''; page.value = 1
|
||||
void load()
|
||||
}
|
||||
function showLicense(row: Supplier) { licenseSupplier.value = row; licenseOpen.value = true }
|
||||
function supplierRow(row: object) { return row as Supplier }
|
||||
async function toggleStatus(row: Supplier) {
|
||||
const next: SupplierStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
|
||||
const verb = next === 'ACTIVE' ? '启用' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${verb}供应商“${row.name}”吗?`, `${verb}供应商`, { type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
|
||||
actionId.value = row.id
|
||||
await changeSupplierStatus(row.id, next)
|
||||
ElMessage.success(`供应商已${verb}`)
|
||||
await load()
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
const details = getApiErrorDetails(error, `${verb}供应商失败`)
|
||||
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
|
||||
} finally { actionId.value = '' }
|
||||
}
|
||||
|
||||
watch([page, pageSize], ([newPage, newSize], [oldPage, oldSize]) => { if (newPage !== oldPage || newSize !== oldSize) void load() })
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminPage>
|
||||
<ListPageLayout aria-label="供应商列表">
|
||||
<template #metrics>
|
||||
<div class="supplier-metrics" aria-label="供应商分类统计">
|
||||
<button v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)">
|
||||
<span>{{ item.label }}</span><strong>{{ item.value }}<small>家</small></strong>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar>
|
||||
<QueryToolbar v-model="filters" ariaLabel="供应商筛选" :fields="queryFields" @search="search" />
|
||||
</template>
|
||||
<template #state>
|
||||
<LoadErrorState v-if="!canView" title="暂无供应商查看权限" message="请联系管理员开通供应商查看权限。" :show-retry="false" />
|
||||
<LoadErrorState v-else-if="loadError" title="供应商加载失败" :message="loadError.message" :loading="loading" @retry="load">
|
||||
<template #details><ApiErrorMeta :details="loadError" /></template>
|
||||
</LoadErrorState>
|
||||
</template>
|
||||
<section v-if="canView" class="surface-panel table-panel">
|
||||
<nav class="supplier-tabs" aria-label="供应商分类">
|
||||
<button v-for="item in metricItems" :key="item.category" type="button" :class="{ active: activeCategory === item.category }" @click="switchCategory(item.category)">{{ categoryLabels[item.category] }}</button>
|
||||
</nav>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" empty-text="暂无供应商数据" preference-key="supplier-list">
|
||||
<template #toolbar-actions>
|
||||
<el-button v-if="access.can('platform:supplier:create')" type="primary" @click="router.push({ name: 'supplier-create' })"><Plus :size="16" />新增供应商</el-button>
|
||||
</template>
|
||||
<template #actions="{ row }">
|
||||
<div class="row-actions">
|
||||
<el-button link type="primary" @click="router.push({ name: 'supplier-detail', params: { id: row.id } })"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="access.can('platform:supplier:edit')" link type="primary" @click="router.push({ name: 'supplier-edit', params: { id: row.id } })"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="access.can('platform:supplier:status')" link :type="row.status === 'ACTIVE' ? 'danger' : 'success'" :loading="actionId === row.id" @click="toggleStatus(supplierRow(row))"><Power :size="15" />{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #name="{ row }"><strong class="supplier-name">{{ row.name }}</strong></template>
|
||||
<template #category="{ row }">{{ categoryLabels[row.category as SupplierCategory] }}</template>
|
||||
<template #scope="{ row }">{{ row.scope ? scopeLabels[row.scope as SupplierScope] : '—' }}</template>
|
||||
<template #license="{ row }"><el-button link type="primary" @click="showLicense(supplierRow(row))"><FileText :size="15" />查看</el-button></template>
|
||||
<template #status="{ row }"><StatusBadge :status="statusLabels[row.status as SupplierStatus]" /></template>
|
||||
<template #settlement="{ row }">{{ settlementLabels[supplierRow(row).settlementPeriod] }}</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
</ListPageLayout>
|
||||
<SupplierLicenseDialog v-model="licenseOpen" :supplier="licenseSupplier" />
|
||||
</AdminPage>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.supplier-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; }
|
||||
.supplier-metrics button { min-width: 0; padding: 18px 20px; border: 0; border-right: 1px solid var(--cc-border); background: #fff; color: var(--cc-text); text-align: left; cursor: pointer; }
|
||||
.supplier-metrics button:last-child { border-right: 0; }.supplier-metrics button.active { box-shadow: inset 0 3px 0 var(--el-color-primary); background: #f8fbff; }
|
||||
.supplier-metrics span { display: block; margin-bottom: 7px; color: var(--cc-muted); font-size: 12px; }.supplier-metrics strong { font-size: 25px; }.supplier-metrics small { margin-left: 3px; color: var(--cc-muted); font-size: 12px; }
|
||||
.supplier-tabs { display: flex; gap: 6px; padding: 14px 14px 0; overflow-x: auto; border-bottom: 1px solid var(--cc-border); }
|
||||
.supplier-tabs button { min-width: 76px; padding: 7px 20px; border: 0; border-radius: 6px 6px 0 0; background: #f5f5f5; color: #555; cursor: pointer; }.supplier-tabs button.active { background: var(--el-color-primary); color: #fff; font-weight: 650; }
|
||||
.row-actions { display: flex; align-items: center; white-space: nowrap; }.row-actions :deep(.el-button + .el-button) { margin-left: 8px; }.supplier-name { overflow-wrap: anywhere; }
|
||||
@media (max-width: 720px) { .supplier-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } .supplier-metrics button:nth-child(2) { border-right: 0; } .supplier-metrics button:nth-child(-n+2) { border-bottom: 1px solid var(--cc-border); } }
|
||||
@media (max-width: 380px) { .supplier-metrics { grid-template-columns: 1fr; } .supplier-metrics button { border-right: 0; border-bottom: 1px solid var(--cc-border); } .supplier-metrics button:last-child { border-bottom: 0; } }
|
||||
</style>
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
|
||||
export const supplierRoutes = [
|
||||
{ path: 'suppliers', name: 'supplier-list', component: () => import('@/modules/suppliers/pages/SuppliersView.vue'), meta: { title: '供应商中心', description: '维护平台公共订舱、报关、拖车和证书供应商', permission: 'platform:supplier:list' } },
|
||||
{ path: 'suppliers/new', name: 'supplier-create', component: () => import('@/modules/suppliers/pages/SupplierFormView.vue'), meta: { title: '新增供应商', description: '新增平台公共供应商档案', permission: 'platform:supplier:create' } },
|
||||
{ path: 'suppliers/:id', name: 'supplier-detail', component: () => import('@/modules/suppliers/pages/SupplierDetailView.vue'), meta: { title: '供应商详情', description: '查看平台公共供应商完整档案', permission: 'platform:supplier:list' } },
|
||||
{ path: 'suppliers/:id/edit', name: 'supplier-edit', component: () => import('@/modules/suppliers/pages/SupplierFormView.vue'), meta: { title: '编辑供应商', description: '编辑平台公共供应商档案', permission: 'platform:supplier:edit' } },
|
||||
] satisfies RouteRecordRaw[]
|
||||
@@ -0,0 +1,73 @@
|
||||
export type SupplierCategory = 'BOOKING' | 'CUSTOMS' | 'TRUCKING' | 'CERTIFICATE'
|
||||
export type SupplierScope = 'DOMESTIC' | 'INTERNATIONAL'
|
||||
export type SupplierStatus = 'ACTIVE' | 'INACTIVE'
|
||||
export type SettlementPeriod = 'MONTHLY_30' | 'MONTHLY_45' | 'MONTHLY_60' | 'MONTHLY_90' | 'PER_TRANSACTION' | 'IMMEDIATE'
|
||||
|
||||
export interface Supplier {
|
||||
id: string
|
||||
tenantId: string
|
||||
name: string
|
||||
category: SupplierCategory
|
||||
scope: SupplierScope | null
|
||||
region: string
|
||||
legalRepresentative: string
|
||||
contactName: string
|
||||
phone: string
|
||||
email: string | null
|
||||
address: string | null
|
||||
businessLicenseNo: string | null
|
||||
licenseFileId: string | null
|
||||
status: SupplierStatus
|
||||
settlementPeriod: SettlementPeriod
|
||||
remark: string | null
|
||||
createdBy: string | null
|
||||
createdAt: string
|
||||
updatedBy: string | null
|
||||
updatedAt: string
|
||||
deleted: boolean
|
||||
version: number
|
||||
}
|
||||
|
||||
export interface SupplierPage {
|
||||
records: Supplier[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface SupplierStats { booking: number; customs: number; trucking: number; certificate: number }
|
||||
|
||||
export interface SupplierQuery {
|
||||
page?: number
|
||||
size?: number
|
||||
category?: SupplierCategory
|
||||
scope?: SupplierScope | ''
|
||||
region?: string
|
||||
status?: SupplierStatus | ''
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export interface SaveSupplierRequest {
|
||||
name: string
|
||||
category: SupplierCategory
|
||||
scope: SupplierScope | null
|
||||
region: string
|
||||
legalRepresentative: string
|
||||
contactName: string
|
||||
phone: string
|
||||
email: string | null
|
||||
address: string | null
|
||||
businessLicenseNo: string | null
|
||||
licenseFileId: string | null
|
||||
status: SupplierStatus
|
||||
settlementPeriod: SettlementPeriod
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export const categoryLabels: Record<SupplierCategory, string> = { BOOKING: '订舱', CUSTOMS: '报关', TRUCKING: '拖车', CERTIFICATE: '证书' }
|
||||
export const scopeLabels: Record<SupplierScope, string> = { DOMESTIC: '国内', INTERNATIONAL: '国外' }
|
||||
export const statusLabels: Record<SupplierStatus, string> = { ACTIVE: '合作中', INACTIVE: '停用' }
|
||||
export const settlementLabels: Record<SettlementPeriod, string> = {
|
||||
MONTHLY_30: '月结30天', MONTHLY_45: '月结45天', MONTHLY_60: '月结60天', MONTHLY_90: '月结90天', PER_TRANSACTION: '票结', IMMEDIATE: '现结',
|
||||
}
|
||||
Reference in New Issue
Block a user