This commit is contained in:
2025-07-16 21:52:57 +03:00
parent b51a472738
commit 3e43efc70d
31 changed files with 801 additions and 391 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,30 +0,0 @@
Software SURVy (the “Tool”) to facilitate your selection CCTV products based on your needs.
Before you start using the Tool, we ask you to carefully read through the below terms of use ("Terms of Use") and make sure that you have understood them prior to any use of the Tool. By downloading, installing, activating, accessing or otherwise using the Tool, you agree to be bound by the terms and conditions of the Terms of Use. If you are executing the Terms of Use on behalf of an entity, you represent that you have authority to legally bind that entity. If you do not have such authority or you do not agree to the terms and conditions of the Terms of Use, neither you nor the entity is permitted to and must not download, install, access or use the Tool.
The Tool developer ("Developer") - Individual Entrepreneur/Sole Proprietor Martyshkin Alexey Alexandrovich (Russia, Moscow, PSRNSP/Primary State Registration Number of the Sole Proprietor 318774600262084, ITN/Individual Taxpayer Number 366316608346). All rights to software SURVy belong to Developer.
By "Developer Representatives" in this document means to the circle of persons involved by Developer for development and support of the Tool.
TERMS OF USE
The Tool is provided for guidance only. The estimates, recommendations and calculation results (collectively the "Deliverables") produced by the use of the Tool are only orientational.
DEVELOPER AND/OR ITS REPRESENTATIVES WILL IN NO EVENT BE RESPONSIBLE FOR DAMAGES OF ANY NATURE WHATSOEVER RESULTING FROM THE USE OF, OR RELIANCE UPON, THE TOOL AND THE DELIVERABLES.
The Tool for authorization use HttpOnly cookies-files. Also in local memory of your web browser (known as "local storage") stored language settings.
Consent to Use of Data
Your projects will be stored on Developer servers. By agreeing to these terms of use, you accept that your project data will be used by Developer and/or its representatives for internal purposes (such as subsequent improving the Tool).
Rest assured that will Developer and/or its representatives intentionally not share or transfer about your projects to anyone.
Developer can use email address specified in the account to send notifications about changes to these Terms, request feedback on the use of the Tool and provide technical support.
Restrictions
You may not (and you may not allow anyone else to):
(i) reverse engineer, decompile, disassemble or otherwise attempt to derive access to the source code of the Tool, or any part thereof,
(ii) misuse the Tool by interfering with its normal operation, or attempting to access it using a method other than through the interfaces and instructions that provide,
(iii) submit or upload any data or content that is illegal or violates these Terms of Use,
(iv) use the Tool on the territory of Russian Federation for objects of state-owned enterprises/institutions (including companies with state participation), as well as for those objects whose data can be identified as classified information.
The data submitted by you by use of the Tool may not exceed 1 GB.
Developer reserves the right to delete your data (account and projects) in its sole discretion.
You undertake to indemnify and hold Developer and/or its representatives harmless for all damages and losses incurred due to any breach of the undertakings in this Section.
DISCLAIMER
THE TOOL AND ANY DELIVERABLES ARE DELIVERED FREE OF CHARGE AND 'AS IS' WITHOUT WARRANTY OF ANY KIND. THE ENTIRE RISK AS TO THE RESULTS AND PERFORMANCE OF THE TOOL AND THE DELIVERABLES IS ASSUMED BY YOU/THE USER. DEVELOPER DISCLAIMS ALL WARRANTIES, WHETHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT, OR ANY WARRANTY ARISING OUT OF ANY PROPOSAL, SPECIFICATION OR SAMPLE WITH RESPECT TO THE TOOL AND DELIVERABLES. DEVELOPER AND/OR ITS REPRESENTATIVES SHALL NOT BE LIABLE FOR LOSS OF DATA, LOSS OF PRODUCTION, LOSS OF PROFIT, LOSS OF USE, LOSS OF CONTRACTS OR FOR ANY OTHER CONSEQUENTIAL, ECONOMIC OR INDIRECT LOSS WHATSOEVER IN RESPECT OF DELIVERY, USE OR DISPOSITION OF THE TOOL AND THE DELIVERABLES. DEVELOPER AND/OR ITS REPRESENTATIVES TOTAL LIABILITY FOR ANY AND ALL CLAIMS, DAMAGES AND LIABILITY IN ACCORDANCE WITH THE DELIVERY AND USE OF THE TOOL AND THE DELIVERABLES SHALL NOT EXCEED THE PRICE PAID FOR THE TOOL.
Governing law and dispute resolution
These Terms of Use shall be deemed performed in and shall be construed and governed by the laws of Russian Federation.

