Initial release
This commit is contained in:
2
.env
Normal file
2
.env
Normal file
@@ -0,0 +1,2 @@
|
||||
VITE_API_BASE_URL=https://test-taiga.lpr12.ru
|
||||
VITE_ROOT_BASE_URL=tableview
|
||||
30
.gitignore
vendored
Normal file
30
.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
17
Caddyfile
Normal file
17
Caddyfile
Normal file
@@ -0,0 +1,17 @@
|
||||
:80 {
|
||||
# Ensure VITE_ROOT_BASE_URL in your .env file is 'tableview' (no slashes)
|
||||
handle_path /{$VITE_ROOT_BASE_URL}/* {
|
||||
root * /usr/share/caddy
|
||||
|
||||
# try_files will attempt to serve the {path} directly.
|
||||
# If {path} (e.g., /assets/app.js after /tableview/ is stripped) is found, it's served.
|
||||
# If {path} (e.g., /some-spa-route after /tableview/ is stripped) is not found,
|
||||
# it falls back to serving /index.html.
|
||||
try_files {path} /index.html
|
||||
|
||||
# file_server serves the file determined by try_files (either the original asset or index.html).
|
||||
file_server
|
||||
|
||||
encode gzip
|
||||
}
|
||||
}
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:18-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_BASE_URL
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
ARG VITE_ROOT_BASE_URL
|
||||
ENV VITE_ROOT_BASE_URL=${VITE_ROOT_BASE_URL}
|
||||
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM caddy:2-alpine
|
||||
|
||||
COPY Caddyfile /etc/caddy/Caddyfile
|
||||
COPY --from=builder /app/dist /usr/share/caddy
|
||||
|
||||
EXPOSE 80
|
||||
13
LICENSE.md
Normal file
13
LICENSE.md
Normal file
@@ -0,0 +1,13 @@
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
|
||||
Copyright (C) 2025 Vladimir Zagainov <mrixs@mrixs.me>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim or modified
|
||||
copies of this license document, and changing it is allowed as long
|
||||
as the name is changed.
|
||||
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
Change VITE_API_BASE_URL (taiga api) and VITE_ROOT_BASE_URL (frontend base path) in .env file
|
||||
Change project (table) size in styles .userstory-table-container max-height: 500px;
|
||||
Change port in docker-compose.yml
|
||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal file
@@ -0,0 +1,14 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
|
||||
VITE_ROOT_BASE_URL: ${VITE_ROOT_BASE_URL}
|
||||
ports:
|
||||
- "80:80"
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Базовая база</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "vue-caddy-app",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.1",
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"npm-run-all2": "^7.0.2",
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^6.2.4",
|
||||
"vite-plugin-vue-devtools": "^7.7.2",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
}
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
75
src/App.vue
Normal file
75
src/App.vue
Normal 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
86
src/assets/base.css
Normal 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
35
src/assets/main.css
Normal 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
14
src/main.ts
Normal 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
44
src/router/index.ts
Normal 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
50
src/services/api.ts
Normal 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;
|
||||
13
src/services/authService.ts
Normal file
13
src/services/authService.ts
Normal 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
63
src/stores/auth.ts
Normal 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
98
src/stores/data.ts
Normal 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
44
src/types/api.ts
Normal 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
93
src/views/Login.vue
Normal 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
140
src/views/Project.vue
Normal 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
32
src/views/ProjectList.vue
Normal 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>
|
||||
12
tsconfig.app.json
Normal file
12
tsconfig.app.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
19
tsconfig.node.json
Normal file
19
tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
|
||||
// const ROOT_BASE_URL = "/" + process.env.VITE_ROOT_BASE_URL + "/" || "/";
|
||||
const ROOT_BASE_URL = "/tableview/";
|
||||
export default defineConfig({
|
||||
base: ROOT_BASE_URL,
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user