3
0

3 Коммиты 551061a586 ... 1bb73f5c17

Автор SHA1 Сообщение Дата
  zkn 1bb73f5c17 Merge branch 'server_test' of http://192.168.0.3:3000/SD-SafeAI/shudao-main into server_test 1 месяц назад
  zkn c296e5e3a5 添加点赞/点踩加积分功能 1 месяц назад
  zkn 88ac6a71dd 开发剩余积分 1 месяц назад

+ 4 - 0
.gitignore

@@ -54,3 +54,7 @@ shudao-go-backend/views/index.html
 .npm-cache
 
 shudao-vue-frontend/.playwright-cli/
+
+# Local regression tests not required for runtime deployment
+shudao-vue-frontend/src/views/Chat.feedbackPoints.test.js
+shudao-chat-py/tests/test_like_feedback_points.py

+ 97 - 13
shudao-chat-py/routers/total.py

@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
 from pydantic import BaseModel
 from typing import Optional
 from database import get_db
-from models.total import RecommendQuestion, PolicyFile, FunctionCard, HotQuestion, FeedbackQuestion
+from models.total import RecommendQuestion, PolicyFile, FunctionCard, HotQuestion, FeedbackQuestion, User
 from models.chat import AIMessage
 from models.user_data import UserData
 from services.oss_service import oss_service
