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

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 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
}
}
const baseDocName =
props.type ==='terms_of_use' ? 'Terms_of_use' : 'Privacy'
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-card class="q-pa-none q-ma-none w100 no-scroll" align="center">
<q-dialog v-model="modelValue">
<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
:label="$t(auxBtnLabel)"
outline
color="grey"
class="w100"
v-close-popup
rounded
@click="emit('clickAuxBtn')"
/>
</div>
<div class="col-grow">
<q-btn
:label="$t(mainBtnLabel)"
:color="color"
class="w100"
v-close-popup
rounded
@click="emit('clickMainBtn')"
/>
</div>
</div>
<q-btn
v-if="auxBtnLabel"
:label="$t(auxBtnLabel)"
outline
color="grey"
v-close-popup
rounded
class="w50"
@click="emit('clickAuxBtn')"
/>
<q-btn
:label="$t(mainBtnLabel)"
:color="color"
v-close-popup
rounded
:class="auxBtnLabel ? 'w50' : 'w80'"
@click="emit('clickMainBtn')"
/>
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('close')}}
</div>
</q-btn>
</div>
<q-btn
class="w80 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')}}
</div>
</q-btn>
</q-card-actions>
</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>
<span class="text-caption">
{{ !showArchive
? $t('projects__show_archive') + ' (' + archiveProjects.length +')'
: $t('projects__hide_archive')
}}
</span>
</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

