initial commit

This commit is contained in:
2026-04-12 15:55:04 +03:00
commit c0da5a8348
35 changed files with 5877 additions and 0 deletions

1
.env Normal file
View File

@@ -0,0 +1 @@
VITE_USE_MOCK_AUTH=true

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:25.8.2-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
# Clean install with explicit cache clean
RUN npm cache clean --force && \
rm -rf node_modules && \
npm install
# Copy application code
COPY . .
# Expose port
EXPOSE 5173
# Run dev server
CMD ["npm", "run", "dev", "--host"]

5
README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

13
index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>resugen-frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2533
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "resugen-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.15.0",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.2.2",
"@vitejs/plugin-vue": "^6.0.5",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.9",
"tailwindcss": "^4.2.2",
"vite": "^8.0.4"
}
}

1
public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

11
src/App.vue Normal file
View File

@@ -0,0 +1,11 @@
<script setup>
import AppHeader from '@/components/AppHeader.vue'
// Никаких вызовов initMockData здесь!
</script>
<template>
<div>
<AppHeader />
<router-view />
</div>
</template>

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

38
src/assets/main.css Normal file
View File

@@ -0,0 +1,38 @@
@import "tailwindcss";
@theme {
--color-off-white: #F7F7F7;
--color-accent-blue: #2563EB;
--color-dark-gray: #111827;
}
/* Стили для печати резюме */
@media print {
/* Скрываем всё, кроме контейнера с резюме */
body * {
visibility: hidden;
}
/* Показываем только блок резюме и его содержимое */
.print-resume-container,
.print-resume-container * {
visibility: visible;
}
/* Убираем лишние отступы и фон */
.print-resume-container {
position: absolute;
left: 0;
top: 0;
width: 100%;
background: white;
box-shadow: none;
padding: 0 !important;
margin: 0 !important;
}
/* Скрываем кнопки, ссылки и декоративные элементы внутри резюме (по желанию) */
.no-print {
display: none !important;
}
}

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

1
src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,32 @@
<script setup>
import { useAuthStore } from '@/stores/auth'
import { useRouter } from 'vue-router'
const authStore = useAuthStore()
const router = useRouter()
const logout = () => {
authStore.logout()
router.push('/')
}
</script>
<template>
<header class="bg-white border-b border-gray-100">
<div class="mx-auto max-w-6xl px-6 py-4 flex items-center justify-between">
<router-link to="/" class="text-2xl font-bold text-accent-blue">Resugen</router-link>
<nav class="flex items-center gap-6">
<router-link to="/about" class="text-dark-gray hover:text-accent-blue">О платформе</router-link>
<template v-if="authStore.isAuthenticated">
<router-link to="/dashboard" class="text-dark-gray hover:text-accent-blue">Кабинет</router-link>
<router-link to="/security" class="text-dark-gray hover:text-accent-blue">Аккаунт</router-link>
<button @click="logout" class="text-dark-gray hover:text-accent-blue">Выйти</button>
</template>
<template v-else>
<router-link to="/login" class="text-dark-gray hover:text-accent-blue">Вход</router-link>
<router-link to="/register" class="bg-accent-blue text-white px-4 py-2 rounded-lg hover:bg-blue-700">Регистрация</router-link>
</template>
</nav>
</div>
</header>
</template>

14
src/main.js Normal file
View File

@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // если нужен persist
import App from './App.vue'
import router from './router'
import './assets/main.css'
const pinia = createPinia()
pinia.use(piniaPluginPersistedstate) // подключаем плагин
const app = createApp(App)
app.use(pinia)
app.use(router)
app.mount('#app')

82
src/router/index.js Normal file
View File

@@ -0,0 +1,82 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
},
{
path: '/register',
name: 'register',
component: () => import('../views/RegisterView.vue'),
},
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue'),
},
{
path: '/dashboard',
name: 'dashboard',
component: () => import('../views/DashboardView.vue'),
meta: { requiresAuth: true },
},
{
path: '/security',
name: 'security',
component: () => import('../views/SecurityView.vue'),
meta: { requiresAuth: true },
},
{
path: '/about',
name: 'about',
component: () => import('../views/AboutView.vue'),
},
// Создание новой анкеты
{
path: '/questionnaires/create',
name: 'questionnaire-create',
component: () => import('../views/QuestionnaireFormView.vue'),
meta: { requiresAuth: true },
},
// Редактирование существующей
{
path: '/questionnaires/:id/edit',
name: 'questionnaire-edit',
component: () => import('../views/QuestionnaireFormView.vue'),
meta: { requiresAuth: true },
props: true,
},
{
path: '/vacancies/create',
name: 'vacancy-create',
component: () => import('../views/VacancyFormView.vue'),
meta: { requiresAuth: true },
},
{
path: '/vacancies/:id/edit',
name: 'vacancy-edit',
component: () => import('../views/VacancyFormView.vue'),
meta: { requiresAuth: true },
props: true,
},
{
path: '/resumes/generate',
name: 'resume-generate',
component: () => import('../views/ResumeGenerateView.vue'),
meta: { requiresAuth: true },
},
{
path: '/resumes/:id',
name: 'resume-detail',
component: () => import('../views/ResumeDetailView.vue'),
meta: { requiresAuth: true },
},
],
})
export default router

19
src/services/api.js Normal file
View File

@@ -0,0 +1,19 @@
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000/api', // Замените на ваш бэкенд
headers: {
'Content-Type': 'application/json',
},
})
// Интерцептор для добавления токена
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth-token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default api

114
src/stores/auth.js Normal file
View File

@@ -0,0 +1,114 @@
import { defineStore } from 'pinia'
import api from '@/services/api'
// Флаг мока из переменной окружения
const USE_MOCK = import.meta.env.VITE_USE_MOCK_AUTH === 'true'
// Мок-функции
const mockRegister = (userData) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve({
data: {
user: {
id: 1,
email: userData.email,
first_name: userData.first_name || 'Иван',
last_name: userData.last_name || 'Иванов',
},
token: 'mock-jwt-token-' + Date.now(),
},
})
}, 500)
})
}
const mockLogin = (credentials) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
// Для простоты примем любые непустые email/пароль
if (credentials.email && credentials.password) {
resolve({
data: {
user: {
id: 1,
email: credentials.email,
first_name: 'Тестовый',
last_name: 'Пользователь',
},
token: 'mock-jwt-token-' + Date.now(),
},
})
} else {
reject({ response: { data: { message: 'Неверный email или пароль' } } })
}
}, 500)
})
}
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
token: null,
isAuthenticated: false,
}),
actions: {
async register(userData) {
try {
let response
if (USE_MOCK) {
response = await mockRegister(userData)
} else {
response = await api.post('/auth/register', userData)
}
const { user, token } = response.data
this.user = user
this.token = token
this.isAuthenticated = true
localStorage.setItem('auth-token', token)
return { success: true }
} catch (error) {
return {
success: false,
error: error.response?.data?.message || 'Ошибка регистрации',
}
}
},
async login(credentials) {
try {
let response
if (USE_MOCK) {
response = await mockLogin(credentials)
} else {
response = await api.post('/auth/login', credentials)
}
const { user, token } = response.data
this.user = user
this.token = token
this.isAuthenticated = true
localStorage.setItem('auth-token', token)
return { success: true }
} catch (error) {
return {
success: false,
error: error.response?.data?.message || 'Ошибка входа',
}
}
},
logout() {
this.user = null
this.token = null
this.isAuthenticated = false
localStorage.removeItem('auth-token')
},
},
persist: {
key: 'auth-storage',
storage: localStorage,
paths: ['user', 'token', 'isAuthenticated'],
},
})

View File

@@ -0,0 +1,128 @@
import { defineStore } from 'pinia'
// Моковая анкета для примера
const mockDetailedQuestionnaire = {
id: 1,
name: 'Основная анкета',
description: 'Полные данные для IT-специальностей',
createdAt: '2026-03-15',
updatedAt: '2026-04-01',
fields: {
first_name: 'Иван',
last_name: 'Иванов',
patronymic: 'Иванович',
age: '30',
title: 'Ведущий разработчик',
salary: '300000',
worktime: 'Полный день',
phone: '+7 (999) 123-45-67',
email: 'ivan@example.com',
city: 'Москва',
about: 'Опытный full-stack разработчик с фокусом на Vue и Python.',
experience: '8 лет коммерческой разработки...',
education: 'МГУ, факультет ВМК, 2015',
courses: 'Vue Advanced, Python Pro',
skills: 'Vue, React, Python, Django, PostgreSQL',
languages: 'Русский (родной), English (B2)',
projects: 'Разработка CRM-системы, мобильное приложение для банка',
},
}
const mockQuestionnaires = [
{
id: 1,
name: 'Основная анкета',
description: 'Полные данные для IT-специальностей',
createdAt: '2026-03-15',
updatedAt: '2026-04-01',
fieldsCount: 12,
},
{
id: 2,
name: 'Анкета для менеджмента',
description: 'Акцент на управленческие навыки',
createdAt: '2026-03-20',
updatedAt: '2026-03-25',
fieldsCount: 10,
},
]
export const useQuestionnairesStore = defineStore('questionnaires', {
state: () => ({
questionnaires: [...mockQuestionnaires],
// Детальные данные анкет храним отдельно (можно объединить, но для простоты отдельный объект)
details: {
1: { ...mockDetailedQuestionnaire.fields },
},
nextId: 3,
}),
actions: {
// Получить детальные поля анкеты по id
getQuestionnaireDetails(id) {
return this.details[id] || this.createEmptyFields()
},
// Создать или обновить анкету
saveQuestionnaire(id, data) {
const { name, description, fields } = data
const existing = this.questionnaires.find(q => q.id === id)
if (existing) {
// Обновление существующей
existing.name = name
existing.description = description
existing.updatedAt = new Date().toISOString().split('T')[0]
existing.fieldsCount = Object.keys(fields).filter(k => fields[k]).length
this.details[id] = { ...fields }
} else {
// Создание новой
const newId = this.nextId++
const newQuestionnaire = {
id: newId,
name,
description,
createdAt: new Date().toISOString().split('T')[0],
updatedAt: new Date().toISOString().split('T')[0],
fieldsCount: Object.keys(fields).filter(k => fields[k]).length,
}
this.questionnaires.push(newQuestionnaire)
this.details[newId] = { ...fields }
return newId
}
return id
},
createEmptyFields() {
return {
first_name: '',
last_name: '',
patronymic: '',
age: '',
title: '',
salary: '',
worktime: '',
phone: '',
email: '',
city: '',
about: '',
experience: '',
education: '',
courses: '',
skills: '',
languages: '',
projects: '',
}
},
deleteQuestionnaire(id) {
this.questionnaires = this.questionnaires.filter(q => q.id !== id)
delete this.details[id]
},
},
persist: {
key: 'questionnaires-storage',
storage: localStorage,
},
})

