修复bug
This commit is contained in:
@@ -76,6 +76,26 @@ export interface CreatePlatformPermissionRequest {
|
||||
sortNo: number
|
||||
}
|
||||
|
||||
export interface PlatformInternalUser {
|
||||
membershipId: number | string
|
||||
userId: number | string
|
||||
authUserId: number | string
|
||||
username: string
|
||||
displayName: string
|
||||
memberType: string
|
||||
memberNo: string | null
|
||||
status: 'ACTIVE' | 'DISABLED' | string
|
||||
roleIds: Array<number | string>
|
||||
}
|
||||
|
||||
export interface CreatePlatformInternalUserRequest {
|
||||
authUserId: number | string
|
||||
userType: string
|
||||
displayName: string
|
||||
phone: string
|
||||
email: string
|
||||
}
|
||||
|
||||
async function getData<T>(path: string): Promise<T> {
|
||||
const response = await http.get<ApiEnvelope<T>, ApiEnvelope<T>>(path)
|
||||
return unwrapApiResponse(response)
|
||||
@@ -163,6 +183,14 @@ export function createPlatformPermission(payload: CreatePlatformPermissionReques
|
||||
return postData<PlatformPermissionResource>('/platform/v1/iam/permissions', payload)
|
||||
}
|
||||
|
||||
export function listPlatformInternalUsers() {
|
||||
return getData<PlatformInternalUser[]>('/platform/v1/iam/internal-users')
|
||||
}
|
||||
|
||||
export function createPlatformInternalUser(payload: CreatePlatformInternalUserRequest) {
|
||||
return postData<PlatformInternalUser>('/platform/v1/iam/internal-users', payload)
|
||||
}
|
||||
|
||||
export function approveEnterpriseReview(reviewId: number, comment: string) {
|
||||
return postData<unknown>(`/platform/v1/iam/reviews/${reviewId}/approve`, { comment })
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Vendored
-4
@@ -36,8 +36,6 @@ declare module 'vue' {
|
||||
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
|
||||
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElStep: typeof import('element-plus/es')['ElStep']
|
||||
ElSteps: typeof import('element-plus/es')['ElSteps']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
@@ -47,10 +45,8 @@ declare module 'vue' {
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
MetricStrip: typeof import('./components/MetricStrip.vue')['default']
|
||||
PlatformFirstLegOrdersPanel: typeof import('./components/PlatformFirstLegOrdersPanel.vue')['default']
|
||||
RolePreviewSwitcher: typeof import('./components/RolePreviewSwitcher.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
StaticBatchImportDialog: typeof import('./components/StaticBatchImportDialog.vue')['default']
|
||||
StatusBadge: typeof import('./components/StatusBadge.vue')['default']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
|
||||
@@ -297,7 +297,7 @@ onMounted(() => {
|
||||
<el-button v-if="tenantId" link type="danger" @click="loadOrders(true)">重新加载</el-button>
|
||||
</el-alert>
|
||||
<el-alert v-else type="info" :closable="false" show-icon>
|
||||
当前页面已直连 Order 服务。后端尚未提供平台代客创建、平台编辑及履约任务查询接口,因此真实会话不显示这些静态操作。
|
||||
当前页面已直连 Order 服务。后端尚未提供平台代客创建、平台编辑及履约任务查询接口,因此相关操作暂不可用。
|
||||
</el-alert>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Role } from '@/types/admin'
|
||||
import RolePreviewSwitcher from './RolePreviewSwitcher.vue'
|
||||
|
||||
const roles: Role[] = [
|
||||
{ id: 'R001', name: '超级管理员', code: 'SUPER_ADMIN', description: '', userCount: 1, dataScope: '全部数据', status: '启用', permissionIds: [] },
|
||||
{ id: 'R003', name: '操作员', code: 'OPERATOR', description: '', userCount: 2, dataScope: '本部门数据', status: '启用', permissionIds: [] },
|
||||
]
|
||||
|
||||
const stubs = {
|
||||
'el-dropdown': {
|
||||
name: 'ElDropdownStub',
|
||||
emits: ['command'],
|
||||
template: '<section data-dropdown><slot /><slot name="dropdown" /></section>',
|
||||
},
|
||||
'el-dropdown-menu': { template: '<div data-menu><slot /></div>' },
|
||||
'el-dropdown-item': {
|
||||
props: ['command', 'disabled'],
|
||||
template: '<button data-role :data-command="command" :disabled="disabled"><slot /></button>',
|
||||
},
|
||||
}
|
||||
|
||||
describe('RolePreviewSwitcher', () => {
|
||||
it('announces the active role and explains the static preview boundary', () => {
|
||||
const wrapper = mount(RolePreviewSwitcher, {
|
||||
props: { roles, currentRole: roles[0] },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
const trigger = wrapper.get('button[aria-label]')
|
||||
expect(trigger.attributes('aria-label')).toBe('权限预览:超级管理员,数据范围未模拟')
|
||||
expect(trigger.attributes('title')).toBe('权限预览:超级管理员,数据范围未模拟')
|
||||
expect(wrapper.text()).toContain('权限预览:超级管理员')
|
||||
expect(wrapper.text()).toContain('仅预览菜单、页面和操作;数据范围未模拟')
|
||||
expect(wrapper.find('svg.lucide-user-round-cog').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('disables the current role and emits a string role id for another selection', async () => {
|
||||
const wrapper = mount(RolePreviewSwitcher, {
|
||||
props: { roles, currentRole: roles[0] },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
const items = wrapper.findAll('[data-role]')
|
||||
expect(items[0].attributes('disabled')).toBeDefined()
|
||||
expect(items[1].attributes('disabled')).toBeUndefined()
|
||||
wrapper.getComponent({ name: 'ElDropdownStub' }).vm.$emit('command', 3)
|
||||
|
||||
expect(wrapper.emitted('select')).toEqual([['3']])
|
||||
})
|
||||
|
||||
it('labels the trigger when no enabled role is available', () => {
|
||||
const wrapper = mount(RolePreviewSwitcher, {
|
||||
props: { roles: [], currentRole: null },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.get('button[aria-label]').attributes('aria-label'))
|
||||
.toBe('权限预览:无可用角色,数据范围未模拟')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ChevronDown, UserRoundCog } from '@lucide/vue'
|
||||
import type { Role } from '@/types/admin'
|
||||
|
||||
const props = defineProps<{
|
||||
roles: Role[]
|
||||
currentRole: Role | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [roleId: string]
|
||||
}>()
|
||||
|
||||
const roleName = computed(() => props.currentRole?.name ?? '无可用角色')
|
||||
const accessibleLabel = computed(() => `权限预览:${roleName.value},数据范围未模拟`)
|
||||
|
||||
function handleCommand(command: string | number) {
|
||||
emit('select', String(command))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dropdown :disabled="roles.length === 0" trigger="click" @command="handleCommand">
|
||||
<button
|
||||
class="role-preview-trigger"
|
||||
type="button"
|
||||
:aria-label="accessibleLabel"
|
||||
:title="accessibleLabel"
|
||||
>
|
||||
<UserRoundCog :size="18" aria-hidden="true" />
|
||||
<span class="role-preview-copy">
|
||||
<strong>权限预览:{{ roleName }}</strong>
|
||||
<small>仅预览菜单、页面和操作;数据范围未模拟</small>
|
||||
</span>
|
||||
<ChevronDown :size="15" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="role in roles"
|
||||
:key="role.id"
|
||||
:command="role.id"
|
||||
:disabled="role.id === currentRole?.id"
|
||||
>
|
||||
{{ role.name }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync('src/components/StaticBatchImportDialog.vue', 'utf8')
|
||||
|
||||
describe('StaticBatchImportDialog source contract', () => {
|
||||
it('only performs safe client-side file checks before the backend endpoint exists', () => {
|
||||
expect(source).toContain("fileName.endsWith('.xlsx')")
|
||||
expect(source).toContain('10 * 1024 * 1024')
|
||||
expect(source).toContain('前端不会解析工作表内容')
|
||||
expect(source).toContain('等待后端导入接口')
|
||||
})
|
||||
|
||||
it('does not simulate server validation or a successful import', () => {
|
||||
expect(source).not.toContain('demonstratesFailure')
|
||||
expect(source).not.toContain('静态演示')
|
||||
expect(source).not.toContain("emit('imported'")
|
||||
})
|
||||
})
|
||||
@@ -1,118 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Download, FileCheck2, FileSpreadsheet, Upload, XCircle } from '@lucide/vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
entityLabel: string
|
||||
templateUrl: string
|
||||
templateFileName: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
type ValidationState = 'idle' | 'pending' | 'ready' | 'invalid'
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const selectedFile = ref<File | null>(null)
|
||||
const validationState = ref<ValidationState>('idle')
|
||||
const validationMessage = ref('')
|
||||
const dialogOpen = computed({ get: () => props.modelValue, set: (value) => emit('update:modelValue', value) })
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) return
|
||||
selectedFile.value = null
|
||||
validationState.value = 'idle'
|
||||
validationMessage.value = ''
|
||||
})
|
||||
|
||||
function chooseFile() {
|
||||
inputRef.value?.click()
|
||||
}
|
||||
|
||||
function handleFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
selectedFile.value = input.files?.[0] ?? null
|
||||
validationState.value = selectedFile.value ? 'pending' : 'idle'
|
||||
validationMessage.value = ''
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
function validateFile() {
|
||||
const file = selectedFile.value
|
||||
if (!file) return
|
||||
const fileName = file.name.toLowerCase()
|
||||
if (!fileName.endsWith('.xlsx')) {
|
||||
validationMessage.value = '仅支持标准模板 .xlsx 文件'
|
||||
validationState.value = 'invalid'
|
||||
return
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
validationMessage.value = '文件超过上传上限 10MB'
|
||||
validationState.value = 'invalid'
|
||||
return
|
||||
}
|
||||
validationMessage.value = '扩展名和文件大小符合上传要求'
|
||||
validationState.value = 'ready'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="dialogOpen" :title="`${entityLabel}批量导入`" width="min(680px, 94vw)" class="batch-import-dialog" :close-on-click-modal="false">
|
||||
<div class="import-guidance">
|
||||
<FileSpreadsheet :size="22" />
|
||||
<div><strong>只新增,不更新或覆盖已有档案</strong><span>前端只检查文件类型和大小;模板内容、重复数据及整批事务由后端校验。</span></div>
|
||||
<a :href="templateUrl" :download="templateFileName"><Download :size="15" />下载标准模板</a>
|
||||
</div>
|
||||
|
||||
<input ref="inputRef" class="visually-hidden" type="file" accept=".xlsx" @change="handleFile" />
|
||||
<button type="button" class="import-dropzone" @click="chooseFile">
|
||||
<Upload :size="24" />
|
||||
<strong>{{ selectedFile ? selectedFile.name : '选择 .xlsx 导入文件' }}</strong>
|
||||
<span>{{ selectedFile ? `${(selectedFile.size / 1024).toFixed(1)} KB · 点击重新选择` : '仅支持 .xlsx,最大 10MB' }}</span>
|
||||
</button>
|
||||
|
||||
<div v-if="validationState === 'pending'" class="import-result is-pending">
|
||||
<FileSpreadsheet :size="20" /><div><strong>文件已选择,等待基础检查</strong><span>点击“检查文件”确认扩展名和文件大小;前端不会解析工作表内容。</span></div>
|
||||
</div>
|
||||
<div v-else-if="validationState === 'ready'" class="import-result is-passed" role="status">
|
||||
<FileCheck2 :size="20" /><div><strong>文件基础检查通过</strong><span>{{ validationMessage }}。后端导入接口尚未提供,文件没有上传,也没有写入任何业务数据。</span></div>
|
||||
</div>
|
||||
<div v-else-if="validationState === 'invalid'" class="import-result is-failed" role="alert">
|
||||
<XCircle :size="20" /><div><strong>文件基础检查未通过</strong><span>{{ validationMessage }};当前文件没有上传。</span></div>
|
||||
</div>
|
||||
|
||||
<p class="server-boundary">后端接口接入后,此处将展示上传进度、整批校验结果、逐行错误和错误报告下载;任一行错误时后端必须保证 0 行写入。</p>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogOpen = false">取消</el-button>
|
||||
<el-button :disabled="!selectedFile" @click="validateFile">检查文件</el-button>
|
||||
<el-button type="primary" disabled>等待后端导入接口</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.import-guidance { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 12px; padding: 14px 16px; background: #f6f8fb; color: var(--cc-primary); }
|
||||
.import-guidance > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.import-guidance strong { color: #172033; font-size: 13px; }
|
||||
.import-guidance span { color: var(--cc-muted); font-size: 11px; line-height: 1.55; }
|
||||
.import-guidance a { display: inline-flex; align-items: center; gap: 5px; color: var(--cc-primary); font-size: 12px; font-weight: 700; text-decoration: none; }
|
||||
.import-dropzone { display: flex; width: 100%; min-height: 132px; margin-top: 16px; align-items: center; justify-content: center; border: 1px dashed #aab6c8; background: #fff; color: var(--cc-primary); cursor: pointer; flex-direction: column; gap: 8px; }
|
||||
.import-dropzone:hover { border-color: var(--cc-primary); background: #f8faff; }
|
||||
.import-dropzone:focus-visible { outline: 3px solid rgba(21, 94, 239, .2); outline-offset: 2px; }
|
||||
.import-dropzone strong { color: #172033; font-size: 13px; }
|
||||
.import-dropzone span { color: var(--cc-muted); font-size: 11px; }
|
||||
.import-result { display: flex; align-items: center; gap: 10px; margin-top: 14px; padding: 13px 14px; }
|
||||
.import-result > div { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
|
||||
.import-result strong { font-size: 13px; }
|
||||
.import-result span { font-size: 11px; line-height: 1.5; }
|
||||
.import-result.is-pending { background: #f5f7fa; color: #475467; }
|
||||
.import-result.is-passed { background: #ecf8f2; color: #137a4b; }
|
||||
.import-result.is-failed { background: #fff1f1; color: #b42318; }
|
||||
.server-boundary { margin: 14px 0 0; color: var(--cc-muted); font-size: 11px; line-height: 1.65; }
|
||||
.visually-hidden { position: fixed; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
||||
@media (max-width: 600px) { .import-guidance { grid-template-columns: auto 1fr; } .import-guidance a { grid-column: 2; justify-self: start; } }
|
||||
</style>
|
||||
+19
-311
@@ -1,341 +1,49 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { nextTick } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ApiResponseError } from '@/api/http'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
|
||||
import AppLayout from './AppLayout.vue'
|
||||
|
||||
vi.mock('element-plus', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('element-plus')>()
|
||||
return {
|
||||
...actual,
|
||||
ElMessage: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
|
||||
ElMessageBox: { confirm: vi.fn() },
|
||||
}
|
||||
return { ...actual, ElMessage: { warning: vi.fn(), error: vi.fn() }, ElMessageBox: { confirm: vi.fn() } }
|
||||
})
|
||||
|
||||
const page = { template: '<p>page</p>' }
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
async function mountLayout(path: string) {
|
||||
async function mountLayout() {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'test-access-token'
|
||||
auth.user = { id: '1', name: '平台管理员', account: 'platform_admin', roles: ['PLATFORM_ADMIN'] }
|
||||
localStorage.setItem('cc-admin-token', auth.token)
|
||||
auth.token = 'token'
|
||||
auth.user = { id: '1', name: '平台管理员', account: 'admin', roles: ['PLATFORM_ADMIN'] }
|
||||
auth.permissions = ['platform:warehouse:list']
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', component: page, meta: { title: '管理后台' } },
|
||||
{ path: '/customers', component: page, meta: { title: '客户管理', permission: 'platform:customer:list' } },
|
||||
{ path: '/warehouses', component: page, meta: { title: '仓库管理', permission: 'platform:warehouse:list' } },
|
||||
{ path: '/orders/first-leg', component: page, meta: { title: '头程订单', permission: 'platform:order:list' } },
|
||||
{ path: '/system/users', component: page, meta: { title: '内部用户', permission: 'platform:user:list' } },
|
||||
{ path: '/403', component: page, meta: { title: '权限不足' } },
|
||||
{ path: '/login', component: page, meta: { title: '登录' } },
|
||||
],
|
||||
routes: [{ path: '/', component: { template: '<p>page</p>' } }, { path: '/login', component: { template: '<p>login</p>' } }],
|
||||
})
|
||||
await router.push(path)
|
||||
await router.push('/')
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(AppLayout, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
stubs: {
|
||||
AdminSidebarMenu: { props: ['items'], template: '<nav data-sidebar :data-count="items.length" />' },
|
||||
RolePreviewSwitcher: {
|
||||
props: ['roles', 'currentRole'],
|
||||
emits: ['select'],
|
||||
template: '<div><span data-preview>{{ currentRole?.name }}</span><button data-switch-r003 @click="$emit(\'select\', \'R003\')">switch R003</button><button data-switch-r005 @click="$emit(\'select\', \'R005\')">switch R005</button></div>',
|
||||
},
|
||||
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
||||
},
|
||||
},
|
||||
global: { plugins: [pinia, router], stubs: { AdminSidebarMenu: { template: '<nav />' }, 'el-button': { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' } } },
|
||||
})
|
||||
await nextTick()
|
||||
return {
|
||||
wrapper,
|
||||
router,
|
||||
auth,
|
||||
access: useAccessPreviewStore(),
|
||||
data: useAdminDataStore(),
|
||||
unsaved: useUnsavedWorkStore(),
|
||||
}
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
describe('AppLayout role preview coordination', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.mocked(ElMessageBox.confirm).mockReset()
|
||||
vi.mocked(ElMessage.success).mockReset()
|
||||
vi.mocked(ElMessage.warning).mockReset()
|
||||
vi.mocked(ElMessage.error).mockReset()
|
||||
document.body.innerHTML = ''
|
||||
describe('AppLayout', () => {
|
||||
beforeEach(() => vi.mocked(ElMessageBox.confirm).mockReset())
|
||||
|
||||
it('shows the authenticated identity without a role preview switcher', async () => {
|
||||
const { wrapper } = await mountLayout()
|
||||
expect(wrapper.text()).toContain('登录身份:平台管理员(admin)')
|
||||
expect(wrapper.text()).not.toContain('权限预览')
|
||||
})
|
||||
|
||||
it('keeps the login identity visibly separate from the preview role', async () => {
|
||||
const { wrapper } = await mountLayout('/')
|
||||
|
||||
expect(wrapper.get('[aria-label="登录身份:平台管理员(platform_admin)"]').text())
|
||||
.toBe('登录身份:平台管理员(platform_admin)')
|
||||
expect(wrapper.get('[data-preview]').text()).toBe('超级管理员')
|
||||
})
|
||||
|
||||
it('commits a role and replaces an inaccessible system route with its first leaf', async () => {
|
||||
const { wrapper, router, access } = await mountLayout('/system/users')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
expect(replace).toHaveBeenCalledWith('/warehouses')
|
||||
expect(router.currentRoute.value.path).toBe('/warehouses')
|
||||
expect(wrapper.get('[aria-live="polite"]').text()).toContain('操作员')
|
||||
expect(wrapper.get('[aria-live="polite"]').text()).toContain('仓库管理')
|
||||
expect(ElMessage.success).toHaveBeenCalledWith('已切换至“操作员”,当前仅为静态权限预览')
|
||||
expect(document.activeElement).toBe(wrapper.get('h1').element)
|
||||
expect(wrapper.get('[data-sidebar]').attributes('data-count'))
|
||||
.toBe(String(access.filteredNavigation.length))
|
||||
expect(access.filteredNavigation).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('recovers from 403 to the first accessible leaf after a role change', async () => {
|
||||
const { wrapper, router, access } = await mountLayout('/403')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
expect(replace).toHaveBeenCalledWith('/warehouses')
|
||||
})
|
||||
|
||||
it('keeps a queried 403 route when the candidate role has no accessible page', async () => {
|
||||
const { wrapper, router, access, data } = await mountLayout('/403?from=/system/users')
|
||||
data.roles.find((role) => role.id === 'R003')!.permissionIds = []
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
expect(router.currentRoute.value.fullPath).toBe('/403?from=/system/users')
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(wrapper.get('[aria-live="polite"]').text()).toBe('权限预览已切换为操作员')
|
||||
})
|
||||
|
||||
it('keeps role, route and dirty entry unchanged when role switching is cancelled', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||
const { wrapper, router, access, unsaved } = await mountLayout('/system/users')
|
||||
unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' })
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/切换到操作员.*将离开内部用户.*内部用户编辑表单/),
|
||||
expect.any(String),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
expect(router.currentRoute.value.path).toBe('/system/users')
|
||||
expect(unsaved.entries).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('warns that same-page forms will lose save permission without claiming navigation', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockRejectedValueOnce('cancel')
|
||||
const { wrapper, router, access, unsaved } = await mountLayout('/orders/first-leg')
|
||||
unsaved.upsert({ key: 'restricted-form', label: '受限订单表单', dirty: true, requiredPermission: 'system:user:write' })
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const message = String(vi.mocked(ElMessageBox.confirm).mock.calls[0]?.[0])
|
||||
expect(message).toContain('切换到操作员')
|
||||
expect(message).toContain('头程订单')
|
||||
expect(message).toContain('将失去保存权限')
|
||||
expect(message).toContain('受限订单表单')
|
||||
expect(message).not.toContain('将离开头程订单')
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
expect(router.currentRoute.value.path).toBe('/orders/first-leg')
|
||||
})
|
||||
|
||||
it('commits only after dirty-work confirmation succeeds', async () => {
|
||||
let resolveConfirmation: (() => void) | undefined
|
||||
vi.mocked(ElMessageBox.confirm).mockReturnValueOnce(new Promise((resolve) => {
|
||||
resolveConfirmation = () => resolve({} as never)
|
||||
}))
|
||||
const { wrapper, access, unsaved } = await mountLayout('/system/users')
|
||||
unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' })
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await nextTick()
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
|
||||
resolveConfirmation?.()
|
||||
await flushPromises()
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
})
|
||||
|
||||
it('ignores another role selection while the first confirmation is pending', async () => {
|
||||
let resolveConfirmation: (() => void) | undefined
|
||||
vi.mocked(ElMessageBox.confirm).mockReturnValueOnce(new Promise((resolve) => {
|
||||
resolveConfirmation = () => resolve({} as never)
|
||||
}))
|
||||
const { wrapper, access, unsaved } = await mountLayout('/system/users')
|
||||
unsaved.upsert({ key: 'user-form', label: '内部用户编辑表单', dirty: true, requiredPermission: 'system:user:write' })
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await wrapper.get('[data-switch-r005]').trigger('click')
|
||||
await nextTick()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledTimes(1)
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
resolveConfirmation?.()
|
||||
await flushPromises()
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
})
|
||||
|
||||
it('reconfirms when a new blocker appears while the first confirmation is pending', async () => {
|
||||
let resolveFirst: (() => void) | undefined
|
||||
let resolveSecond: (() => void) | undefined
|
||||
vi.mocked(ElMessageBox.confirm)
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveFirst = () => resolve({} as never) }))
|
||||
.mockReturnValueOnce(new Promise((resolve) => { resolveSecond = () => resolve({} as never) }))
|
||||
const { wrapper, access, unsaved } = await mountLayout('/orders/first-leg')
|
||||
unsaved.upsert({ key: 'first-form', label: '第一张表单', dirty: true, requiredPermission: 'system:user:write' })
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await nextTick()
|
||||
unsaved.upsert({ key: 'second-form', label: '新增表单', dirty: true, requiredPermission: 'customer:create' })
|
||||
resolveFirst?.()
|
||||
await flushPromises()
|
||||
|
||||
expect(ElMessageBox.confirm).toHaveBeenCalledTimes(2)
|
||||
expect(String(vi.mocked(ElMessageBox.confirm).mock.calls[1]?.[0])).toContain('新增表单')
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
resolveSecond?.()
|
||||
await flushPromises()
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
})
|
||||
|
||||
it('replaces the route when live permission changes make the current page inaccessible', async () => {
|
||||
const { router, access, data } = await mountLayout('/system/users')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
const permission = data.permissions.find((item) => item.code === 'platform:user:list')
|
||||
if (!permission) throw new Error('fixture permission missing')
|
||||
|
||||
permission.status = '停用'
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
expect(replace).toHaveBeenCalledWith('/customers')
|
||||
expect(document.activeElement).toBe(document.querySelector('h1'))
|
||||
expect(document.querySelector('[aria-live="polite"]')?.textContent)
|
||||
.toBe('当前页面权限已变化,已前往客户管理')
|
||||
})
|
||||
|
||||
it('does not replace or announce navigation when live access remains on queried 403', async () => {
|
||||
const { router, data } = await mountLayout('/403?from=/system/users')
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
|
||||
data.permissions.forEach((permission) => { permission.status = '停用' })
|
||||
await nextTick()
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/403?from=/system/users')
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
expect(document.querySelector('[aria-live="polite"]')?.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('rolls back the role and reports an error when correction navigation fails', async () => {
|
||||
const { wrapper, router, access } = await mountLayout('/system/users')
|
||||
vi.spyOn(router, 'replace').mockRejectedValueOnce(new Error('navigation failed'))
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(access.currentRole?.id).toBe('R001')
|
||||
expect(router.currentRoute.value.path).toBe('/system/users')
|
||||
expect(ElMessage.error).toHaveBeenCalledWith('权限预览切换失败,已恢复原角色')
|
||||
expect(ElMessage.success).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.get('[data-switch-r003]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(access.currentRole?.id).toBe('R003')
|
||||
})
|
||||
|
||||
it('clears preview and unsaved state before logging out and replacing with login', async () => {
|
||||
it('logs out after confirmation', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
|
||||
const { wrapper, router, auth, access, unsaved } = await mountLayout('/')
|
||||
unsaved.upsert({ key: 'draft', label: '草稿', dirty: true, requiredPermission: null })
|
||||
const calls: string[] = []
|
||||
const resetAccess = access.reset
|
||||
const clearUnsaved = unsaved.clear
|
||||
const logout = auth.logout
|
||||
vi.spyOn(access, 'reset').mockImplementation(() => { calls.push('access'); resetAccess() })
|
||||
vi.spyOn(unsaved, 'clear').mockImplementation(() => { calls.push('unsaved'); clearUnsaved() })
|
||||
vi.spyOn(auth, 'logout').mockImplementation(() => { calls.push('auth'); return logout() })
|
||||
const replace = vi.spyOn(router, 'replace')
|
||||
|
||||
const { wrapper, router } = await mountLayout()
|
||||
await wrapper.get('[aria-label="退出登录"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(calls).toEqual(['access', 'unsaved', 'auth'])
|
||||
expect(replace).toHaveBeenCalledWith('/login')
|
||||
})
|
||||
|
||||
it('keeps cleared logout state and reports a login navigation failure', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
|
||||
const { wrapper, router, auth, access, unsaved } = await mountLayout('/')
|
||||
unsaved.upsert({ key: 'draft', label: '草稿', dirty: true, requiredPermission: null })
|
||||
vi.spyOn(router, 'replace').mockRejectedValueOnce(new Error('navigation failed'))
|
||||
|
||||
await wrapper.get('[aria-label="退出登录"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(auth.isAuthenticated).toBe(false)
|
||||
expect(access.currentRole).toBeNull()
|
||||
expect(unsaved.entries).toHaveLength(0)
|
||||
expect(ElMessage.error).toHaveBeenCalledWith('已退出登录,但登录页跳转失败,请刷新页面')
|
||||
expect(router.currentRoute.value.path).toBe('/')
|
||||
expect(router.replace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the local logout and reports server revocation failure with a tracking id', async () => {
|
||||
vi.mocked(ElMessageBox.confirm).mockResolvedValueOnce({} as never)
|
||||
const { wrapper, router, auth } = await mountLayout('/')
|
||||
vi.spyOn(auth, 'logout').mockImplementation(async () => {
|
||||
auth.token = ''
|
||||
auth.refreshToken = ''
|
||||
throw new ApiResponseError('服务不可用', 'S0503', 503, undefined, 'request-logout')
|
||||
})
|
||||
|
||||
await wrapper.get('[aria-label="退出登录"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/login')
|
||||
expect(ElMessage.warning).toHaveBeenCalledWith('本地已退出,但服务器会话撤销失败(追踪标识:request-logout)')
|
||||
})
|
||||
})
|
||||
|
||||
+13
-198
@@ -1,18 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
Menu,
|
||||
} from '@lucide/vue'
|
||||
import { PRODUCT_NAME } from '@chongchong/shared'
|
||||
import { ChevronLeft, ChevronRight, LogOut, Menu } from '@lucide/vue'
|
||||
import { getApiErrorDetails } from '@/api/http'
|
||||
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
|
||||
import baiyueMarkWhite from '@/assets/brand/baiyue-mark-white.png'
|
||||
import AdminSidebarMenu from '@/components/AdminSidebarMenu.vue'
|
||||
import RolePreviewSwitcher from '@/components/RolePreviewSwitcher.vue'
|
||||
import { resolvePreviewTarget } from '@/router/accessDecision'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
|
||||
@@ -24,138 +18,11 @@ const access = useAccessPreviewStore()
|
||||
const unsaved = useUnsavedWorkStore()
|
||||
const collapsed = ref(false)
|
||||
const mobileMenuOpen = ref(false)
|
||||
const correctingRoute = ref(false)
|
||||
const roleSwitchPending = ref(false)
|
||||
const loggingOut = ref(false)
|
||||
const pageHeading = ref<HTMLHeadingElement | null>(null)
|
||||
const announcement = ref('')
|
||||
|
||||
access.initializeAfterLogin()
|
||||
|
||||
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
|
||||
const pageTitle = computed(() => route.meta.title || '管理后台')
|
||||
const pageDescription = computed(() => route.meta.description || '')
|
||||
const menuCollapsed = computed(() => collapsed.value && !mobileMenuOpen.value)
|
||||
|
||||
function currentPermission(): string | undefined {
|
||||
return typeof route.meta.permission === 'string' ? route.meta.permission : undefined
|
||||
}
|
||||
|
||||
async function replaceAndFocus(path: string) {
|
||||
await router.replace(path)
|
||||
await nextTick()
|
||||
pageHeading.value?.focus()
|
||||
}
|
||||
|
||||
function getRoleSwitchDecision(roleId: string) {
|
||||
const candidateRole = access.enabledRoles.find((role) => role.id === roleId)
|
||||
if (!candidateRole) return null
|
||||
const candidate = access.previewRole(roleId)
|
||||
const target = resolvePreviewTarget({
|
||||
currentPath: route.path,
|
||||
currentPermission: currentPermission(),
|
||||
candidateCodes: candidate.codes,
|
||||
firstPath: candidate.firstPath,
|
||||
})
|
||||
const willLeave = target !== null && target !== route.path
|
||||
const blockers = unsaved.findBlocking(candidate.codes, willLeave)
|
||||
const blockerSignature = blockers
|
||||
.map((entry) => `${entry.key}:${entry.label}:${entry.requiredPermission ?? ''}`)
|
||||
.sort()
|
||||
.join('|')
|
||||
|
||||
return {
|
||||
candidateRole,
|
||||
target,
|
||||
willLeave,
|
||||
blockers,
|
||||
signature: `${target ?? ''}:${willLeave}:${blockerSignature}`,
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRoleSwitch(decision: NonNullable<ReturnType<typeof getRoleSwitchDecision>>) {
|
||||
const labels = decision.blockers.map((entry) => entry.label).join('、')
|
||||
const currentPage = typeof route.meta.title === 'string' ? route.meta.title : '当前页面'
|
||||
const message = decision.willLeave
|
||||
? `切换到${decision.candidateRole.name}后,将离开${currentPage},并放弃以下未保存内容:${labels}。是否继续?`
|
||||
: `切换到${decision.candidateRole.name}后,${currentPage}中的以下表单将失去保存权限:${labels}。是否继续?`
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
message,
|
||||
'未保存内容',
|
||||
{ confirmButtonText: '放弃并切换', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRoleSwitch(roleId: string) {
|
||||
if (access.currentRole?.id === roleId || roleSwitchPending.value) return
|
||||
roleSwitchPending.value = true
|
||||
|
||||
try {
|
||||
let decision: NonNullable<ReturnType<typeof getRoleSwitchDecision>>
|
||||
let confirmedSignature: string | null = null
|
||||
|
||||
while (true) {
|
||||
const refreshedDecision = getRoleSwitchDecision(roleId)
|
||||
if (!refreshedDecision) return
|
||||
decision = refreshedDecision
|
||||
|
||||
if (decision.blockers.length && decision.signature !== confirmedSignature) {
|
||||
if (!await confirmRoleSwitch(decision)) return
|
||||
confirmedSignature = decision.signature
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
correctingRoute.value = true
|
||||
const currentPage = typeof route.meta.title === 'string' ? route.meta.title : '当前页面'
|
||||
const previousRoleId = access.currentRole?.id ?? null
|
||||
try {
|
||||
if (!access.commitRole(roleId)) return
|
||||
const navigated = Boolean(decision.target && decision.target !== route.path)
|
||||
if (navigated && decision.target) {
|
||||
try {
|
||||
await replaceAndFocus(decision.target)
|
||||
} catch {
|
||||
const rolledBack = previousRoleId ? access.commitRole(previousRoleId) : false
|
||||
if (rolledBack) {
|
||||
ElMessage.error('权限预览切换失败,已恢复原角色')
|
||||
return
|
||||
}
|
||||
|
||||
const reconciliationTarget = resolvePreviewTarget({
|
||||
currentPath: route.path,
|
||||
currentPermission: currentPermission(),
|
||||
candidateCodes: access.permissionCodes,
|
||||
firstPath: access.firstAccessiblePath,
|
||||
})
|
||||
if (reconciliationTarget && reconciliationTarget !== route.path) {
|
||||
try {
|
||||
await replaceAndFocus(reconciliationTarget)
|
||||
} catch {
|
||||
// 保留当前路由,错误提示要求用户刷新以重新进入一致状态。
|
||||
}
|
||||
}
|
||||
ElMessage.error('权限预览切换失败,当前权限状态可能已变化,请刷新页面')
|
||||
return
|
||||
}
|
||||
}
|
||||
const roleName = access.currentRole?.name ?? decision.candidateRole.name
|
||||
announcement.value = navigated
|
||||
? `权限预览已切换为${roleName},当前页面:${String(route.meta.title ?? currentPage)}`
|
||||
: `权限预览已切换为${roleName}`
|
||||
ElMessage.success(`已切换至“${roleName}”,当前仅为静态权限预览`)
|
||||
} finally {
|
||||
correctingRoute.value = false
|
||||
}
|
||||
} finally {
|
||||
roleSwitchPending.value = false
|
||||
}
|
||||
}
|
||||
access.initializeAfterLogin()
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
@@ -167,7 +34,6 @@ async function handleLogout() {
|
||||
return
|
||||
}
|
||||
|
||||
loggingOut.value = true
|
||||
access.reset()
|
||||
unsaved.clear()
|
||||
try {
|
||||
@@ -185,30 +51,6 @@ async function handleLogout() {
|
||||
}
|
||||
|
||||
watch(() => route.fullPath, () => { mobileMenuOpen.value = false })
|
||||
watch(
|
||||
() => `${access.currentRole?.id ?? ''}:${access.permissionSignature}`,
|
||||
async () => {
|
||||
if (correctingRoute.value || loggingOut.value) return
|
||||
const target = resolvePreviewTarget({
|
||||
currentPath: route.path,
|
||||
currentPermission: currentPermission(),
|
||||
candidateCodes: access.permissionCodes,
|
||||
firstPath: access.firstAccessiblePath,
|
||||
})
|
||||
if (!target || target === route.path) return
|
||||
|
||||
correctingRoute.value = true
|
||||
try {
|
||||
await replaceAndFocus(target)
|
||||
const targetTitle = String(route.meta.title ?? '可访问页面')
|
||||
announcement.value = target === '/403'
|
||||
? `当前权限预览角色没有可访问页面,已前往${targetTitle}`
|
||||
: `当前页面权限已变化,已前往${targetTitle}`
|
||||
} finally {
|
||||
correctingRoute.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -216,17 +58,12 @@ watch(
|
||||
<button v-if="mobileMenuOpen" class="sidebar-scrim" type="button" aria-label="关闭菜单" @click="mobileMenuOpen = false" />
|
||||
<aside class="app-sidebar">
|
||||
<div class="brand-block">
|
||||
<div class="brand-mark">CC</div>
|
||||
<div v-if="!collapsed" class="brand-copy">
|
||||
<strong>{{ PRODUCT_NAME }}</strong>
|
||||
<span>管理后台</span>
|
||||
</div>
|
||||
<img class="brand-logo brand-logo-full" :src="baiyueLogoWhite" alt="柏悦物流" />
|
||||
<img class="brand-logo brand-logo-mark" :src="baiyueMarkWhite" alt="柏悦物流" />
|
||||
</div>
|
||||
|
||||
<nav class="side-nav" aria-label="主导航">
|
||||
<AdminSidebarMenu :items="access.filteredNavigation" :active-path="route.path" :collapsed="menuCollapsed" />
|
||||
</nav>
|
||||
|
||||
<button class="sidebar-toggle" type="button" @click="collapsed = !collapsed">
|
||||
<ChevronRight v-if="collapsed" :size="18" />
|
||||
<ChevronLeft v-else :size="18" />
|
||||
@@ -236,37 +73,15 @@ watch(
|
||||
|
||||
<div class="app-main">
|
||||
<header class="app-header">
|
||||
<button class="mobile-menu-button" type="button" aria-label="打开菜单" :aria-expanded="mobileMenuOpen" @click="mobileMenuOpen = true">
|
||||
<Menu :size="20" />
|
||||
</button>
|
||||
<div class="page-heading">
|
||||
<h1 ref="pageHeading" tabindex="-1">{{ pageTitle }}</h1>
|
||||
<p v-if="pageDescription">{{ pageDescription }}</p>
|
||||
</div>
|
||||
<RolePreviewSwitcher
|
||||
v-if="!access.usesServerPermissions"
|
||||
:roles="access.enabledRoles"
|
||||
:current-role="access.currentRole"
|
||||
@select="requestRoleSwitch"
|
||||
/>
|
||||
<button class="mobile-menu-button" type="button" aria-label="打开菜单" :aria-expanded="mobileMenuOpen" @click="mobileMenuOpen = true"><Menu :size="20" /></button>
|
||||
<div class="page-heading"><h1>{{ pageTitle }}</h1><p v-if="pageDescription">{{ pageDescription }}</p></div>
|
||||
<div class="header-user">
|
||||
<div class="user-avatar">{{ auth.user?.name.slice(0, 1) }}</div>
|
||||
<div
|
||||
class="user-copy"
|
||||
:aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"
|
||||
>
|
||||
<strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong>
|
||||
</div>
|
||||
<el-button text aria-label="退出登录" @click="handleLogout">
|
||||
<LogOut :size="18" />
|
||||
</el-button>
|
||||
<div class="user-copy" :aria-label="`登录身份:${auth.user?.name ?? '-'}(${auth.user?.account ?? '-'})`"><strong>登录身份:{{ auth.user?.name }}({{ auth.user?.account }})</strong></div>
|
||||
<el-button text aria-label="退出登录" @click="handleLogout"><LogOut :size="18" /></el-button>
|
||||
</div>
|
||||
<p class="sr-only" aria-live="polite" aria-atomic="true">{{ announcement }}</p>
|
||||
</header>
|
||||
|
||||
<main class="page-container">
|
||||
<RouterView />
|
||||
</main>
|
||||
<main class="page-container"><RouterView /></main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+8
-5
@@ -27,7 +27,8 @@ const router = createRouter({
|
||||
{
|
||||
path: 'customers',
|
||||
name: 'customers',
|
||||
component: () => import('@/views/CustomersView.vue'),
|
||||
component: () => import('@/views/IntegrationUnavailableView.vue'),
|
||||
props: { moduleName: '客户管理', description: '后端尚未提供平台客户档案列表及维护接口。企业注册审核请使用“企业审核”页面。' },
|
||||
meta: { title: '客户管理', description: '维护客户企业资料、合作状态和注册审核', permission: 'platform:customer:list' },
|
||||
},
|
||||
{
|
||||
@@ -51,13 +52,15 @@ const router = createRouter({
|
||||
{
|
||||
path: 'master-data/express-companies',
|
||||
name: 'express-companies',
|
||||
component: () => import('@/views/ExpressCompaniesView.vue'),
|
||||
component: () => import('@/views/IntegrationUnavailableView.vue'),
|
||||
props: { moduleName: '快递公司', description: '后端尚未提供平台快递公司主数据的正式查询和维护接口。' },
|
||||
meta: { title: '快递公司', description: '查询平台可用的快递公司档案与公开联系信息', permission: 'platform:express:list' },
|
||||
},
|
||||
{
|
||||
path: 'master-data/counterparties',
|
||||
name: 'counterparties',
|
||||
component: () => import('@/views/CounterpartiesView.vue'),
|
||||
component: () => import('@/views/IntegrationUnavailableView.vue'),
|
||||
props: { moduleName: '往来单位', description: '现有往来单位接口仅面向企业端,后端尚未提供平台侧正式维护接口。' },
|
||||
meta: { title: '往来单位', description: '维护供应商、客户、承运商、报关行及收发货单位档案', permission: 'platform:counterparty:list' },
|
||||
},
|
||||
{
|
||||
@@ -82,7 +85,7 @@ const router = createRouter({
|
||||
path: '403',
|
||||
name: 'forbidden',
|
||||
component: () => import('@/views/ForbiddenView.vue'),
|
||||
meta: { title: '权限不足', description: '当前权限预览角色无法访问此页面' },
|
||||
meta: { title: '权限不足', description: '当前登录账号没有访问此页面的权限' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -91,7 +94,7 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
document.title = to.meta.title ? `${to.meta.title} - 虫虫出海管理后台` : '虫虫出海管理后台'
|
||||
document.title = to.meta.title ? `${to.meta.title} - 柏悦物流管理后台` : '柏悦物流管理后台'
|
||||
const auth = useAuthStore()
|
||||
if (to.name === 'login' && auth.isAuthenticated) {
|
||||
return { name: 'home' }
|
||||
|
||||
@@ -1,200 +1,27 @@
|
||||
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'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
describe('server access store', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
it('uses only the authenticated permission snapshot', () => {
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = ['platform:warehouse:list']
|
||||
const access = useAccessPreviewStore()
|
||||
access.initializeAfterLogin()
|
||||
|
||||
describe('access preview store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
expect(access.can('platform:warehouse:list')).toBe(true)
|
||||
expect(access.can('platform:user:list')).toBe(false)
|
||||
expect(access.firstAccessiblePath).toBe('/warehouses')
|
||||
})
|
||||
|
||||
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)
|
||||
it('exposes no permissions when the server snapshot is empty', () => {
|
||||
const access = useAccessPreviewStore()
|
||||
expect(access.permissionCodes.size).toBe(0)
|
||||
expect(access.filteredNavigation).toHaveLength(0)
|
||||
expect(access.firstAccessiblePath).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
+3
-118
@@ -1,121 +1,22 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { computed, ref } 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 permissionCodes = computed<ReadonlySet<string>>(() => auth.permissionCodes)
|
||||
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,
|
||||
}
|
||||
initialized.value = auth.isAuthenticated
|
||||
}
|
||||
|
||||
function can(permissionCode: string): boolean {
|
||||
@@ -124,31 +25,15 @@ export const useAccessPreviewStore = defineStore('access-preview', () => {
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
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('所选收货仓当前不可用于新订单')
|
||||
})
|
||||
})
|
||||
@@ -1,389 +0,0 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
+11
-9
@@ -20,11 +20,12 @@ button { letter-spacing: 0; }
|
||||
.app-shell { min-width: 0; min-height: 100vh; }
|
||||
.app-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 20; display: flex; width: 240px; flex-direction: column; background: var(--cc-navy); color: #fff; }
|
||||
.is-collapsed .app-sidebar { width: 76px; }
|
||||
.brand-block { display: flex; height: 72px; align-items: center; gap: 12px; padding: 0 18px; border-bottom: 1px solid rgba(255,255,255,.1); }
|
||||
.brand-mark { display: grid; width: 40px; height: 40px; flex: 0 0 auto; place-items: center; border-radius: 8px; background: #fff; color: var(--cc-navy); font-weight: 800; letter-spacing: 0; }
|
||||
.brand-copy { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
|
||||
.brand-copy strong { font-size: 16px; }
|
||||
.brand-copy span { color: #aebbd0; font-size: 12px; }
|
||||
.brand-block { display: flex; height: 72px; align-items: center; padding: 0 18px; border-bottom: 1px solid rgba(255,255,255,.1); }
|
||||
.brand-logo { display: block; object-fit: contain; object-position: left center; }
|
||||
.brand-logo-full { width: 184px; height: 58px; }
|
||||
.brand-logo-mark { display: none; width: 40px; height: 40px; }
|
||||
.is-collapsed .brand-logo-full { display: none; }
|
||||
.is-collapsed .brand-logo-mark { display: block; }
|
||||
.side-nav { flex: 1; min-height: 0; overflow-y: auto; padding: 14px 10px; }
|
||||
.side-nav-menu {
|
||||
width: 100%;
|
||||
@@ -82,8 +83,8 @@ button { letter-spacing: 0; }
|
||||
|
||||
.login-page { display: grid; min-height: 100vh; grid-template-columns: minmax(420px, 1.05fr) minmax(420px, .95fr); background: #fff; }
|
||||
.login-intro { display: flex; flex-direction: column; justify-content: space-between; padding: 46px clamp(42px, 7vw, 100px); background: var(--cc-navy); color: #fff; }
|
||||
.login-brand { display: flex; align-items: center; gap: 12px; font-weight: 700; }
|
||||
.login-brand span { display: grid; width: 42px; height: 42px; place-items: center; border-radius: 10px; background: #fff; color: var(--cc-navy); }
|
||||
.login-brand { display: flex; align-items: center; min-height: 64px; }
|
||||
.login-brand-logo { display: block; width: 205px; height: 64px; object-fit: contain; object-position: left center; }
|
||||
.eyebrow, .section-kicker { color: #3975e9; font-size: 11px; font-weight: 750; letter-spacing: 0; }
|
||||
.login-intro h1 { max-width: 680px; margin: 16px 0 22px; font-size: 52px; line-height: 1.16; letter-spacing: 0; }
|
||||
.intro-copy { max-width: 560px; color: #b9c6d8; font-size: 16px; line-height: 1.8; }
|
||||
@@ -98,7 +99,6 @@ button { letter-spacing: 0; }
|
||||
.login-error { margin: 14px 0 0; color: #b42318; font-size: 13px; line-height: 1.5; }
|
||||
.login-error > p { margin: 0; }
|
||||
.api-error-alert-title { min-width: 0; }
|
||||
.demo-tip { margin-top: 18px; padding: 10px 12px; border: 1px solid #dce6f8; background: #f7faff; color: #64748b; font-size: 12px; }
|
||||
|
||||
.dashboard-stack { display: flex; flex-direction: column; gap: 20px; }
|
||||
.welcome-panel, .content-card, .module-card { border: 1px solid var(--cc-border); background: #fff; }
|
||||
@@ -283,7 +283,9 @@ button { letter-spacing: 0; }
|
||||
.app-sidebar { display: flex; width: min(280px, 86vw); transform: translateX(-100%); transition: transform .2s ease; }
|
||||
.is-mobile-open .app-sidebar { transform: translateX(0); }
|
||||
.is-collapsed .app-sidebar { width: min(280px, 86vw); }
|
||||
.is-collapsed .brand-copy, .is-collapsed .sidebar-toggle span { display: flex; }
|
||||
.is-collapsed .brand-logo-full { display: block; }
|
||||
.is-collapsed .brand-logo-mark { display: none; }
|
||||
.is-collapsed .sidebar-toggle span { display: flex; }
|
||||
.side-nav-menu, .side-nav-menu.el-menu--collapse { width: 100%; }
|
||||
.sidebar-toggle { display: none; }
|
||||
.sidebar-scrim { position: fixed; inset: 0 0 0 min(280px, 86vw); z-index: 19; display: block; border: 0; background: rgba(15, 23, 42, .46); }
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
import ElementPlus, { type MessageBoxData } from 'element-plus'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useUnsavedWorkStore } from '@/stores/unsavedWork'
|
||||
import CustomersView from './CustomersView.vue'
|
||||
import FirstLegOrdersView from './FirstLegOrdersView.vue'
|
||||
import InternalUsersView from './InternalUsersView.vue'
|
||||
import PermissionsView from './PermissionsView.vue'
|
||||
import RolesView from './RolesView.vue'
|
||||
import WarehousesView from './WarehousesView.vue'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { getPlatformWarehouse, listPlatformWarehouses } from '@/api/platformWarehouses'
|
||||
|
||||
vi.mock('@/api/platformWarehouses', () => ({
|
||||
listPlatformWarehouses: vi.fn(),
|
||||
getPlatformWarehouse: vi.fn(),
|
||||
createPlatformWarehouse: vi.fn(),
|
||||
updatePlatformWarehouse: vi.fn(),
|
||||
changePlatformWarehouseStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
async function mountForRole(
|
||||
component: typeof CustomersView | typeof FirstLegOrdersView | typeof InternalUsersView | typeof RolesView | typeof PermissionsView | typeof WarehousesView,
|
||||
roleId: string,
|
||||
) {
|
||||
const pinia: Pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const access = useAccessPreviewStore()
|
||||
access.initializeAfterLogin()
|
||||
expect(access.commitRole(roleId)).toBe(true)
|
||||
const wrapper = mount(component, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, access, unsaved: useUnsavedWorkStore() }
|
||||
}
|
||||
|
||||
function buttonTexts(wrapper: VueWrapper) {
|
||||
return wrapper.findAll('button').map((button) => button.text().trim())
|
||||
}
|
||||
|
||||
async function clickButton(wrapper: VueWrapper, text: string) {
|
||||
const button = wrapper.findAll('button').find((candidate) => candidate.text().trim() === text)
|
||||
if (!button) throw new Error(`Button not found: ${text}`)
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function fillNativeInput(placeholder: string, value: string) {
|
||||
const input = document.querySelector<HTMLInputElement>(`input[placeholder="${placeholder}"]`)
|
||||
if (!input) throw new Error(`Input not found: ${placeholder}`)
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
afterAll(() => vi.unstubAllGlobals())
|
||||
|
||||
describe('business action permissions', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage())
|
||||
sessionStorage.setItem('cc-admin-warehouse-tenant-id', '10001')
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
const warehouse = {
|
||||
id: '19001', tenantId: 10001, warehouseCode: 'CN-TEST-01', warehouseName: '测试收货仓',
|
||||
warehouseType: 'DOMESTIC', countryCode: 'CN', timezone: 'Asia/Shanghai', contactName: '仓库客服',
|
||||
contactPhone: '0579-12345678', contactEmail: null, status: 'ACTIVE' as const, remark: null,
|
||||
}
|
||||
vi.mocked(listPlatformWarehouses).mockResolvedValue({ records: [warehouse], page: 1, size: 200, total: 1, pages: 1 })
|
||||
vi.mocked(getPlatformWarehouse).mockResolvedValue({
|
||||
warehouse,
|
||||
addresses: [{
|
||||
id: '19002', tenantId: 10001, warehouseId: '19001', addressType: 'OPERATING', countryCode: 'CN',
|
||||
stateProvince: '浙江省', city: '义乌市', district: null, postalCode: null, addressLine1: '测试地址',
|
||||
addressLine2: null, latitude: null, longitude: null,
|
||||
}],
|
||||
capabilities: [{
|
||||
id: '19003', tenantId: 10001, warehouseId: '19001', capabilityCode: 'RECEIVING', capabilityName: '收货服务',
|
||||
enabled: true, cutoffTime: null, description: '周一至周六 08:30-18:00',
|
||||
}],
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps review actions out of customer records now that reviews use a dedicated route', async () => {
|
||||
const { wrapper } = await mountForRole(CustomersView, 'R002')
|
||||
const texts = buttonTexts(wrapper)
|
||||
|
||||
expect(texts).not.toContain('新增客户')
|
||||
expect(texts).not.toContain('审核')
|
||||
expect(texts).toContain('编辑')
|
||||
})
|
||||
|
||||
it('shows no audit or status action for a pending customer without audit permission', async () => {
|
||||
const { wrapper } = await mountForRole(CustomersView, 'R005')
|
||||
const pendingRow = wrapper.findAll('tr')
|
||||
.find((row) => row.text().includes('北京国贸跨境供应链有限公司'))
|
||||
expect(pendingRow).toBeDefined()
|
||||
|
||||
const actions = pendingRow?.findAll('button').map((button) => button.text().trim()) ?? []
|
||||
expect(actions).not.toContain('审核')
|
||||
expect(actions).not.toContain('停用')
|
||||
expect(actions).not.toContain('启用')
|
||||
})
|
||||
|
||||
it('lets sales create and edit customers while finance cannot update orders', async () => {
|
||||
const sales = await mountForRole(CustomersView, 'R005')
|
||||
expect(buttonTexts(sales.wrapper)).toContain('新增客户')
|
||||
sales.wrapper.unmount()
|
||||
document.body.innerHTML = ''
|
||||
|
||||
const finance = await mountForRole(FirstLegOrdersView, 'R004')
|
||||
const texts = buttonTexts(finance.wrapper)
|
||||
expect(texts).not.toContain('创建订单')
|
||||
expect(texts).not.toContain('批量更新')
|
||||
expect(texts).not.toContain('处理')
|
||||
expect(texts).toContain('详情')
|
||||
expect(texts).not.toContain('编辑')
|
||||
})
|
||||
|
||||
it('shows create actions to the super administrator but no order status actions to sales', async () => {
|
||||
const superAdmin = await mountForRole(FirstLegOrdersView, 'R001')
|
||||
expect(buttonTexts(superAdmin.wrapper)).toContain('创建订单')
|
||||
expect(buttonTexts(superAdmin.wrapper)).toContain('批量更新')
|
||||
expect(buttonTexts(superAdmin.wrapper)).toContain('处理')
|
||||
superAdmin.wrapper.unmount()
|
||||
document.body.innerHTML = ''
|
||||
|
||||
const sales = await mountForRole(FirstLegOrdersView, 'R005')
|
||||
const texts = buttonTexts(sales.wrapper)
|
||||
expect(texts).not.toContain('批量更新')
|
||||
expect(texts).not.toContain('处理')
|
||||
expect(texts).not.toContain('编辑')
|
||||
})
|
||||
|
||||
it('lets operations maintain warehouses while operators can only view them', async () => {
|
||||
const operations = await mountForRole(WarehousesView, 'R002')
|
||||
expect(buttonTexts(operations.wrapper)).toContain('新增仓库')
|
||||
expect(buttonTexts(operations.wrapper)).toContain('编辑')
|
||||
expect(buttonTexts(operations.wrapper)).toContain('停用')
|
||||
operations.wrapper.unmount()
|
||||
document.body.innerHTML = ''
|
||||
|
||||
const operator = await mountForRole(WarehousesView, 'R003')
|
||||
expect(buttonTexts(operator.wrapper)).not.toContain('新增仓库')
|
||||
expect(buttonTexts(operator.wrapper)).not.toContain('编辑')
|
||||
expect(operator.wrapper.text()).toContain('仅查看')
|
||||
})
|
||||
|
||||
it('registers a dirty customer draft and discards it when create permission is lost', async () => {
|
||||
const { wrapper, access, unsaved } = await mountForRole(CustomersView, 'R001')
|
||||
await clickButton(wrapper, '新增客户')
|
||||
await fillNativeInput('请输入营业执照上的企业名称', '测试客户')
|
||||
|
||||
expect(unsaved.entries).toContainEqual({
|
||||
key: 'customer-editor',
|
||||
label: '新增客户',
|
||||
dirty: true,
|
||||
requiredPermission: 'customer:create',
|
||||
})
|
||||
|
||||
expect(access.commitRole('R002')).toBe(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'customer-editor')).toBeUndefined()
|
||||
expect(document.body.textContent).not.toContain('请输入营业执照上的企业名称')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[CustomersView, '新增客户'],
|
||||
[FirstLegOrdersView, '创建订单'],
|
||||
[InternalUsersView, '新增用户'],
|
||||
[RolesView, '新增角色'],
|
||||
[PermissionsView, '新增权限'],
|
||||
] as const)('keeps the role preview reachable while the %s editor is open', async (component, openText) => {
|
||||
const { wrapper } = await mountForRole(component, 'R001')
|
||||
await clickButton(wrapper, openText)
|
||||
|
||||
const editor = wrapper.findComponent({ name: 'ElDrawer' })
|
||||
expect(editor.exists()).toBe(true)
|
||||
expect(document.querySelector('.editor-drawer')).not.toBeNull()
|
||||
expect(editor.props('modal')).toBe(false)
|
||||
expect(editor.props('closeOnClickModal')).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps an existing customer editor open when create permission is lost', async () => {
|
||||
const { wrapper, access, unsaved } = await mountForRole(CustomersView, 'R001')
|
||||
await clickButton(wrapper, '编辑')
|
||||
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'customer-editor')).toMatchObject({
|
||||
label: '编辑客户',
|
||||
requiredPermission: null,
|
||||
})
|
||||
|
||||
expect(access.commitRole('R002')).toBe(true)
|
||||
await flushPromises()
|
||||
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'customer-editor')).toBeDefined()
|
||||
expect(document.body.textContent).toContain('编辑客户')
|
||||
})
|
||||
|
||||
it('discards only a new order editor when create permission is lost', async () => {
|
||||
const { wrapper, access, unsaved } = await mountForRole(FirstLegOrdersView, 'R001')
|
||||
await clickButton(wrapper, '创建订单')
|
||||
await fillNativeInput('如 宁波', '上海')
|
||||
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'first-leg-order-editor')).toMatchObject({
|
||||
label: '创建头程订单',
|
||||
dirty: true,
|
||||
requiredPermission: 'first-leg:create',
|
||||
})
|
||||
|
||||
expect(access.commitRole('R004')).toBe(true)
|
||||
await flushPromises()
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'first-leg-order-editor')).toBeUndefined()
|
||||
|
||||
access.commitRole('R001')
|
||||
await nextTick()
|
||||
await clickButton(wrapper, '编辑')
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'first-leg-order-editor')).toMatchObject({
|
||||
label: '编辑头程订单',
|
||||
requiredPermission: null,
|
||||
})
|
||||
|
||||
expect(access.commitRole('R004')).toBe(true)
|
||||
await flushPromises()
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'first-leg-order-editor')).toBeDefined()
|
||||
expect(document.body.textContent).toContain('编辑头程订单')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[InternalUsersView, '系统管理员', ['编辑', '重置密码', '停用']],
|
||||
[RolesView, '超级管理员', ['配置', '停用', '删除']],
|
||||
[PermissionsView, '系统管理', ['编辑', '添加下级', '停用']],
|
||||
] as const)('hides every maintenance action on %s rows for the operations manager', async (component, rowText, actions) => {
|
||||
const { wrapper } = await mountForRole(component, 'R002')
|
||||
const row = wrapper.findAll('tr').find((candidate) => candidate.text().includes(rowText))
|
||||
expect(row).toBeDefined()
|
||||
const rowActions = row?.findAll('button').map((button) => button.text().trim()) ?? []
|
||||
for (const action of actions) expect(rowActions).not.toContain(action)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[InternalUsersView, '新增用户', '系统管理员', '编辑'],
|
||||
[RolesView, '新增角色', '超级管理员', '配置'],
|
||||
[PermissionsView, '新增权限', '系统管理', '添加下级'],
|
||||
] as const)('shows toolbar and row maintenance actions on %s for the super administrator', async (component, toolbarAction, rowText, rowAction) => {
|
||||
const { wrapper } = await mountForRole(component, 'R001')
|
||||
expect(buttonTexts(wrapper)).toContain(toolbarAction)
|
||||
const row = wrapper.findAll('tr').find((candidate) => candidate.text().includes(rowText))
|
||||
expect(row?.findAll('button').map((button) => button.text().trim())).toContain(rowAction)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[InternalUsersView, '新增用户', null, null, 'internal-user-editor', '新增内部用户', 'system:user:write'],
|
||||
[RolesView, '新增角色', null, null, 'role-editor', '新增角色', 'system:role:grant'],
|
||||
[PermissionsView, '新增权限', '用户可理解的中文名称', '测试权限', 'permission-editor', '新增权限', 'system:permission:write'],
|
||||
] as const)('registers system editor dirty work with the maintenance permission on %s', async (component, openText, placeholder, value, key, label, requiredPermission) => {
|
||||
const { wrapper, unsaved } = await mountForRole(component, 'R001')
|
||||
await clickButton(wrapper, openText)
|
||||
if (placeholder && value) await fillNativeInput(placeholder, value)
|
||||
if (!placeholder) {
|
||||
const nameInput = document.querySelector<HTMLInputElement>('.el-drawer input')
|
||||
expect(nameInput).not.toBeNull()
|
||||
if (nameInput) {
|
||||
nameInput.value = `${label}测试内容`
|
||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
}
|
||||
|
||||
expect(unsaved.entries.find((entry) => entry.key === key)).toMatchObject({
|
||||
label,
|
||||
dirty: true,
|
||||
requiredPermission,
|
||||
})
|
||||
expect(buttonTexts(wrapper)).toContain(component === InternalUsersView ? '保存用户' : component === RolesView ? '保存角色' : '保存权限')
|
||||
})
|
||||
|
||||
it('marks the role editor dirty when only a permission checkbox changes', async () => {
|
||||
const { wrapper, unsaved } = await mountForRole(RolesView, 'R001')
|
||||
await clickButton(wrapper, '配置')
|
||||
const permissionCheckbox = document.querySelector<HTMLElement>('.el-drawer .el-tree .el-checkbox')
|
||||
expect(permissionCheckbox).not.toBeNull()
|
||||
permissionCheckbox?.click()
|
||||
await flushPromises()
|
||||
|
||||
expect(unsaved.entries.find((entry) => entry.key === 'role-editor')).toMatchObject({
|
||||
dirty: true,
|
||||
requiredPermission: 'system:role:grant',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[InternalUsersView, '保存用户'],
|
||||
[RolesView, '保存角色'],
|
||||
[PermissionsView, '保存权限'],
|
||||
] as const)('does not render a save entry on %s without maintenance permission', async (component, saveText) => {
|
||||
const { wrapper } = await mountForRole(component, 'R002')
|
||||
expect(buttonTexts(wrapper)).not.toContain(saveText)
|
||||
})
|
||||
|
||||
it('warns that disabling a directory is non-cascading and preserves descendant statuses', async () => {
|
||||
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue({
|
||||
value: '',
|
||||
action: 'confirm',
|
||||
} as unknown as MessageBoxData)
|
||||
const { wrapper } = await mountForRole(PermissionsView, 'R001')
|
||||
const data = useAdminDataStore()
|
||||
const directory = data.permissions.find((permission) => permission.id === 'P020')
|
||||
const descendant = data.permissions.find((permission) => permission.id === 'P300')
|
||||
expect(directory?.status).toBe('启用')
|
||||
expect(descendant?.status).toBe('启用')
|
||||
|
||||
const row = wrapper.findAll('tr').find((candidate) => candidate.text().includes('系统管理'))
|
||||
const stop = row?.findAll('button').find((button) => button.text().trim() === '停用')
|
||||
expect(stop).toBeDefined()
|
||||
await stop?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('目录仅用于组织结构,停用不会级联影响已启用的下级菜单和操作。'),
|
||||
'权限状态',
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(data.permissions.find((permission) => permission.id === 'P020')?.status).toBe('停用')
|
||||
expect(data.permissions.find((permission) => permission.id === 'P300')?.status).toBe('启用')
|
||||
})
|
||||
})
|
||||
@@ -1,182 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Building2, Eye, FileUp, MapPin, Pencil, Plus, RotateCcw, Search } from '@lucide/vue'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import StaticBatchImportDialog from '@/components/StaticBatchImportDialog.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { Counterparty, CounterpartyStatus, CounterpartyType } from '@/types/admin'
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const editorOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const importOpen = ref(false)
|
||||
const selectedCounterparty = ref<Counterparty | null>(null)
|
||||
const filters = reactive({ keyword: '', type: '' as CounterpartyType | '', status: '' as CounterpartyStatus | '' })
|
||||
const counterpartyTypes: CounterpartyType[] = ['供应商', '客户', '承运商', '报关行', '发货单位', '收货单位']
|
||||
|
||||
const emptyForm = (): Omit<Counterparty, 'updatedAt'> => ({
|
||||
id: '', code: '', name: '', types: [], contactName: '', contactPhone: '', email: '',
|
||||
country: '中国', province: '', city: '', postalCode: '', address: '', status: '启用', remark: '',
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑往来单位' : '新增往来单位')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'counterparty-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: computed(() => form.id ? null : 'counterparty:write'),
|
||||
})
|
||||
|
||||
const filteredRows = computed(() => store.counterparties.filter((item) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
const keywordMatches = !keyword || [item.name, item.code, item.contactName, item.contactPhone, item.email]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return keywordMatches && (!filters.type || item.types.includes(filters.type)) && (!filters.status || item.status === filters.status)
|
||||
}))
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
const metrics = computed(() => [
|
||||
{ label: '全部单位', value: store.counterparties.length, note: '当前静态数据范围' },
|
||||
{ label: '启用单位', value: store.activeCounterparties, note: '可供业务引用' },
|
||||
{ label: '多类型单位', value: store.counterparties.filter((item) => item.types.length > 1).length, note: '一个档案承担多个角色' },
|
||||
{ label: '收发货单位', value: store.counterparties.filter((item) => item.types.some((type) => ['发货单位', '收货单位'].includes(type))).length, note: '客户端只读选择范围' },
|
||||
])
|
||||
|
||||
watch(filters, () => { page.value = 1 })
|
||||
|
||||
function resetFilters() { Object.assign(filters, { keyword: '', type: '', status: '' }) }
|
||||
function openCreate() { Object.assign(form, emptyForm()); captureBaseline(); editorOpen.value = true }
|
||||
function openEdit(item: Counterparty) { Object.assign(form, { ...item, types: [...item.types] }); captureBaseline(); detailOpen.value = false; editorOpen.value = true }
|
||||
function openDetail(item: Counterparty) { selectedCounterparty.value = item; detailOpen.value = true }
|
||||
|
||||
function saveCounterparty() {
|
||||
if (!form.code.trim() || !form.name.trim() || form.types.length === 0 || !form.contactName.trim() || !form.contactPhone.trim() || !form.country.trim() || !form.city.trim() || !form.address.trim()) {
|
||||
ElMessage.warning('请填写单位编码、名称、至少一种类型、联系人、电话及完整地址')
|
||||
return
|
||||
}
|
||||
if (form.email && !/^\S+@\S+\.\S+$/.test(form.email)) {
|
||||
ElMessage.warning('请输入正确的联系邮箱')
|
||||
return
|
||||
}
|
||||
try {
|
||||
store.saveCounterparty({ ...form, id: form.id || undefined, types: [...form.types] })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '往来单位已更新' : '往来单位已创建')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '往来单位保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item: Counterparty) {
|
||||
const target: CounterpartyStatus = item.status === '启用' ? '停用' : '启用'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
target === '停用' ? '停用后不能用于新订单,历史订单快照不受影响。' : '启用后可重新用于符合类型和数据范围的新订单。',
|
||||
`${target}往来单位“${item.name}”`,
|
||||
{ confirmButtonText: target, cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
store.setCounterpartyStatus(item.id, target)
|
||||
ElMessage.success(`往来单位已${target}`)
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
}
|
||||
}
|
||||
|
||||
function addressText(item: Counterparty) {
|
||||
return [item.country, item.province, item.city, item.address, item.postalCode].filter(Boolean).join(' · ')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- THESIS: 把多角色往来单位整理成可扫描、可维护的主数据目录,而不是复制原型的宽表。
|
||||
OWN-WORLD: 延续 Admin 的海军蓝导航、白色数据面板、紧凑筛选区、状态标签与右侧抽屉。
|
||||
STORY: 平台人员按类型和状态定位单位,核对联系地址,维护多类型档案或执行整批导入。
|
||||
FIRST VIEWPORT: 四项运营摘要、三条件筛选、维护与导入动作、单位表格连续出现。
|
||||
FORM: 现有 Operate 页面结构的扩展,类型用标签聚合,完整资料在抽屉中展开。
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md -->
|
||||
<div class="business-page counterparty-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
|
||||
<section class="surface-panel filter-panel" aria-label="往来单位筛选">
|
||||
<div class="filter-fields">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="单位名称、编码、联系人或电话" aria-label="往来单位关键词"><template #prefix><Search :size="16" /></template></el-input>
|
||||
<el-select v-model="filters.type" clearable placeholder="全部类型" aria-label="往来单位类型"><el-option v-for="item in counterpartyTypes" :key="item" :label="item" :value="item" /></el-select>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="往来单位状态"><el-option label="启用" value="启用" /><el-option label="停用" value="停用" /></el-select>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button>
|
||||
<el-button v-if="access.can('counterparty:import')" @click="importOpen = true"><FileUp :size="16" />批量导入</el-button>
|
||||
<el-button v-if="access.can('counterparty:write')" type="primary" @click="openCreate"><Plus :size="16" />新增单位</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<header class="panel-heading"><div><h2>往来单位档案</h2><p>平台维护完整主数据;企业客户端仅可只读选择已启用的发货单位和收货单位。</p></div></header>
|
||||
<el-table :data="pagedRows" row-key="id" empty-text="没有符合条件的往来单位">
|
||||
<el-table-column label="操作" width="220" fixed="left"><template #default="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as Counterparty)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('counterparty:write')" link type="primary" @click="openEdit(row as Counterparty)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can('counterparty:write')" link :type="(row as Counterparty).status === '启用' ? 'danger' : 'success'" @click="toggleStatus(row as Counterparty)">{{ (row as Counterparty).status === '启用' ? '停用' : '启用' }}</el-button></div></template></el-table-column>
|
||||
<el-table-column label="单位" min-width="230"><template #default="{ row }"><div class="counterparty-cell"><span><Building2 :size="17" /></span><div><strong>{{ (row as Counterparty).name }}</strong><small>{{ (row as Counterparty).code }}</small></div></div></template></el-table-column>
|
||||
<el-table-column label="类型" min-width="220"><template #default="{ row }"><div class="type-tags"><el-tag v-for="type in (row as Counterparty).types" :key="type" size="small" effect="plain">{{ type }}</el-tag></div></template></el-table-column>
|
||||
<el-table-column label="主要联系人" min-width="170"><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as Counterparty).contactName }}</strong><span>{{ (row as Counterparty).contactPhone }}</span></div></template></el-table-column>
|
||||
<el-table-column label="城市 / 地址" min-width="210"><template #default="{ row }"><div class="primary-cell"><strong>{{ (row as Counterparty).country }} · {{ (row as Counterparty).city }}</strong><span>{{ (row as Counterparty).address }}</span></div></template></el-table-column>
|
||||
<el-table-column label="状态" width="90"><template #default="{ row }"><StatusBadge :status="(row as Counterparty).status" /></template></el-table-column>
|
||||
</el-table>
|
||||
<footer class="table-footer"><span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="filteredRows.length" layout="prev, pager, next" /></footer>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="form.id ? '编辑往来单位' : '新增往来单位'" size="760px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form">
|
||||
<div class="form-section"><h3>单位信息</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="单位编码" required><el-input v-model="form.code" placeholder="如 CP-SUP-001" maxlength="40" /></el-form-item>
|
||||
<el-form-item label="单位名称" required><el-input v-model="form.name" placeholder="请输入营业执照或登记名称" maxlength="120" /></el-form-item>
|
||||
<el-form-item class="wide-field" label="单位类型" required><el-select v-model="form.types" multiple collapse-tags :max-collapse-tags="3" placeholder="可多选"><el-option v-for="item in counterpartyTypes" :key="item" :label="item" :value="item" /></el-select></el-form-item>
|
||||
<el-form-item label="状态"><el-radio-group v-model="form.status"><el-radio-button label="启用" value="启用" /><el-radio-button label="停用" value="停用" /></el-radio-group></el-form-item>
|
||||
</div></div>
|
||||
<div class="form-section"><h3>联系信息</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="联系人" required><el-input v-model="form.contactName" maxlength="60" /></el-form-item>
|
||||
<el-form-item label="联系电话" required><el-input v-model="form.contactPhone" maxlength="32" /></el-form-item>
|
||||
<el-form-item label="联系邮箱"><el-input v-model="form.email" maxlength="120" /></el-form-item>
|
||||
</div></div>
|
||||
<div class="form-section"><h3>主要地址</h3><div class="form-grid three-cols">
|
||||
<el-form-item label="国家/地区" required><el-input v-model="form.country" maxlength="40" /></el-form-item>
|
||||
<el-form-item label="省/州"><el-input v-model="form.province" maxlength="60" /></el-form-item>
|
||||
<el-form-item label="城市" required><el-input v-model="form.city" maxlength="60" /></el-form-item>
|
||||
<el-form-item label="邮政编码"><el-input v-model="form.postalCode" maxlength="20" /></el-form-item>
|
||||
<el-form-item class="wide-field" label="详细地址" required><el-input v-model="form.address" maxlength="180" /></el-form-item>
|
||||
</div><el-form-item label="内部备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="300" show-word-limit /></el-form-item></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="access.can('counterparty:write')" type="primary" @click="saveCounterparty">保存往来单位</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="往来单位详情" size="620px" class="business-drawer">
|
||||
<template v-if="selectedCounterparty">
|
||||
<div class="detail-identity"><span><Building2 :size="20" /></span><div><h2>{{ selectedCounterparty.name }}</h2><p>{{ selectedCounterparty.code }} · {{ selectedCounterparty.id }}</p></div><StatusBadge :status="selectedCounterparty.status" /></div>
|
||||
<div class="detail-section"><h3>单位角色</h3><div class="type-tags"><el-tag v-for="type in selectedCounterparty.types" :key="type" effect="plain">{{ type }}</el-tag></div></div>
|
||||
<div class="detail-section"><h3>联系与地址</h3><el-descriptions :column="1" border><el-descriptions-item label="联系人">{{ selectedCounterparty.contactName }} · {{ selectedCounterparty.contactPhone }}</el-descriptions-item><el-descriptions-item label="邮箱">{{ selectedCounterparty.email || '—' }}</el-descriptions-item><el-descriptions-item label="完整地址"><span class="address-value"><MapPin :size="15" />{{ addressText(selectedCounterparty) }}</span></el-descriptions-item><el-descriptions-item label="更新时间">{{ selectedCounterparty.updatedAt }}</el-descriptions-item></el-descriptions></div>
|
||||
<div class="detail-section"><h3>内部备注</h3><p class="detail-note">{{ selectedCounterparty.remark || '暂无内部备注。' }}</p></div>
|
||||
</template>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="access.can('counterparty:write') && selectedCounterparty" type="primary" @click="openEdit(selectedCounterparty)">编辑资料</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<StaticBatchImportDialog v-model="importOpen" entity-label="往来单位" template-url="/templates/counterparties-import-template.xlsx" template-file-name="往来单位导入标准模板-v1.xlsx" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.counterparty-cell { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
||||
.counterparty-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; background: #eef3fa; color: var(--cc-primary); }
|
||||
.counterparty-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.counterparty-cell strong, .counterparty-cell small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.counterparty-cell small { color: var(--cc-muted); font-size: 11px; }
|
||||
.type-tags { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||
.wide-field { grid-column: span 2; }
|
||||
.address-value { display: inline-flex; align-items: flex-start; gap: 6px; line-height: 1.6; }
|
||||
.address-value svg { flex: 0 0 auto; margin-top: 2px; color: var(--cc-muted); }
|
||||
@media (max-width: 720px) { .wide-field { grid-column: auto; } }
|
||||
</style>
|
||||
@@ -1,190 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Download, Eye, Pencil, Plus, RotateCcw, Search } from '@lucide/vue'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { Customer } from '@/types/admin'
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const editorOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const currentCustomer = ref<Customer | null>(null)
|
||||
const filters = reactive({ keyword: '', status: '', packageName: '' })
|
||||
|
||||
const emptyForm = (): Omit<Customer, 'orders' | 'joinedAt'> => ({
|
||||
id: '', name: '', creditCode: '', contact: '', phone: '', email: '', sales: '',
|
||||
packageName: '基础版', period: 30, status: '正常', address: '', remark: '',
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑客户' : '新增客户')
|
||||
const editorRequiredPermission = computed(() => form.id ? null : 'customer:create')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'customer-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: editorRequiredPermission,
|
||||
})
|
||||
|
||||
const filteredRows = computed(() => store.customers.filter((item) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
const matchesKeyword = !keyword || [item.name, item.id, item.contact, item.phone, item.creditCode]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesKeyword && (!filters.status || item.status === filters.status) && (!filters.packageName || item.packageName === filters.packageName)
|
||||
}))
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
const metrics = computed(() => [
|
||||
{ label: '全部客户', value: store.customers.length, note: '已建立企业档案' },
|
||||
{ label: '正常合作', value: store.customers.filter((item) => item.status === '正常').length, note: '当前可正常下单' },
|
||||
{ label: '待审核', value: store.pendingCustomers, note: '需要平台处理' },
|
||||
{ label: '累计订单', value: store.customers.reduce((total, item) => total + item.orders, 0).toLocaleString(), note: '全部客户历史订单' },
|
||||
])
|
||||
|
||||
watch(filters, () => { page.value = 1 })
|
||||
watch(
|
||||
() => access.can('customer:create'),
|
||||
(canCreate) => {
|
||||
if (canCreate || !editorOpen.value || form.id) return
|
||||
editorOpen.value = false
|
||||
Object.assign(form, emptyForm())
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
function resetFilters() {
|
||||
Object.assign(filters, { keyword: '', status: '', packageName: '' })
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, emptyForm())
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: Customer) {
|
||||
Object.assign(form, row)
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function openDetail(row: Customer) {
|
||||
currentCustomer.value = row
|
||||
detailOpen.value = true
|
||||
}
|
||||
|
||||
function saveCustomer() {
|
||||
if (!form.name.trim() || !form.contact.trim() || !form.phone.trim()) {
|
||||
ElMessage.warning('请填写企业名称、联系人和联系电话')
|
||||
return
|
||||
}
|
||||
store.saveCustomer({ ...form, id: form.id || undefined })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '客户资料已更新' : '客户已创建')
|
||||
}
|
||||
|
||||
async function toggleStatus(row: Customer) {
|
||||
const target = row.status === '停用' ? '正常' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认将“${row.name}”设置为${target}吗?`, '变更客户状态', {
|
||||
confirmButtonText: '确认变更', cancelButtonText: '取消',
|
||||
})
|
||||
store.setCustomerStatus(row.id, target)
|
||||
ElMessage.success(`客户状态已更新为${target}`)
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
}
|
||||
}
|
||||
|
||||
function exportCustomers() {
|
||||
ElMessage.success(`已生成 ${filteredRows.value.length} 条客户数据的导出任务`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
|
||||
<section class="surface-panel filter-panel" aria-label="客户筛选">
|
||||
<div class="filter-fields">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="客户名称、编号、联系人或信用代码" aria-label="客户关键词">
|
||||
<template #prefix><Search :size="16" /></template>
|
||||
</el-input>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="客户状态">
|
||||
<el-option label="正常" value="正常" /><el-option label="待审核" value="待审核" /><el-option label="停用" value="停用" />
|
||||
</el-select>
|
||||
<el-select v-model="filters.packageName" clearable placeholder="全部套餐" aria-label="客户套餐">
|
||||
<el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button>
|
||||
<el-button v-if="!access.usesServerPermissions" @click="exportCustomers"><Download :size="16" />导出</el-button>
|
||||
<el-button v-if="access.can('customer:create')" type="primary" @click="openCreate"><Plus :size="16" />新增客户</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<header class="panel-heading">
|
||||
<div><h2>客户档案</h2><p>共 {{ filteredRows.length }} 家企业,客户资料与注册审核统一管理。</p></div>
|
||||
</header>
|
||||
<el-table :data="pagedRows" row-key="id" empty-text="没有符合条件的客户">
|
||||
<el-table-column label="操作" width="246" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" @click="openDetail(row as Customer)"><Eye :size="15" />详情</el-button>
|
||||
<el-button v-if="!access.usesServerPermissions" link type="primary" @click="openEdit(row as Customer)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="!access.usesServerPermissions && row.status !== '待审核'" link :type="row.status === '停用' ? 'success' : 'danger'" @click="toggleStatus(row as Customer)">{{ row.status === '停用' ? '启用' : '停用' }}</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="客户编号" width="96" />
|
||||
<el-table-column label="企业信息" min-width="250">
|
||||
<template #default="{ row }"><div class="primary-cell"><strong>{{ row.name }}</strong><span>{{ row.creditCode }}</span></div></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="联系人" min-width="150"><template #default="{ row }"><div class="primary-cell"><strong>{{ row.contact }}</strong><span>{{ row.phone }}</span></div></template></el-table-column>
|
||||
<el-table-column prop="sales" label="销售负责人" width="108" />
|
||||
<el-table-column label="套餐/账期" width="130"><template #default="{ row }"><div class="primary-cell"><strong>{{ row.packageName }}</strong><span>{{ row.period }} 天账期</span></div></template></el-table-column>
|
||||
<el-table-column prop="orders" label="订单数" width="86" align="right" />
|
||||
<el-table-column label="状态" width="96"><template #default="{ row }"><StatusBadge :status="row.status" /></template></el-table-column>
|
||||
</el-table>
|
||||
<footer class="table-footer"><span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="filteredRows.length" layout="prev, pager, next" /></footer>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="form.id ? '编辑客户' : '新增客户'" size="680px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form">
|
||||
<div class="form-section"><h3>企业信息</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="企业名称" required><el-input v-model="form.name" placeholder="请输入营业执照上的企业名称" /></el-form-item>
|
||||
<el-form-item label="统一社会信用代码"><el-input v-model="form.creditCode" placeholder="18 位统一社会信用代码" /></el-form-item>
|
||||
<el-form-item label="套餐"><el-select v-model="form.packageName"><el-option label="基础版" value="基础版" /><el-option label="专业版" value="专业版" /><el-option label="企业版" value="企业版" /></el-select></el-form-item>
|
||||
<el-form-item label="账期"><el-input-number v-model="form.period" :min="0" :max="180" controls-position="right" /><span class="field-suffix">天</span></el-form-item>
|
||||
<el-form-item label="状态"><el-radio-group v-model="form.status"><el-radio-button label="正常" value="正常" /><el-radio-button label="待审核" value="待审核" /><el-radio-button label="停用" value="停用" /></el-radio-group></el-form-item>
|
||||
<el-form-item label="销售负责人"><el-select v-model="form.sales" filterable allow-create placeholder="请选择或输入负责人"><el-option v-for="name in ['王建国','李秀英','张志强','刘德海','陈美玲']" :key="name" :label="name" :value="name" /></el-select></el-form-item>
|
||||
</div></div>
|
||||
<div class="form-section"><h3>联系资料</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="联系人" required><el-input v-model="form.contact" /></el-form-item>
|
||||
<el-form-item label="联系电话" required><el-input v-model="form.phone" /></el-form-item>
|
||||
<el-form-item label="联系邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||
<el-form-item label="注册地址"><el-input v-model="form.address" /></el-form-item>
|
||||
</div><el-form-item label="内部备注"><el-input v-model="form.remark" type="textarea" :rows="3" placeholder="仅平台内部可见" /></el-form-item></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="!access.usesServerPermissions && (form.id || access.can('customer:create'))" type="primary" @click="saveCustomer">保存客户</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="客户详情" size="620px" class="business-drawer">
|
||||
<template v-if="currentCustomer">
|
||||
<div class="detail-identity"><span>{{ currentCustomer.name.slice(0, 1) }}</span><div><h2>{{ currentCustomer.name }}</h2><p>{{ currentCustomer.id }} · {{ currentCustomer.creditCode }}</p></div><StatusBadge :status="currentCustomer.status" /></div>
|
||||
<MetricStrip :items="[{ label: '历史订单', value: currentCustomer.orders }, { label: '账期', value: `${currentCustomer.period} 天` }, { label: '合作时长', value: currentCustomer.joinedAt.slice(0,4) + ' 至今' }]" />
|
||||
<div class="detail-section"><h3>基本资料</h3><el-descriptions :column="1" border><el-descriptions-item label="套餐">{{ currentCustomer.packageName }}</el-descriptions-item><el-descriptions-item label="销售负责人">{{ currentCustomer.sales }}</el-descriptions-item><el-descriptions-item label="联系人">{{ currentCustomer.contact }} · {{ currentCustomer.phone }}</el-descriptions-item><el-descriptions-item label="邮箱">{{ currentCustomer.email }}</el-descriptions-item><el-descriptions-item label="地址">{{ currentCustomer.address }}</el-descriptions-item><el-descriptions-item label="入驻时间">{{ currentCustomer.joinedAt }}</el-descriptions-item></el-descriptions></div>
|
||||
<div class="detail-section"><h3>内部备注</h3><p class="detail-note">{{ currentCustomer.remark || '暂无内部备注。' }}</p></div>
|
||||
</template>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="!access.usesServerPermissions" type="primary" @click="currentCustomer && openEdit(currentCustomer)">编辑资料</el-button></div></template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type PlatformTenantQualification,
|
||||
} from '@/api/platformIam'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import EnterpriseReviewsView from './EnterpriseReviewsView.vue'
|
||||
|
||||
vi.mock('@/api/platformIam', () => ({
|
||||
@@ -58,6 +59,9 @@ const pendingQualification: PlatformTenantQualification = {
|
||||
async function mountView() {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = ['platform:customer:review', 'platform:customer:approve', 'platform:customer:reject']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(EnterpriseReviewsView, {
|
||||
attachTo: document.body,
|
||||
|
||||
@@ -189,7 +189,7 @@ onBeforeUnmount(clearPreviewUrl)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- THESIS: 把企业审核组织成一条清晰的待办队列,而不是混在客户档案的模拟状态里。
|
||||
<!-- THESIS: 把企业审核组织成一条清晰的待办队列,与客户档案保持清楚的任务边界。
|
||||
OWN-WORLD: 延续 Admin 的海军蓝框架、白色数据面板、紧凑表格和克制状态色。
|
||||
STORY: 审核员先识别待审企业,再核对营业执照与联系人,最后留下明确的通过或驳回意见。
|
||||
FIRST VIEWPORT: 摘要、搜索和真实待审列表连续呈现,详情从右侧展开,决策在受保护的确认对话框中完成。
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
import ExpressCompaniesView from './ExpressCompaniesView.vue'
|
||||
|
||||
class ResizeObserverStub implements ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
async function mountView() {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const wrapper = mount(ExpressCompaniesView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
async function filterByKeyword(value: string) {
|
||||
const input = document.querySelector<HTMLInputElement>('input[placeholder="公司名称、编码、官网或电话"]')
|
||||
expect(input).not.toBeNull()
|
||||
if (!input) return
|
||||
input.value = value
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
}
|
||||
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('ExpressCompaniesView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
it('renders the prototype-backed company records and public fields', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.text()).toContain('DHL')
|
||||
expect(wrapper.text()).toContain('www.dhl.com')
|
||||
expect(wrapper.text()).toContain('95380')
|
||||
expect(wrapper.text()).toContain('已终止')
|
||||
const actions = wrapper.findAll('button').map((button) => button.text().trim())
|
||||
expect(actions).not.toContain('新增')
|
||||
expect(actions).not.toContain('编辑')
|
||||
})
|
||||
|
||||
it('filters by company name or code and renders an actionable empty state', async () => {
|
||||
const wrapper = await mountView()
|
||||
|
||||
await filterByKeyword('YTO')
|
||||
expect(wrapper.text()).toContain('圆通速递')
|
||||
expect(wrapper.text()).not.toContain('顺丰速运')
|
||||
|
||||
await filterByKeyword('不存在的公司')
|
||||
expect(wrapper.text()).toContain('没有符合条件的快递公司')
|
||||
expect(wrapper.text()).toContain('共 0 条')
|
||||
})
|
||||
|
||||
it('opens a detail drawer without exposing maintenance actions to an unauthorized role', async () => {
|
||||
const wrapper = await mountView()
|
||||
const detail = wrapper.findAll('button').find((button) => button.text().trim() === '详情')
|
||||
expect(detail).toBeDefined()
|
||||
|
||||
await detail?.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(document.body.textContent).toContain('快递公司详情')
|
||||
expect(document.body.textContent).toContain('公开联系信息')
|
||||
expect(document.body.textContent).not.toContain('保存快递公司')
|
||||
})
|
||||
})
|
||||
@@ -1,153 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ExternalLink, FileUp, Pencil, Phone, Plus, RotateCcw, Search, Truck } from '@lucide/vue'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import StaticBatchImportDialog from '@/components/StaticBatchImportDialog.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { ExpressCompany, ExpressCompanyStatus } from '@/types/admin'
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const detailOpen = ref(false)
|
||||
const editorOpen = ref(false)
|
||||
const importOpen = ref(false)
|
||||
const selectedCompany = ref<ExpressCompany | null>(null)
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const filters = reactive({ keyword: '', status: '' as ExpressCompanyStatus | '' })
|
||||
const emptyForm = (): Omit<ExpressCompany, 'updatedAt'> => ({ id: '', name: '', code: '', website: '', phone: '', status: '合作中', remark: '' })
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑快递公司' : '新增快递公司')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'express-company-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: computed(() => form.id ? null : 'express-company:write'),
|
||||
})
|
||||
|
||||
const filteredRows = computed(() => store.expressCompanies.filter((company) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
const matchesKeyword = !keyword || [company.name, company.code, company.website, company.phone]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesKeyword && (!filters.status || company.status === filters.status)
|
||||
}))
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
const metrics = computed(() => [
|
||||
{ label: '全部快递公司', value: store.expressCompanies.length, note: '平台快递档案' },
|
||||
{ label: '合作中', value: store.activeExpressCompanies, note: '可新增到履约信息' },
|
||||
{ label: '已终止', value: store.expressCompanies.length - store.activeExpressCompanies, note: '历史订单继续展示' },
|
||||
{ label: '官网已登记', value: store.expressCompanies.filter((item) => item.website).length, note: '公开查询入口' },
|
||||
])
|
||||
|
||||
watch(filters, () => { page.value = 1 })
|
||||
|
||||
function resetFilters() { filters.keyword = ''; filters.status = '' }
|
||||
function openDetail(company: ExpressCompany) { selectedCompany.value = company; detailOpen.value = true }
|
||||
function openCreate() { Object.assign(form, emptyForm()); captureBaseline(); editorOpen.value = true }
|
||||
function openEdit(company: ExpressCompany) { Object.assign(form, company); captureBaseline(); detailOpen.value = false; editorOpen.value = true }
|
||||
function websiteUrl(company: ExpressCompany) { return /^https?:\/\//i.test(company.website) ? company.website : `https://${company.website}` }
|
||||
|
||||
function saveCompany() {
|
||||
if (!form.name.trim() || !form.code.trim()) { ElMessage.warning('请填写快递公司名称和稳定编码'); return }
|
||||
try {
|
||||
store.saveExpressCompany({ ...form, id: form.id || undefined })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '快递公司已更新' : '快递公司已创建')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '快递公司保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(company: ExpressCompany) {
|
||||
const target: ExpressCompanyStatus = company.status === '合作中' ? '已终止' : '合作中'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
target === '已终止' ? '停用后不能新增到订单履约信息,历史订单仍保留公司名称和编码。' : '启用后可重新用于订单履约信息。',
|
||||
`${target === '已终止' ? '停用' : '启用'}快递公司“${company.name}”`,
|
||||
{ confirmButtonText: target === '已终止' ? '确认停用' : '确认启用', cancelButtonText: '取消', type: 'warning' },
|
||||
)
|
||||
store.setExpressCompanyStatus(company.id, target)
|
||||
ElMessage.success(target === '已终止' ? '快递公司已停用' : '快递公司已启用')
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- THESIS: 把快递档案升级为可维护、可批量校验的履约主数据,而不是停留在只读名录。
|
||||
OWN-WORLD: 继承 Admin 的白色数据面板、海军蓝导航、紧凑表格、克制状态色和右侧抽屉。
|
||||
STORY: 平台人员筛选档案,维护唯一编码与名称,控制启停,并按标准模板执行整批导入。
|
||||
FIRST VIEWPORT: 摘要、双条件筛选、导入与新增动作、完整名录连续排列。
|
||||
FORM: 现有 Operate 页面结构的深化,详情与编辑分层,导入使用受保护对话框。
|
||||
FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, and DESIGN.md -->
|
||||
<div class="business-page express-company-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
|
||||
<section class="surface-panel filter-panel" aria-label="快递公司筛选">
|
||||
<div class="filter-fields">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="公司名称、编码、官网或电话" aria-label="快递公司关键词"><template #prefix><Search :size="16" /></template></el-input>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="合作状态"><el-option label="合作中" value="合作中" /><el-option label="已终止" value="已终止" /></el-select>
|
||||
</div>
|
||||
<div class="filter-actions">
|
||||
<el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button>
|
||||
<el-button v-if="access.can('express-company:import')" @click="importOpen = true"><FileUp :size="16" />批量导入</el-button>
|
||||
<el-button v-if="access.can('express-company:write')" type="primary" @click="openCreate"><Plus :size="16" />新增快递公司</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<div class="panel-heading"><div><h2>快递公司名录</h2><p>编码和名称必须唯一;停用后不允许新增引用,历史订单快照不受影响。</p></div></div>
|
||||
<el-table :data="pagedRows" row-key="id" empty-text="没有符合条件的快递公司">
|
||||
<el-table-column label="操作" width="220" fixed="left"><template #default="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as ExpressCompany)">详情</el-button><el-button v-if="access.can('express-company:write')" link type="primary" @click="openEdit(row as ExpressCompany)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can('express-company:write')" link :type="(row as ExpressCompany).status === '合作中' ? 'danger' : 'success'" @click="toggleStatus(row as ExpressCompany)">{{ (row as ExpressCompany).status === '合作中' ? '停用' : '启用' }}</el-button></div></template></el-table-column>
|
||||
<el-table-column label="公司名称" min-width="190"><template #default="{ row }"><div class="express-company-cell"><span><Truck :size="18" /></span><div><strong>{{ (row as ExpressCompany).name }}</strong><small>{{ (row as ExpressCompany).id }} · {{ (row as ExpressCompany).updatedAt }}</small></div></div></template></el-table-column>
|
||||
<el-table-column label="编码" min-width="110"><template #default="{ row }"><code class="code-value">{{ (row as ExpressCompany).code }}</code></template></el-table-column>
|
||||
<el-table-column label="官网" min-width="220"><template #default="{ row }"><a v-if="(row as ExpressCompany).website" class="company-link" :href="websiteUrl(row as ExpressCompany)" target="_blank" rel="noopener noreferrer">{{ (row as ExpressCompany).website }}<ExternalLink :size="14" /></a><span v-else>—</span></template></el-table-column>
|
||||
<el-table-column label="客服电话" min-width="150"><template #default="{ row }"><span class="phone-value"><Phone :size="14" />{{ (row as ExpressCompany).phone || '—' }}</span></template></el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><StatusBadge :status="(row as ExpressCompany).status" /></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="table-footer"><span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="filteredRows.length" layout="prev, pager, next" /></div>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="form.id ? '编辑快递公司' : '新增快递公司'" size="620px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form"><div class="form-section"><h3>档案信息</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="公司名称" required><el-input v-model="form.name" placeholder="请输入快递公司名称" maxlength="80" /></el-form-item>
|
||||
<el-form-item label="稳定编码" required><el-input v-model="form.code" placeholder="如 SF" maxlength="30" /></el-form-item>
|
||||
<el-form-item label="官方查询网址"><el-input v-model="form.website" placeholder="如 www.sf-express.com" maxlength="160" /></el-form-item>
|
||||
<el-form-item label="客服电话"><el-input v-model="form.phone" maxlength="40" /></el-form-item>
|
||||
<el-form-item label="状态"><el-radio-group v-model="form.status"><el-radio-button label="合作中" value="合作中" /><el-radio-button label="已终止" value="已终止" /></el-radio-group></el-form-item>
|
||||
</div><el-form-item label="内部备注"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="300" show-word-limit /></el-form-item></div></el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="access.can('express-company:write')" type="primary" @click="saveCompany">保存快递公司</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="快递公司详情" size="520px" class="business-drawer" :close-on-click-modal="false">
|
||||
<template v-if="selectedCompany"><div class="detail-identity"><span><Truck :size="20" /></span><div><h2>{{ selectedCompany.name }}</h2><p>{{ selectedCompany.code }} · {{ selectedCompany.id }}</p></div><StatusBadge :status="selectedCompany.status" /></div><section class="detail-section"><h3>公开联系信息</h3><dl class="company-detail-list"><div><dt>公司官网</dt><dd><a v-if="selectedCompany.website" class="company-link" :href="websiteUrl(selectedCompany)" target="_blank" rel="noopener noreferrer">{{ selectedCompany.website }}<ExternalLink :size="14" /></a><span v-else>—</span></dd></div><div><dt>客服电话</dt><dd>{{ selectedCompany.phone || '—' }}</dd></div><div><dt>更新时间</dt><dd>{{ selectedCompany.updatedAt }}</dd></div></dl><p class="detail-note">{{ selectedCompany.remark || '暂无内部备注。' }}</p></section></template>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="access.can('express-company:write') && selectedCompany" type="primary" @click="openEdit(selectedCompany)">编辑资料</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<StaticBatchImportDialog v-model="importOpen" entity-label="快递公司" template-url="/templates/express-companies-import-template.xlsx" template-file-name="快递公司导入标准模板-v1.xlsx" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.express-company-cell { display: flex; min-width: 0; align-items: center; gap: 10px; }
|
||||
.express-company-cell > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; background: #eef3fa; color: var(--cc-primary); }
|
||||
.express-company-cell > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
|
||||
.express-company-cell strong, .express-company-cell small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.express-company-cell small { color: var(--cc-muted); font-size: 11px; }
|
||||
.company-link { display: inline-flex; max-width: 100%; align-items: center; gap: 5px; color: var(--cc-primary); text-decoration: none; overflow-wrap: anywhere; }
|
||||
.company-link:hover { text-decoration: underline; }
|
||||
.company-link:focus-visible { outline: 3px solid rgba(21, 94, 239, .2); outline-offset: 2px; }
|
||||
.phone-value { display: inline-flex; align-items: center; gap: 6px; color: #344054; }
|
||||
.phone-value svg { color: var(--cc-muted); }
|
||||
.company-detail-list { margin: 0; }
|
||||
.company-detail-list > div { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 16px; padding: 14px 0; border-bottom: 1px solid var(--cc-border); }
|
||||
.company-detail-list dt { color: var(--cc-muted); font-size: 12px; }
|
||||
.company-detail-list dd { min-width: 0; margin: 0; color: #172033; font-size: 13px; font-weight: 650; }
|
||||
@media (max-width: 420px) { .company-detail-list > div { grid-template-columns: 1fr; gap: 6px; } }
|
||||
</style>
|
||||
@@ -1,243 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ClipboardCheck, Download, Eye, PackageCheck, Pencil, Plus, RotateCcw, Search, Ship, UserRoundCheck } from '@lucide/vue'
|
||||
import { FIRST_LEG_ORDER_STATUSES } from '@chongchong/shared'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import PlatformFirstLegOrdersPanel from '@/components/PlatformFirstLegOrdersPanel.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { FirstLegOrder, OrderStatus } from '@/types/admin'
|
||||
|
||||
type OrderForm = Omit<FirstLegOrder, 'no' | 'createdAt' | 'timeline'>
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const editorOpen = ref(false)
|
||||
const detailOpen = ref(false)
|
||||
const statusDialogOpen = ref(false)
|
||||
const deliveryDialogOpen = ref(false)
|
||||
const currentOrder = ref<FirstLegOrder | null>(null)
|
||||
const selectedRows = ref<FirstLegOrder[]>([])
|
||||
const statusTargetIds = ref<string[]>([])
|
||||
const dateRange = ref<[string, string] | []>([])
|
||||
const filters = reactive({ keyword: '', status: '', mode: '', customerId: '' })
|
||||
const statusForm = reactive<{ status: OrderStatus; operator: string; note: string }>({ status: '已受理', operator: '', note: '' })
|
||||
const deliveryForm = reactive({ orderId: '', expressCompanyId: '', trackingNo: '', operator: '' })
|
||||
|
||||
const emptyForm = (): OrderForm => ({
|
||||
id: '', customerId: '', customerName: '', referenceNo: '', transportMode: '海运整箱 FCL', businessType: '出口',
|
||||
origin: '', destination: '', receivingWarehouseId: '', receivingWarehouseSnapshot: null, container: '40HQ x 1', etd: '', eta: '', amount: 0, currency: 'CNY', status: '草稿',
|
||||
priority: '普通', operator: '未分配', shipper: '', consignee: '', cargoName: '', pieces: 1, weight: 0, volume: 0, remark: '',
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑头程订单' : '创建头程订单')
|
||||
const editorRequiredPermission = computed(() => form.id ? null : 'first-leg:create')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'first-leg-order-editor',
|
||||
label: editorLabel,
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission: editorRequiredPermission,
|
||||
})
|
||||
const statusOptions: OrderStatus[] = FIRST_LEG_ORDER_STATUSES.map((item) => item.label)
|
||||
const statusTransitions: Record<OrderStatus, OrderStatus[]> = { 草稿: ['待审核', '已取消'], 待审核: ['已受理', '已取消'], 已受理: ['已订舱', '已取消'], 已订舱: ['运输中', '已取消'], 运输中: ['已完成', '已取消'], 已完成: [], 已取消: [] }
|
||||
const availableStatusOptions = computed(() => {
|
||||
if (statusTargetIds.value.length !== 1) return ['已取消'] as OrderStatus[]
|
||||
const order = store.orders.find((item) => item.id === statusTargetIds.value[0])
|
||||
return order ? [order.status, ...statusTransitions[order.status]] : []
|
||||
})
|
||||
const operatorOptions = computed(() => store.users.filter((item) => item.status === '正常' && ['运营部', '操作部', '平台管理'].includes(item.department)).map((item) => item.name))
|
||||
const selectableWarehouses = computed(() => store.warehouses.filter((item) => item.status === 'ACTIVE' || item.id === form.receivingWarehouseId))
|
||||
|
||||
const filteredRows = computed(() => store.orders.filter((item) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
const keywordMatch = !keyword || [item.no, item.customerName, item.referenceNo, item.origin, item.destination].some((value) => value.toLowerCase().includes(keyword))
|
||||
const dateMatch = dateRange.value.length !== 2 || (item.createdAt.slice(0, 10) >= dateRange.value[0] && item.createdAt.slice(0, 10) <= dateRange.value[1])
|
||||
return keywordMatch && dateMatch && (!filters.status || item.status === filters.status) && (!filters.mode || item.transportMode === filters.mode) && (!filters.customerId || item.customerId === filters.customerId)
|
||||
}))
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
const metrics = computed(() => [
|
||||
{ label: '全部订单', value: store.orders.length, note: '当前数据范围' },
|
||||
{ label: '待审核/受理', value: store.pendingOrders, note: '需要运营处理' },
|
||||
{ label: '运输中', value: store.orders.filter((item) => item.status === '运输中').length, note: '正在履约' },
|
||||
{ label: '应收金额', value: `¥${store.orders.reduce((total, item) => total + (item.currency === 'CNY' ? item.amount : 0), 0).toLocaleString()}`, note: '人民币订单合计' },
|
||||
])
|
||||
|
||||
watch(filters, () => { page.value = 1 })
|
||||
watch(dateRange, () => { page.value = 1 })
|
||||
watch(
|
||||
() => access.can('first-leg:create'),
|
||||
(canCreate) => {
|
||||
if (canCreate || !editorOpen.value || form.id) return
|
||||
editorOpen.value = false
|
||||
Object.assign(form, emptyForm())
|
||||
},
|
||||
{ flush: 'sync' },
|
||||
)
|
||||
|
||||
function resetFilters() { Object.assign(filters, { keyword: '', status: '', mode: '', customerId: '' }); dateRange.value = [] }
|
||||
function openCreate() { Object.assign(form, emptyForm()); captureBaseline(); editorOpen.value = true }
|
||||
function openEdit(row: FirstLegOrder) { Object.assign(form, row); captureBaseline(); detailOpen.value = false; editorOpen.value = true }
|
||||
function openDetail(row: FirstLegOrder) { currentOrder.value = row; detailOpen.value = true }
|
||||
function handleCustomerChange(customerId: string) {
|
||||
const customer = store.customers.find((item) => item.id === customerId)
|
||||
if (!customer) return
|
||||
form.customerName = customer.name
|
||||
if (!form.shipper) form.shipper = customer.name
|
||||
}
|
||||
|
||||
function saveOrder(status: '草稿' | '待审核') {
|
||||
if (!form.customerId || !form.origin.trim() || !form.destination.trim() || !form.receivingWarehouseId || !form.cargoName.trim()) {
|
||||
ElMessage.warning('请填写客户、起运地、目的地、平台收货仓和货物名称')
|
||||
return
|
||||
}
|
||||
const customer = store.customers.find((item) => item.id === form.customerId)
|
||||
try {
|
||||
store.saveOrder({ ...form, id: form.id || undefined, customerName: customer?.name || form.customerName, status: form.id ? form.status : status })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(status === '草稿' ? '订单草稿已保存' : '头程订单已创建并提交审核')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '订单保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openStatusDialog(rows: FirstLegOrder[]) {
|
||||
if (rows.length === 0) return
|
||||
statusTargetIds.value = rows.map((item) => item.id)
|
||||
statusForm.status = rows.length === 1 ? rows[0].status : '已受理'
|
||||
statusForm.operator = rows.length === 1 && rows[0].operator !== '未分配' ? rows[0].operator : ''
|
||||
statusForm.note = ''
|
||||
statusDialogOpen.value = true
|
||||
}
|
||||
|
||||
function applyStatus() {
|
||||
if (!statusForm.operator && !['草稿', '待审核', '已取消'].includes(statusForm.status)) {
|
||||
ElMessage.warning('请指定订单负责人')
|
||||
return
|
||||
}
|
||||
try { statusTargetIds.value.forEach((id) => store.updateOrderStatus(id, statusForm.status, statusForm.operator, statusForm.note)) } catch (error) { ElMessage.error(error instanceof Error ? error.message : '订单状态更新失败'); return }
|
||||
statusDialogOpen.value = false
|
||||
selectedRows.value = []
|
||||
if (currentOrder.value) currentOrder.value = store.orders.find((item) => item.id === currentOrder.value?.id) || null
|
||||
ElMessage.success(statusTargetIds.value.length > 1 ? `已更新 ${statusTargetIds.value.length} 笔订单` : '订单状态已更新')
|
||||
}
|
||||
|
||||
function openDeliveryDialog(order: FirstLegOrder) {
|
||||
currentOrder.value = order
|
||||
Object.assign(deliveryForm, {
|
||||
orderId: order.id,
|
||||
expressCompanyId: order.deliveryInfo?.expressCompanyId || '',
|
||||
trackingNo: order.deliveryInfo?.trackingNo || '',
|
||||
operator: order.deliveryInfo?.operator || (order.operator === '未分配' ? '' : order.operator),
|
||||
})
|
||||
deliveryDialogOpen.value = true
|
||||
}
|
||||
|
||||
function saveDeliveryInfo() {
|
||||
if (!deliveryForm.expressCompanyId || !deliveryForm.trackingNo.trim() || !deliveryForm.operator) {
|
||||
ElMessage.warning('请选择快递公司,并填写快递单号和操作人')
|
||||
return
|
||||
}
|
||||
try {
|
||||
store.saveOrderDeliveryInfo(deliveryForm.orderId, deliveryForm.expressCompanyId, deliveryForm.trackingNo, deliveryForm.operator)
|
||||
currentOrder.value = store.orders.find((item) => item.id === deliveryForm.orderId) || null
|
||||
deliveryDialogOpen.value = false
|
||||
ElMessage.success('履约快递信息已保存')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '履约快递信息保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
function deliveryCompanies(order: FirstLegOrder | null) {
|
||||
return store.expressCompanies.filter((item) => item.status === '合作中' || item.id === order?.deliveryInfo?.expressCompanyId)
|
||||
}
|
||||
|
||||
function statusStep(status: OrderStatus) {
|
||||
const index = ['待审核', '已受理', '已订舱', '运输中', '已完成'].indexOf(status)
|
||||
return index < 0 ? 0 : index + 1
|
||||
}
|
||||
|
||||
function contactCustomerForModification(order: FirstLegOrder) {
|
||||
try { store.recordCustomerModificationContact(order.id, order.operator === '未分配' ? '平台管理员' : order.operator); currentOrder.value = store.orders.find((item) => item.id === order.id) || null; ElMessage.success('已记录联系客户修改,订单继续保持待审核') }
|
||||
catch (error) { ElMessage.error(error instanceof Error ? error.message : '记录失败') }
|
||||
}
|
||||
|
||||
function canEditOrder(order: FirstLegOrder) { return access.can('first-leg:status') || (order.status === '草稿' && access.can('first-leg:create')) }
|
||||
|
||||
function exportOrders() { ElMessage.success(`已生成 ${filteredRows.value.length} 笔订单的导出任务`) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PlatformFirstLegOrdersPanel v-if="access.usesServerPermissions" />
|
||||
<div v-else class="business-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
<section class="surface-panel filter-panel order-filter" aria-label="头程订单筛选">
|
||||
<div class="filter-fields">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="订单号、客户、参考号或港口" aria-label="订单关键词"><template #prefix><Search :size="16" /></template></el-input>
|
||||
<el-select v-model="filters.customerId" clearable filterable placeholder="全部客户" aria-label="订单客户"><el-option v-for="item in store.customers" :key="item.id" :label="item.name" :value="item.id" /></el-select>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="订单状态"><el-option v-for="item in statusOptions" :key="item" :label="item" :value="item" /></el-select>
|
||||
<el-select v-model="filters.mode" clearable placeholder="全部运输方式" aria-label="运输方式"><el-option v-for="item in ['海运整箱 FCL','海运拼箱 LCL','空运','陆运']" :key="item" :label="item" :value="item" /></el-select>
|
||||
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="创建开始" end-placeholder="创建结束" aria-label="订单创建日期" />
|
||||
</div>
|
||||
<div class="filter-actions"><el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button><el-button v-if="access.can('first-leg:status')" :disabled="selectedRows.length === 0" @click="openStatusDialog(selectedRows)"><ClipboardCheck :size="16" />批量更新</el-button><el-button v-if="!access.usesServerPermissions" @click="exportOrders"><Download :size="16" />导出</el-button><el-button v-if="access.can('first-leg:create')" type="primary" @click="openCreate"><Plus :size="16" />创建订单</el-button></div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<header class="panel-heading"><div><h2>头程订单</h2><p>从审核、受理、订舱到运输完成,统一跟进订单状态与负责人。</p></div><span v-if="selectedRows.length" class="selection-note">已选择 {{ selectedRows.length }} 笔</span></header>
|
||||
<el-table :data="pagedRows" row-key="id" empty-text="没有符合条件的头程订单" @selection-change="selectedRows = $event">
|
||||
<el-table-column label="操作" width="290" fixed="left"><template #default="{ row }"><div class="table-actions"><el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button><el-button v-if="canEditOrder(row as FirstLegOrder)" link type="primary" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button><el-button v-if="access.can('first-leg:delivery')" link type="primary" @click="openDeliveryDialog(row as FirstLegOrder)"><PackageCheck :size="15" />快递</el-button><el-button v-if="access.can('first-leg:status')" link type="success" @click="openStatusDialog([row as FirstLegOrder])"><UserRoundCheck :size="15" />处理</el-button></div></template></el-table-column>
|
||||
<el-table-column type="selection" width="44" />
|
||||
<el-table-column label="订单信息" min-width="210"><template #default="{ row }"><div class="primary-cell"><strong class="order-no">{{ row.no }}</strong><span>{{ row.referenceNo || '无客户参考号' }} · {{ row.createdAt }}</span></div></template></el-table-column>
|
||||
<el-table-column label="客户" min-width="185" show-overflow-tooltip><template #default="{ row }"><div class="primary-cell"><strong>{{ row.customerName }}</strong><span>{{ row.businessType }} · {{ row.priority }}</span></div></template></el-table-column>
|
||||
<el-table-column label="运输方案" min-width="190"><template #default="{ row }"><div class="primary-cell"><strong>{{ row.transportMode }}</strong><span>{{ row.container }}</span></div></template></el-table-column>
|
||||
<el-table-column label="路线" min-width="160"><template #default="{ row }"><div class="route-cell"><span>{{ row.origin }}</span><i>→</i><span>{{ row.destination }}</span></div></template></el-table-column>
|
||||
<el-table-column label="平台收货仓" min-width="150"><template #default="{ row }"><div class="primary-cell"><strong>{{ row.receivingWarehouseSnapshot?.name || '-' }}</strong><span>{{ row.receivingWarehouseSnapshot?.code || '-' }}</span></div></template></el-table-column>
|
||||
<el-table-column label="履约快递" min-width="160"><template #default="{ row }"><div class="primary-cell"><strong>{{ row.deliveryInfo?.expressCompanyName || '未维护' }}</strong><span>{{ row.deliveryInfo?.trackingNo || '—' }}</span></div></template></el-table-column>
|
||||
<el-table-column label="应收" width="118" align="right"><template #default="{ row }"><strong>{{ row.currency === 'CNY' ? '¥' : '$' }}{{ row.amount.toLocaleString() }}</strong></template></el-table-column>
|
||||
<el-table-column label="负责人" width="100"><template #default="{ row }"><span :class="{ 'muted-value': row.operator === '未分配' }">{{ row.operator }}</span></template></el-table-column>
|
||||
<el-table-column label="状态" width="96"><template #default="{ row }"><StatusBadge :status="row.status" /></template></el-table-column>
|
||||
</el-table>
|
||||
<footer class="table-footer"><span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="filteredRows.length" layout="prev, pager, next" /></footer>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="form.id ? '编辑头程订单' : '创建头程订单'" size="780px" class="business-drawer editor-drawer order-editor" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form">
|
||||
<div class="form-section"><h3>业务信息</h3><div class="form-grid three-cols"><el-form-item label="客户" required><el-select v-model="form.customerId" filterable placeholder="请选择客户" @change="handleCustomerChange"><el-option v-for="item in store.customers.filter((customer) => customer.status === '正常')" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><el-form-item label="运输方式"><el-select v-model="form.transportMode"><el-option v-for="item in ['海运整箱 FCL','海运拼箱 LCL','空运','陆运']" :key="item" :label="item" :value="item" /></el-select></el-form-item><el-form-item label="业务类型"><el-radio-group v-model="form.businessType"><el-radio-button label="出口" value="出口" /><el-radio-button label="进口" value="进口" /></el-radio-group></el-form-item><el-form-item label="客户参考号"><el-input v-model="form.referenceNo" /></el-form-item><el-form-item label="优先级"><el-select v-model="form.priority"><el-option label="普通" value="普通" /><el-option label="紧急" value="紧急" /><el-option label="加急" value="加急" /></el-select></el-form-item><el-form-item label="负责人"><el-select v-model="form.operator" clearable placeholder="暂不分配"><el-option v-for="item in operatorOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item></div></div>
|
||||
<div class="form-section"><h3>运输信息</h3><div class="form-grid three-cols"><el-form-item label="起运地 / POL" required><el-input v-model="form.origin" placeholder="如 宁波" /></el-form-item><el-form-item label="目的地 / POD" required><el-input v-model="form.destination" placeholder="如 洛杉矶" /></el-form-item><el-form-item label="平台收货仓" required><el-select v-model="form.receivingWarehouseId" filterable placeholder="请选择收货仓"><el-option v-for="warehouse in selectableWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.city}`" :value="warehouse.id" :disabled="warehouse.status !== 'ACTIVE'" /></el-select></el-form-item><el-form-item label="柜型 / 箱量"><el-input v-model="form.container" placeholder="如 40HQ x 2" /></el-form-item><el-form-item label="预计出发 ETD"><el-date-picker v-model="form.etd" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /></el-form-item><el-form-item label="预计到达 ETA"><el-date-picker v-model="form.eta" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" /></el-form-item><el-form-item label="应收金额"><div class="amount-field"><el-select v-model="form.currency"><el-option label="CNY" value="CNY" /><el-option label="USD" value="USD" /></el-select><el-input-number v-model="form.amount" :min="0" :precision="2" controls-position="right" /></div></el-form-item></div></div>
|
||||
<div class="form-section"><h3>货物与收发货方</h3><div class="form-grid three-cols"><el-form-item label="货物名称" required><el-input v-model="form.cargoName" /></el-form-item><el-form-item label="件数"><el-input-number v-model="form.pieces" :min="1" controls-position="right" /></el-form-item><el-form-item label="毛重 (kg)"><el-input-number v-model="form.weight" :min="0" :precision="2" controls-position="right" /></el-form-item><el-form-item label="体积 (m³)"><el-input-number v-model="form.volume" :min="0" :precision="2" controls-position="right" /></el-form-item><el-form-item label="发货方"><el-input v-model="form.shipper" /></el-form-item><el-form-item label="收货方"><el-input v-model="form.consignee" /></el-form-item></div><el-form-item label="内部备注"><el-input v-model="form.remark" type="textarea" :rows="3" placeholder="仅平台内部可见" /></el-form-item></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="!form.id && access.can('first-leg:create')" @click="saveOrder('草稿')">保存草稿</el-button><el-button v-if="form.id || access.can('first-leg:create')" type="primary" @click="saveOrder('待审核')">{{ form.id ? '保存修改' : '创建并提交' }}</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="detailOpen" title="订单详情" size="720px" class="business-drawer">
|
||||
<template v-if="currentOrder">
|
||||
<div class="order-detail-head"><div><span>{{ currentOrder.transportMode }}</span><h2>{{ currentOrder.no }}</h2><p>{{ currentOrder.customerName }} · {{ currentOrder.referenceNo }}</p></div><StatusBadge :status="currentOrder.status" /></div>
|
||||
<div class="order-progress"><el-steps :active="statusStep(currentOrder.status)" finish-status="success" align-center><el-step title="待审核" /><el-step title="已受理" /><el-step title="已订舱" /><el-step title="运输中" /><el-step title="已完成" /></el-steps></div>
|
||||
<div class="detail-section"><h3>订单概要</h3><el-descriptions :column="2" border><el-descriptions-item label="运输路线">{{ currentOrder.origin }} → {{ currentOrder.destination }}</el-descriptions-item><el-descriptions-item label="业务类型">{{ currentOrder.businessType }}</el-descriptions-item><el-descriptions-item label="平台收货仓"><strong>{{ currentOrder.receivingWarehouseSnapshot?.name || '-' }}</strong><span v-if="currentOrder.receivingWarehouseSnapshot" class="warehouse-snapshot-detail">{{ currentOrder.receivingWarehouseSnapshot.code }} · {{ currentOrder.receivingWarehouseSnapshot.province }}{{ currentOrder.receivingWarehouseSnapshot.city }}{{ currentOrder.receivingWarehouseSnapshot.address }}</span></el-descriptions-item><el-descriptions-item label="柜型/箱量">{{ currentOrder.container }}</el-descriptions-item><el-descriptions-item label="负责人">{{ currentOrder.operator }}</el-descriptions-item><el-descriptions-item label="ETD / ETA">{{ currentOrder.etd || '-' }} / {{ currentOrder.eta || '-' }}</el-descriptions-item><el-descriptions-item label="应收金额">{{ currentOrder.currency === 'CNY' ? '¥' : '$' }}{{ currentOrder.amount.toLocaleString() }}</el-descriptions-item></el-descriptions></div>
|
||||
<div class="detail-section"><h3>货物信息</h3><MetricStrip :items="[{ label: '品名', value: currentOrder.cargoName }, { label: '件数', value: currentOrder.pieces }, { label: '毛重', value: `${currentOrder.weight.toLocaleString()} kg` }, { label: '体积', value: `${currentOrder.volume} m³` }]" /><div class="party-grid"><div><span>发货方</span><strong>{{ currentOrder.shipper }}</strong></div><div><span>收货方</span><strong>{{ currentOrder.consignee }}</strong></div></div></div>
|
||||
<div class="detail-section"><h3>履约快递</h3><el-empty v-if="!currentOrder.deliveryInfo" description="尚未维护快递公司和快递单号" :image-size="64" /><el-descriptions v-else :column="2" border><el-descriptions-item label="快递公司">{{ currentOrder.deliveryInfo.expressCompanyName }}({{ currentOrder.deliveryInfo.expressCompanyCode }})</el-descriptions-item><el-descriptions-item label="快递单号">{{ currentOrder.deliveryInfo.trackingNo }}</el-descriptions-item><el-descriptions-item label="维护人">{{ currentOrder.deliveryInfo.operator }}</el-descriptions-item><el-descriptions-item label="更新时间">{{ currentOrder.deliveryInfo.updatedAt }}</el-descriptions-item></el-descriptions></div>
|
||||
<div class="detail-section"><h3>处理记录</h3><el-timeline><el-timeline-item v-for="item in [...currentOrder.timeline].reverse()" :key="`${item.time}-${item.title}`" :timestamp="`${item.time} · ${item.operator}`" placement="top"><strong>{{ item.title }}</strong><p>{{ item.description }}</p></el-timeline-item></el-timeline></div>
|
||||
</template>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="currentOrder && canEditOrder(currentOrder)" @click="openEdit(currentOrder)">编辑订单</el-button><el-button v-if="currentOrder?.status === '待审核' && access.can('first-leg:status')" @click="contactCustomerForModification(currentOrder)">联系客户修改</el-button><el-button v-if="access.can('first-leg:delivery')" @click="currentOrder && openDeliveryDialog(currentOrder)">维护快递</el-button><el-button v-if="access.can('first-leg:status')" type="primary" @click="currentOrder && openStatusDialog([currentOrder])">更新状态</el-button></div></template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="statusDialogOpen" :title="statusTargetIds.length > 1 ? `批量更新 ${statusTargetIds.length} 笔订单` : '处理订单'" width="520px" class="status-dialog">
|
||||
<el-form label-position="top"><el-form-item label="订单状态"><el-select v-model="statusForm.status"><el-option v-for="item in availableStatusOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item><el-form-item label="负责人"><el-select v-model="statusForm.operator" clearable placeholder="请选择负责人"><el-option v-for="item in operatorOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item><el-form-item label="处理说明"><el-input v-model="statusForm.note" type="textarea" :rows="3" placeholder="记录本次状态变更的原因或下一步动作" /></el-form-item></el-form>
|
||||
<template #footer><el-button @click="statusDialogOpen = false">取消</el-button><el-button v-if="access.can('first-leg:status')" type="primary" @click="applyStatus">确认更新</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="deliveryDialogOpen" title="维护履约快递" width="520px">
|
||||
<el-alert title="一期每个订单仅维护一组快递公司和快递单号;修改会覆盖当前值并追加审计记录。" type="info" :closable="false" show-icon />
|
||||
<el-form label-position="top" class="delivery-form"><el-form-item label="快递公司" required><el-select v-model="deliveryForm.expressCompanyId" filterable placeholder="请选择合作中的快递公司"><el-option v-for="item in deliveryCompanies(currentOrder)" :key="item.id" :label="`${item.name} · ${item.code}`" :value="item.id" :disabled="item.status !== '合作中'" /></el-select></el-form-item><el-form-item label="快递单号" required><el-input v-model="deliveryForm.trackingNo" maxlength="80" placeholder="请输入完整快递单号" /></el-form-item><el-form-item label="操作人" required><el-select v-model="deliveryForm.operator" filterable placeholder="请选择操作人"><el-option v-for="item in operatorOptions" :key="item" :label="item" :value="item" /></el-select></el-form-item></el-form>
|
||||
<template #footer><el-button @click="deliveryDialogOpen = false">取消</el-button><el-button v-if="access.can('first-leg:delivery')" type="primary" @click="saveDeliveryInfo">保存快递信息</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<PlatformFirstLegOrdersPanel />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.warehouse-snapshot-detail { display: block; margin-top: 4px; color: var(--cc-muted); font-size: 11px; line-height: 1.5; }
|
||||
</style>
|
||||
|
||||
@@ -1,80 +1,19 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ForbiddenView from './ForbiddenView.vue'
|
||||
|
||||
let pinia: ReturnType<typeof createPinia>
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
const values = new Map<string, string>()
|
||||
return {
|
||||
get length() { return values.size },
|
||||
clear: () => values.clear(),
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
key: (index) => [...values.keys()][index] ?? null,
|
||||
removeItem: (key) => values.delete(key),
|
||||
setItem: (key, value) => values.set(key, value),
|
||||
}
|
||||
}
|
||||
|
||||
async function mountForbidden(from: string) {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/customers', component: { template: '<p>customers</p>' } },
|
||||
{ path: '/403', component: ForbiddenView },
|
||||
],
|
||||
})
|
||||
await router.push({ path: '/403', query: { from } })
|
||||
await router.isReady()
|
||||
|
||||
const wrapper = mount(ForbiddenView, {
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
stubs: {
|
||||
'el-button': {
|
||||
template: '<button type="button" @click="$emit(\'click\')"><slot /></button>',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return { wrapper, router }
|
||||
}
|
||||
|
||||
describe('ForbiddenView', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
pinia = createPinia()
|
||||
it('shows server-account guidance and sanitizes the source path', async () => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
})
|
||||
|
||||
it('shows the preview role and replaces the route with the first accessible page', async () => {
|
||||
const access = useAccessPreviewStore()
|
||||
access.initializeAfterLogin()
|
||||
const { wrapper, router } = await mountForbidden('/customers?status=pending')
|
||||
|
||||
expect(wrapper.text()).toContain('权限预览:超级管理员')
|
||||
expect(wrapper.text()).toContain('来源页面:/customers?status=pending')
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/customers')
|
||||
})
|
||||
|
||||
it('sanitizes an external-looking source and guides role switching when no role is available', async () => {
|
||||
const data = useAdminDataStore()
|
||||
data.roles.forEach((role) => { role.status = '停用' })
|
||||
const access = useAccessPreviewStore()
|
||||
access.initializeAfterLogin()
|
||||
const { wrapper } = await mountForbidden('//example.com')
|
||||
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/403', component: ForbiddenView }] })
|
||||
await router.push({ path: '/403', query: { from: '//example.com' } })
|
||||
await router.isReady()
|
||||
const wrapper = mount(ForbiddenView, { global: { plugins: [pinia, router] } })
|
||||
expect(wrapper.text()).toContain('当前登录账号无法访问此页面')
|
||||
expect(wrapper.text()).toContain('来源页面:/')
|
||||
expect(wrapper.text()).not.toContain('//example.com')
|
||||
expect(wrapper.text()).toContain('当前无可用角色')
|
||||
expect(wrapper.text()).toContain('请使用顶栏切换角色')
|
||||
expect(wrapper.find('button').exists()).toBe(false)
|
||||
expect(wrapper.text()).not.toContain('权限预览')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,30 +8,21 @@ import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const access = useAccessPreviewStore()
|
||||
|
||||
const sourcePath = computed(() => sanitizeInternalPath(route.query.from))
|
||||
const previewRoleLabel = computed(() => access.currentRole
|
||||
? `权限预览:${access.currentRole.name}`
|
||||
: '当前无可用角色')
|
||||
|
||||
async function returnToAccessiblePage() {
|
||||
if (!access.firstAccessiblePath) return
|
||||
await router.replace(access.firstAccessiblePath)
|
||||
if (access.firstAccessiblePath) await router.replace(access.firstAccessiblePath)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="forbidden-page" aria-labelledby="forbidden-title">
|
||||
<div class="forbidden-icon" aria-hidden="true">
|
||||
<ShieldX :size="32" />
|
||||
</div>
|
||||
<div class="forbidden-icon" aria-hidden="true"><ShieldX :size="32" /></div>
|
||||
<p class="section-kicker">403 · 权限不足</p>
|
||||
<h2 id="forbidden-title">当前预览角色无法访问此页面</h2>
|
||||
<p>{{ previewRoleLabel }}</p>
|
||||
<h2 id="forbidden-title">当前登录账号无法访问此页面</h2>
|
||||
<p>请联系平台管理员核对账号角色和权限配置。</p>
|
||||
<p class="forbidden-source">来源页面:{{ sourcePath }}</p>
|
||||
<el-button v-if="access.firstAccessiblePath" type="primary" @click="returnToAccessiblePage">
|
||||
返回可访问页面
|
||||
</el-button>
|
||||
<p v-else>请使用顶栏切换角色,选择具有可访问菜单的角色。</p>
|
||||
<el-button v-if="access.firstAccessiblePath" type="primary" @click="returnToAccessiblePage">返回可访问页面</el-button>
|
||||
<p v-else>当前账号暂无可访问的业务菜单。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { Cable } from '@lucide/vue'
|
||||
|
||||
defineProps<{
|
||||
moduleName: string
|
||||
description: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="surface-panel unavailable-panel" role="status" aria-live="polite">
|
||||
<Cable :size="30" aria-hidden="true" />
|
||||
<p class="section-kicker">接口尚未开放</p>
|
||||
<h2>{{ moduleName }}暂不可用</h2>
|
||||
<p>{{ description }}</p>
|
||||
<p class="secondary-text">为避免产生误导,本页面不会展示演示数据,也不会模拟新增、编辑或导入成功。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.unavailable-panel {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 10px;
|
||||
max-width: 720px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.unavailable-panel h2,
|
||||
.unavailable-panel p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,128 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { KeyRound, Pencil, Plus, RotateCcw, Search, UserRoundCog } from '@lucide/vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, RotateCcw, Search } from '@lucide/vue'
|
||||
import {
|
||||
createPlatformInternalUser,
|
||||
listPlatformInternalUsers,
|
||||
listPlatformRoles,
|
||||
type PlatformInternalUser,
|
||||
type PlatformRoleResource,
|
||||
} from '@/api/platformIam'
|
||||
import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/api/http'
|
||||
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { InternalUser } from '@/types/admin'
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const users = ref<PlatformInternalUser[]>([])
|
||||
const roles = ref<PlatformRoleResource[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const loadError = ref<ApiErrorDetails | null>(null)
|
||||
const editorOpen = ref(false)
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const editorOpen = ref(false)
|
||||
const filters = reactive({ keyword: '', department: '', roleId: '', status: '' })
|
||||
const emptyForm = (): Omit<InternalUser, 'lastLogin' | 'createdAt'> => ({ id: '', account: '', name: '', department: '', phone: '', email: '', roleIds: [], status: '正常' })
|
||||
const filters = reactive({ keyword: '', status: '' })
|
||||
const emptyForm = () => ({ authUserId: '', userType: 'PLATFORM_STAFF', displayName: '', phone: '', email: '' })
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑内部用户' : '新增内部用户')
|
||||
const requiredPermission = computed(() => 'system:user:write')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'internal-user-editor',
|
||||
label: editorLabel,
|
||||
label: computed(() => '新增内部用户'),
|
||||
isOpen: editorOpen,
|
||||
model: form,
|
||||
requiredPermission,
|
||||
requiredPermission: computed(() => 'platform:user:create'),
|
||||
})
|
||||
|
||||
const roleName = (id: string) => store.roles.find((role) => role.id === id)?.name || id
|
||||
const filteredRows = computed(() => store.users.filter((item) => {
|
||||
const canCreate = computed(() => access.can('platform:user:create'))
|
||||
const roleNames = computed(() => new Map(roles.value.map((role) => [String(role.id), role.roleName])))
|
||||
const filteredRows = computed(() => users.value.filter((item) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
const matchesKeyword = !keyword || [item.account, item.name, item.phone, item.email].some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesKeyword && (!filters.department || item.department === filters.department) && (!filters.roleId || item.roleIds.includes(filters.roleId)) && (!filters.status || item.status === filters.status)
|
||||
const matchesKeyword = !keyword || [item.displayName, item.username, item.memberNo ?? '', String(item.authUserId)]
|
||||
.some((value) => value.toLowerCase().includes(keyword))
|
||||
return matchesKeyword && (!filters.status || item.status === filters.status)
|
||||
}))
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize))
|
||||
const departments = computed(() => [...new Set(store.users.map((item) => item.department))])
|
||||
const metrics = computed(() => [
|
||||
{ label: '内部用户', value: store.users.length, note: '平台员工账号' },
|
||||
{ label: '正常账号', value: store.activeUsers, note: '当前可登录' },
|
||||
{ label: '已停用', value: store.users.filter((item) => item.status === '停用').length, note: '无法登录平台' },
|
||||
{ label: '角色覆盖', value: store.roles.length, note: '已配置角色' },
|
||||
{ label: '内部用户', value: users.value.length, note: 'IAM 返回的用户' },
|
||||
{ label: '正常账号', value: users.value.filter((item) => item.status === 'ACTIVE').length, note: '当前有效' },
|
||||
{ label: '停用账号', value: users.value.filter((item) => item.status !== 'ACTIVE').length, note: '只读展示' },
|
||||
])
|
||||
|
||||
watch(filters, () => { page.value = 1 })
|
||||
|
||||
function resetFilters() { Object.assign(filters, { keyword: '', department: '', roleId: '', status: '' }) }
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
users.value = []
|
||||
try {
|
||||
const [userRecords, roleRecords] = await Promise.all([listPlatformInternalUsers(), listPlatformRoles()])
|
||||
users.value = userRecords
|
||||
roles.value = roleRecords
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorDetails(error, '内部用户列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() { Object.assign(filters, { keyword: '', status: '' }) }
|
||||
function roleLabel(roleId: number | string) { return roleNames.value.get(String(roleId)) ?? `角色 #${roleId}` }
|
||||
function openCreate() { Object.assign(form, emptyForm()); captureBaseline(); editorOpen.value = true }
|
||||
function openEdit(row: InternalUser) { Object.assign(form, { ...row, roleIds: [...row.roleIds] }); captureBaseline(); editorOpen.value = true }
|
||||
|
||||
function saveUser() {
|
||||
if (!form.account.trim() || !form.name.trim() || !form.department.trim() || form.roleIds.length === 0) {
|
||||
ElMessage.warning('请填写账号、姓名、部门并至少分配一个角色')
|
||||
async function saveUser() {
|
||||
if (!form.authUserId.trim() || !form.displayName.trim() || !form.userType.trim()) {
|
||||
ElMessage.warning('请填写认证用户 ID、姓名和用户类型')
|
||||
return
|
||||
}
|
||||
if (!form.id && store.users.some((item) => item.account === form.account)) {
|
||||
ElMessage.error('该登录账号已存在,请更换账号')
|
||||
return
|
||||
}
|
||||
store.saveUser({ ...form, id: form.id || undefined, roleIds: [...form.roleIds] })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '用户资料已更新' : '内部用户已创建')
|
||||
}
|
||||
|
||||
async function resetPassword(row: InternalUser) {
|
||||
saving.value = true
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认重置“${row.name}”的登录密码吗?`, '重置密码', { confirmButtonText: '确认重置', cancelButtonText: '取消', type: 'warning' })
|
||||
ElMessage.success('密码已重置,请通知用户首次登录后修改密码')
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
await createPlatformInternalUser({ ...form, authUserId: form.authUserId.trim() })
|
||||
editorOpen.value = false
|
||||
ElMessage.success('内部用户已创建')
|
||||
await loadUsers()
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, '内部用户创建失败,请稍后重试'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(row: InternalUser) {
|
||||
if (row.account === 'admin') { ElMessage.warning('系统管理员账号不能停用'); return }
|
||||
const target = row.status === '正常' ? '停用' : '正常'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${target === '停用' ? '停用' : '启用'}“${row.name}”的账号吗?`, '账号状态', { confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
store.setUserStatus(row.id, target)
|
||||
ElMessage.success(`账号已${target === '正常' ? '启用' : '停用'}`)
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
}
|
||||
}
|
||||
onMounted(loadUsers)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
<el-alert type="info" :closable="false" show-icon title="成员列表已返回当前角色关系;编辑、启停、重置密码及角色分配操作尚未接入本页面。" />
|
||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert">
|
||||
<template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadUsers">重新加载</el-button></template>
|
||||
</el-alert>
|
||||
<section class="surface-panel filter-panel" aria-label="内部用户筛选">
|
||||
<div class="filter-fields">
|
||||
<el-input v-model="filters.keyword" clearable placeholder="账号、姓名、手机或邮箱" aria-label="用户关键词"><template #prefix><Search :size="16" /></template></el-input>
|
||||
<el-select v-model="filters.department" clearable placeholder="全部部门" aria-label="用户部门"><el-option v-for="item in departments" :key="item" :label="item" :value="item" /></el-select>
|
||||
<el-select v-model="filters.roleId" clearable placeholder="全部角色" aria-label="用户角色"><el-option v-for="item in store.roles" :key="item.id" :label="item.name" :value="item.id" /></el-select>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="用户状态"><el-option label="正常" value="正常" /><el-option label="停用" value="停用" /></el-select>
|
||||
<el-input v-model="filters.keyword" clearable placeholder="登录账号、认证用户 ID、姓名或成员编号" aria-label="用户关键词"><template #prefix><Search :size="16" /></template></el-input>
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="用户状态"><el-option label="正常" value="ACTIVE" /><el-option label="停用" value="INACTIVE" /></el-select>
|
||||
</div>
|
||||
<div class="filter-actions"><el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button><el-button v-if="access.can('system:user:write')" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></div>
|
||||
<div class="filter-actions"><el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<header class="panel-heading"><div><h2>内部用户</h2><p>维护平台员工账号,并通过角色控制菜单和操作权限。</p></div></header>
|
||||
<el-table :data="pagedRows" row-key="id" empty-text="没有符合条件的内部用户">
|
||||
<el-table-column label="操作" width="250" fixed="left"><template #default="{ row }"><div v-if="access.can('system:user:write')" class="table-actions"><el-button link type="primary" @click="openEdit(row as InternalUser)"><Pencil :size="15" />编辑</el-button><el-button link type="primary" @click="resetPassword(row as InternalUser)"><KeyRound :size="15" />重置密码</el-button><el-button link :type="row.status === '正常' ? 'danger' : 'success'" :disabled="row.account === 'admin'" @click="toggleStatus(row as InternalUser)">{{ row.status === '正常' ? '停用' : '启用' }}</el-button></div></template></el-table-column>
|
||||
<el-table-column label="用户" min-width="190"><template #default="{ row }"><div class="user-cell"><span>{{ row.name.slice(0,1) }}</span><div><strong>{{ row.name }}</strong><small>{{ row.account }}</small></div></div></template></el-table-column>
|
||||
<el-table-column prop="department" label="部门" width="110" />
|
||||
<el-table-column label="角色" min-width="190"><template #default="{ row }"><div class="tag-list"><el-tag v-for="roleId in row.roleIds" :key="roleId" effect="plain" size="small">{{ roleName(roleId) }}</el-tag></div></template></el-table-column>
|
||||
<el-table-column label="联系方式" min-width="190"><template #default="{ row }"><div class="primary-cell"><strong>{{ row.phone }}</strong><span>{{ row.email }}</span></div></template></el-table-column>
|
||||
<el-table-column prop="lastLogin" label="最后登录" width="150" />
|
||||
<el-table-column label="状态" width="88"><template #default="{ row }"><StatusBadge :status="row.status" /></template></el-table-column>
|
||||
<header class="panel-heading"><div><h2>内部用户</h2><p>数据来自平台 IAM,未开放的操作不会在前端模拟执行。</p></div></header>
|
||||
<el-table v-loading="loading" :data="pagedRows" row-key="membershipId" empty-text="没有符合条件的内部用户">
|
||||
<el-table-column label="操作" width="100" fixed="left"><template #default><span class="secondary-text">仅查看</span></template></el-table-column>
|
||||
<el-table-column prop="displayName" label="姓名" min-width="150" />
|
||||
<el-table-column prop="username" label="登录账号" min-width="150" />
|
||||
<el-table-column prop="authUserId" label="认证用户 ID" min-width="145" />
|
||||
<el-table-column prop="memberNo" label="成员编号" min-width="170" />
|
||||
<el-table-column prop="memberType" label="成员类型" min-width="130" />
|
||||
<el-table-column label="角色" min-width="190"><template #default="{ row }"><div class="tag-list"><el-tag v-for="roleId in row.roleIds" :key="roleId" effect="plain" size="small">{{ roleLabel(roleId) }}</el-tag><span v-if="row.roleIds.length === 0" class="secondary-text">未分配</span></div></template></el-table-column>
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><StatusBadge :status="row.status === 'ACTIVE' ? '正常' : '停用'" /></template></el-table-column>
|
||||
</el-table>
|
||||
<footer class="table-footer"><span>显示 {{ pagedRows.length }} 条,共 {{ filteredRows.length }} 条</span><el-pagination v-model:current-page="page" :page-size="pageSize" :total="filteredRows.length" layout="prev, pager, next" /></footer>
|
||||
</section>
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="form.id ? '编辑内部用户' : '新增内部用户'" size="620px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-drawer v-model="editorOpen" title="新增内部用户" size="560px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form">
|
||||
<div class="form-section"><h3>账号信息</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="登录账号" required><el-input v-model="form.account" :disabled="Boolean(form.id)" placeholder="英文、数字或下划线" /></el-form-item>
|
||||
<el-form-item label="姓名" required><el-input v-model="form.name" /></el-form-item>
|
||||
<el-form-item label="所属部门" required><el-select v-model="form.department" filterable allow-create placeholder="请选择或输入部门"><el-option v-for="item in departments" :key="item" :label="item" :value="item" /></el-select></el-form-item>
|
||||
<el-form-item label="账号状态"><el-radio-group v-model="form.status"><el-radio-button label="正常" value="正常" /><el-radio-button label="停用" value="停用" /></el-radio-group></el-form-item>
|
||||
<div class="form-section"><h3>用户信息</h3><div class="form-grid two-cols">
|
||||
<el-form-item label="认证用户 ID" required><el-input v-model="form.authUserId" placeholder="已存在的认证账号 ID" /></el-form-item>
|
||||
<el-form-item label="姓名" required><el-input v-model="form.displayName" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="用户类型" required><el-input v-model="form.userType" maxlength="32" /></el-form-item>
|
||||
<el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item>
|
||||
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
|
||||
</div></div>
|
||||
<div class="form-section"><h3>联系方式</h3><div class="form-grid two-cols"><el-form-item label="手机号"><el-input v-model="form.phone" /></el-form-item><el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item></div></div>
|
||||
<div class="form-section"><h3>角色分配</h3><el-form-item label="平台角色" required><el-select v-model="form.roleIds" multiple collapse-tags placeholder="至少选择一个角色"><el-option v-for="item in store.roles.filter((role) => role.status === '启用')" :key="item.id" :label="item.name" :value="item.id" /></el-select></el-form-item><p class="form-help"><UserRoundCog :size="16" />用户最终权限为所分配角色权限的合集。</p></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="access.can('system:user:write')" type="primary" @click="saveUser">保存用户</el-button></div></template>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button v-if="canCreate" type="primary" :loading="saving" @click="saveUser">创建用户</el-button></div></template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+24
-13
@@ -22,16 +22,18 @@ function createMemoryStorage(): Storage {
|
||||
}
|
||||
}
|
||||
|
||||
describe('LoginView access preview lifecycle', () => {
|
||||
describe('LoginView authentication lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', createMemoryStorage())
|
||||
})
|
||||
|
||||
it('only exposes the integration account hint through the development environment branch', () => {
|
||||
expect(loginSource).toContain('const isDevelopment = import.meta.env.DEV')
|
||||
expect(loginSource).toContain('v-if="developmentHint" class="demo-tip"')
|
||||
expect(loginSource).toContain("account: isDevelopment ? 'platform_admin' : ''")
|
||||
expect(loginSource).toContain("password: isDevelopment ? 'ChangeMe123!' : ''")
|
||||
it('always renders a blank login form without demo credentials or hints', () => {
|
||||
expect(loginSource).toContain("account: ''")
|
||||
expect(loginSource).toContain("password: ''")
|
||||
expect(loginSource).not.toContain('import.meta.env.DEV')
|
||||
expect(loginSource).not.toContain('platform_admin')
|
||||
expect(loginSource).not.toContain('ChangeMe123!')
|
||||
expect(loginSource).not.toContain('demo-tip')
|
||||
})
|
||||
|
||||
it('initializes preview access after login and before redirecting', async () => {
|
||||
@@ -59,12 +61,15 @@ describe('LoginView access preview lifecycle', () => {
|
||||
stubs: {
|
||||
'el-form': { template: '<form><slot /></form>' },
|
||||
'el-form-item': { template: '<label><slot /></label>' },
|
||||
'el-input': { template: '<input />' },
|
||||
'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
await inputs[0]!.setValue('test_admin')
|
||||
await inputs[1]!.setValue('password')
|
||||
await wrapper.get('.login-submit').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -74,10 +79,10 @@ describe('LoginView access preview lifecycle', () => {
|
||||
it.each([
|
||||
['/customers?page=2', '/customers?page=2'],
|
||||
['/customers/reviews', '/customers/reviews'],
|
||||
['//example.com', '/customers'],
|
||||
['https://example.com', '/customers'],
|
||||
[['/customers', '/orders/first-leg'], '/customers'],
|
||||
[null, '/customers'],
|
||||
['//example.com', '/403'],
|
||||
['https://example.com', '/403'],
|
||||
[['/customers', '/orders/first-leg'], '/403'],
|
||||
[null, '/403'],
|
||||
])('sanitizes the post-login redirect %j to %s', async (redirect, expected) => {
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
@@ -99,12 +104,15 @@ describe('LoginView access preview lifecycle', () => {
|
||||
stubs: {
|
||||
'el-form': { template: '<form><slot /></form>' },
|
||||
'el-form-item': { template: '<label><slot /></label>' },
|
||||
'el-input': { template: '<input />' },
|
||||
'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
await inputs[0]!.setValue('test_admin')
|
||||
await inputs[1]!.setValue('password')
|
||||
await wrapper.get('.login-submit').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
@@ -134,12 +142,15 @@ describe('LoginView access preview lifecycle', () => {
|
||||
stubs: {
|
||||
'el-form': { template: '<form><slot /></form>' },
|
||||
'el-form-item': { template: '<label><slot /></label>' },
|
||||
'el-input': { template: '<input />' },
|
||||
'el-input': { props: ['modelValue'], emits: ['update:modelValue'], template: '<input :value="modelValue" @input="$emit(\'update:modelValue\', $event.target.value)" />' },
|
||||
'el-button': { emits: ['click'], template: '<button type="button" @click="$emit(\'click\')"><slot /></button>' },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const inputs = wrapper.findAll('input')
|
||||
await inputs[0]!.setValue('test_admin')
|
||||
await inputs[1]!.setValue('password')
|
||||
await wrapper.get('.login-submit').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowRight, LockKeyhole, ShieldCheck, UserRound } from '@lucide/vue'
|
||||
import { getApiErrorDetails, type ApiErrorDetails } from '@/api/http'
|
||||
import baiyueLogoWhite from '@/assets/brand/baiyue-logo-white.png'
|
||||
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
|
||||
import { sanitizeInternalPath } from '@/router/accessDecision'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
@@ -15,11 +16,9 @@ const auth = useAuthStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const loading = ref(false)
|
||||
const loginError = ref<ApiErrorDetails | null>(null)
|
||||
const isDevelopment = import.meta.env.DEV
|
||||
const developmentHint = isDevelopment ? '当前为前端开发环境,可使用预填账号进入。' : ''
|
||||
const form = reactive({
|
||||
account: isDevelopment ? 'platform_admin' : '',
|
||||
password: isDevelopment ? 'ChangeMe123!' : '',
|
||||
account: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
@@ -51,9 +50,8 @@ async function submit() {
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<section class="login-intro">
|
||||
<div class="login-brand"><span>CC</span> 虫虫出海</div>
|
||||
<div class="login-brand"><img class="login-brand-logo" :src="baiyueLogoWhite" alt="柏悦物流" /></div>
|
||||
<div>
|
||||
<p class="eyebrow">CHONGCHONG GLOBAL</p>
|
||||
<h1>让跨境物流管理<br />清晰、可靠、可追踪</h1>
|
||||
<p class="intro-copy">统一管理企业入驻、内部权限和头程订单,让每一次操作都有边界、有记录。</p>
|
||||
</div>
|
||||
@@ -80,7 +78,6 @@ async function submit() {
|
||||
</el-button>
|
||||
</el-form>
|
||||
<div v-if="loginError" class="login-error" role="alert"><p>{{ loginError.message }}</p><ApiErrorMeta :details="loginError" /></div>
|
||||
<div v-if="developmentHint" class="demo-tip" role="note">{{ developmentHint }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Braces, FolderTree, Pencil, Plus, Search, ShieldCheck } from '@lucide/vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Braces, FolderTree, Plus, Search, ShieldCheck } from '@lucide/vue'
|
||||
import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { createPlatformPermission, listPlatformPermissions } from '@/api/platformIam'
|
||||
@@ -9,12 +9,10 @@ import { getApiErrorDetails, getApiErrorMessage, type ApiErrorDetails } from '@/
|
||||
import ApiErrorMeta from '@/components/ApiErrorMeta.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { Permission, PermissionType } from '@/types/admin'
|
||||
import { buildPermissionTree, getDescendantIds } from '@/utils/permissionTree'
|
||||
import { mapPlatformPermission } from '@/utils/platformPermission'
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const editorOpen = ref(false)
|
||||
const serverPermissions = ref<Permission[]>([])
|
||||
@@ -25,7 +23,7 @@ const filters = reactive({ keyword: '', type: '', status: '' })
|
||||
const emptyForm = (): Permission => ({ id: '', parentId: null, name: '', code: '', type: '菜单', path: '', method: '-', status: '启用', sort: 10 })
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑权限' : '新增权限')
|
||||
const requiredPermission = computed(() => access.usesServerPermissions ? 'platform:permission:create' : 'system:permission:write')
|
||||
const requiredPermission = computed(() => 'platform:permission:create')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'permission-editor',
|
||||
label: editorLabel,
|
||||
@@ -33,9 +31,8 @@ const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
model: form,
|
||||
requiredPermission,
|
||||
})
|
||||
const permissionTypes: PermissionType[] = ['目录', '菜单', '按钮', '接口']
|
||||
const availablePermissionTypes = computed<PermissionType[]>(() => access.usesServerPermissions ? ['菜单', '按钮'] : permissionTypes)
|
||||
const permissions = computed(() => access.usesServerPermissions ? serverPermissions.value : store.permissions)
|
||||
const availablePermissionTypes: PermissionType[] = ['菜单', '按钮']
|
||||
const permissions = computed(() => serverPermissions.value)
|
||||
const canCreate = computed(() => access.can('platform:permission:create'))
|
||||
|
||||
function matches(item: Permission) {
|
||||
@@ -47,17 +44,16 @@ const blockedParentIds = computed(() => new Set(
|
||||
form.id ? [form.id, ...getDescendantIds(permissions.value, form.id)] : [],
|
||||
))
|
||||
const parentOptions = computed(() => permissions.value.filter((item) => (
|
||||
(access.usesServerPermissions ? item.type === '菜单' : ['目录', '菜单'].includes(item.type)) && !blockedParentIds.value.has(item.id)
|
||||
item.type === '菜单' && !blockedParentIds.value.has(item.id)
|
||||
)))
|
||||
const metrics = computed(() => [
|
||||
{ label: '权限资源', value: permissions.value.length, note: access.usesServerPermissions ? 'IAM 返回的资源' : '全部权限编码' },
|
||||
{ label: '权限资源', value: permissions.value.length, note: 'IAM 返回的资源' },
|
||||
{ label: '导航资源', value: permissions.value.filter((item) => ['目录','菜单'].includes(item.type)).length, note: '目录与页面入口' },
|
||||
{ label: '操作权限', value: permissions.value.filter((item) => item.type === '按钮').length, note: '控制页面操作' },
|
||||
{ label: '停用资源', value: permissions.value.filter((item) => item.status === '停用').length, note: '当前不可分配' },
|
||||
])
|
||||
|
||||
async function loadPermissions() {
|
||||
if (!access.usesServerPermissions) return
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
serverPermissions.value = []
|
||||
@@ -71,15 +67,13 @@ async function loadPermissions() {
|
||||
}
|
||||
|
||||
function openCreate(parentId: string | null = null) { Object.assign(form, emptyForm(), { parentId }); captureBaseline(); editorOpen.value = true }
|
||||
function openEdit(row: Permission) { if (access.usesServerPermissions) return; Object.assign(form, row); captureBaseline(); editorOpen.value = true }
|
||||
async function savePermission() {
|
||||
if (!form.name.trim() || !form.code.trim()) { ElMessage.warning('请填写权限名称和权限编码'); return }
|
||||
if (permissions.value.some((item) => item.code.toLowerCase() === form.code.toLowerCase() && item.id !== form.id)) { ElMessage.error('权限编码已经存在'); return }
|
||||
if (access.usesServerPermissions) {
|
||||
if (form.type === '菜单' && !form.path.trim()) { ElMessage.warning('请填写菜单路由'); return }
|
||||
if (form.type === '按钮' && !form.parentId) { ElMessage.warning('按钮权限必须选择上级菜单'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
if (form.type === '菜单' && !form.path.trim()) { ElMessage.warning('请填写菜单路由'); return }
|
||||
if (form.type === '按钮' && !form.parentId) { ElMessage.warning('按钮权限必须选择上级菜单'); return }
|
||||
saving.value = true
|
||||
try {
|
||||
const created = await createPlatformPermission({
|
||||
type: form.type === '按钮' ? 'BUTTON' : 'MENU',
|
||||
code: form.code.trim(),
|
||||
@@ -92,28 +86,10 @@ async function savePermission() {
|
||||
serverPermissions.value.push(mapPlatformPermission(created))
|
||||
editorOpen.value = false
|
||||
ElMessage.success('权限资源已创建')
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, '权限资源创建失败,请稍后重试'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
store.savePermission({ ...form, id: form.id || undefined })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '权限资源已更新' : '权限资源已创建')
|
||||
}
|
||||
async function toggleStatus(row: Permission) {
|
||||
const target = row.status === '启用' ? '停用' : '启用'
|
||||
const directoryNotice = row.type === '目录' && target === '停用'
|
||||
? ' 目录仅用于组织结构,停用不会级联影响已启用的下级菜单和操作。'
|
||||
: ''
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${target}权限“${row.name}”吗?${directoryNotice}`, '权限状态', { confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
store.savePermission({ ...row, status: target })
|
||||
ElMessage.success(`权限已${target}`)
|
||||
} catch {
|
||||
// 用户取消,不需要反馈。
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, '权限资源创建失败,请稍后重试'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
function typeClass(type: PermissionType) { return { '目录': 'directory', '菜单': 'menu', '按钮': 'action', '接口': 'api' }[type] }
|
||||
@@ -124,7 +100,7 @@ onMounted(loadPermissions)
|
||||
<template>
|
||||
<div class="business-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
<el-alert v-if="access.usesServerPermissions" type="warning" :closable="false" show-icon title="权限资源列表和新增已接入 IAM;一期真实资源仅使用菜单和按钮类型,编辑与启停接口尚未提供。" />
|
||||
<el-alert type="warning" :closable="false" show-icon title="权限资源列表和新增已接入 IAM;一期真实资源仅使用菜单和按钮类型,编辑与启停接口尚未提供。" />
|
||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadPermissions">重新加载</el-button></template></el-alert>
|
||||
<section class="surface-panel filter-panel" aria-label="权限筛选">
|
||||
<div class="filter-fields"><el-input v-model="filters.keyword" clearable placeholder="权限名称、编码或路径" aria-label="权限关键词"><template #prefix><Search :size="16" /></template></el-input><el-select v-model="filters.type" clearable placeholder="全部类型" aria-label="权限类型"><el-option v-for="item in availablePermissionTypes" :key="item" :label="item" :value="item" /></el-select><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="权限状态"><el-option label="启用" value="启用" /><el-option label="停用" value="停用" /></el-select></div>
|
||||
@@ -134,7 +110,7 @@ onMounted(loadPermissions)
|
||||
<section class="surface-panel table-panel">
|
||||
<header class="panel-heading"><div><h2>权限资源树</h2><p>目录组织菜单,按钮控制页面操作;接口权限将在服务端契约确定后补充。</p></div><div class="legend-list"><span><i class="is-menu" />目录 / 菜单</span><span><i class="is-action" />操作</span><span><i class="is-api" />接口预留</span></div></header>
|
||||
<el-table v-loading="loading" :data="treeRows" row-key="id" default-expand-all :tree-props="{ children: 'children' }" empty-text="没有符合条件的权限资源">
|
||||
<el-table-column label="操作" width="235" fixed="left"><template #default="{ row }"><div v-if="access.usesServerPermissions && canCreate" class="table-actions"><el-button v-if="row.type === '菜单'" link type="primary" @click="openCreate(row.id)"><Plus :size="15" />添加下级</el-button><span class="secondary-text">编辑接口待补齐</span></div><div v-else-if="access.can('system:permission:write')" class="table-actions"><el-button link type="primary" @click="openEdit(row as Permission)"><Pencil :size="15" />编辑</el-button><el-button v-if="row.type === '目录' || row.type === '菜单'" link type="primary" @click="openCreate(row.id)"><Plus :size="15" />添加下级</el-button><el-button link :type="row.status === '启用' ? 'danger' : 'success'" @click="toggleStatus(row as Permission)">{{ row.status === '启用' ? '停用' : '启用' }}</el-button></div></template></el-table-column>
|
||||
<el-table-column label="操作" width="235" fixed="left"><template #default="{ row }"><div v-if="canCreate" class="table-actions"><el-button v-if="row.type === '菜单'" link type="primary" @click="openCreate(row.id)"><Plus :size="15" />添加下级</el-button><span class="secondary-text">编辑接口待补齐</span></div><span v-else class="secondary-text">仅查看</span></template></el-table-column>
|
||||
<el-table-column label="权限名称" min-width="260"><template #default="{ row }"><div class="permission-name"><span :class="`is-${typeClass(row.type)}`"><FolderTree v-if="row.type === '目录' || row.type === '菜单'" :size="16" /><ShieldCheck v-else-if="row.type === '按钮'" :size="16" /><Braces v-else :size="16" /></span><strong>{{ row.name }}</strong></div></template></el-table-column>
|
||||
<el-table-column label="权限编码" min-width="220"><template #default="{ row }"><code class="code-value">{{ row.code }}</code></template></el-table-column>
|
||||
<el-table-column prop="type" label="类型" width="80" />
|
||||
@@ -153,15 +129,13 @@ onMounted(loadPermissions)
|
||||
<el-form-item label="权限编码" required><el-input v-model="form.code" placeholder="如 platform:customer:list" maxlength="128" /></el-form-item>
|
||||
<el-form-item label="资源类型"><el-select v-model="form.type"><el-option v-for="item in availablePermissionTypes" :key="item" :label="item" :value="item" /></el-select></el-form-item>
|
||||
<el-form-item label="上级资源"><el-select v-model="form.parentId" clearable placeholder="作为一级资源"><el-option v-for="item in parentOptions" :key="item.id" :label="`${item.name} (${item.code})`" :value="item.id" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.type === '菜单' || !access.usesServerPermissions" label="路由或接口路径"><el-input v-model="form.path" placeholder="例如 /customers" /></el-form-item>
|
||||
<el-form-item v-if="!access.usesServerPermissions" label="请求方法"><el-select v-model="form.method"><el-option v-for="item in ['-','GET','POST','PUT','PATCH','DELETE']" :key="item" :label="item" :value="item" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.type === '菜单'" label="路由路径"><el-input v-model="form.path" placeholder="例如 /customers" /></el-form-item>
|
||||
<el-form-item label="显示排序"><el-input-number v-model="form.sort" :min="0" :max="999" controls-position="right" /></el-form-item>
|
||||
<el-form-item v-if="!access.usesServerPermissions" label="状态"><el-radio-group v-model="form.status"><el-radio-button label="启用" value="启用" /><el-radio-button label="停用" value="停用" /></el-radio-group></el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-callout"><Braces :size="18" /><div><strong>权限编码约定</strong><p>使用稳定的“业务域:资源:动作”编码;一期真实资源只创建菜单和按钮,接口鉴权由服务端实现。</p></div></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="access.usesServerPermissions ? canCreate : access.can('system:permission:write')" type="primary" :loading="saving" @click="savePermission">保存权限</el-button></div></template>
|
||||
<template #footer><div class="drawer-footer"><el-button @click="editorOpen = false">取消</el-button><el-button v-if="canCreate" type="primary" :loading="saving" @click="savePermission">保存权限</el-button></div></template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPlatformRole, listPlatformPermissions, listPlatformRoles, type PlatformRoleResource } from '@/api/platformIam'
|
||||
import { createPlatformRole, listPlatformInternalUsers, listPlatformPermissions, listPlatformRoles, type PlatformRoleResource } from '@/api/platformIam'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import RolesView from './RolesView.vue'
|
||||
|
||||
vi.mock('@/api/platformIam', () => ({
|
||||
createPlatformRole: vi.fn(),
|
||||
listPlatformInternalUsers: vi.fn(),
|
||||
listPlatformPermissions: vi.fn(),
|
||||
listPlatformRoles: vi.fn(),
|
||||
updatePlatformRole: vi.fn(),
|
||||
@@ -28,6 +29,8 @@ const platformRole: PlatformRoleResource = {
|
||||
|
||||
function mountView(pinia = createPinia()) {
|
||||
setActivePinia(pinia)
|
||||
const auth = useAuthStore()
|
||||
if (auth.permissions.length === 0) auth.permissions = ['platform:role:list', 'platform:role:create', 'platform:role:grant']
|
||||
return shallowMount(RolesView, {
|
||||
global: {
|
||||
plugins: [pinia],
|
||||
@@ -63,7 +66,9 @@ describe('RolesView', () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.mocked(listPlatformRoles).mockReset()
|
||||
vi.mocked(listPlatformPermissions).mockReset()
|
||||
vi.mocked(listPlatformInternalUsers).mockReset()
|
||||
vi.mocked(listPlatformPermissions).mockResolvedValue([])
|
||||
vi.mocked(listPlatformInternalUsers).mockResolvedValue([])
|
||||
vi.mocked(createPlatformRole).mockReset()
|
||||
})
|
||||
|
||||
@@ -85,6 +90,7 @@ describe('RolesView', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(listPlatformRoles).toHaveBeenCalledTimes(1)
|
||||
expect(listPlatformInternalUsers).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('角色查询、新增、编辑、启停、删除及权限分配均使用 IAM 真实接口')
|
||||
expect(wrapper.find('[data-testid="permission-tree"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
+48
-52
@@ -6,6 +6,7 @@ import {
|
||||
changePlatformRoleStatus,
|
||||
createPlatformRole,
|
||||
deletePlatformRole,
|
||||
listPlatformInternalUsers,
|
||||
listPlatformPermissions,
|
||||
listPlatformRoles,
|
||||
replacePlatformRolePermissions,
|
||||
@@ -18,8 +19,7 @@ import MetricStrip from '@/components/MetricStrip.vue'
|
||||
import StatusBadge from '@/components/StatusBadge.vue'
|
||||
import { useUnsavedWorkRegistration } from '@/composables/useUnsavedWorkRegistration'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAdminDataStore } from '@/stores/adminData'
|
||||
import type { DataScope, Permission, Role } from '@/types/admin'
|
||||
import type { Permission, Role } from '@/types/admin'
|
||||
import { buildPermissionTree, summarizePermissionSelection } from '@/utils/permissionTree'
|
||||
import { mapPlatformPermission } from '@/utils/platformPermission'
|
||||
|
||||
@@ -29,12 +29,12 @@ interface TreeController {
|
||||
setCheckedKeys: (keys: string[]) => void
|
||||
}
|
||||
|
||||
const store = useAdminDataStore()
|
||||
const access = useAccessPreviewStore()
|
||||
const editorOpen = ref(false)
|
||||
const treeRef = ref<TreeController | null>(null)
|
||||
const serverRoles = ref<Role[]>([])
|
||||
const serverPermissions = ref<Permission[]>([])
|
||||
const assignedMemberCount = ref(0)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const rowActionId = ref('')
|
||||
@@ -45,9 +45,7 @@ const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '配置角色' : '新增角色')
|
||||
const canEdit = computed(() => access.can('platform:role:edit'))
|
||||
const canGrant = computed(() => access.can('platform:role:grant'))
|
||||
const requiredPermission = computed(() => access.usesServerPermissions
|
||||
? (form.id && canEdit.value ? 'platform:role:edit' : form.id ? 'platform:role:grant' : 'platform:role:create')
|
||||
: 'system:role:grant')
|
||||
const requiredPermission = computed(() => form.id && canEdit.value ? 'platform:role:edit' : form.id ? 'platform:role:grant' : 'platform:role:create')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'role-editor',
|
||||
label: editorLabel,
|
||||
@@ -55,34 +53,32 @@ const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
model: form,
|
||||
requiredPermission,
|
||||
})
|
||||
const dataScopes: DataScope[] = ['全部数据', '本部门数据', '本人数据', '自定义']
|
||||
|
||||
const roles = computed(() => access.usesServerPermissions ? serverRoles.value : store.roles)
|
||||
const roles = computed(() => serverRoles.value)
|
||||
const canCreate = computed(() => access.can('platform:role:create'))
|
||||
const filteredRows = computed(() => roles.value.filter((item) => {
|
||||
const keyword = filters.keyword.trim().toLowerCase()
|
||||
return (!keyword || [item.name, item.code, item.description].some((value) => value.toLowerCase().includes(keyword))) && (!filters.status || item.status === filters.status) && (!filters.dataScope || item.dataScope === filters.dataScope)
|
||||
}))
|
||||
const metrics = computed(() => [
|
||||
{ label: '角色总数', value: roles.value.length, note: access.usesServerPermissions ? 'IAM 返回的角色' : '平台角色模型' },
|
||||
{ label: '角色总数', value: roles.value.length, note: 'IAM 返回的角色' },
|
||||
{ label: '启用角色', value: roles.value.filter((item) => item.status === '启用').length, note: '当前可分配' },
|
||||
{ label: '授权用户', value: access.usesServerPermissions ? '—' : store.users.length, note: access.usesServerPermissions ? '成员关系待接口回显' : '已分配平台角色' },
|
||||
{ label: '权限资源', value: access.usesServerPermissions ? serverPermissions.value.length : store.permissions.length, note: '菜单、按钮与接口' },
|
||||
{ label: '授权用户', value: assignedMemberCount.value, note: '已分配角色成员' },
|
||||
{ label: '权限资源', value: serverPermissions.value.length, note: '菜单与按钮' },
|
||||
])
|
||||
const availablePermissions = computed(() => access.usesServerPermissions ? serverPermissions.value : store.permissions)
|
||||
const availablePermissions = computed(() => serverPermissions.value)
|
||||
const permissionTree = computed(() => buildPermissionTree(availablePermissions.value))
|
||||
|
||||
watch(() => form.dataScope, () => undefined)
|
||||
|
||||
function resetFilters() { Object.assign(filters, { keyword: '', status: '', dataScope: '' }) }
|
||||
|
||||
function mapPlatformRole(role: PlatformRoleResource): Role {
|
||||
function mapPlatformRole(role: PlatformRoleResource, userCount = 0): Role {
|
||||
return {
|
||||
id: String(role.id),
|
||||
name: role.roleName,
|
||||
code: role.roleCode,
|
||||
description: role.description ?? '暂无角色说明',
|
||||
userCount: 0,
|
||||
userCount,
|
||||
dataScope: '全部数据',
|
||||
status: role.status === 'ACTIVE' ? '启用' : '停用',
|
||||
permissionIds: (role.permissionIds ?? []).map(String),
|
||||
@@ -91,15 +87,27 @@ function mapPlatformRole(role: PlatformRoleResource): Role {
|
||||
}
|
||||
|
||||
async function loadRoles() {
|
||||
if (!access.usesServerPermissions) return
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
serverRoles.value = []
|
||||
serverPermissions.value = []
|
||||
assignedMemberCount.value = 0
|
||||
try {
|
||||
const [roleRecords, permissionRecords] = await Promise.all([listPlatformRoles(), listPlatformPermissions()])
|
||||
const [roleRecords, permissionRecords, memberRecords] = await Promise.all([
|
||||
listPlatformRoles(),
|
||||
listPlatformPermissions(),
|
||||
listPlatformInternalUsers(),
|
||||
])
|
||||
const roleMemberCounts = new Map<string, number>()
|
||||
for (const member of memberRecords) {
|
||||
for (const roleId of member.roleIds) {
|
||||
const key = String(roleId)
|
||||
roleMemberCounts.set(key, (roleMemberCounts.get(key) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
serverPermissions.value = permissionRecords.map(mapPlatformPermission)
|
||||
serverRoles.value = roleRecords.map(mapPlatformRole)
|
||||
serverRoles.value = roleRecords.map((role) => mapPlatformRole(role, roleMemberCounts.get(String(role.id)) ?? 0))
|
||||
assignedMemberCount.value = memberRecords.filter((member) => member.roleIds.length > 0).length
|
||||
} catch (error) {
|
||||
loadError.value = getApiErrorDetails(error, '平台角色列表加载失败,请稍后重试')
|
||||
} finally {
|
||||
@@ -114,7 +122,7 @@ function openCreate() {
|
||||
nextTick(() => treeRef.value?.setCheckedKeys([]))
|
||||
}
|
||||
function openEdit(row: Role) {
|
||||
if (access.usesServerPermissions && !canEdit.value && !canGrant.value) return
|
||||
if (!canEdit.value && !canGrant.value) return
|
||||
Object.assign(form, { ...row, permissionIds: [...row.permissionIds] })
|
||||
captureBaseline()
|
||||
editorOpen.value = true
|
||||
@@ -136,9 +144,8 @@ async function saveRole() {
|
||||
const normalizedCode = form.code.trim().toUpperCase()
|
||||
const duplicate = roles.value.some((item) => item.code.toLowerCase() === normalizedCode.toLowerCase() && item.id !== form.id)
|
||||
if (duplicate) { ElMessage.error('角色编码已经存在'); return }
|
||||
if (access.usesServerPermissions) {
|
||||
saving.value = true
|
||||
try {
|
||||
saving.value = true
|
||||
try {
|
||||
const permissionIds = selectedPermissionIds()
|
||||
if (form.id) {
|
||||
if (canEdit.value) {
|
||||
@@ -156,18 +163,11 @@ async function saveRole() {
|
||||
await loadRoles()
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '角色已更新' : '角色已创建')
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, '角色创建失败,请稍后重试'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
return
|
||||
} catch (error) {
|
||||
ElMessage.error(getApiErrorMessage(error, '角色保存失败,请稍后重试'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
const permissionIds = selectedPermissionIds()
|
||||
if (permissionIds.length === 0) { ElMessage.warning('请至少选择一项菜单权限'); return }
|
||||
store.saveRole({ ...form, id: form.id || undefined, permissionIds })
|
||||
editorOpen.value = false
|
||||
ElMessage.success(form.id ? '角色及权限已更新' : '角色已创建')
|
||||
}
|
||||
|
||||
async function removeRole(row: Role) {
|
||||
@@ -175,12 +175,10 @@ async function removeRole(row: Role) {
|
||||
if (row.userCount > 0) { ElMessage.warning('请先移除该角色下的用户'); return }
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除角色“${row.name}”吗?`, '删除角色', { confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' })
|
||||
if (access.usesServerPermissions) {
|
||||
rowActionId.value = row.id
|
||||
await deletePlatformRole(row.id)
|
||||
serverRoles.value = serverRoles.value.filter((item) => item.id !== row.id)
|
||||
ElMessage.success('角色已删除')
|
||||
} else if (store.removeRole(row.id)) ElMessage.success('角色已删除')
|
||||
rowActionId.value = row.id
|
||||
await deletePlatformRole(row.id)
|
||||
serverRoles.value = serverRoles.value.filter((item) => item.id !== row.id)
|
||||
ElMessage.success('角色已删除')
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
ElMessage.error(getApiErrorMessage(error, '角色删除失败,请稍后重试'))
|
||||
@@ -194,11 +192,9 @@ async function toggleStatus(row: Role) {
|
||||
const target = row.status === '启用' ? '停用' : '启用'
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认${target}角色“${row.name}”吗?`, '角色状态', { confirmButtonText: '确认', cancelButtonText: '取消' })
|
||||
if (access.usesServerPermissions) {
|
||||
rowActionId.value = row.id
|
||||
const updated = await changePlatformRoleStatus(row.id, target === '启用' ? 'ACTIVE' : 'DISABLED')
|
||||
Object.assign(row, mapPlatformRole(updated))
|
||||
} else store.saveRole({ ...row, status: target })
|
||||
rowActionId.value = row.id
|
||||
const updated = await changePlatformRoleStatus(row.id, target === '启用' ? 'ACTIVE' : 'DISABLED')
|
||||
Object.assign(row, mapPlatformRole(updated))
|
||||
ElMessage.success(`角色已${target}`)
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
@@ -219,21 +215,21 @@ onMounted(loadRoles)
|
||||
<template>
|
||||
<div class="business-page">
|
||||
<MetricStrip :items="metrics" />
|
||||
<el-alert v-if="access.usesServerPermissions" type="success" :closable="false" show-icon title="角色查询、新增、编辑、启停、删除及权限分配均使用 IAM 真实接口;系统内置角色继续由服务端保护。" />
|
||||
<el-alert type="success" :closable="false" show-icon title="角色查询、新增、编辑、启停、删除及权限分配均使用 IAM 真实接口;系统内置角色继续由服务端保护。" />
|
||||
<el-alert v-if="loadError" type="error" :closable="false" show-icon role="alert"><template #title><div class="api-error-alert-title"><span>{{ loadError.message }}</span><ApiErrorMeta :details="loadError" /></div><el-button link type="danger" @click="loadRoles">重新加载</el-button></template></el-alert>
|
||||
<section class="surface-panel filter-panel" aria-label="角色筛选">
|
||||
<div class="filter-fields"><el-input v-model="filters.keyword" clearable placeholder="角色名称、编码或描述" aria-label="角色关键词"><template #prefix><Search :size="16" /></template></el-input><el-select v-if="!access.usesServerPermissions" v-model="filters.dataScope" clearable placeholder="全部数据范围" aria-label="数据范围"><el-option v-for="item in ['全部数据','本部门数据','本人数据','自定义']" :key="item" :label="item" :value="item" /></el-select><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="角色状态"><el-option label="启用" value="启用" /><el-option label="停用" value="停用" /></el-select></div>
|
||||
<div class="filter-fields"><el-input v-model="filters.keyword" clearable placeholder="角色名称、编码或描述" aria-label="角色关键词"><template #prefix><Search :size="16" /></template></el-input><el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="角色状态"><el-option label="启用" value="启用" /><el-option label="停用" value="停用" /></el-select></div>
|
||||
<div class="filter-actions"><el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增角色</el-button></div>
|
||||
</section>
|
||||
|
||||
<section class="surface-panel table-panel">
|
||||
<header class="panel-heading"><div><h2>角色与授权</h2><p>角色负责聚合菜单、操作和数据范围,内部用户可分配多个角色。</p></div></header>
|
||||
<el-table v-loading="loading" :data="filteredRows" row-key="id" empty-text="没有符合条件的角色">
|
||||
<el-table-column label="操作" width="235" fixed="left"><template #default="{ row }"><div v-if="access.usesServerPermissions && (canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete'))" class="table-actions"><el-button v-if="canEdit || canGrant" link type="primary" :disabled="rowActionId === row.id || row.builtIn" @click="openEdit(row as Role)"><Pencil :size="15" />配置</el-button><el-button v-if="access.can('platform:role:status')" link :type="row.status === '启用' ? 'danger' : 'success'" :loading="rowActionId === row.id" :disabled="row.builtIn" @click="toggleStatus(row as Role)">{{ row.status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="access.can('platform:role:delete')" link type="danger" :disabled="row.builtIn || row.userCount > 0 || rowActionId === row.id" @click="removeRole(row as Role)"><Trash2 :size="15" />删除</el-button></div><span v-else-if="access.usesServerPermissions" class="secondary-text">仅查看</span><div v-else-if="access.can('system:role:grant')" class="table-actions"><el-button link type="primary" @click="openEdit(row as Role)"><Pencil :size="15" />配置</el-button><el-button link :type="row.status === '启用' ? 'danger' : 'success'" :disabled="row.builtIn" @click="toggleStatus(row as Role)">{{ row.status === '启用' ? '停用' : '启用' }}</el-button><el-button link type="danger" :disabled="row.builtIn || row.userCount > 0" @click="removeRole(row as Role)"><Trash2 :size="15" />删除</el-button></div></template></el-table-column>
|
||||
<el-table-column label="操作" width="235" fixed="left"><template #default="{ row }"><div v-if="canEdit || canGrant || access.can('platform:role:status') || access.can('platform:role:delete')" class="table-actions"><el-button v-if="canEdit || canGrant" link type="primary" :disabled="rowActionId === row.id || row.builtIn" @click="openEdit(row as Role)"><Pencil :size="15" />配置</el-button><el-button v-if="access.can('platform:role:status')" link :type="row.status === '启用' ? 'danger' : 'success'" :loading="rowActionId === row.id" :disabled="row.builtIn" @click="toggleStatus(row as Role)">{{ row.status === '启用' ? '停用' : '启用' }}</el-button><el-button v-if="access.can('platform:role:delete')" link type="danger" :disabled="row.builtIn || row.userCount > 0 || rowActionId === row.id" @click="removeRole(row as Role)"><Trash2 :size="15" />删除</el-button></div><span v-else class="secondary-text">仅查看</span></template></el-table-column>
|
||||
<el-table-column label="角色" min-width="210"><template #default="{ row }"><div class="role-cell"><span><ShieldCheck :size="18" /></span><div><strong>{{ row.name }}</strong><small>{{ row.code }}</small></div></div></template></el-table-column>
|
||||
<el-table-column prop="description" label="说明" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column label="授权范围" min-width="170"><template #default="{ row }"><div class="primary-cell"><strong>{{ permissionSummary(row as Role) }}</strong><span>{{ access.usesServerPermissions ? '平台 IAM 权限' : row.dataScope }}</span></div></template></el-table-column>
|
||||
<el-table-column label="用户数" width="86" align="right"><template #default="{ row }">{{ access.usesServerPermissions ? '—' : row.userCount }}</template></el-table-column>
|
||||
<el-table-column label="授权范围" min-width="170"><template #default="{ row }"><div class="primary-cell"><strong>{{ permissionSummary(row as Role) }}</strong><span>平台 IAM 权限</span></div></template></el-table-column>
|
||||
<el-table-column prop="userCount" label="用户数" width="86" align="right" />
|
||||
<el-table-column label="类型" width="95"><template #default="{ row }"><el-tag :type="row.builtIn ? 'warning' : 'info'" effect="plain" size="small">{{ row.builtIn ? '系统内置' : '自定义' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="88"><template #default="{ row }"><StatusBadge :status="row.status" /></template></el-table-column>
|
||||
</el-table>
|
||||
@@ -241,10 +237,10 @@ onMounted(loadRoles)
|
||||
|
||||
<el-drawer v-model="editorOpen" :title="form.id ? '配置角色' : '新增角色'" size="760px" class="business-drawer editor-drawer" :modal="false" :close-on-click-modal="false">
|
||||
<el-form label-position="top" class="drawer-form">
|
||||
<div class="form-section"><h3>基本信息</h3><div class="form-grid two-cols"><el-form-item label="角色名称" required><el-input v-model="form.name" :disabled="access.usesServerPermissions && Boolean(form.id) && !canEdit" maxlength="64" /></el-form-item><el-form-item label="角色编码" required><el-input v-model="form.code" :disabled="Boolean(form.builtIn) || (access.usesServerPermissions && Boolean(form.id))" placeholder="如 OPS_MANAGER" maxlength="64" /></el-form-item><el-form-item v-if="!access.usesServerPermissions" label="数据范围"><el-select v-model="form.dataScope"><el-option v-for="item in dataScopes" :key="item" :label="item" :value="item" /></el-select></el-form-item><el-form-item v-if="!access.usesServerPermissions" label="状态"><el-radio-group v-model="form.status" :disabled="Boolean(form.builtIn)"><el-radio-button label="启用" value="启用" /><el-radio-button label="停用" value="停用" /></el-radio-group></el-form-item></div><el-form-item label="角色说明"><el-input v-model="form.description" :disabled="access.usesServerPermissions && Boolean(form.id) && !canEdit" type="textarea" :rows="2" maxlength="200" show-word-limit /></el-form-item></div>
|
||||
<div v-if="!access.usesServerPermissions || canGrant" class="form-section permission-section"><div class="section-heading-row"><div><h3>菜单与操作权限</h3><p>保存时按后端契约全量替换当前角色权限;未勾选表示清空授权。</p></div><span>{{ form.permissionIds.length }} 项原有授权</span></div><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox check-strictly default-expand-all :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node"><span>{{ data.name }}</span><code>{{ data.code }}</code></div></template></el-tree></div>
|
||||
<div class="form-section"><h3>基本信息</h3><div class="form-grid two-cols"><el-form-item label="角色名称" required><el-input v-model="form.name" :disabled="Boolean(form.id) && !canEdit" maxlength="64" /></el-form-item><el-form-item label="角色编码" required><el-input v-model="form.code" :disabled="Boolean(form.id)" placeholder="如 OPS_MANAGER" maxlength="64" /></el-form-item></div><el-form-item label="角色说明"><el-input v-model="form.description" :disabled="Boolean(form.id) && !canEdit" type="textarea" :rows="2" maxlength="200" show-word-limit /></el-form-item></div>
|
||||
<div v-if="canGrant" class="form-section permission-section"><div class="section-heading-row"><div><h3>菜单与操作权限</h3><p>保存时按后端契约全量替换当前角色权限;未勾选表示清空授权。</p></div><span>{{ form.permissionIds.length }} 项原有授权</span></div><el-tree ref="treeRef" class="permission-tree" :data="permissionTree" node-key="id" show-checkbox check-strictly default-expand-all :props="{ label: 'name', children: 'children' }" @check="syncPermissionIds"><template #default="{ data }"><div class="permission-node"><span>{{ data.name }}</span><code>{{ data.code }}</code></div></template></el-tree></div>
|
||||
</el-form>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button v-if="access.usesServerPermissions ? (form.id ? canEdit || canGrant : canCreate) : access.can('system:role:grant')" type="primary" :loading="saving" @click="saveRole">保存角色</el-button></div></template>
|
||||
<template #footer><div class="drawer-footer"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button v-if="form.id ? canEdit || canGrant : canCreate" type="primary" :loading="saving" @click="saveRole">保存角色</el-button></div></template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type PlatformWarehouseResource,
|
||||
} from '@/api/platformWarehouses'
|
||||
import { useAccessPreviewStore } from '@/stores/accessPreview'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import WarehousesView from './WarehousesView.vue'
|
||||
|
||||
vi.mock('@/api/platformWarehouses', () => ({
|
||||
@@ -73,9 +74,10 @@ async function mountView(withTenant = true) {
|
||||
vi.stubGlobal('sessionStorage', createMemoryStorage(withTenant ? { 'cc-admin-warehouse-tenant-id': '10001' } : {}))
|
||||
const pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
const access = useAccessPreviewStore()
|
||||
access.initializeAfterLogin()
|
||||
expect(access.commitRole('R002')).toBe(true)
|
||||
const auth = useAuthStore()
|
||||
auth.token = 'token'
|
||||
auth.permissions = ['platform:warehouse:list', 'platform:warehouse:create', 'platform:warehouse:edit', 'platform:warehouse:status']
|
||||
useAccessPreviewStore().initializeAfterLogin()
|
||||
const wrapper = mount(WarehousesView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, ElementPlus] },
|
||||
|
||||
@@ -45,7 +45,7 @@ const emptyForm = (): Omit<Warehouse, 'updatedAt'> => ({
|
||||
})
|
||||
const form = reactive(emptyForm())
|
||||
const editorLabel = computed(() => form.id ? '编辑收货仓' : '新增收货仓')
|
||||
const editorPermission = computed(() => 'warehouse:write')
|
||||
const editorPermission = computed(() => form.id ? 'platform:warehouse:edit' : 'platform:warehouse:create')
|
||||
const { captureBaseline } = useUnsavedWorkRegistration({
|
||||
key: 'warehouse-editor',
|
||||
label: editorLabel,
|
||||
@@ -88,7 +88,7 @@ const loadErrorDescription = computed(() => {
|
||||
|
||||
watch(filters, () => { page.value = 1 })
|
||||
watch(
|
||||
() => access.can('warehouse:write'),
|
||||
() => access.can(editorPermission.value),
|
||||
(canWrite) => {
|
||||
if (canWrite || !editorOpen.value) return
|
||||
editorOpen.value = false
|
||||
@@ -356,7 +356,7 @@ onMounted(() => {
|
||||
<div class="filter-actions">
|
||||
<el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button>
|
||||
<el-button
|
||||
v-if="access.can('warehouse:write')"
|
||||
v-if="access.can('platform:warehouse:create')"
|
||||
type="primary"
|
||||
:disabled="!tenantMatchesLoaded || loading"
|
||||
@click="openCreate"
|
||||
@@ -376,9 +376,10 @@ onMounted(() => {
|
||||
<el-table v-if="pagedRows.length" :data="pagedRows" row-key="id">
|
||||
<el-table-column label="操作" width="180" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div v-if="access.can('warehouse:write')" class="table-actions">
|
||||
<el-button link type="primary" :disabled="!tenantMatchesLoaded" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
|
||||
<div v-if="access.can('platform:warehouse:edit') || access.can('platform:warehouse:status')" class="table-actions">
|
||||
<el-button v-if="access.can('platform:warehouse:edit')" link type="primary" :disabled="!tenantMatchesLoaded" @click="openEdit(row as WarehouseRow)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button
|
||||
v-if="access.can('platform:warehouse:status')"
|
||||
link
|
||||
:type="(row as WarehouseRow).status === 'ACTIVE' ? 'danger' : 'success'"
|
||||
:disabled="!tenantMatchesLoaded"
|
||||
|
||||
Reference in New Issue
Block a user