chore: initial commit

This commit is contained in:
2026-07-30 17:59:42 +08:00
commit 155f06846d
62 changed files with 7852 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getPasswordStrength,
validateEditableAccountProfile,
validatePasswordChange,
} from './accountProfile.ts'
test('accepts a complete editable account profile', () => {
assert.deepEqual(validateEditableAccountProfile({
realName: '企业管理员',
phone: '13800006601',
email: 'admin@example.com',
}), [])
})
test('reports invalid profile fields together', () => {
const errors = validateEditableAccountProfile({ realName: '', phone: '1'.repeat(33), email: 'bad' })
assert.equal(errors.length, 3)
})
test('validates password length, equality and confirmation', () => {
assert.deepEqual(validatePasswordChange({
currentPassword: 'OldPass123',
newPassword: 'NewPass456',
confirmPassword: 'NewPass456',
}), [])
assert.equal(validatePasswordChange({ currentPassword: '', newPassword: '123', confirmPassword: '456' }).length, 3)
assert.match(validatePasswordChange({ currentPassword: 'SamePass1', newPassword: 'SamePass1', confirmPassword: 'SamePass1' }).join('|'), /不能与当前密码相同/)
assert.match(validatePasswordChange({ currentPassword: 'OldPass1', newPassword: 'abcdefgh', confirmPassword: 'abcdefgh' }).join('|'), /同时包含字母和数字/)
})
test('grades password strength without making extra complexity mandatory', () => {
assert.equal(getPasswordStrength('short'), 'weak')
assert.equal(getPasswordStrength('Password1'), 'medium')
assert.equal(getPasswordStrength('Stronger@2026'), 'strong')
})
+57
View File
@@ -0,0 +1,57 @@
export interface EditableAccountProfile {
realName: string
phone: string
email: string
}
export interface PasswordChangePayload {
currentPassword: string
newPassword: string
confirmPassword: string
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export function validateEditableAccountProfile(profile: EditableAccountProfile): string[] {
const errors: string[] = []
const realName = profile.realName.trim()
const phone = profile.phone.trim()
const email = profile.email.trim()
if (realName.length < 1 || realName.length > 64) errors.push('姓名需为 1–64 个字符')
if (phone.length > 32) errors.push('联系电话不能超过 32 个字符')
if (email && !EMAIL_PATTERN.test(email)) errors.push('请输入有效的邮箱地址')
return errors
}
export function validatePasswordChange(payload: PasswordChangePayload): string[] {
const errors: string[] = []
if (!payload.currentPassword) errors.push('请输入当前密码')
if (payload.newPassword.length < 8 || payload.newPassword.length > 64) {
errors.push('新密码需为 8–64 个字符')
}
if (payload.newPassword.length >= 8 && (!/[a-zA-Z]/.test(payload.newPassword) || !/\d/.test(payload.newPassword))) {
errors.push('新密码必须同时包含字母和数字')
}
if (payload.newPassword && payload.newPassword === payload.currentPassword) {
errors.push('新密码不能与当前密码相同')
}
if (payload.confirmPassword !== payload.newPassword) errors.push('两次输入的新密码不一致')
return errors
}
export type PasswordStrength = 'weak' | 'medium' | 'strong'
export function getPasswordStrength(password: string): PasswordStrength {
let categoryCount = 0
if (/[a-zA-Z]/.test(password)) categoryCount += 1
if (/\d/.test(password)) categoryCount += 1
if (/[^a-zA-Z\d]/.test(password)) categoryCount += 1
if (password.length >= 12 && categoryCount >= 3) return 'strong'
if (password.length >= 8 && categoryCount >= 2) return 'medium'
return 'weak'
}
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createFirstLegPartySnapshot, DEMO_FIRST_LEG_PARTIES } from '../data/firstLegParties.ts'
import {
canClientCancelFirstLegOrder,
canClientEditFirstLegOrder,
createEmptyCargoItem,
getFirstLegStatusLabel,
getFirstLegTransportModeLabel,
summarizeFirstLegCargo,
validateFirstLegOrderDraft,
} from './firstLegOrder.ts'
function validDraft() {
return {
referenceNo: 'REF-001',
transportMode: 'FCL',
businessType: 'EXPORT',
origin: '宁波',
destination: '洛杉矶',
receivingWarehouseId: 'WH005',
expectedDepartureDate: '2026-08-08',
shipperPartyId: 'CP001',
shipperPartySnapshot: createFirstLegPartySnapshot(DEMO_FIRST_LEG_PARTIES[0], 'SHIPPER'),
shipper: '示例贸易有限公司',
consigneePartyId: 'CP003',
consigneePartySnapshot: createFirstLegPartySnapshot(DEMO_FIRST_LEG_PARTIES[2], 'CONSIGNEE'),
consignee: 'DEMO GLOBAL LLC',
priority: 'NORMAL',
customerRemark: '',
tradeTerm: 'FOB',
destinationCountryCode: 'US',
destinationWarehouseName: 'ONT8',
pickupMethod: 'SELF_EXPRESS',
channelCode: 'SEA-US-FBA',
channelName: '美国海运渠道',
taxMode: 'BUY_DECLARATION',
customsInspection: false,
destinationType: 'FBA',
amazonWarehouseCode: 'ONT8',
shipmentId: 'FBA-001',
items: [{ ...createEmptyCargoItem('cargo-1'), cargoNameCn: '家居用品', pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, containerType: '40HQ', containerCount: 1 }],
}
}
test('uses the confirmed status and transport labels', () => {
assert.equal(getFirstLegStatusLabel('BOOKED'), '已订舱')
assert.equal(getFirstLegStatusLabel('IN_TRANSIT'), '运输中')
assert.equal(getFirstLegTransportModeLabel('FCL'), '海运整箱 FCL')
})
test('only allows draft orders to be edited in the confirmed phase-one flow', () => {
assert.equal(canClientEditFirstLegOrder('DRAFT'), true)
assert.equal(canClientEditFirstLegOrder('PENDING_REVIEW'), false)
assert.equal(canClientCancelFirstLegOrder('DRAFT'), true)
assert.equal(canClientCancelFirstLegOrder('PENDING_REVIEW'), true)
assert.equal(canClientCancelFirstLegOrder('ACCEPTED'), false)
assert.equal(canClientCancelFirstLegOrder('BOOKED'), false)
})
test('validates the confirmed minimum submission fields', () => {
assert.deepEqual(validateFirstLegOrderDraft(validDraft()), [])
const invalid = validDraft()
invalid.origin = ''
invalid.receivingWarehouseId = ''
invalid.items[0].grossWeightKg = 0
invalid.items[0].containerType = ''
invalid.items[0].containerCount = 0
const errors = validateFirstLegOrderDraft(invalid)
assert.ok(errors.includes('请填写起运地'))
assert.ok(errors.includes('请选择平台收货仓'))
assert.ok(errors.includes('第 1 条货物毛重必须大于 0'))
assert.ok(errors.includes('第 1 条货物需要填写箱型和柜量'))
})
test('requires enabled-party snapshots and order addresses before submission', () => {
const draft = validDraft()
draft.shipperPartyId = ''
draft.shipperPartySnapshot = null
draft.consigneePartySnapshot.address = ''
const errors = validateFirstLegOrderDraft(draft)
assert.ok(errors.includes('请选择发货单位'))
assert.ok(errors.includes('请填写本单收货地址'))
})
test('does not require container details for non-FCL cargo', () => {
const draft = validDraft()
draft.transportMode = 'AIR'
draft.items[0].containerType = ''
draft.items[0].containerCount = 0
assert.deepEqual(validateFirstLegOrderDraft(draft), [])
})
test('summarizes cargo values from line items', () => {
const first = validDraft().items[0]
const second = { ...first, id: 'cargo-2', pieces: 40, packageCount: 4, grossWeightKg: 300, volumeCbm: 1.25 }
assert.deepEqual(summarizeFirstLegCargo([first, second]), {
pieces: 140,
packageCount: 5,
grossWeightKg: 1500,
volumeCbm: 4.75,
})
})
+101
View File
@@ -0,0 +1,101 @@
import {
FIRST_LEG_ORDER_STATUSES,
FIRST_LEG_TRANSPORT_MODES,
type FirstLegOrderStatus,
type FirstLegTransportMode,
} from '@chongchong/shared'
import type { FirstLegCargoItem, FirstLegOrderDraft } from '../types/firstLegOrder.ts'
const statusLabels = new Map<FirstLegOrderStatus, string>(
FIRST_LEG_ORDER_STATUSES.map((item) => [item.code, item.label]),
)
const transportModeLabels = new Map<FirstLegTransportMode, string>(
FIRST_LEG_TRANSPORT_MODES.map((item) => [item.code, item.label]),
)
export function getFirstLegStatusLabel(status: FirstLegOrderStatus) {
return statusLabels.get(status) ?? status
}
export function getFirstLegTransportModeLabel(mode: FirstLegTransportMode) {
return transportModeLabels.get(mode) ?? mode
}
export function getFirstLegStatusTone(status: FirstLegOrderStatus): 'success' | 'warning' | 'info' | 'danger' {
if (status === 'COMPLETED') return 'success'
if (status === 'DRAFT' || status === 'PENDING_REVIEW') return 'warning'
if (status === 'CANCELLED') return 'danger'
return 'info'
}
export function canClientEditFirstLegOrder(status: FirstLegOrderStatus) {
return status === 'DRAFT'
}
export function canClientCancelFirstLegOrder(status: FirstLegOrderStatus) {
return status === 'DRAFT' || status === 'PENDING_REVIEW'
}
export function createEmptyCargoItem(id = `cargo-${Date.now()}`): FirstLegCargoItem {
return {
id,
sku: '',
cargoNameCn: '',
cargoNameEn: '',
pieces: 1,
packageCount: 1,
grossWeightKg: 0,
volumeCbm: 0,
containerType: '',
containerCount: 0,
remark: '',
packageType: 'CARTON',
hsCode: '',
declaredValue: 0,
currency: 'USD',
dangerousGoods: false,
}
}
export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
const errors: string[] = []
if (!draft.origin.trim()) errors.push('请填写起运地')
if (!draft.destination.trim()) errors.push('请填写目的地')
if (!draft.destinationCountryCode.trim()) errors.push('请填写目的国两位代码')
if (!draft.destinationWarehouseName.trim()) errors.push('请填写目的仓名称')
if (!draft.channelCode.trim() || !draft.channelName.trim()) errors.push('请填写渠道编码和渠道名称')
if (!draft.receivingWarehouseId) errors.push('请选择平台收货仓')
if (!draft.shipperPartyId || !draft.shipperPartySnapshot || !draft.shipper.trim()) errors.push('请选择发货单位')
if (draft.shipperPartySnapshot && !draft.shipperPartySnapshot.address.trim()) errors.push('请填写本单发货地址')
if (!draft.consigneePartyId || !draft.consigneePartySnapshot || !draft.consignee.trim()) errors.push('请选择收货单位')
if (draft.consigneePartySnapshot && !draft.consigneePartySnapshot.address.trim()) errors.push('请填写本单收货地址')
if (draft.items.length === 0) errors.push('请至少添加一条货物明细')
draft.items.forEach((item, index) => {
const line = `第 ${index + 1} 条货物`
if (!item.cargoNameCn.trim()) errors.push(`${line}缺少中文品名`)
if (!item.packageType.trim()) errors.push(`${line}缺少包装类型`)
if (!Number.isInteger(item.pieces) || item.pieces <= 0) errors.push(`${line}件数必须为正整数`)
if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`)
if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`)
if (draft.transportMode === 'FCL' && (!item.containerType || item.containerCount <= 0)) {
errors.push(`${line}需要填写箱型和柜量`)
}
})
return errors
}
export function summarizeFirstLegCargo(items: FirstLegCargoItem[]) {
return items.reduce(
(summary, item) => ({
pieces: summary.pieces + item.pieces,
packageCount: summary.packageCount + item.packageCount,
grossWeightKg: summary.grossWeightKg + item.grossWeightKg,
volumeCbm: summary.volumeCbm + item.volumeCbm,
}),
{ pieces: 0, packageCount: 0, grossWeightKg: 0, volumeCbm: 0 },
)
}
+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { decodeMerchantToken } from './jwt.ts'
function tokenFor(payload) {
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
return `header.${encoded}.signature`
}
const payload = {
user_id: 1001,
username: 'merchant_admin',
tenant_id: 2001,
membership_id: 3001,
app_code: 'MERCHANT',
roles: ['MERCHANT_OWNER'],
permissions: ['merchant:profile:view'],
exp: 1900000000,
}
test('decodes a merchant JWT payload for UI identity and permissions', () => {
assert.deepEqual(decodeMerchantToken(tokenFor(payload)), payload)
})
test('rejects tokens from the platform application domain', () => {
assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, app_code: 'PLATFORM' })), /账号不属于当前应用端/)
})
test('rejects malformed tokens', () => {
assert.throws(() => decodeMerchantToken('not-a-jwt'), /登录凭证格式无效/)
})
+37
View File
@@ -0,0 +1,37 @@
export interface MerchantJwtPayload {
user_id: number | string
username: string
tenant_id: number | string
operator_tenant_id?: number | string
membership_id: number | string
app_code: string
client_type?: string
roles?: string[]
permissions?: string[]
exp: number
}
function decodeBase64Url(value: string) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
const binary = atob(padded)
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
export function decodeMerchantToken(token: string): MerchantJwtPayload {
const payloadPart = token.split('.')[1]
if (!payloadPart) throw new Error('登录凭证格式无效')
try {
const value: unknown = JSON.parse(decodeBase64Url(payloadPart))
if (!value || typeof value !== 'object') throw new Error()
const payload = value as Partial<MerchantJwtPayload>
if (!payload.user_id || !payload.username || !payload.tenant_id || !payload.membership_id || !payload.exp) throw new Error()
if (payload.app_code !== 'MERCHANT') throw new Error('账号不属于当前应用端')
return payload as MerchantJwtPayload
} catch (error) {
if (error instanceof Error && error.message === '账号不属于当前应用端') throw error
throw new Error('登录凭证无法解析,请重新登录')
}
}
+21
View File
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { formatQualificationFileSize, QUALIFICATION_MAX_FILE_SIZE, validateQualificationFile } from './qualificationFile.ts'
test('accepts the backend-confirmed JPG and PNG formats up to 5MB', () => {
assert.equal(validateQualificationFile({ name: '营业执照.jpeg', size: QUALIFICATION_MAX_FILE_SIZE, type: 'image/jpeg' }), '')
assert.equal(validateQualificationFile({ name: '营业执照.PNG', size: 2048, type: 'image/png' }), '')
})
test('rejects PDF, empty, mismatched, and oversized files', () => {
assert.match(validateQualificationFile({ name: '营业执照.pdf', size: 1024, type: 'application/pdf' }), /格式不支持/)
assert.match(validateQualificationFile({ name: '伪装图片.png', size: 1024, type: 'text/plain' }), /内容类型/)
assert.match(validateQualificationFile({ name: '空文件.png', size: 0, type: 'image/png' }), /内容为空/)
assert.match(validateQualificationFile({ name: '超大文件.jpg', size: QUALIFICATION_MAX_FILE_SIZE + 1, type: 'image/jpeg' }), /超过 5MB/)
})
test('formats selected file sizes for review', () => {
assert.equal(formatQualificationFileSize(512), '512 B')
assert.equal(formatQualificationFileSize(1536), '1.5 KB')
assert.equal(formatQualificationFileSize(2 * 1024 * 1024), '2.0 MB')
})
+25
View File
@@ -0,0 +1,25 @@
export const QUALIFICATION_MAX_FILE_SIZE = 5 * 1024 * 1024
const allowedExtensions = new Set(['jpg', 'jpeg', 'png'])
const allowedMimeTypes = new Set(['image/jpeg', 'image/png'])
export interface QualificationFileLike {
name: string
size: number
type?: string
}
export function validateQualificationFile(file: QualificationFileLike) {
const extension = file.name.includes('.') ? file.name.split('.').pop()?.toLowerCase() ?? '' : ''
if (!allowedExtensions.has(extension)) return '文件格式不支持,请选择 JPG 或 PNG 图片'
if (file.type && !allowedMimeTypes.has(file.type)) return '文件内容类型与支持的资质格式不一致'
if (file.size <= 0) return '文件内容为空,请重新选择'
if (file.size > QUALIFICATION_MAX_FILE_SIZE) return '文件超过 5MB,请压缩后重新选择'
return ''
}
export function formatQualificationFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}