228
src/stores/resumes.js Normal file
View File

@@ -0,0 +1,228 @@
import { defineStore } from 'pinia'
import { useQuestionnairesStore } from './questionnaires'
import { useVacanciesStore } from './vacancies'
// Генератор мокового контента резюме на основе анкеты и вакансии
const generateResumeSections = (questionnaire, vacancy) => {
return {
summary: `${questionnaire.fields.first_name} ${questionnaire.fields.last_name}${questionnaire.fields.title || 'специалист'} с опытом работы ${questionnaire.fields.experience || 'более 3 лет'}.`,
contacts: {
phone: questionnaire.fields.phone,
email: questionnaire.fields.email,
city: questionnaire.fields.city,
},
experience: questionnaire.fields.experience || 'Опыт работы не указан',
education: questionnaire.fields.education || 'Образование не указано',
skills: questionnaire.fields.skills || 'Навыки не указаны',
languages: questionnaire.fields.languages || 'Языки не указаны',
projects: questionnaire.fields.projects || 'Проекты не указаны',
courses: questionnaire.fields.courses || '',
about: questionnaire.fields.about || '',
}
}
// Моковые данные
const mockResumes = [
{
id: 1,
title: 'Резюме Frontend Developer',
questionnaireId: 1,
vacancyId: 1,
createdAt: '2026-04-05',
updatedAt: '2026-04-07',
atsScore: 87,
status: 'Сгенерировано',
},
{
id: 2,
title: 'Резюме Python Backend',
questionnaireId: 2,
vacancyId: 2,
createdAt: '2026-04-01',
updatedAt: '2026-04-02',
atsScore: 92,
status: 'Сгенерировано',
},
{
id: 3,
title: 'Резюме Data Scientist',
questionnaireId: 1,
vacancyId: 3,
createdAt: '2026-03-28',
updatedAt: '2026-03-30',
atsScore: 85,
status: 'Сгенерировано',
},
]
export const useResumesStore = defineStore('resumes', {
state: () => ({
resumes: [],
contents: {}, // ключ: resumeId, значение: объект с секциями
nextId: 4,
}),
actions: {
// Инициализация моков (вызывать при старте приложения или после загрузки других хранилищ)
initMockData(questionnairesStore, vacanciesStore) {
if (this.resumes.length > 0) return // уже инициализировано
this.resumes = mockResumes.map(r => ({ ...r }))
this.nextId = 4
mockResumes.forEach(resume => {
const q = questionnairesStore.questionnaires.find(q => q.id === resume.questionnaireId)
const v = vacanciesStore.vacancies.find(v => v.id === resume.vacancyId)
if (q && v) {
const qDetails = questionnairesStore.getQuestionnaireDetails(q.id)
const vDetails = vacanciesStore.getVacancyDetails(v.id)
this.contents[resume.id] = {
...generateResumeSections({ fields: qDetails }, { fields: vDetails }),
vacancy: {
title: v.title,
company: v.company,
salary_from: vDetails.salary_from,
salary_to: vDetails.salary_to,
area: vDetails.area_name,
experience: vDetails.experience,
schedule: vDetails.schedule,
employment: vDetails.employment,
key_skills: vDetails.key_skills,
url: v.url,
},
}
}
})
},
// Добавление нового резюме (используется при быстром создании)
addResume({ title, questionnaireId, vacancyId }) {
const newId = this.nextId++
const newResume = {
id: newId,
title: title || `Резюме ${new Date().toLocaleDateString()}`,
questionnaireId,
vacancyId,
createdAt: new Date().toISOString().split('T')[0],
updatedAt: new Date().toISOString().split('T')[0],
atsScore: Math.floor(Math.random() * 30) + 70,
status: 'Сгенерировано',
}
this.resumes.push(newResume)
const qStore = useQuestionnairesStore()
const vStore = useVacanciesStore()
const q = qStore.questionnaires.find(q => q.id === questionnaireId)
const v = vStore.vacancies.find(v => v.id === vacancyId)
if (q && v) {
const qDetails = qStore.getQuestionnaireDetails(q.id)
const vDetails = vStore.getVacancyDetails(v.id)
this.contents[newId] = {
...generateResumeSections({ fields: qDetails }, { fields: vDetails }),
vacancy: {
title: v.title,
company: v.company,
salary_from: vDetails.salary_from,
salary_to: vDetails.salary_to,
area: vDetails.area_name,
experience: vDetails.experience,
schedule: vDetails.schedule,
employment: vDetails.employment,
key_skills: vDetails.key_skills,
url: v.url,
},
}
} else {
// fallback: пустой контент, но чтобы не было ошибки
this.contents[newId] = {
summary: 'Резюме без привязки к анкете/вакансии',
contacts: {},
vacancy: null,
}
}
return newResume
},
// Генерация через ИИ (используется на странице генерации)
generateResume(questionnaireId, vacancyId, vacancyUrl = null) {
return new Promise((resolve) => {
setTimeout(() => {
const newId = this.nextId++
const newResume = {
id: newId,
title: `Резюме ${new Date().toLocaleDateString()}`,
questionnaireId,
vacancyId: vacancyId || 'custom',
createdAt: new Date().toISOString().split('T')[0],
updatedAt: new Date().toISOString().split('T')[0],
atsScore: Math.floor(Math.random() * 30) + 70,
status: 'Сгенерировано',
}
this.resumes.push(newResume)
const qStore = useQuestionnairesStore()
const vStore = useVacanciesStore()
const q = qStore.questionnaires.find(q => q.id === questionnaireId)
let v = null, vDetails = null
if (vacancyId) {
v = vStore.vacancies.find(v => v.id === vacancyId)
if (v) vDetails = vStore.getVacancyDetails(v.id)
} else if (vacancyUrl) {
// Создаём фиктивную вакансию на основе URL
v = {
title: 'Импортированная вакансия',
company: 'Компания с hh.ru',
url: vacancyUrl,
}
vDetails = {
salary_from: '200000',
salary_to: '300000',
area_name: 'Москва',
experience: 'От 3 лет',
schedule: 'Полный день',
employment: 'Полная занятость',
key_skills: 'JavaScript, Vue.js, React',
}
}
// Генерируем контент
this.contents[newId] = {
...generateResumeSections(
{ fields: qStore.getQuestionnaireDetails(questionnaireId) },
{ fields: vDetails || {} }
),
vacancy: v ? {
title: v.title,
company: v.company,
salary_from: vDetails?.salary_from || '',
salary_to: vDetails?.salary_to || '',
area: vDetails?.area_name || '',
experience: vDetails?.experience || '',
schedule: vDetails?.schedule || '',
employment: vDetails?.employment || '',
key_skills: vDetails?.key_skills || '',
url: v.url || '',
} : null,
}
resolve(newId)
}, 2000)
})
},
getResumeContent(id) {
return this.contents[id] || null
},
deleteResume(id) {
this.resumes = this.resumes.filter(r => r.id !== id)
delete this.contents[id]
},
},
persist: {
key: 'resumes-storage',
storage: localStorage,
},
})

161
src/stores/vacancies.js Normal file
View File

@@ -0,0 +1,161 @@
import { defineStore } from 'pinia'
// Моковые детальные данные для существующих вакансий
const mockVacancyDetails = {
1: {
offer_name: 'Senior Frontend Developer',
employer_name: 'TechCorp',
salary_from: '250000',
salary_to: '350000',
area_name: 'Москва',
experience: 'От 3 до 6 лет',
schedule: 'Полный день',
employment: 'Полная занятость',
key_skills: 'Vue.js, React, TypeScript, Tailwind CSS, Webpack, Vite',
},
2: {
offer_name: 'Python Backend Engineer',
employer_name: 'DataSystems',
salary_from: '200000',
salary_to: '280000',
area_name: 'Санкт-Петербург',
experience: 'От 1 года до 3 лет',
schedule: 'Удаленная работа',
employment: 'Проектная работа',
key_skills: 'Python, Django, FastAPI, PostgreSQL, Redis, Docker',
},
3: {
offer_name: 'Product Manager',
employer_name: 'StartupX',
salary_from: '180000',
salary_to: '250000',
area_name: 'Москва',
experience: 'От 3 до 6 лет',
schedule: 'Гибкий график',
employment: 'Полная занятость',
key_skills: 'Agile, Scrum, Product Roadmap, User Stories, Jira, Confluence',
},
}
const mockVacancies = [
{
id: 1,
title: 'Senior Frontend Developer',
company: 'TechCorp',
source: 'hh.ru',
url: 'https://hh.ru/vacancy/123',
createdAt: '2026-04-01',
status: 'Активна',
},
{
id: 2,
title: 'Python Backend Engineer',
company: 'DataSystems',
source: 'hh.ru',
url: 'https://hh.ru/vacancy/456',
createdAt: '2026-03-28',
status: 'Активна',
},
{
id: 3,
title: 'Product Manager',
company: 'StartupX',
source: 'hh.ru',
url: 'https://hh.ru/vacancy/789',
createdAt: '2026-03-20',
status: 'В архиве',
},
]
export const useVacanciesStore = defineStore('vacancies', {
state: () => ({
vacancies: [...mockVacancies],
details: { ...mockVacancyDetails },
nextId: 4,
}),
actions: {
// Создать пустой объект полей
createEmptyFields() {
return {
offer_name: '',
employer_name: '',
salary_from: '',
salary_to: '',
area_name: '',
experience: '',
schedule: '',
employment: '',
key_skills: '',
}
},
// Получить детальные поля вакансии
getVacancyDetails(id) {
return this.details[id] || this.createEmptyFields()
},
// Сохранить (создать или обновить)
saveVacancy(id, data) {
const { offer_name, employer_name, salary_from, salary_to, area_name, experience, schedule, employment, key_skills, url, status } = data
if (id && this.vacancies.find(v => v.id === id)) {
// Обновление существующей
const vacancy = this.vacancies.find(v => v.id === id)
vacancy.title = offer_name
vacancy.company = employer_name
if (url !== undefined) vacancy.url = url
if (status !== undefined) vacancy.status = status
vacancy.updatedAt = new Date().toISOString().split('T')[0]
this.details[id] = {
offer_name,
employer_name,
salary_from,
salary_to,
area_name,
experience,
schedule,
employment,
key_skills,
}
return id
} else {
// Создание новой
const newId = this.nextId++
const newVacancy = {
id: newId,
title: offer_name,
company: employer_name,
source: 'Ручной ввод',
url: url || '',
createdAt: new Date().toISOString().split('T')[0],
status: status || 'Активна',
}
this.vacancies.push(newVacancy)
this.details[newId] = {
offer_name,
employer_name,
salary_from,
salary_to,
area_name,
experience,
schedule,
employment,
key_skills,
}
return newId
}
},
deleteVacancy(id) {
this.vacancies = this.vacancies.filter(v => v.id !== id)
delete this.details[id]
},
},
persist: {
key: 'vacancies-storage',
storage: localStorage,
},
})

