feat: 完成后台联调与通用列表组件

This commit is contained in:
2026-08-01 12:17:33 +08:00
parent b53fe81999
commit 29dbae573f
50 changed files with 3394 additions and 580 deletions
+7
View File
@@ -18,6 +18,13 @@ afterEach(() => {
})
describe('API error diagnostics', () => {
it('treats only body code 200 as success', () => {
expect(unwrapApiResponse({ code: '200', message: 'success', data: { id: '1' } }))
.toEqual({ id: '1' })
expect(() => unwrapApiResponse({ code: '0', message: 'legacy success code', data: null }))
.toThrow(ApiResponseError)
})
it('keeps business request and trace identifiers when unwrapping fails', () => {
expect(() => unwrapApiResponse({
code: 'A0403',
+1 -1
View File
@@ -98,7 +98,7 @@ http.interceptors.response.use(
)
export function unwrapApiResponse<T>(response: ApiEnvelope<T>): T {
if (response?.code !== '0') {
if (response?.code !== '200') {
throw new ApiResponseError(
response?.message || '接口请求失败',
response?.code || 'UNKNOWN',
+74
View File
@@ -0,0 +1,74 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/api/http'
import {
changePlatformExpressCompanyStatus,
createPlatformExpressCompany,
getPlatformExpressCompany,
listPlatformExpressCompanies,
updatePlatformExpressCompany,
type SavePlatformExpressCompanyRequest,
} from './platformExpressCompanies'
vi.mock('@/api/http', () => ({
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const payload: SavePlatformExpressCompanyRequest = {
companyCode: 'SF',
companyName: '顺丰速运',
servicePhone: '95338',
website: 'https://www.sf-express.com',
trackingUrlTemplate: 'https://example.com/track/{trackingNo}',
status: 'ACTIVE',
remark: null,
}
describe('platform express company API', () => {
beforeEach(() => {
vi.mocked(http.get).mockReset()
vi.mocked(http.post).mockReset()
vi.mocked(http.put).mockReset()
vi.mocked(http.patch).mockReset()
})
it('uses the implemented list and detail contracts', async () => {
const page = { records: [], page: '1', size: '20', total: '0', pages: '0' }
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '200', message: 'success', data: page })
.mockResolvedValueOnce({ code: '200', message: 'success', data: { id: '19001' } })
await expect(listPlatformExpressCompanies(10001, 2, 20, ' 顺丰 ', 'ACTIVE')).resolves.toEqual({
records: [],
page: 1,
size: 20,
total: 0,
pages: 0,
})
await expect(getPlatformExpressCompany(10001, '19001')).resolves.toEqual({ id: '19001' })
expect(http.get).toHaveBeenNthCalledWith(1, '/platform/v1/express-companies', {
params: { tenantId: 10001, page: 2, size: 20, keyword: '顺丰', status: 'ACTIVE' },
})
expect(http.get).toHaveBeenNthCalledWith(2, '/platform/v1/express-companies/19001', {
params: { tenantId: 10001 },
})
})
it('uses the implemented create, update, and status contracts', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: '19001' })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null })
await expect(createPlatformExpressCompany(10001, payload)).resolves.toBe('19001')
await expect(updatePlatformExpressCompany(10001, '19001', payload)).resolves.toBeNull()
await expect(changePlatformExpressCompanyStatus(10001, '19001', 'INACTIVE')).resolves.toBeNull()
expect(http.post).toHaveBeenCalledWith('/platform/v1/express-companies', payload, { params: { tenantId: 10001 } })
expect(http.put).toHaveBeenCalledWith('/platform/v1/express-companies/19001', payload, { params: { tenantId: 10001 } })
expect(http.patch).toHaveBeenCalledWith(
'/platform/v1/express-companies/19001/status',
{ status: 'INACTIVE' },
{ params: { tenantId: 10001 } },
)
})
})
+100
View File
@@ -0,0 +1,100 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/api/http'
export type PlatformExpressCompanyStatus = 'ACTIVE' | 'INACTIVE'
export interface PlatformExpressCompanyResource {
id: number | string
tenantId: number | string
companyCode: string
companyName: string
servicePhone: string | null
website: string | null
trackingUrlTemplate: string | null
status: PlatformExpressCompanyStatus
remark: string | null
createdAt?: string | null
updatedAt?: string | null
}
export interface PlatformExpressCompanyPage {
records: PlatformExpressCompanyResource[]
page: number
size: number
total: number
pages: number
}
export interface SavePlatformExpressCompanyRequest {
companyCode: string
companyName: string
servicePhone: string | null
website: string | null
trackingUrlTemplate: string | null
status: PlatformExpressCompanyStatus
remark: string | null
}
const basePath = '/platform/v1/express-companies'
export async function listPlatformExpressCompanies(
tenantId: number,
page = 1,
size = 20,
keyword = '',
status: PlatformExpressCompanyStatus | '' = '',
) {
const response = await http.get<ApiEnvelope<PlatformExpressCompanyPage>, ApiEnvelope<PlatformExpressCompanyPage>>(basePath, {
params: {
tenantId,
page,
size,
keyword: keyword.trim() || undefined,
status: status || undefined,
},
})
const result = unwrapApiResponse(response)
return {
...result,
page: Number(result.page),
size: Number(result.size),
total: Number(result.total),
pages: Number(result.pages),
}
}
export async function getPlatformExpressCompany(tenantId: number, id: number | string) {
const response = await http.get<ApiEnvelope<PlatformExpressCompanyResource>, ApiEnvelope<PlatformExpressCompanyResource>>(
`${basePath}/${id}`,
{ params: { tenantId } },
)
return unwrapApiResponse(response)
}
export async function createPlatformExpressCompany(tenantId: number, payload: SavePlatformExpressCompanyRequest) {
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload, {
params: { tenantId },
})
return unwrapApiResponse(response)
}
export async function updatePlatformExpressCompany(
tenantId: number,
id: number | string,
payload: SavePlatformExpressCompanyRequest,
) {
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}`, payload, {
params: { tenantId },
})
return unwrapApiResponse(response)
}
export async function changePlatformExpressCompanyStatus(
tenantId: number,
id: number | string,
status: PlatformExpressCompanyStatus,
) {
const response = await http.patch<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${id}/status`, { status }, {
params: { tenantId },
})
return unwrapApiResponse(response)
}
+4 -4
View File
@@ -26,7 +26,7 @@ describe('platform first-leg order API', () => {
it('queries a tenant-scoped order page with server pagination and filters', async () => {
const page = { records: [], page: 2, size: 20, total: 0, pages: 0 }
vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: page })
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: page })
await expect(listPlatformFirstLegOrders('10001', {
keyword: 'FL2026', status: 'SUBMITTED', createdFrom: '2026-07-01T00:00:00', createdTo: '2026-07-31T23:59:59', page: 2, size: 20,
@@ -39,8 +39,8 @@ describe('platform first-leg order API', () => {
})
it('uses the platform lifecycle routes and target tenant query parameter', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: { order: {} } })
vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: null })
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { order: {} } })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: null })
await getPlatformFirstLegOrder('10001', '90001')
await approvePlatformFirstLegOrder('10001', '90001')
@@ -60,7 +60,7 @@ describe('platform first-leg order API', () => {
})
it('writes the backend express contract as names, not a guessed master-data ID', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '0', message: 'success', data: null })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
await updatePlatformFirstLegOrderExpress('10001', '90001', { expressCompany: '顺丰速运', expressTrackingNo: 'SF123456' })
expect(http.put).toHaveBeenCalledWith(
+81 -8
View File
@@ -1,19 +1,29 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/api/http'
import {
assignPlatformInternalUserRoles,
changePlatformInternalUserStatus,
changePlatformPermissionStatus,
changePlatformRoleStatus,
createPlatformPermission,
createPlatformRole,
deletePlatformPermission,
deletePlatformRole,
downloadTenantQualification,
listPlatformTenants,
listTenantQualifications,
listCurrentPlatformPermissionCodes,
listPlatformPermissions,
listPlatformRoles,
listPlatformInternalUsers,
qualificationFileName,
replacePlatformRolePermissions,
resetPlatformInternalUserPassword,
updatePlatformInternalUser,
updatePlatformPermission,
updatePlatformRole,
type PlatformPermissionResource,
type PlatformInternalUser,
type PlatformRoleResource,
} from './platformIam'
@@ -46,6 +56,21 @@ const permission: PlatformPermissionResource = {
httpMethod: null,
sortNo: 10,
status: 'ACTIVE',
builtIn: false,
}
const internalUser: PlatformInternalUser = {
membershipId: 3000001,
userId: 2000001,
authUserId: 2000001,
username: 'platform_admin',
displayName: '平台管理员',
phone: '13800138000',
email: 'admin@example.com',
memberType: 'INTERNAL',
memberNo: 'PLATFORM-0001',
status: 'ACTIVE',
roleIds: [4000001],
}
describe('platform IAM roles', () => {
@@ -57,7 +82,7 @@ describe('platform IAM roles', () => {
})
it('loads roles from the platform IAM prefix', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [role] })
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [role] })
await expect(listPlatformRoles()).resolves.toEqual([role])
expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/roles')
@@ -69,15 +94,15 @@ describe('platform IAM roles', () => {
roleName: '企业审核员',
description: '企业资料与入驻审核',
}
vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: role })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: role })
await expect(createPlatformRole(payload)).resolves.toEqual(role)
expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/roles', payload)
})
it('updates status, permissions, and deletes roles with the implemented IAM routes', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '0', message: 'success', data: role })
vi.mocked(http.delete).mockResolvedValue({ code: '0', message: 'success', data: null })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: role })
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
await expect(updatePlatformRole(role.id, { roleName: '审核主管', description: '负责企业审核' })).resolves.toEqual(role)
await expect(changePlatformRoleStatus(role.id, 'DISABLED')).resolves.toEqual(role)
@@ -91,8 +116,8 @@ describe('platform IAM roles', () => {
})
it('loads and creates permission resources under the platform IAM prefix', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [permission] })
vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: permission })
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [permission] })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: permission })
const payload = {
type: 'MENU' as const,
code: permission.permissionCode,
@@ -109,8 +134,46 @@ describe('platform IAM roles', () => {
expect(http.post).toHaveBeenCalledWith('/platform/v1/iam/permissions', payload)
})
it('updates, changes status, and deletes custom permission resources with IAM routes', async () => {
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: permission })
vi.mocked(http.delete).mockResolvedValue({ code: '200', message: 'success', data: null })
const payload = { name: '企业客户', parentId: permission.parentId, routePath: '/customers', httpMethod: null, sortNo: 10 }
await expect(updatePlatformPermission(permission.id, payload)).resolves.toEqual(permission)
await expect(changePlatformPermissionStatus(permission.id, 'DISABLED')).resolves.toEqual(permission)
await expect(deletePlatformPermission(permission.id)).resolves.toBeNull()
expect(http.put).toHaveBeenNthCalledWith(1, `/platform/v1/iam/permissions/${permission.id}`, payload)
expect(http.put).toHaveBeenNthCalledWith(2, `/platform/v1/iam/permissions/${permission.id}/status`, { status: 'DISABLED' })
expect(http.delete).toHaveBeenCalledWith(`/platform/v1/iam/permissions/${permission.id}`)
})
it('maintains internal users through their public membership ID', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [internalUser] })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: internalUser })
const update = {
displayName: '平台管理员',
phone: '13800138000',
email: 'admin@example.com',
memberNo: 'PLATFORM-0001',
roleIds: [4000001],
}
await expect(listPlatformInternalUsers()).resolves.toEqual([internalUser])
await expect(updatePlatformInternalUser(internalUser.membershipId, update)).resolves.toEqual(internalUser)
await expect(changePlatformInternalUserStatus(internalUser.membershipId, 'DISABLED')).resolves.toEqual(internalUser)
await expect(resetPlatformInternalUserPassword(internalUser.membershipId, 'ChangeMe123')).resolves.toEqual(internalUser)
await expect(assignPlatformInternalUserRoles(internalUser.membershipId, [4000001])).resolves.toEqual(internalUser)
expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/internal-users')
expect(http.put).toHaveBeenNthCalledWith(1, `/platform/v1/iam/internal-users/${internalUser.membershipId}`, update)
expect(http.put).toHaveBeenNthCalledWith(2, `/platform/v1/iam/internal-users/${internalUser.membershipId}/status`, { status: 'DISABLED' })
expect(http.put).toHaveBeenNthCalledWith(3, `/platform/v1/iam/internal-users/${internalUser.membershipId}/password`, { newPassword: 'ChangeMe123' })
expect(http.put).toHaveBeenNthCalledWith(4, `/platform/v1/iam/internal-users/${internalUser.membershipId}/roles`, { roleIds: [4000001] })
})
it('loads the current users permission snapshot without calling the role-management endpoint', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: ['platform:warehouse:list'] })
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: ['platform:warehouse:list'] })
await expect(listCurrentPlatformPermissionCodes()).resolves.toEqual(['platform:warehouse:list'])
expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/authorization/permissions')
@@ -126,12 +189,22 @@ describe('platform IAM roles', () => {
status: 'PENDING' as const,
reviewComment: null,
}
vi.mocked(http.get).mockResolvedValue({ code: '0', message: 'success', data: [qualification] })
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: [qualification] })
await expect(listTenantQualifications(8)).resolves.toEqual([qualification])
expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/tenants/8/qualifications')
})
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 })
await expect(listPlatformTenants({ page: 2, size: 10, keyword: '柏悦', status: 'ACTIVE' })).resolves.toEqual(page)
expect(http.get).toHaveBeenCalledWith('/platform/v1/iam/tenants', {
params: { page: 2, size: 10, keyword: '柏悦', status: 'ACTIVE' },
})
})
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)
+94 -1
View File
@@ -11,7 +11,8 @@ export interface PlatformPermissionResource {
routePath: string | null
httpMethod: string | null
sortNo: number
status: 'ACTIVE' | 'INACTIVE'
status: 'ACTIVE' | 'DISABLED' | 'INACTIVE'
builtIn?: boolean
}
export interface EnterpriseReview {
@@ -33,6 +34,35 @@ export interface EnterpriseReview {
[key: string]: unknown
}
export type PlatformTenantStatus = 'PENDING' | 'ACTIVE' | 'REJECTED' | 'INACTIVE' | string
export interface PlatformTenantResource {
id: number | string
tenantCode: string
tenantName: string
status: PlatformTenantStatus
legalName: string | null
unifiedSocialCreditCode: string | null
contactName: string | null
contactPhone: string | null
contactEmail: string | null
}
export interface PlatformTenantPage {
records: PlatformTenantResource[]
page: number
size: number
total: number
pages: number
}
export interface PlatformTenantQuery {
page: number
size: number
keyword?: string
status?: PlatformTenantStatus
}
export interface PlatformTenantQualification {
id: number | string
tenantId: number | string
@@ -76,12 +106,22 @@ export interface CreatePlatformPermissionRequest {
sortNo: number
}
export interface UpdatePlatformPermissionRequest {
name: string
parentId: number | string | null
routePath: string | null
httpMethod: string | null
sortNo: number
}
export interface PlatformInternalUser {
membershipId: number | string
userId: number | string
authUserId: number | string
username: string
displayName: string
phone: string | null
email: string | null
memberType: string
memberNo: string | null
status: 'ACTIVE' | 'DISABLED' | string
@@ -94,6 +134,16 @@ export interface CreatePlatformInternalUserRequest {
displayName: string
phone: string
email: string
memberNo?: string
roleIds?: Array<number | string>
}
export interface UpdatePlatformInternalUserRequest {
displayName: string
phone: string
email: string
memberNo: string
roleIds: Array<number | string>
}
async function getData<T>(path: string): Promise<T> {
@@ -128,6 +178,21 @@ export function listEnterpriseReviews() {
return getData<EnterpriseReview[]>('/platform/v1/iam/reviews')
}
export async function listPlatformTenants(query: PlatformTenantQuery) {
const response = await http.get<ApiEnvelope<PlatformTenantPage>, ApiEnvelope<PlatformTenantPage>>(
'/platform/v1/iam/tenants',
{
params: {
page: query.page,
size: query.size,
...(query.keyword ? { keyword: query.keyword } : {}),
...(query.status ? { status: query.status } : {}),
},
},
)
return unwrapApiResponse(response)
}
export function listTenantQualifications(tenantId: number | string) {
return getData<PlatformTenantQualification[]>(`/platform/v1/iam/tenants/${tenantId}/qualifications`)
}
@@ -183,6 +248,18 @@ export function createPlatformPermission(payload: CreatePlatformPermissionReques
return postData<PlatformPermissionResource>('/platform/v1/iam/permissions', payload)
}
export function updatePlatformPermission(permissionId: number | string, payload: UpdatePlatformPermissionRequest) {
return putData<PlatformPermissionResource>(`/platform/v1/iam/permissions/${permissionId}`, payload)
}
export function changePlatformPermissionStatus(permissionId: number | string, status: 'ACTIVE' | 'DISABLED') {
return putData<PlatformPermissionResource>(`/platform/v1/iam/permissions/${permissionId}/status`, { status })
}
export function deletePlatformPermission(permissionId: number | string) {
return deleteData<void>(`/platform/v1/iam/permissions/${permissionId}`)
}
export function listPlatformInternalUsers() {
return getData<PlatformInternalUser[]>('/platform/v1/iam/internal-users')
}
@@ -191,6 +268,22 @@ export function createPlatformInternalUser(payload: CreatePlatformInternalUserRe
return postData<PlatformInternalUser>('/platform/v1/iam/internal-users', payload)
}
export function updatePlatformInternalUser(membershipId: number | string, payload: UpdatePlatformInternalUserRequest) {
return putData<PlatformInternalUser>(`/platform/v1/iam/internal-users/${membershipId}`, payload)
}
export function changePlatformInternalUserStatus(membershipId: number | string, status: 'ACTIVE' | 'DISABLED') {
return putData<PlatformInternalUser>(`/platform/v1/iam/internal-users/${membershipId}/status`, { status })
}
export function resetPlatformInternalUserPassword(membershipId: number | string, newPassword: string) {
return putData<void>(`/platform/v1/iam/internal-users/${membershipId}/password`, { newPassword })
}
export function assignPlatformInternalUserRoles(membershipId: number | string, roleIds: Array<number | string>) {
return putData<PlatformInternalUser>(`/platform/v1/iam/internal-users/${membershipId}/roles`, { roleIds })
}
export function approveEnterpriseReview(reviewId: number, comment: string) {
return postData<unknown>(`/platform/v1/iam/reviews/${reviewId}/approve`, { comment })
}
+59
View File
@@ -0,0 +1,59 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { http } from '@/api/http'
import {
changePlatformPartnerStatus,
createPlatformPartner,
listPlatformPartners,
updatePlatformPartner,
type SavePlatformPartnerRequest,
} from './platformPartners'
vi.mock('@/api/http', () => ({
http: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn() },
unwrapApiResponse: <T>(response: { data: T }) => response.data,
}))
const payload: SavePlatformPartnerRequest = {
partnerCode: 'CUS-001',
partnerName: '测试往来单位',
partnerType: 'CUSTOMER',
contactName: '测试联系人',
contactPhone: '13800000000',
contactEmail: null,
countryCode: 'CN',
province: '广东省',
city: '深圳市',
district: null,
addressLine: '测试地址 1 号',
postalCode: null,
status: 'ACTIVE',
}
describe('platform partner API', () => {
beforeEach(() => vi.clearAllMocks())
it('queries the platform partner page with tenant and filters', async () => {
vi.mocked(http.get).mockResolvedValue({ code: '200', message: 'success', data: { records: [], page: '1', size: '20', total: '1', pages: '1' } })
const result = await listPlatformPartners(10001, { page: 1, size: 20, keyword: '测试', type: 'CUSTOMER', status: 'ACTIVE' })
expect(http.get).toHaveBeenCalledWith('/platform/v1/partners', {
params: { tenantId: 10001, page: 1, size: 20, keyword: '测试', type: 'CUSTOMER', status: 'ACTIVE' },
})
expect(result).toMatchObject({ page: 1, size: 20, total: 1, pages: 1 })
})
it('uses the backend create, update and status contracts', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: '19001' })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null })
await createPlatformPartner(10001, payload)
await updatePlatformPartner(10001, '19001', payload)
await changePlatformPartnerStatus(10001, '19001', 'INACTIVE')
expect(http.post).toHaveBeenCalledWith('/platform/v1/partners', payload, { params: { tenantId: 10001 } })
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 } })
})
})
+108
View File
@@ -0,0 +1,108 @@
import { http, unwrapApiResponse, type ApiEnvelope } from '@/api/http'
export type PlatformPartnerType = 'CUSTOMER' | 'SUPPLIER' | 'BOTH'
export type PlatformPartnerStatus = 'ACTIVE' | 'INACTIVE'
export interface PlatformPartnerResource {
id: number | string
tenantId: number | string
partnerCode: string
partnerName: string
partnerType: PlatformPartnerType
contactName: string
contactPhone: string
contactEmail: string | null
countryCode: string
province: string | null
city: string
district: string | null
addressLine: string
postalCode: string | null
status: PlatformPartnerStatus
createdAt?: string | null
updatedAt?: string | null
}
export interface PlatformPartnerPage {
records: PlatformPartnerResource[]
page: number
size: number
total: number
pages: number
}
type PlatformPartnerPagePayload = Omit<PlatformPartnerPage, 'page' | 'size' | 'total' | 'pages'> & {
page: number | string
size: number | string
total: number | string
pages: number | string
}
export interface PlatformPartnerQuery {
page: number
size: number
keyword?: string
type?: PlatformPartnerType
status?: PlatformPartnerStatus
}
export interface SavePlatformPartnerRequest {
partnerCode: string
partnerName: string
partnerType: PlatformPartnerType
contactName: string
contactPhone: string
contactEmail: string | null
countryCode: string
province: string | null
city: string
district: string | null
addressLine: string
postalCode: string | null
status: PlatformPartnerStatus
}
const basePath = '/platform/v1/partners'
export async function listPlatformPartners(tenantId: number, query: PlatformPartnerQuery) {
const response = await http.get<ApiEnvelope<PlatformPartnerPagePayload>, ApiEnvelope<PlatformPartnerPagePayload>>(basePath, {
params: { tenantId, ...query },
})
const page = unwrapApiResponse(response)
return {
...page,
page: Number(page.page),
size: Number(page.size),
total: Number(page.total),
pages: Number(page.pages),
}
}
export async function createPlatformPartner(tenantId: number, payload: SavePlatformPartnerRequest) {
const response = await http.post<ApiEnvelope<number | string>, ApiEnvelope<number | string>>(basePath, payload, {
params: { tenantId },
})
return unwrapApiResponse(response)
}
export async function updatePlatformPartner(
tenantId: number,
partnerId: number | string,
payload: SavePlatformPartnerRequest,
) {
const response = await http.put<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${partnerId}`, payload, {
params: { tenantId },
})
return unwrapApiResponse(response)
}
export async function changePlatformPartnerStatus(
tenantId: number,
partnerId: number | string,
status: PlatformPartnerStatus,
) {
const response = await http.patch<ApiEnvelope<null>, ApiEnvelope<null>>(`${basePath}/${partnerId}/status`, { status }, {
params: { tenantId },
})
return unwrapApiResponse(response)
}
+5 -5
View File
@@ -59,8 +59,8 @@ describe('platform warehouse API', () => {
const page = { records: [], page: 1, size: 200, total: 0, pages: 0 }
const detail = { warehouse: {}, addresses: [], capabilities: [] }
vi.mocked(http.get)
.mockResolvedValueOnce({ code: '0', message: 'success', data: page })
.mockResolvedValueOnce({ code: '0', message: 'success', data: detail })
.mockResolvedValueOnce({ code: '200', message: 'success', data: page })
.mockResolvedValueOnce({ code: '200', message: 'success', data: detail })
await expect(listPlatformWarehouses(10001)).resolves.toEqual(page)
await expect(getPlatformWarehouse(10001, '19001')).resolves.toEqual(detail)
@@ -73,9 +73,9 @@ describe('platform warehouse API', () => {
})
it('uses the implemented create, update, and status routes', async () => {
vi.mocked(http.post).mockResolvedValue({ code: '0', message: 'success', data: '19001' })
vi.mocked(http.put).mockResolvedValue({ code: '0', message: 'success', data: null })
vi.mocked(http.patch).mockResolvedValue({ code: '0', message: 'success', data: null })
vi.mocked(http.post).mockResolvedValue({ code: '200', message: 'success', data: '19001' })
vi.mocked(http.put).mockResolvedValue({ code: '200', message: 'success', data: null })
vi.mocked(http.patch).mockResolvedValue({ code: '200', message: 'success', data: null })
await expect(createPlatformWarehouse(payload)).resolves.toBe('19001')
await expect(updatePlatformWarehouse(10001, '19001', payload)).resolves.toBeNull()
+6
View File
@@ -14,6 +14,10 @@ declare module 'vue' {
AdminSidebarMenu: typeof import('./components/AdminSidebarMenu.vue')['default']
AdminSidebarMenuNode: typeof import('./components/AdminSidebarMenuNode.vue')['default']
ApiErrorMeta: typeof import('./components/ApiErrorMeta.vue')['default']
AppDialog: typeof import('./components/common/AppDialog.vue')['default']
AppDrawer: typeof import('./components/common/AppDrawer.vue')['default']
CopyButton: typeof import('./components/common/CopyButton.vue')['default']
DataTable: typeof import('./components/common/DataTable.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElButton: typeof import('element-plus/es')['ElButton']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
@@ -43,8 +47,10 @@ declare module 'vue' {
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTree: typeof import('element-plus/es')['ElTree']
LoadErrorState: typeof import('./components/common/LoadErrorState.vue')['default']
MetricStrip: typeof import('./components/MetricStrip.vue')['default']
PlatformFirstLegOrdersPanel: typeof import('./components/PlatformFirstLegOrdersPanel.vue')['default']
QueryToolbar: typeof import('./components/common/QueryToolbar.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
StatusBadge: typeof import('./components/StatusBadge.vue')['default']
+12 -21
View File
@@ -1,38 +1,29 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { Copy } from '@lucide/vue'
import type { ApiErrorDetails } from '@/api/http'
import CopyButton from '@/components/common/CopyButton.vue'
const props = defineProps<{ details: ApiErrorDetails }>()
async function copyIdentifier(value: string, label: string) {
try {
await navigator.clipboard.writeText(value)
ElMessage.success(`${label} 已复制`)
} catch {
ElMessage.error(`${label} 复制失败,请手动选择复制`)
}
}
</script>
<template>
<div v-if="details.code || details.requestId || details.traceId" class="api-error-meta" aria-label="接口错误追踪信息">
<span v-if="details.code" class="api-error-code">{{ details.code }}</span>
<button v-if="details.requestId" type="button" :title="`复制请求 ID:${props.details.requestId}`" @click="copyIdentifier(props.details.requestId!, '请求 ID')">
<span>请求 ID</span><code>{{ props.details.requestId }}</code><Copy :size="13" />
</button>
<button v-if="details.traceId" type="button" :title="`复制 Trace ID:${props.details.traceId}`" @click="copyIdentifier(props.details.traceId!, 'Trace ID')">
<span>Trace ID</span><code>{{ props.details.traceId }}</code><Copy :size="13" />
</button>
<CopyButton v-if="details.requestId" class="api-error-copy" :value="props.details.requestId" label="请求 ID">
<span class="api-error-copy__label">请求 ID</span><code>{{ props.details.requestId }}</code>
</CopyButton>
<CopyButton v-if="details.traceId" class="api-error-copy" :value="props.details.traceId" label="Trace ID">
<span class="api-error-copy__label">Trace ID</span><code>{{ props.details.traceId }}</code>
</CopyButton>
</div>
</template>
<style scoped>
.api-error-meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
.api-error-code { padding: 3px 7px; border-radius: 4px; background: #fee4e2; color: #912018; font-size: 10px; font-weight: 750; }
.api-error-meta button { display: inline-flex; min-width: 0; max-width: 100%; min-height: 30px; align-items: center; gap: 6px; padding: 4px 7px; border: 1px solid #fecdca; border-radius: 4px; background: #fff; color: #7a271a; cursor: pointer; }
.api-error-meta button:hover { background: #fff5f4; }
.api-error-meta button span { flex: 0 0 auto; font-size: 10px; font-weight: 700; }
.api-error-meta :deep(.api-error-copy) { display: inline-flex; min-width: 0; max-width: 100%; min-height: 30px; align-items: center; gap: 6px; padding: 4px 7px; border-color: #fecdca; border-radius: 4px; background: #fff; color: #7a271a; }
.api-error-meta :deep(.api-error-copy:hover) { border-color: #fda29b; background: #fff5f4; color: #7a271a; }
.api-error-meta :deep(.api-error-copy > span) { min-width: 0; gap: 6px; }
.api-error-copy__label { flex: 0 0 auto; font-size: 10px; font-weight: 700; }
.api-error-meta code { min-width: 0; overflow: hidden; color: #b42318; font-family: "Cascadia Code", Consolas, monospace; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
.api-error-meta svg { flex: 0 0 auto; }
.api-error-meta :deep(svg) { flex: 0 0 auto; }
</style>
+116 -90
View File
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Eye, FilePenLine, PackageCheck, RotateCcw, Search } from '@lucide/vue'
import type { FormInstance, FormRules } from 'element-plus'
import { Eye, FilePenLine, PackageCheck } from '@lucide/vue'
import {
acceptPlatformFirstLegOrder,
addPlatformFirstLegOrderNote,
@@ -18,18 +19,18 @@ import {
} from '@/api/platformFirstLegOrders'
import { getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
import MetricStrip from '@/components/MetricStrip.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 { useAccessPreviewStore } from '@/stores/accessPreview'
const TENANT_SESSION_KEY = 'cc-admin-first-leg-order-tenant-id'
const PHASE_ONE_TENANT_ID = '10001'
const access = useAccessPreviewStore()
const tenantId = ref(sessionStorage.getItem(TENANT_SESSION_KEY) || '')
const loadedTenantId = ref('')
const loadedTenantId = ref(PHASE_ONE_TENANT_ID)
const orders = ref<PlatformFirstLegOrderResource[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const pageSize = ref(20)
const loading = ref(false)
const actionLoadingId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
@@ -39,23 +40,64 @@ const detail = ref<PlatformFirstLegOrderDetailResource | null>(null)
const noteDialogOpen = ref(false)
const expressDialogOpen = ref(false)
const selectedOrder = ref<PlatformFirstLegOrderResource | null>(null)
const noteContent = ref('')
const noteForm = reactive({ content: '' })
const noteSaving = ref(false)
const expressSaving = ref(false)
const expressFormRef = ref<FormInstance>()
const noteFormRef = ref<FormInstance>()
const expressForm = reactive({ expressCompany: '', expressTrackingNo: '' })
const filters = reactive<{ keyword: string; status: PlatformFirstLegOrderStatus | ''; dateRange: [string, string] | [] }>({
const filters = ref<QueryModel & {
keyword: string
status: PlatformFirstLegOrderStatus | ''
dateRange: string[]
}>({
keyword: '',
status: '',
dateRange: [],
})
const tenantReady = computed(() => Boolean(loadedTenantId.value) && tenantId.value.trim() === loadedTenantId.value)
const metrics = computed(() => [
{ label: '订单总数', value: total.value, note: loadedTenantId.value ? `租户 ${loadedTenantId.value}` : '等待选择租户' },
{ label: '待审核', value: orders.value.filter((item) => item.status === 'SUBMITTED').length, note: '当前页数据' },
{ label: '履约处理中', value: orders.value.filter((item) => ['APPROVED', 'ACCEPTED', 'PROCESSING'].includes(item.status)).length, note: '当前页数据' },
{ label: '已完成', value: orders.value.filter((item) => item.status === 'COMPLETED').length, note: '当前页数据' },
])
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '订单号或客户名称', props: { 'aria-label': '订单关键词' } },
{
key: 'status',
type: 'select',
placeholder: '全部状态',
props: { 'aria-label': '订单状态' },
options: [
{ value: 'DRAFT', label: '草稿' },
{ value: 'SUBMITTED', label: '待审核' },
{ value: 'APPROVED', label: '已审核' },
{ value: 'ACCEPTED', label: '已受理' },
{ value: 'PROCESSING', label: '处理中' },
{ value: 'COMPLETED', label: '已完成' },
{ value: 'CANCELLED', label: '已取消' },
{ value: 'REJECTED', label: '已驳回(旧契约)' },
],
},
{ key: 'dateRange', type: 'daterange', props: { 'aria-label': '订单创建日期' } },
]
const orderColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', minWidth: 200, fixed: 'left', slotName: 'actions' },
{ key: 'orderInfo', label: '订单信息', minWidth: 210, slotName: 'orderInfo' },
{ key: 'customer', label: '客户', minWidth: 190, showOverflowTooltip: true, slotName: 'customer' },
{ key: 'transport', label: '运输方案', minWidth: 180, slotName: 'transport' },
{ key: 'destination', label: '目的地', minWidth: 150, slotName: 'destination' },
{ key: 'receivingWarehouse', label: '平台收货仓', minWidth: 180, slotName: 'receivingWarehouse' },
{ key: 'express', label: '履约快递', minWidth: 160, slotName: 'express' },
{ key: 'status', label: '状态', width: 120, slotName: 'status' },
]
const expressRules: FormRules = {
expressCompany: [{ required: true, message: '请填写快递公司', trigger: 'blur' }],
expressTrackingNo: [{ required: true, message: '请填写快递单号', trigger: 'blur' }],
}
const noteRules: FormRules = {
content: [{ required: true, message: '请填写内部备注', trigger: 'blur' }],
}
const tenantReady = computed(() => loadedTenantId.value === PHASE_ONE_TENANT_ID)
const statusOptions: Array<{ value: PlatformFirstLegOrderStatus; label: string }> = [
{ value: 'DRAFT', label: '草稿' },
@@ -99,28 +141,22 @@ function actionLabel(order: PlatformFirstLegOrderResource) {
return ''
}
async function loadOrders(resetPage = false) {
const targetTenantId = tenantId.value.trim()
async function loadOrders() {
const targetTenantId = PHASE_ONE_TENANT_ID
loadError.value = null
if (!/^\d+$/.test(targetTenantId) || targetTenantId === '0') {
loadError.value = { message: '请输入有效的目标租户 ID 后查询订单' }
return
}
if (resetPage) page.value = 1
loading.value = true
try {
const result = await listPlatformFirstLegOrders(targetTenantId, {
keyword: filters.keyword.trim() || undefined,
status: filters.status || undefined,
createdFrom: filters.dateRange.length === 2 ? `${filters.dateRange[0]}T00:00:00` : undefined,
createdTo: filters.dateRange.length === 2 ? `${filters.dateRange[1]}T23:59:59` : undefined,
keyword: filters.value.keyword.trim() || undefined,
status: filters.value.status || undefined,
createdFrom: filters.value.dateRange.length === 2 ? `${filters.value.dateRange[0]}T00:00:00` : undefined,
createdTo: filters.value.dateRange.length === 2 ? `${filters.value.dateRange[1]}T23:59:59` : undefined,
page: page.value,
size: pageSize,
size: pageSize.value,
})
orders.value = result.records
total.value = result.total
loadedTenantId.value = targetTenantId
sessionStorage.setItem(TENANT_SESSION_KEY, targetTenantId)
} catch (error) {
orders.value = []
total.value = 0
@@ -130,18 +166,18 @@ async function loadOrders(resetPage = false) {
}
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
filters.dateRange = []
void loadOrders(true)
}
function handlePageChange(nextPage: number) {
page.value = nextPage
function searchOrders() {
if (page.value !== 1) {
page.value = 1
return
}
void loadOrders()
}
watch([page, pageSize], () => {
void loadOrders()
})
async function openDetail(order: PlatformFirstLegOrderResource) {
if (!tenantReady.value) return
selectedOrder.value = order
@@ -217,14 +253,13 @@ function openExpress(order: PlatformFirstLegOrderResource) {
expressForm.expressCompany = order.expressCompany || ''
expressForm.expressTrackingNo = order.expressTrackingNo || ''
expressDialogOpen.value = true
void nextTick(() => expressFormRef.value?.clearValidate())
}
async function saveExpress() {
if (!selectedOrder.value || !tenantReady.value) return
if (!expressForm.expressCompany.trim() || !expressForm.expressTrackingNo.trim()) {
ElMessage.warning('请填写快递公司和快递单号')
return
}
const valid = await expressFormRef.value?.validate().catch(() => false)
if (!valid) return
expressSaving.value = true
try {
await updatePlatformFirstLegOrderExpress(loadedTenantId.value, String(selectedOrder.value.id), {
@@ -243,19 +278,18 @@ async function saveExpress() {
function openNote(order: PlatformFirstLegOrderResource) {
selectedOrder.value = order
noteContent.value = ''
noteForm.content = ''
noteDialogOpen.value = true
void nextTick(() => noteFormRef.value?.clearValidate())
}
async function saveNote() {
if (!selectedOrder.value || !tenantReady.value) return
if (!noteContent.value.trim()) {
ElMessage.warning('请填写内部备注')
return
}
const valid = await noteFormRef.value?.validate().catch(() => false)
if (!valid) return
noteSaving.value = true
try {
await addPlatformFirstLegOrderNote(loadedTenantId.value, String(selectedOrder.value.id), noteContent.value.trim())
await addPlatformFirstLegOrderNote(loadedTenantId.value, String(selectedOrder.value.id), noteForm.content.trim())
noteDialogOpen.value = false
await refreshAfterAction(String(selectedOrder.value.id))
ElMessage.success('内部备注已保存')
@@ -267,55 +301,49 @@ async function saveNote() {
}
onMounted(() => {
if (tenantId.value) void loadOrders()
void loadOrders()
})
</script>
<template>
<div class="business-page">
<MetricStrip :items="metrics" />
<section class="surface-panel filter-panel" aria-label="头程订单筛选">
<div class="filter-fields server-order-filter-fields">
<el-input v-model.trim="tenantId" clearable placeholder="目标租户 ID" aria-label="目标租户 ID" @keyup.enter="loadOrders(true)" />
<el-input v-model.trim="filters.keyword" clearable placeholder="订单号或客户名称" aria-label="订单关键词" @keyup.enter="loadOrders(true)">
<template #prefix><Search :size="16" /></template>
</el-input>
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="订单状态">
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-date-picker v-model="filters.dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="创建开始" end-placeholder="创建结束" aria-label="订单创建日期" />
</div>
<div class="filter-actions">
<el-button type="primary" plain :loading="loading" @click="loadOrders(true)"><Search :size="16" />查询租户</el-button>
<el-button :disabled="loading" @click="resetFilters"><RotateCcw :size="16" />重置筛选</el-button>
</div>
</section>
<QueryToolbar
v-model="filters"
ariaLabel="头程订单筛选"
:fields="queryFields"
search-text="查询订单"
:search-on-reset="true"
@search="searchOrders"
/>
<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></template>
<el-button v-if="tenantId" link type="danger" @click="loadOrders(true)">重新加载</el-button>
</el-alert>
<el-alert v-else type="info" :closable="false" show-icon>
当前页面已直连 Order 服务。后端尚未提供平台代客创建、平台编辑及履约任务查询接口,因此相关操作暂不可用。
<el-button link type="danger" @click="loadOrders">重新加载</el-button>
</el-alert>
<section class="surface-panel table-panel">
<header class="panel-heading"><div><h2>头程订单</h2><p>{{ loadedTenantId ? `当前数据范围:租户 ${loadedTenantId}` : '输入目标租户 ID 后读取 Order 服务真实数据' }}</p></div></header>
<el-table v-if="loading || orders.length" v-loading="loading" :data="orders" row-key="id">
<el-table-column label="操作" min-width="200" fixed="left">
<template #default="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button><el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" link type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)">{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button><el-button v-if="canCancel(row as PlatformFirstLegOrderResource)" link type="danger" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="cancelOrder(row as PlatformFirstLegOrderResource)">取消</el-button><el-button v-if="can('platform:order:fulfillment') && !['DRAFT', 'REJECTED', 'CANCELLED'].includes((row as PlatformFirstLegOrderResource).status)" link type="primary" @click="openExpress(row as PlatformFirstLegOrderResource)"><PackageCheck :size="15" />快递</el-button><el-button v-if="can('platform:order:note')" link type="primary" @click="openNote(row as PlatformFirstLegOrderResource)"><FilePenLine :size="15" />备注</el-button></div></template>
</el-table-column>
<el-table-column label="订单信息" min-width="210"><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).referenceId || '无客户参考号' }} · {{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></div></template></el-table-column>
<el-table-column label="客户" min-width="190" show-overflow-tooltip><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).customerName }}</strong><span>租户 {{ (row as PlatformFirstLegOrderResource).tenantId }}</span></div></template></el-table-column>
<el-table-column label="运输方案" min-width="180"><template #default="{ row }"><div class="primary-cell"><strong>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).channelName || '未填写渠道' }}</span></div></template></el-table-column>
<el-table-column label="目的地" min-width="150"><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '-' }}</span></div></template></el-table-column>
<el-table-column label="平台收货仓" min-width="180"><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseAddress || '-' }}</span></div></template></el-table-column>
<el-table-column label="履约快递" min-width="160"><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).expressCompany || '未维护' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).expressTrackingNo || '—' }}</span></div></template></el-table-column>
<el-table-column label="状态" width="120"><template #default="{ row }"><StatusBadge :status="statusLabel((row as PlatformFirstLegOrderResource).status)" /></template></el-table-column>
</el-table>
<el-empty v-else :description="loadedTenantId ? '当前租户没有符合条件的头程订单' : '请先输入目标租户 ID 并查询'" />
<footer class="table-footer"><span>共 {{ total }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="handlePageChange" /></footer>
<header class="panel-heading"><div><h2>头程订单</h2><p>当前数据范围:一期固定租户 10001</p></div></header>
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="orders"
:columns="orderColumns"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100]"
empty-text="当前没有符合条件的头程订单"
row-key="id"
>
<template #actions="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as PlatformFirstLegOrderResource)"><Eye :size="15" />详情</el-button><el-button v-if="actionLabel(row as PlatformFirstLegOrderResource)" link type="success" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="runLifecycleAction(row as PlatformFirstLegOrderResource)">{{ actionLabel(row as PlatformFirstLegOrderResource) }}</el-button><el-button v-if="canCancel(row as PlatformFirstLegOrderResource)" link type="danger" :loading="actionLoadingId === String((row as PlatformFirstLegOrderResource).id)" @click="cancelOrder(row as PlatformFirstLegOrderResource)">取消</el-button><el-button v-if="can('platform:order:fulfillment') && !['DRAFT', 'REJECTED', 'CANCELLED'].includes((row as PlatformFirstLegOrderResource).status)" link type="primary" @click="openExpress(row as PlatformFirstLegOrderResource)"><PackageCheck :size="15" />快递</el-button><el-button v-if="can('platform:order:note')" link type="primary" @click="openNote(row as PlatformFirstLegOrderResource)"><FilePenLine :size="15" />备注</el-button></div></template>
<template #orderInfo="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).orderNo || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).referenceId || '无客户参考号' }} · {{ formattedDate((row as PlatformFirstLegOrderResource).createdAt) }}</span></div></template>
<template #customer="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).customerName }}</strong><span>租户 {{ (row as PlatformFirstLegOrderResource).tenantId }}</span></div></template>
<template #transport="{ row }"><div class="primary-cell"><strong>{{ transportLabel(row as PlatformFirstLegOrderResource) }}</strong><span>{{ (row as PlatformFirstLegOrderResource).channelName || '未填写渠道' }}</span></div></template>
<template #destination="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).destinationWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).destinationCountryCode || '-' }}</span></div></template>
<template #receivingWarehouse="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseName || '-' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).receivingWarehouseAddress || '-' }}</span></div></template>
<template #express="{ row }"><div class="primary-cell"><strong>{{ (row as PlatformFirstLegOrderResource).expressCompany || '未维护' }}</strong><span>{{ (row as PlatformFirstLegOrderResource).expressTrackingNo || '—' }}</span></div></template>
<template #status="{ row }"><StatusBadge :status="statusLabel((row as PlatformFirstLegOrderResource).status)" /></template>
<template #empty><el-empty description="当前没有符合条件的头程订单" /></template>
</DataTable>
</section>
<el-drawer v-model="detailOpen" title="订单详情" size="760px" class="business-drawer">
@@ -336,19 +364,17 @@ onMounted(() => {
<el-dialog v-model="expressDialogOpen" title="维护履约快递" width="520px">
<el-alert title="当前后端契约只接收快递公司名称和快递单号;快递主数据关联待后端补充。" type="info" :closable="false" show-icon />
<el-form label-position="top" class="delivery-form"><el-form-item label="快递公司" required><el-input v-model.trim="expressForm.expressCompany" maxlength="64" placeholder="例如 顺丰速运" /></el-form-item><el-form-item label="快递单号" required><el-input v-model.trim="expressForm.expressTrackingNo" maxlength="128" placeholder="请输入完整快递单号" /></el-form-item></el-form>
<el-form ref="expressFormRef" :model="expressForm" :rules="expressRules" label-position="top" class="delivery-form"><el-form-item label="快递公司" prop="expressCompany" required><el-input v-model.trim="expressForm.expressCompany" maxlength="64" placeholder="例如 顺丰速运" /></el-form-item><el-form-item label="快递单号" prop="expressTrackingNo" required><el-input v-model.trim="expressForm.expressTrackingNo" maxlength="128" placeholder="请输入完整快递单号" /></el-form-item></el-form>
<template #footer><el-button :disabled="expressSaving" @click="expressDialogOpen = false">取消</el-button><el-button type="primary" :loading="expressSaving" @click="saveExpress">保存快递信息</el-button></template>
</el-dialog>
<el-dialog v-model="noteDialogOpen" title="添加内部备注" width="520px">
<el-form label-position="top"><el-form-item label="内部备注" required><el-input v-model.trim="noteContent" maxlength="2000" type="textarea" :rows="4" show-word-limit placeholder="仅平台内部可见" /></el-form-item></el-form>
<el-form ref="noteFormRef" :model="noteForm" :rules="noteRules" label-position="top"><el-form-item label="内部备注" prop="content" required><el-input v-model.trim="noteForm.content" maxlength="2000" type="textarea" :rows="4" show-word-limit placeholder="仅平台内部可见" /></el-form-item></el-form>
<template #footer><el-button :disabled="noteSaving" @click="noteDialogOpen = false">取消</el-button><el-button type="primary" :loading="noteSaving" @click="saveNote">保存备注</el-button></template>
</el-dialog>
</div>
</template>
<style scoped>
.server-order-filter-fields { grid-template-columns: minmax(150px, .65fr) minmax(220px, 1.2fr) minmax(150px, .75fr) minmax(260px, 1.1fr); }
.party-grid p { margin: 4px 0 0; color: var(--cc-muted); font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; }
@media (max-width: 720px) { .server-order-filter-fields { grid-template-columns: 1fr; } }
</style>
+65
View File
@@ -0,0 +1,65 @@
import { defineComponent } from 'vue'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import AppDialog from './AppDialog.vue'
const ElDialogStub = defineComponent({
props: {
modelValue: Boolean,
showClose: Boolean,
closeOnClickModal: Boolean,
closeOnPressEscape: Boolean,
},
template: '<section data-testid="dialog"><slot name="header" /><slot /><slot name="footer" /></section>',
})
const ElButtonStub = defineComponent({
props: {
disabled: Boolean,
loading: Boolean,
},
emits: ['click'],
template: '<button type="button" :disabled="disabled" @click="$emit(\'click\')"><slot /></button>',
})
function mountDialog(props: Record<string, unknown> = {}) {
return mount(AppDialog, {
props: { modelValue: true, title: '编辑资料', ...props },
slots: { default: '<p>弹框内容</p>' },
global: {
stubs: { ElDialog: ElDialogStub, ElButton: ElButtonStub },
directives: { loading: {} },
},
})
}
describe('AppDialog', () => {
it('provides default cancel and confirm actions', async () => {
const wrapper = mountDialog()
const buttons = wrapper.findAll('button')
expect(buttons.map((button) => button.text())).toEqual(['取消', '确定'])
await buttons[1].trigger('click')
expect(wrapper.emitted('confirm')).toHaveLength(1)
await buttons[0].trigger('click')
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.emitted('update:modelValue')).toEqual([[false]])
})
it('prevents accidental closing while confirmation is submitting', () => {
const wrapper = mountDialog({ confirmLoading: true })
const dialog = wrapper.getComponent(ElDialogStub)
const cancelButton = wrapper.findAll('button')[0]
expect(dialog.props('showClose')).toBe(false)
expect(dialog.props('closeOnClickModal')).toBe(false)
expect(dialog.props('closeOnPressEscape')).toBe(false)
expect(cancelButton.attributes('disabled')).toBeDefined()
})
it('can render a content-only dialog without footer actions', () => {
const wrapper = mountDialog({ showFooter: false })
expect(wrapper.findAll('button')).toHaveLength(0)
})
})
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed } from 'vue'
export type DialogConfirmType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
title: string
width?: string | number
loading?: boolean
confirmLoading?: boolean
confirmDisabled?: boolean
confirmText?: string
cancelText?: string
confirmType?: DialogConfirmType
showFooter?: boolean
showCancel?: boolean
showConfirm?: boolean
showClose?: boolean
closeOnClickModal?: boolean
closeOnPressEscape?: boolean
destroyOnClose?: boolean
lockWhileSubmitting?: boolean
}>(), {
width: 'min(560px, calc(100vw - 28px))',
loading: false,
confirmLoading: false,
confirmDisabled: false,
confirmText: '确定',
cancelText: '取消',
confirmType: 'primary',
showFooter: true,
showCancel: true,
showConfirm: true,
showClose: true,
closeOnClickModal: false,
closeOnPressEscape: true,
destroyOnClose: false,
lockWhileSubmitting: true,
})
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading)
function closeDialog() {
if (submittingLocked.value) return
open.value = false
emit('cancel')
}
</script>
<template>
<el-dialog
v-bind="$attrs"
v-model="open"
class="app-dialog"
:title="title"
:width="width"
:show-close="showClose && !submittingLocked"
:close-on-click-modal="closeOnClickModal && !submittingLocked"
:close-on-press-escape="closeOnPressEscape && !submittingLocked"
:destroy-on-close="destroyOnClose"
>
<template v-if="$slots.header" #header><slot name="header" /></template>
<div v-loading="loading" class="app-dialog__body" :aria-busy="loading || confirmLoading">
<slot />
</div>
<template v-if="showFooter" #footer>
<slot name="footer" :close="closeDialog" :submitting="confirmLoading">
<div class="app-dialog__footer">
<el-button v-if="showCancel" :disabled="submittingLocked" @click="closeDialog">{{ cancelText }}</el-button>
<el-button
v-if="showConfirm"
:type="confirmType"
:loading="confirmLoading"
:disabled="loading || confirmDisabled"
@click="emit('confirm')"
>{{ confirmText }}</el-button>
</div>
</slot>
</template>
</el-dialog>
</template>
<style scoped>
.app-dialog__body { min-height: 0; }
.app-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.app-dialog__footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { .app-dialog__footer :deep(.el-button) { min-height: 44px; } }
</style>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DialogConfirmType } from './AppDialog.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
title: string
size?: string | number
loading?: boolean
confirmLoading?: boolean
confirmDisabled?: boolean
confirmText?: string
cancelText?: string
confirmType?: DialogConfirmType
showFooter?: boolean
showCancel?: boolean
showConfirm?: boolean
showClose?: boolean
closeOnClickModal?: boolean
closeOnPressEscape?: boolean
destroyOnClose?: boolean
lockWhileSubmitting?: boolean
}>(), {
size: 'min(680px, 94vw)',
loading: false,
confirmLoading: false,
confirmDisabled: false,
confirmText: '确定',
cancelText: '取消',
confirmType: 'primary',
showFooter: true,
showCancel: true,
showConfirm: true,
showClose: true,
closeOnClickModal: false,
closeOnPressEscape: true,
destroyOnClose: false,
lockWhileSubmitting: true,
})
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading)
function closeDrawer() {
if (submittingLocked.value) return
open.value = false
emit('cancel')
}
</script>
<template>
<el-drawer
v-bind="$attrs"
v-model="open"
class="app-drawer"
:title="title"
:size="size"
:show-close="showClose && !submittingLocked"
:close-on-click-modal="closeOnClickModal && !submittingLocked"
:close-on-press-escape="closeOnPressEscape && !submittingLocked"
:destroy-on-close="destroyOnClose"
>
<template v-if="$slots.header" #header><slot name="header" /></template>
<div v-loading="loading" class="app-drawer__body" :aria-busy="loading || confirmLoading">
<slot />
</div>
<template v-if="showFooter" #footer>
<slot name="footer" :close="closeDrawer" :submitting="confirmLoading">
<div class="app-drawer__footer">
<el-button v-if="showCancel" :disabled="submittingLocked" @click="closeDrawer">{{ cancelText }}</el-button>
<el-button
v-if="showConfirm"
:type="confirmType"
:loading="confirmLoading"
:disabled="loading || confirmDisabled"
@click="emit('confirm')"
>{{ confirmText }}</el-button>
</div>
</slot>
</template>
</el-drawer>
</template>
<style scoped>
:global(.app-drawer) { max-width: calc(100vw - 20px); }
.app-drawer__body { min-height: 0; }
.app-drawer__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.app-drawer__footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { :global(.app-drawer) { max-width: 94vw; } .app-drawer__footer :deep(.el-button) { min-height: 44px; } }
</style>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Copy } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { copyText } from '@/utils/copyText'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
value: string | number | null | undefined
label?: string
buttonText?: string
successText?: string
errorText?: string
disabled?: boolean
notify?: boolean
}>(), {
label: '内容',
buttonText: '复制',
successText: undefined,
errorText: undefined,
disabled: false,
notify: true,
})
const emit = defineEmits<{
copied: [value: string]
'copy-error': [error: unknown]
}>()
const copying = ref(false)
const copyValue = computed(() => props.value == null ? '' : String(props.value))
const isDisabled = computed(() => props.disabled || copying.value || copyValue.value.length === 0)
async function handleCopy() {
if (isDisabled.value) return
copying.value = true
try {
await copyText(copyValue.value)
if (props.notify) ElMessage.success(props.successText || `${props.label}已复制`)
emit('copied', copyValue.value)
} catch (error) {
if (props.notify) ElMessage.error(props.errorText || `${props.label}复制失败,请手动选择复制`)
emit('copy-error', error)
} finally {
copying.value = false
}
}
</script>
<template>
<el-button
v-bind="$attrs"
:disabled="isDisabled"
:aria-label="`复制${label}`"
:title="`复制${label}`"
@click="handleCopy"
>
<Copy :size="14" aria-hidden="true" />
<slot :copying="copying">{{ buttonText }}</slot>
</el-button>
</template>
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { createPaginationItems, normalizePage, normalizePageSizes } from './DataTable.vue'
describe('DataTable pagination helpers', () => {
it('normalizes page-size options', () => {
expect(normalizePageSizes([20, 10, 20, 0, -1, 0.8, 10.8])).toEqual([20, 10])
expect(normalizePageSizes([])).toEqual([10])
})
it('keeps the page inside the available range', () => {
expect(normalizePage(0, 8)).toBe(1)
expect(normalizePage(12, 8)).toBe(8)
expect(normalizePage(3.8, 8)).toBe(3)
expect(normalizePage(Number.NaN, 8)).toBe(1)
expect(normalizePage(4, 0)).toBe(1)
expect(normalizePage(4, Number.NaN)).toBe(1)
})
it('does not create page buttons for an empty result', () => {
expect(createPaginationItems(1, 0)).toEqual([])
})
it('shows all pages for a short result set', () => {
expect(createPaginationItems(3, 5)).toEqual([1, 2, 3, 4, 5])
})
it('keeps first and last pages visible for long result sets', () => {
expect(createPaginationItems(1, 30)).toEqual([1, 2, 3, 4, 5, 'ellipsis-end', 30])
expect(createPaginationItems(15, 30)).toEqual([1, 'ellipsis-start', 14, 15, 16, 'ellipsis-end', 30])
expect(createPaginationItems(30, 30)).toEqual([1, 'ellipsis-start', 26, 27, 28, 29, 30])
})
})
+180
View File
@@ -0,0 +1,180 @@
<script lang="ts">
export interface DataTableColumn {
key?: string | number
label?: string
prop?: string
property?: string
columnKey?: string
slotName?: string
headerSlotName?: string
[attribute: string]: unknown
}
export type PaginationItem = number | 'ellipsis-start' | 'ellipsis-end'
export function normalizePageSizes(values: readonly number[]) {
const sizes = values
.filter((value) => Number.isFinite(value))
.map((value) => Math.trunc(value))
.filter((value) => value > 0)
const uniqueSizes = [...new Set(sizes)]
return uniqueSizes.length > 0 ? uniqueSizes : [10]
}
export function normalizePage(value: number, pageCount: number) {
const maximum = Number.isFinite(pageCount) && pageCount > 0 ? Math.trunc(pageCount) : 1
if (!Number.isFinite(value)) return 1
return Math.min(Math.max(Math.trunc(value), 1), maximum)
}
export function createPaginationItems(page: number, pageCount: number): PaginationItem[] {
if (pageCount <= 0) return []
if (pageCount <= 7) return Array.from({ length: pageCount }, (_, index) => index + 1)
if (page <= 4) return [1, 2, 3, 4, 5, 'ellipsis-end', pageCount]
if (page >= pageCount - 3) {
return [1, 'ellipsis-start', pageCount - 4, pageCount - 3, pageCount - 2, pageCount - 1, pageCount]
}
return [1, 'ellipsis-start', page - 1, page, page + 1, 'ellipsis-end', pageCount]
}
</script>
<script setup lang="ts">
import { computed, useSlots, watch } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
data: object[]
columns: DataTableColumn[]
loading?: boolean
emptyText?: string
total?: number
pageSizes?: number[]
}>(), {
loading: false,
emptyText: '暂无数据',
total: undefined,
pageSizes: () => [10, 20, 50, 100],
})
const page = defineModel<number>('page', { default: 1 })
const pageSize = defineModel<number>('pageSize', { default: 10 })
const slots = useSlots()
const normalizedTotal = computed(() => {
const value = props.total ?? 0
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0
})
const normalizedPageSizes = computed(() => normalizePageSizes(props.pageSizes))
const effectivePageSize = computed(() => (
normalizedPageSizes.value.includes(pageSize.value) ? pageSize.value : normalizedPageSizes.value[0]
))
const pageCount = computed(() => (
normalizedTotal.value === 0 ? 0 : Math.ceil(normalizedTotal.value / effectivePageSize.value)
))
const currentPage = computed(() => normalizePage(page.value, pageCount.value))
const paginationItems = computed(() => createPaginationItems(currentPage.value, pageCount.value))
watch([pageSize, normalizedPageSizes], ([value, sizes]) => {
const integerValue = Number.isFinite(value) ? Math.trunc(value) : sizes[0]
const nextValue = sizes.includes(integerValue) ? integerValue : sizes[0]
if (value === nextValue) return
pageSize.value = nextValue
page.value = 1
}, { immediate: true })
watch([page, pageCount], ([value, count]) => {
const nextValue = normalizePage(value, count)
if (value !== nextValue) page.value = nextValue
}, { immediate: true })
function columnAttrs({ key, slotName, headerSlotName, ...attrs }: DataTableColumn) {
return attrs
}
function columnRenderKey(column: DataTableColumn, index: number) {
return String(column.key ?? column.columnKey ?? column.prop ?? column.property ?? index)
}
function hasSlot(name?: string) {
return Boolean(name && slots[name])
}
function updatePageSize(value: number) {
const nextValue = normalizedPageSizes.value.includes(value) ? value : normalizedPageSizes.value[0]
if (nextValue === pageSize.value) return
pageSize.value = nextValue
page.value = 1
}
function setPage(value: number) {
page.value = normalizePage(value, pageCount.value)
}
</script>
<template>
<div class="data-table">
<el-table v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
<el-table-column
v-for="(column, index) in columns"
:key="columnRenderKey(column, index)"
v-bind="columnAttrs(column)"
>
<template v-if="hasSlot(column.headerSlotName)" #header="scope">
<slot :name="column.headerSlotName" v-bind="scope" />
</template>
<template v-if="hasSlot(column.slotName)" #default="scope">
<slot :name="column.slotName" v-bind="scope" />
</template>
</el-table-column>
<template v-if="$slots.empty" #empty><slot name="empty" /></template>
</el-table>
<footer v-if="total !== undefined" class="data-table__pagination" aria-label="表格分页">
<div class="data-table__pagination-left">
<slot name="pagination-left" :total="normalizedTotal" :page="currentPage" :page-size="effectivePageSize" :page-count="pageCount">
<span v-if="pageCount > 0">共 {{ normalizedTotal }} 条 · 第 {{ currentPage }} / {{ pageCount }} 页</span>
<span v-else>共 0 条</span>
</slot>
</div>
<div class="data-table__pagination-controls">
<label class="data-table__page-size">
<span>每页</span>
<el-select :model-value="effectivePageSize" :disabled="loading" aria-label="每页条数" @update:model-value="updatePageSize">
<el-option v-for="size in normalizedPageSizes" :key="size" :label="String(size)" :value="size" />
</el-select>
<span>条</span>
</label>
<nav v-if="pageCount > 0" class="data-table__page-buttons" aria-label="页码">
<el-button class="data-table__page-button" :disabled="loading || currentPage <= 1" aria-label="上一页" title="上一页" @click="setPage(currentPage - 1)"><ChevronLeft :size="16" /></el-button>
<template v-for="item in paginationItems" :key="item">
<span v-if="typeof item !== 'number'" class="data-table__page-ellipsis" aria-hidden="true">...</span>
<el-button
v-else
class="data-table__page-button"
:type="item === currentPage ? 'primary' : undefined"
:disabled="loading"
:aria-current="item === currentPage ? 'page' : undefined"
:aria-label="`第 ${item} 页`"
:title="`第 ${item} 页`"
@click="setPage(item)"
>{{ item }}</el-button>
</template>
<el-button class="data-table__page-button" :disabled="loading || currentPage >= pageCount" aria-label="下一页" title="下一页" @click="setPage(currentPage + 1)"><ChevronRight :size="16" /></el-button>
</nav>
</div>
</footer>
</div>
</template>
<style scoped>
.data-table { min-width: 0; }
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
.data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; }
.data-table__pagination-controls { min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
.data-table__page-size :deep(.el-select) { width: 82px; }
.data-table__page-buttons { gap: 4px; }
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
@media (max-width: 720px) { .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
</style>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { CircleAlert } from '@lucide/vue'
withDefaults(defineProps<{
title: string
message?: string
loading?: boolean
retryText?: string
showRetry?: boolean
compact?: boolean
}>(), {
message: '',
loading: false,
retryText: '重新加载',
showRetry: true,
compact: false,
})
defineEmits<{ retry: [] }>()
</script>
<template>
<section class="load-error-state" :class="{ 'is-compact': compact }" role="alert">
<CircleAlert class="load-error-state__icon" :size="20" aria-hidden="true" />
<div class="load-error-state__content">
<strong>{{ title }}</strong>
<p v-if="message">{{ message }}</p>
<slot name="details" />
</div>
<div v-if="showRetry || $slots.actions" class="load-error-state__actions">
<slot name="actions">
<el-button type="primary" plain :loading="loading" @click="$emit('retry')">{{ retryText }}</el-button>
</slot>
</div>
</section>
</template>
<style scoped>
.load-error-state { display: flex; min-width: 0; align-items: flex-start; gap: 12px; padding: 16px; border: 1px solid var(--el-color-danger-light-7); border-radius: 8px; background: var(--el-color-danger-light-9); color: var(--el-text-color-primary); }
.load-error-state.is-compact { padding: 12px; }
.load-error-state__icon { flex: 0 0 auto; margin-top: 1px; color: var(--el-color-danger); }
.load-error-state__content { min-width: 0; flex: 1; }
.load-error-state__content strong { display: block; font-size: 14px; line-height: 1.5; }
.load-error-state__content p { margin: 4px 0 0; color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.load-error-state__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 8px; }
.load-error-state__actions :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { .load-error-state { flex-wrap: wrap; } .load-error-state__content { flex-basis: calc(100% - 36px); } .load-error-state__actions { width: 100%; padding-left: 32px; } .load-error-state__actions :deep(.el-button) { min-height: 44px; } }
</style>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { Search, RotateCcw } from '@lucide/vue'
defineOptions({ inheritAttrs: false })
export type QueryFieldType = 'input' | 'select' | 'date' | 'daterange'
export type QueryValue = string | number | boolean | null | undefined | string[] | number[] | Date | Date[]
export type QueryModel = Record<string, QueryValue>
export interface QueryOption {
label: string
value: string | number | boolean
disabled?: boolean
}
export interface QueryField {
key: string
type: QueryFieldType
placeholder?: string
options?: QueryOption[]
props?: Record<string, unknown>
}
const props = withDefaults(defineProps<{
ariaLabel: string
fields: QueryField[]
searchText?: string
resetText?: string
searchOnReset?: boolean
}>(), {
searchText: '查询',
resetText: '重置',
searchOnReset: true,
})
const model = defineModel<QueryModel>({ required: true })
const emit = defineEmits<{
search: [model: QueryModel]
reset: [model: QueryModel]
}>()
function updateField(key: string, value: QueryValue) {
model.value = { ...model.value, [key]: value }
}
function defaultPlaceholder(field: QueryField) {
if (field.type === 'input') return '请输入关键词'
if (field.type === 'select') return '全部'
return field.type === 'daterange' ? '请选择日期范围' : '请选择日期'
}
function datePickerType(field: QueryField): 'date' | 'daterange' {
return field.type === 'daterange' ? 'daterange' : 'date'
}
function resetValue(field: QueryField): QueryValue {
return field.type === 'daterange' ? [] : ''
}
function search() {
emit('search', model.value)
}
function reset() {
const next = { ...model.value }
for (const field of props.fields) next[field.key] = resetValue(field)
model.value = next
emit('reset', next)
if (props.searchOnReset) emit('search', next)
}
defineExpose({ search, reset })
</script>
<template>
<section
v-bind="$attrs"
class="query-toolbar"
:aria-label="ariaLabel"
>
<div class="query-toolbar__filters">
<template v-for="field in fields" :key="field.key">
<el-input
v-if="field.type === 'input'"
:model-value="model[field.key] as string"
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
>
<template #prefix><Search :size="16" /></template>
</el-input>
<el-select
v-else-if="field.type === 'select'"
:model-value="model[field.key] as string | number | boolean | undefined"
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
>
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" :disabled="option.disabled" />
</el-select>
<el-date-picker
v-else
:model-value="model[field.key] as string | string[] | undefined"
:type="datePickerType(field)"
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
/>
</template>
</div>
<div class="query-toolbar__actions">
<el-button type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
<el-button @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
<slot name="actions" />
</div>
</section>
</template>
<style scoped>
.query-toolbar { display: flex; align-items: flex-end; justify-content: flex-start; flex-wrap: wrap; gap: 10px; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 10px; }
.query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 260px; max-width: 100%; min-width: 0; }
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
</style>
+1 -1
View File
@@ -8,8 +8,8 @@ declare module 'vue-router' {
interface RouteMeta {
title?: string
description?: string
showPageHeading?: boolean
requiresAuth?: boolean
permission?: string
}
}
+13 -3
View File
@@ -11,7 +11,7 @@ vi.mock('element-plus', async (importOriginal) => {
return { ...actual, ElMessage: { warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }
})
async function mountLayout() {
async function mountLayout(path = '/') {
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
@@ -20,9 +20,13 @@ async function mountLayout() {
auth.permissions = ['platform:warehouse:list']
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/', component: { template: '<p>page</p>' } }, { path: '/login', component: { template: '<p>login</p>' } }],
routes: [
{ path: '/', component: { template: '<p>page</p>' } },
{ path: '/customers', component: { template: '<p>customers</p>' }, meta: { title: '客户管理', showPageHeading: false } },
{ path: '/login', component: { template: '<p>login</p>' } },
],
})
await router.push('/')
await router.push(path)
await router.isReady()
const wrapper = mount(AppLayout, {
global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '<nav />' }, 'el-button': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
@@ -39,6 +43,12 @@ describe('AppLayout', () => {
expect(wrapper.text()).not.toContain('权限预览')
})
it('hides the shell heading when the route opts out', async () => {
const { wrapper } = await mountLayout('/customers')
expect(wrapper.find('.page-heading').exists()).toBe(false)
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
})
it('logs out after confirmation', async () => {
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
const { wrapper, router } = await mountLayout()
+7 -2
View File
@@ -20,6 +20,7 @@ const collapsed = ref(false)
const mobileMenuOpen = ref(false)
const pageTitle = computed(() => route.meta.title || '管理后台')
const pageDescription = computed(() => route.meta.description || '')
const showPageHeading = computed(() => route.meta.showPageHeading !== false)
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
access.initializeAfterLogin()
@@ -72,9 +73,9 @@ watch(() => route.fullPath, () => { mobileMenuOpen.value = false })
</aside>
<div class="app-main">
<header class="app-header">
<header class="app-header" :class="{ 'app-header--without-page-heading': !showPageHeading }">
<button class="mobile-menu-button" type="button" aria-label="打开菜单" :aria-expanded="mobileMenuOpen" @click="mobileMenuOpen = true"><Menu :size="20" /></button>
<div class="page-heading"><h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p></div>
<div v-if="showPageHeading" class="page-heading"><h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p></div>
<div class="header-user">
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
<div class="user-copy" :aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"><strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong></div>
@@ -85,3 +86,7 @@ watch(() => route.fullPath, () => { mobileMenuOpen.value = false })
</div>
</div>
</template>
<style scoped>
.app-header--without-page-heading .header-user { margin-left: auto; }
</style>
+5 -8
View File
@@ -27,9 +27,8 @@ const router = createRouter({
{
path: 'customers',
name: 'customers',
component: () => import('@/views/IntegrationUnavailableView.vue'),
props: { moduleName: '客户管理', description: '后端尚未提供平台客户档案列表及维护接口。企业注册审核请使用“企业审核”页面。' },
meta: { title: '客户管理', description: '维护客户企业资料、合作状态和注册审核', permission: 'platform:customer:list' },
component: () => import('@/views/CustomersView.vue'),
meta: { title: '客户管理', description: '维护客户企业资料、合作状态和注册审核', showPageHeading: false, permission: 'platform:customer:list' },
},
{
path: 'customers/reviews',
@@ -52,16 +51,14 @@ const router = createRouter({
{
path: 'master-data/express-companies',
name: 'express-companies',
component: () => import('@/views/IntegrationUnavailableView.vue'),
props: { moduleName: '快递公司', description: '后端尚未提供平台快递公司主数据的正式查询和维护接口。' },
component: () => import('@/views/ExpressCompaniesView.vue'),
meta: { title: '快递公司', description: '查询平台可用的快递公司档案与公开联系信息', permission: 'platform:express:list' },
},
{
path: 'master-data/counterparties',
name: 'counterparties',
component: () => import('@/views/IntegrationUnavailableView.vue'),
props: { moduleName: '往来单位', description: '现有往来单位接口仅面向企业端,后端尚未提供平台侧正式维护接口。' },
meta: { title: '往来单位', description: '维护供应商、客户、承运商、报关行及收发货单位档案', permission: 'platform:counterparty:list' },
component: () => import('@/views/PartnersView.vue'),
meta: { title: '往来单位', description: '维护客户、供应商及客户/供应商双重类型单位档案', permission: 'platform:counterparty:list' },
},
{
path: 'system/users',
+1
View File
@@ -4,6 +4,7 @@ declare module 'vue-router' {
interface RouteMeta {
title?: string
description?: string
showPageHeading?: boolean
requiresAuth?: boolean
permission?: string
}
+4 -4
View File
@@ -11,7 +11,7 @@ vi.mock('@/api/http', () => ({
http: { post: mocks.post },
registerAuthRefreshHandler: (handler: () => Promise<string>) => { mocks.refreshHandler = handler },
unwrapApiResponse: <T>(response: { code: string; message: string; data: T }) => {
if (response.code !== '0') throw new Error(response.message)
if (response.code !== '200') throw new Error(response.message)
return response.data
},
getApiErrorDetails: (error: { code?: string; message?: string }) => ({
@@ -54,7 +54,7 @@ function token(overrides: Record<string, unknown> = {}) {
function loginResponse(accessToken = token(), refreshToken = 'refresh-next') {
return {
code: '0',
code: '200',
message: 'success',
data: {
tokenType: 'Bearer',
@@ -103,7 +103,7 @@ describe('admin auth session', () => {
it('revokes an issued token pair when the permission snapshot prevents login completion', async () => {
mocks.post
.mockResolvedValueOnce(loginResponse())
.mockResolvedValueOnce({ code: '0', message: 'success', data: null })
.mockResolvedValueOnce({ code: '200', message: 'success', data: null })
mocks.listCurrentPlatformPermissionCodes.mockRejectedValue({ code: 'S0503', message: 'IAM 服务不可用' })
const auth = useAuthStore()
@@ -137,7 +137,7 @@ describe('admin auth session', () => {
if (url === '/auth/refresh') {
return new Promise((resolve) => { resolveRefresh = resolve })
}
return Promise.resolve({ code: '0', message: 'success', data: null })
return Promise.resolve({ code: '200', message: 'success', data: null })
})
const auth = useAuthStore()
+18
View File
@@ -0,0 +1,18 @@
:root {
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
color: #172033;
background: #f4f6f8;
font-synthesis: none;
--cc-primary: #155eef;
--cc-navy: #172a46;
--cc-border: #e3e8ef;
--cc-muted: #667085;
}
* { box-sizing: border-box; }
html, body, #app { width: 100%; min-width: 0; min-height: 100%; margin: 0; }
body { min-height: 100vh; background: #f4f6f8; }
button, input, textarea, select { font: inherit; }
button { letter-spacing: 0; }
:focus-visible { outline: 3px solid rgba(21, 94, 239, .28); outline-offset: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
+2 -74
View File
@@ -1,77 +1,5 @@
:root {
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
color: #172033;
background: #f4f6f8;
font-synthesis: none;
--cc-primary: #155eef;
--cc-navy: #172a46;
--cc-border: #e3e8ef;
--cc-muted: #667085;
}
* { box-sizing: border-box; }
html, body, #app { width: 100%; min-width: 0; min-height: 100%; margin: 0; }
body { min-height: 100vh; background: #f4f6f8; }
button, input, textarea, select { font: inherit; }
button { letter-spacing: 0; }
:focus-visible { outline: 3px solid rgba(21, 94, 239, .28); outline-offset: 2px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.app-shell { min-width: 0; min-height: 100vh; }
.app-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 20; display: flex; width: 240px; flex-direction: column; background: var(--cc-navy); color: #fff; }
.is-collapsed .app-sidebar { width: 76px; }
.brand-block { display: flex; height: 72px; align-items: center; padding: 0 18px; border-bottom: 1px solid rgba(255,255,255,.1); }
.brand-logo { display: block; object-fit: contain; object-position: left center; }
.brand-logo-full { width: 184px; height: 58px; }
.brand-logo-mark { display: none; width: 40px; height: 40px; }
.is-collapsed .brand-logo-full { display: none; }
.is-collapsed .brand-logo-mark { display: block; }
.side-nav { flex: 1; min-height: 0; overflow-y: auto; padding: 14px 10px; }
.side-nav-menu {
width: 100%;
border-right: 0 !important;
background: transparent !important;
--el-menu-bg-color: transparent;
--el-menu-text-color: #bdc7d7;
--el-menu-active-color: var(--cc-navy);
--el-menu-hover-bg-color: rgba(255,255,255,.08);
}
.side-nav-menu.el-menu--collapse { width: 56px; }
.side-nav-menu .el-menu-item,
.side-nav-menu .el-sub-menu__title { height: 44px; margin: 3px 0; border-radius: 8px; line-height: 44px; }
.side-nav-menu .el-menu-item:hover,
.side-nav-menu .el-sub-menu__title:hover { color: #fff; }
.side-nav-menu .el-menu-item.is-active { background: #fff; color: var(--cc-navy); font-weight: 650; }
.side-nav-menu .el-sub-menu .el-menu-item { min-width: 0; padding-left: 48px !important; }
.side-nav-menu .el-menu-item svg,
.side-nav-menu .el-sub-menu__title svg { flex: 0 0 auto; margin-right: 12px; }
.sidebar-toggle { display: flex; height: 52px; align-items: center; justify-content: center; gap: 8px; border: 0; border-top: 1px solid rgba(255,255,255,.1); background: transparent; color: #aebbd0; cursor: pointer; }
.app-main { min-width: 0; min-height: 100vh; margin-left: 240px; }
.is-collapsed .app-main { margin-left: 76px; }
.app-header { position: sticky; top: 0; z-index: 15; display: flex; min-width: 0; min-height: 72px; align-items: center; gap: 16px; padding: 10px 28px; border-bottom: 1px solid var(--cc-border); background: rgba(255,255,255,.96); }
.page-heading { min-width: 0; flex: 1; }
.page-heading h1 { margin: 0; overflow: hidden; font-size: 20px; letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; }
.page-heading p { margin: 4px 0 0; overflow: hidden; color: var(--cc-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.header-user { display: flex; min-width: 0; flex: 0 0 auto; align-items: center; gap: 10px; }
.user-avatar { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 50%; background: #eaf1ff; color: var(--cc-primary); font-weight: 700; }
.user-copy { display: flex; flex-direction: column; }
.user-copy strong { font-size: 13px; }
.user-copy span { color: var(--cc-muted); font-size: 11px; }
.mobile-menu-button { display: none; width: 44px; height: 44px; padding: 0; border: 0; background: none; cursor: pointer; }
.header-user .el-button { width: 44px; height: 44px; }
.page-container { padding: 24px 28px 40px; }
/* Static permission preview */
.app-header > .el-dropdown { min-width: 0; max-width: 320px; flex: 0 1 320px; }
.role-preview-trigger { display: flex; width: 100%; min-width: 0; min-height: 44px; align-items: center; gap: 10px; padding: 6px 10px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: #172033; text-align: left; cursor: pointer; }
.role-preview-trigger:hover { border-color: #b8c6d9; background: #f8fafc; }
.role-preview-trigger:focus-visible { border-color: var(--cc-primary); outline: 3px solid rgba(21, 94, 239, .2); outline-offset: 1px; }
.role-preview-trigger:disabled, .role-preview-trigger[aria-disabled="true"] { cursor: not-allowed; opacity: .58; }
.role-preview-trigger > svg { flex: 0 0 auto; }
.role-preview-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; }
.role-preview-copy strong, .role-preview-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.role-preview-copy strong { font-size: 12px; font-weight: 700; }
.role-preview-copy small { color: var(--cc-muted); font-size: 10px; line-height: 1.35; }
@import './foundation.css';
@import './shell.css';
.forbidden-page { display: flex; min-height: min(560px, calc(100vh - 136px)); align-items: center; justify-content: center; flex-direction: column; padding: clamp(28px, 7vw, 64px) clamp(18px, 6vw, 48px); border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; text-align: center; }
.forbidden-icon { display: grid; width: 56px; height: 56px; place-items: center; margin-bottom: 18px; border-radius: 8px; background: #fef3f2; color: #b42318; }
+55
View File
@@ -0,0 +1,55 @@
.app-shell { min-width: 0; min-height: 100vh; }
.app-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 20; display: flex; width: 240px; flex-direction: column; background: var(--cc-navy); color: #fff; }
.is-collapsed .app-sidebar { width: 76px; }
.brand-block { display: flex; height: 72px; align-items: center; padding: 0 18px; border-bottom: 1px solid rgba(255,255,255,.1); }
.brand-logo { display: block; object-fit: contain; object-position: left center; }
.brand-logo-full { width: 184px; height: 58px; }
.brand-logo-mark { display: none; width: 40px; height: 40px; }
.is-collapsed .brand-logo-full { display: none; }
.is-collapsed .brand-logo-mark { display: block; }
.side-nav { flex: 1; min-height: 0; overflow-y: auto; padding: 14px 10px; }
.side-nav-menu {
width: 100%;
border-right: 0 !important;
background: transparent !important;
--el-menu-bg-color: transparent;
--el-menu-text-color: #bdc7d7;
--el-menu-active-color: var(--cc-navy);
--el-menu-hover-bg-color: rgba(255,255,255,.08);
}
.side-nav-menu.el-menu--collapse { width: 56px; }
.side-nav-menu .el-menu-item,
.side-nav-menu .el-sub-menu__title { height: 44px; margin: 3px 0; border-radius: 8px; line-height: 44px; }
.side-nav-menu .el-menu-item:hover,
.side-nav-menu .el-sub-menu__title:hover { color: #fff; }
.side-nav-menu .el-menu-item.is-active { background: #fff; color: var(--cc-navy); font-weight: 650; }
.side-nav-menu .el-sub-menu .el-menu-item { min-width: 0; padding-left: 48px !important; }
.side-nav-menu .el-menu-item svg,
.side-nav-menu .el-sub-menu__title svg { flex: 0 0 auto; margin-right: 12px; }
.sidebar-toggle { display: flex; height: 52px; align-items: center; justify-content: center; gap: 8px; border: 0; border-top: 1px solid rgba(255,255,255,.1); background: transparent; color: #aebbd0; cursor: pointer; }
.app-main { min-width: 0; min-height: 100vh; margin-left: 240px; }
.is-collapsed .app-main { margin-left: 76px; }
.app-header { position: sticky; top: 0; z-index: 15; display: flex; min-width: 0; min-height: 72px; align-items: center; gap: 16px; padding: 10px 20px; border-bottom: 1px solid var(--cc-border); background: rgba(255,255,255,.96); }
.page-heading { min-width: 0; flex: 1; }
.page-heading h1 { margin: 0; overflow: hidden; font-size: 20px; letter-spacing: 0; text-overflow: ellipsis; white-space: nowrap; }
.page-heading p { margin: 4px 0 0; overflow: hidden; color: var(--cc-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.header-user { display: flex; min-width: 0; flex: 0 0 auto; align-items: center; gap: 10px; }
.user-avatar { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 50%; background: #eaf1ff; color: var(--cc-primary); font-weight: 700; }
.user-copy { display: flex; flex-direction: column; }
.user-copy strong { font-size: 13px; }
.user-copy span { color: var(--cc-muted); font-size: 11px; }
.mobile-menu-button { display: none; width: 44px; height: 44px; padding: 0; border: 0; background: none; cursor: pointer; }
.header-user .el-button { width: 44px; height: 44px; }
.page-container { padding: 20px 20px 32px; }
/* Static permission preview */
.app-header > .el-dropdown { min-width: 0; max-width: 320px; flex: 0 1 320px; }
.role-preview-trigger { display: flex; width: 100%; min-width: 0; min-height: 44px; align-items: center; gap: 10px; padding: 6px 10px; border: 1px solid var(--cc-border); border-radius: 8px; background: #fff; color: #172033; text-align: left; cursor: pointer; }
.role-preview-trigger:hover { border-color: #b8c6d9; background: #f8fafc; }
.role-preview-trigger:focus-visible { border-color: var(--cc-primary); outline: 3px solid rgba(21, 94, 239, .2); outline-offset: 1px; }
.role-preview-trigger:disabled, .role-preview-trigger[aria-disabled="true"] { cursor: not-allowed; opacity: .58; }
.role-preview-trigger > svg { flex: 0 0 auto; }
.role-preview-copy { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 2px; }
.role-preview-copy strong, .role-preview-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.role-preview-copy strong { font-size: 12px; font-weight: 700; }
.role-preview-copy small { color: var(--cc-muted); font-size: 10px; line-height: 1.35; }
+1
View File
@@ -105,6 +105,7 @@ export interface Permission {
method: string
status: RoleStatus
sort: number
builtIn?: boolean
}
export interface OrderTimelineItem {
+31
View File
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { copyText } from './copyText'
describe('copyText', () => {
afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
Reflect.deleteProperty(document, 'execCommand')
document.querySelectorAll('textarea[aria-hidden="true"]').forEach((element) => element.remove())
})
it('uses the Clipboard API when it is available', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', { clipboard: { writeText } })
await copyText('request-123')
expect(writeText).toHaveBeenCalledWith('request-123')
})
it('falls back to a temporary textarea when Clipboard API access is denied', async () => {
vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('denied')) } })
const execCommand = vi.fn().mockReturnValue(true)
Object.defineProperty(document, 'execCommand', { configurable: true, value: execCommand })
await copyText('trace-456')
expect(execCommand).toHaveBeenCalledWith('copy')
expect(document.querySelector('textarea[aria-hidden="true"]')).toBeNull()
})
})
+35
View File
@@ -0,0 +1,35 @@
export async function copyText(value: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value)
return
} catch {
// In insecure development contexts the Clipboard API may exist but reject.
}
}
if (typeof document === 'undefined' || !document.body) {
throw new Error('Clipboard is unavailable')
}
const textarea = document.createElement('textarea')
textarea.value = value
textarea.readOnly = true
textarea.setAttribute('aria-hidden', 'true')
Object.assign(textarea.style, {
position: 'fixed',
inset: '0 auto auto -9999px',
opacity: '0',
pointerEvents: 'none',
})
document.body.appendChild(textarea)
textarea.select()
try {
if (typeof document.execCommand !== 'function' || !document.execCommand('copy')) {
throw new Error('Clipboard copy failed')
}
} finally {
textarea.remove()
}
}
+1
View File
@@ -13,5 +13,6 @@ export function mapPlatformPermission(item: PlatformPermissionResource): Permiss
method: item.httpMethod ?? '-',
status: item.status === 'ACTIVE' ? '启用' : '停用',
sort: item.sortNo,
builtIn: Boolean(item.builtIn),
}
}
+165
View File
@@ -0,0 +1,165 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
import { UsersRound } from '@lucide/vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
import {
listPlatformTenants,
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'
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 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' },
],
},
]
let loadSequence = 0
function displayText(value?: string | null) {
return value?.trim() || '—'
}
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: 'status', label: '状态', width: 110, slotName: 'status' },
]
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' }
}
async function loadCustomers() {
const sequence = ++loadSequence
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,
})
if (sequence !== loadSequence) return
result.value = {
records: pageResult.records,
total: pageResult.total,
pages: pageResult.pages,
}
} catch (error) {
if (sequence !== loadSequence) return
loadError.value = getApiErrorDetails(error, '客户列表加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
function applyFilters() {
if (page.value === 1) {
void loadCustomers()
return
}
page.value = 1
}
watch([page, pageSize], () => { void loadCustomers() })
onMounted(() => {
void 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-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>
</DataTable>
</section>
</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; }
}
</style>
+76 -61
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Eye, FileCheck2, RefreshCw, Search, ShieldCheck, ShieldX } from '@lucide/vue'
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { Eye, FileCheck2, ShieldCheck, ShieldX } from '@lucide/vue'
import {
approveEnterpriseReview,
downloadTenantQualification,
@@ -14,14 +14,22 @@ import {
} from '@/api/platformIam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http'
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
import MetricStrip from '@/components/MetricStrip.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'
const access = useAccessPreviewStore()
const loading = ref(false)
const loadError = ref<ApiErrorDetails | null>(null)
const reviews = ref<EnterpriseReview[]>([])
const keyword = ref('')
const page = ref(1)
const pageSize = ref(10)
type ReviewFilters = QueryModel & { keyword: string }
const filters = ref<ReviewFilters>({ keyword: '' })
const appliedFilters = ref<ReviewFilters>({ keyword: '' })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '企业名称、企业编号、信用代码或审核单号' },
]
const detailOpen = ref(false)
const selectedReview = ref<EnterpriseReview | null>(null)
const qualifications = ref<PlatformTenantQualification[]>([])
@@ -34,9 +42,22 @@ const previewName = ref('')
const decisionOpen = ref(false)
const submitting = ref(false)
const decision = reactive({ type: 'approve' as 'approve' | 'reject', comment: '' })
const decisionFormRef = ref<FormInstance>()
const decisionRules: FormRules = {
comment: [{
validator: (_rule, value, callback) => {
if (typeof value === 'string' && value.trim()) {
callback()
return
}
callback(new Error('请填写审核说明或驳回原因'))
},
trigger: ['blur', 'change'],
}],
}
const filteredReviews = computed(() => {
const query = keyword.value.trim().toLowerCase()
const query = appliedFilters.value.keyword.trim().toLowerCase()
if (!query) return reviews.value
return reviews.value.filter((review) => [
review.tenantName,
@@ -47,12 +68,19 @@ const filteredReviews = computed(() => {
].some((value) => value?.toLowerCase().includes(query)))
})
const metrics = computed(() => [
{ label: '待审核企业', value: reviews.value.length, note: '当前需要平台处理' },
{ label: '营业执照', value: reviews.value.filter((item) => item.qualificationType === 'BUSINESS_LICENSE').length, note: '本期待核验资质' },
{ label: '可通过', value: access.can('platform:customer:approve') ? reviews.value.length : 0, note: '按当前账号权限' },
{ label: '当前结果', value: filteredReviews.value.length, note: keyword.value ? '符合搜索条件' : '全部待审记录' },
])
const pagedReviews = computed(() => {
const start = (page.value - 1) * pageSize.value
return filteredReviews.value.slice(start, start + pageSize.value)
})
const reviewColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 250, fixed: 'left', slotName: 'actions' },
{ key: 'company', label: '企业', minWidth: 230, slotName: 'company' },
{ key: 'qualification', label: '资质', minWidth: 210, slotName: 'qualification' },
{ key: 'contact', label: '联系人', minWidth: 180, slotName: 'contact' },
{ key: 'submittedAt', label: '提交时间', minWidth: 175, slotName: 'submittedAt' },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
]
function displayText(value: unknown) {
return typeof value === 'string' && value.trim() ? value : '—'
@@ -86,6 +114,12 @@ async function loadReviews() {
}
}
function searchReviews() {
appliedFilters.value = { ...filters.value }
page.value = 1
void loadReviews()
}
async function loadSelectedQualifications() {
const tenantId = selectedReview.value?.tenantId
if (!tenantId) return
@@ -139,15 +173,15 @@ function openDecision(review: EnterpriseReview, type: 'approve' | 'reject') {
decision.type = type
decision.comment = type === 'approve' ? '营业执照清晰,企业资料审核通过' : ''
decisionOpen.value = true
void nextTick(() => decisionFormRef.value?.clearValidate('comment'))
}
async function submitDecision() {
const review = selectedReview.value
if (!review || submitting.value) return
if (!decision.comment.trim()) {
ElMessage.warning(decision.type === 'approve' ? '请填写审核说明' : '请填写驳回原因')
return
}
const form = decisionFormRef.value
if (!review || !form || submitting.value) return
const valid = await form.validate().then(() => true).catch(() => false)
if (!valid) return
submitting.value = true
try {
@@ -189,21 +223,8 @@ onBeforeUnmount(clearPreviewUrl)
</script>
<template>
<!-- THESIS: 把企业审核组织成一条清晰的待办队列,与客户档案保持清楚的任务边界。
OWN-WORLD: 延续 Admin 的海军蓝框架、白色数据面板、紧凑表格和克制状态色。
STORY: 审核员先识别待审企业,再核对营业执照与联系人,最后留下明确的通过或驳回意见。
FIRST VIEWPORT: 摘要、搜索和真实待审列表连续呈现,详情从右侧展开,决策在受保护的确认对话框中完成。
FORM: 现有 Operate 页面体系的直接扩展,不改变全局导航与品牌语言。
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md -->
<div class="business-page enterprise-review-page">
<MetricStrip :items="metrics" />
<section class="surface-panel review-toolbar" aria-label="企业审核筛选">
<el-input v-model="keyword" clearable placeholder="企业名称、企业编号、信用代码或审核单号" aria-label="企业审核关键词">
<template #prefix><Search :size="16" /></template>
</el-input>
<el-button :loading="loading" @click="loadReviews"><RefreshCw :size="16" />刷新</el-button>
</section>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="企业审核筛选" :fields="queryFields" @search="searchReviews" />
<section v-if="loadError" class="surface-panel review-error" role="alert">
<ShieldX :size="22" />
@@ -215,35 +236,30 @@ onBeforeUnmount(clearPreviewUrl)
<header class="panel-heading">
<div><h2>企业资料审核</h2><p>仅展示后端返回的待审核记录,提交审核时使用审核单 ID。</p></div>
</header>
<el-table v-loading="loading" :data="filteredReviews" row-key="id" empty-text="暂无待审核企业">
<el-table-column label="操作" width="250" fixed="left">
<template #default="{ row }">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="pagedReviews"
:columns="reviewColumns"
:loading="loading"
:total="filteredReviews.length"
aria-label="企业审核列表"
row-key="id"
empty-text="暂无待审核企业"
>
<template #actions="{ row }">
<div class="table-actions">
<el-button link type="primary" @click="openDetail(row as EnterpriseReview)"><Eye :size="15" />详情</el-button>
<el-button v-if="access.can('platform:customer:approve')" link type="success" @click="openDecision(row as EnterpriseReview, 'approve')"><ShieldCheck :size="15" />通过</el-button>
<el-button v-if="access.can('platform:customer:reject')" link type="danger" @click="openDecision(row as EnterpriseReview, 'reject')"><ShieldX :size="15" />驳回</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="企业" min-width="230">
<template #default="{ row }">
<div class="primary-cell"><strong>{{ displayText((row as EnterpriseReview).tenantName) }}</strong><span>{{ displayText((row as EnterpriseReview).tenantCode) }} · 租户 {{ (row as EnterpriseReview).tenantId }}</span></div>
</template>
</el-table-column>
<el-table-column label="资质" min-width="210">
<template #default="{ row }">
<div class="primary-cell"><strong>{{ qualificationName((row as EnterpriseReview).qualificationType) }}</strong><span>{{ displayText((row as EnterpriseReview).certificateNo) }}</span></div>
</template>
</el-table-column>
<el-table-column label="联系人" min-width="180">
<template #default="{ row }">
<div class="primary-cell"><strong>{{ displayText((row as EnterpriseReview).contactName) }}</strong><span>{{ displayText((row as EnterpriseReview).contactPhone) }}</span></div>
</template>
</el-table-column>
<el-table-column label="提交时间" min-width="175"><template #default="{ row }">{{ formatDate((row as EnterpriseReview).submittedAt ?? (row as EnterpriseReview).createdAt) }}</template></el-table-column>
<el-table-column label="状态" width="110"><template #default><span class="status-badge is-warning"><i />待审核</span></template></el-table-column>
</el-table>
<footer class="table-footer"><span>共 {{ filteredReviews.length }} 条待审记录</span><span v-if="keyword">已按关键词筛选</span></footer>
</template>
<template #company="{ row }"><div class="primary-cell"><strong>{{ displayText((row as EnterpriseReview).tenantName) }}</strong><span>{{ displayText((row as EnterpriseReview).tenantCode) }} · 租户 {{ (row as EnterpriseReview).tenantId }}</span></div></template>
<template #qualification="{ row }"><div class="primary-cell"><strong>{{ qualificationName((row as EnterpriseReview).qualificationType) }}</strong><span>{{ displayText((row as EnterpriseReview).certificateNo) }}</span></div></template>
<template #contact="{ row }"><div class="primary-cell"><strong>{{ displayText((row as EnterpriseReview).contactName) }}</strong><span>{{ displayText((row as EnterpriseReview).contactPhone) }}</span></div></template>
<template #submittedAt="{ row }">{{ formatDate((row as EnterpriseReview).submittedAt ?? (row as EnterpriseReview).createdAt) }}</template>
<template #status><span class="status-badge is-warning"><i />待审核</span></template>
</DataTable>
</section>
<el-drawer v-model="detailOpen" title="企业审核详情" size="620px" class="business-drawer">
@@ -312,8 +328,8 @@ onBeforeUnmount(clearPreviewUrl)
<strong>{{ displayText(selectedReview.tenantName) }}</strong>
<span>审核单 {{ selectedReview.id }}</span>
</div>
<el-form label-position="top">
<el-form-item :label="decision.type === 'approve' ? '审核说明' : '驳回原因'" required>
<el-form ref="decisionFormRef" :model="decision" :rules="decisionRules" label-position="top">
<el-form-item prop="comment" :label="decision.type === 'approve' ? '审核说明' : '驳回原因'" required>
<el-input v-model="decision.comment" type="textarea" :rows="4" maxlength="300" show-word-limit :placeholder="decision.type === 'approve' ? '请填写审核通过说明' : '请明确说明需要企业修改的内容'" />
</el-form-item>
</el-form>
@@ -328,8 +344,6 @@ onBeforeUnmount(clearPreviewUrl)
</template>
<style scoped>
.review-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; }
.review-toolbar .el-input { width: min(460px, 100%); }
.review-error { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 18px 20px; border-color: #fecdca; background: #fffafa; color: #b42318; }
.review-error strong { color: #7a271a; font-size: 13px; }
.review-error p { margin: 4px 0 0; color: #b54708; font-size: 12px; }
@@ -347,14 +361,15 @@ onBeforeUnmount(clearPreviewUrl)
.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; }
.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; }
.decision-summary span { color: var(--cc-muted); font-size: 11px; }
:deep(.review-decision-dialog .el-form-item__error) { line-height: 1.45; overflow-wrap: anywhere; }
@media (max-width: 720px) {
.review-toolbar { align-items: stretch; flex-direction: column; }
.review-toolbar .el-input { width: 100%; }
.review-error { grid-template-columns: auto minmax(0, 1fr); }
.review-error .el-button { grid-column: 1 / -1; min-height: 44px; }
.qualification-error, .qualification-item { align-items: stretch; flex-direction: column; }
.qualification-error .el-button, .qualification-item .el-button { min-height: 44px; }
.decision-summary { align-items: flex-start; flex-direction: column; }
}
</style>
+98
View File
@@ -0,0 +1,98 @@
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 { listPlatformExpressCompanies, type PlatformExpressCompanyResource } from '@/api/platformExpressCompanies'
import { useAccessPreviewStore } from '@/stores/accessPreview'
import { useAuthStore } from '@/stores/auth'
import ExpressCompaniesView from './ExpressCompaniesView.vue'
vi.mock('@/api/platformExpressCompanies', () => ({
listPlatformExpressCompanies: vi.fn(),
createPlatformExpressCompany: vi.fn(),
updatePlatformExpressCompany: vi.fn(),
changePlatformExpressCompanyStatus: 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 company: PlatformExpressCompanyResource = {
id: '19001',
tenantId: 10001,
companyCode: 'SF',
companyName: '顺丰速运',
servicePhone: '95338',
website: 'https://www.sf-express.com',
trackingUrlTemplate: 'https://example.com/track/{trackingNo}',
status: 'ACTIVE',
remark: '测试数据',
updatedAt: '2026-07-31T10:20:00',
}
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:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status']
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(ExpressCompaniesView, {
attachTo: document.body,
global: { plugins: [pinia, ElementPlus] },
})
await flushPromises()
return wrapper
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('ExpressCompaniesView query and table migration', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
document.body.innerHTML = ''
vi.mocked(listPlatformExpressCompanies).mockReset().mockResolvedValue({
records: [company], page: 1, size: 20, total: 1, pages: 1,
})
})
it('renders the shared toolbar and data table without a summary strip', async () => {
const wrapper = await mountView()
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
expect(wrapper.find('.data-table').exists()).toBe(true)
expect(wrapper.text()).toContain('顺丰速运')
expect(wrapper.find('.metric-strip').exists()).toBe(false)
})
it('does not request with edited filters until search is submitted', async () => {
const wrapper = await mountView()
await wrapper.find('input[placeholder="快递公司名称或编码"]').setValue('顺丰')
await flushPromises()
expect(listPlatformExpressCompanies).toHaveBeenCalledTimes(1)
const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === '查询')
await searchButton?.trigger('click')
await flushPromises()
expect(listPlatformExpressCompanies).toHaveBeenLastCalledWith(10001, 1, 20, '顺丰', '')
})
})
+346
View File
@@ -0,0 +1,346 @@
<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 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 { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
import {
changePlatformExpressCompanyStatus,
createPlatformExpressCompany,
listPlatformExpressCompanies,
updatePlatformExpressCompany,
type PlatformExpressCompanyResource,
type PlatformExpressCompanyStatus,
type SavePlatformExpressCompanyRequest,
} from '@/api/platformExpressCompanies'
import { useAccessPreviewStore } from '@/stores/accessPreview'
const fixedTenantId = 10001
const access = useAccessPreviewStore()
const loading = ref(false)
const saving = ref(false)
const editorOpen = ref(false)
const formRef = ref<FormInstance>()
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>())
type ExpressCompanyFilters = QueryModel & {
keyword: string
status: PlatformExpressCompanyStatus | ''
}
const filters = ref<ExpressCompanyFilters>({ keyword: '', status: '' })
const appliedFilters = ref<ExpressCompanyFilters>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '快递公司名称或编码', props: { maxlength: 128, 'aria-label': '快递公司关键词' } },
{
key: 'status',
type: 'select',
placeholder: '全部状态',
props: { 'aria-label': '快递公司状态' },
options: [
{ label: '启用', value: 'ACTIVE' },
{ label: '停用', value: 'INACTIVE' },
],
},
]
let loadSequence = 0
const emptyForm = (): SavePlatformExpressCompanyRequest & { id: string } => ({
id: '',
companyCode: '',
companyName: '',
servicePhone: null,
website: null,
trackingUrlTemplate: null,
status: 'ACTIVE',
remark: null,
})
const form = reactive(emptyForm())
const rules: FormRules<typeof form> = {
companyCode: [{ required: true, message: '请输入快递公司编码', trigger: 'blur' }],
companyName: [{ required: true, message: '请输入快递公司名称', trigger: 'blur' }],
website: [{ type: 'url', message: '请输入以 http:// 或 https:// 开头的完整网址', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
const editorTitle = computed(() => form.id ? '编辑快递公司' : '新增快递公司')
const editorPermission = computed(() => form.id ? 'platform:express:edit' : 'platform:express:create')
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'express-company-editor',
label: editorTitle,
isOpen: editorOpen,
model: form,
requiredPermission: editorPermission,
})
watch(
() => access.can(editorPermission.value),
(canWrite) => {
if (canWrite || !editorOpen.value) return
editorOpen.value = false
Object.assign(form, emptyForm())
},
{ flush: 'sync' },
)
function optionalText(value: string | null) {
const trimmed = value?.trim()
return trimmed || null
}
function safeHttpUrl(value: string | null) {
if (!value) return null
try {
const url = new URL(value)
return ['http:', 'https:'].includes(url.protocol) ? url.toString() : null
} catch {
return null
}
}
function formatUpdatedAt(value?: string | null) {
return value?.replace('T', ' ').slice(0, 16) || '—'
}
const expressCompanyColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 170, fixed: 'left', slotName: 'actions' },
{ key: 'company', label: '快递公司', minWidth: 210, slotName: 'company' },
{ prop: 'servicePhone', label: '客服电话', minWidth: 130, formatter: (row: PlatformExpressCompanyResource) => row.servicePhone || '—' },
{ key: 'website', label: '官方网站', minWidth: 210, slotName: 'website' },
{ key: 'trackingUrlTemplate', label: '轨迹查询模板', minWidth: 260, slotName: 'trackingUrlTemplate' },
{ key: 'remark', label: '备注', minWidth: 200, slotName: 'remark' },
{ prop: 'updatedAt', label: '更新时间', minWidth: 150, formatter: (row: PlatformExpressCompanyResource) => formatUpdatedAt(row.updatedAt) },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
]
async function loadExpressCompanies() {
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const result = await listPlatformExpressCompanies(
fixedTenantId,
page.value,
pageSize.value,
appliedFilters.value.keyword,
appliedFilters.value.status,
)
if (sequence !== loadSequence) return
rows.value = result.records
total.value = result.total
} catch (error) {
if (sequence !== loadSequence) return
rows.value = []
total.value = 0
loadError.value = getApiErrorDetails(error, '快递公司加载失败,请稍后重试')
} finally {
if (sequence === loadSequence) loading.value = false
}
}
function applyFilters(model: QueryModel) {
appliedFilters.value = {
keyword: String(model.keyword || ''),
status: (model.status || '') as PlatformExpressCompanyStatus | '',
}
if (page.value === 1) {
void loadExpressCompanies()
return
}
page.value = 1
}
function openCreate() {
Object.assign(form, emptyForm())
captureBaseline()
editorOpen.value = true
void formRef.value?.clearValidate()
}
function openEdit(row: PlatformExpressCompanyResource) {
Object.assign(form, {
id: String(row.id),
companyCode: row.companyCode,
companyName: row.companyName,
servicePhone: row.servicePhone,
website: row.website,
trackingUrlTemplate: row.trackingUrlTemplate,
status: row.status,
remark: row.remark,
})
captureBaseline()
editorOpen.value = true
void formRef.value?.clearValidate()
}
function buildPayload(): SavePlatformExpressCompanyRequest {
return {
companyCode: form.companyCode.trim(),
companyName: form.companyName.trim(),
servicePhone: optionalText(form.servicePhone),
website: optionalText(form.website),
trackingUrlTemplate: optionalText(form.trackingUrlTemplate),
status: form.status,
remark: optionalText(form.remark),
}
}
async function saveExpressCompany() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const payload = buildPayload()
if (form.id) await updatePlatformExpressCompany(fixedTenantId, form.id, payload)
else await createPlatformExpressCompany(fixedTenantId, payload)
editorOpen.value = false
ElMessage.success(form.id ? '快递公司资料已更新' : '快递公司已创建')
await loadExpressCompanies()
} catch (error) {
const details = getApiErrorDetails(error, '快递公司保存失败,请检查后重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
saving.value = false
}
}
async function toggleStatus(row: PlatformExpressCompanyResource) {
const nextStatus: PlatformExpressCompanyStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
const action = nextStatus === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox.confirm(
`确定${action}“${row.companyName}”吗?${nextStatus === 'INACTIVE' ? '停用后不会再作为可用快递公司。' : ''}`,
`${action}快递公司`,
{ type: nextStatus === 'ACTIVE' ? 'success' : 'warning', confirmButtonText: `确认${action}`, cancelButtonText: '取消' },
)
} catch {
return
}
const id = String(row.id)
const pending = new Set(statusLoadingIds.value)
pending.add(id)
statusLoadingIds.value = pending
try {
await changePlatformExpressCompanyStatus(fixedTenantId, row.id, nextStatus)
ElMessage.success(`已${action}${row.companyName}`)
await loadExpressCompanies()
} catch (error) {
const details = getApiErrorDetails(error, `${action}快递公司失败`)
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
const next = new Set(statusLoadingIds.value)
next.delete(id)
statusLoadingIds.value = next
}
}
watch([page, pageSize], () => { void loadExpressCompanies() })
onMounted(() => {
void loadExpressCompanies()
})
</script>
<template>
<div class="business-page express-company-page">
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
<template #title>快递公司加载失败</template>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
<el-button size="small" @click="loadExpressCompanies">重新查询</el-button>
</el-alert>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="快递公司筛选" :fields="queryFields" @search="applyFilters">
<template #actions>
<el-button v-if="access.can('platform:express:create')" type="primary" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
</template>
</QueryToolbar>
<section class="surface-panel table-panel">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="rows"
:columns="expressCompanyColumns"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100]"
aria-label="快递公司列表"
row-key="id"
:empty-text="appliedFilters.keyword.trim() || appliedFilters.status ? '暂无符合筛选条件的快递公司' : '暂无快递公司档案'"
>
<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:status')"
link
:type="(row as PlatformExpressCompanyResource).status === 'ACTIVE' ? 'danger' : 'success'"
:loading="statusLoadingIds.has(String((row as PlatformExpressCompanyResource).id))"
@click="toggleStatus(row as PlatformExpressCompanyResource)"
><Power :size="15" />{{ (row as PlatformExpressCompanyResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
<span v-else class="muted-value">仅查看</span>
</template>
<template #company="{ row }"><div class="express-company-cell"><span><Truck :size="17" /></span><div><strong>{{ (row as PlatformExpressCompanyResource).companyName }}</strong><small>{{ (row as PlatformExpressCompanyResource).companyCode }}</small></div></div></template>
<template #website="{ row }">
<a v-if="safeHttpUrl((row as PlatformExpressCompanyResource).website)" class="external-link" :href="safeHttpUrl((row as PlatformExpressCompanyResource).website)!" target="_blank" rel="noopener noreferrer">访问官网<ExternalLink :size="13" /></a>
<span v-else class="muted-value">{{ (row as PlatformExpressCompanyResource).website || '—' }}</span>
</template>
<template #trackingUrlTemplate="{ row }"><code class="tracking-template">{{ (row as PlatformExpressCompanyResource).trackingUrlTemplate || '—' }}</code></template>
<template #remark="{ row }"><span class="remark-cell">{{ (row as PlatformExpressCompanyResource).remark || '—' }}</span></template>
<template #status="{ row }"><el-tag :type="(row as PlatformExpressCompanyResource).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as PlatformExpressCompanyResource).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #empty>
<el-empty v-if="!loading" :description="appliedFilters.keyword.trim() || appliedFilters.status ? '暂无符合筛选条件的快递公司' : '暂无快递公司档案'">
<el-button v-if="!appliedFilters.keyword.trim() && !appliedFilters.status && access.can('platform:express:create')" type="primary" @click="openCreate"><Plus :size="16" />新增第一家快递公司</el-button>
</el-empty>
</template>
<template #pagination-left="{ total: tableTotal }"><span>共 {{ tableTotal }} 家快递公司</span></template>
</DataTable>
</section>
<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">
<h3>公司标识</h3>
<div class="form-grid two-cols">
<el-form-item label="快递公司编码" prop="companyCode" required><el-input v-model.trim="form.companyCode" maxlength="50" placeholder="例如 SF" /></el-form-item>
<el-form-item label="快递公司名称" prop="companyName" required><el-input v-model.trim="form.companyName" maxlength="128" placeholder="例如 顺丰速运" /></el-form-item>
</div>
<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 class="form-section">
<h3>服务与查询</h3>
<div class="form-grid two-cols">
<el-form-item label="客服电话"><el-input v-model.trim="form.servicePhone" maxlength="32" placeholder="例如 95338" /></el-form-item>
<el-form-item label="官方网站" prop="website"><el-input v-model.trim="form.website" maxlength="255" placeholder="https://www.example.com" /></el-form-item>
</div>
<el-form-item label="轨迹查询地址模板"><el-input v-model.trim="form.trackingUrlTemplate" maxlength="500" placeholder="https://example.com/track/{trackingNo}" /></el-form-item>
<p class="form-help">可使用 <code>{trackingNo}</code> 作为快递单号占位符;一期不包含轨迹订阅和自动回传。</p>
</div>
<div class="form-section"><h3>备注</h3><el-form-item label="内部说明"><el-input v-model.trim="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit placeholder="请输入适用范围、服务限制或其他说明" /></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="saveExpressCompany">保存快递公司</el-button></div></template>
</el-drawer>
</div>
</template>
<style scoped>
.state-alert p { margin: 6px 0 0; line-height: 1.55; }
.state-alert .el-button { margin-top: 10px; }
.express-company-cell { display: flex; min-width: 0; align-items: center; gap: 10px; }
.express-company-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 7px; background: #eef3fa; color: var(--cc-primary); }
.express-company-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.express-company-cell strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.express-company-cell small { color: var(--cc-muted); font-size: 11px; }
.external-link { display: inline-flex; align-items: center; gap: 5px; color: var(--cc-primary); font-size: 12px; text-decoration: none; }
.external-link:hover { text-decoration: underline; }
.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; }
</style>
+193 -57
View File
@@ -1,57 +1,96 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, RotateCcw, Search } from '@lucide/vue'
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 {
assignPlatformInternalUserRoles,
changePlatformInternalUserStatus,
createPlatformInternalUser,
listPlatformInternalUsers,
listPlatformRoles,
resetPlatformInternalUserPassword,
updatePlatformInternalUser,
type PlatformInternalUser,
type PlatformRoleResource,
} from '@/api/platformIam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http'
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
import MetricStrip from '@/components/MetricStrip.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 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 rowActionId = ref('')
const page = ref(1)
const pageSize = 10
const filters = reactive({ keyword: '', status: '' })
const emptyForm = () => ({ authUserId: '', userType: 'PLATFORM_STAFF', displayName: '', phone: '', email: '' })
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 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',
label: computed(() => '新增内部用户'),
label: editorLabel,
isOpen: editorOpen,
model: form,
requiredPermission: computed(() => 'platform:user:create'),
requiredPermission: computed(() => editingMembershipId.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 = filters.keyword.trim().toLowerCase()
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 && (!filters.status || item.status === filters.status)
return matchesKeyword && (!appliedFilters.value.status || item.status === appliedFilters.value.status)
}))
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
const metrics = computed(() => [
{ label: '内部用户', value: users.value.length, note: 'IAM 返回的用户' },
{ label: '正常账号', value: users.value.filter((item) => item.status === 'ACTIVE').length, note: '当前有效' },
{ label: '停用账号', value: users.value.filter((item) => item.status !== 'ACTIVE').length, note: '只读展示' },
])
watch(filters, () => { page.value = 1 })
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: 'status', label: '状态', width: 100, slotName: 'status' },
]
async function loadUsers() {
loading.value = true
@@ -68,70 +107,167 @@ async function loadUsers() {
}
}
function resetFilters() { Object.assign(filters, { keyword: '', status: '' }) }
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()); captureBaseline(); editorOpen.value = true }
function openCreate() {
Object.assign(form, emptyForm())
editingMembershipId.value = null
captureBaseline()
editorOpen.value = true
nextTick(() => formRef.value?.clearValidate())
}
function openEdit(row: PlatformInternalUser) {
if (!canEdit.value) return
editingMembershipId.value = String(row.membershipId)
Object.assign(form, {
authUserId: String(row.authUserId),
userType: row.memberType,
displayName: row.displayName,
phone: row.phone ?? '',
email: row.email ?? '',
memberNo: row.memberNo ?? '',
roleIds: row.roleIds.map(String),
})
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 isCurrentUser(row: PlatformInternalUser) {
return String(row.userId) === auth.user?.id || String(row.authUserId) === auth.user?.id
}
async function saveUser() {
if (!form.authUserId.trim() || !form.displayName.trim() || !form.userType.trim()) {
ElMessage.warning('请填写认证用户 ID、姓名和用户类型')
return
}
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
await createPlatformInternalUser({ ...form, authUserId: form.authUserId.trim() })
if (editingMembershipId.value) {
await updatePlatformInternalUser(editingMembershipId.value, {
displayName: form.displayName.trim(),
phone: form.phone.trim(),
email: form.email.trim(),
memberNo: form.memberNo.trim(),
roleIds: form.roleIds,
})
} 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,
})
}
editorOpen.value = false
ElMessage.success('内部用户已创建')
ElMessage.success(editingMembershipId.value ? '内部用户已更新' : '内部用户已创建')
await loadUsers()
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '内部用户创建失败,请稍后重试'))
ElMessage.error(getApiErrorMessage(error, editingMembershipId.value ? '内部用户更新失败,请稍后重试' : '内部用户创建失败,请稍后重试'))
} finally {
saving.value = false
}
}
async function toggleStatus(row: PlatformInternalUser) {
const target = row.status === '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: '确认重置',
cancelButtonText: '取消',
inputType: 'password',
inputPlaceholder: '8-64 位,必须包含字母和数字',
inputValidator: (password) => /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/.test(password) || '密码需为 8-64 位,且同时包含字母和数字',
})
rowActionId.value = String(row.membershipId)
await resetPlatformInternalUserPassword(row.membershipId, value)
ElMessage.success('密码已重置')
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorMessage(error, '密码重置失败,请稍后重试'))
} 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
}
}
onMounted(loadUsers)
</script>
<template>
<div class="business-page">
<MetricStrip :items="metrics" />
<el-alert type="info" :closable="false" show-icon title="成员列表已返回当前角色关系;编辑、启停、重置密码及角色分配操作尚未接入本页面。" />
<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>
</el-alert>
<section class="surface-panel filter-panel" aria-label="内部用户筛选">
<div class="filter-fields">
<el-input v-model="filters.keyword" clearable placeholder="登录账号、认证用户 ID、姓名或成员编号" aria-label="用户关键词"><template #prefix><Search :size="16" /></template></el-input>
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="用户状态"><el-option label="正常" value="ACTIVE" /><el-option label="停用" value="INACTIVE" /></el-select>
</div>
<div class="filter-actions"><el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></div>
</section>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="内部用户筛选" :fields="queryFields" @search="applyFilters"><template #actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></template></QueryToolbar>
<section class="surface-panel table-panel">
<header class="panel-heading"><div><h2>内部用户</h2><p>数据来自平台 IAM,未开放的操作不会在前端模拟执行。</p></div></header>
<el-table v-loading="loading" :data="pagedRows" row-key="membershipId" empty-text="没有符合条件的内部用户">
<el-table-column label="操作" width="100" fixed="left"><template #default><span class="secondary-text">仅查看</span></template></el-table-column>
<el-table-column prop="displayName" label="姓名" min-width="150" />
<el-table-column prop="username" label="登录账号" min-width="150" />
<el-table-column prop="authUserId" label="认证用户 ID" min-width="145" />
<el-table-column prop="memberNo" label="成员编号" min-width="170" />
<el-table-column prop="memberType" label="成员类型" min-width="130" />
<el-table-column label="角色" min-width="190"><template #default="{ row }"><div class="tag-list"><el-tag v-for="roleId in row.roleIds" :key="roleId" effect="plain" size="small">{{ roleLabel(roleId) }}</el-tag><span v-if="row.roleIds.length === 0" class="secondary-text">未分配</span></div></template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><StatusBadge :status="row.status === 'ACTIVE' ? '正常' : '停用'" /></template></el-table-column>
</el-table>
<footer class="table-footer"><span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="filteredRows.length" layout="prev, pager, next" /></footer>
<header class="panel-heading"><div><h2>内部用户</h2><p>数据和维护操作均由平台 IAM 提供,角色关系按成员维度保存。</p></div></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>
</DataTable>
</section>
<el-drawer v-model="editorOpen" title="新增内部用户" size="560px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
<el-form label-position="top" class="drawer-form">
<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" required><el-input v-model="form.authUserId" placeholder="已存在的认证账号 ID" /></el-form-item>
<el-form-item label="姓名" required><el-input v-model="form.displayName" maxlength="64" /></el-form-item>
<el-form-item label="用户类型" required><el-input v-model="form.userType" maxlength="32" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
<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-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button v-if="canCreate" type="primary" :loading="saving" @click="saveUser">创建用户</el-button></div></template>
<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>
</div>
</template>
+12 -6
View File
@@ -9,6 +9,12 @@ import { useAuthStore } from '@/stores/auth'
import LoginView from './LoginView.vue'
const loginSource = readFileSync(resolve(process.cwd(), 'src/views/LoginView.vue'), 'utf8')
const formStub = {
template: '<form><slot /></form>',
setup(_: unknown, { expose }: { expose: (exposed: { validate: () => Promise<boolean> }) => void }) {
expose({ validate: async () => true })
},
}
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
@@ -59,7 +65,7 @@ describe('LoginView authentication lifecycle', () => {
global: {
plugins: [pinia, router],
stubs: {
'el-form': { template: '<form><slot /></form>' },
'el-form': formStub,
'el-form-item': { template: '<label><slot /></label>' },
'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
@@ -70,7 +76,7 @@ describe('LoginView authentication lifecycle', () => {
const inputs = wrapper.findAll('input')
await inputs[0]!.setValue('test_admin')
await inputs[1]!.setValue('password')
await wrapper.get('.login-submit').trigger('click')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(calls).toEqual(['login', 'access', 'replace'])
@@ -102,7 +108,7 @@ describe('LoginView authentication lifecycle', () => {
global: {
plugins: [pinia, router],
stubs: {
'el-form': { template: '<form><slot /></form>' },
'el-form': formStub,
'el-form-item': { template: '<label><slot /></label>' },
'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
@@ -113,7 +119,7 @@ describe('LoginView authentication lifecycle', () => {
const inputs = wrapper.findAll('input')
await inputs[0]!.setValue('test_admin')
await inputs[1]!.setValue('password')
await wrapper.get('.login-submit').trigger('click')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(replace).toHaveBeenCalledWith(expected)
@@ -140,7 +146,7 @@ describe('LoginView authentication lifecycle', () => {
global: {
plugins: [pinia, router],
stubs: {
'el-form': { template: '<form><slot /></form>' },
'el-form': formStub,
'el-form-item': { template: '<label><slot /></label>' },
'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
@@ -151,7 +157,7 @@ describe('LoginView authentication lifecycle', () => {
const inputs = wrapper.findAll('input')
await inputs[0]!.setValue('test_admin')
await inputs[1]!.setValue('password')
await wrapper.get('.login-submit').trigger('click')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.get('[role="alert"]').text()).toBe('network unavailable')
+13 -11
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowRight, LockKeyhole, ShieldCheck, UserRound } from '@lucide/vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
@@ -16,18 +16,20 @@ const auth = useAuthStore()
const access = useAccessPreviewStore()
const loading = ref(false)
const loginError = ref<ApiErrorDetails | null>(null)
const formRef = ref<FormInstance>()
const form = reactive({
account: '',
password: '',
})
const rules: FormRules<typeof form> = {
account: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}
async function submit() {
loginError.value = null
if (!form.account || !form.password) {
loginError.value = { message: '请输入账号和密码' }
ElMessage.warning(loginError.value.message)
return
}
const isValid = await formRef.value?.validate().catch(() => false)
if (!isValid) return
loading.value = true
try {
await auth.login(form.account, form.password)
@@ -66,14 +68,14 @@ async function submit() {
<div class="login-card-icon"><LockKeyhole :size="24" /></div>
<h2>登录管理后台</h2>
<p>使用平台内部员工账号登录</p>
<el-form label-position="top" size="large" @submit.prevent="submit">
<el-form-item label="账号">
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="submit">
<el-form-item label="账号" prop="account" required>
<el-input v-model.trim="form.account" placeholder="请输入账号" :prefix-icon="UserRound" autocomplete="username" maxlength="100" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" show-password placeholder="请输入密码" :prefix-icon="LockKeyhole" autocomplete="current-password" maxlength="128" @keyup.enter="submit" />
<el-form-item label="密码" prop="password" required>
<el-input v-model="form.password" type="password" show-password placeholder="请输入密码" :prefix-icon="LockKeyhole" autocomplete="current-password" maxlength="128" />
</el-form-item>
<el-button class="login-submit" type="primary" :loading="loading" @click="submit">
<el-button class="login-submit" native-type="submit" type="primary" :loading="loading">
登录 <ArrowRight :size="17" />
</el-button>
</el-form>
+149
View File
@@ -0,0 +1,149 @@
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 {
listPlatformPartners,
type PlatformPartnerResource,
} from '@/api/platformPartners'
import { useAccessPreviewStore } from '@/stores/accessPreview'
import { useAuthStore } from '@/stores/auth'
import PartnersView from './PartnersView.vue'
vi.mock('@/api/platformPartners', () => ({
listPlatformPartners: vi.fn(),
createPlatformPartner: vi.fn(),
updatePlatformPartner: vi.fn(),
changePlatformPartnerStatus: 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 partner: PlatformPartnerResource = {
id: '19001',
tenantId: 10001,
partnerCode: 'CUS-001',
partnerName: '深圳示例贸易有限公司',
partnerType: 'CUSTOMER',
contactName: '张三',
contactPhone: '13800138000',
contactEmail: 'contact@example.com',
countryCode: 'CN',
province: '广东省',
city: '深圳市',
district: '宝安区',
addressLine: '航城街道示例路 1 号',
postalCode: '518000',
status: 'ACTIVE',
updatedAt: '2026-07-30T22:45:00',
}
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:counterparty:list',
'platform:counterparty:create',
'platform:counterparty:edit',
'platform:counterparty:status',
]
useAccessPreviewStore().initializeAfterLogin()
const wrapper = mount(PartnersView, {
attachTo: document.body,
global: { plugins: [pinia, ElementPlus] },
})
await flushPromises()
return wrapper
}
enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('PartnersView real API integration', () => {
beforeEach(() => {
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
document.body.innerHTML = ''
vi.mocked(listPlatformPartners).mockReset().mockResolvedValue({
records: [partner], page: 1, size: 20, total: 1, pages: 1,
})
})
it('loads the fixed tenant page and renders the real partner fields', async () => {
const wrapper = await mountView()
expect(listPlatformPartners).toHaveBeenCalledWith(10001, {
page: 1, size: 20, keyword: undefined, type: undefined, status: undefined,
})
expect(wrapper.text()).toContain('深圳示例贸易有限公司')
expect(wrapper.text()).toContain('CUS-001')
expect(wrapper.text()).toContain('contact@example.com')
expect(wrapper.text()).toContain('广东省 深圳市 宝安区 航城街道示例路 1 号')
expect(wrapper.text()).not.toContain('往来单位总数')
expect(wrapper.text()).not.toContain('往来单位档案')
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
expect(wrapper.find('.data-table').exists()).toBe(true)
expect(wrapper.find('.table-panel').exists()).toBe(true)
})
it('updates filters locally and only requests after search', async () => {
const wrapper = await mountView()
const keywordInput = wrapper.find('input[placeholder="单位名称、编码、联系人或电话"]')
await keywordInput.setValue('深圳')
await flushPromises()
expect(listPlatformPartners).toHaveBeenCalledTimes(1)
const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === '查询')
expect(searchButton).toBeDefined()
await searchButton?.trigger('click')
await flushPromises()
expect(listPlatformPartners).toHaveBeenCalledTimes(2)
expect(listPlatformPartners).toHaveBeenLastCalledWith(10001, {
page: 1, size: 20, keyword: '深圳', type: undefined, status: undefined,
})
})
it('shows useful placeholders and visible required markers in the create form', async () => {
const wrapper = await mountView()
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增往来单位')
expect(createButton).toBeDefined()
await createButton?.trigger('click')
await flushPromises()
expect(document.querySelector('input[placeholder="请输入工商登记或常用单位名称"]')).not.toBeNull()
expect(document.querySelector('input[placeholder="请输入主要联系人姓名"]')).not.toBeNull()
expect(document.querySelector('textarea[placeholder="请输入街道、园区、楼栋及门牌信息"]')).not.toBeNull()
expect(document.querySelectorAll('.el-form-item.is-required').length).toBe(9)
})
it('shows a recoverable API failure without demo data', async () => {
vi.mocked(listPlatformPartners).mockRejectedValueOnce(new Error('Gateway unavailable'))
const wrapper = await mountView()
expect(wrapper.text()).toContain('往来单位列表暂时无法加载')
expect(wrapper.text()).toContain('Gateway unavailable')
expect(wrapper.text()).toContain('重新加载')
expect(wrapper.text()).not.toContain('深圳示例贸易有限公司')
})
})
+379
View File
@@ -0,0 +1,379 @@
<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 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 { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
import { getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
import {
changePlatformPartnerStatus,
createPlatformPartner,
listPlatformPartners,
updatePlatformPartner,
type PlatformPartnerResource,
type PlatformPartnerStatus,
type PlatformPartnerType,
type SavePlatformPartnerRequest,
} from '@/api/platformPartners'
import { useAccessPreviewStore } from '@/stores/accessPreview'
const fixedTenantId = 10001
const pageSize = ref(20)
const access = useAccessPreviewStore()
const loading = ref(false)
const saving = ref(false)
const editorOpen = ref(false)
const formRef = ref<FormInstance>()
const rows = ref<PlatformPartnerResource[]>([])
const page = ref(1)
const total = ref(0)
const loadError = ref<ApiErrorDetails | null>(null)
const statusLoadingIds = ref(new Set<string>())
type PartnerFilters = QueryModel & {
keyword: string
type: PlatformPartnerType | ''
status: PlatformPartnerStatus | ''
}
const filters = ref<PartnerFilters>({
keyword: '',
type: '',
status: '',
})
const appliedFilters = ref<PartnerFilters>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '单位名称、编码、联系人或电话' },
{
key: 'type',
type: 'select',
placeholder: '全部类型',
options: [
{ label: '客户', value: 'CUSTOMER' },
{ label: '供应商', value: 'SUPPLIER' },
{ label: '客户 / 供应商', value: 'BOTH' },
],
},
{
key: 'status',
type: 'select',
placeholder: '全部状态',
options: [
{ label: '启用', value: 'ACTIVE' },
{ label: '停用', value: 'INACTIVE' },
],
},
]
interface PartnerForm extends SavePlatformPartnerRequest {
id: string
}
const emptyForm = (): PartnerForm => ({
id: '',
partnerCode: '',
partnerName: '',
partnerType: 'CUSTOMER',
contactName: '',
contactPhone: '',
contactEmail: null,
countryCode: 'CN',
province: null,
city: '',
district: null,
addressLine: '',
postalCode: null,
status: 'ACTIVE',
})
const form = reactive<PartnerForm>(emptyForm())
const editorLabel = computed(() => form.id ? '编辑往来单位' : '新增往来单位')
const editorPermission = computed(() => form.id ? 'platform:counterparty:edit' : 'platform:counterparty:create')
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'partner-editor',
label: editorLabel,
isOpen: editorOpen,
model: form,
requiredPermission: editorPermission,
})
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' }],
contactName: [{ required: true, whitespace: true, message: '请输入联系人', trigger: 'blur' }],
contactPhone: [{ required: true, whitespace: true, message: '请输入联系电话', trigger: 'blur' }],
contactEmail: [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
countryCode: [
{ required: true, whitespace: true, message: '请输入国家二字码', trigger: 'blur' },
{ pattern: /^[A-Z]{2}$/, message: '国家码须为两个大写英文字母', trigger: 'blur' },
],
city: [{ required: true, whitespace: true, message: '请输入城市', trigger: 'blur' }],
addressLine: [{ required: true, whitespace: true, message: '请输入详细地址', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
}
const typeLabels: Record<PlatformPartnerType, string> = {
CUSTOMER: '客户',
SUPPLIER: '供应商',
BOTH: '客户 / 供应商',
}
const partnerColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 150, 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' },
{ key: 'address', label: '地址', minWidth: 240, slotName: 'address' },
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
{ prop: 'updatedAt', label: '更新时间', minWidth: 140, formatter: (row: PlatformPartnerResource) => formatUpdatedAt(row.updatedAt) },
]
watch(
() => access.can(editorPermission.value),
(canWrite) => {
if (canWrite || !editorOpen.value) return
editorOpen.value = false
Object.assign(form, emptyForm())
},
{ flush: 'sync' },
)
function queryParams() {
return {
page: page.value,
size: pageSize.value,
keyword: appliedFilters.value.keyword.trim() || undefined,
type: appliedFilters.value.type || undefined,
status: appliedFilters.value.status || undefined,
}
}
async function loadPartners() {
loading.value = true
loadError.value = null
try {
const result = await listPlatformPartners(fixedTenantId, queryParams())
rows.value = result.records
total.value = result.total
} catch (error) {
loadError.value = getApiErrorDetails(error, '往来单位加载失败,请稍后重试')
} finally {
loading.value = false
}
}
function searchPartners(model: QueryModel) {
appliedFilters.value = {
keyword: String(model.keyword || ''),
type: (model.type || '') as PlatformPartnerType | '',
status: (model.status || '') as PlatformPartnerStatus | '',
}
if (page.value === 1) {
void loadPartners()
return
}
page.value = 1
}
function openCreate() {
Object.assign(form, emptyForm())
captureBaseline()
editorOpen.value = true
requestAnimationFrame(() => formRef.value?.clearValidate())
}
function nullable(value: string | null) {
return value?.trim() || null
}
function openEdit(row: PlatformPartnerResource) {
Object.assign(form, {
id: String(row.id),
partnerCode: row.partnerCode,
partnerName: row.partnerName,
partnerType: row.partnerType,
contactName: row.contactName,
contactPhone: row.contactPhone,
contactEmail: row.contactEmail,
countryCode: row.countryCode,
province: row.province,
city: row.city,
district: row.district,
addressLine: row.addressLine,
postalCode: row.postalCode,
status: row.status,
})
captureBaseline()
editorOpen.value = true
requestAnimationFrame(() => formRef.value?.clearValidate())
}
function savePayload(): SavePlatformPartnerRequest {
return {
partnerCode: form.partnerCode.trim(),
partnerName: form.partnerName.trim(),
partnerType: form.partnerType,
contactName: form.contactName.trim(),
contactPhone: form.contactPhone.trim(),
contactEmail: nullable(form.contactEmail),
countryCode: form.countryCode.trim().toUpperCase(),
province: nullable(form.province),
city: form.city.trim(),
district: nullable(form.district),
addressLine: form.addressLine.trim(),
postalCode: nullable(form.postalCode),
status: form.status,
}
}
async function savePartner() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true
try {
const payload = savePayload()
if (form.id) {
await updatePlatformPartner(fixedTenantId, form.id, payload)
ElMessage.success('往来单位已更新')
} else {
await createPlatformPartner(fixedTenantId, payload)
ElMessage.success('往来单位已新增')
}
editorOpen.value = false
await loadPartners()
} catch (error) {
const details = getApiErrorDetails(error, '往来单位保存失败,请重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
saving.value = false
}
}
async function toggleStatus(row: PlatformPartnerResource) {
const nextStatus: PlatformPartnerStatus = row.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE'
const action = nextStatus === 'ACTIVE' ? '启用' : '停用'
try {
await ElMessageBox.confirm(
`${action}后${nextStatus === 'ACTIVE' ? '可继续用于新业务' : '不再用于新业务,历史数据不受影响'}。`,
`${action}“${row.partnerName}”`,
{ type: nextStatus === 'ACTIVE' ? 'success' : 'warning', confirmButtonText: action, cancelButtonText: '取消' },
)
} catch {
return
}
const id = String(row.id)
statusLoadingIds.value = new Set(statusLoadingIds.value).add(id)
try {
await changePlatformPartnerStatus(fixedTenantId, row.id, nextStatus)
ElMessage.success(`往来单位已${action}`)
await loadPartners()
} catch (error) {
const details = getApiErrorDetails(error, `往来单位${action}失败,请重试`)
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
} finally {
const next = new Set(statusLoadingIds.value)
next.delete(id)
statusLoadingIds.value = next
}
}
function formatUpdatedAt(value?: string | null) {
return value ? value.replace('T', ' ').slice(0, 16) : '-'
}
watch([page, pageSize], () => { void loadPartners() })
onMounted(loadPartners)
</script>
<template>
<div class="business-page partners-page">
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="往来单位筛选" :fields="queryFields" @search="searchPartners">
<template #actions>
<el-button v-if="access.can('platform:counterparty:create')" class="create-partner-button" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button>
</template>
</QueryToolbar>
<section v-if="loadError" class="surface-panel partners-error" role="alert">
<div>
<strong>往来单位列表暂时无法加载</strong>
<p>{{ loadError.message }}</p>
<ApiErrorMeta :details="loadError" />
</div>
<el-button type="primary" :loading="loading" @click="loadPartners">重新加载</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="rows"
:columns="partnerColumns"
:loading="loading"
:total="total"
:page-sizes="[20, 50, 100]"
aria-label="往来单位列表"
row-key="id"
empty-text="暂无符合条件的往来单位"
>
<template #actions="{ row }">
<div v-if="access.can('platform:counterparty:edit') || access.can('platform:counterparty:status')" class="table-actions">
<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 #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>
<template #pagination-left>共 {{ total }} 个往来单位</template>
</DataTable>
</section>
<el-drawer v-model="editorOpen" :title="editorLabel" size="680px" 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"><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="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">
<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>
</div></div>
<div class="form-section"><h3>地址信息</h3><div class="form-grid two-cols">
<el-form-item label="国家二字码" prop="countryCode" required><el-input v-model="form.countryCode" placeholder="如:CN" maxlength="2" @input="form.countryCode = String($event).toUpperCase()" /></el-form-item>
<el-form-item label="省 / 州" prop="province"><el-input v-model="form.province" placeholder="如:广东省" maxlength="64" /></el-form-item>
<el-form-item label="城市" prop="city" required><el-input v-model="form.city" placeholder="如:深圳市" maxlength="64" /></el-form-item>
<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>
</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>
</div>
</template>
<style scoped>
.partners-error { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 16px; padding: 18px 20px; border-color: #fecdca; background: #fffafa; }
.partners-error strong { color: #7a271a; font-size: 13px; }
.partners-error p { margin: 4px 0 0; color: #b54708; font-size: 12px; overflow-wrap: anywhere; }
.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; }
.partner-cell strong { overflow-wrap: anywhere; }
.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); }
@media (max-width: 720px) {
.partners-page :deep(.query-toolbar__actions .create-partner-button) { grid-column: 1 / -1; width: 100%; }
.partners-error { align-items: stretch; flex-direction: column; }
.partners-error .el-button { min-height: 44px; }
}
</style>
+5 -2
View File
@@ -6,8 +6,11 @@ import { useAuthStore } from '@/stores/auth'
import PermissionsView from './PermissionsView.vue'
vi.mock('@/api/platformIam', () => ({
changePlatformPermissionStatus: vi.fn(),
createPlatformPermission: vi.fn(),
deletePlatformPermission: vi.fn(),
listPlatformPermissions: vi.fn(),
updatePlatformPermission: vi.fn(),
}))
const permission: PlatformPermissionResource = {
@@ -65,8 +68,8 @@ describe('PermissionsView IAM mode', () => {
await flushPromises()
expect(listPlatformPermissions).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('权限资源列表和新增已接入 IAM')
expect(wrapper.text()).toContain('一期真实资源仅使用菜单和按钮类型')
expect(wrapper.text()).toContain('权限资源维护已接入 IAM')
expect(wrapper.text()).toContain('系统内置资源不可修改')
})
it('shows a recoverable state when the permission list fails', async () => {
+187 -46
View File
@@ -1,29 +1,57 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Braces, FolderTree, Plus, Search, ShieldCheck } from '@lucide/vue'
import MetricStrip from '@/components/MetricStrip.vue'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Braces, FolderTree, Pencil, Plus, ShieldCheck, Trash2 } from '@lucide/vue'
import StatusBadge from '@/components/StatusBadge.vue'
import { createPlatformPermission, listPlatformPermissions } from '@/api/platformIam'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import {
changePlatformPermissionStatus,
createPlatformPermission,
deletePlatformPermission,
listPlatformPermissions,
updatePlatformPermission,
} from '@/api/platformIam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http'
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
import { useAccessPreviewStore } from '@/stores/accessPreview'
import type { Permission, PermissionType } from '@/types/admin'
import { buildPermissionTree, getDescendantIds } from '@/utils/permissionTree'
import { buildPermissionTree, getDescendantIds, type PermissionTreeNode } from '@/utils/permissionTree'
import { mapPlatformPermission } from '@/utils/platformPermission'
const access = useAccessPreviewStore()
const editorOpen = ref(false)
const formRef = ref<FormInstance>()
const serverPermissions = ref<Permission[]>([])
const loading = ref(false)
const saving = ref(false)
const rowActionId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const filters = reactive({ keyword: '', type: '', status: '' })
const page = ref(1)
const pageSize = ref(10)
type PermissionFilters = QueryModel & { keyword: string; type: string; status: string }
const filters = ref<PermissionFilters>({ keyword: '', type: '', status: '' })
const appliedFilters = ref<PermissionFilters>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '权限名称、编码或路径' },
{ key: 'type', type: 'select', placeholder: '全部类型', options: [{ label: '菜单', value: '菜单' }, { label: '按钮', value: '按钮' }] },
{ key: 'status', type: 'select', placeholder: '全部状态', options: [{ label: '启用', value: '启用' }, { label: '停用', value: '停用' }] },
]
const expandedRowKeys = ref<string[]>([])
const tableRenderKey = ref(0)
const emptyForm = (): Permission => ({ id: '', parentId: null, name: '', code: '', type: '菜单', path: '', method: '-', status: '启用', sort: 10 })
const form = reactive(emptyForm())
const formRules = computed<FormRules>(() => ({
name: [{ required: true, whitespace: true, message: '请输入权限名称', trigger: 'blur' }],
code: [{ required: true, whitespace: true, message: '请输入权限编码', trigger: 'blur' }],
type: [{ required: true, message: '请选择资源类型', trigger: 'change' }],
parentId: form.type === '按钮' ? [{ required: true, message: '请选择上级菜单', trigger: 'change' }] : [],
path: form.type === '菜单' ? [{ required: true, whitespace: true, message: '请输入菜单路由', trigger: 'blur' }] : [],
sort: [{ required: true, type: 'number', message: '请输入显示排序', trigger: 'change' }],
}))
const editorLabel = computed(() => form.id ? '编辑权限' : '新增权限')
const requiredPermission = computed(() => 'platform:permission:create')
const requiredPermission = computed(() => form.id ? 'platform:permission:edit' : 'platform:permission:create')
const { captureBaseline } = useUnsavedWorkRegistration({
key: 'permission-editor',
label: editorLabel,
@@ -34,24 +62,32 @@ const { captureBaseline } = useUnsavedWorkRegistration({
const availablePermissionTypes: PermissionType[] = ['菜单', '按钮']
const permissions = computed(() => serverPermissions.value)
const canCreate = computed(() => access.can('platform:permission:create'))
const canEdit = computed(() => access.can('platform:permission:edit'))
const canChangeStatus = computed(() => access.can('platform:permission:status'))
const canDelete = computed(() => access.can('platform:permission:delete'))
function matches(item: Permission) {
const keyword = filters.keyword.trim().toLowerCase()
return (!keyword || [item.name, item.code, item.path].some((value) => value.toLowerCase().includes(keyword))) && (!filters.type || item.type === filters.type) && (!filters.status || item.status === filters.status)
const keyword = appliedFilters.value.keyword.trim().toLowerCase()
return (!keyword || [item.name, item.code, item.path].some((value) => value.toLowerCase().includes(keyword))) && (!appliedFilters.value.type || item.type === appliedFilters.value.type) && (!appliedFilters.value.status || item.status === appliedFilters.value.status)
}
const treeRows = computed(() => buildPermissionTree(permissions.value, matches))
const pagedTreeRows = computed(() => treeRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const rootRowKeys = computed(() => treeRows.value.map((item) => item.id))
const blockedParentIds = computed(() => new Set(
form.id ? [form.id, ...getDescendantIds(permissions.value, form.id)] : [],
))
const parentOptions = computed(() => permissions.value.filter((item) => (
item.type === '菜单' && !blockedParentIds.value.has(item.id)
)))
const metrics = computed(() => [
{ label: '权限资源', value: permissions.value.length, note: 'IAM 返回的资源' },
{ label: '导航资源', value: permissions.value.filter((item) => ['目录','菜单'].includes(item.type)).length, note: '目录与页面入口' },
{ label: '操作权限', value: permissions.value.filter((item) => item.type === '按钮').length, note: '控制页面操作' },
{ label: '停用资源', value: permissions.value.filter((item) => item.status === '停用').length, note: '当前不可分配' },
])
const permissionColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 280, fixed: 'left', slotName: 'actions' },
{ key: 'name', label: '权限名称', minWidth: 260, slotName: 'name' },
{ key: 'code', label: '权限编码', minWidth: 220, slotName: 'code' },
{ prop: 'type', label: '类型', width: 80 },
{ key: 'endpoint', label: '路由 / 接口', minWidth: 260, slotName: 'endpoint' },
{ prop: 'sort', label: '排序', width: 72, align: 'right' },
{ key: 'status', label: '状态', width: 88, slotName: 'status' },
]
async function loadPermissions() {
loading.value = true
@@ -59,6 +95,8 @@ async function loadPermissions() {
serverPermissions.value = []
try {
serverPermissions.value = (await listPlatformPermissions()).map(mapPlatformPermission)
expandedRowKeys.value = rootRowKeys.value
tableRenderKey.value += 1
} catch (error) {
loadError.value = getApiErrorDetails(error, '权限资源加载失败,请稍后重试')
} finally {
@@ -66,14 +104,75 @@ async function loadPermissions() {
}
}
function openCreate(parentId: string | null = null) { Object.assign(form, emptyForm(), { parentId }); captureBaseline(); editorOpen.value = true }
function applyFilters() {
appliedFilters.value = { ...filters.value }
page.value = 1
void loadPermissions()
}
function collectDescendantIds(nodes: PermissionTreeNode[], ids: string[] = []) {
nodes.forEach((node) => {
ids.push(node.id)
if (node.children) collectDescendantIds(node.children, ids)
})
return ids
}
function permissionSummary(row: PermissionTreeNode) {
const descendants = collectDescendantIds(row.children ?? [])
.map((id) => permissions.value.find((item) => item.id === id))
.filter((item): item is Permission => Boolean(item))
const menuCount = descendants.filter((item) => item.type === '菜单').length
const actionCount = descendants.filter((item) => ['按钮', '接口'].includes(item.type)).length
if (menuCount === 0 && actionCount === 0) return '无下级权限'
return [menuCount ? `${menuCount} 个菜单` : '', actionCount ? `${actionCount} 项操作` : ''].filter(Boolean).join(' · ')
}
function expandAllRows() {
expandedRowKeys.value = collectDescendantIds(treeRows.value)
tableRenderKey.value += 1
}
function collapseActionRows() {
expandedRowKeys.value = rootRowKeys.value
tableRenderKey.value += 1
}
function openCreate(parentId: string | null = null) {
Object.assign(form, emptyForm(), { parentId })
captureBaseline()
editorOpen.value = true
nextTick(() => formRef.value?.clearValidate())
}
function openEdit(row: Permission) {
if (!canEdit.value || row.builtIn) return
Object.assign(form, { ...row })
captureBaseline()
editorOpen.value = true
nextTick(() => formRef.value?.clearValidate())
}
function handleTypeChange() {
if (form.type === '按钮') form.path = ''
nextTick(() => formRef.value?.clearValidate(['parentId', 'path']))
}
async function savePermission() {
if (!form.name.trim() || !form.code.trim()) { ElMessage.warning('请填写权限名称和权限编码'); return }
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
if (permissions.value.some((item) => item.code.toLowerCase() === form.code.toLowerCase() && item.id !== form.id)) { ElMessage.error('权限编码已经存在'); return }
if (form.type === '菜单' && !form.path.trim()) { ElMessage.warning('请填写菜单路由'); return }
if (form.type === '按钮' && !form.parentId) { ElMessage.warning('按钮权限必须选择上级菜单'); return }
saving.value = true
try {
if (form.id) {
const updated = await updatePlatformPermission(form.id, {
name: form.name.trim(),
parentId: form.parentId,
routePath: form.type === '菜单' ? form.path.trim() : null,
httpMethod: form.method === '-' ? null : form.method,
sortNo: form.sort,
})
const index = serverPermissions.value.findIndex((item) => item.id === form.id)
if (index >= 0) serverPermissions.value[index] = mapPlatformPermission(updated)
ElMessage.success('权限资源已更新')
} else {
const created = await createPlatformPermission({
type: form.type === '按钮' ? 'BUTTON' : 'MENU',
code: form.code.trim(),
@@ -84,14 +183,47 @@ async function savePermission() {
sortNo: form.sort,
})
serverPermissions.value.push(mapPlatformPermission(created))
editorOpen.value = false
ElMessage.success('权限资源已创建')
}
editorOpen.value = false
} catch (error) {
ElMessage.error(getApiErrorMessage(error, '权限资源创建失败,请稍后重试'))
ElMessage.error(getApiErrorMessage(error, form.id ? '权限资源更新失败,请稍后重试' : '权限资源创建失败,请稍后重试'))
} finally {
saving.value = false
}
}
async function toggleStatus(row: Permission) {
if (row.builtIn) return
const target = row.status === '启用' ? '停用' : '启用'
try {
await ElMessageBox.confirm(`确认${target}权限“${row.name}”吗?`, '权限状态', { confirmButtonText: '确认', cancelButtonText: '取消' })
rowActionId.value = row.id
const updated = await changePlatformPermissionStatus(row.id, target === '启用' ? 'ACTIVE' : 'DISABLED')
Object.assign(row, mapPlatformPermission(updated))
ElMessage.success(`权限已${target}`)
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorMessage(error, '权限状态更新失败,请稍后重试'))
} finally {
rowActionId.value = ''
}
}
async function removePermission(row: Permission) {
if (row.builtIn) return
try {
await ElMessageBox.confirm(`确认删除权限“${row.name}”吗?删除前需确保没有下级资源且未分配给角色。`, '删除权限', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
rowActionId.value = row.id
await deletePlatformPermission(row.id)
await loadPermissions()
ElMessage.success('权限资源已删除')
} catch (error) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(getApiErrorMessage(error, '权限资源删除失败,请稍后重试'))
} finally {
rowActionId.value = ''
}
}
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '按钮': 'action', '接口': 'api' }[type] }
onMounted(loadPermissions)
@@ -99,43 +231,52 @@ onMounted(loadPermissions)
<template>
<div class="business-page">
<MetricStrip :items="metrics" />
<el-alert type="warning" :closable="false" show-icon title="权限资源列表和新增已接入 IAM;一期真实资源仅使用菜单和按钮类型,编辑与启停接口尚未提供。" />
<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="loadPermissions">重新加载</el-button></template></el-alert>
<section class="surface-panel filter-panel" aria-label="权限筛选">
<div class="filter-fields"><el-input v-model="filters.keyword" clearable placeholder="权限名称、编码或路径" aria-label="权限关键词"><template #prefix><Search :size="16" /></template></el-input><el-select v-model="filters.type" clearable placeholder="全部类型" aria-label="权限类型"><el-option v-for="item in availablePermissionTypes" :key="item" :label="item" :value="item" /></el-select><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="权限状态"><el-option label="启用" value="启用" /><el-option label="停用" value="停用" /></el-select></div>
<div class="filter-actions"><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></div>
</section>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="权限筛选" :fields="queryFields" @search="applyFilters"><template #actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template></QueryToolbar>
<section class="surface-panel table-panel">
<header class="panel-heading"><div><h2>权限资源树</h2><p>目录组织菜单,按钮控制页面操作;接口权限将在服务端契约确定后补充。</p></div><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span></div></header>
<el-table v-loading="loading" :data="treeRows" row-key="id" default-expand-all :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<el-table-column label="操作" width="235" fixed="left"><template #default="{ row }"><div v-if="canCreate" class="table-actions"><el-button v-if="row.type === '菜单'" link type="primary" @click="openCreate(row.id)"><Plus :size="15" />添加下级</el-button><span class="secondary-text">编辑接口待补齐</span></div><span v-else class="secondary-text">仅查看</span></template></el-table-column>
<el-table-column label="权限名称" min-width="260"><template #default="{ row }"><div class="permission-name"><span :class="`is-${typeClass(row.type)}`"><FolderTree v-if="row.type === '目录' || row.type === '菜单'" :size="16" /><ShieldCheck v-else-if="row.type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><strong>{{ row.name }}</strong></div></template></el-table-column>
<el-table-column label="权限编码" min-width="220"><template #default="{ row }"><code class="code-value">{{ row.code }}</code></template></el-table-column>
<el-table-column prop="type" label="类型" width="80" />
<el-table-column label="路由 / 接口" min-width="260"><template #default="{ row }"><div class="endpoint-cell"><span v-if="row.method !== '-'">{{ row.method }}</span><code>{{ row.path || '-' }}</code></div></template></el-table-column>
<el-table-column prop="sort" label="排序" width="72" align="right" />
<el-table-column label="状态" width="88"><template #default="{ row }"><StatusBadge :status="row.status" /></template></el-table-column>
</el-table>
<header class="panel-heading"><div><h2>权限资源树</h2><p>默认展示目录与菜单,展开菜单后查看按钮权限;接口权限将在服务端契约确定后补充。</p></div><div class="permission-tree-tools"><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span></div><div class="tree-view-actions"><el-button link type="primary" @click="expandAllRows">展开全部</el-button><el-button link @click="collapseActionRows">收起操作权限</el-button></div></div></header>
<DataTable :key="tableRenderKey" v-model:page="page" v-model:page-size="pageSize" :data="pagedTreeRows" :columns="permissionColumns" :loading="loading" :total="treeRows.length" row-key="id" :expand-row-keys="expandedRowKeys" :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
<template #actions="{ row }"><div v-if="canCreate || canEdit || canChangeStatus || canDelete" class="table-actions"><el-button v-if="canCreate && (row as Permission).type === '菜单'" link type="primary" @click="openCreate((row as Permission).id)"><Plus :size="15" />添加下级</el-button><el-button v-if="canEdit" link type="primary" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="openEdit(row as Permission)"><Pencil :size="15" />编辑</el-button><el-button v-if="canChangeStatus" link :type="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" :disabled="(row as Permission).builtIn" @click="toggleStatus(row as Permission)">{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="canDelete" link type="danger" :disabled="(row as Permission).builtIn || rowActionId === (row as Permission).id" @click="removePermission(row as Permission)"><Trash2 :size="15" />删除</el-button><span v-if="(row as Permission).builtIn" class="secondary-text">系统内置</span></div><span v-else class="secondary-text">仅查看</span></template>
<template #name="{ row }"><div class="permission-name"><span :class="`is-${typeClass((row as Permission).type)}`"><FolderTree v-if="(row as Permission).type === '目录' || (row as Permission).type === '菜单'" :size="16" /><ShieldCheck v-else-if="(row as Permission).type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><div><strong>{{ (row as Permission).name }}</strong><small v-if="(row as PermissionTreeNode).children?.length">{{ permissionSummary(row as PermissionTreeNode) }}</small></div></div></template>
<template #code="{ row }"><code class="code-value">{{ (row as Permission).code }}</code></template>
<template #endpoint="{ row }"><div class="endpoint-cell"><span v-if="(row as Permission).method !== '-'">{{ (row as Permission).method }}</span><code>{{ (row as Permission).path || '-' }}</code></div></template>
<template #status="{ row }"><StatusBadge :status="(row as Permission).status" /></template>
</DataTable>
</section>
<el-drawer v-model="editorOpen" :title="form.id ? '编辑权限资源' : '新增权限资源'" size="600px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
<el-form label-position="top" class="drawer-form">
<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="权限名称" required><el-input v-model="form.name" placeholder="用户可理解的中文名称" maxlength="64" /></el-form-item>
<el-form-item label="权限编码" required><el-input v-model="form.code" placeholder="如 platform:customer:list" maxlength="128" /></el-form-item>
<el-form-item label="资源类型"><el-select v-model="form.type"><el-option v-for="item in availablePermissionTypes" :key="item" :label="item" :value="item" /></el-select></el-form-item>
<el-form-item label="上级资源"><el-select v-model="form.parentId" clearable placeholder="作为一级资源"><el-option v-for="item in parentOptions" :key="item.id" :label="`${item.name} (${item.code})`" :value="item.id" /></el-select></el-form-item>
<el-form-item v-if="form.type === '菜单'" label="路由路径"><el-input v-model="form.path" placeholder="例如 /customers" /></el-form-item>
<el-form-item label="显示排序"><el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" /></el-form-item>
<el-form-item label="权限名称" prop="name" required><el-input v-model="form.name" placeholder="用户可理解的中文名称" maxlength="64" /></el-form-item>
<el-form-item label="权限编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:platform:customer:list" maxlength="128" /></el-form-item>
<el-form-item label="资源类型" prop="type" required><el-select v-model="form.type" :disabled="Boolean(form.id)" @change="handleTypeChange"><el-option v-for="item in availablePermissionTypes" :key="item" :label="item" :value="item" /></el-select></el-form-item>
<el-form-item label="上级资源" prop="parentId" :required="form.type === '按钮'"><el-select v-model="form.parentId" clearable placeholder="作为一级资源"><el-option v-for="item in parentOptions" :key="item.id" :label="`${item.name} (${item.code})`" :value="item.id" /></el-select></el-form-item>
<el-form-item v-if="form.type === '菜单'" label="路由路径" prop="path" required><el-input v-model="form.path" placeholder="如:/customers" /></el-form-item>
<el-form-item label="显示排序" prop="sort" required><el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" /></el-form-item>
</div>
</div>
<div class="form-callout"><Braces :size="18" /><div><strong>权限编码约定</strong><p>使用稳定的“业务域:资源:动作”编码;一期真实资源只创建菜单和按钮,接口鉴权由服务端实现。</p></div></div>
</el-form>
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="canCreate" type="primary" :loading="saving" @click="savePermission">保存权限</el-button></div></template>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button v-if="form.id ? canEdit : canCreate" type="primary" :loading="saving" @click="savePermission">{{ form.id ? '保存修改' : '保存权限' }}</el-button></div></template>
</el-drawer>
</div>
</template>
<style scoped>
.permission-tree-tools { display: flex; align-items: center; gap: 16px; }
.tree-view-actions { display: flex; align-items: center; gap: 4px; white-space: nowrap; }
.tree-view-actions .el-button + .el-button { margin-left: 0; }
.permission-name > div { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.permission-name small { color: var(--cc-muted); font-size: 11px; line-height: 1.35; }
@media (max-width: 720px) {
.permission-tree-tools { width: 100%; align-items: flex-start; flex-direction: column; gap: 8px; }
}
@media (max-width: 420px) {
.tree-view-actions { width: 100%; justify-content: flex-start; }
}
</style>
+50 -39
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Pencil, Plus, RotateCcw, Search, ShieldCheck, Trash2 } from '@lucide/vue'
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Pencil, Plus, ShieldCheck, Trash2 } from '@lucide/vue'
import {
changePlatformRoleStatus,
createPlatformRole,
@@ -15,8 +15,9 @@ import {
} from '@/api/platformIam'
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http'
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
import MetricStrip from '@/components/MetricStrip.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 type { Permission, Role } from '@/types/admin'
@@ -31,17 +32,29 @@ interface TreeController {
const access = useAccessPreviewStore()
const editorOpen = ref(false)
const formRef = ref<FormInstance>()
const treeRef = ref<TreeController | null>(null)
const serverRoles = ref<Role[]>([])
const serverPermissions = ref<Permission[]>([])
const assignedMemberCount = ref(0)
const loading = ref(false)
const saving = ref(false)
const rowActionId = ref('')
const loadError = ref<ApiErrorDetails | null>(null)
const filters = reactive({ keyword: '', status: '', dataScope: '' })
const page = ref(1)
const pageSize = ref(10)
type RoleFilters = QueryModel & { keyword: string; status: string }
const filters = ref<RoleFilters>({ keyword: '', status: '' })
const appliedFilters = ref<RoleFilters>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '角色名称、编码或描述' },
{ key: 'status', type: 'select', placeholder: '全部状态', options: [{ label: '启用', value: '启用' }, { label: '停用', value: '停用' }] },
]
const emptyForm = (): Omit<Role, 'userCount'> => ({ id: '', name: '', code: '', description: '', dataScope: '本部门数据', status: '启用', permissionIds: [], builtIn: false })
const form = reactive(emptyForm())
const formRules: FormRules = {
name: [{ required: true, whitespace: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, whitespace: true, message: '请输入角色编码', trigger: 'blur' }],
}
const editorLabel = computed(() => form.id ? '配置角色' : '新增角色')
const canEdit = computed(() => access.can('platform:role:edit'))
const canGrant = computed(() => access.can('platform:role:grant'))
@@ -56,21 +69,27 @@ const { captureBaseline } = useUnsavedWorkRegistration({
const roles = computed(() => serverRoles.value)
const canCreate = computed(() => access.can('platform:role:create'))
const filteredRows = computed(() => roles.value.filter((item) => {
const keyword = filters.keyword.trim().toLowerCase()
return (!keyword || [item.name, item.code, item.description].some((value) => value.toLowerCase().includes(keyword))) && (!filters.status || item.status === filters.status) && (!filters.dataScope || item.dataScope === filters.dataScope)
const keyword = appliedFilters.value.keyword.trim().toLowerCase()
return (!keyword || [item.name, item.code, item.description].some((value) => value.toLowerCase().includes(keyword))) && (!appliedFilters.value.status || item.status === appliedFilters.value.status)
}))
const metrics = computed(() => [
{ label: '角色总数', value: roles.value.length, note: 'IAM 返回的角色' },
{ label: '启用角色', value: roles.value.filter((item) => item.status === '启用').length, note: '当前可分配' },
{ label: '授权用户', value: assignedMemberCount.value, note: '已分配角色成员' },
{ label: '权限资源', value: serverPermissions.value.length, note: '菜单与按钮' },
])
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const roleColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 235, fixed: 'left', slotName: 'actions' },
{ key: 'role', label: '角色', minWidth: 210, slotName: 'role' },
{ prop: 'description', label: '说明', minWidth: 240, showOverflowTooltip: true },
{ key: 'permissionSummary', label: '授权范围', minWidth: 170, slotName: 'permissionSummary' },
{ prop: 'userCount', label: '用户数', width: 86, align: 'right' },
{ key: 'roleType', label: '类型', width: 95, slotName: 'roleType' },
{ key: 'status', label: '状态', width: 88, slotName: 'status' },
]
const availablePermissions = computed(() => serverPermissions.value)
const permissionTree = computed(() => buildPermissionTree(availablePermissions.value))
watch(() => form.dataScope, () => undefined)
function resetFilters() { Object.assign(filters, { keyword: '', status: '', dataScope: '' }) }
function applyFilters() {
appliedFilters.value = { ...filters.value }
page.value = 1
void loadRoles()
}
function mapPlatformRole(role: PlatformRoleResource, userCount = 0): Role {
return {
@@ -91,7 +110,6 @@ async function loadRoles() {
loadError.value = null
serverRoles.value = []
serverPermissions.value = []
assignedMemberCount.value = 0
try {
const [roleRecords, permissionRecords, memberRecords] = await Promise.all([
listPlatformRoles(),
@@ -107,7 +125,6 @@ async function loadRoles() {
}
serverPermissions.value = permissionRecords.map(mapPlatformPermission)
serverRoles.value = roleRecords.map((role) => mapPlatformRole(role, roleMemberCounts.get(String(role.id)) ?? 0))
assignedMemberCount.value = memberRecords.filter((member) => member.roleIds.length > 0).length
} catch (error) {
loadError.value = getApiErrorDetails(error, '平台角色列表加载失败,请稍后重试')
} finally {
@@ -119,14 +136,14 @@ function openCreate() {
Object.assign(form, emptyForm())
captureBaseline()
editorOpen.value = true
nextTick(() => treeRef.value?.setCheckedKeys([]))
nextTick(() => { formRef.value?.clearValidate(); treeRef.value?.setCheckedKeys([]) })
}
function openEdit(row: Role) {
if (!canEdit.value && !canGrant.value) return
Object.assign(form, { ...row, permissionIds: [...row.permissionIds] })
captureBaseline()
editorOpen.value = true
nextTick(() => treeRef.value?.setCheckedKeys([...row.permissionIds]))
nextTick(() => { formRef.value?.clearValidate(); treeRef.value?.setCheckedKeys([...row.permissionIds]) })
}
function selectedPermissionIds() {
@@ -140,7 +157,8 @@ function syncPermissionIds() {
}
async function saveRole() {
if (!form.name.trim() || !form.code.trim()) { ElMessage.warning('请填写角色名称和角色编码'); return }
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
const normalizedCode = form.code.trim().toUpperCase()
const duplicate = roles.value.some((item) => item.code.toLowerCase() === normalizedCode.toLowerCase() && item.id !== form.id)
if (duplicate) { ElMessage.error('角色编码已经存在'); return }
@@ -214,30 +232,23 @@ onMounted(loadRoles)
<template>
<div class="business-page">
<MetricStrip :items="metrics" />
<el-alert type="success" :closable="false" show-icon title="角色查询、新增、编辑、启停、删除及权限分配均使用 IAM 真实接口;系统内置角色继续由服务端保护。" />
<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="loadRoles">重新加载</el-button></template></el-alert>
<section class="surface-panel filter-panel" aria-label="角色筛选">
<div class="filter-fields"><el-input v-model="filters.keyword" clearable placeholder="角色名称、编码或描述" aria-label="角色关键词"><template #prefix><Search :size="16" /></template></el-input><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="角色状态"><el-option label="启用" value="启用" /><el-option label="停用" value="停用" /></el-select></div>
<div class="filter-actions"><el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></div>
</section>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="角色筛选" :fields="queryFields" @search="applyFilters"><template #actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></template></QueryToolbar>
<section class="surface-panel table-panel">
<header class="panel-heading"><div><h2>角色与授权</h2><p>角色负责聚合菜单、操作和数据范围,内部用户可分配多个角色。</p></div></header>
<el-table v-loading="loading" :data="filteredRows" row-key="id" empty-text="没有符合条件的角色">
<el-table-column label="操作" width="235" fixed="left"><template #default="{ row }"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions"><el-button v-if="canEdit || canGrant" link type="primary" :disabled="rowActionId === row.id || row.builtIn" @click="openEdit(row as Role)"><Pencil :size="15" />配置</el-button><el-button v-if="access.can('platform:role:status')" link :type="row.status === '启用' ? 'danger' : 'success'" :loading="rowActionId === row.id" :disabled="row.builtIn" @click="toggleStatus(row as Role)">{{ row.status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="access.can('platform:role:delete')" link type="danger" :disabled="row.builtIn || row.userCount > 0 || rowActionId === row.id" @click="removeRole(row as Role)"><Trash2 :size="15" />删除</el-button></div><span v-else class="secondary-text">仅查看</span></template></el-table-column>
<el-table-column label="角色" min-width="210"><template #default="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ row.name }}</strong><small>{{ row.code }}</small></div></div></template></el-table-column>
<el-table-column prop="description" label="说明" min-width="240" show-overflow-tooltip />
<el-table-column label="授权范围" min-width="170"><template #default="{ row }"><div class="primary-cell"><strong>{{ permissionSummary(row as Role) }}</strong><span>平台 IAM 权限</span></div></template></el-table-column>
<el-table-column prop="userCount" label="用户数" width="86" align="right" />
<el-table-column label="类型" width="95"><template #default="{ row }"><el-tag :type="row.builtIn ? 'warning' : 'info'" effect="plain" size="small">{{ row.builtIn ? '系统内置' : '自定义' }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="88"><template #default="{ row }"><StatusBadge :status="row.status" /></template></el-table-column>
</el-table>
<DataTable v-model:page="page" v-model:page-size="pageSize" :data="pagedRows" :columns="roleColumns" :loading="loading" :total="filteredRows.length" row-key="id" empty-text="没有符合条件的角色">
<template #actions="{ row }"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions"><el-button v-if="canEdit || canGrant" link type="primary" :disabled="rowActionId === (row as Role).id || (row as Role).builtIn" @click="openEdit(row as Role)"><Pencil :size="15" />配置</el-button><el-button v-if="access.can('platform:role:status')" link :type="(row as Role).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Role).id" :disabled="(row as Role).builtIn" @click="toggleStatus(row as Role)">{{ (row as Role).status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="access.can('platform:role:delete')" link type="danger" :disabled="(row as Role).builtIn || (row as Role).userCount > 0 || rowActionId === (row as Role).id" @click="removeRole(row as Role)"><Trash2 :size="15" />删除</el-button></div><span v-else class="secondary-text">仅查看</span></template>
<template #role="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ (row as Role).name }}</strong><small>{{ (row as Role).code }}</small></div></div></template>
<template #permissionSummary="{ row }"><div class="primary-cell"><strong>{{ permissionSummary(row as Role) }}</strong><span>平台 IAM 权限</span></div></template>
<template #roleType="{ row }"><el-tag :type="(row as Role).builtIn ? 'warning' : 'info'" effect="plain" size="small">{{ (row as Role).builtIn ? '系统内置' : '自定义' }}</el-tag></template>
<template #status="{ row }"><StatusBadge :status="(row as Role).status" /></template>
</DataTable>
</section>
<el-drawer v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" size="760px" 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><div class="form-grid two-cols"><el-form-item label="角色名称" required><el-input v-model="form.name" :disabled="Boolean(form.id) && !canEdit" maxlength="64" /></el-form-item><el-form-item label="角色编码" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如 OPS_MANAGER" maxlength="64" /></el-form-item></div><el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="2" maxlength="200" show-word-limit /></el-form-item></div>
<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="角色名称" prop="name" required><el-input v-model="form.name" :disabled="Boolean(form.id) && !canEdit" placeholder="如:订单运营专员" maxlength="64" /></el-form-item><el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如:ORDER_OPERATOR" maxlength="64" /></el-form-item></div><el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="2" placeholder="说明该角色的工作范围" maxlength="200" show-word-limit /></el-form-item></div>
<div v-if="canGrant" class="form-section permission-section"><div class="section-heading-row"><div><h3>菜单与操作权限</h3><p>保存时按后端契约全量替换当前角色权限;未勾选表示清空授权。</p></div><span>{{ form.permissionIds.length }} 项原有授权</span></div><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox check-strictly default-expand-all :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node"><span>{{ data.name }}</span><code>{{ data.code }}</code></div></template></el-tree></div>
</el-form>
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button v-if="form.id ? canEdit || canGrant : canCreate" type="primary" :loading="saving" @click="saveRole">保存角色</el-button></div></template>
+55 -8
View File
@@ -69,9 +69,9 @@ const detail: PlatformWarehouseDetailResource = {
],
}
async function mountView(withTenant = true) {
async function mountView() {
vi.stubGlobal('localStorage', createMemoryStorage())
vi.stubGlobal('sessionStorage', createMemoryStorage(withTenant ? { 'cc-admin-warehouse-tenant-id': '10001' } : {}))
vi.stubGlobal('sessionStorage', createMemoryStorage())
const pinia = createPinia()
setActivePinia(pinia)
const auth = useAuthStore()
@@ -99,7 +99,7 @@ describe('WarehousesView real API integration', () => {
vi.mocked(updatePlatformWarehouse).mockReset().mockResolvedValue(null)
})
it('loads the selected tenant and renders address and receiving capability from details', async () => {
it('automatically loads the fixed tenant and renders address and receiving capability from details', async () => {
const wrapper = await mountView()
expect(listPlatformWarehouses).toHaveBeenCalledWith(10001, 1, 200)
@@ -107,14 +107,34 @@ describe('WarehousesView real API integration', () => {
expect(wrapper.text()).toContain('测试环境收货仓')
expect(wrapper.text()).toContain('北苑街道 真实物流园 1 号库 A 栋')
expect(wrapper.text()).toContain('周一至周六 08:30-18:00')
expect(wrapper.text()).not.toContain('全部收货仓')
expect(wrapper.find('.query-toolbar').exists()).toBe(true)
expect(wrapper.find('.data-table').exists()).toBe(true)
})
it('does not query or show demo data until a valid tenant is selected', async () => {
const wrapper = await mountView(false)
it('updates filters locally and only reloads after search', async () => {
const wrapper = await mountView()
const keywordInput = wrapper.find('input[placeholder="仓库名称、编码、城市或地址"]')
expect(listPlatformWarehouses).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请输入租户 ID 并查询真实仓库数据')
expect(wrapper.text()).not.toContain('演示物流园')
await keywordInput.setValue('义乌')
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenCalledTimes(1)
const searchButton = wrapper.findAll('button').find((button) => button.text().trim() === '查询')
expect(searchButton).toBeDefined()
await searchButton?.trigger('click')
await flushPromises()
expect(listPlatformWarehouses).toHaveBeenCalledTimes(2)
expect(wrapper.text()).toContain('测试环境收货仓')
})
it('does not render a tenant selector during the single-tenant phase', async () => {
const wrapper = await mountView()
expect(wrapper.find('[aria-label="目标租户 ID"]').exists()).toBe(false)
expect(wrapper.text()).not.toContain('查询仓库')
expect(wrapper.text()).toContain('租户 10001')
})
it('shows a recoverable real API error without falling back to demo warehouses', async () => {
@@ -151,4 +171,31 @@ describe('WarehousesView real API integration', () => {
}))
expect(payload.capabilities).toContainEqual(expect.objectContaining({ capabilityCode: 'STORAGE', description: '托盘仓储' }))
})
it('shows field-level required errors and does not submit an empty warehouse', async () => {
const wrapper = await mountView()
const createButton = wrapper.findAll('button').find((button) => button.text().trim() === '新增仓库')
expect(createButton).toBeDefined()
await createButton?.trigger('click')
await flushPromises()
expect(document.querySelector('input[placeholder="例如 广东省"]')).not.toBeNull()
expect(document.querySelector('input[placeholder="请输入仓库联系人姓名"]')).not.toBeNull()
expect(document.querySelector('textarea[placeholder="请输入街道、园区、楼栋及库位信息"]')).not.toBeNull()
await wrapper.find('input[placeholder="例如 CN-NB-01"]').trigger('blur')
await wrapper.vm.$nextTick()
expect(wrapper.find('#warehouse-code-error').text()).toBe('请输入仓库编码')
const saveButton = wrapper.findAll('button').find((button) => button.text().trim() === '保存仓库')
expect(saveButton).toBeDefined()
await saveButton?.trigger('click')
await flushPromises()
expect(createPlatformWarehouse).not.toHaveBeenCalled()
expect(wrapper.text()).toContain('请输入仓库编码')
expect(wrapper.text()).toContain('请输入仓库名称')
expect(wrapper.text()).toContain('请输入城市')
expect(wrapper.text()).toContain('请输入详细地址')
})
})
+113 -127
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MapPin, Pencil, Plus, Power, RotateCcw, Search, Warehouse as WarehouseIcon } from '@lucide/vue'
import MetricStrip from '@/components/MetricStrip.vue'
import { MapPin, Pencil, Plus, Power, Warehouse as WarehouseIcon } from '@lucide/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 { getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
import {
@@ -18,19 +19,29 @@ import {
import { useAccessPreviewStore } from '@/stores/accessPreview'
import type { Warehouse } from '@/types/admin'
const tenantStorageKey = 'cc-admin-warehouse-tenant-id'
const fixedTenantId = 10001
const access = useAccessPreviewStore()
const editorOpen = ref(false)
const saving = ref(false)
const loading = ref(false)
const page = ref(1)
const pageSize = 10
const tenantInput = ref(sessionStorage.getItem(tenantStorageKey) || '')
const pageSize = ref(10)
const loadedTenantId = ref<number | null>(null)
const loadError = ref<ApiErrorDetails | null>(null)
const detailWarningCount = ref(0)
const statusLoadingIds = ref(new Set<string>())
const filters = reactive({ keyword: '', status: '' as Warehouse['status'] | '' })
type WarehouseFilters = QueryModel & { keyword: string; status: Warehouse['status'] | '' }
const filters = ref<WarehouseFilters>({ keyword: '', status: '' })
const appliedFilters = ref<WarehouseFilters>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '仓库名称、编码、城市或地址' },
{
key: 'status', type: 'select', placeholder: '全部状态', options: [
{ label: '启用', value: 'ACTIVE' },
{ label: '停用', value: 'INACTIVE' },
],
},
]
let loadSequence = 0
interface WarehouseRow extends Warehouse {
@@ -38,12 +49,29 @@ interface WarehouseRow extends Warehouse {
}
const rows = ref<WarehouseRow[]>([])
const warehouseColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'warehouse', label: '仓库', minWidth: 200, slotName: 'warehouse' },
{ key: 'city', label: '所在城市', minWidth: 130, slotName: 'city' },
{ key: 'address', label: '收货地址', minWidth: 260, slotName: 'address' },
{ key: 'contact', label: '联系人', minWidth: 150, slotName: 'contact' },
{ prop: 'businessHours', label: '收货时间', minWidth: 190, formatter: (row: WarehouseRow) => row.businessHours || '-' },
{ key: 'status', label: '状态', width: 100, slotName: 'status' },
]
const emptyForm = (): Omit<Warehouse, 'updatedAt'> => ({
id: '', code: '', name: '', province: '', city: '', address: '', contactName: '', contactPhone: '',
businessHours: '周一至周六 08:30-18:00', status: 'ACTIVE', remark: '',
})
const form = reactive(emptyForm())
type WarehouseRequiredField = 'code' | 'name' | 'city' | 'address'
const requiredFieldMessages: Record<WarehouseRequiredField, string> = {
code: '请输入仓库编码',
name: '请输入仓库名称',
city: '请输入城市',
address: '请输入详细地址',
}
const formErrors = reactive<Record<WarehouseRequiredField, string>>({ code: '', name: '', city: '', address: '' })
const editorLabel = computed(() => form.id ? '编辑收货仓' : '新增收货仓')
const editorPermission = computed(() => form.id ? 'platform:warehouse:edit' : 'platform:warehouse:create')
const { captureBaseline } = useUnsavedWorkRegistration({
@@ -54,27 +82,14 @@ const { captureBaseline } = useUnsavedWorkRegistration({
requiredPermission: editorPermission,
})
function parseTenantId(value = tenantInput.value) {
const normalized = value.trim()
if (!/^[1-9]\d*$/.test(normalized)) return null
const tenantId = Number(normalized)
return Number.isSafeInteger(tenantId) ? tenantId : null
}
const tenantMatchesLoaded = computed(() => parseTenantId() === loadedTenantId.value)
const warehouseReady = computed(() => loadedTenantId.value === fixedTenantId && !loading.value)
const filteredRows = computed(() => rows.value.filter((warehouse) => {
const keyword = filters.keyword.trim().toLowerCase()
const keyword = appliedFilters.value.keyword.trim().toLowerCase()
const matchesKeyword = !keyword || [warehouse.code, warehouse.name, warehouse.province, warehouse.city, warehouse.address]
.some((value) => value.toLowerCase().includes(keyword))
return matchesKeyword && (!filters.status || warehouse.status === filters.status)
return matchesKeyword && (!appliedFilters.value.status || warehouse.status === appliedFilters.value.status)
}))
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
const metrics = computed(() => [
{ label: '全部收货仓', value: rows.value.length, note: loadedTenantId.value ? `租户 ${loadedTenantId.value}` : '等待查询租户' },
{ label: '启用仓库', value: rows.value.filter((item) => item.status === 'ACTIVE').length, note: '客户当前可选择' },
{ label: '暂停接单', value: rows.value.filter((item) => item.status === 'INACTIVE').length, note: '不出现在客户下单选项' },
{ label: '覆盖城市', value: new Set(rows.value.map((item) => item.city).filter(Boolean)).size, note: '国内收货区域' },
])
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value))
const loadErrorDescription = computed(() => {
if (!loadError.value) return ''
const references = [
@@ -86,7 +101,6 @@ const loadErrorDescription = computed(() => {
return [loadError.value.message, references.join(' · '), retained].filter(Boolean).join(' ')
})
watch(filters, () => { page.value = 1 })
watch(
() => access.can(editorPermission.value),
(canWrite) => {
@@ -117,23 +131,42 @@ function toRow(warehouse: PlatformWarehouseResource, detail: PlatformWarehouseDe
}
}
function resetFilters() {
filters.keyword = ''
filters.status = ''
function queryWarehouses(model: QueryModel) {
appliedFilters.value = {
keyword: String(model.keyword || ''),
status: (model.status || '') as Warehouse['status'] | '',
}
page.value = 1
void loadWarehouses()
}
async function loadWarehouses(requestedTenantId = parseTenantId()) {
if (!requestedTenantId) {
ElMessage.warning('请输入有效的正整数租户 ID')
return
}
function clearWarehouseFormErrors() {
for (const field of Object.keys(formErrors) as WarehouseRequiredField[]) formErrors[field] = ''
}
function clearFieldError(field: WarehouseRequiredField) {
if (formErrors[field]) formErrors[field] = ''
}
function validateRequiredField(field: WarehouseRequiredField) {
formErrors[field] = form[field].trim() ? '' : requiredFieldMessages[field]
return !formErrors[field]
}
function validateWarehouseForm() {
return (Object.keys(requiredFieldMessages) as WarehouseRequiredField[])
.map(validateRequiredField)
.every(Boolean)
}
async function loadWarehouses() {
const sequence = ++loadSequence
loading.value = true
loadError.value = null
try {
const result = await listPlatformWarehouses(requestedTenantId, 1, 200)
const result = await listPlatformWarehouses(fixedTenantId, 1, 200)
const details = await Promise.allSettled(
result.records.map((warehouse) => getPlatformWarehouse(requestedTenantId, warehouse.id)),
result.records.map((warehouse) => getPlatformWarehouse(fixedTenantId, warehouse.id)),
)
if (sequence !== loadSequence) return
rows.value = result.records.map((warehouse, index) => {
@@ -141,9 +174,7 @@ async function loadWarehouses(requestedTenantId = parseTenantId()) {
return toRow(warehouse, detailResult?.status === 'fulfilled' ? detailResult.value : null)
})
detailWarningCount.value = details.filter((item) => item.status === 'rejected').length
loadedTenantId.value = requestedTenantId
tenantInput.value = String(requestedTenantId)
sessionStorage.setItem(tenantStorageKey, tenantInput.value)
loadedTenantId.value = fixedTenantId
page.value = 1
} catch (error) {
if (sequence !== loadSequence) return
@@ -154,20 +185,17 @@ async function loadWarehouses(requestedTenantId = parseTenantId()) {
}
function requireCurrentTenant() {
if (!loadedTenantId.value) {
ElMessage.warning('请先查询目标租户的仓库数据')
if (!warehouseReady.value) {
ElMessage.warning('仓库数据尚未加载完成,请稍后重试')
return null
}
if (!tenantMatchesLoaded.value) {
ElMessage.warning('租户 ID 已变更,请先重新查询后再维护仓库')
return null
}
return loadedTenantId.value
return fixedTenantId
}
function openCreate() {
if (!requireCurrentTenant()) return
Object.assign(form, emptyForm())
clearWarehouseFormErrors()
captureBaseline()
editorOpen.value = true
}
@@ -197,6 +225,7 @@ async function openEdit(warehouse: WarehouseRow) {
city: address?.city || '',
address: address?.addressLine1 || '',
})
clearWarehouseFormErrors()
captureBaseline()
editorOpen.value = true
}
@@ -256,10 +285,7 @@ function buildSaveRequest(tenantId: number): SavePlatformWarehouseRequest {
async function saveWarehouse() {
const tenantId = requireCurrentTenant()
if (!tenantId) return
if (!form.code.trim() || !form.name.trim() || !form.city.trim() || !form.address.trim()) {
ElMessage.warning('请填写仓库编码、名称、城市和详细地址')
return
}
if (!validateWarehouseForm()) return
saving.value = true
try {
const payload = buildSaveRequest(tenantId)
@@ -268,7 +294,7 @@ async function saveWarehouse() {
const successMessage = form.id ? '仓库资料已更新' : '收货仓已创建'
editorOpen.value = false
ElMessage.success(successMessage)
await loadWarehouses(tenantId)
await loadWarehouses()
} catch (error) {
const details = getApiErrorDetails(error, '仓库保存失败,请检查后重试')
ElMessage.error(details.requestId ? `${details.message}(requestId: ${details.requestId})` : details.message)
@@ -307,31 +333,12 @@ async function toggleStatus(warehouse: WarehouseRow) {
}
onMounted(() => {
const tenantId = parseTenantId()
if (tenantId) void loadWarehouses(tenantId)
void loadWarehouses()
})
</script>
<template>
<div class="business-page">
<section class="surface-panel tenant-panel" aria-label="目标租户">
<div class="tenant-copy">
<h2>目标租户</h2>
<p>仓库数据按租户隔离,请输入企业租户 ID 后查询。</p>
</div>
<div class="tenant-query">
<el-input
v-model="tenantInput"
inputmode="numeric"
maxlength="19"
placeholder="请输入租户 ID"
aria-label="目标租户 ID"
@keyup.enter="loadWarehouses()"
/>
<el-button type="primary" :loading="loading" @click="loadWarehouses()">查询仓库</el-button>
</div>
</section>
<el-alert v-if="loadError" type="error" :closable="false" show-icon class="state-alert">
<template #title>仓库数据加载失败</template>
<p>{{ loadErrorDescription }}</p>
@@ -342,73 +349,64 @@ onMounted(() => {
<p>{{ detailWarningCount }} 个仓库缺少地址或服务能力详情;编辑时会再次尝试加载,不会用模拟数据补齐。</p>
</el-alert>
<MetricStrip :items="metrics" />
<section class="surface-panel filter-panel" aria-label="仓库筛选">
<div class="filter-fields">
<el-input v-model="filters.keyword" clearable placeholder="仓库名称、编码、城市或地址" aria-label="仓库关键词">
<template #prefix><Search :size="16" /></template>
</el-input>
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="仓库状态">
<el-option label="启用" value="ACTIVE" /><el-option label="停用" value="INACTIVE" />
</el-select>
</div>
<div class="filter-actions">
<el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button>
<QueryToolbar v-model="filters" class="surface-panel" ariaLabel="仓库筛选" :fields="queryFields" @search="queryWarehouses">
<template #actions>
<el-button
v-if="access.can('platform:warehouse:create')"
type="primary"
:disabled="!tenantMatchesLoaded || loading"
:disabled="!warehouseReady"
@click="openCreate"
><Plus :size="16" />新增仓库</el-button>
</div>
</section>
</template>
</QueryToolbar>
<section class="surface-panel table-panel" aria-live="polite">
<div class="panel-heading">
<div>
<h2>平台收货仓</h2>
<p v-if="loadedTenantId">当前数据:租户 {{ loadedTenantId }} · 仅启用仓库会进入客户头程下单选项</p>
<p v-else>查询租户后显示服务端仓库数据</p>
<p v-if="loadedTenantId">租户 {{ fixedTenantId }} · 仅启用仓库会进入客户头程下单选项</p>
<p v-else>正在加载平台仓库数据</p>
</div>
</div>
<div v-loading="loading" class="warehouse-table-state">
<el-table v-if="pagedRows.length" :data="pagedRows" row-key="id">
<el-table-column label="操作" width="180" fixed="left">
<template #default="{ row }">
<DataTable
v-model:page="page"
v-model:page-size="pageSize"
:data="pagedRows"
:columns="warehouseColumns"
:loading="loading"
:total="filteredRows.length"
:page-sizes="[10, 20, 50]"
row-key="id"
aria-label="平台收货仓列表"
:empty-text="loadedTenantId ? '暂无符合筛选条件的仓库' : '仓库数据暂不可用,请重新加载'"
>
<template #actions="{ row }">
<div v-if="access.can('platform:warehouse:edit') || access.can('platform:warehouse:status')" class="table-actions">
<el-button v-if="access.can('platform:warehouse:edit')" link type="primary" :disabled="!tenantMatchesLoaded" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="access.can('platform:warehouse:edit')" link type="primary" :disabled="!warehouseReady" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('platform:warehouse:status')"
link
:type="(row as WarehouseRow).status === 'ACTIVE' ? 'danger' : 'success'"
:disabled="!tenantMatchesLoaded"
:disabled="!warehouseReady"
:loading="statusLoadingIds.has((row as WarehouseRow).id)"
@click="toggleStatus(row as WarehouseRow)"
><Power :size="15" />{{ (row as WarehouseRow).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
<span v-else class="warehouse-subline">仅查看</span>
</template>
</el-table-column>
<el-table-column label="仓库" min-width="200">
<template #default="{ row }"><div class="warehouse-cell"><span><WarehouseIcon :size="18" /></span><div><strong>{{ (row as WarehouseRow).name }}</strong><small>{{ (row as WarehouseRow).code }}</small></div></div></template>
</el-table-column>
<el-table-column label="所在城市" min-width="130"><template #default="{ row }">{{ [(row as WarehouseRow).province, (row as WarehouseRow).city].filter(Boolean).join(' · ') || '-' }}</template></el-table-column>
<el-table-column label="收货地址" min-width="260"><template #default="{ row }"><div class="warehouse-address"><MapPin :size="15" />{{ (row as WarehouseRow).address || '详情暂未加载' }}</div></template></el-table-column>
<el-table-column label="联系人" min-width="150"><template #default="{ row }"><strong>{{ (row as WarehouseRow).contactName || '-' }}</strong><span class="warehouse-subline">{{ (row as WarehouseRow).contactPhone || '-' }}</span></template></el-table-column>
<el-table-column label="收货时间" min-width="190"><template #default="{ row }">{{ (row as WarehouseRow).businessHours || '-' }}</template></el-table-column>
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="(row as WarehouseRow).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as WarehouseRow).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template></el-table-column>
</el-table>
<el-empty v-else-if="!loading" :description="loadedTenantId ? '该租户暂无符合条件的仓库' : '请输入租户 ID 并查询真实仓库数据'" />
</div>
<div v-if="loadedTenantId" class="table-footer"><span>共 {{ filteredRows.length }} 个收货仓</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="filteredRows.length" layout="prev, pager, next" /></div>
</template>
<template #warehouse="{ row }"><div class="warehouse-cell"><span><WarehouseIcon :size="18" /></span><div><strong>{{ (row as WarehouseRow).name }}</strong><small>{{ (row as WarehouseRow).code }}</small></div></div></template>
<template #city="{ row }">{{ [(row as WarehouseRow).province, (row as WarehouseRow).city].filter(Boolean).join(' · ') || '-' }}</template>
<template #address="{ row }"><div class="warehouse-address"><MapPin :size="15" />{{ (row as WarehouseRow).address || '详情暂未加载' }}</div></template>
<template #contact="{ row }"><strong>{{ (row as WarehouseRow).contactName || '-' }}</strong><span class="warehouse-subline">{{ (row as WarehouseRow).contactPhone || '-' }}</span></template>
<template #status="{ row }"><el-tag :type="(row as WarehouseRow).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as WarehouseRow).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #pagination-left>共 {{ filteredRows.length }} 个收货仓</template>
</DataTable>
</section>
<el-drawer v-model="editorOpen" :title="editorLabel" size="620px" class="business-drawer editor-drawer" :close-on-click-modal="false">
<el-form label-position="top" class="drawer-form" :disabled="saving">
<div class="form-section"><h3>仓库标识</h3><div class="form-grid two-cols"><el-form-item label="仓库编码" required><el-input v-model="form.code" maxlength="50" placeholder="例如 CN-NB-01" /></el-form-item><el-form-item label="仓库名称" required><el-input v-model="form.name" maxlength="120" placeholder="客户下单时显示的名称" /></el-form-item><el-form-item label="状态"><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"><el-form-item label="省份"><el-input v-model="form.province" maxlength="100" /></el-form-item><el-form-item label="城市" required><el-input v-model="form.city" maxlength="100" /></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="64" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="32" /></el-form-item><el-form-item label="收货时间"><el-input v-model="form.businessHours" maxlength="500" /></el-form-item></div><el-form-item label="详细地址" required><el-input v-model="form.address" maxlength="255" type="textarea" :rows="3" /></el-form-item></div>
<div class="form-section"><h3>备注</h3><el-form-item label="客户可理解的收货说明"><el-input v-model="form.remark" maxlength="500" type="textarea" :rows="3" show-word-limit /></el-form-item></div>
<el-form :model="form" label-position="top" class="drawer-form" :disabled="saving">
<div class="form-section"><h3>仓库标识</h3><div class="form-grid two-cols"><el-form-item label="仓库编码" required :class="{ 'has-field-error': formErrors.code }"><el-input v-model="form.code" maxlength="50" placeholder="例如 CN-NB-01" :aria-invalid="Boolean(formErrors.code)" :aria-describedby="formErrors.code ? 'warehouse-code-error' : undefined" @input="clearFieldError('code')" @blur="validateRequiredField('code')" /><p v-if="formErrors.code" id="warehouse-code-error" class="field-error" role="alert">{{ formErrors.code }}</p></el-form-item><el-form-item label="仓库名称" required :class="{ 'has-field-error': formErrors.name }"><el-input v-model="form.name" maxlength="120" placeholder="请输入客户下单时显示的名称" :aria-invalid="Boolean(formErrors.name)" :aria-describedby="formErrors.name ? 'warehouse-name-error' : undefined" @input="clearFieldError('name')" @blur="validateRequiredField('name')" /><p v-if="formErrors.name" id="warehouse-name-error" class="field-error" role="alert">{{ formErrors.name }}</p></el-form-item><el-form-item label="状态"><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"><el-form-item label="省份"><el-input v-model="form.province" maxlength="100" placeholder="例如 广东省" /></el-form-item><el-form-item label="城市" required :class="{ 'has-field-error': formErrors.city }"><el-input v-model="form.city" maxlength="100" placeholder="例如 深圳市" :aria-invalid="Boolean(formErrors.city)" :aria-describedby="formErrors.city ? 'warehouse-city-error' : undefined" @input="clearFieldError('city')" @blur="validateRequiredField('city')" /><p v-if="formErrors.city" id="warehouse-city-error" class="field-error" role="alert">{{ formErrors.city }}</p></el-form-item><el-form-item label="联系人"><el-input v-model="form.contactName" maxlength="64" placeholder="请输入仓库联系人姓名" /></el-form-item><el-form-item label="联系电话"><el-input v-model="form.contactPhone" maxlength="32" placeholder="请输入手机号或固定电话" /></el-form-item><el-form-item label="收货时间"><el-input v-model="form.businessHours" maxlength="500" placeholder="例如 周一至周六 08:30-18:00" /></el-form-item></div><el-form-item label="详细地址" required :class="{ 'has-field-error': formErrors.address }"><el-input v-model="form.address" maxlength="255" type="textarea" :rows="3" placeholder="请输入街道、园区、楼栋及库位信息" :aria-invalid="Boolean(formErrors.address)" :aria-describedby="formErrors.address ? 'warehouse-address-error' : undefined" @input="clearFieldError('address')" @blur="validateRequiredField('address')" /><p v-if="formErrors.address" id="warehouse-address-error" class="field-error" role="alert">{{ formErrors.address }}</p></el-form-item></div>
<div class="form-section"><h3>备注</h3><el-form-item label="客户可理解的收货说明"><el-input v-model="form.remark" maxlength="500" type="textarea" :rows="3" show-word-limit placeholder="例如 预约要求、进仓指引或其他注意事项" /></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="saveWarehouse">保存仓库</el-button></div></template>
</el-drawer>
@@ -416,11 +414,6 @@ onMounted(() => {
</template>
<style scoped>
.tenant-panel { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; }
.tenant-copy h2 { margin: 0; font-size: 16px; }
.tenant-copy p { margin: 6px 0 0; color: var(--cc-muted); font-size: 13px; }
.tenant-query { display: flex; width: min(100%, 390px); gap: 10px; }
.tenant-query .el-input { flex: 1; }
.state-alert p { margin: 6px 0 10px; line-height: 1.55; }
.warehouse-table-state { min-height: 180px; }
.warehouse-cell { display: flex; align-items: center; gap: 10px; }
@@ -430,15 +423,8 @@ onMounted(() => {
.warehouse-cell small, .warehouse-subline { display: block; color: var(--cc-muted); font-size: 11px; }
.warehouse-address { display: flex; align-items: flex-start; gap: 7px; line-height: 1.55; overflow-wrap: anywhere; }
.warehouse-address svg { flex: 0 0 15px; margin-top: 2px; color: var(--cc-muted); }
.field-error { width: 100%; margin: 6px 0 0; color: var(--el-color-danger); font-size: 12px; line-height: 1.35; overflow-wrap: anywhere; }
:deep(.has-field-error .el-input__wrapper) { box-shadow: 0 0 0 1px var(--el-color-danger) inset; }
:deep(.has-field-error .el-textarea__inner) { box-shadow: 0 0 0 1px var(--el-color-danger) inset; }
@media (max-width: 640px) {
.tenant-panel { align-items: stretch; flex-direction: column; gap: 16px; }
.tenant-query { width: 100%; }
.tenant-query .el-button { flex: 0 0 auto; }
}
@media (max-width: 420px) {
.tenant-query { flex-direction: column; }
.tenant-query .el-button { width: 100%; }
}
</style>