diff options
| author | Shishkevich D. <135337715+shishkevichd@users.noreply.github.com> | 2025-04-06 12:40:33 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-04-06 12:40:33 +0300 |
| commit | bea19a263db88fef44b4356082b199fbfcc39a25 (patch) | |
| tree | a111e9328c6273ad9721118238c40cf3004f72a9 /web/html/component | |
| parent | 878e0d02cd01a045f4f32464124c59e24f98aedd (diff) | |
Code refactoring (#2865)
* refactor: use vue inline styles in entire application
* refactor: setting row in dashboard page
* refactor: use blob for download file in text modal
* refactor: move all html templates in `web/html` folder
* refactor: `DeviceUtils` -> `MediaQueryMixin`
The transition to mixins has been made, as they can update themselves.
* chore: pretty right buttons in `outbounds` tab in xray settings
* refactor: add translations for system status
* refactor: adjust gutter spacing in setting list item
* refactor: use native `a-input-password` for password field
* chore: return old system status
with new translations
* chore: add missing translation
Diffstat (limited to 'web/html/component')
| -rw-r--r-- | web/html/component/aClientTable.html | 267 | ||||
| -rw-r--r-- | web/html/component/aCustomStatistic.html | 42 | ||||
| -rw-r--r-- | web/html/component/aPersianDatepicker.html | 72 | ||||
| -rw-r--r-- | web/html/component/aSettingListItem.html | 49 | ||||
| -rw-r--r-- | web/html/component/aSidebar.html | 103 | ||||
| -rw-r--r-- | web/html/component/aTableSortable.html | 232 | ||||
| -rw-r--r-- | web/html/component/aThemeSwitch.html | 119 |
7 files changed, 884 insertions, 0 deletions
diff --git a/web/html/component/aClientTable.html b/web/html/component/aClientTable.html new file mode 100644 index 00000000..868112d9 --- /dev/null +++ b/web/html/component/aClientTable.html @@ -0,0 +1,267 @@ +{{define "component/aClientTable"}} +<template slot="actions" slot-scope="text, client, index"> + <a-tooltip> + <template slot="title">{{ i18n "qrCode" }}</template> + <a-icon :style="{ fontSize: '24px' }" class="normal-icon" type="qrcode" v-if="record.hasLink()" @click="showQrcode(record.id,client);"></a-icon> + </a-tooltip> + <a-tooltip> + <template slot="title">{{ i18n "pages.client.edit" }}</template> + <a-icon :style="{ fontSize: '24px' }" class="normal-icon" type="edit" @click="openEditClient(record.id,client);"></a-icon> + </a-tooltip> + <a-tooltip> + <template slot="title">{{ i18n "info" }}</template> + <a-icon :style="{ fontSize: '24px' }" class="normal-icon" type="info-circle" @click="showInfo(record.id,client);"></a-icon> + </a-tooltip> + <a-tooltip> + <template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template> + <a-popconfirm @confirm="resetClientTraffic(client,record.id,false)" title='{{ i18n "pages.inbounds.resetTrafficContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}' cancel-text='{{ i18n "cancel"}}'> + <a-icon slot="icon" type="question-circle-o" :style="{ color: 'var(--color-primary-100)'}"></a-icon> + <a-icon :style="{ fontSize: '24px', cursor: 'pointer' }" class="normal-icon" type="retweet" v-if="client.email.length > 0"></a-icon> + </a-popconfirm> + </a-tooltip> + <a-tooltip> + <template slot="title"> + <span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span> + </template> + <a-popconfirm @confirm="delClient(record.id,client,false)" title='{{ i18n "pages.inbounds.deleteClientContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "delete"}}' ok-type="danger" cancel-text='{{ i18n "cancel"}}'> + <a-icon slot="icon" type="question-circle-o" :style="{ color: '#e04141' }"></a-icon> + <a-icon :style="{ fontSize: '24px', cursor: 'pointer' }" class="delete-icon" type="delete" v-if="isRemovable(record.id)"></a-icon> + </a-popconfirm> + </a-tooltip> +</template> +<template slot="enable" slot-scope="text, client, index"> + <a-switch v-model="client.enable" @change="switchEnableClient(record.id,client)"></a-switch> +</template> +<template slot="online" slot-scope="text, client, index"> + <template v-if="client.enable && isClientOnline(client.email)"> + <a-tag color="green">{{ i18n "online" }}</a-tag> + </template> + <template v-else> + <a-tag>{{ i18n "offline" }}</a-tag> + </template> +</template> +<template slot="client" slot-scope="text, client"> + <a-tooltip> + <template slot="title"> + <template v-if="!isClientEnabled(record, client.email)">{{ i18n "depleted" }}</template> + <template v-else-if="!client.enable">{{ i18n "disabled" }}</template> + <template v-else-if="client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template> + </template> + <a-badge :class="isClientOnline(client.email)? 'online-animation' : ''" :color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge> + </a-tooltip> [[ client.email ]] +</template> +<template slot="traffic" slot-scope="text, client"> + <a-popover :overlay-class-name="themeSwitcher.currentTheme"> + <template slot="content" v-if="client.email"> + <table cellpadding="2" width="100%"> + <tr> + <td>↑[[ SizeFormatter.sizeFormat(getUpStats(record, client.email)) ]]</td> + <td>↓[[ SizeFormatter.sizeFormat(getDownStats(record, client.email)) ]]</td> + </tr> + <tr v-if="client.totalGB > 0"> + <td>{{ i18n "remained" }}</td> + <td>[[ SizeFormatter.sizeFormat(getRemStats(record, client.email)) ]]</td> + </tr> + </table> + </template> + <table> + <tr class="tr-table-box"> + <td class="tr-table-rt"> [[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]] </td> + <td class="tr-table-bar" v-if="!client.enable"> + <a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" /> + </td> + <td class="tr-table-bar" v-else-if="client.totalGB > 0"> + <a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientEnabled(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" /> + </td> + <td v-else class="infinite-bar tr-table-bar"> + <a-progress :show-info="false" :percent="100"></a-progress> + </td> + <td class="tr-table-lt"> + <template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template> + <span v-else class="tr-infinity-ch">∞</span> + </td> + </tr> + </table> + </a-popover> +</template> +<template slot="expiryTime" slot-scope="text, client, index"> + <template v-if="client.expiryTime !=0 && client.reset >0"> + <a-popover :overlay-class-name="themeSwitcher.currentTheme"> + <template slot="content"> + <span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }} + </span> + <span v-else> + <template v-if="app.datepicker === 'gregorian'"> + [[ DateUtil.formatMillis(client._expiryTime) ]] + </template> + <template v-else> + [[ DateUtil.convertToJalalian(moment(client._expiryTime)) ]] + </template> + </span> + </template> + <table> + <tr class="tr-table-box"> + <td class="tr-table-rt"> [[ remainedDays(client.expiryTime) ]] </td> + <td class="infinite-bar tr-table-bar"> + <a-progress :show-info="false" :status="isClientEnabled(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" /> + </td> + <td class="tr-table-lt">[[ client.reset + "d" ]]</td> + </tr> + </table> + </a-popover> + </template> + <template v-else> + <a-popover v-if="client.expiryTime != 0" :overlay-class-name="themeSwitcher.currentTheme"> + <template slot="content"> + <span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }} + </span> + <span v-else> + <template v-if="app.datepicker === 'gregorian'"> + [[ DateUtil.formatMillis(client._expiryTime) ]] + </template> + <template v-else> + [[ DateUtil.convertToJalalian(moment(client._expiryTime)) ]] + </template> + </span> + </template> + <a-tag :style="{ minWidth: '50px', border: 'none' }" :color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)"> [[ remainedDays(client.expiryTime) ]] </a-tag> + </a-popover> + <a-tag v-else :color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)" :style="{ border: 'none' }" class="infinite-tag"> + <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor"> + <path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path> + </svg> + </a-tag> + </template> +</template> +<template slot="actionMenu" slot-scope="text, client, index"> + <a-dropdown :trigger="['click']"> + <a-icon @click="e => e.preventDefault()" type="ellipsis" :style="{ fontSize: '20px' }"></a-icon> + <a-menu slot="overlay" :theme="themeSwitcher.currentTheme"> + <a-menu-item v-if="record.hasLink()" @click="showQrcode(record.id,client);"> + <a-icon :style="{ fontSize: '14px' }" type="qrcode"></a-icon> + {{ i18n "qrCode" }} + </a-menu-item> + <a-menu-item @click="openEditClient(record.id,client);"> + <a-icon :style="{ fontSize: '14px' }" type="edit"></a-icon> + {{ i18n "pages.client.edit" }} + </a-menu-item> + <a-menu-item @click="showInfo(record.id,client);"> + <a-icon :style="{ fontSize: '14px' }" type="info-circle"></a-icon> + {{ i18n "info" }} + </a-menu-item> + <a-menu-item @click="resetClientTraffic(client,record.id)" v-if="client.email.length > 0"> + <a-icon :style="{ fontSize: '14px' }" type="retweet"></a-icon> + {{ i18n "pages.inbounds.resetTraffic" }} + </a-menu-item> + <a-menu-item v-if="isRemovable(record.id)" @click="delClient(record.id,client)"> + <a-icon :style="{ fontSize: '14px' }" type="delete"></a-icon> + <span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span> + </a-menu-item> + <a-menu-item> + <a-switch v-model="client.enable" size="small" @change="switchEnableClient(record.id,client)"></a-switch> + {{ i18n "enable"}} + </a-menu-item> + </a-menu> + </a-dropdown> +</template> +<template slot="info" slot-scope="text, client, index"> + <a-popover placement="bottomRight" :overlay-class-name="themeSwitcher.currentTheme" trigger="click"> + <template slot="content"> + <table> + <tr> + <td colspan="3" :style="{ textAlign: 'center' }">{{ i18n "pages.inbounds.traffic" }}</td> + </tr> + <tr> + <td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[ SizeFormatter.sizeFormat(getUpStats(record, client.email) + getDownStats(record, client.email)) ]] </td> + <td width="120px" v-if="!client.enable"> + <a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" /> + </td> + <td width="120px" v-else-if="client.totalGB > 0"> + <a-popover :overlay-class-name="themeSwitcher.currentTheme"> + <template slot="content" v-if="client.email"> + <table cellpadding="2" width="100%"> + <tr> + <td>↑[[ SizeFormatter.sizeFormat(getUpStats(record, client.email)) ]]</td> + <td>↓[[ SizeFormatter.sizeFormat(getDownStats(record, client.email)) ]]</td> + </tr> + <tr> + <td>{{ i18n "remained" }}</td> + <td>[[ SizeFormatter.sizeFormat(getRemStats(record, client.email)) ]]</td> + </tr> + </table> + </template> + <a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientEnabled(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" /> + </a-popover> + </td> + <td width="120px" v-else class="infinite-bar"> + <a-progress :stroke-color="themeSwitcher.isDarkTheme ? '#2c1e32':'#F2EAF1'" :show-info="false" :percent="100"></a-progress> + </td> + <td width="80px"> + <template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template> + <span v-else class="tr-infinity-ch">∞</span> + </td> + </tr> + <tr> + <td colspan="3" :style="{ textAlign: 'center' }"> + <a-divider :style="{ margin: '0', borderCollapse: 'separate' }"></a-divider> + {{ i18n "pages.inbounds.expireDate" }} + </td> + </tr> + <tr> + <template v-if="client.expiryTime !=0 && client.reset >0"> + <td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[ remainedDays(client.expiryTime) ]] </td> + <td width="120px" class="infinite-bar"> + <a-popover :overlay-class-name="themeSwitcher.currentTheme"> + <template slot="content"> + <span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }} + </span> + <span v-else> + <template v-if="app.datepicker === 'gregorian'"> + [[ DateUtil.formatMillis(client._expiryTime) ]] + </template> + <template v-else> + [[ DateUtil.convertToJalalian(moment(client._expiryTime)) ]] + </template> + </span> + </template> + <a-progress :show-info="false" :status="isClientEnabled(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" /> + </a-popover> + </td> + <td width="60px">[[ client.reset + "d" ]]</td> + </template> + <template v-else> + <td colspan="3" :style="{ textAlign: 'center' }"> + <a-popover v-if="client.expiryTime != 0" :overlay-class-name="themeSwitcher.currentTheme"> + <template slot="content"> + <span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }} + </span> + <span v-else> + <template v-if="app.datepicker === 'gregorian'"> + [[ DateUtil.formatMillis(client._expiryTime) ]] + </template> + <template v-else> + [[ DateUtil.convertToJalalian(moment(client._expiryTime)) ]] + </template> + </span> + </template> + <a-tag :style="{ minWidth: '50px', border: 'none' }" :color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)"> [[ remainedDays(client.expiryTime) ]] </a-tag> + </a-popover> + <a-tag v-else :color="client.enable ? 'purple' : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'" class="infinite-tag"> + <svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor"> + <path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path> + </svg> + </a-tag> + </template> + </td> + </tr> + </table> + </template> + <a-badge> + <a-icon v-if="!client.enable" slot="count" type="pause-circle" theme="filled" :style="{ color: themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc' }"></a-icon> + <a-button shape="round" size="small" :style="{ fontSize: '14px', padding: '0 10px' }"> + <a-icon type="solution"></a-icon> + </a-button> + </a-badge> + </a-popover> +</template> +{{end}} diff --git a/web/html/component/aCustomStatistic.html b/web/html/component/aCustomStatistic.html new file mode 100644 index 00000000..0bff128d --- /dev/null +++ b/web/html/component/aCustomStatistic.html @@ -0,0 +1,42 @@ +{{define "component/customStatistic"}} +<template> + <a-statistic :title="title" :value="value"> + <template #prefix> + <slot name="prefix"></slot> + </template> + <template #suffix> + <slot name="suffix"></slot> + </template> + </a-statistic> +</template> +{{end}} + +{{define "component/aCustomStatistic"}} +<style> + .dark .ant-statistic-content { + color: var(--dark-color-text-primary) + } + .dark .ant-statistic-title { + color: rgba(255, 255, 255, 0.55) + } + .ant-statistic-content { + font-size: 16px; + } +</style> + +<script> + Vue.component('a-custom-statistic', { + props: { + 'title': { + type: String, + required: false, + }, + 'value': { + type: String, + required: false + } + }, + template: `{{template "component/customStatistic"}}`, + }); +</script> +{{end}}
\ No newline at end of file diff --git a/web/html/component/aPersianDatepicker.html b/web/html/component/aPersianDatepicker.html new file mode 100644 index 00000000..ebd85a08 --- /dev/null +++ b/web/html/component/aPersianDatepicker.html @@ -0,0 +1,72 @@ +{{define "component/persianDatepickerTemplate"}} +<template> + <div> + <a-input :value="value" type="text" v-model="date" data-jdp class="persian-datepicker" + @input="$emit('input', convertToGregorian($event.target.value)); jalaliDatepicker.hide();" + :placeholder="placeholder"> + <template #addonAfter> + <a-icon type="calendar" :style="{ fontSize: '14px', opacity: '0.5' }" /> + </template> + </a-input> + </div> +</template> +{{end}} + +{{define "component/aPersianDatepicker"}} +<link rel="stylesheet" href="{{ .base_path }}assets/persian-datepicker/persian-datepicker.min.css?{{ .cur_ver }}" /> +<script src="{{ .base_path }}assets/moment/moment-jalali.min.js?{{ .cur_ver }}"></script> +<script src="{{ .base_path }}assets/persian-datepicker/persian-datepicker.min.js?{{ .cur_ver }}"></script> +<script> + const persianDatepicker = {}; + + Vue.component('a-persian-datepicker', { + props: { + 'format': { + type: undefined, + required: false, + }, + 'value': { + type: String, + required: false, + }, + 'placeholder': { + type: String, + required: false, + }, + }, + template: `{{template "component/persianDatepickerTemplate"}}`, + data() { + return { + date: '', + persianDatepicker, + }; + }, + watch: { + value: function (date) { + this.date = this.convertToJalalian(date) + } + }, + mounted() { + this.date = this.convertToJalalian(this.value) + this.listenToDatepicker() + }, + methods: { + convertToGregorian(date) { + return date ? moment(moment(date, 'jYYYY/jMM/jDD HH:mm:ss').format('YYYY-MM-DD HH:mm:ss')) : null + }, + convertToJalalian(date) { + return date && moment.isMoment(date) ? date.format('jYYYY/jMM/jDD HH:mm:ss') : null + }, + listenToDatepicker() { + jalaliDatepicker.startWatch({ + time: true, + zIndex: '9999', + hideAfterChange: true, + useDropDownYears: false, + changeMonthRotateYear: true, + }); + }, + } + }); +</script> +{{end}}
\ No newline at end of file diff --git a/web/html/component/aSettingListItem.html b/web/html/component/aSettingListItem.html new file mode 100644 index 00000000..27a7abac --- /dev/null +++ b/web/html/component/aSettingListItem.html @@ -0,0 +1,49 @@ +{{define "component/settingListItem"}} +<a-list-item :style="{ padding: padding }"> + <a-row :gutter="[8,16]"> + <a-col :lg="24" :xl="12"> + <a-list-item-meta> + <template #title> + <slot name="title"></slot> + </template> + <template #description> + <slot name="description"></slot> + </template> + </a-list-item-meta> + </a-col> + <a-col :lg="24" :xl="12"> + <slot name="control"></slot> + </a-col> + </a-row> +</a-list-item> +{{end}} + +{{define "component/aSettingListItem"}} +<script> + Vue.component('a-setting-list-item', { + props: { + 'paddings': { + type: String, + required: false, + defaultValue: "default", + validator: function (value) { + return ['small', 'default'].includes(value) + } + } + }, + template: `{{ template "component/settingListItem" }}`, + computed: { + padding() { + switch (this.paddings) { + case "small": + return "10px 20px !important" + break; + case "default": + return "20px !important" + break; + } + } + } + }) +</script> +{{end}} diff --git a/web/html/component/aSidebar.html b/web/html/component/aSidebar.html new file mode 100644 index 00000000..dfaebb17 --- /dev/null +++ b/web/html/component/aSidebar.html @@ -0,0 +1,103 @@ +{{define "component/sidebar/content"}} +<template> + <div class="ant-sidebar"> + <a-layout-sider :theme="themeSwitcher.currentTheme" collapsible :collapsed="collapsed" + @collapse="(isCollapsed, type) => collapseHandle(isCollapsed, type)" breakpoint="md"> + <a-theme-switch></a-theme-switch> + <a-menu :theme="themeSwitcher.currentTheme" mode="inline" :selected-keys="activeTab" + @click="({key}) => openLink(key)"> + <a-menu-item v-for="tab in tabs" :key="tab.key"> + <a-icon :type="tab.icon"></a-icon> + <span v-text="tab.title"></span> + </a-menu-item> + </a-menu> + </a-layout-sider> + <a-drawer placement="left" :closable="false" @close="closeDrawer" :visible="visible" + :wrap-class-name="themeSwitcher.currentTheme" :wrap-style="{ padding: 0 }" :style="{ height: '100%' }"> + <div class="drawer-handle" @click="toggleDrawer" slot="handle"> + <a-icon :type="visible ? 'close' : 'menu-fold'"></a-icon> + </div> + <a-theme-switch></a-theme-switch> + <a-menu :theme="themeSwitcher.currentTheme" mode="inline" :selected-keys="activeTab" + @click="({key}) => openLink(key)"> + <a-menu-item v-for="tab in tabs" :key="tab.key"> + <a-icon :type="tab.icon"></a-icon> + <span v-text="tab.title"></span> + </a-menu-item> + </a-menu> + </a-drawer> + </div> +</template> +{{end}} + +{{define "component/aSidebar"}} +<style> + .ant-sidebar>.ant-layout-sider { + height: 100%; + } +</style> + +<script> + const SIDEBAR_COLLAPSED_KEY = "isSidebarCollapsed" + + Vue.component('a-sidebar', { + data() { + return { + tabs: [ + { + key: 'panel/', + icon: 'dashboard', + title: '{{ i18n "menu.dashboard"}}' + }, + { + key: 'panel/inbounds', + icon: 'user', + title: '{{ i18n "menu.inbounds"}}' + }, + { + key: 'panel/settings', + icon: 'setting', + title: '{{ i18n "menu.settings"}}' + }, + { + key: 'panel/xray', + icon: 'tool', + title: '{{ i18n "menu.xray"}}' + }, + { + key: 'logout/', + icon: 'logout', + title: '{{ i18n "menu.logout"}}' + }, + ], + activeTab: [ + '{{ .request_uri }}' + ], + visible: false, + collapsed: JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)), + } + }, + methods: { + openLink(key) { + return key.startsWith('http') ? + window.open(`{{ .base_path }}${key}`) : + location.href = `{{ .base_path }}${key}` + }, + closeDrawer() { + this.visible = false; + }, + toggleDrawer() { + this.visible = !this.visible; + }, + collapseHandle(collapsed, type) { + if (type === "clickTrigger") { + localStorage.setItem(SIDEBAR_COLLAPSED_KEY, collapsed); + + this.collapsed = JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY)); + } + } + }, + template: `{{template "component/sidebar/content"}}`, + }); +</script> +{{end}}
\ No newline at end of file diff --git a/web/html/component/aTableSortable.html b/web/html/component/aTableSortable.html new file mode 100644 index 00000000..443ac50c --- /dev/null +++ b/web/html/component/aTableSortable.html @@ -0,0 +1,232 @@ +{{define "component/sortableTableTrigger"}} +<a-icon type="drag" class="sortable-icon" :style="{ cursor: 'move' }" @mouseup="mouseUpHandler" @mousedown="mouseDownHandler" + @click="clickHandler" /> +{{end}} + +{{define "component/aTableSortable"}} +<script> + const DRAGGABLE_ROW_CLASS = 'draggable-row'; + const findParentRowElement = (el) => { + if (!el || !el.tagName) { + return null; + } else if (el.classList.contains(DRAGGABLE_ROW_CLASS)) { + return el; + } else if (el.parentNode) { + return findParentRowElement(el.parentNode); + } else { + return null; + } + } + Vue.component('a-table-sortable', { + data() { + return { + sortingElementIndex: null, + newElementIndex: null, + }; + }, + props: { + 'data-source': { + type: undefined, + required: false, + }, + 'customRow': { + type: undefined, + required: false, + } + }, + inheritAttrs: false, + provide() { + const sortable = {} + Object.defineProperty(sortable, "setSortableIndex", { + enumerable: true, + get: () => this.setCurrentSortableIndex, + }); + Object.defineProperty(sortable, "resetSortableIndex", { + enumerable: true, + get: () => this.resetSortableIndex, + }); + return { + sortable, + } + }, + render: function (createElement) { + return createElement('a-table', { + class: { + 'ant-table-is-sorting': this.isDragging(), + }, + props: { + ...this.$attrs, + 'data-source': this.records, + customRow: (record, index) => this.customRowRender(record, index), + }, + on: this.$listeners, + nativeOn: { + drop: (e) => this.dropHandler(e), + }, + scopedSlots: this.$scopedSlots, + }, this.$slots.default,) + }, + created() { + this.$memoSort = {}; + }, + methods: { + isDragging() { + const currentIndex = this.sortingElementIndex; + return currentIndex !== null && currentIndex !== undefined; + }, + resetSortableIndex(e, index) { + this.sortingElementIndex = null; + this.newElementIndex = null; + this.$memoSort = {}; + }, + setCurrentSortableIndex(e, index) { + this.sortingElementIndex = index; + }, + dragStartHandler(e, index) { + if (!this.isDragging()) { + e.preventDefault(); + return; + } + const hideDragImage = this.$el.cloneNode(true); + hideDragImage.id = "hideDragImage-hide"; + hideDragImage.style.opacity = 0; + e.dataTransfer.setDragImage(hideDragImage, 0, 0); + }, + dragStopHandler(e, index) { + const hideDragImage = document.getElementById('hideDragImage-hide'); + if (hideDragImage) hideDragImage.remove(); + this.resetSortableIndex(e, index); + }, + dragOverHandler(e, index) { + if (!this.isDragging()) { + return; + } + e.preventDefault(); + const currentIndex = this.sortingElementIndex; + if (index === currentIndex) { + this.newElementIndex = null; + return; + } + const row = findParentRowElement(e.target); + if (!row) { + return; + } + const rect = row.getBoundingClientRect(); + const offsetTop = e.pageY - rect.top; + if (offsetTop < rect.height / 2) { + this.newElementIndex = Math.max(index - 1, 0); + } else { + this.newElementIndex = index; + } + }, + dropHandler(e) { + if (this.isDragging()) { + this.$emit('onsort', this.sortingElementIndex, this.newElementIndex); + } + }, + customRowRender(record, index) { + const parentMethodResult = this.customRow?.(record, index) || {}; + const newIndex = this.newElementIndex; + const currentIndex = this.sortingElementIndex; + return { + ...parentMethodResult, + attrs: { + ...(parentMethodResult?.attrs || {}), + draggable: true, + }, + on: { + ...(parentMethodResult?.on || {}), + dragstart: (e) => this.dragStartHandler(e, index), + dragend: (e) => this.dragStopHandler(e, index), + dragover: (e) => this.dragOverHandler(e, index), + }, + class: { + ...(parentMethodResult?.class || {}), + [DRAGGABLE_ROW_CLASS]: true, + ['dragging']: this.isDragging() ? (newIndex === null ? index === currentIndex : index === newIndex) : false, + }, + }; + } + }, + computed: { + records() { + const newIndex = this.newElementIndex; + const currentIndex = this.sortingElementIndex; + if (!this.isDragging() || newIndex === null || currentIndex === newIndex) { + return this.dataSource; + } + if (this.$memoSort.newIndex === newIndex) { + return this.$memoSort.list; + } + let list = [...this.dataSource]; + list.splice(newIndex, 0, list.splice(currentIndex, 1)[0]); + this.$memoSort = { + newIndex, + list, + }; + return list; + } + } + }); + Vue.component('a-table-sort-trigger', { + template: `{{template "component/sortableTableTrigger"}}`, + props: { + 'item-index': { + type: undefined, + required: false + } + }, + inject: ['sortable'], + methods: { + mouseDownHandler(e) { + if (this.sortable) { + this.sortable.setSortableIndex(e, this.itemIndex); + } + }, + mouseUpHandler(e) { + if (this.sortable) { + this.sortable.resetSortableIndex(e, this.itemIndex); + } + }, + clickHandler(e) { + e.preventDefault(); + }, + } + }) +</script> +<style> + @media only screen and (max-width: 767px) { + .sortable-icon { + display: none; + } + } + + .ant-table-is-sorting .draggable-row td { + background-color: #ffffff !important; + } + + .dark .ant-table-is-sorting .draggable-row td { + background-color: var(--dark-color-surface-100) !important; + } + + .ant-table-is-sorting .dragging td { + background-color: rgb(232 244 242) !important; + color: rgba(0, 0, 0, 0.3); + } + + .dark .ant-table-is-sorting .dragging td { + background-color: var(--dark-color-table-hover) !important; + color: rgba(255, 255, 255, 0.3); + } + + .ant-table-is-sorting .dragging { + opacity: 1; + box-shadow: 1px -2px 2px #008771; + transition: all 0.2s; + } + + .ant-table-is-sorting .dragging .ant-table-row-index { + opacity: 0.3; + } +</style> +{{end}}
\ No newline at end of file diff --git a/web/html/component/aThemeSwitch.html b/web/html/component/aThemeSwitch.html new file mode 100644 index 00000000..ccc17714 --- /dev/null +++ b/web/html/component/aThemeSwitch.html @@ -0,0 +1,119 @@ +{{define "component/themeSwitchTemplate"}} +<template> + <a-menu :theme="themeSwitcher.currentTheme" mode="inline" selected-keys=""> + <a-sub-menu> + <span slot="title"> + <a-icon type="bulb" :theme="themeSwitcher.isDarkTheme ? 'filled' : 'outlined'"></a-icon> + <span>{{ i18n "menu.theme" }}</span> + </span> + <a-menu-item id="change-theme" class="ant-menu-theme-switch" @mousedown="themeSwitcher.animationsOff()"> + <span>{{ i18n "menu.dark" }}</span> + <a-switch :style="{ marginLeft: '2px' }" size="small" :default-checked="themeSwitcher.isDarkTheme" + @change="themeSwitcher.toggleTheme()"></a-switch> + </a-menu-item> + <a-menu-item id="change-theme-ultra" v-if="themeSwitcher.isDarkTheme" class="ant-menu-theme-switch" + @mousedown="themeSwitcher.animationsOffUltra()"> + <span>{{ i18n "menu.ultraDark" }}</span> |