297
src/style.css Normal file
View File

@@ -0,0 +1,297 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

53
src/views/AboutView.vue Normal file
View File

@@ -0,0 +1,53 @@
<script setup>
// Статическая страница, без логики
</script>
<template>
<div class="min-h-screen bg-off-white">
<!-- Hero -->
<section class="bg-white border-b border-gray-100 px-6 py-16">
<div class="mx-auto max-w-4xl text-center">
<h1 class="text-4xl font-bold text-dark-gray md:text-5xl">О платформе Resugen</h1>
<p class="mt-4 text-lg text-gray-600">
Интеллектуальный генератор ATS-оптимизированных резюме на основе семантического анализа
</p>
</div>
</section>
<!-- Основное описание -->
<section class="px-6 py-16">
<div class="mx-auto max-w-4xl">
<div class="bg-white rounded-xl shadow-card p-8 md:p-12">
<h2 class="text-2xl font-semibold text-dark-gray mb-6">Наша миссия</h2>
<p class="text-gray-700 leading-relaxed mb-6">
Resugen создан, чтобы устранить разрыв между субъективным представлением соискателя о «хорошем» резюме
и объективными алгоритмическими критериями современных систем отбора персонала (ATS).
Мы автоматизируем процесс адаптации резюме под конкретную вакансию, повышая шансы на прохождение первичного скрининга.
</p>
<h2 class="text-2xl font-semibold text-dark-gray mb-6 mt-10">Ключевые технологии</h2>
<ul class="space-y-3 text-gray-700 list-disc list-inside">
<li><span class="font-medium text-dark-gray">Семантический анализ</span> векторные представления текста и NLP для извлечения ключевых требований</li>
<li><span class="font-medium text-dark-gray">Большие языковые модели</span> адаптация контента под стиль и ожидания работодателя</li>
<li><span class="font-medium text-dark-gray">Микросервисная архитектура</span> масштабируемость, надёжность и независимое развёртывание</li>
<li><span class="font-medium text-dark-gray">Интеграция с hh.ru</span> автоматический импорт вакансий и требований</li>
</ul>
<h2 class="text-2xl font-semibold text-dark-gray mb-6 mt-10">Контакты</h2>
<p class="text-gray-700">
По всем вопросам: <a href="mailto:support@resugen.ru" class="text-accent-blue hover:underline">support@resugen.ru</a>
</p>
<p class="text-gray-700 mt-2">
© 2026 Resugen. Дипломный проект.
</p>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.shadow-card {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
}
</style>

367
src/views/DashboardView.vue Normal file
View File

@@ -0,0 +1,367 @@
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useResumesStore } from '@/stores/resumes'
import { useQuestionnairesStore } from '@/stores/questionnaires'
import { useVacanciesStore } from '@/stores/vacancies'
const router = useRouter()
const authStore = useAuthStore()
// Проверка авторизации
onMounted(() => {
if (!authStore.isAuthenticated) {
router.replace('/login')
}
})
const activeTab = ref('resumes') // 'resumes', 'questionnaires', 'vacancies'
// Хранилища
const resumesStore = useResumesStore()
const questionnairesStore = useQuestionnairesStore()
const vacanciesStore = useVacanciesStore()
const getVacancyTitle = (vacancyId) => {
const v = vacanciesStore.vacancies.find(v => v.id === vacancyId)
return v ? v.title : 'Неизвестная вакансия'
}
const quickActions = [
{ name: 'Заполнить анкету', path: '/questionnaires/create', icon: '📝' },
{ name: 'Добавить вакансию', path: '/vacancies/create', icon: '💼' },
{ name: 'Создать резюме', path: '/resumes/generate', icon: '✨' },
]
const isMocksInitialized = ref(false)
watch(
[() => questionnairesStore.questionnaires.length, () => vacanciesStore.vacancies.length],
([qLen, vLen]) => {
if (!isMocksInitialized.value && qLen > 0 && vLen > 0) {
resumesStore.initMockData(questionnairesStore, vacanciesStore)
isMocksInitialized.value = true
}
},
{ immediate: true }
)
// Модальные окна для быстрого добавления (простые примеры)
const showAddModal = ref(false)
const newItemTitle = ref('')
const newItemDescription = ref('')
// Для редактирования (заглушка)
const editingItem = ref(null)
const openAddModal = () => {
newItemTitle.value = ''
newItemDescription.value = ''
showAddModal.value = true
}
const addNewItem = () => {
if (!newItemTitle.value.trim()) return
if (activeTab.value === 'resumes') {
// Используем первую попавшуюся анкету и вакансию для демонстрации
const qId = questionnairesStore.questionnaires[0]?.id || 1
const vId = vacanciesStore.vacancies[0]?.id || 1
resumesStore.addResume({
title: newItemTitle.value,
questionnaireId: qId,
vacancyId: vId,
})
} else if (activeTab.value === 'questionnaires') {
questionnairesStore.addQuestionnaire({
name: newItemTitle.value,
description: newItemDescription.value || '',
fieldsCount: 10,
})
} else if (activeTab.value === 'vacancies') {
vacanciesStore.addVacancy({
title: newItemTitle.value,
company: newItemDescription.value || 'Не указана',
source: 'Ручной ввод',
status: 'Активна',
})
}
showAddModal.value = false
}
const deleteItem = (id) => {
if (!confirm('Удалить элемент?')) return
if (activeTab.value === 'resumes') {
resumesStore.deleteResume(id)
} else if (activeTab.value === 'questionnaires') {
questionnairesStore.deleteQuestionnaire(id)
} else if (activeTab.value === 'vacancies') {
vacanciesStore.deleteVacancy(id)
}
}
// Заголовок раздела
const sectionTitle = computed(() => {
const map = {
resumes: 'Мои резюме',
questionnaires: 'Мои анкеты',
vacancies: 'Мои вакансии',
}
return map[activeTab.value] || ''
})
</script>
<template>
<div class="min-h-screen bg-off-white">
<!-- Верхняя панель -->
<div class="bg-white border-b border-gray-100 px-6 py-6">
<div class="mx-auto max-w-6xl">
<h1 class="text-3xl font-bold text-dark-gray">Личный кабинет</h1>
<p class="mt-2 text-gray-600">Управляйте резюме, анкетами и вакансиями</p>
</div>
</div>
<div class="bg-white border-b border-gray-100 px-6 py-4">
<div class="mx-auto max-w-6xl">
<div class="flex flex-wrap gap-3">
<router-link
v-for="action in quickActions"
:key="action.name"
:to="action.path"
class="inline-flex items-center px-5 py-2 bg-off-white border border-gray-200 rounded-lg text-dark-gray font-medium hover:bg-gray-100 transition"
>
<span class="mr-2 text-lg">{{ action.icon }}</span>
{{ action.name }}
</router-link>
</div>
</div>
</div>
<!-- Вкладки -->
<div class="mx-auto max-w-6xl px-6 pt-6">
<div class="border-b border-gray-200">
<nav class="-mb-px flex space-x-8">
<button
@click="activeTab = 'resumes'"
:class="[
'py-3 px-1 border-b-2 font-medium text-sm transition',
activeTab === 'resumes'
? 'border-accent-blue text-accent-blue'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
>
Резюме ({{ resumesStore.resumes.length }})
</button>
<button
@click="activeTab = 'questionnaires'"
:class="[
'py-3 px-1 border-b-2 font-medium text-sm transition',
activeTab === 'questionnaires'
? 'border-accent-blue text-accent-blue'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
>
Анкеты ({{ questionnairesStore.questionnaires.length }})
</button>
<button
@click="activeTab = 'vacancies'"
:class="[
'py-3 px-1 border-b-2 font-medium text-sm transition',
activeTab === 'vacancies'
? 'border-accent-blue text-accent-blue'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
]"
>
Вакансии ({{ vacanciesStore.vacancies.length }})
</button>
</nav>
</div>
</div>
<!-- Содержимое вкладки -->
<div class="mx-auto max-w-6xl px-6 py-8">
<!-- Кнопка добавления -->
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold text-dark-gray">{{ sectionTitle }}</h2>
<button
@click="openAddModal"
class="bg-accent-blue text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition flex items-center gap-2"
>
<span>+</span> Создать
</button>
</div>
<!-- Список резюме -->
<div v-if="activeTab === 'resumes'" class="space-y-4">
<div v-if="resumesStore.resumes.length === 0" class="bg-white rounded-xl p-8 text-center text-gray-500">
У вас пока нет резюме. Нажмите «Создать», чтобы начать.
</div>
<div
v-for="resume in resumesStore.resumes"
:key="resume.id"
class="bg-white rounded-xl shadow-card p-5 flex flex-wrap items-center justify-between gap-4"
>
<div class="flex-1">
<h3 class="font-semibold text-dark-gray text-lg">{{ resume.title }}</h3>
<p class="text-sm text-gray-500 mt-1">
Вакансия: {{ getVacancyTitle(resume.vacancyId) }} · ATS-score:
<span :class="resume.atsScore >= 80 ? 'text-green-600' : 'text-yellow-600'">
{{ resume.atsScore }}%
</span>
</p>
<p class="text-xs text-gray-400 mt-1">
Создано: {{ resume.createdAt }} · Обновлено: {{ resume.updatedAt }}
</p>
</div>
<div class="flex items-center gap-2">
<router-link
:to="`/resumes/${resume.id}`"
class="text-accent-blue hover:underline text-sm font-medium px-3 py-1"
>
Открыть
</router-link>
<button
@click="deleteItem(resume.id)"
class="text-red-500 hover:text-red-700 text-sm font-medium px-3 py-1"
>
Удалить
</button>
</div>
</div>
</div>
<!-- Список анкет -->
<div v-if="activeTab === 'questionnaires'" class="space-y-4">
<div v-if="questionnairesStore.questionnaires.length === 0" class="bg-white rounded-xl p-8 text-center text-gray-500">
У вас пока нет анкет. Нажмите «Создать», чтобы добавить.
</div>
<div
v-for="q in questionnairesStore.questionnaires"
:key="q.id"
class="bg-white rounded-xl shadow-card p-5 flex flex-wrap items-center justify-between gap-4"
>
<div class="flex-1">
<h3 class="font-semibold text-dark-gray text-lg">{{ q.name }}</h3>
<p class="text-sm text-gray-500 mt-1">{{ q.description || 'Без описания' }}</p>
<p class="text-xs text-gray-400 mt-1">
Полей: {{ q.fieldsCount }} · Обновлено: {{ q.updatedAt }}
</p>
</div>
<div class="flex items-center gap-2">
<router-link
:to="`/questionnaires/${q.id}/edit`"
class="text-accent-blue hover:underline text-sm font-medium px-3 py-1"
>
Редактировать
</router-link>
<button
@click="deleteItem(q.id)"
class="text-red-500 hover:text-red-700 text-sm font-medium px-3 py-1"
>
Удалить
</button>
</div>
</div>
</div>
<!-- Список вакансий -->
<div v-if="activeTab === 'vacancies'" class="space-y-4">
<div v-if="vacanciesStore.vacancies.length === 0" class="bg-white rounded-xl p-8 text-center text-gray-500">
У вас пока нет сохранённых вакансий. Нажмите «Создать», чтобы добавить.
</div>
<div
v-for="v in vacanciesStore.vacancies"
:key="v.id"
class="bg-white rounded-xl shadow-card p-5 flex flex-wrap items-center justify-between gap-4"
>
<div class="flex-1">
<h3 class="font-semibold text-dark-gray text-lg">{{ v.title }}</h3>
<p class="text-sm text-gray-500 mt-1">{{ v.company }} · Источник: {{ v.source }}</p>
<p class="text-xs text-gray-400 mt-1">
Добавлена: {{ v.createdAt }} · Статус:
<span :class="v.status === 'Активна' ? 'text-green-600' : 'text-gray-500'">
{{ v.status }}
</span>
</p>
</div>
<div class="flex items-center gap-2">
<a
v-if="v.url"
:href="v.url"
target="_blank"
class="text-gray-500 hover:text-accent-blue text-sm font-medium px-3 py-1"
>
hh.ru
</a>
<router-link
:to="`/vacancies/${v.id}/edit`"
class="text-accent-blue hover:underline text-sm font-medium px-3 py-1"
>
Редактировать
</router-link>
<button
@click="deleteItem(v.id)"
class="text-red-500 hover:text-red-700 text-sm font-medium px-3 py-1"
>
Удалить
</button>
</div>
</div>
</div>
</div>
<!-- Модальное окно быстрого добавления (упрощённое) -->
<Teleport to="body">
<div
v-if="showAddModal"
class="fixed inset-0 bg-black/30 flex items-center justify-center z-50 p-4"
@click.self="showAddModal = false"
>
<div class="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
<h3 class="text-xl font-semibold text-dark-gray mb-4">
Новое {{ activeTab === 'resumes' ? 'резюме' : activeTab === 'questionnaires' ? 'анкета' : 'вакансия' }}
</h3>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-dark-gray mb-1">Название</label>
<input
v-model="newItemTitle"
type="text"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-accent-blue outline-none"
:placeholder="activeTab === 'resumes' ? 'Например: Резюме для Яндекса' : 'Название'"
/>
</div>
<div>
<label class="block text-sm font-medium text-dark-gray mb-1">
{{ activeTab === 'resumes' ? 'Вакансия' : activeTab === 'questionnaires' ? 'Описание' : 'Компания / описание' }}
</label>
<input
v-model="newItemDescription"
type="text"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-accent-blue outline-none"
:placeholder="activeTab === 'resumes' ? 'Должность' : 'Дополнительно'"
/>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button
@click="showAddModal = false"
class="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition"
>
Отмена
</button>
<button
@click="addNewItem"
class="px-4 py-2 bg-accent-blue text-white rounded-lg hover:bg-blue-700 transition"
>
Создать
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<style scoped>
.shadow-card {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
}
</style>

