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 @@ + + + + + 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}} +
@@ -53,15 +52,18 @@ const glyph = computed(() => props.item.title.slice(0, 1));

{{ item.summary }}

- - - {{ item.date }} - - - - {{ tag }} - - +
+ {{ categoryLabel }} + + + {{ item.date }} + + + + {{ tag }} + + +
查看详情 @@ -149,17 +151,26 @@ const glyph = computed(() => props.item.title.slice(0, 1)); opacity 0.4s ease; } + /* 分类标签:位于正文区日期上方,不再覆盖在封面图上 */ &__badge { - position: absolute; - left: 22px; - top: 22px; - padding: 5px 13px; + display: inline-flex; + align-items: center; + align-self: flex-start; + padding: 4px 12px; border-radius: 100px; - background: rgba(255, 255, 255, 0.16); - backdrop-filter: blur(6px); - font-size: 11.5px; + background: @bg-soft; + font-size: 12px; font-weight: 500; - letter-spacing: 0.06em; + letter-spacing: 0.04em; + color: @ink-600; + } + + &__foot-left { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + min-width: 0; } /* ---------------- 正文 ---------------- */ @@ -248,7 +259,7 @@ const glyph = computed(() => props.item.title.slice(0, 1)); flex-direction: column; .content-card__cover { - height: 176px; + aspect-ratio: 25 / 14; } } @@ -258,7 +269,7 @@ const glyph = computed(() => props.item.title.slice(0, 1)); .content-card__cover { width: 300px; - min-height: 200px; + aspect-ratio: 25 / 14; } .content-card__glyph { @@ -283,7 +294,7 @@ const glyph = computed(() => props.item.title.slice(0, 1)); .content-card--list { .content-card__cover { width: 230px; - min-height: 176px; + aspect-ratio: 25 / 14; } .content-card__glyph { @@ -298,7 +309,7 @@ const glyph = computed(() => props.item.title.slice(0, 1)); .content-card__cover { width: 100%; - min-height: 148px; + aspect-ratio: 25 / 14; } } diff --git a/website/src/components/SiteHeader.vue b/website/src/components/SiteHeader.vue index 213b71d..e135451 100644 --- a/website/src/components/SiteHeader.vue +++ b/website/src/components/SiteHeader.vue @@ -7,6 +7,11 @@ import { navMenus } from "@/config/nav"; const route = useRoute(); +// 统一认证中心地址:登录/注册均跳转到认证中心页面 +const AUTH_CENTER = "https://api.yunzer.cn"; +const authLoginUrl = `${AUTH_CENTER}/auth/login`; +const authRegisterUrl = `${AUTH_CENTER}/auth/register`; + const stuck = ref(false); const menuOpen = ref(false); @@ -118,6 +123,12 @@ const onHomeClick = () => {
+ +
+ + 试用 +
+
@@ -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;