This commit is contained in:
2026-08-03 14:58:17 +08:00
parent bc44a7942d
commit ee997ae894
17 changed files with 1694 additions and 303 deletions
+26
View File
@@ -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 } })
})
})
+29
View File
@@ -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
View File
@@ -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)
+169
View File
@@ -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 })
}
+32
View File
@@ -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 } })
})
})
+58 -2
View File
@@ -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
View File
@@ -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',
+83
View File
@@ -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
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') }
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>
+33 -6
View File
@@ -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'))
+14 -3
View File
@@ -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; }
+212 -5
View File
@@ -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>
+123
View File
@@ -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
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -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>