first commit

This commit is contained in:
brusnitsyn
2025-10-31 16:48:05 +09:00
commit 8b650558e2
143 changed files with 24664 additions and 0 deletions

View File

@@ -0,0 +1,447 @@
<script setup>
import {computed, nextTick, onMounted, ref, useTemplateRef, watch} from "vue"
import {useDateFormat, useDebounceFn} from "@vueuse/core"
import Sections from "../Layouts/Sections.vue";
import Input from '../Components/Input/Input.vue'
import Select from "../Components/Select/Select.vue";
import Card from "../Components/Card/Card.vue";
import Button from "../Components/Button/Button.vue";
import ListStrate from "../Components/List/ListStrate.vue";
import CardBack from "../Components/Card/CardBack.vue";
import {Link, router} from "@inertiajs/vue3";
import Editor from "../Components/Editor.vue";
import VuePdfEmbed, { useVuePdfEmbed } from 'vue-pdf-embed'
import {useFileDownload} from "../Composables/useFileDownload.js";
import PriceInput from "../Components/Document/InputVariable/PriceInput.vue";
import TextArea from "../Components/Input/TextArea.vue";
import Calendar from "../Components/Calendar/Calendar.vue";
import Collapsible from "../Components/Collapsible/Collapsible.vue";
import Accordion from "../Components/Accordion/Accordion.vue";
const { downloadFile } = useFileDownload()
const props = defineProps({
template: Object,
})
const editorRef = ref(null)
const content = ref(props.template.content ?? [])
const formData = ref([])
const documentStructure = ref(props.template.content || [])
const prepareVariables = (variables) => {
for (const variable of variables) {
formData.value.push(variable)
}
}
// Форматируем ключ переменной в читаемый name
const formatLabel = (key) => {
return key
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const viewer = useTemplateRef('viewer')
onMounted(async() => {
await preview()
prepareVariables(props.template.variables)
})
const previewLoading = ref(true)
const previewUrl = ref()
const preview = async () => {
previewLoading.value = true
await axios.post(`/contract-generator/${props.template.id}/preview`, {
variables: formData.value
}, {
responseType: 'blob'
}).then(res => {
previewUrl.value = URL.createObjectURL(res.data)
})
}
const updatePreview = async () => {
await preview()
}
const onChangeVariableTextValue = (variableId, value) => {
console.log(variableId, value)
changeVariableValue(variableId, value)
}
const onChangeVariableSelectValue = (variableId, option) => {
changeVariableValue(variableId, option.value)
}
const changeVariableValue = (variableId, value) => {
if (content.value && Array.isArray(content.value)) {
const updatedContent = content.value.map(htmlString => {
return htmlString.replace(
new RegExp(`(<span[^>]*brs-element-id="${variableId}"[^>]*>)[^<]*(</span>)`, 'g'),
`$1${value}$2`
)
})
content.value = updatedContent
}
}
const onPrint = () => {
if (viewer.value)
viewer.value.print(200, props.template.name, true)
}
const onDownloadDocx = async () => {
try {
await downloadFile(
`/contract-generator/${props.template.id}/download`,
{ variables: formData.value },
`${props.template.name}.docx`
)
} catch (e) {
console.error('Ошибка при скачивании docx файла: ', e.message)
}
}
// function scrollToElementByText(text, options = {}) {
// const {
// behavior = 'smooth',
// block = 'start',
// inline = 'nearest',
// partialMatch = false,
// caseSensitive = false
// } = options
//
// // Ищем все элементы, содержащие текст
// const elements = Array.from(document.querySelectorAll('*')).filter(element => {
// const elementText = caseSensitive
// ? element.textContent
// : element.textContent.toLowerCase()
// const searchText = caseSensitive
// ? text
// : text.toLowerCase()
//
// return partialMatch
// ? elementText.includes(searchText)
// : elementText.trim() === searchText
// })
//
// if (elements.length > 0) {
// elements[0].scrollIntoView({
// behavior,
// block,
// inline
// })
// return elements[0]
// }
//
// return null
// }
const searchAndScroll = (targetText) => {
if (!targetText.trim()) return
const result = scrollToElementByText(targetText)
if (result) {
console.log(result)
highlightElement(result.element)
}
}
const scrollToElementByText = (targetText) => {
const elementContainers = document.querySelectorAll('.textLayer')
if (!elementContainers) return null
const elementsOfContainers = []
for (const container of elementContainers) {
elementsOfContainers.push(...container.children)
}
const allElements = Array.from(elementsOfContainers)
.filter(el => el.textContent && el.textContent.trim())
.filter(el => {
const style = window.getComputedStyle(el)
return style.display !== 'none' && style.visibility !== 'hidden'
})
// Сначала ищем точное совпадение
for (const el of allElements) {
if (el.textContent.trim() === targetText) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
return { element: el, foundText: targetText, isComposite: false }
}
}
// Ищем составной текст в соседних элементах
for (let i = 0; i < allElements.length - 1; i++) {
const current = allElements[i]
const next = allElements[i + 1]
const combined = current.textContent.trim() + next.textContent.trim()
if (combined === targetText) {
current.scrollIntoView({ behavior: 'smooth', block: 'center' })
return {
element: [current, next],
foundText: targetText,
isComposite: true,
parts: [current.textContent.trim(), next.textContent.trim()]
}
}
}
return null
}
const highlightElement = (element) => {
// Убираем предыдущую подсветку
document.querySelectorAll('.search-highlight').forEach(el => {
el.classList.remove('search-highlight')
})
if (Array.isArray(element)) {
for (const el of element) {
el.classList.add('search-highlight')
setTimeout(() => {
el.classList.remove('search-highlight')
}, 3000)
}
} else {
element.classList.add('search-highlight')
setTimeout(() => {
element.classList.remove('search-highlight')
}, 3000)
}
}
</script>
<template>
<Sections>
<template #leftbar>
<Card header="Информация о документе">
<div>
<ListStrate header="Наименование">
<span class="block text-sm">
{{ template.name }}
</span>
</ListStrate>
<ListStrate header="Дата обновления">
<span class="text-sm">
{{ useDateFormat(template.updated_at, 'DD.MM.YYYY HH:mm:ss') }}
</span>
</ListStrate>
<ListStrate header="Дата создания">
<span class="text-sm">
{{ useDateFormat(template.created_at, 'DD.MM.YYYY HH:mm:ss') }}
</span>
</ListStrate>
</div>
<template #footer>
<div class="flex flex-col gap-y-1">
<Button block @click="onPrint" :loading="previewLoading" icon-left>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 17h2a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h2"></path><path d="M17 9V5a2 2 0 0 0-2-2H9a2 2 0 0 0-2 2v4"></path><rect x="7" y="13" width="10" height="8" rx="2"></rect></g></svg>
</template>
Печать документа
</Button>
<Button block @click="onDownloadDocx" :loading="previewLoading" icon-left>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3v4a1 1 0 0 0 1 1h4"></path><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z"></path><path d="M12 11v6"></path><path d="M9 14l3 3l3-3"></path></g></svg>
</template>
Скачать docx
</Button>
<CardBack :tag="Link" href="/" class="mt-2" />
</div>
</template>
</Card>
</template>
<Card header="Предпросмотр" :content-scroll="!previewLoading" :content-relative>
<div class="flex flex-col items-center justify-center">
<VuePdfEmbed width="793.701" text-layer ref="viewer" :source="previewUrl" @rendered="previewLoading = false" />
<div v-if="previewLoading" class="absolute inset-0 backdrop-blur-xs h-full flex items-center justify-center z-10">
<div class="text-center space-y-4">
<div class="flex items-center justify-center">
<div class="relative">
<div class="w-8 h-8 border-3 border-gray-200 border-t-blue-500 rounded-full animate-spin"></div>
<div class="absolute inset-0 w-8 h-8 border-3 border-transparent border-r-blue-300 rounded-full animate-spin" style="animation-duration: 1.5s"></div>
</div>
</div>
</div>
</div>
</div>
</Card>
<template #rightbar>
<Card header="Свойства документа" :content-relative>
<div v-if="previewLoading" class="absolute inset-0 flex items-center justify-center">
<div class="flex items-center justify-center">
<div class="relative">
<div class="w-8 h-8 border-3 border-gray-200 border-t-blue-500 rounded-full animate-spin"></div>
<div class="absolute inset-0 w-8 h-8 border-3 border-transparent border-r-blue-300 rounded-full animate-spin" style="animation-duration: 1.5s"></div>
</div>
</div>
</div>
<template v-for="(data, key) in formData" :key="key">
<div v-if="data.type === 'group'">
<Collapsible :header="data.label">
<ListStrate v-for="(variable, key) in data.children" :key="key" :header="variable.label">
<Input
v-if="variable.type === 'text'"
:id="key"
@focus="searchAndScroll(variable.name)"
v-model:value="variable.value"
@update:value="value => onChangeVariableTextValue(key, value)"
:placeholder="`Введите ${formatLabel(variable.label)}`"
/>
<TextArea
v-if="variable.type === 'textarea'"
:rows="8"
:resize="false"
:id="key"
@focus="searchAndScroll(variable.name)"
v-model:value="variable.value"
@update:value="value => onChangeVariableTextValue(key, value)"
:placeholder="`Введите ${formatLabel(variable.label)}`"
/>
<!-- Select поле -->
<Select
v-else-if="variable.type === 'select'"
:id="key"
@focus="searchAndScroll(variable.name)"
@change="value => onChangeVariableSelectValue(key, value)"
v-model:value="variable.value"
:options="variable.options"
/>
<!-- Radio кнопки -->
<div v-else-if="variable.type === 'radio'" class="space-y-2">
<label
v-for="(optionLabel, optionValue) in variable.options"
:key="optionValue"
class="flex items-center"
>
<input
type="radio"
:name="key"
:value="optionValue"
v-model="formData[key]"
@change="updatePreview"
class="mr-2"
>
{{ optionLabel }}
</label>
</div>
<PriceInput v-else-if="variable.type === 'price-input'"
v-model:number="variable.number"
v-model:text="variable.value"
@focus="searchAndScroll(variable.name)"
/>
<Calendar v-else-if="variable.type === 'calendar'"
v-model="variable.value"
:format="variable.format"
block
@focus="searchAndScroll(variable.name)"
/>
</ListStrate>
</Collapsible>
</div>
<ListStrate v-else :key="key" :header="data.label">
<Input
v-if="data.type === 'text'"
:id="key"
@focus="searchAndScroll(data.name)"
v-model:value="data.value"
@update:value="value => onChangeVariableTextValue(key, value)"
:placeholder="`Введите ${formatLabel(data.label)}`"
/>
<TextArea
v-if="data.type === 'textarea'"
:rows="8"
:resize="false"
:id="key"
@focus="searchAndScroll(data.name)"
v-model:value="data.value"
@update:value="value => onChangeVariableTextValue(key, value)"
:placeholder="`Введите ${formatLabel(data.label)}`"
/>
<!-- Select поле -->
<Select
v-else-if="data.type === 'select'"
:id="key"
@focus="searchAndScroll(data.name)"
@change="value => onChangeVariableSelectValue(key, value)"
v-model:value="data.value"
:options="data.options"
/>
<!-- Radio кнопки -->
<div v-else-if="data.type === 'radio'" class="space-y-2">
<label
v-for="(optionLabel, optionValue) in data.options"
:key="optionValue"
class="flex items-center"
>
<input
type="radio"
:name="key"
:value="optionValue"
v-model="formData[key]"
@change="updatePreview"
class="mr-2"
>
{{ optionLabel }}
</label>
</div>
<PriceInput v-else-if="data.type === 'price-input'"
v-model:number="data.number"
v-model:text="data.value"
@focus="searchAndScroll(data.name)"
/>
<Calendar v-else-if="data.type === 'calendar'"
v-model="data.value"
:format="data.format"
block
@focus="searchAndScroll(data.name)"
/>
</ListStrate>
</template>
<template #footer>
<Button :loading="previewLoading" block @click="updatePreview">
Обновить предпросмотр
</Button>
</template>
</Card>
</template>
</Sections>
</template>
<style scoped>
@reference "tailwindcss";
:deep(.search-highlight) {
@apply border border-dashed border-yellow-500 bg-yellow-200 text-black;
}
:deep(.vue-pdf-embed) {
margin: 0 auto;
}
:deep(.vue-pdf-embed .vue-pdf-embed__page) {
margin-bottom: 20px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border-radius: 4px;
overflow: hidden;
}
</style>