feat: 合同文档模板设置

This commit is contained in:
Zzz1zz
2025-10-12 15:08:29 +08:00
parent fc07ed06d5
commit 8e43e1835f
38 changed files with 4560 additions and 1410 deletions
@@ -0,0 +1,189 @@
<template>
<div class="system-setting-contract-file">
<Breadcrumb
:items="['menu.setting', 'menu.setting.system', 'setting.contract']"
/>
<a-card class="full_height">
<a-row :gutter="24">
<a-col
v-if="contractFileInfo && Object.keys(contractFileInfo).length"
:span="7"
>
<icon-arrow-left /><span class="ml-[10px]">合同文档模板设置</span>
<div
class="mt-[24px] rounded-[6px] border-solid border-[#efefef] border-[1px] px-[12px] py-[20px]"
>
<h4 class="text-[16px] font-bold">{{ contractFileInfo?.name }}</h4>
<p class="text-[14px] mt-[12px]"
><span class="mr-[5px] text-[#101010]">适用范围</span
>立思广州分公司</p
>
<p class="text-[14px] mt-[4px]"
><span class="mr-[5px] text-[#101010]">适用项目</span
>星河商务中心</p
>
</div>
<div class="flex items-center mt-[24px]">
<a-input-search class="w-[256px] h-[30px]" placeholder="搜索名称" />
<div
class="flex items-center border-[1px] border-solid border-[#165DFF] px-[4px] py-[6px] ml-[10px]"
>
<icon-google />
<p class="ml-[5px] text-[#165DFF]">AI助手</p>
</div>
</div>
<a-tabs default-active-key="1" class="mt-[10px]">
<a-tab-pane key="1" title="租赁条款"> 租赁条款 </a-tab-pane>
<a-tab-pane key="2" title="物业条款"> w物业条款 </a-tab-pane>
</a-tabs>
</a-col>
<a-col :span="17">
<div class="flex items-center justify-between">
<div class="flex items-center">
<a-button>
<template #icon>
<icon-upload />
</template>
上传文档
</a-button>
<a-button class="ml-[10px]">
<template #icon>
<icon-cloud-download />
</template>
下载文档
</a-button>
</div>
<div class="flex items-center">
<p>2025-09-10 00:25:30更新</p>
<a-button type="primary" class="ml-[10px]">
<template #icon>
<icon-storage />
</template>
保存
</a-button>
</div>
</div>
<!-- OnlyOffice 文档编辑器 -->
<div class="onlyoffice-wrapper">
<!-- <OnlyOfficeEditor v-if="contractFileInfo?.url" :document-url="contractFileInfo.url"
:document-title="contractFileInfo.name || '合同文档'"
:document-type="getDocumentType(contractFileInfo.url)" :height="'600px'" :editable="true"
:mode="'edit'" @ready="onEditorReady" @error="onEditorError" @save="onDocumentSave"
@close="onEditorClose" />
<div v-else class="no-document">
<a-empty description="暂无文档内容" />
</div> -->
</div>
</a-col>
</a-row>
</a-card>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { Message } from '@arco-design/web-vue';
import { getWordSettingFromId } from '@/api/setting';
import { CreateFileSettingParam } from '@/types/setting';
const contractFileInfo = ref<CreateFileSettingParam | null>(null);
const route = useRoute();
// 获取文档数据
const getWordSettingFromIdData = async () => {
const id: any = route?.query?.id;
const { code, data } = await getWordSettingFromId(id);
if (code === 200 && data) {
const { contractWordSetting } = data;
contractFileInfo.value = contractWordSetting || null;
contractFileInfo.value.url =
'http://oss.kola.work/documents%2F20250910%2F%E5%90%88%E5%90%8C%E6%A8%A1%E7%89%88.docx?Expires=1757479342&OSSAccessKeyId=LTAI5tDDNxXnZq7QcpZEf6hR&Signature=ydBzbF2yCyRWZ0G52s%2FPr%2BFIUzo%3D';
}
};
// 根据文件 URL 获取文档类型
const getDocumentType = (url: string): 'word' | 'excel' | 'powerpoint' => {
if (!url) return 'word';
const extension = url.toLowerCase().split('.').pop();
switch (extension) {
case 'xlsx':
case 'xls':
return 'excel';
case 'pptx':
case 'ppt':
return 'powerpoint';
case 'docx':
case 'doc':
default:
return 'word';
}
};
// OnlyOffice 编辑器事件处理
const onEditorReady = () => {
console.log('OnlyOffice 编辑器已准备就绪');
Message.success('文档编辑器加载完成');
};
const onEditorError = (error: string) => {
console.error('OnlyOffice 编辑器错误:', error);
Message.error(`编辑器错误: ${error}`);
};
const onDocumentSave = (data: any) => {
console.log('文档保存事件:', data);
Message.success('文档已保存');
// 这里可以添加保存后的处理逻辑,比如更新服务器数据
};
const onEditorClose = () => {
console.log('编辑器关闭');
Message.info('编辑器已关闭');
};
onMounted(() => {
getWordSettingFromIdData();
});
</script>
<style scoped>
.onlyoffice-wrapper {
margin-top: 16px;
border: 1px solid #e5e6eb;
border-radius: 6px;
overflow: hidden;
background: #fff;
}
.no-document {
padding: 40px;
text-align: center;
background: #f7f8fa;
}
.system-setting-contract-file {
height: 100vh;
overflow: hidden;
}
.full_height {
height: calc(100vh - 120px);
overflow: hidden;
}
:deep(.arco-card-body) {
height: 100%;
padding: 20px;
overflow: hidden;
}
:deep(.arco-row) {
height: 100%;
}
:deep(.arco-col) {
height: 100%;
overflow: hidden;
}
</style>
@@ -61,7 +61,6 @@
import { Message } from '@arco-design/web-vue';
import useDataTable from '@/composables/useDataTable';
import {
getWordSettingFromId,
delWordSetting,
getWordSettingList,
disableWordSetting,
@@ -72,7 +71,7 @@
import { camelToSnake } from '@/utils/caseConvert';
// import contractNumberFormDrawer from './drawer/contract-number-form-drawer.vue';
import contractFileForm from './modal/contract-file-form.vue';
import { useRouter } from 'vue-router';
const {
data: renderData,
columns: projectColumn,
@@ -91,7 +90,7 @@
initialPageSize: 10,
pageSizeOptions: [10, 20, 50, 100],
});
const router = useRouter();
const contractWordList = ref<ContractWordItem[]>([]);
const departmentListData = ref<DepartmentItem[]>([]);
const showModal = ref<boolean>(false);
@@ -200,26 +199,16 @@
// 处理错误
}
};
const getWordSettingFromIdData = async (id: number) => {
const { code, data } = await getWordSettingFromId(id);
if (code === 200 && data) {
const { contractCodeSetting } = data;
currentNumberInfo.value = contractCodeSetting || null;
currentNumberInfo.value.arrange = camelToSnake(
currentNumberInfo.value.arrange
);
}
};
const handleAddContractNumber = () => {
// currentNumberInfo.value = null;
isEdit.value = false;
showModal.value = true;
};
const handleEditContractNumber = async (id: number) => {
await getWordSettingFromIdData(id);
isEdit.value = true;
// currentNumberInfo.value = info
showModal.value = true;
router.push({
name: 'system-setting-contract-file-edit',
query: { id },
});
};
// 初始化数据
onMounted(() => {
@@ -0,0 +1,709 @@
<template>
<div
v-if="
currentContractTemplateInfo &&
Object.keys(currentContractTemplateInfo).length
"
class="system-setting-contract-file pr-[20px]"
>
<Breadcrumb
:items="['menu.setting', 'menu.setting.system', 'setting.contract']"
/>
<div class="flex items-center justify-between cursor-pointer mb-[20px]">
<div @click="router.go(-1)">
<icon-arrow-left /> <span class="ml-[10px]">合同文档模板设置</span>
</div>
<a-button type="primary" @click="handleSubmit">保存</a-button>
</div>
<a-row :gutter="24">
<a-col :span="7">
<a-card class="full_height rounded-[5px]">
<div class="pb-[10px] border-b-[1px] border-b-solid border-[#f0f0f0]">
<div class="flex items-center justify-between">
<p class="text-[#000]">{{ currentContractTemplateInfo.name }}</p>
<p class="cursor-pointer" @click="handleBaseInfoEdit"
><icon-edit
/></p>
</div>
<a-tag class="mt-[5px]" color="green">{{
currentContractTemplateInfo.contractType
}}</a-tag>
<p class="text-[#000] mt-[5px]">
<span class="text-[#999999] mr-[5px]">适用范围</span>
{{ currentContractTemplateInfo.departmentName }}
</p>
</div>
<div class="py-[10px]">
<h3 class="text-[16px] text-[#505968]">基本配置</h3>
<p
:class="[
'cursor-pointer mt-[8px] px-[10px] h-[48px] leading-[48px] text-left text-[14px]',
'rounded-[2px] border-[1px] border-solid border-[#f8f8f8]',
{ isActive: currentId === 0 },
]"
@click="handleChoose(0, '基本信息')"
>
基本信息</p
>
<div class="flex items-center justify-between mt-[8px]">
<h3 class="text-[16px] text-[#505968]">条款配置</h3>
<p class="cursor-pointer">
<a-popover trigger="click" position="br">
<icon-plus />
<template #content>
<ul>
<li
v-for="item in allTermsList"
:key="item.id"
class="cursor-pointer px-[10px] py-[5px] border-b-[1px] border-b-solid border-[#f0f0f0]"
@click="handleAddTerms(item.id)"
>
{{ item.name }}</li
>
</ul>
</template>
</a-popover>
</p>
</div>
<div
v-for="item in currentContractTemplateInfo.terms"
:key="item.id"
:class="[
'cursor-pointer mt-[8px] px-[10px] h-[48px] leading-[48px] text-left text-[14px]',
'rounded-[2px] border-[1px] border-solid border-[#f8f8f8]',
{ isActive: currentId === item.id },
]"
@click="handleChoose(item.id, item.name)"
>
{{ item.name }}
</div>
</div>
<contractTemplateForm
v-model:visible="showModal"
:is-edit="true"
:department-list-data="departmentListData"
:record="currentContractTemplateInfo"
@submitted="getTemplateSettingFromIdData"
/>
</a-card>
</a-col>
<a-col :span="17">
<a-card class="full_height rounded-[5px]">
<div class="flex items-center justify-between mb-[10px]">
<p class="flex items-center">{{ title }}</p>
<a-button
v-if="currentId > 0"
type="text"
status="danger"
@click="handleDeleteTerm"
>
<template #icon>
<icon-delete />
</template>
<template #default>删除条款</template>
</a-button>
</div>
<!-- 基本信息内容 -->
<div v-if="currentId === 0" class="mt-[5px]">
<a-form
ref="formRef"
:model="baseInfoForm"
:rules="rules"
auto-label-width
layout="vertical"
>
<a-row :gutter="24">
<a-col :span="12">
<!-- 合同编号模板 -->
<a-form-item
label="合同编号模板"
field="contractCodeSettingId"
required
>
<a-select
v-model="baseInfoForm.contractCodeSettingId"
placeholder="请选择合同编号模板"
@change="chooseCodeSettingId"
>
<a-option
v-for="item in codeSettingList"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
<div
v-if="currentContractTemplateInfo.contractCode"
class="p-[10px] bg-[#eaf3fe]"
>
合同编号预览:{{
currentContractTemplateInfo.contractCode
}}</div
>
</a-col>
<a-col :span="12">
<!-- 合同类型 -->
<a-form-item label="合同类型" field="type" required>
<ul class="options">
<li
v-for="item in contractTaxMap"
:key="item.type"
:class="[
'subItem',
{ isActive: item.type === baseInfoForm.type },
]"
@click="chooseTaxType(item.type)"
>
{{ item.text }}</li
>
</ul>
</a-form-item>
</a-col>
</a-row>
<p class="my-[20px] font-bold text-[15px] text-[#101010]"
>其他信息(选填)</p
>
<a-form-item label="合同标签" field="tags">
<a-select
v-model="baseInfoForm.tags"
multiple
placeholder="请选择合同标签"
>
<a-option
v-for="item in contractTagsList"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</a-option>
</a-select>
</a-form-item>
<a-form-item label="备注" field="remark">
<a-textarea
v-model="baseInfoForm.remark"
placeholder="输入合同备注"
allow-clear
/>
</a-form-item>
</a-form>
</div>
<template v-else>
<a-tag
v-for="item of termTabList"
:key="item.key"
:style="{ color: item.isChoose === 1 ? '#165DFF' : '' }"
:color="item.isChoose === 1 ? '#eaf3fe' : ''"
:class="[
'mr-[10px] mb-[10px] cursor-pointer text-[#1D2129]',
{ 'text-[#165DFF]': item.isChoose == 1 },
]"
@click="handleToggleTerm(item)"
>
{{ item.title }}
<icon-close v-if="item.isChoose === 1" />
</a-tag>
<a-tag class="cursor-pointer mb-[10px]" @click="handleAddTerm">
<icon-plus />
优惠条款
</a-tag>
<div class="mt-[20px]">
<bond
v-if="isBondShown"
ref="bondRef"
:title="'保证金条款(租金保证金)'"
/>
<incremental
v-if="isIncrementalShown"
ref="incrementalRef"
:title="'递增条款'"
/>
<discount
v-for="(d, di) in discountChosen"
:key="d.key"
:ref="(el) => setDiscountRef(el, di)"
:title="d.title"
/>
<lateFee
v-if="isLateFeeShown"
ref="lateFeeRef"
:title="'滞纳金条款'"
/>
<rentSetting
ref="rentSettingRef"
:term-setting-info="termSettingInfo"
/>
</div>
</template>
</a-card>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, reactive, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
getTemplateSettingFromId,
getTermsSettingFromId,
getDepartmentList,
getAllCodeSettingList,
getContractTagsList,
updateTemplateSetting,
getAllTermsSettingList,
} from '@/api/setting';
import contractTemplateForm from './modal/contract-template-form.vue';
import { DepartmentItem } from '@/types/setting';
import type { FormInstance } from '@arco-design/web-vue';
import { Message } from '@arco-design/web-vue';
import bond from './terms/bond.vue';
import incremental from './terms/incremental.vue';
import discount from './terms/discount.vue';
import lateFee from './terms/late-fee.vue';
import rentSetting from './terms/rent-setting.vue';
const currentContractTemplateInfo = ref<any>(null);
const route = useRoute();
const router = useRouter();
const showModal = ref<boolean>(false);
const title = ref<string>('基本信息');
const currentId = ref<number>(0);
const departmentListData = ref<DepartmentItem[]>([]);
const contractTaxMap = [
// 1是含税2不含税
{ type: 1, text: '含税合同' },
{ type: 2, text: '不含税合同' },
];
// 表单数据
const formRef = ref<FormInstance>();
const defaultForm: any = {
id: null,
contractCodeSettingId: null,
saveType: 2, // 保存类别:1 名称与适用范围数据,2 基础信息数据,3 条款数据)
tags: [],
remark: '',
type: 1,
};
const codeSettingList = ref<any>([]);
const baseInfoForm = reactive<any>({ ...defaultForm });
const allTermsList = ref<any>([]);
// refs for terms components
const rentSettingRef = ref();
const bondRef = ref();
const incrementalRef = ref();
const lateFeeRef = ref();
const discountRefs = ref<any[]>([]);
// 校验规则
const rules = reactive({
contractCodeSettingId: [
{ required: true, message: '合同编号模板不能为空' },
],
type: [{ required: true, message: '请选择合同类型' }],
});
const termSettingInfo = ref<any>({});
const termTabList = ref([
{
key: 'bond',
title: '保证金条款',
isChoose: 1,
type: 'bond',
},
{
key: 'incremental',
title: '递增条款',
isChoose: 1,
type: 'incremental',
},
{
key: 'discount-0',
title: '单价折扣',
isChoose: 1,
type: 'unitDiscount',
},
{
key: 'discount',
title: '优惠条款',
isChoose: 1,
type: 'discount',
},
{
key: 'lateFee',
title: '滞纳金条款',
isChoose: 1,
type: 'lateFee',
},
]);
const contractTagsList = ref<any>([]);
const getTemplateSettingFromIdData = async () => {
const id: any = route?.query?.id;
const { code, data } = await getTemplateSettingFromId(id);
if (code === 200 && data) {
const { contractTemplateSetting } = data;
currentContractTemplateInfo.value = contractTemplateSetting || null;
Object.keys(baseInfoForm).forEach((key) => {
// const value = baseInfoForm[key];
if (key === 'tags') {
baseInfoForm.tags = contractTemplateSetting.tags
.map((i) => i.id)
.filter(Boolean);
} else {
baseInfoForm[key] = contractTemplateSetting[key];
}
});
}
};
// 部门列表
const getDepartmentListData = async () => {
try {
const { code, data } = await getDepartmentList();
if (code === 200) {
departmentListData.value = data.departmentList;
}
} catch (error) {
// console.error('获取部门列表失败:', error);
}
};
// 由 termTabList 计算可见的优惠条款列表
const discountChosen = computed<any[]>(() =>
termTabList.value.filter(
(i: any) => i.type === 'discount' && i.isChoose === 1
)
);
// 组件显隐由接口开关 + 选择状态共同决定
let toNum = (v: any) => Number(v || 0);
const isBondShown = computed(
() =>
toNum(termSettingInfo.value?.isBond) === 1 &&
termTabList.value.some((i: any) => i.type === 'bond' && i.isChoose === 1)
);
const isIncrementalShown = computed(
() =>
toNum(termSettingInfo.value?.isIncremental) === 1 &&
termTabList.value.some(
(i: any) => i.type === 'incremental' && i.isChoose === 1
)
);
const isLateFeeShown = computed(
() =>
toNum(termSettingInfo.value?.isLateFee) === 1 &&
termTabList.value.some(
(i: any) => i.type === 'lateFee' && i.isChoose === 1
)
);
async function getTermsSettingFromIdData(id) {
const { code, data } = await getTermsSettingFromId(id);
if (code === 200) {
termSettingInfo.value = data?.contractTermsSetting ?? {};
// 根据后端开关更新标签显示状态(例如:isBond === 2 时,保证金标签置为未选中)
const next = (termTabList.value || []).map((t) => ({ ...t }));
const bondIdx = next.findIndex((t) => t.type === 'bond');
if (bondIdx > -1) {
next[bondIdx].isChoose =
toNum(termSettingInfo.value?.isBond) === 1 ? 1 : 2;
}
// 保持其他标签不变;如需扩展递增、滞纳金可在此追加
termTabList.value = next;
}
}
const handleChoose = (id: number, str: string) => {
if (id === currentId.value) return;
currentId.value = id;
title.value = str;
getTermsSettingFromIdData(id);
};
const handleBaseInfoEdit = () => {
showModal.value = true;
};
const getAllCodeSettingListData = async () => {
const { code, data } = await getAllCodeSettingList();
if (code === 200) {
codeSettingList.value = data?.contractCodeSetting || [];
}
};
const chooseCodeSettingId = (id) => {
const info = codeSettingList.value.find((i) => i.id === id);
if (info) {
currentContractTemplateInfo.value.contractCode = info?.code;
}
};
const chooseTaxType = (type) => {
baseInfoForm.type = type;
};
const getContractTagsListData = async () => {
const { code, data } = await getContractTagsList();
if (code === 200 && data) {
contractTagsList.value = data?.contractTagsSettingList;
}
};
const submitBaseInfo = async () => {
await formRef.value?.validate();
baseInfoForm.id = Number(route?.query?.id);
const { code, message } = await updateTemplateSetting(baseInfoForm);
if (code === 200) {
Message.success(message);
}
};
const handleSubmit = async () => {
if (currentId.value === 0) {
await submitBaseInfo();
} else {
// 先统一执行可见子表单校验;任一失败则中断
let hasValidationError = false;
if ((rentSettingRef as any)?.value?.validate) {
try {
await (rentSettingRef as any).value.validate();
} catch (e) {
hasValidationError = true;
}
}
if (isBondShown.value && (bondRef as any)?.value?.validate) {
try {
await (bondRef as any).value.validate();
} catch (e) {
hasValidationError = true;
}
}
if (
isIncrementalShown.value &&
(incrementalRef as any)?.value?.validate
) {
try {
await (incrementalRef as any).value.validate();
} catch (e) {
hasValidationError = true;
}
}
if (isLateFeeShown.value && (lateFeeRef as any)?.value?.validate) {
try {
await (lateFeeRef as any).value.validate();
} catch (e) {
hasValidationError = true;
}
}
const validationPromises = (discountRefs.value || [])
.filter((r) => r?.validate)
.map((r) =>
r.validate().catch(() => {
hasValidationError = true;
})
);
await Promise.all(validationPromises);
if (hasValidationError) return;
// 校验通过后再收集数据
const rentSettingData = (rentSettingRef as any)?.value?.getData?.() || {};
const bondData = (bondRef as any)?.value?.getData?.() || {};
const incrementalData = (incrementalRef as any)?.value?.getData?.() || {};
const discountData = (discountRefs.value || [])
.map((r: any) => r?.getData?.() || {})
.filter((x: any) => x && Object.keys(x).length);
const lateFeeData = (lateFeeRef as any)?.value?.getData?.() || {};
// 非空校验(仅校验当前显示/选中的条款)
const isEmptyValue = (v: any) =>
v === undefined ||
v === null ||
(typeof v === 'string' && v.trim() === '');
const validateObject = (obj: any, label: string): string[] => {
if (!obj || typeof obj !== 'object') return [`${label}不能为空`];
if (Object.keys(obj).length === 0) return [`${label}不能为空`];
const errs: string[] = [];
Object.keys(obj).forEach((k) => {
const val = obj[k];
// 数值 0 视为有效
if (typeof val === 'number') return;
if (Array.isArray(val)) {
if (val.length === 0) errs.push(`${label}存在未填写项`);
} else if (isEmptyValue(val)) {
errs.push(`${label}存在未填写项`);
}
});
return errs;
};
const errors: string[] = [];
// 租金/租期条款固定显示,必校验
errors.push(...validateObject(rentSettingData, '租期条款'));
// 保证金
if (isBondShown.value)
errors.push(...validateObject(bondData, '保证金条款'));
// 递增
if (isIncrementalShown.value)
errors.push(...validateObject(incrementalData, '递增条款'));
// 优惠(多个)
if (discountChosen.value.length > 0) {
if (discountData.length === 0) {
errors.push('优惠条款存在未填写项');
} else {
discountData.forEach((d: any, idx: number) => {
errors.push(...validateObject(d, `优惠条款${idx + 1}`));
});
}
}
// 滞纳金
if (isLateFeeShown.value)
errors.push(...validateObject(lateFeeData, '滞纳金条款'));
if (errors.length > 0) {
Message.error(errors[0]);
return;
}
const currentTerm =
(currentContractTemplateInfo.value?.terms || []).find(
(t: any) => t.id === currentId.value
) || {};
const payload: any = {
saveType: 3,
id: Number(route?.query?.id),
name: currentContractTemplateInfo.value?.name,
departmentId: currentContractTemplateInfo.value?.departmentId,
type: baseInfoForm.type,
contractCodeSettingId: baseInfoForm.contractCodeSettingId,
tags: baseInfoForm.tags,
remark: baseInfoForm.remark,
term: {
id: currentTerm?.id,
type: 1,
contractTermsSettingId: currentTerm?.id,
leaseTerm: rentSettingData,
bondTerm: bondData,
incrementalTerm: incrementalData,
discountTerm: discountData,
lateFeeTerm: lateFeeData,
remarkTerm: { remark: '' },
},
};
updateTemplateSetting(payload).then(({ code, message }) => {
if (code === 200) {
Message.success(message);
}
});
}
};
const getAllTermsSettingListData = async () => {
const { code, data } = await getAllTermsSettingList();
if (code === 200) {
allTermsList.value = data?.contractTermsSetting || [];
}
};
function handleAddTerms(id: number) {
const allList = allTermsList.value || [];
const found = allList.find((t: any) => t.id === id);
if (!found) return;
const list = currentContractTemplateInfo.value?.terms || [];
const exists = list.some((t: any) => t.id === id);
if (!exists) {
// 仅插入必要字段,保持与现有结构一致
list.push({ id: found.id, name: found.name });
currentContractTemplateInfo.value.terms = [...list];
}
// 切换到新添加/已存在的条款并拉取其配置
currentId.value = id;
title.value = found.name;
getTermsSettingFromIdData(id);
}
function handleDeleteTerm() {
const id = currentId.value;
if (!id) return;
const terms = currentContractTemplateInfo.value?.terms || [];
const nextTerms = terms.filter((t: any) => t.id !== id);
currentContractTemplateInfo.value.terms = nextTerms;
// 选择下一个可编辑的目标:优先剩余的第一项,否则回到基本信息
if (nextTerms.length > 0) {
currentId.value = nextTerms[0].id;
title.value = nextTerms[0].name;
getTermsSettingFromIdData(nextTerms[0].id);
} else {
currentId.value = 0;
title.value = '基本信息';
}
}
function handleToggleTerm(info: any) {
if (info?.type === 'discount') {
const discounts = termTabList.value.filter(
(i: any) => i.type === 'discount'
);
if (discounts.length > 1) {
// 删除重复的优惠条款,仅保留第一条
const firstKey = discounts[0].key;
termTabList.value = termTabList.value.filter(
(i: any) => i.type !== 'discount' || i.key === firstKey
);
// 强制保留项为选中
const keep = termTabList.value.find((i: any) => i.key === firstKey);
if (keep) keep.isChoose = 1;
} else {
// 只有一条时正常切换选中状态
info.isChoose = info.isChoose === 1 ? 2 : 1;
}
} else {
info.isChoose = info.isChoose === 1 ? 2 : 1;
}
}
function handleAddTerm() {
const idx = termTabList.value.filter(
(i: any) => i.type === 'discount'
).length;
const key = `discount-${Date.now()}-${idx}`;
termTabList.value.push({
key,
title: `优惠条款${idx + 1}`,
isChoose: 1,
type: 'discount',
});
}
const setDiscountRef = (el: any, index: number) => {
if (el) discountRefs.value[index] = el;
};
onMounted(() => {
getTemplateSettingFromIdData();
getDepartmentListData();
getAllCodeSettingListData();
getContractTagsListData();
getAllTermsSettingListData();
});
</script>
<style lang="less" scoped>
.isActive {
background-color: #eaf3fe;
border-color: #73a5f1;
}
.options {
display: flex;
align-items: center;
background-color: #f2f3f5;
width: 100%;
height: 36px;
padding: 0 3px;
.subItem {
flex: 1;
text-align: center;
padding: 4px 0;
cursor: pointer;
&.isActive {
background-color: #fff;
}
}
}
</style>
<style>
:deep(.arco-popover-popup-content) {
padding: 0 !important;
}
:deep(.arco-tag .arco-tag-custom-color) {
color: #165dff !important;
}
</style>
@@ -48,20 +48,19 @@
</DataTable>
<contractTemplateForm
v-model:visible="showModal"
:is-edit="false"
:department-list-data="departmentListData"
:is-edit="isEdit"
@submitted="getTemplateSettingListData"
/>
</a-card>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import type { TableColumnData } from '@arco-design/web-vue';
import { Message } from '@arco-design/web-vue';
import useDataTable from '@/composables/useDataTable';
import {
getTemplateSettingFromId,
delTemplateSetting,
getTemplateSettingList,
getDepartmentList,
@@ -69,10 +68,8 @@
} from '@/api/setting';
import { ContractTemplateSettingItem, DepartmentItem } from '@/types/setting';
import { UserStatus } from '@/types/comm';
import { camelToSnake } from '@/utils/caseConvert';
// import contractNumberFormDrawer from './drawer/contract-number-form-drawer.vue';
import contractTemplateForm from './modal/contract-template-form.vue';
const router = useRouter();
const {
data: renderData,
columns: projectColumn,
@@ -96,7 +93,7 @@
const departmentListData = ref<DepartmentItem[]>([]);
const showModal = ref<boolean>(false);
const isEdit = ref<boolean>(false);
const currentNumberInfo = ref<any>(null);
const currentContractTemplateInfo = ref<any>(null);
// 列配置
const columns: TableColumnData[] = [
{
@@ -201,26 +198,17 @@
// 处理错误
}
};
const getTemplateSettingFromIdData = async (id: number) => {
const { code, data } = await getTemplateSettingFromId(id);
if (code === 200 && data) {
const { contractCodeSetting } = data;
currentNumberInfo.value = contractCodeSetting || null;
currentNumberInfo.value.arrange = camelToSnake(
currentNumberInfo.value.arrange
);
}
};
const handleAddContractNumber = () => {
// currentNumberInfo.value = null;
currentContractTemplateInfo.value = null;
isEdit.value = false;
showModal.value = true;
};
const handleEditContractNumber = async (id: number) => {
await getTemplateSettingFromIdData(id);
isEdit.value = true;
// currentNumberInfo.value = info
showModal.value = true;
router.push({
name: 'system-setting-contract-template-edit',
query: { id },
});
};
// 初始化数据
onMounted(() => {
@@ -0,0 +1,313 @@
<template>
<a-drawer
:width="650"
:visible="visible"
title="租赁条款租期设置"
unmount-on-close
@ok="handleOk"
@cancel="handleCancel"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
auto-label-width
layout="vertical"
>
<p class="title">计费相关</p>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="付款周期" field="payCycle" required>
<a-input-number
v-model="form.payCycle"
append="月1付"
:min="1"
placeholder="请输入"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="税率" field="taxRate" required>
<a-input-number
v-model="form.taxRate"
append="%"
:min="0"
placeholder="请输入"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="计费模式" field="chargeType" required>
<a-select v-model="form.chargeType">
<a-option :value="1">按月计算租金</a-option>
<a-option :value="2">按实际天数计算租金</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="合同单价默认单位" field="priceUnit" required>
<a-select v-model="form.priceUnit">
<a-option
v-for="item in priceUnits"
:key="item.value"
:value="item.value"
>
{{ item.text }}</a-option
>
</a-select>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item v-if="form.chargeType === 1">
<div class="bg-[#eaf3fe] p-[10px] w-full">
<icon-exclamation-circle-fill style="color: #2d5cf6" />
按月计算租金公式=合同单价 x 合同面积
</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="每天单价算法(适合不足月租期计费)"
field="dayPriceAlgorithm"
required
>
<a-select v-model="form.dayPriceAlgorithm">
<a-option :value="1">按当前年天数换算</a-option>
<a-option :value="2">按当前月天数换算</a-option>
<a-option :value="3">按自定义每天单价</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="每天单价" field="dayPrice" required>
<a-input-number v-model="form.dayPrice" :min="0.1">
<template #append>
<a-select v-model="form.dayPriceUnit" style="width: 100px">
<a-option
v-for="item in priceUnits"
:key="item.value"
:value="item.value"
>
{{ item.text }}</a-option
>
</a-select>
</template>
</a-input-number>
</a-form-item>
</a-col>
<a-col v-if="form.dayPrice && form.dayPriceUnit" :span="24">
<a-form-item>
<div class="bg-[#eaf3fe] p-[10px] w-full">
<icon-exclamation-circle-fill style="color: #2d5cf6" />
每天单价算法公式 = {{ dayPriceCalc }} x 合同面积
</div>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="计算精度" field="calculationAccuracy" required>
<a-select v-model="form.calculationAccuracy">
<a-option :value="1"
>每步计算进行精确计算,直至计算结果。</a-option
>
<a-option :value="2">每步计算保留2位小数,直至计算结果</a-option>
<a-option :value="2">每步计算保留1位小数,直至计算结果</a-option>
<a-option :value="2">每步计算不保留小数,直至计算结果</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="每期账单小数点" field="billAccuracy" required>
<a-select v-model="form.billAccuracy">
<a-option :value="1">2位小数</a-option>
<a-option :value="2">1位小数</a-option>
<a-option :value="2">不保留</a-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<p class="title">租期相关</p>
<a-row :gutter="24">
<a-col :span="24">
<a-form-item label="租期划分模式" field="partitionMode" required>
<a-select v-model="form.partitionMode">
<a-option :value="1"
>按自然月划分租期(“周期开始月”月首日到“后期结束月”月末日)</a-option
>
<a-option :value="2">按起止日划分租期</a-option>
</a-select>
</a-form-item>
<a-form-item>
<div class="bg-[#eaf3fe] p-[10px] w-full">
<icon-exclamation-circle-fill style="color: #2d5cf6" />
按自然月划分租期,如2025-10-01 ~ 2025-10-31
</div>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="首月不足月租期处理"
field="firstLessMonthType"
required
>
<a-select v-model="form.firstLessMonthType">
<a-option :value="1">独立为一个租期</a-option>
<a-option :value="2">作为完整月来划分租期</a-option>
<a-option :value="3">与下一租期合并为一个租期</a-option>
</a-select>
</a-form-item>
<a-form-item>
<div class="bg-[#eaf3fe] p-[10px] w-full">
<icon-exclamation-circle-fill style="color: #2d5cf6" />
与下一租期合并为一个租期,如:2月1付,2025-07-16 ~
2025-09-30为一期账单
</div>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item
label="免租期、装修期租期处理"
field="otherLeaseProcessing"
required
>
<a-select v-model="form.otherLeaseProcessing">
<a-option :value="1">免租期计入租期,装修期计入租期</a-option>
<a-option :value="2">免租期计入租期,装修期不计入租期</a-option>
<a-option :value="3">免租期不计入租期,装修期计入租期</a-option>
<a-option :value="4">免租期不计入租期,装修期不计入租期</a-option>
</a-select>
</a-form-item>
<a-form-item>
<div class="bg-[#eaf3fe] p-[10px] w-full flex items-center">
<p> <icon-exclamation-circle-fill style="color: #2d5cf6" /></p>
<div class="ml-[2px]">
<p
>计入租期:租期从免租/装修期起始时间开始算,虽免租但算在合同期内</p
>
<p
>不计入租期:租期从免租/装修期结束时间开始算,为租期之外赠送的时间</p
>
</div>
</div>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-drawer>
</template>
<script lang="ts" setup>
import { ref, reactive, onMounted, computed, watch } from 'vue';
import type { FormInstance } from '@arco-design/web-vue';
const priceUnits = [
{ value: 1, text: '元/㎡*月' },
{ value: 2, text: '元/㎡*天' },
{ value: 3, text: '元/天' },
{ value: 4, text: '元/月' },
{ value: 5, text: '元/年' },
];
const priceUnitMap = new Map([
[1, '元/㎡*月'],
[2, '元/㎡*天'],
[3, '元/天'],
[4, '元/月'],
[5, '元/年'],
]);
const props = defineProps<{
visible: boolean;
record?: any | null;
}>();
const emit = defineEmits<{
(e: 'update:visible', val: boolean): void;
(e: 'submitted', val: any): void;
}>();
const isLoading = ref<boolean>(false);
// 表单数据
const formRef = ref<FormInstance>();
const defaultForm: any = {
payCycle: null, // 付款周期
taxRate: null, // 税率
chargeType: 1, //计费模式
priceUnit: 1, //合同单价默认单位
dayPriceAlgorithm: 1, //每天单价算法
dayPrice: null, //每天单价
dayPriceUnit: 1, //每天单价单位
calculationAccuracy: 1, //计算精度
billAccuracy: 1, //账单小数点
partitionMode: 1, //租期划分模式
firstLessMonthType: 1, //首月不足月租金处理
otherLeaseProcessing: 1, //免租期、装修期租期处理
};
const form = reactive<any>({ ...defaultForm });
// 校验规则
const rules = reactive({
payCycle: [{ required: true, message: '请输入付款周期' }],
taxRate: [{ required: true, message: '请输入税率' }],
chargeType: [{ required: true, message: '请选择计费模式' }],
priceUnit: [{ required: true, message: '请选择合同单价默认单位' }],
dayPriceAlgorithm: [{ required: true, message: '请选择每天单价算法' }],
dayPrice: [{ required: true, message: '请输入每天单价' }],
});
const handleOk = async () => {
if (isLoading.value) return;
try {
isLoading.value = true;
const valid = await formRef.value?.validate();
if (valid) return;
emit('update:visible', false);
emit('submitted', form);
} catch {
console.warn('❌ 校验未通过');
} finally {
isLoading.value = false;
}
};
const handleCancel = () => {
emit('update:visible', false);
};
const dayPriceCalc = computed(() => {
const unit = priceUnitMap.get(form.dayPriceUnit);
return `${form.dayPrice}${unit}`;
});
// 打开抽屉时,用 record 合并默认值填充表单;编辑场景回填
function applyRecordToForm(record: any) {
const merged = { ...defaultForm, ...(record || {}) };
Object.keys(defaultForm).forEach((key) => {
// 仅覆盖已定义字段,保持响应式
(form as any)[key] = (merged as any)[key];
});
}
watch(
() => props.visible,
(val) => {
if (val) {
applyRecordToForm(props.record);
}
}
);
watch(
() => props.record,
(val) => {
if (props.visible) {
applyRecordToForm(val);
}
}
);
</script>
<style lang="less" scoped>
:deep(label.arco-form-item-label) {
width: 100% !important;
}
.title {
font-size: 14px;
font-weight: 800;
margin-bottom: 20px;
}
</style>
@@ -1,7 +1,7 @@
<template>
<a-modal
:visible="visible"
:title="isEdit ? '编辑创建合同模板' : '新增创建合同模板'"
title="新增创建合同模板"
unmount-on-close
@ok="handleSubmit"
@cancel="handleCancel"
@@ -37,7 +37,7 @@
import { createTemplateSetting, updateTemplateSetting } from '@/api/setting';
import { DepartmentItem, CreateTemplateSettingParam } from '@/types/setting';
import { Message } from '@arco-design/web-vue';
import { useRouter } from 'vue-router';
interface TreeNode {
id: string | number;
name: string;
@@ -46,7 +46,7 @@
const props = defineProps<{
visible: boolean;
isEdit: boolean;
record?: DepartmentItem | null;
record?: any;
departmentListData: TreeNode[]; // 修复类型
}>();
@@ -54,13 +54,13 @@
(e: 'update:visible', val: boolean): void;
(e: 'submitted'): void;
}>();
const router = useRouter();
// 表单数据
const formRef = ref<FormInstance>();
const form = reactive<CreateTemplateSettingParam>({
const form = reactive<any>({
name: '',
departmentId: null,
// id: undefined,
id: undefined,
});
// 校验规则
@@ -68,19 +68,18 @@
name: [{ required: true, message: '请输入模板名称' }],
departmentId: [{ required: true, message: '请选择适用范围' }],
};
// Watch 弹窗控制 & 初始化
// 表单事件
const handleCancel = () => {
emit('update:visible', false);
};
watch(
() => props.visible,
(val) => {
if (val) {
if (props.record) {
// form.id = props.record?.id;
// form.name = props.record?.name;
// form.parent_id = props.record?.parentId ?? '';
// form.user_id = props.record?.UserId ?? '';
// form.remark = props.record?.remark ?? '';
// Object.assign(form, props.record);
Object.assign(form, {
...props.record,
});
} else {
Object.assign(form, {
id: undefined,
@@ -92,21 +91,29 @@
},
{ immediate: true }
);
// 表单事件
const handleCancel = () => {
emit('update:visible', false);
};
const handleSubmit = async () => {
await formRef.value?.validate();
const { code, message } = props.isEdit
if (!form.departmentId || !form.name) return;
if (props.isEdit) {
// saveType 保存类别:1 名称与适用范围数据,2 基础信息数据,3 条款数据)
form.saveType = 1;
}
const { code, message, data } = props.isEdit
? await updateTemplateSetting(form)
: await createTemplateSetting(form);
if (code === 200) {
Message.success(message);
if (props.isEdit) {
emit('submitted');
} else {
const info = data as any;
const id = info.contractTemplateSettingId;
router.push({
name: 'system-setting-contract-template-edit',
query: { id },
});
}
handleCancel();
// 通知父组件更新部门列表数据
emit('submitted');
}
};
</script>
@@ -0,0 +1,113 @@
<template>
<div class="border-solid border-[1px] border-[#f0f0f0] px-[10px] py-[15px]">
<h4 class="text-[14px] text-[#505968] font-extrabold">{{ title }}</h4>
<a-form
ref="formRef"
class="mt-[15px]"
:model="bondForm"
:rules="rules"
auto-label-width
layout="vertical"
>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="保证金金额" field="num" required>
<a-input-number
v-model="bondForm.num"
:min="1"
placeholder="请输入数字"
/>
<ul class="options ml-[10px]">
<li
v-for="item in bondUnits"
:key="item.type"
:class="[
'subItem',
{ isActive: item.type === bondForm.priceType },
]"
@click="bondForm.priceType = item.type"
>
{{ item.text }}</li
>
</ul>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="付款日期" field="payType" required>
<ul class="options">
<li
v-for="item in payDateTypes"
:key="item.type"
:class="[
'subItem',
{ isActive: item.type === bondForm.payType },
]"
@click="choosePayDateType(item.type)"
>
{{ item.text }}</li
>
</ul>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue';
defineProps<{ title?: string }>();
import type { FormInstance } from '@arco-design/web-vue';
// 表单数据
const formRef = ref<FormInstance>();
const defaultForm: any = {
num: null,
payType: 1,
priceType: 1,
};
const bondForm = reactive<any>({ ...defaultForm });
// 校验规则
const rules = reactive({
num: [{ required: true, message: '请输入保证金金额' }],
payType: [{ required: true, message: '请选择保证金单位' }],
priceType: [{ required: true, message: '请选择付款日期' }],
});
const payDateTypes = [
{ type: 1, text: '首期账单日期' },
{ type: 2, text: '签约日期' },
];
const bondUnits = [
{ type: 1, text: '个月' },
{ type: 2, text: '元' },
];
const choosePayDateType = (type) => {
bondForm.payType = type;
};
// 暴露保存数据
function getData() {
return { ...bondForm };
}
defineExpose({ getData });
</script>
<style lang="less" scoped>
.options {
display: flex;
align-items: center;
background-color: #f2f3f5;
width: 100%;
height: 36px;
padding: 0 3px;
.subItem {
flex: 1;
text-align: center;
padding: 4px 0;
cursor: pointer;
&.isActive {
background-color: #fff;
}
}
}
</style>
@@ -0,0 +1,127 @@
<template>
<div
class="border-solid border-[1px] border-[#f0f0f0] px-[10px] py-[15px] mt-[20px]"
>
<h4 class="text-[14px] text-[#505968] font-extrabold">优惠条款</h4>
<a-form
ref="formRef"
class="mt-[15px]"
:model="discountForm"
:rules="rules"
auto-label-width
layout="vertical"
>
<a-row :gutter="24">
<a-col :span="7">
<a-form-item label="优惠类型" field="type" required>
<a-select v-model="discountForm.type" placeholder="请选择">
<a-option :value="1">单价折扣</a-option>
<a-option :value="2">单价减免</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="金额折扣" field="discount" required>
<a-input
v-model="discountForm.discount"
append="折"
placeholder="请输入"
/>
</a-form-item>
</a-col>
<a-col :span="11">
<a-form-item label="作用范围" required>
<a-input-group>
<a-form-item no-style field="startNum">
<a-input
v-model="discountForm.startNum"
prepend="第"
append="期开始"
placeholder="请输入"
/>
</a-form-item>
<a-form-item no-style field="endNum">
<a-input
v-model="discountForm.endNum"
prepend="持续"
append="期"
placeholder="请输入"
/>
</a-form-item>
</a-input-group>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue';
import type { FormInstance } from '@arco-design/web-vue';
// 表单数据
const formRef = ref<FormInstance>();
const defaultForm: any = {
type: 1,
discount: null,
startNum: null,
endNum: null,
};
const discountForm = reactive<any>({ ...defaultForm });
// 校验规则
const rules = reactive({
type: [{ required: true, message: '优惠类型不能为空' }],
discount: [{ required: true, message: '金额折扣不能为空' }],
startNum: [{ required: true, message: '开始期数不能为空' }],
endNum: [{ required: true, message: '持续期数不能为空' }],
});
// 向父组件暴露数据与校验方法
function getData() {
const isEmptyValue = (v: any) =>
v === undefined ||
v === null ||
(typeof v === 'string' && v.trim() === '');
const { type, discount, startNum, endNum } = discountForm;
// 任一必填未有效填写则返回空对象,避免父层误判有数据
if (
isEmptyValue(type) ||
isEmptyValue(discount) ||
isEmptyValue(startNum) ||
isEmptyValue(endNum)
) {
return {};
}
return {
type: Number(type),
discount: Number(discount),
startNum: Number(startNum),
endNum: Number(endNum),
};
}
async function validate() {
return formRef.value?.validate?.();
}
defineExpose({ getData, validate });
</script>
<style lang="less" scoped>
.options {
display: flex;
align-items: center;
background-color: #f2f3f5;
width: 100%;
height: 36px;
padding: 0 3px;
.subItem {
flex: 1;
text-align: center;
padding: 4px 0;
cursor: pointer;
&.isActive {
background-color: #fff;
}
}
}
</style>
@@ -0,0 +1,134 @@
<template>
<div
class="border-solid border-[1px] border-[#f0f0f0] px-[10px] py-[15px] mt-[20px]"
>
<h4 class="text-[14px] text-[#505968] font-extrabold">递增条款</h4>
<a-form
ref="formRef"
class="mt-[15px]"
:model="increamForm"
:rules="rules"
auto-label-width
layout="vertical"
>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="递增间隔" field="num" required>
<a-input-number
v-model="increamForm.num"
:min="1"
prepend="每隔"
append="年"
placeholder="请输入数字"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="单价递增" field="priceIncrease" required>
<a-input v-model="increamForm.priceIncrease" placeholder="请选择">
<template #append>
<a-select
v-model="increamForm.priceIncreaseType"
style="width: 80px"
>
<a-option :value="1">%</a-option>
<a-option :value="2">元</a-option>
</a-select>
</template>
</a-input>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="保证金递增" field="bondIncrease" required>
<a-input v-model="increamForm.bondIncrease" placeholder="请选择">
<template #append>
<a-select
v-model="increamForm.bondIncreaseType"
style="width: 80px"
>
<a-option :value="1">%</a-option>
<a-option :value="2">元</a-option>
</a-select>
</template>
</a-input>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue';
import type { FormInstance } from '@arco-design/web-vue';
// 表单数据
const formRef = ref<FormInstance>();
const defaultForm: any = {
num: null,
priceIncrease: null,
priceIncreaseType: 1,
bondIncrease: null,
bondIncreaseType: 1,
};
const increamForm = reactive<any>({ ...defaultForm });
// 校验规则
const rules = reactive({
num: [{ required: true, message: '递增间隔不能为空' }],
priceIncrease: [{ required: true, message: '单价递增不能为空' }],
bondIncrease: [{ required: true, message: '保证金递增不能为空' }],
});
// 向父组件暴露数据与校验方法
function getData() {
const isEmptyValue = (v: any) =>
v === undefined ||
v === null ||
(typeof v === 'string' && v.trim() === '');
const {
num,
priceIncrease,
priceIncreaseType,
bondIncrease,
bondIncreaseType,
} = increamForm;
if (
isEmptyValue(num) ||
isEmptyValue(priceIncrease) ||
isEmptyValue(bondIncrease)
) {
return {};
}
return {
num: Number(num),
priceIncrease: Number(priceIncrease),
priceIncreaseType: Number(priceIncreaseType),
bondIncrease: Number(bondIncrease),
bondIncreaseType: Number(bondIncreaseType),
};
}
async function validate() {
return formRef.value?.validate?.();
}
defineExpose({ getData, validate });
</script>
<style lang="less" scoped>
.options {
display: flex;
align-items: center;
background-color: #f2f3f5;
width: 100%;
height: 36px;
padding: 0 3px;
.subItem {
flex: 1;
text-align: center;
padding: 4px 0;
cursor: pointer;
&.isActive {
background-color: #fff;
}
}
}
</style>
@@ -0,0 +1,144 @@
<template>
<div
class="border-solid border-[1px] border-[#f0f0f0] px-[10px] py-[15px] mt-[20px]"
>
<h4 class="text-[14px] text-[#505968] font-extrabold">滞纳金条款</h4>
<a-form
ref="formRef"
class="mt-[15px]"
:model="lateFeeForm"
:rules="rules"
auto-label-width
layout="vertical"
>
<a-row :gutter="24">
<a-col :span="7">
<a-form-item label="起算天数" field="days" required>
<a-input-number
v-model="lateFeeForm.days"
:min="1"
prepend="账单逾期"
append="天"
placeholder="请输入"
/>
</a-form-item>
</a-col>
<a-col :span="6">
<a-form-item label="收取方式" field="chargeType" required>
<a-select v-model="lateFeeForm.chargeType" placeholder="请选择">
<a-option :value="1">仅计算不生成账单</a-option>
<a-option :value="2">达到上限生成账单</a-option>
<a-option :value="3">生成账单每天调</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="11">
<a-form-item label="滞纳金" field="ratio" required>
<a-input-group>
<a-input v-model="lateFeeForm.ratio" placeholder="请输入">
<template #append>
<a-select
v-model="lateFeeForm.ratioType"
style="width: 100px"
>
<a-option :value="1">%/天</a-option>
<a-option :value="2">元/天</a-option>
</a-select>
</template>
</a-input>
<a-input v-model="lateFeeForm.ceiling" placeholder="请输入">
<template #append>
<a-select
v-model="lateFeeForm.ceilingType"
style="width: 100px"
>
<a-option :value="1">%上限</a-option>
<a-option :value="2">元上限</a-option>
</a-select>
</template>
</a-input>
</a-input-group>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive } from 'vue';
import type { FormInstance } from '@arco-design/web-vue';
// 表单数据
const formRef = ref<FormInstance>();
const defaultForm: any = {
days: null,
chargeType: 1,
ratio: null,
ratioType: 1,
ceiling: null,
ceilingType: 1,
};
const lateFeeForm = reactive<any>({ ...defaultForm });
// 校验规则
const rules = reactive({
days: [{ required: true, message: '起算天数不能为空' }],
chargeType: [{ required: true, message: '收取方式不能为空' }],
ratio: [{ required: true, message: '滞纳金不能为空' }],
ratioType: [{ required: true, message: '滞纳金单位不能为空' }],
ceiling: [{ required: true, message: '上限不能为空' }],
ceilingType: [{ required: true, message: '上限单位不能为空' }],
});
// 向父组件暴露数据与校验方法
function getData() {
const isEmptyValue = (v: any) =>
v === undefined ||
v === null ||
(typeof v === 'string' && v.trim() === '');
const { days, chargeType, ratio, ratioType, ceiling, ceilingType } =
lateFeeForm;
if (
isEmptyValue(days) ||
isEmptyValue(chargeType) ||
isEmptyValue(ratio) ||
isEmptyValue(ratioType) ||
isEmptyValue(ceiling) ||
isEmptyValue(ceilingType)
) {
return {};
}
return {
days: Number(days),
chargeType: Number(chargeType),
ratio: Number(ratio),
ratioType: Number(ratioType),
ceiling: Number(ceiling),
ceilingType: Number(ceilingType),
};
}
async function validate() {
return formRef.value?.validate?.();
}
defineExpose({ getData, validate });
</script>
<style lang="less" scoped>
.options {
display: flex;
align-items: center;
background-color: #f2f3f5;
width: 100%;
height: 36px;
padding: 0 3px;
.subItem {
flex: 1;
text-align: center;
padding: 4px 0;
cursor: pointer;
&.isActive {
background-color: #fff;
}
}
}
</style>
@@ -0,0 +1,312 @@
<template>
<div
class="border-solid border-[1px] border-[#f0f0f0] px-[10px] py-[15px] mt-[20px]"
>
<h4 class="text-[14px] text-[#505968] font-extrabold">租期条款(租金)</h4>
<div class="bg-[#edfeec] px-[10px] py-[5px]">
<ul v-if="hasRentSetting" class="rent-list">
<li v-for="(text, idx) in displayItems" :key="idx" class="rent-item">{{
text
}}</li>
<li class="rent-action" @click="handleShowDrawer">
<icon-settings :size="18" />
</li>
</ul>
</div>
<a-form
ref="formRef"
class="mt-[15px]"
:model="rentSettingInfo"
:rules="rules"
auto-label-width
layout="vertical"
>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="首期账单缴费日期" field="firstPayDate" required>
<a-date-picker
v-model="rentSettingInfo.firstPayDate"
:disabled-date="(current) => dayjs(current).isBefore(dayjs())"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="账单缴费日期" field="payDay" required>
<a-input-number v-model="rentSettingInfo.payDay">
<template #prepend>
<a-select v-model="rentSettingInfo.payDateType">
<a-option :value="1">租期开始前</a-option>
<a-option :value="2">租期开始后</a-option>
<a-option :value="3">租期开始上月</a-option>
<a-option :value="4">租期开始本月</a-option>
</a-select>
</template>
<template #append>
<a-select v-model="rentSettingInfo.payDayType">
<a-option :value="1">日付款</a-option>
<a-option :value="2">自然日付款</a-option>
<a-option :value="3">工作日付款</a-option>
</a-select>
</template>
</a-input-number>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item
label="默认收款账户"
field="financeTurnoverAccountSettingId"
required
>
<a-select v-model="rentSettingInfo.financeTurnoverAccountSettingId">
<a-option
v-for="item in turnoverAccountList"
:key="item.id"
:value="item.id"
>
{{ item.bankAccuntName }}</a-option
>
</a-select>
</a-form-item>
</a-col>
</a-row>
</a-form>
<rentSettingDrawer
v-model:visible="isShowDrawer"
:record="rentSettingInfo"
@submitted="getRendSetting"
/>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, watch, computed, onMounted } from 'vue';
import type { FormInstance } from '@arco-design/web-vue';
import rentSettingDrawer from '../drawer/rent-setting-drawer.vue';
import dayjs from 'dayjs';
import { getUseTurnoverAccountList } from '@/api/setting';
const isShowDrawer = ref<boolean>(false);
// 与抽屉保持一致的默认表单,用于初始回显与编辑回填
const defaultForm: any = {
payCycle: null, // 付款周期 (输入类,默认不显示)
taxRate: null, // 税率 (输入类,默认不显示)
chargeType: 1, // 计费模式
priceUnit: 1, // 合同单价默认单位
dayPriceAlgorithm: 1, // 每天单价算法
dayPrice: null, // 每天单价 (输入类,默认不显示)
dayPriceUnit: 1, // 每天单价单位
calculationAccuracy: 1, // 计算精度
billAccuracy: 1, // 账单小数点
partitionMode: 1, // 租期划分模式
firstLessMonthType: 1, // 首月不足月租金处理
otherLeaseProcessing: 1, // 免租期、装修期租期处理
firstPayDate: '',
payDay: '',
payDateType: 1,
payDayType: 1,
};
const rentSettingInfo = ref<any>({ ...defaultForm });
const formRef = ref<FormInstance>();
const rules = reactive({
firstPayDate: [{ required: true, message: '请选择首期账单缴费日期' }],
payDay: [{ required: true, message: '请输入账单缴费日期' }],
financeTurnoverAccountSettingId: [
{ required: true, message: '请选择默认收款账户' },
],
});
const turnoverAccountList = ref<any>([]);
const props = defineProps<{
termSettingInfo?: any | null;
}>();
// 文案映射与计算属性
const chargeTypeMap = new Map([
[1, '按月计算租金'],
[2, '按实际天数计算租金'],
]);
const priceUnitMap = new Map([
[1, '元/㎡*月'],
[2, '元/㎡*天'],
[3, '元/天'],
[4, '元/月'],
[5, '元/年'],
]);
const dayPriceAlgorithmMap = new Map([
[1, '按当前年天数换算'],
[2, '按当前月天数换算'],
[3, '按自定义每天单价'],
]);
const calculationAccuracyMap = new Map([
[1, '每步计算进行精确计算,直至计算结果。'],
[2, '每步计算保留2位小数,直至计算结果'],
[3, '每步计算保留1位小数,直至计算结果'],
[4, '每步计算不保留小数,直至计算结果'],
]);
const billAccuracyMap = new Map([
[1, '2位小数'],
[2, '1位小数'],
[3, '不保留'],
]);
const partitionModeMap = new Map([
[1, '按自然月划分租期(“周期开始月”月首日到“后期结束月”月末日)'],
[2, '按起止日划分租期'],
]);
const firstLessMonthTypeMap = new Map([
[1, '首月不足月租期:独立为一个租期'],
[2, '首月不足月租期:作为完整月来划分租期'],
[3, '首月不足月租期:与下一租期合并为一个租期'],
]);
const otherLeaseProcessingMap = new Map([
[1, '免租期计入租期,装修期计入租期'],
[2, '免租期计入租期,装修期不计入租期'],
[3, '免租期不计入租期,装修期计入租期'],
[4, '免租期不计入租期,装修期不计入租期'],
]);
const hasRentSetting = computed(
() => Object.keys(rentSettingInfo.value || {}).length > 0
);
// 汇总所有字段的回显文案
const displayItems = computed(() => {
const list: string[] = [];
const v = rentSettingInfo.value || {};
// 计费相关
if (v.payCycle !== undefined && v.payCycle !== null)
list.push(`${v.payCycle}月1付`);
if (v.taxRate !== undefined && v.taxRate !== null && v.taxRate !== '')
list.push(`税率${v.taxRate}%`);
if (v.chargeType !== undefined)
list.push(chargeTypeMap.get(v.chargeType) || '');
if (v.priceUnit !== undefined)
list.push(`合同默认单价${priceUnitMap.get(v.priceUnit) || ''}`);
if (v.dayPriceAlgorithm !== undefined)
list.push(
`每天单价算法:${dayPriceAlgorithmMap.get(v.dayPriceAlgorithm) || ''}`
);
if (v.dayPrice !== undefined && v.dayPrice !== null && v.dayPrice !== '') {
const unit = priceUnitMap.get(v.dayPriceUnit);
list.push(`每天单价${v.dayPrice}${unit ? unit : ''}`);
}
if (v.calculationAccuracy !== undefined)
list.push(
`计算精度:${calculationAccuracyMap.get(v.calculationAccuracy) || ''}`
);
if (v.billAccuracy !== undefined)
list.push(`每期账单小数点:${billAccuracyMap.get(v.billAccuracy) || ''}`);
// 租期相关
if (v.partitionMode !== undefined)
list.push(`租期划分模式:${partitionModeMap.get(v.partitionMode) || ''}`);
if (v.firstLessMonthType !== undefined)
list.push(firstLessMonthTypeMap.get(v.firstLessMonthType) || '');
if (v.otherLeaseProcessing !== undefined)
list.push(
`免租/装修期处理:${
otherLeaseProcessingMap.get(v.otherLeaseProcessing) || ''
}`
);
// 过滤空串
return list.filter(Boolean);
});
function handleShowDrawer() {
isShowDrawer.value = true;
}
// 暴露方法给父组件用于保存/校验
function getData() {
return { ...rentSettingInfo.value };
}
async function validate() {
const res = await formRef.value?.validate();
if (res) throw new Error('租金设置校验未通过');
return true;
}
function getRendSetting(value) {
rentSettingInfo.value = { ...rentSettingInfo.value, ...(value || {}) };
}
async function getUseTurnoverAccountListData(info) {
const params = {
fee_attribute: 1,
fee_type: info?.type || 1,
is_bond: info?.isBond || 1,
};
const { code, data } = await getUseTurnoverAccountList(params);
if (code === 200) {
turnoverAccountList.value = data?.list || [];
}
}
// 避免重复触发:使用 key 去重与并发锁
const lastTermKey = ref<string>('');
const isFetching = ref<boolean>(false);
function buildTermKey(info: any) {
if (!info) return '';
return `${info?.id || ''}-${info?.type || ''}-${info?.isBond || ''}`;
}
async function triggerTurnoverList(info: any) {
const key = buildTermKey(info);
if (!key || key === lastTermKey.value || isFetching.value) return;
lastTermKey.value = key;
isFetching.value = true;
try {
await getUseTurnoverAccountListData(info);
} finally {
isFetching.value = false;
}
}
onMounted(() => {
triggerTurnoverList(props.termSettingInfo);
});
watch(
() => props.termSettingInfo,
(newVal) => {
triggerTurnoverList(newVal);
},
{ deep: false }
);
defineExpose({ getData, validate });
</script>
<style lang="less" scoped>
.options {
display: flex;
align-items: center;
background-color: #f2f3f5;
width: 100%;
height: 36px;
padding: 0 3px;
.subItem {
flex: 1;
text-align: center;
padding: 4px 0;
cursor: pointer;
&.isActive {
background-color: #fff;
}
}
}
.rent-list {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-top: 15px;
.rent-item {
line-height: 22px;
border-radius: 2px;
background-color: rgba(232, 255, 234, 1);
color: rgba(0, 180, 42, 1);
font-size: 12px;
text-align: center;
border: 1px solid rgba(0, 180, 42, 1);
margin-right: 8px;
margin-bottom: 8px;
padding: 2px 5px;
}
.rent-action {
margin-left: auto;
order: 9999;
cursor: pointer;
}
}
</style>
@@ -0,0 +1,195 @@
<template>
<a-card title="合同管理">
<a-spin style="display: block" :loading="loading">
<div
v-if="contractTagsList && contractTagsList.length"
class="project_label_wrap"
>
<div class="flex_center_between">
<div class="title_wrap">
<h4>合同类型(标签类)</h4>
<a-checkbox
:model-value="
checkedLabelId.length === contractTagsList.length &&
contractTagsList.length > 0
"
@change="onCheckAllLabelChange"
>全选</a-checkbox
>
</div>
<a-button type="text" @click="handleAddLabel"
><icon-plus />新增</a-button
>
<labelFormModal
v-model:visible="showLabelModal"
:create-api="createContractTags"
:update-api="updateContractTags"
:del-api="delContractTags"
:title="isEdit ? '编辑合同类型字典' : '新增合同类型字典'"
:record="currentLabelFormInfo"
:is-edit="isEdit"
@submitted="getAllContractTagListData"
/>
</div>
<ul class="ul_wrap">
<li
v-for="item in contractTagsList"
:key="item.id"
style="margin: 5px 30px 0 0"
>
<a-checkbox
:model-value="checkedLabelId.includes(item.id)"
@change="(val) => onProjectTypeCheckChange(item, val)"
>
{{ item.name }}
<span
class="circle"
:style="{
borderColor: item.color,
backgroundColor: item.color,
}"
></span>
<icon-edit
v-if="item.type === 2"
class="edit_icon"
@click.stop.prevent="handleEditLabel(item, $event)"
/>
</a-checkbox>
</li>
</ul>
</div>
</a-spin>
</a-card>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import {
getAllContractTagsList,
checkContractTags,
checkAllContractTags,
createContractTags,
updateContractTags,
delContractTags,
} from '@/api/setting';
import { ProjectTypeChecked } from '@/types/comm';
import useLoading from '@/hooks/loading';
import { Message } from '@arco-design/web-vue';
import labelFormModal from './label-form-modal.vue';
const contractTagsList = ref<any>([]);
const checkedLabelId = ref<number[]>([]);
const { loading, setLoading } = useLoading(true);
const showLabelModal = ref<boolean>(false);
const isEdit = ref<boolean>(false);
const currentLabelFormInfo = ref(null);
const checkAllProjectTypeData = async (checked: boolean) => {
try {
const params = { is_check: (checked ? 1 : 0) as ProjectTypeChecked };
const { code, message } = await checkAllContractTags(params);
if (code === 200) {
Message.success(message);
}
} catch (error) {
// console.error(error);
}
};
const checkProjectTypeData = async (params: {
id: number;
is_check: ProjectTypeChecked;
}) => {
try {
const { code, message } = await checkContractTags(params);
if (code === 200) {
Message.success(message);
}
} catch (error) {
// console.error(error);
}
};
const onCheckAllLabelChange = (checked: boolean) => {
if (checked) {
// 全选
checkedLabelId.value = contractTagsList.value.map((item) => item.id);
} else {
// 取消全选
checkedLabelId.value = [];
}
checkAllProjectTypeData(checked);
};
const onProjectTypeCheckChange = (item, checked) => {
if (checked) {
if (!checkedLabelId.value.includes(item.id)) {
checkedLabelId.value.push(item.id);
}
} else {
checkedLabelId.value = checkedLabelId.value.filter(
(id) => id !== item.id
);
}
checkProjectTypeData({ id: item.id, is_check: checked ? 1 : 0 });
};
const getAllContractTagListData = async () => {
try {
setLoading(true);
const { code, data } = await getAllContractTagsList();
if (code === 200 && data) {
contractTagsList.value = data?.contractTagsSettingList;
if (contractTagsList.value?.length === 0) return;
checkedLabelId.value = contractTagsList.value
.filter((i) => i.isCheck === 1)
.map((i) => i.id);
}
} catch (error) {
// console.error(error);
} finally {
setLoading(false);
}
};
const handleAddLabel = () => {
showLabelModal.value = true;
isEdit.value = false;
currentLabelFormInfo.value = null;
};
const handleEditLabel = (item, event) => {
if (event) event.preventDefault();
showLabelModal.value = true;
isEdit.value = true;
currentLabelFormInfo.value = item;
};
onMounted(async () => {
await getAllContractTagListData();
});
</script>
<style lang="less" scoped>
.project_label_wrap {
.title_wrap {
display: flex;
align-items: center;
}
.circle {
display: inline-block;
width: 12px;
height: 12px;
border: 1px solid #ccc;
border-radius: 50%;
margin-left: 4px;
vertical-align: middle;
}
.edit_icon {
margin-left: 5px;
}
.ul_wrap {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-top: 10px;
}
}
</style>
@@ -27,6 +27,7 @@
<a-col :span="18">
<projectCard v-if="currentSettingId === 2" />
<roomCard v-if="currentSettingId === 3" />
<contractCard v-if="currentSettingId === 4" />
<tenantCard v-if="currentSettingId === 5" />
</a-col>
</a-row>
@@ -42,6 +43,7 @@
import { dataDictionaryTabs } from './contrant';
import projectCard from './components/project-card.vue';
import roomCard from './components/room-card.vue';
import contractCard from './components/contract-card.vue';
import tenantCard from './components/tenant-card.vue';
const { t } = useI18n();