feat: 完成后台联调与通用列表组件
This commit is contained in:
@@ -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
@@ -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',
|
||||
|
||||
@@ -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 } },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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 } })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Vendored
+6
@@ -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']
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Vendored
+1
-1
@@ -8,8 +8,8 @@ declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
description?: string
|
||||
showPageHeading?: boolean
|
||||
requiresAuth?: boolean
|
||||
permission?: string
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
Vendored
+1
@@ -4,6 +4,7 @@ declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
title?: string
|
||||
description?: string
|
||||
showPageHeading?: boolean
|
||||
requiresAuth?: boolean
|
||||
permission?: string
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Vendored
+18
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
@@ -105,6 +105,7 @@ export interface Permission {
|
||||
method: string
|
||||
status: RoleStatus
|
||||
sort: number
|
||||
builtIn?: boolean
|
||||
}
|
||||
|
||||
export interface OrderTimelineItem {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -13,5 +13,6 @@ export function mapPlatformPermission(item: PlatformPermissionResource): Permiss
|
||||
method: item.httpMethod ?? '-',
|
||||
status: item.status === 'ACTIVE' ? '启用' : '停用',
|
||||
sort: item.sortNo,
|
||||
builtIn: Boolean(item.builtIn),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '顺丰', '')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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('深圳示例贸易有限公司')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user