@@ -1,234 +0,0 @@
<template>
<pn-page-card>
<template #title>
{{ $t('software__title') }}
</template>
<pn-scroll-list>
<template #card-body-header>
<div class="q-pa-md">
{{ $t('software__description') }}
</div>
</template>
<q-list separator>
<q-expansion-item
v-for="item in software"
group="somegroup"
>
<template #header>
<q-item-section avatar>
<q-avatar square size="sm" v-if="item.logo">
<img :src="'3software/logo/' + item.logo">
</q-avatar>
</q-item-section>
<q-item-section>
<div class="flex items-baseline">
<span class="text-h6">
{{ item.name }}
</span>
<span v-if = "item.ver" class="text-caption q-pl-xs">
{{ 'v.' + item.ver }}
</span>
</div>
</q-item-section>
</template>
<div class="w100 flex column q-px-md q-gutter-y-md q-py-sm">
<div class="flex row no-wrap items-center">
<q-icon name="mdi-scale-balance" size="sm" class="q-pr-lg" color="grey"/>
<div
@click="downloadFile('3software/license/' + item.license_file)"
class="flex w100 column q-pl-sm cursor-pointer"
>
<span> {{ item.license }} </span>
<span
class="text-caption"
style="white-space: pre-line;"
>
{{ item.license_copyright }}
</span>
</div>
</div>
<div class="flex row no-wrap items-center" v-if="item.web">
<q-icon name="mdi-web" size="sm" class="q-pr-lg" color="grey"/>
<span
class="q-pl-sm cursor-pointer"
@click="tg.openLink(item.web_url)"
>
{{ item.web }}
</span>
</div>
<div class="flex row no-wrap items-center" v-if="item.git">
<q-icon name="mdi-github" size="sm" class="q-pr-lg" color="grey"/>
<span
class="q-pl-sm cursor-pointer"
@click="tg.openLink(item.git_url)"
>
{{ item.git }}
</span>
</div>
</div>
</q-expansion-item>
</q-list>
</pn-scroll-list>
</pn-page-card>
</template>
<script setup lang="ts">
import { inject } from 'vue'
import type { WebApp } from '@twa-dev/types'
const tg = inject('tg') as WebApp
const software = [
{
id: 1,
name: 'Vue',
ver: '3.4',
logo: 'vue.webp',
web: 'vuejs.org',
web_url: 'https://vuejs.org/',
git: 'vuejs/core',
git_url: 'https://github.com/vuejs/core',
license: 'MIT License',
license_copyright: 'Copyright (c) 2018-present, Yuxi (Evan) You and Vue contributors',
license_file: 'vue/LICENSE.txt'
},
{
id: 2,
name: 'Quasar',
ver: '2.x',
logo: 'quasar.png',
web: 'quasar.dev',
web_url: 'https://quasar.dev/',
git: 'quasarframework/quasar',
git_url: 'https://github.com/quasarframework/quasar',
license: 'MIT License',
license_copyright: 'Copyright (c) 2015-present Razvan Stoenescu',
license_file: 'quasar/LICENSE.txt'
},
{
id: 3,
name: 'Pinia',
ver: '2.x',
logo: 'pinia.svg',
web: 'pinia.vuejs.org',
web_url: 'https://pinia.vuejs.org/',
git: 'vuejs/pinia',
git_url: 'https://github.com/vuejs/pinia',
license: 'MIT License',
license_copyright: 'Copyright (c) 2019-present Eduardo San Martin Morote',
license_file: 'pinia/LICENSE.txt'
},
{
id: 4,
name: 'Vue Router',
ver: '4.x',
logo: 'vue.webp',
web: 'router.vuejs.org',
web_url: 'https://router.vuejs.org/',
git: 'vuejs/router',
git_url: 'https://github.com/vuejs/router',
license: 'MIT License',
license_copyright: 'Copyright (c) 2019-present Eduardo San Martin Morote',
license_file: 'vue-router/LICENSE.txt'
},
{
id: 5,
name: 'Vue i18n',
ver: '9.x',
logo: 'vue-i18n.svg',
web: 'vue-i18n.intlify.dev',
web_url: 'https://vue-i18n.intlify.dev/',
git: 'intlify/vue-i18n',
git_url: 'https://github.com/intlify/vue-i18n',
license: 'MIT License',
license_copyright: 'Copyright (c) 2016-present kazuya kawaguchi and contributors',
license_file: 'vue-i18n/LICENSE.txt'
},
{
id: 6,
name: 'Axios',
ver: '1.x',
logo: 'axios.svg',
web: 'axios-http.com',
web_url: 'https://axios-http.com/',
git: 'axios/axios',
git_url: 'https://github.com/axios/axios',
license: 'MIT License',
license_copyright: 'Copyright (c) 2014-present Matt Zabriskie & Collaborators',
license_file: 'axios/LICENSE.txt'
},
{
id: 7,
name: 'better-sqlite3',
ver: '11.x',
logo: '',
web: '',
web_url: '',
git: 'WiseLibs/better-sqlite3',
git_url: 'https://github.com/WiseLibs/better-sqlite3',
license: 'MIT License',
license_copyright: 'Copyright (c) 2017 Joshua Wise',
license_file: 'better-sqlite3/LICENSE.txt'
},
{
id: 8,
name: 'Express',
ver: '4.x',
logo: 'express.svg',
web: 'expressjs.com',
web_url: 'https://expressjs.com/',
git: 'expressjs/express',
git_url: 'https://github.com/expressjs/express',
license: 'MIT License',
license_copyright: `Copyright (c) 2009-2014 TJ Holowaychuk <tj@vision-media.ca>
Copyright (c) 2013-2014 Roman Shtylman <shtylman+expressjs@gmail.com>
Copyright (c) 2014-2015 Douglas Christopher Wilson <doug@somethingdoug.com>`,
license_file: 'express/LICENSE.txt'
}
]
const downloadFile = async (url: string) => {
try {
const fullUrl = '/admin/' + url;
const fileUrl = new URL(fullUrl, window.location.origin).href;
const response = await fetch(fileUrl)
if (!response.ok) throw new Error(`HTTP error: ${response.status}`)
const blob = await response.blob()
const downloadUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
const extractFileName = (url: string): string => {
return url.substring(url.lastIndexOf('/') + 1)
}
link.href = downloadUrl
link.download = extractFileName(url)
link.style.display = 'none'
document.body.appendChild(link)
link.click()
setTimeout(() => {
document.body.removeChild(link)
URL.revokeObjectURL(downloadUrl)
}, 100)
} catch (error) {
console.error('Download error:', error)
}
}
</script>
<style scope>
</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"
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"
/>
<q-btn
@click="settingsStore.increaseFontSize()"
color="positive" flat
icon="mdi-format-font-size-increase"
class="q-pa-sm q-mx-xs"
:disable="!settingsStore.canIncrease"
/>
</div>
</q-item-section>
</q-item>
<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"
/>
</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"
/>
</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"
/>
</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')
}}
</span>
</template>
</q-btn-dropdown>
<span class="text-caption">
{{ !showBlockedUsers
? $t('users__show_blocked_users') + ' (' + blockedUsers.length +')'
: $t('users__hide_blocked_users')
}}
</span>
</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)
}
socket.onmessage = (event) => {
if (wsEvent.value) {
wsEvent.value.needUpdateStores = []
wsEvent.value = JSON.parse(event.data)
if (wsEvent.value?.entity === 'chat') wsEvent.value.needUpdateStores = ['users']
}
console.log('Код: ' + event.code + ' причина: ' + event.reason);
};
socket.onmessage = function(event) {
console.log("Получены данные " + event.data);
};
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,9 +87,7 @@ 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) => {
@@ -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
}
@@ -41,6 +43,12 @@ export const useChatsStore = defineStore('chats', () => {
function chatById (id: number) {
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,

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,13 +31,17 @@ 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 = {
@@ -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