This commit is contained in:
吴红兵
2025-12-02 10:37:49 +08:00
commit 1f645dad3e
1183 changed files with 147673 additions and 0 deletions

View File

@@ -0,0 +1,367 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-container class="h-screen">
<el-main class="p-0">
<Splitpanes style="height: 100%">
<Pane :size="70">
<el-card class="h-full">
<!-- Image Display -->
<div class="relative w-full h-full">
<div class="flex absolute top-0 left-0 z-20 items-center px-4 py-2 w-full bg-white bg-opacity-20">
<el-button-group class="mr-4">
<el-button @click="rotateImage(-90)" :icon="Refresh" circle></el-button>
<el-button @click="zoomIn" :icon="ZoomIn" circle></el-button>
<el-button @click="zoomOut" :icon="ZoomOut" circle></el-button>
</el-button-group>
</div>
<div class="mt-12 w-full h-[calc(100%-3rem)]">
<div class="flex relative justify-center items-center w-full h-full" @wheel.prevent="handleWheel">
<img
v-if="imageUrl"
:src="baseURL + imageUrl"
class="max-h-[80vh] w-auto transition-transform duration-200 transform-gpu"
:style="{
transform: `rotate(${rotation}deg) scale(${scale})`,
transformOrigin: 'center center',
}"
/>
<div v-else class="flex flex-col justify-center items-center h-full">
<upload-img v-model:imageUrl="imageUrl" :fileType="['image/png']">
<template #empty>
<el-icon><Picture /></el-icon>
<span>请上传底图</span>
</template>
</upload-img>
<el-link
v-if="isPreview"
class="mt-4 w-full text-center"
href="https://minio.cloud.vip/oss/202412/1735479611.zip"
target="_blank"
>点击下载示例图</el-link
>
</div>
</div>
</div>
</div>
</el-card>
</Pane>
<Pane :size="30">
<Splitpanes horizontal>
<Pane :size="20">
<el-card class="h-full">
<ControlPanel
@clearAll="clearImage"
@saveData="saveData"
@parseData="parseData"
:isPreview="isPreview"
:isParsing="isParsing"
:hasImage="!!imageUrl"
/>
</el-card>
</Pane>
<Pane :size="80">
<el-card class="overflow-auto h-full">
<DataDisplay
:markerList="markerList"
:isPreview="isPreview"
@addMarker="handleAddMarker"
@removeMarker="handleRemoveMarker"
@editMarker="handleEditMarker"
/>
</el-card>
</Pane>
</Splitpanes>
</Pane>
</Splitpanes>
</el-main>
<!-- Loading Animation -->
<div v-if="isParsing" class="flex absolute inset-0 z-50 justify-center items-center bg-black bg-opacity-50">
<div class="flex justify-center items-center text-center">
<span class="loading loading-ball loading-xs"></span>
<span class="loading loading-ball loading-sm"></span>
<span class="loading loading-ball loading-md"></span>
<span class="loading loading-ball loading-lg"></span>
</div>
</div>
</el-container>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { Splitpanes, Pane } from 'splitpanes';
import { Refresh, ZoomIn, ZoomOut, Picture } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus';
import 'splitpanes/dist/splitpanes.css';
import ControlPanel from './components/ControlPanel.vue';
import DataDisplay from './components/DataDisplay.vue';
import { putObj, getObj, parseObj } from '/@/api/knowledge/ocr';
import { Local } from '/@/utils/storage';
import other from '/@/utils/other';
const route = useRoute();
const id = ref(route.query.id);
const isPreview = computed(() => route.query.preview === '1');
const imageUrl = ref<string | null>(null);
const rotation = ref(0);
const scale = ref(1);
const isParsing = ref(false);
const markerList = ref<any[]>([]);
const markerTemplate = ref<any[]>([]); // 存储标记模板
const MIN_SCALE = 0.1;
const MAX_SCALE = 10;
const SCALE_FACTOR = 0.1;
// 添加模型选择的响应式变量
const selectedVisionModel = ref<any>(null);
const selectedChatModel = ref<any>(null);
function rotateImage(angle: number) {
rotation.value = (rotation.value + angle + 360) % 360;
}
function handleWheel(event: WheelEvent) {
if (!imageUrl.value) return;
// deltaY 小于 0 表示向上滚动(放大),大于 0 表示向下滚动(缩小)
const delta = -Math.sign(event.deltaY) * SCALE_FACTOR;
const newScale = scale.value + delta;
// 限制缩放范围
if (newScale >= MIN_SCALE && newScale <= MAX_SCALE) {
scale.value = newScale;
}
}
function zoomIn() {
const newScale = scale.value * 1.1;
if (newScale <= MAX_SCALE) {
scale.value = newScale;
}
}
function zoomOut() {
const newScale = scale.value / 1.1;
if (newScale >= MIN_SCALE) {
scale.value = newScale;
}
}
function clearImage() {
imageUrl.value = null;
rotation.value = 0;
scale.value = 1;
}
function handleAddMarker(marker: any) {
markerList.value.push(marker);
}
function handleRemoveMarker(id: number) {
const index = markerList.value.findIndex((marker) => marker.id === id);
if (index !== -1) {
markerList.value.splice(index, 1);
// 重新编号
markerList.value = markerList.value.map((marker, idx) => ({
...marker,
label: (idx + 1).toString(),
}));
}
}
async function saveData() {
// 检查底图是否存在
if (!imageUrl.value) {
ElMessage.warning('请先上传底图');
return;
}
// 检查是否有标记点
if (markerList.value.length === 0) {
ElMessage.warning('请至少添加一个标记点');
return;
}
// 检查所有标记点是否都有名称和描述
const incompleteMarker = markerList.value.find((marker) => !marker.name || !marker.description);
if (incompleteMarker) {
ElMessage.warning(`请完善标记点 ${incompleteMarker.label} 的信息`);
return;
}
try {
await putObj({
id: id.value,
imageResource: imageUrl.value,
ocrMarked: JSON.stringify({
markerList: markerList.value,
}),
});
ElMessage.success('保存成功');
} catch (error) {
ElMessage.error('保存数据时出错');
}
}
// 加载数据
async function loadData() {
if (!id.value) {
// 如果没有 id清空所有数据
clearAll();
return;
}
try {
const response = await getObj({ id: id.value });
if (!response?.data?.[0]) {
// 如果没有数据,清空所有数据
clearAll();
return;
}
const data = response.data[0];
// 预览模式下只加载标记模板,不加载图片
if (isPreview.value) {
if (data.ocrMarked) {
const ocrMarked = JSON.parse(data.ocrMarked);
if (ocrMarked.markerList) {
markerTemplate.value = ocrMarked.markerList;
// 清空标记值,只保留结构
markerList.value = ocrMarked.markerList.map((marker: any) => ({
...marker,
markedValue: '', // 清空标记值
}));
}
}
} else {
// 非预览模式下加载完整数据
if (data.imageResource) {
imageUrl.value = data.imageResource;
}
if (data.ocrMarked) {
const ocrMarked = JSON.parse(data.ocrMarked);
if (ocrMarked.markerList) {
markerList.value = ocrMarked.markerList;
}
}
}
} catch (error) {
ElMessage.error('加载数据失败');
// 加载失败时也清空所有数据
clearAll();
}
}
// 添加清空所有数据的函数
function clearAll() {
imageUrl.value = null;
rotation.value = 0;
scale.value = 1;
markerList.value = [];
markerTemplate.value = [];
}
// 解析数据
async function parseData() {
if (!imageUrl.value) {
ElMessage.warning('请先上传图片');
return;
}
selectedChatModel.value = Local.get(`selectedAiModel:Chat`);
isParsing.value = true;
try {
// 从 imageUrl 中提取文件名
const fileName = other.getQueryString(imageUrl.value, 'fileName');
const response = await parseObj({
id: id.value,
visionModelName: selectedVisionModel.value?.name,
chatModelName: selectedChatModel.value?.name,
imageResource: fileName,
ocrMarked: JSON.stringify({
markers: markerTemplate.value,
markerList: markerList.value,
}),
});
if (response?.data) {
// 解析返回的 JSON 字符串
const parsedData = JSON.parse(response.data);
if (!parsedData.isContain) {
ElMessage.warning('上传底图和目标数据类型图片不一致');
return;
}
// 将解析结果与标记模板匹配
markerList.value = markerTemplate.value.map((template: any) => {
const value = parsedData[template.name] || ''; // 使用模板的 name 属性作为键
return {
...template,
markedValue: value,
};
});
ElMessage.success('解析完成');
}
} catch (error) {
console.error('Parse error:', error);
ElMessage.error('解析失败');
} finally {
isParsing.value = false;
}
}
// 处理编辑标记
function handleEditMarker(updatedMarker: any) {
const index = markerList.value.findIndex((marker) => marker.id === updatedMarker.id);
if (index !== -1) {
// 保持原有的其他属性(如果有的话)
markerList.value[index] = {
...markerList.value[index],
name: updatedMarker.name,
description: updatedMarker.description,
};
}
}
// 在组件挂载时加载数据
onMounted(() => {
loadData();
});
</script>
<style scoped>
.el-main {
padding: 0;
overflow: hidden;
}
.el-card {
height: 100%;
display: flex;
flex-direction: column;
}
.splitpanes__pane {
overflow: hidden;
}
img {
max-width: 100%;
object-fit: contain;
margin: auto;
will-change: transform;
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<transition name="el-zoom-in-center">
<div
v-show="isShow"
class="el-dropdown__popper el-popper is-light is-pure custom-contextmenu"
:style="`top: ${y}px; left: ${x}px;`"
>
<ul class="el-dropdown-menu">
<li
v-for="item in menuItems"
:key="item.text"
class="el-dropdown-menu__item"
@click="handleItemClick(item)"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.text }}</span>
</li>
</ul>
<div class="el-popper__arrow" :style="{ left: '10px' }"></div>
</div>
</transition>
</template>
<script setup>
import { ref, defineProps, defineEmits } from 'vue';
const props = defineProps({
x: Number,
y: Number,
menuItems: Array,
});
const emit = defineEmits(['itemClick', 'close']);
const isShow = ref(false);
const handleItemClick = (item) => {
emit('itemClick', item);
isShow.value = false;
};
const show = () => {
isShow.value = true;
};
const hide = () => {
isShow.value = false;
};
defineExpose({ show, hide });
</script>
<style scoped>
.custom-contextmenu {
position: fixed;
z-index: 2190;
min-width: 120px;
background-color: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
}
.el-dropdown-menu__item {
font-size: 14px;
padding: 8px 16px;
cursor: pointer;
display: flex;
align-items: center;
}
.el-dropdown-menu__item:hover {
background-color: #f5f7fa;
}
.el-icon {
margin-right: 8px;
}
</style>

