优化
This commit is contained in:
@@ -3,7 +3,9 @@ import { http } from '@/api/http'
|
||||
import {
|
||||
changePlatformExpressCompanyStatus,
|
||||
createPlatformExpressCompany,
|
||||
downloadPlatformExpressCompanyImportTemplate,
|
||||
getPlatformExpressCompany,
|
||||
importPlatformExpressCompanies,
|
||||
listPlatformExpressCompanies,
|
||||
updatePlatformExpressCompany,
|
||||
type SavePlatformExpressCompanyRequest,
|
||||
@@ -71,4 +73,28 @@ describe('platform express company API', () => {
|
||||
{ params: { tenantId: 10001 } },
|
||||
)
|
||||
})
|
||||
|
||||
it('downloads the standard template as a blob', async () => {
|
||||
const template = new Blob(['xlsx'], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
vi.mocked(http.get).mockResolvedValue(template)
|
||||
|
||||
await expect(downloadPlatformExpressCompanyImportTemplate()).resolves.toBe(template)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/express-companies/import-template', { responseType: 'blob' })
|
||||
})
|
||||
|
||||
it('uploads the selected XLSX file as multipart form data', async () => {
|
||||
const result = { fileName: 'express.xlsx', totalRows: 2, importedRows: 2, success: true, errors: [] }
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: result })
|
||||
const file = new File(['xlsx'], 'express.xlsx', {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
})
|
||||
|
||||
await expect(importPlatformExpressCompanies(10001, file)).resolves.toEqual(result)
|
||||
expect(http.post).toHaveBeenCalledOnce()
|
||||
const [path, body, config] = vi.mocked(http.post).mock.calls[0]!
|
||||
expect(path).toBe('/platform/v1/express-companies/import')
|
||||
expect(body).toBeInstanceOf(FormData)
|
||||
expect((body as FormData).get('file')).toBe(file)
|
||||
expect(config).toEqual({ params: { tenantId: 10001 } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,6 +34,20 @@ export interface SavePlatformExpressCompanyRequest {
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface PlatformExpressCompanyImportError {
|
||||
row: number
|
||||
field: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface PlatformExpressCompanyImportResult {
|
||||
fileName: string
|
||||
totalRows: number
|
||||
importedRows: number
|
||||
success: boolean
|
||||
errors: PlatformExpressCompanyImportError[]
|
||||
}
|
||||
|
||||
const basePath = '/platform/v1/express-companies'
|
||||
|
||||
export async function listPlatformExpressCompanies(
|
||||
@@ -98,3 +112,18 @@ export async function changePlatformExpressCompanyStatus(
|
||||
})
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function downloadPlatformExpressCompanyImportTemplate() {
|
||||
return http.get<Blob, Blob>(`${basePath}/import-template`, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export async function importPlatformExpressCompanies(tenantId: number, file: File) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const response = await http.post<ApiEnvelope<PlatformExpressCompanyImportResult>, ApiEnvelope<PlatformExpressCompanyImportResult>>(
|
||||
`${basePath}/import`,
|
||||
formData,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
+115
-1
@@ -2,28 +2,41 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from '@/api/http'
|
||||
import {
|
||||
assignPlatformInternalUserRoles,
|
||||
changePlatformManagedUserStatus,
|
||||
changePlatformInternalUserStatus,
|
||||
changePlatformTenantStatus,
|
||||
changePlatformPermissionStatus,
|
||||
changePlatformRoleStatus,
|
||||
createPlatformPermission,
|
||||
createPlatformManagedUser,
|
||||
createPlatformRole,
|
||||
createPlatformTenant,
|
||||
deletePlatformPermission,
|
||||
deletePlatformRole,
|
||||
downloadTenantQualification,
|
||||
listPlatformTenants,
|
||||
listPlatformTenantOrderCounts,
|
||||
listTenantQualifications,
|
||||
listCurrentPlatformPermissionCodes,
|
||||
listPlatformPermissions,
|
||||
listPlatformRoles,
|
||||
listPlatformInternalUsers,
|
||||
listPlatformManagedUsers,
|
||||
getPlatformUserOptions,
|
||||
qualificationFileName,
|
||||
replacePlatformRolePermissions,
|
||||
resetPlatformInternalUserPassword,
|
||||
resetPlatformManagedUserPassword,
|
||||
submitPlatformTenantReview,
|
||||
updatePlatformInternalUser,
|
||||
updatePlatformManagedUser,
|
||||
updatePlatformPermission,
|
||||
updatePlatformRole,
|
||||
updatePlatformTenant,
|
||||
uploadPlatformTenantQualification,
|
||||
type PlatformPermissionResource,
|
||||
type PlatformInternalUser,
|
||||
type PlatformManagedUser,
|
||||
type PlatformRoleResource,
|
||||
} from './platformIam'
|
||||
|
||||
@@ -73,6 +86,25 @@ const internalUser: PlatformInternalUser = {
|
||||
roleIds: [4000001],
|
||||
}
|
||||
|
||||
const managedUser: PlatformManagedUser = {
|
||||
membershipId: '3000002',
|
||||
userId: '2000002',
|
||||
authUserId: '1000002',
|
||||
username: 'zhangwei',
|
||||
displayName: '张伟',
|
||||
phone: '13800138000',
|
||||
email: 'zhangwei@example.com',
|
||||
tenantId: '10001',
|
||||
tenantName: '华远贸易',
|
||||
memberType: 'EMPLOYEE',
|
||||
memberNo: 'MEMBER-3000002',
|
||||
status: 'ACTIVE',
|
||||
roleIds: ['40001'],
|
||||
roleNames: ['租户管理员'],
|
||||
lastLoginAt: '2026-06-01T10:30:00',
|
||||
protectedAccount: false,
|
||||
}
|
||||
|
||||
describe('platform IAM roles', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(http.get).mockReset()
|
||||
@@ -172,6 +204,39 @@ describe('platform IAM roles', () => {
|
||||
expect(http.put).toHaveBeenNthCalledWith(4, `/platform/v1/iam/internal-users/${internalUser.membershipId}/roles`, { roleIds: [4000001] })
|
||||
})
|
||||
|
||||
it('maintains platform and tenant users through the unified user contract', async () => {
|
||||
const userOptions = {
|
||||
tenants: [{ id: '10001', name: '华远贸易', status: 'ACTIVE' }],
|
||||
roles: [{ id: '40001', tenantId: '10001', name: '租户管理员', status: 'ACTIVE' }],
|
||||
}
|
||||
const create = {
|
||||
username: 'zhangwei', displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com',
|
||||
tenantId: '10001', roleIds: ['40001'], password: 'Password1', status: 'ACTIVE' as const,
|
||||
}
|
||||
const update = {
|
||||
displayName: '张伟', phone: '13800138000', email: 'zhangwei@example.com',
|
||||
tenantId: '10001', roleIds: ['40001'],
|
||||
}
|
||||
vi.mocked(http.get).mockResolvedValueOnce({ code: '200', message: 'success', data: [managedUser] })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: userOptions })
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: managedUser })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: managedUser })
|
||||
|
||||
await expect(listPlatformManagedUsers()).resolves.toEqual([managedUser])
|
||||
await expect(getPlatformUserOptions()).resolves.toEqual(userOptions)
|
||||
await expect(createPlatformManagedUser(create)).resolves.toEqual(managedUser)
|
||||
await expect(updatePlatformManagedUser(managedUser.membershipId, update)).resolves.toEqual(managedUser)
|
||||
await expect(changePlatformManagedUserStatus(managedUser.membershipId, 'DISABLED')).resolves.toEqual(managedUser)
|
||||
await expect(resetPlatformManagedUserPassword(managedUser.membershipId, 'ChangeMe123')).resolves.toEqual(managedUser)
|
||||
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/iam/users')
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/iam/users/options')
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/users', create)
|
||||
expect(http.put).toHaveBeenNthCalledWith(1, `/platform/v1/iam/users/${managedUser.membershipId}`, update)
|
||||
expect(http.put).toHaveBeenNthCalledWith(2, `/platform/v1/iam/users/${managedUser.membershipId}/status`, { status: 'DISABLED' })
|
||||
expect(http.put).toHaveBeenNthCalledWith(3, `/platform/v1/iam/users/${managedUser.membershipId}/password`, { newPassword: 'ChangeMe123' })
|
||||
})
|
||||
|
||||
it('loads the current users permission snapshot without calling the role-management endpoint', async () => {
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: ['platform:warehouse:list'] })
|
||||
|
||||
@@ -197,7 +262,7 @@ describe('platform IAM roles', () => {
|
||||
|
||||
it('loads paginated tenants with the documented filters', async () => {
|
||||
const page = { records: [], page: 2, size: 10, total: 11, pages: 2 }
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: page })
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { ...page, page: '2', size: '10', total: '11', pages: '2' } })
|
||||
|
||||
await expect(listPlatformTenants({ page: 2, size: 10, keyword: '柏悦', status: 'ACTIVE' })).resolves.toEqual(page)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/tenants', {
|
||||
@@ -205,6 +270,55 @@ describe('platform IAM roles', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('creates, updates, and changes customer status with the platform tenant contract', async () => {
|
||||
const tenant = { id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'ACTIVE' }
|
||||
const create = {
|
||||
tenantName: '柏悦物流', legalName: '柏悦物流有限公司', unifiedSocialCreditCode: '91440300MA5ABCDE12',
|
||||
contactName: '张三', contactPhone: '13800138000', contactEmail: 'ops@example.com', servicePackage: '标准版',
|
||||
contactWechat: 'baiyue-ops', salesOwnerName: '李四', paymentTermDays: 30, enterpriseType: '有限责任公司',
|
||||
establishedDate: '2020-01-01', registeredCapital: 1000, registeredCapitalCurrency: 'CNY',
|
||||
registeredAddress: '深圳市宝安区', internalRemark: '重点客户',
|
||||
}
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: tenant })
|
||||
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: tenant })
|
||||
|
||||
await expect(createPlatformTenant(create)).resolves.toEqual(tenant)
|
||||
const { unifiedSocialCreditCode: _creditCode, ...update } = create
|
||||
await expect(updatePlatformTenant('8', update)).resolves.toEqual(tenant)
|
||||
await expect(changePlatformTenantStatus('8', 'DISABLED')).resolves.toEqual(tenant)
|
||||
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/tenants', create)
|
||||
expect(http.put).toHaveBeenNthCalledWith(1, '/platform/v1/iam/tenants/8', update)
|
||||
expect(http.put).toHaveBeenNthCalledWith(2, '/platform/v1/iam/tenants/8/status', { status: 'DISABLED' })
|
||||
})
|
||||
|
||||
it('loads real order totals for the visible customer tenants', async () => {
|
||||
const counts = [{ tenantId: '8', orderCount: 12 }, { tenantId: '9', orderCount: 0 }]
|
||||
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: counts })
|
||||
await expect(listPlatformTenantOrderCounts(['8', '9'])).resolves.toEqual(counts)
|
||||
expect(http.get).toHaveBeenCalledWith('/platform/v1/orders/first-leg/tenant-counts?tenantIds=8,9')
|
||||
})
|
||||
|
||||
it('uploads qualification metadata before explicitly submitting the customer review', async () => {
|
||||
const uploadResult = { qualificationId: '91', reviewId: null, tenantStatus: 'PROFILE_REQUIRED', documentUrl: '/file.pdf' }
|
||||
const tenant = { id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'PENDING' }
|
||||
vi.mocked(http.post).mockResolvedValueOnce({ code: '200', message: 'success', data: uploadResult })
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: tenant })
|
||||
const file = new File(['%PDF-1.4'], '营业执照.pdf', { type: 'application/pdf' })
|
||||
|
||||
await expect(uploadPlatformTenantQualification('8', 'BUSINESS_LICENSE', file, '2030-12-31', '91440300MA5ABCDE12', true)).resolves.toEqual(uploadResult)
|
||||
await expect(submitPlatformTenantReview('8')).resolves.toEqual(tenant)
|
||||
|
||||
const form = vi.mocked(http.post).mock.calls[0]?.[1] as FormData
|
||||
expect(http.post).toHaveBeenNthCalledWith(1, '/platform/v1/files/qualifications/8', expect.any(FormData))
|
||||
expect(form.get('file')).toBe(file)
|
||||
expect(form.get('qualificationType')).toBe('BUSINESS_LICENSE')
|
||||
expect(form.get('validTo')).toBe('2030-12-31')
|
||||
expect(form.get('certificateNo')).toBe('91440300MA5ABCDE12')
|
||||
expect(form.get('replace')).toBe('true')
|
||||
expect(http.post).toHaveBeenNthCalledWith(2, '/platform/v1/iam/tenants/8/submit-review', {})
|
||||
})
|
||||
|
||||
it('downloads a qualification with the platform file endpoint and a safe filename', async () => {
|
||||
const blob = new Blob(['image'], { type: 'image/png' })
|
||||
vi.mocked(http.get).mockResolvedValue(blob)
|
||||
|
||||
@@ -46,6 +46,44 @@ export interface PlatformTenantResource {
|
||||
contactName: string | null
|
||||
contactPhone: string | null
|
||||
contactEmail: string | null
|
||||
servicePackage: string | null
|
||||
contactWechat: string | null
|
||||
salesOwnerName: string | null
|
||||
paymentTermDays: number | null
|
||||
enterpriseType: string | null
|
||||
establishedDate: string | null
|
||||
registeredCapital: number | string | null
|
||||
registeredCapitalCurrency: string | null
|
||||
registeredAddress: string | null
|
||||
internalRemark: string | null
|
||||
createdAt: string | null
|
||||
approvedAt: string | null
|
||||
}
|
||||
|
||||
export interface CreatePlatformTenantRequest {
|
||||
tenantName: string
|
||||
legalName: string
|
||||
unifiedSocialCreditCode: string
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
contactEmail: string
|
||||
servicePackage: string
|
||||
contactWechat: string
|
||||
salesOwnerName: string
|
||||
paymentTermDays: number
|
||||
enterpriseType: string
|
||||
establishedDate: string | null
|
||||
registeredCapital: number | null
|
||||
registeredCapitalCurrency: string
|
||||
registeredAddress: string
|
||||
internalRemark: string
|
||||
}
|
||||
|
||||
export type UpdatePlatformTenantRequest = Omit<CreatePlatformTenantRequest, 'unifiedSocialCreditCode'>
|
||||
|
||||
export interface PlatformTenantOrderCount {
|
||||
tenantId: number | string
|
||||
orderCount: number
|
||||
}
|
||||
|
||||
export interface PlatformTenantPage {
|
||||
@@ -71,6 +109,17 @@ export interface PlatformTenantQualification {
|
||||
documentUrl: string
|
||||
status: 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
reviewComment: string | null
|
||||
validTo?: string | null
|
||||
}
|
||||
|
||||
export type PlatformQualificationType = 'BUSINESS_LICENSE' | 'LEGAL_PERSON_ID' | 'FREIGHT_FORWARDER_CERTIFICATE' | 'BANK_ACCOUNT_LICENSE'
|
||||
|
||||
export interface PlatformQualificationUploadResponse {
|
||||
qualificationId: number | string
|
||||
reviewId: number | string | null
|
||||
tenantStatus: PlatformTenantStatus
|
||||
documentUrl: string
|
||||
replacedDocumentUrl?: string | null
|
||||
}
|
||||
|
||||
export interface PlatformRoleResource {
|
||||
@@ -146,6 +195,49 @@ export interface UpdatePlatformInternalUserRequest {
|
||||
roleIds: Array<number | string>
|
||||
}
|
||||
|
||||
export interface PlatformManagedUser {
|
||||
membershipId: number | string
|
||||
userId: number | string
|
||||
authUserId: number | string
|
||||
username: string
|
||||
displayName: string
|
||||
phone: string | null
|
||||
email: string | null
|
||||
tenantId: number | string
|
||||
tenantName: string
|
||||
memberType: string
|
||||
memberNo: string | null
|
||||
status: 'ACTIVE' | 'DISABLED'
|
||||
roleIds: Array<number | string>
|
||||
roleNames: string[]
|
||||
lastLoginAt: string | null
|
||||
protectedAccount: boolean
|
||||
}
|
||||
|
||||
export interface PlatformUserOptions {
|
||||
tenants: Array<{ id: number | string; name: string; status: string }>
|
||||
roles: Array<{ id: number | string; tenantId: number | string; name: string; status: string }>
|
||||
}
|
||||
|
||||
export interface CreatePlatformManagedUserRequest {
|
||||
username: string
|
||||
displayName: string
|
||||
phone: string
|
||||
email: string
|
||||
tenantId: number | string
|
||||
roleIds: Array<number | string>
|
||||
password: string
|
||||
status: 'ACTIVE' | 'DISABLED'
|
||||
}
|
||||
|
||||
export interface UpdatePlatformManagedUserRequest {
|
||||
displayName: string
|
||||
phone: string
|
||||
email: string
|
||||
tenantId: number | string
|
||||
roleIds: Array<number | string>
|
||||
}
|
||||
|
||||
async function getData<T>(path: string): Promise<T> {
|
||||
const response = await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(path)
|
||||
return unwrapApiResponse(response)
|
||||
@@ -190,9 +282,62 @@ export async function listPlatformTenants(query: PlatformTenantQuery) {
|
||||
},
|
||||
},
|
||||
)
|
||||
const data = unwrapApiResponse(response)
|
||||
return {
|
||||
...data,
|
||||
page: Number(data.page),
|
||||
size: Number(data.size),
|
||||
total: Number(data.total),
|
||||
pages: Number(data.pages),
|
||||
}
|
||||
}
|
||||
|
||||
export function getPlatformTenant(tenantId: number | string) {
|
||||
return getData<PlatformTenantResource>(`/platform/v1/iam/tenants/${tenantId}`)
|
||||
}
|
||||
|
||||
export function createPlatformTenant(payload: CreatePlatformTenantRequest) {
|
||||
return postData<PlatformTenantResource>('/platform/v1/iam/tenants', payload)
|
||||
}
|
||||
|
||||
export function updatePlatformTenant(tenantId: number | string, payload: UpdatePlatformTenantRequest) {
|
||||
return putData<PlatformTenantResource>(`/platform/v1/iam/tenants/${tenantId}`, payload)
|
||||
}
|
||||
|
||||
export function changePlatformTenantStatus(tenantId: number | string, status: 'ACTIVE' | 'DISABLED') {
|
||||
return putData<PlatformTenantResource>(`/platform/v1/iam/tenants/${tenantId}/status`, { status })
|
||||
}
|
||||
|
||||
export function submitPlatformTenantReview(tenantId: number | string) {
|
||||
return postData<PlatformTenantResource>(`/platform/v1/iam/tenants/${tenantId}/submit-review`, {})
|
||||
}
|
||||
|
||||
export async function uploadPlatformTenantQualification(
|
||||
tenantId: number | string,
|
||||
qualificationType: PlatformQualificationType,
|
||||
file: File,
|
||||
validTo?: string,
|
||||
certificateNo?: string,
|
||||
replace = false,
|
||||
) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('qualificationType', qualificationType)
|
||||
if (validTo) form.append('validTo', validTo)
|
||||
if (certificateNo) form.append('certificateNo', certificateNo)
|
||||
if (replace) form.append('replace', 'true')
|
||||
const response = await http.post<ApiEnvelope<PlatformQualificationUploadResponse>, ApiEnvelope<PlatformQualificationUploadResponse>>(
|
||||
`/platform/v1/files/qualifications/${tenantId}`,
|
||||
form,
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export function listPlatformTenantOrderCounts(tenantIds: Array<number | string>) {
|
||||
const ids = tenantIds.map((id) => encodeURIComponent(String(id))).join(',')
|
||||
return getData<PlatformTenantOrderCount[]>(`/platform/v1/orders/first-leg/tenant-counts?tenantIds=${ids}`)
|
||||
}
|
||||
|
||||
export function listTenantQualifications(tenantId: number | string) {
|
||||
return getData<PlatformTenantQualification[]>(`/platform/v1/iam/tenants/${tenantId}/qualifications`)
|
||||
}
|
||||
@@ -284,6 +429,30 @@ export function assignPlatformInternalUserRoles(membershipId: number | string, r
|
||||
return putData<PlatformInternalUser>(`/platform/v1/iam/internal-users/${membershipId}/roles`, { roleIds })
|
||||
}
|
||||
|
||||
export function listPlatformManagedUsers() {
|
||||
return getData<PlatformManagedUser[]>('/platform/v1/iam/users')
|
||||
}
|
||||
|
||||
export function getPlatformUserOptions() {
|
||||
return getData<PlatformUserOptions>('/platform/v1/iam/users/options')
|
||||
}
|
||||
|
||||
export function createPlatformManagedUser(payload: CreatePlatformManagedUserRequest) {
|
||||
return postData<PlatformManagedUser>('/platform/v1/iam/users', payload)
|
||||
}
|
||||
|
||||
export function updatePlatformManagedUser(membershipId: number | string, payload: UpdatePlatformManagedUserRequest) {
|
||||
return putData<PlatformManagedUser>(`/platform/v1/iam/users/${membershipId}`, payload)
|
||||
}
|
||||
|
||||
export function changePlatformManagedUserStatus(membershipId: number | string, status: 'ACTIVE' | 'DISABLED') {
|
||||
return putData<PlatformManagedUser>(`/platform/v1/iam/users/${membershipId}/status`, { status })
|
||||
}
|
||||
|
||||
export function resetPlatformManagedUserPassword(membershipId: number | string, newPassword: string) {
|
||||
return putData<void>(`/platform/v1/iam/users/${membershipId}/password`, { newPassword })
|
||||
}
|
||||
|
||||
export function approveEnterpriseReview(reviewId: number, comment: string) {
|
||||
return postData<unknown>(`/platform/v1/iam/reviews/${reviewId}/approve`, { comment })
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { http } from '@/api/http'
|
||||
import {
|
||||
changePlatformPartnerStatus,
|
||||
createPlatformPartner,
|
||||
downloadPlatformPartnerImportTemplate,
|
||||
getPlatformPartner,
|
||||
importPlatformPartners,
|
||||
listPlatformPartners,
|
||||
updatePlatformPartner,
|
||||
type SavePlatformPartnerRequest,
|
||||
@@ -17,6 +20,7 @@ const payload: SavePlatformPartnerRequest = {
|
||||
partnerCode: 'CUS-001',
|
||||
partnerName: '测试往来单位',
|
||||
partnerType: 'CUSTOMER',
|
||||
types: ['CUSTOMER', 'SHIPPER'],
|
||||
contactName: '测试联系人',
|
||||
contactPhone: '13800000000',
|
||||
contactEmail: null,
|
||||
@@ -27,6 +31,7 @@ const payload: SavePlatformPartnerRequest = {
|
||||
addressLine: '测试地址 1 号',
|
||||
postalCode: null,
|
||||
status: 'ACTIVE',
|
||||
remark: '测试档案',
|
||||
}
|
||||
|
||||
describe('platform partner API', () => {
|
||||
@@ -56,4 +61,31 @@ describe('platform partner API', () => {
|
||||
expect(http.put).toHaveBeenCalledWith('/platform/v1/partners/19001', payload, { params: { tenantId: 10001 } })
|
||||
expect(http.patch).toHaveBeenCalledWith('/platform/v1/partners/19001/status', { status: 'INACTIVE' }, { params: { tenantId: 10001 } })
|
||||
})
|
||||
|
||||
it('normalizes legacy detail types and uses import contracts', async () => {
|
||||
vi.mocked(http.get)
|
||||
.mockResolvedValueOnce({ code: '200', message: 'success', data: {
|
||||
id: '19001', tenantId: 10001, partnerCode: 'CUS-001', partnerName: '测试单位',
|
||||
partnerType: 'BOTH', contactName: '张三', contactPhone: '13800000000', contactEmail: null,
|
||||
countryCode: 'CN', province: null, city: '深圳市', district: null, addressLine: '测试地址',
|
||||
postalCode: null, status: 'ACTIVE',
|
||||
} })
|
||||
.mockResolvedValueOnce(new Blob(['template']))
|
||||
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: {
|
||||
fileName: 'partners.xlsx', totalRows: 1, importedRows: 1, success: true, errors: [],
|
||||
} })
|
||||
|
||||
const detail = await getPlatformPartner(10001, '19001')
|
||||
const template = await downloadPlatformPartnerImportTemplate()
|
||||
const file = new File(['xlsx'], 'partners.xlsx')
|
||||
const result = await importPlatformPartners(10001, file)
|
||||
|
||||
expect(detail.types).toEqual(['CUSTOMER', 'SUPPLIER'])
|
||||
expect(detail.dataScopeId).toBe(10001)
|
||||
expect(template).toBeInstanceOf(Blob)
|
||||
expect(result.success).toBe(true)
|
||||
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/partners/19001', { params: { tenantId: 10001 } })
|
||||
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/partners/import-template', { responseType: 'blob' })
|
||||
expect(http.post).toHaveBeenCalledWith('/platform/v1/partners/import', expect.any(FormData), { params: { tenantId: 10001 } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { http, unwrapApiResponse, type ApiEnvelope } from '@/api/http'
|
||||
|
||||
export type PlatformPartnerType = 'CUSTOMER' | 'SUPPLIER' | 'BOTH'
|
||||
export type PlatformPartnerType = 'CUSTOMER' | 'SUPPLIER' | 'CARRIER' | 'CUSTOMS_BROKER' | 'SHIPPER' | 'CONSIGNEE'
|
||||
export type PlatformPartnerLegacyType = PlatformPartnerType | 'BOTH'
|
||||
export type PlatformPartnerStatus = 'ACTIVE' | 'INACTIVE'
|
||||
|
||||
export interface PlatformPartnerResource {
|
||||
id: number | string
|
||||
tenantId: number | string
|
||||
dataScopeId: number | string
|
||||
partnerCode: string
|
||||
partnerName: string
|
||||
partnerType: PlatformPartnerType
|
||||
partnerType: PlatformPartnerLegacyType
|
||||
types: PlatformPartnerType[]
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
contactEmail: string | null
|
||||
@@ -19,6 +22,7 @@ export interface PlatformPartnerResource {
|
||||
addressLine: string
|
||||
postalCode: string | null
|
||||
status: PlatformPartnerStatus
|
||||
remark: string | null
|
||||
createdAt?: string | null
|
||||
updatedAt?: string | null
|
||||
}
|
||||
@@ -50,6 +54,7 @@ export interface SavePlatformPartnerRequest {
|
||||
partnerCode: string
|
||||
partnerName: string
|
||||
partnerType: PlatformPartnerType
|
||||
types: PlatformPartnerType[]
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
contactEmail: string | null
|
||||
@@ -60,10 +65,37 @@ export interface SavePlatformPartnerRequest {
|
||||
addressLine: string
|
||||
postalCode: string | null
|
||||
status: PlatformPartnerStatus
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface PlatformPartnerImportError {
|
||||
row: number
|
||||
field: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface PlatformPartnerImportResult {
|
||||
fileName: string
|
||||
totalRows: number
|
||||
importedRows: number
|
||||
success: boolean
|
||||
errors: PlatformPartnerImportError[]
|
||||
}
|
||||
|
||||
const basePath = '/platform/v1/partners'
|
||||
|
||||
function normalizePartner(resource: PlatformPartnerResource): PlatformPartnerResource {
|
||||
const legacyTypes: PlatformPartnerType[] = resource.partnerType === 'BOTH'
|
||||
? ['CUSTOMER', 'SUPPLIER']
|
||||
: [resource.partnerType]
|
||||
return {
|
||||
...resource,
|
||||
dataScopeId: resource.dataScopeId ?? resource.tenantId,
|
||||
types: resource.types?.length ? resource.types : legacyTypes,
|
||||
remark: resource.remark ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function listPlatformPartners(tenantId: number, query: PlatformPartnerQuery) {
|
||||
const response = await http.get<ApiEnvelope<PlatformPartnerPagePayload>, ApiEnvelope<PlatformPartnerPagePayload>>(basePath, {
|
||||
params: { tenantId, ...query },
|
||||
@@ -71,6 +103,7 @@ export async function listPlatformPartners(tenantId: number, query: PlatformPart
|
||||
const page = unwrapApiResponse(response)
|
||||
return {
|
||||
...page,
|
||||
records: page.records.map(normalizePartner),
|
||||
page: Number(page.page),
|
||||
size: Number(page.size),
|
||||
total: Number(page.total),
|
||||
@@ -78,6 +111,29 @@ export async function listPlatformPartners(tenantId: number, query: PlatformPart
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPlatformPartner(tenantId: number, partnerId: number | string) {
|
||||
const response = await http.get<ApiEnvelope<PlatformPartnerResource>, ApiEnvelope<PlatformPartnerResource>>(
|
||||
`${basePath}/${partnerId}`,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return normalizePartner(unwrapApiResponse(response))
|
||||
}
|
||||
|
||||
export async function downloadPlatformPartnerImportTemplate() {
|
||||
return http.get<Blob, Blob>(`${basePath}/import-template`, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
export async function importPlatformPartners(tenantId: number, file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const response = await http.post<ApiEnvelope<PlatformPartnerImportResult>, ApiEnvelope<PlatformPartnerImportResult>>(
|
||||
`${basePath}/import`,
|
||||
form,
|
||||
{ params: { tenantId } },
|
||||
)
|
||||
return unwrapApiResponse(response)
|
||||
}
|
||||
|
||||
export async function createPlatformPartner(tenantId: number, payload: SavePlatformPartnerRequest) {
|
||||
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload, {
|
||||
params: { tenantId },
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ const router = createRouter({
|
||||
path: 'system/users',
|
||||
name: 'users',
|
||||
component: () => import('@/views/InternalUsersView.vue'),
|
||||
meta: { title: '内部用户', description: '管理平台内部员工账号、状态和角色', permission: 'platform:user:list' },
|
||||
meta: { title: '用户管理', description: '统一管理平台和企业租户用户、状态及角色', permission: 'platform:user:list' },
|
||||
},
|
||||
{
|
||||
path: 'system/roles',
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getPlatformTenant,
|
||||
listPlatformTenants,
|
||||
listTenantQualifications,
|
||||
type PlatformTenantResource,
|
||||
} from '@/api/platformIam'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import CustomersView from './CustomersView.vue'
|
||||
|
||||
vi.mock('@/api/platformIam', () => ({
|
||||
changePlatformTenantStatus: vi.fn(),
|
||||
createPlatformTenant: vi.fn(),
|
||||
getPlatformTenant: vi.fn(),
|
||||
listPlatformTenantOrderCounts: vi.fn(),
|
||||
listPlatformTenants: vi.fn(),
|
||||
listTenantQualifications: vi.fn(),
|
||||
submitPlatformTenantReview: vi.fn(),
|
||||
updatePlatformTenant: vi.fn(),
|
||||
uploadPlatformTenantQualification: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const fullTenant: PlatformTenantResource = {
|
||||
id: '8', tenantCode: 'TEN8', tenantName: '柏悦物流', status: 'PROFILE_REQUIRED',
|
||||
legalName: '柏悦物流有限公司', unifiedSocialCreditCode: '91440300MA5ABCDE12',
|
||||
contactName: '张三', contactPhone: '13800138000', contactEmail: 'ops@example.com',
|
||||
servicePackage: '基础版', contactWechat: 'baiyue-ops', salesOwnerName: '李四', paymentTermDays: 30,
|
||||
enterpriseType: '有限责任公司', establishedDate: '2020-01-01', registeredCapital: 1000,
|
||||
registeredCapitalCurrency: 'CNY', registeredAddress: '深圳市宝安区', internalRemark: '重点客户',
|
||||
createdAt: '2026-08-01T10:00:00', approvedAt: null,
|
||||
}
|
||||
|
||||
function tableButtonByText(text: string) {
|
||||
return [...document.querySelectorAll<HTMLButtonElement>('.el-table__body button')]
|
||||
.find((button) => button.textContent?.trim() === text)
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('CustomersView', () => {
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = ['platform:customer:list', 'platform:customer:view', 'platform:customer:edit']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
vi.mocked(listPlatformTenants).mockResolvedValue({
|
||||
records: [{ ...fullTenant, contactPhone: '138****8000', contactEmail: 'o***@example.com' }],
|
||||
page: 1, size: 10, total: 1, pages: 1,
|
||||
})
|
||||
vi.mocked(getPlatformTenant).mockResolvedValue(fullTenant)
|
||||
vi.mocked(listTenantQualifications).mockResolvedValue([])
|
||||
})
|
||||
|
||||
it('loads the complete customer detail before editing masked list fields', async () => {
|
||||
mount(CustomersView, { attachTo: document.body, global: { plugins: [pinia, ElementPlus] } })
|
||||
await flushPromises()
|
||||
|
||||
tableButtonByText('编辑')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(getPlatformTenant).toHaveBeenCalledWith(fullTenant.id)
|
||||
const phone = document.querySelector<HTMLInputElement>('input[placeholder="请输入手机号或固定电话"]')
|
||||
const email = document.querySelector<HTMLInputElement>('input[placeholder="如:contact@example.com"]')
|
||||
expect(phone?.value).toBe(fullTenant.contactPhone)
|
||||
expect(email?.value).toBe(fullTenant.contactEmail)
|
||||
})
|
||||
})
|
||||
+335
-109
@@ -1,165 +1,391 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { UsersRound } from '@lucide/vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { Building2, CheckCircle2, Download, Eye, FileUp, Pencil, Plus, Power } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http'
|
||||
import {
|
||||
changePlatformTenantStatus,
|
||||
createPlatformTenant,
|
||||
getPlatformTenant,
|
||||
listPlatformTenantOrderCounts,
|
||||
listPlatformTenants,
|
||||
listTenantQualifications,
|
||||
submitPlatformTenantReview,
|
||||
updatePlatformTenant,
|
||||
uploadPlatformTenantQualification,
|
||||
type CreatePlatformTenantRequest,
|
||||
type PlatformQualificationType,
|
||||
type PlatformTenantQualification,
|
||||
type PlatformTenantResource,
|
||||
type PlatformTenantStatus,
|
||||
} from '@/api/platformIam'
|
||||
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
|
||||
type CustomerFilters = QueryModel & { keyword: string; status: PlatformTenantStatus | '' }
|
||||
type QualificationDraft = {
|
||||
type: PlatformQualificationType
|
||||
label: string
|
||||
required: boolean
|
||||
file: File | null
|
||||
validTo: string
|
||||
uploaded: boolean
|
||||
uploadedName: string
|
||||
uploading: boolean
|
||||
error: string
|
||||
}
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const loading = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const result = ref({ records: [] as PlatformTenantResource[], total: 0, pages: 0 })
|
||||
type CustomerFilters = QueryModel & {
|
||||
keyword: string
|
||||
status: PlatformTenantStatus | ''
|
||||
}
|
||||
const orderCounts = ref<Record<string, number>>({})
|
||||
const rowActionId = ref('')
|
||||
const editorOpen = ref(false)
|
||||
const editorLoading = ref(false)
|
||||
const saving = ref(false)
|
||||
const submitting = ref(false)
|
||||
const savedFormSnapshot = ref('')
|
||||
const editingId = ref<string | null>(null)
|
||||
const editorStatus = ref<PlatformTenantStatus | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<PlatformTenantResource | null>(null)
|
||||
const detailQualifications = ref<PlatformTenantQualification[]>([])
|
||||
const qualificationLoadError = ref('')
|
||||
|
||||
const filters = ref<CustomerFilters>({ keyword: '', status: '' })
|
||||
const queryFields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', placeholder: '企业名称、企业编号、联系人或信用代码' },
|
||||
{
|
||||
key: 'status',
|
||||
type: 'select',
|
||||
placeholder: '全部状态',
|
||||
options: [
|
||||
{ label: '启用', value: 'ACTIVE' },
|
||||
{ label: '待审核', value: 'PENDING' },
|
||||
{ label: '已驳回', value: 'REJECTED' },
|
||||
{ label: '已停用', value: 'INACTIVE' },
|
||||
],
|
||||
},
|
||||
{ key: 'keyword', type: 'input', placeholder: '公司名称、客户ID或联系人' },
|
||||
{ key: 'status', type: 'select', placeholder: '全部状态', options: [
|
||||
{ label: '待完善', value: 'PROFILE_REQUIRED' }, { label: '待审核', value: 'PENDING' },
|
||||
{ label: '启用', value: 'ACTIVE' }, { label: '已驳回', value: 'REJECTED' },
|
||||
{ label: '已停用', value: 'DISABLED' },
|
||||
] },
|
||||
]
|
||||
let loadSequence = 0
|
||||
|
||||
function displayText(value?: string | null) {
|
||||
return value?.trim() || '—'
|
||||
const emptyForm = (): CreatePlatformTenantRequest => ({
|
||||
tenantName: '', legalName: '', unifiedSocialCreditCode: '', contactName: '', contactPhone: '',
|
||||
contactEmail: '', contactWechat: '', servicePackage: '基础版', salesOwnerName: '', paymentTermDays: 0,
|
||||
enterpriseType: '', establishedDate: null, registeredCapital: null, registeredCapitalCurrency: 'CNY',
|
||||
registeredAddress: '', internalRemark: '',
|
||||
})
|
||||
const form = ref(emptyForm())
|
||||
const rules: FormRules = {
|
||||
tenantName: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
|
||||
legalName: [{ required: true, message: '请输入营业执照上的企业名称', trigger: 'blur' }],
|
||||
unifiedSocialCreditCode: [
|
||||
{ required: true, message: '请输入统一社会信用代码', trigger: 'blur' },
|
||||
{ pattern: /^[0-9A-Z]{18}$/, message: '请输入 18 位大写字母或数字', trigger: 'blur' },
|
||||
],
|
||||
contactName: [{ required: true, message: '请输入联系人姓名', trigger: 'blur' }],
|
||||
contactPhone: [{ required: true, message: '请输入联系电话', trigger: 'blur' }],
|
||||
contactEmail: [{ type: 'email', message: '请输入有效邮箱地址', trigger: 'blur' }],
|
||||
servicePackage: [{ required: true, message: '请选择客户套餐', trigger: 'change' }],
|
||||
paymentTermDays: [{ required: true, message: '请输入账期天数', trigger: 'change' }],
|
||||
}
|
||||
|
||||
function newQualificationDrafts(): QualificationDraft[] {
|
||||
const definitions: Array<Pick<QualificationDraft, 'type' | 'label' | 'required'>> = [
|
||||
{ type: 'BUSINESS_LICENSE', label: '营业执照', required: true },
|
||||
{ type: 'LEGAL_PERSON_ID', label: '法人身份证', required: true },
|
||||
{ type: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', required: false },
|
||||
{ type: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', required: false },
|
||||
]
|
||||
return definitions.map((item) => ({ ...item, file: null, validTo: '', uploaded: false, uploadedName: '', uploading: false, error: '' }))
|
||||
}
|
||||
const qualifications = ref<QualificationDraft[]>(newQualificationDrafts())
|
||||
|
||||
const customerColumns: DataTableColumn[] = [
|
||||
{ key: 'company', label: '企业', minWidth: 230, slotName: 'company' },
|
||||
{
|
||||
prop: 'unifiedSocialCreditCode',
|
||||
label: '统一社会信用代码',
|
||||
minWidth: 205,
|
||||
formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode),
|
||||
},
|
||||
{
|
||||
prop: 'legalName',
|
||||
label: '法定代表人',
|
||||
minWidth: 150,
|
||||
formatter: (row: PlatformTenantResource) => displayText(row.legalName),
|
||||
},
|
||||
{ key: 'contact', label: '联系人', minWidth: 180, slotName: 'contact' },
|
||||
{
|
||||
prop: 'contactEmail',
|
||||
label: '联系邮箱',
|
||||
minWidth: 220,
|
||||
formatter: (row: PlatformTenantResource) => displayText(row.contactEmail),
|
||||
},
|
||||
{ key: 'actions', label: '操作', width: 245, fixed: 'left', slotName: 'actions' },
|
||||
{ prop: 'tenantCode', label: '客户ID', minWidth: 170 },
|
||||
{ key: 'company', label: '公司名称', minWidth: 230, slotName: 'company' },
|
||||
{ prop: 'unifiedSocialCreditCode', label: '统一社会信用代码', minWidth: 205, formatter: (row: PlatformTenantResource) => displayText(row.unifiedSocialCreditCode) },
|
||||
{ prop: 'contactName', label: '联系人', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.contactName) },
|
||||
{ prop: 'contactPhone', label: '电话', minWidth: 150, formatter: (row: PlatformTenantResource) => displayText(row.contactPhone) },
|
||||
{ prop: 'salesOwnerName', label: '销售人员', minWidth: 120, formatter: (row: PlatformTenantResource) => displayText(row.salesOwnerName) },
|
||||
{ prop: 'paymentTermDays', label: '账期(天)', width: 105, formatter: (row: PlatformTenantResource) => row.paymentTermDays ?? 0 },
|
||||
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
|
||||
{ key: 'orderCount', label: '运单数', width: 100, slotName: 'orderCount' },
|
||||
{ prop: 'createdAt', label: '入驻时间', minWidth: 165, formatter: (row: PlatformTenantResource) => formatDate(row.createdAt) },
|
||||
]
|
||||
|
||||
let loadSequence = 0
|
||||
const editorTitle = computed(() => editingId.value ? '编辑客户' : '新增客户')
|
||||
const canUploadQualifications = computed(() => !editorStatus.value || ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value))
|
||||
const canSubmitReview = computed(() => Boolean(editingId.value) && ['PROFILE_REQUIRED', 'REJECTED'].includes(editorStatus.value || ''))
|
||||
const uploadingQualifications = computed(() => qualifications.value.some((item) => item.uploading))
|
||||
const hasSelectedQualifications = computed(() => qualifications.value.some((item) => item.file))
|
||||
const hasUnsavedChanges = computed(() => Boolean(editingId.value) && JSON.stringify(form.value) !== savedFormSnapshot.value)
|
||||
function displayText(value?: string | null) { return value?.trim() || '—' }
|
||||
function formatDate(value?: string | null) { return value ? value.replace('T', ' ').slice(0, 16) : '—' }
|
||||
function statusMeta(status: PlatformTenantStatus) {
|
||||
if (status === 'ACTIVE') return { label: '启用', tone: 'is-success' }
|
||||
if (status === 'PENDING') return { label: '待审核', tone: 'is-warning' }
|
||||
if (status === 'REJECTED') return { label: '已驳回', tone: 'is-danger' }
|
||||
if (status === 'INACTIVE') return { label: '已停用', tone: 'is-info' }
|
||||
return { label: displayText(status), tone: 'is-info' }
|
||||
if (status === 'ACTIVE') return { label: '启用', type: 'success' as const }
|
||||
if (status === 'PROFILE_REQUIRED') return { label: '待完善', type: 'info' as const }
|
||||
if (status === 'PENDING') return { label: '待审核', type: 'warning' as const }
|
||||
if (status === 'REJECTED') return { label: '已驳回', type: 'danger' as const }
|
||||
return { label: '已停用', type: 'info' as const }
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
const sequence = ++loadSequence
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
loading.value = true; loadError.value = null
|
||||
try {
|
||||
const pageResult = await listPlatformTenants({
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
keyword: filters.value.keyword.trim() || undefined,
|
||||
status: filters.value.status || undefined,
|
||||
})
|
||||
const pageResult = await listPlatformTenants({ page: page.value, size: pageSize.value, keyword: filters.value.keyword.trim() || undefined, status: filters.value.status || undefined })
|
||||
if (sequence !== loadSequence) return
|
||||
result.value = {
|
||||
records: pageResult.records,
|
||||
total: pageResult.total,
|
||||
pages: pageResult.pages,
|
||||
result.value = pageResult; orderCounts.value = {}
|
||||
if (access.can('platform:order:list') && pageResult.records.length) {
|
||||
try {
|
||||
const counts = await listPlatformTenantOrderCounts(pageResult.records.map((item) => item.id))
|
||||
if (sequence === loadSequence) orderCounts.value = Object.fromEntries(counts.map((item) => [String(item.tenantId), Number(item.orderCount)]))
|
||||
} catch { /* 主档可继续使用,运单服务失败时不显示模拟数量。 */ }
|
||||
}
|
||||
} catch (error) {
|
||||
if (sequence !== loadSequence) return
|
||||
loadError.value = getApiErrorDetails(error, '客户列表加载失败,请稍后重试')
|
||||
if (sequence === loadSequence) loadError.value = getApiErrorDetails(error, '客户列表加载失败,请稍后重试')
|
||||
} finally { if (sequence === loadSequence) loading.value = false }
|
||||
}
|
||||
|
||||
function applyFilters() { if (page.value === 1) void loadCustomers(); else page.value = 1 }
|
||||
function resetEditor() {
|
||||
form.value = emptyForm(); qualifications.value = newQualificationDrafts(); qualificationLoadError.value = ''; savedFormSnapshot.value = ''
|
||||
}
|
||||
function openCreate() {
|
||||
editingId.value = null; editorStatus.value = null; resetEditor(); editorOpen.value = true
|
||||
nextTick(() => formRef.value?.clearValidate())
|
||||
}
|
||||
function mapTenantToForm(row: PlatformTenantResource): CreatePlatformTenantRequest {
|
||||
return {
|
||||
tenantName: row.tenantName, legalName: row.legalName || '', unifiedSocialCreditCode: row.unifiedSocialCreditCode || '',
|
||||
contactName: row.contactName || '', contactPhone: row.contactPhone || '', contactEmail: row.contactEmail || '',
|
||||
contactWechat: row.contactWechat || '', servicePackage: row.servicePackage || '基础版', salesOwnerName: row.salesOwnerName || '',
|
||||
paymentTermDays: Number(row.paymentTermDays ?? 0), enterpriseType: row.enterpriseType || '', establishedDate: row.establishedDate || null,
|
||||
registeredCapital: row.registeredCapital === null ? null : Number(row.registeredCapital),
|
||||
registeredCapitalCurrency: row.registeredCapitalCurrency || 'CNY', registeredAddress: row.registeredAddress || '',
|
||||
internalRemark: row.internalRemark || '',
|
||||
}
|
||||
}
|
||||
async function loadEditorQualifications(tenantId: number | string) {
|
||||
qualificationLoadError.value = ''
|
||||
try {
|
||||
const records = await listTenantQualifications(tenantId)
|
||||
for (const draft of qualifications.value) {
|
||||
const item = [...records].reverse().find((record) => record.qualificationType === draft.type && record.status !== 'REJECTED')
|
||||
if (item) { draft.uploaded = true; draft.validTo = item.validTo || ''; draft.uploadedName = '已登记文件' }
|
||||
}
|
||||
} catch (error) { qualificationLoadError.value = getApiErrorMessage(error, '资质信息加载失败,可稍后重试') }
|
||||
}
|
||||
async function openEdit(row: PlatformTenantResource) {
|
||||
editingId.value = String(row.id); editorStatus.value = row.status; resetEditor(); form.value = mapTenantToForm(row)
|
||||
editorOpen.value = true; editorLoading.value = true
|
||||
try {
|
||||
const tenant = await getPlatformTenant(row.id)
|
||||
if (editingId.value !== String(row.id)) return
|
||||
editorStatus.value = tenant.status
|
||||
form.value = mapTenantToForm(tenant)
|
||||
savedFormSnapshot.value = JSON.stringify(form.value)
|
||||
await loadEditorQualifications(row.id)
|
||||
await nextTick(() => formRef.value?.clearValidate())
|
||||
} catch (error) {
|
||||
if (editingId.value === String(row.id)) editorOpen.value = false
|
||||
ElMessage.error(getApiErrorMessage(error, '客户完整资料加载失败,请稍后重试'))
|
||||
} finally {
|
||||
if (sequence === loadSequence) loading.value = false
|
||||
if (editingId.value === String(row.id)) editorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
if (page.value === 1) {
|
||||
void loadCustomers()
|
||||
return
|
||||
function selectQualificationFile(event: Event, draft: QualificationDraft) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] || null
|
||||
draft.error = ''; draft.file = null
|
||||
if (!file) return
|
||||
const extensionValid = /\.(pdf|jpe?g|png)$/i.test(file.name)
|
||||
const mimeValid = ['application/pdf', 'image/jpeg', 'image/png'].includes(file.type)
|
||||
if (!extensionValid || !mimeValid) { draft.error = '仅支持 PDF、JPG、PNG 文件'; input.value = ''; return }
|
||||
if (file.size > 10 * 1024 * 1024) { draft.error = '单个文件不能超过 10MB'; input.value = ''; return }
|
||||
draft.file = file
|
||||
}
|
||||
|
||||
async function uploadSelectedQualifications(tenantId: string) {
|
||||
const failures: string[] = []
|
||||
for (const draft of qualifications.value.filter((item) => item.file)) {
|
||||
draft.uploading = true; draft.error = ''
|
||||
try {
|
||||
await uploadPlatformTenantQualification(
|
||||
tenantId, draft.type, draft.file as File, draft.validTo || undefined,
|
||||
draft.type === 'BUSINESS_LICENSE' ? form.value.unifiedSocialCreditCode : undefined,
|
||||
draft.uploaded,
|
||||
)
|
||||
draft.uploaded = true; draft.uploadedName = draft.file?.name || '已登记文件'; draft.file = null
|
||||
} catch (error) {
|
||||
draft.error = getApiErrorMessage(error, '上传失败,请重试'); failures.push(draft.label)
|
||||
} finally { draft.uploading = false }
|
||||
}
|
||||
page.value = 1
|
||||
return failures
|
||||
}
|
||||
|
||||
async function uploadQualifications() {
|
||||
if (!editingId.value) { ElMessage.warning('请先保存客户资料,再上传资质文件'); return }
|
||||
if (!hasSelectedQualifications.value) { ElMessage.warning('请先选择需要上传的资质文件'); return }
|
||||
const failures = await uploadSelectedQualifications(editingId.value)
|
||||
if (failures.length) ElMessage.warning(`${failures.join('、')}上传失败,请根据提示重试`)
|
||||
else ElMessage.success('已选资质上传成功,尚未提交审核')
|
||||
}
|
||||
|
||||
async function saveCustomer() {
|
||||
if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return
|
||||
saving.value = true
|
||||
try {
|
||||
let saved: PlatformTenantResource
|
||||
if (editingId.value) {
|
||||
const { unifiedSocialCreditCode: _immutable, ...payload } = form.value
|
||||
saved = await updatePlatformTenant(editingId.value, payload)
|
||||
} else {
|
||||
saved = await createPlatformTenant(form.value)
|
||||
editingId.value = String(saved.id)
|
||||
}
|
||||
editorStatus.value = saved.status
|
||||
savedFormSnapshot.value = JSON.stringify(form.value)
|
||||
ElMessage.success('客户资料已保存为草稿,尚未提交审核')
|
||||
await loadCustomers()
|
||||
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户资料保存失败,请稍后重试')) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function submitReview() {
|
||||
if (!editingId.value) { ElMessage.warning('请先保存客户资料和资质文件'); return }
|
||||
if (!formRef.value || !(await formRef.value.validate().catch(() => false))) return
|
||||
if (hasUnsavedChanges.value) { ElMessage.warning('客户资料有未保存的修改,请先保存资料'); return }
|
||||
if (uploadingQualifications.value) { ElMessage.warning('资质文件正在上传,请稍候再提交审核'); return }
|
||||
if (hasSelectedQualifications.value) { ElMessage.warning('存在尚未上传的已选文件,请先点击“上传已选资质”'); return }
|
||||
const missing = qualifications.value.filter((item) => item.required && !item.uploaded)
|
||||
for (const item of missing) item.error = `${item.label}为提交审核必填文件`
|
||||
if (missing.length) { ElMessage.warning('请先上传营业执照和法人身份证'); return }
|
||||
try {
|
||||
await ElMessageBox.confirm('提交后客户资料与资质将进入企业审核,审核完成前不能继续替换文件。确认提交吗?', '提交企业审核', { type: 'warning', confirmButtonText: '确认提交', cancelButtonText: '继续完善' })
|
||||
submitting.value = true
|
||||
const saved = await submitPlatformTenantReview(editingId.value)
|
||||
editorStatus.value = saved.status; ElMessage.success('审核单已提交')
|
||||
editorOpen.value = false; await loadCustomers()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorMessage(error, '提交审核失败,请稍后重试'))
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
|
||||
async function showDetail(row: PlatformTenantResource) {
|
||||
detail.value = row; detailQualifications.value = []; detailOpen.value = true; detailLoading.value = true
|
||||
try {
|
||||
const [tenant, records] = await Promise.all([getPlatformTenant(row.id), listTenantQualifications(row.id)])
|
||||
detail.value = tenant; detailQualifications.value = records
|
||||
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户详情加载失败')) }
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
async function toggleStatus(row: PlatformTenantResource) {
|
||||
const next = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'; const verb = next === 'ACTIVE' ? '启用' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${verb}客户“${row.tenantName}”吗?`, `${verb}客户`, { type: 'warning', confirmButtonText: `确认${verb}`, cancelButtonText: '取消' })
|
||||
rowActionId.value = String(row.id); await changePlatformTenantStatus(row.id, next)
|
||||
ElMessage.success(`客户已${verb}`); await loadCustomers()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel' && error !== 'close') ElMessage.error(getApiErrorMessage(error, `客户${verb}失败`))
|
||||
} finally { rowActionId.value = '' }
|
||||
}
|
||||
|
||||
function xmlEscape(value: unknown) { return String(value ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') }
|
||||
async function exportCustomers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const first = await listPlatformTenants({ page: 1, size: 200, keyword: filters.value.keyword.trim() || undefined, status: filters.value.status || undefined })
|
||||
const rows = [...first.records]
|
||||
for (let current = 2; current <= first.pages; current += 1) rows.push(...(await listPlatformTenants({ page: current, size: 200, keyword: filters.value.keyword.trim() || undefined, status: filters.value.status || undefined })).records)
|
||||
const body = rows.map((row) => `<tr>${[row.tenantCode, row.tenantName, row.unifiedSocialCreditCode, row.contactName, row.contactPhone, row.salesOwnerName, row.paymentTermDays, statusMeta(row.status).label, formatDate(row.createdAt)].map((cell) => `<td>${xmlEscape(cell)}</td>`).join('')}</tr>`).join('')
|
||||
const html = `<html><head><meta charset="utf-8"></head><body><table><tr>${['客户ID','公司名称','统一社会信用代码','联系人','电话','销售人员','账期(天)','状态','入驻时间'].map((cell) => `<th>${cell}</th>`).join('')}</tr>${body}</table></body></html>`
|
||||
const url = URL.createObjectURL(new Blob(['\ufeff', html], { type: 'application/vnd.ms-excel;charset=utf-8' }))
|
||||
const anchor = document.createElement('a'); anchor.href = url; anchor.download = `客户列表-${new Date().toISOString().slice(0, 10)}.xls`; anchor.click(); URL.revokeObjectURL(url)
|
||||
ElMessage.success(`已导出 ${rows.length} 条客户数据`)
|
||||
} catch (error) { ElMessage.error(getApiErrorMessage(error, '客户数据导出失败')) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
watch([page, pageSize], () => { void loadCustomers() })
|
||||
|
||||
onMounted(() => {
|
||||
void loadCustomers()
|
||||
})
|
||||
onMounted(loadCustomers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page customers-page">
|
||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="客户筛选" :fields="queryFields" @search="applyFilters" />
|
||||
|
||||
<section v-if="loadError" class="surface-panel customers-error" role="alert">
|
||||
<div>
|
||||
<strong>客户列表暂时无法加载</strong>
|
||||
<p>{{ loadError.message }}</p>
|
||||
<ApiErrorMeta :details="loadError" />
|
||||
</div>
|
||||
<el-button type="primary" @click="loadCustomers">重新加载</el-button>
|
||||
</section>
|
||||
|
||||
<section v-if="loadError" class="surface-panel customers-error" role="alert"><div><strong>客户列表暂时无法加载</strong><p>{{ loadError.message }}</p><ApiErrorMeta :details="loadError" /></div><el-button type="primary" :loading="loading" @click="loadCustomers">重新加载</el-button></section>
|
||||
<section v-else class="surface-panel table-panel" :aria-busy="loading">
|
||||
<DataTable
|
||||
v-model:page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:data="result.records"
|
||||
:columns="customerColumns"
|
||||
:loading="loading"
|
||||
:total="result.total"
|
||||
aria-label="客户企业列表"
|
||||
row-key="id"
|
||||
empty-text="暂无符合筛选条件的客户企业"
|
||||
>
|
||||
<template #company="{ row }">
|
||||
<div class="customer-company-cell">
|
||||
<span><UsersRound :size="18" /></span>
|
||||
<div class="primary-cell"><strong>{{ displayText((row as PlatformTenantResource).tenantName) }}</strong><span>{{ displayText((row as PlatformTenantResource).tenantCode) }}</span></div>
|
||||
</div>
|
||||
</template>
|
||||
<template #contact="{ row }"><div class="primary-cell"><strong>{{ displayText((row as PlatformTenantResource).contactName) }}</strong><span>{{ displayText((row as PlatformTenantResource).contactPhone) }}</span></div></template>
|
||||
<template #status="{ row }"><span class="status-badge" :class="statusMeta((row as PlatformTenantResource).status).tone"><i />{{ statusMeta((row as PlatformTenantResource).status).label }}</span></template>
|
||||
<div class="customers-table-header"><div class="customers-table-heading"><h2>客户列表</h2><span>共 {{ result.total }} 个客户</span></div><div class="customers-table-actions"><el-button :disabled="loading" @click="exportCustomers"><Download :size="16" />导出Excel</el-button><el-button v-if="access.can('platform:customer:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增客户</el-button></div></div>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="result.records" :columns="customerColumns" :loading="loading" :total="result.total" aria-label="客户列表" row-key="id" empty-text="暂无符合筛选条件的客户">
|
||||
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:customer:view')" link type="primary" @click="showDetail(row as PlatformTenantResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:customer:view') && access.can('platform:customer:edit')" link type="primary" @click="openEdit(row as PlatformTenantResource)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can('platform:customer:status') && ['ACTIVE','DISABLED'].includes((row as PlatformTenantResource).status)" link :type="(row as PlatformTenantResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="rowActionId === String((row as PlatformTenantResource).id)" @click="toggleStatus(row as PlatformTenantResource)"><Power :size="15" />{{ (row as PlatformTenantResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button></div></template>
|
||||
<template #company="{ row }"><div class="customer-company-cell"><span><Building2 :size="18" /></span><strong>{{ (row as PlatformTenantResource).tenantName }}</strong></div></template>
|
||||
<template #status="{ row }"><el-tag :type="statusMeta((row as PlatformTenantResource).status).type" effect="light">{{ statusMeta((row as PlatformTenantResource).status).label }}</el-tag></template>
|
||||
<template #orderCount="{ row }">{{ access.can('platform:order:list') ? (orderCounts[String((row as PlatformTenantResource).id)] ?? '—') : '—' }}</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="editorTitle" size="760px" class="business-drawer customer-editor" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" v-loading="editorLoading" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="editorLoading || saving || submitting || uploadingQualifications">
|
||||
<div class="form-section"><h3>基础资料</h3><div class="form-grid two-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="legalName" required><el-input v-model="form.legalName" placeholder="请输入营业执照上的企业名称" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="统一社会信用代码" prop="unifiedSocialCreditCode" required><el-input v-model="form.unifiedSocialCreditCode" :disabled="Boolean(editingId)" placeholder="请输入18位大写字母或数字" maxlength="18" @input="form.unifiedSocialCreditCode = String($event).toUpperCase()" /></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 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" /><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></div>
|
||||
|
||||
<div class="form-section"><h3>联系与商务</h3><div class="form-grid two-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" /></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" /></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></div>
|
||||
|
||||
<div class="form-section qualification-section"><div class="qualification-heading"><div><h3>资质文件</h3><p>先选择并上传文件;上传成功不会创建审核单,点击“提交审核”并确认后才会进入审核。</p></div><el-tag v-if="editorStatus" :type="statusMeta(editorStatus).type" effect="light">{{ statusMeta(editorStatus).label }}</el-tag></div>
|
||||
<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>
|
||||
<div v-for="draft in qualifications" :key="draft.type" class="qualification-row" role="row">
|
||||
<div class="qualification-label" role="cell"><span v-if="draft.required" aria-hidden="true">*</span>{{ draft.label }}<CheckCircle2 v-if="draft.uploaded" :size="16" aria-label="已上传" /></div>
|
||||
<div class="qualification-file" role="cell"><label class="file-picker" :class="{ 'is-disabled': !canUploadQualifications || draft.uploading }"><FileUp :size="16" /><span>{{ draft.uploading ? '上传中' : (draft.file ? '已选择新文件' : (draft.uploaded ? '更换文件' : '选择文件')) }}</span><input type="file" accept=".pdf,.jpg,.jpeg,.png,application/pdf,image/jpeg,image/png" :disabled="!canUploadQualifications || draft.uploading" @change="selectQualificationFile($event, draft)" /></label><span class="qualification-file-name">{{ draft.file?.name || draft.uploadedName || '未选择文件' }}</span><small v-if="draft.error" class="qualification-error" role="alert">{{ draft.error }}</small></div>
|
||||
<div role="cell"><el-date-picker v-model="draft.validTo" type="date" value-format="YYYY-MM-DD" placeholder="请选择有效期" :disabled="!canUploadQualifications || draft.uploaded" /></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="qualification-hint">支持 PDF、JPG、PNG,单个文件不超过 10MB;<span>*</span> 为提交审核必填项。</p>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer customer-editor-footer"><span>{{ editingId ? '保存资料和上传资质均不会送审,提交审核需要再次确认' : '请先保存资料生成客户草稿,再上传资质' }}</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="canUploadQualifications" :loading="uploadingQualifications" :disabled="editorLoading || !editingId || saving || submitting || !hasSelectedQualifications" @click="uploadQualifications">上传已选资质</el-button><el-button v-if="canSubmitReview" type="primary" :loading="submitting" :disabled="editorLoading || saving || uploadingQualifications" @click="submitReview">提交审核</el-button></div></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="客户详情" size="620px" class="business-drawer"><div v-loading="detailLoading" class="customer-detail"><template v-if="detail"><div class="detail-title"><span><Building2 :size="22" /></span><div><h3>{{ detail.tenantName }}</h3><p>{{ detail.tenantCode }}</p></div></div><el-descriptions :column="1" border><el-descriptions-item label="统一社会信用代码">{{ displayText(detail.unifiedSocialCreditCode) }}</el-descriptions-item><el-descriptions-item label="联系人">{{ displayText(detail.contactName) }} · {{ displayText(detail.contactPhone) }}</el-descriptions-item><el-descriptions-item label="联系邮箱">{{ displayText(detail.contactEmail) }}</el-descriptions-item><el-descriptions-item label="客户套餐">{{ displayText(detail.servicePackage) }}</el-descriptions-item><el-descriptions-item label="销售人员">{{ displayText(detail.salesOwnerName) }}</el-descriptions-item><el-descriptions-item label="账期">{{ detail.paymentTermDays ?? 0 }} 天</el-descriptions-item><el-descriptions-item label="注册地址">{{ displayText(detail.registeredAddress) }}</el-descriptions-item><el-descriptions-item label="入驻时间">{{ formatDate(detail.createdAt) }}</el-descriptions-item><el-descriptions-item label="内部备注">{{ displayText(detail.internalRemark) }}</el-descriptions-item></el-descriptions><div class="detail-qualification-list"><h4>资质文件</h4><div v-if="detailQualifications.length"><div v-for="item in detailQualifications" :key="String(item.id)"><span>{{ qualifications.find((draft) => draft.type === item.qualificationType)?.label || item.qualificationType }}</span><el-tag :type="item.status === 'APPROVED' ? 'success' : item.status === 'REJECTED' ? 'danger' : 'warning'" size="small">{{ item.status === 'APPROVED' ? '已通过' : item.status === 'REJECTED' ? '已驳回' : '待审核' }}</el-tag></div></div><p v-else>暂未上传资质文件</p></div></template></div></el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customers-error { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 20px; border-color: #fecdca; background: #fffafa; }
|
||||
.customers-error strong { color: #7a271a; font-size: 13px; }
|
||||
.customers-error p { margin: 4px 0 0; color: #b54708; font-size: 12px; overflow-wrap: anywhere; }
|
||||
.customer-company-cell { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
||||
.customer-company-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border-radius: 7px; background: #eef4ff; color: var(--cc-primary); }
|
||||
@media (max-width: 720px) {
|
||||
.customers-error { align-items: stretch; flex-direction: column; }
|
||||
.customers-error .el-button { min-height: 44px; }
|
||||
}
|
||||
.customers-error { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:16px; padding:18px 20px; border-color:#fecdca; background:#fffafa; }
|
||||
.customers-error strong { color:#7a271a; font-size:16px; } .customers-error p { margin:4px 0 0; color:#b54708; font-size:12px; overflow-wrap:anywhere; }
|
||||
.customers-table-header { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:16px; padding:16px 18px; border-bottom:1px solid var(--cc-border); }
|
||||
.customers-table-heading { display:flex; min-width:0; align-items:baseline; gap:10px; } .customers-table-heading h2 { margin:0; color:var(--cc-text); font-size:16px; } .customers-table-heading span { color:var(--cc-muted); font-size:12px; white-space:nowrap; }
|
||||
.customers-table-actions { display:flex; flex-wrap:wrap; gap:8px; } .customers-table-actions :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.customer-company-cell { display:flex; min-width:0; align-items:center; gap:10px; } .customer-company-cell > span,.detail-title > span { display:grid; width:34px; height:34px; flex:0 0 auto; place-items:center; border-radius:7px; background:#eef4ff; color:var(--cc-primary); } .customer-company-cell strong { overflow:hidden; text-overflow:ellipsis; }
|
||||
.capital-input { display:grid; grid-template-columns:minmax(0,1fr) 130px; width:100%; gap:8px; } .capital-input :deep(.el-input-number),.capital-input :deep(.el-select) { width:100%; }
|
||||
.qualification-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:16px; } .qualification-heading h3 { margin-bottom:4px; } .qualification-heading p,.qualification-hint { margin:0; color:var(--cc-muted); font-size:12px; line-height:1.6; } .qualification-hint { margin-top:12px; } .qualification-hint span,.qualification-label > span { color:var(--el-color-danger); }
|
||||
.qualification-table { overflow:hidden; margin-top:14px; border:1px solid var(--cc-border); border-radius:8px; } .qualification-row { display:grid; grid-template-columns:180px minmax(260px,1fr) 180px; min-width:0; border-top:1px solid var(--cc-border); } .qualification-row:first-child { border-top:0; } .qualification-row > * { display:flex; min-width:0; min-height:68px; align-items:center; padding:12px 14px; } .qualification-header { min-height:44px; background:#f7f8fc; color:var(--cc-muted); font-size:12px; font-weight:600; } .qualification-header > * { min-height:44px; }
|
||||
.qualification-label { gap:5px; font-weight:600; } .qualification-label svg { margin-left:auto; color:var(--el-color-success); }
|
||||
.qualification-file { flex-wrap:wrap; gap:8px; } .file-picker { display:inline-flex; min-height:36px; align-items:center; gap:7px; padding:0 12px; border:1px solid var(--cc-border); border-radius:6px; background:#fff; color:var(--cc-text); cursor:pointer; } .file-picker:focus-within { outline:2px solid color-mix(in srgb,var(--cc-primary) 35%,transparent); outline-offset:2px; } .file-picker.is-disabled { cursor:not-allowed; opacity:.6; } .file-picker input { position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; } .qualification-file-name { min-width:0; overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; } .qualification-error { width:100%; color:var(--el-color-danger); line-height:1.4; }
|
||||
.qualification-load-error { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-top:12px; padding:10px 12px; border:1px solid #fecdca; border-radius:6px; background:#fffafa; color:#b42318; font-size:12px; }
|
||||
.customer-editor-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; } .customer-editor-footer > span { color:var(--cc-muted); font-size:12px; text-align:left; } .customer-editor-footer > div { display:flex; flex:0 0 auto; gap:8px; } .customer-editor-footer :deep(.el-button + .el-button) { margin-left:0; }
|
||||
.customer-detail { min-height:200px; } .detail-title { display:flex; align-items:center; gap:12px; margin-bottom:20px; } .detail-title h3 { margin:0; font-size:20px; } .detail-title p { margin:4px 0 0; color:var(--cc-muted); font-size:12px; }
|
||||
.detail-qualification-list { margin-top:20px; } .detail-qualification-list h4 { margin:0 0 10px; } .detail-qualification-list > div > div { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 0; border-bottom:1px solid var(--cc-border); } .detail-qualification-list p { color:var(--cc-muted); }
|
||||
@media (max-width:720px) { .customers-error,.customers-table-header,.customer-editor-footer { align-items:stretch; flex-direction:column; } .customers-table-actions { display:grid; grid-template-columns:1fr 1fr; } .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; } }
|
||||
@media (max-width:360px) { .customers-table-actions,.capital-input { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
downloadTenantQualification,
|
||||
listEnterpriseReviews,
|
||||
listTenantQualifications,
|
||||
qualificationFileName,
|
||||
rejectEnterpriseReview,
|
||||
type EnterpriseReview,
|
||||
type PlatformTenantQualification,
|
||||
@@ -76,6 +77,11 @@ function buttonByText(text: string) {
|
||||
.find((button) => button.textContent?.trim() === text)
|
||||
}
|
||||
|
||||
function tableButtonByText(text: string) {
|
||||
return [...document.querySelectorAll<HTMLButtonElement>('.el-table__body button')]
|
||||
.find((button) => button.textContent?.trim() === text)
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('EnterpriseReviewsView', () => {
|
||||
@@ -86,6 +92,10 @@ describe('EnterpriseReviewsView', () => {
|
||||
vi.mocked(downloadTenantQualification).mockReset()
|
||||
vi.mocked(approveEnterpriseReview).mockReset()
|
||||
vi.mocked(rejectEnterpriseReview).mockReset()
|
||||
vi.mocked(qualificationFileName).mockReset()
|
||||
vi.mocked(qualificationFileName).mockReturnValue('test.png')
|
||||
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:qualification-preview') })
|
||||
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
@@ -96,7 +106,7 @@ describe('EnterpriseReviewsView', () => {
|
||||
|
||||
expect(listEnterpriseReviews).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('暂无待审核企业')
|
||||
expect(wrapper.text()).toContain('共 0 条待审记录')
|
||||
expect(wrapper.text()).toContain('共 0 条')
|
||||
})
|
||||
|
||||
it('shows review identity and submits approval with the review id', async () => {
|
||||
@@ -107,7 +117,7 @@ describe('EnterpriseReviewsView', () => {
|
||||
expect(wrapper.text()).toContain('深圳测试跨境科技有限公司')
|
||||
expect(wrapper.text()).toContain('91440300XXXXXXXXXX')
|
||||
|
||||
const approve = buttonByText('通过')
|
||||
const approve = tableButtonByText('通过')
|
||||
expect(approve).toBeDefined()
|
||||
approve?.click()
|
||||
await flushPromises()
|
||||
@@ -119,10 +129,10 @@ describe('EnterpriseReviewsView', () => {
|
||||
|
||||
expect(approveEnterpriseReview).toHaveBeenCalledWith(
|
||||
pendingReview.id,
|
||||
'营业执照清晰,企业资料审核通过',
|
||||
'企业资料与必填资质完整、清晰,审核通过',
|
||||
)
|
||||
expect(wrapper.text()).toContain('暂无待审核企业')
|
||||
expect(wrapper.text()).toContain('共 0 条待审记录')
|
||||
expect(wrapper.text()).toContain('共 0 条')
|
||||
})
|
||||
|
||||
it('loads tenant qualifications when the reviewer opens the detail drawer', async () => {
|
||||
@@ -130,7 +140,7 @@ describe('EnterpriseReviewsView', () => {
|
||||
vi.mocked(listTenantQualifications).mockResolvedValue([pendingQualification])
|
||||
|
||||
const wrapper = await mountView()
|
||||
buttonByText('详情')?.click()
|
||||
tableButtonByText('详情')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(listTenantQualifications).toHaveBeenCalledWith(pendingReview.tenantId)
|
||||
@@ -143,7 +153,7 @@ describe('EnterpriseReviewsView', () => {
|
||||
vi.mocked(listTenantQualifications).mockRejectedValue(new Error('qualification unavailable'))
|
||||
|
||||
const wrapper = await mountView()
|
||||
buttonByText('详情')?.click()
|
||||
tableButtonByText('详情')?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('资质暂时无法加载')
|
||||
@@ -152,6 +162,23 @@ describe('EnterpriseReviewsView', () => {
|
||||
expect(buttonByText('重新加载')).toBeDefined()
|
||||
})
|
||||
|
||||
it('renders uploaded PDF qualifications in a PDF preview frame', async () => {
|
||||
vi.mocked(listEnterpriseReviews).mockResolvedValue([pendingReview])
|
||||
vi.mocked(listTenantQualifications).mockResolvedValue([pendingQualification])
|
||||
vi.mocked(downloadTenantQualification).mockResolvedValue(new Blob(['%PDF-1.4'], { type: 'application/pdf' }))
|
||||
vi.mocked(qualificationFileName).mockReturnValue('test.pdf')
|
||||
|
||||
await mountView()
|
||||
tableButtonByText('详情')?.click()
|
||||
await flushPromises()
|
||||
buttonByText('查看文件')?.click()
|
||||
await flushPromises()
|
||||
|
||||
const frame = document.querySelector<HTMLIFrameElement>('.qualification-preview iframe')
|
||||
expect(frame?.getAttribute('src')).toBe('blob:qualification-preview')
|
||||
expect(frame?.getAttribute('title')).toContain('PDF 预览')
|
||||
})
|
||||
|
||||
it('keeps the page recoverable when the endpoint fails', async () => {
|
||||
vi.mocked(listEnterpriseReviews).mockRejectedValue(new Error('IAM unavailable'))
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ const previewLoadingId = ref<string>()
|
||||
const previewOpen = ref(false)
|
||||
const previewUrl = ref('')
|
||||
const previewName = ref('')
|
||||
const previewKind = ref<'image' | 'pdf' | ''>('')
|
||||
const decisionOpen = ref(false)
|
||||
const submitting = ref(false)
|
||||
const decision = reactive({ type: 'approve' as 'approve' | 'reject', comment: '' })
|
||||
@@ -93,7 +94,13 @@ function formatDate(value?: string | null) {
|
||||
}
|
||||
|
||||
function qualificationName(value?: string | null) {
|
||||
return value === 'BUSINESS_LICENSE' ? '营业执照' : displayText(value)
|
||||
const names: Record<string, string> = {
|
||||
BUSINESS_LICENSE: '营业执照',
|
||||
LEGAL_PERSON_ID: '法人身份证',
|
||||
FREIGHT_FORWARDER_CERTIFICATE: '货代资质证书',
|
||||
BANK_ACCOUNT_LICENSE: '银行开户许可证',
|
||||
}
|
||||
return value ? (names[value] || value) : '—'
|
||||
}
|
||||
|
||||
function qualificationStatus(status: PlatformTenantQualification['status']) {
|
||||
@@ -148,6 +155,7 @@ function clearPreviewUrl() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = ''
|
||||
previewName.value = ''
|
||||
previewKind.value = ''
|
||||
}
|
||||
|
||||
async function previewQualification(qualification: PlatformTenantQualification) {
|
||||
@@ -160,6 +168,7 @@ async function previewQualification(qualification: PlatformTenantQualification)
|
||||
clearPreviewUrl()
|
||||
previewUrl.value = URL.createObjectURL(blob)
|
||||
previewName.value = qualificationFileName(qualification.documentUrl)
|
||||
previewKind.value = blob.type === 'application/pdf' || previewName.value.toLowerCase().endsWith('.pdf') ? 'pdf' : 'image'
|
||||
previewOpen.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, '资质文件加载失败,请稍后重试'))
|
||||
@@ -171,7 +180,7 @@ async function previewQualification(qualification: PlatformTenantQualification)
|
||||
function openDecision(review: EnterpriseReview, type: 'approve' | 'reject') {
|
||||
selectedReview.value = review
|
||||
decision.type = type
|
||||
decision.comment = type === 'approve' ? '营业执照清晰,企业资料审核通过' : ''
|
||||
decision.comment = type === 'approve' ? '企业资料与必填资质完整、清晰,审核通过' : ''
|
||||
decisionOpen.value = true
|
||||
void nextTick(() => decisionFormRef.value?.clearValidate('comment'))
|
||||
}
|
||||
@@ -315,7 +324,8 @@ onBeforeUnmount(clearPreviewUrl)
|
||||
|
||||
<el-dialog v-model="previewOpen" :title="previewName || '查看资质文件'" width="min(860px, calc(100vw - 28px))" class="qualification-preview-dialog" destroy-on-close>
|
||||
<div class="qualification-preview">
|
||||
<img v-if="previewUrl" :src="previewUrl" :alt="`${previewName}预览`" />
|
||||
<img v-if="previewUrl && previewKind === 'image'" :src="previewUrl" :alt="`${previewName}预览`" />
|
||||
<iframe v-else-if="previewUrl && previewKind === 'pdf'" :src="previewUrl" :title="`${previewName} PDF 预览`" />
|
||||
</div>
|
||||
<template #footer><el-button @click="previewOpen = false">关闭预览</el-button></template>
|
||||
</el-dialog>
|
||||
@@ -357,6 +367,7 @@ onBeforeUnmount(clearPreviewUrl)
|
||||
.qualification-copy p { margin: 5px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.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: #f8fafc; }
|
||||
.qualification-preview img { display: block; max-width: 100%; height: auto; }
|
||||
.qualification-preview iframe { width: 100%; min-height: min(68vh, 720px); border: 0; background: #fff; }
|
||||
.decision-summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 18px; padding: 12px 14px; border-radius: 8px; background: #f8fafc; }
|
||||
.decision-summary > * { min-width: 0; overflow-wrap: anywhere; }
|
||||
.decision-summary strong { color: #172033; font-size: 13px; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ExternalLink, Pencil, Plus, Power, Truck } from '@lucide/vue'
|
||||
import { Download, ExternalLink, FileSpreadsheet, Pencil, Plus, Power, Truck, Upload } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
@@ -10,8 +10,12 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
|
||||
import {
|
||||
changePlatformExpressCompanyStatus,
|
||||
createPlatformExpressCompany,
|
||||
downloadPlatformExpressCompanyImportTemplate,
|
||||
getPlatformExpressCompany,
|
||||
importPlatformExpressCompanies,
|
||||
listPlatformExpressCompanies,
|
||||
updatePlatformExpressCompany,
|
||||
type PlatformExpressCompanyImportResult,
|
||||
type PlatformExpressCompanyResource,
|
||||
type PlatformExpressCompanyStatus,
|
||||
type SavePlatformExpressCompanyRequest,
|
||||
@@ -23,13 +27,21 @@ const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const editorOpen = ref(false)
|
||||
const importOpen = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const fileInputRef = ref<HTMLInputElement>()
|
||||
const rows = ref<PlatformExpressCompanyResource[]>([])
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const statusLoadingIds = ref(new Set<string>())
|
||||
const editLoadingIds = ref(new Set<string>())
|
||||
const downloadingTemplate = ref(false)
|
||||
const importing = ref(false)
|
||||
const importFile = ref<File | null>(null)
|
||||
const importResult = ref<PlatformExpressCompanyImportResult | null>(null)
|
||||
const importError = ref<ApiErrorDetails | null>(null)
|
||||
type ExpressCompanyFilters = QueryModel & {
|
||||
keyword: string
|
||||
status: PlatformExpressCompanyStatus | ''
|
||||
@@ -70,6 +82,7 @@ const rules: FormRules<typeof form> = {
|
||||
}
|
||||
const editorTitle = computed(() => form.id ? '编辑快递公司' : '新增快递公司')
|
||||
const editorPermission = computed(() => form.id ? 'platform:express:edit' : 'platform:express:create')
|
||||
const displayedImportErrors = computed(() => importResult.value?.errors.slice(0, 100) || [])
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'express-company-editor',
|
||||
label: editorTitle,
|
||||
@@ -162,7 +175,7 @@ function openCreate() {
|
||||
void formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
function openEdit(row: PlatformExpressCompanyResource) {
|
||||
function assignEditor(row: PlatformExpressCompanyResource) {
|
||||
Object.assign(form, {
|
||||
id: String(row.id),
|
||||
companyCode: row.companyCode,
|
||||
@@ -178,6 +191,110 @@ function openEdit(row: PlatformExpressCompanyResource) {
|
||||
void formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
async function openEdit(row: PlatformExpressCompanyResource) {
|
||||
const id = String(row.id)
|
||||
const pending = new Set(editLoadingIds.value)
|
||||
pending.add(id)
|
||||
editLoadingIds.value = pending
|
||||
try {
|
||||
assignEditor(await getPlatformExpressCompany(fixedTenantId, row.id))
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '快递公司详情加载失败,请稍后重试')
|
||||
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
|
||||
} finally {
|
||||
const next = new Set(editLoadingIds.value)
|
||||
next.delete(id)
|
||||
editLoadingIds.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function openImport() {
|
||||
importFile.value = null
|
||||
importResult.value = null
|
||||
importError.value = null
|
||||
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||
importOpen.value = true
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = fileName
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function downloadImportTemplate() {
|
||||
downloadingTemplate.value = true
|
||||
try {
|
||||
const blob = await downloadPlatformExpressCompanyImportTemplate()
|
||||
saveBlob(blob, '快递公司批量导入模板-v1.xlsx')
|
||||
} catch (error) {
|
||||
const details = getApiErrorDetails(error, '导入模板下载失败,请稍后重试')
|
||||
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
|
||||
} finally {
|
||||
downloadingTemplate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectImportFile(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0] || null
|
||||
importResult.value = null
|
||||
importError.value = null
|
||||
if (!file) {
|
||||
importFile.value = null
|
||||
return
|
||||
}
|
||||
if (!file.name.toLowerCase().endsWith('.xlsx')) {
|
||||
importFile.value = null
|
||||
ElMessage.error('请选择 .xlsx 格式的标准模板文件')
|
||||
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||
return
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
importFile.value = null
|
||||
ElMessage.error('导入文件不能超过 5MB')
|
||||
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||
return
|
||||
}
|
||||
importFile.value = file
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
if (!importFile.value) {
|
||||
ElMessage.warning('请先选择填写完成的 XLSX 文件')
|
||||
return
|
||||
}
|
||||
importing.value = true
|
||||
importError.value = null
|
||||
importResult.value = null
|
||||
try {
|
||||
const result = await importPlatformExpressCompanies(fixedTenantId, importFile.value)
|
||||
importResult.value = result
|
||||
if (result.success) {
|
||||
ElMessage.success(`成功导入 ${result.importedRows} 家快递公司`)
|
||||
importFile.value = null
|
||||
if (fileInputRef.value) fileInputRef.value.value = ''
|
||||
await loadExpressCompanies()
|
||||
}
|
||||
} catch (error) {
|
||||
importError.value = getApiErrorDetails(error, '快递公司导入失败,请稍后重试')
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadImportErrors() {
|
||||
if (!importResult.value?.errors.length) return
|
||||
const csvCell = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
|
||||
const lines = [
|
||||
['行号', '字段', '错误原因'].map(csvCell).join(','),
|
||||
...importResult.value.errors.map((item) => [item.row || '文件', item.field, item.message].map(csvCell).join(',')),
|
||||
]
|
||||
saveBlob(new Blob([`\ufeff${lines.join('\r\n')}`], { type: 'text/csv;charset=utf-8' }), '快递公司导入错误报告.csv')
|
||||
}
|
||||
|
||||
function buildPayload(): SavePlatformExpressCompanyRequest {
|
||||
return {
|
||||
companyCode: form.companyCode.trim(),
|
||||
@@ -263,7 +380,10 @@ onMounted(() => {
|
||||
<h2>快递公司列表</h2>
|
||||
<p>共 {{ total }} 家快递公司</p>
|
||||
</div>
|
||||
<el-button v-if="access.can('platform:express:create')" type="primary" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
|
||||
<div class="panel-actions">
|
||||
<el-button v-if="access.can('platform:express:import')" @click="openImport"><Upload :size="16" />批量导入</el-button>
|
||||
<el-button v-if="access.can('platform:express:create')" type="primary" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
v-model:page="page"
|
||||
@@ -279,7 +399,13 @@ onMounted(() => {
|
||||
>
|
||||
<template #actions="{ row }">
|
||||
<div v-if="access.can('platform:express:edit') || access.can('platform:express:status')" class="table-actions">
|
||||
<el-button v-if="access.can('platform:express:edit')" link type="primary" @click="openEdit(row as PlatformExpressCompanyResource)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button
|
||||
v-if="access.can('platform:express:edit')"
|
||||
link
|
||||
type="primary"
|
||||
:loading="editLoadingIds.has(String((row as PlatformExpressCompanyResource).id))"
|
||||
@click="openEdit(row as PlatformExpressCompanyResource)"
|
||||
><Pencil :size="15" />编辑</el-button>
|
||||
<el-button
|
||||
v-if="access.can('platform:express:status')"
|
||||
link
|
||||
@@ -307,6 +433,61 @@ onMounted(() => {
|
||||
</DataTable>
|
||||
</section>
|
||||
|
||||
<el-dialog
|
||||
v-model="importOpen"
|
||||
title="批量导入快递公司"
|
||||
width="min(680px, calc(100vw - 32px))"
|
||||
:close-on-click-modal="false"
|
||||
:close-on-press-escape="!importing"
|
||||
:show-close="!importing"
|
||||
class="import-dialog"
|
||||
>
|
||||
<div class="import-flow">
|
||||
<section class="import-step">
|
||||
<div class="step-heading"><span>1</span><div><strong>下载标准模板</strong><p>请勿修改模板版本或表头,仅填写模板中的数据行。</p></div></div>
|
||||
<el-button :loading="downloadingTemplate" @click="downloadImportTemplate"><Download :size="16" />下载模板</el-button>
|
||||
</section>
|
||||
<section class="import-step">
|
||||
<div class="step-heading"><span>2</span><div><strong>选择填写后的文件</strong><p>仅支持 .xlsx,最大 5MB、1000 行;本期只新增,不更新已有档案。</p></div></div>
|
||||
<label class="file-picker" :class="{ disabled: importing }">
|
||||
<input ref="fileInputRef" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" :disabled="importing" @change="selectImportFile" />
|
||||
<FileSpreadsheet :size="20" />
|
||||
<span>{{ importFile?.name || '选择 XLSX 文件' }}</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<el-alert type="info" :closable="false" show-icon title="整批校验通过后才会写入;任一行有误时整批不写入。" />
|
||||
<el-alert v-if="importError" type="error" :closable="false" show-icon title="导入请求失败">
|
||||
<p>{{ importError.message }}</p>
|
||||
<ApiErrorMeta :details="importError" />
|
||||
</el-alert>
|
||||
<el-alert v-if="importResult?.success" type="success" :closable="false" show-icon :title="`导入完成:成功新增 ${importResult.importedRows} 家快递公司`" />
|
||||
|
||||
<section v-if="importResult && !importResult.success" class="import-errors" aria-live="polite">
|
||||
<div class="error-heading">
|
||||
<div><strong>校验未通过,未写入任何数据</strong><p>发现 {{ importResult.errors.length }} 项问题,请修正后重新上传。</p></div>
|
||||
<el-button size="small" @click="downloadImportErrors"><Download :size="14" />下载错误报告</el-button>
|
||||
</div>
|
||||
<div class="error-list">
|
||||
<div v-for="(item, index) in displayedImportErrors" :key="`${item.row}-${item.field}-${index}`" class="error-item">
|
||||
<span>{{ item.row > 0 ? `第 ${item.row} 行` : '文件' }}</span>
|
||||
<strong>{{ item.field }}</strong>
|
||||
<p>{{ item.message }}</p>
|
||||
</div>
|
||||
<p v-if="importResult.errors.length > displayedImportErrors.length" class="error-overflow-note">
|
||||
仅展示前 {{ displayedImportErrors.length }} 项,完整内容请下载错误报告。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button :disabled="importing" @click="importOpen = false">关闭</el-button>
|
||||
<el-button type="primary" :loading="importing" :disabled="!importFile" @click="submitImport"><Upload :size="16" />开始导入</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="editorTitle" size="580px" class="business-drawer editor-drawer" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="drawer-form" :disabled="saving">
|
||||
<div class="form-section">
|
||||
@@ -346,8 +527,34 @@ onMounted(() => {
|
||||
.tracking-template { display: block; max-width: 100%; color: #475467; font-family: "Cascadia Code", Consolas, monospace; font-size: 11px; overflow-wrap: anywhere; }
|
||||
.remark-cell { display: -webkit-box; overflow: hidden; line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
|
||||
.form-help code { color: #475467; font-family: "Cascadia Code", Consolas, monospace; }
|
||||
.panel-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
.panel-actions .el-button { margin-left: 0; }
|
||||
.import-flow { display: grid; gap: 16px; }
|
||||
.import-step { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 16px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fafbfc; }
|
||||
.step-heading { display: flex; min-width: 0; align-items: flex-start; gap: 12px; }
|
||||
.step-heading > span { display: grid; width: 24px; height: 24px; flex: 0 0 24px; place-items: center; border-radius: 50%; background: var(--cc-primary); color: #fff; font-size: 12px; font-weight: 700; }
|
||||
.step-heading strong, .error-heading strong { color: var(--cc-text); font-size: 14px; }
|
||||
.step-heading p, .error-heading p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.55; }
|
||||
.file-picker { display: inline-flex; min-width: 180px; max-width: 260px; align-items: center; justify-content: center; gap: 8px; padding: 9px 14px; border: 1px solid var(--cc-border); border-radius: 6px; background: #fff; color: var(--cc-primary); cursor: pointer; }
|
||||
.file-picker:hover { border-color: var(--cc-primary); }
|
||||
.file-picker:focus-within { outline: 2px solid color-mix(in srgb, var(--cc-primary) 28%, transparent); outline-offset: 2px; }
|
||||
.file-picker.disabled { cursor: not-allowed; opacity: .6; }
|
||||
.file-picker input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||
.file-picker span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.import-flow .el-alert p { margin: 5px 0 0; }
|
||||
.import-errors { overflow: hidden; border: 1px solid #f4c7c7; border-radius: 8px; }
|
||||
.error-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 16px; background: #fff5f5; }
|
||||
.error-list { max-height: 220px; overflow: auto; }
|
||||
.error-item { display: grid; grid-template-columns: 72px 130px minmax(0, 1fr); gap: 10px; padding: 10px 16px; border-top: 1px solid #f4dddd; font-size: 12px; }
|
||||
.error-item span { color: #b42318; font-weight: 600; }
|
||||
.error-item p { margin: 0; overflow-wrap: anywhere; }
|
||||
.error-overflow-note { margin: 0; padding: 10px 16px; border-top: 1px solid #f4dddd; color: var(--cc-muted); font-size: 12px; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.panel-heading .el-button { width: 100%; }
|
||||
.panel-actions, .panel-actions .el-button { width: 100%; }
|
||||
.import-step, .error-heading { align-items: stretch; flex-direction: column; }
|
||||
.file-picker, .import-step > .el-button { width: 100%; max-width: none; }
|
||||
.error-item { grid-template-columns: 64px minmax(0, 1fr); }
|
||||
.error-item p { grid-column: 1 / -1; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getPlatformUserOptions,
|
||||
listPlatformManagedUsers,
|
||||
type PlatformManagedUser,
|
||||
} from '@/api/platformIam'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import InternalUsersView from './InternalUsersView.vue'
|
||||
|
||||
vi.mock('@/api/platformIam', () => ({
|
||||
listPlatformManagedUsers: vi.fn(),
|
||||
getPlatformUserOptions: vi.fn(),
|
||||
createPlatformManagedUser: vi.fn(),
|
||||
updatePlatformManagedUser: vi.fn(),
|
||||
changePlatformManagedUserStatus: vi.fn(),
|
||||
resetPlatformManagedUserPassword: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
const user: PlatformManagedUser = {
|
||||
membershipId: '30', userId: '20', authUserId: '10', username: 'zhangwei', displayName: '张伟',
|
||||
phone: '13800138000', email: 'zhangwei@example.com', tenantId: '10001', tenantName: '华远贸易',
|
||||
memberType: 'EMPLOYEE', memberNo: 'MEMBER-30', status: 'ACTIVE', roleIds: ['40'],
|
||||
roleNames: ['租户管理员'], lastLoginAt: '2026-06-01T10:30:00',
|
||||
protectedAccount: false,
|
||||
}
|
||||
|
||||
async function mountView() {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = [
|
||||
'platform:user:list', 'platform:user:create', 'platform:user:edit',
|
||||
'platform:user:status', 'platform:user:reset-password', 'platform:user:grant-role',
|
||||
]
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(InternalUsersView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('InternalUsersView unified platform user management', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
vi.mocked(listPlatformManagedUsers).mockReset().mockResolvedValue([user])
|
||||
vi.mocked(getPlatformUserOptions).mockReset().mockResolvedValue({
|
||||
tenants: [
|
||||
{ id: '0', name: '平台', status: 'ACTIVE' },
|
||||
{ id: '10001', name: '华远贸易', status: 'ACTIVE' },
|
||||
],
|
||||
roles: [{ id: '40', tenantId: '10001', name: '租户管理员', status: 'ACTIVE' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('loads and renders prototype fields from the unified API', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(listPlatformManagedUsers).toHaveBeenCalledOnce()
|
||||
expect(getPlatformUserOptions).toHaveBeenCalledOnce()
|
||||
expect(wrapper.text()).toContain('zhangwei')
|
||||
expect(wrapper.text()).toContain('张伟')
|
||||
expect(wrapper.text()).toContain('华远贸易')
|
||||
expect(wrapper.text()).toContain('租户管理员')
|
||||
expect(wrapper.text()).toContain('2026-06-01 10:30')
|
||||
expect(wrapper.text()).not.toContain('认证用户 ID')
|
||||
expect(wrapper.text()).not.toContain('成员编号')
|
||||
})
|
||||
|
||||
it('opens the complete prototype create form', async () => {
|
||||
const wrapper = await mountView()
|
||||
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增用户')
|
||||
await createButton?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.querySelector('input[placeholder="登录用户名"]')).not.toBeNull()
|
||||
expect(document.querySelector('input[placeholder="真实姓名"]')).not.toBeNull()
|
||||
expect(document.querySelector('input[placeholder="手机号码"]')).not.toBeNull()
|
||||
expect(document.querySelector('input[placeholder="邮箱地址"]')).not.toBeNull()
|
||||
expect(document.body.textContent).toContain('所属租户')
|
||||
expect(document.body.textContent).toContain('初始密码')
|
||||
expect(document.body.textContent).toContain('确认添加')
|
||||
})
|
||||
|
||||
it('shows a recoverable API error without fake users', async () => {
|
||||
vi.mocked(listPlatformManagedUsers).mockRejectedValueOnce(new Error('Gateway unavailable'))
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.text()).toContain('Gateway unavailable')
|
||||
expect(wrapper.text()).toContain('重新加载')
|
||||
expect(wrapper.text()).not.toContain('zhangwei')
|
||||
})
|
||||
})
|
||||
+265
-161
@@ -1,237 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { KeyRound, Pencil, Plus, ShieldCheck } from '@lucide/vue'
|
||||
import { Pencil, Plus } from '@lucide/vue'
|
||||
import {
|
||||
assignPlatformInternalUserRoles,
|
||||
changePlatformInternalUserStatus,
|
||||
createPlatformInternalUser,
|
||||
listPlatformInternalUsers,
|
||||
listPlatformRoles,
|
||||
resetPlatformInternalUserPassword,
|
||||
updatePlatformInternalUser,
|
||||
type PlatformInternalUser,
|
||||
type PlatformRoleResource,
|
||||
changePlatformManagedUserStatus,
|
||||
createPlatformManagedUser,
|
||||
getPlatformUserOptions,
|
||||
listPlatformManagedUsers,
|
||||
resetPlatformManagedUserPassword,
|
||||
updatePlatformManagedUser,
|
||||
type PlatformManagedUser,
|
||||
type PlatformUserOptions,
|
||||
} from '@/api/platformIam'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http'
|
||||
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const access = useAccessPreviewStore()
|
||||
const auth = useAuthStore()
|
||||
const users = ref<PlatformInternalUser[]>([])
|
||||
const roles = ref<PlatformRoleResource[]>([])
|
||||
const users = ref<PlatformManagedUser[]>([])
|
||||
const options = ref<PlatformUserOptions>({ tenants: [], roles: [] })
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const editorOpen = ref(false)
|
||||
const formRef = ref<FormInstance>()
|
||||
const editingMembershipId = ref<string | null>(null)
|
||||
const roleEditorOpen = ref(false)
|
||||
const assigningRoles = ref(false)
|
||||
const roleAssignmentUser = ref<PlatformInternalUser | null>(null)
|
||||
const assignedRoleIds = ref<string[]>([])
|
||||
const editingUser = ref<PlatformManagedUser | null>(null)
|
||||
const rowActionId = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = ref(10)
|
||||
type InternalUserFilters = QueryModel & { keyword: string; status: string }
|
||||
const filters = ref<InternalUserFilters>({ keyword: '', status: '' })
|
||||
const appliedFilters = ref<InternalUserFilters>({ ...filters.value })
|
||||
const queryFields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', placeholder: '登录账号、认证用户 ID、姓名或成员编号' },
|
||||
{ key: 'status', type: 'select', placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '停用', value: 'DISABLED' }] },
|
||||
]
|
||||
const emptyForm = () => ({ authUserId: '', userType: 'INTERNAL', displayName: '', phone: '', email: '', memberNo: '', roleIds: [] as string[] })
|
||||
const filters = reactive({ keyword: '', tenantId: '', status: '' })
|
||||
|
||||
const emptyForm = () => ({
|
||||
username: '',
|
||||
displayName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
tenantId: '' as string,
|
||||
roleIds: [] as string[],
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
status: 'ACTIVE' as 'ACTIVE' | 'DISABLED',
|
||||
resetPassword: false,
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
authUserId: [
|
||||
{ required: !editingMembershipId.value, message: '请输入认证用户 ID', trigger: 'blur' },
|
||||
{ pattern: /^[1-9]\d*$/, message: '认证用户 ID 必须为正整数', trigger: 'blur' },
|
||||
],
|
||||
displayName: [{ required: true, whitespace: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
userType: editingMembershipId.value ? [] : [{ required: true, message: '请选择用户类型', trigger: 'change' }],
|
||||
memberNo: editingMembershipId.value ? [{ required: true, whitespace: true, message: '请输入成员编号', trigger: 'blur' }] : [],
|
||||
email: [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
|
||||
}))
|
||||
const editorLabel = computed(() => editingMembershipId.value ? '编辑内部用户' : '新增内部用户')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'internal-user-editor',
|
||||
|
||||
const canCreate = computed(() => access.can('platform:user:create') && access.can('platform:user:grant-role'))
|
||||
const canEdit = computed(() => access.can('platform:user:edit'))
|
||||
const canGrantRoles = computed(() => access.can('platform:user:grant-role'))
|
||||
const canChangeStatus = computed(() => access.can('platform:user:status'))
|
||||
const canResetPassword = computed(() => access.can('platform:user:reset-password'))
|
||||
const editorLabel = computed(() => editingUser.value ? `编辑用户 - ${editingUser.value.displayName}` : '新增用户')
|
||||
|
||||
useUnsavedWorkRegistration({
|
||||
key: 'platform-user-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: computed(() => editingMembershipId.value ? 'platform:user:edit' : 'platform:user:create'),
|
||||
requiredPermission: computed(() => editingUser.value ? 'platform:user:edit' : 'platform:user:create'),
|
||||
})
|
||||
|
||||
const canCreate = computed(() => access.can('platform:user:create'))
|
||||
const canEdit = computed(() => access.can('platform:user:edit'))
|
||||
const canChangeStatus = computed(() => access.can('platform:user:status'))
|
||||
const canResetPassword = computed(() => access.can('platform:user:reset-password'))
|
||||
const canGrantRoles = computed(() => access.can('platform:user:grant-role'))
|
||||
const roleNames = computed(() => new Map(roles.value.map((role) => [String(role.id), role.roleName])))
|
||||
const roleOptions = computed(() => roles.value.filter((role) => role.status === 'ACTIVE' || form.roleIds.includes(String(role.id)) || assignedRoleIds.value.includes(String(role.id))))
|
||||
const filteredRows = computed(() => users.value.filter((item) => {
|
||||
const keyword = appliedFilters.value.keyword.trim().toLowerCase()
|
||||
const matchesKeyword = !keyword || [item.displayName, item.username, item.memberNo ?? '', String(item.authUserId)]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesKeyword && (!appliedFilters.value.status || item.status === appliedFilters.value.status)
|
||||
const enabledTenants = computed(() => options.value.tenants.filter((tenant) =>
|
||||
tenant.status === 'ACTIVE' || String(tenant.id) === form.tenantId,
|
||||
))
|
||||
const availableRoles = computed(() => options.value.roles.filter((role) =>
|
||||
String(role.tenantId) === form.tenantId
|
||||
&& (role.status === 'ACTIVE' || form.roleIds.includes(String(role.id))),
|
||||
))
|
||||
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
username: editingUser.value ? [] : [
|
||||
{ required: true, whitespace: true, message: '请输入用户名', trigger: 'blur' },
|
||||
{ pattern: /^[A-Za-z0-9_.@-]{4,64}$/, message: '用户名为 4-64 位字母、数字或 _ . @ -', trigger: 'blur' },
|
||||
],
|
||||
displayName: editingUser.value && !canEdit.value ? [] : [{ required: true, whitespace: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
phone: editingUser.value && !canEdit.value ? [] : [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1\d{10}$/, message: '请输入正确的 11 位手机号', trigger: 'blur' },
|
||||
],
|
||||
email: editingUser.value && !canEdit.value ? [] : [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
|
||||
tenantId: editingUser.value && !canEdit.value ? [] : [{ required: true, message: '请选择所属租户', trigger: 'change' }],
|
||||
roleIds: editingUser.value && !canEdit.value ? [] : [{ type: 'array', required: true, min: 1, message: '请选择角色', trigger: 'change' }],
|
||||
password: editingUser.value && !form.resetPassword ? [] : [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码为 8-64 位,且必须包含字母和数字', trigger: 'blur' },
|
||||
],
|
||||
confirmPassword: editingUser.value && !form.resetPassword ? [] : [
|
||||
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
||||
{ validator: (_rule, value, callback) => value === form.password ? callback() : callback(new Error('两次输入的密码不一致')), trigger: 'blur' },
|
||||
],
|
||||
}))
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
return users.value.filter((item) => {
|
||||
const matchesKeyword = !keyword || [item.username, item.displayName, item.phone ?? '', item.email ?? '', item.tenantName, ...item.roleNames]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesKeyword
|
||||
&& (!filters.tenantId || String(item.tenantId) === filters.tenantId)
|
||||
&& (!filters.status || item.status === filters.status)
|
||||
})
|
||||
})
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
|
||||
|
||||
const userColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 355, fixed: 'left', slotName: 'actions' },
|
||||
{ prop: 'displayName', label: '姓名', minWidth: 150 },
|
||||
{ prop: 'username', label: '登录账号', minWidth: 150 },
|
||||
{ prop: 'authUserId', label: '认证用户 ID', minWidth: 145 },
|
||||
{ prop: 'memberNo', label: '成员编号', minWidth: 170 },
|
||||
{ prop: 'memberType', label: '成员类型', minWidth: 130 },
|
||||
{ key: 'roles', label: '角色', minWidth: 190, slotName: 'roles' },
|
||||
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
|
||||
{ prop: 'username', label: '用户名', minWidth: 145 },
|
||||
{ prop: 'displayName', label: '姓名', minWidth: 130 },
|
||||
{ prop: 'tenantName', label: '所属租户', minWidth: 190 },
|
||||
{ key: 'roles', label: '角色', minWidth: 180, slotName: 'roles' },
|
||||
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
|
||||
{ key: 'lastLoginAt', label: '最后登录', minWidth: 170, slotName: 'lastLoginAt' },
|
||||
]
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
users.value = []
|
||||
try {
|
||||
const [userRecords, roleRecords] = await Promise.all([listPlatformInternalUsers(), listPlatformRoles()])
|
||||
const [userRecords, optionRecords] = await Promise.all([
|
||||
listPlatformManagedUsers(),
|
||||
getPlatformUserOptions(),
|
||||
])
|
||||
users.value = userRecords
|
||||
roles.value = roleRecords
|
||||
options.value = optionRecords
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorDetails(error, '内部用户列表加载失败,请稍后重试')
|
||||
users.value = []
|
||||
loadError.value = getApiErrorDetails(error, '用户列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
appliedFilters.value = { ...filters.value }
|
||||
page.value = 1
|
||||
void loadUsers()
|
||||
}
|
||||
function roleLabel(roleId: number | string) { return roleNames.value.get(String(roleId)) ?? `角色 #${roleId}` }
|
||||
function openCreate() {
|
||||
Object.assign(form, emptyForm())
|
||||
editingMembershipId.value = null
|
||||
captureBaseline()
|
||||
const defaultTenant = options.value.tenants.find((tenant) => tenant.status === 'ACTIVE')
|
||||
form.tenantId = defaultTenant ? String(defaultTenant.id) : ''
|
||||
editingUser.value = null
|
||||
editorOpen.value = true
|
||||
nextTick(() => formRef.value?.clearValidate())
|
||||
}
|
||||
function openEdit(row: PlatformInternalUser) {
|
||||
if (!canEdit.value) return
|
||||
editingMembershipId.value = String(row.membershipId)
|
||||
|
||||
function openEdit(row: PlatformManagedUser) {
|
||||
editingUser.value = row
|
||||
Object.assign(form, {
|
||||
authUserId: String(row.authUserId),
|
||||
userType: row.memberType,
|
||||
username: row.username,
|
||||
displayName: row.displayName,
|
||||
phone: row.phone ?? '',
|
||||
email: row.email ?? '',
|
||||
memberNo: row.memberNo ?? '',
|
||||
tenantId: String(row.tenantId),
|
||||
roleIds: row.roleIds.map(String),
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
status: row.status,
|
||||
resetPassword: false,
|
||||
})
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
nextTick(() => formRef.value?.clearValidate())
|
||||
}
|
||||
|
||||
function openRoleAssignment(row: PlatformInternalUser) {
|
||||
if (!canGrantRoles.value) return
|
||||
roleAssignmentUser.value = row
|
||||
assignedRoleIds.value = row.roleIds.map(String)
|
||||
roleEditorOpen.value = true
|
||||
function handleTenantChange() {
|
||||
const validRoleIds = new Set(availableRoles.value.map((role) => String(role.id)))
|
||||
form.roleIds = form.roleIds.filter((roleId) => validRoleIds.has(roleId))
|
||||
}
|
||||
|
||||
function isCurrentUser(row: PlatformInternalUser) {
|
||||
function isCurrentUser(row: PlatformManagedUser | null) {
|
||||
if (!row) return false
|
||||
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
|
||||
}
|
||||
|
||||
function cannotDisable(row: PlatformManagedUser | null) {
|
||||
return Boolean(row?.protectedAccount) || isCurrentUser(row)
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingMembershipId.value) {
|
||||
await updatePlatformInternalUser(editingMembershipId.value, {
|
||||
if (!editingUser.value) {
|
||||
await createPlatformManagedUser({
|
||||
username: form.username.trim(),
|
||||
displayName: form.displayName.trim(),
|
||||
phone: form.phone.trim(),
|
||||
email: form.email.trim(),
|
||||
memberNo: form.memberNo.trim(),
|
||||
tenantId: form.tenantId,
|
||||
roleIds: form.roleIds,
|
||||
password: form.password,
|
||||
status: form.status,
|
||||
})
|
||||
ElMessage.success('用户添加成功')
|
||||
} else {
|
||||
await createPlatformInternalUser({
|
||||
authUserId: form.authUserId.trim(),
|
||||
userType: form.userType,
|
||||
displayName: form.displayName.trim(),
|
||||
phone: form.phone.trim(),
|
||||
email: form.email.trim(),
|
||||
memberNo: form.memberNo.trim() || undefined,
|
||||
roleIds: canGrantRoles.value ? form.roleIds : undefined,
|
||||
})
|
||||
const row = editingUser.value
|
||||
if (canEdit.value) {
|
||||
await updatePlatformManagedUser(row.membershipId, {
|
||||
displayName: form.displayName.trim(),
|
||||
phone: form.phone.trim(),
|
||||
email: form.email.trim(),
|
||||
tenantId: form.tenantId,
|
||||
roleIds: form.roleIds,
|
||||
})
|
||||
}
|
||||
if (canChangeStatus.value && form.status !== row.status) {
|
||||
await changePlatformManagedUserStatus(row.membershipId, form.status)
|
||||
}
|
||||
if (canResetPassword.value && form.resetPassword) {
|
||||
await resetPlatformManagedUserPassword(row.membershipId, form.password)
|
||||
}
|
||||
ElMessage.success(form.resetPassword ? '用户信息已更新,密码已重置' : '用户信息已更新')
|
||||
}
|
||||
editorOpen.value = false
|
||||
ElMessage.success(editingMembershipId.value ? '内部用户已更新' : '内部用户已创建')
|
||||
await loadUsers()
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, editingMembershipId.value ? '内部用户更新失败,请稍后重试' : '内部用户创建失败,请稍后重试'))
|
||||
ElMessage.error(getApiErrorMessage(error, editingUser.value ? '用户更新失败,请稍后重试' : '用户添加失败,请稍后重试'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row: PlatformInternalUser) {
|
||||
const target = row.status === 'ACTIVE' ? '停用' : '启用'
|
||||
async function toggleStatus(row: PlatformManagedUser) {
|
||||
const targetStatus = row.status === 'ACTIVE' ? 'DISABLED' : 'ACTIVE'
|
||||
const targetLabel = targetStatus === 'ACTIVE' ? '启用' : '禁用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${target}内部用户“${row.displayName}”吗?`, '用户状态', { confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
rowActionId.value = String(row.membershipId)
|
||||
const updated = await changePlatformInternalUserStatus(row.membershipId, target === '启用' ? 'ACTIVE' : 'DISABLED')
|
||||
Object.assign(row, updated)
|
||||
ElMessage.success(`用户已${target}`)
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(getApiErrorMessage(error, '用户状态更新失败,请稍后重试'))
|
||||
} finally {
|
||||
rowActionId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(row: PlatformInternalUser) {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt(`为“${row.displayName}”设置新密码`, '重置密码', {
|
||||
confirmButtonText: '确认重置',
|
||||
await ElMessageBox.confirm(`确认${targetLabel}用户“${row.displayName}”吗?`, `${targetLabel}用户`, {
|
||||
confirmButtonText: `确认${targetLabel}`,
|
||||
cancelButtonText: '取消',
|
||||
inputType: 'password',
|
||||
inputPlaceholder: '8-64 位,必须包含字母和数字',
|
||||
inputValidator: (password) => /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/.test(password) || '密码需为 8-64 位,且同时包含字母和数字',
|
||||
type: targetStatus === 'DISABLED' ? 'warning' : 'info',
|
||||
})
|
||||
rowActionId.value = String(row.membershipId)
|
||||
await resetPlatformInternalUserPassword(row.membershipId, value)
|
||||
ElMessage.success('密码已重置')
|
||||
const updated = await changePlatformManagedUserStatus(row.membershipId, targetStatus)
|
||||
Object.assign(row, updated)
|
||||
ElMessage.success(`用户已${targetLabel}`)
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(getApiErrorMessage(error, '密码重置失败,请稍后重试'))
|
||||
ElMessage.error(getApiErrorMessage(error, `用户${targetLabel}失败,请稍后重试`))
|
||||
} finally {
|
||||
rowActionId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRoleAssignment() {
|
||||
if (!roleAssignmentUser.value) return
|
||||
assigningRoles.value = true
|
||||
try {
|
||||
const updated = await assignPlatformInternalUserRoles(roleAssignmentUser.value.membershipId, assignedRoleIds.value)
|
||||
const index = users.value.findIndex((item) => String(item.membershipId) === String(updated.membershipId))
|
||||
if (index >= 0) users.value[index] = updated
|
||||
roleEditorOpen.value = false
|
||||
ElMessage.success('用户角色已更新')
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, '用户角色更新失败,请稍后重试'))
|
||||
} finally {
|
||||
assigningRoles.value = false
|
||||
}
|
||||
function formatLogin(value: string | null) {
|
||||
if (!value) return '—'
|
||||
return value.replace('T', ' ').slice(0, 16)
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
filters.keyword = ''
|
||||
filters.tenantId = ''
|
||||
filters.status = ''
|
||||
page.value = 1
|
||||
}
|
||||
|
||||
onMounted(loadUsers)
|
||||
@@ -240,34 +260,118 @@ onMounted(loadUsers)
|
||||
<template>
|
||||
<div class="business-page">
|
||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
|
||||
<template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadUsers">重新加载</el-button></template>
|
||||
<template #title>
|
||||
<div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div>
|
||||
<el-button link type="danger" @click="loadUsers">重新加载</el-button>
|
||||
</template>
|
||||
</el-alert>
|
||||
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="内部用户筛选" :fields="queryFields" @search="applyFilters" />
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<header class="panel-heading"><div><h2>内部用户 <span class="secondary-text">共 {{ filteredRows.length }} 条</span></h2><p>数据和维护操作均由平台 IAM 提供,角色关系按成员维度保存。</p></div><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></header>
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="userColumns" :loading="loading" :total="filteredRows.length" row-key="membershipId" empty-text="没有符合条件的内部用户">
|
||||
<template #actions="{ row }"><div v-if="canEdit || canChangeStatus || canResetPassword || canGrantRoles" class="table-actions"><el-button v-if="canEdit" link type="primary" :disabled="rowActionId === String((row as PlatformInternalUser).membershipId)" @click="openEdit(row as PlatformInternalUser)"><Pencil :size="15" />编辑</el-button><el-button v-if="canGrantRoles" link type="primary" :disabled="rowActionId === String((row as PlatformInternalUser).membershipId)" @click="openRoleAssignment(row as PlatformInternalUser)"><ShieldCheck :size="15" />分配角色</el-button><el-button v-if="canChangeStatus" link :type="(row as PlatformInternalUser).status === 'ACTIVE' ? 'danger' : 'success'" :loading="rowActionId === String((row as PlatformInternalUser).membershipId)" :disabled="isCurrentUser(row as PlatformInternalUser)" :title="isCurrentUser(row as PlatformInternalUser) ? '不能停用当前登录用户' : undefined" @click="toggleStatus(row as PlatformInternalUser)">{{ (row as PlatformInternalUser).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="canResetPassword" link type="warning" :loading="rowActionId === String((row as PlatformInternalUser).membershipId)" @click="resetPassword(row as PlatformInternalUser)"><KeyRound :size="15" />重置密码</el-button></div><span v-else class="secondary-text">仅查看</span></template>
|
||||
<template #roles="{ row }"><div class="tag-list"><el-tag v-for="roleId in (row as PlatformInternalUser).roleIds" :key="roleId" effect="plain" size="small">{{ roleLabel(roleId) }}</el-tag><span v-if="(row as PlatformInternalUser).roleIds.length === 0" class="secondary-text">未分配</span></div></template>
|
||||
<template #status="{ row }"><StatusBadge :status="(row as PlatformInternalUser).status === 'ACTIVE' ? '正常' : '停用'" /></template>
|
||||
<header class="panel-heading">
|
||||
<div><h2>用户管理 <span class="secondary-text">共 {{ filteredRows.length }} 条</span></h2></div>
|
||||
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button>
|
||||
</header>
|
||||
|
||||
<div class="query-toolbar">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="用户名、姓名、手机号、租户或角色" @keyup.enter="page = 1" />
|
||||
<el-select v-model="filters.tenantId" clearable placeholder="全部租户" @change="page = 1">
|
||||
<el-option v-for="tenant in options.tenants" :key="tenant.id" :label="tenant.name" :value="String(tenant.id)" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="page = 1">
|
||||
<el-option label="正常" value="ACTIVE" />
|
||||
<el-option label="禁用" value="DISABLED" />
|
||||
</el-select>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
|
||||
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="userColumns"
|
||||
:loading="loading" :total="filteredRows.length" row-key="membershipId" empty-text="暂无用户数据">
|
||||
<template #actions="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button v-if="canEdit || canChangeStatus || canResetPassword" link type="primary" @click="openEdit(row as PlatformManagedUser)">
|
||||
<Pencil :size="15" />编辑
|
||||
</el-button>
|
||||
<el-button v-if="canChangeStatus" link :type="(row as PlatformManagedUser).status === 'ACTIVE' ? 'danger' : 'success'"
|
||||
:loading="rowActionId === String((row as PlatformManagedUser).membershipId)"
|
||||
:disabled="cannotDisable(row as PlatformManagedUser)"
|
||||
:title="(row as PlatformManagedUser).protectedAccount ? '系统管理员不能被禁用' : isCurrentUser(row as PlatformManagedUser) ? '不能禁用当前登录用户' : undefined"
|
||||
@click="toggleStatus(row as PlatformManagedUser)">
|
||||
{{ (row as PlatformManagedUser).status === 'ACTIVE' ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<template #roles="{ row }">
|
||||
<div class="tag-list">
|
||||
<el-tag v-for="role in (row as PlatformManagedUser).roleNames" :key="role" effect="plain" size="small">{{ role }}</el-tag>
|
||||
<span v-if="!(row as PlatformManagedUser).roleNames.length" class="secondary-text">未分配</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #status="{ row }"><StatusBadge :status="(row as PlatformManagedUser).status === 'ACTIVE' ? '正常' : '禁用'" /></template>
|
||||
<template #lastLoginAt="{ row }">{{ formatLogin((row as PlatformManagedUser).lastLoginAt) }}</template>
|
||||
</DataTable>
|
||||
</section>
|
||||
<el-drawer v-model="editorOpen" :title="editorLabel" size="560px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top" class="drawer-form">
|
||||
<div class="form-section"><h3>用户信息</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="认证用户 ID" prop="authUserId" :required="!editingMembershipId"><el-input v-model="form.authUserId" :disabled="Boolean(editingMembershipId)" inputmode="numeric" placeholder="输入已有认证账号的数字 ID" /></el-form-item>
|
||||
<el-form-item label="姓名" prop="displayName" required><el-input v-model="form.displayName" placeholder="如:张三" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="用户类型" prop="userType" :required="!editingMembershipId"><el-select v-model="form.userType" :disabled="Boolean(editingMembershipId)"><el-option label="平台内部用户" value="INTERNAL" /><el-option label="平台管理员" value="PLATFORM" /></el-select></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="form.phone" placeholder="如:13800138000" inputmode="tel" /></el-form-item>
|
||||
<el-form-item label="邮箱" prop="email"><el-input v-model="form.email" placeholder="如:name@example.com" inputmode="email" /></el-form-item>
|
||||
<el-form-item v-if="editingMembershipId" label="成员编号" prop="memberNo" required><el-input v-model="form.memberNo" placeholder="如:PLATFORM-0001" maxlength="64" /></el-form-item>
|
||||
<el-form-item v-if="canGrantRoles" label="角色"><el-select v-model="form.roleIds" multiple collapse-tags collapse-tags-tooltip placeholder="选择用户角色"><el-option v-for="role in roleOptions" :key="role.id" :label="role.roleName" :value="String(role.id)" :disabled="role.status !== 'ACTIVE'" /></el-select></el-form-item>
|
||||
</div></div>
|
||||
|
||||
<el-dialog v-model="editorOpen" :title="editorLabel" width="min(760px, calc(100vw - 32px))" :close-on-click-modal="false" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="formRules" label-position="top">
|
||||
<div class="form-grid two-cols">
|
||||
<el-form-item label="用户名" prop="username" required>
|
||||
<el-input v-model="form.username" :disabled="Boolean(editingUser)" placeholder="登录用户名" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="姓名" prop="displayName" required>
|
||||
<el-input v-model="form.displayName" :disabled="Boolean(editingUser) && !canEdit" placeholder="真实姓名" maxlength="64" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号" prop="phone" required>
|
||||
<el-input v-model="form.phone" :disabled="Boolean(editingUser) && !canEdit" placeholder="手机号码" inputmode="tel" maxlength="11" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="form.email" :disabled="Boolean(editingUser) && !canEdit" placeholder="邮箱地址" inputmode="email" maxlength="128" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属租户" prop="tenantId" required>
|
||||
<el-select v-model="form.tenantId" :disabled="Boolean(editingUser) && (!canEdit || !canGrantRoles || editingUser?.protectedAccount)" placeholder="选择所属租户" @change="handleTenantChange">
|
||||
<el-option v-for="tenant in enabledTenants" :key="tenant.id" :label="tenant.name" :value="String(tenant.id)" :disabled="tenant.status !== 'ACTIVE'" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色" prop="roleIds" required>
|
||||
<el-select v-model="form.roleIds" multiple collapse-tags collapse-tags-tooltip
|
||||
:disabled="Boolean(editingUser) && (!canEdit || !canGrantRoles || editingUser?.protectedAccount)" placeholder="选择角色">
|
||||
<el-option v-for="role in availableRoles" :key="role.id" :label="role.name" :value="String(role.id)" :disabled="role.status !== 'ACTIVE'" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="status">
|
||||
<el-select v-model="form.status" :disabled="Boolean(editingUser) && (!canChangeStatus || cannotDisable(editingUser))">
|
||||
<el-option label="正常" value="ACTIVE" />
|
||||
<el-option label="禁用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editingUser" label="最后登录">
|
||||
<el-input :model-value="formatLogin(editingUser.lastLoginAt)" disabled />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<template v-if="!editingUser || canResetPassword">
|
||||
<el-checkbox v-if="editingUser" v-model="form.resetPassword">同时重置密码</el-checkbox>
|
||||
<div v-if="!editingUser || form.resetPassword" class="form-grid two-cols password-fields">
|
||||
<el-form-item :label="editingUser ? '新密码' : '初始密码'" prop="password" required>
|
||||
<el-input v-model="form.password" type="password" show-password autocomplete="new-password" placeholder="8-64 位,包含字母和数字" />
|
||||
</el-form-item>
|
||||
<el-form-item label="确认密码" prop="confirmPassword" required>
|
||||
<el-input v-model="form.confirmPassword" type="password" show-password autocomplete="new-password" placeholder="再次输入密码" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button v-if="editingMembershipId ? canEdit : canCreate" type="primary" :loading="saving" @click="saveUser">{{ editingMembershipId ? '保存修改' : '创建用户' }}</el-button></div></template>
|
||||
</el-drawer>
|
||||
<el-drawer v-model="roleEditorOpen" :title="roleAssignmentUser ? `分配角色:${roleAssignmentUser.displayName}` : '分配角色'" size="520px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form"><div class="form-section"><h3>平台角色</h3><p class="secondary-text">保存后按后端契约全量替换当前用户的角色;不选择角色表示清空。</p><el-form-item label="角色"><el-select v-model="assignedRoleIds" multiple collapse-tags collapse-tags-tooltip placeholder="选择用户角色"><el-option v-for="role in roleOptions" :key="role.id" :label="role.roleName" :value="String(role.id)" :disabled="role.status !== 'ACTIVE'" /></el-select></el-form-item></div></el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="assigningRoles" @click="roleEditorOpen = false">取消</el-button><el-button type="primary" :loading="assigningRoles" @click="saveRoleAssignment">保存角色</el-button></div></template>
|
||||
</el-drawer>
|
||||
<template #footer>
|
||||
<el-button :disabled="saving" @click="editorOpen = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="saveUser">{{ editingUser ? '保存修改' : '确认添加' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.query-toolbar { display: grid; grid-template-columns: minmax(240px, 1fr) 220px 160px auto; gap: 12px; padding: 0 20px 16px; }
|
||||
.password-fields { margin-top: 16px; }
|
||||
@media (max-width: 720px) {
|
||||
.query-toolbar, .form-grid.two-cols { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,9 @@ import PartnersView from './PartnersView.vue'
|
||||
vi.mock('@/api/platformPartners', () => ({
|
||||
listPlatformPartners: vi.fn(),
|
||||
createPlatformPartner: vi.fn(),
|
||||
getPlatformPartner: vi.fn(),
|
||||
downloadPlatformPartnerImportTemplate: vi.fn(),
|
||||
importPlatformPartners: vi.fn(),
|
||||
updatePlatformPartner: vi.fn(),
|
||||
changePlatformPartnerStatus: vi.fn(),
|
||||
}))
|
||||
@@ -38,9 +41,11 @@ function createMemoryStorage(): Storage {
|
||||
const partner: PlatformPartnerResource = {
|
||||
id: '19001',
|
||||
tenantId: 10001,
|
||||
dataScopeId: 10001,
|
||||
partnerCode: 'CUS-001',
|
||||
partnerName: '深圳示例贸易有限公司',
|
||||
partnerType: 'CUSTOMER',
|
||||
types: ['CUSTOMER', 'SHIPPER'],
|
||||
contactName: '张三',
|
||||
contactPhone: '13800138000',
|
||||
contactEmail: 'contact@example.com',
|
||||
@@ -51,6 +56,7 @@ const partner: PlatformPartnerResource = {
|
||||
addressLine: '航城街道示例路 1 号',
|
||||
postalCode: '518000',
|
||||
status: 'ACTIVE',
|
||||
remark: '长期合作单位',
|
||||
updatedAt: '2026-07-30T22:45:00',
|
||||
}
|
||||
|
||||
|
||||
+191
-13
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Building2, MapPin, Pencil, Plus, Power } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile } from 'element-plus'
|
||||
import { Building2, Download, Eye, MapPin, Pencil, Plus, Power, Upload } from '@lucide/vue'
|
||||
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
|
||||
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
|
||||
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
|
||||
@@ -10,10 +10,15 @@ import { getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
|
||||
import {
|
||||
changePlatformPartnerStatus,
|
||||
createPlatformPartner,
|
||||
downloadPlatformPartnerImportTemplate,
|
||||
getPlatformPartner,
|
||||
importPlatformPartners,
|
||||
listPlatformPartners,
|
||||
updatePlatformPartner,
|
||||
type PlatformPartnerImportResult,
|
||||
type PlatformPartnerResource,
|
||||
type PlatformPartnerStatus,
|
||||
type PlatformPartnerLegacyType,
|
||||
type PlatformPartnerType,
|
||||
type SavePlatformPartnerRequest,
|
||||
} from '@/api/platformPartners'
|
||||
@@ -25,6 +30,14 @@ const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const editorOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref<PlatformPartnerResource | null>(null)
|
||||
const importOpen = ref(false)
|
||||
const importFile = ref<File | null>(null)
|
||||
const importing = ref(false)
|
||||
const downloadingTemplate = ref(false)
|
||||
const importResult = ref<PlatformPartnerImportResult | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const rows = ref<PlatformPartnerResource[]>([])
|
||||
const page = ref(1)
|
||||
@@ -53,7 +66,10 @@ const queryFields: QueryField[] = [
|
||||
options: [
|
||||
{ label: '客户', value: 'CUSTOMER' },
|
||||
{ label: '供应商', value: 'SUPPLIER' },
|
||||
{ label: '客户 / 供应商', value: 'BOTH' },
|
||||
{ label: '承运商', value: 'CARRIER' },
|
||||
{ label: '报关行', value: 'CUSTOMS_BROKER' },
|
||||
{ label: '发货单位', value: 'SHIPPER' },
|
||||
{ label: '收货单位', value: 'CONSIGNEE' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -76,6 +92,7 @@ const emptyForm = (): PartnerForm => ({
|
||||
partnerCode: '',
|
||||
partnerName: '',
|
||||
partnerType: 'CUSTOMER',
|
||||
types: ['CUSTOMER'],
|
||||
contactName: '',
|
||||
contactPhone: '',
|
||||
contactEmail: null,
|
||||
@@ -86,6 +103,7 @@ const emptyForm = (): PartnerForm => ({
|
||||
addressLine: '',
|
||||
postalCode: null,
|
||||
status: 'ACTIVE',
|
||||
remark: null,
|
||||
})
|
||||
const form = reactive<PartnerForm>(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑往来单位' : '新增往来单位')
|
||||
@@ -101,7 +119,7 @@ const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
const rules: FormRules<PartnerForm> = {
|
||||
partnerCode: [{ required: true, whitespace: true, message: '请输入往来单位编码', trigger: 'blur' }],
|
||||
partnerName: [{ required: true, whitespace: true, message: '请输入往来单位名称', trigger: 'blur' }],
|
||||
partnerType: [{ required: true, message: '请选择单位类型', trigger: 'change' }],
|
||||
types: [{ required: true, type: 'array', min: 1, message: '请至少选择一个单位类型', trigger: 'change' }],
|
||||
contactName: [{ required: true, whitespace: true, message: '请输入联系人', trigger: 'blur' }],
|
||||
contactPhone: [{ required: true, whitespace: true, message: '请输入联系电话', trigger: 'blur' }],
|
||||
contactEmail: [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
|
||||
@@ -117,10 +135,17 @@ const rules: FormRules<PartnerForm> = {
|
||||
const typeLabels: Record<PlatformPartnerType, string> = {
|
||||
CUSTOMER: '客户',
|
||||
SUPPLIER: '供应商',
|
||||
BOTH: '客户 / 供应商',
|
||||
CARRIER: '承运商',
|
||||
CUSTOMS_BROKER: '报关行',
|
||||
SHIPPER: '发货单位',
|
||||
CONSIGNEE: '收货单位',
|
||||
}
|
||||
const typeOptions = Object.entries(typeLabels).map(([value, label]) => ({ value: value as PlatformPartnerType, label }))
|
||||
function partnerTypeLabel(type: PlatformPartnerLegacyType) {
|
||||
return type === 'BOTH' ? '客户 / 供应商' : typeLabels[type]
|
||||
}
|
||||
const partnerColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', width: 150, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'actions', label: '操作', width: 210, fixed: 'left', slotName: 'actions' },
|
||||
{ key: 'partner', label: '往来单位', minWidth: 210, slotName: 'partner' },
|
||||
{ key: 'type', label: '类型', minWidth: 115, slotName: 'type' },
|
||||
{ key: 'contact', label: '联系人', minWidth: 165, slotName: 'contact' },
|
||||
@@ -192,7 +217,8 @@ function openEdit(row: PlatformPartnerResource) {
|
||||
id: String(row.id),
|
||||
partnerCode: row.partnerCode,
|
||||
partnerName: row.partnerName,
|
||||
partnerType: row.partnerType,
|
||||
partnerType: row.types[0],
|
||||
types: [...row.types],
|
||||
contactName: row.contactName,
|
||||
contactPhone: row.contactPhone,
|
||||
contactEmail: row.contactEmail,
|
||||
@@ -203,6 +229,7 @@ function openEdit(row: PlatformPartnerResource) {
|
||||
addressLine: row.addressLine,
|
||||
postalCode: row.postalCode,
|
||||
status: row.status,
|
||||
remark: row.remark,
|
||||
})
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
@@ -210,10 +237,12 @@ function openEdit(row: PlatformPartnerResource) {
|
||||
}
|
||||
|
||||
function savePayload(): SavePlatformPartnerRequest {
|
||||
const types = [...form.types]
|
||||
return {
|
||||
partnerCode: form.partnerCode.trim(),
|
||||
partnerName: form.partnerName.trim(),
|
||||
partnerType: form.partnerType,
|
||||
partnerType: types[0],
|
||||
types,
|
||||
contactName: form.contactName.trim(),
|
||||
contactPhone: form.contactPhone.trim(),
|
||||
contactEmail: nullable(form.contactEmail),
|
||||
@@ -224,9 +253,94 @@ function savePayload(): SavePlatformPartnerRequest {
|
||||
addressLine: form.addressLine.trim(),
|
||||
postalCode: nullable(form.postalCode),
|
||||
status: form.status,
|
||||
remark: nullable(form.remark),
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row: PlatformPartnerResource) {
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
try {
|
||||
detail.value = await getPlatformPartner(fixedTenantId, row.id)
|
||||
} catch (error) {
|
||||
const meta = getApiErrorDetails(error, '往来单位详情加载失败')
|
||||
ElMessage.error(meta.requestId ? `${meta.message}(requestId: ${meta.requestId})` : meta.message)
|
||||
detailOpen.value = false
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openImport() {
|
||||
importFile.value = null
|
||||
importResult.value = null
|
||||
importOpen.value = true
|
||||
}
|
||||
|
||||
function selectImportFile(uploadFile: UploadFile) {
|
||||
importFile.value = uploadFile.raw ?? null
|
||||
importResult.value = null
|
||||
}
|
||||
|
||||
function removeImportFile() {
|
||||
importFile.value = null
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
downloadingTemplate.value = true
|
||||
try {
|
||||
const blob = await downloadPlatformPartnerImportTemplate()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = '往来单位导入模板-v1.xlsx'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorDetails(error, '模板下载失败,请重试').message)
|
||||
} finally {
|
||||
downloadingTemplate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitImport() {
|
||||
if (!importFile.value) {
|
||||
ElMessage.warning('请先选择 XLSX 文件')
|
||||
return
|
||||
}
|
||||
importing.value = true
|
||||
importResult.value = null
|
||||
try {
|
||||
importResult.value = await importPlatformPartners(fixedTenantId, importFile.value)
|
||||
if (importResult.value.success) {
|
||||
ElMessage.success(`成功导入 ${importResult.value.importedRows} 条往来单位`)
|
||||
await loadPartners()
|
||||
}
|
||||
} catch (error) {
|
||||
const meta = getApiErrorDetails(error, '往来单位导入失败,请重试')
|
||||
ElMessage.error(meta.requestId ? `${meta.message}(requestId: ${meta.requestId})` : meta.message)
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadImportErrors() {
|
||||
if (!importResult.value?.errors.length) return
|
||||
const escapeCsv = (value: string | number) => `"${String(value).replaceAll('"', '""')}"`
|
||||
const rows = [
|
||||
['行号', '字段', '原因'],
|
||||
...importResult.value.errors.map((error) => [error.row || '-', error.field, error.message]),
|
||||
]
|
||||
const csv = rows.map((row) => row.map(escapeCsv).join(',')).join('\r\n')
|
||||
const url = URL.createObjectURL(new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' }))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${importResult.value.fileName.replace(/\.xlsx$/i, '')}-错误报告.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
@@ -306,7 +420,10 @@ onMounted(loadPartners)
|
||||
<h2>往来单位列表</h2>
|
||||
<span>共 {{ total }} 个往来单位</span>
|
||||
</div>
|
||||
<el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button>
|
||||
<div class="partners-primary-actions">
|
||||
<el-button v-if="access.can('platform:counterparty:import')" :disabled="loading" @click="openImport"><Upload :size="16" />批量导入</el-button>
|
||||
<el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
v-model:page="page"
|
||||
@@ -321,14 +438,14 @@ onMounted(loadPartners)
|
||||
empty-text="暂无符合条件的往来单位"
|
||||
>
|
||||
<template #actions="{ row }">
|
||||
<div v-if="access.can('platform:counterparty:edit') || access.can('platform:counterparty:status')" class="table-actions">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" @click="openDetail(row as PlatformPartnerResource)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="access.can('platform:counterparty:edit')" link type="primary" @click="openEdit(row as PlatformPartnerResource)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="access.can('platform:counterparty:status')" link :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusLoadingIds.has(String((row as PlatformPartnerResource).id))" @click="toggleStatus(row as PlatformPartnerResource)"><Power :size="15" />{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
|
||||
</div>
|
||||
<span v-else class="partner-subline">仅查看</span>
|
||||
</template>
|
||||
<template #partner="{ row }"><div class="partner-cell"><span><Building2 :size="18" /></span><div><strong>{{ (row as PlatformPartnerResource).partnerName }}</strong><small>{{ (row as PlatformPartnerResource).partnerCode }}</small></div></div></template>
|
||||
<template #type="{ row }"><el-tag effect="plain">{{ typeLabels[(row as PlatformPartnerResource).partnerType] }}</el-tag></template>
|
||||
<template #type="{ row }"><div class="partner-types"><el-tag v-for="type in (row as PlatformPartnerResource).types" :key="type" effect="plain">{{ partnerTypeLabel(type) }}</el-tag></div></template>
|
||||
<template #contact="{ row }"><strong>{{ (row as PlatformPartnerResource).contactName }}</strong><span class="partner-subline">{{ (row as PlatformPartnerResource).contactPhone }}</span><span v-if="(row as PlatformPartnerResource).contactEmail" class="partner-subline">{{ (row as PlatformPartnerResource).contactEmail }}</span></template>
|
||||
<template #address="{ row }"><div class="partner-address"><MapPin :size="15" /><span>{{ [(row as PlatformPartnerResource).province, (row as PlatformPartnerResource).city, (row as PlatformPartnerResource).district, (row as PlatformPartnerResource).addressLine].filter(Boolean).join(' ') }}</span></div></template>
|
||||
<template #status="{ row }"><el-tag :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
|
||||
@@ -341,7 +458,7 @@ onMounted(loadPartners)
|
||||
<div class="form-section"><h3>单位信息</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="单位编码" prop="partnerCode" required><el-input v-model="form.partnerCode" placeholder="如:CUS-001" maxlength="50" /></el-form-item>
|
||||
<el-form-item label="单位名称" prop="partnerName" required><el-input v-model="form.partnerName" placeholder="请输入工商登记或常用单位名称" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="单位类型" prop="partnerType" required><el-select v-model="form.partnerType"><el-option label="客户" value="CUSTOMER" /><el-option label="供应商" value="SUPPLIER" /><el-option label="客户 / 供应商" value="BOTH" /></el-select></el-form-item>
|
||||
<el-form-item label="单位类型" prop="types" required><el-select v-model="form.types" multiple collapse-tags collapse-tags-tooltip placeholder="请选择一个或多个单位类型"><el-option v-for="option in typeOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select></el-form-item>
|
||||
<el-form-item label="状态" prop="status" required><el-radio-group v-model="form.status"><el-radio-button value="ACTIVE">启用</el-radio-button><el-radio-button value="INACTIVE">停用</el-radio-button></el-radio-group></el-form-item>
|
||||
</div></div>
|
||||
<div class="form-section"><h3>联系信息</h3><div class="form-grid two-cols">
|
||||
@@ -356,9 +473,50 @@ onMounted(loadPartners)
|
||||
<el-form-item label="区 / 县" prop="district"><el-input v-model="form.district" placeholder="如:宝安区" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="邮政编码" prop="postalCode"><el-input v-model="form.postalCode" placeholder="如:518000" maxlength="20" /></el-form-item>
|
||||
</div><el-form-item label="详细地址" prop="addressLine" required><el-input v-model="form.addressLine" type="textarea" :rows="3" placeholder="请输入街道、园区、楼栋及门牌信息" maxlength="255" show-word-limit /></el-form-item></div>
|
||||
<div class="form-section"><h3>其他信息</h3><el-form-item label="备注" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入内部说明(选填)" maxlength="500" show-word-limit /></el-form-item></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="savePartner">保存往来单位</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="往来单位详情" size="680px" class="business-drawer detail-drawer">
|
||||
<div v-loading="detailLoading" class="partner-detail">
|
||||
<template v-if="detail">
|
||||
<section><h3>单位信息</h3><dl class="detail-grid">
|
||||
<div><dt>单位编码</dt><dd>{{ detail.partnerCode }}</dd></div>
|
||||
<div><dt>单位名称</dt><dd>{{ detail.partnerName }}</dd></div>
|
||||
<div><dt>状态</dt><dd><el-tag :type="detail.status === 'ACTIVE' ? 'success' : 'info'">{{ detail.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></dd></div>
|
||||
<div><dt>单位类型</dt><dd class="partner-types"><el-tag v-for="type in detail.types" :key="type" effect="plain">{{ partnerTypeLabel(type) }}</el-tag></dd></div>
|
||||
</dl></section>
|
||||
<section><h3>联系与地址</h3><dl class="detail-grid">
|
||||
<div><dt>联系人</dt><dd>{{ detail.contactName }}</dd></div><div><dt>联系电话</dt><dd>{{ detail.contactPhone }}</dd></div>
|
||||
<div><dt>联系邮箱</dt><dd>{{ detail.contactEmail || '-' }}</dd></div><div><dt>邮政编码</dt><dd>{{ detail.postalCode || '-' }}</dd></div>
|
||||
<div class="detail-span"><dt>详细地址</dt><dd>{{ [detail.countryCode, detail.province, detail.city, detail.district, detail.addressLine].filter(Boolean).join(' ') }}</dd></div>
|
||||
</dl></section>
|
||||
<section><h3>其他信息</h3><dl class="detail-grid">
|
||||
<div><dt>数据范围</dt><dd>{{ detail.dataScopeId }}</dd></div><div><dt>更新时间</dt><dd>{{ formatUpdatedAt(detail.updatedAt) }}</dd></div>
|
||||
<div class="detail-span"><dt>备注</dt><dd>{{ detail.remark || '-' }}</dd></div>
|
||||
</dl></section>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="importOpen" title="批量导入往来单位" width="min(720px, calc(100vw - 32px))" :close-on-click-modal="!importing" :close-on-press-escape="!importing">
|
||||
<div class="import-content">
|
||||
<div class="import-guide"><strong>使用标准模板导入</strong><p>仅新增档案,最多 1000 行。服务端会整批校验;任一行有误时不会写入任何数据。</p><el-button :loading="downloadingTemplate" @click="downloadTemplate"><Download :size="16" />下载 XLSX 模板</el-button></div>
|
||||
<el-upload accept=".xlsx" :auto-upload="false" :limit="1" :on-change="selectImportFile" :on-remove="removeImportFile">
|
||||
<el-button><Upload :size="16" />选择 XLSX 文件</el-button>
|
||||
<template #tip><div class="el-upload__tip">文件不得超过 5MB,请勿修改模板版本或表头。</div></template>
|
||||
</el-upload>
|
||||
<div v-if="importResult" :class="['import-result', importResult.success ? 'is-success' : 'is-error']" role="status">
|
||||
<div class="import-result-heading"><strong>{{ importResult.success ? `已成功导入 ${importResult.importedRows} 条记录` : `发现 ${importResult.errors.length} 个问题,未写入数据` }}</strong><el-button v-if="importResult.errors.length" size="small" @click="downloadImportErrors"><Download :size="15" />下载错误报告</el-button></div>
|
||||
<el-table v-if="importResult.errors.length" :data="importResult.errors" max-height="260" size="small">
|
||||
<el-table-column prop="row" label="行号" width="70"><template #default="{ row }">{{ row.row || '-' }}</template></el-table-column>
|
||||
<el-table-column prop="field" label="字段" width="120" /><el-table-column prop="message" label="原因" min-width="240" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer><el-button :disabled="importing" @click="importOpen = false">关闭</el-button><el-button type="primary" :loading="importing" :disabled="!importFile" @click="submitImport">开始导入</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -370,6 +528,7 @@ onMounted(loadPartners)
|
||||
.partners-table-heading { display: flex; min-width: 0; align-items: baseline; gap: 10px; }
|
||||
.partners-table-heading h2 { margin: 0; color: var(--cc-text); font-size: 16px; line-height: 1.4; }
|
||||
.partners-table-heading span { color: var(--cc-muted); font-size: 12px; white-space: nowrap; }
|
||||
.partners-primary-actions, .partner-types { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.partner-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.partner-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
|
||||
.partner-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
@@ -377,11 +536,30 @@ onMounted(loadPartners)
|
||||
.partner-cell small, .partner-subline { display: block; color: var(--cc-muted); font-size: 11px; line-height: 1.45; overflow-wrap: anywhere; }
|
||||
.partner-address { display: flex; align-items: flex-start; gap: 7px; line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.partner-address svg { flex: 0 0 15px; margin-top: 3px; color: var(--cc-muted); }
|
||||
.partner-detail { min-height: 180px; }
|
||||
.partner-detail section + section { margin-top: 24px; }
|
||||
.partner-detail h3 { margin: 0 0 12px; color: var(--cc-text); font-size: 15px; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; margin: 0; overflow: hidden; border: 1px solid var(--cc-border); border-radius: 8px; background: var(--cc-border); }
|
||||
.detail-grid > div { min-width: 0; padding: 12px 14px; background: #fff; }
|
||||
.detail-grid .detail-span { grid-column: 1 / -1; }
|
||||
.detail-grid dt { margin-bottom: 5px; color: var(--cc-muted); font-size: 12px; }
|
||||
.detail-grid dd { margin: 0; color: var(--cc-text); line-height: 1.55; overflow-wrap: anywhere; }
|
||||
.import-content { display: grid; gap: 18px; }
|
||||
.import-guide { padding: 16px; border: 1px solid #d8e2f0; border-radius: 8px; background: #f8fbff; }
|
||||
.import-guide p { margin: 6px 0 14px; color: var(--cc-muted); font-size: 13px; line-height: 1.6; }
|
||||
.import-result { display: grid; gap: 12px; padding: 14px; border: 1px solid; border-radius: 8px; }
|
||||
.import-result-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.import-result.is-success { border-color: #a6f4c5; background: #f6fef9; color: #067647; }
|
||||
.import-result.is-error { border-color: #fecdca; background: #fffafa; color: #b42318; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.partners-error { align-items: stretch; flex-direction: column; }
|
||||
.partners-error .el-button { min-height: 44px; }
|
||||
.partners-table-header { align-items: stretch; flex-direction: column; }
|
||||
.partners-table-header .el-button { width: 100%; min-height: 40px; }
|
||||
.partners-primary-actions { flex-direction: column; }
|
||||
.detail-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.detail-grid .detail-span { grid-column: auto; }
|
||||
.import-result-heading { align-items: stretch; flex-direction: column; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user