mirror of
https://github.com/Afilmory/afilmory
synced 2026-04-24 23:05:05 +00:00
refactor: remove ApiKeyGuard and update AuthGuard logic
- Deleted the ApiKeyGuard as it was no longer needed. - Updated AuthGuard to handle session validation more efficiently by checking for a session before assigning user data. - Enhanced role verification logic to ensure proper authorization checks. - Introduced Onboarding module with controllers and services for managing onboarding processes. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -1,22 +0,0 @@
|
||||
import { env } from '@afilmory/env'
|
||||
import type { CanActivate, ExecutionContext } from '@afilmory/framework'
|
||||
import { UnauthorizedException } from '@afilmory/framework'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
@injectable()
|
||||
export class ApiKeyGuard implements CanActivate {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const { hono } = context.getContext()
|
||||
const apiKey = hono.req.header('x-api-key')
|
||||
const expected = env.API_KEY ?? 'secret-key'
|
||||
|
||||
if (apiKey !== expected) {
|
||||
throw new UnauthorizedException({
|
||||
statusCode: 401,
|
||||
message: 'Invalid API key',
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import type { Session } from 'better-auth'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { AuthProvider, AuthSession } from '../modules/auth/auth.provider'
|
||||
import type { AuthSession } from '../modules/auth/auth.provider'
|
||||
import { AuthProvider } from '../modules/auth/auth.provider'
|
||||
import { getAllowedRoleMask, roleNameToBit } from './roles.decorator'
|
||||
|
||||
declare module '@afilmory/framework' {
|
||||
@@ -24,24 +25,26 @@ export class AuthGuard implements CanActivate {
|
||||
const store = context.getContext()
|
||||
const { hono } = store
|
||||
|
||||
const auth = this.authProvider.getAuth()
|
||||
const auth = await this.authProvider.getAuth()
|
||||
|
||||
const session = await auth.api.getSession({ headers: hono.req.raw.headers })
|
||||
if (!session) {
|
||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
|
||||
|
||||
if (session) {
|
||||
HttpContext.assign({
|
||||
auth: {
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
HttpContext.assign({
|
||||
auth: {
|
||||
user: session.user,
|
||||
session: session.session,
|
||||
},
|
||||
})
|
||||
|
||||
// Role verification if decorator is present
|
||||
const handler = context.getHandler()
|
||||
const requiredMask = getAllowedRoleMask(handler)
|
||||
if (requiredMask > 0) {
|
||||
if (!session) {
|
||||
throw new BizException(ErrorCode.AUTH_UNAUTHORIZED)
|
||||
}
|
||||
|
||||
const userRoleName = session.user.role as 'user' | 'admin' | undefined
|
||||
const userMask = userRoleName ? roleNameToBit(userRoleName) : 0
|
||||
const hasRole = (requiredMask & userMask) !== 0
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import 'reflect-metadata'
|
||||
import { applyDecorators } from '@afilmory/framework'
|
||||
|
||||
export const ROLES_METADATA = Symbol.for('core.auth.allowed_roles')
|
||||
|
||||
export enum RoleBit {
|
||||
GUEST = 0,
|
||||
USER = 1 << 0,
|
||||
ADMIN = 1 << 1,
|
||||
}
|
||||
|
||||
export type RoleName = 'user' | 'admin'
|
||||
export type RoleName = 'user' | 'admin' | (string & {})
|
||||
|
||||
export function roleNameToBit(name: RoleName): RoleBit {
|
||||
export function roleNameToBit(name?: RoleName): RoleBit {
|
||||
switch (name) {
|
||||
case 'admin': {
|
||||
return RoleBit.ADMIN
|
||||
return RoleBit.ADMIN | RoleBit.USER | RoleBit.GUEST
|
||||
}
|
||||
|
||||
case 'user': {
|
||||
return RoleBit.USER | RoleBit.GUEST
|
||||
}
|
||||
|
||||
default: {
|
||||
return RoleBit.USER
|
||||
return RoleBit.GUEST
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,10 +29,10 @@ export function roleNameToBit(name: RoleName): RoleBit {
|
||||
export function Roles(...roles: Array<RoleBit | RoleName>): MethodDecorator & ClassDecorator {
|
||||
const mask = roles.map((r) => (typeof r === 'string' ? roleNameToBit(r) : r)).reduce((m, r) => m | r, 0)
|
||||
|
||||
return (target: object, propertyKey?: string | symbol, descriptor?: PropertyDescriptor) => {
|
||||
return applyDecorators((target: object, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
|
||||
const targetForMetadata = descriptor?.value && typeof descriptor.value === 'function' ? descriptor.value : target
|
||||
Reflect.defineMetadata(ROLES_METADATA, mask, targetForMetadata)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getAllowedRoleMask(target: object): number {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Body, ContextParam, Controller, Get, Post, UnauthorizedException, UseGuards } from '@afilmory/framework'
|
||||
import { Body, ContextParam, Controller, Get, Post, UnauthorizedException } from '@afilmory/framework'
|
||||
import type { Context } from 'hono'
|
||||
|
||||
import { AuthGuard } from '../../guards/auth.guard'
|
||||
import { RoleBit, Roles } from '../../guards/roles.decorator'
|
||||
import { AuthProvider } from './auth.provider'
|
||||
|
||||
@@ -19,24 +18,6 @@ export class AuthController {
|
||||
return { user: session.user, session: session.session }
|
||||
}
|
||||
|
||||
@Post('/sign-up/email')
|
||||
async signUpEmail(
|
||||
@ContextParam() _context: Context,
|
||||
@Body() body: { name: string; email: string; password: string },
|
||||
) {
|
||||
const auth = this.auth.getAuth()
|
||||
|
||||
const res = await auth.api.signUpEmail({
|
||||
body: {
|
||||
name: body.name,
|
||||
email: body.email,
|
||||
password: body.password,
|
||||
},
|
||||
})
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
@Post('/sign-in/email')
|
||||
async signInEmail(@ContextParam() _context: Context, @Body() body: { email: string; password: string }) {
|
||||
const auth = this.auth.getAuth()
|
||||
@@ -51,7 +32,6 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Get('/admin-only')
|
||||
@UseGuards(AuthGuard)
|
||||
@Roles(RoleBit.ADMIN)
|
||||
async adminOnly(@ContextParam() _context: Context) {
|
||||
return { ok: true }
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RedisAccessor } from 'core/redis/redis.provider'
|
||||
import { DatabaseModule } from '../database/database.module'
|
||||
import { RedisModule } from '../redis/redis.module'
|
||||
import { AuthModule } from './auth/auth.module'
|
||||
import { OnboardingModule } from './onboarding/onboarding.module'
|
||||
import { PhotoModule } from './photo/photo.module'
|
||||
import { SettingModule } from './setting/setting.module'
|
||||
|
||||
@@ -16,6 +17,7 @@ import { SettingModule } from './setting/setting.module'
|
||||
RedisModule,
|
||||
AuthModule,
|
||||
SettingModule,
|
||||
OnboardingModule,
|
||||
PhotoModule,
|
||||
EventModule.forRootAsync({
|
||||
useFactory: async (redis: RedisAccessor) => {
|
||||
|
||||
26
be/apps/core/src/modules/onboarding/onboarding.controller.ts
Normal file
26
be/apps/core/src/modules/onboarding/onboarding.controller.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Body, Controller, Get, Post } from '@afilmory/framework'
|
||||
import { BizException, ErrorCode } from 'core/errors'
|
||||
|
||||
import { OnboardingInitDto } from './onboarding.dto'
|
||||
import { OnboardingService } from './onboarding.service'
|
||||
|
||||
@Controller('onboarding')
|
||||
export class OnboardingController {
|
||||
constructor(private readonly service: OnboardingService) {}
|
||||
|
||||
@Get('/status')
|
||||
async getStatus() {
|
||||
const initialized = await this.service.isInitialized()
|
||||
return { initialized }
|
||||
}
|
||||
|
||||
@Post('/init')
|
||||
async initialize(@Body() dto: OnboardingInitDto) {
|
||||
const initialized = await this.service.isInitialized()
|
||||
if (initialized) {
|
||||
throw new BizException(ErrorCode.COMMON_CONFLICT, { message: 'Already initialized' })
|
||||
}
|
||||
const result = await this.service.initialize(dto)
|
||||
return { ok: true, adminUserId: result.adminUserId }
|
||||
}
|
||||
}
|
||||
39
be/apps/core/src/modules/onboarding/onboarding.dto.ts
Normal file
39
be/apps/core/src/modules/onboarding/onboarding.dto.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createZodDto } from '@afilmory/framework'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { SETTING_SCHEMAS, SettingKeys } from '../setting/setting.constant'
|
||||
|
||||
const adminSchema = z.object({
|
||||
email: z.email(),
|
||||
password: z.string().min(8),
|
||||
name: z.string().min(1),
|
||||
})
|
||||
|
||||
const keySchema = z.enum(SettingKeys)
|
||||
|
||||
const settingEntrySchema = z.object({
|
||||
key: keySchema,
|
||||
value: z.unknown(),
|
||||
})
|
||||
|
||||
const normalizeEntries = z
|
||||
.union([settingEntrySchema, z.object({ entries: z.array(settingEntrySchema).min(1) })])
|
||||
.transform((payload) => {
|
||||
const entries = 'entries' in payload ? payload.entries : [payload]
|
||||
return entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
value: SETTING_SCHEMAS[entry.key].parse(entry.value),
|
||||
}))
|
||||
})
|
||||
|
||||
export class OnboardingInitDto extends createZodDto(
|
||||
z.object({
|
||||
admin: adminSchema,
|
||||
settings: normalizeEntries.optional().transform((entries) => entries ?? []),
|
||||
}),
|
||||
) {}
|
||||
|
||||
export type NormalizedSettingEntry = {
|
||||
key: z.infer<typeof keySchema>
|
||||
value: unknown
|
||||
}
|
||||
14
be/apps/core/src/modules/onboarding/onboarding.module.ts
Normal file
14
be/apps/core/src/modules/onboarding/onboarding.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@afilmory/framework'
|
||||
|
||||
import { DatabaseModule } from '../../database/database.module'
|
||||
import { AuthModule } from '../auth/auth.module'
|
||||
import { SettingModule } from '../setting/setting.module'
|
||||
import { OnboardingController } from './onboarding.controller'
|
||||
import { OnboardingService } from './onboarding.service'
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, AuthModule, SettingModule],
|
||||
providers: [OnboardingService],
|
||||
controllers: [OnboardingController],
|
||||
})
|
||||
export class OnboardingModule {}
|
||||
55
be/apps/core/src/modules/onboarding/onboarding.service.ts
Normal file
55
be/apps/core/src/modules/onboarding/onboarding.service.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { authUsers } from '@afilmory/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { injectable } from 'tsyringe'
|
||||
|
||||
import { DbAccessor } from '../../database/database.provider'
|
||||
import { AuthProvider } from '../auth/auth.provider'
|
||||
import { SettingService } from '../setting/setting.service'
|
||||
import type { NormalizedSettingEntry, OnboardingInitDto } from './onboarding.dto'
|
||||
|
||||
@injectable()
|
||||
export class OnboardingService {
|
||||
constructor(
|
||||
private readonly db: DbAccessor,
|
||||
private readonly auth: AuthProvider,
|
||||
private readonly settings: SettingService,
|
||||
) {}
|
||||
|
||||
async isInitialized(): Promise<boolean> {
|
||||
const db = this.db.get()
|
||||
const [user] = await db.select().from(authUsers).limit(1)
|
||||
return Boolean(user)
|
||||
}
|
||||
|
||||
async initialize(payload: OnboardingInitDto): Promise<{ adminUserId: string }> {
|
||||
const already = await this.isInitialized()
|
||||
if (already) {
|
||||
return { adminUserId: 'already-initialized' }
|
||||
}
|
||||
|
||||
const auth = this.auth.getAuth()
|
||||
|
||||
// Create admin via better-auth email/password
|
||||
const result = await auth.api.signUpEmail({
|
||||
body: {
|
||||
email: payload.admin.email,
|
||||
password: payload.admin.password,
|
||||
name: payload.admin.name,
|
||||
},
|
||||
})
|
||||
|
||||
const userId = result.user.id
|
||||
|
||||
// Promote to admin if not already
|
||||
const db = this.db.get()
|
||||
await db.update(authUsers).set({ role: 'admin' }).where(eq(authUsers.id, userId))
|
||||
|
||||
// Apply initial settings
|
||||
const entries = (payload.settings as unknown as NormalizedSettingEntry[]) ?? []
|
||||
if (entries.length > 0) {
|
||||
await this.settings.setMany(entries as any)
|
||||
}
|
||||
|
||||
return { adminUserId: userId }
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
# ActionGroup 优化方案
|
||||
|
||||
## 当前问题分析
|
||||
|
||||
### 现状
|
||||
- **5 个操作按钮**水平排列:搜索、地图、过滤器、列数、排序
|
||||
- **圆形按钮设计**占据较大空间(每个 40px + 12px gap)
|
||||
- **扩展性受限**:无法继续添加新功能按钮
|
||||
- **桌面端空间浪费**:按钮过于分散
|
||||
|
||||
---
|
||||
|
||||
## 方案 1:分组下拉设计 ⭐️ 推荐
|
||||
|
||||
### 设计思路
|
||||
将功能按使用场景分组,减少顶层按钮数量:
|
||||
|
||||
**分组逻辑:**
|
||||
1. **搜索**(Search)- 独立按钮
|
||||
2. **探索**(Explore)- 独立按钮(地图)
|
||||
3. **筛选**(Filter)- 独立按钮(标签、相机、镜头、评分)
|
||||
4. **视图**(View)- **合并按钮**(排序 + 列数 + 布局模式)
|
||||
|
||||
### 优点
|
||||
- ✅ 按钮数量从 5 个减少到 **4 个**
|
||||
- ✅ 功能分组符合用户心智模型
|
||||
- ✅ 为未来功能预留扩展空间
|
||||
- ✅ 视图设置集中管理,逻辑清晰
|
||||
|
||||
### 实现细节
|
||||
```typescript
|
||||
// View 按钮下拉内容
|
||||
<ViewPanel>
|
||||
<Section title="排序">
|
||||
<SortOptions /> // 最新优先 / 最早优先
|
||||
</Section>
|
||||
<Divider />
|
||||
<Section title="列数">
|
||||
<ColumnsSlider /> // 2-8 列滑块
|
||||
</Section>
|
||||
<Divider />
|
||||
<Section title="布局">
|
||||
<LayoutOptions /> // Masonry / Grid (未来扩展)
|
||||
</Section>
|
||||
</ViewPanel>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 方案 2:紧凑工具栏模式
|
||||
|
||||
### 设计思路
|
||||
使用更紧凑的按钮样式,添加视觉分隔符:
|
||||
|
||||
```
|
||||
[搜索] [地图] | [过滤器] [排序] [列数] | [•••更多]
|
||||
```
|
||||
|
||||
### 优点
|
||||
- ✅ 所有功能一目了然
|
||||
- ✅ 分组视觉清晰(分隔符)
|
||||
- ✅ 更多菜单提供扩展性
|
||||
|
||||
### 缺点
|
||||
- ❌ 按钮仍然较多,空间利用不够高效
|
||||
- ❌ 移动端适配困难
|
||||
|
||||
---
|
||||
|
||||
## 方案 3:双层菜单设计
|
||||
|
||||
### 设计思路
|
||||
主要按钮 + 次要功能折叠:
|
||||
|
||||
**第一层(常驻):**
|
||||
- 搜索、过滤器、更多菜单
|
||||
|
||||
**第二层(更多菜单):**
|
||||
- 地图探索
|
||||
- 视图设置(排序、列数)
|
||||
- 其他设置
|
||||
|
||||
### 优点
|
||||
- ✅ 顶层按钮最少(3 个)
|
||||
- ✅ 扩展性最强
|
||||
|
||||
### 缺点
|
||||
- ❌ 次要功能可发现性降低
|
||||
- ❌ 需要额外点击层级
|
||||
|
||||
---
|
||||
|
||||
## 方案 4:智能响应式设计
|
||||
|
||||
### 设计思路
|
||||
根据屏幕宽度动态调整按钮显示:
|
||||
|
||||
**宽屏(>1280px):**
|
||||
显示全部 5 个按钮
|
||||
|
||||
**中等屏幕(768-1280px):**
|
||||
显示 3 个常用按钮 + 更多菜单
|
||||
|
||||
**窄屏(<768px):**
|
||||
移动端抽屉模式(当前实现)
|
||||
|
||||
### 优点
|
||||
- ✅ 充分利用屏幕空间
|
||||
- ✅ 不同设备最佳体验
|
||||
|
||||
### 缺点
|
||||
- ❌ 实现复杂度高
|
||||
- ❌ 用户体验不一致
|
||||
|
||||
---
|
||||
|
||||
## 推荐方案对比
|
||||
|
||||
| 方案 | 按钮数量 | 扩展性 | 用户体验 | 实现难度 |
|
||||
|------|---------|--------|----------|----------|
|
||||
| **方案 1 分组下拉** | ⭐️⭐️⭐️⭐️ 4个 | ⭐️⭐️⭐️⭐️ | ⭐️⭐️⭐️⭐️⭐️ | ⭐️⭐️⭐️ 中等 |
|
||||
| 方案 2 紧凑工具栏 | ⭐️⭐️ 6+个 | ⭐️⭐️⭐️ | ⭐️⭐️⭐️⭐️ | ⭐️⭐️ 简单 |
|
||||
| 方案 3 双层菜单 | ⭐️⭐️⭐️⭐️⭐️ 3个 | ⭐️⭐️⭐️⭐️⭐️ | ⭐️⭐️⭐️ | ⭐️⭐️⭐️ 中等 |
|
||||
| 方案 4 智能响应式 | ⭐️⭐️⭐️ 动态 | ⭐️⭐️⭐️⭐️ | ⭐️⭐️⭐️⭐️ | ⭐️⭐️⭐️⭐️ 复杂 |
|
||||
|
||||
---
|
||||
|
||||
## 最终建议
|
||||
|
||||
**推荐实现方案 1 + 方案 3 的混合版本:**
|
||||
|
||||
### 桌面端(Desktop)
|
||||
```
|
||||
[搜索] [探索] [筛选 (badge)] [视图] [•••]
|
||||
```
|
||||
|
||||
- **搜索**:全局照片搜索
|
||||
- **探索**:地图视图(导航到 /explory)
|
||||
- **筛选**:标签、相机、镜头、评分过滤(显示激活数量 badge)
|
||||
- **视图**:排序 + 列数 + 布局设置
|
||||
- **更多**:未来功能(导出、设置等)
|
||||
|
||||
### 移动端(Mobile)
|
||||
保持当前抽屉设计,但可以将"视图"合并为统一入口
|
||||
|
||||
---
|
||||
|
||||
## 实现优先级
|
||||
|
||||
1. **Phase 1(当前 PR)**:实现方案 1 - 合并排序和列数为"视图"按钮
|
||||
2. **Phase 2(未来)**:添加"更多"菜单,为新功能预留空间
|
||||
3. **Phase 3(优化)**:根据用户反馈调整分组逻辑
|
||||
|
||||
---
|
||||
|
||||
## 国际化文案建议
|
||||
|
||||
```json
|
||||
// locales/app/en.json
|
||||
{
|
||||
"action.view.title": "View Settings",
|
||||
"action.view.layout": "Layout",
|
||||
"action.view.layout.masonry": "Masonry",
|
||||
"action.view.layout.grid": "Grid",
|
||||
"action.more.title": "More Options",
|
||||
"action.explore.title": "Explore Map"
|
||||
}
|
||||
```
|
||||
@@ -1,247 +0,0 @@
|
||||
# ActionGroup 优化实现总结
|
||||
|
||||
## 📋 实现的改动
|
||||
|
||||
### 1. **合并搜索和过滤功能**
|
||||
|
||||
#### 之前的设计问题:
|
||||
- ❌ 搜索和过滤是两个独立按钮
|
||||
- ❌ Filter 中使用 Tab 切换(标签、相机、镜头、评分)
|
||||
- ❌ 功能分散,操作层级过深
|
||||
- ❌ 按钮数量过多(5个),桌面端空间不足
|
||||
|
||||
#### 优化后的设计:
|
||||
- ✅ **统一搜索面板** (`UnifiedSearchPanel`)
|
||||
- ✅ 使用**预设按钮**替代 Tab 界面
|
||||
- ✅ 顶部快速切换:全部、标签、相机、镜头、评分
|
||||
- ✅ 减少顶层按钮至 **3 个**:搜索/过滤、地图、视图
|
||||
|
||||
---
|
||||
|
||||
## 🎨 新的 UI 结构
|
||||
|
||||
### 按钮布局(从左到右)
|
||||
```
|
||||
[🔍 搜索/过滤] [🗺️ 地图探索] [⚙️ 视图设置]
|
||||
```
|
||||
|
||||
### 统一搜索面板布局
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 搜索和筛选 [清除] │
|
||||
├─────────────────────────────────────┤
|
||||
│ [搜索输入框] │
|
||||
├─────────────────────────────────────┤
|
||||
│ [全部] [标签²] [相机¹] [镜头] [评分] │ ← 预设按钮
|
||||
├─────────────────────────────────────┤
|
||||
│ │
|
||||
│ 【根据预设显示不同内容】 │
|
||||
│ │
|
||||
│ - 全部: 照片搜索结果 │
|
||||
│ - 标签: 标签列表 + 匹配模式 │
|
||||
│ - 相机: 相机列表 │
|
||||
│ - 镜头: 镜头列表 │
|
||||
│ - 评分: 星级评分选择器 │
|
||||
│ │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 新增文件
|
||||
|
||||
### `apps/web/src/components/gallery/UnifiedSearchPanel.tsx`
|
||||
|
||||
**核心组件:**
|
||||
- `UnifiedSearchPanel` - 主面板组件
|
||||
- `FilterList` - 通用过滤列表组件
|
||||
- `StarRating` - 星级评分组件
|
||||
|
||||
**关键特性:**
|
||||
1. **预设系统**:5 种预设(全部、标签、相机、镜头、评分)
|
||||
2. **统一搜索**:全局照片搜索(标题、描述、标签、EXIF)
|
||||
3. **快速过滤**:每个类别独立的项目搜索
|
||||
4. **智能 Badge**:显示激活的过滤器数量
|
||||
5. **一键清除**:重置所有搜索和过滤条件
|
||||
|
||||
---
|
||||
|
||||
## 🔄 修改的文件
|
||||
|
||||
### 1. `apps/web/src/modules/gallery/ActionGroup.tsx`
|
||||
|
||||
**主要改动:**
|
||||
- ✅ 导入 `UnifiedSearchPanel` 替代 `FilterPanel`
|
||||
- ✅ 移除独立的"过滤"按钮
|
||||
- ✅ 移除旧的 `SearchPanel` 组件
|
||||
- ✅ 合并搜索和过滤逻辑
|
||||
- ✅ 更新 badge 计算逻辑
|
||||
|
||||
**Button 减少:**
|
||||
```diff
|
||||
- [搜索] [地图] [过滤] [列数] [排序] // 5个按钮
|
||||
+ [搜索/过滤] [地图] [视图] // 3个按钮
|
||||
```
|
||||
|
||||
### 2. 国际化文件(i18n)
|
||||
|
||||
**新增翻译键:**
|
||||
```json
|
||||
{
|
||||
"action.search.unified.title": "Search & Filter",
|
||||
"action.search.preset.all": "All",
|
||||
"action.search.filter.placeholder": "Filter items...",
|
||||
"action.search.filter.no-results": "No matches found"
|
||||
}
|
||||
```
|
||||
|
||||
**支持的语言:**
|
||||
- ✅ English (`en.json`)
|
||||
- ✅ 简体中文 (`zh-CN.json`)
|
||||
- ✅ 繁体中文-台湾 (`zh-TW.json`)
|
||||
- ✅ 繁体中文-香港 (`zh-HK.json`)
|
||||
- ✅ 日本語 (`jp.json`)
|
||||
- ✅ 한국어 (`ko.json`)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 用户体验提升
|
||||
|
||||
### 操作流程简化
|
||||
|
||||
**之前:**
|
||||
1. 点击"过滤"按钮
|
||||
2. 点击 Tab 切换类别(标签/相机/镜头/评分)
|
||||
3. 搜索或选择项目
|
||||
4. 返回,点击"搜索"按钮
|
||||
5. 输入关键词
|
||||
|
||||
**现在:**
|
||||
1. 点击"搜索/过滤"按钮
|
||||
2. 输入关键词(全局搜索)或点击预设
|
||||
3. 立即查看结果
|
||||
|
||||
### 交互优化
|
||||
|
||||
1. **预设按钮**
|
||||
- 一键切换不同过滤类型
|
||||
- Badge 显示激活数量
|
||||
- 激活状态有明显视觉反馈
|
||||
|
||||
2. **搜索体验**
|
||||
- 全局搜索:匹配标题、描述、标签、EXIF
|
||||
- 类别搜索:在列表中快速筛选
|
||||
- 实时结果展示
|
||||
|
||||
3. **Badge 系统**
|
||||
- 搜索激活:显示 `●`
|
||||
- 过滤激活:显示数字(如 `3`)
|
||||
- 一目了然的状态提示
|
||||
|
||||
---
|
||||
|
||||
## 💡 设计理念
|
||||
|
||||
### 信息架构优化
|
||||
|
||||
**旧架构(平铺):**
|
||||
```
|
||||
搜索 | 地图 | 过滤(Tab:标签/相机/镜头/评分) | 列数 | 排序
|
||||
```
|
||||
|
||||
**新架构(分组):**
|
||||
```
|
||||
搜索/过滤(预设:全部/标签/相机/镜头/评分) | 地图 | 视图(排序+列数)
|
||||
```
|
||||
|
||||
### 原则:
|
||||
1. **功能聚合**:相关功能合并到同一入口
|
||||
2. **预设替代 Tab**:减少交互层级
|
||||
3. **扁平化**:主要操作一步到位
|
||||
4. **扩展性**:预设系统易于添加新类型
|
||||
|
||||
---
|
||||
|
||||
## 🔮 未来扩展方向
|
||||
|
||||
### 可能的新预设
|
||||
- 📅 **日期范围**:按拍摄日期筛选
|
||||
- 📍 **地理位置**:按拍摄地点筛选
|
||||
- 🎨 **颜色**:按主色调筛选
|
||||
- 📸 **参数**:按光圈、快门、ISO 筛选
|
||||
|
||||
### 可能的功能增强
|
||||
- 💾 **保存预设**:自定义常用过滤组合
|
||||
- 🔄 **搜索历史**:快速访问之前的搜索
|
||||
- 🏷️ **智能建议**:根据输入提示相关标签
|
||||
|
||||
---
|
||||
|
||||
## 📊 技术实现亮点
|
||||
|
||||
### 1. 组件复用
|
||||
```typescript
|
||||
<FilterList
|
||||
items={allTags}
|
||||
selectedItems={selectedTags}
|
||||
onToggle={toggleTag}
|
||||
showMatchMode // 仅标签支持
|
||||
/>
|
||||
```
|
||||
|
||||
### 2. 状态管理
|
||||
- 使用 Jotai 全局状态
|
||||
- 预设切换本地状态
|
||||
- 过滤器持久化
|
||||
|
||||
### 3. 性能优化
|
||||
- `useMemo` 缓存搜索结果
|
||||
- `useCallback` 防止重渲染
|
||||
- 虚拟滚动(如需要可添加)
|
||||
|
||||
### 4. 响应式设计
|
||||
- 桌面端:下拉菜单
|
||||
- 移动端:底部抽屉
|
||||
- 自适应宽度和高度
|
||||
|
||||
---
|
||||
|
||||
## ✅ 实现检查清单
|
||||
|
||||
- [x] 创建 `UnifiedSearchPanel` 组件
|
||||
- [x] 更新 `ActionGroup` 使用新面板
|
||||
- [x] 移除旧的 Tab 界面
|
||||
- [x] 添加预设按钮系统
|
||||
- [x] 实现过滤列表组件
|
||||
- [x] 添加国际化支持(6种语言)
|
||||
- [x] 更新 badge 逻辑
|
||||
- [x] 保持响应式设计
|
||||
- [x] 创建设计文档
|
||||
|
||||
---
|
||||
|
||||
## 📝 使用指南
|
||||
|
||||
### 对于用户
|
||||
1. 点击搜索图标打开面板
|
||||
2. 输入关键词或点击预设按钮
|
||||
3. 查看结果或选择过滤项
|
||||
4. Badge 显示激活的过滤器数量
|
||||
|
||||
### 对于开发者
|
||||
1. 新预设添加位置:`UnifiedSearchPanel.tsx` 的 `presets` 数组
|
||||
2. 修改过滤逻辑:更新 `FilterList` 组件
|
||||
3. 添加新的过滤类型:扩展 `SearchPreset` 类型
|
||||
|
||||
---
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
这次优化成功地:
|
||||
- ✅ **简化了 UI**:从 5 个按钮减少到 3 个
|
||||
- ✅ **提升了效率**:减少点击层级
|
||||
- ✅ **增强了扩展性**:预设系统易于添加新功能
|
||||
- ✅ **改善了体验**:搜索和过滤统一入口
|
||||
- ✅ **保持了一致性**:与现有设计风格匹配
|
||||
|
||||
预设按钮系统比 Tab 界面更加直观和高效!🚀
|
||||
Reference in New Issue
Block a user