119
src/views/HomeView.vue Normal file
View File

@@ -0,0 +1,119 @@
<script setup>
import { computed } from 'vue'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const isLoggedIn = computed(() => authStore.isAuthenticated)
// Примерные карточки возможностей
const features = [
{
title: 'ATSоптимизация',
description: 'Резюме адаптируются под алгоритмы систем автоматического отбора.',
icon: '📄',
},
{
title: 'Семантический анализ',
description: 'Ключевые слова и векторные представления повышают релевантность.',
icon: '🔍',
},
{
title: 'Импорт вакансий',
description: 'Быстрая загрузка требований с hh.ru и других источников.',
icon: '🌐',
},
{
title: 'Прозрачная аналитика',
description: 'ATSscore и рекомендации по улучшению каждого документа.',
icon: '📊',
},
]
</script>
<template>
<div class="min-h-screen bg-off-white">
<!-- Hero Section -->
<section class="px-6 py-16 md:py-24">
<div class="mx-auto max-w-4xl text-center">
<h1 class="text-4xl font-bold tracking-tight text-dark-gray md:text-5xl">
Резюме, которое <span class="text-accent-blue">проходит отбор</span>
</h1>
<p class="mt-6 text-lg text-gray-600 md:text-xl">
Resugen автоматически создаёт и оптимизирует резюме под требования
конкретной вакансии, учитывая алгоритмы современных ATS.
</p>
<div class="mt-10 flex flex-wrap justify-center gap-4">
<router-link
v-if="!isLoggedIn"
to="/register"
class="rounded-lg bg-accent-blue px-6 py-3 text-sm font-medium text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Начать бесплатно
</router-link>
<router-link
v-else
to="/dashboard"
class="rounded-lg bg-accent-blue px-6 py-3 text-sm font-medium text-white shadow-sm transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Перейти в кабинет
</router-link>
<router-link
to="/about"
class="rounded-lg border border-gray-200 bg-white px-6 py-3 text-sm font-medium text-dark-gray shadow-sm transition hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Узнать больше
</router-link>
</div>
</div>
</section>
<!-- Features Grid -->
<section class="px-6 pb-24">
<div class="mx-auto max-w-6xl">
<h2 class="mb-12 text-center text-3xl font-semibold text-dark-gray">
Возможности платформы
</h2>
<div class="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-4">
<div
v-for="feature in features"
:key="feature.title"
class="rounded-xl bg-white p-6 shadow-card transition hover:shadow-lg"
>
<div class="mb-4 text-4xl">{{ feature.icon }}</div>
<h3 class="mb-2 text-xl font-semibold text-dark-gray">
{{ feature.title }}
</h3>
<p class="text-sm leading-relaxed text-gray-600">
{{ feature.description }}
</p>
</div>
</div>
</div>
</section>
<!-- Call to Action (для неавторизованных) -->
<section v-if="!isLoggedIn" class="border-t border-gray-100 bg-white px-6 py-16">
<div class="mx-auto max-w-3xl text-center">
<h2 class="text-3xl font-bold text-dark-gray">
Готовы создать идеальное резюме?
</h2>
<p class="mt-4 text-gray-600">
Присоединяйтесь к Resugen и начните получать приглашения на собеседования.
</p>
<router-link
to="/register"
class="mt-8 inline-block rounded-lg bg-accent-blue px-8 py-3 text-sm font-medium text-white shadow-sm transition hover:bg-blue-700"
>
Создать аккаунт
</router-link>
</div>
</section>
</div>
</template>
<style scoped>
/* Дополнительные кастомные стили при необходимости */
.shadow-card {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
}
</style>

103
src/views/LoginView.vue Normal file
View File

