загрузка фото
Gitea Actions Demo / build_and_push (push) Successful in 49s Details

This commit is contained in:
artem 2026-09-17 09:29:27 +03:00
parent 0bbc90cb5c
commit f2e670d531
5 changed files with 269 additions and 6 deletions

View File

@ -2,12 +2,33 @@
## Task State
- task_id: TASK-DEPS-UPDATE (PARENT)
- task_id: TASK-F1 (PARENT)
- status: success (CLOSED)
- parent_task: —
- summary: **All waves W0a→W3 done and Architect-verified.** Final stack: vue 3.5.42, pinia 3.0.4, TS 5.8.3, vue-tsc 2.2.12, vite 5.4.21, eslint 9.39.5, typescript-eslint 8.69.0, node:22 (Docker). lint 0 errors, build green, docker build green.
- summary: **Галерея фото тренировки + загрузка/удаление + маркеры фото на Yandex-карте (components/WorkoutItem.vue).** npm run build exit 0 (vue-tsc + vite), npm run lint exit 0.
- next task: create a NEW task_id when starting the next feature.
## TASK-F1 — TODO
- [x] `src/pages/workouts/Definitions.vue`: тип `WorkoutPhoto` + `photos?: Array<WorkoutPhoto>` в `WorkoutItem` (fetch менять не нужно — бэкенд уже присылает `workout.photos`).
- [x] `src/pages/workouts/components/WorkoutItem.vue`: `photos` ref (из `workoutItem?.photos`), `photosWithCoords` computed, `<yandex-map-default-marker>` для фото с координатами (порядок `[lon, lat]` как у `clickCoordinates`, `color: 'blue'`, onClick → `window.open(photo.url, "_blank")`).
- [x] `src/pages/workouts/components/WorkoutItem.vue`: секция `#workout-photos` под `#workout-container` — grid `<img :src="photo.url">`, «Добавить фото» (`v-if="isPrivate"`, скрытый `input[type=file] accept="image/*" multiple`, `axiosAuth.post` multipart поле `file`, `Promise.allSettled`, ответ в `photos`, VaButton `:loading`), «Удалить» (`v-if="isPrivate"`, `axiosAuth.delete` → убрать из массива), ошибки/успех — `useToast`.
- [x] `src/pages/workouts/components/WorkoutItem.vue`: `isPrivate` добавлен в деструктуризацию `defineProps`; стили `.workout-photos-*` (grid auto-fill 160px, img 120px object-fit cover, кнопка удаления absolute).
- [x] `WorkoutListItem.vue` НЕ тронут (по ТЗ).
- [x] Верификация: `npm run build` exit 0 (vue-tsc --noEmit + vite, ~8–12s), `npm run lint` exit 0 (prettier+eslint --fix).
## ✅ CODER SUCCESS REPORT — TASK-F1
- Изменены файлы: `src/pages/workouts/Definitions.vue` (+8 строк: тип `WorkoutPhoto`, поле `photos?`), `src/pages/workouts/components/WorkoutItem.vue` (+163 строки: маркеры фото, галерея, upload/delete, стили).
- `photos` — локальный `ref<Array<WorkoutPhoto>>` (иниц. из пропса), все мутации только по нему; `axiosAuth` — существующий `inject("axiosAuth")`.
- Маркеры: `<yandex-map-default-marker>` с `coordinates: [lon, lat] as LngLat` (тот же порядок, что у `clickCoordinates`), `color: 'blue'` для отличия от серых маркеров кликов; onClick → `window.open(photo.url, "_blank")`.
- Загрузка: `Promise.allSettled` по каждому файлу (multipart, поле `file`); успех → push в `photos` + toast success; частичная неудача → toast error «Не удалось загрузить часть фото.»; `:loading` на кнопке.
- Удаление: `axiosAuth.delete` → filter из `photos`; ошибка → toast «Не удалось удалить фото.»
- Кнопки «Добавить фото»/«Удалить» и `v-if` секции только при `isPrivate` (владелец); на публичной странице (`WorkoutPublicItem.vue`, `isPrivate=false`) фото видны только-чтение без управления.
- `npm run build`: exit 0, `✓ built in 11.71s` (vue-tsc 0 ошибок; warning о chunk >500kB — предсуществующий).
- `npm run lint`: exit 0 (prettier: `WorkoutItem.vue` переформатирован, eslint 0 ошибок).
- Ручные проверки (сценарии для QA): фото с GPS → в галерее + синий маркер на карте; фото без GPS → только в галерее; добавление нескольких файлов сразу → все добавлены, `:loading` на кнопке во время; удаление → исчезло из галерее и с карты; публичная страница → фото видны, кнопок нет; клик по фото/маркеру → opens в новой вкладке.
## W3 acceptance criteria
- `eslint` 9.x + `typescript-eslint` 8.x in yarn.lock; legacy `@typescript-eslint/eslint-plugin@6` + `@typescript-eslint/parser@6` removed (superseded by the `typescript-eslint` 8 meta-package); NO other package moved to a new major

