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

2
.env Normal file
View File

@@ -0,0 +1,2 @@
VITE_API_BASE_URL=https://test-taiga.lpr12.ru
VITE_ROOT_BASE_URL=tableview

30
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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

1
env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

13
index.html Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

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>

12
tsconfig.app.json Normal file
View 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
View File

@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

19
tsconfig.node.json Normal file
View 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
View 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)),
},
},
});