This commit is contained in:
2026-10-07 23:09:47 +07:00
parent 53c21ce171
commit 393207fe21
11 changed files with 917 additions and 5 deletions
@@ -1111,7 +1111,86 @@ export type AcceptTicketRsp = {
ticket: TicketPage | undefined;
};
// Developer tasks use new, in_progress, review and done statuses.
export type DeveloperTask = {
ID: number | undefined;
title: string | undefined;
description: string | undefined;
status: string | undefined;
// Omitted assignee means the task is unassigned.
assigneeID?: number;
assigneeName: string | undefined;
authorID?: number;
// Calendar deadline in YYYY-MM-DD format; empty means no deadline.
dueDate: string | undefined;
createdAt: wellKnownTimestamp | undefined;
updatedAt: wellKnownTimestamp | undefined;
// Required for updates and deletion to detect concurrent edits.
version: number | undefined;
};
export type TaskAssignee = {
ID: number | undefined;
name: string | undefined;
};
export type GetTasksReq = {
};
export type GetTasksRsp = {
tasks: DeveloperTask[] | undefined;
};
export type GetTaskAssigneesReq = {
};
export type GetTaskAssigneesRsp = {
assignees: TaskAssignee[] | undefined;
};
export type CreateTaskReq = {
title: string | undefined;
description: string | undefined;
status: string | undefined;
assigneeID?: number;
dueDate: string | undefined;
};
export type UpdateTaskReq = {
ID: number | undefined;
title: string | undefined;
description: string | undefined;
status: string | undefined;
assigneeID?: number;
dueDate: string | undefined;
version: number | undefined;
};
export type SetTaskStatusReq = {
ID: number | undefined;
status: string | undefined;
version: number | undefined;
};
export type DeleteTaskReq = {
ID: number | undefined;
version: number | undefined;
};
export type TaskRsp = {
task: DeveloperTask | undefined;
};
export type DeleteTaskRsp = {
};
export interface EveningDetectiveServer {
GetTasks(request: GetTasksReq): Promise<GetTasksRsp>;
GetTaskAssignees(request: GetTaskAssigneesReq): Promise<GetTaskAssigneesRsp>;
CreateTask(request: CreateTaskReq): Promise<TaskRsp>;
UpdateTask(request: UpdateTaskReq): Promise<TaskRsp>;
SetTaskStatus(request: SetTaskStatusReq): Promise<TaskRsp>;
DeleteTask(request: DeleteTaskReq): Promise<DeleteTaskRsp>;
GetParticipation(request: GetParticipationReq): Promise<ParticipationRsp>;
AnswerParticipation(request: AnswerParticipationReq): Promise<ParticipationRsp>;
Ping(request: PingReq): Promise<PingRsp>;
@@ -1234,6 +1313,117 @@ export function createEveningDetectiveServerClient(
handler: RequestHandler
): EveningDetectiveServer {
return {
GetTasks(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/tasks`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "GET",
body,
}, {
service: "EveningDetectiveServer",
method: "GetTasks",
}) as Promise<GetTasksRsp>;
},
GetTaskAssignees(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/tasks/assignees`; // eslint-disable-line quotes
const body = null;
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "GET",
body,
}, {
service: "EveningDetectiveServer",
method: "GetTaskAssignees",
}) as Promise<GetTaskAssigneesRsp>;
},
CreateTask(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/tasks`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "POST",
body,
}, {
service: "EveningDetectiveServer",
method: "CreateTask",
}) as Promise<TaskRsp>;
},
UpdateTask(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.ID) {
throw new Error("missing required field request.ID");
}
const path = `api/tasks/${request.ID}`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "PUT",
body,
}, {
service: "EveningDetectiveServer",
method: "UpdateTask",
}) as Promise<TaskRsp>;
},
SetTaskStatus(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.ID) {
throw new Error("missing required field request.ID");
}
const path = `api/tasks/${request.ID}/status`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "POST",
body,
}, {
service: "EveningDetectiveServer",
method: "SetTaskStatus",
}) as Promise<TaskRsp>;
},
DeleteTask(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
if (!request.ID) {
throw new Error("missing required field request.ID");
}
const path = `api/tasks/${request.ID}/delete`; // eslint-disable-line quotes
const body = JSON.stringify(request);
const queryParams: string[] = [];
let uri = path;
if (queryParams.length > 0) {
uri += `?${queryParams.join("&")}`
}
return handler({
path: uri,
method: "POST",
body,
}, {
service: "EveningDetectiveServer",
method: "DeleteTask",
}) as Promise<DeleteTaskRsp>;
},
GetParticipation(request) { // eslint-disable-line @typescript-eslint/no-unused-vars
const path = `api/participation/get`; // eslint-disable-line quotes
const body = JSON.stringify(request);
+9
View File
@@ -112,6 +112,15 @@ const openMenu = ref(false)
>
Сценарии
</button>
<button
v-if="hasPermission('tasks_page')"
type="button"
class="menu-item-block"
:class="{ active: props.active == 'tasks' }"
@click="router.push('/tasks')"
>
Задачи
</button>
<button
type="button"
class="menu-item-block"
+562
View File
@@ -0,0 +1,562 @@
<script setup lang="ts">
import {
dateRuRU,
NAlert,
NButton,
NCard,
NConfigProvider,
NDatePicker,
NEmpty,
NFlex,
NFormItem,
NInput,
NModal,
NSelect,
NSpin,
NTag,
ruRU,
useMessage,
} from 'naive-ui'
import { computed, onMounted, reactive, ref } from 'vue'
import { getAuthClient } from '@/api/auth_client'
import type { DeveloperTask, TaskAssignee } from '@/api/generated/crabs/evening_detective_server'
import HeaderMenu from '@/components/HeaderMenu.vue'
import { isTaskOverdue } from '@/utils/taskDates'
const client = getAuthClient()
const message = useMessage()
const statuses = [
{ label: 'Новая', value: 'new' },
{ label: 'В работе', value: 'in_progress' },
{ label: 'На проверке', value: 'review' },
{ label: 'Готово', value: 'done' },
]
const tasks = ref<DeveloperTask[]>([])
const assignees = ref<TaskAssignee[]>([])
const hasAccess = ref(false)
const isLoading = ref(true)
const isSaving = ref(false)
const loadError = ref('')
const selectedAssignee = ref<number | 'all'>('all')
const isFormOpen = ref(false)
const editingID = ref<number | undefined>()
const editingVersion = ref<number | undefined>()
const deleteTarget = ref<DeveloperTask | null>(null)
const draggedID = ref<number | undefined>()
const form = reactive<{
title: string
description: string
status: string
assigneeID: number | null
dueDate: string | null
}>({
title: '',
description: '',
status: 'new',
assigneeID: null,
dueDate: null,
})
const assigneeOptions = computed(() =>
assignees.value.map((user) => ({ label: user.name || `#${user.ID}`, value: user.ID! })),
)
const filterOptions = computed(() => [
{ label: 'Все ответственные', value: 'all' },
{ label: 'Без ответственного', value: 0 },
...assigneeOptions.value,
...tasks.value
.filter(
(task) => task.assigneeID && !assignees.value.some((user) => user.ID === task.assigneeID),
)
.filter(
(task, index, items) =>
items.findIndex((item) => item.assigneeID === task.assigneeID) === index,
)
.map((task) => ({
label: task.assigneeName || `#${task.assigneeID}`,
value: task.assigneeID!,
})),
])
const formAssigneeOptions = computed(() => {
if (
!form.assigneeID ||
assigneeOptions.value.some((option) => option.value === form.assigneeID)
) {
return assigneeOptions.value
}
const current = tasks.value.find((task) => task.ID === editingID.value)
return [
...assigneeOptions.value,
{
label: `${current?.assigneeName || `#${form.assigneeID}`} (роль отозвана)`,
value: form.assigneeID,
disabled: true,
},
]
})
const columns = computed(() =>
statuses.map((status) => ({
...status,
tasks: tasks.value.filter(
(task) =>
task.status === status.value &&
(selectedAssignee.value === 'all' || (task.assigneeID || 0) === selectedAssignee.value),
),
})),
)
const latestTask = computed(() => tasks.value.find((task) => task.ID === editingID.value))
const titleError = computed(() => {
if (!form.title.trim()) return 'Введите название задачи'
return [...form.title.trim()].length > 255 ? 'Не более 255 символов' : undefined
})
const descriptionError = computed(() =>
[...form.description].length > 10000 ? 'Не более 10 000 символов' : undefined,
)
const hasConflict = computed(
() =>
editingID.value !== undefined &&
latestTask.value !== undefined &&
latestTask.value.version !== editingVersion.value,
)
const isDeleted = computed(() => editingID.value !== undefined && !latestTask.value)
function statusLabel(status: string | undefined) {
return statuses.find((item) => item.value === status)?.label || status
}
function formatDate(value: string | undefined) {
return value ? value.split('-').reverse().join('.') : 'Без срока'
}
async function loadBoard() {
if (isLoading.value || isSaving.value) return
isLoading.value = true
loadError.value = ''
try {
const permissions = await client.GetPermissions({})
hasAccess.value = permissions.permissions?.includes('tasks_page') || false
if (!hasAccess.value) return
const [taskResponse, assigneeResponse] = await Promise.all([
client.GetTasks({}),
client.GetTaskAssignees({}),
])
tasks.value = taskResponse.tasks || []
assignees.value = assigneeResponse.assignees || []
} catch (err) {
loadError.value =
err instanceof Error && err.message === 'unauthorized'
? 'Войдите под учётной записью разработчика или администратора'
: err instanceof Error
? err.message
: 'Не удалось загрузить задачи'
message.error(loadError.value)
} finally {
isLoading.value = false
}
}
function openForm(task?: DeveloperTask) {
if (isSaving.value) return
editingID.value = task?.ID
editingVersion.value = task?.version
Object.assign(form, {
title: task?.title || '',
description: task?.description || '',
status: task?.status || 'new',
assigneeID: task?.assigneeID ?? null,
dueDate: task?.dueDate || null,
})
isFormOpen.value = true
}
async function saveTask() {
if (isSaving.value || isLoading.value || hasConflict.value || isDeleted.value) return
const title = form.title.trim()
if (!title || [...title].length > 255 || [...form.description].length > 10000) {
message.error('Введите название до 255 символов и описание до 10 000 символов')
return
}
isSaving.value = true
try {
const payload = {
title,
description: form.description,
status: form.status,
assigneeID: form.assigneeID ?? undefined,
dueDate: form.dueDate || '',
}
const result =
editingID.value !== undefined
? await client.UpdateTask({
...payload,
ID: editingID.value,
version: editingVersion.value,
})
: await client.CreateTask(payload)
if (result.task) {
const saved = result.task
tasks.value =
editingID.value !== undefined
? tasks.value.map((task) => (task.ID === saved.ID ? saved : task))
: [saved, ...tasks.value]
}
isFormOpen.value = false
message.success('Задача сохранена')
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось сохранить задачу')
} finally {
isSaving.value = false
}
await loadBoard()
}
function startDrag(event: DragEvent, task: DeveloperTask) {
if (isSaving.value || isLoading.value || !event.dataTransfer) {
event.preventDefault()
return
}
draggedID.value = task.ID
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(task.ID))
}
async function dropTask(status: string) {
const task = tasks.value.find((item) => item.ID === draggedID.value)
draggedID.value = undefined
if (!task || task.status === status || isSaving.value || isLoading.value) return
isSaving.value = true
try {
const result = await client.SetTaskStatus({ ID: task.ID, status, version: task.version })
if (result.task) {
const saved = result.task
tasks.value = tasks.value.map((item) => (item.ID === task.ID ? saved : item))
}
message.success('Статус изменён')
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось изменить статус')
} finally {
isSaving.value = false
}
await loadBoard()
}
async function deleteTask() {
if (!deleteTarget.value || isSaving.value || isLoading.value) return
isSaving.value = true
try {
await client.DeleteTask({ ID: deleteTarget.value.ID, version: deleteTarget.value.version })
tasks.value = tasks.value.filter((task) => task.ID !== deleteTarget.value?.ID)
deleteTarget.value = null
isFormOpen.value = false
message.success('Задача удалена')
} catch (err) {
message.error(err instanceof Error ? err.message : 'Не удалось удалить задачу')
deleteTarget.value = null
} finally {
isSaving.value = false
}
await loadBoard()
}
function useLatestVersion() {
editingVersion.value = latestTask.value?.version
}
onMounted(() => {
isLoading.value = false
void loadBoard()
})
</script>
<template>
<n-config-provider :locale="ruRU" :date-locale="dateRuRU">
<div class="center-block-custom">
<div class="width1200">
<n-flex class="toolbar" justify="space-between" align="center">
<h1>Задачи</h1>
<n-flex>
<n-button :loading="isLoading" :disabled="isSaving" @click="loadBoard"
>Обновить</n-button
>
<n-button
v-if="hasAccess"
type="primary"
:disabled="isSaving || isLoading"
@click="openForm()"
>
Создать задачу
</n-button>
</n-flex>
</n-flex>
<n-alert v-if="loadError" type="error" class="notice">{{ loadError }}</n-alert>
<n-spin v-if="isLoading && !hasAccess" class="initial-loading" />
<n-alert v-else-if="!hasAccess && !loadError" type="warning">
Страница доступна разработчикам и администратору.
</n-alert>
<template v-if="hasAccess">
<n-select
v-model:value="selectedAssignee"
:options="filterOptions"
aria-label="Фильтр по ответственному"
class="assignee-filter"
/>
<n-spin :show="isLoading || isSaving">
<div class="board">
<section
v-for="column in columns"
:key="column.value"
class="column"
:aria-label="column.label"
@dragover.prevent
@drop.prevent="dropTask(column.value)"
>
<n-flex justify="space-between" align="center" class="column-heading">
<h2>{{ column.label }}</h2>
<n-tag size="small">{{ column.tasks.length }}</n-tag>
</n-flex>
<n-empty v-if="!column.tasks.length" description="Нет задач" class="empty-column" />
<n-card
v-for="task in column.tasks"
:key="task.ID"
class="task-card"
size="small"
:draggable="!isSaving && !isLoading"
@dragstart="startDrag($event, task)"
@dragend="draggedID = undefined"
>
<div class="task-id">#{{ task.ID }}</div>
<h3 class="task-title">{{ task.title }}</h3>
<p>{{ task.assigneeName || 'Без ответственного' }}</p>
<n-flex align="center" class="task-due">
<span>{{ formatDate(task.dueDate) }}</span>
<n-tag v-if="isTaskOverdue(task)" type="error" size="small">Просрочена</n-tag>
</n-flex>
<n-button size="small" :disabled="isSaving || isLoading" @click="openForm(task)">
Открыть задачу
</n-button>
</n-card>
</section>
</div>
</n-spin>
</template>
</div>
</div>
<HeaderMenu active="tasks" />
<n-modal v-model:show="isFormOpen" :mask-closable="!isSaving" :close-on-esc="!isSaving">
<n-card
class="task-modal"
:title="editingID ? `Задача #${editingID}` : 'Новая задача'"
role="dialog"
aria-modal="true"
>
<n-alert v-if="isDeleted" type="warning" class="notice">
Задача удалена другим пользователем. Черновик сохранён в этой форме.
</n-alert>
<n-alert v-if="hasConflict && latestTask" type="warning" class="notice">
Задача изменена другим пользователем. Ваш черновик сохранён. Актуальные данные:
<p>{{ latestTask.title }} — {{ statusLabel(latestTask.status) }}</p>
<p>
{{ latestTask.assigneeName || 'Без ответственного' }} ·
{{ formatDate(latestTask.dueDate) }}
</p>
<p class="server-description">{{ latestTask.description || 'Без описания' }}</p>
<n-button :disabled="isLoading || isSaving" @click="useLatestVersion">
Продолжить с моим черновиком
</n-button>
</n-alert>
<n-form-item
label="Название *"
label-for="task-title"
:feedback="titleError"
:validation-status="titleError ? 'error' : undefined"
>
<n-input
:input-props="{ id: 'task-title' }"
v-model:value="form.title"
:disabled="isSaving"
placeholder="Название задачи"
/>
</n-form-item>
<n-form-item
label="Описание"
label-for="task-description"
:feedback="descriptionError"
:validation-status="descriptionError ? 'error' : undefined"
>
<n-input
:input-props="{ id: 'task-description' }"
v-model:value="form.description"
type="textarea"
:disabled="isSaving"
placeholder="Что нужно сделать"
:autosize="{ minRows: 3, maxRows: 10 }"
/>
</n-form-item>
<n-form-item label="Статус" label-for="task-status">
<n-select
:input-props="{ id: 'task-status', 'aria-label': 'Статус' }"
filterable
v-model:value="form.status"
:options="statuses"
:disabled="isSaving"
/>
</n-form-item>
<n-form-item label="Ответственный" label-for="task-assignee">
<n-select
:input-props="{ id: 'task-assignee', 'aria-label': 'Ответственный' }"
v-model:value="form.assigneeID"
:options="formAssigneeOptions"
:disabled="isSaving"
placeholder="Без ответственного"
clearable
filterable
/>
</n-form-item>
<n-form-item label="Срок выполнения" label-for="task-due-date">
<n-date-picker
id="task-due-date"
v-model:formatted-value="form.dueDate"
type="date"
placeholder="Дата завершения"
value-format="yyyy-MM-dd"
format="dd.MM.yyyy"
:disabled="isSaving"
clearable
class="date-picker"
/>
</n-form-item>
<n-flex justify="space-between">
<n-button
v-if="editingID && latestTask"
type="error"
:disabled="isSaving || isLoading"
@click="deleteTarget = latestTask"
>Удалить</n-button
>
<n-flex>
<n-button :disabled="isSaving" @click="isFormOpen = false">Закрыть</n-button>
<n-button
type="primary"
:loading="isSaving"
:disabled="
isLoading || hasConflict || isDeleted || !!titleError || !!descriptionError
"
@click="saveTask"
>Сохранить</n-button
>
</n-flex>
</n-flex>
</n-card>
</n-modal>
<n-modal
:show="!!deleteTarget"
:mask-closable="!isSaving"
:close-on-esc="!isSaving"
@update:show="!isSaving && (deleteTarget = null)"
>
<n-card class="task-modal" title="Удалить задачу?" role="dialog" aria-modal="true">
<p>Удалить «{{ deleteTarget?.title }}»? Это действие необратимо.</p>
<n-flex justify="end" class="delete-actions">
<n-button :disabled="isSaving" @click="deleteTarget = null">Отмена</n-button>
<n-button type="error" :loading="isSaving" @click="deleteTask">Удалить</n-button>
</n-flex>
</n-card>
</n-modal>
</n-config-provider>
</template>
<style scoped>
.center-block-custom {
height: calc(100dvh - 70px);
}
.width1200 {
max-width: 100%;
}
.toolbar {
margin-bottom: 16px;
}
.assignee-filter {
margin-bottom: 16px;
max-width: 320px;
}
.board {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(260px, 1fr));
overflow-x: auto;
padding-bottom: 16px;
}
.column {
background: var(--vt-c-black-soft);
border: 1px solid var(--vt-c-divider-dark-2);
border-radius: 8px;
min-height: 240px;
padding: 12px;
}
.column-heading {
margin-bottom: 16px;
}
.column-heading h2 {
font-size: 18px;
}
.task-card {
margin-bottom: 12px;
}
.task-id {
color: var(--vt-c-text-dark-2);
font-size: 12px;
}
.task-title {
font-size: 16px;
overflow-wrap: anywhere;
}
.task-due {
margin: 8px 0 12px;
}
.empty-column {
padding: 24px 0;
}
.task-modal {
max-width: 640px;
width: calc(100% - 24px);
}
.date-picker {
width: 100%;
}
.notice {
margin-bottom: 16px;
}
.server-description {
max-height: 160px;
overflow: auto;
white-space: pre-wrap;
}
.delete-actions {
margin-top: 16px;
}
.initial-loading {
display: block;
margin: 32px auto;
}
</style>
+7
View File
@@ -93,6 +93,13 @@ async function deleteRole(userId: number, role: string) {
v-if="auth.hasRole('admin') && auth.user && user.ID !== auth.user.ID"
@click="passwordUser = user"
>Изменить пароль</n-button>
<n-button
v-if="!user.roles?.includes('developer')"
@click="addRole(user.ID!, 'developer')"
>Выдать роль разработчика</n-button>
<n-button v-else @click="deleteRole(user.ID!, 'developer')"
>Забрать роль разработчика</n-button
>
<n-button @click="addRole(user.ID!, 'author')" v-if="!user.roles?.includes('author')"
>Сделать автором</n-button
>
+5
View File
@@ -88,6 +88,11 @@ const router = createRouter({
name: 'scenario',
component: () => import('../views/ScenarioView.vue'),
},
{
path: '/tasks',
name: 'tasks',
component: () => import('../views/TasksView.vue'),
},
{
path: '/users',
name: 'users',
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { isTaskOverdue } from '../taskDates.ts'
for (const scenario of [
{ name: 'past date', status: 'new', dueDate: '2026-10-06', expected: true },
{ name: 'today remains on time', status: 'review', dueDate: '2026-10-07', expected: false },
{ name: 'future date', status: 'in_progress', dueDate: '2026-10-08', expected: false },
{ name: 'finished task', status: 'done', dueDate: '2026-10-06', expected: false },
{ name: 'no deadline', status: 'new', dueDate: '', expected: false },
]) {
test(scenario.name, () => {
const now = new Date(2026, 9, 7, 0, 1)
assert.equal(isTaskOverdue(scenario, now), scenario.expected)
})
}
test('local date is used at the UTC boundary', () => {
const now = new Date(2026, 9, 7, 0, 1)
assert.equal(isTaskOverdue({ status: 'new', dueDate: '2026-10-07' }, now), false)
assert.equal(isTaskOverdue({ status: 'new', dueDate: '2026-10-06' }, now), true)
})
+9
View File
@@ -0,0 +1,9 @@
import type { DeveloperTask } from '@/api/generated/crabs/evening_detective_server'
export function isTaskOverdue(
task: Pick<DeveloperTask, 'status' | 'dueDate'>,
now = new Date(),
): boolean {
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
return task.status !== 'done' && !!task.dueDate && task.dueDate < today
}
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import TasksPage from '@/components/TasksPage.vue'
</script>
<template>
<TasksPage />
</template>