yourname hai 8 meses
pai
achega
f11c414ec8

+ 144 - 0
USER_PREFERENCE_GUIDE.md

@@ -0,0 +1,144 @@
+# 用户偏好设置功能指南
+
+## 功能概述
+
+本项目新增了移动端字体大小设置功能,允许用户选择四种字体大小:小(0)、中(1)、大(2)、超大(3)。
+
+## 技术实现
+
+### 1. 数据库结构
+
+- **实体**: `UserPreference` 
+- **表名**: `user_preferences`
+- **字段**:
+  - `id`: 主键ID
+  - `user_id`: 关联用户ID(唯一索引)
+  - `font_size`: 字体大小设置(0-3)
+  - `is_dark_mode`: 深色模式开关(0/1)
+  - `created_at`, `updated_at`: 时间戳
+  - `created_by`, `updated_by`: 操作人ID
+
+### 2. API接口
+
+#### 获取用户偏好设置
+```
+GET /api/v1/user-preferences?filters={"userId":1}
+```
+
+#### 创建用户偏好设置
+```
+POST /api/v1/user-preferences
+{
+  "userId": 1,
+  "fontSize": 1,
+  "isDarkMode": 0
+}
+```
+
+#### 更新用户偏好设置
+```
+PUT /api/v1/user-preferences/{id}
+{
+  "fontSize": 2
+}
+```
+
+### 3. 前端使用
+
+#### 字体大小枚举
+```typescript
+enum FontSizeType {
+  SMALL = 0,    // 14px
+  MEDIUM = 1,   // 16px (默认)
+  LARGE = 2,    // 18px
+  EXTRA_LARGE = 3  // 20px
+}
+```
+
+#### 客户端调用示例
+```typescript
+import { userPreferenceClient } from '@/client/api';
+import { FontSizeType } from '@/server/modules/silver-users/user-preference.entity';
+
+// 获取用户偏好设置
+const response = await userPreferenceClient.$get({
+  query: { filters: JSON.stringify({ userId: userId }) }
+});
+
+// 创建偏好设置
+await userPreferenceClient.$post({
+  json: {
+    userId: user.id,
+    fontSize: FontSizeType.LARGE,
+    isDarkMode: 0
+  }
+});
+
+// 更新偏好设置
+await (userPreferenceClient as any)[preferenceId].$put({
+  json: { fontSize: FontSizeType.LARGE }
+});
+```
+
+### 4. 移动端页面集成
+
+在`ProfilePage.tsx`中已经集成了字体大小设置功能:
+
+1. 用户登录后自动加载当前偏好设置
+2. 提供四种字体大小选项的radio选择
+3. 实时保存设置并应用到页面
+4. 通过CSS变量`--mobile-font-size`控制全局字体大小
+
+### 5. 字体大小映射
+
+| 枚举值 | 名称 | 实际大小 | 适用场景 |
+|--------|------|----------|----------|
+| 0 | 小 | 14px | 视力良好用户 |
+| 1 | 中 | 16px | 默认大小 |
+| 2 | 大 | 18px | 一般视力需求 |
+| 3 | 超大 | 20px | 视力不佳用户 |
+
+## 测试步骤
+
+### 1. 数据库初始化
+```bash
+# 运行数据库迁移
+npm run db:migrate
+# 或使用TypeORM同步
+npm run dev
+```
+
+### 2. 功能测试
+1. 登录系统
+2. 进入个人中心页面
+3. 选择"字体大小设置"
+4. 切换不同字体大小
+5. 验证设置是否保存成功
+6. 刷新页面验证设置是否持久
+
+### 3. API测试
+```bash
+# 测试获取偏好设置
+curl -X GET "http://localhost:3000/api/v1/user-preferences?filters=%7B%22userId%22%3A1%7D" \
+  -H "Authorization: Bearer YOUR_TOKEN"
+
+# 测试创建偏好设置
+curl -X POST http://localhost:3000/api/v1/user-preferences \
+  -H "Authorization: Bearer YOUR_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"userId": 1, "fontSize": 2, "isDarkMode": 0}'
+```
+
+## 扩展建议
+
+1. **深色模式**: 已预留`is_dark_mode`字段,可扩展深色模式支持
+2. **更多设置**: 可扩展用户其他偏好设置,如通知开关、布局方式等
+3. **响应式**: 可针对不同设备类型设置不同字体大小
+4. **A/B测试**: 支持针对不同用户群体测试最佳默认设置
+
+## 注意事项
+
+1. 确保用户表中存在对应用户ID
+2. 用户ID在user_preferences表中为唯一索引
+3. 字体大小变更会立即应用到当前页面
+4. 建议为视力障碍用户提供更大的字体选项

