Selaa lähdekoodia

增加积分机制,跟租户绑定,图片100、视频500

liyanbo 4 viikkoa sitten
vanhempi
sitoutus
511a03c8bf

+ 2 - 2
.env

@@ -4,8 +4,8 @@ VITE_APP_TITLE=人工智能通识课平台
 # 请求路径
 # VITE_BASE_URL='http://59.110.91.129:8088/admin-api'
 # VITE_BASE_URL='http://8.140.223.94:8088/admin-api'
- VITE_BASE_URL='https://learn-ai.com.cn/admin-api'
-#VITE_BASE_URL='http://localhost:8080/admin-api'
+# VITE_BASE_URL='https://learn-ai.com.cn/admin-api'
+VITE_BASE_URL='http://localhost:8080/admin-api'
 
 # 登录是否强制租户
 VITE_APP_COMPULSORY_TENANT=false

+ 6 - 0
src/api/aiPoint.js

@@ -0,0 +1,6 @@
+import axios from '@/utils/request'
+
+/** 当前登录用户所属租户的共享 AI 积分余额。 */
+export function getMyAiPointBalance () {
+  return axios({ url: 'ai/point/my-balance', method: 'get' })
+}

+ 1 - 0
src/api/questions.js

@@ -1,4 +1,5 @@
 import axios from "@/utils/request";
+export { getMyAiPointBalance } from './aiPoint'
 
 import { fetchEventSource } from '@microsoft/fetch-event-source'
 

+ 24 - 4
src/components/ai/image/ImageToImage.vue

@@ -126,8 +126,8 @@
           >
             <img :src="stopicon" alt="停止" />
           </div>
-          <button v-if="!conversationInProgress"
-            @click="sendMessage">发送</button>
+          <button v-if="!conversationInProgress" class="generate-btn" data-point-cost="需消耗 ✦100积分"
+            aria-label="生成图片,本次需消耗100积分" @click="sendMessage">生成</button>
         </div>
       </div>
     </div>
@@ -136,7 +136,7 @@
 
 <script setup>
 import {ref, onMounted, onUnmounted, defineEmits, computed} from 'vue'
-import {AiImageStatusEnum, CreatePainting, PaintingGetMys} from '@/api/questions.js'
+import {AiImageStatusEnum, CreatePainting, PaintingGetMys, getMyAiPointBalance} from '@/api/questions.js'
 import VoiceInput from '../voice/VoiceInput_Api.vue'
 
 // 导入全局状态
@@ -301,8 +301,20 @@ const stopStream = async () => {
 };
 
 // 发送消息函数,图片数据
