@@ -10,6 +10,8 @@ VUE_APP_TITLE=irandom
|
||||
VUE_APP_BRAND_URL=https://git.imiheev.online/admin/irandom
|
||||
# Logo path or absolute URL (file should be in public/ if relative)
|
||||
VUE_APP_LOGO_URL=/logo.png
|
||||
# Default UI language: ru | en (overridden by localStorage after user chooses)
|
||||
VUE_APP_DEFAULT_LOCALE=ru
|
||||
|
||||
# Theme colors (CSS hex)
|
||||
VUE_APP_COLOR_PRIMARY=#c9a66b
|
||||
|
||||
Generated
+83
@@ -11,6 +11,7 @@
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"element-plus": "^2.14.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.4.8",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -653,6 +654,67 @@
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.8.tgz",
|
||||
"integrity": "sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/devtools-types": "11.4.8",
|
||||
"@intlify/message-compiler": "11.4.8",
|
||||
"@intlify/shared": "11.4.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/devtools-types": {
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.8.tgz",
|
||||
"integrity": "sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.4.8",
|
||||
"@intlify/shared": "11.4.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.8.tgz",
|
||||
"integrity": "sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "11.4.8",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.8.tgz",
|
||||
"integrity": "sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/sourcemap-codec": {
|
||||
"version": "1.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||
@@ -3201,6 +3263,27 @@
|
||||
"eslint": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "11.4.8",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.8.tgz",
|
||||
"integrity": "sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "11.4.8",
|
||||
"@intlify/devtools-types": "11.4.8",
|
||||
"@intlify/shared": "11.4.8",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/kazupon"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"element-plus": "^2.14.3",
|
||||
"vue": "^3.5.13",
|
||||
"vue-i18n": "^11.4.8",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -4,6 +4,7 @@ window.__APP_CONFIG__ = {
|
||||
"title": "irandom",
|
||||
"brandUrl": "https://git.imiheev.online/admin/irandom",
|
||||
"logoUrl": "/logo.png",
|
||||
"defaultLocale": "ru",
|
||||
"colors": {
|
||||
"primary": "#c9a66b",
|
||||
"accent": "#e6c566",
|
||||
|
||||
@@ -10,6 +10,7 @@ const DEFAULTS = {
|
||||
VUE_APP_TITLE: 'irandom',
|
||||
VUE_APP_BRAND_URL: 'https://git.imiheev.online/admin/irandom',
|
||||
VUE_APP_LOGO_URL: '/logo.png',
|
||||
VUE_APP_DEFAULT_LOCALE: 'ru',
|
||||
VUE_APP_COLOR_PRIMARY: '#c9a66b',
|
||||
VUE_APP_COLOR_ACCENT: '#e6c566',
|
||||
VUE_APP_COLOR_BG: '#1a1815',
|
||||
@@ -63,6 +64,7 @@ function buildConfig(env) {
|
||||
title: pick('VUE_APP_TITLE'),
|
||||
brandUrl: pick('VUE_APP_BRAND_URL'),
|
||||
logoUrl: pick('VUE_APP_LOGO_URL'),
|
||||
defaultLocale: pick('VUE_APP_DEFAULT_LOCALE'),
|
||||
colors: {
|
||||
primary: pick('VUE_APP_COLOR_PRIMARY'),
|
||||
accent: pick('VUE_APP_COLOR_ACCENT'),
|
||||
|
||||
+36
-3
@@ -1,7 +1,8 @@
|
||||
<template>
|
||||
<el-config-provider :locale="elementLocale">
|
||||
<el-container class="app-shell">
|
||||
<el-aside width="220px" class="aside desktop-aside">
|
||||
<ModeSidebar show-brand />
|
||||
<el-aside :width="asideWidth" class="aside desktop-aside">
|
||||
<ModeSidebar show-brand @collapse-change="onCollapseChange" />
|
||||
</el-aside>
|
||||
<el-container class="main-column">
|
||||
<el-header height="auto" class="header mobile-header">
|
||||
@@ -11,13 +12,17 @@
|
||||
<RouterView />
|
||||
</el-main>
|
||||
</el-container>
|
||||
<nav class="mobile-toolbar" aria-label="Режимы генерации">
|
||||
<nav class="mobile-toolbar" :aria-label="$t('nav.modes')">
|
||||
<ModeSidebar />
|
||||
</nav>
|
||||
</el-container>
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ru from 'element-plus/es/locale/lang/ru'
|
||||
import en from 'element-plus/es/locale/lang/en'
|
||||
import AppHeader from './components/AppHeader.vue'
|
||||
import ModeSidebar from './components/ModeSidebar.vue'
|
||||
|
||||
@@ -26,6 +31,28 @@ export default {
|
||||
components: {
|
||||
AppHeader,
|
||||
ModeSidebar
|
||||
},
|
||||
setup() {
|
||||
const { locale } = useI18n()
|
||||
return { locale }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sidebarCollapsed: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
elementLocale() {
|
||||
return this.locale === 'en' ? en : ru
|
||||
},
|
||||
asideWidth() {
|
||||
return this.sidebarCollapsed ? '64px' : '220px'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onCollapseChange(collapsed) {
|
||||
this.sidebarCollapsed = collapsed
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -40,6 +67,7 @@ export default {
|
||||
background: var(--ir-surface);
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
transition: width 0.2s ease;
|
||||
|
||||
.mode-sidebar {
|
||||
height: 100%;
|
||||
@@ -68,6 +96,11 @@ export default {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ir-lang-popper {
|
||||
background: var(--ir-surface) !important;
|
||||
border: 1px solid var(--ir-border) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 424px) {
|
||||
.app-shell {
|
||||
flex-direction: column !important;
|
||||
|
||||
+210
-70
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<nav class="mode-sidebar" aria-label="Режимы генерации">
|
||||
<nav class="mode-sidebar" :class="{ collapsed: isCollapsed && !isMobile }" :aria-label="t('nav.modes')">
|
||||
<a
|
||||
v-if="showBrand"
|
||||
class="brand"
|
||||
@@ -8,33 +8,84 @@
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img class="logo" :src="config.logoUrl" :alt="config.name" />
|
||||
<span class="brand-name">{{ config.name }}</span>
|
||||
<span v-show="!isCollapsed || isMobile" class="brand-name">{{ config.name }}</span>
|
||||
</a>
|
||||
<RouterLink
|
||||
|
||||
<button
|
||||
v-if="showBrand && !isMobile"
|
||||
type="button"
|
||||
class="collapse-btn"
|
||||
:title="isCollapsed ? t('nav.expand') : t('nav.collapse')"
|
||||
:aria-label="isCollapsed ? t('nav.expand') : t('nav.collapse')"
|
||||
@click="toggleCollapse"
|
||||
>
|
||||
<el-icon :size="18">
|
||||
<Fold v-if="!isCollapsed" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
</button>
|
||||
|
||||
<el-menu
|
||||
class="side-menu"
|
||||
:key="activeIndex + '-' + locale"
|
||||
:collapse="isCollapsed && !isMobile"
|
||||
:collapse-transition="false"
|
||||
:default-active="activeIndex"
|
||||
background-color="transparent"
|
||||
text-color="var(--ir-text-muted)"
|
||||
active-text-color="var(--ir-primary)"
|
||||
@select="onMenuSelect"
|
||||
>
|
||||
<el-menu-item
|
||||
v-for="mode in modes"
|
||||
:key="mode.id"
|
||||
:to="mode.to"
|
||||
class="mode-item"
|
||||
active-class="active"
|
||||
:index="mode.to"
|
||||
>
|
||||
<el-icon class="mode-icon" :size="22">
|
||||
<el-icon>
|
||||
<component :is="mode.icon" />
|
||||
</el-icon>
|
||||
<span class="mode-label">{{ mode.label }}</span>
|
||||
</RouterLink>
|
||||
<template #title>{{ t(mode.labelKey) }}</template>
|
||||
</el-menu-item>
|
||||
|
||||
<el-sub-menu index="language" popper-class="ir-lang-popper">
|
||||
<template #title>
|
||||
<el-icon><Flag /></el-icon>
|
||||
<span>{{ t('nav.language') }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
index="lang-ru"
|
||||
:class="{ 'is-locale-active': locale === 'ru' }"
|
||||
>
|
||||
{{ t('lang.ru') }}
|
||||
</el-menu-item>
|
||||
<el-menu-item
|
||||
index="lang-en"
|
||||
:class="{ 'is-locale-active': locale === 'en' }"
|
||||
>
|
||||
{{ t('lang.en') }}
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-menu>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Histogram, List, Coin } from '@element-plus/icons-vue'
|
||||
import { Histogram, List, Coin, Fold, Expand, Flag } from '@element-plus/icons-vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { getAppConfig } from '@/config/appConfig'
|
||||
import i18n, { setAppLocale } from '@/i18n'
|
||||
|
||||
const COLLAPSE_KEY = 'irandom:sidebar-collapsed'
|
||||
|
||||
export default {
|
||||
name: 'ModeSidebar',
|
||||
components: {
|
||||
Histogram,
|
||||
List,
|
||||
Coin
|
||||
Coin,
|
||||
Fold,
|
||||
Expand,
|
||||
Flag
|
||||
},
|
||||
props: {
|
||||
showBrand: {
|
||||
@@ -42,15 +93,76 @@ export default {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['collapse-change'],
|
||||
setup() {
|
||||
const { t, locale } = useI18n()
|
||||
return { t, locale }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config: getAppConfig(),
|
||||
isCollapsed: false,
|
||||
isMobile: false,
|
||||
modes: [
|
||||
{ id: 'range', to: '/range', label: 'Диапазон', icon: 'Histogram' },
|
||||
{ id: 'list', to: '/list', label: 'Список', icon: 'List' },
|
||||
{ id: 'coin', to: '/coin', label: 'Орёл / решка', icon: 'Coin' }
|
||||
{ id: 'range', to: '/range', labelKey: 'nav.range', icon: 'Histogram' },
|
||||
{ id: 'list', to: '/list', labelKey: 'nav.list', icon: 'List' },
|
||||
{ id: 'coin', to: '/coin', labelKey: 'nav.coin', icon: 'Coin' }
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
activeIndex() {
|
||||
return this.$route.path
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.isCollapsed = this.readCollapsed()
|
||||
},
|
||||
mounted() {
|
||||
this.updateMobile()
|
||||
window.addEventListener('resize', this.updateMobile)
|
||||
this.$emit('collapse-change', this.isCollapsed && !this.isMobile)
|
||||
},
|
||||
beforeUnmount() {
|
||||
window.removeEventListener('resize', this.updateMobile)
|
||||
},
|
||||
methods: {
|
||||
readCollapsed() {
|
||||
try {
|
||||
return localStorage.getItem(COLLAPSE_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
persistCollapsed() {
|
||||
try {
|
||||
localStorage.setItem(COLLAPSE_KEY, this.isCollapsed ? '1' : '0')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
toggleCollapse() {
|
||||
this.isCollapsed = !this.isCollapsed
|
||||
this.persistCollapsed()
|
||||
this.$emit('collapse-change', this.isCollapsed && !this.isMobile)
|
||||
},
|
||||
updateMobile() {
|
||||
this.isMobile = window.matchMedia('(max-width: 424px)').matches
|
||||
this.$emit('collapse-change', this.isCollapsed && !this.isMobile)
|
||||
},
|
||||
onMenuSelect(index) {
|
||||
if (index === 'lang-ru') {
|
||||
setAppLocale(i18n, 'ru')
|
||||
return
|
||||
}
|
||||
if (index === 'lang-en') {
|
||||
setAppLocale(i18n, 'en')
|
||||
return
|
||||
}
|
||||
if (index.startsWith('/') && this.$route.path !== index) {
|
||||
this.$router.push(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -59,9 +171,9 @@ export default {
|
||||
.mode-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: 0.35rem;
|
||||
height: 100%;
|
||||
padding: 1rem 0.75rem;
|
||||
padding: 1rem 0.5rem;
|
||||
background: var(--ir-surface);
|
||||
border-right: 1px solid var(--ir-border);
|
||||
}
|
||||
@@ -70,10 +182,11 @@ export default {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.35rem 0.5rem 1rem;
|
||||
margin-bottom: 0.25rem;
|
||||
padding: 0.35rem 0.5rem 0.85rem;
|
||||
margin-bottom: 0.15rem;
|
||||
border-bottom: 1px solid var(--ir-border);
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo {
|
||||
@@ -90,93 +203,120 @@ export default {
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--ir-primary);
|
||||
transition: color 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand:hover .brand-name {
|
||||
color: color-mix(in srgb, var(--ir-primary) 70%, white);
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
.collapse-btn {
|
||||
align-self: flex-end;
|
||||
appearance: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--ir-text-muted);
|
||||
padding: 0.85rem 0.75rem;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
font: inherit;
|
||||
font-size: 0.95rem;
|
||||
padding: 0.45rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
||||
transition: background 0.15s ease, color 0.15s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--ir-surface-raised);
|
||||
color: var(--ir-text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(var(--ir-primary-rgb), 0.18);
|
||||
border-color: var(--ir-primary);
|
||||
color: var(--ir-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
flex-shrink: 0;
|
||||
.side-menu {
|
||||
border-right: none !important;
|
||||
width: 100%;
|
||||
background: transparent !important;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.side-menu:not(.el-menu--collapse) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item),
|
||||
:deep(.el-sub-menu__title) {
|
||||
border-radius: 8px;
|
||||
margin: 0.15rem 0;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background: rgba(var(--ir-primary-rgb), 0.18) !important;
|
||||
border: 1px solid var(--ir-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item:hover),
|
||||
:deep(.el-sub-menu__title:hover) {
|
||||
background: var(--ir-surface-raised) !important;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-locale-active) {
|
||||
color: var(--ir-primary) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
.brand {
|
||||
justify-content: center;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 424px) {
|
||||
.mode-sidebar {
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
justify-content: space-around;
|
||||
gap: 0;
|
||||
height: auto;
|
||||
padding: 0.35rem 0.25rem calc(0.35rem + env(safe-area-inset-bottom, 0px));
|
||||
padding: 0.2rem 0.15rem calc(0.2rem + env(safe-area-inset-bottom, 0px));
|
||||
border-right: none;
|
||||
border-top: 1px solid var(--ir-border);
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.35);
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
.side-menu {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item),
|
||||
:deep(.el-sub-menu) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item),
|
||||
:deep(.el-sub-menu__title) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
padding: 0.55rem 0.25rem;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
font-size: 0.7rem;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
|
||||
&:hover {
|
||||
background: transparent;
|
||||
color: var(--ir-text);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--ir-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.mode-label {
|
||||
height: auto;
|
||||
line-height: 1.2;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0.45rem 0.2rem !important;
|
||||
border: none !important;
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item.is-active) {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
:deep(.el-sub-menu .el-sub-menu__icon-arrow) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="result-display">
|
||||
<div class="result-label">Результат</div>
|
||||
<div class="result-label">{{ t('result.label') }}</div>
|
||||
<div v-if="hasResult" class="result-value">
|
||||
<template v-if="Array.isArray(result)">
|
||||
<span v-for="(item, index) in result" :key="index" class="result-chip">
|
||||
@@ -11,11 +11,13 @@
|
||||
{{ result }}
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="result-empty">Нажмите «Сгенерировать»</div>
|
||||
<div v-else class="result-empty">{{ t('result.empty') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
export default {
|
||||
name: 'ResultDisplay',
|
||||
props: {
|
||||
@@ -24,6 +26,10 @@ export default {
|
||||
default: null
|
||||
}
|
||||
},
|
||||
setup() {
|
||||
const { t } = useI18n()
|
||||
return { t }
|
||||
},
|
||||
computed: {
|
||||
hasResult() {
|
||||
if (this.result === null || this.result === undefined || this.result === '') {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<template>
|
||||
<el-form label-position="top" class="generator-form" @submit.prevent="onGenerate">
|
||||
<el-form-item label="Количество бросков">
|
||||
<el-form-item :label="t('coin.flips')">
|
||||
<el-input-number v-model="count" :min="1" :max="100" :controls="true" />
|
||||
</el-form-item>
|
||||
<p class="hint">Каждый бросок даёт «орёл» или «решку».</p>
|
||||
<p class="hint">{{ t('coin.hint') }}</p>
|
||||
<el-button type="primary" native-type="submit" size="large">
|
||||
Сгенерировать
|
||||
{{ t('coin.generate') }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { flipCoin } from '@/utils/random'
|
||||
import { loadFormState, saveFormState } from '@/utils/persistForm'
|
||||
|
||||
@@ -23,6 +24,10 @@ const DEFAULTS = {
|
||||
export default {
|
||||
name: 'CoinGenerator',
|
||||
emits: ['result'],
|
||||
setup() {
|
||||
const { t } = useI18n()
|
||||
return { t }
|
||||
},
|
||||
data() {
|
||||
return loadFormState(STORAGE_KEY, DEFAULTS)
|
||||
},
|
||||
@@ -37,10 +42,10 @@ export default {
|
||||
},
|
||||
onGenerate() {
|
||||
if (this.count < 1) {
|
||||
ElMessage.error('Количество должно быть не меньше 1')
|
||||
ElMessage.error(this.t('coin.errCount'))
|
||||
return
|
||||
}
|
||||
const flips = flipCoin(this.count)
|
||||
const flips = flipCoin(this.count).map((side) => this.t(`coin.${side}`))
|
||||
this.$emit('result', this.count === 1 ? flips[0] : flips)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
<template>
|
||||
<el-form label-position="top" class="generator-form" @submit.prevent="onGenerate">
|
||||
<el-form-item label="Список значений (по одному на строку)">
|
||||
<el-form-item :label="t('list.values')">
|
||||
<el-input
|
||||
v-model="rawList"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
placeholder="яблоко банан вишня"
|
||||
:placeholder="t('list.placeholder')"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Количество">
|
||||
<el-form-item :label="t('list.count')">
|
||||
<el-input-number v-model="count" :min="1" :controls="true" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="unique">Только уникальные значения</el-checkbox>
|
||||
<el-checkbox v-model="unique">{{ t('list.unique') }}</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-button type="primary" native-type="submit" size="large">
|
||||
Сгенерировать
|
||||
{{ t('list.generate') }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { generateFromList } from '@/utils/random'
|
||||
import { loadFormState, saveFormState } from '@/utils/persistForm'
|
||||
|
||||
@@ -35,6 +36,10 @@ const DEFAULTS = {
|
||||
export default {
|
||||
name: 'ListGenerator',
|
||||
emits: ['result'],
|
||||
setup() {
|
||||
const { t } = useI18n()
|
||||
return { t }
|
||||
},
|
||||
data() {
|
||||
return loadFormState(STORAGE_KEY, DEFAULTS)
|
||||
},
|
||||
@@ -61,17 +66,23 @@ export default {
|
||||
},
|
||||
onGenerate() {
|
||||
if (!this.items.length) {
|
||||
ElMessage.error('Список пуст — добавьте хотя бы одно значение')
|
||||
ElMessage.error(this.t('list.errEmpty'))
|
||||
return
|
||||
}
|
||||
if (this.count < 1) {
|
||||
ElMessage.error('Количество должно быть не меньше 1')
|
||||
ElMessage.error(this.t('list.errCount'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.$emit('result', generateFromList(this.items, this.count, this.unique))
|
||||
} catch (err) {
|
||||
ElMessage.error(err.message)
|
||||
const key =
|
||||
err.code === 'LIST_UNIQUE_TOO_LARGE'
|
||||
? 'list.errUniqueList'
|
||||
: err.code === 'LIST_EMPTY'
|
||||
? 'list.errEmpty'
|
||||
: 'list.errCount'
|
||||
ElMessage.error(this.t(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
<template>
|
||||
<el-form label-position="top" class="generator-form" @submit.prevent="onGenerate">
|
||||
<el-form-item label="Минимум">
|
||||
<el-form-item :label="t('range.min')">
|
||||
<el-input-number v-model="min" :controls="true" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Максимум">
|
||||
<el-form-item :label="t('range.max')">
|
||||
<el-input-number v-model="max" :controls="true" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Количество">
|
||||
<el-form-item :label="t('range.count')">
|
||||
<el-input-number v-model="count" :min="1" :controls="true" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="unique">Только уникальные значения</el-checkbox>
|
||||
<el-checkbox v-model="unique">{{ t('range.unique') }}</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-button type="primary" native-type="submit" size="large">
|
||||
Сгенерировать
|
||||
{{ t('range.generate') }}
|
||||
</el-button>
|
||||
</el-form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { generateRange } from '@/utils/random'
|
||||
import { loadFormState, saveFormState } from '@/utils/persistForm'
|
||||
|
||||
@@ -34,6 +35,10 @@ const DEFAULTS = {
|
||||
export default {
|
||||
name: 'RangeGenerator',
|
||||
emits: ['result'],
|
||||
setup() {
|
||||
const { t } = useI18n()
|
||||
return { t }
|
||||
},
|
||||
data() {
|
||||
return loadFormState(STORAGE_KEY, DEFAULTS)
|
||||
},
|
||||
@@ -54,17 +59,18 @@ export default {
|
||||
},
|
||||
onGenerate() {
|
||||
if (this.min > this.max) {
|
||||
ElMessage.error('Минимум не может быть больше максимума')
|
||||
ElMessage.error(this.t('range.errMinMax'))
|
||||
return
|
||||
}
|
||||
if (this.count < 1) {
|
||||
ElMessage.error('Количество должно быть не меньше 1')
|
||||
ElMessage.error(this.t('range.errCount'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.$emit('result', generateRange(this.min, this.max, this.count, this.unique))
|
||||
} catch (err) {
|
||||
ElMessage.error(err.message)
|
||||
const key = err.code === 'RANGE_UNIQUE_TOO_LARGE' ? 'range.errUniqueRange' : 'range.errCount'
|
||||
ElMessage.error(this.t(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const DEFAULTS = {
|
||||
title: 'irandom',
|
||||
brandUrl: 'https://git.imiheev.online/admin/irandom',
|
||||
logoUrl: '/logo.png',
|
||||
defaultLocale: 'ru',
|
||||
colors: {
|
||||
primary: '#c9a66b',
|
||||
accent: '#e6c566',
|
||||
@@ -26,6 +27,7 @@ export function getAppConfig() {
|
||||
title: runtime.title || DEFAULTS.title,
|
||||
brandUrl: runtime.brandUrl || DEFAULTS.brandUrl,
|
||||
logoUrl: runtime.logoUrl || DEFAULTS.logoUrl,
|
||||
defaultLocale: runtime.defaultLocale || DEFAULTS.defaultLocale,
|
||||
colors
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import { getAppConfig } from '@/config/appConfig'
|
||||
import ru from './locales/ru'
|
||||
import en from './locales/en'
|
||||
|
||||
export const LOCALE_STORAGE_KEY = 'irandom:locale'
|
||||
export const SUPPORTED_LOCALES = ['ru', 'en']
|
||||
|
||||
function normalizeLocale(value) {
|
||||
const code = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.slice(0, 2)
|
||||
return SUPPORTED_LOCALES.includes(code) ? code : null
|
||||
}
|
||||
|
||||
export function resolveInitialLocale() {
|
||||
const stored = normalizeLocale(
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem(LOCALE_STORAGE_KEY) : null
|
||||
)
|
||||
if (stored) {
|
||||
return stored
|
||||
}
|
||||
|
||||
const fromEnv = normalizeLocale(getAppConfig().defaultLocale)
|
||||
return fromEnv || 'ru'
|
||||
}
|
||||
|
||||
export function persistLocale(locale) {
|
||||
const code = normalizeLocale(locale)
|
||||
if (!code) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(LOCALE_STORAGE_KEY, code)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function setAppLocale(i18n, locale) {
|
||||
const code = normalizeLocale(locale)
|
||||
if (!code) {
|
||||
return
|
||||
}
|
||||
i18n.global.locale.value = code
|
||||
persistLocale(code)
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = code
|
||||
}
|
||||
}
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: resolveInitialLocale(),
|
||||
fallbackLocale: 'ru',
|
||||
messages: { ru, en }
|
||||
})
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = i18n.global.locale.value
|
||||
}
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,53 @@
|
||||
export default {
|
||||
nav: {
|
||||
range: 'Range',
|
||||
list: 'List',
|
||||
coin: 'Heads / Tails',
|
||||
language: 'Language',
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand',
|
||||
modes: 'Generation modes'
|
||||
},
|
||||
lang: {
|
||||
ru: 'Русский',
|
||||
en: 'English'
|
||||
},
|
||||
range: {
|
||||
title: 'Numbers from a range',
|
||||
description: 'Generate several random integers within a chosen range.',
|
||||
min: 'Minimum',
|
||||
max: 'Maximum',
|
||||
count: 'Count',
|
||||
unique: 'Unique values only',
|
||||
generate: 'Generate',
|
||||
errMinMax: 'Minimum cannot be greater than maximum',
|
||||
errCount: 'Count must be at least 1',
|
||||
errUniqueRange: 'Unique count cannot exceed the size of the range'
|
||||
},
|
||||
list: {
|
||||
title: 'Values from a list',
|
||||
description: 'Enter a list and pick random items from it.',
|
||||
values: 'List of values (one per line)',
|
||||
placeholder: 'apple\nbanana\ncherry',
|
||||
count: 'Count',
|
||||
unique: 'Unique values only',
|
||||
generate: 'Generate',
|
||||
errEmpty: 'The list is empty — add at least one value',
|
||||
errCount: 'Count must be at least 1',
|
||||
errUniqueList: 'Unique count cannot exceed the list length'
|
||||
},
|
||||
coin: {
|
||||
title: 'Heads / Tails',
|
||||
description: 'Flip a coin once or several times.',
|
||||
flips: 'Number of flips',
|
||||
hint: 'Each flip is either heads or tails.',
|
||||
generate: 'Generate',
|
||||
errCount: 'Count must be at least 1',
|
||||
heads: 'heads',
|
||||
tails: 'tails'
|
||||
},
|
||||
result: {
|
||||
label: 'Result',
|
||||
empty: 'Press “Generate”'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export default {
|
||||
nav: {
|
||||
range: 'Диапазон',
|
||||
list: 'Список',
|
||||
coin: 'Орёл / решка',
|
||||
language: 'Язык',
|
||||
collapse: 'Свернуть',
|
||||
expand: 'Развернуть',
|
||||
modes: 'Режимы генерации'
|
||||
},
|
||||
lang: {
|
||||
ru: 'Русский',
|
||||
en: 'English'
|
||||
},
|
||||
range: {
|
||||
title: 'Числа из диапазона',
|
||||
description: 'Сгенерируйте несколько случайных чисел в заданном диапазоне.',
|
||||
min: 'Минимум',
|
||||
max: 'Максимум',
|
||||
count: 'Количество',
|
||||
unique: 'Только уникальные значения',
|
||||
generate: 'Сгенерировать',
|
||||
errMinMax: 'Минимум не может быть больше максимума',
|
||||
errCount: 'Количество должно быть не меньше 1',
|
||||
errUniqueRange: 'Количество уникальных значений не может превышать размер диапазона'
|
||||
},
|
||||
list: {
|
||||
title: 'Значения из списка',
|
||||
description: 'Введите список и выберите случайные элементы из него.',
|
||||
values: 'Список значений (по одному на строку)',
|
||||
placeholder: 'яблоко\nбанан\nвишня',
|
||||
count: 'Количество',
|
||||
unique: 'Только уникальные значения',
|
||||
generate: 'Сгенерировать',
|
||||
errEmpty: 'Список пуст — добавьте хотя бы одно значение',
|
||||
errCount: 'Количество должно быть не меньше 1',
|
||||
errUniqueList: 'Количество уникальных значений не может превышать длину списка'
|
||||
},
|
||||
coin: {
|
||||
title: 'Орёл / решка',
|
||||
description: 'Подбросьте монетку один или несколько раз.',
|
||||
flips: 'Количество бросков',
|
||||
hint: 'Каждый бросок даёт «орёл» или «решку».',
|
||||
generate: 'Сгенерировать',
|
||||
errCount: 'Количество должно быть не меньше 1',
|
||||
heads: 'орёл',
|
||||
tails: 'решка'
|
||||
},
|
||||
result: {
|
||||
label: 'Результат',
|
||||
empty: 'Нажмите «Сгенерировать»'
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -4,6 +4,7 @@ import 'element-plus/dist/index.css'
|
||||
import './styles/theme.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './i18n'
|
||||
import { getAppConfig } from './config/appConfig'
|
||||
import { applyTheme } from './config/applyTheme'
|
||||
|
||||
@@ -11,4 +12,4 @@ const appConfig = getAppConfig()
|
||||
applyTheme(appConfig)
|
||||
document.title = appConfig.title
|
||||
|
||||
createApp(App).use(router).use(ElementPlus).mount('#app')
|
||||
createApp(App).use(router).use(i18n).use(ElementPlus).mount('#app')
|
||||
|
||||
+3
-15
@@ -6,29 +6,17 @@ const routes = [
|
||||
{
|
||||
path: '/range',
|
||||
name: 'range',
|
||||
component: GeneratorView,
|
||||
meta: {
|
||||
title: 'Числа из диапазона',
|
||||
description: 'Сгенерируйте несколько случайных чисел в заданном диапазоне.'
|
||||
}
|
||||
component: GeneratorView
|
||||
},
|
||||
{
|
||||
path: '/list',
|
||||
name: 'list',
|
||||
component: GeneratorView,
|
||||
meta: {
|
||||
title: 'Значения из списка',
|
||||
description: 'Введите список и выберите случайные элементы из него.'
|
||||
}
|
||||
component: GeneratorView
|
||||
},
|
||||
{
|
||||
path: '/coin',
|
||||
name: 'coin',
|
||||
component: GeneratorView,
|
||||
meta: {
|
||||
title: 'Орёл / решка',
|
||||
description: 'Подбросьте монетку один или несколько раз.'
|
||||
}
|
||||
component: GeneratorView
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/range' }
|
||||
]
|
||||
|
||||
+11
-5
@@ -14,6 +14,12 @@ export function generateNumber(min, max) {
|
||||
return randomInt(min, max)
|
||||
}
|
||||
|
||||
function codedError(code) {
|
||||
const err = new Error(code)
|
||||
err.code = code
|
||||
return err
|
||||
}
|
||||
|
||||
/**
|
||||
* N random integers in [min, max].
|
||||
* When unique is true, values are distinct (count cannot exceed range size).
|
||||
@@ -25,7 +31,7 @@ export function generateRange(min, max, count, unique = false) {
|
||||
|
||||
if (unique) {
|
||||
if (count > span) {
|
||||
throw new Error('Количество уникальных значений не может превышать размер диапазона')
|
||||
throw codedError('RANGE_UNIQUE_TOO_LARGE')
|
||||
}
|
||||
const pool = Array.from({ length: span }, (_, i) => lo + i)
|
||||
for (let i = pool.length - 1; i > 0; i -= 1) {
|
||||
@@ -44,12 +50,12 @@ export function generateRange(min, max, count, unique = false) {
|
||||
*/
|
||||
export function generateFromList(items, count, unique = false) {
|
||||
if (!items.length) {
|
||||
throw new Error('Список пуст')
|
||||
throw codedError('LIST_EMPTY')
|
||||
}
|
||||
|
||||
if (unique) {
|
||||
if (count > items.length) {
|
||||
throw new Error('Количество уникальных значений не может превышать длину списка')
|
||||
throw codedError('LIST_UNIQUE_TOO_LARGE')
|
||||
}
|
||||
const pool = [...items]
|
||||
for (let i = pool.length - 1; i > 0; i -= 1) {
|
||||
@@ -63,8 +69,8 @@ export function generateFromList(items, count, unique = false) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Coin flips: returns array of 'орёл' | 'решка'.
|
||||
* Coin flips: returns array of 'heads' | 'tails'.
|
||||
*/
|
||||
export function flipCoin(count = 1) {
|
||||
return Array.from({ length: count }, () => (Math.random() < 0.5 ? 'орёл' : 'решка'))
|
||||
return Array.from({ length: count }, () => (Math.random() < 0.5 ? 'heads' : 'tails'))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import ResultDisplay from '@/components/ResultDisplay.vue'
|
||||
import RangeGenerator from '@/components/generators/RangeGenerator.vue'
|
||||
import ListGenerator from '@/components/generators/ListGenerator.vue'
|
||||
@@ -20,6 +21,12 @@ const GENERATORS = {
|
||||
coin: CoinGenerator
|
||||
}
|
||||
|
||||
const META_KEYS = {
|
||||
range: { title: 'range.title', description: 'range.description' },
|
||||
list: { title: 'list.title', description: 'list.description' },
|
||||
coin: { title: 'coin.title', description: 'coin.description' }
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'GeneratorView',
|
||||
components: {
|
||||
@@ -28,6 +35,10 @@ export default {
|
||||
ListGenerator,
|
||||
CoinGenerator
|
||||
},
|
||||
setup() {
|
||||
const { t } = useI18n()
|
||||
return { t }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
result: null
|
||||
@@ -35,10 +46,12 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
title() {
|
||||
return this.$route.meta.title ?? ''
|
||||
const keys = META_KEYS[this.$route.name]
|
||||
return keys ? this.t(keys.title) : ''
|
||||
},
|
||||
description() {
|
||||
return this.$route.meta.description ?? ''
|
||||
const keys = META_KEYS[this.$route.name]
|
||||
return keys ? this.t(keys.description) : ''
|
||||
},
|
||||
generatorComponent() {
|
||||
return GENERATORS[this.$route.name] ?? RangeGenerator
|
||||
|
||||
Reference in New Issue
Block a user