增加ai配置和ai广场功能
This commit is contained in:
1 parent
244b4bd9fd
commit
d9a50e23dd
27 files changed
+3808
-57
No files matched your search
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="container-box ocr-page">
|
||||
<div class="header-bar">
|
||||
<h2>OCR 识别</h2>
|
||||
<p class="page-tip">
|
||||
上传图片进行文字识别。识别服务由平台统一配置(平台端「系统 - AI管理 - OCR识别配置」),
|
||||
当前识别能力待接入,已保存的配置即将生效。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :span="10">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>上传图片</span></template>
|
||||
<el-upload
|
||||
class="ocr-uploader"
|
||||
drag
|
||||
:show-file-list="false"
|
||||
accept="image/*"
|
||||
:auto-upload="false"
|
||||
:on-change="handleFile"
|
||||
>
|
||||
<div class="ocr-uploader__inner">
|
||||
<el-icon :size="40"><UploadFilled /></el-icon>
|
||||
<p>点击或拖拽图片到此处</p>
|
||||
<p class="sub">支持 png / jpg / webp 等常见格式</p>
|
||||
</div>
|
||||
</el-upload>
|
||||
|
||||
<div v-if="previewUrl" class="ocr-preview">
|
||||
<img :src="previewUrl" alt="待识别图片" />
|
||||
<p class="ocr-preview__name">{{ fileName }}</p>
|
||||
</div>
|
||||
|
||||
<el-input
|
||||
v-model="prompt"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
class="ocr-prompt"
|
||||
placeholder="识别提示词(可选,留空使用平台默认配置)"
|
||||
/>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="recognizing"
|
||||
:disabled="!imageBase64"
|
||||
style="width: 100%"
|
||||
@click="doRecognize"
|
||||
>
|
||||
开始识别
|
||||
</el-button>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="14">
|
||||
<el-card shadow="never" class="ocr-result-card">
|
||||
<template #header><span>识别结果</span></template>
|
||||
<div v-if="resultText" class="ocr-result">
|
||||
<pre>{{ resultText }}</pre>
|
||||
</div>
|
||||
<el-empty v-else description="识别结果将显示在这里" />
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { UploadFilled } from "@element-plus/icons-vue";
|
||||
import { ocrRecognize } from "@/api/ocr";
|
||||
|
||||
const previewUrl = ref("");
|
||||
const fileName = ref("");
|
||||
const imageBase64 = ref("");
|
||||
const prompt = ref("");
|
||||
const recognizing = ref(false);
|
||||
const resultText = ref("");
|
||||
|
||||
const MAX_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
const handleFile = (file) => {
|
||||
const raw = file?.raw;
|
||||
if (!raw) return;
|
||||
if (!String(raw.type || "").startsWith("image/")) {
|
||||
ElMessage.warning("请选择图片文件");
|
||||
return;
|
||||
}
|
||||
if (raw.size > MAX_SIZE) {
|
||||
ElMessage.warning("图片不能超过 10MB");
|
||||
return;
|
||||
}
|
||||
fileName.value = raw.name || "image";
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = String(reader.result || "");
|
||||
previewUrl.value = dataUrl;
|
||||
imageBase64.value = dataUrl.includes(",") ? dataUrl.split(",")[1] : dataUrl;
|
||||
resultText.value = "";
|
||||
};
|
||||
reader.readAsDataURL(raw);
|
||||
};
|
||||
|
||||
const doRecognize = async () => {
|
||||
recognizing.value = true;
|
||||
resultText.value = "";
|
||||
try {
|
||||
const res = await ocrRecognize({
|
||||
image_base64: imageBase64.value,
|
||||
prompt: prompt.value.trim(),
|
||||
});
|
||||
if (res?.code === 200) {
|
||||
const d = res.data || {};
|
||||
if (d.recognized && d.text) {
|
||||
resultText.value = d.text;
|
||||
} else {
|
||||
ElMessage.info(d.message || "OCR 识别服务待接入");
|
||||
resultText.value = d.message || "OCR 识别服务待接入";
|
||||
}
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "识别失败");
|
||||
}
|
||||
} finally {
|
||||
recognizing.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.ocr-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.page-tip {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.ocr-uploader {
|
||||
:deep(.el-upload-dragger) {
|
||||
padding: 36px 0;
|
||||
}
|
||||
|
||||
&__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sub {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ocr-preview {
|
||||
margin-top: 16px;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
max-height: 260px;
|
||||
object-fit: contain;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
&__name {
|
||||
margin: 8px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.ocr-prompt {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.ocr-result-card {
|
||||
min-height: 420px;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
font-size: 14px;
|
||||
line-height: 1.9;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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" {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
@@ -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`;
|
||||
@@ -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会话消息';
|
||||
@@ -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服务商配置';
|
||||
@@ -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),
|
||||
|
||||
@@ -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" }
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
+53
-8
@@ -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 @@
|
||||
<span id="errorText"></span>
|
||||
</div>
|
||||
|
||||
<!-- 登录方式切换:默认账号密码 -->
|
||||
<div class="login-tabs" role="tablist">
|
||||
<button class="login-tab active" type="button" role="tab" data-tab="account" onclick="switchLoginTab('account')">账号密码</button>
|
||||
<button class="login-tab" type="button" role="tab" data-tab="sms" onclick="switchLoginTab('sms')">短信登录</button>
|
||||
<button class="login-tab" type="button" role="tab" data-tab="qr" onclick="switchLoginTab('qr')">扫码登录</button>
|
||||
{{if .LoginDisabled}}
|
||||
<!-- 平台设置已关闭全部登录方式 -->
|
||||
<div class="login-disabled">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V8a4 4 0 018 0v3"/>
|
||||
</svg>
|
||||
<p class="login-disabled-title">登录功能已关闭</p>
|
||||
<p class="login-disabled-desc">管理员已在平台设置中关闭全部登录方式,请联系管理员开启后再试。</p>
|
||||
</div>
|
||||
{{else}}
|
||||
<!-- 登录方式切换:按平台设置的开关渲染,默认选中第一个可用方式 -->
|
||||
{{if .LoginShowTabs}}
|
||||
<div class="login-tabs" role="tablist">
|
||||
{{if .LoginPwdOn}}<button class="login-tab{{if eq .LoginDefaultTab "account"}} active{{end}}" type="button" role="tab" data-tab="account" onclick="switchLoginTab('account')">账号密码</button>{{end}}
|
||||
{{if .LoginSmsOn}}<button class="login-tab{{if eq .LoginDefaultTab "sms"}} active{{end}}" type="button" role="tab" data-tab="sms" onclick="switchLoginTab('sms')">短信登录</button>{{end}}
|
||||
{{if .LoginQrOn}}<button class="login-tab{{if eq .LoginDefaultTab "qr"}} active{{end}}" type="button" role="tab" data-tab="qr" onclick="switchLoginTab('qr')">扫码登录</button>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- 账号密码登录 -->
|
||||
<div class="login-panel active" id="panel-account">
|
||||
{{if .LoginPwdOn}}
|
||||
<div class="login-panel{{if eq .LoginDefaultTab "account"}} active{{end}}" id="panel-account">
|
||||
{{if .Notice}}
|
||||
<div class="notice-box{{if eq .Notice "registered_warn"}} warn{{end}}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -680,9 +719,11 @@
|
||||
|
||||
<div class="back">还没有账号?<a class="link" href="/auth/register?client_id={{urlquery .ClientID}}&redirect={{urlquery .Redirect}}">立即注册</a></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- 手机号验证码登录 -->
|
||||
<div class="login-panel" id="panel-sms">
|
||||
{{if .LoginSmsOn}}
|
||||
<div class="login-panel{{if eq .LoginDefaultTab "sms"}} active{{end}}" id="panel-sms">
|
||||
<form onsubmit="return submitSmsLogin(event)" autocomplete="on">
|
||||
<div class="field">
|
||||
<label class="field-label" for="smsMobile">手机号</label>
|
||||
@@ -714,9 +755,11 @@
|
||||
</form>
|
||||
<p class="form-hint">未注册的手机号验证通过后将自动创建账号</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- 扫码登录:微信扫码 → 小程序内确认 -->
|
||||
<div class="login-panel" id="panel-qr">
|
||||
{{if .LoginQrOn}}
|
||||
<div class="login-panel{{if eq .LoginDefaultTab "qr"}} active{{end}}" id="panel-qr">
|
||||
<div class="qr-panel">
|
||||
<div class="qr-box">
|
||||
<img id="scanQrImg" class="qr-img" alt="微信扫码登录" style="display:none" />
|
||||
@@ -730,6 +773,7 @@
|
||||
<p class="qr-note">二维码 5 分钟内有效,过期可点击刷新</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<!-- 第三方登录(未配置时点击提示即将上线) -->
|
||||
<div class="third-area" id="thirdArea">
|
||||
@@ -749,6 +793,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}{{/* end LoginDisabled */}}
|
||||
{{end}}
|
||||
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
/** AI 服务商列表(可按 provider_type 过滤) */
|
||||
export function getAiProviderList(params) {
|
||||
return request({ url: "/platform/ai/provider/list", method: "get", params });
|
||||
}
|
||||
|
||||
export function createAiProvider(data) {
|
||||
return request({ url: "/platform/ai/provider", method: "post", data });
|
||||
}
|
||||
|
||||
export function updateAiProvider(id, data) {
|
||||
return request({ url: `/platform/ai/provider/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
export function deleteAiProvider(id) {
|
||||
return request({ url: `/platform/ai/provider/${id}`, method: "delete" });
|
||||
}
|
||||
|
||||
/** 连通性测试 { id, model? } */
|
||||
export function testAiProvider(data) {
|
||||
return request({ url: "/platform/ai/provider/test", method: "post", data });
|
||||
}
|
||||
|
||||
/** Playground 对话 { provider_id, model, messages } */
|
||||
export function aiChat(data) {
|
||||
return request({ url: "/platform/ai/chat", method: "post", data });
|
||||
}
|
||||
|
||||
export function getOcrConfig() {
|
||||
return request({ url: "/platform/ai/ocrConfig", method: "get" });
|
||||
}
|
||||
|
||||
export function saveOcrConfig(data) {
|
||||
return request({ url: "/platform/ai/ocrConfig", method: "post", data });
|
||||
}
|
||||
|
||||
/** OCR 识别占位接口 */
|
||||
export function ocrRecognize(data) {
|
||||
return request({ url: "/platform/ai/ocr/recognize", method: "post", data });
|
||||
}
|
||||
|
||||
/** AI 会话列表 */
|
||||
export function getAiChatSessions() {
|
||||
return request({ url: "/platform/ai/chat/sessions", method: "get" });
|
||||
}
|
||||
|
||||
/** 新建 AI 会话 { provider_id, model } */
|
||||
export function createAiChatSession(data) {
|
||||
return request({ url: "/platform/ai/chat/session", method: "post", data });
|
||||
}
|
||||
|
||||
/** 会话消息列表 */
|
||||
export function getAiChatSessionMessages(id) {
|
||||
return request({ url: `/platform/ai/chat/session/${id}/messages`, method: "get" });
|
||||
}
|
||||
|
||||
/** 重命名 / 置顶会话 { title?, is_top? } */
|
||||
export function updateAiChatSession(id, data) {
|
||||
return request({ url: `/platform/ai/chat/session/${id}`, method: "put", data });
|
||||
}
|
||||
|
||||
/** 删除会话 */
|
||||
export function deleteAiChatSession(id) {
|
||||
return request({ url: `/platform/ai/chat/session/${id}`, method: "delete" });
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="ocr-settings" v-loading="loading">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>OCR 识别配置</span>
|
||||
<!-- <span class="card-tip">租户端「OA - OCR 识别」读取此配置;识别实现后续接入</span> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :model="form" label-width="130px" style="max-width: 720px">
|
||||
<el-form-item label="启用 OCR">
|
||||
<el-switch v-model="form.ocr_enabled" active-value="1" inactive-value="0" />
|
||||
<span class="form-tip" style="margin-left: 10px">关闭后租户端 OCR 功能不可用</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="识别服务商">
|
||||
<el-select v-model="form.ocr_provider_id" placeholder="选择已启用的服务商配置" style="width: 320px" clearable @change="onProviderChange">
|
||||
<el-option
|
||||
v-for="p in providers"
|
||||
:key="p.id"
|
||||
:label="`${p.name}(${apiTypeLabel(p.api_type)})`"
|
||||
:value="String(p.id)"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="form-tip">从上方已保存的模型配置中选择</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="识别模型">
|
||||
<el-select
|
||||
v-model="form.ocr_model"
|
||||
placeholder="先选择服务商,再选择模型"
|
||||
style="width: 320px"
|
||||
clearable
|
||||
:disabled="!modelOptions.length"
|
||||
>
|
||||
<el-option
|
||||
v-for="m in modelOptions"
|
||||
:key="m.id"
|
||||
:label="m.name ? `${m.name}(${m.id})` : m.id"
|
||||
:value="m.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div v-if="!modelOptions.length" class="form-tip">该服务商暂无可用模型</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="识别提示词">
|
||||
<el-input
|
||||
v-model="form.ocr_prompt"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
placeholder="留空使用默认:请把图片中的文字完整识别出来,保持原有排版"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="footer-actions">
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存配置</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref, computed } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { getOcrConfig, saveOcrConfig, getAiProviderList } from "@/api/aisettings";
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const providers = ref([]);
|
||||
|
||||
const form = reactive({
|
||||
ocr_enabled: "0",
|
||||
ocr_provider_id: "",
|
||||
ocr_model: "",
|
||||
ocr_prompt: "",
|
||||
});
|
||||
let snapshot = { ...form };
|
||||
|
||||
const API_TYPE_LABELS = {
|
||||
chat_completions: "Chat Completions",
|
||||
responses: "Responses",
|
||||
anthropic: "Anthropic Messages",
|
||||
};
|
||||
const apiTypeLabel = (t) => API_TYPE_LABELS[t] || t || "Chat Completions";
|
||||
|
||||
const currentProvider = computed(() =>
|
||||
providers.value.find((p) => String(p.id) === String(form.ocr_provider_id))
|
||||
);
|
||||
const modelOptions = computed(() => {
|
||||
const p = currentProvider.value;
|
||||
if (!p) return [];
|
||||
return (p.models || []).map((m) => ({
|
||||
id: m.id || m,
|
||||
name: m.name || "",
|
||||
}));
|
||||
});
|
||||
|
||||
const onProviderChange = () => {
|
||||
// 切换服务商后,若当前模型不在新列表中则清空
|
||||
const ids = modelOptions.value.map((m) => m.id);
|
||||
if (form.ocr_model && !ids.includes(form.ocr_model)) {
|
||||
form.ocr_model = "";
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [cfgRes, listRes] = await Promise.all([getOcrConfig(), getAiProviderList()]);
|
||||
if (cfgRes?.code === 200 && cfgRes.data) {
|
||||
Object.assign(form, {
|
||||
ocr_enabled: cfgRes.data.ocr_enabled || "0",
|
||||
ocr_provider_id: cfgRes.data.ocr_provider_id || "",
|
||||
ocr_model: cfgRes.data.ocr_model || "",
|
||||
ocr_prompt: cfgRes.data.ocr_prompt || "",
|
||||
});
|
||||
snapshot = { ...form };
|
||||
}
|
||||
if (listRes?.code === 200) {
|
||||
providers.value = (listRes.data || []).filter((p) => p.status === 1);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
submitting.value = true;
|
||||
try {
|
||||
const res = await saveOcrConfig({ ...form });
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success("保存成功");
|
||||
snapshot = { ...form };
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "保存失败");
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(form, snapshot);
|
||||
ElMessage.info("已恢复到上次保存的配置");
|
||||
};
|
||||
|
||||
onMounted(loadConfig);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.ocr-settings {
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.card-tip {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.form-tip {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,207 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="isEdit ? '编辑模型配置' : '新增模型配置'"
|
||||
width="720px"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
@open="onOpen"
|
||||
>
|
||||
<el-form :model="form" label-width="110px">
|
||||
<el-form-item label="配置名称" required>
|
||||
<el-input v-model="form.name" placeholder="如:DeepSeek 生产环境" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API 地址" required>
|
||||
<el-input v-model="form.api_base" :placeholder="apiBasePlaceholder" />
|
||||
<div class="form-tip">{{ apiTypeTip }}</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="API 密钥" :required="!isEdit">
|
||||
<el-input
|
||||
v-model="form.api_key"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="isEdit ? '留空表示不修改(当前:' + (item?.api_key_masked || '未设置') + ')' : 'sk-...'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="API 协议" required>
|
||||
<el-select v-model="form.api_type" style="width: 100%" :disabled="isEdit">
|
||||
<el-option label="OpenAI Chat Completions" value="chat_completions" />
|
||||
<el-option label="OpenAI Responses" value="responses" />
|
||||
<el-option label="Anthropic Messages" value="anthropic" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="模型列表">
|
||||
<div class="models-table">
|
||||
<el-table :data="form.models" size="small" border>
|
||||
<el-table-column label="模型名称" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.name" placeholder="展示名称,如 GPT-4o" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="模型 ID" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.id" placeholder="如 gpt-4o / deepseek-chat" size="small" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="70" align="center">
|
||||
<template #default="{ $index }">
|
||||
<el-button link type="danger" size="small" @click="form.models.splice($index, 1)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-button class="add-model-btn" size="small" @click="addModelRow">
|
||||
<el-icon><Plus /></el-icon> 添加模型
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="默认模型">
|
||||
<el-select v-model="form.default_model" placeholder="选择默认模型" style="width: 100%" clearable>
|
||||
<el-option
|
||||
v-for="(m, i) in form.models"
|
||||
:key="i"
|
||||
:label="m.name ? `${m.name}(${m.id})` : m.id"
|
||||
:value="m.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="设为平台默认">
|
||||
<el-switch v-model="form.is_default" :active-value="1" :inactive-value="0" />
|
||||
<span class="form-tip" style="margin-left: 10px">Playground / OCR 未指定时优先使用</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="form.status" :active-value="1" :inactive-value="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" maxlength="255" placeholder="可选" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="emit('update:modelValue', false)">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
import { createAiProvider, updateAiProvider } from "@/api/aisettings";
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
/** null/undefined = 新增;有 id = 编辑 */
|
||||
item: { type: Object, default: null },
|
||||
presetApiType: { type: String, default: "chat_completions" },
|
||||
});
|
||||
const emit = defineEmits(["update:modelValue", "saved"]);
|
||||
|
||||
const API_TYPE_TIPS = {
|
||||
chat_completions: "OpenAI Chat Completions:POST {api}/chat/completions,Bearer 鉴权",
|
||||
responses: "OpenAI Responses:POST {api}/responses,Bearer 鉴权",
|
||||
anthropic: "Anthropic Messages:POST {api}/v1/messages,x-api-key + anthropic-version 鉴权",
|
||||
};
|
||||
const API_BASE_PLACEHOLDERS = {
|
||||
chat_completions: "https://api.deepseek.com",
|
||||
responses: "https://api.openai.com/v1",
|
||||
anthropic: "https://api.anthropic.com",
|
||||
};
|
||||
|
||||
const isEdit = computed(() => !!props.item?.id);
|
||||
const apiTypeTip = computed(() => API_TYPE_TIPS[form.api_type] || "");
|
||||
const apiBasePlaceholder = computed(() => API_BASE_PLACEHOLDERS[form.api_type] || "https://...");
|
||||
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
name: "",
|
||||
api_base: "",
|
||||
api_key: "",
|
||||
api_type: "chat_completions",
|
||||
models: [],
|
||||
default_model: "",
|
||||
is_default: 0,
|
||||
status: 1,
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const onOpen = () => {
|
||||
const t = props.item?.api_type || props.presetApiType || "chat_completions";
|
||||
Object.assign(form, {
|
||||
name: props.item?.name || "",
|
||||
api_base: props.item?.api_base || "",
|
||||
api_key: "",
|
||||
api_type: t,
|
||||
models: (props.item?.models || []).map((m) => ({ id: m.id || "", name: m.name || "" })),
|
||||
default_model: props.item?.default_model || "",
|
||||
is_default: props.item?.is_default || 0,
|
||||
status: props.item?.status ?? 1,
|
||||
remark: props.item?.remark || "",
|
||||
});
|
||||
if (!form.models.length) addModelRow();
|
||||
};
|
||||
|
||||
const addModelRow = () => {
|
||||
form.models.push({ id: "", name: "" });
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.name.trim()) return ElMessage.warning("请填写配置名称");
|
||||
if (!form.api_base.trim()) return ElMessage.warning("请填写 API 地址");
|
||||
if (!isEdit.value && !form.api_key.trim()) return ElMessage.warning("请填写 API 密钥");
|
||||
const models = form.models
|
||||
.map((m) => ({ id: (m.id || "").trim(), name: (m.name || "").trim() }))
|
||||
.filter((m) => m.id);
|
||||
if (!models.length) return ElMessage.warning("请至少添加一个模型 ID");
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const data = {
|
||||
api_type: form.api_type,
|
||||
name: form.name.trim(),
|
||||
api_base: form.api_base.trim().replace(/\/+$/, ""),
|
||||
api_key: form.api_key.trim(),
|
||||
models,
|
||||
default_model: form.default_model,
|
||||
is_default: form.is_default,
|
||||
status: form.status,
|
||||
remark: form.remark,
|
||||
};
|
||||
const res = isEdit.value
|
||||
? await updateAiProvider(props.item.id, data)
|
||||
: await createAiProvider({ ...data, provider_type: "custom" });
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success("保存成功");
|
||||
emit("update:modelValue", false);
|
||||
emit("saved", { id: res.data?.id || props.item?.id });
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "保存失败");
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.form-tip {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.models-table {
|
||||
width: 100%;
|
||||
|
||||
.add-model-btn {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div class="container-box aisettings">
|
||||
<div class="header-bar">
|
||||
<h2>AI 管理</h2>
|
||||
<el-button type="primary" plain @click="$router.push('/system/aisettings/playground')">
|
||||
打开 Playground
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 顶部引导 -->
|
||||
<div class="ai-guide">
|
||||
<div class="ai-guide__text">
|
||||
<h3>统一 AI 能力中台</h3>
|
||||
<p>
|
||||
左侧管理已接入的模型配置(OpenAI Chat Completions / OpenAI Responses / Anthropic Messages),
|
||||
点击「新增」或列表项进行配置;连通性测试与 Playground 对话验证已保存的模型。
|
||||
OCR 识别配置在右侧统一维护,租户端「OA - OCR 识别」将读取这里的配置。
|
||||
</p>
|
||||
</div>
|
||||
<div class="ai-guide__steps">
|
||||
<span class="step"><i>1</i>新增模型</span>
|
||||
<span class="step"><i>2</i>填写 API 信息</span>
|
||||
<span class="step"><i>3</i>连通性测试</span>
|
||||
<span class="step"><i>4</i>Playground 验证</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ai-layout">
|
||||
<!-- 左侧:已配置模型列表 -->
|
||||
<div class="ai-list">
|
||||
<div class="ai-list__header">
|
||||
<span class="ai-list__title">模型配置</span>
|
||||
<el-button type="primary" size="small" @click="openAdd">新增</el-button>
|
||||
</div>
|
||||
|
||||
<div class="ai-list__body" v-loading="listLoading">
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="ai-item"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<div class="ai-item__main">
|
||||
<div class="ai-item__name">
|
||||
{{ item.name }}
|
||||
<el-tag v-if="item.is_default === 1" type="success" size="small">默认</el-tag>
|
||||
</div>
|
||||
<div class="ai-item__meta">
|
||||
{{ apiTypeLabel(item.api_type) }} · {{ firstModel(item) }}
|
||||
</div>
|
||||
</div>
|
||||
<el-tag :type="item.status === 1 ? 'success' : 'info'" size="small" effect="plain">
|
||||
{{ item.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!listLoading && !list.length" description="暂无配置,点击新增创建" :image-size="60" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:OCR 识别配置 -->
|
||||
<div class="ai-detail">
|
||||
<ocr-settings />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增 / 编辑弹窗 -->
|
||||
<provider-dialog
|
||||
v-model="dialogVisible"
|
||||
:item="dialogItem"
|
||||
@saved="onSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from "vue";
|
||||
import { getAiProviderList } from "@/api/aisettings";
|
||||
import providerDialog from "./components/providerDialog.vue";
|
||||
import ocrSettings from "./components/ocrSettings.vue";
|
||||
|
||||
const listLoading = ref(false);
|
||||
const list = ref([]);
|
||||
const dialogVisible = ref(false);
|
||||
const dialogItem = ref(null);
|
||||
|
||||
const API_TYPE_LABELS = {
|
||||
chat_completions: "Chat Completions",
|
||||
responses: "Responses",
|
||||
anthropic: "Anthropic Messages",
|
||||
};
|
||||
const apiTypeLabel = (t) => API_TYPE_LABELS[t] || t || "Chat Completions";
|
||||
const firstModel = (item) => {
|
||||
const m = (item.models || [])[0];
|
||||
if (!m) return item.default_model || "未设置模型";
|
||||
return m.name ? `${m.name}(${m.id})` : m.id;
|
||||
};
|
||||
|
||||
const loadList = async () => {
|
||||
listLoading.value = true;
|
||||
try {
|
||||
const res = await getAiProviderList();
|
||||
if (res?.code === 200) list.value = res.data || [];
|
||||
} finally {
|
||||
listLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
dialogItem.value = null;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (item) => {
|
||||
dialogItem.value = item;
|
||||
dialogVisible.value = true;
|
||||
};
|
||||
|
||||
const onSaved = async () => {
|
||||
await loadList();
|
||||
};
|
||||
|
||||
onMounted(loadList);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.aisettings {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.ai-guide {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
padding: 20px 24px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(120deg, var(--el-color-primary-light-9), var(--el-fill-color-light));
|
||||
border: 1px solid var(--el-color-primary-light-7);
|
||||
|
||||
&__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
&__steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 100px;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
font-size: 12.5px;
|
||||
color: var(--el-text-color-regular);
|
||||
|
||||
i {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ai-layout {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.ai-list {
|
||||
width: 340px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 10px;
|
||||
background: var(--el-bg-color);
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
&__body {
|
||||
max-height: 560px;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&__main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.ai-detail {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,964 @@
|
||||
<template>
|
||||
<div class="pg-page">
|
||||
<!-- 左侧:会话列表 -->
|
||||
<div class="pg-sidebar">
|
||||
<div class="pg-sidebar__header">
|
||||
<span>对话列表</span>
|
||||
<el-button size="small" type="primary" plain @click="newChat">
|
||||
<el-icon><Plus /></el-icon> 新对话
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="pg-sidebar__list">
|
||||
<div
|
||||
v-for="s in sessions"
|
||||
:key="s.id"
|
||||
class="pg-session"
|
||||
:class="{ active: s.id === sessionId }"
|
||||
@click="selectSession(s)"
|
||||
>
|
||||
<el-icon v-if="s.is_top === 1" class="pg-session__pin"><Top /></el-icon>
|
||||
<el-input
|
||||
v-if="renamingId === s.id"
|
||||
v-model="renamingText"
|
||||
size="small"
|
||||
class="pg-session__input"
|
||||
@click.stop
|
||||
@keyup.enter="submitRename(s)"
|
||||
@keyup.esc="renamingId = null"
|
||||
@blur="submitRename(s)"
|
||||
/>
|
||||
<span v-else class="pg-session__title">{{ s.title || '新对话' }}</span>
|
||||
<el-icon class="pg-session__act" title="重命名" @click.stop="startRename(s)"><Edit /></el-icon>
|
||||
<el-icon class="pg-session__act" :title="s.is_top === 1 ? '取消置顶' : '置顶'" @click.stop="toggleTop(s)">
|
||||
<Top />
|
||||
</el-icon>
|
||||
<el-icon class="pg-session__del" title="删除" @click.stop="removeSession(s)"><Delete /></el-icon>
|
||||
</div>
|
||||
<div v-if="!sessions.length" class="pg-sidebar__empty">暂无对话记录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="pg-card">
|
||||
<template #header>
|
||||
<div class="pg-header">
|
||||
<span class="pg-title">Playground · 场景对话测试</span>
|
||||
<div class="pg-selects">
|
||||
<el-select v-model="providerId" placeholder="选择服务商配置" style="width: 220px" @change="onProviderChange">
|
||||
<el-option
|
||||
v-for="p in providers"
|
||||
:key="p.id"
|
||||
:label="`${p.name}(${apiTypeLabel(p.api_type)})`"
|
||||
:value="p.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select v-model="model" placeholder="选择模型" style="width: 220px" :disabled="!modelOptions.length">
|
||||
<el-option v-for="m in modelOptions" :key="m.id" :label="m.label" :value="m.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="pg-presets">
|
||||
<el-button
|
||||
v-for="s in scenes"
|
||||
:key="s.label"
|
||||
size="small"
|
||||
text
|
||||
@click="useScene(s)"
|
||||
>{{ s.label }}</el-button>
|
||||
</div>
|
||||
|
||||
<div class="pg-chat" ref="chatRef">
|
||||
<div v-if="!messages.length" class="pg-empty">
|
||||
选择服务商与模型后,输入场景问题开始测试对话效果
|
||||
</div>
|
||||
<div
|
||||
v-for="(m, i) in messages"
|
||||
:key="i"
|
||||
class="pg-msg"
|
||||
:class="m.role"
|
||||
>
|
||||
<div class="pg-msg__role">{{ m.role === 'user' ? '我' : 'AI' }}</div>
|
||||
<div class="pg-msg__main">
|
||||
<!-- 思考过程 -->
|
||||
<div v-if="m.think" class="pg-think">
|
||||
<button class="pg-think__toggle" @click="m._thinkOpen = !m._thinkOpen">
|
||||
<el-icon><CaretRight v-if="!m._thinkOpen" /><CaretBottom v-else /></el-icon>
|
||||
思考过程
|
||||
</button>
|
||||
<div v-show="m._thinkOpen" class="pg-think__body">{{ m.think }}</div>
|
||||
</div>
|
||||
<div class="pg-msg__bubble">
|
||||
<div v-if="m.attachments?.length" class="pg-attachments">
|
||||
<img
|
||||
v-for="(a, j) in m.attachments"
|
||||
:key="j"
|
||||
:src="a.dataUrl"
|
||||
:alt="a.name"
|
||||
class="pg-attachments__img"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="m.content">{{ m.content }}</span>
|
||||
<span v-else-if="m._streaming || m._pending" class="pg-caret"></span>
|
||||
</div>
|
||||
<div class="pg-msg__foot">
|
||||
<span class="pg-msg__tokens">
|
||||
≈{{ estimateTokens(m.content) }} tokens
|
||||
<template v-if="m._toksPerSec"> · {{ m._toksPerSec }} toks/s</template>
|
||||
</span>
|
||||
<span class="pg-msg__actions">
|
||||
<el-tooltip content="复制" placement="top">
|
||||
<el-button link size="small" @click="copyMsg(m)"><el-icon><CopyDocument /></el-icon></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="编辑" placement="top">
|
||||
<el-button link size="small" @click="editMsg(i, m)"><el-icon><Edit /></el-icon></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip v-if="m.role === 'user'" content="重新发送" placement="top">
|
||||
<el-button link size="small" @click="resendMsg(i)"><el-icon><RefreshRight /></el-icon></el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除" placement="top">
|
||||
<el-button link size="small" type="danger" @click="removeMsg(i)"><el-icon><Delete /></el-icon></el-button>
|
||||
</el-tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pg-input">
|
||||
<el-tooltip content="上传附件(图片/PDF,直传模型不存储)" placement="top">
|
||||
<el-button class="pg-attach-btn" @click="fileInputRef?.click()">
|
||||
<el-icon><Paperclip /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*,.pdf"
|
||||
multiple
|
||||
style="display: none"
|
||||
@change="handleFiles"
|
||||
/>
|
||||
<div class="pg-input__main">
|
||||
<div v-if="pendingFiles.length" class="pg-pending-files">
|
||||
<div v-for="(f, i) in pendingFiles" :key="i" class="pg-pending-file">
|
||||
<img v-if="f.preview" :src="f.preview" class="pg-pending-file__img" />
|
||||
<span class="pg-pending-file__name">{{ f.name }}</span>
|
||||
<el-icon class="pg-pending-file__del" @click="pendingFiles.splice(i, 1)"><Close /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="input"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
resize="none"
|
||||
placeholder="输入测试内容,Enter 发送,Ctrl+Enter 换行"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
/>
|
||||
</div>
|
||||
<el-button type="primary" :loading="sending" :disabled="!canSend" class="pg-send-btn" @click="send">发送</el-button>
|
||||
</div>
|
||||
|
||||
<div class="pg-total">本次会话 ≈ {{ totalTokens }} tokens</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { CopyDocument, Edit, RefreshRight, Delete, CaretRight, CaretBottom, Paperclip, Close, Plus, Top } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getAiProviderList,
|
||||
getAiChatSessions,
|
||||
createAiChatSession,
|
||||
getAiChatSessionMessages,
|
||||
deleteAiChatSession,
|
||||
updateAiChatSession,
|
||||
} from "@/api/aisettings";
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || "";
|
||||
|
||||
const providers = ref([]);
|
||||
const providerId = ref(null);
|
||||
const model = ref("");
|
||||
const messages = ref([]);
|
||||
const input = ref("");
|
||||
const sending = ref(false);
|
||||
const chatRef = ref(null);
|
||||
const fileInputRef = ref(null);
|
||||
const pendingFiles = ref([]); // {name, mime, dataUrl, preview} 直传模型,不走系统上传接口
|
||||
const sessions = ref([]);
|
||||
const sessionId = ref(null);
|
||||
const renamingId = ref(null);
|
||||
const renamingText = ref("");
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024;
|
||||
|
||||
const TYPE_LABELS = { deepseek: "DeepSeek", doubao: "豆包", codex: "Codex", custom: "自定义" };
|
||||
const API_TYPES = { chat_completions: "Chat", responses: "Resp", anthropic: "Anthropic" };
|
||||
const apiTypeLabel = (t) => TYPE_LABELS[t] || t;
|
||||
|
||||
const currentProvider = computed(() => providers.value.find((p) => p.id === providerId.value));
|
||||
const modelOptions = computed(() =>
|
||||
(currentProvider.value?.models || []).map((m) => ({
|
||||
id: m.id || m,
|
||||
label: m.name ? `${m.name}(${m.id})` : m.id || m,
|
||||
}))
|
||||
);
|
||||
const canSend = computed(
|
||||
() => providerId.value && model.value && (input.value.trim() || pendingFiles.value.length) && !sending.value
|
||||
);
|
||||
const totalTokens = computed(() =>
|
||||
messages.value.reduce((sum, m) => sum + estimateTokens(m.content), 0)
|
||||
);
|
||||
|
||||
const scenes = [
|
||||
{ label: "通用问答", prompt: "用三句话介绍一下你自己" },
|
||||
{ label: "代码生成", prompt: "用 Vue3 写一个防抖函数示例" },
|
||||
{ label: "文案翻译", prompt: "把「欢迎使用云泽科技」翻译成英文和日文" },
|
||||
{ label: "OCR 提示词", prompt: "请把图片中的文字完整识别出来,保持原有排版" },
|
||||
];
|
||||
|
||||
const loadProviders = async () => {
|
||||
const res = await getAiProviderList();
|
||||
if (res?.code === 200) {
|
||||
providers.value = (res.data || []).filter((p) => p.status === 1);
|
||||
const def = providers.value.find((p) => p.is_default === 1) || providers.value[0];
|
||||
if (def) {
|
||||
providerId.value = def.id;
|
||||
model.value = def.default_model || def.models?.[0]?.id || "";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onProviderChange = () => {
|
||||
const p = currentProvider.value;
|
||||
model.value = p?.default_model || p?.models?.[0]?.id || "";
|
||||
};
|
||||
|
||||
const loadSessions = async () => {
|
||||
const res = await getAiChatSessions();
|
||||
if (res?.code === 200) sessions.value = res.data || [];
|
||||
};
|
||||
|
||||
const newChat = () => {
|
||||
sessionId.value = null;
|
||||
messages.value = [];
|
||||
pendingFiles.value = [];
|
||||
input.value = "";
|
||||
};
|
||||
|
||||
/** 解析库中 user content:字符串或多模态数组 → {content, attachments} */
|
||||
const parseStoredUserContent = (raw) => {
|
||||
try {
|
||||
const v = JSON.parse(raw);
|
||||
if (typeof v === "string") return { content: v, attachments: [] };
|
||||
if (Array.isArray(v)) {
|
||||
let text = "";
|
||||
const attachments = [];
|
||||
for (const p of v) {
|
||||
if (p?.type === "text") text += p.text || "";
|
||||
if (p?.type === "image_url" && p.image_url?.url) {
|
||||
attachments.push({ name: "图片", dataUrl: p.image_url.url });
|
||||
}
|
||||
}
|
||||
return { content: text, attachments };
|
||||
}
|
||||
} catch {
|
||||
/* 兼容纯文本 */
|
||||
}
|
||||
return { content: raw || "", attachments: [] };
|
||||
};
|
||||
|
||||
const selectSession = async (s) => {
|
||||
sessionId.value = s.id;
|
||||
const res = await getAiChatSessionMessages(s.id);
|
||||
if (res?.code !== 200) return;
|
||||
messages.value = (res.data || []).map((m) => {
|
||||
if (m.role === "user") {
|
||||
const u = parseStoredUserContent(m.content);
|
||||
return { role: "user", content: u.content, attachments: u.attachments };
|
||||
}
|
||||
return { role: m.role, content: m.content || "", think: m.think || "", _thinkOpen: false };
|
||||
});
|
||||
await scrollBottom();
|
||||
};
|
||||
|
||||
const startRename = (s) => {
|
||||
renamingId.value = s.id;
|
||||
renamingText.value = s.title || "";
|
||||
};
|
||||
|
||||
const submitRename = async (s) => {
|
||||
if (renamingId.value !== s.id) return;
|
||||
renamingId.value = null;
|
||||
const title = renamingText.value.trim();
|
||||
if (!title || title === s.title) return;
|
||||
const res = await updateAiChatSession(s.id, { title });
|
||||
if (res?.code === 200) loadSessions();
|
||||
};
|
||||
|
||||
const toggleTop = async (s) => {
|
||||
const res = await updateAiChatSession(s.id, { is_top: s.is_top === 1 ? 0 : 1 });
|
||||
if (res?.code === 200) loadSessions();
|
||||
};
|
||||
|
||||
const removeSession = async (s) => {
|
||||
const res = await deleteAiChatSession(s.id);
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success("已删除");
|
||||
if (sessionId.value === s.id) newChat();
|
||||
loadSessions();
|
||||
}
|
||||
};
|
||||
|
||||
const useScene = (s) => {
|
||||
input.value = s.prompt;
|
||||
};
|
||||
|
||||
const scrollBottom = async () => {
|
||||
await nextTick();
|
||||
if (chatRef.value) chatRef.value.scrollTop = chatRef.value.scrollHeight;
|
||||
};
|
||||
|
||||
/** 简单 token 估算:CJK 字符按 1,ASCII 连续串按 4 字符 1 token */
|
||||
const estimateTokens = (text) => {
|
||||
if (!text) return 0;
|
||||
let cjk = 0;
|
||||
let ascii = 0;
|
||||
for (const ch of text) {
|
||||
if (/[⺀-鿿豈--]/.test(ch)) cjk++;
|
||||
else ascii++;
|
||||
}
|
||||
return cjk + Math.ceil(ascii / 4);
|
||||
};
|
||||
|
||||
const doStream = async (history, assistantMsg) => {
|
||||
const token = localStorage.getItem("token");
|
||||
const startedAt = Date.now();
|
||||
const resp = await fetch(`${API_BASE}/platform/ai/chat/stream`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
provider_id: providerId.value,
|
||||
model: model.value,
|
||||
messages: history,
|
||||
session_id: sessionId.value || 0,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok || !resp.body) {
|
||||
throw new Error(`请求失败(HTTP ${resp.status})`);
|
||||
}
|
||||
|
||||
// 客户端打字机队列:上游分片可能被代理缓冲成大块,这里按帧匀速吐出
|
||||
const pending = { text: "", think: "" };
|
||||
assistantMsg._pending = true;
|
||||
let finished = false;
|
||||
const typeTimer = setInterval(() => {
|
||||
let changed = false;
|
||||
if (pending.think) {
|
||||
const s = pending.think.slice(0, 2);
|
||||
pending.think = pending.think.slice(2);
|
||||
assistantMsg.think = (assistantMsg.think || "") + s;
|
||||
assistantMsg._thinkOpen = true;
|
||||
changed = true;
|
||||
}
|
||||
if (pending.text) {
|
||||
const s = pending.text.slice(0, 2);
|
||||
pending.text = pending.text.slice(2);
|
||||
assistantMsg.content += s;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) scrollBottom();
|
||||
if (finished && !pending.text && !pending.think) {
|
||||
assistantMsg._pending = false;
|
||||
clearInterval(typeTimer);
|
||||
}
|
||||
}, 16);
|
||||
|
||||
const finish = () => {
|
||||
finished = true;
|
||||
const secs = Math.max((Date.now() - startedAt) / 1000, 0.1);
|
||||
assistantMsg._toksPerSec = (estimateTokens(assistantMsg.content) / secs).toFixed(1);
|
||||
// 队列排空后由定时器自行清理并关闭 _pending;兜底 15s 强制清理
|
||||
setTimeout(() => {
|
||||
clearInterval(typeTimer);
|
||||
assistantMsg._pending = false;
|
||||
assistantMsg._streaming = false;
|
||||
}, 15000);
|
||||
};
|
||||
|
||||
const reader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const payload = line.slice(5).trim();
|
||||
if (!payload) continue;
|
||||
let ev;
|
||||
try {
|
||||
ev = JSON.parse(payload);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (ev.t === "delta") {
|
||||
pending.text += ev.c;
|
||||
} else if (ev.t === "think") {
|
||||
pending.think += ev.c;
|
||||
} else if (ev.t === "error") {
|
||||
pending.text += `\n\n[错误] ${ev.c}`;
|
||||
} else if (ev.t === "done") {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
finish();
|
||||
};
|
||||
|
||||
const fileToDataURL = (file) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
/** 图片压缩:最长边缩到 1600px,转 JPEG 0.85,显著减小 base64 体积 */
|
||||
const compressImage = (dataUrl) =>
|
||||
new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const max = 1600;
|
||||
let { width, height } = img;
|
||||
if (width <= max && height <= max) return resolve(dataUrl);
|
||||
const scale = max / Math.max(width, height);
|
||||
width = Math.round(width * scale);
|
||||
height = Math.round(height * scale);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
canvas.getContext("2d").drawImage(img, 0, 0, width, height);
|
||||
resolve(canvas.toDataURL("image/jpeg", 0.85));
|
||||
};
|
||||
img.onerror = () => resolve(dataUrl);
|
||||
img.src = dataUrl;
|
||||
});
|
||||
|
||||
const handleFiles = async (e) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
e.target.value = "";
|
||||
for (const f of files) {
|
||||
if (f.size > MAX_FILE_SIZE) {
|
||||
ElMessage.warning(`${f.name} 超过 10MB,已跳过`);
|
||||
continue;
|
||||
}
|
||||
let dataUrl = await fileToDataURL(f);
|
||||
const isImage = f.type?.startsWith("image/");
|
||||
if (isImage) dataUrl = await compressImage(dataUrl);
|
||||
// 上游网关一般限制请求体 ~4-10MB,base64 约膨胀 33%,超过 5MB 原始文件风险较高
|
||||
if (f.size > 5 * 1024 * 1024 && !isImage) {
|
||||
ElMessage.warning(`${f.name} 较大(${(f.size / 1024 / 1024).toFixed(1)}MB),上游可能拒绝,请尝试压缩后重试`);
|
||||
}
|
||||
pendingFiles.value.push({
|
||||
name: f.name,
|
||||
mime: isImage ? "image/jpeg" : f.type || "application/octet-stream",
|
||||
dataUrl,
|
||||
preview: isImage ? dataUrl : "",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/** 把消息转成 OpenAI 风格 content:有附件时为多模态内容块数组 */
|
||||
const buildApiContent = (m) => {
|
||||
const text = (m.content || "").trim();
|
||||
if (!m.attachments?.length) return text || " ";
|
||||
const parts = [];
|
||||
if (text) parts.push({ type: "text", text });
|
||||
for (const a of m.attachments) {
|
||||
parts.push({ type: "image_url", image_url: { url: a.dataUrl } });
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend.value) return;
|
||||
// 首次发送时创建会话
|
||||
if (!sessionId.value) {
|
||||
const res = await createAiChatSession({ provider_id: providerId.value, model: model.value });
|
||||
if (res?.code === 200 && res.data?.id) {
|
||||
sessionId.value = res.data.id;
|
||||
loadSessions();
|
||||
}
|
||||
}
|
||||
const text = input.value.trim();
|
||||
const files = [...pendingFiles.value];
|
||||
input.value = "";
|
||||
pendingFiles.value = [];
|
||||
messages.value.push({
|
||||
role: "user",
|
||||
content: text,
|
||||
attachments: files.map((f) => ({ name: f.name, dataUrl: f.dataUrl })),
|
||||
});
|
||||
await scrollBottom();
|
||||
|
||||
sending.value = true;
|
||||
messages.value.push({ role: "assistant", content: "", think: "", _streaming: true, _thinkOpen: false });
|
||||
// 必须拿响应式代理对象再改,直接改原始对象不会触发视图更新
|
||||
const assistantMsg = messages.value[messages.value.length - 1];
|
||||
const history = messages.value
|
||||
.filter((m) => !m._streaming && (m.content || m.attachments?.length))
|
||||
.map((m) => ({ role: m.role, content: buildApiContent(m) }));
|
||||
try {
|
||||
await doStream(history, assistantMsg);
|
||||
} catch (e) {
|
||||
assistantMsg.content += `\n\n[调用失败] ${e.message || e}`;
|
||||
assistantMsg._streaming = false;
|
||||
ElMessage.error("调用失败");
|
||||
} finally {
|
||||
sending.value = false;
|
||||
await scrollBottom();
|
||||
}
|
||||
};
|
||||
|
||||
const copyMsg = async (m) => {
|
||||
const text = m.think ? `【思考】\n${m.think}\n\n【回复】\n${m.content}` : m.content;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ElMessage.success("已复制");
|
||||
} catch {
|
||||
ElMessage.warning("复制失败");
|
||||
}
|
||||
};
|
||||
|
||||
const editMsg = (i, m) => {
|
||||
if (m.role !== "user") return;
|
||||
input.value = m.content;
|
||||
messages.value.splice(i, 1);
|
||||
};
|
||||
|
||||
const resendMsg = async (i) => {
|
||||
const m = messages.value[i];
|
||||
if (!m || m.role !== "user") return;
|
||||
input.value = m.content;
|
||||
messages.value.splice(i);
|
||||
await nextTick();
|
||||
send();
|
||||
};
|
||||
|
||||
const removeMsg = (i) => {
|
||||
messages.value.splice(i, 1);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadProviders();
|
||||
loadSessions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.pg-page {
|
||||
/* 顶部 = header 80px + right-main 上下 padding 40px + 多标签栏约 70px + 面板 padding 约 30px */
|
||||
height: calc(100vh - 220px);
|
||||
min-height: 480px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pg-sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 10px;
|
||||
background: var(--el-bg-color);
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--el-fill-color-lighter);
|
||||
}
|
||||
|
||||
&__list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
&__empty {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
}
|
||||
|
||||
.pg-session {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
color: var(--el-text-color-regular);
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
&__pin {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__act {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.pg-session:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__del {
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
.pg-session:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pg-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pg-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.pg-selects {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pg-presets {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.pg-chat {
|
||||
flex: 1;
|
||||
min-height: 280px;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pg-empty {
|
||||
margin: auto;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.pg-msg {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
|
||||
&__role {
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__bubble {
|
||||
max-width: 85%;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
background: var(--el-bg-color);
|
||||
color: var(--el-text-color-primary);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
&__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&__tokens {
|
||||
font-size: 11.5px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
.el-button {
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.user {
|
||||
flex-direction: row-reverse;
|
||||
|
||||
.pg-msg__main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.pg-msg__bubble {
|
||||
background: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary-light-7);
|
||||
}
|
||||
|
||||
.pg-msg__foot {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pg-think {
|
||||
max-width: 85%;
|
||||
margin-bottom: 6px;
|
||||
|
||||
&__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border: none;
|
||||
border-radius: 100px;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__body {
|
||||
margin-top: 6px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-left: 3px solid var(--el-color-warning);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.8;
|
||||
color: var(--el-text-color-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-caret {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 15px;
|
||||
background: var(--el-color-primary);
|
||||
vertical-align: text-bottom;
|
||||
animation: pgBlink 0.8s steps(2) infinite;
|
||||
}
|
||||
|
||||
@keyframes pgBlink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.pg-input {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
align-items: flex-start;
|
||||
|
||||
.pg-attach-btn {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
&__main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.el-textarea) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pg-send-btn {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-pending-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pg-pending-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px 2px 2px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-lighter);
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
max-width: 220px;
|
||||
|
||||
&__img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
&__name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__del {
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
color: var(--el-text-color-secondary);
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pg-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&__img {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
cursor: zoom-in;
|
||||
}
|
||||
}
|
||||
|
||||
.pg-total {
|
||||
margin-top: 8px;
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="login-settings" v-loading="loading">
|
||||
<el-form ref="formRef" :model="form" label-width="140px" class="settings-form">
|
||||
<el-card shadow="never" class="settings-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>登录方式开关</span>
|
||||
<span class="card-tip">
|
||||
控制统一认证中心登录页(/auth/login)展示的登录方式,保存后立即生效
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form-item label="账号密码登录">
|
||||
<el-switch v-model="form.login_pwd_enabled" active-value="1" inactive-value="0" />
|
||||
<div class="form-tip">账号 + 密码登录。关闭后登录页不再显示该 tab,接口侧同步拒绝。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="短信登录">
|
||||
<el-switch v-model="form.login_sms_enabled" active-value="1" inactive-value="0" />
|
||||
<div class="form-tip">手机号 + 短信验证码登录。关闭后登录页不再显示该 tab。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="扫码登录">
|
||||
<el-switch v-model="form.login_qr_enabled" active-value="1" inactive-value="0" />
|
||||
<div class="form-tip">微信扫码登录(云泽小程序内确认)。关闭后登录页不再显示该 tab。</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-alert
|
||||
v-if="allDisabled"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="当前已关闭全部登录方式,统一认证中心将提示「登录功能已关闭」,所有用户无法登录,请谨慎操作。"
|
||||
/>
|
||||
</el-card>
|
||||
</el-form>
|
||||
|
||||
<div class="footer-actions">
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存设置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { getLoginBrand, saveLoginBrand } from "@/api/sitesettings";
|
||||
|
||||
const formRef = ref();
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
login_pwd_enabled: "1",
|
||||
login_sms_enabled: "1",
|
||||
login_qr_enabled: "1",
|
||||
});
|
||||
|
||||
// 服务端快照,用于“重置”丢弃未保存的改动
|
||||
let snapshot = { ...form };
|
||||
|
||||
const allDisabled = computed(
|
||||
() =>
|
||||
form.login_pwd_enabled === "0" &&
|
||||
form.login_sms_enabled === "0" &&
|
||||
form.login_qr_enabled === "0"
|
||||
);
|
||||
|
||||
const loadConfig = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getLoginBrand();
|
||||
if (res?.code === 200 && res.data) {
|
||||
// 未配置的开关后端返回空串,按默认开启处理
|
||||
form.login_pwd_enabled = res.data.login_pwd_enabled === "0" ? "0" : "1";
|
||||
form.login_sms_enabled = res.data.login_sms_enabled === "0" ? "0" : "1";
|
||||
form.login_qr_enabled = res.data.login_qr_enabled === "0" ? "0" : "1";
|
||||
snapshot = { ...form };
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "获取登录方式配置失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.response?.data?.msg || "获取登录方式配置失败");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (submitting.value) return;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const res = await saveLoginBrand({
|
||||
login_pwd_enabled: form.login_pwd_enabled,
|
||||
login_sms_enabled: form.login_sms_enabled,
|
||||
login_qr_enabled: form.login_qr_enabled,
|
||||
});
|
||||
if (res?.code === 200) {
|
||||
ElMessage.success("保存成功,统一登录页已同步更新");
|
||||
snapshot = { ...form };
|
||||
} else {
|
||||
ElMessage.error(res?.msg || "保存失败");
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error?.response?.data?.msg || "保存失败");
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
Object.assign(form, snapshot);
|
||||
ElMessage.info("已恢复到上次保存的配置");
|
||||
};
|
||||
|
||||
onMounted(loadConfig);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.login-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card-tip {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -36,6 +36,11 @@
|
||||
<el-tab-pane label="登录页设置" name="loginBrand">
|
||||
<loginBrandSettings v-if="activeTab === 'loginBrand'" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 登录方式开关(账号密码 / 短信 / 扫码) -->
|
||||
<el-tab-pane label="登录方式" name="loginMethods">
|
||||
<loginSettings v-if="activeTab === 'loginMethods'" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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");
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
<template>
|
||||
<el-drawer v-model="visible" title="文章预览" size="60%">
|
||||
<div class="preview" v-loading="!model">
|
||||
<h1 class="preview__title">{{ model?.title || '-' }}</h1>
|
||||
<div class="preview__header">
|
||||
<el-image
|
||||
v-if="model?.image"
|
||||
:src="previewUrl(model.image)"
|
||||
:preview-src-list="model?.image ? [previewUrl(model.image)] : []"
|
||||
fit="cover"
|
||||
preview-teleported
|
||||
class="preview__cover"
|
||||
alt="封面"
|
||||
/>
|
||||
<div class="preview__heading">
|
||||
<h1 class="preview__title">{{ model?.title || '-' }}</h1>
|
||||
|
||||
<div class="preview__meta">
|
||||
<span v-if="model?.cate">分类:{{ model.cate }}</span>
|
||||
<span v-if="model?.author">作者:{{ model.author }}</span>
|
||||
<span>发布时间:{{ formatDateTime(model?.publish_time || model?.publish_date) }}</span>
|
||||
<span v-if="model?.views">浏览:{{ model.views }}</span>
|
||||
<div class="preview__meta">
|
||||
<span v-if="model?.cate">分类:{{ model.cate }}</span>
|
||||
<span v-if="model?.author">作者:{{ model.author }}</span>
|
||||
<span>发布时间:{{ formatDateTime(model?.publish_time || model?.publish_date) }}</span>
|
||||
<span v-if="model?.views">浏览:{{ model.views }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<p v-if="model?.desc" class="preview__desc">{{ model.desc }}</p>
|
||||
|
||||
<img v-if="model?.image" :src="previewUrl(model.image)" class="preview__cover" alt="封面" />
|
||||
|
||||
<div v-if="model?.is_trans === 1 && model?.transurl" class="preview__trans">
|
||||
本文为转载内容,原文地址:
|
||||
<el-link type="primary" :href="model.transurl" target="_blank">{{ model.transurl }}</el-link>
|
||||
@@ -67,11 +78,37 @@ const previewUrl = (url) => {
|
||||
.preview {
|
||||
padding: 0 8px 40px;
|
||||
|
||||
&__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
&__cover {
|
||||
flex: 0 0 200px;
|
||||
width: 200px;
|
||||
aspect-ratio: 25 / 14;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
cursor: zoom-in;
|
||||
|
||||
:deep(img) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&__heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
&__meta {
|
||||
@@ -92,14 +129,6 @@ const previewUrl = (url) => {
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
&__cover {
|
||||
width: 100%;
|
||||
max-height: 260px;
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__trans {
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
@@ -107,32 +136,134 @@ const previewUrl = (url) => {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/*
|
||||
* 文章正文排版:与官网详情页 .blk-html 结构一致(标题左侧色条、引用块、
|
||||
* 表格、代码块等),颜色使用主题变量,明暗模式下都能正常阅读。
|
||||
*/
|
||||
&__content {
|
||||
font-size: 15px;
|
||||
line-height: 1.9;
|
||||
color: var(--el-text-color-primary);
|
||||
padding: 8px 4px 20px;
|
||||
font-size: 15.5px;
|
||||
line-height: 2.05;
|
||||
color: var(--el-text-color-regular);
|
||||
word-break: break-word;
|
||||
|
||||
:deep(> :first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
:deep(h1),
|
||||
:deep(h2),
|
||||
:deep(h3),
|
||||
:deep(h4) {
|
||||
margin: 34px 0 14px;
|
||||
padding-left: 16px;
|
||||
border-left: 3px solid #2f6bf3;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
:deep(h1) { font-size: 24px; }
|
||||
:deep(h2) { font-size: 22px; }
|
||||
:deep(h3) { font-size: 18px; }
|
||||
:deep(h4) { font-size: 16px; }
|
||||
|
||||
:deep(p) {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
:deep(a) {
|
||||
color: #2f6bf3;
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.82;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(strong) {
|
||||
font-weight: 700;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
margin: 16px 0;
|
||||
padding-left: 26px;
|
||||
|
||||
li {
|
||||
margin: 8px 0;
|
||||
line-height: 1.95;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(ul li) { list-style: disc; }
|
||||
:deep(ol li) { list-style: decimal; }
|
||||
|
||||
:deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
height: auto;
|
||||
margin: 18px 0;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
:deep(blockquote) {
|
||||
margin: 22px 0;
|
||||
padding: 22px 26px;
|
||||
border-radius: 12px;
|
||||
border-left: 3px solid #2f6bf3;
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
:deep(hr) {
|
||||
margin: 30px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
:deep(pre) {
|
||||
padding: 12px 16px;
|
||||
margin: 18px 0;
|
||||
padding: 18px 22px;
|
||||
border-radius: 10px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
:deep(code) {
|
||||
padding: 2px 7px;
|
||||
border-radius: 4px;
|
||||
background: var(--el-fill-color-light);
|
||||
font-family: Consolas, Monaco, "Courier New", monospace;
|
||||
font-size: 13.5px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
:deep(pre code) {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:deep(table) {
|
||||
width: 100%;
|
||||
margin: 20px 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--el-border-color);
|
||||
padding: 8px 12px;
|
||||
padding: 13px 18px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
text-align: left;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
th {
|
||||
background: var(--el-fill-color-light);
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ const glyph = computed(() => props.item.title.slice(0, 1));
|
||||
<span class="content-card__deco" aria-hidden="true"></span>
|
||||
<span class="content-card__glyph" aria-hidden="true">{{ glyph }}</span>
|
||||
</template>
|
||||
<span class="content-card__badge">{{ categoryLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="content-card__body">
|
||||
@@ -53,15 +52,18 @@ const glyph = computed(() => props.item.title.slice(0, 1));
|
||||
<p class="content-card__summary">{{ item.summary }}</p>
|
||||
|
||||
<div class="content-card__foot">
|
||||
<span v-if="item.date" class="content-card__date">
|
||||
<AppIcon name="clock" :size="14" />
|
||||
{{ item.date }}
|
||||
</span>
|
||||
<span v-else class="content-card__tags">
|
||||
<em v-for="tag in (item.tags ?? []).slice(0, 2)" :key="tag">
|
||||
{{ tag }}
|
||||
</em>
|
||||
</span>
|
||||
<div class="content-card__foot-left">
|
||||
<span class="content-card__badge">{{ categoryLabel }}</span>
|
||||
<span v-if="item.date" class="content-card__date">
|
||||
<AppIcon name="clock" :size="14" />
|
||||
{{ item.date }}
|
||||
</span>
|
||||
<span v-else class="content-card__tags">
|
||||
<em v-for="tag in (item.tags ?? []).slice(0, 2)" :key="tag">
|
||||
{{ tag }}
|
||||
</em>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span class="content-card__more">
|
||||
查看详情
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 登录 / 注册:跳转统一认证中心 -->
|
||||
<div class="header-auth">
|
||||
<a class="auth-link auth-link--login" :href="authLoginUrl">登录</a>
|
||||
<a class="auth-link auth-link--register" :href="authRegisterUrl">试用</a>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="menu-toggle"
|
||||
type="button"
|
||||
@@ -181,6 +192,8 @@ const onHomeClick = () => {
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
<a :href="authLoginUrl">登录</a>
|
||||
<a :href="authRegisterUrl">试用</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user