Initial release

This commit is contained in:
2025-05-30 17:07:06 +03:00
commit cd63464bc2
28 changed files with 988 additions and 0 deletions

75
src/App.vue Normal file
View File

@@ -0,0 +1,75 @@
<template>
<div id="app-container">
<header v-if="authStore.isAuthenticated">
<h1>Добро пожаловать, {{ authStore.fullName || authStore.username }}, в базированную базу!</h1>
<button @click="handleLogout" class="logout-button">Logout</button>
</header>
<main>
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from "@/stores/auth";
const authStore = useAuthStore();
const handleLogout = () => {
authStore.logout();
};
</script>
<style>
#app-container {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
color: #2c3e50;
margin: 0 auto;
padding: 20px;
max-width: 100%;
}
header {
display: flex;
justify-content: space-evenly;
align-items: center;
padding: 10px 0;
margin-bottom: 20px;
border-bottom: 1px solid #eee;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
th,
td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
th {
background-color: #f2f2f2;
}
.userstory-table-container {
overflow-x: auto;
}
ul {
list-style-type: none;
padding: 0;
}
li {
margin-bottom: 10px;
}
.logout-button {
background-color: #f44336;
border: none;
color: white;
padding: 15px 32px;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
font-weight: bold;
}
</style>

86
src/assets/base.css Normal file
View File

@@ -0,0 +1,86 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

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

@@ -0,0 +1,35 @@
@import "./base.css";
#app {
max-width: 1800px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}

14
src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import "./assets/main.css"; // Если есть общие стили
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");

44
src/router/index.ts Normal file
View File

@@ -0,0 +1,44 @@
import { createRouter, createWebHistory } from "vue-router";
import ProjectList from "../views/ProjectList.vue";
import Login from "../views/Login.vue";
import { useAuthStore } from "@/stores/auth";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), // BASE_URL из vite.config.ts
routes: [
{
path: "/login",
name: "Login",
component: Login,
meta: { requiresGuest: true },
},
{
path: "/",
name: "ProjectList",
component: ProjectList,
meta: { requiresAuth: true },
},
// Каждый проект (доска) имеет отдельную страницу, возможно такой вариант будет лучше, особенно для больших отделений
{
path: "/project/:projectId",
name: "ProjectDetails",
component: () => import("../views/Project.vue"),
props: true,
meta: { requiresAuth: true },
},
],
});
router.beforeEach((to, from, next) => {
const authStore = useAuthStore();
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: "Login" });
} else if (to.meta.requiresGuest && authStore.isAuthenticated) {
next({ name: "ProjectList" });
} else {
next();
}
});
export default router;

50
src/services/api.ts Normal file
View File

@@ -0,0 +1,50 @@
import { useAuthStore } from "@/stores/auth";
import router from "@/router";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
const authStore = useAuthStore();
const headers = new Headers(options.headers || {});
if (authStore.token) {
// В localStorage токен уже с кавычками, приходится парсить его, чтобы использовать без кавычек
const tokenValue = JSON.parse(authStore.token);
headers.set("Authorization", `Bearer ${tokenValue}`);
}
if (options.method === "POST" || options.method === "PUT" || options.method === "PATCH") {
if (!headers.has("Content-Type") && !(options.body instanceof FormData)) {
headers.set("Content-Type", "application/json");
}
}
const response = await fetch(`${API_BASE_URL}${url}`, {
...options,
headers,
});
if (response.status === 401 || response.status === 403) {
// Если не залогинен (или токен протух), то перенаправляем на логин
if (router.currentRoute.value.name !== "Login") {
authStore.logout(); // разлогиниваем, если токен протух (возвращается 401/403 при наличии токена)
router.push({ name: "Login" });
}
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(errorData.detail || errorData.message || `Request failed with status ${response.status}`);
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(errorData.detail || errorData.message || `Request failed with status ${response.status}`);
}
// Если пустой ответ (например, 204 No Content)
if (response.status === 204) {
return null as T;
}
return response.json() as Promise<T>;
}
export default request;

View File

@@ -0,0 +1,13 @@
import request from "./api";
import type { AuthResponse } from "@/types/api";
const login = (username: string, password: string): Promise<AuthResponse> => {
return request<AuthResponse>("/api/v1/auth", {
method: "POST",
body: JSON.stringify({ type: "normal", username, password }),
});
};
export default {
login,
};

