feat: 新增IM即时通讯(浮窗)、工作流、打印模块及工作台增强
- IM: 新增浮窗聊天(ImFloatWindow)、管理页(monitor/config/service/message)、SSE推送 - 工作流: 新增待办/我的流程页面及后端服务 - 打印: 新增打印模板、出库单打印(PrintPage)、模板种子脚本 - 工作台: 增强快捷入口与工作台数据 - 修复: TagsView页签关闭、CrudPage通用表格增强 - 移除导航菜单中的即时通讯入口,改为右下角浮窗
This commit is contained in:
+14
-7
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user