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
+6 -3
View File
@@ -37,9 +37,9 @@ export function listLogs(params) { return http.get('/admin/logs', { params }) }
export function listConfig() { return http.get('/admin/config') }
export function updateConfig(key, value) { return http.put(`/admin/config/${key}`, { value }) }
export function listQuotas() { return http.get('/admin/quotas') }
export function updateQuota(version, data) { return http.put(`/admin/quotas/${version}`, data) }
export function resetQuota(version) { return http.post(`/admin/quotas/${version}/reset`) }
export function listQuotas() { return http.get('/admin/translation-quotas') }
export function updateQuota(version, data) { return http.put(`/admin/translation-quotas/${version}`, data) }
export function resetQuota(version) { return http.post(`/admin/translation-quotas/${version}/reset`) }
export function listCertifications(page = 1, size = 50, status = '') {
return http.get('/admin/certifications', { params: { page, size, status: status || undefined } })
@@ -55,4 +55,7 @@ export function processInvoice(id, action) {
return http.post(`/admin/invoices/${id}/process`, { action })
}
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,363 @@
<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-if="action.type === 'navigate'" class="ai-navigate-hint">
<el-tag type="info">跳转至 {{ action.fields?.path }}</el-tag>
</div>
<div v-for="(val, key) in action.fields" :key="key" class="ai-field-row" v-if="action.type !== 'navigate'">
<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, searchUsers, updateUser, updateConfig, reviewCertification, processInvoice } 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: '你好!我是管理后台 AI 助手,可以帮你管理用户、配置、认证审核、发票等。你可以说:"查找用户"、"审核认证"等。' },
])
const showSuggestions = ref(false)
const fieldLabel = (type, key) => {
const labels = {
name: '名称', phone: '电话', email: '邮箱', company: '公司',
country: '国家', status: '状态', notes: '备注',
key: '配置键', value: '配置值',
action: '操作', reason: '原因',
username: '用户名', role: '角色',
version: '版本', quota: '额度',
path: '目标页面',
}
return labels[key] || key
}
const fieldPlaceholder = (type, key) => {
const placeholders = {
reason: '审核备注(可选)',
action: 'approved / rejected',
role: 'admin / user',
value: '配置值',
quota: '如 100',
path: '如 /users, /config, /certifications',
}
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 'navigate':
if (fields.path) {
const router = (await import('@/router')).default
router.push(fields.path)
ElMessage.success(`正在跳转到 ${fields.path}`)
messages.value[msgIdx].actions = []
}
return
case 'search_users':
if (!fields.query) { ElMessage.warning('搜索关键词不能为空'); return }
await searchUsers(fields.query)
break
case 'update_user':
if (!fields.user_id && !fields.username) { ElMessage.warning('用户标识不能为空'); return }
await updateUser(fields.user_id || fields.username, fields)
break
case 'update_config':
if (!fields.key) { ElMessage.warning('配置键不能为空'); return }
await updateConfig(fields.key, fields.value)
break
case 'review_certification':
if (!fields.id) { ElMessage.warning('认证ID不能为空'); return }
await reviewCertification(fields.id, fields.action || 'approved', fields.reason)
break
case 'process_invoice':
if (!fields.id) { ElMessage.warning('发票ID不能为空'); return }
await processInvoice(fields.id, fields.action || 'approve')
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>
+3 -1
View File
@@ -51,7 +51,7 @@
<el-icon><Search /></el-icon>
<span>搜索配置</span>
</el-menu-item>
<el-menu-item index="/system/ai-providers">
<el-menu-item index="/system/search-config/ai-providers">
<el-icon><Cpu /></el-icon>
<span>AI 模型配置</span>
</el-menu-item>
@@ -128,6 +128,7 @@
</div>
</el-footer>
</el-container>
<AiAssistant />
</el-container>
</template>
@@ -135,6 +136,7 @@
import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import AiAssistant from '@/components/AiAssistant.vue'
const route = useRoute()
const router = useRouter()
+1 -1
View File
@@ -2,7 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import AdminLayout from '@/layouts/AdminLayout.vue'
const routes = [
{ path: '/login', redirect: '/' },
{ path: '/login', redirect: to => ({ path: '/', query: to.query }) },
{ path: '/', name: 'Landing', component: () => import('@/views/Landing.vue') },
{
path: '/dashboard',
+46 -5
View File
@@ -7,7 +7,24 @@
<el-tag size="small" v-if="cfg.description">{{ cfg.description }}</el-tag>
</div>
</template>
<div v-if="typeof cfg.value === 'object' && !Array.isArray(cfg.value)">
<div v-if="cfg.key === 'ai_routing'">
<div v-for="(taskVal, taskKey) in cfg.value" :key="taskKey" class="cfg-nested-group">
<div class="cfg-group-title">{{ fieldLabelsMap.ai_routing?.[taskKey] || taskKey }}</div>
<div class="cfg-field">
<span class="cfg-label">主选</span>
<el-select v-model="edits[cfg.key][taskKey].primary" size="small" style="width:300px" filterable>
<el-option v-for="p in providers" :key="p.provider_type" :value="p.provider_type" :label="p.name + ' — ' + p.model_name" />
</el-select>
</div>
<div class="cfg-field">
<span class="cfg-label">备用</span>
<el-select v-model="edits[cfg.key][taskKey].fallback" size="small" style="width:400px" multiple filterable collapse-tags>
<el-option v-for="p in providers" :key="p.provider_type" :value="p.provider_type" :label="p.name + ' — ' + p.model_name" />
</el-select>
</div>
</div>
</div>
<div v-else-if="typeof cfg.value === 'object' && !Array.isArray(cfg.value)">
<div class="cfg-field" v-for="(v, k) in cfg.value" :key="k">
<span class="cfg-label">{{ fieldLabel(cfg.key, k) }}</span>
<el-input v-if="typeof v === 'string'" v-model="edits[cfg.key][k]" size="small" style="width:300px" />
@@ -34,12 +51,33 @@
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { listConfig, updateConfig } from '@/api'
import http from '@/api'
const configs = ref([])
const edits = reactive({})
const configLabels = { system_maintenance: '系统维护', feature_flags: '功能开关', translation_providers: '翻译服务', ai_model_config: 'AI模型', cache_ttl: '缓存时间' }
const fieldLabelsMap = { system_maintenance: { maintenance_mode: '维护模式', maintenance_message: '维护消息' }, feature_flags: { feature_wechat_login: '微信登录', feature_export: '数据导出' }, translation_providers: { primary: '首选服务', fallback: '备用服务' }, ai_model_config: { default_model: '默认模型', max_tokens: '最大Token' } }
function fieldLabel(key, k) { return fieldLabelsMap[key]?.[k] || k }
const providers = ref([])
const configLabels = { system_maintenance: '系统维护', feature_flags: '功能开关', translation_providers: '翻译服务', ai_model_config: 'AI模型', cache_ttl: '缓存时间', ai_routing: 'AI路由规则' }
const fieldLabelsMap = { system_maintenance: { maintenance_mode: '维护模式', maintenance_message: '维护消息' }, feature_flags: { feature_wechat_login: '微信登录', feature_export: '数据导出' }, translation_providers: { primary: '首选服务', fallback: '备用服务' }, ai_model_config: { default_model: '默认模型', max_tokens: '最大Token' }, ai_routing: { translate: '翻译', reply: '回复建议', marketing: '营销文案', extract: '信息提取', quotation: '报价单', chat: 'AI助手' } }
const taskFieldLabels = { primary: '主选', fallback: '备用' }
function fieldLabel(key, k) {
if (key === 'ai_routing') return configLabels[key] + ' > ' + (taskFieldLabels[k] || k)
return fieldLabelsMap[key]?.[k] || k
}
function taskFieldLabel(cfgKey, taskKey, subKey) {
if (cfgKey === 'ai_routing') {
const taskLabel = fieldLabelsMap.ai_routing?.[taskKey] || taskKey
const subLabel = taskFieldLabels[subKey] || subKey
return taskLabel + ' > ' + subLabel
}
return subKey || taskKey
}
async function loadProviders() {
try {
const res = await http.get('/admin/ai-providers')
providers.value = (res.items || []).filter(p => p.enabled)
} catch (_) {}
}
async function load() {
try {
@@ -48,13 +86,14 @@ async function load() {
for (const cfg of configs.value) {
edits[cfg.key] = JSON.parse(JSON.stringify(cfg.value))
}
await loadProviders()
} catch (e) { console.error(e) }
}
async function save(key) {
try {
await updateConfig(key, edits[key])
ElMessage.success('已保存')
ElMessage.success('已保存AI 路由器已重载')
} catch (e) { ElMessage.error(e?.detail || '保存失败') }
}
@@ -66,5 +105,7 @@ onMounted(load)
.cfg-header { display: flex; align-items: center; gap: 12px; font-weight: 600; }
.cfg-field { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
.cfg-label { width: 160px; font-size: 13px; color: #666; flex-shrink: 0; }
.cfg-nested-group { margin-bottom: 12px; padding: 8px 12px; background: #f9fafb; border-radius: 6px; }
.cfg-group-title { font-weight: 600; font-size: 13px; color: #333; margin-bottom: 4px; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; }
.cfg-actions { margin-top: 12px; }
</style>
+1 -1
View File
@@ -88,7 +88,7 @@ async function submit() {
const res = await loginApi(form)
localStorage.setItem('admin_token', res.access_token)
localStorage.setItem('admin_user', JSON.stringify(res.user || {}))
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (e) {
const map = { 'Invalid credentials': '用户名或密码错误' }
error.value = map[e?.detail] || e?.detail || '登录失败'
+1 -1
View File
@@ -4,7 +4,7 @@
<el-card shadow="never">
<template #header>
<div class="quota-header">
<span class="quota-version">{{ q.version === 'ecommerce' ? '电商版' : '通用版' }}</span>
<span class="quota-version">{{ { ecommerce: '电商版', general: '通用版', llm: 'AI模型翻译' }[q.version] || q.version }}</span>
<el-tag size="small" v-if="q.description">{{ q.description }}</el-tag>
</div>
</template>