63
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,63 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import type { AuthResponse } from "@/types/api";
import router from "@/router";
import authService from "@/services/authService";
export const useAuthStore = defineStore("auth", () => {
const token = ref<string | null>(localStorage.getItem("token"));
const refreshToken = ref<string | null>(localStorage.getItem("refresh"));
const userId = ref<number | null>(JSON.parse(localStorage.getItem("userId") || "null"));
const username = ref<string | null>(localStorage.getItem("username"));
const fullName = ref<string | null>(localStorage.getItem("fullName"));
const isAuthenticated = computed(() => !!token.value);
function setAuthData(data: AuthResponse) {
// Сохраняем токены с кавычками, как требует ТЗ
localStorage.setItem("token", JSON.stringify(data.auth_token));
localStorage.setItem("refresh", JSON.stringify(data.refresh));
localStorage.setItem("userId", JSON.stringify(data.id));
localStorage.setItem("username", data.username);
localStorage.setItem("fullName", data.full_name);
token.value = JSON.stringify(data.auth_token);
refreshToken.value = JSON.stringify(data.refresh);
userId.value = data.id;
username.value = data.username;
fullName.value = data.full_name;
}
function logout() {
localStorage.removeItem("token");
localStorage.removeItem("refresh");
localStorage.removeItem("userId");
localStorage.removeItem("username");
localStorage.removeItem("fullName");
token.value = null;
refreshToken.value = null;
userId.value = null;
username.value = null;
fullName.value = null;
router.push({ name: "Login" });
}
async function login(email: string, pass: string) {
const data = await authService.login(email, pass);
setAuthData(data);
}
return {
token,
refreshToken,
userId,
username,
fullName,
isAuthenticated,
login,
logout,
setAuthData,
};
});

98
src/stores/data.ts Normal file
View File

@@ -0,0 +1,98 @@
import { defineStore } from "pinia";
import { ref, reactive } from "vue";
import type { Project, ProjectField, Userstory, UserstoryAttributeValuesResponse } from "@/types/api";
import request from "@/services/api";
export const useDataStore = defineStore("data", () => {
const projects = ref<Project[]>([]);
const projectFieldsMap = reactive<Map<number, ProjectField[]>>(new Map()); // Ключ - ID проекта (доски), значение - массив полей для userstories
const userstoriesMap = reactive<Map<number, Userstory[]>>(new Map()); // Ключ - ID userstory (карточки), значение - объект с кастомными полями
const userstoryAttributesMap = reactive<Map<number, UserstoryAttributeValuesResponse["attributes_values"]>>(new Map()); // Ключ - ID кастомного поля, значение - значение кастомного поля (заголовок)
const loadingProjects = ref(false);
const loadingFields = ref(false);
const loadingUserstories = ref(false);
const loadingAttributes = ref(false);
async function fetchProjects() {
loadingProjects.value = true;
try {
const data = await request<Project[]>("/api/v1/projects");
projects.value = data;
} catch (error) {
console.error("Failed to fetch projects:", error);
} finally {
loadingProjects.value = false;
}
}
async function fetchProjectFields(projectId: number) {
if (projectFieldsMap.has(projectId)) return;
loadingFields.value = true;
try {
const data = await request<ProjectField[]>(`/api/v1/userstory-custom-attributes?project=${projectId}`);
projectFieldsMap.set(projectId, data);
} catch (error) {
console.error(`Failed to fetch fields for project ${projectId}:`, error);
} finally {
loadingFields.value = false;
}
}
async function fetchUserstories(projectId: number) {
if (userstoriesMap.has(projectId)) return;
loadingUserstories.value = true;
try {
const data = await request<Userstory[]>(`/api/v1/userstories?project=${projectId}`);
userstoriesMap.set(projectId, data);
// Можно попробовать загружать кастомные поля при загрузке карточки
// data.forEach(us => fetchUserstoryAttributes(us.id));
} catch (error) {
console.error(`Failed to fetch userstories for project ${projectId}:`, error);
} finally {
loadingUserstories.value = false;
}
}
async function fetchUserstoryAttributes(userstoryId: number) {
if (userstoryAttributesMap.has(userstoryId)) return;
// loadingAttributes.value = true; // Если общий лоадер
try {
const data = await request<UserstoryAttributeValuesResponse>(`/api/v1/userstories/custom-attributes-values/${userstoryId}`);
userstoryAttributesMap.set(userstoryId, data.attributes_values);
} catch (error) {
console.error(`Failed to fetch attributes for userstory ${userstoryId}:`, error);
} finally {
// loadingAttributes.value = false;
}
}
function getProjectById(projectId: number): Project | undefined {
return projects.value.find((p) => p.id === projectId);
}
function clearData() {
// При логауте очищаем загруженные данные
projects.value = [];
projectFieldsMap.clear();
userstoriesMap.clear();
userstoryAttributesMap.clear();
}
return {
projects,
projectFieldsMap,
userstoriesMap,
userstoryAttributesMap,
loadingProjects,
loadingFields,
loadingUserstories,
loadingAttributes,
fetchProjects,
fetchProjectFields,
fetchUserstories,
fetchUserstoryAttributes,
getProjectById,
clearData,
};
});

