修复bug

This commit is contained in:
2026-07-31 15:34:22 +08:00
parent 151d76f77c
commit be7675954a
25 changed files with 224 additions and 204 deletions
+6 -3
View File
@@ -33,9 +33,12 @@ test('uses membershipId and full replacement arrays in authorization writes', ()
assert.doesNotMatch(source, /data: \{ userId, roleIds \}/)
})
test('clears mock records before loading server role and member lists', () => {
assert.match(storeSource, /rolesLoadedFromApi\.value = true\s+roles\.value = \[\]/)
assert.match(storeSource, /membersLoadedFromApi\.value = true\s+members\.value = \[\]/)
test('starts empty and only loads server role and member records', () => {
assert.match(storeSource, /const members = ref<TenantMember\[]>\(\[\]\)/)
assert.match(storeSource, /const roles = ref<TenantRole\[]>\(\[\]\)/)
assert.match(storeSource, /async function loadRoles\(\) \{\s+roles\.value = \[\]/)
assert.match(storeSource, /async function loadMembers\(\) \{\s+members\.value = \[\]/)
assert.doesNotMatch(storeSource, /initialRoles|initialMembers|示例贸易|演示大道/)
assert.match(storeSource, /await createMerchantRole/)
assert.match(storeSource, /await updateMerchantRole/)
assert.match(storeSource, /await changeMerchantRoleStatus/)
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

-9
View File
@@ -36,12 +36,3 @@ export function createFirstLegPartySnapshot(party: FirstLegParty, role: FirstLeg
address: party.address,
}
}
// API 接入前供客户端订单静态流程使用,不代表已确认的往来单位类型或数据范围契约。
export const DEMO_FIRST_LEG_PARTIES: readonly FirstLegParty[] = [
{ id: 'CP001', code: 'CN-SHIP-001', name: '示例贸易有限公司', roles: ['SHIPPER'], contactName: '王经理', phone: '138****1201', email: 'shipping@example.com', countryRegion: '中国', province: '浙江省', city: '杭州市', postalCode: '310000', address: '滨江区演示产业园 A 座', status: 'ACTIVE' },
{ id: 'CP002', code: 'CN-SHIP-002', name: '华南演示供应链有限公司', roles: ['SHIPPER'], contactName: '陈主管', phone: '139****8820', email: 'ops@example.cn', countryRegion: '中国', province: '广东省', city: '深圳市', postalCode: '518000', address: '宝安区演示供应链园 2 号楼', status: 'ACTIVE' },
{ id: 'CP003', code: 'US-CONS-001', name: 'DEMO GLOBAL LLC', roles: ['CONSIGNEE'], contactName: 'Alex Morgan', phone: '+1 *** *** 2108', email: 'receiving@example.us', countryRegion: '美国', province: 'California', city: 'Los Angeles', postalCode: '90001', address: '100 Demo Logistics Ave', status: 'ACTIVE' },
{ id: 'CP004', code: 'DE-CONS-001', name: 'DEMO EUROPE GMBH', roles: ['CONSIGNEE'], contactName: 'Lena Fischer', phone: '+49 *** *** 4810', email: 'warehouse@example.de', countryRegion: '德国', province: 'Hamburg', city: 'Hamburg', postalCode: '20095', address: '8 Demo Hafenstrasse', status: 'ACTIVE' },
{ id: 'CP005', code: 'US-CONS-OLD', name: '停用演示收货单位', roles: ['CONSIGNEE'], contactName: 'Demo Contact', phone: '', email: '', countryRegion: '美国', province: '', city: '', postalCode: '', address: '', status: 'INACTIVE' },
]
+2 -1
View File
@@ -41,6 +41,7 @@ export const firstLegPartySource: FirstLegPartySource = {
return parties.find((party) => party.id === id && isAvailableForRole(party, role))
},
async load() {
parties.splice(0, parties.length, ...(await getMerchantPartnerCandidates()).map(mapPartner))
parties.splice(0, parties.length)
parties.push(...(await getMerchantPartnerCandidates()).map(mapPartner))
},
}
+2 -3
View File
@@ -8,7 +8,7 @@ import {
Menu,
X,
} from '@lucide/vue'
import { PRODUCT_NAME } from '@chongchong/shared'
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
import ClientSidebarMenu from '@/components/ClientSidebarMenu.vue'
import { useAuthStore } from '@/stores/auth'
@@ -106,8 +106,7 @@ async function handleLogout() {
:aria-hidden="mobileMenuOpen ? 'true' : undefined"
>
<RouterLink class="client-brand" to="/">
<span class="client-brand-mark">CC</span>
<span><strong>{{ PRODUCT_NAME }}</strong><small>客户工作台</small></span>
<img class="client-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" />
</RouterLink>
<div class="tenant-name"><Building2 :size="17" /> {{ auth.user?.tenantName }}</div>
<div class="client-user">
+1 -1
View File
@@ -28,7 +28,7 @@ const router = createRouter({
})
router.beforeEach(async (to) => {
document.title = to.meta.title ? `${to.meta.title} - 虫虫出海客户工作台` : '虫虫出海客户工作台'
document.title = to.meta.title ? `${to.meta.title} - 柏悦物流客户工作台` : '柏悦物流客户工作台'
const auth = useAuthStore()
if (!auth.sessionRestored) {
try {
+6 -6
View File
@@ -138,8 +138,8 @@ function timeline(detail: MerchantFirstLegOrderDetail): FirstLegOrderTimelineIte
id: String(item.id),
event: item.toStatus === 'SUBMITTED' || item.toStatus === 'REJECTED' ? 'SUBMITTED' : item.toStatus === 'CANCELLED' ? 'CANCELLED' : item.toStatus === 'COMPLETED' ? 'COMPLETED' : item.toStatus === 'ACCEPTED' || item.toStatus === 'APPROVED' ? 'ACCEPTED' : 'IN_TRANSIT',
title: item.toStatus === 'REJECTED' ? '平台要求补充订单信息' : item.action || `状态更新为 ${item.toStatus}`,
description: item.reason || '订单状态已更新。',
operatorName: item.operatorName || '系统',
description: item.reason ?? '',
operatorName: item.operatorName ?? '',
occurredAt: item.occurredAt,
}))
}
@@ -155,7 +155,7 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
receivingWarehouseSnapshot: orderWarehouseSnapshot(order, warehouse), expectedDepartureDate: order.expectedShipDate ?? '', etd: '', eta: '',
shipperPartyId: shipper?.id ?? '', shipperPartySnapshot: shipper, shipper: shipper?.name ?? '',
consigneePartyId: consignee?.id ?? '', consigneePartySnapshot: consignee, consignee: consignee?.name ?? '',
expressCompanyName: order.expressCompany ?? '', trackingNumber: order.expressTrackingNo ?? '', priority: 'NORMAL', customerRemark: order.customerRemark ?? '', operatorName: '待平台分配',
expressCompanyName: order.expressCompany ?? '', trackingNumber: order.expressTrackingNo ?? '', priority: 'NORMAL', customerRemark: order.customerRemark ?? '', operatorName: '',
status: clientStatus(order.status), version: order.version ?? 0,
items: (detail?.cargoes ?? []).map((item) => ({
id: String(item.id), sku: item.skuNo ?? '', cargoNameCn: item.cargoName, cargoNameEn: item.cargoNameEn ?? '',
@@ -174,9 +174,9 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
function mapWarehouse(item: MerchantWarehouse): PlatformReceivingWarehouse {
return {
id: String(item.id), code: item.warehouseCode, name: item.warehouseName, province: item.countryCode,
city: item.countryCode, address: '详情地址待选择后加载', contactName: item.contactName ?? '', contactPhone: item.contactPhone ?? '',
businessHours: item.timezone ?? '以仓库通知为准', status: item.status, remark: item.remark ?? '', updatedAt: item.updatedAt ?? '',
id: String(item.id), code: item.warehouseCode, name: item.warehouseName, province: '',
city: '', address: '', contactName: item.contactName ?? '', contactPhone: item.contactPhone ?? '',
businessHours: '', status: item.status, remark: item.remark ?? '', updatedAt: item.updatedAt ?? '',
}
}
+18 -71
View File
@@ -27,17 +27,10 @@ export type TenantRecordStatus = 'ACTIVE' | 'INACTIVE'
export interface TenantCompanyProfile {
companyName: string
creditCode: string
companyType: string
registeredAddress: string
establishedDate: string
registeredCapital: string
legalRepresentative: string
legalRepresentativePhone: string
contactName: string
contactPhone: string
contactEmail: string
contactWechat: string
businessAddress: string
auditStatus: MerchantTenantStatus
}
@@ -160,56 +153,32 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
const mappedMerchantPermissionCodes = new Set(Object.values(FRONTEND_TO_MERCHANT_PERMISSION_CODES).flat())
const initialRoles: TenantRole[] = [
{ id: 'ROLE-ADMIN', name: '企业管理员', code: 'TENANT_ADMIN', description: '企业内置管理员,拥有全部客户端权限', status: 'ACTIVE', builtin: true, permissionIds: [], permissionCodes: [...allPermissionCodes] },
{ id: 'ROLE-ORDER', name: '订单专员', code: 'ORDER_OPERATOR', description: '负责头程订单创建、提交和跟进', status: 'ACTIVE', builtin: false, permissionIds: [], permissionCodes: ['account:profile:view', 'dashboard:view', 'order:first-leg:list', 'order:first-leg:create', 'order:first-leg:edit', 'order:first-leg:submit', 'order:first-leg:cancel', 'company:profile:view'] },
{ id: 'ROLE-VIEWER', name: '业务查看员', code: 'BUSINESS_VIEWER', description: '只读查看企业资料和业务订单', status: 'ACTIVE', builtin: false, permissionIds: [], permissionCodes: ['account:profile:view', 'dashboard:view', 'order:first-leg:list', 'company:profile:view'] },
]
const initialMembers: TenantMember[] = [
{ id: 'MEMBER-001', name: '企业管理员', account: 'customer', phone: '138****6601', email: 'admin@example.com', roleIds: ['ROLE-ADMIN'], status: 'ACTIVE', lastLoginAt: '2026-07-28 16:42', isAdministrator: true },
{ id: 'MEMBER-002', name: '张伟', account: 'zhangwei', phone: '138****6789', email: 'zhangwei@example.com', roleIds: ['ROLE-ORDER'], status: 'ACTIVE', lastLoginAt: '2026-07-28 09:18', isAdministrator: false },
{ id: 'MEMBER-003', name: '李娜', account: 'lina', phone: '137****2210', email: 'lina@example.com', roleIds: ['ROLE-VIEWER'], status: 'INACTIVE', lastLoginAt: '2026-07-12 11:30', isAdministrator: false },
]
const emptyCompany: TenantCompanyProfile = {
companyName: '',
creditCode: '',
legalRepresentative: '',
contactName: '',
contactPhone: '',
contactEmail: '',
auditStatus: 'PROFILE_REQUIRED',
}
export const useTenantAdministrationStore = defineStore('tenant-administration', () => {
const company = ref<TenantCompanyProfile>({
companyName: '示例贸易有限公司',
creditCode: '91330100MA2XXXXXXX',
companyType: '有限责任公司',
registeredAddress: '浙江省杭州市滨江区',
establishedDate: '2018-06-15',
registeredCapital: '500 万人民币',
legalRepresentative: '陈明',
legalRepresentativePhone: '139****8866',
contactName: '张伟',
contactPhone: '138****6789',
contactEmail: 'business@example.com',
contactWechat: 'cc_business',
businessAddress: '浙江省杭州市滨江区演示大道 88 号',
auditStatus: 'PROFILE_REQUIRED',
})
const qualifications = ref<TenantQualification[]>([
{ id: 'QUAL-001', type: '营业执照', fileName: '示例贸易有限公司营业执照.pdf', status: 'VERIFIED', expiresAt: '2028-06-15', updatedAt: '2026-07-27 14:20', documentUrl: '' },
{ id: 'QUAL-002', type: '法人身份证', fileName: '法人身份证明.pdf', status: 'VERIFIED', expiresAt: '2030-01-01', updatedAt: '2026-07-27 14:22', documentUrl: '' },
{ id: 'QUAL-003', type: '货代资质证书', fileName: '货代资质证书.pdf', status: 'PENDING', expiresAt: '2027-03-01', updatedAt: '2026-07-28 10:08', documentUrl: '' },
])
const members = ref<TenantMember[]>(structuredClone(initialMembers))
const roles = ref<TenantRole[]>(structuredClone(initialRoles))
const rolesLoadedFromApi = ref(false)
const membersLoadedFromApi = ref(false)
const company = ref<TenantCompanyProfile>({ ...emptyCompany })
const qualifications = ref<TenantQualification[]>([])
const members = ref<TenantMember[]>([])
const roles = ref<TenantRole[]>([])
const permissionResources = ref<MerchantPermissionResource[]>([])
const activeMemberCount = computed(() => members.value.filter((member) => member.status === 'ACTIVE').length)
async function loadCompanyContext() {
company.value = { ...emptyCompany }
qualifications.value = []
const [profile, qualificationRecords] = await Promise.all([
getTenantProfile(),
getTenantQualifications(),
])
company.value = {
...company.value,
companyName: profile.tenantName,
creditCode: profile.unifiedSocialCreditCode ?? '',
legalRepresentative: profile.legalName,
@@ -223,8 +192,8 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
type: item.qualificationType === 'BUSINESS_LICENSE' ? '营业执照' : item.qualificationType,
fileName: item.documentUrl.split('/').pop() || '营业执照',
status: item.status === 'APPROVED' ? 'VERIFIED' : item.status,
expiresAt: '长期',
updatedAt: '以服务端记录为准',
expiresAt: '-',
updatedAt: '-',
documentUrl: item.documentUrl,
}))
}
@@ -264,15 +233,14 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
}
async function loadRoles() {
rolesLoadedFromApi.value = true
roles.value = []
permissionResources.value = []
const [roleRecords, resources] = await Promise.all([getMerchantRoles(), getMerchantPermissionResources()])
permissionResources.value = resources.filter((item) => item.status === 'ACTIVE')
roles.value = roleRecords.map(mapMerchantRole)
}
async function loadMembers() {
membersLoadedFromApi.value = true
members.value = []
members.value = (await getMerchantMembers()).map(mapMerchantMember)
}
@@ -286,7 +254,6 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
contactEmail: payload.contactEmail.trim(),
})
company.value = {
...company.value,
companyName: updated.tenantName,
creditCode: updated.unifiedSocialCreditCode ?? '',
legalRepresentative: updated.legalName,
@@ -297,22 +264,6 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
}
}
function setCompanyAuditStatusForPreview(status: TenantCompanyProfile['auditStatus']) {
company.value.auditStatus = status
}
function upsertQualification(payload: Omit<TenantQualification, 'id' | 'status' | 'updatedAt'>) {
const existing = qualifications.value.find((item) => item.type === payload.type)
const next = {
...payload,
status: 'PENDING' as const,
updatedAt: new Date().toLocaleString('zh-CN', { hour12: false }),
}
if (existing) Object.assign(existing, next)
else qualifications.value.unshift({ id: `QUAL-${Date.now()}`, ...next })
company.value.auditStatus = 'PENDING'
}
async function createMember(payload: { name: string; account: string; password: string; phone: string; email: string; roleIds: string[]; memberNo: string }) {
const created = await createMerchantMember({
username: payload.account.trim(),
@@ -417,14 +368,10 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
members,
roles,
activeMemberCount,
rolesLoadedFromApi,
membersLoadedFromApi,
loadCompanyContext,
loadRoles,
loadMembers,
updateCompany,
setCompanyAuditStatusForPreview,
upsertQualification,
createMember,
updateMember,
toggleMemberStatus,
+5 -14
View File
@@ -7,11 +7,8 @@ a { color: inherit; }
.client-shell { min-height: 100vh; overflow-x: hidden; }
.top-header { position: fixed; inset: 0 0 auto 0; z-index: 20; display: flex; width: 100%; max-width: 100vw; height: 68px; align-items: center; padding: 0 28px; border-bottom: 1px solid var(--border); background: #fff; }
.client-brand { display: flex; width: 232px; min-width: 0; align-items: center; gap: 11px; color: var(--navy); text-decoration: none; }
.client-brand-mark, .auth-brand span { display: grid; width: 38px; height: 38px; place-items: center; border-radius: 8px; background: var(--primary); color: #fff; font-size: 13px; font-weight: 800; }
.client-brand > span:last-child { display: flex; flex-direction: column; }
.client-brand strong { font-size: 15px; }
.client-brand small { margin-top: 1px; color: var(--muted); font-size: 10px; }
.client-brand { display: flex; width: 232px; min-width: 0; align-items: center; color: var(--navy); text-decoration: none; }
.client-brand-logo { display: block; width: 150px; height: 54px; object-fit: contain; object-position: left center; }
.tenant-name { display: flex; flex: 1; align-items: center; gap: 8px; color: #475467; font-size: 13px; }
.client-user { display: flex; align-items: center; gap: 10px; }
.client-avatar { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 50%; background: #e7f3f1; color: var(--primary); font-weight: 750; }
@@ -57,7 +54,8 @@ a { color: inherit; }
.auth-page { min-height: 100vh; background: #eef3f3; }
.auth-header { display: flex; height: 72px; align-items: center; justify-content: space-between; padding: 0 clamp(24px, 6vw, 90px); border-bottom: 1px solid var(--border); background: #fff; color: var(--muted); font-size: 13px; }
.auth-brand { display: flex; align-items: center; gap: 10px; color: var(--navy); text-decoration: none; }
.auth-brand { display: flex; min-width: 0; align-items: center; color: var(--navy); text-decoration: none; }
.auth-brand-logo { display: block; width: 158px; height: 56px; object-fit: contain; object-position: left center; }
.auth-header a { color: var(--primary); text-decoration: none; }
.auth-main { display: grid; min-height: calc(100vh - 72px); grid-template-columns: 1fr 440px; align-items: center; gap: clamp(60px, 10vw, 150px); width: min(1180px, calc(100% - 48px)); margin: 0 auto; padding: 50px 0; }
.auth-kicker, .section-label { color: var(--primary); font-size: 11px; font-weight: 750; letter-spacing: .13em; }
@@ -74,8 +72,6 @@ a { color: inherit; }
.auth-form-options button:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.auth-submit { width: 100%; }
.auth-submit span { display: flex; align-items: center; gap: 7px; }
.demo-tip { display: flex; flex-direction: column; gap: 3px; margin-top: 18px; padding: 10px 12px; background: #f2f7f6; color: var(--muted); font-size: 12px; line-height: 1.55; }
.demo-tip strong { color: #40534f; }
.register-page { min-height: 100vh; background: #f5f7f8; }
.register-header { display: flex; height: 68px; align-items: center; justify-content: space-between; padding: 0 clamp(20px, 5vw, 72px); border-bottom: 1px solid var(--border); background: #fff; }
@@ -139,12 +135,6 @@ a { color: inherit; }
.profile-summary dt { color: var(--muted); }
.profile-summary dd { margin: 0; font-weight: 600; }
.module-stack { display: flex; flex-direction: column; gap: 14px; }
.module-actions { display: flex; justify-content: flex-end; gap: 10px; }
.module-actions .el-input { width: 280px; }
.module-empty { display: flex; min-height: 430px; align-items: center; justify-content: center; flex-direction: column; text-align: center; }
.module-empty-icon { display: grid; width: 56px; height: 56px; place-items: center; margin-bottom: 17px; background: #eaf2f1; color: var(--primary); }
.module-empty h2 { margin: 0 0 7px; font-size: 20px; }
.module-empty p { max-width: 540px; margin: 0 0 18px; color: var(--muted); }
.el-button--primary { --el-button-bg-color: var(--primary); --el-button-border-color: var(--primary); --el-button-hover-bg-color: var(--primary-dark); --el-button-hover-border-color: var(--primary-dark); }
@@ -163,6 +153,7 @@ a { color: inherit; }
.client-sidebar-header button { display: grid; width: 44px; height: 44px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--navy); place-items: center; }
.client-main { width: 100%; max-width: 100%; margin-left: 0; padding: 88px 14px 30px; }
.client-brand { width: auto; }
.client-brand-logo { width: 132px; height: 50px; }
.tenant-name, .client-user-copy { display: none; }
.client-user { margin-left: auto; }
.client-page-heading > button { display: grid; width: 44px; height: 44px; place-items: center; }
+16 -5
View File
@@ -1,6 +1,6 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createFirstLegPartySnapshot, DEMO_FIRST_LEG_PARTIES } from '../data/firstLegParties.ts'
import { createFirstLegPartySnapshot } from '../data/firstLegParties.ts'
import {
canClientCancelFirstLegOrder,
canClientEditFirstLegOrder,
@@ -11,6 +11,17 @@ import {
validateFirstLegOrderDraft,
} from './firstLegOrder.ts'
const shipperParty = {
id: 'CP001', code: 'SHIPPER-001', name: '测试发货企业', roles: ['SHIPPER'], contactName: '测试联系人',
phone: '13800000000', email: 'shipper@example.test', countryRegion: '中国', province: '浙江省', city: '宁波市',
postalCode: '315000', address: '测试发货地址', status: 'ACTIVE',
}
const consigneeParty = {
id: 'CP002', code: 'CONSIGNEE-001', name: 'TEST CONSIGNEE LLC', roles: ['CONSIGNEE'], contactName: 'Test Receiver',
phone: '+1 555 0100', email: 'receiver@example.test', countryRegion: '美国', province: 'California', city: 'Los Angeles',
postalCode: '90001', address: '100 Test Logistics Ave', status: 'ACTIVE',
}
function validDraft() {
return {
referenceNo: 'REF-001',
@@ -21,11 +32,11 @@ function validDraft() {
receivingWarehouseId: 'WH005',
expectedDepartureDate: '2026-08-08',
shipperPartyId: 'CP001',
shipperPartySnapshot: createFirstLegPartySnapshot(DEMO_FIRST_LEG_PARTIES[0], 'SHIPPER'),
shipper: '示例贸易有限公司',
shipperPartySnapshot: createFirstLegPartySnapshot(shipperParty, 'SHIPPER'),
shipper: shipperParty.name,
consigneePartyId: 'CP003',
consigneePartySnapshot: createFirstLegPartySnapshot(DEMO_FIRST_LEG_PARTIES[2], 'CONSIGNEE'),
consignee: 'DEMO GLOBAL LLC',
consigneePartySnapshot: createFirstLegPartySnapshot(consigneeParty, 'CONSIGNEE'),
consignee: consigneeParty.name,
priority: 'NORMAL',
customerRemark: '',
tradeTerm: 'FOB',
+2 -3
View File
@@ -13,9 +13,8 @@ const mainSource = readFileSync(new URL('../main.ts', import.meta.url), 'utf8')
test('matches the backend username login contract', () => {
assert.match(loginSource, /label="用户名"/)
assert.match(loginSource, /\^\[A-Za-z0-9_\]/)
assert.match(loginSource, /const isDevelopment = import\.meta\.env\.DEV/)
assert.match(loginSource, /v-if="developmentHint" class="demo-tip"/)
assert.match(loginSource, /merchant_admin/)
assert.match(loginSource, /typeof route\.query\.account === 'string'/)
assert.doesNotMatch(loginSource, /developmentHint|demo-tip|merchant_admin|联调账号|本地后端/)
assert.match(loginSource, /form\.remember/)
assert.match(loginSource, /忘记密码/)
})
+25 -23
View File
@@ -32,21 +32,25 @@ const qualificationFileError = ref('')
const qualificationSubmitting = ref(false)
const profileSubmitting = ref(false)
const loading = ref(false)
const loadError = ref('')
const selectedFile = ref<File | null>(null)
const draft = reactive<TenantCompanyProfile>({ ...store.company })
const qualificationDraft = reactive({ type: '营业执照', fileName: '', certificateNo: '' })
onMounted(async () => {
async function loadCompanyContext() {
loading.value = true
loadError.value = ''
try {
await store.loadCompanyContext()
Object.assign(draft, store.company)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '企业资料加载失败')
loadError.value = error instanceof Error ? error.message : '企业资料加载失败'
} finally {
loading.value = false
}
})
}
onMounted(() => void loadCompanyContext())
function startEdit() {
Object.assign(draft, store.company)
@@ -176,11 +180,16 @@ function qualificationStatus(status: string) {
<template>
<section v-loading="loading" class="profile-page">
<div v-if="loadError" class="client-card profile-load-error" role="alert">
<div><strong>企业资料加载失败</strong><span>{{ loadError }}</span></div>
<el-button :loading="loading" @click="loadCompanyContext">重新加载</el-button>
</div>
<template v-else>
<div class="profile-summary-card client-card">
<div class="company-mark"><Building2 :size="28" /></div>
<div class="company-summary">
<div><h2>{{ store.company.companyName }}</h2><el-tag :type="companyApproved ? 'success' : store.company.auditStatus === 'REJECTED' || store.company.auditStatus === 'DISABLED' ? 'danger' : 'warning'" effect="light">{{ auditStatusCopy.label }}</el-tag></div>
<p>{{ store.company.creditCode }} · {{ store.company.companyType }}</p>
<p>{{ store.company.creditCode || '统一社会信用代码暂未填写' }}</p>
</div>
<el-button v-if="auth.hasPermission('company:profile:edit')" type="primary" @click="startEdit"><Pencil :size="16" />编辑企业资料</el-button>
</div>
@@ -195,14 +204,9 @@ function qualificationStatus(status: string) {
<template #label><span class="tab-label"><Building2 :size="16" />企业基本信息</span></template>
<div class="client-card info-panel">
<dl class="profile-grid">
<div><dt>公司名称</dt><dd>{{ store.company.companyName }}</dd></div>
<div><dt>统一社会信用代码</dt><dd>{{ store.company.creditCode }}</dd></div>
<div><dt>企业类型</dt><dd>{{ store.company.companyType }}</dd></div>
<div><dt>注册地</dt><dd>{{ store.company.registeredAddress }}</dd></div>
<div><dt>成立日期</dt><dd>{{ store.company.establishedDate }}</dd></div>
<div><dt>注册资本</dt><dd>{{ store.company.registeredCapital }}</dd></div>
<div><dt>法人姓名</dt><dd>{{ store.company.legalRepresentative }}</dd></div>
<div><dt>法人电话</dt><dd>{{ store.company.legalRepresentativePhone }}</dd></div>
<div><dt>公司名称</dt><dd>{{ store.company.companyName || '-' }}</dd></div>
<div><dt>统一社会信用代码</dt><dd>{{ store.company.creditCode || '-' }}</dd></div>
<div><dt>法人姓名</dt><dd>{{ store.company.legalRepresentative || '-' }}</dd></div>
</dl>
</div>
</el-tab-pane>
@@ -211,11 +215,9 @@ function qualificationStatus(status: string) {
<template #label><span class="tab-label"><UserRound :size="16" />联系信息</span></template>
<div class="client-card info-panel">
<dl class="profile-grid">
<div><dt>联系人</dt><dd>{{ store.company.contactName }}</dd></div>
<div><dt>联系电话</dt><dd>{{ store.company.contactPhone }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ store.company.contactEmail }}</dd></div>
<div><dt>微信</dt><dd>{{ store.company.contactWechat }}</dd></div>
<div class="full-row"><dt>业务地址</dt><dd>{{ store.company.businessAddress }}</dd></div>
<div><dt>联系人</dt><dd>{{ store.company.contactName || '-' }}</dd></div>
<div><dt>联系电话</dt><dd>{{ store.company.contactPhone || '-' }}</dd></div>
<div><dt>联系邮箱</dt><dd>{{ store.company.contactEmail || '-' }}</dd></div>
</dl>
</div>
</el-tab-pane>
@@ -224,7 +226,7 @@ function qualificationStatus(status: string) {
<template #label><span class="tab-label"><FileCheck2 :size="16" />资质文件</span></template>
<div class="client-card qualification-panel">
<header><div><h3>企业资质</h3><p>文件更新后需等待平台重新审核,历史业务不受影响。</p></div><el-button v-if="auth.hasPermission('company:qualification:upload')" type="primary" @click="openQualificationDialog"><Upload :size="16" />更新资质</el-button></header>
<el-table class="desktop-qualification-table" :data="store.qualifications" row-key="id">
<el-table class="desktop-qualification-table" :data="store.qualifications" row-key="id" empty-text="暂无企业资质记录">
<el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button></template></el-table-column>
<el-table-column prop="type" label="文件类型" min-width="150" />
<el-table-column prop="fileName" label="文件名称" min-width="220" show-overflow-tooltip />
@@ -239,6 +241,7 @@ function qualificationStatus(status: string) {
<dl><div><dt>有效期</dt><dd>{{ record.expiresAt }}</dd></div><div><dt>更新时间</dt><dd>{{ record.updatedAt }}</dd></div></dl>
<el-button link type="primary" @click="previewQualification(record)">查看文件信息</el-button>
</article>
<div v-if="store.qualifications.length === 0" class="client-empty">暂无企业资质记录。</div>
</div>
</div>
</el-tab-pane>
@@ -250,18 +253,13 @@ function qualificationStatus(status: string) {
<div class="form-grid-two">
<el-form-item label="公司名称" required><el-input v-model="draft.companyName" /></el-form-item>
<el-form-item label="统一社会信用代码"><el-input v-model="draft.creditCode" disabled /></el-form-item>
<el-form-item label="企业类型"><el-input v-model="draft.companyType" /></el-form-item>
<el-form-item label="注册地"><el-input v-model="draft.registeredAddress" /></el-form-item>
<el-form-item label="法人姓名" required><el-input v-model="draft.legalRepresentative" /></el-form-item>
<el-form-item label="法人电话"><el-input v-model="draft.legalRepresentativePhone" /></el-form-item>
</div>
<h3>联系信息</h3>
<div class="form-grid-two">
<el-form-item label="联系人" required><el-input v-model="draft.contactName" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model="draft.contactPhone" /></el-form-item>
<el-form-item label="联系邮箱"><el-input v-model="draft.contactEmail" /></el-form-item>
<el-form-item label="微信"><el-input v-model="draft.contactWechat" /></el-form-item>
<el-form-item label="业务地址" class="full-row"><el-input v-model="draft.businessAddress" /></el-form-item>
</div>
</el-form>
<template #footer><div class="drawer-actions"><el-button :disabled="profileSubmitting" @click="cancelEdit">取消</el-button><el-button type="primary" :loading="profileSubmitting" @click="saveProfile">保存修改</el-button></div></template>
@@ -296,11 +294,15 @@ function qualificationStatus(status: string) {
<span>{{ previewUrl ? '文件通过当前商户会话临时加载,关闭弹窗后立即释放。' : '当前文件暂不可预览。' }}</span>
</div>
</el-dialog>
</template>
</section>
</template>
<style scoped>
.profile-page { display: flex; flex-direction: column; gap: 16px; }
.profile-load-error { display: flex; min-height: 180px; align-items: center; justify-content: center; gap: 22px; text-align: left; }
.profile-load-error > div { display: flex; max-width: 560px; flex-direction: column; gap: 6px; }
.profile-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.company-audit-notice { display: flex; align-items: flex-start; gap: 12px; padding: 15px 17px; border: 1px solid; }
.company-audit-notice svg { flex: 0 0 21px; margin-top: 1px; }
.company-audit-notice div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
+14 -6
View File
@@ -13,9 +13,15 @@ const loading = ref(false)
const loadError = ref('')
const companyApproved = computed(() => tenant.company.auditStatus === 'ACTIVE')
const stats = computed(() => [
{ label: '进行中订单', value: String(orders.activeCount), icon: Clock3 },
{ label: '待平台审核', value: String(orders.pendingCount), icon: ClipboardCheck },
{ label: '企业成员', value: String(tenant.activeMemberCount), icon: Users },
...(auth.hasPermission('order:first-leg:list')
? [
{ label: '进行中订单', value: String(orders.activeCount), icon: Clock3 },
{ label: '待平台审核', value: String(orders.pendingCount), icon: ClipboardCheck },
]
: []),
...(auth.hasPermission('company:member:list')
? [{ label: '企业成员', value: String(tenant.activeMemberCount), icon: Users }]
: []),
])
const recentOrders = computed(() => orders.orders.slice(0, 3))
@@ -45,16 +51,18 @@ onMounted(() => void loadDashboardData())
<div><strong>业务总览加载失败</strong><span>{{ loadError }}</span></div>
<el-button :loading="loading" @click="loadDashboardData">重新加载</el-button>
</div>
<template v-else>
<section class="quick-order-band">
<div><span>头程物流</span><h2>{{ auth.hasPermission('order:first-leg:create') ? '创建新的头程订单' : '跟进企业头程订单' }}</h2><p>{{ companyApproved ? '企业资料已审核,可正常提交并跟进订单。' : '企业资料待平台审核,当前可以维护并保存订单草稿。' }}</p></div>
<el-button v-if="auth.hasPermission('order:first-leg:list')" type="primary" size="large" @click="$router.push('/orders/first-leg')"><FilePlus2 :size="18" /> {{ auth.hasPermission('order:first-leg:create') ? '开始下单' : '查看订单' }}</el-button>
</section>
<section class="client-stats">
<section v-if="stats.length" class="client-stats">
<article v-for="item in stats" :key="item.label"><component :is="item.icon" :size="20" /><div><span>{{ item.label }}</span><strong>{{ item.value }}</strong></div></article>
</section>
<section class="client-content-grid">
<article class="client-card"><header><div><span class="section-label">ORDERS</span><h3>最近订单</h3></div><el-button text @click="$router.push('/orders/first-leg')">查看全部 <ArrowRight :size="16" /></el-button></header><div v-if="recentOrders.length" class="recent-orders"><button v-for="order in recentOrders" :key="order.id" type="button" @click="$router.push('/orders/first-leg')"><span><strong>{{ order.orderNo || '未提交草稿' }}</strong><small>{{ order.origin }} → {{ order.destination }}</small></span><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></button></div><div v-else class="client-empty">暂无订单,提交第一笔头程订单后将在这里展示。</div></article>
<article class="client-card"><header><div><span class="section-label">ACCOUNT</span><h3>企业资料</h3></div></header><dl class="profile-summary"><div><dt>企业名称</dt><dd>{{ tenant.company.companyName }}</dd></div><div><dt>审核状态</dt><dd><el-tag :type="companyApproved ? 'success' : 'warning'" effect="light">{{ companyApproved ? '已通过' : '待审核' }}</el-tag></dd></div><div><dt>当前成员</dt><dd>{{ auth.user?.name }}</dd></div></dl><el-button v-if="auth.hasPermission('company:profile:view')" plain @click="$router.push('/company/profile')">{{ companyApproved ? '查看企业资料' : '完善企业资料' }}</el-button></article>
<article v-if="auth.hasPermission('order:first-leg:list')" class="client-card"><header><div><span class="section-label">ORDERS</span><h3>最近订单</h3></div><el-button text @click="$router.push('/orders/first-leg')">查看全部 <ArrowRight :size="16" /></el-button></header><div v-if="recentOrders.length" class="recent-orders"><button v-for="order in recentOrders" :key="order.id" type="button" @click="$router.push('/orders/first-leg')"><span><strong>{{ order.orderNo || '未提交草稿' }}</strong><small>{{ order.origin }} → {{ order.destination }}</small></span><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></button></div><div v-else class="client-empty">暂无订单,提交第一笔头程订单后将在这里展示。</div></article>
<article v-if="auth.hasPermission('company:profile:view')" class="client-card"><header><div><span class="section-label">ACCOUNT</span><h3>企业资料</h3></div></header><dl class="profile-summary"><div><dt>企业名称</dt><dd>{{ tenant.company.companyName || '-' }}</dd></div><div><dt>审核状态</dt><dd><el-tag :type="companyApproved ? 'success' : 'warning'" effect="light">{{ companyApproved ? '已通过' : '待审核' }}</el-tag></dd></div><div><dt>当前成员</dt><dd>{{ auth.user?.name }}</dd></div></dl><el-button plain @click="$router.push('/company/profile')">{{ companyApproved ? '查看企业资料' : '完善企业资料' }}</el-button></article>
</section>
</template>
</div>
</template>
+5 -1
View File
@@ -14,7 +14,8 @@ test('exposes real list, draft, submit, and detail workflow entries without canc
assert.match(source, /async function saveDraft\(\)/)
assert.match(source, /function submitOrder\(\)/)
assert.match(source, /async function openDetail\(order: FirstLegOrder\)/)
assert.match(source, /\[store\.load\(\), firstLegPartySource\.load\(\)\]/)
assert.match(source, /Promise\.allSettled\(\[/)
assert.match(source, /store\.load\(\),\s+firstLegPartySource\.load\(\)/)
assert.match(source, /tenantStore\.loadCompanyContext\(\)/)
assert.match(source, /await store\.loadDetail\(order\.id\)/)
assert.doesNotMatch(source, /cancelOrder|取消订单/)
@@ -94,6 +95,9 @@ test('protects unsaved drafts and exposes recoverable load failures', () => {
assert.match(source, /store\.loadError/)
assert.match(source, /订单数据加载失败/)
assert.match(source, /订单详情加载失败/)
assert.match(source, /部分订单配套数据加载失败/)
assert.match(source, /往来单位加载失败/)
assert.match(source, /企业资料加载失败/)
assert.match(source, /@click="loadPageData"/)
assert.match(source, /@click="retryDetail"/)
})
+48 -13
View File
@@ -40,6 +40,8 @@ const detailId = ref<string>()
const saving = ref(false)
const detailLoading = ref(false)
const detailError = ref('')
const supportingDataLoading = ref(false)
const supportingDataErrors = ref<string[]>([])
const editorBaseline = ref('')
let cargoSequence = 0
@@ -110,15 +112,37 @@ const hasActiveFilters = computed(() => Boolean(filters.keyword.trim() || filter
const emptyDescription = computed(() => hasActiveFilters.value ? '没有符合筛选条件的头程订单' : '还没有头程订单')
async function loadPageData() {
try {
const requests: Promise<unknown>[] = [store.load(), firstLegPartySource.load()]
if (auth.hasPermission('company:profile:view')) requests.push(tenantStore.loadCompanyContext())
await Promise.all(requests)
supportingDataLoading.value = true
supportingDataErrors.value = []
availableShippers.value = []
availableConsignees.value = []
const companyRequest = auth.hasPermission('company:profile:view')
? tenantStore.loadCompanyContext()
: Promise.resolve()
const [ordersResult, partiesResult, companyResult] = await Promise.allSettled([
store.load(),
firstLegPartySource.load(),
companyRequest,
])
if (partiesResult.status === 'fulfilled') {
availableShippers.value = firstLegPartySource.listAvailable('SHIPPER')
availableConsignees.value = firstLegPartySource.listAvailable('CONSIGNEE')
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '订单与往来单位数据加载失败')
} else {
supportingDataErrors.value.push(`往来单位加载失败:${errorMessage(partiesResult.reason)}`)
}
if (companyResult.status === 'rejected') {
supportingDataErrors.value.push(`企业资料加载失败:${errorMessage(companyResult.reason)}`)
}
if (ordersResult.status === 'rejected' && !store.loadError) {
supportingDataErrors.value.push(`订单与仓库加载失败:${errorMessage(ordersResult.reason)}`)
}
supportingDataLoading.value = false
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : '服务暂时不可用'
}
function handleBeforeUnload(event: BeforeUnloadEvent) {
@@ -401,11 +425,16 @@ function resetFilters() {
<template>
<section class="first-leg-page">
<div class="order-eligibility" :class="companyApproved ? 'is-approved' : 'is-pending'" role="status">
<div v-if="!supportingDataErrors.some((message) => message.startsWith('企业资料'))" class="order-eligibility" :class="companyApproved ? 'is-approved' : 'is-pending'" role="status">
<component :is="companyApproved ? CheckCircle2 : AlertTriangle" :size="20" />
<div><strong>{{ companyApproved ? '企业资料已审核,可正常提交订单' : canCreateOrder ? '企业资料待审核,当前仅可保存订单草稿' : '企业资料待审核,当前订单为只读' }}</strong><span>{{ companyApproved ? '提交后订单将进入平台审核。' : canCreateOrder ? '平台审核通过后即可提交;您仍可创建、编辑和保存草稿。' : '您当前拥有只读权限,可以继续查看订单进展。' }}</span></div>
<el-button v-if="!companyApproved && auth.hasPermission('company:profile:view')" link type="primary" @click="router.push('/company/profile')">查看企业资料</el-button>
</div>
<div v-if="supportingDataErrors.length" class="supporting-data-error" role="alert">
<AlertTriangle :size="20" />
<div><strong>部分订单配套数据加载失败</strong><span v-for="message in supportingDataErrors" :key="message">{{ message }}</span></div>
<el-button :loading="supportingDataLoading || store.loading" @click="loadPageData">重新加载</el-button>
</div>
<div class="order-metrics" aria-label="订单概览">
<article><span>全部订单</span><strong>{{ store.orders.length }}</strong></article>
<article><span>进行中</span><strong>{{ store.activeCount }}</strong></article>
@@ -463,14 +492,14 @@ function resetFilters() {
<el-table-column label="状态" width="120">
<template #default="{ row }"><el-tag :type="getFirstLegStatusTone((row as FirstLegOrder).status)" effect="light">{{ getFirstLegStatusLabel((row as FirstLegOrder).status) }}</el-tag></template>
</el-table-column>
<el-table-column label="负责人" prop="operatorName" width="110" />
<el-table-column label="负责人" width="110"><template #default="{ row }">{{ (row as FirstLegOrder).operatorName || '-' }}</template></el-table-column>
<el-table-column label="更新时间" width="155"><template #default="{ row }">{{ formatDateTime((row as FirstLegOrder).updatedAt) }}</template></el-table-column>
</el-table>
<div v-if="filteredOrders.length" class="mobile-order-list">
<article v-for="order in filteredOrders" :key="order.id" class="mobile-order-card">
<header><button type="button" @click="openDetail(order)">{{ order.orderNo || '未提交草稿' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header>
<span class="order-reference">{{ order.referenceNo || '无客户参考号' }}</span>
<dl><div><dt>运输路线</dt><dd>{{ order.origin }} → {{ order.destination }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName }}</dd></div><div><dt>货物</dt><dd>{{ order.items[0]?.cargoNameCn || '-' }}<span v-if="order.items.length > 1">等 {{ order.items.length }} 项</span></dd></div><div><dt>负责人</dt><dd>{{ order.operatorName }}</dd></div></dl>
<dl><div><dt>运输路线</dt><dd>{{ order.origin }} → {{ order.destination }}</dd></div><div><dt>收货仓</dt><dd>{{ order.receivingWarehouseName }}</dd></div><div><dt>货物</dt><dd>{{ order.items[0]?.cargoNameCn || '-' }}<span v-if="order.items.length > 1">等 {{ order.items.length }} 项</span></dd></div><div><dt>负责人</dt><dd>{{ order.operatorName || '-' }}</dd></div></dl>
<footer><span>更新于 {{ formatDateTime(order.updatedAt) }}</span><div><el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button></div></footer>
</article>
</div>
@@ -489,7 +518,7 @@ function resetFilters() {
<el-form-item label="业务类型" required><el-radio-group v-model="form.businessType"><el-radio-button value="EXPORT">出口</el-radio-button><el-radio-button value="IMPORT">进口</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="起运地" required><el-input v-model="form.origin" placeholder="例如:宁波" /></el-form-item>
<el-form-item label="目的地" required><el-input v-model="form.destination" placeholder="例如:洛杉矶" /></el-form-item>
<el-form-item label="平台收货仓" required><el-select v-model="form.receivingWarehouseId" filterable placeholder="请选择送货仓库" @change="loadSelectedWarehouse"><el-option v-for="warehouse in availableWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.city}`" :value="warehouse.id" /></el-select></el-form-item>
<el-form-item label="平台收货仓" required><el-select v-model="form.receivingWarehouseId" filterable placeholder="请选择送货仓库" @change="loadSelectedWarehouse"><el-option v-for="warehouse in availableWarehouses" :key="warehouse.id" :label="warehouse.city ? `${warehouse.name} · ${warehouse.city}` : warehouse.name" :value="warehouse.id" /></el-select></el-form-item>
<el-form-item label="期望出发日期"><el-date-picker v-model="form.expectedDepartureDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item>
<el-form-item label="客户参考号"><el-input v-model="form.referenceNo" maxlength="100" placeholder="贵司内部单号(选填)" /></el-form-item>
<el-form-item label="优先级"><el-select v-model="form.priority"><el-option label="普通" value="NORMAL" /><el-option label="紧急" value="URGENT" /><el-option label="加急" value="EXPEDITED" /></el-select></el-form-item>
@@ -504,7 +533,7 @@ function resetFilters() {
<el-form-item v-if="form.destinationType === 'FBA'" label="Amazon 仓库编码"><el-input v-model="form.amazonWarehouseCode" maxlength="32" placeholder="例如:ONT8" /></el-form-item>
<el-form-item v-if="form.destinationType === 'FBA'" label="Shipment ID"><el-input v-model="form.shipmentId" maxlength="64" /></el-form-item>
</div>
<div v-if="selectedWarehouse" class="selected-warehouse"><MapPin :size="17" /><div><strong>{{ selectedWarehouse.name }}</strong><span>{{ selectedWarehouse.province }}{{ selectedWarehouse.city }} · {{ selectedWarehouse.address }}</span><small>收货时间:{{ selectedWarehouse.businessHours }}</small></div></div>
<div v-if="selectedWarehouse" class="selected-warehouse"><MapPin :size="17" /><div><strong>{{ selectedWarehouse.name }}</strong><span>{{ selectedWarehouse.address ? `${selectedWarehouse.province}${selectedWarehouse.city} · ${selectedWarehouse.address}` : '仓库地址详情暂不可用' }}</span><small>{{ selectedWarehouse.businessHours ? `收货时间:${selectedWarehouse.businessHours}` : '收货时间暂不可用' }}</small></div></div>
</section>
<section class="order-form-section">
@@ -598,7 +627,7 @@ function resetFilters() {
<div><dt>客户参考号</dt><dd>{{ detailOrder.referenceNo || '-' }}</dd></div><div><dt>运输方式</dt><dd>{{ getFirstLegTransportModeLabel(detailOrder.transportMode) }}</dd></div>
<div><dt>发货方</dt><dd>{{ detailOrder.shipperPartySnapshot?.name || detailOrder.shipper }}<span v-if="detailOrder.shipperPartySnapshot" class="detail-subline">{{ detailOrder.shipperPartySnapshot.code }} · {{ detailOrder.shipperPartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.shipperPartySnapshot.phone || '未填写电话' }}<br />{{ detailOrder.shipperPartySnapshot.countryRegion }} {{ detailOrder.shipperPartySnapshot.province }} {{ detailOrder.shipperPartySnapshot.city }} {{ detailOrder.shipperPartySnapshot.address }}</span></dd></div>
<div><dt>收货方</dt><dd>{{ detailOrder.consigneePartySnapshot?.name || detailOrder.consignee }}<span v-if="detailOrder.consigneePartySnapshot" class="detail-subline">{{ detailOrder.consigneePartySnapshot.code }} · {{ detailOrder.consigneePartySnapshot.contactName || '未填写联系人' }} · {{ detailOrder.consigneePartySnapshot.phone || '未填写电话' }}<br />{{ detailOrder.consigneePartySnapshot.countryRegion }} {{ detailOrder.consigneePartySnapshot.province }} {{ detailOrder.consigneePartySnapshot.city }} {{ detailOrder.consigneePartySnapshot.address }}</span></dd></div>
<div><dt>平台收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div><div><dt>负责人</dt><dd>{{ detailOrder.operatorName }}</dd></div>
<div><dt>平台收货仓</dt><dd>{{ detailOrder.receivingWarehouseSnapshot?.name || detailOrder.receivingWarehouseName }}<span v-if="detailOrder.receivingWarehouseSnapshot" class="detail-subline">{{ detailOrder.receivingWarehouseSnapshot.code }} · {{ detailOrder.receivingWarehouseSnapshot.province }}{{ detailOrder.receivingWarehouseSnapshot.city }}{{ detailOrder.receivingWarehouseSnapshot.address }}</span></dd></div><div><dt>负责人</dt><dd>{{ detailOrder.operatorName || '未提供' }}</dd></div>
<div><dt>期望出发</dt><dd>{{ detailOrder.expectedDepartureDate || '-' }}</dd></div><div><dt>ETD / ETA</dt><dd>{{ detailOrder.etd || '-' }} / {{ detailOrder.eta || '-' }}</dd></div>
</dl>
<section class="detail-section">
@@ -607,7 +636,7 @@ function resetFilters() {
<div v-else class="express-empty"><PackageCheck :size="20" /><div><strong>平台尚未维护快递信息</strong><span>快递公司和快递单号由平台后台维护,客户端仅只读展示。</span></div></div>
</section>
<section class="detail-section"><h3>货物汇总</h3><div v-if="detailSummary" class="cargo-summary"><div><Box :size="18" /><span>明细</span><strong>{{ detailOrder.items.length }} 项</strong></div><div><span>件数</span><strong>{{ detailSummary.pieces }}</strong></div><div><span>毛重</span><strong>{{ detailSummary.grossWeightKg.toLocaleString() }} kg</strong></div><div><span>体积</span><strong>{{ detailSummary.volumeCbm }} m³</strong></div></div><ul class="cargo-detail-list"><li v-for="item in detailOrder.items" :key="item.id"><strong>{{ item.cargoNameCn }}</strong><span>{{ item.sku || '无 SKU' }} · {{ item.pieces }} 件 · {{ item.grossWeightKg }} kg</span></li></ul></section>
<section class="detail-section"><h3>处理时间线</h3><el-timeline><el-timeline-item v-for="item in [...detailOrder.timeline].reverse()" :key="item.id" :timestamp="item.occurredAt" placement="top"><strong>{{ item.title }}</strong><p>{{ item.description }}</p><span>{{ item.operatorName }}</span></el-timeline-item></el-timeline></section>
<section class="detail-section"><h3>处理时间线</h3><el-timeline><el-timeline-item v-for="item in [...detailOrder.timeline].reverse()" :key="item.id" :timestamp="item.occurredAt" placement="top"><strong>{{ item.title }}</strong><p>{{ item.description || '-' }}</p><span>{{ item.operatorName || '未提供' }}</span></el-timeline-item></el-timeline></section>
</template>
</div>
<template #footer><div v-if="detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div></template>
@@ -624,6 +653,10 @@ function resetFilters() {
.order-eligibility span { color: var(--muted); font-size: 11px; line-height: 1.55; }
.order-eligibility.is-approved { border-color: #bcd9d2; background: #eff8f5; color: #20745f; }
.order-eligibility.is-pending { border-color: #e5d2a5; background: #fff9eb; color: #9a6a12; }
.supporting-data-error { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px 16px; border: 1px solid #e5d2a5; background: #fff9eb; color: #9a6a12; }
.supporting-data-error > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.supporting-data-error strong { color: var(--navy); font-size: 13px; }
.supporting-data-error span { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; }
.order-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.order-metrics article { display: flex; align-items: center; justify-content: space-between; min-height: 76px; padding: 16px 20px; border: 1px solid var(--border); background: #fff; }
.order-metrics span { color: var(--muted); font-size: 12px; }
@@ -729,6 +762,8 @@ function resetFilters() {
.editor-actions :deep(.el-button) { min-height: 44px; }
.desktop-order-table { display: none; }
.order-load-state { min-height: 240px; grid-template-columns: 1fr; justify-items: start; }
.supporting-data-error { grid-template-columns: auto minmax(0, 1fr); }
.supporting-data-error .el-button { grid-column: 1 / -1; width: 100%; min-height: 44px; }
.order-load-state .el-button { width: 100%; min-height: 44px; }
.mobile-order-list { grid-template-columns: 1fr; }
.mobile-order-card { padding: 14px; border: 1px solid var(--border); background: #fff; }
+3 -10
View File
@@ -3,6 +3,7 @@ import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowRight, LockKeyhole, UserRound } from '@lucide/vue'
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
import { useAuthStore } from '@/stores/auth'
interface LoginForm {
@@ -16,17 +17,10 @@ const router = useRouter()
const auth = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const isDevelopment = import.meta.env.DEV
const developmentHint = isDevelopment
? {
title: '一期联调账号',
message: '本地后端启动后可使用商户账号 merchant_admin 登录;密码请使用后端联调环境提供的初始密码。',
}
: null
const form = reactive<LoginForm>({
account: typeof route.query.account === 'string'
? route.query.account
: isDevelopment ? 'merchant_admin' : '',
: '',
password: '',
remember: false,
})
@@ -68,7 +62,7 @@ function showResetHint() {
<template>
<div class="auth-page">
<header class="auth-header">
<RouterLink to="/login" class="auth-brand"><span>CC</span><strong>虫虫出海</strong></RouterLink>
<RouterLink to="/login" class="auth-brand"><img class="auth-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" /></RouterLink>
<div>还没有企业账号? <RouterLink to="/register">申请入驻</RouterLink></div>
</header>
<main class="auth-main">
@@ -93,7 +87,6 @@ function showResetHint() {
</div>
<el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button>
</el-form>
<div v-if="developmentHint" class="demo-tip" role="note"><strong>{{ developmentHint.title }}</strong><span>{{ developmentHint.message }}</span></div>
</section>
</main>
</div>
+16 -5
View File
@@ -12,6 +12,7 @@ const statusFilter = ref<TenantRecordStatus | ''>('')
const editorOpen = ref(false)
const editingId = ref('')
const loading = ref(false)
const loadError = ref('')
const saving = ref(false)
const form = reactive({ name: '', account: '', password: '', phone: '', email: '', roleIds: [] as string[] })
@@ -21,16 +22,19 @@ const filteredMembers = computed(() => store.members.filter((member) => {
return matchesKeyword && (!statusFilter.value || member.status === statusFilter.value)
}))
onMounted(async () => {
async function loadMembers() {
loading.value = true
loadError.value = ''
try {
await Promise.all([store.loadRoles(), store.loadMembers()])
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '企业成员加载失败')
loadError.value = error instanceof Error ? error.message : '企业成员加载失败'
} finally {
loading.value = false
}
})
}
onMounted(() => void loadMembers())
function resetForm() {
editingId.value = ''
@@ -116,7 +120,11 @@ function clearFilters() {
<template>
<section v-loading="loading" class="members-page">
<div v-if="store.membersLoadedFromApi" class="member-contract-notice client-card">成员资料、角色回显、新增、编辑、启停和密码重置均使用当前企业的真实接口;企业所有者保护和租户隔离由服务端最终校验。</div>
<div v-if="loadError" class="client-card collection-load-error" role="alert">
<div><strong>企业成员加载失败</strong><span>{{ loadError }}</span></div>
<el-button :loading="loading" @click="loadMembers">重新加载</el-button>
</div>
<template v-else>
<div class="member-metrics">
<article><UserCheck :size="20" /><div><span>成员总数</span><strong>{{ store.members.length }}</strong></div></article>
<article><ShieldCheck :size="20" /><div><span>正常成员</span><strong>{{ store.activeMemberCount }}</strong></div></article>
@@ -164,12 +172,15 @@ function clearFilters() {
</el-form>
<template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMember">保存</el-button></div></template>
</el-drawer>
</template>
</section>
</template>
<style scoped>
.members-page { display: flex; flex-direction: column; gap: 14px; }
.member-contract-notice { padding: 13px 16px; border-left: 3px solid #d97706; background: #fff8eb; color: #8a5b10; font-size: 11px; line-height: 1.6; }
.collection-load-error { display: flex; min-height: 150px; align-items: center; justify-content: center; gap: 20px; }
.collection-load-error > div { display: flex; max-width: 520px; flex-direction: column; gap: 6px; }
.collection-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.member-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.member-metrics article { display: flex; align-items: center; gap: 14px; padding: 18px 20px; border: 1px solid var(--border); background: #fff; color: var(--primary); }
.member-metrics article div { display: flex; flex-direction: column; gap: 2px; }
-16
View File
@@ -1,16 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Construction, Plus, Search } from '@lucide/vue'
const route = useRoute()
const title = computed(() => route.meta.title || '功能模块')
const description = computed(() => route.meta.description || '')
</script>
<template>
<section class="module-stack">
<div class="module-actions"><el-input placeholder="输入关键词搜索" clearable><template #prefix><Search :size="17" /></template></el-input><el-button type="primary"><Plus :size="17" /> 新增</el-button></div>
<div class="client-card module-empty"><div class="module-empty-icon"><Construction :size="28" /></div><h2>{{ title }}</h2><p>{{ description }}</p><el-tag effect="plain">页面骨架已完成,业务接口待接入</el-tag></div>
</section>
</template>
+24 -4
View File
@@ -13,6 +13,8 @@ const initialRoleId = typeof route.query.role === 'string' && store.roles.some((
const selectedRoleId = ref(initialRoleId)
const selectedCodes = ref<string[]>([])
const savedCodes = ref<string[]>([])
const loading = ref(false)
const loadError = ref('')
const selectedRole = computed(() => store.roles.find((role) => role.id === selectedRoleId.value))
const editable = computed(() => Boolean(auth.hasPermission('company:permission:assign') && selectedRole.value && !selectedRole.value.builtin && selectedRole.value.status === 'ACTIVE'))
@@ -26,15 +28,21 @@ function loadRolePermissions() {
watch(selectedRoleId, loadRolePermissions, { immediate: true })
onMounted(async () => {
async function loadRoles() {
loading.value = true
loadError.value = ''
try {
await store.loadRoles()
if (!store.roles.some((role) => role.id === selectedRoleId.value)) selectedRoleId.value = store.roles[0]?.id ?? ''
loadRolePermissions()
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '角色列表加载失败')
loadError.value = error instanceof Error ? error.message : '角色列表加载失败'
} finally {
loading.value = false
}
})
}
onMounted(() => void loadRoles())
function groupCodes(groupKey: string) {
return TENANT_PERMISSION_GROUPS.find((group) => group.key === groupKey)?.items.map((item) => item.code) ?? []
@@ -76,7 +84,12 @@ async function savePermissions() {
</script>
<template>
<section class="permissions-page">
<section v-loading="loading" class="permissions-page">
<div v-if="loadError" class="client-card permission-load-error" role="alert">
<div><strong>角色权限加载失败</strong><span>{{ loadError }}</span></div>
<el-button :loading="loading" @click="loadRoles">重新加载</el-button>
</div>
<template v-else-if="store.roles.length">
<aside class="client-card role-selector">
<header><div><span class="section-label">ROLES</span><h3>选择角色</h3></div></header>
<button v-for="role in store.roles" :key="role.id" type="button" :class="{ active: role.id === selectedRoleId }" @click="selectedRoleId = role.id">
@@ -111,11 +124,18 @@ async function savePermissions() {
</article>
</div>
</div>
</template>
<div v-else class="client-card permission-empty">当前企业暂无可用角色,创建角色后才能查看和分配权限。</div>
</section>
</template>
<style scoped>
.permissions-page { display: grid; grid-template-columns: 250px minmax(0, 1fr); align-items: start; gap: 14px; }
.permission-load-error, .permission-empty { grid-column: 1 / -1; min-height: 170px; }
.permission-load-error { display: flex; align-items: center; justify-content: center; gap: 20px; }
.permission-load-error > div { display: flex; max-width: 520px; flex-direction: column; gap: 6px; }
.permission-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.permission-empty { display: grid; place-items: center; color: var(--muted); font-size: 12px; text-align: center; }
.role-selector { position: sticky; top: 84px; padding: 12px; }
.role-selector header { padding: 8px 8px 12px; }
.role-selector h3 { margin-bottom: 0; }
+2 -1
View File
@@ -3,6 +3,7 @@ import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { ArrowLeft, Building2, CheckCircle2, Mail, Phone } from '@lucide/vue'
import baiyueLogoDark from '@/assets/brand/baiyue-logo-dark.png'
import { registerMerchant } from '@/api/auth'
interface RegisterForm {
@@ -95,7 +96,7 @@ function showDocument(name: string) {
<div class="register-page">
<header class="register-header">
<RouterLink to="/login"><ArrowLeft :size="18" /> 返回登录</RouterLink>
<div class="auth-brand"><span>CC</span><strong>虫虫出海</strong></div>
<div class="auth-brand"><img class="auth-brand-logo" :src="baiyueLogoDark" alt="柏悦物流" /></div>
</header>
<main class="register-main">
<template v-if="!submitted">
+16 -5
View File
@@ -13,6 +13,7 @@ const keyword = ref('')
const editorOpen = ref(false)
const editingId = ref('')
const loading = ref(false)
const loadError = ref('')
const saving = ref(false)
const actionRoleId = ref('')
const form = reactive({ name: '', code: '', description: '' })
@@ -38,18 +39,21 @@ function openEdit(role: TenantRole) {
editorOpen.value = true
}
onMounted(async () => {
async function loadRoles() {
loading.value = true
loadError.value = ''
try {
const requests = [store.loadRoles()]
if (auth.hasPermission('company:member:list')) requests.push(store.loadMembers())
await Promise.all(requests)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '角色与成员统计加载失败')
loadError.value = error instanceof Error ? error.message : '角色与成员统计加载失败'
} finally {
loading.value = false
}
})
}
onMounted(() => void loadRoles())
async function saveRole() {
if (!form.name || !form.code || !form.description) {
@@ -105,8 +109,12 @@ function assignPermissions(role: TenantRole) {
<template>
<section v-loading="loading" class="roles-page">
<div v-if="loadError" class="client-card collection-load-error" role="alert">
<div><strong>企业角色加载失败</strong><span>{{ loadError }}</span></div>
<el-button :loading="loading" @click="loadRoles">重新加载</el-button>
</div>
<template v-else>
<div class="role-guidance client-card"><ShieldCheck :size="22" /><div><strong>角色用于约束企业内部成员可见菜单和可执行操作</strong><span>企业管理员为系统内置角色,不能停用或删除;平台后台权限不会出现在客户工作台。</span></div></div>
<div v-if="store.rolesLoadedFromApi" class="role-contract-notice client-card">角色列表、新增、编辑、启停、删除及权限回显均使用真实 IAM 接口;系统内置角色和仍有关联成员的角色继续受服务端保护。</div>
<div class="role-toolbar client-card">
<el-input v-model="keyword" clearable placeholder="搜索角色名称、编码或说明"><template #prefix><Search :size="16" /></template></el-input>
@@ -132,6 +140,7 @@ function assignPermissions(role: TenantRole) {
</el-form>
<template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveRole">保存</el-button></div></template>
</el-drawer>
</template>
</section>
</template>
@@ -141,7 +150,9 @@ function assignPermissions(role: TenantRole) {
.role-guidance > div { display: flex; flex-direction: column; gap: 4px; }
.role-guidance strong { color: var(--navy); font-size: 13px; }
.role-guidance span { color: var(--muted); font-size: 11px; }
.role-contract-notice { padding: 13px 16px; border-left: 3px solid #d97706; background: #fff8eb; color: #8a5b10; font-size: 11px; line-height: 1.6; }
.collection-load-error { display: flex; min-height: 150px; align-items: center; justify-content: center; gap: 20px; }
.collection-load-error > div { display: flex; max-width: 520px; flex-direction: column; gap: 6px; }
.collection-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.role-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px; }
.role-toolbar .el-input { max-width: 360px; }
.role-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
@@ -30,6 +30,9 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /downloadQualificationImage/)
assert.match(profileSource, /URL\.createObjectURL/)
assert.match(profileSource, /URL\.revokeObjectURL/)
assert.match(profileSource, /企业资料加载失败/)
assert.match(profileSource, /@click="loadCompanyContext"/)
assert.doesNotMatch(profileSource, /企业类型|注册资本|contactWechat|businessAddress/)
})
test('implements member list, role assignment, password reset and status control', () => {
@@ -42,6 +45,8 @@ test('implements member list, role assignment, password reset and status control
assert.match(membersSource, /await store\.resetMemberPassword/)
assert.match(membersSource, /await store\.toggleMemberStatus/)
assert.match(membersSource, /v-model="form\.password"/)
assert.match(membersSource, /企业成员加载失败/)
assert.match(membersSource, /@click="loadMembers"/)
})
test('protects built-in roles and exposes role permission assignment', () => {
@@ -56,6 +61,8 @@ test('protects built-in roles and exposes role permission assignment', () => {
assert.match(rolesSource, /await store\.updateRole/)
assert.match(rolesSource, /await store\.toggleRoleStatus/)
assert.match(rolesSource, /await store\.deleteRole/)
assert.match(rolesSource, /企业角色加载失败/)
assert.match(rolesSource, /@click="loadRoles"/)
})
test('shows company audit status and applies button permissions', () => {
@@ -75,6 +82,8 @@ test('implements grouped menu and action permission editing', () => {
assert.match(permissionsSource, /系统内置角色,权限由平台统一维护,不允许修改/)
assert.match(permissionsSource, /全量替换/)
assert.match(permissionsSource, /await store\.setRolePermissions/)
assert.match(permissionsSource, /角色权限加载失败/)
assert.match(permissionsSource, /当前企业暂无可用角色/)
assert.doesNotMatch(permissionsSource, /当前只读展示权限资源/)
})