feat: 新增文件上传支持,优化移动端UI与巡检功能

1.  新增全局上传配置,设置默认20MB请求体上限与上传目录
2.  重构上传文件处理,按天自动创建子目录存储文件
3.  修复巡检上传表单移动端强制调用相机的问题
4.  为MES与库房客户端添加响应式侧边栏,支持桌面折叠与移动端抽屉模式
5.  补充PAD端巡检页面的独立访问说明文档
6.  删除冗余的前端静态首页文件
7.  更新库房客户端静态资源哈希值
This commit is contained in:
SunYF
2026-08-31 18:54:15 +08:00
parent ae4c292487
commit bb1bb2c3f5
10 changed files with 174 additions and 64 deletions
+5
View File
@@ -54,3 +54,8 @@ Plc:
Enable: false
Host: 127.0.0.1
Port: 102
# 文件上传(工艺图纸 PDF / 巡检照片):Dir 相对服务启动目录,可改绝对路径;按天自动分子目录
Upload:
Dir: uploads
MaxMB: 20
+10
View File
@@ -254,6 +254,16 @@ export const helpInspect = {
overview:
'检验员/线长用手持平板(PAD)或电脑浏览器做巡检与检验确认,PC 与 PAD 访问同一地址、前端自适应。\n\n数据来源:全部记录提交后写入 MES 巡检记录表(inspection_record),用于追溯和流程卡输出,也可在看板触发报警。\n\n权限:菜单「巡检终端」+ 各功能独立按钮权限码(sys.inspect:checkin/point/process/done/alarm/view),由【角色权限管理】统一分配。操作工通常无此菜单。',
sections: [
{
title: '打开方式(PAD / 手机独立访问)',
overview:
'电脑端:左侧菜单「巡检终端」打开。\n\nPAD / 手机独立打开:在浏览器地址栏输入\n' +
window.location.origin + '/inspect\n' +
'(如上所示即本机当前访问地址,例如 http://192.168.1.100:8888/inspect)。\n\n' +
'首次打开会跳转登录页,登录一次后(浏览器记住登录态)再次打开将直接进入巡检页;PAD 与电脑需在同一局域网。\n\n' +
'功能按权限显示:无「巡检终端」菜单或按钮权限的账号无法使用。',
fields: []
},
{
title: '开工签到',
fields: [
@@ -1,9 +1,16 @@
<template>
<el-container class="layout" style="min-height:100vh">
<el-aside width="210px" class="aside">
<div class="logo">MES 产线控制</div>
<!-- 移动端遮罩 -->
<div v-if="isMobile && mobileOpen" class="drawer-mask" @click="mobileOpen = false"></div>
<el-aside :width="asideWidth" class="aside"
:class="{ 'drawer': isMobile, 'drawer-open': isMobile && mobileOpen }">
<div class="logo">
<el-icon :size="22"><Monitor /></el-icon>
<span v-show="!isCollapse">MES 产线控制</span>
</div>
<el-menu v-if="menus.length" :default-active="$route.path" router background-color="#001529"
text-color="#a6adb4" active-text-color="#409eff" style="border-right:none">
text-color="#a6adb4" active-text-color="#409eff" style="border-right:none"
:collapse="isCollapse && !isMobile">
<el-menu-item v-for="m in menus" :key="m.path" :index="m.path">
<el-icon style="margin-right:8px"><component :is="m.icon" /></el-icon>
<span>{{ m.name }}</span>
@@ -16,7 +23,13 @@
</el-aside>
<el-container>
<el-header class="header">
<div class="header-left">
<el-icon class="collapse-btn" @click="toggleAside">
<Expand v-if="isMobile ? !mobileOpen : isCollapse" />
<Fold v-else />
</el-icon>
<span class="page-title">{{ $route.meta.title || '' }}</span>
</div>
<div class="spacer"></div>
<div class="header-right">
<el-button class="help-btn" text @click="openHelp">帮助</el-button>
@@ -39,17 +52,50 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
Document, Calendar, Box, Goods, ShoppingCart, Monitor, Tools,
Operation, Link, DataLine, EditPen, Search, CircleCheck, Clock, User
Operation, Link, DataLine, EditPen, Search, CircleCheck, Clock, User,
Fold, Expand
} from '@element-plus/icons-vue'
import { getUser, logout } from '../utils/auth'
import request from '../utils/request'
const router = useRouter()
// ===== 响应式侧边栏:桌面可折叠,移动端抽屉 =====
const isCollapse = ref(false) // 桌面端折叠(仅图标)
const isMobile = ref(false) // 移动端(≤767px
const mobileOpen = ref(false) // 移动端抽屉展开
const asideWidth = computed(() => (isMobile.value ? '210px' : isCollapse.value ? '64px' : '210px'))
function toggleAside() {
if (isMobile.value) {
mobileOpen.value = !mobileOpen.value
} else {
isCollapse.value = !isCollapse.value
}
}
function onScreenChange() {
const mq = window.matchMedia('(max-width: 767px)')
isMobile.value = mq.matches
if (!isMobile.value) mobileOpen.value = false
}
// 路由切换后自动收起移动端抽屉
watch(() => router.currentRoute.value.path, () => {
if (isMobile.value) mobileOpen.value = false
})
onMounted(() => {
onScreenChange()
window.addEventListener('resize', onScreenChange)
})
onBeforeUnmount(() => window.removeEventListener('resize', onScreenChange))
const menus = computed(() => {
const me = getUser()
if (!me) return defaultMenus
@@ -59,21 +105,21 @@ const menus = computed(() => {
})
const defaultMenus = [
{ code: 'produce.workorder', name: '工单管理', path: '/work-order' },
{ code: 'produce.dailyplan', name: '日排产', path: '/daily-plan' },
{ code: 'produce.bom', name: '物料清单', path: '/bom' },
{ code: 'produce.material', name: '备料单', path: '/material-request' },
{ code: 'produce.plc', name: '工位组合下发', path: '/plc-send' },
{ code: 'produce.torque', name: '拧紧查询', path: '/torque' },
{ code: 'produce.processflow', name: '工艺流程', path: '/process-flow' },
{ code: 'produce.station', name: '关联工位', path: '/station' },
{ code: 'produce.performance', name: '绩效报表', path: '/performance' },
{ code: 'produce.scan', name: '手动报工', path: '/scan' },
{ code: 'produce.trace', name: '工件追溯', path: '/trace' },
{ code: 'produce.product', name: '产品类型', path: '/product-type' },
{ code: 'sys.inspect', name: '巡检终端', path: '/inspect' },
{ code: 'sys.eventlog', name: '操作日志', path: '/event-log' },
{ code: 'sys.rbac', name: '角色权限管理', path: '/rbac' }
{ code: 'produce.workorder', name: '工单管理', path: '/work-order', icon: Document },
{ code: 'produce.dailyplan', name: '日排产', path: '/daily-plan', icon: Calendar },
{ code: 'produce.bom', name: '物料清单', path: '/bom', icon: Goods },
{ code: 'produce.material', name: '备料单', path: '/material-request', icon: ShoppingCart },
{ code: 'produce.plc', name: '工位组合下发', path: '/plc-send', icon: Monitor },
{ code: 'produce.torque', name: '拧紧查询', path: '/torque', icon: Tools },
{ code: 'produce.processflow', name: '工艺流程', path: '/process-flow', icon: Operation },
{ code: 'produce.station', name: '关联工位', path: '/station', icon: Link },
{ code: 'produce.performance', name: '绩效报表', path: '/performance', icon: DataLine },
{ code: 'produce.scan', name: '手动报工', path: '/scan', icon: EditPen },
{ code: 'produce.trace', name: '工件追溯', path: '/trace', icon: Search },
{ code: 'produce.product', name: '产品类型', path: '/product-type', icon: Box },
{ code: 'sys.inspect', name: '巡检终端', path: '/inspect', icon: CircleCheck },
{ code: 'sys.eventlog', name: '操作日志', path: '/event-log', icon: Clock },
{ code: 'sys.rbac', name: '角色权限管理', path: '/rbac', icon: User }
]
const userName = computed(() => {
@@ -108,14 +154,20 @@ function onUserCmd(cmd) {
</script>
<style scoped>
.aside { background: #001529; }
.logo { color: #fff; font-size: 17px; font-weight: 600; text-align: center; line-height: 60px; height: 60px; }
.aside { background: #001529; transition: width .2s ease; }
.drawer { position: fixed; left: 0; top: 0; bottom: 0; z-index: 1001; transform: translateX(-100%); transition: transform .3s ease; }
.drawer-open { transform: translateX(0); }
.drawer-mask { position: fixed; inset: 0; background: rgba(0, 0, 0, .45); z-index: 1000; }
.logo { display: flex; align-items: center; justify-content: center; gap: 8px; color: #fff; font-size: 17px; font-weight: 600; height: 60px; white-space: nowrap; }
.no-menu { padding: 24px 10px; text-align: center; color: #a6adb4; font-size: 14px; }
.no-menu .sub { margin-top: 6px; font-size: 12px; color: #6b7684; }
.header { display: flex; align-items: center; justify-content: space-between; background: #fff; border-bottom: 1px solid #eee; }
.page-title { font-size: 16px; font-weight: 600; color: #303133; }
.header-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
.collapse-btn { font-size: 20px; cursor: pointer; color: #303133; display: flex; align-items: center; flex-shrink: 0; }
.collapse-btn:hover { color: #1668dc; }
.page-title { font-size: 16px; font-weight: 600; color: #303133; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.spacer { flex: 1; }
.header-right { display: flex; align-items: center; gap: 6px; }
.header-right { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.help-btn { font-size: 13px; color: #606266; }
.user-name { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; font-size: 14px; color: #303133; outline: none; padding: 4px 6px; border-radius: 4px; }
.user-name:hover { color: #1668dc; background: #f2f6fc; }
+3 -3
View File
@@ -15,7 +15,7 @@
</el-radio-group>
</el-form-item>
<el-form-item label="签到拍照">
<input type="file" accept="image/*" capture="environment" class="file-input" @change="handlePhoto($event, checkin)">
<input type="file" accept="image/*" class="file-input" @change="handlePhoto($event, checkin)">
<div class="photo-box">
<el-image v-if="checkin.photo" :src="checkin.photo" :preview-src-list="[checkin.photo]" fit="cover" class="photo-preview" preview-teleported />
<span v-else class="photo-tip">点击上方按钮拍照上传</span>
@@ -41,7 +41,7 @@
</el-checkbox-group>
</el-form-item>
<el-form-item label="点检拍照">
<input type="file" accept="image/*" capture="environment" class="file-input" @change="handlePhoto($event, point)">
<input type="file" accept="image/*" class="file-input" @change="handlePhoto($event, point)">
<div class="photo-box">
<el-image v-if="point.photo" :src="point.photo" :preview-src-list="[point.photo]" fit="cover" class="photo-preview" preview-teleported />
<span v-else class="photo-tip">点击上方按钮拍照上传</span>
@@ -115,7 +115,7 @@
</el-checkbox-group>
</el-form-item>
<el-form-item label="异常拍照">
<input type="file" accept="image/*" capture="environment" class="file-input" @change="handlePhoto($event, alarm)">
<input type="file" accept="image/*" class="file-input" @change="handlePhoto($event, alarm)">
<div class="photo-box">
<el-image v-if="alarm.photo" :src="alarm.photo" :preview-src-list="[alarm.photo]" fit="cover" class="photo-preview" preview-teleported />
<span v-else class="photo-tip">点击上方按钮拍照上传</span>
+1 -1
View File
@@ -53,7 +53,7 @@ func ListInspectionsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
// UploadPhotoHandler POST /inspections/upload(巡检拍照上传,返回文件名)
func UploadPhotoHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
if err := r.ParseMultipartForm(svcCtx.Config.Upload.MaxMB << 20); err != nil {
httpx.BadRequest(w, "上传文件过大或格式错误")
return
}
+17 -11
View File
@@ -11,9 +11,11 @@ import (
"time"
)
// saveUploadFile 保存上传文件(工艺图纸PDF / 巡检照片),返回生成的文件名
// saveUploadFile 保存上传文件(工艺图纸PDF / 巡检照片),返回生成的相对文件名(含日期子目录)
func saveUploadFile(r *http.Request, field, dir string) (string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
sub := time.Now().Format("2006-01-02")
full := filepath.Join(dir, sub)
if err := os.MkdirAll(full, 0o755); err != nil {
return "", err
}
file, header, err := r.FormFile(field)
@@ -27,7 +29,7 @@ func saveUploadFile(r *http.Request, field, dir string) (string, error) {
ext = ".bin"
}
name := time.Now().Format("20060102150405") + "_" + randHex(6) + ext
dst, err := os.Create(filepath.Join(dir, name))
dst, err := os.Create(filepath.Join(full, name))
if err != nil {
return "", err
}
@@ -35,7 +37,7 @@ func saveUploadFile(r *http.Request, field, dir string) (string, error) {
if _, err := io.Copy(dst, file); err != nil {
return "", err
}
return name, nil
return filepath.ToSlash(filepath.Join(sub, name)), nil
}
func randHex(n int) string {
@@ -44,26 +46,30 @@ func randHex(n int) string {
return hex.EncodeToString(b)[:n*2]
}
// serveUploadFile 下发上传目录中的文件(限制为单段文件名,防路径穿越)
// serveUploadFile 下发上传目录中的文件(仅允许 单层日期子目录/文件名,防路径穿越)
func serveUploadFile(w http.ResponseWriter, r *http.Request, dir, name string) {
name = strings.TrimSpace(filepath.Base(name))
if name == "" || name == "." || name == ".." || strings.Contains(name, "..") {
name = strings.TrimSpace(name)
clean := filepath.Clean(filepath.FromSlash(name))
if clean == "." || clean == ".." ||
strings.HasPrefix(clean, ".."+string(filepath.Separator)) ||
filepath.IsAbs(clean) || strings.ContainsRune(name, '\x00') {
http.Error(w, "非法文件名", http.StatusBadRequest)
return
}
f, err := os.Open(filepath.Join(dir, name))
f, err := os.Open(filepath.Join(dir, clean))
if err != nil {
http.Error(w, "文件不存在", http.StatusNotFound)
return
}
defer f.Close()
ct := "application/octet-stream"
base := strings.ToLower(clean)
switch {
case strings.HasSuffix(strings.ToLower(name), ".pdf"):
case strings.HasSuffix(base, ".pdf"):
ct = "application/pdf"
case strings.HasSuffix(strings.ToLower(name), ".jpg"), strings.HasSuffix(strings.ToLower(name), ".jpeg"):
case strings.HasSuffix(base, ".jpg"), strings.HasSuffix(base, ".jpeg"):
ct = "image/jpeg"
case strings.HasSuffix(strings.ToLower(name), ".png"):
case strings.HasSuffix(base, ".png"):
ct = "image/png"
}
w.Header().Set("Content-Type", ct)
+1
View File
@@ -167,6 +167,7 @@ func main() {
server := rest.MustNewServer(
c.RestConf,
rest.WithCors("*"),
rest.WithMaxBytes(c.Upload.MaxMB<<20), // 请求体上限跟随 Upload.MaxMB(默认20MB),否则 go-zero 默认 1MB 会 413
rest.WithNotFoundHandler(http.HandlerFunc(spaHandler.ServeHTTP)),
)
defer server.Stop()
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>MES 产线控制</title>
<script type="module" crossorigin src="./assets/index-CEKQX1B2.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-HmZIfQ_J.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
@@ -1,10 +1,10 @@
<script setup>
import { computed } from 'vue'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
HomeFilled, Download, Upload, Search, CircleCheck, List,
Box, Tickets, Monitor, EditPen
Box, Tickets, Monitor, EditPen, Fold, Expand
} from '@element-plus/icons-vue'
import { getUser, logout } from '../utils/auth'
@@ -15,6 +15,38 @@ const user = getUser()
const userName = computed(() => user?.realName || user?.username || '未登录')
const pageTitle = computed(() => route.meta?.title || '')
// ===== 响应式侧边栏:桌面可折叠,移动端抽屉 =====
const isCollapse = ref(false) // 桌面端折叠(仅图标)
const isMobile = ref(false) // 移动端(≤767px
const mobileOpen = ref(false) // 移动端抽屉展开
const asideWidth = computed(() => (isMobile.value ? '210px' : isCollapse.value ? '64px' : '210px'))
function toggleAside() {
if (isMobile.value) {
mobileOpen.value = !mobileOpen.value
} else {
isCollapse.value = !isCollapse.value
}
}
function onScreenChange() {
const mq = window.matchMedia('(max-width: 767px)')
isMobile.value = mq.matches
if (!isMobile.value) mobileOpen.value = false
}
// 路由切换后自动收起移动端抽屉
watch(() => route.path, () => {
if (isMobile.value) mobileOpen.value = false
})
onMounted(() => {
onScreenChange()
window.addEventListener('resize', onScreenChange)
})
onBeforeUnmount(() => window.removeEventListener('resize', onScreenChange))
const menus = [
{ path: '/', title: '工作台', icon: HomeFilled },
{ path: '/inbound', title: '入库管理', icon: Download },
@@ -50,13 +82,17 @@ function onLogout() {
<template>
<el-container class="layout">
<el-aside width="210px" class="aside">
<!-- 移动端遮罩 -->
<div v-if="isMobile && mobileOpen" class="drawer-mask" @click="mobileOpen = false"></div>
<el-aside :width="asideWidth" class="aside"
:class="{ 'drawer': isMobile, 'drawer-open': isMobile && mobileOpen }">
<div class="brand">
<el-icon :size="22"><Box /></el-icon>
<span>库房客户端</span>
<span v-show="!isCollapse">库房客户端</span>
</div>
<el-menu :default-active="route.path" class="menu" background-color="#001529"
text-color="#c2cad8" active-text-color="#ffffff" router>
text-color="#c2cad8" active-text-color="#ffffff" router
:collapse="isCollapse && !isMobile">
<el-menu-item v-for="m in menus" :key="m.path" :index="m.path">
<el-icon><component :is="m.icon" /></el-icon>
<span>{{ m.title }}</span>
@@ -66,7 +102,13 @@ function onLogout() {
<el-container>
<el-header height="56px" class="header">
<div class="header-left">
<el-icon class="collapse-btn" @click="toggleAside">
<Expand v-if="isMobile ? !mobileOpen : isCollapse" />
<Fold v-else />
</el-icon>
<span class="title">{{ pageTitle }}</span>
</div>
<div class="spacer"></div>
<el-button text type="primary" @click="router.push('/display')">
<el-icon style="margin-right:4px"><Monitor /></el-icon>免登录大屏
@@ -91,19 +133,26 @@ function onLogout() {
<style scoped>
.layout { height: 100vh; }
.aside { background: #001529; }
.aside { background: #001529; transition: width .2s ease; }
.drawer { position: fixed; left: 0; top: 0; bottom: 0; z-index: 1001; transform: translateX(-100%); transition: transform .3s ease; }
.drawer-open { transform: translateX(0); }
.drawer-mask { position: fixed; inset: 0; background: rgba(0, 0, 0, .45); z-index: 1000; }
.brand {
display: flex; align-items: center; gap: 8px;
height: 56px; padding: 0 16px;
color: #fff; font-weight: 600; font-size: 15px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
white-space: nowrap;
}
.menu { border-right: none; }
.header {
display: flex; align-items: center; gap: 10px;
background: #fff; border-bottom: 1px solid #ebeef5;
}
.title { font-size: 16px; font-weight: 600; }
.header-left { display: flex; align-items: center; gap: 8px; min-width: 0; }
.collapse-btn { font-size: 20px; cursor: pointer; color: #303133; display: flex; align-items: center; flex-shrink: 0; }
.collapse-btn:hover { color: #1668dc; }
.title { font-size: 16px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.spacer { flex: 1; }
.help-btn { font-size: 13px; color: #606266; }
.user-name {
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>库房客户端</title>
<script type="module" crossorigin src="/assets/index-C_gZ2k2u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bi2AMikI.css">
<script type="module" crossorigin src="/assets/index-BytlUM1G.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bc2zCoxT.css">
</head>
<body>
<div id="app"></div>