@@ -0,0 +1,103 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const form = ref({
email: '',
password: '',
})
const isLoading = ref(false)
const errorMessage = ref('')
const fieldErrors = ref({})
const validateForm = () => {
fieldErrors.value = {}
if (!form.value.email) fieldErrors.value.email = 'Email обязателен'
else if (!/^\S+@\S+\.\S+$/.test(form.value.email)) fieldErrors.value.email = 'Некорректный email'
if (!form.value.password) fieldErrors.value.password = 'Пароль обязателен'
return Object.keys(fieldErrors.value).length === 0
}
const handleSubmit = async () => {
if (!validateForm()) return
isLoading.value = true
errorMessage.value = ''
const result = await authStore.login(form.value)
if (result.success) {
router.push('/dashboard')
} else {
errorMessage.value = result.error
}
isLoading.value = false
}
</script>
<template>
<div class="min-h-screen bg-off-white flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="bg-white rounded-xl shadow-card p-8">
<h2 class="text-2xl font-bold text-dark-gray mb-2">Вход в Resugen</h2>
<p class="text-gray-600 mb-6">Войдите, чтобы продолжить работу</p>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Email -->
<div>
<label class="block text-sm font-medium text-dark-gray mb-1">Email</label>
<input
v-model="form.email"
type="email"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-accent-blue focus:border-transparent outline-none transition"
:class="{ 'border-red-400': fieldErrors.email }"
placeholder="ivan@example.com"
/>
<p v-if="fieldErrors.email" class="text-red-500 text-sm mt-1">{{ fieldErrors.email }}</p>
</div>
<!-- Пароль -->
<div>
<label class="block text-sm font-medium text-dark-gray mb-1">Пароль</label>
<input
v-model="form.password"
type="password"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-accent-blue focus:border-transparent outline-none transition"
:class="{ 'border-red-400': fieldErrors.password }"
placeholder="••••••••"
/>
<p v-if="fieldErrors.password" class="text-red-500 text-sm mt-1">{{ fieldErrors.password }}</p>
</div>
<!-- Ошибка сервера -->
<div v-if="errorMessage" class="text-red-500 text-sm bg-red-50 p-3 rounded-lg">
{{ errorMessage }}
</div>
<button
type="submit"
:disabled="isLoading"
class="w-full bg-accent-blue text-white font-medium py-2 px-4 rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ isLoading ? 'Вход...' : 'Войти' }}
</button>
</form>
<p class="mt-6 text-center text-sm text-gray-600">
Нет аккаунта?
<router-link to="/register" class="text-accent-blue hover:underline font-medium">
Зарегистрироваться
</router-link>
</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,221 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useQuestionnairesStore } from '@/stores/questionnaires'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const store = useQuestionnairesStore()
// Проверка авторизации
onMounted(() => {
if (!authStore.isAuthenticated) {
router.replace('/login')
}
})
const isEditMode = computed(() => !!route.params.id)
const questionnaireId = computed(() => route.params.id ? Number(route.params.id) : null)
// Заголовок страницы
const pageTitle = computed(() => isEditMode.value ? 'Редактирование анкеты' : 'Новая анкета')
// Поля формы
const form = ref({
name: '',
description: '',
fields: store.createEmptyFields(),
})
// Загрузка данных при редактировании
if (isEditMode.value) {
const existing = store.questionnaires.find(q => q.id === questionnaireId.value)
if (existing) {
form.value.name = existing.name
form.value.description = existing.description
form.value.fields = store.getQuestionnaireDetails(questionnaireId.value)
} else {
// Если анкета не найдена, редирект на список
router.replace('/dashboard')
}
}
// Состояние загрузки и ошибок
const isLoading = ref(false)
const errorMessage = ref('')
const fieldErrors = ref({})
// Список полей для генерации формы (можно вынести в константу)
const fieldDefinitions = [
{ key: 'first_name', label: 'Имя', required: true, type: 'text', placeholder: 'Иван' },
{ key: 'last_name', label: 'Фамилия', required: true, type: 'text', placeholder: 'Иванов' },
{ key: 'patronymic', label: 'Отчество', required: false, type: 'text', placeholder: 'Иванович' },
{ key: 'age', label: 'Возраст', required: false, type: 'text', placeholder: '30' },
{ key: 'title', label: 'Желаемая должность', required: true, type: 'text', placeholder: 'Frontend Developer' },
{ key: 'salary', label: 'Желаемая зарплата', required: false, type: 'text', placeholder: '200000' },
{ key: 'worktime', label: 'График работы', required: false, type: 'text', placeholder: 'Полный день' },
{ key: 'phone', label: 'Телефон', required: true, type: 'tel', placeholder: '+7 (999) 123-45-67' },
{ key: 'email', label: 'Email', required: true, type: 'email', placeholder: 'ivan@example.com' },
{ key: 'city', label: 'Город', required: true, type: 'text', placeholder: 'Москва' },
{ key: 'about', label: 'О себе', required: false, type: 'textarea', placeholder: 'Краткая информация о вас' },
{ key: 'experience', label: 'Опыт работы', required: true, type: 'textarea', placeholder: 'Опишите ваш опыт работы' },
{ key: 'education', label: 'Образование', required: true, type: 'textarea', placeholder: 'ВУЗ, специальность, год окончания' },
{ key: 'courses', label: 'Курсы и сертификаты', required: false, type: 'textarea', placeholder: 'Пройденные курсы' },
{ key: 'skills', label: 'Навыки', required: true, type: 'textarea', placeholder: 'Ключевые навыки через запятую' },
{ key: 'languages', label: 'Языки', required: false, type: 'textarea', placeholder: 'Русский, English B2' },
{ key: 'projects', label: 'Проекты', required: false, type: 'textarea', placeholder: 'Ссылки или описание проектов' },
]
// Валидация
const validateForm = () => {
fieldErrors.value = {}
for (const def of fieldDefinitions) {
if (def.required) {
const val = form.value.fields[def.key]
if (!val || (typeof val === 'string' && !val.trim())) {
fieldErrors.value[def.key] = `${def.label} обязательно для заполнения`
}
}
}
if (!form.value.name.trim()) {
fieldErrors.value.name = 'Название анкеты обязательно'
}
return Object.keys(fieldErrors.value).length === 0
}
// Сохранение
const handleSubmit = async () => {
if (!validateForm()) {
// Скролл к первой ошибке
const firstError = document.querySelector('.border-red-400')
if (firstError) firstError.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
isLoading.value = true
errorMessage.value = ''
try {
const id = isEditMode.value ? questionnaireId.value : null
const savedId = store.saveQuestionnaire(id, {
name: form.value.name,
description: form.value.description,
fields: form.value.fields,
})
// Перенаправление на дашборд после сохранения
router.push('/dashboard')
} catch (e) {
errorMessage.value = 'Ошибка при сохранении анкеты'
} finally {
isLoading.value = false
}
}
// Отмена
const cancel = () => {
router.back()
}
</script>
<template>
<div class="min-h-screen bg-off-white py-8">
<div class="mx-auto max-w-3xl px-6">
<!-- Заголовок -->
<div class="mb-6">
<h1 class="text-3xl font-bold text-dark-gray">{{ pageTitle }}</h1>
<p class="mt-2 text-gray-600">Заполните все необходимые поля. Поля, отмеченные *, обязательны.</p>
</div>
<!-- Форма -->
<div class="bg-white rounded-xl shadow-card p-6 md:p-8">
<form @submit.prevent="handleSubmit">
<!-- Название анкеты -->
<div class="mb-6">
<label class="block text-sm font-medium text-dark-gray mb-1">Название анкеты *</label>
<input
v-model="form.name"
type="text"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-accent-blue outline-none transition"
:class="fieldErrors.name ? 'border-red-400' : 'border-gray-200'"
placeholder="Например: Основная анкета"
/>
<p v-if="fieldErrors.name" class="text-red-500 text-sm mt-1">{{ fieldErrors.name }}</p>
</div>
<!-- Описание (необязательно) -->
<div class="mb-6">
<label class="block text-sm font-medium text-dark-gray mb-1">Описание</label>
<input
v-model="form.description"
type="text"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-accent-blue outline-none transition"
placeholder="Краткое описание анкеты"
/>
</div>
<!-- Поля анкеты -->
<div class="space-y-5">
<div v-for="def in fieldDefinitions" :key="def.key">
<label class="block text-sm font-medium text-dark-gray mb-1">
{{ def.label }} <span v-if="def.required" class="text-red-500">*</span>
</label>
<!-- Text input -->
<input
v-if="def.type === 'text' || def.type === 'tel' || def.type === 'email'"
v-model="form.fields[def.key]"
:type="def.type"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-accent-blue outline-none transition"
:class="fieldErrors[def.key] ? 'border-red-400' : 'border-gray-200'"
:placeholder="def.placeholder"
/>
<!-- Textarea -->
<textarea
v-else-if="def.type === 'textarea'"
v-model="form.fields[def.key]"
rows="3"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-accent-blue outline-none transition resize-y"
:class="fieldErrors[def.key] ? 'border-red-400' : 'border-gray-200'"
:placeholder="def.placeholder"
></textarea>
<p v-if="fieldErrors[def.key]" class="text-red-500 text-sm mt-1">{{ fieldErrors[def.key] }}</p>
</div>
</div>
<!-- Ошибка сервера -->
<div v-if="errorMessage" class="mt-6 text-red-500 text-sm bg-red-50 p-3 rounded-lg">
{{ errorMessage }}
</div>
<!-- Кнопки -->
<div class="mt-8 flex justify-end gap-4">
<button
type="button"
@click="cancel"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition"
>
Отмена
</button>
<button
type="submit"
:disabled="isLoading"
class="px-6 py-2 bg-accent-blue text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ isLoading ? 'Сохранение...' : (isEditMode ? 'Сохранить изменения' : 'Создать анкету') }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
.shadow-card {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
}
</style>

158
src/views/RegisterView.vue Normal file
View File

@@ -0,0 +1,158 @@
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const form = ref({
email: '',
password: '',
confirmPassword: '',
firstName: '',
lastName: '',
})
const isLoading = ref(false)
const errorMessage = ref('')
const fieldErrors = ref({})
const validateForm = () => {
fieldErrors.value = {}
if (!form.value.email) fieldErrors.value.email = 'Email обязателен'
else if (!/^\S+@\S+\.\S+$/.test(form.value.email)) fieldErrors.value.email = 'Некорректный email'
if (!form.value.password) fieldErrors.value.password = 'Пароль обязателен'
else if (form.value.password.length < 6) fieldErrors.value.password = 'Минимум 6 символов'
if (form.value.password !== form.value.confirmPassword) {
fieldErrors.value.confirmPassword = 'Пароли не совпадают'
}
if (!form.value.firstName) fieldErrors.value.firstName = 'Имя обязательно'
if (!form.value.lastName) fieldErrors.value.lastName = 'Фамилия обязательна'
return Object.keys(fieldErrors.value).length === 0
}
const handleSubmit = async () => {
if (!validateForm()) return
isLoading.value = true
errorMessage.value = ''
const result = await authStore.register({
email: form.value.email,
password: form.value.password,
first_name: form.value.firstName,
last_name: form.value.lastName,
})
if (result.success) {
router.push('/dashboard')
} else {
errorMessage.value = result.error
}
isLoading.value = false
}
</script>
<template>
<div class="min-h-screen bg-off-white flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md">
<div class="bg-white rounded-xl shadow-card p-8">
<h2 class="text-2xl font-bold text-dark-gray mb-2">Регистрация</h2>
<p class="text-gray-600 mb-6">Создайте аккаунт для доступа к Resugen</p>
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Имя -->
<div>
<label class="block text-sm font-medium text-dark-gray mb-1">Имя</label>
<input
v-model="form.firstName"
type="text"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-accent-blue focus:border-transparent outline-none transition"
:class="{ 'border-red-400': fieldErrors.firstName }"
placeholder="Иван"
/>
<p v-if="fieldErrors.firstName" class="text-red-500 text-sm mt-1">{{ fieldErrors.firstName }}</p>
</div>
<!-- Фамилия -->
<div>
<label class="block text-sm font-medium text-dark-gray mb-1">Фамилия</label>
<input
v-model="form.lastName"
type="text"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-accent-blue focus:border-transparent outline-none transition"
:class="{ 'border-red-400': fieldErrors.lastName }"
placeholder="Иванов"
/>
<p v-if="fieldErrors.lastName" class="text-red-500 text-sm mt-1">{{ fieldErrors.lastName }}</p>
</div>
<!-- Email -->
<div>
<label class="block text-sm font-medium text-dark-gray mb-1">Email</label>
<input
v-model="form.email"
type="email"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-accent-blue focus:border-transparent outline-none transition"
:class="{ 'border-red-400': fieldErrors.email }"
placeholder="ivan@example.com"
/>
<p v-if="fieldErrors.email" class="text-red-500 text-sm mt-1">{{ fieldErrors.email }}</p>
</div>
<!-- Пароль -->
<div>
<label class="block text-sm font-medium text-dark-gray mb-1">Пароль</label>
<input
v-model="form.password"
type="password"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-accent-blue focus:border-transparent outline-none transition"
:class="{ 'border-red-400': fieldErrors.password }"
placeholder="••••••••"
/>
<p v-if="fieldErrors.password" class="text-red-500 text-sm mt-1">{{ fieldErrors.password }}</p>
</div>
<!-- Подтверждение пароля -->
<div>
<label class="block text-sm font-medium text-dark-gray mb-1">Подтвердите пароль</label>
<input
v-model="form.confirmPassword"
type="password"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-accent-blue focus:border-transparent outline-none transition"
:class="{ 'border-red-400': fieldErrors.confirmPassword }"
placeholder="••••••••"
/>
<p v-if="fieldErrors.confirmPassword" class="text-red-500 text-sm mt-1">{{ fieldErrors.confirmPassword }}</p>
</div>
<!-- Ошибка сервера -->
<div v-if="errorMessage" class="text-red-500 text-sm bg-red-50 p-3 rounded-lg">
{{ errorMessage }}
</div>
<button
type="submit"
:disabled="isLoading"
class="w-full bg-accent-blue text-white font-medium py-2 px-4 rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ isLoading ? 'Регистрация...' : 'Зарегистрироваться' }}
</button>
</form>
<p class="mt-6 text-center text-sm text-gray-600">
Уже есть аккаунт?
<router-link to="/login" class="text-accent-blue hover:underline font-medium">
Войти
</router-link>
</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,204 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useResumesStore } from '@/stores/resumes'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const store = useResumesStore()
onMounted(() => {
if (!authStore.isAuthenticated) {
router.replace('/login')
}
// Слушатель Ctrl+P
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
})
const resumeId = Number(route.params.id)
const resume = computed(() => store.resumes.find(r => r.id === resumeId))
const content = computed(() => store.getResumeContent(resumeId))
if (!resume.value) {
router.replace('/dashboard')
}
const isPrinting = ref(false)
const handleKeyDown = (e) => {
if (e.ctrlKey && e.key === 'p') {
e.preventDefault() // предотвращаем стандартное поведение (чтобы не печатать всю страницу)
downloadPdf()
}
}
const downloadPdf = () => {
isPrinting.value = true
// Даём время на перерисовку с классом печати (если нужно)
setTimeout(() => {
window.print()
isPrinting.value = false
}, 50)
}
</script>
<template>
<div class="min-h-screen bg-off-white py-8">
<div class="mx-auto max-w-4xl px-6">
<!-- Заголовок и метаинформация (скроется при печати) -->
<div class="mb-6 flex items-center justify-between no-print">
<div>
<h1 class="text-3xl font-bold text-dark-gray">{{ resume.title }}</h1>
<p class="text-gray-600 mt-1">
Сгенерировано {{ resume.createdAt }} · ATS-score:
<span :class="resume.atsScore >= 80 ? 'text-green-600' : 'text-yellow-600'">
{{ resume.atsScore }}%
</span>
</p>
</div>
<button
@click="downloadPdf"
class="px-4 py-2 bg-accent-blue text-white rounded-lg hover:bg-blue-700 transition"
>
Скачать PDF
</button>
</div>
<!-- Карточка резюме (контейнер для печати) -->
<div
v-if="content"
class="bg-white rounded-xl shadow-card overflow-hidden print-resume-container"
:class="{ 'shadow-card': !isPrinting }"
>
<!-- Шапка резюме -->
<div class="bg-dark-gray text-white p-6">
<h2 class="text-2xl font-semibold">
{{ content.summary.split('—')[0]?.trim() || 'Соискатель' }}
</h2>
<p class="text-gray-300 mt-1">{{ content.summary.split('—')[1]?.trim() || content.summary }}</p>
</div>
<!-- Основное содержимое -->
<div class="p-6 md:p-8">
<!-- Контакты -->
<section class="mb-6">
<h3 class="text-lg font-semibold text-dark-gray border-b border-gray-200 pb-2 mb-3">Контакты</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 text-gray-700">
<div v-if="content.contacts?.phone">
<span class="font-medium">Телефон:</span> {{ content.contacts.phone }}
</div>
<div v-if="content.contacts?.email">
<span class="font-medium">Email:</span> {{ content.contacts.email }}
</div>
<div v-if="content.contacts?.city">
<span class="font-medium">Город:</span> {{ content.contacts.city }}
</div>
</div>
</section>
<!-- О себе -->
<section v-if="content.about" class="mb-6">
<h3 class="text-lg font-semibold text-dark-gray border-b border-gray-200 pb-2 mb-3">О себе</h3>
<p class="text-gray-700 whitespace-pre-line">{{ content.about }}</p>
</section>
<!-- Опыт работы -->
<section v-if="content.experience" class="mb-6">
<h3 class="text-lg font-semibold text-dark-gray border-b border-gray-200 pb-2 mb-3">Опыт работы</h3>
<p class="text-gray-700 whitespace-pre-line">{{ content.experience }}</p>
</section>
<!-- Образование -->
<section v-if="content.education" class="mb-6">
<h3 class="text-lg font-semibold text-dark-gray border-b border-gray-200 pb-2 mb-3">Образование</h3>
<p class="text-gray-700 whitespace-pre-line">{{ content.education }}</p>
</section>
<!-- Курсы -->
<section v-if="content.courses" class="mb-6">
<h3 class="text-lg font-semibold text-dark-gray border-b border-gray-200 pb-2 mb-3">Курсы и сертификаты</h3>
<p class="text-gray-700 whitespace-pre-line">{{ content.courses }}</p>
</section>
<!-- Навыки -->
<section v-if="content.skills" class="mb-6">
<h3 class="text-lg font-semibold text-dark-gray border-b border-gray-200 pb-2 mb-3">Ключевые навыки</h3>
<div class="flex flex-wrap gap-2">
<span
v-for="skill in content.skills.split(',').map(s => s.trim())"
:key="skill"
class="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm"
>
{{ skill }}
</span>
</div>
</section>
<!-- Языки -->
<section v-if="content.languages" class="mb-6">
<h3 class="text-lg font-semibold text-dark-gray border-b border-gray-200 pb-2 mb-3">Языки</h3>
<p class="text-gray-700">{{ content.languages }}</p>
</section>
<!-- Проекты -->
<section v-if="content.projects" class="mb-6">
<h3 class="text-lg font-semibold text-dark-gray border-b border-gray-200 pb-2 mb-3">Проекты</h3>
<p class="text-gray-700 whitespace-pre-line">{{ content.projects }}</p>
</section>
<!-- Информация о вакансии (для контекста) -->
<section v-if="content.vacancy" class="mt-8 pt-4 border-t border-gray-200">
<h3 class="text-md font-semibold text-gray-500 mb-2">Вакансия, под которую оптимизировано резюме</h3>
<div class="bg-gray-50 rounded-lg p-4 text-sm">
<p><span class="font-medium">{{ content.vacancy.title }}</span> {{ content.vacancy.company }}</p>
<p v-if="content.vacancy.salary_from || content.vacancy.salary_to">
Зарплата: {{ content.vacancy.salary_from }} {{ content.vacancy.salary_to }}
</p>
<p v-if="content.vacancy.area">{{ content.vacancy.area }}</p>
<p v-if="content.vacancy.experience">Опыт: {{ content.vacancy.experience }}</p>
<p v-if="content.vacancy.schedule">{{ content.vacancy.schedule }}</p>
<p v-if="content.vacancy.employment">{{ content.vacancy.employment }}</p>
<p v-if="content.vacancy.key_skills">Навыки: {{ content.vacancy.key_skills }}</p>
<a
v-if="content.vacancy.url"
:href="content.vacancy.url"
target="_blank"
class="text-accent-blue hover:underline mt-2 inline-block"
>
Открыть вакансию на hh.ru
</a>
</div>
</section>
</div>
</div>
<div v-else class="bg-white rounded-xl shadow-card p-8 text-center text-gray-500">
<p>Контент резюме не найден. Возможно, произошла ошибка при генерации.</p>
</div>
<div class="mt-6 no-print">
<router-link to="/dashboard" class="text-accent-blue hover:underline">
Вернуться в кабинет
</router-link>
</div>
</div>
</div>
</template>
<style scoped>
.shadow-card {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
}
/* При печати отключаем тени */
@media print {
.shadow-card {
box-shadow: none !important;
}
}
</style>

