generated from VLADIMIR/template_frontend
up
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
# Дизайн-система сайта и игры «Вечерний детектив»
|
||||
|
||||
Два направления с общим логотипом: сайт помогает выбрать дело и управлять
|
||||
участием, игра погружает в расследование. Визуальные правила описаны здесь;
|
||||
единственный источник правил реализации — [FRONTEND_CODE_STANDARD.md](FRONTEND_CODE_STANDARD.md).
|
||||
Рабочие страницы мигрируют постепенно; образцы не меняют их оформление.
|
||||
|
||||
## Области применения
|
||||
|
||||
| Направление | Где применяется | Характер |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| Сайт «Золотой вечер» | Главная, каталог, кабинет, страницы организатора, редакторы и обычные формы | Тёмные ровные панели, машинописный шрифт, ярко-жёлтые действия, лёгкие фактуры |
|
||||
| Игра «Материалы дела» | Расследование команды, улики, сюжетные документы, игровое управление и печатные материалы | Существующие бумага, кожа, металл, машинописный текст, бронзовые надписи и фактурные кнопки |
|
||||
|
||||
Превью сюжетного документа внутри каталога или редактора использует игровой
|
||||
стиль; окружающие управление и формы — стиль сайта. Игровые изображения и
|
||||
фоны сценариев сохраняют собственное оформление.
|
||||
|
||||
## Живые образцы
|
||||
|
||||
Запустить `npm run dev` и открыть `/design-system` на адресе Vite. Вкладка
|
||||
«Сайт» открывается первой, «Игра» показывает отдельные материалы и управление,
|
||||
«Компоненты» — последовательный каталог: заголовок компонента, живой пример,
|
||||
описание контракта и код подключения; затем следующий компонент.
|
||||
Страница доступна только в разработке, без входа и пункта основного меню;
|
||||
маршрут и компоненты образцов исключены из production-сборки.
|
||||
|
||||
Все данные вымышлены. Форма, модальное окно и ход игры работают локально;
|
||||
игровой ход не вызывает API и не меняет данные игры. Сайт использует `SiteThemeProvider`, игровые образцы —
|
||||
собственный `NConfigProvider`: тема сайта не попадает в игровые компоненты.
|
||||
|
||||
## Общий бренд и логотип
|
||||
|
||||
Использовать [существующий знак](../src/assets/images/лого.jpg): детектив в
|
||||
шляпе, чёрный круг, золотая окантовка. Не растягивать, не обрезать окантовку,
|
||||
не перерисовывать. Свободное поле — не меньше четверти диаметра;
|
||||
промежуток до названия — от 12 px. Размеры образцов — 48 и 144 px.
|
||||
|
||||
Изображение рядом с названием имеет пустой `alt`, самостоятельный знак —
|
||||
описательный. JPEG сохраняет белый фон внутри круга, круглое размещение
|
||||
допускает `border-radius: 50%`. Прозрачного и векторного исходника нет;
|
||||
качество крупной печати и узнаваемость favicon проверяются отдельно.
|
||||
|
||||
Основной цвет текста обоих стилей на тёмных поверхностях — светлый `#EEE`,
|
||||
второстепенный — `#BDB8AE`. Игровой бежевый `#BFA07D` используется точечно:
|
||||
в бренде, декоративных надписях и текстовыделителе. Цвета статусов сохраняют
|
||||
значение; текст на бумаге и жёлтых кнопках остаётся тёмным. Общий блок бренда
|
||||
`DesignBrand` содержит логотип 48 px, промежуток 12 px и название
|
||||
«Вечерний детектив» через `HeaderText`. На узком экране название может
|
||||
переноситься; название направления «Материалы дела» располагается отдельно.
|
||||
|
||||
Общие правила: mobile-first, desktop от 1024 px, текущие контейнеры 700/1200 px,
|
||||
явные состояния ошибки и загрузки, доступность с клавиатуры. Шкала отступов:
|
||||
4, 8, 12, 16, 24, 32, 48, 64 px. Цвета задавать семантическими CSS-переменными.
|
||||
|
||||
## Сайт «Золотой вечер»
|
||||
|
||||
### Палитра
|
||||
|
||||
Токены находятся в `src/assets/base.css`. `--brand-bronze` и бумага связывают
|
||||
оформление с игрой; жёлтый primary относится к сайту, а не к игровым действиям.
|
||||
|
||||
| CSS-переменная | Цвет | Назначение |
|
||||
| ------------------------ | --------- | -------------------------------------------------- |
|
||||
| `--brand-background` | `#111111` | Фон сайта; текст на золотой кнопке и бумаге |
|
||||
| `--brand-surface` | `#222222` | Карточки, панели, окна |
|
||||
| `--brand-surface-hover` | `#2E2E39` | Подсветка поверхности при наведении |
|
||||
| `--brand-text` | `#EEEEEE` | Основной текст |
|
||||
| `--brand-text-muted` | `#BDB8AE` | Пояснения, подписи, второстепенные данные |
|
||||
| `--brand-accent` | `#FFD43B` | Основное действие, ссылки, активный элемент, focus |
|
||||
| `--brand-accent-hover` | `#FFE066` | Наведение на основное действие |
|
||||
| `--brand-accent-pressed` | `#F5C518` | Нажатие на основное действие |
|
||||
| `--brand-border` | `#77737F` | Границы полей и панелей |
|
||||
| `--brand-success` | `#87C99D` | Успех |
|
||||
| `--brand-info` | `#94BFEE` | Информация |
|
||||
| `--brand-error` | `#F29A9A` | Ошибка и опасное действие |
|
||||
| `--brand-bronze` | `#BFA07D` | Бренд, текстовыделитель и декоративные надписи |
|
||||
| `--brand-paper` | `#F4EDE0` | Бумажные материалы |
|
||||
|
||||
Предупреждение использует золото и явную подпись. Статусы не передавать только
|
||||
цветом: добавлять текст и штатную иконку. Жёлтый акцент не использовать как цвет
|
||||
обычного текста на бумаге или белом фоне.
|
||||
|
||||
## Типографика и компоновка
|
||||
|
||||
Весь стиль сайта использует существующий `font_old_typer`: заголовки, основной
|
||||
текст, подписи, меню, кнопки, формы, таблицы и модальные окна. Новые шрифты не
|
||||
загружаются. Шрифт задаётся на контейнере и в локальной теме Naive UI, чтобы
|
||||
сохраняться в телепортируемых окнах. Игровая типографика остаётся прежней.
|
||||
|
||||
| Элемент | Размер | Вес | Межстрочный интервал |
|
||||
| ----------------------------- | ------------------------ | ------------------------------- | -------------------- |
|
||||
| Главный заголовок | `clamp(32px, 4vw, 48px)` | 600 | 1.15 |
|
||||
| Заголовок раздела | 28 px | 600 | 1.3 |
|
||||
| Заголовок блока | 22 px | 600 | 1.4 |
|
||||
| Вступление | 18 px | 400 | 1.5 |
|
||||
| Основной текст | 18 px | 400 | 1.5 |
|
||||
| Подпись | 14 px | 400 | 1.5 |
|
||||
| Короткая надпись над разделом | 14 px | 400 | 1.5 |
|
||||
| Бумажный текст | 18 px | существующий вес `MessagePaper` | 1.5 |
|
||||
|
||||
Шкала отступов: 4, 8, 12, 16, 24, 32, 48, 64 px. На мобильном экране внешние
|
||||
поля 16 px, на desktop — 24 px; между разделами 32 px на мобильном и 48 px на desktop, без сложения
|
||||
верхних и нижних отступов соседних секций. Базовое скругление
|
||||
8 px. Панели имеют тонкую границу; выраженную тень оставить бумажному слою и
|
||||
существующим фактурным компонентам.
|
||||
|
||||
Использовать текущие контейнеры `.width700` для дел и `.width1200` для списков,
|
||||
прокрутку `.center-block-custom` и шапку высотой 70 px. Mobile-first;
|
||||
desktop-порог — `@media (width >= 1024px)`. До него колонки складываются
|
||||
вертикально, кнопки переносятся; широкая таблица прокручивается внутри себя.
|
||||
|
||||
Главный блок выравнивается по верхнему краю, между колонками 32 px. Внутри
|
||||
карточек поля 16 px на мобильном и 24 px на desktop, между элементами 16 px,
|
||||
перед главным действием 24 px. Отступы абзацев по умолчанию нулевые;
|
||||
интервалы задаются контейнерами. Не сжимать машинописный заголовок отрицательным
|
||||
межбуквенным интервалом. Небольшие папки каталога сохраняют подписи не меньше 14 px.
|
||||
|
||||
### Компоненты и материалы
|
||||
|
||||
Использовать Naive UI. `NConfigProvider` берёт тему из CSS-токенов. Primary
|
||||
имеет сплошную заливку `#FFD43B`, hover `#FFE066`, pressed `#F5C518`, текст
|
||||
`#111`; сохранять машинописный шрифт и видимый focus. Опасное действие
|
||||
использует красный акцент. Disabled и loading — штатные состояния Naive UI.
|
||||
|
||||
Главный блок, карточки, формы, таблицы и окна имеют ровные поверхности. Кожа
|
||||
допускается как небольшая полоса в шапке (`BeltBlock`), бумага — в документальных фрагментах превью дела (`paper.jpg`);
|
||||
не оформлять весь сайт стопками листов. Название с логотипом в обоих стилях использует общий `DesignBrand` и
|
||||
существующий `HeaderText`: бежевый `#BFA07D`, машинописный шрифт 22 px,
|
||||
вес 600 и разрядка 2 px. Меню и основной текст сайта используют `font_old_typer`.
|
||||
|
||||
Ссылки жёлтые на тёмном фоне; на светлом ярлыке текст тёмный. Формы имеют
|
||||
подписи и ошибки у полей, таблицы — локальную прокрутку при необходимости.
|
||||
Модальные окна — `n-modal` + `n-card`, заголовок, role dialog и aria-modal.
|
||||
|
||||
### Доска расследования
|
||||
|
||||
Без градиентов и свечения. Главный блок использует композицию
|
||||
из текстовых улик: записка, билет, время отправления, номера и соединительные
|
||||
линии приглушённого бронзового цвета. Логотип остаётся в шапке, не заменяет
|
||||
обложку каждого дела. Под композицией — название, параметры и жёлтое действие.
|
||||
|
||||
На desktop фрагменты расположены асимметрично с небольшими поворотами;
|
||||
на мобильном — вертикально, без наложения текста. Бумага использует существующую
|
||||
фактуру. Машинописный шрифт допустим внутри документальных фрагментов;
|
||||
описания, навигация, формы и кнопки тоже используют `font_old_typer`.
|
||||
|
||||
Документальные фрагменты декоративные, не имеют самостоятельных hover или действий. Линии не перехватывают
|
||||
клики. Формы и служебные панели сохраняют ровные поверхности. Большая доска использует `CaseBoard`; каталог — повторяемые папки
|
||||
`CaseFolder` с предметными обложками.
|
||||
|
||||
### Детали досье
|
||||
|
||||
| Приём | Где и как применять |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| Папка с ярлыком | Карточка каталога, номер на выступающем ярлыке; оставлять 32 px между карточками |
|
||||
| Бежевый маркер | Одно важное выражение в заголовке, тёмный текст на бежевой неровной полосе |
|
||||
| Улика на обороте | Явная кнопка раскрывает цитату, `aria-expanded` и `aria-controls`; без 3D-переворота |
|
||||
| Архивные разделители | Пунктир между разделами, номера и уголки на обложках; не дополнительные рамки вокруг каждого текста |
|
||||
| Предметная обложка | Билет, ключ или письмо из существующих иконок и бумажных фактур; тот же символ рядом с названием |
|
||||
| Заметка на полях | Короткая сюжетная подпись рядом с большой доской; на мобильном — ниже композиции |
|
||||
| Штамп статуса | «Новое дело», «Скоро», «Пройдено»; только при наличии соответствующего реального состояния |
|
||||
| Подсветка связи | Одна бронзовая линия становится жёлтой при hover или фокусе действия в карточке |
|
||||
| Конец досье | Номер листа, название проекта, тонкая линия и «Продолжение — в следующем деле» |
|
||||
|
||||
Карточки поднимаются на 3 px при наведении мыши и фокусе внутри. Переходы
|
||||
занимают 160 ms; при `prefers-reduced-motion` подъём и переходы отключаются,
|
||||
раскрытие цитат продолжает работать. Touch не зависит от hover: цитата
|
||||
открывается кнопкой. Декоративные слои и линии не перехватывают клики.
|
||||
|
||||
Три папки в образцах вымышлены, их штампы не обозначают реальные публикации
|
||||
или историю пользователя. В образце дело «Скоро» имеет отключённое основное действие через отдельный
|
||||
`disabled`; сам статус не управляет доступностью.
|
||||
На рабочем сайте привязать состояния к данным; не вычислять «новое» или
|
||||
«пройдено» по предположениям. Формы и таблицы сохраняют спокойный вид.
|
||||
|
||||
## Игра «Материалы дела»
|
||||
|
||||
### Эталон и палитра
|
||||
|
||||
Эталон — текущее оформление расследования команды, а не тема сайта.
|
||||
Сохранить `MessagePaper`, `IntroBlock`, `GameInputForm`, `BeltBlock`,
|
||||
`MetalPlate` и их фактуры. Окружение тёмное `#111`, панели `#222`, основной текст
|
||||
`#EEE`, бумажный текст чёрный, декоративные надписи бронзовые `#BFA07D`.
|
||||
Существующие мятные состояния и фактурные игровые кнопки сохраняются;
|
||||
жёлтую тему сайта не передавать игровому provider.
|
||||
|
||||
### Материалы и управление
|
||||
|
||||
| Материал | Источник | Применение |
|
||||
| ------------------ | ------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| Бумага | `MessagePaper`, `paper.jpg`, `paper_white.jpg` | Вступление, точки, улики и фотографии; сохранять слои и тени |
|
||||
| Кожа | `BeltBlock`, `belt.png` | Полосы управления и фактурные действия |
|
||||
| Металл | `MetalPlate`, `metal.png`, `pin.png` | Детали панелей, без заливки всей страницы |
|
||||
| Игровое управление | `GameInputForm`, `input_center.png`, `button.png` | Место назначения, счётчики и фактурная кнопка |
|
||||
|
||||
Машинописный шрифт сохраняется в документах и существующих игровых
|
||||
компонентах; сайт использует тот же шрифт по собственным правилам компоновки. `HeaderText` — для
|
||||
бронзовых игровых надписей. В образцах подписи на коже и металле используют
|
||||
чёрную подложку 80% для читаемости. Это локальное оформление образцов,
|
||||
не массовая переделка рабочих игровых компонентов.
|
||||
|
||||
Вступление и улика используют настоящие компоненты с демонстрационными
|
||||
данными. Панель хода повторяет поведение игры: успешный локальный ход очищает
|
||||
поле и показывает результат. В образцах панель размещена в потоке страницы;
|
||||
в рабочей игре остаётся её существующее фиксированное положение.
|
||||
|
||||
### Печатные материалы
|
||||
|
||||
Светлые внутренние страницы, тёмный текст, небольшие золотые метки. Тёмное
|
||||
оформление допустимо для обложек. Не печатать свечение интерфейса. Номера,
|
||||
подписи и статусы должны читаться в чёрно-белом виде; сюжетные изображения
|
||||
сохраняют собственную стилистику.
|
||||
|
||||
## Проверка изменённого экрана
|
||||
|
||||
- На ширинах 375, 768 и 1280 px нет горизонтальной прокрутки всей страницы;
|
||||
таблица может прокручиваться локально.
|
||||
- Текст имеет контраст от 4.5:1; крупный текст и значимые границы управления —
|
||||
от 3:1. Проверять реальные пары цветов, в том числе hover и pressed.
|
||||
- Все действия доступны с клавиатуры; focus виден, окно закрывается и возвращает
|
||||
фокус на открывший его элемент.
|
||||
- Ошибка, успех, загрузка и пустое состояние различимы без цвета.
|
||||
- Логотип не деформирован; весь UI сайта использует `font_old_typer`, включая окна.
|
||||
- Компонентные стили локальны, глобальная тема изменяется только при осознанной
|
||||
миграции. Типы, ESLint и Stylelint проходят; изменения маршрутов проверены сборкой.
|
||||
|
||||
- Переключение вкладок доступно с клавиатуры; сайт открывается первым.
|
||||
- Жёлтая тема сайта не меняет игровые кнопки и документы.
|
||||
- Игровой ход в образцах работает без сетевых запросов.
|
||||
|
||||
## Подключение библиотеки
|
||||
|
||||
Компоненты находятся в `src/components`, импортируются напрямую и не зависят
|
||||
от dev-маршрута. Отдельного пакета и глобальной регистрации нет. Демонстрационные
|
||||
данные остаются в `SiteDesignPreview`, `GameDesignPreview` и `ComponentDesignCatalog`.
|
||||
Рабочие экраны пока не мигрируют.
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { NButton, NCard } from 'naive-ui'
|
||||
import SiteThemeProvider from '@/components/SiteThemeProvider.vue'
|
||||
import CaseFolder from '@/components/CaseFolder.vue'
|
||||
|
||||
function openCase(title: string) {
|
||||
// Здесь родитель выполняет нужную навигацию или загрузку.
|
||||
console.log(title)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SiteThemeProvider>
|
||||
<NCard title="Материалы"><NButton type="primary">Открыть</NButton></NCard>
|
||||
<CaseFolder
|
||||
number="014"
|
||||
title="Последний поезд"
|
||||
description="Письмо и билет"
|
||||
quote="Не доверяй времени"
|
||||
object="ticket"
|
||||
cover-label="Материал 014 / Билет"
|
||||
cover-caption="23:40 · Вагон 03"
|
||||
@open="openCase"
|
||||
/>
|
||||
</SiteThemeProvider>
|
||||
</template>
|
||||
```
|
||||
|
||||
Provider читает существующие CSS-токены при монтировании, задаёт darkTheme,
|
||||
цвета, машинописный шрифт и размеры Naive UI; кнопки, поля, таблицы и окна
|
||||
используются непосредственно из `naive-ui`. Телепортируемые окна сохраняют
|
||||
тему; для текста внутри `NCard` используйте машинописный шрифт на классе окна,
|
||||
как в каталоге. `--site-card-padding` позволяет контейнеру задавать поля карточек.
|
||||
Игровые компоненты размещайте вне provider сайта, под своим
|
||||
`NConfigProvider :theme="darkTheme" :theme-overrides="{}"`.
|
||||
|
||||
| Компонент | Props | Slots / события |
|
||||
| --- | --- | --- |
|
||||
| `SiteThemeProvider` | Нет | default / нет |
|
||||
| `DesignBrand` | Нет | Нет |
|
||||
| `CaseFolder` | number, title, description, quote, object: ticket/key/letter, coverLabel, coverCaption; status?, disabled=false, actionLabel='Открыть дело' | Нет / open(title: string) |
|
||||
| `CaseBoard` | label, fragments: readonly массив { number, title, text, caption?, detail?, kind?: note/ticket/timetable }, footer? | footer / нет |
|
||||
| `ObjectCover` | object: ticket/key/letter, label, caption, status? | Нет |
|
||||
| `StatusStamp` | text? | default заменяет text / нет |
|
||||
| `TextHighlight` | Нет | default / нет |
|
||||
| `SectionHeading` | title, id?, eyebrow? | default заменяет title / нет |
|
||||
| `DossierFooter` | label | default / нет |
|
||||
| `MessagePaper` | Нет | default / нет |
|
||||
| `HeaderText` | Нет | default / нет |
|
||||
| `BeltBlock` | Нет | default / нет |
|
||||
| `MetalPlate` | Нет | default / нет |
|
||||
| `IntroBlock` | introduction: EditableIntroduction, mode: show/show-editor/edit | default в режиме edit / update:mode |
|
||||
| `GameInputForm` | addAction: (code: string) => Promise<boolean>, allPlacesCount, newPlacesCount, gameStatus | Нет; true от addAction очищает поле |
|
||||
|
||||
Статус папки необязателен и только показывает текст. Доступность задаёт родитель
|
||||
через `disabled`, подпись действия — через `actionLabel`. Событие `open` передаёт
|
||||
название; маршрутизация и API остаются у родителя. Каждая цитата получает
|
||||
уникальный Vue `useId()`, даже для повторных карточек одного дела.
|
||||
|
||||
Вкладки переключаются стрелками по кругу, Home открывает «Сайт», End —
|
||||
«Компоненты». Каталог показывает disabled, loading, ошибку и успешную форму,
|
||||
обрабатывает open локально и не отправляет запросы к серверу.
|
||||
@@ -125,8 +125,10 @@
|
||||
`@media (width >= 1024px)`.
|
||||
- Сохраняй текущий flow: `HeaderMenu` сверху, `.center-block-custom` для
|
||||
прокрутки, `.width700` для дел и `.width1200` для списков.
|
||||
- Бумажные листы используют `MessagePaper` и `font_old_typer`; UI-формы, меню и
|
||||
кнопки этот шрифт не используют.
|
||||
- Бумажные листы используют `MessagePaper` и `font_old_typer`. В стиле сайта
|
||||
`font_old_typer` используется также для текста, меню, кнопок и форм согласно
|
||||
`docs/DESIGN_SYSTEM.md` (выбор владельца от 14.10.2026). Игровые компоненты
|
||||
сохраняют существующую типографику. Рабочие экраны мигрируют отдельно.
|
||||
- Не добавляй глобальный utility-класс для одного компонента.
|
||||
|
||||
## 8. Формы, доступность и состояния
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
/* «Золотой вечер»: применяется при миграции экранов на дизайн-систему. */
|
||||
--brand-background: #111;
|
||||
--brand-surface: #222;
|
||||
--brand-surface-hover: #2e2e39;
|
||||
--brand-text: #eee;
|
||||
--brand-text-muted: #bdb8ae;
|
||||
--brand-accent: #ffd43b;
|
||||
--brand-accent-hover: #ffe066;
|
||||
--brand-accent-pressed: #f5c518;
|
||||
--brand-border: #77737f;
|
||||
--brand-success: #87c99d;
|
||||
--brand-info: #94bfee;
|
||||
--brand-error: #f29a9a;
|
||||
--brand-paper: #f4ede0;
|
||||
--brand-bronze: #bfa07d;
|
||||
--vt-c-white: #fff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label: string
|
||||
footer?: string
|
||||
fragments: ReadonlyArray<{
|
||||
number: string
|
||||
title: string
|
||||
text: string
|
||||
caption?: string
|
||||
detail?: string
|
||||
kind?: 'note' | 'ticket' | 'timetable'
|
||||
}>
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<div class="case-board">
|
||||
<p class="board-label">{{ label }}</p>
|
||||
<div class="clues">
|
||||
<article
|
||||
v-for="(fragment, index) in fragments"
|
||||
:key="index"
|
||||
class="clue"
|
||||
:class="fragment.kind"
|
||||
>
|
||||
<span class="clue-number">{{ fragment.number }} / {{ fragment.title }}</span>
|
||||
<p
|
||||
:class="{
|
||||
'ticket-route': fragment.kind === 'ticket',
|
||||
'departure-time': fragment.kind === 'timetable',
|
||||
}"
|
||||
>
|
||||
{{ fragment.text }}
|
||||
</p>
|
||||
<p v-if="fragment.detail" class="ticket-time">{{ fragment.detail }}</p>
|
||||
<span v-if="fragment.caption" class="clue-caption">{{ fragment.caption }}</span>
|
||||
</article>
|
||||
</div>
|
||||
<p v-if="footer || $slots.footer" class="board-footer">
|
||||
<slot name="footer">{{ footer }}</slot>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.case-board {
|
||||
background: var(--brand-background);
|
||||
border: 1px solid var(--brand-border);
|
||||
border-radius: 8px;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.board-label,
|
||||
.board-footer {
|
||||
color: var(--brand-text-muted);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.08em;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.board-footer {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.clues {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
margin-top: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.clues::before {
|
||||
background: var(--brand-bronze);
|
||||
bottom: 16px;
|
||||
content: '';
|
||||
left: 50%;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
transition: background-color 160ms ease;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
.case-board:focus-within .clues::before {
|
||||
background: var(--brand-accent);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.case-board:hover .clues::before {
|
||||
background: var(--brand-accent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.clues::before {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.clue {
|
||||
background: var(--brand-paper) url('@/assets/images/paper.jpg') center / cover;
|
||||
box-shadow: 0 4px 8px rgb(0 0 0 / 35%);
|
||||
color: var(--brand-background);
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.clue::before {
|
||||
background: var(--brand-bronze);
|
||||
border: 2px solid var(--brand-background);
|
||||
border-radius: 50%;
|
||||
content: '';
|
||||
height: 8px;
|
||||
left: calc(50% - 6px);
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.clue-number,
|
||||
.clue-caption {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.clue-number {
|
||||
border-bottom: 1px dashed var(--brand-background);
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.clue p {
|
||||
font-family: 'font_old_typer', serif;
|
||||
font-size: 17px;
|
||||
line-height: 1.5;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.clue .ticket-route {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.clue .ticket-time,
|
||||
.clue .departure-time {
|
||||
font-size: 32px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
@media (width >= 1024px) {
|
||||
.clues {
|
||||
gap: 24px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.clues::before {
|
||||
height: 1px;
|
||||
left: 20%;
|
||||
top: 65%;
|
||||
transform: rotate(-20deg);
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
.note {
|
||||
grid-column: 1 / -1;
|
||||
margin-right: 24px;
|
||||
transform: rotate(-2deg);
|
||||
}
|
||||
|
||||
.ticket {
|
||||
transform: rotate(3deg);
|
||||
}
|
||||
|
||||
.timetable {
|
||||
margin-top: 24px;
|
||||
transform: rotate(-3deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyOutline, MailOutline, TicketOutline } from '@vicons/ionicons5'
|
||||
import { Icon } from '@vicons/utils'
|
||||
import { NButton, NCard } from 'naive-ui'
|
||||
import { ref, useId } from 'vue'
|
||||
|
||||
import ObjectCover from '@/components/ObjectCover.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
number: string
|
||||
title: string
|
||||
description: string
|
||||
quote: string
|
||||
object: 'ticket' | 'key' | 'letter'
|
||||
status?: string
|
||||
coverLabel: string
|
||||
coverCaption: string
|
||||
disabled?: boolean
|
||||
actionLabel?: string
|
||||
}>(),
|
||||
{ disabled: false, actionLabel: 'Открыть дело' },
|
||||
)
|
||||
const emit = defineEmits<{ open: [title: string] }>()
|
||||
const clueId = useId()
|
||||
const isClueOpen = ref(false)
|
||||
const icons = { ticket: TicketOutline, key: KeyOutline, letter: MailOutline }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-card class="case-folder" :content-style="{ display: 'grid', gap: '16px' }">
|
||||
<span class="folder-tab">Дело / {{ number }}</span>
|
||||
<ObjectCover :object="object" :label="coverLabel" :caption="coverCaption" :status="status" />
|
||||
<h3 class="folder-title">
|
||||
<Icon :size="20" aria-hidden="true"><component :is="icons[props.object]" /></Icon>{{ title }}
|
||||
</h3>
|
||||
<p class="folder-description">{{ description }}</p>
|
||||
<n-button
|
||||
class="clue-toggle"
|
||||
:aria-expanded="isClueOpen"
|
||||
:aria-controls="clueId"
|
||||
@click="isClueOpen = !isClueOpen"
|
||||
>{{ isClueOpen ? 'Скрыть улику' : 'Посмотреть улику' }}</n-button
|
||||
>
|
||||
<blockquote v-show="isClueOpen" :id="clueId" class="clue-quote">{{ quote }}</blockquote>
|
||||
<n-button
|
||||
type="primary"
|
||||
class="folder-action"
|
||||
:disabled="disabled"
|
||||
@click="emit('open', title)"
|
||||
>{{ actionLabel }}</n-button
|
||||
>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.case-folder {
|
||||
margin-top: 24px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.folder-tab {
|
||||
background: var(--brand-surface);
|
||||
border: 1px solid var(--brand-border);
|
||||
border-bottom: 0;
|
||||
border-radius: 6px 6px 0 0;
|
||||
color: var(--brand-text-muted);
|
||||
font-size: 14px;
|
||||
left: 16px;
|
||||
padding: 4px 12px;
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
}
|
||||
|
||||
.folder-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
font-size: 22px;
|
||||
gap: 8px;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.folder-description {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.clue-quote {
|
||||
border-left: 2px solid var(--brand-accent);
|
||||
color: var(--brand-text);
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.folder-action {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.case-folder:focus-within {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.case-folder:hover {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.case-folder {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.case-folder:hover,
|
||||
.case-folder:focus-within {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,480 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
darkTheme,
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NConfigProvider,
|
||||
NDataTable,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
} from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import BeltBlock from '@/components/BeltBlock.vue'
|
||||
import CaseBoard from '@/components/CaseBoard.vue'
|
||||
import CaseFolder from '@/components/CaseFolder.vue'
|
||||
import DesignBrand from '@/components/DesignBrand.vue'
|
||||
import DossierFooter from '@/components/DossierFooter.vue'
|
||||
import GameInputForm from '@/components/GameInputForm.vue'
|
||||
import HeaderText from '@/components/HeaderText.vue'
|
||||
import IntroBlock from '@/components/IntroBlock.vue'
|
||||
import MessagePaper from '@/components/MessagePaper.vue'
|
||||
import MetalPlate from '@/components/MetalPlate.vue'
|
||||
import ObjectCover from '@/components/ObjectCover.vue'
|
||||
import SectionHeading from '@/components/SectionHeading.vue'
|
||||
import SiteThemeProvider from '@/components/SiteThemeProvider.vue'
|
||||
import StatusStamp from '@/components/StatusStamp.vue'
|
||||
import TextHighlight from '@/components/TextHighlight.vue'
|
||||
const entries = [
|
||||
{
|
||||
name: 'SiteThemeProvider',
|
||||
description:
|
||||
'Без props и событий; default slot. Тема Naive UI и машинописная типографика сайта.',
|
||||
example:
|
||||
"import SiteThemeProvider from '@/components/SiteThemeProvider.vue'\nimport { NButton } from 'naive-ui'\n\n<SiteThemeProvider><NButton type=\"primary\">Открыть</NButton></SiteThemeProvider>",
|
||||
},
|
||||
{
|
||||
name: 'DesignBrand',
|
||||
description: 'Без props и событий; общий логотип и название.',
|
||||
example: "import DesignBrand from '@/components/DesignBrand.vue'\n\n<DesignBrand />",
|
||||
},
|
||||
{
|
||||
name: 'CaseFolder',
|
||||
description:
|
||||
'Обязательные: number, title, description, quote, object (ticket/key/letter), coverLabel, coverCaption. Необязательные: status, disabled=false, actionLabel. Событие open(title). Без slots; статус не запрещает действие.',
|
||||
example:
|
||||
'import CaseFolder from \'@/components/CaseFolder.vue\'\n\nfunction openCase(title: string) {\n console.log(title)\n}\n\n<CaseFolder number="014" title="Поезд" description="Описание" quote="Улика" object="ticket" cover-label="Билет" cover-caption="23:40" @open="openCase" />',
|
||||
},
|
||||
{
|
||||
name: 'CaseBoard',
|
||||
description:
|
||||
'label, fragments: массив { number, title, text, caption?, detail?, kind?: note/ticket/timetable }; footer необязателен. Slot footer, без событий.',
|
||||
example:
|
||||
"import CaseBoard from '@/components/CaseBoard.vue'\n\n<CaseBoard label=\"Дело 014\" :fragments=\"[{ number: '01', title: 'Записка', text: 'Текст улики' }]\" />",
|
||||
},
|
||||
{
|
||||
name: 'ObjectCover',
|
||||
description:
|
||||
'object: ticket/key/letter, label, caption; status необязателен. Без slots и событий.',
|
||||
example:
|
||||
'import ObjectCover from \'@/components/ObjectCover.vue\'\n\n<ObjectCover object="key" label="Ключ" caption="Комната 7" />',
|
||||
},
|
||||
{
|
||||
name: 'StatusStamp',
|
||||
description: 'Необязательный text; default slot заменяет текст. Без событий.',
|
||||
example:
|
||||
'import StatusStamp from \'@/components/StatusStamp.vue\'\n\n<StatusStamp text="В работе" />',
|
||||
},
|
||||
{
|
||||
name: 'TextHighlight',
|
||||
description: 'Без props и событий; default slot для произвольного текста.',
|
||||
example:
|
||||
"import TextHighlight from '@/components/TextHighlight.vue'\n\n<TextHighlight>Важная деталь</TextHighlight>",
|
||||
},
|
||||
{
|
||||
name: 'SectionHeading',
|
||||
description:
|
||||
'title; необязательные id и eyebrow. Default slot заменяет заголовок. Без событий.',
|
||||
example:
|
||||
'import SectionHeading from \'@/components/SectionHeading.vue\'\n\n<SectionHeading title="Материалы" eyebrow="01 / Дело" />',
|
||||
},
|
||||
{
|
||||
name: 'DossierFooter',
|
||||
description: 'label; default slot для содержимого. Без событий.',
|
||||
example:
|
||||
'import DossierFooter from \'@/components/DossierFooter.vue\'\n\n<DossierFooter label="Лист 01">Продолжение следует.</DossierFooter>',
|
||||
},
|
||||
{
|
||||
name: 'MessagePaper',
|
||||
description: 'Без props и событий; default slot — бумажный документ.',
|
||||
example:
|
||||
"import MessagePaper from '@/components/MessagePaper.vue'\n\n<MessagePaper>Текст письма</MessagePaper>",
|
||||
},
|
||||
{
|
||||
name: 'HeaderText',
|
||||
description: 'Без props и событий; default slot — игровая надпись.',
|
||||
example:
|
||||
"import HeaderText from '@/components/HeaderText.vue'\n\n<HeaderText>Расследование</HeaderText>",
|
||||
},
|
||||
{
|
||||
name: 'BeltBlock',
|
||||
description: 'Без props и событий; default slot — содержимое кожаной полосы.',
|
||||
example:
|
||||
"import BeltBlock from '@/components/BeltBlock.vue'\nimport HeaderText from '@/components/HeaderText.vue'\n\n<BeltBlock><HeaderText>Панель</HeaderText></BeltBlock>",
|
||||
},
|
||||
{
|
||||
name: 'MetalPlate',
|
||||
description: 'Без props и событий; default slot — содержимое металлической панели.',
|
||||
example:
|
||||
"import MetalPlate from '@/components/MetalPlate.vue'\n\n<MetalPlate>Деталь панели</MetalPlate>",
|
||||
},
|
||||
{
|
||||
name: 'IntroBlock',
|
||||
description:
|
||||
'introduction: EditableIntroduction, mode: show/show-editor/edit. Событие update:mode; default slot в редакторе. В каталоге только просмотр.',
|
||||
example:
|
||||
"import IntroBlock from '@/components/IntroBlock.vue'\n\n<IntroBlock :introduction=\"{ text: 'Вступление', audio: '' }\" mode=\"show\" />",
|
||||
},
|
||||
{
|
||||
name: 'GameInputForm',
|
||||
description:
|
||||
'addAction: (code: string) => Promise<boolean>, allPlacesCount, newPlacesCount, gameStatus. Без slots и событий. true очищает ввод, false сохраняет.',
|
||||
example:
|
||||
'import GameInputForm from \'@/components/GameInputForm.vue\'\n\nasync function demoAction(code: string): Promise<boolean> {\n console.log(code)\n return true\n}\n\n<GameInputForm :add-action="demoAction" :all-places-count="3" :new-places-count="1" game-status="run" />',
|
||||
},
|
||||
{
|
||||
name: 'NButton',
|
||||
description:
|
||||
'Naive UI: type, disabled, loading; default slot, событие click. Использовать под SiteThemeProvider.',
|
||||
example: `import { NButton } from 'naive-ui'
|
||||
|
||||
<NButton type="primary" @click="openCase('Пример окна')">Открыть окно</NButton>
|
||||
<NButton disabled>Недоступно</NButton>
|
||||
<NButton loading>Загрузка</NButton>`,
|
||||
},
|
||||
{
|
||||
name: 'NInput / NFormItem',
|
||||
description:
|
||||
'Naive UI: value и update:value у поля; label, validationStatus и feedback у NFormItem. Форма проверяется локально.',
|
||||
example: `import { NInput, NFormItem, NButton } from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const teamName = ref('')
|
||||
const isSubmitted = ref(false)
|
||||
const hasNameError = computed(() => isSubmitted.value && !teamName.value.trim())
|
||||
|
||||
<form @submit.prevent="isSubmitted = true">
|
||||
<NFormItem label="Команда" :validation-status="hasNameError ? 'error' : undefined" :feedback="hasNameError ? 'Укажите название команды' : undefined">
|
||||
<NInput v-model:value="teamName" />
|
||||
</NFormItem>
|
||||
<NButton attr-type="submit">Проверить форму</NButton>
|
||||
</form>`,
|
||||
},
|
||||
{
|
||||
name: 'NDataTable',
|
||||
description:
|
||||
'Naive UI: columns, data, scrollX; широкая таблица прокручивается внутри своего блока.',
|
||||
example: `import { NDataTable } from 'naive-ui'
|
||||
|
||||
const columns = [{ title: 'Команда', key: 'name' }, { title: 'Статус', key: 'status' }]
|
||||
const teams = [{ name: 'Детективы', status: 'В работе' }]
|
||||
|
||||
<NDataTable :columns="columns" :data="teams" :scroll-x="480" />`,
|
||||
},
|
||||
{
|
||||
name: 'NModal / NCard',
|
||||
description:
|
||||
'Naive UI: show и update:show у окна; title, closable, footer slot и close у карточки. Закрывается Escape и возвращает фокус.',
|
||||
example: `import { NModal, NCard, NButton } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isModalOpen = ref(false)
|
||||
|
||||
<NButton @click="isModalOpen = true">Открыть окно</NButton>
|
||||
<NModal v-model:show="isModalOpen">
|
||||
<NCard title="Дело" role="dialog" aria-modal="true" aria-label="Дело" closable @close="isModalOpen = false">
|
||||
Материалы дела
|
||||
<template #footer><NButton @click="isModalOpen = false">Закрыть</NButton></template>
|
||||
</NCard>
|
||||
</NModal>`,
|
||||
},
|
||||
{
|
||||
name: 'NAlert',
|
||||
description:
|
||||
'Naive UI: type и title; default slot для сообщения. Ошибка обозначена текстом и иконкой.',
|
||||
example: `import { NAlert } from 'naive-ui'
|
||||
|
||||
<NAlert type="error">Не удалось загрузить материалы.</NAlert>`,
|
||||
},
|
||||
]
|
||||
const lastCode = ref('')
|
||||
async function demoAction(code: string): Promise<boolean> {
|
||||
lastCode.value = code
|
||||
return true
|
||||
}
|
||||
const isModalOpen = ref(false)
|
||||
const selectedTitle = ref('')
|
||||
const teamName = ref('')
|
||||
const isSubmitted = ref(false)
|
||||
const hasNameError = computed(() => isSubmitted.value && !teamName.value.trim())
|
||||
function openCase(title: string) {
|
||||
selectedTitle.value = title
|
||||
isModalOpen.value = true
|
||||
}
|
||||
function submitDemo() {
|
||||
isSubmitted.value = true
|
||||
}
|
||||
const fragments = [
|
||||
{ number: 'A', title: 'Ключ', text: 'Найден у двери', caption: 'Другие данные' },
|
||||
{ number: 'B', title: 'Письмо', text: 'Встреча в саду' },
|
||||
]
|
||||
const columns = [
|
||||
{ title: 'Команда', key: 'name' },
|
||||
{ title: 'Статус', key: 'status' },
|
||||
]
|
||||
const teams = [{ name: 'Детективы', status: 'В работе' }]
|
||||
</script>
|
||||
<template>
|
||||
<SiteThemeProvider>
|
||||
<div class="catalog width1200">
|
||||
<SectionHeading title="Компоненты" eyebrow="Библиотека / Прямые импорты" />
|
||||
<p>
|
||||
Примеры работают локально. Компоненты библиотеки доступны рабочим страницам независимо от
|
||||
каталога.
|
||||
</p>
|
||||
<section
|
||||
v-for="entry in entries"
|
||||
:key="entry.name"
|
||||
class="catalog-entry"
|
||||
:data-component="entry.name"
|
||||
>
|
||||
<h2>{{ entry.name }}</h2>
|
||||
<n-config-provider
|
||||
v-if="
|
||||
[
|
||||
'MessagePaper',
|
||||
'HeaderText',
|
||||
'BeltBlock',
|
||||
'MetalPlate',
|
||||
'IntroBlock',
|
||||
'GameInputForm',
|
||||
].includes(entry.name)
|
||||
"
|
||||
:theme="darkTheme"
|
||||
:theme-overrides="{}"
|
||||
>
|
||||
<div class="game-preview">
|
||||
<MessagePaper v-if="entry.name === 'MessagePaper'">Текст письма</MessagePaper>
|
||||
<HeaderText v-else-if="entry.name === 'HeaderText'">Расследование</HeaderText>
|
||||
<BeltBlock v-else-if="entry.name === 'BeltBlock'" class="material-belt"
|
||||
><div class="material-label"><HeaderText>Панель</HeaderText></div></BeltBlock
|
||||
>
|
||||
<MetalPlate v-else-if="entry.name === 'MetalPlate'"
|
||||
><div class="material-label">Деталь панели</div></MetalPlate
|
||||
>
|
||||
<IntroBlock
|
||||
v-else-if="entry.name === 'IntroBlock'"
|
||||
:introduction="{ text: 'Вступление', audio: '' }"
|
||||
mode="show"
|
||||
/>
|
||||
<template v-else>
|
||||
<div class="game-controls">
|
||||
<GameInputForm
|
||||
:add-action="demoAction"
|
||||
:all-places-count="3"
|
||||
:new-places-count="1"
|
||||
game-status="run"
|
||||
/>
|
||||
</div>
|
||||
<n-alert v-if="lastCode" class="action-result" type="success" role="status"
|
||||
>Ход «{{ lastCode }}» принят локально.</n-alert
|
||||
>
|
||||
</template>
|
||||
</div>
|
||||
</n-config-provider>
|
||||
<div v-else class="component-sample">
|
||||
<n-button
|
||||
v-if="entry.name === 'SiteThemeProvider'"
|
||||
type="primary"
|
||||
@click="openCase('Тема сайта')"
|
||||
>Открыть</n-button
|
||||
>
|
||||
<DesignBrand v-else-if="entry.name === 'DesignBrand'" />
|
||||
<template v-else-if="entry.name === 'CaseFolder'">
|
||||
<p>
|
||||
Две папки одного дела: цитаты независимы. Статус «Скоро» у первой не блокирует
|
||||
действие; вторая без статуса и с disabled.
|
||||
</p>
|
||||
<div class="folders">
|
||||
<CaseFolder
|
||||
number="014"
|
||||
title="Повторное дело"
|
||||
description="Первый экземпляр"
|
||||
quote="Первая цитата"
|
||||
object="ticket"
|
||||
cover-label="Другой билет"
|
||||
cover-caption="10:15"
|
||||
status="Скоро"
|
||||
@open="openCase"
|
||||
/>
|
||||
<CaseFolder
|
||||
number="014"
|
||||
title="Повторное дело"
|
||||
description="Второй экземпляр"
|
||||
quote="Вторая цитата"
|
||||
object="letter"
|
||||
cover-label="Другое письмо"
|
||||
cover-caption="Адрес известен"
|
||||
disabled
|
||||
@open="openCase"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<CaseBoard
|
||||
v-else-if="entry.name === 'CaseBoard'"
|
||||
label="Другое расследование"
|
||||
:fragments="fragments"
|
||||
><template #footer>Произвольный итог доски.</template></CaseBoard
|
||||
>
|
||||
<ObjectCover
|
||||
v-else-if="entry.name === 'ObjectCover'"
|
||||
object="key"
|
||||
label="Отдельная обложка"
|
||||
caption="Чердак"
|
||||
/>
|
||||
<StatusStamp v-else-if="entry.name === 'StatusStamp'" text="Проверено" />
|
||||
<TextHighlight v-else-if="entry.name === 'TextHighlight'">Важная деталь</TextHighlight>
|
||||
<SectionHeading
|
||||
v-else-if="entry.name === 'SectionHeading'"
|
||||
title="Материалы"
|
||||
eyebrow="01 / Дело"
|
||||
/>
|
||||
<DossierFooter v-else-if="entry.name === 'DossierFooter'" label="Лист 01"
|
||||
>Продолжение следует.</DossierFooter
|
||||
>
|
||||
<template v-else-if="entry.name === 'NButton'">
|
||||
<div class="buttons">
|
||||
<n-button type="primary" @click="openCase('Пример окна')">Открыть окно</n-button
|
||||
><n-button disabled>Недоступно</n-button><n-button loading>Загрузка</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="entry.name === 'NInput / NFormItem'">
|
||||
<form novalidate @submit.prevent="submitDemo">
|
||||
<n-form-item
|
||||
label="Название команды"
|
||||
label-for="catalog-team"
|
||||
:validation-status="hasNameError ? 'error' : undefined"
|
||||
:feedback="hasNameError ? 'Укажите название команды' : undefined"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="teamName"
|
||||
:input-props="{ id: 'catalog-team', required: true }"
|
||||
/> </n-form-item
|
||||
><n-button type="primary" attr-type="submit">Проверить форму</n-button>
|
||||
<n-alert v-if="isSubmitted && !hasNameError" type="success" role="status"
|
||||
>Команда «{{ teamName.trim() }}» готова.</n-alert
|
||||
>
|
||||
</form>
|
||||
</template>
|
||||
<n-data-table
|
||||
v-else-if="entry.name === 'NDataTable'"
|
||||
:columns="columns"
|
||||
:data="teams"
|
||||
:scroll-x="480"
|
||||
/>
|
||||
<n-button v-else-if="entry.name === 'NModal / NCard'" @click="openCase('Пример окна')"
|
||||
>Открыть окно</n-button
|
||||
>
|
||||
<n-alert v-else-if="entry.name === 'NAlert'" type="error"
|
||||
>Пример ошибки загрузки материалов.</n-alert
|
||||
>
|
||||
</div>
|
||||
<p>{{ entry.description }}</p>
|
||||
<pre><code>{{ entry.example }}</code></pre>
|
||||
</section>
|
||||
<n-modal v-model:show="isModalOpen"
|
||||
><n-card
|
||||
class="catalog-modal"
|
||||
:title="selectedTitle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="selectedTitle"
|
||||
closable
|
||||
@close="isModalOpen = false"
|
||||
>Событие обработано локально.<template #footer
|
||||
><n-button type="primary" @click="isModalOpen = false">Закрыть</n-button></template
|
||||
></n-card
|
||||
></n-modal
|
||||
>
|
||||
</div>
|
||||
</SiteThemeProvider>
|
||||
</template>
|
||||
<style scoped>
|
||||
.catalog {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
max-width: 100%;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.folders {
|
||||
display: grid;
|
||||
gap: 32px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.catalog-entry {
|
||||
border-bottom: 1px dashed var(--brand-border);
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.component-sample {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.game-controls {
|
||||
margin: 48px 30px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* stylelint-disable-next-line selector-pseudo-class-no-unknown -- Панель образца располагается в потоке каталога. */
|
||||
.game-controls :deep(.form-custom) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.material-belt {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.material-label {
|
||||
background: rgb(0 0 0 / 80%);
|
||||
color: var(--brand-bronze);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.action-result {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-size: 14px;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.catalog-modal {
|
||||
font-family: 'font_old_typer', serif;
|
||||
max-width: 560px;
|
||||
width: calc(100vw - 32px);
|
||||
}
|
||||
|
||||
@media (width >= 1024px) {
|
||||
.folders {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import logo from '@/assets/images/лого.jpg'
|
||||
import HeaderText from '@/components/HeaderText.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="design-brand">
|
||||
<img :src="logo" alt="" width="48" height="48" />
|
||||
<span class="brand-name"><HeaderText>Вечерний детектив</HeaderText></span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design-brand {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.design-brand img {
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
line-height: 1.3;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { darkTheme, NButton, NConfigProvider } from 'naive-ui'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import ComponentDesignCatalog from '@/components/ComponentDesignCatalog.vue'
|
||||
import GameDesignPreview from '@/components/GameDesignPreview.vue'
|
||||
import SiteDesignPreview from '@/components/SiteDesignPreview.vue'
|
||||
const tabs = [
|
||||
{ name: 'site', label: 'Сайт' },
|
||||
{ name: 'game', label: 'Игра' },
|
||||
{ name: 'components', label: 'Компоненты' },
|
||||
] as const
|
||||
const activeTab = ref<(typeof tabs)[number]['name']>('site')
|
||||
const tabList = ref<HTMLElement | null>(null)
|
||||
async function handleTabKey(event: KeyboardEvent) {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
|
||||
event.preventDefault()
|
||||
const index = tabs.findIndex((tab) => tab.name === activeTab.value)
|
||||
const nextIndex =
|
||||
event.key === 'Home'
|
||||
? 0
|
||||
: event.key === 'End'
|
||||
? tabs.length - 1
|
||||
: (index + (event.key === 'ArrowRight' ? 1 : -1) + tabs.length) % tabs.length
|
||||
activeTab.value = tabs[nextIndex]!.name
|
||||
await nextTick()
|
||||
tabList.value?.querySelector<HTMLButtonElement>('[aria-selected="true"]')?.focus()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :theme="darkTheme">
|
||||
<header class="design-header">Вечерний детектив · Дизайн-система</header>
|
||||
<main class="center-block-custom design-scroll">
|
||||
<div
|
||||
ref="tabList"
|
||||
class="design-tabs"
|
||||
role="tablist"
|
||||
aria-label="Стили оформления"
|
||||
@keydown="handleTabKey"
|
||||
>
|
||||
<n-button
|
||||
v-for="tab in tabs"
|
||||
:id="`tab-${tab.name}`"
|
||||
:key="tab.name"
|
||||
role="tab"
|
||||
:aria-selected="activeTab === tab.name"
|
||||
:aria-controls="`panel-${tab.name}`"
|
||||
:tabindex="activeTab === tab.name ? 0 : -1"
|
||||
:type="activeTab === tab.name ? 'primary' : 'default'"
|
||||
@click="activeTab = tab.name"
|
||||
>{{ tab.label }}</n-button
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
:id="`panel-${activeTab}`"
|
||||
role="tabpanel"
|
||||
:aria-labelledby="`tab-${activeTab}`"
|
||||
tabindex="0"
|
||||
>
|
||||
<SiteDesignPreview v-if="activeTab === 'site'" />
|
||||
<GameDesignPreview v-else-if="activeTab === 'game'" />
|
||||
<ComponentDesignCatalog v-else />
|
||||
</div>
|
||||
</main>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.design-header {
|
||||
align-items: center;
|
||||
background: var(--brand-background);
|
||||
color: var(--brand-text);
|
||||
display: flex;
|
||||
font-size: 16px;
|
||||
height: 70px;
|
||||
left: 0;
|
||||
padding: 0 16px;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.design-scroll {
|
||||
background: var(--brand-background);
|
||||
}
|
||||
|
||||
.design-tabs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ label: string }>()
|
||||
</script>
|
||||
<template>
|
||||
<footer>
|
||||
<p class="archive-number">{{ label }}</p>
|
||||
<div><slot /></div>
|
||||
</footer>
|
||||
</template>
|
||||
<style scoped>
|
||||
footer {
|
||||
border-top: 1px solid var(--brand-border);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
.archive-number {
|
||||
color: var(--brand-text-muted);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,160 @@
|
||||
<script setup lang="ts">
|
||||
import { darkTheme, NAlert, NConfigProvider } from 'naive-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import BeltBlock from '@/components/BeltBlock.vue'
|
||||
import DesignBrand from '@/components/DesignBrand.vue'
|
||||
import GameInputForm from '@/components/GameInputForm.vue'
|
||||
import HeaderText from '@/components/HeaderText.vue'
|
||||
import IntroBlock from '@/components/IntroBlock.vue'
|
||||
import MessagePaper from '@/components/MessagePaper.vue'
|
||||
import MetalPlate from '@/components/MetalPlate.vue'
|
||||
|
||||
const lastCode = ref('')
|
||||
async function demoAction(code: string): Promise<boolean> {
|
||||
lastCode.value = code
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-config-provider :theme="darkTheme" :theme-overrides="{}">
|
||||
<div class="game-preview width700">
|
||||
<div class="game-brand">
|
||||
<DesignBrand />
|
||||
</div>
|
||||
<h1 class="game-title">Материалы дела</h1>
|
||||
<p class="description">
|
||||
Игровой стиль: существующие бумажные листы, машинописный текст, кожа и металл. Все данные
|
||||
демонстрационные.
|
||||
</p>
|
||||
<IntroBlock
|
||||
:introduction="{
|
||||
audio: '',
|
||||
text: 'Последний поезд ушёл в полночь. На пустой платформе осталось письмо без подписи. Выясните, кому оно предназначалось и почему адресат не пришёл.',
|
||||
}"
|
||||
mode="show"
|
||||
/>
|
||||
<div class="paper-sample">
|
||||
<MessagePaper>
|
||||
<p class="document-number">Улика № 03 · Личное письмо</p>
|
||||
<h2>Встретимся до полуночи</h2>
|
||||
<hr />
|
||||
<p>
|
||||
Если поезд придёт вовремя, я буду ждать у старых часов. Возьми письмо с собой. Кажется,
|
||||
мы упустили одну важную деталь.
|
||||
</p>
|
||||
<p>Не доверяй времени на билете.</p>
|
||||
</MessagePaper>
|
||||
</div>
|
||||
<BeltBlock class="material-belt"
|
||||
><div class="material-label"><HeaderText>Панель расследования</HeaderText></div></BeltBlock
|
||||
>
|
||||
<div class="metal-sample">
|
||||
<MetalPlate><div class="metal-label">Металлическая деталь панели</div></MetalPlate>
|
||||
</div>
|
||||
<p class="description">
|
||||
Введите место назначения: действие покажет локальный результат и очистит поле, как при
|
||||
успешном ходе в игре.
|
||||
</p>
|
||||
<div class="game-controls">
|
||||
<GameInputForm
|
||||
:add-action="demoAction"
|
||||
:all-places-count="3"
|
||||
:new-places-count="1"
|
||||
game-status="run"
|
||||
/>
|
||||
</div>
|
||||
<n-alert v-if="lastCode" type="success" class="action-result" role="status"
|
||||
>Ход «{{ lastCode }}» принят локально. На сервер ничего не отправлено.</n-alert
|
||||
>
|
||||
</div>
|
||||
</n-config-provider>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.game-preview {
|
||||
background: var(--brand-background);
|
||||
color: var(--brand-text);
|
||||
max-width: 100%;
|
||||
padding: 32px 24px 48px;
|
||||
}
|
||||
|
||||
.game-brand {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
color: var(--brand-text);
|
||||
font-family: 'font_old_typer', serif;
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--brand-text-muted);
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.paper-sample {
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.paper-sample h2 {
|
||||
font-size: 24px;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.paper-sample hr {
|
||||
border: 0;
|
||||
border-top: 1px dashed var(--brand-background);
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.paper-sample p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.document-number {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.material-belt {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.material-label {
|
||||
background: rgb(0 0 0 / 80%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.metal-sample {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.metal-label {
|
||||
background: rgb(0 0 0 / 80%);
|
||||
color: var(--brand-bronze);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.game-controls {
|
||||
margin-inline: 6px;
|
||||
margin-top: 48px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* stylelint-disable-next-line selector-pseudo-class-no-unknown -- В образцах игровая панель находится в потоке страницы. */
|
||||
.game-controls :deep(.form-custom) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.action-result {
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { KeyOutline, MailOutline, TicketOutline } from '@vicons/ionicons5'
|
||||
import { Icon } from '@vicons/utils'
|
||||
|
||||
import StatusStamp from '@/components/StatusStamp.vue'
|
||||
defineProps<{
|
||||
object: 'ticket' | 'key' | 'letter'
|
||||
label: string
|
||||
caption: string
|
||||
status?: string
|
||||
}>()
|
||||
const icons = { ticket: TicketOutline, key: KeyOutline, letter: MailOutline }
|
||||
</script>
|
||||
<template>
|
||||
<div class="object-cover" :class="object">
|
||||
<span class="cover-label">{{ label }}</span>
|
||||
<Icon :size="72" aria-hidden="true"><component :is="icons[object]" /></Icon>
|
||||
<span class="object-caption">{{ caption }}</span>
|
||||
<StatusStamp v-if="status" :text="status" />
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.object-cover {
|
||||
background: var(--brand-paper) url('@/assets/images/paper_white.jpg') center / cover;
|
||||
border: 1px solid var(--brand-border);
|
||||
color: var(--brand-background);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
min-height: 240px;
|
||||
padding: 20px 12px;
|
||||
place-items: center center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.object-cover::before,
|
||||
.object-cover::after {
|
||||
border: 1px solid var(--brand-background);
|
||||
content: '';
|
||||
height: 12px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.object-cover::before {
|
||||
border-bottom: 0;
|
||||
border-right: 0;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.object-cover::after {
|
||||
border-left: 0;
|
||||
border-top: 0;
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
.ticket {
|
||||
border-bottom: 2px dashed var(--brand-background);
|
||||
}
|
||||
|
||||
.letter {
|
||||
background-image: url('@/assets/images/paper.jpg');
|
||||
}
|
||||
|
||||
.cover-label,
|
||||
.object-caption {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ id?: string; eyebrow?: string; title: string }>()
|
||||
</script>
|
||||
<template>
|
||||
<div class="section-heading">
|
||||
<p v-if="eyebrow" class="eyebrow">{{ eyebrow }}</p>
|
||||
<h2 :id="id">
|
||||
<slot>{{ title }}</slot>
|
||||
</h2>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.section-heading {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--brand-accent);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.1em;
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,647 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
NAlert,
|
||||
NButton,
|
||||
NCard,
|
||||
NCheckbox,
|
||||
NDataTable,
|
||||
NFormItem,
|
||||
NInput,
|
||||
NModal,
|
||||
NSelect,
|
||||
NTag,
|
||||
} from 'naive-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import BeltBlock from '@/components/BeltBlock.vue'
|
||||
import CaseBoard from '@/components/CaseBoard.vue'
|
||||
import CaseFolder from '@/components/CaseFolder.vue'
|
||||
import DesignBrand from '@/components/DesignBrand.vue'
|
||||
import DossierFooter from '@/components/DossierFooter.vue'
|
||||
import SectionHeading from '@/components/SectionHeading.vue'
|
||||
import SiteThemeProvider from '@/components/SiteThemeProvider.vue'
|
||||
import TextHighlight from '@/components/TextHighlight.vue'
|
||||
|
||||
const palette = [
|
||||
['background', 'Фон'],
|
||||
['surface', 'Панель'],
|
||||
['text', 'Текст'],
|
||||
['text-muted', 'Подпись'],
|
||||
['accent', 'Жёлтый акцент'],
|
||||
['paper', 'Бумага'],
|
||||
] as const
|
||||
|
||||
const isModalOpen = ref(false)
|
||||
const selectedTitle = ref('Последний поезд')
|
||||
function openCase(title: string) {
|
||||
selectedTitle.value = title
|
||||
isModalOpen.value = true
|
||||
}
|
||||
const teamName = ref('')
|
||||
const difficulty = ref('medium')
|
||||
const hasMaterials = ref(false)
|
||||
const isSubmitted = ref(false)
|
||||
const isSaved = ref(false)
|
||||
const nameError = computed(() => isSubmitted.value && !teamName.value.trim())
|
||||
const difficulties = [
|
||||
{ label: 'Лёгкое дело', value: 'easy' },
|
||||
{ label: 'Средняя сложность', value: 'medium' },
|
||||
{ label: 'Для опытных детективов', value: 'hard' },
|
||||
]
|
||||
function submitDemo() {
|
||||
isSubmitted.value = true
|
||||
isSaved.value = !nameError.value
|
||||
}
|
||||
const columns = [
|
||||
{ title: 'Команда', key: 'name' },
|
||||
{ title: 'Улики', key: 'clues' },
|
||||
{ title: 'Статус', key: 'status' },
|
||||
]
|
||||
const teams = [
|
||||
{ key: 1, name: 'Полночь', clues: '7 / 9', status: 'Расследуют' },
|
||||
{ key: 2, name: 'Третий звонок', clues: '9 / 9', status: 'Дело раскрыто' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="site-preview">
|
||||
<SiteThemeProvider>
|
||||
<header class="showcase-header">
|
||||
<BeltBlock class="header-belt">
|
||||
<div class="header-content">
|
||||
<a class="brand" href="#preview">
|
||||
<DesignBrand />
|
||||
</a>
|
||||
<nav aria-label="Разделы образцов">
|
||||
<a href="#foundation">Стиль</a>
|
||||
<a href="#controls">Элементы</a>
|
||||
<a href="#paper">Материалы</a>
|
||||
</nav>
|
||||
</div>
|
||||
</BeltBlock>
|
||||
</header>
|
||||
|
||||
<div class="showcase-scroll">
|
||||
<main class="width1200 showcase-content">
|
||||
<p class="eyebrow">Дизайн-система · Золотой вечер</p>
|
||||
<section id="preview" class="hero" aria-labelledby="hero-title">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Расследование своей компанией</p>
|
||||
|
||||
<h1 id="hero-title">
|
||||
Соберите друзей.<br /><TextHighlight>Раскройте дело.</TextHighlight>
|
||||
</h1>
|
||||
<p class="lead">
|
||||
Один вечер, загадочная история и улики, которые складываются в разгадку. Главное —
|
||||
то, что вы заметите вместе.
|
||||
</p>
|
||||
<div class="row">
|
||||
<n-button
|
||||
class="accent-button"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="openCase('Последний поезд')"
|
||||
>Выбрать дело</n-button
|
||||
>
|
||||
<n-button class="paper-link" tag="a" href="#paper" size="large" secondary
|
||||
>Посмотреть улику</n-button
|
||||
>
|
||||
</div>
|
||||
<p class="muted">
|
||||
Образцы будущего сайта. Все данные на этой странице — демонстрационные.
|
||||
</p>
|
||||
</div>
|
||||
<n-card class="case-card" :content-style="{ display: 'grid', gap: '16px' }">
|
||||
<CaseBoard
|
||||
label="Дело 014 / Связь установлена"
|
||||
aria-label="Улики дела «Последний поезд»"
|
||||
footer="Три детали. Одна история."
|
||||
:fragments="[
|
||||
{
|
||||
number: '01',
|
||||
title: 'Записка',
|
||||
text: '«Буду ждать у старых часов. Не доверяй времени на билете».',
|
||||
caption: 'Подпись отсутствует',
|
||||
kind: 'note',
|
||||
},
|
||||
{
|
||||
number: '02',
|
||||
title: 'Билет',
|
||||
text: 'ВОКЗАЛ → ПОЛНОЧЬ',
|
||||
detail: '23:40',
|
||||
caption: 'Вагон 03 · Место 14',
|
||||
kind: 'ticket',
|
||||
},
|
||||
{
|
||||
number: '03',
|
||||
title: 'Отправление',
|
||||
text: '00:00',
|
||||
caption: 'Разница — двадцать минут. Совпадение?',
|
||||
kind: 'timetable',
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<aside class="margin-note">
|
||||
На полях: обратите внимание на время. У каждого своя версия.
|
||||
</aside>
|
||||
<h2 class="case-title">Последний поезд</h2>
|
||||
|
||||
<p>Письмо без подписи, исчезнувший пассажир и встреча, которая не состоялась.</p>
|
||||
<p class="document-number">2–6 игроков · 90 минут</p>
|
||||
<n-button
|
||||
class="accent-button case-action"
|
||||
type="primary"
|
||||
block
|
||||
@click="openCase('Последний поезд')"
|
||||
>Открыть дело</n-button
|
||||
>
|
||||
</n-card>
|
||||
</section>
|
||||
|
||||
<section id="foundation" aria-labelledby="foundation-title">
|
||||
<SectionHeading
|
||||
id="foundation-title"
|
||||
eyebrow="01 / Основы"
|
||||
title="Цвет и типографика"
|
||||
/>
|
||||
<div class="palette">
|
||||
<div v-for="[token, label] in palette" :key="token" class="swatch-card">
|
||||
<div class="swatch" :style="{ backgroundColor: `var(--brand-${token})` }"></div>
|
||||
<strong>{{ label }}</strong
|
||||
><code>--brand-{{ token }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="two-columns typography">
|
||||
<div>
|
||||
<h3>Истина складывается из деталей</h3>
|
||||
<p>
|
||||
Ровные тёмные панели, читаемый интерфейсный текст и ярко-жёлтое главное действие.
|
||||
</p>
|
||||
<p class="muted">Второстепенный текст: время игры, автор и пояснения.</p>
|
||||
<a href="#controls">Пример текстовой ссылки</a>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Лёгкие фактуры</h3>
|
||||
<p>
|
||||
Кожаная полоса в шапке и фрагменты улик связывают сайт с игрой. Доска
|
||||
расследования украшает превью дела; формы и таблицы остаются ровными панелями.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="controls" aria-labelledby="controls-title">
|
||||
<SectionHeading
|
||||
id="controls-title"
|
||||
eyebrow="02 / Управление"
|
||||
title="Кнопки и состояния"
|
||||
/>
|
||||
<div class="row">
|
||||
<n-button class="accent-button" type="primary" @click="openCase('Последний поезд')"
|
||||
>Основное действие</n-button
|
||||
>
|
||||
<n-button secondary @click="openCase('Последний поезд')">Второстепенное</n-button>
|
||||
<n-button type="error" @click="openCase('Последний поезд')"
|
||||
>Опасное действие</n-button
|
||||
>
|
||||
<n-button disabled>Недоступно</n-button><n-button loading>Загрузка</n-button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<n-tag type="success">Раскрыто</n-tag><n-tag type="warning">В работе</n-tag
|
||||
><n-tag type="error">Ошибка</n-tag><n-tag type="info">Подсказка</n-tag>
|
||||
</div>
|
||||
<div class="two-columns">
|
||||
<n-card title="Соберите команду">
|
||||
<form novalidate @submit.prevent="submitDemo">
|
||||
<n-form-item
|
||||
label="Название команды"
|
||||
label-for="demo-team"
|
||||
:validation-status="nameError ? 'error' : undefined"
|
||||
:feedback="nameError ? 'Укажите название команды' : undefined"
|
||||
>
|
||||
<n-input
|
||||
v-model:value="teamName"
|
||||
:input-props="{ id: 'demo-team', required: true }"
|
||||
placeholder="Например, Полночь"
|
||||
@update:value="isSaved = false"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="Сложность дела" label-for="demo-difficulty"
|
||||
><n-select
|
||||
v-model:value="difficulty"
|
||||
:options="difficulties"
|
||||
:input-props="{ id: 'demo-difficulty' }"
|
||||
/></n-form-item>
|
||||
<n-checkbox v-model:checked="hasMaterials"
|
||||
>Бумажные материалы подготовлены</n-checkbox
|
||||
>
|
||||
<div class="row">
|
||||
<n-button attr-type="submit" type="primary">Проверить форму</n-button>
|
||||
</div>
|
||||
<n-alert v-if="isSaved" type="success"
|
||||
>Команда «{{ teamName.trim() }}» готова. Это локальный пример, данные не
|
||||
отправляются.</n-alert
|
||||
>
|
||||
</form>
|
||||
</n-card>
|
||||
<div class="notifications">
|
||||
<n-alert type="info" title="Новая улика"
|
||||
>В материалах дела появилось письмо.</n-alert
|
||||
>
|
||||
<n-alert type="warning" title="Обратите внимание"
|
||||
>Перед началом подготовьте бумажные материалы.</n-alert
|
||||
>
|
||||
<n-alert type="error" title="Не удалось загрузить материалы"
|
||||
>Попробуйте ещё раз.</n-alert
|
||||
>
|
||||
<n-alert type="success" title="Дело раскрыто"
|
||||
>Все факты сложились в одну историю.</n-alert
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="table-title">
|
||||
<SectionHeading id="table-title" eyebrow="03 / Данные" title="Ход расследования" />
|
||||
<n-data-table :columns="columns" :data="teams" :bordered="false" :scroll-x="480" />
|
||||
</section>
|
||||
|
||||
<section id="paper" aria-labelledby="paper-title">
|
||||
<SectionHeading
|
||||
id="paper-title"
|
||||
eyebrow="04 / Каталог дел"
|
||||
title="У каждой истории свои улики"
|
||||
/>
|
||||
<p class="muted">
|
||||
Три демонстрационные папки: билет, ключ и письмо. Штампы показывают примеры состояний.
|
||||
</p>
|
||||
<div class="folder-grid">
|
||||
<CaseFolder
|
||||
number="014"
|
||||
cover-label="Материал 014 / Билет"
|
||||
cover-caption="23:40 · Вагон 03"
|
||||
title="Последний поезд"
|
||||
description="Письмо, билет и двадцать минут, которые меняют всё."
|
||||
quote="Часы остановились раньше, чем исчез пассажир."
|
||||
object="ticket"
|
||||
status="Новое дело"
|
||||
@open="openCase"
|
||||
/>
|
||||
<CaseFolder
|
||||
number="007"
|
||||
cover-label="Материал 007 / Ключ"
|
||||
cover-caption="Комната 07"
|
||||
disabled
|
||||
action-label="Скоро в каталоге"
|
||||
title="Седьмая комната"
|
||||
description="Ключ найден. Но кто запер дверь с другой стороны?"
|
||||
quote="Этот ключ открывает дверь, которой нет на плане."
|
||||
object="key"
|
||||
status="Скоро"
|
||||
@open="openCase"
|
||||
/>
|
||||
<CaseFolder
|
||||
number="021"
|
||||
cover-label="Материал 021 / Письмо"
|
||||
cover-caption="Адресат неизвестен"
|
||||
title="Без подписи"
|
||||
description="Три письма. Один адрес. Ни одного имени."
|
||||
quote="Последняя строка написана другим почерком."
|
||||
object="letter"
|
||||
status="Пройдено"
|
||||
@open="openCase"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<DossierFooter label="Лист 01 / Вечерний детектив"
|
||||
>Продолжение — в следующем деле.</DossierFooter
|
||||
>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<n-modal v-model:show="isModalOpen">
|
||||
<n-card
|
||||
class="demo-modal"
|
||||
:content-style="{ display: 'grid', gap: '16px' }"
|
||||
:title="selectedTitle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="selectedTitle"
|
||||
closable
|
||||
@close="isModalOpen = false"
|
||||
>
|
||||
<p>Соберите компанию, подготовьте материалы и начните с первой улики.</p>
|
||||
<p class="muted">Это демонстрация оформления окна. Игровое действие не выполняется.</p>
|
||||
<template #footer
|
||||
><n-button type="primary" @click="isModalOpen = false">Понятно</n-button></template
|
||||
>
|
||||
</n-card>
|
||||
</n-modal>
|
||||
</SiteThemeProvider>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.header-belt {
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
align-items: center;
|
||||
background: rgb(0 0 0 / 80%);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
justify-content: space-between;
|
||||
min-height: 70px;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.paper-link {
|
||||
color: var(--brand-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.paper-link:hover {
|
||||
color: var(--brand-accent);
|
||||
}
|
||||
|
||||
.accent-button:focus-visible {
|
||||
outline: 2px solid var(--brand-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.site-preview {
|
||||
--site-card-padding: 16px;
|
||||
|
||||
background: var(--brand-background);
|
||||
color: var(--brand-text);
|
||||
font-family: 'font_old_typer', serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.showcase-header {
|
||||
background: var(--brand-background);
|
||||
border-bottom: 1px solid var(--brand-border);
|
||||
min-height: 70px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.brand {
|
||||
align-items: center;
|
||||
color: var(--brand-text);
|
||||
display: flex;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
gap: 12px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: none;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--brand-accent);
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--brand-accent-hover);
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--brand-accent);
|
||||
outline-offset: 4px;
|
||||
}
|
||||
|
||||
.showcase-scroll {
|
||||
background: var(--brand-background);
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.showcase-content {
|
||||
max-width: 100%;
|
||||
padding: 24px 16px 32px;
|
||||
}
|
||||
|
||||
section {
|
||||
border-bottom: 1px dashed var(--brand-border);
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
margin-top: 32px;
|
||||
min-width: 0;
|
||||
padding-bottom: 32px;
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.two-columns {
|
||||
display: grid;
|
||||
gap: 32px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(32px, 4vw, 48px);
|
||||
font-weight: 600;
|
||||
letter-spacing: normal;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.margin-note {
|
||||
border-left: 1px solid var(--brand-bronze);
|
||||
color: var(--brand-text-muted);
|
||||
font-size: 14px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.folder-grid {
|
||||
display: grid;
|
||||
gap: 32px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.archive-number {
|
||||
color: var(--brand-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.lead {
|
||||
color: var(--brand-text-muted);
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
max-width: 580px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--brand-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: var(--brand-accent);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.row {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.case-card {
|
||||
align-self: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* stylelint-disable-next-line selector-pseudo-class-no-unknown -- Vue scoped: связь в дочерней доске при фокусе действия карточки. */
|
||||
.case-card:focus-within :deep(.clues)::before {
|
||||
background: var(--brand-accent);
|
||||
}
|
||||
|
||||
.case-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.case-action {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.palette {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.swatch-card {
|
||||
background: var(--brand-surface);
|
||||
border: 1px solid var(--brand-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
border-bottom: 1px solid var(--brand-border);
|
||||
height: 72px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.swatch-card strong,
|
||||
.swatch-card code {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.swatch-card code {
|
||||
color: var(--brand-text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.typography > div {
|
||||
align-content: start;
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hero-copy .row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
form .row {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.notifications {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.document-number {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.demo-modal {
|
||||
font-family: 'font_old_typer', serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
max-width: 560px;
|
||||
width: calc(100vw - 32px);
|
||||
}
|
||||
|
||||
@media (width >= 1024px) {
|
||||
nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
padding: 8px 32px;
|
||||
}
|
||||
|
||||
.site-preview {
|
||||
--site-card-padding: 24px;
|
||||
}
|
||||
|
||||
.showcase-content {
|
||||
padding: 32px 24px 48px;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-top: 48px;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
align-items: start;
|
||||
gap: 32px;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
}
|
||||
|
||||
.folder-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.two-columns {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.palette {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.showcase-scroll {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import type { GlobalThemeOverrides } from 'naive-ui'
|
||||
import { darkTheme, NConfigProvider } from 'naive-ui'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const theme = ref<GlobalThemeOverrides>({})
|
||||
onMounted(() => {
|
||||
if (!root.value) return
|
||||
const styles = getComputedStyle(root.value)
|
||||
const color = (name: string) => styles.getPropertyValue(`--brand-${name}`).trim()
|
||||
theme.value = {
|
||||
common: {
|
||||
fontFamily: styles.fontFamily,
|
||||
fontSize: '18px',
|
||||
fontSizeSmall: '14px',
|
||||
fontSizeMedium: '18px',
|
||||
fontSizeLarge: '20px',
|
||||
lineHeight: '1.5',
|
||||
bodyColor: color('background'),
|
||||
cardColor: color('surface'),
|
||||
modalColor: color('surface'),
|
||||
popoverColor: color('surface'),
|
||||
inputColor: color('background'),
|
||||
tableColor: color('surface'),
|
||||
textColorBase: color('text'),
|
||||
textColor1: color('text'),
|
||||
textColor2: color('text'),
|
||||
textColor3: color('text-muted'),
|
||||
placeholderColor: color('text-muted'),
|
||||
borderColor: color('border'),
|
||||
primaryColor: color('accent'),
|
||||
primaryColorHover: color('accent-hover'),
|
||||
primaryColorPressed: color('accent-pressed'),
|
||||
primaryColorSuppl: color('accent'),
|
||||
successColor: color('success'),
|
||||
infoColor: color('info'),
|
||||
warningColor: color('accent'),
|
||||
errorColor: color('error'),
|
||||
borderRadius: '8px',
|
||||
},
|
||||
Card: {
|
||||
paddingSmall: 'var(--site-card-padding, 16px)',
|
||||
paddingMedium: 'var(--site-card-padding, 16px)',
|
||||
paddingLarge: 'var(--site-card-padding, 24px)',
|
||||
},
|
||||
Button: {
|
||||
textColorPrimary: color('background'),
|
||||
textColorHoverPrimary: color('background'),
|
||||
textColorPressedPrimary: color('background'),
|
||||
textColorFocusPrimary: color('background'),
|
||||
textColorError: color('background'),
|
||||
textColorHoverError: color('background'),
|
||||
textColorPressedError: color('background'),
|
||||
textColorFocusError: color('background'),
|
||||
},
|
||||
}
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div ref="root" class="site-theme">
|
||||
<n-config-provider :theme="darkTheme" :theme-overrides="theme"><slot /></n-config-provider>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
.site-theme {
|
||||
color: var(--brand-text);
|
||||
font-family: 'font_old_typer', serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ text?: string }>()
|
||||
</script>
|
||||
<template>
|
||||
<span class="status-stamp"
|
||||
><slot>{{ text }}</slot></span
|
||||
>
|
||||
</template>
|
||||
<style scoped>
|
||||
.status-stamp {
|
||||
border: 2px solid currentcolor;
|
||||
color: var(--brand-background);
|
||||
font-size: 14px;
|
||||
padding: 4px 8px;
|
||||
transform: rotate(-5deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<span class="marker"><slot /></span>
|
||||
</template>
|
||||
<style scoped>
|
||||
.marker {
|
||||
color: var(--brand-background);
|
||||
display: inline;
|
||||
isolation: isolate;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.marker::before {
|
||||
background: var(--brand-bronze);
|
||||
clip-path: polygon(0 8%, 98% 0, 100% 88%, 2% 100%);
|
||||
content: '';
|
||||
inset: 0 -4px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,15 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
...(import.meta.env.DEV
|
||||
? [
|
||||
{
|
||||
path: '/design-system',
|
||||
name: 'design-system',
|
||||
component: () => import('../views/DesignSystemView.vue'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
path: '/participation',
|
||||
name: 'participation',
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import DesignSystemPage from '@/components/DesignSystemPage.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesignSystemPage />
|
||||
</template>
|
||||
Reference in New Issue
Block a user