chore: initial commit
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { adminNavigation, filterAdminNavigation, type AdminNavigationNode } from '@/navigation/adminNavigation'
|
||||
import AdminSidebarMenu from './AdminSidebarMenu.vue'
|
||||
|
||||
const stubs = {
|
||||
'el-menu': {
|
||||
props: ['defaultActive', 'collapse'],
|
||||
template: '<nav :data-active="defaultActive" :data-collapsed="String(collapse)"><slot /></nav>',
|
||||
},
|
||||
'el-sub-menu': {
|
||||
props: ['index'],
|
||||
template: '<section data-kind="group" :data-index="index"><header><slot name="title" /></header><slot /></section>',
|
||||
},
|
||||
'el-menu-item': {
|
||||
props: ['index'],
|
||||
template: '<a data-kind="item" :data-index="index"><slot name="title" /><slot /></a>',
|
||||
},
|
||||
}
|
||||
|
||||
describe('AdminSidebarMenu', () => {
|
||||
it('renders grouped page entries and forwards the active route', () => {
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items: adminNavigation, activePath: '/system/users', collapsed: false },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.get('nav').attributes('data-active')).toBe('/system/users')
|
||||
expect(wrapper.findAll('[data-kind="group"] header').map((node) => node.text())).toEqual([
|
||||
'业务管理',
|
||||
'基础资料',
|
||||
'系统管理',
|
||||
])
|
||||
expect(wrapper.findAll('[data-kind="item"]').map((node) => node.attributes('data-index'))).toEqual([
|
||||
'/customers',
|
||||
'/customers/reviews',
|
||||
'/warehouses',
|
||||
'/orders/first-leg',
|
||||
'/master-data/express-companies',
|
||||
'/master-data/counterparties',
|
||||
'/system/users',
|
||||
'/system/roles',
|
||||
'/system/permissions',
|
||||
])
|
||||
})
|
||||
|
||||
it('forwards desktop collapse state to Element Plus', () => {
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items: adminNavigation, activePath: '/', collapsed: true },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.get('nav').attributes('data-collapsed')).toBe('true')
|
||||
})
|
||||
|
||||
it('renders only entries included in the filtered navigation input', () => {
|
||||
const items = filterAdminNavigation(adminNavigation, new Set(['platform:permission:list']))
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items, activePath: '/', collapsed: false },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('菜单权限')
|
||||
expect(wrapper.text()).not.toContain('客户管理')
|
||||
})
|
||||
|
||||
it('recursively renders three navigation levels', () => {
|
||||
const items: AdminNavigationNode[] = [{
|
||||
key: 'operations',
|
||||
title: '运营管理',
|
||||
icon: adminNavigation[0].icon,
|
||||
children: [{
|
||||
key: 'shipment',
|
||||
title: '运输管理',
|
||||
icon: adminNavigation[0].icon,
|
||||
children: [{
|
||||
key: 'tracking',
|
||||
title: '轨迹管理',
|
||||
icon: adminNavigation[0].icon,
|
||||
path: '/tracking',
|
||||
permission: 'tracking:list',
|
||||
}],
|
||||
}],
|
||||
}]
|
||||
const wrapper = mount(AdminSidebarMenu, {
|
||||
props: { items, activePath: '/tracking', collapsed: false },
|
||||
global: { stubs },
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('[data-kind="group"]')).toHaveLength(2)
|
||||
expect(wrapper.get('[data-kind="item"]').attributes('data-index')).toBe('/tracking')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AdminNavigationNode } from '@/navigation/adminNavigation'
|
||||
import AdminSidebarMenuNode from './AdminSidebarMenuNode.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
items: AdminNavigationNode[]
|
||||
activePath: string
|
||||
collapsed: boolean
|
||||
}>()
|
||||
|
||||
function getGroupKeys(nodes: AdminNavigationNode[]): string[] {
|
||||
return nodes.flatMap((node) => node.children?.length
|
||||
? [node.key, ...getGroupKeys(node.children)]
|
||||
: [])
|
||||
}
|
||||
|
||||
const defaultOpeneds = computed(() => getGroupKeys(props.items))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-menu
|
||||
class="side-nav-menu"
|
||||
:default-active="activePath"
|
||||
:collapse="collapsed"
|
||||
:default-openeds="defaultOpeneds"
|
||||
router
|
||||
>
|
||||
<AdminSidebarMenuNode v-for="item in items" :key="item.key" :node="item" />
|
||||
</el-menu>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminNavigationNode } from '@/navigation/adminNavigation'
|
||||
|
||||
defineOptions({ name: 'AdminSidebarMenuNode' })
|
||||
|
||||
defineProps<{
|
||||
node: AdminNavigationNode
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-sub-menu v-if="node.children?.length" :index="node.key">
|
||||
<template #title>
|
||||
<component :is="node.icon" :size="19" :stroke-width="1.8" />
|
||||
<span>{{ node.title }}</span>
|
||||
</template>
|
||||
|
||||
<AdminSidebarMenuNode v-for="child in node.children" :key="child.key" :node="child" />
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item v-else :index="node.path || node.key">
|
||||
<component :is="node.icon" :size="19" :stroke-width="1.8" />
|
||||
<template #title>{{ node.title }}</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Copy } from '@lucide/vue'
|
||||
import type { ApiErrorDetails } from '@/api/http'
|
||||
|
||||
const props = defineProps<{ details: ApiErrorDetails }>()
|
||||
|
||||
async function copyIdentifier(value: string, label: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
ElMessage.success(`${label} 已复制`)
|
||||
} catch {
|
||||
ElMessage.error(`${label} 复制失败,请手动选择复制`)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="details.code || details.requestId || details.traceId" class="api-error-meta" aria-label="接口错误追踪信息">
|
||||
<span v-if="details.code" class="api-error-code">{{ details.code }}</span>
|
||||
<button v-if="details.requestId" type="button" :title="`复制请求 ID:${props.details.requestId}`" @click="copyIdentifier(props.details.requestId!, '请求 ID')">
|
||||
<span>请求 ID</span><code>{{ props.details.requestId }}</code><Copy :size="13" />
|
||||
</button>
|
||||
<button v-if="details.traceId" type="button" :title="`复制 Trace ID:${props.details.traceId}`" @click="copyIdentifier(props.details.traceId!, 'Trace ID')">
|
||||
<span>Trace ID</span><code>{{ props.details.traceId }}</code><Copy :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.api-error-meta { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
|
||||
.api-error-code { padding: 3px 7px; border-radius: 4px; background: #fee4e2; color: #912018; font-size: 10px; font-weight: 750; }
|
||||
.api-error-meta button { display: inline-flex; min-width: 0; max-width: 100%; min-height: 30px; align-items: center; gap: 6px; padding: 4px 7px; border: 1px solid #fecdca; border-radius: 4px; background: #fff; color: #7a271a; cursor: pointer; }
|
||||
.api-error-meta button:hover { background: #fff5f4; }
|
||||
.api-error-meta button span { flex: 0 0 auto; font-size: 10px; font-weight: 700; }
|
||||
.api-error-meta code { min-width: 0; overflow: hidden; color: #b42318; font-family: "Cascadia Code", Consolas, monospace; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.api-error-meta svg { flex: 0 0 auto; }
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
items: Array<{ label: string; value: string | number; note?: string }>
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="metric-strip" aria-label="数据摘要">
|
||||
<div v-for="item in items" :key="item.label" class="metric-item">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
<small v-if="item.note">{{ item.note }}</small>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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('权限预览:无可用角色,数据范围未模拟')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<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>
|
||||
@@ -0,0 +1,19 @@
|
||||
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'")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
<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>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{ status: string }>()
|
||||
|
||||
const tone = computed(() => {
|
||||
if (['正常', '启用', '已完成'].includes(props.status)) return 'success'
|
||||
if (['待审核', '草稿', '待处理'].includes(props.status)) return 'warning'
|
||||
if (['已受理', '已订舱', '运输中', '处理中'].includes(props.status)) return 'info'
|
||||
if (['停用', '已取消', '已驳回', '失败'].includes(props.status)) return 'danger'
|
||||
return 'neutral'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="status-badge" :class="`is-${tone}`"><i aria-hidden="true" />{{ status }}</span>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user