1270 lines
35 KiB
Vue
1270 lines
35 KiB
Vue
<script setup lang="ts">
|
|
import type { FormInstance } from 'element-plus';
|
|
|
|
import type { ApprovalAssigneeType as AssigneeType } from './approval-flow-restriction';
|
|
|
|
import { computed, nextTick, ref, watch } from 'vue';
|
|
|
|
import { EasyFlowFormModal } from '@easyflow/common-ui';
|
|
|
|
import {
|
|
ElButton,
|
|
ElDivider,
|
|
ElForm,
|
|
ElFormItem,
|
|
ElInput,
|
|
ElInputNumber,
|
|
ElMessage,
|
|
ElMessageBox,
|
|
ElOption,
|
|
ElSelect,
|
|
ElSwitch,
|
|
ElTreeSelect,
|
|
} from 'element-plus';
|
|
|
|
import { api } from '#/api/request';
|
|
import { $t } from '#/locales';
|
|
|
|
import {
|
|
applyApplicantDeptRestriction,
|
|
isApplicantDeptAssigneeTypeDisabled,
|
|
needsApplicantDeptRestrictionConfirmation,
|
|
normalizeApplicantDeptRestriction,
|
|
serializeApplicantDeptRestriction,
|
|
} from './approval-flow-restriction';
|
|
|
|
const emit = defineEmits<{
|
|
reload: [];
|
|
}>();
|
|
|
|
defineExpose({
|
|
openDialog,
|
|
});
|
|
|
|
type ResourceType = '' | 'AGENT' | 'BOT' | 'KNOWLEDGE' | 'WORKFLOW';
|
|
type ActionType = '' | 'DELETE' | 'OFFLINE' | 'PUBLISH';
|
|
type ScopeType = 'CATEGORY' | 'DEPT';
|
|
type FlowStatus = 'DISABLED' | 'ENABLED';
|
|
|
|
interface SelectOption {
|
|
id: number | string;
|
|
label: string;
|
|
}
|
|
|
|
interface ScopeItem {
|
|
id?: number | string;
|
|
includeChildren: boolean;
|
|
scopeType: ScopeType;
|
|
scopeValue?: number | string;
|
|
}
|
|
|
|
interface StepItem {
|
|
assigneeTargetIds: Array<number | string>;
|
|
assigneeTargets: AssigneeTarget[];
|
|
assigneeType: AssigneeType;
|
|
includeChildrenByTarget: Record<string, boolean>;
|
|
id?: number | string;
|
|
restrictToApplicantDept: boolean;
|
|
stepName: string;
|
|
}
|
|
|
|
interface AssigneeTarget {
|
|
includeChildren: boolean;
|
|
targetCode?: string;
|
|
targetId: number | string;
|
|
targetName?: string;
|
|
}
|
|
|
|
interface FlowFormModel {
|
|
actionType: ActionType;
|
|
id?: number | string;
|
|
name: string;
|
|
priority: number;
|
|
remark: string;
|
|
resourceType: ResourceType;
|
|
scopes: ScopeItem[];
|
|
status: FlowStatus;
|
|
steps: StepItem[];
|
|
}
|
|
|
|
const RESOURCE_OPTIONS = [
|
|
{ label: $t('approval.resource.agent'), value: 'AGENT' },
|
|
{ label: $t('approval.resource.workflow'), value: 'WORKFLOW' },
|
|
{ label: $t('approval.resource.knowledge'), value: 'KNOWLEDGE' },
|
|
];
|
|
|
|
const ACTION_OPTIONS = [
|
|
{ label: $t('approval.action.publish'), value: 'PUBLISH' },
|
|
{ label: $t('approval.action.offline'), value: 'OFFLINE' },
|
|
{ label: $t('approval.action.delete'), value: 'DELETE' },
|
|
];
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ label: $t('approval.status.enabled'), value: 'ENABLED' },
|
|
{ label: $t('approval.status.disabled'), value: 'DISABLED' },
|
|
];
|
|
|
|
const SCOPE_TYPE_OPTIONS = [
|
|
{ label: $t('approval.scope.category'), value: 'CATEGORY' },
|
|
{ label: $t('approval.scope.dept'), value: 'DEPT' },
|
|
];
|
|
|
|
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 roleLoaded = ref(false);
|
|
const accountLoading = ref(false);
|
|
const deptTreeOptions = ref<any[]>([]);
|
|
const roleOptions = ref<SelectOption[]>([]);
|
|
const accountOptions = ref<SelectOption[]>([]);
|
|
const categoryOptions = ref<Record<Exclude<ResourceType, ''>, SelectOption[]>>({
|
|
AGENT: [],
|
|
BOT: [],
|
|
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());
|
|
|
|
const rules = {
|
|
actionType: [
|
|
{ required: true, message: $t('message.required'), trigger: 'change' },
|
|
],
|
|
name: [{ required: true, message: $t('message.required'), trigger: 'blur' }],
|
|
priority: [
|
|
{ required: true, message: $t('message.required'), trigger: 'change' },
|
|
],
|
|
resourceType: [
|
|
{ required: true, message: $t('message.required'), trigger: 'change' },
|
|
],
|
|
status: [
|
|
{ required: true, message: $t('message.required'), trigger: 'change' },
|
|
],
|
|
};
|
|
|
|
watch(
|
|
() => formModel.value.resourceType,
|
|
(resourceType) => {
|
|
formModel.value.scopes = formModel.value.scopes.map((scope) => {
|
|
if (scope.scopeType === 'CATEGORY') {
|
|
return {
|
|
...scope,
|
|
scopeValue: undefined,
|
|
};
|
|
}
|
|
return scope;
|
|
});
|
|
if (resourceType) {
|
|
void ensureCategoryOptions();
|
|
}
|
|
},
|
|
);
|
|
|
|
function buildDefaultForm(): FlowFormModel {
|
|
return {
|
|
actionType: 'PUBLISH',
|
|
name: '',
|
|
priority: 100,
|
|
remark: '',
|
|
resourceType: 'AGENT',
|
|
scopes: [],
|
|
status: 'ENABLED',
|
|
steps: [buildDefaultStep()],
|
|
};
|
|
}
|
|
|
|
function buildDefaultStep(): StepItem {
|
|
return {
|
|
assigneeTargetIds: [],
|
|
assigneeTargets: [],
|
|
assigneeType: 'ROLE',
|
|
includeChildrenByTarget: {},
|
|
restrictToApplicantDept: false,
|
|
stepName: '',
|
|
};
|
|
}
|
|
|
|
async function openDialog(row: any = {}) {
|
|
isAdd.value = !row?.id;
|
|
formModel.value = buildDefaultForm();
|
|
dialogVisible.value = true;
|
|
await Promise.all([
|
|
ensureCategoryOptions(),
|
|
ensureDeptOptions(),
|
|
ensureRoleOptions(),
|
|
]);
|
|
if (!row?.id) {
|
|
await nextTick();
|
|
saveForm.value?.clearValidate();
|
|
return;
|
|
}
|
|
btnLoading.value = true;
|
|
try {
|
|
const res = await api.get('/api/v1/approvalFlow/detail', {
|
|
params: {
|
|
id: row.id,
|
|
},
|
|
});
|
|
if (res.errorCode !== 0) {
|
|
return;
|
|
}
|
|
formModel.value = {
|
|
actionType: res.data?.actionType || 'PUBLISH',
|
|
id: res.data?.id,
|
|
name: res.data?.name || '',
|
|
priority: Number(res.data?.priority || 100),
|
|
remark: res.data?.remark || '',
|
|
resourceType: res.data?.resourceType || 'AGENT',
|
|
scopes: (res.data?.scopes || []).map((item: any) => ({
|
|
id: item.id,
|
|
includeChildren: item.includeChildren === 1,
|
|
scopeType: item.scopeType,
|
|
scopeValue: item.scopeValue,
|
|
})),
|
|
status: res.data?.status || 'ENABLED',
|
|
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),
|
|
restrictToApplicantDept: normalizeApplicantDeptRestriction(
|
|
item.restrictToApplicantDept,
|
|
),
|
|
stepName: item.stepName,
|
|
};
|
|
}),
|
|
};
|
|
if (formModel.value.steps.length === 0) {
|
|
formModel.value.steps = [buildDefaultStep()];
|
|
}
|
|
for (const step of formModel.value.steps) {
|
|
registerAccountOption(step);
|
|
}
|
|
if (formModel.value.steps.some((item) => item.assigneeType === 'USER')) {
|
|
await searchAccountOptions('');
|
|
}
|
|
} finally {
|
|
btnLoading.value = false;
|
|
await nextTick();
|
|
saveForm.value?.clearValidate();
|
|
}
|
|
}
|
|
|
|
async function ensureCategoryOptions() {
|
|
if (categoryLoaded.value) {
|
|
return;
|
|
}
|
|
const [agentRes, workflowRes, knowledgeRes] = await Promise.all([
|
|
api.get('/api/v1/agentCategory/list', {
|
|
params: { sortKey: 'sortNo', sortType: 'asc' },
|
|
}),
|
|
api.get('/api/v1/workflowCategory/list', {
|
|
params: { sortKey: 'sortNo', sortType: 'asc' },
|
|
}),
|
|
api.get('/api/v1/documentCollectionCategory/list', {
|
|
params: { sortKey: 'sortNo', sortType: 'asc' },
|
|
}),
|
|
]);
|
|
categoryOptions.value = {
|
|
AGENT: normalizeCategoryOptions(agentRes.data, 'categoryName'),
|
|
BOT: [],
|
|
KNOWLEDGE: normalizeCategoryOptions(knowledgeRes.data, 'categoryName'),
|
|
WORKFLOW: normalizeCategoryOptions(workflowRes.data, 'categoryName'),
|
|
};
|
|
categoryLoaded.value = true;
|
|
}
|
|
|
|
async function ensureDeptOptions() {
|
|
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 : [];
|
|
}
|
|
|
|
async function ensureRoleOptions() {
|
|
if (roleLoaded.value) {
|
|
return;
|
|
}
|
|
const res = await api.get('/api/v1/approvalFlow/assigneeRoleOptions');
|
|
roleOptions.value = normalizeSelectOptions(res.data);
|
|
roleLoaded.value = true;
|
|
}
|
|
|
|
async function searchAccountOptions(keyword = '') {
|
|
accountLoading.value = true;
|
|
try {
|
|
const res = await api.get('/api/v1/approvalFlow/assigneeAccountPage', {
|
|
params: {
|
|
keyword,
|
|
pageNumber: 1,
|
|
pageSize: 20,
|
|
},
|
|
});
|
|
for (const item of normalizeSelectOptions(res?.data?.records)) {
|
|
if (
|
|
!accountOptions.value.some(
|
|
(option) => String(option.id) === String(item.id),
|
|
)
|
|
) {
|
|
accountOptions.value.push(item);
|
|
}
|
|
}
|
|
} finally {
|
|
accountLoading.value = false;
|
|
}
|
|
}
|
|
|
|
function normalizeCategoryOptions(data: any[] = [], labelKey: string) {
|
|
return (Array.isArray(data) ? data : []).map((item) => ({
|
|
id: item.id,
|
|
label: item[labelKey],
|
|
}));
|
|
}
|
|
|
|
function normalizeSelectOptions(data: any[] = []) {
|
|
return (Array.isArray(data) ? data : []).map((item) => ({
|
|
id: item.id,
|
|
label: item.name || item.label || item.code || String(item.id),
|
|
}));
|
|
}
|
|
|
|
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,
|
|
scopeType: 'CATEGORY',
|
|
scopeValue: undefined,
|
|
});
|
|
}
|
|
|
|
function removeScope(index: number) {
|
|
formModel.value.scopes.splice(index, 1);
|
|
}
|
|
|
|
function addStep() {
|
|
formModel.value.steps.push(buildDefaultStep());
|
|
}
|
|
|
|
function removeStep(index: number) {
|
|
if (formModel.value.steps.length <= 1) {
|
|
return;
|
|
}
|
|
formModel.value.steps.splice(index, 1);
|
|
}
|
|
|
|
function getCategoryScopeOptions() {
|
|
const resourceType = formModel.value.resourceType;
|
|
if (!resourceType) {
|
|
return [];
|
|
}
|
|
return categoryOptions.value[resourceType];
|
|
}
|
|
|
|
async function handleAssigneeTypeChange(
|
|
step: StepItem,
|
|
assigneeType: AssigneeType,
|
|
) {
|
|
if (
|
|
step.assigneeType === assigneeType ||
|
|
isApplicantDeptAssigneeTypeDisabled(
|
|
step.restrictToApplicantDept,
|
|
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('');
|
|
}
|
|
}
|
|
|
|
async function handleApplicantDeptRestrictionChange(
|
|
step: StepItem,
|
|
enabled: boolean | number | string,
|
|
) {
|
|
const restricted = normalizeApplicantDeptRestriction(enabled);
|
|
if (needsApplicantDeptRestrictionConfirmation(step, restricted)) {
|
|
try {
|
|
await ElMessageBox.confirm(
|
|
$t('approval.message.confirmApplicantDeptRestriction'),
|
|
$t('approval.fields.restrictToApplicantDept'),
|
|
{
|
|
cancelButtonText: $t('button.cancel'),
|
|
confirmButtonText: $t('button.confirm'),
|
|
type: 'warning',
|
|
},
|
|
);
|
|
} catch (error) {
|
|
if (error === 'cancel' || error === 'close') {
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
applyApplicantDeptRestriction(step, restricted);
|
|
}
|
|
|
|
function handleAssigneeTargetChange(step: StepItem) {
|
|
const selectedIds = new Set(step.assigneeTargetIds.map(String));
|
|
step.assigneeTargets = step.assigneeTargets.filter((target) =>
|
|
selectedIds.has(String(target.targetId)),
|
|
);
|
|
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 || step.assigneeType !== 'USER') {
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
if (btnLoading.value) {
|
|
return;
|
|
}
|
|
btnLoading.value = true;
|
|
try {
|
|
const valid = await saveForm.value?.validate().catch(() => false);
|
|
if (!valid) {
|
|
return;
|
|
}
|
|
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,
|
|
restrictToApplicantDept: serializeApplicantDeptRestriction(
|
|
step.restrictToApplicantDept,
|
|
),
|
|
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() {
|
|
formModel.value = buildDefaultForm();
|
|
dialogVisible.value = false;
|
|
saveForm.value?.resetFields();
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<EasyFlowFormModal
|
|
v-model:open="dialogVisible"
|
|
:title="
|
|
isAdd ? $t('approval.action.addFlow') : $t('approval.action.editFlow')
|
|
"
|
|
:before-close="closeDialog"
|
|
:confirm-loading="btnLoading"
|
|
:submitting="btnLoading"
|
|
:confirm-text="$t('button.save')"
|
|
width="xl"
|
|
@confirm="save"
|
|
>
|
|
<ElForm
|
|
ref="saveForm"
|
|
:model="formModel"
|
|
:rules="rules"
|
|
label-position="top"
|
|
class="easyflow-modal-form"
|
|
>
|
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<ElFormItem prop="name" :label="$t('approval.fields.flowName')">
|
|
<ElInput v-model.trim="formModel.name" maxlength="128" />
|
|
</ElFormItem>
|
|
<ElFormItem prop="priority" :label="$t('approval.fields.priority')">
|
|
<ElInputNumber
|
|
v-model="formModel.priority"
|
|
:min="0"
|
|
:step="10"
|
|
class="!w-full"
|
|
/>
|
|
</ElFormItem>
|
|
<ElFormItem
|
|
prop="resourceType"
|
|
:label="$t('approval.fields.resourceType')"
|
|
>
|
|
<ElSelect v-model="formModel.resourceType">
|
|
<ElOption
|
|
v-for="item in RESOURCE_OPTIONS"
|
|
:key="item.value"
|
|
:label="item.label"
|
|
:value="item.value"
|
|
/>
|
|
</ElSelect>
|
|
</ElFormItem>
|
|
<ElFormItem prop="actionType" :label="$t('approval.fields.actionType')">
|
|
<ElSelect v-model="formModel.actionType">
|
|
<ElOption
|
|
v-for="item in ACTION_OPTIONS"
|
|
:key="item.value"
|
|
:label="item.label"
|
|
:value="item.value"
|
|
/>
|
|
</ElSelect>
|
|
</ElFormItem>
|
|
<ElFormItem prop="status" :label="$t('approval.fields.status')">
|
|
<ElSelect v-model="formModel.status">
|
|
<ElOption
|
|
v-for="item in STATUS_OPTIONS"
|
|
:key="item.value"
|
|
:label="item.label"
|
|
:value="item.value"
|
|
/>
|
|
</ElSelect>
|
|
</ElFormItem>
|
|
<ElFormItem prop="remark" :label="$t('approval.fields.remark')">
|
|
<ElInput v-model.trim="formModel.remark" maxlength="500" />
|
|
</ElFormItem>
|
|
</div>
|
|
|
|
<ElDivider>{{ $t('approval.section.scope') }}</ElDivider>
|
|
<div class="approval-flow-modal__section">
|
|
<div class="approval-flow-modal__section-meta">
|
|
<p>{{ $t('approval.helper.scope') }}</p>
|
|
<ElButton text type="primary" class="w-fit" @click="addScope">
|
|
{{ $t('approval.action.addScope') }}
|
|
</ElButton>
|
|
</div>
|
|
|
|
<div class="approval-flow-modal__scope-list">
|
|
<div
|
|
v-if="formModel.scopes.length === 0"
|
|
class="approval-flow-modal__scope-empty"
|
|
>
|
|
{{ $t('approval.helper.scopeEmpty') }}
|
|
</div>
|
|
|
|
<div
|
|
v-for="(scope, index) in formModel.scopes"
|
|
:key="`scope-${index}`"
|
|
class="approval-flow-modal__scope-row"
|
|
>
|
|
<ElSelect
|
|
v-model="scope.scopeType"
|
|
class="approval-flow-modal__scope-control approval-flow-modal__scope-type"
|
|
>
|
|
<ElOption
|
|
v-for="item in SCOPE_TYPE_OPTIONS"
|
|
:key="item.value"
|
|
:label="item.label"
|
|
:value="item.value"
|
|
/>
|
|
</ElSelect>
|
|
|
|
<ElSelect
|
|
v-if="scope.scopeType === 'CATEGORY'"
|
|
v-model="scope.scopeValue"
|
|
class="approval-flow-modal__scope-control"
|
|
filterable
|
|
clearable
|
|
:placeholder="$t('approval.placeholder.scopeValue')"
|
|
>
|
|
<ElOption
|
|
v-for="item in getCategoryScopeOptions()"
|
|
:key="item.id"
|
|
:label="item.label"
|
|
:value="item.id"
|
|
/>
|
|
</ElSelect>
|
|
|
|
<ElTreeSelect
|
|
v-else
|
|
v-model="scope.scopeValue"
|
|
class="approval-flow-modal__scope-control"
|
|
clearable
|
|
check-strictly
|
|
:data="deptTreeOptions"
|
|
:props="{
|
|
label: 'deptName',
|
|
children: 'children',
|
|
value: 'id',
|
|
}"
|
|
:placeholder="$t('approval.placeholder.scopeValue')"
|
|
/>
|
|
|
|
<div class="approval-flow-modal__scope-switch">
|
|
<span>{{ $t('approval.fields.includeChildren') }}</span>
|
|
<ElSwitch v-model="scope.includeChildren" />
|
|
</div>
|
|
|
|
<ElButton text type="danger" @click="removeScope(index)">
|
|
{{ $t('button.delete') }}
|
|
</ElButton>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ElDivider>{{ $t('approval.section.steps') }}</ElDivider>
|
|
<div class="flex flex-col gap-3">
|
|
<div
|
|
v-for="(step, index) in formModel.steps"
|
|
:key="`step-${index}`"
|
|
class="approval-flow-modal__step-card"
|
|
>
|
|
<div class="approval-flow-modal__step-header">
|
|
<span class="approval-flow-modal__step-index">
|
|
{{ $t('approval.fields.stepNo', { value: index + 1 }) }}
|
|
</span>
|
|
<div class="approval-flow-modal__step-actions">
|
|
<label class="approval-flow-modal__restriction-switch">
|
|
<span>{{ $t('approval.fields.restrictToApplicantDept') }}</span>
|
|
<ElSwitch
|
|
:model-value="step.restrictToApplicantDept"
|
|
@change="
|
|
(value) => handleApplicantDeptRestrictionChange(step, value)
|
|
"
|
|
/>
|
|
</label>
|
|
<ElButton
|
|
text
|
|
type="danger"
|
|
:disabled="formModel.steps.length <= 1"
|
|
@click="removeStep(index)"
|
|
>
|
|
{{ $t('button.delete') }}
|
|
</ElButton>
|
|
</div>
|
|
</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,
|
|
'is-disabled': isApplicantDeptAssigneeTypeDisabled(
|
|
step.restrictToApplicantDept,
|
|
item.value,
|
|
),
|
|
}"
|
|
:disabled="
|
|
isApplicantDeptAssigneeTypeDisabled(
|
|
step.restrictToApplicantDept,
|
|
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>
|
|
</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>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.approval-flow-modal__section {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
|
|
.approval-flow-modal__section-meta {
|
|
display: flex;
|
|
gap: 16px;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
|
|
.approval-flow-modal__section-meta p {
|
|
margin: 0;
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
color: hsl(var(--text-muted));
|
|
}
|
|
|
|
.approval-flow-modal__scope-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0;
|
|
}
|
|
|
|
.approval-flow-modal__scope-empty {
|
|
padding: 4px 0 2px;
|
|
font-size: 13px;
|
|
color: hsl(var(--text-muted));
|
|
}
|
|
|
|
.approval-flow-modal__scope-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 12px;
|
|
padding: 10px 0;
|
|
}
|
|
|
|
.approval-flow-modal__scope-row + .approval-flow-modal__scope-row {
|
|
border-top: 1px solid hsl(var(--divider-faint) / 72%);
|
|
}
|
|
|
|
.approval-flow-modal__scope-control {
|
|
width: 100%;
|
|
}
|
|
|
|
.approval-flow-modal__scope-switch {
|
|
display: flex;
|
|
gap: 10px;
|
|
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-actions {
|
|
display: inline-flex;
|
|
gap: var(--space-3);
|
|
align-items: center;
|
|
}
|
|
|
|
.approval-flow-modal__restriction-switch {
|
|
display: inline-flex;
|
|
gap: var(--space-2);
|
|
align-items: center;
|
|
font-size: 13px;
|
|
color: hsl(var(--text-muted));
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.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: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
width: 100%;
|
|
min-height: 40px;
|
|
padding: 4px;
|
|
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 {
|
|
flex: 1;
|
|
min-width: 0;
|
|
height: 32px;
|
|
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;
|
|
}
|
|
|
|
.approval-flow-modal__assignee-type-item:hover {
|
|
color: hsl(var(--foreground));
|
|
}
|
|
|
|
.approval-flow-modal__assignee-type-item:disabled {
|
|
color: hsl(var(--text-muted) / 48%);
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.approval-flow-modal__assignee-type-item:disabled:hover {
|
|
color: hsl(var(--text-muted) / 48%);
|
|
}
|
|
|
|
.approval-flow-modal__assignee-type-item:focus-visible {
|
|
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) / 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) {
|
|
.approval-flow-modal__scope-row {
|
|
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>
|