feat: 支持审批步骤绑定部门与多对象

- 新增审批对象关联表和存量数据迁移

- 支持用户、角色、部门多选及办理权限匹配

- 完善部门状态与批量管理交互
This commit is contained in:
2026-07-30 15:13:11 +08:00
parent 864cea6135
commit 57bd7b5d06
29 changed files with 3694 additions and 365 deletions

View File

@@ -206,13 +206,44 @@ function formatApplicationReason(value?: null | string) {
}
function formatAssigneeDisplay(row: Record<string, any>) {
if (!row?.assigneeType || !row?.assigneeTargetName) {
if (!row?.assigneeType) {
return '-';
}
if (row.assigneeType === 'ROLE') {
return `${$t('approval.assignee.role')}${row.assigneeTargetName}`;
const labelMap: Record<string, string> = {
DEPT: $t('approval.assignee.dept'),
ROLE: $t('approval.assignee.role'),
USER: $t('approval.assignee.user'),
};
let targets: Array<Record<string, any>> = [];
if (Array.isArray(row.assigneeTargets) && row.assigneeTargets.length > 0) {
targets = row.assigneeTargets;
} else if (row.assigneeTargetName) {
targets = [
{
includeChildren: 0,
targetName: row.assigneeTargetName,
},
];
}
return `${$t('approval.assignee.user')}${row.assigneeTargetName}`;
const targetNames = targets
.map((target: Record<string, any>) => {
const name = String(target?.targetName || '').trim();
if (!name) {
return '';
}
if (
row.assigneeType === 'DEPT' &&
(target.includeChildren === 1 || target.includeChildren === true)
) {
return `${name}${$t('approval.fields.includeChildren')}`;
}
return name;
})
.filter(Boolean);
if (targetNames.length === 0) {
return '-';
}
return `${labelMap[row.assigneeType] || row.assigneeType}${targetNames.join('、')}`;
}
function getEventTypeLabel(eventType?: string) {
@@ -384,7 +415,7 @@ function formatEventInfo(row: Record<string, any>) {
</ElTableColumn>
<ElTableColumn
:label="$t('approval.fields.assigneeTarget')"
width="220"
min-width="260"
>
<template #default="{ row }">
{{ formatAssigneeDisplay(row) }}

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { nextTick, ref, watch } from 'vue';
import { computed, nextTick, ref, watch } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
@@ -13,6 +13,7 @@ import {
ElInput,
ElInputNumber,
ElMessage,
ElMessageBox,
ElOption,
ElSelect,
ElSwitch,
@@ -32,7 +33,7 @@ defineExpose({
type ResourceType = '' | 'BOT' | 'KNOWLEDGE' | 'WORKFLOW';
type ActionType = '' | 'DELETE' | 'OFFLINE' | 'PUBLISH';
type AssigneeType = 'ROLE' | 'USER';
type AssigneeType = 'DEPT' | 'ROLE' | 'USER';
type ScopeType = 'CATEGORY' | 'DEPT';
type FlowStatus = 'DISABLED' | 'ENABLED';
@@ -49,14 +50,21 @@ interface ScopeItem {
}
interface StepItem {
assigneeTargetCode?: string;
assigneeTargetId?: number | string;
assigneeTargetName?: string;
assigneeTargetIds: Array<number | string>;
assigneeTargets: AssigneeTarget[];
assigneeType: AssigneeType;
includeChildrenByTarget: Record<string, boolean>;
id?: number | string;
stepName: string;
}
interface AssigneeTarget {
includeChildren: boolean;
targetCode?: string;
targetId: number | string;
targetName?: string;
}
interface FlowFormModel {
actionType: ActionType;
id?: number | string;
@@ -94,14 +102,16 @@ const SCOPE_TYPE_OPTIONS = [
const ASSIGNEE_TYPE_OPTIONS: Array<{ label: string; value: AssigneeType }> = [
{ label: $t('approval.assignee.role'), value: 'ROLE' },
{ label: $t('approval.assignee.user'), value: 'USER' },
{ label: $t('approval.assignee.dept'), value: 'DEPT' },
];
const ENABLED_DATA_STATUS = 1;
const ASSIGNEE_VISIBLE_TAG_COUNT = 2;
const saveForm = ref<FormInstance>();
const dialogVisible = ref(false);
const isAdd = ref(true);
const btnLoading = ref(false);
const categoryLoaded = ref(false);
const deptLoaded = ref(false);
const roleLoaded = ref(false);
const accountLoading = ref(false);
const deptTreeOptions = ref<any[]>([]);
@@ -112,6 +122,19 @@ const categoryOptions = ref<Record<Exclude<ResourceType, ''>, SelectOption[]>>({
KNOWLEDGE: [],
WORKFLOW: [],
});
const deptOptionMap = computed(() => {
const result = new Map<string, string>();
const visit = (items: any[]) => {
for (const item of items) {
result.set(String(item.id), item.deptName || String(item.id));
if (Array.isArray(item.children)) {
visit(item.children);
}
}
};
visit(deptTreeOptions.value);
return result;
});
const formModel = ref<FlowFormModel>(buildDefaultForm());
@@ -158,7 +181,17 @@ function buildDefaultForm(): FlowFormModel {
resourceType: 'BOT',
scopes: [],
status: 'ENABLED',
steps: [{ assigneeType: 'ROLE', stepName: '' }],
steps: [buildDefaultStep()],
};
}
function buildDefaultStep(): StepItem {
return {
assigneeTargetIds: [],
assigneeTargets: [],
assigneeType: 'ROLE',
includeChildrenByTarget: {},
stepName: '',
};
}
@@ -200,17 +233,20 @@ async function openDialog(row: any = {}) {
scopeValue: item.scopeValue,
})),
status: res.data?.status || 'ENABLED',
steps: (res.data?.steps || []).map((item: any) => ({
assigneeTargetCode: item.assigneeTargetCode,
assigneeTargetId: item.assigneeTargetId,
assigneeTargetName: item.assigneeTargetName,
assigneeType: item.assigneeType || 'ROLE',
id: item.id,
stepName: item.stepName,
})),
steps: (res.data?.steps || []).map((item: any) => {
const targets = normalizeAssigneeTargets(item);
return {
assigneeTargetIds: targets.map((target) => target.targetId),
assigneeTargets: targets,
assigneeType: item.assigneeType || 'ROLE',
id: item.id,
includeChildrenByTarget: buildIncludeChildrenByTarget(targets),
stepName: item.stepName,
};
}),
};
if (formModel.value.steps.length === 0) {
formModel.value.steps = [{ assigneeType: 'ROLE', stepName: '' }];
formModel.value.steps = [buildDefaultStep()];
}
for (const step of formModel.value.steps) {
registerAccountOption(step);
@@ -249,18 +285,15 @@ async function ensureCategoryOptions() {
}
async function ensureDeptOptions() {
if (deptLoaded.value) {
return;
}
const res = await api.get('/api/v1/sysDept/list', {
params: {
asTree: true,
sortKey: 'sortNo',
sortType: 'asc',
status: ENABLED_DATA_STATUS,
},
});
deptTreeOptions.value = Array.isArray(res.data) ? res.data : [];
deptLoaded.value = true;
}
async function ensureRoleOptions() {
@@ -310,6 +343,41 @@ function normalizeSelectOptions(data: any[] = []) {
}));
}
function normalizeAssigneeTargets(item: any): AssigneeTarget[] {
const targets = Array.isArray(item?.assigneeTargets)
? item.assigneeTargets
: [];
if (targets.length > 0) {
return targets
.filter((target: any) => target?.targetId)
.map((target: any) => ({
includeChildren: target.includeChildren === 1,
targetCode: target.targetCode,
targetId: target.targetId,
targetName: target.targetName,
}));
}
if (!item?.assigneeTargetId) {
return [];
}
return [
{
includeChildren: false,
targetCode: item.assigneeTargetCode,
targetId: item.assigneeTargetId,
targetName: item.assigneeTargetName,
},
];
}
function buildIncludeChildrenByTarget(targets: AssigneeTarget[]) {
const result: Record<string, boolean> = {};
for (const target of targets) {
result[String(target.targetId)] = target.includeChildren;
}
return result;
}
function addScope() {
formModel.value.scopes.push({
includeChildren: false,
@@ -323,7 +391,7 @@ function removeScope(index: number) {
}
function addStep() {
formModel.value.steps.push({ assigneeType: 'ROLE', stepName: '' });
formModel.value.steps.push(buildDefaultStep());
}
function removeStep(index: number) {
@@ -341,104 +409,163 @@ function getCategoryScopeOptions() {
return categoryOptions.value[resourceType];
}
function handleAssigneeTypeChange(step: StepItem) {
step.assigneeTargetId = undefined;
step.assigneeTargetCode = '';
step.assigneeTargetName = '';
if (step.assigneeType === 'USER') {
async function handleAssigneeTypeChange(
step: StepItem,
assigneeType: AssigneeType,
) {
if (step.assigneeType === assigneeType) {
return;
}
if (step.assigneeTargetIds.length > 0) {
try {
await ElMessageBox.confirm(
$t('approval.message.confirmAssigneeTypeChange'),
$t('approval.fields.assigneeTarget'),
{
cancelButtonText: $t('button.cancel'),
confirmButtonText: $t('button.confirm'),
type: 'warning',
},
);
} catch (error) {
if (error === 'cancel' || error === 'close') {
return;
}
throw error;
}
}
step.assigneeType = assigneeType;
step.assigneeTargetIds = [];
step.assigneeTargets = [];
step.includeChildrenByTarget = {};
if (assigneeType === 'USER') {
void searchAccountOptions('');
}
}
function handleAssigneeTargetChange(step: StepItem) {
const options =
step.assigneeType === 'ROLE' ? roleOptions.value : accountOptions.value;
const selected = options.find(
(item) => String(item.id) === String(step.assigneeTargetId),
const selectedIds = new Set(step.assigneeTargetIds.map(String));
step.assigneeTargets = step.assigneeTargets.filter((target) =>
selectedIds.has(String(target.targetId)),
);
step.assigneeTargetName = selected?.label || '';
for (const targetId of Object.keys(step.includeChildrenByTarget)) {
if (!selectedIds.has(targetId)) {
delete step.includeChildrenByTarget[targetId];
}
}
for (const targetId of step.assigneeTargetIds) {
const targetKey = String(targetId);
if (!(targetKey in step.includeChildrenByTarget)) {
step.includeChildrenByTarget[targetKey] = false;
}
}
}
function removeAssigneeTarget(step: StepItem, targetId: number | string) {
const targetKey = String(targetId);
step.assigneeTargetIds = step.assigneeTargetIds.filter(
(item) => String(item) !== targetKey,
);
handleAssigneeTargetChange(step);
}
function getDeptTargetName(targetId: number | string) {
return deptOptionMap.value.get(String(targetId)) || String(targetId);
}
function registerAccountOption(step?: StepItem) {
if (!step?.assigneeTargetId || !step.assigneeTargetName) {
if (!step || step.assigneeType !== 'USER') {
return;
}
if (
accountOptions.value.some(
(item) => String(item.id) === String(step.assigneeTargetId),
)
) {
return;
for (const target of step.assigneeTargets) {
if (
!target.targetName ||
accountOptions.value.some(
(item) => String(item.id) === String(target.targetId),
)
) {
continue;
}
accountOptions.value.push({
id: target.targetId,
label: target.targetName,
});
}
accountOptions.value.push({
id: step.assigneeTargetId,
label: step.assigneeTargetName,
});
}
async function save() {
saveForm.value?.validate(async (valid) => {
if (btnLoading.value) {
return;
}
btnLoading.value = true;
try {
const valid = await saveForm.value?.validate().catch(() => false);
if (!valid) {
return;
}
btnLoading.value = true;
try {
const steps = formModel.value.steps
.map((step, index) => ({
assigneeTargetId: step.assigneeTargetId,
assigneeType: step.assigneeType,
id: step.id,
stepName: step.stepName?.trim(),
stepNo: index + 1,
}))
.filter((step) => step.stepName);
if (steps.length === 0) {
ElMessage.warning($t('approval.message.needStep'));
btnLoading.value = false;
return;
}
if (steps.some((step) => !step.assigneeType || !step.assigneeTargetId)) {
ElMessage.warning($t('approval.message.needStepAssignee'));
btnLoading.value = false;
return;
}
const scopes = formModel.value.scopes
.filter((scope) => scope.scopeValue && scope.scopeType)
.map((scope) => ({
id: scope.id,
includeChildren: scope.includeChildren ? 1 : 0,
scopeType: scope.scopeType,
scopeValue: scope.scopeValue,
}));
const payload = {
actionType: formModel.value.actionType,
id: formModel.value.id,
name: formModel.value.name.trim(),
priority: formModel.value.priority,
remark: formModel.value.remark.trim(),
resourceType: formModel.value.resourceType,
scopes,
status: formModel.value.status,
steps,
};
const url = isAdd.value
? '/api/v1/approvalFlow/save'
: '/api/v1/approvalFlow/update';
const res = await api.post(url, payload);
if (res.errorCode !== 0) {
btnLoading.value = false;
return;
}
ElMessage.success($t('approval.message.saveSuccess'));
emit('reload');
closeDialog();
} finally {
btnLoading.value = false;
const steps = formModel.value.steps
.map((step, index) => ({
assigneeTargets: step.assigneeTargetIds.map((targetId) => ({
includeChildren:
step.assigneeType === 'DEPT' &&
step.includeChildrenByTarget[String(targetId)]
? 1
: 0,
targetId,
})),
assigneeType: step.assigneeType,
id: step.id,
stepName: step.stepName?.trim(),
stepNo: index + 1,
}))
.filter((step) => step.stepName);
if (steps.length === 0) {
ElMessage.warning($t('approval.message.needStep'));
return;
}
});
if (
steps.some(
(step) => !step.assigneeType || step.assigneeTargets.length === 0,
)
) {
ElMessage.warning($t('approval.message.needStepAssignee'));
return;
}
const scopes = formModel.value.scopes
.filter((scope) => scope.scopeValue && scope.scopeType)
.map((scope) => ({
id: scope.id,
includeChildren: scope.includeChildren ? 1 : 0,
scopeType: scope.scopeType,
scopeValue: scope.scopeValue,
}));
const payload = {
actionType: formModel.value.actionType,
id: formModel.value.id,
name: formModel.value.name.trim(),
priority: formModel.value.priority,
remark: formModel.value.remark.trim(),
resourceType: formModel.value.resourceType,
scopes,
status: formModel.value.status,
steps,
};
const url = isAdd.value
? '/api/v1/approvalFlow/save'
: '/api/v1/approvalFlow/update';
const res = await api.post(url, payload);
if (res.errorCode !== 0) {
return;
}
ElMessage.success($t('approval.message.saveSuccess'));
emit('reload');
closeDialog();
} finally {
btnLoading.value = false;
}
}
function closeDialog() {
@@ -600,84 +727,185 @@ function closeDialog() {
<div
v-for="(step, index) in formModel.steps"
:key="`step-${index}`"
class="grid grid-cols-1 gap-3 rounded-2xl border border-[hsl(var(--divider-faint)/0.66)] bg-[hsl(var(--surface-panel)/0.72)] p-4 md:grid-cols-[72px,minmax(0,1.1fr),148px,minmax(0,1fr),88px]"
class="approval-flow-modal__step-card"
>
<div
class="flex items-center text-sm font-medium text-[hsl(var(--text-muted))]"
>
{{ $t('approval.fields.stepNo', { value: index + 1 }) }}
</div>
<ElInput
v-model.trim="step.stepName"
:placeholder="$t('approval.placeholder.stepName')"
/>
<div class="approval-flow-modal__assignee-type">
<button
v-for="item in ASSIGNEE_TYPE_OPTIONS"
:key="item.value"
type="button"
class="approval-flow-modal__assignee-type-item"
:class="{
'is-active': step.assigneeType === item.value,
}"
:aria-pressed="step.assigneeType === item.value"
@click="
step.assigneeType !== item.value &&
((step.assigneeType = item.value),
handleAssigneeTypeChange(step))
"
<div class="approval-flow-modal__step-header">
<span class="approval-flow-modal__step-index">
{{ $t('approval.fields.stepNo', { value: index + 1 }) }}
</span>
<ElButton
text
type="danger"
:disabled="formModel.steps.length <= 1"
@click="removeStep(index)"
>
{{ item.label }}
</button>
{{ $t('button.delete') }}
</ElButton>
</div>
<div class="approval-flow-modal__step-grid">
<div class="approval-flow-modal__step-field">
<span class="approval-flow-modal__step-field-label">
{{ $t('approval.fields.stepName') }}
</span>
<ElInput
v-model.trim="step.stepName"
:placeholder="$t('approval.placeholder.stepName')"
/>
</div>
<div class="approval-flow-modal__step-field">
<span class="approval-flow-modal__step-field-label">
{{ $t('approval.fields.assigneeType') }}
</span>
<div class="approval-flow-modal__assignee-type">
<button
v-for="item in ASSIGNEE_TYPE_OPTIONS"
:key="item.value"
type="button"
class="approval-flow-modal__assignee-type-item"
:class="{
'is-active': step.assigneeType === item.value,
}"
:aria-pressed="step.assigneeType === item.value"
@click="handleAssigneeTypeChange(step, item.value)"
>
{{ item.label }}
</button>
</div>
</div>
<div
class="approval-flow-modal__step-field approval-flow-modal__target-field"
>
<div class="approval-flow-modal__target-heading">
<span class="approval-flow-modal__step-field-label">
{{ $t('approval.fields.assigneeTarget') }}
</span>
<span
v-if="
step.assigneeType === 'DEPT' &&
step.assigneeTargetIds.length > 0
"
class="approval-flow-modal__target-count"
>
{{
$t('approval.fields.selectedDeptCount', {
value: step.assigneeTargetIds.length,
})
}}
</span>
</div>
<ElSelect
v-if="step.assigneeType === 'ROLE'"
v-model="step.assigneeTargetIds"
filterable
clearable
multiple
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="ASSIGNEE_VISIBLE_TAG_COUNT"
:placeholder="$t('approval.placeholder.assigneeTarget')"
@change="handleAssigneeTargetChange(step)"
>
<ElOption
v-for="item in roleOptions"
:key="item.id"
:label="item.label"
:value="item.id"
/>
</ElSelect>
<ElSelect
v-else-if="step.assigneeType === 'USER'"
v-model="step.assigneeTargetIds"
filterable
clearable
multiple
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="ASSIGNEE_VISIBLE_TAG_COUNT"
remote
reserve-keyword
:loading="accountLoading"
:placeholder="$t('approval.placeholder.assigneeTarget')"
:remote-method="searchAccountOptions"
@change="handleAssigneeTargetChange(step)"
@visible-change="
(visible) => visible && searchAccountOptions('')
"
>
<ElOption
v-for="item in accountOptions"
:key="item.id"
:label="item.label"
:value="item.id"
/>
</ElSelect>
<div v-else class="approval-flow-modal__dept-target">
<ElTreeSelect
v-model="step.assigneeTargetIds"
clearable
multiple
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="ASSIGNEE_VISIBLE_TAG_COUNT"
check-strictly
:data="deptTreeOptions"
:props="{
label: 'deptName',
children: 'children',
value: 'id',
}"
:placeholder="$t('approval.placeholder.assigneeTarget')"
@change="handleAssigneeTargetChange(step)"
/>
<div
v-if="step.assigneeTargetIds.length > 0"
class="approval-flow-modal__dept-target-list"
>
<div
v-for="targetId in step.assigneeTargetIds"
:key="String(targetId)"
class="approval-flow-modal__dept-target-option"
>
<span
class="approval-flow-modal__dept-target-name"
:title="getDeptTargetName(targetId)"
>
{{ getDeptTargetName(targetId) }}
</span>
<div class="approval-flow-modal__dept-target-actions">
<label>
{{ $t('approval.fields.includeChildren') }}
<ElSwitch
v-model="
step.includeChildrenByTarget[String(targetId)]
"
/>
</label>
<ElButton
link
type="danger"
@click="removeAssigneeTarget(step, targetId)"
>
{{ $t('approval.action.removeTarget') }}
</ElButton>
</div>
</div>
</div>
</div>
</div>
</div>
<ElSelect
v-if="step.assigneeType === 'ROLE'"
v-model="step.assigneeTargetId"
filterable
clearable
:placeholder="$t('approval.placeholder.assigneeTarget')"
@change="handleAssigneeTargetChange(step)"
>
<ElOption
v-for="item in roleOptions"
:key="item.id"
:label="item.label"
:value="item.id"
/>
</ElSelect>
<ElSelect
v-else
v-model="step.assigneeTargetId"
filterable
clearable
remote
reserve-keyword
:loading="accountLoading"
:placeholder="$t('approval.placeholder.assigneeTarget')"
:remote-method="searchAccountOptions"
@change="handleAssigneeTargetChange(step)"
@visible-change="(visible) => visible && searchAccountOptions('')"
>
<ElOption
v-for="item in accountOptions"
:key="item.id"
:label="item.label"
:value="item.id"
/>
</ElSelect>
<ElButton
text
type="danger"
:disabled="formModel.steps.length <= 1"
@click="removeStep(index)"
>
{{ $t('button.delete') }}
</ElButton>
</div>
<ElButton text type="primary" class="w-fit" @click="addStep">
{{ $t('approval.action.addStep') }}
</ElButton>
<p class="approval-flow-modal__assignee-helper">
{{ $t('approval.helper.assigneeAny') }}
</p>
</div>
</ElForm>
</EasyFlowFormModal>
@@ -692,16 +920,16 @@ function closeDialog() {
.approval-flow-modal__section-meta {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.approval-flow-modal__section-meta p {
margin: 0;
color: hsl(var(--text-muted));
font-size: 13px;
line-height: 1.5;
color: hsl(var(--text-muted));
}
.approval-flow-modal__scope-list {
@@ -712,8 +940,8 @@ function closeDialog() {
.approval-flow-modal__scope-empty {
padding: 4px 0 2px;
color: hsl(var(--text-muted));
font-size: 13px;
color: hsl(var(--text-muted));
}
.approval-flow-modal__scope-row {
@@ -724,7 +952,7 @@ function closeDialog() {
}
.approval-flow-modal__scope-row + .approval-flow-modal__scope-row {
border-top: 1px solid hsl(var(--divider-faint) / 0.72);
border-top: 1px solid hsl(var(--divider-faint) / 72%);
}
.approval-flow-modal__scope-control {
@@ -733,40 +961,90 @@ function closeDialog() {
.approval-flow-modal__scope-switch {
display: flex;
align-items: center;
gap: 10px;
color: hsl(var(--text-muted));
align-items: center;
font-size: 13px;
color: hsl(var(--text-muted));
}
.approval-flow-modal__step-card {
padding: var(--space-4);
background: hsl(var(--surface-panel) / 78%);
border: 1px solid hsl(var(--divider-faint) / 72%);
border-radius: var(--radius-panel);
}
.approval-flow-modal__step-header {
display: flex;
gap: var(--space-3);
align-items: center;
justify-content: space-between;
min-height: 32px;
margin-bottom: var(--space-3);
}
.approval-flow-modal__step-index {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 var(--space-3);
font-size: 13px;
font-weight: 600;
color: hsl(var(--primary));
background: hsl(var(--primary) / 9%);
border-radius: var(--radius-pill);
}
.approval-flow-modal__step-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--space-4);
align-items: start;
}
.approval-flow-modal__step-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
}
.approval-flow-modal__step-field-label {
font-size: 12px;
font-weight: 600;
line-height: 1.4;
color: hsl(var(--text-strong));
}
.approval-flow-modal__assignee-type {
display: inline-flex;
align-items: center;
display: flex;
gap: 6px;
width: fit-content;
align-items: center;
width: 100%;
min-height: 40px;
padding: 4px;
border: 1px solid hsl(var(--divider-faint) / 0.72);
border-radius: 14px;
background: hsl(var(--surface-contrast-soft) / 0.82);
background: hsl(var(--surface-contrast-soft) / 82%);
border: 1px solid hsl(var(--divider-faint) / 72%);
border-radius: var(--radius-toolbar);
}
.approval-flow-modal__assignee-type-item {
min-width: 64px;
flex: 1;
min-width: 0;
height: 32px;
padding: 0 14px;
border: 0;
border-radius: 10px;
background: transparent;
color: hsl(var(--text-muted));
padding: 0 var(--space-3);
font-size: 13px;
font-weight: 600;
line-height: 32px;
color: hsl(var(--text-muted));
cursor: pointer;
background: transparent;
border: 0;
border-radius: var(--radius-control);
transition:
background-color 0.18s ease,
color 0.18s ease,
box-shadow 0.18s ease;
cursor: pointer;
}
.approval-flow-modal__assignee-type-item:hover {
@@ -774,14 +1052,86 @@ function closeDialog() {
}
.approval-flow-modal__assignee-type-item:focus-visible {
outline: 2px solid hsl(var(--primary) / 0.28);
outline: 2px solid hsl(var(--primary) / 28%);
outline-offset: 1px;
}
.approval-flow-modal__assignee-type-item.is-active {
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12);
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 0.14);
background: hsl(var(--primary) / 12%);
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 14%);
}
.approval-flow-modal__target-heading {
display: flex;
gap: var(--space-2);
align-items: center;
justify-content: space-between;
}
.approval-flow-modal__target-count {
font-size: 12px;
line-height: 1.4;
color: hsl(var(--text-muted));
}
.approval-flow-modal__dept-target {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.approval-flow-modal__dept-target-list {
max-height: 176px;
padding: 0 var(--space-2);
overflow-y: auto;
background: hsl(var(--surface-subtle));
border: 1px solid hsl(var(--divider-faint) / 72%);
border-radius: var(--radius-toolbar);
}
.approval-flow-modal__dept-target-option {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--space-3);
align-items: center;
min-height: 48px;
padding: var(--space-2) 0;
font-size: 13px;
color: hsl(var(--text-muted));
}
.approval-flow-modal__dept-target-option
+ .approval-flow-modal__dept-target-option {
border-top: 1px solid hsl(var(--divider-faint) / 72%);
}
.approval-flow-modal__dept-target-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
color: hsl(var(--foreground));
white-space: nowrap;
}
.approval-flow-modal__dept-target-actions {
display: inline-flex;
flex: none;
gap: var(--space-3);
align-items: center;
}
.approval-flow-modal__dept-target-actions label {
display: inline-flex;
gap: var(--space-2);
align-items: center;
white-space: nowrap;
}
.approval-flow-modal__assignee-helper {
margin: -4px 0 0;
font-size: 13px;
color: hsl(var(--text-muted));
}
@media (min-width: 768px) {
@@ -789,5 +1139,35 @@ function closeDialog() {
grid-template-columns: 148px minmax(0, 1fr) 128px 72px;
align-items: center;
}
.approval-flow-modal__step-grid {
grid-template-columns: minmax(0, 1fr) minmax(216px, 0.8fr);
}
.approval-flow-modal__target-field {
grid-column: 1 / -1;
}
}
@media (min-width: 1200px) {
.approval-flow-modal__step-grid {
grid-template-columns:
minmax(180px, 0.85fr) minmax(216px, 0.72fr)
minmax(280px, 1.35fr);
}
.approval-flow-modal__target-field {
grid-column: auto;
}
}
@media (max-width: 560px) {
.approval-flow-modal__dept-target-option {
grid-template-columns: minmax(0, 1fr);
}
.approval-flow-modal__dept-target-actions {
justify-content: space-between;
}
}
</style>

View File

@@ -1,14 +1,11 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { Delete, MoreFilled, Plus } from '@element-plus/icons-vue';
import { Plus } from '@element-plus/icons-vue';
import {
ElButton,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElForm,
ElFormItem,
ElIcon,
@@ -20,89 +17,243 @@ import {
} from 'element-plus';
import { api } from '#/api/request';
import DictSelect from '#/components/dict/DictSelect.vue';
import { $t } from '#/locales';
import { useDictStore } from '#/store';
import SysDeptModal from './SysDeptModal.vue';
onMounted(() => {
getTree();
void getTree();
initDict();
});
const formRef = ref<FormInstance>();
const tableRef = ref<InstanceType<typeof ElTable>>();
const treeData = ref([]);
const selectedRows = ref<any[]>([]);
const loading = ref(false);
const batchActionType = ref<'' | 'delete' | 'disable' | 'enable'>('');
const rowDeleteLoadingId = ref('');
const rowStatusLoadingId = ref('');
const saveDialog = ref();
const formInline = ref({
deptName: '',
status: '',
});
const dictStore = useDictStore();
const selectedCount = computed(() => selectedRows.value.length);
const batchActionLoading = computed(() => batchActionType.value !== '');
const rowActionLoading = computed(
() => rowDeleteLoadingId.value !== '' || rowStatusLoadingId.value !== '',
);
function initDict() {
dictStore.fetchDictionary('dataStatus');
}
function search(formEl: FormInstance | undefined) {
formEl?.validate((valid) => {
if (valid) {
getTree();
void getTree();
}
});
}
function reset(formEl: FormInstance | undefined) {
formEl?.resetFields();
getTree();
void getTree();
}
function showDialog(row: any) {
saveDialog.value.openDialog({ ...row });
}
function remove(row: any) {
ElMessageBox.confirm($t('message.deleteAlert'), $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
beforeClose: (action, instance, done) => {
if (action === 'confirm') {
instance.confirmButtonLoading = true;
api
.post('/api/v1/sysDept/remove', { id: row.id })
.then((res) => {
instance.confirmButtonLoading = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
reset(formRef.value);
done();
}
})
.catch(() => {
instance.confirmButtonLoading = false;
});
} else {
done();
}
},
}).catch(() => {});
function isRootDept(row: any) {
return row?.deptCode === 'root_dept';
}
function getTree() {
function isSelectable(row: any) {
return !isRootDept(row);
}
function handleSelectionChange(rows: any[]) {
selectedRows.value = rows;
}
function clearSelection() {
selectedRows.value = [];
tableRef.value?.clearSelection();
}
function getSelectedIds() {
return selectedRows.value
.map((row) => row?.id)
.filter((id) => id !== null && id !== undefined);
}
function getErrorMessage(error: any) {
return error?.message || $t('sysDept.operationFailed');
}
async function confirmAction(message: string) {
try {
await ElMessageBox.confirm(message, $t('message.noticeTitle'), {
confirmButtonText: $t('message.ok'),
cancelButtonText: $t('message.cancel'),
type: 'warning',
});
return true;
} catch {
return false;
}
}
async function changeStatus(row: any) {
const status = row.status === 1 ? 0 : 1;
const action = status === 1 ? $t('sysDept.enable') : $t('sysDept.disable');
const confirmed = await confirmAction(
$t('sysDept.statusConfirm', {
action,
name: row.deptName,
}),
);
if (!confirmed) {
return;
}
rowStatusLoadingId.value = String(row.id);
try {
const res = await api.post('/api/v1/sysDept/changeStatusBatch', {
ids: [row.id],
status,
});
if (res.errorCode !== 0) {
ElMessage.error(res.message || $t('sysDept.operationFailed'));
return;
}
ElMessage.success($t('sysDept.operationSuccess'));
await getTree();
} catch (error: any) {
ElMessage.error(getErrorMessage(error));
} finally {
rowStatusLoadingId.value = '';
}
}
async function changeSelectedStatus(status: 0 | 1) {
const ids = getSelectedIds();
if (ids.length === 0) {
ElMessage.warning($t('sysDept.selectRequired'));
return;
}
const action = status === 1 ? $t('sysDept.enable') : $t('sysDept.disable');
const confirmed = await confirmAction(
$t('sysDept.batchStatusConfirm', {
action,
count: ids.length,
}),
);
if (!confirmed) {
return;
}
batchActionType.value = status === 1 ? 'enable' : 'disable';
try {
const res = await api.post('/api/v1/sysDept/changeStatusBatch', {
ids,
status,
});
if (res.errorCode !== 0) {
ElMessage.error(res.message || $t('sysDept.operationFailed'));
return;
}
ElMessage.success($t('sysDept.operationSuccess'));
await getTree();
} catch (error: any) {
ElMessage.error(getErrorMessage(error));
} finally {
batchActionType.value = '';
}
}
async function submitDelete(
url: string,
payload: Record<string, any>,
confirmMessage: string,
isBatch: boolean,
) {
const confirmed = await confirmAction(confirmMessage);
if (!confirmed) {
return;
}
if (isBatch) {
batchActionType.value = 'delete';
} else {
rowDeleteLoadingId.value = String(payload.id);
}
try {
const res = await api.post(url, payload);
if (res.errorCode !== 0) {
ElMessage.error(res.message || $t('sysDept.operationFailed'));
return;
}
ElMessage.success($t('sysDept.operationSuccess'));
await getTree();
} catch (error: any) {
ElMessage.error(getErrorMessage(error));
} finally {
if (isBatch) {
batchActionType.value = '';
} else {
rowDeleteLoadingId.value = '';
}
}
}
function remove(row: any) {
void submitDelete(
'/api/v1/sysDept/remove',
{ id: row.id },
$t('sysDept.deleteConfirm', { name: row.deptName }),
false,
);
}
function removeSelected() {
const ids = getSelectedIds();
if (ids.length === 0) {
ElMessage.warning($t('sysDept.selectRequired'));
return;
}
void submitDelete(
'/api/v1/sysDept/removeBatch',
{ ids },
$t('sysDept.batchDeleteConfirm', { count: ids.length }),
true,
);
}
async function getTree() {
loading.value = true;
api
.get('/api/v1/sysDept/list', {
try {
const res = await api.get('/api/v1/sysDept/list', {
params: {
asTree: true,
...formInline.value,
},
})
.then((res) => {
loading.value = false;
treeData.value = res.data;
});
treeData.value = res.data || [];
clearSelection();
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="flex h-full flex-col gap-1.5 p-6">
<SysDeptModal ref="saveDialog" @reload="reset" />
<div class="flex items-center justify-between">
<ElForm ref="formRef" :inline="true" :model="formInline">
<div class="flex flex-wrap items-start justify-between gap-3">
<ElForm ref="formRef" :inline="true" :model="formInline" class="min-w-0">
<ElFormItem prop="deptName" class="!mr-3">
<ElInput
class="search-input"
@@ -110,6 +261,14 @@ function getTree() {
:placeholder="$t('sysDept.deptName')"
/>
</ElFormItem>
<ElFormItem prop="status" class="!mr-3">
<DictSelect
class="status-select"
v-model="formInline.status"
dict-code="dataStatus"
:placeholder="$t('sysDept.status')"
/>
</ElFormItem>
<ElFormItem>
<ElButton @click="search(formRef)" type="primary">
{{ $t('button.query') }}
@@ -119,9 +278,42 @@ function getTree() {
</ElButton>
</ElFormItem>
</ElForm>
<div class="handle-div">
<div class="department-actions">
<div v-if="selectedCount > 0" class="department-batch-actions">
<ElButton
v-access:code="'/api/v1/sysDept/save'"
class="department-batch-action is-enable"
:disabled="batchActionLoading || rowActionLoading"
:loading="batchActionType === 'enable'"
text
@click="changeSelectedStatus(1)"
>
{{ $t('sysDept.batchEnable') }}
</ElButton>
<ElButton
v-access:code="'/api/v1/sysDept/save'"
class="department-batch-action is-disable"
:disabled="batchActionLoading || rowActionLoading"
:loading="batchActionType === 'disable'"
text
@click="changeSelectedStatus(0)"
>
{{ $t('sysDept.batchDisable') }}
</ElButton>
<ElButton
v-access:code="'/api/v1/sysDept/remove'"
class="department-batch-action is-danger"
:disabled="batchActionLoading || rowActionLoading"
:loading="batchActionType === 'delete'"
text
@click="removeSelected"
>
{{ $t('sysDept.batchDelete') }}
</ElButton>
</div>
<ElButton
v-access:code="'/api/v1/sysDept/save'"
class="department-add-action"
@click="showDialog({})"
type="primary"
>
@@ -134,7 +326,16 @@ function getTree() {
</div>
<div class="bg-background border-border flex-1 rounded-lg border p-5">
<ElTable :data="treeData" row-key="id" v-loading="loading" border>
<ElTable
ref="tableRef"
:data="treeData"
:tree-props="{ children: 'children', checkStrictly: true }"
row-key="id"
v-loading="loading"
border
@selection-change="handleSelectionChange"
>
<ElTableColumn type="selection" width="48" :selectable="isSelectable" />
<ElTableColumn prop="deptName" :label="$t('sysDept.deptName')">
<template #default="{ row }">
{{ row.deptName }}
@@ -150,6 +351,11 @@ function getTree() {
{{ row.sortNo }}
</template>
</ElTableColumn>
<ElTableColumn prop="status" :label="$t('sysDept.status')" width="120">
<template #default="{ row }">
{{ dictStore.getDictLabel('dataStatus', row.status) }}
</template>
</ElTableColumn>
<ElTableColumn prop="created" :label="$t('sysDept.created')">
<template #default="{ row }">
{{ row.created }}
@@ -160,26 +366,61 @@ function getTree() {
{{ row.remark }}
</template>
</ElTableColumn>
<ElTableColumn :label="$t('common.handle')" width="90" align="right">
<ElTableColumn
:label="$t('common.handle')"
width="224"
align="right"
fixed="right"
>
<template #default="{ row }">
<div class="flex items-center gap-3">
<ElButton link type="primary" @click="showDialog(row)">
<div class="department-row-actions">
<ElButton
v-access:code="'/api/v1/sysDept/save'"
:disabled="
batchActionLoading ||
rowDeleteLoadingId !== '' ||
rowStatusLoadingId !== '' ||
(isRootDept(row) && row.status === 1)
"
:loading="rowStatusLoadingId === String(row.id)"
link
type="primary"
@click="changeStatus(row)"
>
{{
row.status === 1
? $t('sysDept.disable')
: $t('sysDept.enable')
}}
</ElButton>
<ElButton
v-access:code="'/api/v1/sysDept/save'"
:disabled="
batchActionLoading ||
rowDeleteLoadingId !== '' ||
rowStatusLoadingId !== ''
"
link
type="primary"
@click="showDialog(row)"
>
{{ $t('button.edit') }}
</ElButton>
<ElDropdown>
<ElButton link :icon="MoreFilled" />
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem @click="remove(row)">
<ElButton link :icon="Delete" type="danger">
{{ $t('button.delete') }}
</ElButton>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
<ElButton
v-access:code="'/api/v1/sysDept/remove'"
:disabled="
batchActionLoading ||
rowStatusLoadingId !== '' ||
rowDeleteLoadingId !== '' ||
isRootDept(row)
"
:loading="rowDeleteLoadingId === String(row.id)"
link
type="danger"
@click="remove(row)"
>
{{ $t('button.delete') }}
</ElButton>
</div>
</template>
</ElTableColumn>
@@ -193,4 +434,121 @@ function getTree() {
width: 300px;
border-radius: 4px;
}
.status-select {
width: 160px;
}
.department-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: flex-end;
min-height: 32px;
}
.department-batch-actions {
box-sizing: border-box;
display: inline-flex;
gap: 2px;
align-items: center;
height: 32px;
padding: 0 3px;
background: hsl(var(--toolbar-bg));
border: 1px solid hsl(var(--toolbar-border));
border-radius: var(--radius-toolbar);
}
.department-actions :deep(.department-add-action) {
height: 32px;
}
.department-batch-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.department-batch-actions :deep(.department-batch-action) {
height: 30px;
min-height: 30px;
padding: 0 12px;
font-weight: 500;
background: transparent;
border-radius: var(--radius-control);
transition:
color var(--motion-duration-base) var(--motion-ease-standard),
background-color var(--motion-duration-base) var(--motion-ease-standard),
box-shadow var(--motion-duration-base) var(--motion-ease-standard);
}
.department-batch-actions :deep(.department-batch-action.is-enable) {
color: hsl(var(--primary));
}
.department-batch-actions :deep(.department-batch-action.is-disable) {
color: hsl(var(--text-strong));
}
.department-batch-actions :deep(.department-batch-action.is-danger) {
color: hsl(var(--destructive));
}
.department-batch-actions
:deep(.department-batch-action.is-enable:not(.is-disabled):hover),
.department-batch-actions
:deep(.department-batch-action.is-enable:not(.is-disabled):focus-visible) {
color: hsl(var(--primary));
background: hsl(var(--primary) / 8%);
}
.department-batch-actions
:deep(.department-batch-action.is-disable:not(.is-disabled):hover),
.department-batch-actions
:deep(.department-batch-action.is-disable:not(.is-disabled):focus-visible) {
color: hsl(var(--text-strong));
background: hsl(var(--surface-contrast-soft));
}
.department-batch-actions
:deep(.department-batch-action.is-danger:not(.is-disabled):hover),
.department-batch-actions
:deep(.department-batch-action.is-danger:not(.is-disabled):focus-visible) {
color: hsl(var(--destructive));
background: hsl(var(--destructive) / 8%);
}
.department-batch-actions
:deep(.department-batch-action:not(.is-disabled):focus-visible) {
outline: none;
box-shadow: 0 0 0 2px hsl(var(--primary) / 20%);
}
.department-row-actions {
display: flex;
gap: 16px;
align-items: center;
justify-content: flex-end;
white-space: nowrap;
}
@media (max-width: 960px) {
.department-actions {
width: 100%;
}
}
@media (max-width: 640px) {
.department-actions {
justify-content: stretch;
}
.department-batch-actions {
flex: 1;
}
.department-batch-actions :deep(.department-batch-action) {
flex: 1;
padding: 0 8px;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { FormInstance } from 'element-plus';
import { onMounted, ref } from 'vue';
import { ref } from 'vue';
import { EasyFlowFormModal } from '@easyflow/common-ui';
@@ -12,24 +12,15 @@ import DictSelect from '#/components/dict/DictSelect.vue';
import { $t } from '#/locales';
const emit = defineEmits(['reload']);
// vue
onMounted(() => {});
defineExpose({
openDialog,
});
const saveForm = ref<FormInstance>();
// variables
const ENABLED_STATUS = 1;
const dialogVisible = ref(false);
const isAdd = ref(true);
const entity = ref<any>({
parentId: '',
ancestors: '',
deptName: '',
deptCode: '',
sortNo: '',
status: '',
remark: '',
});
const entity = ref<any>(buildDefaultEntity());
const btnLoading = ref(false);
const rules = ref({
parentId: [
@@ -43,40 +34,52 @@ const rules = ref({
],
});
// functions
function openDialog(row: any) {
if (row.id) {
isAdd.value = false;
}
entity.value = row;
function buildDefaultEntity() {
return {
ancestors: '',
deptCode: '',
deptName: '',
parentId: '',
remark: '',
sortNo: '',
status: ENABLED_STATUS,
};
}
function openDialog(row: any = {}) {
isAdd.value = !row.id;
entity.value = {
...buildDefaultEntity(),
...row,
};
dialogVisible.value = true;
}
function save() {
saveForm.value?.validate((valid) => {
if (valid) {
btnLoading.value = true;
api
.post(
isAdd.value ? 'api/v1/sysDept/save' : 'api/v1/sysDept/update',
entity.value,
)
.then((res) => {
btnLoading.value = false;
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
})
.catch(() => {
btnLoading.value = false;
});
async function save() {
if (btnLoading.value) {
return;
}
btnLoading.value = true;
try {
const valid = await saveForm.value?.validate().catch(() => false);
if (!valid) {
return;
}
});
const res = await api.post(
isAdd.value ? 'api/v1/sysDept/save' : 'api/v1/sysDept/update',
entity.value,
);
if (res.errorCode === 0) {
ElMessage.success(res.message);
emit('reload');
closeDialog();
}
} finally {
btnLoading.value = false;
}
}
function closeDialog() {
saveForm.value?.resetFields();
isAdd.value = true;
entity.value = {};
entity.value = buildDefaultEntity();
dialogVisible.value = false;
}
</script>
@@ -120,6 +123,13 @@ function closeDialog() {
<ElFormItem prop="sortNo" :label="$t('sysDept.sortNo')">
<ElInput v-model.trim="entity.sortNo" />
</ElFormItem>
<ElFormItem prop="status" :label="$t('sysDept.status')">
<DictSelect
v-model="entity.status"
:clearable="false"
dict-code="dataStatus"
/>
</ElFormItem>
<ElFormItem prop="remark" :label="$t('sysDept.remark')">
<ElInput v-model.trim="entity.remark" />
</ElFormItem>