From d9a50e23dd745baab3a484d45aba39f6ad107dc9 Mon Sep 17 00:00:00 2001
From: lzq <357099073@qq.com>
Date: Mon, 9 Nov 2026 16:44:11 +0800
Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0ai=E9=85=8D=E7=BD=AE=E5=92=8C?=
=?UTF-8?q?ai=E5=B9=BF=E5=9C=BA=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/src/api/ocr.js | 6 +
backend/src/views/apps/oa/ocr/index.vue | 215 +++
go/controllers/auth/login.go | 44 +
go/controllers/backend_ocr.go | 66 +
go/controllers/platform_ai.go | 1149 +++++++++++++++++
go/controllers/platform_website_setting.go | 16 +-
.../alter_platform_ai_chat_session_top.sql | 4 +
.../alter_platform_ai_provider_api_type.sql | 4 +
go/docs/sql/create_platform_ai_chat.sql | 25 +
go/docs/sql/create_platform_ai_provider.sql | 20 +
go/models/init.go | 4 +
go/models/platform_ai_chat.go | 30 +
go/models/platform_ai_provider.go | 34 +
go/routers/backend/backend.go | 3 +
go/routers/platform/platform.go | 15 +
go/views/auth/login.tpl | 61 +-
platform/src/api/aisettings.js | 66 +
.../aisettings/components/ocrSettings.vue | 178 +++
.../aisettings/components/providerDialog.vue | 207 +++
.../src/views/system/aisettings/index.vue | 288 +++++
.../views/system/aisettings/playground.vue | 964 ++++++++++++++
.../components/loginSettings.vue | 159 +++
.../views/system/platformsettings/index.vue | 6 +
.../content/article/components/preview.vue | 179 ++-
website/src/components/ContentCard.vue | 55 +-
website/src/components/SiteHeader.vue | 59 +
website/src/views/common/DetailPage.vue | 8 +
27 files changed, 3808 insertions(+), 57 deletions(-)
create mode 100644 backend/src/api/ocr.js
create mode 100644 backend/src/views/apps/oa/ocr/index.vue
create mode 100644 go/controllers/backend_ocr.go
create mode 100644 go/controllers/platform_ai.go
create mode 100644 go/docs/sql/alter_platform_ai_chat_session_top.sql
create mode 100644 go/docs/sql/alter_platform_ai_provider_api_type.sql
create mode 100644 go/docs/sql/create_platform_ai_chat.sql
create mode 100644 go/docs/sql/create_platform_ai_provider.sql
create mode 100644 go/models/platform_ai_chat.go
create mode 100644 go/models/platform_ai_provider.go
create mode 100644 platform/src/api/aisettings.js
create mode 100644 platform/src/views/system/aisettings/components/ocrSettings.vue
create mode 100644 platform/src/views/system/aisettings/components/providerDialog.vue
create mode 100644 platform/src/views/system/aisettings/index.vue
create mode 100644 platform/src/views/system/aisettings/playground.vue
create mode 100644 platform/src/views/system/platformsettings/components/loginSettings.vue
diff --git a/backend/src/api/ocr.js b/backend/src/api/ocr.js
new file mode 100644
index 0000000..2377c61
--- /dev/null
+++ b/backend/src/api/ocr.js
@@ -0,0 +1,6 @@
+import request from "@/utils/request";
+
+/** OCR 识别(配置在平台「AI管理 → OCR识别配置」) */
+export function ocrRecognize(data) {
+ return request({ url: "/backend/ocr/recognize", method: "post", data });
+}
diff --git a/backend/src/views/apps/oa/ocr/index.vue b/backend/src/views/apps/oa/ocr/index.vue
new file mode 100644
index 0000000..1b2d2eb
--- /dev/null
+++ b/backend/src/views/apps/oa/ocr/index.vue
@@ -0,0 +1,215 @@
+
+
+
+
+
+
+
+
+
+ 上传图片
+
+
+
+
点击或拖拽图片到此处
+
支持 png / jpg / webp 等常见格式
+
+
+
+
+
![待识别图片]()
+
{{ fileName }}
+
+
+
+
+
+ 开始识别
+
+
+
+
+
+
+ 识别结果
+
+
+
+
+
+
+
+
+
+
+
diff --git a/go/controllers/auth/login.go b/go/controllers/auth/login.go
index c073630..d2aa730 100644
--- a/go/controllers/auth/login.go
+++ b/go/controllers/auth/login.go
@@ -108,6 +108,34 @@ func (c *AuthLoginController) LoginPage() {
c.Data["LoginLogo"] = strings.TrimSpace(brand["login_logo"])
c.Data["LoginBg"] = strings.TrimSpace(brand["login_bg"])
+ // 登录方式开关(平台设置 → 登录方式):值 "0" 关闭,未配置默认开启。
+ // 三种方式全部关闭时禁止登录;只开一种时不显示 tab 直接展示对应面板。
+ loginEnabled := func(tag string) bool {
+ return strings.TrimSpace(brand[tag]) != "0"
+ }
+ pwdOn, smsOn, qrOn := loginEnabled("login_pwd_enabled"), loginEnabled("login_sms_enabled"), loginEnabled("login_qr_enabled")
+ c.Data["LoginPwdOn"] = pwdOn
+ c.Data["LoginSmsOn"] = smsOn
+ c.Data["LoginQrOn"] = qrOn
+ c.Data["LoginDisabled"] = !(pwdOn || smsOn || qrOn)
+ defaultTab := "account"
+ if !pwdOn {
+ if smsOn {
+ defaultTab = "sms"
+ } else {
+ defaultTab = "qr"
+ }
+ }
+ c.Data["LoginDefaultTab"] = defaultTab
+ methodCount := 0
+ for _, on := range []bool{pwdOn, smsOn, qrOn} {
+ if on {
+ methodCount++
+ }
+ }
+ // 只开启一种登录方式时不需要 tab 切换栏
+ c.Data["LoginShowTabs"] = methodCount > 1
+
c.TplName = "auth/login.tpl"
}
@@ -146,6 +174,22 @@ func (c *AuthLoginController) LoginSubmit() {
return
}
+ // 平台设置的登录方式开关兜底:账号密码被关或全部登录方式被关时直接拒绝
+ // (前端已隐藏入口,这里防止绕过页面直接调接口)
+ loginTags := models.GetPlatformSiteTagMap()
+ pwdOff := strings.TrimSpace(loginTags["login_pwd_enabled"]) == "0"
+ allOff := pwdOff &&
+ strings.TrimSpace(loginTags["login_sms_enabled"]) == "0" &&
+ strings.TrimSpace(loginTags["login_qr_enabled"]) == "0"
+ if allOff {
+ c.serveJSON(map[string]interface{}{"code": 403, "msg": "登录功能已关闭,请联系管理员"})
+ return
+ }
+ if pwdOff {
+ c.serveJSON(map[string]interface{}{"code": 403, "msg": "账号密码登录已关闭,请使用其他登录方式"})
+ return
+ }
+
// 开启人机验证时:先极验通过,再走账号密码校验
// 小程序端(yz-miniapp)无法集成极验 SDK 且运行于微信沙箱内,跳过人机验证
if strings.TrimSpace(req.ClientID) != "yz-miniapp" {
diff --git a/go/controllers/backend_ocr.go b/go/controllers/backend_ocr.go
new file mode 100644
index 0000000..0c63afc
--- /dev/null
+++ b/go/controllers/backend_ocr.go
@@ -0,0 +1,66 @@
+package controllers
+
+import (
+ "encoding/json"
+ "io"
+ "strings"
+
+ "server/models"
+ "server/pkg/identity"
+
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// BackendOcrController 租户端 OCR 识别(OA 应用)
+// OCR 配置由平台端「AI管理 → OCR配置」统一维护(yz_platform_site_setting 标签)。
+// 识别实现(视觉模型 / 专用 OCR)后续接入;当前返回配置状态占位。
+type BackendOcrController struct {
+ beego.Controller
+}
+
+func (c *BackendOcrController) jsonErr(httpStatus, bizCode int, msg string) {
+ c.Ctx.Output.SetStatus(httpStatus)
+ c.Data["json"] = map[string]any{"code": bizCode, "msg": msg}
+ _ = c.ServeJSON()
+}
+
+// Recognize POST /backend/ocr/recognize
+// 入参 { image_base64 } 或 { image_url };占位阶段只校验配置并返回状态。
+func (c *BackendOcrController) Recognize() {
+ if _, err := identity.Claims(c.Ctx.Request.Header.Get("Authorization"), identity.EndBackend); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ raw, _ := io.ReadAll(c.Ctx.Request.Body)
+ var req struct {
+ ImageBase64 string `json:"image_base64"`
+ ImageURL string `json:"image_url"`
+ Prompt string `json:"prompt"`
+ }
+ _ = json.Unmarshal(raw, &req)
+ if strings.TrimSpace(req.ImageBase64) == "" && strings.TrimSpace(req.ImageURL) == "" {
+ c.jsonErr(400, 400, "请上传图片")
+ return
+ }
+
+ all := models.GetPlatformSiteTagMap()
+ enabled := strings.TrimSpace(all["ocr_enabled"]) == "1"
+ if !enabled {
+ c.jsonErr(403, 403, "OCR 识别未启用,请联系平台管理员在「AI管理」中开启")
+ return
+ }
+
+ c.Data["json"] = map[string]any{
+ "code": 200,
+ "msg": "success",
+ "data": map[string]any{
+ "recognized": false,
+ "placeholder": true,
+ "message": "OCR 识别服务待接入,平台配置已生效",
+ "provider_id": all["ocr_provider_id"],
+ "model": all["ocr_model"],
+ },
+ }
+ _ = c.ServeJSON()
+}
diff --git a/go/controllers/platform_ai.go b/go/controllers/platform_ai.go
new file mode 100644
index 0000000..3c56e1b
--- /dev/null
+++ b/go/controllers/platform_ai.go
@@ -0,0 +1,1149 @@
+package controllers
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "server/models"
+ "server/pkg/identity"
+
+ "github.com/beego/beego/v2/client/orm"
+ beego "github.com/beego/beego/v2/server/web"
+)
+
+// PlatformAiController 平台�?AI 管理(platform「AI管理」页�?
+// 服务商配置存 yz_platform_ai_provider;OCR 配置暂存 yz_platform_site_setting 标签�?
+type PlatformAiController struct {
+ beego.Controller
+}
+
+func (c *PlatformAiController) jsonErr(httpStatus, bizCode int, msg string) {
+ c.Ctx.Output.SetStatus(httpStatus)
+ c.Data["json"] = map[string]any{"code": bizCode, "msg": msg}
+ _ = c.ServeJSON()
+}
+
+func maskKey(key string) string {
+ key = strings.TrimSpace(key)
+ if len(key) <= 8 {
+ if key == "" {
+ return ""
+ }
+ return "***"
+ }
+ return key[:4] + "***" + key[len(key)-4:]
+}
+
+func fillProvider(m *models.PlatformAiProvider) {
+ if m.Models != "" {
+ // 兼容两种存储格式:[{"id","name"}] 对象数组 �?["model-id"] 字符串数�?
+ var raw []json.RawMessage
+ if err := json.Unmarshal([]byte(m.Models), &raw); err == nil {
+ for _, r := range raw {
+ var item models.AiModelItem
+ if err := json.Unmarshal(r, &item); err == nil && item.ID != "" {
+ m.ModelsList = append(m.ModelsList, item)
+ continue
+ }
+ var s string
+ if err := json.Unmarshal(r, &s); err == nil && s != "" {
+ m.ModelsList = append(m.ModelsList, models.AiModelItem{ID: s, Name: s})
+ }
+ }
+ }
+ }
+ m.ApiKeyMasked = maskKey(m.ApiKey)
+ m.ApiKey = ""
+}
+
+// List GET /platform/ai/provider/list?provider_type=
+func (c *PlatformAiController) List() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ ptype := strings.TrimSpace(c.GetString("provider_type"))
+ qs := models.Orm.QueryTable(new(models.PlatformAiProvider)).
+ Filter("delete_time__isnull", true)
+ if ptype != "" {
+ qs = qs.Filter("provider_type", ptype)
+ }
+ var rows []models.PlatformAiProvider
+ _, _ = qs.OrderBy("-is_default", "-id").All(&rows)
+ for i := range rows {
+ fillProvider(&rows[i])
+ }
+ c.Data["json"] = map[string]any{"code": 200, "msg": "success", "data": rows}
+ _ = c.ServeJSON()
+}
+
+type platformAiProviderPayload struct {
+ ID uint64 `json:"id"`
+ ProviderType string `json:"provider_type"`
+ ApiType string `json:"api_type"`
+ Name string `json:"name"`
+ ApiBase string `json:"api_base"`
+ ApiKey string `json:"api_key"`
+ Models []models.AiModelItem `json:"models"`
+ DefaultModel string `json:"default_model"`
+ IsDefault int8 `json:"is_default"`
+ Status int8 `json:"status"`
+ Remark string `json:"remark"`
+}
+
+func readProviderPayload(c *PlatformAiController) (*platformAiProviderPayload, bool) {
+ raw, err := io.ReadAll(c.Ctx.Request.Body)
+ if err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return nil, false
+ }
+ var p platformAiProviderPayload
+ if err := json.Unmarshal(raw, &p); err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return nil, false
+ }
+ p.ProviderType = strings.TrimSpace(p.ProviderType)
+ p.ApiType = strings.TrimSpace(p.ApiType)
+ if p.ApiType == "" {
+ p.ApiType = "chat_completions"
+ }
+ switch p.ApiType {
+ case "chat_completions", "responses", "anthropic":
+ default:
+ c.jsonErr(400, 400, "不支持的协议类型")
+ return nil, false
+ }
+ p.Name = strings.TrimSpace(p.Name)
+ p.ApiBase = strings.TrimRight(strings.TrimSpace(p.ApiBase), "/")
+ if p.ProviderType == "" || p.Name == "" || p.ApiBase == "" {
+ c.jsonErr(400, 400, "服务商类型、名称、API基址不能为空")
+ return nil, false
+ }
+ return &p, true
+}
+
+func (p *platformAiProviderPayload) toModel() *models.PlatformAiProvider {
+ modelsJSON, _ := json.Marshal(p.Models)
+ return &models.PlatformAiProvider{
+ ID: p.ID, ProviderType: p.ProviderType, ApiType: p.ApiType, Name: p.Name, ApiBase: p.ApiBase,
+ ApiKey: p.ApiKey, Models: string(modelsJSON), DefaultModel: strings.TrimSpace(p.DefaultModel),
+ IsDefault: p.IsDefault, Status: p.Status, Remark: strings.TrimSpace(p.Remark),
+ }
+}
+
+// Create POST /platform/ai/provider
+func (c *PlatformAiController) Create() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ p, ok := readProviderPayload(c)
+ if !ok {
+ return
+ }
+ m := p.toModel()
+ if m.Status != 0 {
+ m.Status = 1
+ }
+ if p.IsDefault == 1 {
+ _, _ = models.Orm.QueryTable(new(models.PlatformAiProvider)).
+ Filter("delete_time__isnull", true).Update(map[string]any{"is_default": 0})
+ }
+ id, err := models.Orm.Insert(m)
+ if err != nil {
+ c.jsonErr(500, 500, "保存失败: "+err.Error())
+ return
+ }
+ c.Data["json"] = map[string]any{"code": 200, "msg": "保存成功", "data": map[string]any{"id": id}}
+ _ = c.ServeJSON()
+}
+
+// Update PUT /platform/ai/provider/:id
+func (c *PlatformAiController) Update() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ id, _ := c.GetInt64(":id", 0)
+ if id <= 0 {
+ c.jsonErr(400, 400, "无效ID")
+ return
+ }
+ p, ok := readProviderPayload(c)
+ if !ok {
+ return
+ }
+ m := p.toModel()
+ // api_key 传空表示不修�?
+ update := map[string]any{
+ "provider_type": m.ProviderType, "api_type": m.ApiType, "name": m.Name, "api_base": m.ApiBase,
+ "models": m.Models, "default_model": m.DefaultModel,
+ "is_default": m.IsDefault, "status": m.Status, "remark": m.Remark,
+ }
+ if strings.TrimSpace(p.ApiKey) != "" {
+ update["api_key"] = p.ApiKey
+ }
+ if p.IsDefault == 1 {
+ _, _ = models.Orm.QueryTable(new(models.PlatformAiProvider)).
+ Filter("delete_time__isnull", true).Filter("id__ne", id).
+ Update(map[string]any{"is_default": 0})
+ }
+ n, err := models.Orm.QueryTable(new(models.PlatformAiProvider)).
+ Filter("id", id).Filter("delete_time__isnull", true).Update(update)
+ if err != nil {
+ c.jsonErr(500, 500, "保存失败: "+err.Error())
+ return
+ }
+ if n == 0 {
+ c.jsonErr(404, 404, "配置不存在")
+ return
+ }
+ c.Data["json"] = map[string]any{"code": 200, "msg": "保存成功"}
+ _ = c.ServeJSON()
+}
+
+// Delete DELETE /platform/ai/provider/:id
+func (c *PlatformAiController) Delete() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ id, _ := c.GetInt64(":id", 0)
+ n, err := models.Orm.QueryTable(new(models.PlatformAiProvider)).
+ Filter("id", id).Filter("delete_time__isnull", true).
+ Update(map[string]any{"delete_time": time.Now()})
+ if err != nil {
+ c.jsonErr(500, 500, "删除失败: "+err.Error())
+ return
+ }
+ if n == 0 {
+ c.jsonErr(404, 404, "配置不存在")
+ return
+ }
+ c.Data["json"] = map[string]any{"code": 200, "msg": "删除成功"}
+ _ = c.ServeJSON()
+}
+
+// loadProvider 取一条启用中的服务商配置(优先指�?id,否则平台默认)
+func loadPlatformProvider(id uint64) (*models.PlatformAiProvider, error) {
+ var m models.PlatformAiProvider
+ qs := models.Orm.QueryTable(new(models.PlatformAiProvider)).
+ Filter("delete_time__isnull", true).Filter("status", 1)
+ if id > 0 {
+ err := qs.Filter("id", id).One(&m)
+ return &m, err
+ }
+ err := qs.Filter("is_default", 1).One(&m)
+ if err != nil {
+ err = models.Orm.QueryTable(new(models.PlatformAiProvider)).
+ Filter("delete_time__isnull", true).Filter("status", 1).
+ OrderBy("-id").One(&m)
+ }
+ return &m, err
+}
+
+// chatCompletion 按服务商协议类型分发调用,返回助手回复文�?
+func chatCompletion(provider *models.PlatformAiProvider, model string, messages []map[string]string) (string, error) {
+ switch provider.ApiType {
+ case "responses":
+ return chatResponses(provider, model, messages)
+ case "anthropic":
+ return chatAnthropic(provider, model, messages)
+ default:
+ return chatCompletions(provider, model, messages)
+ }
+}
+
+func doAIRequest(req *http.Request, apiKey string) ([]byte, int, error) {
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ client := &http.Client{Timeout: 90 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, 0, err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ return body, resp.StatusCode, nil
+}
+
+// chatCompletions OpenAI Chat Completions:POST {api_base}/chat/completions
+func chatCompletions(provider *models.PlatformAiProvider, model string, messages []map[string]string) (string, error) {
+ payload := map[string]any{"model": model, "messages": messages, "stream": false}
+ body, _ := json.Marshal(payload)
+ req, err := http.NewRequest("POST", provider.ApiBase+"/chat/completions", bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ respBody, status, err := doAIRequest(req, provider.ApiKey)
+ if err != nil {
+ return "", err
+ }
+ if status != 200 {
+ return "", errUpstream(string(respBody))
+ }
+ var out struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(respBody, &out); err != nil {
+ return "", err
+ }
+ if out.Error != nil && out.Error.Message != "" {
+ return "", errUpstream(out.Error.Message)
+ }
+ if len(out.Choices) == 0 {
+ return "", errUpstream("上游返回为空")
+ }
+ return out.Choices[0].Message.Content, nil
+}
+
+// chatResponses OpenAI Responses:POST {api_base}/responses
+// input 直接复用 messages 数组(role/content 结构兼容�?
+func chatResponses(provider *models.PlatformAiProvider, model string, messages []map[string]string) (string, error) {
+ payload := map[string]any{"model": model, "input": messages}
+ body, _ := json.Marshal(payload)
+ req, err := http.NewRequest("POST", provider.ApiBase+"/responses", bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ respBody, status, err := doAIRequest(req, provider.ApiKey)
+ if err != nil {
+ return "", err
+ }
+ if status != 200 {
+ return "", errUpstream(string(respBody))
+ }
+ var out struct {
+ OutputText string `json:"output_text"`
+ Output []struct {
+ Type string `json:"type"`
+ Content []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"content"`
+ } `json:"output"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(respBody, &out); err != nil {
+ return "", err
+ }
+ if out.Error != nil && out.Error.Message != "" {
+ return "", errUpstream(out.Error.Message)
+ }
+ if out.OutputText != "" {
+ return out.OutputText, nil
+ }
+ for _, item := range out.Output {
+ for _, c := range item.Content {
+ if c.Text != "" {
+ return c.Text, nil
+ }
+ }
+ }
+ return "", errUpstream("上游返回为空")
+}
+
+// chatAnthropic Anthropic Messages:POST {api_base}/v1/messages
+// system 消息提取到顶�?system 字段;x-api-key + anthropic-version 鉴权
+func chatAnthropic(provider *models.PlatformAiProvider, model string, messages []map[string]string) (string, error) {
+ var systemParts []string
+ var msgs []map[string]string
+ for _, m := range messages {
+ role := m["role"]
+ if role == "system" {
+ systemParts = append(systemParts, m["content"])
+ continue
+ }
+ if role == "assistant" {
+ role = "assistant"
+ } else {
+ role = "user"
+ }
+ msgs = append(msgs, map[string]string{"role": role, "content": m["content"]})
+ }
+ payload := map[string]any{
+ "model": model,
+ "max_tokens": 4096,
+ "messages": msgs,
+ }
+ if len(systemParts) > 0 {
+ payload["system"] = strings.Join(systemParts, "\n")
+ }
+ body, _ := json.Marshal(payload)
+
+ base := strings.TrimRight(provider.ApiBase, "/")
+ if !strings.HasSuffix(base, "/v1") {
+ base += "/v1"
+ }
+ req, err := http.NewRequest("POST", base+"/messages", bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("x-api-key", provider.ApiKey)
+ req.Header.Set("anthropic-version", "2023-06-01")
+ req.Header.Set("Content-Type", "application/json")
+
+ client := &http.Client{Timeout: 90 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+ respBody, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != 200 {
+ return "", errUpstream(string(respBody))
+ }
+ var out struct {
+ Content []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"content"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(respBody, &out); err != nil {
+ return "", err
+ }
+ if out.Error != nil && out.Error.Message != "" {
+ return "", errUpstream(out.Error.Message)
+ }
+ for _, c := range out.Content {
+ if c.Text != "" {
+ return c.Text, nil
+ }
+ }
+ return "", errUpstream("上游返回为空")
+}
+
+type upstreamError string
+
+func (e upstreamError) Error() string { return "AI服务调用失败: " + string(e) }
+func errUpstream(msg string) error { return upstreamError(msg) }
+
+// Test POST /platform/ai/provider/test { id } �?{ provider_id, model }
+// 用一�?ping 验证连通�?
+func (c *PlatformAiController) Test() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ var req struct {
+ ID uint64 `json:"id"`
+ Model string `json:"model"`
+ }
+ raw, _ := io.ReadAll(c.Ctx.Request.Body)
+ _ = json.Unmarshal(raw, &req)
+
+ provider, err := loadPlatformProvider(req.ID)
+ if err != nil {
+ c.jsonErr(400, 400, "服务商配置不存在或未启用")
+ return
+ }
+ model := strings.TrimSpace(req.Model)
+ if model == "" {
+ model = provider.DefaultModel
+ }
+ if model == "" {
+ var list []models.AiModelItem
+ _ = json.Unmarshal([]byte(provider.Models), &list)
+ if len(list) > 0 {
+ model = list[0].ID
+ }
+ }
+ if model == "" {
+ c.jsonErr(400, 400, "未配置可用模型")
+ return
+ }
+ start := time.Now()
+ content, err := chatCompletion(provider, model, []map[string]string{
+ {"role": "user", "content": "请只回复两个字:正常"},
+ })
+ if err != nil {
+ c.jsonErr(502, 502, err.Error())
+ return
+ }
+ c.Data["json"] = map[string]any{
+ "code": 200, "msg": "连接成功",
+ "data": map[string]any{
+ "latency_ms": int(time.Since(start).Milliseconds()),
+ "reply": content,
+ "model": model,
+ },
+ }
+ _ = c.ServeJSON()
+}
+
+// Chat POST /platform/ai/chat { provider_id, model, messages:[{role,content}] }
+// Playground 对话代理:平台管理员�?AI管理页测试各服务�?
+func (c *PlatformAiController) Chat() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ var req struct {
+ ProviderID uint64 `json:"provider_id"`
+ Model string `json:"model"`
+ Messages []map[string]string `json:"messages"`
+ }
+ raw, _ := io.ReadAll(c.Ctx.Request.Body)
+ if err := json.Unmarshal(raw, &req); err != nil || len(req.Messages) == 0 {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ provider, err := loadPlatformProvider(req.ProviderID)
+ if err != nil {
+ c.jsonErr(400, 400, "服务商配置不存在或未启用")
+ return
+ }
+ model := strings.TrimSpace(req.Model)
+ if model == "" {
+ model = provider.DefaultModel
+ }
+ if model == "" {
+ c.jsonErr(400, 400, "未选择模型")
+ return
+ }
+ // 只保�?role/content,避免把多余字段透传给上�?
+ clean := make([]map[string]string, 0, len(req.Messages))
+ for _, m := range req.Messages {
+ role := strings.TrimSpace(m["role"])
+ if role != "user" && role != "assistant" && role != "system" {
+ continue
+ }
+ clean = append(clean, map[string]string{"role": role, "content": m["content"]})
+ }
+ if len(clean) == 0 {
+ c.jsonErr(400, 400, "消息为空")
+ return
+ }
+ content, err := chatCompletion(provider, model, clean)
+ if err != nil {
+ c.jsonErr(502, 502, err.Error())
+ return
+ }
+ c.Data["json"] = map[string]any{
+ "code": 200, "msg": "success",
+ "data": map[string]any{"content": content, "model": model},
+ }
+ _ = c.ServeJSON()
+}
+
+// OCR 配置标签(存 yz_platform_site_setting,OCR 识别实现后续接入�?
+var ocrConfigTags = []string{"ocr_enabled", "ocr_provider_id", "ocr_model", "ocr_prompt"}
+
+// OcrConfig GET /platform/ai/ocrConfig
+func (c *PlatformAiController) OcrConfig() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ all := models.GetPlatformSiteTagMap()
+ data := make(map[string]string, len(ocrConfigTags))
+ for _, tag := range ocrConfigTags {
+ data[tag] = all[tag]
+ }
+ c.Data["json"] = map[string]any{"code": 200, "msg": "success", "data": data}
+ _ = c.ServeJSON()
+}
+
+// SaveOcrConfig POST /platform/ai/ocrConfig { ocr_enabled, ocr_provider_id, ocr_model, ocr_prompt }
+func (c *PlatformAiController) SaveOcrConfig() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ raw, err := io.ReadAll(c.Ctx.Request.Body)
+ if err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ var payload map[string]string
+ if err := json.Unmarshal(raw, &payload); err != nil {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ values := make(map[string]string, len(ocrConfigTags))
+ for _, tag := range ocrConfigTags {
+ if v, ok := payload[tag]; ok {
+ values[tag] = strings.TrimSpace(v)
+ }
+ }
+ if len(values) == 0 {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ if err := models.SavePlatformSiteSettings(values); err != nil {
+ c.jsonErr(500, 500, "保存失败: "+err.Error())
+ return
+ }
+ c.Data["json"] = map[string]any{"code": 200, "msg": "保存成功"}
+ _ = c.ServeJSON()
+}
+
+// RecognizeOcr POST /platform/ai/ocr/recognize —�?OCR 识别占位接口
+// 具体实现(视觉模�?/ 专用 OCR 服务)后续接入;当前返回配置状态供前端联调�?
+func (c *PlatformAiController) RecognizeOcr() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ all := models.GetPlatformSiteTagMap()
+ enabled := strings.TrimSpace(all["ocr_enabled"]) == "1"
+ c.Data["json"] = map[string]any{
+ "code": 200,
+ "msg": "success",
+ "data": map[string]any{
+ "enabled": enabled,
+ "provider_id": all["ocr_provider_id"],
+ "model": all["ocr_model"],
+ "recognized": false,
+ "message": "OCR 识别服务待接入,配置已保存",
+ "placeholder": true,
+ },
+ }
+ _ = c.ServeJSON()
+}
+
+// ==================== 流式对话(Playground 打字机效果)====================
+
+// 归一�?SSE 事件:{"t":"delta"|"think"|"done"|"error","c":"..."}
+func writeStreamEvent(w http.ResponseWriter, flusher http.Flusher, ev map[string]any) {
+ b, _ := json.Marshal(ev)
+ fmt.Fprintf(w, "data: %s\n\n", b)
+ if flusher != nil {
+ flusher.Flush()
+ }
+}
+
+// streamHTTPRequest 构造带 stream:true 的上游请求,返回 *http.Request 与鉴权头已设置的 client 请求
+// messages 的 content 支持字符串或多模态内容块数组(附件直传大模型,不落库)
+func buildStreamRequest(provider *models.PlatformAiProvider, model string, messages []map[string]any) (*http.Request, error) {
+ var url string
+ var payload map[string]any
+
+ switch provider.ApiType {
+ case "responses":
+ url = provider.ApiBase + "/responses"
+ payload = map[string]any{"model": model, "input": messages, "stream": true}
+ case "anthropic":
+ base := strings.TrimRight(provider.ApiBase, "/")
+ if !strings.HasSuffix(base, "/v1") {
+ base += "/v1"
+ }
+ url = base + "/messages"
+ var systemParts []string
+ var msgs []map[string]any
+ for _, m := range messages {
+ role, _ := m["role"].(string)
+ if role == "system" {
+ if s, ok := m["content"].(string); ok {
+ systemParts = append(systemParts, s)
+ }
+ continue
+ }
+ if role != "assistant" {
+ role = "user"
+ }
+ msgs = append(msgs, map[string]any{"role": role, "content": anthropicContent(m["content"])})
+ }
+ payload = map[string]any{"model": model, "max_tokens": 4096, "messages": msgs, "stream": true}
+ if len(systemParts) > 0 {
+ payload["system"] = strings.Join(systemParts, "\n")
+ }
+ default:
+ url = provider.ApiBase + "/chat/completions"
+ payload = map[string]any{"model": model, "messages": messages, "stream": true}
+ }
+
+ body, _ := json.Marshal(payload)
+ req, err := http.NewRequest("POST", url, bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if provider.ApiType == "anthropic" {
+ req.Header.Set("x-api-key", provider.ApiKey)
+ req.Header.Set("anthropic-version", "2023-06-01")
+ } else {
+ req.Header.Set("Authorization", "Bearer "+provider.ApiKey)
+ }
+ return req, nil
+}
+
+// normalizeStreamData 把上�?SSE data 负载转成归一化事件列�?
+// 返回 (events, isDone, isError)
+// anthropicContent 把 OpenAI 风格的 message content 转成 Anthropic content blocks
+// 字符串 → [{type:text}];数组 → text 块 + base64 图片块(data URL 拆分)
+func anthropicContent(content any) []map[string]any {
+ if s, ok := content.(string); ok {
+ return []map[string]any{{"type": "text", "text": s}}
+ }
+ arr, ok := content.([]any)
+ if !ok {
+ return []map[string]any{{"type": "text", "text": ""}}
+ }
+ var blocks []map[string]any
+ for _, item := range arr {
+ m, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ switch m["type"] {
+ case "text":
+ if t, ok := m["text"].(string); ok {
+ blocks = append(blocks, map[string]any{"type": "text", "text": t})
+ }
+ case "image_url":
+ if iu, ok := m["image_url"].(map[string]any); ok {
+ if u, ok := iu["url"].(string); ok {
+ if media, data, ok := parseDataURL(u); ok {
+ blocks = append(blocks, map[string]any{
+ "type": "image",
+ "source": map[string]any{
+ "type": "base64",
+ "media_type": media,
+ "data": data,
+ },
+ })
+ }
+ }
+ }
+ }
+ }
+ if len(blocks) == 0 {
+ blocks = append(blocks, map[string]any{"type": "text", "text": ""})
+ }
+ return blocks
+}
+
+// parseDataURL 解析 data:[mediatype];base64,xxxx
+func parseDataURL(u string) (media, data string, ok bool) {
+ if !strings.HasPrefix(u, "data:") {
+ return "", "", false
+ }
+ rest := strings.TrimPrefix(u, "data:")
+ idx := strings.Index(rest, ";base64,")
+ if idx < 0 {
+ return "", "", false
+ }
+ return rest[:idx], rest[idx+len(";base64,"):], true
+}
+
+func normalizeStreamData(apiType string, data string) ([]map[string]any, bool) {
+ data = strings.TrimSpace(data)
+ if data == "" || data == "[DONE]" {
+ return nil, data == "[DONE]"
+ }
+ var raw map[string]any
+ if err := json.Unmarshal([]byte(data), &raw); err != nil {
+ return nil, false
+ }
+
+ switch apiType {
+ case "responses":
+ typ, _ := raw["type"].(string)
+ switch typ {
+ case "response.output_text.delta":
+ if d, ok := raw["delta"].(string); ok && d != "" {
+ return []map[string]any{{"t": "delta", "c": d}}, false
+ }
+ case "response.reasoning_summary_text.delta", "response.reasoning_text.delta":
+ if d, ok := raw["delta"].(string); ok && d != "" {
+ return []map[string]any{{"t": "think", "c": d}}, false
+ }
+ case "response.completed":
+ return nil, true
+ case "response.failed", "error":
+ msg := "上游返回错误"
+ if e, ok := raw["error"].(map[string]any); ok {
+ if m, ok := e["message"].(string); ok {
+ msg = m
+ }
+ }
+ if r, ok := raw["response"].(map[string]any); ok {
+ if e, ok := r["error"].(map[string]any); ok {
+ if m, ok := e["message"].(string); ok {
+ msg = m
+ }
+ }
+ }
+ return []map[string]any{{"t": "error", "c": msg}}, true
+ }
+ case "anthropic":
+ typ, _ := raw["type"].(string)
+ switch typ {
+ case "content_block_delta":
+ if d, ok := raw["delta"].(map[string]any); ok {
+ dt, _ := d["type"].(string)
+ if dt == "text_delta" {
+ if t, ok := d["text"].(string); ok && t != "" {
+ return []map[string]any{{"t": "delta", "c": t}}, false
+ }
+ }
+ if dt == "thinking_delta" {
+ if t, ok := d["thinking"].(string); ok && t != "" {
+ return []map[string]any{{"t": "think", "c": t}}, false
+ }
+ }
+ }
+ case "message_stop":
+ return nil, true
+ case "error":
+ msg := "上游返回错误"
+ if e, ok := raw["error"].(map[string]any); ok {
+ if m, ok := e["message"].(string); ok {
+ msg = m
+ }
+ }
+ return []map[string]any{{"t": "error", "c": msg}}, true
+ }
+ default: // chat_completions
+ choices, _ := raw["choices"].([]any)
+ if len(choices) > 0 {
+ if ch, ok := choices[0].(map[string]any); ok {
+ if delta, ok := ch["delta"].(map[string]any); ok {
+ var evs []map[string]any
+ if t, ok := delta["reasoning_content"].(string); ok && t != "" {
+ evs = append(evs, map[string]any{"t": "think", "c": t})
+ }
+ if t, ok := delta["content"].(string); ok && t != "" {
+ evs = append(evs, map[string]any{"t": "delta", "c": t})
+ }
+ return evs, false
+ }
+ }
+ }
+ if e, ok := raw["error"].(map[string]any); ok {
+ if m, ok := e["message"].(string); ok {
+ return []map[string]any{{"t": "error", "c": m}}, true
+ }
+ }
+ }
+ return nil, false
+}
+
+// ChatStream POST /platform/ai/chat/stream
+// SSE 代理:把三种上游协议的流式响应归一化为 {"t":"delta|think|done|error","c":"..."} 转发给前�?
+func (c *PlatformAiController) ChatStream() {
+ defer c.StopRun() // 阻止 Beego 在 handler 返回后继续模板渲染
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ code := identity.Status(err)
+ c.jsonErr(code, code, err.Error())
+ return
+ }
+ var req struct {
+ ProviderID uint64 `json:"provider_id"`
+ Model string `json:"model"`
+ Messages []map[string]any `json:"messages"`
+ SessionID uint64 `json:"session_id"`
+ }
+ raw, _ := io.ReadAll(c.Ctx.Request.Body)
+ if err := json.Unmarshal(raw, &req); err != nil || len(req.Messages) == 0 {
+ c.jsonErr(400, 400, "参数错误")
+ return
+ }
+ // 落库用户消息(最后一条)
+ if req.SessionID > 0 {
+ persistUserMessage(req.SessionID, req.Messages[len(req.Messages)-1]["content"])
+ }
+ provider, err := loadPlatformProvider(req.ProviderID)
+ if err != nil {
+ c.jsonErr(400, 400, "服务商配置不存在或未启用")
+ return
+ }
+ model := strings.TrimSpace(req.Model)
+ if model == "" {
+ model = provider.DefaultModel
+ }
+ if model == "" {
+ c.jsonErr(400, 400, "未选择模型")
+ return
+ }
+ clean := make([]map[string]any, 0, len(req.Messages))
+ for _, m := range req.Messages {
+ role, _ := m["role"].(string)
+ role = strings.TrimSpace(role)
+ if role != "user" && role != "assistant" && role != "system" {
+ continue
+ }
+ clean = append(clean, map[string]any{"role": role, "content": m["content"]})
+ }
+ if len(clean) == 0 {
+ c.jsonErr(400, 400, "消息为空")
+ return
+ }
+
+ upReq, err := buildStreamRequest(provider, model, clean)
+ if err != nil {
+ c.jsonErr(500, 500, "构建请求失败")
+ return
+ }
+ client := &http.Client{Timeout: 300 * time.Second}
+ resp, err := client.Do(upReq)
+ if err != nil {
+ c.jsonErr(502, 502, "AI服务调用失败: "+err.Error())
+ return
+ }
+ defer resp.Body.Close()
+
+ // 切换为 SSE 响应(Beego ResponseWriter 是包装类型,取底层 http.ResponseWriter)
+ rw := c.Ctx.ResponseWriter.ResponseWriter
+ rw.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
+ rw.Header().Set("Cache-Control", "no-cache")
+ rw.Header().Set("Connection", "keep-alive")
+ rw.Header().Set("X-Accel-Buffering", "no")
+ rw.WriteHeader(200)
+ flusher, _ := rw.(http.Flusher)
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ writeStreamEvent(rw, flusher, map[string]any{"t": "error", "c": "AI服务调用失败: " + string(body)})
+ writeStreamEvent(rw, flusher, map[string]any{"t": "done"})
+ return
+ }
+
+ scanner := bufio.NewScanner(resp.Body)
+ scanner.Buffer(make([]byte, 64*1024), 1024*1024)
+ var accContent, accThink strings.Builder
+ for scanner.Scan() {
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data:") {
+ continue
+ }
+ payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
+ events, isDone := normalizeStreamData(provider.ApiType, payload)
+ for _, ev := range events {
+ writeStreamEvent(rw, flusher, ev)
+ if ev["t"] == "delta" {
+ if s, ok := ev["c"].(string); ok {
+ accContent.WriteString(s)
+ }
+ } else if ev["t"] == "think" {
+ if s, ok := ev["c"].(string); ok {
+ accThink.WriteString(s)
+ }
+ }
+ }
+ if isDone {
+ break
+ }
+ }
+ writeStreamEvent(rw, flusher, map[string]any{"t": "done"})
+ persistStreamResult(req.SessionID, accContent.String(), accThink.String())
+}
+
+// persistStreamResult 流式结束后落库:assistant 消息 + 会话更新时间/标题
+func persistStreamResult(sessionID uint64, content, think string) {
+ if sessionID == 0 {
+ return
+ }
+ var last models.PlatformAiChatMessage
+ _ = models.Orm.QueryTable(new(models.PlatformAiChatMessage)).
+ Filter("session_id", sessionID).OrderBy("-seq").Limit(1).One(&last, "seq")
+ _, _ = models.Orm.Insert(&models.PlatformAiChatMessage{
+ SessionID: sessionID,
+ Role: "assistant",
+ Content: content,
+ Think: think,
+ Seq: last.Seq + 1,
+ CreateTime: time.Now(),
+ })
+ _, _ = models.Orm.QueryTable(new(models.PlatformAiChatSession)).
+ Filter("id", sessionID).
+ Update(orm.Params{"update_time": time.Now()})
+}
+
+// persistUserMessage 流式开始前落库 user 消息(content 存原始 JSON,含附件)
+func persistUserMessage(sessionID uint64, content any) {
+ if sessionID == 0 {
+ return
+ }
+ b, _ := json.Marshal(content)
+ var last models.PlatformAiChatMessage
+ _ = models.Orm.QueryTable(new(models.PlatformAiChatMessage)).
+ Filter("session_id", sessionID).OrderBy("-seq").Limit(1).One(&last, "seq")
+ _, _ = models.Orm.Insert(&models.PlatformAiChatMessage{
+ SessionID: sessionID,
+ Role: "user",
+ Content: string(b),
+ Seq: last.Seq + 1,
+ CreateTime: time.Now(),
+ })
+ // 首条消息时回填会话标题
+ var cnt int64
+ _, _ = models.Orm.QueryTable(new(models.PlatformAiChatMessage)).
+ Filter("session_id", sessionID).Count()
+ if cnt == 1 {
+ if s := userContentText(content); s != "" {
+ if r := []rune(s); len(r) > 30 {
+ s = string(r[:30]) + "..."
+ }
+ _, _ = models.Orm.QueryTable(new(models.PlatformAiChatSession)).
+ Filter("id", sessionID).Update(orm.Params{"title": s})
+ }
+ }
+ _, _ = models.Orm.QueryTable(new(models.PlatformAiChatSession)).
+ Filter("id", sessionID).
+ Update(orm.Params{"update_time": time.Now()})
+}
+
+// userContentText 从 user content(字符串或多模态数组)提取纯文本用于标题
+func userContentText(content any) string {
+ switch v := content.(type) {
+ case string:
+ return v
+ case []any:
+ var sb strings.Builder
+ for _, item := range v {
+ if m, ok := item.(map[string]any); ok && m["type"] == "text" {
+ if t, ok := m["text"].(string); ok {
+ sb.WriteString(t)
+ }
+ }
+ }
+ return sb.String()
+ }
+ return ""
+}
+
+// ==================== 会话管理 ====================
+
+// ListChatSessions GET /platform/ai/chat/sessions
+func (c *PlatformAiController) ListChatSessions() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ c.jsonErr(403, 403, "无权限")
+ return
+ }
+ var list []models.PlatformAiChatSession
+ _, err := models.Orm.QueryTable(new(models.PlatformAiChatSession)).
+ Filter("delete_time__isnull", true).
+ OrderBy("-is_top", "-update_time").Limit(100).All(&list)
+ if err != nil {
+ c.jsonErr(500, 500, "查询失败")
+ return
+ }
+ c.Data["json"] = map[string]any{"code": 200, "msg": "success", "data": list}
+ _ = c.ServeJSON()
+}
+
+// CreateChatSession POST /platform/ai/chat/session {provider_id, model}
+func (c *PlatformAiController) CreateChatSession() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ c.jsonErr(403, 403, "无权限")
+ return
+ }
+ var req struct {
+ ProviderID uint64 `json:"provider_id"`
+ Model string `json:"model"`
+ }
+ _ = json.Unmarshal(c.Ctx.Input.RequestBody, &req)
+ s := models.PlatformAiChatSession{
+ ProviderID: req.ProviderID,
+ Model: req.Model,
+ CreateTime: time.Now(),
+ UpdateTime: time.Now(),
+ }
+ id, err := models.Orm.Insert(&s)
+ if err != nil {
+ c.jsonErr(500, 500, "创建失败")
+ return
+ }
+ s.ID = uint64(id)
+ c.Data["json"] = map[string]any{"code": 200, "msg": "success", "data": s}
+ _ = c.ServeJSON()
+}
+
+// GetChatSessionMessages GET /platform/ai/chat/session/:id/messages
+func (c *PlatformAiController) GetChatSessionMessages() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ c.jsonErr(403, 403, "无权限")
+ return
+ }
+ id, _ := c.GetInt64(":id")
+ var msgs []models.PlatformAiChatMessage
+ _, _ = models.Orm.QueryTable(new(models.PlatformAiChatMessage)).
+ Filter("session_id", id).OrderBy("seq").All(&msgs)
+ c.Data["json"] = map[string]any{"code": 200, "msg": "success", "data": msgs}
+ _ = c.ServeJSON()
+}
+
+// UpdateChatSession PUT /platform/ai/chat/session/:id {title?, is_top?}
+func (c *PlatformAiController) UpdateChatSession() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ c.jsonErr(403, 403, "无权限")
+ return
+ }
+ id, _ := c.GetInt64(":id")
+ var req struct {
+ Title *string `json:"title"`
+ IsTop *int8 `json:"is_top"`
+ }
+ _ = json.Unmarshal(c.Ctx.Input.RequestBody, &req)
+ params := orm.Params{}
+ if req.Title != nil {
+ t := strings.TrimSpace(*req.Title)
+ if t == "" {
+ t = "新对话"
+ }
+ if r := []rune(t); len(r) > 100 {
+ t = string(r[:100])
+ }
+ params["title"] = t
+ }
+ if req.IsTop != nil {
+ params["is_top"] = *req.IsTop
+ }
+ if len(params) == 0 {
+ c.jsonErr(400, 400, "无更新内容")
+ return
+ }
+ _, err := models.Orm.QueryTable(new(models.PlatformAiChatSession)).
+ Filter("id", id).Update(params)
+ if err != nil {
+ c.jsonErr(500, 500, "更新失败")
+ return
+ }
+ c.Data["json"] = map[string]any{"code": 200, "msg": "success"}
+ _ = c.ServeJSON()
+}
+
+// DeleteChatSession DELETE /platform/ai/chat/session/:id
+func (c *PlatformAiController) DeleteChatSession() {
+ if _, err := requirePlatform(&c.Controller); err != nil {
+ c.jsonErr(403, 403, "无权限")
+ return
+ }
+ id, _ := c.GetInt64(":id")
+ now := time.Now()
+ _, _ = models.Orm.QueryTable(new(models.PlatformAiChatSession)).
+ Filter("id", id).Update(orm.Params{"delete_time": now})
+ _, _ = models.Orm.QueryTable(new(models.PlatformAiChatMessage)).
+ Filter("session_id", id).Delete()
+ c.Data["json"] = map[string]any{"code": 200, "msg": "success"}
+ _ = c.ServeJSON()
+}
diff --git a/go/controllers/platform_website_setting.go b/go/controllers/platform_website_setting.go
index 72eee95..a65c005 100644
--- a/go/controllers/platform_website_setting.go
+++ b/go/controllers/platform_website_setting.go
@@ -110,9 +110,14 @@ func (c *PlatformWebsiteSettingController) Save() {
_ = c.ServeJSON()
}
-// loginBrandTags 登录页品牌配置项,存放于 yz_platform_site_setting,
+// loginBrandTags 登录页品牌/登录方式配置项,存放于 yz_platform_site_setting,
// 统一认证中心 /auth/login 渲染时读取。
-var loginBrandTags = []string{"login_title", "login_subtitle", "login_logo", "login_bg"}
+// login_pwd_enabled / login_sms_enabled / login_qr_enabled:
+// 值为 "0" 表示关闭该登录方式,未配置(空)默认开启;三者全关时登录页禁止登录。
+var loginBrandTags = []string{
+ "login_title", "login_subtitle", "login_logo", "login_bg",
+ "login_pwd_enabled", "login_sms_enabled", "login_qr_enabled",
+}
// LoginBrandInfo GET /platform/loginBrand
// 返回登录页品牌配置(主标题 / 副标题 / Logo / 背景图)。
@@ -172,8 +177,13 @@ func (c *PlatformWebsiteSettingController) SaveLoginBrand() {
c.jsonErr(400, 400, "副标题不能超过 120 个字符")
return
}
+ // 只校验本次提交了的图片字段,避免「登录方式」等只传开关的保存把品牌图清空
for _, tag := range []string{"login_logo", "login_bg"} {
- img, ok := normalizeLoginBrandImage(values[tag])
+ raw, submitted := payload[tag]
+ if !submitted {
+ continue
+ }
+ img, ok := normalizeLoginBrandImage(raw)
if !ok {
c.jsonErr(400, 400, "图片地址必须是 HTTPS 或站内相对路径")
return
diff --git a/go/docs/sql/alter_platform_ai_chat_session_top.sql b/go/docs/sql/alter_platform_ai_chat_session_top.sql
new file mode 100644
index 0000000..1bba594
--- /dev/null
+++ b/go/docs/sql/alter_platform_ai_chat_session_top.sql
@@ -0,0 +1,4 @@
+-- 会话表增加置顶字段
+ALTER TABLE `yz_platform_ai_chat_session`
+ ADD COLUMN `is_top` tinyint NOT NULL DEFAULT 0 COMMENT '是否置顶 1-是' AFTER `model`,
+ ADD KEY `idx_is_top` (`is_top`);
diff --git a/go/docs/sql/alter_platform_ai_provider_api_type.sql b/go/docs/sql/alter_platform_ai_provider_api_type.sql
new file mode 100644
index 0000000..0781649
--- /dev/null
+++ b/go/docs/sql/alter_platform_ai_provider_api_type.sql
@@ -0,0 +1,4 @@
+-- 平台 AI 服务商配置增加协议类型字段
+-- api_type: chat_completions(OpenAI Chat Completions) / responses(OpenAI Responses) / anthropic(Anthropic Messages)
+ALTER TABLE `yz_platform_ai_provider`
+ ADD COLUMN `api_type` varchar(30) NOT NULL DEFAULT 'chat_completions' COMMENT '协议类型' AFTER `provider_type`;
diff --git a/go/docs/sql/create_platform_ai_chat.sql b/go/docs/sql/create_platform_ai_chat.sql
new file mode 100644
index 0000000..3629f67
--- /dev/null
+++ b/go/docs/sql/create_platform_ai_chat.sql
@@ -0,0 +1,25 @@
+-- 平台 AI Playground 会话与消息表
+CREATE TABLE IF NOT EXISTS `yz_platform_ai_chat_session` (
+ `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+ `title` varchar(100) NOT NULL DEFAULT '' COMMENT '会话标题(首条用户消息摘要)',
+ `provider_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '服务商配置ID',
+ `model` varchar(100) NOT NULL DEFAULT '' COMMENT '模型ID',
+ `create_time` datetime NOT NULL,
+ `update_time` datetime NOT NULL,
+ `delete_time` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_delete_time` (`delete_time`),
+ KEY `idx_update_time` (`update_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台AI会话';
+
+CREATE TABLE IF NOT EXISTS `yz_platform_ai_chat_message` (
+ `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+ `session_id` bigint unsigned NOT NULL DEFAULT 0,
+ `role` varchar(20) NOT NULL DEFAULT '' COMMENT 'user/assistant/system',
+ `content` text COMMENT 'user存原始content JSON(可含附件);assistant存纯文本',
+ `think` text COMMENT 'assistant思考过程',
+ `seq` int NOT NULL DEFAULT 0 COMMENT '消息序号',
+ `create_time` datetime NOT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_session_seq` (`session_id`, `seq`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台AI会话消息';
diff --git a/go/docs/sql/create_platform_ai_provider.sql b/go/docs/sql/create_platform_ai_provider.sql
new file mode 100644
index 0000000..7781b24
--- /dev/null
+++ b/go/docs/sql/create_platform_ai_provider.sql
@@ -0,0 +1,20 @@
+-- 平台级 AI 服务商配置(platform「AI管理」页签:DeepSeek / 豆包 / Codex 等接入)
+-- 人工执行;表数据驱动,前端按 provider_type 区分不同服务商 tab
+
+CREATE TABLE IF NOT EXISTS `yz_platform_ai_provider` (
+ `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+ `provider_type` varchar(30) NOT NULL DEFAULT '' COMMENT 'deepseek/doubao/codex/custom',
+ `name` varchar(100) NOT NULL DEFAULT '' COMMENT '配置名称',
+ `api_base` varchar(255) NOT NULL DEFAULT '' COMMENT 'OpenAI兼容接口基址,如 https://api.deepseek.com',
+ `api_key` varchar(500) NOT NULL DEFAULT '' COMMENT 'API Key(不回传前端)',
+ `models` text COMMENT '可用模型 JSON 数组,如 ["deepseek-chat"]',
+ `default_model` varchar(255) NOT NULL DEFAULT '' COMMENT '默认模型',
+ `is_default` tinyint NOT NULL DEFAULT 0 COMMENT '是否平台默认(Playground/OCR优先使用)',
+ `status` tinyint NOT NULL DEFAULT 1 COMMENT '0-禁用 1-启用',
+ `remark` varchar(255) NOT NULL DEFAULT '',
+ `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ `delete_time` datetime DEFAULT NULL,
+ PRIMARY KEY (`id`),
+ KEY `idx_type_status` (`provider_type`, `status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台AI服务商配置';
diff --git a/go/models/init.go b/go/models/init.go
index d509053..0c32a67 100644
--- a/go/models/init.go
+++ b/go/models/init.go
@@ -148,6 +148,10 @@ func Init(_ string) {
new(ErpFundBill),
new(ErpFundSettle),
new(BackendAiProvider),
+ // 平台级 AI 服务商配置(docs/sql/create_platform_ai_provider.sql)
+ new(PlatformAiProvider),
+ new(PlatformAiChatSession),
+ new(PlatformAiChatMessage),
new(BackendAiChatSession),
new(BackendAiChatMessage),
new(BackendAiChatPreset),
diff --git a/go/models/platform_ai_chat.go b/go/models/platform_ai_chat.go
new file mode 100644
index 0000000..cea8aa1
--- /dev/null
+++ b/go/models/platform_ai_chat.go
@@ -0,0 +1,30 @@
+package models
+
+import "time"
+
+// PlatformAiChatSession 平台 AI Playground 会话
+type PlatformAiChatSession struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ Title string `orm:"column(title);size(100)" json:"title"`
+ ProviderID uint64 `orm:"column(provider_id)" json:"provider_id"`
+ Model string `orm:"column(model);size(100)" json:"model"`
+ IsTop int8 `orm:"column(is_top);default(0)" json:"is_top"` // 1-置顶
+ CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
+ UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
+ DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
+}
+
+func (m *PlatformAiChatSession) TableName() string { return "yz_platform_ai_chat_session" }
+
+// PlatformAiChatMessage 平台 AI 会话消息
+type PlatformAiChatMessage struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ SessionID uint64 `orm:"column(session_id)" json:"session_id"`
+ Role string `orm:"column(role);size(20)" json:"role"`
+ Content string `orm:"column(content);type(text)" json:"content"` // user 存原始 content JSON(含附件);assistant 存纯文本
+ Think string `orm:"column(think);type(text)" json:"think"` // assistant 思考过程
+ Seq int `orm:"column(seq)" json:"seq"`
+ CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
+}
+
+func (m *PlatformAiChatMessage) TableName() string { return "yz_platform_ai_chat_message" }
diff --git a/go/models/platform_ai_provider.go b/go/models/platform_ai_provider.go
new file mode 100644
index 0000000..e5fa42e
--- /dev/null
+++ b/go/models/platform_ai_provider.go
@@ -0,0 +1,34 @@
+package models
+
+import "time"
+
+// AiModelItem 模型条目:模型 ID + 展示名称
+type AiModelItem struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+}
+
+// PlatformAiProvider 平台级 AI 服务商配置(DeepSeek / 豆包 / Codex 等)
+// 表:yz_platform_ai_provider,建表 SQL 见 docs/sql/create_platform_ai_provider.sql
+type PlatformAiProvider struct {
+ ID uint64 `orm:"column(id);pk;auto" json:"id"`
+ ProviderType string `orm:"column(provider_type);size(30)" json:"provider_type"` // 预设分类:deepseek / doubao / codex / custom
+ ApiType string `orm:"column(api_type);size(30)" json:"api_type"` // 协议类型:chat_completions / responses / anthropic
+ Name string `orm:"column(name);size(100)" json:"name"`
+ ApiBase string `orm:"column(api_base);size(255)" json:"api_base"`
+ ApiKey string `orm:"column(api_key);size(500)" json:"-"` // 不回传前端
+ ApiKeyMasked string `orm:"-" json:"api_key_masked"` // 回显掩码,如 sk-***abcd
+ Models string `orm:"column(models);type(text)" json:"-"` // JSON数组
+ ModelsList []AiModelItem `orm:"-" json:"models"`
+ DefaultModel string `orm:"column(default_model);size(255)" json:"default_model"`
+ IsDefault int8 `orm:"column(is_default);default(0)" json:"is_default"`
+ Status int8 `orm:"column(status);default(1)" json:"status"` // 0-禁用 1-启用
+ Remark string `orm:"column(remark);size(255)" json:"remark"`
+ CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
+ UpdateTime time.Time `orm:"column(update_time);auto_now;type(datetime)" json:"update_time"`
+ DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"`
+}
+
+func (m *PlatformAiProvider) TableName() string {
+ return "yz_platform_ai_provider"
+}
diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go
index e9507ad..38bbe67 100644
--- a/go/routers/backend/backend.go
+++ b/go/routers/backend/backend.go
@@ -104,6 +104,9 @@ func RegisterAuthRoutes() {
beego.Router("/backend/frontmenus", &controllers.BackendMenuFrontController{}, "get:List;post:Create")
beego.Router("/backend/frontmenus/:id", &controllers.BackendMenuFrontController{}, "post:Update;delete:Delete")
+ // OCR 识别(配置在平台「AI管理」,识别实现待接入)
+ beego.Router("/backend/ocr/recognize", &controllers.BackendOcrController{}, "post:Recognize")
+
// 操作日志(yz_system_operation_log)
beego.Router("/backend/operationLogs", &controllers.BackendOperationLogController{}, "get:List")
beego.Router("/backend/operationLogs/statistics", &controllers.BackendOperationLogController{}, "get:Statistics")
diff --git a/go/routers/platform/platform.go b/go/routers/platform/platform.go
index e2edddf..c0171d1 100644
--- a/go/routers/platform/platform.go
+++ b/go/routers/platform/platform.go
@@ -407,6 +407,21 @@ func Register() {
beego.Router("/platform/loginBrand", &controllers.PlatformWebsiteSettingController{}, "get:LoginBrandInfo")
beego.Router("/platform/saveLoginBrand", &controllers.PlatformWebsiteSettingController{}, "post:SaveLoginBrand")
+ // ===== 平台 AI 管理(docs/sql/create_platform_ai_provider.sql)=====
+ // 注意:/provider/test、/provider/list 等固定路径先于 /provider/:id 注册
+ beego.Router("/platform/ai/provider/list", &controllers.PlatformAiController{}, "get:List")
+ beego.Router("/platform/ai/provider", &controllers.PlatformAiController{}, "post:Create")
+ beego.Router("/platform/ai/provider/test", &controllers.PlatformAiController{}, "post:Test")
+ beego.Router("/platform/ai/provider/:id", &controllers.PlatformAiController{}, "put:Update;delete:Delete")
+ beego.Router("/platform/ai/chat", &controllers.PlatformAiController{}, "post:Chat")
+ beego.Router("/platform/ai/chat/stream", &controllers.PlatformAiController{}, "post:ChatStream")
+ beego.Router("/platform/ai/chat/sessions", &controllers.PlatformAiController{}, "get:ListChatSessions")
+ beego.Router("/platform/ai/chat/session", &controllers.PlatformAiController{}, "post:CreateChatSession")
+ beego.Router("/platform/ai/chat/session/:id/messages", &controllers.PlatformAiController{}, "get:GetChatSessionMessages")
+ beego.Router("/platform/ai/chat/session/:id", &controllers.PlatformAiController{}, "delete:DeleteChatSession;put:UpdateChatSession")
+ beego.Router("/platform/ai/ocrConfig", &controllers.PlatformAiController{}, "get:OcrConfig;post:SaveOcrConfig")
+ beego.Router("/platform/ai/ocr/recognize", &controllers.PlatformAiController{}, "post:RecognizeOcr")
+
// 平台端排班模板(全局模板;系统预置只读,只有平台可维护)
// 注意:带 /status 的固定路径先于 /:id 注册
beego.Router("/platform/schedTemplates", &controllers.PlatformSchedTemplateController{}, "get:List;post:Create")
diff --git a/go/views/auth/login.tpl b/go/views/auth/login.tpl
index 9da40ef..d9dd6a7 100644
--- a/go/views/auth/login.tpl
+++ b/go/views/auth/login.tpl
@@ -180,6 +180,32 @@
background: linear-gradient(90deg, var(--primary) 0%, var(--primary-light) 100%);
}
.login-panel { display: none; }
+
+ /* ---------- 登录方式全部关闭 ---------- */
+ .login-disabled {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ padding: 48px 24px 32px;
+ color: var(--text-sub);
+ }
+ .login-disabled svg {
+ width: 44px; height: 44px;
+ color: var(--text-faint, #a3acbf);
+ margin-bottom: 14px;
+ }
+ .login-disabled-title {
+ margin: 0 0 8px;
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text);
+ }
+ .login-disabled-desc {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.7;
+ }
.login-panel.active { display: block; animation: panelIn .24s ease both; }
@keyframes panelIn {
from { opacity: 0; transform: translateY(6px); }
@@ -618,15 +644,28 @@
-
-
-
-
-
+ {{if .LoginDisabled}}
+
+
+
+
登录功能已关闭
+
管理员已在平台设置中关闭全部登录方式,请联系管理员开启后再试。
+ {{else}}
+
+ {{if .LoginShowTabs}}
+
+ {{if .LoginPwdOn}}{{end}}
+ {{if .LoginSmsOn}}{{end}}
+ {{if .LoginQrOn}}{{end}}
+
+ {{end}}
-
+ {{if .LoginPwdOn}}
+
{{if .Notice}}
+ {{end}}
-
@@ -47,6 +52,7 @@ import platformSettings from "./components/platformSettings.vue";
import storageSettings from "./components/storageSettings.vue";
import notificationSettings from "./components/notificationSettings.vue";
import loginBrandSettings from "./components/loginBrandSettings.vue";
+import loginSettings from "./components/loginSettings.vue";
const activeTab = ref("platform");
diff --git a/platform/src/views/website/content/article/components/preview.vue b/platform/src/views/website/content/article/components/preview.vue
index 00d4775..eb4c40d 100644
--- a/platform/src/views/website/content/article/components/preview.vue
+++ b/platform/src/views/website/content/article/components/preview.vue
@@ -1,21 +1,32 @@
-
{{ model?.title || '-' }}
+
@@ -403,6 +416,52 @@ const onHomeClick = () => {
}
}
+/* ---------- 登录 / 注册入口 ---------- */
+.header-auth {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-shrink: 0;
+ margin-left: 24px;
+}
+
+.auth-link {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 38px;
+ padding: 0 22px;
+ border-radius: 19px;
+ font-size: 14px;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+ transition: color 0.2s ease, background-color 0.2s ease,
+ border-color 0.2s ease;
+
+ &--login {
+ color: @ink-700;
+ border: 1px solid @ink-300;
+ background: @white;
+
+ &:hover {
+ color: @blue-600;
+ border-color: @blue-600;
+ }
+ }
+
+ &--register {
+ color: @white;
+ background: @blue-600;
+ border: 1px solid @blue-600;
+
+ &:hover {
+ background: darken(@blue-600, 8%);
+ border-color: darken(@blue-600, 8%);
+ }
+ }
+}
+
.menu-toggle {
display: none;
width: 30px;
diff --git a/website/src/views/common/DetailPage.vue b/website/src/views/common/DetailPage.vue
index b137737..8cb4087 100644
--- a/website/src/views/common/DetailPage.vue
+++ b/website/src/views/common/DetailPage.vue
@@ -221,6 +221,10 @@ watch(() => `${section.value.key}|${slug.value}`, load, { immediate: true });
grid-template-columns: minmax(0, 1fr) 340px;
gap: 64px;
align-items: start;
+ /* 全站容器 1680px 偏宽,详情页正文+侧栏收敛到 1400px 居中 */
+ max-width: 1400px;
+ margin-left: auto;
+ margin-right: auto;
}
}
@@ -390,6 +394,10 @@ watch(() => `${section.value.key}|${slug.value}`, load, { immediate: true });
/* ---------------- 相关推荐 ---------------- */
.detail-related {
margin-top: 96px;
+ /* 与上方正文网格同宽对齐(1400px 居中) */
+ max-width: 1400px;
+ margin-left: auto;
+ margin-right: auto;
&__head {
display: flex;