feat: 完成MES工位终端系统全量功能迭代

本次提交包含以下核心变更:
1. 新增按钮级权限控制框架与前端权限校验
2. 新增工位管理、工艺流程、巡检终端等核心业务模块
3. 完善用户体系,支持工位终端专属登录权限
4. 新增文件上传下载、报表查询等配套功能
5. 修复多系统兼容性与交互体验问题
6. 完成所有文档与配置项的规范化更新
This commit is contained in:
SunYF
2026-08-31 08:06:55 +08:00
parent dab03ac0cf
commit 4ec6784629
99 changed files with 17436 additions and 479 deletions
@@ -7,8 +7,11 @@ export const login = (data) => request.post('/api/auth/login', data)
export const getConfig = () => request.get('/api/config')
// 当前工单任务(代理 MES
export const getTaskCurrent = (dockCode) =>
request.get('/api/task/current', { params: { dockCode } })
export const getTaskCurrent = (stationNo) =>
request.get('/api/task/current', { params: { stationNo } })
// 我的工作量(代理 MES,按当前登录人查询)
export const getWorkload = (params) => request.get('/api/workload', { params })
// 拧紧结果列表(本地 SQLite)
export const getTighteningList = (params) => request.get('/api/tightening/list', { params })
@@ -1,7 +1,7 @@
<template>
<div class="login-page">
<div class="login-card">
<h1 class="title">北京动力 · 工位终端</h1>
<h1 class="title">工位终端</h1>
<p class="subtitle">产线触屏终端端口 8892</p>
<div class="field">
@@ -25,37 +25,73 @@
@keyup.enter="submit"
/>
</div>
<div class="field">
<span class="lab">工位</span>
<el-select
v-if="!fixedStation"
v-model="stationNo"
size="large"
class="station-el"
placeholder="请选择工位"
>
<el-option v-for="n in 12" :key="n" :label="'工位 ' + n" :value="n" />
</el-select>
<span v-else class="station-fixed">工位 {{ fixedStation }}</span>
</div>
<el-button class="btn-login" type="primary" size="large" :loading="loading" @click="submit">
</el-button>
<p class="hint">内置账号admin / 123456 &nbsp;&nbsp;operator1 / 123456</p>
<p class="hint">账号由 MES 统一管理</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { login } from '../api'
import { getConfig, login } from '../api'
import { setAuth } from '../utils/request'
const STATION_KEY = 'ws_station_no'
const router = useRouter()
const username = ref('')
const password = ref('')
const stationNo = ref(null)
const fixedStation = ref(0)
const loading = ref(false)
onMounted(async () => {
try {
const cfg = await getConfig()
if (cfg?.stationNo > 0) fixedStation.value = cfg.stationNo
} catch {
// 网络异常时忽略,工位仍可自选
}
stationNo.value = fixedStation.value || Number(localStorage.getItem(STATION_KEY)) || null
})
async function submit() {
if (!username.value || !password.value) {
ElMessage.warning('请输入账号和密码')
return
}
const sn = fixedStation.value || stationNo.value
if (!sn) {
ElMessage.warning('请选择工位')
return
}
loading.value = true
try {
const data = await login({ username: username.value, password: password.value })
setAuth(data.token, data.user)
const data = await login({
username: username.value,
password: password.value,
stationNo: sn
})
setAuth(data.accessToken, data.user)
localStorage.setItem(STATION_KEY, String(sn))
ElMessage.success('登录成功')
router.push('/main')
} catch (e) {
@@ -85,7 +121,7 @@ async function submit() {
.title {
margin: 0 0 8px;
font-size: 30px;
font-size: 34px;
text-align: center;
color: #16324f;
}
@@ -117,6 +153,28 @@ async function submit() {
font-size: 20px;
}
.station-el {
flex: 1;
}
.station-el :deep(.el-input__inner) {
height: 52px;
font-size: 20px;
font-weight: bold;
color: #16324f;
}
.station-fixed {
flex: 1;
font-size: 22px;
font-weight: bold;
color: #16324f;
line-height: 52px;
background: #f2f6fb;
border-radius: 8px;
padding: 0 16px;
}
.btn-login {
width: calc(100% - 80px);
margin-left: 80px;
@@ -131,4 +189,4 @@ async function submit() {
font-size: 14px;
color: #9aa7b8;
}
</style>
</style>
File diff suppressed because it is too large Load Diff
@@ -31,9 +31,9 @@ type SqliteConfig struct {
Path string `json:",default=workstation.db"`
}
// StationConfig 工位配置:可从配置文件指定固定工位,并定义该工位需拧紧的螺丝颗数
// StationConfig 工位配置:可从配置文件指定固定工位号(1~12),留空则由终端自选
type StationConfig struct {
Code string `json:",optional"` // 固定工位号(如 DOCK01);留空则由终端自选
Code string `json:",optional"` // 固定工位号(如 "3");留空则由终端自选 1~12
ScrewCount int `json:",default=10"` // 本工位需拧紧的螺丝颗数(进度 9/10 用)
}
+19 -36
View File
@@ -25,13 +25,15 @@ func healthHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
}
// loginHandler 本地 SQLite + bcrypt 校验登录,返回 {token, expireAt, user}
// 账号自动记录为操作人(前端通过 localStorage 的 user 保存操作人)
// loginHandler 登录代理 MES /api/internal/station/login(块1:用户体系来源于 MES)
// 前端提交 {username, password, stationNo}MES 校验工位终端密码 + 工位权限并签发同密钥 JWT
// 响应体原样透传({code,message,data:{accessToken,expireAt,user,stations,stationNo}})。
func loginHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
Username string `json:"username"`
Password string `json:"password"`
StationNo int `json:"stationNo"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
@@ -42,46 +44,27 @@ func loginHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return
}
u, err := ctx.Store.GetUserByUsername(strings.TrimSpace(req.Username))
if err != nil {
fail(w, http.StatusUnauthorized, "用户名或密码错误")
return
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(req.Password)) != nil {
fail(w, http.StatusUnauthorized, "用户名或密码错误")
return
}
token, expireAt, err := auth.SignToken(
ctx.Config.Auth.AccessSecret,
ctx.Config.Auth.AccessExpire,
auth.TokenInfo{UserID: u.ID, Username: u.Username, RealName: u.RealName, Role: u.Role},
)
if err != nil {
fail(w, http.StatusInternalServerError, "签发 token 失败")
return
}
ctx.Store.AddEventLog("", u.RealName, "login", "账号登录")
ok(w, map[string]any{
"token": token,
"expireAt": expireAt,
"user": map[string]any{
"id": u.ID,
"username": u.Username,
"realName": u.RealName,
"role": u.Role,
},
body, _, err := ctx.Mes.Post("/api/internal/station/login", map[string]any{
"username": strings.TrimSpace(req.Username),
"password": req.Password,
"stationNo": req.StationNo,
})
if err != nil {
fail(w, http.StatusBadGateway, "MES 服务不可达:"+errString(err))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
}
// configHandler GET /api/config 返回工位配置:
// 固定工位号(Station.Code 为空则前端可自选)、本工位需拧紧的螺丝颗数。
// 固定工位号 stationNoStation.Code 为空则前端可自选 1~12)、本工位需拧紧的螺丝颗数。
func configHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ok(w, map[string]any{
"station": ctx.Config.Station.Code,
"stationNo": atoi(ctx.Config.Station.Code, 0),
"screwCount": ctx.Config.Station.ScrewCount,
})
}
@@ -23,6 +23,10 @@ func atoi(s string, def int) int {
return n
}
func itoa(n int) string {
return strconv.Itoa(n)
}
// errString 压缩错误信息长度便于前端展示。
func errString(err error) string {
s := err.Error()
+33 -5
View File
@@ -13,17 +13,45 @@ import (
"bj_power_workstation/internal/svc"
)
// taskCurrentHandler 代理 MES GET /api/internal/station/task?dockCode=。
// taskCurrentHandler 代理 MES GET /api/internal/station/task?stationNo=。
// 响应体原样透传(MES 已按 {code,message,data} 封装,不再二次包装)。
func taskCurrentHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
dockCode := strings.TrimSpace(r.URL.Query().Get("dockCode"))
if dockCode == "" {
fail(w, http.StatusBadRequest, "缺少 dockCode 参数")
stationNo := atoi(strings.TrimSpace(r.URL.Query().Get("stationNo")), 0)
if stationNo <= 0 {
fail(w, http.StatusBadRequest, "缺少 stationNo 参数")
return
}
body, _, err := ctx.Mes.Get("/api/internal/station/task", url.Values{"dockCode": {dockCode}})
body, _, err := ctx.Mes.Get("/api/internal/station/task", url.Values{"stationNo": {strconv.Itoa(stationNo)}})
if err != nil {
fail(w, http.StatusBadGateway, "MES 服务不可达:"+errString(err))
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}
}
// workloadHandler 代理 MES GET /api/internal/station/workload?operator=&from=&to=(块6)。
// 工位终端「我的工作量」仅查询当前登录人数据;MES 响应体原样透传。
func workloadHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
operator := strings.TrimSpace(r.URL.Query().Get("operator"))
from := strings.TrimSpace(r.URL.Query().Get("from"))
to := strings.TrimSpace(r.URL.Query().Get("to"))
params := url.Values{}
if operator != "" {
params.Set("operator", operator)
}
if from != "" {
params.Set("from", from)
}
if to != "" {
params.Set("to", to)
}
body, _, err := ctx.Mes.Get("/api/internal/station/workload", params)
if err != nil {
fail(w, http.StatusBadGateway, "MES 服务不可达:"+errString(err))
return
+35 -27
View File
@@ -10,24 +10,30 @@ import (
)
// processDoneHandler POST /api/report/process-done
// {dockCode, orderNo, sn, processCode, operator}
// 流程:先冲刷一轮积压 → 实时报 MES /api/internal/station/done
// {sn, processCode, stationNo, steps:[{stepId,name,value,text}]}
// 流程:先冲刷一轮积压 → 实时报 MES /api/internal/station/report
// MES 不可达则写入 report_queue(kind=process_done) 并返回 data.queued=true(离线缓存稍后自动重传)。
func processDoneHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
DockCode string `json:"dockCode"`
OrderNo string `json:"orderNo"`
Sn string `json:"sn"`
ProcessCode string `json:"processCode"`
Operator string `json:"operator"`
OrderNo string `json:"orderNo"`
ProcessCode int `json:"processCode"`
StationNo int `json:"stationNo"`
Steps []struct {
StepId int `json:"stepId"`
Name string `json:"name"`
Value float64 `json:"value"`
Text string `json:"text"`
} `json:"steps"`
Operator string `json:"operator"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
if req.DockCode == "" {
fail(w, http.StatusBadRequest, "缺少 dockCode")
if req.Sn == "" || req.ProcessCode <= 0 || req.StationNo <= 0 {
fail(w, http.StatusBadRequest, "sn/processCode/stationNo 必填")
return
}
@@ -35,18 +41,19 @@ func processDoneHandler(ctx *svc.ServiceContext) http.HandlerFunc {
ctx.Syncer.RunOnce(r.Context())
body := map[string]any{
"dockCode": req.DockCode,
"orderNo": req.OrderNo,
"sn": req.Sn,
"orderNo": req.OrderNo,
"processCode": req.ProcessCode,
"stationNo": req.StationNo,
"steps": req.Steps,
"operator": req.Operator,
}
payloadBytes, _ := json.Marshal(body)
ctx.Store.AddEventLog(req.OrderNo, req.Operator, "process_done",
"工序完成上报 dockCode="+req.DockCode+" sn="+req.Sn+" processCode="+req.ProcessCode)
"工序完成上报 sn="+req.Sn+" stationNo="+itoa(req.StationNo)+" processCode="+itoa(req.ProcessCode))
if _, _, err := ctx.Mes.Post("/api/internal/station/done", json.RawMessage(payloadBytes)); err != nil {
if _, _, err := ctx.Mes.Post("/api/internal/station/report", json.RawMessage(payloadBytes)); err != nil {
if qerr := ctx.Store.InsertQueueItem("process_done", string(payloadBytes)); qerr != nil {
fail(w, http.StatusInternalServerError, "上报失败且离线队列写入失败:"+qerr.Error())
return
@@ -61,22 +68,23 @@ func processDoneHandler(ctx *svc.ServiceContext) http.HandlerFunc {
}
// tempStoreHandler POST /api/report/temp-store
// {dockCode, orderNo, sn, operator} → 调 MES /api/internal/station/checkin
// body 附带 type=temp_store不可达时以同样 payload 写入 report_queue(kind=temp_store)。
// {sn, orderNo, stationNo, processCode, operator} → 调 MES /api/internal/station/checkin(半成品入库)。
// 不可达时以同样 payload 写入 report_queue(kind=temp_store)。
func tempStoreHandler(ctx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
DockCode string `json:"dockCode"`
OrderNo string `json:"orderNo"`
Sn string `json:"sn"`
Operator string `json:"operator"`
Sn string `json:"sn"`
OrderNo string `json:"orderNo"`
StationNo int `json:"stationNo"`
ProcessCode int `json:"processCode"`
Operator string `json:"operator"`
}
if err := parseJSON(r, &req); err != nil {
fail(w, http.StatusBadRequest, "参数错误")
return
}
if req.DockCode == "" {
fail(w, http.StatusBadRequest, "缺少 dockCode")
if req.Sn == "" {
fail(w, http.StatusBadRequest, "缺少 sn")
return
}
@@ -84,16 +92,16 @@ func tempStoreHandler(ctx *svc.ServiceContext) http.HandlerFunc {
ctx.Syncer.RunOnce(r.Context())
body := map[string]any{
"type": "temp_store", // checkin 类型标识:暂存退库
"dockCode": req.DockCode,
"orderNo": req.OrderNo,
"sn": req.Sn,
"operator": req.Operator,
"sn": req.Sn,
"orderNo": req.OrderNo,
"stationNo": req.StationNo,
"processCode": req.ProcessCode,
"operator": req.Operator,
}
payloadBytes, _ := json.Marshal(body)
ctx.Store.AddEventLog(req.OrderNo, req.Operator, "temp_store",
"暂存/退回库房 dockCode="+req.DockCode+" sn="+req.Sn)
"暂存/退回库房 sn="+req.Sn+" stationNo="+itoa(req.StationNo))
if _, _, err := ctx.Mes.Post("/api/internal/station/checkin", json.RawMessage(payloadBytes)); err != nil {
if qerr := ctx.Store.InsertQueueItem("temp_store", string(payloadBytes)); qerr != nil {
@@ -107,4 +115,4 @@ func tempStoreHandler(ctx *svc.ServiceContext) http.HandlerFunc {
ok(w, map[string]any{"queued": false})
}
}
}
@@ -29,6 +29,7 @@ func RegisterHandlers(server *rest.Server, ctx *svc.ServiceContext) {
server.AddRoutes(
[]rest.Route{
{Method: http.MethodGet, Path: "/api/task/current", Handler: taskCurrentHandler(ctx)},
{Method: http.MethodGet, Path: "/api/workload", Handler: workloadHandler(ctx)},
{Method: http.MethodGet, Path: "/api/pdf", Handler: pdfQueryHandler(ctx)},
{Method: http.MethodGet, Path: "/pdfopen/:name", Handler: pdfOpenHandler(ctx)},
{Method: http.MethodGet, Path: "/api/tightening/list", Handler: tighteningListHandler(ctx)},
@@ -96,7 +96,7 @@ func (s *Syncer) flushTorque(ctx context.Context) int {
}
// flushQueue 上报未同步的离线缓存队列,payload 原样透传:
// - kind=process_done → POST /api/internal/station/done
// - kind=process_done → POST /api/internal/station/report
// - kind=temp_store → POST /api/internal/station/checkin
//
// 成功标记 synced=1;失败 synced 保持 0 并累计 retry_count。
@@ -118,7 +118,7 @@ func (s *Syncer) flushQueue(ctx context.Context) int {
var path string
switch it.Kind {
case "process_done":
path = "/api/internal/station/done"
path = "/api/internal/station/report"
case "temp_store":
path = "/api/internal/station/checkin"
default:
@@ -87,7 +87,19 @@ func (m *MockSource) produceOne() {
// fetchRecentTask 从 MES 拉取当前工位最近任务,宽松解析其中的 sn/orderNo 字段,
// 任一环节失败均静默返回空串(保证离线时继续生成 MOCK 数据)。
func (m *MockSource) fetchRecentTask() (sn string, orderNo string) {
body, _, err := m.mes.Get("/api/internal/station/task", url.Values{"dockCode": {m.dockCode}})
stationNo := 0
for _, ch := range m.dockCode {
if ch < '0' || ch > '9' {
stationNo = 0
break
}
stationNo = stationNo*10 + int(ch-'0')
}
query := url.Values{}
if stationNo > 0 {
query.Set("stationNo", fmt.Sprintf("%d", stationNo))
}
body, _, err := m.mes.Get("/api/internal/station/task", query)
if err != nil {
return "", ""
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>北京动力 · 工位终端</title>
<script type="module" crossorigin src="/assets/index-Bjt8aeCw.js"></script>
<script type="module" crossorigin src="/assets/index-CpYENLUl.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Dz4RGjfJ.css">
</head>
<body>