This commit is contained in:
zlwu committed 2026-07-21 15:23:59 +08:00
1 parent 417d6c9c51
commit c8fe0cdc20
102 files changed
+19847 -12224

No files matched your search

@@ -1,6 +1,6 @@
<!-- 协议附件 — 迷你缩略图只读(卡片列表用) -->
<template>
<div class="protocol-attachment-mini">
<div class="protocol-attachment-mini" :class="{ 'is-multi': files.length > 1 }">
<div v-if="label" class="protocol-attachment-mini__label">{{ label }}</div>
<span v-if="!files.length" class="protocol-attachment-mini__empty">{{ emptyText }}</span>
<div v-else class="protocol-attachment-mini__list">
@@ -22,12 +22,12 @@
</template>
<template v-else-if="isVideoFile(file)">
<div class="protocol-attachment-mini__icon protocol-attachment-mini__icon--video">
<el-icon :size="20"><VideoPlay /></el-icon>
<el-icon :size="files.length > 1 ? 14 : 20"><VideoPlay /></el-icon>
</div>
</template>
<template v-else>
<div class="protocol-attachment-mini__icon protocol-attachment-mini__icon--doc">
<el-icon :size="20"><Document /></el-icon>
<el-icon :size="files.length > 1 ? 14 : 20"><Document /></el-icon>
</div>
</template>
</div>
@@ -219,4 +219,42 @@
color: #fff;
}
}
/* 多附件时缩小缩略图,便于在卡片内并排展示 */
.protocol-attachment-mini.is-multi {
.protocol-attachment-mini__list {
gap: 4px;
}
.protocol-attachment-mini__item {
width: 48px;
border-radius: 4px;
}
.protocol-attachment-mini__preview {
height: 36px;
}
.protocol-attachment-mini__icon {
width: 22px;
height: 22px;
border-radius: 6px;
}
.protocol-attachment-mini__footer {
padding: 2px 3px;
gap: 1px;
}
.protocol-attachment-mini__name {
font-size: 9px;
}
.protocol-attachment-mini__download {
width: 14px;
height: 14px;
font-size: 9px;
border-radius: 3px;
}
}
</style>
@@ -0,0 +1,10 @@
<!-- 运行时栅格行:对齐设计器 FcRow(._fd-row flex),间距由 oa-fc-runtime.scss 提供 -->
<template>
<div class="_fd-row el-row" v-bind="$attrs">
<slot />
</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'FcRow', inheritAttrs: false })
</script>
@@ -1,11 +1,14 @@
<!-- 发起审批 · 表单底部审批流预览(对齐 docs/mockups/oa-approval-start-flow-preview-demo.html) -->
<template>
<div class="oa-flow-preview">
<div v-loading="loading" class="oa-flow-preview">
<div class="oa-flow-preview__title">
<span>审批流程</span>
<span v-if="hint" class="oa-flow-preview__hint">{{ hint }}</span>
<span v-if="resolved && hint" class="oa-flow-preview__hint">{{ hint }}</span>
</div>
<div v-loading="loading" class="oa-flow-preview__chain">
<div v-if="!resolved" class="oa-flow-preview__pending">
{{ pendingMessage || '必填信息填写后,流程将自动显示' }}
</div>
<div v-else class="oa-flow-preview__chain">
<div v-if="!loading && !steps.length" class="oa-flow-preview__empty">暂无流程配置</div>
<div v-for="(s, idx) in steps" :key="s.id || idx" class="oa-flow-step">
<div class="oa-flow-rail">
@@ -49,15 +52,20 @@
steps?: OaFlowPreviewStep[]
loading?: boolean
hint?: string
resolved?: boolean
pendingMessage?: string
}>(),
{
steps: () => [],
loading: false,
hint: '按当前发起人解析审批人'
hint: '按当前发起人解析审批人',
resolved: true,
pendingMessage: '必填信息填写后,流程将自动显示'
}
)
const steps = computed(() => props.steps || [])
const resolved = computed(() => props.resolved !== false)
function signBadge(s: OaFlowPreviewStep) {
if (s.type === 'cc' || s.signMode === 'cc') return 'cc'
@@ -105,6 +113,14 @@
font-weight: 400;
}
.oa-flow-preview__pending {
padding: 16px 8px 20px;
color: var(--mint-text-muted, #94a3b8);
font-size: 13px;
line-height: 1.5;
text-align: center;
}
.oa-flow-preview__empty {
padding: 8px 0;
color: var(--mint-text-muted, #94a3b8);
@@ -29,6 +29,8 @@
const frameRef = ref<HTMLIFrameElement | null>(null)
const frameReady = ref(false)
let reqSeq = 0
/** 最近一次推入 iframe 的 schema JSON,用于避免保存回写后重复 setRule 清空画布 */
let lastPushedJson = ''
const pending = new Map<
number,
{ resolve: (v: { rule: unknown; option: unknown }) => void; reject: (e: Error) => void }
@@ -62,6 +64,10 @@
}
}
function schemaJson(schema: unknown) {
return JSON.stringify(toPlainSchema(schema))
}
function postToFrame(payload: Record<string, unknown>) {
try {
frameRef.value?.contentWindow?.postMessage(payload, '*')
@@ -70,11 +76,16 @@
}
}
function pushSchemaToFrame() {
/** force=true 时即使内容相同也重新 setRule(用于切回表单 Tab 强制回显) */
function pushSchemaToFrame(force = false) {
if (!frameReady.value) return
const schema = toPlainSchema(props.modelValue)
const json = JSON.stringify(schema)
if (!force && json === lastPushedJson) return
lastPushedJson = json
postToFrame({
type: MSG.SET,
schema: toPlainSchema(props.modelValue)
schema
})
}
@@ -88,7 +99,7 @@
function onFrameLoad() {
if (frameReady.value) {
pushSchemaToFrame()
pushSchemaToFrame(true)
pushFlowRefs()
}
}
@@ -98,11 +109,13 @@
if (!data || typeof data !== 'object') return
if (data.type === MSG.READY) {
frameReady.value = true
pushSchemaToFrame()
lastPushedJson = ''
pushSchemaToFrame(true)
pushFlowRefs()
// 宿主刚就绪时再补推一次,避免 setRule 早于内部 dragForm 初始化
window.setTimeout(() => pushSchemaToFrame(), 50)
window.setTimeout(() => pushSchemaToFrame(), 200)
// 宿主刚就绪时再补推,避免 setRule 早于内部 dragForm / 菜单初始化
window.setTimeout(() => pushSchemaToFrame(true), 50)
window.setTimeout(() => pushSchemaToFrame(true), 200)
window.setTimeout(() => pushSchemaToFrame(true), 500)
return
}
if (data.type === MSG.SCHEMA && typeof data.requestId === 'number') {
@@ -110,6 +123,8 @@
if (!wait) return
pending.delete(data.requestId)
const schema = toPlainSchema(data.schema)
// 与父级即将回写的内容对齐,避免 watch 再次 setRule
lastPushedJson = JSON.stringify(schema)
wait.resolve(schema)
}
}
@@ -136,12 +151,17 @@
})
}
/** 切回「表单设计」Tab 时强制把父级 schema 灌回画布 */
function reapplySchema() {
pushSchemaToFrame(true)
pushFlowRefs()
}
watch(
() => props.modelValue,
() => schemaJson(props.modelValue),
() => {
pushSchemaToFrame()
},
{ deep: true }
pushSchemaToFrame(false)
}
)
watch(
@@ -162,7 +182,7 @@
pending.clear()
})
defineExpose({ fetchSchema })
defineExpose({ fetchSchema, reapplySchema })
</script>
<style scoped lang="scss">
@@ -8,10 +8,7 @@
<div v-if="!items.length" class="oa-flow-preview__empty">暂无任务记录</div>
<div v-for="(item, idx) in items" :key="item.key" class="oa-flow-step">
<div class="oa-flow-rail">
<div
class="oa-flow-dot"
:class="dotClass(item)"
/>
<div class="oa-flow-dot" :class="dotClass(item)" />
<div v-if="idx < items.length - 1" class="oa-flow-line" />
</div>
<div class="oa-flow-card">
@@ -20,7 +17,11 @@
<span v-if="statusBadge(item)" class="oa-flow-badge" :class="statusBadge(item)">
{{ statusText(item.status) }}
</span>
<span v-if="item.taskType === 'cc'" class="oa-flow-badge cc">抄送</span>
<span v-if="signBadge(item)" class="oa-flow-badge" :class="signBadge(item)">
{{ signBadgeText(item) }}
</span>
<span v-else-if="item.taskType === 'cc'" class="oa-flow-badge cc">抄送</span>
<span v-else-if="item.taskType === 'end'" class="oa-flow-badge mute">结束</span>
<template v-if="item.timestamp">
<span class="oa-flow-card__sep">·</span>
<span class="oa-flow-card__meta" :title="item.timestamp">{{ item.timestamp }}</span>
@@ -32,12 +33,21 @@
</span>
</template>
<span
v-if="item.assignee && item.kind !== 'pending_node' && item.kind !== 'terminal'"
v-if="displayPeople(item).length && item.kind !== 'terminal'"
class="oa-flow-card__people"
>
<span class="oa-person-chip">
<span class="oa-person-chip__dot">{{ avatarChar(item.assignee) }}</span>
{{ item.assignee }}
<span
v-for="p in displayPeople(item)"
:key="p.id || p.nickName"
class="oa-person-chip"
:class="personChipClass(p.status)"
:title="personTitle(p)"
>
<span class="oa-person-chip__dot">{{ avatarChar(p.nickName) }}</span>
{{ p.nickName }}
<span v-if="personStatusHint(p.status)" class="oa-person-chip__st">
{{ personStatusHint(p.status) }}
</span>
</span>
</span>
</div>
@@ -53,6 +63,15 @@
<script setup lang="ts">
defineOptions({ name: 'OaProgressChain' })
export type OaProgressPerson = {
id?: string
nickName?: string
status?: string
timestamp?: string
commentText?: string
taskId?: string
}
export type OaProgressChainItem = {
key: string
title: string
@@ -63,6 +82,8 @@
commentText?: string
taskType?: string
kind?: string
signMode?: string
people?: OaProgressPerson[]
}
withDefaults(
@@ -100,6 +121,59 @@
return ''
}
function signBadge(item: OaProgressChainItem) {
if (item.taskType === 'cc' || item.signMode === 'cc') return 'cc'
if (item.signMode === 'and') return 'and'
if (item.signMode === 'or') return 'or'
return ''
}
function signBadgeText(item: OaProgressChainItem) {
const k = signBadge(item)
if (k === 'cc') return '抄送'
if (k === 'and') return '会签'
if (k === 'or') return '或签'
return ''
}
function displayPeople(item: OaProgressChainItem): OaProgressPerson[] {
const list = Array.isArray(item.people) ? item.people.filter((p) => p?.nickName || p?.id) : []
if (list.length) {
return list.map((p) => ({
...p,
nickName: (p.nickName || p.id || '').trim()
}))
}
const name = (item.assignee || '').trim()
if (!name) return []
return [{ nickName: name, status: item.status }]
}
function personChipClass(status?: string) {
const s = status || ''
if (s === 'approved' || s === 'completed') return 'is-ok'
if (s === 'pending' || s === 'running') return 'is-pending'
if (s === 'rejected' || s === 'returned' || s === 'cancelled') return 'is-danger'
if (s === 'done' || s === 'not_reached') return 'is-mute'
return ''
}
function personStatusHint(status?: string) {
const s = status || ''
if (s === 'approved') return '已同意'
if (s === 'rejected') return '已拒绝'
if (s === 'returned') return '已打回'
if (s === 'pending' || s === 'running') return '待处理'
if (s === 'done') return '无需处理'
if (s === 'not_reached') return '未到达'
return ''
}
function personTitle(p: OaProgressPerson) {
const parts = [p.nickName || '', statusText(p.status || ''), p.timestamp || ''].filter(Boolean)
return parts.join(' · ')
}
function dotClass(item: OaProgressChainItem) {
const s = item.status || ''
if (s === 'approved' || s === 'completed' || s === 'done' || s === 'submitted') return 'ok'
@@ -275,6 +349,42 @@
white-space: nowrap;
background: #f1f5f9;
border-radius: 999px;
&.is-ok {
color: #059669;
background: #ecfdf5;
.oa-person-chip__dot {
background: var(--primary, #10b981);
}
}
&.is-pending {
color: #d97706;
background: #fff7ed;
.oa-person-chip__dot {
background: #d97706;
}
}
&.is-danger {
color: #dc2626;
background: #fef2f2;
.oa-person-chip__dot {
background: #ef4444;
}
}
&.is-mute {
color: #94a3b8;
background: #f8fafc;
.oa-person-chip__dot {
background: #94a3b8;
}
}
}
.oa-person-chip__dot {
@@ -289,6 +399,12 @@
border-radius: 50%;
}
.oa-person-chip__st {
margin-left: 2px;
font-size: 10px;
opacity: 0.85;
}
.oa-flow-badge {
flex-shrink: 0;
padding: 0 5px;
@@ -308,6 +424,11 @@
background: #fff7ed;
}
&.and {
color: #2563eb;
background: #eff6ff;
}
&.danger {
color: #dc2626;
background: #fef2f2;
@@ -68,6 +68,16 @@
:disabled="disabled"
@update:model-value="(v) => onValueChange(idx, coerceValue(rule.field, v))"
/>
<el-button
class="cond-rule-editor__del"
text
type="danger"
:disabled="disabled || localRules.length <= 1"
title="删除条件"
@click="removeRule(idx)"
>
<el-icon><Delete /></el-icon>
</el-button>
</div>
<div v-if="idx < localRules.length - 1" class="cond-rule-editor__and">且(AND)</div>
</div>
@@ -86,7 +96,7 @@
<script setup lang="ts">
import { computed, watch } from 'vue'
import { Plus } from '@element-plus/icons-vue'
import { Delete, Plus } from '@element-plus/icons-vue'
import {
opsForFieldType,
type ConditionCandidate,
@@ -207,6 +217,12 @@
}
])
}
function removeRule(idx: number) {
if (localRules.value.length <= 1) return
const next = localRules.value.filter((_, i) => i !== idx)
emitRules(next)
}
</script>
<style scoped lang="scss">
@@ -230,11 +246,16 @@
.cond-rule-editor__row {
display: grid;
grid-template-columns: 1fr auto 1fr;
grid-template-columns: 1fr auto 1fr auto;
gap: 6px;
align-items: center;
margin-bottom: 6px;
}
.cond-rule-editor__del {
padding: 4px;
}
.cond-rule-editor__and {
margin: 0 0 6px;
color: var(--mint-text-muted, #94a3b8);
@@ -36,8 +36,23 @@
<div class="flow-cond-col__title" @click.stop="emit('edit', node)">
{{ branchTitle(g, idx) }}
</div>
<!-- 分支仅有结束节点时,先露出「+」才能往分支内插入 -->
<el-dropdown
v-if="!g.child || g.child.type === 'end'"
trigger="click"
@command="(cmd: string) => onAddInGroup(idx, cmd)"
>
<button type="button" class="flow-plus" title="在本分支添加节点">+</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="approval">审批人</el-dropdown-item>
<el-dropdown-item command="cc">抄送人</el-dropdown-item>
<el-dropdown-item command="condition">条件分支</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<FlowBranch
v-if="g.child"
v-if="g.child && g.child.type !== 'end'"
:node="g.child"
:depth="depth + 1"
:candidates="candidates"
@@ -48,6 +63,28 @@
<div v-else class="flow-end">流程结束</div>
</div>
</div>
<!-- 条件分支汇合后继续主链 -->
<el-dropdown trigger="click" @command="(cmd: string) => onAdd(cmd)">
<button type="button" class="flow-plus" title="在条件分支后添加节点">+</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="approval">审批人</el-dropdown-item>
<el-dropdown-item command="cc">抄送人</el-dropdown-item>
<el-dropdown-item command="condition">条件分支</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<FlowBranch
v-if="node.child"
:node="node.child"
:depth="depth"
:candidates="candidates"
@edit="(n) => emit('edit', n)"
@remove="(id) => emit('remove', id)"
@add="(payload) => emit('add', payload)"
/>
<div v-else class="flow-end">流程结束</div>
</template>
<template v-else>
@@ -102,7 +139,14 @@
const emit = defineEmits<{
edit: [node: FlowNode]
remove: [nodeId: string]
add: [payload: { parentId: string; type: 'approval' | 'cc' | 'condition' }]
add: [
payload: {
parentId: string
type: 'approval' | 'cc' | 'condition'
/** 写入条件节点的第 N 个分支;缺省则写入 parent.child(汇合后主链) */
groupIndex?: number
}
]
}>()
function typeLabel(t: string) {
@@ -134,6 +178,12 @@
emit('add', { parentId: props.node.id, type: cmd })
}
}
function onAddInGroup(groupIndex: number, cmd: string) {
if (cmd === 'approval' || cmd === 'cc' || cmd === 'condition') {
emit('add', { parentId: props.node.id, type: cmd, groupIndex })
}
}
</script>
<style scoped lang="scss">
@@ -459,10 +459,35 @@
return null
}
function onAdd(payload: { parentId: string; type: 'approval' | 'cc' | 'condition' }) {
function onAdd(payload: {
parentId: string
type: 'approval' | 'cc' | 'condition'
groupIndex?: number
}) {
const parent = findNode(root.value, payload.parentId)
if (!parent) return
const neu = createNode(payload.type)
// 写入条件节点的某一分支
if (
parent.type === 'condition' &&
typeof payload.groupIndex === 'number' &&
parent.conditionGroups?.[payload.groupIndex]
) {
const g = parent.conditionGroups[payload.groupIndex]
const old = g.child || createEndNode()
if (payload.type === 'condition') {
const groups = neu.conditionGroups || []
const def = groups.find((x) => x.isDefault) || groups[groups.length - 1]
if (def) def.child = old
} else {
neu.child = old
}
g.child = neu
commit()
return
}
const old = parent.child || createEndNode()
if (payload.type === 'condition') {
const groups = neu.conditionGroups || []
@@ -1,5 +1,6 @@
/** 允许「参与流程条件分支」的 FormCreate type */
export const CONDITION_ELIGIBLE_TYPES = new Set([
'input',
'inputNumber',
'number',
'money',
@@ -20,6 +21,7 @@ export interface ConditionCandidate {
}
export function opsForFieldType(type: string): Array<{ value: ConditionOp; label: string }> {
// 单选/下拉为离散选项,仅等于/不等于;输入框常填数字参与区间比较,与数字/金额/日期同套运算符
if (type === 'radio' || type === 'select') {
return [
{ value: '==', label: '等于' },