chore: initial commit

This commit is contained in:
2026-07-30 17:59:43 +08:00
commit dd0d365b03
73 changed files with 9898 additions and 0 deletions
+200
View File
@@ -0,0 +1,200 @@
import { beforeEach, describe, expect, expectTypeOf, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { nextTick } from 'vue'
import { getNavigationLeaves } from '@/navigation/adminNavigation'
import { useAdminDataStore } from '@/stores/adminData'
import { PREVIEW_ROLE_KEY, useAccessPreviewStore } from '@/stores/accessPreview'
function createMemoryStorage(): Storage {
const values = new Map<string, string>()
return {
get length() {
return values.size
},
clear() {
values.clear()
},
getItem(key) {
return values.get(key) ?? null
},
key(index) {
return [...values.keys()][index] ?? null
},
removeItem(key) {
values.delete(key)
},
setItem(key, value) {
values.set(key, value)
},
}
}
describe('access preview store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.stubGlobal('localStorage', createMemoryStorage())
})
it('defaults to the enabled super admin with all enabled permissions', () => {
const preview = useAccessPreviewStore()
preview.initializeAfterLogin()
expect(preview.enabledRoles).toHaveLength(5)
expect(preview.currentRole?.code).toBe('SUPER_ADMIN')
expect(preview.can('system:permission:write')).toBe(true)
expect(preview.firstAccessiblePath).toBe('/customers')
expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBe('R001')
})
it('ignores disabled permission resources even when the selected role still holds their ids', () => {
const data = useAdminDataStore()
const preview = useAccessPreviewStore()
preview.initializeAfterLogin()
const customerList = data.permissions.find((item) => item.code === 'platform:customer:list')
expect(customerList).toBeDefined()
if (customerList) customerList.status = '停用'
expect(preview.can('platform:customer:list')).toBe(false)
expect(getNavigationLeaves(preview.filteredNavigation).map((item) => item.path)).not.toContain('/customers')
expect(preview.firstAccessiblePath).toBe('/customers/reviews')
})
it('does not cascade a disabled directory to enabled descendant leaves', () => {
const data = useAdminDataStore()
const preview = useAccessPreviewStore()
preview.initializeAfterLogin()
const businessDirectory = data.permissions.find((item) => item.code === 'business')
expect(businessDirectory).toBeDefined()
if (businessDirectory) businessDirectory.status = '停用'
expect(preview.can('business')).toBe(false)
expect(preview.can('platform:customer:list')).toBe(true)
expect(getNavigationLeaves(preview.filteredNavigation).map((item) => item.path)).toContain('/customers')
})
it('reacts immediately to permission id changes and falls back when the current role is disabled', async () => {
const data = useAdminDataStore()
const preview = useAccessPreviewStore()
preview.initializeAfterLogin()
expect(preview.commitRole('R003')).toBe(true)
const operator = data.roles.find((item) => item.id === 'R003')
expect(operator).toBeDefined()
if (operator) operator.permissionIds = ['P100']
expect(preview.can('first-leg:list')).toBe(false)
expect(preview.can('platform:customer:list')).toBe(true)
expect(preview.permissionSignature).toBe('platform:customer:list')
if (operator) operator.status = '停用'
await nextTick()
expect(preview.currentRole?.code).toBe('SUPER_ADMIN')
expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBe('R001')
})
it('falls back from an invalid persisted role and reset clears selection and persistence', () => {
localStorage.setItem(PREVIEW_ROLE_KEY, 'missing-role')
const preview = useAccessPreviewStore()
preview.initializeAfterLogin()
expect(preview.currentRole?.code).toBe('SUPER_ADMIN')
preview.reset()
expect(preview.initialized).toBe(false)
expect(preview.currentRole).toBeNull()
expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBeNull()
})
it('restores an enabled persisted role', () => {
localStorage.setItem(PREVIEW_ROLE_KEY, 'R003')
const preview = useAccessPreviewStore()
preview.initializeAfterLogin()
expect(preview.currentRole?.id).toBe('R003')
expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBe('R003')
})
it('does not reload an initialized role during guarded navigation', () => {
const preview = useAccessPreviewStore()
preview.initializeAfterLogin()
expect(preview.commitRole('R003')).toBe(true)
localStorage.setItem(PREVIEW_ROLE_KEY, 'R001')
preview.initializeAfterLogin()
expect(preview.currentRole?.id).toBe('R003')
})
it('has no role, permissions, or route when every role is disabled', () => {
const data = useAdminDataStore()
data.roles.forEach((role) => {
role.status = '停用'
})
const preview = useAccessPreviewStore()
preview.initializeAfterLogin()
expect(preview.currentRole).toBeNull()
expect([...preview.permissionCodes]).toEqual([])
expect(preview.filteredNavigation).toEqual([])
expect(preview.firstAccessiblePath).toBeNull()
expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBeNull()
})
it('does not recreate a default preview after reset when role data changes', async () => {
const data = useAdminDataStore()
const preview = useAccessPreviewStore()
preview.initializeAfterLogin()
preview.reset()
data.roles[0].status = '停用'
data.roles[1].status = '停用'
await nextTick()
expect(preview.initialized).toBe(false)
expect(preview.selectedRoleId).toBeNull()
expect(preview.currentRole).toBeNull()
expect(localStorage.getItem(PREVIEW_ROLE_KEY)).toBeNull()
})
it('previews candidates without committing and rejects disabled roles', () => {
const data = useAdminDataStore()
const preview = useAccessPreviewStore()
preview.initializeAfterLogin()
const candidate = preview.previewRole('R003')
expect(candidate.codes.has('first-leg:create')).toBe(true)
expect(candidate.firstPath).toBe('/warehouses')
expect(getNavigationLeaves(candidate.navigation).map((item) => item.path)).toContain('/orders/first-leg')
expectTypeOf(candidate.codes).toEqualTypeOf<ReadonlySet<string>>()
expectTypeOf(preview.permissionCodes).toEqualTypeOf<ReadonlySet<string>>()
expect(preview.currentRole?.id).toBe('R001')
const finance = data.roles.find((item) => item.id === 'R004')
if (finance) finance.status = '停用'
expect(preview.commitRole('R004')).toBe(false)
expect(preview.previewRole('R004').firstPath).toBeNull()
expect(preview.currentRole?.id).toBe('R001')
})
it('does not filter or mutate mock business data when switching data scopes', () => {
const data = useAdminDataStore()
const preview = useAccessPreviewStore()
preview.initializeAfterLogin()
const customerSnapshot = JSON.parse(JSON.stringify(data.customers)) as typeof data.customers
const orderSnapshot = JSON.parse(JSON.stringify(data.orders)) as typeof data.orders
expect(preview.commitRole('R003')).toBe(true)
expect(preview.currentRole?.dataScope).toBe('本部门数据')
expect(preview.commitRole('R005')).toBe(true)
expect(preview.currentRole?.dataScope).toBe('本人数据')
expect(data.customers).toEqual(customerSnapshot)
expect(data.orders).toEqual(orderSnapshot)
})
})
+155
View File
@@ -0,0 +1,155 @@
import { computed, ref, watch } from 'vue'
import { defineStore } from 'pinia'
import {
adminNavigation,
filterAdminNavigation,
getFirstNavigationLeaf,
type AdminNavigationNode,
} from '@/navigation/adminNavigation'
import { useAdminDataStore } from '@/stores/adminData'
import { useAuthStore } from '@/stores/auth'
export const PREVIEW_ROLE_KEY = 'cc-admin-preview-role'
export interface RoleAccessPreview {
codes: ReadonlySet<string>
navigation: AdminNavigationNode[]
firstPath: string | null
}
const serverPermissionAliases: Record<string, string[]> = {
'customer:list': ['platform:customer:list'],
'warehouse:list': ['platform:warehouse:list'],
'first-leg:list': ['platform:order:list'],
'express-company:list': ['platform:express:list'],
'counterparty:list': ['platform:counterparty:list'],
'system:user:list': ['platform:user:list'],
'system:role:list': ['platform:role:list'],
'system:role:grant': ['platform:role:create', 'platform:role:grant'],
'system:permission:list': ['platform:permission:list'],
'system:permission:write': ['platform:permission:create'],
}
function withServerAliases(codes: ReadonlySet<string>): ReadonlySet<string> {
const result = new Set(codes)
codes.forEach((code) => serverPermissionAliases[code]?.forEach((alias) => result.add(alias)))
return result
}
export const useAccessPreviewStore = defineStore('access-preview', () => {
const data = useAdminDataStore()
const auth = useAuthStore()
const selectedRoleId = ref<string | null>(null)
const initialized = ref(false)
const enabledRoles = computed(() => data.roles.filter((role) => role.status === '启用'))
const enabledRoleIdsSignature = computed(() => enabledRoles.value
.map((role) => role.id)
.sort()
.join('|'))
const currentRole = computed(() => enabledRoles.value
.find((role) => role.id === selectedRoleId.value) ?? null)
function permissionCodesFor(roleId: string | null): ReadonlySet<string> {
const role = enabledRoles.value.find((item) => item.id === roleId)
if (!role) return new Set<string>()
const permissionIds = new Set(role.permissionIds)
return new Set(data.permissions
.filter((permission) => permission.status === '启用' && permissionIds.has(permission.id))
.map((permission) => permission.code))
}
const usesServerPermissions = computed(() => auth.isServerSession)
const permissionCodes = computed<ReadonlySet<string>>(() => usesServerPermissions.value
? auth.permissionCodes
: withServerAliases(permissionCodesFor(selectedRoleId.value)))
const permissionSignature = computed(() => [...permissionCodes.value].sort().join('|'))
const filteredNavigation = computed(() => filterAdminNavigation(adminNavigation, permissionCodes.value))
const firstAccessiblePath = computed(() => getFirstNavigationLeaf(filteredNavigation.value)?.path ?? null)
function getDefaultRoleId(): string | null {
return enabledRoles.value.find((role) => role.code === 'SUPER_ADMIN')?.id
?? enabledRoles.value[0]?.id
?? null
}
function persistRole(roleId: string | null) {
if (roleId) {
localStorage.setItem(PREVIEW_ROLE_KEY, roleId)
return
}
localStorage.removeItem(PREVIEW_ROLE_KEY)
}
function initializeAfterLogin() {
if (usesServerPermissions.value) {
initialized.value = true
selectedRoleId.value = null
localStorage.removeItem(PREVIEW_ROLE_KEY)
return
}
if (initialized.value && currentRole.value) return
initialized.value = true
const persistedRoleId = localStorage.getItem(PREVIEW_ROLE_KEY)
const roleId = enabledRoles.value.some((role) => role.id === persistedRoleId)
? persistedRoleId
: getDefaultRoleId()
selectedRoleId.value = roleId
persistRole(roleId)
}
function commitRole(roleId: string): boolean {
if (usesServerPermissions.value) return false
if (!enabledRoles.value.some((role) => role.id === roleId)) return false
selectedRoleId.value = roleId
persistRole(roleId)
return true
}
function previewRole(roleId: string): RoleAccessPreview {
const candidateCodes = withServerAliases(permissionCodesFor(roleId))
const candidateNavigation = filterAdminNavigation(adminNavigation, candidateCodes)
return {
codes: candidateCodes,
navigation: candidateNavigation,
firstPath: getFirstNavigationLeaf(candidateNavigation)?.path ?? null,
}
}
function can(permissionCode: string): boolean {
return permissionCodes.value.has(permissionCode)
}
function reset() {
initialized.value = false
selectedRoleId.value = null
localStorage.removeItem(PREVIEW_ROLE_KEY)
}
watch(enabledRoleIdsSignature, () => {
if (!initialized.value || currentRole.value) return
const fallbackRoleId = getDefaultRoleId()
selectedRoleId.value = fallbackRoleId
persistRole(fallbackRoleId)
})
return {
selectedRoleId,
initialized,
usesServerPermissions,
enabledRoles,
currentRole,
permissionCodes,
permissionSignature,
filteredNavigation,
firstAccessiblePath,
permissionCodesFor,
initializeAfterLogin,
commitRole,
previewRole,
can,
reset,
}
})
+126
View File
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { adminNavigation, getNavigationLeaves } from '@/navigation/adminNavigation'
import { useAdminDataStore } from '@/stores/adminData'
import { buildPermissionTree } from '@/utils/permissionTree'
describe('admin permission fixtures', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('groups page permissions under business and system directories', () => {
const tree = buildPermissionTree(useAdminDataStore().permissions)
expect(tree.map((node) => node.name)).toEqual(['业务管理', '基础资料', '系统管理'])
expect(tree[0]?.children?.map((node) => node.name)).toEqual(['客户管理', '企业审核', '仓库管理', '头程订单'])
expect(tree[1]?.children?.map((node) => node.name)).toEqual(['快递公司', '往来单位'])
expect(tree[2]?.children?.map((node) => node.name)).toEqual(['内部用户', '角色管理', '菜单权限'])
})
it('keeps menu permission codes aligned with navigation leaves', () => {
const store = useAdminDataStore()
const menuCodes = store.permissions
.filter((item) => item.type === '菜单')
.map((item) => item.code)
.sort()
const navigationCodes = getNavigationLeaves(adminNavigation)
.map((item) => item.permission)
.sort()
expect(menuCodes).toEqual(navigationCodes)
})
it('does not guess API paths for static button permissions', () => {
const buttonPermissions = useAdminDataStore().permissions.filter((item) => item.type === '按钮')
expect(buttonPermissions.every((item) => item.path === '' && item.method === '-')).toBe(true)
expect(useAdminDataStore().permissions.some((item) => item.type === '接口')).toBe(false)
})
it('keeps every role permission id valid after regrouping', () => {
const store = useAdminDataStore()
const permissionIds = new Set(store.permissions.map((item) => item.id))
store.roles.forEach((role) => {
expect(role.permissionIds.every((id) => permissionIds.has(id))).toBe(true)
})
})
it('maintains platform receiving warehouses with unique normalized codes', () => {
const store = useAdminDataStore()
expect(store.warehouses).toHaveLength(6)
expect(store.activeWarehouses).toBe(5)
const id = store.saveWarehouse({
code: 'cn-hz-01', name: '杭州仓', province: '浙江省', city: '杭州市', address: '演示物流园 7 号库',
contactName: '仓库客服', contactPhone: '0571-****007', businessHours: '周一至周五 09:00-18:00',
status: 'ACTIVE', remark: '',
})
expect(store.warehouses.find((item) => item.id === id)?.code).toBe('CN-HZ-01')
expect(store.activeWarehouses).toBe(6)
expect(() => store.saveWarehouse({
code: 'CN-HZ-01', name: '重复仓', province: '', city: '杭州市', address: '重复地址',
contactName: '', contactPhone: '', businessHours: '', status: 'ACTIVE', remark: '',
})).toThrow('仓库编码已存在')
store.setWarehouseStatus(id, 'INACTIVE')
expect(store.activeWarehouses).toBe(5)
})
it('provides the express company records defined by the phase-one prototype', () => {
const store = useAdminDataStore()
expect(store.expressCompanies).toHaveLength(8)
expect(store.activeExpressCompanies).toBe(7)
expect(store.expressCompanies.find((item) => item.code === 'YTO')?.status).toBe('已终止')
expect(new Set(store.expressCompanies.map((item) => item.code)).size).toBe(store.expressCompanies.length)
})
it('validates express company uniqueness and counterparty multi-types', () => {
const store = useAdminDataStore()
expect(() => store.saveExpressCompany({ name: '顺丰速运', code: 'NEW-SF', website: '', phone: '', status: '合作中', remark: '' })).toThrow('快递公司名称已存在')
expect(() => store.saveExpressCompany({ name: '新快递', code: 'sf', website: '', phone: '', status: '合作中', remark: '' })).toThrow('快递公司编码已存在')
const id = store.saveCounterparty({ code: 'cp-test', name: '测试多角色单位', types: ['供应商', '发货单位'], contactName: '联系人', contactPhone: '13800000000', email: '', country: '中国', province: '浙江省', city: '义乌市', postalCode: '', address: '测试地址', status: '启用', remark: '' })
expect(store.counterparties.find((item) => item.id === id)?.types).toEqual(['供应商', '发货单位'])
expect(() => store.saveCounterparty({ code: 'CP-TEST', name: '重复单位', types: ['客户'], contactName: '联系人', contactPhone: '13800000000', email: '', country: '中国', province: '', city: '深圳市', postalCode: '', address: '测试地址', status: '启用', remark: '' })).toThrow('往来单位编码已存在')
})
it('keeps pending orders pending after admin edits and audits delivery maintenance', () => {
const store = useAdminDataStore()
const order = store.orders.find((item) => item.status === '待审核')!
const before = order.timeline.length
store.saveOrder({ ...order, id: order.id, remark: '后台补充资料' })
const updated = store.orders.find((item) => item.id === order.id)!
expect(updated.status).toBe('待审核')
expect(updated.timeline).toHaveLength(before + 1)
expect(updated.timeline.at(-1)?.title).toBe('后台修改待审核订单')
store.saveOrderDeliveryInfo(order.id, 'E001', 'DHL-20260730-001', '张伟')
expect(updated.deliveryInfo?.trackingNo).toBe('DHL-20260730-001')
expect(updated.timeline.at(-1)?.title).toBe('更新履约快递信息')
})
it('keeps the order warehouse snapshot stable when the warehouse master changes', () => {
const store = useAdminDataStore()
const order = store.orders[0]
const warehouse = store.warehouses.find((item) => item.id === order.receivingWarehouseId)
expect(warehouse).toBeDefined()
const originalSnapshot = order.receivingWarehouseSnapshot ? { ...order.receivingWarehouseSnapshot } : null
store.saveWarehouse({ ...warehouse!, name: '宁波仓(新名称)' })
expect(store.warehouses.find((item) => item.id === warehouse!.id)?.name).toBe('宁波仓(新名称)')
expect(order.receivingWarehouseSnapshot).toEqual(originalSnapshot)
})
it('rejects new orders that select an inactive receiving warehouse', () => {
const store = useAdminDataStore()
const template = store.orders[0]
expect(() => store.saveOrder({
...template,
id: undefined,
receivingWarehouseId: 'WH006',
})).toThrow('所选收货仓当前不可用于新订单')
})
})
+389
View File
@@ -0,0 +1,389 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import {
createPlatformReceivingWarehouseSnapshot,
DEMO_PLATFORM_RECEIVING_WAREHOUSES,
} from '@chongchong/shared'
import type {
Customer,
Counterparty,
ExpressCompany,
FirstLegOrder,
InternalUser,
OrderStatus,
Permission,
Role,
Warehouse,
} from '@/types/admin'
const initialExpressCompanies: ExpressCompany[] = [
{ id: 'E001', name: 'DHL', code: 'DHL', website: 'www.dhl.com', phone: '95380', status: '合作中', remark: '国际快递渠道。', updatedAt: '2026-07-28 09:10' },
{ id: 'E002', name: 'FedEx', code: 'FDX', website: 'www.fedex.com', phone: '400-886-1888', status: '合作中', remark: '国际快递渠道。', updatedAt: '2026-07-28 09:12' },
{ id: 'E003', name: 'UPS', code: 'UPS', website: 'www.ups.com', phone: '400-820-8388', status: '合作中', remark: '', updatedAt: '2026-07-28 09:15' },
{ id: 'E004', name: '顺丰速运', code: 'SF', website: 'www.sf-express.com', phone: '95338', status: '合作中', remark: '国内收货常用渠道。', updatedAt: '2026-07-28 09:20' },
{ id: 'E005', name: '中通快递', code: 'ZTO', website: 'www.zto.com', phone: '95311', status: '合作中', remark: '', updatedAt: '2026-07-28 09:22' },
{ id: 'E006', name: '圆通速递', code: 'YTO', website: 'www.yto.net.cn', phone: '95554', status: '已终止', remark: '暂停新增引用,历史订单保留。', updatedAt: '2026-07-28 09:25' },
{ id: 'E007', name: 'EMS', code: 'EMS', website: 'www.ems.com.cn', phone: '11183', status: '合作中', remark: '', updatedAt: '2026-07-28 09:28' },
{ id: 'E008', name: '德邦快递', code: 'DPK', website: 'www.deppon.com', phone: '95353', status: '合作中', remark: '大件货物备选渠道。', updatedAt: '2026-07-28 09:30' },
]
const initialCounterparties: Counterparty[] = [
{ id: 'CP001', code: 'CP-HY-001', name: '华远贸易有限公司', types: ['客户', '发货单位'], contactName: '张伟', contactPhone: '138****6789', email: 'zhangwei@huayuan.cn', country: '中国', province: '浙江省', city: '杭州市', postalCode: '310000', address: '滨江区江南大道 588 号', status: '启用', remark: '美线核心客户。', updatedAt: '2026-07-28 10:10' },
{ id: 'CP002', code: 'CP-SD-002', name: '深圳速达供应链有限公司', types: ['供应商', '承运商', '发货单位'], contactName: '李明', contactPhone: '139****8901', email: 'liming@suda.cn', country: '中国', province: '广东省', city: '深圳市', postalCode: '518000', address: '盐田区深盐路 2028 号', status: '启用', remark: '欧洲线拼箱合作方。', updatedAt: '2026-07-28 10:15' },
{ id: 'CP003', code: 'CP-CUS-003', name: 'HY GLOBAL LLC', types: ['客户', '收货单位'], contactName: 'Alice Chen', contactPhone: '+1 213 *** 8899', email: 'ops@hyglobal.example', country: '美国', province: 'California', city: 'Los Angeles', postalCode: '90001', address: '1200 S Alameda St', status: '启用', remark: '华远贸易美国收货方。', updatedAt: '2026-07-28 10:20' },
{ id: 'CP004', code: 'CP-CB-004', name: '宁波海诚报关有限公司', types: ['供应商', '报关行'], contactName: '周海', contactPhone: '0574-****2211', email: 'service@haicheng.example', country: '中国', province: '浙江省', city: '宁波市', postalCode: '315800', address: '北仑区港兴路 36 号', status: '启用', remark: '宁波口岸报关服务。', updatedAt: '2026-07-28 10:25' },
{ id: 'CP005', code: 'CP-OLD-005', name: '厦门远港运输有限公司', types: ['承运商'], contactName: '刘洋', contactPhone: '0592-****9812', email: 'dispatch@xm-old.example', country: '中国', province: '福建省', city: '厦门市', postalCode: '361000', address: '湖里区东港北路 31 号', status: '停用', remark: '合同到期,停止新增引用。', updatedAt: '2026-07-28 10:30' },
]
const initialCustomers: Customer[] = [
{ id: 'T001', name: '华远贸易有限公司', creditCode: '91330100MA15XXXX91', contact: '张伟', phone: '138****6789', email: 'zhangwei@huayuan.cn', sales: '王建国', packageName: '企业版', period: 30, status: '正常', orders: 286, joinedAt: '2025-03-15', address: '浙江省杭州市滨江区江南大道 588 号', remark: '美线核心客户,优先处理订舱需求。' },
{ id: 'T002', name: '深圳速达供应链有限公司', creditCode: '91440300MA95XXXX13', contact: '李明', phone: '139****8901', email: 'liming@suda.cn', sales: '李秀英', packageName: '专业版', period: 45, status: '正常', orders: 152, joinedAt: '2025-06-20', address: '广东省深圳市盐田区深盐路 2028 号', remark: '欧洲线拼箱客户。' },
{ id: 'T003', name: '广州恒通物流有限公司', creditCode: '91440100MA32XXXX45', contact: '王芳', phone: '137****2345', email: 'wangfang@hengtong.cn', sales: '张志强', packageName: '专业版', period: 60, status: '正常', orders: 98, joinedAt: '2025-09-01', address: '广东省广州市白云区机场路 1688 号', remark: '空运及敏感货渠道客户。' },
{ id: 'T004', name: '宁波远洋货运代理有限公司', creditCode: '91330200MA18XXXX38', contact: '陈强', phone: '136****4567', email: 'chenqiang@ocean-nb.cn', sales: '刘德海', packageName: '基础版', period: 90, status: '正常', orders: 45, joinedAt: '2026-01-10', address: '浙江省宁波市北仑区港兴路 88 号', remark: '' },
{ id: 'T005', name: '北京国贸跨境供应链有限公司', creditCode: '91110100MA87XXXX23', contact: '赵雪', phone: '135****7890', email: 'zhaoxue@bjgmt.cn', sales: '陈美玲', packageName: '基础版', period: 30, status: '待审核', orders: 0, joinedAt: '2026-07-26', address: '北京市朝阳区建国路 93 号', remark: '营业执照已上传,等待审核。' },
{ id: 'T006', name: '厦门港务货运有限公司', creditCode: '91350200MA12XXXX79', contact: '刘洋', phone: '133****0123', email: 'liuyang@xmgw.cn', sales: '赵晓东', packageName: '企业版', period: 45, status: '停用', orders: 73, joinedAt: '2024-11-01', address: '福建省厦门市湖里区东港北路 31 号', remark: '合同到期,暂时停用。' },
]
const initialUsers: InternalUser[] = [
{ id: 'U001', account: 'admin', name: '系统管理员', department: '平台管理', phone: '13800000001', email: 'admin@ccglobal.cn', roleIds: ['R001'], status: '正常', lastLogin: '2026-07-27 17:56', createdAt: '2026-01-01' },
{ id: 'U002', account: 'zhangwei', name: '张伟', department: '运营部', phone: '13800000002', email: 'zhangwei@ccglobal.cn', roleIds: ['R002'], status: '正常', lastLogin: '2026-07-27 16:30', createdAt: '2026-02-08' },
{ id: 'U003', account: 'liming', name: '李明', department: '操作部', phone: '13800000003', email: 'liming@ccglobal.cn', roleIds: ['R003'], status: '正常', lastLogin: '2026-07-27 15:42', createdAt: '2026-02-18' },
{ id: 'U004', account: 'wangfang', name: '王芳', department: '财务部', phone: '13800000004', email: 'wangfang@ccglobal.cn', roleIds: ['R004'], status: '正常', lastLogin: '2026-07-27 14:18', createdAt: '2026-03-02' },
{ id: 'U005', account: 'chenqiang', name: '陈强', department: '销售部', phone: '13800000005', email: 'chenqiang@ccglobal.cn', roleIds: ['R005'], status: '正常', lastLogin: '2026-07-26 18:02', createdAt: '2026-04-15' },
{ id: 'U006', account: 'zhaoting', name: '赵婷', department: '操作部', phone: '13800000006', email: 'zhaoting@ccglobal.cn', roleIds: ['R003'], status: '停用', lastLogin: '2026-07-18 10:10', createdAt: '2026-05-10' },
]
const initialPermissions: Permission[] = [
{ id: 'P010', parentId: null, name: '业务管理', code: 'business', type: '目录', path: '', method: '-', status: '启用', sort: 20 },
{ id: 'P100', parentId: 'P010', name: '客户管理', code: 'platform:customer:list', type: '菜单', path: '/customers', method: '-', status: '启用', sort: 10 },
{ id: 'P101', parentId: 'P010', name: '企业审核', code: 'platform:customer:review', type: '菜单', path: '/customers/reviews', method: '-', status: '启用', sort: 15 },
{ id: 'P102', parentId: 'P100', name: '新增客户', code: 'customer:create', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
{ id: 'P103', parentId: 'P101', name: '审核通过', code: 'platform:customer:approve', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
{ id: 'P104', parentId: 'P101', name: '审核驳回', code: 'platform:customer:reject', type: '按钮', path: '', method: '-', status: '启用', sort: 20 },
{ id: 'P150', parentId: 'P010', name: '仓库管理', code: 'platform:warehouse:list', type: '菜单', path: '/warehouses', method: '-', status: '启用', sort: 20 },
{ id: 'P152', parentId: 'P150', name: '维护仓库', code: 'warehouse:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
{ id: 'P200', parentId: 'P010', name: '头程订单', code: 'platform:order:list', type: '菜单', path: '/orders/first-leg', method: '-', status: '启用', sort: 30 },
{ id: 'P202', parentId: 'P200', name: '创建订单', code: 'first-leg:create', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
{ id: 'P203', parentId: 'P200', name: '更新状态', code: 'first-leg:status', type: '按钮', path: '', method: '-', status: '启用', sort: 20 },
{ id: 'P204', parentId: 'P200', name: '维护履约快递', code: 'first-leg:delivery', type: '按钮', path: '', method: '-', status: '启用', sort: 30 },
{ id: 'P015', parentId: null, name: '基础资料', code: 'master-data', type: '目录', path: '', method: '-', status: '启用', sort: 25 },
{ id: 'P160', parentId: 'P015', name: '快递公司', code: 'platform:express:list', type: '菜单', path: '/master-data/express-companies', method: '-', status: '启用', sort: 10 },
{ id: 'P161', parentId: 'P160', name: '维护快递公司', code: 'express-company:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
{ id: 'P162', parentId: 'P160', name: '导入快递公司', code: 'express-company:import', type: '按钮', path: '', method: '-', status: '启用', sort: 20 },
{ id: 'P165', parentId: 'P015', name: '往来单位', code: 'platform:counterparty:list', type: '菜单', path: '/master-data/counterparties', method: '-', status: '启用', sort: 20 },
{ id: 'P166', parentId: 'P165', name: '维护往来单位', code: 'counterparty:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
{ id: 'P167', parentId: 'P165', name: '导入往来单位', code: 'counterparty:import', type: '按钮', path: '', method: '-', status: '启用', sort: 20 },
{ id: 'P020', parentId: null, name: '系统管理', code: 'system', type: '目录', path: '', method: '-', status: '启用', sort: 30 },
{ id: 'P300', parentId: 'P020', name: '内部用户', code: 'platform:user:list', type: '菜单', path: '/system/users', method: '-', status: '启用', sort: 10 },
{ id: 'P302', parentId: 'P300', name: '维护用户', code: 'system:user:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
{ id: 'P400', parentId: 'P020', name: '角色管理', code: 'platform:role:list', type: '菜单', path: '/system/roles', method: '-', status: '启用', sort: 20 },
{ id: 'P402', parentId: 'P400', name: '角色授权', code: 'system:role:grant', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
{ id: 'P500', parentId: 'P020', name: '菜单权限', code: 'platform:permission:list', type: '菜单', path: '/system/permissions', method: '-', status: '启用', sort: 30 },
{ id: 'P502', parentId: 'P500', name: '维护权限', code: 'system:permission:write', type: '按钮', path: '', method: '-', status: '启用', sort: 10 },
]
const allPermissionIds = initialPermissions.map((item) => item.id)
const initialRoles: Role[] = [
{ id: 'R001', name: '超级管理员', code: 'SUPER_ADMIN', description: '平台最高权限,拥有全部菜单和数据权限。', userCount: 1, dataScope: '全部数据', status: '启用', permissionIds: allPermissionIds, builtIn: true },
{ id: 'R002', name: '运营主管', code: 'OPS_MANAGER', description: '负责客户审核、仓库维护、基础资料、订单分派和运营监控。', userCount: 1, dataScope: '全部数据', status: '启用', permissionIds: ['P010', 'P100', 'P103', 'P150', 'P152', 'P200', 'P203', 'P204', 'P015', 'P160', 'P161', 'P162', 'P165', 'P166', 'P167'] },
{ id: 'R003', name: '操作员', code: 'OPERATOR', description: '查看收货仓并处理头程订单和物流节点更新。', userCount: 2, dataScope: '本部门数据', status: '启用', permissionIds: ['P010', 'P150', 'P200', 'P202', 'P203', 'P204'] },
{ id: 'R004', name: '财务', code: 'FINANCE', description: '查看订单应收并处理对账结算。', userCount: 1, dataScope: '本部门数据', status: '启用', permissionIds: ['P010', 'P200'] },
{ id: 'R005', name: '销售', code: 'SALES', description: '维护客户、查看收货仓并跟进本人负责的订单。', userCount: 1, dataScope: '本人数据', status: '启用', permissionIds: ['P010', 'P100', 'P102', 'P150', 'P200'] },
]
function demoWarehouseSnapshot(id: string) {
const warehouse = DEMO_PLATFORM_RECEIVING_WAREHOUSES.find((item) => item.id === id)
if (!warehouse) throw new Error(`演示仓库不存在:${id}`)
return createPlatformReceivingWarehouseSnapshot(warehouse)
}
const initialOrders: FirstLegOrder[] = [
{ id: 'O001', no: 'BY20260727001', customerId: 'T001', customerName: '华远贸易有限公司', referenceNo: 'HY-0727-01', transportMode: '海运整箱 FCL', businessType: '出口', origin: '宁波', destination: '洛杉矶', receivingWarehouseId: 'WH005', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH005'), container: '40HQ x 2', etd: '2026-08-02', eta: '2026-08-18', amount: 48500, currency: 'CNY', status: '已受理', priority: '紧急', operator: '张伟', shipper: '华远贸易有限公司', consignee: 'HY GLOBAL LLC', cargoName: '家居用品', pieces: 1260, weight: 27800, volume: 62.5, createdAt: '2026-07-27 09:18', remark: '需要在截关日前完成报关资料确认。', timeline: [{ time: '2026-07-27 09:18', title: '客户提交订单', description: '客户完成货物与平台收货仓信息提交。', operator: '客户管理员' }, { time: '2026-07-27 10:02', title: '订单已受理', description: '运营审核资料完整,进入订舱准备。', operator: '张伟' }] },
{ id: 'O002', no: 'BY20260726008', customerId: 'T002', customerName: '深圳速达供应链有限公司', referenceNo: 'SD-EU-558', transportMode: '海运拼箱 LCL', businessType: '出口', origin: '深圳', destination: '汉堡', receivingWarehouseId: 'WH002', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH002'), container: 'LCL 8.6 CBM', etd: '2026-08-05', eta: '2026-09-06', amount: 12800, currency: 'CNY', status: '已订舱', priority: '普通', operator: '李明', shipper: '深圳速达供应链有限公司', consignee: 'SUDA EUROPE GMBH', cargoName: '电子配件', pieces: 286, weight: 5200, volume: 8.6, createdAt: '2026-07-26 15:42', remark: '', timeline: [{ time: '2026-07-26 15:42', title: '客户提交订单', description: '订单进入平台审核。', operator: '客户管理员' }, { time: '2026-07-27 08:55', title: '订舱完成', description: '已获得船公司舱位确认。', operator: '李明' }] },
{ id: 'O003', no: 'BY20260726005', customerId: 'T003', customerName: '广州恒通物流有限公司', referenceNo: 'HT-AIR-0719', transportMode: '空运', businessType: '出口', origin: '广州', destination: '纽约', receivingWarehouseId: 'WH002', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH002'), container: '500 KG', etd: '2026-07-30', eta: '2026-08-01', amount: 35600, currency: 'CNY', status: '运输中', priority: '加急', operator: '赵婷', shipper: '广州恒通物流有限公司', consignee: 'HENGTONG US INC.', cargoName: '服装样品', pieces: 36, weight: 500, volume: 2.8, createdAt: '2026-07-26 10:20', remark: '航班起飞后第一时间同步轨迹。', timeline: [{ time: '2026-07-26 10:20', title: '客户提交订单', description: '加急空运订单。', operator: '客户管理员' }, { time: '2026-07-27 16:30', title: '货物已起飞', description: '航班 CZ699 离港。', operator: '赵婷' }] },
{ id: 'O004', no: 'BY20260725012', customerId: 'T004', customerName: '宁波远洋货运代理有限公司', referenceNo: 'NB-ROAD-201', transportMode: '陆运', businessType: '出口', origin: '宁波', destination: '霍尔果斯', receivingWarehouseId: 'WH005', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH005'), container: '整车 x 3', etd: '2026-07-28', eta: '2026-08-08', amount: 18200, currency: 'CNY', status: '待审核', priority: '普通', operator: '未分配', shipper: '宁波远洋货运代理有限公司', consignee: 'ALATAU TRADING LLP', cargoName: '机械配件', pieces: 480, weight: 18500, volume: 46, createdAt: '2026-07-25 14:08', remark: '', timeline: [{ time: '2026-07-25 14:08', title: '客户提交订单', description: '等待平台审核与人员分派。', operator: '客户管理员' }] },
{ id: 'O005', no: 'BY20260724009', customerId: 'T001', customerName: '华远贸易有限公司', referenceNo: 'HY-0724-06', transportMode: '海运整箱 FCL', businessType: '出口', origin: '上海', destination: '长滩', receivingWarehouseId: 'WH004', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH004'), container: '20GP x 5', etd: '2026-07-28', eta: '2026-08-12', amount: 56000, currency: 'CNY', status: '已完成', priority: '普通', operator: '张伟', shipper: '华远贸易有限公司', consignee: 'HY GLOBAL LLC', cargoName: '户外用品', pieces: 3200, weight: 55000, volume: 135, createdAt: '2026-07-24 08:45', remark: '', timeline: [{ time: '2026-07-24 08:45', title: '客户提交订单', description: '订单资料提交。', operator: '客户管理员' }, { time: '2026-07-27 12:20', title: '订单已完成', description: '全部业务节点已确认。', operator: '张伟' }] },
{ id: 'O006', no: 'BY20260723007', customerId: 'T002', customerName: '深圳速达供应链有限公司', referenceNo: 'SD-EU-552', transportMode: '海运拼箱 LCL', businessType: '出口', origin: '青岛', destination: '鹿特丹', receivingWarehouseId: 'WH003', receivingWarehouseSnapshot: demoWarehouseSnapshot('WH003'), container: 'LCL 12 CBM', etd: '2026-08-10', eta: '2026-09-15', amount: 15300, currency: 'CNY', status: '草稿', priority: '普通', operator: '未分配', shipper: '深圳速达供应链有限公司', consignee: 'SUDA EUROPE GMBH', cargoName: '厨房用品', pieces: 520, weight: 8500, volume: 12, createdAt: '2026-07-23 17:10', remark: '等待客户补充收货人税号。', timeline: [{ time: '2026-07-23 17:10', title: '保存草稿', description: '订单尚未提交审核。', operator: '客户管理员' }] },
]
export const useAdminDataStore = defineStore('admin-data', () => {
const customers = ref<Customer[]>(structuredClone(initialCustomers))
const users = ref<InternalUser[]>(structuredClone(initialUsers))
const roles = ref<Role[]>(structuredClone(initialRoles))
const permissions = ref<Permission[]>(structuredClone(initialPermissions))
const warehouses = ref<Warehouse[]>(DEMO_PLATFORM_RECEIVING_WAREHOUSES.map((item) => ({ ...item })))
const expressCompanies = ref<ExpressCompany[]>(structuredClone(initialExpressCompanies))
const counterparties = ref<Counterparty[]>(structuredClone(initialCounterparties))
const orders = ref<FirstLegOrder[]>(structuredClone(initialOrders))
const activeUsers = computed(() => users.value.filter((item) => item.status === '正常').length)
const pendingCustomers = computed(() => customers.value.filter((item) => item.status === '待审核').length)
const activeWarehouses = computed(() => warehouses.value.filter((item) => item.status === 'ACTIVE').length)
const activeExpressCompanies = computed(() => expressCompanies.value.filter((item) => item.status === '合作中').length)
const activeCounterparties = computed(() => counterparties.value.filter((item) => item.status === '启用').length)
const pendingOrders = computed(() => orders.value.filter((item) => ['待审核', '已受理'].includes(item.status)).length)
function nextId(prefix: string, rows: Array<{ id: string }>) {
const max = rows.reduce((value, item) => Math.max(value, Number(item.id.replace(/\D/g, '')) || 0), 0)
return `${prefix}${String(max + 1).padStart(3, '0')}`
}
function saveCustomer(payload: Omit<Customer, 'id' | 'orders' | 'joinedAt'> & { id?: string }) {
if (payload.id) {
const index = customers.value.findIndex((item) => item.id === payload.id)
if (index >= 0) customers.value[index] = { ...customers.value[index], ...payload } as Customer
return payload.id
}
const id = nextId('T', customers.value)
customers.value.unshift({ ...payload, id, orders: 0, joinedAt: new Date().toISOString().slice(0, 10) })
return id
}
function setCustomerStatus(id: string, status: Customer['status']) {
const customer = customers.value.find((item) => item.id === id)
if (customer) customer.status = status
}
function saveWarehouse(payload: Omit<Warehouse, 'id' | 'updatedAt'> & { id?: string }) {
const duplicate = warehouses.value.find((item) => item.code.toLowerCase() === payload.code.trim().toLowerCase() && item.id !== payload.id)
if (duplicate) throw new Error('仓库编码已存在')
const updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
if (payload.id) {
const index = warehouses.value.findIndex((item) => item.id === payload.id)
if (index >= 0) warehouses.value[index] = { ...warehouses.value[index], ...payload, code: payload.code.trim().toUpperCase(), updatedAt } as Warehouse
return payload.id
}
const id = nextId('WH', warehouses.value)
warehouses.value.unshift({ ...payload, id, code: payload.code.trim().toUpperCase(), updatedAt })
return id
}
function setWarehouseStatus(id: string, status: Warehouse['status']) {
const warehouse = warehouses.value.find((item) => item.id === id)
if (warehouse) {
warehouse.status = status
warehouse.updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
}
}
function saveExpressCompany(payload: Omit<ExpressCompany, 'id' | 'updatedAt'> & { id?: string }) {
const normalizedCode = payload.code.trim().toUpperCase()
const duplicateCode = expressCompanies.value.find((item) => item.code.toLowerCase() === normalizedCode.toLowerCase() && item.id !== payload.id)
if (duplicateCode) throw new Error('快递公司编码已存在')
const duplicateName = expressCompanies.value.find((item) => item.name.trim().toLowerCase() === payload.name.trim().toLowerCase() && item.id !== payload.id)
if (duplicateName) throw new Error('快递公司名称已存在')
const updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
if (payload.id) {
const index = expressCompanies.value.findIndex((item) => item.id === payload.id)
if (index >= 0) expressCompanies.value[index] = { ...expressCompanies.value[index], ...payload, code: normalizedCode, updatedAt } as ExpressCompany
return payload.id
}
const id = nextId('E', expressCompanies.value)
expressCompanies.value.unshift({ ...payload, id, code: normalizedCode, updatedAt })
return id
}
function setExpressCompanyStatus(id: string, status: ExpressCompany['status']) {
const company = expressCompanies.value.find((item) => item.id === id)
if (!company) return
company.status = status
company.updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
}
function saveCounterparty(payload: Omit<Counterparty, 'id' | 'updatedAt'> & { id?: string }) {
const normalizedCode = payload.code.trim().toUpperCase()
const duplicate = counterparties.value.find((item) => item.code.toLowerCase() === normalizedCode.toLowerCase() && item.id !== payload.id)
if (duplicate) throw new Error('往来单位编码已存在')
const updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
if (payload.id) {
const index = counterparties.value.findIndex((item) => item.id === payload.id)
if (index >= 0) counterparties.value[index] = { ...counterparties.value[index], ...payload, code: normalizedCode, types: [...payload.types], updatedAt } as Counterparty
return payload.id
}
const id = nextId('CP', counterparties.value)
counterparties.value.unshift({ ...payload, id, code: normalizedCode, types: [...payload.types], updatedAt })
return id
}
function setCounterpartyStatus(id: string, status: Counterparty['status']) {
const counterparty = counterparties.value.find((item) => item.id === id)
if (!counterparty) return
counterparty.status = status
counterparty.updatedAt = new Date().toISOString().slice(0, 16).replace('T', ' ')
}
function saveUser(payload: Omit<InternalUser, 'id' | 'lastLogin' | 'createdAt'> & { id?: string }) {
if (payload.id) {
const index = users.value.findIndex((item) => item.id === payload.id)
if (index >= 0) users.value[index] = { ...users.value[index], ...payload } as InternalUser
return payload.id
}
const id = nextId('U', users.value)
users.value.unshift({ ...payload, id, lastLogin: '-', createdAt: new Date().toISOString().slice(0, 10) })
syncRoleCounts()
return id
}
function setUserStatus(id: string, status: InternalUser['status']) {
const user = users.value.find((item) => item.id === id)
if (user && user.account !== 'admin') user.status = status
}
function saveRole(payload: Omit<Role, 'id' | 'userCount'> & { id?: string }) {
if (payload.id) {
const index = roles.value.findIndex((item) => item.id === payload.id)
if (index >= 0) roles.value[index] = { ...roles.value[index], ...payload } as Role
syncRoleCounts()
return payload.id
}
const id = nextId('R', roles.value)
roles.value.push({ ...payload, id, userCount: 0 })
return id
}
function removeRole(id: string) {
const role = roles.value.find((item) => item.id === id)
if (!role || role.builtIn || users.value.some((item) => item.roleIds.includes(id))) return false
roles.value = roles.value.filter((item) => item.id !== id)
return true
}
function syncRoleCounts() {
roles.value.forEach((role) => {
role.userCount = users.value.filter((user) => user.roleIds.includes(role.id)).length
})
}
function savePermission(payload: Omit<Permission, 'id'> & { id?: string }) {
if (payload.id) {
const index = permissions.value.findIndex((item) => item.id === payload.id)
if (index >= 0) permissions.value[index] = { ...permissions.value[index], ...payload } as Permission
return payload.id
}
const id = nextId('P', permissions.value)
permissions.value.push({ ...payload, id })
return id
}
function saveOrder(payload: Omit<FirstLegOrder, 'id' | 'no' | 'createdAt' | 'timeline' | 'receivingWarehouseSnapshot'> & { id?: string }) {
const existing = payload.id ? orders.value.find((item) => item.id === payload.id) : undefined
const warehouse = warehouses.value.find((item) => item.id === payload.receivingWarehouseId)
const keepsExistingWarehouse = existing?.receivingWarehouseId === payload.receivingWarehouseId
if (!warehouse || (warehouse.status !== 'ACTIVE' && !keepsExistingWarehouse)) throw new Error('所选收货仓当前不可用于新订单')
const receivingWarehouseSnapshot = keepsExistingWarehouse && existing?.receivingWarehouseSnapshot
? existing.receivingWarehouseSnapshot
: createPlatformReceivingWarehouseSnapshot(warehouse)
if (payload.id) {
const index = orders.value.findIndex((item) => item.id === payload.id)
if (index >= 0) {
const previous = orders.value[index]
const keepsPendingReview = previous.status === '待审核'
const time = new Date().toISOString().slice(0, 16).replace('T', ' ')
const timeline = [...previous.timeline]
if (keepsPendingReview) timeline.push({
time,
title: '后台修改待审核订单',
description: '平台已修正订单资料,订单继续保持待审核;本次修改已记录审计。',
operator: payload.operator === '未分配' ? '平台管理员' : payload.operator,
})
orders.value[index] = {
...previous,
...payload,
status: keepsPendingReview ? '待审核' : payload.status,
receivingWarehouseSnapshot,
timeline,
} as FirstLegOrder
}
return payload.id
}
const id = nextId('O', orders.value)
const no = `BY${new Date().toISOString().slice(0, 10).replace(/-/g, '')}${String(orders.value.length + 1).padStart(3, '0')}`
orders.value.unshift({
...payload,
receivingWarehouseSnapshot,
id,
no,
createdAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
timeline: [{ time: new Date().toISOString().slice(0, 16).replace('T', ' '), title: '后台创建订单', description: '平台工作人员创建头程订单。', operator: '平台管理员' }],
})
return id
}
function updateOrderStatus(id: string, status: OrderStatus, operator: string, note: string) {
const order = orders.value.find((item) => item.id === id)
if (!order) return
const transitions: Record<OrderStatus, OrderStatus[]> = { 草稿: ['待审核', '已取消'], 待审核: ['已受理', '已取消'], 已受理: ['已订舱', '已取消'], 已订舱: ['运输中', '已取消'], 运输中: ['已完成', '已取消'], 已完成: [], 已取消: [] }
if (status !== order.status && !transitions[order.status].includes(status)) throw new Error(`订单不能从“${order.status}”直接变更为“${status}”`)
order.status = status
order.operator = operator || order.operator
order.timeline.push({
time: new Date().toISOString().slice(0, 16).replace('T', ' '),
title: `状态更新为${status}`,
description: note || '平台更新订单处理状态。',
operator: operator || '平台管理员',
})
}
function recordCustomerModificationContact(id: string, operator: string) {
const order = orders.value.find((item) => item.id === id)
if (!order || order.status !== '待审核') throw new Error('仅待审核订单可记录联系客户修改')
order.timeline.push({ time: new Date().toISOString().slice(0, 16).replace('T', ' '), title: '联系客户修改订单', description: '已联系客户补充或修正订单资料,订单继续保持待审核。', operator })
}
function saveOrderDeliveryInfo(id: string, expressCompanyId: string, trackingNo: string, operator: string) {
const order = orders.value.find((item) => item.id === id)
const company = expressCompanies.value.find((item) => item.id === expressCompanyId)
if (!order) throw new Error('订单不存在')
if (!company) throw new Error('快递公司不存在')
if (company.status !== '合作中' && order.deliveryInfo?.expressCompanyId !== company.id) throw new Error('停用快递公司不能新增到履约信息')
const time = new Date().toISOString().slice(0, 16).replace('T', ' ')
order.deliveryInfo = {
expressCompanyId: company.id,
expressCompanyCode: company.code,
expressCompanyName: company.name,
trackingNo: trackingNo.trim(),
updatedAt: time,
operator,
}
order.timeline.push({
time,
title: '更新履约快递信息',
description: `${company.name} · ${trackingNo.trim()}`,
operator,
})
}
return {
customers,
users,
roles,
permissions,
warehouses,
expressCompanies,
counterparties,
orders,
activeUsers,
pendingCustomers,
activeWarehouses,
activeExpressCompanies,
activeCounterparties,
pendingOrders,
saveCustomer,
setCustomerStatus,
saveWarehouse,
setWarehouseStatus,
saveExpressCompany,
setExpressCompanyStatus,
saveCounterparty,
setCounterpartyStatus,
saveUser,
setUserStatus,
saveRole,
removeRole,
savePermission,
saveOrder,
updateOrderStatus,
recordCustomerModificationContact,
saveOrderDeliveryInfo,
}
})
+150
View File
@@ -0,0 +1,150 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import type { CurrentUser } from '@chongchong/shared'
import { http, registerAuthRefreshHandler, unwrapApiResponse, type ApiEnvelope } from '@/api/http'
import { listPlatformPermissions, type PlatformPermissionResource } from '@/api/platformIam'
import { decodeJwtClaims } from '@/utils/jwt'
const TOKEN_KEY = 'cc-admin-token'
const REFRESH_TOKEN_KEY = 'cc-admin-refresh-token'
const PERMISSION_CODES_KEY = 'cc-admin-permission-codes'
interface LoginResponse {
tokenType: string
accessToken: string
expiresIn: number
refreshToken: string
refreshExpiresIn: number
}
function sessionFromToken(token: string) {
const claims = decodeJwtClaims(token)
if (claims.app_code !== 'PLATFORM') throw new Error('账号不属于当前应用端')
if (claims.exp && claims.exp * 1000 <= Date.now()) throw new Error('登录状态已过期,请重新登录')
return {
user: {
id: String(claims.user_id ?? ''),
name: claims.username || '平台用户',
account: claims.username || '',
roles: claims.roles ?? [],
} satisfies CurrentUser,
permissions: claims.permissions ?? [],
}
}
export const useAuthStore = defineStore('admin-auth', () => {
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
const refreshToken = ref(localStorage.getItem(REFRESH_TOKEN_KEY) || '')
const user = ref<CurrentUser | null>(null)
const permissions = ref<string[]>([])
const permissionResources = ref<PlatformPermissionResource[]>([])
const isServerSession = ref(false)
if (token.value) {
try {
const session = sessionFromToken(token.value)
const cachedPermissions = JSON.parse(localStorage.getItem(PERMISSION_CODES_KEY) || '[]') as unknown
if (!Array.isArray(cachedPermissions)) throw new Error('权限缓存格式无效')
user.value = session.user
permissions.value = session.permissions.filter((code) => cachedPermissions.includes(code))
isServerSession.value = true
} catch {
token.value = ''
refreshToken.value = ''
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(PERMISSION_CODES_KEY)
}
}
const isAuthenticated = computed(() => Boolean(token.value))
const permissionCodes = computed<ReadonlySet<string>>(() => new Set(permissions.value))
async function applySession(result: LoginResponse) {
const session = sessionFromToken(result.accessToken)
token.value = result.accessToken
refreshToken.value = result.refreshToken
user.value = session.user
isServerSession.value = true
localStorage.setItem(TOKEN_KEY, token.value)
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken.value)
try {
permissionResources.value = await listPlatformPermissions()
const activeResourceCodes = new Set(permissionResources.value
.filter((resource) => resource.status === 'ACTIVE')
.map((resource) => resource.permissionCode))
permissions.value = session.permissions.filter((code) => activeResourceCodes.has(code))
localStorage.setItem(PERMISSION_CODES_KEY, JSON.stringify(permissions.value))
} catch (error) {
clearSession()
throw error
}
}
function clearSession() {
token.value = ''
refreshToken.value = ''
user.value = null
permissions.value = []
permissionResources.value = []
isServerSession.value = false
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_TOKEN_KEY)
localStorage.removeItem(PERMISSION_CODES_KEY)
}
async function login(account: string, password: string) {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/login',
{ username: account, password },
)
const result = unwrapApiResponse<LoginResponse>(response)
await applySession(result)
}
async function refreshSession() {
if (!refreshToken.value) throw new Error('刷新令牌不存在,请重新登录')
try {
const response = await http.post<ApiEnvelope<LoginResponse>, ApiEnvelope<LoginResponse>>(
'/auth/refresh',
{ refreshToken: refreshToken.value },
)
const result = unwrapApiResponse<LoginResponse>(response)
await applySession(result)
} catch (error) {
clearSession()
throw error
}
}
registerAuthRefreshHandler(async () => {
await refreshSession()
if (!token.value) throw new Error('登录状态刷新失败')
return token.value
})
function logout() {
const accessToken = token.value
const tokenToRevoke = refreshToken.value
clearSession()
if (accessToken && tokenToRevoke) {
void http.post('/auth/logout', { refreshToken: tokenToRevoke }, {
headers: { Authorization: `Bearer ${accessToken}` },
}).catch(() => undefined)
}
}
return {
token,
refreshToken,
user,
permissions,
permissionResources,
isServerSession,
permissionCodes,
isAuthenticated,
login,
refreshSession,
logout,
}
})
+83
View File
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
describe('unsaved work store', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('blocks dirty work only when a role switch removes permission or leaves the page', () => {
const store = useUnsavedWorkStore()
store.upsert({
key: 'customer-form',
label: '客户资料',
dirty: true,
requiredPermission: 'customer:create',
})
store.upsert({
key: 'order-form',
label: '头程订单',
dirty: true,
requiredPermission: 'first-leg:create',
})
expect(store.findBlocking(new Set(['customer:create', 'first-leg:create']), false)).toEqual([])
expect(store.findBlocking(new Set(), false).map((entry) => entry.key)).toEqual([
'customer-form',
'order-form',
])
expect(store.findBlocking(new Set(['customer:create', 'first-leg:create']), true)
.map((entry) => entry.key)).toEqual(['customer-form', 'order-form'])
})
it('does not block clean work', () => {
const store = useUnsavedWorkStore()
store.upsert({
key: 'customer-form',
label: '客户资料',
dirty: false,
requiredPermission: 'customer:create',
})
expect(store.findBlocking(new Set(), true)).toEqual([])
})
it('removes an entry by key', () => {
const store = useUnsavedWorkStore()
store.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null })
store.remove('customer-form')
expect(store.entries).toEqual([])
})
it('replaces an existing entry with the same key', () => {
const store = useUnsavedWorkStore()
store.upsert({ key: 'customer-form', label: '旧名称', dirty: false, requiredPermission: null })
store.upsert({
key: 'customer-form',
label: '客户资料',
dirty: true,
requiredPermission: 'customer:create',
})
expect(store.entries).toEqual([{
key: 'customer-form',
label: '客户资料',
dirty: true,
requiredPermission: 'customer:create',
}])
})
it('clears every registered entry', () => {
const store = useUnsavedWorkStore()
store.upsert({ key: 'customer-form', label: '客户资料', dirty: true, requiredPermission: null })
store.upsert({ key: 'order-form', label: '头程订单', dirty: true, requiredPermission: null })
store.clear()
expect(store.entries).toEqual([])
})
})
+40
View File
@@ -0,0 +1,40 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
export interface UnsavedWorkEntry {
key: string
label: string
dirty: boolean
requiredPermission: string | null
}
export const useUnsavedWorkStore = defineStore('unsaved-work', () => {
const entries = ref<UnsavedWorkEntry[]>([])
function upsert(entry: UnsavedWorkEntry) {
const index = entries.value.findIndex((item) => item.key === entry.key)
if (index === -1) {
entries.value.push(entry)
return
}
entries.value[index] = entry
}
function remove(key: string) {
const index = entries.value.findIndex((item) => item.key === key)
if (index !== -1) entries.value.splice(index, 1)
}
function findBlocking(candidatePermissions: ReadonlySet<string>, willLeave: boolean) {
return entries.value.filter((entry) => entry.dirty && (
willLeave
|| (entry.requiredPermission !== null && !candidatePermissions.has(entry.requiredPermission))
))
}
function clear() {
entries.value = []
}
return { entries, upsert, remove, findBlocking, clear }
})