+const ensureAiPoint = async (cost) => {
+  try {
+    const res = await getMyAiPointBalance()
+    if (res.code === 0 && Number(res.data?.balance ?? 0) >= cost) return true
+    Message().error(`当前租户 AI 积分不足,生成图片需要 ${cost} 积分`)
+  } catch (error) {
+    Message().error('暂时无法查询 AI 积分,请稍后重试')
+  }
+  return false
+}
+
 const sendMessage = async() => {
   if (uploadedImage.value) {
+    if (!await ensureAiPoint(100)) return
     // 如果正在录音,先停止录音
     if (isVoiceRecording.value) {
       const voiceInputComponent = document.querySelector('.voice-input-container .speech-btn');
@@ -760,6 +772,14 @@ const download = (event, activeIndex) => {
   white-space: nowrap;
 }
 
+.input-section .generate-btn {
+  position: relative;
+  &::after { position: absolute; z-index: 5; bottom: calc(100% + rpx(5)); left: 50%; transform: translateX(-50%) translateY(rpx(2)); content: attr(data-point-cost); padding: rpx(4) rpx(6); border-radius: rpx(4); background: rgba(49, 37, 12, 0.92); color: #fff7d8; font-size: rpx(5.5); font-weight: 400; line-height: 1; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 0.16s ease, transform 0.16s ease; }
+  &::before { position: absolute; z-index: 5; bottom: calc(100% + rpx(1)); left: 50%; transform: translateX(-50%); content: ''; border: rpx(3) solid transparent; border-top-color: rgba(49, 37, 12, 0.92); opacity: 0; transition: opacity 0.16s ease; }
+  &:hover::after, &:focus-visible::after { opacity: 1; transform: translateX(-50%) translateY(0); }
+  &:hover::before, &:focus-visible::before { opacity: 1; }
+}
+
 .image-upload-section {
   padding: rpx(10);
   display: flex;
@@ -781,4 +801,4 @@ const download = (event, activeIndex) => {
      border: 1px solid #ffce1b;
     border-radius: rpx(5);
   }
-</style>
+</style>

+ 57 - 4
src/components/ai/image/TextToImage.vue

@@ -128,8 +128,8 @@
           >
             <img :src="stopicon" alt="停止" />
           </div>
-          <button v-if="!conversationInProgress"
-            @click="sendMessage">发送</button>
+          <button v-if="!conversationInProgress" class="generate-btn" data-point-cost="需消耗 ✦100积分"
+            aria-label="生成图片,本次需消耗100积分" @click="sendMessage">生成</button>
         </div>
       </div>
     </div>
@@ -138,7 +138,8 @@
 
 <script setup>
 import {ref, onMounted, onUnmounted, defineEmits, computed} from 'vue'
-import {AiImageStatusEnum, CreatePainting, PaintingGetMys} from '@/api/questions.js'
+import {AiImageStatusEnum, CreatePainting, PaintingGetMys, getMyAiPointBalance} from '@/api/questions.js'
+import {Message} from '@/utils/message/Message.js'
 import demo1 from '@/assets/images/ai-demo/ai-image-demo1.png'
 import demo2 from '@/assets/images/ai-demo/ai-image-demo2.png'
 import demo3 from '@/assets/images/ai-demo/ai-image-demo3.png'
@@ -280,8 +281,20 @@ const stopStream = async () => {
 };
 
 // 发送消息函数
+const ensureAiPoint = async (cost) => {
+  try {
+    const res = await getMyAiPointBalance()
+    if (res.code === 0 && Number(res.data?.balance ?? 0) >= cost) return true
+    Message().error(`当前租户 AI 积分不足,生成图片需要 ${cost} 积分`)
+  } catch (error) {
+    Message().error('暂时无法查询 AI 积分,请稍后重试')
+  }
+  return false
+}
+
 const sendMessage = async() => {
   if (inputMessage.value.trim()) {
+    if (!await ensureAiPoint(100)) return
     // 创建 AbortController 实例,以便中止请求
     conversationInAbortController.value = new AbortController();
     // 标记对话进行中
@@ -638,4 +651,44 @@ const download = (activeIndex) => {
     box-shadow: 0 0px 2px rgba(0, 0, 0, 0.3);
 
 }
-</style>
+
+.input-section .generate-btn {
+  position: relative;
+
+  &::after {
+    position: absolute;
+    z-index: 5;
+    bottom: calc(100% + rpx(5));
+    left: 50%;
+    transform: translateX(-50%) translateY(rpx(2));
+    content: attr(data-point-cost);
+    padding: rpx(4) rpx(6);
+    border-radius: rpx(4);
+    background: rgba(49, 37, 12, 0.92);
+    color: #fff7d8;
+    font-size: rpx(5.5);
+    font-weight: 400;
+    line-height: 1;
+    white-space: nowrap;
+    opacity: 0;
+    pointer-events: none;
+    transition: opacity 0.16s ease, transform 0.16s ease;
+  }
+
+  &::before {
+    position: absolute;
+    z-index: 5;
+    bottom: calc(100% + rpx(1));
+    left: 50%;
+    transform: translateX(-50%);
+    content: '';
+    border: rpx(3) solid transparent;
+    border-top-color: rgba(49, 37, 12, 0.92);
+    opacity: 0;
+    transition: opacity 0.16s ease;
+  }
+
+  &:hover::after, &:focus-visible::after { opacity: 1; transform: translateX(-50%) translateY(0); }
+  &:hover::before, &:focus-visible::before { opacity: 1; }
+}
+</style>

+ 24 - 4
src/components/ai/video/ImageToVideo.vue

@@ -91,8 +91,8 @@
             >
               <img :src="stopicon" alt="停止" />
             </div>
-            <button v-if="!conversationInProgress"
-              @click="sendMessage">发送</button>
+            <button v-if="!conversationInProgress" class="generate-btn" data-point-cost="需消耗 ✦500积分"
+              aria-label="生成视频,本次需消耗500积分" @click="sendMessage">生成</button>
           </div>
         </div>
       </div>
@@ -101,7 +101,7 @@
 
 <script setup>
 import {ref, onMounted, onUnmounted, defineEmits, computed} from 'vue'
-import {AiImageStatusEnum, CreateVideo, VideoGetMys} from '@/api/questions.js'
+import {AiImageStatusEnum, CreateVideo, VideoGetMys, getMyAiPointBalance} from '@/api/questions.js'
 import { useRouter } from 'vue-router'
 
 import VoiceInput from '../voice/VoiceInput_Api.vue'
@@ -246,8 +246,20 @@ const stopStream = async () => {
 };
 
 // 发送消息函数
+const ensureAiPoint = async (cost) => {
+  try {
+    const res = await getMyAiPointBalance()
+    if (res.code === 0 && Number(res.data?.balance ?? 0) >= cost) return true
+    Message().error(`当前租户 AI 积分不足,生成视频需要 ${cost} 积分`)
+  } catch (error) {
+    Message().error('暂时无法查询 AI 积分,请稍后重试')
+  }
+  return false
+}
+
 const sendMessage = async() => {
   if (uploadedImage.value) {
+    if (!await ensureAiPoint(500)) return
     // 如果正在录音,先停止录音
     if (isVoiceRecording.value) {
       const voiceInputComponent = document.querySelector('.voice-input-container .speech-btn');
@@ -639,10 +651,18 @@ const inProgressTimerFun = () => {
   white-space: nowrap;
 }
 
+.input-section .generate-btn {
+  position: relative;
+  &::after { position: absolute; z-index: 5; bottom: calc(100% + rpx(5)); left: 50%; transform: translateX(-50%) translateY(rpx(2)); content: attr(data-point-cost); padding: rpx(4) rpx(6); border-radius: rpx(4); background: rgba(49, 37, 12, 0.92); color: #fff7d8; font-size: rpx(5.5); font-weight: 400; line-height: 1; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 0.16s ease, transform 0.16s ease; }
+  &::before { position: absolute; z-index: 5; bottom: calc(100% + rpx(1)); left: 50%; transform: translateX(-50%); content: ''; border: rpx(3) solid transparent; border-top-color: rgba(49, 37, 12, 0.92); opacity: 0; transition: opacity 0.16s ease; }
+  &:hover::after, &:focus-visible::after { opacity: 1; transform: translateX(-50%) translateY(0); }
+  &:hover::before, &:focus-visible::before { opacity: 1; }
+}
+
 .image-upload-section {
   padding: rpx(10);
   display: flex;
   justify-content: center;
   align-items: center;
 }
-</style>
+</style>

+ 21 - 0
src/components/user/UserInfoPopover.vue

@@ -46,6 +46,11 @@
           </div>
         </div>
 
+        <div class="ai-point-section">
+          <span>租户AI积分剩余:</span>
+          <strong> ✦ {{ userInfo.tenantAiPointBalance ?? 0 }}</strong>
+        </div>
+
         <!-- 激活码 -->
         <div class="bottom-box">
            <el-input
@@ -94,6 +99,7 @@ const userInfo = ref({
   blocklyExpireTime: null,
   aiCourseExpireTime: null,
   aiPoetryExpireTime: null,
+  tenantAiPointBalance: 0,
 })
 
 // 下拉框可见性状态
@@ -102,6 +108,7 @@ const dropdownVisible = ref(false)
 // 处理下拉框显示/隐藏事件
 const handleVisibleChange = (visible) => {
   dropdownVisible.value = visible
+  if (visible) fetchUserInfo()
 }
 
 // 获取用户信息
@@ -227,6 +234,20 @@ onMounted(() => {
   position: relative;
 }
 
+.ai-point-section {
+  margin: rpx(7) rpx(8) rpx(4);
+  padding: rpx(7) rpx(6) rpx(2);
+  border-top: 1px solid #eeeeee;
+  color: #555;
+  font-size: rpx(7);
+
+  strong {
+    margin-left: rpx(2);
+    color: #d98a00;
+    font-size: rpx(9);
+  }
+}
+
 .user-logout-btn {
   position: absolute;
   top: 0;