View File

@@ -0,0 +1,22 @@
<template>
<div class="flex flex-col space-y-2 w-full">
<el-button class="w-full" v-if="!isPreview" type="primary" @click="$emit('saveData')">
<el-icon class="mr-1"><Document /></el-icon>保存数据
</el-button>
<el-button class="w-full" v-if="isPreview" type="success" @click="$emit('parseData')" :loading="isParsing" :disabled="isParsing || !hasImage">
<el-icon class="mr-1"><Connection /></el-icon>{{ isParsing ? '解析中...' : '解析数据' }}
</el-button>
<el-button class="w-full" type="warning" @click="$emit('clearAll')" style="margin-left: 0">
<el-icon class="mr-1"><Delete /></el-icon>清空图片
</el-button>
</div>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
defineProps({
isPreview: Boolean,
isParsing: Boolean,
hasImage: Boolean,
});
</script>

View File

@@ -0,0 +1,215 @@
<template>
<div>
<div class="flex justify-between items-center mb-4">
<div class="flex gap-2 items-center">
<el-button v-if="!isPreview" type="primary" size="small" @click="handleAddMarker">
<el-icon><Plus /></el-icon>
新增标记
</el-button>
<ModelList v-if="isPreview" :modelType="['Chat']" :support-json="true" />
</div>
</div>
<el-table
:data="markerList"
style="width: 100%"
:border="true"
size="large"
row-class-name="hover:bg-gray-50"
header-row-class-name="bg-gray-50"
@row-dblclick="handleRowDblClick"
>
<el-table-column prop="label" label="#" width="60" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain" round>{{ row.label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="属性" width="120" align="center">
<template #default="{ row }">
<span class="font-medium">{{ row.name }}</span>
</template>
</el-table-column>
<el-table-column :label="isPreview ? '标记值' : '描述'" min-width="200" show-overflow-tooltip align="left">
<template #default="scope">
<el-tooltip
:content="isPreview ? scope.row.markedValue : scope.row.description"
placement="top"
:effect="isPreview ? 'dark' : 'light'"
:show-after="500"
>
<div class="px-2 py-1">
<span class="text-gray-600">
{{ isPreview ? scope.row.markedValue : scope.row.description }}
</span>
</div>
</el-tooltip>
</template>
</el-table-column>
<el-table-column v-if="!isPreview" label="操作" width="60" fixed="right" align="center">
<template #default="scope">
<el-button type="danger" :icon="Delete" circle size="small" @click="handleDelete(scope.row)" />
</template>
</el-table-column>
</el-table>
<!-- 新增标记对话框 -->
<el-dialog v-model="dialogVisible" title="新增标记" width="500px" :close-on-click-modal="false" destroy-on-close>
<el-form :model="form" :rules="rules" ref="formRef" label-width="80px" class="py-4">
<el-form-item label="属性" prop="name">
<el-input v-model="form.name" placeholder="请输入属性名" clearable />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" :rows="4" placeholder="请输入描述" show-word-limit maxlength="500" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">确认</el-button>
</div>
</template>
</el-dialog>
<!-- 编辑标记对话框 -->
<el-dialog v-model="editDialogVisible" title="编辑标记" width="500px" :close-on-click-modal="false" destroy-on-close>
<el-form :model="editForm" :rules="rules" ref="editFormRef" label-width="80px" class="py-4">
<el-form-item label="属性" prop="name">
<el-input v-model="editForm.name" placeholder="请输入属性名" clearable />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="editForm.description" type="textarea" :rows="4" placeholder="请输入描述" show-word-limit maxlength="500" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="editDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitEditForm">确认</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ElTable, ElTableColumn, ElButton, ElTooltip, ElMessage, ElMessageBox } from 'element-plus';
import { Plus, Delete } from '@element-plus/icons-vue';
import { defineAsyncComponent } from 'vue';
import type { FormInstance } from 'element-plus';
import { rule } from '/@/utils/validate';
const ModelList = defineAsyncComponent(() => import('/@/views/knowledge/aiChat/components/widgets/modelList.vue'));
interface Props {
markerList: Array<any>;
isPreview: boolean;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'removeMarker', id: number): void;
(e: 'editMarker', marker: any): void;
(e: 'selectChatModel', model: any): void;
(e: 'addMarker', marker: any): void;
}>();
const dialogVisible = ref(false);
const formRef = ref<FormInstance>();
const form = ref({
name: '',
description: '',
});
const rules = {
name: [
{ required: true, message: '请输入属性名', trigger: 'blur' },
{ validator: rule.overLength, trigger: 'blur' },
{ validator: rule.validatorLowercase, trigger: 'blur' },
],
description: [
{ required: true, message: '请输入描述', trigger: 'blur' },
{ validator: rule.overLength, trigger: 'blur' },
],
};
function handleAddMarker() {
dialogVisible.value = true;
form.value = {
name: '',
description: '',
};
}
function submitForm() {
if (!formRef.value) return;
formRef.value.validate((valid) => {
if (valid) {
const newMarker = {
id: Date.now(),
label: (props.markerList.length + 1).toString(),
name: form.value.name,
description: form.value.description,
};
emit('addMarker', newMarker);
dialogVisible.value = false;
ElMessage.success('新增标记成功');
}
});
}
function handleDelete(row: any) {
ElMessageBox.confirm('确认删除该标记吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
emit('removeMarker', row.id);
ElMessage.success('删除成功');
});
}
// 添加编辑相关的响应式变量
const editDialogVisible = ref(false);
const editFormRef = ref<FormInstance>();
const editForm = ref({
id: '',
label: '',
name: '',
description: '',
});
// 处理双击行事件
function handleRowDblClick(row: any) {
if (props.isPreview) return; // 预览模式下不允许编辑
editForm.value = {
id: row.id,
label: row.label,
name: row.name,
description: row.description,
};
editDialogVisible.value = true;
}
// 提交编辑表单
function submitEditForm() {
if (!editFormRef.value) return;
editFormRef.value.validate((valid) => {
if (valid) {
const updatedMarker = {
...editForm.value,
};
emit('editMarker', updatedMarker);
editDialogVisible.value = false;
ElMessage.success('编辑标记成功');
}
});
}
</script>

