first commit
This commit is contained in:
158
resources/frontend/core/components/AppImage.vue
Normal file
158
resources/frontend/core/components/AppImage.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div>
|
||||
<transition appear mode="out-in" name="fade">
|
||||
<Skeleton v-if="!loaded" />
|
||||
<template v-else>
|
||||
<component :is="openable ? 'a' : 'div'" :href="url" target="_blank">
|
||||
<svg
|
||||
v-if="error"
|
||||
class="error-image"
|
||||
viewBox="0 0 280 162"
|
||||
x="0px"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
y="0px"
|
||||
>
|
||||
<rect height="162" width="280" />
|
||||
<path
|
||||
d="M140,30.59c-27.85,0-50.41,22.56-50.41,50.41s22.56,50.41,50.41,50.41s50.41-22.56,50.41-50.41
|
||||
S167.85,30.59,140,30.59z M140,121.65c-22.42,0-40.65-18.23-40.65-40.65S117.58,40.35,140,40.35S180.65,58.58,180.65,81
|
||||
S162.42,121.65,140,121.65z M123.74,77.75c3.6,0,6.5-2.91,6.5-6.5s-2.91-6.5-6.5-6.5s-6.5,2.91-6.5,6.5S120.14,77.75,123.74,77.75z
|
||||
M156.26,64.74c-3.6,0-6.5,2.91-6.5,6.5s2.91,6.5,6.5,6.5c3.6,0,6.5-2.91,6.5-6.5S159.86,64.74,156.26,64.74z M140,90.76
|
||||
c-8.17,0-15.85,3.6-21.1,9.88c-1.73,2.07-1.44,5.14,0.63,6.87c2.07,1.71,5.14,1.44,6.87-0.63c3.37-4.05,8.33-6.38,13.6-6.38
|
||||
s10.22,2.32,13.6,6.38c1.65,1.97,4.7,2.42,6.87,0.63c2.07-1.73,2.34-4.8,0.63-6.87C155.85,94.35,148.17,90.76,140,90.76z"
|
||||
/>
|
||||
</svg>
|
||||
<lazy-component v-else-if="lazy">
|
||||
<img :src="url" alt="screenshot" @click="$emit('click', $event)" @error="handleError" />
|
||||
</lazy-component>
|
||||
<img v-else :src="url" alt="screenshot" @click="$emit('click', $event)" @error="handleError" />
|
||||
</component>
|
||||
</template>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from '@/config/app';
|
||||
import { Skeleton } from 'vue-loading-skeleton';
|
||||
|
||||
export default {
|
||||
name: 'AppImage',
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
lazy: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
openable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const baseUrl =
|
||||
this.src.indexOf('http') === 0
|
||||
? ''
|
||||
: (process.env.VUE_APP_API_URL !== 'null'
|
||||
? process.env.VUE_APP_API_URL
|
||||
: `${window.location.origin}/api`) + '/';
|
||||
|
||||
const url = baseUrl + this.src;
|
||||
|
||||
return {
|
||||
error: this.src === 'none',
|
||||
loaded: this.src === 'none',
|
||||
url,
|
||||
baseUrl,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
Skeleton,
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
if (this.error) return;
|
||||
|
||||
if (this.src === 'none') {
|
||||
this.error = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this.loaded = false;
|
||||
|
||||
if (this.url) {
|
||||
URL.revokeObjectURL(this.url);
|
||||
this.url = null;
|
||||
}
|
||||
|
||||
if (this.src) {
|
||||
axios
|
||||
.get(this.src, {
|
||||
responseType: 'blob',
|
||||
muteError: true,
|
||||
})
|
||||
.then(({ data }) => {
|
||||
this.url = URL.createObjectURL(data);
|
||||
})
|
||||
.catch(() => {
|
||||
this.error = true;
|
||||
})
|
||||
.finally(() => {
|
||||
this.loaded = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
handleError() {
|
||||
this.error = true;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.load();
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.url) {
|
||||
URL.revokeObjectURL(this.url);
|
||||
this.url = null;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
src() {
|
||||
this.error = false;
|
||||
this.load();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
img {
|
||||
width: 100%;
|
||||
object-fit: cover;
|
||||
background-color: $gray-5;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.4s;
|
||||
}
|
||||
|
||||
.fade-enter,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.error-image {
|
||||
rect {
|
||||
fill: $gray-4;
|
||||
}
|
||||
|
||||
path {
|
||||
fill: $red-1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
37
resources/frontend/core/components/BackButton.vue
Normal file
37
resources/frontend/core/components/BackButton.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<at-button v-bind="$attrs" v-on="$listeners" @click="onClick"><slot /></at-button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
routeChanged: true,
|
||||
};
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.routeChanged = true;
|
||||
},
|
||||
|
||||
methods: {
|
||||
onClick() {
|
||||
this.routeChanged = false;
|
||||
this.$router.go(-1);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.routeChanged && window.opener !== null) {
|
||||
window.opener.focus();
|
||||
window.close();
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
$route() {
|
||||
this.routeChanged = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
712
resources/frontend/core/components/Calendar.vue
Normal file
712
resources/frontend/core/components/Calendar.vue
Normal file
@@ -0,0 +1,712 @@
|
||||
<template>
|
||||
<div class="calendar" @click="togglePopup">
|
||||
<at-input class="input" :readonly="true" :value="inputValue">
|
||||
<template #prepend>
|
||||
<i class="icon icon-chevron-left previous" @click.stop.prevent="selectPrevious"></i>
|
||||
</template>
|
||||
|
||||
<template #append>
|
||||
<i class="icon icon-chevron-right next" @click.stop.prevent="selectNext"></i>
|
||||
</template>
|
||||
</at-input>
|
||||
|
||||
<span class="calendar-icon icon icon-calendar" />
|
||||
|
||||
<transition name="slide-up">
|
||||
<div
|
||||
v-show="showPopup"
|
||||
:class="{
|
||||
'datepicker-wrapper': true,
|
||||
'datepicker-wrapper--range': datePickerRange,
|
||||
'at-select__dropdown at-select__dropdown--bottom': true,
|
||||
}"
|
||||
@click.stop
|
||||
>
|
||||
<div>
|
||||
<at-tabs ref="tabs" v-model="tab" @on-change="onTabChange">
|
||||
<at-tab-pane v-if="day" :label="$t('control.day')" name="day"></at-tab-pane>
|
||||
<at-tab-pane v-if="week" :label="$t('control.week')" name="week"></at-tab-pane>
|
||||
<at-tab-pane v-if="month" :label="$t('control.month')" name="month"></at-tab-pane>
|
||||
<at-tab-pane v-if="range" :label="$t('control.range')" name="range"></at-tab-pane>
|
||||
</at-tabs>
|
||||
</div>
|
||||
|
||||
<date-picker
|
||||
:key="$i18n.locale"
|
||||
class="datepicker"
|
||||
:append-to-body="false"
|
||||
:clearable="false"
|
||||
:editable="false"
|
||||
:inline="true"
|
||||
:lang="datePickerLang"
|
||||
:type="datePickerType"
|
||||
:range="datePickerRange"
|
||||
:value="datePickerValue"
|
||||
@change="onDateChange"
|
||||
>
|
||||
<template #footer>
|
||||
<div v-if="day" class="datepicker__footer">
|
||||
<button class="mx-btn mx-btn-text" size="small" @click="setToday">
|
||||
{{ $t('control.today') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</date-picker>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import { getDateToday, getEndDay, getStartDay } from '@/utils/time';
|
||||
|
||||
export default {
|
||||
name: 'Calendar',
|
||||
props: {
|
||||
day: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
week: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
month: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
range: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
initialTab: {
|
||||
type: String,
|
||||
default: 'day',
|
||||
},
|
||||
sessionStorageKey: {
|
||||
type: String,
|
||||
default: 'amazingcat.session.storage',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const { query } = this.$route;
|
||||
const today = this.getDateToday();
|
||||
|
||||
const data = {
|
||||
showPopup: false,
|
||||
lang: null,
|
||||
datePickerLang: {},
|
||||
};
|
||||
|
||||
const sessionData = {
|
||||
type: sessionStorage.getItem(this.sessionStorageKey + '.type'),
|
||||
start: sessionStorage.getItem(this.sessionStorageKey + '.start'),
|
||||
end: sessionStorage.getItem(this.sessionStorageKey + '.end'),
|
||||
};
|
||||
|
||||
if (typeof query['type'] === 'string' && this.validateTab(query['type'])) {
|
||||
data.tab = query['type'];
|
||||
} else if (typeof sessionData.type === 'string' && this.validateTab(sessionData.type)) {
|
||||
data.tab = sessionData.type;
|
||||
} else {
|
||||
data.tab = this.initialTab;
|
||||
}
|
||||
|
||||
if (typeof query['start'] === 'string' && this.validateDate(query['start'])) {
|
||||
data.start = query['start'];
|
||||
} else if (typeof sessionData.start === 'string' && this.validateDate(sessionData.start)) {
|
||||
data.start = sessionData.start;
|
||||
} else {
|
||||
data.start = today;
|
||||
}
|
||||
|
||||
if (typeof query['end'] === 'string' && this.validateDate(query['end'])) {
|
||||
data.end = query['end'];
|
||||
} else if (typeof sessionData.end === 'string' && this.validateDate(sessionData.end)) {
|
||||
data.end = sessionData.end;
|
||||
} else {
|
||||
data.end = today;
|
||||
}
|
||||
|
||||
switch (data.tab) {
|
||||
case 'day':
|
||||
case 'date':
|
||||
data.end = data.start;
|
||||
break;
|
||||
|
||||
case 'week': {
|
||||
const date = moment(data.start, 'YYYY-MM-DD', true);
|
||||
if (date.isValid()) {
|
||||
data.start = date.startOf('isoWeek').format('YYYY-MM-DD');
|
||||
data.end = date.endOf('isoWeek').format('YYYY-MM-DD');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'month': {
|
||||
const date = moment(data.start, 'YYYY-MM-DD', true);
|
||||
if (date.isValid()) {
|
||||
data.start = date.startOf('month').format('YYYY-MM-DD');
|
||||
data.end = date.endOf('month').format('YYYY-MM-DD');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('click', this.hidePopup);
|
||||
this.saveData(this.tab, this.start, this.end);
|
||||
this.emitChangeEvent();
|
||||
this.$nextTick(async () => {
|
||||
try {
|
||||
const locale = await import(`vue2-datepicker/locale/${this.$i18n.locale}`);
|
||||
|
||||
this.datePickerLang = {
|
||||
...locale,
|
||||
formatLocale: {
|
||||
...locale.formatLocale,
|
||||
firstDayOfWeek: 1,
|
||||
},
|
||||
monthFormat: 'MMMM',
|
||||
};
|
||||
} catch {
|
||||
this.datePickerLang = {
|
||||
formatLocale: { firstDayOfWeek: 1 },
|
||||
monthFormat: 'MMMM',
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('click', this.hidePopup);
|
||||
},
|
||||
computed: {
|
||||
inputValue() {
|
||||
switch (this.tab) {
|
||||
case 'date':
|
||||
default:
|
||||
return moment(this.start, 'YYYY-MM-DD').locale(this.$i18n.locale).format('MMM DD, YYYY');
|
||||
|
||||
case 'week': {
|
||||
const start = moment(this.start, 'YYYY-MM-DD').locale(this.$i18n.locale).startOf('isoWeek');
|
||||
const end = moment(this.end, 'YYYY-MM-DD').locale(this.$i18n.locale).endOf('isoWeek');
|
||||
if (start.month() === end.month()) {
|
||||
return start.format('MMM DD-') + end.format('DD, YYYY');
|
||||
}
|
||||
|
||||
return start.format('MMM DD — ') + end.format('MMM DD, YYYY');
|
||||
}
|
||||
|
||||
case 'month':
|
||||
return moment(this.start, 'YYYY-MM-DD')
|
||||
.locale(this.$i18n.locale)
|
||||
.startOf('month')
|
||||
.format('MMM, YYYY');
|
||||
|
||||
case 'range': {
|
||||
const start = moment(this.start, 'YYYY-MM-DD').locale(this.$i18n.locale);
|
||||
const end = moment(this.end, 'YYYY-MM-DD').locale(this.$i18n.locale);
|
||||
|
||||
if (start.year() === end.year()) {
|
||||
return start.format('MMM DD, — ') + end.format('MMM DD, YYYY');
|
||||
} else {
|
||||
return start.format('MMM DD, YYYY — ') + end.format('MMM DD, YYYY');
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
datePickerType() {
|
||||
switch (this.tab) {
|
||||
case 'day':
|
||||
case 'range':
|
||||
default:
|
||||
return 'date';
|
||||
|
||||
case 'week':
|
||||
return 'week';
|
||||
|
||||
case 'month':
|
||||
return 'month';
|
||||
}
|
||||
},
|
||||
datePickerRange() {
|
||||
return this.tab === 'range';
|
||||
},
|
||||
datePickerValue() {
|
||||
if (this.tab === 'range') {
|
||||
return [moment(this.start, 'YYYY-MM-DD').toDate(), moment(this.end, 'YYYY-MM-DD').toDate()];
|
||||
}
|
||||
|
||||
return moment(this.start, 'YYYY-MM-DD').toDate();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getDateToday,
|
||||
validateTab(tab) {
|
||||
return ['day', 'date', 'week', 'month', 'range'].indexOf(tab) !== -1;
|
||||
},
|
||||
validateDate(date) {
|
||||
return moment(date, 'YYYY-MM-DD', true).isValid();
|
||||
},
|
||||
togglePopup() {
|
||||
this.showPopup = !this.showPopup;
|
||||
},
|
||||
hidePopup() {
|
||||
if (this.$el.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showPopup = false;
|
||||
},
|
||||
selectPrevious() {
|
||||
let start, end;
|
||||
switch (this.tab) {
|
||||
case 'day':
|
||||
default: {
|
||||
const date = moment(this.start).subtract(1, 'day').format('YYYY-MM-DD');
|
||||
start = date;
|
||||
end = date;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'week': {
|
||||
const date = moment(this.start).subtract(1, 'week');
|
||||
start = date.startOf('isoWeek').format('YYYY-MM-DD');
|
||||
end = date.endOf('isoWeek').format('YYYY-MM-DD');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'month': {
|
||||
const date = moment(this.start).subtract(1, 'month');
|
||||
start = date.startOf('month').format('YYYY-MM-DD');
|
||||
end = date.endOf('month').format('YYYY-MM-DD');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'range': {
|
||||
const diff = moment(this.end).diff(this.start, 'days') + 1;
|
||||
start = moment(this.start).subtract(diff, 'days').format('YYYY-MM-DD');
|
||||
end = moment(this.end).subtract(diff, 'days').format('YYYY-MM-DD');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.saveData(this.tab, start, end);
|
||||
this.emitChangeEvent();
|
||||
},
|
||||
selectNext() {
|
||||
let start, end;
|
||||
switch (this.tab) {
|
||||
case 'day':
|
||||
default: {
|
||||
const date = moment(this.start).add(1, 'day').format('YYYY-MM-DD');
|
||||
start = date;
|
||||
end = date;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'week': {
|
||||
const date = moment(this.start).add(1, 'week');
|
||||
start = date.startOf('isoWeek').format('YYYY-MM-DD');
|
||||
end = date.endOf('isoWeek').format('YYYY-MM-DD');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'month': {
|
||||
const date = moment(this.start).add(1, 'month');
|
||||
start = date.startOf('month').format('YYYY-MM-DD');
|
||||
end = date.endOf('month').format('YYYY-MM-DD');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'range': {
|
||||
const diff = moment(this.end).diff(this.start, 'days') + 1;
|
||||
start = moment(this.start).add(diff, 'days').format('YYYY-MM-DD');
|
||||
end = moment(this.end).add(diff, 'days').format('YYYY-MM-DD');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.saveData(this.tab, start, end);
|
||||
this.emitChangeEvent();
|
||||
},
|
||||
onTabChange({ index, name }) {
|
||||
this.tab = 'range';
|
||||
this.$nextTick(() => {
|
||||
this.tab = name;
|
||||
});
|
||||
},
|
||||
setDate(value) {
|
||||
let start, end;
|
||||
|
||||
switch (this.tab) {
|
||||
case 'day':
|
||||
default: {
|
||||
const date = moment(value).format('YYYY-MM-DD');
|
||||
start = date;
|
||||
end = date;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'week':
|
||||
start = moment(value).startOf('isoWeek').format('YYYY-MM-DD');
|
||||
end = moment(value).endOf('isoWeek').format('YYYY-MM-DD');
|
||||
break;
|
||||
|
||||
case 'month':
|
||||
start = moment(value).startOf('month').format('YYYY-MM-DD');
|
||||
end = moment(value).endOf('month').format('YYYY-MM-DD');
|
||||
break;
|
||||
|
||||
case 'range':
|
||||
start = moment(value[0]).format('YYYY-MM-DD');
|
||||
end = moment(value[1]).format('YYYY-MM-DD');
|
||||
break;
|
||||
}
|
||||
|
||||
this.saveData(this.tab, start, end);
|
||||
this.emitChangeEvent();
|
||||
},
|
||||
saveData(type, start, end) {
|
||||
this.tab = type;
|
||||
this.start = start;
|
||||
this.end = end;
|
||||
|
||||
sessionStorage.setItem(this.sessionStorageKey + '.type', type);
|
||||
sessionStorage.setItem(this.sessionStorageKey + '.start', start);
|
||||
sessionStorage.setItem(this.sessionStorageKey + '.end', end);
|
||||
|
||||
const { query } = this.$route;
|
||||
|
||||
const searchParams = new URLSearchParams({ type, start, end }).toString();
|
||||
|
||||
// HACK: The native history is used because changing
|
||||
// params via Vue Router closes all pending requests
|
||||
history.pushState(null, null, `?${searchParams}`);
|
||||
},
|
||||
emitChangeEvent() {
|
||||
this.$emit('change', {
|
||||
type: sessionStorage.getItem(this.sessionStorageKey + '.type'),
|
||||
start: sessionStorage.getItem(this.sessionStorageKey + '.start'),
|
||||
end: sessionStorage.getItem(this.sessionStorageKey + '.end'),
|
||||
});
|
||||
},
|
||||
onDateChange(value) {
|
||||
this.showPopup = false;
|
||||
|
||||
this.setDate(value);
|
||||
},
|
||||
setToday() {
|
||||
this.tab = 'day';
|
||||
this.$refs.tabs.setNavByIndex(0);
|
||||
this.setDate(new Date());
|
||||
this.hidePopup();
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
$route(to, from) {
|
||||
const { query } = to;
|
||||
|
||||
if (typeof query['type'] === 'string' && this.validateTab(query['type'])) {
|
||||
sessionStorage.setItem(this.sessionStorageKey + '.type', (this.tab = query['type']));
|
||||
}
|
||||
|
||||
if (typeof query['start'] === 'string' && this.validateDate(query['start'])) {
|
||||
sessionStorage.setItem(this.sessionStorageKey + '.start', (this.start = query['start']));
|
||||
}
|
||||
|
||||
if (typeof query['end'] === 'string' && this.validateDate(query['end'])) {
|
||||
sessionStorage.setItem(this.sessionStorageKey + '.end', (this.end = query['end']));
|
||||
}
|
||||
|
||||
this.emitChangeEvent();
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.calendar {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.calendar-icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 2em;
|
||||
color: #2e2ef9;
|
||||
line-height: 40px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.input {
|
||||
background: #ffffff;
|
||||
width: 330px;
|
||||
height: 40px;
|
||||
border: 1px solid #eeeef5;
|
||||
border-radius: 5px;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&::v-deep {
|
||||
.at-input-group__prepend,
|
||||
.at-input-group__append,
|
||||
.at-input__original {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.at-input-group__prepend,
|
||||
.at-input-group__append {
|
||||
padding: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.at-input__original {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.fa-calendar {
|
||||
color: #2e2ef9;
|
||||
}
|
||||
|
||||
.previous,
|
||||
.next {
|
||||
color: #2e2ef9;
|
||||
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 28px;
|
||||
height: 100%;
|
||||
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker-wrapper {
|
||||
position: absolute;
|
||||
width: 320px;
|
||||
max-height: unset;
|
||||
|
||||
&--range {
|
||||
width: 640px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 750px) {
|
||||
.datepicker-wrapper {
|
||||
&--range {
|
||||
width: 320px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.datepicker__footer {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.calendar::v-deep {
|
||||
.at-tabs__header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.at-tabs-nav {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.at-tabs-nav__item {
|
||||
color: #c4c4cf;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-right: 0;
|
||||
padding: 0;
|
||||
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
&--active {
|
||||
color: #2e2ef9;
|
||||
}
|
||||
|
||||
&::after {
|
||||
background-color: #2e2ef9;
|
||||
}
|
||||
}
|
||||
|
||||
.mx-datepicker {
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.mx-datepicker-main,
|
||||
.mx-datepicker-inline {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.mx-datepicker-header {
|
||||
padding: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.mx-calendar {
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.mx-calendar-content {
|
||||
width: unset;
|
||||
}
|
||||
|
||||
.mx-calendar-header {
|
||||
& > .mx-btn-text {
|
||||
padding: 0;
|
||||
width: 34px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.mx-calendar-header-label .mx-btn {
|
||||
color: #1a051d;
|
||||
}
|
||||
|
||||
.mx-table thead {
|
||||
color: #b1b1be;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mx-week-number-header,
|
||||
.mx-week-number {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mx-table-date td {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mx-table-date .cell:last-child {
|
||||
color: #ff5569;
|
||||
}
|
||||
|
||||
.mx-table {
|
||||
.cell.not-current-month {
|
||||
color: #e7ecf2;
|
||||
}
|
||||
|
||||
.cell.active {
|
||||
background: transparent;
|
||||
|
||||
& > div {
|
||||
display: inline-block;
|
||||
background: #2e2ef9;
|
||||
color: #ffffff;
|
||||
border-radius: 7px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
.cell.in-range {
|
||||
background: transparent;
|
||||
|
||||
& > div {
|
||||
display: inline-block;
|
||||
background: #eeeef5;
|
||||
color: inherit;
|
||||
border-top-left-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
&:last-child > div {
|
||||
border-top-right-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.cell.in-range + .cell.in-range > div {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
.mx-active-week {
|
||||
background: transparent;
|
||||
|
||||
.cell > div {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.cell:nth-child(3) > div {
|
||||
border-top-left-radius: 5px;
|
||||
border-bottom-left-radius: 5px;
|
||||
}
|
||||
|
||||
.cell:nth-child(7) > div {
|
||||
border-top-right-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
}
|
||||
|
||||
.cell + .cell:not(:last-child) > div {
|
||||
display: inline-block;
|
||||
background: #eeeef5;
|
||||
color: #151941;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.mx-week-number + .cell > div,
|
||||
.cell:last-child > div {
|
||||
display: inline-block;
|
||||
background: #2e2ef9;
|
||||
color: #ffffff;
|
||||
border-radius: 7px;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
line-height: 25px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx-table-month {
|
||||
color: #000000;
|
||||
|
||||
.cell {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.cell.active > div {
|
||||
border-radius: 5px;
|
||||
width: 54px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx-table-year {
|
||||
color: #000000;
|
||||
|
||||
.cell.active > div {
|
||||
width: 54px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx-btn:hover {
|
||||
color: #2e2ef9;
|
||||
}
|
||||
|
||||
.mx-table .cell.today {
|
||||
color: #2a90e9;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
72
resources/frontend/core/components/ColorInput.vue
Normal file
72
resources/frontend/core/components/ColorInput.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="color-input__item">
|
||||
<at-modal v-model="modal" :showHead="false" :showClose="false" :showFooter="false">
|
||||
<ChromePicker :value="value" @input="$emit('change', $event.hex)" />
|
||||
</at-modal>
|
||||
|
||||
<div class="color-input__color">
|
||||
<div class="at-input__original" :style="{ background: value }" @click.prevent="modal = true" />
|
||||
</div>
|
||||
|
||||
<at-button class="color-input__remove" @click.prevent="$emit('change', null)">
|
||||
<span class="icon icon-x" />
|
||||
</at-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Chrome } from 'vue-color';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChromePicker: Chrome,
|
||||
},
|
||||
props: {
|
||||
value: {},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
modal: false,
|
||||
};
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.at-input__original {
|
||||
width: 170px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.color-input {
|
||||
&__item {
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
|
||||
&::v-deep .at-modal {
|
||||
width: 225px !important;
|
||||
}
|
||||
|
||||
&::v-deep .at-modal__body {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__color {
|
||||
flex: 1;
|
||||
margin-right: 0.5em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
&__remove {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
&__color {
|
||||
max-width: 170px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
71
resources/frontend/core/components/ExportDropdown.vue
Normal file
71
resources/frontend/core/components/ExportDropdown.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<div class="dropdown">
|
||||
<at-dropdown :placement="position" :trigger="trigger" @on-dropdown-command="onExport">
|
||||
<at-button type="text">
|
||||
<span class="icon icon-save" />
|
||||
</at-button>
|
||||
|
||||
<at-dropdown-menu slot="menu">
|
||||
<at-dropdown-item v-for="(type, key) in types" :key="key" :name="key">{{
|
||||
key.toUpperCase()
|
||||
}}</at-dropdown-item>
|
||||
</at-dropdown-menu>
|
||||
</at-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AboutService from '@/services/resource/about.service';
|
||||
|
||||
const aboutService = new AboutService();
|
||||
|
||||
export default {
|
||||
name: 'ExportDropdown',
|
||||
props: {
|
||||
position: {
|
||||
type: String,
|
||||
default: 'bottom-left',
|
||||
},
|
||||
trigger: {
|
||||
type: String,
|
||||
default: 'click',
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
types: [],
|
||||
}),
|
||||
async created() {
|
||||
this.types = await aboutService.getReportTypes();
|
||||
},
|
||||
methods: {
|
||||
onExport(format) {
|
||||
this.$emit('export', this.types[format]);
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dropdown {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&::v-deep .at-btn__text {
|
||||
color: #2e2ef9;
|
||||
font-size: 25px;
|
||||
}
|
||||
}
|
||||
|
||||
.at-dropdown-menu {
|
||||
right: 5px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
350
resources/frontend/core/components/GroupSelect.vue
Normal file
350
resources/frontend/core/components/GroupSelect.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<div ref="groupSelect" class="group-select" @click="onActive">
|
||||
<v-select
|
||||
v-model="model"
|
||||
:options="options"
|
||||
:filterable="false"
|
||||
label="name"
|
||||
:clearable="true"
|
||||
:reduce="option => option.name"
|
||||
:components="{ Deselect, OpenIndicator }"
|
||||
:dropdownShouldOpen="dropdownShouldOpen"
|
||||
@open="onOpen"
|
||||
@close="onClose"
|
||||
@search="onSearch"
|
||||
@option:selecting="handleSelecting"
|
||||
>
|
||||
<template #option="{ id, name, depth, current }">
|
||||
<span class="option" :class="{ 'option--current': current }">
|
||||
<span class="option__text">
|
||||
<span v-if="depth > 0" class="option__depth">{{ getSpaceByDepth(depth) }}</span>
|
||||
<span class="option__label" :title="ucfirst(name)">{{ ucfirst(name) }}</span>
|
||||
<span @click.stop>
|
||||
<router-link
|
||||
class="option__link"
|
||||
:to="{ name: 'ProjectGroups.crud.groups.edit', params: { id: id } }"
|
||||
target="_blank"
|
||||
rel="opener"
|
||||
>
|
||||
<i class="icon icon-external-link" />
|
||||
</router-link>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #no-options="{ search }">
|
||||
<span>{{ $t('field.no_groups_found', { query: search }) }}</span>
|
||||
</template>
|
||||
|
||||
<template #list-footer="{ search }">
|
||||
<at-button v-show="query !== ''" type="primary" class="no-option" size="small" @click="createGroup">
|
||||
<span class="icon icon-plus-circle"></span>
|
||||
{{ $t('field.fast_create_group', { query: search }) }}
|
||||
</at-button>
|
||||
|
||||
<at-button type="primary" class="no-option" size="small" @click="navigateToCreateGroup">
|
||||
<span class="icon icon-plus-circle"></span>
|
||||
{{ $t('field.to_create_group', { query: search }) }}
|
||||
</at-button>
|
||||
|
||||
<li v-show="hasNextPage" ref="load" class="option__infinite-loader">
|
||||
{{ $t('field.loading_groups') }} <i class="icon icon-loader"></i>
|
||||
</li>
|
||||
</template>
|
||||
</v-select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ProjectGroupsService from '@/services/resource/project-groups.service';
|
||||
import { ucfirst } from '@/utils/string';
|
||||
import { mapGetters } from 'vuex';
|
||||
import vSelect from 'vue-select';
|
||||
import debounce from 'lodash/debounce';
|
||||
|
||||
const service = new ProjectGroupsService();
|
||||
|
||||
export default {
|
||||
name: 'GroupSelect',
|
||||
components: {
|
||||
vSelect,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: [Object],
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isSelectOpen: false,
|
||||
totalPages: 0,
|
||||
currentPage: 0,
|
||||
query: '',
|
||||
lastSearchQuery: '',
|
||||
Deselect: { render: h => h('i', { class: 'icon icon-x' }) },
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('projectGroups', ['groups']),
|
||||
model: {
|
||||
get() {
|
||||
return this.value;
|
||||
},
|
||||
set(option) {
|
||||
if (typeof option === 'object') {
|
||||
this.$emit('input', option);
|
||||
}
|
||||
},
|
||||
},
|
||||
options() {
|
||||
if (!this.groups.has(this.lastSearchQuery)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.groups.get(this.lastSearchQuery).map(({ id, name, depth }) => ({
|
||||
id,
|
||||
name,
|
||||
depth,
|
||||
current: id === this.value?.id,
|
||||
}));
|
||||
},
|
||||
hasNextPage() {
|
||||
return this.currentPage < this.totalPages;
|
||||
},
|
||||
OpenIndicator() {
|
||||
return {
|
||||
render: h =>
|
||||
h('i', {
|
||||
class: {
|
||||
icon: true,
|
||||
'icon-chevron-down': !this.isSelectOpen,
|
||||
'icon-chevron-up': this.isSelectOpen,
|
||||
},
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.search = debounce(this.search, 350);
|
||||
},
|
||||
mounted() {
|
||||
this.observer = new IntersectionObserver(this.infiniteScroll);
|
||||
document.addEventListener('click', this.onClickOutside);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.removeEventListener('click', this.onClickOutside);
|
||||
},
|
||||
methods: {
|
||||
ucfirst,
|
||||
navigateToCreateGroup() {
|
||||
this.$router.push({ name: 'ProjectGroups.crud.groups.new' });
|
||||
},
|
||||
dropdownShouldOpen() {
|
||||
if (this.isSelectOpen) {
|
||||
this.onSearch(this.query);
|
||||
}
|
||||
|
||||
return this.isSelectOpen;
|
||||
},
|
||||
async createGroup() {
|
||||
const query = this.query;
|
||||
this.query = '';
|
||||
this.onClose();
|
||||
|
||||
try {
|
||||
const { data } = await service.save({ name: query }, true);
|
||||
this.model = data.data;
|
||||
|
||||
document.activeElement.blur();
|
||||
} catch (e) {
|
||||
// TODO
|
||||
}
|
||||
},
|
||||
getSpaceByDepth: function (depth) {
|
||||
return ''.padStart(depth, '-');
|
||||
},
|
||||
onActive() {
|
||||
this.onOpen();
|
||||
this.$refs.groupSelect.parentElement.style.zIndex = 1;
|
||||
},
|
||||
async onOpen() {
|
||||
this.isSelectOpen = true;
|
||||
await this.$nextTick();
|
||||
this.observe();
|
||||
},
|
||||
onClose() {
|
||||
this.$refs.groupSelect.parentElement.style.zIndex = 0;
|
||||
this.isSelectOpen = false;
|
||||
this.observer.disconnect();
|
||||
},
|
||||
onSearch(query) {
|
||||
this.query = query;
|
||||
this.search.cancel();
|
||||
this.search();
|
||||
},
|
||||
async search() {
|
||||
this.observer.disconnect();
|
||||
|
||||
this.totalPages = 0;
|
||||
this.currentPage = 0;
|
||||
this.lastSearchQuery = this.query;
|
||||
|
||||
await this.$nextTick();
|
||||
await this.loadOptions();
|
||||
await this.$nextTick();
|
||||
|
||||
this.observe();
|
||||
},
|
||||
handleSelecting(option) {
|
||||
this.onClose();
|
||||
this.model = option;
|
||||
},
|
||||
async infiniteScroll([{ isIntersecting, target }]) {
|
||||
if (isIntersecting) {
|
||||
const ul = target.offsetParent;
|
||||
const scrollTop = target.offsetParent.scrollTop;
|
||||
|
||||
await this.loadOptions();
|
||||
await this.$nextTick();
|
||||
|
||||
ul.scrollTop = scrollTop;
|
||||
|
||||
this.observer.disconnect();
|
||||
this.observe();
|
||||
}
|
||||
},
|
||||
observe() {
|
||||
if (this.isSelectOpen && this.$refs.load) {
|
||||
this.observer.observe(this.$refs.load);
|
||||
}
|
||||
},
|
||||
async loadOptions() {
|
||||
this.$store.dispatch('projectGroups/loadGroups', {
|
||||
query: this.lastSearchQuery,
|
||||
page: this.currentPage,
|
||||
});
|
||||
|
||||
this.currentPage++;
|
||||
},
|
||||
onClickOutside(e) {
|
||||
const opened = this.$el.contains(e.target);
|
||||
if (!opened) {
|
||||
this.onClose();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.group-select {
|
||||
border-radius: 5px;
|
||||
width: 100%;
|
||||
|
||||
&::v-deep {
|
||||
.v-select {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vs__selected-options {
|
||||
display: block;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.vs__selected,
|
||||
.vs__search {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.vs--open .vs__search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vs__actions {
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
margin-right: 8px;
|
||||
width: 30px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.vs__clear {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.vs__open-indicator {
|
||||
transform: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.vs__no-options {
|
||||
padding: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.vs__dropdown-menu {
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.option {
|
||||
&--current {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&__depth {
|
||||
padding-right: 0.3em;
|
||||
letter-spacing: 0.1em;
|
||||
opacity: 0.3;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
&__infinite-loader {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
column-gap: 0.3rem;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__label {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
&__link {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.no-option {
|
||||
margin-top: 10px;
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
& div {
|
||||
word-break: break-all;
|
||||
white-space: initial;
|
||||
line-height: 20px;
|
||||
|
||||
&::before {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
35
resources/frontend/core/components/LanguageSelector.vue
Normal file
35
resources/frontend/core/components/LanguageSelector.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<at-select v-if="Object.keys(languages).length > 0" :value="value" @on-change="inputHandler($event)">
|
||||
<at-option v-for="(lang, index) in languages" :key="index" :value="lang.value">
|
||||
{{ lang.label }}
|
||||
</at-option>
|
||||
</at-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: [Number, String],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('lang', ['langList']),
|
||||
|
||||
languages() {
|
||||
return Object.keys(this.langList).map(p => ({
|
||||
value: p,
|
||||
label: this.langList[p],
|
||||
}));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
inputHandler(ev) {
|
||||
this.$emit('setLanguage', ev);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
65
resources/frontend/core/components/ListBox.vue
Normal file
65
resources/frontend/core/components/ListBox.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<ul class="listbox">
|
||||
<li v-for="(value, index) of values" :key="value[keyField]" class="listbox__item">
|
||||
<at-checkbox :checked="value[valueField]" @on-change="onChange(index, $event)">
|
||||
{{ value[labelField] }}
|
||||
</at-checkbox>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
model: {
|
||||
prop: 'values',
|
||||
event: 'change',
|
||||
},
|
||||
|
||||
props: {
|
||||
values: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
keyField: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
labelField: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
valueField: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onChange(index, value) {
|
||||
const values = [...this.values];
|
||||
values[index][this.valueField] = value;
|
||||
this.$emit('change', values);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.listbox {
|
||||
border: 1px solid #c5d9e8;
|
||||
border-radius: 4px;
|
||||
transition: border 0.2s;
|
||||
|
||||
margin-bottom: 0.75em;
|
||||
padding: 8px 12px;
|
||||
|
||||
min-height: 40px;
|
||||
max-height: 200px;
|
||||
|
||||
overflow-y: auto;
|
||||
|
||||
&:hover {
|
||||
border-color: #79a1eb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
259
resources/frontend/core/components/MultiSelect.vue
Normal file
259
resources/frontend/core/components/MultiSelect.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div class="at-select-wrapper">
|
||||
<at-select
|
||||
ref="select"
|
||||
v-model="model"
|
||||
multiple
|
||||
filterable
|
||||
placeholder=""
|
||||
:size="size"
|
||||
@click="onClick"
|
||||
@input="onChange"
|
||||
>
|
||||
<li v-if="showSelectAll" class="at-select__option" @click="selectAll()">
|
||||
{{ allOptionsSelected ? $t('control.deselect_all') : $t('control.select_all') }}
|
||||
</li>
|
||||
<slot name="before-options"></slot>
|
||||
<at-option
|
||||
v-for="option of options"
|
||||
:key="option.id"
|
||||
:value="option.id"
|
||||
:label="option.name"
|
||||
@on-select-close="onClose"
|
||||
>
|
||||
</at-option>
|
||||
</at-select>
|
||||
<span v-if="showCount" class="at-select__placeholder">
|
||||
{{ placeholderText }}
|
||||
</span>
|
||||
<i v-if="model.length > 0" class="icon icon-x at-select__clear" @click.stop="clearSelect"></i>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ResourceService from '../services/resource.service';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
service: {
|
||||
type: ResourceService,
|
||||
},
|
||||
selected: {
|
||||
type: [String, Number, Array, Object],
|
||||
default: Array,
|
||||
},
|
||||
inputHandler: {
|
||||
type: Function,
|
||||
},
|
||||
prependName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showSelectAll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'normal',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
model: [],
|
||||
showCount: true,
|
||||
options: [],
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
try {
|
||||
const all = await this.service.getAll({ headers: { 'X-Paginate': 'false' } });
|
||||
this.options.push(...all);
|
||||
this.$emit('onOptionsLoad', this.options);
|
||||
} catch ({ response }) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn(response ? response : 'request to projects is canceled');
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(this.selected)) {
|
||||
this.model = this.selected;
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.model.forEach(modelValue => {
|
||||
if (this.$refs.select && Object.prototype.hasOwnProperty.call(this.$refs.select, '$children')) {
|
||||
this.$refs.select.$children.forEach(option => {
|
||||
if (option.value === modelValue) {
|
||||
option.selected = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.lastQuery = '';
|
||||
this.$watch(
|
||||
() => {
|
||||
if (this.$refs.select === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
query: this.$refs.select.query,
|
||||
visible: this.$refs.select.visible,
|
||||
};
|
||||
},
|
||||
({ query, visible }) => {
|
||||
if (visible) {
|
||||
if (query.length) {
|
||||
this.lastQuery = query;
|
||||
} else {
|
||||
if (
|
||||
['input', 'keypress'].includes(window?.event?.type) ||
|
||||
window?.event?.key === 'Backspace'
|
||||
) {
|
||||
// If query changed by user typing, save query
|
||||
this.lastQuery = query;
|
||||
} else {
|
||||
// If query changed by clicking option and so on, restore query
|
||||
this.$refs.select.query = this.lastQuery;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.lastQuery = query;
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
watch: {
|
||||
model(value) {
|
||||
if (this.inputHandler) {
|
||||
this.inputHandler(value);
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
selectAll(predicate = () => true) {
|
||||
if (this.allOptionsSelected) {
|
||||
this.model = [];
|
||||
} else {
|
||||
// console.log(this.$refs.select);
|
||||
const query = this.$refs.select.query.toUpperCase();
|
||||
this.model = this.options
|
||||
.filter(({ name }) => name.toUpperCase().indexOf(query) !== -1)
|
||||
.filter(predicate)
|
||||
.map(({ id }) => id);
|
||||
}
|
||||
},
|
||||
clearSelect() {
|
||||
this.$emit('input', []);
|
||||
this.model = [];
|
||||
},
|
||||
onClick() {
|
||||
if (this.showCount) {
|
||||
this.showCount = false;
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.showCount = true;
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
onClose() {
|
||||
this.$refs.select.query = '';
|
||||
|
||||
if (!this.showCount) {
|
||||
setTimeout(() => {
|
||||
this.showCount = true;
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
onChange(val) {
|
||||
if (this.inputHandler) {
|
||||
this.inputHandler(val);
|
||||
}
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
selectionAmount() {
|
||||
return this.model.length;
|
||||
},
|
||||
allOptionsSelected() {
|
||||
return this.options.length > 0 && this.options.length === this.selectionAmount;
|
||||
},
|
||||
placeholderText() {
|
||||
const i18nKey = this.placeholder + (this.allOptionsSelected ? '_all' : '');
|
||||
return this.$tc(i18nKey, this.selectionAmount, {
|
||||
count: this.selectionAmount,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.at-select-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.at-select {
|
||||
min-width: 240px;
|
||||
|
||||
&__placeholder {
|
||||
left: 0;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
&--small ~ &__placeholder {
|
||||
font-size: 11px;
|
||||
padding: 5px 24px 0 8px;
|
||||
}
|
||||
|
||||
&__clear {
|
||||
margin-right: $spacing-05;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.at-select {
|
||||
&__placeholder {
|
||||
color: #3f536d;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
&__input {
|
||||
height: 100%;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&__selection {
|
||||
border-radius: 5px;
|
||||
color: black;
|
||||
}
|
||||
|
||||
&--visible + .at-select__placeholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&__clear {
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
z-index: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.at-tag {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
216
resources/frontend/core/components/Navigation.vue
Normal file
216
resources/frontend/core/components/Navigation.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template>
|
||||
<at-menu class="navbar container-fluid" router mode="horizontal">
|
||||
<router-link to="/" class="navbar__logo"></router-link>
|
||||
<div v-if="loggedIn">
|
||||
<template v-for="(item, key) in navItems">
|
||||
<at-submenu v-if="item.type === 'dropdown'" :key="item.label" :title="$t(item.label)">
|
||||
<template slot="title">{{ $t(item.label) }}</template>
|
||||
<template v-for="(child, childKey) in item.children">
|
||||
<navigation-menu-item
|
||||
:key="childKey"
|
||||
:to="child.to || undefined"
|
||||
@click="child.click || undefined"
|
||||
>
|
||||
{{ $t(child.label) }}
|
||||
</navigation-menu-item>
|
||||
</template>
|
||||
</at-submenu>
|
||||
|
||||
<navigation-menu-item v-else :key="key" :to="item.to || undefined" @click="item.click || undefined">
|
||||
{{ $t(item.label) }}
|
||||
</navigation-menu-item>
|
||||
</template>
|
||||
</div>
|
||||
<at-dropdown v-if="loggedIn" placement="bottom-right" @on-dropdown-command="userDropdownHandle">
|
||||
<i class="icon icon-chevron-down at-menu__submenu-icon"></i>
|
||||
<user-avatar :border-radius="10" :user="user"></user-avatar>
|
||||
<at-dropdown-menu slot="menu">
|
||||
<template v-for="(item, key) of userDropdownItems">
|
||||
<at-dropdown-item :key="key" :name="item.to.name">
|
||||
<span><i class="icon" :class="[item.icon]"></i>{{ item.title }}</span>
|
||||
</at-dropdown-item>
|
||||
</template>
|
||||
<li class="at-dropdown-menu__item" @click="logout()">
|
||||
<i class="icon icon-log-out"></i> {{ $t('navigation.logout') }}
|
||||
</li>
|
||||
</at-dropdown-menu>
|
||||
</at-dropdown>
|
||||
</at-menu>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NavigationMenuItem from '@/components/NavigationMenuItem';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { getModuleList } from '@/moduleLoader';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
UserAvatar,
|
||||
NavigationMenuItem,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
modules: Object.values(getModuleList()).map(i => i.moduleInstance),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
userDropdownHandle(route) {
|
||||
this.$router.push({ name: route });
|
||||
},
|
||||
async logout() {
|
||||
await this.$store.getters['user/apiService'].logout();
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
navItems() {
|
||||
const navItems = [];
|
||||
const dropdowns = {};
|
||||
this.modules.forEach(m => {
|
||||
const entries = m.getNavbarEntries();
|
||||
entries.forEach(e => {
|
||||
if (e.displayCondition(this.$store)) {
|
||||
navItems.push(e.getData());
|
||||
}
|
||||
});
|
||||
|
||||
const entriesDropdown = m.getNavbarEntriesDropdown();
|
||||
Object.keys(entriesDropdown).forEach(section => {
|
||||
let entry = dropdowns[section];
|
||||
if (typeof entry === 'undefined') {
|
||||
entry = dropdowns[section] = {
|
||||
type: 'dropdown',
|
||||
label: section,
|
||||
children: [],
|
||||
};
|
||||
|
||||
navItems.push(entry);
|
||||
}
|
||||
|
||||
entriesDropdown[section].forEach(e => {
|
||||
if (e.displayCondition(this.$store)) {
|
||||
entry.children.push(e.getData());
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return navItems;
|
||||
},
|
||||
...mapGetters('user', ['user']),
|
||||
userDropdownItems() {
|
||||
const items = [
|
||||
{
|
||||
name: 'about',
|
||||
to: {
|
||||
name: 'about',
|
||||
},
|
||||
title: this.$t('navigation.about'),
|
||||
icon: 'icon-info',
|
||||
},
|
||||
// {
|
||||
// name: 'desktop-login',
|
||||
// to: {
|
||||
// name: 'desktop-login',
|
||||
// },
|
||||
// title: this.$t('navigation.client-login'),
|
||||
// icon: 'icon-log-in',
|
||||
// },
|
||||
];
|
||||
this.modules.forEach(m => {
|
||||
const entriesDropdown = m.getNavbarMenuEntriesDropDown();
|
||||
Object.keys(entriesDropdown).forEach(el => {
|
||||
const { displayCondition, label, to, click, icon } = entriesDropdown[el];
|
||||
if (displayCondition(this.$store)) {
|
||||
items.push({
|
||||
to,
|
||||
icon,
|
||||
click,
|
||||
title: this.$t(label),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return items;
|
||||
},
|
||||
rules() {
|
||||
return this.$store.getters['user/allowedRules'];
|
||||
},
|
||||
loggedIn() {
|
||||
return this.$store.getters['user/loggedIn'];
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.navbar {
|
||||
border-bottom: 0;
|
||||
box-shadow: 0px 0px 10px rgba(63, 51, 86, 0.1);
|
||||
display: flex;
|
||||
height: auto;
|
||||
justify-content: space-between;
|
||||
padding: 0.75em 24px;
|
||||
|
||||
&__logo {
|
||||
background: url('../assets/logo.svg');
|
||||
background-size: cover;
|
||||
height: 45px;
|
||||
width: 45px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&::v-deep {
|
||||
.at-menu {
|
||||
&__item-link {
|
||||
&::after {
|
||||
bottom: -0.75em;
|
||||
height: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.at-menu__submenu-title {
|
||||
padding-right: 0 !important;
|
||||
}
|
||||
|
||||
.at-dropdown {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
&-menu {
|
||||
overflow: hidden;
|
||||
|
||||
&__item {
|
||||
color: $gray-3;
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background-color: #fff;
|
||||
color: $blue-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__trigger {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
|
||||
.icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
&__popover {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.at-dropdown-menu__item .icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
66
resources/frontend/core/components/NavigationMenuItem.vue
Normal file
66
resources/frontend/core/components/NavigationMenuItem.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<li
|
||||
class="at-menu__item"
|
||||
:class="[this.active ? 'at-menu__item--active' : '', this.disabled ? 'at-menu__item--disabled' : '']"
|
||||
>
|
||||
<router-link v-if="Object.keys(to).length" ref="link" class="at-menu__item-link" :to="to">
|
||||
<slot></slot>
|
||||
</router-link>
|
||||
<div v-else class="at-menu__item-link">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { findComponentsUpward } from '@cattr/ui-kit/src/utils/util';
|
||||
|
||||
export default {
|
||||
name: 'NavigationMenuItem',
|
||||
props: {
|
||||
name: {
|
||||
type: [String, Number],
|
||||
},
|
||||
to: {
|
||||
type: [Object, String],
|
||||
default() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
replace: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
active: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$on('on-update-active', name => {
|
||||
this.$nextTick(() => {
|
||||
if (
|
||||
this.name === name ||
|
||||
(this.$refs.link && this.$refs.link.$el.classList.contains('router-link-active'))
|
||||
) {
|
||||
this.active = true;
|
||||
|
||||
const parents = findComponentsUpward(this, 'AtSubmenu');
|
||||
if (parents && parents.length) {
|
||||
parents.forEach(parent => {
|
||||
parent.$emit('on-update-active', true);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.active = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
||||
109
resources/frontend/core/components/Preloader.vue
Normal file
109
resources/frontend/core/components/Preloader.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="loader" :class="{ 'loader--transparent': isTransparent }">
|
||||
<div class="lds-ellipsis">
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Preloader',
|
||||
props: {
|
||||
isTransparent: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.loader {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 99;
|
||||
transition: all 1s ease-out;
|
||||
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
|
||||
&--transparent {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.lds-ellipsis {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.lds-ellipsis div {
|
||||
position: absolute;
|
||||
top: 33px;
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
border-radius: 50%;
|
||||
background: $brand-blue-800;
|
||||
animation-timing-function: cubic-bezier(0, 1, 1, 0);
|
||||
}
|
||||
|
||||
.lds-ellipsis div:nth-child(1) {
|
||||
left: 8px;
|
||||
animation: lds-ellipsis1 0.6s infinite;
|
||||
}
|
||||
|
||||
.lds-ellipsis div:nth-child(2) {
|
||||
left: 8px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
.lds-ellipsis div:nth-child(3) {
|
||||
left: 32px;
|
||||
animation: lds-ellipsis2 0.6s infinite;
|
||||
}
|
||||
|
||||
.lds-ellipsis div:nth-child(4) {
|
||||
left: 56px;
|
||||
animation: lds-ellipsis3 0.6s infinite;
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis1 {
|
||||
0% {
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis3 {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes lds-ellipsis2 {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
100% {
|
||||
transform: translate(24px, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
123
resources/frontend/core/components/PrioritySelect.vue
Normal file
123
resources/frontend/core/components/PrioritySelect.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="priority-select" :style="{ background: color, color: getTextColor(color) }">
|
||||
<at-select
|
||||
v-if="options.length"
|
||||
ref="select"
|
||||
v-model="model"
|
||||
:placeholder="$t('control.select')"
|
||||
filterable
|
||||
clearable="clearable"
|
||||
>
|
||||
<at-option
|
||||
v-for="option of options"
|
||||
:key="option.value"
|
||||
:label="ucfirst(option.label)"
|
||||
:value="option.value"
|
||||
>
|
||||
<span class="option" :style="{ background: option.color, color: getTextColor(option.color) }">
|
||||
<span class="option-text">
|
||||
{{ ucfirst(option.label) }}
|
||||
</span>
|
||||
</span>
|
||||
</at-option>
|
||||
</at-select>
|
||||
<at-input v-else disabled></at-input>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getTextColor } from '@/utils/color';
|
||||
import { ucfirst } from '@/utils/string';
|
||||
import PriorityService from '@/services/resource/priority.service';
|
||||
|
||||
export default {
|
||||
name: 'PrioritySelect',
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
try {
|
||||
this.options = await this.service.getAll().then(data => {
|
||||
return data.map(option => {
|
||||
return {
|
||||
value: option.id,
|
||||
label: option['name'],
|
||||
color: option.color,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
await this.$nextTick();
|
||||
|
||||
if (this.$refs.select && Object.prototype.hasOwnProperty.call(this.$refs.select, '$children')) {
|
||||
this.$refs.select.$children.forEach(option => {
|
||||
option.hidden = false;
|
||||
});
|
||||
}
|
||||
} catch ({ response }) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn(response ? response : 'request to resource is canceled');
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
options: [],
|
||||
service: new PriorityService(),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
ucfirst,
|
||||
getTextColor,
|
||||
},
|
||||
computed: {
|
||||
model: {
|
||||
get() {
|
||||
return this.value;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('input', value);
|
||||
},
|
||||
},
|
||||
color() {
|
||||
const option = this.options.find(option => +option.value === +this.value);
|
||||
if (typeof option === 'undefined') {
|
||||
return 'transparent';
|
||||
}
|
||||
|
||||
return option.color;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.priority-select {
|
||||
border-radius: 5px;
|
||||
|
||||
&::v-deep .at-select__selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::v-deep .at-select__dropdown .at-select__option {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&::v-deep .at-select {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
</style>
|
||||
78
resources/frontend/core/components/ProjectSelect.vue
Normal file
78
resources/frontend/core/components/ProjectSelect.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<multi-select
|
||||
placeholder="control.project_selected"
|
||||
:inputHandler="selectedProjects"
|
||||
:selected="selectedProjectIds"
|
||||
:service="projectService"
|
||||
name="projects"
|
||||
:size="size"
|
||||
@onOptionsLoad="onLoad"
|
||||
>
|
||||
</multi-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MultiSelect from '@/components/MultiSelect';
|
||||
import ProjectService from '@/services/resource/project.service';
|
||||
|
||||
const localStorageKey = 'amazingcat.local.storage.project_select';
|
||||
|
||||
export default {
|
||||
name: 'ProjectSelect',
|
||||
components: {
|
||||
MultiSelect,
|
||||
},
|
||||
props: {
|
||||
size: {
|
||||
type: String,
|
||||
default: 'normal',
|
||||
},
|
||||
value: {
|
||||
type: Array,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const selectedProjectIds =
|
||||
this.value !== null ? this.value : JSON.parse(localStorage.getItem(localStorageKey));
|
||||
|
||||
return {
|
||||
projectService: new ProjectService(),
|
||||
selectedProjectIds,
|
||||
ids: [],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onLoad(allSelectOptions) {
|
||||
const allProjectIds = allSelectOptions.map(option => option.id);
|
||||
this.ids = allProjectIds;
|
||||
// Select all options if storage is empty
|
||||
if (!localStorage.getItem(localStorageKey)) {
|
||||
this.selectedProjectIds = allProjectIds;
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(this.selectedProjectIds));
|
||||
this.$emit('change', this.selectedProjectIds);
|
||||
this.$nextTick(() => this.$emit('loaded'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove options that no longer exists
|
||||
const existingProjectIds = this.selectedProjectIds.filter(projectId =>
|
||||
allProjectIds.includes(projectId),
|
||||
);
|
||||
|
||||
if (this.selectedProjectIds.length > existingProjectIds.length) {
|
||||
this.selectedProjectIds = existingProjectIds;
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(this.selectedProjectIds));
|
||||
}
|
||||
|
||||
this.$emit('change', this.selectedProjectIds);
|
||||
this.$nextTick(() => this.$emit('loaded'));
|
||||
},
|
||||
selectedProjects(values) {
|
||||
this.selectedProjectIds = values;
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(this.selectedProjectIds));
|
||||
this.$emit('change', values);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
62
resources/frontend/core/components/RenderableField.vue
Normal file
62
resources/frontend/core/components/RenderableField.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
export default {
|
||||
name: 'RenderableField',
|
||||
props: {
|
||||
render: {
|
||||
required: true,
|
||||
type: Function,
|
||||
},
|
||||
value: {
|
||||
default: Object,
|
||||
},
|
||||
field: {
|
||||
required: true,
|
||||
type: Object,
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
},
|
||||
setValue: {
|
||||
type: Function,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentValue: this.value,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
this.currentValue = val;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('user', ['companyData']),
|
||||
},
|
||||
methods: {
|
||||
inputHandler(val) {
|
||||
this.$emit('input', val);
|
||||
this.$emit('change', val);
|
||||
},
|
||||
focusHandler(evt) {
|
||||
this.$emit('focus', evt);
|
||||
},
|
||||
blurHandler(evt) {
|
||||
this.$emit('blur', evt);
|
||||
},
|
||||
},
|
||||
render(h) {
|
||||
return this.render(h, {
|
||||
inputHandler: this.inputHandler,
|
||||
currentValue: this.currentValue,
|
||||
focusHandler: this.focusHandler,
|
||||
blurHandler: this.blurHandler,
|
||||
field: this.field,
|
||||
values: this.values,
|
||||
setValue: this.setValue,
|
||||
companyData: this.companyData,
|
||||
});
|
||||
},
|
||||
};
|
||||
</script>
|
||||
91
resources/frontend/core/components/ResourceSelect.vue
Normal file
91
resources/frontend/core/components/ResourceSelect.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div>
|
||||
<at-select
|
||||
v-if="options.length"
|
||||
ref="select"
|
||||
v-model="model"
|
||||
:placeholder="$t('control.select')"
|
||||
filterable
|
||||
clearable="clearable"
|
||||
>
|
||||
<at-option v-for="option of options" :key="option.id" :label="formattedLabel(option)" :value="option.id" />
|
||||
</at-select>
|
||||
<at-input v-else disabled></at-input>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ucfirst } from '@/utils/string';
|
||||
|
||||
export default {
|
||||
name: 'ResourceSelect',
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
service: {
|
||||
type: Object,
|
||||
},
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
try {
|
||||
this.options = await this.service.getAll({ headers: { 'X-Paginate': 'false' } });
|
||||
|
||||
await this.$nextTick();
|
||||
|
||||
if (this.$refs.select && Object.prototype.hasOwnProperty.call(this.$refs.select, '$children')) {
|
||||
this.$refs.select.$children.forEach(option => {
|
||||
option.hidden = false;
|
||||
});
|
||||
}
|
||||
} catch ({ response }) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn(response ? response : 'request to resource is canceled');
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
options: [],
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
ucfirst,
|
||||
getName(object = {}) {
|
||||
const names = ['full_name'];
|
||||
let key = 'name';
|
||||
|
||||
if (typeof object === 'object') {
|
||||
let keys = Object.keys(object);
|
||||
|
||||
for (let i = 0; i <= names.length; i++) {
|
||||
if (keys.indexOf(names[i]) !== -1) {
|
||||
key = names[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return object[key] ?? '';
|
||||
}
|
||||
},
|
||||
formattedLabel(option) {
|
||||
const name = this.getName(option);
|
||||
return name ? this.ucfirst(name) : '';
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
model: {
|
||||
get() {
|
||||
return this.value;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit('input', value);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
73
resources/frontend/core/components/RoleSelect.vue
Normal file
73
resources/frontend/core/components/RoleSelect.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<at-select
|
||||
v-if="Object.keys(roles).length > 0"
|
||||
ref="select"
|
||||
class="role-select"
|
||||
:value="value"
|
||||
@on-change="inputHandler"
|
||||
>
|
||||
<at-option v-for="(role, name) in roles" :key="role" :value="role" :label="$t(`field.roles.${name}.name`)">
|
||||
<div>
|
||||
<slot :name="`role_${name}_name`">
|
||||
{{ $t(`field.roles.${name}.name`) }}
|
||||
</slot>
|
||||
</div>
|
||||
<div class="role-select__description">
|
||||
<slot :name="`role_${name}_description`">
|
||||
{{ $t(`field.roles.${name}.description`) }}
|
||||
</slot>
|
||||
</div>
|
||||
</at-option>
|
||||
</at-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters, mapActions } from 'vuex';
|
||||
import { ucfirst } from '@/utils/string';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Number,
|
||||
excludeRoles: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
roles() {
|
||||
return Object.keys(this.$store.getters['roles/roles'])
|
||||
.filter(key => !this.excludeRoles.includes(key))
|
||||
.reduce((acc, el) => Object.assign(acc, { [el]: this.$store.getters['roles/roles'][el] }), {});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
ucfirst,
|
||||
...mapActions({
|
||||
getRoles: 'roles/loadRoles',
|
||||
}),
|
||||
inputHandler(value) {
|
||||
this.$emit('input', value);
|
||||
this.$emit('updateProps', value);
|
||||
},
|
||||
},
|
||||
async created() {
|
||||
await this.getRoles();
|
||||
|
||||
if (this.$refs.select && Object.prototype.hasOwnProperty.call(this.$refs.select, '$children')) {
|
||||
this.$refs.select.$children.forEach(option => {
|
||||
option.hidden = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.role-select {
|
||||
&__description {
|
||||
white-space: normal;
|
||||
opacity: 0.6;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
234
resources/frontend/core/components/Screenshot.vue
Normal file
234
resources/frontend/core/components/Screenshot.vue
Normal file
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="screenshot" @click="$emit('click', $event)">
|
||||
<AppImage
|
||||
v-if="screenshotsEnabled"
|
||||
:is-blob="true"
|
||||
:src="getThumbnailPath(interval)"
|
||||
class="screenshot__image"
|
||||
:lazy="lazyImage"
|
||||
@click="onShow"
|
||||
/>
|
||||
<i v-else class="icon icon-camera-off screenshot__image" />
|
||||
<at-tooltip>
|
||||
<template slot="content">
|
||||
<div v-if="interval.activity_fill === null" class="screenshot__activity">
|
||||
{{ $t('tooltip.activity_progress.not_tracked') }}
|
||||
</div>
|
||||
<div v-else class="screenshot__activity">
|
||||
<span v-if="interval.activity_fill !== null" class="screenshot__overall-activity">
|
||||
{{
|
||||
$tc('tooltip.activity_progress.overall', interval.activity_fill, {
|
||||
percent: interval.activity_fill,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div class="screenshot__device-activity">
|
||||
<span v-if="interval.mouse_fill !== null">
|
||||
{{
|
||||
$tc('tooltip.activity_progress.mouse', interval.mouse_fill, {
|
||||
percent: interval.mouse_fill,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span v-if="interval.keyboard_fill !== null">{{
|
||||
$tc('tooltip.activity_progress.keyboard', interval.keyboard_fill, {
|
||||
percent: interval.keyboard_fill,
|
||||
})
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<at-progress
|
||||
class="screenshot__activity-bar"
|
||||
:stroke-width="5"
|
||||
:percent="+(+interval.activity_fill / 2 || 0)"
|
||||
/>
|
||||
</at-tooltip>
|
||||
|
||||
<div v-if="showText" class="screenshot__text">
|
||||
<span v-if="task && showTask" class="screenshot__task" :title="`${task.task_name} (${task.project.name})`">
|
||||
{{ task.task_name }} ({{ task.project.name }})
|
||||
</span>
|
||||
<span class="screenshot__time">{{ screenshotTime }}</span>
|
||||
</div>
|
||||
|
||||
<ScreenshotModal
|
||||
v-if="!disableModal"
|
||||
:project="project"
|
||||
:interval="interval"
|
||||
:show="showModal"
|
||||
:showNavigation="showNavigation"
|
||||
:task="task"
|
||||
:user="user"
|
||||
@close="onHide"
|
||||
@remove="onRemove"
|
||||
@showNext="$emit('showNext')"
|
||||
@showPrevious="$emit('showPrevious')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment-timezone';
|
||||
import AppImage from './AppImage';
|
||||
import ScreenshotModal from './ScreenshotModal';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export function thumbnailPathProvider(interval) {
|
||||
return `time-intervals/${interval.id}/thumb`;
|
||||
}
|
||||
|
||||
export const config = { thumbnailPathProvider };
|
||||
|
||||
export default {
|
||||
name: 'Screenshot',
|
||||
components: {
|
||||
AppImage,
|
||||
ScreenshotModal,
|
||||
},
|
||||
props: {
|
||||
interval: {
|
||||
type: Object,
|
||||
},
|
||||
project: {
|
||||
type: Object,
|
||||
},
|
||||
task: {
|
||||
type: Object,
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
},
|
||||
showText: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showTask: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showNavigation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
disableModal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
lazyImage: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
timezone: {
|
||||
type: String,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { showModal: false };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('user', ['companyData']),
|
||||
...mapGetters('screenshots', { screenshotsEnabled: 'enabled' }),
|
||||
screenshotTime() {
|
||||
const timezone = this.timezone || this.companyData['timezone'];
|
||||
|
||||
if (!timezone || !this.interval.start_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
return moment
|
||||
.utc(this.interval.start_at)
|
||||
.tz(this.companyData['timezone'], true)
|
||||
.tz(timezone)
|
||||
.format('HH:mm');
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onShow() {
|
||||
if (this.disableModal) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.showModal = true;
|
||||
this.$emit('showModalChange', true);
|
||||
},
|
||||
onHide() {
|
||||
this.showModal = false;
|
||||
this.$emit('showModalChange', false);
|
||||
},
|
||||
onRemove() {
|
||||
this.onHide();
|
||||
this.$emit('remove', this.interval);
|
||||
},
|
||||
getThumbnailPath(interval) {
|
||||
return config.thumbnailPathProvider(interval);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.screenshot {
|
||||
&__image {
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
line-height: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&::v-deep {
|
||||
.pu-skeleton {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
img {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 70px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__text {
|
||||
align-items: baseline;
|
||||
color: #59566e;
|
||||
display: flex;
|
||||
flex-flow: row nowrap;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__activity {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__device-activity {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__task {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&::v-deep {
|
||||
.at-tooltip {
|
||||
width: 100%;
|
||||
|
||||
&__trigger {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.at-progress__text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
308
resources/frontend/core/components/ScreenshotModal.vue
Normal file
308
resources/frontend/core/components/ScreenshotModal.vue
Normal file
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<at-modal v-if="show" class="modal" :width="900" :value="true" @on-cancel="onClose" @on-confirm="onClose">
|
||||
<template v-slot:header>
|
||||
<span class="modal-title">{{ $t('field.screenshot') }}</span>
|
||||
</template>
|
||||
|
||||
<AppImage
|
||||
v-if="interval && interval.id && screenshotsEnabled"
|
||||
class="modal-screenshot"
|
||||
:src="getScreenshotPath(interval)"
|
||||
:openable="true"
|
||||
/>
|
||||
<i v-else class="icon icon-camera-off modal-screenshot" />
|
||||
<at-progress
|
||||
class="screenshot__activity-bar"
|
||||
:stroke-width="7"
|
||||
:percent="+(+interval.activity_fill / 2 || 0)"
|
||||
/>
|
||||
|
||||
<div v-if="showNavigation" class="modal-left">
|
||||
<at-button type="primary" icon="icon-arrow-left" @click="$emit('showPrevious')"></at-button>
|
||||
</div>
|
||||
|
||||
<div v-if="showNavigation" class="modal-right">
|
||||
<at-button type="primary" icon="icon-arrow-right" @click="$emit('showNext')"></at-button>
|
||||
</div>
|
||||
|
||||
<template v-slot:footer>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div v-if="project" class="modal-field">
|
||||
<span class="modal-label">{{ $t('field.project') }}:</span>
|
||||
<span class="modal-value">
|
||||
<router-link :to="`/projects/view/${project.id}`">{{ project.name }}</router-link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="task" class="modal-field">
|
||||
<span class="modal-label">{{ $t('field.task') }}:</span>
|
||||
<span class="modal-value">
|
||||
<router-link :to="`/tasks/view/${task.id}`">{{ task.task_name }}</router-link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="user" class="modal-field">
|
||||
<span class="modal-label">{{ $t('field.user') }}:</span>
|
||||
<span class="modal-value">
|
||||
{{ user.full_name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="interval" class="modal-field">
|
||||
<span class="modal-label">{{ $t('field.created_at') }}:</span>
|
||||
<span class="modal-value">{{ formatDate(interval.start_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div v-if="interval.activity_fill === null" class="screenshot__activity">
|
||||
{{ $t('tooltip.activity_progress.not_tracked') }}
|
||||
</div>
|
||||
<div v-else class="screenshot__activity modal-field">
|
||||
<div class="modal-field">
|
||||
<span class="modal-label">{{ $tc('tooltip.activity_progress.overall', 0) }}</span>
|
||||
<span class="modal-value">
|
||||
{{ interval.activity_fill + '%' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="interval.mouse_fill !== null" class="modal-field">
|
||||
<span class="modal-label">
|
||||
{{ $t('tooltip.activity_progress.just_mouse') }}
|
||||
</span>
|
||||
|
||||
<span class="modal-value">
|
||||
{{ interval.mouse_fill + '%' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="interval.keyboard_fill !== null" class="modal-field">
|
||||
<span class="modal-label">{{ $t('tooltip.activity_progress.just_keyboard') }}</span>
|
||||
<span class="modal-value">
|
||||
{{ interval.keyboard_fill + '%' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="interval" class="modal-duration modal-field">
|
||||
<span class="modal-label">{{ $t('field.duration') }}:</span>
|
||||
<span class="modal-value">{{
|
||||
$t('field.duration_value', [formatDate(interval.start_at), formatDate(interval.end_at)])
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canRemove" class="row">
|
||||
<at-button class="modal-remove" type="text" icon="icon-trash-2" @click="onRemove" />
|
||||
</div>
|
||||
</template>
|
||||
</at-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment-timezone';
|
||||
import AppImage from './AppImage';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
export function screenshotPathProvider(interval) {
|
||||
return `time-intervals/${interval.id}/screenshot`;
|
||||
}
|
||||
|
||||
export const config = { screenshotPathProvider };
|
||||
|
||||
export default {
|
||||
name: 'ScreenshotModal',
|
||||
components: {
|
||||
AppImage,
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
project: {
|
||||
type: Object,
|
||||
},
|
||||
task: {
|
||||
type: Object,
|
||||
},
|
||||
interval: {
|
||||
type: Object,
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
},
|
||||
showNavigation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
canRemove: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
...mapGetters('user', ['companyData']),
|
||||
...mapGetters('screenshots', { screenshotsEnabled: 'enabled' }),
|
||||
},
|
||||
methods: {
|
||||
formatDate(value) {
|
||||
return moment
|
||||
.utc(value)
|
||||
.tz(this.companyData.timezone, true)
|
||||
.locale(this.$i18n.locale)
|
||||
.format('MMMM D, YYYY — HH:mm:ss (Z)');
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
onRemove() {
|
||||
this.$emit('remove', this.interval.id);
|
||||
},
|
||||
getScreenshotPath(interval) {
|
||||
return config.screenshotPathProvider(interval);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal {
|
||||
&::v-deep {
|
||||
.pu-skeleton {
|
||||
height: 70vh;
|
||||
}
|
||||
|
||||
.at-modal__mask {
|
||||
background: rgba(#151941, 0.7);
|
||||
}
|
||||
|
||||
.at-modal__wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
overflow-y: scroll;
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.at-modal {
|
||||
border-radius: 15px;
|
||||
top: unset;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.at-modal__header {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.at-modal__body {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
|
||||
.icon-camera-off {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
.at-modal__footer {
|
||||
position: relative;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.at-modal__close {
|
||||
color: #b1b1be;
|
||||
}
|
||||
|
||||
.at-progress-bar {
|
||||
display: block;
|
||||
&__wraper,
|
||||
&__inner {
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.at-progress__text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&-left {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-right {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&-title {
|
||||
color: #000000;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&-screenshot {
|
||||
display: block;
|
||||
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 300px;
|
||||
max-height: 70vh;
|
||||
|
||||
object-fit: contain;
|
||||
object-position: center;
|
||||
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
&-remove {
|
||||
position: absolute;
|
||||
|
||||
bottom: 12px;
|
||||
right: 16px;
|
||||
|
||||
color: #ff5569;
|
||||
}
|
||||
|
||||
&-field {
|
||||
color: #666;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
&-label {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
&-value,
|
||||
&-value a {
|
||||
color: #2e2ef9;
|
||||
}
|
||||
|
||||
&-duration {
|
||||
padding-right: 3em;
|
||||
}
|
||||
}
|
||||
@media (max-width: 500px) {
|
||||
.modal ::v-deep .at-modal__wrapper {
|
||||
align-items: start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div>
|
||||
<at-radio-group ref="select" v-model="model" class="screenshots-state-select">
|
||||
<at-radio-button
|
||||
v-for="(state, key) in states"
|
||||
:key="key"
|
||||
:label="key"
|
||||
:disabled="isDisabled"
|
||||
class="screenshots-state-select__btn"
|
||||
>
|
||||
<div>
|
||||
<slot :name="`state__name`">
|
||||
{{ $t(`control.screenshot_state_options.${state}`) }}
|
||||
</slot>
|
||||
</div>
|
||||
</at-radio-button>
|
||||
</at-radio-group>
|
||||
<div v-if="hint.length > 0" class="hint">{{ $t(hint) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: Number,
|
||||
default: () => 1,
|
||||
},
|
||||
isDisabled: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
required: false,
|
||||
},
|
||||
hideIndexes: {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => [],
|
||||
},
|
||||
hint: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: () => '',
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
inputHandler(value) {
|
||||
this.$emit('input', value);
|
||||
this.$emit('updateProps', value);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
model: {
|
||||
get() {
|
||||
return this.value;
|
||||
},
|
||||
set(value) {
|
||||
this.inputHandler(value);
|
||||
},
|
||||
},
|
||||
states() {
|
||||
let states = [];
|
||||
|
||||
Object.keys(this.$store.getters['screenshots/states']).forEach((item, i) => {
|
||||
if (!this.hideIndexes.includes(i)) {
|
||||
return states.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
return states;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.screenshots-state-select {
|
||||
&::v-deep {
|
||||
.at-radio--checked {
|
||||
.at-radio-button__inner {
|
||||
background-color: $blue-2;
|
||||
border-color: $blue-2;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
83
resources/frontend/core/components/StatusSelect.vue
Normal file
83
resources/frontend/core/components/StatusSelect.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<multi-select
|
||||
ref="select"
|
||||
placeholder="control.status_selected"
|
||||
:inputHandler="selectedStatuses"
|
||||
:selected="selectedStatusIds"
|
||||
:service="statusService"
|
||||
name="statuses"
|
||||
:size="size"
|
||||
@onOptionsLoad="onLoad"
|
||||
>
|
||||
<template v-slot:before-options>
|
||||
<li class="at-select__option" @click="selectAllOpen">
|
||||
{{ $t('control.select_all_open') }}
|
||||
</li>
|
||||
<li class="at-select__option" @click="selectAllClosed">
|
||||
{{ $t('control.select_all_closed') }}
|
||||
</li>
|
||||
</template>
|
||||
</multi-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MultiSelect from '@/components/MultiSelect';
|
||||
import StatusService from '@/services/resource/status.service';
|
||||
|
||||
const localStorageKey = 'amazingcat.local.storage.status_select';
|
||||
|
||||
export default {
|
||||
name: 'StatusSelect',
|
||||
components: {
|
||||
MultiSelect,
|
||||
},
|
||||
props: {
|
||||
size: {
|
||||
type: String,
|
||||
default: 'normal',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
statusService: new StatusService(),
|
||||
selectedStatusIds: JSON.parse(localStorage.getItem(localStorageKey)),
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onLoad(allSelectOptions) {
|
||||
const allStatusIds = allSelectOptions.map(option => option.id);
|
||||
|
||||
// Select all options if storage is empty
|
||||
if (!localStorage.getItem(localStorageKey)) {
|
||||
this.selectedStatusIds = allStatusIds;
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(this.selectedStatusIds));
|
||||
this.$emit('change', this.selectedStatusIds);
|
||||
this.$nextTick(() => this.$emit('loaded'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove options that no longer exists
|
||||
const existingStatusIds = this.selectedStatusIds.filter(statusId => allStatusIds.includes(statusId));
|
||||
|
||||
if (this.selectedStatusIds.length > existingStatusIds.length) {
|
||||
this.selectedStatusIds = existingStatusIds;
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(this.selectedStatusIds));
|
||||
}
|
||||
|
||||
this.$emit('change', this.selectedStatusIds);
|
||||
this.$nextTick(() => this.$emit('loaded'));
|
||||
},
|
||||
selectedStatuses(values) {
|
||||
this.selectedStatusIds = values;
|
||||
localStorage.setItem(localStorageKey, JSON.stringify(this.selectedStatusIds));
|
||||
this.$emit('change', values);
|
||||
},
|
||||
selectAllOpen() {
|
||||
this.$refs.select.selectAll(item => item.active);
|
||||
},
|
||||
selectAllClosed() {
|
||||
this.$refs.select.selectAll(item => !item.active);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
166
resources/frontend/core/components/StorageManagementTab.vue
Normal file
166
resources/frontend/core/components/StorageManagementTab.vue
Normal file
@@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div class="col-offset-9">
|
||||
<template v-if="storage">
|
||||
<p class="row">
|
||||
<span v-t="'about.storage.space.used'" class="col-6" />
|
||||
<at-progress
|
||||
:percent="storageSpaceUsed"
|
||||
:status="storageSpaceUsed < storageSpaceMaxUsed ? 'error' : 'default'"
|
||||
:stroke-width="15"
|
||||
:title="getSize(storage.space.used)"
|
||||
class="col-4"
|
||||
/>
|
||||
<span class="col-1" v-html="`${storageSpaceUsed}%`" />
|
||||
</p>
|
||||
<p class="row">
|
||||
<span v-t="'about.storage.space.total'" class="col-6" />
|
||||
<span class="col-4">
|
||||
<at-tag>{{ getSize(storage.space.total) }}</at-tag>
|
||||
</span>
|
||||
</p>
|
||||
<p class="row">
|
||||
<span v-t="'about.storage.space.left'" class="col-6" />
|
||||
<span class="col-4">
|
||||
<at-tag>{{ getSize(storage.space.left) }}</at-tag>
|
||||
</span>
|
||||
</p>
|
||||
<p class="row">
|
||||
<span v-t="'about.storage.last_thinning'" class="col-6" />
|
||||
<span class="col-4">
|
||||
<at-tag :title="storageCleanTime">{{ storageRelativeCleanTime }}</at-tag>
|
||||
</span>
|
||||
</p>
|
||||
<p class="row">
|
||||
<span v-t="'about.storage.screenshots_available'" class="col-6" />
|
||||
<span class="col-4">
|
||||
<at-tag>{{
|
||||
$tc('about.storage.screenshots', storage.screenshots_available, {
|
||||
n: storage.screenshots_available,
|
||||
})
|
||||
}}</at-tag>
|
||||
</span>
|
||||
</p>
|
||||
<p class="row">
|
||||
<at-button
|
||||
:disabled="thinRequested || !storage.screenshots_available || storage.thinning.now"
|
||||
:loading="thinRequested"
|
||||
:title="cleanButtonTitle"
|
||||
class="col-10"
|
||||
hollow
|
||||
@click="cleanStorage"
|
||||
>
|
||||
{{ thinRequested ? '' : $t('about.storage.thin') }}
|
||||
</at-button>
|
||||
</p>
|
||||
</template>
|
||||
<p v-else v-t="'about.no_storage'" />
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import moment from 'moment';
|
||||
import AboutService from '@/services/resource/about.service';
|
||||
|
||||
const aboutService = new AboutService();
|
||||
|
||||
export default {
|
||||
name: 'StorageManagementTab',
|
||||
data: () => ({
|
||||
storageSpaceMaxUsed: process.env.VUE_APP_STORAGE_SPACE_MAX_USED,
|
||||
storage: null,
|
||||
thinRequested: false,
|
||||
}),
|
||||
computed: {
|
||||
storageSpaceUsed() {
|
||||
return Math.round((this.storage.space.used * 100) / this.storage.space.total);
|
||||
},
|
||||
storageRelativeCleanTime() {
|
||||
return moment(this.storage.thinning.last).fromNow();
|
||||
},
|
||||
storageCleanTime() {
|
||||
return moment(this.storage.thinning.last).format('LLL');
|
||||
},
|
||||
cleanButtonTitle() {
|
||||
return this.$t(
|
||||
!this.storage.screenshots_available || this.storage.thinning.now
|
||||
? 'about.storage.thin_unavailable'
|
||||
: 'about.storage.thin_available',
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
async cleanStorage() {
|
||||
this.thinRequested = true;
|
||||
|
||||
try {
|
||||
const { status } = await aboutService.startCleanup();
|
||||
|
||||
if (status === 204) {
|
||||
this.$Message.success('Thin has been queued!');
|
||||
|
||||
this.storage.thinning.now = true;
|
||||
} else {
|
||||
this.$Message.error('Error happened during thin queueing!');
|
||||
}
|
||||
} catch (e) {
|
||||
this.$Message.error('Error happened during thin queueing!');
|
||||
} finally {
|
||||
this.thinRequested = false;
|
||||
}
|
||||
},
|
||||
getSize(value) {
|
||||
if (value < 1024) {
|
||||
return `${value} B`;
|
||||
}
|
||||
|
||||
const KB = value / 1024;
|
||||
|
||||
if (KB < 1024) {
|
||||
return `${Math.round(KB)} KB`;
|
||||
}
|
||||
|
||||
const MB = KB / 1024;
|
||||
|
||||
if (MB < 1024) {
|
||||
return `${Math.round(MB)} MB`;
|
||||
}
|
||||
|
||||
const GB = MB / 1024;
|
||||
|
||||
if (GB < 1024) {
|
||||
return `${Math.round(GB)} GB`;
|
||||
}
|
||||
|
||||
return `${Math.round(GB / 1024)} TB`;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
this.storage = await aboutService.getStorageInfo();
|
||||
} catch ({ response }) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn(response ? response : 'request to storage is canceled');
|
||||
}
|
||||
}
|
||||
|
||||
this.isLoading = false;
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.storage {
|
||||
.at-progress {
|
||||
position: relative;
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
& > div {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.at-btn {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
80
resources/frontend/core/components/TeamAvatars.vue
Normal file
80
resources/frontend/core/components/TeamAvatars.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<div class="team-avatars">
|
||||
<div class="team-avatars__preview">
|
||||
<at-tooltip v-for="user of users.slice(0, 2)" :key="user.id" placement="top" :content="user.full_name">
|
||||
<user-avatar :user="user" class="team-avatars__avatar"></user-avatar>
|
||||
</at-tooltip>
|
||||
<at-popover placement="top" trigger="click">
|
||||
<div v-if="users.length > 2" class="team-avatars__placeholder team-avatars__avatar">
|
||||
<span>+{{ users.slice(2).length }}</span>
|
||||
</div>
|
||||
<template slot="content">
|
||||
<div class="tooltip__avatars">
|
||||
<at-tooltip
|
||||
v-for="user of users.slice(2)"
|
||||
:key="user.id"
|
||||
placement="top"
|
||||
:content="user.full_name"
|
||||
>
|
||||
<user-avatar :user="user" class="team-avatars__avatar"></user-avatar>
|
||||
</at-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</at-popover>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import UserAvatar from '@/components/UserAvatar.vue';
|
||||
|
||||
export default {
|
||||
name: 'TeamAvatars',
|
||||
components: {
|
||||
UserAvatar,
|
||||
},
|
||||
props: {
|
||||
users: {
|
||||
required: true,
|
||||
type: Array,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.team-avatars {
|
||||
&__preview {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__avatar {
|
||||
margin: $spacing-01;
|
||||
}
|
||||
|
||||
&__placeholder {
|
||||
display: flex;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 5px;
|
||||
font:
|
||||
12px / 30px Helvetica,
|
||||
Arial,
|
||||
sans-serif;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
background-color: rgb(158, 158, 158);
|
||||
color: rgb(238, 238, 238);
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
&__avatars {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
146
resources/frontend/core/components/TimezonePicker.vue
Normal file
146
resources/frontend/core/components/TimezonePicker.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<div class="at-select" :class="{ 'at-select--visible': visible }">
|
||||
<v-select
|
||||
ref="select"
|
||||
v-model="model"
|
||||
class="timezone-select"
|
||||
:options="paginated"
|
||||
:filterable="false"
|
||||
:placeholder="$t('control.select')"
|
||||
@open="onOpen"
|
||||
@close="onClose"
|
||||
@search="search = $event"
|
||||
>
|
||||
<template #list-footer>
|
||||
<li v-show="hasNextPage" ref="load" class="vs__dropdown-option">Loading...</li>
|
||||
</template>
|
||||
</v-select>
|
||||
<i class="icon icon-chevron-down at-select__arrow" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import moment from 'moment-timezone';
|
||||
import vSelect from 'vue-select';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Object],
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
vSelect,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
timezones: [],
|
||||
limit: 10,
|
||||
search: '',
|
||||
observer: null,
|
||||
visible: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
model: {
|
||||
get() {
|
||||
return {
|
||||
value: this.value,
|
||||
label: this.formatTimezone(this.value),
|
||||
};
|
||||
},
|
||||
set(option) {
|
||||
if (!option) return;
|
||||
|
||||
this.$emit('onTimezoneChange', option.value);
|
||||
},
|
||||
},
|
||||
filtered() {
|
||||
if (!this.timezones || !this.timezones.length) return [];
|
||||
|
||||
return this.timezones.filter(timezone =>
|
||||
timezone.label.toLowerCase().includes(this.search.toLowerCase()),
|
||||
);
|
||||
},
|
||||
paginated() {
|
||||
return this.filtered.slice(0, this.limit);
|
||||
},
|
||||
hasNextPage() {
|
||||
return this.paginated.length < this.filtered.length;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
inputHandler(value) {
|
||||
this.$emit('onTimezoneChange', value);
|
||||
},
|
||||
async onOpen() {
|
||||
if (this.hasNextPage) {
|
||||
await this.$nextTick();
|
||||
this.observer.observe(this.$refs.load);
|
||||
}
|
||||
this.visible = true;
|
||||
},
|
||||
onClose() {
|
||||
this.visible = false;
|
||||
this.observer.disconnect();
|
||||
},
|
||||
async infiniteScroll([{ isIntersecting, target }]) {
|
||||
if (isIntersecting) {
|
||||
const ul = target.offsetParent;
|
||||
const scrollTop = target.offsetParent.scrollTop;
|
||||
this.limit += 10;
|
||||
await this.$nextTick();
|
||||
ul.scrollTop = scrollTop;
|
||||
}
|
||||
},
|
||||
setTimezones() {
|
||||
if (this.timezones.length > 1) return;
|
||||
|
||||
moment.tz.names().map(timezoneName => {
|
||||
if (this.timezones.some(t => t.value === timezoneName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
//Asia/Kolkata
|
||||
if (timezoneName === 'Asia/Calcutta') {
|
||||
timezoneName = 'Asia/Kolkata';
|
||||
}
|
||||
|
||||
if (typeof timezoneName !== 'string') return;
|
||||
|
||||
this.timezones.push({
|
||||
value: timezoneName,
|
||||
label: this.formatTimezone(timezoneName),
|
||||
});
|
||||
});
|
||||
},
|
||||
formatTimezone(timezone) {
|
||||
return `${timezone} (GMT${moment.tz(timezone).format('Z')})`;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.timezones.push({
|
||||
value: this.value,
|
||||
label: this.formatTimezone(this.value),
|
||||
});
|
||||
this.setTimezones();
|
||||
},
|
||||
mounted() {
|
||||
this.observer = new IntersectionObserver(this.infiniteScroll);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.timezone-select {
|
||||
min-width: 240px;
|
||||
|
||||
&::v-deep {
|
||||
.vs__dropdown-menu {
|
||||
width: auto;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
89
resources/frontend/core/components/UserAvatar.vue
Normal file
89
resources/frontend/core/components/UserAvatar.vue
Normal file
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="avatar">
|
||||
<vue-avatar
|
||||
class="avatar__photo"
|
||||
:username="username"
|
||||
:size="size"
|
||||
:customStyle="styles"
|
||||
:backgroundColor="backgroundColor"
|
||||
:src="src"
|
||||
/>
|
||||
<div v-show="user.online" class="avatar__online-status" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import md5 from 'js-md5';
|
||||
import Avatar from 'vue-avatar';
|
||||
|
||||
export default {
|
||||
name: 'UserAvatar',
|
||||
props: {
|
||||
size: {
|
||||
type: Number,
|
||||
default: 30,
|
||||
},
|
||||
borderRadius: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
'vue-avatar': Avatar,
|
||||
},
|
||||
computed: {
|
||||
username() {
|
||||
if (!this.user || !this.user.full_name) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.user.full_name;
|
||||
},
|
||||
email() {
|
||||
if (!this.user || !this.user.email) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return this.user.email;
|
||||
},
|
||||
src() {
|
||||
if (this.user.email) {
|
||||
const emailMD5 = md5(this.email);
|
||||
|
||||
return `https://www.gravatar.com/avatar/${emailMD5}?d=404`;
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
backgroundColor() {
|
||||
return !this.username ? '#eaeaea' : null;
|
||||
},
|
||||
styles() {
|
||||
return {
|
||||
borderRadius: `${this.borderRadius}px`,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.avatar {
|
||||
position: relative;
|
||||
|
||||
&__online-status {
|
||||
height: 7px;
|
||||
width: 7px;
|
||||
position: absolute;
|
||||
background: #6eceb2;
|
||||
border-radius: 100%;
|
||||
border: 1px solid white;
|
||||
right: 0;
|
||||
bottom: 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
462
resources/frontend/core/components/UserSelect.vue
Normal file
462
resources/frontend/core/components/UserSelect.vue
Normal file
@@ -0,0 +1,462 @@
|
||||
<template>
|
||||
<div class="user-select" :class="{ 'at-select--visible': showPopup }" @click="togglePopup">
|
||||
<at-input class="user-select-input" :readonly="true" :value="inputValue" :size="size" />
|
||||
|
||||
<span v-show="userIDs.length" class="user-select__clear icon icon-x at-select__clear" @click="clearSelection" />
|
||||
|
||||
<span class="icon icon-chevron-down at-select__arrow" />
|
||||
|
||||
<transition name="slide-up">
|
||||
<div v-show="showPopup" class="at-select__dropdown at-select__dropdown--bottom" @click.stop>
|
||||
<at-tabs :value="userSelectTab" @on-change="onTabChange">
|
||||
<at-tab-pane :label="$t('control.active')" name="active" />
|
||||
<at-tab-pane :label="$t('control.inactive')" name="inactive" />
|
||||
</at-tabs>
|
||||
|
||||
<div v-if="userSelectTab == 'active'">
|
||||
<div class="user-search">
|
||||
<at-input v-model="searchValue" class="user-search-input" :placeholder="$t('control.search')" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<at-select v-model="userType" placeholder="fields.type" class="user-type-filter">
|
||||
<at-option key="all" value="all">
|
||||
{{ $t('field.types.all') }}
|
||||
</at-option>
|
||||
|
||||
<at-option key="employee" value="employee">
|
||||
{{ $t('field.types.employee') }}
|
||||
</at-option>
|
||||
|
||||
<at-option key="client" value="client">
|
||||
{{ $t('field.types.client') }}
|
||||
</at-option>
|
||||
</at-select>
|
||||
</div>
|
||||
|
||||
<div class="user-select-all" @click="selectAllActiveUsers">
|
||||
<span>{{ $t(selectedActiveUsers.length ? 'control.clear_all' : 'control.select_all') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="user-select-list">
|
||||
<preloader v-if="isLoading"></preloader>
|
||||
<ul>
|
||||
<li
|
||||
v-for="user in filteredActiveUsers"
|
||||
:key="user.id"
|
||||
:class="{
|
||||
'user-select-item': true,
|
||||
active: userIDs.includes(user.id),
|
||||
}"
|
||||
@click="toggleUser(user.id)"
|
||||
>
|
||||
<UserAvatar
|
||||
class="user-avatar"
|
||||
:size="25"
|
||||
:borderRadius="5"
|
||||
:user="user"
|
||||
:online="user.online"
|
||||
/>
|
||||
|
||||
<div class="user-name">{{ user.full_name }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userSelectTab == 'inactive'">
|
||||
<div class="user-search">
|
||||
<at-input v-model="searchValue" class="user-search-input" :placeholder="$t('control.search')" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<at-select v-model="userType" placeholder="fields.type" class="user-type-filter">
|
||||
<at-option key="all" value="all">
|
||||
{{ $t('field.types.all') }}
|
||||
</at-option>
|
||||
|
||||
<at-option key="employee" value="employee">
|
||||
{{ $t('field.types.employee') }}
|
||||
</at-option>
|
||||
|
||||
<at-option key="client" value="client">
|
||||
{{ $t('field.types.client') }}
|
||||
</at-option>
|
||||
</at-select>
|
||||
</div>
|
||||
|
||||
<div class="user-select-all" @click="selectAllInactiveUsers">
|
||||
<span>{{ $t(selectedInactiveUsers.length ? 'control.clear_all' : 'control.select_all') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="user-select-list">
|
||||
<preloader v-if="isLoading"></preloader>
|
||||
<ul>
|
||||
<li
|
||||
v-for="user in filteredInactiveUsers"
|
||||
:key="user.id"
|
||||
:class="{
|
||||
'user-select-item': true,
|
||||
active: userIDs.includes(user.id),
|
||||
}"
|
||||
@click="toggleUser(user.id)"
|
||||
>
|
||||
<UserAvatar
|
||||
class="user-avatar"
|
||||
:size="25"
|
||||
:borderRadius="5"
|
||||
:user="user"
|
||||
:online="user.online"
|
||||
/>
|
||||
|
||||
<div class="user-name">{{ user.full_name }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import UsersService from '@/services/resource/user.service';
|
||||
import Preloader from '@/components/Preloader';
|
||||
|
||||
export default {
|
||||
name: 'UserSelect',
|
||||
components: {
|
||||
UserAvatar,
|
||||
Preloader,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
required: false,
|
||||
default: () => {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'normal',
|
||||
},
|
||||
localStorageKey: {
|
||||
type: String,
|
||||
default: 'user-select.users',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
let userIDs = [];
|
||||
if (typeof this.value !== 'undefined' && this.value.length) {
|
||||
userIDs = this.value;
|
||||
} else {
|
||||
if (localStorage.getItem(this.localStorageKey)) {
|
||||
userIDs = JSON.parse(localStorage.getItem(this.localStorageKey));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showPopup: false,
|
||||
userSelectTab: 'active',
|
||||
userIDs,
|
||||
usersService: new UsersService(),
|
||||
searchValue: '',
|
||||
changed: false,
|
||||
users: [],
|
||||
userType: 'all',
|
||||
isLoading: false,
|
||||
};
|
||||
},
|
||||
async created() {
|
||||
window.addEventListener('click', this.hidePopup);
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
this.users = await this.usersService.getAll({ headers: { 'X-Paginate': 'false' } });
|
||||
} catch ({ response }) {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.warn(response ? response : 'request to users is canceled');
|
||||
}
|
||||
}
|
||||
|
||||
if (!localStorage.getItem(this.localStorageKey)) {
|
||||
this.userIDs = this.users.filter(user => user.active).map(user => user.id);
|
||||
localStorage.setItem(this.localStorageKey, JSON.stringify(this.userIDs));
|
||||
}
|
||||
|
||||
// remove nonexistent users from selected
|
||||
const existingUserIDs = this.users.filter(user => this.userIDs.includes(user.id)).map(user => user.id);
|
||||
|
||||
if (this.userIDs.length > existingUserIDs.length) {
|
||||
this.userIDs = existingUserIDs;
|
||||
localStorage.setItem(this.localStorageKey, JSON.stringify(this.userIDs));
|
||||
}
|
||||
|
||||
if (this.userIDs.length) {
|
||||
this.$emit('change', this.userIDs);
|
||||
}
|
||||
this.isLoading = false;
|
||||
this.$nextTick(() => this.$emit('loaded'));
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('click', this.hidePopup);
|
||||
},
|
||||
computed: {
|
||||
activeUsers() {
|
||||
return this.users.filter(user => user.active);
|
||||
},
|
||||
inactiveUsers() {
|
||||
return this.users.filter(user => !user.active);
|
||||
},
|
||||
selectedActiveUsers() {
|
||||
return this.activeUsers.filter(({ id }) => this.userIDs.includes(id));
|
||||
},
|
||||
selectedInactiveUsers() {
|
||||
return this.inactiveUsers.filter(({ id }) => this.userIDs.includes(id));
|
||||
},
|
||||
filteredActiveUsers() {
|
||||
return this.activeUsers.filter(user => {
|
||||
if (this.userType !== 'all' && user.type !== this.userType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = user.full_name.toUpperCase();
|
||||
const value = this.searchValue.toUpperCase();
|
||||
|
||||
return name.indexOf(value) !== -1;
|
||||
});
|
||||
},
|
||||
filteredInactiveUsers() {
|
||||
return this.inactiveUsers.filter(user => {
|
||||
if (this.userType !== 'all' && user.type !== this.userType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const name = user.full_name.toUpperCase();
|
||||
const value = this.searchValue.toUpperCase();
|
||||
|
||||
return name.indexOf(value) !== -1;
|
||||
});
|
||||
},
|
||||
inputValue() {
|
||||
return this.$tc('control.user_selected', this.userIDs.length, {
|
||||
count: this.userIDs.length,
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
togglePopup() {
|
||||
this.showPopup = !this.showPopup;
|
||||
|
||||
if (!this.showPopup && this.changed) {
|
||||
this.changed = false;
|
||||
this.$emit('change', this.userIDs);
|
||||
}
|
||||
},
|
||||
hidePopup() {
|
||||
if (this.$el.contains(event.target)) {
|
||||
return;
|
||||
}
|
||||
this.showPopup = false;
|
||||
|
||||
if (this.changed) {
|
||||
this.changed = false;
|
||||
this.$emit('change', this.userIDs);
|
||||
}
|
||||
},
|
||||
clearSelection() {
|
||||
this.userIDs = [];
|
||||
this.$emit('change', this.userIDs);
|
||||
localStorage[this.localStorageKey] = JSON.stringify(this.userIDs);
|
||||
},
|
||||
toggleUser(userID) {
|
||||
if (this.userIDs.includes(userID)) {
|
||||
this.userIDs = this.userIDs.filter(id => id !== userID);
|
||||
} else {
|
||||
this.userIDs.push(userID);
|
||||
}
|
||||
|
||||
this.changed = true;
|
||||
localStorage[this.localStorageKey] = JSON.stringify(this.userIDs);
|
||||
},
|
||||
selectAllActiveUsers() {
|
||||
// If some users already selected we are going to clear it
|
||||
if (!this.selectedActiveUsers.length) {
|
||||
this.userIDs = this.userIDs.concat(
|
||||
this.activeUsers
|
||||
.filter(({ full_name, type }) => {
|
||||
if (this.userType !== 'all' && this.userType !== type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return full_name.toUpperCase().indexOf(this.searchValue.toUpperCase()) !== -1;
|
||||
})
|
||||
.map(({ id }) => id)
|
||||
.filter(id => !this.userIDs.includes(id)),
|
||||
);
|
||||
} else {
|
||||
this.userIDs = this.userIDs.filter(uid => !this.activeUsers.map(({ id }) => id).includes(uid));
|
||||
}
|
||||
|
||||
this.changed = true;
|
||||
localStorage[this.localStorageKey] = JSON.stringify(this.userIDs);
|
||||
},
|
||||
selectAllInactiveUsers() {
|
||||
if (!this.selectedInactiveUsers.length) {
|
||||
this.userIDs = this.userIDs.concat(
|
||||
this.inactiveUsers
|
||||
.filter(({ full_name, type }) => {
|
||||
if (this.userType !== 'all' && this.userType !== type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return full_name.toUpperCase().indexOf(this.searchValue.toUpperCase()) !== -1;
|
||||
})
|
||||
.map(({ id }) => id)
|
||||
.filter(id => !this.userIDs.includes(id)),
|
||||
);
|
||||
} else {
|
||||
this.userIDs = this.userIDs.filter(uid => !this.inactiveUsers.map(({ id }) => id).includes(uid));
|
||||
}
|
||||
|
||||
this.changed = true;
|
||||
localStorage[this.localStorageKey] = JSON.stringify(this.userIDs);
|
||||
},
|
||||
onTabChange({ name }) {
|
||||
this.userSelectTab = name;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-select {
|
||||
position: relative;
|
||||
min-width: 240px;
|
||||
|
||||
&::v-deep {
|
||||
.at-input__original {
|
||||
border-radius: 5px;
|
||||
|
||||
padding-right: $spacing-08;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.at-tabs-nav {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.at-tabs-nav__item {
|
||||
color: #b1b1be;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
line-height: 39px;
|
||||
width: 50%;
|
||||
|
||||
&--active {
|
||||
color: #2e2ef9;
|
||||
|
||||
&::after {
|
||||
background-color: #2e2ef9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.at-tabs__nav {
|
||||
height: 39px;
|
||||
}
|
||||
|
||||
.at-tabs__header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.at-tabs__body {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__clear {
|
||||
margin-right: $spacing-05;
|
||||
display: block;
|
||||
}
|
||||
|
||||
&-list {
|
||||
overflow-y: scroll;
|
||||
max-height: 200px;
|
||||
position: relative;
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
&-all {
|
||||
position: relative;
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: #59566e;
|
||||
text-transform: uppercase;
|
||||
|
||||
padding: 8px 20px;
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&-item {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #151941;
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
padding: 7px 20px;
|
||||
|
||||
&.active {
|
||||
background: #f4f4ff;
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: ' ';
|
||||
display: table;
|
||||
clear: both;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-search-input {
|
||||
margin: 0;
|
||||
|
||||
&::v-deep {
|
||||
.at-input__original {
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.user-type-filter {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
float: left;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
|
||||
.at-select {
|
||||
&__dropdown {
|
||||
overflow: hidden;
|
||||
max-height: 360px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
38
resources/frontend/core/components/VueMarkdown.vue
Normal file
38
resources/frontend/core/components/VueMarkdown.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<script>
|
||||
import MarkdownIt from 'markdown-it';
|
||||
|
||||
export default {
|
||||
name: 'VueMarkdown',
|
||||
props: {
|
||||
source: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
plugins: {
|
||||
type: Array,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
const md = new MarkdownIt(this.options);
|
||||
for (const plugin of this.plugins ?? []) {
|
||||
md.use(plugin);
|
||||
}
|
||||
return {
|
||||
md,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
content() {
|
||||
return this.md.render(this.source);
|
||||
},
|
||||
},
|
||||
render(h) {
|
||||
return h('div', { domProps: { innerHTML: this.content } });
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<div>
|
||||
<transition name="fade">
|
||||
<div v-show="visible" class="at-modal__mask" @click="handleMaskClick"></div>
|
||||
</transition>
|
||||
<div
|
||||
class="at-modal__wrapper"
|
||||
:class="{
|
||||
'at-modal--hidden': !wrapShow,
|
||||
'at-modal--confirm': isIconType,
|
||||
[`at-modal--confirm-${type}`]: isIconType,
|
||||
}"
|
||||
@click.self="handleWrapperClick"
|
||||
>
|
||||
<transition name="fade">
|
||||
<div v-show="visible" class="at-modal" :style="modalStyle">
|
||||
<div v-if="showHead && ($slots.header || this.title)" class="at-modal__header" :style="headerStyle">
|
||||
<div class="at-modal__title">
|
||||
<slot name="header">
|
||||
<i v-if="isIconType" class="icon at-modal__icon" :class="iconClass" />
|
||||
<p>{{ title }}</p>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="at-modal__body" :style="bodyStyle">
|
||||
<slot>
|
||||
<p>{{ content }}</p>
|
||||
<div v-if="showInput" class="at-modal__input">
|
||||
<at-input
|
||||
ref="input"
|
||||
v-model="inputValue"
|
||||
:placeholder="inputPlaceholder"
|
||||
@keyup.enter.native="handleAction('confirm')"
|
||||
></at-input>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="showFooter" class="at-modal__footer" :style="footerStyle">
|
||||
<slot name="footer">
|
||||
<at-button v-show="showCancelButton" @click.native="handleAction('cancel')"
|
||||
>{{ localeCancelText }}
|
||||
</at-button>
|
||||
<at-button
|
||||
v-show="showConfirmButton"
|
||||
:type="typeButton"
|
||||
@click.native="handleAction('confirm')"
|
||||
>{{ localeOKText }}
|
||||
</at-button>
|
||||
</slot>
|
||||
</div>
|
||||
<span v-if="showClose" class="at-modal__close" @click="handleAction('cancel')"
|
||||
><i class="icon icon-x"></i
|
||||
></span>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { t } from '@cattr/ui-kit/src/locale';
|
||||
|
||||
export default {
|
||||
name: 'custom-at-modal',
|
||||
props: {
|
||||
title: String,
|
||||
content: String,
|
||||
value: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
cancelText: {
|
||||
type: String,
|
||||
},
|
||||
okText: {
|
||||
type: String,
|
||||
},
|
||||
maskClosable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showHead: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showClose: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showFooter: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showInput: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
width: {
|
||||
type: [Number, String],
|
||||
default: 520,
|
||||
},
|
||||
closeOnPressEsc: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
styles: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
type: String,
|
||||
typeButton: {
|
||||
type: String,
|
||||
default: 'primary',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
wrapShow: false,
|
||||
showCancelButton: true,
|
||||
showConfirmButton: true,
|
||||
action: '',
|
||||
visible: this.value,
|
||||
inputValue: null,
|
||||
inputPlaceholder: '',
|
||||
callback: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
headerStyle() {
|
||||
return Object.prototype.hasOwnProperty.call(this.styles, 'header') ? this.styles.header : {};
|
||||
},
|
||||
footerStyle() {
|
||||
return Object.prototype.hasOwnProperty.call(this.styles, 'footer') ? this.styles.footer : {};
|
||||
},
|
||||
bodyStyle() {
|
||||
return Object.prototype.hasOwnProperty.call(this.styles, 'body') ? this.styles.body : {};
|
||||
},
|
||||
iconClass() {
|
||||
const classArr = {
|
||||
success: 'icon-check-circle',
|
||||
error: 'icon-x-circle',
|
||||
warning: 'icon-alert-circle',
|
||||
info: 'icon-info',
|
||||
trash: 'icon-trash-2',
|
||||
};
|
||||
|
||||
return classArr[this.type] || '';
|
||||
},
|
||||
isIconType() {
|
||||
return ['success', 'error', 'warning', 'info', 'trash'].indexOf(this.type) > -1;
|
||||
},
|
||||
modalStyle() {
|
||||
const style = {};
|
||||
const styleWidth = {
|
||||
width: `${this.width}px`,
|
||||
};
|
||||
|
||||
Object.assign(style, styleWidth, this.styles);
|
||||
|
||||
return style;
|
||||
},
|
||||
localeOKText() {
|
||||
return typeof this.okText === 'undefined' ? t('at.modal.okText') : this.okText;
|
||||
},
|
||||
localeCancelText() {
|
||||
return typeof this.cancelText === 'undefined' ? t('at.modal.cancelText') : this.cancelText;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
this.visible = val;
|
||||
},
|
||||
visible(val) {
|
||||
if (val) {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.wrapShow = true;
|
||||
} else {
|
||||
this.timer = setTimeout(() => {
|
||||
this.wrapShow = false;
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
doClose() {
|
||||
this.visible = false;
|
||||
this.$emit('input', false);
|
||||
this.$emit('on-cancel');
|
||||
|
||||
if (this.action && this.callback) {
|
||||
this.callback(this.action, this);
|
||||
}
|
||||
},
|
||||
handleMaskClick(evt) {
|
||||
if (this.maskClosable) {
|
||||
this.doClose();
|
||||
}
|
||||
},
|
||||
handleWrapperClick(evt) {
|
||||
this.action = 'close';
|
||||
if (this.maskClosable) {
|
||||
this.doClose();
|
||||
}
|
||||
},
|
||||
handleAction(action) {
|
||||
this.action = action;
|
||||
|
||||
if (action === 'confirm') {
|
||||
this.$emit('input', false);
|
||||
this.$emit('on-confirm');
|
||||
}
|
||||
|
||||
this.doClose();
|
||||
},
|
||||
handleKeyCode(evt) {
|
||||
if (this.visible && this.showClose) {
|
||||
if (evt.keyCode === 27) {
|
||||
// Escape
|
||||
this.doClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.visible) {
|
||||
this.wrapShow = true;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', this.handleKeyCode);
|
||||
},
|
||||
beforeDestory() {
|
||||
document.removeEventListener('keydown', this.handleKeyCode);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
226
resources/frontend/core/components/global/CustomModal/dialog.js
Normal file
226
resources/frontend/core/components/global/CustomModal/dialog.js
Normal file
@@ -0,0 +1,226 @@
|
||||
import Vue from 'vue';
|
||||
import CustomAtModal from './CustomAtModal.vue';
|
||||
|
||||
const DialogConstructer = Vue.extend(CustomAtModal);
|
||||
|
||||
let currentModal;
|
||||
let instance;
|
||||
let modalQueue = [];
|
||||
|
||||
const defaults = {
|
||||
title: '',
|
||||
content: '',
|
||||
type: '',
|
||||
};
|
||||
|
||||
const defultCallback = action => {
|
||||
if (currentModal) {
|
||||
const callback = currentModal.callback;
|
||||
if (typeof callback === 'function') {
|
||||
if (instance.showInput) {
|
||||
callback(instance.inputValue, action);
|
||||
} else {
|
||||
callback(action);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentModal.resolve) {
|
||||
const type = currentModal.options.type;
|
||||
if (type === 'confirm' || type === 'prompt') {
|
||||
if (action === 'confirm') {
|
||||
if (instance.showInput) {
|
||||
currentModal.resolve({ value: instance.inputValue, action });
|
||||
} else {
|
||||
currentModal.resolve(action);
|
||||
}
|
||||
} else if (action === 'cancel' && currentModal.reject) {
|
||||
currentModal.reject(action);
|
||||
}
|
||||
} else {
|
||||
currentModal.resolve(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const initInstance = () => {
|
||||
instance = new DialogConstructer({
|
||||
el: document.createElement('div'),
|
||||
});
|
||||
|
||||
instance.callback = defultCallback;
|
||||
};
|
||||
|
||||
const showNextModal = () => {
|
||||
initInstance();
|
||||
instance.action = '';
|
||||
|
||||
if (!instance.visible && modalQueue.length) {
|
||||
currentModal = modalQueue.shift();
|
||||
|
||||
const options = currentModal.options;
|
||||
for (const prop in options) {
|
||||
if (Object.prototype.hasOwnProperty.call(options, prop)) {
|
||||
instance[prop] = options[prop];
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof options.callback !== 'function') {
|
||||
instance.callback = defultCallback;
|
||||
}
|
||||
|
||||
const oldCallback = instance.callback;
|
||||
instance.callback = (action, instance) => {
|
||||
oldCallback(action, instance);
|
||||
showNextModal();
|
||||
};
|
||||
|
||||
document.body.appendChild(instance.$el);
|
||||
|
||||
Vue.nextTick(() => {
|
||||
instance.visible = true;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const Dialog = (options, callback) => {
|
||||
if (Vue.prototype.$isServer) return;
|
||||
if (options.callback && !callback) {
|
||||
callback = options.callback;
|
||||
}
|
||||
|
||||
if (typeof Promise !== 'undefined') {
|
||||
return new Promise((resolve, reject) => {
|
||||
modalQueue.push({
|
||||
options: Object.assign({}, defaults, options),
|
||||
callback,
|
||||
resolve,
|
||||
reject,
|
||||
});
|
||||
|
||||
showNextModal();
|
||||
});
|
||||
}
|
||||
|
||||
modalQueue.push({
|
||||
options: Object.assign({}, defaults, options),
|
||||
callback,
|
||||
});
|
||||
|
||||
showNextModal();
|
||||
};
|
||||
|
||||
Dialog.close = () => {
|
||||
instance.visible = false;
|
||||
modalQueue = [];
|
||||
currentModal = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Such like window.alert
|
||||
*/
|
||||
Dialog.alert = (content, title, options) => {
|
||||
if (typeof content === 'object') {
|
||||
options = content;
|
||||
content = options.content;
|
||||
title = options.title || '';
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
Object.assign(
|
||||
{
|
||||
title,
|
||||
content,
|
||||
type: 'alert',
|
||||
maskClosable: false,
|
||||
showCancelButton: false,
|
||||
},
|
||||
options,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Such like window.confirm
|
||||
*/
|
||||
Dialog.confirm = (content, title, options) => {
|
||||
if (typeof content === 'object') {
|
||||
options = content;
|
||||
content = options.content;
|
||||
title = options.title || '';
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
Object.assign(
|
||||
{
|
||||
title,
|
||||
content,
|
||||
type: 'confirm',
|
||||
},
|
||||
options,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Such like window.prompt
|
||||
*/
|
||||
Dialog.prompt = (content, title, options) => {
|
||||
if (typeof content === 'object') {
|
||||
options = content;
|
||||
content = options.content;
|
||||
title = options.title || '';
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
Object.assign(
|
||||
{
|
||||
title,
|
||||
content,
|
||||
type: 'prompt',
|
||||
showInput: true,
|
||||
},
|
||||
options,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Status Dialog
|
||||
*/
|
||||
function createStatusDialog(type) {
|
||||
const statusTitles = {
|
||||
info: '信息',
|
||||
success: '成功',
|
||||
warning: '警告',
|
||||
error: '错误',
|
||||
};
|
||||
return (content, title, options) => {
|
||||
if (typeof content === 'object') {
|
||||
options = content;
|
||||
content = options.content;
|
||||
title = options.title || statusTitles[type];
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
Object.assign(
|
||||
{
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
maskClosable: false,
|
||||
showCancelButton: false,
|
||||
showClose: false,
|
||||
},
|
||||
options,
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
Dialog.info = createStatusDialog('info');
|
||||
Dialog.success = createStatusDialog('success');
|
||||
Dialog.warning = createStatusDialog('warning');
|
||||
Dialog.error = createStatusDialog('error');
|
||||
|
||||
export default Dialog;
|
||||
Reference in New Issue
Block a user