загрузка фото
Gitea Actions Demo / build_and_push (push) Successful in 49s
Details
Gitea Actions Demo / build_and_push (push) Successful in 49s
Details
This commit is contained in:
parent
0bbc90cb5c
commit
f2e670d531
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue