feat(oa): add mobile approval PDF print for mine and cc
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
915253eefb
commit
cbef68199b
7 files changed
+889
-9
No files matched your search
@@ -786,3 +786,12 @@ EOF
|
||||
## 执行说明
|
||||
|
||||
实现时每完成一个 Task 再开下一个;打印(Task 12)可与角标并行,但不得阻塞 Task 5–10 合并。
|
||||
|
||||
---
|
||||
|
||||
## 打印验收笔记(S6 / Task 12)
|
||||
|
||||
- **实现**:`mobile-oa-h5` 用可打印 HTML + `window.print()`(无 jspdf/html2canvas 依赖);字段/流程组装对齐 Web `oa-approval-print.ts`(关联审批嵌套块简化为「已关联 N 条」)。
|
||||
- **入口**:我发起的 / 抄送列表「打印」→ 详情 `autoPrint=1`;详情底栏次要「打印」按钮(紧挨关闭)。
|
||||
- **微信小程序 web-view**:系统打印能力通常不可用;检测后 `showToast`:「微信内暂不支持系统打印,请用手机浏览器打开或到电脑端导出」。不阻塞其它功能。
|
||||
- **建议验收**:手机浏览器 / 电脑打开 H5 详情应能调起打印对话框;微信内应看到明确失败提示。
|
||||
@@ -238,7 +238,8 @@
|
||||
key: 'read',
|
||||
label: '已读',
|
||||
disabled: !!(row && row.isRead)
|
||||
}
|
||||
},
|
||||
{ key: 'print', label: '打印' }
|
||||
]
|
||||
},
|
||||
isSelected(id) {
|
||||
@@ -307,6 +308,7 @@
|
||||
if (!act || !row) return
|
||||
if (act.key === 'view') this.openView(row)
|
||||
else if (act.key === 'read') this.markOneRead(row)
|
||||
else if (act.key === 'print') this.openView(row, { autoPrint: true })
|
||||
},
|
||||
async markOneRead(row) {
|
||||
if (!row || row.isRead || this.markingId) return
|
||||
@@ -350,7 +352,7 @@
|
||||
this.batchMarking = false
|
||||
}
|
||||
},
|
||||
async openView(row) {
|
||||
async openView(row, opts) {
|
||||
if (!row || this.openingId) return
|
||||
const instanceId = String(row.instanceId || '').trim()
|
||||
const taskId = String(row.id || '').trim()
|
||||
@@ -358,6 +360,7 @@
|
||||
uni.showToast({ title: '缺少实例 ID', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const autoPrint = !!(opts && opts.autoPrint)
|
||||
this.openingId = taskId || instanceId
|
||||
try {
|
||||
if (taskId && !row.isRead) {
|
||||
@@ -376,7 +379,8 @@
|
||||
url:
|
||||
'/pages/oa/webview/index?instanceId=' +
|
||||
encodeURIComponent(instanceId) +
|
||||
'&mode=view'
|
||||
'&mode=view' +
|
||||
(autoPrint ? '&autoPrint=1' : '')
|
||||
})
|
||||
} finally {
|
||||
this.openingId = ''
|
||||
|
||||
@@ -143,7 +143,10 @@
|
||||
]
|
||||
},
|
||||
rowActions(row) {
|
||||
const actions = [{ key: 'view', label: '查看', primary: true }]
|
||||
const actions = [
|
||||
{ key: 'view', label: '查看', primary: true },
|
||||
{ key: 'print', label: '打印' }
|
||||
]
|
||||
const status = String((row && (row.instanceStatus || row.status)) || '')
|
||||
const appId = String((row && row.appId) || '').trim()
|
||||
if (COPY_RESTART_STATUSES.includes(status) && appId) {
|
||||
@@ -181,9 +184,13 @@
|
||||
this.openCopyRestart(row)
|
||||
return
|
||||
}
|
||||
if (act.key === 'print') {
|
||||
this.openView(row, { autoPrint: true })
|
||||
return
|
||||
}
|
||||
this.openView(row)
|
||||
},
|
||||
openView(row) {
|
||||
openView(row, opts) {
|
||||
if (!row) return
|
||||
// mine 行是实例:Web 用 row.id;兼容 instanceId
|
||||
const instanceId = String(row.instanceId || row.id || '').trim()
|
||||
@@ -191,12 +198,14 @@
|
||||
uni.showToast({ title: '缺少实例 ID', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const autoPrint = !!(opts && opts.autoPrint)
|
||||
stashOaDetailTitle(row.appName || row.title)
|
||||
uni.navigateTo({
|
||||
url:
|
||||
'/pages/oa/webview/index?instanceId=' +
|
||||
encodeURIComponent(instanceId) +
|
||||
'&mode=view'
|
||||
'&mode=view' +
|
||||
(autoPrint ? '&autoPrint=1' : '')
|
||||
})
|
||||
},
|
||||
openCopyRestart(row) {
|
||||
|
||||
@@ -37,6 +37,7 @@ export default {
|
||||
const instanceId = String((query && query.instanceId) || '')
|
||||
const taskId = String((query && query.taskId) || '')
|
||||
const mode = String((query && query.mode) || '') || 'view'
|
||||
const autoPrint = String((query && query.autoPrint) || '') === '1'
|
||||
const appId = String((query && query.appId) || '')
|
||||
const draftId = String((query && query.draftId) || '')
|
||||
const fromInstanceId = String((query && query.fromInstanceId) || '')
|
||||
@@ -48,7 +49,7 @@ export default {
|
||||
if (cachedName) {
|
||||
uni.setNavigationBarTitle({ title: cachedName })
|
||||
}
|
||||
src = buildOaDetailUrl({ instanceId, taskId, mode })
|
||||
src = buildOaDetailUrl({ instanceId, taskId, mode, autoPrint })
|
||||
} else if (draftId || appId || fromInstanceId) {
|
||||
const cachedName = takeOaStartTitle()
|
||||
if (cachedName) {
|
||||
|
||||
@@ -39,13 +39,14 @@ export function buildOaStartUrl({ appId, draftId, fromInstanceId } = {}) {
|
||||
return withQuery('/oa-start', params)
|
||||
}
|
||||
|
||||
/** 审批详情页 URL(须带 token;mode 为 view | approve) */
|
||||
export function buildOaDetailUrl({ instanceId, taskId, mode } = {}) {
|
||||
/** 审批详情页 URL(须带 token;mode 为 view | approve;autoPrint=1 加载后自动调打印) */
|
||||
export function buildOaDetailUrl({ instanceId, taskId, mode, autoPrint } = {}) {
|
||||
const token = getToken()
|
||||
const params = {}
|
||||
if (instanceId) params.instanceId = instanceId
|
||||
if (taskId) params.taskId = taskId
|
||||
if (mode) params.mode = mode // view | approve
|
||||
if (autoPrint) params.autoPrint = '1'
|
||||
if (token) params.token = token
|
||||
return withQuery('/oa-detail', params)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,801 @@
|
||||
/**
|
||||
* OA 审批单移动端打印(S6)
|
||||
* 对齐 web-admin `oa-approval-print.ts` 字段/流程结构;优先 window.print(),无 jspdf 依赖。
|
||||
* Spec: docs/superpowers/specs/2026-08-12-mobile-oa-task-centers-design.md §6.5
|
||||
*/
|
||||
import { formatDeptDisplay } from '../kit/oa-dept-value.js'
|
||||
import { formatPersonDisplay } from '../kit/oa-person-value.js'
|
||||
import { formatIdNameDisplay } from '../../utils/id-name-value.js'
|
||||
import {
|
||||
formatReceiveAccountLine,
|
||||
normalizeReceiveAccountModelValue
|
||||
} from '../../utils/receive-account-value.js'
|
||||
import { toRmbUpper } from '../../utils/rmb-upper.js'
|
||||
import { OA_STATUS_TEXT } from '../status-label.js'
|
||||
|
||||
export const OA_PRINT_COMPANY_NAME = '安徽中科兴皖医学研究有限公司'
|
||||
|
||||
const UPLOAD_TYPES = new Set([
|
||||
'upload',
|
||||
'fcUpload',
|
||||
'elUpload',
|
||||
'el-upload',
|
||||
'attachment',
|
||||
'file',
|
||||
'files'
|
||||
])
|
||||
|
||||
const SKIP_RULE_TYPES = new Set([
|
||||
'div',
|
||||
'fcRow',
|
||||
'fcCol',
|
||||
'fcTable',
|
||||
'fcTableGrid',
|
||||
'col',
|
||||
'row',
|
||||
'group',
|
||||
'tab',
|
||||
'tabs',
|
||||
'space',
|
||||
'divider',
|
||||
'html',
|
||||
'vanCellGroup',
|
||||
'leaveKit',
|
||||
'confirmationKit',
|
||||
'overtimeKit',
|
||||
'resignationKit',
|
||||
'tripKit'
|
||||
])
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function statusText(s) {
|
||||
return OA_STATUS_TEXT[s] || s || '-'
|
||||
}
|
||||
|
||||
function formatDateOnly(createTime) {
|
||||
const raw = String(createTime || '').trim()
|
||||
if (!raw) return '-'
|
||||
return raw.slice(0, 10)
|
||||
}
|
||||
|
||||
function formatNow() {
|
||||
const d = new Date()
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
export function resolveStamp(status) {
|
||||
const s = String(status || '').trim()
|
||||
if (s === 'completed' || s === 'approved' || s === 'done') {
|
||||
return { stampClass: 'ok', stampText: '已通过' }
|
||||
}
|
||||
if (s === 'rejected') return { stampClass: 'rej', stampText: '已拒绝' }
|
||||
if (s === 'returned') return { stampClass: 'ret', stampText: '已打回' }
|
||||
if (s === 'cancelled') return { stampClass: 'can', stampText: '已撤销' }
|
||||
if (s === 'running' || s === 'pending') {
|
||||
return { stampClass: 'run', stampText: '审批中' }
|
||||
}
|
||||
return { stampClass: 'run', stampText: '审批中' }
|
||||
}
|
||||
|
||||
function ensurePlainObject(raw) {
|
||||
if (raw == null) return {}
|
||||
if (typeof raw === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
if (typeof raw === 'object') return raw
|
||||
return {}
|
||||
}
|
||||
|
||||
function formatScalar(value, sep = '、') {
|
||||
if (value == null || value === '') return '-'
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((x) => formatScalar(x, sep))
|
||||
.filter((x) => x && x !== '-')
|
||||
.join(sep) || '-'
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
if (value.name != null) return String(value.name)
|
||||
if (value.label != null) return String(value.label)
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function formatRegionLikeDisplay(value) {
|
||||
if (value == null || value === '') return ''
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((x) => {
|
||||
if (x && typeof x === 'object') return String(x.name || x.label || x.text || '').trim()
|
||||
return String(x || '').trim()
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' / ')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return String(value.name || value.label || value.text || '').trim()
|
||||
}
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
function fileNameFromUrl(url) {
|
||||
const u = String(url || '').trim()
|
||||
if (!u) return '未命名文件'
|
||||
try {
|
||||
const path = u.split('?')[0] || u
|
||||
const last = path.split('/').pop() || ''
|
||||
return decodeURIComponent(last) || '未命名文件'
|
||||
} catch {
|
||||
return '未命名文件'
|
||||
}
|
||||
}
|
||||
|
||||
function formatUploadNames(value) {
|
||||
if (value == null || value === '') return []
|
||||
if (Array.isArray(value)) {
|
||||
const out = []
|
||||
for (const item of value) {
|
||||
if (typeof item === 'string') {
|
||||
item
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((p) => out.push(fileNameFromUrl(p)))
|
||||
} else if (item && typeof item === 'object') {
|
||||
const name = String(item.name || '').trim()
|
||||
out.push(name || fileNameFromUrl(item.url || item.fileUrl || item.path))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((p) => fileNameFromUrl(p))
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const name = String(value.name || '').trim()
|
||||
return [name || fileNameFromUrl(value.url || value.fileUrl || value.path)]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function formatMoney(value, showUpper) {
|
||||
const n =
|
||||
value == null || value === ''
|
||||
? null
|
||||
: typeof value === 'number'
|
||||
? value
|
||||
: Number(String(value).replace(/,/g, ''))
|
||||
if (n == null || !Number.isFinite(n)) return '-'
|
||||
const numText = n.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
if (showUpper === false) return numText
|
||||
const upper = toRmbUpper(n)
|
||||
return upper ? `${numText}(${upper})` : numText
|
||||
}
|
||||
|
||||
function formatReceiveAccounts(value) {
|
||||
const list = normalizeReceiveAccountModelValue(value)
|
||||
if (!list.length) return ''
|
||||
return list.map(formatReceiveAccountLine).join('\n')
|
||||
}
|
||||
|
||||
function formatTripLeg(leg, index) {
|
||||
const transport = String(leg.transport || '').trim() || '-'
|
||||
const tripType = String(leg.tripType || '').trim()
|
||||
const fromCity = String(leg.fromCity || '').trim() || '-'
|
||||
const toCity = String(leg.toCity || '').trim() || '-'
|
||||
const startDate = String(leg.startDate || '').trim()
|
||||
const startHalf = String(leg.startHalf || '').trim()
|
||||
const endDate = String(leg.endDate || '').trim()
|
||||
const endHalf = String(leg.endHalf || '').trim()
|
||||
const duration = leg.duration
|
||||
const durationText =
|
||||
duration == null || duration === '' ? '-' : `${Number(duration)}天`
|
||||
const route = `${fromCity} → ${toCity}`
|
||||
const period =
|
||||
startDate && endDate
|
||||
? `${startDate}${startHalf ? ` ${startHalf}` : ''} ~ ${endDate}${endHalf ? ` ${endHalf}` : ''}`
|
||||
: '-'
|
||||
const typePart = tripType ? ` · ${tripType}` : ''
|
||||
return `行程${index + 1}:${transport}${typePart} · ${route} · ${period} · ${durationText}`
|
||||
}
|
||||
|
||||
function formatTripItinerary(value) {
|
||||
if (!Array.isArray(value) || !value.length) return '-'
|
||||
const lines = value
|
||||
.filter((leg) => leg && typeof leg === 'object')
|
||||
.map((leg, idx) => formatTripLeg(leg, idx))
|
||||
return lines.length ? lines.join('\n') : '-'
|
||||
}
|
||||
|
||||
function isRangeFieldRule(rule) {
|
||||
const type = String(rule?.type || '').trim()
|
||||
const props = rule?.props && typeof rule.props === 'object' ? rule.props : {}
|
||||
if (type === 'datePicker' || type === 'date' || type === 'dateRange') {
|
||||
return /range$/i.test(String(props.type || type))
|
||||
}
|
||||
if (type === 'timePicker' || type === 'time' || type === 'timeRange') {
|
||||
return props.isRange === true || type === 'timeRange'
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function normalizeRelatedIds(value) {
|
||||
if (value == null || value === '') return []
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((x) => String(x || '').trim()).filter(Boolean)
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const t = value.trim()
|
||||
if (!t) return []
|
||||
try {
|
||||
const parsed = JSON.parse(t)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((x) => String(x || '').trim()).filter(Boolean)
|
||||
}
|
||||
} catch {
|
||||
/* comma list */
|
||||
}
|
||||
return t
|
||||
.split(',')
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function walkRules(rules, formData, out) {
|
||||
if (!Array.isArray(rules)) return
|
||||
for (const rule of rules) {
|
||||
if (!rule || typeof rule !== 'object') continue
|
||||
const type = String(rule.type || '').trim()
|
||||
const children = rule.children || rule.props?.rule
|
||||
if (Array.isArray(children) && children.length) {
|
||||
walkRules(children, formData, out)
|
||||
}
|
||||
if (SKIP_RULE_TYPES.has(type)) continue
|
||||
const field = String(rule.field || '').trim()
|
||||
if (!field) continue
|
||||
const label =
|
||||
String(rule.title || rule.label || rule.props?.label || field).trim() || field
|
||||
const value = formData?.[field]
|
||||
|
||||
if (type === 'relatedApproval') {
|
||||
const ids = normalizeRelatedIds(value)
|
||||
out.push({
|
||||
label,
|
||||
text: ids.length ? `已关联 ${ids.length} 条` : '-'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (type === 'dept' || type === 'ownerDept') {
|
||||
out.push({ label, text: formatDeptDisplay(value) || '-' })
|
||||
continue
|
||||
}
|
||||
if (type === 'person' || type === 'ownerPerson') {
|
||||
out.push({ label, text: formatPersonDisplay(value) || '-' })
|
||||
continue
|
||||
}
|
||||
if (type === 'post') {
|
||||
out.push({ label, text: formatIdNameDisplay(value) || '-' })
|
||||
continue
|
||||
}
|
||||
if (type === 'money') {
|
||||
const showUpper = rule.props?.showUpper !== false
|
||||
out.push({ label, text: formatMoney(value, showUpper) })
|
||||
continue
|
||||
}
|
||||
if (type === 'receiveAccount') {
|
||||
out.push({ label, text: formatReceiveAccounts(value) || '-' })
|
||||
continue
|
||||
}
|
||||
if (type === 'region' || type === 'cascader') {
|
||||
out.push({ label, text: formatRegionLikeDisplay(value) || '-' })
|
||||
continue
|
||||
}
|
||||
if (type === 'tripItinerary') {
|
||||
out.push({ label, text: formatTripItinerary(value) })
|
||||
continue
|
||||
}
|
||||
|
||||
const dragTag = String(rule._fc_drag_tag || '').trim()
|
||||
if (
|
||||
UPLOAD_TYPES.has(type) ||
|
||||
UPLOAD_TYPES.has(dragTag) ||
|
||||
String(rule.name || '')
|
||||
.toLowerCase()
|
||||
.includes('upload') ||
|
||||
dragTag.toLowerCase().includes('upload')
|
||||
) {
|
||||
const names = formatUploadNames(value)
|
||||
out.push({
|
||||
label,
|
||||
urls: names.length ? names : undefined,
|
||||
text: names.length ? undefined : '-'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && isRangeFieldRule(rule)) {
|
||||
out.push({ label, text: formatScalar(value, '~') })
|
||||
continue
|
||||
}
|
||||
out.push({ label, text: formatScalar(value) })
|
||||
}
|
||||
}
|
||||
|
||||
function buildFormRows(detail) {
|
||||
const schema = ensurePlainObject(detail?.formSchemaJson)
|
||||
const formData = ensurePlainObject(detail?.formDataJson || detail?.formData)
|
||||
const rule = Array.isArray(schema?.rule)
|
||||
? schema.rule
|
||||
: Array.isArray(detail?.formSchemaJson)
|
||||
? detail.formSchemaJson
|
||||
: []
|
||||
const out = []
|
||||
walkRules(rule, formData, out)
|
||||
if (out.length) return out
|
||||
for (const [k, v] of Object.entries(formData)) {
|
||||
const names = formatUploadNames(v)
|
||||
if (names.length) {
|
||||
out.push({ label: k, urls: names })
|
||||
continue
|
||||
}
|
||||
out.push({ label: k, text: formatScalar(v) })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function peopleDisplayNames(p) {
|
||||
const people = Array.isArray(p?.people) ? p.people : []
|
||||
const fromPeople = people
|
||||
.map((x) => String(x?.nickName || x?.id || '').trim())
|
||||
.filter(Boolean)
|
||||
if (fromPeople.length) return fromPeople
|
||||
const name = String(p?.assigneeUserName || p?.assigneeUserId || '').trim()
|
||||
return name ? [name] : []
|
||||
}
|
||||
|
||||
function formatProgressPersonLines(people) {
|
||||
const list = Array.isArray(people) ? people : []
|
||||
const out = []
|
||||
const ACTED = new Set(['approved', 'rejected', 'returned', 'completed', 'transferred'])
|
||||
for (const p of list) {
|
||||
const name = String(p?.nickName || p?.id || '').trim()
|
||||
if (!name) continue
|
||||
const status = String(p.status || '').trim()
|
||||
const comment = String(p.commentText || '').trim()
|
||||
const ts = String(p.timestamp || '').trim()
|
||||
const transferTo = String(p.transferToUserName || p.transferToUserId || '').trim()
|
||||
|
||||
if (status === 'done') {
|
||||
out.push({ left: `${name} 无需处理`, right: '' })
|
||||
continue
|
||||
}
|
||||
if (status === 'transferred') {
|
||||
const mid = transferTo
|
||||
? comment
|
||||
? `转交给${transferTo} ${comment}`
|
||||
: `转交给${transferTo}`
|
||||
: comment || '已转交'
|
||||
out.push({ left: `${name} ${mid}`, right: ts })
|
||||
continue
|
||||
}
|
||||
if (status === 'pending' || status === 'running' || status === 'not_reached' || !status) {
|
||||
if (!status && (comment || ts)) {
|
||||
out.push({ left: `${name} ${comment || '已同意'}`, right: ts })
|
||||
continue
|
||||
}
|
||||
out.push({ left: `${name} 待处理`, right: '' })
|
||||
continue
|
||||
}
|
||||
if (ACTED.has(status) || comment || ts) {
|
||||
const mid =
|
||||
comment ||
|
||||
(status === 'approved'
|
||||
? '已同意'
|
||||
: status === 'rejected'
|
||||
? '已拒绝'
|
||||
: status === 'returned'
|
||||
? '已打回'
|
||||
: status === 'completed'
|
||||
? '已完成'
|
||||
: statusText(status))
|
||||
out.push({ left: `${name} ${mid}`, right: ts })
|
||||
continue
|
||||
}
|
||||
out.push({ left: `${name} 待处理`, right: '' })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function isCcProgressItem(p) {
|
||||
const taskType = String(p?.taskType || p?.task_type || '').trim()
|
||||
const signMode = String(p?.signMode || p?.sign_mode || '').trim()
|
||||
const title = String(p?.title || p?.nodeName || '').trim()
|
||||
return (
|
||||
taskType === 'cc' ||
|
||||
signMode === 'cc' ||
|
||||
title === '抄送人' ||
|
||||
/^抄送/.test(title)
|
||||
)
|
||||
}
|
||||
|
||||
export function buildFlowItems(detail) {
|
||||
const list = Array.isArray(detail?.progress) ? detail.progress : []
|
||||
const items = []
|
||||
let ccBuf = null
|
||||
const flushCc = () => {
|
||||
if (!ccBuf) return
|
||||
const uniq = Array.from(new Set(ccBuf.names.map((n) => n.trim()).filter(Boolean)))
|
||||
items.push({
|
||||
left: uniq.length ? `抄送: ${uniq.join(', ')}` : '抄送:',
|
||||
right: ccBuf.ts || ''
|
||||
})
|
||||
ccBuf = null
|
||||
}
|
||||
|
||||
for (const p of list) {
|
||||
const kind = String(p.kind || '')
|
||||
const title = String(p.title || '-').trim()
|
||||
const names = peopleDisplayNames(p)
|
||||
const name = names.join(', ')
|
||||
const ts = String(p.timestamp || '').trim()
|
||||
const comment = String(p.commentText || '').trim()
|
||||
const st = statusText(String(p.status || ''))
|
||||
const signMode = String(p.signMode || p.sign_mode || '')
|
||||
const signLabel = signMode === 'and' ? '会签' : signMode === 'or' ? '或签' : ''
|
||||
|
||||
if (isCcProgressItem(p)) {
|
||||
if (!ccBuf) ccBuf = { names: [], ts }
|
||||
if (names.length) ccBuf.names.push(...names)
|
||||
if (ts) ccBuf.ts = ts
|
||||
continue
|
||||
}
|
||||
flushCc()
|
||||
|
||||
if (kind === 'submit') {
|
||||
items.push({
|
||||
left: name ? `【${title}】 ${name} ${st}` : `【${title}】 ${st}`,
|
||||
right: ts,
|
||||
comment: comment || undefined
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (kind === 'terminal') {
|
||||
if (String(p.status || '') === 'returned' || title === '已打回') continue
|
||||
items.push({ left: title || st, right: ts })
|
||||
continue
|
||||
}
|
||||
|
||||
const head = signLabel ? `【${title}·${signLabel}】` : `【${title}】`
|
||||
const personLines = formatProgressPersonLines(Array.isArray(p?.people) ? p.people : [])
|
||||
if (personLines.length) {
|
||||
items.push({
|
||||
left: `${head} ${st}`,
|
||||
right: '',
|
||||
personLines
|
||||
})
|
||||
continue
|
||||
}
|
||||
items.push({
|
||||
left: name ? `${head} ${name} ${st}` : `${head} ${st}`,
|
||||
right: ts,
|
||||
comment: comment || undefined
|
||||
})
|
||||
}
|
||||
flushCc()
|
||||
|
||||
const hasCcLine = items.some((it) => it.left.startsWith('抄送'))
|
||||
if (!hasCcLine) {
|
||||
const tasks = Array.isArray(detail?.tasks) ? detail.tasks : []
|
||||
const ccTasks = tasks.filter((t) => {
|
||||
const tt = String(t?.taskType || t?.task_type || '').trim()
|
||||
const sm = String(t?.signMode || t?.sign_mode || '').trim()
|
||||
return tt === 'cc' || sm === 'cc'
|
||||
})
|
||||
if (ccTasks.length) {
|
||||
const names = ccTasks
|
||||
.map((t) =>
|
||||
String(t?.assigneeUserName || t?.assignee_user_name || t?.assigneeUserId || '').trim()
|
||||
)
|
||||
.filter(Boolean)
|
||||
const uniq = Array.from(new Set(names))
|
||||
let ts = ''
|
||||
for (const t of ccTasks) {
|
||||
const tTs = String(t?.updateTime || t?.createTime || '').trim()
|
||||
if (tTs && (!ts || tTs > ts)) ts = tTs
|
||||
}
|
||||
if (uniq.length) items.push({ left: `抄送: ${uniq.join(', ')}`, right: ts })
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
export function buildOaPrintModel(detail, opts = {}) {
|
||||
const { stampClass, stampText } = resolveStamp(detail?.status)
|
||||
const appName = String(detail?.appName || '').trim()
|
||||
const titleFallback = String(detail?.title || '').trim()
|
||||
const title = appName || titleFallback || '审批单'
|
||||
const printerName = String(opts.printerName || '').trim() || '-'
|
||||
const printTime = opts.printTime || formatNow()
|
||||
const headerRows = [
|
||||
{ label: '审批编号', text: String(detail?.id || '-').trim() || '-' },
|
||||
{
|
||||
label: '创建人',
|
||||
text:
|
||||
String(detail?.initiatorUserName || detail?.initiatorUserId || '-').trim() || '-'
|
||||
},
|
||||
{
|
||||
label: '创建人部门',
|
||||
text:
|
||||
String(detail?.initiatorDeptName || detail?.initiatorDept || '-').trim() || '-'
|
||||
}
|
||||
]
|
||||
return {
|
||||
title,
|
||||
companyName: OA_PRINT_COMPANY_NAME,
|
||||
createDate: formatDateOnly(detail?.createTime),
|
||||
stampClass,
|
||||
stampText,
|
||||
rows: [...headerRows, ...buildFormRows(detail)],
|
||||
flowItems: buildFlowItems(detail),
|
||||
printTime,
|
||||
printerName,
|
||||
watermarkName: printerName === '-' ? '审批' : printerName
|
||||
}
|
||||
}
|
||||
|
||||
function cellInnerHtml(row) {
|
||||
if (Array.isArray(row.urls) && row.urls.length) {
|
||||
return row.urls.map((u) => `<div class="oa-print-url">${escapeHtml(u)}</div>`).join('')
|
||||
}
|
||||
const raw = String(row.text != null ? row.text : '-')
|
||||
const html = raw
|
||||
.split('\n')
|
||||
.map((line) => escapeHtml(line))
|
||||
.join('<br/>')
|
||||
return `<span class="oa-print-text">${html}</span>`
|
||||
}
|
||||
|
||||
function flowCellHtml(f) {
|
||||
let html = `<div class="oa-print-flow__row"><div class="oa-print-flow__left">${escapeHtml(f.left)}</div>`
|
||||
if (f.right) {
|
||||
html += `<div class="oa-print-flow__right">${escapeHtml(f.right)}</div>`
|
||||
}
|
||||
html += '</div>'
|
||||
if (Array.isArray(f.personLines) && f.personLines.length) {
|
||||
html += '<div class="oa-print-flow__person-lines">'
|
||||
for (const line of f.personLines) {
|
||||
html += `<div class="oa-print-flow__row oa-print-flow__person-line"><div class="oa-print-flow__left">${escapeHtml(line.left)}</div>`
|
||||
if (line.right) {
|
||||
html += `<div class="oa-print-flow__right">${escapeHtml(line.right)}</div>`
|
||||
}
|
||||
html += '</div>'
|
||||
}
|
||||
html += '</div>'
|
||||
} else if (f.comment) {
|
||||
html += `<div class="oa-print-flow__comment">意见:${escapeHtml(f.comment)}</div>`
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
function buildPrintHtml(model) {
|
||||
const stampChars3 = model.stampText.length >= 3 ? ' is-chars3' : ''
|
||||
const wmItems = Array.from({ length: 24 }, (_, i) => {
|
||||
const row = Math.floor(i / 3)
|
||||
const col = i % 3
|
||||
const top = 40 + row * 100
|
||||
const left = -20 + col * 240 + (row % 2) * 40
|
||||
return `<span class="oa-print-wm__item" style="top:${top}px;left:${left}px">${escapeHtml(model.watermarkName)} ${escapeHtml(model.watermarkName)}</span>`
|
||||
}).join('')
|
||||
|
||||
let rowsHtml = ''
|
||||
for (const row of model.rows) {
|
||||
rowsHtml += `<tr><th>${escapeHtml(row.label)}</th><td>${cellInnerHtml(row)}</td></tr>`
|
||||
}
|
||||
|
||||
let flowHtml = ''
|
||||
if (model.flowItems.length) {
|
||||
model.flowItems.forEach((f, fi) => {
|
||||
flowHtml += '<tr class="oa-print-flow">'
|
||||
if (fi === 0) {
|
||||
flowHtml += `<th class="oa-print-flow__label" rowspan="${model.flowItems.length}">审批流程</th>`
|
||||
}
|
||||
flowHtml += `<td class="oa-print-flow__cell">${flowCellHtml(f)}</td></tr>`
|
||||
})
|
||||
} else {
|
||||
flowHtml =
|
||||
'<tr class="oa-print-flow"><th class="oa-print-flow__label">审批流程</th><td class="oa-print-flow__cell"><span class="oa-print-text">-</span></td></tr>'
|
||||
}
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"/>
|
||||
<title>${escapeHtml(model.title)}</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #fff; color: #1f2329;
|
||||
font-family: "PingFang SC","Microsoft YaHei","Helvetica Neue",Arial,sans-serif; }
|
||||
.oa-print-page { position: relative; max-width: 794px; margin: 0 auto;
|
||||
min-height: 100vh; padding: 32px 28px 40px; overflow: hidden; }
|
||||
.oa-print-wm { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
|
||||
.oa-print-wm__item { position: absolute; font-size: 14px; color: rgba(0,0,0,.045);
|
||||
white-space: nowrap; transform: rotate(-30deg); letter-spacing: 2px; }
|
||||
.oa-print-inner { position: relative; z-index: 1; }
|
||||
.oa-print-title { margin: 0 0 12px; text-align: center; font-size: 22px;
|
||||
font-weight: 700; letter-spacing: 2px; line-height: 1.3; }
|
||||
.oa-print-meta { display: flex; flex-wrap: wrap; gap: 16px 32px; margin-bottom: 12px;
|
||||
font-size: 12px; line-height: 1.5; }
|
||||
.oa-print-table-wrap { position: relative; }
|
||||
.oa-print-table { width: 100%; border-collapse: separate; border-spacing: 0;
|
||||
table-layout: fixed; border-top: 1px solid #333; border-left: 1px solid #333; font-size: 13px; }
|
||||
.oa-print-table th, .oa-print-table td { border-right: 1px solid #333; border-bottom: 1px solid #333;
|
||||
padding: 8px 10px; vertical-align: top; word-break: break-word; }
|
||||
.oa-print-table th { width: 28%; background: #fafafa; font-weight: 600; text-align: left; }
|
||||
.oa-print-text { white-space: pre-wrap; }
|
||||
.oa-print-url { line-height: 1.45; }
|
||||
.oa-print-flow__row { display: flex; justify-content: space-between; gap: 12px; }
|
||||
.oa-print-flow__left { flex: 1; min-width: 0; }
|
||||
.oa-print-flow__right { flex: 0 0 auto; color: #646464; white-space: nowrap; }
|
||||
.oa-print-flow__person-lines { margin-top: 4px; padding-left: 8px; }
|
||||
.oa-print-flow__person-line { margin-top: 2px; font-size: 12px; }
|
||||
.oa-print-flow__comment { margin-top: 4px; color: #646464; font-size: 12px; }
|
||||
.oa-stamp { position: absolute; right: 12px; top: -8px; width: 88px; height: 88px;
|
||||
border-radius: 50%; border: 3px solid; display: flex; align-items: center;
|
||||
justify-content: center; font-weight: 700; font-size: 16px; transform: rotate(-12deg);
|
||||
opacity: .85; z-index: 2; background: rgba(255,255,255,.55); }
|
||||
.oa-stamp.ok { color: #059669; border-color: #059669; }
|
||||
.oa-stamp.run { color: #d97706; border-color: #d97706; }
|
||||
.oa-stamp.rej, .oa-stamp.ret, .oa-stamp.can { color: #dc2626; border-color: #dc2626; }
|
||||
.oa-stamp.is-chars3 { font-size: 14px; }
|
||||
.oa-print-footer { display: flex; flex-wrap: wrap; justify-content: space-between;
|
||||
gap: 8px; margin-top: 16px; font-size: 12px; color: #646464; }
|
||||
@media print {
|
||||
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
.oa-print-page { max-width: none; padding: 12mm 14mm; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="oa-print-page">
|
||||
<div class="oa-print-wm" aria-hidden="true">${wmItems}</div>
|
||||
<div class="oa-print-inner">
|
||||
<h1 class="oa-print-title">${escapeHtml(model.title)}</h1>
|
||||
<div class="oa-print-meta">
|
||||
<span>${escapeHtml(model.companyName)}</span>
|
||||
<span>创建时间:${escapeHtml(model.createDate)}</span>
|
||||
</div>
|
||||
<div class="oa-print-table-wrap">
|
||||
<div class="oa-stamp ${escapeHtml(model.stampClass)}${stampChars3}">${escapeHtml(model.stampText)}</div>
|
||||
<table class="oa-print-table">
|
||||
<tbody>${rowsHtml}${flowHtml}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="oa-print-footer">
|
||||
<span>打印时间:${escapeHtml(model.printTime)}</span>
|
||||
<span>打印人:${escapeHtml(model.printerName)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
function isWeChatMiniWebView() {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && window.__wxjs_environment === 'miniprogram') {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
if (typeof wx !== 'undefined' && wx.miniProgram) return true
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const ua = String(navigator?.userAgent || '')
|
||||
if (/miniProgram/i.test(ua)) return true
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开可打印 HTML 并调用 window.print()。
|
||||
* @returns {Promise<{ ok: boolean, message?: string }>}
|
||||
*/
|
||||
export async function exportOaApprovalPdf(detail, opts = {}) {
|
||||
if (!detail) {
|
||||
return { ok: false, message: '暂无审批详情,无法打印' }
|
||||
}
|
||||
|
||||
const model = buildOaPrintModel(detail, {
|
||||
printerName: opts.printerName || ''
|
||||
})
|
||||
const html = buildPrintHtml(model)
|
||||
|
||||
// 微信小程序 web-view 内系统打印能力普遍不可用
|
||||
if (isWeChatMiniWebView()) {
|
||||
return {
|
||||
ok: false,
|
||||
message: '微信内暂不支持系统打印,请用手机浏览器打开或到电脑端导出'
|
||||
}
|
||||
}
|
||||
|
||||
let frame = null
|
||||
try {
|
||||
frame = document.createElement('iframe')
|
||||
frame.setAttribute('title', 'oa-print')
|
||||
frame.style.cssText =
|
||||
'position:fixed;right:0;bottom:0;width:0;height:0;border:0;opacity:0;pointer-events:none;'
|
||||
document.body.appendChild(frame)
|
||||
|
||||
const doc = frame.contentDocument || frame.contentWindow?.document
|
||||
if (!doc || !frame.contentWindow) {
|
||||
throw new Error('print frame unavailable')
|
||||
}
|
||||
doc.open()
|
||||
doc.write(html)
|
||||
doc.close()
|
||||
|
||||
await new Promise((r) => setTimeout(r, 80))
|
||||
const win = frame.contentWindow
|
||||
if (typeof win.print !== 'function') {
|
||||
throw new Error('print not supported')
|
||||
}
|
||||
win.focus()
|
||||
win.print()
|
||||
return { ok: true }
|
||||
} catch (e) {
|
||||
return {
|
||||
ok: false,
|
||||
message:
|
||||
(e && e.message) ||
|
||||
'当前环境无法调起打印,请用手机浏览器打开或到电脑端导出'
|
||||
}
|
||||
} finally {
|
||||
if (frame) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
frame.remove()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, 1500)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@
|
||||
<van-button class="oa-detail__btn oa-detail__btn--close" @click="onClose">
|
||||
关闭
|
||||
</van-button>
|
||||
<van-button
|
||||
class="oa-detail__btn oa-detail__btn--ghost oa-detail__btn--print"
|
||||
:loading="printing"
|
||||
:disabled="acting || printing"
|
||||
@click="onPrint"
|
||||
>
|
||||
打印
|
||||
</van-button>
|
||||
<template v-if="canApprove">
|
||||
<van-button
|
||||
class="oa-detail__btn oa-detail__btn--ghost"
|
||||
@@ -204,6 +212,7 @@ import {
|
||||
} from '../api/oa.js'
|
||||
import { prepareMobileFormSchema } from '../oa/schema-prep.js'
|
||||
import { statusText, statusTone } from '../oa/status-label.js'
|
||||
import { exportOaApprovalPdf } from '../oa/print/exportOaApprovalPdf.js'
|
||||
import OaProgressTimeline from '../oa/components/OaProgressTimeline.vue'
|
||||
import OaPulseLoading from '../oa/components/OaPulseLoading.vue'
|
||||
import OaPersonSelect from '../oa/components/OaPersonSelect.vue'
|
||||
@@ -212,11 +221,14 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const acting = ref(false)
|
||||
const printing = ref(false)
|
||||
const detail = ref(null)
|
||||
const formData = ref({})
|
||||
const formRule = ref([])
|
||||
const formOption = ref({})
|
||||
const currentUserId = ref('')
|
||||
const currentUserName = ref('')
|
||||
const autoPrinted = ref(false)
|
||||
|
||||
const commentVisible = ref(false)
|
||||
const commentKind = ref('approve') // approve | reject | return
|
||||
@@ -232,6 +244,10 @@ const transferComment = ref('')
|
||||
|
||||
const instanceId = computed(() => String(route.query.instanceId || '').trim())
|
||||
const queryTaskId = computed(() => String(route.query.taskId || '').trim())
|
||||
const wantAutoPrint = computed(() => {
|
||||
const v = String(route.query.autoPrint || '').trim()
|
||||
return v === '1' || v === 'true'
|
||||
})
|
||||
const mode = computed(() => {
|
||||
const m = String(route.query.mode || 'view').trim()
|
||||
return m || 'view'
|
||||
@@ -357,6 +373,30 @@ function onClose() {
|
||||
closeToMini({ action: 'oa-detail-close' })
|
||||
}
|
||||
|
||||
async function onPrint() {
|
||||
if (!detail.value || printing.value) return
|
||||
printing.value = true
|
||||
try {
|
||||
const printerName =
|
||||
currentUserName.value ||
|
||||
String(detail.value?.initiatorUserName || '').trim() ||
|
||||
'-'
|
||||
const result = await exportOaApprovalPdf(detail.value, { printerName })
|
||||
if (!result?.ok) {
|
||||
showToast(
|
||||
result?.message ||
|
||||
'当前环境无法调起打印,请用手机浏览器打开或到电脑端导出'
|
||||
)
|
||||
}
|
||||
} catch (e) {
|
||||
showToast(
|
||||
e?.message || '当前环境无法调起打印,请用手机浏览器打开或到电脑端导出'
|
||||
)
|
||||
} finally {
|
||||
printing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function parseFormData(d) {
|
||||
if (d?.formData && typeof d.formData === 'object') return { ...d.formData }
|
||||
if (d?.formDataJson && typeof d.formDataJson === 'object') return { ...d.formDataJson }
|
||||
@@ -545,6 +585,9 @@ async function bootstrap() {
|
||||
fetchOaInstanceDetail(id)
|
||||
])
|
||||
currentUserId.value = String(me?.id || me?.userId || '').trim()
|
||||
currentUserName.value = String(
|
||||
me?.nickName || me?.userName || me?.name || ''
|
||||
).trim()
|
||||
detail.value = d
|
||||
try {
|
||||
document.title = d?.title || d?.appName || '审批详情'
|
||||
@@ -568,6 +611,14 @@ async function bootstrap() {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
if (wantAutoPrint.value && detail.value && !autoPrinted.value) {
|
||||
autoPrinted.value = true
|
||||
// 等首屏渲染后再调打印,避免遮挡 loading
|
||||
setTimeout(() => {
|
||||
void onPrint()
|
||||
}, 320)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -679,6 +730,10 @@ onMounted(() => {
|
||||
.oa-detail__btn--close:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
.oa-detail__btn--print {
|
||||
flex: 0 0 auto;
|
||||
min-width: 64px;
|
||||
}
|
||||
.oa-detail__btn--ghost {
|
||||
border: 1px solid #e2e8f0 !important;
|
||||
background: #fff !important;
|
||||
|
||||
Reference in new issue
Block a user