feat: 五项目业务实现并对接完成

- MES(B): 工单/BOM/备料/工序字典/PLC下发/拧紧/扫码报工/半成品/AGV/追溯逻辑,WMS与海康RCS客户端,看板Redis缓存API(概览/设备/进度/报警/趋势)+SSE
- WMS(C): JWT滑动续签、Excel导入、盘点、内部API、种子数据、独立Postgres配置
- WMS客户端(E): Go网关8891反向代理+内嵌Vue3十页
- 工位终端(D): SQLite本地缓存+模拟拧紧源+内嵌Vue3页面
- Dashboard(A): 看板数据改接MES内部缓存API,SSE实时刷新+vite代理
- 清理各项目球形磨遗留代码,新增部署手册.md
This commit is contained in:
SunYF
2026-08-27 19:50:57 +08:00
parent c85c3bd6f1
commit a2afd2f6dc
335 changed files with 87495 additions and 7357 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>北京动力 · 工位终端</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
@@ -0,0 +1,20 @@
{
"name": "bj_power_workstation_frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"axios": "^1.7.9",
"element-plus": "^2.9.3",
"vue": "^3.5.13",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
# pnpm 11 构建脚本白名单(仅允许 esbuild 运行 install/postinstall 脚本)
allowBuilds:
esbuild: true
@@ -0,0 +1,9 @@
<template>
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<script setup>
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>
@@ -0,0 +1,26 @@
import request from '../utils/request'
// 登录
export const login = (data) => request.post('/api/auth/login', data)
// 当前工单任务(代理 MES
export const getTaskCurrent = (dockCode) =>
request.get('/api/task/current', { params: { dockCode } })
// 拧紧结果列表(本地 SQLite)
export const getTighteningList = (params) => request.get('/api/tightening/list', { params })
// 工序完成上报
export const reportProcessDone = (data) => request.post('/api/report/process-done', data)
// 暂存退库上报
export const reportTempStore = (data) => request.post('/api/report/temp-store', data)
// 手动触发一轮同步
export const syncFlush = () => request.post('/api/sync/flush')
// 待同步统计
export const getSyncStats = () => request.get('/api/sync/stats')
// 工艺 PDF 免鉴权直链(iframe / window.open 用)
export const pdfOpenUrl = (name) => '/pdfopen/' + encodeURIComponent(name)
@@ -0,0 +1,7 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
createApp(App).use(router).use(ElementPlus).mount('#app')
@@ -0,0 +1,31 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { getToken } from '../utils/request'
// hash 路由:静态托管下刷新/深链稳定,不需要服务端兜底配置
const routes = [
{ path: '/', redirect: '/main' },
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue')
},
{
path: '/main',
name: 'Main',
component: () => import('../views/Main.vue')
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
router.beforeEach((to) => {
const hasToken = !!getToken()
if (!hasToken && to.path !== '/login') return '/login'
if (hasToken && to.path === '/login') return '/main'
return true
})
export default router
@@ -0,0 +1,92 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
/**
* 统一请求封装(风格同 bj_power_wms_client 前端 E 工程):
* - 请求头自动携带 Bearer token
* - 响应头 X-Renewed-Token 自动保存(滑动续签)
* - 401 清除登录态并跳转登录页
* - 业务 code!==0 弹错并 rejectdata 缺省时返回原响应体
*/
const TOKEN_KEY = 'ws_token'
const USER_KEY = 'ws_user'
export function getToken() {
return localStorage.getItem(TOKEN_KEY) || ''
}
export function setToken(token) {
localStorage.setItem(TOKEN_KEY, token)
}
export function getUser() {
try {
return JSON.parse(localStorage.getItem(USER_KEY)) || {}
} catch {
return {}
}
}
export function setAuth(token, user) {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(USER_KEY, JSON.stringify(user || {}))
}
export function clearAuth() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
}
const service = axios.create({
baseURL: '',
timeout: 30000
})
service.interceptors.request.use((config) => {
const token = getToken()
if (token) config.headers.Authorization = 'Bearer ' + token
return config
})
service.interceptors.response.use(
(response) => {
// 滑动续签:后端剩余有效期不足一半时下发新 token
const renewed = response.headers?.['x-renewed-token']
if (renewed) setToken(renewed)
const d = response.data
if (d && typeof d === 'object' && 'code' in d) {
if (d.code !== 0) {
const err = new Error(d.message || '请求失败')
showErrThrottled(err.message)
return Promise.reject(err)
}
return d.data !== undefined ? d.data : d
}
return d
},
(error) => {
if (error.response && error.response.status === 401) {
clearAuth()
window.location.hash = '#/login'
error.message = '登录已失效,请重新登录'
} else {
error.message =
error.response?.data?.message ||
(error.code === 'ECONNABORTED' ? '请求超时' : '网络异常:' + (error.message || '未知错误'))
}
showErrThrottled(error.message)
return Promise.reject(error)
}
)
let lastErrAt = 0
function showErrThrottled(msg) {
// 简单节流,避免轮询接口失败刷屏(3 秒内只提示一次)
const now = Date.now()
if (now - lastErrAt < 3000) return
lastErrAt = now
ElMessage.error(msg)
}
export default service
@@ -0,0 +1,134 @@
<template>
<div class="login-page">
<div class="login-card">
<h1 class="title">北京动力 · 工位终端</h1>
<p class="subtitle">产线触屏终端D 端口 8892</p>
<div class="field">
<span class="lab">账号</span>
<el-input
v-model.trim="username"
size="large"
placeholder="请输入账号"
clearable
@keyup.enter="submit"
/>
</div>
<div class="field">
<span class="lab">密码</span>
<el-input
v-model="password"
size="large"
type="password"
show-password
placeholder="请输入密码"
@keyup.enter="submit"
/>
</div>
<el-button class="btn-login" type="primary" size="large" :loading="loading" @click="submit">
</el-button>
<p class="hint">内置账号admin / admin123 &nbsp;&nbsp;operator1 / 123456</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { login } from '../api'
import { setAuth } from '../utils/request'
const router = useRouter()
const username = ref('')
const password = ref('')
const loading = ref(false)
async function submit() {
if (!username.value || !password.value) {
ElMessage.warning('请输入账号和密码')
return
}
loading.value = true
try {
const data = await login({ username: username.value, password: password.value })
setAuth(data.token, data.user)
ElMessage.success('登录成功')
router.push('/main')
} catch (e) {
// 错误提示已由 request 封装统一弹出
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f2b4c 0%, #14385f 60%, #1a4a7a 100%);
}
.login-card {
width: 480px;
padding: 48px 56px;
background: #fff;
border-radius: 16px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.35);
}
.title {
margin: 0 0 8px;
font-size: 30px;
text-align: center;
color: #16324f;
}
.subtitle {
margin: 0 0 32px;
text-align: center;
font-size: 16px;
color: #7a8aa0;
}
.field {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 22px;
}
.lab {
width: 64px;
font-size: 20px;
font-weight: bold;
color: #16324f;
text-align: right;
}
.field :deep(.el-input__inner) {
height: 52px;
font-size: 20px;
}
.btn-login {
width: calc(100% - 80px);
margin-left: 80px;
height: 54px;
font-size: 24px;
letter-spacing: 12px;
}
.hint {
margin-top: 26px;
text-align: center;
font-size: 14px;
color: #9aa7b8;
}
</style>
@@ -0,0 +1,698 @@
<template>
<div class="page" tabindex="-1">
<!-- 顶部标题 / 工位选择 / 登录人 / 同步状态角标 -->
<header class="top-bar">
<div class="brand">北京动力 · 工位终端</div>
<div class="dock-select">
<span class="dock-lab">工位</span>
<el-select v-model="dock" size="large" class="dock-el">
<el-option v-for="d in docks" :key="d" :label="d" :value="d" />
</el-select>
</div>
<div class="spacer"></div>
<div class="sync-badge" @click="manualFlush">
<el-tag v-if="pendingTotal > 0" type="warning" size="large" effect="dark" round>
待同步 {{ pendingTotal }}
</el-tag>
<el-tag v-else type="success" size="large" effect="dark" round>同步正常</el-tag>
</div>
<div class="user-box">
<span class="user-name">{{ user.realName || user.username || '-' }}</span>
<el-button size="large" text type="primary" @click="logout">退出</el-button>
</div>
</header>
<!-- NG 告警横幅 -->
<transition name="fade">
<div v-if="hasNg" class="alert-banner"> 存在NG请复检</div>
</transition>
<!-- 扫码区常驻聚焦 -->
<section class="scan-bar">
<span class="sn-label">当前工件 SN</span>
<span class="sn-value">{{ currentSn || '——' }}</span>
<el-input
ref="scanRef"
v-model.trim="scanInput"
class="scan-input"
size="large"
placeholder="请扫描工件SN条码"
clearable
@keyup.enter="onScan"
/>
<el-button size="large" @click="focusScan">聚焦扫码</el-button>
</section>
<!-- 主内容任务区 + 工艺PDF + 拧紧结果 -->
<main class="content">
<!-- 任务区 -->
<aside class="card task-card">
<h3 class="card-title">当前工单</h3>
<template v-if="task">
<div class="order-no">{{ task.orderNo || '-' }}</div>
<div class="kv"><span>产品型号</span><b>{{ task.productModel || '-' }}</b></div>
<div class="kv"><span>进度</span><b>{{ progressDone }} / {{ progressTotal }} </b></div>
<ul class="steps">
<li
v-for="(s, i) in taskSteps"
:key="i"
:class="{ done: isStepDone(s) }"
>
<span class="step-flag">{{ isStepDone(s) ? '✔' : i + 1 }}</span>
<span class="step-name">{{ s.name || s.processCode || ('工序' + (i + 1)) }}</span>
</li>
</ul>
</template>
<el-empty v-else description="当前工位暂无任务" :image-size="90" />
</aside>
<section class="right-col">
<!-- 工艺 PDF -->
<div v-if="pdfFile" class="card pdf-card">
<div class="pdf-head">
<h3 class="card-title">工艺图纸 · {{ pdfFile }}</h3>
<el-button type="primary" size="large" @click="openPdf">放大查看</el-button>
</div>
<iframe v-if="pdfUrl" :src="pdfUrl" class="pdf-frame" title="工艺图纸"></iframe>
</div>
<!-- 拧紧结果区 -->
<div class="card table-card" :class="{ 'table-card-fill': !pdfFile }">
<h3 class="card-title">拧紧结果实时</h3>
<el-table
:data="tightRows"
height="100%"
size="large"
:row-class-name="rowClass"
empty-text="暂无拧紧数据"
>
<el-table-column label="时间" width="200">
<template #default="{ row }">{{ fmtTs(row.createdAt) }}</template>
</el-table-column>
<el-table-column prop="screwNo" label="螺丝" width="120" align="center" />
<el-table-column label="扭矩 (Nm)" width="160" align="center">
<template #default="{ row }">{{ row.torque.toFixed(1) }}</template>
</el-table-column>
<el-table-column label="角度 (°)" width="140" align="center">
<template #default="{ row }">{{ Math.round(row.angle) }}</template>
</el-table-column>
<el-table-column label="结果" align="center">
<template #default="{ row }">
<span :class="row.result === 'OK' ? 'res-ok' : 'res-ng'">{{ row.result }}</span>
</template>
</el-table-column>
</el-table>
</div>
</section>
</main>
<!-- 底部三大按钮 -->
<footer class="actions">
<el-button class="big-btn primary" size="large" @click="onProcessDone">工序完成上报</el-button>
<el-button class="big-btn warning" size="large" @click="onTempStore">暂存退库</el-button>
<el-button class="big-btn success" size="large" @click="manualFlush">重试同步</el-button>
</footer>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getSyncStats,
getTaskCurrent,
getTighteningList,
pdfOpenUrl,
reportProcessDone,
reportTempStore,
syncFlush
} from '../api'
import { clearAuth, getUser } from '../utils/request'
const DOCK_KEY = 'ws_dock'
const router = useRouter()
const user = getUser()
// ---------- 工位 ----------
const dockCount = 21 // DOCK01 ~ DOCK21
const docks = Array.from({ length: dockCount }, (_, i) => 'DOCK' + String(i + 1).padStart(2, '0'))
const dock = ref(localStorage.getItem(DOCK_KEY) || 'DOCK01')
watch(dock, (v) => {
localStorage.setItem(DOCK_KEY, v)
refreshAll()
})
// ---------- 同步状态角标 ----------
const stats = ref({ pendingTorque: 0, pendingQueue: 0 })
const pendingTotal = computed(() => stats.value.pendingTorque + stats.value.pendingQueue)
async function loadStats() {
try {
stats.value = await getSyncStats()
} catch {}
}
// ---------- 当前任务 ----------
const task = ref(null)
const taskSteps = computed(() => {
const t = task.value
if (!t) return []
return Array.isArray(t.steps) ? t.steps : Array.isArray(t.stepList) ? t.stepList : []
})
const progressDone = computed(() => {
const p = task.value?.progress
if (p && typeof p === 'object') return Number(p.done ?? 0)
return Number(task.value?.doneCount ?? 0)
})
const progressTotal = computed(() => {
const p = task.value?.progress
if (p && typeof p === 'object') return Number(p.total ?? 0)
return Number(task.value?.totalCount ?? 0)
})
function isStepDone(s) {
return s && (s.done === true || s.status === 'done' || s.finished === true)
}
function firstPendingStepNo() {
for (let i = 0; i < taskSteps.value.length; i++) {
if (!isStepDone(taskSteps.value[i])) {
const s = taskSteps.value[i]
return String(s.no ?? s.code ?? i + 1)
}
}
return ''
}
async function loadTask() {
try {
task.value = await getTaskCurrent(dock.value)
} catch {
task.value = null
}
}
// ---------- 工艺 PDF ----------
const pdfFile = computed(() => {
const f = task.value?.processFile || task.value?.processPdf
return typeof f === 'string' && f.trim() !== '' ? f.trim() : ''
})
const pdfUrl = computed(() => (pdfFile.value ? pdfOpenUrl(pdfFile.value) : ''))
function openPdf() {
if (pdfUrl.value) window.open(pdfUrl.value, '_blank')
}
// ---------- 扫码 ----------
const scanRef = ref()
const scanInput = ref('')
const currentSn = ref('')
function focusScan() {
scanRef.value?.focus?.()
}
function onScan() {
const sn = scanInput.value.trim()
if (!sn) return
currentSn.value = sn
scanInput.value = ''
loadTight()
}
watch(currentSn, () => loadTight())
// ---------- 拧紧结果 ----------
const tightRows = ref([])
const hasNg = computed(() => tightRows.value.some((r) => r.result === 'NG'))
async function loadTight() {
try {
const data = await getTighteningList({ sn: currentSn.value, limit: 50 })
tightRows.value = Array.isArray(data?.list) ? data.list : []
} catch {
/* 轮询失败静默,避免刷屏 */
}
}
function rowClass({ row }) {
return row.result === 'NG' ? 'ng-row' : ''
}
function fmtTs(ts) {
if (!ts) return '-'
const d = new Date(Number(ts))
const p = (n) => String(n).padStart(2, '0')
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
// ---------- 底部按钮 ----------
async function onProcessDone() {
focusScan()
try {
let value
try {
const res = await ElMessageBox.prompt(
`确认对工位 ${dock.value} 执行「工序完成上报」?请输入当前工序号`,
'工序完成上报',
{
inputValue: firstPendingStepNo(),
inputPlaceholder: '如:OP10',
confirmButtonText: '确 认',
cancelButtonText: '取 消',
inputValidator: (v) => (v && String(v).trim() !== '' ? true : '请输入工序号'),
distinguishCancelAndClose: true
}
)
value = String(res.value || '').trim()
} catch (e) {
return // 用户取消/关闭
}
const data = await reportProcessDone({
dockCode: dock.value,
orderNo: task.value?.orderNo || '',
sn: currentSn.value,
processCode: value,
operator: user.realName || user.username || ''
})
if (data?.queued) {
ElMessage.warning('MES 暂不可达,已离线缓存,稍后自动重传')
} else {
ElMessage.success('工序完成上报成功')
}
loadTask()
loadStats()
} catch (e) {
/* 统一错误提示已在 request 中弹出 */
}
}
async function onTempStore() {
focusScan()
try {
await ElMessageBox.confirm(
`确认将当前工件「${currentSn.value || '未扫码'}」暂存退库?`,
'暂存退库',
{ confirmButtonText: '确认暂存', cancelButtonText: '取消', type: 'warning' }
)
} catch {
return
}
try {
const data = await reportTempStore({
dockCode: dock.value,
orderNo: task.value?.orderNo || '',
sn: currentSn.value,
operator: user.realName || user.username || ''
})
if (data?.queued) {
ElMessage.warning('MES 暂不可达,已离线缓存,稍后自动重传')
} else {
ElMessage.success('暂存退库已上报')
}
loadTask()
loadStats()
} catch (e) {
/* 统一错误提示 */
}
}
async function manualFlush() {
try {
const data = await syncFlush()
ElMessage.success(`本轮同步成功 ${data?.count ?? 0}`)
loadStats()
} catch (e) {
/* 统一错误提示 */
}
}
// ---------- 轮询与生命周期 ----------
let timerTask = null
let timerTight = null
let timerStats = null
function refreshAll() {
loadTask()
loadTight()
loadStats()
}
function logout() {
clearAuth()
router.push('/login')
}
onMounted(() => {
refreshAll()
focusScan()
window.addEventListener('focus', focusScan)
timerTask = setInterval(loadTask, 5000) // 任务 5 秒
timerTight = setInterval(loadTight, 2000) // 拧紧结果 2 秒
timerStats = setInterval(loadStats, 10000) // 同步统计 10 秒
})
onBeforeUnmount(() => {
clearInterval(timerTask)
clearInterval(timerTight)
clearInterval(timerStats)
window.removeEventListener('focus', focusScan)
})
</script>
<style scoped>
/* 触屏终端:大字号、高对比、无滚动闪烁 */
.page {
height: 100vh;
display: flex;
flex-direction: column;
background: #eef2f7;
overflow: hidden;
font-family: 'Microsoft YaHei', system-ui, sans-serif;
}
/* ---- 顶部 ---- */
.top-bar {
height: 76px;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 24px;
padding: 0 28px;
background: #16324f;
color: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
}
.brand {
font-size: 30px;
font-weight: bold;
letter-spacing: 2px;
white-space: nowrap;
}
.dock-select {
display: flex;
align-items: center;
gap: 12px;
}
.dock-lab {
font-size: 22px;
font-weight: bold;
}
.dock-el {
width: 200px;
}
.dock-el :deep(.el-input__inner) {
font-size: 22px;
font-weight: bold;
}
.spacer {
flex: 1;
}
.sync-badge {
cursor: pointer;
}
.sync-badge :deep(.el-tag) {
font-size: 18px;
height: 40px;
padding: 0 20px;
}
.user-box {
display: flex;
align-items: center;
gap: 8px;
}
.user-name {
font-size: 22px;
font-weight: bold;
}
.user-box :deep(.el-button) {
font-size: 18px;
color: #9fd0ff;
}
/* ---- NG 告警横幅 ---- */
.alert-banner {
flex-shrink: 0;
background: #d80f16;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: bold;
letter-spacing: 8px;
padding: 14px 0;
animation: blink 1.2s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
/* ---- 扫码区 ---- */
.scan-bar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 18px;
padding: 12px 28px;
background: #fff;
border-bottom: 1px solid #dde4ee;
}
.sn-label {
font-size: 22px;
font-weight: bold;
color: #16324f;
white-space: nowrap;
}
.sn-value {
min-width: 220px;
font-size: 26px;
font-weight: bold;
color: #d80f16;
letter-spacing: 1px;
}
.scan-input {
flex: 1;
max-width: 640px;
}
.scan-bar :deep(.el-input__inner) {
height: 52px;
font-size: 24px;
}
/* ---- 主内容 ---- */
.content {
flex: 1;
min-height: 0;
display: flex;
gap: 20px;
padding: 20px 28px;
}
.card {
background: #fff;
border-radius: 14px;
box-shadow: 0 2px 10px rgba(22, 50, 79, 0.08);
padding: 20px 24px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.card-title {
margin: 0 0 16px;
font-size: 24px;
font-weight: bold;
color: #16324f;
}
.task-card {
width: 360px;
flex-shrink: 0;
}
.order-no {
font-size: 32px;
font-weight: bold;
color: #16324f;
word-break: break-all;
margin-bottom: 16px;
}
.kv {
display: flex;
justify-content: space-between;
font-size: 22px;
margin-bottom: 10px;
color: #5a6a80;
}
.kv b {
color: #16324f;
}
.steps {
list-style: none;
margin: 14px 0 0;
padding: 0;
overflow-y: auto;
flex: 1;
}
.steps li {
display: flex;
align-items: center;
gap: 12px;
font-size: 20px;
padding: 9px 4px;
border-bottom: 1px dashed #e6ecf4;
color: #16324f;
}
.steps li.done {
color: #2aa861;
}
.steps li.done .step-name {
text-decoration: line-through;
}
.step-flag {
width: 36px;
height: 36px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: #eaf1f8;
font-weight: bold;
flex-shrink: 0;
}
.steps li.done .step-flag {
background: #2aa861;
color: #fff;
}
.right-col {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 20px;
}
.pdf-card {
height: 38%;
min-height: 240px;
}
.pdf-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.pdf-head .card-title {
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.pdf-frame {
flex: 1;
width: 100%;
border: 1px solid #dde4ee;
border-radius: 8px;
background: #fafbfd;
}
.table-card {
flex: 1;
min-height: 220px;
}
.table-card :deep(.el-table) {
font-size: 20px;
}
.table-card :deep(.el-table th.el-table__cell) {
font-size: 20px;
color: #16324f;
}
.res-ok {
color: #2aa861;
font-size: 26px;
font-weight: bold;
}
.res-ng {
color: #d80f16;
font-size: 26px;
font-weight: bold;
}
:deep(.ng-row) {
--el-table-tr-bg-color: #ffecec;
background: #ffecec !important;
}
/* ---- 底部三大按钮 ---- */
.actions {
flex-shrink: 0;
display: flex;
justify-content: center;
gap: 48px;
padding: 14px 0 18px;
background: #fff;
box-shadow: 0 -2px 10px rgba(22, 50, 79, 0.06);
}
.big-btn {
width: 320px;
height: 72px;
font-size: 27px;
font-weight: bold;
letter-spacing: 6px;
border-radius: 12px;
color: #fff;
border: none;
}
.big-btn.primary { background: #1668dc; }
.big-btn.primary:hover { background: #0e57c3; }
.big-btn.warning { background: #e6870f; }
.big-btn.warning:hover { background: #cf7508; }
.big-btn.success { background: #2aa861; }
.big-btn.success:hover { background: #239154; }
</style>
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// 构建输出到 Go 服务内嵌目录 ../web/staticemptyOutDir 清空旧产物)
export default defineConfig({
plugins: [vue()],
build: {
outDir: '../web/static',
emptyOutDir: true
},
server: {
port: 8893,
proxy: {
'/api': 'http://127.0.0.1:8892',
'/pdfopen': 'http://127.0.0.1:8892'
}
}
})