View File

@@ -0,0 +1,210 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useQuestionnairesStore } from '@/stores/questionnaires'
import { useResumesStore } from '@/stores/resumes'
const router = useRouter()
const authStore = useAuthStore()
const questionnairesStore = useQuestionnairesStore()
const resumesStore = useResumesStore()
onMounted(() => {
if (!authStore.isAuthenticated) {
router.replace('/login')
}
})
// Выбор анкеты
const selectedQuestionnaireId = ref(null)
const questionnaireOptions = computed(() =>
questionnairesStore.questionnaires.map(q => ({ value: q.id, label: q.name }))
)
// Ссылка на вакансию
const vacancyUrl = ref('')
// Состояния генерации
const isGenerating = ref(false)
const generatedResumeId = ref(null)
const generatedContent = ref(null)
const errorMessage = ref('')
// Имитация вопросов от нейросети
const aiQuestion = ref('')
const aiAnswer = ref('')
const showAiDialog = ref(false)
const canGenerate = computed(() => {
return selectedQuestionnaireId.value !== null && vacancyUrl.value.trim().length > 0
})
const generateResume = async () => {
if (!canGenerate.value) {
errorMessage.value = 'Выберите анкету и укажите ссылку на вакансию'
return
}
errorMessage.value = ''
isGenerating.value = true
generatedResumeId.value = null
generatedContent.value = null
try {
// Эмуляция вопроса от ИИ (опционально)
showAiDialog.value = true
aiQuestion.value = 'Уточните, какие ключевые навыки вы бы хотели особо выделить в резюме?'
// Ждём ответа пользователя (для демо пропускаем)
const newId = await resumesStore.generateResume(
selectedQuestionnaireId.value,
null, // vacancyId нет, т.к. используем URL
vacancyUrl.value
)
generatedResumeId.value = newId
generatedContent.value = resumesStore.getResumeContent(newId)
showAiDialog.value = false
} catch (e) {
errorMessage.value = 'Ошибка при генерации резюме'
} finally {
isGenerating.value = false
}
}
const resetForm = () => {
selectedQuestionnaireId.value = null
vacancyUrl.value = ''
generatedResumeId.value = null
generatedContent.value = null
errorMessage.value = ''
showAiDialog.value = false
aiQuestion.value = ''
aiAnswer.value = ''
}
const downloadPdf = () => {
window.print()
}
</script>
<template>
<div class="min-h-screen bg-off-white py-8">
<div class="mx-auto max-w-4xl px-6">
<h1 class="text-3xl font-bold text-dark-gray mb-2">Генерация резюме</h1>
<p class="text-gray-600 mb-8">Выберите анкету и вставьте ссылку на вакансию с hh.ru</p>
<!-- Форма ввода -->
<div class="bg-white rounded-xl shadow-card p-6 md:p-8 mb-8">
<div class="space-y-6">
<!-- Выбор анкеты -->
<div>
<label class="block text-sm font-medium text-dark-gray mb-2">Анкета *</label>
<select
v-model="selectedQuestionnaireId"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-accent-blue outline-none bg-white"
>
<option :value="null" disabled>Выберите анкету</option>
<option v-for="opt in questionnaireOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<p v-if="questionnaireOptions.length === 0" class="text-sm text-gray-500 mt-1">
У вас пока нет анкет.
<router-link to="/questionnaires/create" class="text-accent-blue">Создать анкету</router-link>
</p>
</div>
<!-- Ссылка на вакансию -->
<div>
<label class="block text-sm font-medium text-dark-gray mb-2">Ссылка на вакансию (hh.ru) *</label>
<input
v-model="vacancyUrl"
type="url"
placeholder="https://hh.ru/vacancy/..."
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-accent-blue outline-none"
/>
</div>
<!-- Ошибка -->
<div v-if="errorMessage" class="text-red-500 text-sm bg-red-50 p-3 rounded-lg">
{{ errorMessage }}
</div>
<!-- Кнопки -->
<div class="flex gap-4 pt-4">
<button
@click="generateResume"
:disabled="!canGenerate || isGenerating"
class="px-6 py-2 bg-accent-blue text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<span v-if="isGenerating" class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
{{ isGenerating ? 'Генерация...' : 'Сгенерировать резюме' }}
</button>
<button
@click="resetForm"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition"
>
Сбросить
</button>
</div>
</div>
</div>
<!-- Блок с вопросом от ИИ (появляется во время генерации) -->
<div v-if="showAiDialog" class="bg-white rounded-xl shadow-card p-6 md:p-8 mb-8">
<h3 class="text-lg font-semibold text-dark-gray mb-3">Уточнение от ИИ</h3>
<p class="text-gray-700 mb-4">{{ aiQuestion }}</p>
<textarea
v-model="aiAnswer"
rows="3"
class="w-full px-4 py-2 border border-gray-200 rounded-lg focus:ring-2 focus:ring-accent-blue outline-none resize-y"
placeholder="Введите ваш ответ..."
></textarea>
<div class="mt-4 flex justify-end">
<button
@click="showAiDialog = false"
class="px-4 py-2 bg-accent-blue text-white rounded-lg hover:bg-blue-700"
>
Продолжить генерацию
</button>
</div>
<p class="text-xs text-gray-400 mt-2">* Это демонстрация, ответ не влияет на результат</p>
</div>
<!-- Результат генерации -->
<div v-if="generatedContent" class="bg-white rounded-xl shadow-card p-6 md:p-8">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-dark-gray">Результат генерации</h2>
<button
@click="downloadPdf"
class="px-4 py-2 bg-accent-blue text-white rounded-lg hover:bg-blue-700 text-sm"
>
Скачать PDF
</button>
</div>
<!-- Предпросмотр в упрощённом виде (можно использовать тот же шаблон, что и в детальной странице) -->
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50 overflow-auto max-h-96">
<div v-html="generatedContent.html"></div>
</div>
<p class="mt-4 text-sm text-gray-500">
ATS-score: {{ resumesStore.resumes.find(r => r.id === generatedResumeId)?.atsScore || '—' }}%
</p>
<div class="mt-4 flex gap-4">
<router-link :to="`/resumes/${generatedResumeId}`" class="text-accent-blue hover:underline text-sm">
Открыть полную версию
</router-link>
<router-link to="/dashboard" class="text-accent-blue hover:underline text-sm">
Вернуться в кабинет
</router-link>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.shadow-card {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
}
</style>

