Сделал фронтенд

This commit is contained in:
Ilia Miheev
2026-07-24 21:42:08 +03:00
parent 5c1302a582
commit 572ddc35fc
23 changed files with 13958 additions and 9 deletions
+20 -8
View File
@@ -1,11 +1,23 @@
# ---> Vue .DS_Store
# gitignore template for Vue.js projects /node_modules
# /dist
# Recommended template: Node.gitignore
# TODO: where does this rule come from?
docs/_book
# TODO: where does this rule come from? # local env files
test/ .env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+22 -1
View File
@@ -1,3 +1,24 @@
# irandom # irandom
Проект для работы со случайностью ## Project setup
```
npm install
```
### Compiles and hot-reloads for development
```
npm run serve
```
### Compiles and minifies for production
```
npm run build
```
### Lints and fixes files
```
npm run lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).
+5
View File
@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}
+12960
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "irandom",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"core-js": "^3.8.3",
"element-plus": "^2.14.3",
"vue": "^3.2.13",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-plugin-vue": "^8.0.3",
"sass": "^1.32.7",
"sass-loader": "^12.0.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/vue3-essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "@babel/eslint-parser"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>logo.png">
<title>irandom — простой генератор случайностей</title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+100
View File
@@ -0,0 +1,100 @@
<template>
<el-container class="app-shell">
<el-aside width="220px" class="aside desktop-aside">
<ModeSidebar show-brand />
</el-aside>
<el-container class="main-column">
<el-header height="auto" class="header mobile-header">
<AppHeader />
</el-header>
<el-main class="main">
<RouterView />
</el-main>
</el-container>
<nav class="mobile-toolbar" aria-label="Режимы генерации">
<ModeSidebar />
</nav>
</el-container>
</template>
<script>
import AppHeader from './components/AppHeader.vue'
import ModeSidebar from './components/ModeSidebar.vue'
export default {
name: 'App',
components: {
AppHeader,
ModeSidebar
}
}
</script>
<style lang="scss">
.app-shell {
height: 100%;
background: var(--ir-bg);
}
.aside {
background: var(--ir-surface);
overflow: hidden;
padding: 0;
.mode-sidebar {
height: 100%;
}
}
.main-column {
min-width: 0;
}
.header {
padding: 0;
background: var(--ir-surface);
}
.mobile-header {
display: none;
}
.main {
padding: 1.5rem 2rem 2.5rem;
background: var(--ir-bg);
}
.mobile-toolbar {
display: none;
}
@media (max-width: 424px) {
.app-shell {
flex-direction: column !important;
padding-bottom: 72px;
}
.desktop-aside {
display: none !important;
}
.mobile-header {
display: block;
height: auto !important;
}
.main {
padding: 1rem 1rem 2rem;
}
.mobile-toolbar {
display: block;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
background: var(--ir-surface);
}
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+60
View File
@@ -0,0 +1,60 @@
<template>
<header class="app-header">
<a
class="brand"
href="https://git.imiheev.online/admin/irandom"
target="_blank"
rel="noopener noreferrer"
title="Gitea"
>
<img class="logo" src="/logo.png" alt="irandom" />
<span class="brand-name">irandom</span>
</a>
</header>
</template>
<script>
export default {
name: 'AppHeader'
}
</script>
<style scoped lang="scss">
.app-header {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 0.75rem 1rem;
background: var(--ir-surface);
border-bottom: 1px solid var(--ir-border);
}
.brand {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
text-decoration: none;
}
.logo {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.brand-name {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ir-primary);
transition: color 0.15s ease;
}
.brand:hover .brand-name {
color: var(--ir-accent);
}
</style>
+181
View File
@@ -0,0 +1,181 @@
<template>
<nav class="mode-sidebar" aria-label="Режимы генерации">
<a
v-if="showBrand"
class="brand"
href="https://git.imiheev.online/admin/irandom"
target="_blank"
rel="noopener noreferrer"
title="Gitea"
>
<img class="logo" src="/logo.png" alt="irandom" />
<span class="brand-name">irandom</span>
</a>
<RouterLink
v-for="mode in modes"
:key="mode.id"
:to="mode.to"
class="mode-item"
active-class="active"
>
<el-icon class="mode-icon" :size="22">
<component :is="mode.icon" />
</el-icon>
<span class="mode-label">{{ mode.label }}</span>
</RouterLink>
</nav>
</template>
<script>
import { Histogram, List, Coin } from '@element-plus/icons-vue'
export default {
name: 'ModeSidebar',
components: {
Histogram,
List,
Coin
},
props: {
showBrand: {
type: Boolean,
default: false
}
},
data() {
return {
modes: [
{ id: 'range', to: '/range', label: 'Диапазон', icon: 'Histogram' },
{ id: 'list', to: '/list', label: 'Список', icon: 'List' },
{ id: 'coin', to: '/coin', label: 'Орёл / решка', icon: 'Coin' }
]
}
}
}
</script>
<style scoped lang="scss">
.mode-sidebar {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: 100%;
padding: 1rem 0.75rem;
background: var(--ir-surface);
border-right: 1px solid var(--ir-border);
}
.brand {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.35rem 0.5rem 1rem;
margin-bottom: 0.25rem;
border-bottom: 1px solid var(--ir-border);
text-decoration: none;
}
.logo {
width: 36px;
height: 36px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.brand-name {
font-size: 1.15rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ir-primary);
transition: color 0.15s ease;
}
.brand:hover .brand-name {
color: var(--ir-accent);
}
.mode-item {
display: flex;
align-items: center;
gap: 0.65rem;
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;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
&:hover {
background: var(--ir-surface-raised);
color: var(--ir-text);
}
&.active {
background: rgba(201, 166, 107, 0.18);
border-color: var(--ir-primary);
color: var(--ir-accent);
font-weight: 600;
}
}
.mode-icon {
flex-shrink: 0;
}
@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));
border-right: none;
border-top: 1px solid var(--ir-border);
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.35);
}
.mode-item {
flex: 1;
flex-direction: column;
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-accent);
}
}
.mode-icon {
font-size: 1.35rem;
}
.mode-label {
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
</style>
+81
View File
@@ -0,0 +1,81 @@
<template>
<div class="result-display">
<div class="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">
{{ item }}
</span>
</template>
<template v-else>
{{ result }}
</template>
</div>
<div v-else class="result-empty">Нажмите «Сгенерировать»</div>
</div>
</template>
<script>
export default {
name: 'ResultDisplay',
props: {
result: {
type: [Number, String, Array],
default: null
}
},
computed: {
hasResult() {
if (this.result === null || this.result === undefined || this.result === '') {
return false
}
if (Array.isArray(this.result)) {
return this.result.length > 0
}
return true
}
}
}
</script>
<style scoped lang="scss">
.result-display {
margin-top: 1.5rem;
padding: 1.25rem 1.5rem;
background: var(--ir-surface);
border: 1px solid var(--ir-border);
border-radius: 12px;
min-height: 120px;
}
.result-label {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ir-text-muted);
margin-bottom: 0.75rem;
}
.result-value {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 2rem;
font-weight: 700;
color: var(--ir-accent);
line-height: 1.3;
word-break: break-word;
}
.result-chip {
display: inline-block;
padding: 0.15rem 0.65rem;
background: rgba(230, 197, 102, 0.12);
border-radius: 8px;
}
.result-empty {
color: var(--ir-text-muted);
font-size: 1rem;
}
</style>
@@ -0,0 +1,52 @@
<template>
<el-form label-position="top" class="generator-form" @submit.prevent="onGenerate">
<el-form-item label="Количество бросков">
<el-input-number v-model="count" :min="1" :max="100" :controls="true" />
</el-form-item>
<p class="hint">Каждый бросок даёт «орёл» или «решку».</p>
<el-button type="primary" native-type="submit" size="large">
Сгенерировать
</el-button>
</el-form>
</template>
<script>
import { ElMessage } from 'element-plus'
import { flipCoin } from '@/utils/random'
export default {
name: 'CoinGenerator',
emits: ['result'],
data() {
return {
count: 1
}
},
methods: {
onGenerate() {
if (this.count < 1) {
ElMessage.error('Количество должно быть не меньше 1')
return
}
const flips = flipCoin(this.count)
this.$emit('result', this.count === 1 ? flips[0] : flips)
}
}
}
</script>
<style scoped lang="scss">
.generator-form {
max-width: 420px;
}
.hint {
margin: 0 0 1.25rem;
color: var(--ir-text-muted);
font-size: 0.95rem;
}
:deep(.el-input-number) {
width: 100%;
}
</style>
@@ -0,0 +1,73 @@
<template>
<el-form label-position="top" class="generator-form" @submit.prevent="onGenerate">
<el-form-item label="Список значений (по одному на строку)">
<el-input
v-model="rawList"
type="textarea"
:rows="8"
placeholder="яблоко&#10;банан&#10;вишня"
/>
</el-form-item>
<el-form-item label="Количество">
<el-input-number v-model="count" :min="1" :controls="true" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="unique">Только уникальные значения</el-checkbox>
</el-form-item>
<el-button type="primary" native-type="submit" size="large">
Сгенерировать
</el-button>
</el-form>
</template>
<script>
import { ElMessage } from 'element-plus'
import { generateFromList } from '@/utils/random'
export default {
name: 'ListGenerator',
emits: ['result'],
data() {
return {
rawList: 'яблоко\nбанан\nвишня\nгруша\nдыня',
count: 1,
unique: false
}
},
computed: {
items() {
return this.rawList
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
}
},
methods: {
onGenerate() {
if (!this.items.length) {
ElMessage.error('Список пуст — добавьте хотя бы одно значение')
return
}
if (this.count < 1) {
ElMessage.error('Количество должно быть не меньше 1')
return
}
try {
this.$emit('result', generateFromList(this.items, this.count, this.unique))
} catch (err) {
ElMessage.error(err.message)
}
}
}
}
</script>
<style scoped lang="scss">
.generator-form {
max-width: 480px;
}
:deep(.el-input-number) {
width: 100%;
}
</style>
@@ -0,0 +1,64 @@
<template>
<el-form label-position="top" class="generator-form" @submit.prevent="onGenerate">
<el-form-item label="Минимум">
<el-input-number v-model="min" :controls="true" />
</el-form-item>
<el-form-item label="Максимум">
<el-input-number v-model="max" :controls="true" />
</el-form-item>
<el-form-item label="Количество">
<el-input-number v-model="count" :min="1" :controls="true" />
</el-form-item>
<el-form-item>
<el-checkbox v-model="unique">Только уникальные значения</el-checkbox>
</el-form-item>
<el-button type="primary" native-type="submit" size="large">
Сгенерировать
</el-button>
</el-form>
</template>
<script>
import { ElMessage } from 'element-plus'
import { generateRange } from '@/utils/random'
export default {
name: 'RangeGenerator',
emits: ['result'],
data() {
return {
min: 1,
max: 100,
count: 5,
unique: false
}
},
methods: {
onGenerate() {
if (this.min > this.max) {
ElMessage.error('Минимум не может быть больше максимума')
return
}
if (this.count < 1) {
ElMessage.error('Количество должно быть не меньше 1')
return
}
try {
this.$emit('result', generateRange(this.min, this.max, this.count, this.unique))
} catch (err) {
ElMessage.error(err.message)
}
}
}
}
</script>
<style scoped lang="scss">
.generator-form {
max-width: 420px;
}
:deep(.el-input-number) {
width: 100%;
}
</style>
+8
View File
@@ -0,0 +1,8 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import './styles/theme.css'
import App from './App.vue'
import router from './router'
createApp(App).use(router).use(ElementPlus).mount('#app')
+41
View File
@@ -0,0 +1,41 @@
import { createRouter, createWebHistory } from 'vue-router'
import GeneratorView from '@/views/GeneratorView.vue'
const routes = [
{ path: '/', redirect: '/range' },
{
path: '/range',
name: 'range',
component: GeneratorView,
meta: {
title: 'Числа из диапазона',
description: 'Сгенерируйте несколько случайных чисел в заданном диапазоне.'
}
},
{
path: '/list',
name: 'list',
component: GeneratorView,
meta: {
title: 'Значения из списка',
description: 'Введите список и выберите случайные элементы из него.'
}
},
{
path: '/coin',
name: 'coin',
component: GeneratorView,
meta: {
title: 'Орёл / решка',
description: 'Подбросьте монетку один или несколько раз.'
}
},
{ path: '/:pathMatch(.*)*', redirect: '/range' }
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
export default router
+54
View File
@@ -0,0 +1,54 @@
:root {
--ir-primary: #c9a66b;
--ir-accent: #e6c566;
--ir-bg: #1a1815;
--ir-surface: #2e2820;
--ir-surface-raised: #3a3228;
--ir-text: #ffffff;
--ir-text-muted: #c8c0b4;
--ir-danger: #ee5c5c;
--ir-link: #4a6a8a;
--ir-border: #4a4034;
--el-color-primary: var(--ir-primary);
--el-color-primary-light-3: #d4b882;
--el-color-primary-light-5: #dfcaa0;
--el-color-primary-light-7: #e9dbbd;
--el-color-primary-light-8: #eee4cc;
--el-color-primary-light-9: #f4ede0;
--el-color-primary-dark-2: #a18856;
--el-color-danger: var(--ir-danger);
--el-bg-color: var(--ir-surface);
--el-bg-color-overlay: var(--ir-surface-raised);
--el-text-color-primary: var(--ir-text);
--el-text-color-regular: var(--ir-text-muted);
--el-text-color-secondary: var(--ir-text-muted);
--el-border-color: var(--ir-border);
--el-border-color-light: var(--ir-border);
--el-fill-color-blank: var(--ir-surface);
--el-fill-color-light: var(--ir-surface-raised);
--el-mask-color: rgba(26, 24, 21, 0.8);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#app {
margin: 0;
padding: 0;
height: 100%;
background: var(--ir-bg);
color: var(--ir-text);
font-family: 'Segoe UI', 'Candara', 'Trebuchet MS', sans-serif;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Inclusive random integer in [min, max].
*/
export function randomInt(min, max) {
const lo = Math.ceil(min)
const hi = Math.floor(max)
return Math.floor(Math.random() * (hi - lo + 1)) + lo
}
/**
* One random integer in [min, max].
*/
export function generateNumber(min, max) {
return randomInt(min, max)
}
/**
* N random integers in [min, max].
* When unique is true, values are distinct (count cannot exceed range size).
*/
export function generateRange(min, max, count, unique = false) {
const lo = Math.ceil(min)
const hi = Math.floor(max)
const span = hi - lo + 1
if (unique) {
if (count > span) {
throw new Error('Количество уникальных значений не может превышать размер диапазона')
}
const pool = Array.from({ length: span }, (_, i) => lo + i)
for (let i = pool.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1))
;[pool[i], pool[j]] = [pool[j], pool[i]]
}
return pool.slice(0, count)
}
return Array.from({ length: count }, () => randomInt(lo, hi))
}
/**
* N random picks from items (with replacement).
* When unique is true, picks are distinct (count cannot exceed list length).
*/
export function generateFromList(items, count, unique = false) {
if (!items.length) {
throw new Error('Список пуст')
}
if (unique) {
if (count > items.length) {
throw new Error('Количество уникальных значений не может превышать длину списка')
}
const pool = [...items]
for (let i = pool.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1))
;[pool[i], pool[j]] = [pool[j], pool[i]]
}
return pool.slice(0, count)
}
return Array.from({ length: count }, () => items[randomInt(0, items.length - 1)])
}
/**
* Coin flips: returns array of 'орёл' | 'решка'.
*/
export function flipCoin(count = 1) {
return Array.from({ length: count }, () => (Math.random() < 0.5 ? 'орёл' : 'решка'))
}
+73
View File
@@ -0,0 +1,73 @@
<template>
<div class="generator-view">
<h1 class="page-title">{{ title }}</h1>
<p class="page-desc">{{ description }}</p>
<component :is="generatorComponent" @result="onResult" />
<ResultDisplay :result="result" />
</div>
</template>
<script>
import ResultDisplay from '@/components/ResultDisplay.vue'
import RangeGenerator from '@/components/generators/RangeGenerator.vue'
import ListGenerator from '@/components/generators/ListGenerator.vue'
import CoinGenerator from '@/components/generators/CoinGenerator.vue'
const GENERATORS = {
range: RangeGenerator,
list: ListGenerator,
coin: CoinGenerator
}
export default {
name: 'GeneratorView',
components: {
ResultDisplay,
RangeGenerator,
ListGenerator,
CoinGenerator
},
data() {
return {
result: null
}
},
computed: {
title() {
return this.$route.meta.title ?? ''
},
description() {
return this.$route.meta.description ?? ''
},
generatorComponent() {
return GENERATORS[this.$route.name] ?? RangeGenerator
}
},
watch: {
'$route.name'() {
this.result = null
}
},
methods: {
onResult(value) {
this.result = value
}
}
}
</script>
<style scoped lang="scss">
.page-title {
margin: 0 0 0.35rem;
font-size: 1.75rem;
font-weight: 700;
color: var(--ir-text);
}
.page-desc {
margin: 0 0 1.5rem;
color: var(--ir-text-muted);
max-width: 40rem;
}
</style>
+10
View File
@@ -0,0 +1,10 @@
const { defineConfig } = require('@vue/cli-service')
module.exports = defineConfig({
transpileDependencies: true,
pages: {
index: {
entry: 'src/main.js',
title: 'irandom'
}
}
})