44
src/types/api.ts Normal file
View File

@@ -0,0 +1,44 @@
export interface AuthResponse {
id: number;
username: string;
full_name: string;
auth_token: string;
refresh: string;
}
export interface Project {
id: number;
name: string;
slug: string;
}
export interface ProjectField {
id: number;
name: string;
description: string;
type: "text" | "number" | "date" | string; // Возможно, понадобятся другие типы, но пока так
order: number;
project: number; // ID проекта (доски)
}
export interface UserstoryStatusInfo {
name: string;
color: string;
is_closed: boolean;
}
export interface Userstory {
id: number;
subject: string;
status: number; // ID статуса
status_extra_info: UserstoryStatusInfo;
project: number; // ID проекта (доски)
}
export interface UserstoryAttributeValuesResponse {
attributes_values: {
[fieldId: string]: string | number | null;
};
version: number;
user_story: number; // ID юзерстори (карточки)
}

93
src/views/Login.vue Normal file
View File

@@ -0,0 +1,93 @@
<template>
<div class="login-container">
<h2>Логин в базовую базу</h2>
<form @submit.prevent="handleLogin">
<div>
<label for="email">Email:</label>
<input type="email" id="email" v-model="email" required />
</div>
<div>
<label for="password">Пароль:</label>
<input type="password" id="password" v-model="password" required />
</div>
<button type="submit" :disabled="loading">
{{ loading ? "Логинимся..." : "Логин" }}
</button>
<p v-if="error" class="error-message">{{ error }}</p>
</form>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "@/stores/auth";
const email = ref("");
const password = ref("");
const loading = ref(false);
const error = ref<string | null>(null);
const authStore = useAuthStore();
const router = useRouter();
const handleLogin = async () => {
loading.value = true;
error.value = null;
try {
await authStore.login(email.value, password.value);
router.push({ name: "ProjectList" });
} catch (err: any) {
error.value = err.message || "Не получилось залогиниться. Проверьте правильность Email/пароля.";
console.error("Login error:", err);
} finally {
loading.value = false;
}
};
</script>
<style scoped>
.login-container {
max-width: 400px;
margin: 50px auto;
padding: 20px;
border: 1px solid #ccc;
border-radius: 5px;
}
.login-container div {
margin-bottom: 15px;
}
.login-container label {
display: block;
margin-bottom: 5px;
font-size: 16px;
font-weight: bold;
}
.login-container input {
width: calc(100% - 12px);
padding: 8px;
border: 1px solid #ddd;
border-radius: 3px;
}
.login-container button {
padding: 15px 32px;
background-color: #04aa6d;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
text-align: center;
text-decoration: none;
display: inline-block;
font-size: 16px;
font-weight: bold;
}
.login-container button:disabled {
background-color: #aaa;
}
.error-message {
color: red;
margin-top: 10px;
}
</style>

140
src/views/Project.vue Normal file
View File

