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:
@@ -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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 || '登录失败'
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user