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:
@@ -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>
|
||||
Reference in New Issue
Block a user