@@ -0,0 +1,140 @@
<template>
<div class="project-container">
<h3>{{ projectData.name }} (ID: {{ projectData.id }})</h3>
<div v-if="isLoadingInitialData">Загружаем карточки...</div>
<div v-else>
<div v-if="userstoriesForProject && userstoriesForProject.length === 0">Нет карточек на доске.</div>
<div v-else-if="userstoriesForProject" class="userstory-table-container">
<table>
<thead>
<tr>
<th v-for="header in tableHeaders" :key="header.key">{{ header.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="userstory in userstoriesForProject" :key="userstory.id">
<td v-for="header in tableHeaders" :key="`${userstory.id}-${header.key}`">
{{ getCellValue(userstory, header) }}
</td>
</tr>
<tr v-if="isLoadingAttributesForAnyStory">
<td :colspan="tableHeaders.length">Загружаем данные карточек...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from "vue";
import { useDataStore } from "@/stores/data";
import type { Project, Userstory, ProjectField, UserstoryStatusInfo } from "@/types/api";
interface TableHeader {
key: string;
label: string;
isAttribute: boolean;
attributeId?: number;
}
const props = defineProps<{
projectData: Project;
}>();
const dataStore = useDataStore();
const isLoadingInitialData = ref(true);
const isLoadingAttributesForAnyStory = ref(false);
const tableHeaders = computed<TableHeader[]>(() => {
const headers: TableHeader[] = [
{ key: "id", label: "ID", isAttribute: false },
{ key: "subject", label: "Заголовок карточки", isAttribute: false },
{ key: "status", label: "Статус", isAttribute: false },
];
const fields = dataStore.projectFieldsMap.get(props.projectData.id);
if (fields) {
// Сортируем поля, возможно не нужно
const sortedFields = [...fields].sort((a, b) => a.order - b.order);
sortedFields.forEach((field) => {
headers.push({
key: field.name,
label: field.name,
isAttribute: true,
attributeId: field.id,
});
});
}
return headers;
});
const userstoriesForProject = computed(() => {
return dataStore.userstoriesMap.get(props.projectData.id);
});
watch(
userstoriesForProject,
async (newUserstories) => {
if (newUserstories && newUserstories.length > 0) {
isLoadingAttributesForAnyStory.value = true;
const attributePromises = newUserstories
.filter((us) => !dataStore.userstoryAttributesMap.has(us.id))
.map((us) => dataStore.fetchUserstoryAttributes(us.id));
if (attributePromises.length > 0) {
await Promise.all(attributePromises);
}
isLoadingAttributesForAnyStory.value = false;
}
},
{ immediate: false },
);
onMounted(async () => {
isLoadingInitialData.value = true;
try {
await Promise.all([dataStore.fetchProjectFields(props.projectData.id), dataStore.fetchUserstories(props.projectData.id)]);
} catch (error) {
console.error(`Error loading data for project ${props.projectData.id}:`, error);
} finally {
isLoadingInitialData.value = false;
}
});
function getCellValue(userstory: Userstory, header: TableHeader): string | number | UserstoryStatusInfo | null {
if (!header.isAttribute) {
if (header.key === "status") {
return userstory.status_extra_info?.name || userstory.status.toString();
}
return userstory[header.key as keyof Userstory] ?? "";
} else {
if (header.attributeId === undefined) return "N/A (no attr ID)";
const attributes = dataStore.userstoryAttributesMap.get(userstory.id);
if (attributes) {
// Ключи для кастомных полей приходят как строки
return attributes[header.attributeId.toString()] ?? "";
}
return isLoadingAttributesForAnyStory.value ? "..." : "";
}
}
</script>
<style scoped>
.project-container {
margin-bottom: 30px;
padding: 15px;
border: 1px solid #e0e0e0;
border-radius: 4px;
}
.userstory-table-container {
max-height: 500px;
overflow-y: auto;
overflow-x: auto;
}
table thead tr th {
font-weight: bold;
}
</style>

32
src/views/ProjectList.vue Normal file
View File

@@ -0,0 +1,32 @@
<template>
<div id="projects-list-container">
<h2>Доступные доски</h2>
<div v-if="dataStore.loadingProjects">Загрузка досок...</div>
<div v-else-if="dataStore.projects.length === 0 && !dataStore.loadingProjects">Доски не найдены.</div>
<ul v-else>
<li v-for="project in dataStore.projects" :key="project.id">
<ProjectComponent :project-data="project" />
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useDataStore } from "@/stores/data";
import ProjectComponent from "./Project.vue";
const dataStore = useDataStore();
onMounted(async () => {
if (dataStore.projects.length === 0) {
await dataStore.fetchProjects();
}
});
</script>
<style scoped>
#projects-list-container h1 {
margin-bottom: 20px;
}
</style>