Files
ShiErFeng/shierfeng-fastapi-frontend/src/layout/components/Navbar.vue
T
yujiangandClaude Opus 5.5 167df4adb8 Feat: 前端改版 Web 端界面与业务作业流程
按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:22 -07:00

383 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="navbar" :class="'nav' + settingsStore.navType">
<hamburger id="hamburger-container" :is-active="appStore.sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
<breadcrumb v-if="settingsStore.navType == 1" id="breadcrumb-container" class="breadcrumb-container" />
<top-nav v-if="settingsStore.navType == 2" id="topmenu-container" class="topmenu-container" />
<template v-if="settingsStore.navType == 3">
<logo v-show="settingsStore.sidebarLogo" :collapse="false"></logo>
<top-bar id="topbar-container" class="topbar-container" />
</template>
<div class="right-menu">
<button type="button" class="right-menu-item search-trigger" aria-label="搜索或扫码(Ctrl K)" @click="openSearch">
<sf-icon name="search" :size="15" />
<span class="search-trigger__text">搜索或扫码</span>
<kbd class="search-trigger__kbd">Ctrl K</kbd>
</button>
<test-batch-mode-switch class="right-menu-item test-batch-mode" aria-label="测试数据模式" />
<template v-if="appStore.device !== 'mobile'">
<el-tooltip content="全屏" effect="dark" placement="bottom">
<screenfull id="screenfull" class="right-menu-item hover-effect" />
</el-tooltip>
<el-tooltip :content="settingsStore.isDark ? '切换浅色模式' : '切换深色模式'" effect="dark" placement="bottom">
<div class="right-menu-item hover-effect theme-switch-wrapper" @click="toggleTheme">
<svg-icon v-if="settingsStore.isDark" icon-class="sunny" />
<svg-icon v-if="!settingsStore.isDark" icon-class="moon" />
</div>
</el-tooltip>
<el-tooltip content="布局大小" effect="dark" placement="bottom">
<size-select id="size-select" class="right-menu-item hover-effect" />
</el-tooltip>
</template>
<el-dropdown v-if="!sidebarShowsUser" @command="handleCommand" class="avatar-container right-menu-item hover-effect" trigger="hover">
<div class="avatar-wrapper">
<img :src="userStore.avatar" class="user-avatar" alt="" />
<span class="user-nickname"> {{ userStore.nickName }} </span>
</div>
<template #dropdown>
<el-dropdown-menu>
<router-link to="/user/profile">
<el-dropdown-item>个人中心</el-dropdown-item>
</router-link>
<el-dropdown-item command="setLayout" v-if="settingsStore.showSettings">
<span>布局设置</span>
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<span>退出登录</span>
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</template>
<script setup>
import { ElMessageBox } from 'element-plus'
import Breadcrumb from '@/components/Breadcrumb'
import TopNav from '@/components/TopNav'
import TopBar from './TopBar'
import Logo from './Sidebar/Logo'
import Hamburger from '@/components/Hamburger'
import Screenfull from '@/components/Screenfull'
import SizeSelect from '@/components/SizeSelect'
import SfIcon from '@/components/SfIcon'
import TestBatchModeSwitch from '@/components/TestBatchModeSwitch'
import useAppStore from '@/store/modules/app'
import useUserStore from '@/store/modules/user'
import useSettingsStore from '@/store/modules/settings'
const appStore = useAppStore()
const userStore = useUserStore()
const settingsStore = useSettingsStore()
// 左侧导航底部已展示当前用户时,顶部不再重复显示头像
const sidebarShowsUser = computed(() => appStore.device !== 'mobile' && !appStore.sidebar.hide && settingsStore.navType == 1)
function toggleSideBar() {
appStore.toggleSideBar()
}
function handleCommand(command) {
switch (command) {
case "setLayout":
setLayout()
break
case "logout":
logout()
break
default:
break
}
}
function logout() {
ElMessageBox.confirm('确定注销并退出系统吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
userStore.logOut().then(() => {
location.href = '/index'
})
}).catch(() => { })
}
const emits = defineEmits(['setLayout', 'openSearch'])
function setLayout() {
emits('setLayout')
}
function openSearch() {
emits('openSearch')
}
async function toggleTheme(event) {
const x = event?.clientX || window.innerWidth / 2
const y = event?.clientY || window.innerHeight / 2
const wasDark = settingsStore.isDark
const isReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches
const isSupported = document.startViewTransition && !isReducedMotion
if (!isSupported) {
settingsStore.toggleTheme()
return
}
try {
const transition = document.startViewTransition(async () => {
await new Promise((resolve) => setTimeout(resolve, 10))
settingsStore.toggleTheme()
await nextTick()
})
await transition.ready
const endRadius = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y))
const clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`]
document.documentElement.animate(
{
clipPath: !wasDark ? [...clipPath].reverse() : clipPath
}, {
duration: 650,
easing: "cubic-bezier(0.4, 0, 0.2, 1)",
fill: "forwards",
pseudoElement: !wasDark ? "::view-transition-old(root)" : "::view-transition-new(root)"
}
)
await transition.finished
} catch (error) {
console.warn("View transition failed, falling back to immediate toggle:", error)
settingsStore.toggleTheme()
}
}
</script>
<style lang='scss' scoped>
.navbar.nav3 {
.hamburger-container {
display: none !important;
}
}
.navbar {
height: 56px;
overflow: hidden;
position: relative;
background: var(--navbar-bg);
border-bottom: 1px solid var(--sf-border-soft, #eef0f2);
display: flex;
align-items: center;
gap: 4px;
padding: 0 12px 0 8px;
box-sizing: border-box;
.hamburger-container {
height: 34px;
border-radius: 8px;
cursor: pointer;
color: var(--sf-text-3, #5b616e);
transition: background 0.15s;
-webkit-tap-highlight-color: transparent;
display: flex;
align-items: center;
flex-shrink: 0;
margin-right: 4px;
&:hover {
background: var(--sf-hover, rgba(15, 17, 21, 0.045));
}
:deep(div) {
padding: 0 8px !important;
}
:deep(.hamburger) {
width: 18px;
height: 18px;
}
}
.breadcrumb-container {
flex-shrink: 0;
margin-left: 4px;
}
.topmenu-container {
position: absolute;
left: 50px;
}
.topbar-container {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
overflow: hidden;
margin-left: 8px;
}
.right-menu {
height: 100%;
display: flex;
align-items: center;
gap: 2px;
margin-left: auto;
min-width: 0;
flex-shrink: 1;
overflow: hidden;
&:focus {
outline: none;
}
.right-menu-item {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 34px;
height: 34px;
padding: 0 8px;
border-radius: 8px;
font-size: 17px;
color: var(--sf-text-3, #5b616e);
vertical-align: middle;
&.hover-effect {
cursor: pointer;
transition: background 0.15s, color 0.15s;
&:hover {
background: var(--sf-hover, rgba(15, 17, 21, 0.045));
color: var(--sf-text-1, #0f1115);
}
}
&.theme-switch-wrapper {
display: flex;
align-items: center;
}
}
.search-trigger {
gap: 8px;
width: 248px;
height: 34px;
margin-right: 8px;
padding: 0 8px 0 10px;
border: 1px solid var(--sf-border-strong, #dde0e4);
background: var(--sf-surface, #fff);
color: var(--sf-text-4, #6b717d);
font: inherit;
font-size: 13px;
cursor: pointer;
justify-content: flex-start;
transition: border-color 0.15s;
&:hover {
border-color: #c3c8cf;
}
.search-trigger__text {
flex: 1;
text-align: left;
}
.search-trigger__kbd {
padding: 1px 6px;
border: 1px solid var(--sf-border, #e7e9ec);
border-radius: 5px;
font-family: var(--sf-font-mono);
font-size: 11px;
color: var(--sf-text-4, #6b717d);
}
}
.avatar-container {
margin-left: 4px;
padding: 0;
.avatar-wrapper {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 2px 8px 2px 2px;
border-radius: 18px;
transition: background 0.15s ease;
&:hover {
background: var(--sf-hover, rgba(15, 17, 21, 0.045));
}
.user-avatar {
cursor: pointer;
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
border: 1px solid var(--sf-border, #e7e9ec);
}
.user-nickname {
font-size: 13px;
font-weight: 500;
color: var(--sf-text-1, #0f1115);
}
}
}
.test-batch-mode {
display: flex;
align-items: center;
min-width: 0;
margin-right: 6px;
font-size: 14px;
color: inherit;
}
}
}
@media screen and (max-width: 1200px) {
.navbar .right-menu .search-trigger {
width: auto;
.search-trigger__text,
.search-trigger__kbd {
display: none;
}
}
}
@media screen and (max-width: 767px) {
.navbar {
.breadcrumb-container {
display: none;
}
.right-menu {
overflow: visible;
.test-batch-mode {
padding: 0;
}
.search-trigger {
margin-right: 2px;
border: 0;
}
.avatar-container .avatar-wrapper .user-nickname {
display: none;
}
.avatar-container .avatar-wrapper .user-avatar {
margin-right: 0;
}
}
}
}
</style>