chore: initial commit
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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('所选收货仓当前不可用于新订单')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
Reference in New Issue
Block a user