feat: 新增IM即时通讯(浮窗)、工作流、打印模块及工作台增强

- IM: 新增浮窗聊天(ImFloatWindow)、管理页(monitor/config/service/message)、SSE推送
- 工作流: 新增待办/我的流程页面及后端服务
- 打印: 新增打印模板、出库单打印(PrintPage)、模板种子脚本
- 工作台: 增强快捷入口与工作台数据
- 修复: TagsView页签关闭、CrudPage通用表格增强
- 移除导航菜单中的即时通讯入口,改为右下角浮窗
This commit is contained in:
2026-08-16 00:19:24 +08:00
parent 7235265749
commit 1bec470647
49 changed files with 5791 additions and 51 deletions
+14 -7
View File
@@ -6,24 +6,31 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import PrintPage from './print/PrintPage.vue'
import { getBackendConfig } from './config/backend'
import { createHttpRepository } from './repository/http-repo'
import { createDataSourceHttp } from './repository/http-datasource'
import { useDesignerStore } from './design/stores/designer'
import { useDataSourceStore } from './design/stores/dataSource'
const app = createApp(App)
// 外部打印模式:MES 等宿主应用以 ?print=1&template=&table=&row=&token= 打开,仅挂载打印页
const isPrintMode = new URLSearchParams(window.location.search).get('print') === '1'
const app = createApp(isPrintMode ? PrintPage : App)
const pinia = createPinia()
app.use(pinia)
// 后端对接:仅当配置了 VITE_OPENPRINT_API_BASE 才切云端仓库;
// 未配置时 designer 用 localStorage、dataSource 用内置 Mock(主任铁律:无后端全链路可用)。
const backend = getBackendConfig()
if (backend) {
const designerStore = useDesignerStore(pinia)
const dataSourceStore = useDataSourceStore(pinia)
designerStore.setRepository(createHttpRepository(backend.options), 'cloud')
dataSourceStore.setRepository(createDataSourceHttp(backend.options))
// 打印模式由 PrintPage 自行按 URL 参数构建云端仓库,此处跳过。
if (!isPrintMode) {
const backend = getBackendConfig()
if (backend) {
const designerStore = useDesignerStore(pinia)
const dataSourceStore = useDataSourceStore(pinia)
designerStore.setRepository(createHttpRepository(backend.options), 'cloud')
dataSourceStore.setRepository(createDataSourceHttp(backend.options))
}
}
app.mount('#app')
+212
View File
@@ -0,0 +1,212 @@
<template>
<div class="print-page">
<div class="card">
<div class="card-title">打印任务</div>
<div class="status" :class="phase">
<span v-if="phase === 'loading' || phase === 'printing'" class="spin"></span>
<span class="status-text">{{ statusText }}</span>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-if="meta" class="meta">{{ meta }}</div>
<div v-if="phase === 'done' || phase === 'error'" class="actions">
<button class="primary" @click="run">重新打印</button>
<button @click="close">关闭</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { createHttpRepository } from '@/repository/http-repo'
import { createHeadless } from '@/core/headless/createHeadless'
type Phase = 'loading' | 'printing' | 'done' | 'error'
const phase = ref<Phase>('loading')
const error = ref('')
const meta = ref('')
const statusText = computed(() => {
switch (phase.value) {
case 'loading':
return '正在加载模板与数据…'
case 'printing':
return '正在调用系统打印…'
case 'done':
return '打印任务已发出'
case 'error':
return '打印失败'
default:
return ''
}
})
/** URL-safe Base64 解码(UTF-8 */
function decodeBase64Url(s: string): string {
const b64 = s.replace(/-/g, '+').replace(/_/g, '/')
const pad = b64.length % 4 === 0 ? '' : '='.repeat(4 - (b64.length % 4))
const bin = atob(b64 + pad)
const bytes = Uint8Array.from(bin, (c) => c.charCodeAt(0))
return new TextDecoder().decode(bytes)
}
/** 为行对象补充 PascalCase 别名,兼容模板 Path 的大小写(如 RawMaterial_OutStock.BillNo 与 billNo */
function withPascalAliases(row: Record<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = { ...row }
for (const [k, v] of Object.entries(row)) {
const pascal = k.charAt(0).toUpperCase() + k.slice(1)
if (!(pascal in out)) out[pascal] = v
}
return out
}
async function run() {
phase.value = 'loading'
error.value = ''
meta.value = ''
try {
const q = new URLSearchParams(location.search)
const templateId = q.get('template') || q.get('tpl') || ''
const table = q.get('table') || ''
const rowId = q.get('row') || q.get('id') || ''
const token = q.get('token') || ''
const apiBase = (q.get('api') || import.meta.env.VITE_OPENPRINT_API_BASE || '').replace(/\/+$/, '')
if (!templateId) throw new Error('缺少模板参数 template')
if (!apiBase) throw new Error('未配置后端地址 api(或环境变量 VITE_OPENPRINT_API_BASE')
let data: Record<string, unknown>
const dataParam = q.get('data')
if (dataParam) {
data = JSON.parse(decodeBase64Url(dataParam)) as Record<string, unknown>
meta.value = '数据:URL 直传'
} else {
if (!table || !rowId) throw new Error('缺少数据参数 table/row')
const res = await fetch(`${apiBase}/api/print/data/${encodeURIComponent(table)}/${encodeURIComponent(rowId)}`, {
headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
})
if (!res.ok) throw new Error(`数据请求失败(HTTP ${res.status}`)
data = (await res.json()) as Record<string, unknown>
meta.value = `数据:${table}#${rowId}`
}
// 数据键归一化:顶层 { 表名: 行 },行内 camelCase 与 PascalCase 并存
const normalized: Record<string, unknown> = {}
for (const [key, val] of Object.entries(data)) {
normalized[key] =
val && typeof val === 'object' && !Array.isArray(val)
? withPascalAliases(val as Record<string, unknown>)
: val
}
const repository = createHttpRepository({ baseUrl: apiBase, token: token || undefined })
const headless = createHeadless({ repository })
try {
phase.value = 'printing'
const req = await headless.buildRequest(templateId, normalized)
await headless.print(req)
phase.value = 'done'
} finally {
headless.dispose()
}
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
phase.value = 'error'
}
}
function close() {
window.close()
}
onMounted(run)
</script>
<style scoped>
.print-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #f4f6f9;
font-family: system-ui, 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
.card {
width: 380px;
background: #fff;
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.08);
padding: 32px 28px;
text-align: center;
}
.card-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin-bottom: 20px;
}
.status {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 28px;
}
.spin {
width: 16px;
height: 16px;
border: 2px solid #d1d5db;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.status-text {
font-size: 14px;
color: #374151;
}
.error {
margin-top: 14px;
padding: 10px 12px;
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
border-radius: 8px;
font-size: 13px;
text-align: left;
word-break: break-all;
}
.meta {
margin-top: 10px;
font-size: 12px;
color: #9ca3af;
}
.actions {
margin-top: 22px;
display: flex;
gap: 12px;
justify-content: center;
}
.actions button {
padding: 8px 20px;
border-radius: 8px;
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
cursor: pointer;
font-size: 14px;
}
.actions button.primary {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
.actions button:hover {
opacity: 0.9;
}
</style>