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:
TradeMate Dev
2026-05-20 09:39:22 +08:00
parent 4755cc75ba
commit f8a23855d2
20 changed files with 744 additions and 5 deletions
+372
View File
@@ -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
View File
@@ -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,
}
}
}
+4
View File
@@ -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'
+2
View File
@@ -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 = {
+2
View File
@@ -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')
+5
View File
@@ -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'),