做双端审批流

This commit is contained in:
hero920103 committed 2026-10-07 13:46:32 +08:00
1 parent 5b4dbb2b46
commit ffaadc26ae
69 files changed
+6662 -1071

No files matched your search

+1
View File
@@ -83,6 +83,7 @@ declare module 'vue' {
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
FlowCanvas: typeof import('./src/components/approval/FlowCanvas.vue')['default']
MessageDetailDialog: typeof import('./src/components/MessageDetailDialog.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
+39
View File
@@ -12,6 +12,9 @@
"@turbodocx/html-to-docx": "^1.22.2",
"@types/qrcode": "^1.5.6",
"@umoteam/editor": "^10.2.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"axios": "^1.13.1",
"chart": "^0.1.2",
"chart.js": "^4.5.1",
@@ -3062,6 +3065,42 @@
"vue": "^3.2.25"
}
},
"node_modules/@vue-flow/background": {
"version": "1.3.2",
"resolved": "https://registry.npmmirror.com/@vue-flow/background/-/background-1.3.2.tgz",
"integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==",
"license": "MIT",
"peerDependencies": {
"@vue-flow/core": "^1.23.0",
"vue": "^3.3.0"
}
},
"node_modules/@vue-flow/controls": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/@vue-flow/controls/-/controls-1.1.3.tgz",
"integrity": "sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==",
"license": "MIT",
"peerDependencies": {
"@vue-flow/core": "^1.23.0",
"vue": "^3.3.0"
}
},
"node_modules/@vue-flow/core": {
"version": "1.48.2",
"resolved": "https://registry.npmmirror.com/@vue-flow/core/-/core-1.48.2.tgz",
"integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==",
"license": "MIT",
"dependencies": {
"@vueuse/core": "^10.5.0",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
},
"peerDependencies": {
"vue": "^3.3.0"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.29",
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
+3
View File
@@ -13,6 +13,9 @@
"@turbodocx/html-to-docx": "^1.22.2",
"@types/qrcode": "^1.5.6",
"@umoteam/editor": "^10.2.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"axios": "^1.13.1",
"chart": "^0.1.2",
"chart.js": "^4.5.1",
+54
View File
@@ -0,0 +1,54 @@
import request from '@/utils/request';
// ==================== 租户端审批流配置 ====================
// 编排数据源:当前租户的功能模块 + 审批人候选(用户/角色/部门/职位)
export function getApprovalOptions() {
return request({
url: '/backend/approval/options',
method: 'get',
});
}
// 流程列表(按功能模块过滤,module 即 biz_type)
export function getApprovalFlowList(params) {
return request({
url: '/backend/approval/flow/list',
method: 'get',
params,
});
}
// 流程详情(含全部节点)
export function getApprovalFlowDetail(id) {
return request({
url: `/backend/approval/flow/detail/${id}`,
method: 'get',
});
}
// 保存流程(新建或更新,节点全量覆盖)
export function saveApprovalFlow(data) {
return request({
url: '/backend/approval/flow/save',
method: 'post',
data,
});
}
// 删除流程
export function deleteApprovalFlow(id) {
return request({
url: `/backend/approval/flow/delete/${id}`,
method: 'delete',
});
}
// 审批流编号轻量清单(按分类返回启用流程的 flow_code/flow_name,供其它功能按编号调用)
export function getApprovalFlowPick(params) {
return request({
url: '/backend/approval/flow/pick',
method: 'get',
params,
});
}
-9
View File
@@ -109,15 +109,6 @@ export function getCustomDomainGuide() {
})
}
// 审核租户域名
export function auditTenantDomain(data) {
return request({
url: '/backend/domain/tenant/audit',
method: 'post',
data
})
}
// 禁用/启用租户域名
export function toggleTenantDomainStatus(id) {
return request({
+2 -2
View File
@@ -51,13 +51,13 @@ export function deleteOnePage(id) {
}
/**
* 根据路径获取单页(前端使用)
* 根据路径获取单页(后端租户端独立接口)
* @param {string} path 路由路径
* @returns {Promise}
*/
export function getOnePageByPath(path) {
return request({
url: `/index/onepage/${encodeURIComponent(path)}`,
url: `/backend/onepage/path/${encodeURIComponent(path)}`,
method: "get",
});
}
-39
View File
@@ -1,39 +0,0 @@
import request from '@/utils/request'
// 平台端角色管理(yz_system_admin_role,可同时管理平台角色 cid=1 与租户角色 cid=2)
export function getAllPlatformRoles() {
return request({
url: '/platform/allRoles',
method: 'get'
})
}
export function getPlatformRoleById(id) {
return request({
url: `/platform/roles/${id}`,
method: 'get'
})
}
export function createPlatformRole(data) {
return request({
url: '/platform/roles',
method: 'post',
data
})
}
export function updatePlatformRole(id, data) {
return request({
url: `/platform/roles/${id}`,
method: 'put',
data
})
}
export function deletePlatformRole(id) {
return request({
url: `/platform/roles/${id}`,
method: 'delete'
})
}
+7 -6
View File
@@ -1,9 +1,10 @@
import request from '@/utils/request'
// 平台端租户用户(yz_system_tenant_user)相关接口
// 租户后台用户管理(yz_system_tenant_user),走后端租户端 /backend/* 独立接口
// (BackendAdminUserController 直接操作 SystemTenantUser,租户隔离由 JWT 完成)
export function getTenantUserList(params) {
return request({
url: '/platform/tenantUser/list',
url: '/backend/getAllUsers',
method: 'get',
params,
})
@@ -11,14 +12,14 @@ export function getTenantUserList(params) {
export function getTenantUsersByTid(tid) {
return request({
url: `/platform/getTenantUsers/${tid}`,
url: `/backend/getTenantUsers/${tid}`,
method: 'get',
})
}
export function createTenantUser(data) {
return request({
url: '/platform/tenantUser/create',
url: '/backend/addUser',
method: 'post',
data,
})
@@ -26,7 +27,7 @@ export function createTenantUser(data) {
export function editTenantUser(id, data) {
return request({
url: `/platform/tenantUser/edit/${id}`,
url: `/backend/editUser/${id}`,
method: 'post',
data,
})
@@ -34,7 +35,7 @@ export function editTenantUser(id, data) {
export function deleteTenantUser(id) {
return request({
url: `/platform/tenantUser/delete/${id}`,
url: `/backend/deleteUser/${id}`,
method: 'delete',
})
}
@@ -0,0 +1,562 @@
<template>
<div class="flow-canvas">
<!-- 左侧节点面板 -->
<div class="palette">
<div class="palette-title">节点面板</div>
<div
v-for="p in palette"
:key="p.type"
class="palette-item"
:class="'pal-' + p.cls"
draggable="true"
@dragstart="onDragStart($event, p)"
>
<el-icon class="pal-icon"><component :is="p.icon" /></el-icon>
<span>{{ p.label }}</span>
</div>
<div class="palette-tip">
拖入画布添加节点;从节点右侧圆点拖到目标节点连线;双击节点配置;点击连线设置分支条件。
</div>
</div>
<!-- 画布 -->
<div ref="canvasWrap" class="canvas-wrap" @drop="onDrop" @dragover.prevent @dragenter.prevent>
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:default-viewport="{ zoom: 1 }"
:min-zoom="0.4"
:max-zoom="2"
@node-double-click="onNodeDblClick"
@edge-click="onEdgeClick"
>
<template #node-flow="nodeProps">
<div class="fn" :class="'fn-' + nodeProps.data.cls">
<Handle type="target" :position="Position.Left" />
<div class="fn-badge">{{ typeShort(nodeProps.data.nodeType) }}</div>
<div class="fn-title">{{ nodeProps.data.name || '未命名' }}</div>
<div class="fn-sub">{{ nodeProps.data.summary }}</div>
<Handle type="source" :position="Position.Right" />
</div>
</template>
<Background />
<Controls />
</VueFlow>
</div>
<!-- 节点配置对话框 -->
<el-dialog v-model="nodeDialog" :title="nodeDialogTitle" width="560px" append-to-body>
<el-form :model="nodeForm" label-width="96px">
<el-form-item label="节点名称" required>
<el-input v-model="nodeForm.name" placeholder="如:部门主管审批" />
</el-form-item>
<template v-if="nodeForm.nodeType === 2">
<el-form-item label="审批人类型">
<el-radio-group v-model="nodeForm.approverType" @change="onApproverTypeChange">
<el-radio v-for="t in approverTypes" :key="t.value" :value="t.value">{{ t.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="currentApproverMeta && currentApproverMeta.needsSelection" :label="currentApproverMeta.selectLabel" required>
<el-select v-model="nodeForm.approverIds" multiple filterable style="width: 100%" :placeholder="'选择' + currentApproverMeta.selectLabel + '(可多选)'">
<el-option v-for="opt in currentPool" :key="opt.id" :label="opt.name" :value="opt.id" />
</el-select>
</el-form-item>
<el-form-item v-else label=" ">
<span class="hint">该类型运行期自动定位审批人,无需指定对象</span>
</el-form-item>
<el-form-item
v-if="currentApproverMeta && currentApproverMeta.needsSelection && nodeForm.approverIds && nodeForm.approverIds.length > 1"
label="多人方式"
>
<el-radio-group v-model="nodeForm.multiMode">
<el-radio :value="1">
或签
<el-tooltip placement="top" effect="dark">
<template #content>
<div class="mode-tip">
<div class="mode-tip-title">或签 (OR)</div>
<div>所选审批人任一人通过/驳回,即视为本节点完成;</div>
<div>其余待办任务将自动作废并推进至下一节点。</div>
</div>
</template>
<el-icon class="mode-tip-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</el-radio>
<el-radio :value="2">
会签
<el-tooltip placement="top" effect="dark">
<template #content>
<div class="mode-tip">
<div class="mode-tip-title">会签 (AND)</div>
<div>所选审批人须全部通过,本节点才算完成;</div>
<div>任一人驳回则本节点立即驳回,流程回退。</div>
</div>
</template>
<el-icon class="mode-tip-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="允许退回">
<el-switch v-model="nodeForm.allowReturn" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="超时(小时)">
<el-input-number v-model="nodeForm.timeoutHours" :min="1" :max="720" />
</el-form-item>
</template>
<el-form-item v-else-if="nodeForm.nodeType === 4" label=" ">
<span class="hint">条件网关:拖出多条连线,点击连线分别填写分支条件(留空为默认分支)。</span>
</el-form-item>
</el-form>
<template #footer>
<el-button type="danger" plain :disabled="!editingNodeId" @click="removeCurrentNode">删除节点</el-button>
<span style="flex: 1"></span>
<el-button @click="nodeDialog = false">取消</el-button>
<el-button type="primary" @click="confirmNode">确定</el-button>
</template>
</el-dialog>
<!-- 连线条件对话框 -->
<el-dialog v-model="edgeDialog" title="设置分支条件" width="460px" append-to-body>
<el-form label-width="88px">
<el-form-item label="条件表达式">
<el-input v-model="edgeForm.condition" placeholder="如 amount>5000 且 dept=='研发' ;留空为默认分支" />
</el-form-item>
<el-form-item label=" ">
<span class="hint">支持 &gt; &gt;= &lt; &lt;= == != 与 &amp;&amp; / ||。条件仅在“条件网关”的出边上生效。</span>
</el-form-item>
</el-form>
<template #footer>
<el-button type="danger" plain @click="removeCurrentEdge">删除连线</el-button>
<span style="flex: 1"></span>
<el-button @click="edgeDialog = false">取消</el-button>
<el-button type="primary" @click="confirmEdge">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { VideoPlay, User, Share, SwitchButton, QuestionFilled } from '@element-plus/icons-vue'
import { VueFlow, Handle, Position, MarkerType, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
const props = defineProps({
// { nodes: [payloadNode], edges: [payloadEdge] }
modelValue: { type: Object, default: () => ({ nodes: [], edges: [] }) },
// 审批人类型:[{ value, label, needsSelection, selectLabel, poolKey }]
approverTypes: { type: Array, default: () => [] },
// 候选池:{ [poolKey]: [{ id, name }] }
pools: { type: Object, default: () => ({}) },
})
const nodes = ref<any[]>([])
const edges = ref<any[]>([])
const canvasWrap = ref<HTMLElement | null>(null)
const { screenToFlowCoordinate, onConnect } = useVueFlow()
let keySeq = 1
const nextKey = () => `n${Date.now().toString(36)}${(keySeq++).toString(36)}`
const NODE_TYPE = { START: 1, APPROVAL: 2, END: 3, CONDITION: 4 }
const palette = [
{ type: NODE_TYPE.START, cls: 'start', label: '开始', icon: VideoPlay },
{ type: NODE_TYPE.APPROVAL, cls: 'approval', label: '审批节点', icon: User },
{ type: NODE_TYPE.CONDITION, cls: 'condition', label: '条件网关', icon: Share },
{ type: NODE_TYPE.END, cls: 'end', label: '结束', icon: SwitchButton },
]
const clsOf = (t: number) =>
t === NODE_TYPE.START ? 'start' : t === NODE_TYPE.END ? 'end' : t === NODE_TYPE.CONDITION ? 'condition' : 'approval'
const typeShort = (t: number) =>
t === NODE_TYPE.START ? '开始' : t === NODE_TYPE.END ? '结束' : t === NODE_TYPE.CONDITION ? '条件' : '审批'
const approverTypeLabel = (t: number) => {
const m = (props.approverTypes as any[]).find((x) => x.value === t)
return m ? m.label : '未知'
}
function approverSummary(node: any) {
if (node.nodeType === NODE_TYPE.APPROVAL) {
const meta = (props.approverTypes as any[]).find((x) => x.value === node.approverType)
if (!meta || !meta.needsSelection) return approverTypeLabel(node.approverType)
const pool = (props.pools as any)[meta.poolKey] || []
const names = (node.approverIds || []).map((id: number) => pool.find((p: any) => p.id === id)?.name).filter(Boolean)
const mode = node.multiMode === 2 ? '会签' : '或签'
return `${approverTypeLabel(node.approverType)}:${names.length ? names.join('、') : '未选'}(${mode})`
}
if (node.nodeType === NODE_TYPE.CONDITION) return '按分支条件路由'
return ''
}
// 由 payload 节点/边构建画布内部结构
function buildGraph(payload: any) {
const pNodes = payload?.nodes || []
const pEdges = payload?.edges || []
const ns = pNodes.map((n: any) => ({
id: n.node_key || nextKey(),
type: 'flow',
position: { x: n.pos_x || 0, y: n.pos_y || 0 },
data: makeNodeData(n),
}))
const es = pEdges
.filter((e: any) => e.from && e.to)
.map((e: any) => ({
id: `e_${e.from}_${e.to}`,
source: e.from,
target: e.to,
label: e.condition || '',
data: { condition: e.condition || '' },
markerEnd: { type: MarkerType.ArrowClosed },
animated: !!e.condition,
}))
return { ns, es }
}
function makeNodeData(n: any) {
const nodeType = n.node_type || n.nodeType || NODE_TYPE.APPROVAL
const data = {
nodeType,
cls: clsOf(nodeType),
name: n.node_name || n.name || '',
approverType: n.approver_type ?? n.approverType ?? 1,
approverIds: n.approver_ids ?? n.approverIds ?? [],
multiMode: n.multi_mode ?? n.multiMode ?? 1,
allowReturn: n.allow_return ?? n.allowReturn ?? 0,
timeoutHours: n.timeout_hours ?? n.timeoutHours ?? 48,
}
data.summary = approverSummary(data)
return data
}
// 初始化 / 外部切换流程时重建
function reload() {
const { ns, es } = buildGraph(props.modelValue || {})
// 避免坐标全 0 重叠:无坐标时自动铺开
let autoX = 60
let autoY = 60
ns.forEach((n: any) => {
if (!n.position.x && !n.position.y) {
n.position = { x: autoX, y: autoY }
autoY += 140
}
})
nodes.value = ns
edges.value = es
void autoX
}
watch(() => props.modelValue, reload, { deep: false, immediate: true })
// ==================== 拖拽添加 ====================
let dragType = 0
function onDragStart(_evt: DragEvent, p: any) {
dragType = p.type
}
function onDrop(evt: DragEvent) {
if (!dragType) return
const position = screenToFlowCoordinate({ x: evt.clientX, y: evt.clientY })
const data = makeNodeData({ node_type: dragType, node_name: defaultName(dragType) })
nodes.value.push({ id: nextKey(), type: 'flow', position, data })
dragType = 0
}
function defaultName(t: number) {
return t === NODE_TYPE.START ? '开始' : t === NODE_TYPE.END ? '结束' : t === NODE_TYPE.CONDITION ? '条件网关' : '审批节点'
}
// ==================== 连线 ====================
onConnect((params: any) => {
const exists = edges.value.some((e) => e.source === params.source && e.target === params.target)
if (exists) return
edges.value.push({
id: `e_${params.source}_${params.target}`,
source: params.source,
target: params.target,
label: '',
data: { condition: '' },
markerEnd: { type: MarkerType.ArrowClosed },
animated: false,
})
})
// ==================== 节点配置 ====================
const nodeDialog = ref(false)
const editingNodeId = ref('')
const nodeForm = reactive<any>({
name: '',
nodeType: NODE_TYPE.APPROVAL,
approverType: 1,
approverIds: [],
multiMode: 1,
allowReturn: 0,
timeoutHours: 48,
})
const nodeDialogTitle = computed(() => {
if (nodeForm.nodeType === NODE_TYPE.CONDITION) return '配置条件网关'
if (nodeForm.nodeType === NODE_TYPE.APPROVAL) return '配置审批节点'
return '配置节点'
})
const currentApproverMeta = computed(() =>
(props.approverTypes as any[]).find((x) => x.value === nodeForm.approverType) || null
)
const currentPool = computed(() => {
const meta = currentApproverMeta.value
return meta ? (props.pools as any)[meta.poolKey] || [] : []
})
function onNodeDblClick(evt: any) {
const node = evt.node
if (!node) return
editingNodeId.value = node.id
const d = node.data
Object.assign(nodeForm, {
name: d.name,
nodeType: d.nodeType,
approverType: d.approverType,
approverIds: [...(d.approverIds || [])],
multiMode: d.multiMode,
allowReturn: d.allowReturn,
timeoutHours: d.timeoutHours,
})
nodeDialog.value = true
}
function onApproverTypeChange() {
nodeForm.approverIds = []
}
function confirmNode() {
const node = nodes.value.find((n) => n.id === editingNodeId.value)
if (!node) {
nodeDialog.value = false
return
}
if (!String(nodeForm.name).trim()) {
ElMessage.warning('请填写节点名称')
return
}
const meta = currentApproverMeta.value
if (nodeForm.nodeType === NODE_TYPE.APPROVAL && meta && meta.needsSelection && !(nodeForm.approverIds || []).length) {
ElMessage.warning('请选择审批人')
return
}
node.data = makeNodeData({
node_type: nodeForm.nodeType,
node_name: nodeForm.name,
approver_type: nodeForm.approverType,
approver_ids: nodeForm.approverIds,
multi_mode: nodeForm.multiMode,
allow_return: nodeForm.allowReturn,
timeout_hours: nodeForm.timeoutHours,
})
nodeDialog.value = false
}
function removeCurrentNode() {
const id = editingNodeId.value
if (!id) return
nodes.value = nodes.value.filter((n) => n.id !== id)
edges.value = edges.value.filter((e) => e.source !== id && e.target !== id)
nodeDialog.value = false
}
// ==================== 连线条件 ====================
const edgeDialog = ref(false)
const editingEdgeId = ref('')
const edgeForm = reactive<any>({ condition: '' })
function onEdgeClick(evt: any) {
const edge = evt.edge
if (!edge) return
editingEdgeId.value = edge.id
edgeForm.condition = edge.data?.condition || ''
edgeDialog.value = true
}
function confirmEdge() {
const edge = edges.value.find((e) => e.id === editingEdgeId.value)
if (edge) {
edge.data = { condition: edgeForm.condition }
edge.label = edgeForm.condition || ''
edge.animated = !!edgeForm.condition
}
edgeDialog.value = false
}
function removeCurrentEdge() {
edges.value = edges.value.filter((e) => e.id !== editingEdgeId.value)
edgeDialog.value = false
}
// ==================== 对外序列化 ====================
function serialize() {
const outNodes = nodes.value.map((n: any, i: number) => ({
node_key: n.id,
node_name: n.data.name,
node_type: n.data.nodeType,
approver_type: n.data.approverType,
approver_ids: n.data.approverIds,
multi_mode: n.data.multiMode,
allow_return: n.data.allowReturn,
timeout_hours: n.data.timeoutHours,
sort: i + 1,
pos_x: Math.round(n.position.x),
pos_y: Math.round(n.position.y),
}))
const outEdges = edges.value.map((e: any) => ({
from: e.source,
to: e.target,
condition: e.data?.condition || '',
}))
return { nodes: outNodes, edges: outEdges }
}
async function fitView() {
await nextTick()
}
defineExpose({ serialize, fitView })
</script>
<style lang="less" scoped>
.flow-canvas {
display: flex;
height: 560px;
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
}
.palette {
width: 160px;
flex: 0 0 160px;
padding: 12px;
background: #fafafa;
border-right: 1px solid #e4e7ed;
.palette-title {
font-weight: 600;
margin-bottom: 10px;
font-size: 14px;
}
.palette-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
margin-bottom: 8px;
border-radius: 6px;
border: 1px solid #dcdfe6;
background: #fff;
cursor: grab;
font-size: 13px;
&:hover {
border-color: #409eff;
}
.pal-icon {
font-size: 15px;
}
}
.pal-start { border-left: 3px solid #67c23a; }
.pal-approval { border-left: 3px solid #409eff; }
.pal-condition { border-left: 3px solid #e6a23c; }
.pal-end { border-left: 3px solid #909399; }
.palette-tip {
margin-top: 10px;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
}
.canvas-wrap {
flex: 1;
position: relative;
background: #f7f8fb;
}
.fn {
min-width: 150px;
padding: 8px 12px;
border-radius: 8px;
background: #fff;
border: 1px solid #dcdfe6;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
font-size: 13px;
.fn-badge {
display: inline-block;
font-size: 11px;
padding: 0 6px;
border-radius: 8px;
color: #fff;
margin-bottom: 4px;
}
.fn-title {
font-weight: 600;
color: #303133;
}
.fn-sub {
margin-top: 2px;
color: #909399;
font-size: 12px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.fn-start { border-top: 3px solid #67c23a; .fn-badge { background: #67c23a; } }
.fn-approval { border-top: 3px solid #409eff; .fn-badge { background: #409eff; } }
.fn-condition { border-top: 3px solid #e6a23c; .fn-badge { background: #e6a23c; } }
.fn-end { border-top: 3px solid #909399; .fn-badge { background: #909399; } }
.hint {
font-size: 12px;
color: #909399;
}
</style>
<style lang="less">
/* 多人方式问号图标 + 气泡内容:因 el-dialog / el-tooltip 均 append-to-body,需非 scoped 样式 */
.el-radio .mode-tip-icon {
margin-left: 4px;
color: #909399;
cursor: help;
vertical-align: -2px;
transition: color 0.2s;
}
.el-radio .mode-tip-icon:hover {
color: #409eff;
}
.el-popper.is-dark .mode-tip {
font-size: 12px;
line-height: 1.7;
max-width: 260px;
}
.el-popper.is-dark .mode-tip .mode-tip-title {
font-weight: 600;
margin-bottom: 4px;
color: #ffd04b;
}
</style>
+7
View File
@@ -15,6 +15,13 @@ const staticMainChildren = [
component: () => import("@/views/dashboard/index.vue"),
meta: { requiresAuth: true, title: "工作台" }
},
// 审批流配置(租户侧):静态注册保证 URL 直达,菜单入口由数据库驱动
{
path: "/system/approval",
name: "BackendApprovalFlow",
component: () => import("@/views/system/approval/index.vue"),
meta: { requiresAuth: true, title: "审批流", modulePath: "/systemManager" }
},
// CMS 文章/产品/解决方案是系统内置功能,不依赖数据库菜单配置。
// 统一结构:index 为列表页,type 为分类页。
{
-296
View File
@@ -1,296 +0,0 @@
<template>
<div class="container-box">
<div class="header-bar">
<h2>租户域名审核</h2>
<el-button @click="fetchData" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
<el-divider></el-divider>
<!-- 搜索区域 -->
<div class="search-box">
<el-input
v-model="searchForm.sub_domain"
placeholder="搜索二级前缀或完整域名"
clearable
style="width: 220px; margin-right: 10px;"
@keyup.enter="handleSearch"
/>
<el-select v-model="searchForm.domain_type" placeholder="类型" clearable style="width: 150px; margin-right: 10px;">
<el-option label="平台二级域名" :value="1" />
<el-option label="自有域名" :value="2" />
</el-select>
<el-select v-model="searchForm.status" placeholder="状态" clearable style="width: 120px; margin-right: 10px;">
<el-option label="待生效/审核中" :value="0" />
<el-option label="已生效" :value="1" />
<el-option label="已禁用" :value="2" />
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
</div>
<!-- 表格 -->
<el-table
:data="tableData"
style="width: 100%"
border
v-loading="loading"
element-loading-text="正在加载..."
>
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="tenant_name" label="租户名称" min-width="150" />
<el-table-column label="类型" width="130" align="center">
<template #default="scope">
<el-tag v-if="isCustom(scope.row)" type="warning">自有域名</el-tag>
<el-tag v-else type="info">平台二级域名</el-tag>
</template>
</el-table-column>
<el-table-column prop="full_domain" label="完整域名" min-width="200" />
<el-table-column prop="status" label="状态" width="120" align="center">
<template #default="scope">
<el-tag v-if="scope.row.status === 1" type="success">已生效</el-tag>
<el-tag v-else-if="scope.row.status === 2" type="danger">已禁用</el-tag>
<el-tag v-else type="warning">{{ isCustom(scope.row) ? '待生效' : '审核中' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="解析检测" width="120" align="center">
<template #default="scope">
<span v-if="!isCustom(scope.row)" class="text-muted">—</span>
<el-tooltip v-else-if="scope.row.verify_msg" :content="scope.row.verify_msg" placement="top">
<el-tag :type="verifyTagType(scope.row.verify_status)">{{ verifyText(scope.row.verify_status) }}</el-tag>
</el-tooltip>
<el-tag v-else :type="verifyTagType(scope.row.verify_status)">{{ verifyText(scope.row.verify_status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="HTTPS 证书" width="130" align="center">
<template #default="scope">
<span v-if="!isCustom(scope.row)" class="text-muted">平台证书</span>
<el-tooltip v-else-if="scope.row.ssl_status === 3 && scope.row.ssl_error" :content="scope.row.ssl_error" placement="top">
<el-tag type="danger">签发失败</el-tag>
</el-tooltip>
<el-tooltip v-else-if="scope.row.ssl_status === 2 && scope.row.ssl_expire_at" :content="'到期时间:' + scope.row.ssl_expire_at" placement="top">
<el-tag type="success">已签发</el-tag>
</el-tooltip>
<el-tag v-else :type="sslTagType(scope.row.ssl_status)">{{ sslText(scope.row.ssl_status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="create_time" label="申请时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right" align="center">
<template #default="scope">
<!-- 自有域名无人工审核:解析与证书就绪后自动生效,这里只提供重新检测与禁用 -->
<template v-if="isCustom(scope.row)">
<el-button
size="small"
text
type="primary"
:loading="checkingId === scope.row.id"
@click="handleCheckDns(scope.row)"
>
检测解析
</el-button>
<el-button v-if="scope.row.status === 1" size="small" text type="danger" @click="handleDisable(scope.row)">
禁用
</el-button>
<el-button v-else-if="scope.row.status === 2" size="small" text type="success" @click="handleEnable(scope.row)">
启用
</el-button>
</template>
<template v-else-if="scope.row.status === 0">
<el-button size="small" type="success" @click="handleAudit(scope.row, 'approve')">
通过
</el-button>
<el-button size="small" type="danger" @click="handleAudit(scope.row, 'reject')">
拒绝
</el-button>
</template>
<template v-else-if="scope.row.status === 1">
<el-button size="small" text type="danger" @click="handleDisable(scope.row)">
禁用
</el-button>
</template>
<span v-else style="color: #999;">-</span>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="fetchData"
@current-change="fetchData"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { formatDateTime } from '@/utils/datetime'
import {
getTenantDomainList,
auditTenantDomain,
toggleTenantDomainStatus,
checkCustomDomainDns
} from '@/api/domain'
// 域名类型:与后端 models.DomainTypeCustom 对应
const DOMAIN_TYPE_CUSTOM = 2
const loading = ref(false)
const checkingId = ref<number | null>(null)
const searchForm = reactive({
sub_domain: '',
status: '',
domain_type: ''
})
const pagination = reactive({
page: 1,
pageSize: 10,
total: 0
})
const tableData = ref<any[]>([])
const isCustom = (row: any) => row.domain_type === DOMAIN_TYPE_CUSTOM
const verifyText = (status: number) =>
status === 1 ? '已通过' : status === 2 ? '未通过' : '未检测'
const verifyTagType = (status: number) =>
status === 1 ? 'success' : status === 2 ? 'danger' : 'info'
const sslText = (status: number) =>
status === 2 ? '已签发' : status === 1 ? '签发中' : status === 3 ? '签发失败' : '待处理'
const sslTagType = (status: number) =>
status === 2 ? 'success' : status === 1 ? 'warning' : status === 3 ? 'danger' : 'info'
const fetchData = async () => {
loading.value = true
try {
const res = await getTenantDomainList({
page: pagination.page,
pageSize: pagination.pageSize,
...searchForm
})
if (res.code === 200) {
tableData.value = res.data.list || []
pagination.total = res.data.total || 0
}
} finally {
loading.value = false
}
}
const handleSearch = () => {
pagination.page = 1
fetchData()
}
const handleAudit = async (row: any, action: string) => {
const msg = action === 'approve' ? '确定要通过该域名申请吗?' : '确定要拒绝该域名申请吗?'
await ElMessageBox.confirm(msg, '提示', { type: 'warning' })
const res = await auditTenantDomain({ id: row.id, action })
if (res.code === 200) {
ElMessage.success(res.msg)
fetchData()
} else {
ElMessage.error(res.msg || '操作失败')
}
}
const handleDisable = async (row: any) => {
await ElMessageBox.confirm('确定要禁用该域名吗?禁用后该域名将无法访问官网。', '提示', { type: 'warning' })
const res = await toggleTenantDomainStatus(row.id)
if (res.code === 200) {
ElMessage.success(res.msg)
fetchData()
} else {
ElMessage.error(res.msg || '操作失败')
}
}
const handleEnable = async (row: any) => {
await ElMessageBox.confirm('确定要重新启用该域名吗?', '提示', { type: 'warning' })
const res = await toggleTenantDomainStatus(row.id)
if (res.code === 200) {
ElMessage.success(res.msg)
fetchData()
} else {
ElMessage.error(res.msg || '操作失败')
}
}
// 自有域名:重新检测解析,通过后后端自动申请证书
const handleCheckDns = async (row: any) => {
checkingId.value = row.id
try {
const res = await checkCustomDomainDns(row.id)
if (res.code === 200) {
const data = res.data || {}
if (data.verify_status === 1) {
ElMessage.success(data.ssl_message || data.verify_msg || '解析检测通过')
} else {
ElMessage.warning(data.verify_msg || '解析尚未生效')
}
fetchData()
} else {
ElMessage.error(res.msg || '检测失败')
}
} finally {
checkingId.value = null
}
}
onMounted(() => {
fetchData()
})
</script>
<style lang="less" scoped>
.container-box {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.search-box {
margin-bottom: 20px;
}
.text-muted {
color: #999;
}
.pagination-wrap {
margin-top: 20px;
display: flex;
justify-content: flex-end;
}
</style>
+357
View File
@@ -0,0 +1,357 @@
<template>
<div class="approval-config">
<div class="header-bar">
<h2>审批流配置</h2>
<div>
<el-button type="primary" :disabled="!activeModule" @click="openNewFlow">
<el-icon><Plus /></el-icon>
新建流程
</el-button>
</div>
</div>
<el-divider />
<el-row :gutter="16">
<!-- 左侧:分类 + 流程列表 -->
<el-col :span="6">
<el-card shadow="never" class="side-card">
<template #header>
<span class="card-title">选择分类</span>
</template>
<el-select v-model="activeModule" placeholder="选择分类(功能模块)" style="width: 100%" @change="onModuleChange">
<el-option v-for="m in modules" :key="m.code" :label="m.name" :value="m.code" :disabled="!m.enabled">
<span>{{ m.name }}</span>
<el-tag v-if="!m.enabled" size="small" type="info" style="float: right">未开通</el-tag>
</el-option>
</el-select>
<div class="flow-list">
<div
v-for="f in flows"
:key="f.id"
:class="['flow-item', { active: current && current.id === f.id }]"
@click="selectFlow(f.id)"
>
<div class="flow-name">
{{ f.flow_name }}
<el-tag v-if="f.is_default === 1" size="small" type="success">默认</el-tag>
</div>
<div class="flow-code">{{ f.flow_code || '(保存后生成编号)' }}</div>
<div class="flow-sub">
<span>{{ f.node_count }} 个节点</span>
<el-tag size="small" :type="f.status === 1 ? 'info' : 'danger'">
{{ f.status === 1 ? '启用' : '停用' }}
</el-tag>
</div>
</div>
<el-empty v-if="activeModule && !flows.length" description="该分类暂无审批流" :image-size="60" />
<el-empty v-if="!activeModule" description="请先选择分类" :image-size="60" />
</div>
</el-card>
</el-col>
<!-- 右侧:流程编辑 + 画布 -->
<el-col :span="18">
<el-card v-if="current" shadow="never">
<!-- 流程基本信息 -->
<el-form :model="current" label-width="96px" class="flow-meta">
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="流程名称">
<el-input v-model="current.flow_name" placeholder="如:报销审批流程" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="所属分类">
<el-input :model-value="moduleName(current.module_code)" disabled />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="审批流编号">
<el-input :model-value="current.flow_code || '保存后自动生成'" disabled />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="说明">
<el-input v-model="current.description" type="textarea" :rows="2" placeholder="流程用途说明" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="设为默认">
<el-switch v-model="current.is_default" :active-value="1" :inactive-value="0" />
<span class="hint">同分类仅一条默认,新申请走默认流程</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="启用状态">
<el-switch v-model="current.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-divider content-position="left">流程编排(拖拽节点 · 手动连线 · 条件分支)</el-divider>
<FlowCanvas ref="canvasRef" v-model="graphModel" :approver-types="approverTypes" :pools="pools" />
<div class="save-bar">
<el-button @click="deleteCurrentFlow" type="danger" plain>删除流程</el-button>
<el-button type="primary" :loading="saving" @click="saveFlow">保存流程</el-button>
</div>
</el-card>
<el-card v-else shadow="never">
<el-empty description="请选择左侧分类并新建/选择流程" />
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import FlowCanvas from '@/components/approval/FlowCanvas.vue'
import {
getApprovalOptions,
getApprovalFlowList,
getApprovalFlowDetail,
saveApprovalFlow,
deleteApprovalFlow
} from '@/api/approval'
const modules = ref<any[]>([])
const activeModule = ref<string>('')
const flows = ref<any[]>([])
const current = ref<any>(null)
const saving = ref(false)
const canvasRef = ref<any>(null)
// 传给画布的模型:{ nodes, edges }
const graphModel = ref<any>({ nodes: [], edges: [] })
const pools = reactive<{ users: any[]; roles: any[]; orgs: any[]; positions: any[] }>({
users: [],
roles: [],
orgs: [],
positions: []
})
// 租户侧 6 种审批人类型
const approverTypes = [
{ value: 1, label: '指定用户', needsSelection: true, selectLabel: '审批人', poolKey: 'users' },
{ value: 2, label: '按角色', needsSelection: true, selectLabel: '角色', poolKey: 'roles' },
{ value: 3, label: '指定部门主管', needsSelection: true, selectLabel: '部门', poolKey: 'orgs' },
{ value: 4, label: '按职位', needsSelection: true, selectLabel: '职位', poolKey: 'positions' },
{ value: 5, label: '发起人本人', needsSelection: false, selectLabel: '', poolKey: '' },
{ value: 6, label: '发起人部门主管', needsSelection: false, selectLabel: '', poolKey: '' }
]
function moduleName(code: string) {
return modules.value.find((m) => m.code === code)?.name || code || '-'
}
async function loadOptions() {
const res: any = await getApprovalOptions()
if (res.code === 200) {
modules.value = res.data.modules || []
pools.users = res.data.users || []
pools.roles = res.data.roles || []
pools.orgs = res.data.orgs || []
pools.positions = res.data.positions || []
if (!activeModule.value) {
const first = modules.value.find((m: any) => m.enabled) || modules.value[0]
if (first) {
activeModule.value = first.code
loadFlows()
}
}
}
}
function onModuleChange() {
current.value = null
graphModel.value = { nodes: [], edges: [] }
loadFlows()
}
async function loadFlows() {
if (!activeModule.value) return
const res: any = await getApprovalFlowList({ module: activeModule.value })
if (res.code === 200) flows.value = res.data || []
}
async function selectFlow(id: number) {
const res: any = await getApprovalFlowDetail(id)
if (res.code === 200) {
const d = res.data || {}
current.value = d
graphModel.value = { nodes: d.nodes || [], edges: d.edges || [] }
}
}
function openNewFlow() {
current.value = {
id: 0,
flow_name: '',
module_code: activeModule.value,
biz_type: activeModule.value,
flow_code: '',
description: '',
is_default: 0,
status: 1
}
// 预置开始/结束,方便用户直接拖审批节点连线
graphModel.value = {
nodes: [
{ node_key: 'start', node_name: '开始', node_type: 1, pos_x: 60, pos_y: 200 },
{ node_key: 'end', node_name: '结束', node_type: 3, pos_x: 520, pos_y: 200 }
],
edges: []
}
}
async function saveFlow() {
if (!current.value.flow_name?.trim()) {
ElMessage.warning('请填写流程名称')
return
}
if (!current.value.module_code) {
ElMessage.warning('请选择分类')
return
}
const graph = canvasRef.value?.serialize?.() || { nodes: [], edges: [] }
saving.value = true
try {
const payload = {
id: current.value.id || 0,
module_code: current.value.module_code,
biz_type: current.value.biz_type || current.value.module_code,
flow_name: current.value.flow_name,
description: current.value.description,
is_default: current.value.is_default,
status: current.value.status,
nodes: graph.nodes,
edges: graph.edges
}
const res: any = await saveApprovalFlow(payload)
if (res.code === 200) {
ElMessage.success('保存成功')
activeModule.value = current.value.module_code
await loadFlows()
if (res.data?.id) await selectFlow(res.data.id)
} else {
ElMessage.error(res.msg || '保存失败')
}
} finally {
saving.value = false
}
}
async function deleteCurrentFlow() {
if (!current.value?.id) {
current.value = null
graphModel.value = { nodes: [], edges: [] }
return
}
await ElMessageBox.confirm('确定删除该审批流?在途审批存在时无法删除。', '提示', { type: 'warning' })
const res: any = await deleteApprovalFlow(current.value.id)
if (res.code === 200) {
ElMessage.success('已删除')
current.value = null
graphModel.value = { nodes: [], edges: [] }
loadFlows()
} else {
ElMessage.error(res.msg || '删除失败')
}
}
onMounted(() => {
loadOptions()
})
</script>
<style lang="less" scoped>
.approval-config {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.side-card {
min-height: 400px;
.card-title {
font-weight: 600;
}
}
.flow-list {
margin-top: 12px;
.flow-item {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
&:hover {
background: #f5f7fa;
}
&.active {
background: #ecf5ff;
border-color: #b3d8ff;
}
.flow-name {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #303133;
}
.flow-code {
margin-top: 2px;
font-size: 12px;
color: #409eff;
font-family: Menlo, Consolas, monospace;
}
.flow-sub {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
font-size: 12px;
color: #909399;
}
}
}
.flow-meta {
.hint {
margin-left: 10px;
font-size: 12px;
color: #909399;
}
}
.save-bar {
margin-top: 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
+264
View File
@@ -0,0 +1,264 @@
package controllers
import (
"strconv"
"strings"
"server/models"
"server/pkg/jwtutil"
"server/services"
"github.com/beego/beego/v2/client/orm"
)
// 移动端员工档案「全公司视角」接口(HR 使用)。
//
// 与 Web 端(/backend/oa/employeefile/*)的关系:
// - 数据口径完全一致:一人一档(yz_backend_employee_file)+ 教育/工作/合同/证照子记录,
// DTO 组装直接复用 Web 端 efAssembleDTOList,保证两端展示字段完全一致;
// - 只读:建档、改档、离职等写操作仍只在 Web 端提供;
// - 数据一律按 JWT 中的 TenantId 隔离,前端无法指定租户。
//
// 权限口径:租户角色的 rights 里勾选了「员工档案」菜单(或其祖先节点)即视为 HR,可见全公司档案;
// 租户管理员 / 未分配角色 / 角色未配置权限等既有「全权限」情形同样放行。
// 未放行的用户仍可查看本人档案(/api/oa/employee-file),前端据此展示不同视图。
// employeeFileMenuIDs 命中「员工档案」菜单的菜单ID集合(含其祖先节点)。
// 菜单表由租户自行维护,标题/路径可能被改写,因此按 路径关键字 + 标题关键字 双重匹配,
// 与移动端菜单跳转规则(uniapp/utils/nav.js 中的 employeefile 规则)保持同一识别口径。
func employeeFileMenuIDs() map[uint64]bool {
ids := map[uint64]bool{}
if models.Orm == nil {
return ids
}
var rows []models.SystemMenu
if _, err := models.Orm.QueryTable(new(models.SystemMenu)).
Filter("status", 1).
Filter("delete_time__isnull", true).
All(&rows); err != nil {
return ids
}
pidOf := make(map[uint64]int64, len(rows))
hit := map[uint64]bool{}
for _, m := range rows {
pidOf[m.ID] = m.Pid
hay := strings.ToLower(m.Title + " " + derefString(m.Path) + " " + derefString(m.ComponentPath))
if strings.Contains(hay, "employeefile") || strings.Contains(hay, "employee-file") ||
strings.Contains(m.Title, "员工档案") || strings.Contains(m.Title, "人事档案") {
hit[m.ID] = true
}
}
if len(hit) == 0 {
return ids
}
// 角色权限勾到父目录即代表含全部下级,因此把命中菜单的祖先一并纳入判定。
for id := range hit {
cur := id
for i := 0; i < 32; i++ { // 限制深度,防御脏数据造成的环
if _, ok := pidOf[cur]; !ok {
break
}
ids[cur] = true
pid := pidOf[cur]
if pid <= 0 {
break
}
cur = uint64(pid)
}
}
return ids
}
// canViewAllEmployeeFiles 判断当前用户是否有权查看全公司员工档案(HR 视角)。
func canViewAllEmployeeFiles(claims *jwtutil.Claims) bool {
if claims == nil || claims.TenantId <= 0 {
return false
}
// 平台管理员
if claims.UserType == "platform" {
return true
}
// 租户管理员 / 未分配角色 / 角色未配置权限 —— 与 Web 端 isTenantAdmin 同一口径
if isTenantAdmin(claims.UserID, claims.TenantId) {
return true
}
var tu models.SystemTenantUser
if models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("uid", tenantUIDOf(claims)).
Filter("tid", claims.TenantId).
Filter("delete_time__isnull", true).One(&tu) != nil {
return false
}
if tu.GroupID == 0 {
return true
}
var role models.AdminRole
if models.Orm.QueryTable(new(models.AdminRole)).
Filter("id", tu.GroupID).
Filter("cid", 2).One(&role) != nil {
return false
}
if strings.TrimSpace(role.Name) == "租户管理员" {
return true
}
rights := ""
if role.Rights != nil {
rights = strings.TrimSpace(*role.Rights)
}
if rights == "" || rights == "[]" || rights == "null" {
return true
}
allowed := parseRightsToSet(rights)
if len(allowed) == 0 {
return true
}
for id := range employeeFileMenuIDs() {
if allowed[id] {
return true
}
}
return false
}
// tenantUIDOf 令牌 user_id 是认证中心 identity_id,换算成本租户的老 uid。
func tenantUIDOf(claims *jwtutil.Claims) uint64 {
return services.ResolveTenantUID(uint64(claims.TenantId), uint64(claims.UserID))
}
// EmployeeFileAccess GET /api/oa/employee-file/access
// 返回当前用户是否具备 HR 视角(可见全公司档案),前端据此决定展示列表还是本人档案。
func (c *AppOaController) EmployeeFileAccess() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
c.oaOk(map[string]interface{}{"can_view_all": canViewAllEmployeeFiles(claims)})
}
// EmployeeFileList GET /api/oa/employee-file/list 全公司档案分页列表(需 HR 权限)。
// 支持 keyword(档案编号/身份证/员工姓名/账号/手机)、status(在职状态)、org_id(部门,含下级)。
func (c *AppOaController) EmployeeFileList() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
if !canViewAllEmployeeFiles(claims) {
c.oaErr(403, 403, "您没有查看全部员工档案的权限")
return
}
tid := claims.TenantId
page, _ := c.GetInt("page", 1)
pageSize, _ := c.GetInt("pageSize", 20)
keyword := strings.TrimSpace(c.GetString("keyword"))
statusRaw := strings.TrimSpace(c.GetString("status"))
orgID, _ := c.GetUint64("org_id")
if page < 1 {
page = 1
}
if pageSize < 1 || pageSize > 100 {
pageSize = 20
}
web := &BackendEmployeeFileController{}
qs := web.efQuery(tid)
if orgID > 0 {
empIDs := web.efEmployeeIDsInOrgs(tid, web.efSubtreeOrgIDs(tid, orgID))
if len(empIDs) == 0 {
c.oaOk(map[string]interface{}{"list": []employeeFileDTO{}, "total": 0, "page": page, "pageSize": pageSize})
return
}
qs = qs.Filter("employee_id__in", empIDs)
}
if statusRaw != "" {
status, convErr := strconv.Atoi(statusRaw)
if convErr != nil || status < 1 || status > 3 {
c.oaErr(400, 400, "在职状态参数无效")
return
}
qs = qs.Filter("employment_status", int8(status))
}
if keyword != "" {
// 关键词同时命中档案自身字段(编号/身份证)与员工字段(姓名/账号/手机)
empIDs := web.efEmployeeIDsByKeyword(tid, keyword)
cond := orm.NewCondition().
Or("file_no__icontains", keyword).
Or("id_card__icontains", keyword)
if len(empIDs) > 0 {
cond = cond.Or("employee_id__in", empIDs)
}
qs = qs.SetCond(orm.NewCondition().AndCond(cond))
}
total, _ := qs.Count()
var files []models.BackendEmployeeFile
if _, err := qs.OrderBy("-id").Limit(pageSize).Offset((page - 1) * pageSize).All(&files); err != nil && err != orm.ErrNoRows {
c.oaErr(500, 500, "查询失败")
return
}
c.oaOk(map[string]interface{}{
"list": web.efAssembleDTOList(tid, files),
"total": total,
"page": page,
"pageSize": pageSize,
})
}
// EmployeeFileDetail GET /api/oa/employee-file/detail/:id 员工档案详情(含子记录)。
//
// 两种访问路径:
// - HR 视角:可查看任意员工的档案(租户内隔离);
// - 普通员工:仅可查看本人档案,看他人档案返回 403。
func (c *AppOaController) EmployeeFileDetail() {
claims, err := c.oaClaims()
if err != nil {
c.oaErr(401, 401, "未登录或无权限")
return
}
tid := claims.TenantId
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if id == 0 {
c.oaErr(400, 400, "无效的档案ID")
return
}
web := &BackendEmployeeFileController{}
var file models.BackendEmployeeFile
if web.efQuery(tid).Filter("id", id).One(&file) != nil {
c.oaErr(404, 404, "档案不存在")
return
}
if !canViewAllEmployeeFiles(claims) {
emp, ok := c.oaMyEmployee(claims)
if !ok || uint64(emp.ID) != file.EmployeeID {
c.oaErr(403, 403, "您没有查看他人档案的权限")
return
}
}
var records []models.BackendEmployeeFileRecord
if _, err := models.Orm.QueryTable(new(models.BackendEmployeeFileRecord)).
Filter("tid", tid).
Filter("is_deleted", 0).
Filter("employee_id", file.EmployeeID).
OrderBy("sort", "id").
All(&records); err != nil && err != orm.ErrNoRows {
c.oaErr(500, 500, "查询档案记录失败")
return
}
if records == nil {
records = []models.BackendEmployeeFileRecord{}
}
dtos := web.efAssembleDTOList(tid, []models.BackendEmployeeFile{file})
c.oaOk(map[string]interface{}{"file": dtos[0], "records": records})
}
+466
View File
@@ -0,0 +1,466 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/services"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// BackendApprovalController 租户端审批流配置(流程头 + 节点编排)。
//
// 与平台端差异:
// - 数据按登录租户隔离(tid 取自 JWT,写入/查询均带 tid);
// - 「功能」下拉取当前租户已开通的模块(SystemModules),流程 module_code / biz_type 均绑定模块 code,
// 实现「选功能 → 命名 → 把审批流引用到该功能」;
// - 审批人取本租户主体:租户用户 / 租户角色 / 部门主管 / 职位 / 发起人 / 发起人主管(引擎支持 6 种)。
type BackendApprovalController struct {
beego.Controller
}
// readTenantBody 读取并解析 JSON 请求体
func (c *BackendApprovalController) readTenantBody(dst interface{}) error {
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
return fmt.Errorf("参数错误")
}
if err := json.Unmarshal(raw, dst); err != nil {
return fmt.Errorf("参数错误: %s", err.Error())
}
return nil
}
// tenantFlowList GET /backend/approval/flow/list?module=oa
// 当前租户的流程列表;按分类(module_code)过滤,附带节点数。
func (c *BackendApprovalController) FlowList() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
module := strings.TrimSpace(c.GetString("module"))
qs := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("tid", tid).Filter("is_deleted", 0)
if module != "" {
qs = qs.Filter("module_code", module)
}
var rows []models.TenantApprovalFlow
_, _ = qs.OrderBy("-is_default", "id").All(&rows)
list := make([]approvalFlowView, 0, len(rows))
for _, r := range rows {
cnt, _ := models.Orm.QueryTable(new(models.TenantApprovalNode)).
Filter("flow_id", r.ID).Filter("is_deleted", 0).Count()
list = append(list, approvalFlowView{
ID: r.ID,
ModuleCode: r.ModuleCode,
BizType: r.BizType,
FlowCode: r.FlowCode,
FlowName: r.FlowName,
Description: r.Description,
IsDefault: r.IsDefault,
Status: r.Status,
NodeCount: int(cnt),
CreateTime: r.CreateTime.Format("2006-01-02 15:04:05"),
})
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list}
_ = c.ServeJSON()
}
// FlowDetail GET /backend/approval/flow/detail/:id
// 返回本租户某流程及其全部节点(按 sort 升序),供编排界面回显。
func (c *BackendApprovalController) FlowDetail() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
var f models.TenantApprovalFlow
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("id", id).Filter("tid", tid).Filter("is_deleted", 0).One(&f); err != nil {
jsonErr(&c.Controller, 404, 404, "流程不存在")
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": tenantFlowToView(f)}
_ = c.ServeJSON()
}
// tenantFlowToView 组装租户流程视图(含节点与连线)
func tenantFlowToView(f models.TenantApprovalFlow) approvalFlowView {
var nodes []models.TenantApprovalNode
_, _ = models.Orm.QueryTable(new(models.TenantApprovalNode)).
Filter("flow_id", f.ID).Filter("is_deleted", 0).
OrderBy("sort", "id").All(&nodes)
view := approvalFlowView{
ID: f.ID,
ModuleCode: f.ModuleCode,
BizType: f.BizType,
FlowCode: f.FlowCode,
FlowName: f.FlowName,
Description: f.Description,
IsDefault: f.IsDefault,
Status: f.Status,
NodeCount: len(nodes),
CreateTime: f.CreateTime.Format("2006-01-02 15:04:05"),
Nodes: make([]approvalNodePayload, 0, len(nodes)),
}
edgeNodes := make([]flowEdgeNode, 0, len(nodes))
for _, n := range nodes {
view.Nodes = append(view.Nodes, approvalNodePayload{
NodeKey: n.NodeKey,
NodeName: n.NodeName,
NodeType: n.NodeType,
ApproverType: n.ApproverType,
ApproverIDs: parseIDSlice(n.ApproverIDs),
MultiMode: n.MultiMode,
AllowReturn: n.AllowReturn,
TimeoutHours: n.TimeoutHours,
Sort: n.Sort,
PosX: n.PosX,
PosY: n.PosY,
ConditionExpr: n.ConditionExpr,
})
edgeNodes = append(edgeNodes, flowEdgeNode{NodeKey: n.NodeKey, NextKeys: n.NextKeys, Condition: n.ConditionExpr})
}
view.Edges = rebuildEdges(edgeNodes)
return view
}
// FlowSave POST /backend/approval/flow/save
// 新建或更新本租户流程;节点采用「全量覆盖」语义(先软删旧节点,再按传入顺序重建)。
func (c *BackendApprovalController) FlowSave() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
var p approvalFlowSave
if err := c.readTenantBody(&p); err != nil {
jsonErr(&c.Controller, 400, 400, err.Error())
return
}
if strings.TrimSpace(p.FlowName) == "" {
jsonErr(&c.Controller, 400, 400, "流程名称不能为空")
return
}
if strings.TrimSpace(p.ModuleCode) == "" {
jsonErr(&c.Controller, 400, 400, "请选择要配置的分类")
return
}
// 分类与单据类型对齐:运行期按 biz_type=分类 code 命中默认流
if strings.TrimSpace(p.BizType) == "" {
p.BizType = p.ModuleCode
}
if err := validateTenantApprovalNodes(p.Nodes); err != nil {
jsonErr(&c.Controller, 400, 400, err.Error())
return
}
if err := validateApprovalGraph(p.Nodes, p.Edges); err != nil {
jsonErr(&c.Controller, 400, 400, err.Error())
return
}
g := buildGraph(p.Nodes, p.Edges)
now := time.Now()
o := models.Orm
var flowID uint64
var flowCode string
if p.ID > 0 {
var exist models.TenantApprovalFlow
if err := o.QueryTable(new(models.TenantApprovalFlow)).
Filter("id", p.ID).Filter("tid", tid).Filter("is_deleted", 0).One(&exist); err != nil {
jsonErr(&c.Controller, 404, 404, "流程不存在")
return
}
flowID = p.ID
flowCode = exist.FlowCode
_, err := o.QueryTable(new(models.TenantApprovalFlow)).
Filter("id", flowID).Filter("tid", tid).Update(orm.Params{
"module_code": p.ModuleCode,
"biz_type": p.BizType,
"flow_name": p.FlowName,
"description": p.Description,
"is_default": p.IsDefault,
"status": p.Status,
"update_time": now,
})
if err != nil {
jsonErr(&c.Controller, 500, 500, "保存失败: "+err.Error())
return
}
} else {
row := models.TenantApprovalFlow{
Tid: tid,
ModuleCode: p.ModuleCode,
BizType: p.BizType,
FlowName: p.FlowName,
Description: p.Description,
IsDefault: p.IsDefault,
Status: p.Status,
CreateTime: now,
}
id, err := o.Insert(&row)
if err != nil {
jsonErr(&c.Controller, 500, 500, "保存失败: "+err.Error())
return
}
flowID = uint64(id)
flowCode = genApprovalFlowCode(flowID)
_, _ = o.QueryTable(new(models.TenantApprovalFlow)).Filter("id", flowID).Filter("tid", tid).
Update(orm.Params{"flow_code": flowCode})
}
// 默认流程唯一:同租户同单据类型下只允许一条 is_default=1
if p.IsDefault == 1 {
_, _ = o.QueryTable(new(models.TenantApprovalFlow)).
Filter("tid", tid).Filter("biz_type", p.BizType).Filter("is_deleted", 0).
Exclude("id", flowID).Update(orm.Params{"is_default": 0, "update_time": now})
}
// 覆盖节点:软删旧节点后按 sort 重建
_, _ = o.QueryTable(new(models.TenantApprovalNode)).
Filter("flow_id", flowID).Filter("is_deleted", 0).
Update(orm.Params{"is_deleted": 1, "delete_time": now})
for i, nd := range p.Nodes {
sort := nd.Sort
if sort == 0 {
sort = i + 1
}
nodeKey := strings.TrimSpace(nd.NodeKey)
if nodeKey == "" {
nodeKey = fmt.Sprintf("n%d", sort)
}
nodeType := nd.NodeType
if nodeType == 0 {
nodeType = models.ApprovalNodeApproval
}
multi := nd.MultiMode
if multi != models.ApprovalMultiAll {
multi = models.ApprovalMultiAny
}
timeout := nd.TimeoutHours
if timeout <= 0 {
timeout = 48
}
node := models.TenantApprovalNode{
FlowID: flowID,
Tid: tid,
NodeKey: nodeKey,
NodeName: strings.TrimSpace(nd.NodeName),
NodeType: nodeType,
ApproverType: nd.ApproverType,
ApproverIDs: joinIDs(nd.ApproverIDs),
MultiMode: multi,
AllowReturn: nd.AllowReturn,
TimeoutHours: timeout,
Sort: sort,
PosX: nd.PosX,
PosY: nd.PosY,
PrevKeys: strings.Join(g.prev[nodeKey], ","),
NextKeys: strings.Join(g.next[nodeKey], ","),
ConditionExpr: g.cond[nodeKey],
CreateTime: now,
}
if _, err := o.Insert(&node); err != nil {
jsonErr(&c.Controller, 500, 500, "保存节点失败: "+err.Error())
return
}
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功", "data": map[string]interface{}{"id": flowID, "flow_code": flowCode}}
_ = c.ServeJSON()
}
// Pick GET /backend/approval/flow/pick
// 轻量清单:按分类返回本租户启用流程的 {flow_code, flow_name},供其它功能“选/填编号调用”。
func (c *BackendApprovalController) Pick() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
module := strings.TrimSpace(c.GetString("module"))
qs := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("tid", tid).Filter("is_deleted", 0).Filter("status", 1)
if module != "" {
qs = qs.Filter("module_code", module)
}
var rows []models.TenantApprovalFlow
_, _ = qs.OrderBy("module_code", "id").All(&rows)
list := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
list = append(list, map[string]interface{}{
"module_code": r.ModuleCode,
"flow_code": r.FlowCode,
"flow_name": r.FlowName,
})
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list}
_ = c.ServeJSON()
}
// FlowDelete DELETE /backend/approval/flow/delete/:id
// 软删本租户流程及其节点。存在在途实例时禁止删除,避免审批中断。
func (c *BackendApprovalController) FlowDelete() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
var f models.TenantApprovalFlow
if err := models.Orm.QueryTable(new(models.TenantApprovalFlow)).
Filter("id", id).Filter("tid", tid).Filter("is_deleted", 0).One(&f); err != nil {
jsonErr(&c.Controller, 404, 404, "流程不存在")
return
}
running, _ := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("flow_id", id).Filter("scope", models.ApprovalScopeTenant).
Filter("status", models.ApprovalInstanceRunning).Filter("is_deleted", 0).Count()
if running > 0 {
jsonErr(&c.Controller, 400, 400, fmt.Sprintf("该流程还有 %d 条在途审批,暂不能删除", running))
return
}
now := time.Now()
_, _ = models.Orm.QueryTable(new(models.TenantApprovalFlow)).Filter("id", id).Filter("tid", tid).
Update(orm.Params{"is_deleted": 1, "delete_time": now, "update_time": now})
_, _ = models.Orm.QueryTable(new(models.TenantApprovalNode)).Filter("flow_id", id).Filter("tid", tid).
Update(orm.Params{"is_deleted": 1, "delete_time": now})
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "已删除"}
_ = c.ServeJSON()
}
// Options GET /backend/approval/options
// 编排界面数据源:当前租户的功能模块 + 审批人候选(用户 / 角色 / 部门 / 职位)。
func (c *BackendApprovalController) Options() {
claims, err := requireBackend(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
tid := uint64(claims.TenantId)
// 功能模块:状态可用、Web 可见,并标记当前租户是否已开通
var mods []models.SystemModules
_, _ = models.Orm.QueryTable(new(models.SystemModules)).
Filter("delete_time__isnull", true).Filter("status", 1).Filter("is_show", 1).
OrderBy("sort", "id").All(&mods)
mods = services.FilterModulesByPlatform(mods, models.PlatformWeb)
allowed := services.GetTenantModuleCodes(tid)
moduleList := make([]map[string]interface{}, 0, len(mods))
for _, m := range mods {
enabled := true
if len(allowed) > 0 {
enabled = allowed[services.NormalizeModuleCode(m.Code)]
}
moduleList = append(moduleList, map[string]interface{}{
"code": m.Code, "name": m.Name, "enabled": enabled,
})
}
// 租户用户
var tu []models.SystemTenantUser
_, _ = models.Orm.QueryTable(new(models.SystemTenantUser)).
Filter("tid", tid).Filter("status", 1).Filter("delete_time__isnull", true).
OrderBy("id").All(&tu)
userList := make([]map[string]interface{}, 0, len(tu))
for _, u := range tu {
userList = append(userList, map[string]interface{}{
"id": u.Uid, "name": firstNonEmptyStr(u.Name, u.Account),
})
}
// 租户角色(cid=2,含平台预置 tenant_id=0 与本租户自定义)
var ar []models.AdminRole
_, _ = models.Orm.QueryTable(new(models.AdminRole)).
Filter("cid", 2).Filter("tenant_id__in", []uint64{0, tid}).
Filter("status", 1).Filter("delete_time__isnull", true).
OrderBy("id").All(&ar)
roleList := make([]map[string]interface{}, 0, len(ar))
for _, r := range ar {
roleList = append(roleList, map[string]interface{}{"id": r.ID, "name": r.Name})
}
// 部门 / 职位
var og []models.BackendOrganization
_, _ = models.Orm.QueryTable(new(models.BackendOrganization)).
Filter("tid", tid).Filter("status", 1).Filter("delete_time__isnull", true).
OrderBy("sort", "id").All(&og)
orgList := make([]map[string]interface{}, 0, len(og))
for _, o := range og {
orgList = append(orgList, map[string]interface{}{"id": o.ID, "name": o.OrgName})
}
var ps []models.BackendPosition
_, _ = models.Orm.QueryTable(new(models.BackendPosition)).
Filter("tid", tid).Filter("status", 1).Filter("delete_time__isnull", true).
OrderBy("sort", "id").All(&ps)
posList := make([]map[string]interface{}, 0, len(ps))
for _, p := range ps {
posList = append(posList, map[string]interface{}{"id": p.ID, "name": p.PositionName})
}
c.Data["json"] = map[string]interface{}{
"code": 200, "msg": "ok",
"data": map[string]interface{}{
"modules": moduleList,
"users": userList,
"roles": roleList,
"orgs": orgList,
"positions": posList,
},
}
_ = c.ServeJSON()
}
// ============================ 辅助函数 ============================
// firstNonEmptyStr 取字符串指针/普通字符串中第一个非空值
func firstNonEmptyStr(vals ...*string) string {
for _, v := range vals {
if v != nil && strings.TrimSpace(*v) != "" {
return strings.TrimSpace(*v)
}
}
return ""
}
// validateTenantApprovalNodes 校验租户节点:审批/条件节点须合法选择审批人来源。
// 租户侧支持 6 种审批人类型:1用户 2角色 3部门主管 4职位 5发起人本人 6发起人主管。
func validateTenantApprovalNodes(nodes []approvalNodePayload) error {
for i, nd := range nodes {
if nd.NodeType == models.ApprovalNodeStart || nd.NodeType == models.ApprovalNodeEnd ||
nd.NodeType == models.ApprovalNodeCondition {
continue
}
if strings.TrimSpace(nd.NodeName) == "" {
return fmt.Errorf("第 %d 个节点名称不能为空", i+1)
}
if nd.ApproverType < models.ApprovalApproverUser || nd.ApproverType > models.ApprovalApproverInitLeader {
return fmt.Errorf("节点「%s」审批人类型非法", nd.NodeName)
}
// 发起人相关类型无需指定对象;其余必须选择非空来源
needIDs := nd.ApproverType != models.ApprovalApproverInitiator &&
nd.ApproverType != models.ApprovalApproverInitLeader
if needIDs && len(nd.ApproverIDs) == 0 {
return fmt.Errorf("节点「%s」未选择审批人", nd.NodeName)
}
}
return nil
}
+4 -50
View File
@@ -564,58 +564,12 @@ func (c *BackendTenantDomainController) Apply() {
jsonErr(&c.Controller, 500, 500, "申请失败: "+err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "申请提交成功,等待审核", "data": map[string]interface{}{"id": uint64(id)}}
_ = c.ServeJSON()
}
// Audit POST /backend/domain/tenant/audit body:{id,action} action=approve/reject
func (c *BackendTenantDomainController) Audit() {
if _, err := requireBackend(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
// 提交后进入平台侧审批流(backend 提起、platform 审核);严格:未配置流程/无有效审批人则提交失败并回滚
if errMsg := submitDomainApprovalChecked(uint64(id), tenantID, uint64(claims.UserID), claims.Username, full); errMsg != "" {
jsonErr(&c.Controller, 400, 400, errMsg)
return
}
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
var p struct {
ID uint64 `json:"id"`
Action string `json:"action"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 {
jsonErr(&c.Controller, 400, 400, "参数错误")
return
}
var row models.SystemTenantDomain
if err := models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", p.ID).One(&row); err != nil {
jsonErr(&c.Controller, 404, 404, "域名不存在")
return
}
if row.DomainType == models.DomainTypeCustom {
jsonErr(&c.Controller, 400, 400, "自有域名无需人工审核,解析检测与证书签发通过后自动生效")
return
}
if row.Status != 0 {
jsonErr(&c.Controller, 400, 400, "该域名已审核过了")
return
}
newStatus := 2
msg := "已拒绝"
if strings.ToLower(strings.TrimSpace(p.Action)) == "approve" {
newStatus = 1
msg = "审核通过"
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", p.ID).Update(map[string]interface{}{
"status": newStatus,
"update_time": now,
})
if err != nil {
jsonErr(&c.Controller, 500, 500, "审核失败: "+err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": msg}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "申请提交成功,等待审批", "data": map[string]interface{}{"id": uint64(id)}}
_ = c.ServeJSON()
}
@@ -677,6 +677,44 @@ func (c *BackendOnepageController) ListAll() {
_ = c.ServeJSON()
}
// GetOnePageByPath GET /backend/onepage/path/:path 按路径读取租户单页(后端端独立接口)
func (c *BackendOnepageController) GetOnePageByPath() {
claims, err := c.cmsClaims()
if err != nil {
c.cmsJSONErr(401, 401, err.Error())
return
}
tid := cmsEffectiveTid(&c.Controller, claims)
if tid == 0 {
c.cmsJSONErr(400, 400, "tid不能为空")
return
}
path := strings.TrimSpace(c.Ctx.Input.Param(":path"))
if path == "" {
c.cmsJSONErr(400, 400, "路径标识不能为空")
return
}
var row models.CmsFrontendOnepage
err = models.Orm.QueryTable(new(models.CmsFrontendOnepage)).
Filter("tid", tid).
Filter("path", path).
Filter("delete_time__isnull", true).
One(&row)
if err == orm.ErrNoRows {
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": nil}
_ = c.ServeJSON()
return
}
if err != nil {
c.cmsJSONErr(500, 500, "获取单页失败")
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": cmsOnepageToMap(row)}
_ = c.ServeJSON()
}
// Create POST /backend/createonepage
func (c *BackendOnepageController) Create() {
claims, err := c.cmsClaims()
+65
View File
@@ -0,0 +1,65 @@
package controllers
import (
"strconv"
"server/models"
beego "github.com/beego/beego/v2/server/web"
)
// IndexUpgradeNoticeController 官网端「平台更新公告」公开接口。
// 数据同源于 models.PlatformUpgradeNotice,但作为官网端独立接口,
// 不与平台端 /platform、移动端 /api 复用同一 handler。
type IndexUpgradeNoticeController struct {
beego.Controller
}
// List GET /site/upgrade/list 官网首页公开展示的更新公告(仅启用、未删除)
func (c *IndexUpgradeNoticeController) List() {
limitStr := c.GetString("limit")
limit := 10
if limitStr != "" {
val, err := strconv.Atoi(limitStr)
if err == nil {
limit = val
}
}
if limit <= 0 || limit > 50 {
limit = 10
}
var upgradeList []models.PlatformUpgradeNotice
qs := models.Orm.QueryTable(new(models.PlatformUpgradeNotice)).
Filter("status", 1).
Filter("delete_time__isnull", true)
_, err := qs.OrderBy("-sort", "-create_time").Limit(limit).All(&upgradeList)
if err != nil {
c.Data["json"] = map[string]interface{}{
"code": 500,
"msg": "查询失败",
}
_ = c.ServeJSON()
return
}
data := make([]map[string]interface{}, 0, len(upgradeList))
for _, item := range upgradeList {
record := map[string]interface{}{
"id": item.ID,
"update_date": item.UpdateDate,
"title": item.Title,
"content": item.Content,
"create_time": item.CreateTime.Format("2006-01-02 15:04:05"),
}
data = append(data, record)
}
c.Data["json"] = map[string]interface{}{
"code": 200,
"msg": "success",
"data": data,
}
_ = c.ServeJSON()
}
+660
View File
@@ -0,0 +1,660 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"server/models"
"server/services"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformApprovalController 平台端审批流配置(流程头 + 节点编排)。
//
// 当期面向「域名申请」这类平台侧管控场景,按 module_code + biz_type 联合唯一组织流程;
// 平台账号不归属租户,故流程与节点均无 tid,审批人取平台用户 / 平台角色。
type PlatformApprovalController struct {
beego.Controller
}
// ---- 配置侧 JSON 结构 ----
// approvalNodePayload 单个节点(前端画布节点)。
// node_type:1开始 2审批 3结束 4条件网关;pos_x/pos_y 为画布坐标回显;
// condition_expr 为该节点作为「条件网关出边目标」时的进入条件(由 edges 反推落库)。
type approvalNodePayload struct {
NodeKey string `json:"node_key"`
NodeName string `json:"node_name"`
NodeType int8 `json:"node_type"` // 1开始 2审批 3结束 4条件网关
ApproverType int8 `json:"approver_type"` // 1指定用户 2指定角色 ...
ApproverIDs []uint64 `json:"approver_ids"`
MultiMode int8 `json:"multi_mode"` // 1或签 2会签
AllowReturn int8 `json:"allow_return"` // 是否允许退回
TimeoutHours int `json:"timeout_hours"` // 超时小时
Sort int `json:"sort"` // 顺序(越小越靠前)
PosX int `json:"pos_x"` // 画布 X 坐标
PosY int `json:"pos_y"` // 画布 Y 坐标
ConditionExpr string `json:"condition_expr"`
}
// approvalEdge 画布连线:from→to,condition 为该出边的分支条件(落到 to 节点的 condition_expr)
type approvalEdge struct {
From string `json:"from"`
To string `json:"to"`
Condition string `json:"condition"`
}
// approvalFlowSave 保存流程(图:节点 + 连线,节点全量覆盖)
type approvalFlowSave struct {
ID uint64 `json:"id"`
ModuleCode string `json:"module_code"`
BizType string `json:"biz_type"`
FlowName string `json:"flow_name"`
Description string `json:"description"`
IsDefault int8 `json:"is_default"`
Status int8 `json:"status"`
Nodes []approvalNodePayload `json:"nodes"`
Edges []approvalEdge `json:"edges"`
}
// approvalFlowView 流程 + 节点 + 连线(详情返回)
type approvalFlowView struct {
ID uint64 `json:"id"`
ModuleCode string `json:"module_code"`
BizType string `json:"biz_type"`
FlowCode string `json:"flow_code"`
FlowName string `json:"flow_name"`
Description string `json:"description"`
IsDefault int8 `json:"is_default"`
Status int8 `json:"status"`
NodeCount int `json:"node_count"`
CreateTime string `json:"create_time"`
Nodes []approvalNodePayload `json:"nodes"`
Edges []approvalEdge `json:"edges"`
}
// readApprovalBody 读取并解析 JSON 请求体
func (c *PlatformApprovalController) readApprovalBody(dst interface{}) error {
raw, err := io.ReadAll(c.Ctx.Request.Body)
if err != nil {
return fmt.Errorf("参数错误")
}
if err := json.Unmarshal(raw, dst); err != nil {
return fmt.Errorf("参数错误: %s", err.Error())
}
return nil
}
// FlowList GET /platform/approval/flow/list?biz_type=domain
// 平台端流程列表;可按单据类型过滤,附带节点数。
func (c *PlatformApprovalController) FlowList() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
bizType := strings.TrimSpace(c.GetString("biz_type"))
if bizType == "" {
bizType = strings.TrimSpace(c.GetString("module")) // 兼容:分类即业务类型
}
qs := models.Orm.QueryTable(new(models.PlatformApprovalFlow)).
Filter("is_deleted", 0)
if bizType != "" {
qs = qs.Filter("biz_type", bizType)
}
var rows []models.PlatformApprovalFlow
_, _ = qs.OrderBy("-is_default", "id").All(&rows)
list := make([]approvalFlowView, 0, len(rows))
for _, r := range rows {
cnt, _ := models.Orm.QueryTable(new(models.PlatformApprovalNode)).
Filter("flow_id", r.ID).Filter("is_deleted", 0).Count()
list = append(list, approvalFlowView{
ID: r.ID,
ModuleCode: r.ModuleCode,
BizType: r.BizType,
FlowCode: r.FlowCode,
FlowName: r.FlowName,
Description: r.Description,
IsDefault: r.IsDefault,
Status: r.Status,
NodeCount: int(cnt),
CreateTime: r.CreateTime.Format("2006-01-02 15:04:05"),
})
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list}
_ = c.ServeJSON()
}
// FlowDetail GET /platform/approval/flow/detail/:id
// 返回流程及其全部节点(按 sort 升序),供编排界面回显。
func (c *PlatformApprovalController) FlowDetail() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
var f models.PlatformApprovalFlow
if err := models.Orm.QueryTable(new(models.PlatformApprovalFlow)).
Filter("id", id).Filter("is_deleted", 0).One(&f); err != nil {
jsonErr(&c.Controller, 404, 404, "流程不存在")
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": c.flowToView(f)}
_ = c.ServeJSON()
}
// flowToView 组装流程视图(含节点与连线)
func (c *PlatformApprovalController) flowToView(f models.PlatformApprovalFlow) approvalFlowView {
var nodes []models.PlatformApprovalNode
_, _ = models.Orm.QueryTable(new(models.PlatformApprovalNode)).
Filter("flow_id", f.ID).Filter("is_deleted", 0).
OrderBy("sort", "id").All(&nodes)
view := approvalFlowView{
ID: f.ID,
ModuleCode: f.ModuleCode,
BizType: f.BizType,
FlowCode: f.FlowCode,
FlowName: f.FlowName,
Description: f.Description,
IsDefault: f.IsDefault,
Status: f.Status,
NodeCount: len(nodes),
CreateTime: f.CreateTime.Format("2006-01-02 15:04:05"),
Nodes: make([]approvalNodePayload, 0, len(nodes)),
}
edgeNodes := make([]flowEdgeNode, 0, len(nodes))
for _, n := range nodes {
view.Nodes = append(view.Nodes, approvalNodePayload{
NodeKey: n.NodeKey,
NodeName: n.NodeName,
NodeType: n.NodeType,
ApproverType: n.ApproverType,
ApproverIDs: parseIDSlice(n.ApproverIDs),
MultiMode: n.MultiMode,
AllowReturn: n.AllowReturn,
TimeoutHours: n.TimeoutHours,
Sort: n.Sort,
PosX: n.PosX,
PosY: n.PosY,
ConditionExpr: n.ConditionExpr,
})
edgeNodes = append(edgeNodes, flowEdgeNode{NodeKey: n.NodeKey, NextKeys: n.NextKeys, Condition: n.ConditionExpr})
}
view.Edges = rebuildEdges(edgeNodes)
return view
}
// FlowSave POST /platform/approval/flow/save
// 新建或更新流程;节点采用「全量覆盖」语义(先软删旧节点,再按传入顺序重建)。
func (c *PlatformApprovalController) FlowSave() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
var p approvalFlowSave
if err := c.readApprovalBody(&p); err != nil {
jsonErr(&c.Controller, 400, 400, err.Error())
return
}
if strings.TrimSpace(p.FlowName) == "" {
jsonErr(&c.Controller, 400, 400, "流程名称不能为空")
return
}
// 分类(module_code)与单据类型(biz_type)对齐:二者留空时互补,至少其一非空
if strings.TrimSpace(p.ModuleCode) == "" {
p.ModuleCode = p.BizType
}
if strings.TrimSpace(p.BizType) == "" {
p.BizType = p.ModuleCode
}
if strings.TrimSpace(p.BizType) == "" {
jsonErr(&c.Controller, 400, 400, "请选择所属分类")
return
}
if err := validateApprovalNodes(p.Nodes); err != nil {
jsonErr(&c.Controller, 400, 400, err.Error())
return
}
if err := validateApprovalGraph(p.Nodes, p.Edges); err != nil {
jsonErr(&c.Controller, 400, 400, err.Error())
return
}
g := buildGraph(p.Nodes, p.Edges)
now := time.Now()
o := models.Orm
var flowID uint64
var flowCode string
if p.ID > 0 {
var exist models.PlatformApprovalFlow
if err := o.QueryTable(new(models.PlatformApprovalFlow)).
Filter("id", p.ID).Filter("is_deleted", 0).One(&exist); err != nil {
jsonErr(&c.Controller, 404, 404, "流程不存在")
return
}
flowID = p.ID
flowCode = exist.FlowCode
_, err := o.QueryTable(new(models.PlatformApprovalFlow)).Filter("id", flowID).Update(orm.Params{
"module_code": p.ModuleCode,
"biz_type": p.BizType,
"flow_name": p.FlowName,
"description": p.Description,
"is_default": p.IsDefault,
"status": p.Status,
"update_time": now,
})
if err != nil {
jsonErr(&c.Controller, 500, 500, "保存失败: "+err.Error())
return
}
} else {
row := models.PlatformApprovalFlow{
ModuleCode: p.ModuleCode,
BizType: p.BizType,
FlowName: p.FlowName,
Description: p.Description,
IsDefault: p.IsDefault,
Status: p.Status,
CreateTime: now,
}
id, err := o.Insert(&row)
if err != nil {
jsonErr(&c.Controller, 500, 500, "保存失败: "+err.Error())
return
}
flowID = uint64(id)
flowCode = genApprovalFlowCode(flowID)
_, _ = o.QueryTable(new(models.PlatformApprovalFlow)).Filter("id", flowID).
Update(orm.Params{"flow_code": flowCode})
}
// 默认流程唯一:同 biz_type 下只允许一条 is_default=1
if p.IsDefault == 1 {
_, _ = o.QueryTable(new(models.PlatformApprovalFlow)).
Filter("biz_type", p.BizType).Filter("is_deleted", 0).
Exclude("id", flowID).Update(orm.Params{"is_default": 0, "update_time": now})
}
// 覆盖节点:软删旧节点后按 sort 重建
_, _ = o.QueryTable(new(models.PlatformApprovalNode)).
Filter("flow_id", flowID).Filter("is_deleted", 0).
Update(orm.Params{"is_deleted": 1, "delete_time": now})
for i, nd := range p.Nodes {
sort := nd.Sort
if sort == 0 {
sort = i + 1
}
nodeKey := strings.TrimSpace(nd.NodeKey)
if nodeKey == "" {
nodeKey = fmt.Sprintf("n%d", sort)
}
nodeType := nd.NodeType
if nodeType == 0 {
nodeType = models.ApprovalNodeApproval
}
multi := nd.MultiMode
if multi != models.ApprovalMultiAll {
multi = models.ApprovalMultiAny
}
timeout := nd.TimeoutHours
if timeout <= 0 {
timeout = 48
}
node := models.PlatformApprovalNode{
FlowID: flowID,
NodeKey: nodeKey,
NodeName: strings.TrimSpace(nd.NodeName),
NodeType: nodeType,
ApproverType: nd.ApproverType,
ApproverIDs: joinIDs(nd.ApproverIDs),
MultiMode: multi,
AllowReturn: nd.AllowReturn,
TimeoutHours: timeout,
Sort: sort,
PosX: nd.PosX,
PosY: nd.PosY,
PrevKeys: strings.Join(g.prev[nodeKey], ","),
NextKeys: strings.Join(g.next[nodeKey], ","),
ConditionExpr: g.cond[nodeKey],
CreateTime: now,
}
if _, err := o.Insert(&node); err != nil {
jsonErr(&c.Controller, 500, 500, "保存节点失败: "+err.Error())
return
}
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "保存成功", "data": map[string]interface{}{"id": flowID, "flow_code": flowCode}}
_ = c.ServeJSON()
}
// Pick GET /platform/approval/flow/pick
// 轻量清单:按分类返回启用流程的 {flow_code, flow_name},供其它功能“选/填编号调用”。
func (c *PlatformApprovalController) Pick() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
module := strings.TrimSpace(c.GetString("module"))
if module == "" {
module = strings.TrimSpace(c.GetString("biz_type"))
}
qs := models.Orm.QueryTable(new(models.PlatformApprovalFlow)).
Filter("is_deleted", 0).Filter("status", 1)
if module != "" {
qs = qs.Filter("biz_type", module)
}
var rows []models.PlatformApprovalFlow
_, _ = qs.OrderBy("biz_type", "id").All(&rows)
list := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
list = append(list, map[string]interface{}{
"module_code": r.ModuleCode,
"biz_type": r.BizType,
"flow_code": r.FlowCode,
"flow_name": r.FlowName,
})
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": list}
_ = c.ServeJSON()
}
// FlowDelete DELETE /platform/approval/flow/delete/:id
// 软删流程及其节点。存在在途实例时禁止删除,避免审批中断。
func (c *PlatformApprovalController) FlowDelete() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
var f models.PlatformApprovalFlow
if err := models.Orm.QueryTable(new(models.PlatformApprovalFlow)).
Filter("id", id).Filter("is_deleted", 0).One(&f); err != nil {
jsonErr(&c.Controller, 404, 404, "流程不存在")
return
}
running, _ := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("flow_id", id).Filter("scope", models.ApprovalScopePlatform).
Filter("status", models.ApprovalInstanceRunning).Filter("is_deleted", 0).Count()
if running > 0 {
jsonErr(&c.Controller, 400, 400, fmt.Sprintf("该流程还有 %d 条在途审批,暂不能删除", running))
return
}
now := time.Now()
_, _ = models.Orm.QueryTable(new(models.PlatformApprovalFlow)).Filter("id", id).
Update(orm.Params{"is_deleted": 1, "delete_time": now, "update_time": now})
_, _ = models.Orm.QueryTable(new(models.PlatformApprovalNode)).Filter("flow_id", id).
Update(orm.Params{"is_deleted": 1, "delete_time": now})
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "已删除"}
_ = c.ServeJSON()
}
// Options GET /platform/approval/options
// 编排界面「选审批人」用:返回平台角色(cid=1)与平台用户列表。
func (c *PlatformApprovalController) Options() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
var roles []models.AdminRole
_, _ = models.Orm.QueryTable(new(models.AdminRole)).
Filter("cid", 1).Filter("status", 1).Filter("delete_time__isnull", true).
OrderBy("id").All(&roles)
roleList := make([]map[string]interface{}, 0, len(roles))
for _, r := range roles {
roleList = append(roleList, map[string]interface{}{"id": r.ID, "name": r.Name})
}
var users []models.AdminUser
_, _ = models.Orm.QueryTable(new(models.AdminUser)).
Filter("status", 1).Filter("delete_time__isnull", true).
OrderBy("id").All(&users)
userList := make([]map[string]interface{}, 0, len(users))
for _, u := range users {
userList = append(userList, map[string]interface{}{
"id": u.ID,
"name": approverDisplayName(u.Name, u.Account),
"role": u.RoleID,
})
}
// 分类(复用功能模块):Web 可见、启用、未删除
var mods []models.SystemModules
_, _ = models.Orm.QueryTable(new(models.SystemModules)).
Filter("delete_time__isnull", true).Filter("status", 1).Filter("is_show", 1).
OrderBy("sort", "id").All(&mods)
mods = services.FilterModulesByPlatform(mods, models.PlatformWeb)
moduleList := make([]map[string]interface{}, 0, len(mods))
for _, m := range mods {
moduleList = append(moduleList, map[string]interface{}{
"code": m.Code, "name": m.Name, "enabled": true,
})
}
c.Data["json"] = map[string]interface{}{
"code": 200, "msg": "ok",
"data": map[string]interface{}{"roles": roleList, "users": userList, "modules": moduleList},
}
_ = c.ServeJSON()
}
// BizTypes GET /platform/approval/biz-types
// 可绑定业务类型字典(当期平台侧为「域名申请」),供配置界面顶级下拉。
func (c *PlatformApprovalController) BizTypes() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": []map[string]interface{}{
{"value": models.ApprovalBizDomain, "label": "域名申请"},
}}
_ = c.ServeJSON()
}
// ============================ 辅助函数 ============================
// approverDisplayName 优先姓名,回退账号(AdminUser.Account 为普通字符串)
func approverDisplayName(name *string, account string) string {
if name != nil && strings.TrimSpace(*name) != "" {
return strings.TrimSpace(*name)
}
return strings.TrimSpace(account)
}
// validateApprovalNodes 校验审批节点:必须选定审批人来源且非空(开始/结束/条件网关跳过)
func validateApprovalNodes(nodes []approvalNodePayload) error {
for i, nd := range nodes {
if nd.NodeType == models.ApprovalNodeStart || nd.NodeType == models.ApprovalNodeEnd ||
nd.NodeType == models.ApprovalNodeCondition {
continue
}
if strings.TrimSpace(nd.NodeName) == "" {
return fmt.Errorf("第 %d 个节点名称不能为空", i+1)
}
if nd.ApproverType != models.ApprovalApproverUser && nd.ApproverType != models.ApprovalApproverRole {
return fmt.Errorf("节点「%s」审批人类型非法(仅支持指定用户/指定角色)", nd.NodeName)
}
if len(nd.ApproverIDs) == 0 {
return fmt.Errorf("节点「%s」未选择审批人", nd.NodeName)
}
}
return nil
}
// joinIDs 将 ID 数组拼成逗号分隔串(存库口径)
func joinIDs(ids []uint64) string {
parts := make([]string, 0, len(ids))
for _, id := range ids {
if id > 0 {
parts = append(parts, strconv.FormatUint(id, 10))
}
}
return strings.Join(parts, ",")
}
// parseIDSlice 解析逗号分隔 ID 串为数组
func parseIDSlice(raw string) []uint64 {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
out := make([]uint64, 0)
for _, p := range strings.Split(raw, ",") {
if v, err := strconv.ParseUint(strings.TrimSpace(p), 10, 64); err == nil && v > 0 {
out = append(out, v)
}
}
return out
}
// ============================ 图(连线)辅助 ============================
// flowEdgeNode 连线重建输入:节点标识 + 出边(next_keys) + 本节点进入条件
type flowEdgeNode struct {
NodeKey string
NextKeys string
Condition string
}
// splitKeyList 拆分逗号分隔 node_key 列表
func splitKeyList(raw string) []string {
out := make([]string, 0, 4)
for _, p := range strings.Split(raw, ",") {
if s := strings.TrimSpace(p); s != "" {
out = append(out, s)
}
}
return out
}
// containsStr 列表是否包含某字符串
func containsStr(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// approvalGraph 由画布连线反推的邻接与条件
type approvalGraph struct {
next map[string][]string
prev map[string][]string
cond map[string]string
}
// buildGraph 由 edges 反推每个节点的 prev_keys/next_keys 与进入条件(条件落到目标节点)
func buildGraph(nodes []approvalNodePayload, edges []approvalEdge) approvalGraph {
g := approvalGraph{next: map[string][]string{}, prev: map[string][]string{}, cond: map[string]string{}}
for _, e := range edges {
f := strings.TrimSpace(e.From)
t := strings.TrimSpace(e.To)
if f == "" || t == "" || f == t {
continue
}
if !containsStr(g.next[f], t) {
g.next[f] = append(g.next[f], t)
}
if !containsStr(g.prev[t], f) {
g.prev[t] = append(g.prev[t], f)
}
if c := strings.TrimSpace(e.Condition); c != "" {
g.cond[t] = c
}
}
return g
}
// rebuildEdges 由节点 next_keys 邻接重建画布连线;连线条件取目标节点 condition_expr
func rebuildEdges(nodes []flowEdgeNode) []approvalEdge {
condByKey := make(map[string]string, len(nodes))
for _, n := range nodes {
condByKey[n.NodeKey] = n.Condition
}
edges := make([]approvalEdge, 0)
for _, n := range nodes {
for _, to := range splitKeyList(n.NextKeys) {
edges = append(edges, approvalEdge{From: n.NodeKey, To: to, Condition: condByKey[to]})
}
}
return edges
}
// validateApprovalGraph 校验画布图的完整性:单一开始、审批节点≥1、网关出边≥2、全部可达、连线端点有效
func validateApprovalGraph(nodes []approvalNodePayload, edges []approvalEdge) error {
if len(nodes) == 0 {
return fmt.Errorf("流程至少需包含开始与审批节点")
}
keySet := map[string]bool{}
var startCount, approvalCount int
var condKeys []string
var startKey string
for _, n := range nodes {
k := strings.TrimSpace(n.NodeKey)
if k == "" {
return fmt.Errorf("存在缺少标识的节点,请重新连线后再保存")
}
if keySet[k] {
return fmt.Errorf("节点标识重复:%s", k)
}
keySet[k] = true
switch n.NodeType {
case models.ApprovalNodeStart:
startCount++
startKey = k
case models.ApprovalNodeApproval:
approvalCount++
case models.ApprovalNodeCondition:
condKeys = append(condKeys, k)
}
}
if startCount != 1 {
return fmt.Errorf("流程需有且仅有一个「开始」节点")
}
if approvalCount < 1 {
return fmt.Errorf("流程需至少包含一个「审批」节点")
}
nextAdj := map[string][]string{}
for _, e := range edges {
f := strings.TrimSpace(e.From)
t := strings.TrimSpace(e.To)
if !keySet[f] || !keySet[t] {
return fmt.Errorf("存在连向未知节点的连线,请检查画布")
}
nextAdj[f] = append(nextAdj[f], t)
}
for _, k := range condKeys {
if len(nextAdj[k]) < 2 {
return fmt.Errorf("条件网关需至少两条分支连线")
}
}
visited := map[string]bool{}
queue := []string{startKey}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
if visited[cur] {
continue
}
visited[cur] = true
queue = append(queue, nextAdj[cur]...)
}
for _, n := range nodes {
if !visited[strings.TrimSpace(n.NodeKey)] {
return fmt.Errorf("节点「%s」未接入流程,请检查连线", strings.TrimSpace(n.NodeName))
}
}
return nil
}
// genApprovalFlowCode 由自增 id 生成审批流编号(全局唯一、供各程序按编号发起)
func genApprovalFlowCode(id uint64) string {
return fmt.Sprintf("FL%06d", id)
}
+37 -18
View File
@@ -463,7 +463,8 @@ func (c *PlatformTenantDomainController) CustomDomainGuide() {
// Apply POST /platform/domain/tenant/apply body:{tid,sub_domain,main_domain}
func (c *PlatformTenantDomainController) Apply() {
if _, err := requirePlatform(&c.Controller); err != nil {
claims, err := requirePlatform(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
@@ -551,13 +552,21 @@ func (c *PlatformTenantDomainController) Apply() {
jsonErr(&c.Controller, 500, 500, "申请失败: "+err.Error())
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "申请提交成功,等待审核", "data": map[string]interface{}{"id": uint64(id)}}
// 进入平台侧审批流(严格:未配置流程/无有效审批人则提交失败并回滚)
if errMsg := submitDomainApprovalChecked(uint64(id), p.Tid, uint64(claims.UserID), claims.Username, full); errMsg != "" {
jsonErr(&c.Controller, 400, 400, errMsg)
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "申请提交成功,等待审批", "data": map[string]interface{}{"id": uint64(id)}}
_ = c.ServeJSON()
}
// Audit POST /platform/domain/tenant/audit body:{id,action} action=approve/reject
// Audit POST /platform/domain/tenant/audit body:{id,action,comment} action=approve/reject
// 引擎驱动:已配置审批流则走多级流转,仅当前待办人可操作;
// 未配置流程(无在途实例)则回退到单级直改状态,保证兼容。
func (c *PlatformTenantDomainController) Audit() {
if _, err := requirePlatform(&c.Controller); err != nil {
claims, err := requirePlatform(&c.Controller)
if err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
@@ -567,8 +576,9 @@ func (c *PlatformTenantDomainController) Audit() {
return
}
var p struct {
ID uint64 `json:"id"`
Action string `json:"action"`
ID uint64 `json:"id"`
Action string `json:"action"`
Comment string `json:"comment"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.ID == 0 {
jsonErr(&c.Controller, 400, 400, "参数错误")
@@ -587,25 +597,34 @@ func (c *PlatformTenantDomainController) Audit() {
jsonErr(&c.Controller, 400, 400, "该域名已审核过了")
return
}
newStatus := 2
msg := "已拒绝"
if strings.ToLower(strings.TrimSpace(p.Action)) == "approve" {
newStatus = 1
msg = "审核通过"
pass := strings.ToLower(strings.TrimSpace(p.Action)) == "approve"
var bizTid uint64
if row.Tid != nil {
bizTid = *row.Tid
}
now := time.Now()
_, err = models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", p.ID).Update(map[string]interface{}{
"status": newStatus,
"update_time": now,
})
if err != nil {
jsonErr(&c.Controller, 500, 500, "审核失败: "+err.Error())
// 严格引擎驱动:必须命中在途实例与当前待办,否则拒绝(已移除单级兼容回退)
msg, code := engineDomainAudit(p.ID, uint64(claims.UserID), pass, p.Comment, bizTid)
if code != 200 {
jsonErr(&c.Controller, code, code, msg)
return
}
c.Data["json"] = map[string]interface{}{"code": 200, "msg": msg}
_ = c.ServeJSON()
}
// AuditProgress GET /platform/domain/tenant/audit/progress/:id
// 返回该域名的审批进度(节点 + 各审批人状态),供平台端展示流转。
func (c *PlatformTenantDomainController) AuditProgress() {
if _, err := requirePlatform(&c.Controller); err != nil {
jsonErr(&c.Controller, 401, 401, err.Error())
return
}
id, _ := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
c.Data["json"] = map[string]interface{}{"code": 200, "msg": "ok", "data": domainApprovalProgress(id)}
_ = c.ServeJSON()
}
// ToggleStatus POST /platform/domain/tenant/toggleStatus body:{id}
func (c *PlatformTenantDomainController) ToggleStatus() {
if _, err := requirePlatform(&c.Controller); err != nil {
+171
View File
@@ -0,0 +1,171 @@
package controllers
import (
"fmt"
"time"
"server/models"
"server/services/approval"
"github.com/beego/beego/v2/client/orm"
)
// =============================================================
// 域名申请 × 审批流引擎 的接入层(严格模式,无单级兼容)
//
// 口径:
// - 域名申请提交必须命中已配置的默认流程,走引擎多级流转;
// - 未配置流程 / 节点无有效审批人 → 提交失败并回滚刚创建的域名行,绝不留下无法审批的脏数据;
// - 审核只能通过引擎:必须存在在途实例且命中当前审批人的待办,否则拒绝操作。
// =============================================================
// submitDomainApprovalChecked 将域名提交进平台侧审批流。
// 成功返回空串;失败则软删回滚刚创建的域名行,并返回给前端的错误文案。
func submitDomainApprovalChecked(domainRowID, bizTid, submitUID uint64, submitName, fullDomain string) string {
_, err := approval.Submit(approval.SubmitInput{
Scope: models.ApprovalScopePlatform,
BizTid: bizTid,
BizType: models.ApprovalBizDomain,
BizID: domainRowID,
BizTitle: fullDomain,
SubmitUID: submitUID,
SubmitName: submitName,
})
if err == nil {
return ""
}
// 提交失败:回滚刚创建的域名行(软删除),避免出现无法审批的待审僵尸数据
_, _ = models.Orm.QueryTable(new(models.SystemTenantDomain)).
Filter("id", domainRowID).Update(map[string]interface{}{"delete_time": time.Now()})
if _, ok := err.(*approval.ErrNoFlow); ok {
return "平台尚未配置域名审批流程,无法提交申请"
}
if _, ok := err.(*approval.ErrNoApprover); ok {
return "域名审批流程未配置有效审批人,无法提交申请"
}
return "提交审批失败:" + err.Error()
}
// findRunningDomainInstance 取某域名当前在途的平台侧审批实例
func findRunningDomainInstance(domainID uint64) (*models.ApprovalInstance, bool) {
var inst models.ApprovalInstance
err := models.Orm.QueryTable(new(models.ApprovalInstance)).
Filter("scope", models.ApprovalScopePlatform).
Filter("biz_type", models.ApprovalBizDomain).
Filter("biz_id", domainID).
Filter("status", models.ApprovalInstanceRunning).
Filter("is_deleted", 0).
OrderBy("-id").One(&inst)
if err == orm.ErrNoRows {
return nil, false
}
if err != nil {
return nil, false
}
return &inst, true
}
// myPendingDomainTask 取指定审批人在该实例上的当前待办任务
func myPendingDomainTask(instID, approverID uint64) (*models.ApprovalTask, bool) {
var task models.ApprovalTask
err := models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("instance_id", instID).
Filter("approver_id", approverID).
Filter("status", models.ApprovalTaskPending).
Filter("is_deleted", 0).
OrderBy("sort", "id").One(&task)
if err != nil {
return nil, false
}
return &task, true
}
// applyDomainApprovalResult 依据引擎结果回写域名状态(仅实例终结时改动)
func applyDomainApprovalResult(domainID uint64, res *approval.ApproveResult) {
if res == nil || !res.Finished {
return
}
newStatus := 2 // 默认拒绝/驳回
if res.InstanceStat == models.ApprovalInstanceApproved {
newStatus = 1 // 通过生效
}
now := time.Now()
_, _ = models.Orm.QueryTable(new(models.SystemTenantDomain)).Filter("id", domainID).
Update(orm.Params{"status": newStatus, "update_time": now})
}
// domainApprovalProgress 组装域名审批进度(节点 + 任务),供平台端展示
func domainApprovalProgress(domainID uint64) map[string]interface{} {
inst, ok := findRunningDomainInstance(domainID)
if !ok {
// 无在途实例:可能未走引擎(单级审核)或已结束
return map[string]interface{}{"engine": false}
}
flow, err := approval.LoadFlow(models.ApprovalScopePlatform, 0, models.ApprovalBizDomain, inst.FlowID)
nodes := []map[string]interface{}{}
if err == nil && flow != nil {
for _, nd := range flow.Nodes {
nodes = append(nodes, map[string]interface{}{
"node_key": nd.NodeKey,
"node_name": nd.NodeName,
"node_type": nd.NodeType,
"sort": nd.Sort,
})
}
}
var tasks []models.ApprovalTask
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
Filter("instance_id", inst.ID).Filter("is_deleted", 0).
OrderBy("sort", "id").All(&tasks)
taskList := make([]map[string]interface{}, 0, len(tasks))
for _, t := range tasks {
taskList = append(taskList, map[string]interface{}{
"id": t.ID,
"node_key": t.NodeKey,
"node_name": t.NodeName,
"approver_id": t.ApproverID,
"approver_name": t.ApproverName,
"status": t.Status,
"action": t.Action,
"comment": t.Comment,
})
}
return map[string]interface{}{
"engine": true,
"instance_id": inst.ID,
"current_node": inst.CurrentNode,
"status": inst.Status,
"nodes": nodes,
"tasks": taskList,
}
}
// engineDomainAudit 平台端审批入口(严格引擎驱动):
// 必须存在在途实例且命中当前审批人的待办;否则拒绝,不做任何单级回退。
func engineDomainAudit(domainID, adminID uint64, pass bool, comment string, bizTid uint64) (msg string, code int) {
inst, ok := findRunningDomainInstance(domainID)
if !ok {
return "该域名申请未进入审批流程(未配置流程或已处理)", 400
}
task, ok := myPendingDomainTask(inst.ID, adminID)
if !ok {
return "该单据在审批流程中,当前不在您的待办节点", 403
}
res, err := approval.Approve(task.ID, approval.ApproveInput{
ApproverID: adminID,
Pass: pass,
Comment: comment,
BizTid: bizTid,
})
if err != nil {
return err.Error(), 400
}
applyDomainApprovalResult(domainID, res)
if res.Finished {
if res.InstanceStat == models.ApprovalInstanceApproved {
return "审批通过,域名已生效", 200
}
return "已驳回", 200
}
return fmt.Sprintf("已%s,流程继续(%s)", map[bool]string{true: "通过", false: "驳回"}[pass], res.Message), 200
}
@@ -9,14 +9,15 @@ import (
beego "github.com/beego/beego/v2/server/web"
)
// ApiPlatformUpgradeController 平台更新内容接口
type ApiPlatformUpgradeController struct {
// PlatformUpgradeNoticeController 平台端「平台更新公告」接口。
// 数据同源于 models.PlatformUpgradeNotice,但作为平台端独立接口,
// 不与移动端 /api、官网 /index 复用同一 handler。
type PlatformUpgradeNoticeController struct {
beego.Controller
}
// List GET /api/platform/upgrade/list 获取平台更新列表(公开接口)
func (c *ApiPlatformUpgradeController) List() {
// 可选参数:limit - 返回数量限制,默认 10
// List GET /platform/upgrade/list 获取平台更新列表
func (c *PlatformUpgradeNoticeController) List() {
limitStr := c.GetString("limit")
limit := 10
if limitStr != "" {
@@ -29,7 +30,6 @@ func (c *ApiPlatformUpgradeController) List() {
limit = 10
}
// 查询显示状态的更新记录,按排序字段和创建时间倒序
var upgradeList []models.PlatformUpgradeNotice
qs := models.Orm.QueryTable(new(models.PlatformUpgradeNotice)).
Filter("status", 1).
@@ -45,7 +45,6 @@ func (c *ApiPlatformUpgradeController) List() {
return
}
// 转换为前端需要的格式
data := make([]map[string]interface{}, 0, len(upgradeList))
for _, item := range upgradeList {
record := map[string]interface{}{
@@ -66,8 +65,8 @@ func (c *ApiPlatformUpgradeController) List() {
_ = c.ServeJSON()
}
// Detail GET /api/platform/upgrade/detail?id={id} 获取单条更新详情
func (c *ApiPlatformUpgradeController) Detail() {
// Detail GET /platform/upgrade/detail?id={id} 获取单条更新详情
func (c *PlatformUpgradeNoticeController) Detail() {
idStr := strings.TrimSpace(c.GetString("id"))
if idStr == "" {
c.Data["json"] = map[string]interface{}{
+27
View File
@@ -6,6 +6,7 @@ import (
"server/models"
"github.com/beego/beego/v2/client/orm"
beego "github.com/beego/beego/v2/server/web"
)
@@ -552,6 +553,32 @@ func (c *PlatformWebsitePageController) Get() {
websiteOK(&c.Controller, row)
}
// GetByPath GET /platform/website/page/path/:path — 按路径读取单页(平台端独立接口)
func (c *PlatformWebsitePageController) GetByPath() {
if _, ok := websiteAuth(&c.Controller); !ok {
return
}
path := normalizePath(c.Ctx.Input.Param(":path"))
if path == "" {
jsonErr(&c.Controller, 400, 400, "路径标识不能为空")
return
}
var row models.PlatformWebsitePage
err := models.Orm.QueryTable(new(models.PlatformWebsitePage)).
Filter("path", path).
Filter("delete_time__isnull", true).
One(&row)
if err == orm.ErrNoRows {
websiteOK(&c.Controller, nil)
return
}
if err != nil {
jsonErr(&c.Controller, 500, 500, "获取单页失败: "+err.Error())
return
}
websiteOK(&c.Controller, row)
}
// Create POST /platform/website/page
func (c *PlatformWebsitePageController) Create() {
if _, ok := websiteAuth(&c.Controller); !ok {
+198
View File
@@ -0,0 +1,198 @@
-- =============================================================
-- 自定义审批流(租户侧 + 平台侧)
--
-- 对应模型:go/models/approval.go
--
-- 设计要点:
-- 1. 配置侧与实例侧分离:flow/node 是配置(租户可编辑),
-- instance/task 是运行时(提交后生成,不可编辑)。
-- 2. 节点表用 prev_keys / next_keys 存连线,当期顺序链只填 sort,
-- 后续上拖拽图与条件分支无需改表。
-- 3. scope 区分租户侧(tenant)与平台侧(platform):平台账号不归属租户,
-- 其流程不带 tid,实例用 biz_tid 标记申请来源租户。
--
-- 注意:不显式指定 COLLATE,沿用数据库默认排序规则,避免与既有表
-- (utf8mb4_0900_ai_ci)在 JOIN 比较字符串时报 Illegal mix of collations。
-- =============================================================
SET NAMES utf8mb4;
-- ---------------- 1. 租户侧审批流程 ----------------
CREATE TABLE IF NOT EXISTS `yz_backend_approval_flow` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`module_code` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '所属功能模块 code(yz_system_modules.code):oa/erp/crm/cms',
`biz_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '适用单据类型:reimburse/bid/domain/...',
`flow_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '审批流编号(自动生成、全局唯一,供各程序按编号发起):FL000007',
`flow_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '流程名称',
`description` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '流程说明',
`is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否该单据类型的默认流程:1是 0否',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
`is_deleted` TINYINT NOT NULL DEFAULT 0,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_tid` (`tid`),
KEY `idx_tid_module` (`tid`,`module_code`),
KEY `idx_tid_biz` (`tid`,`biz_type`),
KEY `idx_tid_code` (`tid`,`flow_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-自定义审批流程(租户侧)';
-- ---------------- 2. 租户侧审批节点 ----------------
CREATE TABLE IF NOT EXISTS `yz_backend_approval_node` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`flow_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属流程ID',
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID(冗余,便于按租户清理)',
`node_key` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '节点唯一标识(流程内唯一)',
`node_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '节点名称',
`node_type` TINYINT NOT NULL DEFAULT 2 COMMENT '1开始 2审批 3结束 4条件分支',
`approver_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1指定用户 2指定角色 3部门主管 4指定职位 5发起人本人 6发起人主管',
`approver_ids` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '审批人ID列表,逗号分隔(user=SystemTenantUser.uid / role=AdminRole.id / position=BackendPosition.id)',
`multi_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '多人审批方式:1或签(任一通过) 2会签(全部通过)',
`allow_return` TINYINT NOT NULL DEFAULT 0 COMMENT '是否允许退回上一节点:1是 0否',
`timeout_hours` INT NOT NULL DEFAULT 48 COMMENT '超时小时数,超时后自动通过',
`prev_keys` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '前置节点 node_key 列表,逗号分隔(为条件分支预留)',
`next_keys` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '后继节点 node_key 列表,逗号分隔(为条件分支预留)',
`condition_expr` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '进入条件表达式,如 amount>5000(条件分支网关的出边条件)',
`sort` INT NOT NULL DEFAULT 0 COMMENT '顺序号,顺序链按此升序流转',
`pos_x` INT NOT NULL DEFAULT 0 COMMENT '画布X坐标(图编辑器回显)',
`pos_y` INT NOT NULL DEFAULT 0 COMMENT '画布Y坐标(图编辑器回显)',
`is_deleted` TINYINT NOT NULL DEFAULT 0,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_flow` (`flow_id`),
KEY `idx_tid` (`tid`),
KEY `idx_flow_sort` (`flow_id`,`sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-自定义审批节点(租户侧)';
-- ---------------- 3. 平台侧审批流程 ----------------
CREATE TABLE IF NOT EXISTS `yz_platform_approval_flow` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`module_code` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '所属平台模块 code',
`biz_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '适用单据类型:domain/tenant/...',
`flow_code` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '审批流编号(自动生成、全局唯一):FL000007',
`flow_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '流程名称',
`description` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '流程说明',
`is_default` TINYINT NOT NULL DEFAULT 0 COMMENT '是否默认流程:1是 0否',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1启用 0停用',
`is_deleted` TINYINT NOT NULL DEFAULT 0,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_module` (`module_code`),
KEY `idx_biz` (`biz_type`),
KEY `idx_code` (`flow_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台-自定义审批流程';
-- ---------------- 4. 平台侧审批节点 ----------------
CREATE TABLE IF NOT EXISTS `yz_platform_approval_node` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`flow_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '所属流程ID',
`node_key` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '节点唯一标识(流程内唯一)',
`node_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '节点名称',
`node_type` TINYINT NOT NULL DEFAULT 2 COMMENT '1开始 2审批 3结束 4条件分支',
`approver_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1指定平台用户 2指定平台角色 5发起人本人',
`approver_ids` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '审批人ID列表,逗号分隔(user=AdminUser.id / role=AdminRole.id)',
`multi_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '1或签 2会签',
`allow_return` TINYINT NOT NULL DEFAULT 0 COMMENT '是否允许退回:1是 0否',
`timeout_hours` INT NOT NULL DEFAULT 48 COMMENT '超时小时数',
`prev_keys` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '前置节点(为条件分支预留)',
`next_keys` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '后继节点(为条件分支预留)',
`condition_expr` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '进入条件(条件分支网关出边条件)',
`sort` INT NOT NULL DEFAULT 0 COMMENT '顺序号',
`pos_x` INT NOT NULL DEFAULT 0 COMMENT '画布X坐标(图编辑器回显)',
`pos_y` INT NOT NULL DEFAULT 0 COMMENT '画布Y坐标(图编辑器回显)',
`is_deleted` TINYINT NOT NULL DEFAULT 0,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_flow` (`flow_id`),
KEY `idx_flow_sort` (`flow_id`,`sort`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台-自定义审批节点';
-- ---------------- 5. 审批实例(运行时,租户侧与平台侧共用) ----------------
CREATE TABLE IF NOT EXISTS `yz_approval_instance` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`scope` TINYINT NOT NULL DEFAULT 1 COMMENT '1租户侧 2平台侧',
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID(租户侧=本租户;平台侧=发起申请的平台账号所属租户,平台账号通常为0)',
`biz_tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '业务来源租户ID(平台侧审批必填:申请由哪个租户提交)',
`flow_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '流程ID(配置可能被改,实例内节点快照为准)',
`biz_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '单据类型:reimburse/bid/domain',
`biz_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '单据ID',
`biz_title` VARCHAR(200) NOT NULL DEFAULT '' COMMENT '单据标题快照',
`submit_user_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '发起人ID',
`submit_user_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '发起人姓名快照',
`submit_dept_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '发起人部门ID快照(部门主管审批依赖)',
`form_data` TEXT NULL COMMENT '发起表单数据快照(JSON),条件分支求值依据',
`current_node` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '当前节点 node_key',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '0草稿 1审批中 2已通过 3已驳回 4已撤回 5已取消',
`submit_time` DATETIME DEFAULT NULL COMMENT '提交时间',
`finish_time` DATETIME DEFAULT NULL COMMENT '完成时间',
`is_deleted` TINYINT NOT NULL DEFAULT 0,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_scope_tid` (`scope`,`tid`),
KEY `idx_biz` (`biz_type`,`biz_id`),
KEY `idx_biz_tid` (`scope`,`biz_tid`,`biz_type`),
KEY `idx_status` (`status`),
KEY `idx_flow` (`flow_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批实例(运行时)';
-- ---------------- 6. 审批任务(运行时,节点级待办) ----------------
CREATE TABLE IF NOT EXISTS `yz_approval_task` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`instance_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '实例ID',
`scope` TINYINT NOT NULL DEFAULT 1 COMMENT '1租户侧 2平台侧',
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
`biz_type` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '单据类型(冗余,便于列表筛选)',
`biz_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '单据ID(冗余)',
`node_key` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '节点 node_key',
`node_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '节点名称快照',
`sort` INT NOT NULL DEFAULT 0 COMMENT '节点顺序号快照',
`approver_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '审批人ID',
`approver_name` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '审批人姓名快照',
`multi_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '1或签 2会签',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待审批 1已通过 2已驳回 3已退回 4已跳过 5已超时',
`action` TINYINT NOT NULL DEFAULT 0 COMMENT '1提交 2通过 3驳回 4退回 5撤回 6超时自动通过',
`comment` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '审批意见',
`deadline` DATETIME DEFAULT NULL COMMENT '超时时间点',
`finish_time` DATETIME DEFAULT NULL COMMENT '处理时间',
`is_deleted` TINYINT NOT NULL DEFAULT 0,
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`delete_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_instance` (`instance_id`),
KEY `idx_approver` (`scope`,`approver_id`,`status`),
KEY `idx_biz` (`biz_type`,`biz_id`),
KEY `idx_tid` (`tid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='审批任务(运行时·待办)';
-- =============================================================
-- 旧表迁移说明:
-- yz_backend_approval_flows(扁平步骤行结构)仅被 OA 报销 Submit 读取。
-- 第一期接管报销后改走新引擎,旧表保留只读兼容,不再写入。
-- 如需保留历史配置,可按如下语句转换(按需执行):
--
-- INSERT INTO yz_backend_approval_flow
-- (tid, module_code, biz_type, flow_name, is_default, status)
-- SELECT DISTINCT tid, 'oa', 'reimburse', flow_name, 1, 1
-- FROM yz_backend_approval_flows
-- WHERE is_active = 1 AND is_deleted = 0
-- GROUP BY tid, flow_name;
--
-- INSERT INTO yz_backend_approval_node
-- (flow_id, tid, node_key, node_name, node_type, approver_type, approver_ids, timeout_hours, sort)
-- SELECT f.id, f.tid, CONCAT('step_', s.step_order), s.flow_name, 2, 2, '', s.timeout_hours, s.step_order
-- FROM yz_backend_approval_flows s
-- JOIN yz_backend_approval_flow f
-- ON f.tid = s.tid AND f.biz_type = 'reimburse'
-- WHERE s.is_active = 1 AND s.is_deleted = 0;
-- =============================================================
+38
View File
@@ -0,0 +1,38 @@
-- ============================================================
-- 审批流配置 —— 平台端菜单(yz_system_menu)
-- 挂在「域名管理」目录(path = /system/domains)下,紧邻「域名审核」。
-- 幂等脚本:按 path 判重,重复执行不会产生重复菜单。
-- 执行:mysql -h <host> -P <port> -u <user> -p <db> < platform_approval_menu.sql
-- ============================================================
-- 字段说明:
-- type 1=目录 2=页面;views [1]=平台端;is_visible/status=1 显示并启用
-- path 与 platform 前端静态路由 /system/approval 一致(component 同源,双保险)
SET @domain_dir := (
SELECT id FROM yz_system_menu
WHERE path = '/system/domains' AND delete_time IS NULL
LIMIT 1
);
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
SELECT @domain_dir, '审批流配置', '/system/approval', 'system/approval/index.vue',
'SetUp', 5, 1, 1, '[1]', 2, 'system', '平台侧审批流节点编排(域名申请等管控场景)'
FROM DUAL
WHERE @domain_dir IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/system/approval' AND delete_time IS NULL) x);
-- 若未找到「域名管理」目录,则退化为平台端顶级菜单(pid=0)
INSERT INTO yz_system_menu
(pid, title, path, component_path, icon, sort, status, is_visible, views, type, creater, remark)
SELECT 0, '审批流配置', '/system/approval', 'system/approval/index.vue',
'SetUp', 90, 1, 1, '[1]', 2, 'system', '平台侧审批流节点编排(域名申请等管控场景)'
FROM DUAL
WHERE @domain_dir IS NULL
AND NOT EXISTS (SELECT 1 FROM (SELECT id FROM yz_system_menu WHERE path = '/system/approval' AND delete_time IS NULL) x);
-- 结果校验
SELECT id, pid, title, path, component_path, views, type, is_visible, status
FROM yz_system_menu
WHERE delete_time IS NULL AND path = '/system/approval';
+23 -14
View File
@@ -25,9 +25,16 @@ const (
)
// protectedPrefixes 需要鉴权的业务前缀。
// 仅保护业务 API:官网(/ /site/ /index/…)、开放 API(/api/…)、
// 仅保护业务 API:官网(/ /site/ /index/…)、开放/登录器接口(/api/ 下的公开项)、
// 静态资源与 ACME 验证路径均不在其中,避免误伤公开接口。
var protectedPrefixes = []string{"/backend/", "/platform/", "/app/"}
// 移动端(uniapp)已并入 /api/,故显式列出其中需要鉴权的移动端前缀,
// 而登录器公开接口(/api/getcard、/api/cursor/*、/api/softwareupgrade/check 等)
// 不在下列前缀内,保持公开。
var protectedPrefixes = []string{
"/backend/", "/platform/",
"/api/oa/", "/api/notebook/", "/api/schedule", "/api/currentUser",
"/api/activity/", "/api/home", "/api/quick-menus",
}
// authWhitelist 受保护前缀内无需鉴权的路径(前缀匹配)
var authWhitelist = []string{
@@ -44,23 +51,25 @@ var authWhitelist = []string{
"/backend/verifyAccount",
"/backend/logout",
// platform 登录/找回密码
// platform 登录/注册/找回密码
"/platform/login",
"/platform/sendLoginCode",
"/platform/register",
"/platform/sendRegisterCode",
"/platform/resetPassword",
"/platform/logout",
// 平台开放接口(客户端更新通知,无需登录)
"/platform/api/",
// app 登录/注册/找回密码
"/app/login",
"/app/sendLoginCode",
"/app/register",
"/app/sendRegisterCode",
"/app/resetPassword",
"/app/sendResetCode",
"/app/verifyAccount",
"/app/logout",
// 移动端(/api)登录/注册/找回密码(/api/login 前缀同时覆盖 loginBySms、极验配置)
"/api/login",
"/api/sendLoginCode",
"/api/register",
"/api/sendRegisterCode",
"/api/resetPassword",
"/api/sendResetCode",
"/api/verifyAccount",
"/api/logout",
// 登录器日程提醒确认(公开回调)
"/api/schedule/reminder/ack",
}
var (
+40 -24
View File
@@ -60,16 +60,16 @@ func BeginOperationLog(ctx *context.Context) {
go func() {
time.Sleep(100 * time.Millisecond) // 等待响应完成
logRow := &models.SystemOperationLog{
Tid: tid,
UserID: uid,
Module: module,
Action: action,
Method: method,
URL: truncateString(url, 255),
IP: ip,
UserAgent: userAgent,
RequestData: strPtr(reqBody),
Status: 1,
Tid: tid,
UserID: uid,
Module: module,
Action: action,
Method: method,
URL: truncateString(url, 255),
IP: ip,
UserAgent: userAgent,
RequestData: strPtr(reqBody),
Status: 1,
ExecutionTime: 0,
}
_, err := models.Orm.Insert(logRow)
@@ -88,20 +88,40 @@ func strPtr(s string) *string {
return &s
}
// isMobileAppPath 判断是否属于移动端(/api/)功能接口前缀集合。
// 登录器公开接口(/api/getcard、/api/cursor/*、/api/softwareupgrade/*、
// /api/schedule/reminder/ack 等)不在此集合内。
func isMobileAppPath(url string) bool {
mobilePrefixes := []string{
"/api/oa/", "/api/notebook/", "/api/currentUser",
"/api/activity/", "/api/home", "/api/quick-menus",
}
// /api/schedule 需区分:/api/schedule/reminder/ack 属登录器,排除
if strings.HasPrefix(url, "/api/schedule") {
return !strings.HasPrefix(url, "/api/schedule/reminder/ack")
}
for _, p := range mobilePrefixes {
if strings.HasPrefix(url, p) {
return true
}
}
return false
}
// WriteDeleteLog 控制器删除后直接写操作日志(带标题)
func WriteDeleteLog(userID uint64, tid int, module, title string) {
moduleCN := oplogModuleLabel(module)
desc := fmt.Sprintf("您删除了%s:【%s】", moduleCN, truncateString(title, 20))
tidVal := uint64(tid)
logRow := &models.SystemOperationLog{
UserID: userID,
Tid: &tidVal,
Module: module,
Action: "删除",
Method: "DELETE",
URL: fmt.Sprintf("/app/%s", module),
Status: 1,
RequestData: &desc,
UserID: userID,
Tid: &tidVal,
Module: module,
Action: "删除",
Method: "DELETE",
URL: fmt.Sprintf("/api/%s", module),
Status: 1,
RequestData: &desc,
ExecutionTime: 0,
}
models.Orm.Insert(logRow)
@@ -291,12 +311,8 @@ func shouldSkipLogging(method, url string) bool {
if strings.HasPrefix(url, "/api/softwareupgrade/check") {
return true
}
// App 端读接口跳过(只记录写操作:POST/PUT/DELETE)
if strings.HasPrefix(url, "/app/") && method == "GET" {
return true
}
// App 端 DELETE 由控制器自行记录(需要标题信息)
if strings.HasPrefix(url, "/app/") && method == "DELETE" {
// 移动端(已并入 /api/)读接口跳过(只记录写操作:POST/PUT/DELETE)
if isMobileAppPath(url) {
return true
}
}
+42 -50
View File
@@ -13,7 +13,7 @@ func PermissionMiddleware() func(ctx *context.Context) {
return func(ctx *context.Context) {
// 获取当前请求的路径
path := ctx.Input.URL()
// 不需要权限验证的路径列表
publicPaths := []string{
"/api/login",
@@ -23,19 +23,19 @@ func PermissionMiddleware() func(ctx *context.Context) {
"/api/program-infos/public",
"/api/files/public",
}
// 检查是否为公开路径
for _, p := range publicPaths {
if path == p {
return
}
}
// 检查是否为公开预览接口
if strings.HasPrefix(path, "/api/files/public-preview/") {
return
}
// 获取用户ID
userIdData := ctx.Input.GetData("userId")
if userIdData == nil {
@@ -43,7 +43,7 @@ func PermissionMiddleware() func(ctx *context.Context) {
// 这里直接返回,因为JWT中间件已经拦截了
return
}
userId, ok := userIdData.(int)
if !ok {
ctx.Output.JSON(map[string]interface{}{
@@ -52,15 +52,15 @@ func PermissionMiddleware() func(ctx *context.Context) {
}, false, false)
return
}
// 获取当前路由对应的权限标识
permission := getPermissionByPath(path, ctx.Input.Method())
// 如果没有权限标识,说明该接口不需要权限控制
if permission == "" {
return
}
// 检查用户是否拥有该权限
hasPermission, err := services.CheckUserPermission(userId, permission)
if err != nil {
@@ -71,7 +71,7 @@ func PermissionMiddleware() func(ctx *context.Context) {
}, false, false)
return
}
if !hasPermission {
ctx.Output.JSON(map[string]interface{}{
"success": false,
@@ -90,26 +90,19 @@ func getPermissionByPath(path, method string) string {
// 这里只列举了部分示例,实际应该从数据库中加载
permissionMap := map[string]string{
// 用户管理
"GET:/api/allUsers": "user:list",
"GET:/api/user/:id": "user:detail",
"POST:/api/addUser": "user:add",
"POST:/api/editUser/:id": "user:edit",
"DELETE:/api/deleteUser/:id": "user:delete",
"POST:/api/changePassword/:id":"user:changePassword",
// 角色管理
"GET:/api/roles": "role:list",
"POST:/api/roles": "role:create",
"GET:/api/roles/:id": "role:detail",
"POST:/api/roles/:id": "role:update",
"DELETE:/api/roles/:id": "role:delete",
"GET:/api/allUsers": "user:list",
"GET:/api/user/:id": "user:detail",
"POST:/api/addUser": "user:add",
"POST:/api/editUser/:id": "user:edit",
"DELETE:/api/deleteUser/:id": "user:delete",
"POST:/api/changePassword/:id": "user:changePassword",
// 菜单管理
"GET:/api/allmenu": "menu:list",
"POST:/api/menu": "menu:create",
"PUT:/api/menu/:id": "menu:update",
"DELETE:/api/menu/:id": "menu:delete",
"GET:/api/allmenu": "menu:list",
"POST:/api/menu": "menu:create",
"PUT:/api/menu/:id": "menu:update",
"DELETE:/api/menu/:id": "menu:delete",
// 文件管理
"GET:/api/files": "file:list",
"POST:/api/files": "file:upload",
@@ -121,29 +114,29 @@ func getPermissionByPath(path, method string) string {
"DELETE:/api/files/:id": "file:delete",
"GET:/api/files/search": "file:search",
"GET:/api/files/statistics": "file:statistics",
// 租户管理
"GET:/api/tenant/list": "tenant:list",
"POST:/api/tenant": "tenant:create",
"PUT:/api/tenant/:id": "tenant:update",
"DELETE:/api/tenant/:id": "tenant:delete",
"POST:/api/tenant/:id/audit": "tenant:audit",
"GET:/api/tenant/:id": "tenant:detail",
"GET:/api/tenant/list": "tenant:list",
"POST:/api/tenant": "tenant:create",
"PUT:/api/tenant/:id": "tenant:update",
"DELETE:/api/tenant/:id": "tenant:delete",
"POST:/api/tenant/:id/audit": "tenant:audit",
"GET:/api/tenant/:id": "tenant:detail",
// 知识库
"GET:/api/knowledge/list": "knowledge:list",
"GET:/api/knowledge/detail": "knowledge:detail",
"POST:/api/knowledge/create": "knowledge:create",
"POST:/api/knowledge/update": "knowledge:update",
"POST:/api/knowledge/delete": "knowledge:delete",
"GET:/api/knowledge/list": "knowledge:list",
"GET:/api/knowledge/detail": "knowledge:detail",
"POST:/api/knowledge/create": "knowledge:create",
"POST:/api/knowledge/update": "knowledge:update",
"POST:/api/knowledge/delete": "knowledge:delete",
}
// 匹配路径(简化版本,不支持动态参数匹配)
key := method + ":" + path
if perm, ok := permissionMap[key]; ok {
return perm
}
// 尝试匹配动态路由(简单的ID参数替换)
// 例如:/api/user/123 -> /api/user/:id
pathParts := strings.Split(path, "/")
@@ -152,19 +145,19 @@ func getPermissionByPath(path, method string) string {
if len(parts) != 2 {
continue
}
methodPart := parts[0]
pathPattern := parts[1]
if methodPart != method {
continue
}
patternParts := strings.Split(pathPattern, "/")
if len(patternParts) != len(pathParts) {
continue
}
match := true
for i, part := range patternParts {
if strings.HasPrefix(part, ":") {
@@ -176,13 +169,12 @@ func getPermissionByPath(path, method string) string {
break
}
}
if match {
return perm
}
}
// 如果没有找到匹配的权限标识,返回空字符串(表示不需要权限控制)
return ""
}
+246
View File
@@ -0,0 +1,246 @@
package models
import "time"
// =============================================================
// 自定义审批流(配置侧)
//
// 设计要点:
// - 租户侧与平台侧配置分表隔离:平台账号不归属租户,其流程不带 tid;
// - 节点表用 prev_keys / next_keys / condition_expr 承载连线与条件,
// 当期顺序链只按 sort 流转,后续上拖拽图与条件分支无需改表。
// =============================================================
// 审批适用范围:租户侧 / 平台侧
const (
ApprovalScopeTenant = 1 // 租户侧(backend / uniapp)
ApprovalScopePlatform = 2 // 平台侧(platform,跨租户管控)
)
// 节点类型
const (
ApprovalNodeStart = 1 // 开始
ApprovalNodeApproval = 2 // 审批
ApprovalNodeEnd = 3 // 结束
ApprovalNodeCondition = 4 // 条件分支(预留)
)
// 审批人类型
const (
ApprovalApproverUser = 1 // 指定用户
ApprovalApproverRole = 2 // 指定角色
ApprovalApproverOrgLeader = 3 // 部门主管
ApprovalApproverPosition = 4 // 指定职位
ApprovalApproverInitiator = 5 // 发起人本人
ApprovalApproverInitLeader = 6 // 发起人所在部门主管
)
// 多人审批方式
const (
ApprovalMultiAny = 1 // 或签:任一通过即通过
ApprovalMultiAll = 2 // 会签:全部通过才通过
)
// 单据类型
const (
ApprovalBizReimburse = "reimburse" // OA 报销单
ApprovalBizBid = "bid" // CRM 投标立项
ApprovalBizDomain = "domain" // 域名申请(平台侧审批)
)
// ---------------- 租户侧配置 ----------------
// TenantApprovalFlow 租户侧审批流程 yz_backend_approval_flow
type TenantApprovalFlow struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Tid uint64 `orm:"column(tid)" json:"tid"`
ModuleCode string `orm:"column(module_code);size(50);default()" json:"module_code"`
BizType string `orm:"column(biz_type);size(50);default()" json:"biz_type"`
FlowCode string `orm:"column(flow_code);size(64);default()" json:"flow_code"`
FlowName string `orm:"column(flow_name);size(100);default()" json:"flow_name"`
Description string `orm:"column(description);size(500);default()" json:"description"`
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"`
Status int8 `orm:"column(status);default(1)" json:"status"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *TenantApprovalFlow) TableName() string {
return "yz_backend_approval_flow"
}
// TenantApprovalNode 租户侧审批节点 yz_backend_approval_node
type TenantApprovalNode struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
FlowID uint64 `orm:"column(flow_id)" json:"flow_id"`
Tid uint64 `orm:"column(tid);default(0)" json:"tid"`
NodeKey string `orm:"column(node_key);size(50);default()" json:"node_key"`
NodeName string `orm:"column(node_name);size(100);default()" json:"node_name"`
NodeType int8 `orm:"column(node_type);default(2)" json:"node_type"`
ApproverType int8 `orm:"column(approver_type);default(1)" json:"approver_type"`
ApproverIDs string `orm:"column(approver_ids);size(500);default()" json:"approver_ids"`
MultiMode int8 `orm:"column(multi_mode);default(1)" json:"multi_mode"`
AllowReturn int8 `orm:"column(allow_return);default(0)" json:"allow_return"`
TimeoutHours int `orm:"column(timeout_hours);default(48)" json:"timeout_hours"`
PrevKeys string `orm:"column(prev_keys);size(500);default()" json:"prev_keys"`
NextKeys string `orm:"column(next_keys);size(500);default()" json:"next_keys"`
ConditionExpr string `orm:"column(condition_expr);size(500);default()" json:"condition_expr"`
Sort int `orm:"column(sort);default(0)" json:"sort"`
PosX int `orm:"column(pos_x);default(0)" json:"pos_x"`
PosY int `orm:"column(pos_y);default(0)" json:"pos_y"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *TenantApprovalNode) TableName() string {
return "yz_backend_approval_node"
}
// ---------------- 平台侧配置 ----------------
// PlatformApprovalFlow 平台侧审批流程 yz_platform_approval_flow
// 平台账号不归属租户,因此无 tid 字段。
type PlatformApprovalFlow struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
ModuleCode string `orm:"column(module_code);size(50);default()" json:"module_code"`
BizType string `orm:"column(biz_type);size(50);default()" json:"biz_type"`
FlowCode string `orm:"column(flow_code);size(64);default()" json:"flow_code"`
FlowName string `orm:"column(flow_name);size(100);default()" json:"flow_name"`
Description string `orm:"column(description);size(500);default()" json:"description"`
IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"`
Status int8 `orm:"column(status);default(1)" json:"status"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformApprovalFlow) TableName() string {
return "yz_platform_approval_flow"
}
// PlatformApprovalNode 平台侧审批节点 yz_platform_approval_node
// approver_ids 存平台主体:user=AdminUser.id / role=AdminRole.id
type PlatformApprovalNode struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
FlowID uint64 `orm:"column(flow_id)" json:"flow_id"`
NodeKey string `orm:"column(node_key);size(50);default()" json:"node_key"`
NodeName string `orm:"column(node_name);size(100);default()" json:"node_name"`
NodeType int8 `orm:"column(node_type);default(2)" json:"node_type"`
ApproverType int8 `orm:"column(approver_type);default(1)" json:"approver_type"`
ApproverIDs string `orm:"column(approver_ids);size(500);default()" json:"approver_ids"`
MultiMode int8 `orm:"column(multi_mode);default(1)" json:"multi_mode"`
AllowReturn int8 `orm:"column(allow_return);default(0)" json:"allow_return"`
TimeoutHours int `orm:"column(timeout_hours);default(48)" json:"timeout_hours"`
PrevKeys string `orm:"column(prev_keys);size(500);default()" json:"prev_keys"`
NextKeys string `orm:"column(next_keys);size(500);default()" json:"next_keys"`
ConditionExpr string `orm:"column(condition_expr);size(500);default()" json:"condition_expr"`
Sort int `orm:"column(sort);default(0)" json:"sort"`
PosX int `orm:"column(pos_x);default(0)" json:"pos_x"`
PosY int `orm:"column(pos_y);default(0)" json:"pos_y"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *PlatformApprovalNode) TableName() string {
return "yz_platform_approval_node"
}
// ---------------- 运行时(租户侧与平台侧共用) ----------------
// 实例状态
const (
ApprovalInstanceDraft = 0 // 草稿
ApprovalInstanceRunning = 1 // 审批中
ApprovalInstanceApproved = 2 // 已通过
ApprovalInstanceRejected = 3 // 已驳回
ApprovalInstanceWithdrawn = 4 // 已撤回
ApprovalInstanceCanceled = 5 // 已取消
)
// 任务状态
const (
ApprovalTaskPending = 0 // 待审批
ApprovalTaskApproved = 1 // 已通过
ApprovalTaskRejected = 2 // 已驳回
ApprovalTaskReturned = 3 // 已退回
ApprovalTaskSkipped = 4 // 已跳过
ApprovalTaskTimeout = 5 // 已超时
)
// 任务动作
const (
ApprovalActionSubmit = 1 // 提交
ApprovalActionApprove = 2 // 通过
ApprovalActionReject = 3 // 驳回
ApprovalActionReturn = 4 // 退回
ApprovalActionWithdraw = 5 // 撤回
ApprovalActionAutoPass = 6 // 超时自动通过
)
// ApprovalInstance 审批实例 yz_approval_instance
//
// scope 区分租户侧/平台侧。平台侧实例的 biz_tid 标记申请来源租户,
// 使平台端能按租户筛选并代租户操作。
type ApprovalInstance struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
Scope int8 `orm:"column(scope);default(1)" json:"scope"`
Tid uint64 `orm:"column(tid);default(0)" json:"tid"`
BizTid uint64 `orm:"column(biz_tid);default(0)" json:"biz_tid"`
FlowID uint64 `orm:"column(flow_id);default(0)" json:"flow_id"`
BizType string `orm:"column(biz_type);size(50);default()" json:"biz_type"`
BizID uint64 `orm:"column(biz_id);default(0)" json:"biz_id"`
BizTitle string `orm:"column(biz_title);size(200);default()" json:"biz_title"`
SubmitUserID uint64 `orm:"column(submit_user_id);default(0)" json:"submit_user_id"`
SubmitUserName string `orm:"column(submit_user_name);size(64);default()" json:"submit_user_name"`
SubmitDeptID uint64 `orm:"column(submit_dept_id);default(0)" json:"submit_dept_id"`
FormData *string `orm:"column(form_data);type(text);null" json:"form_data"`
CurrentNode string `orm:"column(current_node);size(50);default()" json:"current_node"`
Status int8 `orm:"column(status);default(1)" json:"status"`
SubmitTime *time.Time `orm:"column(submit_time);type(datetime);null" json:"submit_time"`
FinishTime *time.Time `orm:"column(finish_time);type(datetime);null" json:"finish_time"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *ApprovalInstance) TableName() string {
return "yz_approval_instance"
}
// ApprovalTask 审批任务 yz_approval_task
// 节点级待办:一个节点可展开多条任务(会签时每个审批人一条)。
type ApprovalTask struct {
ID uint64 `orm:"column(id);pk;auto" json:"id"`
InstanceID uint64 `orm:"column(instance_id)" json:"instance_id"`
Scope int8 `orm:"column(scope);default(1)" json:"scope"`
Tid uint64 `orm:"column(tid);default(0)" json:"tid"`
BizType string `orm:"column(biz_type);size(50);default()" json:"biz_type"`
BizID uint64 `orm:"column(biz_id);default(0)" json:"biz_id"`
NodeKey string `orm:"column(node_key);size(50);default()" json:"node_key"`
NodeName string `orm:"column(node_name);size(100);default()" json:"node_name"`
Sort int `orm:"column(sort);default(0)" json:"sort"`
ApproverID uint64 `orm:"column(approver_id);default(0)" json:"approver_id"`
ApproverName string `orm:"column(approver_name);size(64);default()" json:"approver_name"`
MultiMode int8 `orm:"column(multi_mode);default(1)" json:"multi_mode"`
Status int8 `orm:"column(status);default(0)" json:"status"`
Action int8 `orm:"column(action);default(0)" json:"action"`
Comment string `orm:"column(comment);size(500);default()" json:"comment"`
Deadline *time.Time `orm:"column(deadline);type(datetime);null" json:"deadline"`
FinishTime *time.Time `orm:"column(finish_time);type(datetime);null" json:"finish_time"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
}
func (m *ApprovalTask) TableName() string {
return "yz_approval_task"
}
+7
View File
@@ -50,6 +50,13 @@ func Init(_ string) {
new(BackendErpContact),
new(BackendApprovalFlow),
new(BackendApprovalRecord),
// 自定义审批流(docs/sql/create_approval_flow.sql)
new(TenantApprovalFlow),
new(TenantApprovalNode),
new(PlatformApprovalFlow),
new(PlatformApprovalNode),
new(ApprovalInstance),
new(ApprovalTask),
new(BackendReimbursement),
new(BackendReimbursementItem),
new(BackendReimbursementExpenseType),
+84 -18
View File
@@ -6,40 +6,106 @@ import (
beego "github.com/beego/beego/v2/server/web"
)
// Register 注册移动端 / 开放 API(api)路由。
// Register 注册移动端 / 客户端开放 API(api)路由。
// 说明:/api 统一承载「所有移动端接口」(uniapp)与「登录器等客户端开放接口」,
// 与 /platform、/backend、/index、/auth 各端完全隔离,不复用其它端的功能接口。
//
// 注意:字面量路由必须注册在 :id 通配之前(beego 按注册顺序匹配)。
func Register() {
// 平台更新通知列表(无需登录)
beego.Router("/platform/api/upgrade/list", &controllers.ApiPlatformUpgradeController{}, "get:List")
// 平台更新通知详情(无需登录)
beego.Router("/platform/api/upgrade/detail", &controllers.ApiPlatformUpgradeController{}, "get:Detail")
// 客户端检查更新(无需登录)
// ===== 客户端登录器 / 开放接口(无需登录)=====
// 客户端检查更新
beego.Router("/api/softwareupgrade/check", &controllers.ApiSoftwareUpgradeController{}, "get:Check")
// 登录器上报 Cursor 设备信息(无需登录)
// 登录器上报 Cursor 设备信息
beego.Router("/api/cursor/equipment/report", &controllers.ApiCursorEquipmentController{}, "post:Report")
// 登录器使用激活码激活/续期 Cursor 设备(无需登录)
// 登录器使用激活码激活/续期 Cursor 设备
beego.Router("/api/cursor/equipment/activateByCode", &controllers.ApiCursorEquipmentController{}, "post:ActivateByCode")
// 登录器心跳接口,用于更新在线状态(无需登录)
// 登录器心跳接口,用于更新在线状态
beego.Router("/api/cursor/equipment/heartbeat", &controllers.ApiCursorEquipmentController{}, "post:Heartbeat")
// Cursor Token 顺序读取/检测接口(无需登录,peek 不改变号池状态)
// Cursor Token 顺序读取/检测接口(peek 不改变号池状态)
// GET /api/cursor/token/peek?id=11&data_type=tk
beego.Router("/api/cursor/token/peek", &controllers.ApiCursorDetectController{}, "get:PeekToken")
// Cursor Token 可用状态标记接口(无需登录)
// Cursor Token 可用状态标记接口
// POST /api/cursor/token/available?id=11
// POST /api/cursor/token/unavailable?id=11
beego.Router("/api/cursor/token/available", &controllers.ApiCursorDetectController{}, "post:MarkTokenAvailable")
beego.Router("/api/cursor/token/unavailable", &controllers.ApiCursorDetectController{}, "post:MarkTokenUnavailable")
// 对外提卡接口(无需登录)
// 对外提卡接口
// GET /api/getcard?type=xianyu&module=cursor&data_type=tk
beego.Router("/api/getcard", &controllers.ApiGetCardController{}, "get:GetCard")
// 日程提醒确认接口(无需登录)
// 日程提醒确认接口
beego.Router("/api/schedule/reminder/ack", &controllers.ApiReminderController{}, "get:AckReminder")
// ===== 移动端(uniapp)登录相关(AppAuthController)=====
beego.Router("/api/login", &controllers.AppAuthController{}, "post:LoginBackend")
beego.Router("/api/sendLoginCode", &controllers.AppAuthController{}, "post:SendLoginCode")
beego.Router("/api/loginBySms", &controllers.AppAuthController{}, "post:LoginBySms")
beego.Router("/api/logout", &controllers.AppAuthController{}, "post:Logout")
// 当前用户信息
beego.Router("/api/currentUser", &controllers.AppAuthController{}, "get:GetCurrentUser")
// 极验与登录验证配置
beego.Router("/api/login/getGeetest3Infos", &controllers.AppAuthController{}, "get:GetGeetest3Infos")
beego.Router("/api/login/getGeetest4Infos", &controllers.AppAuthController{}, "get:GetGeetest4Infos")
beego.Router("/api/login/getOpenVerify", &controllers.AppAuthController{}, "get:GetOpenVerify")
// 找回密码(三步流程)
beego.Router("/api/verifyAccount", &controllers.AppAuthController{}, "post:VerifyAccount")
beego.Router("/api/sendResetCode", &controllers.AppAuthController{}, "post:SendResetCode")
beego.Router("/api/resetPassword", &controllers.AppAuthController{}, "post:ResetPassword")
// 注册
beego.Router("/api/register", &controllers.AppAuthController{}, "post:Register")
beego.Router("/api/sendRegisterCode", &controllers.AppAuthController{}, "post:SendRegisterCode")
// 日程提醒
beego.Router("/api/schedule/list", &controllers.AppReminderController{}, "get:GetList")
beego.Router("/api/schedule", &controllers.AppReminderController{}, "post:Create")
beego.Router("/api/schedule/:id", &controllers.AppReminderController{}, "get:GetDetail;put:Update;delete:Delete")
beego.Router("/api/schedule/:id/toggle", &controllers.AppReminderController{}, "post:ToggleComplete")
// 记事本
beego.Router("/api/notebook/list", &controllers.AppNotebookController{}, "get:GetList")
beego.Router("/api/notebook", &controllers.AppNotebookController{}, "post:Create")
beego.Router("/api/notebook/:id", &controllers.AppNotebookController{}, "get:GetDetail;put:Update;delete:Delete")
beego.Router("/api/notebook/:id/togglePin", &controllers.AppNotebookController{}, "post:TogglePin")
// OA 协同办公(移动端只读:通知公告 / 文档库 / 薪酬工资单 / 员工档案)
beego.Router("/api/oa/notice/list", &controllers.AppOaController{}, "get:NoticeList")
beego.Router("/api/oa/notice/:id", &controllers.AppOaController{}, "get:NoticeDetail")
beego.Router("/api/oa/document/categories", &controllers.AppOaController{}, "get:DocCategories")
beego.Router("/api/oa/document/list", &controllers.AppOaController{}, "get:DocList")
beego.Router("/api/oa/document/download/:id", &controllers.AppOaController{}, "get:DocDownload")
beego.Router("/api/oa/document/:id/star", &controllers.AppOaController{}, "post:DocStar")
beego.Router("/api/oa/document/:id", &controllers.AppOaController{}, "get:DocDetail")
beego.Router("/api/oa/compensation/payrolls", &controllers.AppOaController{}, "get:PayrollList")
beego.Router("/api/oa/compensation/payrolls/:id", &controllers.AppOaController{}, "get:PayrollDetail")
beego.Router("/api/oa/employee-file", &controllers.AppOaController{}, "get:MyEmployeeFile")
// 员工档案 HR 视角:access 判定是否可看全公司档案,list 全公司档案列表,detail 档案详情
beego.Router("/api/oa/employee-file/access", &controllers.AppOaController{}, "get:EmployeeFileAccess")
beego.Router("/api/oa/employee-file/list", &controllers.AppOaController{}, "get:EmployeeFileList")
beego.Router("/api/oa/employee-file/detail/:id", &controllers.AppOaController{}, "get:EmployeeFileDetail")
// OA 工作台仪表盘(移动端首屏顶部聚合:待办/报销统计 + 通知公告 + 今日日程)
beego.Router("/api/oa/dashboard", &controllers.AppOaController{}, "get:Dashboard")
// 通讯录(移动端只读:列表 / 组织树 / 详情)
beego.Router("/api/oa/contact/list", &controllers.AppOaController{}, "get:ContactList")
beego.Router("/api/oa/contact/org-tree", &controllers.AppOaController{}, "get:ContactOrgTree")
beego.Router("/api/oa/contact/:id", &controllers.AppOaController{}, "get:ContactDetail")
// 活动日志
beego.Router("/api/activity/list", &controllers.AppActivityController{}, "get:GetList")
// 工作台聚合(ERP/OA/CRM/CMS 应用门户首屏数据)
beego.Router("/api/home", &controllers.AppAggregateController{}, "get:Home")
// 移动端用户自定义「常用功能」保存(读取随 /api/home 下发 quick_menu_ids)
beego.Router("/api/quick-menus", &controllers.AppAggregateController{}, "post:SaveQuickMenus")
}
-78
View File
@@ -1,78 +0,0 @@
package app
import (
"server/controllers"
beego "github.com/beego/beego/v2/server/web"
)
// Register 注册移动端(app)路由。
func Register() {
// 登录相关(使用 AppAuthController)
beego.Router("/app/login", &controllers.AppAuthController{}, "post:LoginBackend")
beego.Router("/app/sendLoginCode", &controllers.AppAuthController{}, "post:SendLoginCode")
beego.Router("/app/loginBySms", &controllers.AppAuthController{}, "post:LoginBySms")
beego.Router("/app/logout", &controllers.AppAuthController{}, "post:Logout")
// 当前用户信息
beego.Router("/app/currentUser", &controllers.AppAuthController{}, "get:GetCurrentUser")
// 极验与登录验证配置
beego.Router("/app/login/getGeetest3Infos", &controllers.AppAuthController{}, "get:GetGeetest3Infos")
beego.Router("/app/login/getGeetest4Infos", &controllers.AppAuthController{}, "get:GetGeetest4Infos")
beego.Router("/app/login/getOpenVerify", &controllers.AppAuthController{}, "get:GetOpenVerify")
// 找回密码(三步流程)
beego.Router("/app/verifyAccount", &controllers.AppAuthController{}, "post:VerifyAccount")
beego.Router("/app/sendResetCode", &controllers.AppAuthController{}, "post:SendResetCode")
beego.Router("/app/resetPassword", &controllers.AppAuthController{}, "post:ResetPassword")
// 注册
beego.Router("/app/register", &controllers.AppAuthController{}, "post:Register")
beego.Router("/app/sendRegisterCode", &controllers.AppAuthController{}, "post:SendRegisterCode")
// 日程提醒
beego.Router("/app/schedule/list", &controllers.AppReminderController{}, "get:GetList")
beego.Router("/app/schedule", &controllers.AppReminderController{}, "post:Create")
beego.Router("/app/schedule/:id", &controllers.AppReminderController{}, "get:GetDetail;put:Update;delete:Delete")
beego.Router("/app/schedule/:id/toggle", &controllers.AppReminderController{}, "post:ToggleComplete")
// 记事本
beego.Router("/app/notebook/list", &controllers.AppNotebookController{}, "get:GetList")
beego.Router("/app/notebook", &controllers.AppNotebookController{}, "post:Create")
beego.Router("/app/notebook/:id", &controllers.AppNotebookController{}, "get:GetDetail;put:Update;delete:Delete")
beego.Router("/app/notebook/:id/togglePin", &controllers.AppNotebookController{}, "post:TogglePin")
// OA 协同办公(移动端只读:通知公告 / 文档库 / 薪酬工资单 / 员工档案)
// 注意:字面量路由必须注册在 :id 通配之前,beego 按注册顺序匹配。
beego.Router("/app/oa/notice/list", &controllers.AppOaController{}, "get:NoticeList")
beego.Router("/app/oa/notice/:id", &controllers.AppOaController{}, "get:NoticeDetail")
beego.Router("/app/oa/document/categories", &controllers.AppOaController{}, "get:DocCategories")
beego.Router("/app/oa/document/list", &controllers.AppOaController{}, "get:DocList")
beego.Router("/app/oa/document/download/:id", &controllers.AppOaController{}, "get:DocDownload")
beego.Router("/app/oa/document/:id/star", &controllers.AppOaController{}, "post:DocStar")
beego.Router("/app/oa/document/:id", &controllers.AppOaController{}, "get:DocDetail")
beego.Router("/app/oa/compensation/payrolls", &controllers.AppOaController{}, "get:PayrollList")
beego.Router("/app/oa/compensation/payrolls/:id", &controllers.AppOaController{}, "get:PayrollDetail")
beego.Router("/app/oa/employee-file", &controllers.AppOaController{}, "get:MyEmployeeFile")
// OA 工作台仪表盘(移动端首屏顶部聚合:待办/报销统计 + 通知公告 + 今日日程)
beego.Router("/app/oa/dashboard", &controllers.AppOaController{}, "get:Dashboard")
// 通讯录(移动端只读:列表 / 组织树 / 详情)
// 同样:字面量路由必须排在 :id 通配之前。
beego.Router("/app/oa/contact/list", &controllers.AppOaController{}, "get:ContactList")
beego.Router("/app/oa/contact/org-tree", &controllers.AppOaController{}, "get:ContactOrgTree")
beego.Router("/app/oa/contact/:id", &controllers.AppOaController{}, "get:ContactDetail")
// 活动日志
beego.Router("/app/activity/list", &controllers.AppActivityController{}, "get:GetList")
// 工作台聚合(ERP/OA/CRM/CMS 应用门户首屏数据)
beego.Router("/app/home", &controllers.AppAggregateController{}, "get:Home")
// 移动端用户自定义「常用功能」保存(读取随 /app/home 下发 quick_menu_ids)
beego.Router("/app/quick-menus", &controllers.AppAggregateController{}, "post:SaveQuickMenus")
}
+13 -1
View File
@@ -15,6 +15,18 @@ func Register() {
RegisterProductRoutes()
RegisterWechatMpRoutes()
RegisterDictRoutes()
RegisterApprovalRoutes()
}
// RegisterApprovalRoutes 注册租户端「审批流配置」路由。
// 数据按登录租户隔离,审批人取本租户用户/角色/部门/职位。
func RegisterApprovalRoutes() {
beego.Router("/backend/approval/options", &controllers.BackendApprovalController{}, "get:Options")
beego.Router("/backend/approval/flow/list", &controllers.BackendApprovalController{}, "get:FlowList")
beego.Router("/backend/approval/flow/detail/:id", &controllers.BackendApprovalController{}, "get:FlowDetail")
beego.Router("/backend/approval/flow/save", &controllers.BackendApprovalController{}, "post:FlowSave")
beego.Router("/backend/approval/flow/delete/:id", &controllers.BackendApprovalController{}, "delete:FlowDelete")
beego.Router("/backend/approval/flow/pick", &controllers.BackendApprovalController{}, "get:Pick")
}
// RegisterProductRoutes 注册租户端「产品购买」路由。
@@ -228,6 +240,7 @@ func RegisterAuthRoutes() {
beego.Router("/backend/friendlinks/:id", &controllers.BackendFriendlinkController{}, "put:Update;delete:Delete")
beego.Router("/backend/allonepages", &controllers.BackendOnepageController{}, "get:ListAll")
beego.Router("/backend/onepage/path/:path", &controllers.BackendOnepageController{}, "get:GetOnePageByPath")
beego.Router("/backend/createonepage", &controllers.BackendOnepageController{}, "post:Create")
beego.Router("/backend/editonepage/:id", &controllers.BackendOnepageController{}, "post:Update")
beego.Router("/backend/deleteonepage/:id", &controllers.BackendOnepageController{}, "delete:Delete")
@@ -251,7 +264,6 @@ func RegisterAuthRoutes() {
beego.Router("/backend/domain/tenant/bindCustom", &controllers.BackendTenantDomainController{}, "post:BindCustom")
beego.Router("/backend/domain/tenant/checkCustomDns", &controllers.BackendTenantDomainController{}, "post:CheckCustomDns")
beego.Router("/backend/domain/tenant/customDomainGuide", &controllers.BackendTenantDomainController{}, "get:CustomDomainGuide")
beego.Router("/backend/domain/tenant/audit", &controllers.BackendTenantDomainController{}, "post:Audit")
beego.Router("/backend/domain/tenant/toggleStatus", &controllers.BackendTenantDomainController{}, "post:ToggleStatus")
beego.Router("/backend/domain/tenant/delete/:id", &controllers.BackendTenantDomainController{}, "delete:Delete")
+3
View File
@@ -20,6 +20,9 @@ func Register() {
// 官网页头导航菜单(平台「官网管理 - 导航菜单」配置,仅返回启用且显示的项)
beego.Router("/site/nav", &controllers.IndexWebsiteController{}, "get:Nav")
// 官网端平台更新公告(公开读取,与平台端 /platform/upgrade 各自独立)
beego.Router("/site/upgrade/list", &controllers.IndexUpgradeNoticeController{}, "get:List")
// SPA 官网内容接口(读取平台官网管理 yz_platform_website_*,仅返回已发布内容)
// 注意:静态段必须在 :id 之前注册,保证 /site/news/categories 不被当作详情
beego.Router("/site/news", &controllers.IndexWebsiteController{}, "get:News")
+19
View File
@@ -64,6 +64,10 @@ func Register() {
beego.Router("/platform/resetPassword", &controllers.PlatformAuthController{}, "post:ResetPassword")
beego.Router("/platform/sendResetCode", &controllers.PlatformAuthController{}, "post:SendResetCode")
// 租户自助注册(平台端独立接口,与后端 /backend 不复用)
beego.Router("/platform/register", &controllers.PlatformAuthController{}, "post:Register")
beego.Router("/platform/sendRegisterCode", &controllers.PlatformAuthController{}, "post:SendRegisterCode")
// 平台菜单配置相关
beego.Router("/platform/menu/:id", &controllers.AdminMenuController{}, "get:GetMenu")
beego.Router("/platform/allmenu", &controllers.AdminMenuController{}, "get:GetAllMenus")
@@ -162,9 +166,19 @@ func Register() {
beego.Router("/platform/domain/tenant/checkCustomDns", &controllers.PlatformTenantDomainController{}, "post:CheckCustomDns")
beego.Router("/platform/domain/tenant/customDomainGuide", &controllers.PlatformTenantDomainController{}, "get:CustomDomainGuide")
beego.Router("/platform/domain/tenant/audit", &controllers.PlatformTenantDomainController{}, "post:Audit")
beego.Router("/platform/domain/tenant/audit/progress/:id", &controllers.PlatformTenantDomainController{}, "get:AuditProgress")
beego.Router("/platform/domain/tenant/toggleStatus", &controllers.PlatformTenantDomainController{}, "post:ToggleStatus")
beego.Router("/platform/domain/tenant/delete/:id", &controllers.PlatformTenantDomainController{}, "delete:Delete")
// 审批流配置(平台侧:域名申请等管控场景)
beego.Router("/platform/approval/flow/list", &controllers.PlatformApprovalController{}, "get:FlowList")
beego.Router("/platform/approval/flow/detail/:id", &controllers.PlatformApprovalController{}, "get:FlowDetail")
beego.Router("/platform/approval/flow/save", &controllers.PlatformApprovalController{}, "post:FlowSave")
beego.Router("/platform/approval/flow/delete/:id", &controllers.PlatformApprovalController{}, "delete:FlowDelete")
beego.Router("/platform/approval/options", &controllers.PlatformApprovalController{}, "get:Options")
beego.Router("/platform/approval/biz-types", &controllers.PlatformApprovalController{}, "get:BizTypes")
beego.Router("/platform/approval/flow/pick", &controllers.PlatformApprovalController{}, "get:Pick")
// 模块管理(yz_system_modules)
beego.Router("/platform/modules/list", &controllers.PlatformModulesController{}, "get:GetList")
beego.Router("/platform/modules/getTenantList", &controllers.PlatformModulesController{}, "get:GetTenantList")
@@ -193,6 +207,10 @@ func Register() {
beego.Router("/platform/softwareupgrade", &controllers.PlatformSoftwareUpgradeController{}, "post:Create")
beego.Router("/platform/softwareupgrade/:id", &controllers.PlatformSoftwareUpgradeController{}, "get:Detail;post:Update;delete:Delete")
// 平台更新公告(yz_platform_upgrade_notice,平台端独立接口)
beego.Router("/platform/upgrade/list", &controllers.PlatformUpgradeNoticeController{}, "get:List")
beego.Router("/platform/upgrade/detail", &controllers.PlatformUpgradeNoticeController{}, "get:Detail")
// 租户站点设置(yz_tenant_site_setting)
beego.Router("/platform/normalInfos", &controllers.PlatformSiteSettingsController{}, "get:GetNormalInfos")
beego.Router("/platform/saveNormalInfos", &controllers.PlatformSiteSettingsController{}, "post:SaveNormalInfos")
@@ -426,6 +444,7 @@ func Register() {
// 单页管理 yz_platform_website_page
beego.Router("/platform/website/page/all", &controllers.PlatformWebsitePageController{}, "get:All")
beego.Router("/platform/website/page/path/:path", &controllers.PlatformWebsitePageController{}, "get:GetByPath")
beego.Router("/platform/website/page", &controllers.PlatformWebsitePageController{}, "post:Create")
beego.Router("/platform/website/page/:id", &controllers.PlatformWebsitePageController{}, "get:Get;post:Update;delete:Delete")
+4 -8
View File
@@ -6,7 +6,6 @@ import (
"server/controllers"
"server/middleware"
"server/routers/api"
"server/routers/app"
authrouter "server/routers/auth"
"server/routers/backend"
"server/routers/index"
@@ -67,28 +66,25 @@ func init() {
switch mode {
case "platform":
platform.Register()
app.Register()
api.Register()
// 在 platform 模式下,仍保留 backend 登录相关路由,避免后台登录 404
backend.RegisterAuthRoutes()
case "backend":
backend.Register()
app.Register()
api.Register()
case "index":
index.Register()
app.Register()
api.Register()
case "api":
api.Register()
case "app":
app.Register()
case "all":
platform.Register()
backend.Register()
index.Register()
api.Register()
app.Register()
default:
// 未知模式时,退回到只启用平台端,避免启动失败
platform.Register()
app.Register()
api.Register()
}
}
File diff suppressed because it is too large. Load diff
+3
View File
@@ -62,6 +62,8 @@ declare module 'vue' {
ElScrollbar: typeof import('element-plus/es')['ElScrollbar']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSkeleton: typeof import('element-plus/es')['ElSkeleton']
ElStep: typeof import('element-plus/es')['ElStep']
ElSteps: typeof import('element-plus/es')['ElSteps']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElSwitch: typeof import('element-plus/es')['ElSwitch']
ElTable: typeof import('element-plus/es')['ElTable']
@@ -75,6 +77,7 @@ declare module 'vue' {
ElTree: typeof import('element-plus/es')['ElTree']
ElTreeSelect: typeof import('element-plus/es')['ElTreeSelect']
ElUpload: typeof import('element-plus/es')['ElUpload']
FlowCanvas: typeof import('./src/components/approval/FlowCanvas.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
+39
View File
@@ -18,6 +18,9 @@
"@tiptap/starter-kit": "^3.28.0",
"@tiptap/vue-3": "^3.28.0",
"@umoteam/editor": "^10.2.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"axios": "^1.13.1",
"chart": "^0.1.2",
"chart.js": "^4.5.1",
@@ -2826,6 +2829,42 @@
"vue": "^3.2.25"
}
},
"node_modules/@vue-flow/background": {
"version": "1.3.2",
"resolved": "https://registry.npmmirror.com/@vue-flow/background/-/background-1.3.2.tgz",
"integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==",
"license": "MIT",
"peerDependencies": {
"@vue-flow/core": "^1.23.0",
"vue": "^3.3.0"
}
},
"node_modules/@vue-flow/controls": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/@vue-flow/controls/-/controls-1.1.3.tgz",
"integrity": "sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==",
"license": "MIT",
"peerDependencies": {
"@vue-flow/core": "^1.23.0",
"vue": "^3.3.0"
}
},
"node_modules/@vue-flow/core": {
"version": "1.48.2",
"resolved": "https://registry.npmmirror.com/@vue-flow/core/-/core-1.48.2.tgz",
"integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==",
"license": "MIT",
"dependencies": {
"@vueuse/core": "^10.5.0",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
},
"peerDependencies": {
"vue": "^3.3.0"
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.29",
"resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
+3
View File
@@ -20,6 +20,9 @@
"@tiptap/starter-kit": "^3.28.0",
"@tiptap/vue-3": "^3.28.0",
"@umoteam/editor": "^10.2.1",
"@vue-flow/background": "^1.3.2",
"@vue-flow/controls": "^1.1.3",
"@vue-flow/core": "^1.48.2",
"axios": "^1.13.1",
"chart": "^0.1.2",
"chart.js": "^4.5.1",
+62
View File
@@ -0,0 +1,62 @@
import request from '@/utils/request'
// ==================== 平台端审批流配置 ====================
// 可绑定的业务类型(顶级选择,用于流程绑定模块)
export function getApprovalBizTypes() {
return request({
url: '/platform/approval/biz-types',
method: 'get'
})
}
// 流程列表(可按 biz_type 过滤)
export function getApprovalFlowList(params) {
return request({
url: '/platform/approval/flow/list',
method: 'get',
params
})
}
// 流程详情(含全部节点)
export function getApprovalFlowDetail(id) {
return request({
url: `/platform/approval/flow/detail/${id}`,
method: 'get'
})
}
// 保存流程(新建或更新,节点全量覆盖)
export function saveApprovalFlow(data) {
return request({
url: '/platform/approval/flow/save',
method: 'post',
data
})
}
// 删除流程
export function deleteApprovalFlow(id) {
return request({
url: `/platform/approval/flow/delete/${id}`,
method: 'delete'
})
}
// 审批人候选项(平台角色 + 平台用户 + 分类模块)
export function getApprovalOptions() {
return request({
url: '/platform/approval/options',
method: 'get'
})
}
// 审批流编号轻量清单(按分类返回启用流程的 flow_code/flow_name)
export function getApprovalFlowPick(params) {
return request({
url: '/platform/approval/flow/pick',
method: 'get',
params
})
}
+8
View File
@@ -118,6 +118,14 @@ export function auditTenantDomain(data) {
})
}
// 获取域名审批进度(引擎节点与各审批人状态)
export function getDomainAuditProgress(id) {
return request({
url: `/platform/domain/tenant/audit/progress/${id}`,
method: 'get'
})
}
// 禁用/启用租户域名
export function toggleTenantDomainStatus(id) {
return request({
+3 -3
View File
@@ -106,10 +106,10 @@ export function sendResetCode(data) {
});
}
// 租户端自助注册(/backend/*,与 go-platform routers/backend 一致)
// 租户自助注册(平台端独立接口 /platform/*,与 go routers/platform 一致)
export function register(data) {
return request({
url: "/backend/register",
url: "/platform/register",
method: "post",
data,
});
@@ -117,7 +117,7 @@ export function register(data) {
export function sendRegisterCode(data) {
return request({
url: "/backend/sendRegisterCode",
url: "/platform/sendRegisterCode",
method: "post",
data,
});
+2 -2
View File
@@ -67,13 +67,13 @@ export function deleteOnePage(id) {
}
/**
* 租户官网前台:根据路径获取单页(公开接口,非平台管理)
* 平台端:根据路径读取单页(平台端独立接口,不跨端复用 /index)
* @param {string} path 路由路径
* @returns {Promise}
*/
export function getOnePageByPath(path) {
return request({
url: `/index/onepage/${encodeURIComponent(path)}`,
url: `/platform/website/page/path/${encodeURIComponent(path)}`,
method: "get",
});
}
-7
View File
@@ -35,11 +35,4 @@ export function deleteRole(id) {
url: `/platform/roles/${id}`,
method: 'delete'
})
}
export function getRoleByTenantId(tenantId) {
return request({
url: '/api/role/tenant/' + tenantId, // 根据实际 API 调整
method: 'get'
})
}
@@ -0,0 +1,562 @@
<template>
<div class="flow-canvas">
<!-- 左侧节点面板 -->
<div class="palette">
<div class="palette-title">节点面板</div>
<div
v-for="p in palette"
:key="p.type"
class="palette-item"
:class="'pal-' + p.cls"
draggable="true"
@dragstart="onDragStart($event, p)"
>
<el-icon class="pal-icon"><component :is="p.icon" /></el-icon>
<span>{{ p.label }}</span>
</div>
<div class="palette-tip">
拖入画布添加节点;从节点右侧圆点拖到目标节点连线;双击节点配置;点击连线设置分支条件。
</div>
</div>
<!-- 画布 -->
<div ref="canvasWrap" class="canvas-wrap" @drop="onDrop" @dragover.prevent @dragenter.prevent>
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:default-viewport="{ zoom: 1 }"
:min-zoom="0.4"
:max-zoom="2"
@node-double-click="onNodeDblClick"
@edge-click="onEdgeClick"
>
<template #node-flow="nodeProps">
<div class="fn" :class="'fn-' + nodeProps.data.cls">
<Handle type="target" :position="Position.Left" />
<div class="fn-badge">{{ typeShort(nodeProps.data.nodeType) }}</div>
<div class="fn-title">{{ nodeProps.data.name || '未命名' }}</div>
<div class="fn-sub">{{ nodeProps.data.summary }}</div>
<Handle type="source" :position="Position.Right" />
</div>
</template>
<Background />
<Controls />
</VueFlow>
</div>
<!-- 节点配置对话框 -->
<el-dialog v-model="nodeDialog" :title="nodeDialogTitle" width="560px" append-to-body>
<el-form :model="nodeForm" label-width="96px">
<el-form-item label="节点名称" required>
<el-input v-model="nodeForm.name" placeholder="如:部门主管审批" />
</el-form-item>
<template v-if="nodeForm.nodeType === 2">
<el-form-item label="审批人类型">
<el-radio-group v-model="nodeForm.approverType" @change="onApproverTypeChange">
<el-radio v-for="t in approverTypes" :key="t.value" :value="t.value">{{ t.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="currentApproverMeta && currentApproverMeta.needsSelection" :label="currentApproverMeta.selectLabel" required>
<el-select v-model="nodeForm.approverIds" multiple filterable style="width: 100%" :placeholder="'选择' + currentApproverMeta.selectLabel + '(可多选)'">
<el-option v-for="opt in currentPool" :key="opt.id" :label="opt.name" :value="opt.id" />
</el-select>
</el-form-item>
<el-form-item v-else label=" ">
<span class="hint">该类型运行期自动定位审批人,无需指定对象</span>
</el-form-item>
<el-form-item
v-if="currentApproverMeta && currentApproverMeta.needsSelection && nodeForm.approverIds && nodeForm.approverIds.length > 1"
label="多人方式"
>
<el-radio-group v-model="nodeForm.multiMode">
<el-radio :value="1">
或签
<el-tooltip placement="top" effect="dark">
<template #content>
<div class="mode-tip">
<div class="mode-tip-title">或签 (OR)</div>
<div>所选审批人任一人通过/驳回,即视为本节点完成;</div>
<div>其余待办任务将自动作废并推进至下一节点。</div>
</div>
</template>
<el-icon class="mode-tip-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</el-radio>
<el-radio :value="2">
会签
<el-tooltip placement="top" effect="dark">
<template #content>
<div class="mode-tip">
<div class="mode-tip-title">会签 (AND)</div>
<div>所选审批人须全部通过,本节点才算完成;</div>
<div>任一人驳回则本节点立即驳回,流程回退。</div>
</div>
</template>
<el-icon class="mode-tip-icon"><QuestionFilled /></el-icon>
</el-tooltip>
</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="允许退回">
<el-switch v-model="nodeForm.allowReturn" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="超时(小时)">
<el-input-number v-model="nodeForm.timeoutHours" :min="1" :max="720" />
</el-form-item>
</template>
<el-form-item v-else-if="nodeForm.nodeType === 4" label=" ">
<span class="hint">条件网关:拖出多条连线,点击连线分别填写分支条件(留空为默认分支)。</span>
</el-form-item>
</el-form>
<template #footer>
<el-button type="danger" plain :disabled="!editingNodeId" @click="removeCurrentNode">删除节点</el-button>
<span style="flex: 1"></span>
<el-button @click="nodeDialog = false">取消</el-button>
<el-button type="primary" @click="confirmNode">确定</el-button>
</template>
</el-dialog>
<!-- 连线条件对话框 -->
<el-dialog v-model="edgeDialog" title="设置分支条件" width="460px" append-to-body>
<el-form label-width="88px">
<el-form-item label="条件表达式">
<el-input v-model="edgeForm.condition" placeholder="如 amount>5000 且 dept=='研发' ;留空为默认分支" />
</el-form-item>
<el-form-item label=" ">
<span class="hint">支持 &gt; &gt;= &lt; &lt;= == != 与 &amp;&amp; / ||。条件仅在“条件网关”的出边上生效。</span>
</el-form-item>
</el-form>
<template #footer>
<el-button type="danger" plain @click="removeCurrentEdge">删除连线</el-button>
<span style="flex: 1"></span>
<el-button @click="edgeDialog = false">取消</el-button>
<el-button type="primary" @click="confirmEdge">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, watch, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { VideoPlay, User, Share, SwitchButton, QuestionFilled } from '@element-plus/icons-vue'
import { VueFlow, Handle, Position, MarkerType, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
const props = defineProps({
// { nodes: [payloadNode], edges: [payloadEdge] }
modelValue: { type: Object, default: () => ({ nodes: [], edges: [] }) },
// 审批人类型:[{ value, label, needsSelection, selectLabel, poolKey }]
approverTypes: { type: Array, default: () => [] },
// 候选池:{ [poolKey]: [{ id, name }] }
pools: { type: Object, default: () => ({}) },
})
const nodes = ref<any[]>([])
const edges = ref<any[]>([])
const canvasWrap = ref<HTMLElement | null>(null)
const { screenToFlowCoordinate, onConnect } = useVueFlow()
let keySeq = 1
const nextKey = () => `n${Date.now().toString(36)}${(keySeq++).toString(36)}`
const NODE_TYPE = { START: 1, APPROVAL: 2, END: 3, CONDITION: 4 }
const palette = [
{ type: NODE_TYPE.START, cls: 'start', label: '开始', icon: VideoPlay },
{ type: NODE_TYPE.APPROVAL, cls: 'approval', label: '审批节点', icon: User },
{ type: NODE_TYPE.CONDITION, cls: 'condition', label: '条件网关', icon: Share },
{ type: NODE_TYPE.END, cls: 'end', label: '结束', icon: SwitchButton },
]
const clsOf = (t: number) =>
t === NODE_TYPE.START ? 'start' : t === NODE_TYPE.END ? 'end' : t === NODE_TYPE.CONDITION ? 'condition' : 'approval'
const typeShort = (t: number) =>
t === NODE_TYPE.START ? '开始' : t === NODE_TYPE.END ? '结束' : t === NODE_TYPE.CONDITION ? '条件' : '审批'
const approverTypeLabel = (t: number) => {
const m = (props.approverTypes as any[]).find((x) => x.value === t)
return m ? m.label : '未知'
}
function approverSummary(node: any) {
if (node.nodeType === NODE_TYPE.APPROVAL) {
const meta = (props.approverTypes as any[]).find((x) => x.value === node.approverType)
if (!meta || !meta.needsSelection) return approverTypeLabel(node.approverType)
const pool = (props.pools as any)[meta.poolKey] || []
const names = (node.approverIds || []).map((id: number) => pool.find((p: any) => p.id === id)?.name).filter(Boolean)
const mode = node.multiMode === 2 ? '会签' : '或签'
return `${approverTypeLabel(node.approverType)}:${names.length ? names.join('、') : '未选'}(${mode})`
}
if (node.nodeType === NODE_TYPE.CONDITION) return '按分支条件路由'
return ''
}
// 由 payload 节点/边构建画布内部结构
function buildGraph(payload: any) {
const pNodes = payload?.nodes || []
const pEdges = payload?.edges || []
const ns = pNodes.map((n: any) => ({
id: n.node_key || nextKey(),
type: 'flow',
position: { x: n.pos_x || 0, y: n.pos_y || 0 },
data: makeNodeData(n),
}))
const es = pEdges
.filter((e: any) => e.from && e.to)
.map((e: any) => ({
id: `e_${e.from}_${e.to}`,
source: e.from,
target: e.to,
label: e.condition || '',
data: { condition: e.condition || '' },
markerEnd: { type: MarkerType.ArrowClosed },
animated: !!e.condition,
}))
return { ns, es }
}
function makeNodeData(n: any) {
const nodeType = n.node_type || n.nodeType || NODE_TYPE.APPROVAL
const data = {
nodeType,
cls: clsOf(nodeType),
name: n.node_name || n.name || '',
approverType: n.approver_type ?? n.approverType ?? 1,
approverIds: n.approver_ids ?? n.approverIds ?? [],
multiMode: n.multi_mode ?? n.multiMode ?? 1,
allowReturn: n.allow_return ?? n.allowReturn ?? 0,
timeoutHours: n.timeout_hours ?? n.timeoutHours ?? 48,
}
data.summary = approverSummary(data)
return data
}
// 初始化 / 外部切换流程时重建
function reload() {
const { ns, es } = buildGraph(props.modelValue || {})
// 避免坐标全 0 重叠:无坐标时自动铺开
let autoX = 60
let autoY = 60
ns.forEach((n: any) => {
if (!n.position.x && !n.position.y) {
n.position = { x: autoX, y: autoY }
autoY += 140
}
})
nodes.value = ns
edges.value = es
void autoX
}
watch(() => props.modelValue, reload, { deep: false, immediate: true })
// ==================== 拖拽添加 ====================
let dragType = 0
function onDragStart(_evt: DragEvent, p: any) {
dragType = p.type
}
function onDrop(evt: DragEvent) {
if (!dragType) return
const position = screenToFlowCoordinate({ x: evt.clientX, y: evt.clientY })
const data = makeNodeData({ node_type: dragType, node_name: defaultName(dragType) })
nodes.value.push({ id: nextKey(), type: 'flow', position, data })
dragType = 0
}
function defaultName(t: number) {
return t === NODE_TYPE.START ? '开始' : t === NODE_TYPE.END ? '结束' : t === NODE_TYPE.CONDITION ? '条件网关' : '审批节点'
}
// ==================== 连线 ====================
onConnect((params: any) => {
const exists = edges.value.some((e) => e.source === params.source && e.target === params.target)
if (exists) return
edges.value.push({
id: `e_${params.source}_${params.target}`,
source: params.source,
target: params.target,
label: '',
data: { condition: '' },
markerEnd: { type: MarkerType.ArrowClosed },
animated: false,
})
})
// ==================== 节点配置 ====================
const nodeDialog = ref(false)
const editingNodeId = ref('')
const nodeForm = reactive<any>({
name: '',
nodeType: NODE_TYPE.APPROVAL,
approverType: 1,
approverIds: [],
multiMode: 1,
allowReturn: 0,
timeoutHours: 48,
})
const nodeDialogTitle = computed(() => {
if (nodeForm.nodeType === NODE_TYPE.CONDITION) return '配置条件网关'
if (nodeForm.nodeType === NODE_TYPE.APPROVAL) return '配置审批节点'
return '配置节点'
})
const currentApproverMeta = computed(() =>
(props.approverTypes as any[]).find((x) => x.value === nodeForm.approverType) || null
)
const currentPool = computed(() => {
const meta = currentApproverMeta.value
return meta ? (props.pools as any)[meta.poolKey] || [] : []
})
function onNodeDblClick(evt: any) {
const node = evt.node
if (!node) return
editingNodeId.value = node.id
const d = node.data
Object.assign(nodeForm, {
name: d.name,
nodeType: d.nodeType,
approverType: d.approverType,
approverIds: [...(d.approverIds || [])],
multiMode: d.multiMode,
allowReturn: d.allowReturn,
timeoutHours: d.timeoutHours,
})
nodeDialog.value = true
}
function onApproverTypeChange() {
nodeForm.approverIds = []
}
function confirmNode() {
const node = nodes.value.find((n) => n.id === editingNodeId.value)
if (!node) {
nodeDialog.value = false
return
}
if (!String(nodeForm.name).trim()) {
ElMessage.warning('请填写节点名称')
return
}
const meta = currentApproverMeta.value
if (nodeForm.nodeType === NODE_TYPE.APPROVAL && meta && meta.needsSelection && !(nodeForm.approverIds || []).length) {
ElMessage.warning('请选择审批人')
return
}
node.data = makeNodeData({
node_type: nodeForm.nodeType,
node_name: nodeForm.name,
approver_type: nodeForm.approverType,
approver_ids: nodeForm.approverIds,
multi_mode: nodeForm.multiMode,
allow_return: nodeForm.allowReturn,
timeout_hours: nodeForm.timeoutHours,
})
nodeDialog.value = false
}
function removeCurrentNode() {
const id = editingNodeId.value
if (!id) return
nodes.value = nodes.value.filter((n) => n.id !== id)
edges.value = edges.value.filter((e) => e.source !== id && e.target !== id)
nodeDialog.value = false
}
// ==================== 连线条件 ====================
const edgeDialog = ref(false)
const editingEdgeId = ref('')
const edgeForm = reactive<any>({ condition: '' })
function onEdgeClick(evt: any) {
const edge = evt.edge
if (!edge) return
editingEdgeId.value = edge.id
edgeForm.condition = edge.data?.condition || ''
edgeDialog.value = true
}
function confirmEdge() {
const edge = edges.value.find((e) => e.id === editingEdgeId.value)
if (edge) {
edge.data = { condition: edgeForm.condition }
edge.label = edgeForm.condition || ''
edge.animated = !!edgeForm.condition
}
edgeDialog.value = false
}
function removeCurrentEdge() {
edges.value = edges.value.filter((e) => e.id !== editingEdgeId.value)
edgeDialog.value = false
}
// ==================== 对外序列化 ====================
function serialize() {
const outNodes = nodes.value.map((n: any, i: number) => ({
node_key: n.id,
node_name: n.data.name,
node_type: n.data.nodeType,
approver_type: n.data.approverType,
approver_ids: n.data.approverIds,
multi_mode: n.data.multiMode,
allow_return: n.data.allowReturn,
timeout_hours: n.data.timeoutHours,
sort: i + 1,
pos_x: Math.round(n.position.x),
pos_y: Math.round(n.position.y),
}))
const outEdges = edges.value.map((e: any) => ({
from: e.source,
to: e.target,
condition: e.data?.condition || '',
}))
return { nodes: outNodes, edges: outEdges }
}
async function fitView() {
await nextTick()
}
defineExpose({ serialize, fitView })
</script>
<style lang="less" scoped>
.flow-canvas {
display: flex;
height: 560px;
border: 1px solid #e4e7ed;
border-radius: 8px;
overflow: hidden;
}
.palette {
width: 160px;
flex: 0 0 160px;
padding: 12px;
background: #fafafa;
border-right: 1px solid #e4e7ed;
.palette-title {
font-weight: 600;
margin-bottom: 10px;
font-size: 14px;
}
.palette-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
margin-bottom: 8px;
border-radius: 6px;
border: 1px solid #dcdfe6;
background: #fff;
cursor: grab;
font-size: 13px;
&:hover {
border-color: #409eff;
}
.pal-icon {
font-size: 15px;
}
}
.pal-start { border-left: 3px solid #67c23a; }
.pal-approval { border-left: 3px solid #409eff; }
.pal-condition { border-left: 3px solid #e6a23c; }
.pal-end { border-left: 3px solid #909399; }
.palette-tip {
margin-top: 10px;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
}
.canvas-wrap {
flex: 1;
position: relative;
background: #f7f8fb;
}
.fn {
min-width: 150px;
padding: 8px 12px;
border-radius: 8px;
background: #fff;
border: 1px solid #dcdfe6;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
font-size: 13px;
.fn-badge {
display: inline-block;
font-size: 11px;
padding: 0 6px;
border-radius: 8px;
color: #fff;
margin-bottom: 4px;
}
.fn-title {
font-weight: 600;
color: #303133;
}
.fn-sub {
margin-top: 2px;
color: #909399;
font-size: 12px;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.fn-start { border-top: 3px solid #67c23a; .fn-badge { background: #67c23a; } }
.fn-approval { border-top: 3px solid #409eff; .fn-badge { background: #409eff; } }
.fn-condition { border-top: 3px solid #e6a23c; .fn-badge { background: #e6a23c; } }
.fn-end { border-top: 3px solid #909399; .fn-badge { background: #909399; } }
.hint {
font-size: 12px;
color: #909399;
}
</style>
<style lang="less">
/* 多人方式问号图标 + 气泡内容:因 el-dialog / el-tooltip 均 append-to-body,需非 scoped 样式 */
.el-radio .mode-tip-icon {
margin-left: 4px;
color: #909399;
cursor: help;
vertical-align: -2px;
transition: color 0.2s;
}
.el-radio .mode-tip-icon:hover {
color: #409eff;
}
.el-popper.is-dark .mode-tip {
font-size: 12px;
line-height: 1.7;
max-width: 260px;
}
.el-popper.is-dark .mode-tip .mode-tip-title {
font-weight: 600;
margin-bottom: 4px;
color: #ffd04b;
}
</style>
+7
View File
@@ -42,6 +42,13 @@ const staticMainChildren = [
component: () => import("@/views/system/authConfig/index.vue"),
meta: { requiresAuth: true, title: "登录策略" }
},
// 审批流配置(平台侧:域名申请等管控场景)
{
path: "/system/approval",
name: "PlatformApprovalFlow",
component: () => import("@/views/system/approval/index.vue"),
meta: { requiresAuth: true, title: "审批流配置" }
},
{
path: "/tools/passwordStore",
name: "PlatformPasswordStore",
+87 -11
View File
@@ -2,10 +2,16 @@
<div class="container-box">
<div class="header-bar">
<h2>租户域名审核</h2>
<el-button @click="fetchData" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
<div>
<el-button @click="goConfig">
<el-icon><Setting /></el-icon>
审批流配置
</el-button>
<el-button @click="fetchData" :loading="loading">
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</div>
<el-divider></el-divider>
@@ -99,7 +105,10 @@
启用
</el-button>
</template>
<template v-else-if="scope.row.status === 0">
<template v-else-if="scope.row.status === 0">
<el-button size="small" text @click="openProgress(scope.row)">
进度
</el-button>
<el-button size="small" type="success" @click="handleAudit(scope.row, 'approve')">
通过
</el-button>
@@ -129,19 +138,41 @@
@current-change="fetchData"
/>
</div>
<!-- 审批进度 -->
<el-dialog v-model="progressVisible" title="审批进度" width="480px">
<el-alert v-if="!progress.engine" type="info" :closable="false" title="该域名未走审批流引擎(未配置流程,当前为平台单级审核)" />
<el-steps v-else direction="vertical" :active="progressActive" finish-status="success" class="progress-steps">
<el-step
v-for="nd in progress.nodes"
:key="nd.node_key"
:title="nd.node_name"
>
<template #description>
<div v-for="t in tasksOfNode(nd.node_key)" :key="t.id" class="task-line">
<span>{{ t.approver_name || ('#' + t.approver_id) }}</span>
<el-tag size="small" :type="taskTagType(t.status)">{{ taskText(t.status) }}</el-tag>
<span v-if="t.comment" class="task-comment">{{ t.comment }}</span>
</div>
</template>
</el-step>
</el-steps>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { Refresh, Setting } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import { formatDateTime } from '@/utils/datetime'
import {
getTenantDomainList,
auditTenantDomain,
toggleTenantDomainStatus,
checkCustomDomainDns
checkCustomDomainDns,
getDomainAuditProgress
} from '@/api/domain'
// 域名类型:与后端 models.DomainTypeCustom 对应
@@ -166,6 +197,43 @@ const tableData = ref<any[]>([])
const isCustom = (row: any) => row.domain_type === DOMAIN_TYPE_CUSTOM
const router = useRouter()
const goConfig = () => router.push('/system/approval')
// 审批进度
const progressVisible = ref(false)
const progress = reactive<any>({ engine: false, nodes: [], tasks: [] })
const tasksOfNode = (key: string) => (progress.tasks || []).filter((t: any) => t.node_key === key)
// el-steps 当前激活步骤:首个含待办的节点序号,全部完成则为节点数
const progressActive = computed(() => {
const nodes = progress.nodes || []
for (let i = 0; i < nodes.length; i++) {
const ts = tasksOfNode(nodes[i].node_key)
if (ts.some((t: any) => t.status === 0)) return i
}
return nodes.length
})
const taskText = (s: number) =>
s === 1 ? '已通过' : s === 2 ? '已驳回' : s === 3 ? '已退回' : s === 4 ? '已跳过' : s === 5 ? '超时' : '待审批'
const taskTagType = (s: number) =>
s === 1 ? 'success' : s === 2 ? 'danger' : s === 0 ? 'warning' : 'info'
const openProgress = async (row: any) => {
const res: any = await getDomainAuditProgress(row.id)
if (res.code === 200) {
progress.engine = !!(res.data && res.data.engine)
progress.nodes = res.data?.nodes || []
progress.tasks = res.data?.tasks || []
progressVisible.value = true
} else {
ElMessage.error(res.msg || '加载进度失败')
}
}
const verifyText = (status: number) =>
status === 1 ? '已通过' : status === 2 ? '未通过' : '未检测'
@@ -201,10 +269,18 @@ const handleSearch = () => {
}
const handleAudit = async (row: any, action: string) => {
const msg = action === 'approve' ? '确定要通过该域名申请吗?' : '确定要拒绝该域名申请吗?'
await ElMessageBox.confirm(msg, '提示', { type: 'warning' })
let comment = ''
if (action === 'reject') {
const r: any = await ElMessageBox.prompt('请输入拒绝原因', '拒绝', {
inputPlaceholder: '拒绝原因(可选)',
type: 'warning'
})
comment = r.value || ''
} else {
await ElMessageBox.confirm('确定要通过该域名申请吗?', '提示', { type: 'warning' })
}
const res = await auditTenantDomain({ id: row.id, action })
const res = await auditTenantDomain({ id: row.id, action, comment })
if (res.code === 200) {
ElMessage.success(res.msg)
fetchData()
@@ -40,7 +40,7 @@ const validateForm = ref<FormRules>({});
const fetchList = async () => {
loading.value = true;
try {
const response = await fetch(`/platform/api/upgrade/list?limit=${pageSize.value * 2}`);
const response = await fetch(`/platform/upgrade/list?limit=${pageSize.value * 2}`);
const result = await response.json();
if (result.code === 200) {
tableData.value = result.data;
@@ -0,0 +1,350 @@
<template>
<div class="approval-config">
<div class="header-bar">
<h2>审批流配置</h2>
<div>
<el-button @click="goDomainAudit">
<el-icon><Tickets /></el-icon>
域名审核
</el-button>
<el-button type="primary" :disabled="!activeBiz" @click="openNewFlow">
<el-icon><Plus /></el-icon>
新建流程
</el-button>
</div>
</div>
<el-divider />
<el-row :gutter="16">
<!-- 左侧:分类(业务类型)+ 流程列表 -->
<el-col :span="6">
<el-card shadow="never" class="side-card">
<template #header>
<span class="card-title">选择分类</span>
</template>
<el-select v-model="activeBiz" placeholder="选择分类" style="width: 100%" @change="onBizChange">
<el-option v-for="b in bizTypes" :key="b.value" :label="b.label" :value="b.value" />
</el-select>
<div class="flow-list">
<div
v-for="f in flows"
:key="f.id"
:class="['flow-item', { active: current && current.id === f.id }]"
@click="selectFlow(f.id)"
>
<div class="flow-name">
{{ f.flow_name }}
<el-tag v-if="f.is_default === 1" size="small" type="success">默认</el-tag>
</div>
<div class="flow-code">{{ f.flow_code || '(保存后生成编号)' }}</div>
<div class="flow-sub">
<span>{{ f.node_count }} 个节点</span>
<el-tag size="small" :type="f.status === 1 ? 'info' : 'danger'">
{{ f.status === 1 ? '启用' : '停用' }}
</el-tag>
</div>
</div>
<el-empty v-if="!flows.length" description="暂无流程" :image-size="60" />
</div>
</el-card>
</el-col>
<!-- 右侧:流程编辑 + 画布 -->
<el-col :span="18">
<el-card v-if="current" shadow="never">
<el-form :model="current" label-width="96px" class="flow-meta">
<el-row :gutter="12">
<el-col :span="8">
<el-form-item label="流程名称">
<el-input v-model="current.flow_name" placeholder="如:域名申请审批" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="所属分类">
<el-select v-model="current.biz_type" style="width: 100%">
<el-option v-for="b in bizTypes" :key="b.value" :label="b.label" :value="b.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="审批流编号">
<el-input :model-value="current.flow_code || '保存后自动生成'" disabled />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="说明">
<el-input v-model="current.description" type="textarea" :rows="2" placeholder="流程用途说明" />
</el-form-item>
<el-row :gutter="12">
<el-col :span="12">
<el-form-item label="设为默认">
<el-switch v-model="current.is_default" :active-value="1" :inactive-value="0" />
<span class="hint">同分类仅一条默认,新申请走默认流程</span>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="启用状态">
<el-switch v-model="current.status" :active-value="1" :inactive-value="0" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-divider content-position="left">流程编排(拖拽节点 · 手动连线 · 条件分支)</el-divider>
<FlowCanvas ref="canvasRef" v-model="graphModel" :approver-types="approverTypes" :pools="pools" />
<div class="save-bar">
<el-button @click="deleteCurrentFlow" type="danger" plain>删除流程</el-button>
<el-button type="primary" :loading="saving" @click="saveFlow">保存流程</el-button>
</div>
</el-card>
<el-card v-else shadow="never">
<el-empty description="请选择左侧流程,或点击「新建流程」" />
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Tickets } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import FlowCanvas from '@/components/approval/FlowCanvas.vue'
import {
getApprovalBizTypes,
getApprovalFlowList,
getApprovalFlowDetail,
saveApprovalFlow,
deleteApprovalFlow,
getApprovalOptions
} from '@/api/approval'
const bizTypes = ref<any[]>([])
const activeBiz = ref<string>('')
const flows = ref<any[]>([])
const current = ref<any>(null)
const saving = ref(false)
const router = useRouter()
const goDomainAudit = () => router.push('/domain/audit')
const canvasRef = ref<any>(null)
const graphModel = ref<any>({ nodes: [], edges: [] })
const pools = reactive<{ roles: any[]; users: any[] }>({ roles: [], users: [] })
// 平台侧 2 种审批人类型
const approverTypes = [
{ value: 1, label: '指定用户', needsSelection: true, selectLabel: '平台用户', poolKey: 'users' },
{ value: 2, label: '按平台角色', needsSelection: true, selectLabel: '平台角色', poolKey: 'roles' }
]
async function loadBizTypes() {
const res: any = await getApprovalBizTypes()
if (res.code === 200) {
bizTypes.value = res.data || []
if (!activeBiz.value && bizTypes.value.length) {
activeBiz.value = bizTypes.value[0].value
loadFlows()
}
}
}
async function loadOptions() {
const res: any = await getApprovalOptions()
if (res.code === 200) {
pools.roles = res.data.roles || []
pools.users = res.data.users || []
}
}
function onBizChange() {
current.value = null
graphModel.value = { nodes: [], edges: [] }
loadFlows()
}
async function loadFlows() {
if (!activeBiz.value) return
const res: any = await getApprovalFlowList({ biz_type: activeBiz.value })
if (res.code === 200) flows.value = res.data || []
}
async function selectFlow(id: number) {
const res: any = await getApprovalFlowDetail(id)
if (res.code === 200) {
const d = res.data || {}
current.value = d
graphModel.value = { nodes: d.nodes || [], edges: d.edges || [] }
}
}
function openNewFlow() {
current.value = {
id: 0,
flow_name: '',
module_code: activeBiz.value,
biz_type: activeBiz.value,
flow_code: '',
description: '',
is_default: 0,
status: 1
}
graphModel.value = {
nodes: [
{ node_key: 'start', node_name: '开始', node_type: 1, pos_x: 60, pos_y: 200 },
{ node_key: 'end', node_name: '结束', node_type: 3, pos_x: 520, pos_y: 200 }
],
edges: []
}
}
async function saveFlow() {
if (!current.value.flow_name?.trim()) {
ElMessage.warning('请填写流程名称')
return
}
if (!current.value.biz_type) {
ElMessage.warning('请选择分类')
return
}
const graph = canvasRef.value?.serialize?.() || { nodes: [], edges: [] }
saving.value = true
try {
const payload = {
id: current.value.id || 0,
module_code: current.value.module_code || current.value.biz_type,
biz_type: current.value.biz_type,
flow_name: current.value.flow_name,
description: current.value.description,
is_default: current.value.is_default,
status: current.value.status,
nodes: graph.nodes,
edges: graph.edges
}
const res: any = await saveApprovalFlow(payload)
if (res.code === 200) {
ElMessage.success('保存成功')
activeBiz.value = current.value.biz_type
await loadFlows()
if (res.data?.id) await selectFlow(res.data.id)
} else {
ElMessage.error(res.msg || '保存失败')
}
} finally {
saving.value = false
}
}
async function deleteCurrentFlow() {
if (!current.value?.id) {
current.value = null
graphModel.value = { nodes: [], edges: [] }
return
}
await ElMessageBox.confirm('确定删除该审批流?在途审批存在时无法删除。', '提示', { type: 'warning' })
const res: any = await deleteApprovalFlow(current.value.id)
if (res.code === 200) {
ElMessage.success('已删除')
current.value = null
graphModel.value = { nodes: [], edges: [] }
loadFlows()
} else {
ElMessage.error(res.msg || '删除失败')
}
}
onMounted(() => {
loadBizTypes()
loadOptions()
})
</script>
<style lang="less" scoped>
.approval-config {
padding: 20px;
}
.header-bar {
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
}
.side-card {
min-height: 400px;
.card-title {
font-weight: 600;
}
}
.flow-list {
margin-top: 12px;
.flow-item {
padding: 10px 12px;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
&:hover {
background: #f5f7fa;
}
&.active {
background: #ecf5ff;
border-color: #b3d8ff;
}
.flow-name {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: #303133;
}
.flow-code {
margin-top: 2px;
font-size: 12px;
color: #409eff;
font-family: Menlo, Consolas, monospace;
}
.flow-sub {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
font-size: 12px;
color: #909399;
}
}
}
.flow-meta {
.hint {
margin-left: 10px;
font-size: 12px;
color: #909399;
}
}
.save-bar {
margin-top: 20px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -167,7 +167,7 @@ import {
Select,
RefreshLeft,
} from '@element-plus/icons-vue';
import { getRoleByTenantId, getAllRoles } from '@/api/role';
import { getAllRoles } from '@/api/role';
import {
getAllMenuPermissions,
getRolePermissions,
+33 -1
View File
@@ -29,7 +29,39 @@
box-sizing: border-box;
}
view, text {
/* 全局统一 border-box:必须显式覆盖 scroll-view / swiper / input 等盒元素,
否则它们 width:100% + padding 会按 content-box 溢出视口(小程序端高发),
导致带左右内边距的列表/弹层右侧内容被裁切。 */
view,
text,
scroll-view,
swiper,
swiper-item,
button,
input,
textarea,
label,
navigator {
box-sizing: border-box;
}
/* ============ 全局自适应(宽屏:桌面 / 平板 H5)============
说明:手机竖屏 CSS 宽度普遍 < 560px,此断链不会命中,App / 小程
序 / 手机 H5 行为完全不变;仅在宽屏下把整个应用收敛为居中的手机列,
避免页面铺满超宽屏、两侧大片留白。配合 pages.json 的 rpx 上限,
无需逐页适配。 */
@media (min-width: 560px) {
body {
background: #e9edf3;
}
#app {
max-width: 560px;
min-height: 100vh;
margin-left: auto;
margin-right: auto;
background: $color-bg-page;
box-shadow: 0 0 1px rgba(15, 23, 42, 0.16), 0 10px 40px rgba(15, 23, 42, 0.08);
}
}
</style>
+14 -14
View File
@@ -1,12 +1,12 @@
/**
* 移动端认证接口 — 对接 Go /app/*
* 移动端认证接口 — 对接 Go /api/*
*/
import { request, requestRaw } from '@/api/request.js'
/** 账号密码登录(旧:租户端直登) */
export function login(data) {
return request({
url: '/app/login',
url: '/api/login',
method: 'POST',
data
})
@@ -157,7 +157,7 @@ export function revokeUAC(data = {}) {
/** 发送登录验证码(账号二次校验用,需后台开启验证) */
export function sendLoginCode(data) {
return request({
url: '/app/sendLoginCode',
url: '/api/sendLoginCode',
method: 'POST',
data
})
@@ -166,7 +166,7 @@ export function sendLoginCode(data) {
/** 手机号验证码登录 */
export function loginBySms(data) {
return request({
url: '/app/loginBySms',
url: '/api/loginBySms',
method: 'POST',
data
})
@@ -175,7 +175,7 @@ export function loginBySms(data) {
/** 退出登录(后端无状态,失败可忽略;短超时避免卡住) */
export function logoutApi(data = {}) {
return request({
url: '/app/logout',
url: '/api/logout',
method: 'POST',
data,
silent: true,
@@ -186,7 +186,7 @@ export function logoutApi(data = {}) {
/** 当前用户信息 */
export function getCurrentUser() {
return request({
url: '/app/currentUser',
url: '/api/currentUser',
method: 'GET'
})
}
@@ -194,7 +194,7 @@ export function getCurrentUser() {
/** 是否开启登录人机/验证码校验 */
export function getOpenVerify() {
return request({
url: '/app/login/getOpenVerify',
url: '/api/login/getOpenVerify',
method: 'GET',
silent: true,
timeout: 8000
@@ -204,7 +204,7 @@ export function getOpenVerify() {
/** 极验 3 配置 */
export function getGeetest3Infos() {
return request({
url: '/app/login/getGeetest3Infos',
url: '/api/login/getGeetest3Infos',
method: 'GET',
silent: true
})
@@ -213,7 +213,7 @@ export function getGeetest3Infos() {
/** 极验 4 配置 */
export function getGeetest4Infos() {
return request({
url: '/app/login/getGeetest4Infos',
url: '/api/login/getGeetest4Infos',
method: 'GET',
silent: true,
timeout: 8000
@@ -223,7 +223,7 @@ export function getGeetest4Infos() {
/** 注册 */
export function register(data) {
return request({
url: '/app/register',
url: '/api/register',
method: 'POST',
data
})
@@ -232,7 +232,7 @@ export function register(data) {
/** 发送注册验证码 */
export function sendRegisterCode(data) {
return request({
url: '/app/sendRegisterCode',
url: '/api/sendRegisterCode',
method: 'POST',
data
})
@@ -241,7 +241,7 @@ export function sendRegisterCode(data) {
/** 重置密码 */
export function resetPassword(data) {
return request({
url: '/app/resetPassword',
url: '/api/resetPassword',
method: 'POST',
data
})
@@ -250,7 +250,7 @@ export function resetPassword(data) {
/** 验证租户和账号(找回密码第一步) */
export function verifyAccount(data) {
return request({
url: '/app/verifyAccount',
url: '/api/verifyAccount',
method: 'POST',
data
})
@@ -259,7 +259,7 @@ export function verifyAccount(data) {
/** 发送找回密码验证码(找回密码第二步) */
export function sendResetCode(data) {
return request({
url: '/app/sendResetCode',
url: '/api/sendResetCode',
method: 'POST',
data
})
+4 -4
View File
@@ -1,6 +1,6 @@
/**
* 通讯录(移动端)接口封装。
* 对应后端 go/controllers/app_contact.go 的 /app/oa/contact/* 只读接口。
* 对应后端 go/controllers/app_contact.go 的 /api/oa/contact/* 只读接口。
*/
import { request } from '@/api/request.js'
@@ -48,7 +48,7 @@ export function primaryPhone(contact) {
/** 分页查询通讯录 */
export async function fetchContacts(params = {}) {
const data = await request({
url: '/app/oa/contact/list',
url: '/api/oa/contact/list',
data: {
page: params.page || 1,
pageSize: params.pageSize || 20,
@@ -62,10 +62,10 @@ export async function fetchContacts(params = {}) {
/** 组织架构树(含各部门内部联系人数量) */
export function fetchContactOrgTree() {
return request({ url: '/app/oa/contact/org-tree' })
return request({ url: '/api/oa/contact/org-tree' })
}
/** 联系人详情 */
export function getContact(id) {
return request({ url: `/app/oa/contact/${id}` })
return request({ url: `/api/oa/contact/${id}` })
}
+6 -6
View File
@@ -5,7 +5,7 @@ import { request } from '@/api/request.js'
*/
export async function fetchNotes(params = {}) {
const data = await request({
url: '/app/notebook/list',
url: '/api/notebook/list',
data: {
keyword: params.keyword || ''
}
@@ -23,7 +23,7 @@ export async function fetchNotes(params = {}) {
* 获取单条笔记
*/
export async function getNote(id) {
const data = await request({ url: `/app/notebook/${id}` })
const data = await request({ url: `/api/notebook/${id}` })
if (!data) return null
return {
...data,
@@ -38,7 +38,7 @@ export async function getNote(id) {
*/
export async function createNote(data) {
const res = await request({
url: '/app/notebook',
url: '/api/notebook',
method: 'POST',
data: {
title: data.title || '',
@@ -54,7 +54,7 @@ export async function createNote(data) {
*/
export async function updateNote(id, data) {
const res = await request({
url: `/app/notebook/${id}`,
url: `/api/notebook/${id}`,
method: 'PUT',
data: {
title: data.title || '',
@@ -69,7 +69,7 @@ export async function updateNote(id, data) {
* 删除笔记
*/
export async function deleteNote(id) {
await request({ url: `/app/notebook/${id}`, method: 'DELETE' })
await request({ url: `/api/notebook/${id}`, method: 'DELETE' })
return true
}
@@ -78,7 +78,7 @@ export async function deleteNote(id) {
*/
export async function toggleNotePin(id) {
const res = await request({
url: `/app/notebook/${id}/togglePin`,
url: `/api/notebook/${id}/togglePin`,
method: 'POST'
})
return res
+39 -12
View File
@@ -1,6 +1,6 @@
/**
* OA 协同办公(移动端)接口封装。
* 对应后端 go/controllers/app_oa.go 的 /app/oa/* 只读接口。
* 对应后端 go/controllers/app_oa.go 的 /api/oa/* 只读接口。
*/
import { request } from '@/api/request.js'
@@ -110,7 +110,7 @@ export function formatFileSize(size) {
export async function fetchNotices(params = {}) {
const data = await request({
url: '/app/oa/notice/list',
url: '/api/oa/notice/list',
data: {
page: params.page || 1,
pageSize: params.pageSize || 20,
@@ -121,18 +121,18 @@ export async function fetchNotices(params = {}) {
}
export function getNotice(id) {
return request({ url: `/app/oa/notice/${id}` })
return request({ url: `/api/oa/notice/${id}` })
}
// ---------------- 文档库 ----------------
export function fetchDocCategories(scope = 'shared') {
return request({ url: '/app/oa/document/categories', data: { scope } })
return request({ url: '/api/oa/document/categories', data: { scope } })
}
export async function fetchDocs(params = {}) {
const data = await request({
url: '/app/oa/document/list',
url: '/api/oa/document/list',
data: {
scope: params.scope || 'shared',
keyword: params.keyword || '',
@@ -148,22 +148,22 @@ export async function fetchDocs(params = {}) {
}
export function getDoc(id) {
return request({ url: `/app/oa/document/${id}` })
return request({ url: `/api/oa/document/${id}` })
}
export function downloadDoc(id) {
return request({ url: `/app/oa/document/download/${id}` })
return request({ url: `/api/oa/document/download/${id}` })
}
export function toggleDocStar(id) {
return request({ url: `/app/oa/document/${id}/star`, method: 'POST' })
return request({ url: `/api/oa/document/${id}/star`, method: 'POST' })
}
// ---------------- 薪酬工资单(仅本人) ----------------
export async function fetchPayrolls(params = {}) {
const data = await request({
url: '/app/oa/compensation/payrolls',
url: '/api/oa/compensation/payrolls',
data: {
payroll_month: params.payrollMonth || '',
page: params.page || 1,
@@ -174,11 +174,38 @@ export async function fetchPayrolls(params = {}) {
}
export function getPayroll(id) {
return request({ url: `/app/oa/compensation/payrolls/${id}` })
return request({ url: `/api/oa/compensation/payrolls/${id}` })
}
// ---------------- 员工档案(仅本人) ----------------
// ---------------- 员工档案 ----------------
/** 当前用户是否具备 HR 视角(可见全公司档案)。角色里勾了「员工档案」菜单即为 HR。 */
export async function getEmployeeFileAccess() {
const data = await request({ url: '/api/oa/employee-file/access' })
return !!(data && data.can_view_all)
}
/** 全公司档案分页列表(HR),支持 keyword / status / org_id */
export async function fetchEmployeeFiles(params = {}) {
const data = await request({
url: '/api/oa/employee-file/list',
data: {
keyword: params.keyword || '',
status: params.status || 0,
org_id: params.orgId || 0,
page: params.page || 1,
pageSize: params.pageSize || 20
}
})
return { list: (data && data.list) || [], total: (data && data.total) || 0 }
}
/** 指定员工档案详情(含教育/工作/合同/证照子记录) */
export function getEmployeeFileDetail(id) {
return request({ url: `/api/oa/employee-file/detail/${id}` })
}
/** 本人档案(无 HR 权限时的兜底视图) */
export function getMyEmployeeFile() {
return request({ url: '/app/oa/employee-file' })
return request({ url: '/api/oa/employee-file' })
}
+6 -6
View File
@@ -61,7 +61,7 @@ export function normalizeSchedule(item) {
*/
export async function fetchSchedules(params = {}) {
const data = await request({
url: '/app/schedule/list',
url: '/api/schedule/list',
data: {
page: params.page || 1,
pageSize: params.pageSize || 50,
@@ -77,7 +77,7 @@ export async function fetchSchedules(params = {}) {
* 获取单条日程
*/
export async function getSchedule(id) {
const data = await request({ url: `/app/schedule/${id}` })
const data = await request({ url: `/api/schedule/${id}` })
return data ? normalizeSchedule(data) : null
}
@@ -90,7 +90,7 @@ export async function createSchedule(data) {
return ch.toUpperCase()
})
const res = await request({
url: '/app/schedule',
url: '/api/schedule',
method: 'POST',
data: {
content: data.content,
@@ -113,7 +113,7 @@ export async function updateSchedule(id, data) {
return ch.toUpperCase()
})
await request({
url: `/app/schedule/${id}`,
url: `/api/schedule/${id}`,
method: 'PUT',
data: {
content: data.content,
@@ -130,7 +130,7 @@ export async function updateSchedule(id, data) {
* 删除日程
*/
export async function deleteSchedule(id) {
await request({ url: `/app/schedule/${id}`, method: 'DELETE' })
await request({ url: `/api/schedule/${id}`, method: 'DELETE' })
return true
}
@@ -139,7 +139,7 @@ export async function deleteSchedule(id) {
*/
export async function toggleScheduleComplete(id) {
const res = await request({
url: `/app/schedule/${id}/toggle`,
url: `/api/schedule/${id}/toggle`,
method: 'POST'
})
return {
+2 -2
View File
@@ -1,7 +1,7 @@
/**
* 移动端「工作台」接口
*
* /app/home 一次返回首屏所需的全部数据(应用模块卡片 + 可见菜单 + KPI),
* /api/home 一次返回首屏所需的全部数据(应用模块卡片 + 可见菜单 + KPI),
* 避免移动端首屏多次往返。
*/
import { request } from '@/api/request.js'
@@ -18,7 +18,7 @@ let cacheAt = 0
export async function fetchHome(force = false) {
if (!force && cache && Date.now() - cacheAt < CACHE_TTL) return cache
const data = await request({
url: '/app/home',
url: '/api/home',
method: 'GET',
silent: true,
timeout: 15000
+10 -5
View File
@@ -297,7 +297,7 @@ function togglePick(id) {
async function savePicker() {
try {
await request({
url: '/app/quick-menus',
url: '/api/quick-menus',
method: 'POST',
data: { menu_ids: pickerSelected.value }
})
@@ -314,7 +314,7 @@ async function savePicker() {
async function loadDashboard() {
if (!isOa.value) return
try {
dash.value = await request({ url: '/app/oa/dashboard', method: 'GET', silent: true })
dash.value = await request({ url: '/api/oa/dashboard', method: 'GET', silent: true })
} catch (e) {
// 仪表盘失败不影响首页其余内容
dash.value = null
@@ -326,8 +326,8 @@ const overdue = computed(() => pending.value.filter((s) => isOverdue(s)).length)
async function loadTodo() {
try {
const [p, d] = await Promise.all([
request({ url: '/app/schedule/list', data: { status: 'pending', pageSize: 50 } }),
request({ url: '/app/schedule/list', data: { status: 'done', pageSize: 50 } })
request({ url: '/api/schedule/list', data: { status: 'pending', pageSize: 50 } }),
request({ url: '/api/schedule/list', data: { status: 'done', pageSize: 50 } })
])
pending.value = (p && p.list) || []
doneCount.value = ((d && d.list) || []).length
@@ -340,7 +340,7 @@ async function loadTodo() {
async function loadNotices() {
if (!isOa.value) return
try {
const data = await request({ url: '/app/oa/notice/list', data: { pageSize: 3 } })
const data = await request({ url: '/api/oa/notice/list', data: { pageSize: 3 } })
notices.value = (data && data.list) || []
} catch (e) {
notices.value = []
@@ -714,6 +714,7 @@ onShow(() => {
z-index: 100;
display: flex;
align-items: flex-end;
@include mask-responsive;
}
.picker-sheet {
@@ -724,6 +725,7 @@ onShow(() => {
display: flex;
flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
@include sheet-responsive;
}
.picker-head {
@@ -789,6 +791,9 @@ onShow(() => {
min-width: 0;
font-size: 28rpx;
color: $color-text;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.picker-check {
+2 -2
View File
@@ -138,8 +138,8 @@ const avatarText = computed(() => {
async function loadStats() {
try {
const [p, d] = await Promise.all([
request({ url: '/app/schedule/list', data: { status: 'pending', pageSize: 50 } }),
request({ url: '/app/schedule/list', data: { status: 'done', pageSize: 50 } })
request({ url: '/api/schedule/list', data: { status: 'pending', pageSize: 50 } }),
request({ url: '/api/schedule/list', data: { status: 'done', pageSize: 50 } })
])
const pending = (p && p.list) || []
pendingCount.value = pending.length
+2 -2
View File
@@ -81,8 +81,8 @@ const done = computed(() => finished.value.length)
async function loadTodo() {
try {
const [p, f] = await Promise.all([
request({ url: '/app/schedule/list', data: { status: 'pending', pageSize: 50 } }),
request({ url: '/app/schedule/list', data: { status: 'done', pageSize: 50 } })
request({ url: '/api/schedule/list', data: { status: 'pending', pageSize: 50 } }),
request({ url: '/api/schedule/list', data: { status: 'done', pageSize: 50 } })
])
pending.value = (p && p.list) || []
finished.value = (f && f.list) || []
+12 -2
View File
@@ -151,7 +151,14 @@
{
"path": "pages/oa/employeefile/index",
"style": {
"navigationBarTitleText": "我的档案",
"navigationBarTitleText": "员工档案",
"navigationBarBackgroundColor": "#F8FAFC"
}
},
{
"path": "pages/oa/employeefile/detail",
"style": {
"navigationBarTitleText": "档案详情",
"navigationBarBackgroundColor": "#F8FAFC"
}
},
@@ -336,7 +343,10 @@
"navigationBarTextStyle": "black",
"navigationBarTitleText": "云泽",
"navigationBarBackgroundColor": "#F8FAFC",
"backgroundColor": "#F8FAFC"
"backgroundColor": "#F8FAFC",
"rpxCalcMaxDeviceWidth": 560,
"rpxCalcBaseDeviceWidth": 375,
"rpxCalcIncludeWidth": 750
},
"uniIdRouter": {}
}
+2 -2
View File
@@ -356,7 +356,7 @@ async function loadHome() {
async function loadPending() {
try {
const data = await request({ url: '/app/schedule/list', data: { status: 'pending', pageSize: 5 } })
const data = await request({ url: '/api/schedule/list', data: { status: 'pending', pageSize: 5 } })
pendingList.value = (data && data.list ? data.list : []).slice(0, 5)
} catch (e) {
pendingList.value = []
@@ -365,7 +365,7 @@ async function loadPending() {
async function loadActivities() {
try {
const data = await request({ url: '/app/activity/list', data: { limit: 5 } })
const data = await request({ url: '/api/activity/list', data: { limit: 5 } })
const list = Array.isArray(data) ? data : (data && data.list) || []
activities.value = list.slice(0, 5).map((item) => ({
type: activityTint(item.target_type),
+496
View File
@@ -0,0 +1,496 @@
<template>
<view class="page">
<scroll-view class="body" scroll-y :show-scrollbar="false">
<view class="body-inner">
<view v-if="loading" class="state-box">
<text class="state-text">加载中...</text>
</view>
<u-empty
v-else-if="!file"
mode="list"
:icon-size="80"
:text="fileId ? '该员工暂无档案' : '您暂无员工档案'"
/>
<block v-else>
<view class="hero-card">
<view class="hero-avatar">
<text>{{ avatarText }}</text>
</view>
<view class="hero-info">
<view class="hero-name-row">
<text class="hero-name">{{ file.employee_name || '本人' }}</text>
<u-tag
:text="getEmploymentStatusLabel(file.employment_status)"
size="mini"
bg-color="rgba(255,255,255,0.22)"
color="#ffffff"
border-color="rgba(255,255,255,0.22)"
class="status-tag"
/>
</view>
<text class="hero-sub">{{ positionText }}</text>
</view>
</view>
<view class="group-card">
<view class="group-head">
<view class="group-bar" />
<text class="group-title">档案完整度</text>
<text class="group-extra">{{ file.completeness || 0 }}%</text>
</view>
<view class="progress-track">
<view class="progress-fill" :style="{ width: (file.completeness || 0) + '%' }" />
</view>
</view>
<view class="group-card">
<view class="group-head">
<view class="group-bar" />
<text class="group-title">基本信息</text>
</view>
<view v-for="row in baseInfo" :key="row.label" class="row">
<text class="row-label">{{ row.label }}</text>
<text class="row-text">{{ row.value || '-' }}</text>
</view>
</view>
<view class="group-card">
<view class="group-head">
<view class="group-bar" />
<text class="group-title">联系方式</text>
</view>
<view v-for="row in contactInfo" :key="row.label" class="row">
<text class="row-label">{{ row.label }}</text>
<text class="row-text">{{ row.value || '-' }}</text>
</view>
</view>
<view class="group-card">
<view class="group-head">
<view class="group-bar" />
<text class="group-title">紧急联系人</text>
</view>
<view v-for="row in emergencyInfo" :key="row.label" class="row">
<text class="row-label">{{ row.label }}</text>
<text class="row-text">{{ row.value || '-' }}</text>
</view>
</view>
<view v-for="group in recordGroups" :key="group.type" class="group-card">
<view class="group-head">
<view class="group-bar" />
<text class="group-title">{{ group.label }}</text>
<text class="group-extra">{{ group.list.length }}</text>
</view>
<view v-if="group.list.length === 0" class="record-empty">
<text class="record-empty-text">暂无记录</text>
</view>
<view v-else v-for="record in group.list" :key="record.id" class="record-item">
<view class="record-top">
<text class="record-title">{{ record.title || group.label }}</text>
<text v-if="record.sub_title" class="record-sub">{{ record.sub_title }}</text>
</view>
<view v-if="recordDate(record)" class="record-date">
<text>{{ recordDate(record) }}</text>
</view>
<text v-if="record.extra" class="record-extra">{{ record.extra }}</text>
<text v-if="record.description" class="record-desc">{{ record.description }}</text>
<view v-if="record.attachment_url" class="record-file" @tap="openAttachment(record.attachment_url)">
<FaIcon name="paperclip" color="#2563eb" :size="13" />
<text>查看附件</text>
</view>
</view>
</view>
</block>
<view class="bottom-space" />
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import {
getMyEmployeeFile,
getEmployeeFileDetail,
getEmploymentStatusLabel,
getMaritalStatusLabel,
FILE_RECORD_TYPE_OPTIONS
} from '@/api/oa.js'
import { isLoggedIn } from '@/utils/auth.js'
import { getBaseURL } from '@/api/request.js'
const GENDER_TEXT = { 0: '未知', 1: '男', 2: '女' }
const file = ref(null)
const records = ref([])
const loading = ref(true)
/**
* 档案 ID。
* 带 id(HR 从列表进入)看指定员工档案;不带 id 则看本人档案。
*/
const fileId = ref(0)
const avatarText = computed(() => {
const name = (file.value && file.value.employee_name) || '我'
return name.slice(0, 1)
})
const positionText = computed(() => {
if (!file.value) return ''
const parts = [file.value.department_name || file.value.department, file.value.position].filter(Boolean)
return parts.join(' · ')
})
const baseInfo = computed(() => {
const f = file.value || {}
return [
{ label: '档案编号', value: f.file_no },
{ label: '员工账号', value: f.employee_account },
{ label: '性别', value: GENDER_TEXT[Number(f.gender)] || '未知' },
{ label: '生日', value: f.birthday },
{ label: '身份证号', value: f.id_card },
{ label: '学历', value: f.education },
{ label: '民族', value: f.nation },
{ label: '政治面貌', value: f.political_status },
{ label: '婚姻状况', value: getMaritalStatusLabel(f.marital_status) },
{ label: '籍贯', value: f.native_place },
{ label: '所属部门', value: f.department_name || f.department },
{ label: '职位', value: f.position },
{ label: '入职日期', value: f.hire_date },
{ label: '转正日期', value: f.regular_date },
{ label: '离职日期', value: f.leave_date },
{ label: '户籍地址', value: f.household_address },
{ label: '现居住地', value: f.current_address }
]
})
const contactInfo = computed(() => {
const f = file.value || {}
return [
{ label: '手机号码', value: f.phone },
{ label: '工作邮箱', value: f.work_email },
{ label: '个人邮箱', value: f.email },
{ label: '家庭住址', value: f.home_address }
]
})
const emergencyInfo = computed(() => {
const f = file.value || {}
return [
{ label: '联系人', value: f.emergency_contact },
{ label: '联系电话', value: f.emergency_phone },
{ label: '关系', value: f.emergency_relation }
]
})
const recordGroups = computed(() => {
return FILE_RECORD_TYPE_OPTIONS.map(option => ({
type: option.value,
label: option.label,
list: records.value.filter(r => Number(r.type) === option.value)
}))
})
onLoad((options) => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
// 带 id 进入(HR 从档案列表进入)看指定员工档案;不带 id 则看本人档案
fileId.value = Number((options && options.id) || 0)
loadFile()
})
async function loadFile() {
loading.value = true
try {
const data = fileId.value
? await getEmployeeFileDetail(fileId.value)
: await getMyEmployeeFile()
file.value = (data && data.file) || null
records.value = (data && data.records) || []
if (fileId.value && file.value && file.value.employee_name) {
uni.setNavigationBarTitle({ title: `${file.value.employee_name}的档案` })
}
} catch (e) {
file.value = null
records.value = []
} finally {
loading.value = false
}
}
function fmtDate(value) {
if (!value) return ''
const text = String(value)
return text.length >= 10 ? text.slice(0, 10) : text
}
function recordDate(record) {
const start = fmtDate(record.start_date)
const end = fmtDate(record.end_date)
if (!start && !end) return ''
if (start && end) return `${start} ~ ${end}`
return start || end
}
function absoluteUrl(url) {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
const base = (getBaseURL() || '').replace(/\/$/, '')
return base + (url.startsWith('/') ? url : '/' + url)
}
function openAttachment(url) {
const full = absoluteUrl(url)
if (!full) return
uni.setClipboardData({
data: full,
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
})
}
</script>
<style lang="scss" scoped>
@import '@/src/styles/page-common.scss';
.page {
min-height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
}
.body {
flex: 1;
height: 0;
}
.body-inner {
padding: 24rpx $page-padding-x 0;
}
.hero-card {
@include card;
display: flex;
align-items: center;
gap: 24rpx;
padding: 32rpx 28rpx;
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
box-shadow: $shadow-float;
}
.hero-avatar {
width: 104rpx;
height: 104rpx;
border-radius: $radius-full;
background: rgba(255, 255, 255, 0.22);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hero-avatar text {
font-size: 44rpx;
font-weight: 600;
color: #ffffff;
}
.hero-info {
flex: 1;
min-width: 0;
}
.hero-name-row {
display: flex;
align-items: center;
gap: 14rpx;
}
.hero-name {
font-size: 36rpx;
font-weight: 600;
color: #ffffff;
min-width: 0;
word-break: break-all;
}
.status-tag {
flex-shrink: 0;
}
.hero-sub {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.82);
}
.group-card {
@include card;
margin-top: 20rpx;
padding: 24rpx 28rpx;
}
.group-head {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
}
.group-bar {
width: 6rpx;
height: 26rpx;
border-radius: 3rpx;
background: $color-primary;
}
.group-title {
font-size: 29rpx;
font-weight: 600;
color: $color-text;
}
.group-extra {
margin-left: auto;
font-size: 24rpx;
color: $color-text-muted;
}
.progress-track {
height: 14rpx;
margin-top: 20rpx;
border-radius: $radius-full;
background: $color-bg-page;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: $radius-full;
background: linear-gradient(90deg, #60a5fa 0%, #2563eb 100%);
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid $color-divider;
&:last-child {
border-bottom: none;
}
}
.row-label {
font-size: 27rpx;
color: $color-text-secondary;
flex-shrink: 0;
}
.row-text {
font-size: 27rpx;
color: $color-text;
text-align: right;
flex: 1;
min-width: 0;
word-break: break-all;
}
.record-item {
padding: 22rpx 0;
border-bottom: 1rpx solid $color-divider;
&:last-child {
border-bottom: none;
}
}
.record-top {
display: flex;
align-items: center;
gap: 14rpx;
}
.record-title {
font-size: 28rpx;
font-weight: 600;
color: $color-text;
min-width: 0;
word-break: break-all;
}
.record-sub {
font-size: 24rpx;
color: $color-text-secondary;
min-width: 0;
word-break: break-all;
}
.record-date {
margin-top: 10rpx;
font-size: 23rpx;
color: $color-text-muted;
}
.record-extra {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
color: $color-text-secondary;
word-break: break-all;
}
.record-desc {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.6;
color: $color-text-muted;
word-break: break-all;
}
.record-file {
display: inline-flex;
align-items: center;
gap: 8rpx;
margin-top: 14rpx;
font-size: 24rpx;
color: $color-primary;
}
.record-empty {
padding: 24rpx 0;
}
.record-empty-text {
font-size: 25rpx;
color: $color-text-muted;
}
.state-box {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 48rpx;
gap: 20rpx;
}
.state-text {
font-size: 26rpx;
color: $color-text-muted;
text-align: center;
}
.bottom-space {
height: 48rpx;
}
</style>
+256 -362
View File
@@ -1,241 +1,233 @@
<template>
<view class="page">
<scroll-view class="body" scroll-y :show-scrollbar="false">
<view class="body-inner">
<view class="toolbar">
<u-search
v-model="keyword"
placeholder="搜索姓名、账号、手机或档案编号"
:show-action="false"
shape="round"
bg-color="#f1f5f9"
color="#0f172a"
@search="onSearch"
@clear="onSearch"
/>
<u-tabs
:list="STATUS_TABS"
:current="statusIndex"
:scrollable="false"
line-color="#2563eb"
:line-width="40"
:line-height="6"
:active-style="{ color: '#2563eb', fontWeight: '600' }"
:inactive-style="{ color: '#475569' }"
:item-style="{ height: '84rpx' }"
@change="onTabChange"
/>
<u-dropdown active-color="#2563eb" :height="84">
<u-dropdown-item
v-model="selectedOrgId"
title="部门"
:options="deptOptions"
height="520rpx"
@change="onDeptChange"
/>
</u-dropdown>
</view>
<scroll-view class="list-scroll" scroll-y :show-scrollbar="false" @scrolltolower="loadMore">
<view v-if="loading" class="state-box">
<text class="state-text">加载中...</text>
</view>
<u-empty
v-else-if="!file"
v-else-if="list.length === 0"
mode="list"
:icon-size="80"
text="您暂无员工档案"
:text="keyword ? '未找到相关档案' : '暂无员工档案'"
/>
<block v-else>
<view class="hero-card">
<view class="hero-avatar">
<text>{{ avatarText }}</text>
</view>
<view class="hero-info">
<view class="hero-name-row">
<text class="hero-name">{{ file.employee_name || '本人' }}</text>
<u-tag
:text="getEmploymentStatusLabel(file.employment_status)"
size="mini"
bg-color="rgba(255,255,255,0.22)"
color="#ffffff"
border-color="rgba(255,255,255,0.22)"
class="status-tag"
/>
<view class="file-list">
<view v-for="item in list" :key="item.id" class="file-card" @tap="openDetail(item.id)">
<u-avatar
:text="firstChar(item.employee_name)"
:size="48"
bg-color="#2563eb"
font-size="18"
/>
<view class="file-body">
<view class="file-head">
<text class="file-name">{{ item.employee_name || '未命名' }}</text>
<text class="status-tag" :class="'st' + item.employment_status">
{{ getEmploymentStatusLabel(item.employment_status) }}
</text>
</view>
<text class="file-sub">{{ subLine(item) }}</text>
<view class="file-foot">
<text class="file-no">{{ item.file_no || '暂无编号' }}</text>
<text class="file-rate">完整度 {{ item.completeness || 0 }}%</text>
</view>
</view>
<text class="hero-sub">{{ positionText }}</text>
<u-icon name="arrow-right" color="#94a3b8" size="16" />
</view>
</view>
<view class="group-card">
<view class="group-head">
<view class="group-bar" />
<text class="group-title">档案完整度</text>
<text class="group-extra">{{ file.completeness || 0 }}%</text>
</view>
<view class="progress-track">
<view class="progress-fill" :style="{ width: (file.completeness || 0) + '%' }" />
</view>
<view v-if="hasMore" class="more-tip" @tap="loadMore">
<text>{{ loadingMore ? '加载中...' : '加载更多' }}</text>
</view>
<view class="group-card">
<view class="group-head">
<view class="group-bar" />
<text class="group-title">基本信息</text>
</view>
<view v-for="row in baseInfo" :key="row.label" class="row">
<text class="row-label">{{ row.label }}</text>
<text class="row-text">{{ row.value || '-' }}</text>
</view>
</view>
<view class="group-card">
<view class="group-head">
<view class="group-bar" />
<text class="group-title">联系方式</text>
</view>
<view v-for="row in contactInfo" :key="row.label" class="row">
<text class="row-label">{{ row.label }}</text>
<text class="row-text">{{ row.value || '-' }}</text>
</view>
</view>
<view class="group-card">
<view class="group-head">
<view class="group-bar" />
<text class="group-title">紧急联系人</text>
</view>
<view v-for="row in emergencyInfo" :key="row.label" class="row">
<text class="row-label">{{ row.label }}</text>
<text class="row-text">{{ row.value || '-' }}</text>
</view>
</view>
<view v-for="group in recordGroups" :key="group.type" class="group-card">
<view class="group-head">
<view class="group-bar" />
<text class="group-title">{{ group.label }}</text>
<text class="group-extra">{{ group.list.length }}</text>
</view>
<view v-if="group.list.length === 0" class="record-empty">
<text class="record-empty-text">暂无记录</text>
</view>
<view v-else v-for="record in group.list" :key="record.id" class="record-item">
<view class="record-top">
<text class="record-title">{{ record.title || group.label }}</text>
<text v-if="record.sub_title" class="record-sub">{{ record.sub_title }}</text>
</view>
<view v-if="recordDate(record)" class="record-date">
<text>{{ recordDate(record) }}</text>
</view>
<text v-if="record.extra" class="record-extra">{{ record.extra }}</text>
<text v-if="record.description" class="record-desc">{{ record.description }}</text>
<view v-if="record.attachment_url" class="record-file" @tap="openAttachment(record.attachment_url)">
<FaIcon name="paperclip" color="#2563eb" :size="13" />
<text>查看附件</text>
</view>
</view>
<view v-else class="more-tip end">
<text>已加载全部 {{ total }} 份档案</text>
</view>
</block>
<view class="bottom-space" />
</view>
</scroll-view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
/**
* 员工档案(HR 视角):展示全公司员工的档案列表。
*
* 权限口径:租户角色里勾选了「员工档案」菜单即为 HR,可见全公司档案;
* 未开通该权限的用户自动降级为查看本人档案(跳详情页),与旧版行为一致。
*/
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import {
getMyEmployeeFile,
getEmploymentStatusLabel,
getMaritalStatusLabel,
FILE_RECORD_TYPE_OPTIONS
getEmployeeFileAccess,
fetchEmployeeFiles,
getEmploymentStatusLabel
} from '@/api/oa.js'
// 组织树复用通讯录接口(同一套组织数据,仅用于部门筛选)
import { fetchContactOrgTree } from '@/api/contact.js'
import { isLoggedIn } from '@/utils/auth.js'
import { getBaseURL } from '@/api/request.js'
const GENDER_TEXT = { 0: '未知', 1: '男', 2: '女' }
/** 在职状态 tab:全部 / 试用 / 正式 / 离职,索引与 employment_status 对应(0 表示不筛选) */
const STATUS_TABS = [{ name: '全部' }, { name: '试用' }, { name: '正式' }, { name: '离职' }]
const STATUS_VALUES = [0, 1, 2, 3]
const file = ref(null)
const records = ref([])
const loading = ref(true)
const keyword = ref('')
const orgs = ref([])
const selectedOrgId = ref(0)
const statusIndex = ref(0)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const loading = ref(false)
const loadingMore = ref(false)
const hasMore = ref(false)
const avatarText = computed(() => {
const name = (file.value && file.value.employee_name) || '我'
return name.slice(0, 1)
})
const positionText = computed(() => {
if (!file.value) return ''
const parts = [file.value.department_name || file.value.department, file.value.position].filter(Boolean)
return parts.join(' · ')
})
const baseInfo = computed(() => {
const f = file.value || {}
return [
{ label: '档案编号', value: f.file_no },
{ label: '员工账号', value: f.employee_account },
{ label: '性别', value: GENDER_TEXT[Number(f.gender)] || '未知' },
{ label: '生日', value: f.birthday },
{ label: '身份证号', value: f.id_card },
{ label: '学历', value: f.education },
{ label: '民族', value: f.nation },
{ label: '政治面貌', value: f.political_status },
{ label: '婚姻状况', value: getMaritalStatusLabel(f.marital_status) },
{ label: '籍贯', value: f.native_place },
{ label: '所属部门', value: f.department_name || f.department },
{ label: '职位', value: f.position },
{ label: '入职日期', value: f.hire_date },
{ label: '转正日期', value: f.regular_date },
{ label: '离职日期', value: f.leave_date },
{ label: '户籍地址', value: f.household_address },
{ label: '现居住地', value: f.current_address }
]
})
const contactInfo = computed(() => {
const f = file.value || {}
return [
{ label: '手机号码', value: f.phone },
{ label: '工作邮箱', value: f.work_email },
{ label: '个人邮箱', value: f.email },
{ label: '家庭住址', value: f.home_address }
]
})
const emergencyInfo = computed(() => {
const f = file.value || {}
return [
{ label: '联系人', value: f.emergency_contact },
{ label: '联系电话', value: f.emergency_phone },
{ label: '关系', value: f.emergency_relation }
]
})
const recordGroups = computed(() => {
return FILE_RECORD_TYPE_OPTIONS.map(option => ({
type: option.value,
label: option.label,
list: records.value.filter(r => Number(r.type) === option.value)
}))
})
onMounted(() => {
onLoad(() => {
if (!isLoggedIn()) {
uni.reLaunch({ url: '/pages/login/login' })
return
}
loadFile()
init()
})
async function loadFile() {
loading.value = true
async function init() {
let canViewAll = false
try {
const data = await getMyEmployeeFile()
file.value = (data && data.file) || null
records.value = (data && data.records) || []
canViewAll = await getEmployeeFileAccess()
} catch (e) {
file.value = null
records.value = []
} finally {
loading.value = false
canViewAll = false
}
if (!canViewAll) {
// 无 HR 权限:保持原有「只能看本人档案」的行为
uni.redirectTo({ url: '/pages/oa/employeefile/detail' })
return
}
loadOrgTree()
loadFiles(true)
}
/** 部门下拉选项:全部 + 各部门(组织树含子级,展开为平铺列表) */
const deptOptions = computed(() => {
const options = [{ label: '全部部门', value: 0 }]
flattenOrgs(orgs.value, options)
return options
})
/** 组织树拍平为部门列表 */
function flattenOrgs(nodes, out) {
;(nodes || []).forEach((node) => {
if (node.org_name) {
out.push({ label: node.org_name, value: node.id })
}
if (node.children && node.children.length) {
flattenOrgs(node.children, out)
}
})
}
async function loadOrgTree() {
try {
// 复用通讯录的组织树接口:同一套组织数据,仅用于筛选,不涉及联系人明细
const tree = await fetchContactOrgTree()
orgs.value = Array.isArray(tree) ? tree : []
} catch (e) {
orgs.value = []
}
}
function fmtDate(value) {
if (!value) return ''
const text = String(value)
return text.length >= 10 ? text.slice(0, 10) : text
async function loadFiles(reset) {
if (reset) {
page.value = 1
loading.value = true
} else {
loadingMore.value = true
}
try {
const res = await fetchEmployeeFiles({
keyword: keyword.value,
status: STATUS_VALUES[statusIndex.value] || 0,
orgId: selectedOrgId.value,
page: page.value,
pageSize
})
list.value = reset ? res.list : list.value.concat(res.list)
total.value = res.total
hasMore.value = list.value.length < res.total
} catch (e) {
if (reset) list.value = []
} finally {
loading.value = false
loadingMore.value = false
}
}
function recordDate(record) {
const start = fmtDate(record.start_date)
const end = fmtDate(record.end_date)
if (!start && !end) return ''
if (start && end) return `${start} ~ ${end}`
return start || end
function loadMore() {
if (!hasMore.value || loading.value || loadingMore.value) return
page.value += 1
loadFiles(false)
}
function absoluteUrl(url) {
if (!url) return ''
if (/^https?:\/\//i.test(url)) return url
const base = (getBaseURL() || '').replace(/\/$/, '')
return base + (url.startsWith('/') ? url : '/' + url)
function onSearch() {
loadFiles(true)
}
function openAttachment(url) {
const full = absoluteUrl(url)
if (!full) return
uni.setClipboardData({
data: full,
success: () => uni.showToast({ title: '链接已复制', icon: 'none' })
})
function onTabChange(item, index) {
statusIndex.value = index
loadFiles(true)
}
function onDeptChange() {
loadFiles(true)
}
function subLine(item) {
const parts = []
if (item.position) parts.push(item.position)
const dept = item.department_name || item.department
if (dept) parts.push(dept)
if (item.phone) parts.push(item.phone)
return parts.length ? parts.join(' · ') : '暂无职位与联系方式'
}
function firstChar(name) {
return String(name || '?').charAt(0)
}
function openDetail(id) {
uni.navigateTo({ url: `/pages/oa/employeefile/detail?id=${id}` })
}
</script>
@@ -249,215 +241,117 @@ function openAttachment(url) {
flex-direction: column;
}
.body {
.toolbar {
position: relative;
z-index: 10;
flex-shrink: 0;
padding: 20rpx $page-padding-x 0;
background: $color-card;
box-shadow: $shadow-header;
}
.list-scroll {
flex: 1;
height: 0;
}
.body-inner {
.file-list {
padding: 24rpx $page-padding-x 0;
}
.hero-card {
.file-card {
@include card;
display: flex;
align-items: center;
gap: 24rpx;
padding: 32rpx 28rpx;
background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
box-shadow: $shadow-float;
gap: 22rpx;
padding: 24rpx;
margin-bottom: 18rpx;
&:active {
background: $color-bg-page;
}
}
.hero-avatar {
width: 104rpx;
height: 104rpx;
border-radius: $radius-full;
background: rgba(255, 255, 255, 0.22);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hero-avatar text {
font-size: 44rpx;
font-weight: 600;
color: #ffffff;
}
.hero-info {
.file-body {
flex: 1;
min-width: 0;
}
.hero-name-row {
.file-head {
display: flex;
align-items: center;
gap: 14rpx;
gap: 12rpx;
}
.hero-name {
font-size: 36rpx;
.file-name {
font-size: 31rpx;
font-weight: 600;
color: #ffffff;
min-width: 0;
word-break: break-all;
color: $color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-tag {
flex-shrink: 0;
}
.hero-sub {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
color: rgba(255, 255, 255, 0.82);
}
.group-card {
@include card;
margin-top: 20rpx;
padding: 24rpx 28rpx;
}
.group-head {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 8rpx;
}
.group-bar {
width: 6rpx;
height: 26rpx;
border-radius: 3rpx;
background: $color-primary;
}
.group-title {
font-size: 29rpx;
font-weight: 600;
color: $color-text;
}
.group-extra {
margin-left: auto;
font-size: 24rpx;
padding: 2rpx 12rpx;
font-size: 20rpx;
border-radius: $radius-sm;
color: $color-text-muted;
}
.progress-track {
height: 14rpx;
margin-top: 20rpx;
border-radius: $radius-full;
background: $color-bg-page;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: $radius-full;
background: linear-gradient(90deg, #60a5fa 0%, #2563eb 100%);
}
&.st2 {
color: $color-primary;
background: $color-primary-bg;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20rpx;
padding: 22rpx 0;
border-bottom: 1rpx solid $color-divider;
&:last-child {
border-bottom: none;
&.st3 {
color: #94a3b8;
background: #f1f5f9;
}
}
.row-label {
font-size: 27rpx;
color: $color-text-secondary;
flex-shrink: 0;
}
.row-text {
font-size: 27rpx;
color: $color-text;
text-align: right;
flex: 1;
min-width: 0;
word-break: break-all;
}
.record-item {
padding: 22rpx 0;
border-bottom: 1rpx solid $color-divider;
&:last-child {
border-bottom: none;
}
}
.record-top {
display: flex;
align-items: center;
gap: 14rpx;
}
.record-title {
font-size: 28rpx;
font-weight: 600;
color: $color-text;
min-width: 0;
word-break: break-all;
}
.record-sub {
font-size: 24rpx;
color: $color-text-secondary;
min-width: 0;
word-break: break-all;
}
.record-date {
margin-top: 10rpx;
font-size: 23rpx;
color: $color-text-muted;
}
.record-extra {
.file-sub {
display: block;
margin-top: 8rpx;
font-size: 24rpx;
font-size: 25rpx;
color: $color-text-secondary;
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.record-desc {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
line-height: 1.6;
color: $color-text-muted;
word-break: break-all;
}
.record-file {
display: inline-flex;
.file-foot {
display: flex;
align-items: center;
gap: 8rpx;
margin-top: 14rpx;
font-size: 24rpx;
gap: 16rpx;
margin-top: 10rpx;
}
.file-no {
font-size: 22rpx;
color: $color-text-muted;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-rate {
margin-left: auto;
flex-shrink: 0;
font-size: 22rpx;
color: $color-primary;
}
.record-empty {
padding: 24rpx 0;
}
.more-tip {
padding: 12rpx 0 4rpx;
text-align: center;
font-size: 24rpx;
color: $color-primary;
.record-empty-text {
font-size: 25rpx;
color: $color-text-muted;
&.end {
color: $color-text-muted;
}
}
.state-box {
@@ -465,7 +359,7 @@ function openAttachment(url) {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 48rpx;
padding: 160rpx 48rpx;
gap: 20rpx;
}
@@ -476,6 +370,6 @@ function openAttachment(url) {
}
.bottom-space {
height: 48rpx;
height: 40rpx;
}
</style>
+24
View File
@@ -69,3 +69,27 @@
font-size: 22rpx;
line-height: 1.6;
}
// ============ 弹层宽屏自适应(全局约定,新弹层直接复用即可,无需逐页调) ============
// 用法:铺满式遮罩根元素内 `@include mask-responsive;`,其底部面板内 `@include sheet-responsive;`
// 手机竖屏(<560px)不命中,行为不变;宽屏下遮罩仍铺满做暗色背景,面板收敛为居中手机列宽。
@mixin mask-responsive {
@media (min-width: 560px) {
justify-content: center;
}
}
@mixin sheet-responsive($w: 560px) {
@media (min-width: 560px) {
max-width: $w;
}
}
// 居中弹窗(非贴底):宽屏下把固定定位的遮罩面板限制为列宽并水平+垂直居中
@mixin dialog-responsive($w: 560px) {
@media (min-width: 560px) {
max-width: $w;
left: 50%;
transform: translateX(-50%);
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ export function isMenuLocked(menu = {}) {
/**
* 打开一个菜单功能
* @param {object} menu /app/home 返回的菜单项
* @param {object} menu /api/home 返回的菜单项
* @param {string} [fallbackTitle] 没有菜单时用于提示的功能名
*/
export function openMenu(menu, fallbackTitle = '') {
+1 -1
View File
@@ -3,7 +3,7 @@
*
* 三个子系统页面(概览 / 功能 / 待办)都需要:
* 1. 该子系统的配置(config/subsystems.js)
* 2. 该子系统下的菜单(/app/home → menus 按 module 归组)
* 2. 该子系统下的菜单(/api/home → menus 按 module 归组)
* 这里统一封装,避免三个页面各写一遍。
*/
import { ref, computed } from 'vue'
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { footerColumns, isExternalLink, policyLinks, siteInfo } from "@/config/site";
import { isExternalLink, policyLinks, siteInfo } from "@/config/site";
</script>
<template>
@@ -17,7 +17,7 @@ const fetchUpgradeList = async () => {
loading.value = true;
try {
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:9000';
const response = await fetch(`${API_BASE_URL}/platform/api/upgrade/list?limit=3`);
const response = await fetch(`${API_BASE_URL}/site/upgrade/list?limit=3`);
const result = await response.json();
if (result.code === 200 && result.data) {
upgradeList.value = result.data;