chore: initial commit

This commit is contained in:
2026-07-30 17:59:43 +08:00
commit dd0d365b03
73 changed files with 9898 additions and 0 deletions
+93
View File
@@ -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')
})
})
+31
View File
@@ -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>
+25
View File
@@ -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>
+38
View File
@@ -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>
+16
View File
@@ -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('权限预览:无可用角色,数据范围未模拟')
})
})
+52
View File
@@ -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'")
})
})
+118
View File
@@ -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>
+18
View File
@@ -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>