initial commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
20
Dockerfile
Normal file
20
Dockerfile
Normal 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
5
README.md
Normal 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
13
index.html
Normal 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
2533
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal 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
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
24
public/icons.svg
Normal 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
11
src/App.vue
Normal 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
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
38
src/assets/main.css
Normal file
38
src/assets/main.css
Normal 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
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
1
src/assets/vue.svg
Normal 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 |
32
src/components/AppHeader.vue
Normal file
32
src/components/AppHeader.vue
Normal 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
14
src/main.js
Normal 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
82
src/router/index.js
Normal 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
19
src/services/api.js
Normal 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
114
src/stores/auth.js
Normal 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'],
|
||||
},
|
||||
})
|
||||
128
src/stores/questionnaires.js
Normal file
128
src/stores/questionnaires.js
Normal 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
228
src/stores/resumes.js
Normal 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
161
src/stores/vacancies.js
Normal 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
297
src/style.css
Normal 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
53
src/views/AboutView.vue
Normal 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
367
src/views/DashboardView.vue
Normal 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
119
src/views/HomeView.vue
Normal 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: 'ATS‑score и рекомендации по улучшению каждого документа.',
|
||||
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
103
src/views/LoginView.vue
Normal 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>
|
||||
221
src/views/QuestionnaireFormView.vue
Normal file
221
src/views/QuestionnaireFormView.vue
Normal 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
158
src/views/RegisterView.vue
Normal 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>
|
||||
204
src/views/ResumeDetailView.vue
Normal file
204
src/views/ResumeDetailView.vue
Normal 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>
|
||||
210
src/views/ResumeGenerateView.vue
Normal file
210
src/views/ResumeGenerateView.vue
Normal 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
447
src/views/SecurityView.vue
Normal 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>
|
||||
203
src/views/VacancyFormView.vue
Normal file
203
src/views/VacancyFormView.vue
Normal 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
16
vite.config.js
Normal 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
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user