chore: post-deployment cleanup and docs update

- Make AI routing rules DB-driven (read from system_configs, removed from config.py)
- Add translation quota tracking to LLM translation (OpenAIProvider)
- Add Alibaba MT ECS RAM role support (STS token, no AccessKey needed)
- Fix admin sidebar link for AI模型配置 page
- Fix Quota.vue API path (quotas → translation-quotas)
- Fix login auto-redirect to dashboard
- Add provider dropdown selects to AI routing config UI
- Clean up stale ai_provider_* system_configs records
- Remove OpencodeGo, Spark providers (code + DB)
- Update deploy config: nginx port 8000, systemd cwd
This commit is contained in:
TradeMate Dev
2026-06-02 15:40:02 +08:00
parent fa3050a17c
commit f17a6ccbac
28 changed files with 1140 additions and 209 deletions
+3
View File
@@ -116,4 +116,7 @@ export function sendWhatsApp(data) { return http.post('/whatsapp/send', data) }
export function getUsageStats() { return http.get('/usage/stats') }
export function aiChat(message, history = []) { return http.post('/ai/chat', { message, history }) }
export function aiQuickQuestions() { return http.get('/ai/quick-questions') }
export default http
@@ -0,0 +1,382 @@
<template>
<div>
<div class="ai-float-btn" @click="visible = true">
<span class="ai-float-icon">AI</span>
</div>
<el-dialog
v-model="visible"
title="TradeMate AI 助手"
width="480px"
:close-on-click-modal="false"
class="ai-dialog"
top="5vh"
@opened="onOpened"
>
<div class="ai-messages" ref="msgContainer">
<div v-for="(msg, i) in messages" :key="i" class="ai-msg-row" :class="msg.role">
<div class="ai-avatar" v-if="msg.role === 'assistant'">
<el-icon :size="18" color="#667eea"><Cpu /></el-icon>
</div>
<div class="ai-msg-body">
<div class="ai-msg-bubble">
<div class="ai-msg-text">{{ msg.content }}</div>
<div v-if="msg.actions && msg.actions.length" class="ai-action-card">
<div v-for="(action, ai) in msg.actions" :key="ai" class="ai-action-item">
<div class="ai-action-title">{{ action.label }}</div>
<div v-for="(val, key) in action.fields" :key="key" class="ai-field-row">
<span class="ai-field-label">{{ fieldLabel(action.type, key) }}</span>
<el-input
v-model="action.fields[key]"
:placeholder="fieldPlaceholder(action.type, key)"
size="small"
/>
</div>
<div class="ai-action-btns">
<el-button size="small" @click="cancelAction(i, ai)">取消</el-button>
<el-button size="small" type="primary" @click="confirmAction(i, ai)">确认执行</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="loading" class="ai-loading">
<el-icon class="is-loading" :size="18"><Loading /></el-icon>
<span>思考中...</span>
</div>
<div v-if="showSuggestions" class="ai-suggestions">
<div
v-for="(s, i) in suggestions"
:key="i"
class="ai-suggestion"
@click="sendQuick(s)"
>
{{ s }}
</div>
</div>
</div>
<template #footer>
<div class="ai-input-bar">
<el-input
v-model="inputText"
placeholder="输入你的问题..."
:disabled="loading"
size="default"
@keyup.enter="send"
/>
<el-button type="primary" :disabled="!inputText.trim() || loading" @click="send">发送</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { Cpu, Loading } from '@element-plus/icons-vue'
import {
aiChat, aiQuickQuestions,
createCustomer, createProduct, createQuotation,
scanFollowups, generateMarketing, discoverySearch,
} from '@/api'
const visible = ref(false)
const inputText = ref('')
const loading = ref(false)
const suggestions = ref([])
const msgContainer = ref(null)
const messages = ref([
{ role: 'assistant', content: '你好!我是 TradeMate AI 助手,可以帮你操作外贸工具的各项功能。你可以说:"帮我添加一个客户"、"创建一个产品"、"生成报价单"等。' },
])
const showSuggestions = ref(false)
const fieldLabel = (type, key) => {
const labels = {
name: '名称', phone: '电话', email: '邮箱', company: '公司',
country: '国家', website: '网站', notes: '备注',
name_en: '英文名称', description: '描述', description_en: '英文描述',
category: '分类', price: '价格', price_unit: '货币', moq: 'MOQ',
keywords: '关键词',
customer_name: '客户名称', product_info: '产品信息', quantity: '数量',
terms: '交易条款',
message: '消息内容',
product_name: '产品名称', target_market: '目标市场', tone: '语气',
language: '语言',
industry: '行业',
}
return labels[key] || key
}
const fieldPlaceholder = (type, key) => {
const placeholders = {
phone: '如 +86-13800138000',
email: '如 contact@example.com',
price: '如 12.50',
price_unit: '默认 USD',
keywords: '逗号分隔',
quantity: '如 1000 pcs',
target_market: '如 美国、欧洲',
}
return placeholders[key] || ''
}
const cancelAction = (msgIdx, actionIdx) => {
const msg = messages.value[msgIdx]
if (msg.actions) msg.actions.splice(actionIdx, 1)
}
const confirmAction = async (msgIdx, actionIdx) => {
const action = messages.value[msgIdx].actions[actionIdx]
const { type, fields } = action
loading.value = true
try {
switch (type) {
case 'create_customer':
if (!fields.name) { ElMessage.warning('客户名称不能为空'); return }
await createCustomer(fields)
break
case 'create_product':
if (!fields.name) { ElMessage.warning('产品名称不能为空'); return }
if (fields.keywords && typeof fields.keywords === 'string') {
fields.keywords = fields.keywords.split(/[,]/).map(s => s.trim()).filter(Boolean)
}
await createProduct(fields)
break
case 'create_quotation':
if (!fields.customer_name) { ElMessage.warning('客户名称不能为空'); return }
if (!fields.product_info) { ElMessage.warning('产品信息不能为空'); return }
if (!fields.quantity) { ElMessage.warning('数量不能为空'); return }
await createQuotation({
customer_name: fields.customer_name,
items: [{ description: fields.product_info, quantity: fields.quantity, price: fields.price || '0' }],
terms: fields.terms || '',
})
break
case 'scan_followups':
await scanFollowups()
break
case 'generate_marketing':
if (!fields.product_name) { ElMessage.warning('产品名称不能为空'); return }
await generateMarketing({
product_info: { name: fields.product_name },
target_market: fields.target_market,
tone: fields.tone,
language: fields.language,
})
break
case 'discovery_search':
if (!fields.keywords) { ElMessage.warning('关键词不能为空'); return }
await discoverySearch({
keywords: fields.keywords.split(/[,]/).map(s => s.trim()).filter(Boolean),
country: fields.country,
industry: fields.industry,
})
break
default:
ElMessage.warning(`未知操作类型: ${type}`)
return
}
ElMessage.success('操作成功')
messages.value[msgIdx].actions = []
} catch (e) {
ElMessage.error(e.message || e.detail || '操作失败')
} finally {
loading.value = false
}
}
const sendQuick = (text) => {
inputText.value = text
send()
}
const fetchSuggestions = async () => {
try {
const res = await aiQuickQuestions()
if (Array.isArray(res)) suggestions.value = res
} catch {}
}
const onOpened = () => {
if (suggestions.value.length === 0) fetchSuggestions()
showSuggestions.value = messages.value.length <= 1
scrollToBottom()
}
const send = async () => {
const msg = inputText.value.trim()
if (!msg || loading.value) return
inputText.value = ''
showSuggestions.value = false
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 aiChat(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()
}
}
const scrollToBottom = async () => {
await nextTick()
const el = msgContainer.value
if (el) el.scrollTop = el.scrollHeight
}
</script>
<style scoped>
.ai-float-btn {
position: fixed;
right: 30px;
bottom: 30px;
width: 52px;
height: 52px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
z-index: 9999;
cursor: pointer;
transition: transform 0.2s;
}
.ai-float-btn:hover {
transform: scale(1.08);
}
.ai-float-icon {
color: #fff;
font-size: 18px;
font-weight: bold;
}
.ai-dialog {
--el-dialog-content-padding: 0;
}
.ai-dialog :deep(.el-dialog__body) {
padding: 0;
height: 480px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.ai-messages {
flex: 1;
padding: 16px;
overflow-y: auto;
background: #f5f5f5;
}
.ai-msg-row {
margin-bottom: 14px;
display: flex;
gap: 8px;
}
.ai-msg-row.user {
flex-direction: row-reverse;
}
.ai-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #f0edff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ai-msg-body {
max-width: 80%;
}
.ai-msg-bubble {
padding: 10px 14px;
border-radius: 8px;
background: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.ai-msg-row.user .ai-msg-bubble {
background: #667eea;
color: #fff;
}
.ai-msg-text {
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.ai-msg-row.user .ai-msg-text {
color: #fff;
}
.ai-action-card {
margin-top: 12px;
border-top: 1px solid #eee;
padding-top: 10px;
}
.ai-action-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 10px;
}
.ai-field-row {
margin-bottom: 8px;
}
.ai-field-label {
display: block;
font-size: 12px;
color: #666;
margin-bottom: 3px;
}
.ai-action-btns {
display: flex;
gap: 8px;
margin-top: 10px;
}
.ai-loading {
text-align: center;
padding: 12px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
color: #999;
font-size: 13px;
}
.ai-suggestions {
padding: 8px 0;
}
.ai-suggestion {
background: #f0edff;
border-radius: 6px;
padding: 8px 12px;
margin-bottom: 8px;
font-size: 13px;
color: #667eea;
cursor: pointer;
transition: background 0.15s;
}
.ai-suggestion:hover {
background: #e0dbff;
}
.ai-input-bar {
display: flex;
gap: 8px;
padding: 10px 16px;
border-top: 1px solid #f0f0f0;
}
</style>
+2
View File
@@ -102,6 +102,7 @@
</div>
</footer>
</div>
<AiAssistant />
</div>
</template>
@@ -110,6 +111,7 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { getUnreadCount } from '@/api'
import AiAssistant from '@/components/AiAssistant.vue'
const route = useRoute()
const router = useRouter()
+2 -1
View File
@@ -193,7 +193,8 @@ async function register() {
regForm.password = ''
} catch (e) {
const map = { 'Phone already registered': '该手机号已被注册', 'Invalid credentials': '手机号或密码错误' }
regError.value = map[e?.detail] || e?.detail || '注册失败'
const detail = typeof e === 'string' ? e : e?.detail
regError.value = map[detail] || detail || '注册失败,请检查网络连接'
} finally {
regLoading.value = false
}