1
public/telegram_star.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="14" height="15" viewBox="0 0 14 15" fill="000" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M6.63869 12.1902L3.50621 14.1092C3.18049 14.3087 2.75468 14.2064 2.55515 13.8807C2.45769 13.7216 2.42864 13.5299 2.47457 13.3491L2.95948 11.4405C3.13452 10.7515 3.60599 10.1756 4.24682 9.86791L7.6642 8.22716C7.82352 8.15067 7.89067 7.95951 7.81418 7.80019C7.75223 7.67116 7.61214 7.59896 7.47111 7.62338L3.66713 8.28194C2.89387 8.41581 2.1009 8.20228 1.49941 7.69823L0.297703 6.69116C0.00493565 6.44581 -0.0335059 6.00958 0.211842 5.71682C0.33117 5.57442 0.502766 5.48602 0.687982 5.47153L4.35956 5.18419C4.61895 5.16389 4.845 4.99974 4.94458 4.75937L6.36101 1.3402C6.5072 0.987302 6.91179 0.819734 7.26469 0.965925C7.43413 1.03612 7.56876 1.17075 7.63896 1.3402L9.05539 4.75937C9.15496 4.99974 9.38101 5.16389 9.6404 5.18419L13.3322 5.47311C13.713 5.50291 13.9975 5.83578 13.9677 6.2166C13.9534 6.39979 13.8667 6.56975 13.7269 6.68896L10.9114 9.08928C10.7131 9.25826 10.6267 9.52425 10.6876 9.77748L11.5532 13.3733C11.6426 13.7447 11.414 14.1182 11.0427 14.2076C10.8642 14.2506 10.676 14.2208 10.5195 14.1249L7.36128 12.1902C7.13956 12.0544 6.8604 12.0544 6.63869 12.1902Z" fill="000"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1,5 +1,6 @@
import { defineBoot } from '#q-app/wrappers'
import axios, { type AxiosError } from 'axios'
import { Notify } from 'quasar'
class ServerError extends Error {
constructor(
@@ -19,6 +20,7 @@ const api = axios.create({
api.interceptors.response.use(
response => response,
async (error: AxiosError<{ error?: { code: string; message: string } }>) => {
console.log(error)
const errorData = error.response?.data?.error || {
code: 'ZERO',
message: error.message || 'Unknown error'
@@ -29,6 +31,12 @@ api.interceptors.response.use(
errorData.message
)
Notify.create({
type: 'negative',
message: errorData.code + ': ' + errorData.message,
icon: 'mdi-alert-outline',
position: 'bottom'
})
return Promise.reject(serverError)
}
)

View File

@@ -1,96 +0,0 @@
<template>
<div class="q-pt-md" v-if="mapUsers.length !==0 ">
<span class="q-pl-md text-h6">
{{ $t('company_info__users') }}
</span>
<q-list separator>
<q-item
v-for="item in mapUsers"
:key="item.id"
v-ripple
clickable
@click="goPersonInfo(item.id)"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.photo"
:name="item.section1"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold" v-if="item.section1">
{{item.section1}}
</q-item-label>
<q-item-label caption lines="2">
<span v-if="item.section2_1" class="q-mr-sm">{{item.section2_1}}</span>
<span class="text-blue" v-if="item.section2_2">{{'@' + item.section2_2}}</span>
</q-item-label>
<q-item-label lines="1" v-if="item.section3">
{{item.section3}}
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</div>
</template>
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
import { useUsersStore } from 'stores/users'
import type { User } from 'types/Users'
const usersStore = useUsersStore()
const router = useRouter()
const route = useRoute()
const currentCompanyId = Number(route.params.companyId)
const users = usersStore.users
const mapUsers = users
.filter(el => el.company_id === currentCompanyId)
.map(el => ({...el, ...userSection(el)}))
async function goPersonInfo (userId: number) {
await router.push({ name: 'user_info', params: { id: route.params.id, userId }})
}
// copy from 'pages/project-page/ProjectPageUsers.vue' кроме company.name
function userSection (user: User) {
const tname = () => {
return user.firstname
? user.lastname
? user.firstname + ' ' + user.lastname
: user.firstname
: user.lastname ?? ''
}
const section1 = user.name
? user.name
: tname()
const section2_1 = user.name
? tname()
: ''
const section2_2 = user.username ?? ''
const section3 = (
user.department
? user.department + ' '
: ''
) + (
user.role ?? ''
)
return {
section1,
section2_1, section2_2,
section3
}
}
</script>
<style scoped>
</style>

View File

@@ -34,37 +34,46 @@
import { ref, onMounted } from 'vue'
import { useSettingsStore } from 'stores/settings'
const fileText = ref<string>('')
const isLoading = ref<boolean>(true)
const error = ref<boolean>(false)
const settingsStore = useSettingsStore()
const lang = ref<string>('EN')
const props = defineProps<{
type: 'terms_of_use' | 'privacy'
}>()
function parseLocale(locale: string): string {
return locale.split(/[-_]/)[0] ?? ''
}
const settingsStore = useSettingsStore()
const fileText = ref<string | null>('')
const isLoading = ref(true)
const DEFAULT_LANG = 'ru'
const baseDocName =
props.type ==='terms_of_use' ? 'Terms_of_use' : 'Privacy'
const baseDocName = props.type === 'terms_of_use'
? 'Terms_of_use'
: 'Privacy'
const parseLocale = (locale: string) => locale.split(/[-_]/)[0] || DEFAULT_LANG
const fetchDocument = async (language: string) => {
try {
const response = await fetch(`/admin/doc/${baseDocName}_${language}.txt`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return await response.text()
} catch (error) {
console.error(`Failed to load ${language} version:`, error)
return null
}
}
onMounted(async () => {
const locale = settingsStore.settings.locale
lang.value = parseLocale(locale)
try {
const response = await fetch('/admin/doc/' + baseDocName + '_' + lang.value +'.txt')
const lang = parseLocale(settingsStore.settings.locale)
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`)
fileText.value = await fetchDocument(lang)
if (!fileText.value && lang !== DEFAULT_LANG) {
fileText.value = await fetchDocument(DEFAULT_LANG)
}
fileText.value = await response.text()
} catch (err) {
console.error('File load error:', err)
error.value = true
if (!fileText.value) throw new Error('All loading attempts failed')
} catch (error) {
console.error('Document loading failed:', error)
} finally {
isLoading.value = false
}

View File

@@ -0,0 +1,125 @@
<template>
<q-item
@click="showDialog=true"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar
rounded
text-color="white"
:icon
:color="iconColor"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label>
{{ $t(title) }}
</q-item-label>
<q-item-label caption>
<slot name="value"/>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="mdi-chevron-right" color="grey"/>
</q-item-section>
</q-item>
<q-dialog
v-model="showDialog"
maximized
transition-show="slide-up"
transition-hide="slide-down"
position="bottom"
>
<q-card
class="fix-card-width flex column no-scroll no-wrap q-px-none"
style="
border-top-left-radius: var(--top-raduis) !important;
border-top-right-radius: var(--top-raduis) !important;
"
>
<div
ref="cardHeaderRef"
class="flex items-center no-wrap justify-between w100 q-my-none q-pa-md"
>
<div>
<div class="flex column q-mx-xs">
<span class="text-h6 ellipsis">{{ $t(title) }}</span>
<span v-if="caption" class="text-grey text-caption">{{ $t(caption) }}</span>
</div>
</div>
<div class="flex items-center justify-between no-wrap">
<q-btn
icon="mdi-close"
@click="showDialog=false"
flat round
/>
</div>
</div>
<div
ref="cardBodyRef"
class="q-px-none q-ma-none"
>
<pn-shadow-scroll
:hideShadows="false"
:height="bodyHeight"
>
<div ref="cardBodyInnerRef" class="q-px-md q-ma-none">
<q-resize-observer @resize="updateDimensions" />
<slot/>
</div>
</pn-shadow-scroll>
</div>
<div
ref="cardFooterRef"
class="q-pa-md"
>
<slot name="footer"/>
</div>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useSlots } from 'vue'
defineProps<{
title: string
caption?: string
icon: string
iconColor: string
}>()
const showDialog=ref<boolean>(false)
const slots = useSlots()
const cardHeaderRef = ref<HTMLElement | null>(null)
const cardFooterRef = ref<HTMLElement | null>(null)
const cardBodyRef = ref<HTMLElement | null>(null)
const cardBodyInnerRef = ref<HTMLElement | null>(null)
const headerHeight = ref(0)
const footerHeight = ref(0)
const bodyInnerHeight = ref(0)
const bodyHeight = ref(0)
const updateDimensions = () => {
headerHeight.value = cardHeaderRef.value?.offsetHeight || 0
footerHeight.value = cardFooterRef.value?.offsetHeight || 0
bodyInnerHeight.value = cardBodyInnerRef.value?.offsetHeight || 0
bodyHeight.value = window.innerHeight - headerHeight.value - footerHeight.value - 48
}
watch(() => slots.body?.(), updateDimensions, { flush: 'post' })
</script>
<style scoped>
.fix-card-width {
width: var(--body-width) !important;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<q-list class="q-gutter-y-sm">
<q-btn
v-for="(option, index) in options"
:key="index"
flat
no-caps dense
class="w100"
align="left"
@click="selectItem(option)"
:class="isSelected(option.value) ? 'text-primary' : ''"
>
<q-icon
:name="isSelected(option.value) ? 'mdi-check' : ''"
color="primary"
class="q-pr-sm"
/>
<span
:class="!isSelected(option.value) ? 'text-weight-regular' : ''"
>
{{ $te(option.label) ? $t(option.label) : option.label }}
</span>
</q-btn>
</q-list>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface ListOption {
label: string
value: string | number
}
defineProps<{
options: ListOption[]
}>()
const model = defineModel<string | number | null>({
required: true
})
const isSelected = computed(() => (value: string | number) => {
return model.value === value
})
const selectItem = (option: ListOption) => {
model.value = option.value
}
</script>
<style scoped>
</style>

View File

@@ -1,8 +1,9 @@
<template>
<q-dialog
v-model="modelValue"
<q-dialog v-model="modelValue">
<q-card
class="q-pa-none q-ma-none w100 no-scroll"
align="center"
>
<q-card class="q-pa-none q-ma-none w100 no-scroll" align="center">
<q-card-section>
<q-avatar :color :icon size="60px" font-size="45px" text-color="white"/>
</q-card-section>
@@ -12,50 +13,55 @@
style="overflow-wrap: break-word"
>
<div class="text-h6 text-bold ">
{{ $t(title)}}
{{ $t(title) }}
</div>
<div v-if="message1">
{{ $t(message1)}}
{{ $t(message1) }}
</div>
<div v-if="message2">
{{ $t(message2)}}
{{ $t(message2) }}
</div>
</q-card-section>
<q-card-actions align="center" vertical>
<div class="flex q-mt-lg no-wrap w100 justify-center q-gutter-x-md">
<q-card-section>
<div class="flex column w100 q-mt-lg q-px-sm">
<div class="flex q-gutter-md">
<div class="col-grow" v-if="auxBtnLabel">
<q-btn
v-if="auxBtnLabel"
:label="$t(auxBtnLabel)"
outline
color="grey"
class="w100"
v-close-popup
rounded
class="w50"
@click="emit('clickAuxBtn')"
/>
</div>
<div class="col-grow">
<q-btn
:label="$t(mainBtnLabel)"
:color="color"
class="w100"
v-close-popup
rounded
:class="auxBtnLabel ? 'w50' : 'w80'"
@click="emit('clickMainBtn')"
/>
</div>
</div>
<q-btn
class="w80 q-mt-md q-mb-sm" flat
class="w100 q-mt-md q-mb-sm" flat
v-close-popup rounded
@click="emit('close')"
>
<div class="flex items-center">
<q-icon name="close"/>
{{$t('cancel')}}
{{$t('close')}}
</div>
</q-btn>
</q-card-actions>
</div>
</q-card-section>
</q-card>
</q-dialog>
</template>
@@ -83,5 +89,5 @@
</script>
<style>
<style scoped>
</style>

View File

@@ -0,0 +1,140 @@
<template>
<div class="w100 flex column">
<div class="flex row w100">
<q-input
v-model="search"
clearable
clear-icon="close"
:placeholder="$t('settings__timezone_search')"
dense
class="col-grow"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</div>
<q-list class="q-gutter-y-sm q-my-sm">
<q-btn
v-for="tz in displayTimeZones"
:key="tz.value"
flat
no-caps
dense
class="w100"
align="left"
@click="selectTz(tz)"
:class="isSelected(tz.value) ? 'text-primary' : ''"
>
<div class="flex w100 no-wrap">
<q-icon
:name="isSelected(tz.value) ? 'mdi-check' : ''"
color="primary"
class="q-pr-sm"
/>
<div class="flex row no-wrap justify-between w100">
<div>
<span class="text-grey text-weight-regular">{{ tz.continent + ' / ' }}</span>
<span>{{ tz.city + ' ' }}</span>
</div>
<span class="text-grey text-weight-regular">{{ tz.offsetDisplay }}</span>
</div>
</div>
</q-btn>
</q-list>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
interface TimeZone {
value: string
continent: string
city: string
offsetDisplay: string
offsetHours: number
}
const props = defineProps<{
locale: string
}>()
const modelValue = defineModel<{ tz: string; offset: number, offsetString: string }>({
required: true
})
const search = ref('')
const timeZonesBase = Intl.supportedValuesOf('timeZone')
const timeZones = ref<TimeZone[]>([])
watchEffect(() => {
const date = Date.now()
timeZones.value = timeZonesBase.map((tz) => {
const [continent, ...cityParts] = tz.split('/')
const city = cityParts.join('/').replace(/_/g, ' ')
const formatter = new Intl.DateTimeFormat('en', {
timeZone: tz,
timeZoneName: 'longOffset'
})
const offsetPart = formatter
.formatToParts(date)
.find(part => part.type === 'timeZoneName')?.value || 'UTC'
const cleanOffset = offsetPart.replace(/^GMT|^UTC/, '') || '+00:00'
let sign = 1
let offsetString = cleanOffset
if (cleanOffset.startsWith('-')) {
sign = -1
offsetString = cleanOffset.substring(1)
} else if (cleanOffset.startsWith('+')) {
offsetString = cleanOffset.substring(1)
}
const [hours = '0', minutes = '0'] = offsetString.split(':')
const totalHours = sign * (parseInt(hours) + parseInt(minutes) / 60)
const absHours = Math.abs(parseInt(hours))
const absMinutes = Math.abs(parseInt(minutes))
const displaySign = sign === -1 ? '-' : '+'
const offsetDisplay = `${displaySign}${String(absHours).padStart(2, '0')}:${String(absMinutes).padStart(2, '0')}`
return {
value: tz,
continent: continent?.replace(/_/g, ' ') || '',
city: city,
offsetDisplay,
offsetHours: totalHours
}
})
})
const displayTimeZones = computed(() => {
if (!search.value || !(search.value && search.value.trim())) return timeZones.value
const searchValue = search.value.trim().toLowerCase()
return timeZones.value.filter(
el =>
el.continent.toLowerCase().includes(searchValue) ||
el.city.toLowerCase().includes(searchValue) ||
el.offsetDisplay.includes(searchValue) ||
el.offsetDisplay.replace(':', '').includes(searchValue)
)
})
const isSelected = (value: string) => {
return modelValue.value.tz === value
}
const selectTz = (tz: TimeZone) => {
modelValue.value = { tz: tz.value, offset: tz.offsetHours, offsetString: tz.offsetDisplay }
}
</script>
<style scoped>
</style>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -68,7 +68,6 @@
{ id: 5, name: 'account__company_data', icon: 'mdi-account-group-outline', description: 'account__company_data_description', pathName: 'your_company' },
{ id: 6, name: 'account__settings', icon: 'mdi-cog-outline', description: 'account__settings_description', iconColor: 'info', pathName: 'settings' },
{ id: 7, name: 'account__support', icon: 'mdi-lifebuoy', description: 'account__support_description', iconColor: 'info', pathName: 'support' },
{ id: 8, name: 'account__3rd_party_software', icon: 'mdi-scale-balance', description: 'account__3rd_party_software_description', iconColor: 'grey', pathName: '3software' },
{ id: 9, name: 'account__terms_of_use', icon: 'mdi-book-open-variant-outline', description: '', iconColor: 'grey', pathName: 'terms' },
{ id: 10, name: 'account__privacy', icon: 'mdi-lock-outline', description: '', iconColor: 'grey', pathName: 'privacy' }
]))

View File

@@ -1,8 +1,8 @@
<template>
<company-block
v-model="newCompany"
title="company_create__title_card"
btnText="company_create__btn"
title="company_add__title_card"
btnText="company_add__btn"
@update = "addCompany"
/>
</template>

View File

@@ -25,7 +25,7 @@
v-model="searchProject"
clearable
clear-icon="close"
:placeholder="$t('project_chats__search')"
:placeholder="$t('projects__search')"
dense
class="col-grow q-px-md q-py-md"
>
@@ -91,22 +91,22 @@
class="flex column items-center w100"
:class="showArchive ? 'bg-grey-12' : ''"
>
<q-btn-dropdown
class="w100 fix-rotate-arrow"
<q-btn
class="w100 rotate-icon-btn"
color="grey"
flat no-caps
flat
no-caps
@click="showArchive=!showArchive"
dropdown-icon="arrow_drop_up"
icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showArchive }"
>
<template #label>
<span class="text-caption">
{{ !showArchive
? $t('projects__show_archive') + ' (' + archiveProjects.length +')'
: $t('projects__hide_archive')
}}
</span>
</template>
</q-btn-dropdown>
</q-btn>
<div class="w100" style="overflow: hidden">
<transition
@@ -186,6 +186,7 @@
color="negative"
title="projects__dialog_archive_title"
message1="projects__dialog_archive_message"
message2="projects__dialog_archive_message2"
mainBtnLabel="projects__dialog_archive_ok"
@clickMainBtn="onConfirmArchiveProject()"
@close="onCancel()"
@@ -198,7 +199,8 @@
color="green"
title="projects__dialog_restore_title"
message1="projects__dialog_restore_message"
mainBtnLabel="projects__dialog_cancel_ok"
message2="projects__dialog_restore_message2"
mainBtnLabel="projects__dialog_restore_ok"
@clickMainBtn="restoreFromArchive()"
/>
</template>
@@ -319,7 +321,17 @@
flex-wrap: nowrap;
}
.fix-rotate-arrow :deep(.q-btn-dropdown--simple) {
margin-left: 0;
.rotate-icon-btn {
transition: transform 0.3s;
}
.rotate-icon-btn.rotate-icon :deep(.q-icon) {
transform: rotate(180deg);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.rotate-icon-btn :deep(.q-icon) {
transform: rotate(0deg);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
}
</style>

View File

@@ -6,75 +6,120 @@
<pn-scroll-list>
<q-list separator>
<q-item>
<q-item-section avatar>
<q-avatar color="primary" rounded text-color="white" icon="mdi-translate" size="md" />
</q-item-section>
<q-item-section>
<span>{{ $t('settings__language') }}</span>
</q-item-section>
<q-item-section>
<q-select
class="fix-input-right text-body1"
<q-item-label header>{{ $t('settings__software_title') }}</q-item-label>
<pn-item-btm-dialog
title="settings__language"
caption="settings__software_title"
icon="mdi-translate"
iconColor="primary"
>
<template #value>
{{ localeOptions.find(el => el.value === locale)?.label }}
</template>
<pn-list-selector
v-model="locale"
:options="localeOptions"
dense
borderless
emit-value
map-options
hide-bottom-space
/>
</q-item-section>
</q-item>
<q-item>
<q-item-section avatar>
<q-avatar color="primary" rounded text-color="white" icon="mdi-format-size" size="md" />
</q-item-section>
<q-item-section>
<span>{{ $t('settings__font_size') }}</span>
</q-item-section>
<q-item-section>
<div class="flex justify-end">
<q-btn
@click="settingsStore.decreaseFontSize()"
color="negative" flat
icon="mdi-format-font-size-decrease"
class="q-pa-sm q-mx-xs"
:disable="!settingsStore.canDecrease"
</pn-item-btm-dialog>
<pn-item-btm-dialog
title="settings__font_size"
caption="settings__software_title"
icon="mdi-format-size"
iconColor="primary"
>
<template #value>
{{ $t(fontSizeLabel) }}
</template>
<pn-list-selector
v-model="fontSize"
:options="fontSizeOptions"
/>
<q-btn
@click="settingsStore.increaseFontSize()"
color="positive" flat
icon="mdi-format-font-size-increase"
class="q-pa-sm q-mx-xs"
:disable="!settingsStore.canIncrease"
</pn-item-btm-dialog>
<q-item-label class="q-mt-md" header>{{ $t('settings__bot_title') }}</q-item-label>
<pn-item-btm-dialog
title="settings__language"
caption="settings__bot_title"
icon="mdi-translate"
iconColor="primary"
>
<template #value>
{{ localeOptions.find(el => el.value === localeBot)?.label }}
</template>
<pn-list-selector
v-model="localeBot"
:options="localeOptions"
/>
</div>
</q-item-section>
</q-item>
</pn-item-btm-dialog>
<pn-item-btm-dialog
title="settings__timezone"
caption="settings__bot_title"
icon="mdi-map-clock-outline"
iconColor="primary"
>
<template #value>
{{ timeZoneBot.tz }}
{{ timeZoneBot.offsetString }}
</template>
<pn-time-zone-selector
v-model="timeZoneBot"
:locale
/>
</pn-item-btm-dialog>
</q-list>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ref, watch, computed, onMounted } from 'vue'
import { useSettingsStore } from 'stores/settings'
import pnItemBtmDialog from 'components/pnItemBtmDialog.vue'
import pnListSelector from 'components/pnListSelector.vue'
import pnTimeZoneSelector from 'components/pnTimeZoneSelector.vue'
const settingsStore = useSettingsStore()
const locale = ref('')
const localeBot = ref('')
const localeOptions = settingsStore.supportLocale
const locale = computed({
get: () => settingsStore.settings.locale,
// eslint-disable-next-line @typescript-eslint/no-misused-promises
set: (value: string) => settingsStore.updateLocale(value)
watch(locale, async (newValue) => {
await settingsStore.updateSettings({ locale: newValue })
})
watch(localeBot, async (newValue) => {
await settingsStore.updateSettings({ localeBot: newValue })
})
const fontSize = ref(14)
const fontSizeOptions = settingsStore.supportFontSizes
const fontSizeLabel = computed(() =>
fontSizeOptions.find(el => el.value === fontSize.value)?.label ?? ''
)
watch(fontSize, async (newValue) => {
await settingsStore.updateSettings({ fontSize: newValue })
})
const timeZoneBot = ref<{ tz: string, offset: number }>({ tz: '', offset: 1 })
watch(timeZoneBot, async (newValue) => {
if (newValue) await settingsStore.updateSettings({ timeZoneBot: newValue })
})
onMounted(() => {
locale.value = settingsStore.settings.locale
localeBot.value = settingsStore.settings.localeBot
fontSize.value = settingsStore.settings.fontSize
timeZoneBot.value = settingsStore.settings.timeZoneBot
})
</script>
<style scoped>
.fix-input-right :deep(.q-field__native) {
justify-content: end;
}
</style>

View File

@@ -8,7 +8,7 @@
v-model="search"
clearable
clear-icon="close"
:placeholder="$t('project_chats__search')"
:placeholder="$t('chats__search')"
dense
class="col-grow"
v-if="chats.length !== 0"
@@ -71,8 +71,8 @@
<pn-onboard-btn
v-if="chats.length === 0 && chatsInit"
icon="mdi-chat-plus-outline"
:message1="$t('project_chat__onboard_msg1')"
:message2="$t('project_chat__onboard_msg2')"
:message1="$t('chats__onboard_msg1')"
:message2="$t('chats__onboard_msg2')"
@click="showOverlay=true; fabState=true"
/>
<div
@@ -110,7 +110,7 @@
anchor="center left" self="center end"
style="width: calc(min(100vw, var(--body-width)) - 102px) !important;"
>
{{ $t('project_chats_disabled_FAB')}}
{{ $t('chats_disabled_FAB')}}
</q-tooltip>
</template>
<q-fab-action
@@ -152,9 +152,10 @@
v-model="showDialogDeleteChat"
icon="mdi-link-off"
color="negative"
title="project_chat__delete_warning"
message1="project_chat__delete_warning_message"
mainBtnLabel="project_chat__dialog_cancel_ok"
title="chats__dialog_unlink_title"
message1="chats__dialog_unlink_message"
message2="chats__dialog_unlink_message2"
mainBtnLabel="chats__dialog_unlink_ok"
@clickMainBtn="onConfirm()"
@close="onCancel()"
@before-hide="onDialogBeforeHide()"
@@ -190,8 +191,8 @@
const chatsInit = computed(() => chatsStore.isInit)
const fabMenu = [
{id: 1, icon: 'mdi-chat-plus-outline', name: 'project_chats__attach_chat', description: 'project_chats__attach_chat_description', func: attachChat},
{id: 2, icon: 'mdi-share-outline', name: 'project_chats__send_chat', description: 'project_chats__send_chat_description', func: sendChat},
{id: 1, icon: 'mdi-chat-plus-outline', name: 'chats__attach_chat', description: 'chats__attach_chat_description', func: attachChat},
{id: 2, icon: 'mdi-share-outline', name: 'chats__send_chat', description: 'chats__send_chat_description', func: sendChat},
]
const displayChats = computed(() => {
@@ -253,7 +254,7 @@
const key = await chatsStore.getKey()
const message = urlAdmin + key + urlAdminPermission
const tgShareUrl = 'https://t.me/share/url?url=' +
encodeURIComponent( t('project_chats__send_chat_title')) +
encodeURIComponent( t('chats__send_chat_title')) +
'&text=' + `${encodeURIComponent(message)}`
tg.openTelegramLink(tgShareUrl)
}

View File

@@ -7,7 +7,7 @@
:color="companies.length <= 2 ? 'grey-6' : 'primary'"
flat
no-caps
@click="maskCompany()"
@click="maskCompany"
:disable="companies.length <= 2"
class="q-pr-md"
rounded
@@ -16,9 +16,10 @@
left
size="sm"
name="mdi-domino-mask"
class="q-mr-xs"
/>
<div>
{{ $t('company__mask')}}
{{ $t('companies__mask')}}
</div>
</q-btn>
</div>
@@ -53,7 +54,7 @@
<q-item-label lines="1" class="text-caption text-amber-10" v-if="item.id === myCompany?.id">
<div class="flex items-center">
<q-icon name="star" class="q-pr-xs"/>
{{ $t('company__my_company') }}
{{ $t('companies__my_company') }}
</div>
</q-item-label>
<q-item-label lines="1" class="text-bold">{{ item.name }}</q-item-label>
@@ -93,9 +94,9 @@
<pn-onboard-btn
v-if="companies.length <= 1 && companiesInit"
icon="mdi-account-multiple-plus-outline"
:message1="$t('company__onboard_msg1')"
:message2="$t('company__onboard_msg2')"
@btn-click="createCompany()"
:message1="$t('companies__onboard_msg1')"
:message2="$t('companies__onboard_msg2')"
@btn-click="createCompany"
/>
<div
class="flex column justify-center items-center w100"
@@ -130,9 +131,10 @@
v-model="showDialogDeleteCompany"
icon="mdi-account-multiple-minus-outline"
color="negative"
title="company__dialog_delete_title"
message1="company__dialog_delete_message"
mainBtnLabel="company__dialog_delete_ok"
title="companies__dialog_delete_title"
message1="companies__dialog_delete_message"
message2="companies__dialog_delete_message2"
mainBtnLabel="companies__dialog_delete_ok"
@clickMainBtn="onConfirm()"
@close="onCancel()"
@before-hide="onDialogBeforeHide()"

View File

@@ -7,7 +7,7 @@
v-model="search"
clearable
clear-icon="close"
:placeholder="$t('project_users__search')"
:placeholder="$t('users__search')"
dense
class="col-grow"
>
@@ -60,27 +60,92 @@
</q-slide-item>
</q-list>
<!-- LEAVE USERS SECTION -->
<div
v-if="leaveUsers.length!==0"
class="flex column items-center w100"
:class="showLeaveUsers ? 'bg-grey-12' : ''"
>
<q-btn
class="w100 rotate-icon-btn"
color="grey"
flat
no-caps
@click="showLeaveUsers=!showLeaveUsers"
icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showLeaveUsers }"
>
<span class="text-caption">
{{ !showLeaveUsers
? $t('users__show_left_users') + ' (' + leaveUsers.length +')'
: $t('users__hide_left_users')
}}
</span>
</q-btn>
<div class="w100" style="overflow: hidden">
<transition
appear
enter-active-class="animated slideInDown"
leave-active-class="animated slideOutUp"
>
<q-list separator v-if="showLeaveUsers" class="w100">
<q-item
v-for = "item in leaveUsers"
:key="item.id"
class="w100 text-grey"
>
<q-item-section avatar>
<pn-auto-avatar
:img="item.photo"
:name="item.section1"
/>
</q-item-section>
<q-item-section>
<q-item-label lines="1" class="text-bold" v-if="item.section1">
{{item.section1}}
</q-item-label>
<q-item-label lines="1" caption v-if="item.section3">
{{item.section3}}
</q-item-label>
<q-item-label caption lines="2">
<div class="flex items-center">
<q-icon name="telegram" v-if="item.section2_1 || item.section2_2" class="q-pr-xs" style="color: #27a7e7"/>
<div v-if="item.section2_1" class="q-mr-sm text-bold">{{item.section2_1}}</div>
<div class="text-blue" v-if="item.section2_2">{{'@' + item.section2_2}}</div>
</div>
</q-item-label>
</q-item-section>
</q-item>
</q-list>
</transition>
</div>
</div>
<!-- END LEAVE USERS SECTION -->
<!-- BLOCKED USERS SECTION -->
<div
v-if="blockedUsers.length!==0"
class="flex column items-center w100"
:class="showBlockedUsers ? 'bg-grey-12' : ''"
>
<q-btn-dropdown
class="w100 fix-rotate-arrow"
<q-btn
class="w100 rotate-icon-btn"
color="grey"
flat no-caps
flat
no-caps
@click="showBlockedUsers=!showBlockedUsers"
dropdown-icon="arrow_drop_up"
icon-right="arrow_drop_down"
:class="{ 'rotate-icon': showBlockedUsers }"
>
<template #label>
<span class="text-caption">
{{ !showBlockedUsers
? $t('users__show_archive') + ' (' + blockedUsers.length +')'
: $t('user__hide_archive')
? $t('users__show_blocked_users') + ' (' + blockedUsers.length +')'
: $t('users__hide_blocked_users')
}}
</span>
</template>
</q-btn-dropdown>
</q-btn>
<div class="w100" style="overflow: hidden">
<transition
@@ -123,12 +188,13 @@
</transition>
</div>
</div>
<!-- END BLOCKED USERS SECTION -->
<pn-onboard-btn
v-if="users.length === 0 && usersInit"
icon="mdi-account-outline"
:message1="$t('project_users__onboard_msg1')"
:message2="$t('project_users__onboard_msg2')"
:message1="$t('users__onboard_msg1')"
:message2="$t('users__onboard_msg2')"
noBtn
/>
<div
@@ -142,13 +208,14 @@
</div>
<pn-small-dialog
v-model="showDialogDeleteUser"
v-model="showDialogBlockUser"
icon="mdi-account-remove-outline"
color="negative"
title="user__dialog_delete_title"
message1="user__dialog_delete_message"
mainBtnLabel="user__dialog_delete_ok"
@clickMainBtn="onConfirmDeleteUser()"
title="users__dialog_block_title"
message1="users__dialog_block_message"
message2="users__dialog_block_message2"
mainBtnLabel="users__dialog_block_ok"
@clickMainBtn="onConfirmBlockUser()"
@close="onCancel()"
@before-hide="onDialogBeforeHide()"
/>
@@ -157,9 +224,10 @@
v-model="showDialogRestoreUser"
icon="mdi-account-reactivate-outline"
color="green"
title="user__dialog_restore_title"
message1="user__dialog_restore_message"
mainBtnLabel="user__dialog_restore_ok"
title="users__dialog_restore_title"
message1="users__dialog_restore_message"
message2="users__dialog_restore_message2"
mainBtnLabel="users__dialog_restore_ok"
@clickMainBtn="onConfirmRestoreUser()"
/>
@@ -183,12 +251,18 @@
const users = usersStore.getUsers
const usersInit = computed(() => usersStore.isInit)
const deleteUserId = ref<number | undefined>(undefined)
const showDialogDeleteUser = ref<boolean>(false)
const blockUserId = ref<number | undefined>(undefined)
const showDialogBlockUser = ref<boolean>(false)
const currentSlideEvent = ref<SlideEvent | null>(null)
const closedByUserAction = ref(false)
const mapUsers = computed(() => users.map(el => ({...el, ...userSection(el)})))
const mapUsers = computed(() => users.map(el => ({
...el,
...userSection(el),
companyName: el.company_id && companiesStore.companyById(el.company_id)
? companiesStore.companyById(el.company_id)?.name
: null
})))
interface SlideEvent {
reset: () => void
@@ -202,12 +276,13 @@
el.section1.toLowerCase().includes(searchValue) ||
el.section2_1.toLowerCase().includes(searchValue) ||
el.section2_2.toLowerCase().includes(searchValue) ||
el.section3.toLowerCase().includes(searchValue)
el.section3.toLowerCase().includes(searchValue) ||
el.companyName && el.companyName.toLowerCase().includes(searchValue)
)
return arrOut
})
const displayUsers = computed(() => displayUsersAll.value.filter(el => !el.is_block))
const displayUsers = computed(() => displayUsersAll.value.filter(el => !el.is_blocked))
function userSection (user: User) {
const tname = () => {
@@ -244,8 +319,8 @@
function handleSlide (event: SlideEvent, id: number) {
currentSlideEvent.value = event
showDialogDeleteUser.value = true
deleteUserId.value = id
showDialogBlockUser.value = true
blockUserId.value = id
}
function onDialogBeforeHide () {
@@ -263,26 +338,29 @@
}
}
async function onConfirmDeleteUser() {
async function onConfirmBlockUser() {
closedByUserAction.value = true
if (deleteUserId.value) {
await usersStore.blockUser(deleteUserId.value)
if (blockUserId.value) {
await usersStore.blockUser(blockUserId.value)
}
currentSlideEvent.value = null
}
const showBlockedUsers = ref(false)
const blockedUsers = computed(() => displayUsersAll.value.filter(el => el.is_block))
const blockedUsers = computed(() => displayUsersAll.value.filter(el => el.is_blocked))
const unblockUserId = ref<number | undefined> (undefined)
const showDialogRestoreUser = ref(false)
const showLeaveUsers = ref(false)
const leaveUsers = computed(() => displayUsersAll.value.filter(el => el.is_leave))
function handleUnblockUser (id: number) {
showDialogRestoreUser.value = true
unblockUserId.value = id
}
async function onConfirmRestoreUser () {
if (unblockUserId.value) await usersStore.restore(unblockUserId.value)
if (unblockUserId.value) await usersStore.unblockUser(unblockUserId.value)
}
watch(showDialogRestoreUser, (newD :boolean) => {
@@ -293,5 +371,18 @@
</script>
<style>
<style scoped>
.rotate-icon-btn {
transition: transform 0.3s;
}
.rotate-icon-btn.rotate-icon :deep(.q-icon) {
transform: rotate(180deg);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
}
.rotate-icon-btn :deep(.q-icon) {
transform: rotate(0deg);
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.5, 1);
}
</style>

View File

@@ -129,11 +129,6 @@ const routes: RouteRecordRaw[] = [
component: () => import('pages/account/SubscribePage.vue'),
meta: { requiresAuth: true }
},
{
name: '3software',
path: '/3software',
component: () => import('pages/account/3Software.vue'),
},
{
name: 'terms',
path: '/terms-of-use',

View File

@@ -14,6 +14,14 @@ interface Customer {
company?: CompanyParams
}
interface WSMessage {
id: number
action: string
entity: string
entity_id?: number
needUpdateStores: string[]
}
const ENDPOINT_MAP = {
register: '/auth/email/register',
forgot: '/auth/forgot',
@@ -25,6 +33,7 @@ export type AuthFlowType = keyof typeof ENDPOINT_MAP
export const useAuthStore = defineStore('auth', () => {
const customer = ref<Customer | null>(null)
const wsEvent = ref<WSMessage | null>(null)
const projectsStore = useProjectsStore()
const isAuth = computed(() => !!customer.value)
@@ -35,29 +44,26 @@ export const useAuthStore = defineStore('auth', () => {
customer.value = data.data
const socket = new WebSocket("wss://946gp81j-9000.euw.devtunnels.ms/api/admin")
console.log(socket)
socket.onopen = function() {
console.log("Соединение установлено.");
};
socket.onopen = () => console.log("Connection ws create.")
socket.onclose = function(event) {
if (event.wasClean) {
console.log('Соединение закрыто чисто');
} else {
console.log('Обрыв соединения'); // например, "убит" процесс сервера
socket.onclose = (event) => {
if (event.wasClean) console.log('Connection ws close.')
else console.log('Connection ws failure!')
console.log('Code: ' + event.code + ', reason : ' + event.reason)
}
console.log('Код: ' + event.code + ' причина: ' + event.reason);
};
socket.onmessage = function(event) {
console.log("Получены данные " + event.data);
};
socket.onmessage = (event) => {
if (wsEvent.value) {
wsEvent.value.needUpdateStores = []
wsEvent.value = JSON.parse(event.data)
if (wsEvent.value?.entity === 'chat') wsEvent.value.needUpdateStores = ['users']
}
socket.onerror = function(error) {
console.log("Ошибка " + error.message);
};
socket.onerror = (event) => console.error("Ошибка ", event)
}
} catch (error) {
if (isAuth.value) console.log(error)
if (isAuth.value) console.error(error)
}
}
@@ -81,10 +87,8 @@ export const useAuthStore = defineStore('auth', () => {
if (flowType !== 'changePwd')
await api.post(ENDPOINT_MAP[flowType], { email })
else
{console.log(222)
await api.post(ENDPOINT_MAP[flowType])
}
}
const confirmCode = async (flowType: AuthFlowType, email: string, code: string) => {
await api.post(ENDPOINT_MAP[flowType], { email, code })
@@ -100,36 +104,32 @@ export const useAuthStore = defineStore('auth', () => {
}
//change email of account
const getCodeCurrentEmail = async () => {
const getCodeCurrentEmail = async () =>
await api.post('/auth/email/change-email')
}
const confirmCurrentEmailCode = async (code: string) => {
const confirmCurrentEmailCode = async (code: string) =>
await api.post('/auth/email/change-email', { code })
}
const getCodeNewEmail = async (code: string, email: string) => {
const getCodeNewEmail = async (code: string, email: string) =>
await api.post('/auth/email/change-email', { code, email })
}
const confirmNewEmailCode = async (code: string, code2: string, email: string) => {
const confirmNewEmailCode = async (code: string, code2: string, email: string) =>
await api.post('/auth/email/change-email', { code, code2, email })
}
const setNewEmailPassword = async (code: string, code2: string, email: string, password: string) => {
const setNewEmailPassword = async (code: string, code2: string, email: string, password: string) =>
await api.post('/auth/email/change-email', { code, code2, email, password })
}
// user data company
const updateMyCompany = async (companyData: CompanyParams) => {
const response = await api.put('/customer/profile', { company: companyData })
console.log(response)
if (response.status === 200 && customer.value) customer.value.company = companyData
}
return {
customer,
isAuth,
wsEvent,
initialize,
loginWithCredentials,
loginWithTelegram,

View File

@@ -1,8 +1,9 @@
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
import { api } from 'boot/axios'
import type { Chat } from 'types/Chat'
import { useProjectsStore } from 'stores/projects'
import { useAuthStore } from 'stores/auth'
export const useChatsStore = defineStore('chats', () => {
const projectsStore = useProjectsStore()
@@ -12,8 +13,9 @@ export const useChatsStore = defineStore('chats', () => {
const currentProjectId = computed(() => projectsStore.currentProjectId)
async function init () {
const response = await api.get('/project/' + currentProjectId.value + '/chat')
const chatsAPI = response.data.data
reset()
const { data } = await api.get('/project/' + currentProjectId.value + '/chat')
const chatsAPI = data.data
chats.value.push(...chatsAPI)
isInit.value = true
}
@@ -42,6 +44,12 @@ export const useChatsStore = defineStore('chats', () => {
return chats.value.find(el =>el.id === id)
}
const authStore = useAuthStore()
watch(() => authStore.wsEvent, async (event) => {
if (!event || event.entity !== 'chat' || !event.needUpdateStores.includes('chats')) return
await init()
}, { deep: true })
return {
chats,
isInit,

View File

@@ -14,6 +14,7 @@ export const useCompaniesStore = defineStore('companies', () => {
const currentProjectId = computed(() => projectsStore.currentProjectId)
async function init () {
reset()
const { data }= await api.get('/project/' + currentProjectId.value + '/company')
const companiesAPI = data.data
companies.value.push(...(companiesAPI.sort((a: Company, b: Company) => (a.id - b.id))))

View File

@@ -9,16 +9,17 @@ import type { WebApp } from '@twa-dev/types'
interface AppSettings {
fontSize: number
locale: string
timeZoneBot: { tz: string, offset: number, offsetString: string }
localeBot: string
}
const defaultFontSize = 16
const minFontSize = 10
const maxFontSize = 22
const fontSizeStep = 2
const defaultSettings: AppSettings = {
fontSize: defaultFontSize,
locale: 'en-US'
locale: 'en-US',
timeZoneBot: { tz: 'Europe/Moscow', offset: 3, offsetString: '+03:00' },
localeBot: 'en-US'
}
export const useSettingsStore = defineStore('settings', () => {
@@ -30,14 +31,18 @@ export const useSettingsStore = defineStore('settings', () => {
const isInit = ref(false)
const currentFontSize = computed(() => settings.value?.fontSize ?? defaultFontSize)
const canIncrease = computed(() => currentFontSize.value < maxFontSize)
const canDecrease = computed(() => currentFontSize.value > minFontSize)
const supportLocale = [
{ value: 'en-US', label: 'English' },
{ value: 'ru-RU', label: 'Русский' }
]
const supportFontSizes = [
{ value: 12, label: 'settings__fontsize_small' },
{ value: 16, label: 'settings__fontsize_medium' },
{ value: 20, label: 'settings__fontsize_large' }
]
const detectLocale = (): string => {
const localeMap = {
ru: 'ru-RU',
@@ -74,10 +79,12 @@ export const useSettingsStore = defineStore('settings', () => {
const init = async () => {
if (authStore.isAuth) {
try {
const response = await api.get('/customer/settings')
const { data } = await api.get('/customer/settings')
settings.value = {
fontSize: response.data.data.settings.fontSize || defaultSettings.fontSize,
locale: response.data.data.settings.locale || detectLocale()
fontSize: data.data.settings.fontSize || defaultSettings.fontSize,
locale: data.data.settings.locale || detectLocale(),
timeZoneBot: data.data.settings.timeZone || defaultSettings.timeZoneBot,
localeBot: data.data.settings.localeBot || detectLocale()
}
} catch {
settings.value.locale = detectLocale()
@@ -93,12 +100,6 @@ export const useSettingsStore = defineStore('settings', () => {
isInit.value = true
}
const updateLocale = async (newLocale: string) => {
settings.value.locale = newLocale
applyLocale()
await saveSettings()
}
const saveSettings = async () => {
await api.put('/customer/settings', { settings: settings.value })
}
@@ -110,19 +111,6 @@ export const useSettingsStore = defineStore('settings', () => {
await saveSettings()
}
const clampFontSize = (size: number) =>
Math.max(minFontSize, Math.min(size, maxFontSize))
const increaseFontSize = async () => {
const newSize = clampFontSize(currentFontSize.value + fontSizeStep)
await updateSettings({ fontSize: newSize })
}
const decreaseFontSize = async () => {
const newSize = clampFontSize(currentFontSize.value - fontSizeStep)
await updateSettings({ fontSize: newSize })
}
watch(() => authStore.isAuth, (newVal) => {
if (newVal !== undefined) void init()
}, { immediate: true })
@@ -130,14 +118,10 @@ export const useSettingsStore = defineStore('settings', () => {
return {
settings,
supportLocale,
supportFontSizes,
isInit,
currentFontSize,
canIncrease,
canDecrease,
init,
increaseFontSize,
decreaseFontSize,
updateSettings,
updateLocale
updateSettings
}
})

View File

@@ -1,8 +1,9 @@
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { defineStore } from 'pinia'
import { api } from 'boot/axios'
import type { User, UserParams } from 'types/Users'
import { useProjectsStore } from 'stores/projects'
import { useAuthStore } from 'stores/auth'
export const useUsersStore = defineStore('users', () => {
const projectsStore = useProjectsStore()
@@ -12,6 +13,7 @@ export const useUsersStore = defineStore('users', () => {
const currentProjectId = computed(() => projectsStore.currentProjectId)
async function init () {
reset()
const { data } = await api.get('/project/' + currentProjectId.value + '/user')
const usersAPI = data.data
users.value.push(...usersAPI)
@@ -38,6 +40,13 @@ export const useUsersStore = defineStore('users', () => {
if (users.value[idx]) Object.assign(users.value[idx], userAPI)
}
async function unblockUser (userId: number) {
const { data } = await api.put('/project/' + currentProjectId.value + '/user/' + userId, { is_blocked: false })
const userAPI = data.data
const idx = users.value.findIndex(item => item.id === userAPI.id)
if (users.value[idx]) Object.assign(users.value[idx], userAPI)
}
function userById (id: number) {
return users.value.find(el =>el.id === id)
}
@@ -52,6 +61,12 @@ export const useUsersStore = defineStore('users', () => {
const getUsers = computed(() => users.value)
const authStore = useAuthStore()
watch(() => authStore.wsEvent, async (event) => {
if (!event || event.entity !== 'user' || !event.needUpdateStores.includes('users')) return
await init()
}, { deep: true })
return {
users,
isInit,
@@ -59,6 +74,7 @@ export const useUsersStore = defineStore('users', () => {
reset,
update,
blockUser,
unblockUser,
userById,
userNameById,
getUsers

40
todo_all.txt Normal file
View File

@@ -0,0 +1,40 @@
1. Приложение Администратора:
1.1. Внедрение web-socket
1.2. Страница подписки и внедрение оплаты звездами
1.3. Юридические документы:
- Лицензионное соглашение (EN / RU)
- Обработка персональных данных (RU)
- Privacy police (EN)
1.4. Маскировка компаний
1.5. Взаимодействие с архивным чатом
2. Приложение Пользователя:
2.1. Авторизация с флагом принятия документов
2.2. При выборе пользователя в задаче или совещании - предоставлять
только список пользователей в чате
2.3. Отображение владельца чата (?техническая возможность)
2.4. В файлах добавить новую сущность URL
2.5. Работа с архивом задач
2.6. Механизм передачи vCard (через медиашаринг https://telegram.tips/blog/mini-apps-media-sharing/ )
2.7. Внедрение web-socket
3. Чаты:
3.1. Внедрение inline-режима создания форматированного сообщения, задачи и совещания
3.2. Проверить реализацию всех фич.
4. Сайт:
4.1. Создание сайта-лендинга
4.2. Оплата адреса
5. Инфраструктура:
5.1. Разворачивание гипервизора
5.2. Развертывание виртуальных машин
5.3. Бекапирование
5.4. Настройка nginx
6. Прочее:
6.1. Подача заявления в Роскомнадзор о работе с персональными данными.