289 lines
6.6 KiB
Vue
289 lines
6.6 KiB
Vue
<template>
|
||
<div class="container-box aisettings">
|
||
<div class="header-bar">
|
||
<h2>AI 管理</h2>
|
||
<el-button type="primary" plain @click="$router.push('/system/aisettings/playground')">
|
||
打开 Playground
|
||
</el-button>
|
||
</div>
|
||
|
||
<!-- 顶部引导 -->
|
||
<div class="ai-guide">
|
||
<div class="ai-guide__text">
|
||
<h3>统一 AI 能力中台</h3>
|
||
<p>
|
||
左侧管理已接入的模型配置(OpenAI Chat Completions / OpenAI Responses / Anthropic Messages),
|
||
点击「新增」或列表项进行配置;连通性测试与 Playground 对话验证已保存的模型。
|
||
OCR 识别配置在右侧统一维护,租户端「OA - OCR 识别」将读取这里的配置。
|
||
</p>
|
||
</div>
|
||
<div class="ai-guide__steps">
|
||
<span class="step"><i>1</i>新增模型</span>
|
||
<span class="step"><i>2</i>填写 API 信息</span>
|
||
<span class="step"><i>3</i>连通性测试</span>
|
||
<span class="step"><i>4</i>Playground 验证</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="ai-layout">
|
||
<!-- 左侧:已配置模型列表 -->
|
||
<div class="ai-list">
|
||
<div class="ai-list__header">
|
||
<span class="ai-list__title">模型配置</span>
|
||
<el-button type="primary" size="small" @click="openAdd">新增</el-button>
|
||
</div>
|
||
|
||
<div class="ai-list__body" v-loading="listLoading">
|
||
<div
|
||
v-for="item in list"
|
||
:key="item.id"
|
||
class="ai-item"
|
||
@click="openEdit(item)"
|
||
>
|
||
<div class="ai-item__main">
|
||
<div class="ai-item__name">
|
||
{{ item.name }}
|
||
<el-tag v-if="item.is_default === 1" type="success" size="small">默认</el-tag>
|
||
</div>
|
||
<div class="ai-item__meta">
|
||
{{ apiTypeLabel(item.api_type) }} · {{ firstModel(item) }}
|
||
</div>
|
||
</div>
|
||
<el-tag :type="item.status === 1 ? 'success' : 'info'" size="small" effect="plain">
|
||
{{ item.status === 1 ? '启用' : '禁用' }}
|
||
</el-tag>
|
||
</div>
|
||
|
||
<el-empty v-if="!listLoading && !list.length" description="暂无配置,点击新增创建" :image-size="60" />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧:OCR 识别配置 -->
|
||
<div class="ai-detail">
|
||
<ocr-settings />
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 新增 / 编辑弹窗 -->
|
||
<provider-dialog
|
||
v-model="dialogVisible"
|
||
:item="dialogItem"
|
||
@saved="onSaved"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted } from "vue";
|
||
import { getAiProviderList } from "@/api/aisettings";
|
||
import providerDialog from "./components/providerDialog.vue";
|
||
import ocrSettings from "./components/ocrSettings.vue";
|
||
|
||
const listLoading = ref(false);
|
||
const list = ref([]);
|
||
const dialogVisible = ref(false);
|
||
const dialogItem = ref(null);
|
||
|
||
const API_TYPE_LABELS = {
|
||
chat_completions: "Chat Completions",
|
||
responses: "Responses",
|
||
anthropic: "Anthropic Messages",
|
||
};
|
||
const apiTypeLabel = (t) => API_TYPE_LABELS[t] || t || "Chat Completions";
|
||
const firstModel = (item) => {
|
||
const m = (item.models || [])[0];
|
||
if (!m) return item.default_model || "未设置模型";
|
||
return m.name ? `${m.name}(${m.id})` : m.id;
|
||
};
|
||
|
||
const loadList = async () => {
|
||
listLoading.value = true;
|
||
try {
|
||
const res = await getAiProviderList();
|
||
if (res?.code === 200) list.value = res.data?.list || res.data || [];
|
||
} finally {
|
||
listLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const openAdd = () => {
|
||
dialogItem.value = null;
|
||
dialogVisible.value = true;
|
||
};
|
||
|
||
const openEdit = (item) => {
|
||
dialogItem.value = item;
|
||
dialogVisible.value = true;
|
||
};
|
||
|
||
const onSaved = async () => {
|
||
await loadList();
|
||
};
|
||
|
||
onMounted(loadList);
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.aisettings {
|
||
padding: 20px;
|
||
}
|
||
|
||
.header-bar {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
|
||
h2 {
|
||
margin: 0;
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
}
|
||
|
||
.ai-guide {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 24px;
|
||
margin-top: 16px;
|
||
padding: 20px 24px;
|
||
border-radius: 12px;
|
||
background: linear-gradient(120deg, var(--el-color-primary-light-9), var(--el-fill-color-light));
|
||
border: 1px solid var(--el-color-primary-light-7);
|
||
|
||
&__text {
|
||
flex: 1;
|
||
min-width: 0;
|
||
|
||
h3 {
|
||
margin: 0 0 8px;
|
||
font-size: 16px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
|
||
p {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
line-height: 1.8;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
}
|
||
|
||
&__steps {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.step {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 14px;
|
||
border-radius: 100px;
|
||
background: var(--el-bg-color);
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
font-size: 12.5px;
|
||
color: var(--el-text-color-regular);
|
||
|
||
i {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
background: var(--el-color-primary);
|
||
color: #fff;
|
||
font-style: normal;
|
||
font-size: 11px;
|
||
}
|
||
}
|
||
}
|
||
|
||
.ai-layout {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-top: 16px;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.ai-list {
|
||
width: 340px;
|
||
flex-shrink: 0;
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
border-radius: 10px;
|
||
background: var(--el-bg-color);
|
||
overflow: hidden;
|
||
|
||
&__header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||
background: var(--el-fill-color-lighter);
|
||
}
|
||
|
||
&__title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
|
||
&__body {
|
||
max-height: 560px;
|
||
overflow-y: auto;
|
||
padding: 8px;
|
||
}
|
||
}
|
||
|
||
.ai-item {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
padding: 10px 12px;
|
||
border-radius: 8px;
|
||
cursor: pointer;
|
||
transition: background 0.2s ease;
|
||
|
||
&:hover {
|
||
background: var(--el-fill-color-light);
|
||
}
|
||
|
||
&__main {
|
||
min-width: 0;
|
||
}
|
||
|
||
&__name {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 13.5px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-primary);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
&__meta {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
}
|
||
|
||
.ai-detail {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
</style>
|