签章位置固定功能

This commit is contained in:
2026-07-10 17:03:55 +08:00
parent fc969390ae
commit a6e4e74ad1
10 changed files with 5155 additions and 308 deletions
+38
View File
@@ -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
View File
@@ -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,
})
}
+598
View File
@@ -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>
+727
View File
@@ -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
+383 -63
View File
@@ -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
View File
@@ -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()