feat: add Telegram customers management system with admin panel
Implement comprehensive Telegram customers storage and management functionality: Backend: - Add database migration for telecart_customers table with indexes - Create TelegramCustomer model with CRUD operations - Implement TelegramCustomerService for business logic - Add TelegramCustomerHandler for API endpoint (saveOrUpdate) - Add TelegramCustomersHandler for admin API (getCustomers with pagination, filtering, sorting) - Add SendMessageHandler for sending messages to customers via Telegram - Create custom exceptions: TelegramCustomerNotFoundException, TelegramCustomerWriteNotAllowedException - Refactor TelegramInitDataDecoder to separate decoding logic - Add TelegramHeader enum for header constants - Update SignatureValidator to use TelegramInitDataDecoder - Register new routes in bastion/routes.php and src/routes.php Frontend (Admin): - Add CustomersView.vue component with PrimeVue DataTable - Implement advanced filtering (text, date, boolean filters) - Add column visibility toggle functionality - Add global search with debounce - Implement message sending dialog with validation - Add Russian locale for PrimeVue components - Add navigation link in App.vue - Register route in router Frontend (SPA): - Add saveTelegramCustomer utility function - Integrate automatic customer data saving on app initialization - Extract user data from Telegram.WebApp.initDataUnsafe The system automatically saves/updates customer data when users access the Telegram Mini App, and provides admin interface for viewing, filtering, and messaging customers. BREAKING CHANGE: None
This commit is contained in:
201
.cursor/rules/architecture.md
Normal file
201
.cursor/rules/architecture.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# Архитектурные правила
|
||||
|
||||
## OpenCart Framework Architecture
|
||||
|
||||
### MVC-L Pattern
|
||||
|
||||
Проект использует модифицированный паттерн MVC-L (Model-View-Controller-Language):
|
||||
|
||||
- **Model**: Классы в `src/Models/` - работа с данными, доступ к БД
|
||||
- **View**: Vue компоненты на фронтенде, JSON ответы на бэкенде
|
||||
- **Controller**: Handlers в `src/Handlers/` и `bastion/Handlers/`
|
||||
- **Language**: Переводчик в `framework/Translator/`
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
Все зависимости внедряются через Container:
|
||||
|
||||
```php
|
||||
// ✅ Правильно
|
||||
public function __construct(
|
||||
private Builder $builder,
|
||||
private TelegramCustomer $telegramCustomerModel
|
||||
) {}
|
||||
|
||||
// ❌ Неправильно
|
||||
public function __construct() {
|
||||
$this->builder = new Builder(...);
|
||||
}
|
||||
```
|
||||
|
||||
### Service Providers
|
||||
|
||||
Регистрация сервисов через Service Providers:
|
||||
|
||||
```php
|
||||
class MyServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(MyService::class, function ($app) {
|
||||
return new MyService($app->get(Dependency::class));
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Routes
|
||||
|
||||
Маршруты определяются в `routes.php`:
|
||||
|
||||
```php
|
||||
return [
|
||||
'actionName' => [HandlerClass::class, 'methodName'],
|
||||
];
|
||||
```
|
||||
|
||||
### Handlers (Controllers)
|
||||
|
||||
Handlers обрабатывают HTTP запросы:
|
||||
|
||||
```php
|
||||
class MyHandler
|
||||
{
|
||||
public function handle(Request $request): JsonResponse
|
||||
{
|
||||
// Валидация
|
||||
// Бизнес-логика через Services
|
||||
// Возврат JsonResponse
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Models
|
||||
|
||||
Models работают с данными:
|
||||
|
||||
```php
|
||||
class MyModel
|
||||
{
|
||||
public function __construct(
|
||||
private ConnectionInterface $database,
|
||||
private Builder $builder
|
||||
) {}
|
||||
|
||||
public function findById(int $id): ?array
|
||||
{
|
||||
return $this->builder->newQuery()
|
||||
->from($this->tableName)
|
||||
->where('id', '=', $id)
|
||||
->firstOrNull();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Services
|
||||
|
||||
Services содержат бизнес-логику:
|
||||
|
||||
```php
|
||||
class MyService
|
||||
{
|
||||
public function __construct(
|
||||
private MyModel $model
|
||||
) {}
|
||||
|
||||
public function doSomething(array $data): array
|
||||
{
|
||||
// Бизнес-логика
|
||||
return $this->model->create($data);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Migrations
|
||||
|
||||
Миграции в `database/migrations/`:
|
||||
|
||||
```php
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$this->database->statement('CREATE TABLE ...');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Query Builder
|
||||
|
||||
Всегда используй Query Builder вместо прямых SQL:
|
||||
|
||||
```php
|
||||
// ✅ Правильно
|
||||
$query = $this->builder->newQuery()
|
||||
->select(['id', 'name'])
|
||||
->from('table_name')
|
||||
->where('status', '=', 'active')
|
||||
->get();
|
||||
|
||||
// ❌ Неправильно
|
||||
$result = $this->database->query("SELECT * FROM table_name WHERE status = 'active'");
|
||||
```
|
||||
|
||||
### Frontend Architecture
|
||||
|
||||
#### Admin Panel (Vue 3)
|
||||
|
||||
- Composition API
|
||||
- Pinia для state management
|
||||
- PrimeVue для UI компонентов
|
||||
- Axios для HTTP запросов
|
||||
- Vue Router для навигации
|
||||
|
||||
#### SPA (Telegram Mini App)
|
||||
|
||||
- Composition API
|
||||
- Pinia stores
|
||||
- Tailwind CSS для стилей
|
||||
- Telegram WebApp API
|
||||
- Vue Router
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Classes**: PascalCase (`TelegramCustomerService`)
|
||||
- **Methods**: camelCase (`getCustomers`)
|
||||
- **Variables**: camelCase (`$customerData`)
|
||||
- **Constants**: UPPER_SNAKE_CASE (`MAX_RETRIES`)
|
||||
- **Files**: PascalCase для классов, kebab-case для остального
|
||||
- **Tables**: snake_case с префиксом `telecart_`
|
||||
|
||||
### Error Handling
|
||||
|
||||
Всегда обрабатывай ошибки:
|
||||
|
||||
```php
|
||||
try {
|
||||
$result = $this->service->doSomething();
|
||||
} catch (SpecificException $e) {
|
||||
$this->logger->error('Error message', ['exception' => $e]);
|
||||
throw new UserFriendlyException('User message');
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Используй конфигурационные файлы в `configs/`:
|
||||
|
||||
```php
|
||||
$config = $this->app->getConfigValue('app.setting_name');
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
Используй Cache Service для кэширования:
|
||||
|
||||
```php
|
||||
$cache = $this->app->get(CacheInterface::class);
|
||||
$value = $cache->get('key', function() {
|
||||
return expensiveOperation();
|
||||
});
|
||||
```
|
||||
|
||||
332
.cursor/rules/javascript.md
Normal file
332
.cursor/rules/javascript.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# JavaScript/TypeScript Code Style Rules
|
||||
|
||||
## JavaScript Version
|
||||
|
||||
- ES2020+ features
|
||||
- Modern async/await
|
||||
- Optional chaining (`?.`)
|
||||
- Nullish coalescing (`??`)
|
||||
- Template literals
|
||||
|
||||
## Code Style
|
||||
|
||||
### Variable Declarations
|
||||
|
||||
```javascript
|
||||
// ✅ Используй const по умолчанию
|
||||
const customers = [];
|
||||
const totalRecords = 0;
|
||||
|
||||
// ✅ let только когда нужно переназначение
|
||||
let currentPage = 1;
|
||||
currentPage = 2;
|
||||
|
||||
// ❌ Не используй var
|
||||
var oldVariable = 'bad';
|
||||
```
|
||||
|
||||
### Arrow Functions
|
||||
|
||||
```javascript
|
||||
// ✅ Предпочтительно для коротких функций
|
||||
const filtered = items.filter(item => item.isActive);
|
||||
|
||||
// ✅ Для методов объектов
|
||||
const api = {
|
||||
get: async (url) => {
|
||||
return await fetch(url);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ Для сложной логики - обычные функции
|
||||
function complexCalculation(data) {
|
||||
// много строк кода
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### Template Literals
|
||||
|
||||
```javascript
|
||||
// ✅ Предпочтительно
|
||||
const message = `User ${userId} not found`;
|
||||
const url = `${baseUrl}/api/${endpoint}`;
|
||||
|
||||
// ❌ Не используй конкатенацию
|
||||
const message = 'User ' + userId + ' not found';
|
||||
```
|
||||
|
||||
### Optional Chaining
|
||||
|
||||
```javascript
|
||||
// ✅ Используй optional chaining
|
||||
const name = user?.profile?.name;
|
||||
const count = data?.items?.length ?? 0;
|
||||
|
||||
// ❌ Избегай длинных проверок
|
||||
const name = user && user.profile && user.profile.name;
|
||||
```
|
||||
|
||||
### Nullish Coalescing
|
||||
|
||||
```javascript
|
||||
// ✅ Используй ?? для значений по умолчанию
|
||||
const page = params.page ?? 1;
|
||||
const name = user.name ?? 'Unknown';
|
||||
|
||||
// ❌ Не используй || для чисел/булевых
|
||||
const page = params.page || 1; // 0 будет заменено на 1
|
||||
```
|
||||
|
||||
### Destructuring
|
||||
|
||||
```javascript
|
||||
// ✅ Используй деструктуризацию
|
||||
const { data, totalRecords } = response.data;
|
||||
const [first, second] = items;
|
||||
|
||||
// ✅ В параметрах функций
|
||||
function processUser({ id, name, email }) {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ✅ С значениями по умолчанию
|
||||
const { page = 1, limit = 20 } = params;
|
||||
```
|
||||
|
||||
### Async/Await
|
||||
|
||||
```javascript
|
||||
// ✅ Предпочтительно
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
const response = await apiGet('getCustomers', params);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Избегай .then() цепочек
|
||||
function loadCustomers() {
|
||||
return apiGet('getCustomers', params)
|
||||
.then(response => response.data)
|
||||
.catch(error => console.error(error));
|
||||
}
|
||||
```
|
||||
|
||||
## Vue.js 3 Composition API
|
||||
|
||||
### Script Setup
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Используй <script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { apiGet } from '@/utils/http.js';
|
||||
|
||||
const customers = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const totalRecords = computed(() => customers.value.length);
|
||||
|
||||
async function loadCustomers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await apiGet('getCustomers');
|
||||
customers.value = result.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomers();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Reactive State
|
||||
|
||||
```javascript
|
||||
// ✅ Используй ref для примитивов
|
||||
const count = ref(0);
|
||||
const name = ref('');
|
||||
|
||||
// ✅ Используй reactive для объектов
|
||||
import { reactive } from 'vue';
|
||||
const state = reactive({
|
||||
customers: [],
|
||||
loading: false
|
||||
});
|
||||
|
||||
// ✅ Или ref для объектов (предпочтительно)
|
||||
const state = ref({
|
||||
customers: [],
|
||||
loading: false
|
||||
});
|
||||
```
|
||||
|
||||
### Computed Properties
|
||||
|
||||
```javascript
|
||||
// ✅ Используй computed для производных значений
|
||||
const filteredCustomers = computed(() => {
|
||||
return customers.value.filter(c => c.isActive);
|
||||
});
|
||||
|
||||
// ❌ Не используй методы для вычислений
|
||||
function filteredCustomers() {
|
||||
return customers.value.filter(c => c.isActive);
|
||||
}
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Определяй props с типами
|
||||
const props = defineProps({
|
||||
customerId: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
showDetails: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Emits
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Определяй emits
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
function handleUpdate() {
|
||||
emit('update', data);
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Pinia Stores
|
||||
|
||||
```javascript
|
||||
// ✅ Используй setup stores
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
export const useCustomersStore = defineStore('customers', () => {
|
||||
const customers = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
const totalRecords = computed(() => customers.value.length);
|
||||
|
||||
async function loadCustomers() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await apiGet('getCustomers');
|
||||
customers.value = result.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
customers,
|
||||
loading,
|
||||
totalRecords,
|
||||
loadCustomers
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```javascript
|
||||
// ✅ Всегда обрабатывай ошибки
|
||||
async function loadData() {
|
||||
try {
|
||||
const result = await apiGet('endpoint');
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load data:', error);
|
||||
toast.error('Не удалось загрузить данные');
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Variables and Functions
|
||||
|
||||
```javascript
|
||||
// ✅ camelCase
|
||||
const customerData = {};
|
||||
const totalRecords = 0;
|
||||
function loadCustomers() {}
|
||||
|
||||
// ✅ Константы UPPER_SNAKE_CASE
|
||||
const MAX_RETRIES = 3;
|
||||
const API_BASE_URL = '/api';
|
||||
```
|
||||
|
||||
### Components
|
||||
|
||||
```vue
|
||||
<!-- ✅ PascalCase для компонентов -->
|
||||
<CustomerCard />
|
||||
<ProductsList />
|
||||
```
|
||||
|
||||
### Files
|
||||
|
||||
```javascript
|
||||
// ✅ kebab-case для файлов
|
||||
// customers-view.vue
|
||||
// http-utils.js
|
||||
// customer-service.js
|
||||
```
|
||||
|
||||
## Imports
|
||||
|
||||
```javascript
|
||||
// ✅ Группируй импорты
|
||||
// 1. Vue core
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
|
||||
// 2. Third-party
|
||||
import { apiGet } from '@/utils/http.js';
|
||||
import { useToast } from 'primevue';
|
||||
|
||||
// 3. Local components
|
||||
import CustomerCard from '@/components/CustomerCard.vue';
|
||||
|
||||
// 4. Types (если TypeScript)
|
||||
import type { Customer } from '@/types';
|
||||
```
|
||||
|
||||
## TypeScript (где используется)
|
||||
|
||||
```typescript
|
||||
// ✅ Используй типы
|
||||
interface Customer {
|
||||
id: number;
|
||||
name: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
function getCustomer(id: number): Promise<Customer> {
|
||||
return apiGet(`customers/${id}`);
|
||||
}
|
||||
```
|
||||
|
||||
243
.cursor/rules/php.md
Normal file
243
.cursor/rules/php.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# PHP Code Style Rules
|
||||
|
||||
## PHP Version
|
||||
|
||||
Проект поддерживает PHP 7.4+
|
||||
|
||||
## PSR Standards
|
||||
|
||||
- **PSR-1**: Basic Coding Standard
|
||||
- **PSR-4**: Autoloading Standard
|
||||
- **PSR-12**: Extended Coding Style
|
||||
|
||||
## Code Style
|
||||
|
||||
### Type Declarations
|
||||
|
||||
```php
|
||||
// ✅ Правильно - строгая типизация
|
||||
public function getCustomers(Request $request): JsonResponse
|
||||
{
|
||||
$id = (int) $request->get('id');
|
||||
return new JsonResponse(['data' => $customers]);
|
||||
}
|
||||
|
||||
// ❌ Неправильно - без типов
|
||||
public function getCustomers($request)
|
||||
{
|
||||
return ['data' => $customers];
|
||||
}
|
||||
```
|
||||
|
||||
### Nullable Types
|
||||
|
||||
```php
|
||||
// ✅ Правильно
|
||||
public function findById(?int $id): ?array
|
||||
{
|
||||
if ($id === null) {
|
||||
return null;
|
||||
}
|
||||
return $this->query->where('id', '=', $id)->firstOrNull();
|
||||
}
|
||||
```
|
||||
|
||||
### Strict Types
|
||||
|
||||
Всегда используй `declare(strict_types=1);`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
```
|
||||
|
||||
### Array Syntax
|
||||
|
||||
```php
|
||||
// ✅ Предпочтительно - короткий синтаксис
|
||||
$array = ['key' => 'value'];
|
||||
|
||||
// ❌ Не использовать
|
||||
$array = array('key' => 'value');
|
||||
```
|
||||
|
||||
### String Interpolation
|
||||
|
||||
```php
|
||||
// ✅ Предпочтительно
|
||||
$message = "User {$userId} not found";
|
||||
|
||||
// ✅ Альтернатива
|
||||
$message = sprintf('User %d not found', $userId);
|
||||
```
|
||||
|
||||
### Arrow Functions (PHP 7.4+)
|
||||
|
||||
```php
|
||||
// ✅ Для простых операций
|
||||
$filtered = array_filter($items, fn($item) => $item->isActive());
|
||||
|
||||
// ❌ Для сложной логики - используй обычные функции
|
||||
```
|
||||
|
||||
### Nullsafe Operator (PHP 8.0+)
|
||||
|
||||
```php
|
||||
// ✅ Для PHP 7.4
|
||||
$name = $user && $user->profile ? $user->profile->name : null;
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Classes
|
||||
|
||||
```php
|
||||
// ✅ PascalCase
|
||||
class TelegramCustomerService {}
|
||||
class UserRepository {}
|
||||
```
|
||||
|
||||
### Methods
|
||||
|
||||
```php
|
||||
// ✅ camelCase
|
||||
public function getCustomers(): array {}
|
||||
public function saveOrUpdate(array $data): array {}
|
||||
```
|
||||
|
||||
### Variables
|
||||
|
||||
```php
|
||||
// ✅ camelCase
|
||||
$customerData = [];
|
||||
$totalRecords = 0;
|
||||
```
|
||||
|
||||
### Constants
|
||||
|
||||
```php
|
||||
// ✅ UPPER_SNAKE_CASE
|
||||
private const MAX_RETRIES = 3;
|
||||
public const DEFAULT_PAGE_SIZE = 20;
|
||||
```
|
||||
|
||||
### Private Properties
|
||||
|
||||
```php
|
||||
// ✅ camelCase с модификатором доступа
|
||||
private string $tableName;
|
||||
private Builder $builder;
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
### PHPDoc
|
||||
|
||||
```php
|
||||
/**
|
||||
* @throws ValidationException Если параметры невалидны
|
||||
*/
|
||||
public function getCustomers(Request $request): JsonResponse
|
||||
{
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Inline Comments
|
||||
|
||||
```php
|
||||
// ✅ Полезные комментарии
|
||||
// Применяем фильтры для подсчета общего количества записей
|
||||
$countQuery = $this->buildCountQuery($filters);
|
||||
|
||||
// ❌ Очевидные комментарии
|
||||
// Получаем данные
|
||||
$data = $this->getData();
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Exceptions
|
||||
|
||||
```php
|
||||
// ✅ Специфичные исключения
|
||||
if (!$userId) {
|
||||
throw new InvalidArgumentException('User ID is required');
|
||||
}
|
||||
|
||||
// ✅ Логирование
|
||||
try {
|
||||
$result = $this->service->process();
|
||||
} catch (Exception $e) {
|
||||
$this->logger->error('Processing failed', [
|
||||
'exception' => $e,
|
||||
'context' => $context,
|
||||
]);
|
||||
throw new ProcessingException('Failed to process', 0, $e);
|
||||
}
|
||||
```
|
||||
|
||||
## Query Builder Usage
|
||||
|
||||
### Always Use Query Builder
|
||||
|
||||
```php
|
||||
// ✅ Правильно
|
||||
$customers = $this->builder->newQuery()
|
||||
->select(['id', 'name', 'email'])
|
||||
->from('telecart_customers')
|
||||
->where('status', '=', 'active')
|
||||
->orderBy('created_at', 'DESC')
|
||||
->get();
|
||||
|
||||
// В крайних случаях можно использовать прямые SQL
|
||||
$result = $this->database->query("SELECT * FROM telecart_customers");
|
||||
```
|
||||
|
||||
### Parameter Binding
|
||||
|
||||
```php
|
||||
// ✅ Query Builder автоматически биндит параметры
|
||||
$query->where('name', 'LIKE', "%{$search}%");
|
||||
|
||||
// ❌ Никогда не конкатенируй значения в SQL, избегай SQL Injection.
|
||||
```
|
||||
|
||||
## Array Access
|
||||
|
||||
### Safe Array Access
|
||||
|
||||
```php
|
||||
// ✅ Используй Arr::get()
|
||||
use Openguru\OpenCartFramework\Support\Arr;
|
||||
|
||||
$value = Arr::get($data, 'key', 'default');
|
||||
|
||||
// ❌ Небезопасно
|
||||
$value = $data['key']; // может вызвать ошибку
|
||||
```
|
||||
|
||||
## Return Types
|
||||
|
||||
```php
|
||||
// ✅ Всегда указывай return type
|
||||
public function getData(): array {}
|
||||
public function findById(int $id): ?array {}
|
||||
public function process(): void {}
|
||||
|
||||
// ❌ Без типа
|
||||
public function getData() {}
|
||||
```
|
||||
|
||||
## Visibility Modifiers
|
||||
|
||||
```php
|
||||
// ✅ Всегда указывай модификатор доступа
|
||||
private string $tableName;
|
||||
protected Builder $builder;
|
||||
public function getData(): array {}
|
||||
```
|
||||
|
||||
370
.cursor/rules/vue.md
Normal file
370
.cursor/rules/vue.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# Vue.js 3 Rules
|
||||
|
||||
## Component Structure
|
||||
|
||||
### Template
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Логическая структура -->
|
||||
<div class="container">
|
||||
<header>
|
||||
<h2>{{ title }}</h2>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<DataTable :value="items" />
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<Button @click="handleSave">Save</Button>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Script Setup
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Всегда используй <script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import { apiGet } from '@/utils/http.js';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['update', 'delete']);
|
||||
|
||||
// State
|
||||
const items = ref([]);
|
||||
const loading = ref(false);
|
||||
|
||||
// Computed
|
||||
const totalItems = computed(() => items.value.length);
|
||||
|
||||
// Methods
|
||||
async function loadItems() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await apiGet('getItems');
|
||||
items.value = result.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadItems();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Styles
|
||||
|
||||
```vue
|
||||
<style scoped>
|
||||
/* ✅ Используй scoped стили */
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* ✅ Используй :deep() для доступа к дочерним компонентам */
|
||||
:deep(.p-datatable) {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Component Naming
|
||||
|
||||
```vue
|
||||
<!-- ✅ PascalCase для компонентов -->
|
||||
<CustomerCard />
|
||||
<ProductsList />
|
||||
<OrderDetails />
|
||||
|
||||
<!-- ✅ kebab-case в шаблоне тоже работает -->
|
||||
<customer-card />
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Всегда определяй типы и валидацию
|
||||
const props = defineProps({
|
||||
customerId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
validator: (value) => value > 0
|
||||
},
|
||||
showDetails: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Emits
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Определяй emits с типами
|
||||
const emit = defineEmits<{
|
||||
update: [id: number, data: object];
|
||||
delete: [id: number];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
// ✅ Или с валидацией
|
||||
const emit = defineEmits({
|
||||
update: (id: number, data: object) => {
|
||||
if (id > 0 && typeof data === 'object') {
|
||||
return true;
|
||||
}
|
||||
console.warn('Invalid emit arguments');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Reactive State
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ ref для примитивов
|
||||
const count = ref(0);
|
||||
const name = ref('');
|
||||
|
||||
// ✅ ref для объектов (предпочтительно)
|
||||
const customer = ref({
|
||||
id: null,
|
||||
name: '',
|
||||
email: ''
|
||||
});
|
||||
|
||||
// ✅ reactive только если нужно
|
||||
import { reactive } from 'vue';
|
||||
const state = reactive({
|
||||
items: [],
|
||||
loading: false
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Computed Properties
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Используй computed для производных значений
|
||||
const filteredItems = computed(() => {
|
||||
return items.value.filter(item => item.isActive);
|
||||
});
|
||||
|
||||
// ✅ Computed с getter/setter
|
||||
const fullName = computed({
|
||||
get: () => `${firstName.value} ${lastName.value}`,
|
||||
set: (value) => {
|
||||
const parts = value.split(' ');
|
||||
firstName.value = parts[0];
|
||||
lastName.value = parts[1];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Event Handlers
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Используй kebab-case для событий -->
|
||||
<Button @click="handleClick" />
|
||||
<Input @input="handleInput" />
|
||||
<Form @submit.prevent="handleSubmit" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// ✅ Именуй обработчики с префиксом handle
|
||||
function handleClick() {
|
||||
// ...
|
||||
}
|
||||
|
||||
function handleInput(event) {
|
||||
// ...
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
// ...
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Conditional Rendering
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Используй v-if для условного рендеринга -->
|
||||
<div v-if="loading">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<!-- ✅ v-show для частых переключений -->
|
||||
<div v-show="hasItems">
|
||||
<ItemsList :items="items" />
|
||||
</div>
|
||||
|
||||
<!-- ✅ v-else для альтернатив -->
|
||||
<div v-else>
|
||||
<EmptyState />
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Lists
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Всегда используй :key -->
|
||||
<div v-for="item in items" :key="item.id">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
|
||||
<!-- ✅ Для индексов -->
|
||||
<div v-for="(item, index) in items" :key="`item-${index}`">
|
||||
{{ item.name }}
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Form Handling
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<!-- ✅ Используй v-model -->
|
||||
<InputText v-model="form.name" />
|
||||
<Textarea v-model="form.description" />
|
||||
|
||||
<!-- ✅ Для кастомных компонентов -->
|
||||
<CustomInput v-model="form.email" />
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
email: ''
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
// Валидация и отправка
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## PrimeVue Components
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<!-- ✅ Используй PrimeVue компоненты в админке -->
|
||||
<DataTable
|
||||
:value="customers"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="20"
|
||||
@page="onPage"
|
||||
>
|
||||
<Column field="name" header="Name" sortable />
|
||||
</DataTable>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
```vue
|
||||
<style scoped>
|
||||
/* ✅ Используй scoped -->
|
||||
.container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* ✅ :deep() для дочерних компонентов */
|
||||
:deep(.p-datatable) {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
/* ✅ :slotted() для слотов */
|
||||
:slotted(.header) {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Composition Functions
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
// ✅ Выноси сложную логику в composables
|
||||
import { useCustomers } from '@/composables/useCustomers.js';
|
||||
|
||||
const {
|
||||
customers,
|
||||
loading,
|
||||
loadCustomers,
|
||||
totalRecords
|
||||
} = useCustomers();
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomers();
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { useToast } from 'primevue';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
async function loadData() {
|
||||
try {
|
||||
const result = await apiGet('endpoint');
|
||||
if (result.success) {
|
||||
data.value = result.data;
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Ошибка',
|
||||
detail: 'Не удалось загрузить данные'
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user