@@ -170,25 +170,109 @@ async def submit_feedback(request: SubmitFeedbackRequest, req: Request, db: Sess
 
 
 class LikeDislikeRequest(BaseModel):
-    ai_message_id: int
-    action: str  # "like" 或 "dislike"
+    ai_message_id: Optional[int] = None
+    action: Optional[str] = None
+    id: Optional[int] = None
+    user_feedback: Optional[int] = None
+
+
+FEEDBACK_NONE = 0
+FEEDBACK_LIKE = 2
+FEEDBACK_DISLIKE = 3
+FEEDBACK_REWARD_POINTS = {
+    FEEDBACK_NONE: 0,
+    FEEDBACK_LIKE: 2,
+    FEEDBACK_DISLIKE: 1,
+}
+
+
+def _resolve_like_dislike_payload(data: LikeDislikeRequest):
+    message_id = data.ai_message_id or data.id
+    if not message_id:
+        return None, None, "缺少消息ID"
+
+    if data.user_feedback is not None:
+        feedback = int(data.user_feedback)
+    else:
+        action = (data.action or "").strip().lower()
+        if action in ("like", "2"):
+            feedback = FEEDBACK_LIKE
+        elif action in ("dislike", "3"):
+            feedback = FEEDBACK_DISLIKE
+        elif action in ("", "none", "cancel", "0"):
+            feedback = FEEDBACK_NONE
+        else:
+            return None, None, "反馈类型错误"
+
+    if feedback not in (FEEDBACK_NONE, FEEDBACK_LIKE, FEEDBACK_DISLIKE):
+        return None, None, "反馈类型错误"
+
+    return message_id, feedback, None
+
+
+def _find_current_points_holder(db: Session, user_info):
+    user = db.query(User).filter(
+        User.id == user_info.user_id,
+        User.is_deleted == 0,
+    ).first()
+    if user:
+        return user
+
+    return db.query(UserData).filter(
+        UserData.accountID == user_info.account,
+    ).first()
 
 
 @router.post("/like_and_dislike")
-async def like_and_dislike(request: LikeDislikeRequest, db: Session = Depends(get_db)):
-    """点赞/踩(对齐Go版本)"""
-    message = db.query(AIMessage).filter(
-        AIMessage.id == request.ai_message_id).first()
+async def like_and_dislike(data: LikeDislikeRequest, request: Request, db: Session = Depends(get_db)):
+    """Save AI message feedback and reward points to the current user."""
+    user_info = request.state.user
+    if not user_info:
+        return {"statusCode": 401, "msg": "未认证"}
+
+    message_id, feedback, error = _resolve_like_dislike_payload(data)
+    if error:
+        return {"statusCode": 400, "msg": error}
+
+    message = db.query(AIMessage).filter(AIMessage.id == message_id).first()
     if not message:
         return {"statusCode": 404, "msg": "消息不存在"}
 
-    # 将action转换为user_feedback:like=2(满意/赞), dislike=3(不满意/踩)
-    user_feedback = 2 if request.action == "like" else 3
-    message.user_feedback = user_feedback
-    message.updated_at = int(time.time())
-    db.commit()
+    if getattr(message, "user_id", user_info.user_id) != user_info.user_id:
+        return {"statusCode": 403, "msg": "无权评价该消息"}
 
-    return {"statusCode": 200, "msg": "success"}
+    points_holder = _find_current_points_holder(db, user_info)
+    if not points_holder:
+        return {"statusCode": 404, "msg": "未找到用户数据"}
+
+    previous_feedback = int(message.user_feedback or 0)
+    previous_points = FEEDBACK_REWARD_POINTS.get(previous_feedback, 0)
+    current_points = FEEDBACK_REWARD_POINTS.get(feedback, 0)
+    points_delta = current_points - previous_points
+
+    try:
+        message.user_feedback = feedback
+        message.updated_at = int(time.time())
+
+        new_balance = (points_holder.points or 0) + points_delta
+        points_holder.points = new_balance
+
+        db.commit()
+    except Exception as e:
+        db.rollback()
+        return {"statusCode": 500, "msg": f"反馈提交失败: {str(e)}"}
+
+    return {
+        "statusCode": 200,
+        "msg": "success",
+        "data": {
+            "ai_message_id": message_id,
+            "user_feedback": feedback,
+            "points_added": points_delta,
+            "points_delta": points_delta,
+            "new_balance": new_balance,
+        },
+    }
 
 
 @router.get("/get_user_data_id")

+ 20 - 0
shudao-vue-frontend/src/views/Chat.pointsBalance.test.js

@@ -0,0 +1,20 @@
+import { readFileSync } from 'node:fs'
+import { dirname, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+const __filename = fileURLToPath(import.meta.url)
+const __dirname = dirname(__filename)
+const chatSource = readFileSync(resolve(__dirname, 'Chat.vue'), 'utf8')
+
+describe('Chat points balance card', () => {
+  it('renders the remaining points card from the points balance service', () => {
+    expect(chatSource).toContain("import { getBalance as getPointsBalance } from '@/services/pointsService.js'")
+    expect(chatSource).toContain('class="points-display"')
+    expect(chatSource).toContain('剩余积分')
+    expect(chatSource).toContain('{{ userPointsBalance }}')
+    expect(chatSource).toContain('const userPointsBalance = ref(0)')
+    expect(chatSource).toContain('userPointsBalance.value = await getPointsBalance()')
+    expect(chatSource).toContain('fetchPointsBalance()')
+  })
+})

+ 67 - 12
shudao-vue-frontend/src/views/Chat.vue

@@ -63,9 +63,17 @@
       <!-- 右侧AI问答区域 -->
       <div class="main-chat" :class="{ 'sidebar-open': webSearchSidebarVisible }">
       <!-- 聊天头部 -->
-      <div class="chat-header" v-if="currentMode !== 'exam-workshop' && currentQuestion">
-        <div class="question-title-card">
-          <h2>{{ currentQuestion }}</h2>
+      <div class="chat-header" v-if="currentMode !== 'exam-workshop'">
+        <div class="header-left">
+          <div class="question-title-card" v-if="currentQuestion">
+            <h2>{{ currentQuestion }}</h2>
+          </div>
+        </div>
+        <div class="header-right">
+          <div class="points-display">
+            <span class="points-label">剩余积分</span>
+            <span class="points-value">{{ userPointsBalance }}</span>
+          </div>
         </div>
       </div>
 
@@ -361,7 +369,6 @@
                         class="action-btn thumbs-up-btn" 
                         :class="{ active: message.userFeedback === 'like' }"
                         @click="handleThumbsUp(message)"
-                        :title="message.userFeedback === 'like' ? '取消点赞' : '点赞'"
                       >
                         <img :src="likeIcon" alt="点赞" class="action-icon">
                       </button>
@@ -369,7 +376,6 @@
                         class="action-btn thumbs-down-btn"
                         :class="{ active: message.userFeedback === 'dislike' }"
                         @click="handleThumbsDown(message)"
-                        :title="message.userFeedback === 'dislike' ? '取消点踩' : '点踩'"
                       >
                         <img :src="dislikeIcon" alt="踩" class="action-icon">
                       </button>
@@ -772,6 +778,7 @@ import StatusAvatar from '@/components/StatusAvatar.vue'
 import { createSSEConnection, closeSSEConnection } from '@/utils/sse'
 import { getApiPrefix } from '@/utils/apiConfig'
 import { synthesizeSpeechToObjectUrl } from '@/services/speechService'
+import { getBalance as getPointsBalance } from '@/services/pointsService.js'
 import { Document } from '@element-plus/icons-vue'
 
 // 导入发送按钮图标
@@ -1072,6 +1079,7 @@ const expandedOnlineSearchResults = ref({}) // 记录每个消息的联网搜索
 
 // 网络搜索结果数据(新增)
 const webSearchSidebarVisible = ref(false) // 网络搜索侧边栏显示状态
+const userPointsBalance = ref(0)
 
 // AI写作侧边栏状态
 const aiWritingSidebarVisible = ref(false)
@@ -1089,6 +1097,15 @@ const aiWritingSidebarWidth = ref(AI_WRITING_SIDEBAR_SIZE.default)
 const aiWritingSidebarResizing = ref(false)
 let aiWritingSidebarResizeFrame = null
 
+const fetchPointsBalance = async () => {
+  try {
+    userPointsBalance.value = await getPointsBalance()
+  } catch (error) {
+    console.error('获取积分余额失败:', error)
+    userPointsBalance.value = 0
+  }
+}
+
 const aiWritingToolbarConfig = {
   excludeKeys: [
     'group-video',
@@ -4971,14 +4988,10 @@ const syncFeedbackToBackend = async (message) => {
     
     if (response.statusCode === 200) {
       console.log('反馈同步成功')
-      // 根据反馈类型显示不同提示
-      if (feedback === 2) {
-        ElMessage.success('点赞成功')
-      } else if (feedback === 3) {
-        ElMessage.success('点踩成功')
-      } else {
-        ElMessage.success('已取消反馈')
+      if (typeof response.data?.new_balance === 'number') {
+        userPointsBalance.value = response.data.new_balance
       }
+      ElMessage.success(feedback === 0 ? '已取消反馈' : '反馈成功')
     } else {
       console.error('反馈同步失败:', response.msg)
       ElMessage.error('反馈提交失败,请稍后重试')
@@ -5586,6 +5599,8 @@ onMounted(async () => {
     // 1. 首先获取历史记录列表(最高优先级)
     await getHistoryRecordList()
     console.log('✅ AI问答历史记录加载完成')
+
+    await fetchPointsBalance()
     
     // 2. 测试TTS服务连接
     try {
@@ -5976,7 +5991,25 @@ onActivated(async () => {
 /* 聊天头部 */
 .chat-header {
   background: transparent;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-shrink: 0;
+  min-height: 42px;
+  box-sizing: border-box;
   padding: 20px 0px 0px 18px; /* 从30px改为20px,向上提升10px */
+
+  .header-left {
+    flex: 1;
+    min-width: 0;
+  }
+
+  .header-right {
+    display: flex;
+    align-items: center;
+    flex-shrink: 0;
+    padding-right: 24px;
+  }
   
   .default-title {
     margin: 0;
@@ -6019,6 +6052,28 @@ onActivated(async () => {
       box-shadow: 0 4px 12px rgba(91, 141, 239, 0.15);
     }
   }
+
+  .points-display {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 8px 16px;
+    background: rgba(255, 255, 255, 0.9);
+    border-radius: 20px;
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
+    white-space: nowrap;
+
+    .points-label {
+      font-size: 14px;
+      color: #6b7280;
+    }
+
+    .points-value {
+      font-size: 16px;
+      font-weight: 600;
+      color: #3b82f6;
+    }
+  }
 }
 
 /* 聊天内容区域 */

+ 1 - 10
shudao-vue-frontend/src/views/mobile/m-Chat.vue

@@ -253,7 +253,6 @@
                       class="action-btn thumbs-up-btn" 
                       :class="{ active: message.userFeedback === 'like' }"
                       @click="handleThumbsUp(message)"
-                      :title="message.userFeedback === 'like' ? '取消点赞' : '点赞'"
                     >
                       <img :src="likeIcon" alt="点赞" class="action-icon">
                     </button>
@@ -261,7 +260,6 @@
                       class="action-btn thumbs-down-btn"
                       :class="{ active: message.userFeedback === 'dislike' }"
                       @click="handleThumbsDown(message)"
-                      :title="message.userFeedback === 'dislike' ? '取消点踩' : '点踩'"
                     >
                       <img :src="dislikeIcon" alt="踩" class="action-icon">
                     </button>
@@ -4268,14 +4266,7 @@ const syncFeedbackToBackend = async (message) => {
     
     if (response.statusCode === 200) {
     console.log('反馈同步成功')
-      // 根据反馈类型显示不同提示
-      if (feedback === 2) {
-        showToastMessage('点赞成功')
-      } else if (feedback === 3) {
-        showToastMessage('点踩成功')
-      } else {
-        showToastMessage('已取消反馈')
-      }
+      showToastMessage(feedback === 0 ? '已取消反馈' : '反馈成功')
     } else {
       console.error('反馈同步失败:', response.msg)
       showToastMessage('反馈提交失败,请稍后重试', 'error')