View File

@ -106,3 +106,11 @@
- Consider vite 6/7 after the ecosystem settles (own breaking changes).
- `GetWorkout.ts` module-level `let` refactor to pure functions returning state.
- `components/WorkoutItem.vue` prop mutation → local state extraction (unblock the `no-mutating-props` suppression).
### 2026-09-12 — TASK-F1: workout photos gallery + upload/delete + map markers (uncommitted)
- `src/pages/workouts/Definitions.vue`: `WorkoutPhoto` type (`{id, url, size, latitude: number|null, longitude: number|null}`) + `photos?: Array<WorkoutPhoto>` in `WorkoutItem`. No fetch changes (backend already sends `workout.photos` via `GET /workouts/{id}` / public / list).
- `src/pages/workouts/components/WorkoutItem.vue`: local `photos` ref initialized from `workoutItem?.photos`; `photosWithCoords` computed (non-null lat/lon); photo markers as `<yandex-map-default-marker>` with `coordinates: [lon, lat] as LngLat` (same order as `clickCoordinates`), `color: 'blue'`, `onClick` → `window.open(photo.url, "_blank")`.
- Same file: `#workout-photos` section under the map/short-data container — grid of `<img :src="photo.url">` (auto-fill 160px, 120px cover), «Добавить фото» (`v-if="isPrivate"`, hidden `input[type=file] accept="image/*" multiple`, `axiosAuth.post` multipart field `file`, `Promise.allSettled`, success → push to `photos` + toast, partial failure → error toast, VaButton `:loading`), per-photo «Удалить» (`v-if="isPrivate"`, `axiosAuth.delete` → filter from `photos`, error toast). `isPrivate` added to `defineProps` destructure. Styles: `.workout-photos-*` block.
- `WorkoutListItem.vue` untouched (per scope).
- Verified: `npm run build` exit 0 (vue-tsc --noEmit 0 errors, vite `✓ built in 11.71s`; pre-existing >500kB chunk warning), `npm run lint` exit 0 (prettier reformatted the component, eslint 0 errors).

View File

