Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 29s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m42s
Deploy / Build Web Image (push) Failing after 27s
Deploy / Build AI Services Image (push) Failing after 29s
E2E Tests / Playwright E2E (push) Failing after 43s
Deploy / Build API Image (push) Failing after 1m31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Security Scanning / Trivy Scan — API Image (push) Failing after 5m35s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 3m45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — Web Image (push) Failing after 13m51s
Security Scanning / Trivy Filesystem Scan (push) Failing after 14m46s
Security Scanning / Security Gate (push) Has been cancelled
354 lines
9.9 KiB
Markdown
354 lines
9.9 KiB
Markdown
# Sửa Lỗi Trợ Năng - Hướng Dẫn Triển Khai Chi Tiết
|
|
|
|
## Sửa #1: aria-label cho Input Tải Tệp Lên
|
|
|
|
**Tệp**: `apps/web/components/listings/image-upload.tsx`
|
|
**Dòng**: 118
|
|
|
|
### Trước
|
|
```tsx
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
if (e.target.files) addFiles(e.target.files);
|
|
e.target.value = '';
|
|
}}
|
|
/>
|
|
```
|
|
|
|
### Sau
|
|
```tsx
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp"
|
|
multiple
|
|
className="hidden"
|
|
aria-label="Chọn ảnh để tải lên"
|
|
onChange={(e) => {
|
|
if (e.target.files) addFiles(e.target.files);
|
|
e.target.value = '';
|
|
}}
|
|
/>
|
|
```
|
|
|
|
**Lý do**: Các input ẩn cần aria-label để trình đọc màn hình có thể thông báo mục đích của chúng khi được lấy tiêu điểm.
|
|
|
|
---
|
|
|
|
## Sửa #2: aria-label cho Input Hộp Thoại Tìm Kiếm
|
|
|
|
**Tệp**: `apps/web/app/[locale]/(public)/search/page.tsx`
|
|
**Dòng**: 189
|
|
|
|
### Trước
|
|
```tsx
|
|
<input
|
|
type="text"
|
|
value={saveName}
|
|
onChange={(e) => setSaveName(e.target.value)}
|
|
placeholder="Tên tìm kiếm (VD: Chung cư Q7 dưới 3 tỷ)"
|
|
className="mb-3 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
|
maxLength={100}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSaveSearch()}
|
|
/>
|
|
```
|
|
|
|
### Sau
|
|
```tsx
|
|
<input
|
|
type="text"
|
|
value={saveName}
|
|
onChange={(e) => setSaveName(e.target.value)}
|
|
placeholder="Tên tìm kiếm (VD: Chung cư Q7 dưới 3 tỷ)"
|
|
aria-label="Tên bộ lọc tìm kiếm"
|
|
className="mb-3 w-full rounded-md border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
|
maxLength={100}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSaveSearch()}
|
|
/>
|
|
```
|
|
|
|
**Lý do**: Các input văn bản cần aria-label khi không có phần tử nhãn liên kết. Placeholder không thể thay thế cho aria-label.
|
|
|
|
---
|
|
|
|
## Sửa #3: Quản Trị Kiểm Duyệt - Checkbox Chọn Tất Cả
|
|
|
|
**Tệp**: `apps/web/app/[locale]/(admin)/admin/moderation/page.tsx`
|
|
**Dòng**: 222
|
|
|
|
### Trước
|
|
```tsx
|
|
<TableHead className="w-10">
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.size === result.data.length && result.data.length > 0}
|
|
onChange={toggleSelectAll}
|
|
className="rounded border-input"
|
|
/>
|
|
</TableHead>
|
|
```
|
|
|
|
### Sau
|
|
```tsx
|
|
<TableHead className="w-10">
|
|
<input
|
|
type="checkbox"
|
|
aria-label="Chọn tất cả tin đăng"
|
|
checked={selected.size === result.data.length && result.data.length > 0}
|
|
onChange={toggleSelectAll}
|
|
className="rounded border-input"
|
|
/>
|
|
</TableHead>
|
|
```
|
|
|
|
**Lý do**: Checkbox trong tiêu đề bảng cần aria-label để phân biệt với các checkbox trên từng hàng.
|
|
|
|
---
|
|
|
|
## Sửa #4: Quản Trị Kiểm Duyệt - Checkbox Từng Hàng
|
|
|
|
**Tệp**: `apps/web/app/[locale]/(admin)/admin/moderation/page.tsx`
|
|
**Dòng**: 242
|
|
|
|
### Trước
|
|
```tsx
|
|
<TableCell>
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.has(item.listingId)}
|
|
onChange={() => toggleSelect(item.listingId)}
|
|
className="rounded border-input"
|
|
/>
|
|
</TableCell>
|
|
```
|
|
|
|
### Sau (Phương án 1 - Đơn giản)
|
|
```tsx
|
|
<TableCell>
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`Chọn tin đăng: ${item.listingId}`}
|
|
checked={selected.has(item.listingId)}
|
|
onChange={() => toggleSelect(item.listingId)}
|
|
className="rounded border-input"
|
|
/>
|
|
</TableCell>
|
|
```
|
|
|
|
### Sau (Phương án 2 - Tốt hơn với tiêu đề)
|
|
```tsx
|
|
<TableCell>
|
|
<input
|
|
type="checkbox"
|
|
aria-label={`Chọn tin đăng: ${item.title || item.listingId}`}
|
|
checked={selected.has(item.listingId)}
|
|
onChange={() => toggleSelect(item.listingId)}
|
|
className="rounded border-input"
|
|
/>
|
|
</TableCell>
|
|
```
|
|
|
|
**Lý do**: Mỗi checkbox cần aria-label duy nhất bao gồm ngữ cảnh về tin đăng mà nó đại diện.
|
|
|
|
---
|
|
|
|
## Sửa #5: Component Ảnh Giả Lập trong Kiểm Thử
|
|
|
|
**Tệp**: `apps/web/app/[locale]/(public)/search/__tests__/search.spec.tsx`
|
|
**Dòng**: 46
|
|
|
|
### Trước
|
|
```tsx
|
|
vi.mock('next/image', () => ({
|
|
default: (props: Record<string, unknown>) => <img {...props} />,
|
|
}));
|
|
```
|
|
|
|
### Sau (Phương án 1 - Đơn giản)
|
|
```tsx
|
|
vi.mock('next/image', () => ({
|
|
default: (props: Record<string, unknown>) => <img {...props} alt={props.alt || ''} />,
|
|
}));
|
|
```
|
|
|
|
### Sau (Phương án 2 - Có Cảnh Báo)
|
|
```tsx
|
|
vi.mock('next/image', () => ({
|
|
default: (props: Record<string, unknown>) => {
|
|
if (!props.alt) {
|
|
console.warn('Image mock: Missing alt attribute', props);
|
|
}
|
|
return <img {...props} alt={props.alt || 'image'} />;
|
|
},
|
|
}));
|
|
```
|
|
|
|
**Lý do**: Bản giả lập nên bắt buộc thuộc tính alt để phát hiện các ảnh thiếu alt trong kiểm thử trước khi đưa lên môi trường production.
|
|
|
|
---
|
|
|
|
## Sửa #6 (Cải Tiến): Trợ Năng Kéo-Thả Tải Ảnh Lên
|
|
|
|
**Tệp**: `apps/web/components/listings/image-upload.tsx`
|
|
**Dòng**: 86-128
|
|
|
|
### Mã Hiện Tại
|
|
```tsx
|
|
<div
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onClick={() => inputRef.current?.click()}
|
|
className={cn(
|
|
'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors',
|
|
isDragging
|
|
? 'border-primary bg-primary/5'
|
|
: 'border-muted-foreground/25 hover:border-primary/50',
|
|
)}
|
|
>
|
|
<svg>...</svg>
|
|
<p className="text-sm font-medium">Kéo thả ảnh vào đây hoặc nhấp để chọn</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
JPG, PNG, WebP - Tối đa {maxFiles} ảnh, mỗi ảnh 10MB
|
|
</p>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
if (e.target.files) addFiles(e.target.files);
|
|
e.target.value = '';
|
|
}}
|
|
/>
|
|
</div>
|
|
```
|
|
|
|
### Mã Đã Cải Tiến
|
|
```tsx
|
|
<div
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onClick={() => inputRef.current?.click()}
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label="Khu vực kéo thả hoặc nhấp để tải ảnh lên"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
inputRef.current?.click();
|
|
}
|
|
}}
|
|
className={cn(
|
|
'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
|
isDragging
|
|
? 'border-primary bg-primary/5'
|
|
: 'border-muted-foreground/25 hover:border-primary/50',
|
|
)}
|
|
>
|
|
<svg>...</svg>
|
|
<p className="text-sm font-medium">Kéo thả ảnh vào đây hoặc nhấp để chọn</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
JPG, PNG, WebP - Tối đa {maxFiles} ảnh, mỗi ảnh 10MB
|
|
</p>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
accept="image/jpeg,image/png,image/webp"
|
|
multiple
|
|
className="hidden"
|
|
aria-label="Chọn ảnh để tải lên"
|
|
onChange={(e) => {
|
|
if (e.target.files) addFiles(e.target.files);
|
|
e.target.value = '';
|
|
}}
|
|
/>
|
|
</div>
|
|
```
|
|
|
|
**Các thay đổi**:
|
|
- `role="button"` - Xác định div là một nút tương tác
|
|
- `tabIndex={0}` - Cho phép div nhận tiêu điểm từ bàn phím
|
|
- `aria-label` - Mô tả mục đích cho trình đọc màn hình
|
|
- Trình xử lý `onKeyDown` - Cho phép kích hoạt bằng Enter/Space
|
|
- Kiểu `focus-visible` - Hiển thị chỉ báo tiêu điểm khi điều hướng bằng bàn phím
|
|
|
|
**Lý do**: Làm cho vùng kéo-thả có thể truy cập hoàn toàn bằng bàn phím cho những người dùng không thể sử dụng chuột.
|
|
|
|
---
|
|
|
|
## Kiểm Tra Sau Khi Triển Khai
|
|
|
|
### 1. Kiểm Tra Trình Đọc Màn Hình
|
|
```bash
|
|
# Sử dụng VoiceOver (Mac), NVDA (Windows), hoặc JAWS
|
|
# Điều hướng đến từng phần tử đã sửa và xác nhận:
|
|
# - Input được thông báo với aria-label của nó
|
|
# - Checkbox được thông báo với aria-label của nó
|
|
# - Mục đích rõ ràng qua thông báo của trình đọc màn hình
|
|
```
|
|
|
|
### 2. Kiểm Tra Điều Hướng Bằng Bàn Phím
|
|
```bash
|
|
# Tab qua trang
|
|
# Xác nhận:
|
|
# - Tất cả các phần tử tương tác có thể truy cập bằng Tab
|
|
# - Tiêu điểm hiển thị rõ trên tất cả các phần tử
|
|
# - Enter/Space kích hoạt các nút và checkbox
|
|
# - Vùng kéo-thả tải ảnh có thể nhận tiêu điểm và kích hoạt bằng bàn phím
|
|
```
|
|
|
|
### 3. Kiểm Tra Tự Động
|
|
```bash
|
|
# Chạy axe
|
|
npm run test:a11y
|
|
|
|
# Hoặc dùng Lighthouse
|
|
npx lighthouse https://localhost:3000 --view
|
|
|
|
# Plugin ESLint JSX Accessibility nên phát hiện các vấn đề này:
|
|
npm run lint
|
|
```
|
|
|
|
### 4. Kiểm Tra Trực Quan
|
|
```bash
|
|
# Xác nhận bằng công cụ dành cho nhà phát triển trong trình duyệt:
|
|
# - Kiểm tra từng input để xác nhận thuộc tính aria-label tồn tại
|
|
# - Kiểm tra kiểu tiêu điểm phù hợp
|
|
# - Xác nhận màu vòng tiêu điểm đáp ứng yêu cầu độ tương phản
|
|
```
|
|
|
|
---
|
|
|
|
## Tóm Tắt Các Thay Đổi
|
|
|
|
| Vấn đề | Tệp | Dòng | Loại | Mức độ nghiêm trọng |
|
|
|--------|-----|------|------|---------------------|
|
|
| Input tệp thiếu aria-label | image-upload.tsx | 118 | aria-label | CAO |
|
|
| Input tìm kiếm thiếu aria-label | search/page.tsx | 189 | aria-label | CAO |
|
|
| Checkbox tiêu đề thiếu aria-label | moderation/page.tsx | 222 | aria-label | CAO |
|
|
| Checkbox hàng thiếu aria-label | moderation/page.tsx | 242 | aria-label | CAO |
|
|
| Ảnh giả lập thiếu alt | search.spec.tsx | 46 | thuộc tính alt | TRUNG BÌNH |
|
|
| Vùng kéo-thả không thể truy cập bằng bàn phím | image-upload.tsx | 86-128 | cải tiến | TRUNG BÌNH |
|
|
|
|
---
|
|
|
|
## Thời Gian Triển Khai Ước Tính
|
|
|
|
- Sửa #1: 2 phút
|
|
- Sửa #2: 2 phút
|
|
- Sửa #3: 2 phút
|
|
- Sửa #4: 3 phút (cần tìm item.title trong ngữ cảnh)
|
|
- Sửa #5: 2 phút
|
|
- Sửa #6: 10 phút
|
|
- Kiểm tra: 15-20 phút
|
|
|
|
**Tổng cộng: ~35-45 phút**
|