feat: AI assistant phase 2 - configurable prompt, action operations, FAQ matching, NVIDIA provider
- Admin-configurable AI prompt/quick questions from system_configs DB - GET /api/v1/ai/quick-questions endpoint for fetching quick questions - Local FAQ matching for instant responses (avoid AI calls for common Qs) - AI action extraction: "add customer" intent detected, structured data returned - Frontend action confirmation card with editable fields, calls customer API on confirm - NVIDIA provider (stepfun-ai/step-3.5-flash) for faster chat vs deepseek-v4-flash - Fixed httpx client timeout preventing backend hangs - Added log_usage calls for auth events (register/login/guest/wechat) - Admin tabs (users/stats/logs/config) fully functional with real backend - AiAssistant component added to all tabbar pages
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
<template>
|
||||
<view>
|
||||
<view class="ai-float-btn" @click="open = !open">
|
||||
<text class="ai-float-icon">AI</text>
|
||||
</view>
|
||||
|
||||
<view class="ai-dialog" v-if="open">
|
||||
<view class="ai-header">
|
||||
<text class="ai-title">TradeMate AI 助手</text>
|
||||
<text class="ai-close" @click="open = false">×</text>
|
||||
</view>
|
||||
|
||||
<scroll-view class="ai-messages" scroll-y :scroll-top="scrollTop" ref="msgRef">
|
||||
<view v-for="(msg, i) in messages" :key="i" class="ai-msg-row" :class="msg.role">
|
||||
<view class="ai-msg-bubble">
|
||||
<text class="ai-msg-text">{{ msg.content }}</text>
|
||||
|
||||
<view v-if="msg.actions && msg.actions.length" class="ai-action-card">
|
||||
<view v-for="(action, ai) in msg.actions" :key="ai" class="ai-action-item">
|
||||
<text class="ai-action-title">{{ action.label }}</text>
|
||||
<view v-for="(val, key) in action.fields" :key="key" class="ai-field-row">
|
||||
<text class="ai-field-label">{{ fieldLabel(key) }}</text>
|
||||
<input class="ai-field-input" :value="val" @input="e => editField(i, ai, key, e.detail.value)" />
|
||||
</view>
|
||||
<view class="ai-action-btns">
|
||||
<text class="ai-btn-cancel" @click="cancelAction(i, ai)">取消</text>
|
||||
<text class="ai-btn-confirm" @click="confirmAction(i, ai)">确认添加</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="ai-loading" v-if="loading">
|
||||
<text class="ai-loading-text">思考中...</text>
|
||||
</view>
|
||||
|
||||
<view class="ai-suggestions" v-if="messages.length === 1">
|
||||
<view class="ai-suggestion" v-for="(s, i) in suggestions" :key="i" @click="sendQuick(s)">
|
||||
<text class="ai-suggestion-text">{{ s }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="ai-input-bar">
|
||||
<input class="ai-input" v-model="inputText" placeholder="输入你的问题..." @confirm="send" :disabled="loading" />
|
||||
<text class="ai-send-btn" :class="{ disabled: !inputText.trim() || loading }" @click="send">发送</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick, watch } from 'vue'
|
||||
import { aiChatApi, customerApi } from '@/utils/api.js'
|
||||
|
||||
const open = ref(false)
|
||||
const inputText = ref('')
|
||||
const loading = ref(false)
|
||||
const scrollTop = ref(0)
|
||||
const msgRef = ref(null)
|
||||
const suggestions = ref([])
|
||||
|
||||
const messages = ref([
|
||||
{ role: 'assistant', content: '你好!我是 TradeMate AI 助手,可以帮你解答外贸工具使用问题或外贸业务知识。有什么可以帮你的?' },
|
||||
])
|
||||
|
||||
const fieldLabel = (key) => ({
|
||||
name: '客户名称 *', phone: '电话', email: '邮箱',
|
||||
company: '公司', country: '国家', notes: '备注',
|
||||
})[key] || key
|
||||
|
||||
const editField = (msgIdx, actionIdx, key, val) => {
|
||||
const msg = messages.value[msgIdx]
|
||||
if (!msg.actions || !msg.actions[actionIdx]) return
|
||||
msg.actions[actionIdx].fields[key] = val
|
||||
}
|
||||
|
||||
const confirmAction = async (msgIdx, actionIdx) => {
|
||||
const action = messages.value[msgIdx].actions[actionIdx]
|
||||
if (action.type !== 'create_customer') return
|
||||
const { fields } = action
|
||||
if (!fields.name) {
|
||||
uni.showToast({ title: '客户名称不能为空', icon: 'none' })
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await customerApi.create(fields)
|
||||
uni.showToast({ title: '客户添加成功', icon: 'success' })
|
||||
messages.value[msgIdx].actions = []
|
||||
setTimeout(() => uni.switchTab({ url: '/pages/customers/customers' }), 1500)
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '添加失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelAction = (msgIdx, actionIdx) => {
|
||||
const msg = messages.value[msgIdx]
|
||||
if (msg.actions) msg.actions.splice(actionIdx, 1)
|
||||
}
|
||||
|
||||
const sendQuick = (text) => {
|
||||
inputText.value = text
|
||||
send()
|
||||
}
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
try {
|
||||
const res = await aiChatApi.quickQuestions()
|
||||
if (Array.isArray(res)) {
|
||||
suggestions.value = res
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
const msg = inputText.value.trim()
|
||||
if (!msg || loading.value) return
|
||||
inputText.value = ''
|
||||
messages.value.push({ role: 'user', content: msg })
|
||||
loading.value = true
|
||||
scrollToBottom()
|
||||
|
||||
try {
|
||||
const hist = messages.value.map(m => ({ role: m.role, content: m.content }))
|
||||
const res = await aiChatApi.chat(msg, hist.slice(0, -1))
|
||||
const newMsg = { role: 'assistant', content: res.reply || '抱歉,我没有理解,请重新描述一下你的问题。' }
|
||||
if (res.actions && res.actions.length) {
|
||||
newMsg.actions = res.actions
|
||||
}
|
||||
messages.value.push(newMsg)
|
||||
} catch {
|
||||
messages.value.push({ role: 'assistant', content: '请求失败,请稍后重试。' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
watch(open, (val) => { if (val && !suggestions.value.length) fetchSuggestions() })
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
scrollTop.value += 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.ai-float-btn {
|
||||
position: fixed;
|
||||
right: 30rpx;
|
||||
bottom: 120rpx;
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4rpx 20rpx rgba(102, 126, 234, 0.4);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.ai-float-icon {
|
||||
color: #fff;
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.ai-dialog {
|
||||
position: fixed;
|
||||
right: 30rpx;
|
||||
bottom: 240rpx;
|
||||
width: 580rpx;
|
||||
height: 700rpx;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
box-shadow: 0 8rpx 40rpx rgba(0,0,0,0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 9998;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ai-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 24rpx 30rpx;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
}
|
||||
|
||||
.ai-title {
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ai-close {
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-size: 40rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ai-messages {
|
||||
flex: 1;
|
||||
padding: 20rpx;
|
||||
overflow-y: auto;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.ai-msg-row {
|
||||
margin-bottom: 16rpx;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ai-msg-row.user {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ai-msg-bubble {
|
||||
max-width: 80%;
|
||||
padding: 16rpx 20rpx;
|
||||
border-radius: 12rpx;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ai-msg-row.assistant .ai-msg-bubble {
|
||||
background: #fff;
|
||||
border-bottom-left-radius: 4rpx;
|
||||
}
|
||||
|
||||
.ai-msg-row.user .ai-msg-bubble {
|
||||
background: #667eea;
|
||||
border-bottom-right-radius: 4rpx;
|
||||
}
|
||||
|
||||
.ai-msg-text {
|
||||
font-size: 26rpx;
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ai-msg-row.user .ai-msg-text {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.ai-action-card {
|
||||
margin-top: 16rpx;
|
||||
border-top: 2rpx solid #eee;
|
||||
padding-top: 12rpx;
|
||||
}
|
||||
|
||||
.ai-action-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ai-field-row {
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.ai-field-label {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
display: block;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.ai-field-input {
|
||||
height: 56rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 0 12rpx;
|
||||
font-size: 26rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ai-action-btns {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
margin-top: 14rpx;
|
||||
}
|
||||
|
||||
.ai-btn-cancel {
|
||||
flex: 1;
|
||||
height: 56rpx;
|
||||
line-height: 56rpx;
|
||||
text-align: center;
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
border-radius: 8rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.ai-btn-confirm {
|
||||
flex: 1;
|
||||
height: 56rpx;
|
||||
line-height: 56rpx;
|
||||
text-align: center;
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
border-radius: 8rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.ai-loading {
|
||||
text-align: center;
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
|
||||
.ai-loading-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.ai-suggestions {
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
|
||||
.ai-suggestion {
|
||||
background: #f0edff;
|
||||
border-radius: 8rpx;
|
||||
padding: 14rpx 18rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.ai-suggestion-text {
|
||||
font-size: 24rpx;
|
||||
color: #667eea;
|
||||
}
|
||||
|
||||
.ai-input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16rpx 20rpx;
|
||||
border-top: 2rpx solid #f0f0f0;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.ai-input {
|
||||
flex: 1;
|
||||
height: 64rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 32rpx;
|
||||
padding: 0 24rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.ai-send-btn {
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
padding: 0 24rpx;
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
border-radius: 32rpx;
|
||||
font-size: 26rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-send-btn.disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
||||
+3
-1
@@ -1,9 +1,11 @@
|
||||
import { createSSRApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import AiAssistant from './components/ai-assistant.vue'
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App)
|
||||
app.component('AiAssistant', AiAssistant)
|
||||
return {
|
||||
app,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,11 +258,13 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AiAssistant />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import AiAssistant from '@/components/ai-assistant.vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { adminApi } from '@/utils/api.js'
|
||||
|
||||
@@ -297,6 +299,8 @@ const configLabels = {
|
||||
feature_registration: '新用户注册',
|
||||
free_daily_limits: '免费版每日配额',
|
||||
pro_daily_limits: 'Pro 版每日配额',
|
||||
ai_assistant_prompt: 'AI 助手系统提示词',
|
||||
ai_assistant_quick_questions: 'AI 助手快捷提问',
|
||||
}
|
||||
|
||||
const fieldLabels = (configKey, fieldKey) => {
|
||||
|
||||
@@ -338,11 +338,13 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AiAssistant />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import AiAssistant from '@/components/ai-assistant.vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { customerApi, healthApi, silentPatternApi, whatsappApi, translateApi } from '@/utils/api.js'
|
||||
|
||||
|
||||
@@ -253,6 +253,7 @@
|
||||
</view>
|
||||
<text class="footer-copyright">© 2026 北京宇之然科技中心. 保留所有权利.</text>
|
||||
</view>
|
||||
<AiAssistant />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -260,6 +261,7 @@
|
||||
import { ref, computed, onUnmounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js'
|
||||
import AiAssistant from '@/components/ai-assistant.vue'
|
||||
|
||||
const showAnnouncement = ref(false)
|
||||
const currentAnnouncement = ref(0)
|
||||
|
||||
@@ -143,11 +143,13 @@
|
||||
<view class="empty" v-if="!loading && (!resultsMap[activeTab] || resultsMap[activeTab].length === 0)">
|
||||
<text>{{ tabConfig[activeTab].emptyHint }}</text>
|
||||
</view>
|
||||
<AiAssistant />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import AiAssistant from '@/components/ai-assistant.vue'
|
||||
import { marketingApi, interactionApi, translateApi, BASE_URL } from '@/utils/api.js'
|
||||
|
||||
const tabConfig = {
|
||||
|
||||
@@ -191,11 +191,13 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AiAssistant />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import AiAssistant from '@/components/ai-assistant.vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { productApi } from '@/utils/api.js'
|
||||
|
||||
|
||||
@@ -242,11 +242,13 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<AiAssistant />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import AiAssistant from '@/components/ai-assistant.vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { quotationApi, customerApi } from '@/utils/api.js'
|
||||
|
||||
|
||||
@@ -105,11 +105,13 @@
|
||||
</view>
|
||||
|
||||
<view class="keyboard-height" :style="{ height: keyboardHeight + 'px' }"></view>
|
||||
<AiAssistant />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import AiAssistant from '@/components/ai-assistant.vue'
|
||||
import { translateApi, interactionApi, BASE_URL } from '@/utils/api.js'
|
||||
|
||||
const mode = ref('translate')
|
||||
|
||||
@@ -176,6 +176,11 @@ export const adminApi = {
|
||||
updateConfig: (key, value) => request(`/admin/config/${key}`, 'PUT', { value }),
|
||||
}
|
||||
|
||||
export const aiChatApi = {
|
||||
chat: (message, history = []) => request('/ai/chat', 'POST', { message, history }),
|
||||
quickQuestions: () => request('/ai/quick-questions'),
|
||||
}
|
||||
|
||||
export const analyticsApi = {
|
||||
getOverview: () => request('/analytics/overview'),
|
||||
getCustomers: () => request('/analytics/customers'),
|
||||
|
||||
Reference in New Issue
Block a user