1
0

Мобильная версия

1. Адаптация и разработка мобильного варианта.
This commit is contained in:
2026-01-15 09:33:16 +07:00
parent 901604afd4
commit 5018a2d123
23 changed files with 2787 additions and 171 deletions

View File

@@ -1,41 +1,82 @@
<template>
<div class="comment-form">
<!-- Индикатор "ответ на" -->
<div v-if="replyingTo" class="reply-indicator">
<i data-lucide="corner-down-right"></i>
<span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
<button class="reply-cancel" @click="$emit('cancel-reply')" title="Отменить">
<i data-lucide="x"></i>
</button>
</div>
<FormField :label="replyingTo ? 'Ваш ответ' : 'Новый комментарий'">
<template #actions>
<div class="format-buttons">
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный">
<i data-lucide="bold"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив">
<i data-lucide="italic"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание">
<i data-lucide="underline"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="triggerFileInput" title="Прикрепить файл">
<i data-lucide="paperclip"></i>
<!-- Desktop: Inline форма -->
<template v-if="!isMobile">
<!-- Индикатор "ответ на" -->
<div v-if="replyingTo" class="reply-indicator">
<i data-lucide="corner-down-right"></i>
<span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
<button class="reply-cancel" @click="$emit('cancel-reply')" title="Отменить">
<i data-lucide="x"></i>
</button>
</div>
<FormField :label="replyingTo ? 'Ваш ответ' : 'Новый комментарий'">
<template #actions>
<div class="format-buttons">
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('bold')" title="Жирный">
<i data-lucide="bold"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('italic')" title="Курсив">
<i data-lucide="italic"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="applyFormat('underline')" title="Подчёркивание">
<i data-lucide="underline"></i>
</button>
<button type="button" class="format-btn" @mousedown.prevent="triggerFileInput" title="Прикрепить файл">
<i data-lucide="paperclip"></i>
</button>
</div>
</template>
<RichTextEditor
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
:placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
:show-toolbar="false"
ref="editorRef"
/>
</FormField>
<!-- Превью прикреплённых файлов -->
<div v-if="files.length > 0" class="attached-files">
<div
v-for="(file, index) in files"
:key="file.name + '-' + index"
class="attached-file"
>
<div class="attached-file-icon">
<i v-if="isArchive(file)" data-lucide="archive"></i>
<i v-else data-lucide="image"></i>
</div>
<span class="attached-file-name" :title="file.name">{{ file.name }}</span>
<button class="attached-file-remove" @click="removeFile(index)" title="Удалить">
<i data-lucide="x"></i>
</button>
</div>
</template>
<RichTextEditor
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
:placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
:show-toolbar="false"
ref="editorRef"
/>
</FormField>
</div>
<button
class="btn-send-comment"
@click="$emit('send')"
:disabled="!canSend || isSending"
>
<span v-if="isSending" class="btn-loader"></span>
<template v-else>
<i data-lucide="send"></i>
Отправить
</template>
</button>
</template>
<!-- Скрытый input для файлов -->
<!-- Mobile: Кнопка открытия формы -->
<template v-else>
<button class="btn-open-form" @click="openMobileForm">
<i data-lucide="message-square-plus"></i>
{{ replyingTo ? 'Написать ответ' : 'Написать комментарий' }}
</button>
</template>
<!-- Скрытый input для файлов (общий) -->
<input
type="file"
ref="fileInputRef"
@@ -45,42 +86,94 @@
style="display: none"
>
<!-- Превью прикреплённых файлов -->
<div v-if="files.length > 0" class="attached-files">
<div
v-for="(file, index) in files"
:key="file.name + '-' + index"
class="attached-file"
>
<div class="attached-file-icon">
<i v-if="isArchive(file)" data-lucide="archive"></i>
<i v-else data-lucide="image"></i>
<!-- Mobile: Fullscreen форма -->
<Teleport to="body">
<Transition name="mobile-form">
<div v-if="isMobile && mobileFormOpen" class="mobile-form-overlay">
<div class="mobile-form-panel">
<div class="mobile-form-header">
<button class="btn-close" @click="closeMobileForm">
<i data-lucide="x"></i>
</button>
<h3 class="panel-title">{{ replyingTo ? 'Ответ' : 'Новый комментарий' }}</h3>
<div class="header-spacer"></div>
</div>
<!-- Индикатор "ответ на" -->
<div v-if="replyingTo" class="mobile-reply-indicator">
<i data-lucide="corner-down-right"></i>
<span>Ответ для <strong>{{ replyingTo.author_name }}</strong></span>
</div>
<div class="mobile-form-body">
<RichTextEditor
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
:placeholder="replyingTo ? 'Напишите ответ...' : 'Напишите комментарий...'"
:show-toolbar="false"
ref="mobileEditorRef"
class="mobile-editor"
/>
<!-- Превью прикреплённых файлов -->
<div v-if="files.length > 0" class="attached-files mobile">
<div
v-for="(file, index) in files"
:key="file.name + '-' + index"
class="attached-file"
>
<div class="attached-file-icon">
<i v-if="isArchive(file)" data-lucide="archive"></i>
<i v-else data-lucide="image"></i>
</div>
<span class="attached-file-name" :title="file.name">{{ file.name }}</span>
<button class="attached-file-remove" @click="removeFile(index)" title="Удалить">
<i data-lucide="x"></i>
</button>
</div>
</div>
</div>
<div class="mobile-form-footer">
<div class="mobile-format-buttons">
<button class="btn-format" @click="applyMobileFormat('bold')" title="Жирный">
<i data-lucide="bold"></i>
</button>
<button class="btn-format" @click="applyMobileFormat('italic')" title="Курсив">
<i data-lucide="italic"></i>
</button>
<button class="btn-format" @click="applyMobileFormat('underline')" title="Подчёркивание">
<i data-lucide="underline"></i>
</button>
<button class="btn-format" @click="triggerFileInput" title="Прикрепить файл">
<i data-lucide="paperclip"></i>
</button>
</div>
<button
class="btn-send"
@click="handleMobileSend"
:disabled="!canSend || isSending"
>
<span v-if="isSending" class="btn-loader"></span>
<template v-else>
<i data-lucide="send"></i>
</template>
</button>
</div>
</div>
</div>
<span class="attached-file-name" :title="file.name">{{ file.name }}</span>
<button class="attached-file-remove" @click="removeFile(index)" title="Удалить">
<i data-lucide="x"></i>
</button>
</div>
</div>
<button
class="btn-send-comment"
@click="$emit('send')"
:disabled="!canSend || isSending"
>
<span v-if="isSending" class="btn-loader"></span>
<template v-else>
<i data-lucide="send"></i>
Отправить
</template>
</button>
</Transition>
</Teleport>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUpdated } from 'vue'
import { ref, computed, onMounted, onUpdated, watch, nextTick } from 'vue'
import FormField from '../ui/FormField.vue'
import RichTextEditor from '../ui/RichTextEditor.vue'
import { useMobile } from '../../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({
modelValue: {
@@ -97,11 +190,47 @@ const props = defineProps({
}
})
defineEmits(['update:modelValue', 'send', 'cancel-reply'])
const emit = defineEmits(['update:modelValue', 'send', 'cancel-reply'])
const editorRef = ref(null)
const mobileEditorRef = ref(null)
const fileInputRef = ref(null)
const files = ref([])
const mobileFormOpen = ref(false)
// Открытие мобильной формы
const openMobileForm = async () => {
mobileFormOpen.value = true
await nextTick()
refreshIcons()
}
// Закрытие мобильной формы
const closeMobileForm = () => {
mobileFormOpen.value = false
emit('cancel-reply')
}
// Отправка из мобильной формы
const handleMobileSend = () => {
emit('send')
// Форма закроется после успешной отправки через watch
}
// Закрытие формы когда isSending становится false после отправки
watch(() => props.isSending, (newVal, oldVal) => {
if (oldVal === true && newVal === false && mobileFormOpen.value) {
// Отправка завершена, закрываем форму
closeMobileForm()
}
})
// Открытие формы при выборе ответа
watch(() => props.replyingTo, (newVal) => {
if (newVal && isMobile.value) {
openMobileForm()
}
})
const allowedExtensions = ['png', 'jpg', 'jpeg', 'zip', 'rar']
const archiveExtensions = ['zip', 'rar']
@@ -197,6 +326,10 @@ const applyFormat = (command) => {
editorRef.value?.applyFormat(command)
}
const applyMobileFormat = (command) => {
mobileEditorRef.value?.applyFormat(command)
}
const refreshIcons = () => {
if (window.lucide) {
window.lucide.createIcons()
@@ -221,6 +354,7 @@ defineExpose({
.comment-form {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 16px;
padding-bottom: calc(var(--safe-area-bottom, 0px));
margin-top: auto;
}
@@ -420,4 +554,215 @@ defineExpose({
width: 12px;
height: 12px;
}
/* ========== MOBILE: Кнопка открытия формы ========== */
.btn-open-form {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
padding: 14px;
background: var(--accent);
border: none;
border-radius: 10px;
color: #000;
font-family: inherit;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.btn-open-form i {
width: 18px;
height: 18px;
}
.btn-open-form:active {
filter: brightness(0.9);
}
/* ========== MOBILE: Fullscreen Form ========== */
.mobile-form-overlay {
position: fixed;
inset: 0;
background: #18181b;
z-index: 2000;
display: flex;
flex-direction: column;
touch-action: none;
}
.mobile-form-panel {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.mobile-form-header {
display: flex;
align-items: center;
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.mobile-form-header .panel-title {
flex: 1;
text-align: center;
font-size: 18px;
font-weight: 500;
margin: 0;
}
.mobile-form-header .btn-close {
width: 36px;
height: 36px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
flex-shrink: 0;
}
.mobile-form-header .btn-close i {
width: 20px;
height: 20px;
}
.mobile-form-header .header-spacer {
width: 36px;
flex-shrink: 0;
}
.mobile-reply-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(0, 212, 170, 0.08);
font-size: 14px;
color: var(--text-secondary);
}
.mobile-reply-indicator i {
width: 16px;
height: 16px;
color: var(--accent);
}
.mobile-reply-indicator strong {
color: var(--text-primary);
}
.mobile-form-body {
flex: 1;
padding: 16px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.mobile-editor {
flex: 1;
min-height: 150px;
}
.mobile-editor :deep(.editor-content) {
min-height: 150px;
}
.attached-files.mobile {
margin-top: auto;
}
.mobile-form-footer {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
padding-bottom: calc(12px + var(--safe-area-bottom, 0px));
border-top: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.mobile-format-buttons {
display: flex;
gap: 6px;
}
.btn-format {
width: 40px;
height: 40px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: var(--text-secondary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
flex-shrink: 0;
}
.btn-format i {
width: 16px;
height: 16px;
}
.btn-format:active {
background: var(--accent);
border-color: var(--accent);
color: #000;
}
.btn-send {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px;
background: var(--accent);
border: none;
border-radius: 10px;
color: #000;
font-family: inherit;
font-size: 15px;
font-weight: 500;
cursor: pointer;
}
.btn-send i {
width: 18px;
height: 18px;
}
.btn-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-send:not(:disabled):active {
filter: brightness(0.9);
}
/* Mobile form transition */
.mobile-form-enter-active,
.mobile-form-leave-active {
transition: opacity 0.2s ease;
}
.mobile-form-enter-from,
.mobile-form-leave-to {
opacity: 0;
}
</style>

View File

@@ -402,6 +402,7 @@ defineExpose({
height: 100%;
display: flex;
flex-direction: column;
min-height: 0; /* Важно для flex overflow */
}
.comments-list {
@@ -413,10 +414,19 @@ defineExpose({
padding: 2px; /* Для outline при редактировании */
padding-right: 6px;
margin-bottom: 16px;
min-height: 200px;
min-height: 100px;
max-height: calc(100vh - 400px);
}
/* Mobile: убираем max-height, используем flex */
@media (max-width: 768px) {
.comments-list {
max-height: none;
min-height: 50px;
margin-bottom: 12px;
}
}
.comments-loading,
.comments-empty {
display: flex;

View File

@@ -51,7 +51,7 @@
/>
</FormField>
<div class="field-row">
<div class="field-row" :class="{ mobile: isMobile }">
<FormField label="Срок выполнения">
<DatePicker v-model="form.dueDate" />
</FormField>
@@ -106,6 +106,9 @@ import FileUploader from '../ui/FileUploader.vue'
import DatePicker from '../DatePicker.vue'
import ConfirmDialog from '../ConfirmDialog.vue'
import { getFullUrl } from '../../api'
import { useMobile } from '../../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({
card: {
@@ -370,6 +373,11 @@ defineExpose({
flex: 1;
}
.field-row.mobile {
flex-direction: column;
gap: 16px;
}
.format-buttons {
display: flex;
gap: 3px;

View File

@@ -4,10 +4,12 @@
@close="tryClose"
>
<template #header>
<h2>{{ panelTitle }}</h2>
<span v-if="!isNew && card?.dateCreate" class="header-date">
Создано: {{ formatDate(card.dateCreate) }}
</span>
<div class="header-title-block">
<h2>{{ panelTitle }}</h2>
<span v-if="!isNew && card?.dateCreate" class="header-date">
Создано: {{ formatDate(card.dateCreate) }}
</span>
</div>
<!-- Вкладки (только для существующих задач) -->
<TabsPanel
@@ -43,7 +45,8 @@
/>
</template>
<template #footer>
<!-- Footer: скрываем на вкладке комментариев -->
<template #footer v-if="activeTab !== 'comments'">
<div class="footer-left">
<IconButton
v-if="!isNew"
@@ -146,6 +149,9 @@ import ConfirmDialog from '../ConfirmDialog.vue'
import TaskEditTab from './TaskEditTab.vue'
import TaskCommentsTab from './TaskCommentsTab.vue'
import { taskImageApi, commentImageApi, getFullUrl } from '../../api'
import { useMobile } from '../../composables/useMobile'
const { isMobile } = useMobile()
const props = defineProps({
show: Boolean,
@@ -402,7 +408,7 @@ watch(() => props.show, async (newVal) => {
await nextTick()
editTabRef.value?.saveInitialForm()
editTabRef.value?.focusTitle()
// Не фокусируем поле автоматически, чтобы не открывалась клавиатура
refreshIcons()
}
})
@@ -412,13 +418,22 @@ onUpdated(refreshIcons)
</script>
<style scoped>
.header-title-block {
display: flex;
flex-direction: column;
gap: 4px;
}
.header-title-block h2 {
margin: 0;
}
.header-date {
font-size: 13px;
font-size: 12px;
color: var(--text-muted);
}
.header-tabs {
margin-left: auto;
margin-right: 12px;
}