447
src/views/SecurityView.vue Normal file
View File

@@ -0,0 +1,447 @@
<!-- views/SecurityView.vue -->
<script setup>
import { ref } from 'vue'
// --- Данные аккаунта (Mock) ---
const accountData = ref({
firstName: 'Николай',
lastName: 'Папин',
email: 'papin@example.com'
})
const isEditingName = ref(false)
const editFirstName = ref('')
const editLastName = ref('')
const startEditName = () => {
editFirstName.value = accountData.value.firstName
editLastName.value = accountData.value.lastName
isEditingName.value = true
}
const saveName = () => {
if (editFirstName.value.trim() && editLastName.value.trim()) {
accountData.value.firstName = editFirstName.value.trim()
accountData.value.lastName = editLastName.value.trim()
isEditingName.value = false
alert('Имя и фамилия успешно обновлены (Mock)')
} else {
alert('Пожалуйста, заполните оба поля')
}
}
const cancelEditName = () => {
isEditingName.value = false
editFirstName.value = ''
editLastName.value = ''
}
// --- Смена пароля (Mock) ---
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const showCurrentPassword = ref(false)
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
const changePassword = () => {
if (!currentPassword.value || !newPassword.value || !confirmPassword.value) {
alert('Заполните все поля пароля (Mock)')
return
}
if (newPassword.value !== confirmPassword.value) {
alert('Пароли не совпадают (Mock)')
return
}
if (newPassword.value.length < 6) {
alert('Пароль должен быть не менее 6 символов (Mock)')
return
}
alert('Пароль успешно изменён (Mock)')
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
}
// --- Активные сессии (Mock) ---
const sessions = ref([
{
id: 1,
device: 'desktop',
deviceName: 'Windows PC',
browser: 'Chrome',
browserVersion: '120.0.6099.109',
os: 'Windows 11',
ip: '192.168.1.15',
location: 'Москва, Россия',
current: true,
lastActive: 'Только что',
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36'
},
{
id: 2,
device: 'mobile',
deviceName: 'iPhone 15 Pro',
browser: 'Safari',
browserVersion: '17.2',
os: 'iOS 17.2',
ip: '10.0.0.52',
location: 'Санкт-Петербург, Россия',
current: false,
lastActive: '2 часа назад',
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 Version/17.2 Mobile/15E148 Safari/604.1'
},
{
id: 3,
device: 'tablet',
deviceName: 'iPad Air',
browser: 'Safari',
browserVersion: '17.1',
os: 'iPadOS 17.1',
ip: '172.20.0.3',
location: 'Казань, Россия',
current: false,
lastActive: 'Вчера в 22:15',
userAgent: 'Mozilla/5.0 (iPad; CPU OS 17_1 like Mac OS X) AppleWebKit/605.1.15 Version/17.1 Mobile/15E148 Safari/604.1'
},
{
id: 4,
device: 'desktop',
deviceName: 'MacBook Pro',
browser: 'Firefox',
browserVersion: '122.0',
os: 'macOS 14.2',
ip: '185.22.10.88',
location: 'Екатеринбург, Россия',
current: false,
lastActive: '5 дней назад',
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.2; rv:122.0) Gecko/20100101 Firefox/122.0'
}
])
const getDeviceIcon = (deviceType) => {
const icons = {
desktop: '', // nf-fa-desktop
mobile: '', // nf-fa-mobile
tablet: '', // nf-fa-tablet
laptop: '' // nf-fa-laptop
}
return icons[deviceType] || '' // nf-fa-server as fallback
}
const getDeviceColor = (deviceType) => {
const colors = {
desktop: 'bg-blue-50 text-blue-600',
mobile: 'bg-green-50 text-green-600',
tablet: 'bg-purple-50 text-purple-600',
laptop: 'bg-indigo-50 text-indigo-600'
}
return colors[deviceType] || 'bg-gray-50 text-gray-600'
}
const terminateSession = (sessionId) => {
if (confirm('Завершить эту сессию? Все данные будут удалены с устройства. (Mock)')) {
sessions.value = sessions.value.filter(s => s.id !== sessionId)
alert('Сессия успешно завершена (Mock)')
}
}
const terminateAllOtherSessions = () => {
if (confirm('Завершить все остальные сессии? Текущая сессия останется активной. (Mock)')) {
sessions.value = sessions.value.filter(s => s.current)
alert('Все остальные сессии завершены (Mock)')
}
}
</script>
<template>
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 py-8 px-4 sm:px-6 lg:px-8">
<div class="max-w-4xl mx-auto">
<!-- Заголовок -->
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 flex items-center gap-3">
<span class="text-3xl font-nerd"></span> <!-- nf-fa-lock -->
Мой аккаунт
</h1>
<p class="mt-2 text-gray-600">Управление аккаунтом и активными сессиями</p>
</div>
<!-- Карточка информации об аккаунте -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden mb-6">
<div class="border-b border-gray-200 bg-gray-50/50 px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<span class="font-nerd"></span> <!-- nf-fa-user -->
Информация об аккаунте
</h2>
<p class="text-sm text-gray-600 mt-0.5">Основные данные вашей учётной записи</p>
</div>
<div class="p-6">
<!-- Режим редактирования имени и фамилии -->
<div v-if="isEditingName" class="mb-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Имя</label>
<input
v-model="editFirstName"
type="text"
placeholder="Ваше имя"
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Фамилия</label>
<input
v-model="editLastName"
type="text"
placeholder="Ваша фамилия"
class="w-full px-4 py-2.5 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition"
/>
</div>
</div>
<div class="flex justify-end gap-3 mt-4">
<button
@click="cancelEditName"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 rounded-xl hover:bg-gray-200 transition"
>
Отмена
</button>
<button
@click="saveName"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition"
>
Сохранить
</button>
</div>
</div>
<!-- Режим просмотра -->
<div v-else>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Имя и фамилия</label>
<div class="flex items-center gap-2">
<p class="text-lg font-medium text-gray-900">{{ accountData.firstName }} {{ accountData.lastName }}</p>
<button
@click="startEditName"
class="text-gray-400 hover:text-blue-600 transition font-nerd text-sm"
title="Редактировать"
>
<!-- nf-fa-pencil -->
</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 mb-1">Электронная почта</label>
<p class="text-lg text-gray-900">{{ accountData.email }}</p>
<p class="text-xs text-gray-400 mt-1 font-nerd">Подтверждена </p>
</div>
</div>
</div>
</div>
</div>
<!-- Карточка смены пароля -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden mb-6">
<div class="border-b border-gray-200 bg-gray-50/50 px-6 py-4">
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<span class="font-nerd"></span> <!-- nf-fa-key -->
Изменить пароль
</h2>
<p class="text-sm text-gray-600 mt-0.5">Действие не прекратит существующие сессии</p>
</div>
<div class="p-6">
<div class="space-y-4">
<!-- Текущий пароль -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Текущий пароль</label>
<div class="relative">
<input
v-model="currentPassword"
:type="showCurrentPassword ? 'text' : 'password'"
placeholder="Введите текущий пароль"
class="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition"
/>
<button
@click="showCurrentPassword = !showCurrentPassword"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 font-nerd"
>
{{ showCurrentPassword ? '' : '' }} <!-- nf-fa-eye_slash : nf-fa-eye -->
</button>
</div>
</div>
<!-- Новый пароль -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Новый пароль</label>
<div class="relative">
<input
v-model="newPassword"
:type="showNewPassword ? 'text' : 'password'"
placeholder="Введите новый пароль"
class="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition"
/>
<button
@click="showNewPassword = !showNewPassword"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 font-nerd"
>
{{ showNewPassword ? '' : '' }} <!-- nf-fa-eye_slash : nf-fa-eye -->
</button>
</div>
</div>
<!-- Подтверждение пароля -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1.5">Подтвердите пароль</label>
<div class="relative">
<input
v-model="confirmPassword"
:type="showConfirmPassword ? 'text' : 'password'"
placeholder="Повторите новый пароль"
class="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition"
/>
<button
@click="showConfirmPassword = !showConfirmPassword"
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 font-nerd"
>
{{ showConfirmPassword ? '' : '' }} <!-- nf-fa-eye_slash : nf-fa-eye -->
</button>
</div>
</div>
<!-- Индикатор силы пароля (Mock) -->
<div v-if="newPassword" class="mt-2">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm text-gray-600">Надёжность пароля:</span>
<span class="text-sm font-medium" :class="{
'text-red-500': newPassword.length < 6,
'text-yellow-500': newPassword.length >= 6 && newPassword.length < 10,
'text-green-500': newPassword.length >= 10
}">
{{ newPassword.length < 6 ? 'Слабый' : newPassword.length < 10 ? 'Средний' : 'Сильный' }}
</span>
</div>
<div class="h-1.5 bg-gray-200 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-300"
:class="{
'bg-red-500 w-1/3': newPassword.length < 6,
'bg-yellow-500 w-2/3': newPassword.length >= 6 && newPassword.length < 10,
'bg-green-500 w-full': newPassword.length >= 10
}"
></div>
</div>
</div>
</div>
<div class="flex justify-end mt-6">
<button
@click="changePassword"
class="bg-blue-600 text-white px-6 py-2.5 rounded-xl text-sm font-medium hover:bg-blue-700 transition shadow-sm hover:shadow"
>
Изменить пароль
</button>
</div>
</div>
</div>
<!-- Карточка активных сессий -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
<div class="border-b border-gray-200 bg-gray-50/50 px-6 py-4 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
<span class="font-nerd"></span> <!-- nf-fa-server -->
Активные сессии
</h2>
<p class="text-sm text-gray-600 mt-0.5">Устройства, на которых выполнен вход в аккаунт</p>
</div>
<button
@click="terminateAllOtherSessions"
v-if="sessions.filter(s => !s.current).length > 0"
class="text-sm text-red-600 hover:text-red-700 font-medium px-3 py-1.5 rounded-lg hover:bg-red-50 transition"
>
Завершить все остальные
</button>
</div>
<div class="divide-y divide-gray-100">
<div
v-for="session in sessions"
:key="session.id"
class="p-5 hover:bg-gray-50/50 transition"
>
<div class="flex items-start justify-between gap-4">
<div class="flex items-start gap-4 flex-1">
<!-- Иконка устройства -->
<div :class="['w-12 h-12 rounded-xl flex items-center justify-center text-2xl font-nerd', getDeviceColor(session.device)]">
{{ getDeviceIcon(session.device) }}
</div>
<div class="flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-semibold text-gray-900">{{ session.deviceName }}</span>
<span v-if="session.current" class="bg-green-100 text-green-700 text-xs px-2 py-0.5 rounded-full font-medium">
Текущее устройство
</span>
</div>
<div class="mt-1.5 space-y-1">
<div class="flex items-center gap-2 text-sm text-gray-600">
<span class="w-20 text-gray-500">Браузер:</span>
<span>{{ session.browser }} {{ session.browserVersion }}</span>
</div>
<div class="flex items-center gap-2 text-sm text-gray-600">
<span class="w-20 text-gray-500">ОС:</span>
<span>{{ session.os }}</span>
</div>
<div class="flex items-center gap-2 text-sm text-gray-600">
<span class="w-20 text-gray-500">IP адрес:</span>
<span class="font-mono">{{ session.ip }}</span>
<span class="text-gray-400"></span>
<span>{{ session.location }}</span>
</div>
<div class="flex items-center gap-2 text-sm text-gray-600">
<span class="w-20 text-gray-500">User Agent:</span>
<span class="text-xs text-gray-400 truncate max-w-md">{{ session.userAgent }}</span>
</div>
<div class="flex items-center gap-2 text-sm text-gray-600">
<span class="w-20 text-gray-500">Активность:</span>
<span class="text-gray-500">{{ session.lastActive }}</span>
</div>
</div>
</div>
</div>
<button
@click="terminateSession(session.id)"
class="text-red-500 hover:text-white text-sm font-medium px-4 py-2 rounded-xl border border-red-200 hover:bg-red-500 transition-all"
:disabled="session.current"
:class="{ 'opacity-50 cursor-not-allowed border-gray-200 hover:bg-transparent hover:text-red-500': session.current }"
>
Завершить
</button>
</div>
</div>
</div>
<!-- Подвал с информацией -->
<div class="bg-gray-50/30 px-6 py-4 border-t border-gray-100">
<p class="text-xs text-gray-500 flex items-center gap-2">
<span class="font-nerd"></span> <!-- nf-fa-info_circle -->
Это демонстрационная страница с Mock-данными. Кнопки выполняют визуальные изменения без реальных запросов к серверу.
</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.font-nerd {
font-family: 'Iosevka Nerd Font Propo', 'Iosevka', monospace;
}
</style>

