This commit is contained in:
che
2026-07-25 11:15:39 +05:00
parent 860ba21dd0
commit 0544d41b54
27 changed files with 766 additions and 120 deletions
@@ -0,0 +1,128 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { contractCategoryApi } from "../../../generated/api";
import type { ContractCategoryListParams } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
const PAGE_SIZE = 30;
const selectedCategoryId = defineModel<number>({ required: true });
const search = ref("");
const showSelector = ref(false);
const {
items: categories,
filters,
loading,
} = useModelApi(contractCategoryApi, {
defaultListParams: { ordering: "id", limit: PAGE_SIZE, offset: 0 } as ContractCategoryListParams,
loadErrorMessage: "Не удалось загрузить категории",
cleanListParams(params) {
params.name__contains = params.name__contains?.trim() || undefined;
},
});
const selectedCategory = computed(() =>
categories.value.find((category) => category.id === selectedCategoryId.value),
);
const selectedCategoryName = computed(() => selectedCategory.value?.name ?? "все");
function openSelector() {
search.value = "";
showSelector.value = true;
}
function selectCategory(id: number) {
selectedCategoryId.value = id;
showSelector.value = false;
}
watch(search, (value) => {
filters.name__contains = value.trim();
filters.offset = 0;
});
</script>
<template>
<button class="entity-select" type="button" @click="openSelector">
<span>{{ selectedCategoryName }}</span>
<van-icon name="arrow" />
</button>
<van-popup v-model:show="showSelector" round position="bottom" class="entity-popup">
<div class="entity-popup-header">
<h2>Выберите категорию</h2>
<van-button size="small" type="primary" plain @click="selectCategory(0)">Все</van-button>
</div>
<van-search v-model="search" placeholder="Поиск по категории" />
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
<template v-else>
<van-cell-group inset>
<van-cell
v-for="category in categories"
:key="category.id"
:title="category.name"
:label="category.name_group || `ID: ${category.id}`"
clickable
center
@click="selectCategory(category.id)"
>
<template #right-icon>
<van-icon v-if="selectedCategoryId === category.id" name="success" color="#1989fa" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="categories.length === 0" description="Категории не найдены" />
</template>
</van-popup>
</template>
<style scoped>
.entity-popup {
min-height: 55vh;
padding: 18px 0 28px;
}
.entity-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px 10px;
}
.entity-popup-header h2 {
margin: 0;
font-size: 18px;
}
.entity-state {
display: flex;
justify-content: center;
padding: 36px 0;
}
.entity-select {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
width: 100%;
border: 0;
padding: 0;
color: #323233;
background: transparent;
font: inherit;
line-height: 24px;
text-align: right;
}
.entity-select .van-icon {
color: #969799;
font-size: 16px;
}
</style>
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { counterpartyApi } from "../../../generated/api";
import type { CounterpartyListParams } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
const PAGE_SIZE = 20;
const selectedCounterpartyId = defineModel<number>({ required: true });
const search = ref("");
const showSelector = ref(false);
const {
items: counterparties,
filters,
loading,
} = useModelApi(counterpartyApi, {
defaultListParams: { ordering: "id", limit: PAGE_SIZE, offset: 0 } as CounterpartyListParams,
loadErrorMessage: "Не удалось загрузить контрагентов",
cleanListParams: (params) => {
if (!params.name__contains?.trim()) {
delete params.name__contains;
}
},
});
const selectedCounterparty = computed(() =>
counterparties.value.find((counterparty) => counterparty.id === selectedCounterpartyId.value),
);
const selectedCounterpartyName = computed(() => selectedCounterparty.value?.name ?? "все");
function openSelector() {
search.value = "";
showSelector.value = true;
}
function selectCounterparty(id: number) {
selectedCounterpartyId.value = id;
showSelector.value = false;
}
watch(search, (value) => {
filters.name__contains = value.trim();
filters.offset = 0;
});
</script>
<template>
<button class="counterparty-select" type="button" @click="openSelector">
<span>{{ selectedCounterpartyName }}</span>
<van-icon name="arrow" />
</button>
<van-popup v-model:show="showSelector" round position="bottom" class="counterparty-popup">
<div class="counterparty-popup-header">
<h2>Выберите контрагента</h2>
<van-button size="small" type="primary" plain @click="selectCounterparty(0)">Все</van-button>
</div>
<van-search v-model="search" placeholder="Поиск по имени" />
<van-loading v-if="loading" class="counterparty-state" type="spinner">Загрузка...</van-loading>
<template v-else>
<van-cell-group inset>
<van-cell
v-for="counterparty in counterparties"
:key="counterparty.id"
:title="counterparty.name"
:label="`ID: ${counterparty.id}`"
clickable
center
@click="selectCounterparty(counterparty.id)"
>
<template #right-icon>
<van-icon
v-if="selectedCounterpartyId === counterparty.id"
name="success"
color="#1989fa"
/>
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="counterparties.length === 0" description="Контрагенты не найдены" />
</template>
</van-popup>
</template>
<style scoped>
.counterparty-popup {
min-height: 55vh;
padding: 18px 0 28px;
}
.counterparty-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px 10px;
}
.counterparty-popup-header h2 {
margin: 0;
font-size: 18px;
}
.counterparty-state {
display: flex;
justify-content: center;
padding: 36px 0;
}
.counterparty-select {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
width: 100%;
border: 0;
padding: 0;
color: #323233;
background: transparent;
font: inherit;
line-height: 24px;
text-align: right;
}
.counterparty-select .van-icon {
color: #969799;
font-size: 16px;
}
</style>
+126
View File
@@ -0,0 +1,126 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { frcApi } from "../../../generated/api";
import type { FrcListParams } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
const PAGE_SIZE = 20;
const selectedFrcId = defineModel<number>({ required: true });
const search = ref("");
const showSelector = ref(false);
const {
items: frcs,
filters,
loading,
} = useModelApi(frcApi, {
defaultListParams: { ordering: "id", limit: PAGE_SIZE, offset: 0 } as FrcListParams,
loadErrorMessage: "Не удалось загрузить ФРЦ",
cleanListParams(params) {
params.name__contains = params.name__contains?.trim() || undefined;
},
});
const selectedFrc = computed(() => frcs.value.find((frc) => frc.id === selectedFrcId.value));
const selectedFrcName = computed(() => selectedFrc.value?.name ?? "все");
function openSelector() {
search.value = "";
showSelector.value = true;
}
function selectFrc(id: number) {
selectedFrcId.value = id;
showSelector.value = false;
}
watch(search, (value) => {
filters.name__contains = value.trim();
filters.offset = 0;
});
</script>
<template>
<button class="entity-select" type="button" @click="openSelector">
<span>{{ selectedFrcName }}</span>
<van-icon name="arrow" />
</button>
<van-popup v-model:show="showSelector" round position="bottom" class="entity-popup">
<div class="entity-popup-header">
<h2>Выберите ФРЦ</h2>
<van-button size="small" type="primary" plain @click="selectFrc(0)">Все</van-button>
</div>
<van-search v-model="search" placeholder="Поиск по названию" />
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
<template v-else>
<van-cell-group inset>
<van-cell
v-for="frc in frcs"
:key="frc.id"
:title="frc.name"
:label="`Баланс: ${frc.balance}`"
clickable
center
@click="selectFrc(frc.id)"
>
<template #right-icon>
<van-icon v-if="selectedFrcId === frc.id" name="success" color="#1989fa" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="frcs.length === 0" description="ФРЦ не найдены" />
</template>
</van-popup>
</template>
<style scoped>
.entity-popup {
min-height: 55vh;
padding: 18px 0 28px;
}
.entity-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px 10px;
}
.entity-popup-header h2 {
margin: 0;
font-size: 18px;
}
.entity-state {
display: flex;
justify-content: center;
padding: 36px 0;
}
.entity-select {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
width: 100%;
border: 0;
padding: 0;
color: #323233;
background: transparent;
font: inherit;
line-height: 24px;
text-align: right;
}
.entity-select .van-icon {
color: #969799;
font-size: 16px;
}
</style>
@@ -0,0 +1,264 @@
<script setup lang="ts">
import { invoke } from "@tauri-apps/api/core";
import {
GlobalWorkerOptions,
getDocument,
type PDFDocumentLoadingTask,
type PDFDocumentProxy,
type RenderTask,
} from "pdfjs-dist";
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
const props = defineProps<{
localPath: string;
scanUrl: string;
title: string;
}>();
const show = defineModel<boolean>("show", { required: true });
GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
const canvasRef = ref<HTMLCanvasElement | null>(null);
const pdfDocument = ref<PDFDocumentProxy | null>(null);
const loading = ref(false);
const error = ref("");
const pageNumber = ref(1);
const pageCount = ref(0);
const scale = ref(1.1);
let loadingTask: PDFDocumentLoadingTask | null = null;
let renderTask: RenderTask | null = null;
const pageInfo = computed(() => `${pageNumber.value} / ${pageCount.value}`);
const canGoBack = computed(() => pageNumber.value > 1);
const canGoForward = computed(() => pageNumber.value < pageCount.value);
async function loadDocument() {
resetDocument();
loading.value = true;
error.value = "";
let task: PDFDocumentLoadingTask | null = null;
try {
const bytes = await invoke<number[]>("load_application_file", {
localPath: props.localPath,
scanUrl: props.scanUrl,
});
if (!bytes.length) {
throw new Error("Файл пустой");
}
task = getDocument({ data: new Uint8Array(bytes) });
loadingTask = task;
const document = await task.promise;
if (loadingTask !== task) {
await document.destroy();
return;
}
pdfDocument.value = document;
pageCount.value = document.numPages;
pageNumber.value = 1;
scale.value = 1.1;
await renderPage();
} catch (err) {
error.value = err instanceof Error ? err.message : "Не удалось открыть PDF";
} finally {
loading.value = false;
if (task && loadingTask === task) {
loadingTask = null;
}
}
}
async function renderPage() {
const document = pdfDocument.value;
const canvas = canvasRef.value;
if (!document || !canvas) {
return;
}
renderTask?.cancel();
const page = await document.getPage(pageNumber.value);
const viewport = page.getViewport({ scale: scale.value });
const context = canvas.getContext("2d");
if (!context) {
error.value = "Не удалось создать canvas context";
return;
}
const devicePixelRatio = window.devicePixelRatio || 1;
canvas.width = Math.floor(viewport.width * devicePixelRatio);
canvas.height = Math.floor(viewport.height * devicePixelRatio);
canvas.style.width = `${viewport.width}px`;
canvas.style.height = `${viewport.height}px`;
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
renderTask = page.render({ canvas: canvas, canvasContext: context, viewport });
try {
await renderTask.promise;
} catch (err) {
if (!(err instanceof Error) || err.name !== "RenderingCancelledException") {
error.value = err instanceof Error ? err.message : "Не удалось отрисовать PDF";
}
}
}
function resetDocument() {
renderTask?.cancel();
renderTask = null;
loadingTask?.destroy().catch(() => undefined);
loadingTask = null;
pdfDocument.value?.destroy().catch(() => undefined);
pdfDocument.value = null;
pageCount.value = 0;
pageNumber.value = 1;
loading.value = false;
error.value = "";
}
async function goBack() {
if (canGoBack.value) {
pageNumber.value -= 1;
await nextTick();
await renderPage();
}
}
async function goForward() {
if (canGoForward.value) {
pageNumber.value += 1;
await nextTick();
await renderPage();
}
}
async function zoomIn() {
scale.value = Math.min(scale.value + 0.2, 3);
await nextTick();
await renderPage();
}
async function zoomOut() {
scale.value = Math.max(scale.value - 0.2, 0.5);
await nextTick();
await renderPage();
}
watch([show, () => props.localPath, () => props.scanUrl], async ([visible]) => {
if (visible) {
await nextTick();
await loadDocument();
} else {
resetDocument();
}
});
onBeforeUnmount(() => {
resetDocument();
});
</script>
<template>
<van-popup v-model:show="show" round position="bottom" class="pdf-preview">
<div class="pdf-preview__header">
<div class="pdf-preview__title">{{ title }}</div>
<van-button size="small" plain @click="show = false">Закрыть</van-button>
</div>
<div class="pdf-preview__toolbar">
<van-button size="small" plain :disabled="!canGoBack" @click="goBack">
Назад
</van-button>
<div class="pdf-preview__page-info">{{ pageInfo }}</div>
<van-button size="small" plain :disabled="!canGoForward" @click="goForward">
Вперед
</van-button>
<van-button size="small" plain @click="zoomOut">-</van-button>
<van-button size="small" plain @click="zoomIn">+</van-button>
</div>
<div class="pdf-preview__body">
<van-loading v-if="loading">Загрузка PDF...</van-loading>
<van-empty v-else-if="error" :description="error" />
<div v-else class="pdf-preview__canvas-wrap">
<canvas ref="canvasRef" class="pdf-preview__canvas" />
</div>
</div>
</van-popup>
</template>
<style scoped>
.pdf-preview {
display: flex;
flex-direction: column;
height: 92vh;
background: #f5f7fb;
}
.pdf-preview__header,
.pdf-preview__toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: #fff;
}
.pdf-preview__header {
justify-content: space-between;
border-bottom: 1px solid #eef0f4;
}
.pdf-preview__title {
min-width: 0;
font-size: 16px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pdf-preview__toolbar {
justify-content: center;
flex-wrap: wrap;
border-bottom: 1px solid #eef0f4;
}
.pdf-preview__page-info {
min-width: 72px;
text-align: center;
color: #636b74;
font-size: 13px;
}
.pdf-preview__body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
}
.pdf-preview__canvas-wrap {
display: flex;
justify-content: center;
}
.pdf-preview__canvas {
max-width: 100%;
height: auto;
background: #fff;
box-shadow: 0 8px 28px rgba(15, 23, 42, 0.16);
}
</style>
@@ -0,0 +1,130 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { projectApi } from "../../../generated/api";
import type { ProjectListParams } from "../../../generated/models";
import { useModelApi } from "../../../shared/composables/useModelApi";
const PAGE_SIZE = 20;
const selectedProjectId = defineModel<number>({ required: true });
const search = ref("");
const showSelector = ref(false);
const {
items: projects,
filters,
loading,
} = useModelApi(projectApi, {
defaultListParams: { ordering: "id", limit: PAGE_SIZE, offset: 0 } as ProjectListParams,
loadErrorMessage: "Не удалось загрузить проекты",
cleanListParams(params) {
params.name__contains = params.name__contains?.trim() || undefined;
},
});
const selectedProject = computed(() =>
projects.value.find((project) => project.id === selectedProjectId.value),
);
const selectedProjectName = computed(
() => selectedProject.value?.short_name || selectedProject.value?.name || "все",
);
function openSelector() {
search.value = "";
showSelector.value = true;
}
function selectProject(id: number) {
selectedProjectId.value = id;
showSelector.value = false;
}
watch(search, (value) => {
filters.name__contains = value.trim();
filters.offset = 0;
});
</script>
<template>
<button class="entity-select" type="button" @click="openSelector">
<span>{{ selectedProjectName }}</span>
<van-icon name="arrow" />
</button>
<van-popup v-model:show="showSelector" round position="bottom" class="entity-popup">
<div class="entity-popup-header">
<h2>Выберите проект</h2>
<van-button size="small" type="primary" plain @click="selectProject(0)">Все</van-button>
</div>
<van-search v-model="search" placeholder="Поиск по проекту" />
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
<template v-else>
<van-cell-group inset>
<van-cell
v-for="project in projects"
:key="project.id"
:title="project.short_name || project.name"
:label="project.full_name || `ID: ${project.id}`"
clickable
center
@click="selectProject(project.id)"
>
<template #right-icon>
<van-icon v-if="selectedProjectId === project.id" name="success" color="#1989fa" />
</template>
</van-cell>
</van-cell-group>
<van-empty v-if="projects.length === 0" description="Проекты не найдены" />
</template>
</van-popup>
</template>
<style scoped>
.entity-popup {
min-height: 55vh;
padding: 18px 0 28px;
}
.entity-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 18px 10px;
}
.entity-popup-header h2 {
margin: 0;
font-size: 18px;
}
.entity-state {
display: flex;
justify-content: center;
padding: 36px 0;
}
.entity-select {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
width: 100%;
border: 0;
padding: 0;
color: #323233;
background: transparent;
font: inherit;
line-height: 24px;
text-align: right;
}
.entity-select .van-icon {
color: #969799;
font-size: 16px;
}
</style>