签章位置固定功能
This commit is contained in:
@@ -106,6 +106,44 @@ export function finishLaunchSeal(id, data) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initLaunchSealPosition(id) {
|
||||||
|
return request({
|
||||||
|
url: `/contract/launch/${id}/seal-position/init`,
|
||||||
|
method: "post",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLaunchSealPositionPage(id) {
|
||||||
|
return request({
|
||||||
|
url: `/contract/launch/${id}/seal-position/list`,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveLaunchSealPositions(id, data = {}) {
|
||||||
|
return request({
|
||||||
|
url: `/contract/launch/${id}/seal-position/save`,
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function batchUpdateLaunchSealPositionStatus(id, data) {
|
||||||
|
return request({
|
||||||
|
url: `/contract/launch/${id}/seal-position/batch-status`,
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkLaunchSealPositions(id, data = {}) {
|
||||||
|
return request({
|
||||||
|
url: `/contract/launch/${id}/seal-position/check`,
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function queryApproveViewUrl(data) {
|
export function queryApproveViewUrl(data) {
|
||||||
return request({
|
return request({
|
||||||
url: "/contract/push/settlement/approve/view-url",
|
url: "/contract/push/settlement/approve/view-url",
|
||||||
|
|||||||
+9
-1
@@ -175,7 +175,7 @@ export function downloadContract(contractId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// 回调轮询
|
// 回调轮询 / 测试回调
|
||||||
// ========================================
|
// ========================================
|
||||||
export function pollCallback(requestId) {
|
export function pollCallback(requestId) {
|
||||||
return request({
|
return request({
|
||||||
@@ -183,3 +183,11 @@ export function pollCallback(requestId) {
|
|||||||
method: 'get',
|
method: 'get',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mockPreSignSuccessCallback(data) {
|
||||||
|
return request({
|
||||||
|
url: '/seal/callback/presign/mock-success',
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,598 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
v-model:visible="visible"
|
||||||
|
centered
|
||||||
|
title="公路运输产品新增"
|
||||||
|
width="800px"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
@ok="handleOk"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formState"
|
||||||
|
:label-col="{ span: 8 }"
|
||||||
|
:wrapper-col="{ span: 16 }"
|
||||||
|
:rules="rules"
|
||||||
|
>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="短驳类型" name="busType">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formState.busType"
|
||||||
|
placeholder="请选择短驳类型"
|
||||||
|
allow-clear
|
||||||
|
>
|
||||||
|
<a-select-option value="集装箱">集装箱</a-select-option>
|
||||||
|
<a-select-option value="散杂货">散杂货</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="计价方式" name="priceType">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formState.priceType"
|
||||||
|
placeholder="请选择计价方式"
|
||||||
|
allow-clear
|
||||||
|
>
|
||||||
|
<a-select-option value="1">一口价</a-select-option>
|
||||||
|
<a-select-option value="2">阶梯价</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- 一口价时显示起运地类型和目的地类型 -->
|
||||||
|
<template v-if="formState.priceType === '1'">
|
||||||
|
<!-- 起运地类型(前端UI控制字段) -->
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="起运地类型" name="fromLocUIType">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formState.fromLocUIType"
|
||||||
|
placeholder="请选择起运地类型"
|
||||||
|
allow-clear
|
||||||
|
@change="handleFromLocTypeChange"
|
||||||
|
>
|
||||||
|
<a-select-option value="railway">铁路场站</a-select-option>
|
||||||
|
<a-select-option value="dock">码头</a-select-option>
|
||||||
|
<a-select-option value="door">门点地址</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- 目的地类型(前端UI控制字段) -->
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="目的地类型" name="toLocUIType">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formState.toLocUIType"
|
||||||
|
placeholder="请选择目的地类型"
|
||||||
|
allow-clear
|
||||||
|
@change="handleToLocTypeChange"
|
||||||
|
>
|
||||||
|
<a-select-option value="railway">铁路场站</a-select-option>
|
||||||
|
<a-select-option value="dock">码头</a-select-option>
|
||||||
|
<a-select-option value="door">门点地址</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- 起运地 -->
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="起运地" name="fromLoc">
|
||||||
|
<a-form-item-rest>
|
||||||
|
<div style="display: flex; align-items: center">
|
||||||
|
<!-- 铁路场站或码头:使用下拉选择 -->
|
||||||
|
<a-select
|
||||||
|
v-if="formState.fromLocUIType === 'railway' || formState.fromLocUIType === 'dock'"
|
||||||
|
v-model:value="formState.fromLoc"
|
||||||
|
:placeholder="formState.fromLocUIType === 'railway' ? '请选择铁路场站' : '请选择码头'"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
:filter-option="filterOption"
|
||||||
|
@change="handleFromStationSelect"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in fromStationOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<!-- 门点地址:使用搜索选择 -->
|
||||||
|
<a-select
|
||||||
|
v-else
|
||||||
|
v-model:value="formState.fromLoc"
|
||||||
|
show-search
|
||||||
|
placeholder="请输入地点"
|
||||||
|
:filter-option="false"
|
||||||
|
@search="handleLocationSearch"
|
||||||
|
@select="handleRealSelect"
|
||||||
|
@dropdownVisibleChange="handleDropdownHide"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in locationOptions"
|
||||||
|
:key="item.uid"
|
||||||
|
:value="item.title"
|
||||||
|
:data="item"
|
||||||
|
>
|
||||||
|
<div>{{ item.title }}</div>
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<!-- 只有门点地址才显示定位按钮 -->
|
||||||
|
<a-button
|
||||||
|
v-if="formState.fromLocUIType === 'door'"
|
||||||
|
@click="handleSelectCoordinates('from')"
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
style="margin-left: 8px"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<EnvironmentOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- 目的地 -->
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="目的地" name="toLoc">
|
||||||
|
<a-form-item-rest>
|
||||||
|
<div style="display: flex; align-items: center">
|
||||||
|
<!-- 铁路场站或码头:使用下拉选择 -->
|
||||||
|
<a-select
|
||||||
|
v-if="formState.toLocUIType === 'railway' || formState.toLocUIType === 'dock'"
|
||||||
|
v-model:value="formState.toLoc"
|
||||||
|
:placeholder="formState.toLocUIType === 'railway' ? '请选择铁路场站' : '请选择码头'"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
:filter-option="filterOption"
|
||||||
|
@change="handleToStationSelect"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in toStationOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<!-- 门点地址:使用下拉选择 -->
|
||||||
|
<a-select
|
||||||
|
v-else
|
||||||
|
v-model:value="formState.toLoc"
|
||||||
|
placeholder="请选择目的地"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
:filter-option="filterOption"
|
||||||
|
@change="handleDoorToChange"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in stationOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<!-- 只有门点地址才显示定位按钮 -->
|
||||||
|
<a-button
|
||||||
|
v-if="formState.toLocUIType === 'door'"
|
||||||
|
@click="handleSelectCoordinates('to')"
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
style="margin-left: 8px"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<EnvironmentOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- 价格 -->
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="价格(元)" name="basePrice">
|
||||||
|
<a-form-item-rest>
|
||||||
|
<div style="display: flex; align-items: center">
|
||||||
|
<a-input-number
|
||||||
|
style="flex: 1; margin-right: 10px"
|
||||||
|
v-model:value="formState.basePrice"
|
||||||
|
placeholder="请输入运价"
|
||||||
|
/>
|
||||||
|
<a-input disabled value="元" style="width: 100px" placeholder="单位" />
|
||||||
|
</div>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="运价备注" name="priceRemark">
|
||||||
|
<a-input
|
||||||
|
style="width: 100%"
|
||||||
|
v-model:value="formState.priceRemark"
|
||||||
|
placeholder="请输入运价备注"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="预计时长" name="estTime">
|
||||||
|
<a-form-item-rest>
|
||||||
|
<div style="display: flex; align-items: center">
|
||||||
|
<a-input-number
|
||||||
|
style="flex: 1; margin-right: 10px"
|
||||||
|
v-model:value="formState.estTime"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="请输入预计时长"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="formState.estTimeUnit"
|
||||||
|
style="width: 100px"
|
||||||
|
placeholder="单位"
|
||||||
|
>
|
||||||
|
<a-select-option value="D">天</a-select-option>
|
||||||
|
<a-select-option value="H">小时</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</div>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="揽货备注" name="cargoRemark">
|
||||||
|
<a-input v-model:value="formState.cargoRemark" placeholder="请输入揽货备注" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<gridientComp
|
||||||
|
v-if="formState.priceType === '2'"
|
||||||
|
v-model:modelValue="formState.elementTieredPriceList"
|
||||||
|
/>
|
||||||
|
</a-row>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<locationSelect ref="locationSelectRef" @location-selected="changeLocation" />
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, reactive, nextTick, watch } from 'vue';
|
||||||
|
import { FormInstance, message } from 'ant-design-vue';
|
||||||
|
import locationSelect from '../../../commonComponents/locationSelect.vue';
|
||||||
|
import { addTruckInfo, editTruckInfo, getTruckInfoById, getBaiduPlace } from '@/api/qcwl';
|
||||||
|
import * as commonApi from '@/api/common';
|
||||||
|
import type { RuleObject } from 'ant-design-vue/lib/form/interface';
|
||||||
|
import { noLoginlistStation } from '@/api/sysOrgRelation';
|
||||||
|
import { commonMixin } from '@/mixins/commonMixin';
|
||||||
|
import gridientComp from './gridientComp.vue';
|
||||||
|
|
||||||
|
const { filterOption } = commonMixin();
|
||||||
|
|
||||||
|
interface StationOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits(['refresh']);
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const locationSelectRef = ref();
|
||||||
|
const visible = ref(false);
|
||||||
|
|
||||||
|
// 起运地站点选项(铁路场站/码头)
|
||||||
|
const fromStationOptions = ref<StationOption[]>([]);
|
||||||
|
// 目的地站点选项(铁路场站/码头)
|
||||||
|
const toStationOptions = ref<StationOption[]>([]);
|
||||||
|
// 门点地址选项
|
||||||
|
const stationOptions = ref<StationOption[]>([]);
|
||||||
|
|
||||||
|
// 位置搜索选项
|
||||||
|
const locationOptions = ref<any[]>([]);
|
||||||
|
let realInputValue = '';
|
||||||
|
|
||||||
|
// 当前操作的坐标类型(用于定位回调)
|
||||||
|
let currentCoordType: 'from' | 'to' = 'from';
|
||||||
|
|
||||||
|
const formState = reactive<any>({
|
||||||
|
id: undefined,
|
||||||
|
busType: '集装箱',
|
||||||
|
// 后端接口字段(保持不变)
|
||||||
|
fromLocType: 'city',
|
||||||
|
toLocType: 'railway',
|
||||||
|
// 前端UI控制字段(不提交给后端)
|
||||||
|
fromLocUIType: 'door',
|
||||||
|
toLocUIType: 'door',
|
||||||
|
fromLoc: undefined,
|
||||||
|
toLoc: undefined,
|
||||||
|
basePrice: undefined,
|
||||||
|
priceRemark: undefined,
|
||||||
|
estTime: undefined,
|
||||||
|
estTimeUnit: 'D',
|
||||||
|
cargoRemark: undefined,
|
||||||
|
fromLocPosition: '',
|
||||||
|
toLocPosition: '',
|
||||||
|
priceType: '1',
|
||||||
|
elementTieredPriceList: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules: Record<string, RuleObject | RuleObject[]> = {
|
||||||
|
busType: [{ required: true, message: '请选择短驳类型' }],
|
||||||
|
fromLocUIType: [{ required: true, message: '请选择起运地类型' }],
|
||||||
|
toLocUIType: [{ required: true, message: '请选择目的地类型' }],
|
||||||
|
fromLoc: [{ required: true, message: '请选择起运地' }],
|
||||||
|
toLoc: [{ required: true, message: '请选择目的地' }],
|
||||||
|
basePrice: [{ required: true, message: '请输入运价' }],
|
||||||
|
estTime: [{ required: true, message: '请输入预计时长' }],
|
||||||
|
estTimeUnit: [{ required: true, message: '请选择预计时长单位' }],
|
||||||
|
priceType: [{ required: true, message: '请选择计价方式' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const showModal = (row?: any) => {
|
||||||
|
// 初始化站点选项
|
||||||
|
initStationOptions();
|
||||||
|
// 初始化门点地址选项
|
||||||
|
initDoorStationOptions();
|
||||||
|
// 清空位置搜索
|
||||||
|
locationOptions.value = [];
|
||||||
|
|
||||||
|
if (row && row.id) {
|
||||||
|
getTruckInfoById(row.id).then((res) => {
|
||||||
|
// 从后端获取数据,UI类型默认设置为door
|
||||||
|
Object.assign(formState, {
|
||||||
|
...res.result,
|
||||||
|
fromLocUIType: 'door',
|
||||||
|
toLocUIType: 'door',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.assign(formState, {
|
||||||
|
id: undefined,
|
||||||
|
busType: '集装箱',
|
||||||
|
fromLocType: 'city',
|
||||||
|
toLocType: 'railway',
|
||||||
|
fromLocUIType: 'door',
|
||||||
|
toLocUIType: 'door',
|
||||||
|
fromLoc: undefined,
|
||||||
|
toLoc: undefined,
|
||||||
|
basePrice: undefined,
|
||||||
|
priceRemark: undefined,
|
||||||
|
estTime: undefined,
|
||||||
|
estTimeUnit: 'D',
|
||||||
|
cargoRemark: undefined,
|
||||||
|
fromLocPosition: '',
|
||||||
|
toLocPosition: '',
|
||||||
|
priceType: '1',
|
||||||
|
elementTieredPriceList: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
visible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化站点选项(铁路场站和码头)- 占位数据
|
||||||
|
const initStationOptions = () => {
|
||||||
|
// 铁路场站占位数据(待接口对接)
|
||||||
|
const railwayStations = [
|
||||||
|
{ label: '上海铁路场站', value: 'shanghai_railway' },
|
||||||
|
{ label: '北京铁路场站', value: 'beijing_railway' },
|
||||||
|
{ label: '广州铁路场站', value: 'guangzhou_railway' },
|
||||||
|
{ label: '深圳铁路场站', value: 'shenzhen_railway' },
|
||||||
|
{ label: '成都铁路场站', value: 'chengdu_railway' },
|
||||||
|
{ label: '武汉铁路场站', value: 'wuhan_railway' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 码头占位数据(待接口对接)
|
||||||
|
const dockStations = [
|
||||||
|
{ label: '上海港码头', value: 'shanghai_dock' },
|
||||||
|
{ label: '宁波港码头', value: 'ningbo_dock' },
|
||||||
|
{ label: '深圳港码头', value: 'shenzhen_dock' },
|
||||||
|
{ label: '广州港码头', value: 'guangzhou_dock' },
|
||||||
|
{ label: '青岛港码头', value: 'qingdao_dock' },
|
||||||
|
{ label: '天津港码头', value: 'tianjin_dock' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 合并所有站点供选择(实际使用时可区分铁路和码头)
|
||||||
|
fromStationOptions.value = [...railwayStations, ...dockStations];
|
||||||
|
toStationOptions.value = [...railwayStations, ...dockStations];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化门点地址选项
|
||||||
|
const initDoorStationOptions = () => {
|
||||||
|
noLoginlistStation({}).then((res) => {
|
||||||
|
if (res.code !== '0000') {
|
||||||
|
return Promise.reject(res);
|
||||||
|
}
|
||||||
|
stationOptions.value = res.result.map((item: any) => ({
|
||||||
|
label: item,
|
||||||
|
value: item,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 起运地类型变更
|
||||||
|
const handleFromLocTypeChange = (val: string) => {
|
||||||
|
formState.fromLoc = undefined;
|
||||||
|
formState.fromLocPosition = '';
|
||||||
|
// 如果不是门点地址,清空搜索选项
|
||||||
|
if (val !== 'door') {
|
||||||
|
locationOptions.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 目的地类型变更
|
||||||
|
const handleToLocTypeChange = (val: string) => {
|
||||||
|
formState.toLoc = undefined;
|
||||||
|
formState.toLocPosition = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 起运地铁路/码头选择
|
||||||
|
const handleFromStationSelect = (val: string) => {
|
||||||
|
// 从站点列表中查找对应的经纬度
|
||||||
|
const found = fromStationOptions.value.find((item) => item.value === val);
|
||||||
|
if (found) {
|
||||||
|
// TODO: 从后端获取对应站点的经纬度,目前使用示例坐标
|
||||||
|
formState.fromLocPosition = '121.4737,31.2304';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 目的地铁路/码头选择
|
||||||
|
const handleToStationSelect = (val: string) => {
|
||||||
|
const found = toStationOptions.value.find((item) => item.value === val);
|
||||||
|
if (found) {
|
||||||
|
// TODO: 从后端获取对应站点的经纬度,目前使用示例坐标
|
||||||
|
formState.toLocPosition = '121.4737,31.2304';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 起运地门点地址搜索选择
|
||||||
|
const handleRealSelect = (selectedTitle: string, option: any) => {
|
||||||
|
const selectedItem = option.data;
|
||||||
|
formState.fromLocPosition = `${selectedItem.point.lng},${selectedItem.point.lat}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 起运地搜索
|
||||||
|
const handleLocationSearch = async (value: string) => {
|
||||||
|
realInputValue = value;
|
||||||
|
if (!value.trim()) {
|
||||||
|
locationOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
getBaiduPlace(value).then((res) => {
|
||||||
|
locationOptions.value = res.result.results.map((item: any) => ({
|
||||||
|
title: item.name,
|
||||||
|
point: { lat: item.location.lat, lng: item.location.lng },
|
||||||
|
uid: item.uid,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 起运地下拉隐藏
|
||||||
|
const handleDropdownHide = (visible: boolean) => {
|
||||||
|
if (!visible) {
|
||||||
|
nextTick(() => {
|
||||||
|
formState.fromLoc = realInputValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 目的地门点地址选择
|
||||||
|
const handleDoorToChange = (val: string) => {
|
||||||
|
if (val) {
|
||||||
|
// TODO: 根据实际数据获取经纬度
|
||||||
|
formState.toLocPosition = '121.4737,31.2304';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选择坐标(起运地或目的地)
|
||||||
|
const handleSelectCoordinates = (type: 'from' | 'to') => {
|
||||||
|
currentCoordType = type;
|
||||||
|
locationSelectRef.value.showModal(formState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 坐标选择回调
|
||||||
|
const changeLocation = (data: { lng: number; lat: number }) => {
|
||||||
|
const position = `${data.lng},${data.lat}`;
|
||||||
|
if (currentCoordType === 'from') {
|
||||||
|
formState.fromLocPosition = position;
|
||||||
|
} else {
|
||||||
|
formState.toLocPosition = position;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交前处理:将UI类型映射到后端字段
|
||||||
|
const prepareSubmitData = () => {
|
||||||
|
const submitData = { ...formState };
|
||||||
|
|
||||||
|
// 根据UI类型设置后端字段(如果需要映射)
|
||||||
|
// 注意:fromLocType 和 toLocType 保持后端支持的值
|
||||||
|
// 如果 UI 类型是 railway,可以设置 fromLocType = 'railway' 等
|
||||||
|
// 但根据你的说明,后端不支持其他值,所以保持原样
|
||||||
|
|
||||||
|
// 删除前端UI控制字段(不提交给后端)
|
||||||
|
delete submitData.fromLocUIType;
|
||||||
|
delete submitData.toLocUIType;
|
||||||
|
|
||||||
|
return submitData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
// 阶梯价校验
|
||||||
|
if (formState.priceType === '2') {
|
||||||
|
if (!formState.elementTieredPriceList || formState.elementTieredPriceList.length === 0) {
|
||||||
|
message.error('请设置梯度价格');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const invalidRows = formState.elementTieredPriceList
|
||||||
|
.map((item: any, index: number) => ({
|
||||||
|
index: index + 1,
|
||||||
|
hasError: !item.distance || !item.price,
|
||||||
|
}))
|
||||||
|
.filter((item: any) => item.hasError);
|
||||||
|
if (invalidRows.length > 0) {
|
||||||
|
message.error(`第 ${invalidRows.map((r: any) => r.index).join(', ')} 行的距离或价格未填写`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一口价校验
|
||||||
|
if (formState.priceType === '1') {
|
||||||
|
if (!formState.fromLocPosition) {
|
||||||
|
message.error('请选择起运地经纬度');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formState.toLocPosition) {
|
||||||
|
message.error('请选择目的地经纬度');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await formRef.value?.validateFields();
|
||||||
|
|
||||||
|
const submitData = prepareSubmitData();
|
||||||
|
|
||||||
|
if (formState.id) {
|
||||||
|
editTruckInfo(submitData).then((res) => {
|
||||||
|
if (res.code === '0000') {
|
||||||
|
message.success('更新成功');
|
||||||
|
emit('refresh');
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addTruckInfo(submitData).then((res) => {
|
||||||
|
if (res.code === '0000') {
|
||||||
|
message.success('新增成功');
|
||||||
|
emit('refresh');
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('验证失败:', error);
|
||||||
|
message.error('请检查表单数据');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ showModal });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,727 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
v-model:visible="visible"
|
||||||
|
centered
|
||||||
|
title="公路运输产品新增"
|
||||||
|
width="800px"
|
||||||
|
@cancel="handleCancel"
|
||||||
|
@ok="handleOk"
|
||||||
|
>
|
||||||
|
<a-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formState"
|
||||||
|
:label-col="{ span: 8 }"
|
||||||
|
:wrapper-col="{ span: 16 }"
|
||||||
|
:rules="rules"
|
||||||
|
>
|
||||||
|
<a-row :gutter="16">
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="短驳类型" name="busType">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formState.busType"
|
||||||
|
placeholder="请选择短驳类型"
|
||||||
|
allow-clear
|
||||||
|
>
|
||||||
|
<a-select-option value="集装箱">集装箱</a-select-option>
|
||||||
|
<a-select-option value="散杂货">散杂货</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="计价方式" name="priceType">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formState.priceType"
|
||||||
|
placeholder="请选择计价方式"
|
||||||
|
allow-clear
|
||||||
|
>
|
||||||
|
<a-select-option value="1">一口价</a-select-option>
|
||||||
|
<a-select-option value="2">阶梯价</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- 一口价时显示起运地类型和目的地类型 -->
|
||||||
|
<template v-if="formState.priceType === '1'">
|
||||||
|
<!-- 起运地类型(前端UI控制字段) -->
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="起运地类型" name="fromLocUIType">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formState.fromLocUIType"
|
||||||
|
placeholder="请选择起运地类型"
|
||||||
|
allow-clear
|
||||||
|
@change="handleFromLocTypeChange"
|
||||||
|
>
|
||||||
|
<a-select-option value="railway">铁路场站</a-select-option>
|
||||||
|
<a-select-option value="dock">码头</a-select-option>
|
||||||
|
<a-select-option value="door">门点地址</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- 目的地类型(前端UI控制字段) -->
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="目的地类型" name="toLocUIType">
|
||||||
|
<a-select
|
||||||
|
v-model:value="formState.toLocUIType"
|
||||||
|
placeholder="请选择目的地类型"
|
||||||
|
allow-clear
|
||||||
|
@change="handleToLocTypeChange"
|
||||||
|
>
|
||||||
|
<a-select-option value="railway">铁路场站</a-select-option>
|
||||||
|
<a-select-option value="dock">码头</a-select-option>
|
||||||
|
<a-select-option value="door">门点地址</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- 起运地 -->
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="起运地" name="fromLoc">
|
||||||
|
<a-form-item-rest>
|
||||||
|
<div style="display: flex; align-items: center">
|
||||||
|
<!-- 铁路场站 -->
|
||||||
|
<a-select
|
||||||
|
v-if="formState.fromLocUIType === 'railway'"
|
||||||
|
v-model:value="formState.fromLoc"
|
||||||
|
placeholder="请选择铁路场站"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
:filter-option="filterOption"
|
||||||
|
@change="handleFromStationSelect"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in fromRailwayOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<!-- 码头 -->
|
||||||
|
<a-select
|
||||||
|
v-else-if="formState.fromLocUIType === 'dock'"
|
||||||
|
v-model:value="formState.fromLoc"
|
||||||
|
placeholder="请选择码头"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
:filter-option="filterOption"
|
||||||
|
@change="handleFromStationSelect"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in fromDockOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<!-- 门点地址:百度地图搜索 -->
|
||||||
|
<a-select
|
||||||
|
v-else
|
||||||
|
v-model:value="formState.fromLoc"
|
||||||
|
show-search
|
||||||
|
placeholder="请输入地点"
|
||||||
|
:filter-option="false"
|
||||||
|
@search="handleFromLocationSearch"
|
||||||
|
@select="handleFromRealSelect"
|
||||||
|
@dropdownVisibleChange="handleFromDropdownHide"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in fromLocationOptions"
|
||||||
|
:key="item.uid"
|
||||||
|
:value="item.title"
|
||||||
|
:data="item"
|
||||||
|
>
|
||||||
|
<div>{{ item.title }}</div>
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<!-- 只有门点地址才显示定位按钮 -->
|
||||||
|
<a-button
|
||||||
|
v-if="formState.fromLocUIType === 'door'"
|
||||||
|
@click="handleSelectCoordinates('from')"
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
style="margin-left: 8px"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<EnvironmentOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- 目的地 -->
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="目的地" name="toLoc">
|
||||||
|
<a-form-item-rest>
|
||||||
|
<div style="display: flex; align-items: center">
|
||||||
|
<!-- 铁路场站 -->
|
||||||
|
<a-select
|
||||||
|
v-if="formState.toLocUIType === 'railway'"
|
||||||
|
v-model:value="formState.toLoc"
|
||||||
|
placeholder="请选择铁路场站"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
:filter-option="filterOption"
|
||||||
|
@change="handleToStationSelect"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in toRailwayOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<!-- 码头 -->
|
||||||
|
<a-select
|
||||||
|
v-else-if="formState.toLocUIType === 'dock'"
|
||||||
|
v-model:value="formState.toLoc"
|
||||||
|
placeholder="请选择码头"
|
||||||
|
allow-clear
|
||||||
|
show-search
|
||||||
|
:filter-option="filterOption"
|
||||||
|
@change="handleToStationSelect"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in toDockOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<!-- 门点地址:百度地图搜索 -->
|
||||||
|
<a-select
|
||||||
|
v-else
|
||||||
|
v-model:value="formState.toLoc"
|
||||||
|
show-search
|
||||||
|
placeholder="请输入地点"
|
||||||
|
:filter-option="false"
|
||||||
|
@search="handleToLocationSearch"
|
||||||
|
@select="handleToRealSelect"
|
||||||
|
@dropdownVisibleChange="handleToDropdownHide"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="item in toLocationOptions"
|
||||||
|
:key="item.uid"
|
||||||
|
:value="item.title"
|
||||||
|
:data="item"
|
||||||
|
>
|
||||||
|
<div>{{ item.title }}</div>
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
|
||||||
|
<!-- 只有门点地址才显示定位按钮 -->
|
||||||
|
<a-button
|
||||||
|
v-if="formState.toLocUIType === 'door'"
|
||||||
|
@click="handleSelectCoordinates('to')"
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
style="margin-left: 8px"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<EnvironmentOutlined />
|
||||||
|
</template>
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<!-- 价格 -->
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="价格(元)" name="basePrice">
|
||||||
|
<a-form-item-rest>
|
||||||
|
<div style="display: flex; align-items: center">
|
||||||
|
<a-input-number
|
||||||
|
style="flex: 1; margin-right: 10px"
|
||||||
|
v-model:value="formState.basePrice"
|
||||||
|
placeholder="请输入运价"
|
||||||
|
/>
|
||||||
|
<a-input disabled value="元" style="width: 100px" placeholder="单位" />
|
||||||
|
</div>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="运价备注" name="priceRemark">
|
||||||
|
<a-input
|
||||||
|
style="width: 100%"
|
||||||
|
v-model:value="formState.priceRemark"
|
||||||
|
placeholder="请输入运价备注"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="预计时长" name="estTime">
|
||||||
|
<a-form-item-rest>
|
||||||
|
<div style="display: flex; align-items: center">
|
||||||
|
<a-input-number
|
||||||
|
style="flex: 1; margin-right: 10px"
|
||||||
|
v-model:value="formState.estTime"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="请输入预计时长"
|
||||||
|
/>
|
||||||
|
<a-select
|
||||||
|
v-model:value="formState.estTimeUnit"
|
||||||
|
style="width: 100px"
|
||||||
|
placeholder="单位"
|
||||||
|
>
|
||||||
|
<a-select-option value="D">天</a-select-option>
|
||||||
|
<a-select-option value="H">小时</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</div>
|
||||||
|
</a-form-item-rest>
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<a-col :span="12">
|
||||||
|
<a-form-item label="揽货备注" name="cargoRemark">
|
||||||
|
<a-input v-model:value="formState.cargoRemark" placeholder="请输入揽货备注" />
|
||||||
|
</a-form-item>
|
||||||
|
</a-col>
|
||||||
|
|
||||||
|
<gridientComp
|
||||||
|
v-if="formState.priceType === '2'"
|
||||||
|
v-model:modelValue="formState.elementTieredPriceList"
|
||||||
|
/>
|
||||||
|
</a-row>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<locationSelect ref="locationSelectRef" @location-selected="changeLocation" />
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, reactive, nextTick } from 'vue';
|
||||||
|
import { FormInstance, message } from 'ant-design-vue';
|
||||||
|
import locationSelect from '../../../commonComponents/locationSelect.vue';
|
||||||
|
import { addTruckInfo, editTruckInfo, getTruckInfoById, getBaiduPlace } from '@/api/qcwl';
|
||||||
|
import * as commonApi from '@/api/common';
|
||||||
|
import type { RuleObject } from 'ant-design-vue/lib/form/interface';
|
||||||
|
import { noLoginlistStation } from '@/api/sysOrgRelation';
|
||||||
|
import { commonMixin } from '@/mixins/commonMixin';
|
||||||
|
import gridientComp from './gridientComp.vue';
|
||||||
|
|
||||||
|
const { filterOption } = commonMixin();
|
||||||
|
|
||||||
|
interface StationOption {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocationItem {
|
||||||
|
title: string;
|
||||||
|
point: { lat: number; lng: number };
|
||||||
|
uid: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits(['refresh']);
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const locationSelectRef = ref();
|
||||||
|
const visible = ref(false);
|
||||||
|
|
||||||
|
// ==================== 数据源定义 ====================
|
||||||
|
|
||||||
|
// 起运地 - 铁路场站选项
|
||||||
|
const fromRailwayOptions = ref<StationOption[]>([]);
|
||||||
|
// 起运地 - 码头选项
|
||||||
|
const fromDockOptions = ref<StationOption[]>([]);
|
||||||
|
// 目的地 - 铁路场站选项
|
||||||
|
const toRailwayOptions = ref<StationOption[]>([]);
|
||||||
|
// 目的地 - 码头选项
|
||||||
|
const toDockOptions = ref<StationOption[]>([]);
|
||||||
|
|
||||||
|
// 起运地 - 百度地图搜索选项(门点地址专用)
|
||||||
|
const fromLocationOptions = ref<LocationItem[]>([]);
|
||||||
|
// 目的地 - 百度地图搜索选项(门点地址专用)
|
||||||
|
const toLocationOptions = ref<LocationItem[]>([]);
|
||||||
|
|
||||||
|
// 起运地搜索暂存值
|
||||||
|
let fromRealInputValue = '';
|
||||||
|
// 目的地搜索暂存值
|
||||||
|
let toRealInputValue = '';
|
||||||
|
|
||||||
|
// 当前操作的坐标类型(用于定位回调)
|
||||||
|
let currentCoordType: 'from' | 'to' = 'from';
|
||||||
|
|
||||||
|
// ==================== 表单数据 ====================
|
||||||
|
|
||||||
|
const formState = reactive<any>({
|
||||||
|
id: undefined,
|
||||||
|
busType: '集装箱',
|
||||||
|
// 后端接口字段(保持不变)
|
||||||
|
fromLocType: 'city',
|
||||||
|
toLocType: 'railway',
|
||||||
|
// 前端UI控制字段(不提交给后端)
|
||||||
|
fromLocUIType: 'door',
|
||||||
|
toLocUIType: 'door',
|
||||||
|
fromLoc: undefined,
|
||||||
|
toLoc: undefined,
|
||||||
|
basePrice: undefined,
|
||||||
|
priceRemark: undefined,
|
||||||
|
estTime: undefined,
|
||||||
|
estTimeUnit: 'D',
|
||||||
|
cargoRemark: undefined,
|
||||||
|
fromLocPosition: '',
|
||||||
|
toLocPosition: '',
|
||||||
|
priceType: '1',
|
||||||
|
elementTieredPriceList: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==================== 校验规则 ====================
|
||||||
|
|
||||||
|
const rules: Record<string, RuleObject | RuleObject[]> = {
|
||||||
|
busType: [{ required: true, message: '请选择短驳类型' }],
|
||||||
|
fromLocUIType: [{ required: true, message: '请选择起运地类型' }],
|
||||||
|
toLocUIType: [{ required: true, message: '请选择目的地类型' }],
|
||||||
|
fromLoc: [{ required: true, message: '请选择起运地' }],
|
||||||
|
toLoc: [{ required: true, message: '请选择目的地' }],
|
||||||
|
basePrice: [{ required: true, message: '请输入运价' }],
|
||||||
|
estTime: [{ required: true, message: '请输入预计时长' }],
|
||||||
|
estTimeUnit: [{ required: true, message: '请选择预计时长单位' }],
|
||||||
|
priceType: [{ required: true, message: '请选择计价方式' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 数据加载函数 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载铁路场站列表
|
||||||
|
*/
|
||||||
|
const loadRailwayStationList = () => {
|
||||||
|
// 接口不存在时使用占位数据
|
||||||
|
const railwayPlaceholder = [
|
||||||
|
{ label: '上海铁路场站', value: 'shanghai_railway' },
|
||||||
|
{ label: '北京铁路场站', value: 'beijing_railway' },
|
||||||
|
{ label: '广州铁路场站', value: 'guangzhou_railway' },
|
||||||
|
{ label: '深圳铁路场站', value: 'shenzhen_railway' },
|
||||||
|
{ label: '成都铁路场站', value: 'chengdu_railway' },
|
||||||
|
{ label: '武汉铁路场站', value: 'wuhan_railway' },
|
||||||
|
{ label: '郑州铁路场站', value: 'zhengzhou_railway' },
|
||||||
|
{ label: '西安铁路场站', value: 'xian_railway' },
|
||||||
|
];
|
||||||
|
fromRailwayOptions.value = railwayPlaceholder;
|
||||||
|
toRailwayOptions.value = railwayPlaceholder;
|
||||||
|
|
||||||
|
// 如果接口已存在,使用下面的代码替换上面
|
||||||
|
/*
|
||||||
|
noLoginlistStation({ type: 'railway' }).then((res) => {
|
||||||
|
if (res.code !== '0000') {
|
||||||
|
return Promise.reject(res);
|
||||||
|
}
|
||||||
|
const list = res.result.map((item: any) => ({
|
||||||
|
label: item,
|
||||||
|
value: item,
|
||||||
|
}));
|
||||||
|
fromRailwayOptions.value = list;
|
||||||
|
toRailwayOptions.value = list;
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载码头列表
|
||||||
|
*/
|
||||||
|
const loadDockStationList = () => {
|
||||||
|
// 接口不存在时使用占位数据
|
||||||
|
const dockPlaceholder = [
|
||||||
|
{ label: '上海港码头', value: 'shanghai_dock' },
|
||||||
|
{ label: '宁波港码头', value: 'ningbo_dock' },
|
||||||
|
{ label: '深圳港码头', value: 'shenzhen_dock' },
|
||||||
|
{ label: '广州港码头', value: 'guangzhou_dock' },
|
||||||
|
{ label: '青岛港码头', value: 'qingdao_dock' },
|
||||||
|
{ label: '天津港码头', value: 'tianjin_dock' },
|
||||||
|
{ label: '大连港码头', value: 'dalian_dock' },
|
||||||
|
{ label: '厦门港码头', value: 'xiamen_dock' },
|
||||||
|
];
|
||||||
|
fromDockOptions.value = dockPlaceholder;
|
||||||
|
toDockOptions.value = dockPlaceholder;
|
||||||
|
|
||||||
|
// 如果接口已存在,使用下面的代码替换上面
|
||||||
|
/*
|
||||||
|
noLoginlistStation({ type: 'dock' }).then((res) => {
|
||||||
|
if (res.code !== '0000') {
|
||||||
|
return Promise.reject(res);
|
||||||
|
}
|
||||||
|
const list = res.result.map((item: any) => ({
|
||||||
|
label: item,
|
||||||
|
value: item,
|
||||||
|
}));
|
||||||
|
fromDockOptions.value = list;
|
||||||
|
toDockOptions.value = list;
|
||||||
|
});
|
||||||
|
*/
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化所有下拉数据
|
||||||
|
*/
|
||||||
|
const initAllOptions = () => {
|
||||||
|
loadRailwayStationList();
|
||||||
|
loadDockStationList();
|
||||||
|
// 清空百度地图搜索选项
|
||||||
|
fromLocationOptions.value = [];
|
||||||
|
toLocationOptions.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== UI 交互函数 ====================
|
||||||
|
|
||||||
|
const showModal = (row?: any) => {
|
||||||
|
initAllOptions();
|
||||||
|
|
||||||
|
if (row && row.id) {
|
||||||
|
getTruckInfoById(row.id).then((res) => {
|
||||||
|
Object.assign(formState, {
|
||||||
|
...res.result,
|
||||||
|
fromLocUIType: 'door',
|
||||||
|
toLocUIType: 'door',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
Object.assign(formState, {
|
||||||
|
id: undefined,
|
||||||
|
busType: '集装箱',
|
||||||
|
fromLocType: 'city',
|
||||||
|
toLocType: 'railway',
|
||||||
|
fromLocUIType: 'door',
|
||||||
|
toLocUIType: 'door',
|
||||||
|
fromLoc: undefined,
|
||||||
|
toLoc: undefined,
|
||||||
|
basePrice: undefined,
|
||||||
|
priceRemark: undefined,
|
||||||
|
estTime: undefined,
|
||||||
|
estTimeUnit: 'D',
|
||||||
|
cargoRemark: undefined,
|
||||||
|
fromLocPosition: '',
|
||||||
|
toLocPosition: '',
|
||||||
|
priceType: '1',
|
||||||
|
elementTieredPriceList: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
visible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 起运地类型变更
|
||||||
|
const handleFromLocTypeChange = (val: string) => {
|
||||||
|
formState.fromLoc = undefined;
|
||||||
|
formState.fromLocPosition = '';
|
||||||
|
if (val !== 'door') {
|
||||||
|
fromLocationOptions.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 目的地类型变更
|
||||||
|
const handleToLocTypeChange = (val: string) => {
|
||||||
|
formState.toLoc = undefined;
|
||||||
|
formState.toLocPosition = '';
|
||||||
|
if (val !== 'door') {
|
||||||
|
toLocationOptions.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 起运地 - 铁路/码头选择 ====================
|
||||||
|
|
||||||
|
const handleFromStationSelect = (val: string) => {
|
||||||
|
let found: StationOption | undefined;
|
||||||
|
if (formState.fromLocUIType === 'railway') {
|
||||||
|
found = fromRailwayOptions.value.find((item) => item.value === val);
|
||||||
|
} else if (formState.fromLocUIType === 'dock') {
|
||||||
|
found = fromDockOptions.value.find((item) => item.value === val);
|
||||||
|
}
|
||||||
|
if (found) {
|
||||||
|
// TODO: 从后端获取对应站点的经纬度,目前使用示例坐标
|
||||||
|
formState.fromLocPosition = '121.4737,31.2304';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 目的地 - 铁路/码头选择 ====================
|
||||||
|
|
||||||
|
const handleToStationSelect = (val: string) => {
|
||||||
|
let found: StationOption | undefined;
|
||||||
|
if (formState.toLocUIType === 'railway') {
|
||||||
|
found = toRailwayOptions.value.find((item) => item.value === val);
|
||||||
|
} else if (formState.toLocUIType === 'dock') {
|
||||||
|
found = toDockOptions.value.find((item) => item.value === val);
|
||||||
|
}
|
||||||
|
if (found) {
|
||||||
|
// TODO: 从后端获取对应站点的经纬度,目前使用示例坐标
|
||||||
|
formState.toLocPosition = '121.4737,31.2304';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 起运地 - 门点地址(百度地图搜索) ====================
|
||||||
|
|
||||||
|
const handleFromLocationSearch = async (value: string) => {
|
||||||
|
fromRealInputValue = value;
|
||||||
|
if (!value.trim()) {
|
||||||
|
fromLocationOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
getBaiduPlace(value).then((res) => {
|
||||||
|
fromLocationOptions.value = res.result.results.map((item: any) => ({
|
||||||
|
title: item.name,
|
||||||
|
point: { lat: item.location.lat, lng: item.location.lng },
|
||||||
|
uid: item.uid,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFromRealSelect = (selectedTitle: string, option: any) => {
|
||||||
|
const selectedItem = option.data;
|
||||||
|
formState.fromLocPosition = `${selectedItem.point.lng},${selectedItem.point.lat}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFromDropdownHide = (visible: boolean) => {
|
||||||
|
if (!visible) {
|
||||||
|
nextTick(() => {
|
||||||
|
formState.fromLoc = fromRealInputValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 目的地 - 门点地址(百度地图搜索) ====================
|
||||||
|
|
||||||
|
const handleToLocationSearch = async (value: string) => {
|
||||||
|
toRealInputValue = value;
|
||||||
|
if (!value.trim()) {
|
||||||
|
toLocationOptions.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
getBaiduPlace(value).then((res) => {
|
||||||
|
toLocationOptions.value = res.result.results.map((item: any) => ({
|
||||||
|
title: item.name,
|
||||||
|
point: { lat: item.location.lat, lng: item.location.lng },
|
||||||
|
uid: item.uid,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToRealSelect = (selectedTitle: string, option: any) => {
|
||||||
|
const selectedItem = option.data;
|
||||||
|
formState.toLocPosition = `${selectedItem.point.lng},${selectedItem.point.lat}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToDropdownHide = (visible: boolean) => {
|
||||||
|
if (!visible) {
|
||||||
|
nextTick(() => {
|
||||||
|
formState.toLoc = toRealInputValue;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 定位按钮 ====================
|
||||||
|
|
||||||
|
const handleSelectCoordinates = (type: 'from' | 'to') => {
|
||||||
|
currentCoordType = type;
|
||||||
|
locationSelectRef.value.showModal(formState);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeLocation = (data: { lng: number; lat: number }) => {
|
||||||
|
const position = `${data.lng},${data.lat}`;
|
||||||
|
if (currentCoordType === 'from') {
|
||||||
|
formState.fromLocPosition = position;
|
||||||
|
} else {
|
||||||
|
formState.toLocPosition = position;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 提交逻辑 ====================
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const prepareSubmitData = () => {
|
||||||
|
const submitData = { ...formState };
|
||||||
|
// 删除前端UI控制字段
|
||||||
|
delete submitData.fromLocUIType;
|
||||||
|
delete submitData.toLocUIType;
|
||||||
|
return submitData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOk = async () => {
|
||||||
|
// 阶梯价校验
|
||||||
|
if (formState.priceType === '2') {
|
||||||
|
if (!formState.elementTieredPriceList || formState.elementTieredPriceList.length === 0) {
|
||||||
|
message.error('请设置梯度价格');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const invalidRows = formState.elementTieredPriceList
|
||||||
|
.map((item: any, index: number) => ({
|
||||||
|
index: index + 1,
|
||||||
|
hasError: !item.distance || !item.price,
|
||||||
|
}))
|
||||||
|
.filter((item: any) => item.hasError);
|
||||||
|
if (invalidRows.length > 0) {
|
||||||
|
message.error(`第 ${invalidRows.map((r: any) => r.index).join(', ')} 行的距离或价格未填写`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 一口价校验
|
||||||
|
if (formState.priceType === '1') {
|
||||||
|
if (!formState.fromLocPosition) {
|
||||||
|
message.error('请选择起运地经纬度');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formState.toLocPosition) {
|
||||||
|
message.error('请选择目的地经纬度');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await formRef.value?.validateFields();
|
||||||
|
|
||||||
|
const submitData = prepareSubmitData();
|
||||||
|
|
||||||
|
if (formState.id) {
|
||||||
|
editTruckInfo(submitData).then((res) => {
|
||||||
|
if (res.code === '0000') {
|
||||||
|
message.success('更新成功');
|
||||||
|
emit('refresh');
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addTruckInfo(submitData).then((res) => {
|
||||||
|
if (res.code === '0000') {
|
||||||
|
message.success('新增成功');
|
||||||
|
emit('refresh');
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('验证失败:', error);
|
||||||
|
message.error('请检查表单数据');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
defineExpose({ showModal });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -0,0 +1,830 @@
|
|||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
v-model:open="visible"
|
||||||
|
:title="modalTitle"
|
||||||
|
width="96%"
|
||||||
|
:footer="null"
|
||||||
|
:mask-closable="false"
|
||||||
|
:destroy-on-close="false"
|
||||||
|
wrap-class-name="seal-position-config-wrap"
|
||||||
|
:bodyStyle="{ padding: 0, height: 'calc(100vh - 180px)', overflow: 'hidden' }"
|
||||||
|
>
|
||||||
|
<div class="seal-position-config-layout">
|
||||||
|
<div ref="previewPaneRef" class="preview-pane">
|
||||||
|
<div class="preview-toolbar">
|
||||||
|
<a-space wrap>
|
||||||
|
<a-tag color="blue">当前页:第 {{ currentPageNum }} 页</a-tag>
|
||||||
|
<a-tag>签章位 {{ positions.length }} 个</a-tag>
|
||||||
|
<a-button type="primary" :disabled="!pdfImages.length" @click="handleAddPosition">
|
||||||
|
添加签章位
|
||||||
|
</a-button>
|
||||||
|
<a-button :disabled="!currentPosition" @click="handleDeleteCurrent">
|
||||||
|
删除当前框
|
||||||
|
</a-button>
|
||||||
|
<a-button :loading="loading" @click="loadPageData(true)">刷新预览</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-scroll">
|
||||||
|
<div
|
||||||
|
v-for="(imgUrl, pageIndex) in pdfImages"
|
||||||
|
:key="`page_${pageIndex}`"
|
||||||
|
class="pdf-page-card"
|
||||||
|
:class="{ 'is-active': currentPageNum === pageIndex + 1 }"
|
||||||
|
@click="currentPageNum = pageIndex + 1"
|
||||||
|
>
|
||||||
|
<div class="page-card-header">
|
||||||
|
<span>第 {{ pageIndex + 1 }} 页</span>
|
||||||
|
<a-tag v-if="currentPageNum === pageIndex + 1" color="processing">当前页</a-tag>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="pdf-page-shell"
|
||||||
|
:style="{ width: `${pageDisplayWidth}px`, height: `${pageDisplayHeight}px` }"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="imgUrl"
|
||||||
|
class="pdf-page-image"
|
||||||
|
:style="{ width: `${pageDisplayWidth}px`, height: `${pageDisplayHeight}px` }"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="box in getPagePositions(pageIndex + 1)"
|
||||||
|
:key="box.localId"
|
||||||
|
class="seal-position-box"
|
||||||
|
:class="{ 'is-selected': selectedLocalId === box.localId }"
|
||||||
|
:style="getBoxStyle(box)"
|
||||||
|
@click.stop="handleSelectBox(box.localId, box.pageNum)"
|
||||||
|
@mousedown.stop.prevent="startDragBox($event, box)"
|
||||||
|
>
|
||||||
|
<div class="box-label-row">
|
||||||
|
<span class="box-label">{{ box.label || '未命名签章位' }}</span>
|
||||||
|
<span v-if="box.required === 'Y'" class="box-required">必签</span>
|
||||||
|
</div>
|
||||||
|
<div class="box-party">{{ getPartyLabel(box.partyId) }}</div>
|
||||||
|
<div
|
||||||
|
class="resize-handle"
|
||||||
|
title="拖动调整大小"
|
||||||
|
@mousedown.stop.prevent="startResizeBox($event, box)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a-empty
|
||||||
|
v-if="!loading && !pdfImages.length"
|
||||||
|
class="empty-preview"
|
||||||
|
description="暂无签署文件预览,请先确认水印文件是否已上传"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-pane">
|
||||||
|
<div class="side-card">
|
||||||
|
<div class="side-card-title">合同信息</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span>合同名称</span>
|
||||||
|
<strong>{{ contractDetail?.contractName || '-' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span>合同状态</span>
|
||||||
|
<strong>{{ contractDetail?.status || '-' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="meta-item">
|
||||||
|
<span>签章位配置</span>
|
||||||
|
<strong>{{ positions.length ? '已配置' : '未配置' }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="meta-note">
|
||||||
|
当前方案采用“当前合同单独配框”。框的位置以后会直接用于在线签订时的吸附和未签提醒。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-card">
|
||||||
|
<div class="side-card-title">签章位属性</div>
|
||||||
|
<template v-if="currentPosition">
|
||||||
|
<a-form layout="vertical">
|
||||||
|
<a-form-item label="标签">
|
||||||
|
<a-input
|
||||||
|
v-model:value="currentPosition.label"
|
||||||
|
allow-clear
|
||||||
|
placeholder="例如:甲方公章位"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="签署方">
|
||||||
|
<a-select
|
||||||
|
v-model:value="currentPosition.partyId"
|
||||||
|
:options="partyOptions"
|
||||||
|
placeholder="请选择签署方"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="是否必签">
|
||||||
|
<a-switch
|
||||||
|
:checked="currentPosition.required === 'Y'"
|
||||||
|
checked-children="必签"
|
||||||
|
un-checked-children="可选"
|
||||||
|
@change="(checked) => handleRequiredChange(currentPosition, checked)"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="所在页">
|
||||||
|
<a-select
|
||||||
|
v-model:value="currentPosition.pageNum"
|
||||||
|
:options="pageOptions"
|
||||||
|
placeholder="请选择页码"
|
||||||
|
@change="(value) => handleBoxPageChange(currentPosition, value)"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<div class="coordinate-grid">
|
||||||
|
<a-form-item label="X 坐标">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="currentPosition.x"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="normalizeBox(currentPosition)"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="Y 坐标">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="currentPosition.y"
|
||||||
|
:min="0"
|
||||||
|
:step="1"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="normalizeBox(currentPosition)"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="宽度">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="currentPosition.width"
|
||||||
|
:min="60"
|
||||||
|
:step="5"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="normalizeBox(currentPosition)"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="高度">
|
||||||
|
<a-input-number
|
||||||
|
v-model:value="currentPosition.height"
|
||||||
|
:min="60"
|
||||||
|
:step="5"
|
||||||
|
style="width: 100%"
|
||||||
|
@change="normalizeBox(currentPosition)"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
</div>
|
||||||
|
</a-form>
|
||||||
|
</template>
|
||||||
|
<a-empty v-else description="请先新增并选中一个签章位" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-card side-card-grow">
|
||||||
|
<div class="side-card-title">签章位列表</div>
|
||||||
|
<div class="position-list">
|
||||||
|
<div
|
||||||
|
v-for="box in sortedPositions"
|
||||||
|
:key="box.localId"
|
||||||
|
class="position-list-item"
|
||||||
|
:class="{ 'is-selected': selectedLocalId === box.localId }"
|
||||||
|
@click="handleSelectBox(box.localId, box.pageNum)"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="position-item-title">{{ box.label || '未命名签章位' }}</div>
|
||||||
|
<div class="position-item-sub">
|
||||||
|
第 {{ box.pageNum }} 页 · {{ getPartyLabel(box.partyId) }} · {{ box.required === 'Y' ? '必签' : '可选' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-button type="link" danger size="small" @click.stop="handleDeletePosition(box.localId)">
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-actions">
|
||||||
|
<a-space>
|
||||||
|
<a-button @click="visible = false">取消</a-button>
|
||||||
|
<a-button type="primary" :loading="saving" @click="handleSave">保存签章位</a-button>
|
||||||
|
</a-space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { initLaunchSealPosition, saveLaunchSealPositions } from '@/api/launch'
|
||||||
|
|
||||||
|
const emit = defineEmits(['saved'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const previewPaneRef = ref(null)
|
||||||
|
const contractDetail = ref(null)
|
||||||
|
const pageData = ref(null)
|
||||||
|
const positions = ref([])
|
||||||
|
const selectedLocalId = ref('')
|
||||||
|
const currentPageNum = ref(1)
|
||||||
|
const pageDisplayWidth = ref(760)
|
||||||
|
|
||||||
|
const DEFAULT_IMAGE_WIDTH = 793
|
||||||
|
const DEFAULT_IMAGE_HEIGHT = 1122
|
||||||
|
const DEFAULT_BOX_WIDTH = 160
|
||||||
|
const DEFAULT_BOX_HEIGHT = 160
|
||||||
|
const MIN_BOX_SIZE = 60
|
||||||
|
let localSeed = 0
|
||||||
|
|
||||||
|
const modalTitle = computed(() => {
|
||||||
|
const name = contractDetail.value?.contractName || '合同'
|
||||||
|
return `配置签章位 - ${name}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const pdfImages = computed(() => normalizePdfImages(pageData.value?.pdfToImageList))
|
||||||
|
const imageWidth = computed(() => Number(pageData.value?.imageWidth || DEFAULT_IMAGE_WIDTH))
|
||||||
|
const imageHeight = computed(() => Number(pageData.value?.imageHeight || DEFAULT_IMAGE_HEIGHT))
|
||||||
|
const scaleRatio = computed(() => pageDisplayWidth.value / imageWidth.value)
|
||||||
|
const pageDisplayHeight = computed(() => Math.round(imageHeight.value * scaleRatio.value))
|
||||||
|
const currentPosition = computed(() => positions.value.find((item) => item.localId === selectedLocalId.value) || null)
|
||||||
|
|
||||||
|
const sortedPositions = computed(() =>
|
||||||
|
[...positions.value].sort((a, b) => {
|
||||||
|
if (a.pageNum !== b.pageNum) {
|
||||||
|
return Number(a.pageNum || 0) - Number(b.pageNum || 0)
|
||||||
|
}
|
||||||
|
return String(a.localId).localeCompare(String(b.localId))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const partyOptions = computed(() =>
|
||||||
|
(contractDetail.value?.partyList || []).map((item) => ({
|
||||||
|
label: `${item.roleName || item.roleCode || '签署方'} - ${item.companyName || item.subjectCode || item.id}`,
|
||||||
|
value: item.id,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const pageOptions = computed(() =>
|
||||||
|
pdfImages.value.map((_, index) => ({
|
||||||
|
label: `第 ${index + 1} 页`,
|
||||||
|
value: index + 1,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
function isSuccessResponse(res) {
|
||||||
|
return String(res?.code || '') === '0000'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResult(res) {
|
||||||
|
return res?.result || null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePdfImages(list = []) {
|
||||||
|
return (Array.isArray(list) ? list : [])
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === 'string') return item
|
||||||
|
if (!item || typeof item !== 'object') return ''
|
||||||
|
return item.url || item.imageUrl || item.previewUrl || item.fileUrl || item.path || ''
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLocalId(id) {
|
||||||
|
if (id != null) {
|
||||||
|
return `db_${id}`
|
||||||
|
}
|
||||||
|
localSeed += 1
|
||||||
|
return `tmp_${localSeed}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
loading.value = false
|
||||||
|
saving.value = false
|
||||||
|
pageData.value = null
|
||||||
|
positions.value = []
|
||||||
|
selectedLocalId.value = ''
|
||||||
|
currentPageNum.value = 1
|
||||||
|
pageDisplayWidth.value = 760
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLocalPositions(page) {
|
||||||
|
const rawPositions = Array.isArray(page?.positions) ? page.positions : []
|
||||||
|
return rawPositions.map((item) => ({
|
||||||
|
...item,
|
||||||
|
localId: createLocalId(item.id),
|
||||||
|
required: item.required === 'N' ? 'N' : 'Y',
|
||||||
|
pageNum: Number(item.pageNum || 1),
|
||||||
|
x: Number(item.x || 0),
|
||||||
|
y: Number(item.y || 0),
|
||||||
|
width: Number(item.width || DEFAULT_BOX_WIDTH),
|
||||||
|
height: Number(item.height || DEFAULT_BOX_HEIGHT),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPreviewSize() {
|
||||||
|
nextTick(() => {
|
||||||
|
const paneWidth = previewPaneRef.value?.clientWidth || 980
|
||||||
|
const availableWidth = Math.max(360, paneWidth - 48)
|
||||||
|
pageDisplayWidth.value = Math.min(imageWidth.value, availableWidth)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRequiredChange(box, checked) {
|
||||||
|
box.required = checked ? 'Y' : 'N'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectBox(localId, pageNum) {
|
||||||
|
selectedLocalId.value = localId
|
||||||
|
currentPageNum.value = Number(pageNum || 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPagePositions(pageNum) {
|
||||||
|
return positions.value.filter((item) => Number(item.pageNum) === Number(pageNum))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBoxStyle(box) {
|
||||||
|
return {
|
||||||
|
left: `${Number(box.x || 0) * scaleRatio.value}px`,
|
||||||
|
top: `${Number(box.y || 0) * scaleRatio.value}px`,
|
||||||
|
width: `${Number(box.width || DEFAULT_BOX_WIDTH) * scaleRatio.value}px`,
|
||||||
|
height: `${Number(box.height || DEFAULT_BOX_HEIGHT) * scaleRatio.value}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPartyLabel(partyId) {
|
||||||
|
const party = (contractDetail.value?.partyList || []).find((item) => Number(item.id) === Number(partyId))
|
||||||
|
if (!party) {
|
||||||
|
return '未绑定签署方'
|
||||||
|
}
|
||||||
|
return `${party.roleName || party.roleCode || '签署方'} - ${party.companyName || party.subjectCode || party.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampValue(value, min, max) {
|
||||||
|
const numericValue = Number(value)
|
||||||
|
if (Number.isNaN(numericValue)) return min
|
||||||
|
return Math.min(Math.max(numericValue, min), max)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBox(box) {
|
||||||
|
if (!box) return
|
||||||
|
box.pageNum = clampValue(box.pageNum || 1, 1, Math.max(pdfImages.value.length, 1))
|
||||||
|
box.width = clampValue(box.width || DEFAULT_BOX_WIDTH, MIN_BOX_SIZE, imageWidth.value)
|
||||||
|
box.height = clampValue(box.height || DEFAULT_BOX_HEIGHT, MIN_BOX_SIZE, imageHeight.value)
|
||||||
|
box.x = clampValue(box.x || 0, 0, Math.max(0, imageWidth.value - box.width))
|
||||||
|
box.y = clampValue(box.y || 0, 0, Math.max(0, imageHeight.value - box.height))
|
||||||
|
box.x = Math.round(box.x * 100) / 100
|
||||||
|
box.y = Math.round(box.y * 100) / 100
|
||||||
|
box.width = Math.round(box.width * 100) / 100
|
||||||
|
box.height = Math.round(box.height * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBoxPageChange(box, pageNum) {
|
||||||
|
box.pageNum = Number(pageNum || 1)
|
||||||
|
currentPageNum.value = box.pageNum
|
||||||
|
normalizeBox(box)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddPosition() {
|
||||||
|
if (!pdfImages.value.length) {
|
||||||
|
message.warning('暂无可配置的签署文件预览')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const partyId = currentPosition.value?.partyId ?? partyOptions.value[0]?.value
|
||||||
|
const box = {
|
||||||
|
id: null,
|
||||||
|
localId: createLocalId(null),
|
||||||
|
partyId,
|
||||||
|
label: `签章位${positions.value.length + 1}`,
|
||||||
|
required: 'Y',
|
||||||
|
pageNum: currentPageNum.value,
|
||||||
|
x: Math.max(0, Math.round((imageWidth.value - DEFAULT_BOX_WIDTH) / 2)),
|
||||||
|
y: Math.max(0, Math.round((imageHeight.value - DEFAULT_BOX_HEIGHT) / 2)),
|
||||||
|
width: DEFAULT_BOX_WIDTH,
|
||||||
|
height: DEFAULT_BOX_HEIGHT,
|
||||||
|
}
|
||||||
|
positions.value.push(box)
|
||||||
|
handleSelectBox(box.localId, box.pageNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeletePosition(localId) {
|
||||||
|
positions.value = positions.value.filter((item) => item.localId !== localId)
|
||||||
|
if (selectedLocalId.value === localId) {
|
||||||
|
const nextBox = positions.value[0] || null
|
||||||
|
selectedLocalId.value = nextBox?.localId || ''
|
||||||
|
currentPageNum.value = Number(nextBox?.pageNum || currentPageNum.value || 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteCurrent() {
|
||||||
|
if (!currentPosition.value) return
|
||||||
|
handleDeletePosition(currentPosition.value.localId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDragBox(event, box) {
|
||||||
|
const startX = event.clientX
|
||||||
|
const startY = event.clientY
|
||||||
|
const originX = Number(box.x || 0)
|
||||||
|
const originY = Number(box.y || 0)
|
||||||
|
|
||||||
|
const onMouseMove = (moveEvent) => {
|
||||||
|
const deltaX = (moveEvent.clientX - startX) / scaleRatio.value
|
||||||
|
const deltaY = (moveEvent.clientY - startY) / scaleRatio.value
|
||||||
|
box.x = originX + deltaX
|
||||||
|
box.y = originY + deltaY
|
||||||
|
normalizeBox(box)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startResizeBox(event, box) {
|
||||||
|
const startX = event.clientX
|
||||||
|
const startY = event.clientY
|
||||||
|
const originWidth = Number(box.width || DEFAULT_BOX_WIDTH)
|
||||||
|
const originHeight = Number(box.height || DEFAULT_BOX_HEIGHT)
|
||||||
|
|
||||||
|
const onMouseMove = (moveEvent) => {
|
||||||
|
const deltaX = (moveEvent.clientX - startX) / scaleRatio.value
|
||||||
|
const deltaY = (moveEvent.clientY - startY) / scaleRatio.value
|
||||||
|
box.width = originWidth + deltaX
|
||||||
|
box.height = originHeight + deltaY
|
||||||
|
normalizeBox(box)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', onMouseMove)
|
||||||
|
document.removeEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', onMouseMove)
|
||||||
|
document.addEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPageData(showSuccessTip = false) {
|
||||||
|
if (!contractDetail.value?.id) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await initLaunchSealPosition(contractDetail.value.id)
|
||||||
|
if (!isSuccessResponse(res) || !getResult(res)) {
|
||||||
|
message.error(res?.message || res?.msg || '初始化签署文件失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const page = getResult(res)
|
||||||
|
pageData.value = page
|
||||||
|
positions.value = buildLocalPositions(page)
|
||||||
|
if (positions.value.length > 0) {
|
||||||
|
handleSelectBox(positions.value[0].localId, positions.value[0].pageNum)
|
||||||
|
} else {
|
||||||
|
selectedLocalId.value = ''
|
||||||
|
currentPageNum.value = 1
|
||||||
|
}
|
||||||
|
refreshPreviewSize()
|
||||||
|
if (showSuccessTip) {
|
||||||
|
message.success('签署文件预览已刷新')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error?.message || '初始化签署文件失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePositions() {
|
||||||
|
if (!positions.value.length) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const invalid = positions.value.find(
|
||||||
|
(item) => !item.partyId || !String(item.label || '').trim() || !item.pageNum,
|
||||||
|
)
|
||||||
|
if (invalid) {
|
||||||
|
handleSelectBox(invalid.localId, invalid.pageNum)
|
||||||
|
return '请完整填写签章位标签、签署方和页码'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
const validationMessage = validatePositions()
|
||||||
|
if (validationMessage) {
|
||||||
|
message.warning(validationMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
positions: positions.value.map((item) => {
|
||||||
|
normalizeBox(item)
|
||||||
|
return {
|
||||||
|
partyId: item.partyId,
|
||||||
|
label: String(item.label || '').trim(),
|
||||||
|
required: item.required === 'N' ? 'N' : 'Y',
|
||||||
|
pageNum: Number(item.pageNum || 1),
|
||||||
|
x: Number(item.x || 0),
|
||||||
|
y: Number(item.y || 0),
|
||||||
|
width: Number(item.width || DEFAULT_BOX_WIDTH),
|
||||||
|
height: Number(item.height || DEFAULT_BOX_HEIGHT),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
const res = await saveLaunchSealPositions(contractDetail.value.id, payload)
|
||||||
|
if (!isSuccessResponse(res) || !getResult(res)) {
|
||||||
|
message.error(res?.message || res?.msg || '保存签章位失败')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageData.value = getResult(res)
|
||||||
|
positions.value = buildLocalPositions(pageData.value)
|
||||||
|
if (positions.value.length > 0) {
|
||||||
|
handleSelectBox(positions.value[0].localId, positions.value[0].pageNum)
|
||||||
|
}
|
||||||
|
emit('saved', pageData.value)
|
||||||
|
message.success('签章位已保存')
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error?.message || '保存签章位失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showModal(detail) {
|
||||||
|
resetState()
|
||||||
|
contractDetail.value = detail ? { ...detail } : null
|
||||||
|
visible.value = true
|
||||||
|
await nextTick()
|
||||||
|
refreshPreviewSize()
|
||||||
|
await loadPageData(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWindowResize() {
|
||||||
|
if (visible.value) {
|
||||||
|
refreshPreviewSize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('resize', handleWindowResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', handleWindowResize)
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({ showModal })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.seal-position-config-layout {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
background: #f4f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-toolbar {
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-scroll {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-card {
|
||||||
|
margin: 0 auto 18px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid #dbe4f0;
|
||||||
|
background: #ffffff;
|
||||||
|
width: fit-content;
|
||||||
|
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-card.is-active {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
box-shadow: 0 16px 30px rgba(37, 99, 235, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-shell {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pdf-page-image {
|
||||||
|
display: block;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seal-position-box {
|
||||||
|
position: absolute;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 2px dashed #2563eb;
|
||||||
|
background: rgba(37, 99, 235, 0.12);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
color: #1d4ed8;
|
||||||
|
cursor: move;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seal-position-box.is-selected {
|
||||||
|
border-color: #ef4444;
|
||||||
|
background: rgba(239, 68, 68, 0.14);
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-label-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-required {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box-party {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: inherit;
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: currentColor;
|
||||||
|
cursor: nwse-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-pane {
|
||||||
|
width: 380px;
|
||||||
|
flex: 0 0 380px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
background: linear-gradient(180deg, #fbfdff 0%, #f5f8fc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-card {
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background: rgba(255, 255, 255, 0.96);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-card-grow {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.side-card-title {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item strong {
|
||||||
|
flex: 1;
|
||||||
|
text-align: right;
|
||||||
|
color: #0f172a;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-note {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #eff6ff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coordinate-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-list {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #dbe4f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-list-item:hover,
|
||||||
|
.position-list-item.is-selected {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-item-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.position-item-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
padding-top: 12px;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
background: linear-gradient(180deg, rgba(245, 248, 252, 0) 0%, rgba(245, 248, 252, 0.96) 24%, rgba(245, 248, 252, 1) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-preview {
|
||||||
|
margin-top: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.seal-position-config-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-pane {
|
||||||
|
width: 100%;
|
||||||
|
flex-basis: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@
|
|||||||
@update:modelValue="handleChange"
|
@update:modelValue="handleChange"
|
||||||
/>
|
/>
|
||||||
<div class="editor-tip">
|
<div class="editor-tip">
|
||||||
当前支持普通文字、列表和基础表格。为保证导出 Word 效果,建议不要使用合并单元格和复杂样式。
|
支持普通文字、列表和表格。复制带合并单元格的表格时会自动规整结构,建议粘贴后结合右侧预览确认最终 Word 效果。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -44,6 +44,19 @@ const emit = defineEmits(['update:modelValue'])
|
|||||||
|
|
||||||
const mode = 'default'
|
const mode = 'default'
|
||||||
const editorRef = shallowRef(null)
|
const editorRef = shallowRef(null)
|
||||||
|
const TABLE_STYLE_PROPS = [
|
||||||
|
'width',
|
||||||
|
'min-width',
|
||||||
|
'max-width',
|
||||||
|
'text-align',
|
||||||
|
'vertical-align',
|
||||||
|
'background-color',
|
||||||
|
'color',
|
||||||
|
'font-weight',
|
||||||
|
'font-style',
|
||||||
|
'text-decoration',
|
||||||
|
'white-space',
|
||||||
|
]
|
||||||
|
|
||||||
const toolbarConfig = {
|
const toolbarConfig = {
|
||||||
toolbarKeys: [
|
toolbarKeys: [
|
||||||
@@ -83,6 +96,7 @@ const editorConfig = computed(() => ({
|
|||||||
readOnly: props.disabled,
|
readOnly: props.disabled,
|
||||||
autoFocus: false,
|
autoFocus: false,
|
||||||
scroll: true,
|
scroll: true,
|
||||||
|
customPaste: handleCustomPaste,
|
||||||
hoverbarKeys: {
|
hoverbarKeys: {
|
||||||
table: {
|
table: {
|
||||||
menuKeys: [
|
menuKeys: [
|
||||||
@@ -114,6 +128,193 @@ function handleChange(value) {
|
|||||||
emit('update:modelValue', value || '')
|
emit('update:modelValue', value || '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleCustomPaste(editor, event) {
|
||||||
|
const html = event?.clipboardData?.getData('text/html') || ''
|
||||||
|
if (!html || !/<table[\s>]/i.test(html)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedHtml = normalizePastedHtml(html)
|
||||||
|
if (!normalizedHtml) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
editor.dangerouslyInsertHtml(normalizedHtml)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePastedHtml(html) {
|
||||||
|
const parser = new window.DOMParser()
|
||||||
|
const doc = parser.parseFromString(html, 'text/html')
|
||||||
|
const body = doc.body
|
||||||
|
if (!body) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
body.querySelectorAll('script, style, meta, link, title').forEach((node) => node.remove())
|
||||||
|
body.querySelectorAll('table').forEach((table) => normalizePastedTable(table, doc))
|
||||||
|
body.querySelectorAll('*').forEach((node) => cleanupPastedNode(node))
|
||||||
|
|
||||||
|
return body.innerHTML.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePastedTable(table, doc) {
|
||||||
|
const normalizedTable = doc.createElement('table')
|
||||||
|
normalizedTable.style.width = normalizeTableWidth(table) || '100%'
|
||||||
|
normalizedTable.style.borderCollapse = 'collapse'
|
||||||
|
normalizedTable.style.tableLayout = 'fixed'
|
||||||
|
normalizedTable.style.margin = '8px 0'
|
||||||
|
|
||||||
|
extractPastedTableRows(table).forEach((row) => {
|
||||||
|
const normalizedRow = doc.createElement('tr')
|
||||||
|
Array.from(row.children || []).forEach((cell) => {
|
||||||
|
const tag = String(cell.tagName || '').toLowerCase() === 'th' ? 'th' : 'td'
|
||||||
|
const normalizedCell = doc.createElement(tag)
|
||||||
|
copyTableSpanAttribute(cell, normalizedCell, 'rowspan')
|
||||||
|
copyTableSpanAttribute(cell, normalizedCell, 'colspan')
|
||||||
|
applyNormalizedCellStyle(cell, normalizedCell)
|
||||||
|
normalizedCell.innerHTML = sanitizeCellInnerHtml(cell.innerHTML)
|
||||||
|
normalizedRow.appendChild(normalizedCell)
|
||||||
|
})
|
||||||
|
normalizedTable.appendChild(normalizedRow)
|
||||||
|
})
|
||||||
|
|
||||||
|
table.replaceWith(normalizedTable)
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractPastedTableRows(table) {
|
||||||
|
const rows = Array.from(table.querySelectorAll(':scope > tr'))
|
||||||
|
if (rows.length) {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionRows = ['thead', 'tbody', 'tfoot'].flatMap((tagName) =>
|
||||||
|
Array.from(table.querySelectorAll(`:scope > ${tagName} > tr`)),
|
||||||
|
)
|
||||||
|
if (sectionRows.length) {
|
||||||
|
return sectionRows
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(table.querySelectorAll('tr'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTableWidth(table) {
|
||||||
|
const inlineWidth = table.style?.width || table.getAttribute('width') || ''
|
||||||
|
const normalized = String(inlineWidth || '').trim()
|
||||||
|
if (!normalized) {
|
||||||
|
return '100%'
|
||||||
|
}
|
||||||
|
if (/^\d+(\.\d+)?$/.test(normalized)) {
|
||||||
|
return `${normalized}px`
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyTableSpanAttribute(sourceCell, targetCell, attrName) {
|
||||||
|
const rawValue = sourceCell.getAttribute(attrName)
|
||||||
|
const spanValue = Number.parseInt(String(rawValue || '').trim(), 10)
|
||||||
|
if (Number.isFinite(spanValue) && spanValue > 1) {
|
||||||
|
targetCell.setAttribute(attrName, String(spanValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNormalizedCellStyle(sourceCell, targetCell) {
|
||||||
|
const textAlign = sourceCell.style?.textAlign || sourceCell.getAttribute('align') || ''
|
||||||
|
const verticalAlign = sourceCell.style?.verticalAlign || sourceCell.getAttribute('valign') || ''
|
||||||
|
const backgroundColor = sourceCell.style?.backgroundColor || sourceCell.getAttribute('bgcolor') || ''
|
||||||
|
const width = sourceCell.style?.width || sourceCell.getAttribute('width') || ''
|
||||||
|
|
||||||
|
targetCell.style.border = '1px solid #1f2937'
|
||||||
|
targetCell.style.padding = '6px 8px'
|
||||||
|
targetCell.style.verticalAlign = normalizeVerticalAlign(verticalAlign) || 'middle'
|
||||||
|
targetCell.style.wordBreak = 'break-word'
|
||||||
|
targetCell.style.whiteSpace = 'pre-wrap'
|
||||||
|
|
||||||
|
if (textAlign) {
|
||||||
|
targetCell.style.textAlign = textAlign
|
||||||
|
}
|
||||||
|
if (backgroundColor) {
|
||||||
|
targetCell.style.backgroundColor = backgroundColor
|
||||||
|
}
|
||||||
|
if (width) {
|
||||||
|
targetCell.style.width = /^\d+(\.\d+)?$/.test(width) ? `${width}px` : width
|
||||||
|
}
|
||||||
|
|
||||||
|
if (String(sourceCell.tagName || '').toLowerCase() === 'th') {
|
||||||
|
targetCell.style.fontWeight = '700'
|
||||||
|
targetCell.style.textAlign = targetCell.style.textAlign || 'center'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeCellInnerHtml(html) {
|
||||||
|
const parser = new window.DOMParser()
|
||||||
|
const doc = parser.parseFromString(`<div>${html || ''}</div>`, 'text/html')
|
||||||
|
const wrapper = doc.body.firstElementChild
|
||||||
|
if (!wrapper) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper.querySelectorAll('script, style, meta, link, title').forEach((node) => node.remove())
|
||||||
|
Array.from(wrapper.querySelectorAll('*')).forEach((node) => {
|
||||||
|
cleanupPastedNode(node)
|
||||||
|
if (String(node.tagName || '').toLowerCase() === 'table') {
|
||||||
|
normalizePastedTable(node, doc)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return wrapper.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupPastedNode(node) {
|
||||||
|
if (!(node instanceof window.HTMLElement)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node.removeAttribute('class')
|
||||||
|
node.removeAttribute('lang')
|
||||||
|
node.removeAttribute('dir')
|
||||||
|
node.removeAttribute('data-mce-style')
|
||||||
|
|
||||||
|
const tag = String(node.tagName || '').toLowerCase()
|
||||||
|
if (tag === 'table' || tag === 'td' || tag === 'th' || tag === 'tr') {
|
||||||
|
retainInlineStyle(node, TABLE_STYLE_PROPS)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
retainInlineStyle(node, [
|
||||||
|
'text-align',
|
||||||
|
'background-color',
|
||||||
|
'color',
|
||||||
|
'font-weight',
|
||||||
|
'font-style',
|
||||||
|
'text-decoration',
|
||||||
|
'white-space',
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function retainInlineStyle(node, allowedProperties = []) {
|
||||||
|
const preservedEntries = allowedProperties
|
||||||
|
.map((propertyName) => {
|
||||||
|
const value = node.style?.getPropertyValue(propertyName) || ''
|
||||||
|
return value ? `${propertyName}:${value}` : ''
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
if (preservedEntries.length) {
|
||||||
|
node.setAttribute('style', preservedEntries.join(';'))
|
||||||
|
} else {
|
||||||
|
node.removeAttribute('style')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVerticalAlign(value) {
|
||||||
|
const normalized = String(value || '').trim().toLowerCase()
|
||||||
|
if (['top', 'middle', 'center', 'bottom'].includes(normalized)) {
|
||||||
|
return normalized === 'center' ? 'middle' : normalized
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
const editor = editorRef.value
|
const editor = editorRef.value
|
||||||
if (editor) {
|
if (editor) {
|
||||||
@@ -146,10 +347,47 @@ onBeforeUnmount(() => {
|
|||||||
min-height: 240px !important;
|
min-height: 240px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-content :deep(.w-e-scroll) {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-content :deep(.w-e-text-placeholder) {
|
.editor-content :deep(.w-e-text-placeholder) {
|
||||||
top: 14px;
|
top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.editor-content :deep(.w-e-text-container [data-slate-editor]) {
|
||||||
|
padding: 0 14px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content :deep(table) {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 8px 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: fixed;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content :deep(table td),
|
||||||
|
.editor-content :deep(table th) {
|
||||||
|
min-width: 84px;
|
||||||
|
border: 1px solid #1f2937 !important;
|
||||||
|
padding: 6px 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content :deep(table th) {
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-content :deep(table p) {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.editor-tip {
|
.editor-tip {
|
||||||
padding: 8px 12px 10px;
|
padding: 8px 12px 10px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,8 @@ const TEXT_NODE = 3
|
|||||||
const DEFAULT_WORD_TABLE_WIDTH_TWIPS = 9000
|
const DEFAULT_WORD_TABLE_WIDTH_TWIPS = 9000
|
||||||
const DEFAULT_WORD_TABLE_WIDTH_PERCENT = 5000
|
const DEFAULT_WORD_TABLE_WIDTH_PERCENT = 5000
|
||||||
const DEFAULT_TABLE_CELL_PADDING_TWIPS = 120
|
const DEFAULT_TABLE_CELL_PADDING_TWIPS = 120
|
||||||
|
const DEFAULT_TABLE_BORDER_SIZE = 6
|
||||||
|
const DEFAULT_TABLE_BORDER_COLOR = '000000'
|
||||||
const DEFAULT_PARAGRAPH_SPACING_BEFORE = 40
|
const DEFAULT_PARAGRAPH_SPACING_BEFORE = 40
|
||||||
const DEFAULT_PARAGRAPH_SPACING_AFTER = 40
|
const DEFAULT_PARAGRAPH_SPACING_AFTER = 40
|
||||||
const DEFAULT_PARAGRAPH_LINE = 300
|
const DEFAULT_PARAGRAPH_LINE = 300
|
||||||
@@ -344,49 +346,22 @@ function buildParagraphXmlBySegments(segments = [], paragraphStyle = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildTableXml(tableElement) {
|
function buildTableXml(tableElement) {
|
||||||
const directRows = getDirectChildElements(tableElement, 'tr')
|
const rows = extractTableRows(tableElement)
|
||||||
const rows = directRows.length ? directRows : Array.from(tableElement.getElementsByTagName('tr'))
|
const tableLayout = buildTableLayout(rows)
|
||||||
const columnWidths = extractTableColumnWidths(tableElement)
|
const columnWidths = extractTableColumnWidths(tableElement)
|
||||||
const tableWidth = resolveTableWordWidth(readElementWidth(tableElement))
|
const tableWidth = resolveTableWordWidth(readElementWidth(tableElement))
|
||||||
const normalizedColumnWidths = normalizeTableColumnWidths(rows, columnWidths, tableWidth)
|
const normalizedColumnWidths = normalizeTableColumnWidths(
|
||||||
|
tableLayout.rows,
|
||||||
|
columnWidths,
|
||||||
|
tableWidth,
|
||||||
|
)
|
||||||
|
|
||||||
const rowXml = rows
|
const rowXml = tableLayout.rows
|
||||||
.map((row) => {
|
.map((rowLayout) => {
|
||||||
const cells = getDirectCellElements(row)
|
const cellXml = rowLayout.renderCells
|
||||||
let logicalColumnIndex = 0
|
.map((cellEntry) =>
|
||||||
const cellXml = cells
|
buildWordTableCellXml(cellEntry, rowLayout, tableLayout.rows, normalizedColumnWidths),
|
||||||
.map((cell) => {
|
)
|
||||||
const isHeader = isHeaderCell(cell)
|
|
||||||
const cellStyle = parseNodeStyleInfo(cell)
|
|
||||||
const defaultCellAlign = cellStyle.align || 'center'
|
|
||||||
const blocks = []
|
|
||||||
collectBlocksFromChildren(cell.childNodes, blocks, {
|
|
||||||
bold: isHeader,
|
|
||||||
color: cellStyle.color || '',
|
|
||||||
bgColor: '',
|
|
||||||
align: defaultCellAlign,
|
|
||||||
})
|
|
||||||
const contentXml =
|
|
||||||
blocks.filter(Boolean).join('') || buildParagraphXml('', { align: defaultCellAlign })
|
|
||||||
const gridSpan = parseSpanValue(cell.getAttribute?.('colspan'))
|
|
||||||
const tcWidth =
|
|
||||||
buildCellWidthFromGrid(normalizedColumnWidths, logicalColumnIndex, gridSpan) || {
|
|
||||||
value: 0,
|
|
||||||
type: 'auto',
|
|
||||||
}
|
|
||||||
logicalColumnIndex += gridSpan
|
|
||||||
const tcProps = [
|
|
||||||
`<w:tcW w:w="${tcWidth.value}" w:type="${tcWidth.type}"/>`,
|
|
||||||
'<w:vAlign w:val="center"/>',
|
|
||||||
cellStyle.bgColor
|
|
||||||
? `<w:shd w:val="clear" w:color="auto" w:fill="${cellStyle.bgColor}"/>`
|
|
||||||
: '',
|
|
||||||
gridSpan > 1 ? `<w:gridSpan w:val="${gridSpan}"/>` : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('')
|
|
||||||
return `<w:tc><w:tcPr>${tcProps}</w:tcPr>${contentXml}</w:tc>`
|
|
||||||
})
|
|
||||||
.join('')
|
.join('')
|
||||||
return `<w:tr>${cellXml}</w:tr>`
|
return `<w:tr>${cellXml}</w:tr>`
|
||||||
})
|
})
|
||||||
@@ -394,7 +369,315 @@ function buildTableXml(tableElement) {
|
|||||||
|
|
||||||
const normalizedTableWidth = buildNormalizedTableWidth(tableWidth, normalizedColumnWidths)
|
const normalizedTableWidth = buildNormalizedTableWidth(tableWidth, normalizedColumnWidths)
|
||||||
const tableGridXml = buildTableGridXml(normalizedColumnWidths)
|
const tableGridXml = buildTableGridXml(normalizedColumnWidths)
|
||||||
return `<w:tbl><w:tblPr><w:tblW w:w="${normalizedTableWidth.value}" w:type="${normalizedTableWidth.type}"/><w:tblLayout w:type="fixed"/><w:tblCellMar><w:top w:w="${DEFAULT_TABLE_CELL_PADDING_TWIPS}" w:type="dxa"/><w:left w:w="${DEFAULT_TABLE_CELL_PADDING_TWIPS}" w:type="dxa"/><w:bottom w:w="${DEFAULT_TABLE_CELL_PADDING_TWIPS}" w:type="dxa"/><w:right w:w="${DEFAULT_TABLE_CELL_PADDING_TWIPS}" w:type="dxa"/></w:tblCellMar><w:tblBorders><w:top w:val="single" w:sz="8" w:space="0" w:color="000000"/><w:left w:val="single" w:sz="8" w:space="0" w:color="000000"/><w:bottom w:val="single" w:sz="8" w:space="0" w:color="000000"/><w:right w:val="single" w:sz="8" w:space="0" w:color="000000"/><w:insideH w:val="single" w:sz="6" w:space="0" w:color="000000"/><w:insideV w:val="single" w:sz="6" w:space="0" w:color="000000"/></w:tblBorders></w:tblPr>${tableGridXml}${rowXml}</w:tbl>`
|
return `<w:tbl><w:tblPr><w:tblW w:w="${normalizedTableWidth.value}" w:type="${normalizedTableWidth.type}"/><w:tblLayout w:type="fixed"/><w:tblCellMar><w:top w:w="${DEFAULT_TABLE_CELL_PADDING_TWIPS}" w:type="dxa"/><w:left w:w="${DEFAULT_TABLE_CELL_PADDING_TWIPS}" w:type="dxa"/><w:bottom w:w="${DEFAULT_TABLE_CELL_PADDING_TWIPS}" w:type="dxa"/><w:right w:w="${DEFAULT_TABLE_CELL_PADDING_TWIPS}" w:type="dxa"/></w:tblCellMar></w:tblPr>${tableGridXml}${rowXml}</w:tbl>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTableRows(tableElement) {
|
||||||
|
const directRows = getDirectChildElements(tableElement, 'tr')
|
||||||
|
if (directRows.length) {
|
||||||
|
return directRows
|
||||||
|
}
|
||||||
|
|
||||||
|
const sectionRows = ['thead', 'tbody', 'tfoot'].flatMap((tagName) =>
|
||||||
|
getDirectChildElements(tableElement, tagName).flatMap((section) =>
|
||||||
|
getDirectChildElements(section, 'tr'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if (sectionRows.length) {
|
||||||
|
return sectionRows
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(tableElement.getElementsByTagName('tr'))
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTableLayout(rows = []) {
|
||||||
|
const grid = []
|
||||||
|
let columnCount = 0
|
||||||
|
let nextCellId = 1
|
||||||
|
|
||||||
|
rows.forEach((row, rowIndex) => {
|
||||||
|
const rowSlots = grid[rowIndex] || []
|
||||||
|
grid[rowIndex] = rowSlots
|
||||||
|
const cells = getDirectCellElements(row)
|
||||||
|
let searchColumnIndex = 0
|
||||||
|
|
||||||
|
cells.forEach((cell) => {
|
||||||
|
const startColumn = findNextAvailableTableColumn(rowSlots, searchColumnIndex)
|
||||||
|
const gridSpan = parseSpanValue(cell.getAttribute?.('colspan'))
|
||||||
|
const rowSpan = parseSpanValue(cell.getAttribute?.('rowspan'))
|
||||||
|
const entry = {
|
||||||
|
kind: 'cell',
|
||||||
|
id: nextCellId,
|
||||||
|
cell,
|
||||||
|
rowIndex,
|
||||||
|
startColumn,
|
||||||
|
gridSpan,
|
||||||
|
rowSpan,
|
||||||
|
}
|
||||||
|
nextCellId += 1
|
||||||
|
|
||||||
|
for (let offset = 0; offset < gridSpan; offset += 1) {
|
||||||
|
rowSlots[startColumn + offset] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rowSpan > 1) {
|
||||||
|
for (let nextRowIndex = rowIndex + 1; nextRowIndex < rowIndex + rowSpan; nextRowIndex += 1) {
|
||||||
|
const nextRowSlots = grid[nextRowIndex] || []
|
||||||
|
grid[nextRowIndex] = nextRowSlots
|
||||||
|
const continuationEntry = {
|
||||||
|
kind: 'continuation',
|
||||||
|
id: entry.id,
|
||||||
|
cell,
|
||||||
|
rowIndex: nextRowIndex,
|
||||||
|
startColumn,
|
||||||
|
gridSpan,
|
||||||
|
rowSpan,
|
||||||
|
}
|
||||||
|
for (let offset = 0; offset < gridSpan; offset += 1) {
|
||||||
|
nextRowSlots[startColumn + offset] = continuationEntry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
searchColumnIndex = startColumn + gridSpan
|
||||||
|
columnCount = Math.max(columnCount, startColumn + gridSpan)
|
||||||
|
})
|
||||||
|
|
||||||
|
columnCount = Math.max(columnCount, rowSlots.length)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
columnCount,
|
||||||
|
rows: rows.map((row, rowIndex) => {
|
||||||
|
const slots = Array.from(
|
||||||
|
{ length: columnCount },
|
||||||
|
(_, columnIndex) => grid[rowIndex]?.[columnIndex] || null,
|
||||||
|
)
|
||||||
|
const renderCells = buildRenderableRowEntries(slots, rowIndex, columnCount)
|
||||||
|
const renderSlots = buildTableRowRenderSlots(renderCells, columnCount)
|
||||||
|
return { row, rowIndex, slots, renderCells, renderSlots }
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRenderableRowEntries(slots = [], rowIndex, columnCount) {
|
||||||
|
const actualEntries = []
|
||||||
|
for (let columnIndex = 0; columnIndex < columnCount; ) {
|
||||||
|
const entry = slots[columnIndex]
|
||||||
|
if (!entry) {
|
||||||
|
columnIndex += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (entry.startColumn === columnIndex) {
|
||||||
|
actualEntries.push(entry)
|
||||||
|
columnIndex += entry.gridSpan
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
columnIndex += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasBlank = slots.some((entry) => !entry)
|
||||||
|
const canRedistribute =
|
||||||
|
hasBlank &&
|
||||||
|
actualEntries.length > 0 &&
|
||||||
|
actualEntries.every(
|
||||||
|
(entry) =>
|
||||||
|
entry.kind === 'cell' &&
|
||||||
|
Number(entry.gridSpan || 1) === 1 &&
|
||||||
|
Number(entry.rowSpan || 1) === 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (canRedistribute) {
|
||||||
|
return buildEvenlyDistributedRowEntries(actualEntries, rowIndex, columnCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCells = []
|
||||||
|
for (let columnIndex = 0; columnIndex < columnCount; ) {
|
||||||
|
const entry = slots[columnIndex]
|
||||||
|
if (!entry) {
|
||||||
|
renderCells.push({
|
||||||
|
kind: 'blank',
|
||||||
|
rowIndex,
|
||||||
|
startColumn: columnIndex,
|
||||||
|
gridSpan: 1,
|
||||||
|
rowSpan: 1,
|
||||||
|
})
|
||||||
|
columnIndex += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (entry.startColumn !== columnIndex) {
|
||||||
|
columnIndex += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
renderCells.push(entry)
|
||||||
|
columnIndex += entry.gridSpan
|
||||||
|
}
|
||||||
|
return renderCells
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEvenlyDistributedRowEntries(entries = [], rowIndex, columnCount) {
|
||||||
|
const totalEntries = entries.length
|
||||||
|
const baseSpan = Math.max(1, Math.floor(columnCount / totalEntries))
|
||||||
|
let remainder = Math.max(0, columnCount - baseSpan * totalEntries)
|
||||||
|
let startColumn = 0
|
||||||
|
|
||||||
|
return entries.map((entry, index) => {
|
||||||
|
let gridSpan = baseSpan
|
||||||
|
if (remainder > 0) {
|
||||||
|
gridSpan += 1
|
||||||
|
remainder -= 1
|
||||||
|
}
|
||||||
|
if (index === totalEntries - 1) {
|
||||||
|
gridSpan = Math.max(1, columnCount - startColumn)
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextEntry = {
|
||||||
|
...entry,
|
||||||
|
rowIndex,
|
||||||
|
startColumn,
|
||||||
|
gridSpan,
|
||||||
|
}
|
||||||
|
startColumn += gridSpan
|
||||||
|
return nextEntry
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTableRowRenderSlots(renderCells = [], columnCount) {
|
||||||
|
const slots = Array.from({ length: columnCount }, () => null)
|
||||||
|
renderCells.forEach((entry) => {
|
||||||
|
const gridSpan = Math.max(1, Number(entry?.gridSpan) || 1)
|
||||||
|
const startColumn = Math.max(0, Number(entry?.startColumn) || 0)
|
||||||
|
for (let offset = 0; offset < gridSpan; offset += 1) {
|
||||||
|
if (startColumn + offset < columnCount) {
|
||||||
|
slots[startColumn + offset] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return slots
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNextAvailableTableColumn(rowSlots = [], startIndex = 0) {
|
||||||
|
let columnIndex = Math.max(0, startIndex)
|
||||||
|
while (rowSlots[columnIndex]) {
|
||||||
|
columnIndex += 1
|
||||||
|
}
|
||||||
|
return columnIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWordTableCellXml(
|
||||||
|
cellEntry,
|
||||||
|
rowLayout,
|
||||||
|
tableRows = [],
|
||||||
|
normalizedColumnWidths = [],
|
||||||
|
) {
|
||||||
|
const gridSpan = Math.max(1, Number(cellEntry?.gridSpan) || 1)
|
||||||
|
const startColumn = Math.max(0, Number(cellEntry?.startColumn) || 0)
|
||||||
|
const tcWidth = buildCellWidthFromGrid(normalizedColumnWidths, startColumn, gridSpan) || {
|
||||||
|
value: 0,
|
||||||
|
type: 'auto',
|
||||||
|
}
|
||||||
|
const borderXml = buildWordTableCellBorderXml(cellEntry, rowLayout, tableRows)
|
||||||
|
const baseCellProps = [
|
||||||
|
`<w:tcW w:w="${tcWidth.value}" w:type="${tcWidth.type}"/>`,
|
||||||
|
gridSpan > 1 ? `<w:gridSpan w:val="${gridSpan}"/>` : '',
|
||||||
|
borderXml,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (cellEntry?.kind === 'blank') {
|
||||||
|
const blankProps = [...baseCellProps, '<w:vAlign w:val="center"/>'].filter(Boolean).join('')
|
||||||
|
return `<w:tc><w:tcPr>${blankProps}</w:tcPr>${buildParagraphXml('', { align: 'left' })}</w:tc>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = cellEntry?.cell
|
||||||
|
const isHeader = isHeaderCell(cell)
|
||||||
|
const cellStyle = parseNodeStyleInfo(cell)
|
||||||
|
const defaultCellAlign = cellStyle.align || (isHeader ? 'center' : 'left')
|
||||||
|
const verticalAlign = normalizeWordVerticalAlign(cellStyle.verticalAlign) || 'center'
|
||||||
|
const tcProps = [
|
||||||
|
...baseCellProps,
|
||||||
|
`<w:vAlign w:val="${verticalAlign}"/>`,
|
||||||
|
cellStyle.bgColor
|
||||||
|
? `<w:shd w:val="clear" w:color="auto" w:fill="${cellStyle.bgColor}"/>`
|
||||||
|
: '',
|
||||||
|
]
|
||||||
|
|
||||||
|
if (cellEntry?.kind === 'continuation') {
|
||||||
|
tcProps.push('<w:vMerge/>')
|
||||||
|
return `<w:tc><w:tcPr>${tcProps.filter(Boolean).join('')}</w:tcPr>${buildParagraphXml('', { align: defaultCellAlign })}</w:tc>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const blocks = []
|
||||||
|
collectBlocksFromChildren(cell.childNodes, blocks, {
|
||||||
|
bold: isHeader,
|
||||||
|
color: cellStyle.color || '',
|
||||||
|
bgColor: '',
|
||||||
|
align: defaultCellAlign,
|
||||||
|
})
|
||||||
|
const contentXml =
|
||||||
|
blocks.filter(Boolean).join('') || buildParagraphXml('', { align: defaultCellAlign })
|
||||||
|
|
||||||
|
if ((Number(cellEntry?.rowSpan) || 1) > 1) {
|
||||||
|
tcProps.push('<w:vMerge w:val="restart"/>')
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<w:tc><w:tcPr>${tcProps.filter(Boolean).join('')}</w:tcPr>${contentXml}</w:tc>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWordTableCellBorderXml(cellEntry, rowLayout, tableRows = []) {
|
||||||
|
const startColumn = Math.max(0, Number(cellEntry?.startColumn) || 0)
|
||||||
|
const endColumn = startColumn + Math.max(1, Number(cellEntry?.gridSpan) || 1) - 1
|
||||||
|
const currentSlots = resolveRenderedTableSlots(rowLayout)
|
||||||
|
const previousSlots = resolveRenderedTableSlots(tableRows?.[rowLayout?.rowIndex - 1])
|
||||||
|
const nextSlots = resolveRenderedTableSlots(tableRows?.[rowLayout?.rowIndex + 1])
|
||||||
|
const borders = []
|
||||||
|
|
||||||
|
if (!isSameCellEntry(currentSlots[startColumn - 1], cellEntry)) {
|
||||||
|
borders.push(buildWordBorderSideXml('left'))
|
||||||
|
}
|
||||||
|
if (!isSameCellEntry(currentSlots[endColumn + 1], cellEntry)) {
|
||||||
|
borders.push(buildWordBorderSideXml('right'))
|
||||||
|
}
|
||||||
|
if (!isSameCellEntryRange(previousSlots, startColumn, endColumn, cellEntry)) {
|
||||||
|
borders.push(buildWordBorderSideXml('top'))
|
||||||
|
}
|
||||||
|
if (!isSameCellEntryRange(nextSlots, startColumn, endColumn, cellEntry)) {
|
||||||
|
borders.push(buildWordBorderSideXml('bottom'))
|
||||||
|
}
|
||||||
|
|
||||||
|
return borders.length ? `<w:tcBorders>${borders.join('')}</w:tcBorders>` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRenderedTableSlots(rowLayout) {
|
||||||
|
if (Array.isArray(rowLayout?.renderSlots) && rowLayout.renderSlots.length) {
|
||||||
|
return rowLayout.renderSlots
|
||||||
|
}
|
||||||
|
return Array.isArray(rowLayout?.slots) ? rowLayout.slots : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameCellEntryRange(slots = [], startColumn, endColumn, cellEntry) {
|
||||||
|
if (!cellEntry || cellEntry.kind === 'blank') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for (let columnIndex = startColumn; columnIndex <= endColumn; columnIndex += 1) {
|
||||||
|
if (!isSameCellEntry(slots[columnIndex], cellEntry)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameCellEntry(slotEntry, cellEntry) {
|
||||||
|
if (!slotEntry || !cellEntry) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (slotEntry.kind === 'blank' || cellEntry.kind === 'blank') {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return Number(slotEntry.id) === Number(cellEntry.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWordBorderSideXml(sideName) {
|
||||||
|
return `<w:${sideName} w:val="single" w:sz="${DEFAULT_TABLE_BORDER_SIZE}" w:space="0" w:color="${DEFAULT_TABLE_BORDER_COLOR}"/>`
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDirectChildElements(parent, tagName) {
|
function getDirectChildElements(parent, tagName) {
|
||||||
@@ -423,8 +706,8 @@ function extractTableColumnWidths(tableElement) {
|
|||||||
return cols.map((col) => resolveCellWordWidth(readElementWidth(col))).filter(Boolean)
|
return cols.map((col) => resolveCellWordWidth(readElementWidth(col))).filter(Boolean)
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeTableColumnWidths(rows, initialColumnWidths, tableWidth) {
|
function normalizeTableColumnWidths(tableRows, initialColumnWidths, tableWidth) {
|
||||||
const columnCount = getTableColumnCount(rows)
|
const columnCount = getTableColumnCount(tableRows)
|
||||||
if (!columnCount) {
|
if (!columnCount) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
@@ -435,21 +718,22 @@ function normalizeTableColumnWidths(rows, initialColumnWidths, tableWidth) {
|
|||||||
return Number.isFinite(existingWidth) && existingWidth > 0 ? existingWidth : 0
|
return Number.isFinite(existingWidth) && existingWidth > 0 ? existingWidth : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
rows.forEach((row) => {
|
tableRows.forEach((rowLayout) => {
|
||||||
let logicalColumnIndex = 0
|
rowLayout.renderCells.forEach((cellEntry) => {
|
||||||
getDirectCellElements(row).forEach((cell) => {
|
if (cellEntry?.kind !== 'cell') {
|
||||||
const gridSpan = parseSpanValue(cell.getAttribute?.('colspan'))
|
return
|
||||||
const cellWidth = resolveCellWordWidth(readElementWidth(cell))
|
}
|
||||||
|
const gridSpan = Math.max(1, Number(cellEntry.gridSpan) || 1)
|
||||||
|
const cellWidth = resolveCellWordWidth(readElementWidth(cellEntry.cell))
|
||||||
if (cellWidth?.type === 'dxa' && cellWidth.value > 0) {
|
if (cellWidth?.type === 'dxa' && cellWidth.value > 0) {
|
||||||
const distributedWidth = Math.max(300, Math.round(cellWidth.value / gridSpan))
|
const distributedWidth = Math.max(300, Math.round(cellWidth.value / gridSpan))
|
||||||
for (let offset = 0; offset < gridSpan; offset += 1) {
|
for (let offset = 0; offset < gridSpan; offset += 1) {
|
||||||
const columnIndex = logicalColumnIndex + offset
|
const columnIndex = cellEntry.startColumn + offset
|
||||||
if (columnIndex < columnCount) {
|
if (columnIndex < columnCount) {
|
||||||
widths[columnIndex] = Math.max(widths[columnIndex] || 0, distributedWidth)
|
widths[columnIndex] = Math.max(widths[columnIndex] || 0, distributedWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logicalColumnIndex += gridSpan
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -478,14 +762,11 @@ function normalizeTableColumnWidths(rows, initialColumnWidths, tableWidth) {
|
|||||||
return scaledWidths.map((width) => Math.max(300, Math.round(width)))
|
return scaledWidths.map((width) => Math.max(300, Math.round(width)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTableColumnCount(rows) {
|
function getTableColumnCount(tableRows) {
|
||||||
return rows.reduce((maxCount, row) => {
|
return tableRows.reduce(
|
||||||
const count = getDirectCellElements(row).reduce(
|
(maxCount, rowLayout) => Math.max(maxCount, rowLayout?.renderSlots?.length || 0),
|
||||||
(sum, cell) => sum + parseSpanValue(cell.getAttribute?.('colspan')),
|
0,
|
||||||
0,
|
)
|
||||||
)
|
|
||||||
return Math.max(maxCount, count)
|
|
||||||
}, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTableTargetWidthTwips(tableWidth, columnCount) {
|
function resolveTableTargetWidthTwips(tableWidth, columnCount) {
|
||||||
@@ -627,16 +908,33 @@ function parseSpanValue(rawValue) {
|
|||||||
function parseNodeStyleInfo(node) {
|
function parseNodeStyleInfo(node) {
|
||||||
const tag = String(node?.tagName || '').toLowerCase()
|
const tag = String(node?.tagName || '').toLowerCase()
|
||||||
const styleText = String(node?.getAttribute?.('style') || '')
|
const styleText = String(node?.getAttribute?.('style') || '')
|
||||||
|
const fontWeight = String(readStyleProperty(styleText, 'font-weight') || '').trim().toLowerCase()
|
||||||
|
const fontStyle = String(readStyleProperty(styleText, 'font-style') || '').trim().toLowerCase()
|
||||||
|
const textDecoration = String(readStyleProperty(styleText, 'text-decoration') || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
const color = normalizeCssColor(readStyleProperty(styleText, 'color'))
|
const color = normalizeCssColor(readStyleProperty(styleText, 'color'))
|
||||||
const bgColor = normalizeCssColor(readStyleProperty(styleText, 'background-color'))
|
const bgColor = normalizeCssColor(
|
||||||
const textAlign = normalizeTextAlign(readStyleProperty(styleText, 'text-align'))
|
readStyleProperty(styleText, 'background-color') || node?.getAttribute?.('bgcolor') || '',
|
||||||
|
)
|
||||||
|
const textAlign = normalizeTextAlign(
|
||||||
|
readStyleProperty(styleText, 'text-align') || node?.getAttribute?.('align') || '',
|
||||||
|
)
|
||||||
|
const verticalAlign = normalizeVerticalAlign(
|
||||||
|
readStyleProperty(styleText, 'vertical-align') || node?.getAttribute?.('valign') || '',
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
bold: tag === 'strong' || tag === 'b',
|
bold:
|
||||||
italic: tag === 'em' || tag === 'i',
|
tag === 'strong' ||
|
||||||
underline: tag === 'u',
|
tag === 'b' ||
|
||||||
|
fontWeight === 'bold' ||
|
||||||
|
['600', '700', '800', '900'].includes(fontWeight),
|
||||||
|
italic: tag === 'em' || tag === 'i' || fontStyle === 'italic' || fontStyle === 'oblique',
|
||||||
|
underline: tag === 'u' || textDecoration.includes('underline'),
|
||||||
color,
|
color,
|
||||||
bgColor,
|
bgColor,
|
||||||
align: textAlign,
|
align: textAlign,
|
||||||
|
verticalAlign,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,6 +946,7 @@ function mergeTextStyle(baseStyle = {}, overrideStyle = {}) {
|
|||||||
color: overrideStyle.color || baseStyle.color || '',
|
color: overrideStyle.color || baseStyle.color || '',
|
||||||
bgColor: overrideStyle.bgColor || baseStyle.bgColor || '',
|
bgColor: overrideStyle.bgColor || baseStyle.bgColor || '',
|
||||||
align: overrideStyle.align || baseStyle.align || '',
|
align: overrideStyle.align || baseStyle.align || '',
|
||||||
|
verticalAlign: overrideStyle.verticalAlign || baseStyle.verticalAlign || '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -704,12 +1003,26 @@ function normalizeCssColor(rawColor) {
|
|||||||
|
|
||||||
function normalizeTextAlign(rawValue) {
|
function normalizeTextAlign(rawValue) {
|
||||||
const value = String(rawValue || '').trim().toLowerCase()
|
const value = String(rawValue || '').trim().toLowerCase()
|
||||||
|
if (value === 'start') {
|
||||||
|
return 'left'
|
||||||
|
}
|
||||||
|
if (value === 'end') {
|
||||||
|
return 'right'
|
||||||
|
}
|
||||||
if (['left', 'center', 'right', 'justify'].includes(value)) {
|
if (['left', 'center', 'right', 'justify'].includes(value)) {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeVerticalAlign(rawValue) {
|
||||||
|
const value = String(rawValue || '').trim().toLowerCase()
|
||||||
|
if (['top', 'middle', 'center', 'bottom'].includes(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeWordAlignment(value) {
|
function normalizeWordAlignment(value) {
|
||||||
if (value === 'left') return 'left'
|
if (value === 'left') return 'left'
|
||||||
if (value === 'center') return 'center'
|
if (value === 'center') return 'center'
|
||||||
@@ -718,6 +1031,13 @@ function normalizeWordAlignment(value) {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWordVerticalAlign(value) {
|
||||||
|
if (value === 'top') return 'top'
|
||||||
|
if (value === 'middle' || value === 'center') return 'center'
|
||||||
|
if (value === 'bottom') return 'bottom'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
function escapeRegExp(text) {
|
function escapeRegExp(text) {
|
||||||
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-221
@@ -1,29 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="section-container">
|
<section class="section-container">
|
||||||
<a-card
|
<a-card :bordered="false" class="query-card" :body-style="queryCardBodyStyle">
|
||||||
:bordered="false"
|
|
||||||
class="query-card"
|
|
||||||
:body-style="queryCardBodyStyle"
|
|
||||||
>
|
|
||||||
<div ref="queryContainer" class="section-query-container">
|
<div ref="queryContainer" class="section-query-container">
|
||||||
<a-form :model="queryParam" layout="inline">
|
<a-form :model="queryParam" layout="inline">
|
||||||
<a-row :gutter="16" style="width: 100%">
|
<a-row :gutter="16" style="width: 100%">
|
||||||
<a-col :md="6" :sm="24">
|
<a-col :md="6" :sm="24">
|
||||||
<a-form-item label="合同名称">
|
<a-form-item label="合同名称">
|
||||||
<a-input
|
<a-input v-model:value="queryParam.contractName" allow-clear placeholder="请输入合同名称" />
|
||||||
v-model:value="queryParam.contractName"
|
|
||||||
allow-clear
|
|
||||||
placeholder="请输入合同名称"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :md="6" :sm="24">
|
<a-col :md="6" :sm="24">
|
||||||
<a-form-item label="合同编码">
|
<a-form-item label="合同编码">
|
||||||
<a-input
|
<a-input v-model:value="queryParam.contractCode" allow-clear placeholder="请输入合同编码" />
|
||||||
v-model:value="queryParam.contractCode"
|
|
||||||
allow-clear
|
|
||||||
placeholder="请输入合同编码"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</a-col>
|
</a-col>
|
||||||
<a-col :md="6" :sm="24">
|
<a-col :md="6" :sm="24">
|
||||||
@@ -60,17 +48,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<a-card
|
<a-card :bordered="false" class="table-card" :body-style="tableCardBodyStyle">
|
||||||
:bordered="false"
|
|
||||||
class="table-card"
|
|
||||||
:body-style="tableCardBodyStyle"
|
|
||||||
>
|
|
||||||
<vxe-toolbar ref="toolbarRef" custom>
|
<vxe-toolbar ref="toolbarRef" custom>
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
<a-space wrap>
|
<a-space wrap>
|
||||||
<a-button type="primary" :icon="h(SearchOutlined)" @click="handleQuery"
|
<a-button type="primary" :icon="h(SearchOutlined)" @click="handleQuery">查询</a-button>
|
||||||
>查询</a-button
|
|
||||||
>
|
|
||||||
<a-button :icon="h(RedoOutlined)" @click="handleReset">重置</a-button>
|
<a-button :icon="h(RedoOutlined)" @click="handleReset">重置</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
@@ -86,36 +68,13 @@
|
|||||||
:scroll-y="{ enabled: true, gt: 0, mode: 'wheel' }"
|
:scroll-y="{ enabled: true, gt: 0, mode: 'wheel' }"
|
||||||
:data="tableData"
|
:data="tableData"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:loading-config="{
|
:loading-config="{ icon: 'vxe-icon-indicator roll', text: '正在拼命加载中...' }"
|
||||||
icon: 'vxe-icon-indicator roll',
|
|
||||||
text: '正在拼命加载中...',
|
|
||||||
}"
|
|
||||||
>
|
>
|
||||||
<vxe-column type="seq" title="序号" width="68" />
|
<vxe-column type="seq" title="序号" width="68" />
|
||||||
<vxe-column
|
<vxe-column field="contractCode" title="合同编码" width="160" show-overflow="title" />
|
||||||
field="contractCode"
|
<vxe-column field="lawContractCode" title="法务合同编号" width="160" show-overflow="title" />
|
||||||
title="合同编码"
|
<vxe-column field="contractName" title="合同名称" width="220" show-overflow="title" />
|
||||||
width="160"
|
<vxe-column field="contractType" title="合同类型" width="160" show-overflow="title">
|
||||||
show-overflow="title"
|
|
||||||
/>
|
|
||||||
<vxe-column
|
|
||||||
field="lawContractCode"
|
|
||||||
title="法务合同编号"
|
|
||||||
width="160"
|
|
||||||
show-overflow="title"
|
|
||||||
/>
|
|
||||||
<vxe-column
|
|
||||||
field="contractName"
|
|
||||||
title="合同名称"
|
|
||||||
width="220"
|
|
||||||
show-overflow="title"
|
|
||||||
/>
|
|
||||||
<vxe-column
|
|
||||||
field="contractType"
|
|
||||||
title="合同类型"
|
|
||||||
width="160"
|
|
||||||
show-overflow="title"
|
|
||||||
>
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
{{ getContractTypeSimpleLabel(row.contractTypeCode || row.contractType) }}
|
{{ getContractTypeSimpleLabel(row.contractTypeCode || row.contractType) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -127,62 +86,27 @@
|
|||||||
</a-tag>
|
</a-tag>
|
||||||
</template>
|
</template>
|
||||||
</vxe-column>
|
</vxe-column>
|
||||||
<vxe-column
|
<vxe-column field="counterpartySummary" title="合同相对方" width="260" show-overflow="title" />
|
||||||
field="counterpartySummary"
|
|
||||||
title="合同相对方"
|
|
||||||
width="260"
|
|
||||||
show-overflow="title"
|
|
||||||
/>
|
|
||||||
<vxe-column field="status" title="状态" width="120">
|
<vxe-column field="status" title="状态" width="120">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<a-tag :color="getStatusColor(row.status)">{{ getStatusLabelByRow(row) }}</a-tag>
|
<a-tag :color="getStatusColor(row.status)">{{ getStatusLabelByRow(row) }}</a-tag>
|
||||||
</template>
|
</template>
|
||||||
</vxe-column>
|
</vxe-column>
|
||||||
<vxe-column
|
<vxe-column field="approveMessage" title="审批说明" width="220" show-overflow="title" />
|
||||||
field="approveMessage"
|
<vxe-column field="initiator" title="发起人" width="100" show-overflow="title" />
|
||||||
title="审批说明"
|
<vxe-column field="initiatorDept" title="所属部门" width="140" show-overflow="title" />
|
||||||
width="220"
|
<vxe-column field="createTime" title="发起时间" width="170" show-overflow="title" />
|
||||||
show-overflow="title"
|
<vxe-column field="updateTime" title="最后更新时间" width="170" show-overflow="title" />
|
||||||
/>
|
<vxe-column title="操作" width="760" fixed="right">
|
||||||
<vxe-column
|
|
||||||
field="initiator"
|
|
||||||
title="发起人"
|
|
||||||
width="100"
|
|
||||||
show-overflow="title"
|
|
||||||
/>
|
|
||||||
<vxe-column
|
|
||||||
field="initiatorDept"
|
|
||||||
title="所属部门"
|
|
||||||
width="140"
|
|
||||||
show-overflow="title"
|
|
||||||
/>
|
|
||||||
<vxe-column
|
|
||||||
field="createTime"
|
|
||||||
title="发起时间"
|
|
||||||
width="170"
|
|
||||||
show-overflow="title"
|
|
||||||
/>
|
|
||||||
<vxe-column
|
|
||||||
field="updateTime"
|
|
||||||
title="最后更新时间"
|
|
||||||
width="170"
|
|
||||||
show-overflow="title"
|
|
||||||
/>
|
|
||||||
<vxe-column title="操作" width="660" fixed="right">
|
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="table-actions">
|
<div class="table-actions">
|
||||||
<a @click="handleView(row)">查看</a>
|
<a @click="handleView(row)">查看</a>
|
||||||
<a v-if="canViewApprove(row)" @click="handleViewApprove(row)">审批记录</a>
|
<a v-if="canViewApprove(row)" @click="handleViewApprove(row)">审批记录</a>
|
||||||
<a v-if="canViewLawCallback(row)" @click="handleViewLawCallback(row)">回调data</a>
|
<a v-if="canViewLawCallback(row)" @click="handleViewLawCallback(row)">回调data</a>
|
||||||
<a v-if="canOnlineSign(row)" @click="handleOnlineSign(row)">
|
<a v-if="canConfigSealPosition(row)" @click="handleConfigSealPosition(row)">配置签章位</a>
|
||||||
{{ isSingleAgreementRecord(row) ? '发起盖章' : '在线签订' }}
|
<a v-if="canOnlineSign(row)" @click="handleOnlineSign(row)">在线签订</a>
|
||||||
</a>
|
<a v-if="canUploadSigned(row)" @click="handleUploadSigned(row)">上传已签署版本</a>
|
||||||
<a v-if="canUploadSigned(row)" @click="handleUploadSigned(row)">
|
<a v-if="canMockFinishSign(row)" @click="handleMockFinishSign(row)">测试签署完成</a>
|
||||||
{{ isSingleAgreementRecord(row) ? '上传盖章版' : '上传已签署版本' }}
|
|
||||||
</a>
|
|
||||||
<a v-if="canMockFinishSign(row)" @click="handleMockFinishSign(row)">
|
|
||||||
{{ isSingleAgreementRecord(row) ? '测试盖章完成' : '测试签署完成' }}
|
|
||||||
</a>
|
|
||||||
<a v-if="canDownloadSigned(row)" @click="handleDownloadSigned(row)">下载签署文件</a>
|
<a v-if="canDownloadSigned(row)" @click="handleDownloadSigned(row)">下载签署文件</a>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -205,14 +129,9 @@
|
|||||||
</a-card>
|
</a-card>
|
||||||
|
|
||||||
<ContractPartyPlaceholderModal ref="contractModalRef" />
|
<ContractPartyPlaceholderModal ref="contractModalRef" />
|
||||||
<MixedSignModal
|
<SealPositionConfigModal ref="sealPositionConfigModalRef" @saved="handleSealPositionSaved" />
|
||||||
ref="mixedSignModalRef"
|
<MixedSignModal ref="mixedSignModalRef" @business-updated="handleSignBusinessUpdated" />
|
||||||
@business-updated="handleSignBusinessUpdated"
|
<UploadSignedModal ref="uploadSignedModalRef" @done="handleSignBusinessUpdated" />
|
||||||
/>
|
|
||||||
<UploadSignedModal
|
|
||||||
ref="uploadSignedModalRef"
|
|
||||||
@done="handleSignBusinessUpdated"
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -220,7 +139,9 @@
|
|||||||
import { h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { RedoOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
import { RedoOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
|
import { getCurrentMockUser } from '@/api/mockUser'
|
||||||
import ContractPartyPlaceholderModal from '../launch/components/ContractPartyPlaceholderModal.vue'
|
import ContractPartyPlaceholderModal from '../launch/components/ContractPartyPlaceholderModal.vue'
|
||||||
|
import SealPositionConfigModal from '../launch/components/SealPositionConfigModal.vue'
|
||||||
import MixedSignModal from '../launch/components/mixedSignModal.vue'
|
import MixedSignModal from '../launch/components/mixedSignModal.vue'
|
||||||
import UploadSignedModal from '../launch/components/UploadSignedModal.vue'
|
import UploadSignedModal from '../launch/components/UploadSignedModal.vue'
|
||||||
import {
|
import {
|
||||||
@@ -231,6 +152,7 @@ import {
|
|||||||
downloadLaunchFile,
|
downloadLaunchFile,
|
||||||
finishLaunchSeal,
|
finishLaunchSeal,
|
||||||
getLaunchDetail,
|
getLaunchDetail,
|
||||||
|
getLaunchSealPositionPage,
|
||||||
getLawApproveCallbackSample,
|
getLawApproveCallbackSample,
|
||||||
queryApproveViewUrl,
|
queryApproveViewUrl,
|
||||||
queryLaunchList,
|
queryLaunchList,
|
||||||
@@ -245,6 +167,7 @@ const signStatusOptions = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const contractModalRef = ref(null)
|
const contractModalRef = ref(null)
|
||||||
|
const sealPositionConfigModalRef = ref(null)
|
||||||
const mixedSignModalRef = ref(null)
|
const mixedSignModalRef = ref(null)
|
||||||
const uploadSignedModalRef = ref(null)
|
const uploadSignedModalRef = ref(null)
|
||||||
const queryContainer = ref(null)
|
const queryContainer = ref(null)
|
||||||
@@ -253,6 +176,7 @@ const tableRef = ref(null)
|
|||||||
const toolbarRef = ref(null)
|
const toolbarRef = ref(null)
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const currentMockUser = ref(null)
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const tableHeight = ref(420)
|
const tableHeight = ref(420)
|
||||||
const tableData = ref([])
|
const tableData = ref([])
|
||||||
@@ -343,9 +267,6 @@ function getStatusColor(status) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getStatusLabelByRow(row) {
|
function getStatusLabelByRow(row) {
|
||||||
if (isSingleAgreementRecord(row) && row?.status === 'pending_sign') {
|
|
||||||
return '待盖章'
|
|
||||||
}
|
|
||||||
return getStatusLabel(row?.status)
|
return getStatusLabel(row?.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,12 +274,34 @@ function isSingleAgreementRecord(row) {
|
|||||||
return String(row?.isSingleAgreement || '0') === '1'
|
return String(row?.isSingleAgreement || '0') === '1'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isManageOperator() {
|
||||||
|
if (currentMockUser.value?.superAdmin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return String(currentMockUser.value?.systemType || '').toLowerCase() === 'manage'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSignOperator() {
|
||||||
|
if (currentMockUser.value?.superAdmin) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const systemType = String(currentMockUser.value?.systemType || '').toLowerCase()
|
||||||
|
return systemType === 'manage' || systemType === 'business'
|
||||||
|
}
|
||||||
|
|
||||||
|
function canConfigSealPosition(row) {
|
||||||
|
return isManageOperator()
|
||||||
|
&& !isSingleAgreementRecord(row)
|
||||||
|
&& String(row.isElectron || '1') === '1'
|
||||||
|
&& row.status === 'pending_sign'
|
||||||
|
}
|
||||||
|
|
||||||
function canOnlineSign(row) {
|
function canOnlineSign(row) {
|
||||||
return (
|
return isSignOperator()
|
||||||
!isSingleAgreementRecord(row) &&
|
&& !isSingleAgreementRecord(row)
|
||||||
String(row.isElectron || '1') === '1' &&
|
&& String(row.isElectron || '1') === '1'
|
||||||
SIGN_STAGE_STATUSES.includes(row.status)
|
&& SIGN_STAGE_STATUSES.includes(row.status)
|
||||||
)
|
&& String(row.sealPosConfigured || 'N') === 'Y'
|
||||||
}
|
}
|
||||||
|
|
||||||
function canUploadSigned(row) {
|
function canUploadSigned(row) {
|
||||||
@@ -366,9 +309,6 @@ function canUploadSigned(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canMockFinishSign(row) {
|
function canMockFinishSign(row) {
|
||||||
if (isSingleAgreementRecord(row)) {
|
|
||||||
return row.status === 'pending_sign'
|
|
||||||
}
|
|
||||||
return String(row.isElectron || '1') === '1' && SIGN_STAGE_STATUSES.includes(row.status)
|
return String(row.isElectron || '1') === '1' && SIGN_STAGE_STATUSES.includes(row.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,15 +351,9 @@ async function fetchRecordsByStatus(status) {
|
|||||||
const data = Array.isArray(pageResult?.data) ? pageResult.data : []
|
const data = Array.isArray(pageResult?.data) ? pageResult.data : []
|
||||||
totalCount = Number(pageResult?.totalSize ?? pageResult?.total ?? 0)
|
totalCount = Number(pageResult?.totalSize ?? pageResult?.total ?? 0)
|
||||||
records.push(...data)
|
records.push(...data)
|
||||||
if (!data.length) {
|
if (!data.length) break
|
||||||
break
|
if (totalCount > 0 && records.length >= totalCount) break
|
||||||
}
|
if (data.length < SIGN_LIST_QUERY_PAGE_SIZE) break
|
||||||
if (totalCount > 0 && records.length >= totalCount) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if (data.length < SIGN_LIST_QUERY_PAGE_SIZE) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
pageNo += 1
|
pageNo += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,7 +441,6 @@ function handleViewApprove(row) {
|
|||||||
message.warning('当前暂无法务合同ID,审批记录地址尚未生成')
|
message.warning('当前暂无法务合同ID,审批记录地址尚未生成')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
queryApproveViewUrl({
|
queryApproveViewUrl({
|
||||||
contractId: String(row.lawContractId),
|
contractId: String(row.lawContractId),
|
||||||
ivUser: row.initiator || 'admin',
|
ivUser: row.initiator || 'admin',
|
||||||
@@ -528,62 +461,46 @@ function handleViewApprove(row) {
|
|||||||
function buildLawCallbackModalContent(sample) {
|
function buildLawCallbackModalContent(sample) {
|
||||||
const approvedJson = JSON.stringify({ data: sample?.approvedData || {} }, null, 2)
|
const approvedJson = JSON.stringify({ data: sample?.approvedData || {} }, null, 2)
|
||||||
const rejectedJson = JSON.stringify({ data: sample?.rejectedData || {} }, null, 2)
|
const rejectedJson = JSON.stringify({ data: sample?.rejectedData || {} }, null, 2)
|
||||||
return h(
|
return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } }, [
|
||||||
'div',
|
h('div', [
|
||||||
{ style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
|
h('div', { style: { marginBottom: '6px', fontWeight: 600 } }, '回调地址'),
|
||||||
[
|
h('div', { style: { color: '#666', wordBreak: 'break-all' } }, `${sample?.callbackMethod || 'POST'} ${sample?.callbackUrl || '/contract/launch/callback/law/approve'}`),
|
||||||
h('div', [
|
]),
|
||||||
h('div', { style: { marginBottom: '6px', fontWeight: 600 } }, '回调地址'),
|
h('div', [
|
||||||
h(
|
h('div', { style: { marginBottom: '6px', fontWeight: 600 } }, '审批通过 data'),
|
||||||
'div',
|
h('pre', {
|
||||||
{ style: { color: '#666', wordBreak: 'break-all' } },
|
style: {
|
||||||
`${sample?.callbackMethod || 'POST'} ${sample?.callbackUrl || '/contract/launch/callback/law/approve'}`,
|
margin: 0,
|
||||||
),
|
maxHeight: '260px',
|
||||||
]),
|
overflow: 'auto',
|
||||||
h('div', [
|
padding: '12px',
|
||||||
h('div', { style: { marginBottom: '6px', fontWeight: 600 } }, '审批通过 data'),
|
borderRadius: '8px',
|
||||||
h(
|
background: '#f6f8fa',
|
||||||
'pre',
|
fontSize: '12px',
|
||||||
{
|
lineHeight: '1.6',
|
||||||
style: {
|
whiteSpace: 'pre-wrap',
|
||||||
margin: 0,
|
wordBreak: 'break-word',
|
||||||
maxHeight: '260px',
|
},
|
||||||
overflow: 'auto',
|
}, approvedJson),
|
||||||
padding: '12px',
|
]),
|
||||||
borderRadius: '8px',
|
h('div', [
|
||||||
background: '#f6f8fa',
|
h('div', { style: { marginBottom: '6px', fontWeight: 600 } }, '审批驳回 data'),
|
||||||
fontSize: '12px',
|
h('pre', {
|
||||||
lineHeight: '1.6',
|
style: {
|
||||||
whiteSpace: 'pre-wrap',
|
margin: 0,
|
||||||
wordBreak: 'break-word',
|
maxHeight: '220px',
|
||||||
},
|
overflow: 'auto',
|
||||||
},
|
padding: '12px',
|
||||||
approvedJson,
|
borderRadius: '8px',
|
||||||
),
|
background: '#f6f8fa',
|
||||||
]),
|
fontSize: '12px',
|
||||||
h('div', [
|
lineHeight: '1.6',
|
||||||
h('div', { style: { marginBottom: '6px', fontWeight: 600 } }, '审批驳回 data'),
|
whiteSpace: 'pre-wrap',
|
||||||
h(
|
wordBreak: 'break-word',
|
||||||
'pre',
|
},
|
||||||
{
|
}, rejectedJson),
|
||||||
style: {
|
]),
|
||||||
margin: 0,
|
])
|
||||||
maxHeight: '220px',
|
|
||||||
overflow: 'auto',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
background: '#f6f8fa',
|
|
||||||
fontSize: '12px',
|
|
||||||
lineHeight: '1.6',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rejectedJson,
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleViewLawCallback(row) {
|
function handleViewLawCallback(row) {
|
||||||
@@ -603,19 +520,15 @@ function handleViewLawCallback(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleMockFinishSign(row) {
|
function handleMockFinishSign(row) {
|
||||||
const singleAgreement = isSingleAgreementRecord(row)
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: singleAgreement ? '测试操作:模拟盖章完成' : '测试操作:模拟签署完成',
|
title: '测试操作:模拟签署完成',
|
||||||
content: singleAgreement
|
content: `模拟合同【${row.contractName}】签署完成,状态将变为「待采集」。`,
|
||||||
? `模拟单方协议【${row.contractName}】内部业务章加盖完成,状态将变为「已完成」。`
|
|
||||||
: `模拟合同【${row.contractName}】签署完成,状态将变为「待采集」。`,
|
|
||||||
okText: '确认模拟',
|
okText: '确认模拟',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
onOk: () => {
|
onOk: () => {
|
||||||
const signDate = sliceDateValue(row.signDate) || todayString()
|
const signDate = sliceDateValue(row.signDate) || todayString()
|
||||||
const sealDate = sliceDateValue(row.sealDate) || signDate
|
const sealDate = sliceDateValue(row.sealDate) || signDate
|
||||||
const activeDate =
|
const activeDate = sliceDateValue(row.activeDate) || sliceDateValue(row.effectiveStart) || signDate
|
||||||
sliceDateValue(row.activeDate) || sliceDateValue(row.effectiveStart) || signDate
|
|
||||||
return finishLaunchSeal(row.id, {
|
return finishLaunchSeal(row.id, {
|
||||||
sealContractId: row.sealContractId || `TEST-SEAL-${row.id}`,
|
sealContractId: row.sealContractId || `TEST-SEAL-${row.id}`,
|
||||||
sealContractStatus: 2000,
|
sealContractStatus: 2000,
|
||||||
@@ -623,22 +536,16 @@ function handleMockFinishSign(row) {
|
|||||||
signedFilePath: row.contractTextFilePath || `mock-signed/${row.id}.pdf`,
|
signedFilePath: row.contractTextFilePath || `mock-signed/${row.id}.pdf`,
|
||||||
signedFileType: 'pdf',
|
signedFileType: 'pdf',
|
||||||
signedFileSize: row.contractTextFileSize || null,
|
signedFileSize: row.contractTextFileSize || null,
|
||||||
signedOfflineReason: singleAgreement
|
signedOfflineReason: '测试按钮模拟签署完成,真实签章接入后可删除此按钮',
|
||||||
? '测试按钮模拟内部业务章加盖完成'
|
|
||||||
: '测试按钮模拟签署完成,真实签章接入后可删除此按钮',
|
|
||||||
signDate: formatDateTimeValue(signDate),
|
signDate: formatDateTimeValue(signDate),
|
||||||
sealDate: formatDateTimeValue(sealDate),
|
sealDate: formatDateTimeValue(sealDate),
|
||||||
activeDate: formatDateTimeValue(activeDate),
|
activeDate: formatDateTimeValue(activeDate),
|
||||||
}).then((res) => {
|
}).then((res) => {
|
||||||
if (!isSuccessResponse(res)) {
|
if (!isSuccessResponse(res)) {
|
||||||
message.error(
|
message.error(getResponseMessage(res, '测试签署完成失败'))
|
||||||
getResponseMessage(res, singleAgreement ? '测试盖章完成失败' : '测试签署完成失败'),
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
message.success(
|
message.success('测试签署完成,合同已流转到待采集')
|
||||||
singleAgreement ? '测试盖章完成,单方协议已办结' : '测试签署完成,合同已流转到待采集',
|
|
||||||
)
|
|
||||||
getList()
|
getList()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -675,35 +582,69 @@ function handleUploadSigned(row) {
|
|||||||
uploadSignedModalRef.value?.showModal(row)
|
uploadSignedModalRef.value?.showModal(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleOnlineSign(row) {
|
async function ensureDetailReady(row, actionLabel) {
|
||||||
getLaunchDetail(row.id).then(async (res) => {
|
const detailRes = await getLaunchDetail(row.id)
|
||||||
if (!isSuccessResponse(res) || !getDataResult(res)) {
|
if (!isSuccessResponse(detailRes) || !getDataResult(detailRes)) {
|
||||||
message.error(getResponseMessage(res, '获取合同详情失败'))
|
message.error(getResponseMessage(detailRes, '获取合同详情失败'))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
let detail = getDataResult(detailRes)
|
||||||
|
if (detail.contractTextFilePath) {
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
const uploaded = await handleWatermarkUpload(row, actionLabel)
|
||||||
|
if (!uploaded) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const reloadRes = await getLaunchDetail(row.id)
|
||||||
|
if (!isSuccessResponse(reloadRes) || !getDataResult(reloadRes)) {
|
||||||
|
message.error(getResponseMessage(reloadRes, '获取合同详情失败'))
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
detail = getDataResult(reloadRes)
|
||||||
|
return detail
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleConfigSealPosition(row) {
|
||||||
|
const detail = await ensureDetailReady(row, '配置签章位')
|
||||||
|
if (!detail) return
|
||||||
|
sealPositionConfigModalRef.value?.showModal(detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOnlineSign(row) {
|
||||||
|
const detail = await ensureDetailReady(row, '在线签订')
|
||||||
|
if (!detail) return
|
||||||
|
try {
|
||||||
|
const pageRes = await getLaunchSealPositionPage(row.id)
|
||||||
|
if (!isSuccessResponse(pageRes) || !getDataResult(pageRes)) {
|
||||||
|
message.error(getResponseMessage(pageRes, '获取签章位失败'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const detail = getDataResult(res)
|
const page = getDataResult(pageRes)
|
||||||
if (!detail.contractTextFilePath) {
|
if (!Array.isArray(page.positions) || !page.positions.length) {
|
||||||
await handleWatermarkUpload(row)
|
message.warning('当前合同还未配置签章位,请先点击“配置签章位”')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!Array.isArray(page.availablePartyIds) || !page.availablePartyIds.length) {
|
||||||
|
message.warning('当前用户未匹配到可签署方,暂时不能在线签订')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mixedSignModalRef.value?.showModal({
|
mixedSignModalRef.value?.showModal({
|
||||||
...detail,
|
...detail,
|
||||||
contractId: detail.sealContractId,
|
contractId: detail.sealContractId,
|
||||||
})
|
})
|
||||||
})
|
} catch {
|
||||||
|
message.error('获取签章位失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleWatermarkUpload(row) {
|
async function handleWatermarkUpload(row, actionLabel = '在线签订') {
|
||||||
let file
|
let file
|
||||||
const ok = await new Promise((resolve) => {
|
const ok = await new Promise((resolve) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '法务水印文件缺失',
|
title: '法务水印文件缺失',
|
||||||
content: h('div', [
|
content: h('div', [
|
||||||
h(
|
h('p', { style: 'margin-bottom: 12px' }, `法务审批回调未带回带水印的合同文件,请先上传后再${actionLabel}。`),
|
||||||
'p',
|
|
||||||
{ style: 'margin-bottom: 12px' },
|
|
||||||
'法务审批回调未带回带水印的合同文件,请手动上传带水印的合同文件后继续在线签订。',
|
|
||||||
),
|
|
||||||
h('input', {
|
h('input', {
|
||||||
type: 'file',
|
type: 'file',
|
||||||
accept: '.pdf,.doc,.docx',
|
accept: '.pdf,.doc,.docx',
|
||||||
@@ -740,21 +681,25 @@ async function handleWatermarkUpload(row) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if (!ok) return
|
return ok
|
||||||
getLaunchDetail(row.id).then((res) => {
|
}
|
||||||
if (!isSuccessResponse(res) || !getDataResult(res)) return
|
|
||||||
const detail = getDataResult(res)
|
function handleSealPositionSaved() {
|
||||||
mixedSignModalRef.value?.showModal({
|
getList()
|
||||||
...detail,
|
|
||||||
contractId: detail.sealContractId,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSignBusinessUpdated() {
|
function handleSignBusinessUpdated() {
|
||||||
getList()
|
getList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadCurrentMockUser() {
|
||||||
|
try {
|
||||||
|
currentMockUser.value = await getCurrentMockUser()
|
||||||
|
} catch {
|
||||||
|
currentMockUser.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function syncTableHeight() {
|
function syncTableHeight() {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const viewportHeight = window.innerHeight
|
const viewportHeight = window.innerHeight
|
||||||
@@ -765,6 +710,7 @@ function syncTableHeight() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadCurrentMockUser()
|
||||||
handleQuery()
|
handleQuery()
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
connectToolbar()
|
connectToolbar()
|
||||||
|
|||||||
Reference in New Issue
Block a user