View File

@@ -0,0 +1,203 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useVacanciesStore } from '@/stores/vacancies'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const store = useVacanciesStore()
// Проверка авторизации
onMounted(() => {
if (!authStore.isAuthenticated) {
router.replace('/login')
}
})
const isEditMode = computed(() => !!route.params.id)
const vacancyId = computed(() => route.params.id ? Number(route.params.id) : null)
const pageTitle = computed(() => isEditMode.value ? 'Редактирование вакансии' : 'Новая вакансия')
// Поля формы
const form = ref({
offer_name: '',
employer_name: '',
salary_from: '',
salary_to: '',
area_name: '',
experience: '',
schedule: '',
employment: '',
key_skills: '',
url: '',
status: 'Активна',
})
// Загрузка данных при редактировании
if (isEditMode.value) {
const existing = store.vacancies.find(v => v.id === vacancyId.value)
if (existing) {
const details = store.getVacancyDetails(vacancyId.value)
form.value = {
offer_name: details.offer_name || existing.title,
employer_name: details.employer_name || existing.company,
salary_from: details.salary_from || '',
salary_to: details.salary_to || '',
area_name: details.area_name || '',
experience: details.experience || '',
schedule: details.schedule || '',
employment: details.employment || '',
key_skills: details.key_skills || '',
url: existing.url || '',
status: existing.status || 'Активна',
}
} else {
router.replace('/dashboard')
}
}
// Состояние загрузки и ошибок
const isLoading = ref(false)
const errorMessage = ref('')
const fieldErrors = ref({})
// Определения полей
const fieldDefinitions = [
{ key: 'offer_name', label: 'Название вакансии', required: true, type: 'text', placeholder: 'Senior Frontend Developer' },
{ key: 'employer_name', label: 'Работодатель', required: true, type: 'text', placeholder: 'ООО "Технологии"' },
{ key: 'salary_from', label: 'Зарплата от', required: false, type: 'text', placeholder: '200000' },
{ key: 'salary_to', label: 'Зарплата до', required: false, type: 'text', placeholder: '300000' },
{ key: 'area_name', label: 'Город / регион', required: false, type: 'text', placeholder: 'Москва' },
{ key: 'experience', label: 'Требуемый опыт', required: false, type: 'text', placeholder: 'От 3 до 6 лет' },
{ key: 'schedule', label: 'График работы', required: false, type: 'text', placeholder: 'Полный день' },
{ key: 'employment', label: 'Тип занятости', required: false, type: 'text', placeholder: 'Полная занятость' },
{ key: 'key_skills', label: 'Ключевые навыки', required: false, type: 'textarea', placeholder: 'Перечислите навыки через запятую' },
{ key: 'url', label: 'Ссылка на вакансию', required: false, type: 'text', placeholder: 'https://hh.ru/vacancy/...' },
{ key: 'status', label: 'Статус', required: false, type: 'select', options: ['Активна', 'В архиве'] },
]
// Валидация
const validateForm = () => {
fieldErrors.value = {}
for (const def of fieldDefinitions) {
if (def.required) {
const val = form.value[def.key]
if (!val || (typeof val === 'string' && !val.trim())) {
fieldErrors.value[def.key] = `${def.label} обязательно для заполнения`
}
}
}
return Object.keys(fieldErrors.value).length === 0
}
// Сохранение
const handleSubmit = async () => {
if (!validateForm()) {
const firstError = document.querySelector('.border-red-400')
if (firstError) firstError.scrollIntoView({ behavior: 'smooth', block: 'center' })
return
}
isLoading.value = true
errorMessage.value = ''
try {
const id = isEditMode.value ? vacancyId.value : null
store.saveVacancy(id, form.value)
router.push('/dashboard')
} catch (e) {
errorMessage.value = 'Ошибка при сохранении вакансии'
} finally {
isLoading.value = false
}
}
const cancel = () => {
router.back()
}
</script>
<template>
<div class="min-h-screen bg-off-white py-8">
<div class="mx-auto max-w-3xl px-6">
<div class="mb-6">
<h1 class="text-3xl font-bold text-dark-gray">{{ pageTitle }}</h1>
<p class="mt-2 text-gray-600">Заполните информацию о вакансии. Поля со * обязательны.</p>
</div>
<div class="bg-white rounded-xl shadow-card p-6 md:p-8">
<form @submit.prevent="handleSubmit">
<div class="space-y-5">
<div v-for="def in fieldDefinitions" :key="def.key">
<label class="block text-sm font-medium text-dark-gray mb-1">
{{ def.label }} <span v-if="def.required" class="text-red-500">*</span>
</label>
<!-- Text input -->
<input
v-if="def.type === 'text'"
v-model="form[def.key]"
type="text"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-accent-blue outline-none transition"
:class="fieldErrors[def.key] ? 'border-red-400' : 'border-gray-200'"
:placeholder="def.placeholder"
/>
<!-- Textarea -->
<textarea
v-else-if="def.type === 'textarea'"
v-model="form[def.key]"
rows="3"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-accent-blue outline-none transition resize-y"
:class="fieldErrors[def.key] ? 'border-red-400' : 'border-gray-200'"
:placeholder="def.placeholder"
></textarea>
<!-- Select -->
<select
v-else-if="def.type === 'select'"
v-model="form[def.key]"
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-accent-blue outline-none transition bg-white"
:class="fieldErrors[def.key] ? 'border-red-400' : 'border-gray-200'"
>
<option v-for="opt in def.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
<p v-if="fieldErrors[def.key]" class="text-red-500 text-sm mt-1">{{ fieldErrors[def.key] }}</p>
</div>
</div>
<div v-if="errorMessage" class="mt-6 text-red-500 text-sm bg-red-50 p-3 rounded-lg">
{{ errorMessage }}
</div>
<div class="mt-8 flex justify-end gap-4">
<button
type="button"
@click="cancel"
class="px-6 py-2 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition"
>
Отмена
</button>
<button
type="submit"
:disabled="isLoading"
class="px-6 py-2 bg-accent-blue text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ isLoading ? 'Сохранение...' : (isEditMode ? 'Сохранить изменения' : 'Создать вакансию') }}
</button>
</div>
</form>
</div>
</div>
</div>
</template>
<style scoped>
.shadow-card {
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.05), 0 2px 4px -2px rgb(0 0 0 / 0.05);
}
</style>

16
vite.config.js Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
export default defineConfig({
plugins: [vue(), tailwindcss()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
optimizeDeps: {
exclude: ['zustand'], // Исключаем предварительную сборку для избежания анализа react
},
})