View File

@@ -0,0 +1,84 @@
<template>
<el-dialog
v-model="dialogVisible"
title="标记信息"
width="30%"
:close-on-click-modal="false"
>
<el-form :model="form" :rules="rules" ref="formRef">
<el-form-item prop="name" label="字段名" :label-width="formLabelWidth">
<el-input v-model="form.name" autocomplete="off"></el-input>
</el-form-item>
<el-form-item prop="description" label="字段描述" :label-width="formLabelWidth">
<el-input
v-model="form.description"
type="textarea"
:rows="4"
placeholder="请输入描述"
></el-input>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" @click="submitForm">确认</el-button>
</span>
</template>
</el-dialog>
</template>
<script setup>
import {rule} from "/@/utils/validate";
const props = defineProps({
initialName: String,
initialDescription: String,
});
const emit = defineEmits(['submit', 'close']);
const dialogVisible = ref(false);
const formRef = ref(null);
const form = reactive({
name: props.initialName || '',
description: props.initialDescription || ''
});
const rules = {
name: [
{required: true, message: '请输入属性名', trigger: 'blur'},
{validator: rule.overLength, trigger: 'blur'},
{validator: rule.validatorLowercase, trigger: 'blur'}
],
description: [
{required: true, message: '请输入描述', trigger: 'blur'},
{validator: rule.overLength, trigger: 'blur'},
]
};
const formLabelWidth = '80px';
const submitForm = () => {
formRef.value.validate((valid) => {
if (valid) {
emit('submit', {name: form.name, description: form.description});
closeDialog();
} else {
return false;
}
});
};
const closeDialog = () => {
dialogVisible.value = false;
emit('close');
};
const showDialog = (name, description) => {
form.name = name || '';
form.description = description || '';
dialogVisible.value = true;
};
defineExpose({showDialog});
</script>

