Feat: 前端改版 Web 端界面与业务作业流程

按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。

- 全局外壳:浅色侧边栏与分组菜单、左下角用户卡片、Ctrl/⌘K 搜索面板、
  测试模式提示条、深色模式配色、窄屏适配
- 共用组件:页头 PageHeader、托盘流程条 FlowStepper、扫码反馈 ScanFeedback、
  缩短单号 DocNo、托盘档案链接 PalletCodeLink、图标 SfIcon、上级资料选择
  MasterRefSelect,以及编号段解析等工具
- 工作台重做;新增托盘档案页;托盘追溯增加单托盘入口,报表托盘号可跳转档案
- 基础资料维护:各页加指标条、名称代替 ID、详情面板;三步式批量导入预览;
  分配用户对话框标注增减;7 个新增/编辑对话框统一布局
- 业务管理:发货出库、仓库入库、工厂装填、客户现场使用、退回仓库改为
  列表 + 详情面板 + 全屏作业区;写入流程、幂等键与防串单逻辑保持不变
- 修复入库页扫码反馈条因同名状态变量遮蔽组件而不显示的问题,并新增契约检查
- 新增入库粘贴解析、退回、装填的行为测试与现场使用页新流程测试,
  同步更新业务与工作台契约脚本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 05:03:22 -07:00
