fix
This commit is contained in:
+125
-15
@@ -9,6 +9,7 @@ use che_tauri::{
|
|||||||
TauriApi,
|
TauriApi,
|
||||||
};
|
};
|
||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
|
||||||
use crate::sync::SyncContractsResult;
|
use crate::sync::SyncContractsResult;
|
||||||
|
|
||||||
@@ -256,23 +257,76 @@ async fn sync_contracts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn load_application_file(local_path: String, scan_url: String) -> Result<Vec<u8>, ApiError> {
|
async fn load_remote_file(
|
||||||
if !local_path.is_empty() {
|
app: tauri::AppHandle,
|
||||||
match tokio::fs::metadata(&local_path).await {
|
api: tauri::State<'_, TauriApi>,
|
||||||
Ok(metadata) if metadata.len() > 0 => {
|
url: String,
|
||||||
return tokio::fs::read(&local_path)
|
) -> Result<Vec<u8>, ApiError> {
|
||||||
.await
|
let path = cache_remote_file(&app, &api, &url).await?;
|
||||||
.map_err(|error| ApiError::new("file_error", error.to_string()));
|
tokio::fs::read(path)
|
||||||
}
|
.await
|
||||||
Ok(_) | Err(_) => {}
|
.map_err(|error| ApiError::new("file_error", error.to_string()))
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if scan_url.is_empty() {
|
#[tauri::command]
|
||||||
|
async fn ensure_remote_file(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
api: tauri::State<'_, TauriApi>,
|
||||||
|
url: String,
|
||||||
|
) -> Result<String, ApiError> {
|
||||||
|
let path = cache_remote_file(&app, &api, &url).await?;
|
||||||
|
Ok(path.to_string_lossy().into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn open_remote_file(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
api: tauri::State<'_, TauriApi>,
|
||||||
|
url: String,
|
||||||
|
) -> Result<(), ApiError> {
|
||||||
|
let path = cache_remote_file(&app, &api, &url).await?;
|
||||||
|
app.opener()
|
||||||
|
.open_path(path.to_string_lossy().into_owned(), None::<&str>)
|
||||||
|
.map_err(|error| ApiError::new("file_error", error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cache_remote_file(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
api: &tauri::State<'_, TauriApi>,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<PathBuf, ApiError> {
|
||||||
|
if url.trim().is_empty() {
|
||||||
return Err(ApiError::new("file_error", "Файл недоступен"));
|
return Err(ApiError::new("file_error", "Файл недоступен"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = reqwest::Client::new().get(&scan_url).send().await?;
|
let remote = api.state().remote_config().ok_or_else(|| {
|
||||||
|
ApiError::bad_request("load_remote_file requires [remote].base_url config")
|
||||||
|
})?;
|
||||||
|
let file_url = normalize_remote_file_url(&remote.base_url, url)?;
|
||||||
|
let relative_path = remote_file_cache_path(file_url.path())?;
|
||||||
|
let app_data_dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|error| ApiError::new("file_error", error.to_string()))?;
|
||||||
|
let path = app_data_dir.join("remote_files").join(relative_path);
|
||||||
|
|
||||||
|
match tokio::fs::metadata(&path).await {
|
||||||
|
Ok(metadata) if metadata.len() > 0 => {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
Ok(_) | Err(_) => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
let token = api
|
||||||
|
.state()
|
||||||
|
.auth_token()
|
||||||
|
.ok_or_else(|| ApiError::new("not_authenticated", "authentication token is missing"))?;
|
||||||
|
|
||||||
|
let response = reqwest::Client::new()
|
||||||
|
.get(file_url)
|
||||||
|
.header(reqwest::header::AUTHORIZATION, format!("Token {token}"))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(ApiError::new(
|
return Err(ApiError::new(
|
||||||
"remote_error",
|
"remote_error",
|
||||||
@@ -285,7 +339,61 @@ async fn load_application_file(local_path: String, scan_url: String) -> Result<V
|
|||||||
return Err(ApiError::new("file_error", "Файл пустой"));
|
return Err(ApiError::new("file_error", "Файл пустой"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(bytes.to_vec())
|
if let Some(parent) = path.parent() {
|
||||||
|
tokio::fs::create_dir_all(parent)
|
||||||
|
.await
|
||||||
|
.map_err(|error| ApiError::new("file_error", error.to_string()))?;
|
||||||
|
}
|
||||||
|
tokio::fs::write(&path, &bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|error| ApiError::new("file_error", error.to_string()))?;
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_remote_file_url(base_url: &str, url: &str) -> Result<reqwest::Url, ApiError> {
|
||||||
|
if let Ok(parsed) = reqwest::Url::parse(url.trim()) {
|
||||||
|
return Ok(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = reqwest::Url::parse(&format!("{}/", base_url.trim_end_matches('/')))
|
||||||
|
.map_err(|error| ApiError::new("file_error", error.to_string()))?;
|
||||||
|
base.join(url.trim_start_matches('/'))
|
||||||
|
.map_err(|error| ApiError::new("file_error", error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remote_file_cache_path(url_path: &str) -> Result<PathBuf, ApiError> {
|
||||||
|
let mut path = PathBuf::new();
|
||||||
|
|
||||||
|
for segment in url_path.split('/') {
|
||||||
|
if segment.is_empty() || segment == "." || segment == ".." {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
path.push(sanitize_path_segment(segment));
|
||||||
|
}
|
||||||
|
|
||||||
|
if path.as_os_str().is_empty() {
|
||||||
|
Err(ApiError::new("file_error", "URL не содержит имя файла"))
|
||||||
|
} else {
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_path_segment(segment: &str) -> String {
|
||||||
|
let sanitized = segment
|
||||||
|
.chars()
|
||||||
|
.map(|character| match character {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
_ => character,
|
||||||
|
})
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
if sanitized.is_empty() {
|
||||||
|
"file".to_string()
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
@@ -323,7 +431,9 @@ pub fn run() {
|
|||||||
reset_app_settings,
|
reset_app_settings,
|
||||||
current_employee,
|
current_employee,
|
||||||
sync_contracts,
|
sync_contracts,
|
||||||
load_application_file
|
load_remote_file,
|
||||||
|
ensure_remote_file,
|
||||||
|
open_remote_file
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
+3
-73
@@ -158,7 +158,7 @@ struct RemoteEmployee {
|
|||||||
|
|
||||||
pub async fn sync_contracts(
|
pub async fn sync_contracts(
|
||||||
api: &TauriApi,
|
api: &TauriApi,
|
||||||
app_data_dir: PathBuf,
|
_app_data_dir: PathBuf,
|
||||||
) -> Result<SyncContractsResult, ApiError> {
|
) -> Result<SyncContractsResult, ApiError> {
|
||||||
let state = api.state();
|
let state = api.state();
|
||||||
let token = state
|
let token = state
|
||||||
@@ -205,12 +205,9 @@ pub async fn sync_contracts(
|
|||||||
fetch_contract_detail(&client, &token, &remote.base_url, contract.id).await?;
|
fetch_contract_detail(&client, &token, &remote.base_url, contract.id).await?;
|
||||||
|
|
||||||
for application in &detail.contractapplicationfile_set {
|
for application in &detail.contractapplicationfile_set {
|
||||||
let local_path =
|
upsert_contract_application_file(api, contract.id, application, "").await?;
|
||||||
download_application_file(&client, &token, &app_data_dir, application).await?;
|
|
||||||
upsert_contract_application_file(api, contract.id, application, &local_path)
|
|
||||||
.await?;
|
|
||||||
applications += 1;
|
applications += 1;
|
||||||
if !local_path.is_empty() {
|
if !application.scan.is_empty() {
|
||||||
files_downloaded += 1;
|
files_downloaded += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -324,69 +321,6 @@ async fn fetch_contract_detail(
|
|||||||
Ok(response.json::<RemoteContractDetail>().await?)
|
Ok(response.json::<RemoteContractDetail>().await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn download_application_file(
|
|
||||||
client: &reqwest::Client,
|
|
||||||
token: &str,
|
|
||||||
app_data_dir: &PathBuf,
|
|
||||||
application: &RemoteContractApplicationFile,
|
|
||||||
) -> Result<String, ApiError> {
|
|
||||||
if application.scan.is_empty() {
|
|
||||||
return Ok(String::new());
|
|
||||||
}
|
|
||||||
|
|
||||||
let directory = application_download_dir(app_data_dir);
|
|
||||||
tokio::fs::create_dir_all(&directory)
|
|
||||||
.await
|
|
||||||
.map_err(file_error)?;
|
|
||||||
|
|
||||||
let file_name = format!(
|
|
||||||
"{}_{}",
|
|
||||||
application.id,
|
|
||||||
sanitize_file_name(&application.scan_name)
|
|
||||||
);
|
|
||||||
let path = directory.join(file_name);
|
|
||||||
|
|
||||||
let response = client
|
|
||||||
.get(&application.scan)
|
|
||||||
.header(reqwest::header::AUTHORIZATION, format!("Token {token}"))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
let status = response.status();
|
|
||||||
let detail = response.text().await.unwrap_or_default();
|
|
||||||
return Err(ApiError::new(
|
|
||||||
"remote_error",
|
|
||||||
format!("file download failed with {status}: {detail}"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let bytes = response.bytes().await?;
|
|
||||||
tokio::fs::write(&path, bytes).await.map_err(file_error)?;
|
|
||||||
|
|
||||||
Ok(path.to_string_lossy().into_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn application_download_dir(app_data_dir: &PathBuf) -> PathBuf {
|
|
||||||
app_data_dir.join("contract_applications")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sanitize_file_name(file_name: &str) -> String {
|
|
||||||
let sanitized = file_name
|
|
||||||
.chars()
|
|
||||||
.map(|character| match character {
|
|
||||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
|
||||||
_ => character,
|
|
||||||
})
|
|
||||||
.collect::<String>();
|
|
||||||
|
|
||||||
if sanitized.is_empty() {
|
|
||||||
"application_file".to_string()
|
|
||||||
} else {
|
|
||||||
sanitized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn upsert_contract_dependencies(
|
async fn upsert_contract_dependencies(
|
||||||
api: &TauriApi,
|
api: &TauriApi,
|
||||||
contract: &RemoteContract,
|
contract: &RemoteContract,
|
||||||
@@ -604,7 +538,3 @@ async fn upsert_contract_application_file(
|
|||||||
fn database_error(error: sqlx::Error) -> ApiError {
|
fn database_error(error: sqlx::Error) -> ApiError {
|
||||||
ApiError::new("database_error", error.to_string())
|
ApiError::new("database_error", error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn file_error(error: impl std::fmt::Display) -> ApiError {
|
|
||||||
ApiError::new("file_error", error.to_string())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -55,36 +55,42 @@ watch(search, (value) => {
|
|||||||
<van-button size="small" type="primary" plain @click="selectCategory(0)">Все</van-button>
|
<van-button size="small" type="primary" plain @click="selectCategory(0)">Все</van-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<van-search v-model="search" placeholder="Поиск по категории" />
|
<div class="entity-popup-body">
|
||||||
|
<van-search v-model="search" placeholder="Поиск по категории" />
|
||||||
|
|
||||||
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
|
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell
|
<van-cell
|
||||||
v-for="category in categories"
|
v-for="category in categories"
|
||||||
:key="category.id"
|
:key="category.id"
|
||||||
:title="category.name"
|
:title="category.name"
|
||||||
:label="category.name_group || `ID: ${category.id}`"
|
:label="category.name_group || `ID: ${category.id}`"
|
||||||
clickable
|
clickable
|
||||||
center
|
center
|
||||||
@click="selectCategory(category.id)"
|
@click="selectCategory(category.id)"
|
||||||
>
|
>
|
||||||
<template #right-icon>
|
<template #right-icon>
|
||||||
<van-icon v-if="selectedCategoryId === category.id" name="success" color="#1989fa" />
|
<van-icon v-if="selectedCategoryId === category.id" name="success" color="#1989fa" />
|
||||||
</template>
|
</template>
|
||||||
</van-cell>
|
</van-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<van-empty v-if="categories.length === 0" description="Категории не найдены" />
|
<van-empty v-if="categories.length === 0" description="Категории не найдены" />
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
</van-popup>
|
</van-popup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.entity-popup {
|
.entity-popup {
|
||||||
min-height: 55vh;
|
display: flex;
|
||||||
padding: 18px 0 28px;
|
flex-direction: column;
|
||||||
|
height: 70vh;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 18px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-popup-header {
|
.entity-popup-header {
|
||||||
@@ -106,6 +112,12 @@ watch(search, (value) => {
|
|||||||
padding: 36px 0;
|
padding: 36px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-popup-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.entity-select {
|
.entity-select {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -58,40 +58,46 @@ watch(search, (value) => {
|
|||||||
<van-button size="small" type="primary" plain @click="selectCounterparty(0)">Все</van-button>
|
<van-button size="small" type="primary" plain @click="selectCounterparty(0)">Все</van-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<van-search v-model="search" placeholder="Поиск по имени" />
|
<div class="counterparty-popup-body">
|
||||||
|
<van-search v-model="search" placeholder="Поиск по имени" />
|
||||||
|
|
||||||
<van-loading v-if="loading" class="counterparty-state" type="spinner">Загрузка...</van-loading>
|
<van-loading v-if="loading" class="counterparty-state" type="spinner">Загрузка...</van-loading>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell
|
<van-cell
|
||||||
v-for="counterparty in counterparties"
|
v-for="counterparty in counterparties"
|
||||||
:key="counterparty.id"
|
:key="counterparty.id"
|
||||||
:title="counterparty.name"
|
:title="counterparty.name"
|
||||||
:label="`ID: ${counterparty.id}`"
|
:label="`ID: ${counterparty.id}`"
|
||||||
clickable
|
clickable
|
||||||
center
|
center
|
||||||
@click="selectCounterparty(counterparty.id)"
|
@click="selectCounterparty(counterparty.id)"
|
||||||
>
|
>
|
||||||
<template #right-icon>
|
<template #right-icon>
|
||||||
<van-icon
|
<van-icon
|
||||||
v-if="selectedCounterpartyId === counterparty.id"
|
v-if="selectedCounterpartyId === counterparty.id"
|
||||||
name="success"
|
name="success"
|
||||||
color="#1989fa"
|
color="#1989fa"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</van-cell>
|
</van-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<van-empty v-if="counterparties.length === 0" description="Контрагенты не найдены" />
|
<van-empty v-if="counterparties.length === 0" description="Контрагенты не найдены" />
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
</van-popup>
|
</van-popup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.counterparty-popup {
|
.counterparty-popup {
|
||||||
min-height: 55vh;
|
display: flex;
|
||||||
padding: 18px 0 28px;
|
flex-direction: column;
|
||||||
|
height: 70vh;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 18px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.counterparty-popup-header {
|
.counterparty-popup-header {
|
||||||
@@ -113,6 +119,12 @@ watch(search, (value) => {
|
|||||||
padding: 36px 0;
|
padding: 36px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.counterparty-popup-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.counterparty-select {
|
.counterparty-select {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -53,36 +53,42 @@ watch(search, (value) => {
|
|||||||
<van-button size="small" type="primary" plain @click="selectFrc(0)">Все</van-button>
|
<van-button size="small" type="primary" plain @click="selectFrc(0)">Все</van-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<van-search v-model="search" placeholder="Поиск по названию" />
|
<div class="entity-popup-body">
|
||||||
|
<van-search v-model="search" placeholder="Поиск по названию" />
|
||||||
|
|
||||||
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
|
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell
|
<van-cell
|
||||||
v-for="frc in frcs"
|
v-for="frc in frcs"
|
||||||
:key="frc.id"
|
:key="frc.id"
|
||||||
:title="frc.name"
|
:title="frc.name"
|
||||||
:label="`Баланс: ${frc.balance}`"
|
:label="`Баланс: ${frc.balance}`"
|
||||||
clickable
|
clickable
|
||||||
center
|
center
|
||||||
@click="selectFrc(frc.id)"
|
@click="selectFrc(frc.id)"
|
||||||
>
|
>
|
||||||
<template #right-icon>
|
<template #right-icon>
|
||||||
<van-icon v-if="selectedFrcId === frc.id" name="success" color="#1989fa" />
|
<van-icon v-if="selectedFrcId === frc.id" name="success" color="#1989fa" />
|
||||||
</template>
|
</template>
|
||||||
</van-cell>
|
</van-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<van-empty v-if="frcs.length === 0" description="ФРЦ не найдены" />
|
<van-empty v-if="frcs.length === 0" description="ФРЦ не найдены" />
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
</van-popup>
|
</van-popup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.entity-popup {
|
.entity-popup {
|
||||||
min-height: 55vh;
|
display: flex;
|
||||||
padding: 18px 0 28px;
|
flex-direction: column;
|
||||||
|
height: 70vh;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 18px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-popup-header {
|
.entity-popup-header {
|
||||||
@@ -104,6 +110,12 @@ watch(search, (value) => {
|
|||||||
padding: 36px 0;
|
padding: 36px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-popup-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.entity-select {
|
.entity-select {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -7,10 +7,9 @@ import {
|
|||||||
type PDFDocumentProxy,
|
type PDFDocumentProxy,
|
||||||
type RenderTask,
|
type RenderTask,
|
||||||
} from "pdfjs-dist";
|
} from "pdfjs-dist";
|
||||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
|
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, watch } from "vue";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
localPath: string;
|
|
||||||
scanUrl: string;
|
scanUrl: string;
|
||||||
title: string;
|
title: string;
|
||||||
}>();
|
}>();
|
||||||
@@ -23,7 +22,7 @@ GlobalWorkerOptions.workerSrc = new URL(
|
|||||||
).toString();
|
).toString();
|
||||||
|
|
||||||
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
const canvasRef = ref<HTMLCanvasElement | null>(null);
|
||||||
const pdfDocument = ref<PDFDocumentProxy | null>(null);
|
const pdfDocument = shallowRef<PDFDocumentProxy | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref("");
|
const error = ref("");
|
||||||
const pageNumber = ref(1);
|
const pageNumber = ref(1);
|
||||||
@@ -41,19 +40,17 @@ async function loadDocument() {
|
|||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = "";
|
error.value = "";
|
||||||
let task: PDFDocumentLoadingTask | null = null;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bytes = await invoke<number[]>("load_application_file", {
|
const bytes = await invoke<number[]>("load_remote_file", {
|
||||||
localPath: props.localPath,
|
url: props.scanUrl,
|
||||||
scanUrl: props.scanUrl,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!bytes.length) {
|
if (!bytes.length) {
|
||||||
throw new Error("Файл пустой");
|
throw new Error("Файл пустой");
|
||||||
}
|
}
|
||||||
|
|
||||||
task = getDocument({ data: new Uint8Array(bytes) });
|
const task = getDocument({ data: new Uint8Array(bytes) });
|
||||||
loadingTask = task;
|
loadingTask = task;
|
||||||
|
|
||||||
const document = await task.promise;
|
const document = await task.promise;
|
||||||
@@ -66,12 +63,13 @@ async function loadDocument() {
|
|||||||
pageCount.value = document.numPages;
|
pageCount.value = document.numPages;
|
||||||
pageNumber.value = 1;
|
pageNumber.value = 1;
|
||||||
scale.value = 1.1;
|
scale.value = 1.1;
|
||||||
|
loading.value = false;
|
||||||
|
await nextTick();
|
||||||
await renderPage();
|
await renderPage();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : "Не удалось открыть PDF";
|
error.value = err instanceof Error ? err.message : "Не удалось открыть PDF";
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
if (task && loadingTask === task) {
|
if (loadingTask) {
|
||||||
loadingTask = null;
|
loadingTask = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,7 +154,7 @@ async function zoomOut() {
|
|||||||
await renderPage();
|
await renderPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
watch([show, () => props.localPath, () => props.scanUrl], async ([visible]) => {
|
watch([show, () => props.scanUrl], async ([visible]) => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
await nextTick();
|
await nextTick();
|
||||||
await loadDocument();
|
await loadDocument();
|
||||||
|
|||||||
@@ -57,36 +57,42 @@ watch(search, (value) => {
|
|||||||
<van-button size="small" type="primary" plain @click="selectProject(0)">Все</van-button>
|
<van-button size="small" type="primary" plain @click="selectProject(0)">Все</van-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<van-search v-model="search" placeholder="Поиск по проекту" />
|
<div class="entity-popup-body">
|
||||||
|
<van-search v-model="search" placeholder="Поиск по проекту" />
|
||||||
|
|
||||||
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
|
<van-loading v-if="loading" class="entity-state" type="spinner">Загрузка...</van-loading>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell
|
<van-cell
|
||||||
v-for="project in projects"
|
v-for="project in projects"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
:title="project.short_name || project.name"
|
:title="project.short_name || project.name"
|
||||||
:label="project.full_name || `ID: ${project.id}`"
|
:label="project.full_name || `ID: ${project.id}`"
|
||||||
clickable
|
clickable
|
||||||
center
|
center
|
||||||
@click="selectProject(project.id)"
|
@click="selectProject(project.id)"
|
||||||
>
|
>
|
||||||
<template #right-icon>
|
<template #right-icon>
|
||||||
<van-icon v-if="selectedProjectId === project.id" name="success" color="#1989fa" />
|
<van-icon v-if="selectedProjectId === project.id" name="success" color="#1989fa" />
|
||||||
</template>
|
</template>
|
||||||
</van-cell>
|
</van-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<van-empty v-if="projects.length === 0" description="Проекты не найдены" />
|
<van-empty v-if="projects.length === 0" description="Проекты не найдены" />
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
</van-popup>
|
</van-popup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.entity-popup {
|
.entity-popup {
|
||||||
min-height: 55vh;
|
display: flex;
|
||||||
padding: 18px 0 28px;
|
flex-direction: column;
|
||||||
|
height: 70vh;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 18px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entity-popup-header {
|
.entity-popup-header {
|
||||||
@@ -108,6 +114,12 @@ watch(search, (value) => {
|
|||||||
padding: 36px 0;
|
padding: 36px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.entity-popup-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.entity-select {
|
.entity-select {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { openPath, openUrl } from "@tauri-apps/plugin-opener";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { showToast } from "vant";
|
import { showToast } from "vant";
|
||||||
@@ -173,14 +174,16 @@ function formatMoney(value: unknown) {
|
|||||||
}).format(amount);
|
}).format(amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openApplicationFile(localPath: string) {
|
async function openApplicationFile(scanUrl: string) {
|
||||||
if (!localPath) {
|
if (!scanUrl) {
|
||||||
showToast("Файл не скачан");
|
showToast("Файл не скачан");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await openPath(localPath);
|
await invoke("open_remote_file", {
|
||||||
|
url: scanUrl,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(errorMessage(err, "Не удалось открыть файл"));
|
showToast(errorMessage(err, "Не удалось открыть файл"));
|
||||||
}
|
}
|
||||||
@@ -209,6 +212,10 @@ function openPdfPreview(file: ContractApplicationFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function errorMessage(err: unknown, fallback: string) {
|
function errorMessage(err: unknown, fallback: string) {
|
||||||
|
if (typeof err === "object" && err && "detail" in err) {
|
||||||
|
return String((err as { detail: unknown }).detail);
|
||||||
|
}
|
||||||
|
|
||||||
if (err instanceof Error) {
|
if (err instanceof Error) {
|
||||||
return err.message;
|
return err.message;
|
||||||
}
|
}
|
||||||
@@ -435,7 +442,7 @@ onMounted(() => {
|
|||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
plain
|
plain
|
||||||
:disabled="!file.local_path"
|
:disabled="!file.scan_url"
|
||||||
@click="openPdfPreview(file)"
|
@click="openPdfPreview(file)"
|
||||||
>
|
>
|
||||||
Просмотр
|
Просмотр
|
||||||
@@ -443,8 +450,8 @@ onMounted(() => {
|
|||||||
<van-button
|
<van-button
|
||||||
size="small"
|
size="small"
|
||||||
plain
|
plain
|
||||||
:disabled="!file.local_path"
|
:disabled="!file.scan_url"
|
||||||
@click="openApplicationFile(file.local_path)"
|
@click="openApplicationFile(file.scan_url)"
|
||||||
>
|
>
|
||||||
Открыть
|
Открыть
|
||||||
</van-button>
|
</van-button>
|
||||||
@@ -507,7 +514,6 @@ onMounted(() => {
|
|||||||
|
|
||||||
<PdfPreview
|
<PdfPreview
|
||||||
v-model:show="pdfPreviewVisible"
|
v-model:show="pdfPreviewVisible"
|
||||||
:local-path="pdfPreviewFile?.local_path ?? ''"
|
|
||||||
:scan-url="pdfPreviewFile?.scan_url ?? ''"
|
:scan-url="pdfPreviewFile?.scan_url ?? ''"
|
||||||
:title="pdfPreviewTitle"
|
:title="pdfPreviewTitle"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, ref, watch } from "vue";
|
import { computed, ref, watch } from "vue";
|
||||||
import { employeeApi } from "../../../generated/api";
|
import { employeeApi } from "../../../generated/api";
|
||||||
import type { EmployeeListParams } from "../../../generated/models";
|
import type { EmployeeListParams } from "../../../generated/models";
|
||||||
|
import RemoteImage from "../../../shared/components/RemoteImage.vue";
|
||||||
import { useModelApi } from "../../../shared/composables/useModelApi";
|
import { useModelApi } from "../../../shared/composables/useModelApi";
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
@@ -58,39 +59,45 @@ watch(search, (value) => {
|
|||||||
<van-button size="small" type="primary" plain @click="selectEmployee(0)">Не указан</van-button>
|
<van-button size="small" type="primary" plain @click="selectEmployee(0)">Не указан</van-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<van-search v-model="search" placeholder="Поиск по имени" />
|
<div class="employee-popup-body">
|
||||||
|
<van-search v-model="search" placeholder="Поиск по имени" />
|
||||||
|
|
||||||
<van-loading v-if="loading" class="employee-state" type="spinner">Загрузка...</van-loading>
|
<van-loading v-if="loading" class="employee-state" type="spinner">Загрузка...</van-loading>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell
|
<van-cell
|
||||||
v-for="employee in employees"
|
v-for="employee in employees"
|
||||||
:key="employee.id"
|
:key="employee.id"
|
||||||
:title="employee.name"
|
:title="employee.name"
|
||||||
:label="`ID: ${employee.id}`"
|
:label="`ID: ${employee.id}`"
|
||||||
clickable
|
clickable
|
||||||
center
|
center
|
||||||
@click="selectEmployee(employee.id)"
|
@click="selectEmployee(employee.id)"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<van-image class="employee-avatar" round width="36" height="36" :src="employee.avatar_small ?? ''" />
|
<RemoteImage class="employee-avatar" round width="36" height="36" :src="employee.avatar_small" />
|
||||||
</template>
|
</template>
|
||||||
<template #right-icon>
|
<template #right-icon>
|
||||||
<van-icon v-if="selectedEmployeeId === employee.id" name="success" color="#1989fa" />
|
<van-icon v-if="selectedEmployeeId === employee.id" name="success" color="#1989fa" />
|
||||||
</template>
|
</template>
|
||||||
</van-cell>
|
</van-cell>
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<van-empty v-if="employees.length === 0" description="Сотрудники не найдены" />
|
<van-empty v-if="employees.length === 0" description="Сотрудники не найдены" />
|
||||||
</template>
|
</template>
|
||||||
|
</div>
|
||||||
</van-popup>
|
</van-popup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.employee-popup {
|
.employee-popup {
|
||||||
min-height: 55vh;
|
display: flex;
|
||||||
padding: 18px 0 28px;
|
flex-direction: column;
|
||||||
|
height: 70vh;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 18px 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.employee-popup-header {
|
.employee-popup-header {
|
||||||
@@ -116,6 +123,12 @@ watch(search, (value) => {
|
|||||||
padding: 36px 0;
|
padding: 36px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.employee-popup-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.employee-select {
|
.employee-select {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Task } from "../../../generated/models";
|
import type { Task } from "../../../generated/models";
|
||||||
|
import RemoteImage from "../../../shared/components/RemoteImage.vue";
|
||||||
|
|
||||||
type TaskItem = Task;
|
type TaskItem = Task;
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ const emit = defineEmits<{
|
|||||||
@click="emit('open', `/tasks/${props.item.id}`)"
|
@click="emit('open', `/tasks/${props.item.id}`)"
|
||||||
>
|
>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<van-image
|
<RemoteImage
|
||||||
class="task-avatar"
|
class="task-avatar"
|
||||||
round
|
round
|
||||||
width="36"
|
width="36"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed, onMounted } from "vue";
|
import { computed, onMounted } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { taskApi } from "../../../generated/api";
|
import { taskApi } from "../../../generated/api";
|
||||||
|
import RemoteImage from "../../../shared/components/RemoteImage.vue";
|
||||||
import { useModelApi } from "../../../shared/composables/useModelApi";
|
import { useModelApi } from "../../../shared/composables/useModelApi";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@@ -93,7 +94,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<div v-else class="message-list">
|
<div v-else class="message-list">
|
||||||
<article v-for="message in task.message_set" :key="message.id" class="message-item">
|
<article v-for="message in task.message_set" :key="message.id" class="message-item">
|
||||||
<van-image class="message-avatar" round width="40" height="40" :src="message.author.avatar_small ?? ''" />
|
<RemoteImage class="message-avatar" round width="40" height="40" :src="message.author.avatar_small" />
|
||||||
<div class="message-bubble">
|
<div class="message-bubble">
|
||||||
<div class="message-author">{{ personName(message.author) }}</div>
|
<div class="message-author">{{ personName(message.author) }}</div>
|
||||||
<div class="message-text">{{ message.text }}</div>
|
<div class="message-text">{{ message.text }}</div>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { useRemoteFileUrl } from "../composables/useRemoteFileUrl";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
src?: string | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const sourceUrl = computed(() => props.src);
|
||||||
|
const { objectUrl } = useRemoteFileUrl(sourceUrl);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<van-image v-bind="$attrs" :src="objectUrl" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { onBeforeUnmount, ref, watch, type Ref } from "vue";
|
||||||
|
|
||||||
|
export function useRemoteFileUrl(sourceUrl: Ref<string | null | undefined>) {
|
||||||
|
const objectUrl = ref("");
|
||||||
|
const loading = ref(false);
|
||||||
|
const error = ref("");
|
||||||
|
let loadId = 0;
|
||||||
|
|
||||||
|
async function load(url: string | null | undefined) {
|
||||||
|
const currentLoadId = ++loadId;
|
||||||
|
revokeObjectUrl();
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
loading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bytes = await invoke<number[]>("load_remote_file", { url });
|
||||||
|
if (currentLoadId !== loadId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
objectUrl.value = URL.createObjectURL(new Blob([new Uint8Array(bytes)]));
|
||||||
|
} catch (err) {
|
||||||
|
if (currentLoadId !== loadId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
error.value = err instanceof Error ? err.message : "Не удалось загрузить файл";
|
||||||
|
} finally {
|
||||||
|
if (currentLoadId === loadId) {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeObjectUrl() {
|
||||||
|
if (objectUrl.value) {
|
||||||
|
URL.revokeObjectURL(objectUrl.value);
|
||||||
|
objectUrl.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(sourceUrl, (url) => void load(url), { immediate: true });
|
||||||
|
onBeforeUnmount(revokeObjectUrl);
|
||||||
|
|
||||||
|
return {
|
||||||
|
objectUrl,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user