15 KiB
TMC Test Task — Claude Code Prompt
Запусти этот промт из корня
A:\my-apps\tmc-test-task
Контекст и цель
Ты создаёшь fullstack-приложение для тестового задания. В текущей директории создай две папки: backend и frontend. Начни с бэкенда, затем фронтенд. Работай итеративно — сначала scaffold, потом реализация по фичам.
Структура репозитория
tmc-test-task/
├── backend/
│ ├── src/
│ │ ├── index.ts
│ │ ├── routes/
│ │ │ └── items.ts
│ │ ├── services/
│ │ │ ├── itemsStore.ts ← in-memory хранилище
│ │ │ └── queue.ts ← очередь с дедупликацией
│ │ ├── middleware/
│ │ │ └── batcher.ts ← батчинг запросов
│ │ └── swagger.ts
│ ├── eslint.config.js
│ ├── tsconfig.json
│ └── package.json
└── frontend/
├── app/
│ ├── components/
│ │ ├── LeftPanel.vue
│ │ └── RightPanel.vue
│ ├── composables/
│ │ └── useItems.ts
│ ├── services/ ← сюда генерится axios-клиент
│ └── app.vue
├── eslint.config.js
├── nuxt.config.ts
└── package.json
BACKEND
Технологии
- Runtime: Node.js + TypeScript
- Framework: Express.js
- Package manager: pnpm
- Dev runner:
tsx watch - Build:
tsc && tsc-alias - Lint:
@antfu/eslint-config - Docs:
swagger-jsdoc+swagger-ui-express
package.json (backend)
{
"name": "tmc-backend",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc && tsc-alias",
"start": "node dist/index.js",
"lint": "eslint ."
}
}
Зависимости: express, swagger-jsdoc, swagger-ui-express, cors
Dev: tsx, tsc-alias, typescript, @types/node, @types/express, @types/swagger-jsdoc, @types/swagger-ui-express, @types/cors, eslint, @antfu/eslint-config
tsconfig.json (backend)
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
eslint.config.js (backend)
import antfu from '@antfu/eslint-config'
export default antfu({
typescript: true,
node: true,
formatters: true,
rules: {
'no-console': 'warn',
'node/prefer-global/process': 'off',
},
})
Бизнес-логика
In-memory хранилище (itemsStore.ts)
- При старте приложения инициализировать массив
allItemsс ID от 1 до 1 000 000 selectedIds: Set<number>— множество выбранных IDorderedSelected: number[]— массив для сохранения порядка drag&drop- Методы:
getItems(page, limit, search)— возвращает НЕвыбранные элементы с пагинацией и фильтрацией по ID (строковый поиск по ID)getSelectedItems(page, limit, search)— возвращает выбранные в порядкеorderedSelectedaddItem(id)— добавить новый элемент (id может быть любым числом)selectItem(id)— переместить в правое окноdeselectItem(id)— вернуть в левое окноreorderSelected(ids: number[])— сохранить новый порядок
Очередь с дедупликацией (queue.ts)
Реализовать RequestQueue класс:
- Очередь принимает задачи типа:
'add' | 'select' | 'deselect' | 'reorder' - Дедупликация: для
add,select,deselect— ключ дедупликации это${type}:${id}. Если такая задача уже в очереди (не выполнена), повторно не добавлять - Гарантия: одно значение не будет добавлено повторно (проверять и в store)
- Батчинг:
- Задачи
add— флашатся раз в 10 секунд (или при накоплении 100 задач) - Задачи
select,deselect,reorder— флашатся раз в 1 секунду
- Задачи
- При флаше — выполнять все задачи из батча атомарно
Swagger (swagger.ts)
Настроить swagger-jsdoc + swagger-ui-express:
- Документация доступна по
GET /docs - JSON-схема по
GET /docs/json(нужен для генерации клиента во фронте) - Описать все модели и ответы
API Routes
Все роуты с JSDoc-комментариями для swagger. Базовый путь: /api
GET /api/items
Возвращает НЕвыбранные элементы (левое окно).
Query params:
page(number, default: 1)limit(number, default: 20, max: 20)search(string, optional) — фильтр по ID (поиск подстроки в строке ID)
Response:
{
"data": [{ "id": 1 }, { "id": 2 }],
"total": 999980,
"page": 1,
"limit": 20,
"hasMore": true
}
GET /api/items/selected
Возвращает выбранные элементы (правое окно) в сохранённом порядке.
Query params:
page(number, default: 1)limit(number, default: 20, max: 20)search(string, optional)
Response: аналогично выше.
POST /api/items/add
Добавить элемент в очередь на добавление.
Body:
{ "id": 1000001 }
Response:
{ "queued": true, "id": 1000001 }
Ошибки: 400 если id не передан или не число.
POST /api/items/select
Добавить элемент в очередь на выбор (переместить вправо).
Body:
{ "id": 42 }
Response:
{ "queued": true }
POST /api/items/deselect
Вернуть элемент в левое окно.
Body:
{ "id": 42 }
PUT /api/items/reorder
Сохранить новый порядок выбранных элементов после drag&drop.
Body:
{ "ids": [5, 3, 1, 42] }
Response:
{ "queued": true }
CORS и порт
- Порт:
1337 - CORS: разрешить
http://localhost:3000(фронт на nuxt) - Swagger UI:
GET /docs, JSON:GET /docs/json
FRONTEND
Технологии
- Framework: Nuxt 3
- UI:
@nuxt/ui(последняя версия) - Styling: Tailwind CSS (через
@nuxt/ui), SASS - Package manager: pnpm
- Lint:
@antfu/eslint-config+vue-tsc - API клиент: axios + автогенерация через
swagger-typescript-api
package.json (frontend) — scripts
{
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview",
"lint": "eslint .",
"typecheck": "vue-tsc --noEmit",
"gen:api": "npx swagger-typescript-api generate --path http://localhost:1337/docs/json --output ./app/services --name api.ts --axios --unwrap-response-data"
}
}
Зависимости: @nuxt/ui, axios, vue-draggable-next (drag&drop)
Dev: eslint, @antfu/eslint-config, vue-tsc, typescript, sass, swagger-typescript-api
nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
compatibilityDate: '2024-11-01',
devtools: { enabled: true },
css: ['~/assets/styles/main.scss'],
runtimeConfig: {
public: {
apiBase: process.env.API_BASE ?? 'http://localhost:1337/api',
},
},
})
eslint.config.js (frontend)
import antfu from '@antfu/eslint-config'
export default antfu({
typescript: true,
vue: true,
formatters: true,
rules: {
'no-console': 'warn',
},
})
UI и функционал
Главная страница (app/app.vue или app/pages/index.vue)
Разметка: два равных контейнера в ряд (50/50 или flex/grid), высота — весь экран минус header.
Левое окно (компонент LeftPanel.vue)
- Заголовок: "All items"
- Инпут поиска: фильтрация по ID (debounce 300ms)
- Кнопка "Add item": открывает modal/popover с инпутом для ввода ID → POST /api/items/add
- Список элементов:
- Каждый элемент:
#ID+ кнопка "→" для выбора (POST /api/items/select) - Инфинити-скролл: IntersectionObserver на sentinel-элементе в конце списка, подгружать по 20 элементов
- При поиске — сбрасывать страницу, начинать с 1
hasMoreиз ответа API определяет, показывать ли sentinel
- Каждый элемент:
Правое окно (компонент RightPanel.vue)
- Заголовок: "Selected items"
- Инпут поиска: фильтрация по ID (debounce 300ms)
- Список с drag&drop (библиотека
vue-draggable-next):- Drag&drop работает для видимого (в том числе отфильтрованного) списка
- После drop: PUT /api/items/reorder с полным новым порядком
- Каждый элемент: drag-handle иконка +
#ID+ кнопка "←" для возврата (POST /api/items/deselect) - Инфинити-скролл аналогично левому окну
Composable (composables/useItems.ts)
Создать composable useItems() с методами:
fetchLeft(reset?)— загрузить/дозагрузить левую панельfetchRight(reset?)— загрузить/дозагрузить правую панельselectItem(id)— выбрать элементdeselectItem(id)— вернутьreorderSelected(ids)— сохранить порядокaddItem(id)— добавить новый элемент- Реактивные состояния:
leftItems,rightItems,leftSearch,rightSearch,leftLoading,rightLoading
Использовать axios для запросов. После каждой мутации (select/deselect/add) — перезагрузить обе панели (reset + fetch).
Сохранение состояния
На сервере хранится выбор и порядок (in-memory). Фронт при монтировании делает fetchLeft() и fetchRight() — данные восстанавливаются из сервера. Поиск не сохраняется (сбрасывается при перезагрузке страницы — это правильное поведение).
Порядок выполнения
- Создать директории
backend/иfrontend/ - Backend:
- Инициализировать
pnpm init+ установить зависимости - Создать
tsconfig.json,eslint.config.js - Реализовать
itemsStore.ts - Реализовать
queue.ts - Реализовать роуты в
routes/items.ts - Настроить swagger в
swagger.ts - Собрать всё в
index.ts - Проверить
pnpm dev— должен стартовать на порту 1337
- Инициализировать
- Frontend:
pnpm dlx nuxi@latest init frontend(или вручную scaffold)- Установить зависимости
- Создать
nuxt.config.ts,eslint.config.js - Создать структуру папок
- Реализовать composable
useItems.ts - Реализовать
LeftPanel.vue - Реализовать
RightPanel.vue - Собрать главную страницу
- Добавить скрипт
gen:apiи сгенерировать клиент (бэк должен быть запущен)
Важные детали реализации
- Инфинити-скролл: не забудь
disconnect()observer при unmount - Дедупликация в очереди: если
POST /api/items/selectвызывается дважды для одного ID — второй запрос игнорируется до выполнения первого - При инициализации 1М элементов — не держать в памяти объекты, только числа (массив
number[]весит ~8MB, приемлемо) - Типы из swagger: после
gen:apiвapp/services/api.tsбудет классApiс axios. Использовать его в composable черезconst api = new Api({ baseURL: config.public.apiBase }) - CORS: обязательно, иначе фронт не достучится до бэка
- Батчинг 10 сек для
add— при разработке можно уменьшить до 3 сек для теста, но в финальной версии должно быть 10 сек reorderчерез очередь: drag&drop может вызываться часто, дедуплицировать по типу (последний вызов побеждает, предыдущие отменяются — реализовать как "debounced queue" дляreorder)
Что должно работать в итоге
http://localhost:1337/docs— Swagger UI с описанием всех эндпоинтовhttp://localhost:1337/docs/json— OpenAPI JSON (для генерации клиента)http://localhost:3000— фронт с двумя панелями- Левая панель: поиск + инфинити-скролл + добавление + перемещение вправо
- Правая панель: поиск + инфинити-скролл + drag&drop + перемещение влево
- Состояние сохраняется при перезагрузке страницы (данные на сервере)
- Все запросы через очередь с дедупликацией и батчингом