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
+5 -1
View File
@@ -1,3 +1,7 @@
/*.json
/*.js
dist
dist
*.config.js
*.config.ts
.eslintrc.js
node_modules/
+99 -47
View File
@@ -1,77 +1,129 @@
// eslint-disable-next-line @typescript-eslint/no-var-requires
const path = require('path');
module.exports = {
root: true,
parser: 'vue-eslint-parser',
parserOptions: {
// Parser that checks the content of the <script> tag
parser: '@typescript-eslint/parser',
sourceType: 'module',
ecmaVersion: 2020,
ecmaFeatures: {
jsx: true,
},
},
env: {
'browser': true,
'node': true,
'es2021': true,
'vue/setup-compiler-macros': true,
},
plugins: ['@typescript-eslint'],
// 1. 主解析器始终是 'vue-eslint-parser'
parser: 'vue-eslint-parser',
parserOptions: {
// 2. 在顶层 parserOptions 中指定 TS 解析器和 JSX 支持
parser: '@typescript-eslint/parser',
ecmaVersion: 'latest', // 使用 'latest' 更稳妥
sourceType: 'module',
ecmaFeatures: {
jsx: true, // 关键:开启 JSX 支持
},
extraFileExtensions: ['.vue'],
project: path.resolve(__dirname, './tsconfig.json'),
},
extends: [
// Airbnb JavaScript Style Guide https://github.com/airbnb/javascript
'airbnb-base',
'plugin:vue/vue3-recommended',
'plugin:@typescript-eslint/recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:vue/vue3-recommended',
'plugin:prettier/recommended',
],
settings: {
'import/resolver': {
typescript: {
project: path.resolve(__dirname, './tsconfig.json'),
},
},
},
plugins: ['vue', '@typescript-eslint', 'import', 'prettier'],
rules: {
// 你的规则保持不变
'no-param-reassign': 0,
'no-plusplus': 0,
'prettier/prettier': 1,
// Vue: Recommended rules to be closed or modify
'no-console': process.env.NODE_ENV === 'production' ? 2 : 1,
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
'no-unused-expressions': 'off',
'vue/multi-word-component-names': 0,
'vue/require-default-prop': 0,
'vue/singleline-html-element-content-newline': 0,
'vue/max-attributes-per-line': 0,
// Vue: Add extra rules
'vue/custom-event-name-casing': [0, 'camelCase'],
'camelcase': ['warn', { properties: 'never' }],
'vue/no-v-text': 1,
'vue/padding-line-between-blocks': 1,
'vue/require-direct-export': 1,
'vue/multi-word-component-names': 0,
// Allow @ts-ignore comment
'@typescript-eslint/ban-ts-comment': 0,
'@typescript-eslint/no-unused-vars': 1,
'@typescript-eslint/no-empty-function': 1,
'@typescript-eslint/no-explicit-any': 0, // 改为警告
'no-unused-expressions': 'off',
'no-console': process.env.NODE_ENV === 'production' ? 2 : 1, // 生产环境禁用console
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/ban-types': 'off',
'import/no-unresolved': [2, { ignore: ['^@/'] }],
'import/extensions': [
2,
'error',
'ignorePackages',
{
js: 'never',
jsx: 'never',
ts: 'never',
tsx: 'never',
vue: 'off',
},
],
'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
'no-plusplus': ['error', { allowForLoopAfterthoughts: true }],
'no-param-reassign': 0,
'prefer-regex-literals': 0,
'import/no-extraneous-dependencies': 0,
// 禁用 prefer-destructuring 规则,因为它对对象解构和数组解构都产生错误警告
'prefer-destructuring': 0,
'no-return-assign': ['error', 'except-parens'],
},
overrides: [
// 3. 为 .vue 文件提供更具体的解析器选项(如果需要)
{
files: ['*.vue'],
rules: {
// 在这里可以覆盖一些只针对 .vue 文件的规则
// 例如,vue-eslint-parser 已经处理了未使用的变量,所以可以关闭 TS 的检查
'@typescript-eslint/no-unused-vars': 'off',
},
},
// 4. 为 .tsx 文件单独配置(推荐)
{
files: ['*.tsx'],
parser: '@typescript-eslint/parser', // 直接使用 TS 解析器
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
project: path.resolve(__dirname, './tsconfig.json'),
},
extends: [
'plugin:@typescript-eslint/recommended',
'plugin:prettier/recommended',
],
},
// d.ts 文件配置保持不变
{
files: ['**/*.d.ts'],
parserOptions: { project: undefined },
rules: {
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/ban-types': 'off',
},
},
{
files: ['*.config.ts', '*.config.js'], // 匹配所有 .config.ts 和 .config.js 文件
parserOptions: {
project: undefined, // 关键:告诉 ESLint 这个文件不需要关联 TS 项目配置
},
rules: {
// 在这里可以关闭一些对配置文件不适用的规则
'import/no-named-as-default': 'off', // 例如,这个规则在配置文件中很常见
},
},
],
settings: {
'import/resolver': {
typescript: {
alwaysTryTypes: true,
project: path.resolve(__dirname, './tsconfig.json'),
},
node: {
extensions: ['.js', '.ts', '.tsx', '.vue'],
},
},
},
ignorePatterns: ['.eslintrc.js', 'node_modules', 'dist', '*.config.js'],
};
+19 -10
View File
@@ -24,6 +24,9 @@
"prettier --write",
"eslint --fix"
],
"*.d.ts": [
"prettier --write"
],
"*.{less,css}": [
"stylelint --fix",
"prettier --write"
@@ -31,6 +34,7 @@
},
"dependencies": {
"@arco-design/web-vue": "^2.57.0",
"@onlyoffice/document-editor-vue": "^1.6.1",
"@vueuse/core": "^9.3.0",
"arco-design-pro-vue": "^2.7.3",
"axios": "^0.24.0",
@@ -39,7 +43,7 @@
"lodash": "^4.17.21",
"mitt": "^3.0.0",
"nprogress": "^0.2.0",
"pinia": "^2.0.23",
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.3.0",
"query-string": "^8.0.3",
"sortablejs": "^1.15.0",
@@ -53,38 +57,42 @@
"@arco-plugins/vite-vue": "^1.4.5",
"@commitlint/cli": "^17.1.2",
"@commitlint/config-conventional": "^17.1.0",
"@tailwindcss/postcss": "^4.1.13",
"@types/lodash": "^4.14.186",
"@types/mockjs": "^1.0.7",
"@types/node": "^24.0.3",
"@types/nprogress": "^0.2.0",
"@types/sortablejs": "^1.15.0",
"@typescript-eslint/eslint-plugin": "^5.40.0",
"@typescript-eslint/parser": "^5.40.0",
"@typescript-eslint/eslint-plugin": "^8.46.0",
"@typescript-eslint/parser": "^8.46.0",
"@vitejs/plugin-vue": "^3.1.2",
"@vitejs/plugin-vue-jsx": "^2.0.1",
"@vue/babel-plugin-jsx": "^1.1.1",
"autoprefixer": "^10.4.21",
"consola": "^2.15.3",
"cross-env": "^7.0.3",
"eslint": "^8.25.0",
"eslint": "^8.57.1",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^8.5.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-prettier": "^4.2.1",
"eslint-plugin-vue": "^9.6.0",
"eslint-config-prettier": "^8.10.2",
"eslint-import-resolver-typescript": "^3.10.1",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-prettier": "^4.2.5",
"eslint-plugin-vue": "^9.33.0",
"globals": "^16.4.0",
"husky": "^8.0.1",
"less": "^4.1.3",
"lint-staged": "^13.0.3",
"mockjs": "^1.1.0",
"postcss": "^8.5.6",
"postcss-html": "^1.8.0",
"prettier": "^2.7.1",
"prettier": "^2.8.8",
"rollup": "^3.9.1",
"rollup-plugin-visualizer": "^5.8.2",
"stylelint": "^16.21.0",
"stylelint-config-recommended-vue": "^1.5.0",
"stylelint-config-standard": "^36.0.1",
"stylelint-order": "^6.0.4",
"tailwindcss": "^4.1.13",
"terser": "^5.43.1",
"typescript": "^4.8.4",
"unplugin-vue-components": "^0.24.1",
@@ -93,6 +101,7 @@
"vite-plugin-eslint": "^1.8.1",
"vite-plugin-imagemin": "^0.6.1",
"vite-svg-loader": "^3.6.0",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^1.0.14"
},
"engines": {
+1368 -1114
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
+54 -9
View File
@@ -39,6 +39,7 @@ import {
CreateTemplateSettingParam,
ContractWordFormParam,
ContractWordSettingListRes,
SettingContractFileInfoRes,
} from '@/types/setting';
import { PaginationParams, TenantDictionaryType } from '@/types/comm';
import { HttpResponse } from './interceptor';
@@ -346,7 +347,7 @@ export function getCodeSettingList(
return axios.post(`contractSetting/getCodeSettingList`, params);
}
// 合同设置-合同条款设置-全部启用列表
export function getAllTermsSettingList(): Promise<SettingContractNumbeRes> {
export function getAllTermsSettingList(): Promise<any> {
return axios.get(`contractSetting/getAllTermsSettingList`);
}
// 合同设置-合同条款设置-新增
@@ -416,6 +417,10 @@ export function getUseFeeList(
): Promise<FeeManagementListRes> {
return axios.post(`financeSetting/getUseFeeList`, params);
}
// 合同设置-流水账户管理--获取下拉列表
export function getUseTurnoverAccountList(params: any): Promise<any> {
return axios.get(`financeSetting/getUseTurnoverAccountList`, params);
}
// 合同设置-合同模板管理-全部启用列表
export function getAllTemplateSettingList(): Promise<SettingContractNumbeRes> {
@@ -428,19 +433,15 @@ export function createTemplateSetting(
return axios.post(`contractSetting/createTemplateSetting`, params);
}
// 合同设置-合同模板管理-编辑
export function updateTemplateSetting(
params: CreateTemplateSettingParam
): Promise<HttpResponse> {
return axios.post(`contractSetting/updateTemplateSetting`, params);
export function updateTemplateSetting(params: any): Promise<HttpResponse> {
return axios.post(`contractSetting/saveTemplateSetting`, params);
}
// 合同设置-合同模板管理-删除
export function delTemplateSetting(id: number): Promise<HttpResponse> {
return axios.get(`contractSetting/delTemplateSetting?id=${id}`);
}
// 合同设置-合同模板管理-单个详情
export function getTemplateSettingFromId(
id: number
): Promise<SettingContractNumbeRes> {
export function getTemplateSettingFromId(id: number): Promise<any> {
return axios.get(`contractSetting/getTemplateSetting?id=${id}`);
}
// 合同设置-合同模板管理-启用/禁用
@@ -479,7 +480,7 @@ export function delWordSetting(id: number): Promise<HttpResponse> {
// 合同设置-合同文档模板管理-单个详情
export function getWordSettingFromId(
id: number
): Promise<SettingContractNumbeRes> {
): Promise<SettingContractFileInfoRes> {
return axios.get(`contractSetting/getWordSetting?id=${id}`);
}
// 合同设置-合同文档模板管理-启用/禁用
@@ -494,3 +495,47 @@ export function getWordSettingList(
): Promise<ContractWordSettingListRes> {
return axios.post(`contractSetting/getWordSettingList`, params);
}
// 合同标签列表(已勾选)
export function getContractTagsList(): Promise<any> {
return axios.get(`dictionary/getContractTagsList`);
}
// 数据字典-合同管理-合同标签列表(全部)
export function getAllContractTagsList(): Promise<any> {
return axios.get(`dictionary/getAllContractTagsList`);
}
// 数据字典-合同管理-创建合同标签
export function createContractTags(
params: DictionaryForm
): Promise<HttpResponse> {
return axios.post(`dictionary/createContractTags`, params);
}
// 数据字典-合同管理-更新合同标签
export function updateContractTags(
params: DictionaryForm
): Promise<HttpResponse> {
return axios.post(`dictionary/updateContractTags`, params);
}
// 数据字典-合同管理-删除合同标签
export function delContractTags(
id: number
): Promise<SettingProjectProvinceRes> {
return axios.get(`dictionary/delContractTags?id=${id}`);
}
// 数据字典-合同管理-禁用/启用 合同标签(单个)
export function checkContractTags(
params: CheckProjectParam
): Promise<HttpResponse> {
return axios.post(`dictionary/checkContractTags`, params);
}
// 数据字典-合同管理-禁用/启用 合同标签(全选)
export function checkAllContractTags(
params: CheckProjectParam
): Promise<HttpResponse> {
return axios.post(`dictionary/checkAllContractTags`, params);
}
// 数据字典-合同管理-单个合同标签信息
export function getContractTags(
id: number
): Promise<SettingProjectProvinceRes> {
return axios.get(`dictionary/getContractTags?id=${id}`);
}
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+431
View File
@@ -0,0 +1,431 @@
<template>
<div class="onlyoffice-editor">
<div v-if="loading" class="loading-container">
<a-spin size="large" />
<p class="mt-4">正在加载文档编辑器...</p>
</div>
<div v-else-if="error" class="error-container">
<a-alert
type="error"
:message="error"
show-icon
closable
@close="error = ''"
/>
</div>
<div
v-else
id="onlyoffice-editor"
ref="editorContainer"
class="editor-container"
>
<!-- 临时测试显示 -->
<div class="test-editor-placeholder">
<div class="editor-header">
<h3>{{ props.documentTitle }}</h3>
<div class="editor-actions">
<a-button type="primary" size="small">保存</a-button>
<a-button size="small">关闭</a-button>
</div>
</div>
<div class="editor-content">
<div class="document-info">
<p><strong>文档类型:</strong> {{ props.documentType }}</p>
<p><strong>文档地址:</strong> {{ props.documentUrl }}</p>
<p><strong>编辑模式:</strong> {{ props.mode }}</p>
<p><strong>可编辑:</strong> {{ props.editable ? '是' : '否' }}</p>
</div>
<div class="editor-mock">
<div class="toolbar">
<a-button size="small">文件</a-button>
<a-button size="small">编辑</a-button>
<a-button size="small">视图</a-button>
<a-button size="small">插入</a-button>
<a-button size="small">格式</a-button>
</div>
<div class="document-area">
<p>这里是文档编辑区域</p>
<p>OnlyOffice 编辑器将在这里显示</p>
<p>当前文档: {{ props.documentTitle }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { Message } from '@arco-design/web-vue';
interface Props {
documentUrl: string;
documentTitle?: string;
documentType?: 'word' | 'excel' | 'powerpoint';
height?: string;
width?: string;
editable?: boolean;
mode?: 'edit' | 'view';
}
const props = withDefaults(defineProps<Props>(), {
documentTitle: '文档',
documentType: 'word',
height: '600px',
width: '100%',
editable: true,
mode: 'edit',
});
const emit = defineEmits<{
ready: [];
error: [error: string];
save: [data: any];
close: [];
}>();
const editorContainer = ref<HTMLElement>();
const loading = ref(true);
const error = ref('');
let docEditor: any = null;
// 动态加载 OnlyOffice API
const loadOnlyOfficeAPI = (): Promise<void> => {
return new Promise((resolve, reject) => {
if (typeof (window as any).DocsAPI !== 'undefined') {
resolve();
return;
}
const script = document.createElement('script');
// 使用多个备选 API 地址
const apiUrls = [
'https://documentserver/web-apps/apps/api/documents/api.js', // 本地 Document Server
'https://api.onlyoffice.com/editors/latest/web-apps/apps/api/documents/api.js', // 官方 API
'https://documentserver.onlyoffice.com/web-apps/apps/api/documents/api.js', // 备用地址
];
let currentIndex = 0;
const tryLoadScript = () => {
if (currentIndex >= apiUrls.length) {
reject(new Error('所有 OnlyOffice API 地址都无法访问'));
return;
}
script.src = apiUrls[currentIndex];
script.onload = () => {
console.log(`OnlyOffice API 加载成功: ${apiUrls[currentIndex]}`);
resolve();
};
script.onerror = () => {
console.warn(`OnlyOffice API 加载失败: ${apiUrls[currentIndex]}`);
currentIndex++;
tryLoadScript();
};
document.head.appendChild(script);
};
tryLoadScript();
});
};
// 获取文档类型对应的文件扩展名
const getFileExtension = (type: string) => {
const extensions = {
word: '.docx',
excel: '.xlsx',
powerpoint: '.pptx',
};
return extensions[type as keyof typeof extensions] || '.docx';
};
// 生成文档唯一标识
function generateDocumentKey() {
return 'doc_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
}
// 编辑器事件处理
function onAppReady() {
console.log('OnlyOffice 编辑器已准备就绪');
loading.value = false;
emit('ready');
}
function onDocumentStateChange(event: any) {
console.log('文档状态变化:', event);
if (event.data === 1) {
// 文档正在编辑
Message.info('文档正在编辑中...');
} else if (event.data === 2) {
// 文档已保存
Message.success('文档已保存');
emit('save', event);
}
}
function onRequestSaveAs(event: any) {
console.log('请求另存为:', event);
// 处理另存为请求
}
function onError(event: any) {
console.error('OnlyOffice 错误:', event);
error.value = event.data || '编辑器发生错误';
emit('error', error.value);
}
function onWarning(event: any) {
console.warn('OnlyOffice 警告:', event);
}
function onRequestClose() {
console.log('请求关闭编辑器');
emit('close');
}
// 销毁编辑器
function destroyEditor() {
if (docEditor) {
try {
docEditor.destroyEditor();
} catch (err) {
console.error('销毁编辑器失败:', err);
}
docEditor = null;
}
}
// 初始化 OnlyOffice 编辑器
async function initEditor() {
try {
loading.value = true;
error.value = '';
if (!props.documentUrl) {
throw new Error('文档 URL 不能为空');
}
// 检查是否已经加载了 OnlyOffice API
if (typeof (window as any).DocsAPI === 'undefined') {
// 动态加载 OnlyOffice API
try {
await loadOnlyOfficeAPI();
} catch (apiError) {
console.warn('OnlyOffice API 加载失败,显示测试界面:', apiError);
// API 加载失败时,直接显示测试界面
loading.value = false;
return;
}
}
// 编辑器配置
const config = {
width: props.width,
height: props.height,
type: props.mode === 'edit' ? 'desktop' : 'embedded',
documentType: props.documentType,
document: {
fileType: getFileExtension(props.documentType),
key: generateDocumentKey(),
title: props.documentTitle,
url: props.documentUrl,
permissions: {
edit: props.editable && props.mode === 'edit',
download: true,
print: true,
review: props.mode === 'edit',
},
},
editorConfig: {
mode: props.mode === 'edit' ? 'edit' : 'view',
lang: 'zh-CN',
location: 'zh-CN',
customization: {
autosave: true,
forcesave: true,
chat: false,
comments: props.mode === 'edit',
help: true,
hideRightMenu: false,
hideRulers: false,
integrationMode: 'embedded',
zoom: 100,
uiTheme: 'theme-classic-light',
},
callbackUrl: '', // 如果需要回调,可以设置服务器回调地址
user: {
id: 'user-1',
name: '用户',
},
},
events: {
onAppReady: onAppReady,
onDocumentStateChange: onDocumentStateChange,
onRequestSaveAs: onRequestSaveAs,
onError: onError,
onWarning: onWarning,
onRequestClose: onRequestClose,
},
};
// 创建编辑器实例
docEditor = new (window as any).DocsAPI.DocEditor(
'onlyoffice-editor',
config
);
} catch (err) {
console.error('OnlyOffice 初始化失败:', err);
error.value = err instanceof Error ? err.message : '编辑器初始化失败';
loading.value = false;
}
}
// 监听文档 URL 变化
watch(
() => props.documentUrl,
(newUrl) => {
if (newUrl && editorContainer.value) {
destroyEditor();
nextTick(() => {
initEditor();
});
}
},
{ immediate: false }
);
// 组件挂载时初始化
onMounted(() => {
if (props.documentUrl) {
initEditor();
} else {
loading.value = false;
error.value = '请提供文档 URL';
}
});
// 组件卸载时清理
onUnmounted(() => {
destroyEditor();
});
// 暴露方法给父组件
defineExpose({
destroyEditor,
getEditor: () => docEditor,
});
</script>
<style scoped>
.onlyoffice-editor {
width: 100%;
height: 100%;
border: 1px solid #e5e6eb;
border-radius: 6px;
overflow: hidden;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
color: #86909c;
}
.error-container {
padding: 20px;
}
.editor-container {
width: 100%;
height: 100%;
min-height: 400px;
}
#onlyoffice-editor {
width: 100% !important;
height: 100% !important;
}
/* 测试编辑器样式 */
.test-editor-placeholder {
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e5e6eb;
background: #f7f8fa;
}
.editor-header h3 {
margin: 0;
font-size: 16px;
color: #1d2129;
}
.editor-actions {
display: flex;
gap: 8px;
}
.editor-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.document-info {
padding: 16px;
background: #f7f8fa;
border-bottom: 1px solid #e5e6eb;
font-size: 14px;
}
.document-info p {
margin: 4px 0;
color: #4e5969;
}
.editor-mock {
flex: 1;
display: flex;
flex-direction: column;
}
.toolbar {
display: flex;
gap: 8px;
padding: 8px 16px;
background: #f7f8fa;
border-bottom: 1px solid #e5e6eb;
}
.document-area {
flex: 1;
padding: 20px;
background: #fff;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
color: #86909c;
}
.document-area p {
margin: 8px 0;
font-size: 14px;
}
</style>
+2
View File
@@ -13,6 +13,7 @@ import Chart from './chart/index.vue';
import Breadcrumb from './breadcrumb/index.vue';
import DataTable from './common/DataTable.vue';
import ImageUpload from './common/ImageUpload.vue';
import OnlyOfficeEditor from './common/OnlyOfficeEditor.vue';
// Manually introduce ECharts modules to reduce packing size
@@ -35,5 +36,6 @@ export default {
Vue.component('Breadcrumb', Breadcrumb);
Vue.component('DataTable', DataTable);
Vue.component('ImageUpload', ImageUpload);
Vue.component('OnlyOfficeEditor', OnlyOfficeEditor);
},
};
+11 -146
View File
@@ -1,158 +1,23 @@
<script lang="tsx">
import { defineComponent, ref, h, compile, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter, RouteRecordRaw } from 'vue-router';
import type { RouteMeta } from 'vue-router';
import { useAppStore } from '@/store';
import { listenerRouteChange } from '@/utils/route-listener';
import { openWindow, regexUrl } from '@/utils';
import useMenuTree from './use-menu-tree';
<!-- src/components/menu/index.vue -->
<template>
<!-- 直接使用 TSX 组件,与普通 Vue 组件无区别 -->
<MenuContent />
</template>
export default defineComponent({
emit: ['collapse'],
setup() {
const { t } = useI18n();
const appStore = useAppStore();
const router = useRouter();
const route = useRoute();
const { menuTree } = useMenuTree();
const collapsed = computed({
get() {
if (appStore.device === 'desktop') return appStore.menuCollapse;
return false;
},
set(value: boolean) {
appStore.updateSettings({ menuCollapse: value });
},
});
const topMenu = computed(() => appStore.topMenu);
const openKeys = ref<string[]>([]);
const selectedKey = ref<string[]>([]);
const goto = (item: RouteRecordRaw) => {
// Open external link
if (regexUrl.test(item.path)) {
openWindow(item.path);
selectedKey.value = [item.name as string];
return;
}
// Eliminate external link side effects
const { hideInMenu, activeMenu } = item.meta as RouteMeta;
if (route.name === item.name && !hideInMenu && !activeMenu) {
selectedKey.value = [item.name as string];
return;
}
// Trigger router change
router.push({
name: item.name,
});
};
const findMenuOpenKeys = (target: string) => {
const result: string[] = [];
let isFind = false;
const backtrack = (item: RouteRecordRaw, keys: string[]) => {
if (item.name === target) {
isFind = true;
result.push(...keys);
return;
}
if (item.children?.length) {
item.children.forEach((el) => {
backtrack(el, [...keys, el.name as string]);
});
}
};
menuTree.value.forEach((el: RouteRecordRaw) => {
if (isFind) return; // Performance optimization
backtrack(el, [el.name as string]);
});
return result;
};
listenerRouteChange((newRoute) => {
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
if (requiresAuth && (!hideInMenu || activeMenu)) {
const menuOpenKeys = findMenuOpenKeys(
(activeMenu || newRoute.name) as string
);
const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
openKeys.value = [...keySet];
// 修复选中逻辑:优先使用 activeMenu,否则使用当前路由名称
const targetKey = activeMenu || newRoute.name;
if (targetKey) {
selectedKey.value = [targetKey as string];
}
}
}, true);
const setCollapse = (val: boolean) => {
if (appStore.device === 'desktop')
appStore.updateSettings({ menuCollapse: val });
};
const renderSubMenu = () => {
function travel(_route: RouteRecordRaw[], nodes = []) {
if (_route) {
_route.forEach((element) => {
// This is demo, modify nodes as needed
const icon = element?.meta?.icon
? () => h(compile(`<${element?.meta?.icon}/>`))
: null;
const node =
element?.children && element?.children.length !== 0 ? (
<a-sub-menu
key={element?.name}
v-slots={{
icon,
title: () => h(compile(t(element?.meta?.locale || ''))),
}}
>
{travel(element?.children)}
</a-sub-menu>
) : (
<a-menu-item
key={element?.name}
v-slots={{ icon }}
onClick={() => goto(element)}
>
{t(element?.meta?.locale || '')}
</a-menu-item>
);
nodes.push(node as never);
});
}
return nodes;
}
return travel(menuTree.value);
};
return () => (
<a-menu
mode={topMenu.value ? 'horizontal' : 'vertical'}
v-model:collapsed={collapsed.value}
v-model:open-keys={openKeys.value}
show-collapse-button={appStore.device !== 'mobile'}
auto-open={true}
selected-keys={selectedKey.value}
auto-open-selected={true}
level-indent={34}
style="height: 100%;width:100%;"
onCollapse={setCollapse}
>
{renderSubMenu()}
</a-menu>
);
},
});
<!-- 替换原 TSX 脚本,改为导入逻辑 -->
<script setup lang="ts">
// 导入独立的 TSX 组件
import MenuContent from './menu';
</script>
<!-- 保留原有的样式,无需修改 -->
<style lang="less" scoped>
:deep(.arco-menu-inner) {
.arco-menu-inline-header {
display: flex;
align-items: center;
}
.arco-icon {
&:not(.arco-icon-down) {
font-size: 18px;
+180
View File
@@ -0,0 +1,180 @@
// src/components/menu/MenuContent.tsx
import { defineComponent, ref, h, compile, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter, RouteRecordRaw } from 'vue-router';
import type { RouteMeta } from 'vue-router';
import { useAppStore } from '@/store';
import { listenerRouteChange } from '@/utils/route-listener';
import { openWindow, regexUrl } from '@/utils';
import useMenuTree from './use-menu-tree';
// 直接导出 TSX 组件(无需 .vue 包裹)
export default defineComponent({
setup() {
const { t } = useI18n();
const appStore = useAppStore();
const router = useRouter();
const route = useRoute();
const { menuTree } = useMenuTree();
const collapsed = computed({
get() {
if (appStore.device === 'desktop') return appStore.menuCollapse;
return false;
},
set(value: boolean) {
appStore.updateSettings({ menuCollapse: value });
},
});
const topMenu = computed(() => appStore.topMenu);
const openKeys = ref<string[]>([]);
const selectedKey = ref<string[]>([]);
const goto = (item: RouteRecordRaw) => {
if (regexUrl.test(item.path)) {
openWindow(item.path);
selectedKey.value = [item.name as string];
return;
}
if (item.redirect) {
router.push(item.redirect);
return;
}
const { hideInMenu, activeMenu } = item.meta as RouteMeta;
if (route.name === item.name && !hideInMenu && !activeMenu) {
selectedKey.value = [item.name as string];
return;
}
router.push({ name: item.name });
};
const findMenuOpenKeys = (target: string) => {
const result: string[] = [];
let isFind = false;
const backtrack = (item: RouteRecordRaw, keys: string[]) => {
if (item.name === target) {
isFind = true;
result.push(...keys);
return;
}
if (item.children?.length) {
item.children.forEach((el) =>
backtrack(el, [...keys, el.name as string])
);
}
};
menuTree.value.forEach((el: RouteRecordRaw) => {
if (isFind) return;
backtrack(el, [el.name as string]);
});
return result;
};
listenerRouteChange((newRoute) => {
const { requiresAuth, activeMenu, hideInMenu } = newRoute.meta;
if (requiresAuth && (!hideInMenu || activeMenu)) {
const menuOpenKeys = findMenuOpenKeys(
(activeMenu || newRoute.name) as string
);
const keySet = new Set([...menuOpenKeys, ...openKeys.value]);
openKeys.value = [...keySet];
let targetKey = activeMenu || newRoute.name;
if (targetKey && !activeMenu) {
const findParentWithHiddenChildren = (
routes: RouteRecordRaw[],
targetName: string
): string | null => {
for (const route of routes) {
if (route.children) {
const hasTargetChild = route.children.some(
(child) =>
child.name === targetName ||
(child.children &&
findParentWithHiddenChildren([child], targetName))
);
if (hasTargetChild && route.meta?.hideChildrenInMenu) {
return route.name as string;
}
const parentResult = findParentWithHiddenChildren(
route.children,
targetName
);
if (parentResult) return parentResult;
}
}
return null;
};
const parentWithHiddenChildren = findParentWithHiddenChildren(
menuTree.value,
targetKey as string
);
if (parentWithHiddenChildren) targetKey = parentWithHiddenChildren;
}
if (targetKey) selectedKey.value = [targetKey as string];
}
}, true);
const setCollapse = (val: boolean) => {
if (appStore.device === 'desktop')
appStore.updateSettings({ menuCollapse: val });
};
const renderSubMenu = () => {
function travel(_route: RouteRecordRaw[], nodes = []) {
if (_route) {
_route.forEach((element) => {
const icon = element?.meta?.icon
? () => h(compile(`<${element?.meta?.icon}/>`))
: null;
const hideChildrenInMenu = element?.meta?.hideChildrenInMenu;
const hasChildren =
element?.children && element?.children.length !== 0;
const node =
hasChildren && !hideChildrenInMenu ? (
<a-sub-menu
key={element?.name}
v-slots={{
icon,
title: () => h(compile(t(element?.meta?.locale || ''))),
}}
>
{element?.children ? travel(element?.children) : null}
</a-sub-menu>
) : (
<a-menu-item
key={element?.name}
v-slots={{ icon }}
onClick={() => goto(element)}
>
{t(element?.meta?.locale || '')}
</a-menu-item>
);
nodes.push(node as never);
});
}
return nodes;
}
return travel(menuTree.value);
};
// 返回 TSX 渲染函数(直接渲染菜单)
return () => (
<a-menu
mode={topMenu.value ? 'horizontal' : 'vertical'}
v-model:collapsed={collapsed.value}
v-model:open-keys={openKeys.value}
show-collapse-button={appStore.device !== 'mobile'}
auto-open={true}
selected-keys={selectedKey.value}
auto-open-selected={true}
level-indent={34}
style="height: 100%;width:100%;"
onCollapse={setCollapse}
>
{renderSubMenu()}
</a-menu>
);
},
});
+1 -1
View File
@@ -2,7 +2,7 @@
declare module '*.vue' {
import { DefineComponent } from 'vue';
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>;
export default component;
}
+7 -6
View File
@@ -5,17 +5,17 @@ export default function usePermission() {
const userStore = useUserStore();
return {
accessRouter(route: RouteLocationNormalized | RouteRecordRaw) {
return true
return true;
// 如果路由不需要认证,直接允许访问
// if (!route.meta?.requiresAuth) {
// return true;
// }
// // 如果用户没有权限数据,拒绝访问
// if (!userStore.permission || userStore.permission.length === 0) {
// return false;
// }
// // 检查用户是否有该路由的权限
// return userStore.permission.some(
// (p: any) =>
@@ -29,14 +29,15 @@ export default function usePermission() {
if (!permissions || permissions.length === 0) {
return null;
}
const cloneRouters = [..._routers];
while (cloneRouters.length) {
const firstElement = cloneRouters.shift();
if (
permissions.some(
(p: any) => p.perm_key === firstElement.meta?.permissionKey && p.visible === 1
(p: any) =>
p.perm_key === firstElement.meta?.permissionKey && p.visible === 1
)
) {
return { name: firstElement.name };
-1
View File
@@ -9,7 +9,6 @@ import locale403 from '@/views/exception/403/locale/en-US';
import locale404 from '@/views/exception/404/locale/en-US';
import locale500 from '@/views/exception/500/locale/en-US';
import localeSettings from './en-US/settings';
export default {
+1 -1
View File
@@ -12,7 +12,7 @@ import App from './App.vue';
// https://arco.design/docs/designlab/use-theme-package
import '@/assets/style/global.less';
import '@/api/interceptor';
import '@/assets/style/index.css';
const app = createApp(App);
app.use(ArcoVue, {});
-1
View File
@@ -41,7 +41,6 @@ export default function setupPermissionGuard(router: Router) {
next();
} else next(NOT_FOUND);
} else {
// eslint-disable-next-line no-lonely-if
if (permissionsAllow) next();
else {
const destination =
+2 -2
View File
@@ -5,13 +5,13 @@ const RESULT: AppRouteRecordRaw = {
path: '/result',
name: 'result',
component: DEFAULT_LAYOUT,
redirect:'/result/success',
redirect: '/result/success',
meta: {
locale: 'menu.result',
icon: 'icon-check-circle',
requiresAuth: true,
order: 5,
isFirst:true,
isFirst: true,
},
children: [
{
+2 -2
View File
@@ -5,13 +5,13 @@ const USER: AppRouteRecordRaw = {
path: '/user',
name: 'user',
component: DEFAULT_LAYOUT,
redirect:'/user/info',
redirect: '/user/info',
meta: {
locale: 'menu.user',
icon: 'icon-user',
requiresAuth: true,
order: 7,
isFirst:true,
isFirst: true,
},
children: [
{
@@ -5,13 +5,13 @@ const VISUALIZATION: AppRouteRecordRaw = {
path: '/visualization',
name: 'visualization',
component: DEFAULT_LAYOUT,
redirect:'/visualization/data-analysis',
redirect: '/visualization/data-analysis',
meta: {
locale: 'menu.visualization',
requiresAuth: true,
icon: 'icon-apps',
order: 1,
isFirst:true,
isFirst: true,
},
children: [
{
+1 -1
View File
@@ -11,7 +11,7 @@ const PROPERTY: AppRouteRecordRaw = {
requiresAuth: true,
icon: 'icon-list',
order: 4,
isFirst:true,
isFirst: true,
},
children: [
{
+44 -2
View File
@@ -118,13 +118,55 @@ const SETTING: AppRouteRecordRaw = {
{
path: 'contract',
name: 'system-setting-contract',
component: () =>
import('@/views/setting/system/contract-settings/index.vue'),
redirect: '/setting/system/contract/setting',
component: PageLayout,
meta: {
locale: 'setting.contract',
requiresAuth: true,
roles: ['*'],
hideChildrenInMenu: true,
},
children: [
{
path: 'setting',
name: 'contract-setting',
component: () =>
import('@/views/setting/system/contract-settings/index.vue'),
meta: {
locale: 'setting.contract',
requiresAuth: true,
roles: ['*'],
},
},
// 合同设置-合同文档模板设置-编辑
{
path: 'file-edit',
name: 'system-setting-contract-file-edit',
component: () =>
import(
'@/views/setting/system/contract-settings/contract-file-edit.vue'
),
meta: {
locale: 'setting.contract',
requiresAuth: true,
roles: ['*'],
},
},
// 合同设置-合同模板设置-编辑
{
path: 'template-edit',
name: 'system-setting-contract-template-edit',
component: () =>
import(
'@/views/setting/system/contract-settings/contract-template-edit.vue'
),
meta: {
locale: 'setting.contract',
requiresAuth: true,
roles: ['*'],
},
},
],
},
// 财务设置
{
+3
View File
@@ -413,3 +413,6 @@ export type ContractWordSettingListRes = HttpResponse<{
contractWordSetting: ContractWordItem[];
page?: PaginationParams & PaginationResult;
}>;
export type SettingContractFileInfoRes = HttpResponse<{
contractWordSetting: CreateFileSettingParam;
}>;
+1 -2
View File
@@ -1,2 +1 @@
import axios, { AxiosError } from 'axios'
import axios, { AxiosError } from 'axios';
+1 -1
View File
@@ -13,7 +13,7 @@ export function isString(obj: any): obj is string {
}
export function isNumber(obj: any): obj is number {
return opt.call(obj) === '[object Number]' && obj === obj; // eslint-disable-line
return opt.call(obj) === '[object Number]' && !Number.isNaN(obj);
}
export function isRegExp(obj: any) {
@@ -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();
+34
View File
@@ -0,0 +1,34 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./index.html',
'./src/**/*.{vue,js,ts,jsx,tsx}', // 扫描所有 Vue/TS/JS 文件,保证类名能被提取
],
theme: {
extend: {
colors: {
// 让 Tailwind 能直接用 Arco Design 的主题变量
primary: 'var(--color-primary)',
success: 'var(--color-success)',
warning: 'var(--color-warning)',
danger: 'var(--color-danger)',
},
borderRadius: {
// 覆盖 Tailwind 的圆角,用 Arco token
lg: 'var(--border-radius-medium)',
xl: 'var(--border-radius-large)',
},
fontSize: {
// 对齐 Arco Design 的字体大小
sm: 'var(--font-size-small)',
base: 'var(--font-size-body-3)',
lg: 'var(--font-size-title-2)',
},
},
},
darkMode: 'class', // 跟 Arco 的暗黑模式配合
plugins: [],
};
export default config;