增加智能体数据留存功能
This commit is contained in:
@@ -0,0 +1,888 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"server/models"
|
||||||
|
"server/pkg/jwtutil"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/client/orm"
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlatformAgentApiController 智能体API管理(yz_platform_agent_api,平台端)
|
||||||
|
type PlatformAgentApiController struct {
|
||||||
|
beego.Controller
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentApiHTTPClient 用于探测上游接口连通性
|
||||||
|
var agentApiHTTPClient = &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentApiProtocol 上游协议族,决定请求路径、鉴权头与响应解析方式
|
||||||
|
type agentApiProtocol string
|
||||||
|
|
||||||
|
const (
|
||||||
|
protocolAnthropic agentApiProtocol = "anthropic"
|
||||||
|
protocolGemini agentApiProtocol = "gemini"
|
||||||
|
protocolOpenAI agentApiProtocol = "openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
// detectAgentApiProtocol 由上游名称与地址关键词推断协议族
|
||||||
|
// 上游名称由用户自由填写,因此这里用包含匹配而非枚举比对;
|
||||||
|
// 未命中任何关键词时按 OpenAI 兼容协议处理(绝大多数中转与国产网关都兼容该协议)
|
||||||
|
func detectAgentApiProtocol(provider, url string) agentApiProtocol {
|
||||||
|
hay := strings.ToLower(provider + " " + url)
|
||||||
|
|
||||||
|
for _, k := range []string{"anthropic", "claude"} {
|
||||||
|
if strings.Contains(hay, k) {
|
||||||
|
return protocolAnthropic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, k := range []string{"gemini", "generativelanguage", "googleapis"} {
|
||||||
|
if strings.Contains(hay, k) {
|
||||||
|
return protocolGemini
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return protocolOpenAI
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PlatformAgentApiController) jsonErr(httpStatus, bizCode int, msg string) {
|
||||||
|
c.Ctx.Output.SetStatus(httpStatus)
|
||||||
|
c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg}
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PlatformAgentApiController) ok(msg string, data interface{}) {
|
||||||
|
resp := map[string]interface{}{"code": 200, "msg": msg}
|
||||||
|
if data != nil {
|
||||||
|
resp["data"] = data
|
||||||
|
}
|
||||||
|
c.Data["json"] = resp
|
||||||
|
_ = c.ServeJSON()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *PlatformAgentApiController) platformClaims() (*jwtutil.Claims, error) {
|
||||||
|
auth := c.Ctx.Request.Header.Get("Authorization")
|
||||||
|
if auth == "" {
|
||||||
|
return nil, fmt.Errorf("未登录")
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(auth, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
return nil, fmt.Errorf("认证信息格式错误")
|
||||||
|
}
|
||||||
|
claims, err := jwtutil.ParseToken(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("无效的token")
|
||||||
|
}
|
||||||
|
if claims.UserType != "platform" {
|
||||||
|
return nil, fmt.Errorf("无权访问")
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentApiPayload 新增/更新请求体
|
||||||
|
// ApiKeys 用指针:编辑时不传该字段表示不修改已存密钥列表
|
||||||
|
type agentApiPayload struct {
|
||||||
|
Provider string `json:"provider"`
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
UseCustomURL int8 `json:"use_custom_url"`
|
||||||
|
CustomURL string `json:"custom_url"`
|
||||||
|
ApiKeys *[]models.AgentApiKey `json:"api_keys"`
|
||||||
|
Models []string `json:"models"`
|
||||||
|
Status *int8 `json:"status"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiKeysToJSON 把密钥列表序列化为 JSON 字符串入库
|
||||||
|
// 去掉空密钥、按 key 去重,备注允许为空
|
||||||
|
func apiKeysToJSON(list []models.AgentApiKey) string {
|
||||||
|
cleaned := make([]models.AgentApiKey, 0, len(list))
|
||||||
|
seen := make(map[string]bool, len(list))
|
||||||
|
for _, item := range list {
|
||||||
|
k := strings.TrimSpace(item.Key)
|
||||||
|
if k == "" || seen[k] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[k] = true
|
||||||
|
cleaned = append(cleaned, models.AgentApiKey{
|
||||||
|
Key: k,
|
||||||
|
Remark: strings.TrimSpace(item.Remark),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(cleaned)
|
||||||
|
if err != nil {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// apiKeysFromJSON 把库里的 JSON 字符串反序列化为密钥列表
|
||||||
|
// 兼容三种历史/异常格式:对象数组、纯字符串数组、单个裸密钥字符串
|
||||||
|
func apiKeysFromJSON(raw string) []models.AgentApiKey {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return []models.AgentApiKey{}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(raw, "[") {
|
||||||
|
// 标准格式:[{"key":"sk-x","remark":"mimo198"}]
|
||||||
|
var objs []models.AgentApiKey
|
||||||
|
if err := json.Unmarshal([]byte(raw), &objs); err == nil {
|
||||||
|
out := make([]models.AgentApiKey, 0, len(objs))
|
||||||
|
for _, o := range objs {
|
||||||
|
if strings.TrimSpace(o.Key) != "" {
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
// 退化格式:["sk-x","sk-y"]
|
||||||
|
var strs []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &strs); err == nil {
|
||||||
|
out := make([]models.AgentApiKey, 0, len(strs))
|
||||||
|
for _, s := range strs {
|
||||||
|
if s = strings.TrimSpace(s); s != "" {
|
||||||
|
out = append(out, models.AgentApiKey{Key: s})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return []models.AgentApiKey{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 迁移遗漏时的兜底:整个字段就是一个裸密钥
|
||||||
|
return []models.AgentApiKey{{Key: raw}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsToJSON 把模型数组序列化为 JSON 字符串入库
|
||||||
|
func modelsToJSON(list []string) string {
|
||||||
|
cleaned := make([]string, 0, len(list))
|
||||||
|
seen := make(map[string]bool, len(list))
|
||||||
|
for _, m := range list {
|
||||||
|
m = strings.TrimSpace(m)
|
||||||
|
if m == "" || seen[m] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[m] = true
|
||||||
|
cleaned = append(cleaned, m)
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(cleaned)
|
||||||
|
if err != nil {
|
||||||
|
return "[]"
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsFromJSON 把库里的 JSON 字符串反序列化为模型数组
|
||||||
|
// 兼容历史数据可能存的逗号分隔格式
|
||||||
|
func modelsFromJSON(raw string) []string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(raw, "[") {
|
||||||
|
var out []string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &out); err == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
if p = strings.TrimSpace(p); p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentApiToMap 统一输出结构
|
||||||
|
// 密钥以明文返回,前端卡片默认脱敏、可切换显示;接口本身已受平台端鉴权保护
|
||||||
|
func agentApiToMap(row *models.PlatformAgentApi) map[string]interface{} {
|
||||||
|
keys := apiKeysFromJSON(row.ApiKeys)
|
||||||
|
out := map[string]interface{}{
|
||||||
|
"id": row.ID,
|
||||||
|
"provider": row.Provider,
|
||||||
|
"api_keys": keys,
|
||||||
|
"key_count": len(keys),
|
||||||
|
"base_url": row.BaseURL,
|
||||||
|
"use_custom_url": row.UseCustomURL,
|
||||||
|
"custom_url": row.CustomURL,
|
||||||
|
"models": modelsFromJSON(row.Models),
|
||||||
|
"status": row.Status,
|
||||||
|
"remark": row.Remark,
|
||||||
|
"user_id": row.UserID,
|
||||||
|
"user_name": row.UserName,
|
||||||
|
"create_time": row.CreateTime.Format("2006-01-02 15:04:05"),
|
||||||
|
"update_time": "",
|
||||||
|
}
|
||||||
|
if row.UpdateTime != nil {
|
||||||
|
out["update_time"] = row.UpdateTime.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAgentApiPayload 校验必填与地址格式
|
||||||
|
func validateAgentApiPayload(p *agentApiPayload, isCreate bool) error {
|
||||||
|
// 上游接口为用户自由填写的文本,不做枚举校验
|
||||||
|
p.Provider = strings.TrimSpace(p.Provider)
|
||||||
|
if p.Provider == "" {
|
||||||
|
return fmt.Errorf("请输入上游接口名称")
|
||||||
|
}
|
||||||
|
if len([]rune(p.Provider)) > 100 {
|
||||||
|
return fmt.Errorf("上游接口名称过长")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.BaseURL = strings.TrimSpace(p.BaseURL)
|
||||||
|
p.CustomURL = strings.TrimSpace(p.CustomURL)
|
||||||
|
|
||||||
|
if p.UseCustomURL == 1 {
|
||||||
|
if p.CustomURL == "" {
|
||||||
|
return fmt.Errorf("请输入自定义地址")
|
||||||
|
}
|
||||||
|
if !isHTTPURL(p.CustomURL) {
|
||||||
|
return fmt.Errorf("自定义地址需以 http:// 或 https:// 开头")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
p.CustomURL = ""
|
||||||
|
if p.BaseURL == "" {
|
||||||
|
return fmt.Errorf("请输入接口地址")
|
||||||
|
}
|
||||||
|
if !isHTTPURL(p.BaseURL) {
|
||||||
|
return fmt.Errorf("接口地址需以 http:// 或 https:// 开头")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(p.Models) == 0 {
|
||||||
|
return fmt.Errorf("请至少添加一个模型")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增时必须带密钥;编辑时不传表示沿用已存密钥列表
|
||||||
|
if p.ApiKeys != nil {
|
||||||
|
valid := 0
|
||||||
|
for _, k := range *p.ApiKeys {
|
||||||
|
if strings.TrimSpace(k.Key) != "" {
|
||||||
|
valid++
|
||||||
|
}
|
||||||
|
if len([]rune(k.Remark)) > 100 {
|
||||||
|
return fmt.Errorf("密钥备注过长,单条限 100 字")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if valid == 0 {
|
||||||
|
return fmt.Errorf("请至少添加一个 API Key")
|
||||||
|
}
|
||||||
|
} else if isCreate {
|
||||||
|
return fmt.Errorf("请至少添加一个 API Key")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isHTTPURL(s string) bool {
|
||||||
|
low := strings.ToLower(s)
|
||||||
|
return strings.HasPrefix(low, "http://") || strings.HasPrefix(low, "https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// List GET /platform/agentApi/list?page=1&pageSize=24&keyword=&provider=&status=
|
||||||
|
func (c *PlatformAgentApiController) List() {
|
||||||
|
if _, err := c.platformClaims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
page, _ := c.GetInt("page", 1)
|
||||||
|
pageSize, _ := c.GetInt("pageSize", 24)
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize < 1 {
|
||||||
|
pageSize = 24
|
||||||
|
}
|
||||||
|
if pageSize > 200 {
|
||||||
|
pageSize = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
keyword := strings.TrimSpace(c.GetString("keyword"))
|
||||||
|
provider := strings.TrimSpace(c.GetString("provider"))
|
||||||
|
statusStr := strings.TrimSpace(c.GetString("status"))
|
||||||
|
|
||||||
|
qs := models.Orm.QueryTable(new(models.PlatformAgentApi)).Filter("is_deleted", 0)
|
||||||
|
|
||||||
|
cond := orm.NewCondition()
|
||||||
|
needCond := false
|
||||||
|
|
||||||
|
// 上游名称为自由文本,用模糊匹配而非精确相等
|
||||||
|
if provider != "" {
|
||||||
|
cond = cond.And("provider__icontains", provider)
|
||||||
|
needCond = true
|
||||||
|
}
|
||||||
|
if statusStr != "" {
|
||||||
|
if st, err := strconv.Atoi(statusStr); err == nil {
|
||||||
|
cond = cond.And("status", st)
|
||||||
|
needCond = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
kw := orm.NewCondition().
|
||||||
|
Or("provider__icontains", keyword).
|
||||||
|
Or("base_url__icontains", keyword).
|
||||||
|
Or("custom_url__icontains", keyword).
|
||||||
|
Or("models__icontains", keyword).
|
||||||
|
Or("remark__icontains", keyword)
|
||||||
|
cond = cond.AndCond(kw)
|
||||||
|
needCond = true
|
||||||
|
}
|
||||||
|
if needCond {
|
||||||
|
qs = qs.SetCond(cond)
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := qs.Count()
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "查询失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []models.PlatformAgentApi
|
||||||
|
_, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows)
|
||||||
|
if err != nil && err != orm.ErrNoRows {
|
||||||
|
c.jsonErr(500, 500, "查询失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]map[string]interface{}, 0, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
list = append(list, agentApiToMap(&rows[i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ok("success", map[string]interface{}{
|
||||||
|
"list": list,
|
||||||
|
"total": total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail GET /platform/agentApi/:id
|
||||||
|
func (c *PlatformAgentApiController) Detail() {
|
||||||
|
if _, err := c.platformClaims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := c.findByID()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ok("success", agentApiToMap(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// findByID 读取路径参数并查询记录,出错时已写入响应
|
||||||
|
func (c *PlatformAgentApiController) findByID() (*models.PlatformAgentApi, error) {
|
||||||
|
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
c.jsonErr(400, 400, "无效ID")
|
||||||
|
return nil, fmt.Errorf("无效ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var row models.PlatformAgentApi
|
||||||
|
err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
|
||||||
|
Filter("id", id).
|
||||||
|
Filter("is_deleted", 0).
|
||||||
|
One(&row)
|
||||||
|
if err != nil {
|
||||||
|
if err == orm.ErrNoRows {
|
||||||
|
c.jsonErr(404, 404, "配置不存在")
|
||||||
|
} else {
|
||||||
|
c.jsonErr(500, 500, "查询失败: "+err.Error())
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create POST /platform/agentApi
|
||||||
|
func (c *PlatformAgentApiController) Create() {
|
||||||
|
claims, err := c.platformClaims()
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var p agentApiPayload
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateAgentApiPayload(&p, true); err != nil {
|
||||||
|
c.jsonErr(400, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := int8(1)
|
||||||
|
if p.Status != nil {
|
||||||
|
status = *p.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := uint64(claims.UserID)
|
||||||
|
row := &models.PlatformAgentApi{
|
||||||
|
Provider: p.Provider,
|
||||||
|
BaseURL: p.BaseURL,
|
||||||
|
UseCustomURL: p.UseCustomURL,
|
||||||
|
CustomURL: p.CustomURL,
|
||||||
|
ApiKeys: apiKeysToJSON(*p.ApiKeys),
|
||||||
|
Models: modelsToJSON(p.Models),
|
||||||
|
Status: status,
|
||||||
|
Remark: strings.TrimSpace(p.Remark),
|
||||||
|
UserID: &userID,
|
||||||
|
UserName: &claims.Username,
|
||||||
|
IsDeleted: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := models.Orm.Insert(row)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "创建失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
row.ID = uint64(id)
|
||||||
|
|
||||||
|
c.ok("创建成功", agentApiToMap(row))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update PUT /platform/agentApi/:id
|
||||||
|
func (c *PlatformAgentApiController) Update() {
|
||||||
|
if _, err := c.platformClaims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := c.findByID()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var p agentApiPayload
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateAgentApiPayload(&p, false); err != nil {
|
||||||
|
c.jsonErr(400, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"provider": p.Provider,
|
||||||
|
"base_url": p.BaseURL,
|
||||||
|
"use_custom_url": p.UseCustomURL,
|
||||||
|
"custom_url": p.CustomURL,
|
||||||
|
"models": modelsToJSON(p.Models),
|
||||||
|
"remark": strings.TrimSpace(p.Remark),
|
||||||
|
"update_time": now,
|
||||||
|
}
|
||||||
|
if p.Status != nil {
|
||||||
|
updates["status"] = *p.Status
|
||||||
|
}
|
||||||
|
// 未上送 api_keys 表示不修改已存密钥列表
|
||||||
|
if p.ApiKeys != nil {
|
||||||
|
updates["api_keys"] = apiKeysToJSON(*p.ApiKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
|
||||||
|
Filter("id", row.ID).
|
||||||
|
Update(updates)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "更新失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ok("更新成功", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete DELETE /platform/agentApi/:id
|
||||||
|
func (c *PlatformAgentApiController) Delete() {
|
||||||
|
if _, err := c.platformClaims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := c.findByID()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
_, err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
|
||||||
|
Filter("id", row.ID).
|
||||||
|
Update(map[string]interface{}{
|
||||||
|
"is_deleted": 1,
|
||||||
|
"delete_time": now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "删除失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ok("删除成功", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchDelete POST /platform/agentApi/batchDelete
|
||||||
|
func (c *PlatformAgentApiController) BatchDelete() {
|
||||||
|
if _, err := c.platformClaims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var p struct {
|
||||||
|
IDs []uint64 `json:"ids"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(p.IDs) == 0 {
|
||||||
|
c.jsonErr(400, 400, "请选择要删除的配置")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
_, err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
|
||||||
|
Filter("id__in", p.IDs).
|
||||||
|
Filter("is_deleted", 0).
|
||||||
|
Update(map[string]interface{}{
|
||||||
|
"is_deleted": 1,
|
||||||
|
"delete_time": now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "批量删除失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ok("批量删除成功", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToggleStatus POST /platform/agentApi/:id/status
|
||||||
|
func (c *PlatformAgentApiController) ToggleStatus() {
|
||||||
|
if _, err := c.platformClaims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := c.findByID()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var p struct {
|
||||||
|
Status *int8 `json:"status"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil || p.Status == nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if *p.Status != 0 && *p.Status != 1 {
|
||||||
|
c.jsonErr(400, 400, "状态值不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
_, err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
|
||||||
|
Filter("id", row.ID).
|
||||||
|
Update(map[string]interface{}{
|
||||||
|
"status": *p.Status,
|
||||||
|
"update_time": now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(500, 500, "状态切换失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ok("操作成功", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentApiTestResult 测试结果统一结构,与前端 test.vue 字段对应
|
||||||
|
type agentApiTestResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
StatusCode int `json:"status_code,omitempty"`
|
||||||
|
LatencyMs int64 `json:"latency_ms"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
KeyRemark string `json:"key_remark,omitempty"`
|
||||||
|
Response string `json:"response,omitempty"`
|
||||||
|
Detail string `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test POST /platform/agentApi/test
|
||||||
|
// 请求体:{ "id": 1, "model": "gpt-4o", "key_index": 0, "prompt": "你好" }
|
||||||
|
// key_index 指定用密钥列表中的第几个密钥,缺省用第一个
|
||||||
|
func (c *PlatformAgentApiController) Test() {
|
||||||
|
if _, err := c.platformClaims(); err != nil {
|
||||||
|
c.jsonErr(401, 401, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var p struct {
|
||||||
|
ID uint64 `json:"id"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
KeyIndex *int `json:"key_index"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
c.jsonErr(400, 400, "参数错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p.ID == 0 {
|
||||||
|
c.jsonErr(400, 400, "缺少配置ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var row models.PlatformAgentApi
|
||||||
|
err = models.Orm.QueryTable(new(models.PlatformAgentApi)).
|
||||||
|
Filter("id", p.ID).
|
||||||
|
Filter("is_deleted", 0).
|
||||||
|
One(&row)
|
||||||
|
if err != nil {
|
||||||
|
c.jsonErr(404, 404, "配置不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
model := strings.TrimSpace(p.Model)
|
||||||
|
if model == "" {
|
||||||
|
list := modelsFromJSON(row.Models)
|
||||||
|
if len(list) == 0 {
|
||||||
|
c.jsonErr(400, 400, "该配置未添加模型")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model = list[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 挑选待测密钥
|
||||||
|
keys := apiKeysFromJSON(row.ApiKeys)
|
||||||
|
if len(keys) == 0 {
|
||||||
|
c.jsonErr(400, 400, "该配置未添加 API Key")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idx := 0
|
||||||
|
if p.KeyIndex != nil {
|
||||||
|
idx = *p.KeyIndex
|
||||||
|
}
|
||||||
|
if idx < 0 || idx >= len(keys) {
|
||||||
|
c.jsonErr(400, 400, "指定的密钥不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chosen := keys[idx]
|
||||||
|
|
||||||
|
prompt := strings.TrimSpace(p.Prompt)
|
||||||
|
if prompt == "" {
|
||||||
|
prompt = "你好"
|
||||||
|
}
|
||||||
|
|
||||||
|
result := probeAgentApi(&row, chosen, model, prompt)
|
||||||
|
c.ok("success", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeAgentApi 用指定密钥按上游协议族发起一次最小化对话请求
|
||||||
|
func probeAgentApi(
|
||||||
|
row *models.PlatformAgentApi,
|
||||||
|
apiKey models.AgentApiKey,
|
||||||
|
model, prompt string,
|
||||||
|
) agentApiTestResult {
|
||||||
|
base := strings.TrimRight(row.EffectiveURL(), "/")
|
||||||
|
if base == "" {
|
||||||
|
return agentApiTestResult{
|
||||||
|
Success: false, Message: "接口地址为空",
|
||||||
|
Model: model, KeyRemark: apiKey.Remark,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol := detectAgentApiProtocol(row.Provider, base)
|
||||||
|
|
||||||
|
var (
|
||||||
|
endpoint string
|
||||||
|
body []byte
|
||||||
|
headers = map[string]string{"Content-Type": "application/json"}
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
|
switch protocol {
|
||||||
|
case protocolAnthropic:
|
||||||
|
endpoint = base + "/messages"
|
||||||
|
headers["x-api-key"] = apiKey.Key
|
||||||
|
headers["anthropic-version"] = "2023-06-01"
|
||||||
|
body, err = json.Marshal(map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"max_tokens": 64,
|
||||||
|
"messages": []map[string]string{
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
case protocolGemini:
|
||||||
|
endpoint = fmt.Sprintf("%s/models/%s:generateContent?key=%s", base, model, apiKey.Key)
|
||||||
|
body, err = json.Marshal(map[string]interface{}{
|
||||||
|
"contents": []map[string]interface{}{
|
||||||
|
{"parts": []map[string]string{{"text": prompt}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
default:
|
||||||
|
// OpenAI 兼容协议,覆盖大多数官方接口、国产网关与自建中转
|
||||||
|
endpoint = base + "/chat/completions"
|
||||||
|
headers["Authorization"] = "Bearer " + apiKey.Key
|
||||||
|
body, err = json.Marshal(map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"max_tokens": 64,
|
||||||
|
"messages": []map[string]string{
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return agentApiTestResult{
|
||||||
|
Success: false, Message: "构造请求失败", Detail: err.Error(),
|
||||||
|
Model: model, KeyRemark: apiKey.Remark,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return agentApiTestResult{
|
||||||
|
Success: false, Message: "构造请求失败", Detail: err.Error(),
|
||||||
|
Model: model, KeyRemark: apiKey.Remark,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := agentApiHTTPClient.Do(req)
|
||||||
|
latency := time.Since(start).Milliseconds()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return agentApiTestResult{
|
||||||
|
Success: false,
|
||||||
|
Message: "请求上游失败(网络不通或地址错误)",
|
||||||
|
Detail: err.Error(),
|
||||||
|
LatencyMs: latency,
|
||||||
|
Model: model,
|
||||||
|
KeyRemark: apiKey.Remark,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// 限制读取长度,避免超长响应占满内存
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
|
||||||
|
preview := string(respBody)
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return agentApiTestResult{
|
||||||
|
Success: false,
|
||||||
|
Message: fmt.Sprintf("上游返回 HTTP %d", resp.StatusCode),
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
LatencyMs: latency,
|
||||||
|
Model: model,
|
||||||
|
KeyRemark: apiKey.Remark,
|
||||||
|
Detail: preview,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return agentApiTestResult{
|
||||||
|
Success: true,
|
||||||
|
Message: "连接正常,密钥与模型可用",
|
||||||
|
StatusCode: resp.StatusCode,
|
||||||
|
LatencyMs: latency,
|
||||||
|
Model: model,
|
||||||
|
KeyRemark: apiKey.Remark,
|
||||||
|
Response: extractAgentReply(protocol, respBody, preview),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractAgentReply 从上游响应里提取模型回复文本,解析失败则回退为原始预览
|
||||||
|
func extractAgentReply(protocol agentApiProtocol, respBody []byte, fallback string) string {
|
||||||
|
switch protocol {
|
||||||
|
case protocolAnthropic:
|
||||||
|
var r struct {
|
||||||
|
Content []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(respBody, &r) == nil && len(r.Content) > 0 {
|
||||||
|
return r.Content[0].Text
|
||||||
|
}
|
||||||
|
|
||||||
|
case protocolGemini:
|
||||||
|
var r struct {
|
||||||
|
Candidates []struct {
|
||||||
|
Content struct {
|
||||||
|
Parts []struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"parts"`
|
||||||
|
} `json:"content"`
|
||||||
|
} `json:"candidates"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(respBody, &r) == nil &&
|
||||||
|
len(r.Candidates) > 0 && len(r.Candidates[0].Content.Parts) > 0 {
|
||||||
|
return r.Candidates[0].Content.Parts[0].Text
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
var r struct {
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(respBody, &r) == nil && len(r.Choices) > 0 {
|
||||||
|
return r.Choices[0].Message.Content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
@@ -67,6 +67,7 @@ func Init(_ string) {
|
|||||||
new(PlatformAccountPoolCursor),
|
new(PlatformAccountPoolCursor),
|
||||||
new(PlatformAccountPoolCodex),
|
new(PlatformAccountPoolCodex),
|
||||||
new(PlatformNotebook),
|
new(PlatformNotebook),
|
||||||
|
new(PlatformAgentApi),
|
||||||
|
|
||||||
new(CmsArticleCategory),
|
new(CmsArticleCategory),
|
||||||
new(CmsArticle),
|
new(CmsArticle),
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// PlatformAgentApi 智能体API管理表: yz_platform_agent_api
|
||||||
|
// 按上游接口存储调用地址、密钥列表、模型列表
|
||||||
|
// Provider 为用户自由填写的上游名称,不做枚举约束
|
||||||
|
// ApiKeys 存 JSON 数组,支持同一上游下挂多个账号的密钥,每条可带备注
|
||||||
|
type PlatformAgentApi struct {
|
||||||
|
ID uint64 `orm:"column(id);pk;auto" json:"id"`
|
||||||
|
Provider string `orm:"column(provider);size(100)" json:"provider"`
|
||||||
|
BaseURL string `orm:"column(base_url);size(500)" json:"base_url"`
|
||||||
|
UseCustomURL int8 `orm:"column(use_custom_url);default(0)" json:"use_custom_url"`
|
||||||
|
CustomURL string `orm:"column(custom_url);size(500)" json:"custom_url"`
|
||||||
|
ApiKeys string `orm:"column(api_keys);type(text);null" json:"api_keys"`
|
||||||
|
Models string `orm:"column(models);type(text);null" json:"models"`
|
||||||
|
Status int8 `orm:"column(status);default(1)" json:"status"`
|
||||||
|
Remark string `orm:"column(remark);size(500)" json:"remark"`
|
||||||
|
UserID *uint64 `orm:"column(user_id);null" json:"user_id"`
|
||||||
|
UserName *string `orm:"column(user_name);size(100);null" json:"user_name"`
|
||||||
|
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
|
||||||
|
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
|
||||||
|
UpdateTime *time.Time `orm:"column(update_time);type(datetime);null" json:"update_time"`
|
||||||
|
DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *PlatformAgentApi) TableName() string {
|
||||||
|
return "yz_platform_agent_api"
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveURL 返回实际生效的接口地址
|
||||||
|
// 勾选了自定义地址时用 CustomURL,否则用 BaseURL
|
||||||
|
func (m *PlatformAgentApi) EffectiveURL() string {
|
||||||
|
if m.UseCustomURL == 1 && m.CustomURL != "" {
|
||||||
|
return m.CustomURL
|
||||||
|
}
|
||||||
|
return m.BaseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentApiKey 单条密钥:密钥本身 + 可选备注(如账号名 mimo198)
|
||||||
|
type AgentApiKey struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
}
|
||||||
@@ -268,6 +268,15 @@ func Register() {
|
|||||||
beego.Router("/platform/notebook/update/:id", &controllers.PlatformNotebookController{}, "post:Update")
|
beego.Router("/platform/notebook/update/:id", &controllers.PlatformNotebookController{}, "post:Update")
|
||||||
beego.Router("/platform/notebook/delete/:id", &controllers.PlatformNotebookController{}, "delete:Delete")
|
beego.Router("/platform/notebook/delete/:id", &controllers.PlatformNotebookController{}, "delete:Delete")
|
||||||
|
|
||||||
|
// 智能体API管理(yz_platform_agent_api)
|
||||||
|
// 注意:固定路径需先于 /:id 通配路径注册,避免 list/test/batchDelete 被当作 ID 解析
|
||||||
|
beego.Router("/platform/agentApi/list", &controllers.PlatformAgentApiController{}, "get:List")
|
||||||
|
beego.Router("/platform/agentApi/test", &controllers.PlatformAgentApiController{}, "post:Test")
|
||||||
|
beego.Router("/platform/agentApi/batchDelete", &controllers.PlatformAgentApiController{}, "post:BatchDelete")
|
||||||
|
beego.Router("/platform/agentApi", &controllers.PlatformAgentApiController{}, "post:Create")
|
||||||
|
beego.Router("/platform/agentApi/:id/status", &controllers.PlatformAgentApiController{}, "post:ToggleStatus")
|
||||||
|
beego.Router("/platform/agentApi/:id", &controllers.PlatformAgentApiController{}, "get:Detail;put:Update;delete:Delete")
|
||||||
|
|
||||||
// 日程提醒管理
|
// 日程提醒管理
|
||||||
beego.Router("/platform/reminder/list", &controllers.PlatformReminderController{}, "get:GetReminderList")
|
beego.Router("/platform/reminder/list", &controllers.PlatformReminderController{}, "get:GetReminderList")
|
||||||
beego.Router("/platform/reminder/test", &controllers.PlatformReminderController{}, "post:TestReminder")
|
beego.Router("/platform/reminder/test", &controllers.PlatformReminderController{}, "post:TestReminder")
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能体 API 管理模块
|
||||||
|
* 按上游接口(OpenAI / Anthropic / Gemini 等)存储调用地址、Key、模型列表
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 上游接口配置列表 */
|
||||||
|
export function getAgentApiList(params) {
|
||||||
|
return request({
|
||||||
|
url: "/platform/agentApi/list",
|
||||||
|
method: "get",
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上游接口配置详情 */
|
||||||
|
export function getAgentApiDetail(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/agentApi/${id}`,
|
||||||
|
method: "get",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增上游接口配置 */
|
||||||
|
export function createAgentApi(data) {
|
||||||
|
return request({
|
||||||
|
url: "/platform/agentApi",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新上游接口配置 */
|
||||||
|
export function updateAgentApi(id, data) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/agentApi/${id}`,
|
||||||
|
method: "put",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除上游接口配置 */
|
||||||
|
export function deleteAgentApi(id) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/agentApi/${id}`,
|
||||||
|
method: "delete",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量删除 */
|
||||||
|
export function batchDeleteAgentApi(ids) {
|
||||||
|
return request({
|
||||||
|
url: "/platform/agentApi/batchDelete",
|
||||||
|
method: "post",
|
||||||
|
data: { ids },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换启用状态 */
|
||||||
|
export function toggleAgentApiStatus(id, status) {
|
||||||
|
return request({
|
||||||
|
url: `/platform/agentApi/${id}/status`,
|
||||||
|
method: "post",
|
||||||
|
data: { status },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试指定配置下某个模型的连通性 */
|
||||||
|
export function testAgentApiConnection(data) {
|
||||||
|
return request({
|
||||||
|
url: "/platform/agentApi/test",
|
||||||
|
method: "post",
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer v-model="visible" title="上游接口详情" size="580px" destroy-on-close>
|
||||||
|
<div v-if="detail" class="detail-wrap" v-loading="loading">
|
||||||
|
<!-- 顶部标识 -->
|
||||||
|
<div class="detail-head" :style="{ borderLeftColor: providerColor(detail.provider) }">
|
||||||
|
<div class="provider-badge" :style="{ background: providerColor(detail.provider) }">
|
||||||
|
{{ providerInitial(detail.provider) }}
|
||||||
|
</div>
|
||||||
|
<div class="head-text">
|
||||||
|
<div class="head-name">{{ providerLabel(detail.provider) }}</div>
|
||||||
|
<div class="head-provider">
|
||||||
|
{{ apiKeys.length }} 个密钥 · {{ models.length }} 个模型
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-tag :type="Number(detail.status) === 1 ? 'success' : 'info'" size="small">
|
||||||
|
{{ Number(detail.status) === 1 ? '已启用' : '已禁用' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-descriptions :column="1" border class="detail-desc">
|
||||||
|
<el-descriptions-item label="ID" label-width="130px">
|
||||||
|
{{ detail.id }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<el-descriptions-item label="接口地址" label-width="130px">
|
||||||
|
<div class="url-text">{{ detail.base_url || '—' }}</div>
|
||||||
|
<el-tag
|
||||||
|
v-if="Number(detail.use_custom_url) === 1"
|
||||||
|
type="warning"
|
||||||
|
size="small"
|
||||||
|
effect="plain"
|
||||||
|
class="mt6"
|
||||||
|
>
|
||||||
|
已启用自定义地址,实际调用走下方地址
|
||||||
|
</el-tag>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<el-descriptions-item
|
||||||
|
v-if="Number(detail.use_custom_url) === 1"
|
||||||
|
label="自定义地址"
|
||||||
|
label-width="130px"
|
||||||
|
>
|
||||||
|
<div class="url-text">{{ detail.custom_url || '—' }}</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<el-descriptions-item label="API Key" label-width="130px">
|
||||||
|
<div v-if="apiKeys.length > 0" class="key-list">
|
||||||
|
<div v-for="(k, idx) in apiKeys" :key="idx" class="key-item">
|
||||||
|
<div class="key-item-head">
|
||||||
|
<el-tag v-if="k.remark" size="small" type="success" effect="plain">
|
||||||
|
{{ k.remark }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else class="key-no-remark">密钥 {{ idx + 1 }}</span>
|
||||||
|
<el-button size="small" text type="primary" @click="copyModel(k.key)">
|
||||||
|
复制
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-input :model-value="k.key" readonly class="key-input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="placeholder">未配置密钥</span>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<el-descriptions-item label="模型列表" label-width="130px">
|
||||||
|
<div v-if="models.length > 0" class="model-tags">
|
||||||
|
<el-tag
|
||||||
|
v-for="m in models"
|
||||||
|
:key="m"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
effect="plain"
|
||||||
|
class="model-tag"
|
||||||
|
:title="`点击复制:${m}`"
|
||||||
|
@click="copyModel(m)"
|
||||||
|
>
|
||||||
|
{{ m }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<span v-else class="placeholder">未配置模型</span>
|
||||||
|
<div v-if="models.length > 0" class="model-count">
|
||||||
|
共 {{ models.length }} 个模型,点击可复制
|
||||||
|
</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<el-descriptions-item label="备注" label-width="130px">
|
||||||
|
<div class="content-text">{{ detail.remark || '—' }}</div>
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<el-descriptions-item label="创建时间" label-width="130px">
|
||||||
|
{{ detail.create_time || '—' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
|
||||||
|
<el-descriptions-item label="更新时间" label-width="130px">
|
||||||
|
{{ detail.update_time || '—' }}
|
||||||
|
</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="empty-wrap">
|
||||||
|
<el-skeleton :rows="10" animated />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-wrap">
|
||||||
|
<el-empty description="暂无数据" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getAgentApiDetail } from '@/api/agentapimanagement'
|
||||||
|
import {
|
||||||
|
providerLabel,
|
||||||
|
providerColor,
|
||||||
|
providerInitial,
|
||||||
|
normalizeModels,
|
||||||
|
normalizeApiKeys,
|
||||||
|
} from '../constants'
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const detail = ref(null)
|
||||||
|
|
||||||
|
const models = computed(() => normalizeModels(detail.value?.models))
|
||||||
|
const apiKeys = computed(() => normalizeApiKeys(detail.value?.api_keys))
|
||||||
|
|
||||||
|
async function open(id) {
|
||||||
|
detail.value = null
|
||||||
|
visible.value = true
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getAgentApiDetail(id)
|
||||||
|
if (res?.code === 200 && res.data) {
|
||||||
|
detail.value = res.data
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.msg || '加载失败')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复制文本(密钥 / 模型名共用) */
|
||||||
|
async function copyModel(text) {
|
||||||
|
if (!text) {
|
||||||
|
ElMessage.warning('内容为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
ElMessage.success('已复制')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.detail-wrap {
|
||||||
|
padding: 4px 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-left: 4px solid var(--el-color-primary);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.provider-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 19px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-provider {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-text {
|
||||||
|
word-break: break-all;
|
||||||
|
color: #409eff;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt6 {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-item {
|
||||||
|
.key-item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-no-remark {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-input {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
:deep(.el-input__wrapper) {
|
||||||
|
background: #f5f7fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-input__inner) {
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.model-tag {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-count {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-wrap {
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,557 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer
|
||||||
|
v-model="visible"
|
||||||
|
:title="drawerTitle"
|
||||||
|
size="640px"
|
||||||
|
destroy-on-close
|
||||||
|
@closed="onClosed"
|
||||||
|
>
|
||||||
|
<el-alert
|
||||||
|
v-if="isCopy"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
class="copy-tip"
|
||||||
|
title="已带入源配置的全部内容,修改后保存将创建一条新记录,源配置不受影响"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="form"
|
||||||
|
:rules="rules"
|
||||||
|
label-width="110px"
|
||||||
|
v-loading="loading"
|
||||||
|
label-position="right"
|
||||||
|
class="agent-form"
|
||||||
|
>
|
||||||
|
<el-form-item label="上游接口" prop="provider">
|
||||||
|
<el-input
|
||||||
|
v-model="form.provider"
|
||||||
|
placeholder="自行填写,例如:OpenAI 主账号、Claude 中转、公司内部网关"
|
||||||
|
maxlength="100"
|
||||||
|
show-word-limit
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<span class="form-tip">用于区分不同上游来源,内容完全由你决定</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="接口地址" prop="base_url">
|
||||||
|
<el-input
|
||||||
|
v-model="form.base_url"
|
||||||
|
placeholder="例如:https://api.openai.com/v1"
|
||||||
|
:disabled="form.use_custom_url"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<el-checkbox v-model="form.use_custom_url" class="custom-check">
|
||||||
|
使用自定义地址(中转 / 代理 / 私有部署)
|
||||||
|
</el-checkbox>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="form.use_custom_url" label="自定义地址" prop="custom_url">
|
||||||
|
<el-input
|
||||||
|
v-model="form.custom_url"
|
||||||
|
placeholder="例如:https://your-proxy.com/v1"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<span class="form-tip">勾选自定义后,实际调用将使用此地址,上方地址仅作留档</span>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="API Key" prop="api_keys">
|
||||||
|
<div class="keys-box">
|
||||||
|
<div v-if="form.api_keys.length === 0" class="no-key">暂未添加密钥</div>
|
||||||
|
|
||||||
|
<div v-for="(item, idx) in form.api_keys" :key="idx" class="key-row">
|
||||||
|
<div class="key-index">{{ idx + 1 }}</div>
|
||||||
|
<div class="key-fields">
|
||||||
|
<el-input
|
||||||
|
v-model="item.key"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入 API Key"
|
||||||
|
show-password
|
||||||
|
clearable
|
||||||
|
autocomplete="new-password"
|
||||||
|
@blur="validateKeys"
|
||||||
|
/>
|
||||||
|
<el-input
|
||||||
|
v-model="item.remark"
|
||||||
|
placeholder="备注(可选),例如:mimo198、188 号账号"
|
||||||
|
maxlength="100"
|
||||||
|
clearable
|
||||||
|
class="key-remark"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
type="danger"
|
||||||
|
:disabled="form.api_keys.length <= 1"
|
||||||
|
@click="removeKey(idx)"
|
||||||
|
>
|
||||||
|
<el-icon><Delete /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-button size="small" class="add-key-btn" @click="addKey">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
添加密钥
|
||||||
|
</el-button>
|
||||||
|
<span class="form-tip">
|
||||||
|
同一上游下有多个账号时可添加多条密钥,备注用于区分账号,可留空
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="模型列表" prop="models">
|
||||||
|
<div class="models-box">
|
||||||
|
<div v-if="form.models.length > 0" class="model-tags">
|
||||||
|
<el-tag
|
||||||
|
v-for="m in form.models"
|
||||||
|
:key="m"
|
||||||
|
closable
|
||||||
|
size="default"
|
||||||
|
class="model-tag"
|
||||||
|
@close="removeModel(m)"
|
||||||
|
>
|
||||||
|
{{ m }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-else class="no-model">暂未添加模型</div>
|
||||||
|
|
||||||
|
<div class="model-input-row">
|
||||||
|
<el-input
|
||||||
|
v-if="inputVisible"
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="inputValue"
|
||||||
|
size="small"
|
||||||
|
placeholder="输入模型名后回车"
|
||||||
|
class="model-input"
|
||||||
|
@keyup.enter="confirmInput"
|
||||||
|
@blur="confirmInput"
|
||||||
|
/>
|
||||||
|
<el-button v-else size="small" @click="showInput">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
添加模型
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-radio-group v-model="form.status">
|
||||||
|
<el-radio :value="1">启用</el-radio>
|
||||||
|
<el-radio :value="0">禁用</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="备注" prop="remark">
|
||||||
|
<el-input
|
||||||
|
v-model="form.remark"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="可选:用途说明、配额、到期时间等"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="submit">保存</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, nextTick } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Plus, Delete } from '@element-plus/icons-vue'
|
||||||
|
import { getAgentApiDetail, createAgentApi, updateAgentApi } from '@/api/agentapimanagement'
|
||||||
|
import { normalizeModels, normalizeApiKeys } from '../constants'
|
||||||
|
|
||||||
|
const emit = defineEmits(['saved'])
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const isAdd = ref(true)
|
||||||
|
// 复制新建:带入源配置内容,但保存时走新增
|
||||||
|
const isCopy = ref(false)
|
||||||
|
const formRef = ref(null)
|
||||||
|
|
||||||
|
const drawerTitle = computed(() => {
|
||||||
|
if (isCopy.value) return '复制新建上游接口'
|
||||||
|
return isAdd.value ? '新增上游接口' : '编辑上游接口'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 模型 tag 输入
|
||||||
|
const inputVisible = ref(false)
|
||||||
|
const inputValue = ref('')
|
||||||
|
const inputRef = ref(null)
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
id: 0,
|
||||||
|
provider: '',
|
||||||
|
base_url: '',
|
||||||
|
use_custom_url: false,
|
||||||
|
custom_url: '',
|
||||||
|
api_keys: [],
|
||||||
|
models: [],
|
||||||
|
status: 1,
|
||||||
|
remark: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
provider: [{ required: true, message: '请输入上游接口名称', trigger: 'blur' }],
|
||||||
|
base_url: [
|
||||||
|
{
|
||||||
|
validator: (rule, value, callback) => {
|
||||||
|
// 勾选自定义地址时上方地址可为空
|
||||||
|
if (form.use_custom_url) {
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
callback(new Error('请输入接口地址'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\//i.test(value)) {
|
||||||
|
callback(new Error('地址需以 http:// 或 https:// 开头'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
custom_url: [
|
||||||
|
{
|
||||||
|
validator: (rule, value, callback) => {
|
||||||
|
if (!form.use_custom_url) {
|
||||||
|
callback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!value) {
|
||||||
|
callback(new Error('请输入自定义地址'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!/^https?:\/\//i.test(value)) {
|
||||||
|
callback(new Error('地址需以 http:// 或 https:// 开头'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
api_keys: [
|
||||||
|
{
|
||||||
|
validator: (rule, value, callback) => {
|
||||||
|
const filled = (value || []).filter((k) => String(k.key || '').trim() !== '')
|
||||||
|
if (filled.length === 0) {
|
||||||
|
callback(new Error('请至少添加一个 API Key'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 同一配置内不允许重复密钥
|
||||||
|
const keys = filled.map((k) => k.key.trim())
|
||||||
|
if (new Set(keys).size !== keys.length) {
|
||||||
|
callback(new Error('存在重复的 API Key'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
validator: (rule, value, callback) => {
|
||||||
|
if (!value || value.length === 0) {
|
||||||
|
callback(new Error('请至少添加一个模型'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
},
|
||||||
|
trigger: 'change',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示模型输入框
|
||||||
|
async function showInput() {
|
||||||
|
inputVisible.value = true
|
||||||
|
await nextTick()
|
||||||
|
inputRef.value?.focus?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确认添加模型
|
||||||
|
function confirmInput() {
|
||||||
|
const val = inputValue.value.trim()
|
||||||
|
if (val) {
|
||||||
|
if (form.models.includes(val)) {
|
||||||
|
ElMessage.warning('该模型已存在')
|
||||||
|
} else {
|
||||||
|
form.models.push(val)
|
||||||
|
formRef.value?.validateField?.('models')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
inputVisible.value = false
|
||||||
|
inputValue.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除模型
|
||||||
|
function removeModel(m) {
|
||||||
|
const idx = form.models.indexOf(m)
|
||||||
|
if (idx > -1) {
|
||||||
|
form.models.splice(idx, 1)
|
||||||
|
formRef.value?.validateField?.('models')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增一条空密钥
|
||||||
|
function addKey() {
|
||||||
|
form.api_keys.push({ key: '', remark: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除指定密钥(至少保留一条输入行)
|
||||||
|
function removeKey(idx) {
|
||||||
|
if (form.api_keys.length <= 1) return
|
||||||
|
form.api_keys.splice(idx, 1)
|
||||||
|
validateKeys()
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateKeys() {
|
||||||
|
formRef.value?.validateField?.('api_keys')
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.id = 0
|
||||||
|
form.provider = ''
|
||||||
|
form.base_url = ''
|
||||||
|
form.use_custom_url = false
|
||||||
|
form.custom_url = ''
|
||||||
|
// 默认给一条空行,方便直接填写
|
||||||
|
form.api_keys = [{ key: '', remark: '' }]
|
||||||
|
form.models = []
|
||||||
|
form.status = 1
|
||||||
|
form.remark = ''
|
||||||
|
inputVisible.value = false
|
||||||
|
inputValue.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开抽屉
|
||||||
|
* @param {number} [id] 传入 ID 表示编辑,不传表示新增
|
||||||
|
*/
|
||||||
|
async function open(id) {
|
||||||
|
resetForm()
|
||||||
|
isCopy.value = false
|
||||||
|
isAdd.value = !id
|
||||||
|
visible.value = true
|
||||||
|
await nextTick()
|
||||||
|
formRef.value?.clearValidate?.()
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
await loadInto(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制新建:带入源配置的全部内容,但保存时走新增接口
|
||||||
|
* @param {number} id 源配置 ID
|
||||||
|
*/
|
||||||
|
async function openCopy(id) {
|
||||||
|
resetForm()
|
||||||
|
isCopy.value = true
|
||||||
|
isAdd.value = true
|
||||||
|
visible.value = true
|
||||||
|
await nextTick()
|
||||||
|
formRef.value?.clearValidate?.()
|
||||||
|
|
||||||
|
const ok = await loadInto(id)
|
||||||
|
if (ok) {
|
||||||
|
// 不继承源记录的主键,保证保存时创建新记录
|
||||||
|
form.id = 0
|
||||||
|
// 名称加后缀,避免与源配置混淆
|
||||||
|
if (form.provider) {
|
||||||
|
form.provider = `${form.provider} 副本`.slice(0, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取指定配置填入表单,返回是否成功 */
|
||||||
|
async function loadInto(id) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getAgentApiDetail(id)
|
||||||
|
if (res?.code !== 200 || !res.data) {
|
||||||
|
ElMessage.error(res?.msg || '加载失败')
|
||||||
|
visible.value = false
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const d = res.data
|
||||||
|
form.id = d.id
|
||||||
|
form.provider = d.provider || ''
|
||||||
|
form.base_url = d.base_url || ''
|
||||||
|
form.use_custom_url = Number(d.use_custom_url) === 1
|
||||||
|
form.custom_url = d.custom_url || ''
|
||||||
|
const keys = normalizeApiKeys(d.api_keys)
|
||||||
|
form.api_keys = keys.length > 0 ? keys : [{ key: '', remark: '' }]
|
||||||
|
form.models = normalizeModels(d.models)
|
||||||
|
form.status = d.status === undefined ? 1 : Number(d.status)
|
||||||
|
form.remark = d.remark || ''
|
||||||
|
return true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClosed() {
|
||||||
|
resetForm()
|
||||||
|
isCopy.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!formRef.value) return
|
||||||
|
try {
|
||||||
|
await formRef.value.validate()
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
// 过滤掉空行,备注保持原样(允许为空)
|
||||||
|
const cleanedKeys = form.api_keys
|
||||||
|
.map((k) => ({ key: String(k.key || '').trim(), remark: String(k.remark || '').trim() }))
|
||||||
|
.filter((k) => k.key !== '')
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
provider: form.provider.trim(),
|
||||||
|
base_url: form.base_url || '',
|
||||||
|
use_custom_url: form.use_custom_url ? 1 : 0,
|
||||||
|
custom_url: form.use_custom_url ? form.custom_url : '',
|
||||||
|
api_keys: cleanedKeys,
|
||||||
|
models: form.models,
|
||||||
|
status: Number(form.status),
|
||||||
|
remark: form.remark || '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = isAdd.value
|
||||||
|
? await createAgentApi(payload)
|
||||||
|
: await updateAgentApi(form.id, payload)
|
||||||
|
|
||||||
|
if (res?.code === 200) {
|
||||||
|
ElMessage.success(isCopy.value ? '复制新建成功' : isAdd.value ? '新增成功' : '保存成功')
|
||||||
|
visible.value = false
|
||||||
|
emit('saved')
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.msg || '操作失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open, openCopy })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.copy-tip {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-form {
|
||||||
|
padding: 10px 20px 40px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-check {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.keys-box {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-key {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c0c4cc;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.key-index {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
margin-top: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-fields {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-key-btn {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.models-box {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-model {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #c0c4cc;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-input-row {
|
||||||
|
.model-input {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
<template>
|
||||||
|
<el-drawer v-model="visible" title="测试上游接口连通性" size="560px" destroy-on-close>
|
||||||
|
<div v-loading="loading">
|
||||||
|
<el-form label-width="100px" label-position="right" class="test-form">
|
||||||
|
<el-form-item label="上游接口">
|
||||||
|
<el-input v-model="info.provider_label" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="生效地址">
|
||||||
|
<el-input v-model="info.url" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="测试密钥">
|
||||||
|
<el-select v-model="selectedKeyIndex" placeholder="请选择要测试的密钥" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="(k, idx) in apiKeys"
|
||||||
|
:key="idx"
|
||||||
|
:label="keyLabel(k, idx)"
|
||||||
|
:value="idx"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<span class="form-tip">同一上游下有多个账号时,选择要验证的那个密钥</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="测试模型">
|
||||||
|
<el-select
|
||||||
|
v-model="selectedModel"
|
||||||
|
placeholder="请选择要测试的模型"
|
||||||
|
style="width: 100%"
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
>
|
||||||
|
<el-option v-for="m in models" :key="m" :label="m" :value="m" />
|
||||||
|
</el-select>
|
||||||
|
<span class="form-tip">可从已配置模型中选择,也可直接输入其他模型名</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="测试提示">
|
||||||
|
<el-input
|
||||||
|
v-model="prompt"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="发送给模型的提示词"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="batch-row">
|
||||||
|
<el-button size="small" :loading="testing" @click="runTest">
|
||||||
|
<el-icon><Connection /></el-icon>
|
||||||
|
测试选中模型
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
plain
|
||||||
|
:loading="batchTesting"
|
||||||
|
:disabled="models.length === 0"
|
||||||
|
@click="runBatchTest"
|
||||||
|
>
|
||||||
|
<el-icon><Files /></el-icon>
|
||||||
|
测试全部模型({{ models.length }})
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider content-position="left">测试结果</el-divider>
|
||||||
|
|
||||||
|
<div v-if="results.length === 0 && !testing && !batchTesting" class="empty-result">
|
||||||
|
<el-empty description="点击上方按钮开始测试" :image-size="80" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="testing || batchTesting" class="testing">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
|
<span style="margin-left: 8px;">
|
||||||
|
正在测试{{ batchTesting ? `(${results.length}/${models.length})` : '' }},请稍候...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="results.length > 0" class="result-list">
|
||||||
|
<div v-for="(r, idx) in results" :key="idx" class="result-item">
|
||||||
|
<div class="result-head">
|
||||||
|
<el-tag :type="r.success ? 'success' : 'danger'" size="small">
|
||||||
|
{{ r.success ? '成功' : '失败' }}
|
||||||
|
</el-tag>
|
||||||
|
<span class="result-model">{{ r.model }}</span>
|
||||||
|
<el-tag v-if="r.key_remark" size="small" type="info" effect="plain">
|
||||||
|
{{ r.key_remark }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="r.latency_ms != null" class="result-latency">{{ r.latency_ms }} ms</span>
|
||||||
|
</div>
|
||||||
|
<div class="result-msg" :class="{ error: !r.success }">
|
||||||
|
{{ r.message || (r.success ? '连接正常' : '连接异常') }}
|
||||||
|
</div>
|
||||||
|
<pre v-if="r.response" class="result-response">{{ r.response }}</pre>
|
||||||
|
<pre v-if="!r.success && r.detail" class="result-response error">{{ r.detail }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">关闭</el-button>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Loading, Connection, Files } from '@element-plus/icons-vue'
|
||||||
|
import { getAgentApiDetail, testAgentApiConnection } from '@/api/agentapimanagement'
|
||||||
|
import {
|
||||||
|
providerLabel,
|
||||||
|
effectiveUrl,
|
||||||
|
normalizeModels,
|
||||||
|
normalizeApiKeys,
|
||||||
|
maskKey,
|
||||||
|
} from '../constants'
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
const testing = ref(false)
|
||||||
|
const batchTesting = ref(false)
|
||||||
|
|
||||||
|
const currentId = ref(null)
|
||||||
|
const models = ref([])
|
||||||
|
const selectedModel = ref('')
|
||||||
|
const apiKeys = ref([])
|
||||||
|
const selectedKeyIndex = ref(0)
|
||||||
|
const prompt = ref('你好,请用一句话介绍你自己。')
|
||||||
|
const results = ref([])
|
||||||
|
|
||||||
|
const info = reactive({
|
||||||
|
provider_label: '',
|
||||||
|
url: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 密钥下拉的展示文本:有备注显示备注,无备注显示序号 + 脱敏值 */
|
||||||
|
function keyLabel(k, idx) {
|
||||||
|
const masked = maskKey(k.key)
|
||||||
|
return k.remark ? `${k.remark}(${masked})` : `密钥 ${idx + 1}(${masked})`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function open(id) {
|
||||||
|
currentId.value = id
|
||||||
|
results.value = []
|
||||||
|
models.value = []
|
||||||
|
apiKeys.value = []
|
||||||
|
selectedModel.value = ''
|
||||||
|
selectedKeyIndex.value = 0
|
||||||
|
prompt.value = '你好,请用一句话介绍你自己。'
|
||||||
|
visible.value = true
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getAgentApiDetail(id)
|
||||||
|
if (res?.code === 200 && res.data) {
|
||||||
|
const d = res.data
|
||||||
|
info.provider_label = providerLabel(d.provider)
|
||||||
|
info.url = effectiveUrl(d)
|
||||||
|
models.value = normalizeModels(d.models)
|
||||||
|
selectedModel.value = models.value[0] || ''
|
||||||
|
apiKeys.value = normalizeApiKeys(d.api_keys)
|
||||||
|
selectedKeyIndex.value = apiKeys.value.length > 0 ? 0 : -1
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.msg || '加载失败')
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试单个模型,返回统一结构 */
|
||||||
|
async function testOne(model) {
|
||||||
|
try {
|
||||||
|
const res = await testAgentApiConnection({
|
||||||
|
id: currentId.value,
|
||||||
|
model,
|
||||||
|
key_index: selectedKeyIndex.value,
|
||||||
|
prompt: prompt.value || '你好',
|
||||||
|
})
|
||||||
|
if (res?.code === 200 && res.data) {
|
||||||
|
return { model, ...res.data }
|
||||||
|
}
|
||||||
|
return { model, success: false, message: res?.msg || '测试失败' }
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
model,
|
||||||
|
success: false,
|
||||||
|
message: '测试请求异常',
|
||||||
|
detail: err?.message || String(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTest() {
|
||||||
|
if (!selectedModel.value) {
|
||||||
|
ElMessage.warning('请选择要测试的模型')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedKeyIndex.value < 0) {
|
||||||
|
ElMessage.warning('该配置未添加密钥')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testing.value = true
|
||||||
|
results.value = []
|
||||||
|
try {
|
||||||
|
const r = await testOne(selectedModel.value)
|
||||||
|
results.value = [r]
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBatchTest() {
|
||||||
|
if (models.value.length === 0) {
|
||||||
|
ElMessage.warning('该配置未添加模型')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (selectedKeyIndex.value < 0) {
|
||||||
|
ElMessage.warning('该配置未添加密钥')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batchTesting.value = true
|
||||||
|
results.value = []
|
||||||
|
try {
|
||||||
|
// 逐个串行测试,避免同时打满上游限流
|
||||||
|
for (const m of models.value) {
|
||||||
|
const r = await testOne(m)
|
||||||
|
results.value.push(r)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
batchTesting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.test-form {
|
||||||
|
padding: 10px 20px 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.batch-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 0 4px 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-result {
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.testing {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30px 0;
|
||||||
|
color: #409eff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-list {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-item {
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
|
||||||
|
.result-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-model {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-latency {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-msg {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #606266;
|
||||||
|
line-height: 1.5;
|
||||||
|
word-break: break-all;
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-response {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #303133;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: #fef0f0;
|
||||||
|
border-color: #fde2e2;
|
||||||
|
color: #f56c6c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* 智能体 API 管理 - 共享工具
|
||||||
|
*
|
||||||
|
* 上游接口(provider)为用户自由填写的文本,不做枚举限制。
|
||||||
|
* 这里只提供展示辅助(配色、首字母徽标)与数据规整能力。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 徽标配色池,按上游名称哈希稳定取色,保证同一上游每次渲染颜色一致 */
|
||||||
|
const BADGE_COLORS = [
|
||||||
|
'#10a37f',
|
||||||
|
'#d97757',
|
||||||
|
'#4285f4',
|
||||||
|
'#4d6bfe',
|
||||||
|
'#ff6a00',
|
||||||
|
'#615ced',
|
||||||
|
'#3859ff',
|
||||||
|
'#16b98c',
|
||||||
|
'#7c3aed',
|
||||||
|
'#e6465e',
|
||||||
|
'#0ea5e9',
|
||||||
|
'#f59e0b',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 上游名称展示文本 */
|
||||||
|
export function providerLabel(value) {
|
||||||
|
const s = String(value || '').trim();
|
||||||
|
return s || '未填写上游';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由上游名称稳定推导徽标颜色
|
||||||
|
* 用简单字符串哈希取模,纯展示用途
|
||||||
|
*/
|
||||||
|
export function providerColor(value) {
|
||||||
|
const s = String(value || '').trim();
|
||||||
|
if (!s) return '#909399';
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < s.length; i += 1) {
|
||||||
|
hash = (hash * 31 + s.charCodeAt(i)) % 100000;
|
||||||
|
}
|
||||||
|
return BADGE_COLORS[hash % BADGE_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 徽标显示字符:取上游名称首个可见字符
|
||||||
|
* 英文转大写,中文原样显示
|
||||||
|
*/
|
||||||
|
export function providerInitial(value) {
|
||||||
|
const s = String(value || '').trim();
|
||||||
|
if (!s) return '?';
|
||||||
|
return s.charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算实际生效的接口地址
|
||||||
|
* 勾选了自定义地址时用 custom_url,否则用 base_url
|
||||||
|
*/
|
||||||
|
export function effectiveUrl(row) {
|
||||||
|
if (!row) return '';
|
||||||
|
return Number(row.use_custom_url) === 1 ? row.custom_url || '' : row.base_url || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把后端返回的 models 统一规整成字符串数组
|
||||||
|
* 后端可能返回 JSON 字符串、数组或逗号分隔字符串
|
||||||
|
*/
|
||||||
|
export function normalizeModels(models) {
|
||||||
|
if (!models) return [];
|
||||||
|
if (Array.isArray(models)) return models.filter(Boolean).map((m) => String(m).trim());
|
||||||
|
if (typeof models === 'string') {
|
||||||
|
const raw = models.trim();
|
||||||
|
if (!raw) return [];
|
||||||
|
if (raw.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(raw);
|
||||||
|
return Array.isArray(arr) ? arr.filter(Boolean).map((m) => String(m).trim()) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((m) => m.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把后端返回的 api_keys 统一规整成 [{ key, remark }] 数组
|
||||||
|
* 兼容对象数组、纯字符串数组、单个裸密钥字符串
|
||||||
|
*/
|
||||||
|
export function normalizeApiKeys(apiKeys) {
|
||||||
|
if (!apiKeys) return [];
|
||||||
|
|
||||||
|
const toItem = (v) => {
|
||||||
|
if (v && typeof v === 'object') {
|
||||||
|
const key = String(v.key || '').trim();
|
||||||
|
if (!key) return null;
|
||||||
|
return { key, remark: String(v.remark || '').trim() };
|
||||||
|
}
|
||||||
|
const key = String(v || '').trim();
|
||||||
|
return key ? { key, remark: '' } : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(apiKeys)) {
|
||||||
|
return apiKeys.map(toItem).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof apiKeys === 'string') {
|
||||||
|
const raw = apiKeys.trim();
|
||||||
|
if (!raw) return [];
|
||||||
|
if (raw.startsWith('[')) {
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(raw);
|
||||||
|
return Array.isArray(arr) ? arr.map(toItem).filter(Boolean) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [{ key: raw, remark: '' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API Key 脱敏:保留首 6 位与末 4 位 */
|
||||||
|
export function maskKey(key) {
|
||||||
|
if (!key) return '—';
|
||||||
|
const s = String(key);
|
||||||
|
if (s.length <= 12) return '*'.repeat(s.length);
|
||||||
|
return `${s.slice(0, 6)}${'*'.repeat(8)}${s.slice(-4)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,738 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container-box agent-api-page">
|
||||||
|
<div class="header-bar">
|
||||||
|
<h2>智能体 API 管理</h2>
|
||||||
|
<div class="header-actions">
|
||||||
|
<el-button type="primary" @click="editRef.open()">
|
||||||
|
<el-icon><Plus /></el-icon>
|
||||||
|
新增上游接口
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="reload" :loading="loading">
|
||||||
|
<el-icon><Refresh /></el-icon>
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-divider />
|
||||||
|
|
||||||
|
<!-- 搜索筛选 -->
|
||||||
|
<el-form :inline="true" :model="searchForm" class="search-form">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input
|
||||||
|
v-model="searchForm.keyword"
|
||||||
|
placeholder="上游 / 地址 / 模型"
|
||||||
|
clearable
|
||||||
|
style="width: 220px"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="上游接口">
|
||||||
|
<el-input
|
||||||
|
v-model="searchForm.provider"
|
||||||
|
placeholder="上游名称,支持模糊"
|
||||||
|
clearable
|
||||||
|
style="width: 200px"
|
||||||
|
@keyup.enter="handleSearch"
|
||||||
|
@clear="handleSearch"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select
|
||||||
|
v-model="searchForm.status"
|
||||||
|
placeholder="全部"
|
||||||
|
clearable
|
||||||
|
style="width: 130px"
|
||||||
|
@change="handleSearch"
|
||||||
|
>
|
||||||
|
<el-option label="启用" :value="1" />
|
||||||
|
<el-option label="禁用" :value="0" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="handleSearch">
|
||||||
|
<el-icon><Search /></el-icon>
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="resetSearch">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 瀑布流卡片区 -->
|
||||||
|
<div class="waterfall-wrap">
|
||||||
|
<div v-if="list.length > 0" class="waterfall">
|
||||||
|
<div v-for="row in list" :key="row.id" class="waterfall-item">
|
||||||
|
<div class="api-card" :class="{ disabled: Number(row.status) !== 1 }">
|
||||||
|
<!-- 卡片头:上游接口标识 -->
|
||||||
|
<div class="card-head" :style="{ borderTopColor: providerColor(row.provider) }">
|
||||||
|
<div class="provider-badge" :style="{ background: providerColor(row.provider) }">
|
||||||
|
{{ providerInitial(row.provider) }}
|
||||||
|
</div>
|
||||||
|
<div class="head-text">
|
||||||
|
<div class="card-title" :title="row.provider">
|
||||||
|
{{ providerLabel(row.provider) }}
|
||||||
|
</div>
|
||||||
|
<div class="card-subtitle">
|
||||||
|
{{ keysOf(row).length }} 个密钥 · {{ modelsOf(row).length }} 个模型
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, row)">
|
||||||
|
<el-button type="primary" size="small" class="card-action-btn">
|
||||||
|
操作
|
||||||
|
<el-icon class="action-arrow"><ArrowDown /></el-icon>
|
||||||
|
</el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item command="detail">
|
||||||
|
<el-icon><View /></el-icon> 详情
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="test">
|
||||||
|
<el-icon><Connection /></el-icon> 测试连通性
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="edit">
|
||||||
|
<el-icon><Edit /></el-icon> 编辑
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="copy">
|
||||||
|
<el-icon><CopyDocument /></el-icon> 复制新建
|
||||||
|
</el-dropdown-item>
|
||||||
|
<el-dropdown-item command="delete" divided>
|
||||||
|
<el-icon><Delete /></el-icon> 删除
|
||||||
|
</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 接口地址 -->
|
||||||
|
<div class="card-section">
|
||||||
|
<div class="section-label">
|
||||||
|
接口地址
|
||||||
|
<el-tag v-if="Number(row.use_custom_url) === 1" type="warning" size="small" effect="plain">
|
||||||
|
自定义
|
||||||
|
</el-tag>
|
||||||
|
<span class="section-actions">
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="copyText(effectiveUrl(row), '地址')"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="url-text" :title="effectiveUrl(row)">{{ effectiveUrl(row) || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Key 列表:每条独立脱敏/显示/复制 -->
|
||||||
|
<div class="card-section">
|
||||||
|
<div class="section-label">
|
||||||
|
API Key
|
||||||
|
<span class="model-count">{{ keysOf(row).length }} 个</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="keysOf(row).length > 0" class="key-list">
|
||||||
|
<div v-for="(k, idx) in keysOf(row)" :key="idx" class="key-item">
|
||||||
|
<div class="key-item-head">
|
||||||
|
<el-tag v-if="k.remark" size="small" type="success" effect="plain">
|
||||||
|
{{ k.remark }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else class="key-no-remark">密钥 {{ idx + 1 }}</span>
|
||||||
|
<span class="section-actions">
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="toggleKey(`${row.id}-${idx}`)"
|
||||||
|
>
|
||||||
|
{{ revealedKeys.has(`${row.id}-${idx}`) ? '隐藏' : '显示' }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
text
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="copyText(k.key, '密钥')"
|
||||||
|
>
|
||||||
|
复制
|
||||||
|
</el-button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="key-text"
|
||||||
|
:title="revealedKeys.has(`${row.id}-${idx}`) ? k.key : ''"
|
||||||
|
>
|
||||||
|
{{ revealedKeys.has(`${row.id}-${idx}`) ? k.key : maskKey(k.key) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-models">未配置密钥</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 模型列表:点击 tag 复制模型名 -->
|
||||||
|
<div class="card-section">
|
||||||
|
<div class="section-label">
|
||||||
|
模型列表
|
||||||
|
<span class="model-hint">点击可复制</span>
|
||||||
|
<span class="model-count">{{ modelsOf(row).length }} 个</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="modelsOf(row).length > 0" class="model-tags">
|
||||||
|
<el-tag
|
||||||
|
v-for="m in modelsOf(row)"
|
||||||
|
:key="m"
|
||||||
|
size="small"
|
||||||
|
type="info"
|
||||||
|
effect="plain"
|
||||||
|
class="model-tag"
|
||||||
|
:title="`点击复制:${m}`"
|
||||||
|
@click="copyText(m, '模型名')"
|
||||||
|
>
|
||||||
|
{{ m }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-else class="empty-models">未配置模型</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 备注 -->
|
||||||
|
<div v-if="row.remark" class="card-section">
|
||||||
|
<div class="section-label">备注</div>
|
||||||
|
<div class="remark-text">{{ row.remark }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 卡片底部 -->
|
||||||
|
<div class="card-foot">
|
||||||
|
<span class="foot-time">{{ row.create_time || '' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty
|
||||||
|
v-else-if="!loading && !firstLoading"
|
||||||
|
description="暂无上游接口配置,点击右上角新增"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 首屏骨架 -->
|
||||||
|
<div v-if="firstLoading" class="loading-block">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
|
<span>加载中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 滚动加载哨兵:进入视口即拉取下一页 -->
|
||||||
|
<div ref="sentinelRef" class="load-sentinel">
|
||||||
|
<div v-if="loadingMore" class="loading-block">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
|
<span>正在加载更多...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!hasMore && list.length > 0" class="list-end">
|
||||||
|
已加载全部 {{ total }} 条配置
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增/编辑抽屉 -->
|
||||||
|
<AgentApiEdit ref="editRef" @saved="reload" />
|
||||||
|
|
||||||
|
<!-- 详情抽屉 -->
|
||||||
|
<AgentApiDetail ref="detailRef" />
|
||||||
|
|
||||||
|
<!-- 测试抽屉 -->
|
||||||
|
<AgentApiTest ref="testRef" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Refresh,
|
||||||
|
Search,
|
||||||
|
ArrowDown,
|
||||||
|
View,
|
||||||
|
Edit,
|
||||||
|
Delete,
|
||||||
|
Connection,
|
||||||
|
CopyDocument,
|
||||||
|
Loading,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { getAgentApiList, deleteAgentApi } from '@/api/agentapimanagement'
|
||||||
|
import {
|
||||||
|
providerLabel,
|
||||||
|
providerColor,
|
||||||
|
providerInitial,
|
||||||
|
effectiveUrl,
|
||||||
|
normalizeModels,
|
||||||
|
normalizeApiKeys,
|
||||||
|
maskKey,
|
||||||
|
} from './constants'
|
||||||
|
import AgentApiEdit from './components/edit.vue'
|
||||||
|
import AgentApiDetail from './components/detail.vue'
|
||||||
|
import AgentApiTest from './components/test.vue'
|
||||||
|
|
||||||
|
const editRef = ref(null)
|
||||||
|
const detailRef = ref(null)
|
||||||
|
const testRef = ref(null)
|
||||||
|
|
||||||
|
// 滚动加载哨兵元素
|
||||||
|
const sentinelRef = ref(null)
|
||||||
|
let observer = null
|
||||||
|
|
||||||
|
const loading = ref(false) // 任一加载中(首屏或加载更多)
|
||||||
|
const firstLoading = ref(false) // 首屏加载中
|
||||||
|
const loadingMore = ref(false) // 追加下一页中
|
||||||
|
const list = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const hasMore = ref(true)
|
||||||
|
|
||||||
|
// 每页条数与当前页码;滚动到底部时页码 +1 并追加数据
|
||||||
|
const PAGE_SIZE = 24
|
||||||
|
let page = 1
|
||||||
|
|
||||||
|
// 记录已点开明文的密钥,元素为 `${配置ID}-${密钥下标}`
|
||||||
|
const revealedKeys = ref(new Set())
|
||||||
|
|
||||||
|
const searchForm = reactive({
|
||||||
|
keyword: '',
|
||||||
|
provider: undefined,
|
||||||
|
status: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 取该行的模型数组 */
|
||||||
|
function modelsOf(row) {
|
||||||
|
return normalizeModels(row.models)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取该行的密钥数组 */
|
||||||
|
function keysOf(row) {
|
||||||
|
return normalizeApiKeys(row.api_keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换单条密钥的明文/脱敏显示 */
|
||||||
|
function toggleKey(id) {
|
||||||
|
const next = new Set(revealedKeys.value)
|
||||||
|
if (next.has(id)) {
|
||||||
|
next.delete(id)
|
||||||
|
} else {
|
||||||
|
next.add(id)
|
||||||
|
}
|
||||||
|
revealedKeys.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取一页数据
|
||||||
|
* @param {boolean} append true 追加到现有列表(滚动加载),false 替换(首次/搜索)
|
||||||
|
*/
|
||||||
|
async function fetchPage(append = false) {
|
||||||
|
if (loading.value) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
if (append) {
|
||||||
|
loadingMore.value = true
|
||||||
|
} else {
|
||||||
|
firstLoading.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
keyword: searchForm.keyword || undefined,
|
||||||
|
provider: searchForm.provider || undefined,
|
||||||
|
status: searchForm.status,
|
||||||
|
}
|
||||||
|
const res = await getAgentApiList(params)
|
||||||
|
if (res?.code === 200 && res.data) {
|
||||||
|
const rows = res.data.list || []
|
||||||
|
total.value = res.data.total ?? 0
|
||||||
|
list.value = append ? [...list.value, ...rows] : rows
|
||||||
|
// 本页返回不足一页,或已凑满总数,说明没有更多了
|
||||||
|
hasMore.value = rows.length >= PAGE_SIZE && list.value.length < total.value
|
||||||
|
} else {
|
||||||
|
if (!append) list.value = []
|
||||||
|
hasMore.value = false
|
||||||
|
ElMessage.error(res?.msg || '加载失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
hasMore.value = false
|
||||||
|
if (!append) list.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
loadingMore.value = false
|
||||||
|
firstLoading.value = false
|
||||||
|
// 数据渲染后重新观察哨兵,应对"一页填不满视口"的情况
|
||||||
|
await nextTick()
|
||||||
|
observeSentinel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置到第一页重新加载(搜索、刷新、增删改后调用) */
|
||||||
|
function reload() {
|
||||||
|
page = 1
|
||||||
|
hasMore.value = true
|
||||||
|
fetchPage(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 滚动到底部时加载下一页 */
|
||||||
|
function loadMore() {
|
||||||
|
if (loading.value || !hasMore.value) return
|
||||||
|
page += 1
|
||||||
|
fetchPage(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用 IntersectionObserver 监听哨兵元素进入视口 */
|
||||||
|
function observeSentinel() {
|
||||||
|
if (!observer || !sentinelRef.value) return
|
||||||
|
observer.disconnect()
|
||||||
|
if (hasMore.value) {
|
||||||
|
observer.observe(sentinelRef.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSearch() {
|
||||||
|
searchForm.keyword = ''
|
||||||
|
searchForm.provider = undefined
|
||||||
|
searchForm.status = undefined
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卡片下拉操作
|
||||||
|
function handleCommand(cmd, row) {
|
||||||
|
if (cmd === 'detail') {
|
||||||
|
detailRef.value?.open(row.id)
|
||||||
|
} else if (cmd === 'test') {
|
||||||
|
testRef.value?.open(row.id)
|
||||||
|
} else if (cmd === 'edit') {
|
||||||
|
editRef.value?.open(row.id)
|
||||||
|
} else if (cmd === 'copy') {
|
||||||
|
// 复制新建:带入该卡片数据,保存时创建新记录
|
||||||
|
editRef.value?.openCopy(row.id)
|
||||||
|
} else if (cmd === 'delete') {
|
||||||
|
handleDelete(row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(text, label = '地址') {
|
||||||
|
if (!text) {
|
||||||
|
ElMessage.warning(`${label}为空`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
ElMessage.success(`已复制${label}`)
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('复制失败,请手动复制')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
async function handleDelete(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确定删除「${providerLabel(row.provider)}」该上游接口配置吗?`, '提示', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确定删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await deleteAgentApi(row.id)
|
||||||
|
if (res?.code === 200) {
|
||||||
|
ElMessage.success('已删除')
|
||||||
|
reload()
|
||||||
|
} else {
|
||||||
|
ElMessage.error(res?.msg || '删除失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 哨兵进入视口即触发下一页加载;提前 200px 预加载,滚动更顺滑
|
||||||
|
observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries[0]?.isIntersecting) {
|
||||||
|
loadMore()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: '200px' }
|
||||||
|
)
|
||||||
|
reload()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
observer?.disconnect()
|
||||||
|
observer = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="less" scoped>
|
||||||
|
.search-form {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waterfall-wrap {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CSS 多列实现真实瀑布流:卡片高度不一致时自动错落排布 */
|
||||||
|
.waterfall {
|
||||||
|
column-count: 4;
|
||||||
|
column-gap: 16px;
|
||||||
|
|
||||||
|
@media (max-width: 1600px) {
|
||||||
|
column-count: 3;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
column-count: 2;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
column-count: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.waterfall-item {
|
||||||
|
break-inside: avoid;
|
||||||
|
-webkit-column-break-inside: avoid;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-card {
|
||||||
|
background: var(--el-bg-color-overlay);
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: box-shadow 0.25s ease, transform 0.25s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
filter: grayscale(0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
border-top: 3px solid var(--el-color-primary);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
|
||||||
|
.provider-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-subtitle {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-action-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.action-arrow {
|
||||||
|
margin-left: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-section {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px dashed var(--el-border-color-lighter);
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-hint {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-count {
|
||||||
|
margin-left: auto;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-actions,
|
||||||
|
.section-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
padding: 0 2px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-item {
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
.key-item-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-no-remark {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
padding: 0 2px;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-text {
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: Consolas, Monaco, monospace;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
.model-tag {
|
||||||
|
max-width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
background: var(--el-color-primary-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-models {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.remark-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
line-height: 1.5;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-foot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--el-fill-color-lighter);
|
||||||
|
|
||||||
|
.foot-time {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-sentinel {
|
||||||
|
min-height: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-block {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 24px 0;
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-end {
|
||||||
|
padding: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user