co-authored by Claude Opus 5.5
parent 3050ccdc4e
commit 167df4adb8
71 changed files with 18396 additions and 2428 deletions
@@ -0,0 +1,376 @@
<template>
<teleport to="body">
<transition name="sf-palette-fade">
<div v-if="visible" class="sf-palette-overlay" @mousedown.self="close">
<div class="sf-palette" role="dialog" aria-modal="true" aria-label="搜索或扫码">
<div class="sf-palette__input">
<sf-icon name="scan" :size="18" />
<label for="sf-palette-input" class="sf-visually-hidden">搜索页面或扫描托盘码</label>
<input
id="sf-palette-input"
ref="inputRef"
v-model="keyword"
type="text"
autocomplete="off"
spellcheck="false"
placeholder="扫描托盘码,或搜索页面名称"
role="combobox"
aria-controls="sf-palette-list"
:aria-expanded="results.length > 0"
:aria-activedescendant="results.length ? `sf-palette-item-${activeIndex}` : undefined"
@keydown.down.prevent="move(1)"
@keydown.up.prevent="move(-1)"
@keydown.enter.prevent="choose(results[activeIndex])"
@keydown.esc.prevent="close"
/>
<kbd class="sf-palette__kbd">Esc</kbd>
</div>
<ul v-if="results.length" id="sf-palette-list" class="sf-palette__list" role="listbox">
<li
v-for="(item, index) in results"
:id="`sf-palette-item-${index}`"
:key="item.key"
role="option"
:aria-selected="index === activeIndex"
:class="['sf-palette__item', { 'is-active': index === activeIndex }]"
@mousemove="activeIndex = index"
@click="choose(item)"
>
<span class="sf-palette__item-icon"><sf-icon :name="item.icon" :size="16" /></span>
<span class="sf-palette__item-main">
<span class="sf-palette__item-title" :class="{ 'sf-mono': item.mono }">{{ item.title }}</span>
<span v-if="item.description" class="sf-palette__item-desc">{{ item.description }}</span>
</span>
<span class="sf-palette__item-kind">{{ item.kind }}</span>
</li>
</ul>
<div v-else class="sf-palette__empty">
<template v-if="keyword.trim()">未找到与「{{ keyword.trim() }}」相关的页面</template>
<template v-else>输入页面名称快速跳转,或直接扫描托盘码查看托盘档案</template>
</div>
<div class="sf-palette__footer">
<span><kbd>↑</kbd><kbd>↓</kbd> 选择</span>
<span><kbd>Enter</kbd> 打开</span>
<span class="sf-palette__footer-tip">随时按 <kbd>Ctrl</kbd><kbd>K</kbd> 唤起</span>
</div>
</div>
</div>
</transition>
</teleport>
</template>
<script setup>
import SfIcon from '@/components/SfIcon'
import { resolveMenuIcon } from '@/components/SfIcon/icons'
import auth from '@/plugins/auth'
import usePermissionStore from '@/store/modules/permission'
import { getNormalPath } from '@/utils/shierfeng'
import { isHttp } from '@/utils/validate'
import { palletPassportLocation } from '@/utils/palletPassport'
const MAX_PAGE_RESULTS = 8
const router = useRouter()
const permissionStore = usePermissionStore()
const visible = ref(false)
const keyword = ref('')
const activeIndex = ref(0)
const inputRef = ref(null)
let restoreFocus = null
function flattenRoutes(routes, basePath = '', group = '') {
const pages = []
for (const item of routes || []) {
if (item.hidden) continue
const segment = item.path && item.path[0] === '/' ? item.path : `/${item.path || ''}`
const path = isHttp(item.path) ? item.path : getNormalPath(basePath + segment)
const title = item.meta?.title
const visibleChildren = (item.children || []).filter((child) => !child.hidden)
if (visibleChildren.length) {
pages.push(...flattenRoutes(visibleChildren, path, title || group))
} else if (title) {
pages.push({ path, title, group, query: item.query })
}
}
return pages
}
const pages = computed(() => flattenRoutes(permissionStore.sidebarRouters))
const results = computed(() => {
const term = keyword.value.trim()
const lowered = term.toLowerCase()
const items = []
// 含中文的输入视为页面搜索,只有像托盘码的输入才提供托盘档案入口
const looksLikeCode = term && !/[\u4e00-\u9fa5]/.test(term)
if (looksLikeCode && auth.hasPermi('report:pallettrace:list')) {
items.push({
key: `pallet:${term}`,
kind: '托盘档案',
icon: 'route',
title: term,
mono: true,
description: '查看该托盘的当前状态与完整流转记录',
action: () => router.push(palletPassportLocation(term))
})
}
const matchedPages = term
? pages.value.filter((page) => `${page.title}${page.group}${page.path}`.toLowerCase().includes(lowered))
: pages.value
matchedPages.slice(0, MAX_PAGE_RESULTS).forEach((page) => {
items.push({
key: `page:${page.path}`,
kind: '页面',
icon: resolveMenuIcon(page.path) || 'arrow-right',
title: page.title,
description: page.group,
action: () => openPage(page)
})
})
return items
})
watch(results, () => {
activeIndex.value = 0
})
function openPage(page) {
if (isHttp(page.path)) {
window.open(page.path, '_blank', 'noopener')
return
}
if (page.query) {
router.push({ path: page.path, query: JSON.parse(page.query) })
} else {
router.push(page.path)
}
}
function move(step) {
const count = results.value.length
if (!count) return
activeIndex.value = (activeIndex.value + step + count) % count
}
function choose(item) {
if (!item) return
close()
item.action()
}
function open() {
if (visible.value) return
restoreFocus = document.activeElement
keyword.value = ''
visible.value = true
nextTick(() => inputRef.value?.focus())
}
function close() {
if (!visible.value) return
visible.value = false
if (restoreFocus && typeof restoreFocus.focus === 'function') {
restoreFocus.focus()
}
restoreFocus = null
}
function handleShortcut(event) {
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'k') {
event.preventDefault()
visible.value ? close() : open()
}
}
onMounted(() => window.addEventListener('keydown', handleShortcut))
onBeforeUnmount(() => window.removeEventListener('keydown', handleShortcut))
defineExpose({ open, close })
</script>
<style lang="scss" scoped>
.sf-palette-overlay {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 12vh 16px 16px;
background: rgba(15, 17, 21, 0.28);
}
.sf-palette {
display: flex;
flex-direction: column;
width: 100%;
max-width: 640px;
max-height: 70vh;
overflow: hidden;
border: 1px solid var(--sf-border, #e7e9ec);
border-radius: 14px;
background: var(--sf-surface, #fff);
box-shadow: 0 24px 64px rgba(16, 24, 40, 0.24);
color: var(--sf-text-1, #0f1115);
font-family: var(--sf-font-sans);
}
.sf-palette__input {
display: flex;
align-items: center;
gap: 12px;
height: 56px;
padding: 0 16px;
border-bottom: 1px solid var(--sf-border-soft, #eef0f2);
color: var(--sf-accent, #007787);
input {
flex: 1;
min-width: 0;
height: 100%;
border: 0;
outline: none;
background: transparent;
color: var(--sf-text-1, #0f1115);
font: inherit;
font-size: 15px;
&::placeholder {
color: var(--el-text-color-placeholder, #8a909b);
}
}
}
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 5px;
border: 1px solid var(--sf-border, #e7e9ec);
border-radius: 5px;
background: var(--sf-surface-2, #f7f8f9);
color: var(--sf-text-3, #5b616e);
font-family: var(--sf-font-mono);
font-size: 11px;
line-height: 1;
}
.sf-palette__list {
flex: 1;
margin: 0;
padding: 6px;
overflow-y: auto;
list-style: none;
}
.sf-palette__item {
display: flex;
align-items: center;
gap: 12px;
padding: 9px 10px;
border-radius: 9px;
cursor: pointer;
&.is-active {
background: var(--sf-surface-2, #f7f8f9);
}
}
.sf-palette__item-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 30px;
height: 30px;
border-radius: 8px;
background: var(--sf-surface-3, #f1f2f4);
color: var(--sf-text-3, #5b616e);
.is-active & {
background: var(--sf-accent-soft, #e6f3f5);
color: var(--sf-accent, #007787);
}
}
.sf-palette__item-main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
line-height: 1.4;
}
.sf-palette__item-title {
overflow: hidden;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
text-overflow: ellipsis;
}
.sf-palette__item-desc {
overflow: hidden;
color: var(--sf-text-4, #6b717d);
font-size: 12.5px;
white-space: nowrap;
text-overflow: ellipsis;
}
.sf-palette__item-kind {
flex: none;
color: var(--sf-text-4, #6b717d);
font-size: 12px;
}
.sf-palette__empty {
padding: 28px 20px;
color: var(--sf-text-3, #5b616e);
font-size: 13.5px;
text-align: center;
}
.sf-palette__footer {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
border-top: 1px solid var(--sf-border-soft, #eef0f2);
color: var(--sf-text-4, #6b717d);
font-size: 12px;
span {
display: inline-flex;
align-items: center;
gap: 4px;
}
}
.sf-palette__footer-tip {
margin-left: auto;
}
.sf-palette-fade-enter-active,
.sf-palette-fade-leave-active {
transition: opacity 0.15s ease;
}
.sf-palette-fade-enter-from,
.sf-palette-fade-leave-to {
opacity: 0;
}
@media (max-width: 767px) {
.sf-palette-overlay {
padding-top: 8vh;
}
.sf-palette__footer {
display: none;
}
}
</style>