Сделал сужение сайдбара и локализацию на английский язык #1 #3

This commit is contained in:
Ilia Miheev
2026-08-01 00:25:34 +03:00
parent d8e183a0e3
commit fac7574433
19 changed files with 604 additions and 134 deletions
+2
View File
@@ -10,6 +10,8 @@ VUE_APP_TITLE=irandom
VUE_APP_BRAND_URL=https://git.imiheev.online/admin/irandom VUE_APP_BRAND_URL=https://git.imiheev.online/admin/irandom
# Logo path or absolute URL (file should be in public/ if relative) # Logo path or absolute URL (file should be in public/ if relative)
VUE_APP_LOGO_URL=/logo.png 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) # Theme colors (CSS hex)
VUE_APP_COLOR_PRIMARY=#c9a66b VUE_APP_COLOR_PRIMARY=#c9a66b
+83
View File
@@ -11,6 +11,7 @@
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"element-plus": "^2.14.3", "element-plus": "^2.14.3",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "^11.4.8",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
@@ -653,6 +654,67 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause" "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": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -3201,6 +3263,27 @@
"eslint": ">=6.0.0" "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": { "node_modules/vue-router": {
"version": "4.6.4", "version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+1
View File
@@ -17,6 +17,7 @@
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"element-plus": "^2.14.3", "element-plus": "^2.14.3",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-i18n": "^11.4.8",
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
+1
View File
@@ -4,6 +4,7 @@ window.__APP_CONFIG__ = {
"title": "irandom", "title": "irandom",
"brandUrl": "https://git.imiheev.online/admin/irandom", "brandUrl": "https://git.imiheev.online/admin/irandom",
"logoUrl": "/logo.png", "logoUrl": "/logo.png",
"defaultLocale": "ru",
"colors": { "colors": {
"primary": "#c9a66b", "primary": "#c9a66b",
"accent": "#e6c566", "accent": "#e6c566",
+2
View File
@@ -10,6 +10,7 @@ const DEFAULTS = {
VUE_APP_TITLE: 'irandom', VUE_APP_TITLE: 'irandom',
VUE_APP_BRAND_URL: 'https://git.imiheev.online/admin/irandom', VUE_APP_BRAND_URL: 'https://git.imiheev.online/admin/irandom',
VUE_APP_LOGO_URL: '/logo.png', VUE_APP_LOGO_URL: '/logo.png',
VUE_APP_DEFAULT_LOCALE: 'ru',
VUE_APP_COLOR_PRIMARY: '#c9a66b', VUE_APP_COLOR_PRIMARY: '#c9a66b',
VUE_APP_COLOR_ACCENT: '#e6c566', VUE_APP_COLOR_ACCENT: '#e6c566',
VUE_APP_COLOR_BG: '#1a1815', VUE_APP_COLOR_BG: '#1a1815',
@@ -63,6 +64,7 @@ function buildConfig(env) {
title: pick('VUE_APP_TITLE'), title: pick('VUE_APP_TITLE'),
brandUrl: pick('VUE_APP_BRAND_URL'), brandUrl: pick('VUE_APP_BRAND_URL'),
logoUrl: pick('VUE_APP_LOGO_URL'), logoUrl: pick('VUE_APP_LOGO_URL'),
defaultLocale: pick('VUE_APP_DEFAULT_LOCALE'),
colors: { colors: {
primary: pick('VUE_APP_COLOR_PRIMARY'), primary: pick('VUE_APP_COLOR_PRIMARY'),
accent: pick('VUE_APP_COLOR_ACCENT'), accent: pick('VUE_APP_COLOR_ACCENT'),
+36 -3
View File
@@ -1,7 +1,8 @@
<template> <template>
<el-config-provider :locale="elementLocale">
<el-container class="app-shell"> <el-container class="app-shell">
<el-aside width="220px" class="aside desktop-aside"> <el-aside :width="asideWidth" class="aside desktop-aside">
<ModeSidebar show-brand /> <ModeSidebar show-brand @collapse-change="onCollapseChange" />
</el-aside> </el-aside>
<el-container class="main-column"> <el-container class="main-column">
<el-header height="auto" class="header mobile-header"> <el-header height="auto" class="header mobile-header">
@@ -11,13 +12,17 @@
<RouterView /> <RouterView />
</el-main> </el-main>
</el-container> </el-container>
<nav class="mobile-toolbar" aria-label="Режимы генерации"> <nav class="mobile-toolbar" :aria-label="$t('nav.modes')">
<ModeSidebar /> <ModeSidebar />
</nav> </nav>
</el-container> </el-container>
</el-config-provider>
</template> </template>
<script> <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 AppHeader from './components/AppHeader.vue'
import ModeSidebar from './components/ModeSidebar.vue' import ModeSidebar from './components/ModeSidebar.vue'
@@ -26,6 +31,28 @@ export default {
components: { components: {
AppHeader, AppHeader,
ModeSidebar 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> </script>
@@ -40,6 +67,7 @@ export default {
background: var(--ir-surface); background: var(--ir-surface);
overflow: hidden; overflow: hidden;
padding: 0; padding: 0;
transition: width 0.2s ease;
.mode-sidebar { .mode-sidebar {
height: 100%; height: 100%;
@@ -68,6 +96,11 @@ export default {
display: none; display: none;
} }
.ir-lang-popper {
background: var(--ir-surface) !important;
border: 1px solid var(--ir-border) !important;
}
@media (max-width: 424px) { @media (max-width: 424px) {
.app-shell { .app-shell {
flex-direction: column !important; flex-direction: column !important;
+210 -70
View File
@@ -1,5 +1,5 @@
<template> <template>
<nav class="mode-sidebar" aria-label="Режимы генерации"> <nav class="mode-sidebar" :class="{ collapsed: isCollapsed && !isMobile }" :aria-label="t('nav.modes')">
<a <a
v-if="showBrand" v-if="showBrand"
class="brand" class="brand"
@@ -8,33 +8,84 @@
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<img class="logo" :src="config.logoUrl" :alt="config.name" /> <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> </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" v-for="mode in modes"
:key="mode.id" :key="mode.id"
:to="mode.to" :index="mode.to"
class="mode-item"
active-class="active"
> >
<el-icon class="mode-icon" :size="22"> <el-icon>
<component :is="mode.icon" /> <component :is="mode.icon" />
</el-icon> </el-icon>
<span class="mode-label">{{ mode.label }}</span> <template #title>{{ t(mode.labelKey) }}</template>
</RouterLink> </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> </nav>
</template> </template>
<script> <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 { getAppConfig } from '@/config/appConfig'
import i18n, { setAppLocale } from '@/i18n'
const COLLAPSE_KEY = 'irandom:sidebar-collapsed'
export default { export default {
name: 'ModeSidebar', name: 'ModeSidebar',
components: { components: {
Histogram, Histogram,
List, List,
Coin Coin,
Fold,
Expand,
Flag
}, },
props: { props: {
showBrand: { showBrand: {
@@ -42,15 +93,76 @@ export default {
default: false default: false
} }
}, },
emits: ['collapse-change'],
setup() {
const { t, locale } = useI18n()
return { t, locale }
},
data() { data() {
return { return {
config: getAppConfig(), config: getAppConfig(),
isCollapsed: false,
isMobile: false,
modes: [ modes: [
{ id: 'range', to: '/range', label: 'Диапазон', icon: 'Histogram' }, { id: 'range', to: '/range', labelKey: 'nav.range', icon: 'Histogram' },
{ id: 'list', to: '/list', label: 'Список', icon: 'List' }, { id: 'list', to: '/list', labelKey: 'nav.list', icon: 'List' },
{ id: 'coin', to: '/coin', label: 'Орёл / решка', icon: 'Coin' } { 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> </script>
@@ -59,9 +171,9 @@ export default {
.mode-sidebar { .mode-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: 0.35rem;
height: 100%; height: 100%;
padding: 1rem 0.75rem; padding: 1rem 0.5rem;
background: var(--ir-surface); background: var(--ir-surface);
border-right: 1px solid var(--ir-border); border-right: 1px solid var(--ir-border);
} }
@@ -70,10 +182,11 @@ export default {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.65rem; gap: 0.65rem;
padding: 0.35rem 0.5rem 1rem; padding: 0.35rem 0.5rem 0.85rem;
margin-bottom: 0.25rem; margin-bottom: 0.15rem;
border-bottom: 1px solid var(--ir-border); border-bottom: 1px solid var(--ir-border);
text-decoration: none; text-decoration: none;
overflow: hidden;
} }
.logo { .logo {
@@ -90,93 +203,120 @@ export default {
letter-spacing: 0.02em; letter-spacing: 0.02em;
color: var(--ir-primary); color: var(--ir-primary);
transition: color 0.15s ease; transition: color 0.15s ease;
white-space: nowrap;
} }
.brand:hover .brand-name { .brand:hover .brand-name {
color: color-mix(in srgb, var(--ir-primary) 70%, white); color: color-mix(in srgb, var(--ir-primary) 70%, white);
} }
.mode-item { .collapse-btn {
display: flex; align-self: flex-end;
align-items: center;
gap: 0.65rem;
appearance: none; appearance: none;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 8px; border-radius: 8px;
background: transparent; background: transparent;
color: var(--ir-text-muted); color: var(--ir-text-muted);
padding: 0.85rem 0.75rem; padding: 0.45rem;
text-align: left;
text-decoration: none;
font: inherit;
font-size: 0.95rem;
cursor: pointer; 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 { &:hover {
background: var(--ir-surface-raised); background: var(--ir-surface-raised);
color: var(--ir-text); 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 { .side-menu {
flex-shrink: 0; 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) { @media (max-width: 424px) {
.mode-sidebar { .mode-sidebar {
flex-direction: row; flex-direction: row;
align-items: stretch; align-items: stretch;
justify-content: space-around;
gap: 0;
height: auto; 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-right: none;
border-top: 1px solid var(--ir-border); border-top: 1px solid var(--ir-border);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.35); 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; flex: 1;
}
:deep(.el-menu-item),
:deep(.el-sub-menu__title) {
display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
justify-content: center; justify-content: center;
gap: 0.2rem; height: auto;
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 {
line-height: 1.2; line-height: 1.2;
max-width: 100%; padding: 0.45rem 0.2rem !important;
overflow: hidden; border: none !important;
text-overflow: ellipsis; font-size: 0.65rem;
white-space: nowrap; }
:deep(.el-menu-item.is-active) {
background: transparent !important;
}
:deep(.el-sub-menu .el-sub-menu__icon-arrow) {
display: none;
} }
} }
</style> </style>
+8 -2
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="result-display"> <div class="result-display">
<div class="result-label">Результат</div> <div class="result-label">{{ t('result.label') }}</div>
<div v-if="hasResult" class="result-value"> <div v-if="hasResult" class="result-value">
<template v-if="Array.isArray(result)"> <template v-if="Array.isArray(result)">
<span v-for="(item, index) in result" :key="index" class="result-chip"> <span v-for="(item, index) in result" :key="index" class="result-chip">
@@ -11,11 +11,13 @@
{{ result }} {{ result }}
</template> </template>
</div> </div>
<div v-else class="result-empty">Нажмите «Сгенерировать»</div> <div v-else class="result-empty">{{ t('result.empty') }}</div>
</div> </div>
</template> </template>
<script> <script>
import { useI18n } from 'vue-i18n'
export default { export default {
name: 'ResultDisplay', name: 'ResultDisplay',
props: { props: {
@@ -24,6 +26,10 @@ export default {
default: null default: null
} }
}, },
setup() {
const { t } = useI18n()
return { t }
},
computed: { computed: {
hasResult() { hasResult() {
if (this.result === null || this.result === undefined || this.result === '') { if (this.result === null || this.result === undefined || this.result === '') {
+10 -5
View File
@@ -1,17 +1,18 @@
<template> <template>
<el-form label-position="top" class="generator-form" @submit.prevent="onGenerate"> <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-input-number v-model="count" :min="1" :max="100" :controls="true" />
</el-form-item> </el-form-item>
<p class="hint">Каждый бросок даёт «орёл» или «решку».</p> <p class="hint">{{ t('coin.hint') }}</p>
<el-button type="primary" native-type="submit" size="large"> <el-button type="primary" native-type="submit" size="large">
Сгенерировать {{ t('coin.generate') }}
</el-button> </el-button>
</el-form> </el-form>
</template> </template>
<script> <script>
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { flipCoin } from '@/utils/random' import { flipCoin } from '@/utils/random'
import { loadFormState, saveFormState } from '@/utils/persistForm' import { loadFormState, saveFormState } from '@/utils/persistForm'
@@ -23,6 +24,10 @@ const DEFAULTS = {
export default { export default {
name: 'CoinGenerator', name: 'CoinGenerator',
emits: ['result'], emits: ['result'],
setup() {
const { t } = useI18n()
return { t }
},
data() { data() {
return loadFormState(STORAGE_KEY, DEFAULTS) return loadFormState(STORAGE_KEY, DEFAULTS)
}, },
@@ -37,10 +42,10 @@ export default {
}, },
onGenerate() { onGenerate() {
if (this.count < 1) { if (this.count < 1) {
ElMessage.error('Количество должно быть не меньше 1') ElMessage.error(this.t('coin.errCount'))
return 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) this.$emit('result', this.count === 1 ? flips[0] : flips)
} }
} }
+19 -8
View File
@@ -1,27 +1,28 @@
<template> <template>
<el-form label-position="top" class="generator-form" @submit.prevent="onGenerate"> <el-form label-position="top" class="generator-form" @submit.prevent="onGenerate">
<el-form-item label="Список значений (по одному на строку)"> <el-form-item :label="t('list.values')">
<el-input <el-input
v-model="rawList" v-model="rawList"
type="textarea" type="textarea"
:rows="8" :rows="8"
placeholder="яблоко&#10;банан&#10;вишня" :placeholder="t('list.placeholder')"
/> />
</el-form-item> </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-input-number v-model="count" :min="1" :controls="true" />
</el-form-item> </el-form-item>
<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-form-item>
<el-button type="primary" native-type="submit" size="large"> <el-button type="primary" native-type="submit" size="large">
Сгенерировать {{ t('list.generate') }}
</el-button> </el-button>
</el-form> </el-form>
</template> </template>
<script> <script>
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { generateFromList } from '@/utils/random' import { generateFromList } from '@/utils/random'
import { loadFormState, saveFormState } from '@/utils/persistForm' import { loadFormState, saveFormState } from '@/utils/persistForm'
@@ -35,6 +36,10 @@ const DEFAULTS = {
export default { export default {
name: 'ListGenerator', name: 'ListGenerator',
emits: ['result'], emits: ['result'],
setup() {
const { t } = useI18n()
return { t }
},
data() { data() {
return loadFormState(STORAGE_KEY, DEFAULTS) return loadFormState(STORAGE_KEY, DEFAULTS)
}, },
@@ -61,17 +66,23 @@ export default {
}, },
onGenerate() { onGenerate() {
if (!this.items.length) { if (!this.items.length) {
ElMessage.error('Список пуст — добавьте хотя бы одно значение') ElMessage.error(this.t('list.errEmpty'))
return return
} }
if (this.count < 1) { if (this.count < 1) {
ElMessage.error('Количество должно быть не меньше 1') ElMessage.error(this.t('list.errCount'))
return return
} }
try { try {
this.$emit('result', generateFromList(this.items, this.count, this.unique)) this.$emit('result', generateFromList(this.items, this.count, this.unique))
} catch (err) { } 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))
} }
} }
} }
+14 -8
View File
@@ -1,25 +1,26 @@
<template> <template>
<el-form label-position="top" class="generator-form" @submit.prevent="onGenerate"> <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-input-number v-model="min" :controls="true" />
</el-form-item> </el-form-item>
<el-form-item label="Максимум"> <el-form-item :label="t('range.max')">
<el-input-number v-model="max" :controls="true" /> <el-input-number v-model="max" :controls="true" />
</el-form-item> </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-input-number v-model="count" :min="1" :controls="true" />
</el-form-item> </el-form-item>
<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-form-item>
<el-button type="primary" native-type="submit" size="large"> <el-button type="primary" native-type="submit" size="large">
Сгенерировать {{ t('range.generate') }}
</el-button> </el-button>
</el-form> </el-form>
</template> </template>
<script> <script>
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { generateRange } from '@/utils/random' import { generateRange } from '@/utils/random'
import { loadFormState, saveFormState } from '@/utils/persistForm' import { loadFormState, saveFormState } from '@/utils/persistForm'
@@ -34,6 +35,10 @@ const DEFAULTS = {
export default { export default {
name: 'RangeGenerator', name: 'RangeGenerator',
emits: ['result'], emits: ['result'],
setup() {
const { t } = useI18n()
return { t }
},
data() { data() {
return loadFormState(STORAGE_KEY, DEFAULTS) return loadFormState(STORAGE_KEY, DEFAULTS)
}, },
@@ -54,17 +59,18 @@ export default {
}, },
onGenerate() { onGenerate() {
if (this.min > this.max) { if (this.min > this.max) {
ElMessage.error('Минимум не может быть больше максимума') ElMessage.error(this.t('range.errMinMax'))
return return
} }
if (this.count < 1) { if (this.count < 1) {
ElMessage.error('Количество должно быть не меньше 1') ElMessage.error(this.t('range.errCount'))
return return
} }
try { try {
this.$emit('result', generateRange(this.min, this.max, this.count, this.unique)) this.$emit('result', generateRange(this.min, this.max, this.count, this.unique))
} catch (err) { } catch (err) {
ElMessage.error(err.message) const key = err.code === 'RANGE_UNIQUE_TOO_LARGE' ? 'range.errUniqueRange' : 'range.errCount'
ElMessage.error(this.t(key))
} }
} }
} }
+2
View File
@@ -3,6 +3,7 @@ const DEFAULTS = {
title: 'irandom', title: 'irandom',
brandUrl: 'https://git.imiheev.online/admin/irandom', brandUrl: 'https://git.imiheev.online/admin/irandom',
logoUrl: '/logo.png', logoUrl: '/logo.png',
defaultLocale: 'ru',
colors: { colors: {
primary: '#c9a66b', primary: '#c9a66b',
accent: '#e6c566', accent: '#e6c566',
@@ -26,6 +27,7 @@ export function getAppConfig() {
title: runtime.title || DEFAULTS.title, title: runtime.title || DEFAULTS.title,
brandUrl: runtime.brandUrl || DEFAULTS.brandUrl, brandUrl: runtime.brandUrl || DEFAULTS.brandUrl,
logoUrl: runtime.logoUrl || DEFAULTS.logoUrl, logoUrl: runtime.logoUrl || DEFAULTS.logoUrl,
defaultLocale: runtime.defaultLocale || DEFAULTS.defaultLocale,
colors colors
} }
} }
+64
View File
@@ -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
+53
View File
@@ -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”'
}
}
+53
View File
@@ -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
View File
@@ -4,6 +4,7 @@ import 'element-plus/dist/index.css'
import './styles/theme.css' import './styles/theme.css'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import i18n from './i18n'
import { getAppConfig } from './config/appConfig' import { getAppConfig } from './config/appConfig'
import { applyTheme } from './config/applyTheme' import { applyTheme } from './config/applyTheme'
@@ -11,4 +12,4 @@ const appConfig = getAppConfig()
applyTheme(appConfig) applyTheme(appConfig)
document.title = appConfig.title document.title = appConfig.title
createApp(App).use(router).use(ElementPlus).mount('#app') createApp(App).use(router).use(i18n).use(ElementPlus).mount('#app')
+3 -15
View File
@@ -6,29 +6,17 @@ const routes = [
{ {
path: '/range', path: '/range',
name: 'range', name: 'range',
component: GeneratorView, component: GeneratorView
meta: {
title: 'Числа из диапазона',
description: 'Сгенерируйте несколько случайных чисел в заданном диапазоне.'
}
}, },
{ {
path: '/list', path: '/list',
name: 'list', name: 'list',
component: GeneratorView, component: GeneratorView
meta: {
title: 'Значения из списка',
description: 'Введите список и выберите случайные элементы из него.'
}
}, },
{ {
path: '/coin', path: '/coin',
name: 'coin', name: 'coin',
component: GeneratorView, component: GeneratorView
meta: {
title: 'Орёл / решка',
description: 'Подбросьте монетку один или несколько раз.'
}
}, },
{ path: '/:pathMatch(.*)*', redirect: '/range' } { path: '/:pathMatch(.*)*', redirect: '/range' }
] ]
+11 -5
View File
@@ -14,6 +14,12 @@ export function generateNumber(min, max) {
return randomInt(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]. * N random integers in [min, max].
* When unique is true, values are distinct (count cannot exceed range size). * 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 (unique) {
if (count > span) { if (count > span) {
throw new Error('Количество уникальных значений не может превышать размер диапазона') throw codedError('RANGE_UNIQUE_TOO_LARGE')
} }
const pool = Array.from({ length: span }, (_, i) => lo + i) const pool = Array.from({ length: span }, (_, i) => lo + i)
for (let i = pool.length - 1; i > 0; i -= 1) { 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) { export function generateFromList(items, count, unique = false) {
if (!items.length) { if (!items.length) {
throw new Error('Список пуст') throw codedError('LIST_EMPTY')
} }
if (unique) { if (unique) {
if (count > items.length) { if (count > items.length) {
throw new Error('Количество уникальных значений не может превышать длину списка') throw codedError('LIST_UNIQUE_TOO_LARGE')
} }
const pool = [...items] const pool = [...items]
for (let i = pool.length - 1; i > 0; i -= 1) { 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) { 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'))
} }
+15 -2
View File
@@ -9,6 +9,7 @@
</template> </template>
<script> <script>
import { useI18n } from 'vue-i18n'
import ResultDisplay from '@/components/ResultDisplay.vue' import ResultDisplay from '@/components/ResultDisplay.vue'
import RangeGenerator from '@/components/generators/RangeGenerator.vue' import RangeGenerator from '@/components/generators/RangeGenerator.vue'
import ListGenerator from '@/components/generators/ListGenerator.vue' import ListGenerator from '@/components/generators/ListGenerator.vue'
@@ -20,6 +21,12 @@ const GENERATORS = {
coin: CoinGenerator 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 { export default {
name: 'GeneratorView', name: 'GeneratorView',
components: { components: {
@@ -28,6 +35,10 @@ export default {
ListGenerator, ListGenerator,
CoinGenerator CoinGenerator
}, },
setup() {
const { t } = useI18n()
return { t }
},
data() { data() {
return { return {
result: null result: null
@@ -35,10 +46,12 @@ export default {
}, },
computed: { computed: {
title() { title() {
return this.$route.meta.title ?? '' const keys = META_KEYS[this.$route.name]
return keys ? this.t(keys.title) : ''
}, },
description() { description() {
return this.$route.meta.description ?? '' const keys = META_KEYS[this.$route.name]
return keys ? this.t(keys.description) : ''
}, },
generatorComponent() { generatorComponent() {
return GENERATORS[this.$route.name] ?? RangeGenerator return GENERATORS[this.$route.name] ?? RangeGenerator