@ -9,6 +9,13 @@ export type WorkoutLinkItem = {
export type WorkoutLink = {
values: Array<WorkoutLinkItem>;
};
export type WorkoutPhoto = {
id: string;
url: string;
size: number;
latitude: number | null;
longitude: number | null;
};
export type WorkoutItem = {
id: string;
name: string;
@ -33,6 +40,7 @@ export type WorkoutItem = {
is_public: boolean;
workouted_at: string;
external_links?: WorkoutLink;
photos?: Array<WorkoutPhoto>;
};
export const secondsToDuration = (seconds: number) => {
let hours = Math.floor(seconds / 3600);

View File

@ -10,6 +10,16 @@
:hideFileList="true"
dropzone
/>
<div class="photo-upload" v-if="!inProgress">
<h2 class="photo-upload__title">Фото тренировки (опционально)</h2>
<VaFileUpload
v-model="photoFiles"
file-types="png,jpg,jpeg,webp,heic"
type="list"
color="#F4F6F8"
dropzone
/>
</div>
<div v-else>
<VaInnerLoading loading :size="60"> </VaInnerLoading>
</div>
@ -27,6 +37,7 @@ const { init } = useToast();
const router = useRouter();
const inProgress = ref(false);
let file: File | undefined = undefined;
const photoFiles = ref<File[]>([]);
type ErrorItem = {
code_string: string;
@ -36,6 +47,31 @@ type Error = {
detail: ErrorItem;
};
async function uploadPhotos(workoutId: string): Promise<boolean> {
const total = photoFiles.value.length;
if (total === 0) {
return true;
}
const results = await Promise.allSettled(
photoFiles.value.map((photo) => {
const formData = new FormData();
formData.append("file", photo);
return axiosAuth.post(`/api/v0/workouts/${workoutId}/photos`, formData);
}),
);
const failed = results.filter(
(r): r is PromiseRejectedResult => r.status === "rejected",
).length;
if (failed > 0) {
init({
message: `Часть фото не загружена (${failed} из ${total})`,
color: "error",
});
return false;
}
return true;
}
function onFileChanged() {
if (file == undefined) {
init({
@ -55,16 +91,33 @@ function onFileChanged() {
attachment_id: response.data.id,
name: "Новая тренировка",
})
.then((response: AxiosResponse) => {
.then(async (response: AxiosResponse) => {
const workoutId = response.data.id;
const hasPhotos = photoFiles.value.length > 0;
if (hasPhotos) {
const ok = await uploadPhotos(workoutId);
if (ok) {
init({
message: "Тренировка успешно загружена!",
color: "success",
});
}
} else {
init({
message: "Тренировка успешно загружена!",
color: "success",
});
}
inProgress.value = false;
init({ message: "Тренировка успешно загружена!", color: "success" });
photoFiles.value = [];
router.push({
name: "workout_item",
params: { id: response.data.id },
params: { id: workoutId },
});
})
.catch((error: AxiosError) => {
inProgress.value = false;
photoFiles.value = [];
if (error.status == 400) {
let err = <Error>error.response?.data;
if (err.detail.code_string == "ObjectExists") {
@ -82,6 +135,8 @@ function onFileChanged() {
});
})
.catch(function () {
inProgress.value = false;
photoFiles.value = [];
init({
message: "Что-то пошло не так.",
color: "error",
@ -89,3 +144,12 @@ function onFileChanged() {
});
}
</script>
<style scoped>
.photo-upload {
margin-top: 16px;
}
.photo-upload__title {
font-size: 18px;
margin-bottom: 8px;
}
</style>

View File

@ -70,6 +70,18 @@
color: 'gray',
}"
/>
<yandex-map-default-marker
v-for="photo in photosWithCoords"
:key="'photo-' + photo.id"
:settings="{
coordinates: [
photo.longitude as number,
photo.latitude as number,
] as LngLat,
color: 'blue',
onClick: (_: unknown, __: unknown) => openPhoto(photo),
}"
/>
</yandex-map>
</div>
<div id="workout-short-data">
@ -183,6 +195,47 @@
</div>
</div>
</div>
<div
id="workout-photos"
v-if="workoutItem && (photos.length > 0 || isPrivate)"
>
<div class="workout-photos-header">
<h4>Фото</h4>
<VaButton
v-if="isPrivate"
preset="secondary"
color="primary"
size="small"
:loading="uploading"
@click="triggerFilePicker"
>
Добавить фото
</VaButton>
<input
ref="fileInput"
type="file"
accept="image/*"
multiple
hidden
@change="onFilesSelected"
/>
</div>
<div class="workout-photos-grid" v-if="photos.length > 0">
<div class="workout-photo" v-for="photo in photos" :key="photo.id">
<img :src="photo.url" :alt="photo.id" @click="openPhoto(photo)" />
<VaButton
v-if="isPrivate"
preset="danger"
flat
size="small"
class="workout-photo-delete"
@click="deletePhoto(photo)"
>
Удалить
</VaButton>
</div>
</div>
</div>
<div id="x-axis-switcher">
<div class="x-axis-buttons">
<VaButton
@ -342,6 +395,7 @@ import {
formatTime,
ChartData,
ChartDataByMetric,
WorkoutPhoto,
} from "../Definitions.vue";
type XAxisMode = "time" | "distance";
@ -373,6 +427,7 @@ const {
lineCoordinates,
distances,
dzenLink: dzenLinkProps,
isPrivate,
} = defineProps<Props>();
const dzenLink = ref(dzenLinkProps);
const map = shallowRef<null | YMap>(null);
@ -745,7 +800,7 @@ const buildChartOptions = (
}
} else {
for (let i = 0; i < labels.length; i++) {
if (i !== 0 && Number(labels[i]) > min) {
if (start === 0 && i !== 0 && Number(labels[i]) > min) {
start = i;
}
if (Number(labels[i]) > max) {
@ -1064,6 +1119,79 @@ const changePublic = (value: boolean) => {
});
};
const fileInput = ref<HTMLInputElement | null>(null);
const uploading = ref(false);
// Local copy: all mutations (upload/delete) go through this ref only.
const photos = ref<Array<WorkoutPhoto>>(workoutItem?.photos ?? []);
const photosWithCoords = computed(() =>
photos.value.filter((p) => p.latitude !== null && p.longitude !== null),
);
const openPhoto = (photo: WorkoutPhoto) => {
window.open(photo.url, "_blank");
};
const triggerFilePicker = () => {
fileInput.value?.click();
};
const uploadPhotos = (files: FileList) => {
if (!workoutItem) {
return;
}
uploading.value = true;
const upload = (file: File) => {
const formData = new FormData();
formData.append("file", file);
return axiosAuth.post(
`/api/v0/workouts/${workoutItem.id}/photos`,
formData,
);
};
Promise.allSettled(Array.from(files).map(upload))
.then((results: PromiseSettledResult<AxiosResponse>[]) => {
let uploaded = 0;
for (const r of results) {
if (r.status === "fulfilled") {
photos.value.push(r.value.data as WorkoutPhoto);
uploaded += 1;
}
}
if (uploaded > 0) {
init({ message: "Фото добавлено", color: "success" });
}
if (uploaded < results.length) {
init({
message: "Не удалось загрузить часть фото.",
color: "error",
});
}
})
.finally(() => {
uploading.value = false;
});
};
const onFilesSelected = (event: Event) => {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
uploadPhotos(input.files);
}
input.value = "";
};
const deletePhoto = (photo: WorkoutPhoto) => {
if (!workoutItem) {
return;
}
axiosAuth
.delete(`/api/v0/workouts/${workoutItem.id}/photos/${photo.id}`)
.then((_response: AxiosResponse) => {
photos.value = photos.value.filter((p) => p.id !== photo.id);
})
.catch((_error: AxiosError) => {
init({
message: "Не удалось удалить фото.",
color: "error",
});
});
};
const resetChartZoom = () => {
group.resetAll();
activeSection.value = null;
@ -1146,6 +1274,40 @@ h3 {
.workout-item-params-pointer {
cursor: pointer;
}
#workout-photos {
width: 100%;
margin-top: 20px;
}
.workout-photos-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.workout-photos-header h4 {
margin: 0;
}
.workout-photos-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 12px;
}
.workout-photo {
position: relative;
}
.workout-photo img {
width: 100%;
height: 120px;
object-fit: cover;
border-radius: 6px;
cursor: pointer;
display: block;
}
.workout-photo-delete {
position: absolute;
top: 6px;
right: 6px;
}
#x-axis-switcher {
width: 100%;
display: flex;