+ @click-preview="onClickPreview"
+ >
+
+
+
+
+
{{ shortDisplayName(slotProps) }}
+
点击下载
+
+
+
{{ tipText }}
@@ -17,14 +52,17 @@
diff --git a/mobile-oa-h5/src/oa/oa-flow-preview-refresh.js b/mobile-oa-h5/src/oa/oa-flow-preview-refresh.js
new file mode 100644
index 00000000..24eb1c40
--- /dev/null
+++ b/mobile-oa-h5/src/oa/oa-flow-preview-refresh.js
@@ -0,0 +1,127 @@
+/** 与后端 oa_engine.INITIATOR_CONDITION_FIELD 对齐:不依赖 formData */
+const INITIATOR_CONDITION_FIELD = '__initiator__'
+const FORMULA_FIELD_RE = /\{([^{}]+)\}/g
+
+function nodeType(node) {
+ return String((node && (node.type || node.nodeType)) || '').trim()
+}
+
+function addField(name, fields, seen) {
+ const key = String(name || '').trim()
+ if (!key || seen.has(key)) return
+ if (key === INITIATOR_CONDITION_FIELD) return
+ seen.add(key)
+ fields.push(key)
+}
+
+function addFormulaFields(text, fields, seen) {
+ if (!text) return
+ FORMULA_FIELD_RE.lastIndex = 0
+ let m
+ while ((m = FORMULA_FIELD_RE.exec(String(text)))) {
+ addField(m[1], fields, seen)
+ }
+}
+
+/**
+ * 收集流程树中非默认条件分支引用的表单字段(去重保序)。
+ * 语义对齐 backend `oa_engine._collect_condition_fields`。
+ */
+export function collectConditionFields(node) {
+ const fields = []
+ const seen = new Set()
+
+ function walk(n) {
+ if (!n || typeof n !== 'object') return
+ if (nodeType(n) === 'condition') {
+ for (const g of n.conditionGroups || []) {
+ if (!g || typeof g !== 'object') continue
+ const rules = g.rules
+ const when = g.when
+ if (g.isDefault || (!rules && !when)) {
+ if (g.child && typeof g.child === 'object') walk(g.child)
+ continue
+ }
+ if (Array.isArray(rules) && rules.length) {
+ for (const r of rules) {
+ if (r && typeof r === 'object' && r.field != null) {
+ addField(r.field, fields, seen)
+ }
+ }
+ } else if (typeof rules === 'string' && rules.trim()) {
+ addFormulaFields(rules, fields, seen)
+ } else if (when) {
+ addFormulaFields(when, fields, seen)
+ }
+ if (g.child && typeof g.child === 'object') walk(g.child)
+ }
+ if (n.child && typeof n.child === 'object') walk(n.child)
+ return
+ }
+ if (n.child && typeof n.child === 'object') walk(n.child)
+ for (const g of n.conditionGroups || []) {
+ if (g && typeof g === 'object' && g.child && typeof g.child === 'object') {
+ walk(g.child)
+ }
+ }
+ }
+
+ walk(node)
+ return fields
+}
+
+function sortKeysDeep(value) {
+ if (Array.isArray(value)) return value.map(sortKeysDeep)
+ if (value && typeof value === 'object') {
+ const out = {}
+ for (const k of Object.keys(value).sort()) {
+ out[k] = sortKeysDeep(value[k])
+ }
+ return out
+ }
+ return value
+}
+
+export function pickRelevantFormData(formData, conditionFields) {
+ const data = formData && typeof formData === 'object' ? formData : {}
+ const fields = Array.isArray(conditionFields) ? conditionFields : []
+ const out = {}
+ for (const f of fields) {
+ out[f] = data[f]
+ }
+ return out
+}
+
+/** 稳定序列化:用于判断预览请求是否实质变化 */
+export function buildFlowPreviewRequestKey({
+ formData,
+ initiatorSelectByNode,
+ conditionFields
+} = {}) {
+ const payload = {
+ formData: pickRelevantFormData(formData, conditionFields),
+ initiatorSelectByNode:
+ initiatorSelectByNode && typeof initiatorSelectByNode === 'object'
+ ? initiatorSelectByNode
+ : {}
+ }
+ return JSON.stringify(sortKeysDeep(payload))
+}
+
+/**
+ * @param {string|null|undefined} prevKey 上次成功预览的 key
+ * @param {string} nextKey 本次请求 key
+ * @param {{ inFlightKey?: string|null }} [opts] 在途请求 key;有在途时仅跳过相同 key,避免回滚竞态
+ */
+export function shouldSkipFlowPreviewRequest(prevKey, nextKey, opts = {}) {
+ const inFlightKey = opts.inFlightKey
+ if (inFlightKey != null) {
+ return inFlightKey === nextKey
+ }
+ return prevKey != null && prevKey === nextKey
+}
+
+/** 已有预览结果时静默刷新,避免整区「加载中」闪烁 */
+export function shouldShowFlowPreviewSpinner({ hasCompletedPreview } = {}) {
+ return !hasCompletedPreview
+}
diff --git a/mobile-oa-h5/src/oa/oa-flow-preview-refresh.test.js b/mobile-oa-h5/src/oa/oa-flow-preview-refresh.test.js
new file mode 100644
index 00000000..279232c8
--- /dev/null
+++ b/mobile-oa-h5/src/oa/oa-flow-preview-refresh.test.js
@@ -0,0 +1,140 @@
+import assert from 'node:assert/strict'
+import {
+ collectConditionFields,
+ buildFlowPreviewRequestKey,
+ shouldSkipFlowPreviewRequest,
+ shouldShowFlowPreviewSpinner
+} from './oa-flow-preview-refresh.js'
+
+// 无条件分支:表单无关字段变化不应改变 request key
+{
+ const keyA = buildFlowPreviewRequestKey({
+ formData: { title: 'a', reason: 'x' },
+ initiatorSelectByNode: {},
+ conditionFields: []
+ })
+ const keyB = buildFlowPreviewRequestKey({
+ formData: { title: 'b', reason: 'y' },
+ initiatorSelectByNode: {},
+ conditionFields: []
+ })
+ assert.equal(keyA, keyB)
+ assert.equal(shouldSkipFlowPreviewRequest(keyA, keyB), true)
+}
+
+// 有条件字段:仅条件字段变化才改变 key
+{
+ const fields = ['amount']
+ const keyA = buildFlowPreviewRequestKey({
+ formData: { amount: 100, remark: 'old' },
+ initiatorSelectByNode: {},
+ conditionFields: fields
+ })
+ const keySame = buildFlowPreviewRequestKey({
+ formData: { amount: 100, remark: 'new' },
+ initiatorSelectByNode: {},
+ conditionFields: fields
+ })
+ const keyDiff = buildFlowPreviewRequestKey({
+ formData: { amount: 200, remark: 'new' },
+ initiatorSelectByNode: {},
+ conditionFields: fields
+ })
+ assert.equal(keyA, keySame)
+ assert.notEqual(keyA, keyDiff)
+ assert.equal(shouldSkipFlowPreviewRequest(keyA, keySame), true)
+ assert.equal(shouldSkipFlowPreviewRequest(keyA, keyDiff), false)
+}
+
+// 自选审批人变化应触发预览
+{
+ const keyA = buildFlowPreviewRequestKey({
+ formData: {},
+ initiatorSelectByNode: { n1: ['u1'] },
+ conditionFields: []
+ })
+ const keyB = buildFlowPreviewRequestKey({
+ formData: {},
+ initiatorSelectByNode: { n1: ['u2'] },
+ conditionFields: []
+ })
+ assert.notEqual(keyA, keyB)
+}
+
+// 从 flowTree 收集条件字段(对齐后端语义)
+{
+ const tree = {
+ type: 'start',
+ child: {
+ type: 'condition',
+ conditionGroups: [
+ {
+ rules: [{ field: 'leaveType', op: 'eq', value: '事假' }],
+ child: { type: 'approver', id: 'a1' }
+ },
+ {
+ isDefault: true,
+ child: { type: 'approver', id: 'a2' }
+ }
+ ],
+ child: { type: 'end' }
+ }
+ }
+ assert.deepEqual(collectConditionFields(tree), ['leaveType'])
+}
+
+// 公式字段 {fieldName}
+{
+ const tree = {
+ type: 'condition',
+ conditionGroups: [
+ {
+ when: '{days} > 3',
+ child: { type: 'approver' }
+ }
+ ]
+ }
+ assert.deepEqual(collectConditionFields(tree), ['days'])
+}
+
+// 系统伪字段 __initiator__ 不纳入
+{
+ const tree = {
+ type: 'condition',
+ conditionGroups: [
+ {
+ rules: [{ field: '__initiator__', op: 'eq', value: 'x' }],
+ child: { type: 'approver' }
+ }
+ ]
+ }
+ assert.deepEqual(collectConditionFields(tree), [])
+}
+
+// 仅首次预览展示整区 loading
+assert.equal(shouldShowFlowPreviewSpinner({ hasCompletedPreview: false }), true)
+assert.equal(shouldShowFlowPreviewSpinner({ hasCompletedPreview: true }), false)
+
+// 有在途请求时:相同 key 跳过,不同 key(含回滚到上次成功值)必须再请求
+{
+ const key100 = buildFlowPreviewRequestKey({
+ formData: { amount: 100 },
+ initiatorSelectByNode: {},
+ conditionFields: ['amount']
+ })
+ const key200 = buildFlowPreviewRequestKey({
+ formData: { amount: 200 },
+ initiatorSelectByNode: {},
+ conditionFields: ['amount']
+ })
+ assert.equal(
+ shouldSkipFlowPreviewRequest(key100, key200, { inFlightKey: key200 }),
+ true
+ )
+ assert.equal(
+ shouldSkipFlowPreviewRequest(key100, key100, { inFlightKey: key200 }),
+ false
+ )
+}
+
+console.log('oa-flow-preview-refresh.test.js ok')
diff --git a/mobile-oa-h5/src/oa/oa-progress-person-lines.js b/mobile-oa-h5/src/oa/oa-progress-person-lines.js
new file mode 100644
index 00000000..416ab7ff
--- /dev/null
+++ b/mobile-oa-h5/src/oa/oa-progress-person-lines.js
@@ -0,0 +1,129 @@
+/**
+ * 或签/会签进度人员行格式化(对齐 web-admin oa-progress-person-lines)
+ * Spec: docs/superpowers/specs/2026-07-23-oa-or-and-sign-display-design.md
+ *
+ * 返回字段:
+ * - name / detail / timestamp:移动端分行展示(姓名+时间同行,意见单独一行)
+ * - body / text:兼容旧纯文本拼接
+ */
+
+const ACTED_STATUS = new Set(['approved', 'rejected', 'returned', 'completed', 'transferred'])
+
+function displayName(p) {
+ return String(p?.nickName || p?.id || '').trim()
+}
+
+function transferTargetName(p) {
+ return String(p?.transferToUserName || p?.transferToUserId || '').trim()
+}
+
+function statusFallbackLabel(status) {
+ const s = (status || '').trim()
+ if (s === 'approved') return '已同意'
+ if (s === 'rejected') return '已拒绝'
+ if (s === 'returned') return '已打回'
+ if (s === 'completed') return '已完成'
+ if (s === 'transferred') return '已转交'
+ return s || '已处理'
+}
+
+function toneForStatus(status) {
+ const s = (status || '').trim()
+ if (s === 'approved' || s === 'completed') return 'ok'
+ if (s === 'pending' || s === 'running' || s === 'not_reached') return 'pending'
+ if (s === 'rejected' || s === 'returned') return 'danger'
+ if (s === 'done' || s === 'transferred' || s === 'cancelled') return 'mute'
+ return ''
+}
+
+/**
+ * @param {Array} out
+ * @param {string} name
+ * @param {string} detail 意见/状态文案(可空)
+ * @param {string} timestamp
+ * @param {string} tone
+ * @param {{ isOpinion?: boolean }} [opts] isOpinion=true 时移动端加「意见:」前缀
+ */
+function pushLine(out, name, detail, timestamp, tone, opts = {}) {
+ const n = String(name || '').trim()
+ const d = String(detail || '').trim()
+ const ts = String(timestamp || '').trim()
+ const body = d ? `${n} ${d}` : n
+ out.push({
+ name: n,
+ detail: d,
+ isOpinion: !!opts.isOpinion && !!d,
+ body,
+ timestamp: ts,
+ text: ts ? `${body} ${ts}` : body,
+ tone
+ })
+}
+
+/**
+ * 抄送节点人员行:仅姓名 + 时间,样式统一(不套审批态文案/tone)。
+ * @param {{ taskType?: string, signMode?: string, people?: Array, assignee?: string, timestamp?: string }} item
+ */
+export function formatCcPersonLines(item) {
+ if (item?.taskType !== 'cc' && item?.signMode !== 'cc') return []
+ const fallbackTs = String(item?.timestamp || '').trim()
+ const people = Array.isArray(item?.people) ? item.people : []
+ const fromPeople = []
+ for (const p of people) {
+ const name = displayName(p)
+ if (!name) continue
+ fromPeople.push({
+ name,
+ timestamp: String(p?.timestamp || '').trim() || fallbackTs
+ })
+ }
+ if (fromPeople.length) return fromPeople
+ const assignee = String(item?.assignee || '').trim()
+ if (!assignee) return []
+ return [{ name: assignee, timestamp: fallbackTs }]
+}
+
+/** 将 progress.people 格式化为人员行 */
+export function formatProgressPersonLines(people) {
+ const list = Array.isArray(people) ? people : []
+ const out = []
+ for (const p of list) {
+ const name = displayName(p)
+ if (!name) continue
+ const status = String(p.status || '').trim()
+ const comment = String(p.commentText || '').trim()
+ const ts = String(p.timestamp || '').trim()
+ const tone = toneForStatus(status)
+
+ if (status === 'done' || status === 'cancelled') {
+ pushLine(out, name, '无需处理', '', 'mute')
+ continue
+ }
+ if (status === 'transferred') {
+ const target = transferTargetName(p)
+ const mid = target
+ ? comment
+ ? `转交给${target} ${comment}`
+ : `转交给${target}`
+ : comment || statusFallbackLabel('transferred')
+ pushLine(out, name, mid, ts, 'mute', { isOpinion: !!comment && !target })
+ continue
+ }
+ if (status === 'pending' || status === 'running' || status === 'not_reached' || !status) {
+ if (!status && (comment || ts)) {
+ const mid = comment || statusFallbackLabel('approved')
+ pushLine(out, name, mid, ts, 'ok', { isOpinion: !!comment })
+ continue
+ }
+ pushLine(out, name, '待处理', '', 'pending')
+ continue
+ }
+ if (ACTED_STATUS.has(status) || comment || ts) {
+ const mid = comment || statusFallbackLabel(status)
+ pushLine(out, name, mid, ts, tone || 'ok', { isOpinion: !!comment })
+ continue
+ }
+ pushLine(out, name, '待处理', '', 'pending')
+ }
+ return out
+}
diff --git a/mobile-oa-h5/src/oa/oa-progress-person-lines.test.js b/mobile-oa-h5/src/oa/oa-progress-person-lines.test.js
new file mode 100644
index 00000000..10169ca2
--- /dev/null
+++ b/mobile-oa-h5/src/oa/oa-progress-person-lines.test.js
@@ -0,0 +1,160 @@
+import assert from 'node:assert/strict'
+import {
+ formatCcPersonLines,
+ formatProgressPersonLines
+} from './oa-progress-person-lines.js'
+
+const andPartial = formatProgressPersonLines([
+ {
+ nickName: '吴家猛',
+ status: 'approved',
+ commentText: '同意',
+ timestamp: '2026-08-10 14:17:19'
+ },
+ {
+ nickName: '郑德禄',
+ status: 'approved',
+ commentText: '同意',
+ timestamp: '2026-08-10 14:18:00'
+ },
+ { nickName: '丙', status: 'pending' }
+])
+assert.deepEqual(
+ andPartial.map((x) => ({
+ name: x.name,
+ detail: x.detail,
+ timestamp: x.timestamp,
+ isOpinion: x.isOpinion,
+ tone: x.tone
+ })),
+ [
+ {
+ name: '吴家猛',
+ detail: '同意',
+ timestamp: '2026-08-10 14:17:19',
+ isOpinion: true,
+ tone: 'ok'
+ },
+ {
+ name: '郑德禄',
+ detail: '同意',
+ timestamp: '2026-08-10 14:18:00',
+ isOpinion: true,
+ tone: 'ok'
+ },
+ {
+ name: '丙',
+ detail: '待处理',
+ timestamp: '',
+ isOpinion: false,
+ tone: 'pending'
+ }
+ ]
+)
+
+const notReached = formatProgressPersonLines([
+ { nickName: '张三', status: 'not_reached' },
+ { nickName: '李四', status: 'not_reached' }
+])
+assert.deepEqual(
+ notReached.map((x) => x.body),
+ ['张三 待处理', '李四 待处理']
+)
+
+const orDone = formatProgressPersonLines([
+ {
+ nickName: '甲',
+ status: 'approved',
+ commentText: '同意,材料齐全',
+ timestamp: '2026-07-23 10:05:00'
+ },
+ { nickName: '乙', status: 'done' },
+ { nickName: '丙', status: 'done' }
+])
+assert.deepEqual(
+ orDone.map((x) => x.text),
+ ['甲 同意,材料齐全 2026-07-23 10:05:00', '乙 无需处理', '丙 无需处理']
+)
+assert.equal(orDone[0].isOpinion, true)
+
+const emptyComment = formatProgressPersonLines([
+ {
+ nickName: '甲',
+ status: 'approved',
+ commentText: '',
+ timestamp: '2026-07-23 10:05:00'
+ }
+])
+assert.equal(emptyComment[0].name, '甲')
+assert.equal(emptyComment[0].detail, '已同意')
+assert.equal(emptyComment[0].isOpinion, false)
+
+// 撤销/打回清待办:cancelled 对齐或签 done →「无需处理」(勿挂「已撤销」)
+const cancelledTask = formatProgressPersonLines([
+ {
+ nickName: '直接主管张三',
+ status: 'cancelled',
+ commentText: '',
+ timestamp: '2026-08-13 12:47:46'
+ }
+])
+assert.deepEqual(
+ cancelledTask.map((x) => ({
+ name: x.name,
+ detail: x.detail,
+ timestamp: x.timestamp,
+ isOpinion: x.isOpinion,
+ tone: x.tone
+ })),
+ [
+ {
+ name: '直接主管张三',
+ detail: '无需处理',
+ timestamp: '',
+ isOpinion: false,
+ tone: 'mute'
+ }
+ ]
+)
+const cancelledNoTs = formatProgressPersonLines([{ nickName: '李四', status: 'cancelled' }])
+assert.equal(cancelledNoTs[0].detail, '无需处理')
+assert.equal(cancelledNoTs[0].tone, 'mute')
+
+// 抄送:多人只出统一姓名行(无「无需处理」、无审批态 tone)
+const ccMulti = formatCcPersonLines({
+ taskType: 'cc',
+ timestamp: '2026-08-10 14:27:37',
+ people: [
+ {
+ nickName: '胡倩茜',
+ status: 'done',
+ timestamp: '2026-08-10 14:27:37'
+ },
+ { nickName: '周斌倩', status: 'done', timestamp: '2026-08-10 14:27:37' }
+ ]
+})
+assert.deepEqual(ccMulti, [
+ { name: '胡倩茜', timestamp: '2026-08-10 14:27:37' },
+ { name: '周斌倩', timestamp: '2026-08-10 14:27:37' }
+])
+assert.ok(ccMulti.every((x) => x.tone === undefined))
+
+const ccSingle = formatCcPersonLines({
+ taskType: 'cc',
+ assignee: '胡圆凯',
+ timestamp: '2026-08-10 14:30:04',
+ people: [{ nickName: '胡圆凯', status: 'done', timestamp: '2026-08-10 14:30:04' }]
+})
+assert.deepEqual(ccSingle, [{ name: '胡圆凯', timestamp: '2026-08-10 14:30:04' }])
+
+const ccAssigneeOnly = formatCcPersonLines({
+ taskType: 'cc',
+ assignee: '张三',
+ timestamp: '2026-08-10 15:00:00',
+ people: []
+})
+assert.deepEqual(ccAssigneeOnly, [{ name: '张三', timestamp: '2026-08-10 15:00:00' }])
+
+assert.deepEqual(formatCcPersonLines({ taskType: 'approval', people: [{ nickName: '甲' }] }), [])
+
+console.log('oa-progress-person-lines: all assertions passed')
diff --git a/mobile-oa-h5/src/oa/oa-status-stamp.js b/mobile-oa-h5/src/oa/oa-status-stamp.js
new file mode 100644
index 00000000..22085df0
--- /dev/null
+++ b/mobile-oa-h5/src/oa/oa-status-stamp.js
@@ -0,0 +1,18 @@
+/**
+ * OA 审批状态 → 钉钉风印章文案/色类(与 web-admin resolveStamp 对齐)
+ * @param {string|null|undefined} status
+ * @returns {{ stampClass: string, stampText: string }}
+ */
+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: '审批中' }
+}
diff --git a/mobile-oa-h5/src/oa/oa-status-stamp.test.js b/mobile-oa-h5/src/oa/oa-status-stamp.test.js
new file mode 100644
index 00000000..cde2e479
--- /dev/null
+++ b/mobile-oa-h5/src/oa/oa-status-stamp.test.js
@@ -0,0 +1,45 @@
+import assert from 'node:assert/strict'
+import { resolveStamp } from './oa-status-stamp.js'
+
+assert.deepEqual(resolveStamp('completed'), {
+ stampClass: 'ok',
+ stampText: '已通过'
+})
+assert.deepEqual(resolveStamp('approved'), {
+ stampClass: 'ok',
+ stampText: '已通过'
+})
+assert.deepEqual(resolveStamp('done'), {
+ stampClass: 'ok',
+ stampText: '已通过'
+})
+assert.deepEqual(resolveStamp('rejected'), {
+ stampClass: 'rej',
+ stampText: '已拒绝'
+})
+assert.deepEqual(resolveStamp('returned'), {
+ stampClass: 'ret',
+ stampText: '已打回'
+})
+assert.deepEqual(resolveStamp('cancelled'), {
+ stampClass: 'can',
+ stampText: '已撤销'
+})
+assert.deepEqual(resolveStamp('running'), {
+ stampClass: 'run',
+ stampText: '审批中'
+})
+assert.deepEqual(resolveStamp('pending'), {
+ stampClass: 'run',
+ stampText: '审批中'
+})
+assert.deepEqual(resolveStamp(''), {
+ stampClass: 'run',
+ stampText: '审批中'
+})
+assert.deepEqual(resolveStamp(null), {
+ stampClass: 'run',
+ stampText: '审批中'
+})
+
+console.log('oa-status-stamp: all assertions passed')
diff --git a/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js b/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js
index f483f8ea..6bf7700e 100644
--- a/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js
+++ b/mobile-oa-h5/src/oa/print/exportOaApprovalPdf.js
@@ -14,9 +14,9 @@ import {
normalizeReceiveAccountModelValue
} from '../../utils/receive-account-value.js'
import { toRmbUpper } from '../../utils/rmb-upper.js'
-import { OA_STATUS_TEXT } from '../status-label.js'
import { resolveStamp } from '../oa-status-stamp.js'
import { buildOaPrintFilename } from './oa-print-filename.js'
+import { buildFlowItems } from './oa-print-flow.js'
export { resolveStamp }
@@ -62,9 +62,6 @@ function escapeHtml(s) {
.replace(/"/g, '"')
}
-function statusText(s) {
- return OA_STATUS_TEXT[s] || s || '-'
-}
function formatDateOnly(createTime) {
const raw = String(createTime || '').trim()
@@ -367,171 +364,7 @@ function buildFormRows(detail) {
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 { buildFlowItems }
export function buildOaPrintModel(detail, opts = {}) {
const { stampClass, stampText } = resolveStamp(detail?.status)
@@ -671,8 +504,7 @@ function buildPrintStylesContent() {
justify-content: center; font-size: 32px; font-weight: 700; letter-spacing: 2px;
line-height: 1; color: currentColor; }
.oa-stamp .main.is-chars3 { font-size: 26px; letter-spacing: 1px; }
- .oa-print-footer { display: flex; flex-wrap: wrap; justify-content: space-between;
- gap: 8px; margin-top: 16px; font-size: 12px; color: #646464; }`
+ .oa-print-footer { display: flex; gap: 28px; margin-top: 14px; font-size: 12px; }`
}
function buildPrintPageContent(model) {
diff --git a/mobile-oa-h5/src/oa/print/oa-print-flow.js b/mobile-oa-h5/src/oa/print/oa-print-flow.js
new file mode 100644
index 00000000..19ea2093
--- /dev/null
+++ b/mobile-oa-h5/src/oa/print/oa-print-flow.js
@@ -0,0 +1,144 @@
+/**
+ * 移动端打印「审批流程」行组装(无 html2canvas/API 副作用,便于单测)
+ * Spec: docs/superpowers/specs/2026-08-13-oa-progress-withdraw-cancelled-display-design.md
+ */
+import { OA_STATUS_TEXT } from '../status-label.js'
+import { formatProgressPersonLines as formatSharedPersonLines } from '../oa-progress-person-lines.js'
+
+function statusText(s) {
+ return OA_STATUS_TEXT[s] || s || '-'
+}
+
+/** 任务节点 cancelled=系统清待办 →「无需处理」;终态 cancelled 仍为「已撤销」 */
+function progressNodeStatusText(p) {
+ const kind = String(p?.kind || '')
+ const s = String(p?.status || '').trim()
+ if (kind !== 'terminal' && s === 'cancelled') return '无需处理'
+ return statusText(s)
+}
+
+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) {
+ return formatSharedPersonLines(people).map((x) => ({
+ left: x.body || x.text,
+ right: x.timestamp || ''
+ }))
+}
+
+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 = progressNodeStatusText(p)
+ 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
+ const withdrawName = String(p.assigneeUserName || p.assignee || '').trim()
+ const withdrawTip = comment || (String(p.status || '') === 'cancelled' ? '发起人撤销' : '')
+ const leftExtra =
+ String(p.status || '') === 'cancelled' && (withdrawName || withdrawTip)
+ ? ` ${[withdrawName, withdrawTip].filter(Boolean).join(' ')}`
+ : ''
+ items.push({ left: `${title || st}${leftExtra}`, 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
+}
diff --git a/mobile-oa-h5/src/oa/print/oa-print-flow.test.js b/mobile-oa-h5/src/oa/print/oa-print-flow.test.js
new file mode 100644
index 00000000..8d6f29b5
--- /dev/null
+++ b/mobile-oa-h5/src/oa/print/oa-print-flow.test.js
@@ -0,0 +1,47 @@
+/**
+ * 移动端打印「审批流程」:cancelled → 无需处理;终态归因发起人
+ */
+import assert from 'node:assert/strict'
+import { buildFlowItems } from './oa-print-flow.js'
+
+const items = buildFlowItems({
+ progress: [
+ {
+ kind: 'submit',
+ title: '发起人',
+ status: 'submitted',
+ assigneeUserName: '吴子龙',
+ timestamp: '2026-08-13 11:17:38'
+ },
+ {
+ kind: 'task',
+ title: '直接主管',
+ status: 'cancelled',
+ signMode: 'and',
+ timestamp: '',
+ people: [
+ {
+ nickName: '张主管',
+ status: 'cancelled',
+ timestamp: '2026-08-13 12:47:46',
+ commentText: ''
+ }
+ ]
+ },
+ {
+ kind: 'terminal',
+ title: '已撤销',
+ status: 'cancelled',
+ assigneeUserName: '吴子龙',
+ commentText: '发起人撤销',
+ timestamp: '2026-08-13 12:47:46'
+ }
+ ]
+})
+
+assert.equal(items[1].left, '【直接主管·会签】 无需处理')
+assert.deepEqual(items[1].personLines, [{ left: '张主管 无需处理', right: '' }])
+assert.equal(items[2].left, '已撤销 吴子龙 发起人撤销')
+assert.equal(items[2].right, '2026-08-13 12:47:46')
+
+console.log('oa-print-flow buildFlowItems cancelled: ok')
diff --git a/mobile-oa-h5/src/styles/oa-btn-loading.css b/mobile-oa-h5/src/styles/oa-btn-loading.css
new file mode 100644
index 00000000..ce65b8ed
--- /dev/null
+++ b/mobile-oa-h5/src/styles/oa-btn-loading.css
@@ -0,0 +1,36 @@
+/* 按钮点击加载:文案淡化 + 居中自绘转圈(与发起页暂存/提交一致) */
+.oa-btn-loading {
+ position: relative;
+ overflow: hidden;
+}
+.oa-btn-loading.is-loading.van-button--disabled {
+ opacity: 1 !important;
+}
+.oa-btn-loading.is-loading {
+ pointer-events: none;
+}
+.oa-btn-loading.is-loading .oa-btn-loading__label {
+ opacity: 0.25;
+}
+.oa-btn-loading__spin {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ background: rgba(0, 0, 0, 0.06);
+ pointer-events: none;
+}
+.oa-btn-loading__spin::before {
+ content: '';
+ width: 16px;
+ height: 16px;
+ border: 2px solid currentColor;
+ border-right-color: transparent;
+ border-radius: 50%;
+ animation: oa-btn-loading-spin 0.7s linear infinite;
+}
+@keyframes oa-btn-loading-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/mobile-oa-h5/src/styles/oa-btn-loading.test.js b/mobile-oa-h5/src/styles/oa-btn-loading.test.js
new file mode 100644
index 00000000..70dbf182
--- /dev/null
+++ b/mobile-oa-h5/src/styles/oa-btn-loading.test.js
@@ -0,0 +1,18 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const dir = dirname(fileURLToPath(import.meta.url))
+const css = readFileSync(join(dir, 'oa-btn-loading.css'), 'utf8')
+
+assert.match(css, /\.oa-btn-loading\.is-loading\s+\.oa-btn-loading__label\s*\{[^}]*opacity:\s*0\.25/)
+assert.match(css, /\.oa-btn-loading__spin\s*\{/)
+assert.match(css, /oa-btn-loading-spin/)
+assert.match(css, /rgba\(0,\s*0,\s*0,\s*0\.06\)/)
+assert.match(
+ css,
+ /\.oa-btn-loading\.is-loading\.van-button--disabled\s*\{[^}]*opacity:\s*1\s*!important/
+)
+
+console.log('oa-btn-loading.css contract ok')
diff --git a/mobile-oa-h5/src/styles/oa-form.css b/mobile-oa-h5/src/styles/oa-form.css
index 3fe95d31..5f346ee1 100644
--- a/mobile-oa-h5/src/styles/oa-form.css
+++ b/mobile-oa-h5/src/styles/oa-form.css
@@ -1,4 +1,14 @@
/* form-create + Vant:上下布局与左对齐兜底 */
+.form-create-m {
+ /*
+ * 审批/查看等整表 disabled:Vant 默认灰字(text-color-3)难读,
+ * 改为正文色;仍禁止编辑,仅提升可读性。
+ */
+ --van-field-input-disabled-text-color: var(--text-primary, #0f291e);
+ --van-field-disabled-text-color: var(--text-primary, #0f291e);
+ --van-cell-disabled-color: var(--text-primary, #0f291e);
+}
+
.form-create-m .van-field__label--top {
width: 100%;
margin-bottom: 4px;
@@ -13,6 +23,23 @@
font-size: 13px;
min-height: 22px;
line-height: 22px;
+ color: var(--text-primary, #0f291e);
+}
+
+/*
+ * disabled input 仍可能被 -webkit-text-fill-color 压成灰字,显式覆盖。
+ */
+.form-create-m .van-field__control:disabled,
+.form-create-m .van-field--disabled .van-field__control {
+ color: var(--text-primary, #0f291e) !important;
+ -webkit-text-fill-color: var(--text-primary, #0f291e) !important;
+ opacity: 1;
+ cursor: default;
+}
+
+.form-create-m .van-field--disabled .van-field__label,
+.form-create-m .van-cell--disabled {
+ color: var(--text-primary, #0f291e);
}
.form-create-m .van-field__control::placeholder {
@@ -89,17 +116,7 @@
line-height: 22px;
}
-/*
- * 归属人 / 归属部门:不可改选时仍用 disabled 防点开,但展示色用正文色(勿灰字难读)。
- * Vant disabled 会设 -webkit-text-fill-color,须一并覆盖。
- */
-.form-create-m .oa-owner-field.is-locked .van-field__control,
-.form-create-m .oa-owner-field.is-locked .van-field__control:disabled {
- color: var(--text-primary, #0f291e) !important;
- -webkit-text-fill-color: var(--text-primary, #0f291e) !important;
- opacity: 1;
-}
-
+/* 归属人 / 归属部门锁定:箭头弱化,值色已由上方 disabled 规则统一为正文色 */
.form-create-m .oa-owner-field.is-locked .van-cell__right-icon {
color: #cbd5e1;
}
diff --git a/mobile-oa-h5/src/utils/download-attachment.js b/mobile-oa-h5/src/utils/download-attachment.js
new file mode 100644
index 00000000..aa34bd74
--- /dev/null
+++ b/mobile-oa-h5/src/utils/download-attachment.js
@@ -0,0 +1,45 @@
+/** 附件下载:优先后端预签名 URL,保证跨域场景保留原始文件名 */
+
+import { fetchAttachmentDownloadUrl } from '../api/oa.js'
+import { attachmentFullUrl } from './attachment.js'
+
+function openDownloadUrl(href, fileName) {
+ // 微信 web-view 对 a[download] 支持差;优先新开页打开带 Content-Disposition 的地址
+ const opened = window.open(href, '_blank')
+ if (opened) return
+ const a = document.createElement('a')
+ a.href = href
+ a.download = fileName || 'download'
+ a.target = '_blank'
+ a.rel = 'noopener'
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+}
+
+/**
+ * @param {{ fileUrl?: string, url?: string, fileName?: string, name?: string }} file
+ */
+export async function downloadAttachmentFile(file) {
+ const fileUrl = String(file?.fileUrl || file?.url || '').trim()
+ if (!fileUrl) throw new Error('文件地址无效')
+ const fileName =
+ String(file?.fileName || file?.name || '').trim() ||
+ fileUrl.split('/').pop() ||
+ 'download'
+
+ try {
+ const res = await fetchAttachmentDownloadUrl({ fileUrl, fileName })
+ const downloadUrl = String(res?.downloadUrl || '').trim()
+ if (downloadUrl) {
+ openDownloadUrl(downloadUrl, fileName)
+ return
+ }
+ } catch {
+ // 预签名失败时回退直连
+ }
+
+ const href = attachmentFullUrl(fileUrl) || fileUrl
+ if (!href) throw new Error('无法下载附件')
+ openDownloadUrl(href, fileName)
+}
diff --git a/mobile-oa-h5/src/views/OaDetail.vue b/mobile-oa-h5/src/views/OaDetail.vue
index eb0f87cb..c59b80c7 100644
--- a/mobile-oa-h5/src/views/OaDetail.vue
+++ b/mobile-oa-h5/src/views/OaDetail.vue
@@ -11,26 +11,26 @@
'oa-detail__body--mine-act': (canWithdraw || canCopyRestart) && !canApprove
}"
>
-
- {{ detail?.title || '审批详情' }}
-
- {{ statusText(detail.status) }}
-
-
-
-
暂无表单内容
+