View File

@@ -0,0 +1,155 @@
<template>
<div class="w-full h-[80vh] overflow-y-auto relative">
<div class="flex absolute top-0 left-0 z-20 items-center px-4 py-2 w-full bg-white bg-opacity-70">
<el-button-group class="mr-4">
<el-button @click="rotateImage(-90)" :icon="Refresh" circle></el-button>
<el-button @click="zoomIn" :icon="ZoomIn" circle></el-button>
<el-button @click="zoomOut" :icon="ZoomOut" circle></el-button>
</el-button-group>
</div>
<vk-stage
v-if="imageUrl"
ref="stageRef"
class="mt-12 w-full cursor-pointer"
:config="stageConfig"
@dblclick="handleStageDoubleClick"
@contextmenu.prevent="handleStageContextMenu"
>
<vk-layer>
<vk-image :config="imageConfig" />
</vk-layer>
</vk-stage>
</div>
</template>
<script setup>
import { Refresh, ZoomIn, ZoomOut } from '@element-plus/icons-vue';
const props = defineProps(['config', 'imageUrl', 'imageElement', 'markers', 'isPreview']);
const emit = defineEmits([
'addMarker',
'updateMarkerPosition',
'updateMarkerSize',
'removeMarker',
'addDescription',
'showContextMenu',
'markerClick',
'updateConfig',
]);
const stageRef = ref(null);
const stageConfig = computed(() => {
const containerWidth = document.querySelector('.konvajs-content')?.clientWidth || window.innerWidth;
return {
width: containerWidth,
height: props.config.height,
scaleX: props.config.scale,
scaleY: props.config.scale,
style: { width: '100%' },
};
});
const imageConfig = computed(() => {
const rotation = props.config.rotation || 0;
const isRotated90or270 = rotation % 180 === 90;
return {
x: props.config.width / 2,
y: props.config.height / 2,
image: props.imageElement,
width: isRotated90or270 ? props.config.height : props.config.width,
height: isRotated90or270 ? props.config.width : props.config.height,
offsetX: (isRotated90or270 ? props.config.height : props.config.width) / 2,
offsetY: (isRotated90or270 ? props.config.width : props.config.height) / 2,
rotation: rotation,
};
});
const rotateImage = (angle) => {
emit('updateConfig', {
...props.config,
rotation: (props.config.rotation || 0) + angle,
});
};
const zoomIn = () => {
emit('updateConfig', {
...props.config,
scale: (props.config.scale || 1) * 1.1,
});
};
const zoomOut = () => {
emit('updateConfig', {
...props.config,
scale: (props.config.scale || 1) / 1.1,
});
};
const handleStageDoubleClick = (event) => {
if (props.isPreview) return;
const stage = event.target.getStage();
const pos = stage.getPointerPosition();
// 获取旋转角度
const rotation = props.config.rotation || 0;
const isRotated90or270 = rotation % 180 === 90;
// 计算相对于图片中心的坐标
const centerX = props.config.width / 2;
const centerY = props.config.height / 2;
// 转换点击坐标到旋转后的坐标系
let adjustedX = pos.x - centerX;
let adjustedY = pos.y - centerY;
// 根据旋转角度调整坐标
const rad = (rotation * Math.PI) / 180;
const cos = Math.cos(rad);
const sin = Math.sin(rad);
const rotatedX = adjustedX * cos + adjustedY * sin;
const rotatedY = -adjustedX * sin + adjustedY * cos;
// 转换回绝对坐标
const finalX = rotatedX + centerX;
const finalY = rotatedY + centerY;
emit('addMarker', {
evt: {
offsetX: finalX,
offsetY: finalY,
rotation: rotation, // 传递当前旋转角度
},
});
};
const handleStageContextMenu = (event) => {
if (props.isPreview) return;
const stage = event.target.getStage();
const pos = stage.getPointerPosition();
emit('showContextMenu', {
x: pos.x,
y: pos.y,
items: [{ text: '添加标记', action: () => handleStageDoubleClick(event) }],
});
};
const getStage = () => {
return stageRef.value ? stageRef.value.getNode() : null;
};
defineExpose({ getStage });
</script>
<style>
.konvajs-content {
width: 100% !important;
}
canvas {
width: 100% !important;
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<el-dialog :title="form.id ? '编辑' : '新增'" v-model="visible"
:close-on-click-modal="false" draggable>
<el-form ref="dataFormRef" :model="form" :rules="dataRules" formDialogRef label-width="90px" v-loading="loading">
<el-form-item label="标题" prop="ocrTitle">
<el-input v-model="form.ocrTitle" placeholder="请输入标题"/>
</el-form-item>
<el-form-item label="描述" prop="ocrPrompt">
<el-input type="textarea" :rows="5" v-model="form.ocrPrompt" placeholder="请输入描述提示词"/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="visible = false"> </el-button>
<el-button type="primary" @click="onSubmit" :disabled="loading"> </el-button>
</span>
</template>
</el-dialog>
</template>
<script setup lang="ts" name="AiOcrConfDialog">
import {useDict} from '/@/hooks/dict';
import {useMessage} from "/@/hooks/message";
import {getObj, addObj, putObj, validateExist} from '/@/api/knowledge/ocr'
import {rule} from '/@/utils/validate';
const emit = defineEmits(['refresh']);
// 定义变量内容
const dataFormRef = ref();
const visible = ref(false)
const loading = ref(false)
// 定义字典
// 提交表单数据
const form = reactive({
id: '',
ocrTitle: '',
ocrPrompt: '',
imageResource: '',
ocrMarked: '',
});
// 定义校验规则
const dataRules = {
ocrTitle: [
{required: true, message: '请输入功能名称', trigger: 'blur'},
{validator: rule.overLength, trigger: 'blur'}
],
ocrPrompt: [
{required: true, message: '请输入提示词描述', trigger: 'blur'},
{validator: rule.overLength, trigger: 'blur'},
]
};
// 打开弹窗
const openDialog = (id: string) => {
visible.value = true
form.id = ''
// 重置表单数据
nextTick(() => {
dataFormRef.value?.resetFields();
});
// 获取aiOcrConf信息
if (id) {
form.id = id
getAiOcrConfData(id)
}
};
// 提交
const onSubmit = async () => {
const valid = await dataFormRef.value.validate().catch(() => {
});
if (!valid) return false;
try {
loading.value = true;
form.id ? await putObj(form) : await addObj(form);
useMessage().success(form.id ? '修改成功' : '添加成功');
visible.value = false;
emit('refresh');
} catch (err: any) {
useMessage().error(err.msg);
} finally {
loading.value = false;
}
};
// 初始化表单数据
const getAiOcrConfData = (id: string) => {
// 获取数据
loading.value = true
getObj({id: id}).then((res: any) => {
Object.assign(form, res.data[0])
}).finally(() => {
loading.value = false
})
};
// 暴露变量
defineExpose({
openDialog
});
</script>

View File

@@ -0,0 +1,235 @@
<template>
<div class="layout-padding">
<div class="layout-padding-auto layout-padding-view">
<el-row v-show="showSearch">
<el-form :model="state.queryForm" ref="queryRef" :inline="true" @keyup.enter="getDataList">
<el-form-item label="标题" prop="ocrTitle">
<el-input placeholder="请输入标题" v-model="state.queryForm.ocrTitle" />
</el-form-item>
<el-form-item label="提示词" prop="ocrPrompt">
<el-input placeholder="请输入提示词" v-model="state.queryForm.ocrPrompt" />
</el-form-item>
<el-form-item>
<el-button icon="search" type="primary" @click="getDataList"> 查询 </el-button>
<el-button icon="Refresh" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</el-row>
<el-row>
<div class="mb8" style="width: 100%">
<el-button icon="folder-add" type="primary" class="ml10" @click="formDialogRef.openDialog()" v-auth="'knowledge_ocr_add'">
</el-button>
<el-button plain :disabled="multiple" icon="Delete" type="primary" v-auth="'knowledge_ocr_del'" @click="handleDelete(selectObjs)">
</el-button>
<right-toolbar
v-model:showSearch="showSearch"
:export="'knowledge_ocr_export'"
@exportExcel="exportExcel"
class="ml10 mr20"
style="float: right"
@queryTable="getDataList"
></right-toolbar>
</div>
</el-row>
<el-scrollbar class="h-[calc(100vh-280px)] mb-4">
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div
v-for="item in state.dataList"
:key="item.id"
class="group overflow-hidden bg-white rounded-lg shadow-sm border border-gray-100 transition-all duration-300 cursor-pointer dark:bg-gray-800 dark:border-gray-700 hover:shadow-lg hover:border-primary-100 hover:translate-y-[-2px]"
>
<div class="p-5">
<div class="flex items-start" @click="go2marker(item, 1)">
<div
class="flex justify-center items-center text-lg font-medium text-white bg-indigo-600 rounded-lg transition-transform size-12 group-hover:scale-110"
>
{{ item.ocrTitle ? item.ocrTitle.substring(0, 1).toUpperCase() : '' }}
</div>
<div class="overflow-hidden flex-1 ml-3">
<div class="text-base font-medium text-gray-900 truncate dark:text-white">
{{ item.ocrTitle }}
</div>
</div>
</div>
<!-- 描述区域 -->
<div @click="go2marker(item, 1)" class="overflow-y-auto mt-4 h-16 text-sm text-gray-600 dark:text-gray-300 line-clamp-3">
{{ item.ocrPrompt || '暂无描述' }}
</div>
<div class="flex justify-start items-center pt-3 mt-4 border-t border-gray-100 dark:border-gray-700">
<el-button
v-if="item.ocrMarked"
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
v-auth="'knowledge_ocr_edit'"
@click="go2marker(item, 1)"
>
<el-icon><Document /></el-icon>
</el-button>
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
v-auth="'knowledge_ocr_edit'"
@click="go2marker(item)"
>
<el-icon><Picture /></el-icon>
</el-button>
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
v-auth="'knowledge_ocr_edit'"
@click="formDialogRef.openDialog(item.id)"
>
<el-icon><Edit /></el-icon>
</el-button>
<el-button
class="!p-2 text-gray-600 rounded-full transition-colors dark:text-gray-300 hover:text-primary hover:bg-gray-100 dark:hover:bg-gray-700"
text
type="primary"
v-auth="'knowledge_ocr_del'"
@click="handleDelete([item.id])"
>
<el-icon><Delete /></el-icon>
</el-button>
<div class="flex-grow"></div>
<div class="flex items-center text-xs text-gray-500 dark:text-gray-400">
<el-icon class="mr-1"><Clock /></el-icon>
{{ parseDate(item.createTime) }}
</div>
<el-checkbox
class="ml-4"
:value="selectObjs.includes(item.id)"
@change="(val: boolean) => handleCardSelect(val, item.id)"
></el-checkbox>
</div>
</div>
</div>
</div>
</el-scrollbar>
<!-- 无数据显示 -->
<el-empty v-if="!state.dataList || state.dataList.length === 0" description="暂无数据"></el-empty>
<pagination @size-change="sizeChangeHandle" @current-change="currentChangeHandle" v-bind="state.pagination" />
</div>
<!-- 编辑新增 -->
<form-dialog ref="formDialogRef" @refresh="getDataList(false)" />
<!-- 导入excel -->
<upload-excel
ref="excelUploadRef"
title="导入"
url="/knowledge/ocr/import"
temp-url="/admin/sys-file/local/file/ocr.xlsx"
@refreshDataList="getDataList"
/>
</div>
</template>
<script setup lang="ts" name="systemAiOcrConf">
import { BasicTableProps, useTable } from '/@/hooks/table';
import { fetchList, delObjs } from '/@/api/knowledge/ocr';
import { useMessage, useMessageBox } from '/@/hooks/message';
import { Document, Picture, Edit, Delete, Clock } from '@element-plus/icons-vue';
import { useRouter } from 'vue-router';
import { parseDate } from '/@/utils/formatTime';
// 引入组件
const FormDialog = defineAsyncComponent(() => import('./form.vue'));
// 定义变量内容
const formDialogRef = ref();
const excelUploadRef = ref();
// 搜索变量
const queryRef = ref();
const showSearch = ref(true);
// 多选变量
const selectObjs = ref([]) as any;
const multiple = ref(true);
const state: BasicTableProps = reactive<BasicTableProps>({
queryForm: {},
pageList: fetchList,
dataList: [],
});
// table hook
const { getDataList, currentChangeHandle, sizeChangeHandle, downBlobFile } = useTable(state);
// 清空搜索条件
const resetQuery = () => {
// 清空搜索条件
queryRef.value?.resetFields();
// 清空多选
selectObjs.value = [];
getDataList();
};
// 导出excel
const exportExcel = () => {
downBlobFile('/knowledge/ocr/export', Object.assign(state.queryForm, { ids: selectObjs }), 'ocr.xlsx');
};
// 多选事件 - 为卡片视图添加的选择函数
const handleCardSelect = (selected: boolean, id: string) => {
if (selected) {
selectObjs.value.push(id);
} else {
selectObjs.value = selectObjs.value.filter((item: string) => item !== id);
}
multiple.value = selectObjs.value.length === 0;
};
// 删除操作
const handleDelete = async (ids: string[]) => {
try {
await useMessageBox().confirm('此操作将永久删除');
} catch {
return;
}
try {
await delObjs(ids);
getDataList();
useMessage().success('删除成功');
} catch (err: any) {
useMessage().error(err.msg);
}
};
// 跳转到底图标注页面
const router = useRouter();
const go2marker = (row: { id: string; ocrTitle: string }, preview?: number) => {
router.push({ path: '/knowledge/ocr/KonvaPage', query: { id: row.id, preview, tagsViewName: row.ocrTitle } });
};
</script>
<style lang="scss" scoped>
:deep(.el-scrollbar__wrap) {
overflow-x: hidden !important;
}
:deep(.el-checkbox) {
margin-right: 0;
}
.bg-primary-100 {
background-color: var(--el-color-primary-light-9);
}
.text-primary-500 {
color: var(--el-color-primary);
}
</style>