приложение собрано каркасом и вшито в бинарник

- заведён каталог web/ — Vue 3, роутер пятой версии, сборка Vite; собранное
  вшивается через go:embed и раздаётся корневым маршрутом: разметка на
  неизвестном пути вне корней сервиса, отказ контракта внутри корня
- перечень корней сервиса стал единой точкой и порождает регистрацию маршрутов,
  а не описывает её; журнал раздачи пишет исход и длину пути, но не сам путь
- шаг front зовёт Node контейнером docker — Biome, юнит-тесты Vue и сборка
  входят в гейт, а в Dockerfile появилась ступень приложения
This commit is contained in:
av
2026-08-15 18:51:05 +03:00
parent c6ffda9aac
commit 663021f712
46 changed files with 5958 additions and 43 deletions
+3
View File
@@ -0,0 +1,3 @@
<template>
<RouterView />
</template>
+77
View File
@@ -0,0 +1,77 @@
// Единственное место, где приложение читает код ответа и разбирает тело отказа.
// Экран получает готовый текст, а не `Response`: второй разборщик кодов на
// клиенте разошёлся бы с первым.
const appRoot = '/app'
/** Ответ сервиса на вопрос «кто вошёл». */
export interface Me {
id: string
name: string
}
/** Единая форма тела отказа на адресах приложения. */
interface ErrorBody {
error_code?: string
message?: string
}
/**
* Исход запроса. Отсутствие сессии — свой исход, а не разновидность неудачи:
* экран уводит ко входу только на нём. Всякий прочий отказ ко входу не ведёт,
* иначе отказ сервиса или ограничителя частоты замкнул бы круг «приложение →
* вход → приложение».
*/
export type ApiResult<T> =
| { status: 'ok'; value: T }
| { status: 'unauthorized' }
| { status: 'failed'; message: string }
const noConnectionMessage = 'Связи нет'
const unknownFailureMessage = 'Сервис не отвечает как надо'
async function failureMessage(response: Response): Promise<string> {
try {
const body = (await response.json()) as ErrorBody
if (body.message) {
return body.message
}
} catch {
// Тело не разобралось — своего текста под код ответа не сочиняем.
}
return unknownFailureMessage
}
async function request<T>(path: string): Promise<ApiResult<T>> {
let response: Response
try {
response = await fetch(appRoot + path, {
headers: { Accept: 'application/json' },
})
} catch {
// Сорванный запрос — состояние, а не ошибка: показываем строкой.
return { status: 'failed', message: noConnectionMessage }
}
if (response.status === 401) {
return { status: 'unauthorized' }
}
if (!response.ok) {
return { status: 'failed', message: await failureMessage(response) }
}
try {
return { status: 'ok', value: (await response.json()) as T }
} catch {
return { status: 'failed', message: unknownFailureMessage }
}
}
/** Спрашивает сервис, кто вошёл. Куку сессии браузер шлёт сам. */
export function fetchMe(): Promise<ApiResult<Me>> {
return request<Me>('/me')
}
/** Адрес, которым сервис уводит человека ко входу. */
export const loginPath = '/auth/login'
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
createApp(App).use(router).mount('#app')
+12
View File
@@ -0,0 +1,12 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeScreen from './screens/HomeScreen.vue'
// Одна таблица маршрутов. Маршруты по файлам не включаем: сборочная надстройка
// роутера на нашем числе экранов не окупается.
//
// Адреса обычные, а не после решётки. Отсюда требование к серверу: неизвестный
// путь вне корней сервиса отдаёт разметку, а не отказ.
export const router = createRouter({
history: createWebHistory(),
routes: [{ path: '/', name: 'home', component: HomeScreen }],
})
+86
View File
@@ -0,0 +1,86 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import HomeScreen from './HomeScreen.vue'
// Экран судится по тому, что видит человек, а не по внутреннему состоянию:
// переписанная реализация обязана остаться зелёной.
const assign = vi.fn()
vi.stubGlobal('window', { location: { assign } })
function answerWith(response: Partial<Response> & { json?: () => Promise<unknown> }) {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response))
}
function failWith(error: Error) {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(error))
}
afterEach(() => {
vi.restoreAllMocks()
assign.mockReset()
})
describe('экран показывает вошедшего', () => {
it('показывает имя, когда сессия есть', async () => {
answerWith({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 'u1', name: 'Антон' }),
})
const screen = mount(HomeScreen)
await flushPromises()
expect(screen.text()).toContain('Антон')
expect(assign).not.toHaveBeenCalled()
})
it('ведёт ко входу, когда сессии нет', async () => {
answerWith({ ok: false, status: 401, json: () => Promise.resolve({}) })
mount(HomeScreen)
await flushPromises()
expect(assign).toHaveBeenCalledWith('/auth/login')
})
it('на прочий отказ показывает строку и ко входу не ведёт', async () => {
answerWith({
ok: false,
status: 500,
json: () => Promise.resolve({ error_code: 'internal', message: 'Внутренняя ошибка сервиса' }),
})
const screen = mount(HomeScreen)
await flushPromises()
expect(screen.text()).toContain('Внутренняя ошибка сервиса')
expect(assign).not.toHaveBeenCalled()
})
it('на сорванный запрос показывает, что связи нет', async () => {
failWith(new Error('network down'))
const screen = mount(HomeScreen)
await flushPromises()
expect(screen.text()).toContain('Связи нет')
expect(assign).not.toHaveBeenCalled()
})
it('учётная запись без имени: вход выполнен, почты на экране нет', async () => {
answerWith({
ok: true,
status: 200,
json: () => Promise.resolve({ id: 'u1', name: '' }),
})
const screen = mount(HomeScreen)
await flushPromises()
expect(screen.text()).toContain('Вы вошли')
expect(screen.text()).not.toContain('@')
})
})
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { fetchMe, loginPath } from '../api'
// Состояние экрана живёт в экране: общего между экранами пока нет.
const state = ref<'loading' | 'signed-in' | 'failed'>('loading')
const name = ref('')
const failure = ref('')
async function load() {
const result = await fetchMe()
if (result.status === 'unauthorized') {
// Единственная ветка, уводящая ко входу. Прочие отказы ведут сюда же
// только через круг «приложение → вход → приложение», поэтому их здесь нет.
window.location.assign(loginPath)
return
}
if (result.status === 'failed') {
failure.value = result.message
state.value = 'failed'
return
}
name.value = result.value.name
state.value = 'signed-in'
}
onMounted(load)
</script>
<template>
<main>
<h1>Расшифровка записей</h1>
<p v-if="state === 'loading'">Загружается</p>
<p v-else-if="state === 'failed'">{{ failure }}</p>
<!-- Имени у учётной записи может не быть вовсе: тогда говорим, что вход
выполнен, и не подставляем вместо имени адрес почты его в ответе нет. -->
<p v-else-if="name">Вы вошли как {{ name }}</p>
<p v-else>Вы вошли</p>
</main>
</template>