Files
workspace/code/fms-workflow-refactor-backup-20260923/workflow/designer/api.js
T
2026-09-23 16:54:54 +08:00

251 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 流程设计器 —— 数据读取与流程服务接口。
*
* 分两类:
* 1. 只读查询走通用接口(/data/loaddata),不新增后端接口。读表没有「必须走流程服务」
* 的约束,直接查表最省事。
* 2. 草稿保存、校验、模拟、发布、停用走流程服务(《FMS工作流与审批设计》§13 /
* 《FMS审批流程交互重构设计》§6.3)。wf_* 的写入**不允许**走通用保存接口,
* 否则发布校验、版本快照、发布后不可改、幂等全部落空。
*
* 流程服务尚未实现,接口会以 404 失败。调用方用 isWorkflowServiceMissing 识别后
* 明确提示「流程服务未接入」,绝不能把失败伪装成保存成功(§12.7)。
*/
import { get, post, put } from '@/services/http'
import { loadDataApi } from '@/services/api'
import { rowsToWorkflowDraft } from './domain'
/**
* 流程编码会拼进查询条件里,先净化再做字符串拼接。
* 合法编码形如 expense_approval,只保留字母数字下划线连字符。
*/
function safeCode(value) {
return String(value ?? '').replace(/[^A-Za-z0-9_-]/g, '')
}
function quote(value) {
return `N'${String(value).replaceAll("'", "''")}'`
}
async function query(viewName, condition, orderBy, searchColumns) {
const response = await loadDataApi(viewName, condition, orderBy, searchColumns)
return response?.data || []
}
/* ----------------------------------------------------------------------------
流程服务可用性
---------------------------------------------------------------------------- */
/**
* 判断错误是否属于「流程服务未接入」。
* 后端没有对应映射时返回 404;服务完全不可达时 axios 给不出 status。
*/
export function isWorkflowServiceMissing(error) {
const code = error?.code
return code === 404 || code === -1 || code === undefined || code === null
}
/** 流程服务未接入时统一的提示文案 */
export const WORKFLOW_SERVICE_MISSING_MESSAGE =
'流程服务未接入:保存 / 校验 / 模拟 / 发布需要后端工作流服务(见《FMS审批流程交互重构设计》§6.3),当前只在本页内存中编辑'
/* ----------------------------------------------------------------------------
读取流程草稿
---------------------------------------------------------------------------- */
/**
* 按流程编码读回领域草稿。
* 版本选择:优先取已发布版本,没有则取版本号最大的那条(草稿也能打开来编辑)。
* @param {string} definitionId wf_definition.b_id
* @returns {Promise<object>} 领域草稿(形状见 domain.js)
* @throws {Error} 流程或版本不存在时抛出,调用方自行兜底
*/
export async function loadWorkflowDraft(definitionId) {
const code = safeCode(definitionId)
if (!code) throw new Error('流程编码为空')
const definitions = await query('wf_definition', `b_id = ${quote(code)}`, null)
if (!definitions.length) throw new Error(`流程「${definitionId}」不存在`)
const definition = definitions[0]
const versions = await query('wf_version', `b_definition_id = ${quote(code)}`, 'b_version_no DESC')
if (!versions.length) throw new Error(`流程「${definitionId}」还没有版本`)
const version = versions.find((row) => row.b_status === 'published') || versions[0]
const versionId = version.b_id
const [bindings, nodes, edges, actorRules] = await Promise.all([
query('wf_binding', `b_version_id = ${quote(versionId)}`, 'b_priority ASC, b_id ASC'),
query('wf_node', `b_version_id = ${quote(versionId)}`, 'b_xh ASC, b_node_key ASC'),
query('wf_edge', `b_version_id = ${quote(versionId)}`, 'b_priority ASC, b_id ASC'),
query('wf_actor_rule', `b_version_id = ${quote(versionId)}`, 'b_priority ASC, b_id ASC'),
])
return rowsToWorkflowDraft({ definition, version, bindings, nodes, edges, actorRules })
}
/**
* 流程定义下的版本列表(版本记录页签、列表页的版本记录弹窗)。
* 走 v_wf_version 视图:节点数、定义名、状态中文都是视图派生列,不用前端再算一遍。
*/
export async function loadVersionList(definitionId) {
const code = safeCode(definitionId)
if (!code) return []
return query('v_wf_version', `b_definition_id = ${quote(code)}`, 'b_version_no DESC')
}
/* ----------------------------------------------------------------------------
选择器数据源(§8.4)
---------------------------------------------------------------------------- */
/**
* 可绑定流程的业务模块。
* 只取有保存表的 data / virtual 模块:没有保存表的模块(纯视图)不会产生单据,
* 也就不需要送审;工作流自己的模块(/workflow/ 路径下)不能绑定自己。
*/
export async function loadBindableModules() {
const condition =
"b_canuse = 1 and b_module_type in ('data','virtual') and b_save_table is not null and b_path not like '/workflow/%'"
return query('s_module', condition, 'b_path ASC, b_xh ASC, b_id ASC', [
'b_id',
'b_name',
'b_path',
'b_module_type',
'b_save_table',
])
}
/**
* 模块字段清单(条件字段、业务字段用户、业务状态字段都用它)。
* 字段编码与显示名来自 s_field,是条件 AST 里 field 的合法取值来源。
*/
export async function loadModuleFields(moduleId) {
const code = safeCode(moduleId)
if (!code) return []
return query('s_field', `b_module_id = ${quote(code)} and b_canuse = 1`, 'b_xh ASC, b_field ASC', [
'b_field',
'b_name',
'b_type',
'b_i18n',
])
}
/**
* 用户选择器的候选用户。
* 直接读 b_user 而不是 v_b_user —— 后者带明文密码列,选择器不需要也不该拿到。
*/
export async function loadUserOptions(keyword = '') {
const text = String(keyword ?? '').trim().replace(/[%_[\]^]/g, '')
const condition = text
? `b_canuse = 1 and (b_id like N'%${text}%' or b_name like N'%${text}%')`
: 'b_canuse = 1'
return query('b_user', condition, 'b_id ASC', ['b_id', 'b_name', 'b_dept_id'])
}
/** 部门清单:给用户选择器补部门名、给「部门负责人」来源做展示 */
export async function loadDeptOptions() {
return query('b_dept', 'b_canuse = 1', 'b_path ASC, b_xh ASC, b_id ASC', ['b_id', 'b_name'])
}
/**
* 用户选择器候选项(编码 + 姓名 + 部门名)。
* 整份清单只拉一次并在整个设计器内共享:Select 自带本地筛选,够用且少一次往返。
* 失败不缓存,下次调用重新尝试。
*/
let userOptionCache = null
let userOptionPromise = null
export function loadUserOptionCache() {
if (userOptionCache) return Promise.resolve(userOptionCache)
if (!userOptionPromise) {
userOptionPromise = (async () => {
const [users, depts] = await Promise.all([loadUserOptions(), loadDeptOptions()])
const deptName = new Map(depts.map((row) => [row.b_id, row.b_name]))
return users.map((row) => {
const name = row.b_name || row.b_id
const dept = row.b_dept_id ? deptName.get(row.b_dept_id) || row.b_dept_id : ''
return { value: row.b_id, label: dept ? `${name}(${dept})` : name }
})
})()
userOptionPromise = userOptionPromise.catch((error) => {
userOptionPromise = null
throw error
})
}
return userOptionPromise.then((options) => {
userOptionCache = options
return options
})
}
/** 测试与主体切换时清缓存(避免换机构后还显示上一个机构的人) */
export function clearWorkflowCaches() {
userOptionCache = null
userOptionPromise = null
}
/** 组织角色 / 岗位主数据尚未建表(见 §7.4 缺口清单),只能返回空列表并提示手动填编码 */
export const ORG_MASTER_DATA_MISSING = {
position: '岗位主数据(s_position)尚未建表',
org_role: '组织角色主数据尚未建表',
}
/* ----------------------------------------------------------------------------
流程服务(§6.3)
---------------------------------------------------------------------------- */
/** 读取草稿(带绑定),流程服务版本 */
export function fetchDraftApi(definitionId) {
return get(`/workflow/definitions/${encodeURIComponent(safeCode(definitionId))}/draft`)
}
/** 新建流程定义与初始草稿 */
export function createDefinitionApi(payload) {
return post('/workflow/definitions', payload)
}
/**
* 保存草稿:事务内替换该版本的节点 / 连线 / 规则与画布布局。
* expectedUpdatedAt 用于并发保护(§7.3,两种方案只选一种,这里用 b_updated_at)。
*/
export function saveDraftApi(versionId, { draft, expectedUpdatedAt, requestId }) {
return put(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/draft`, {
draft,
expected_updated_at: expectedUpdatedAt ?? null,
request_id: requestId || '',
})
}
/** 服务端校验(前端校验只是体验,这里才是最终规则) */
export function validateVersionApi(versionId, draft) {
return post(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/validate`, { draft })
}
/** 发布前模拟:输入业务样例,返回命中的绑定、条件、解析出的审批人与待办 */
export function simulateVersionApi(versionId, sample) {
return post(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/simulate`, { sample })
}
/** 发布:固定版本摘要,写发布人与发布时间,之后禁止原地编辑 */
export function publishVersionApi(versionId, { requestId, expectedUpdatedAt } = {}) {
return post(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/publish`, {
request_id: requestId || '',
expected_updated_at: expectedUpdatedAt ?? null,
})
}
/** 停用:不再接受新实例,运行中的实例继续走完 */
export function retireVersionApi(versionId) {
return post(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/retire`, {})
}
/** 版本下的绑定列表 */
export function fetchBindingsApi(versionId) {
return get(`/workflow/versions/${encodeURIComponent(safeCode(versionId))}/bindings`)
}
/** 幂等键:重复点击保存 / 发布时后端据此去重(§12.1) */
export function createRequestId(prefix = 'req') {
const random = Math.random().toString(36).slice(2, 10)
return `${prefix}-${Date.now()}-${random}`
}