Explorar o código

feat: AI 生成改为独立页面

- 新增 /pages/ai/index 页面
- 支持长文本展示和滚动
- 添加历史记录功能
- 生成后可直接使用文本
MyFramework User hai 5 meses
pai
achega
f5897d3f0a
Modificáronse 3 ficheiros con 364 adicións e 176 borrados
  1. 7 0
      client/src/pages.json
  2. 342 0
      client/src/pages/ai/index.vue
  3. 15 176
      client/src/pages/history/index.vue

+ 7 - 0
client/src/pages.json

@@ -41,6 +41,13 @@
         "navigationBarTitleText": "登录",
         "navigationStyle": "custom"
       }
+    },
+    {
+      "path": "pages/ai/index",
+      "style": {
+        "navigationBarTitleText": "AI 生成",
+        "navigationStyle": "custom"
+      }
     }
   ],
   "globalStyle": {

+ 342 - 0
client/src/pages/ai/index.vue

@@ -0,0 +1,342 @@
+<template>
+  <view class="page">
+    <!-- 顶部导航栏 -->
+    <view class="nav-bar">
+      <view class="nav-content">
+        <view class="nav-left" @click="goBack">
+          <text class="back-icon">←</text>
+        </view>
+        <text class="page-title">AI 智能生成</text>
+        <view class="nav-right">
+          <text></text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 主内容区 -->
+    <view class="main-content">
+      <!-- 输入提示词 -->
+      <view class="card input-card">
+        <view class="card-header">
+          <text class="card-title">🤖 输入问题</text>
+        </view>
+        <textarea
+          v-model="prompt"
+          class="prompt-input"
+          placeholder="输入你想了解的内容,例如:计算机原理是什么?什么是人工智能?"
+          :maxlength="500"
+        />
+        <view class="prompt-hint">
+          <text>AI 将根据你的输入生成相关文本内容</text>
+        </view>
+      </view>
+
+      <!-- 生成按钮 -->
+      <button
+        class="generate-btn"
+        :disabled="!prompt.trim() || generating"
+        @click="handleGenerate"
+      >
+        <text class="btn-text">{{ generating ? '生成中...' : '生成文本' }}</text>
+      </button>
+
+      <!-- 生成结果 -->
+      <view v-if="generatedText" class="card result-card">
+        <view class="card-header">
+          <text class="card-title">📝 生成的文本</text>
+          <view class="result-actions">
+            <text class="action-text" @click="copyText">复制</text>
+            <text class="action-text use-text" @click="useText">使用此文本</text>
+          </view>
+        </view>
+        <view class="result-content">
+          <text class="result-text">{{ generatedText }}</text>
+        </view>
+        <view class="result-footer">
+          <text class="word-count">{{ generatedText.length }} 字</text>
+        </view>
+      </view>
+
+      <!-- 历史记录 -->
+      <view v-if="historyList.length > 0" class="card history-card">
+        <view class="card-header">
+          <text class="card-title">📜 历史记录</text>
+        </view>
+        <view class="history-list">
+          <view
+            v-for="(item, index) in historyList"
+            :key="index"
+            class="history-item"
+            @click="useHistoryItem(item)"
+          >
+            <text class="history-prompt">{{ item.prompt }}</text>
+            <text class="history-text">{{ item.text.slice(0, 50) }}...</text>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { post } from '../../utils/request';
+
+const prompt = ref('');
+const generating = ref(false);
+const generatedText = ref('');
+const historyList = ref<{ prompt: string; text: string }[]>([]);
+
+// 生成文本
+async function handleGenerate() {
+  if (!prompt.value.trim() || generating.value) return;
+
+  generating.value = true;
+  try {
+    const result = await post<{ text: string }>('/ai/generate', {
+      prompt: prompt.value,
+    });
+
+    generatedText.value = result.text;
+    
+    // 添加到历史记录
+    historyList.value.unshift({
+      prompt: prompt.value,
+      text: result.text,
+    });
+    
+    // 保留最多 10 条历史
+    if (historyList.value.length > 10) {
+      historyList.value.pop();
+    }
+    
+    uni.showToast({ title: '生成成功', icon: 'success' });
+  } catch (error: any) {
+    console.error('AI 生成失败:', error);
+    uni.showToast({ title: error.message || '生成失败', icon: 'none' });
+  } finally {
+    generating.value = false;
+  }
+}
+
+// 复制文本
+function copyText() {
+  uni.setClipboardData({
+    data: generatedText.value,
+    success: () => {
+      uni.showToast({ title: '已复制', icon: 'success' });
+    },
+  });
+}
+
+// 使用文本 - 跳转到生成音频页面
+function useText() {
+  // 将生成的文本存入本地存储,供下一个页面使用
+  uni.setStorageSync('ai_generated_text', generatedText.value);
+  uni.navigateBack();
+}
+
+// 使用历史记录
+function useHistoryItem(item: { prompt: string; text: string }) {
+  prompt.value = item.prompt;
+  generatedText.value = item.text;
+}
+
+// 返回上一页
+function goBack() {
+  // 如果有生成的文本,带回去
+  if (generatedText.value) {
+    uni.setStorageSync('ai_generated_text', generatedText.value);
+  }
+  uni.navigateBack();
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: #f5f5f5;
+}
+
+.nav-bar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  height: 88rpx;
+  background: #ffffff;
+  z-index: 100;
+  padding-top: 44rpx;
+  box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
+}
+
+.nav-content {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 32rpx;
+  height: 88rpx;
+}
+
+.nav-left,
+.nav-right {
+  width: 80rpx;
+}
+
+.back-icon {
+  font-size: 40rpx;
+  color: #1f2937;
+}
+
+.page-title {
+  font-size: 34rpx;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+.main-content {
+  padding: 132rpx 32rpx 180rpx;
+}
+
+.card {
+  background: #ffffff;
+  border-radius: 24rpx;
+  padding: 32rpx;
+  margin-bottom: 24rpx;
+  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
+}
+
+.card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 24rpx;
+}
+
+.card-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #1f2937;
+}
+
+.prompt-input {
+  width: 100%;
+  min-height: 200rpx;
+  padding: 24rpx;
+  background: #f9fafb;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  color: #1f2937;
+  line-height: 1.6;
+  box-sizing: border-box;
+}
+
+.prompt-hint {
+  margin-top: 16rpx;
+}
+
+.prompt-hint text {
+  font-size: 22rpx;
+  color: #9ca3af;
+}
+
+.generate-btn {
+  width: 100%;
+  height: 96rpx;
+  background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
+  border-radius: 24rpx;
+  border: none;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 24rpx;
+}
+
+.generate-btn::after {
+  border: none;
+}
+
+.generate-btn[disabled] {
+  background: #e5e7eb;
+}
+
+.btn-text {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #ffffff;
+}
+
+.result-actions {
+  display: flex;
+  gap: 24rpx;
+}
+
+.action-text {
+  font-size: 24rpx;
+  color: #6b7280;
+  padding: 8rpx 16rpx;
+  background: #f3f4f6;
+  border-radius: 8rpx;
+}
+
+.use-text {
+  color: #4f46e5;
+  background: #eef2ff;
+}
+
+.result-content {
+  max-height: 600rpx;
+  overflow-y: auto;
+  padding: 24rpx;
+  background: #f9fafb;
+  border-radius: 16rpx;
+  margin-bottom: 16rpx;
+}
+
+.result-text {
+  font-size: 28rpx;
+  color: #1f2937;
+  line-height: 1.8;
+  white-space: pre-wrap;
+  word-break: break-all;
+}
+
+.result-footer {
+  display: flex;
+  justify-content: flex-end;
+}
+
+.word-count {
+  font-size: 22rpx;
+  color: #9ca3af;
+}
+
+.history-list {
+  display: flex;
+  flex-direction: column;
+  gap: 16rpx;
+}
+
+.history-item {
+  padding: 20rpx;
+  background: #f9fafb;
+  border-radius: 12rpx;
+}
+
+.history-prompt {
+  font-size: 26rpx;
+  color: #4f46e5;
+  font-weight: 500;
+  display: block;
+  margin-bottom: 8rpx;
+}
+
+.history-text {
+  font-size: 22rpx;
+  color: #6b7280;
+  display: block;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

+ 15 - 176
client/src/pages/history/index.vue

@@ -23,7 +23,7 @@
           auto-height
         />
         <view class="input-actions">
-          <button class="action-btn ai-btn" @click="showAIModal">
+          <button class="action-btn ai-btn" @click="goToAIPage">
             <text>🤖 AI 生成</text>
           </button>
           <button class="action-btn" @click="clearText">清空</button>
@@ -104,45 +104,14 @@
         <text class="btn-text">{{ generating ? '生成中...' : '生成音频' }}</text>
       </button>
     </view>
-
-    <!-- AI 生成弹窗 -->
-    <view v-if="showAiModal" class="ai-modal-mask" @click="closeAiModal">
-      <view class="ai-modal" @click.stop>
-        <view class="ai-modal-header">
-          <text class="ai-modal-title">🤖 AI 智能生成</text>
-          <text class="ai-modal-close" @click="closeAiModal">×</text>
-        </view>
-        <view class="ai-modal-content">
-          <textarea
-            v-model="aiPrompt"
-            class="ai-prompt-input"
-            placeholder="输入你想了解的内容,例如:计算机原理是什么?什么是人工智能?"
-            :maxlength="500"
-          />
-          <view class="ai-prompt-hint">
-            <text>AI 将根据你的输入生成相关文本,可直接用于生成音频</text>
-          </view>
-        </view>
-        <view class="ai-modal-footer">
-          <button class="ai-cancel-btn" @click="closeAiModal">取消</button>
-          <button
-            class="ai-confirm-btn"
-            :disabled="!aiPrompt.trim() || aiGenerating"
-            @click="handleAIGenerate"
-          >
-            <text>{{ aiGenerating ? '生成中...' : '生成文本' }}</text>
-          </button>
-        </view>
-      </view>
-    </view>
   </view>
 </template>
 
 <script setup lang="ts">
 import { ref, computed, onMounted } from 'vue';
+import { onShow } from '@dcloudio/uni-app';
 import { useUserStore } from '../../store/user';
 import { useAudioStore } from '../../store/audio';
-import { post } from '../../utils/request';
 import type { VoiceParams } from '../../types';
 
 const userStore = useUserStore();
@@ -157,9 +126,6 @@ const voiceParams = ref<VoiceParams>({
   volume: 50,
 });
 const generating = ref(false);
-const aiGenerating = ref(false);
-const aiPrompt = ref('');
-const showAiModal = ref(false);
 
 // 计算属性
 const canGenerate = computed(() => {
@@ -171,6 +137,16 @@ onMounted(async () => {
   await audioStore.fetchVoices();
 });
 
+// 页面显示时检查是否有 AI 生成的文本
+onShow(() => {
+  const aiText = uni.getStorageSync('ai_generated_text');
+  if (aiText) {
+    text.value = aiText;
+    uni.removeStorageSync('ai_generated_text');
+    uni.showToast({ title: '已填充 AI 生成的文本', icon: 'success' });
+  }
+});
+
 // 清空文本
 function clearText() {
   text.value = '';
@@ -184,44 +160,9 @@ async function pasteText() {
   }
 }
 
-// AI 生成文本
-async function handleAIGenerate() {
-  if (!aiPrompt.value.trim()) {
-    uni.showToast({ title: '请输入想了解的内容', icon: 'none' });
-    return;
-  }
-
-  aiGenerating.value = true;
-  try {
-    const result = await post<{ text: string }>('/ai/generate', {
-      prompt: aiPrompt.value,
-    });
-
-    // 将生成的文本填充到输入框
-    text.value = result.text;
-    aiPrompt.value = '';
-    
-    // 关闭弹窗
-    showAiModal.value = false;
-    
-    uni.showToast({ title: '已生成文本', icon: 'success' });
-  } catch (error: any) {
-    console.error('AI 生成失败:', error);
-    uni.showToast({ title: error.message || 'AI 生成失败', icon: 'none' });
-  } finally {
-    aiGenerating.value = false;
-  }
-}
-
-// 显示 AI 生成弹窗
-function showAIModal() {
-  aiPrompt.value = '';
-  showAiModal.value = true;
-}
-
-// 关闭 AI 生成弹窗
-function closeAiModal() {
-  showAiModal.value = false;
+// 跳转到 AI 生成页面
+function goToAIPage() {
+  uni.navigateTo({ url: '/pages/ai/index' });
 }
 
 // 生成音频
@@ -444,108 +385,6 @@ async function handleGenerate() {
   color: #ffffff;
 }
 
-/* AI 弹窗样式 */
-.ai-modal-mask {
-  position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  background: rgba(0, 0, 0, 0.5);
-  z-index: 1000;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 32rpx;
-}
-
-.ai-modal {
-  width: 100%;
-  background: #ffffff;
-  border-radius: 24rpx;
-  overflow: hidden;
-}
-
-.ai-modal-header {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 32rpx;
-  border-bottom: 1rpx solid #f3f4f6;
-}
-
-.ai-modal-title {
-  font-size: 32rpx;
-  font-weight: 600;
-  color: #1f2937;
-}
-
-.ai-modal-close {
-  font-size: 48rpx;
-  color: #9ca3af;
-  line-height: 1;
-}
-
-.ai-modal-content {
-  padding: 32rpx;
-}
-
-.ai-prompt-input {
-  width: 100%;
-  height: 200rpx;
-  padding: 24rpx;
-  background: #f9fafb;
-  border-radius: 16rpx;
-  font-size: 28rpx;
-  color: #1f2937;
-  line-height: 1.6;
-  box-sizing: border-box;
-}
-
-.ai-prompt-hint {
-  margin-top: 16rpx;
-}
-
-.ai-prompt-hint text {
-  font-size: 22rpx;
-  color: #9ca3af;
-}
-
-.ai-modal-footer {
-  display: flex;
-  gap: 24rpx;
-  padding: 32rpx;
-  border-top: 1rpx solid #f3f4f6;
-}
-
-.ai-cancel-btn,
-.ai-confirm-btn {
-  flex: 1;
-  height: 80rpx;
-  border-radius: 16rpx;
-  font-size: 28rpx;
-  font-weight: 500;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.ai-cancel-btn {
-  background: #f3f4f6;
-  color: #6b7280;
-  border: none;
-}
-
-.ai-confirm-btn {
-  background: linear-gradient(135deg, #4f46e5 0%, #818cf8 100%);
-  color: #ffffff;
-  border: none;
-}
-
-.ai-confirm-btn[disabled] {
-  background: #e5e7eb;
-}
-
 .ai-btn {
   display: flex;
   align-items: center;