+ 3 - 1
src/client/api.ts

@@ -56,6 +56,7 @@ const client = hc<any>('/api/v1', {
 // 老年大学客户端
 export const elderlyUniversityClient = client['elderly-universities']
 export const policyNewsClient = client['policy-news']
+export const userPreferenceClient = client['user-preferences']
 
 // 其他客户端
 export const authClient = client.auth
@@ -72,5 +73,6 @@ export default {
   silverJobs: silverJobsClient,
   silverUsers: silverUsersClient,
   elderlyUniversity: elderlyUniversityClient,
-  policyNews: policyNewsClient
+  policyNews: policyNewsClient,
+  userPreferences: userPreferenceClient
 }

+ 113 - 1
src/client/mobile/pages/ProfilePage.tsx

@@ -1,10 +1,97 @@
-import React from 'react';
+import React, { useState, useEffect } from 'react';
 import { useAuth } from '../hooks/AuthProvider';
 import { useNavigate } from 'react-router-dom';
+import { userPreferenceClient } from '@/client/api';
+import { FontSizeType } from '@/server/modules/silver-users/user-preference.entity';
+import { App } from 'antd';
+
+const fontSizeOptions = [
+  { value: FontSizeType.SMALL, label: '小' },
+  { value: FontSizeType.MEDIUM, label: '中' },
+  { value: FontSizeType.LARGE, label: '大' },
+  { value: FontSizeType.EXTRA_LARGE, label: '超大' }
+];
 
 const ProfilePage: React.FC = () => {
   const { user, logout } = useAuth();
   const navigate = useNavigate();
+  const { message } = App.useApp();
+  const [fontSize, setFontSize] = useState<FontSizeType>(FontSizeType.MEDIUM);
+  const [loading, setLoading] = useState(false);
+  const [preferenceId, setPreferenceId] = useState<number | null>(null);
+
+  useEffect(() => {
+    if (user) {
+      loadUserPreference();
+    }
+  }, [user]);
+
+  const loadUserPreference = async () => {
+    try {
+      const response = await (userPreferenceClient as any).$get({
+        query: { filters: JSON.stringify({ userId: user.id }) }
+      });
+      
+      if (response.status === 200) {
+        const data = await response.json();
+        if (data.data && data.data.length > 0) {
+          const preference = data.data[0];
+          setFontSize(preference.fontSize);
+          setPreferenceId(preference.id);
+        }
+      }
+    } catch (error) {
+      console.error('加载用户偏好设置失败:', error);
+    }
+  };
+
+  const handleFontSizeChange = async (size: FontSizeType) => {
+    if (!user) return;
+    
+    setLoading(true);
+    try {
+      let response;
+      
+      if (preferenceId) {
+        // 更新现有设置
+        response = await (userPreferenceClient as any)[preferenceId].$put({
+          json: { fontSize: size }
+        });
+      } else {
+        // 创建新设置
+        response = await (userPreferenceClient as any).$post({
+          json: {
+            userId: user.id,
+            fontSize: size,
+            isDarkMode: 0
+          }
+        });
+      }
+
+      if (response.status === 200) {
+        const data = await response.json();
+        setFontSize(size);
+        setPreferenceId(data.id);
+        message.success('字体大小设置已更新');
+        updateDocumentFontSize(size);
+      }
+    } catch (error) {
+      console.error('更新字体大小失败:', error);
+      message.error('更新失败,请重试');
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const updateDocumentFontSize = (size: FontSizeType) => {
+    const fontSizes: Record<FontSizeType, string> = {
+      [FontSizeType.SMALL]: '14px',
+      [FontSizeType.MEDIUM]: '16px',
+      [FontSizeType.LARGE]: '18px',
+      [FontSizeType.EXTRA_LARGE]: '20px'
+    };
+    document.documentElement.style.setProperty('--mobile-font-size', fontSizes[size]);
+  };
 
   const handleLogout = () => {
     logout();
@@ -42,6 +129,31 @@ const ProfilePage: React.FC = () => {
         </div>
       </div>
 
+      {/* 字体大小设置 */}
+      <div className="bg-white rounded-lg shadow p-6 mb-4">
+        <h3 className="text-lg font-semibold text-gray-900 mb-4">字体大小设置</h3>
+        <div className="space-y-2">
+          {fontSizeOptions.map((option) => (
+            <label
+              key={option.value}
+              className="flex items-center p-3 border rounded-lg cursor-pointer hover:bg-gray-50"
+            >
+              <input
+                type="radio"
+                name="fontSize"
+                value={option.value}
+                checked={fontSize === option.value}
+                onChange={() => handleFontSizeChange(option.value)}
+                disabled={loading}
+                className="mr-3"
+              />
+              <span className="text-gray-900">{option.label}</span>
+              <span className="ml-2 text-sm text-gray-600">示例文字</span>
+            </label>
+          ))}
+        </div>
+      </div>
+
       {/* 功能菜单 */}
       <div className="bg-white rounded-lg shadow">
         <div className="divide-y divide-gray-200">

+ 3 - 0
src/server/api.ts

@@ -8,6 +8,7 @@ import silverJobsRoutes from './api/silver-jobs/index'
 import silverUsersRoutes from './api/silver-users/index'
 import elderlyUniversityRoutes from './api/elderly-universities/index'
 import policyNewsRoutes from './api/policy-news/index'
+import userPreferenceRoutes from './api/user-preferences/index'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 
@@ -61,6 +62,7 @@ const silverJobsApiRoutes = api.route('/api/v1/silver-jobs', silverJobsRoutes)
 const silverUsersApiRoutes = api.route('/api/v1/silver-users', silverUsersRoutes)
 const elderlyUniversityApiRoutes = api.route('/api/v1/elderly-universities', elderlyUniversityRoutes)
 const policyNewsApiRoutes = api.route('/api/v1/policy-news', policyNewsRoutes)
+const userPreferenceApiRoutes = api.route('/api/v1/user-preferences', userPreferenceRoutes)
 
 export type AuthRoutes = typeof authRoutes
 export type UserRoutes = typeof userRoutes
@@ -70,5 +72,6 @@ export type SilverJobsRoutes = typeof silverJobsApiRoutes
 export type SilverUsersRoutes = typeof silverUsersApiRoutes
 export type ElderlyUniversityRoutes = typeof elderlyUniversityApiRoutes
 export type PolicyNewsRoutes = typeof policyNewsApiRoutes
+export type UserPreferenceRoutes = typeof userPreferenceApiRoutes
 
 export default api

+ 19 - 0
src/server/api/user-preferences/index.ts

@@ -0,0 +1,19 @@
+import { createCrudRoutes } from '@/server/utils/generic-crud.routes';
+import { UserPreference } from '@/server/modules/silver-users/user-preference.entity';
+import { UserPreferenceSchema, CreateUserPreferenceDto, UpdateUserPreferenceDto } from '@/server/modules/silver-users/user-preference.dto';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+const userPreferenceRoutes = createCrudRoutes({
+  entity: UserPreference,
+  createSchema: CreateUserPreferenceDto,
+  updateSchema: UpdateUserPreferenceDto,
+  getSchema: UserPreferenceSchema,
+  listSchema: UserPreferenceSchema,
+  middleware: [authMiddleware],
+  userTracking: {
+    createdByField: 'createdBy',
+    updatedByField: 'updatedBy'
+  }
+});
+
+export default userPreferenceRoutes;

+ 103 - 0
src/server/api/user-preferences/my-preference.ts

@@ -0,0 +1,103 @@
+import { createRoute, OpenAPIHono } from '@hono/zod-openapi';
+import { z } from 'zod';
+import { UserPreferenceSchema } from '@/server/modules/silver-users/user-preference.dto';
+import { ErrorSchema } from '@/server/utils/errorHandler';
+import { AppDataSource } from '@/server/data-source';
+import { UserPreferenceService } from '@/server/modules/silver-users/user-preference.service';
+import { AuthContext } from '@/server/types/context';
+import { authMiddleware } from '@/server/middleware/auth.middleware';
+
+// 获取当前用户偏好设置
+const getMyPreferenceRoute = createRoute({
+  method: 'get',
+  path: '/my-preference',
+  middleware: [authMiddleware],
+  responses: {
+    200: {
+      description: '成功获取当前用户偏好设置',
+      content: { 
+        'application/json': { 
+          schema: z.object({
+            data: UserPreferenceSchema.nullable()
+          })
+        } 
+      }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+// 更新当前用户偏好设置
+const updateMyPreferenceRoute = createRoute({
+  method: 'put',
+  path: '/my-preference',
+  middleware: [authMiddleware],
+  request: {
+    body: {
+      content: {
+        'application/json': {
+          schema: z.object({
+            fontSize: z.coerce.number().int().min(0).max(3).optional(),
+            isDarkMode: z.coerce.number().int().min(0).max(1).optional()
+          })
+        }
+      }
+    }
+  },
+  responses: {
+    200: {
+      description: '成功更新用户偏好设置',
+      content: { 'application/json': { schema: UserPreferenceSchema } }
+    },
+    500: {
+      description: '服务器错误',
+      content: { 'application/json': { schema: ErrorSchema } }
+    }
+  }
+});
+
+const app = new OpenAPIHono<AuthContext>();
+
+// 获取当前用户偏好设置
+app.openapi(getMyPreferenceRoute, async (c) => {
+  try {
+    const user = c.get('user');
+    const service = new UserPreferenceService(AppDataSource);
+    
+    const preference = await service.findByUserId(user.id);
+    
+    return c.json({ 
+      data: preference 
+    }, 200);
+  } catch (error) {
+    console.error('获取用户偏好设置失败:', error);
+    return c.json({ 
+      code: 500, 
+      message: '获取用户偏好设置失败' 
+    }, 500);
+  }
+});
+
+// 更新当前用户偏好设置
+app.openapi(updateMyPreferenceRoute, async (c) => {
+  try {
+    const user = c.get('user');
+    const data = await c.req.json();
+    
+    const service = new UserPreferenceService(AppDataSource);
+    const result = await service.createOrUpdate(user.id, data);
+    
+    return c.json(result, 200);
+  } catch (error) {
+    console.error('更新用户偏好设置失败:', error);
+    return c.json({ 
+      code: 500, 
+      message: '更新用户偏好设置失败' 
+    }, 500);
+  }
+});
+
+export default app;

+ 2 - 1
src/server/data-source.ts

@@ -27,6 +27,7 @@ import { SilverKnowledgeStats } from "./modules/silver-users/silver-knowledge-st
 import { SilverKnowledgeInteraction } from "./modules/silver-users/silver-knowledge-interaction.entity"
 import { ElderlyUniversity } from "./modules/silver-users/elderly-university.entity"
 import { PolicyNews } from "./modules/silver-users/policy-news.entity"
+import { UserPreference } from "./modules/silver-users/user-preference.entity"
 
 export const AppDataSource = new DataSource({
   type: "mysql",
@@ -41,7 +42,7 @@ export const AppDataSource = new DataSource({
     TimeBankIntro, TimeBankCase, TimeBankStats,
     SilverKnowledge, SilverKnowledgeCategory, SilverKnowledgeTag,
     SilverKnowledgeTagRelation, SilverKnowledgeStats, SilverKnowledgeInteraction,
-    ElderlyUniversity, PolicyNews,
+    ElderlyUniversity, PolicyNews, UserPreference,
   ],
   migrations: [],
   synchronize: process.env.DB_SYNCHRONIZE !== "false",

+ 20 - 0
src/server/migrations/2025072002-create-user-preferences-table.sql

@@ -0,0 +1,20 @@
+-- 创建用户偏好设置表
+CREATE TABLE IF NOT EXISTS `user_preferences` (
+  `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
+  `user_id` INT UNSIGNED NOT NULL,
+  `font_size` TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '移动端字体大小 0:小 1:中 2:大 3:超大',
+  `is_dark_mode` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '是否启用深色模式 0:否 1:是',
+  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  `created_by` INT DEFAULT NULL,
+  `updated_by` INT DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `unique_user_id` (`user_id`),
+  KEY `idx_user_id` (`user_id`),
+  KEY `idx_font_size` (`font_size`),
+  CONSTRAINT `fk_user_preferences_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户偏好设置表';
+
+-- 插入测试数据(可选)
+-- INSERT INTO `user_preferences` (`user_id`, `font_size`, `is_dark_mode`, `created_by`, `updated_by`) VALUES
+-- (1, 1, 0, 1, 1);

+ 62 - 0
src/server/modules/silver-users/user-preference.dto.ts

@@ -0,0 +1,62 @@
+import { z } from '@hono/zod-openapi';
+import { FontSizeType } from './user-preference.entity';
+
+// 创建用户偏好设置DTO
+export const CreateUserPreferenceDto = z.object({
+  userId: z.coerce.number().int().positive().openapi({
+    description: '关联用户ID',
+    example: 1
+  }),
+  fontSize: z.coerce.number().int().min(0).max(3).default(FontSizeType.MEDIUM).openapi({
+    description: '移动端字体大小 0:小 1:中 2:大 3:超大',
+    example: 1
+  }),
+  isDarkMode: z.coerce.number().int().min(0).max(1).default(0).openapi({
+    description: '是否启用深色模式 0:否 1:是',
+    example: 0
+  })
+});
+
+// 更新用户偏好设置DTO
+export const UpdateUserPreferenceDto = z.object({
+  fontSize: z.coerce.number().int().min(0).max(3).optional().openapi({
+    description: '移动端字体大小 0:小 1:中 2:大 3:超大',
+    example: 1
+  }),
+  isDarkMode: z.coerce.number().int().min(0).max(1).optional().openapi({
+    description: '是否启用深色模式 0:否 1:是',
+    example: 0
+  })
+});
+
+// 用户偏好设置响应Schema
+export const UserPreferenceSchema = z.object({
+  id: z.number().int().positive().openapi({
+    description: '用户偏好设置ID',
+    example: 1
+  }),
+  userId: z.number().int().positive().openapi({
+    description: '关联用户ID',
+    example: 1
+  }),
+  fontSize: z.number().int().min(0).max(3).openapi({
+    description: '移动端字体大小 0:小 1:中 2:大 3:超大',
+    example: 1
+  }),
+  fontSizeName: z.string().openapi({
+    description: '字体大小名称',
+    example: '中'
+  }),
+  isDarkMode: z.number().int().openapi({
+    description: '是否启用深色模式 0:否 1:是',
+    example: 0
+  }),
+  createdAt: z.string().datetime().openapi({
+    description: '创建时间',
+    example: '2024-01-01T12:00:00Z'
+  }),
+  updatedAt: z.string().datetime().openapi({
+    description: '更新时间',
+    example: '2024-01-01T12:00:00Z'
+  })
+});

+ 48 - 0
src/server/modules/silver-users/user-preference.entity.ts

@@ -0,0 +1,48 @@
+import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+import { z } from '@hono/zod-openapi';
+
+export enum FontSizeType {
+  SMALL = 0,    // 小
+  MEDIUM = 1,   // 中
+  LARGE = 2,    // 大
+  EXTRA_LARGE = 3  // 超大
+}
+
+@Entity('user_preferences')
+export class UserPreference {
+  @PrimaryGeneratedColumn({ unsigned: true })
+  id!: number;
+
+  @Column({ name: 'user_id', type: 'int', unsigned: true, unique: true })
+  userId!: number;
+
+  @Column({ 
+    name: 'font_size', 
+    type: 'tinyint', 
+    unsigned: true, 
+    default: FontSizeType.MEDIUM,
+    comment: '移动端字体大小 0:小 1:中 2:大 3:超大'
+  })
+  fontSize!: FontSizeType;
+
+  @Column({ 
+    name: 'is_dark_mode', 
+    type: 'tinyint', 
+    unsigned: true, 
+    default: 0,
+    comment: '是否启用深色模式 0:否 1:是'
+  })
+  isDarkMode!: number;
+
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt!: Date;
+
+  @Column({ name: 'created_by', type: 'int', nullable: true })
+  createdBy!: number | null;
+
+  @Column({ name: 'updated_by', type: 'int', nullable: true })
+  updatedBy!: number | null;
+}

+ 39 - 0
src/server/modules/silver-users/user-preference.service.ts

@@ -0,0 +1,39 @@
+import { GenericCrudService } from '@/server/utils/generic-crud.service';
+import { DataSource } from 'typeorm';
+import { UserPreference } from './user-preference.entity';
+
+export class UserPreferenceService extends GenericCrudService<UserPreference> {
+  constructor(dataSource: DataSource) {
+    super(dataSource, UserPreference);
+  }
+
+  /**
+   * 根据用户ID获取用户偏好设置
+   */
+  async findByUserId(userId: number): Promise<UserPreference | null> {
+    return this.repository.findOne({ where: { userId } });
+  }
+
+  /**
+   * 根据用户ID创建或更新用户偏好设置
+   */
+  async createOrUpdate(userId: number, data: Partial<UserPreference>): Promise<UserPreference> {
+    const existingPreference = await this.findByUserId(userId);
+    
+    if (existingPreference) {
+      // 更新现有设置
+      Object.assign(existingPreference, data);
+      existingPreference.updatedBy = userId;
+      return this.repository.save(existingPreference);
+    } else {
+      // 创建新设置
+      const newPreference = this.repository.create({
+        ...data,
+        userId,
+        createdBy: userId,
+        updatedBy: userId
+      });
+      return this.repository.save(newPreference);
+    }
+  }
+}

+ 4 - 0
银龄平台移动端及平台端.md

@@ -31,6 +31,10 @@
 ◦场景:积分可兑换商品(本地超市 / 药店合作,文档 2)、服务(如康复理疗)、公益捐赠(纳入 “公益积分池”,文档 2),后期跟主程序打通进行兑换,保留保留接口。
 
 前端小程序 通过注册的方式获取信息,注册信息包括 昵称 手机号码  密码  角色(企业/政府/社区/平台机构、银龄人员)
+个人设置:移动端页面字体可以设置大小,分为:小 中 大 超大
+个人设置:
+个人设置实体:
+字体大小:小 中 大 超大
 
 二# 《PC 后台平台功能设计:全面支撑移动端功能落地》